From b032890b4f8e0c8e716dfcb020e18262b0bb9827 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 09:50:52 +0800 Subject: [PATCH 1/6] =?UTF-8?q?style(admin):=20=E6=B8=85=E7=90=86=E8=80=83?= =?UTF-8?q?=E5=8B=A4=E9=A1=B5=E4=B8=8E=E6=8E=92=E8=AF=BE=E8=A1=A8=E7=9A=84?= =?UTF-8?q?=E5=86=97=E4=BD=99=E8=A3=85=E9=A5=B0=E4=B8=8E=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Attendance/AttendanceAdminHeader.tsx | 1 - .../Attendance/LessonAttendanceDetail.tsx | 1 - .../admin/src/pages/Attendance/attendance.css | 30 +------------------ .../src/pages/ClassroomSchedule/index.tsx | 10 +++++-- 4 files changed, 9 insertions(+), 33 deletions(-) diff --git a/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx b/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx index 751cf26..630c017 100644 --- a/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx +++ b/apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx @@ -196,7 +196,6 @@ export const AttendanceAdminHeader: React.FC<{
- 班级考勤概览

{selectedClass}

{dateLabel} · 当前展示 {visibleStudentCount} 名学生 / {total} 条记录 diff --git a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx index 5085b31..f1fce2b 100644 --- a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx +++ b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx @@ -148,7 +148,6 @@ const LessonAttendanceDetail: React.FC = ({ destroyOnHidden >

- LESSON ATTENDANCE

{displayedSchedule?.subject || '课程考勤'}

{className} · {displayedSchedule?.startTime}–{displayedSchedule?.endTime} ·{' '} diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css index 7256145..e6a2c6c 100644 --- a/apps/admin/src/pages/Attendance/attendance.css +++ b/apps/admin/src/pages/Attendance/attendance.css @@ -761,7 +761,6 @@ padding: 0 24px; border-bottom: 1px solid var(--student-line); background: rgb(255 255 255 / 96%); - backdrop-filter: blur(10px); } .student-center-title { @@ -911,21 +910,7 @@ min-height: 176px; overflow: hidden; padding: 20px; - background: - radial-gradient(circle at 100% 0%, rgb(21 122 101 / 10%), transparent 34%), - linear-gradient(135deg, #ffffff 0%, #f8fcfa 100%); -} - -.student-class-identity::after { - content: ''; - position: absolute; - right: -36px; - bottom: -44px; - width: 118px; - height: 118px; - border: 18px solid rgb(21 122 101 / 7%); - border-radius: 999px; - pointer-events: none; + background: var(--student-surface); } .student-class-heading { @@ -933,19 +918,6 @@ z-index: 1; } -.student-overview-kicker { - display: inline-flex; - align-items: center; - min-height: 24px; - margin-bottom: 8px; - padding: 0 9px; - border-radius: 999px; - background: rgb(21 122 101 / 10%); - color: var(--student-primary); - font-size: 12px; - font-weight: 700; -} - .student-class-identity h2 { margin: 0 0 6px; color: #111c18; diff --git a/apps/admin/src/pages/ClassroomSchedule/index.tsx b/apps/admin/src/pages/ClassroomSchedule/index.tsx index f6c2d10..24c41cc 100644 --- a/apps/admin/src/pages/ClassroomSchedule/index.tsx +++ b/apps/admin/src/pages/ClassroomSchedule/index.tsx @@ -16,7 +16,7 @@ import { Empty, Tooltip, } from 'antd'; -import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons'; +import { CalendarOutlined, FileTextOutlined, ReadOutlined } from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; @@ -314,7 +314,13 @@ const ClassroomSchedulePage: React.FC = () => { } > - {isInternal ? '📖' : cell.hasContract ? '📄' : ''} + {isInternal ? ( + + ) : cell.hasContract ? ( + + ) : ( + '' + )} )} From 54b002455f0049d4ad97895251c9c2af224e6746 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 16:37:47 +0800 Subject: [PATCH 2/6] =?UTF-8?q?chore(admin):=20=E5=A3=B0=E6=98=8E=20react-?= =?UTF-8?q?syntax-highlighter=20=E8=BF=90=E8=A1=8C=E6=97=B6=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/package.json | 1 + package-lock.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index 22de786..90b7684 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -33,6 +33,7 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "react-router": "^8.3.0", + "react-syntax-highlighter": "^16.1.1", "use-immer": "^0.11.0", "usehooks-ts": "^3.1.1", "zod": "^4.4.3", diff --git a/package-lock.json b/package-lock.json index bfebdb2..fd2c698 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "react-router": "^8.3.0", + "react-syntax-highlighter": "^16.1.1", "use-immer": "^0.11.0", "usehooks-ts": "^3.1.1", "zod": "^4.4.3", @@ -16256,7 +16257,7 @@ }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "resolved": "https://registry.npmmirror.com/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", "license": "MIT", "dependencies": { From 899d2dde5b1e1e876fe5620479722081351326cc Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 16:37:55 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(admin,server):=20=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20file.buffer=20=E5=B9=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=89=8B=E5=86=99=20multipart=20=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/api/imports.ts | 4 +--- apps/admin/src/components/AiChat/api.ts | 1 - .../components/StudentProfileContent/AttachmentsTab.tsx | 4 +--- apps/admin/src/pages/ClassroomRentals/index.tsx | 4 +--- apps/admin/src/pages/Classrooms/index.tsx | 4 +--- apps/admin/src/pages/Expenses/index.tsx | 8 ++------ apps/admin/src/pages/Occupancies/useOccupancyMutations.ts | 4 +--- apps/admin/src/pages/Rooms/useRoomMutations.ts | 4 +--- apps/admin/src/pages/Students/index.tsx | 8 ++------ apps/server/src/ai-chat/ai-excel-reader.service.ts | 2 +- apps/server/src/classrooms/classrooms.controller.ts | 2 +- apps/server/src/expenses/expenses.controller.ts | 4 ++-- apps/server/src/imports/imports.workbook.ts | 2 +- apps/server/src/occupancies/occupancies.controller.ts | 2 +- apps/server/src/rooms/rooms.controller.ts | 2 +- apps/server/src/students/students.controller.ts | 4 ++-- 16 files changed, 19 insertions(+), 40 deletions(-) diff --git a/apps/admin/src/api/imports.ts b/apps/admin/src/api/imports.ts index d87800a..635e8c2 100644 --- a/apps/admin/src/api/imports.ts +++ b/apps/admin/src/api/imports.ts @@ -31,9 +31,7 @@ export async function createImportRun( if (options.mapping && Object.keys(options.mapping).length > 0) { form.append('mapping', JSON.stringify(options.mapping)); } - const res = await api.post>('/imports/runs', form, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); + const res = await api.post>('/imports/runs', form); return res.data; } diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index f336abc..bcbd8c3 100644 --- a/apps/admin/src/components/AiChat/api.ts +++ b/apps/admin/src/components/AiChat/api.ts @@ -35,7 +35,6 @@ export const aiChatApi = { form.append('file', file); return ( await api.post>('/ai/chat/attachments', form, { - headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000, }) ).data; diff --git a/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx b/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx index 1a0f132..c987f78 100644 --- a/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx +++ b/apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx @@ -29,9 +29,7 @@ export const AttachmentsTab: React.FC = ); const uploadAttachmentMutation = useApiMutation( async (formData: FormData) => - api.post(`/archive/${studentId}/attachments`, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }), + api.post(`/archive/${studentId}/attachments`, formData), { invalidate: [['archive', studentId]] }, ); diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index d2d5bc4..bc7bc4b 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -140,9 +140,7 @@ const ClassroomRentalsPage: React.FC = () => { ); const uploadContractMutation = useApiMutation( async ({ id, formData }: { id: number; formData: FormData }) => - api.post(`/classroom-rentals/${id}/contract`, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }), + api.post(`/classroom-rentals/${id}/contract`, formData), { invalidate: [['classroom-rentals']] }, ); diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index 869b901..5526afb 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -110,9 +110,7 @@ const ClassroomsPage: React.FC = () => { ); const importMutation = useApiMutation( async (formData: FormData) => - api.post('/classrooms/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }), + api.post('/classrooms/import', formData), { invalidate: [['classrooms']] }, ); diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index f1e7763..e8d9dff 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -137,16 +137,12 @@ const ExpensesPage: React.FC = () => { ), importUtility: useApiMutation( async (formData: FormData) => - api.post('/expenses/utility/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }), + api.post('/expenses/utility/import', formData), { invalidate: [['expenses']] }, ), importPersonal: useApiMutation( async (formData: FormData) => - api.post('/expenses/personal/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }), + api.post('/expenses/personal/import', formData), { invalidate: [['expenses']] }, ), archiveRoom: useApiMutation( diff --git a/apps/admin/src/pages/Occupancies/useOccupancyMutations.ts b/apps/admin/src/pages/Occupancies/useOccupancyMutations.ts index 7e90a16..8c26a0d 100644 --- a/apps/admin/src/pages/Occupancies/useOccupancyMutations.ts +++ b/apps/admin/src/pages/Occupancies/useOccupancyMutations.ts @@ -44,9 +44,7 @@ export function useOccupancyMutations() { ); const importMutation = useApiMutation( async ({ formData, params }: { formData: FormData; params: string }) => - api.post(`/occupancies/import?${params}`, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }), + api.post(`/occupancies/import?${params}`, formData), { invalidate: invalidateOccupancies }, ); diff --git a/apps/admin/src/pages/Rooms/useRoomMutations.ts b/apps/admin/src/pages/Rooms/useRoomMutations.ts index 17ec1ac..9195594 100644 --- a/apps/admin/src/pages/Rooms/useRoomMutations.ts +++ b/apps/admin/src/pages/Rooms/useRoomMutations.ts @@ -143,9 +143,7 @@ export function useRoomMutations(editing: any) { ); const importMutation = useApiMutation( async (formData: FormData) => - api.post<{ message?: string }>('/rooms/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }), + api.post<{ message?: string }>('/rooms/import', formData), { invalidate: [['rooms']] }, ); diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 1ccdebf..c601801 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -228,16 +228,12 @@ const StudentsPage: React.FC = () => { ); const importMutation = useApiMutation( async (formData: FormData) => - api.post('/students/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }), + api.post('/students/import', formData), { invalidate: invalidateStudents }, ); const importMatchMutation = useApiMutation( async (formData: FormData) => - api.post('/students/import-match', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }), + api.post('/students/import-match', formData), { invalidate: invalidateStudents }, ); diff --git a/apps/server/src/ai-chat/ai-excel-reader.service.ts b/apps/server/src/ai-chat/ai-excel-reader.service.ts index 67ef99d..83d1d98 100644 --- a/apps/server/src/ai-chat/ai-excel-reader.service.ts +++ b/apps/server/src/ai-chat/ai-excel-reader.service.ts @@ -86,7 +86,7 @@ export class AiExcelReaderService { private async loadWithExcelJs(buffer: Buffer): Promise { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(buffer.buffer as ArrayBuffer); + await workbook.xlsx.load(buffer as unknown as ArrayBuffer); const sheets: ExcelSheetRows[] = []; workbook.eachSheet((sheet) => { const rows: string[][] = []; diff --git a/apps/server/src/classrooms/classrooms.controller.ts b/apps/server/src/classrooms/classrooms.controller.ts index 92b9662..1cd7e6d 100644 --- a/apps/server/src/classrooms/classrooms.controller.ts +++ b/apps/server/src/classrooms/classrooms.controller.ts @@ -158,7 +158,7 @@ export class ClassroomsController { async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); + await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: any[] = []; ws.eachRow((row, idx) => { diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 011a93b..76aa6ad 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -316,7 +316,7 @@ export class ExpensesController { @UseInterceptors(FileInterceptor('file')) async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); + await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: any[] = []; ws.eachRow((row, idx) => { @@ -381,7 +381,7 @@ export class ExpensesController { @UseInterceptors(FileInterceptor('file')) async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); + await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: any[] = []; ws.eachRow((row, idx) => { diff --git a/apps/server/src/imports/imports.workbook.ts b/apps/server/src/imports/imports.workbook.ts index dcfeaa5..e4f248e 100644 --- a/apps/server/src/imports/imports.workbook.ts +++ b/apps/server/src/imports/imports.workbook.ts @@ -84,7 +84,7 @@ export async function parseSheets( if (kind === 'csv') { await workbook.csv.read(Readable.from(Buffer.from(buffer))); } else { - await workbook.xlsx.load(buffer.buffer as ArrayBuffer); + await workbook.xlsx.load(buffer as unknown as ArrayBuffer); } const sheets = extractSheets(workbook, headerRow); if (sheets.length === 0) { diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index ee49fa3..9ee2277 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -264,7 +264,7 @@ export class OccupanciesController { const { ipAddress, userAgent } = extractRequestInfo(req); if (!file?.buffer) throw new BadRequestException('请上传入住名单 Excel 文件'); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); + await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; const rows = parseOccupancyImportWorksheet(ws); const result = await this.service.batchImportCheckIn(rows, { diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index dda7648..a7db9a8 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -343,7 +343,7 @@ export class RoomsController { async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); + await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; const rows: { roomNumber: string; diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 2e85695..0920f64 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -255,7 +255,7 @@ export class StudentsController { @UseInterceptors(FileInterceptor('file')) async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); + await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { @@ -280,7 +280,7 @@ export class StudentsController { @UseInterceptors(FileInterceptor('file')) async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); + await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { From 5b5ffb5b9ebcc44bde6f367ca21483dbbf651f14 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 16:38:03 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(server):=20=E8=B7=AF=E7=94=B1=20id=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20ParseIntPipe=EF=BC=8C=E9=99=90=E5=88=B6?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E4=B8=8E=E6=89=B9=E9=87=8F=E6=95=B0=E7=BB=84?= =?UTF-8?q?=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/attendance-query.service.ts | 8 ++-- apps/server/src/classes/classes.controller.ts | 41 ++++++++++--------- .../classroom-rentals.controller.ts | 19 +++++---- apps/server/src/common/batch-ids.dto.spec.ts | 7 ++++ apps/server/src/common/batch-ids.dto.ts | 3 +- .../expense-types/expense-types.controller.ts | 6 +-- .../src/expenses/dto/expense.dto.spec.ts | 13 ++++++ apps/server/src/expenses/dto/expense.dto.ts | 3 +- .../dto/notification.dto.spec.ts | 9 ++++ .../src/notifications/dto/notification.dto.ts | 2 + .../notifications/notifications.controller.ts | 3 +- .../operation-logs/operation-logs.service.ts | 4 +- .../organizations/organizations.controller.ts | 9 ++-- apps/server/src/rbac/rbac.controller.ts | 27 ++++++------ 14 files changed, 96 insertions(+), 58 deletions(-) diff --git a/apps/server/src/attendance/attendance-query.service.ts b/apps/server/src/attendance/attendance-query.service.ts index c22fbfd..7974eb7 100644 --- a/apps/server/src/attendance/attendance-query.service.ts +++ b/apps/server/src/attendance/attendance-query.service.ts @@ -131,8 +131,8 @@ export class AttendanceQueryService { }, accessibleClassIds?: number[], ) { - const page = query.page || 1; - const pageSize = query.pageSize || 20; + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20))); const qb = this.attendanceRepo.createQueryBuilder('ar'); @@ -233,8 +233,8 @@ export class AttendanceQueryService { // ── DingAttendance raw records ── async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) { - const page = query.page || 1; - const pageSize = query.pageSize || 20; + const page = Math.max(1, Math.floor(Number(query.page) || 1)); + const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20))); const qb = this.dingRawRepo.createQueryBuilder('ar'); qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent'); diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index 2723aca..139cbb5 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + ParseIntPipe, Query, UseGuards, UsePipes, @@ -85,7 +86,7 @@ export class ClassesController { @Get(':id') @RequirePermission('class:view') - async findOne(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + async findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { await this.assertReadAccess(req, +id); return this.service.findOne(+id); } @@ -93,7 +94,7 @@ export class ClassesController { @Get(':id/schedule') @RequirePermission('class:view') async getSchedule( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Query() query: QueryClassScheduleDto, @Request() req: AuthenticatedRequest, ) { @@ -104,7 +105,7 @@ export class ClassesController { @Get(':id/attendance-summary') @RequirePermission('class:view') async getAttendanceSummary( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Query() query: QueryClassAttendanceSummaryDto, @Request() req: AuthenticatedRequest, ) { @@ -125,27 +126,27 @@ export class ClassesController { /** 批量导入学生到班级(通过钉钉用户ID) */ @Post(':id/students/import') @RequirePermission('class:edit') - async batchImportStudents(@Param('id') id: string, @Body() dto: BatchImportStudentsDto) { + async batchImportStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: BatchImportStudentsDto) { return this.service.batchImportStudents(+id, dto.users); } /** 归档班级 */ @Put(':id/archive') @RequirePermission('class:edit') - async archive(@Param('id') id: string) { + async archive(@Param('id', ParseIntPipe) id: number) { return this.service.archive(+id); } /** 取消归档 */ @Put(':id/restore') @RequirePermission('class:edit') - async restore(@Param('id') id: string) { + async restore(@Param('id', ParseIntPipe) id: number) { return this.service.restore(+id); } @Put(':id') @RequirePermission('class:edit') - async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: any) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto), @@ -155,7 +156,7 @@ export class ClassesController { @Delete(':id') @RequirePermission('class:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class', @@ -165,7 +166,7 @@ export class ClassesController { @Delete(':id/permanent') @RequirePermission('class:purge') - async purge(@Param('id') id: string, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复', @@ -176,7 +177,7 @@ export class ClassesController { @Get(':id/roster/export') @RequirePermission('class:view') async exportRoster( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Res() res: Response, @Request() req: AuthenticatedRequest, ) { @@ -218,14 +219,14 @@ export class ClassesController { @Get(':id/students') @RequirePermission('class:view') - async getStudents(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + async getStudents(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { await this.assertReadAccess(req, +id); return this.service.getStudents(+id); } @Post(':id/students') @RequirePermission('class:edit') - async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) { + async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: any) { const result = await this.service.addStudents(+id, dto.studentIds); await logAudit(this.logService, req, { module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`, @@ -249,8 +250,8 @@ export class ClassesController { @Delete(':id/students/:studentId') @RequirePermission('class:edit') async removeStudent( - @Param('id') id: string, - @Param('studentId') studentId: string, + @Param('id', ParseIntPipe) id: number, + @Param('studentId', ParseIntPipe) studentId: number, @Request() req: any, ) { const result = await this.service.removeStudent(+id, +studentId); @@ -262,14 +263,14 @@ export class ClassesController { @Get(':id/teachers') @RequirePermission('class:view') - async getTeachers(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + async getTeachers(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { await this.assertReadAccess(req, +id); return this.service.getTeachers(+id); } @Post(':id/teachers') @RequirePermission('class:edit') - async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) { + async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: any) { const result = await this.service.addTeacher(+id, dto); await logAudit(this.logService, req, { module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`, @@ -290,8 +291,8 @@ export class ClassesController { @Delete(':id/teacher-assignments/:assignmentId') @RequirePermission('class:edit') async removeTeacherAssignment( - @Param('id') id: string, - @Param('assignmentId') assignmentId: string, + @Param('id', ParseIntPipe) id: number, + @Param('assignmentId', ParseIntPipe) assignmentId: number, @Request() req: any, ) { const result = await this.service.removeTeacherAssignment(+id, +assignmentId); @@ -304,8 +305,8 @@ export class ClassesController { @Delete(':id/teachers/:userId') @RequirePermission('class:edit') async removeTeacher( - @Param('id') id: string, - @Param('userId') userId: string, + @Param('id', ParseIntPipe) id: number, + @Param('userId', ParseIntPipe) userId: number, @Request() req: any, ) { const result = await this.service.removeTeacher(+id, +userId); diff --git a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts index dd9e970..d64be00 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + ParseIntPipe, Query, UseGuards, Request, @@ -95,7 +96,7 @@ export class ClassroomRentalsController { @Get(':id') @RequirePermission('rental:view') - findOne(@Param('id') id: string) { + findOne(@Param('id', ParseIntPipe) id: number) { return this.service.findOne(+id); } @@ -111,7 +112,7 @@ export class ClassroomRentalsController { @Put(':id') @RequirePermission('rental:edit') - async update(@Param('id') id: string, @Body() dto: UpdateRentalDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRentalDto, @Request() req: any) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto), @@ -121,7 +122,7 @@ export class ClassroomRentalsController { @Put(':id/cancel') @RequirePermission('rental:edit') - async cancel(@Param('id') id: string, @Request() req: any) { + async cancel(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const result = await this.service.cancel(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental', @@ -131,7 +132,7 @@ export class ClassroomRentalsController { @Put(':id/end') @RequirePermission('rental:edit') - async end(@Param('id') id: string, @Request() req: any) { + async end(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const result = await this.service.end(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental', @@ -141,7 +142,7 @@ export class ClassroomRentalsController { @Delete(':id') @RequirePermission('rental:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental', @@ -151,7 +152,7 @@ export class ClassroomRentalsController { @Delete(':id/permanent') @RequirePermission('rental:purge') - async purge(@Param('id') id: string, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复', @@ -174,7 +175,7 @@ export class ClassroomRentalsController { }), ) async uploadContract( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @UploadedFile() file: Express.Multer.File, @Request() req: any, ) { @@ -188,7 +189,7 @@ export class ClassroomRentalsController { @Get(':id/contract') @RequirePermission('rental:view') - async downloadContract(@Param('id') id: string, @Res() res: Response) { + async downloadContract(@Param('id', ParseIntPipe) id: number, @Res() res: Response) { const { fullPath, originalName } = await this.service.getContractPath(+id); res.setHeader('Content-Type', 'application/pdf'); res.setHeader( @@ -201,7 +202,7 @@ export class ClassroomRentalsController { @Delete(':id/contract') @RequirePermission('rental:edit') - async deleteContract(@Param('id') id: string, @Request() req: any) { + async deleteContract(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const result = await this.service.removeContract(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental', diff --git a/apps/server/src/common/batch-ids.dto.spec.ts b/apps/server/src/common/batch-ids.dto.spec.ts index 6377f99..64f9977 100644 --- a/apps/server/src/common/batch-ids.dto.spec.ts +++ b/apps/server/src/common/batch-ids.dto.spec.ts @@ -17,4 +17,11 @@ describe('BatchIdsDto', () => { const dto = Object.assign(new BatchIdsDto(), { ids: [1, 1, 2] }); await expect(validate(dto)).resolves.toHaveLength(0); }); + + it('rejects batches with more than 500 ids', async () => { + const dto = Object.assign(new BatchIdsDto(), { + ids: Array.from({ length: 501 }, (_, i) => i + 1), + }); + await expect(validate(dto)).resolves.not.toHaveLength(0); + }); }); diff --git a/apps/server/src/common/batch-ids.dto.ts b/apps/server/src/common/batch-ids.dto.ts index d778abb..5e8ce70 100644 --- a/apps/server/src/common/batch-ids.dto.ts +++ b/apps/server/src/common/batch-ids.dto.ts @@ -1,8 +1,9 @@ -import { ArrayNotEmpty, IsArray, IsInt, Min } from 'class-validator'; +import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsInt, Min } from 'class-validator'; export class BatchIdsDto { @IsArray() @ArrayNotEmpty() + @ArrayMaxSize(500) @IsInt({ each: true }) @Min(1, { each: true }) ids: number[]; diff --git a/apps/server/src/expense-types/expense-types.controller.ts b/apps/server/src/expense-types/expense-types.controller.ts index d7db659..3813d15 100644 --- a/apps/server/src/expense-types/expense-types.controller.ts +++ b/apps/server/src/expense-types/expense-types.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, ParseIntPipe, UseGuards, Request } from '@nestjs/common'; import { ExpenseTypesService } from './expense-types.service'; import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -47,7 +47,7 @@ export class ExpenseTypesController { @Put(':id') @RequirePermission('expense:edit') - async update(@Param('id') id: string, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); await this.logService.log({ @@ -66,7 +66,7 @@ export class ExpenseTypesController { @Delete(':id') @RequirePermission('expense:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); await this.service.remove(+id); await this.logService.log({ diff --git a/apps/server/src/expenses/dto/expense.dto.spec.ts b/apps/server/src/expenses/dto/expense.dto.spec.ts index 2db7053..9a03a7c 100644 --- a/apps/server/src/expenses/dto/expense.dto.spec.ts +++ b/apps/server/src/expenses/dto/expense.dto.spec.ts @@ -28,4 +28,17 @@ describe('BatchRoomExpenseDto boundaries', () => { }); await expect(validate(dto)).resolves.toHaveLength(0); }); + + it('rejects a batch with more than 500 items', async () => { + const dto = plainToInstance(BatchRoomExpenseDto, { + periodStart: '2026-07-01', + periodEnd: '2026-07-31', + expenses: Array.from({ length: 501 }, () => ({ + roomId: 1, + expenseType: 'water', + amount: 10, + })), + }); + await expect(validate(dto)).resolves.not.toHaveLength(0); + }); }); diff --git a/apps/server/src/expenses/dto/expense.dto.ts b/apps/server/src/expenses/dto/expense.dto.ts index f6aee27..dd00816 100644 --- a/apps/server/src/expenses/dto/expense.dto.ts +++ b/apps/server/src/expenses/dto/expense.dto.ts @@ -1,4 +1,4 @@ -import { ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsISO8601, IsString, IsNumber, IsOptional, Matches, Min, ValidateNested } from 'class-validator'; +import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsDateString, IsIn, IsInt, IsISO8601, IsString, IsNumber, IsOptional, Matches, Min, ValidateNested } from 'class-validator'; import { PartialType } from '@nestjs/mapped-types'; import { Type } from 'class-transformer'; @@ -117,6 +117,7 @@ export class BatchRoomExpenseDto { @IsArray() @ArrayNotEmpty() + @ArrayMaxSize(500) @ValidateNested({ each: true }) @Type(() => BatchRoomExpenseItemDto) expenses: BatchRoomExpenseItemDto[]; diff --git a/apps/server/src/notifications/dto/notification.dto.spec.ts b/apps/server/src/notifications/dto/notification.dto.spec.ts index 235aa75..2bacea3 100644 --- a/apps/server/src/notifications/dto/notification.dto.spec.ts +++ b/apps/server/src/notifications/dto/notification.dto.spec.ts @@ -32,4 +32,13 @@ describe('notification DTO boundaries', () => { expect(await validate(dto)).not.toEqual([]); } }); + + it('rejects more than 500 recipients', async () => { + const dto = plainToInstance(CreateNotificationDto, { + recipientIds: Array.from({ length: 501 }, (_, i) => i + 1), + type: 'test', + title: '标题', + }); + await expect(validate(dto)).resolves.not.toEqual([]); + }); }); diff --git a/apps/server/src/notifications/dto/notification.dto.ts b/apps/server/src/notifications/dto/notification.dto.ts index fc524ad..1f7a3c3 100644 --- a/apps/server/src/notifications/dto/notification.dto.ts +++ b/apps/server/src/notifications/dto/notification.dto.ts @@ -1,5 +1,6 @@ import { ArrayNotEmpty, + ArrayMaxSize, IsString, IsNotEmpty, IsOptional, @@ -13,6 +14,7 @@ import { Type } from 'class-transformer'; export class CreateNotificationDto { @IsArray() @ArrayNotEmpty() + @ArrayMaxSize(500) @IsInt({ each: true }) recipientIds: number[]; diff --git a/apps/server/src/notifications/notifications.controller.ts b/apps/server/src/notifications/notifications.controller.ts index d285482..d3fdc7b 100644 --- a/apps/server/src/notifications/notifications.controller.ts +++ b/apps/server/src/notifications/notifications.controller.ts @@ -3,6 +3,7 @@ import { Get, Put, Param, + ParseIntPipe, Query, Req, Sse, @@ -74,7 +75,7 @@ export class NotificationsController { @Put(':id/read') async markRead( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Req() req: AuthenticatedRequest, ) { await this.service.markRead(+id, req.user.id); diff --git a/apps/server/src/operation-logs/operation-logs.service.ts b/apps/server/src/operation-logs/operation-logs.service.ts index 644e554..4cc8db0 100644 --- a/apps/server/src/operation-logs/operation-logs.service.ts +++ b/apps/server/src/operation-logs/operation-logs.service.ts @@ -53,8 +53,8 @@ export class OperationLogsService { if (query?.endDate) qb.andWhere('log.createdAt <= :endDate', { endDate: query.endDate + ' 23:59:59' }); - const page = query?.page || 1; - const pageSize = query?.pageSize || 50; + const page = Math.max(1, Math.floor(Number(query?.page) || 1)); + const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query?.pageSize) || 50))); const [data, total] = await qb .skip((page - 1) * pageSize) .take(pageSize) diff --git a/apps/server/src/organizations/organizations.controller.ts b/apps/server/src/organizations/organizations.controller.ts index 9548256..f260c48 100644 --- a/apps/server/src/organizations/organizations.controller.ts +++ b/apps/server/src/organizations/organizations.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + ParseIntPipe, Query, UseGuards, Request, @@ -42,7 +43,7 @@ export class OrganizationsController { @Get(':id') @RequirePermission('organization:view') - findOne(@Param('id') id: string) { + findOne(@Param('id', ParseIntPipe) id: number) { return this.service.findOne(+id); } @@ -67,7 +68,7 @@ export class OrganizationsController { @Put(':id') @RequirePermission('organization:edit') - async update(@Param('id') id: string, @Body() dto: UpdateOrganizationDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOrganizationDto, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); await this.logService.log({ @@ -86,7 +87,7 @@ export class OrganizationsController { @Delete(':id') @RequirePermission('organization:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); await this.logService.log({ @@ -104,7 +105,7 @@ export class OrganizationsController { @Delete(':id/permanent') @RequirePermission('organization:purge') - async purge(@Param('id') id: string, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.purge(+id); await this.logService.log({ diff --git a/apps/server/src/rbac/rbac.controller.ts b/apps/server/src/rbac/rbac.controller.ts index c4dee8f..68d1c31 100644 --- a/apps/server/src/rbac/rbac.controller.ts +++ b/apps/server/src/rbac/rbac.controller.ts @@ -10,6 +10,7 @@ import { UseGuards, Request, BadRequestException, + ParseIntPipe, } from '@nestjs/common'; import { RbacService } from './rbac.service'; import { @@ -41,7 +42,7 @@ export class RbacController { @Get('roles/:id') @RequirePermission('role:view') - findRoleById(@Param('id') id: string) { + findRoleById(@Param('id', ParseIntPipe) id: number) { return this.rbacService.findRoleById(+id); } @@ -57,7 +58,7 @@ export class RbacController { @Put('roles/:id') @RequirePermission('role:edit') - async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto, @Request() req: any) { + async updateRole(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoleDto, @Request() req: any) { try { const result = await this.rbacService.updateRole(+id, dto); await logAudit(this.logService, req, { @@ -71,7 +72,7 @@ export class RbacController { @Delete('roles/:id') @RequirePermission('role:delete') - async deleteRole(@Param('id') id: string, @Request() req: any) { + async deleteRole(@Param('id', ParseIntPipe) id: number, @Request() req: any) { try { const result = await this.rbacService.deleteRole(+id); await logAudit(this.logService, req, { @@ -118,7 +119,7 @@ export class RbacController { @Put('users/:id') @RequirePermission('user:edit') - async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto, @Request() req: any) { + async updateUser(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto, @Request() req: any) { try { const result = await this.rbacService.updateUser(+id, dto); await logAudit(this.logService, req, { @@ -132,7 +133,7 @@ export class RbacController { @Put('users/:id/password') @RequirePermission('user:reset-password') - async resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto, @Request() req: any) { + async resetPassword(@Param('id', ParseIntPipe) id: number, @Body() dto: ResetPasswordDto, @Request() req: any) { try { const result = await this.rbacService.resetPassword(+id, dto.password); await logAudit(this.logService, req, { @@ -146,7 +147,7 @@ export class RbacController { @Put('users/:id/archive') @RequirePermission('user:edit') - async archiveUser(@Param('id') id: string) { + async archiveUser(@Param('id', ParseIntPipe) id: number) { try { return await this.rbacService.archiveUser(+id); } catch (e: unknown) { @@ -157,7 +158,7 @@ export class RbacController { @Put('users/:id/restore') @RequirePermission('user:edit') - async restoreUser(@Param('id') id: string) { + async restoreUser(@Param('id', ParseIntPipe) id: number) { try { return await this.rbacService.restoreUser(+id); } catch (e: unknown) { @@ -168,7 +169,7 @@ export class RbacController { @Delete('users/:id/permanent') @RequirePermission('user:purge') - async purgeUser(@Param('id') id: string, @Request() req: any) { + async purgeUser(@Param('id', ParseIntPipe) id: number, @Request() req: any) { try { const result = await this.rbacService.purgeUser(+id, req.user?.id); await logAudit(this.logService, req, { @@ -183,7 +184,7 @@ export class RbacController { @Put('users/:id/mark-staff') @RequirePermission('user:edit') - async markAsStaff(@Param('id') id: string) { + async markAsStaff(@Param('id', ParseIntPipe) id: number) { try { return await this.rbacService.markAsStaff(+id); } catch (e: unknown) { @@ -194,7 +195,7 @@ export class RbacController { @Put('users/:id/mark-student') @RequirePermission('user:edit') - async markAsStudent(@Param('id') id: string) { + async markAsStudent(@Param('id', ParseIntPipe) id: number) { try { return await this.rbacService.markAsStudent(+id); } catch (e: unknown) { @@ -205,14 +206,14 @@ export class RbacController { @Get('users/:id/profile') @RequirePermission('user:view') - getUserProfile(@Param('id') id: string) { + getUserProfile(@Param('id', ParseIntPipe) id: number) { return this.rbacService.getUserProfile(+id); } @Put('users/:id/profile') @RequirePermission('user:edit') async updateUserProfile( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateProfileDto, @Request() req: any, ) { @@ -250,7 +251,7 @@ export class RbacController { @Put('teachers/:id/profile') @RequirePermission('teacher:edit') async updateTeacherProfile( - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() profile: UpdateProfileDto, @Request() req: { user?: { id: number; username: string } }, ) { From 5f9566e26df38450fc85388a272546a6b54f2e18 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 16:38:09 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(server):=20=E4=BF=AE=E6=AD=A3=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=E6=88=90=E7=BB=A9=E8=B6=8B=E5=8A=BF=E5=9B=BE=20y=20?= =?UTF-8?q?=E8=BD=B4=E5=9D=90=E6=A0=87=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=8B=BC?= =?UTF-8?q?=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/src/archive/archive-report.exam.spec.ts | 14 ++++++++++++++ apps/server/src/archive/archive-report.exam.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/archive/archive-report.exam.spec.ts diff --git a/apps/server/src/archive/archive-report.exam.spec.ts b/apps/server/src/archive/archive-report.exam.spec.ts new file mode 100644 index 0000000..8ae3c69 --- /dev/null +++ b/apps/server/src/archive/archive-report.exam.spec.ts @@ -0,0 +1,14 @@ +import { renderScoreTrendChart } from './archive-report.exam'; + +describe('renderScoreTrendChart', () => { + it('places the first y-axis label 4px below the top gridline using numeric addition', () => { + const html = renderScoreTrendChart([ + { examType: '文化', score: 60, examDate: '2026-01-01' }, + { examType: '文化', score: 90, examDate: '2026-02-01' }, + { examType: '文化', score: 70, examDate: '2026-03-01' }, + ] as never); + + expect(html).toContain('y="154.0"'); + expect(html).not.toContain('y="150.04"'); + }); +}); diff --git a/apps/server/src/archive/archive-report.exam.ts b/apps/server/src/archive/archive-report.exam.ts index f099c5d..47da985 100644 --- a/apps/server/src/archive/archive-report.exam.ts +++ b/apps/server/src/archive/archive-report.exam.ts @@ -146,7 +146,7 @@ export function renderScoreTrendChart(exams: ExamScore[]): string { for (let i = 0; i <= ySteps; i++) { const val = minScore + (scoreRange * i) / ySteps; const y = scaleY(val); - yLabels += `${val.toFixed(0)}`; + yLabels += `${val.toFixed(0)}`; if (i > 0) { yLabels += ``; } From 8d4ebcf9c001ed3ddb1f10d04147341d124a1663 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 16:38:16 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(admin,server):=20=E4=BF=AE=E5=A4=8D=20D?= =?UTF-8?q?ashboard=20=E7=94=98=E7=89=B9=E5=9B=BE=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF=E4=B8=8E=E6=95=B0=E6=8D=AE=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/schemas/dashboard.integration.test.ts | 44 ++++++++++++++ apps/admin/src/api/schemas/dashboard.ts | 15 ++++- .../src/pages/Dashboard/Dashboard.types.ts | 2 +- .../DashboardCharts.integration.test.ts | 59 +++++++++++++++++++ .../src/pages/Dashboard/DashboardCharts.ts | 30 ++++++---- .../pages/Dashboard/DashboardLazyCards.tsx | 11 ++-- apps/admin/src/pages/Dashboard/index.tsx | 2 +- .../dashboard/dashboard-queries.service.ts | 1 + .../src/dashboard/dashboard.scope.spec.ts | 15 +++++ 9 files changed, 160 insertions(+), 19 deletions(-) create mode 100644 apps/admin/src/api/schemas/dashboard.integration.test.ts create mode 100644 apps/admin/src/pages/Dashboard/DashboardCharts.integration.test.ts diff --git a/apps/admin/src/api/schemas/dashboard.integration.test.ts b/apps/admin/src/api/schemas/dashboard.integration.test.ts new file mode 100644 index 0000000..3204177 --- /dev/null +++ b/apps/admin/src/api/schemas/dashboard.integration.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { ganttRoomsSchema } from './dashboard'; + +describe('ganttRoomsSchema 接口校验', () => { + const validPayload = [ + { + roomNumber: 'A101', + occupancies: [ + { + studentName: '张三', + studentId: 3, + checkInDate: '2026-05-01', + checkOutDate: null, + billingStartDate: '2026-05-01', + billingEndDate: null, + }, + ], + }, + ]; + + it('接受合法的甘特图数据(studentId 为数字)', () => { + expect(ganttRoomsSchema.safeParse(validPayload).success).toBe(true); + }); + + it('拒绝缺少 checkInDate 的入住记录', () => { + const payload = [ + { + roomNumber: 'A101', + occupancies: [{ studentName: '张三', checkOutDate: null }], + }, + ]; + expect(ganttRoomsSchema.safeParse(payload).success).toBe(false); + }); + + it('拒绝缺少 studentName 的入住记录', () => { + const payload = [ + { + roomNumber: 'A101', + occupancies: [{ checkInDate: '2026-05-01', checkOutDate: null }], + }, + ]; + expect(ganttRoomsSchema.safeParse(payload).success).toBe(false); + }); +}); diff --git a/apps/admin/src/api/schemas/dashboard.ts b/apps/admin/src/api/schemas/dashboard.ts index d3dbce3..d5a9c4d 100644 --- a/apps/admin/src/api/schemas/dashboard.ts +++ b/apps/admin/src/api/schemas/dashboard.ts @@ -59,8 +59,19 @@ export const classAttendanceRankingSchema = z export const ganttRoomsSchema = z.array( z - .object({ roomNumber: z.string(), occupancies: z.array(z.record(z.string(), z.unknown())) }) - .passthrough(), + .object({ + roomNumber: z.string(), + occupancies: z.array( + z.object({ + studentName: z.string(), + studentId: z.union([z.string(), z.number()]).optional(), + checkInDate: z.string(), + checkOutDate: z.string().nullable(), + billingStartDate: z.string().optional(), + billingEndDate: z.string().nullable().optional(), + }), + ), + }), ); export const classroomOccupanciesSchema = z.array( diff --git a/apps/admin/src/pages/Dashboard/Dashboard.types.ts b/apps/admin/src/pages/Dashboard/Dashboard.types.ts index c5d1a2c..f41ad1c 100644 --- a/apps/admin/src/pages/Dashboard/Dashboard.types.ts +++ b/apps/admin/src/pages/Dashboard/Dashboard.types.ts @@ -55,7 +55,7 @@ export interface ExpenseByTypeRow { } export interface GanttOccupancy { studentName: string; - studentId?: string; + studentId?: string | number; checkInDate: string; checkOutDate: string | null; billingStartDate?: string; diff --git a/apps/admin/src/pages/Dashboard/DashboardCharts.integration.test.ts b/apps/admin/src/pages/Dashboard/DashboardCharts.integration.test.ts new file mode 100644 index 0000000..374ee5c --- /dev/null +++ b/apps/admin/src/pages/Dashboard/DashboardCharts.integration.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { buildGanttOption } from './DashboardCharts'; +import type { GanttOccupancy } from './Dashboard.types'; + +const ganttRoom = (occupancies: GanttOccupancy[]) => [ + { roomNumber: 'A101', occupancies }, +]; + +describe('buildGanttOption 甘特图时间线', () => { + it('未退宿的入住条在查看过去月份时截断到 periodEnd,而不是画到今天', () => { + const option = buildGanttOption( + ganttRoom([ + { + studentName: '张三', + checkInDate: '2026-05-01', + checkOutDate: null, + }, + ]), + { periodEnd: '2026-06-30', today: '2026-08-07' }, + ); + + const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>; + expect(series[0].data[0].value[2]).toBe('2026-06-30'); + expect(series[0].data[0].value[3]).toBe(true); + }); + + it('未退宿的入住条在查看当前月时截断到今天', () => { + const option = buildGanttOption( + ganttRoom([ + { + studentName: '张三', + checkInDate: '2026-07-01', + checkOutDate: null, + }, + ]), + { periodEnd: '2026-08-31', today: '2026-08-07' }, + ); + + const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>; + expect(series[0].data[0].value[2]).toBe('2026-08-07'); + }); + + it('已退宿的入住条保留真实退宿日期', () => { + const option = buildGanttOption( + ganttRoom([ + { + studentName: '李四', + checkInDate: '2026-05-01', + checkOutDate: '2026-06-15', + }, + ]), + { periodEnd: '2026-06-30', today: '2026-08-07' }, + ); + + const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>; + expect(series[0].data[0].value[2]).toBe('2026-06-15'); + expect(series[0].data[0].value[3]).toBe(false); + }); +}); diff --git a/apps/admin/src/pages/Dashboard/DashboardCharts.ts b/apps/admin/src/pages/Dashboard/DashboardCharts.ts index 641e53d..e2bd906 100644 --- a/apps/admin/src/pages/Dashboard/DashboardCharts.ts +++ b/apps/admin/src/pages/Dashboard/DashboardCharts.ts @@ -1,4 +1,5 @@ import type { EChartsOption } from '../../components/ECharts'; +import dayjs from 'dayjs'; import { attendanceLabelMap, COLORS, @@ -201,7 +202,13 @@ export function buildClassroomHeatmapOption( }; } -export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption { +export function buildGanttOption( + ganttData: GanttRoom[], + options?: { periodEnd?: string; today?: string }, +): EChartsOption { + const today = options?.today ?? dayjs().format('YYYY-MM-DD'); + const periodEnd = options?.periodEnd; + return { tooltip: { formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) => @@ -246,15 +253,18 @@ export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption { }, encode: { x: [1, 2], y: 0 }, data: ganttData.flatMap((r) => - (r.occupancies || []).map((o) => ({ - name: o.studentName, - value: [ - r.roomNumber, - o.checkInDate, - o.checkOutDate || new Date().toISOString().slice(0, 10), - !o.checkOutDate, - ] as [string, string, string, boolean], - })), + (r.occupancies || []).map((o) => { + const activeEnd = periodEnd && periodEnd < today ? periodEnd : today; + return { + name: o.studentName, + value: [ + r.roomNumber, + o.checkInDate, + o.checkOutDate || activeEnd, + !o.checkOutDate, + ] as [string, string, string, boolean], + }; + }), ), }, ], diff --git a/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx b/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx index f2f8554..d7751d2 100644 --- a/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx +++ b/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx @@ -61,10 +61,11 @@ export const ClassroomHeatmapCard: React.FC<{ ); }; -export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({ - data, - isMobile, -}) => { +export const GanttCard: React.FC<{ + data: GanttRoom[]; + isMobile: boolean; + periodEnd?: string; +}> = ({ data, isMobile, periodEnd }) => { const vp = useInViewport('200px'); return ( = ({ > {data.length > 0 ? ( ) : ( diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index db8f3c1..f228f29 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -495,7 +495,7 @@ const DashboardPage: React.FC = () => { {/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */} - +

); }; diff --git a/apps/server/src/dashboard/dashboard-queries.service.ts b/apps/server/src/dashboard/dashboard-queries.service.ts index 5ac20f6..be279ec 100644 --- a/apps/server/src/dashboard/dashboard-queries.service.ts +++ b/apps/server/src/dashboard/dashboard-queries.service.ts @@ -117,6 +117,7 @@ async getGanttData( .leftJoinAndSelect('o.student', 'student') .leftJoinAndSelect('o.room', 'room') .where('room.status != :archived', { archived: 'archived' }) + .andWhere('o.status = :status', { status: 'active' }) .orderBy('room.roomNumber', 'ASC') .addOrderBy('o.checkInDate', 'ASC'); diff --git a/apps/server/src/dashboard/dashboard.scope.spec.ts b/apps/server/src/dashboard/dashboard.scope.spec.ts index 302a528..ba94d2b 100644 --- a/apps/server/src/dashboard/dashboard.scope.spec.ts +++ b/apps/server/src/dashboard/dashboard.scope.spec.ts @@ -6,6 +6,7 @@ const queriesService = (attendanceRepo?: unknown) => const createQb = () => ({ leftJoin: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), addSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), @@ -13,9 +14,11 @@ const createQb = () => ({ groupBy: jest.fn().mockReturnThis(), addGroupBy: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue([]), getRawOne: jest.fn().mockResolvedValue({ cnt: '0' }), + getMany: jest.fn().mockResolvedValue([]), }); describe('DashboardService — teacher class scope', () => { @@ -48,6 +51,18 @@ describe('DashboardService — teacher class scope', () => { }); describe('DashboardService — boundary conditions', () => { + it('excludes archived occupancy records from the gantt timeline', async () => { + const qb = createQb(); + const occRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; + const queries = queriesService(); + + await queries.getGanttData(occRepo as never, () => undefined); + + expect(qb.andWhere).toHaveBeenCalledWith('o.status = :status', { + status: 'active', + }); + }); + it('uses a deny-all predicate instead of an empty SQL IN list', async () => { const qb = createQb(); const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };