diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index a057799..eb7236e 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -77,18 +77,18 @@ const allMenuItems: MenuItemType[] = [ children: [ { key: '/schedules', icon: , label: '排课管理', permission: 'schedule:view' }, { key: '/teacher-workspace', icon: , label: '教师工作台', permission: 'class:view' }, - { - key: 'classroom-group', - icon: , - label: '教室管理', - permission: 'classroom:view', - children: [ - { key: '/classroom-schedule', icon: , label: '排期总览', permission: 'classroom:view' }, - { key: '/classrooms', icon: , label: '教室列表', permission: 'classroom:view' }, - { key: '/classroom-rentals', icon: , label: '租赁订单', permission: 'rental:view' }, - { key: '/tenants', icon: , label: '租赁方', permission: 'tenant:view' }, - ], - }, + ], + }, + { + key: 'classroom-group', + icon: , + label: '教室管理', + permission: 'classroom:view', + children: [ + { key: '/classroom-schedule', icon: , label: '排期总览', permission: 'classroom:view' }, + { key: '/classrooms', icon: , label: '教室列表', permission: 'classroom:view' }, + { key: '/classroom-rentals', icon: , label: '租赁订单', permission: 'rental:view' }, + { key: '/tenants', icon: , label: '租赁方', permission: 'tenant:view' }, ], }, { diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index 55a8c5e..219b1e4 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -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 = { 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(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(null); const [classRanking, setClassRanking] = useState<{ top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] }>({ top: [], bottom: [] }); const [classroomOccupancy, setClassroomOccupancy] = useState([]); @@ -84,13 +167,14 @@ const DashboardPage: React.FC = () => { const [classroomUtil, setClassroomUtil] = useState(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('/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>({}); @@ -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 ; return (
+ {/* ─── 顶部标题栏 ─── */}
{ gap: 12, }} > -

数据面板{refreshLoading && }

+

工作台{refreshLoading && }

{ />
+ {/* ═══════════ 待办与异常 ═══════════ */} + + + {/* 今日缺勤 */} + + 0 ? TODO_CARD_WARN : TODO_CARD_OK} + styles={{ body: { padding: 16 } }} + onClick={() => navigate('/attendance')} + > +
+ 0 ? '#FF9500' : '#999' }} + /> + +
+
+
0 ? '#FF9500' : '#999' }}> + {absentCount} +
+
今日缺勤人数
+ {absentCount > 0 ? ( +
需要关注
+ ) : ( +
全员到齐
+ )} +
+
+ + + {/* 待处理账单 */} + + 0 ? TODO_CARD_DRAFT : TODO_CARD_OK} + styles={{ body: { padding: 16 } }} + onClick={() => navigate('/bills')} + > +
+ 0 ? '#AF52DE' : '#999' }} + /> + +
+
+
0 ? '#AF52DE' : '#999' }}> + {draftCount} +
+
待处理账单
+
0 ? '#AF52DE' : '#999', marginTop: 4 }}> + {draftCount > 0 ? `合计 ¥${draftTotal.toLocaleString()}` : '暂无待处理'} +
+
+
+ + + {/* 待退押金 */} + + 0 ? TODO_CARD_DANGER : TODO_CARD_OK} + styles={{ body: { padding: 16 } }} + onClick={() => navigate('/deposits')} + > +
+ 0 ? '#FF3B30' : '#999' }} + /> + +
+
+
0 ? '#FF3B30' : '#999' }}> + ¥{pendingDeposits.toLocaleString()} +
+
待退押金
+
0 ? '#FF3B30' : '#999', marginTop: 4 }}> + {pendingDeposits > 0 ? '需要处理' : '暂无待退'} +
+
+
+ +
+
+ + {/* ═══════════ 核心 KPI ═══════════ */} - - - } /> - - - - - } - /> - - - - - } - /> - - - + { /> - - - - - - } /> - - - + } + title="当前在住" + value={stats?.occupiedBeds || 0} + suffix={`/ ${stats?.totalCapacity || 0}`} + prefix={} /> - + { /> - + + + } + /> + + + { /> - - - - - - } /> - - - - - } /> - - - - - - - - - - } /> - - - - - } - /> - + + {/* 占位:保持 6 列布局最后一个空位 */} - - - - } /> - - - } /> - - - } - styles={{ value: { color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' } }} - /> - - - } /> - - - + {/* ═══════════ 更多指标(折叠) ═══════════ */} + + + + + } /> + + + + + } /> + + + + + } /> + + + + + } /> + + + + + + + } /> + + + + + + + + + + } /> + + + + + } + /> + + + + + + + } /> + + + } /> + + + } + styles={{ value: { color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' } }} + /> + + + } /> + + + + + ), + }]} + /> + {/* ═══════════ 图表:考勤趋势 + 出勤分布 ═══════════ */} @@ -495,6 +685,7 @@ const DashboardPage: React.FC = () => { + {/* ═══════════ 图表:班级出勤排行 ═══════════ */} @@ -516,35 +707,7 @@ const DashboardPage: React.FC = () => { - - - - {classroomOccupancy.length > 0 ? ( - `${p.name}
排课: ${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 }} /> - ) : ( -
暂无教室数据
- )} -
- -
- + {/* ═══════════ 图表:费用分布 + 宿舍排行 ═══════════ */} @@ -571,6 +734,7 @@ const DashboardPage: React.FC = () => { + {/* ═══════════ 图表:月度收入趋势 ═══════════ */} @@ -583,17 +747,65 @@ const DashboardPage: React.FC = () => { - - - - {ganttData.length > 0 ? ( - - ) : ( -
暂无入住数据
- )} + {/* ═══════════ 图表:教室占用热力图(懒加载) ═══════════ */} +
+ {classroomHeatmapVp.inView ? ( + + + + {classroomOccupancy.length > 0 ? ( + + `${p.name}
排课: ${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 }} /> + ) : ( +
暂无教室数据
+ )} +
+ +
+ ) : ( + +
加载中…
- - + )} +
+ + {/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */} +
+ {ganttVp.inView ? ( + + + + {ganttData.length > 0 ? ( + + ) : ( +
暂无入住数据
+ )} +
+ +
+ ) : ( + +
加载中…
+
+ )} +
); }; diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 8f86cc5..9a757b3 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -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(`/rooms/${roomId}/beds/available`), + api.get(`/rooms/${roomId}/lockers/available`), ]); setAvailableBeds(beds); setAvailableLockers(lockers);