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