Files
gongxue-base/apps/server/src/classrooms/classrooms.controller.ts
wangziqi 1e3d6ee20f refactor(server): 收敛类型边界工具,aislop AI Slop 10 → 1
- 新增 common/buffer.ts bufferToArrayBuffer:9 处
  'as unknown as ArrayBuffer' 收敛为精确切片(含 byteOffset),
  消除潜在 Buffer 池偏移隐患,类型断言集中到单一实现
- 新增 common/stringify.ts:4 处重复的 stringify 助手收敛为共享实现
- aislop: AI Slop 10→1(仅剩 1 处有理由的 stringify 薄包装,
  eslint no-base-to-string 绕过所需);Code Quality 剩余
  4 重复块(声明式 SQL 配置)+ 2 文件过大(既有规模)均保留
- 测试 142 套件/1065 用例通过
2026-08-08 09:48:03 +08:00

241 lines
8.1 KiB
TypeScript

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<string, string | string[] | undefined>;
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();
}
}