UX 缺陷修复: - 校验失败不再卡死弹窗按钮(Users/Roles/Bills) - 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选 - AI 表单/批量确认不再出现"假成功" - Dashboard 各数据模块独立加载,单接口失败不再整页清零 - 房间可视化加载失败显示错误态而非永久转圈 - 学生编辑表单回填前重置,避免字段残留污染 - 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单 - 金数据匹配关闭前确认,同步中禁止误关 体验提升: - 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试 - 全局 ErrorBoundary + RouteKeeper 逐页兜底 - 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据 - 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡 - 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期, ArtifactErrorBoundary 渲染降级,图表空数据占位 - AI 助手欢迎语与建议话术按角色定制,会话列表空态引导 - 更新 a2ui-contract.md 契约文档说明实现现状
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
// 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>
|
||
);
|
||
};
|