Files
gongxue-base/apps/admin/src/pages/Deposits/DepositModals.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

425 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.

import React from 'react';
import {
Card,
DatePicker,
Empty,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
} from 'antd';
import { DollarOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import type { DepositStudentLookup } from './deposit-student-option';
export interface DepositRecord {
id: number;
studentId: number;
amount: number;
status: string;
paidDate: string;
refundDate?: string | null;
notes?: string | null;
installments?: Array<{
id: number;
amount: number;
dueDate: string;
paidDate?: string | null;
status: string;
}>;
student?: DepositStudentLookup;
}
export interface EligibleStudent {
studentId: number;
studentName: string;
studentNo?: string | null;
roomId: number;
roomNumber: string;
building?: string | null;
roomType?: string | null;
capacity: number;
depositAmount: number;
}
export const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '有余额', color: 'green' },
refunded: { text: '已全退', color: 'blue' },
depleted: { text: '已扣完', color: 'red' },
};
export const installmentStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待缴', color: 'orange' },
paid: { text: '已缴', color: 'green' },
};
export const roomTypeOptions = [
{ value: '单人间', label: '单人间' },
{ value: '四人间', label: '四人间' },
];
export const suggestedDepositByRoomType: Record<string, number> = {
单人间: 200,
四人间: 100,
};
export interface DepositModalsProps {
batchModal: boolean;
createModal: boolean;
refundModal: DepositRecord | null;
detailModal: DepositRecord | null;
installmentModal: number | null;
batchForm: ReturnType<typeof Form.useForm>[0];
createForm: ReturnType<typeof Form.useForm>[0];
refundForm: ReturnType<typeof Form.useForm>[0];
installmentForm: ReturnType<typeof Form.useForm>[0];
saving: boolean;
batchRoomType: string;
effectiveSelectedEligibleIds: number[];
eligibleStudents: EligibleStudent[];
eligibleLoading: boolean;
eligibleColumns: Array<{ title: string; render?: unknown; dataIndex?: string }>;
studentOptions: Array<{ value: number; label: string }>;
onBatchRoomTypeChange: (roomType: string) => void;
onBatchCreate: () => void;
onCreate: () => void;
onRefund: () => void;
onAddInstallment: () => void;
onPayInstallment: (installmentId: number) => void;
onSaveInstallmentCell: (
installmentId: number,
field: 'status' | 'paidDate',
value: unknown,
) => void;
onDeleteInstallment: (installmentId: number) => void;
onCloseBatch: () => void;
onCloseCreate: () => void;
onCloseRefund: () => void;
onCloseDetail: () => void;
onCloseInstallment: () => void;
onOpenInstallment: (id: number) => void;
onSelectEligible: (ids: number[]) => void;
}
export const DepositModals: React.FC<DepositModalsProps> = ({
batchModal,
createModal,
refundModal,
detailModal,
installmentModal,
batchForm,
createForm,
refundForm,
installmentForm,
saving,
batchRoomType,
effectiveSelectedEligibleIds,
eligibleStudents,
eligibleLoading,
eligibleColumns,
studentOptions,
onBatchRoomTypeChange,
onBatchCreate,
onCreate,
onRefund,
onAddInstallment,
onPayInstallment,
onSaveInstallmentCell,
onDeleteInstallment,
onCloseBatch,
onCloseCreate,
onCloseRefund,
onCloseDetail,
onCloseInstallment,
onOpenInstallment,
onSelectEligible,
}) => {
return (
<>
<Modal
title="按房型批量收取押金"
open={batchModal}
onOk={onBatchCreate}
onCancel={onCloseBatch}
okText="确认批量收取"
confirmLoading={saving}
okButtonProps={{ disabled: effectiveSelectedEligibleIds.length === 0 }}
width={760}
>
<Form form={batchForm} layout="vertical">
<Space style={{ width: '100%' }} align="start" wrap>
<Form.Item
name="roomType"
label="房型"
rules={[{ required: true, message: '请选择房型' }]}
>
<Select
style={{ width: 140 }}
options={roomTypeOptions}
onChange={onBatchRoomTypeChange}
/>
</Form.Item>
<Form.Item
name="amount"
label="每人收取金额(元)"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: 180 }} />
</Form.Item>
<Form.Item
name="paidDate"
label="收取日期"
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker style={{ width: 180 }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
</Space>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder={`${batchRoomType}押金`} />
</Form.Item>
</Form>
<div style={{ marginBottom: 8 }}>
<strong>{effectiveSelectedEligibleIds.length}</strong> / {eligibleStudents.length}{' '}
{suggestedDepositByRoomType[batchRoomType] && (
<span style={{ color: '#999', marginLeft: 8 }}>
¥{suggestedDepositByRoomType[batchRoomType]}
</span>
)}
</div>
<Table
size="small"
columns={eligibleColumns as never}
dataSource={eligibleStudents}
rowKey="studentId"
loading={eligibleLoading}
locale={{ emptyText: <Empty description="暂无符合条件的在住人员" /> }}
pagination={{ pageSize: 6, showSizeChanger: false }}
rowSelection={{
selectedRowKeys: effectiveSelectedEligibleIds,
onChange: (keys) => onSelectEligible(keys as number[]),
}}
/>
</Modal>
<Modal
title="收取押金"
open={createModal}
onOk={onCreate}
onCancel={onCloseCreate}
okText="确认"
confirmLoading={saving}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="studentId"
label="学生"
rules={[{ required: true, message: '请选择学生' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="搜索并选择学生"
options={studentOptions}
/>
</Form.Item>
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
<Modal
title={`退还押金 - ${refundModal?.student?.name}`}
open={!!refundModal}
onOk={onRefund}
onCancel={onCloseRefund}
okText="确认退还"
confirmLoading={saving}
>
<Form form={refundForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
</div>
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
<Modal
title={`押金详情 - ${detailModal?.student?.name}`}
open={!!detailModal}
onCancel={onCloseDetail}
footer={null}
width={640}
>
{detailModal && (
<div>
<Card size="small" style={{ marginBottom: 16 }}>
<p>
<strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}
</p>
<p>
<strong>:</strong> {detailModal.paidDate}
</p>
<p>
<strong>:</strong>{' '}
<Tag color={statusMap[detailModal.status]?.color}>
{statusMap[detailModal.status]?.text || detailModal.status}
</Tag>
</p>
{detailModal.notes && (
<p>
<strong>:</strong> {detailModal.notes}
</p>
)}
</Card>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}
>
<h4 style={{ margin: 0 }}></h4>
<PermissionButton
permission="deposit:edit"
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => onOpenInstallment(detailModal.id)}
>
</PermissionButton>
</div>
{detailModal.installments && detailModal.installments.length > 0 ? (
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={detailModal.installments}
columns={[
{
title: '金额',
dataIndex: 'amount',
render: (value: number) => `¥${value.toFixed(2)}`,
},
{ title: '到期日', dataIndex: 'dueDate' },
{
title: '实付日',
dataIndex: 'paidDate',
render: (value: string, item: any) => (
<EditableCell
value={value}
editor="date"
permission="deposit:edit"
onSave={async (next) =>
onSaveInstallmentCell(item.id, 'paidDate', next)
}
>
{value || '-'}
</EditableCell>
),
},
{
title: '状态',
dataIndex: 'status',
render: (value: string, item: any) => (
<EditableCell
value={value}
editor="select"
options={[
{ value: 'pending', label: '待缴' },
{ value: 'paid', label: '已缴' },
{ value: 'overdue', label: '逾期' },
]}
permission="deposit:edit"
onSave={async (next) =>
onSaveInstallmentCell(item.id, 'status', next)
}
>
<Tag color={installmentStatusMap[value]?.color}>
{installmentStatusMap[value]?.text || value}
</Tag>
</EditableCell>
),
},
{
title: '操作',
render: (_: unknown, item: any) => (
<Space>
{item.status === 'pending' && (
<PermissionButton
permission="deposit:edit"
size="small"
type="primary"
icon={<DollarOutlined />}
onClick={() => onPayInstallment(item.id)}
>
</PermissionButton>
)}
<Popconfirm
title="确定归档?"
onConfirm={() => onDeleteInstallment(item.id)}
>
<PermissionButton
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
),
},
]}
/>
) : (
<p style={{ color: '#999' }}></p>
)}
</div>
)}
</Modal>
<Modal
title="添加分期"
open={installmentModal != null}
onOk={onAddInstallment}
onCancel={onCloseInstallment}
okText="确认"
confirmLoading={saving}
>
<Form form={installmentForm} layout="vertical">
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</>
);
};