Files
nj/apps/web/src/pages/Home.tsx
iven 78018a9a64
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
feat(app): 管理端 Web 基座→暖记品牌迁移 + 日记管理页面
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
2026-06-02 12:16:44 +08:00

331 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } 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>
);
}