Merge pull request 'fix: 上传与路由校验加固、甘特图时间线修复' (#63) from fix/quick-wins-and-gantt into main

This commit is contained in:
2026-08-07 08:38:45 +00:00
47 changed files with 302 additions and 152 deletions

View File

@@ -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",

View File

@@ -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<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form);
return res.data;
}

View File

@@ -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);
});
});

View File

@@ -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(

View File

@@ -35,7 +35,6 @@ export const aiChatApi = {
form.append('file', file);
return (
await api.post<AiApiResponse<AiAttachment>>('/ai/chat/attachments', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120_000,
})
).data;

View File

@@ -29,9 +29,7 @@ export const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> =
);
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]] },
);

View File

@@ -196,7 +196,6 @@ export const AttendanceAdminHeader: React.FC<{
<section className="student-class-overview" aria-label="班级考勤汇总">
<div className="student-class-identity">
<div className="student-class-heading">
<span className="student-overview-kicker"></span>
<h2>{selectedClass}</h2>
<p>
{dateLabel} · {visibleStudentCount} / {total}

View File

@@ -148,7 +148,6 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
destroyOnHidden
>
<div className="lesson-record-header">
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
<h2>{displayedSchedule?.subject || '课程考勤'}</h2>
<p>
{className} · {displayedSchedule?.startTime}{displayedSchedule?.endTime} ·{' '}

View File

@@ -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;

View File

@@ -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']] },
);

View File

@@ -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 = () => {
}
>
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
{isInternal ? '📖' : cell.hasContract ? '📄' : ''}
{isInternal ? (
<ReadOutlined style={{ fontSize: 12 }} />
) : cell.hasContract ? (
<FileTextOutlined style={{ fontSize: 12 }} />
) : (
''
)}
</span>
</Tooltip>
)}

View File

@@ -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']] },
);

View File

@@ -55,7 +55,7 @@ export interface ExpenseByTypeRow {
}
export interface GanttOccupancy {
studentName: string;
studentId?: string;
studentId?: string | number;
checkInDate: string;
checkOutDate: string | null;
billingStartDate?: string;

View File

@@ -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);
});
});

View File

@@ -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],
};
}),
),
},
],

View File

@@ -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 (
<LazySection
@@ -74,7 +75,7 @@ export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
>
{data.length > 0 ? (
<ReactECharts
option={buildGanttOption(data)}
option={buildGanttOption(data, { periodEnd })}
style={{ width: '100%', height: isMobile ? 300 : 450 }}
/>
) : (

View File

@@ -495,7 +495,7 @@ const DashboardPage: React.FC = () => {
<ClassroomHeatmapCard data={classroomOccupancy} isMobile={isMobile} />
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
<GanttCard data={ganttData} isMobile={isMobile} />
<GanttCard data={ganttData} isMobile={isMobile} periodEnd={period[1]} />
</div>
);
};

View File

@@ -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(

View File

@@ -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 },
);

View File

@@ -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']] },
);

View File

@@ -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 },
);

View File

@@ -86,7 +86,7 @@ export class AiExcelReaderService {
private async loadWithExcelJs(buffer: Buffer): Promise<ExcelSheetRows[]> {
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[][] = [];

View File

@@ -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"');
});
});

View File

@@ -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 += `<text x="${pad.left - 6}" y="${y.toFixed(1) + 4}" text-anchor="end" fill="#667085" font-size="10">${val.toFixed(0)}</text>`;
yLabels += `<text x="${pad.left - 6}" y="${(y + 4).toFixed(1)}" text-anchor="end" fill="#667085" font-size="10">${val.toFixed(0)}</text>`;
if (i > 0) {
yLabels += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="#eaf3fd" stroke-width="1"/>`;
}

View File

@@ -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');

View File

@@ -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);

View File

@@ -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',

View File

@@ -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) => {

View File

@@ -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);
});
});

View File

@@ -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[];

View File

@@ -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');

View File

@@ -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) };

View File

@@ -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({

View File

@@ -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);
});
});

View File

@@ -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[];

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -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([]);
});
});

View File

@@ -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[];

View File

@@ -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);

View File

@@ -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, {

View File

@@ -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)

View File

@@ -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({

View File

@@ -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 } },
) {

View File

@@ -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;

View File

@@ -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) {

3
package-lock.json generated
View File

@@ -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": {