feat: 添加管理端前端 (HMS 基座 React 管理面板)
- 从 HMS 基座复制 apps/web/ (React + Ant Design + Vite + TypeScript) - 管理端自动代理 API 到 localhost:3000 (vite.config.ts) - 更新 scripts/dev.sh 支持三端启动: backend/admin/app - 登录验证通过, 用户管理/角色权限/审计日志等页面正常 - 添加 .gitignore 排除 node_modules/dist
This commit is contained in:
443
apps/web/src/pages/Home.tsx
Normal file
443
apps/web/src/pages/Home.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Row, Col, Spin, Empty } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
CalendarOutlined,
|
||||
HeartOutlined,
|
||||
MedicineBoxOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
MessageOutlined,
|
||||
BellOutlined,
|
||||
AlertOutlined,
|
||||
TrophyOutlined,
|
||||
ShoppingOutlined,
|
||||
FileTextOutlined,
|
||||
RightOutlined,
|
||||
PartitionOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
SettingOutlined,
|
||||
ApartmentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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';
|
||||
import ActionDetailDrawer from './health/components/workbench/ActionDetailDrawer';
|
||||
import TaskQueue from './health/components/workbench/TaskQueue';
|
||||
import TaskDetail from './health/components/workbench/TaskDetail';
|
||||
import DoctorWorkbench from './health/components/workbench/DoctorWorkbench';
|
||||
import NurseWorkbench from './health/components/workbench/NurseWorkbench';
|
||||
import OperatorWorkbench from './health/components/workbench/OperatorWorkbench';
|
||||
import AdminDashboard from './health/components/workbench/AdminDashboard';
|
||||
import type { ActionItem } from '../api/health/actionInbox';
|
||||
|
||||
// --- Shared utilities ---
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return '刚刚';
|
||||
if (minutes < 60) return `${minutes} 分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} 天前`;
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Role configs ---
|
||||
|
||||
interface StatCardDef {
|
||||
key: string;
|
||||
title: string;
|
||||
getValue: (p: PersonalStats | null, s: ReturnType<typeof useStatsData>) => number;
|
||||
getDiff?: (p: PersonalStats | null) => number | undefined;
|
||||
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: '患者概览与待办事项' },
|
||||
health_manager: { title: '任务工作台', subtitle: '待处理任务与患者管理' },
|
||||
nurse: { title: '随访监控台', subtitle: '今日随访与体征上报' },
|
||||
admin: { title: '管理中心', subtitle: '平台运营数据概览' },
|
||||
operator: { title: '运营中心', subtitle: '积分、内容与活动' },
|
||||
};
|
||||
|
||||
const STAT_BAR_COLORS: string[] = [
|
||||
'linear-gradient(90deg, #2563EB, #60A5FA)',
|
||||
'linear-gradient(90deg, #7C3AED, #A78BFA)',
|
||||
'linear-gradient(90deg, #DC2626, #F87171)',
|
||||
'linear-gradient(90deg, #D97706, #FBBF24)',
|
||||
];
|
||||
const STAT_TEXT_COLORS: string[] = ['#2563EB', '#7C3AED', '#DC2626', '#D97706'];
|
||||
|
||||
const ROLE_STATS: Record<DashboardRole, StatCardDef[]> = {
|
||||
doctor: [
|
||||
{ key: 'my-patients', title: '我的患者', getValue: (p) => p?.my_patients ?? 0, getDiff: (p) => { const c = p?.my_patients, y = p?.yesterday_my_patients; return c != null && y != null ? c - y : undefined; }, icon: <TeamOutlined />, path: '/health/patients' },
|
||||
{ key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, getDiff: (p) => { const c = p?.today_appointments, y = p?.yesterday_today_appointments; return c != null && y != null ? c - y : undefined; }, icon: <CalendarOutlined />, path: '/health/appointments' },
|
||||
{ key: 'consultations', title: '本月咨询', getValue: (p) => p?.consultations_this_month ?? 0, getDiff: (p) => { const c = p?.consultations_this_month, y = p?.yesterday_consultations_this_month; return c != null && y != null ? c - y : undefined; }, icon: <MessageOutlined />, path: '/health/consultations' },
|
||||
{ key: 'followup-rate', title: '随访完成率', getValue: (p) => p?.follow_up_rate ?? 0, icon: <HeartOutlined />, suffix: '%', path: '/health/follow-ups' },
|
||||
],
|
||||
health_manager: [
|
||||
{ key: 'today-followups', title: '今日随访', getValue: (p) => p?.today_follow_ups ?? 0, getDiff: (p) => { const c = p?.today_follow_ups, y = p?.yesterday_today_follow_ups; return c != null && y != null ? c - y : undefined; }, icon: <HeartOutlined />, path: '/health/follow-up-tasks' },
|
||||
{ key: 'vital-anomaly', title: '体征异常', getValue: (p) => p?.overdue_follow_ups ?? 0, icon: <AlertOutlined />, path: '/health/alert-dashboard' },
|
||||
{ key: 'ai-pending', title: 'AI 建议待审', getValue: (p) => p?.consultations_this_month ?? 0, icon: <MedicineBoxOutlined />, path: '/health/ai-analysis' },
|
||||
{ key: 'followup-rate', title: '处理率', getValue: (p) => p?.follow_up_rate ?? 0, icon: <CheckCircleOutlined />, suffix: '%', path: '/health/follow-up-tasks' },
|
||||
],
|
||||
nurse: [
|
||||
{ key: 'today-appointments', title: '今日预约', getValue: (p) => p?.today_appointments ?? 0, getDiff: (p) => { const c = p?.today_appointments, y = p?.yesterday_today_appointments; return c != null && y != null ? c - y : undefined; }, icon: <CalendarOutlined />, path: '/health/appointments' },
|
||||
{ key: 'today-followups', title: '今日随访', getValue: (p) => p?.today_follow_ups ?? 0, getDiff: (p) => { const c = p?.today_follow_ups, y = p?.yesterday_today_follow_ups; return c != null && y != null ? c - y : undefined; }, icon: <HeartOutlined />, path: '/health/follow-ups' },
|
||||
{ key: 'overdue', title: '逾期随访', getValue: (p) => p?.overdue_follow_ups ?? 0, getDiff: (p) => { const c = p?.overdue_follow_ups, y = p?.yesterday_overdue_follow_ups; return c != null && y != null ? c - y : undefined; }, 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: (_p, s) => s.patientStats?.total_patients ?? 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' },
|
||||
],
|
||||
health_manager: [
|
||||
{ icon: <HeartOutlined />, label: '随访管理', path: '/health/follow-up-tasks' },
|
||||
{ icon: <MedicineBoxOutlined />, label: '体征监测', path: '/health/alert-dashboard' },
|
||||
{ icon: <MessageOutlined />, label: '患者咨询', path: '/health/consultations' },
|
||||
{ icon: <TeamOutlined />, label: '患者管理', path: '/health/patients' },
|
||||
{ icon: <TrophyOutlined />, label: '积分商城', path: '/health/points-products' },
|
||||
{ icon: <FileTextOutlined />, 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 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 [drawerItem, setDrawerItem] = useState<ActionItem | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
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;
|
||||
fetchUnreadCount();
|
||||
|
||||
if (role === 'doctor' || role === 'nurse') {
|
||||
pointsApi.getPersonalStats()
|
||||
.then((data) => { if (!cancelled) setPersonalStats(data); })
|
||||
.catch((err) => console.warn('[Home] 获取个人积分统计失败:', err))
|
||||
.finally(() => { if (!cancelled) setPersonalLoading(false); });
|
||||
} else {
|
||||
setPersonalLoading(false);
|
||||
}
|
||||
|
||||
listPendingTasks(1, 5)
|
||||
.then((result) => { if (!cancelled) setPendingTasks(result.data); })
|
||||
.catch((err) => console.warn('[Home] 获取待办任务失败:', err));
|
||||
|
||||
listAuditLogs({ page: 1, page_size: 5 })
|
||||
.then((result) => {
|
||||
if (!cancelled) setRecentActivities(result.data.filter((a) => a.action !== 'login_failed'));
|
||||
})
|
||||
.catch((err) => console.warn('[Home] 获取审计日志失败:', err))
|
||||
.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]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 角色工作台路由 */}
|
||||
{role === 'doctor' ? (
|
||||
<DoctorWorkbench />
|
||||
) : role === 'health_manager' ? (
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 64px)', overflow: 'hidden', margin: -20 }}>
|
||||
<TaskQueue />
|
||||
<TaskDetail />
|
||||
</div>
|
||||
) : role === 'operator' ? (
|
||||
<OperatorWorkbench />
|
||||
) : role === 'admin' ? (
|
||||
<AdminDashboard />
|
||||
) : (
|
||||
<>
|
||||
{/* 欢迎语 */}
|
||||
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
|
||||
<h2 style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: isDark ? '#f8fafc' : 'var(--erp-text-primary)',
|
||||
margin: '0 0 4px',
|
||||
letterSpacing: '-0.5px',
|
||||
}}>
|
||||
{welcome.title}
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: 'var(--erp-text-secondary)', margin: 0 }}>
|
||||
{welcome.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片行 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||
{statDefs.map((def, i) => {
|
||||
const value = def.getValue(personalStats, statsData);
|
||||
const diff = def.getDiff?.(personalStats);
|
||||
return (
|
||||
<div
|
||||
key={def.key}
|
||||
className={`erp-fade-in erp-fade-in-delay-${i + 1}`}
|
||||
style={{
|
||||
background: 'var(--erp-bg-card, white)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--erp-border, #E2E8F0)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onClick={() => handleNavigate(def.path)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(def.path); }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.06)'; e.currentTarget.style.transform = 'translateY(-1px)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'none'; }}
|
||||
>
|
||||
<div style={{ height: 3, background: STAT_BAR_COLORS[i] || STAT_BAR_COLORS[0] }} />
|
||||
<div style={{ padding: '16px 20px' }}>
|
||||
<div style={{ fontSize: 12, color: '#94A3B8', marginBottom: 6 }}>{def.title}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1.2, color: STAT_TEXT_COLORS[i] || STAT_TEXT_COLORS[0] }}>
|
||||
<StatValue value={value} loading={loading} />
|
||||
{def.suffix && <span style={{ fontSize: 14, marginLeft: 2 }}>{def.suffix}</span>}
|
||||
</div>
|
||||
{diff != null && (
|
||||
<div style={{ fontSize: 11, marginTop: 4, color: diff > 0 ? '#16A34A' : diff < 0 ? '#DC2626' : '#94A3B8' }}>
|
||||
{diff === 0 ? '与昨日持平' : `较昨日 ${diff > 0 ? '+' : ''}${diff}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 护士专属工作台 */}
|
||||
{role === 'nurse' ? (
|
||||
<NurseWorkbench />
|
||||
) : (
|
||||
<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" />
|
||||
<span className="erp-section-title">待办任务</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--erp-text-secondary)' }}>
|
||||
{pendingTasks.length} 项待处理
|
||||
</span>
|
||||
</div>
|
||||
<div className="erp-task-list">
|
||||
{pendingTasks.length === 0 ? (
|
||||
<Empty description="暂无待办任务" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
pendingTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="erp-task-item"
|
||||
style={{ '--task-color': 'var(--erp-primary)' } as React.CSSProperties}
|
||||
onClick={() => handleNavigate('/workflow')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate('/workflow'); }}
|
||||
>
|
||||
<div className="erp-task-item-icon"><PartitionOutlined /></div>
|
||||
<div className="erp-task-item-content">
|
||||
<div className="erp-task-item-title">{task.node_name || task.definition_name || '流程任务'}</div>
|
||||
<div className="erp-task-item-meta">
|
||||
<span>{task.definition_name || '工作流'}</span>
|
||||
<span>{task.status === 'pending' ? '待处理' : task.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="erp-task-priority erp-task-priority-medium">一般</span>
|
||||
<RightOutlined style={{ color: 'var(--erp-text-tertiary)', 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" />
|
||||
<span className="erp-section-title">最近动态</span>
|
||||
</div>
|
||||
<div className="erp-activity-list">
|
||||
{activitiesLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
|
||||
) : recentActivities.length === 0 ? (
|
||||
<Empty description="暂无动态" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
recentActivities.map((log) => (
|
||||
<div key={log.id} className="erp-activity-item">
|
||||
<div className="erp-activity-dot">
|
||||
{RESOURCE_ICONS[log.resource_type] || <FileTextOutlined />}
|
||||
</div>
|
||||
<div className="erp-activity-content">
|
||||
<div className="erp-activity-text">
|
||||
{formatActionLabel(log.action)}了{formatResourceLabel(log.resource_type)}
|
||||
</div>
|
||||
<div className="erp-activity-time">{formatTimeAgo(log.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 快捷入口 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col 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" />
|
||||
<span className="erp-section-title">快捷入口</span>
|
||||
</div>
|
||||
<Row gutter={[12, 12]}>
|
||||
{quickActions.map((action) => (
|
||||
<Col xs={12} sm={8} md={4} key={action.path}>
|
||||
<div
|
||||
className="erp-quick-action"
|
||||
style={{ '--action-color': 'var(--erp-primary)' } 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>
|
||||
</Row>
|
||||
|
||||
{/* 行动详情抽屉 */}
|
||||
<ActionDetailDrawer
|
||||
item={drawerItem}
|
||||
open={drawerOpen}
|
||||
onClose={() => { setDrawerOpen(false); setDrawerItem(null); }}
|
||||
onActionComplete={() => {
|
||||
setDrawerOpen(false);
|
||||
setDrawerItem(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user