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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user