From 194f25c3ad3b89cfbb042d1b42ffc4b1160df891 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 5 Jul 2026 20:35:11 +0800 Subject: [PATCH] feat: add class roster export and attendance stats export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /classes/:id/roster/export — Excel export with 姓名/学号/加入日期/状态 - GET /attendance-records/export — Excel export with 姓名/班级/日期/时段/状态/来源/备注/打卡时间 - Add findAllForExport to AttendanceService (no pagination) - Both use ExcelJS following classrooms controller downloadTemplate pattern --- .../src/attendance/attendance.controller.ts | 58 +++++++++++++++++++ .../src/attendance/attendance.service.ts | 38 ++++++++++++ apps/server/src/classes/classes.controller.ts | 41 +++++++++++++ 3 files changed, 137 insertions(+) diff --git a/apps/server/src/attendance/attendance.controller.ts b/apps/server/src/attendance/attendance.controller.ts index caf6760..19dbabb 100644 --- a/apps/server/src/attendance/attendance.controller.ts +++ b/apps/server/src/attendance/attendance.controller.ts @@ -7,7 +7,9 @@ import { Query, UseGuards, Request, + Res, } from '@nestjs/common'; +import type { Response } from 'express'; import { AttendanceService } from './attendance.service'; import { BatchCreateAttendanceDto, @@ -21,6 +23,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import * as ExcelJS from 'exceljs'; @UseGuards(JwtAuthGuard) @Controller() @@ -51,6 +54,61 @@ export class AttendanceController { return result; } + // ── Export attendance records ── + @Get('attendance-records/export') + @RequirePermission('attendance:view') + async exportRecords( + @Query() query: QueryAttendanceRecordsDto, + @Res() res: Response, + ) { + const records = await this.service.findAllForExport(query); + + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('考勤统计报表'); + ws.columns = [ + { header: '姓名', key: 'studentName', width: 15 }, + { header: '班级', key: 'className', width: 20 }, + { header: '日期', key: 'attendanceDate', width: 15 }, + { header: '时段', key: 'session', width: 15 }, + { header: '状态', key: 'status', width: 10 }, + { header: '来源', key: 'source', width: 10 }, + { header: '备注', key: 'remark', width: 30 }, + { header: '打卡时间', key: 'createdAt', width: 20 }, + ]; + ws.getRow(1).font = { bold: true }; + ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; + + for (const record of records) { + ws.addRow({ + studentName: record.student?.name || '', + className: record.class?.name || '', + attendanceDate: record.attendanceDate || '', + session: record.session || '', + status: record.status || '', + source: record.source || '', + remark: record.remark || '', + createdAt: record.createdAt + ? record.createdAt.toISOString().replace('T', ' ').substring(0, 19) + : '', + }); + } + + const dateRange = [query.dateFrom, query.dateTo] + .filter(Boolean) + .join('-') || '全部'; + + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader( + 'Content-Disposition', + `attachment; filename=${encodeURIComponent(`考勤统计报表-${dateRange}`)}.xlsx`, + ); + await workbook.xlsx.write(res); + res.end(); + } + // ── List attendance records with filters ── @Get('attendance-records') @RequirePermission('attendance:view') diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index 33ab4c3..8a4a15f 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -218,4 +218,42 @@ export class AttendanceService { record.matchStatus = '已匹配'; return this.dingRawRepo.save(record); } + + // ── Export all attendance records with filters (no pagination) ── + async findAllForExport(query: { + classId?: number; + dateFrom?: string; + dateTo?: string; + session?: string; + status?: string; + source?: string; + }) { + const qb = this.attendanceRepo + .createQueryBuilder('ar') + .leftJoinAndSelect('ar.student', 'student') + .leftJoinAndSelect('ar.class', 'class'); + + if (query.classId) { + qb.andWhere('ar.classId = :classId', { classId: query.classId }); + } + if (query.dateFrom) { + qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); + } + if (query.dateTo) { + qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); + } + if (query.session) { + qb.andWhere('ar.session = :session', { session: query.session }); + } + if (query.status) { + qb.andWhere('ar.status = :status', { status: query.status }); + } + if (query.source) { + qb.andWhere('ar.source = :source', { source: query.source }); + } + + qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC'); + + return qb.getMany(); + } } diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index de13115..3a647c4 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -9,7 +9,9 @@ import { Query, UseGuards, Request, + Res, } from '@nestjs/common'; +import type { Response } from 'express'; import { ClassesService } from './classes.service'; import { CreateClassDto, @@ -22,6 +24,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import * as ExcelJS from 'exceljs'; @UseGuards(JwtAuthGuard) @Controller('classes') @@ -103,6 +106,44 @@ export class ClassesController { return result; } + @Get(':id/roster/export') + @RequirePermission('class:view') + async exportRoster(@Param('id') id: string, @Res() res: Response) { + const classEntity = await this.service.findOne(+id); + const classStudents = await this.service.getStudents(+id); + + const workbook = new ExcelJS.Workbook(); + const ws = workbook.addWorksheet('班级花名册'); + ws.columns = [ + { header: '姓名', key: 'name', width: 15 }, + { header: '学号', key: 'studentNo', width: 15 }, + { header: '加入日期', key: 'joinDate', width: 15 }, + { header: '状态', key: 'status', width: 10 }, + ]; + ws.getRow(1).font = { bold: true }; + ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; + + for (const cs of classStudents) { + ws.addRow({ + name: cs.student?.name || '', + studentNo: cs.student?.idNumber || '', + joinDate: cs.joinDate || '', + status: cs.status || '', + }); + } + + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader( + 'Content-Disposition', + `attachment; filename=${encodeURIComponent(`班级花名册-${classEntity.name}`)}.xlsx`, + ); + await workbook.xlsx.write(res); + res.end(); + } + @Get(':id/students') @RequirePermission('class:view') getStudents(@Param('id') id: string) {