feat(ai): 移除 Excel 导入预检链路
- 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件 - 删除 imports.preflight 解析器与 PreflightReport 类型 - SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard - 前端同步移除预检类型/组件/测试,保留导入向导
This commit is contained in:
@@ -16,17 +16,14 @@ import { useUserStore } from '../../store/user/userStore';
|
||||
import { DynamicChart } from './DynamicChart';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { DynamicReview } from './DynamicReview';
|
||||
import { ImportPreflightCard } from './ImportPreflightCard';
|
||||
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
|
||||
import { LiteMermaid } from './LiteMermaid';
|
||||
import type { ResolveImportPreflightInput } from './api';
|
||||
import type {
|
||||
AiAttachment,
|
||||
AiChatMessage,
|
||||
AiChatMessageStatus,
|
||||
AiChartSchema,
|
||||
AiFormSchema,
|
||||
AiImportPreflight,
|
||||
AiImportWizard,
|
||||
AiReviewSection,
|
||||
AiReviewSchema,
|
||||
@@ -46,7 +43,6 @@ const toolLabels: Record<string, string> = {
|
||||
render_form: '生成表单',
|
||||
render_review: '生成导入预览',
|
||||
render_chart: '生成图表',
|
||||
preflight_import: '导入预检',
|
||||
start_import_wizard: '生成导入向导',
|
||||
create_student: '创建学生',
|
||||
search_exams: '查询考试',
|
||||
@@ -193,11 +189,6 @@ export interface AiMessageContentProps {
|
||||
type: AiReviewSectionType,
|
||||
) => AiReviewSchema | Promise<AiReviewSchema> | void;
|
||||
onOpenImportWizard?: (runId: string) => void;
|
||||
onResolveImportPreflight?: (
|
||||
messageId: number | undefined,
|
||||
preflight: AiImportPreflight,
|
||||
input: ResolveImportPreflightInput,
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
@@ -211,7 +202,6 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
onConfirmReviewStep,
|
||||
onConfirmReviewGroup,
|
||||
onOpenImportWizard,
|
||||
onResolveImportPreflight,
|
||||
}) => {
|
||||
const streaming = status === 'loading' || status === 'updating';
|
||||
const formSubmission = message.metadata?.a2uiSubmit;
|
||||
@@ -324,26 +314,6 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
{attachmentCards}
|
||||
</Flex>
|
||||
)}
|
||||
{(() => {
|
||||
const preflight = message.metadata?.a2uiImportPreflight;
|
||||
if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return null;
|
||||
const typedPreflight = preflight as AiImportPreflight;
|
||||
return (
|
||||
<ImportPreflightCard
|
||||
preflight={typedPreflight}
|
||||
onResolve={
|
||||
onResolveImportPreflight
|
||||
? (input) =>
|
||||
onResolveImportPreflight(
|
||||
typeof message.id === 'number' ? message.id : undefined,
|
||||
typedPreflight,
|
||||
input,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined;
|
||||
if (!wizard || !onOpenImportWizard) return null;
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import api from '../../api';
|
||||
import {
|
||||
aiChatApi,
|
||||
resolveImportPreflight,
|
||||
type ImportPreflightResolveUpdate,
|
||||
} from './api';
|
||||
import { aiChatApi } from './api';
|
||||
|
||||
describe('AI chat API adapter', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
@@ -75,55 +71,4 @@ describe('AI chat API adapter', () => {
|
||||
'/ai/chat/reviews/review-1/types/checkins/confirm',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolveImportPreflight 流式解析 SSE 事件并回调预检卡/向导更新', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: ui.import_preflight\ndata: {"preflight":{"verdict":"ready","resolved":true,"runId":"run-1"}}\n\n',
|
||||
),
|
||||
);
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
'event: ui.import_wizard\ndata: {"wizard":{"runId":"run-1","fileName":"students.xlsx","sheets":[],"steps":[]}}\n\n',
|
||||
),
|
||||
);
|
||||
controller.enqueue(encoder.encode('event: done\ndata: {}\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, body });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const updates: ImportPreflightResolveUpdate[] = [];
|
||||
await resolveImportPreflight(
|
||||
42,
|
||||
{ mapping: { students: { name: '姓名' } }, settings: { updateExisting: false } },
|
||||
(update) => updates.push(update),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/ai/chat/import/preflight/42/resolve/stream',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
expect(updates).toEqual([
|
||||
{ preflight: { verdict: 'ready', resolved: true, runId: 'run-1' } },
|
||||
{ wizard: { runId: 'run-1', fileName: 'students.xlsx', sheets: [], steps: [] } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolveImportPreflight 非 2xx 时抛出服务端错误', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ message: '列映射「x」不在工作表表头中' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
resolveImportPreflight(42, { mapping: {} }, () => undefined),
|
||||
).rejects.toThrow('列映射「x」不在工作表表头中');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import api from '../../api';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import type {
|
||||
AiApiResponse,
|
||||
AiAttachment,
|
||||
AiConversation,
|
||||
AiImportPreflight,
|
||||
AiImportWizard,
|
||||
AiMessagePage,
|
||||
AiReviewSchema,
|
||||
AiReviewSection,
|
||||
@@ -86,89 +83,3 @@ export const aiChatApi = {
|
||||
export function conversationStreamUrl(id: number): string {
|
||||
return `/api${basePath}/${id}/stream`;
|
||||
}
|
||||
|
||||
export interface ResolveImportPreflightInput {
|
||||
mapping?: Record<string, Record<string, string>>;
|
||||
settings?: {
|
||||
organization?: string | null;
|
||||
updateExisting?: boolean;
|
||||
duplicatePolicy?: 'error' | 'skip';
|
||||
skipUnmatched?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportPreflightResolveUpdate {
|
||||
preflight?: AiImportPreflight;
|
||||
wizard?: AiImportWizard;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户在预检卡内确认映射与策略后,调用服务端 resolve 流直接生成导入向导。
|
||||
* 与普通对话不同:不新增用户消息,只原位更新目标消息的预检卡/向导卡。
|
||||
*/
|
||||
export async function resolveImportPreflight(
|
||||
messageId: number,
|
||||
input: ResolveImportPreflightInput,
|
||||
onUpdate: (update: ImportPreflightResolveUpdate) => void,
|
||||
): Promise<void> {
|
||||
const token = useUserStore.getState().token;
|
||||
const response = await fetch(`/api/ai/chat/import/preflight/${messageId}/resolve/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
mapping: input.mapping ?? {},
|
||||
settings: input.settings ?? {},
|
||||
}),
|
||||
});
|
||||
if (response.status === 401) {
|
||||
useUserStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
throw new Error('登录已失效');
|
||||
}
|
||||
if (!response.ok || !response.body) {
|
||||
let message = '导入向导生成失败';
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string; error?: string };
|
||||
message = body?.message ?? body?.error ?? message;
|
||||
} catch {
|
||||
// 保留默认错误信息
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const blocks = buffer.split('\n\n');
|
||||
buffer = blocks.pop() ?? '';
|
||||
for (const block of blocks) {
|
||||
const event = /^event:\s*(.+)$/m.exec(block)?.[1]?.trim() ?? 'message';
|
||||
const dataLine = /^data:\s*(.+)$/m.exec(block)?.[1]?.trim();
|
||||
if (!dataLine || dataLine === '[DONE]') continue;
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(dataLine) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (event === 'error') {
|
||||
const message =
|
||||
typeof data.message === 'string' ? data.message : '导入向导生成失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
if (event === 'ui.import_preflight' && data.preflight) {
|
||||
onUpdate({ preflight: data.preflight as AiImportPreflight });
|
||||
} else if (event === 'ui.import_wizard' && data.wizard) {
|
||||
onUpdate({ wizard: data.wizard as AiImportWizard });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AiMessageContent } from './AiMessageContent';
|
||||
import { DynamicChart } from './DynamicChart';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { DynamicReview } from './DynamicReview';
|
||||
import type { ResolveImportPreflightInput } from './api';
|
||||
import type { AiChatMessage, AiChartSchema, AiReviewSchema } from './types';
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
@@ -429,244 +428,6 @@ describe('AI chat bubble rendering', () => {
|
||||
expect(container.textContent).toContain('26暑期文化课宿舍.xlsx');
|
||||
});
|
||||
|
||||
it('renders an import preflight card from assistant message metadata', async () => {
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '这是预检结果',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
metadata: {
|
||||
a2uiImportPreflight: {
|
||||
verdict: 'needs_input',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
total: 2,
|
||||
create: 1,
|
||||
update: 1,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名' },
|
||||
missingRequired: [],
|
||||
},
|
||||
],
|
||||
blocks: [{ code: 'unknown_organization', label: '未知校区', stepKeys: ['students'], message: '校区不存在', count: 1 }],
|
||||
questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }],
|
||||
nextSteps: [
|
||||
{
|
||||
key: 'students-next',
|
||||
label: '分班 / 排课 / 入住',
|
||||
description: '学生档案导入完成后可继续分班、排课或入住。',
|
||||
after: ['students'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<AiMessageContent message={message} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Excel 导入预检');
|
||||
expect(container.textContent).toContain('需要确认');
|
||||
expect(container.textContent).toContain('学生档案');
|
||||
expect(container.textContent).toContain('新建 1');
|
||||
expect(container.textContent).toContain('更新 1');
|
||||
expect(container.textContent).toContain('未知校区');
|
||||
expect(container.textContent).toContain('分班 / 排课 / 入住');
|
||||
});
|
||||
|
||||
it('renders preflight card, form Q&A and opens the import wizard', async () => {
|
||||
let openedRunId: string | null = null;
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '请先确认导入策略,再打开向导。',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
metadata: {
|
||||
a2uiImportPreflight: {
|
||||
verdict: 'ready',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
total: 1,
|
||||
create: 1,
|
||||
update: 0,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名' },
|
||||
missingRequired: [],
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
questions: [],
|
||||
nextSteps: [],
|
||||
},
|
||||
a2uiImportWizard: {
|
||||
runId: 'run-1',
|
||||
fileName: 'students.xlsx',
|
||||
sheets: [{ name: '学生', headers: ['姓名'], rowCount: 1 }],
|
||||
steps: [{ stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' }],
|
||||
},
|
||||
},
|
||||
forms: [
|
||||
{
|
||||
id: 'form-1',
|
||||
title: '确认导入策略',
|
||||
submitLabel: '确认',
|
||||
fields: [
|
||||
{
|
||||
name: 'duplicatePolicy',
|
||||
label: '重复行处理',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '标记为错误', value: 'error' },
|
||||
{ label: '跳过重复行', value: 'skip' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<AiMessageContent
|
||||
message={message}
|
||||
onOpenImportWizard={(runId) => {
|
||||
openedRunId = runId;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Excel 导入预检');
|
||||
expect(container.textContent).toContain('确认导入策略');
|
||||
const wizardButton = Array.from(container.querySelectorAll('button')).find((item) =>
|
||||
item.textContent?.includes('打开导入向导'),
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(wizardButton).toBeDefined();
|
||||
await act(async () => {
|
||||
wizardButton?.click();
|
||||
});
|
||||
expect(openedRunId).toBe('run-1');
|
||||
});
|
||||
|
||||
it('preflight card resolves mapping and settings inline', async () => {
|
||||
let resolvedMessageId: number | undefined;
|
||||
let resolvedInput: ResolveImportPreflightInput | undefined;
|
||||
const message: AiChatMessage = {
|
||||
id: 42,
|
||||
role: 'assistant',
|
||||
content: '请在预检卡内确认列映射与策略。',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
metadata: {
|
||||
a2uiImportPreflight: {
|
||||
verdict: 'blocked',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
headers: ['姓名', '学号', '手机号'],
|
||||
total: 2,
|
||||
create: 2,
|
||||
update: 0,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名', studentNo: '学号' },
|
||||
missingRequired: [],
|
||||
},
|
||||
{
|
||||
stepKey: 'rooms',
|
||||
label: '宿舍档案',
|
||||
sheetNames: ['宿舍'],
|
||||
headers: ['宿舍号', '楼栋', '容量'],
|
||||
total: 1,
|
||||
create: 1,
|
||||
update: 0,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { roomNumber: '宿舍号' },
|
||||
missingRequired: ['容量'],
|
||||
},
|
||||
],
|
||||
blocks: [
|
||||
{
|
||||
code: 'missing_columns',
|
||||
label: '缺少必填列',
|
||||
stepKeys: ['rooms'],
|
||||
message: '宿舍阶段缺少容量列',
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
questions: [
|
||||
{
|
||||
key: 'update',
|
||||
type: 'update',
|
||||
label: '文件中有 1 行已匹配现有记录',
|
||||
options: [
|
||||
{ label: '更新已有记录', value: 'true' },
|
||||
{ label: '跳过已有记录', value: 'false' },
|
||||
],
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
nextSteps: [],
|
||||
permittedSteps: ['students', 'rooms'],
|
||||
resolved: false,
|
||||
runId: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<AiMessageContent
|
||||
message={message}
|
||||
onResolveImportPreflight={(messageId, _preflight, input) => {
|
||||
resolvedMessageId = messageId;
|
||||
resolvedInput = input;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('容量');
|
||||
const resolveButton = Array.from(container.querySelectorAll('button')).find((item) =>
|
||||
item.textContent?.includes('生成导入向导'),
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(resolveButton).toBeDefined();
|
||||
await act(async () => {
|
||||
resolveButton?.click();
|
||||
});
|
||||
|
||||
expect(resolvedMessageId).toBe(42);
|
||||
expect(resolvedInput?.mapping).toMatchObject({
|
||||
students: { name: '姓名', studentNo: '学号' },
|
||||
rooms: { roomNumber: '宿舍号' },
|
||||
});
|
||||
expect(resolvedInput?.settings).toMatchObject({ updateExisting: true });
|
||||
});
|
||||
|
||||
it('renders model retrying hint while waiting for the upstream retry', async () => {
|
||||
const message: AiChatMessage = {
|
||||
role: 'assistant',
|
||||
|
||||
@@ -186,25 +186,4 @@ describe('AI chat history mapper', () => {
|
||||
expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' });
|
||||
});
|
||||
|
||||
it('keeps import preflight metadata on history messages', () => {
|
||||
const preflight = {
|
||||
verdict: 'needs_input',
|
||||
stages: [],
|
||||
blocks: [],
|
||||
questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }],
|
||||
nextSteps: [],
|
||||
};
|
||||
const mapped = mapHistoryMessage({
|
||||
id: 8,
|
||||
role: 'assistant',
|
||||
content: '预检完成',
|
||||
reasoningContent: null,
|
||||
status: 'completed',
|
||||
errorCode: null,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
metadata: { a2uiImportPreflight: preflight },
|
||||
});
|
||||
|
||||
expect(mapped.message.metadata?.a2uiImportPreflight).toEqual(preflight);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,46 +189,6 @@ describe('AI chat SSE message reducer', () => {
|
||||
expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'expired' });
|
||||
});
|
||||
|
||||
it('stores ui.import_preflight in message metadata and restores it from completed message', () => {
|
||||
const preflight = {
|
||||
verdict: 'needs_input',
|
||||
stages: [
|
||||
{
|
||||
stepKey: 'students',
|
||||
label: '学生档案',
|
||||
sheetNames: ['学生'],
|
||||
total: 2,
|
||||
create: 1,
|
||||
update: 1,
|
||||
error: 0,
|
||||
skip: 0,
|
||||
mapping: { name: '姓名' },
|
||||
missingRequired: [],
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }],
|
||||
nextSteps: [{ key: 'students-next', label: '分班 / 排课 / 入住', description: '建议', after: ['students'] }],
|
||||
};
|
||||
let message = reduceAiSseMessage(undefined, {
|
||||
event: 'ui.import_preflight',
|
||||
data: JSON.stringify({ messageId: 8, preflight }),
|
||||
});
|
||||
expect(message.metadata?.a2uiImportPreflight).toEqual(preflight);
|
||||
|
||||
message = reduceAiSseMessage(message, {
|
||||
event: 'message.completed',
|
||||
data: JSON.stringify({
|
||||
message: {
|
||||
id: 8,
|
||||
content: '预检完成',
|
||||
status: 'completed',
|
||||
metadata: { a2uiImportPreflight: preflight },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(message.metadata?.a2uiImportPreflight).toEqual(preflight);
|
||||
});
|
||||
|
||||
it('restores a persisted form from message.completed metadata', () => {
|
||||
const message = reduceAiSseMessage(undefined, {
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
AiChartSchema,
|
||||
AiChatMessage,
|
||||
AiFormSchema,
|
||||
AiImportPreflight,
|
||||
AiModelRetryInfo,
|
||||
AiReviewSchema,
|
||||
AiSseChunk,
|
||||
@@ -30,7 +29,6 @@ export interface AiSsePayload {
|
||||
artifact?: AiArtifactSchema;
|
||||
review?: AiReviewSchema;
|
||||
chart?: AiChartSchema;
|
||||
preflight?: AiImportPreflight;
|
||||
wizard?: unknown;
|
||||
retry?: AiModelRetryInfo;
|
||||
message?:
|
||||
@@ -169,8 +167,6 @@ export function reduceAiSseMessage(
|
||||
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
|
||||
} else if (event === 'ui.artifact' && payload.artifact) {
|
||||
mergeArtifactIntoMessage(message, payload.artifact);
|
||||
} else if (event === 'ui.import_preflight' && payload.preflight) {
|
||||
message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight };
|
||||
} else if (event === 'ui.import_wizard' && payload.wizard) {
|
||||
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
|
||||
} else if (event === 'tool.started') {
|
||||
|
||||
@@ -102,7 +102,6 @@ export type AiArtifactType =
|
||||
| 'form'
|
||||
| 'review'
|
||||
| 'chart'
|
||||
| 'import_preflight'
|
||||
| 'import_wizard';
|
||||
|
||||
export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
|
||||
@@ -136,68 +135,6 @@ export interface AiImportWizard {
|
||||
}>;
|
||||
}
|
||||
|
||||
export type AiImportPreflightVerdict = 'ready' | 'needs_input' | 'blocked';
|
||||
|
||||
export interface AiImportPreflightStage {
|
||||
stepKey: AiReviewSectionType;
|
||||
label: string;
|
||||
sheetNames: string[];
|
||||
/** 该阶段所有工作表的表头并集,供预检卡列映射选择。 */
|
||||
headers?: string[];
|
||||
total: number;
|
||||
create: number;
|
||||
update: number;
|
||||
error: number;
|
||||
skip: number;
|
||||
mapping: Record<string, string>;
|
||||
missingRequired: string[];
|
||||
}
|
||||
|
||||
export interface AiImportPreflightBlock {
|
||||
code: string;
|
||||
label: string;
|
||||
stepKeys: AiReviewSectionType[];
|
||||
message: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AiImportPreflightQuestion {
|
||||
key: string;
|
||||
type: 'mapping' | 'organization' | 'update' | 'duplicate' | 'reference';
|
||||
label: string;
|
||||
description?: string;
|
||||
stepKey?: AiReviewSectionType;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
default?: string | boolean;
|
||||
}
|
||||
|
||||
export interface AiImportPreflightNextStep {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
after: AiReviewSectionType[];
|
||||
}
|
||||
|
||||
export interface AiImportPreflight {
|
||||
verdict: AiImportPreflightVerdict;
|
||||
stages: AiImportPreflightStage[];
|
||||
blocks: AiImportPreflightBlock[];
|
||||
questions: AiImportPreflightQuestion[];
|
||||
nextSteps: AiImportPreflightNextStep[];
|
||||
attachmentId?: number;
|
||||
headerRow?: number;
|
||||
permittedSteps?: AiReviewSectionType[];
|
||||
resolved?: boolean;
|
||||
runId?: string | null;
|
||||
errorSamples?: Array<{
|
||||
code: string;
|
||||
stepKey: AiReviewSectionType;
|
||||
sheet: string;
|
||||
rowNumber: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export type AiToolRunStatus =
|
||||
| 'running'
|
||||
| 'success'
|
||||
|
||||
@@ -60,8 +60,6 @@ export function mergeArtifactIntoMessage(
|
||||
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload as AiReviewSchema);
|
||||
} else if (artifact.type === 'chart') {
|
||||
message.charts = mergeById<AiChartSchema>(message.charts, payload as AiChartSchema);
|
||||
} else if (artifact.type === 'import_preflight') {
|
||||
message.metadata = { ...message.metadata, a2uiImportPreflight: payload };
|
||||
} else if (artifact.type === 'import_wizard') {
|
||||
message.metadata = { ...message.metadata, a2uiImportWizard: payload };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { App } from 'antd';
|
||||
import type { UploadFile, UploadProps } from 'antd';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useSettingsStore } from '../../store/settings/settingsStore';
|
||||
import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api';
|
||||
import { aiChatApi } from './api';
|
||||
import { AiMessageContent } from './AiMessageContent';
|
||||
import { mapHistoryMessage } from './message-mappers';
|
||||
import { GongxueAiChatProvider } from './provider';
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
AiChatMessage,
|
||||
AiChatMessageStatus,
|
||||
AiFormSchema,
|
||||
AiImportPreflight,
|
||||
AiReviewSchema,
|
||||
AiReviewSection,
|
||||
AiReviewSectionType,
|
||||
@@ -416,31 +415,6 @@ export function useAiChatMessageActions({
|
||||
[provider, setMessage],
|
||||
);
|
||||
|
||||
const resolvePreflight = useCallback(
|
||||
async (
|
||||
messageId: number | undefined,
|
||||
_preflight: AiImportPreflight,
|
||||
input: ResolveImportPreflightInput,
|
||||
): Promise<void> => {
|
||||
if (typeof messageId !== 'number') {
|
||||
throw new Error('消息尚未完成生成,请稍后再试');
|
||||
}
|
||||
await resolveImportPreflight(messageId, input, (update) => {
|
||||
setMessage(messageId, (info) => ({
|
||||
message: {
|
||||
...info.message,
|
||||
metadata: {
|
||||
...info.message.metadata,
|
||||
...(update.preflight ? { a2uiImportPreflight: update.preflight } : {}),
|
||||
...(update.wizard ? { a2uiImportWizard: update.wizard } : {}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
},
|
||||
[setMessage],
|
||||
);
|
||||
|
||||
const customUpload = useCallback<NonNullable<UploadProps['customRequest']>>(async (options) => {
|
||||
const file = options.file as File;
|
||||
if (attachmentsRef.current.length >= 5) {
|
||||
@@ -556,7 +530,6 @@ export function useAiChatMessageActions({
|
||||
onConfirmReviewStep={confirmReviewStep}
|
||||
onConfirmReviewGroup={confirmReviewGroup}
|
||||
onOpenImportWizard={setImportWizardRunId}
|
||||
onResolveImportPreflight={resolvePreflight}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
@@ -570,7 +543,6 @@ export function useAiChatMessageActions({
|
||||
isRequesting,
|
||||
messages,
|
||||
reloadMessage,
|
||||
resolvePreflight,
|
||||
setImportWizardRunId,
|
||||
submitForm,
|
||||
submitReview,
|
||||
|
||||
Reference in New Issue
Block a user