import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse } from 'antd'; import { TeamOutlined, HomeOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined, SolutionOutlined, FileProtectOutlined, ReadOutlined, CalendarOutlined, ArrowRightOutlined, ExclamationCircleOutlined, DollarOutlined, } from '@ant-design/icons'; import ReactECharts, { type EChartsOption } from '../../components/ECharts'; import dayjs from 'dayjs'; import { useNavigate } from 'react-router-dom'; import api from '../../api'; import { message } from '../../ui/app-message'; const { RangePicker } = DatePicker; const COLORS = [ '#007AFF', '#34C759', '#FF9500', '#FF3B30', '#5AC8FA', '#AF52DE', '#FF2D55', '#FFCC00', ]; interface BillStatRow { status: string; count: string; total: string; } interface ClassAttendanceRank { className: string; present: number; total: number; rate: number; } interface ClassroomOccupancy { name: string; building: string; capacity: number; scheduleDays: number; rentalCount: number; occupancy: number; } interface ClassroomUtilStats { totalClassrooms: number; inUseCount: number; utilizationRate: string; scheduleCount: number; rentalCount: number; } interface AttendanceTrendRow { date: string; rate: string; } interface IncomeTrendRow { month: string; amount: number; } interface OccupancyByBuildingRow { building: string; count: string; } interface ExpenseByTypeRow { type: string; total: string; } interface GanttOccupancy { studentName: string; studentId?: string; checkInDate: string; checkOutDate: string | null; billingStartDate?: string; billingEndDate?: string; } interface GanttRoom { roomNumber: string; occupancies: GanttOccupancy[]; } interface DashboardStats { totalRooms: number; totalStudents: number; occupiedBeds: number; totalCapacity: number; occupancyRate: string; billStats: BillStatRow[]; classroomCount: number; classroomOccupancyRate: string; todayAttendanceRate: string; monthlyIncome: number; classCount: number; teacherCount: number; pendingDeposits: number; activeRentals: number; todayPresent: number; occupancyByBuilding: OccupancyByBuildingRow[]; attendanceByStatus: Record; expenseByType: ExpenseByTypeRow[]; attendanceTrend: AttendanceTrendRow[]; incomeTrend: IncomeTrendRow[]; } const attendanceLabelMap: Record = { present: '出勤', absent: '缺勤', late: '迟到', early: '早退', leave: '请假', }; const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 }; const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 }; // ─── 待办卡片样式 ─── const TODO_CARD_BASE: React.CSSProperties = { cursor: 'pointer', transition: 'box-shadow 0.2s, transform 0.2s', borderRadius: 8, height: '100%', }; const TODO_CARD_WARN: React.CSSProperties = { ...TODO_CARD_BASE, borderLeft: '4px solid #FF9500', background: '#fff7e6', }; const TODO_CARD_DANGER: React.CSSProperties = { ...TODO_CARD_BASE, borderLeft: '4px solid #FF3B30', background: '#fff1f0', }; const TODO_CARD_OK: React.CSSProperties = { ...TODO_CARD_BASE, borderLeft: '4px solid #34C759', background: '#f0fff4', }; const TODO_CARD_DRAFT: React.CSSProperties = { ...TODO_CARD_BASE, borderLeft: '4px solid #AF52DE', background: '#f9f0ff', }; // ─── IntersectionObserver 自定义 hook ─── // 用 callback ref 注册 observer,避免元素在首屏 loading 后才挂载、 // 而 effect 因依赖不变不再重跑导致 observer 从未注册的问题。 const useInViewport = (rootMargin = '200px') => { const [inView, setInView] = useState(false); const observerRef = useRef(null); const ref = useCallback( (el: HTMLDivElement | null) => { observerRef.current?.disconnect(); if (!el) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } }, { rootMargin }, ); observer.observe(el); observerRef.current = observer; }, [rootMargin], ); return { ref, inView }; }; const DashboardPage: React.FC = () => { const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; const navigate = useNavigate(); const [stats, setStats] = useState(null); const [classRanking, setClassRanking] = useState<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[]; }>({ top: [], bottom: [] }); const [classroomOccupancy, setClassroomOccupancy] = useState([]); const [ganttData, setGanttData] = useState([]); const [roomRanking, setRoomRanking] = useState>([]); const [classroomUtil, setClassroomUtil] = useState(null); const [loading, setLoading] = useState(true); const [refreshLoading, setRefreshLoading] = useState(false); const loadedRef = useRef(false); const [period, setPeriod] = useState<[string, string]>([ dayjs().startOf('month').format('YYYY-MM-DD'), dayjs().endOf('month').format('YYYY-MM-DD'), ]); const fetchData = useCallback(async () => { const isRefresh = loadedRef.current; if (isRefresh) { setRefreshLoading(true); } else { setLoading(true); } try { const [s, rr, cr, g, co, cu] = await Promise.all([ api.get('/dashboard/stats'), api.get>('/dashboard/room-ranking', { params: { periodStart: period[0], periodEnd: period[1] }, }), api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>( '/dashboard/class-attendance-ranking', ), api.get('/dashboard/gantt', { params: { periodStart: period[0], periodEnd: period[1] }, }), api.get('/dashboard/classroom-occupancy'), api.get('/dashboard/classroom-utilization'), ]); setStats(s); setRoomRanking(rr); setClassRanking(cr); setGanttData(g); setClassroomOccupancy(co); setClassroomUtil(cu); loadedRef.current = true; } catch (e) { console.error(e); message.error('数据加载失败,请稍后重试'); } setLoading(false); setRefreshLoading(false); }, [period]); useEffect(() => { fetchData(); }, [fetchData]); const [expenseTypeMap, setExpenseTypeMap] = useState>({}); useEffect(() => { api .get>('/expense-types') .then((types) => { const map: Record = {}; for (const t of types) map[t.code] = t.name; setExpenseTypeMap(map); }) .catch(() => {}); }, []); // ─── 图表 option 计算(保留全部原有逻辑) ─── // 今日出勤状态分布环图 const attendanceRingOption = useMemo( () => ({ tooltip: { trigger: 'item' }, legend: { bottom: 0 }, series: [ { type: 'pie', radius: ['40%', '70%'], center: ['50%', '45%'], data: Object.entries(stats?.attendanceByStatus ?? {}).map(([status, count]) => ({ name: attendanceLabelMap[status] ?? status, value: count, })), itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 }, }, ], color: COLORS, }), [stats?.attendanceByStatus], ); // 宿舍费用排行 const barOption = useMemo( () => ({ tooltip: {}, grid: { left: 80, right: 20, bottom: 30, top: 10 }, xAxis: { type: 'value' }, yAxis: { type: 'category', data: roomRanking.map((r) => r.roomNumber).reverse(), inverse: false, }, series: [ { type: 'bar', data: roomRanking.map((r) => Number(r.total)).reverse(), itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] }, }, ], }), [roomRanking], ); // 班级考勤排行 - 前5 const classRankingTopOption: EChartsOption = { tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: number) => `${v}%`, }, grid: { left: 80, right: 30, bottom: 30, top: 10 }, xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }, yAxis: { type: 'category', data: classRanking.top.map((r) => r.className), inverse: true, }, series: [ { type: 'bar', data: classRanking.top.map((r) => r.rate), itemStyle: { color: '#34C759', borderRadius: [0, 4, 4, 0] }, label: { show: true, position: 'right', formatter: '{c}%' }, }, ], }; // 班级考勤排行 - 后5 const classRankingBottomOption: EChartsOption = { tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: number) => `${v}%`, }, grid: { left: 80, right: 30, bottom: 30, top: 10 }, xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } }, yAxis: { type: 'category', data: classRanking.bottom.map((r) => r.className), inverse: true, }, series: [ { type: 'bar', data: classRanking.bottom.map((r) => r.rate), itemStyle: { color: '#FF3B30', borderRadius: [0, 4, 4, 0] }, label: { show: true, position: 'right', formatter: '{c}%' }, }, ], }; // 考勤趋势折线图 const attendanceLineOption: EChartsOption = { tooltip: { trigger: 'axis' }, grid: { left: 50, right: 20, bottom: 30, top: 10 }, xAxis: { type: 'category', data: (stats?.attendanceTrend || []).map((d: { date: string }) => d.date), axisLabel: { rotate: 45, fontSize: 10 }, }, yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } }, series: [ { type: 'line', data: (stats?.attendanceTrend || []).map((d: { rate: string }) => parseFloat(d.rate) || 0), smooth: true, lineStyle: { color: '#007AFF', width: 2 }, itemStyle: { color: '#007AFF' }, areaStyle: { color: 'rgba(0,122,255,0.1)' }, }, ], }; // 收入趋势折线图 const incomeLineOption: EChartsOption = { tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` }, grid: { left: 70, right: 20, bottom: 30, top: 10 }, xAxis: { type: 'category', data: (stats?.incomeTrend || []).map((d: { month: string }) => d.month), }, yAxis: { type: 'value', axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}万` }, }, series: [ { type: 'line', data: (stats?.incomeTrend || []).map((d: { amount: number }) => d.amount), smooth: true, lineStyle: { color: '#34C759', width: 2 }, itemStyle: { color: '#34C759' }, areaStyle: { color: 'rgba(52,199,89,0.1)' }, }, ], }; // 入住时间线(甘特图) const ganttOption = useMemo( () => ({ tooltip: { formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) => `${p.data.name}
入住: ${p.data.value[1]}
退宿: ${p.data.value[2]}`, }, grid: { left: 100, right: 30, bottom: 40, top: 20 }, xAxis: { type: 'time' }, yAxis: { type: 'category', data: ganttData.map((r) => r.roomNumber), inverse: true }, dataZoom: [ { type: 'slider', xAxisIndex: 0, bottom: 10, height: 20 }, { type: 'inside', xAxisIndex: 0 }, ], series: [ { type: 'custom', renderItem: ( _params: unknown, api: { value: (i: number) => string | boolean; coord: (p: [string | number, string | number]) => [number, number]; size: (p: [number, number]) => [number, number]; }, ) => { const [cat, startDate, endDate, isActive] = [ api.value(0), api.value(1), api.value(2), api.value(3), ] as unknown as [string, string, string, boolean]; const start = api.coord([startDate, cat]); const end = api.coord([endDate, cat]); const height = api.size([0, 1])[1] * 0.6; const rectShape = { x: start[0], y: start[1] - height / 2, width: Math.max(end[0] - start[0], 2), height, }; return { type: 'rect' as const, shape: rectShape, style: { fill: isActive ? '#34C759' : '#FF9500', stroke: '#fff', lineWidth: 1 }, }; }, encode: { x: [1, 2], y: 0 }, data: ganttData.flatMap((r) => (r.occupancies || []).map((o) => ({ name: o.studentName, value: [ r.roomNumber, o.checkInDate, o.checkOutDate || new Date().toISOString().slice(0, 10), !o.checkOutDate, ] as [string, string, string, boolean], })), ), }, ], }), [ganttData], ); // ─── 懒加载 hooks ─── const classroomHeatmapVp = useInViewport('200px'); const ganttVp = useInViewport('200px'); // ─── 待办卡片数据 ─── const absentCount = stats?.attendanceByStatus?.['absent'] ?? 0; const draftBill = (stats?.billStats ?? []).find((b) => b.status === 'draft'); const draftCount = draftBill ? Number(draftBill.count) : 0; const draftTotal = draftBill ? Number(draftBill.total) : 0; const pendingDeposits = stats?.pendingDeposits ?? 0; if (loading && !stats) return ; return (
{/* ─── 顶部标题栏 ─── */}

工作台{refreshLoading && }

{ if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]); }} />
{/* ═══════════ 待办与异常 ═══════════ */} {/* 今日缺勤 */} 0 ? TODO_CARD_WARN : TODO_CARD_OK} styles={{ body: { padding: 16 } }} onClick={() => navigate('/attendance')} >
0 ? '#FF9500' : '#999' }} />
0 ? '#FF9500' : '#999', }} > {absentCount}
今日缺勤人数
{absentCount > 0 ? (
需要关注
) : (
全员到齐
)}
{/* 待处理账单 */} 0 ? TODO_CARD_DRAFT : TODO_CARD_OK} styles={{ body: { padding: 16 } }} onClick={() => navigate('/bills')} >
0 ? '#AF52DE' : '#999' }} />
0 ? '#AF52DE' : '#999', }} > {draftCount}
待处理账单
0 ? '#AF52DE' : '#999', marginTop: 4 }} > {draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'}
{/* 待退押金 */} 0 ? TODO_CARD_DANGER : TODO_CARD_OK} styles={{ body: { padding: 16 } }} onClick={() => navigate('/deposits')} >
0 ? '#FF3B30' : '#999' }} />
0 ? '#FF3B30' : '#999', }} > ¥{pendingDeposits.toLocaleString()}
待退押金
0 ? '#FF3B30' : '#999', marginTop: 4, }} > {pendingDeposits > 0 ? '需要处理' : '暂无待退'}
{/* ═══════════ 核心 KPI ═══════════ */} } /> } /> } /> } /> {/* 占位:保持 6 列布局最后一个空位 */} {/* ═══════════ 更多指标(折叠) ═══════════ */} } /> } /> } /> } /> } /> } /> } /> } /> } /> } styles={{ value: { color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500', }, }} /> } /> ), }, ]} /> {/* ═══════════ 图表:考勤趋势 + 出勤分布 ═══════════ */} {(stats?.attendanceTrend || []).length > 0 ? ( ) : (
暂无考勤数据
)}
{Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? ( ) : (
暂无考勤数据
)}
{/* ═══════════ 图表:班级出勤排行 ═══════════ */} {classRanking.top.length > 0 ? ( ) : (
暂无考勤数据
)}
{classRanking.bottom.length > 0 ? ( ) : (
暂无考勤数据
)}
{/* ═══════════ 图表:费用分布 + 宿舍排行 ═══════════ */} {(stats?.expenseByType ?? []).length > 0 ? ( ({ name: expenseTypeMap[e.type] ?? e.type, value: Number(e.total), })), }, ], } satisfies EChartsOption } style={{ width: '100%', height: isMobile ? 250 : 300 }} /> ) : (
暂无费用数据
)}
{roomRanking.length > 0 ? ( ) : (
暂无费用数据
)}
{/* ═══════════ 图表:月度收入趋势 ═══════════ */} {(stats?.incomeTrend || []).length > 0 ? ( ) : (
暂无收入数据
)}
{/* ═══════════ 图表:教室占用热力图(懒加载) ═══════════ */}
{classroomHeatmapVp.inView ? ( {classroomOccupancy.length > 0 ? ( `${p.name}
排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`, }, grid: { left: 100, right: 20, bottom: 30, top: 10 }, xAxis: { type: 'value', max: 1 }, yAxis: { type: 'category', data: classroomOccupancy.map((r) => r.name), inverse: true, }, visualMap: { min: 0, max: 1, orient: 'horizontal', left: 'center', bottom: 0, inRange: { color: ['#e6f4ff', '#91caff', '#40a9ff', '#0050b3', '#002c8c'], }, }, series: [ { type: 'bar', data: classroomOccupancy.map((r) => ({ name: r.name, value: r.occupancy, scheduleDays: r.scheduleDays, rentalCount: r.rentalCount, occupancy: r.occupancy, })), itemStyle: { borderRadius: [0, 4, 4, 0] }, label: { show: true, position: 'right', formatter: (p: { data: { occupancy: number } }) => `${(p.data.occupancy * 100).toFixed(0)}%`, }, }, ], } satisfies EChartsOption } style={{ width: '100%', height: isMobile ? 300 : 400 }} /> ) : (
暂无教室数据
)}
) : (
加载中…
)}
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
{ganttVp.inView ? ( {ganttData.length > 0 ? ( ) : (
暂无入住数据
)}
) : (
加载中…
)}
); }; export default DashboardPage;