feat(web): 登录页主题适配 + 工作台角色化重构
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

- 登录页接入 4 套主题系统(渐变色/面板背景/文字色),添加 ThemeSwitcher
- 工作台按角色(医生/护士/管理员/运营)显示专属统计卡片和快捷入口
- 移除系统信息填充卡片,硬编码颜色替换为 CSS 变量
This commit is contained in:
iven
2026-04-29 07:27:04 +08:00
parent 202c6dd0d2
commit 9015a2b85e
3 changed files with 388 additions and 398 deletions

View File

@@ -265,6 +265,166 @@
--erp-font-heading: 'Noto Sans SC', -apple-system, system-ui, sans-serif;
}
/* --- Login Page Tokens --- */
:root {
--login-gradient-start: #312E81;
--login-gradient-mid: #2563eb;
--login-gradient-end: #60a5fa;
--login-form-bg: #ffffff;
--login-form-text: #0f172a;
--login-form-text-secondary: #475569;
--login-input-icon-color: #94a3b8;
}
[data-theme='warm'] {
--login-gradient-start: #6B3418;
--login-gradient-mid: #C4623A;
--login-gradient-end: #D4956A;
--login-form-bg: #FAF5F0;
--login-form-text: #2D2A26;
--login-form-text-secondary: #7A756E;
--login-input-icon-color: #A8A29E;
}
[data-theme='dark'] {
--login-gradient-start: #0F172A;
--login-gradient-mid: #1E293B;
--login-gradient-end: #334155;
--login-form-bg: #1E293B;
--login-form-text: rgba(255,255,255,0.95);
--login-form-text-secondary: #94A3B8;
--login-input-icon-color: #64748B;
}
[data-theme='emerald'] {
--login-gradient-start: #2D4A2F;
--login-gradient-mid: #5B7A5E;
--login-gradient-end: #8FB092;
--login-form-bg: #F4F7F4;
--login-form-text: #1A2E1A;
--login-form-text-secondary: #5A6E5A;
--login-input-icon-color: #8FA08F;
}
/* --- Login Page Styles --- */
.login-root {
display: flex;
min-height: 100vh;
}
.login-brand-panel {
flex: 1;
background: linear-gradient(135deg, var(--login-gradient-start) 0%, var(--login-gradient-mid) 50%, var(--login-gradient-end) 100%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 60px;
position: relative;
overflow: hidden;
}
.login-brand-panel .deco-circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
}
.login-brand-panel .brand-icon {
width: 64px;
height: 64px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 32px;
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.login-brand-panel .brand-icon .anticon {
font-size: 32px;
color: #fff;
}
.login-brand-panel .brand-title {
color: #fff;
font-size: 36px;
font-weight: 800;
margin: 0 0 16px;
letter-spacing: -1px;
line-height: 1.2;
}
.login-brand-panel .brand-desc {
color: rgba(255, 255, 255, 0.7);
font-size: 16px;
line-height: 1.6;
margin: 0;
}
.login-brand-panel .brand-sub-desc {
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
line-height: 1.6;
margin-top: 8px;
}
.login-brand-panel .feature-item-value {
color: rgba(255, 255, 255, 0.9);
font-size: 18px;
font-weight: 700;
}
.login-brand-panel .feature-item-label {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
margin-top: 4px;
}
.login-form-panel {
width: 480px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 60px;
background: var(--login-form-bg);
position: relative;
transition: background var(--erp-transition-slow);
}
.login-form-panel .form-title {
margin-bottom: 4px;
font-weight: 700;
font-size: 24px;
color: var(--login-form-text);
}
.login-form-panel .form-subtitle {
font-size: 14px;
color: var(--login-form-text-secondary);
}
.login-form-panel .form-footer {
margin-top: 32px;
text-align: center;
font-size: 12px;
color: var(--login-form-text-secondary);
}
.login-theme-switcher {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
.login-theme-switcher .erp-header-btn {
background: var(--login-form-bg);
border: 1px solid var(--erp-border);
color: var(--login-form-text-secondary);
}
/* --- Global Reset & Base --- */
body {

View File

@@ -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>
);

View File

@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, message, Divider } from 'antd';
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import { useAuthStore } from '../stores/auth';
import ThemeSwitcher from '../components/ThemeSwitcher';
export default function Login() {
const navigate = useNavigate();
@@ -23,100 +24,23 @@ export default function Login() {
};
return (
<div style={{ display: 'flex', minHeight: '100vh' }}>
<div className="login-root">
{contextHolder}
{/* 左侧品牌展示区 */}
<div
style={{
flex: 1,
background: 'linear-gradient(135deg, #312E81 0%, #2563eb 50%, #60a5fa 100%)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: '60px',
position: 'relative',
overflow: 'hidden',
}}
>
{/* 装饰性背景元素 */}
<div
style={{
position: 'absolute',
top: '-20%',
right: '-10%',
width: '500px',
height: '500px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.05)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '-15%',
left: '-8%',
width: '400px',
height: '400px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.03)',
}}
/>
<div className="login-brand-panel">
<div className="deco-circle" style={{ top: '-20%', right: '-10%', width: 500, height: 500 }} />
<div className="deco-circle" style={{ bottom: '-15%', left: '-8%', width: 400, height: 400, background: 'rgba(255, 255, 255, 0.03)' }} />
{/* 品牌内容 */}
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center', maxWidth: '480px' }}>
<div
style={{
width: 64,
height: 64,
borderRadius: 16,
background: 'rgba(255, 255, 255, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 32px',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255, 255, 255, 0.2)',
}}
>
<SafetyCertificateOutlined style={{ fontSize: 32, color: '#fff' }} />
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center', maxWidth: 480 }}>
<div className="brand-icon">
<SafetyCertificateOutlined />
</div>
<h1
style={{
color: '#fff',
fontSize: 36,
fontWeight: 800,
margin: '0 0 16px',
letterSpacing: '-1px',
lineHeight: 1.2,
}}
>
ERP Platform
</h1>
<p
style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 16,
lineHeight: 1.6,
margin: 0,
}}
>
</p>
<p
style={{
color: 'rgba(255, 255, 255, 0.5)',
fontSize: 14,
lineHeight: 1.6,
marginTop: 8,
}}
>
· · ·
</p>
<h1 className="brand-title">HMR Platform</h1>
<p className="brand-desc"></p>
<p className="brand-sub-desc"> · · · </p>
{/* 底部特性点 */}
<div style={{ marginTop: 48, display: 'flex', gap: 32, justifyContent: 'center' }}>
{[
{ label: '多租户架构', value: 'SaaS' },
@@ -124,12 +48,8 @@ export default function Login() {
{ label: '事件驱动', value: '可扩展' },
].map((item) => (
<div key={item.label} style={{ textAlign: 'center' }}>
<div style={{ color: 'rgba(255, 255, 255, 0.9)', fontSize: 18, fontWeight: 700 }}>
{item.value}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 12, marginTop: 4 }}>
{item.label}
</div>
<div className="feature-item-value">{item.value}</div>
<div className="feature-item-label">{item.label}</div>
</div>
))}
</div>
@@ -137,23 +57,14 @@ export default function Login() {
</div>
{/* 右侧登录表单区 */}
<main
style={{
width: 480,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '60px',
background: '#fff',
}}
>
<main className="login-form-panel">
<div className="login-theme-switcher">
<ThemeSwitcher />
</div>
<div style={{ maxWidth: 360, width: '100%', margin: '0 auto' }}>
<h2 style={{ marginBottom: 4, fontWeight: 700, fontSize: 24 }}>
</h2>
<p style={{ fontSize: 14, color: '#475569' }}>
</p>
<h2 className="form-title"></h2>
<p className="form-subtitle"></p>
<Divider style={{ margin: '24px 0' }} />
@@ -163,9 +74,9 @@ export default function Login() {
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined style={{ color: '#94a3b8' }} />}
prefix={<UserOutlined style={{ color: 'var(--login-input-icon-color)' }} />}
placeholder="用户名"
style={{ height: 44, borderRadius: 10 }}
style={{ height: 44, borderRadius: 'var(--erp-radius-md)' }}
/>
</Form.Item>
<Form.Item
@@ -173,9 +84,9 @@ export default function Login() {
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#94a3b8' }} />}
prefix={<LockOutlined style={{ color: 'var(--login-input-icon-color)' }} />}
placeholder="密码"
style={{ height: 44, borderRadius: 10 }}
style={{ height: 44, borderRadius: 'var(--erp-radius-md)' }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
@@ -184,22 +95,15 @@ export default function Login() {
htmlType="submit"
loading={loading}
block
style={{
height: 44,
borderRadius: 10,
fontSize: 15,
fontWeight: 600,
}}
style={{ height: 44, borderRadius: 'var(--erp-radius-md)', fontSize: 15, fontWeight: 600 }}
>
</Button>
</Form.Item>
</Form>
<div style={{ marginTop: 32, textAlign: 'center' }}>
<p style={{ fontSize: 12, color: '#475569', margin: 0 }}>
ERP Platform · Powered by Rust + React
</p>
<div className="form-footer">
HMR Platform · ©
</div>
</div>
</main>