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

@@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router';
import { Button, Card, Form, Space, Tabs, Tag } from 'antd';
import { Button, Card, Form, Space, Spin, Tabs, Tag } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
@@ -8,6 +8,7 @@ import { message } from '../../ui/app-message';
import { useQuery } from '@tanstack/react-query';
import type { TeacherCandidateUser } from './teacher-candidate';
import { getErrorMessage } from '../../utils/error';
import { QueryErrorState } from '../../components/QueryState';
import {
ClassAttendanceTab,
ClassInfoTab,
@@ -51,6 +52,7 @@ const ClassDetailPage: React.FC = () => {
data: detailResult = { detail: null, students: [], teachers: [] },
isLoading: detailLoading,
isFetching: detailFetching,
isError: detailError,
refetch: refetchDetail,
} = useQuery<{
detail: ClassDetail | null;
@@ -59,13 +61,8 @@ const ClassDetailPage: React.FC = () => {
}>({
queryKey: ['classes', 'detail', id],
queryFn: async () => {
try {
const res = (await api.get(`/classes/${id}`)) as ClassDetail;
return { detail: res, students: res.students || [], teachers: res.teachers || [] };
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败'));
return { detail: null, students: [], teachers: [] };
}
const res = (await api.get(`/classes/${id}`)) as ClassDetail;
return { detail: res, students: res.students || [], teachers: res.teachers || [] };
},
});
const detail = detailResult.detail;
@@ -74,52 +71,50 @@ const ClassDetailPage: React.FC = () => {
const loading = detailLoading || detailFetching;
const fetchDetail = useCallback(() => refetchDetail(), [refetchDetail]);
const { data: allUsers = [], refetch: refetchUsers } = useQuery<TeacherCandidateUser[]>({
const {
data: allUsers = [],
isError: allUsersError,
refetch: refetchUsers,
} = useQuery<TeacherCandidateUser[]>({
queryKey: ['rbac', 'users', 'all'],
queryFn: async () => {
try {
return (await api.get('/rbac/users')) as TeacherCandidateUser[];
} catch {
return [];
}
return (await api.get('/rbac/users')) as TeacherCandidateUser[];
},
});
const fetchUsers = useCallback(() => refetchUsers(), [refetchUsers]);
const { data: schedules = [] } = useQuery<ClassScheduleItem[]>({
const {
data: schedules = [],
isError: schedulesError,
refetch: refetchSchedules,
} = useQuery<ClassScheduleItem[]>({
queryKey: ['classes', 'schedule', id, scheduleDateRange],
queryFn: async () => {
if (!id) return [];
try {
const params: Record<string, string> = {};
if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');
if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');
return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载课表失败'));
return [];
}
const params: Record<string, string> = {};
if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');
if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');
return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];
},
});
const { data: attendanceSummary = null } = useQuery<AttendanceSummary | null>({
const {
data: attendanceSummary = null,
isError: attendanceSummaryError,
refetch: refetchAttendanceSummary,
} = useQuery<AttendanceSummary | null>({
queryKey: ['classes', 'attendance-summary', id, attendanceDateRange],
queryFn: async () => {
if (!id) return null;
try {
const params: Record<string, string> = {};
if (attendanceDateRange?.[0])
params.startDate = attendanceDateRange[0].format('YYYY-MM-DD');
if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD');
return (
(await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, {
params,
})) || null
);
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载出勤汇总失败'));
return null;
}
const params: Record<string, string> = {};
if (attendanceDateRange?.[0])
params.startDate = attendanceDateRange[0].format('YYYY-MM-DD');
if (attendanceDateRange?.[1]) params.endDate = attendanceDateRange[1].format('YYYY-MM-DD');
return (
(await api.get<AttendanceSummary>(`/classes/${id}/attendance-summary`, {
params,
})) || null
);
},
});
@@ -221,7 +216,25 @@ const ClassDetailPage: React.FC = () => {
const getTeacherName = (teacher: ClassTeacher) =>
allUsers.find((user) => user.id === teacher.userId)?.name?.trim() || '-';
if (!detail) return null;
if (!detail) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (detailError) {
return (
<QueryErrorState
title="班级详情加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchDetail()}
/>
);
}
return null;
}
return (
<Card
@@ -289,7 +302,13 @@ const ClassDetailPage: React.FC = () => {
{
key: 'teachers',
label: `教师 (${teachers.length})`,
children: (
children: allUsersError ? (
<QueryErrorState
title="可添加教师加载失败"
description="请检查网络后重试。"
onRetry={() => void fetchUsers()}
/>
) : (
<ClassTeachersTab
teachers={teachers}
allUsers={allUsers}
@@ -311,7 +330,13 @@ const ClassDetailPage: React.FC = () => {
{
key: 'schedule',
label: '课表',
children: (
children: schedulesError ? (
<QueryErrorState
title="课表加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchSchedules()}
/>
) : (
<ClassScheduleTab
schedules={schedules}
scheduleDateRange={scheduleDateRange}
@@ -322,7 +347,13 @@ const ClassDetailPage: React.FC = () => {
{
key: 'attendance-summary',
label: '出勤汇总',
children: (
children: attendanceSummaryError ? (
<QueryErrorState
title="出勤汇总加载失败"
description="请检查网络后重试。"
onRetry={() => void refetchAttendanceSummary()}
/>
) : (
<ClassAttendanceTab
attendanceSummary={attendanceSummary}
attendanceDateRange={attendanceDateRange}