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:
@@ -21,6 +21,7 @@
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { type AxiosRequestConfig } from 'axios';
|
||||
import { message } from '../ui/app-message';
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -26,7 +27,8 @@ instance.interceptors.response.use(
|
||||
}
|
||||
if (err.response?.status === 403) {
|
||||
const msg = err.response?.data?.message || '权限不足';
|
||||
console.warn('[403]', msg);
|
||||
// 延迟调用确保 messageApi 已在 AppMessageBridge 中绑定
|
||||
setTimeout(() => message.warning(msg), 0);
|
||||
}
|
||||
return Promise.reject(err.response?.data || err);
|
||||
},
|
||||
|
||||
168
apps/admin/src/components/ImportResultModal/index.tsx
Normal file
168
apps/admin/src/components/ImportResultModal/index.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Modal, Row, Col, Statistic, Table, Button, Tag } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { ImportResult, ImportRowError, ImportDisplayConfig } from '../../types/import';
|
||||
|
||||
export interface ImportResultModalProps<T extends Record<string, number> = Record<string, number>> {
|
||||
result: ImportResult<T> | null;
|
||||
config: ImportDisplayConfig;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDone?: () => void;
|
||||
}
|
||||
|
||||
const ERROR_COLOR_MAP: Record<string, string> = {
|
||||
MISSING_REQUIRED: '#fa8c16',
|
||||
DUPLICATE_NAME: '#fa8c16',
|
||||
DUPLICATE_PHONE: '#fa8c16',
|
||||
INVALID_FORMAT: '#fa8c16',
|
||||
NOT_FOUND: '#ff4d4f',
|
||||
ORGANIZATION_NOT_FOUND: '#ff4d4f',
|
||||
ALREADY_ARCHIVED: '#999',
|
||||
INTERNAL_ERROR: '#ff4d4f',
|
||||
PERMISSION_DENIED: '#ff4d4f',
|
||||
};
|
||||
|
||||
const ERROR_SEVERITY_TAG: Record<string, { text: string; color: string }> = {
|
||||
MISSING_REQUIRED: { text: '缺少必填', color: 'orange' },
|
||||
DUPLICATE_NAME: { text: '重复', color: 'orange' },
|
||||
DUPLICATE_PHONE: { text: '重复', color: 'orange' },
|
||||
INVALID_FORMAT: { text: '格式错误', color: 'orange' },
|
||||
NOT_FOUND: { text: '未匹配', color: 'red' },
|
||||
ORGANIZATION_NOT_FOUND: { text: '机构未匹配', color: 'red' },
|
||||
INTERNAL_ERROR: { text: '系统错误', color: 'red' },
|
||||
PERMISSION_DENIED: { text: '权限不足', color: 'red' },
|
||||
ALREADY_ARCHIVED: { text: '已归档', color: 'default' },
|
||||
};
|
||||
|
||||
function ImportResultModal<T extends Record<string, number> = Record<string, number>>({
|
||||
result,
|
||||
config,
|
||||
open,
|
||||
onClose,
|
||||
onDone,
|
||||
}: ImportResultModalProps<T>) {
|
||||
const hasErrors = result && result.errors.length > 0;
|
||||
|
||||
const errorColumns: ColumnsType<ImportRowError> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '行号',
|
||||
dataIndex: 'row',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '标识',
|
||||
dataIndex: 'identifier',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'code',
|
||||
width: 90,
|
||||
render: (code: string) => {
|
||||
const tag = ERROR_SEVERITY_TAG[code] || { text: code, color: 'default' };
|
||||
return <Tag color={tag.color}>{tag.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '原因',
|
||||
dataIndex: 'reason',
|
||||
render: (reason: string) => (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <span>{children}</span>,
|
||||
strong: ({ children }) => (
|
||||
<span style={{ fontWeight: 600, color: '#ff4d4f' }}>{children}</span>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: '1px 4px',
|
||||
borderRadius: 3,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{reason}
|
||||
</ReactMarkdown>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
onDone?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={config.title}
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={
|
||||
<Button type="primary" onClick={handleClose}>
|
||||
确定
|
||||
</Button>
|
||||
}
|
||||
width={680}
|
||||
>
|
||||
{result && (
|
||||
<div>
|
||||
{/* 成功统计 */}
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
{Object.entries(config.successLabelMap).map(([key, label]) => {
|
||||
const value = (result.success as Record<string, number>)[key];
|
||||
if (value === undefined) return null;
|
||||
return (
|
||||
<Col key={key} span={Math.min(8, 24 / Math.max(1, Object.keys(config.successLabelMap).length))}>
|
||||
<Statistic title={label} value={value} valueStyle={{ fontSize: 18, color: '#1677ff' }} />
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{hasErrors ? (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500, color: '#ff4d4f' }}>
|
||||
<CloseCircleOutlined style={{ marginRight: 4 }} />
|
||||
失败 {result.errors.length} 条(共 {result.total} 行)
|
||||
</div>
|
||||
<Table<ImportRowError>
|
||||
dataSource={result.errors}
|
||||
rowKey={(_, i) => String(i)}
|
||||
columns={errorColumns}
|
||||
size="small"
|
||||
pagination={result.errors.length > 10 ? { defaultPageSize: 10, showSizeChanger: true } : false}
|
||||
scroll={{ x: 600 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#52c41a', fontSize: 15, padding: '12px 0' }}>
|
||||
<CheckCircleOutlined style={{ marginRight: 6 }} />
|
||||
全部导入成功!共 {result.total} 行数据,无错误。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportResultModal;
|
||||
@@ -40,6 +40,8 @@ import api from '../../api';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import { useViewSensitive } from '../../hooks/useViewSensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
import ImportResultModal from '../ImportResultModal';
|
||||
import type { ImportResult } from '../../types/import';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -925,15 +927,15 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
// 批量导入状态
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
students: { created: number; updated: number };
|
||||
const [importResult, setImportResult] = useState<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 }>;
|
||||
} | null>(null);
|
||||
}> | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -957,7 +959,15 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const result = await api.post<typeof importResult>('/archive/import', formData, {
|
||||
const result = await api.post<ImportResult<{
|
||||
students_created: number;
|
||||
students_updated: number;
|
||||
profiles: number;
|
||||
results: number;
|
||||
enrollments: number;
|
||||
examScores: number;
|
||||
learningRecords: number;
|
||||
}>>('/archive/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
setImportResult(result);
|
||||
@@ -1166,83 +1176,27 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
<Tabs defaultActiveKey="profile" items={tabItems} />
|
||||
|
||||
{/* 批量导入结果弹窗 */}
|
||||
<Modal
|
||||
title="导入结果"
|
||||
<ImportResultModal
|
||||
result={importResult as any}
|
||||
config={{
|
||||
title: '学生档案批量导入结果',
|
||||
successLabelMap: {
|
||||
students_created: '新建学生',
|
||||
students_updated: '更新学生',
|
||||
profiles: '扩展档案',
|
||||
results: '录取归档',
|
||||
enrollments: '报读班型',
|
||||
examScores: '考试成绩',
|
||||
learningRecords: '课堂回访',
|
||||
},
|
||||
}}
|
||||
open={importModalOpen}
|
||||
onCancel={() => {
|
||||
onClose={() => {
|
||||
setImportModalOpen(false);
|
||||
setImportResult(null);
|
||||
fetchData();
|
||||
}}
|
||||
footer={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setImportModalOpen(false);
|
||||
setImportResult(null);
|
||||
fetchData();
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
}
|
||||
width={560}
|
||||
>
|
||||
{importResult && (
|
||||
<div>
|
||||
<Row gutter={[16, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="学生"
|
||||
value={`新建 ${importResult.students.created} / 更新 ${importResult.students.updated}`}
|
||||
valueStyle={{ fontSize: 16 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="扩展档案" value={importResult.profiles} valueStyle={{ fontSize: 16 }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="录取归档" value={importResult.results} valueStyle={{ fontSize: 16 }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="报读班型" value={importResult.enrollments} valueStyle={{ fontSize: 16 }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="考试成绩" value={importResult.examScores} valueStyle={{ fontSize: 16 }} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="课堂回访" value={importResult.learningRecords} valueStyle={{ fontSize: 16 }} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{importResult.errors.length > 0 ? (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>
|
||||
<CloseCircleOutlined style={{ color: '#ff4d4f', marginRight: 4 }} />
|
||||
失败 {importResult.errors.length} 条
|
||||
</div>
|
||||
<Table
|
||||
dataSource={importResult.errors}
|
||||
rowKey={(_, i) => String(i)}
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: 'Sheet', dataIndex: 'sheet', width: 140 },
|
||||
{ title: '行号', dataIndex: 'row', width: 60 },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||
{ title: '原因', dataIndex: 'reason' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#52c41a' }}>
|
||||
<CheckCircleOutlined style={{ marginRight: 4 }} />
|
||||
全部导入成功!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
onDone={fetchData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||
import ImportResultModal from '../../components/ImportResultModal';
|
||||
import type { ImportResult } from '../../types/import';
|
||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
@@ -66,12 +68,20 @@ interface StudentCreateImportResult {
|
||||
message?: string;
|
||||
imported?: number;
|
||||
skipped?: number;
|
||||
// 新格式(后端增强后使用)
|
||||
success?: { created: number };
|
||||
total?: number;
|
||||
errors?: Array<{ row: number; identifier?: string; code: string; reason: string }>;
|
||||
}
|
||||
|
||||
interface StudentUpdateImportResult {
|
||||
message?: string;
|
||||
matched?: number;
|
||||
skipped?: number;
|
||||
// 新格式(后端增强后使用)
|
||||
success?: { updated: number };
|
||||
total?: number;
|
||||
errors?: Array<{ row: number; identifier?: string; code: string; reason: string }>;
|
||||
}
|
||||
|
||||
interface StudentFilterLookups {
|
||||
@@ -251,10 +261,20 @@ const StudentsPage: React.FC = () => {
|
||||
.catch(() => message.error('下载失败'));
|
||||
};
|
||||
|
||||
const [importResult, setImportResult] = useState<{
|
||||
result: StudentCreateImportResult | StudentUpdateImportResult;
|
||||
isCreate: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const showCreateImportResult = (result: StudentCreateImportResult) => {
|
||||
// 新格式:有 errors 数组则用 ImportResultModal
|
||||
if (result.errors || result.success) {
|
||||
setImportResult({ result, isCreate: true });
|
||||
return;
|
||||
}
|
||||
// 旧格式兼容:无 errors 时用简单对话框
|
||||
const imported = result.imported ?? 0;
|
||||
const skipped = result.skipped ?? 0;
|
||||
|
||||
modal.success({
|
||||
title: '导入完成',
|
||||
okText: '知道了',
|
||||
@@ -264,23 +284,18 @@ const StudentsPage: React.FC = () => {
|
||||
<Descriptions.Item label="成功新增">{imported} 人</Descriptions.Item>
|
||||
<Descriptions.Item label="跳过">{skipped} 人</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div style={{ marginTop: 12, fontWeight: 600 }}>跳过原因:</div>
|
||||
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
|
||||
<li>姓名为空</li>
|
||||
<li>已存在同名学生</li>
|
||||
</ul>
|
||||
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
|
||||
当前后端只返回统计汇总,暂时无法列出具体哪几行被跳过。
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const showUpdateImportResult = (result: StudentUpdateImportResult) => {
|
||||
if (result.errors || result.success) {
|
||||
setImportResult({ result, isCreate: false });
|
||||
return;
|
||||
}
|
||||
const matched = result.matched ?? 0;
|
||||
const skipped = result.skipped ?? 0;
|
||||
|
||||
modal.success({
|
||||
title: '更新完成',
|
||||
okText: '知道了',
|
||||
@@ -290,11 +305,6 @@ const StudentsPage: React.FC = () => {
|
||||
<Descriptions.Item label="成功更新">{matched} 人</Descriptions.Item>
|
||||
<Descriptions.Item label="未匹配">{skipped} 人</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div style={{ marginTop: 12, fontWeight: 600 }}>匹配规则:</div>
|
||||
<div>手机号优先,身份证号其次</div>
|
||||
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
|
||||
当前后端只返回统计汇总,暂时无法列出具体哪几行未匹配。
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
@@ -875,6 +885,31 @@ const StudentsPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* 导入结果弹窗(使用新格式时) */}
|
||||
{importResult && (
|
||||
<ImportResultModal
|
||||
result={{
|
||||
success: (importResult.result.success || {
|
||||
[importResult.isCreate ? 'created' : 'updated']:
|
||||
(importResult.isCreate
|
||||
? (importResult.result as StudentCreateImportResult).imported
|
||||
: (importResult.result as StudentUpdateImportResult).matched) ?? 0,
|
||||
}) as Record<string, number>,
|
||||
total: importResult.result.total ?? 0,
|
||||
errors: importResult.result.errors ?? [],
|
||||
}}
|
||||
config={{
|
||||
title: importResult.isCreate ? '新建学生导入结果' : '更新学生导入结果',
|
||||
successLabelMap: importResult.isCreate
|
||||
? { created: '新建' }
|
||||
: { updated: '更新' },
|
||||
}}
|
||||
open={!!importResult}
|
||||
onClose={() => setImportResult(null)}
|
||||
onDone={fetchData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
25
apps/admin/src/types/import.ts
Normal file
25
apps/admin/src/types/import.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** 单行导入错误 — 与后端 ImportRowError 结构一致 */
|
||||
export interface ImportRowError {
|
||||
row: number;
|
||||
sheet?: string;
|
||||
identifier?: string;
|
||||
field?: string;
|
||||
code: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** 通用批量导入结果 — 与后端 ImportResult<T> 结构一致 */
|
||||
export interface ImportResult<T extends Record<string, number> = Record<string, number>> {
|
||||
success: T;
|
||||
total: number;
|
||||
errors: ImportRowError[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** ImportResultModal 的展示配置 */
|
||||
export interface ImportDisplayConfig {
|
||||
/** Modal 标题 */
|
||||
title: string;
|
||||
/** 将后端的 success key 映射为中文标签。例:{ created: '新建', updated: '更新' } */
|
||||
successLabelMap: Record<string, string>;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
36
apps/server/src/common/import-result.types.ts
Normal file
36
apps/server/src/common/import-result.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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} 条`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user