feat: 新增通用导入中心

This commit is contained in:
2026-08-05 17:11:39 +08:00
parent e9c8a1085d
commit 0c94ca54df
20 changed files with 3355 additions and 0 deletions

View File

@@ -0,0 +1,616 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CheckCircleOutlined,
CloseCircleOutlined,
DownloadOutlined,
InboxOutlined,
ReloadOutlined,
StepForwardOutlined,
} from '@ant-design/icons';
import {
Alert,
Button,
Checkbox,
Descriptions,
Flex,
Modal,
Select,
Space,
Spin,
Steps,
Table,
Tag,
Tooltip,
Typography,
Upload,
} from 'antd';
import type { UploadProps } from 'antd';
import { message } from '../../ui/app-message';
import { useUserStore } from '../../store/user/userStore';
import {
commitImportStep,
createImportRun,
getImportRun,
importErrorReportUrl,
previewImportStep,
} from '../../api/imports';
import {
STEP_FIELDS,
type ImportPreviewResult,
type ImportReceipt,
type ImportRunDetail,
type ImportStepKey,
} from './types';
interface ImportWizardModalProps {
open: boolean;
/** AI 对话生成的导入任务;为空时向导从上传文件开始。 */
runId?: string | null;
onClose: () => void;
}
type RowAction = 'create' | 'update' | 'skip';
const ACTION_META: Record<RowAction, { label: string; color: string }> = {
create: { label: '新建', color: 'blue' },
update: { label: '更新', color: 'orange' },
skip: { label: '跳过', color: 'default' },
};
function guessMapping(stepKey: ImportStepKey, headers: string[]): Record<string, string> {
const mapping: Record<string, string> = {};
for (const field of STEP_FIELDS[stepKey]) {
const hit = headers.find((header) => {
const normalizedHeader = header.replace(/[\s()]/g, '').toLowerCase();
const normalizedLabel = field.label.replace(/[\s()]/g, '').toLowerCase();
return (
normalizedHeader === normalizedLabel ||
normalizedHeader.includes(normalizedLabel) ||
normalizedLabel.includes(normalizedHeader)
);
});
if (hit) mapping[field.key] = hit;
}
return mapping;
}
function keyInfo(stepKey: ImportStepKey, row: ImportPreviewResult['rows'][number]): string {
const fields = row.fields;
if (stepKey === 'students') {
return [fields.name, fields.studentNo, fields.phone].filter(Boolean).join(' / ');
}
if (stepKey === 'rooms') {
return [fields.roomNumber, fields.building, fields.floor].filter(Boolean).join(' / ');
}
if (stepKey === 'checkins') {
return [fields.name, fields.studentNo, fields.phone, fields.roomNumber, fields.checkInDate]
.filter(Boolean)
.join(' / ');
}
return [fields.studentNo, fields.phone, fields.oldRoom, fields.newRoom, fields.transferDate]
.filter(Boolean)
.join(' / ');
}
async function downloadErrorReport(runId: string, stepKey?: ImportStepKey): Promise<void> {
const token = useUserStore.getState().token;
const response = await fetch(importErrorReportUrl(runId, stepKey), {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!response.ok) throw new Error('错误报告下载失败');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `导入错误报告-${runId.slice(0, 8)}.csv`;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export const ImportWizardModal: React.FC<ImportWizardModalProps> = ({
open,
runId: initialRunId,
onClose,
}) => {
const [run, setRun] = useState<ImportRunDetail | null>(null);
const [loadingRun, setLoadingRun] = useState(false);
const [uploading, setUploading] = useState(false);
const [activeStepKey, setActiveStepKey] = useState<ImportStepKey | null>(null);
const [sheetSelection, setSheetSelection] = useState<Record<string, string[]>>({});
const [mappingDraft, setMappingDraft] = useState<Record<string, Record<string, string>>>({});
const [previewByStep, setPreviewByStep] = useState<Record<string, ImportPreviewResult>>({});
const [previewLoading, setPreviewLoading] = useState(false);
const [onlyErrors, setOnlyErrors] = useState(false);
const [rowActions, setRowActions] = useState<Record<number, RowAction>>({});
const [commitLoading, setCommitLoading] = useState(false);
const [receipt, setReceipt] = useState<ImportReceipt | null>(null);
const [reportDownloading, setReportDownloading] = useState(false);
const requestSeq = useRef(0);
const loadRun = useCallback(async (runId: string) => {
const seq = ++requestSeq.current;
setLoadingRun(true);
try {
const detail = await getImportRun(runId);
if (seq !== requestSeq.current) return;
setRun(detail);
const selections: Record<string, string[]> = {};
const mappings: Record<string, Record<string, string>> = {};
const sheetHeaders = new Map(detail.sheets.map((sheet) => [sheet.name, sheet.headers]));
for (const step of detail.steps) {
selections[step.stepKey] = step.sheets;
const mapped =
step.mapping && Object.keys(step.mapping).length > 0
? step.mapping
: guessMapping(step.stepKey, sheetHeaders.get(step.sheets[0] ?? '') ?? []);
mappings[step.stepKey] = mapped;
}
setSheetSelection(selections);
setMappingDraft(mappings);
setPreviewByStep({});
setRowActions({});
setReceipt(null);
const firstActive =
detail.steps.find((step) => step.status !== 'skipped' && step.status !== 'committed') ??
detail.steps.find((step) => step.status !== 'skipped');
setActiveStepKey(firstActive?.stepKey ?? detail.currentStepKey);
} catch (error) {
if (seq !== requestSeq.current) return;
message.error(error instanceof Error ? error.message : '导入任务加载失败');
setRun(null);
} finally {
if (seq === requestSeq.current) setLoadingRun(false);
}
}, []);
useEffect(() => {
if (!open || !initialRunId) return;
void loadRun(initialRunId);
}, [open, initialRunId, loadRun]);
const activeStep = useMemo(
() => run?.steps.find((step) => step.stepKey === activeStepKey) ?? null,
[run, activeStepKey],
);
const preview = activeStepKey ? (previewByStep[activeStepKey] ?? null) : null;
const sheetOptions = useMemo(() => (run?.sheets ?? []).map((sheet) => sheet.name), [run]);
const headerOptions = useMemo(() => {
if (!run || !activeStepKey) return [];
const names = sheetSelection[activeStepKey] ?? [];
const headers = new Set<string>();
for (const sheet of run.sheets) {
if (names.includes(sheet.name)) sheet.headers.forEach((header) => headers.add(header));
}
return [...headers];
}, [run, activeStepKey, sheetSelection]);
const handleUpload: UploadProps['customRequest'] = async (options) => {
const file = options.file as File;
setUploading(true);
try {
const detail = await createImportRun(file, { source: 'manual' });
await loadRun(detail.id);
message.success(`已识别 ${detail.sheets.length} 个工作表`);
} catch (error) {
message.error(error instanceof Error ? error.message : '文件上传失败');
} finally {
setUploading(false);
}
};
const handlePreview = async () => {
if (!run || !activeStepKey || !activeStep) return;
const mapping = mappingDraft[activeStepKey] ?? {};
const required = STEP_FIELDS[activeStepKey]
.filter((field) => field.required)
.map((field) => field.key);
const missing = required.filter((key) => !mapping[key]);
if (missing.length > 0) {
message.warning('请先完成必填列的映射');
return;
}
setPreviewLoading(true);
try {
const result = await previewImportStep(run.id, activeStepKey, {
sheets: sheetSelection[activeStepKey] ?? [],
mapping,
});
setPreviewByStep((prev) => ({ ...prev, [activeStepKey]: result }));
setRowActions({});
} catch (error) {
message.error(error instanceof Error ? error.message : '预览失败');
} finally {
setPreviewLoading(false);
}
};
const handleCommit = async () => {
if (!run || !activeStepKey || !preview) return;
setCommitLoading(true);
try {
const decisions = preview.rows
.filter((row) => row.status === 'valid')
.map((row) => ({ rowId: row.id, action: rowActions[row.id] ?? row.action ?? 'create' }));
const result = await commitImportStep(run.id, activeStepKey, decisions);
setReceipt(result);
const refreshed = await getImportRun(run.id);
setRun(refreshed);
setPreviewByStep({});
setRowActions({});
if (result.nextStepKey) {
setActiveStepKey(result.nextStepKey);
const nextStep = refreshed.steps.find((step) => step.stepKey === result.nextStepKey);
setMappingDraft((prev) => ({
...prev,
[result.nextStepKey as string]: nextStep?.mapping ?? {},
}));
}
} catch (error) {
message.error(error instanceof Error ? error.message : '提交失败');
} finally {
setCommitLoading(false);
}
};
const handleReupload = async (file: File) => {
if (!run || !activeStepKey) return;
setUploading(true);
try {
const detail = await createImportRun(file, {
source: 'manual',
stages: [{ stepKey: activeStepKey, sheet: sheetSelection[activeStepKey]?.[0] }],
mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} },
});
await loadRun(detail.id);
message.success('已重新上传,并保留原列映射');
} catch (error) {
message.error(error instanceof Error ? error.message : '重新上传失败');
} finally {
setUploading(false);
}
};
const previewRows = useMemo(() => {
if (!preview) return [];
return onlyErrors ? preview.rows.filter((row) => row.status === 'error') : preview.rows;
}, [preview, onlyErrors]);
const columns = useMemo(() => {
if (!activeStepKey) return [];
return [
{ title: '行号', dataIndex: 'rowNumber', width: 70 },
{ title: '工作表', dataIndex: 'sheetName', width: 120, ellipsis: true },
{
title: '数据',
key: 'data',
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
keyInfo(activeStepKey, row),
},
{
title: '判定',
key: 'action',
width: 90,
render: (_: unknown, row: ImportPreviewResult['rows'][number]) => {
const action = rowActions[row.id] ?? row.action;
return action ? (
<Tag color={ACTION_META[action].color}>{ACTION_META[action].label}</Tag>
) : (
'-'
);
},
},
{
title: '状态',
key: 'status',
width: 90,
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
row.status === 'error' ? (
<Tag color="red" icon={<CloseCircleOutlined />}>
</Tag>
) : (
<Tag color="green" icon={<CheckCircleOutlined />}>
</Tag>
),
},
{
title: '错误信息',
key: 'errors',
ellipsis: true,
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
row.errors.length > 0 ? (
<Tooltip title={row.errors.join('')}>
<Typography.Text type="danger" style={{ maxWidth: 320 }}>
{row.errors.join('')}
</Typography.Text>
</Tooltip>
) : null,
},
{
title: '处理方式',
key: 'decision',
width: 120,
render: (_: unknown, row: ImportPreviewResult['rows'][number]) =>
row.status === 'valid' ? (
<Select
size="small"
value={rowActions[row.id] ?? row.action ?? 'create'}
options={
row.action === 'update'
? [
{ value: 'update', label: '更新' },
{ value: 'skip', label: '跳过' },
]
: [
{ value: 'create', label: '新建' },
{ value: 'skip', label: '跳过' },
]
}
onChange={(value: RowAction) =>
setRowActions((prev) => ({ ...prev, [row.id]: value }))
}
/>
) : null,
},
];
}, [activeStepKey, rowActions]);
const stageItems = useMemo(
() =>
(run?.steps ?? [])
.filter((step) => step.status !== 'skipped')
.map((step) => ({
key: step.stepKey,
title: step.label,
status:
step.status === 'committed'
? ('finish' as const)
: step.stepKey === activeStepKey
? ('process' as const)
: ('wait' as const),
})),
[run, activeStepKey],
);
const allCommitted = run?.status === 'committed';
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={980}
title="Excel 批量导入向导"
destroyOnHidden={false}
>
{!run && !loadingRun ? (
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="上传 Excel 后,系统会自动识别工作表并按业务依赖分阶段(学生/宿舍 → 入住/换宿)。每一阶段都需要先预览、再确认,确认后才会写入数据库。"
/>
<Upload.Dragger
accept=".xlsx,.csv"
maxCount={1}
showUploadList={false}
disabled={uploading}
customRequest={handleUpload}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"> .xlsx / .csv </p>
<p className="ant-upload-hint"> 10MB.xls .xlsx</p>
</Upload.Dragger>
</Space>
) : (
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
<Flex justify="space-between" align="center" wrap gap={8}>
<Space wrap>
<Typography.Text strong>{run?.fileName}</Typography.Text>
<Tag color={allCommitted ? 'success' : 'processing'}>
{allCommitted ? '已完成' : '待处理'}
</Tag>
<Tag>{activeStep?.label ?? '—'}</Tag>
</Space>
<Space>
{preview && preview.summary.error > 0 && (
<Button
size="small"
icon={<DownloadOutlined />}
loading={reportDownloading}
onClick={() => {
setReportDownloading(true);
void downloadErrorReport(run?.id ?? '', activeStepKey ?? undefined)
.then(() => message.success('错误报告已下载'))
.catch((error: unknown) =>
message.error(error instanceof Error ? error.message : '下载失败'),
)
.finally(() => setReportDownloading(false));
}}
>
</Button>
)}
<Button size="small" onClick={onClose}>
</Button>
</Space>
</Flex>
<Steps
size="small"
items={stageItems}
onChange={(index) => {
const step = (run?.steps ?? []).filter((s) => s.status !== 'skipped')[index];
if (step) setActiveStepKey(step.stepKey);
}}
/>
{loadingRun ? (
<Flex justify="center" style={{ padding: 32 }}>
<Spin description="正在加载导入任务..." />
</Flex>
) : allCommitted ? (
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
<Alert type="success" showIcon message="全部阶段已提交完成" />
<Descriptions
bordered
size="small"
column={2}
items={(run?.steps ?? [])
.filter((step) => step.status !== 'skipped')
.map((step) => ({
key: step.stepKey,
label: step.label,
children: step.summary
? `新建 ${step.summary.create} / 更新 ${step.summary.update} / 跳过 ${step.summary.skip} / 失败 ${step.summary.error}`
: '—',
}))}
/>
<Button type="primary" onClick={onClose}>
</Button>
</Space>
) : activeStep ? (
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
{receipt && (
<Alert
type={receipt.status === 'committed' ? 'success' : 'warning'}
showIcon
title={receipt.message}
closable
onClose={() => setReceipt(null)}
/>
)}
{activeStep.status === 'committed' ? (
<Alert
type="success"
showIcon
title={`${activeStep.label}」已提交`}
description={
activeStep.summary
? `新建 ${activeStep.summary.create} / 更新 ${activeStep.summary.update} / 跳过 ${activeStep.summary.skip} / 失败 ${activeStep.summary.error}`
: undefined
}
/>
) : preview ? (
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
<Flex wrap gap={12} align="center">
<Space size={4}>
<Tag color="blue"> {preview.summary.total} </Tag>
<Tag color="green"> {preview.summary.valid}</Tag>
<Tag color="red"> {preview.summary.error}</Tag>
<Tag color="blue"> {preview.summary.create}</Tag>
<Tag color="orange"> {preview.summary.update}</Tag>
</Space>
<Checkbox
checked={onlyErrors}
onChange={(e) => setOnlyErrors(e.target.checked)}
>
</Checkbox>
</Flex>
<Table
size="small"
rowKey="id"
columns={columns}
dataSource={previewRows}
pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ x: 900 }}
/>
<Flex justify="end" gap={8}>
<Upload
accept=".xlsx,.csv"
showUploadList={false}
beforeUpload={(file) => {
void handleReupload(file);
return false;
}}
>
<Button icon={<ReloadOutlined />} loading={uploading}>
</Button>
</Upload>
<Button
type="primary"
icon={<StepForwardOutlined />}
loading={commitLoading}
onClick={() => void handleCommit()}
>
</Button>
</Flex>
</Space>
) : (
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
title={`配置「${activeStep.label}」阶段`}
description="选择该阶段使用的工作表,并确认列映射;系统会按“学号/手机号/宿舍号”自动区分新建或更新。"
/>
<Flex align="center" gap={8}>
<Typography.Text style={{ width: 120 }}></Typography.Text>
<Select
mode="multiple"
style={{ minWidth: 320, flex: 1 }}
placeholder="选择该阶段的工作表"
value={sheetSelection[activeStepKey ?? ''] ?? []}
options={sheetOptions.map((name) => ({ value: name, label: name }))}
onChange={(values: string[]) =>
setSheetSelection((prev) => ({
...prev,
[activeStepKey ?? '']: values,
}))
}
/>
</Flex>
{STEP_FIELDS[activeStep.stepKey].map((field) => (
<Flex key={field.key} align="center" gap={8}>
<Typography.Text style={{ width: 120 }}>
{field.label}
{field.required ? <span style={{ color: '#ff4d4f' }}> *</span> : null}
{field.identity ? <Tag style={{ marginLeft: 4 }}></Tag> : null}
</Typography.Text>
<Select
allowClear
showSearch
style={{ minWidth: 320, flex: 1 }}
placeholder="选择对应列(留空则自动识别)"
value={mappingDraft[activeStepKey ?? '']?.[field.key]}
options={headerOptions.map((header) => ({ value: header, label: header }))}
onChange={(value?: string) =>
setMappingDraft((prev) => {
const current = { ...prev[activeStepKey ?? ''] };
if (value) current[field.key] = value;
else delete current[field.key];
return { ...prev, [activeStepKey ?? '']: current };
})
}
/>
</Flex>
))}
<Flex justify="end">
<Button
type="primary"
loading={previewLoading}
onClick={() => void handlePreview()}
>
</Button>
</Flex>
</Space>
)}
</Space>
) : (
<Alert type="warning" showIcon message="当前没有可处理的阶段" />
)}
</Space>
)}
</Modal>
);
};