Files
gongxue-base/apps/admin/src/components/AiChat/ImportPreflightCard.tsx
wangziqi ae88372ef8 fix(imports): 修复 AI 导入向导多工作表与表头误判
- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表
- ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复
- headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配
- suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿
- 预检与预览按工作表逐表解析列映射,兼容异构表头
- 修复预检卡生成向导成功后按钮未复位 loading 的问题
- 补充 mapping/预检/run/ai-chat 多工作表测试
2026-08-06 14:46:33 +08:00

378 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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> | void;
}
/**
* 上传 Excel 后的“可插入性预检”交互卡:展示判定结论、分阶段统计与阻断原因,
* 并让用户直接在卡内确认列映射与导入策略,点击「生成导入向导」由服务端建任务。
* 对应 A2UI demo 中“同一 surface 内完成用户交互 + 增量更新”的交互方式。
*/
export const ImportPreflightCard: React.FC<ImportPreflightCardProps> = ({
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<Record<string, Record<string, string>>>(
() =>
Object.fromEntries(
preflight.stages.map((stage) => [stage.stepKey, { ...stage.mapping }]),
),
);
const [settingsDraft, setSettingsDraft] = useState<SettingsDraft>(() =>
defaultSettings(preflight.questions),
);
const [customOrganization, setCustomOrganization] = useState(false);
const [organizationInput, setOrganizationInput] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Card
size="small"
className="ai-chat-preflight-card"
title={
<Flex gap={8} align="center" wrap>
<TableOutlined />
<Typography.Text strong>Excel </Typography.Text>
<Tag color={verdict.color}>{verdict.label}</Tag>
{resolved && (
<Tag color="success" icon={<CheckOutlined />}>
</Tag>
)}
</Flex>
}
>
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
{resolved && (
<Alert type="success" showIcon title="导入向导已生成,点击下方按钮打开并逐阶段预览确认。" />
)}
{error && <Alert type="error" showIcon title={error} />}
{preflight.stages.length > 0 ? (
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
{preflight.stages.map((stage) => (
<Flex key={stage.stepKey} vertical gap={4}>
<Flex gap={8} align="center" wrap>
<Typography.Text strong>{stage.label}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stage.sheetNames.join('、')}
</Typography.Text>
{stage.stepKey && !permitted.has(stage.stepKey) && (
<Tag color="default"></Tag>
)}
</Flex>
<Space size={4} wrap>
<Tag color="blue"> {stage.total} </Tag>
<Tag color="green"> {stage.create}</Tag>
<Tag color="orange"> {stage.update}</Tag>
<Tag color="red"> {stage.error}</Tag>
<Tag> {stage.skip}</Tag>
</Space>
</Flex>
))}
</Space>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
{preflight.blocks.length > 0 && (
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
{preflight.blocks.map((block) => (
<Flex key={block.code} gap={8} align="baseline" wrap>
<Tag color="red">{block.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{block.message}{block.count}
</Typography.Text>
</Flex>
))}
</Space>
)}
{showActions && (
<>
{preflight.stages
.filter(
(stage) =>
stage.missingRequired.length > 0 &&
stage.stepKey &&
permitted.has(stage.stepKey),
)
.map((stage) => (
<Space key={stage.stepKey} orientation="vertical" size={6} style={{ width: '100%' }}>
<Flex gap={8} align="center" wrap>
<Typography.Text style={{ fontSize: 12 }}></Typography.Text>
<Typography.Text strong style={{ fontSize: 12 }}>
{stage.label}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stage.missingRequired.join('、')}
</Typography.Text>
</Flex>
<Flex wrap gap={8} align="center">
{missingFieldKeys(stage.stepKey, stage.missingRequired).map((field) => {
const fieldMeta = STEP_FIELDS[stage.stepKey]?.find(
(item) => item.key === field,
);
return (
<Select
key={field}
allowClear
showSearch
size="small"
style={{ minWidth: 200 }}
placeholder={`选择${fieldMeta?.label ?? field}对应列`}
value={mappingDraft[stage.stepKey]?.[field]}
options={(stage.headers ?? []).map((header) => ({
value: header,
label: header,
}))}
onChange={(value?: string) =>
setMapping(stage.stepKey, field, value)
}
/>
);
})}
</Flex>
</Space>
))}
{nonMappingQuestions.map((question) => (
<Space key={question.key} orientation="vertical" size={4} style={{ width: '100%' }}>
<Typography.Text style={{ fontSize: 12 }}>
{question.label}
{question.description && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{question.description}
</Typography.Text>
)}
</Typography.Text>
{question.type === 'organization' ? (
<Flex gap={8} align="center" wrap>
<Select
size="small"
style={{ minWidth: 220 }}
value={customOrganization ? '__custom__' : (settingsDraft.organization ?? '')}
options={organizationOptions}
onChange={(value: string) => {
if (value === '__custom__') {
setCustomOrganization(true);
} else {
setCustomOrganization(false);
setSettingsDraft((prev) => ({ ...prev, organization: value }));
}
}}
/>
{customOrganization && (
<Input
size="small"
style={{ width: 200 }}
placeholder="输入校区名称"
value={organizationInput}
onChange={(event) => setOrganizationInput(event.target.value)}
/>
)}
</Flex>
) : (
<Radio.Group
size="small"
value={
question.type === 'update'
? String(settingsDraft.updateExisting ?? '')
: question.type === 'duplicate'
? (settingsDraft.duplicatePolicy ?? '')
: String(settingsDraft.skipUnmatched ?? '')
}
onChange={(event) => {
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) => (
<Radio.Button key={option.value} value={option.value}>
{option.label}
</Radio.Button>
))}
</Radio.Group>
)}
</Space>
))}
<Flex justify="end" gap={8} align="center" wrap>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<Button type="primary" loading={submitting} onClick={() => void handleResolve()}>
</Button>
</Flex>
</>
)}
{preflight.nextSteps.length > 0 && (
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
{preflight.nextSteps.map((step) => (
<Flex key={step.key} gap={8} align="baseline" wrap>
<Tag color="blue">{step.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{step.description}
</Typography.Text>
</Flex>
))}
</Space>
)}
</Space>
</Card>
);
};