Phase 1 — 品牌替换:
- BRAND_DEFAULTS 回退值改为暖记品牌 (themes.ts)
- 登录页/侧边栏/底部回退文字 → 暖记 (Login, MainLayout)
- index.html title/meta/favicon → 暖记
- localStorage key → nuanji-theme, 默认主题 → warm
- 4 套主题色适配暖记设计系统 (珊瑚 #E07A5F / 蓝 / 深色 / 鼠尾草绿)
- 品牌信息通过系统设置配置,不硬编码
Phase 2 — 清理 HMS 模块:
- 删除 health/ai 页面 (~55+2)、API (~30+9)、组件、stores、hooks
- 重写 Home.tsx 为暖记 Dashboard
- 重写 NotificationPanel/MediaPicker 移除 health 依赖
- 清理 routeConfig 移除所有 health/ai 路由权限
Phase 3 — 暖记管理页面:
- API 层: api/diary/{types,journals,classes,topics,comments,stickers}.ts
- 班级管理: 班级列表+创建+成员查看+班级码复制 (ClassList)
- 日记审核: 日记列表+筛选+详情+老师点评 (JournalList)
- 主题管理: 班级选择+主题卡片+创建+过期标记 (TopicList)
- 贴纸管理: 贴纸包卡片+贴纸详情网格 (StickerPackList)
- 路由注册: /diary/classes, /diary/journals, /diary/topics, /diary/stickers
验证: tsc 0 error, vite build ✓, vitest 226/226 pass
331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
||
import { Row, Col, Spin, Empty } from 'antd';
|
||
import {
|
||
TeamOutlined,
|
||
CalendarOutlined,
|
||
BookOutlined,
|
||
HeartOutlined,
|
||
SafetyCertificateOutlined,
|
||
FileTextOutlined,
|
||
RightOutlined,
|
||
PartitionOutlined,
|
||
ClockCircleOutlined,
|
||
CheckCircleOutlined,
|
||
ThunderboltOutlined,
|
||
SettingOutlined,
|
||
UserOutlined,
|
||
BellOutlined,
|
||
ApartmentOutlined,
|
||
SmileOutlined,
|
||
} from '@ant-design/icons';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useThemeMode } from '../hooks/useThemeMode';
|
||
import { useMessageStore } from '../stores/message';
|
||
import { listAuditLogs, type AuditLogItem } from '../api/auditLogs';
|
||
import { listPendingTasks, type TaskInfo } from '../api/workflowTasks';
|
||
import { useCountUp } from '../hooks/useCountUp';
|
||
|
||
// --- 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': '删除',
|
||
};
|
||
const RESOURCE_LABELS: Record<string, string> = {
|
||
user: '用户', role: '角色', organization: '组织',
|
||
message: '消息', plugin: '插件', process_instance: '流程实例',
|
||
};
|
||
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
|
||
user: <UserOutlined />, role: <SafetyCertificateOutlined />,
|
||
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;
|
||
}
|
||
|
||
// --- Dashboard config ---
|
||
|
||
interface QuickActionDef {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
path: string;
|
||
}
|
||
|
||
const QUICK_ACTIONS: QuickActionDef[] = [
|
||
{ icon: <TeamOutlined />, label: '用户管理', path: '/users' },
|
||
{ icon: <SafetyCertificateOutlined />, label: '角色权限', path: '/roles' },
|
||
{ icon: <CalendarOutlined />, label: '工作流', path: '/workflow' },
|
||
{ icon: <BellOutlined />, label: '消息管理', path: '/messages' },
|
||
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings' },
|
||
{ icon: <BookOutlined />, label: '审计日志', path: '/settings' },
|
||
];
|
||
|
||
const STAT_BAR_COLORS: string[] = [
|
||
'linear-gradient(90deg, #E07A5F, #E8907A)',
|
||
'linear-gradient(90deg, #81B29A, #8FBF9E)',
|
||
'linear-gradient(90deg, #F2CC8F, #D4B878)',
|
||
'linear-gradient(90deg, #D4A5A5, #C4A0A0)',
|
||
];
|
||
const STAT_TEXT_COLORS: string[] = ['#E07A5F', '#81B29A', '#F2CC8F', '#D4A5A5'];
|
||
|
||
interface StatCardDef {
|
||
key: string;
|
||
title: string;
|
||
icon: React.ReactNode;
|
||
value: number;
|
||
path: string;
|
||
}
|
||
|
||
const STATS: StatCardDef[] = [
|
||
{ key: 'pending-tasks', title: '待办任务', icon: <PartitionOutlined />, value: 0, path: '/workflow' },
|
||
{ key: 'users', title: '系统用户', icon: <TeamOutlined />, value: 0, path: '/users' },
|
||
{ key: 'messages', title: '消息通知', icon: <BellOutlined />, value: 0, path: '/messages' },
|
||
{ key: 'logs', title: '操作日志', icon: <FileTextOutlined />, value: 0, 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 isDark = useThemeMode();
|
||
const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount);
|
||
|
||
const [pendingTasks, setPendingTasks] = useState<TaskInfo[]>([]);
|
||
const [recentActivities, setRecentActivities] = useState<AuditLogItem[]>([]);
|
||
const [activitiesLoading, setActivitiesLoading] = useState(true);
|
||
const [statsLoading, setStatsLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
fetchUnreadCount();
|
||
|
||
listPendingTasks(1, 5)
|
||
.then((result) => { if (!cancelled) setPendingTasks(result.data); })
|
||
.catch((err) => console.warn('[Home] 获取待办任务失败:', err))
|
||
.finally(() => { if (!cancelled) setStatsLoading(false); });
|
||
|
||
listAuditLogs({ page: 1, page_size: 5 })
|
||
.then((result) => {
|
||
if (!cancelled) setRecentActivities(result.data.filter((a) => a.action !== 'login_failed'));
|
||
if (!cancelled) STATS[3].value = result.total;
|
||
})
|
||
.catch((err) => console.warn('[Home] 获取审计日志失败:', err))
|
||
.finally(() => { if (!cancelled) setActivitiesLoading(false); });
|
||
|
||
return () => { cancelled = true; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// 更新统计值
|
||
STATS[0].value = pendingTasks.length;
|
||
|
||
const handleNavigate = useCallback((path: string) => {
|
||
navigate(path);
|
||
}, [navigate]);
|
||
|
||
return (
|
||
<div>
|
||
{/* 欢迎语 */}
|
||
<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',
|
||
}}>
|
||
<SmileOutlined style={{ marginRight: 8, color: '#E07A5F' }} />
|
||
暖记管理后台
|
||
</h2>
|
||
<p style={{ fontSize: 14, color: 'var(--erp-text-secondary)', margin: 0 }}>
|
||
班级管理 · 日记审核 · 成长追踪
|
||
</p>
|
||
</div>
|
||
|
||
{/* 统计卡片行 */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||
{STATS.map((def, i) => (
|
||
<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={def.value} loading={statsLoading} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 待办任务 + 最近动态 */}
|
||
<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]}>
|
||
{QUICK_ACTIONS.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>
|
||
|
||
{/* 暖记介绍卡片 */}
|
||
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
|
||
<Col span={24}>
|
||
<div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{
|
||
background: 'linear-gradient(135deg, #FFF8F0 0%, #FFE8DE 100%)',
|
||
border: '1px solid #F0D5C8',
|
||
borderRadius: 16,
|
||
padding: '24px 32px',
|
||
}}>
|
||
<Row align="middle" gutter={24}>
|
||
<Col flex="auto">
|
||
<h3 style={{ fontSize: 18, fontWeight: 600, color: '#2D2420', margin: '0 0 8px' }}>
|
||
<HeartOutlined style={{ marginRight: 8, color: '#E07A5F' }} />
|
||
欢迎使用暖记管理后台
|
||
</h3>
|
||
<p style={{ fontSize: 14, color: '#6B5E52', margin: 0, lineHeight: 1.6 }}>
|
||
暖记是一款温暖治愈风格的手账日记 App,以手写/涂鸦为核心输入方式,面向小学生首发。
|
||
通过管理后台可以管理班级、审核日记、追踪学生成长。
|
||
</p>
|
||
</Col>
|
||
<Col>
|
||
<BookOutlined style={{ fontSize: 48, color: '#E07A5F', opacity: 0.3 }} />
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
);
|
||
}
|