feat(admin): AI 预检卡片交互与消息组件完善

- 预检卡支持列映射、导入策略确认与生成导入向导
- 消息组件/API 契约配套更新,含集成测试
This commit is contained in:
2026-08-06 11:59:36 +08:00
parent 6a18fd264d
commit d0ab8da01b
7 changed files with 588 additions and 23 deletions

View File

@@ -19,6 +19,7 @@ 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,
@@ -189,6 +190,11 @@ 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> = ({
@@ -202,6 +208,7 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
onConfirmReviewStep,
onConfirmReviewGroup,
onOpenImportWizard,
onResolveImportPreflight,
}) => {
const streaming = status === 'loading' || status === 'updating';
const formSubmission = message.metadata?.a2uiSubmit;
@@ -317,7 +324,22 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
{(() => {
const preflight = message.metadata?.a2uiImportPreflight;
if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return null;
return <ImportPreflightCard preflight={preflight as AiImportPreflight} />;
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;

View File

@@ -1,10 +1,14 @@
import React from 'react';
import { TableOutlined } from '@ant-design/icons';
import { Card, Flex, Space, Tag, Typography } from 'antd';
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,
@@ -15,14 +19,141 @@ const VERDICT_META: Record<
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 后的“可插入性预检报告”卡片:展示判定结论、
* 分阶段统计、阻断原因、待确认问题与下一步建议
* 上传 Excel 后的“可插入性预检”交互卡:展示判定结论、分阶段统计与阻断原因,
* 并让用户直接在卡内确认列映射与导入策略,点击「生成导入向导」由服务端建任务
* 对应 A2UI demo 中“同一 surface 内完成用户交互 + 增量更新”的交互方式。
*/
export const ImportPreflightCard: React.FC<{ preflight: AiImportPreflight }> = ({
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 : '导入向导生成失败');
setSubmitting(false);
}
};
const nonMappingQuestions = preflight.questions.filter(
(question) => question.type !== 'mapping',
);
return (
<Card
size="small"
@@ -32,10 +163,20 @@ export const ImportPreflightCard: React.FC<{ preflight: AiImportPreflight }> = (
<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) => (
@@ -45,6 +186,9 @@ export const ImportPreflightCard: React.FC<{ preflight: AiImportPreflight }> = (
<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>
@@ -76,22 +220,139 @@ export const ImportPreflightCard: React.FC<{ preflight: AiImportPreflight }> = (
</Space>
)}
{preflight.questions.length > 0 && (
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
{preflight.questions.map((question) => (
<Flex key={question.key} gap={8} align="baseline" wrap>
<Tag color="gold">{question.label}</Tag>
{question.description && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{question.description}
</Typography.Text>
{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>
)}
</Flex>
</Space>
))}
</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 && (

View File

@@ -1,6 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import api from '../../api';
import { aiChatApi } from './api';
import {
aiChatApi,
resolveImportPreflight,
type ImportPreflightResolveUpdate,
} from './api';
describe('AI chat API adapter', () => {
afterEach(() => vi.restoreAllMocks());
@@ -71,4 +75,55 @@ 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」不在工作表表头中');
});
});

View File

@@ -1,8 +1,11 @@
import api from '../../api';
import { useUserStore } from '../../store/user/userStore';
import type {
AiApiResponse,
AiAttachment,
AiConversation,
AiImportPreflight,
AiImportWizard,
AiMessagePage,
AiReviewSchema,
AiReviewSection,
@@ -83,3 +86,89 @@ 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 });
}
}
}
}

View File

@@ -7,6 +7,7 @@ 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;
@@ -532,6 +533,108 @@ describe('AI chat bubble rendering', () => {
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',

View File

@@ -121,6 +121,8 @@ export interface AiImportPreflightStage {
stepKey: AiReviewSectionType;
label: string;
sheetNames: string[];
/** 该阶段所有工作表的表头并集,供预检卡列映射选择。 */
headers?: string[];
total: number;
create: number;
update: number;
@@ -161,6 +163,11 @@ export interface AiImportPreflight {
blocks: AiImportPreflightBlock[];
questions: AiImportPreflightQuestion[];
nextSteps: AiImportPreflightNextStep[];
attachmentId?: number;
headerRow?: number;
permittedSteps?: AiReviewSectionType[];
resolved?: boolean;
runId?: string | null;
errorSamples?: Array<{
code: string;
stepKey: AiReviewSectionType;

View File

@@ -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 } from './api';
import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api';
import { AiMessageContent } from './AiMessageContent';
import { mapHistoryMessage } from './message-mappers';
import { GongxueAiChatProvider } from './provider';
@@ -24,6 +24,7 @@ import type {
AiChatMessage,
AiChatMessageStatus,
AiFormSchema,
AiImportPreflight,
AiReviewSchema,
AiReviewSection,
AiReviewSectionType,
@@ -409,6 +410,31 @@ 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) {
@@ -524,6 +550,7 @@ export function useAiChatMessageActions({
onConfirmReviewStep={confirmReviewStep}
onConfirmReviewGroup={confirmReviewGroup}
onOpenImportWizard={setImportWizardRunId}
onResolveImportPreflight={resolvePreflight}
/>
),
})),
@@ -537,6 +564,7 @@ export function useAiChatMessageActions({
isRequesting,
messages,
reloadMessage,
resolvePreflight,
setImportWizardRunId,
submitForm,
submitReview,