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 契约文档说明实现现状
This commit is contained in:
@@ -22,6 +22,8 @@ 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;
|
||||
@@ -40,23 +42,19 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
const [month, setMonth] = useState<Dayjs>(dayjs());
|
||||
const [detailModal, setDetailModal] = useState<any>(null);
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery<ScheduleData | null>({
|
||||
const { data, isLoading, isFetching, isError, refetch } = useQuery<ScheduleData | null>({
|
||||
queryKey: ['classroom-rentals', 'schedule', month.year(), month.month()],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return validateResponse<ScheduleData | null>(
|
||||
classroomScheduleSchema,
|
||||
await api.get('/classroom-rentals/schedule', {
|
||||
params: { year: month.year(), month: month.month() + 1 },
|
||||
}),
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
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(() => {
|
||||
@@ -135,208 +133,218 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
{/* 图例 */}
|
||||
{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
|
||||
<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: '#fff',
|
||||
zIndex: 1,
|
||||
padding: '6px 8px',
|
||||
background: '#fafafa',
|
||||
zIndex: 2,
|
||||
padding: '8px',
|
||||
border: '1px solid #f0f0f0',
|
||||
fontWeight: 500,
|
||||
minWidth: 120,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
教室
|
||||
</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
|
||||
key={d}
|
||||
onClick={() => {
|
||||
if (isRental) showDetail(cell.rentalId);
|
||||
}}
|
||||
style={{
|
||||
padding: 0,
|
||||
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',
|
||||
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>
|
||||
)}
|
||||
{c.roomType}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
<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="租赁详情"
|
||||
|
||||
Reference in New Issue
Block a user