feat: 学生档案 Excel 批量导入

- 4-Sheet Excel 模板:学生基础+档案+录取 / 报读班型 / 考试成绩 / 课堂回访
- 手机号精确匹配已有学生,不存在则新建
- POST /archive/import 批量导入接口
- GET /archive/import/template 模板下载接口
- 前端:下载模板 + 上传导入 + 结果汇总弹窗
This commit is contained in:
2026-07-20 15:05:41 +08:00
parent bbd700369c
commit 8494c511b6
4 changed files with 664 additions and 14 deletions

View File

@@ -30,6 +30,10 @@ import {
CloseOutlined,
FileTextOutlined,
ReloadOutlined,
ImportOutlined,
DownloadOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
@@ -918,6 +922,19 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
const [loading, setLoading] = useState(false);
// 批量导入状态
const [importModalOpen, setImportModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<{
students: { created: number; updated: number };
profiles: number;
results: number;
enrollments: number;
examScores: number;
learningRecords: number;
errors: Array<{ sheet: string; row: number; phone: string; reason: string }>;
} | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
@@ -935,6 +952,38 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
void fetchData();
}, [fetchData]);
const handleImport = async (file: File) => {
setImporting(true);
try {
const formData = new FormData();
formData.append('file', file);
const result = await api.post<typeof importResult>('/archive/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setImportResult(result);
setImportModalOpen(true);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '导入失败');
} finally {
setImporting(false);
}
};
const handleDownloadTemplate = async () => {
try {
const blob = await api.get<Blob>('/archive/import/template', { responseType: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '学生档案批量导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
} catch {
message.error('下载模板失败');
}
};
const handlePreviewReport = useCallback(async () => {
try {
const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`);
@@ -1022,24 +1071,38 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
return (
<div>
{inDrawer && (
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Space>
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Space>
{inDrawer && (
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
<span style={{ fontSize: 16, fontWeight: 500 }}>
- {student.name} ({student.studentNo})
</span>
</Space>
<Space>
)}
<span style={{ fontSize: 16, fontWeight: 500 }}>
{inDrawer ? `学员档案 - ${student.name} (${student.studentNo})` : '档案操作'}
</span>
</Space>
<Space>
<Button icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
</Button>
<Upload
showUploadList={false}
accept=".xlsx,.xls"
customRequest={({ file }) => handleImport(file as File)}
>
<Button icon={<ImportOutlined />} loading={importing}>
</Button>
</Upload>
{!inDrawer && (
<Button icon={<FileTextOutlined />} onClick={handlePreviewReport}>
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
</Button>
</Space>
</Row>
)}
)}
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
</Button>
</Space>
</Row>
<Row gutter={16} style={{ marginBottom: 24 }}>
{[
@@ -1101,6 +1164,85 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
</Descriptions>
<Tabs defaultActiveKey="profile" items={tabItems} />
{/* 批量导入结果弹窗 */}
<Modal
title="导入结果"
open={importModalOpen}
onCancel={() => {
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>
</div>
);
};