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,5 +1,6 @@
|
||||
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Alert, App, Form } from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
@@ -21,6 +22,9 @@ import { occupanciesSchema } from '../../api/schemas';
|
||||
import { OccupanciesTableArea } from './OccupanciesTableArea';
|
||||
import { OccupanciesToolbar } from './OccupanciesToolbar';
|
||||
import { useOccupancyMutations } from './useOccupancyMutations';
|
||||
import { QueryErrorState } from '../../components/QueryState';
|
||||
import { NextStepHint } from '../../components/NextStepHint';
|
||||
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
||||
|
||||
interface StudentLookupRow {
|
||||
id: number;
|
||||
@@ -44,6 +48,7 @@ interface RoomOverviewRow {
|
||||
|
||||
const OccupanciesPage: React.FC = () => {
|
||||
const { modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission, permissionsReady } = usePermission();
|
||||
const canCheckIn = permissionsReady && hasPermission('occupancy:checkin');
|
||||
const canCheckOut = permissionsReady && hasPermission('occupancy:checkout');
|
||||
@@ -53,6 +58,8 @@ const OccupanciesPage: React.FC = () => {
|
||||
const [checkInModal, setCheckInModal] = useState(false);
|
||||
const [checkOutModal, setCheckOutModal] = useState<any>(null);
|
||||
const [transferModal, setTransferModal] = useState<any>(null);
|
||||
// 入住成功后的「下一步」引导提示
|
||||
const [nextStepHint, setNextStepHint] = useState<'billing' | null>(null);
|
||||
const [viewMode, setViewMode] = useState<OccupancyView>('active');
|
||||
const viewPolicy = occupancyViewPolicy(viewMode);
|
||||
const [autoDeposit, setAutoDeposit] = useState(true);
|
||||
@@ -75,46 +82,44 @@ const OccupanciesPage: React.FC = () => {
|
||||
data: fetchResult = { data: [], students: [], rooms: [] },
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<{ data: OccupancyRow[]; students: StudentLookupRow[]; rooms: RoomOverviewRow[] }>({
|
||||
queryKey: ['occupancies', viewMode, dateRange],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const [occRes, stuRes, rmRes] = await Promise.allSettled([
|
||||
api.get<OccupancyRow[]>('/occupancies', {
|
||||
params: {
|
||||
...occupancyParamsForView(viewMode),
|
||||
dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
},
|
||||
}),
|
||||
api.get<StudentLookupRow[]>('/students/basic-lookups'),
|
||||
api.get<RoomOverviewRow[]>('/rooms/overview'),
|
||||
]);
|
||||
const labels = ['入住数据', '学生列表', '房间列表'];
|
||||
[occRes, stuRes, rmRes].forEach((res, i) => {
|
||||
if (res.status === 'rejected') {
|
||||
message.warning(`${labels[i]}加载失败`);
|
||||
}
|
||||
});
|
||||
return {
|
||||
data:
|
||||
occRes.status === 'fulfilled'
|
||||
? validateResponse<OccupancyRow[]>(occupanciesSchema, occRes.value)
|
||||
: [],
|
||||
students: stuRes.status === 'fulfilled' ? stuRes.value : [],
|
||||
rooms: rmRes.status === 'fulfilled' ? rmRes.value : [],
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('数据加载异常');
|
||||
return { data: [], students: [], rooms: [] };
|
||||
const [occRes, stuRes, rmRes] = await Promise.allSettled([
|
||||
api.get<OccupancyRow[]>('/occupancies', {
|
||||
params: {
|
||||
...occupancyParamsForView(viewMode),
|
||||
dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
},
|
||||
}),
|
||||
api.get<StudentLookupRow[]>('/students/basic-lookups'),
|
||||
api.get<RoomOverviewRow[]>('/rooms/overview'),
|
||||
]);
|
||||
const labels = ['入住数据', '学生列表', '房间列表'];
|
||||
[stuRes, rmRes].forEach((res, i) => {
|
||||
if (res.status === 'rejected') {
|
||||
message.warning(`${labels[i + 1]}加载失败`);
|
||||
}
|
||||
});
|
||||
if (occRes.status === 'rejected') {
|
||||
throw occRes.reason;
|
||||
}
|
||||
return {
|
||||
data: validateResponse<OccupancyRow[]>(occupanciesSchema, occRes.value),
|
||||
students: stuRes.status === 'fulfilled' ? stuRes.value : [],
|
||||
rooms: rmRes.status === 'fulfilled' ? rmRes.value : [],
|
||||
};
|
||||
},
|
||||
});
|
||||
const data = fetchResult.data;
|
||||
const students = fetchResult.students;
|
||||
const rooms = fetchResult.rooms;
|
||||
const loading = isLoading || isFetching;
|
||||
// RouteKeeper 保活页面切回时刷新入住列表
|
||||
useVisibleRefetch(['occupancies']);
|
||||
|
||||
const {
|
||||
checkInMutation,
|
||||
@@ -270,6 +275,8 @@ const OccupanciesPage: React.FC = () => {
|
||||
message.success('入住登记成功');
|
||||
setCheckInModal(false);
|
||||
checkInForm.resetFields();
|
||||
// 引导业务闭环的下一步:录费用 → 生成账单
|
||||
setNextStepHint('billing');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
} finally {
|
||||
@@ -518,26 +525,42 @@ const OccupanciesPage: React.FC = () => {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<OccupanciesTableArea
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
loading={loading}
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
rowSelection={rowSelection}
|
||||
batchAction={viewPolicy.batchAction}
|
||||
canDelete={canDelete}
|
||||
canPurge={canPurge}
|
||||
batchLoading={batchLoading}
|
||||
onBatchCheckOut={() => {
|
||||
batchCheckOutForm.resetFields();
|
||||
batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() });
|
||||
setBatchCheckOutModal(true);
|
||||
}}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onBatchRestore={handleBatchRestore}
|
||||
onBatchPurge={handleBatchPurge}
|
||||
onClearSelection={() => setSelectedRowKeys([])}
|
||||
/>
|
||||
{nextStepHint === 'billing' && (
|
||||
<NextStepHint
|
||||
title="入住登记完成"
|
||||
description="接下来可以为该学生录入公共/个人费用,再生成账单完成计费闭环。"
|
||||
action={{ label: '去费用管理', onClick: () => navigate('/expenses') }}
|
||||
onClose={() => setNextStepHint(null)}
|
||||
/>
|
||||
)}
|
||||
{isError ? (
|
||||
<QueryErrorState
|
||||
title="入住数据加载失败"
|
||||
description="请检查网络后重试。"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<OccupanciesTableArea
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
loading={loading}
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
rowSelection={rowSelection}
|
||||
batchAction={viewPolicy.batchAction}
|
||||
canDelete={canDelete}
|
||||
canPurge={canPurge}
|
||||
batchLoading={batchLoading}
|
||||
onBatchCheckOut={() => {
|
||||
batchCheckOutForm.resetFields();
|
||||
batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() });
|
||||
setBatchCheckOutModal(true);
|
||||
}}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onBatchRestore={handleBatchRestore}
|
||||
onBatchPurge={handleBatchPurge}
|
||||
onClearSelection={() => setSelectedRowKeys([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CheckInModal
|
||||
open={checkInModal}
|
||||
|
||||
Reference in New Issue
Block a user