- refactor(Home): 待办任务改为从工作流API获取真实数据 - refactor(Home): 最近动态改为从审计日志API获取真实操作记录 - refactor(Home): 移除硬编码的sparkline趋势图和假统计数据 - fix(Home): 系统信息 PostgreSQL 16→18,模块数量 5→6 - fix(Login): 移除硬编码版本号 v0.1.0 - fix(MainLayout): Footer 更新为 "HMS 健康管理平台" - fix(dev.ps1): 添加缺失的 WECHAT/HEALTH 环境变量
441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
import { useEffect, useState, useCallback, useRef } from 'react';
|
||
import { Row, Col, Spin, Empty } from 'antd';
|
||
import {
|
||
UserOutlined,
|
||
SafetyCertificateOutlined,
|
||
FileTextOutlined,
|
||
BellOutlined,
|
||
ThunderboltOutlined,
|
||
SettingOutlined,
|
||
PartitionOutlined,
|
||
ClockCircleOutlined,
|
||
ApartmentOutlined,
|
||
CheckCircleOutlined,
|
||
RightOutlined,
|
||
} from '@ant-design/icons';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import client from '../api/client';
|
||
import { useThemeMode } from '../hooks/useThemeMode';
|
||
import { useMessageStore } from '../stores/message';
|
||
import { listAuditLogs, type AuditLogItem } from '../api/auditLogs';
|
||
import { listPendingTasks, type TaskInfo } from '../api/workflowTasks';
|
||
|
||
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: '创建', update: '更新', delete: '删除',
|
||
login: '登录', 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 />,
|
||
};
|
||
|
||
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 < 60) return `${minutes} 分钟前`;
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return `${hours} 小时前`;
|
||
const days = Math.floor(hours / 24);
|
||
return `${days} 天前`;
|
||
}
|
||
|
||
function formatActionLabel(action: string): string {
|
||
if (ACTION_LABELS[action]) return ACTION_LABELS[action];
|
||
const lastPart = action.split('.').pop() || action;
|
||
return ACTION_LABELS[lastPart] || lastPart;
|
||
}
|
||
|
||
function formatResourceLabel(resource: string): string {
|
||
if (RESOURCE_LABELS[resource]) return RESOURCE_LABELS[resource];
|
||
const lastPart = resource.split('.').pop() || resource;
|
||
return RESOURCE_LABELS[lastPart] || lastPart;
|
||
}
|
||
|
||
export default function Home() {
|
||
const [stats, setStats] = useState<DashboardStats>({
|
||
userCount: 0,
|
||
roleCount: 0,
|
||
processInstanceCount: 0,
|
||
unreadMessages: 0,
|
||
});
|
||
const [loading, setLoading] = 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();
|
||
|
||
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);
|
||
} catch {
|
||
// 静默处理
|
||
} finally {
|
||
if (!cancelled) setActivitiesLoading(false);
|
||
}
|
||
}
|
||
|
||
fetchUnreadCount();
|
||
loadStats();
|
||
loadTasks();
|
||
loadActivities();
|
||
|
||
return () => { cancelled = true; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
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>
|
||
{/* 欢迎语 */}
|
||
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
|
||
<h2 style={{
|
||
fontSize: 24,
|
||
fontWeight: 700,
|
||
color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
|
||
margin: '0 0 4px',
|
||
letterSpacing: '-0.5px',
|
||
}}>
|
||
工作台
|
||
</h2>
|
||
<p style={{ fontSize: 14, color: isDark ? '#94a3b8' : '#475569', margin: 0 }}>
|
||
欢迎回来,这是您的系统概览
|
||
</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} />
|
||
</div>
|
||
</div>
|
||
<div className="erp-stat-card-icon">{card.icon}</div>
|
||
</div>
|
||
</div>
|
||
</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' }} />
|
||
<span className="erp-section-title">待办任务</span>
|
||
<span style={{
|
||
marginLeft: 'auto',
|
||
fontSize: 12,
|
||
color: isDark ? '#94a3b8' : '#475569',
|
||
}}>
|
||
{pendingTasks.length} 项待处理
|
||
</span>
|
||
</div>
|
||
<div className="erp-task-list">
|
||
{pendingTasks.length === 0 ? (
|
||
<Empty description="暂无待办任务" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||
) : (
|
||
pendingTasks.map((task) => (
|
||
<div
|
||
key={task.id}
|
||
className="erp-task-item"
|
||
style={{ '--task-color': '#2563eb' } as React.CSSProperties}
|
||
onClick={() => handleNavigate('/workflow')}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate('/workflow'); }}
|
||
>
|
||
<div className="erp-task-item-icon"><PartitionOutlined /></div>
|
||
<div className="erp-task-item-content">
|
||
<div className="erp-task-item-title">{task.node_name || task.definition_name || '流程任务'}</div>
|
||
<div className="erp-task-item-meta">
|
||
<span>{task.definition_name || '工作流'}</span>
|
||
<span>{task.status === 'pending' ? '待处理' : task.status}</span>
|
||
</div>
|
||
</div>
|
||
<span className="erp-task-priority erp-task-priority-medium">一般</span>
|
||
<RightOutlined style={{ color: isDark ? '#475569' : '#CBD5E1', fontSize: 12 }} />
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</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' }} />
|
||
<span className="erp-section-title">最近动态</span>
|
||
</div>
|
||
<div className="erp-activity-list">
|
||
{activitiesLoading ? (
|
||
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
|
||
) : recentActivities.length === 0 ? (
|
||
<Empty description="暂无动态" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||
) : (
|
||
recentActivities.map((log) => (
|
||
<div key={log.id} className="erp-activity-item">
|
||
<div className="erp-activity-dot">
|
||
{RESOURCE_ICONS[log.resource_type] || <FileTextOutlined />}
|
||
</div>
|
||
<div className="erp-activity-content">
|
||
<div className="erp-activity-text">
|
||
{formatActionLabel(log.action)}了{formatResourceLabel(log.resource_type)}
|
||
</div>
|
||
<div className="erp-activity-time">{formatTimeAgo(log.created_at)}</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 快捷入口 + 系统信息 */}
|
||
<Row gutter={[16, 16]}>
|
||
<Col xs={24} lg={16}>
|
||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3">
|
||
<div className="erp-section-header">
|
||
<ThunderboltOutlined className="erp-section-icon" />
|
||
<span className="erp-section-title">快捷入口</span>
|
||
</div>
|
||
<Row gutter={[12, 12]}>
|
||
{quickActions.map((action) => (
|
||
<Col xs={12} sm={8} md={8} key={action.path}>
|
||
<div
|
||
className="erp-quick-action"
|
||
style={{ '--action-color': action.color } as React.CSSProperties}
|
||
onClick={() => handleNavigate(action.path)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(action.path); }}
|
||
>
|
||
<div className="erp-quick-action-icon">{action.icon}</div>
|
||
<span className="erp-quick-action-label">{action.label}</span>
|
||
</div>
|
||
</Col>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|