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.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@ -24,6 +25,33 @@ public interface DeviceInfoMapper extends BaseMapper<DeviceInfoEntity> {
""")
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")
@Select("""
SELECT *

View File

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

View File

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

View File

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

View File

@ -1,5 +1,18 @@
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;
}
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
*/
export async function uploadPlatformAsset(file: File) {
validatePlatformImage(file);
const formData = new FormData();
formData.append("file", file);
const resp = await http.post("/sys/api/admin/platform/config/upload", formData, {

View File

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

View File

@ -317,6 +317,9 @@
"cleanCurrent": "Clear {{type}}",
"cleanConfirmTitle": "Clear {{type}}?",
"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"
},
"devicesExt": {

View File

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

View File

@ -82,6 +82,13 @@ export default function Login() {
const payload = JSON.parse(atob(data.accessToken.split(".")[1]));
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 {
const profile = await getCurrentUser();
@ -106,26 +113,26 @@ export default function Login() {
const loginStyle = platformConfig?.loginBgUrl
? {
backgroundImage: `url(${platformConfig.loginBgUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
position: "relative" as const
}
backgroundImage: `url(${platformConfig.loginBgUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
position: "relative" as const
}
: {};
const leftStyle = platformConfig?.loginBgUrl
? {
...loginStyle,
background: "rgba(255, 255, 255, 0.2)",
backdropFilter: "blur(10px)"
}
...loginStyle,
background: "rgba(255, 255, 255, 0.2)",
backdropFilter: "blur(10px)"
}
: {};
const rightStyle = platformConfig?.loginBgUrl
? {
background: "rgba(255, 255, 255, 0.85)",
backdropFilter: "blur(20px)"
}
background: "rgba(255, 255, 255, 0.85)",
backdropFilter: "blur(20px)"
}
: {};
return (

View File

@ -3,13 +3,13 @@ import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteOutlined, EditOutlined, PhoneOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
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 { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
import { getStandardPagination } from "@/utils/pagination";
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import type { SysTenant } from "@/types";
import type { PlatformRuntime, SysTenant } from "@/types";
const { Title, Text, Paragraph } = Typography;
@ -24,6 +24,7 @@ export default function Tenants() {
const [queryParams, setQueryParams] = useState({ current: 1, size: 12, name: "", code: "" });
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<SysTenant | null>(null);
const [runtime, setRuntime] = useState<PlatformRuntime | null>(null);
const [adminAccountTouched, setAdminAccountTouched] = useState(false);
const [form] = Form.useForm();
const [searchForm] = Form.useForm();
@ -44,6 +45,10 @@ export default function Tenants() {
loadData();
}, [loadData, queryParams.current, queryParams.size]);
useEffect(() => {
getPlatformRuntime().then(setRuntime).catch(() => setRuntime(null));
}, []);
const handleSearch = (values: any) => {
setQueryParams((prev) => ({ ...prev, ...values, current: 1 }));
};
@ -123,7 +128,7 @@ export default function Tenants() {
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column' } }}
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:delete") && (
runtime?.tenantMode !== "single" && can("sys_tenant:delete") && (
<Popconfirm
key="delete-pop"
title={t("tenantsExt.deleteConfirm", { name: item.tenantName })}
@ -200,7 +205,7 @@ export default function Tenants() {
</Space>
</Form.Item>
</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>
</Card>
</div>
@ -291,7 +296,7 @@ export default function Tenants() {
</Col>
</Row>
<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 label={t("common.remark")} name="remark">
<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 { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteOutlined, EyeOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
import { cleanLogs, fetchLogModules, fetchLogs } from "@/api";
import { DeleteOutlined, EyeOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, ShopOutlined, UserOutlined } from "@ant-design/icons";
import { cleanLogs, fetchLogModules, fetchLogs, listTenants } from "@/api";
import { useDict } from "@/hooks/useDict";
import PageHeader from "@/components/shared/PageHeader";
import ListTable from "@/components/shared/ListTable/ListTable";
@ -19,6 +19,7 @@ export default function Logs() {
const [data, setData] = useState<SysLog[]>([]);
const [total, setTotal] = useState(0);
const [moduleOptions, setModuleOptions] = useState<string[]>([]);
const [tenants, setTenants] = useState<SysTenant[]>([]);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [selectedLog, setSelectedLog] = useState<SysLog | null>(null);
const [cleaning, setCleaning] = useState(false);
@ -31,6 +32,7 @@ export default function Logs() {
startDate: "",
endDate: "",
operation: "",
tenantId: undefined as number | undefined,
sortField: "createdAt",
sortOrder: "descend" as any
});
@ -48,6 +50,10 @@ export default function Logs() {
}, []);
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 dictLabel = logTypeDict.find((item) => item.itemValue === activeTab)?.itemLabel;
if (dictLabel) return dictLabel;
@ -67,15 +73,25 @@ export default function Logs() {
useEffect(() => {
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(() => {
if (activeTab !== "OPERATION") {
setModuleOptions([]);
return;
}
fetchLogModules().then((items) => setModuleOptions(items || [])).catch(() => setModuleOptions([]));
}, [activeTab]);
fetchLogModules({ tenantId: isPlatformAdmin ? params.tenantId : undefined }).then((items) => setModuleOptions(items || [])).catch(() => setModuleOptions([]));
}, [activeTab, isPlatformAdmin, params.tenantId]);
const handleTableChange = (pagination: any, _filters: any, sorter: any) => {
setParams({
@ -103,6 +119,7 @@ export default function Logs() {
startDate: "",
endDate: "",
operation: "",
tenantId: undefined,
sortField: "createdAt",
sortOrder: "descend" as any
};
@ -113,7 +130,7 @@ export default function Logs() {
const handleClean = async () => {
setCleaning(true);
try {
await cleanLogs(activeTab);
await cleanLogs(activeTab, isPlatformAdmin ? params.tenantId : undefined);
message.success(t("logsExt.cleanSuccess", { type: activeLogTypeLabel }));
const nextParams = { ...params, current: 1 };
setParams(nextParams);
@ -209,16 +226,16 @@ export default function Logs() {
width: 180,
ellipsis: true,
render: (method: string) => (
<Tag
color="blue"
style={{
fontSize: "11px",
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
<Tag
color="blue"
style={{
fontSize: "11px",
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
verticalAlign: "middle"
}}
}}
title={method}
>
{method}
@ -241,6 +258,17 @@ export default function Logs() {
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
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" && (
<Select
placeholder={t("logsExt.filterModule")}
@ -284,7 +312,7 @@ export default function Logs() {
tabBarExtraContent={(
<Popconfirm
title={t("logsExt.cleanConfirmTitle", { type: activeLogTypeLabel })}
description={t("logsExt.cleanConfirmDescription")}
description={isPlatformAdmin ? t("logsExt.cleanConfirmDescriptionWithTenant", { tenant: activeTenantName }) : t("logsExt.cleanConfirmDescription")}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
okButtonProps={{ danger: true, loading: cleaning }}

View File

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