feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -0,0 +1,128 @@
import React from 'react';
export const COLORS = [
'#007AFF',
'#34C759',
'#FF9500',
'#FF3B30',
'#5AC8FA',
'#AF52DE',
'#FF2D55',
'#FFCC00',
];
export interface BillStatRow {
status: string;
count: string;
total: string;
}
export interface ClassAttendanceRank {
className: string;
present: number;
total: number;
rate: number;
}
export interface ClassroomOccupancy {
name: string;
building: string;
capacity: number;
scheduleDays: number;
rentalCount: number;
occupancy: number;
}
export interface ClassroomUtilStats {
totalClassrooms: number;
inUseCount: number;
utilizationRate: string;
scheduleCount: number;
rentalCount: number;
}
export interface AttendanceTrendRow {
date: string;
rate: string;
}
export interface IncomeTrendRow {
month: string;
amount: number;
}
export interface OccupancyByBuildingRow {
building: string;
count: string;
}
export interface ExpenseByTypeRow {
type: string;
total: string;
}
export interface GanttOccupancy {
studentName: string;
studentId?: string;
checkInDate: string;
checkOutDate: string | null;
billingStartDate?: string;
billingEndDate?: string;
}
export interface GanttRoom {
roomNumber: string;
occupancies: GanttOccupancy[];
}
export 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<string, number>;
expenseByType: ExpenseByTypeRow[];
attendanceTrend: AttendanceTrendRow[];
incomeTrend: IncomeTrendRow[];
}
export const attendanceLabelMap: Record<string, string> = {
present: '出勤',
absent: '缺勤',
late: '迟到',
early: '早退',
leave: '请假',
};
export const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 };
export const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 };
export const TODO_CARD_BASE: React.CSSProperties = {
cursor: 'pointer',
transition: 'box-shadow 0.2s, transform 0.2s',
borderRadius: 8,
height: '100%',
};
export const TODO_CARD_WARN: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #FF9500',
background: '#fff7e6',
};
export const TODO_CARD_DANGER: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #FF3B30',
background: '#fff1f0',
};
export const TODO_CARD_OK: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #34C759',
background: '#f0fff4',
};
export const TODO_CARD_DRAFT: React.CSSProperties = {
...TODO_CARD_BASE,
borderLeft: '4px solid #AF52DE',
background: '#f9f0ff',
};

View File

@@ -0,0 +1,262 @@
import type { EChartsOption } from '../../components/ECharts';
import {
attendanceLabelMap,
COLORS,
type AttendanceTrendRow,
type ClassAttendanceRank,
type ClassroomOccupancy,
type DashboardStats,
type ExpenseByTypeRow,
type GanttRoom,
type IncomeTrendRow,
} from './Dashboard.types';
export function buildAttendanceRingOption(stats: DashboardStats | null): EChartsOption {
return {
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,
};
}
export function buildRoomRankingBarOption(
roomRanking: Array<{ roomNumber: string; total: string }>,
): EChartsOption {
return {
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] },
},
],
};
}
export function buildClassRankingOption(
rows: ClassAttendanceRank[],
color: string,
): EChartsOption {
return {
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: rows.map((r) => r.className),
inverse: true,
},
series: [
{
type: 'bar',
data: rows.map((r) => r.rate),
itemStyle: { color, borderRadius: [0, 4, 4, 0] },
label: { show: true, position: 'right', formatter: '{c}%' },
},
],
};
}
export function buildAttendanceLineOption(rows: AttendanceTrendRow[]): EChartsOption {
return {
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, bottom: 30, top: 10 },
xAxis: {
type: 'category',
data: rows.map((d) => d.date),
axisLabel: { rotate: 45, fontSize: 10 },
},
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
series: [
{
type: 'line',
data: rows.map((d) => parseFloat(d.rate) || 0),
smooth: true,
lineStyle: { color: '#007AFF', width: 2 },
itemStyle: { color: '#007AFF' },
areaStyle: { color: 'rgba(0,122,255,0.1)' },
},
],
};
}
export function buildIncomeLineOption(rows: IncomeTrendRow[]): EChartsOption {
return {
tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` },
grid: { left: 70, right: 20, bottom: 30, top: 10 },
xAxis: {
type: 'category',
data: rows.map((d) => d.month),
},
yAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}` },
},
series: [
{
type: 'line',
data: rows.map((d) => d.amount),
smooth: true,
lineStyle: { color: '#34C759', width: 2 },
itemStyle: { color: '#34C759' },
areaStyle: { color: 'rgba(52,199,89,0.1)' },
},
],
};
}
export function buildExpensePieOption(
rows: ExpenseByTypeRow[],
expenseTypeMap: Record<string, string>,
): EChartsOption {
return {
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
color: COLORS,
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
data: rows.map((e) => ({
name: expenseTypeMap[e.type] ?? e.type,
value: Number(e.total),
})),
},
],
};
}
export function buildClassroomHeatmapOption(
classroomOccupancy: ClassroomOccupancy[],
): EChartsOption {
return {
tooltip: {
formatter: (p: {
name: string;
data: { scheduleDays: number; rentalCount: number; occupancy: number };
}) =>
`${p.name}<br/>排课: ${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)}%`,
},
},
],
};
}
export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption {
return {
tooltip: {
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
`${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${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 = String(api.value(0));
const startDate = String(api.value(1));
const endDate = String(api.value(2));
const isActive = Boolean(api.value(3));
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],
})),
),
},
],
};
}

View File

@@ -0,0 +1,85 @@
import React, { type CSSProperties } from 'react';
import { Card, Col, Row } from 'antd';
import { useIntersectionObserver } from 'usehooks-ts';
import ReactECharts from '../../components/ECharts';
import type { ClassroomOccupancy, GanttRoom } from './Dashboard.types';
import { buildClassroomHeatmapOption, buildGanttOption } from './DashboardCharts';
const useInViewport = (rootMargin = '200px') => {
const { ref, isIntersecting } = useIntersectionObserver({
rootMargin,
freezeOnceVisible: true,
});
return { ref, inView: isIntersecting };
};
const LazySection: React.FC<{
title: string;
vp: { ref: (node?: Element | null) => void; inView: boolean };
minHeight: number;
style?: CSSProperties;
children: React.ReactNode;
}> = ({ title, vp, minHeight, style, children }) => {
return (
<div ref={vp.ref} style={style}>
{vp.inView ? (
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card title={title}>{children}</Card>
</Col>
</Row>
) : (
<Card title={title} style={{ minHeight }}>
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
</Card>
)}
</div>
);
};
export const ClassroomHeatmapCard: React.FC<{
data: ClassroomOccupancy[];
isMobile: boolean;
}> = ({ data, isMobile }) => {
const vp = useInViewport('200px');
return (
<LazySection
title="教室占用热力图"
vp={vp}
minHeight={isMobile ? 340 : 440}
style={{ marginBottom: 24 }}
>
{data.length > 0 ? (
<ReactECharts
option={buildClassroomHeatmapOption(data)}
style={{ width: '100%', height: isMobile ? 300 : 400 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</LazySection>
);
};
export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
data,
isMobile,
}) => {
const vp = useInViewport('200px');
return (
<LazySection
title="入住时间线(甘特图)"
vp={vp}
minHeight={isMobile ? 340 : 490}
>
{data.length > 0 ? (
<ReactECharts
option={buildGanttOption(data)}
style={{ width: '100%', height: isMobile ? 300 : 450 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</LazySection>
);
};

View File

@@ -0,0 +1,124 @@
import React from 'react';
import { Card, Col, Row } from 'antd';
import {
ArrowRightOutlined,
BankOutlined,
DollarOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types';
export const DashboardTodoCards: React.FC<{
absentCount: number;
draftCount: number;
draftTotal: number;
pendingDeposits: number;
}> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => {
const navigate = useNavigate();
return (
<Card title="待办与异常" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}>
<Card
style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/attendance')}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<ExclamationCircleOutlined
style={{ fontSize: 28, color: absentCount > 0 ? '#FF9500' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: absentCount > 0 ? '#FF9500' : '#999',
}}
>
{absentCount}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div>
{absentCount > 0 ? (
<div style={{ fontSize: 12, color: '#FF9500', marginTop: 4 }}></div>
) : (
<div style={{ fontSize: 12, color: '#34C759', marginTop: 4 }}></div>
)}
</div>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card
style={draftCount > 0 ? TODO_CARD_DRAFT : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/bills')}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<DollarOutlined
style={{ fontSize: 28, color: draftCount > 0 ? '#AF52DE' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: draftCount > 0 ? '#AF52DE' : '#999',
}}
>
{draftCount}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div>
<div
style={{ fontSize: 12, color: draftCount > 0 ? '#AF52DE' : '#999', marginTop: 4 }}
>
{draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'}
</div>
</div>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card
style={pendingDeposits > 0 ? TODO_CARD_DANGER : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/deposits')}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<BankOutlined
style={{ fontSize: 28, color: pendingDeposits > 0 ? '#FF3B30' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
}}
>
¥{pendingDeposits.toLocaleString()}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}>退</div>
<div
style={{
fontSize: 12,
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
marginTop: 4,
}}
>
{pendingDeposits > 0 ? '需要处理' : '暂无待退'}
</div>
</div>
</Card>
</Col>
</Row>
</Card>
);
};

View File

@@ -1,4 +1,15 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { validateResponse } from '../../utils/validate';
import {
classAttendanceRankingSchema,
classroomOccupanciesSchema,
classroomUtilStatsSchema,
dashboardStatsSchema,
expenseTypesSchema,
ganttRoomsSchema,
roomRankingSchema,
} from '../../api/schemas';
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse } from 'antd';
import {
TeamOutlined,
@@ -11,460 +22,142 @@ import {
FileProtectOutlined,
ReadOutlined,
CalendarOutlined,
ArrowRightOutlined,
ExclamationCircleOutlined,
DollarOutlined,
} from '@ant-design/icons';
import ReactECharts, { type EChartsOption } from '../../components/ECharts';
import ReactECharts from '../../components/ECharts';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import api from '../../api';
import { message } from '../../ui/app-message';
import {
buildAttendanceLineOption,
buildAttendanceRingOption,
buildClassRankingOption,
buildExpensePieOption,
buildIncomeLineOption,
buildRoomRankingBarOption,
} from './DashboardCharts';
import { ClassroomHeatmapCard, GanttCard } from './DashboardLazyCards';
import {
MARGIN_BOTTOM_16_STYLE,
SECTION_ROW_STYLE,
type ClassAttendanceRank,
type ClassroomOccupancy,
type ClassroomUtilStats,
type DashboardStats,
type GanttRoom,
} from './Dashboard.types';
import { DashboardTodoCards } from './DashboardTodoCards';
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<string, number>;
expenseByType: ExpenseByTypeRow[];
attendanceTrend: AttendanceTrendRow[];
incomeTrend: IncomeTrendRow[];
}
const attendanceLabelMap: Record<string, string> = {
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<IntersectionObserver | null>(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<DashboardStats | null>(null);
const [classRanking, setClassRanking] = useState<{
top: ClassAttendanceRank[];
bottom: ClassAttendanceRank[];
}>({ top: [], bottom: [] });
const [classroomOccupancy, setClassroomOccupancy] = useState<ClassroomOccupancy[]>([]);
const [ganttData, setGanttData] = useState<GanttRoom[]>([]);
const [roomRanking, setRoomRanking] = useState<Array<{ roomNumber: string; total: string }>>([]);
const [classroomUtil, setClassroomUtil] = useState<ClassroomUtilStats | null>(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<DashboardStats>('/dashboard/stats'),
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>(
'/dashboard/class-attendance-ranking',
),
api.get<GanttRoom[]>('/dashboard/gantt', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy'),
api.get<ClassroomUtilStats>('/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]);
const {
data: fetchResult = {
stats: null,
classRanking: { top: [], bottom: [] },
classroomOccupancy: [],
ganttData: [],
roomRanking: [],
classroomUtil: null,
},
isLoading,
isFetching,
} = useQuery<{
stats: DashboardStats | null;
classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] };
classroomOccupancy: ClassroomOccupancy[];
ganttData: GanttRoom[];
roomRanking: Array<{ roomNumber: string; total: string }>;
classroomUtil: ClassroomUtilStats | null;
}>({
queryKey: ['dashboard', period],
queryFn: async () => {
try {
const [s, rr, cr, g, co, cu] = await Promise.all([
api.get<DashboardStats>('/dashboard/stats'),
api.get<Array<{ roomNumber: string; total: string }>>('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>(
'/dashboard/class-attendance-ranking',
),
api.get<GanttRoom[]>('/dashboard/gantt', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get<ClassroomOccupancy[]>('/dashboard/classroom-occupancy'),
api.get<ClassroomUtilStats>('/dashboard/classroom-utilization'),
]);
return {
stats: validateResponse<DashboardStats>(dashboardStatsSchema, s),
roomRanking: validateResponse<Array<{ roomNumber: string; total: string }>>(
roomRankingSchema,
rr,
),
classRanking: validateResponse<{
top: ClassAttendanceRank[];
bottom: ClassAttendanceRank[];
}>(classAttendanceRankingSchema, cr),
ganttData: validateResponse<GanttRoom[]>(ganttRoomsSchema, g),
classroomOccupancy: validateResponse<ClassroomOccupancy[]>(
classroomOccupanciesSchema,
co,
),
classroomUtil: validateResponse<ClassroomUtilStats>(classroomUtilStatsSchema, cu),
};
} catch (e) {
console.error(e);
message.error('数据加载失败,请稍后重试');
return {
stats: null,
classRanking: { top: [], bottom: [] },
classroomOccupancy: [],
ganttData: [],
roomRanking: [],
classroomUtil: null,
};
}
},
});
const stats = fetchResult.stats;
const classRanking = fetchResult.classRanking;
const classroomOccupancy = fetchResult.classroomOccupancy;
const ganttData = fetchResult.ganttData;
const roomRanking = fetchResult.roomRanking;
const classroomUtil = fetchResult.classroomUtil;
const loading = isLoading;
const refreshLoading = isFetching && !isLoading;
useEffect(() => {
fetchData();
}, [fetchData]);
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
useEffect(() => {
api
.get<Array<{ code: string; name: string }>>('/expense-types')
.then((types) => {
const { data: expenseTypeMap = {} } = useQuery<Record<string, string>>({
queryKey: ['expense-types', 'map'],
queryFn: async () => {
try {
const types = validateResponse<Array<{ code: string; name: string }>>(
expenseTypesSchema,
await api.get<Array<{ code: string; name: string }>>('/expense-types'),
);
const map: Record<string, string> = {};
for (const t of types) map[t.code] = t.name;
setExpenseTypeMap(map);
})
.catch(() => {});
}, []);
// ─── 图表 option 计算(保留全部原有逻辑) ───
// 今日出勤状态分布环图
const attendanceRingOption = useMemo<EChartsOption>(
() => ({
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<EChartsOption>(
() => ({
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}%`,
return map;
} catch {
return {};
}
},
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<EChartsOption>(
() => ({
tooltip: {
formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>
`${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${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 attendanceTotal = stats
? Object.values(stats.attendanceByStatus).reduce((sum, n) => sum + Number(n || 0), 0)
: 0;
const presentCount = stats?.attendanceByStatus?.present ?? 0;
const todayAttendanceRate =
stats?.todayAttendanceRate ??
(attendanceTotal > 0 ? ((presentCount / attendanceTotal) * 100).toFixed(1) : '0');
const draftBill = (stats?.billStats ?? []).find((b) => b.status === 'draft');
const draftCount = draftBill ? Number(draftBill.count) : 0;
const draftTotal = draftBill ? Number(draftBill.total) : 0;
@@ -499,118 +192,12 @@ const DashboardPage: React.FC = () => {
</div>
{/* ═══════════ 待办与异常 ═══════════ */}
<Card title="待办与异常" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[16, 16]}>
{/* 今日缺勤 */}
<Col xs={24} sm={8}>
<Card
style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/attendance')}
>
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<ExclamationCircleOutlined
style={{ fontSize: 28, color: absentCount > 0 ? '#FF9500' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: absentCount > 0 ? '#FF9500' : '#999',
}}
>
{absentCount}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div>
{absentCount > 0 ? (
<div style={{ fontSize: 12, color: '#FF9500', marginTop: 4 }}></div>
) : (
<div style={{ fontSize: 12, color: '#34C759', marginTop: 4 }}></div>
)}
</div>
</Card>
</Col>
{/* 待处理账单 */}
<Col xs={24} sm={8}>
<Card
style={draftCount > 0 ? TODO_CARD_DRAFT : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/bills')}
>
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<DollarOutlined
style={{ fontSize: 28, color: draftCount > 0 ? '#AF52DE' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: draftCount > 0 ? '#AF52DE' : '#999',
}}
>
{draftCount}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}></div>
<div
style={{ fontSize: 12, color: draftCount > 0 ? '#AF52DE' : '#999', marginTop: 4 }}
>
{draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'}
</div>
</div>
</Card>
</Col>
{/* 待退押金 */}
<Col xs={24} sm={8}>
<Card
style={pendingDeposits > 0 ? TODO_CARD_DANGER : TODO_CARD_OK}
styles={{ body: { padding: 16 } }}
onClick={() => navigate('/deposits')}
>
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<BankOutlined
style={{ fontSize: 28, color: pendingDeposits > 0 ? '#FF3B30' : '#999' }}
/>
<ArrowRightOutlined style={{ color: '#bbb' }} />
</div>
<div style={{ marginTop: 8 }}>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
}}
>
¥{pendingDeposits.toLocaleString()}
</div>
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}>退</div>
<div
style={{
fontSize: 12,
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
marginTop: 4,
}}
>
{pendingDeposits > 0 ? '需要处理' : '暂无待退'}
</div>
</div>
</Card>
</Col>
</Row>
</Card>
<DashboardTodoCards
absentCount={absentCount}
draftCount={draftCount}
draftTotal={draftTotal}
pendingDeposits={pendingDeposits}
/>
{/* ═══════════ 核心 KPI ═══════════ */}
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
@@ -638,7 +225,7 @@ const DashboardPage: React.FC = () => {
<Card>
<Statistic
title="今日出勤率"
value={stats?.todayAttendanceRate || 0}
value={todayAttendanceRate}
suffix="%"
prefix={<UserSwitchOutlined />}
/>
@@ -809,7 +396,7 @@ const DashboardPage: React.FC = () => {
<Card title="考勤趋势近30天">
{(stats?.attendanceTrend || []).length > 0 ? (
<ReactECharts
option={attendanceLineOption}
option={buildAttendanceLineOption(stats?.attendanceTrend ?? [])}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -821,7 +408,7 @@ const DashboardPage: React.FC = () => {
<Card title="今日出勤状态分布">
{Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? (
<ReactECharts
option={attendanceRingOption}
option={buildAttendanceRingOption(stats)}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -837,7 +424,7 @@ const DashboardPage: React.FC = () => {
<Card title="班级出勤率 TOP 5">
{classRanking.top.length > 0 ? (
<ReactECharts
option={classRankingTopOption}
option={buildClassRankingOption(classRanking.top, '#34C759')}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -849,7 +436,7 @@ const DashboardPage: React.FC = () => {
<Card title="班级出勤率 末位 5">
{classRanking.bottom.length > 0 ? (
<ReactECharts
option={classRankingBottomOption}
option={buildClassRankingOption(classRanking.bottom, '#FF3B30')}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -865,24 +452,7 @@ const DashboardPage: React.FC = () => {
<Card title="费用类型分布">
{(stats?.expenseByType ?? []).length > 0 ? (
<ReactECharts
option={
{
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
color: COLORS,
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
data: (stats?.expenseByType ?? []).map((e) => ({
name: expenseTypeMap[e.type] ?? e.type,
value: Number(e.total),
})),
},
],
} satisfies EChartsOption
}
option={buildExpensePieOption(stats?.expenseByType ?? [], expenseTypeMap)}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -894,7 +464,7 @@ const DashboardPage: React.FC = () => {
<Card title="宿舍费用排行 TOP 20">
{roomRanking.length > 0 ? (
<ReactECharts
option={barOption}
option={buildRoomRankingBarOption(roomRanking)}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -910,7 +480,7 @@ const DashboardPage: React.FC = () => {
<Card title="月度收入趋势">
{(stats?.incomeTrend || []).length > 0 ? (
<ReactECharts
option={incomeLineOption}
option={buildIncomeLineOption(stats?.incomeTrend ?? [])}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
@@ -921,102 +491,10 @@ const DashboardPage: React.FC = () => {
</Row>
{/* ═══════════ 图表:教室占用热力图(懒加载) ═══════════ */}
<div ref={classroomHeatmapVp.ref} style={SECTION_ROW_STYLE}>
{classroomHeatmapVp.inView ? (
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card title="教室占用热力图">
{classroomOccupancy.length > 0 ? (
<ReactECharts
option={
{
tooltip: {
formatter: (p: {
name: string;
data: { scheduleDays: number; rentalCount: number; occupancy: number };
}) =>
`${p.name}<br/>排课: ${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 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
)}
</Card>
</Col>
</Row>
) : (
<Card title="教室占用热力图" style={{ minHeight: isMobile ? 340 : 440 }}>
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
</Card>
)}
</div>
<ClassroomHeatmapCard data={classroomOccupancy} isMobile={isMobile} />
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
<div ref={ganttVp.ref}>
{ganttVp.inView ? (
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card title="入住时间线(甘特图)">
{ganttData.length > 0 ? (
<ReactECharts
option={ganttOption}
style={{ width: '100%', height: isMobile ? 300 : 450 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
)}
</Card>
</Col>
</Row>
) : (
<Card title="入住时间线(甘特图)" style={{ minHeight: isMobile ? 340 : 490 }}>
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
</Card>
)}
</div>
<GanttCard data={ganttData} isMobile={isMobile} />
</div>
);
};