feat(web): 登录页主题适配 + 工作台角色化重构
- 登录页接入 4 套主题系统(渐变色/面板背景/文字色),添加 ThemeSwitcher - 工作台按角色(医生/护士/管理员/运营)显示专属统计卡片和快捷入口 - 移除系统信息填充卡片,硬编码颜色替换为 CSS 变量
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user