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 { DynamicChart } from './DynamicChart';
|
||||||
import { DynamicForm } from './DynamicForm';
|
import { DynamicForm } from './DynamicForm';
|
||||||
import { DynamicReview } from './DynamicReview';
|
import { DynamicReview } from './DynamicReview';
|
||||||
import { ImportPreflightCard } from './ImportPreflightCard';
|
|
||||||
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
|
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
|
||||||
import { LiteMermaid } from './LiteMermaid';
|
import { LiteMermaid } from './LiteMermaid';
|
||||||
import type { ResolveImportPreflightInput } from './api';
|
|
||||||
import type {
|
import type {
|
||||||
AiAttachment,
|
AiAttachment,
|
||||||
AiChatMessage,
|
AiChatMessage,
|
||||||
AiChatMessageStatus,
|
AiChatMessageStatus,
|
||||||
AiChartSchema,
|
AiChartSchema,
|
||||||
AiFormSchema,
|
AiFormSchema,
|
||||||
AiImportPreflight,
|
|
||||||
AiImportWizard,
|
AiImportWizard,
|
||||||
AiReviewSection,
|
AiReviewSection,
|
||||||
AiReviewSchema,
|
AiReviewSchema,
|
||||||
@@ -46,7 +43,6 @@ const toolLabels: Record<string, string> = {
|
|||||||
render_form: '生成表单',
|
render_form: '生成表单',
|
||||||
render_review: '生成导入预览',
|
render_review: '生成导入预览',
|
||||||
render_chart: '生成图表',
|
render_chart: '生成图表',
|
||||||
preflight_import: '导入预检',
|
|
||||||
start_import_wizard: '生成导入向导',
|
start_import_wizard: '生成导入向导',
|
||||||
create_student: '创建学生',
|
create_student: '创建学生',
|
||||||
search_exams: '查询考试',
|
search_exams: '查询考试',
|
||||||
@@ -193,11 +189,6 @@ export interface AiMessageContentProps {
|
|||||||
type: AiReviewSectionType,
|
type: AiReviewSectionType,
|
||||||
) => AiReviewSchema | Promise<AiReviewSchema> | void;
|
) => AiReviewSchema | Promise<AiReviewSchema> | void;
|
||||||
onOpenImportWizard?: (runId: string) => void;
|
onOpenImportWizard?: (runId: string) => void;
|
||||||
onResolveImportPreflight?: (
|
|
||||||
messageId: number | undefined,
|
|
||||||
preflight: AiImportPreflight,
|
|
||||||
input: ResolveImportPreflightInput,
|
|
||||||
) => Promise<void> | void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||||
@@ -211,7 +202,6 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
|||||||
onConfirmReviewStep,
|
onConfirmReviewStep,
|
||||||
onConfirmReviewGroup,
|
onConfirmReviewGroup,
|
||||||
onOpenImportWizard,
|
onOpenImportWizard,
|
||||||
onResolveImportPreflight,
|
|
||||||
}) => {
|
}) => {
|
||||||
const streaming = status === 'loading' || status === 'updating';
|
const streaming = status === 'loading' || status === 'updating';
|
||||||
const formSubmission = message.metadata?.a2uiSubmit;
|
const formSubmission = message.metadata?.a2uiSubmit;
|
||||||
@@ -324,26 +314,6 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
|||||||
{attachmentCards}
|
{attachmentCards}
|
||||||
</Flex>
|
</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;
|
const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined;
|
||||||
if (!wizard || !onOpenImportWizard) return null;
|
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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import {
|
import { aiChatApi } from './api';
|
||||||
aiChatApi,
|
|
||||||
resolveImportPreflight,
|
|
||||||
type ImportPreflightResolveUpdate,
|
|
||||||
} from './api';
|
|
||||||
|
|
||||||
describe('AI chat API adapter', () => {
|
describe('AI chat API adapter', () => {
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
@@ -75,55 +71,4 @@ describe('AI chat API adapter', () => {
|
|||||||
'/ai/chat/reviews/review-1/types/checkins/confirm',
|
'/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 api from '../../api';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
|
||||||
import type {
|
import type {
|
||||||
AiApiResponse,
|
AiApiResponse,
|
||||||
AiAttachment,
|
AiAttachment,
|
||||||
AiConversation,
|
AiConversation,
|
||||||
AiImportPreflight,
|
|
||||||
AiImportWizard,
|
|
||||||
AiMessagePage,
|
AiMessagePage,
|
||||||
AiReviewSchema,
|
AiReviewSchema,
|
||||||
AiReviewSection,
|
AiReviewSection,
|
||||||
@@ -86,89 +83,3 @@ export const aiChatApi = {
|
|||||||
export function conversationStreamUrl(id: number): string {
|
export function conversationStreamUrl(id: number): string {
|
||||||
return `/api${basePath}/${id}/stream`;
|
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 { DynamicChart } from './DynamicChart';
|
||||||
import { DynamicForm } from './DynamicForm';
|
import { DynamicForm } from './DynamicForm';
|
||||||
import { DynamicReview } from './DynamicReview';
|
import { DynamicReview } from './DynamicReview';
|
||||||
import type { ResolveImportPreflightInput } from './api';
|
|
||||||
import type { AiChatMessage, AiChartSchema, AiReviewSchema } from './types';
|
import type { AiChatMessage, AiChartSchema, AiReviewSchema } from './types';
|
||||||
|
|
||||||
let container: HTMLDivElement | null = null;
|
let container: HTMLDivElement | null = null;
|
||||||
@@ -429,244 +428,6 @@ describe('AI chat bubble rendering', () => {
|
|||||||
expect(container.textContent).toContain('26暑期文化课宿舍.xlsx');
|
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 () => {
|
it('renders model retrying hint while waiting for the upstream retry', async () => {
|
||||||
const message: AiChatMessage = {
|
const message: AiChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
|
|||||||
@@ -186,25 +186,4 @@ describe('AI chat history mapper', () => {
|
|||||||
expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' });
|
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' });
|
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', () => {
|
it('restores a persisted form from message.completed metadata', () => {
|
||||||
const message = reduceAiSseMessage(undefined, {
|
const message = reduceAiSseMessage(undefined, {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type {
|
|||||||
AiChartSchema,
|
AiChartSchema,
|
||||||
AiChatMessage,
|
AiChatMessage,
|
||||||
AiFormSchema,
|
AiFormSchema,
|
||||||
AiImportPreflight,
|
|
||||||
AiModelRetryInfo,
|
AiModelRetryInfo,
|
||||||
AiReviewSchema,
|
AiReviewSchema,
|
||||||
AiSseChunk,
|
AiSseChunk,
|
||||||
@@ -30,7 +29,6 @@ export interface AiSsePayload {
|
|||||||
artifact?: AiArtifactSchema;
|
artifact?: AiArtifactSchema;
|
||||||
review?: AiReviewSchema;
|
review?: AiReviewSchema;
|
||||||
chart?: AiChartSchema;
|
chart?: AiChartSchema;
|
||||||
preflight?: AiImportPreflight;
|
|
||||||
wizard?: unknown;
|
wizard?: unknown;
|
||||||
retry?: AiModelRetryInfo;
|
retry?: AiModelRetryInfo;
|
||||||
message?:
|
message?:
|
||||||
@@ -169,8 +167,6 @@ export function reduceAiSseMessage(
|
|||||||
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
|
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
|
||||||
} else if (event === 'ui.artifact' && payload.artifact) {
|
} else if (event === 'ui.artifact' && payload.artifact) {
|
||||||
mergeArtifactIntoMessage(message, 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) {
|
} else if (event === 'ui.import_wizard' && payload.wizard) {
|
||||||
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
|
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
|
||||||
} else if (event === 'tool.started') {
|
} else if (event === 'tool.started') {
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ export type AiArtifactType =
|
|||||||
| 'form'
|
| 'form'
|
||||||
| 'review'
|
| 'review'
|
||||||
| 'chart'
|
| 'chart'
|
||||||
| 'import_preflight'
|
|
||||||
| 'import_wizard';
|
| 'import_wizard';
|
||||||
|
|
||||||
export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
|
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 =
|
export type AiToolRunStatus =
|
||||||
| 'running'
|
| 'running'
|
||||||
| 'success'
|
| 'success'
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ export function mergeArtifactIntoMessage(
|
|||||||
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload as AiReviewSchema);
|
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload as AiReviewSchema);
|
||||||
} else if (artifact.type === 'chart') {
|
} else if (artifact.type === 'chart') {
|
||||||
message.charts = mergeById<AiChartSchema>(message.charts, payload as AiChartSchema);
|
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') {
|
} else if (artifact.type === 'import_wizard') {
|
||||||
message.metadata = { ...message.metadata, a2uiImportWizard: payload };
|
message.metadata = { ...message.metadata, a2uiImportWizard: payload };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { App } from 'antd';
|
|||||||
import type { UploadFile, UploadProps } from 'antd';
|
import type { UploadFile, UploadProps } from 'antd';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { useSettingsStore } from '../../store/settings/settingsStore';
|
import { useSettingsStore } from '../../store/settings/settingsStore';
|
||||||
import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api';
|
import { aiChatApi } from './api';
|
||||||
import { AiMessageContent } from './AiMessageContent';
|
import { AiMessageContent } from './AiMessageContent';
|
||||||
import { mapHistoryMessage } from './message-mappers';
|
import { mapHistoryMessage } from './message-mappers';
|
||||||
import { GongxueAiChatProvider } from './provider';
|
import { GongxueAiChatProvider } from './provider';
|
||||||
@@ -25,7 +25,6 @@ import type {
|
|||||||
AiChatMessage,
|
AiChatMessage,
|
||||||
AiChatMessageStatus,
|
AiChatMessageStatus,
|
||||||
AiFormSchema,
|
AiFormSchema,
|
||||||
AiImportPreflight,
|
|
||||||
AiReviewSchema,
|
AiReviewSchema,
|
||||||
AiReviewSection,
|
AiReviewSection,
|
||||||
AiReviewSectionType,
|
AiReviewSectionType,
|
||||||
@@ -416,31 +415,6 @@ export function useAiChatMessageActions({
|
|||||||
[provider, setMessage],
|
[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 customUpload = useCallback<NonNullable<UploadProps['customRequest']>>(async (options) => {
|
||||||
const file = options.file as File;
|
const file = options.file as File;
|
||||||
if (attachmentsRef.current.length >= 5) {
|
if (attachmentsRef.current.length >= 5) {
|
||||||
@@ -556,7 +530,6 @@ export function useAiChatMessageActions({
|
|||||||
onConfirmReviewStep={confirmReviewStep}
|
onConfirmReviewStep={confirmReviewStep}
|
||||||
onConfirmReviewGroup={confirmReviewGroup}
|
onConfirmReviewGroup={confirmReviewGroup}
|
||||||
onOpenImportWizard={setImportWizardRunId}
|
onOpenImportWizard={setImportWizardRunId}
|
||||||
onResolveImportPreflight={resolvePreflight}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
@@ -570,7 +543,6 @@ export function useAiChatMessageActions({
|
|||||||
isRequesting,
|
isRequesting,
|
||||||
messages,
|
messages,
|
||||||
reloadMessage,
|
reloadMessage,
|
||||||
resolvePreflight,
|
|
||||||
setImportWizardRunId,
|
setImportWizardRunId,
|
||||||
submitForm,
|
submitForm,
|
||||||
submitReview,
|
submitReview,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export type A2uiArtifactType =
|
|||||||
| 'form'
|
| 'form'
|
||||||
| 'review'
|
| 'review'
|
||||||
| 'chart'
|
| 'chart'
|
||||||
| 'import_preflight'
|
|
||||||
| 'import_wizard';
|
| 'import_wizard';
|
||||||
|
|
||||||
export type A2uiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
|
export type A2uiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled';
|
||||||
@@ -23,7 +22,6 @@ const A2UI_ARTIFACT_TYPES = new Set<A2uiArtifactType>([
|
|||||||
'form',
|
'form',
|
||||||
'review',
|
'review',
|
||||||
'chart',
|
'chart',
|
||||||
'import_preflight',
|
|
||||||
'import_wizard',
|
'import_wizard',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -13,31 +13,6 @@ const CELL_VALUE_ANY_OF = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const A2UI_TOOL_SCHEMAS = [
|
export const A2UI_TOOL_SCHEMAS = [
|
||||||
{
|
|
||||||
type: 'function' as const,
|
|
||||||
function: {
|
|
||||||
name: 'preflight_import',
|
|
||||||
description:
|
|
||||||
'对上传的 Excel 进行导入预检并生成“可插入性报告”:分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题与导入后的下一步建议。当用户上传 Excel 并疑似需要批量导入学生、宿舍、换宿或入住数据时先调用;预检结果会以可交互卡片展示列映射与策略确认,引导用户在卡内点击「生成导入向导」,无需在聊天里重复确认卡内已覆盖的问题。',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
attachmentId: {
|
|
||||||
type: 'integer',
|
|
||||||
description: '上传的 Excel 附件 ID。系统直接从文件读取行数据,无需(也不要)在参数里抄录数据。',
|
|
||||||
},
|
|
||||||
headerRow: {
|
|
||||||
type: 'integer',
|
|
||||||
description: '表头所在行(从 1 开始,默认 1)。预检时对整个文件使用该行作为表头。',
|
|
||||||
minimum: 1,
|
|
||||||
maximum: 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['attachmentId'],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'function' as const,
|
type: 'function' as const,
|
||||||
function: {
|
function: {
|
||||||
@@ -71,7 +46,7 @@ export const A2UI_TOOL_SCHEMAS = [
|
|||||||
mapping: {
|
mapping: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description:
|
description:
|
||||||
'列映射确认结果:阶段 stepKey -> { 字段名: 工作表表头 }(字段名如 students.name/rooms.roomNumber/checkins.checkInDate/transfers.newRoom)。来自 preflight_import 报告的映射确认;未确认时省略,系统自动识别。',
|
'列映射确认结果:阶段 stepKey -> { 字段名: 工作表表头 }(字段名如 students.name/rooms.roomNumber/checkins.checkInDate/transfers.newRoom)。用户确认后传入;未确认时省略,系统自动识别。',
|
||||||
additionalProperties: {
|
additionalProperties: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: '字段名 -> 工作表表头',
|
description: '字段名 -> 工作表表头',
|
||||||
@@ -80,7 +55,7 @@ export const A2UI_TOOL_SCHEMAS = [
|
|||||||
},
|
},
|
||||||
organization: {
|
organization: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: '确认后的校区名称(预检报告出现未知校区时由用户确认)',
|
description: '确认后的校区名称(出现未知校区时由用户确认)',
|
||||||
maxLength: 100,
|
maxLength: 100,
|
||||||
},
|
},
|
||||||
updateExisting: {
|
updateExisting: {
|
||||||
@@ -201,20 +176,18 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须
|
|||||||
新增学生示例:render_form 的 fields 使用 name/phone/gender/studentNo。
|
新增学生示例:render_form 的 fields 使用 name/phone/gender/studentNo。
|
||||||
修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。
|
修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。
|
||||||
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行:
|
当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行:
|
||||||
1. 先调用 preflight_import(传入 attachmentId)生成“可插入性预检报告”:报告给出分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题和导入后的下一步建议。
|
1. 先根据消息附带的 Excel 提取文本(工作表名 + tab 分隔行)判断业务类型与表头,向用户说明将导入什么、依赖什么;需要确认的列映射、校区或策略先在聊天中与用户确认,不要替用户默认做出影响数据的决定。
|
||||||
2. 预检报告会以可交互卡片显示给用户:卡内已提供列映射控件和策略控件(更新已有记录、重复行策略、校区、未匹配行处理),并有「生成导入向导」按钮。引导用户在卡内完成确认并点击按钮即可生成向导,不要在聊天里反复确认卡内已覆盖的问题。你只需说明报告结论:blocked 时解释阻断原因并建议修正文件后重传(因缺少列映射而 blocked 时提示在卡内补全映射);needs_input 时说明需要确认的问题并提示在卡内选择;ready 时提示可直接在卡内生成向导。卡内未覆盖的自由输入(如自定义校区)才在聊天中向用户提问。不要替用户默认做出影响数据的决定。
|
2. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。
|
||||||
报告只给汇总统计时,基于报告中的 errorSamples(工作表与行号、示例值)向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。
|
3. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。
|
||||||
3. 仅当用户明确在聊天文本中给出确认(而非使用预检卡)时,才调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。若当前消息已通过预检卡生成向导,不要重复调用。
|
|
||||||
4. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。
|
|
||||||
工具结果中的 permittedSteps 表示当前用户可提交的阶段,只引导这些阶段,未列出的阶段不要建议提交或执行。
|
工具结果中的 permittedSteps 表示当前用户可提交的阶段,只引导这些阶段,未列出的阶段不要建议提交或执行。
|
||||||
每个回答回合最多调用一次 preflight_import 和一次 start_import_wizard;报告与导入完成后由你给出下一步建议,不要自动执行后续写操作。
|
每个回答回合最多调用一次 start_import_wizard;导入完成后由你给出下一步建议,不要自动执行后续写操作。
|
||||||
当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。
|
当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。
|
||||||
上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。
|
上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入时直接调用 start_import_wizard,系统会从文件解析表头与行数据。
|
||||||
业务工作流引导(重要):
|
业务工作流引导(重要):
|
||||||
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织,包含三大闭环:学生教学(学生→分班→排课→考勤→考试)、住宿计费(学生/宿舍→入住→费用→账单→押金)、教室租赁(教室/组织→租赁→合同→日程)。
|
- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织,包含三大闭环:学生教学(学生→分班→排课→考勤→考试)、住宿计费(学生/宿舍→入住→费用→账单→押金)、教室租赁(教室/组织→租赁→合同→日程)。
|
||||||
- 不确定当前角色可用哪些业务流程与实体时,先调用 get_business_context 获取权限范围内的闭环、阶段依赖与实体字典;编写 render_form 字段前可按需调用 get_entity_schema。
|
- 不确定当前角色可用哪些业务流程与实体时,先调用 get_business_context 获取权限范围内的闭环、阶段依赖与实体字典;编写 render_form 字段前可按需调用 get_entity_schema。
|
||||||
- 执行任何写入或导入前,先调用 get_pending_tasks 或现有查询工具核实前置数据是否已存在:入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。
|
- 执行任何写入或导入前,先调用 get_pending_tasks 或现有查询工具核实前置数据是否已存在:入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。
|
||||||
- 导入或录入完成后,根据完成阶段主动给出下一步建议(例如:入住完成 → 建议录入本月公共费用 → 生成并确认账单;学生档案完成 → 建议分班;租赁订单生成 → 建议补充合同),可用 get_pending_tasks 获取有数据支撑的待办。
|
- 导入或录入完成后,根据完成阶段主动给出下一步建议(例如:入住完成 → 建议录入本月公共费用 → 生成并确认账单;学生档案完成 → 建议分班;租赁订单生成 → 建议补充合同),可用 get_pending_tasks 获取有数据支撑的待办。
|
||||||
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。
|
- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;确认后调用 start_import_wizard 生成导入向导,按依赖顺序执行。
|
||||||
- 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。
|
- 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。
|
||||||
不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`;
|
不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`;
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
EditMessageDto,
|
EditMessageDto,
|
||||||
MessagePageQueryDto,
|
MessagePageQueryDto,
|
||||||
RegenerateMessageDto,
|
RegenerateMessageDto,
|
||||||
ResolveImportPreflightDto,
|
|
||||||
SendMessageDto,
|
SendMessageDto,
|
||||||
SubmitFormDto,
|
SubmitFormDto,
|
||||||
SubmitReviewDto,
|
SubmitReviewDto,
|
||||||
@@ -238,23 +237,6 @@ export class AiChatController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('import/preflight/:messageId/resolve/stream')
|
|
||||||
@Throttle({ default: { ttl: 60000, limit: 10 } })
|
|
||||||
async resolveImportPreflight(
|
|
||||||
@Req() req: AuthenticatedRequest,
|
|
||||||
@Res() res: Response,
|
|
||||||
@Param('messageId', ParseIntPipe) messageId: number,
|
|
||||||
@Body() dto: ResolveImportPreflightDto,
|
|
||||||
): Promise<void> {
|
|
||||||
const conversationId = await this.service.resolvePreflightConversationId(
|
|
||||||
req.user.id,
|
|
||||||
messageId,
|
|
||||||
);
|
|
||||||
return this.handleStream(res, dto.clientRequestId, conversationId, (signal, emit, onReady) =>
|
|
||||||
this.service.resolveImportPreflight(req.user, messageId, dto, signal, emit, onReady),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('reviews/:reviewId/steps/:sectionKey/confirm')
|
@Post('reviews/:reviewId/steps/:sectionKey/confirm')
|
||||||
async confirmReviewStep(
|
async confirmReviewStep(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ export async function executeGeneration(
|
|||||||
tool.function.name !== 'create_student' &&
|
tool.function.name !== 'create_student' &&
|
||||||
tool.function.name !== 'update_students' &&
|
tool.function.name !== 'update_students' &&
|
||||||
tool.function.name !== 'render_form' &&
|
tool.function.name !== 'render_form' &&
|
||||||
tool.function.name !== 'start_import_wizard' &&
|
tool.function.name !== 'start_import_wizard',
|
||||||
tool.function.name !== 'preflight_import',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
tools.push(...A2UI_TOOL_SCHEMAS);
|
tools.push(...A2UI_TOOL_SCHEMAS);
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ import {
|
|||||||
} from './ai-chat.streaming';
|
} from './ai-chat.streaming';
|
||||||
import {
|
import {
|
||||||
resolveFormConversationId,
|
resolveFormConversationId,
|
||||||
resolvePreflightConversationId,
|
|
||||||
resolveReviewConversationId,
|
resolveReviewConversationId,
|
||||||
resolveImportPreflight,
|
|
||||||
submitForm,
|
submitForm,
|
||||||
submitReview,
|
submitReview,
|
||||||
confirmReviewStep,
|
confirmReviewStep,
|
||||||
@@ -63,10 +61,7 @@ import {
|
|||||||
markReviewSubmittedOnMessage,
|
markReviewSubmittedOnMessage,
|
||||||
} from './ai-chat.submissions';
|
} from './ai-chat.submissions';
|
||||||
import { denyWriteTool, executeTool } from './ai-chat.tools';
|
import { denyWriteTool, executeTool } from './ai-chat.tools';
|
||||||
import {
|
import { executeStartImportWizard } from './ai-chat.tool-actions';
|
||||||
executePreflightImport,
|
|
||||||
executeStartImportWizard,
|
|
||||||
} from './ai-chat.tool-actions';
|
|
||||||
export abstract class AiChatServiceBase implements AiChatServiceContext {
|
export abstract class AiChatServiceBase implements AiChatServiceContext {
|
||||||
readonly activeConversations = new Set<number>();
|
readonly activeConversations = new Set<number>();
|
||||||
|
|
||||||
@@ -209,15 +204,6 @@ export abstract class AiChatServiceBase implements AiChatServiceContext {
|
|||||||
return executeStartImportWizard(this, messageId, call, context, emit);
|
return executeStartImportWizard(this, messageId, call, context, emit);
|
||||||
}
|
}
|
||||||
|
|
||||||
executePreflightImport(
|
|
||||||
messageId: number,
|
|
||||||
call: ModelToolCall,
|
|
||||||
context: ReturnType<typeof AgentToolContextFactory.fromAuthenticatedUser>,
|
|
||||||
emit: AiSseEmitter,
|
|
||||||
): Promise<string> {
|
|
||||||
return executePreflightImport(this, messageId, call, context, emit);
|
|
||||||
}
|
|
||||||
|
|
||||||
listConversations(userId: number): Promise<PublicConversation[]> {
|
listConversations(userId: number): Promise<PublicConversation[]> {
|
||||||
return listConversations(this, userId);
|
return listConversations(this, userId);
|
||||||
}
|
}
|
||||||
@@ -318,10 +304,6 @@ export abstract class AiChatServiceBase implements AiChatServiceContext {
|
|||||||
return resolveReviewConversationId(this, userId, reviewId);
|
return resolveReviewConversationId(this, userId, reviewId);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvePreflightConversationId(userId: number, messageId: number): Promise<number> {
|
|
||||||
return resolvePreflightConversationId(this, userId, messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
submitForm(
|
submitForm(
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
formId: string,
|
formId: string,
|
||||||
@@ -348,21 +330,6 @@ export abstract class AiChatServiceBase implements AiChatServiceContext {
|
|||||||
return submitReview(this, user, reviewId, dto, signal, emit, onReady);
|
return submitReview(this, user, reviewId, dto, signal, emit, onReady);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveImportPreflight(
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
messageId: number,
|
|
||||||
dto: {
|
|
||||||
clientRequestId: string;
|
|
||||||
mapping?: Record<string, unknown>;
|
|
||||||
settings?: Record<string, unknown>;
|
|
||||||
},
|
|
||||||
signal: AbortSignal,
|
|
||||||
emit: AiSseEmitter,
|
|
||||||
onReady: () => void,
|
|
||||||
): Promise<void> {
|
|
||||||
return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady);
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmReviewStep(
|
confirmReviewStep(
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
reviewId: string,
|
reviewId: string,
|
||||||
|
|||||||
@@ -1503,259 +1503,6 @@ describe('AiChatService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preflight_import 生成预检报告并通过 ui.import_preflight 推送', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const toolRun = { id: 1, status: 'running' };
|
|
||||||
const toolRuns = {
|
|
||||||
create: jest.fn((value) => value),
|
|
||||||
save: jest.fn(async (value) => ({ ...toolRun, ...value })),
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }),
|
|
||||||
save: jest.fn(async (value) => value),
|
|
||||||
};
|
|
||||||
const attachmentService = {
|
|
||||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
originalName: 'students.xlsx',
|
|
||||||
size: 10,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
|
||||||
};
|
|
||||||
const report = {
|
|
||||||
verdict: 'ready',
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
stepKey: 'students',
|
|
||||||
label: '学生档案',
|
|
||||||
sheetNames: ['学生'],
|
|
||||||
total: 2,
|
|
||||||
create: 2,
|
|
||||||
update: 0,
|
|
||||||
error: 0,
|
|
||||||
skip: 0,
|
|
||||||
mapping: { name: '姓名' },
|
|
||||||
missingRequired: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
nextSteps: [],
|
|
||||||
};
|
|
||||||
const importsService = { preflightFile: jest.fn().mockResolvedValue(report) };
|
|
||||||
(service as unknown as { toolRuns: unknown }).toolRuns = toolRuns;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
|
||||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
|
||||||
|
|
||||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
|
||||||
const result = await (
|
|
||||||
service as unknown as {
|
|
||||||
executePreflightImport(
|
|
||||||
messageId: number,
|
|
||||||
call: { id: string; name: string; arguments: string },
|
|
||||||
context: { userId: number; permissions: string[]; isSuperAdmin: boolean },
|
|
||||||
emit: (event: string, data?: unknown) => void,
|
|
||||||
): Promise<string>;
|
|
||||||
}
|
|
||||||
).executePreflightImport(
|
|
||||||
42,
|
|
||||||
{
|
|
||||||
id: 'call-1',
|
|
||||||
name: 'preflight_import',
|
|
||||||
arguments: JSON.stringify({ attachmentId: 9 }),
|
|
||||||
},
|
|
||||||
{ userId: 7, permissions: [], isSuperAdmin: false },
|
|
||||||
(event, data) => emitted.push({ event, data: (data ?? {}) as Record<string, unknown> }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result) as { status: string; report: unknown };
|
|
||||||
expect(parsed.status).toBe('success');
|
|
||||||
expect(parsed.report).toEqual(report);
|
|
||||||
expect(parsed).toMatchObject({ permittedSteps: [] });
|
|
||||||
expect(importsService.preflightFile).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ originalName: 'students.xlsx' }),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
expect(emitted.some(({ event }) => event === 'ui.import_preflight')).toBe(true);
|
|
||||||
expect(messages.save).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
metadata: expect.objectContaining({
|
|
||||||
a2uiImportPreflight: {
|
|
||||||
...report,
|
|
||||||
attachmentId: 9,
|
|
||||||
headerRow: 1,
|
|
||||||
permittedSteps: [],
|
|
||||||
resolved: false,
|
|
||||||
runId: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preflight_import 校验并透传 headerRow', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const toolRun = { id: 1, status: 'running' };
|
|
||||||
const toolRuns = {
|
|
||||||
create: jest.fn((value) => value),
|
|
||||||
save: jest.fn(async (value) => ({ ...toolRun, ...value })),
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }),
|
|
||||||
save: jest.fn(async (value) => value),
|
|
||||||
};
|
|
||||||
const attachmentService = {
|
|
||||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
originalName: 'students.xlsx',
|
|
||||||
size: 10,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
|
||||||
};
|
|
||||||
const report = {
|
|
||||||
verdict: 'ready',
|
|
||||||
stages: [],
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
errorSamples: [],
|
|
||||||
nextSteps: [],
|
|
||||||
};
|
|
||||||
const importsService = { preflightFile: jest.fn().mockResolvedValue(report) };
|
|
||||||
(service as unknown as { toolRuns: unknown }).toolRuns = toolRuns;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
|
||||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
|
||||||
|
|
||||||
const result = await (
|
|
||||||
service as unknown as {
|
|
||||||
executePreflightImport(
|
|
||||||
messageId: number,
|
|
||||||
call: { id: string; name: string; arguments: string },
|
|
||||||
context: { userId: number; permissions: string[]; isSuperAdmin: boolean },
|
|
||||||
emit: (event: string, data?: unknown) => void,
|
|
||||||
): Promise<string>;
|
|
||||||
}
|
|
||||||
).executePreflightImport(
|
|
||||||
42,
|
|
||||||
{
|
|
||||||
id: 'call-1',
|
|
||||||
name: 'preflight_import',
|
|
||||||
arguments: JSON.stringify({ attachmentId: 9, headerRow: 5 }),
|
|
||||||
},
|
|
||||||
{ userId: 7, permissions: ['student:import'], isSuperAdmin: false },
|
|
||||||
jest.fn(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result) as { status: string; permittedSteps: string[] };
|
|
||||||
expect(parsed.status).toBe('success');
|
|
||||||
expect(parsed.permittedSteps).toEqual(['students']);
|
|
||||||
expect(importsService.preflightFile).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ originalName: 'students.xlsx' }),
|
|
||||||
5,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preflight_import 结果超过 32KB 时返回精简版,SSE 仍推送完整报告', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const toolRun = { id: 1, status: 'running' };
|
|
||||||
const toolRuns = {
|
|
||||||
create: jest.fn((value) => value),
|
|
||||||
save: jest.fn(async (value) => ({ ...toolRun, ...value })),
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 42, conversationId: 3, metadata: null }),
|
|
||||||
save: jest.fn(async (value) => value),
|
|
||||||
};
|
|
||||||
const attachmentService = {
|
|
||||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
originalName: 'students.xlsx',
|
|
||||||
size: 10,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
|
||||||
};
|
|
||||||
const report = {
|
|
||||||
verdict: 'needs_input',
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
stepKey: 'students',
|
|
||||||
label: '学生档案',
|
|
||||||
sheetNames: ['学生'],
|
|
||||||
total: 2000,
|
|
||||||
create: 0,
|
|
||||||
update: 0,
|
|
||||||
error: 2000,
|
|
||||||
skip: 0,
|
|
||||||
mapping: { name: '姓名' },
|
|
||||||
missingRequired: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
blocks: [],
|
|
||||||
questions: [{ key: 'mapping_students', type: 'mapping', label: '确认列映射' }],
|
|
||||||
errorSamples: Array.from({ length: 2000 }, (_, index) => ({
|
|
||||||
code: 'format_error',
|
|
||||||
stepKey: 'students',
|
|
||||||
sheet: '学生',
|
|
||||||
rowNumber: index + 2,
|
|
||||||
errors: ['手机号格式不正确:'.repeat(20)],
|
|
||||||
})),
|
|
||||||
nextSteps: [{ key: 'students-next', label: '分班 / 排课 / 入住', description: '下一步' }],
|
|
||||||
};
|
|
||||||
const importsService = { preflightFile: jest.fn().mockResolvedValue(report) };
|
|
||||||
(service as unknown as { toolRuns: unknown }).toolRuns = toolRuns;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
|
||||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
|
||||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
|
||||||
|
|
||||||
const result = await (
|
|
||||||
service as unknown as {
|
|
||||||
executePreflightImport(
|
|
||||||
messageId: number,
|
|
||||||
call: { id: string; name: string; arguments: string },
|
|
||||||
context: { userId: number; permissions: string[]; isSuperAdmin: boolean },
|
|
||||||
emit: (event: string, data?: unknown) => void,
|
|
||||||
): Promise<string>;
|
|
||||||
}
|
|
||||||
).executePreflightImport(
|
|
||||||
42,
|
|
||||||
{
|
|
||||||
id: 'call-1',
|
|
||||||
name: 'preflight_import',
|
|
||||||
arguments: JSON.stringify({ attachmentId: 9 }),
|
|
||||||
},
|
|
||||||
{ userId: 7, permissions: ['student:import'], isSuperAdmin: false },
|
|
||||||
(event, data) => emitted.push({ event, data: (data ?? {}) as Record<string, unknown> }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(result) as {
|
|
||||||
status: string;
|
|
||||||
truncated: boolean;
|
|
||||||
report: { verdict: string; errorSamples: unknown[] };
|
|
||||||
};
|
|
||||||
expect(parsed.status).toBe('success');
|
|
||||||
expect(parsed.truncated).toBe(true);
|
|
||||||
expect(parsed.report.verdict).toBe('needs_input');
|
|
||||||
expect(parsed.report.errorSamples).toHaveLength(10);
|
|
||||||
const preflightEvent = emitted.find(({ event }) => event === 'ui.import_preflight');
|
|
||||||
expect(preflightEvent).toBeDefined();
|
|
||||||
expect(
|
|
||||||
(preflightEvent?.data as { preflight?: { errorSamples?: unknown[] } }).preflight
|
|
||||||
?.errorSamples,
|
|
||||||
).toHaveLength(2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
it('start_import_wizard 拒绝非法的确认参数', async () => {
|
it('start_import_wizard 拒绝非法的确认参数', async () => {
|
||||||
const { service } = createService();
|
const { service } = createService();
|
||||||
const toolRun = { id: 1, status: 'running' };
|
const toolRun = { id: 1, status: 'running' };
|
||||||
@@ -1813,542 +1560,6 @@ describe('AiChatService', () => {
|
|||||||
expect(importsService.createRun).not.toHaveBeenCalled();
|
expect(importsService.createRun).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolveImportPreflight 生成导入任务并原位更新预检卡', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const conversations = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }),
|
|
||||||
};
|
|
||||||
const message = {
|
|
||||||
id: 42,
|
|
||||||
conversationId: 3,
|
|
||||||
role: 'assistant',
|
|
||||||
metadata: {
|
|
||||||
a2uiImportPreflight: {
|
|
||||||
verdict: 'needs_input',
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
stepKey: 'students',
|
|
||||||
label: '学生档案',
|
|
||||||
sheetNames: ['学生'],
|
|
||||||
headers: ['姓名', '学号', '手机号'],
|
|
||||||
mapping: { name: '姓名' },
|
|
||||||
missingRequired: [],
|
|
||||||
total: 1,
|
|
||||||
create: 1,
|
|
||||||
update: 0,
|
|
||||||
error: 0,
|
|
||||||
skip: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
nextSteps: [],
|
|
||||||
errorSamples: [],
|
|
||||||
attachmentId: 9,
|
|
||||||
headerRow: 1,
|
|
||||||
permittedSteps: ['students'],
|
|
||||||
resolved: false,
|
|
||||||
runId: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest.fn().mockResolvedValue(message),
|
|
||||||
save: jest.fn(async (value) => value),
|
|
||||||
exists: jest.fn().mockResolvedValue(false),
|
|
||||||
};
|
|
||||||
const attachmentService = {
|
|
||||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
originalName: 'students.xlsx',
|
|
||||||
size: 10,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
|
||||||
};
|
|
||||||
const importsService = {
|
|
||||||
createRun: jest.fn().mockResolvedValue({
|
|
||||||
id: 'run-9',
|
|
||||||
fileName: 'students.xlsx',
|
|
||||||
sheets: [],
|
|
||||||
steps: [
|
|
||||||
{ stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
(service as unknown as { conversations: unknown }).conversations = conversations;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
|
||||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
|
||||||
|
|
||||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
|
||||||
await service.resolveImportPreflight(
|
|
||||||
authenticatedUser,
|
|
||||||
42,
|
|
||||||
{
|
|
||||||
clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e',
|
|
||||||
mapping: { students: { name: '姓名', studentNo: '学号' } },
|
|
||||||
settings: { updateExisting: false },
|
|
||||||
},
|
|
||||||
new AbortController().signal,
|
|
||||||
(event, data) => emitted.push({ event, data: (data ?? {}) as Record<string, unknown> }),
|
|
||||||
jest.fn(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(importsService.createRun).toHaveBeenCalledWith(
|
|
||||||
{ id: 7, permissions: ['ai:chat:use'], isSuperAdmin: false },
|
|
||||||
'ai',
|
|
||||||
expect.objectContaining({ originalName: 'students.xlsx' }),
|
|
||||||
3,
|
|
||||||
[{ stepKey: 'students', sheets: ['学生'], headerRow: 1 }],
|
|
||||||
{ students: { name: '姓名', studentNo: '学号' } },
|
|
||||||
{ updateExisting: false },
|
|
||||||
);
|
|
||||||
expect(messages.save).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
metadata: expect.objectContaining({
|
|
||||||
a2uiImportPreflight: expect.objectContaining({ resolved: true, runId: 'run-9' }),
|
|
||||||
a2uiImportWizard: expect.objectContaining({ runId: 'run-9' }),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(emitted.map(({ event }) => event)).toEqual(
|
|
||||||
expect.arrayContaining(['ui.import_preflight', 'ui.import_wizard']),
|
|
||||||
);
|
|
||||||
const preflightEvent = emitted.find(({ event }) => event === 'ui.import_preflight');
|
|
||||||
expect(
|
|
||||||
(preflightEvent?.data as { preflight?: { resolved?: boolean; runId?: string | null } })
|
|
||||||
.preflight,
|
|
||||||
).toMatchObject({ resolved: true, runId: 'run-9' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolveImportPreflight 多工作表阶段携带全部 sheetNames 生成导入任务', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const conversations = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }),
|
|
||||||
};
|
|
||||||
const message = {
|
|
||||||
id: 42,
|
|
||||||
conversationId: 3,
|
|
||||||
role: 'assistant',
|
|
||||||
metadata: {
|
|
||||||
a2uiImportPreflight: {
|
|
||||||
verdict: 'needs_input',
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
stepKey: 'checkins',
|
|
||||||
label: '入住管理',
|
|
||||||
sheetNames: ['四人间女', '四人间男'],
|
|
||||||
headers: ['姓名', '学号', '手机号', '宿舍号', '入住日期'],
|
|
||||||
mapping: { name: '姓名', roomNumber: '宿舍号' },
|
|
||||||
missingRequired: [],
|
|
||||||
total: 2,
|
|
||||||
create: 2,
|
|
||||||
update: 0,
|
|
||||||
error: 0,
|
|
||||||
skip: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
nextSteps: [],
|
|
||||||
errorSamples: [],
|
|
||||||
attachmentId: 9,
|
|
||||||
headerRow: 1,
|
|
||||||
permittedSteps: ['checkins'],
|
|
||||||
resolved: false,
|
|
||||||
runId: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest.fn().mockResolvedValue(message),
|
|
||||||
save: jest.fn(async (value) => value),
|
|
||||||
exists: jest.fn().mockResolvedValue(false),
|
|
||||||
};
|
|
||||||
const attachmentService = {
|
|
||||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
originalName: 'dorm.xlsx',
|
|
||||||
size: 10,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
|
||||||
};
|
|
||||||
const importsService = {
|
|
||||||
createRun: jest.fn().mockResolvedValue({
|
|
||||||
id: 'run-9',
|
|
||||||
fileName: 'dorm.xlsx',
|
|
||||||
sheets: [],
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
stepKey: 'checkins',
|
|
||||||
label: '入住管理',
|
|
||||||
sheets: ['四人间女', '四人间男'],
|
|
||||||
status: 'pending',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
(service as unknown as { conversations: unknown }).conversations = conversations;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
|
||||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
|
||||||
|
|
||||||
await service.resolveImportPreflight(
|
|
||||||
authenticatedUser,
|
|
||||||
42,
|
|
||||||
{ clientRequestId: 'multi-sheet', mapping: {}, settings: {} },
|
|
||||||
new AbortController().signal,
|
|
||||||
jest.fn(),
|
|
||||||
jest.fn(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(importsService.createRun).toHaveBeenCalledWith(
|
|
||||||
{ id: 7, permissions: ['ai:chat:use'], isSuperAdmin: false },
|
|
||||||
'ai',
|
|
||||||
expect.objectContaining({ originalName: 'dorm.xlsx' }),
|
|
||||||
3,
|
|
||||||
[{ stepKey: 'checkins', sheets: ['四人间女', '四人间男'], headerRow: 1 }],
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolveImportPreflight 已生成向导时幂等重放,不重复建任务', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const conversations = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }),
|
|
||||||
};
|
|
||||||
const preflight = {
|
|
||||||
verdict: 'ready',
|
|
||||||
stages: [],
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
nextSteps: [],
|
|
||||||
errorSamples: [],
|
|
||||||
attachmentId: 9,
|
|
||||||
headerRow: 1,
|
|
||||||
permittedSteps: [],
|
|
||||||
resolved: true,
|
|
||||||
runId: 'run-1',
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({
|
|
||||||
id: 42,
|
|
||||||
conversationId: 3,
|
|
||||||
role: 'assistant',
|
|
||||||
metadata: {
|
|
||||||
a2uiImportPreflight: preflight,
|
|
||||||
a2uiImportWizard: { runId: 'run-1', fileName: 'students.xlsx', sheets: [], steps: [] },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
exists: jest.fn().mockResolvedValue(false),
|
|
||||||
};
|
|
||||||
const importsService = { createRun: jest.fn() };
|
|
||||||
(service as unknown as { conversations: unknown }).conversations = conversations;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
|
||||||
|
|
||||||
const emitted: Array<{ event: string }> = [];
|
|
||||||
await service.resolveImportPreflight(
|
|
||||||
authenticatedUser,
|
|
||||||
42,
|
|
||||||
{ clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e' },
|
|
||||||
new AbortController().signal,
|
|
||||||
(event) => emitted.push({ event }),
|
|
||||||
jest.fn(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(importsService.createRun).not.toHaveBeenCalled();
|
|
||||||
expect(emitted.map(({ event }) => event)).toEqual(
|
|
||||||
expect.arrayContaining(['ui.import_preflight', 'ui.import_wizard']),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolveImportPreflight 无预检 metadata 时拒绝', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const conversations = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }),
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({
|
|
||||||
id: 42,
|
|
||||||
conversationId: 3,
|
|
||||||
role: 'assistant',
|
|
||||||
metadata: null,
|
|
||||||
}),
|
|
||||||
exists: jest.fn().mockResolvedValue(false),
|
|
||||||
};
|
|
||||||
const importsService = { createRun: jest.fn() };
|
|
||||||
(service as unknown as { conversations: unknown }).conversations = conversations;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.resolveImportPreflight(
|
|
||||||
authenticatedUser,
|
|
||||||
42,
|
|
||||||
{ clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e' },
|
|
||||||
new AbortController().signal,
|
|
||||||
jest.fn(),
|
|
||||||
jest.fn(),
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
expect(importsService.createRun).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolveImportPreflight 拒绝映射到表头之外的列', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const conversations = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }),
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({
|
|
||||||
id: 42,
|
|
||||||
conversationId: 3,
|
|
||||||
role: 'assistant',
|
|
||||||
metadata: {
|
|
||||||
a2uiImportPreflight: {
|
|
||||||
verdict: 'blocked',
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
stepKey: 'students',
|
|
||||||
label: '学生档案',
|
|
||||||
sheetNames: ['学生'],
|
|
||||||
headers: ['姓名', '学号'],
|
|
||||||
mapping: { name: '姓名' },
|
|
||||||
missingRequired: [],
|
|
||||||
total: 1,
|
|
||||||
create: 0,
|
|
||||||
update: 0,
|
|
||||||
error: 0,
|
|
||||||
skip: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
nextSteps: [],
|
|
||||||
errorSamples: [],
|
|
||||||
attachmentId: 9,
|
|
||||||
headerRow: 1,
|
|
||||||
permittedSteps: ['students'],
|
|
||||||
resolved: false,
|
|
||||||
runId: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
exists: jest.fn().mockResolvedValue(false),
|
|
||||||
};
|
|
||||||
const attachmentService = {
|
|
||||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
originalName: 'students.xlsx',
|
|
||||||
size: 10,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
|
||||||
};
|
|
||||||
const importsService = { createRun: jest.fn() };
|
|
||||||
(service as unknown as { conversations: unknown }).conversations = conversations;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
|
||||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.resolveImportPreflight(
|
|
||||||
authenticatedUser,
|
|
||||||
42,
|
|
||||||
{
|
|
||||||
clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e',
|
|
||||||
mapping: { students: { name: '不存在的列' } },
|
|
||||||
},
|
|
||||||
new AbortController().signal,
|
|
||||||
jest.fn(),
|
|
||||||
jest.fn(),
|
|
||||||
),
|
|
||||||
).rejects.toThrow('不在工作表表头中');
|
|
||||||
expect(importsService.createRun).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolveImportPreflight 拒绝非法的策略参数', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const conversations = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }),
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({
|
|
||||||
id: 42,
|
|
||||||
conversationId: 3,
|
|
||||||
role: 'assistant',
|
|
||||||
metadata: {
|
|
||||||
a2uiImportPreflight: {
|
|
||||||
verdict: 'ready',
|
|
||||||
stages: [],
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
nextSteps: [],
|
|
||||||
errorSamples: [],
|
|
||||||
attachmentId: 9,
|
|
||||||
headerRow: 1,
|
|
||||||
permittedSteps: [],
|
|
||||||
resolved: false,
|
|
||||||
runId: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
exists: jest.fn().mockResolvedValue(false),
|
|
||||||
};
|
|
||||||
const attachmentService = {
|
|
||||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
originalName: 'students.xlsx',
|
|
||||||
size: 10,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
|
||||||
};
|
|
||||||
const importsService = { createRun: jest.fn() };
|
|
||||||
(service as unknown as { conversations: unknown }).conversations = conversations;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
|
||||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.resolveImportPreflight(
|
|
||||||
authenticatedUser,
|
|
||||||
42,
|
|
||||||
{
|
|
||||||
clientRequestId: 'a3a30252-f2d6-44b6-8975-f2a1a5e3d17e',
|
|
||||||
settings: { duplicatePolicy: 'bogus' },
|
|
||||||
},
|
|
||||||
new AbortController().signal,
|
|
||||||
jest.fn(),
|
|
||||||
jest.fn(),
|
|
||||||
),
|
|
||||||
).rejects.toThrow('duplicatePolicy');
|
|
||||||
expect(importsService.createRun).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolvePreflightConversationId 返回消息所属会话', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const conversations = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 3, userId: 7 }),
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({
|
|
||||||
id: 42,
|
|
||||||
role: 'assistant',
|
|
||||||
conversation: { id: 3, userId: 7 },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
(service as unknown as { conversations: unknown }).conversations = conversations;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
|
|
||||||
await expect(service.resolvePreflightConversationId(7, 42)).resolves.toBe(3);
|
|
||||||
expect(messages.findOne).toHaveBeenCalledWith({
|
|
||||||
where: { id: 42 },
|
|
||||||
relations: { conversation: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('start_import_wizard 在已有预检卡时同步标记 resolved', async () => {
|
|
||||||
const { service } = createService();
|
|
||||||
const toolRun = { id: 1, status: 'running' };
|
|
||||||
const toolRuns = {
|
|
||||||
create: jest.fn((value) => value),
|
|
||||||
save: jest.fn(async (value) => ({ ...toolRun, ...value })),
|
|
||||||
};
|
|
||||||
const messages = {
|
|
||||||
findOne: jest.fn().mockResolvedValue({
|
|
||||||
id: 42,
|
|
||||||
conversationId: 3,
|
|
||||||
metadata: {
|
|
||||||
a2uiImportPreflight: {
|
|
||||||
verdict: 'ready',
|
|
||||||
stages: [],
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
nextSteps: [],
|
|
||||||
errorSamples: [],
|
|
||||||
attachmentId: 9,
|
|
||||||
headerRow: 1,
|
|
||||||
permittedSteps: ['students'],
|
|
||||||
resolved: false,
|
|
||||||
runId: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
save: jest.fn(async (value) => value),
|
|
||||||
};
|
|
||||||
const attachmentService = {
|
|
||||||
requireReadyOwned: jest.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
originalName: 'students.xlsx',
|
|
||||||
size: 10,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')),
|
|
||||||
};
|
|
||||||
const importsService = {
|
|
||||||
createRun: jest.fn().mockResolvedValue({
|
|
||||||
id: 'run-9',
|
|
||||||
fileName: 'students.xlsx',
|
|
||||||
sheets: [],
|
|
||||||
steps: [
|
|
||||||
{ stepKey: 'students', label: '学生档案', sheets: ['学生'], status: 'pending' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
(service as unknown as { toolRuns: unknown }).toolRuns = toolRuns;
|
|
||||||
(service as unknown as { messages: unknown }).messages = messages;
|
|
||||||
(service as unknown as { attachmentService: unknown }).attachmentService = attachmentService;
|
|
||||||
(service as unknown as { importsService: unknown }).importsService = importsService;
|
|
||||||
|
|
||||||
const emitted: Array<{ event: string; data: Record<string, unknown> }> = [];
|
|
||||||
await (
|
|
||||||
service as unknown as {
|
|
||||||
executeStartImportWizard(
|
|
||||||
messageId: number,
|
|
||||||
call: { id: string; name: string; arguments: string },
|
|
||||||
context: { userId: number; permissions: string[]; isSuperAdmin: boolean },
|
|
||||||
emit: (event: string, data?: unknown) => void,
|
|
||||||
): Promise<string>;
|
|
||||||
}
|
|
||||||
).executeStartImportWizard(
|
|
||||||
42,
|
|
||||||
{
|
|
||||||
id: 'call-1',
|
|
||||||
name: 'start_import_wizard',
|
|
||||||
arguments: JSON.stringify({
|
|
||||||
attachmentId: 9,
|
|
||||||
stages: [{ stepKey: 'students', sheet: '学生' }],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ userId: 7, permissions: ['student:import'], isSuperAdmin: false },
|
|
||||||
(event, data) => emitted.push({ event, data: (data ?? {}) as Record<string, unknown> }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(messages.save).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
metadata: expect.objectContaining({
|
|
||||||
a2uiImportPreflight: expect.objectContaining({ resolved: true, runId: 'run-9' }),
|
|
||||||
a2uiImportWizard: expect.objectContaining({ runId: 'run-9' }),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(emitted.some(({ event }) => event === 'ui.import_preflight')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
||||||
import type {
|
import type {
|
||||||
AiChatServiceContext,
|
AiChatServiceContext,
|
||||||
AiSseEmitter,
|
AiSseEmitter,
|
||||||
} from './ai-chat.types';
|
} from './ai-chat.types';
|
||||||
import type { AuthenticatedUser } from '../authorization';
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
import type {
|
|
||||||
ImportStageRequest,
|
|
||||||
ImportStepKey,
|
|
||||||
PreflightReport,
|
|
||||||
} from '../imports/imports.types';
|
|
||||||
import {
|
|
||||||
isExcelAttachment,
|
|
||||||
parseConfirmedMapping,
|
|
||||||
parseNestedSettings,
|
|
||||||
} from './ai-chat.import-confirm';
|
|
||||||
import { compactImportWizard } from './ai-chat.tool-actions';
|
|
||||||
|
|
||||||
export async function resolveFormConversationId(
|
export async function resolveFormConversationId(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
@@ -34,149 +22,6 @@ export async function resolveReviewConversationId(
|
|||||||
return review.conversationId;
|
return review.conversationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolvePreflightConversationId(
|
|
||||||
context: AiChatServiceContext,
|
|
||||||
userId: number,
|
|
||||||
messageId: number,
|
|
||||||
): Promise<number> {
|
|
||||||
const message = await context.messages.findOne({
|
|
||||||
where: { id: messageId },
|
|
||||||
relations: { conversation: true },
|
|
||||||
});
|
|
||||||
if (!message) throw new NotFoundException('消息不存在');
|
|
||||||
if (message.role !== 'assistant') {
|
|
||||||
throw new BadRequestException('该消息不是助手消息,无法确认导入预检');
|
|
||||||
}
|
|
||||||
const conversation = await context.requireOwnedConversation(userId, message.conversation.id);
|
|
||||||
return conversation.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPreflightCard(
|
|
||||||
metadata: Record<string, unknown> | null | undefined,
|
|
||||||
): PreflightReport {
|
|
||||||
const card = metadata?.a2uiImportPreflight;
|
|
||||||
if (!card || typeof card !== 'object' || Array.isArray(card)) {
|
|
||||||
throw new BadRequestException('预检报告不存在或已失效');
|
|
||||||
}
|
|
||||||
if (!isPreflightReport(card)) {
|
|
||||||
throw new BadRequestException('预检报告格式异常');
|
|
||||||
}
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPreflightReport(value: object): value is PreflightReport {
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
typeof record.verdict === 'string' &&
|
|
||||||
Array.isArray(record.stages) &&
|
|
||||||
Array.isArray(record.blocks) &&
|
|
||||||
Array.isArray(record.questions) &&
|
|
||||||
Array.isArray(record.nextSteps) &&
|
|
||||||
Array.isArray(record.errorSamples)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveImportPreflight(
|
|
||||||
context: AiChatServiceContext,
|
|
||||||
user: AuthenticatedUser,
|
|
||||||
messageId: number,
|
|
||||||
dto: {
|
|
||||||
clientRequestId: string;
|
|
||||||
mapping?: Record<string, unknown>;
|
|
||||||
settings?: Record<string, unknown>;
|
|
||||||
},
|
|
||||||
signal: AbortSignal,
|
|
||||||
emit: AiSseEmitter,
|
|
||||||
onReady: () => void,
|
|
||||||
): Promise<void> {
|
|
||||||
context.throwIfAborted(signal);
|
|
||||||
const message = await context.messages.findOne({ where: { id: messageId } });
|
|
||||||
if (!message) throw new NotFoundException('消息不存在');
|
|
||||||
if (message.role !== 'assistant') {
|
|
||||||
throw new BadRequestException('该消息不是助手消息,无法确认导入预检');
|
|
||||||
}
|
|
||||||
const conversation = await context.requireOwnedConversation(user.id, message.conversationId);
|
|
||||||
const preflight = readPreflightCard(message.metadata);
|
|
||||||
|
|
||||||
await context.acquireConversation(conversation.id);
|
|
||||||
try {
|
|
||||||
context.throwIfAborted(signal);
|
|
||||||
const existingWizard = message.metadata?.a2uiImportWizard;
|
|
||||||
if (existingWizard && typeof existingWizard === 'object' && !Array.isArray(existingWizard)) {
|
|
||||||
onReady();
|
|
||||||
emit('ui.import_preflight', {
|
|
||||||
messageId,
|
|
||||||
preflight: { ...preflight, resolved: true },
|
|
||||||
});
|
|
||||||
emit('ui.import_wizard', { messageId, wizard: existingWizard });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachmentId = preflight.attachmentId;
|
|
||||||
const headerRow = preflight.headerRow ?? 1;
|
|
||||||
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) {
|
|
||||||
throw new BadRequestException('预检报告缺少附件信息,请重新预检');
|
|
||||||
}
|
|
||||||
const [attachment] = await context.attachmentService.requireReadyOwned(user.id, [
|
|
||||||
attachmentId as number,
|
|
||||||
]);
|
|
||||||
if (!isExcelAttachment(attachment)) {
|
|
||||||
throw new BadRequestException('附件不是 Excel 文件,无法生成导入向导');
|
|
||||||
}
|
|
||||||
const stages: ImportStageRequest[] = preflight.stages.map((stage) => ({
|
|
||||||
stepKey: stage.stepKey,
|
|
||||||
sheets: stage.sheetNames,
|
|
||||||
headerRow,
|
|
||||||
}));
|
|
||||||
if (stages.some((stage) => !stage.sheets || stage.sheets.length === 0)) {
|
|
||||||
throw new BadRequestException('预检报告缺少工作表信息,请重新预检');
|
|
||||||
}
|
|
||||||
const allowedHeadersByStep: Partial<Record<ImportStepKey, string[]>> = {};
|
|
||||||
for (const stage of preflight.stages) {
|
|
||||||
allowedHeadersByStep[stage.stepKey] = stage.headers ?? [];
|
|
||||||
}
|
|
||||||
const mapping = parseConfirmedMapping(dto.mapping, { allowedHeadersByStep });
|
|
||||||
const settings = parseNestedSettings(dto.settings);
|
|
||||||
if (!context.importsService) throw new BadRequestException('导入向导服务未配置');
|
|
||||||
|
|
||||||
const buffer = await context.attachmentService.readStoredBuffer(attachment);
|
|
||||||
const detail = await context.importsService.createRun(
|
|
||||||
{
|
|
||||||
id: user.id,
|
|
||||||
permissions: [...user.permissions],
|
|
||||||
isSuperAdmin: user.isSuperAdmin,
|
|
||||||
},
|
|
||||||
'ai',
|
|
||||||
{
|
|
||||||
originalName: attachment.originalName,
|
|
||||||
mimeType: attachment.mimeType,
|
|
||||||
size: attachment.size,
|
|
||||||
buffer,
|
|
||||||
},
|
|
||||||
conversation.id,
|
|
||||||
stages,
|
|
||||||
mapping,
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
const wizard = compactImportWizard(detail);
|
|
||||||
message.metadata = {
|
|
||||||
...message.metadata,
|
|
||||||
a2uiImportPreflight: { ...preflight, resolved: true, runId: detail.id },
|
|
||||||
a2uiImportWizard: wizard,
|
|
||||||
};
|
|
||||||
await context.messages.save(message);
|
|
||||||
|
|
||||||
onReady();
|
|
||||||
emit('ui.import_preflight', {
|
|
||||||
messageId,
|
|
||||||
preflight: { ...preflight, resolved: true, runId: detail.id },
|
|
||||||
});
|
|
||||||
emit('ui.import_wizard', { messageId, wizard });
|
|
||||||
} finally {
|
|
||||||
context.activeConversations.delete(conversation.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
assertReviewImportPermissions,
|
assertReviewImportPermissions,
|
||||||
confirmReviewGroup,
|
confirmReviewGroup,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
IMPORT_STEP_KEYS,
|
IMPORT_STEP_KEYS,
|
||||||
type ImportStageRequest,
|
type ImportStageRequest,
|
||||||
type ImportStepKey,
|
type ImportStepKey,
|
||||||
type PreflightReport,
|
|
||||||
} from '../imports/imports.types';
|
} from '../imports/imports.types';
|
||||||
import { permittedStepKeys } from '../imports/imports.access';
|
import { permittedStepKeys } from '../imports/imports.access';
|
||||||
import { expandStageSheets } from '../imports/imports.mapping';
|
import { expandStageSheets } from '../imports/imports.mapping';
|
||||||
@@ -100,76 +99,13 @@ type ImportToolExecutor = (
|
|||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
|
|
||||||
function makeImportToolExecutor(
|
function makeImportToolExecutor(
|
||||||
toolName: 'preflight_import' | 'start_import_wizard',
|
toolName: 'start_import_wizard',
|
||||||
handler: (tool: ImportToolContext) => Promise<string>,
|
handler: (tool: ImportToolContext) => Promise<string>,
|
||||||
): ImportToolExecutor {
|
): ImportToolExecutor {
|
||||||
return (context, messageId, call, agentContext, emit) =>
|
return (context, messageId, call, agentContext, emit) =>
|
||||||
runImportTool(context, messageId, call, agentContext, emit, toolName, handler);
|
runImportTool(context, messageId, call, agentContext, emit, toolName, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const executePreflightImport = makeImportToolExecutor(
|
|
||||||
'preflight_import',
|
|
||||||
async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => {
|
|
||||||
const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs);
|
|
||||||
const headerRow =
|
|
||||||
parsedRecord.headerRow === undefined ? 1 : Number(parsedRecord.headerRow);
|
|
||||||
if (!Number.isInteger(headerRow) || headerRow < 1 || headerRow > 1000) {
|
|
||||||
throw new Error('headerRow 必须是 1-1000 之间的整数');
|
|
||||||
}
|
|
||||||
const [attachment] = await context.attachmentService.requireReadyOwned(ac.userId, [
|
|
||||||
attachmentId as number,
|
|
||||||
]);
|
|
||||||
if (!isExcelAttachment(attachment)) {
|
|
||||||
throw new Error('附件不是 Excel 文件,无法预检导入');
|
|
||||||
}
|
|
||||||
if (!context.importsService) throw new Error('导入预检服务未配置');
|
|
||||||
const buffer = await context.attachmentService.readStoredBuffer(attachment);
|
|
||||||
const preflight: PreflightReport = await context.importsService.preflightFile({
|
|
||||||
originalName: attachment.originalName,
|
|
||||||
mimeType: attachment.mimeType,
|
|
||||||
size: attachment.size,
|
|
||||||
buffer,
|
|
||||||
}, headerRow);
|
|
||||||
const permittedSteps = permittedStepKeys({
|
|
||||||
id: ac.userId,
|
|
||||||
permissions: [...ac.permissions],
|
|
||||||
isSuperAdmin: ac.isSuperAdmin,
|
|
||||||
});
|
|
||||||
const preflightCard: PreflightReport = {
|
|
||||||
...preflight,
|
|
||||||
attachmentId: attachment.id,
|
|
||||||
headerRow,
|
|
||||||
permittedSteps,
|
|
||||||
resolved: false,
|
|
||||||
runId: null,
|
|
||||||
};
|
|
||||||
assistant.metadata = {
|
|
||||||
...assistant.metadata,
|
|
||||||
a2uiImportPreflight: preflightCard,
|
|
||||||
};
|
|
||||||
await context.messages.save(assistant);
|
|
||||||
|
|
||||||
await finishToolRun(context, run, call, startedAt, {
|
|
||||||
status: 'success',
|
|
||||||
summary: `已完成导入预检:${preflight.stages
|
|
||||||
.map((stage) => `${stage.label} ${stage.total} 行`)
|
|
||||||
.join('、') || '未识别到可导入阶段'}`,
|
|
||||||
}, emit);
|
|
||||||
emit('ui.import_preflight', { messageId, preflight: preflightCard });
|
|
||||||
emit('ui.artifact', {
|
|
||||||
messageId,
|
|
||||||
artifact: buildA2uiArtifact({
|
|
||||||
type: 'import_preflight',
|
|
||||||
id: `preflight-${attachment.id}`,
|
|
||||||
status: 'pending',
|
|
||||||
messageId,
|
|
||||||
conversationId: assistant.conversationId,
|
|
||||||
payload: preflightCard,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return preflightModelPayload(preflight, permittedSteps);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const executeStartImportWizard = makeImportToolExecutor(
|
export const executeStartImportWizard = makeImportToolExecutor(
|
||||||
'start_import_wizard',
|
'start_import_wizard',
|
||||||
async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => {
|
async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => {
|
||||||
@@ -219,18 +155,8 @@ export const executeStartImportWizard = makeImportToolExecutor(
|
|||||||
settings,
|
settings,
|
||||||
);
|
);
|
||||||
const wizard = compactImportWizard(detail);
|
const wizard = compactImportWizard(detail);
|
||||||
const preflightMeta = assistant.metadata?.a2uiImportPreflight;
|
|
||||||
assistant.metadata = {
|
assistant.metadata = {
|
||||||
...assistant.metadata,
|
...assistant.metadata,
|
||||||
...(preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)
|
|
||||||
? {
|
|
||||||
a2uiImportPreflight: {
|
|
||||||
...(preflightMeta as Record<string, unknown>),
|
|
||||||
resolved: true,
|
|
||||||
runId: detail.id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
a2uiImportWizard: wizard,
|
a2uiImportWizard: wizard,
|
||||||
};
|
};
|
||||||
await context.messages.save(assistant);
|
await context.messages.save(assistant);
|
||||||
@@ -242,16 +168,6 @@ export const executeStartImportWizard = makeImportToolExecutor(
|
|||||||
.map((step) => step.label)
|
.map((step) => step.label)
|
||||||
.join('、')}`,
|
.join('、')}`,
|
||||||
}, emit);
|
}, emit);
|
||||||
if (preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)) {
|
|
||||||
emit('ui.import_preflight', {
|
|
||||||
messageId,
|
|
||||||
preflight: {
|
|
||||||
...(preflightMeta as Record<string, unknown>),
|
|
||||||
resolved: true,
|
|
||||||
runId: detail.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
emit('ui.import_wizard', { messageId, wizard });
|
emit('ui.import_wizard', { messageId, wizard });
|
||||||
emit('ui.artifact', {
|
emit('ui.artifact', {
|
||||||
messageId,
|
messageId,
|
||||||
@@ -279,46 +195,6 @@ export const executeStartImportWizard = makeImportToolExecutor(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function preflightModelPayload(
|
|
||||||
report: PreflightReport,
|
|
||||||
permittedSteps: ImportStepKey[],
|
|
||||||
): string {
|
|
||||||
const guidance =
|
|
||||||
'预检报告已以卡片展示:请引导用户在卡内确认列映射与策略并点击「生成导入向导」;' +
|
|
||||||
'仅当用户在聊天文本中显式给出确认时才调用 start_import_wizard';
|
|
||||||
const fullPayload = JSON.stringify({
|
|
||||||
status: 'success',
|
|
||||||
report,
|
|
||||||
permittedSteps,
|
|
||||||
message: guidance,
|
|
||||||
});
|
|
||||||
if (fullPayload.length <= 32 * 1024) return fullPayload;
|
|
||||||
return JSON.stringify({
|
|
||||||
status: 'success',
|
|
||||||
truncated: true,
|
|
||||||
report: {
|
|
||||||
verdict: report.verdict,
|
|
||||||
stages: report.stages.map((stage) => ({
|
|
||||||
stepKey: stage.stepKey,
|
|
||||||
label: stage.label,
|
|
||||||
sheetNames: stage.sheetNames,
|
|
||||||
total: stage.total,
|
|
||||||
create: stage.create,
|
|
||||||
update: stage.update,
|
|
||||||
error: stage.error,
|
|
||||||
skip: stage.skip,
|
|
||||||
mapping: stage.mapping,
|
|
||||||
missingRequired: stage.missingRequired,
|
|
||||||
})),
|
|
||||||
questions: report.questions,
|
|
||||||
errorSamples: report.errorSamples.slice(0, 10),
|
|
||||||
nextSteps: report.nextSteps,
|
|
||||||
},
|
|
||||||
permittedSteps,
|
|
||||||
message: guidance,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compactImportWizard(detail: any): {
|
export function compactImportWizard(detail: any): {
|
||||||
runId: string;
|
runId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|||||||
@@ -270,6 +270,5 @@ export async function executeRenderChart(
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
compactImportWizard,
|
compactImportWizard,
|
||||||
executePreflightImport,
|
|
||||||
executeStartImportWizard,
|
executeStartImportWizard,
|
||||||
} from './ai-chat.tool-actions.import';
|
} from './ai-chat.tool-actions.import';
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { AgentToolContextFactory } from '../agent-tools/agent-tool.types';
|
|||||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||||
import type { AiToolRun } from './entities';
|
import type { AiToolRun } from './entities';
|
||||||
import {
|
import {
|
||||||
executePreflightImport,
|
|
||||||
executeRenderChart,
|
executeRenderChart,
|
||||||
executeRenderForm,
|
executeRenderForm,
|
||||||
executeStartImportWizard,
|
executeStartImportWizard,
|
||||||
@@ -89,9 +88,6 @@ export async function executeTool(
|
|||||||
if (call.name === 'render_form') {
|
if (call.name === 'render_form') {
|
||||||
return executeRenderForm(context, messageId, call, userId, emit);
|
return executeRenderForm(context, messageId, call, userId, emit);
|
||||||
}
|
}
|
||||||
if (call.name === 'preflight_import') {
|
|
||||||
return executePreflightImport(context, messageId, call, agentContext, emit);
|
|
||||||
}
|
|
||||||
if (call.name === 'start_import_wizard') {
|
if (call.name === 'start_import_wizard') {
|
||||||
return executeStartImportWizard(context, messageId, call, agentContext, emit);
|
return executeStartImportWizard(context, messageId, call, agentContext, emit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ export type AiSseEventName =
|
|||||||
| 'ui.review'
|
| 'ui.review'
|
||||||
| 'ui.chart'
|
| 'ui.chart'
|
||||||
| 'ui.artifact'
|
| 'ui.artifact'
|
||||||
| 'ui.import_preflight'
|
|
||||||
| 'ui.import_wizard'
|
| 'ui.import_wizard'
|
||||||
| 'attachment.processed'
|
| 'attachment.processed'
|
||||||
| 'message.completed'
|
| 'message.completed'
|
||||||
|
|||||||
@@ -110,19 +110,6 @@ export class SubmitReviewDto {
|
|||||||
reasoningEffort?: string | null;
|
reasoningEffort?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ResolveImportPreflightDto {
|
|
||||||
@IsUUID()
|
|
||||||
clientRequestId: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
mapping?: Record<string, unknown>;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
settings?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MessagePageQueryDto {
|
export class MessagePageQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class ImportRun {
|
|||||||
@Column({ name: 'sheets_json', type: 'mediumtext' })
|
@Column({ name: 'sheets_json', type: 'mediumtext' })
|
||||||
sheetsJson: string;
|
sheetsJson: string;
|
||||||
|
|
||||||
/** Serialized ImportRunSettings — confirmed mapping/policies from AI preflight. */
|
/** Serialized ImportRunSettings — mapping/policies confirmed by the user. */
|
||||||
@Column({ name: 'settings_json', type: 'text', nullable: true })
|
@Column({ name: 'settings_json', type: 'text', nullable: true })
|
||||||
settingsJson: string | null;
|
settingsJson: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
import { Organization } from '../entities/organization.entity';
|
|
||||||
import { Room } from '../entities/room.entity';
|
|
||||||
import { Student } from '../entities/student.entity';
|
|
||||||
import { Occupancy } from '../entities/occupancy.entity';
|
|
||||||
import { buildPreflightReport } from './imports.preflight';
|
|
||||||
import type { ImportSheetData } from './imports.workbook';
|
|
||||||
|
|
||||||
function sheet(name: string, headers: string[], rows: unknown[][]): ImportSheetData {
|
|
||||||
return { name, headers, rows: rows as ImportSheetData['rows'] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function dataSourceOf(options: {
|
|
||||||
students?: Student[];
|
|
||||||
rooms?: Room[];
|
|
||||||
organizations?: Organization[];
|
|
||||||
occupancies?: Occupancy[];
|
|
||||||
} = {}) {
|
|
||||||
return {
|
|
||||||
getRepository: jest.fn((entity: unknown) => {
|
|
||||||
if (entity === Student) return { find: jest.fn().mockResolvedValue(options.students ?? []) };
|
|
||||||
if (entity === Room) return { find: jest.fn().mockResolvedValue(options.rooms ?? []) };
|
|
||||||
if (entity === Organization) {
|
|
||||||
return { find: jest.fn().mockResolvedValue(options.organizations ?? []) };
|
|
||||||
}
|
|
||||||
if (entity === Occupancy) {
|
|
||||||
return { find: jest.fn().mockResolvedValue(options.occupancies ?? []) };
|
|
||||||
}
|
|
||||||
return { find: jest.fn().mockResolvedValue([]) };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('buildPreflightReport', () => {
|
|
||||||
it('全新学生表判定为 ready,给出分阶段统计与下一步建议', async () => {
|
|
||||||
const report = await buildPreflightReport(
|
|
||||||
dataSourceOf({ organizations: [{ id: 1, name: '主校区' }] }) as never,
|
|
||||||
[
|
|
||||||
sheet('学生', ['姓名', '学号', '手机号'], [
|
|
||||||
['张三', '2024001', '13800138000'],
|
|
||||||
['李四', '2024002', '13900139000'],
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(report.verdict).toBe('ready');
|
|
||||||
expect(report.questions).toEqual([]);
|
|
||||||
expect(report.stages).toHaveLength(1);
|
|
||||||
expect(report.stages[0]).toMatchObject({
|
|
||||||
stepKey: 'students',
|
|
||||||
total: 2,
|
|
||||||
create: 2,
|
|
||||||
update: 0,
|
|
||||||
error: 0,
|
|
||||||
skip: 0,
|
|
||||||
headers: ['姓名', '学号', '手机号'],
|
|
||||||
mapping: { name: '姓名', studentNo: '学号', phone: '手机号' },
|
|
||||||
});
|
|
||||||
expect(report.blocks).toEqual([]);
|
|
||||||
expect(report.nextSteps.some((step) => step.key === 'students-next')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('已匹配记录时判定为 needs_input 并提出更新策略问题', async () => {
|
|
||||||
const existing = {
|
|
||||||
id: 88,
|
|
||||||
name: '张三',
|
|
||||||
studentNo: '2024001',
|
|
||||||
phone: '13800138000',
|
|
||||||
} as Student;
|
|
||||||
const report = await buildPreflightReport(
|
|
||||||
dataSourceOf({ students: [existing] }) as never,
|
|
||||||
[sheet('学生', ['姓名', '学号'], [['张三', '2024001']])],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(report.verdict).toBe('needs_input');
|
|
||||||
expect(report.stages[0]).toMatchObject({ total: 1, create: 0, update: 1 });
|
|
||||||
expect(report.questions.some((question) => question.type === 'update')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('缺少必填列时判定为 blocked 并归因 missing_columns', async () => {
|
|
||||||
const report = await buildPreflightReport(
|
|
||||||
dataSourceOf() as never,
|
|
||||||
[sheet('宿舍', ['宿舍号', '楼栋'], [['A101', '1号楼']])],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(report.verdict).toBe('blocked');
|
|
||||||
expect(report.blocks).toContainEqual(
|
|
||||||
expect.objectContaining({ code: 'missing_columns', count: 1, stepKeys: ['rooms'] }),
|
|
||||||
);
|
|
||||||
expect(report.stages[0].missingRequired).toContain('容量');
|
|
||||||
expect(report.questions.some((question) => question.type === 'mapping')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无法识别任何业务表时判定为 blocked', async () => {
|
|
||||||
const report = await buildPreflightReport(
|
|
||||||
dataSourceOf() as never,
|
|
||||||
[sheet('杂项', ['A', 'B'], [['x', 'y']])],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(report.verdict).toBe('blocked');
|
|
||||||
expect(report.blocks).toContainEqual(expect.objectContaining({ code: 'no_stages' }));
|
|
||||||
expect(report.stages).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('文件内重复入住归因 duplicate_in_file 并提出重复策略问题', async () => {
|
|
||||||
const student = {
|
|
||||||
id: 88,
|
|
||||||
name: '张三',
|
|
||||||
studentNo: '2024001',
|
|
||||||
phone: '13800138000',
|
|
||||||
} as Student;
|
|
||||||
const room = { id: 5, roomNumber: 'A101' } as Room;
|
|
||||||
const report = await buildPreflightReport(
|
|
||||||
dataSourceOf({ students: [student], rooms: [room] }) as never,
|
|
||||||
[
|
|
||||||
sheet('入住', ['姓名', '学号', '房间号', '入住日期'], [
|
|
||||||
['张三', '2024001', 'A101', '2026-09-01'],
|
|
||||||
['张三', '2024001', 'A101', '2026-09-02'],
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(report.verdict).toBe('needs_input');
|
|
||||||
expect(report.stages[0]).toMatchObject({ stepKey: 'checkins', total: 2, create: 1, error: 1 });
|
|
||||||
expect(report.blocks).toContainEqual(
|
|
||||||
expect.objectContaining({ code: 'duplicate_in_file', count: 1, stepKeys: ['checkins'] }),
|
|
||||||
);
|
|
||||||
expect(report.questions.some((question) => question.type === 'duplicate')).toBe(true);
|
|
||||||
expect(report.errorSamples).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
code: 'duplicate_in_file',
|
|
||||||
stepKey: 'checkins',
|
|
||||||
sheet: '入住',
|
|
||||||
rowNumber: 3,
|
|
||||||
errors: expect.arrayContaining([expect.stringContaining('请勿重复导入')]),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('未知校区归因 unknown_organization 并提出校区归属问题', async () => {
|
|
||||||
const report = await buildPreflightReport(
|
|
||||||
dataSourceOf({ organizations: [{ id: 1, name: '主校区' }] }) as never,
|
|
||||||
[sheet('学生', ['姓名', '学号', '校区'], [['张三', '2024001', '东校区']])],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(report.verdict).toBe('needs_input');
|
|
||||||
expect(report.blocks).toContainEqual(
|
|
||||||
expect.objectContaining({ code: 'unknown_organization', count: 1 }),
|
|
||||||
);
|
|
||||||
const orgQuestion = report.questions.find((question) => question.type === 'organization');
|
|
||||||
expect(orgQuestion).toBeDefined();
|
|
||||||
expect(orgQuestion?.options?.map((option) => option.value)).toContain('主校区');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('入住找不到学生/宿舍归因引用缺失并提出未匹配处理问题', async () => {
|
|
||||||
const student = {
|
|
||||||
id: 88,
|
|
||||||
name: '张三',
|
|
||||||
studentNo: '2024001',
|
|
||||||
phone: '13800138000',
|
|
||||||
} as Student;
|
|
||||||
const report = await buildPreflightReport(
|
|
||||||
dataSourceOf({ students: [student] }) as never,
|
|
||||||
[
|
|
||||||
sheet('入住', ['姓名', '学号', '房间号', '入住日期'], [
|
|
||||||
['张三', '2024001', 'A101', '2026-09-01'],
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(report.verdict).toBe('needs_input');
|
|
||||||
expect(report.blocks).toContainEqual(
|
|
||||||
expect.objectContaining({ code: 'room_not_found', count: 1, stepKeys: ['checkins'] }),
|
|
||||||
);
|
|
||||||
expect(report.questions.some((question) => question.type === 'reference')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('格式错误归因 format_error', async () => {
|
|
||||||
const report = await buildPreflightReport(
|
|
||||||
dataSourceOf() as never,
|
|
||||||
[sheet('学生', ['姓名', '手机号'], [['张三', '123']])],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(report.blocks).toContainEqual(
|
|
||||||
expect.objectContaining({ code: 'format_error', count: 1, stepKeys: ['students'] }),
|
|
||||||
);
|
|
||||||
expect(report.verdict).toBe('blocked');
|
|
||||||
expect(report.errorSamples).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
code: 'format_error',
|
|
||||||
stepKey: 'students',
|
|
||||||
sheet: '学生',
|
|
||||||
rowNumber: 2,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('同一阶段多张工作表且表头不一致时按表解析列映射', async () => {
|
|
||||||
const students = [
|
|
||||||
{ id: 88, name: '张三', studentNo: '2024001', phone: '13800138000' } as Student,
|
|
||||||
{ id: 89, name: '李四', studentNo: '2024002', phone: '13900139000' } as Student,
|
|
||||||
];
|
|
||||||
const room = { id: 5, roomNumber: 'A101' } as Room;
|
|
||||||
const report = await buildPreflightReport(
|
|
||||||
dataSourceOf({ students, rooms: [room] }) as never,
|
|
||||||
[
|
|
||||||
sheet('四人间女', ['姓名', '学号', '宿舍号', '入住日期'], [
|
|
||||||
['张三', '2024001', 'A101', '2026-09-01'],
|
|
||||||
]),
|
|
||||||
sheet('四人间男', ['学生姓名', '学号', '房号', '日期'], [
|
|
||||||
['李四', '2024002', 'A101', '2026-09-02'],
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(report.verdict).toBe('ready');
|
|
||||||
const stage = report.stages.find((item) => item.stepKey === 'checkins');
|
|
||||||
expect(stage).toBeDefined();
|
|
||||||
expect(stage).toMatchObject({
|
|
||||||
sheetNames: ['四人间女', '四人间男'],
|
|
||||||
total: 2,
|
|
||||||
create: 2,
|
|
||||||
update: 0,
|
|
||||||
error: 0,
|
|
||||||
});
|
|
||||||
expect(stage?.mapping).toEqual({
|
|
||||||
name: expect.stringMatching(/^姓名|学生姓名$/),
|
|
||||||
studentNo: '学号',
|
|
||||||
roomNumber: expect.stringMatching(/^宿舍号|房号$/),
|
|
||||||
checkInDate: expect.stringMatching(/^入住日期|日期$/),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { Organization } from '../entities/organization.entity';
|
|
||||||
import { buildLookups } from './imports.lookups';
|
|
||||||
import { resolveSheetMapping, suggestMapping, suggestStep } from './imports.mapping';
|
|
||||||
import { validateRow, type ImportBatchState } from './imports.rows';
|
|
||||||
import {
|
|
||||||
IMPORT_STEP_IDENTITY_FIELDS,
|
|
||||||
IMPORT_STEP_LABELS,
|
|
||||||
IMPORT_STEP_ORDER,
|
|
||||||
IMPORT_STEP_REQUIRED_FIELDS,
|
|
||||||
} from './imports.types';
|
|
||||||
import type {
|
|
||||||
CellValue,
|
|
||||||
ColumnMapping,
|
|
||||||
ImportStepKey,
|
|
||||||
PreflightBlock,
|
|
||||||
PreflightBlockCode,
|
|
||||||
PreflightErrorSample,
|
|
||||||
PreflightNextStep,
|
|
||||||
PreflightQuestion,
|
|
||||||
PreflightReport,
|
|
||||||
PreflightStageStat,
|
|
||||||
} from './imports.types';
|
|
||||||
import type { ImportSheetData } from './imports.workbook';
|
|
||||||
|
|
||||||
const BLOCK_META: Record<PreflightBlockCode, { label: string; message: string }> = {
|
|
||||||
no_stages: {
|
|
||||||
label: '未识别工作表',
|
|
||||||
message: '没有识别到可导入的学生、宿舍、入住或换宿工作表,请检查表头',
|
|
||||||
},
|
|
||||||
missing_columns: {
|
|
||||||
label: '缺少必填列',
|
|
||||||
message: '阶段缺少必需列映射,无法自动导入',
|
|
||||||
},
|
|
||||||
student_not_found: {
|
|
||||||
label: '未找到学生',
|
|
||||||
message: '部分行找不到匹配学生,需先完成学生档案或核对学号/手机号',
|
|
||||||
},
|
|
||||||
room_not_found: {
|
|
||||||
label: '未找到宿舍',
|
|
||||||
message: '部分行找不到匹配宿舍,需先完成宿舍档案或核对宿舍号',
|
|
||||||
},
|
|
||||||
duplicate_in_file: {
|
|
||||||
label: '文件内重复',
|
|
||||||
message: '同一文件内存在重复在住/换宿记录',
|
|
||||||
},
|
|
||||||
already_checked_in: {
|
|
||||||
label: '已有在住',
|
|
||||||
message: '学生已有在住记录,重复入住会被拦截',
|
|
||||||
},
|
|
||||||
format_error: {
|
|
||||||
label: '格式错误',
|
|
||||||
message: '部分行存在格式或取值错误(日期、手机号、容量等)',
|
|
||||||
},
|
|
||||||
unknown_organization: {
|
|
||||||
label: '未知校区',
|
|
||||||
message: '部分行填写的校区不存在,需要确认归属',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const REQUIRED_FIELD_LABELS: Record<string, string> = {
|
|
||||||
name: '姓名',
|
|
||||||
roomNumber: '宿舍号',
|
|
||||||
capacity: '容量',
|
|
||||||
checkInDate: '入住日期',
|
|
||||||
oldRoom: '原宿舍',
|
|
||||||
newRoom: '新宿舍',
|
|
||||||
transferDate: '换宿日期',
|
|
||||||
identity: '学号或手机号',
|
|
||||||
};
|
|
||||||
|
|
||||||
const NEXT_STEP_DEFS: Array<PreflightNextStep> = [
|
|
||||||
{
|
|
||||||
key: 'students-next',
|
|
||||||
label: '分班 / 排课 / 入住',
|
|
||||||
description: '学生档案导入完成后,可继续分班、排课或录入入住记录。',
|
|
||||||
after: ['students'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'rooms-next',
|
|
||||||
label: '入住 / 费用',
|
|
||||||
description: '宿舍档案导入完成后,可录入入住记录并维护宿舍费用。',
|
|
||||||
after: ['rooms'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'checkins-next',
|
|
||||||
label: '费用 / 账单',
|
|
||||||
description: '入住记录导入完成后,可录入公共费用并生成账单。',
|
|
||||||
after: ['checkins'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'transfers-next',
|
|
||||||
label: '账单核对',
|
|
||||||
description: '换宿完成后建议核对在住记录与账单,避免计费偏差。',
|
|
||||||
after: ['transfers'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface StageAnalysis extends PreflightStageStat {
|
|
||||||
rowErrorCodes: PreflightBlockCode[];
|
|
||||||
unknownOrgs: string[];
|
|
||||||
errorSamples: PreflightErrorSample[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function classifyErrors(errors: string[]): PreflightBlockCode[] {
|
|
||||||
const codes = new Set<PreflightBlockCode>();
|
|
||||||
for (const error of errors) {
|
|
||||||
if (
|
|
||||||
error.includes('未找到匹配学生') ||
|
|
||||||
error.includes('缺少学生标识') ||
|
|
||||||
error.includes('未找到该学生在原宿舍的在住记录')
|
|
||||||
) {
|
|
||||||
codes.add('student_not_found');
|
|
||||||
} else if (
|
|
||||||
error.includes('未找到宿舍') ||
|
|
||||||
error.includes('未找到原宿舍') ||
|
|
||||||
error.includes('未找到新宿舍')
|
|
||||||
) {
|
|
||||||
codes.add('room_not_found');
|
|
||||||
} else if (
|
|
||||||
error.includes('请勿重复导入') ||
|
|
||||||
error.includes('请勿重复换宿') ||
|
|
||||||
error.includes('本次文件中已有')
|
|
||||||
) {
|
|
||||||
codes.add('duplicate_in_file');
|
|
||||||
} else if (error.includes('已有在住记录')) {
|
|
||||||
codes.add('already_checked_in');
|
|
||||||
} else if (error.includes('未找到校区')) {
|
|
||||||
codes.add('unknown_organization');
|
|
||||||
} else {
|
|
||||||
codes.add('format_error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...codes];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function analyzeStage(
|
|
||||||
dataSource: DataSource,
|
|
||||||
stepKey: ImportStepKey,
|
|
||||||
sheets: ImportSheetData[],
|
|
||||||
): Promise<StageAnalysis> {
|
|
||||||
// 阶段级映射取各表建议的并集,供预检卡预填;实际按表解析在下方逐表进行。
|
|
||||||
const mapping: ColumnMapping = {};
|
|
||||||
for (const sheet of sheets) {
|
|
||||||
const suggested = suggestMapping(sheet.headers, stepKey);
|
|
||||||
for (const [field, header] of Object.entries(suggested)) {
|
|
||||||
if (!mapping[field]) mapping[field] = header;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey];
|
|
||||||
const missingRequired = required
|
|
||||||
.filter((field) => !mapping[field])
|
|
||||||
.map((field) => REQUIRED_FIELD_LABELS[field] ?? field);
|
|
||||||
const identityFields = IMPORT_STEP_IDENTITY_FIELDS[stepKey];
|
|
||||||
const hasIdentity = identityFields.some((field) => mapping[field]);
|
|
||||||
|
|
||||||
let total = 0;
|
|
||||||
let create = 0;
|
|
||||||
let update = 0;
|
|
||||||
let error = 0;
|
|
||||||
const rowErrorCodes: PreflightBlockCode[] = [];
|
|
||||||
const errorSamples: PreflightErrorSample[] = [];
|
|
||||||
const sampleCounts = new Map<PreflightBlockCode, number>();
|
|
||||||
const unknownOrgs = new Set<string>();
|
|
||||||
const batchState: ImportBatchState = {
|
|
||||||
checkinStudentIds: new Set<number>(),
|
|
||||||
transferStudentIds: new Set<number>(),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const sheet of sheets) {
|
|
||||||
const sheetMapping = resolveSheetMapping(mapping, sheet.headers, stepKey);
|
|
||||||
const lookups = await buildLookups(dataSource, stepKey, sheet.headers, sheet.rows, sheetMapping);
|
|
||||||
for (let i = 0; i < sheet.rows.length; i += 1) {
|
|
||||||
const rawValues = sheet.rows[i];
|
|
||||||
const fields: Record<string, CellValue> = {};
|
|
||||||
for (const [field, header] of Object.entries(sheetMapping)) {
|
|
||||||
fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;
|
|
||||||
}
|
|
||||||
const result = validateRow(stepKey, fields, lookups, batchState);
|
|
||||||
total += 1;
|
|
||||||
if (result.errors.length > 0) {
|
|
||||||
error += 1;
|
|
||||||
const codes = classifyErrors(result.errors);
|
|
||||||
rowErrorCodes.push(...codes);
|
|
||||||
for (const code of codes) {
|
|
||||||
const count = sampleCounts.get(code) ?? 0;
|
|
||||||
if (count < 2) {
|
|
||||||
sampleCounts.set(code, count + 1);
|
|
||||||
errorSamples.push({
|
|
||||||
code,
|
|
||||||
stepKey,
|
|
||||||
sheet: sheet.name,
|
|
||||||
rowNumber: sheet.rowNumbers?.[i] ?? (sheet.headerRow ?? 1) + i + 1,
|
|
||||||
errors: result.errors,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (stepKey === 'students' && result.errors.some((item) => item.includes('未找到校区'))) {
|
|
||||||
const org = String(fields.organization ?? '');
|
|
||||||
if (org) unknownOrgs.add(org);
|
|
||||||
}
|
|
||||||
} else if (result.action === 'create') {
|
|
||||||
create += 1;
|
|
||||||
const studentId = result.resolvedIds._studentId;
|
|
||||||
if (studentId !== undefined) {
|
|
||||||
if (stepKey === 'checkins') batchState.checkinStudentIds.add(studentId);
|
|
||||||
if (stepKey === 'transfers') batchState.transferStudentIds.add(studentId);
|
|
||||||
}
|
|
||||||
} else if (result.action === 'update') {
|
|
||||||
update += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
stepKey,
|
|
||||||
label: IMPORT_STEP_LABELS[stepKey],
|
|
||||||
sheetNames: sheets.map((sheet) => sheet.name),
|
|
||||||
headers: [...new Set(sheets.flatMap((sheet) => sheet.headers))],
|
|
||||||
total,
|
|
||||||
create,
|
|
||||||
update,
|
|
||||||
error,
|
|
||||||
skip: 0,
|
|
||||||
mapping,
|
|
||||||
missingRequired: hasIdentity
|
|
||||||
? missingRequired
|
|
||||||
: [...new Set([...missingRequired, REQUIRED_FIELD_LABELS.identity])],
|
|
||||||
rowErrorCodes,
|
|
||||||
unknownOrgs: [...unknownOrgs],
|
|
||||||
errorSamples,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateBlocks(stages: StageAnalysis[]): PreflightBlock[] {
|
|
||||||
const counts = new Map<PreflightBlockCode, number>();
|
|
||||||
const stepKeys = new Map<PreflightBlockCode, Set<ImportStepKey>>();
|
|
||||||
const add = (code: PreflightBlockCode, stepKey: ImportStepKey, count: number) => {
|
|
||||||
counts.set(code, (counts.get(code) ?? 0) + count);
|
|
||||||
const keys = stepKeys.get(code) ?? new Set<ImportStepKey>();
|
|
||||||
keys.add(stepKey);
|
|
||||||
stepKeys.set(code, keys);
|
|
||||||
};
|
|
||||||
for (const stage of stages) {
|
|
||||||
if (stage.missingRequired.length > 0) {
|
|
||||||
add('missing_columns', stage.stepKey, stage.total);
|
|
||||||
}
|
|
||||||
for (const code of stage.rowErrorCodes) {
|
|
||||||
add(code, stage.stepKey, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...counts.entries()]
|
|
||||||
.map(([code, count]) => ({
|
|
||||||
code,
|
|
||||||
label: BLOCK_META[code].label,
|
|
||||||
stepKeys: [...(stepKeys.get(code) ?? [])],
|
|
||||||
message: BLOCK_META[code].message,
|
|
||||||
count,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildQuestions(
|
|
||||||
stages: StageAnalysis[],
|
|
||||||
existingOrganizations: string[],
|
|
||||||
): PreflightQuestion[] {
|
|
||||||
const questions: PreflightQuestion[] = [];
|
|
||||||
for (const stage of stages) {
|
|
||||||
if (stage.missingRequired.length > 0) {
|
|
||||||
questions.push({
|
|
||||||
key: `mapping_${stage.stepKey}`,
|
|
||||||
type: 'mapping',
|
|
||||||
label: `确认「${stage.label}」列映射`,
|
|
||||||
description: `缺少必需列映射:${stage.missingRequired.join('、')};请确认工作表中对应的列名`,
|
|
||||||
stepKey: stage.stepKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const totalUpdates = stages.reduce((sum, stage) => sum + stage.update, 0);
|
|
||||||
if (totalUpdates > 0) {
|
|
||||||
questions.push({
|
|
||||||
key: 'update',
|
|
||||||
type: 'update',
|
|
||||||
label: `文件中有 ${totalUpdates} 行已匹配现有记录`,
|
|
||||||
description: '选择更新已有记录,或跳过已匹配的行(仅新建)',
|
|
||||||
options: [
|
|
||||||
{ label: '更新已有记录', value: 'true' },
|
|
||||||
{ label: '跳过已有记录', value: 'false' },
|
|
||||||
],
|
|
||||||
default: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const unknownOrgs = [...new Set(stages.flatMap((stage) => stage.unknownOrgs))];
|
|
||||||
if (unknownOrgs.length > 0) {
|
|
||||||
const options = [
|
|
||||||
...existingOrganizations.slice(0, 19).map((name) => ({ label: name, value: name })),
|
|
||||||
{ label: '忽略校区', value: '' },
|
|
||||||
];
|
|
||||||
questions.push({
|
|
||||||
key: 'organization',
|
|
||||||
type: 'organization',
|
|
||||||
label: '确认校区归属',
|
|
||||||
description: `文件中存在未匹配的校区:${unknownOrgs.join('、')},请选择实际归属校区`,
|
|
||||||
options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (stages.some((stage) => stage.rowErrorCodes.includes('duplicate_in_file'))) {
|
|
||||||
questions.push({
|
|
||||||
key: 'duplicate',
|
|
||||||
type: 'duplicate',
|
|
||||||
label: '文件内存在重复在住/换宿记录',
|
|
||||||
description: '选择将重复行标记为错误,或按策略跳过重复行',
|
|
||||||
options: [
|
|
||||||
{ label: '标记为错误', value: 'error' },
|
|
||||||
{ label: '跳过重复行', value: 'skip' },
|
|
||||||
],
|
|
||||||
default: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
stages.some((stage) =>
|
|
||||||
stage.rowErrorCodes.some(
|
|
||||||
(code) => code === 'student_not_found' || code === 'room_not_found',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
questions.push({
|
|
||||||
key: 'reference',
|
|
||||||
type: 'reference',
|
|
||||||
label: '存在未匹配的学生或宿舍',
|
|
||||||
description: '选择保留错误提示,或跳过找不到学生/宿舍的行继续导入',
|
|
||||||
options: [
|
|
||||||
{ label: '保留错误提示', value: 'false' },
|
|
||||||
{ label: '跳过未匹配行', value: 'true' },
|
|
||||||
],
|
|
||||||
default: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return questions;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decideVerdict(
|
|
||||||
stages: StageAnalysis[],
|
|
||||||
questions: PreflightQuestion[],
|
|
||||||
hasStages: boolean,
|
|
||||||
): PreflightReport['verdict'] {
|
|
||||||
if (!hasStages) return 'blocked';
|
|
||||||
if (stages.some((stage) => stage.missingRequired.length > 0)) return 'blocked';
|
|
||||||
if (
|
|
||||||
stages.some(
|
|
||||||
(stage) =>
|
|
||||||
stage.total > 0 &&
|
|
||||||
stage.total === stage.error &&
|
|
||||||
stage.rowErrorCodes.length > 0 &&
|
|
||||||
stage.rowErrorCodes.every((code) => code === 'format_error'),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return 'blocked';
|
|
||||||
}
|
|
||||||
if (questions.length > 0) return 'needs_input';
|
|
||||||
return 'ready';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成“可插入性预检报告”:按业务依赖分阶段统计,归类阻断原因,
|
|
||||||
* 给出需要用户确认的问题与导入后的下一步建议。纯读操作,不写库。
|
|
||||||
*/
|
|
||||||
export async function buildPreflightReport(
|
|
||||||
dataSource: DataSource,
|
|
||||||
sheets: ImportSheetData[],
|
|
||||||
): Promise<PreflightReport> {
|
|
||||||
const grouped = new Map<ImportStepKey, ImportSheetData[]>();
|
|
||||||
for (const sheet of sheets) {
|
|
||||||
const suggestion = suggestStep(sheet.headers);
|
|
||||||
if (!suggestion) continue;
|
|
||||||
const list = grouped.get(suggestion.stepKey) ?? [];
|
|
||||||
list.push(sheet);
|
|
||||||
grouped.set(suggestion.stepKey, list);
|
|
||||||
}
|
|
||||||
const stageKeys = IMPORT_STEP_ORDER.filter((stepKey) => grouped.has(stepKey));
|
|
||||||
const hasStages = stageKeys.length > 0;
|
|
||||||
|
|
||||||
const stages: StageAnalysis[] = [];
|
|
||||||
const existingOrganizations = new Set<string>();
|
|
||||||
if (hasStages) {
|
|
||||||
for (const stepKey of stageKeys) {
|
|
||||||
const analysis = await analyzeStage(dataSource, stepKey, grouped.get(stepKey) ?? []);
|
|
||||||
stages.push(analysis);
|
|
||||||
}
|
|
||||||
const organizations = await dataSource
|
|
||||||
.getRepository(Organization)
|
|
||||||
.find({ select: { name: true } });
|
|
||||||
for (const org of organizations) existingOrganizations.add(org.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocks = aggregateBlocks(stages);
|
|
||||||
if (!hasStages) {
|
|
||||||
blocks.push({
|
|
||||||
code: 'no_stages',
|
|
||||||
label: BLOCK_META.no_stages.label,
|
|
||||||
stepKeys: [],
|
|
||||||
message: BLOCK_META.no_stages.message,
|
|
||||||
count: sheets.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const questions = buildQuestions(stages, [...existingOrganizations]);
|
|
||||||
const detectedKeys = new Set(stages.map((stage) => stage.stepKey));
|
|
||||||
const nextSteps = NEXT_STEP_DEFS.filter((step) => step.after.some((key) => detectedKeys.has(key)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
verdict: decideVerdict(stages, questions, hasStages),
|
|
||||||
stages: stages.map(
|
|
||||||
({
|
|
||||||
rowErrorCodes: _rowErrorCodes,
|
|
||||||
unknownOrgs: _unknownOrgs,
|
|
||||||
errorSamples: _errorSamples,
|
|
||||||
...stat
|
|
||||||
}) => stat,
|
|
||||||
),
|
|
||||||
blocks,
|
|
||||||
questions,
|
|
||||||
nextSteps,
|
|
||||||
errorSamples: stages.flatMap((stage) => stage.errorSamples),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -867,29 +867,6 @@ describe('ImportsService', () => {
|
|||||||
expect(result.rows[0].errors.join(';')).toContain('按策略跳过');
|
expect(result.rows[0].errors.join(';')).toContain('按策略跳过');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preflightFile 透传 headerRow 到解析层', async () => {
|
|
||||||
const parseSpy = jest
|
|
||||||
.spyOn(workbookModule, 'parseSheets')
|
|
||||||
.mockResolvedValue([]);
|
|
||||||
try {
|
|
||||||
const service = new ImportsService(
|
|
||||||
makeRunsRepo({} as ImportRun) as never,
|
|
||||||
makeStepsRepo({} as ImportStep) as never,
|
|
||||||
makeRowsRepo() as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
const report = await service.preflightFile(fileOf('students.xlsx', Buffer.from('x')), 3);
|
|
||||||
expect(parseSpy).toHaveBeenCalledWith(
|
|
||||||
expect.any(Buffer),
|
|
||||||
'students.xlsx',
|
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
expect(report.verdict).toBe('blocked');
|
|
||||||
} finally {
|
|
||||||
parseSpy.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('createRun 按 stages 的 headerRow 生成对应工作表视图并写入 sheetsJson', async () => {
|
it('createRun 按 stages 的 headerRow 生成对应工作表视图并写入 sheetsJson', async () => {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
|||||||
@@ -7,16 +7,12 @@ import { ImportRow } from './entities/import-row.entity';
|
|||||||
import { ImportRunService } from './imports.run.service';
|
import { ImportRunService } from './imports.run.service';
|
||||||
import { ImportPreviewService } from './imports.preview.service';
|
import { ImportPreviewService } from './imports.preview.service';
|
||||||
import { ImportCommitService } from './imports.commit.service';
|
import { ImportCommitService } from './imports.commit.service';
|
||||||
import { buildPreflightReport } from './imports.preflight';
|
|
||||||
import { parseSheets } from './imports.workbook';
|
|
||||||
import type { ParsedImportFile } from './imports.types';
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ImportSheetMeta,
|
ImportSheetMeta,
|
||||||
ImportStepDetail,
|
ImportStepDetail,
|
||||||
ImportRunDetail,
|
ImportRunDetail,
|
||||||
ImportRunSettings,
|
ImportRunSettings,
|
||||||
PreflightReport,
|
|
||||||
StepPreviewResult,
|
StepPreviewResult,
|
||||||
} from './imports.types';
|
} from './imports.types';
|
||||||
|
|
||||||
@@ -71,18 +67,6 @@ export class ImportsService {
|
|||||||
return this.runsSvc.createRun(...args);
|
return this.runsSvc.createRun(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传后的只读预检:解析文件并生成可插入性报告,不写库。 */
|
|
||||||
async preflightFile(
|
|
||||||
file: ParsedImportFile,
|
|
||||||
headerRow = 1,
|
|
||||||
): Promise<import('./imports.types').PreflightReport> {
|
|
||||||
if (!file.buffer || file.buffer.length === 0) {
|
|
||||||
throw new BadRequestException('上传文件为空');
|
|
||||||
}
|
|
||||||
const sheets = await parseSheets(file.buffer, file.originalName, file.mimeType, headerRow);
|
|
||||||
return buildPreflightReport(this.dataSource, sheets);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRun(...args: Parameters<ImportRunService['getRun']>) {
|
async getRun(...args: Parameters<ImportRunService['getRun']>) {
|
||||||
return this.runsSvc.getRun(...args);
|
return this.runsSvc.getRun(...args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,94 +150,6 @@ export interface ImportRunSettings {
|
|||||||
skipUnmatched?: boolean;
|
skipUnmatched?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PreflightVerdict = 'ready' | 'needs_input' | 'blocked';
|
|
||||||
|
|
||||||
export interface PreflightStageStat {
|
|
||||||
stepKey: ImportStepKey;
|
|
||||||
label: string;
|
|
||||||
sheetNames: string[];
|
|
||||||
/** 该阶段所有工作表的表头并集,供前端预检卡渲染列映射选项。 */
|
|
||||||
headers: string[];
|
|
||||||
total: number;
|
|
||||||
create: number;
|
|
||||||
update: number;
|
|
||||||
error: number;
|
|
||||||
skip: number;
|
|
||||||
mapping: ColumnMapping;
|
|
||||||
missingRequired: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PreflightBlockCode =
|
|
||||||
| 'no_stages'
|
|
||||||
| 'missing_columns'
|
|
||||||
| 'student_not_found'
|
|
||||||
| 'room_not_found'
|
|
||||||
| 'duplicate_in_file'
|
|
||||||
| 'already_checked_in'
|
|
||||||
| 'format_error'
|
|
||||||
| 'unknown_organization';
|
|
||||||
|
|
||||||
export interface PreflightBlock {
|
|
||||||
code: PreflightBlockCode;
|
|
||||||
label: string;
|
|
||||||
stepKeys: ImportStepKey[];
|
|
||||||
message: string;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PreflightQuestionType =
|
|
||||||
| 'mapping'
|
|
||||||
| 'organization'
|
|
||||||
| 'update'
|
|
||||||
| 'duplicate'
|
|
||||||
| 'reference';
|
|
||||||
|
|
||||||
export interface PreflightQuestionOption {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PreflightQuestion {
|
|
||||||
key: string;
|
|
||||||
type: PreflightQuestionType;
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
stepKey?: ImportStepKey;
|
|
||||||
options?: PreflightQuestionOption[];
|
|
||||||
default?: string | boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PreflightNextStep {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
after: ImportStepKey[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 预检报告中的错误示例(仅工作表、行号与错误信息,不含原始行数据)。 */
|
|
||||||
export interface PreflightErrorSample {
|
|
||||||
code: PreflightBlockCode;
|
|
||||||
stepKey: ImportStepKey;
|
|
||||||
sheet: string;
|
|
||||||
rowNumber: number;
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PreflightReport {
|
|
||||||
verdict: PreflightVerdict;
|
|
||||||
stages: PreflightStageStat[];
|
|
||||||
blocks: PreflightBlock[];
|
|
||||||
questions: PreflightQuestion[];
|
|
||||||
nextSteps: PreflightNextStep[];
|
|
||||||
errorSamples: PreflightErrorSample[];
|
|
||||||
/** 以下字段由 AI 预检卡使用,普通预检报告生成时不设置。 */
|
|
||||||
attachmentId?: number;
|
|
||||||
headerRow?: number;
|
|
||||||
permittedSteps?: ImportStepKey[];
|
|
||||||
resolved?: boolean;
|
|
||||||
runId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StepPreviewResult {
|
export interface StepPreviewResult {
|
||||||
stepKey: ImportStepKey;
|
stepKey: ImportStepKey;
|
||||||
sheetNames: string[];
|
sheetNames: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user