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:
2026-08-07 17:23:23 +08:00
parent db0d972633
commit 67435e46ca
54 changed files with 2298 additions and 1177 deletions

View File

@@ -5,7 +5,9 @@ import { CalendarOutlined, CloudSyncOutlined, LeftOutlined, RightOutlined } from
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState';
import { usePermission } from '../../hooks/usePermission';
import { useVisibleRefetch } from '../../hooks/usePageVisible';
import { message } from '../../ui/app-message';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
@@ -64,6 +66,7 @@ const SchedulesPage: React.FC = () => {
mappedClasses: number;
totalClasses: number;
} | null>(null);
const [syncStatusError, setSyncStatusError] = useState(false);
const [syncResult, setSyncResult] = useState<ScheduleSyncResult | null>(null);
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
const [syncDays, setSyncDays] = useState(30);
@@ -73,6 +76,7 @@ const SchedulesPage: React.FC = () => {
const openSyncModal = async () => {
setSyncModalOpen(true);
setSyncResult(null);
setSyncStatusError(false);
try {
const res = await api.get<{
success: boolean;
@@ -80,6 +84,7 @@ const SchedulesPage: React.FC = () => {
}>('/sync/schedule/status');
setSyncStatus(res.data);
} catch {
setSyncStatusError(true);
setSyncStatus(null);
}
};
@@ -154,6 +159,8 @@ const SchedulesPage: React.FC = () => {
data: fetchResult = { classrooms: [], classes: [], matrix: {} },
isLoading,
isFetching,
isError,
refetch,
} = useQuery<{
classrooms: ClassroomItem[];
classes: ClassItem[];
@@ -161,51 +168,48 @@ const SchedulesPage: React.FC = () => {
}>({
queryKey: ['class-schedules', startDateStr, endDateStr, filterClassroomIds],
queryFn: async () => {
try {
const [lookups, schedulesRes] = await Promise.all([
api.get('/class-schedules/lookups') as Promise<{
classrooms: ClassroomItem[];
classes: ClassItem[];
}>,
api.get('/class-schedules/weekly', {
params: {
startDate: startDateStr,
endDate: endDateStr,
...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}),
},
}) as Promise<Record<string, Record<string, ClassScheduleItem[]>>>,
]);
const validatedLookups = validateResponse<{
const [lookups, schedulesRes] = await Promise.all([
api.get('/class-schedules/lookups') as Promise<{
classrooms: ClassroomItem[];
classes: ClassItem[];
}>(scheduleLookupsSchema, lookups);
const validatedWeekly = validateResponse<
Record<string, Record<string, ClassScheduleItem[]>>
>(weeklyScheduleSchema, schedulesRes);
}>,
api.get('/class-schedules/weekly', {
params: {
startDate: startDateStr,
endDate: endDateStr,
...(filterClassroomIds.length === 1 ? { classroomId: filterClassroomIds[0] } : {}),
},
}) as Promise<Record<string, Record<string, ClassScheduleItem[]>>>,
]);
const validatedLookups = validateResponse<{
classrooms: ClassroomItem[];
classes: ClassItem[];
}>(scheduleLookupsSchema, lookups);
const validatedWeekly = validateResponse<
Record<string, Record<string, ClassScheduleItem[]>>
>(weeklyScheduleSchema, schedulesRes);
const typedMatrix: Record<number, Record<number, ClassScheduleItem[]>> = {};
for (const [cId, dayMap] of Object.entries(validatedWeekly)) {
const classroomId = Number(cId);
typedMatrix[classroomId] = {};
for (const [wd, schedules] of Object.entries(dayMap)) {
typedMatrix[classroomId][Number(wd)] = schedules;
}
const typedMatrix: Record<number, Record<number, ClassScheduleItem[]>> = {};
for (const [cId, dayMap] of Object.entries(validatedWeekly)) {
const classroomId = Number(cId);
typedMatrix[classroomId] = {};
for (const [wd, schedules] of Object.entries(dayMap)) {
typedMatrix[classroomId][Number(wd)] = schedules;
}
return {
classrooms: validatedLookups.classrooms,
classes: validatedLookups.classes,
matrix: typedMatrix,
};
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载排课数据失败'));
return { classrooms: [], classes: [], matrix: {} };
}
return {
classrooms: validatedLookups.classrooms,
classes: validatedLookups.classes,
matrix: typedMatrix,
};
},
});
const classrooms = fetchResult.classrooms;
const classes = fetchResult.classes;
const matrix = fetchResult.matrix;
const loading = isLoading || isFetching;
// RouteKeeper 保活页面切回时刷新排课数据
useVisibleRefetch(['class-schedules']);
const saveMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
@@ -498,18 +502,26 @@ const SchedulesPage: React.FC = () => {
</Space>
</Card>
<ScheduleGrid
loading={loading}
viewMode={viewMode}
classrooms={classrooms}
filteredClassrooms={filteredClassrooms}
displayMatrix={displayMatrix}
weeks={weeks}
monthStart={monthStart}
monthScheduleMap={monthScheduleMap}
onCellClick={handleCellClick}
onDateClick={handleDateClick}
/>
{isError ? (
<QueryErrorState
title="排课数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<ScheduleGrid
loading={loading}
viewMode={viewMode}
classrooms={classrooms}
filteredClassrooms={filteredClassrooms}
displayMatrix={displayMatrix}
weeks={weeks}
monthStart={monthStart}
monthScheduleMap={monthScheduleMap}
onCellClick={handleCellClick}
onDateClick={handleDateClick}
/>
)}
<ScheduleModal
open={modalOpen}
@@ -558,6 +570,7 @@ const SchedulesPage: React.FC = () => {
open={syncModalOpen}
syncing={syncing}
syncStatus={syncStatus}
syncStatusError={syncStatusError}
syncResult={syncResult}
syncDateFrom={syncDateFrom}
syncDays={syncDays}
@@ -566,6 +579,7 @@ const SchedulesPage: React.FC = () => {
setSyncModalOpen(false);
setSyncResult(null);
}}
onRetryStatus={() => void openSyncModal()}
onSync={handleSyncSchedule}
onDateChange={setSyncDateFrom}
onDaysChange={setSyncDays}