import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { ClassroomsService } from './classrooms.service'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import * as ExcelJS from 'exceljs'; import { CLASSROOM_TEMPLATE_COLUMNS } from './classroom-template'; import { bufferToArrayBuffer } from '../common/buffer'; interface AuthenticatedRequest { user?: { id: number; username: string }; headers?: Record; connection?: { remoteAddress?: string }; } /** * exceljs 单元格标量文本。保持原 `String(value || '')` 语义。 * CellValue 联合类型含富文本/超链接对象,实际导入数据均为标量,故在辅助函数内局部豁免。 */ function cellValueText(value: unknown): string { // eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本对象,实际导入数据为标量 return String(value || ''); } @UseGuards(JwtAuthGuard) @Controller('classrooms') export class ClassroomsController { constructor( private service: ClassroomsService, private logService: OperationLogsService, ) {} @Get() @RequirePermission('classroom:view') findAll( @Query('building') building?: string, @Query('roomType') roomType?: string, @Query('includeArchived') includeArchived?: string, ) { return this.service.findAll({ building, roomType, includeArchived: includeArchived === 'true', }); } @Get('template') @RequirePermission('classroom:view') async downloadTemplate(@Res() res: Response) { const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('教室导入模板'); ws.columns = CLASSROOM_TEMPLATE_COLUMNS; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; ws.addRow({ name: 'A201', building: 'A座', floor: 2, roomType: '大', capacity: 60, }); ws.addRow({ name: 'B301', building: 'B座', floor: 3, roomType: '次大', capacity: 40, }); ws.addRow({ name: 'B405', building: 'B座', floor: 4, roomType: '小', capacity: 20, }); // 说明sheet const ws2 = workbook.addWorksheet('使用说明'); ws2.columns = [{ header: '说明', key: 'note', width: 80 }]; ws2.getRow(1).font = { bold: true }; [ '1. 教室名必填,建议采用「楼栋+房号」如 A201、B301', '2. 类型可填 大 / 次大 / 小,为空默认「大」', '3. 同名教室会自动跳过(不覆盖)', ].forEach((note) => ws2.addRow({ note })); res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); res.setHeader('Content-Disposition', 'attachment; filename=classroom_template.xlsx'); await workbook.xlsx.write(res); res.end(); } @Get(':id') @RequirePermission('classroom:view') findOne(@Param('id') id: string) { return this.service.findOne(+id); } @Post() @RequirePermission('classroom:create') async create(@Body() dto: CreateClassroomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto); await logAudit(this.logService, req, { module: '教室', action: '新增教室', targetId: result.id, targetType: 'classroom', detail: dto.name, }); return result; } @Put(':id') @RequirePermission('classroom:edit') async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '教室', action: '编辑教室', targetId: +id, targetType: 'classroom', detail: JSON.stringify(dto), }); return result; } @Delete(':id') @RequirePermission('classroom:delete') async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom', }); return result; } @Delete(':id/permanent') @RequirePermission('classroom:purge') async purge(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '教室', action: '永久删除教室', targetId: +id, targetType: 'classroom', detail: '物理删除,不可恢复', }); return result; } @Put(':id/restore') @RequirePermission('classroom:edit') async restore(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.restore(+id); await logAudit(this.logService, req, { module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom', }); return result; } @Post('import') @RequirePermission('classroom:create') @UseInterceptors(FileInterceptor('file')) async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows: { name: string; building?: string; floor?: number; roomType?: string; capacity?: number; }[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; rows.push({ name: cellValueText(row.getCell(1).value), building: cellValueText(row.getCell(2).value) || undefined, floor: Number(row.getCell(3).value) || undefined, roomType: cellValueText(row.getCell(4).value) || undefined, capacity: Number(row.getCell(5).value) || undefined, }); }); const result = await this.service.batchImport(rows); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '教室', action: '批量导入', detail: result.message, ipAddress, userAgent, }); return result; } @Get('report') @RequirePermission('classroom:view') async exportReport( @Query('dateFrom') dateFrom: string, @Query('dateTo') dateTo: string, @Res() res: Response, ) { const data = await this.service.getUsageReport(dateFrom, dateTo); const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('教室使用统计'); ws.columns = [ { header: '教室名称', key: 'name', width: 20 }, { header: '楼栋', key: 'building', width: 12 }, { header: '类型', key: 'roomType', width: 12 }, { header: '容量', key: 'capacity', width: 8 }, { header: '统计天数', key: 'totalDays', width: 10 }, { header: '租赁占用天数', key: 'rentalDays', width: 14 }, { header: '排课占用天数', key: 'scheduleDays', width: 14 }, { header: '空闲天数', key: 'idleDays', width: 10 }, { header: '占用率', key: 'occupancyRate', width: 10 }, ]; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; for (const row of data) { ws.addRow({ ...row, occupancyRate: `${row.occupancyRate}%` }); } res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', 'attachment; filename=classroom-report.xlsx'); await workbook.xlsx.write(res); res.end(); } }