forked from wangziqi/gongxue-base
450 lines
14 KiB
TypeScript
450 lines
14 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Put,
|
|
Delete,
|
|
Body,
|
|
Param,
|
|
UseGuards,
|
|
Query,
|
|
Request,
|
|
Res,
|
|
UseInterceptors,
|
|
UploadedFile,
|
|
Inject,
|
|
ParseIntPipe,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { Organization } from '../entities/organization.entity';
|
|
import { ClassTeacher } from '../entities/class-teacher.entity';
|
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
import type { Response } from 'express';
|
|
import { StudentsService } from './students.service';
|
|
import { CreateStudentDto, QueryStudentDto, UpdateStudentDto } from './dto/student.dto';
|
|
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 { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
|
import type { AuthenticatedUser } from '../authorization';
|
|
import * as ExcelJS from 'exceljs';
|
|
|
|
interface AuthenticatedRequest {
|
|
user: AuthenticatedUser;
|
|
}
|
|
|
|
|
|
interface StudentImportRow {
|
|
name: string;
|
|
studentNo?: string;
|
|
phone?: string;
|
|
idNumber?: string;
|
|
gender?: string;
|
|
ethnicity?: string;
|
|
emergencyContact?: string;
|
|
emergencyPhone?: string;
|
|
organization?: string;
|
|
supervisor?: string;
|
|
organizationId?: number;
|
|
}
|
|
|
|
const STUDENT_IMPORT_COLUMNS = [
|
|
{ header: '姓名', key: 'name', width: 15 },
|
|
{ header: '学号', key: 'studentNo', width: 15 },
|
|
{ header: '性别', key: 'gender', width: 8 },
|
|
{ header: '电话', key: 'phone', width: 18 },
|
|
{ header: '身份证号', key: 'idNumber', width: 22 },
|
|
{ header: '民族', key: 'ethnicity', width: 10 },
|
|
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
|
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
|
{ header: '所属机构名称', key: 'organization', width: 18 },
|
|
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
|
];
|
|
|
|
const STUDENT_EXPORT_COLUMNS = [
|
|
...STUDENT_IMPORT_COLUMNS.map((column) => ({
|
|
...column,
|
|
header: column.key === 'organization' ? '所属机构' : column.header,
|
|
})),
|
|
{ header: '状态', key: 'status', width: 10 },
|
|
];
|
|
|
|
const STUDENT_IMPORT_HEADER_MAP: Record<string, keyof StudentImportRow> = {
|
|
姓名: 'name',
|
|
学号: 'studentNo',
|
|
电话: 'phone',
|
|
手机号: 'phone',
|
|
'学号/身份证': 'idNumber',
|
|
身份证: 'idNumber',
|
|
身份证号: 'idNumber',
|
|
性别: 'gender',
|
|
民族: 'ethnicity',
|
|
紧急联系人: 'emergencyContact',
|
|
紧急联系人电话: 'emergencyPhone',
|
|
所属机构: 'organization',
|
|
所属机构名称: 'organization',
|
|
负责人: 'supervisor',
|
|
'负责人/班主任': 'supervisor',
|
|
};
|
|
|
|
function getExcelCellText(cell: ExcelJS.Cell): string {
|
|
const value = cell.value;
|
|
if (value === null || value === undefined) return '';
|
|
if (typeof value === 'object') {
|
|
if ('text' in value) return String(value.text || '');
|
|
if ('richText' in value && Array.isArray(value.richText)) {
|
|
return value.richText.map((part) => part.text).join('');
|
|
}
|
|
if ('result' in value) return String(value.result || '');
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function parseStudentImportRows(ws: ExcelJS.Worksheet): StudentImportRow[] {
|
|
const headerIndex = new Map<number, keyof StudentImportRow>();
|
|
ws.getRow(1).eachCell((cell, colNumber) => {
|
|
const header = getExcelCellText(cell).trim();
|
|
const field = STUDENT_IMPORT_HEADER_MAP[header];
|
|
if (field) headerIndex.set(colNumber, field);
|
|
});
|
|
|
|
const rows: StudentImportRow[] = [];
|
|
ws.eachRow((row, idx) => {
|
|
if (idx === 1) return;
|
|
|
|
const parsed: Partial<StudentImportRow> = {};
|
|
if (headerIndex.size > 0) {
|
|
headerIndex.forEach((field, colNumber) => {
|
|
const value = getExcelCellText(row.getCell(colNumber)).trim();
|
|
if (value) {
|
|
Object.assign(parsed, { [field]: value });
|
|
}
|
|
});
|
|
} else {
|
|
parsed.name = getExcelCellText(row.getCell(1)).trim();
|
|
parsed.studentNo = getExcelCellText(row.getCell(2)).trim() || undefined;
|
|
parsed.gender = getExcelCellText(row.getCell(3)).trim() || undefined;
|
|
parsed.phone = getExcelCellText(row.getCell(4)).trim() || undefined;
|
|
parsed.idNumber = getExcelCellText(row.getCell(5)).trim() || undefined;
|
|
parsed.ethnicity = getExcelCellText(row.getCell(6)).trim() || undefined;
|
|
parsed.emergencyContact = getExcelCellText(row.getCell(7)).trim() || undefined;
|
|
parsed.emergencyPhone = getExcelCellText(row.getCell(8)).trim() || undefined;
|
|
parsed.organization = getExcelCellText(row.getCell(9)).trim() || undefined;
|
|
parsed.supervisor = getExcelCellText(row.getCell(10)).trim() || undefined;
|
|
}
|
|
|
|
rows.push({
|
|
name: parsed.name || '',
|
|
studentNo: parsed.studentNo,
|
|
phone: parsed.phone,
|
|
idNumber: parsed.idNumber,
|
|
gender: parsed.gender,
|
|
ethnicity: parsed.ethnicity,
|
|
emergencyContact: parsed.emergencyContact,
|
|
emergencyPhone: parsed.emergencyPhone,
|
|
organization: parsed.organization,
|
|
supervisor: parsed.supervisor,
|
|
});
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller('students')
|
|
export class StudentsController {
|
|
constructor(
|
|
private service: StudentsService,
|
|
private logService: OperationLogsService,
|
|
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
|
|
private authz: AuthorizationService,
|
|
) {}
|
|
|
|
private canManageAllStudents(req: AuthenticatedRequest): boolean {
|
|
return (
|
|
this.authz.can(req, CaslAction.Manage, SubjectName.Student) ||
|
|
// Legacy: class:edit grants broad student access for teacher scoping
|
|
this.authz.can(req, CaslAction.Update, SubjectName.Class)
|
|
);
|
|
}
|
|
|
|
@Get('basic-lookups')
|
|
@RequirePermission('student:basic-view', 'student:view')
|
|
getBasicLookups() {
|
|
return this.service.getBasicLookups();
|
|
}
|
|
|
|
@Get()
|
|
@RequirePermission('student:view')
|
|
async findAll(
|
|
@Query() query: QueryStudentDto,
|
|
@Request() req: AuthenticatedRequest,
|
|
) {
|
|
const classIds = await this.service.getAccessibleClassIds(
|
|
req.user.id,
|
|
this.canManageAllStudents(req),
|
|
);
|
|
return this.service.findAll(
|
|
query,
|
|
classIds,
|
|
);
|
|
}
|
|
|
|
@Get('export')
|
|
@RequirePermission('student:export')
|
|
async exportExcel(
|
|
@Query('includeArchived') includeArchived?: string,
|
|
@Res() res?: Response,
|
|
@Request() req?: any,
|
|
) {
|
|
const classIds = await this.service.getAccessibleClassIds(
|
|
req.user.id,
|
|
this.canManageAllStudents(req),
|
|
);
|
|
const students = await this.service.findAll(
|
|
{ includeArchived: includeArchived === 'true' },
|
|
classIds,
|
|
);
|
|
const workbook = new ExcelJS.Workbook();
|
|
const ws = workbook.addWorksheet('学生名单');
|
|
ws.columns = STUDENT_EXPORT_COLUMNS;
|
|
ws.getRow(1).font = { bold: true };
|
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
|
const statusMap: Record<string, string> = {
|
|
active: '在读',
|
|
graduated: '已毕业',
|
|
withdrawn: '已退训',
|
|
archived: '已归档',
|
|
};
|
|
for (const s of students) {
|
|
ws.addRow({
|
|
name: s.name,
|
|
studentNo: s.studentNo || '',
|
|
gender: s.gender || '',
|
|
phone: s.phone || '',
|
|
idNumber: s.idNumber || '',
|
|
ethnicity: s.ethnicity || '',
|
|
emergencyContact: s.emergencyContact || '',
|
|
emergencyPhone: s.emergencyPhone || '',
|
|
organization: s.organization?.name || '',
|
|
supervisor: s.supervisor || '',
|
|
status: statusMap[s.status] || s.status,
|
|
});
|
|
}
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '学生管理',
|
|
action: '导出学生',
|
|
detail: `导出 ${students.length} 名学生`,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
res!.setHeader(
|
|
'Content-Type',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
);
|
|
res!.setHeader('Content-Disposition', 'attachment; filename=students.xlsx');
|
|
await workbook.xlsx.write(res!);
|
|
res!.end();
|
|
}
|
|
|
|
@Get('template')
|
|
@RequirePermission('student:view')
|
|
async downloadTemplate(@Res() res: Response) {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const ws = workbook.addWorksheet('学生导入模板');
|
|
ws.columns = STUDENT_IMPORT_COLUMNS;
|
|
ws.getRow(1).font = { bold: true };
|
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
|
ws.addRow({
|
|
name: '张三',
|
|
studentNo: '2024001',
|
|
gender: '男',
|
|
phone: '13800138000',
|
|
idNumber: '11010120060101001X',
|
|
ethnicity: '汉族',
|
|
emergencyContact: '张父',
|
|
emergencyPhone: '13900000000',
|
|
organization: 'XX教育公司',
|
|
supervisor: '',
|
|
});
|
|
res.setHeader(
|
|
'Content-Type',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
);
|
|
res.setHeader('Content-Disposition', 'attachment; filename=student_template.xlsx');
|
|
await workbook.xlsx.write(res);
|
|
res.end();
|
|
}
|
|
|
|
@Get(':id')
|
|
@RequirePermission('student:view')
|
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
|
return this.service.findOne(id);
|
|
}
|
|
|
|
@Post()
|
|
@RequirePermission('student:create')
|
|
async create(@Body() dto: CreateStudentDto, @Request() req: any) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const result = await this.service.create(dto);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '学生管理',
|
|
action: '新增学生',
|
|
targetId: result.id,
|
|
targetType: 'student',
|
|
detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Put(':id')
|
|
@RequirePermission('student:edit')
|
|
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const result = await this.service.update(id, dto);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '学生管理',
|
|
action: '编辑学生',
|
|
targetId: id,
|
|
targetType: 'student',
|
|
detail: JSON.stringify(dto),
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Delete(':id')
|
|
@RequirePermission('student:delete')
|
|
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({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '学生管理',
|
|
action: '归档学生',
|
|
targetId: id,
|
|
targetType: 'student',
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Post('batch-delete')
|
|
@RequirePermission('student:delete')
|
|
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const result = await this.service.batchRemove(body.ids || []);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '学生管理',
|
|
action: '批量归档学生',
|
|
detail: `IDs: ${(body.ids || []).join(',')}`,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Put(':id/restore')
|
|
@RequirePermission('student:edit')
|
|
async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const result = await this.service.restore(id);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '学生管理',
|
|
action: '恢复学生',
|
|
targetId: id,
|
|
targetType: 'student',
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Post('import')
|
|
@RequirePermission('student:import')
|
|
@UseInterceptors(FileInterceptor('file'))
|
|
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 as any);
|
|
const ws = workbook.worksheets[0];
|
|
const rows = parseStudentImportRows(ws);
|
|
// Resolve organization names to IDs
|
|
for (const row of rows) {
|
|
if (row.organization) {
|
|
const organization = await this.organizationRepo.findOne({
|
|
where: { name: row.organization },
|
|
});
|
|
if (organization) {
|
|
row.organizationId = organization.id;
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
@Post('import-match')
|
|
@RequirePermission('student:import')
|
|
@UseInterceptors(FileInterceptor('file'))
|
|
async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
const workbook = new ExcelJS.Workbook();
|
|
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
|
const ws = workbook.worksheets[0];
|
|
const rows = parseStudentImportRows(ws);
|
|
// Resolve organization names to IDs
|
|
for (const row of rows) {
|
|
if (row.organization) {
|
|
const organization = await this.organizationRepo.findOne({
|
|
where: { name: row.organization },
|
|
});
|
|
if (organization) row.organizationId = organization.id;
|
|
}
|
|
}
|
|
const result = await this.service.matchImport(rows);
|
|
await this.logService.log({
|
|
userId: req.user?.id,
|
|
username: req.user?.username,
|
|
module: '学生管理',
|
|
action: '更新已有学生资料',
|
|
detail: result.message,
|
|
ipAddress,
|
|
userAgent,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Get(':id/compare-classes')
|
|
@RequirePermission('student:view')
|
|
compareClasses(@Param('id', ParseIntPipe) id: number) {
|
|
return this.service.compareClasses(id);
|
|
}
|
|
}
|