diff --git a/apps/admin/src/api/imports.ts b/apps/admin/src/api/imports.ts new file mode 100644 index 0000000..d87800a --- /dev/null +++ b/apps/admin/src/api/imports.ts @@ -0,0 +1,79 @@ +import api from './index'; +import { validateResponse } from '../utils/validate'; +import { importRunEnvelopeSchema } from './schemas'; +import type { + ImportPreviewResult, + ImportReceipt, + ImportRunDetail, + ImportStageRequest, +} from '../components/ImportWizard/types'; + +interface ApiEnvelope { + success: boolean; + data: T; + message?: string; +} + +export async function createImportRun( + file: File, + options: { + source: 'ai' | 'manual'; + conversationId?: number; + stages?: ImportStageRequest[]; + mapping?: Record>; + }, +): Promise { + const form = new FormData(); + form.append('file', file); + form.append('source', options.source); + if (options.conversationId) form.append('conversationId', String(options.conversationId)); + if (options.stages?.length) form.append('stages', JSON.stringify(options.stages)); + if (options.mapping && Object.keys(options.mapping).length > 0) { + form.append('mapping', JSON.stringify(options.mapping)); + } + const res = await api.post>('/imports/runs', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return res.data; +} + +export async function getImportRun(runId: string): Promise { + const res = await api.get>( + `/imports/runs/${encodeURIComponent(runId)}`, + ); + return validateResponse>(importRunEnvelopeSchema, res).data; +} + +export async function previewImportStep( + runId: string, + stepKey: string, + body: { sheets?: string[]; mapping?: Record }, +): Promise { + const res = await api.post>( + `/imports/runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepKey)}/preview`, + body, + ); + return res.data; +} + +export async function commitImportStep( + runId: string, + stepKey: string, + decisions: Array<{ rowId: number; action: 'create' | 'update' | 'skip' }>, +): Promise { + const res = await api.post>( + `/imports/runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepKey)}/commit`, + { decisions }, + ); + return res.data; +} + +export function importErrorReportUrl(runId: string, stepKey?: string): string { + const base = import.meta.env.PROD + ? '/api' + : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`; + const params = new URLSearchParams(); + if (stepKey) params.set('stepKey', stepKey); + const query = params.toString(); + return `${base}/imports/runs/${encodeURIComponent(runId)}/report${query ? `?${query}` : ''}`; +} diff --git a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx new file mode 100644 index 0000000..a6618e7 --- /dev/null +++ b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx @@ -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 = { + 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} + +