feat: 为批量归档补充批量恢复

This commit is contained in:
2026-07-25 09:34:03 +08:00
parent 1da763109b
commit 0ef86e65ce
19 changed files with 1365 additions and 317 deletions

View File

@@ -25,6 +25,7 @@ import {
UploadOutlined,
DownloadOutlined,
ExportOutlined,
UndoOutlined,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import api from '../../api';
@@ -34,6 +35,7 @@ import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { buildCheckInPayload, buildTransferPayload } from './occupancy-form';
import { usePermission } from '../../hooks/usePermission';
import { occupancyParamsForView, occupancyViewPolicy, type OccupancyView } from '../archive-view';
const { RangePicker } = DatePicker;
@@ -50,7 +52,8 @@ const OccupanciesPage: React.FC = () => {
const [checkInModal, setCheckInModal] = useState(false);
const [checkOutModal, setCheckOutModal] = useState<any>(null);
const [transferModal, setTransferModal] = useState<any>(null);
const [showActive, setShowActive] = useState(true);
const [viewMode, setViewMode] = useState<OccupancyView>('active');
const viewPolicy = occupancyViewPolicy(viewMode);
const [autoDeposit, setAutoDeposit] = useState(true);
const [depositAmount, setDepositAmount] = useState(500);
const [searchText, setSearchText] = useState('');
@@ -73,10 +76,30 @@ const OccupanciesPage: React.FC = () => {
const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm);
// Close modals when the user loses the required permission
useEffect(() => { if (!canCheckIn) { setCheckInModal(false); checkInForm.resetFields(); } }, [canCheckIn, checkInForm]);
useEffect(() => { if (!canCheckOut && checkOutModal) { setCheckOutModal(null); checkOutForm.resetFields(); } }, [canCheckOut, checkOutModal, checkOutForm]);
useEffect(() => { if (!canCheckOut) { setBatchCheckOutModal(false); batchCheckOutForm.resetFields(); } }, [canCheckOut, batchCheckOutForm]);
useEffect(() => { if (!canTransfer && transferModal) { setTransferModal(null); transferForm.resetFields(); } }, [canTransfer, transferModal, transferForm]);
useEffect(() => {
if (!canCheckIn) {
setCheckInModal(false);
checkInForm.resetFields();
}
}, [canCheckIn, checkInForm]);
useEffect(() => {
if (!canCheckOut && checkOutModal) {
setCheckOutModal(null);
checkOutForm.resetFields();
}
}, [canCheckOut, checkOutModal, checkOutForm]);
useEffect(() => {
if (!canCheckOut) {
setBatchCheckOutModal(false);
batchCheckOutForm.resetFields();
}
}, [canCheckOut, batchCheckOutForm]);
useEffect(() => {
if (!canTransfer && transferModal) {
setTransferModal(null);
transferForm.resetFields();
}
}, [canTransfer, transferModal, transferForm]);
const activeOccupancyByStudentId = useMemo(() => {
const map = new Map<number, any>();
@@ -139,7 +162,7 @@ const OccupanciesPage: React.FC = () => {
const [occRes, stuRes, rmRes] = (await Promise.allSettled([
api.get('/occupancies', {
params: {
active: showActive ? 'true' : undefined,
...occupancyParamsForView(viewMode),
dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'),
dateTo: dateRange?.[1]?.format('YYYY-MM-DD'),
},
@@ -161,7 +184,7 @@ const OccupanciesPage: React.FC = () => {
message.error('数据加载异常');
}
setLoading(false);
}, [showActive, dateRange]);
}, [viewMode, dateRange]);
useEffect(() => {
fetchData();
@@ -285,6 +308,7 @@ const OccupanciesPage: React.FC = () => {
};
const handleBatchCheckOut = async () => {
if (batchLoading) return;
const values = await batchCheckOutForm.validateFields();
setBatchLoading(true);
try {
@@ -307,6 +331,7 @@ const OccupanciesPage: React.FC = () => {
};
const handleBatchDelete = async () => {
if (batchLoading) return;
setBatchLoading(true);
try {
const res: any = await api.post('/occupancies/batch-delete', { ids: selectedRowKeys });
@@ -320,6 +345,26 @@ const OccupanciesPage: React.FC = () => {
}
};
const handleBatchRestore = async () => {
if (batchLoading) return;
setBatchLoading(true);
try {
const res = await api.put<{ restored: number; skipped: number }>(
'/occupancies/batch-restore',
{ ids: selectedRowKeys },
);
message.success(
`已恢复 ${res.restored} 条入住记录${res.skipped ? `,跳过 ${res.skipped}` : ''}`,
);
setSelectedRowKeys([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '批量恢复失败');
} finally {
setBatchLoading(false);
}
};
const columns = useMemo(
() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
@@ -350,7 +395,9 @@ const OccupanciesPage: React.FC = () => {
title: '操作',
width: 220,
render: (_: any, record: any) =>
!record.checkOutDate ? (
viewPolicy.readonly ? (
<Tag color="#999"></Tag>
) : !record.checkOutDate ? (
<Space>
<PermissionButton
permission="occupancy:checkout"
@@ -394,11 +441,7 @@ const OccupanciesPage: React.FC = () => {
}
}}
>
<Button
size="small"
danger
icon={<InboxOutlined />}
>
<Button size="small" danger icon={<InboxOutlined />}>
</Button>
</Popconfirm>
@@ -407,17 +450,25 @@ const OccupanciesPage: React.FC = () => {
),
},
],
[fetchData, setCheckOutModal, checkOutForm, setTransferModal, transferForm],
[
fetchData,
setCheckOutModal,
checkOutForm,
setTransferModal,
transferForm,
viewPolicy.readonly,
],
);
const rowSelection = useMemo(
() => ({
selectedRowKeys,
onChange: (keys: any[]) => setSelectedRowKeys(keys),
// 「在住记录」Tab禁用已退宿防止误选用于批量退宿「全部记录」Tab均可选用于批量归档
getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}),
// 「在住记录」视图禁用已退宿;其余视图中的记录均可选择。
getCheckboxProps: (record: any) =>
viewMode === 'active' ? { disabled: !!record.checkOutDate } : {},
}),
[selectedRowKeys, showActive],
[selectedRowKeys, viewMode],
);
return (
@@ -432,12 +483,24 @@ const OccupanciesPage: React.FC = () => {
/>
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<Button type={showActive ? 'primary' : 'default'} onClick={() => setShowActive(true)}>
<Button
type={viewMode === 'active' ? 'primary' : 'default'}
onClick={() => setViewMode('active')}
>
</Button>
<Button type={!showActive ? 'primary' : 'default'} onClick={() => setShowActive(false)}>
<Button
type={viewMode === 'all' ? 'primary' : 'default'}
onClick={() => setViewMode('all')}
>
</Button>
<Button
type={viewMode === 'archived' ? 'primary' : 'default'}
onClick={() => setViewMode('archived')}
>
</Button>
<Input.Search
placeholder="搜索学生姓名或房间号"
onSearch={setSearchText}
@@ -454,29 +517,31 @@ const OccupanciesPage: React.FC = () => {
/>
</Space>
<Space wrap className="responsive-toolbar__group">
<PermissionButton
permission="occupancy:checkin"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
checkInForm.resetFields();
setAvailableBeds([]);
setAvailableLockers([]);
setAvailableResourcesLoading(false);
const today = dayjs();
checkInForm.setFieldsValue({
checkInDate: today,
billingStartDate: today,
stayType: 'short',
collectDeposit: true,
depositAmount: 500,
});
setCheckInModal(true);
}}
>
</PermissionButton>
{canCheckIn ? (
{viewMode !== 'archived' ? (
<PermissionButton
permission="occupancy:checkin"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
checkInForm.resetFields();
setAvailableBeds([]);
setAvailableLockers([]);
setAvailableResourcesLoading(false);
const today = dayjs();
checkInForm.setFieldsValue({
checkInDate: today,
billingStartDate: today,
stayType: 'short',
collectDeposit: true,
depositAmount: 500,
});
setCheckInModal(true);
}}
>
</PermissionButton>
) : null}
{viewMode !== 'archived' && canCheckIn ? (
<>
<Upload
accept=".xlsx,.xls"
@@ -547,30 +612,34 @@ const OccupanciesPage: React.FC = () => {
</span>
</>
) : null}
<PermissionButton
permission="occupancy:view"
icon={<DownloadOutlined />}
onClick={() => {
downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() =>
message.error('下载失败'),
);
}}
>
</PermissionButton>
<PermissionButton
permission="occupancy:view"
icon={<ExportOutlined />}
onClick={() => {
const params = showActive ? '?active=true' : '';
const filename = showActive ? '在住记录.xlsx' : '全部入住记录.xlsx';
downloadBlob('/occupancies/export' + params, filename).catch(() =>
message.error('导出失败'),
);
}}
>
</PermissionButton>
{viewMode !== 'archived' ? (
<PermissionButton
permission="occupancy:view"
icon={<DownloadOutlined />}
onClick={() => {
downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() =>
message.error('下载失败'),
);
}}
>
</PermissionButton>
) : null}
{viewMode !== 'archived' ? (
<PermissionButton
permission="occupancy:view"
icon={<ExportOutlined />}
onClick={() => {
const params = viewMode === 'active' ? '?active=true' : '';
const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx';
downloadBlob('/occupancies/export' + params, filename).catch(() =>
message.error('导出失败'),
);
}}
>
</PermissionButton>
) : null}
</Space>
</div>
{selectedRowKeys.length > 0 && (
@@ -578,7 +647,7 @@ const OccupanciesPage: React.FC = () => {
title={
<span>
<strong>{selectedRowKeys.length}</strong>
{showActive ? (
{viewPolicy.batchAction === 'checkout' ? (
<PermissionButton
permission="occupancy:checkout"
type="primary"
@@ -594,7 +663,7 @@ const OccupanciesPage: React.FC = () => {
>
退宿
</PermissionButton>
) : (
) : viewPolicy.batchAction === 'archive' ? (
canDelete ? (
<Popconfirm
title={`确定归档选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`}
@@ -613,7 +682,24 @@ const OccupanciesPage: React.FC = () => {
</Button>
</Popconfirm>
) : null
)}
) : canDelete ? (
<Popconfirm
title={`确定恢复选中的 ${selectedRowKeys.length} 条入住记录?`}
onConfirm={handleBatchRestore}
okText="恢复"
cancelText="取消"
>
<Button
type="primary"
size="small"
icon={<UndoOutlined />}
style={{ marginLeft: 12 }}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
<Button size="small" onClick={() => setSelectedRowKeys([])} style={{ marginLeft: 8 }}>
</Button>