diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 2e015b1..4be2552 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -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([]); 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)}`} @@ -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) => ( - - } - 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); - }} - > - 编辑 - - { - await api.delete(`/expenses/room/${record.id}`); - message.success('归档成功'); - fetchData(); - }} - > + render: (_: any, record: any) => + showArchived ? ( + 已归档 + ) : ( + } + icon={} + 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); + }} > - 归档 + 编辑 - - - ), + { + await api.delete(`/expenses/room/${record.id}`); + message.success('归档成功'); + fetchData(); + }} + > + } + > + 归档 + + + + ), }, ], - [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)}`} @@ -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) => ( - - } - 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); - }} - > - 编辑 - - { - await api.delete(`/expenses/personal/${record.id}`); - message.success('归档成功'); - fetchData(); - }} - > + render: (_: any, record: any) => + showArchived ? ( + 已归档 + ) : ( + } + icon={} + 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); + }} > - 归档 + 编辑 - - - ), + { + await api.delete(`/expenses/personal/${record.id}`); + message.success('归档成功'); + fetchData(); + }} + > + } + > + 归档 + + + + ), }, ], - [students, personalTypeOptions, typeMap, savePersonalCell, personalForm, fetchData], + [ + students, + personalTypeOptions, + typeMap, + savePersonalCell, + personalForm, + fetchData, + showArchived, + expenseViewPolicy.readonly, + ], ); return (
+ + + + { onChange={(v) => setRoomTypeFilter(v)} options={typeOptions} /> - {hasPermission('expense:create') ? ( + {!showArchived && hasPermission('expense:create') ? ( { ) : null} - } - onClick={() => { - downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch( - () => message.error('下载失败'), - ); - }} - > - 下载水电费模板 - + {!showArchived ? ( + } + onClick={() => { + downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch( + () => message.error('下载失败'), + ); + }} + > + 下载水电费模板 + + ) : null} - - } + {showArchived ? ( + - 批量归档 + } + loading={batchLoading} + disabled={selectedRoomKeys.length === 0} + > + 批量恢复 + + + ) : ( + + } + disabled={selectedRoomKeys.length === 0} + > + 批量归档 + + + )} + {!showArchived ? ( + } + onClick={() => { + setEditingRoom(null); + roomForm.resetFields(); + setRoomModal(true); + }} + > + 录入宿舍费用 - - } - onClick={() => { - setEditingRoom(null); - roomForm.resetFields(); - setRoomModal(true); - }} - > - 录入宿舍费用 - + ) : null}
{ onChange={(v) => setPersonalTypeFilter(v)} options={personalTypeOptions} /> - {hasPermission('expense:create') ? ( + {!showArchived && hasPermission('expense:create') ? ( { ) : null} - } - onClick={() => { - downloadBlob( - '/expenses/personal/template', - '个人附加费导入模板.xlsx', - ).catch(() => message.error('下载失败')); - }} - > - 下载模板 - - } - onClick={() => { - downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() => - message.error('导出失败'), - ); - }} - > - 导出 - + {!showArchived ? ( + } + onClick={() => { + downloadBlob( + '/expenses/personal/template', + '个人附加费导入模板.xlsx', + ).catch(() => message.error('下载失败')); + }} + > + 下载模板 + + ) : null} + {!showArchived ? ( + } + onClick={() => { + downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch( + () => message.error('导出失败'), + ); + }} + > + 导出 + + ) : null} - - } + {showArchived ? ( + - 批量归档 + } + loading={batchLoading} + disabled={selectedPersonalKeys.length === 0} + > + 批量恢复 + + + ) : ( + + } + disabled={selectedPersonalKeys.length === 0} + > + 批量归档 + + + )} + {!showArchived ? ( + } + onClick={() => { + utilityForm.resetFields(); + setUtilityModal(true); + }} + > + 添加学生水电费 - - } - onClick={() => { - utilityForm.resetFields(); - setUtilityModal(true); - }} - > - 添加学生水电费 - - } - onClick={() => { - setEditingPersonal(null); - personalForm.resetFields(); - setPersonalModal(true); - }} - > - 录入个人费用 - + ) : null} + {!showArchived ? ( + } + onClick={() => { + setEditingPersonal(null); + personalForm.resetFields(); + setPersonalModal(true); + }} + > + 录入个人费用 + + ) : null}
{ const [checkInModal, setCheckInModal] = useState(false); const [checkOutModal, setCheckOutModal] = useState(null); const [transferModal, setTransferModal] = useState(null); - const [showActive, setShowActive] = useState(true); + const [viewMode, setViewMode] = useState('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(); @@ -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 ? ( + 已归档 + ) : !record.checkOutDate ? ( { } }} > - @@ -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 = () => { />
- - + { /> - } - onClick={() => { - checkInForm.resetFields(); - setAvailableBeds([]); - setAvailableLockers([]); - setAvailableResourcesLoading(false); - const today = dayjs(); - checkInForm.setFieldsValue({ - checkInDate: today, - billingStartDate: today, - stayType: 'short', - collectDeposit: true, - depositAmount: 500, - }); - setCheckInModal(true); - }} - > - 入住登记 - - {canCheckIn ? ( + {viewMode !== 'archived' ? ( + } + onClick={() => { + checkInForm.resetFields(); + setAvailableBeds([]); + setAvailableLockers([]); + setAvailableResourcesLoading(false); + const today = dayjs(); + checkInForm.setFieldsValue({ + checkInDate: today, + billingStartDate: today, + stayType: 'short', + collectDeposit: true, + depositAmount: 500, + }); + setCheckInModal(true); + }} + > + 入住登记 + + ) : null} + {viewMode !== 'archived' && canCheckIn ? ( <> { ) : null} - } - onClick={() => { - downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() => - message.error('下载失败'), - ); - }} - > - 下载模板 - - } - onClick={() => { - const params = showActive ? '?active=true' : ''; - const filename = showActive ? '在住记录.xlsx' : '全部入住记录.xlsx'; - downloadBlob('/occupancies/export' + params, filename).catch(() => - message.error('导出失败'), - ); - }} - > - 导出记录 - + {viewMode !== 'archived' ? ( + } + onClick={() => { + downloadBlob('/occupancies/template', '入住名单导入模板.xlsx').catch(() => + message.error('下载失败'), + ); + }} + > + 下载模板 + + ) : null} + {viewMode !== 'archived' ? ( + } + onClick={() => { + const params = viewMode === 'active' ? '?active=true' : ''; + const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx'; + downloadBlob('/occupancies/export' + params, filename).catch(() => + message.error('导出失败'), + ); + }} + > + 导出记录 + + ) : null}
{selectedRowKeys.length > 0 && ( @@ -578,7 +647,7 @@ const OccupanciesPage: React.FC = () => { title={ 已选 {selectedRowKeys.length} 条记录 - {showActive ? ( + {viewPolicy.batchAction === 'checkout' ? ( { > 批量退宿 - ) : ( + ) : viewPolicy.batchAction === 'archive' ? ( canDelete ? ( { ) : null - )} + ) : canDelete ? ( + + + + ) : null} diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 61cf40f..c24a03f 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -32,6 +32,7 @@ import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; +import { selectArchiveRecords } from '../archive-view'; const statusMap: Record = { available: { text: '可入住', color: 'green' }, @@ -95,7 +96,6 @@ const RoomsPage: React.FC = () => { const [editing, setEditing] = useState(null); const canSaveRoom = editing ? canEditRooms : canCreateRooms; const [showArchived, setShowArchived] = useState(false); - const [archivedCount, setArchivedCount] = useState(0); const [searchText, setSearchText] = useState(''); const [filterBuilding, setFilterBuilding] = useState(undefined); const [filterStatus, setFilterStatus] = useState(undefined); @@ -118,9 +118,16 @@ const RoomsPage: React.FC = () => { const [batchLoading, setBatchLoading] = useState(false); // Close modals when the required permission is lost - useEffect(() => { if (!canSaveRoom && modalOpen) { setModalOpen(false); setEditing(null); form.resetFields(); } }, [canSaveRoom, modalOpen, form]); + useEffect(() => { + if (!canSaveRoom && modalOpen) { + setModalOpen(false); + setEditing(null); + form.resetFields(); + } + }, [canSaveRoom, modalOpen, form]); const handleBatchDelete = async () => { + if (batchLoading) return; setBatchLoading(true); try { const res: any = await api.post('/rooms/batch-delete', { ids: selectedRowKeys }); @@ -134,14 +141,32 @@ const RoomsPage: React.FC = () => { } }; + const handleBatchRestore = async () => { + if (batchLoading) return; + setBatchLoading(true); + try { + const res = await api.put<{ message?: string; restored: number; skipped: number }>( + '/rooms/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 fetchData = async () => { setLoading(true); try { - const params: any = { includeArchived: 'true' }; + const params: any = { includeArchived: showArchived ? 'true' : undefined }; const res: any = await api.get('/rooms/overview', { params }); - const archived = res.filter((r: any) => r.status === 'archived'); - setArchivedCount(archived.length); - const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived'); + const filtered = selectArchiveRecords(res, showArchived ? 'archived' : 'active'); setData(filtered); } catch (e: unknown) { const err = e as { message?: string }; @@ -530,11 +555,7 @@ const RoomsPage: React.FC = () => { {r.status === 'archived' ? ( canEditRooms ? ( handleRestore(r.id)}> - @@ -568,10 +589,7 @@ const RoomsPage: React.FC = () => {
{canDeleteRooms ? ( handleArchive(r.id)}> - @@ -638,15 +656,34 @@ const RoomsPage: React.FC = () => { />
- {canDeleteRooms ? ( + {showArchived && canEditRooms ? ( + + + + ) : !showArchived && canDeleteRooms ? ( { ) : null} - } - onClick={() => { - setEditing(null); - form.resetFields(); - setModalOpen(true); - }} - > - 添加宿舍 - - {hasPermission('room:create') ? ( + {!showArchived ? ( + } + onClick={() => { + setEditing(null); + form.resetFields(); + setModalOpen(true); + }} + > + 添加宿舍 + + ) : null} + {!showArchived && hasPermission('room:create') ? ( { rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys as number[]), - getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }), }} /> diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 74625f8..69657b7 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -39,6 +39,7 @@ import JinshujuMatchModal from '../../components/JinshujuMatchModal'; import { maskIdNumber, maskPhone } from '../../utils/sensitive'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; +import { selectArchiveRecords } from '../archive-view'; const statusMap: Record = { active: { text: '在读', color: 'green' }, @@ -111,7 +112,6 @@ const StudentsPage: React.FC = () => { const [classOptions, setClassOptions] = useState([]); const [teacherOptions, setTeacherOptions] = useState([]); const [showArchived, setShowArchived] = useState(false); - const [archivedCount, setArchivedCount] = useState(0); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchLoading, setBatchLoading] = useState(false); const [enrollmentData, setEnrollmentData] = useState>({}); @@ -185,6 +185,7 @@ const StudentsPage: React.FC = () => { }; const handleBatchDelete = async () => { + if (batchLoading) return; setBatchLoading(true); try { const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys }); @@ -198,22 +199,41 @@ const StudentsPage: React.FC = () => { } }; + const handleBatchRestore = async () => { + if (batchLoading) return; + setBatchLoading(true); + try { + const res = await api.put<{ message?: string; restored: number; skipped: number }>( + '/students/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 fetchData = useCallback(async () => { setLoading(true); try { const params: Record = { name: searchName || undefined, - includeArchived: 'true', + includeArchived: showArchived ? 'true' : undefined, }; - if (filterStatus) params.status = filterStatus; + if (showArchived) params.status = 'archived'; + else if (filterStatus) params.status = filterStatus; if (filterOrganizationId) params.organizationId = filterOrganizationId; if (filterClassId) params.classId = filterClassId; if (filterTeacherId) params.teacherId = filterTeacherId; const res = (await api.get('/students', { params })) as Array>; const list = res as Array>; - const archived = list.filter((r) => r.status === 'archived'); - setArchivedCount(archived.length); - setData(showArchived ? list : list.filter((r) => r.status !== 'archived')); + setData(selectArchiveRecords(list, showArchived ? 'archived' : 'active')); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '加载失败,请稍后重试'); @@ -702,14 +722,11 @@ const StudentsPage: React.FC = () => { okText="归档" cancelText="取消" > - - - ) : null} + + + ) : null} )} @@ -803,15 +820,34 @@ const StudentsPage: React.FC = () => { /> - {canDeleteStudent ? ( + {showArchived && canEditStudent ? ( + + + + ) : !showArchived && canDeleteStudent ? ( { ) : null} - } - onClick={() => { - setEditing(null); - form.resetFields(); - const host = organizations.find((organization) => organization.isHost); - if (host) form.setFieldValue('organizationId', host.id); - setModalOpen(true); - }} - > - 添加学生 - - {hasPermission('student:import') ? ( + {!showArchived ? ( + } + onClick={() => { + setEditing(null); + form.resetFields(); + const host = organizations.find((organization) => organization.isHost); + if (host) form.setFieldValue('organizationId', host.id); + setModalOpen(true); + }} + > + 添加学生 + + ) : null} + {!showArchived && hasPermission('student:import') ? ( <> { ) : null} - {canSyncJinshuju ? ( + {!showArchived && canSyncJinshuju ? ( @@ -910,7 +948,6 @@ const StudentsPage: React.FC = () => { rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys as number[]), - getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }), }} expandable={{ rowExpandable: () => true, diff --git a/apps/admin/src/pages/archive-view.integration.test.ts b/apps/admin/src/pages/archive-view.integration.test.ts new file mode 100644 index 0000000..4d36a29 --- /dev/null +++ b/apps/admin/src/pages/archive-view.integration.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { + archiveViewPolicy, + expenseStatusForView, + occupancyParamsForView, + occupancyViewPolicy, + selectArchiveRecords, + shouldClearSelectionOnViewChange, +} from './archive-view'; + +describe('归档数据视图', () => { + it('正常视图与归档视图不会混合记录', () => { + const records = [ + { id: 1, status: 'active' }, + { id: 2, status: 'graduated' }, + { id: 3, status: 'archived' }, + ]; + + expect(selectArchiveRecords(records, 'active').map((item) => item.id)).toEqual([1, 2]); + expect(selectArchiveRecords(records, 'archived').map((item) => item.id)).toEqual([3]); + }); + + it('费用视图映射为后端 status 查询', () => { + expect(expenseStatusForView('active')).toBe('active'); + expect(expenseStatusForView('archived')).toBe('archived'); + }); + + it('入住三态分别映射为在住、全部活动记录和归档记录', () => { + expect(occupancyParamsForView('active')).toEqual({ active: 'true', status: 'active' }); + expect(occupancyParamsForView('all')).toEqual({ active: undefined, status: 'active' }); + expect(occupancyParamsForView('archived')).toEqual({ + active: undefined, + status: 'archived', + }); + }); + + it('正常与归档视图的批量动作互斥,且归档视图只读', () => { + expect(archiveViewPolicy('active')).toEqual({ batchAction: 'archive', readonly: false }); + expect(archiveViewPolicy('archived')).toEqual({ batchAction: 'restore', readonly: true }); + }); + + it('入住三态分别只提供退宿、归档和恢复动作', () => { + expect(occupancyViewPolicy('active')).toEqual({ + batchAction: 'checkout', + readonly: false, + }); + expect(occupancyViewPolicy('all')).toEqual({ batchAction: 'archive', readonly: false }); + expect(occupancyViewPolicy('archived')).toEqual({ + batchAction: 'restore', + readonly: true, + }); + }); + + it('只有实际切换视图时才要求清空选择', () => { + expect(shouldClearSelectionOnViewChange('active', 'archived')).toBe(true); + expect(shouldClearSelectionOnViewChange('archived', 'archived')).toBe(false); + }); +}); diff --git a/apps/admin/src/pages/archive-view.ts b/apps/admin/src/pages/archive-view.ts new file mode 100644 index 0000000..26c0ba0 --- /dev/null +++ b/apps/admin/src/pages/archive-view.ts @@ -0,0 +1,36 @@ +export type ArchiveView = 'active' | 'archived'; +export type OccupancyView = 'active' | 'all' | 'archived'; +export type BatchAction = 'archive' | 'restore' | 'checkout'; + +export interface ViewPolicy { + batchAction: BatchAction; + readonly: boolean; +} + +export const selectArchiveRecords = ( + records: T[], + view: ArchiveView, +) => + records.filter((record) => + view === 'archived' ? record.status === 'archived' : record.status !== 'archived', + ); + +export const expenseStatusForView = (view: ArchiveView) => view; + +export const archiveViewPolicy = (view: ArchiveView): ViewPolicy => ({ + batchAction: view === 'archived' ? 'restore' : 'archive', + readonly: view === 'archived', +}); + +export const occupancyViewPolicy = (view: OccupancyView): ViewPolicy => ({ + batchAction: view === 'active' ? 'checkout' : view === 'all' ? 'archive' : 'restore', + readonly: view === 'archived', +}); + +export const shouldClearSelectionOnViewChange = (current: T, next: T) => + current !== next; + +export const occupancyParamsForView = (view: OccupancyView) => ({ + active: view === 'active' ? 'true' : undefined, + status: view === 'archived' ? 'archived' : 'active', +}); diff --git a/apps/server/src/common/batch-ids.dto.spec.ts b/apps/server/src/common/batch-ids.dto.spec.ts new file mode 100644 index 0000000..6377f99 --- /dev/null +++ b/apps/server/src/common/batch-ids.dto.spec.ts @@ -0,0 +1,20 @@ +import { validate } from 'class-validator'; +import { BatchIdsDto } from './batch-ids.dto'; + +describe('BatchIdsDto', () => { + it.each([ + { ids: [] }, + { ids: [0] }, + { ids: [-1] }, + { ids: [1.5] }, + { ids: ['1'] }, + ])('rejects invalid ids: $ids', async ({ ids }) => { + const dto = Object.assign(new BatchIdsDto(), { ids }); + await expect(validate(dto)).resolves.not.toHaveLength(0); + }); + + it('allows duplicate positive integer ids for service-level normalization', async () => { + const dto = Object.assign(new BatchIdsDto(), { ids: [1, 1, 2] }); + await expect(validate(dto)).resolves.toHaveLength(0); + }); +}); diff --git a/apps/server/src/common/batch-ids.dto.ts b/apps/server/src/common/batch-ids.dto.ts new file mode 100644 index 0000000..d778abb --- /dev/null +++ b/apps/server/src/common/batch-ids.dto.ts @@ -0,0 +1,9 @@ +import { ArrayNotEmpty, IsArray, IsInt, Min } from 'class-validator'; + +export class BatchIdsDto { + @IsArray() + @ArrayNotEmpty() + @IsInt({ each: true }) + @Min(1, { each: true }) + ids: number[]; +} diff --git a/apps/server/src/common/batch-restore.controllers.spec.ts b/apps/server/src/common/batch-restore.controllers.spec.ts new file mode 100644 index 0000000..e62c557 --- /dev/null +++ b/apps/server/src/common/batch-restore.controllers.spec.ts @@ -0,0 +1,65 @@ +import 'reflect-metadata'; +import { PIPES_METADATA } from '@nestjs/common/constants'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { ExpensesController } from '../expenses/expenses.controller'; +import { OccupanciesController } from '../occupancies/occupancies.controller'; +import { RoomsController } from '../rooms/rooms.controller'; +import { StudentsController } from '../students/students.controller'; +import { BatchIdsDto } from './batch-ids.dto'; + +describe('batch restore controllers', () => { + const cases = [ + [StudentsController, 'batchRestore', ['student:edit']], + [RoomsController, 'batchRestore', ['room:edit']], + [ExpensesController, 'batchRestoreRoomExpenses', ['expense:edit']], + [ExpensesController, 'batchRestorePersonalExpenses', ['expense:edit']], + [OccupanciesController, 'batchRestore', ['occupancy:delete']], + ] as const; + + it.each(cases)('%p.%s has permission and method-level validation', (controller, method, permission) => { + const handler = controller.prototype[method] as (...args: never[]) => unknown; + expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual(permission); + expect(Reflect.getMetadata(PIPES_METADATA, handler)).toHaveLength(1); + }); + + it.each(cases)('%p.%s rejects invalid and non-whitelisted request bodies', async (controller, method) => { + const handler = controller.prototype[method] as (...args: never[]) => unknown; + const [pipe] = Reflect.getMetadata(PIPES_METADATA, handler); + const metadata = { type: 'body' as const, metatype: BatchIdsDto, data: undefined }; + await expect(pipe.transform({ ids: [] }, metadata)).rejects.toBeDefined(); + await expect(pipe.transform({ ids: [0] }, metadata)).rejects.toBeDefined(); + await expect(pipe.transform({ ids: [1], unexpected: true }, metadata)).rejects.toBeDefined(); + }); + + it('writes the requested audit action and ids for every successful restore endpoint', async () => { + const log = jest.fn().mockResolvedValue(undefined); + const req = { user: { id: 7, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + const services = { + students: { batchRestore: jest.fn().mockResolvedValue({ restored: 1, skipped: 0 }) }, + rooms: { batchRestore: jest.fn().mockResolvedValue({ restored: 1, skipped: 0 }) }, + expenses: { + batchRestoreRoomExpenses: jest.fn().mockResolvedValue({ restored: 1, skipped: 0 }), + batchRestorePersonalExpenses: jest.fn().mockResolvedValue({ restored: 1, skipped: 0 }), + }, + occupancies: { batchRestore: jest.fn().mockResolvedValue({ restored: 1, skipped: 0 }) }, + }; + const students = new StudentsController(services.students as never, { log } as never, {} as never, {} as never); + const rooms = new RoomsController(services.rooms as never, { log } as never, {} as never); + const expenses = new ExpensesController(services.expenses as never, { log } as never); + const occupancies = new OccupanciesController(services.occupancies as never, { log } as never, {} as never, {} as never); + + await students.batchRestore({ ids: [1, 2] }, req); + await rooms.batchRestore({ ids: [1, 2] }, req); + await expenses.batchRestoreRoomExpenses({ ids: [1, 2] }, req); + await expenses.batchRestorePersonalExpenses({ ids: [1, 2] }, req); + await occupancies.batchRestore({ ids: [1, 2] }, req); + + expect(log.mock.calls.map(([entry]) => [entry.action, entry.detail])).toEqual([ + ['批量恢复学生', 'IDs: 1,2'], + ['批量恢复宿舍', 'IDs: 1,2'], + ['批量恢复宿舍费用', 'IDs: 1,2'], + ['批量恢复个人费用', 'IDs: 1,2'], + ['批量恢复入住记录', 'IDs: 1,2'], + ]); + }); +}); diff --git a/apps/server/src/common/batch-restore.services.spec.ts b/apps/server/src/common/batch-restore.services.spec.ts new file mode 100644 index 0000000..21ff641 --- /dev/null +++ b/apps/server/src/common/batch-restore.services.spec.ts @@ -0,0 +1,307 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ExpensesService } from '../expenses/expenses.service'; +import { OccupanciesService } from '../occupancies/occupancies.service'; +import { RoomsService } from '../rooms/rooms.service'; +import { StudentsService } from '../students/students.service'; + +function updateQb(affected = 1) { + const qb = { + update: jest.fn(), + set: jest.fn(), + where: jest.fn(), + execute: jest.fn().mockResolvedValue({ affected }), + }; + qb.update.mockReturnValue(qb); + qb.set.mockReturnValue(qb); + qb.where.mockReturnValue(qb); + return qb; +} + +function listQb() { + const qb = { + leftJoinAndSelect: jest.fn(), + where: jest.fn(), + orderBy: jest.fn(), + andWhere: jest.fn(), + getMany: jest.fn().mockResolvedValue([]), + }; + qb.leftJoinAndSelect.mockReturnValue(qb); + qb.where.mockReturnValue(qb); + qb.orderBy.mockReturnValue(qb); + qb.andWhere.mockReturnValue(qb); + return qb; +} + +describe('batch restore service semantics', () => { + it('rejects empty and invalid ids in every restore service', async () => { + const students = new StudentsService( + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + {} as never, {} as never, {} as never, {} as never, {} as never, + ); + const rooms = new RoomsService( + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + ); + const expenses = new ExpensesService( + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + ); + const occupancies = new OccupanciesService( + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + ); + const calls = [ + (ids: number[]) => students.batchRestore(ids), + (ids: number[]) => rooms.batchRestore(ids), + (ids: number[]) => expenses.batchRestoreRoomExpenses(ids), + (ids: number[]) => expenses.batchRestorePersonalExpenses(ids), + (ids: number[]) => occupancies.batchRestore(ids), + ]; + for (const call of calls) { + await expect(call([])).rejects.toBeInstanceOf(BadRequestException); + await expect(call([0])).rejects.toBeInstanceOf(BadRequestException); + await expect(call([1.5])).rejects.toBeInstanceOf(BadRequestException); + } + }); + + it('deduplicates student ids, restores archived rows, and skips active rows', async () => { + const qb = updateQb(); + const repo = { + find: jest.fn().mockResolvedValue([ + { id: 1, status: 'archived' }, + { id: 2, status: 'active' }, + ]), + createQueryBuilder: jest.fn(() => qb), + }; + const service = new StudentsService( + repo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + await expect(service.batchRestore([1, 1, 2])).resolves.toEqual({ + message: '已批量恢复 1 名学生', + restored: 1, + skipped: 1, + }); + expect(repo.find).toHaveBeenCalledWith({ where: { id: expect.anything() } }); + expect(qb.set).toHaveBeenCalledWith({ status: 'active' }); + expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [1] }); + }); + + it('rejects missing student ids before updating', async () => { + const repo = { find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived' }]), createQueryBuilder: jest.fn() }; + const service = new StudentsService( + repo as never, {} as never, {} as never, {} as never, {} as never, {} as never, + {} as never, {} as never, {} as never, {} as never, {} as never, + ); + await expect(service.batchRestore([1, 2])).rejects.toBeInstanceOf(NotFoundException); + expect(repo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('restores archived rooms to available and skips non-archived rooms', async () => { + const qb = updateQb(); + const repo = { + find: jest.fn().mockResolvedValue([ + { id: 1, status: 'archived' }, + { id: 2, status: 'maintenance' }, + ]), + createQueryBuilder: jest.fn(() => qb), + }; + const service = new RoomsService( + repo as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + ); + await expect(service.batchRestore([1, 2])).resolves.toEqual({ + message: '已批量恢复 1 间宿舍', restored: 1, skipped: 1, + }); + expect(qb.set).toHaveBeenCalledWith({ status: 'available' }); + }); + + it('rejects room-expense restore when any selected record is billed', async () => { + const qb = updateQb(); + const roomExpRepo = { + find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived' }]), + createQueryBuilder: jest.fn(() => qb), + }; + const billItemsRepo = { count: jest.fn().mockResolvedValue(1) }; + const service = new ExpensesService( + roomExpRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + { getRepository: jest.fn(() => billItemsRepo) } as never, + ); + await expect(service.batchRestoreRoomExpenses([1])).rejects.toBeInstanceOf(BadRequestException); + expect(roomExpRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('restores unbilled room expenses and reports active rows as skipped', async () => { + const qb = updateQb(); + const roomExpRepo = { + find: jest.fn().mockResolvedValue([ + { id: 1, status: 'archived' }, + { id: 2, status: 'active' }, + ]), + createQueryBuilder: jest.fn(() => qb), + }; + const service = new ExpensesService( + roomExpRepo as never, {} as never, {} as never, {} as never, {} as never, + { getRepository: jest.fn(() => ({ count: jest.fn().mockResolvedValue(0) })) } as never, + ); + await expect(service.batchRestoreRoomExpenses([1, 2])).resolves.toMatchObject({ restored: 1, skipped: 1 }); + expect(qb.set).toHaveBeenCalledWith({ status: 'active' }); + }); + + it('skips an active billed room expense without blocking an archived unbilled expense', async () => { + const qb = updateQb(); + const count = jest.fn().mockResolvedValue(0); + const roomExpRepo = { + find: jest.fn().mockResolvedValue([ + { id: 1, status: 'archived' }, + { id: 2, status: 'active' }, + ]), + createQueryBuilder: jest.fn(() => qb), + }; + const service = new ExpensesService( + roomExpRepo as never, {} as never, {} as never, {} as never, {} as never, + { getRepository: jest.fn(() => ({ count })) } as never, + ); + await expect(service.batchRestoreRoomExpenses([1, 2])).resolves.toMatchObject({ + restored: 1, + skipped: 1, + }); + expect(count).toHaveBeenCalledWith({ where: { roomExpenseId: expect.anything() } }); + expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [1] }); + }); + + it('rejects personal-expense restore when any selected record has a bill id', async () => { + const personalExpRepo = { + find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived', billId: 9 }]), + createQueryBuilder: jest.fn(), + }; + const service = new ExpensesService( + {} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never, + ); + await expect(service.batchRestorePersonalExpenses([1])).rejects.toBeInstanceOf(BadRequestException); + expect(personalExpRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('restores unbilled personal expenses and skips already active records', async () => { + const qb = updateQb(); + const personalExpRepo = { + find: jest.fn().mockResolvedValue([ + { id: 1, status: 'archived', billId: null }, + { id: 2, status: 'active', billId: null }, + ]), + createQueryBuilder: jest.fn(() => qb), + }; + const service = new ExpensesService( + {} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never, + ); + await expect(service.batchRestorePersonalExpenses([1, 1, 2])).resolves.toMatchObject({ + restored: 1, + skipped: 1, + }); + expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [1] }); + }); + + it('skips an active billed personal expense without blocking an archived unbilled expense', async () => { + const qb = updateQb(); + const personalExpRepo = { + find: jest.fn().mockResolvedValue([ + { id: 1, status: 'archived', billId: null }, + { id: 2, status: 'active', billId: 9 }, + ]), + createQueryBuilder: jest.fn(() => qb), + }; + const service = new ExpensesService( + {} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never, + ); + await expect(service.batchRestorePersonalExpenses([1, 2])).resolves.toMatchObject({ + restored: 1, + skipped: 1, + }); + expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [1] }); + }); + + it('uses archived status when querying expense archive views', async () => { + const roomQb = listQb(); + const personalRepo = { find: jest.fn().mockResolvedValue([]) }; + const service = new ExpensesService( + { createQueryBuilder: jest.fn(() => roomQb) } as never, + personalRepo as never, + {} as never, {} as never, {} as never, {} as never, + ); + await service.findRoomExpenses({ status: 'archived' }); + await service.findPersonalExpenses({ status: 'archived' }); + expect(roomQb.where).toHaveBeenCalledWith('e.status = :status', { status: 'archived' }); + expect(personalRepo.find).toHaveBeenCalledWith(expect.objectContaining({ where: { status: 'archived' } })); + }); + + it('rejects invalid expense query status values', async () => { + const service = new ExpensesService( + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + ); + await expect(service.findRoomExpenses({ status: 'deleted' as never })).rejects.toBeInstanceOf(BadRequestException); + await expect(service.findPersonalExpenses({ status: 'deleted' as never })).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects an archived occupancy without a checkout date before updating', async () => { + const repo = { + find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived', checkOutDate: null }]), + createQueryBuilder: jest.fn(), + }; + const service = new OccupanciesService( + repo as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + ); + await expect(service.batchRestore([1])).rejects.toBeInstanceOf(BadRequestException); + expect(repo.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('restores checked-out occupancies without changing room, bed, or locker state', async () => { + const qb = updateQb(); + const repo = { + find: jest.fn().mockResolvedValue([ + { id: 1, status: 'archived', checkOutDate: '2026-07-01' }, + { id: 2, status: 'active', checkOutDate: '2026-07-02' }, + ]), + createQueryBuilder: jest.fn(() => qb), + }; + const roomRepo = { update: jest.fn() }; + const bedRepo = { update: jest.fn() }; + const lockerRepo = { update: jest.fn() }; + const service = new OccupanciesService( + repo as never, roomRepo as never, {} as never, {} as never, bedRepo as never, + lockerRepo as never, {} as never, {} as never, + ); + await expect(service.batchRestore([1, 2])).resolves.toMatchObject({ restored: 1, skipped: 1 }); + expect(qb.set).toHaveBeenCalledWith({ status: 'active' }); + expect(roomRepo.update).not.toHaveBeenCalled(); + expect(bedRepo.update).not.toHaveBeenCalled(); + expect(lockerRepo.update).not.toHaveBeenCalled(); + }); + + it('uses archived status while preserving active=true as checkout filtering', async () => { + const qb = listQb(); + const service = new OccupanciesService( + { createQueryBuilder: jest.fn(() => qb) } as never, + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + ); + await service.findAll({ status: 'archived', active: true }); + expect(qb.where).toHaveBeenCalledWith('o.status = :status', { status: 'archived' }); + expect(qb.andWhere).toHaveBeenCalledWith('o.checkOutDate IS NULL'); + }); + + it('rejects invalid occupancy query status values', async () => { + const service = new OccupanciesService( + {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, + ); + await expect(service.findAll({ status: 'deleted' as never })).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/server/src/expenses/dto/expense.dto.ts b/apps/server/src/expenses/dto/expense.dto.ts index fc24711..f6aee27 100644 --- a/apps/server/src/expenses/dto/expense.dto.ts +++ b/apps/server/src/expenses/dto/expense.dto.ts @@ -71,6 +71,10 @@ export class QueryRoomExpenseDto { @IsOptional() @IsDateString() periodEnd?: string; + + @IsOptional() + @IsIn(['active', 'archived']) + status?: 'active' | 'archived'; } export class QueryPersonalExpenseDto { @@ -78,6 +82,10 @@ export class QueryPersonalExpenseDto { @Type(() => Number) @IsInt() studentId?: number; + + @IsOptional() + @IsIn(['active', 'archived']) + status?: 'active' | 'archived'; } export class BatchRoomExpenseItemDto { diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index ba18abc..9a2f7e4 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -13,6 +13,8 @@ import { UseInterceptors, UploadedFile, ParseIntPipe, + UsePipes, + ValidationPipe, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; @@ -31,6 +33,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import { BatchIdsDto } from '../common/batch-ids.dto'; import * as ExcelJS from 'exceljs'; /** 提取 ExcelJS 单元格的真实值,兼容公式、富文本、日期、超链接等情况 */ @@ -180,6 +183,24 @@ export class ExpensesController { return result; } + @Put('room/batch-restore') + @RequirePermission('expense:edit') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) + async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.batchRestoreRoomExpenses(dto.ids); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '费用管理', + action: '批量恢复宿舍费用', + detail: `IDs: ${dto.ids.join(',')}`, + ipAddress, + userAgent, + }); + return result; + } + @Put('room/:id') @RequirePermission('expense:edit') async updateRoomExpense( @@ -260,6 +281,24 @@ export class ExpensesController { return result; } + @Put('personal/batch-restore') + @RequirePermission('expense:edit') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) + async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.batchRestorePersonalExpenses(dto.ids); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '费用管理', + action: '批量恢复个人费用', + detail: `IDs: ${dto.ids.join(',')}`, + ipAddress, + userAgent, + }); + return result; + } + @Put('personal/:id') @RequirePermission('expense:edit') async updatePersonalExpense( diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index efa1e38..58bb4bf 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -73,11 +73,13 @@ export class ExpensesService { return this.roomExpRepo.save(entities); } - async findRoomExpenses(query?: { roomId?: number; periodStart?: string; periodEnd?: string }) { + async findRoomExpenses(query?: { roomId?: number; periodStart?: string; periodEnd?: string; status?: 'active' | 'archived' }) { + const status = query?.status ?? 'active'; + if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效'); const qb = this.roomExpRepo .createQueryBuilder('e') .leftJoinAndSelect('e.room', 'room') - .where('e.status = :status', { status: 'active' }) + .where('e.status = :status', { status }) .orderBy('e.createdAt', 'DESC'); if (query?.roomId) qb.andWhere('e.roomId = :roomId', { roomId: query.roomId }); if (query?.periodStart) qb.andWhere('e.periodStart >= :ps', { ps: query.periodStart }); @@ -111,6 +113,33 @@ export class ExpensesService { return { message: `已批量归档 ${result.affected || 0} 条`, archived: result.affected || 0 }; } + async batchRestoreRoomExpenses(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('费用记录 ID 无效'); + } + const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + const targetIds = existing.filter((expense) => expense.status === 'archived').map((expense) => expense.id); + const skipped = existing.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const billed = await this.dataSource + .getRepository('bill_items') + .count({ where: { roomExpenseId: In(targetIds) } }); + if (billed) throw new BadRequestException('选中记录包含已计入账单的宿舍费用'); + const result = await this.roomExpRepo + .createQueryBuilder() + .update() + .set({ status: 'active' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 条宿舍费用`, restored, skipped }; + } + async updateRoomExpense(id: number, dto: Partial) { const e = await this.roomExpRepo.findOne({ where: { id } }); const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } }); @@ -173,8 +202,10 @@ export class ExpensesService { return this.personalExpRepo.save(entity); } - async findPersonalExpenses(query?: { studentId?: number }) { - const where: Record = { status: 'active' }; + async findPersonalExpenses(query?: { studentId?: number; status?: 'active' | 'archived' }) { + const status = query?.status ?? 'active'; + if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效'); + const where: Record = { status }; if (query?.studentId) where.studentId = query.studentId; return this.personalExpRepo.find({ where, @@ -209,6 +240,34 @@ export class ExpensesService { return { message: `已批量归档 ${result.affected || 0} 条`, archived: result.affected || 0 }; } + async batchRestorePersonalExpenses(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('费用记录 ID 无效'); + } + const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } }); + if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); + const targets = existing.filter((expense) => expense.status === 'archived'); + if (targets.some((expense) => expense.billId)) { + throw new BadRequestException('选中记录包含已计入账单的个人费用'); + } + + const targetIds = targets.map((expense) => expense.id); + const skipped = existing.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const result = await this.personalExpRepo + .createQueryBuilder() + .update() + .set({ status: 'active' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 条个人费用`, restored, skipped }; + } + async updatePersonalExpense(id: number, dto: Partial) { const e = await this.personalExpRepo.findOne({ where: { id } }); if (!e) throw new NotFoundException('费用记录不存在'); diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index dc1e0af..5255169 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -13,6 +13,8 @@ import { UseInterceptors, UploadedFile, BadRequestException, + UsePipes, + ValidationPipe, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -27,6 +29,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import { BatchIdsDto } from '../common/batch-ids.dto'; import * as ExcelJS from 'exceljs'; import { createOccupancyImportTemplateWorkbook, @@ -49,14 +52,34 @@ export class OccupanciesController { @Query('roomId') roomId?: string, @Query('studentId') studentId?: string, @Query('active') active?: string, + @Query('status') status?: 'active' | 'archived', ) { return this.service.findAll({ roomId: roomId ? +roomId : undefined, studentId: studentId ? +studentId : undefined, active: active === 'true', + status, }); } + @Put('batch-restore') + @RequirePermission('occupancy:delete') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) + async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.batchRestore(dto.ids); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '入住管理', + action: '批量恢复入住记录', + detail: `IDs: ${dto.ids.join(',')}`, + ipAddress, + userAgent, + }); + return result; + } + @Post('batch-check-out') @RequirePermission('occupancy:checkout') async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: any) { diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 5ff0881..2dfdae1 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -46,14 +46,16 @@ export class OccupanciesService { return qb; } - async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) { + async findAll(query?: { roomId?: number; studentId?: number; active?: boolean; status?: 'active' | 'archived' }) { + const status = query?.status ?? 'active'; + if (status !== 'active' && status !== 'archived') throw new BadRequestException('入住记录状态无效'); const qb = this.repo .createQueryBuilder('o') .leftJoinAndSelect('o.student', 'student') .leftJoinAndSelect('o.room', 'room') .leftJoinAndSelect('o.bed', 'bed') .leftJoinAndSelect('o.locker', 'locker') - .where('o.status = :status', { status: 'active' }) + .where('o.status = :status', { status }) .orderBy('o.checkInDate', 'DESC'); if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId }); if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId }); @@ -348,6 +350,33 @@ export class OccupanciesService { return { message, archived, skipped: skipped.length }; } + async batchRestore(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('入住记录 ID 无效'); + } + const records = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在'); + if (records.some((record) => record.status === 'archived' && !record.checkOutDate)) { + throw new BadRequestException('选中记录包含未退宿的异常归档记录'); + } + + const targetIds = records.filter((record) => record.status === 'archived').map((record) => record.id); + const skipped = records.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'active' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 条入住记录`, restored, skipped }; + } + async batchCheckOut(dto: { ids: number[]; checkOutDate: string; diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index 1d10ea4..e075c20 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -12,6 +12,8 @@ import { Res, UseInterceptors, UploadedFile, + UsePipes, + ValidationPipe, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; @@ -25,6 +27,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import { BatchIdsDto } from '../common/batch-ids.dto'; import * as ExcelJS from 'exceljs'; @UseGuards(JwtAuthGuard) @@ -57,6 +60,24 @@ export class RoomsController { return this.service.getRoomVisual(asOf); } + @Put('batch-restore') + @RequirePermission('room:edit') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) + async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.batchRestore(dto.ids); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '宿舍', + action: '批量恢复宿舍', + detail: `IDs: ${dto.ids.join(',')}`, + ipAddress, + userAgent, + }); + return result; + } + @Put(':roomId/inspections/:date') @RequirePermission('room:inspect') async updateInspection( diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 1771ab3..913390b 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -306,6 +306,30 @@ export class RoomsService { return { message: '已恢复' }; } + async batchRestore(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的宿舍'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('宿舍 ID 无效'); + } + const rooms = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (rooms.length !== uniqueIds.length) throw new NotFoundException('部分宿舍不存在'); + + const targetIds = rooms.filter((room) => room.status === 'archived').map((room) => room.id); + const skipped = rooms.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'available' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 间宿舍`, restored, skipped }; + } + async getRoomVisual(asOf?: string) { // asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。 const isHistorical = !!asOf; diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 96cdd61..28dc968 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -14,6 +14,8 @@ import { UploadedFile, Inject, ParseIntPipe, + UsePipes, + ValidationPipe, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -35,6 +37,7 @@ import { parseStudentImportWorkbook, STUDENT_EXPORT_COLUMNS, } from './student-import'; +import { BatchIdsDto } from '../common/batch-ids.dto'; interface AuthenticatedRequest { user: AuthenticatedUser; @@ -196,6 +199,24 @@ export class StudentsController { return result; } + @Put('batch-restore') + @RequirePermission('student:edit') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) + async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.batchRestore(dto.ids); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生管理', + action: '批量恢复学生', + detail: `IDs: ${dto.ids.join(',')}`, + ipAddress, + userAgent, + }); + return result; + } + @Put(':id') @RequirePermission('student:edit') async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) { diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 30c1ed5..cc82c0e 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -219,6 +219,30 @@ export class StudentsService { return { message: '已恢复' }; } + async batchRestore(ids: number[]) { + const uniqueIds = [...new Set(ids || [])]; + if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的学生'); + if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new BadRequestException('学生 ID 无效'); + } + const students = await this.repo.find({ where: { id: In(uniqueIds) } }); + if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在'); + + const targetIds = students.filter((student) => student.status === 'archived').map((student) => student.id); + const skipped = students.length - targetIds.length; + let restored = 0; + if (targetIds.length > 0) { + const result = await this.repo + .createQueryBuilder() + .update() + .set({ status: 'active' }) + .where('id IN (:...ids)', { ids: targetIds }) + .execute(); + restored = result.affected || 0; + } + return { message: `已批量恢复 ${restored} 名学生`, restored, skipped }; + } + async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { const data = this.normalizeImportData(importData); let imported = 0;