From 8bd445df5a8a808c6e4ed2ecded16167becb33ec Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 15 Jul 2026 10:52:43 +0800 Subject: [PATCH] fix student import and profile labels --- .../StudentProfileContent/index.tsx | 62 +++++- apps/admin/src/pages/Students/index.tsx | 145 ++++++++++--- .../src/students/students.controller.ts | 203 +++++++++++------- apps/server/src/students/students.service.ts | 11 +- 4 files changed, 297 insertions(+), 124 deletions(-) diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index f66f5e6..b360fbe 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -162,6 +162,19 @@ const RECORD_TYPE_OPTIONS = [ { value: 'other', label: '其他' }, ]; +const STUDENT_STATUS_MAP: Record = { + active: { text: '在读', color: 'green' }, + graduated: { text: '已毕业', color: 'blue' }, + withdrawn: { text: '已退训', color: 'red' }, + archived: { text: '已归档', color: '#999' }, +}; + +const ENROLLMENT_STATUS_MAP: Record = { + active: { text: '报读中', color: 'green' }, + completed: { text: '已结课', color: 'blue' }, + withdrawn: { text: '已退训', color: 'red' }, +}; + const COURSE_CATEGORY_OPTIONS = [ { value: 'culture', label: '文化课' }, { value: 'professional', label: '专业课' }, @@ -176,6 +189,34 @@ const CLASS_TYPE_OPTIONS = [ { value: 'offline', label: '线下' }, ]; +const getOptionLabel = ( + options: Array<{ value: string; label: string }>, + value?: string | null, +): string => { + if (!value) return '-'; + return options.find((option) => option.value === value)?.label || value; +}; + +const getCourseCategoryLabel = (value?: string | null): string => + getOptionLabel(COURSE_CATEGORY_OPTIONS, value); + +const getClassTypeLabel = (value?: string | null): string => + getOptionLabel(CLASS_TYPE_OPTIONS, value); + +const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => { + if (!value) return { text: '-', color: 'default' }; + return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' }; +}; + +const getStudentStatus = (value?: string | null): { text: string; color: string } => { + if (!value) return { text: '-', color: 'default' }; + return STUDENT_STATUS_MAP[value] || { text: value, color: 'default' }; +}; + +const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => + enrollment.className || + (enrollment.courseCategory ? getCourseCategoryLabel(enrollment.courseCategory) : String(enrollment.id)); + const ATTACHMENT_CATEGORY_OPTIONS = [ { value: 'id_card', label: '身份证' }, { value: 'transcript', label: '成绩单' }, @@ -350,8 +391,8 @@ const EnrollmentsTab: React.FC = ({ }; const columns: ColumnsType = [ - { title: '课程类别', dataIndex: 'courseCategory', render: (v: string) => v || '-' }, - { title: '班型', dataIndex: 'classType', render: (v: string) => v || '-' }, + { title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel }, + { title: '班型', dataIndex: 'classType', render: getClassTypeLabel }, { title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' }, { title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' }, { title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' }, @@ -361,12 +402,8 @@ const EnrollmentsTab: React.FC = ({ title: '状态', dataIndex: 'status', render: (v: string) => { - const colorMap: Record = { - active: 'green', - completed: 'blue', - withdrawn: 'red', - }; - return {v || '-'}; + const status = getEnrollmentStatus(v); + return {status.text}; }, }, ]; @@ -477,7 +514,7 @@ const ExamScoresTab: React.FC { if (v === undefined) return '-'; const enr = enrollments.find((e) => e.id === v); - return enr ? `${enr.className || enr.courseCategory || v}` : String(v); + return enr ? formatEnrollmentDisplayName(enr) : String(v); }, }, ]; @@ -540,7 +577,7 @@ const ExamScoresTab: React.FC ({ value: e.id, - label: `${e.className || e.courseCategory || e.id} (${e.classType})`, + label: `${formatEnrollmentDisplayName(e)}(${getClassTypeLabel(e.classType)})`, }))} /> @@ -976,7 +1013,10 @@ const StudentProfileContent: React.FC = ({ ) : '-'} - {student.status || '-'} + {(() => { + const status = getStudentStatus(student.status); + return {status.text}; + })()} {profile?.targetCollege && ( {profile.targetCollege} diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 62d3dc8..ca75b6e 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { + Alert, App, Button, Card, @@ -62,6 +63,19 @@ interface EnrollmentInfo { }; } + +interface StudentCreateImportResult { + message?: string; + imported?: number; + skipped?: number; +} + +interface StudentUpdateImportResult { + message?: string; + matched?: number; + skipped?: number; +} + const StudentsPage: React.FC = () => { const { modal } = App.useApp(); const [data, setData] = useState([]); @@ -221,20 +235,94 @@ const StudentsPage: React.FC = () => { .catch(() => message.error('下载失败')); }; - const handleMatchImport: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => { + const showCreateImportResult = (result: StudentCreateImportResult) => { + const imported = result.imported ?? 0; + const skipped = result.skipped ?? 0; + + modal.success({ + title: '导入完成', + okText: '知道了', + content: ( +
+ + {imported} 人 + {skipped} 人 + +
跳过原因:
+
    +
  • 姓名为空
  • +
  • 已存在同名学生
  • +
+
+ 当前后端只返回统计汇总,暂时无法列出具体哪几行被跳过。 +
+
+ ), + }); + }; + + const showUpdateImportResult = (result: StudentUpdateImportResult) => { + const matched = result.matched ?? 0; + const skipped = result.skipped ?? 0; + + modal.success({ + title: '更新完成', + okText: '知道了', + content: ( +
+ + {matched} 人 + {skipped} 人 + +
匹配规则:
+
手机号优先,身份证号其次
+
+ 当前后端只返回统计汇总,暂时无法列出具体哪几行未匹配。 +
+
+ ), + }); + }; + + const handleCreateStudentsImport: UploadProps['customRequest'] = async ({ + file, + onSuccess, + onError, + }) => { + const formData = new FormData(); + formData.append('file', file as File); + try { + const res = (await api.post('/students/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + })) as StudentCreateImportResult; + showCreateImportResult(res); + onSuccess?.(res); + fetchData(); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '导入失败'); + onError?.(e instanceof Error ? e : new Error(err?.message || '导入失败')); + } + }; + + const handleUpdateExistingStudentsImport: UploadProps['customRequest'] = async ({ + file, + onSuccess, + onError, + }) => { const formData = new FormData(); formData.append('file', file as File); try { const res = (await api.post('/students/import-match', formData, { headers: { 'Content-Type': 'multipart/form-data' }, - })) as { message: string }; - message.success(res.message); + })) as StudentUpdateImportResult; + showUpdateImportResult(res); onSuccess?.(res); fetchData(); } catch (e: unknown) { const err = e as { message?: string }; - message.error(err?.message || '匹配导入失败'); - onError?.(e instanceof Error ? e : new Error(err?.message || '匹配导入失败')); + message.error(err?.message || '更新已有学生资料失败'); + onError?.(e instanceof Error ? e : new Error(err?.message || '更新已有学生资料失败')); } }; @@ -277,12 +365,12 @@ const StudentsPage: React.FC = () => { render: (v: string, record: any) => { if (!v) return '-'; return ( - + {maskPhone(v)} + { - const formData = new FormData(); - formData.append('file', file); - try { - const res: any = await api.post('/students/import', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - message.success(res.message); - onSuccess?.(res); - fetchData(); - } catch (e: any) { - message.error(e?.message || '导入失败'); - onError?.(e instanceof Error ? e : new Error(e?.message || '导入失败')); - } - }} + customRequest={handleUpdateExistingStudentsImport} > - - - - + { + + 更新已有学生资料:先按手机号、再按身份证号匹配;Excel + 中填写的非空字段会覆盖原资料,未匹配的学生不会新增。请确认姓名、手机号、身份证号、所属机构和联系人等内容无误。 + + } + /> ({ + ...column, + header: column.key === 'organization' ? '所属机构' : column.header, + })), + { header: '状态', key: 'status', width: 10 }, +]; + +const STUDENT_IMPORT_HEADER_MAP: Record = { + 姓名: '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(); + 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 = {}; + 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 { @@ -92,18 +208,7 @@ export class StudentsController { ); const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('学生名单'); - ws.columns = [ - { header: '姓名', key: 'name', width: 12 }, - { 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 }, - { header: '状态', key: 'status', width: 10 }, - ]; + 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 = { @@ -115,6 +220,7 @@ export class StudentsController { for (const s of students) { ws.addRow({ name: s.name, + studentNo: s.studentNo || '', gender: s.gender || '', phone: s.phone || '', idNumber: s.idNumber || '', @@ -150,24 +256,15 @@ export class StudentsController { async downloadTemplate(@Res() res: Response) { const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('学生导入模板'); - ws.columns = [ - { header: '姓名', key: 'name', width: 15 }, - { header: '电话', key: 'phone', width: 18 }, - { header: '学号/身份证', key: 'idNumber', width: 22 }, - { header: '性别', key: 'gender', width: 8 }, - { 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 }, - ]; + 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: '张三', - phone: '13800138000', - idNumber: '2024001', + studentNo: '2024001', gender: '男', + phone: '13800138000', + idNumber: '11010120060101001X', ethnicity: '汉族', emergencyContact: '张父', emergencyPhone: '13900000000', @@ -288,32 +385,7 @@ export class StudentsController { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as any); const ws = workbook.worksheets[0]; - const rows: { - name: string; - phone?: string; - idNumber?: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organization?: string; - supervisor?: string; - organizationId?: number; - }[] = []; - ws.eachRow((row, idx) => { - if (idx === 1) return; - rows.push({ - name: String(row.getCell(1).value || ''), - phone: String(row.getCell(2).value || ''), - idNumber: String(row.getCell(3).value || ''), - gender: String(row.getCell(4).value || '').trim() || undefined, - ethnicity: String(row.getCell(5).value || '').trim() || undefined, - emergencyContact: String(row.getCell(6).value || '').trim() || undefined, - emergencyPhone: String(row.getCell(7).value || '').trim() || undefined, - organization: String(row.getCell(8).value || '').trim() || undefined, - supervisor: String(row.getCell(9).value || '').trim() || undefined, - }); - }); + const rows = parseStudentImportRows(ws); // Resolve organization names to IDs for (const row of rows) { if (row.organization) { @@ -346,32 +418,7 @@ export class StudentsController { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; - const rows: { - name: string; - phone?: string; - idNumber?: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organization?: string; - supervisor?: string; - organizationId?: number; - }[] = []; - ws.eachRow((row, idx) => { - if (idx === 1) return; - rows.push({ - name: String(row.getCell(1).value || ''), - phone: String(row.getCell(2).value || ''), - idNumber: String(row.getCell(3).value || ''), - gender: String(row.getCell(4).value || '').trim() || undefined, - ethnicity: String(row.getCell(5).value || '').trim() || undefined, - emergencyContact: String(row.getCell(6).value || '').trim() || undefined, - emergencyPhone: String(row.getCell(7).value || '').trim() || undefined, - organization: String(row.getCell(8).value || '').trim() || undefined, - supervisor: String(row.getCell(9).value || '').trim() || undefined, - }); - }); + const rows = parseStudentImportRows(ws); // Resolve organization names to IDs for (const row of rows) { if (row.organization) { @@ -386,7 +433,7 @@ export class StudentsController { userId: req.user?.id, username: req.user?.username, module: '学生管理', - action: '匹配导入学生', + action: '更新已有学生资料', detail: result.message, ipAddress, userAgent, diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 4a3aac6..adf7a86 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -132,6 +132,7 @@ export class StudentsService { async batchImport( rows: { name: string; + studentNo?: string; phone?: string; idNumber?: string; gender?: string; @@ -158,6 +159,7 @@ export class StudentsService { await this.repo.save( this.repo.create({ name: row.name.trim(), + studentNo: row.studentNo?.trim() || undefined, phone: row.phone?.trim() || undefined, idNumber: row.idNumber?.trim() || undefined, gender: row.gender || undefined, @@ -180,6 +182,7 @@ export class StudentsService { async matchImport( rows: { name: string; + studentNo?: string; phone?: string; idNumber?: string; gender?: string; @@ -194,10 +197,6 @@ export class StudentsService { let matched = 0; let skipped = 0; for (const row of rows) { - if (!row.name || !row.name.trim()) { - skipped++; - continue; - } // Match by phone first, then idNumber let student = row.phone?.trim() ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) @@ -214,6 +213,7 @@ export class StudentsService { Pick< Student, | 'name' + | 'studentNo' | 'phone' | 'idNumber' | 'gender' @@ -225,6 +225,7 @@ export class StudentsService { > > = {}; if (row.name?.trim()) updates.name = row.name.trim(); + if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); if (row.phone?.trim()) updates.phone = row.phone.trim(); if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); if (row.gender) updates.gender = row.gender; @@ -237,7 +238,7 @@ export class StudentsService { matched++; } return { - message: `匹配更新 ${matched} 人,跳过 ${skipped} 条(无匹配)`, + message: `更新已有学生资料 ${matched} 人,跳过 ${skipped} 条(无匹配)`, matched, skipped, };