feat: expand student archive import template

This commit is contained in:
2026-07-21 15:11:52 +08:00
parent a585fd42d5
commit 90cc0c10b2
13 changed files with 1103 additions and 385 deletions

View File

@@ -30,127 +30,16 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
import type { AuthenticatedUser } from '../authorization';
import * as ExcelJS from 'exceljs';
import {
createStudentImportTemplateWorkbook,
parseStudentImportWorkbook,
STUDENT_EXPORT_COLUMNS,
} from './student-import';
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 {
@@ -218,25 +107,34 @@ export class StudentsController {
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: '已归档',
};
const { profiles, results } = await this.service.getArchiveExportMaps(
students.map((student) => student.id),
);
for (const s of students) {
const profile = profiles.get(s.id);
const result = results.get(s.id);
ws.addRow({
phone: s.phone || '',
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,
targetCollege: profile?.targetCollege || '',
targetMajor: profile?.targetMajor || '',
subjectDirection: profile?.subjectDirection || '',
grade: profile?.grade || '',
profileDate: profile?.profileDate || '',
notes: profile?.notes || '',
cultureFinalScore: result?.cultureFinalScore ?? '',
professionalFinalScore: result?.professionalFinalScore ?? '',
admissionStatus: result?.admissionStatus || '',
admittedCollege: result?.admittedCollege || '',
admittedMajor: result?.admittedMajor || '',
});
}
const { ipAddress, userAgent } = extractRequestInfo(req);
@@ -261,23 +159,7 @@ export class StudentsController {
@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: '',
});
const workbook = createStudentImportTemplateWorkbook();
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
@@ -391,10 +273,9 @@ export class StudentsController {
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);
const importData = parseStudentImportWorkbook(workbook);
// Resolve organization names to IDs
for (const row of rows) {
for (const row of importData.students) {
if (row.organization) {
const organization = await this.organizationRepo.findOne({
where: { name: row.organization },
@@ -404,7 +285,7 @@ export class StudentsController {
}
}
}
const result = await this.service.batchImport(rows);
const result = await this.service.batchImport(importData);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
@@ -424,10 +305,9 @@ export class StudentsController {
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);
const importData = parseStudentImportWorkbook(workbook);
// Resolve organization names to IDs
for (const row of rows) {
for (const row of importData.students) {
if (row.organization) {
const organization = await this.organizationRepo.findOne({
where: { name: row.organization },
@@ -435,7 +315,7 @@ export class StudentsController {
if (organization) row.organizationId = organization.id;
}
}
const result = await this.service.matchImport(rows);
const result = await this.service.matchImport(importData);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,