feat(web): 登录页主题适配 + 工作台角色化重构
- 登录页接入 4 套主题系统(渐变色/面板背景/文字色),添加 ThemeSwitcher - 工作台按角色(医生/护士/管理员/运营)显示专属统计卡片和快捷入口 - 移除系统信息填充卡片,硬编码颜色替换为 CSS 变量
This commit is contained in:
@@ -1,100 +1,37 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Row, Col, Spin, Empty } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
CalendarOutlined,
|
||||
HeartOutlined,
|
||||
MedicineBoxOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
FileTextOutlined,
|
||||
MessageOutlined,
|
||||
BellOutlined,
|
||||
ThunderboltOutlined,
|
||||
SettingOutlined,
|
||||
AlertOutlined,
|
||||
TrophyOutlined,
|
||||
ShoppingOutlined,
|
||||
FileTextOutlined,
|
||||
RightOutlined,
|
||||
PartitionOutlined,
|
||||
ClockCircleOutlined,
|
||||
ApartmentOutlined,
|
||||
CheckCircleOutlined,
|
||||
RightOutlined,
|
||||
ThunderboltOutlined,
|
||||
SettingOutlined,
|
||||
ApartmentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import client from '../api/client';
|
||||
import { useThemeMode } from '../hooks/useThemeMode';
|
||||
import { useDashboardRole, type DashboardRole } from '../hooks/useDashboardRole';
|
||||
import { useMessageStore } from '../stores/message';
|
||||
import { listAuditLogs, type AuditLogItem } from '../api/auditLogs';
|
||||
import { listPendingTasks, type TaskInfo } from '../api/workflowTasks';
|
||||
import { pointsApi, type PersonalStats } from '../api/health/points';
|
||||
import { useStatsData } from './health/StatisticsDashboard/useStatsData';
|
||||
import { useCountUp } from '../hooks/useCountUp';
|
||||
|
||||
interface DashboardStats {
|
||||
userCount: number;
|
||||
roleCount: number;
|
||||
processInstanceCount: number;
|
||||
unreadMessages: number;
|
||||
}
|
||||
|
||||
interface StatCardConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
gradient: string;
|
||||
iconBg: string;
|
||||
delay: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function useCountUp(end: number, duration = 800) {
|
||||
const [count, setCount] = useState(0);
|
||||
const prevEnd = useRef(end);
|
||||
|
||||
useEffect(() => {
|
||||
if (end === prevEnd.current && count > 0) return;
|
||||
prevEnd.current = end;
|
||||
|
||||
if (end === 0) { setCount(0); return; }
|
||||
|
||||
const startTime = performance.now();
|
||||
const startVal = 0;
|
||||
|
||||
function tick(now: number) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setCount(Math.round(startVal + (end - startVal) * eased));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}, [end, duration]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function StatValue({ value, loading }: { value: number; loading: boolean }) {
|
||||
const animatedValue = useCountUp(value);
|
||||
if (loading) return <Spin size="small" />;
|
||||
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: '创建', created: '创建', update: '更新', updated: '更新', delete: '删除', deleted: '删除',
|
||||
login: '登录', login_failed: '登录失败', user_login: '登录', 'user.login': '登录',
|
||||
'user.create': '创建', 'user.update': '更新', 'user.delete': '删除',
|
||||
'role.create': '创建', 'role.update': '更新', 'role.delete': '删除',
|
||||
'patient.create': '创建', 'patient.update': '更新',
|
||||
'appointment.create': '创建', 'appointment.update': '更新',
|
||||
};
|
||||
const RESOURCE_LABELS: Record<string, string> = {
|
||||
user: '用户', role: '角色', permission: '权限',
|
||||
organization: '组织', department: '部门', position: '岗位',
|
||||
dictionary: '字典', menu: '菜单', setting: '设置',
|
||||
process_definition: '流程定义', process_instance: '流程实例',
|
||||
message: '消息', plugin: '插件',
|
||||
patient: '患者', doctor: '医护', appointment: '预约',
|
||||
follow_up_task: '随访', consultation_session: '咨询',
|
||||
auth: '认证',
|
||||
};
|
||||
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||
user: <UserOutlined />, role: <SafetyCertificateOutlined />,
|
||||
organization: <ApartmentOutlined />, process_definition: <PartitionOutlined />,
|
||||
process_instance: <FileTextOutlined />, message: <BellOutlined />,
|
||||
patient: <UserOutlined />, doctor: <UserOutlined />,
|
||||
};
|
||||
// --- Shared utilities ---
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
@@ -107,158 +44,174 @@ function formatTimeAgo(dateStr: string): string {
|
||||
return `${days} 天前`;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: '创建', created: '创建', update: '更新', updated: '更新', delete: '删除', deleted: '删除',
|
||||
login: '登录', 'user.create': '创建', 'user.update': '更新', 'user.delete': '删除',
|
||||
'patient.create': '创建', 'patient.update': '更新', 'appointment.create': '创建',
|
||||
};
|
||||
const RESOURCE_LABELS: Record<string, string> = {
|
||||
user: '用户', role: '角色', patient: '患者', doctor: '医护', appointment: '预约',
|
||||
follow_up_task: '随访', consultation_session: '咨询', message: '消息', plugin: '插件',
|
||||
process_instance: '流程实例', organization: '组织',
|
||||
};
|
||||
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||
user: <UserOutlined />, role: <SafetyCertificateOutlined />,
|
||||
patient: <UserOutlined />, organization: <ApartmentOutlined />,
|
||||
process_instance: <FileTextOutlined />, message: <BellOutlined />,
|
||||
};
|
||||
|
||||
function formatActionLabel(action: string): string {
|
||||
if (ACTION_LABELS[action]) return ACTION_LABELS[action];
|
||||
const lastPart = action.split('.').pop() || action;
|
||||
return ACTION_LABELS[lastPart] || lastPart;
|
||||
return ACTION_LABELS[action] || ACTION_LABELS[action.split('.').pop() || ''] || action;
|
||||
}
|
||||
function formatResourceLabel(resource: string): string {
|
||||
return RESOURCE_LABELS[resource] || RESOURCE_LABELS[resource.split('.').pop() || ''] || resource;
|
||||
}
|
||||
|
||||
function formatResourceLabel(resource: string): string {
|
||||
if (RESOURCE_LABELS[resource]) return RESOURCE_LABELS[resource];
|
||||
const lastPart = resource.split('.').pop() || resource;
|
||||
return RESOURCE_LABELS[lastPart] || lastPart;
|
||||
// --- Role configs ---
|
||||
|
||||
interface StatCardDef {
|
||||
key: string;
|
||||
title: string;
|
||||
getValue: (p: PersonalStats | null, s: ReturnType<typeof useStatsData>) => number;
|
||||
icon: React.ReactNode;
|
||||
suffix?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface QuickActionDef {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const ROLE_WELCOME: Record<DashboardRole, { title: string; subtitle: string }> = {
|
||||
doctor: { title: '今日工作台', subtitle: '患者概览与待办事项' },
|
||||
nurse: { title: '随访监控台', subtitle: '今日随访与体征上报' },
|
||||
admin: { title: '管理中心', subtitle: '平台运营数据概览' },
|
||||
operator: { title: '运营中心', subtitle: '积分、内容与活动' },
|
||||
};
|
||||
|
||||
const ROLE_STATS: Record<DashboardRole, StatCardDef[]> = {
|
||||
doctor: [
|
||||
{ key: 'my-patients', title: '我的患者', getValue: (p) => p?.my_patients ?? 0, icon: <TeamOutlined />, path: '/health/patients' },
|
||||
{ key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, icon: <CalendarOutlined />, path: '/health/appointments' },
|
||||
{ key: 'consultations', title: '本月咨询', getValue: (p) => p?.consultations_this_month ?? 0, icon: <MessageOutlined />, path: '/health/consultations' },
|
||||
{ key: 'followup-rate', title: '随访完成率', getValue: (p) => p?.follow_up_rate ?? 0, icon: <HeartOutlined />, suffix: '%', path: '/health/follow-ups' },
|
||||
],
|
||||
nurse: [
|
||||
{ key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, icon: <CalendarOutlined />, path: '/health/appointments' },
|
||||
{ key: 'today-followups', title: '今日随访', getValue: (p) => p?.today_follow_ups ?? 0, icon: <HeartOutlined />, path: '/health/follow-ups' },
|
||||
{ key: 'overdue', title: '逾期随访', getValue: (p) => p?.overdue_follow_ups ?? 0, icon: <AlertOutlined />, path: '/health/follow-ups' },
|
||||
{ key: 'vital-rate', title: '体征上报率', getValue: (p) => p?.vital_signs_report_rate ?? 0, icon: <MedicineBoxOutlined />, suffix: '%', path: '/health/vital-signs' },
|
||||
],
|
||||
admin: [
|
||||
{ key: 'patients', title: '患者总数', getValue: (_p, s) => s.patientStats?.total_patients ?? 0, icon: <TeamOutlined />, path: '/health/patients' },
|
||||
{ key: 'appointments', title: '本月预约', getValue: (_p, s) => s.healthDataStats?.appointments?.this_month ?? 0, icon: <CalendarOutlined />, path: '/health/appointments' },
|
||||
{ key: 'followup-rate', title: '随访完成率', getValue: (_p, s) => s.followUpStats?.completion_rate ?? 0, icon: <SafetyCertificateOutlined />, suffix: '%', path: '/health/follow-ups' },
|
||||
{ key: 'vital-rate', title: '体征上报率', getValue: (_p, s) => s.healthDataStats?.vital_signs_report_rate?.report_rate ?? 0, icon: <MedicineBoxOutlined />, suffix: '%', path: '/health/vital-signs' },
|
||||
],
|
||||
operator: [
|
||||
{ key: 'issued', title: '积分发放', getValue: (_p, s) => s.pointsStats?.total_issued ?? 0, icon: <TrophyOutlined />, path: '/health/points' },
|
||||
{ key: 'spent', title: '积分消费', getValue: (_p, s) => s.pointsStats?.total_spent ?? 0, icon: <ShoppingOutlined />, path: '/health/mall' },
|
||||
{ key: 'active', title: '活跃账户', getValue: (_p, s) => s.pointsStats?.active_accounts ?? 0, icon: <TeamOutlined />, path: '/health/points' },
|
||||
{ key: 'articles', title: '内容发布', getValue: () => 0, icon: <FileTextOutlined />, path: '/health/content' },
|
||||
],
|
||||
};
|
||||
|
||||
const ROLE_ACTIONS: Record<DashboardRole, QuickActionDef[]> = {
|
||||
doctor: [
|
||||
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
|
||||
{ icon: <CalendarOutlined />, label: '预约管理', path: '/health/appointments' },
|
||||
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-ups' },
|
||||
{ icon: <MessageOutlined />, label: '咨询管理', path: '/health/consultations' },
|
||||
{ icon: <AlertOutlined />, label: '告警中心', path: '/health/alert-dashboard' },
|
||||
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/statistics' },
|
||||
],
|
||||
nurse: [
|
||||
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-ups' },
|
||||
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/vital-signs' },
|
||||
{ icon: <CalendarOutlined />, label: '预约管理', path: '/health/appointments' },
|
||||
{ icon: <AlertOutlined />, label: '告警中心', path: '/health/alert-dashboard' },
|
||||
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
|
||||
{ icon: <SafetyCertificateOutlined />, label: '健康统计', path: '/health/statistics' },
|
||||
],
|
||||
admin: [
|
||||
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
|
||||
{ icon: <CalendarOutlined />, label: '预约管理', path: '/health/appointments' },
|
||||
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-ups' },
|
||||
{ icon: <MedicineBoxOutlined />, label: '健康数据', path: '/health/vital-signs' },
|
||||
{ icon: <TrophyOutlined />, label: '积分商城', path: '/health/points' },
|
||||
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings' },
|
||||
],
|
||||
operator: [
|
||||
{ icon: <TrophyOutlined />, label: '积分管理', path: '/health/points' },
|
||||
{ icon: <FileTextOutlined />, label: '内容管理', path: '/health/content' },
|
||||
{ icon: <CalendarOutlined />, label: '线下活动', path: '/health/events' },
|
||||
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
|
||||
{ icon: <SafetyCertificateOutlined />, label: '健康统计', path: '/health/statistics' },
|
||||
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings' },
|
||||
],
|
||||
};
|
||||
|
||||
// --- Components ---
|
||||
|
||||
function StatValue({ value, loading }: { value: number; loading: boolean }) {
|
||||
const animatedValue = useCountUp(value);
|
||||
if (loading) return <Spin size="small" />;
|
||||
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
userCount: 0,
|
||||
roleCount: 0,
|
||||
processInstanceCount: 0,
|
||||
unreadMessages: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const role = useDashboardRole();
|
||||
const isDark = useThemeMode();
|
||||
const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount);
|
||||
|
||||
const [personalStats, setPersonalStats] = useState<PersonalStats | null>(null);
|
||||
const [personalLoading, setPersonalLoading] = useState(true);
|
||||
const [pendingTasks, setPendingTasks] = useState<TaskInfo[]>([]);
|
||||
const [recentActivities, setRecentActivities] = useState<AuditLogItem[]>([]);
|
||||
const [activitiesLoading, setActivitiesLoading] = useState(true);
|
||||
const unreadCount = useMessageStore((s) => s.unreadCount);
|
||||
const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isDark = useThemeMode();
|
||||
const statsData = useStatsData();
|
||||
const loading = personalLoading || statsData.loading;
|
||||
|
||||
const welcome = ROLE_WELCOME[role];
|
||||
const statDefs = ROLE_STATS[role];
|
||||
const quickActions = ROLE_ACTIONS[role];
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadStats() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [usersRes, rolesRes, instancesRes] = await Promise.allSettled([
|
||||
client.get('/users', { params: { page: 1, page_size: 1 } }),
|
||||
client.get('/roles', { params: { page: 1, page_size: 1 } }),
|
||||
client.get('/workflow/instances', { params: { page: 1, page_size: 1 } }),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const extractTotal = (res: PromiseSettledResult<{ data: { data?: { total?: number } } }>) => {
|
||||
if (res.status !== 'fulfilled') return 0;
|
||||
const body = res.value.data;
|
||||
if (body && typeof body === 'object' && 'data' in body) {
|
||||
const inner = (body as { data?: { total?: number } }).data;
|
||||
return inner?.total ?? 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
setStats({
|
||||
userCount: extractTotal(usersRes),
|
||||
roleCount: extractTotal(rolesRes),
|
||||
processInstanceCount: extractTotal(instancesRes),
|
||||
unreadMessages: unreadCount,
|
||||
});
|
||||
} catch {
|
||||
// 静默处理
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const result = await listPendingTasks(1, 5);
|
||||
if (!cancelled) setPendingTasks(result.data);
|
||||
} catch {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActivities() {
|
||||
setActivitiesLoading(true);
|
||||
try {
|
||||
const result = await listAuditLogs({ page: 1, page_size: 5 });
|
||||
if (!cancelled) setRecentActivities(result.data.filter(a => a.action !== 'login_failed'));
|
||||
} catch {
|
||||
// 静默处理
|
||||
} finally {
|
||||
if (!cancelled) setActivitiesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchUnreadCount();
|
||||
loadStats();
|
||||
loadTasks();
|
||||
loadActivities();
|
||||
|
||||
if (role === 'doctor' || role === 'nurse') {
|
||||
pointsApi.getPersonalStats()
|
||||
.then((data) => { if (!cancelled) setPersonalStats(data); })
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!cancelled) setPersonalLoading(false); });
|
||||
} else {
|
||||
setPersonalLoading(false);
|
||||
}
|
||||
|
||||
listPendingTasks(1, 5)
|
||||
.then((result) => { if (!cancelled) setPendingTasks(result.data); })
|
||||
.catch(() => {});
|
||||
|
||||
listAuditLogs({ page: 1, page_size: 5 })
|
||||
.then((result) => {
|
||||
if (!cancelled) setRecentActivities(result.data.filter((a) => a.action !== 'login_failed'));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!cancelled) setActivitiesLoading(false); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [role]);
|
||||
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
navigate(path);
|
||||
}, [navigate]);
|
||||
|
||||
const statCards: StatCardConfig[] = [
|
||||
{
|
||||
key: 'users',
|
||||
title: '用户总数',
|
||||
value: stats.userCount,
|
||||
icon: <UserOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #2563eb, #60a5fa)',
|
||||
iconBg: 'rgba(79, 70, 229, 0.12)',
|
||||
delay: 'erp-fade-in erp-fade-in-delay-1',
|
||||
onClick: () => handleNavigate('/users'),
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
title: '角色数量',
|
||||
value: stats.roleCount,
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #059669, #10B981)',
|
||||
iconBg: 'rgba(5, 150, 105, 0.12)',
|
||||
delay: 'erp-fade-in erp-fade-in-delay-2',
|
||||
onClick: () => handleNavigate('/roles'),
|
||||
},
|
||||
{
|
||||
key: 'processes',
|
||||
title: '流程实例',
|
||||
value: stats.processInstanceCount,
|
||||
icon: <FileTextOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #d97706, #F59E0B)',
|
||||
iconBg: 'rgba(217, 119, 6, 0.12)',
|
||||
delay: 'erp-fade-in erp-fade-in-delay-3',
|
||||
onClick: () => handleNavigate('/workflow'),
|
||||
},
|
||||
{
|
||||
key: 'messages',
|
||||
title: '未读消息',
|
||||
value: stats.unreadMessages,
|
||||
icon: <BellOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)',
|
||||
iconBg: 'rgba(225, 29, 72, 0.12)',
|
||||
delay: 'erp-fade-in erp-fade-in-delay-4',
|
||||
onClick: () => handleNavigate('/messages'),
|
||||
},
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{ icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#2563eb' },
|
||||
{ icon: <SafetyCertificateOutlined />, label: '权限管理', path: '/roles', color: '#059669' },
|
||||
{ icon: <ApartmentOutlined />, label: '组织架构', path: '/organizations', color: '#d97706' },
|
||||
{ icon: <PartitionOutlined />, label: '工作流', path: '/workflow', color: '#7C3AED' },
|
||||
{ icon: <BellOutlined />, label: '消息中心', path: '/messages', color: '#E11D48' },
|
||||
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#475569' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 欢迎语 */}
|
||||
@@ -266,57 +219,55 @@ export default function Home() {
|
||||
<h2 style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
|
||||
color: isDark ? '#f8fafc' : 'var(--erp-text-primary)',
|
||||
margin: '0 0 4px',
|
||||
letterSpacing: '-0.5px',
|
||||
}}>
|
||||
工作台
|
||||
{welcome.title}
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: isDark ? '#94a3b8' : '#475569', margin: 0 }}>
|
||||
欢迎回来,这是您的系统概览
|
||||
<p style={{ fontSize: 14, color: 'var(--erp-text-secondary)', margin: 0 }}>
|
||||
{welcome.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片行 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
{statCards.map((card) => (
|
||||
<Col xs={24} sm={12} lg={6} key={card.key}>
|
||||
<div
|
||||
className={`erp-stat-card ${card.delay}`}
|
||||
style={{ '--card-gradient': card.gradient, '--card-icon-bg': card.iconBg } as React.CSSProperties}
|
||||
onClick={card.onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') card.onClick?.(); }}
|
||||
>
|
||||
<div className="erp-stat-card-bar" />
|
||||
<div className="erp-stat-card-body">
|
||||
<div className="erp-stat-card-info">
|
||||
<div className="erp-stat-card-title">{card.title}</div>
|
||||
<div className="erp-stat-card-value">
|
||||
<StatValue value={card.value} loading={loading} />
|
||||
{statDefs.map((def, i) => {
|
||||
const value = def.getValue(personalStats, statsData);
|
||||
return (
|
||||
<Col xs={24} sm={12} lg={6} key={def.key}>
|
||||
<div
|
||||
className={`erp-stat-card erp-fade-in erp-fade-in-delay-${i + 1}`}
|
||||
onClick={() => handleNavigate(def.path)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(def.path); }}
|
||||
>
|
||||
<div className="erp-stat-card-bar" />
|
||||
<div className="erp-stat-card-body">
|
||||
<div className="erp-stat-card-info">
|
||||
<div className="erp-stat-card-title">{def.title}</div>
|
||||
<div className="erp-stat-card-value">
|
||||
<StatValue value={value} loading={loading} />
|
||||
{def.suffix && <span style={{ fontSize: 14, marginLeft: 2, color: 'var(--erp-text-tertiary)' }}>{def.suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="erp-stat-card-icon">{def.icon}</div>
|
||||
</div>
|
||||
<div className="erp-stat-card-icon">{card.icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* 待办任务 + 最近活动 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
{/* 待办任务 */}
|
||||
<Col xs={24} lg={14}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2">
|
||||
<div className="erp-section-header">
|
||||
<CheckCircleOutlined className="erp-section-icon" style={{ color: '#2563eb' }} />
|
||||
<CheckCircleOutlined className="erp-section-icon" />
|
||||
<span className="erp-section-title">待办任务</span>
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 12,
|
||||
color: isDark ? '#94a3b8' : '#475569',
|
||||
}}>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--erp-text-secondary)' }}>
|
||||
{pendingTasks.length} 项待处理
|
||||
</span>
|
||||
</div>
|
||||
@@ -328,7 +279,7 @@ export default function Home() {
|
||||
<div
|
||||
key={task.id}
|
||||
className="erp-task-item"
|
||||
style={{ '--task-color': '#2563eb' } as React.CSSProperties}
|
||||
style={{ '--task-color': 'var(--erp-primary)' } as React.CSSProperties}
|
||||
onClick={() => handleNavigate('/workflow')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -343,7 +294,7 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="erp-task-priority erp-task-priority-medium">一般</span>
|
||||
<RightOutlined style={{ color: isDark ? '#475569' : '#CBD5E1', fontSize: 12 }} />
|
||||
<RightOutlined style={{ color: 'var(--erp-text-tertiary)', fontSize: 12 }} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -351,11 +302,10 @@ export default function Home() {
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{/* 最近活动 */}
|
||||
<Col xs={24} lg={10}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3" style={{ height: '100%' }}>
|
||||
<div className="erp-section-header">
|
||||
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#60a5fa' }} />
|
||||
<ClockCircleOutlined className="erp-section-icon" />
|
||||
<span className="erp-section-title">最近动态</span>
|
||||
</div>
|
||||
<div className="erp-activity-list">
|
||||
@@ -383,9 +333,9 @@ export default function Home() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 快捷入口 + 系统信息 */}
|
||||
{/* 快捷入口 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Col span={24}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3">
|
||||
<div className="erp-section-header">
|
||||
<ThunderboltOutlined className="erp-section-icon" />
|
||||
@@ -393,10 +343,10 @@ export default function Home() {
|
||||
</div>
|
||||
<Row gutter={[12, 12]}>
|
||||
{quickActions.map((action) => (
|
||||
<Col xs={12} sm={8} md={8} key={action.path}>
|
||||
<Col xs={12} sm={8} md={4} key={action.path}>
|
||||
<div
|
||||
className="erp-quick-action"
|
||||
style={{ '--action-color': action.color } as React.CSSProperties}
|
||||
style={{ '--action-color': 'var(--erp-primary)' } as React.CSSProperties}
|
||||
onClick={() => handleNavigate(action.path)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -410,30 +360,6 @@ export default function Home() {
|
||||
</Row>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={8}>
|
||||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{ height: '100%' }}>
|
||||
<div className="erp-section-header">
|
||||
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#60a5fa' }} />
|
||||
<span className="erp-section-title">系统信息</span>
|
||||
</div>
|
||||
<div className="erp-system-info-list">
|
||||
{[
|
||||
{ label: '系统版本', value: 'v0.1.0' },
|
||||
{ label: '后端框架', value: 'Axum 0.8 + Tokio' },
|
||||
{ label: '数据库', value: 'PostgreSQL 18' },
|
||||
{ label: '缓存', value: 'Redis 7' },
|
||||
{ label: '前端框架', value: 'React 19 + Ant Design 6' },
|
||||
{ label: '模块数量', value: '6 个业务模块' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="erp-system-info-item">
|
||||
<span className="erp-system-info-label">{item.label}</span>
|
||||
<span className="erp-system-info-value">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user