feat: 导入导出错误信息 Markdown 渲染升级

Phase 1: 共用类型定义
- apps/server/src/common/import-result.types.ts — ImportRowError + ImportResult<T>
- apps/admin/src/types/import.ts — 前端对应类型 + ImportDisplayConfig

Phase 2: 后端增强
- students.service.ts — batchImport/matchImport 改为 ImportResult 格式
  · 每行跳过时收集 ImportRowError(code + reason)
  · reason 支持 markdown 标记(**字段名** / `值`)
- archive.service.ts — batchImportArchive 统一用 ImportResult 格式
  · 各 Sheet 错误使用标准 error code
- archive.controller.ts — 日志使用新的 success 字段

Phase 3: 前端基础
- 安装 react-markdown
- ImportResultModal 组件(共用导入结果弹窗)
  · Statistic 成功统计 + Table 错误明细
  · 原因列支持 react-markdown 渲染(粗体字段名、等宽代码值)
  · 错误类型用 Tag 颜色区分
- api/index.ts — 403 错误从 console.warn 改为 message.warning 用户可见

Phase 4: 前端集成
- Students/index.tsx — 替换硬编码导入结果弹窗
  · 删除'后端只返回统计汇总'注释和跳过原因 hardcode
  · 新格式自动使用 ImportResultModal,旧格式兼容
- StudentProfileContent/index.tsx — 替换内联导入 Modal 为 ImportResultModal
This commit is contained in:
2026-07-20 15:25:14 +08:00
parent 8494c511b6
commit b6d398b102
59 changed files with 11021 additions and 204 deletions

View File

@@ -398,7 +398,7 @@ export class ArchiveController {
username: req.user?.username,
module: '学生档案',
action: '批量导入档案',
detail: `新建学生:${result.students.created} 更新:${result.students.updated} 报读:${result.enrollments} 考试:${result.examScores} 回访:${result.learningRecords} 档案:${result.profiles} 录取:${result.results}`,
detail: `新建:${result.success.students_created} 更新:${result.success.students_updated} 报读:${result.success.enrollments} 考试:${result.success.examScores} 回访:${result.success.learningRecords} 档案:${result.success.profiles} 录取:${result.success.results} 错误:${result.errors.length}`,
ipAddress,
userAgent,
});

View File

@@ -15,6 +15,7 @@ import { ResultArchive } from '../entities/result-archive.entity';
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Organization } from '../entities/organization.entity';
import type { ImportResult, ImportRowError } from '../common/import-result.types';
import {
UpsertProfileDto,
CreateEnrollmentDto,
@@ -356,14 +357,15 @@ export class ArchiveService {
studentsUpdated: number;
profilesUpserted: number;
resultsUpserted: number;
errors: Array<{ row: number; phone: string; reason: string }>;
totalRows: number;
errors: ImportRowError[];
}> {
const rows = this.collectRows(ws);
let studentsCreated = 0;
let studentsUpdated = 0;
let profilesUpserted = 0;
let resultsUpserted = 0;
const errors: Array<{ row: number; phone: string; reason: string }> = [];
const errors: ImportRowError[] = [];
for (const { rowNumber, data } of rows) {
const phone = data['手机号*'];
@@ -415,11 +417,17 @@ export class ArchiveService {
resultsUpserted++;
}
} catch (e: any) {
errors.push({ row: rowNumber, phone, reason: e.message || '未知错误' });
errors.push({
row: rowNumber,
sheet: '学生基础+档案+录取',
identifier: phone,
code: 'INTERNAL_ERROR',
reason: e.message || '未知错误',
});
}
}
return { studentsCreated, studentsUpdated, profilesUpserted, resultsUpserted, errors };
return { studentsCreated, studentsUpdated, profilesUpserted, resultsUpserted, totalRows: rows.length, errors };
}
/** 通过手机号查找学生,不存在则报错 */
@@ -432,11 +440,12 @@ export class ArchiveService {
/** 导入 Sheet2: 报读班型 */
private async importSheet2(ws: ExcelJS.Worksheet): Promise<{
enrollmentsCreated: number;
errors: Array<{ row: number; phone: string; reason: string }>;
totalRows: number;
errors: ImportRowError[];
}> {
const rows = this.collectRows(ws);
let enrollmentsCreated = 0;
const errors: Array<{ row: number; phone: string; reason: string }> = [];
const errors: ImportRowError[] = [];
for (const { rowNumber, data } of rows) {
const phone = data['手机号*'];
@@ -445,7 +454,11 @@ export class ArchiveService {
const courseCategory = data['课程类别*'];
const classType = data['班型*'];
if (!courseCategory || !classType) {
errors.push({ row: rowNumber, phone, reason: '课程类别和班型为必填' });
errors.push({
row: rowNumber, sheet: '报读班型', identifier: phone,
code: 'MISSING_REQUIRED',
reason: `**${!courseCategory ? '课程类别' : '班型'}** 为空,该行已跳过`,
});
continue;
}
const enrollment = this.enrollmentRepo.create({
@@ -462,21 +475,26 @@ export class ArchiveService {
await this.enrollmentRepo.save(enrollment);
enrollmentsCreated++;
} catch (e: any) {
errors.push({ row: rowNumber, phone, reason: e.message || '未知错误' });
errors.push({
row: rowNumber, sheet: '报读班型', identifier: phone,
code: e instanceof BadRequestException ? 'NOT_FOUND' : 'INTERNAL_ERROR',
reason: e.message || '未知错误',
});
}
}
return { enrollmentsCreated, errors };
return { enrollmentsCreated, totalRows: rows.length, errors };
}
/** 导入 Sheet3: 考试成绩 */
private async importSheet3(ws: ExcelJS.Worksheet): Promise<{
examScoresCreated: number;
errors: Array<{ row: number; phone: string; reason: string }>;
totalRows: number;
errors: ImportRowError[];
}> {
const rows = this.collectRows(ws);
let examScoresCreated = 0;
const errors: Array<{ row: number; phone: string; reason: string }> = [];
const errors: ImportRowError[] = [];
for (const { rowNumber, data } of rows) {
const phone = data['手机号*'];
@@ -486,7 +504,12 @@ export class ArchiveService {
const subject = data['科目*'];
const score = data['成绩*'];
if (!examType || !subject || score === undefined) {
errors.push({ row: rowNumber, phone, reason: '考试类型、科目、成绩为必填' });
const missing = [!examType && '考试类型', !subject && '科目', score === undefined && '成绩'].filter(Boolean).join('、');
errors.push({
row: rowNumber, sheet: '考试成绩', identifier: phone,
code: 'MISSING_REQUIRED',
reason: `**${missing}** 为空,该行已跳过`,
});
continue;
}
@@ -514,21 +537,26 @@ export class ArchiveService {
await this.examScoreRepo.save(examScore);
examScoresCreated++;
} catch (e: any) {
errors.push({ row: rowNumber, phone, reason: e.message || '未知错误' });
errors.push({
row: rowNumber, sheet: '考试成绩', identifier: phone,
code: e instanceof BadRequestException ? 'NOT_FOUND' : 'INTERNAL_ERROR',
reason: e.message || '未知错误',
});
}
}
return { examScoresCreated, errors };
return { examScoresCreated, totalRows: rows.length, errors };
}
/** 导入 Sheet4: 课堂回访 */
private async importSheet4(ws: ExcelJS.Worksheet): Promise<{
learningRecordsCreated: number;
errors: Array<{ row: number; phone: string; reason: string }>;
totalRows: number;
errors: ImportRowError[];
}> {
const rows = this.collectRows(ws);
let learningRecordsCreated = 0;
const errors: Array<{ row: number; phone: string; reason: string }> = [];
const errors: ImportRowError[] = [];
for (const { rowNumber, data } of rows) {
const phone = data['手机号*'];
@@ -538,7 +566,12 @@ export class ArchiveService {
const recordType = data['记录类型*'];
const content = data['内容*'];
if (!recordDate || !recordType || !content) {
errors.push({ row: rowNumber, phone, reason: '记录日期、记录类型、内容为必填' });
const missing = [!recordDate && '记录日期', !recordType && '记录类型', !content && '内容'].filter(Boolean).join('、');
errors.push({
row: rowNumber, sheet: '课堂回访', identifier: phone,
code: 'MISSING_REQUIRED',
reason: `**${missing}** 为空,该行已跳过`,
});
continue;
}
@@ -553,27 +586,31 @@ export class ArchiveService {
await this.learningRecordRepo.save(learningRecord);
learningRecordsCreated++;
} catch (e: any) {
errors.push({ row: rowNumber, phone, reason: e.message || '未知错误' });
errors.push({
row: rowNumber, sheet: '课堂回访', identifier: phone,
code: e instanceof BadRequestException ? 'NOT_FOUND' : 'INTERNAL_ERROR',
reason: e.message || '未知错误',
});
}
}
return { learningRecordsCreated, errors };
return { learningRecordsCreated, totalRows: rows.length, errors };
}
/** 批量导入学生档案 Excel */
async batchImportArchive(fileBuffer: Buffer): Promise<{
students: { created: number; updated: number };
async batchImportArchive(fileBuffer: Buffer): Promise<ImportResult<{
students_created: number;
students_updated: number;
profiles: number;
results: number;
enrollments: number;
examScores: number;
learningRecords: number;
errors: Array<{ sheet: string; row: number; phone: string; reason: string }>;
}> {
}>> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(fileBuffer as any);
const errors: Array<{ sheet: string; row: number; phone: string; reason: string }> = [];
const errors: ImportRowError[] = [];
let studentsCreated = 0;
let studentsUpdated = 0;
let profilesUpserted = 0;
@@ -581,18 +618,19 @@ export class ArchiveService {
let enrollmentsCreated = 0;
let examScoresCreated = 0;
let learningRecordsCreated = 0;
let totalRows = 0;
// Sheet1: 学生基础+扩展档案+录取归档
{
const ws = workbook.getWorksheet('学生基础+档案+录取');
if (ws) {
const { studentsCreated: sc, studentsUpdated: su, profilesUpserted: pu, resultsUpserted: ru, errors: errs } =
await this.importSheet1(ws);
studentsCreated += sc;
studentsUpdated += su;
profilesUpserted += pu;
resultsUpserted += ru;
for (const e of errs) errors.push({ sheet: '学生基础+档案+录取', ...e });
const r = await this.importSheet1(ws);
studentsCreated += r.studentsCreated;
studentsUpdated += r.studentsUpdated;
profilesUpserted += r.profilesUpserted;
resultsUpserted += r.resultsUpserted;
errors.push(...r.errors);
totalRows += r.totalRows;
}
}
@@ -600,9 +638,10 @@ export class ArchiveService {
{
const ws = workbook.getWorksheet('报读班型');
if (ws) {
const { enrollmentsCreated: ec, errors: errs } = await this.importSheet2(ws);
enrollmentsCreated += ec;
for (const e of errs) errors.push({ sheet: '报读班型', ...e });
const r = await this.importSheet2(ws);
enrollmentsCreated += r.enrollmentsCreated;
errors.push(...r.errors);
totalRows += r.totalRows;
}
}
@@ -610,9 +649,10 @@ export class ArchiveService {
{
const ws = workbook.getWorksheet('考试成绩');
if (ws) {
const { examScoresCreated: esc, errors: errs } = await this.importSheet3(ws);
examScoresCreated += esc;
for (const e of errs) errors.push({ sheet: '考试成绩', ...e });
const r = await this.importSheet3(ws);
examScoresCreated += r.examScoresCreated;
errors.push(...r.errors);
totalRows += r.totalRows;
}
}
@@ -620,19 +660,24 @@ export class ArchiveService {
{
const ws = workbook.getWorksheet('课堂回访');
if (ws) {
const { learningRecordsCreated: lrc, errors: errs } = await this.importSheet4(ws);
learningRecordsCreated += lrc;
for (const e of errs) errors.push({ sheet: '课堂回访', ...e });
const r = await this.importSheet4(ws);
learningRecordsCreated += r.learningRecordsCreated;
errors.push(...r.errors);
totalRows += r.totalRows;
}
}
return {
students: { created: studentsCreated, updated: studentsUpdated },
profiles: profilesUpserted,
results: resultsUpserted,
enrollments: enrollmentsCreated,
examScores: examScoresCreated,
learningRecords: learningRecordsCreated,
success: {
students_created: studentsCreated,
students_updated: studentsUpdated,
profiles: profilesUpserted,
results: resultsUpserted,
enrollments: enrollmentsCreated,
examScores: examScoresCreated,
learningRecords: learningRecordsCreated,
},
total: totalRows,
errors,
};
}

View File

@@ -0,0 +1,36 @@
/** 单行导入错误 */
export interface ImportRowError {
/** 1-based row number in the spreadsheet (excluding header). */
row: number;
/** Which sheet this error belongs to (for multi-sheet workbooks). */
sheet?: string;
/** Identifier for the row (e.g. phone number, student name) to help the user locate it. */
identifier?: string;
/** The field that caused the error, if applicable. */
field?: string;
/** Machine-readable error code for client-side handling. */
code:
| 'MISSING_REQUIRED'
| 'DUPLICATE_NAME'
| 'DUPLICATE_PHONE'
| 'INVALID_FORMAT'
| 'NOT_FOUND'
| 'ORGANIZATION_NOT_FOUND'
| 'ALREADY_ARCHIVED'
| 'INTERNAL_ERROR'
| 'PERMISSION_DENIED';
/** Human-readable Chinese error message. Supports markdown inline formatting. */
reason: string;
}
/** Generic batch import result. */
export interface ImportResult<T extends Record<string, number> = Record<string, number>> {
/** Success counts grouped by category. */
success: T;
/** Total data rows processed. */
total: number;
/** Per-row errors, sorted by row number. */
errors: ImportRowError[];
/** Optional human-readable summary string. */
message?: string;
}

View File

@@ -9,6 +9,7 @@ import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Organization } from '../entities/organization.entity';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
import type { StudentAccessScope } from './student-access-scope';
import type { ImportResult, ImportRowError } from '../common/import-result.types';
@Injectable()
export class StudentsService {
@@ -199,39 +200,86 @@ export class StudentsService {
supervisor?: string;
organizationId?: number;
}[],
) {
let imported = 0;
let skipped = 0;
for (const row of rows) {
): Promise<ImportResult<{ created: number }>> {
let created = 0;
const errors: ImportRowError[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 1; // 1-based for user display
const identifier = row.phone || row.name || '';
// Empty name
if (!row.name || !row.name.trim()) {
skipped++;
errors.push({
row: rowNum,
identifier,
field: '姓名',
code: 'MISSING_REQUIRED',
reason: `**姓名** 为空,该行已跳过`,
});
continue;
}
// Duplicate by name
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
if (exists) {
skipped++;
errors.push({
row: rowNum,
identifier: row.name.trim(),
field: '姓名',
code: 'DUPLICATE_NAME',
reason: `学生 \`${row.name.trim()}\` 已存在ID: ${exists.id}),该行已跳过`,
});
continue;
}
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,
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
supervisor: row.supervisor || undefined,
organizationId: row.organizationId || (await this.getHostOrganizationId()),
}),
);
imported++;
// Check phone uniqueness if provided
if (row.phone?.trim()) {
const phoneExists = await this.repo.findOne({ where: { phone: row.phone.trim() } });
if (phoneExists) {
errors.push({
row: rowNum,
identifier: row.phone.trim(),
field: '手机号',
code: 'DUPLICATE_PHONE',
reason: `手机号 \`${row.phone.trim()}\` 已被学生 **${phoneExists.name}**ID: ${phoneExists.id})使用`,
});
continue;
}
}
try {
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,
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
supervisor: row.supervisor || undefined,
organizationId: row.organizationId || (await this.getHostOrganizationId()),
}),
);
created++;
} catch (e: any) {
errors.push({
row: rowNum,
identifier,
code: 'INTERNAL_ERROR',
reason: e.message || '保存失败,未知错误',
});
}
}
return {
message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`,
imported,
skipped,
success: { created },
total: rows.length,
errors,
message: `新建 ${created} 名学生,跳过 ${errors.length}`,
};
}
@@ -249,10 +297,15 @@ export class StudentsService {
supervisor?: string;
organizationId?: number;
}[],
) {
let matched = 0;
let skipped = 0;
for (const row of rows) {
): Promise<ImportResult<{ updated: number }>> {
let updated = 0;
const errors: ImportRowError[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 1;
const identifier = row.phone || row.idNumber || row.name || '';
// Match by phone first, then idNumber
let student = row.phone?.trim()
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
@@ -261,42 +314,50 @@ export class StudentsService {
student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } });
}
if (!student) {
skipped++;
errors.push({
row: rowNum,
identifier,
code: 'NOT_FOUND',
reason: `手机号 \`${row.phone || '—'}\` 或身份证号 \`${row.idNumber || '—'}\` 未匹配到任何学生`,
});
continue;
}
// Update matched student with non-empty imported fields
const updates: Partial<
Pick<
Student,
| 'name'
| 'studentNo'
| 'phone'
| 'idNumber'
| 'gender'
| 'ethnicity'
| 'emergencyContact'
| 'emergencyPhone'
| 'supervisor'
| 'organizationId'
>
> = {};
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;
if (row.ethnicity) updates.ethnicity = row.ethnicity;
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.organizationId) updates.organizationId = row.organizationId;
await this.repo.update(student.id, updates);
matched++;
try {
const updates: Partial<
Pick<
Student,
'name' | 'studentNo' | 'phone' | 'idNumber' | 'gender'
| 'ethnicity' | 'emergencyContact' | 'emergencyPhone' | 'supervisor' | 'organizationId'
>
> = {};
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;
if (row.ethnicity) updates.ethnicity = row.ethnicity;
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.organizationId) updates.organizationId = row.organizationId;
await this.repo.update(student.id, updates);
updated++;
} catch (e: any) {
errors.push({
row: rowNum,
identifier: `ID:${student.id} ${student.name}`,
code: 'INTERNAL_ERROR',
reason: e.message || '更新失败,未知错误',
});
}
}
return {
message: `更新已有学生资料 ${matched} 人,跳过 ${skipped} 条(无匹配)`,
matched,
skipped,
success: { updated },
total: rows.length,
errors,
message: `更新 ${updated} 名学生资料,跳过 ${errors.length}`,
};
}