Files
gongxue-base/apps/admin/src/pages/Rooms/RoomDrawer.tsx
wangziqi 67435e46ca 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 契约文档说明实现现状
2026-08-07 17:23:23 +08:00

406 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// aislop-ignore-file: duplicate-block -- 床位/柜子表格声明结构相似且字段不同,渲染逻辑已共享 EditableRoomCell
import React from 'react';
import {
Button,
Drawer,
InputNumber,
Popconfirm,
Space,
Table,
Tabs,
Tag,
} from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import { QueryErrorState } from '../../components/QueryState';
import {
BED_STATUS_MAP,
BED_STATUS_OPTIONS,
EditableRoomCell,
statusMap,
type BedItem,
type LockerItem,
} from './RoomColumns';
export interface RoomDrawerProps {
open: boolean;
room: any;
beds: BedItem[];
lockers: LockerItem[];
bedsError: boolean;
lockersError: boolean;
onRetryBeds: () => void;
onRetryLockers: () => void;
canEditRooms: boolean;
remainingBedSlots: number;
defaultBatchBedCount: number;
onClose: () => void;
onAddBed: () => void;
onBatchBeds: (count: number) => void;
onEditBed: (record: BedItem) => void;
onDeleteBed: (id: number) => void;
onSaveBedCell: (record: BedItem, field: string, value: unknown) => void;
onAddLocker: () => void;
onBatchLockers: (count: number) => void;
onEditLocker: (record: LockerItem) => void;
onDeleteLocker: (id: number) => void;
onSaveLockerCell: (record: LockerItem, field: string, value: unknown) => void;
}
export const RoomDrawer: React.FC<RoomDrawerProps> = ({
open,
room,
beds,
lockers,
bedsError,
lockersError,
onRetryBeds,
onRetryLockers,
canEditRooms,
remainingBedSlots,
defaultBatchBedCount,
onClose,
onAddBed,
onBatchBeds,
onEditBed,
onDeleteBed,
onSaveBedCell,
onAddLocker,
onBatchLockers,
onEditLocker,
onDeleteLocker,
onSaveLockerCell,
}) => {
const roomItemActions = (kind: 'bed' | 'locker') => (r: any) => {
const isBed = kind === 'bed';
const handleDelete = isBed ? onDeleteBed : onDeleteLocker;
const handleEdit = isBed ? onEditBed : onEditLocker;
return (
<Space size="small">
<PermissionButton
permission="room:edit"
size="small"
type="link"
disabled={room?.status === 'archived'}
onClick={() => handleEdit(r)}
>
</PermissionButton>
{r.status !== 'occupied' && canEditRooms && (
<Popconfirm title="确定归档?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" type="link" danger disabled={room?.status === 'archived'}>
</Button>
</Popconfirm>
)}
</Space>
);
};
return (
<Drawer
title={`${room?.roomNumber} 房间详情`}
open={open}
onClose={onClose}
size={640}
destroyOnClose
>
<Tabs
defaultActiveKey="info"
items={[
{
key: 'info',
label: '基本信息',
children:
room && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<strong></strong>
{room.roomNumber}
</div>
<div>
<strong></strong>
{room.building || '-'}
</div>
<div>
<strong></strong>
{room.floor ?? '-'}
</div>
<div>
<strong></strong>
{room.roomType || '-'}
</div>
<div>
<strong></strong>
{room.capacity}
</div>
<div>
<strong></strong>
{room.rentalCategory === 'long' ? '长租' : '短租'}
</div>
<div>
<strong></strong>
{room.monthlyRate ? `¥${room.monthlyRate}` : '-'}
</div>
<div>
<strong></strong>
<Tag color={statusMap[room.status]?.color}>
{statusMap[room.status]?.text}
</Tag>
</div>
</div>
),
},
{
key: 'beds',
label: `床位管理 (${beds.length})`,
children: bedsError ? (
<QueryErrorState
compact
title="床位加载失败"
description="请检查网络后重试。"
onRetry={onRetryBeds}
/>
) : (
<div>
{canEditRooms ? (
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
disabled={room?.status === 'archived' || remainingBedSlots === 0}
onClick={onAddBed}
>
</Button>
<Popconfirm
title={remainingBedSlots > 0 ? '批量生成床位' : '床位已达到额定人数'}
description={
remainingBedSlots > 0 ? (
<InputNumber
min={1}
max={remainingBedSlots}
defaultValue={defaultBatchBedCount}
id="batch-bed-count"
style={{ width: 80 }}
/>
) : (
'如需增加床位,请先调整宿舍额定人数'
)
}
onConfirm={() => {
const input = document.getElementById(
'batch-bed-count',
) as HTMLInputElement;
onBatchBeds(
input
? parseInt(input.value) || defaultBatchBedCount
: defaultBatchBedCount,
);
}}
okText="生成"
disabled={room?.status === 'archived' || remainingBedSlots === 0}
>
<Button
size="small"
disabled={room?.status === 'archived' || remainingBedSlots === 0}
>
</Button>
</Popconfirm>
</div>
) : null}
<Table
dataSource={beds}
rowKey="id"
pagination={false}
size="small"
columns={[
{
title: '编号',
dataIndex: 'bedNumber',
width: 80,
render: (v: string, r: BedItem) => (
<EditableRoomCell
value={v}
field="bedNumber"
record={r}
required
archived={room?.status === 'archived'}
onSave={onSaveBedCell}
>
{v}
</EditableRoomCell>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string, r: BedItem) => (
<EditableRoomCell
value={s}
field="status"
record={r}
editor="select"
options={BED_STATUS_OPTIONS}
archived={room?.status === 'archived'}
onSave={onSaveBedCell}
>
<Tag color={BED_STATUS_MAP[s]?.color}>
{BED_STATUS_MAP[s]?.text || s}
</Tag>
</EditableRoomCell>
),
},
{
title: '备注',
dataIndex: 'notes',
render: (v: string, r: BedItem) => (
<EditableRoomCell
value={v}
field="notes"
record={r}
editor="textarea"
archived={room?.status === 'archived'}
onSave={onSaveBedCell}
>
{v || '-'}
</EditableRoomCell>
),
},
{
title: '操作',
width: 120,
render: roomItemActions('bed'),
},
]}
/>
</div>
),
},
{
key: 'lockers',
label: `柜子管理 (${lockers.length})`,
children: lockersError ? (
<QueryErrorState
compact
title="柜子加载失败"
description="请检查网络后重试。"
onRetry={onRetryLockers}
/>
) : (
<div>
{canEditRooms ? (
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
disabled={room?.status === 'archived'}
onClick={onAddLocker}
>
</Button>
<Popconfirm
title="批量生成柜子"
description={
<InputNumber
min={1}
max={20}
defaultValue={4}
id="batch-locker-count"
style={{ width: 80 }}
/>
}
onConfirm={() => {
const input = document.getElementById(
'batch-locker-count',
) as HTMLInputElement;
onBatchLockers(input ? parseInt(input.value) || 4 : 4);
}}
okText="生成"
disabled={room?.status === 'archived'}
>
<Button size="small" disabled={room?.status === 'archived'}>
</Button>
</Popconfirm>
</div>
) : null}
<Table
dataSource={lockers}
rowKey="id"
pagination={false}
size="small"
columns={[
{
title: '编号',
dataIndex: 'lockerNumber',
width: 80,
render: (v: string, r: LockerItem) => (
<EditableRoomCell
value={v}
field="lockerNumber"
record={r}
required
archived={room?.status === 'archived'}
onSave={onSaveLockerCell}
>
{v}
</EditableRoomCell>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string, r: LockerItem) => (
<EditableRoomCell
value={s}
field="status"
record={r}
editor="select"
options={BED_STATUS_OPTIONS}
archived={room?.status === 'archived'}
onSave={onSaveLockerCell}
>
<Tag color={BED_STATUS_MAP[s]?.color}>
{BED_STATUS_MAP[s]?.text || s}
</Tag>
</EditableRoomCell>
),
},
{
title: '备注',
dataIndex: 'notes',
render: (v: string, r: LockerItem) => (
<EditableRoomCell
value={v}
field="notes"
record={r}
editor="textarea"
archived={room?.status === 'archived'}
onSave={onSaveLockerCell}
>
{v || '-'}
</EditableRoomCell>
),
},
{
title: '操作',
width: 120,
render: roomItemActions('locker'),
},
]}
/>
</div>
),
},
]}
/>
</Drawer>
);
};