fix permissions and teacher attendance workflows
This commit is contained in:
@@ -1,15 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Statistic,
|
||||
DatePicker,
|
||||
Spin,
|
||||
Grid,
|
||||
message,
|
||||
Collapse,
|
||||
} from 'antd';
|
||||
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, Collapse } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
HomeOutlined,
|
||||
@@ -25,10 +15,11 @@ import {
|
||||
ExclamationCircleOutlined,
|
||||
DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
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;
|
||||
|
||||
@@ -43,14 +34,48 @@ const COLORS = [
|
||||
'#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 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;
|
||||
@@ -160,7 +185,10 @@ const DashboardPage: React.FC = () => {
|
||||
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 [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 }>>([]);
|
||||
@@ -186,7 +214,9 @@ const DashboardPage: React.FC = () => {
|
||||
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<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>(
|
||||
'/dashboard/class-attendance-ranking',
|
||||
),
|
||||
api.get<GanttRoom[]>('/dashboard/gantt', {
|
||||
params: { periodStart: period[0], periodEnd: period[1] },
|
||||
}),
|
||||
@@ -215,56 +245,69 @@ const DashboardPage: React.FC = () => {
|
||||
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(() => {});
|
||||
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(() => ({
|
||||
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 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(() => ({
|
||||
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] },
|
||||
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,
|
||||
},
|
||||
],
|
||||
}), [roomRanking]);
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: roomRanking.map((r) => Number(r.total)).reverse(),
|
||||
itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
[roomRanking],
|
||||
);
|
||||
|
||||
// 班级考勤排行 - 前5
|
||||
const classRankingTopOption = {
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: number) => `${v}%` },
|
||||
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: {
|
||||
@@ -283,8 +326,12 @@ const DashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// 班级考勤排行 - 后5
|
||||
const classRankingBottomOption = {
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (v: number) => `${v}%` },
|
||||
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: {
|
||||
@@ -303,7 +350,7 @@ const DashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// 考勤趋势折线图
|
||||
const attendanceLineOption = {
|
||||
const attendanceLineOption: EChartsOption = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 50, right: 20, bottom: 30, top: 10 },
|
||||
xAxis: {
|
||||
@@ -325,14 +372,17 @@ const DashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// 收入趋势折线图
|
||||
const incomeLineOption = {
|
||||
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)}万` } },
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}万` },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
@@ -346,57 +396,68 @@ const DashboardPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// 入住时间线(甘特图)
|
||||
const ganttOption = useMemo(() => ({
|
||||
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 },
|
||||
};
|
||||
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]}`,
|
||||
},
|
||||
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]);
|
||||
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');
|
||||
@@ -425,7 +486,9 @@ const DashboardPage: React.FC = () => {
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0 }}>工作台{refreshLoading && <Spin size="small" style={{ marginLeft: 12 }} />}</h2>
|
||||
<h2 style={{ margin: 0 }}>
|
||||
工作台{refreshLoading && <Spin size="small" style={{ marginLeft: 12 }} />}
|
||||
</h2>
|
||||
<RangePicker
|
||||
aria-label="选择日期范围"
|
||||
value={[dayjs(period[0]), dayjs(period[1])]}
|
||||
@@ -445,14 +508,22 @@ const DashboardPage: React.FC = () => {
|
||||
styles={{ body: { padding: 16 } }}
|
||||
onClick={() => navigate('/attendance')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<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' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: absentCount > 0 ? '#FF9500' : '#999',
|
||||
}}
|
||||
>
|
||||
{absentCount}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#666', marginTop: 2 }}>今日缺勤人数</div>
|
||||
@@ -472,18 +543,28 @@ const DashboardPage: React.FC = () => {
|
||||
styles={{ body: { padding: 16 } }}
|
||||
onClick={() => navigate('/bills')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<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' }}>
|
||||
<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 }}>
|
||||
<div
|
||||
style={{ fontSize: 12, color: draftCount > 0 ? '#AF52DE' : '#999', marginTop: 4 }}
|
||||
>
|
||||
{draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,18 +578,32 @@ const DashboardPage: React.FC = () => {
|
||||
styles={{ body: { padding: 16 } }}
|
||||
onClick={() => navigate('/deposits')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<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' }}>
|
||||
<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 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: pendingDeposits > 0 ? '#FF3B30' : '#999',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{pendingDeposits > 0 ? '需要处理' : '暂无待退'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -577,90 +672,135 @@ const DashboardPage: React.FC = () => {
|
||||
{/* ═══════════ 更多指标(折叠) ═══════════ */}
|
||||
<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 />} />
|
||||
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={6}>
|
||||
<Statistic title="今日使用" value={classroomUtil?.inUseCount ?? '-'} prefix={<CheckCircleOutlined />} />
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="在读学生"
|
||||
value={stats?.totalStudents || 0}
|
||||
prefix={<TeamOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</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 xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="教室总数"
|
||||
value={stats?.classroomCount || 0}
|
||||
prefix={<BankOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic title="内部排课" value={classroomUtil?.scheduleCount ?? '-'} prefix={<CalendarOutlined />} />
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="班级总数"
|
||||
value={stats?.classCount ?? 0}
|
||||
prefix={<TeamOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</>
|
||||
),
|
||||
}]}
|
||||
<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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ═══════════ 图表:考勤趋势 + 出勤分布 ═══════════ */}
|
||||
@@ -668,7 +808,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="考勤趋势(近30天)">
|
||||
{(stats?.attendanceTrend || []).length > 0 ? (
|
||||
<ReactECharts option={attendanceLineOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
||||
<ReactECharts
|
||||
option={attendanceLineOption}
|
||||
style={{ width: '100%', height: isMobile ? 250 : 300 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无考勤数据</div>
|
||||
)}
|
||||
@@ -677,7 +820,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="今日出勤状态分布">
|
||||
{Object.keys(stats?.attendanceByStatus ?? {}).length > 0 ? (
|
||||
<ReactECharts option={attendanceRingOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
||||
<ReactECharts
|
||||
option={attendanceRingOption}
|
||||
style={{ width: '100%', height: isMobile ? 250 : 300 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无考勤数据</div>
|
||||
)}
|
||||
@@ -690,7 +836,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="班级出勤率 TOP 5">
|
||||
{classRanking.top.length > 0 ? (
|
||||
<ReactECharts option={classRankingTopOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
||||
<ReactECharts
|
||||
option={classRankingTopOption}
|
||||
style={{ width: '100%', height: isMobile ? 250 : 300 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无考勤数据</div>
|
||||
)}
|
||||
@@ -699,7 +848,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="班级出勤率 末位 5">
|
||||
{classRanking.bottom.length > 0 ? (
|
||||
<ReactECharts option={classRankingBottomOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
||||
<ReactECharts
|
||||
option={classRankingBottomOption}
|
||||
style={{ width: '100%', height: isMobile ? 250 : 300 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无考勤数据</div>
|
||||
)}
|
||||
@@ -711,13 +863,28 @@ const DashboardPage: React.FC = () => {
|
||||
<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) })) }],
|
||||
}} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
||||
{(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>
|
||||
)}
|
||||
@@ -726,7 +893,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="宿舍费用排行 TOP 20">
|
||||
{roomRanking.length > 0 ? (
|
||||
<ReactECharts option={barOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
||||
<ReactECharts
|
||||
option={barOption}
|
||||
style={{ width: '100%', height: isMobile ? 250 : 300 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无费用数据</div>
|
||||
)}
|
||||
@@ -739,7 +909,10 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24}>
|
||||
<Card title="月度收入趋势">
|
||||
{(stats?.incomeTrend || []).length > 0 ? (
|
||||
<ReactECharts option={incomeLineOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
|
||||
<ReactECharts
|
||||
option={incomeLineOption}
|
||||
style={{ width: '100%', height: isMobile ? 250 : 300 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无收入数据</div>
|
||||
)}
|
||||
@@ -754,27 +927,60 @@ const DashboardPage: React.FC = () => {
|
||||
<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)}%` },
|
||||
}],
|
||||
}} style={{ width: '100%', height: isMobile ? 300 : 400 }} />
|
||||
<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>
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
|
||||
暂无教室数据
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -793,9 +999,14 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={24}>
|
||||
<Card title="入住时间线(甘特图)">
|
||||
{ganttData.length > 0 ? (
|
||||
<ReactECharts option={ganttOption} style={{ width: '100%', height: isMobile ? 300 : 450 }} />
|
||||
<ReactECharts
|
||||
option={ganttOption}
|
||||
style={{ width: '100%', height: isMobile ? 300 : 450 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无入住数据</div>
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
|
||||
暂无入住数据
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
Reference in New Issue
Block a user