Files
erp/apps/web/src/pages/Home.tsx
iven 5ba11f985f
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(web,plugin): 前端审计修复 — 401 消除 + 统计卡片 crash + 销售漏斗 500 + antd 6 废弃 API
- API client: proactive token refresh(请求前 30s 检查过期,提前刷新避免 401)
- Plugin store: fetchPlugins promise 去重,防止 StrictMode 并发重复请求
- Home stats: 简化 useEffect 加载逻辑,修复 tagColor undefined crash
- PluginGraphPage: valueStyle → styles.content, Spin tip → description(antd 6)
- DashboardWidgets: trailColor → railColor(antd 6)
- data_service: build_scope_sql 参数索引修复(硬编码 $100 → 动态 values.len()+1)
- erp-core error: Internal 错误添加 tracing::error 日志输出
2026-04-18 20:31:49 +08:00

427 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useCallback, useRef } from 'react';
import { Row, Col, Spin, theme } from 'antd';
import {
UserOutlined,
SafetyCertificateOutlined,
FileTextOutlined,
BellOutlined,
ThunderboltOutlined,
SettingOutlined,
PartitionOutlined,
ClockCircleOutlined,
ApartmentOutlined,
CheckCircleOutlined,
TeamOutlined,
FileProtectOutlined,
RiseOutlined,
FallOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import client from '../api/client';
import { useMessageStore } from '../stores/message';
interface DashboardStats {
userCount: number;
roleCount: number;
processInstanceCount: number;
unreadMessages: number;
}
interface TrendData {
value: string;
direction: 'up' | 'down' | 'neutral';
label: string;
}
interface StatCardConfig {
key: string;
title: string;
value: number;
icon: React.ReactNode;
gradient: string;
iconBg: string;
delay: string;
trend: TrendData;
sparkline: number[];
onClick?: () => void;
}
interface TaskItem {
id: string;
title: string;
priority: 'high' | 'medium' | 'low';
assignee: string;
dueText: string;
color: string;
icon: React.ReactNode;
path: string;
}
interface ActivityItem {
id: string;
text: string;
time: string;
icon: React.ReactNode;
}
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>;
}
export default function Home() {
const [stats, setStats] = useState<DashboardStats>({
userCount: 0,
roleCount: 0,
processInstanceCount: 0,
unreadMessages: 0,
});
const [loading, setLoading] = useState(true);
const unreadCount = useMessageStore((s) => s.unreadCount);
const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount);
const { token } = theme.useToken();
const navigate = useNavigate();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
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);
}
}
fetchUnreadCount();
loadStats();
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, #4F46E5, #6366F1)',
iconBg: 'rgba(79, 70, 229, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-1',
trend: { value: '+2', direction: 'up', label: '较上周' },
sparkline: [30, 45, 35, 50, 40, 55, 60, 50, 65, 70],
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',
trend: { value: '+1', direction: 'up', label: '较上月' },
sparkline: [20, 25, 30, 28, 35, 40, 38, 42, 45, 50],
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',
trend: { value: '0', direction: 'neutral', label: '较昨日' },
sparkline: [10, 15, 12, 20, 18, 25, 22, 28, 24, 20],
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',
trend: { value: '0', direction: 'neutral', label: '全部已读' },
sparkline: [5, 8, 3, 10, 6, 12, 8, 4, 7, 5],
onClick: () => handleNavigate('/messages'),
},
];
const quickActions = [
{ icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#4F46E5' },
{ 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: '#64748B' },
];
const pendingTasks: TaskItem[] = [
{ id: '1', title: '审核新用户注册申请', priority: 'high', assignee: '系统', dueText: '待处理', color: '#DC2626', icon: <UserOutlined />, path: '/users' },
{ id: '2', title: '配置工作流审批节点', priority: 'medium', assignee: '管理员', dueText: '进行中', color: '#D97706', icon: <PartitionOutlined />, path: '/workflow' },
{ id: '3', title: '更新角色权限策略', priority: 'low', assignee: '管理员', dueText: '计划中', color: '#059669', icon: <SafetyCertificateOutlined />, path: '/roles' },
];
const recentActivities: ActivityItem[] = [
{ id: '1', text: '系统管理员 创建了 管理员角色', time: '刚刚', icon: <TeamOutlined /> },
{ id: '2', text: '系统管理员 配置了 工作流模板', time: '5 分钟前', icon: <FileProtectOutlined /> },
{ id: '3', text: '系统管理员 更新了 组织架构', time: '10 分钟前', icon: <ApartmentOutlined /> },
{ id: '4', text: '系统管理员 设置了 消息通知偏好', time: '30 分钟前', icon: <BellOutlined /> },
];
const priorityLabel: Record<string, string> = { high: '紧急', medium: '一般', low: '低' };
return (
<div>
{/* 欢迎语 */}
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
<h2 style={{
fontSize: 24,
fontWeight: 700,
color: isDark ? '#F1F5F9' : '#0F172A',
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) => {
const maxSpark = Math.max(...card.sparkline, 1);
return (
<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 className={`erp-stat-card-trend erp-stat-card-trend-${card.trend.direction}`}>
{card.trend.direction === 'up' && <RiseOutlined />}
{card.trend.direction === 'down' && <FallOutlined />}
<span>{card.trend.value}</span>
<span className="erp-stat-card-trend-label">{card.trend.label}</span>
</div>
</div>
<div className="erp-stat-card-icon">{card.icon}</div>
</div>
<div className="erp-stat-card-sparkline">
{card.sparkline.map((v, i) => (
<div
key={i}
className="erp-stat-card-sparkline-bar"
style={{
height: `${(v / maxSpark) * 100}%`,
background: card.gradient,
}}
/>
))}
</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: '#E11D48' }} />
<span className="erp-section-title"></span>
<span style={{
marginLeft: 'auto',
fontSize: 12,
color: isDark ? '#94A3B8' : '#64748B',
}}>
{pendingTasks.length}
</span>
</div>
<div className="erp-task-list">
{pendingTasks.map((task) => (
<div
key={task.id}
className="erp-task-item"
style={{ '--task-color': task.color } as React.CSSProperties}
onClick={() => handleNavigate(task.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(task.path); }}
>
<div className="erp-task-item-icon">{task.icon}</div>
<div className="erp-task-item-content">
<div className="erp-task-item-title">{task.title}</div>
<div className="erp-task-item-meta">
<span>{task.assignee}</span>
<span>{task.dueText}</span>
</div>
</div>
<span className={`erp-task-priority erp-task-priority-${task.priority}`}>
{priorityLabel[task.priority]}
</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: '#6366F1' }} />
<span className="erp-section-title"></span>
</div>
<div className="erp-activity-list">
{recentActivities.map((activity) => (
<div key={activity.id} className="erp-activity-item">
<div className="erp-activity-dot">{activity.icon}</div>
<div className="erp-activity-content">
<div className="erp-activity-text">{activity.text}</div>
<div className="erp-activity-time">{activity.time}</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: '#6366F1' }} />
<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 16' },
{ label: '缓存', value: 'Redis 7' },
{ label: '前端框架', value: 'React 19 + Ant Design 6' },
{ label: '模块数量', value: '5 个业务模块' },
].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>
);
}