Files
gongxue-base/apps/admin/src/pages/ClassroomSchedule/index.tsx
wangziqi 67435e46ca feat(admin): 用户体验体系化提升与高危缺陷修复
UX 缺陷修复:
- 校验失败不再卡死弹窗按钮(Users/Roles/Bills)
- 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选
- AI 表单/批量确认不再出现"假成功"
- Dashboard 各数据模块独立加载,单接口失败不再整页清零
- 房间可视化加载失败显示错误态而非永久转圈
- 学生编辑表单回填前重置,避免字段残留污染
- 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单
- 金数据匹配关闭前确认,同步中禁止误关

体验提升:
- 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试
- 全局 ErrorBoundary + RouteKeeper 逐页兜底
- 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据
- 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡
- 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期,
  ArtifactErrorBoundary 渲染降级,图表空数据占位
- AI 助手欢迎语与建议话术按角色定制,会话列表空态引导
- 更新 a2ui-contract.md 契约文档说明实现现状
2026-08-07 17:23:23 +08:00

426 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { validateResponse } from '../../utils/validate';
import { classroomScheduleSchema } from '../../api/schemas';
import {
DatePicker,
Card,
Row,
Col,
Statistic,
Tag,
Space,
Button,
Modal,
Spin,
Empty,
Tooltip,
} from 'antd';
import { CalendarOutlined, FileTextOutlined, ReadOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message';
import { getErrorMessage } from '../../utils/error';
import { QueryErrorState } from '../../components/QueryState';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
interface ScheduleData {
year: number;
month: number;
days: number;
classrooms: any[];
organizations: any[];
matrix: Record<number, Record<number, any>>;
summary: Record<
number,
{ totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }
>;
}
const ClassroomSchedulePage: React.FC = () => {
const [month, setMonth] = useState<Dayjs>(dayjs());
const [detailModal, setDetailModal] = useState<any>(null);
const { data, isLoading, isFetching, isError, refetch } = useQuery<ScheduleData | null>({
queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()],
queryFn: async () =>
validateResponse<ScheduleData | null>(
classroomScheduleSchema,
await api.get('/classroom-rentals/schedule', {
params: { year: month.year(), month: month.month() + 1 },
}),
),
});
const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新排期数据
useVisibleRefetch(['classroom-rentals', 'schedule']);
// 按楼栋+楼层分组教室
const groups = useMemo(() => {
if (!data) return [];
const map = new Map<string, any[]>();
for (const c of data.classrooms) {
const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}` : ''}`;
if (!map.has(key)) map.set(key, []);
map.get(key)?.push(c);
}
return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms }));
}, [data]);
// 整体统计
const overall = useMemo(() => {
if (!data) return { total: 0, rented: 0, rate: 0 };
let rented = 0;
const total = data.classrooms.length * data.days;
for (const cid of Object.keys(data.summary)) {
rented += data.summary[+cid].rentedDays;
}
return {
total,
rented,
rate: total > 0 ? Math.round((rented / total) * 100) : 0,
};
}, [data]);
const showDetail = async (rentalId: number) => {
try {
const res: any = await api.get(`/classroom-rentals/${rentalId}`);
setDetailModal(res);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载详情失败'));
}
};
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
} catch {
// downloadBlob already shows an error via throw
}
};
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space>
<CalendarOutlined style={{ fontSize: 20 }} />
<h3 style={{ margin: 0 }}></h3>
</Space>
<Space>
<Button onClick={() => setMonth(month.subtract(1, 'month'))}></Button>
<DatePicker
picker="month"
value={month}
onChange={(v) => v && setMonth(v)}
allowClear={false}
placeholder="选择月份"
format="YYYY年M月"
/>
<Button onClick={() => setMonth(month.add(1, 'month'))}></Button>
<Button type="primary" onClick={() => setMonth(dayjs())}>
</Button>
</Space>
</div>
{isError ? (
<QueryErrorState
title="教室排期加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<>
{/* 统计卡片 */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="教室总数" value={data?.classrooms.length || 0} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="本月天数" value={data?.days || 0} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="总占用天数" value={overall.rented} suffix={`/${overall.total}`} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="整体占用率"
value={overall.rate}
suffix="%"
styles={{
value: {
color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',
},
}}
/>
</Card>
</Col>
</Row>
{/* 图例 */}
{data && (
<Card size="small" style={{ marginBottom: 16 }} title="图例">
<Space wrap>
<Tag color="#52c41a"></Tag>
{data.organizations.map((t) => (
<Tag
key={t.id}
color={t.color}
style={{ background: t.color, color: '#fff', borderColor: t.color }}
>
{t.name} ()
</Tag>
))}
<Tag color="#d9d9d9" style={{ color: '#999' }}>
</Tag>
</Space>
</Card>
)}
<Spin spinning={loading}>
{!data || data.classrooms.length === 0 ? (
<Empty description="暂无教室数据" />
) : (
<div style={{ overflowX: 'auto' }}>
{groups.map((group) => (
<Card
key={group.name}
size="small"
title={group.name}
style={{ marginBottom: 12 }}
styles={{ body: { padding: 0 } }}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#fafafa' }}>
<th
style={{
position: 'sticky',
left: 0,
background: '#fafafa',
zIndex: 2,
padding: '8px',
border: '1px solid #f0f0f0',
minWidth: 120,
textAlign: 'left',
}}
>
</th>
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 60 }}>
</th>
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 70 }}>
</th>
{Array.from({ length: data.days }, (_, i) => i + 1).map((d) => (
<th
key={d}
style={{
padding: '8px 4px',
border: '1px solid #f0f0f0',
minWidth: 26,
textAlign: 'center',
}}
>
{d}
</th>
))}
</tr>
</thead>
<tbody>
{group.classrooms.map((c) => {
const sum = data.summary[c.id] || {
rentedDays: 0,
totalDays: data.days,
occupancyRate: 0,
};
return (
<tr key={c.id}>
<td
style={{
position: 'sticky',
left: 0,
background: '#fff',
zIndex: 1,
padding: '6px 8px',
border: '1px solid #f0f0f0',
fontWeight: 500,
}}
>
{c.name}
</td>
<td
style={{
padding: '6px',
border: '1px solid #f0f0f0',
textAlign: 'center',
}}
>
{c.roomType}
</td>
<td
style={{
padding: '6px',
border: '1px solid #f0f0f0',
textAlign: 'center',
color:
sum.occupancyRate > 0.7
? '#cf1322'
: sum.occupancyRate > 0.4
? '#fa8c16'
: '#3f8600',
}}
>
{Math.round(sum.occupancyRate * 100)}%
</td>
{Array.from({ length: data.days }, (_, i) => i + 1).map((d) => {
const cell = data.matrix[c.id]?.[d];
const isInternal = cell?.scheduleType === 'INTERNAL';
const isRental = cell?.scheduleType === 'RENTAL';
return (
<td
key={d}
onClick={() => {
if (isRental) showDetail(cell.rentalId);
}}
style={{
padding: 0,
border: '1px solid #f0f0f0',
background: cell?.color || '#fff',
height: 26,
cursor: isRental ? 'pointer' : 'default',
textAlign: 'center',
}}
>
{cell && (
<Tooltip
title={
isInternal
? `${cell.className} · ${cell.subject}\n${cell.teacherName} · ${cell.startTime}-${cell.endTime}`
: `${cell.organizationName}${cell.hasContract ? ' · 有合同' : ''}`
}
>
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
{isInternal ? (
<ReadOutlined style={{ fontSize: 12 }} />
) : cell.hasContract ? (
<FileTextOutlined style={{ fontSize: 12 }} />
) : (
''
)}
</span>
</Tooltip>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</Card>
))}
</div>
)}
</Spin>
</>
)}
<Modal
title="租赁详情"
open={!!detailModal}
onCancel={() => setDetailModal(null)}
footer={null}
width={500}
>
{detailModal && (
<div style={{ lineHeight: 2 }}>
<div>
<strong></strong>
{detailModal.classroom?.building} · {detailModal.classroom?.name}
{detailModal.classroom?.roomType}
</div>
<div>
<strong></strong>
<Tag
color={detailModal.lesseeOrganization?.color}
style={{
background: detailModal.lesseeOrganization?.color,
color: '#fff',
borderColor: detailModal.lesseeOrganization?.color,
}}
>
{detailModal.lesseeOrganization?.name}
</Tag>
</div>
<div>
<strong></strong>
{detailModal.lesseeOrganization?.contactName || '-'}{' '}
{detailModal.lesseeOrganization?.phone || ''}
</div>
<div>
<strong></strong>
{detailModal.startDate} ~ {detailModal.endDate}
{dayjs(detailModal.endDate).diff(dayjs(detailModal.startDate), 'day') + 1}
</div>
{detailModal.dailyRate != null && (
<div>
<strong></strong>¥{detailModal.dailyRate}
</div>
)}
{detailModal.totalAmount != null && (
<div>
<strong></strong>¥{detailModal.totalAmount}
</div>
)}
{detailModal.notes && (
<div>
<strong></strong>
{detailModal.notes}
</div>
)}
<div style={{ marginTop: 12 }}>
<strong></strong>
{detailModal.contractPath ? (
<Button
type="link"
icon={<FileTextOutlined />}
onClick={() =>
handleDownloadContract(detailModal.id, detailModal.contractOriginalName)
}
>
{detailModal.contractOriginalName || '下载'}
</Button>
) : (
'未上传'
)}
</div>
</div>
)}
</Modal>
</div>
);
};
export default ClassroomSchedulePage;