- 新增 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 用例通过
241 lines
8.1 KiB
TypeScript
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();
|
|
}
|
|
}
|