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

@@ -22,6 +22,7 @@ import {
UploadOutlined,
DownloadOutlined,
ExportOutlined,
UndoOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
@@ -30,6 +31,7 @@ import EditableCell from '../../components/EditableCell';
import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import { archiveViewPolicy, expenseStatusForView } from '../archive-view';
const { RangePicker } = DatePicker;
@@ -61,6 +63,8 @@ const ExpensesPage: React.FC = () => {
const [selectedPersonalKeys, setSelectedPersonalKeys] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const expenseViewPolicy = archiveViewPolicy(showArchived ? 'archived' : 'active');
// Dynamic expense type options from API
const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]);
@@ -124,12 +128,56 @@ const ExpensesPage: React.FC = () => {
}
};
const handleBatchRestoreRoom = async () => {
if (batchLoading) return;
setBatchLoading(true);
try {
const res = await api.put<{ restored: number; skipped: number }>(
'/expenses/room/batch-restore',
{ ids: selectedRoomKeys },
);
message.success(
`已恢复 ${res.restored} 条宿舍费用${res.skipped ? `,跳过 ${res.skipped}` : ''}`,
);
setSelectedRoomKeys([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '批量恢复失败');
} finally {
setBatchLoading(false);
}
};
const handleBatchRestorePersonal = async () => {
if (batchLoading) return;
setBatchLoading(true);
try {
const res = await api.put<{ restored: number; skipped: number }>(
'/expenses/personal/batch-restore',
{ ids: selectedPersonalKeys },
);
message.success(
`已恢复 ${res.restored} 条个人费用${res.skipped ? `,跳过 ${res.skipped}` : ''}`,
);
setSelectedPersonalKeys([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '批量恢复失败');
} finally {
setBatchLoading(false);
}
};
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [re, pe, lookups]: any[] = await Promise.all([
api.get('/expenses/room'),
api.get('/expenses/personal'),
api.get('/expenses/room', {
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
}),
api.get('/expenses/personal', {
params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') },
}),
api.get('/expenses/lookups').catch(() => ({ rooms: [], students: [] })),
]);
setRoomExpenses(re);
@@ -140,10 +188,12 @@ const ExpensesPage: React.FC = () => {
message.error(e?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, []);
}, [showArchived]);
useEffect(() => {
fetchData();
setSelectedRoomKeys([]);
setSelectedPersonalKeys([]);
}, [fetchData]);
const filteredRoomExpenses = useMemo(() => {
@@ -287,6 +337,7 @@ const ExpensesPage: React.FC = () => {
editor="select"
options={rooms.map((item) => ({ value: item.id, label: item.roomNumber }))}
permission="expense:edit"
disabled={expenseViewPolicy.readonly}
required
onSave={(next) => saveRoomCell(r, 'roomId', next)}
>
@@ -304,6 +355,7 @@ const ExpensesPage: React.FC = () => {
editor="select"
options={typeOptions}
permission="expense:edit"
disabled={expenseViewPolicy.readonly}
required
onSave={(next) => saveRoomCell(r, 'expenseType', next)}
>
@@ -321,6 +373,7 @@ const ExpensesPage: React.FC = () => {
editor="money"
min={0.01}
permission="expense:edit"
disabled={expenseViewPolicy.readonly}
required
onSave={(next) => saveRoomCell(r, 'amount', next)}
>{`¥${Number(v).toFixed(2)}`}</EditableCell>
@@ -334,6 +387,7 @@ const ExpensesPage: React.FC = () => {
value={[r.periodStart, r.periodEnd]}
editor="date-range"
permission="expense:edit"
disabled={expenseViewPolicy.readonly}
required
onSave={async (next) => {
const [periodStart, periodEnd] = next as unknown as [string, string];
@@ -353,6 +407,7 @@ const ExpensesPage: React.FC = () => {
value={v}
editor="textarea"
permission="expense:edit"
disabled={expenseViewPolicy.readonly}
onSave={(next) => saveRoomCell(r, 'description', next)}
>
{v || '-'}
@@ -368,48 +423,60 @@ const ExpensesPage: React.FC = () => {
{
title: '操作',
width: 120,
render: (_: any, record: any) => (
<Space>
<PermissionButton
permission="expense:edit"
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditingRoom(record);
roomForm.setFieldsValue({
roomId: record.roomId,
expenseType: record.expenseType,
amount: Number(record.amount),
period: [dayjs(record.periodStart), dayjs(record.periodEnd)],
description: record.description,
});
setRoomModal(true);
}}
>
</PermissionButton>
<Popconfirm
title="确定归档?"
onConfirm={async () => {
await api.delete(`/expenses/room/${record.id}`);
message.success('归档成功');
fetchData();
}}
>
render: (_: any, record: any) =>
showArchived ? (
<Tag color="#999"></Tag>
) : (
<Space>
<PermissionButton
permission="expense:delete"
permission="expense:edit"
size="small"
danger
icon={<InboxOutlined />}
icon={<EditOutlined />}
onClick={() => {
setEditingRoom(record);
roomForm.setFieldsValue({
roomId: record.roomId,
expenseType: record.expenseType,
amount: Number(record.amount),
period: [dayjs(record.periodStart), dayjs(record.periodEnd)],
description: record.description,
});
setRoomModal(true);
}}
>
</PermissionButton>
</Popconfirm>
</Space>
),
<Popconfirm
title="确定归档?"
onConfirm={async () => {
await api.delete(`/expenses/room/${record.id}`);
message.success('归档成功');
fetchData();
}}
>
<PermissionButton
permission="expense:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
),
},
],
[rooms, typeOptions, typeMap, saveRoomCell, roomForm, fetchData],
[
rooms,
typeOptions,
typeMap,
saveRoomCell,
roomForm,
fetchData,
showArchived,
expenseViewPolicy.readonly,
],
);
const personalColumns = useMemo(
@@ -423,6 +490,7 @@ const ExpensesPage: React.FC = () => {
editor="select"
options={students.map((item) => ({ value: item.id, label: item.name }))}
permission="expense:edit"
disabled={expenseViewPolicy.readonly}
required
onSave={(next) => savePersonalCell(r, 'studentId', next)}
>
@@ -440,6 +508,7 @@ const ExpensesPage: React.FC = () => {
editor="select"
options={personalTypeOptions}
permission="expense:edit"
disabled={expenseViewPolicy.readonly}
required
onSave={(next) => savePersonalCell(r, 'expenseType', next)}
>
@@ -456,6 +525,7 @@ const ExpensesPage: React.FC = () => {
editor="money"
min={0.01}
permission="expense:edit"
disabled={expenseViewPolicy.readonly}
required
onSave={(next) => savePersonalCell(r, 'amount', next)}
>{`¥${Number(v).toFixed(2)}`}</EditableCell>
@@ -470,6 +540,7 @@ const ExpensesPage: React.FC = () => {
value={v}
editor="date"
permission="expense:edit"
disabled={expenseViewPolicy.readonly}
required
onSave={(next) => savePersonalCell(r, 'expenseDate', next)}
>
@@ -486,6 +557,7 @@ const ExpensesPage: React.FC = () => {
value={v}
editor="textarea"
permission="expense:edit"
disabled={expenseViewPolicy.readonly}
onSave={(next) => savePersonalCell(r, 'description', next)}
>
{v || '-'}
@@ -495,53 +567,73 @@ const ExpensesPage: React.FC = () => {
{
title: '操作',
width: 120,
render: (_: any, record: any) => (
<Space>
<PermissionButton
permission="expense:edit"
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditingPersonal(record);
personalForm.setFieldsValue({
studentId: record.studentId,
roomId: record.roomId,
expenseType: record.expenseType,
amount: Number(record.amount),
expenseDate: dayjs(record.expenseDate),
description: record.description,
});
setPersonalModal(true);
}}
>
</PermissionButton>
<Popconfirm
title="确定归档?"
onConfirm={async () => {
await api.delete(`/expenses/personal/${record.id}`);
message.success('归档成功');
fetchData();
}}
>
render: (_: any, record: any) =>
showArchived ? (
<Tag color="#999"></Tag>
) : (
<Space>
<PermissionButton
permission="expense:delete"
permission="expense:edit"
size="small"
danger
icon={<InboxOutlined />}
icon={<EditOutlined />}
onClick={() => {
setEditingPersonal(record);
personalForm.setFieldsValue({
studentId: record.studentId,
roomId: record.roomId,
expenseType: record.expenseType,
amount: Number(record.amount),
expenseDate: dayjs(record.expenseDate),
description: record.description,
});
setPersonalModal(true);
}}
>
</PermissionButton>
</Popconfirm>
</Space>
),
<Popconfirm
title="确定归档?"
onConfirm={async () => {
await api.delete(`/expenses/personal/${record.id}`);
message.success('归档成功');
fetchData();
}}
>
<PermissionButton
permission="expense:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
),
},
],
[students, personalTypeOptions, typeMap, savePersonalCell, personalForm, fetchData],
[
students,
personalTypeOptions,
typeMap,
savePersonalCell,
personalForm,
fetchData,
showArchived,
expenseViewPolicy.readonly,
],
);
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Button type={!showArchived ? 'primary' : 'default'} onClick={() => setShowArchived(false)}>
</Button>
<Button type={showArchived ? 'primary' : 'default'} onClick={() => setShowArchived(true)}>
</Button>
</Space>
<Tabs
items={[
{
@@ -576,7 +668,7 @@ const ExpensesPage: React.FC = () => {
onChange={(v) => setRoomTypeFilter(v)}
options={typeOptions}
/>
{hasPermission('expense:create') ? (
{!showArchived && hasPermission('expense:create') ? (
<Upload
accept=".xlsx,.xls"
showUploadList={false}
@@ -605,47 +697,71 @@ const ExpensesPage: React.FC = () => {
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
) : null}
<PermissionButton
permission="expense:view"
icon={<DownloadOutlined />}
onClick={() => {
downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(
() => message.error('下载失败'),
);
}}
>
</PermissionButton>
{!showArchived ? (
<PermissionButton
permission="expense:view"
icon={<DownloadOutlined />}
onClick={() => {
downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(
() => message.error('下载失败'),
);
}}
>
</PermissionButton>
) : null}
</Space>
<Space>
<Popconfirm
title={`确定归档选中的 ${selectedRoomKeys.length} 条费用?`}
onConfirm={handleBatchDeleteRoom}
okText="归档"
cancelText="取消"
disabled={selectedRoomKeys.length === 0}
>
<PermissionButton
permission="expense:delete"
danger
icon={<InboxOutlined />}
{showArchived ? (
<Popconfirm
title={`确定恢复选中的 ${selectedRoomKeys.length} 条费用?`}
onConfirm={handleBatchRestoreRoom}
okText="恢复"
cancelText="取消"
disabled={selectedRoomKeys.length === 0}
>
<PermissionButton
permission="expense:edit"
type="primary"
icon={<UndoOutlined />}
loading={batchLoading}
disabled={selectedRoomKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
) : (
<Popconfirm
title={`确定归档选中的 ${selectedRoomKeys.length} 条费用?`}
onConfirm={handleBatchDeleteRoom}
okText="归档"
cancelText="取消"
disabled={selectedRoomKeys.length === 0}
>
<PermissionButton
permission="expense:delete"
danger
icon={<InboxOutlined />}
disabled={selectedRoomKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
)}
{!showArchived ? (
<PermissionButton
permission="expense:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingRoom(null);
roomForm.resetFields();
setRoomModal(true);
}}
>
宿
</PermissionButton>
</Popconfirm>
<PermissionButton
permission="expense:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingRoom(null);
roomForm.resetFields();
setRoomModal(true);
}}
>
宿
</PermissionButton>
) : null}
</Space>
</div>
<Table
@@ -701,7 +817,7 @@ const ExpensesPage: React.FC = () => {
onChange={(v) => setPersonalTypeFilter(v)}
options={personalTypeOptions}
/>
{hasPermission('expense:create') ? (
{!showArchived && hasPermission('expense:create') ? (
<Upload
accept=".xlsx,.xls"
showUploadList={false}
@@ -724,69 +840,97 @@ const ExpensesPage: React.FC = () => {
<Button icon={<UploadOutlined />}></Button>
</Upload>
) : null}
<PermissionButton
permission="expense:view"
icon={<DownloadOutlined />}
onClick={() => {
downloadBlob(
'/expenses/personal/template',
'个人附加费导入模板.xlsx',
).catch(() => message.error('下载失败'));
}}
>
</PermissionButton>
<PermissionButton
permission="expense:view"
icon={<ExportOutlined />}
onClick={() => {
downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() =>
message.error('导出失败'),
);
}}
>
</PermissionButton>
{!showArchived ? (
<PermissionButton
permission="expense:view"
icon={<DownloadOutlined />}
onClick={() => {
downloadBlob(
'/expenses/personal/template',
'个人附加费导入模板.xlsx',
).catch(() => message.error('下载失败'));
}}
>
</PermissionButton>
) : null}
{!showArchived ? (
<PermissionButton
permission="expense:view"
icon={<ExportOutlined />}
onClick={() => {
downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(
() => message.error('导出失败'),
);
}}
>
</PermissionButton>
) : null}
</Space>
<Space>
<Popconfirm
title={`确定归档选中的 ${selectedPersonalKeys.length} 条个人费用?`}
onConfirm={handleBatchDeletePersonal}
okText="归档"
cancelText="取消"
disabled={selectedPersonalKeys.length === 0}
>
<PermissionButton
permission="expense:delete"
danger
icon={<InboxOutlined />}
{showArchived ? (
<Popconfirm
title={`确定恢复选中的 ${selectedPersonalKeys.length} 条个人费用?`}
onConfirm={handleBatchRestorePersonal}
okText="恢复"
cancelText="取消"
disabled={selectedPersonalKeys.length === 0}
>
<PermissionButton
permission="expense:edit"
type="primary"
icon={<UndoOutlined />}
loading={batchLoading}
disabled={selectedPersonalKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
) : (
<Popconfirm
title={`确定归档选中的 ${selectedPersonalKeys.length} 条个人费用?`}
onConfirm={handleBatchDeletePersonal}
okText="归档"
cancelText="取消"
disabled={selectedPersonalKeys.length === 0}
>
<PermissionButton
permission="expense:delete"
danger
icon={<InboxOutlined />}
disabled={selectedPersonalKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
)}
{!showArchived ? (
<PermissionButton
permission="expense:create"
icon={<PlusOutlined />}
onClick={() => {
utilityForm.resetFields();
setUtilityModal(true);
}}
>
</PermissionButton>
</Popconfirm>
<PermissionButton
permission="expense:create"
icon={<PlusOutlined />}
onClick={() => {
utilityForm.resetFields();
setUtilityModal(true);
}}
>
</PermissionButton>
<PermissionButton
permission="expense:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingPersonal(null);
personalForm.resetFields();
setPersonalModal(true);
}}
>
</PermissionButton>
) : null}
{!showArchived ? (
<PermissionButton
permission="expense:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingPersonal(null);
personalForm.resetFields();
setPersonalModal(true);
}}
>
</PermissionButton>
) : null}
</Space>
</div>
<Table