import React, { useMemo, useState } from 'react'; import { CheckOutlined, TableOutlined } from '@ant-design/icons'; import { Alert, Button, Card, Flex, Input, Radio, Select, Space, Tag, Typography } from 'antd'; import type { ResolveImportPreflightInput } from './api'; import type { AiImportPreflight, AiImportPreflightQuestion, AiImportPreflightVerdict, AiReviewSectionType, } from './types'; import { STEP_FIELDS } from '../ImportWizard/types'; const VERDICT_META: Record< AiImportPreflightVerdict, { label: string; color: string } > = { ready: { label: '可导入', color: 'success' }, needs_input: { label: '需要确认', color: 'warning' }, blocked: { label: '暂无法导入', color: 'error' }, }; interface SettingsDraft { organization?: string | null; updateExisting?: boolean; duplicatePolicy?: 'error' | 'skip'; skipUnmatched?: boolean; } function defaultSettings(questions: AiImportPreflightQuestion[]): SettingsDraft { const settings: SettingsDraft = {}; for (const question of questions) { if (question.type === 'update' && typeof question.default === 'boolean') { settings.updateExisting = question.default; } else if ( question.type === 'duplicate' && (question.default === 'error' || question.default === 'skip') ) { settings.duplicatePolicy = question.default; } else if (question.type === 'reference' && typeof question.default === 'boolean') { settings.skipUnmatched = question.default; } else if (question.type === 'organization') { settings.organization = typeof question.default === 'string' ? question.default : ''; } } return settings; } function missingFieldKeys( stepKey: AiReviewSectionType, missingLabels: string[], ): string[] { const fields = STEP_FIELDS[stepKey] ?? []; return missingLabels .map((label) => fields.find((field) => field.label === label)?.key) .filter((key): key is string => Boolean(key)); } export interface ImportPreflightCardProps { preflight: AiImportPreflight; onResolve?: (input: ResolveImportPreflightInput) => Promise | void; } /** * 上传 Excel 后的“可插入性预检”交互卡:展示判定结论、分阶段统计与阻断原因, * 并让用户直接在卡内确认列映射与导入策略,点击「生成导入向导」由服务端建任务。 * 对应 A2UI demo 中“同一 surface 内完成用户交互 + 增量更新”的交互方式。 */ export const ImportPreflightCard: React.FC = ({ preflight, onResolve, }) => { const verdict = VERDICT_META[preflight.verdict] ?? VERDICT_META.needs_input; const resolved = preflight.resolved === true; const permitted = new Set(preflight.permittedSteps ?? []); const hasStages = preflight.stages.length > 0; const showActions = !resolved && hasStages && Boolean(onResolve); const [mappingDraft, setMappingDraft] = useState>>( () => Object.fromEntries( preflight.stages.map((stage) => [stage.stepKey, { ...stage.mapping }]), ), ); const [settingsDraft, setSettingsDraft] = useState(() => defaultSettings(preflight.questions), ); const [customOrganization, setCustomOrganization] = useState(false); const [organizationInput, setOrganizationInput] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const organizationQuestion = preflight.questions.find( (question) => question.type === 'organization', ); const organizationOptions = useMemo(() => { const options = [...(organizationQuestion?.options ?? [])]; if (!options.some((option) => option.value === '')) { options.push({ label: '忽略校区', value: '' }); } return [ ...options, { label: '手动输入校区', value: '__custom__' }, ]; }, [organizationQuestion]); const setMapping = (stepKey: string, field: string, header?: string) => { setMappingDraft((prev) => { const current = { ...prev[stepKey] }; if (header) current[field] = header; else delete current[field]; return { ...prev, [stepKey]: current }; }); }; const handleResolve = async () => { if (!onResolve) return; setSubmitting(true); setError(null); const settings: SettingsDraft = { ...settingsDraft }; if (organizationQuestion) { if (customOrganization) { const value = organizationInput.trim(); if (!value) { setError('请先输入校区名称,或选择“忽略校区”'); setSubmitting(false); return; } settings.organization = value; } else if (settings.organization === '__custom__') { settings.organization = ''; } } try { await onResolve({ mapping: mappingDraft, settings: { ...(settings.organization ? { organization: settings.organization } : {}), ...(settings.updateExisting !== undefined ? { updateExisting: settings.updateExisting } : {}), ...(settings.duplicatePolicy ? { duplicatePolicy: settings.duplicatePolicy } : {}), ...(settings.skipUnmatched !== undefined ? { skipUnmatched: settings.skipUnmatched } : {}), }, }); } catch (resolveError) { setError(resolveError instanceof Error ? resolveError.message : '导入向导生成失败'); } finally { setSubmitting(false); } }; const nonMappingQuestions = preflight.questions.filter( (question) => question.type !== 'mapping', ); return ( Excel 导入预检 {verdict.label} {resolved && ( }> 已生成向导 )} } > {resolved && ( )} {error && } {preflight.stages.length > 0 ? ( {preflight.stages.map((stage) => ( {stage.label} {stage.sheetNames.join('、')} {stage.stepKey && !permitted.has(stage.stepKey) && ( 无提交权限 )} 共 {stage.total} 行 新建 {stage.create} 更新 {stage.update} 错误 {stage.error} 跳过 {stage.skip} ))} ) : ( 未识别到可导入的工作表 )} {preflight.blocks.length > 0 && ( 阻断原因 {preflight.blocks.map((block) => ( {block.label} {block.message}({block.count} 行) ))} )} {showActions && ( <> {preflight.stages .filter( (stage) => stage.missingRequired.length > 0 && stage.stepKey && permitted.has(stage.stepKey), ) .map((stage) => ( 列映射 {stage.label} 缺少:{stage.missingRequired.join('、')} {missingFieldKeys(stage.stepKey, stage.missingRequired).map((field) => { const fieldMeta = STEP_FIELDS[stage.stepKey]?.find( (item) => item.key === field, ); return ( { if (value === '__custom__') { setCustomOrganization(true); } else { setCustomOrganization(false); setSettingsDraft((prev) => ({ ...prev, organization: value })); } }} /> {customOrganization && ( setOrganizationInput(event.target.value)} /> )} ) : ( { const value = event.target.value; if (question.type === 'update') { setSettingsDraft((prev) => ({ ...prev, updateExisting: value === 'true', })); } else if (question.type === 'duplicate') { setSettingsDraft((prev) => ({ ...prev, duplicatePolicy: value as 'error' | 'skip', })); } else { setSettingsDraft((prev) => ({ ...prev, skipUnmatched: value === 'true', })); } }} > {(question.options ?? []).map((option) => ( {option.label} ))} )} ))} 未确认的列可在向导中继续调整 )} {preflight.nextSteps.length > 0 && ( 下一步建议 {preflight.nextSteps.map((step) => ( {step.label} {step.description} ))} )} ); };