fix: 修复入住管理白屏、重构首页工作台、拍平教务菜单

- Occupancies: 补齐缺失的 Alert import(渲染时 ReferenceError 导致白屏),
  并为两处 api.get 补泛型消除既有类型错误
- Dashboard: 改为待办优先的工作台(待办异常区 + 核心 KPI + 更多指标折叠 +
  图表按重要性重排),修复 fetchData 无限请求循环(loadedRef),
  重活图表用 IntersectionObserver(callback ref)懒加载
- MainLayout: 将三层嵌套的"教室管理"提升为一级菜单,全站菜单统一为两层
This commit is contained in:
2026-07-10 11:16:30 +08:00
parent f6a84fd6b3
commit bc6b8b0095
3 changed files with 374 additions and 161 deletions

View File

@@ -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' },
],
},
{

View File

@@ -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>
);
};

View File

@@ -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);