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 = { create: { label: '新建', color: 'blue' }, update: { label: '更新', color: 'orange' }, skip: { label: '跳过', color: 'default' }, }; function guessMapping(stepKey: ImportStepKey, headers: string[]): Record { const mapping: Record = {}; 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 { 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 = ({ open, runId: initialRunId, onClose, }) => { const [run, setRun] = useState(null); const [loadingRun, setLoadingRun] = useState(false); const [uploading, setUploading] = useState(false); const [activeStepKey, setActiveStepKey] = useState(null); const [sheetSelection, setSheetSelection] = useState>({}); const [mappingDraft, setMappingDraft] = useState>>({}); const [previewByStep, setPreviewByStep] = useState>({}); const [previewLoading, setPreviewLoading] = useState(false); const [onlyErrors, setOnlyErrors] = useState(false); const [rowActions, setRowActions] = useState>({}); const [commitLoading, setCommitLoading] = useState(false); const [receipt, setReceipt] = useState(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 = {}; const mappings: Record> = {}; 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(); 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 ? ( {ACTION_META[action].label} ) : ( '-' ); }, }, { title: '状态', key: 'status', width: 90, render: (_: unknown, row: ImportPreviewResult['rows'][number]) => row.status === 'error' ? ( }> 错误 ) : ( }> 有效 ), }, { title: '错误信息', key: 'errors', ellipsis: true, render: (_: unknown, row: ImportPreviewResult['rows'][number]) => row.errors.length > 0 ? ( {row.errors.join(';')} ) : null, }, { title: '处理方式', key: 'decision', width: 120, render: (_: unknown, row: ImportPreviewResult['rows'][number]) => row.status === 'valid' ? ( ({ value: name, label: name }))} onChange={(values: string[]) => setSheetSelection((prev) => ({ ...prev, [activeStepKey ?? '']: values, })) } /> {STEP_FIELDS[activeStep.stepKey].map((field) => ( {field.label} {field.required ? * : null} {field.identity ? 匹配键 : null}