Files
gongxue-base/apps/admin/src/pages/Dashboard/index.tsx

1025 lines
34 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 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<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]);
useEffect(() => {
fetchData();
}, [fetchData]);
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
useEffect(() => {
api
.get<Array<{ code: string; name: string }>>('/expense-types')
.then((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}%`,
},
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 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 <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
return (
<div>
{/* ─── 顶部标题栏 ─── */}
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: isMobile ? 'flex-start' : 'center',
flexDirection: isMobile ? 'column' : 'row',
gap: 12,
}}
>
<h2 style={{ margin: 0 }}>
{refreshLoading && <Spin size="small" style={{ marginLeft: 12 }} />}
</h2>
<RangePicker
aria-label="选择日期范围"
value={[dayjs(period[0]), dayjs(period[1])]}
onChange={(dates) => {
if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]);
}}
/>
</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>
{/* ═══════════ 核心 KPI ═══════════ */}
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={12} sm={8} md={4}>
<Card>
<Statistic
title="入住率"
value={stats?.occupancyRate || 0}
suffix="%"
prefix={<PercentageOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card>
<Statistic
title="当前在住"
value={stats?.occupiedBeds || 0}
suffix={`/ ${stats?.totalCapacity || 0}`}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card>
<Statistic
title="今日出勤率"
value={stats?.todayAttendanceRate || 0}
suffix="%"
prefix={<UserSwitchOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card>
<Statistic
title="教室占用率"
value={stats?.classroomOccupancyRate || 0}
suffix="%"
prefix={<PercentageOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
<Card>
<Statistic
title="本月收入"
value={stats?.monthlyIncome || 0}
precision={2}
prefix="¥"
/>
</Card>
</Col>
<Col xs={12} sm={8} md={4}>
{/* 占位:保持 6 列布局最后一个空位 */}
</Col>
</Row>
{/* ═══════════ 更多指标(折叠) ═══════════ */}
<Collapse
ghost
items={[
{
key: 'more-metrics',
label: '更多指标',
children: (
<>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="宿舍总数"
value={stats?.totalRooms || 0}
prefix={<HomeOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="在读学生"
value={stats?.totalStudents || 0}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="教室总数"
value={stats?.classroomCount || 0}
prefix={<BankOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="班级总数"
value={stats?.classCount ?? 0}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="教师总数"
value={stats?.teacherCount ?? 0}
prefix={<SolutionOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="待退押金"
value={stats?.pendingDeposits ?? 0}
precision={2}
prefix="¥"
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="活跃租赁"
value={stats?.activeRentals ?? 0}
prefix={<FileProtectOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="今日出勤"
value={stats?.todayPresent ?? 0}
suffix="人"
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
</Row>
<Card title="教室利用率" style={MARGIN_BOTTOM_16_STYLE}>
<Row gutter={[24, 16]}>
<Col xs={12} sm={6}>
<Statistic
title="教室总数"
value={classroomUtil?.totalClassrooms ?? '-'}
prefix={<ReadOutlined />}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title="今日使用"
value={classroomUtil?.inUseCount ?? '-'}
prefix={<CheckCircleOutlined />}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title="利用率"
value={classroomUtil?.utilizationRate ?? '-'}
suffix="%"
prefix={<PercentageOutlined />}
styles={{
value: {
color:
Number(classroomUtil?.utilizationRate ?? 0) > 70
? '#34C759'
: '#FF9500',
},
}}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title="内部排课"
value={classroomUtil?.scheduleCount ?? '-'}
prefix={<CalendarOutlined />}
/>
</Col>
</Row>
</Card>
</>
),
},
]}
/>
{/* ═══════════ 图表:考勤趋势 + 出勤分布 ═══════════ */}
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24} sm={12}>
<Card title="考勤趋势近30天">
{(stats?.attendanceTrend || []).length > 0 ? (
<ReactECharts
option={attendanceLineOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
<Col xs={24} sm={12}>
<Card title="今日出勤状态分布">
{Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? (
<ReactECharts
option={attendanceRingOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
</Row>
{/* ═══════════ 图表:班级出勤排行 ═══════════ */}
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24} sm={12}>
<Card title="班级出勤率 TOP 5">
{classRanking.top.length > 0 ? (
<ReactECharts
option={classRankingTopOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
<Col xs={24} sm={12}>
<Card title="班级出勤率 末位 5">
{classRanking.bottom.length > 0 ? (
<ReactECharts
option={classRankingBottomOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
</Row>
{/* ═══════════ 图表:费用分布 + 宿舍排行 ═══════════ */}
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
<Col xs={24} sm={12}>
<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
}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
<Col xs={24} sm={12}>
<Card title="宿舍费用排行 TOP 20">
{roomRanking.length > 0 ? (
<ReactECharts
option={barOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
</Row>
{/* ═══════════ 图表:月度收入趋势 ═══════════ */}
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card title="月度收入趋势">
{(stats?.incomeTrend || []).length > 0 ? (
<ReactECharts
option={incomeLineOption}
style={{ width: '100%', height: isMobile ? 250 : 300 }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
</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>
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
<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>
</div>
);
};
export default DashboardPage;