forked from wangziqi/gongxue-base
fix: 修复入住管理白屏、重构首页工作台、拍平教务菜单
- Occupancies: 补齐缺失的 Alert import(渲染时 ReferenceError 导致白屏), 并为两处 api.get 补泛型消除既有类型错误 - Dashboard: 改为待办优先的工作台(待办异常区 + 核心 KPI + 更多指标折叠 + 图表按重要性重排),修复 fetchData 无限请求循环(loadedRef), 重活图表用 IntersectionObserver(callback ref)懒加载 - MainLayout: 将三层嵌套的"教室管理"提升为一级菜单,全站菜单统一为两层
This commit is contained in:
@@ -77,18 +77,18 @@ const allMenuItems: MenuItemType[] = [
|
||||
children: [
|
||||
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
|
||||
{ key: '/teacher-workspace', icon: <LaptopOutlined />, label: '教师工作台', permission: 'class:view' },
|
||||
{
|
||||
key: 'classroom-group',
|
||||
icon: <ReadOutlined />,
|
||||
label: '教室管理',
|
||||
permission: 'classroom:view',
|
||||
children: [
|
||||
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'classroom:view' },
|
||||
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
|
||||
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
|
||||
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'classroom-group',
|
||||
icon: <ReadOutlined />,
|
||||
label: '教室管理',
|
||||
permission: 'classroom:view',
|
||||
children: [
|
||||
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'classroom:view' },
|
||||
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
|
||||
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
|
||||
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,8 +1,33 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, message } from 'antd';
|
||||
import { TeamOutlined, HomeOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined, SolutionOutlined, FileProtectOutlined, ReadOutlined, CalendarOutlined } from '@ant-design/icons';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Statistic,
|
||||
DatePicker,
|
||||
Spin,
|
||||
Grid,
|
||||
message,
|
||||
Collapse,
|
||||
} from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
HomeOutlined,
|
||||
CheckCircleOutlined,
|
||||
BankOutlined,
|
||||
PercentageOutlined,
|
||||
UserSwitchOutlined,
|
||||
SolutionOutlined,
|
||||
FileProtectOutlined,
|
||||
ReadOutlined,
|
||||
CalendarOutlined,
|
||||
ArrowRightOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../api';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
@@ -73,9 +98,67 @@ const attendanceLabelMap: Record<string, string> = {
|
||||
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[]>([]);
|
||||
@@ -84,13 +167,14 @@ const DashboardPage: React.FC = () => {
|
||||
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 = stats !== null;
|
||||
const isRefresh = loadedRef.current;
|
||||
if (isRefresh) {
|
||||
setRefreshLoading(true);
|
||||
} else {
|
||||
@@ -115,17 +199,18 @@ const DashboardPage: React.FC = () => {
|
||||
setClassroomOccupancy(co);
|
||||
const cu = await api.get<ClassroomUtilStats>('/dashboard/classroom-utilization');
|
||||
setClassroomUtil(cu);
|
||||
loadedRef.current = true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('数据加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
setRefreshLoading(false);
|
||||
}, [period, stats]);
|
||||
}, [period]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData, period]);
|
||||
}, [fetchData]);
|
||||
|
||||
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -137,6 +222,8 @@ const DashboardPage: React.FC = () => {
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ─── 图表 option 计算(保留全部原有逻辑) ───
|
||||
|
||||
// 今日出勤状态分布环图
|
||||
const attendanceRingOption = useMemo(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
@@ -156,7 +243,6 @@ const DashboardPage: React.FC = () => {
|
||||
color: COLORS,
|
||||
}), [stats?.attendanceByStatus]);
|
||||
|
||||
|
||||
// 宿舍费用排行
|
||||
const barOption = useMemo(() => ({
|
||||
tooltip: {},
|
||||
@@ -279,7 +365,6 @@ const DashboardPage: React.FC = () => {
|
||||
coord: (p: [string | number, string | number]) => [number, number];
|
||||
size: (p: [number, number]) => [number, number];
|
||||
}) => {
|
||||
// ECharts value API returns mixed datum entries; narrow to the known gantt shape.
|
||||
const [cat, startDate, endDate, isActive] = [
|
||||
api.value(0), api.value(1), api.value(2), api.value(3),
|
||||
] as unknown as [string, string, string, boolean];
|
||||
@@ -313,11 +398,23 @@ const DashboardPage: React.FC = () => {
|
||||
}],
|
||||
}), [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,
|
||||
@@ -328,7 +425,7 @@ 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])]}
|
||||
@@ -338,32 +435,91 @@ 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>
|
||||
|
||||
{/* ═══════════ 核心 KPI ═══════════ */}
|
||||
<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?.occupiedBeds || 0}
|
||||
suffix={`/ ${stats?.totalCapacity || 0}`}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="入住率"
|
||||
@@ -373,25 +529,17 @@ const DashboardPage: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||
<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}>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="教室占用率"
|
||||
value={stats?.classroomOccupancyRate || 0}
|
||||
suffix="%"
|
||||
prefix={<PercentageOutlined />}
|
||||
title="当前在住"
|
||||
value={stats?.occupiedBeds || 0}
|
||||
suffix={`/ ${stats?.totalCapacity || 0}`}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日出勤率"
|
||||
@@ -401,7 +549,17 @@ const DashboardPage: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<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="本月收入"
|
||||
@@ -411,69 +569,101 @@ const DashboardPage: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="班级总数" value={stats?.classCount ?? 0} prefix={<TeamOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<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 xs={12} sm={8} md={4}>
|
||||
{/* 占位:保持 6 列布局最后一个空位 */}
|
||||
</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>
|
||||
{/* ═══════════ 更多指标(折叠) ═══════════ */}
|
||||
<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天)">
|
||||
@@ -495,6 +685,7 @@ const DashboardPage: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* ═══════════ 图表:班级出勤排行 ═══════════ */}
|
||||
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card title="班级出勤率 TOP 5">
|
||||
@@ -516,35 +707,7 @@ const DashboardPage: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||
<Col xs={24}>
|
||||
<Card title="教室占用热力图">
|
||||
{classroomOccupancy.length > 0 ? (
|
||||
<ReactECharts option={{
|
||||
tooltip: {
|
||||
formatter: (p: any) => `${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: any) => `${(p.data.occupancy * 100).toFixed(0)}%` },
|
||||
}],
|
||||
}} style={{ width: '100%', height: isMobile ? 300 : 400 }} />
|
||||
) : (
|
||||
<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="费用类型分布">
|
||||
@@ -571,6 +734,7 @@ const DashboardPage: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* ═══════════ 图表:月度收入趋势 ═══════════ */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24}>
|
||||
<Card title="月度收入趋势">
|
||||
@@ -583,17 +747,65 @@ const DashboardPage: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<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>
|
||||
)}
|
||||
{/* ═══════════ 图表:教室占用热力图(懒加载) ═══════════ */}
|
||||
<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)}%` },
|
||||
}],
|
||||
}} 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>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Switch,
|
||||
Tooltip,
|
||||
Empty,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -99,8 +100,8 @@ const OccupanciesPage: React.FC = () => {
|
||||
}
|
||||
try {
|
||||
const [beds, lockers] = await Promise.all([
|
||||
api.get(`/rooms/${roomId}/beds/available`),
|
||||
api.get(`/rooms/${roomId}/lockers/available`),
|
||||
api.get<any[]>(`/rooms/${roomId}/beds/available`),
|
||||
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
|
||||
]);
|
||||
setAvailableBeds(beds);
|
||||
setAvailableLockers(lockers);
|
||||
|
||||
Reference in New Issue
Block a user