Files
erp/apps/web/src/pages/Home.tsx
iven 8f3d2d58e7
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
feat(web): 采用 Notion 设计系统 — 暖色调 + 白色侧边栏 + Inter 字体
引入 Notion 风格的 DESIGN.md 设计系统文件,并全面重构前端 UI:

- 主色从 Indigo (#4F46E5) 迁移到 Notion Blue (#0075de)
- 页面背景从冷灰 (#F1F5F9) 迁移到暖白 (#f6f5f4)
- 侧边栏从深色 (#0F172A) 迁移到白色,活跃项用蓝色指示
- 文字从 Slate 冷色迁移到暖灰系列 (Warm Gray 500/300)
- 圆角从 8px 缩小到 4px(按钮/输入),8px(卡片)
- 阴影改为多层超轻 Notion 风格(最大 opacity 0.05)
- 字体优先使用 Inter,保留中文回退
- 暗色模式适配暖黑色调 (#191918)
- 更新 27 个前端文件的硬编码颜色值
2026-04-20 13:08:22 +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, #0075de, #62aef0)',
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, #1aae39, #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, #dd5b00, #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: '#0075de' },
{ icon: <SafetyCertificateOutlined />, label: '权限管理', path: '/roles', color: '#1aae39' },
{ icon: <ApartmentOutlined />, label: '组织架构', path: '/organizations', color: '#dd5b00' },
{ icon: <PartitionOutlined />, label: '工作流', path: '/workflow', color: '#7C3AED' },
{ icon: <BellOutlined />, label: '消息中心', path: '/messages', color: '#E11D48' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#615d59' },
];
const pendingTasks: TaskItem[] = [
{ id: '1', title: '审核新用户注册申请', priority: 'high', assignee: '系统', dueText: '待处理', color: '#e5534b', icon: <UserOutlined />, path: '/users' },
{ id: '2', title: '配置工作流审批节点', priority: 'medium', assignee: '管理员', dueText: '进行中', color: '#dd5b00', icon: <PartitionOutlined />, path: '/workflow' },
{ id: '3', title: '更新角色权限策略', priority: 'low', assignee: '管理员', dueText: '计划中', color: '#1aae39', 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 ? '#f6f5f4' : 'rgba(0,0,0,0.95)',
margin: '0 0 4px',
letterSpacing: '-0.5px',
}}>
</h2>
<p style={{ fontSize: 14, color: isDark ? '#a39e98' : '#615d59', 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 ? '#a39e98' : '#615d59',
}}>
{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 ? '#615d59' : '#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: '#62aef0' }} />
<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: '#62aef0' }} />
<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>
);
}