fix: 修复多角色找茬测试 V2 发现的 11 个问题
P0 (CRITICAL): - C1: 统计 API 全部改为 safe_aggregate 容错,防止单个子查询崩溃导致 500 - C2: Token 刷新增加用户身份验证,防止并发场景下身份切换 - C3: 患者端线下活动接口添加患者档案验证,防止 Doctor/HM 越权访问 P1 (HIGH): - H1: 操作记录用 EntityName 组件解析用户名,不再显示截断 UUID - H4: 告警标题添加中英文映射 (translateAlertTitle) - H5: 告警面板补全 message import + 修复 hooks 顺序 - H8: 咨询消息发送按钮添加 AuthButton 权限控制 - H9: routeConfig 日常监测权限码改为 health.daily-monitoring.* P2 (MEDIUM): - M4: 咨询类型映射补全 online/phone/doctor/follow_up 中文标签 DTO: LabReportStatisticsResp, AppointmentStatisticsResp, VitalSignsReportRateResp 添加 Default derive
This commit is contained in:
@@ -1,58 +1,125 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../../../stores/auth';
|
||||
import { listAuditLogs, type AuditLogItem } from '../../../../api/auditLogs';
|
||||
import { useStatsData } from '../../StatisticsDashboard/useStatsData';
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "../../../../stores/auth";
|
||||
import { listAuditLogs, type AuditLogItem } from "../../../../api/auditLogs";
|
||||
import { EntityName } from "../../../../components/EntityName";
|
||||
import { useStatsData } from "../../StatisticsDashboard/useStatsData";
|
||||
import {
|
||||
dashboardApi,
|
||||
type SystemHealthResp,
|
||||
type UserActivityResp,
|
||||
type ModuleStatusResp,
|
||||
} from '../../../../api/health/dashboard';
|
||||
} from "../../../../api/health/dashboard";
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 1) return "刚刚";
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
return `${Math.floor(hours / 24)} 天前`;
|
||||
}
|
||||
|
||||
const ACTION_ICONS: Record<string, { icon: string; bg: string; color: string }> = {
|
||||
create: { icon: '✓', bg: '#F0FDF4', color: '#16A34A' },
|
||||
created: { icon: '✓', bg: '#F0FDF4', color: '#16A34A' },
|
||||
update: { icon: '⚙', bg: '#FFFBEB', color: '#D97706' },
|
||||
updated: { icon: '⚙', bg: '#FFFBEB', color: '#D97706' },
|
||||
delete: { icon: '✕', bg: '#FEF2F2', color: '#DC2626' },
|
||||
deleted: { icon: '✕', bg: '#FEF2F2', color: '#DC2626' },
|
||||
login: { icon: '👤', bg: '#EFF6FF', color: '#2563EB' },
|
||||
'user.create': { icon: '✓', bg: '#F0FDF4', color: '#16A34A' },
|
||||
'user.update': { icon: '⚙', bg: '#FFFBEB', color: '#D97706' },
|
||||
'user.delete': { icon: '✕', bg: '#FEF2F2', color: '#DC2626' },
|
||||
const ACTION_ICONS: Record<
|
||||
string,
|
||||
{ icon: string; bg: string; color: string }
|
||||
> = {
|
||||
create: { icon: "✓", bg: "#F0FDF4", color: "#16A34A" },
|
||||
created: { icon: "✓", bg: "#F0FDF4", color: "#16A34A" },
|
||||
update: { icon: "⚙", bg: "#FFFBEB", color: "#D97706" },
|
||||
updated: { icon: "⚙", bg: "#FFFBEB", color: "#D97706" },
|
||||
delete: { icon: "✕", bg: "#FEF2F2", color: "#DC2626" },
|
||||
deleted: { icon: "✕", bg: "#FEF2F2", color: "#DC2626" },
|
||||
login: { icon: "👤", bg: "#EFF6FF", color: "#2563EB" },
|
||||
"user.create": { icon: "✓", bg: "#F0FDF4", color: "#16A34A" },
|
||||
"user.update": { icon: "⚙", bg: "#FFFBEB", color: "#D97706" },
|
||||
"user.delete": { icon: "✕", bg: "#FEF2F2", color: "#DC2626" },
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: '创建', created: '创建', update: '更新', updated: '更新',
|
||||
delete: '删除', deleted: '删除', login: '登录', 'user.create': '创建',
|
||||
'user.update': '更新', 'user.delete': '删除',
|
||||
create: "创建",
|
||||
created: "创建",
|
||||
update: "更新",
|
||||
updated: "更新",
|
||||
delete: "删除",
|
||||
deleted: "删除",
|
||||
login: "登录",
|
||||
"user.create": "创建",
|
||||
"user.update": "更新",
|
||||
"user.delete": "删除",
|
||||
};
|
||||
const RESOURCE_LABELS: Record<string, string> = {
|
||||
user: '用户', role: '角色', patient: '患者', doctor: '医护',
|
||||
appointment: '预约', follow_up_task: '随访', consultation_session: '咨询',
|
||||
message: '消息', plugin: '插件', process_instance: '流程实例', organization: '组织',
|
||||
user: "用户",
|
||||
role: "角色",
|
||||
patient: "患者",
|
||||
doctor: "医护",
|
||||
appointment: "预约",
|
||||
follow_up_task: "随访",
|
||||
consultation_session: "咨询",
|
||||
message: "消息",
|
||||
plugin: "插件",
|
||||
process_instance: "流程实例",
|
||||
organization: "组织",
|
||||
};
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ icon: '👤', bg: '#EFF6FF', color: '#2563EB', text: '用户管理', path: '/users' },
|
||||
{ icon: '🔑', bg: '#F5F3FF', color: '#7C3AED', text: '角色权限', path: '/roles' },
|
||||
{ icon: '⚙', bg: '#FFFBEB', color: '#D97706', text: '系统配置', path: '/settings' },
|
||||
{ icon: '📋', bg: '#FEF2F2', color: '#DC2626', text: '审计日志', path: '/audit-logs' },
|
||||
{ icon: '🧩', bg: '#F0FDF4', color: '#16A34A', text: '插件管理', path: '/plugins' },
|
||||
{ icon: '📖', bg: '#F0F9FF', color: '#0284C7', text: '菜单管理', path: '/menus' },
|
||||
{ icon: '📊', bg: '#FFF1F2', color: '#E11D48', text: '数据字典', path: '/dictionaries' },
|
||||
{ icon: '🔔', bg: '#F8FAFC', color: '#475569', text: '消息管理', path: '/messages' },
|
||||
{
|
||||
icon: "👤",
|
||||
bg: "#EFF6FF",
|
||||
color: "#2563EB",
|
||||
text: "用户管理",
|
||||
path: "/users",
|
||||
},
|
||||
{
|
||||
icon: "🔑",
|
||||
bg: "#F5F3FF",
|
||||
color: "#7C3AED",
|
||||
text: "角色权限",
|
||||
path: "/roles",
|
||||
},
|
||||
{
|
||||
icon: "⚙",
|
||||
bg: "#FFFBEB",
|
||||
color: "#D97706",
|
||||
text: "系统配置",
|
||||
path: "/settings",
|
||||
},
|
||||
{
|
||||
icon: "📋",
|
||||
bg: "#FEF2F2",
|
||||
color: "#DC2626",
|
||||
text: "审计日志",
|
||||
path: "/audit-logs",
|
||||
},
|
||||
{
|
||||
icon: "🧩",
|
||||
bg: "#F0FDF4",
|
||||
color: "#16A34A",
|
||||
text: "插件管理",
|
||||
path: "/plugins",
|
||||
},
|
||||
{
|
||||
icon: "📖",
|
||||
bg: "#F0F9FF",
|
||||
color: "#0284C7",
|
||||
text: "菜单管理",
|
||||
path: "/menus",
|
||||
},
|
||||
{
|
||||
icon: "📊",
|
||||
bg: "#FFF1F2",
|
||||
color: "#E11D48",
|
||||
text: "数据字典",
|
||||
path: "/dictionaries",
|
||||
},
|
||||
{
|
||||
icon: "🔔",
|
||||
bg: "#F8FAFC",
|
||||
color: "#475569",
|
||||
text: "消息管理",
|
||||
path: "/messages",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminDashboard() {
|
||||
@@ -60,142 +127,367 @@ export default function AdminDashboard() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const statsData = useStatsData();
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLogItem[]>([]);
|
||||
const [systemHealth, setSystemHealth] = useState<SystemHealthResp | null>(null);
|
||||
const [userActivity, setUserActivity] = useState<UserActivityResp | null>(null);
|
||||
const [systemHealth, setSystemHealth] = useState<SystemHealthResp | null>(
|
||||
null,
|
||||
);
|
||||
const [userActivity, setUserActivity] = useState<UserActivityResp | null>(
|
||||
null,
|
||||
);
|
||||
const [modules, setModules] = useState<ModuleStatusResp[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const [auditResult, healthResult, activityResult, modulesResult] = await Promise.allSettled([
|
||||
listAuditLogs({ page: 1, page_size: 6 }),
|
||||
dashboardApi.getSystemHealth(),
|
||||
dashboardApi.getUserActivity(),
|
||||
dashboardApi.getModuleStatus(),
|
||||
]);
|
||||
const [auditResult, healthResult, activityResult, modulesResult] =
|
||||
await Promise.allSettled([
|
||||
listAuditLogs({ page: 1, page_size: 6 }),
|
||||
dashboardApi.getSystemHealth(),
|
||||
dashboardApi.getUserActivity(),
|
||||
dashboardApi.getModuleStatus(),
|
||||
]);
|
||||
|
||||
if (auditResult.status === 'fulfilled') {
|
||||
setAuditLogs(auditResult.value.data.filter((a) => a.action !== 'login_failed'));
|
||||
if (auditResult.status === "fulfilled") {
|
||||
setAuditLogs(
|
||||
auditResult.value.data.filter((a) => a.action !== "login_failed"),
|
||||
);
|
||||
}
|
||||
if (healthResult.status === 'fulfilled') setSystemHealth(healthResult.value);
|
||||
if (activityResult.status === 'fulfilled') setUserActivity(activityResult.value);
|
||||
if (modulesResult.status === 'fulfilled') setModules(modulesResult.value);
|
||||
if (healthResult.status === "fulfilled")
|
||||
setSystemHealth(healthResult.value);
|
||||
if (activityResult.status === "fulfilled")
|
||||
setUserActivity(activityResult.value);
|
||||
if (modulesResult.status === "fulfilled") setModules(modulesResult.value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const firstName = user?.display_name ?? user?.username ?? '管理员';
|
||||
const firstName = user?.display_name ?? user?.username ?? "管理员";
|
||||
const now = new Date();
|
||||
const greeting = now.getHours() < 12 ? '早上好' : now.getHours() < 18 ? '下午好' : '晚上好';
|
||||
const activeModules = modules.length > 0 ? modules.filter((m) => m.active).length : 0;
|
||||
const greeting =
|
||||
now.getHours() < 12 ? "早上好" : now.getHours() < 18 ? "下午好" : "晚上好";
|
||||
const activeModules =
|
||||
modules.length > 0 ? modules.filter((m) => m.active).length : 0;
|
||||
const totalModules = modules.length || 8;
|
||||
|
||||
const statCards = [
|
||||
{ label: '注册用户', value: userActivity?.total_registered ?? statsData.patientStats?.total_patients ?? 0, color: '#2563EB', gradient: 'linear-gradient(90deg,#2563EB,#60A5FA)', sub: `今日活跃 ${userActivity?.daily_active ?? 0}` },
|
||||
{ label: '业务模块', value: `${activeModules} / ${totalModules}`, color: '#7C3AED', gradient: 'linear-gradient(90deg,#7C3AED,#A78BFA)', sub: totalModules > 0 ? `${totalModules - activeModules} 个插件待启用` : '加载中...' },
|
||||
{ label: '今日操作', value: userActivity?.daily_active ?? 0, color: '#16A34A', gradient: 'linear-gradient(90deg,#16A34A,#4ADE80)', sub: `近 ${auditLogs.length} 条记录` },
|
||||
{ label: '本周活跃', value: userActivity?.weekly_active ?? 0, color: '#EA580C', gradient: 'linear-gradient(90deg,#EA580C,#FB923C)', sub: `月活 ${userActivity?.monthly_active ?? 0}` },
|
||||
{
|
||||
label: "注册用户",
|
||||
value:
|
||||
userActivity?.total_registered ??
|
||||
statsData.patientStats?.total_patients ??
|
||||
0,
|
||||
color: "#2563EB",
|
||||
gradient: "linear-gradient(90deg,#2563EB,#60A5FA)",
|
||||
sub: `今日活跃 ${userActivity?.daily_active ?? 0}`,
|
||||
},
|
||||
{
|
||||
label: "业务模块",
|
||||
value: `${activeModules} / ${totalModules}`,
|
||||
color: "#7C3AED",
|
||||
gradient: "linear-gradient(90deg,#7C3AED,#A78BFA)",
|
||||
sub:
|
||||
totalModules > 0
|
||||
? `${totalModules - activeModules} 个插件待启用`
|
||||
: "加载中...",
|
||||
},
|
||||
{
|
||||
label: "今日操作",
|
||||
value: userActivity?.daily_active ?? 0,
|
||||
color: "#16A34A",
|
||||
gradient: "linear-gradient(90deg,#16A34A,#4ADE80)",
|
||||
sub: `近 ${auditLogs.length} 条记录`,
|
||||
},
|
||||
{
|
||||
label: "本周活跃",
|
||||
value: userActivity?.weekly_active ?? 0,
|
||||
color: "#EA580C",
|
||||
gradient: "linear-gradient(90deg,#EA580C,#FB923C)",
|
||||
sub: `月活 ${userActivity?.monthly_active ?? 0}`,
|
||||
},
|
||||
];
|
||||
|
||||
const healthServices = systemHealth?.services ?? [
|
||||
{ name: 'API 服务', status: 'unknown' as const, message: '数据加载中...', response_ms: null },
|
||||
{ name: '数据库', status: 'unknown' as const, message: '数据加载中...', response_ms: null },
|
||||
{ name: '定时任务', status: 'unknown' as const, message: '数据加载中...', response_ms: null },
|
||||
{
|
||||
name: "API 服务",
|
||||
status: "unknown" as const,
|
||||
message: "数据加载中...",
|
||||
response_ms: null,
|
||||
},
|
||||
{
|
||||
name: "数据库",
|
||||
status: "unknown" as const,
|
||||
message: "数据加载中...",
|
||||
response_ms: null,
|
||||
},
|
||||
{
|
||||
name: "定时任务",
|
||||
status: "unknown" as const,
|
||||
message: "数据加载中...",
|
||||
response_ms: null,
|
||||
},
|
||||
];
|
||||
|
||||
const userActivityItems = [
|
||||
{ label: '今日活跃', value: userActivity?.daily_active ?? 0, pct: userActivity ? Math.round((userActivity.daily_active / Math.max(userActivity.total_registered, 1)) * 100) : 0, color: '#2563EB' },
|
||||
{ label: '本周活跃', value: userActivity?.weekly_active ?? 0, pct: userActivity ? Math.round((userActivity.weekly_active / Math.max(userActivity.total_registered, 1)) * 100) : 0, color: '#7C3AED' },
|
||||
{ label: '本月活跃', value: userActivity?.monthly_active ?? 0, pct: userActivity ? Math.round((userActivity.monthly_active / Math.max(userActivity.total_registered, 1)) * 100) : 0, color: '#16A34A' },
|
||||
{ label: '总注册', value: userActivity?.total_registered ?? statsData.patientStats?.total_patients ?? 0, pct: 100, color: '#94A3B8' },
|
||||
{
|
||||
label: "今日活跃",
|
||||
value: userActivity?.daily_active ?? 0,
|
||||
pct: userActivity
|
||||
? Math.round(
|
||||
(userActivity.daily_active /
|
||||
Math.max(userActivity.total_registered, 1)) *
|
||||
100,
|
||||
)
|
||||
: 0,
|
||||
color: "#2563EB",
|
||||
},
|
||||
{
|
||||
label: "本周活跃",
|
||||
value: userActivity?.weekly_active ?? 0,
|
||||
pct: userActivity
|
||||
? Math.round(
|
||||
(userActivity.weekly_active /
|
||||
Math.max(userActivity.total_registered, 1)) *
|
||||
100,
|
||||
)
|
||||
: 0,
|
||||
color: "#7C3AED",
|
||||
},
|
||||
{
|
||||
label: "本月活跃",
|
||||
value: userActivity?.monthly_active ?? 0,
|
||||
pct: userActivity
|
||||
? Math.round(
|
||||
(userActivity.monthly_active /
|
||||
Math.max(userActivity.total_registered, 1)) *
|
||||
100,
|
||||
)
|
||||
: 0,
|
||||
color: "#16A34A",
|
||||
},
|
||||
{
|
||||
label: "总注册",
|
||||
value:
|
||||
userActivity?.total_registered ??
|
||||
statsData.patientStats?.total_patients ??
|
||||
0,
|
||||
pct: 100,
|
||||
color: "#94A3B8",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 欢迎栏 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, margin: '0 0 4px' }}>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, margin: "0 0 4px" }}>
|
||||
{greeting},{firstName.charAt(0)}主任
|
||||
</h1>
|
||||
<p style={{ color: '#64748B', fontSize: 13, margin: 0 }}>
|
||||
<p style={{ color: "#64748B", fontSize: 13, margin: 0 }}>
|
||||
平台运行正常 · {activeModules} 个模块已激活 · 今日数据概览
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 系统健康条 */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 12, marginBottom: 20,
|
||||
padding: '14px 20px', background: '#fff', borderRadius: 12,
|
||||
border: '1px solid #E2E8F0',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
padding: "14px 20px",
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E2E8F0",
|
||||
}}
|
||||
>
|
||||
{healthServices.map((item, i) => (
|
||||
<div key={item.name} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
fontSize: 12, color: '#64748B', paddingRight: 12,
|
||||
borderRight: i < healthServices.length - 1 ? '1px solid #F1F5F9' : undefined,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
||||
background: item.status === 'healthy' ? '#22C55E' : item.status === 'degraded' ? '#EAB308' : '#EF4444',
|
||||
}} />
|
||||
<span style={{ fontWeight: 500, color: '#334155' }}>{item.name}</span> {item.message}
|
||||
<div
|
||||
key={item.name}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
color: "#64748B",
|
||||
paddingRight: 12,
|
||||
borderRight:
|
||||
i < healthServices.length - 1 ? "1px solid #F1F5F9" : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
background:
|
||||
item.status === "healthy"
|
||||
? "#22C55E"
|
||||
: item.status === "degraded"
|
||||
? "#EAB308"
|
||||
: "#EF4444",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: 500, color: "#334155" }}>
|
||||
{item.name}
|
||||
</span>{" "}
|
||||
{item.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 14,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{statCards.map((card) => (
|
||||
<div key={card.label} style={{
|
||||
background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0',
|
||||
overflow: 'hidden', cursor: 'pointer', transition: 'all 0.2s',
|
||||
}}>
|
||||
<div
|
||||
key={card.label}
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E2E8F0",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 3, background: card.gradient }} />
|
||||
<div style={{ padding: '14px 18px' }}>
|
||||
<div style={{ fontSize: 12, color: '#94A3B8', marginBottom: 4 }}>{card.label}</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, color: card.color }}>{card.value}</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 3 }}>{card.sub}</div>
|
||||
<div style={{ padding: "14px 18px" }}>
|
||||
<div style={{ fontSize: 12, color: "#94A3B8", marginBottom: 4 }}>
|
||||
{card.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, color: card.color }}>
|
||||
{card.value}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#94A3B8", marginTop: 3 }}>
|
||||
{card.sub}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 双栏:审计日志 + 模块状态 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 16,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{/* 最近审计日志 */}
|
||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E2E8F0",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 18px",
|
||||
borderBottom: "1px solid #F1F5F9",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>最近操作记录</h3>
|
||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/audit-logs')}>审计日志 →</span>
|
||||
<span
|
||||
style={{ fontSize: 11, color: "#2563EB", cursor: "pointer" }}
|
||||
onClick={() => navigate("/audit-logs")}
|
||||
>
|
||||
审计日志 →
|
||||
</span>
|
||||
</div>
|
||||
{auditLogs.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#94A3B8', fontSize: 13 }}>暂无操作记录</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 24,
|
||||
textAlign: "center",
|
||||
color: "#94A3B8",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
暂无操作记录
|
||||
</div>
|
||||
) : (
|
||||
auditLogs.map((log) => {
|
||||
const actionKey = log.action.split('.').pop() ?? log.action;
|
||||
const iconCfg = ACTION_ICONS[log.action] ?? ACTION_ICONS[actionKey] ?? { icon: '📋', bg: '#F0F9FF', color: '#0284C7' };
|
||||
const actionLabel = ACTION_LABELS[log.action] ?? ACTION_LABELS[actionKey] ?? log.action;
|
||||
const resourceLabel = RESOURCE_LABELS[log.resource_type] ?? RESOURCE_LABELS[log.resource_type.split('.').pop() ?? ''] ?? log.resource_type;
|
||||
const actionKey = log.action.split(".").pop() ?? log.action;
|
||||
const iconCfg = ACTION_ICONS[log.action] ??
|
||||
ACTION_ICONS[actionKey] ?? {
|
||||
icon: "📋",
|
||||
bg: "#F0F9FF",
|
||||
color: "#0284C7",
|
||||
};
|
||||
const actionLabel =
|
||||
ACTION_LABELS[log.action] ??
|
||||
ACTION_LABELS[actionKey] ??
|
||||
log.action;
|
||||
const resourceLabel =
|
||||
RESOURCE_LABELS[log.resource_type] ??
|
||||
RESOURCE_LABELS[log.resource_type.split(".").pop() ?? ""] ??
|
||||
log.resource_type;
|
||||
return (
|
||||
<div key={log.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 18px', borderBottom: '1px solid #F1F5F9',
|
||||
fontSize: 13, transition: 'background 0.15s', cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#F8FAFC'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
<div
|
||||
key={log.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "10px 18px",
|
||||
borderBottom: "1px solid #F1F5F9",
|
||||
fontSize: 13,
|
||||
transition: "background 0.15s",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "#F8FAFC";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 6, background: iconCfg.bg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 12, flexShrink: 0, color: iconCfg.color,
|
||||
}}>{iconCfg.icon}</div>
|
||||
<span style={{ fontWeight: 500, flexShrink: 0, width: 60 }}>{log.user_id ? log.user_id.slice(0, 6) : '系统'}</span>
|
||||
<span style={{ flex: 1, color: '#475569' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
background: iconCfg.bg,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
color: iconCfg.color,
|
||||
}}
|
||||
>
|
||||
{iconCfg.icon}
|
||||
</div>
|
||||
<span style={{ fontWeight: 500, flexShrink: 0, width: 60 }}>
|
||||
{log.user_id ? (
|
||||
<EntityName
|
||||
name={log.user_id.slice(0, 6)}
|
||||
id={log.user_id}
|
||||
/>
|
||||
) : (
|
||||
"系统"
|
||||
)}
|
||||
</span>
|
||||
<span style={{ flex: 1, color: "#475569" }}>
|
||||
{actionLabel}了{resourceLabel}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#94A3B8', flexShrink: 0 }}>{formatTimeAgo(log.created_at)}</span>
|
||||
<span
|
||||
style={{ fontSize: 11, color: "#94A3B8", flexShrink: 0 }}
|
||||
>
|
||||
{formatTimeAgo(log.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -203,94 +495,231 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
|
||||
{/* 模块状态 */}
|
||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E2E8F0",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 18px",
|
||||
borderBottom: "1px solid #F1F5F9",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>模块状态</h3>
|
||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/plugins')}>模块管理 →</span>
|
||||
<span
|
||||
style={{ fontSize: 11, color: "#2563EB", cursor: "pointer" }}
|
||||
onClick={() => navigate("/plugins")}
|
||||
>
|
||||
模块管理 →
|
||||
</span>
|
||||
</div>
|
||||
{(modules.length > 0 ? modules : []).map((mod) => (
|
||||
<div key={mod.name} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 18px', borderBottom: '1px solid #F1F5F9',
|
||||
}}>
|
||||
<div
|
||||
key={mod.name}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 18px",
|
||||
borderBottom: "1px solid #F1F5F9",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{mod.display_name}</div>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>{mod.description}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{mod.display_name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#94A3B8" }}>
|
||||
{mod.description}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 11, padding: '2px 10px', borderRadius: 10, fontWeight: 500,
|
||||
background: mod.active ? '#F0FDF4' : '#F1F5F9',
|
||||
color: mod.active ? '#16A34A' : '#94A3B8',
|
||||
}}>{mod.active ? '运行中' : '未启用'}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "2px 10px",
|
||||
borderRadius: 10,
|
||||
fontWeight: 500,
|
||||
background: mod.active ? "#F0FDF4" : "#F1F5F9",
|
||||
color: mod.active ? "#16A34A" : "#94A3B8",
|
||||
}}
|
||||
>
|
||||
{mod.active ? "运行中" : "未启用"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 双栏:用户活跃度 + 快捷管理 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
|
||||
{/* 用户活跃度 */}
|
||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E2E8F0",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 18px",
|
||||
borderBottom: "1px solid #F1F5F9",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>用户活跃度</h3>
|
||||
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/users')}>用户管理 →</span>
|
||||
<span
|
||||
style={{ fontSize: 11, color: "#2563EB", cursor: "pointer" }}
|
||||
onClick={() => navigate("/users")}
|
||||
>
|
||||
用户管理 →
|
||||
</span>
|
||||
</div>
|
||||
{userActivityItems.map((item) => (
|
||||
<div key={item.label} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 18px', borderBottom: '1px solid #F1F5F9',
|
||||
}}>
|
||||
<span style={{ fontSize: 12, width: 70, flexShrink: 0 }}>{item.label}</span>
|
||||
<div style={{ flex: 1, height: 6, background: '#F1F5F9', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${item.pct}%`, height: '100%', background: item.color, borderRadius: 3 }} />
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "10px 18px",
|
||||
borderBottom: "1px solid #F1F5F9",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, width: 70, flexShrink: 0 }}>
|
||||
{item.label}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
background: "#F1F5F9",
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${item.pct}%`,
|
||||
height: "100%",
|
||||
background: item.color,
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, width: 40, textAlign: 'right', flexShrink: 0, color: item.color === '#94A3B8' ? '#475569' : item.color }}>{item.value}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
width: 40,
|
||||
textAlign: "right",
|
||||
flexShrink: 0,
|
||||
color: item.color === "#94A3B8" ? "#475569" : item.color,
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{
|
||||
padding: '12px 18px', borderTop: '1px solid #F1F5F9',
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#94A3B8' }}>按角色分布</div>
|
||||
<div style={{ display: 'flex', gap: 10, fontSize: 11 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 18px",
|
||||
borderTop: "1px solid #F1F5F9",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, color: "#94A3B8" }}>按角色分布</div>
|
||||
<div style={{ display: "flex", gap: 10, fontSize: 11 }}>
|
||||
{userActivity?.by_role.map((r) => (
|
||||
<span key={r.role}>{r.role} {r.count}</span>
|
||||
)) ?? <span style={{ color: '#94A3B8' }}>加载中...</span>}
|
||||
<span key={r.role}>
|
||||
{r.role} {r.count}
|
||||
</span>
|
||||
)) ?? <span style={{ color: "#94A3B8" }}>加载中...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷管理入口 */}
|
||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E2E8F0', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '12px 18px', borderBottom: '1px solid #F1F5F9',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #E2E8F0",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 18px",
|
||||
borderBottom: "1px solid #F1F5F9",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 600 }}>系统管理</h3>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, padding: '14px 18px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 10,
|
||||
padding: "14px 18px",
|
||||
}}
|
||||
>
|
||||
{QUICK_ACTIONS.map((item) => (
|
||||
<div key={item.path} style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
|
||||
padding: '14px 8px', borderRadius: 10, border: '1px solid #E2E8F0',
|
||||
cursor: 'pointer', transition: 'all 0.15s', textAlign: 'center',
|
||||
}}
|
||||
<div
|
||||
key={item.path}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "14px 8px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid #E2E8F0",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s",
|
||||
textAlign: "center",
|
||||
}}
|
||||
onClick={() => navigate(item.path)}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#2563EB'; e.currentTarget.style.background = '#F8FAFC'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#E2E8F0'; e.currentTarget.style.background = 'transparent'; }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#2563EB";
|
||||
e.currentTarget.style.background = "#F8FAFC";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#E2E8F0";
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 8, background: item.bg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 16, color: item.color,
|
||||
}}>{item.icon}</div>
|
||||
<span style={{ fontSize: 12, fontWeight: 500 }}>{item.text}</span>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
background: item.bg,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 16,
|
||||
color: item.color,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user