diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index ac91a6c..2fc1780 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -321,7 +321,11 @@ const EnrollmentsTab: React.FC = ({ columns={columns} dataSource={data} rowKey="id" - pagination={{ pageSize: 15 }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50], + }} /> = ({ data, st columns={columns} dataSource={data} rowKey="id" - pagination={{ pageSize: 15 }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50], + }} /> = ({ dat columns={columns} dataSource={data} rowKey="id" - pagination={{ pageSize: 15 }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50], + }} style={{ marginTop: 16 }} /> diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index a1828ee..c4e1030 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -399,7 +399,12 @@ const BillsPage: React.FC = () => { dataSource={filteredBills} rowKey="id" loading={loading} - pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} locale={{ emptyText: }} rowSelection={{ selectedRowKeys: selectedRows, diff --git a/apps/admin/src/pages/Classes/detail.tsx b/apps/admin/src/pages/Classes/detail.tsx index d2fbaa7..f5a89b0 100644 --- a/apps/admin/src/pages/Classes/detail.tsx +++ b/apps/admin/src/pages/Classes/detail.tsx @@ -530,7 +530,11 @@ const ClassDetailPage: React.FC = () => { columns={studentColumns} dataSource={students} rowKey="id" - pagination={{ pageSize: 20 }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + }} /> { columns={teacherColumns} dataSource={teachers} rowKey="id" - pagination={{ pageSize: 20 }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + }} /> { columns={scheduleColumns} dataSource={schedules} rowKey="id" - pagination={{ pageSize: 20 }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + }} /> ), diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index c1f7d6a..d574658 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -264,7 +264,11 @@ const ClassesPage: React.FC = () => { rowKey="id" loading={loading} locale={{ emptyText: }} - pagination={{ pageSize: 20 }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + }} scroll={{ x: 1100 }} /> diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index 5892fad..a40185a 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -483,7 +483,12 @@ const ClassroomRentalsPage: React.FC = () => { rowKey="id" loading={loading} locale={{ emptyText: }} - pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} scroll={{ x: 1200 }} /> { rowKey="id" loading={loading} locale={{ emptyText: }} - pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} /> { it('uses the student number as the non-sensitive identifier', () => { @@ -17,4 +17,13 @@ describe('deposit student option', () => { label: '张三 (#23)', }); }); + + it('uses lookup rows without requiring a status field', () => { + expect(buildDepositStudentOptions([{ id: 23, name: '张三', studentNo: 'S2026001' }])).toEqual([ + { + value: 23, + label: '张三 (S2026001)', + }, + ]); + }); }); diff --git a/apps/admin/src/pages/Deposits/deposit-student-option.ts b/apps/admin/src/pages/Deposits/deposit-student-option.ts index 732d6b2..3346453 100644 --- a/apps/admin/src/pages/Deposits/deposit-student-option.ts +++ b/apps/admin/src/pages/Deposits/deposit-student-option.ts @@ -8,3 +8,6 @@ export const buildDepositStudentOption = (student: DepositStudentLookup) => ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})`, }); + +export const buildDepositStudentOptions = (students: DepositStudentLookup[]) => + students.map(buildDepositStudentOption); diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 1d50ca3..b1911e7 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -19,7 +19,7 @@ import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; -import { buildDepositStudentOption } from './deposit-student-option'; +import { buildDepositStudentOptions } from './deposit-student-option'; const statusMap: Record = { paid: { text: '已缴', color: 'green' }, @@ -33,6 +33,10 @@ const installmentStatusMap: Record = { paid: { text: '已缴', color: 'green' }, }; +const isFormValidationError = (error: unknown) => + typeof error === 'object' + && error !== null + && Array.isArray((error as { errorFields?: unknown }).errorFields); const DepositsPage: React.FC = () => { const [data, setData] = useState([]); @@ -82,17 +86,14 @@ const DepositsPage: React.FC = () => { }, [data, searchText, filterStatus]); const studentOptions = useMemo( - () => - students - .filter((s: any) => s.status === 'active') - .map(buildDepositStudentOption), + () => buildDepositStudentOptions(students), [students], ); const handleCreate = async () => { setSaving(true); - const values = await createForm.validateFields(); try { + const values = await createForm.validateFields(); await api.post('/deposits', { studentId: values.studentId, amount: values.amount, @@ -104,7 +105,9 @@ const DepositsPage: React.FC = () => { createForm.resetFields(); fetchData(); } catch (e: any) { - message.error(e?.message || '操作失败'); + if (!isFormValidationError(e)) { + message.error(e?.message || '操作失败'); + } } finally { setSaving(false); } @@ -112,8 +115,8 @@ const DepositsPage: React.FC = () => { const handleRefund = async () => { setSaving(true); - const values = await refundForm.validateFields(); try { + const values = await refundForm.validateFields(); await api.put(`/deposits/${refundModal.id}/refund`, { refundDate: values.refundDate.format('YYYY-MM-DD'), deductionAmount: values.deductionAmount || 0, @@ -125,7 +128,9 @@ const DepositsPage: React.FC = () => { refundForm.resetFields(); fetchData(); } catch (e: any) { - message.error(e?.message || '操作失败'); + if (!isFormValidationError(e)) { + message.error(e?.message || '操作失败'); + } } finally { setSaving(false); } @@ -133,8 +138,8 @@ const DepositsPage: React.FC = () => { const handleAddInstallment = async () => { if (installmentModal == null) return; - const values = await installmentForm.validateFields(); try { + const values = await installmentForm.validateFields(); await api.post(`/deposits/${installmentModal}/installments`, { amount: values.amount, dueDate: values.dueDate.format('YYYY-MM-DD'), @@ -144,7 +149,9 @@ const DepositsPage: React.FC = () => { installmentForm.resetFields(); fetchData(); } catch (e: any) { - message.error(e?.message || '操作失败'); + if (!isFormValidationError(e)) { + message.error(e?.message || '操作失败'); + } } }; @@ -304,7 +311,12 @@ const DepositsPage: React.FC = () => { rowKey="id" loading={loading} scroll={{ x: 1200 }} - pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} locale={{ emptyText: }} /> diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 30f241c..af903c1 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -452,7 +452,12 @@ const ExpensesPage: React.FC = () => { rowKey="id" loading={loading} scroll={{ x: 1200 }} - pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} locale={{ emptyText: }} rowSelection={{ selectedRowKeys: selectedRoomKeys, @@ -575,7 +580,12 @@ const ExpensesPage: React.FC = () => { rowKey="id" loading={loading} scroll={{ x: 1200 }} - pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} locale={{ emptyText: }} rowSelection={{ selectedRowKeys: selectedPersonalKeys, diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 6ea930e..b59066a 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -522,7 +522,12 @@ const OccupanciesPage: React.FC = () => { loading={loading} locale={{ emptyText: }} scroll={{ x: 1300 }} - pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 条`, + }} rowSelection={rowSelection} /> { const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); const [filterModule, setFilterModule] = useState(); const [dateRange, setDateRange] = useState<[string, string] | null>(null); const fetchData = useCallback(async () => { setLoading(true); try { - const params: any = { page, pageSize: 20 }; + const params: any = { page, pageSize }; if (filterModule) params.module = filterModule; if (dateRange) { params.startDate = dateRange[0]; @@ -46,7 +47,7 @@ const OperationLogsPage: React.FC = () => { message.error(err?.message || '加载失败,请稍后重试'); } setLoading(false); - }, [page, filterModule, dateRange]); + }, [page, pageSize, filterModule, dateRange]); useEffect(() => { fetchData(); @@ -159,8 +160,13 @@ const OperationLogsPage: React.FC = () => { pagination={{ current: page, total, - pageSize: 20, - onChange: setPage, + pageSize, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + onChange: (nextPage, nextPageSize) => { + setPage(nextPage); + setPageSize(nextPageSize); + }, showTotal: (t) => `共 ${t} 条`, }} /> diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index 990ce27..0ba4775 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -235,7 +235,12 @@ const OrganizationsPage: React.FC = () => { loading={loading} locale={{ emptyText: }} scroll={{ x: 1100 }} - pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个机构` }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (total) => `共 ${total} 个机构`, + }} /> { if (filterStatus) result = result.filter((r: Record) => r.status === filterStatus); return result; }, [data, searchText, filterBuilding, filterStatus]); + const remainingBedSlots = useMemo(() => { + const capacity = Number(drawerRoom?.capacity) || 0; + return Math.max(capacity - beds.length, 0); + }, [drawerRoom?.capacity, beds.length]); + const defaultBatchBedCount = Math.min(4, Math.max(remainingBedSlots, 1)); const handleSave = async () => { const values = await form.validateFields(); @@ -167,7 +172,7 @@ const RoomsPage: React.FC = () => { message.success('更新成功'); } else { await api.post('/rooms', payload); - message.success('创建成功'); + message.success(`创建成功,已自动生成 ${values.capacity} 张床位`); } setModalOpen(false); form.resetFields(); @@ -514,7 +519,12 @@ const RoomsPage: React.FC = () => { scroll={{ x: 1200 }} loading={loading} locale={{ emptyText: }} - pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 间` }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (total) => `共 ${total} 间`, + }} rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')} rowSelection={{ selectedRowKeys, @@ -629,24 +639,26 @@ const RoomsPage: React.FC = () => { type="primary" size="small" icon={} - disabled={drawerRoom?.status === 'archived'} + disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0} onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }} > 添加床位 0 ? '批量生成床位' : '床位已达到额定人数'} description={ - + remainingBedSlots > 0 + ? + : '如需增加床位,请先调整宿舍额定人数' } onConfirm={() => { const input = document.getElementById('batch-bed-count') as HTMLInputElement; - handleBatchBeds(input ? parseInt(input.value) || 4 : 4); + handleBatchBeds(input ? parseInt(input.value) || defaultBatchBedCount : defaultBatchBedCount); }} okText="生成" - disabled={drawerRoom?.status === 'archived'} + disabled={drawerRoom?.status === 'archived' || remainingBedSlots === 0} > - + { loading={loading} locale={{ emptyText: }} scroll={{ x: 1410 }} - pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 人` }} + pagination={{ + defaultPageSize: 15, + showSizeChanger: true, + pageSizeOptions: [15, 30, 50, 100], + showTotal: (total) => `共 ${total} 人`, + }} rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')} rowSelection={{ selectedRowKeys, diff --git a/apps/admin/src/pages/TeacherWorkspace/index.tsx b/apps/admin/src/pages/TeacherWorkspace/index.tsx index 6aa0476..96c70c5 100644 --- a/apps/admin/src/pages/TeacherWorkspace/index.tsx +++ b/apps/admin/src/pages/TeacherWorkspace/index.tsx @@ -146,7 +146,12 @@ const TeacherWorkspacePage: React.FC = () => { columns={classColumns} dataSource={data.assignedClasses} rowKey="classId" - pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个班级` }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (total) => `共 ${total} 个班级`, + }} /> ) : ( @@ -160,7 +165,12 @@ const TeacherWorkspacePage: React.FC = () => { columns={scheduleColumns} dataSource={data.todaySchedules} rowKey="id" - pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 节` }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (total) => `共 ${total} 节`, + }} /> ) : ( @@ -174,7 +184,12 @@ const TeacherWorkspacePage: React.FC = () => { columns={studentColumns} dataSource={data.myStudents} rowKey="studentId" - pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 人` }} + pagination={{ + defaultPageSize: 20, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + showTotal: (total) => `共 ${total} 人`, + }} /> ) : ( diff --git a/apps/admin/src/pages/Teachers/index.tsx b/apps/admin/src/pages/Teachers/index.tsx index ef0d77a..a1a1807 100644 --- a/apps/admin/src/pages/Teachers/index.tsx +++ b/apps/admin/src/pages/Teachers/index.tsx @@ -46,13 +46,14 @@ const ROLE_TYPE_LABELS: Record = { academic_teacher: '教务老师', }; -const PAGE_SIZE = 20; +const DEFAULT_PAGE_SIZE = 20; const TeachersPage: React.FC = () => { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [search, setSearch] = useState(''); const [profileModal, setProfileModal] = useState(null); const [form] = Form.useForm(); @@ -62,7 +63,7 @@ const TeachersPage: React.FC = () => { setLoading(true); try { const res = await api.get('/rbac/teachers', { - params: { search: search || undefined, page, pageSize: PAGE_SIZE }, + params: { search: search || undefined, page, pageSize }, }); setData(res.list); setTotal(res.total); @@ -70,7 +71,7 @@ const TeachersPage: React.FC = () => { // silent } setLoading(false); - }, [page, search]); + }, [page, pageSize, search]); useEffect(() => { fetchData(); @@ -199,9 +200,14 @@ const TeachersPage: React.FC = () => { scroll={{ x: 1300 }} pagination={{ current: page, - pageSize: PAGE_SIZE, + pageSize, total, - onChange: setPage, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + onChange: (nextPage, nextPageSize) => { + setPage(nextPage); + setPageSize(nextPageSize); + }, showTotal: (t) => `共 ${t} 人`, }} expandable={{ diff --git a/apps/server/src/bills/bills-export.service.ts b/apps/server/src/bills/bills-export.service.ts index ef33f88..7038f16 100644 --- a/apps/server/src/bills/bills-export.service.ts +++ b/apps/server/src/bills/bills-export.service.ts @@ -5,7 +5,7 @@ import { Bill } from '../entities/bill.entity'; import { BillItem } from '../entities/bill-item.entity'; import { Deposit } from '../entities/deposit.entity'; import * as ExcelJS from 'exceljs'; -import * as PDFDocument from 'pdfkit'; +import PDFDocument from 'pdfkit'; import { Response } from 'express'; @Injectable() diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 42c2f65..b7c9970 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -110,7 +110,9 @@ export class RoomsService { roomType: dto.roomType ?? parsed.roomType, capacity: dto.capacity ?? parsed.capacity, }); - return this.repo.save(entity); + const room = await this.repo.save(entity); + await this.createDefaultBeds(room.id, room.capacity); + return room; } async update(id: number, dto: UpdateRoomDto) { @@ -312,7 +314,7 @@ export class RoomsService { } // 智能解析房间号 const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); - await this.repo.save( + const room = await this.repo.save( this.repo.create({ roomNumber: row.roomNumber.trim(), building: row.building?.trim() || parsed.building || undefined, @@ -323,6 +325,7 @@ export class RoomsService { monthlyRate: row.monthlyRate ?? undefined, }), ); + await this.createDefaultBeds(room.id, room.capacity); imported++; } return { @@ -353,6 +356,7 @@ export class RoomsService { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); + await this.assertCanAddBeds(room, 1); const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); if (existing) throw new BadRequestException('该床位编号已存在'); const bed = this.bedRepo.create({ ...dto, roomId }); @@ -387,6 +391,7 @@ export class RoomsService { if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } }); + this.assertCanAddBedsFromCount(room, existing.length, dto.count); const numbers = existing.map((b) => { const match = b.bedNumber.match(/^\d+/); return match ? parseInt(match[0]) : 0; @@ -399,6 +404,27 @@ export class RoomsService { return this.bedRepo.save(beds); } + private async createDefaultBeds(roomId: number, capacity: number): Promise { + const count = Math.max(capacity ?? 0, 0); + if (count === 0) return; + const beds = Array.from({ length: count }, (_, index) => + this.bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }), + ); + await this.bedRepo.save(beds); + } + + private async assertCanAddBeds(room: Room, count: number): Promise { + const existingCount = await this.bedRepo.count({ where: { roomId: room.id } }); + this.assertCanAddBedsFromCount(room, existingCount, count); + } + + private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void { + const remaining = Math.max((room.capacity ?? 0) - existingCount, 0); + if (count > remaining) { + throw new BadRequestException(`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`); + } + } + // ── 柜子管理 ── async getRoomLockers(roomId: number): Promise {