feat: 添加平台运行时信息和多租户支持

- 在 `auth.ts` 和 `platform.ts` 中添加获取平台运行时信息的 API
- 在 `AppLayout.tsx` 和 `login/index.tsx` 中处理平台运行时信息,并更新用户界面
- 在 `DeviceInfoMapper.java` 中添加忽略租户的更新方法
- 更新日志清理和模块获取的 API 以支持多租户
- 在 `tenants/index.tsx` 和 `logs/index.tsx` 中添加多租户相关的逻辑和 UI 改进
dev_na
chenhao 2026-05-07 09:46:22 +08:00
parent c0e973e5a9
commit eba6bf105e
12 changed files with 254 additions and 134 deletions

View File

@ -7,6 +7,7 @@ import com.imeeting.entity.biz.DeviceInfoEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List; import java.util.List;
@ -24,6 +25,33 @@ public interface DeviceInfoMapper extends BaseMapper<DeviceInfoEntity> {
""") """)
DeviceInfoEntity selectByDeviceCodeIgnoreTenant(@Param("deviceCode") String deviceCode); DeviceInfoEntity selectByDeviceCodeIgnoreTenant(@Param("deviceCode") String deviceCode);
@InterceptorIgnore(tenantLine = "true")
@Update("""
<script>
UPDATE biz_device_info
SET tenant_id = #{tenantId},
user_id = #{userId},
terminal_type = #{terminalType},
terminal_version = #{terminalVersion},
last_online_at = #{lastOnlineAt},
updated_at = CURRENT_TIMESTAMP
WHERE device_id = #{deviceId}
AND is_deleted = 0
</script>
""")
int updateConnectionInfoByIdIgnoreTenant(DeviceInfoEntity deviceInfoEntity);
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE biz_device_info
SET last_online_at = #{lastOnlineAt},
updated_at = CURRENT_TIMESTAMP
WHERE device_id = #{deviceId}
AND is_deleted = 0
""")
int updateLastOnlineAtByIdIgnoreTenant(@Param("deviceId") Long deviceId,
@Param("lastOnlineAt") java.time.LocalDateTime lastOnlineAt);
@InterceptorIgnore(tenantLine = "true") @InterceptorIgnore(tenantLine = "true")
@Select(""" @Select("""
SELECT * SELECT *

View File

@ -52,13 +52,9 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
existing.setTerminalType(normalizeTerminalType(authContext.getPlatform())); existing.setTerminalType(normalizeTerminalType(authContext.getPlatform()));
existing.setTerminalVersion(normalize(authContext.getAppVersion())); existing.setTerminalVersion(normalize(authContext.getAppVersion()));
existing.setLastOnlineAt(now); existing.setLastOnlineAt(now);
if (existing.getUserId() == null && authContext.getUserId() != null) { existing.setUserId(authContext.getUserId());
existing.setUserId(authContext.getUserId()); existing.setTenantId(authContext.getTenantId());
} deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing);
if (existing.getTenantId() == null && authContext.getTenantId() != null) {
existing.setTenantId(authContext.getTenantId());
}
deviceInfoMapper.updateById(existing);
} }
@Override @Override
@ -71,7 +67,7 @@ public class DeviceOnlineManagementServiceImpl implements DeviceOnlineManagement
return; return;
} }
existing.setLastOnlineAt(toLocalDateTime(lastSeenAtMillis)); existing.setLastOnlineAt(toLocalDateTime(lastSeenAtMillis));
deviceInfoMapper.updateById(existing); deviceInfoMapper.updateLastOnlineAtByIdIgnoreTenant(existing.getDeviceId(), existing.getLastOnlineAt());
} }
@Override @Override

View File

@ -17,6 +17,9 @@ export interface TokenResponse {
accessExpiresInMinutes: number; accessExpiresInMinutes: number;
refreshExpiresInDays: number; refreshExpiresInDays: number;
availableTenants?: TenantInfo[]; availableTenants?: TenantInfo[];
tenantMode?: "single" | "multi";
multiTenantEnabled?: boolean;
currentTenantId?: number;
} }
export interface LoginPayload { export interface LoginPayload {

View File

@ -218,15 +218,17 @@ export async function fetchLogs(params: any) {
const resp = await http.get("/sys/api/logs", { params }); const resp = await http.get("/sys/api/logs", { params });
return resp.data.data; return resp.data.data;
} }
export async function cleanLogs(logType: string) {
const resp = await http.delete("/sys/api/logs/clean", { params: { logType } }); export async function fetchLogModules(params?: { tenantId?: number }) {
return resp.data.data as boolean; const resp = await http.get("/sys/api/logs/modules", { params });
}
export async function fetchLogModules() {
const resp = await http.get("/sys/api/logs/modules");
return resp.data.data as string[]; return resp.data.data as string[];
} }
export async function cleanLogs(logType: string, tenantId?: number) {
const resp = await http.delete("/sys/api/logs/clean", { params: { logType, tenantId } });
return resp.data.data as boolean;
}
export * from "./dict"; export * from "./dict";
export * from "./tenant"; export * from "./tenant";
export * from "./org"; export * from "./org";

View File

@ -1,5 +1,18 @@
import http from "./http"; import http from "./http";
import { SysPlatformConfig } from "../types"; import { PlatformRuntime, SysPlatformConfig } from "../types";
const ALLOWED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/bmp"]);
const ALLOWED_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".bmp"];
function validatePlatformImage(file: File) {
const lowerName = file.name.toLowerCase();
const extensionAllowed = ALLOWED_IMAGE_EXTENSIONS.some((extension) => lowerName.endsWith(extension));
const typeAllowed = ALLOWED_IMAGE_TYPES.has(file.type);
if (!extensionAllowed || !typeAllowed) {
throw new Error("Only PNG, JPG, GIF, or BMP images are allowed");
}
}
/** /**
* *
@ -17,6 +30,11 @@ export async function getAdminPlatformConfig() {
return resp.data.data as SysPlatformConfig; return resp.data.data as SysPlatformConfig;
} }
export async function getPlatformRuntime() {
const resp = await http.get("/sys/api/platform/runtime");
return resp.data.data as PlatformRuntime;
}
/** /**
* *
*/ */
@ -30,6 +48,7 @@ export async function updatePlatformConfig(payload: SysPlatformConfig) {
* @param file * @param file
*/ */
export async function uploadPlatformAsset(file: File) { export async function uploadPlatformAsset(file: File) {
validatePlatformImage(file);
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const resp = await http.post("/sys/api/admin/platform/config/upload", formData, { const resp = await http.post("/sys/api/admin/platform/config/upload", formData, {

View File

@ -17,15 +17,15 @@ import {
UserOutlined, UserOutlined,
VideoCameraOutlined VideoCameraOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Avatar, Button, Dropdown, Layout, Menu, Space, type MenuProps, App } from 'antd'; import { Avatar, Button, Dropdown, Layout, Menu, Space, message, type MenuProps } from "antd";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getCurrentUser, listMyPermissions } from "@/api"; import { getCurrentUser, getPlatformRuntime, listMyPermissions } from "@/api";
import { switchTenant, type TenantInfo } from "@/api/auth"; import { switchTenant, type TenantInfo } from "@/api/auth";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { usePermission } from "@/hooks/usePermission"; import { usePermission } from "@/hooks/usePermission";
import type { SysPermission, SysPlatformConfig } from "@/types"; import type { PlatformRuntime, SysPermission, SysPlatformConfig } from "@/types";
import ThemeSelector from "@/components/ThemeSelector/ThemeSelector"; import ThemeSelector from "@/components/ThemeSelector/ThemeSelector";
import { useThemeStore } from "@/store/themeStore"; import { useThemeStore } from "@/store/themeStore";
@ -55,22 +55,42 @@ function resolveMenuIcon(icon?: string): ReactNode {
type PermissionMenuNode = SysPermission & { type PermissionMenuNode = SysPermission & {
children?: PermissionMenuNode[]; children?: PermissionMenuNode[];
}; };
type CachedUserProfile = { displayName?: string; username?: string; avatarUrl?: string }; type CachedUserProfile = { displayName?: string; username?: string; avatarUrl?: string };
function getAvatarUrl(profile?: CachedUserProfile | null) { function getAvatarUrl(profile?: CachedUserProfile | null) {
return profile?.avatarUrl?.trim() || ""; return profile?.avatarUrl?.trim() || "";
} }
function getDisplayName(profile: CachedUserProfile | null | undefined, fallbackLabel: string) {
return profile?.displayName || profile?.username || localStorage.getItem("displayName") || localStorage.getItem("username") || fallbackLabel;
}
export default function AppLayout() { export default function AppLayout() {
const { message } = App.useApp();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [menus, setMenus] = useState<SysPermission[]>([]); const [menus, setMenus] = useState<SysPermission[]>([]);
const [availableTenants, setAvailableTenants] = useState<TenantInfo[]>([]); const [availableTenants, setAvailableTenants] = useState<TenantInfo[]>([]);
const [currentTenantId, setCurrentTenantId] = useState<number | null>(null); const [currentTenantId, setCurrentTenantId] = useState<number | null>(null);
const [platformRuntime, setPlatformRuntime] = useState<PlatformRuntime | null>(() => {
const runtimeStr = sessionStorage.getItem("platformRuntime");
return runtimeStr ? JSON.parse(runtimeStr) : null;
});
const [currentUserLabel, setCurrentUserLabel] = useState<string>(() => {
try {
const profileStr = sessionStorage.getItem("userProfile");
if (profileStr) {
const profile = JSON.parse(profileStr) as CachedUserProfile;
return profile.displayName || profile.username || localStorage.getItem("username") || "";
}
} catch {
}
return localStorage.getItem("displayName") || localStorage.getItem("username") || "";
});
const [currentUserAvatarUrl, setCurrentUserAvatarUrl] = useState<string>(() => {
try {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? getAvatarUrl(JSON.parse(profileStr) as CachedUserProfile) : "";
} catch {
return "";
}
});
const [openKeys, setOpenKeys] = useState<string[]>([]); const [openKeys, setOpenKeys] = useState<string[]>([]);
const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(() => { const [platformConfig, setPlatformConfig] = useState<SysPlatformConfig | null>(() => {
const configStr = sessionStorage.getItem("platformConfig"); const configStr = sessionStorage.getItem("platformConfig");
@ -83,27 +103,6 @@ export default function AppLayout() {
const { load: loadPermissions, can } = usePermission(); const { load: loadPermissions, can } = usePermission();
const { layoutMode } = useThemeStore(); const { layoutMode } = useThemeStore();
const [currentUserDisplayName, setCurrentUserDisplayName] = useState(() => {
try {
const profileStr = sessionStorage.getItem("userProfile");
if (profileStr) {
const profile = JSON.parse(profileStr) as CachedUserProfile;
return getDisplayName(profile, t("layout.admin"));
}
} catch {
// Ignore invalid cached profile and continue with storage fallback.
}
return getDisplayName(null, t("layout.admin"));
});
const [currentUserAvatarUrl, setCurrentUserAvatarUrl] = useState<string>(() => {
try {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? getAvatarUrl(JSON.parse(profileStr) as CachedUserProfile) : "";
} catch {
return "";
}
});
const fetchInitialData = useCallback(async () => { const fetchInitialData = useCallback(async () => {
try { try {
const storedTenants = localStorage.getItem("availableTenants"); const storedTenants = localStorage.getItem("availableTenants");
@ -116,11 +115,42 @@ export default function AppLayout() {
setCurrentTenantId(Number(activeTenantId)); setCurrentTenantId(Number(activeTenantId));
} }
let runtime: PlatformRuntime | null = null;
try {
runtime = await getPlatformRuntime();
sessionStorage.setItem("platformRuntime", JSON.stringify(runtime));
setPlatformRuntime(runtime);
if (runtime.currentTenantId) {
setCurrentTenantId(runtime.currentTenantId);
localStorage.setItem("activeTenantId", String(runtime.currentTenantId));
}
} catch {
sessionStorage.removeItem("platformRuntime");
setPlatformRuntime(null);
}
try {
const profile = await getCurrentUser();
sessionStorage.setItem("userProfile", JSON.stringify(profile));
if (profile.username) {
localStorage.setItem("username", profile.username);
}
if (profile.displayName) {
localStorage.setItem("displayName", profile.displayName);
}
setCurrentUserLabel(profile.displayName || profile.username || "");
setCurrentUserAvatarUrl(getAvatarUrl(profile));
} catch {
const cached = localStorage.getItem("displayName") || localStorage.getItem("username") || "";
setCurrentUserLabel(cached);
}
const data = await listMyPermissions(); const data = await listMyPermissions();
await loadPermissions(); await loadPermissions();
const filtered = data const filtered = data
.filter((item) => (item.permType === "menu" || item.permType === "directory") && item.isVisible === 1 && item.status === 1) .filter((item) => (item.permType === "menu" || item.permType === "directory") && item.isVisible === 1 && item.status === 1)
.filter((item) => runtime?.tenantMode === "single" ? item.code !== "menu:tenant" && item.code !== "menu:tenants" && item.path !== "/tenants" : true)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
setMenus(filtered); setMenus(filtered);
} catch { } catch {
@ -131,30 +161,23 @@ export default function AppLayout() {
useEffect(() => { useEffect(() => {
fetchInitialData(); fetchInitialData();
}, [fetchInitialData]); }, [fetchInitialData]);
useEffect(() => { useEffect(() => {
const syncUserProfile = () => { const syncUserProfile = () => {
try { try {
const profileStr = sessionStorage.getItem("userProfile"); const profileStr = sessionStorage.getItem("userProfile");
if (!profileStr) { if (!profileStr) return;
setCurrentUserDisplayName(getDisplayName(null, t("layout.admin")));
setCurrentUserAvatarUrl("");
return;
}
const profile = JSON.parse(profileStr) as CachedUserProfile; const profile = JSON.parse(profileStr) as CachedUserProfile;
localStorage.setItem("displayName", profile.displayName || profile.username || ""); setCurrentUserLabel(profile.displayName || profile.username || localStorage.getItem("username") || "");
localStorage.setItem("username", profile.username || localStorage.getItem("username") || "");
setCurrentUserDisplayName(getDisplayName(profile, t("layout.admin")));
setCurrentUserAvatarUrl(getAvatarUrl(profile)); setCurrentUserAvatarUrl(getAvatarUrl(profile));
} catch { } catch {
setCurrentUserDisplayName(getDisplayName(null, t("layout.admin")));
setCurrentUserAvatarUrl("");
} }
}; };
syncUserProfile();
window.addEventListener("user-profile-updated", syncUserProfile); window.addEventListener("user-profile-updated", syncUserProfile);
return () => window.removeEventListener("user-profile-updated", syncUserProfile); return () => window.removeEventListener("user-profile-updated", syncUserProfile);
}, [t]); }, []);
useEffect(() => { useEffect(() => {
const syncPlatformConfig = () => { const syncPlatformConfig = () => {
const configStr = sessionStorage.getItem("platformConfig"); const configStr = sessionStorage.getItem("platformConfig");
@ -177,9 +200,14 @@ export default function AppLayout() {
const profile = await getCurrentUser(); const profile = await getCurrentUser();
sessionStorage.setItem("userProfile", JSON.stringify(profile)); sessionStorage.setItem("userProfile", JSON.stringify(profile));
localStorage.setItem("displayName", profile.displayName || profile.username || ""); if (profile.username) {
localStorage.setItem("username", profile.username || localStorage.getItem("username") || ""); localStorage.setItem("username", profile.username);
window.dispatchEvent(new Event("user-profile-updated")); }
if (profile.displayName) {
localStorage.setItem("displayName", profile.displayName);
}
setCurrentUserLabel(profile.displayName || profile.username || "");
setCurrentUserAvatarUrl(getAvatarUrl(profile));
message.success(t("common.success")); message.success(t("common.success"));
window.location.reload(); window.location.reload();
@ -321,7 +349,7 @@ export default function AppLayout() {
<Dropdown menu={{ items: langMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: langMenuItems }} placement="bottomRight">
<GlobalOutlined style={{ fontSize: "18px", color: "var(--app-text-main)", cursor: "pointer" }} /> <GlobalOutlined style={{ fontSize: "18px", color: "var(--app-text-main)", cursor: "pointer" }} />
</Dropdown> </Dropdown>
{availableTenants.length > 0 && ( {platformRuntime?.tenantMode !== "single" && availableTenants.length > 0 && (
<Dropdown <Dropdown
menu={{ menu={{
items: availableTenants.map((tenant) => ({ items: availableTenants.map((tenant) => ({
@ -339,7 +367,7 @@ export default function AppLayout() {
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}> <Space style={{ cursor: "pointer", color: "var(--app-text-main)" }}>
<Avatar size="small" src={currentUserAvatarUrl || undefined} icon={currentUserAvatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} /> <Avatar size="small" src={currentUserAvatarUrl || undefined} icon={currentUserAvatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "var(--app-primary-color)" }} />
<span style={{ fontWeight: 500 }}>{currentUserDisplayName}</span> <span style={{ fontWeight: 500 }}>{currentUserLabel || t("layout.admin")}</span>
</Space> </Space>
</Dropdown> </Dropdown>
</Space> </Space>
@ -357,33 +385,22 @@ export default function AppLayout() {
flexShrink: 0 flexShrink: 0
}} }}
> >
<Link <img src={platformConfig?.logoUrl || "/logo.svg"} alt="logo" style={{ width: 32, height: 32, objectFit: "contain" }} />
to="/" {(!collapsed || isTop) && (
style={{ <span
display: "inline-flex", style={{
alignItems: "center", fontSize: "18px",
gap: "12px", fontWeight: 700,
textDecoration: "none", color: "var(--app-primary-color)",
minWidth: 0 letterSpacing: "0.5px",
}} overflow: "hidden",
> textOverflow: "ellipsis",
<img src={platformConfig?.logoUrl || "/logo.svg"} alt="logo" style={{ width: 32, height: 32, objectFit: "contain" }} /> whiteSpace: "nowrap"
{(!collapsed || isTop) && ( }}
<span >
style={{ {platformConfig?.projectName || "UnisBase"}
fontSize: "18px", </span>
fontWeight: 700, )}
color: "var(--app-primary-color)",
letterSpacing: "0.5px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{platformConfig?.projectName || "UnisBase"}
</span>
)}
</Link>
</div> </div>
); );
@ -411,7 +428,7 @@ export default function AppLayout() {
</Sider> </Sider>
)} )}
<Layout style={{ flex: 1, minWidth: 0, minHeight: 0 }}> <Layout style={{ flex: 1, minWidth: 0 }}>
<Header <Header
style={{ style={{
background: "var(--app-bg-card)", background: "var(--app-bg-card)",
@ -461,10 +478,8 @@ export default function AppLayout() {
<Content <Content
style={{ style={{
flex: 1,
minHeight: 0,
margin: "24px 24px 12px", margin: "24px 24px 12px",
padding: location.pathname === "/" || location.pathname === "/home" ? 0 : 24, padding: 24,
background: "var(--app-bg-card)", background: "var(--app-bg-card)",
borderRadius: "8px", borderRadius: "8px",
boxShadow: "var(--app-shadow)", boxShadow: "var(--app-shadow)",
@ -481,24 +496,29 @@ export default function AppLayout() {
<Footer <Footer
style={{ style={{
flexShrink: 0, flexShrink: 0,
padding: "16px 24px", padding: "0 24px 16px",
background: "var(--app-bg-card)", background: "transparent"
borderTop: "1px solid var(--app-border-color)"
}} }}
> >
<div <div
style={{ style={{
minHeight: 44,
borderRadius: 8,
border: "1px solid var(--app-border-color)",
background: "var(--app-bg-card)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
gap: 12, gap: 16,
color: "var(--app-text-secondary)", padding: "10px 16px",
color: "var(--app-text-main)",
fontSize: 13, fontSize: 13,
textAlign: "center" textAlign: "center",
backdropFilter: "blur(10px)"
}} }}
> >
{platformConfig?.icpInfo ? <span>{platformConfig.icpInfo}</span> : null} {platformConfig?.icpInfo ? <span>{platformConfig.icpInfo}</span> : null}
{platformConfig?.icpInfo && platformConfig?.copyrightInfo ? <span aria-hidden="true" style={{ color: "var(--app-border-color)" }}>|</span> : null} {platformConfig?.icpInfo && platformConfig?.copyrightInfo ? <span aria-hidden="true" style={{ color: "#d0d5dd" }}>|</span> : null}
{platformConfig?.copyrightInfo ? <span>{platformConfig.copyrightInfo}</span> : null} {platformConfig?.copyrightInfo ? <span>{platformConfig.copyrightInfo}</span> : null}
</div> </div>
</Footer> </Footer>
@ -506,4 +526,3 @@ export default function AppLayout() {
</Layout> </Layout>
); );
} }

View File

@ -317,6 +317,9 @@
"cleanCurrent": "Clear {{type}}", "cleanCurrent": "Clear {{type}}",
"cleanConfirmTitle": "Clear {{type}}?", "cleanConfirmTitle": "Clear {{type}}?",
"cleanConfirmDescription": "This will remove all records of the current log type and ignores the active filters.", "cleanConfirmDescription": "This will remove all records of the current log type and ignores the active filters.",
"cleanConfirmDescriptionWithTenant": "This will remove all records of the current log type under {{tenant}}.",
"allTenants": "All tenants",
"tenantId": "Tenant {{id}}",
"cleanSuccess": "{{type}} cleared" "cleanSuccess": "{{type}} cleared"
}, },
"devicesExt": { "devicesExt": {

View File

@ -317,6 +317,9 @@
"cleanCurrent": "清空{{type}}", "cleanCurrent": "清空{{type}}",
"cleanConfirmTitle": "确认清空{{type}}", "cleanConfirmTitle": "确认清空{{type}}",
"cleanConfirmDescription": "将清空当前日志类型的全部记录,不受当前筛选条件影响。", "cleanConfirmDescription": "将清空当前日志类型的全部记录,不受当前筛选条件影响。",
"cleanConfirmDescriptionWithTenant": "将清空{{tenant}}下当前日志类型的全部记录。",
"allTenants": "全部租户",
"tenantId": "租户 {{id}}",
"cleanSuccess": "{{type}}已清空" "cleanSuccess": "{{type}}已清空"
}, },
"devicesExt": { "devicesExt": {

View File

@ -82,6 +82,13 @@ export default function Login() {
const payload = JSON.parse(atob(data.accessToken.split(".")[1])); const payload = JSON.parse(atob(data.accessToken.split(".")[1]));
localStorage.setItem("activeTenantId", String(payload.tenantId)); localStorage.setItem("activeTenantId", String(payload.tenantId));
} }
if (data.tenantMode) {
sessionStorage.setItem("platformRuntime", JSON.stringify({
tenantMode: data.tenantMode,
multiTenantEnabled: data.multiTenantEnabled !== false,
currentTenantId: data.currentTenantId
}));
}
try { try {
const profile = await getCurrentUser(); const profile = await getCurrentUser();
@ -106,26 +113,26 @@ export default function Login() {
const loginStyle = platformConfig?.loginBgUrl const loginStyle = platformConfig?.loginBgUrl
? { ? {
backgroundImage: `url(${platformConfig.loginBgUrl})`, backgroundImage: `url(${platformConfig.loginBgUrl})`,
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
position: "relative" as const position: "relative" as const
} }
: {}; : {};
const leftStyle = platformConfig?.loginBgUrl const leftStyle = platformConfig?.loginBgUrl
? { ? {
...loginStyle, ...loginStyle,
background: "rgba(255, 255, 255, 0.2)", background: "rgba(255, 255, 255, 0.2)",
backdropFilter: "blur(10px)" backdropFilter: "blur(10px)"
} }
: {}; : {};
const rightStyle = platformConfig?.loginBgUrl const rightStyle = platformConfig?.loginBgUrl
? { ? {
background: "rgba(255, 255, 255, 0.85)", background: "rgba(255, 255, 255, 0.85)",
backdropFilter: "blur(20px)" backdropFilter: "blur(20px)"
} }
: {}; : {};
return ( return (

View File

@ -3,13 +3,13 @@ import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons"; import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { createTenant, deleteTenant, listTenants, updateTenant } from "@/api"; import { createTenant, deleteTenant, getPlatformRuntime, listTenants, updateTenant } from "@/api";
import { useDict } from "@/hooks/useDict"; import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission"; import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader"; import PageHeader from "@/components/shared/PageHeader";
import { getStandardPagination } from "@/utils/pagination"; import { getStandardPagination } from "@/utils/pagination";
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName"; import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import type { SysTenant } from "@/types"; import type { PlatformRuntime, SysTenant } from "@/types";
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
@ -24,6 +24,7 @@ export default function Tenants() {
const [queryParams, setQueryParams] = useState({ current: 1, size: 12, name: "", code: "" }); const [queryParams, setQueryParams] = useState({ current: 1, size: 12, name: "", code: "" });
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysTenant | null>(null); const [editing, setEditing] = useState<SysTenant | null>(null);
const [runtime, setRuntime] = useState<PlatformRuntime | null>(null);
const [adminAccountTouched, setAdminAccountTouched] = useState(false); const [adminAccountTouched, setAdminAccountTouched] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [searchForm] = Form.useForm(); const [searchForm] = Form.useForm();
@ -44,6 +45,10 @@ export default function Tenants() {
loadData(); loadData();
}, [loadData, queryParams.current, queryParams.size]); }, [loadData, queryParams.current, queryParams.size]);
useEffect(() => {
getPlatformRuntime().then(setRuntime).catch(() => setRuntime(null));
}, []);
const handleSearch = (values: any) => { const handleSearch = (values: any) => {
setQueryParams((prev) => ({ ...prev, ...values, current: 1 })); setQueryParams((prev) => ({ ...prev, ...values, current: 1 }));
}; };
@ -123,7 +128,7 @@ export default function Tenants() {
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column' } }} styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column' } }}
actions={[ actions={[
can("sys_tenant:update") && <Tooltip title={t("common.edit")} key="edit-tip"><EditOutlined key="edit" onClick={() => openEdit(item)} style={{ color: "#1677ff" }} /></Tooltip>, can("sys_tenant:update") && <Tooltip title={t("common.edit")} key="edit-tip"><EditOutlined key="edit" onClick={() => openEdit(item)} style={{ color: "#1677ff" }} /></Tooltip>,
can("sys_tenant:delete") && ( runtime?.tenantMode !== "single" && can("sys_tenant:delete") && (
<Popconfirm <Popconfirm
key="delete-pop" key="delete-pop"
title={t("tenantsExt.deleteConfirm", { name: item.tenantName })} title={t("tenantsExt.deleteConfirm", { name: item.tenantName })}
@ -200,7 +205,7 @@ export default function Tenants() {
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
{can("sys_tenant:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{t("common.create")}</Button>} {runtime?.tenantMode !== "single" && can("sys_tenant:create") && <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>{t("common.create")}</Button>}
</div> </div>
</Card> </Card>
</div> </div>
@ -291,7 +296,7 @@ export default function Tenants() {
</Col> </Col>
</Row> </Row>
<Form.Item label={t("common.status")} name="status" initialValue={1}> <Form.Item label={t("common.status")} name="status" initialValue={1}>
<Select options={statusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))} /> <Select disabled={runtime?.tenantMode === "single"} options={statusDict.map((item) => ({ label: item.itemLabel, value: Number(item.itemValue) }))} />
</Form.Item> </Form.Item>
<Form.Item label={t("common.remark")} name="remark"> <Form.Item label={t("common.remark")} name="remark">
<Input.TextArea rows={3} placeholder={t("tenantsExt.remarkPlaceholder")} /> <Input.TextArea rows={3} placeholder={t("tenantsExt.remarkPlaceholder")} />

View File

@ -1,8 +1,8 @@
import { Button, Card, DatePicker, Descriptions, Input, Modal, Popconfirm, Select, Space, Tabs, Tag, Typography, message } from "antd"; import { Button, Card, DatePicker, Descriptions, Input, Modal, Popconfirm, Select, Space, Tabs, Tag, Typography, message } from "antd";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DeleteOutlined, EyeOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons"; import { DeleteOutlined, EyeOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
import { cleanLogs, fetchLogModules, fetchLogs } from "@/api"; import { cleanLogs, fetchLogModules, fetchLogs, listTenants } from "@/api";
import { useDict } from "@/hooks/useDict"; import { useDict } from "@/hooks/useDict";
import PageHeader from "@/components/shared/PageHeader"; import PageHeader from "@/components/shared/PageHeader";
import ListTable from "@/components/shared/ListTable/ListTable"; import ListTable from "@/components/shared/ListTable/ListTable";
@ -19,6 +19,7 @@ export default function Logs() {
const [data, setData] = useState<SysLog[]>([]); const [data, setData] = useState<SysLog[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [moduleOptions, setModuleOptions] = useState<string[]>([]); const [moduleOptions, setModuleOptions] = useState<string[]>([]);
const [tenants, setTenants] = useState<SysTenant[]>([]);
const [detailModalVisible, setDetailModalVisible] = useState(false); const [detailModalVisible, setDetailModalVisible] = useState(false);
const [selectedLog, setSelectedLog] = useState<SysLog | null>(null); const [selectedLog, setSelectedLog] = useState<SysLog | null>(null);
const [cleaning, setCleaning] = useState(false); const [cleaning, setCleaning] = useState(false);
@ -31,6 +32,7 @@ export default function Logs() {
startDate: "", startDate: "",
endDate: "", endDate: "",
operation: "", operation: "",
tenantId: undefined as number | undefined,
sortField: "createdAt", sortField: "createdAt",
sortOrder: "descend" as any sortOrder: "descend" as any
}); });
@ -48,6 +50,10 @@ export default function Logs() {
}, []); }, []);
const isPlatformAdmin = Boolean(userProfile?.isPlatformAdmin); const isPlatformAdmin = Boolean(userProfile?.isPlatformAdmin);
const activeTenantName = useMemo(() => {
if (!isPlatformAdmin || !params.tenantId) return t("logsExt.allTenants");
return tenants.find((tenant) => tenant.id === params.tenantId)?.tenantName || t("logsExt.tenantId", { id: params.tenantId });
}, [isPlatformAdmin, params.tenantId, tenants, t]);
const activeLogTypeLabel = useMemo(() => { const activeLogTypeLabel = useMemo(() => {
const dictLabel = logTypeDict.find((item) => item.itemValue === activeTab)?.itemLabel; const dictLabel = logTypeDict.find((item) => item.itemValue === activeTab)?.itemLabel;
if (dictLabel) return dictLabel; if (dictLabel) return dictLabel;
@ -67,15 +73,25 @@ export default function Logs() {
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [activeTab, params.current, params.size, params.sortField, params.sortOrder]); }, [activeTab, params.current, params.size, params.sortField, params.sortOrder, params.tenantId]);
useEffect(() => {
if (!isPlatformAdmin) {
setTenants([]);
return;
}
listTenants({ current: 1, size: 1000 })
.then((result) => setTenants(result?.records || []))
.catch(() => setTenants([]));
}, [isPlatformAdmin]);
useEffect(() => { useEffect(() => {
if (activeTab !== "OPERATION") { if (activeTab !== "OPERATION") {
setModuleOptions([]); setModuleOptions([]);
return; return;
} }
fetchLogModules().then((items) => setModuleOptions(items || [])).catch(() => setModuleOptions([])); fetchLogModules({ tenantId: isPlatformAdmin ? params.tenantId : undefined }).then((items) => setModuleOptions(items || [])).catch(() => setModuleOptions([]));
}, [activeTab]); }, [activeTab, isPlatformAdmin, params.tenantId]);
const handleTableChange = (pagination: any, _filters: any, sorter: any) => { const handleTableChange = (pagination: any, _filters: any, sorter: any) => {
setParams({ setParams({
@ -103,6 +119,7 @@ export default function Logs() {
startDate: "", startDate: "",
endDate: "", endDate: "",
operation: "", operation: "",
tenantId: undefined,
sortField: "createdAt", sortField: "createdAt",
sortOrder: "descend" as any sortOrder: "descend" as any
}; };
@ -113,7 +130,7 @@ export default function Logs() {
const handleClean = async () => { const handleClean = async () => {
setCleaning(true); setCleaning(true);
try { try {
await cleanLogs(activeTab); await cleanLogs(activeTab, isPlatformAdmin ? params.tenantId : undefined);
message.success(t("logsExt.cleanSuccess", { type: activeLogTypeLabel })); message.success(t("logsExt.cleanSuccess", { type: activeLogTypeLabel }));
const nextParams = { ...params, current: 1 }; const nextParams = { ...params, current: 1 };
setParams(nextParams); setParams(nextParams);
@ -241,6 +258,17 @@ export default function Logs() {
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />} prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
allowClear allowClear
/> />
{isPlatformAdmin && (
<Select
placeholder={t("users.tenantFilter")}
style={{ width: 200 }}
value={params.tenantId}
onChange={(value) => setParams({ ...params, tenantId: value, moduleName: "", current: 1 })}
options={tenants.map((tenant) => ({ label: tenant.tenantName, value: tenant.id }))}
suffixIcon={<ShopOutlined aria-hidden="true" />}
allowClear
/>
)}
{activeTab === "OPERATION" && ( {activeTab === "OPERATION" && (
<Select <Select
placeholder={t("logsExt.filterModule")} placeholder={t("logsExt.filterModule")}
@ -284,7 +312,7 @@ export default function Logs() {
tabBarExtraContent={( tabBarExtraContent={(
<Popconfirm <Popconfirm
title={t("logsExt.cleanConfirmTitle", { type: activeLogTypeLabel })} title={t("logsExt.cleanConfirmTitle", { type: activeLogTypeLabel })}
description={t("logsExt.cleanConfirmDescription")} description={isPlatformAdmin ? t("logsExt.cleanConfirmDescriptionWithTenant", { tenant: activeTenantName }) : t("logsExt.cleanConfirmDescription")}
okText={t("common.confirm")} okText={t("common.confirm")}
cancelText={t("common.cancel")} cancelText={t("common.cancel")}
okButtonProps={{ danger: true, loading: cleaning }} okButtonProps={{ danger: true, loading: cleaning }}

View File

@ -19,6 +19,7 @@ export interface SysUser extends BaseEntity {
orgId?: number; orgId?: number;
isPlatformAdmin?: boolean; isPlatformAdmin?: boolean;
memberships?: any[]; memberships?: any[];
roleIds?: number[];
roles?: SysRole[]; roles?: SysRole[];
} }
@ -84,9 +85,7 @@ export interface PermissionNode extends SysPermission {
export interface DeviceInfo extends BaseEntity { export interface DeviceInfo extends BaseEntity {
deviceId: number; deviceId: number;
userId?: number; userId: number;
username?: string;
displayName?: string;
deviceCode: string; deviceCode: string;
deviceName?: string; deviceName?: string;
terminalType?: string; terminalType?: string;
@ -115,6 +114,7 @@ export interface SysTenant extends BaseEntity {
id: number; id: number;
tenantCode: string; tenantCode: string;
tenantName: string; tenantName: string;
defaultAdminUsername?: string;
expireTime?: string; expireTime?: string;
contactName?: string; contactName?: string;
contactPhone?: string; contactPhone?: string;
@ -173,6 +173,13 @@ export interface SysPlatformConfig {
systemDescription?: string; systemDescription?: string;
} }
export interface PlatformRuntime {
tenantMode: "single" | "multi";
multiTenantEnabled: boolean;
defaultTenantId?: number;
currentTenantId?: number;
}
export interface SysParamQuery { export interface SysParamQuery {
paramKey?: string; paramKey?: string;
paramType?: string; paramType?: string;