Files
gongxue-base/apps/server/src/students/student-import.ts

326 lines
12 KiB
TypeScript

import * as ExcelJS from 'exceljs';
export interface StudentImportRow {
name: string;
studentNo?: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
organizationId?: number;
targetCollege?: string;
targetMajor?: string;
collegeSchool?: string;
collegeMajor?: string;
subjectDirection?: string;
grade?: string;
profileDate?: string;
notes?: string;
cultureFinalScore?: number;
professionalFinalScore?: number;
admissionStatus?: string;
admittedCollege?: string;
admittedMajor?: string;
}
export interface StudentEnrollmentImportRow {
phone: string;
name?: string;
courseCategory: string;
classType: string;
className?: string;
headTeacher?: string;
subjectTeacher?: string;
startDate?: string;
endDate?: string;
status?: string;
}
export interface ExamScoreImportRow {
phone: string;
name?: string;
examType: string;
examName?: string;
subject: string;
score?: number;
classAvg?: number;
rank?: number;
examDate?: string;
enrollmentName?: string;
}
export interface LearningRecordImportRow {
phone: string;
name?: string;
recordDate: string;
recordType: string;
content: string;
followUpMethod?: string;
nextStep?: string;
}
export interface StudentWorkbookImport {
students: StudentImportRow[];
enrollments: StudentEnrollmentImportRow[];
examScores: ExamScoreImportRow[];
learningRecords: LearningRecordImportRow[];
}
type ColumnDef<T> = {
header: string;
key: keyof T;
width: number;
aliases?: string[];
kind?: 'text' | 'date' | 'number' | 'integer';
};
export const STUDENT_IMPORT_COLUMNS: ColumnDef<StudentImportRow>[] = [
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
{ header: '姓名*', key: 'name', width: 15, aliases: ['姓名'] },
{ header: '学号', key: 'studentNo', width: 15 },
{ header: '性别', key: 'gender', width: 8 },
{ header: '身份证号', key: 'idNumber', width: 22, aliases: ['身份证', '学号/身份证'] },
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构', key: 'organization', width: 18, aliases: ['所属机构名称'] },
{ header: '负责人', key: 'supervisor', width: 15, aliases: ['负责人/班主任'] },
{ header: '目标院校', key: 'targetCollege', width: 18 },
{ header: '目标专业', key: 'targetMajor', width: 22 },
{ header: '大专院校', key: 'collegeSchool', width: 18 },
{ header: '大专专业', key: 'collegeMajor', width: 22 },
{ header: '选科方向', key: 'subjectDirection', width: 14 },
{ header: '年级', key: 'grade', width: 10 },
{ header: '建档日期', key: 'profileDate', width: 14, kind: 'date' },
{ header: '档案备注', key: 'notes', width: 28 },
{ header: '文化课最终分', key: 'cultureFinalScore', width: 14, kind: 'number' },
{ header: '专业课最终分', key: 'professionalFinalScore', width: 14, kind: 'number' },
{ header: '录取状态', key: 'admissionStatus', width: 14 },
{ header: '录取院校', key: 'admittedCollege', width: 18 },
{ header: '录取专业', key: 'admittedMajor', width: 22 },
];
export const STUDENT_EXPORT_COLUMNS = [
...STUDENT_IMPORT_COLUMNS,
];
const ENROLLMENT_IMPORT_COLUMNS: ColumnDef<StudentEnrollmentImportRow>[] = [
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
{ header: '姓名', key: 'name', width: 15 },
{ header: '课程类别*', key: 'courseCategory', width: 14, aliases: ['课程类别'] },
{ header: '班型*', key: 'classType', width: 14, aliases: ['班型'] },
{ header: '班级名称', key: 'className', width: 20 },
{ header: '班主任', key: 'headTeacher', width: 15 },
{ header: '任课教师', key: 'subjectTeacher', width: 15 },
{ header: '开始日期', key: 'startDate', width: 14, kind: 'date' },
{ header: '结束日期', key: 'endDate', width: 14, kind: 'date' },
{ header: '状态', key: 'status', width: 12 },
];
const EXAM_SCORE_IMPORT_COLUMNS: ColumnDef<ExamScoreImportRow>[] = [
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
{ header: '姓名', key: 'name', width: 15 },
{ header: '考试类型*', key: 'examType', width: 14, aliases: ['考试类型'] },
{ header: '考试名称', key: 'examName', width: 20 },
{ header: '科目*', key: 'subject', width: 14, aliases: ['科目'] },
{ header: '成绩*', key: 'score', width: 12, aliases: ['成绩'], kind: 'number' },
{ header: '班级均分', key: 'classAvg', width: 12, kind: 'number' },
{ header: '排名', key: 'rank', width: 10, kind: 'integer' },
{ header: '考试日期', key: 'examDate', width: 14, kind: 'date' },
{ header: '关联报读(班级名)', key: 'enrollmentName', width: 22, aliases: ['关联报读(班级名)'] },
];
const LEARNING_RECORD_IMPORT_COLUMNS: ColumnDef<LearningRecordImportRow>[] = [
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
{ header: '姓名', key: 'name', width: 15 },
{ header: '记录日期*', key: 'recordDate', width: 14, aliases: ['记录日期'], kind: 'date' },
{ header: '记录类型*', key: 'recordType', width: 14, aliases: ['记录类型'] },
{ header: '内容*', key: 'content', width: 36, aliases: ['内容'] },
{ header: '跟进方式', key: 'followUpMethod', width: 14 },
{ header: '下一步计划', key: 'nextStep', width: 28 },
];
function normalizeHeader(header: string): string {
return header.trim().replace(/\*+$/u, '').trim();
}
function getCellPrimitiveValue(cell: ExcelJS.Cell): unknown {
const value = cell.value;
if (value === null || value === undefined) return undefined;
if (value instanceof Date) return value;
if (typeof value === 'object') {
if ('result' in value) return value.result;
if ('text' in value) return value.text;
if ('richText' in value && Array.isArray(value.richText)) {
return value.richText.map((part) => part.text).join('');
}
}
return value;
}
function cellToText(cell: ExcelJS.Cell): string {
const value = getCellPrimitiveValue(cell);
if (value === null || value === undefined) return '';
if (value instanceof Date) return formatDate(value);
return String(value).trim();
}
function formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function excelSerialToDate(serial: number): string | undefined {
if (!Number.isFinite(serial) || serial < 1) return undefined;
const epoch = Date.UTC(1899, 11, 30);
return formatDate(new Date(epoch + serial * 24 * 60 * 60 * 1000));
}
function parseDateText(cell: ExcelJS.Cell): string | undefined {
const value = getCellPrimitiveValue(cell);
if (value instanceof Date) return formatDate(value);
if (typeof value === 'number') return excelSerialToDate(value);
const text = value === null || value === undefined ? '' : String(value).trim();
if (!text) return undefined;
const normalized = text.replace(/[/.]/g, '-');
const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u);
if (!match) return text;
return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`;
}
function parseNumber(cell: ExcelJS.Cell): number | undefined {
const text = cellToText(cell).replace(/,/g, '');
if (!text) return undefined;
const value = Number(text);
return Number.isFinite(value) ? value : undefined;
}
function buildHeaderMap<T extends object>(ws: ExcelJS.Worksheet, columns: ColumnDef<T>[]) {
const columnByHeader = new Map<string, ColumnDef<T>>();
for (const column of columns) {
columnByHeader.set(normalizeHeader(column.header), column);
for (const alias of column.aliases || []) {
columnByHeader.set(normalizeHeader(alias), column);
}
}
const headerIndex = new Map<number, ColumnDef<T>>();
ws.getRow(1).eachCell((cell, colNumber) => {
const column = columnByHeader.get(normalizeHeader(cellToText(cell)));
if (column) headerIndex.set(colNumber, column);
});
return headerIndex;
}
function parseWorksheetRows<T extends object>(
ws: ExcelJS.Worksheet | undefined,
columns: ColumnDef<T>[],
): T[] {
if (!ws) return [];
const headerIndex = buildHeaderMap(ws, columns);
const rows: T[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
const parsed: Partial<Record<keyof T, string | number>> = {};
headerIndex.forEach((column, colNumber) => {
const cell = row.getCell(colNumber);
const value =
column.kind === 'date'
? parseDateText(cell)
: column.kind === 'number' || column.kind === 'integer'
? parseNumber(cell)
: cellToText(cell);
if (value !== undefined && value !== '') {
parsed[column.key] = column.kind === 'integer' && typeof value === 'number'
? Math.trunc(value)
: value;
}
});
if (Object.keys(parsed).length > 0) rows.push(parsed as T);
});
return rows;
}
function findWorksheet(workbook: ExcelJS.Workbook, names: string[], fallbackIndex?: number) {
for (const name of names) {
const sheet = workbook.getWorksheet(name);
if (sheet) return sheet;
}
return fallbackIndex === undefined ? undefined : workbook.worksheets[fallbackIndex];
}
export function parseStudentImportWorkbook(workbook: ExcelJS.Workbook): StudentWorkbookImport {
return {
students: parseWorksheetRows(
findWorksheet(workbook, ['学生基础+档案+录取', '学生导入模板', '学生名单'], 0),
STUDENT_IMPORT_COLUMNS,
).map((row) => ({ ...row, name: row.name || '', phone: row.phone || '' })),
enrollments: parseWorksheetRows(
findWorksheet(workbook, ['报读班型']),
ENROLLMENT_IMPORT_COLUMNS,
),
examScores: parseWorksheetRows(
findWorksheet(workbook, ['考试成绩']),
EXAM_SCORE_IMPORT_COLUMNS,
),
learningRecords: parseWorksheetRows(
findWorksheet(workbook, ['课堂回访']),
LEARNING_RECORD_IMPORT_COLUMNS,
),
};
}
function applyHeaderStyle(ws: ExcelJS.Worksheet) {
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
}
function setupWorksheet<T>(workbook: ExcelJS.Workbook, name: string, columns: ColumnDef<T>[]) {
const ws = workbook.addWorksheet(name);
ws.columns = columns.map(({ header, key, width }) => ({ header, key: String(key), width }));
applyHeaderStyle(ws);
return ws;
}
export function createStudentImportTemplateWorkbook(): ExcelJS.Workbook {
const workbook = new ExcelJS.Workbook();
setupWorksheet(workbook, '学生基础+档案+录取', STUDENT_IMPORT_COLUMNS).addRow({
phone: '13800138000',
name: '张三',
studentNo: '2024001',
gender: '男',
idNumber: '11010120060101001X',
ethnicity: '汉族',
emergencyContact: '张父',
emergencyPhone: '13900139000',
organization: '北京校区',
supervisor: '李老师',
targetCollege: '北京大学',
targetMajor: '计算机科学与技术',
collegeSchool: '北京职业技术学院',
collegeMajor: '软件技术',
subjectDirection: '物化生',
grade: '高三',
profileDate: '2024-09-01',
notes: '学习态度积极,目标明确',
cultureFinalScore: 620,
professionalFinalScore: 580,
admissionStatus: 'pending',
admittedCollege: '',
admittedMajor: '',
});
setupWorksheet(workbook, '报读班型', ENROLLMENT_IMPORT_COLUMNS);
setupWorksheet(workbook, '考试成绩', EXAM_SCORE_IMPORT_COLUMNS);
setupWorksheet(workbook, '课堂回访', LEARNING_RECORD_IMPORT_COLUMNS);
return workbook;
}