1025 lines
34 KiB
TypeScript
1025 lines
34 KiB
TypeScript
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;
|