fix: 修复入住管理白屏、重构首页工作台、拍平教务菜单
- Occupancies: 补齐缺失的 Alert import(渲染时 ReferenceError 导致白屏), 并为两处 api.get 补泛型消除既有类型错误 - Dashboard: 改为待办优先的工作台(待办异常区 + 核心 KPI + 更多指标折叠 + 图表按重要性重排),修复 fetchData 无限请求循环(loadedRef), 重活图表用 IntersectionObserver(callback ref)懒加载 - MainLayout: 将三层嵌套的"教室管理"提升为一级菜单,全站菜单统一为两层
This commit is contained in:
@@ -77,6 +77,8 @@ const allMenuItems: MenuItemType[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
|
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
|
||||||
{ key: '/teacher-workspace', icon: <LaptopOutlined />, label: '教师工作台', permission: 'class:view' },
|
{ key: '/teacher-workspace', icon: <LaptopOutlined />, label: '教师工作台', permission: 'class:view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'classroom-group',
|
key: 'classroom-group',
|
||||||
icon: <ReadOutlined />,
|
icon: <ReadOutlined />,
|
||||||
@@ -89,8 +91,6 @@ const allMenuItems: MenuItemType[] = [
|
|||||||
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
|
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'finance-group',
|
key: 'finance-group',
|
||||||
icon: <DollarOutlined />,
|
icon: <DollarOutlined />,
|
||||||
|
|||||||
@@ -1,8 +1,33 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid, message } from 'antd';
|
import {
|
||||||
import { TeamOutlined, HomeOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined, SolutionOutlined, FileProtectOutlined, ReadOutlined, CalendarOutlined } from '@ant-design/icons';
|
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 ReactECharts from 'echarts-for-react';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
@@ -73,9 +98,67 @@ const attendanceLabelMap: Record<string, string> = {
|
|||||||
const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 };
|
const SECTION_ROW_STYLE: React.CSSProperties = { marginBottom: 24 };
|
||||||
const MARGIN_BOTTOM_16_STYLE: React.CSSProperties = { marginBottom: 16 };
|
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 DashboardPage: React.FC = () => {
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.sm;
|
const isMobile = !screens.sm;
|
||||||
|
const navigate = useNavigate();
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
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 [classroomOccupancy, setClassroomOccupancy] = useState<ClassroomOccupancy[]>([]);
|
||||||
@@ -84,13 +167,14 @@ const DashboardPage: React.FC = () => {
|
|||||||
const [classroomUtil, setClassroomUtil] = useState<ClassroomUtilStats | null>(null);
|
const [classroomUtil, setClassroomUtil] = useState<ClassroomUtilStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||||
|
const loadedRef = useRef(false);
|
||||||
const [period, setPeriod] = useState<[string, string]>([
|
const [period, setPeriod] = useState<[string, string]>([
|
||||||
dayjs().startOf('month').format('YYYY-MM-DD'),
|
dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||||
dayjs().endOf('month').format('YYYY-MM-DD'),
|
dayjs().endOf('month').format('YYYY-MM-DD'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
const isRefresh = stats !== null;
|
const isRefresh = loadedRef.current;
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
setRefreshLoading(true);
|
setRefreshLoading(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -115,17 +199,18 @@ const DashboardPage: React.FC = () => {
|
|||||||
setClassroomOccupancy(co);
|
setClassroomOccupancy(co);
|
||||||
const cu = await api.get<ClassroomUtilStats>('/dashboard/classroom-utilization');
|
const cu = await api.get<ClassroomUtilStats>('/dashboard/classroom-utilization');
|
||||||
setClassroomUtil(cu);
|
setClassroomUtil(cu);
|
||||||
|
loadedRef.current = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
message.error('数据加载失败,请稍后重试');
|
message.error('数据加载失败,请稍后重试');
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshLoading(false);
|
setRefreshLoading(false);
|
||||||
}, [period, stats]);
|
}, [period]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData, period]);
|
}, [fetchData]);
|
||||||
|
|
||||||
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
|
const [expenseTypeMap, setExpenseTypeMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -137,6 +222,8 @@ const DashboardPage: React.FC = () => {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ─── 图表 option 计算(保留全部原有逻辑) ───
|
||||||
|
|
||||||
// 今日出勤状态分布环图
|
// 今日出勤状态分布环图
|
||||||
const attendanceRingOption = useMemo(() => ({
|
const attendanceRingOption = useMemo(() => ({
|
||||||
tooltip: { trigger: 'item' },
|
tooltip: { trigger: 'item' },
|
||||||
@@ -156,7 +243,6 @@ const DashboardPage: React.FC = () => {
|
|||||||
color: COLORS,
|
color: COLORS,
|
||||||
}), [stats?.attendanceByStatus]);
|
}), [stats?.attendanceByStatus]);
|
||||||
|
|
||||||
|
|
||||||
// 宿舍费用排行
|
// 宿舍费用排行
|
||||||
const barOption = useMemo(() => ({
|
const barOption = useMemo(() => ({
|
||||||
tooltip: {},
|
tooltip: {},
|
||||||
@@ -279,7 +365,6 @@ const DashboardPage: React.FC = () => {
|
|||||||
coord: (p: [string | number, string | number]) => [number, number];
|
coord: (p: [string | number, string | number]) => [number, number];
|
||||||
size: (p: [number, 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] = [
|
const [cat, startDate, endDate, isActive] = [
|
||||||
api.value(0), api.value(1), api.value(2), api.value(3),
|
api.value(0), api.value(1), api.value(2), api.value(3),
|
||||||
] as unknown as [string, string, string, boolean];
|
] as unknown as [string, string, string, boolean];
|
||||||
@@ -313,11 +398,23 @@ const DashboardPage: React.FC = () => {
|
|||||||
}],
|
}],
|
||||||
}), [ganttData]);
|
}), [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)
|
if (loading && !stats)
|
||||||
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* ─── 顶部标题栏 ─── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -328,7 +425,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
gap: 12,
|
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
|
<RangePicker
|
||||||
aria-label="选择日期范围"
|
aria-label="选择日期范围"
|
||||||
value={[dayjs(period[0]), dayjs(period[1])]}
|
value={[dayjs(period[0]), dayjs(period[1])]}
|
||||||
@@ -338,32 +435,91 @@ const DashboardPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||||
<Col xs={12} sm={12} md={6}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<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}>
|
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="入住率"
|
title="入住率"
|
||||||
@@ -373,25 +529,17 @@ const DashboardPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
<Col xs={12} sm={8} md={4}>
|
||||||
|
|
||||||
<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}>
|
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="教室占用率"
|
title="当前在住"
|
||||||
value={stats?.classroomOccupancyRate || 0}
|
value={stats?.occupiedBeds || 0}
|
||||||
suffix="%"
|
suffix={`/ ${stats?.totalCapacity || 0}`}
|
||||||
prefix={<PercentageOutlined />}
|
prefix={<CheckCircleOutlined />}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={12} md={6}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="今日出勤率"
|
title="今日出勤率"
|
||||||
@@ -401,7 +549,17 @@ const DashboardPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</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>
|
<Card>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="本月收入"
|
title="本月收入"
|
||||||
@@ -411,14 +569,42 @@ const DashboardPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col xs={12} sm={8} md={4}>
|
||||||
|
{/* 占位:保持 6 列布局最后一个空位 */}
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* ═══════════ 更多指标(折叠) ═══════════ */}
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
items={[{
|
||||||
|
key: 'more-metrics',
|
||||||
|
label: '更多指标',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
<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}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic title="班级总数" value={stats?.classCount ?? 0} prefix={<TeamOutlined />} />
|
<Statistic title="班级总数" value={stats?.classCount ?? 0} prefix={<TeamOutlined />} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||||
<Col xs={12} sm={12} md={6}>
|
<Col xs={12} sm={12} md={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic title="教师总数" value={stats?.teacherCount ?? 0} prefix={<SolutionOutlined />} />
|
<Statistic title="教师总数" value={stats?.teacherCount ?? 0} prefix={<SolutionOutlined />} />
|
||||||
@@ -450,7 +636,6 @@ const DashboardPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card title="教室利用率" style={MARGIN_BOTTOM_16_STYLE}>
|
<Card title="教室利用率" style={MARGIN_BOTTOM_16_STYLE}>
|
||||||
<Row gutter={[24, 16]}>
|
<Row gutter={[24, 16]}>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
@@ -473,7 +658,12 @@ const DashboardPage: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ═══════════ 图表:考勤趋势 + 出勤分布 ═══════════ */}
|
||||||
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Card title="考勤趋势(近30天)">
|
<Card title="考勤趋势(近30天)">
|
||||||
@@ -495,6 +685,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* ═══════════ 图表:班级出勤排行 ═══════════ */}
|
||||||
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Card title="班级出勤率 TOP 5">
|
<Card title="班级出勤率 TOP 5">
|
||||||
@@ -516,35 +707,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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}>
|
<Row gutter={[16, 16]} style={SECTION_ROW_STYLE}>
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Card title="费用类型分布">
|
<Card title="费用类型分布">
|
||||||
@@ -571,6 +734,7 @@ const DashboardPage: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* ═══════════ 图表:月度收入趋势 ═══════════ */}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24}>
|
<Col xs={24}>
|
||||||
<Card title="月度收入趋势">
|
<Card title="月度收入趋势">
|
||||||
@@ -583,6 +747,48 @@ const DashboardPage: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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)}%` },
|
||||||
|
}],
|
||||||
|
}} 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]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24}>
|
<Col xs={24}>
|
||||||
<Card title="入住时间线(甘特图)">
|
<Card title="入住时间线(甘特图)">
|
||||||
@@ -594,6 +800,12 @@ const DashboardPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Card title="入住时间线(甘特图)" style={{ minHeight: isMobile ? 340 : 490 }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>加载中…</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Empty,
|
Empty,
|
||||||
|
Alert,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -99,8 +100,8 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [beds, lockers] = await Promise.all([
|
const [beds, lockers] = await Promise.all([
|
||||||
api.get(`/rooms/${roomId}/beds/available`),
|
api.get<any[]>(`/rooms/${roomId}/beds/available`),
|
||||||
api.get(`/rooms/${roomId}/lockers/available`),
|
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
|
||||||
]);
|
]);
|
||||||
setAvailableBeds(beds);
|
setAvailableBeds(beds);
|
||||||
setAvailableLockers(lockers);
|
setAvailableLockers(lockers);
|
||||||
|
|||||||
Reference in New Issue
Block a user