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

@@ -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"
},

View File

@@ -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);
},

View 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;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>;
}