feat: 学生档案 Excel 批量导入
- 4-Sheet Excel 模板:学生基础+档案+录取 / 报读班型 / 考试成绩 / 课堂回访 - 手机号精确匹配已有学生,不存在则新建 - POST /archive/import 批量导入接口 - GET /archive/import/template 模板下载接口 - 前端:下载模板 + 上传导入 + 结果汇总弹窗
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user