- AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表 - ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复 - headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配 - suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿 - 预检与预览按工作表逐表解析列映射,兼容异构表头 - 修复预检卡生成向导成功后按钮未复位 loading 的问题 - 补充 mapping/预检/run/ai-chat 多工作表测试
378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
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>
|
||
);
|
||
};
|