为批量归档补充批量恢复、支持考试批量归档与恢复 #57
@@ -1,11 +1,12 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Card, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Space, Switch, Tag } from 'antd';
|
||||
import { Button, Card, Checkbox, Col, Empty, Form, Input, Popconfirm, Progress, Row, Select, Space, Switch, Tag } from 'antd';
|
||||
import { CalendarOutlined, InboxOutlined, PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import ExamFormModal from './ExamFormModal';
|
||||
import { selectAllExamIds, toggleExamSelection } from './selection';
|
||||
import type { ClassOption, ExamFormValues, ExamItem } from './types';
|
||||
import { EXAM_TYPE_OPTIONS } from './types';
|
||||
import './style.css';
|
||||
@@ -16,12 +17,14 @@ const ExamsPage: React.FC = () => {
|
||||
const [data, setData] = useState<ExamItem[]>([]);
|
||||
const [classes, setClasses] = useState<ClassOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [examType, setExamType] = useState<string>();
|
||||
const [classId, setClassId] = useState<number>();
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [selectedExamIds, setSelectedExamIds] = useState<number[]>([]);
|
||||
|
||||
const loadClasses = useCallback(async () => {
|
||||
const result = await api.get<ClassOption[]>('/classes');
|
||||
@@ -29,6 +32,7 @@ const ExamsPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const loadExams = useCallback(async () => {
|
||||
setSelectedExamIds([]);
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -92,6 +96,46 @@ const ExamsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const allCurrentSelected = data.length > 0 && selectedExamIds.length === data.length;
|
||||
const partiallySelected = selectedExamIds.length > 0 && !allCurrentSelected;
|
||||
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
setSelectedExamIds(selectAllExamIds(data.map((exam) => exam.id), checked));
|
||||
};
|
||||
|
||||
const changeArchiveView = (checked: boolean) => {
|
||||
setSelectedExamIds([]);
|
||||
setShowArchived(checked);
|
||||
};
|
||||
|
||||
const batchChangeArchiveStatus = async (archive: boolean) => {
|
||||
if (selectedExamIds.length === 0 || batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
if (archive) {
|
||||
const result = await api.put<{ archived: number; skipped: number }>('/exams/batch-archive', {
|
||||
ids: selectedExamIds,
|
||||
});
|
||||
message.success(
|
||||
`已归档 ${result.archived} 场考试${result.skipped ? `,跳过 ${result.skipped} 场` : ''}`,
|
||||
);
|
||||
} else {
|
||||
const result = await api.put<{ restored: number; skipped: number }>('/exams/batch-restore', {
|
||||
ids: selectedExamIds,
|
||||
});
|
||||
message.success(
|
||||
`已恢复 ${result.restored} 场考试${result.skipped ? `,跳过 ${result.skipped} 场` : ''}`,
|
||||
);
|
||||
}
|
||||
setSelectedExamIds([]);
|
||||
await loadExams();
|
||||
} catch (error) {
|
||||
message.error((error as { message?: string })?.message || '批量操作失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="exam-page">
|
||||
<div className="exam-toolbar">
|
||||
@@ -101,12 +145,40 @@ const ExamsPage: React.FC = () => {
|
||||
<Select value={classId} onChange={setClassId} options={classOptions} placeholder="考试班级" allowClear showSearch optionFilterProp="label" style={{ width: 180 }} />
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Checkbox
|
||||
checked={allCurrentSelected}
|
||||
indeterminate={partiallySelected}
|
||||
disabled={data.length === 0 || loading || batchLoading}
|
||||
onChange={(event) => toggleSelectAll(event.target.checked)}
|
||||
>
|
||||
全选当前结果
|
||||
</Checkbox>
|
||||
<Popconfirm
|
||||
title={showArchived ? '确认恢复选中的考试?' : '确认归档选中的考试?'}
|
||||
description={
|
||||
showArchived
|
||||
? `将恢复选中的 ${selectedExamIds.length} 场考试。`
|
||||
: `将归档选中的 ${selectedExamIds.length} 场考试,归档后成绩将变为只读。`
|
||||
}
|
||||
disabled={selectedExamIds.length === 0 || batchLoading}
|
||||
onConfirm={() => void batchChangeArchiveStatus(!showArchived)}
|
||||
>
|
||||
<Button
|
||||
danger={!showArchived}
|
||||
loading={batchLoading}
|
||||
disabled={selectedExamIds.length === 0}
|
||||
>
|
||||
{showArchived ? '批量恢复' : '批量归档'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<span className="exam-archive-toggle">
|
||||
<InboxOutlined />
|
||||
归档
|
||||
<Switch size="small" checked={showArchived} onChange={setShowArchived} />
|
||||
<Switch size="small" checked={showArchived} onChange={changeArchiveView} />
|
||||
</span>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>创建考试</Button>
|
||||
{!showArchived ? (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>创建考试</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -119,9 +191,24 @@ const ExamsPage: React.FC = () => {
|
||||
return (
|
||||
<Col key={exam.id} xs={24} sm={12} xl={8} xxl={6}>
|
||||
<Card
|
||||
className="exam-card"
|
||||
className={`exam-card${selectedExamIds.includes(exam.id) ? ' exam-card-selected' : ''}`}
|
||||
loading={loading}
|
||||
title={<Space><Tag color="blue">{exam.examType}</Tag><span>{exam.examName}</span></Space>}
|
||||
title={(
|
||||
<Space>
|
||||
<Checkbox
|
||||
aria-label={`选择考试 ${exam.examName}`}
|
||||
checked={selectedExamIds.includes(exam.id)}
|
||||
disabled={batchLoading}
|
||||
onChange={(event) => {
|
||||
setSelectedExamIds((current) =>
|
||||
toggleExamSelection(current, exam.id, event.target.checked),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Tag color="blue">{exam.examType}</Tag>
|
||||
<span>{exam.examName}</span>
|
||||
</Space>
|
||||
)}
|
||||
extra={<Tag color={exam.status === 'archived' ? 'default' : 'green'}>{exam.status === 'archived' ? '已归档' : '成绩录入'}</Tag>}
|
||||
actions={[
|
||||
<span key="detail" onClick={() => navigate(`/exams/${exam.id}`)}>查看成绩</span>,
|
||||
|
||||
15
apps/admin/src/pages/Exams/selection.integration.test.ts
Normal file
15
apps/admin/src/pages/Exams/selection.integration.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { selectAllExamIds, toggleExamSelection } from './selection';
|
||||
|
||||
describe('考试批量选择', () => {
|
||||
it('可以选择和取消单场考试且不会重复选择', () => {
|
||||
expect(toggleExamSelection([1], 2, true)).toEqual([1, 2]);
|
||||
expect(toggleExamSelection([1, 2], 2, true)).toEqual([1, 2]);
|
||||
expect(toggleExamSelection([1, 2], 1, false)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('全选只包含当前结果并去重,取消全选后清空', () => {
|
||||
expect(selectAllExamIds([1, 2, 2, 3], true)).toEqual([1, 2, 3]);
|
||||
expect(selectAllExamIds([1, 2, 3], false)).toEqual([]);
|
||||
});
|
||||
});
|
||||
13
apps/admin/src/pages/Exams/selection.ts
Normal file
13
apps/admin/src/pages/Exams/selection.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const toggleExamSelection = (
|
||||
selectedIds: number[],
|
||||
examId: number,
|
||||
checked: boolean,
|
||||
): number[] => {
|
||||
if (checked) {
|
||||
return selectedIds.includes(examId) ? selectedIds : [...selectedIds, examId];
|
||||
}
|
||||
return selectedIds.filter((id) => id !== examId);
|
||||
};
|
||||
|
||||
export const selectAllExamIds = (examIds: number[], checked: boolean): number[] =>
|
||||
checked ? [...new Set(examIds)] : [];
|
||||
@@ -25,6 +25,11 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.exam-card-selected {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 1px #1677ff;
|
||||
}
|
||||
|
||||
.exam-card .ant-card-head-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<string, { text: string; color: string }> = {
|
||||
available: { text: '可入住', color: 'green' },
|
||||
@@ -95,7 +96,6 @@ const RoomsPage: React.FC = () => {
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const canSaveRoom = editing ? canEditRooms : canCreateRooms;
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archivedCount, setArchivedCount] = useState(0);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined);
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(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 ? (
|
||||
<Popconfirm title="确定恢复此宿舍?" onConfirm={() => handleRestore(r.id)}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
type="link"
|
||||
>
|
||||
<Button size="small" icon={<UndoOutlined />} type="link">
|
||||
恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -568,10 +589,7 @@ const RoomsPage: React.FC = () => {
|
||||
</PermissionButton>
|
||||
{canDeleteRooms ? (
|
||||
<Popconfirm title="确定归档?" onConfirm={() => handleArchive(r.id)}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
<Button size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
@@ -638,15 +656,34 @@ const RoomsPage: React.FC = () => {
|
||||
/>
|
||||
<Button
|
||||
type={showArchived ? 'primary' : 'default'}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
onClick={() => {
|
||||
setShowArchived(!showArchived);
|
||||
setFilterStatus(undefined);
|
||||
setSelectedRowKeys([]);
|
||||
}}
|
||||
>
|
||||
{showArchived
|
||||
? '隐藏已归档'
|
||||
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
|
||||
{showArchived ? '返回正常数据' : '查看已归档'}
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
{canDeleteRooms ? (
|
||||
{showArchived && canEditRooms ? (
|
||||
<Popconfirm
|
||||
title={`确定批量恢复选中的 ${selectedRowKeys.length} 间宿舍?`}
|
||||
onConfirm={handleBatchRestore}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : !showArchived && canDeleteRooms ? (
|
||||
<Popconfirm
|
||||
title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`}
|
||||
onConfirm={handleBatchDelete}
|
||||
@@ -664,19 +701,21 @@ const RoomsPage: React.FC = () => {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<PermissionButton
|
||||
permission="room:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加宿舍
|
||||
</PermissionButton>
|
||||
{hasPermission('room:create') ? (
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="room:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加宿舍
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{!showArchived && hasPermission('room:create') ? (
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
@@ -734,7 +773,6 @@ const RoomsPage: React.FC = () => {
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||
getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }),
|
||||
}}
|
||||
/>
|
||||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||||
|
||||
@@ -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<string, { text: string; color: string }> = {
|
||||
active: { text: '在读', color: 'green' },
|
||||
@@ -111,7 +112,6 @@ const StudentsPage: React.FC = () => {
|
||||
const [classOptions, setClassOptions] = useState<StudentFilterLookups['classes']>([]);
|
||||
const [teacherOptions, setTeacherOptions] = useState<StudentFilterLookups['teachers']>([]);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archivedCount, setArchivedCount] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
|
||||
@@ -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<string, unknown> = {
|
||||
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<Record<string, unknown>>;
|
||||
const list = res as Array<Record<string, unknown>>;
|
||||
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="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<Button size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
@@ -803,15 +820,34 @@ const StudentsPage: React.FC = () => {
|
||||
/>
|
||||
<Button
|
||||
type={showArchived ? 'primary' : 'default'}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
onClick={() => {
|
||||
setShowArchived(!showArchived);
|
||||
setFilterStatus(undefined);
|
||||
setSelectedRowKeys([]);
|
||||
}}
|
||||
>
|
||||
{showArchived
|
||||
? '隐藏已归档'
|
||||
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
|
||||
{showArchived ? '返回正常数据' : '查看已归档'}
|
||||
</Button>
|
||||
</Space>
|
||||
<Space wrap className="responsive-toolbar__group">
|
||||
{canDeleteStudent ? (
|
||||
{showArchived && canEditStudent ? (
|
||||
<Popconfirm
|
||||
title={`确定批量恢复选中的 ${selectedRowKeys.length} 名学生?`}
|
||||
onConfirm={handleBatchRestore}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : !showArchived && canDeleteStudent ? (
|
||||
<Popconfirm
|
||||
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
|
||||
onConfirm={handleBatchDelete}
|
||||
@@ -829,21 +865,23 @@ const StudentsPage: React.FC = () => {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<PermissionButton
|
||||
permission="student:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
const host = organizations.find((organization) => organization.isHost);
|
||||
if (host) form.setFieldValue('organizationId', host.id);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加学生
|
||||
</PermissionButton>
|
||||
{hasPermission('student:import') ? (
|
||||
{!showArchived ? (
|
||||
<PermissionButton
|
||||
permission="student:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
const host = organizations.find((organization) => organization.isHost);
|
||||
if (host) form.setFieldValue('organizationId', host.id);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加学生
|
||||
</PermissionButton>
|
||||
) : null}
|
||||
{!showArchived && hasPermission('student:import') ? (
|
||||
<>
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
@@ -861,7 +899,7 @@ const StudentsPage: React.FC = () => {
|
||||
</Upload>
|
||||
</>
|
||||
) : null}
|
||||
{canSyncJinshuju ? (
|
||||
{!showArchived && canSyncJinshuju ? (
|
||||
<Button icon={<CloudUploadOutlined />} onClick={() => setJinshujuOpen(true)}>
|
||||
同步金数据
|
||||
</Button>
|
||||
@@ -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,
|
||||
|
||||
58
apps/admin/src/pages/archive-view.integration.test.ts
Normal file
58
apps/admin/src/pages/archive-view.integration.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
36
apps/admin/src/pages/archive-view.ts
Normal file
36
apps/admin/src/pages/archive-view.ts
Normal file
@@ -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 = <T extends { status?: string }>(
|
||||
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 = <T extends string>(current: T, next: T) =>
|
||||
current !== next;
|
||||
|
||||
export const occupancyParamsForView = (view: OccupancyView) => ({
|
||||
active: view === 'active' ? 'true' : undefined,
|
||||
status: view === 'archived' ? 'archived' : 'active',
|
||||
});
|
||||
20
apps/server/src/common/batch-ids.dto.spec.ts
Normal file
20
apps/server/src/common/batch-ids.dto.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
9
apps/server/src/common/batch-ids.dto.ts
Normal file
9
apps/server/src/common/batch-ids.dto.ts
Normal file
@@ -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[];
|
||||
}
|
||||
65
apps/server/src/common/batch-restore.controllers.spec.ts
Normal file
65
apps/server/src/common/batch-restore.controllers.spec.ts
Normal file
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
307
apps/server/src/common/batch-restore.services.spec.ts
Normal file
307
apps/server/src/common/batch-restore.services.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
63
apps/server/src/exams/exams.controller.spec.ts
Normal file
63
apps/server/src/exams/exams.controller.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'reflect-metadata';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { PIPES_METADATA } from '@nestjs/common/constants';
|
||||
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
|
||||
import { BatchIdsDto } from '../common/batch-ids.dto';
|
||||
import { ExamsController } from './exams.controller';
|
||||
|
||||
describe('ExamsController batch archive and restore', () => {
|
||||
const req = {
|
||||
user: {
|
||||
id: 7,
|
||||
username: 'admin',
|
||||
isSuperAdmin: true,
|
||||
permissions: ['exam:view'],
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
headers: {},
|
||||
};
|
||||
|
||||
it.each(['batchArchive', 'batchRestore'] as const)(
|
||||
'%s uses the existing exam permission',
|
||||
(method) => {
|
||||
const handler = ExamsController.prototype[method] as (...args: never[]) => unknown;
|
||||
expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual(['exam:view']);
|
||||
},
|
||||
);
|
||||
|
||||
it('class-level validation rejects invalid and non-whitelisted batch bodies', async () => {
|
||||
const pipes = Reflect.getMetadata(PIPES_METADATA, ExamsController) as ValidationPipe[];
|
||||
expect(pipes).toHaveLength(1);
|
||||
const metadata = { type: 'body' as const, metatype: BatchIdsDto, data: undefined };
|
||||
await expect(pipes[0].transform({ ids: [] }, metadata)).rejects.toBeDefined();
|
||||
await expect(pipes[0].transform({ ids: [0] }, metadata)).rejects.toBeDefined();
|
||||
await expect(
|
||||
pipes[0].transform({ ids: [1], unexpected: true }, metadata),
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('passes ids and access context to services and writes batch audit logs', async () => {
|
||||
const service = {
|
||||
batchArchive: jest.fn().mockResolvedValue({ archived: 1, skipped: 1 }),
|
||||
batchRestore: jest.fn().mockResolvedValue({ restored: 1, skipped: 1 }),
|
||||
};
|
||||
const log = jest.fn().mockResolvedValue(undefined);
|
||||
const controller = new ExamsController(service as never, { log } as never);
|
||||
|
||||
await expect(controller.batchArchive({ ids: [1, 2] }, req)).resolves.toEqual({
|
||||
archived: 1,
|
||||
skipped: 1,
|
||||
});
|
||||
await expect(controller.batchRestore({ ids: [3, 4] }, req)).resolves.toEqual({
|
||||
restored: 1,
|
||||
skipped: 1,
|
||||
});
|
||||
|
||||
expect(service.batchArchive).toHaveBeenCalledWith([1, 2], 7, true);
|
||||
expect(service.batchRestore).toHaveBeenCalledWith([3, 4], 7, true);
|
||||
expect(log.mock.calls.map(([entry]) => [entry.action, entry.detail])).toEqual([
|
||||
['批量归档考试', 'IDs: 1,2'],
|
||||
['批量恢复考试', 'IDs: 3,4'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { BatchIdsDto } from '../common/batch-ids.dto';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
import { CreateExamDto, QueryExamDto, UpdateExamScoreValueDto } from './dto/exam.dto';
|
||||
@@ -46,6 +47,48 @@ export class ExamsController {
|
||||
return this.service.findAll(query, classIds);
|
||||
}
|
||||
|
||||
@Put('batch-archive')
|
||||
@RequirePermission('exam:view')
|
||||
async batchArchive(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.batchArchive(
|
||||
dto.ids,
|
||||
req.user.id,
|
||||
this.canManageAll(req),
|
||||
);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考试管理',
|
||||
action: '批量归档考试',
|
||||
detail: `IDs: ${dto.ids.join(',')}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put('batch-restore')
|
||||
@RequirePermission('exam:view')
|
||||
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.batchRestore(
|
||||
dto.ids,
|
||||
req.user.id,
|
||||
this.canManageAll(req),
|
||||
);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考试管理',
|
||||
action: '批量恢复考试',
|
||||
detail: `IDs: ${dto.ids.join(',')}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('exam:view')
|
||||
findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException, ValidationPipe } from '@nestjs/common';
|
||||
import { ExamScore } from '../entities';
|
||||
import { QueryExamDto } from './dto/exam.dto';
|
||||
import { ExamsService } from './exams.service';
|
||||
@@ -23,6 +23,19 @@ function createService(
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
describe('ExamsService', () => {
|
||||
it('creates score rows from the active class roster snapshot', async () => {
|
||||
const members = [
|
||||
@@ -224,6 +237,102 @@ describe('ExamsService', () => {
|
||||
expect(examRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects empty and invalid ids for batch archive and restore', async () => {
|
||||
const service = createService(async () => undefined, { examRepo: { find: jest.fn() } });
|
||||
|
||||
for (const call of [
|
||||
(ids: number[]) => service.batchArchive(ids, 1, true),
|
||||
(ids: number[]) => service.batchRestore(ids, 1, true),
|
||||
]) {
|
||||
await expect(call([])).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(call([0])).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(call([1.5])).rejects.toBeInstanceOf(BadRequestException);
|
||||
}
|
||||
});
|
||||
|
||||
it('deduplicates ids, archives active exams, skips archived exams, and leaves scores unchanged', async () => {
|
||||
const qb = updateQb();
|
||||
const examRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ id: 8, classId: 3, status: 'active' },
|
||||
{ id: 9, classId: 4, status: 'archived' },
|
||||
]),
|
||||
createQueryBuilder: jest.fn(() => qb),
|
||||
};
|
||||
const scoreRepo = { update: jest.fn(), save: jest.fn() };
|
||||
const service = createService(async () => undefined, { examRepo, scoreRepo });
|
||||
|
||||
await expect(service.batchArchive([8, 8, 9], 1, true)).resolves.toEqual({
|
||||
message: '已批量归档 1 场考试',
|
||||
archived: 1,
|
||||
skipped: 1,
|
||||
});
|
||||
expect(examRepo.find).toHaveBeenCalledWith({ where: { id: expect.anything() } });
|
||||
expect(qb.set).toHaveBeenCalledWith({ status: 'archived' });
|
||||
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] });
|
||||
expect(scoreRepo.update).not.toHaveBeenCalled();
|
||||
expect(scoreRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores archived exams and skips active exams', async () => {
|
||||
const qb = updateQb();
|
||||
const examRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ id: 8, classId: 3, status: 'archived' },
|
||||
{ id: 9, classId: 4, status: 'active' },
|
||||
]),
|
||||
createQueryBuilder: jest.fn(() => qb),
|
||||
};
|
||||
const service = createService(async () => undefined, { examRepo });
|
||||
|
||||
await expect(service.batchRestore([8, 9], 1, true)).resolves.toEqual({
|
||||
message: '已批量恢复 1 场考试',
|
||||
restored: 1,
|
||||
skipped: 1,
|
||||
});
|
||||
expect(qb.set).toHaveBeenCalledWith({ status: 'active' });
|
||||
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] });
|
||||
});
|
||||
|
||||
it('rejects a batch when any exam is missing before updating', async () => {
|
||||
const examRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 8, classId: 3, status: 'active' }]),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
const service = createService(async () => undefined, { examRepo });
|
||||
|
||||
await expect(service.batchArchive([8, 9], 1, true)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(examRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks access for every selected exam before a batch update', async () => {
|
||||
const examRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ id: 8, classId: 3, status: 'active' },
|
||||
{ id: 9, classId: 4, status: 'active' },
|
||||
]),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
const classTeacherRepo = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 1 })
|
||||
.mockResolvedValueOnce(null),
|
||||
};
|
||||
const service = createService(async () => undefined, { examRepo, classTeacherRepo });
|
||||
|
||||
await expect(service.batchArchive([8, 9], 21, false)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(1, {
|
||||
where: { userId: 21, classId: 3 },
|
||||
});
|
||||
expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(2, {
|
||||
where: { userId: 21, classId: 4 },
|
||||
});
|
||||
expect(examRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects score updates for archived exams', async () => {
|
||||
const manager = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }),
|
||||
|
||||
@@ -149,6 +149,58 @@ export class ExamsService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async batchArchive(ids: number[], userId: number, canManageAll: boolean) {
|
||||
const exams = await this.findBatchExams(ids, userId, canManageAll, '归档');
|
||||
const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id);
|
||||
const archived = await this.updateBatchStatus(targetIds, 'archived');
|
||||
return {
|
||||
message: `已批量归档 ${archived} 场考试`,
|
||||
archived,
|
||||
skipped: exams.length - targetIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
async batchRestore(ids: number[], userId: number, canManageAll: boolean) {
|
||||
const exams = await this.findBatchExams(ids, userId, canManageAll, '恢复');
|
||||
const targetIds = exams.filter((exam) => exam.status === 'archived').map((exam) => exam.id);
|
||||
const restored = await this.updateBatchStatus(targetIds, 'active');
|
||||
return {
|
||||
message: `已批量恢复 ${restored} 场考试`,
|
||||
restored,
|
||||
skipped: exams.length - targetIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
private async findBatchExams(
|
||||
ids: number[],
|
||||
userId: number,
|
||||
canManageAll: boolean,
|
||||
action: '归档' | '恢复',
|
||||
) {
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException(`请选择要${action}的考试`);
|
||||
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
|
||||
throw new BadRequestException('考试 ID 无效');
|
||||
}
|
||||
const exams = await this.examRepo.find({ where: { id: In(uniqueIds) } });
|
||||
if (exams.length !== uniqueIds.length) throw new NotFoundException('部分考试不存在');
|
||||
for (const exam of exams) {
|
||||
await this.assertClassAccess(userId, exam.classId, canManageAll);
|
||||
}
|
||||
return exams;
|
||||
}
|
||||
|
||||
private async updateBatchStatus(ids: number[], status: 'active' | 'archived') {
|
||||
if (ids.length === 0) return 0;
|
||||
const result = await this.examRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ status })
|
||||
.where('id IN (:...ids)', { ids })
|
||||
.execute();
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
private async createScoreRows(manager: EntityManager, exam: Exam, members: ClassStudent[]) {
|
||||
const rows = members.map((member) =>
|
||||
manager.create(ExamScore, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<CreateRoomExpenseDto>) {
|
||||
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<string, unknown> = { 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<string, unknown> = { 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<CreatePersonalExpenseDto>) {
|
||||
const e = await this.personalExpRepo.findOne({ where: { id } });
|
||||
if (!e) throw new NotFoundException('费用记录不存在');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user