forked from wangziqi/gongxue-base
326 lines
12 KiB
TypeScript
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;
|
|
}
|