From ab4765cee5fb05958f0ade557a4876ca5887c98c Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 21:12:50 +0800 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20Excel=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E9=A2=84=E6=A3=80=E4=B8=8E=E5=8A=A8=E6=80=81=E9=97=AE=E7=AD=94?= =?UTF-8?q?=EF=BC=8CAI=20=E8=81=8A=E5=A4=A9/=E6=96=87=E4=BB=B6=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E4=BD=93=E9=AA=8C=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - imports: 新增 preflight_import 预检报告(判定/分阶段统计/阻断归因/问题/下一步/错误示例),导入任务 settings 落库(映射/校区/更新/重复/未匹配策略),预览应用策略,向导提交写操作日志 - ai-chat: 新增 excel_analyze(ExcelJS)工具,移除附件/上下文截断,start_import_wizard 支持确认参数,ui.import_preflight SSE,预览确认写操作日志 - admin: ImportPreflightCard 渲染与持久化,聊天抽屉布局/侧边栏修复,考勤页 CSS 引入,费用/学生页接口 schema 校验修复 --- apps/admin/src/api/schemas/core.ts | 16 +- apps/admin/src/api/schemas/import-run.ts | 13 + .../src/components/AiChat/AiChatDrawer.tsx | 32 +- .../components/AiChat/AiMessageContent.tsx | 8 + .../components/AiChat/ImportPreflightCard.tsx | 115 +++++ .../AiChat/bubble.integration.test.tsx | 136 ++++++ .../message-mappers.integration.test.ts | 22 + .../AiChat/provider.integration.test.ts | 41 ++ apps/admin/src/components/AiChat/provider.ts | 4 + apps/admin/src/components/AiChat/style.css | 12 + apps/admin/src/components/AiChat/types.ts | 55 +++ .../src/components/ImportWizard/types.ts | 7 + apps/admin/src/pages/Attendance/index.tsx | 1 + apps/admin/src/pages/Expenses/index.tsx | 15 +- .../src/ai-chat/ai-attachment.service.ts | 5 +- apps/server/src/ai-chat/ai-chat.constants.ts | 108 ++++- apps/server/src/ai-chat/ai-chat.generation.ts | 3 +- .../src/ai-chat/ai-chat.service.spec.ts | 348 ++++++++++++++- apps/server/src/ai-chat/ai-chat.service.ts | 26 +- apps/server/src/ai-chat/ai-chat.streaming.ts | 46 +- .../server/src/ai-chat/ai-chat.submissions.ts | 34 +- .../src/ai-chat/ai-chat.tool-actions.ts | 229 +++++++++- .../server/src/ai-chat/ai-chat.tool-office.ts | 8 +- apps/server/src/ai-chat/ai-chat.tools.ts | 8 + apps/server/src/ai-chat/ai-chat.types.ts | 6 +- .../src/ai-chat/ai-excel-reader.service.ts | 16 +- apps/server/src/app.module.ts | 2 + .../src/expenses/expenses.controller.ts | 6 + .../src/expenses/expenses.lookups.spec.ts | 26 ++ apps/server/src/expenses/expenses.service.ts | 9 + .../src/imports/entities/import-run.entity.ts | 4 + .../src/imports/imports.controller.spec.ts | 74 ++++ apps/server/src/imports/imports.controller.ts | 32 +- apps/server/src/imports/imports.policies.ts | 72 +++ .../src/imports/imports.preflight.spec.ts | 195 ++++++++ apps/server/src/imports/imports.preflight.ts | 417 ++++++++++++++++++ .../src/imports/imports.preview.service.ts | 29 +- .../server/src/imports/imports.run.service.ts | 45 +- .../src/imports/imports.service.spec.ts | 207 +++++++++ apps/server/src/imports/imports.service.ts | 16 +- apps/server/src/imports/imports.types.ts | 95 ++++ apps/server/src/imports/imports.workbook.ts | 49 ++ apps/server/src/migration-runner.ts | 2 + .../1784930000000-AddImportRunSettings.ts | 21 + 44 files changed, 2449 insertions(+), 166 deletions(-) create mode 100644 apps/admin/src/components/AiChat/ImportPreflightCard.tsx create mode 100644 apps/server/src/imports/imports.controller.spec.ts create mode 100644 apps/server/src/imports/imports.policies.ts create mode 100644 apps/server/src/imports/imports.preflight.spec.ts create mode 100644 apps/server/src/imports/imports.preflight.ts create mode 100644 apps/server/src/migrations/1784930000000-AddImportRunSettings.ts diff --git a/apps/admin/src/api/schemas/core.ts b/apps/admin/src/api/schemas/core.ts index 38295e9..5339f16 100644 --- a/apps/admin/src/api/schemas/core.ts +++ b/apps/admin/src/api/schemas/core.ts @@ -165,7 +165,7 @@ export const studentSchema = z .object({ id: z.number(), name: z.string(), - studentNo: z.string().optional(), + studentNo: z.string().nullable().optional(), status: z.string(), }) .passthrough(); @@ -180,7 +180,7 @@ export const depositSchema = z export const depositsSchema = z.array(depositSchema); export const depositStudentLookupSchema = z - .object({ studentId: z.number(), name: z.string().optional() }) + .object({ studentId: z.number(), name: z.string().nullable().optional() }) .passthrough(); export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema); @@ -217,6 +217,18 @@ export const expenseRecordSchema = z export const expenseRecordsSchema = z.array(expenseRecordSchema); +export const expenseStudentLookupSchema = z + .object({ + id: z.number(), + name: z.string().nullable().optional(), + studentNo: z.string().nullable().optional(), + }) + .passthrough(); + +export const expenseStudentLookupsSchema = z.array(expenseStudentLookupSchema); + +export const expenseRoomsListSchema = z.array(z.record(z.string(), z.unknown())); + export const expenseLookupsSchema = z .object({ rooms: z.array(z.record(z.string(), z.unknown())), diff --git a/apps/admin/src/api/schemas/import-run.ts b/apps/admin/src/api/schemas/import-run.ts index 4ada227..2b2155e 100644 --- a/apps/admin/src/api/schemas/import-run.ts +++ b/apps/admin/src/api/schemas/import-run.ts @@ -34,6 +34,18 @@ export const importStepDetailSchema = z }) .passthrough(); +export const importRunSettingsSchema = z + .object({ + mapping: z.record(z.string(), z.record(z.string(), z.string())).optional(), + organization: z.string().nullable().optional(), + updateExisting: z.boolean().optional(), + duplicatePolicy: z.enum(['error', 'skip']).optional(), + skipUnmatched: z.boolean().optional(), + }) + .passthrough() + .nullable() + .optional(); + export const importRunDetailSchema = z .object({ id: z.string(), @@ -44,6 +56,7 @@ export const importRunDetailSchema = z createdAt: z.string(), sheets: z.array(importSheetMetaSchema), steps: z.array(importStepDetailSchema), + settings: importRunSettingsSchema, }) .passthrough(); diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx index 44101e4..c179270 100644 --- a/apps/admin/src/components/AiChat/AiChatDrawer.tsx +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -54,7 +54,9 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting const screens = Grid.useBreakpoint(); const isMobile = !screens.sm; const [loadingList, setLoadingList] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(!isMobile); + // 断点首帧可能尚未解析(isMobile 误判为 true),桌面端默认展开会话侧边栏, + // 移动端通过 effectiveSidebarOpen 统一隐藏。 + const [sidebarOpen, setSidebarOpen] = useState(true); const effectiveSidebarOpen = isMobile ? false : sidebarOpen; const [skills, setSkills] = useState([]); const [conversationStatus, setConversationStatus] = useState< @@ -228,7 +230,7 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting }, }); }, - [setConversation], + [setConversation, modal], ); /** 删除单个会话时中止请求并清理会话运行时状态 */ @@ -269,13 +271,14 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting }, }); }, - [ - activeId, - conversations, - switchConversation, - removeConversation, - removeConversationEntry, - ], + [ + activeId, + conversations, + switchConversation, + removeConversation, + removeConversationEntry, + modal, + ], ); const enterSelectionMode = useCallback(() => { @@ -358,11 +361,12 @@ const AiChatDrawer: React.FC = ({ open, onClose, onRequesting activeId, conversations, removeConversation, - removeConversationEntry, - selectedKeys, - switchConversation, - setConversations, - ]); + removeConversationEntry, + selectedKeys, + switchConversation, + setConversations, + modal, + ]); const conversationMenu = useCallback( (item: ConversationItemType): MenuProps => ({ diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index d5b5346..e9f4756 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -16,6 +16,7 @@ import { useUserStore } from '../../store/user/userStore'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; +import { ImportPreflightCard } from './ImportPreflightCard'; import { LiteCodeHighlighter } from './LiteCodeHighlighter'; import { LiteMermaid } from './LiteMermaid'; import type { @@ -24,6 +25,7 @@ import type { AiChatMessageStatus, AiChartSchema, AiFormSchema, + AiImportPreflight, AiImportWizard, AiReviewSection, AiReviewSchema, @@ -43,6 +45,7 @@ const toolLabels: Record = { render_form: '生成表单', render_review: '生成导入预览', render_chart: '生成图表', + preflight_import: '导入预检', start_import_wizard: '生成导入向导', create_student: '创建学生', search_exams: '查询考试', @@ -311,6 +314,11 @@ export const AiMessageContent: React.FC = ({ {attachmentCards} )} + {(() => { + const preflight = message.metadata?.a2uiImportPreflight; + if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return null; + return ; + })()} {(() => { const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined; if (!wizard || !onOpenImportWizard) return null; diff --git a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx new file mode 100644 index 0000000..2bfce5b --- /dev/null +++ b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { TableOutlined } from '@ant-design/icons'; +import { Card, Flex, Space, Tag, Typography } from 'antd'; +import type { + AiImportPreflight, + AiImportPreflightVerdict, +} from './types'; + +const VERDICT_META: Record< + AiImportPreflightVerdict, + { label: string; color: string } +> = { + ready: { label: '可导入', color: 'success' }, + needs_input: { label: '需要确认', color: 'warning' }, + blocked: { label: '暂无法导入', color: 'error' }, +}; + +/** + * 上传 Excel 后的“可插入性预检报告”卡片:展示判定结论、 + * 分阶段统计、阻断原因、待确认问题与下一步建议。 + */ +export const ImportPreflightCard: React.FC<{ preflight: AiImportPreflight }> = ({ + preflight, +}) => { + const verdict = VERDICT_META[preflight.verdict] ?? VERDICT_META.needs_input; + return ( + + + Excel 导入预检 + {verdict.label} + + } + > + + {preflight.stages.length > 0 ? ( + + {preflight.stages.map((stage) => ( + + + {stage.label} + + {stage.sheetNames.join('、')} + + + + 共 {stage.total} 行 + 新建 {stage.create} + 更新 {stage.update} + 错误 {stage.error} + 跳过 {stage.skip} + + + ))} + + ) : ( + 未识别到可导入的工作表 + )} + + {preflight.blocks.length > 0 && ( + + + 阻断原因 + + {preflight.blocks.map((block) => ( + + {block.label} + + {block.message}({block.count} 行) + + + ))} + + )} + + {preflight.questions.length > 0 && ( + + + 需要确认 + + {preflight.questions.map((question) => ( + + {question.label} + {question.description && ( + + {question.description} + + )} + + ))} + + )} + + {preflight.nextSteps.length > 0 && ( + + + 下一步建议 + + {preflight.nextSteps.map((step) => ( + + {step.label} + + {step.description} + + + ))} + + )} + + + ); +}; diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx index 1ae84ac..5b67c9d 100644 --- a/apps/admin/src/components/AiChat/bubble.integration.test.tsx +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -396,6 +396,142 @@ describe('AI chat bubble rendering', () => { expect(container.textContent).toContain('26暑期文化课宿舍.xlsx'); }); + it('renders an import preflight card from assistant message metadata', async () => { + const message: AiChatMessage = { + role: 'assistant', + content: '这是预检结果', + reasoningContent: '', + toolRuns: [], + attachments: [], + metadata: { + a2uiImportPreflight: { + verdict: 'needs_input', + stages: [ + { + stepKey: 'students', + label: '学生档案', + sheetNames: ['学生'], + total: 2, + create: 1, + update: 1, + error: 0, + skip: 0, + mapping: { name: '姓名' }, + missingRequired: [], + }, + ], + blocks: [{ code: 'unknown_organization', label: '未知校区', stepKeys: ['students'], message: '校区不存在', count: 1 }], + questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }], + nextSteps: [ + { + key: 'students-next', + label: '分班 / 排课 / 入住', + description: '学生档案导入完成后可继续分班、排课或入住。', + after: ['students'], + }, + ], + }, + }, + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + 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( + { + 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('renders model retrying hint while waiting for the upstream retry', async () => { const message: AiChatMessage = { role: 'assistant', diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index 0aa813a..60ea6ee 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -139,4 +139,26 @@ describe('AI chat history mapper', () => { expect(mapped.message.charts).toHaveLength(1); 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); + }); }); diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index 7429bda..d1f1f43 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -154,6 +154,47 @@ describe('AI chat SSE message reducer', () => { expect(message.forms?.[1]).toMatchObject({ id: 'form-2' }); }); + it('stores ui.import_preflight in message metadata and restores it from completed message', () => { + const preflight = { + verdict: 'needs_input', + stages: [ + { + stepKey: 'students', + label: '学生档案', + sheetNames: ['学生'], + total: 2, + create: 1, + update: 1, + error: 0, + skip: 0, + mapping: { name: '姓名' }, + missingRequired: [], + }, + ], + blocks: [], + questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }], + nextSteps: [{ key: 'students-next', label: '分班 / 排课 / 入住', description: '建议', after: ['students'] }], + }; + let message = reduceAiSseMessage(undefined, { + event: 'ui.import_preflight', + data: JSON.stringify({ messageId: 8, preflight }), + }); + expect(message.metadata?.a2uiImportPreflight).toEqual(preflight); + + message = reduceAiSseMessage(message, { + event: 'message.completed', + data: JSON.stringify({ + message: { + id: 8, + content: '预检完成', + status: 'completed', + metadata: { a2uiImportPreflight: preflight }, + }, + }), + }); + expect(message.metadata?.a2uiImportPreflight).toEqual(preflight); + }); + it('restores a persisted form from message.completed metadata', () => { const message = reduceAiSseMessage(undefined, { event: 'message.completed', diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index ad93b6d..e8f7309 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -12,6 +12,7 @@ import type { AiChatMessage, AiChartSchema, AiFormSchema, + AiImportPreflight, AiModelRetryInfo, AiReviewSchema, AiSseChunk, @@ -35,6 +36,7 @@ interface AiSsePayload { form?: AiFormSchema; review?: AiReviewSchema; chart?: AiChartSchema; + preflight?: AiImportPreflight; wizard?: unknown; retry?: AiModelRetryInfo; message?: @@ -196,6 +198,8 @@ export function reduceAiSseMessage( message.reviews = mergeById(message.reviews, payload.review); } else if (event === 'ui.chart' && payload.chart) { message.charts = mergeById(message.charts, payload.chart); + } else if (event === 'ui.import_preflight' && payload.preflight) { + message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight }; } else if (event === 'ui.import_wizard' && payload.wizard) { message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; } else if (event === 'tool.started') { diff --git a/apps/admin/src/components/AiChat/style.css b/apps/admin/src/components/AiChat/style.css index 14a1e44..ab1ac75 100644 --- a/apps/admin/src/components/AiChat/style.css +++ b/apps/admin/src/components/AiChat/style.css @@ -165,6 +165,18 @@ min-height: 0; } +.ai-chat-main .ai-chat-toolbar { + order: 0; +} + +.ai-chat-main .ai-chat-messages { + order: 1; +} + +.ai-chat-main .ai-chat-composer { + order: 2; +} + .ai-chat-toolbar { display: flex; flex: 0 0 48px; diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index a0964b1..8987091 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -115,6 +115,61 @@ export interface AiImportWizard { }>; } +export type AiImportPreflightVerdict = 'ready' | 'needs_input' | 'blocked'; + +export interface AiImportPreflightStage { + stepKey: AiReviewSectionType; + label: string; + sheetNames: string[]; + total: number; + create: number; + update: number; + error: number; + skip: number; + mapping: Record; + 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[]; + errorSamples?: Array<{ + code: string; + stepKey: AiReviewSectionType; + sheet: string; + rowNumber: number; + errors: string[]; + }>; +} + export type AiToolRunStatus = | 'running' | 'success' diff --git a/apps/admin/src/components/ImportWizard/types.ts b/apps/admin/src/components/ImportWizard/types.ts index 097b361..a2e2350 100644 --- a/apps/admin/src/components/ImportWizard/types.ts +++ b/apps/admin/src/components/ImportWizard/types.ts @@ -36,6 +36,13 @@ export interface ImportRunDetail { createdAt: string; sheets: ImportSheetMeta[]; steps: ImportStepDetail[]; + settings?: { + mapping?: Partial>>; + organization?: string | null; + updateExisting?: boolean; + duplicatePolicy?: 'error' | 'skip'; + skipUnmatched?: boolean; + }; } export interface ImportStageRequest { diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index a2ece16..7253958 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -4,6 +4,7 @@ import { useUserStore } from '../../store/user/userStore'; import { getAttendanceExperience } from './attendance-workspace'; import { TeacherAttendanceWorkspace } from './teacher'; import { AdminAttendanceArchive } from './admin'; +import './attendance.css'; function readCurrentRoles(): string[] { const roles = useUserStore.getState().user?.roles; diff --git a/apps/admin/src/pages/Expenses/index.tsx b/apps/admin/src/pages/Expenses/index.tsx index 53aad44..967d928 100644 --- a/apps/admin/src/pages/Expenses/index.tsx +++ b/apps/admin/src/pages/Expenses/index.tsx @@ -9,7 +9,12 @@ import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; -import { expenseLookupsSchema, expenseRecordsSchema } from '../../api/schemas'; +import { + expenseLookupsSchema, + expenseRecordsSchema, + expenseRoomsListSchema, + expenseStudentLookupsSchema, +} from '../../api/schemas'; import { archiveViewPolicy, expenseStatusForView } from '../archive-view'; import { ExpenseTablePanel } from './ExpenseTablePanel'; import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals'; @@ -70,10 +75,10 @@ const ExpensesPage: React.FC = () => { try { const [rooms, personal, students, roomsList] = await Promise.all([ api.get('/expenses/room', { - params: expenseStatusForView(showArchived ? 'archived' : 'active'), + params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, }), api.get('/expenses/personal', { - params: expenseStatusForView(showArchived ? 'archived' : 'active'), + params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, }), api.get('/expenses/student-lookups'), api.get('/rooms'), @@ -81,8 +86,8 @@ const ExpensesPage: React.FC = () => { return { rooms: validateResponse(expenseRecordsSchema, rooms), personal: validateResponse(expenseRecordsSchema, personal), - students: validateResponse(expenseRecordsSchema, students), - roomsList: validateResponse(expenseRecordsSchema, roomsList), + students: validateResponse(expenseStudentLookupsSchema, students), + roomsList: validateResponse(expenseRoomsListSchema, roomsList), }; } catch { message.error('加载费用数据失败'); diff --git a/apps/server/src/ai-chat/ai-attachment.service.ts b/apps/server/src/ai-chat/ai-attachment.service.ts index e725b9a..db53b51 100644 --- a/apps/server/src/ai-chat/ai-attachment.service.ts +++ b/apps/server/src/ai-chat/ai-attachment.service.ts @@ -16,7 +16,6 @@ import { OfficeCliService } from './office-cli.service'; import { AiAttachment } from './entities'; const MAX_FILE_BYTES = 10 * 1024 * 1024; -const MAX_EXTRACTED_CHARS = 48 * 1024; const MAX_MODEL_IMAGE_BYTES = 20 * 1024 * 1024; const ACCEPTED_MIME_TYPES = new Set([ 'image/jpeg', @@ -165,7 +164,7 @@ export class AiAttachmentService { } else { parts.push({ attachment, - text: attachment.extractedText?.slice(0, MAX_EXTRACTED_CHARS) || '', + text: attachment.extractedText || '', }); } } @@ -290,7 +289,7 @@ export class AiAttachmentService { } private normalizeExtractedText(value: string): string { - return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim().slice(0, MAX_EXTRACTED_CHARS); + return value.split('\u0000').join('').replace(/\r\n/g, '\n').trim(); } private assertDeclaredType(declared: string, detected: string): void { diff --git a/apps/server/src/ai-chat/ai-chat.constants.ts b/apps/server/src/ai-chat/ai-chat.constants.ts index 4f728c6..8395aba 100644 --- a/apps/server/src/ai-chat/ai-chat.constants.ts +++ b/apps/server/src/ai-chat/ai-chat.constants.ts @@ -1,11 +1,8 @@ export const MAX_HISTORY_MESSAGES = 30; -export const MAX_CONTEXT_CHARS = 64 * 1024; export const MAX_TOOL_CALLS_PER_ROUND = 50; export const MAX_TOOL_ROUNDS = 90; export const MAX_SUMMARY_CHARS = 2000; export const MAX_GENERATED_CHARS = 256 * 1024; -export const MAX_ATTACHMENT_TEXT_CHARS = 20000; -export const MAX_FOCUS_CONTENT_CHARS = 40000; export const DEFAULT_TITLE = '新对话'; const CELL_VALUE_ANY_OF = [ @@ -16,12 +13,75 @@ const CELL_VALUE_ANY_OF = [ ]; export const A2UI_TOOL_SCHEMAS = [ + { + type: 'function' as const, + function: { + name: 'preflight_import', + description: + '对上传的 Excel 进行导入预检并生成“可插入性报告”:分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题与导入后的下一步建议。当用户上传 Excel 并疑似需要批量导入学生、宿舍、换宿或入住数据时先调用;根据报告向用户确认后,再调用 start_import_wizard。', + parameters: { + type: 'object', + properties: { + attachmentId: { + type: 'integer', + description: '上传的 Excel 附件 ID。系统直接从文件读取行数据,无需(也不要)在参数里抄录数据。', + }, + }, + required: ['attachmentId'], + additionalProperties: false, + }, + }, + }, + { + type: 'function' as const, + function: { + name: 'excel_analyze', + description: + '用 ExcelJS 直接解析上传的 Excel(.xlsx/.csv):overview 查看工作表概览(表名、行数、前几行样本),rows 按工作表/行范围读取具体行。适合核对表头、抽查数据行、确认预检报告里的错误原因;Word/PPT 请用 office_analyze。', + parameters: { + type: 'object', + properties: { + attachmentId: { + type: 'integer', + description: '上传的 Excel 附件 ID', + }, + action: { + type: 'string', + description: 'overview 概览 / rows 读取行', + enum: ['overview', 'rows'], + }, + sheet: { + type: 'string', + description: '工作表名称(rows 时可选,默认第一个表)', + maxLength: 200, + }, + startRow: { + type: 'integer', + description: '起始行(含表头,从 1 开始,默认 1)', + minimum: 1, + }, + maxRows: { + type: 'integer', + description: '读取行数(默认 20;传大值可读取更多/全部行)', + minimum: 1, + }, + maxColumns: { + type: 'integer', + description: '读取列数(默认 30;传大值可读取更多/全部列)', + minimum: 1, + }, + }, + required: ['attachmentId', 'action'], + additionalProperties: false, + }, + }, + }, { type: 'function' as const, function: { name: 'start_import_wizard', description: - '生成一个“批量导入向导”显示给用户。当用户上传 Excel 并需要批量导入学生、宿舍、换宿或入住数据时调用;传入 attachmentId 与 stages(业务类型 + 工作表名),系统直接解析文件、自动识别列映射并按依赖顺序分阶段预览,用户确认后才会入库。每个回答回合最多调用一次,生成成功后提示用户打开向导逐阶段确认,不要重复调用,也不要代替用户调用任何写工具直接插入。', + '生成一个“批量导入向导”显示给用户。当用户上传 Excel 并需要批量导入学生、宿舍、换宿或入住数据时调用;传入 attachmentId 与 stages(业务类型 + 工作表名),并把用户确认的 mapping/organization/updateExisting/duplicatePolicy/skipUnmatched 一并传入。系统直接解析文件、应用确认策略并按依赖顺序分阶段预览,用户确认后才会入库。每个回答回合最多调用一次,生成成功后提示用户打开向导逐阶段确认,不要重复调用,也不要代替用户调用任何写工具直接插入。', parameters: { type: 'object', properties: { @@ -46,6 +106,34 @@ export const A2UI_TOOL_SCHEMAS = [ additionalProperties: false, }, }, + mapping: { + type: 'object', + description: + '列映射确认结果:阶段 stepKey -> { 字段名: 工作表表头 }(字段名如 students.name/rooms.roomNumber/checkins.checkInDate/transfers.newRoom)。来自 preflight_import 报告的映射确认;未确认时省略,系统自动识别。', + additionalProperties: { + type: 'object', + description: '字段名 -> 工作表表头', + additionalProperties: { type: 'string', maxLength: 200 }, + }, + }, + organization: { + type: 'string', + description: '确认后的校区名称(预检报告出现未知校区时由用户确认)', + maxLength: 100, + }, + updateExisting: { + type: 'boolean', + description: '是否更新已匹配的现有记录;默认 true,false 时已匹配行跳过', + }, + duplicatePolicy: { + type: 'string', + description: '文件内重复行策略:error 标记错误 / skip 跳过重复行;默认 error', + enum: ['error', 'skip'], + }, + skipUnmatched: { + type: 'boolean', + description: '关系表(入住/换宿)找不到学生或宿舍时是否跳过该行;默认 false', + }, }, required: ['attachmentId', 'stages'], additionalProperties: false, @@ -214,13 +302,19 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须 当用户需要录入或修改业务数据时,先调用 render_form 生成确认表单,提示用户填写并提交;只有在用户通过表单提交确认后,才能执行写操作工具(如 create_student、update_students)。 新增学生示例:render_form 的 fields 使用 name/phone/gender/studentNo。 修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。 -当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,先调用 start_import_wizard 生成“导入向导”:必须传入 attachmentId(上传附件的 ID)和 stages(声明业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全;生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。每个回答回合最多调用一次 start_import_wizard。 +当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行: +1. 先调用 preflight_import(传入 attachmentId)生成“可插入性预检报告”:报告给出分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题和导入后的下一步建议。 +2. 报告为 blocked 时,向用户说明阻断原因并建议修正文件后重传,不要生成向导;报告为 needs_input 时,按报告中的 questions 向用户确认:选项型问题用 render_form 生成表单(如更新策略、重复策略、校区、未匹配行处理),列映射类问题用聊天文本确认;报告为 ready 时可直接进入下一步,如需列映射确认也可先问。不要替用户默认做出影响数据的决定。 + 报告只给汇总统计时,可用 excel_analyze 读取报告 errorSamples 对应的工作表与行号,向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。 +3. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。 +4. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。 +每个回答回合最多调用一次 preflight_import 和一次 start_import_wizard;报告与导入完成后由你给出下一步建议,不要自动执行后续写操作。 当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。 -上传的 Office 附件(Excel/Word/PPT)可用 office_analyze 查看结构(stats/outline)确认表名与表头;批量导入前如不确定列名,可用 get/query 只读少量单元格核对,不要读取整表。 +上传的 Office 附件:Excel(.xlsx/.csv)优先用 excel_analyze 查看概览(overview)或按行读取(rows)核对表头与数据;Word/PPT 用 office_analyze 查看结构(stats/outline)。批量导入前如不确定列名,可先预检(preflight_import)再用 excel_analyze 抽查具体行,不要读取整表。 业务工作流引导(重要): - 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。 - 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。 - 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)。 -- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么,再生成预览卡;多业务分表合并到同一张预览卡,并按依赖顺序执行。 +- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再按报告提问并生成导入向导,按依赖顺序执行。 - 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。 不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`; diff --git a/apps/server/src/ai-chat/ai-chat.generation.ts b/apps/server/src/ai-chat/ai-chat.generation.ts index c323aba..272a944 100644 --- a/apps/server/src/ai-chat/ai-chat.generation.ts +++ b/apps/server/src/ai-chat/ai-chat.generation.ts @@ -73,7 +73,8 @@ export async function executeGeneration( tool.function.name !== 'create_student' && tool.function.name !== 'update_students' && 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); diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index 01fd0e6..eff1cd5 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -175,7 +175,7 @@ describe('AiChatService', () => { expect(summary.length).toBeLessThanOrEqual(2000); }); - it('超大附件文本在进入模型前被截断并提示', async () => { + it('超大附件文本完整进入模型,不截断', async () => { const { service } = createService(); (service as unknown as { attachmentService: { toModelParts: jest.Mock } }).attachmentService = { toModelParts: jest @@ -195,17 +195,13 @@ describe('AiChatService', () => { ).buildUserContent.bind(service); const result = await build('请看这个文件', [{ id: 1 }], false); expect(typeof result).toBe('string'); - expect(result as string).toContain('内容过长'); - expect((result as string).length).toBeLessThan(50000); + expect(result as string).toContain('附件:big.xlsx'); + expect((result as string).length).toBeGreaterThan(120000); }); - it('大 Excel 附件在进入模型前生成概览并提示可动态读取', async () => { + it('大 Excel 附件全文进入模型,不再生成概览', async () => { const { service } = createService(); - (service as unknown as { excelReader: { overview: jest.Mock } }).excelReader = { - overview: jest.fn().mockResolvedValue({ text: '# 名单(共 100 行)\n表头\t列2' }), - }; (service as unknown as { attachmentService: unknown }).attachmentService = { - readStoredBuffer: jest.fn().mockResolvedValue(Buffer.from('x')), toModelParts: jest.fn().mockResolvedValue([ { attachment: { @@ -227,8 +223,8 @@ describe('AiChatService', () => { } ).buildUserContent.bind(service); const result = await build('请看这个文件', [{ id: 1 }], false); - expect(result as string).toContain('# 名单(共 100 行)'); - expect(result as string).toContain('office_analyze'); + expect(result as string).toContain('附件:big.xlsx'); + expect(result as string).toContain('x'.repeat(30000)); }); it.each([ @@ -1396,6 +1392,58 @@ describe('AiChatService', () => { expect(data).toMatchObject({ id: 'review-1' }); }); + it('confirmReviewStep 确认导入后写入操作日志', async () => { + const { service, reviewService } = createService(); + const review = { + id: 'review-1', + conversationId: 3, + userId: 7, + assistantMessageId: 12, + title: '批量导入', + summary: null, + sectionsJson: JSON.stringify([{ key: 'students', title: '学生' }]), + status: 'pending', + resultSummary: null, + submittedAt: null, + }; + const updated = { + ...review, + sectionsJson: JSON.stringify([ + { key: 'students', title: '学生', status: 'submitted' }, + ]), + }; + const opLog = { log: jest.fn().mockResolvedValue(undefined) }; + (service as unknown as { opLog: unknown }).opLog = opLog; + (service as unknown as { messages: unknown }).messages = { + findOne: jest.fn().mockResolvedValue({ + id: 12, + conversationId: 3, + metadata: { a2uiReview: { id: 'review-1', status: 'pending' } }, + }), + save: jest.fn(async (value) => value), + }; + reviewService.findOwned.mockResolvedValue(review); + reviewService.submitSection.mockResolvedValue({ + review: updated, + result: { created: 1, skipped: 0, issues: [] }, + message: '成功导入学生 1 人,跳过 0 条', + }); + + await service.confirmReviewStep(authenticatedUser as never, 'review-1', 'students'); + + expect(opLog.log).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 7, + username: 'tester', + module: '批量导入', + action: '确认导入分表', + detail: expect.stringContaining('成功导入学生 1 人'), + targetType: 'ai_review', + status: 'success', + }), + ); + }); + it('confirmReviewStep / confirmReviewGroup 对已失效预览返回 409', async () => { const { service, reviewService } = createService(); const review = { @@ -1871,4 +1919,284 @@ describe('AiChatService', () => { expect(importsService.createRun).not.toHaveBeenCalled(); expect(emitted.some(({ event }) => event === 'tool.failed')).toBe(true); }); + + it('start_import_wizard 接收确认参数并写入导入任务', 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 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 result = 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; + } + ).executeStartImportWizard( + 42, + { + id: 'call-1', + name: 'start_import_wizard', + arguments: JSON.stringify({ + attachmentId: 9, + stages: [{ stepKey: 'students', sheet: '学生' }], + mapping: { students: { name: '姓名', studentNo: '学号' } }, + organization: '主校区', + updateExisting: false, + duplicatePolicy: 'skip', + skipUnmatched: true, + }), + }, + { userId: 7, permissions: [], isSuperAdmin: false }, + jest.fn(), + ); + + const parsed = JSON.parse(result) as { status: string }; + expect(parsed.status).toBe('success'); + expect(importsService.createRun).toHaveBeenCalledWith( + { id: 7, permissions: [], isSuperAdmin: false }, + 'ai', + expect.objectContaining({ originalName: 'students.xlsx' }), + 3, + [{ stepKey: 'students', sheet: '学生' }], + { students: { name: '姓名', studentNo: '学号' } }, + { + organization: '主校区', + updateExisting: false, + duplicatePolicy: 'skip', + skipUnmatched: true, + }, + ); + }); + + 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 }> = []; + const result = await ( + service as unknown as { + executePreflightImport( + messageId: number, + call: { id: string; name: string; arguments: string }, + userId: number, + emit: (event: string, data?: unknown) => void, + ): Promise; + } + ).executePreflightImport( + 42, + { + id: 'call-1', + name: 'preflight_import', + arguments: JSON.stringify({ attachmentId: 9 }), + }, + 7, + (event, data) => emitted.push({ event, data: (data ?? {}) as Record }), + ); + + const parsed = JSON.parse(result) as { status: string; report: unknown }; + expect(parsed.status).toBe('success'); + expect(parsed.report).toEqual(report); + expect(importsService.preflightFile).toHaveBeenCalledWith( + expect.objectContaining({ originalName: 'students.xlsx' }), + ); + expect(emitted.some(({ event }) => event === 'ui.import_preflight')).toBe(true); + expect(messages.save).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ a2uiImportPreflight: report }), + }), + ); + }); + + it('excel_analyze 用 ExcelJS 读取概览并返回给模型', 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 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 excelReader = { + overview: jest.fn().mockResolvedValue({ + sheets: [{ name: '学生', rowCount: 2, columns: ['姓名'] }], + text: '# 学生(共 2 行)\n姓名\n张三', + }), + readRows: jest.fn(), + }; + (service as unknown as { toolRuns: unknown }).toolRuns = toolRuns; + (service as unknown as { attachmentService: unknown }).attachmentService = attachmentService; + (service as unknown as { excelReader: unknown }).excelReader = excelReader; + + const emitted: Array<{ event: string }> = []; + const result = await ( + service as unknown as { + executeExcelAnalyze( + messageId: number, + call: { id: string; name: string; arguments: string }, + userId: number, + emit: (event: string, data?: unknown) => void, + ): Promise; + } + ).executeExcelAnalyze( + 42, + { + id: 'call-1', + name: 'excel_analyze', + arguments: JSON.stringify({ attachmentId: 9, action: 'overview' }), + }, + 7, + (event) => emitted.push({ event }), + ); + + const parsed = JSON.parse(result) as { status: string; data: { sheets: unknown[] } }; + expect(parsed.status).toBe('success'); + expect(parsed.data.sheets).toHaveLength(1); + expect(excelReader.overview).toHaveBeenCalledWith(expect.any(Buffer)); + expect(emitted.some(({ event }) => event === 'tool.completed')).toBe(true); + }); + + it('start_import_wizard 拒绝非法的确认参数', 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', + }, + ]), + readStoredBuffer: jest.fn(), + }; + const importsService = { createRun: jest.fn() }; + (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 { + executeStartImportWizard( + messageId: number, + call: { id: string; name: string; arguments: string }, + context: { userId: number; permissions: string[]; isSuperAdmin: boolean }, + emit: (event: string, data?: unknown) => void, + ): Promise; + } + ).executeStartImportWizard( + 42, + { + id: 'call-1', + name: 'start_import_wizard', + arguments: JSON.stringify({ + attachmentId: 9, + stages: [{ stepKey: 'students', sheet: '学生' }], + duplicatePolicy: 'bogus', + }), + }, + { userId: 7, permissions: [], isSuperAdmin: false }, + jest.fn(), + ); + + const parsed = JSON.parse(result) as { status: string; error: string }; + expect(parsed.status).toBe('failed'); + expect(parsed.error).toContain('duplicatePolicy'); + expect(importsService.createRun).not.toHaveBeenCalled(); + }); }); diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index f81981f..55294ee 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -16,6 +16,7 @@ import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OfficeCliService } from './office-cli.service'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AiConversation, AiMessage, @@ -72,7 +73,11 @@ import { markReviewSubmittedOnMessage, } from './ai-chat.submissions'; import { denyWriteTool, executeTool } from './ai-chat.tools'; -import { executeStartImportWizard } from './ai-chat.tool-actions'; +import { + executeExcelAnalyze, + executePreflightImport, + executeStartImportWizard, +} from './ai-chat.tool-actions'; import { executeGeneration } from './ai-chat.generation'; import { assertGeneratedLength, @@ -108,6 +113,7 @@ export class AiChatService implements AiChatServiceContext { readonly excelReader?: AiExcelReaderService, readonly officeCli?: OfficeCliService, readonly importsService?: ImportsService, + readonly opLog?: OperationLogsService, ) {} listSkills(user: AuthenticatedUser): AgentSkillDescriptor[] { @@ -291,6 +297,24 @@ export class AiChatService implements AiChatServiceContext { return executeStartImportWizard(this, messageId, call, context, emit); } + executePreflightImport( + messageId: number, + call: ModelToolCall, + userId: number, + emit: AiSseEmitter, + ): Promise { + return executePreflightImport(this, messageId, call, userId, emit); + } + + executeExcelAnalyze( + messageId: number, + call: ModelToolCall, + userId: number, + emit: AiSseEmitter, + ): Promise { + return executeExcelAnalyze(this, messageId, call, userId, emit); + } + executeGeneration(input: GenerationInput): Promise { return executeGeneration(this, input); } diff --git a/apps/server/src/ai-chat/ai-chat.streaming.ts b/apps/server/src/ai-chat/ai-chat.streaming.ts index a83f11c..ef01afe 100644 --- a/apps/server/src/ai-chat/ai-chat.streaming.ts +++ b/apps/server/src/ai-chat/ai-chat.streaming.ts @@ -12,9 +12,6 @@ import type { } from './ai-chat.types'; import { DEFAULT_TITLE, - MAX_ATTACHMENT_TEXT_CHARS, - MAX_CONTEXT_CHARS, - MAX_FOCUS_CONTENT_CHARS, MAX_HISTORY_MESSAGES, SYSTEM_PROMPT, } from './ai-chat.types'; @@ -301,7 +298,6 @@ export async function buildContext( ? `${SYSTEM_PROMPT}\n当前会话已锁定技能:${skillKey}。只能调用该技能内的工具。` : SYSTEM_PROMPT; const selected: ModelMessage[] = []; - let chars = systemPrompt.length; for (const message of history) { if (message.status !== 'completed') continue; const content = @@ -310,15 +306,6 @@ export async function buildContext( : message.role === 'user' && message.attachments?.length ? await context.buildUserContent(message.content, message.attachments, supportsVision) : message.content; - const contentChars = - typeof content === 'string' - ? content.length - : content.reduce( - (total, part) => total + (part.type === 'text' ? part.text.length : 1024), - 0, - ); - if (chars + contentChars > MAX_CONTEXT_CHARS) break; - chars += contentChars; selected.push({ role: message.role, content } as ModelMessage); if (selected.length >= MAX_HISTORY_MESSAGES) break; } @@ -337,38 +324,15 @@ export async function buildUserContent( const contentParts: ModelContentPart[] = []; for (const part of parts) { if (part.text !== undefined) { - const isSpreadsheet = (part.attachment.mimeType ?? '').includes('spreadsheetml'); - const isLarge = part.text.length > MAX_ATTACHMENT_TEXT_CHARS; - if (isSpreadsheet && isLarge && context.excelReader) { - let overview: string | null = null; - try { - const buffer = await context.attachmentService.readStoredBuffer(part.attachment); - overview = (await context.excelReader.overview(buffer, 12)).text; - } catch { - overview = null; - } - const content = overview ?? context.truncateText(part.text, MAX_ATTACHMENT_TEXT_CHARS); - textSections.push( - `\n\n[附件:${part.attachment.originalName}(附件ID=${part.attachment.id})]\n${content}\n\n[提示:以上仅为文件概览(工作表、行数与前几行样本)。文件较大,需要具体数据时请调用 office_analyze 工具(outline/get/query/text)按需读取,attachmentId 使用上面的附件ID。]`, - ); - } else { - textSections.push( - `\n\n[附件:${part.attachment.originalName}(附件ID=${part.attachment.id})]\n${context.truncateText( - part.text, - MAX_ATTACHMENT_TEXT_CHARS, - )}`, - ); - } + textSections.push( + `\n\n[附件:${part.attachment.originalName}(附件ID=${part.attachment.id})]\n${part.text}`, + ); } else if (part.imageDataUrl) { textSections.push(`\n\n[图片附件:${part.attachment.originalName}]`); contentParts.push({ type: 'image_url', image_url: { url: part.imageDataUrl } }); } } const combinedText = textSections.join(''); - const boundedText = - combinedText.length > MAX_FOCUS_CONTENT_CHARS - ? context.truncateText(combinedText, MAX_FOCUS_CONTENT_CHARS) - : combinedText; - if (!contentParts.length) return boundedText; - return [{ type: 'text', text: boundedText }, ...contentParts]; + if (!contentParts.length) return combinedText; + return [{ type: 'text', text: combinedText }, ...contentParts]; } diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index 889cb9f..be942da 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -138,6 +138,15 @@ export async function submitReview( user.id, ); const summary = `已确认导入「${review.title}」:${result.message}`; + await context.opLog?.log({ + userId: user.id, + username: user.username, + module: '批量导入', + action: '确认导入全部', + detail: `「${review.title}」${result.message}`, + targetType: 'ai_review', + status: 'success', + }); const saved = await context.dataSource.transaction(async (manager) => { const exchange = await persistExchange( context, @@ -204,11 +213,20 @@ export async function confirmReviewStep( throw new ConflictException('导入预览已失效,请重新生成预览'); } assertReviewImportPermissions(context, user, review, sectionKey); - const { review: updated } = await context.reviewService.submitSection( + const { review: updated, message } = await context.reviewService.submitSection( review.id, user.id, sectionKey, ); + await context.opLog?.log({ + userId: user.id, + username: user.username, + module: '批量导入', + action: '确认导入分表', + detail: `「${review.title}」分表「${sectionKey}」:${message}`, + targetType: 'ai_review', + status: 'success', + }); await context.markReviewSubmittedOnMessage( updated.assistantMessageId, updated.conversationId, @@ -235,6 +253,20 @@ export async function confirmReviewGroup( } assertReviewImportPermissions(context, user, review, undefined, type); const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type); + const sectionTitles = context.reviewService + .parseSections(updated.sectionsJson) + .filter((section) => section.type === type) + .map((section) => section.title) + .join('、'); + await context.opLog?.log({ + userId: user.id, + username: user.username, + module: '批量导入', + action: '确认导入分组', + detail: `「${review.title}」分组「${type}」:${sectionTitles}`, + targetType: 'ai_review', + status: 'success', + }); await context.markReviewSubmittedOnMessage( updated.assistantMessageId, updated.conversationId, diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index 71e9b63..2123165 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -1,10 +1,173 @@ import { AiReview } from './entities/ai-review.entity'; -import { IMPORT_STEP_KEYS, type ImportStageRequest } from '../imports/imports.types'; +import { + IMPORT_STEP_KEYS, + type ColumnMapping, + type ImportRunSettings, + type ImportStageRequest, + type ImportStepKey, + type PreflightReport, +} from '../imports/imports.types'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AgentToolContext } from './ai-chat.tools'; import { finishToolRun, startToolRun } from './ai-chat.tools'; export { executeOfficeAnalyze, buildOfficeCliArgs } from './ai-chat.tool-office'; +function isExcelAttachment(attachment: { + mimeType: string; + originalName: string; +}): boolean { + return ( + attachment.mimeType.includes('spreadsheetml') || + attachment.mimeType.includes('excel') || + attachment.mimeType.includes('csv') || + /\.(xlsx|csv)$/i.test(attachment.originalName) + ); +} + +export async function executePreflightImport( + context: AiChatServiceContext, + messageId: number, + call: ModelToolCall, + userId: number, + emit: AiSseEmitter, +): Promise { + const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { + toolName: 'preflight_import', + skillKey: null, + argumentsData: null, + }); + + try { + const assistant = await context.messages.findOne({ where: { id: messageId } }); + if (!assistant) throw new Error('assistant message missing'); + const parsedRecord = + parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) + ? (parsedArgs as Record) + : {}; + const attachmentId = + typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined; + if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) { + throw new Error('缺少附件 attachmentId'); + } + const [attachment] = await context.attachmentService.requireReadyOwned(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, + }); + assistant.metadata = { + ...assistant.metadata, + a2uiImportPreflight: preflight, + }; + 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 }); + return JSON.stringify({ + status: 'success', + report: preflight, + message: '预检报告已生成,请按报告中的 questions 向用户确认后,再调用 start_import_wizard', + }); + } catch (error) { + const summary = + error instanceof Error ? error.message.slice(0, 100) : '导入预检失败'; + await finishToolRun(context, run, call, startedAt, { + status: 'failed', + summary, + error: summary, + }, emit); + return JSON.stringify({ status: 'failed', error: run.resultSummary }); + } +} + +export async function executeExcelAnalyze( + context: AiChatServiceContext, + messageId: number, + call: ModelToolCall, + userId: number, + emit: AiSseEmitter, +): Promise { + const { run, parsedArgs, startedAt } = await startToolRun(context, messageId, call, emit, { + toolName: 'excel_analyze', + skillKey: null, + argumentsData: null, + }); + + try { + const parsedRecord = + parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) + ? (parsedArgs as Record) + : {}; + const attachmentId = + typeof parsedRecord.attachmentId === 'number' ? parsedRecord.attachmentId : undefined; + if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) { + throw new Error('缺少附件 attachmentId'); + } + const action = typeof parsedRecord.action === 'string' ? parsedRecord.action : ''; + if (action !== 'overview' && action !== 'rows') { + throw new Error('action 只能是 overview 或 rows'); + } + const [attachment] = await context.attachmentService.requireReadyOwned(userId, [ + attachmentId as number, + ]); + if (!isExcelAttachment(attachment)) { + throw new Error('附件不是 Excel 文件,无法解析'); + } + if (!context.excelReader) throw new Error('Excel 解析器未配置'); + const buffer = await context.attachmentService.readStoredBuffer(attachment); + + let data: unknown; + let summary: string; + if (action === 'overview') { + const overview = await context.excelReader.overview(buffer); + data = { sheets: overview.sheets, text: overview.text }; + summary = `已解析 ${overview.sheets.length} 个工作表`; + } else { + const sheet = typeof parsedRecord.sheet === 'string' ? parsedRecord.sheet : undefined; + const startRow = Number(parsedRecord.startRow ?? 1); + const maxRows = Number(parsedRecord.maxRows ?? 20); + const maxColumns = Number(parsedRecord.maxColumns ?? 30); + if (!Number.isInteger(startRow) || startRow < 1) throw new Error('startRow 必须是 >=1 的整数'); + if (!Number.isInteger(maxRows) || maxRows < 1) { + throw new Error('maxRows 必须是 >=1 的整数'); + } + if (!Number.isInteger(maxColumns) || maxColumns < 1) { + throw new Error('maxColumns 必须是 >=1 的整数'); + } + data = await context.excelReader.readRows(buffer, sheet, startRow, maxRows, maxColumns); + summary = `已读取工作表「${(data as { sheet: string }).sheet}」${(data as { rows: unknown[] }).rows.length} 行`; + } + + await finishToolRun(context, run, call, startedAt, { + status: 'success', + summary, + }, emit); + return JSON.stringify({ status: 'success', data }); + } catch (error) { + const summary = + error instanceof Error ? error.message.slice(0, 100) : 'Excel 解析失败'; + await finishToolRun(context, run, call, startedAt, { + status: 'failed', + summary, + error: summary, + }, emit); + return JSON.stringify({ status: 'failed', error: run.resultSummary }); + } +} + export async function executeStartImportWizard( context: AiChatServiceContext, messageId: number, @@ -33,12 +196,7 @@ export async function executeStartImportWizard( const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [ attachmentId as number, ]); - const isExcel = - attachment.mimeType.includes('spreadsheetml') || - attachment.mimeType.includes('excel') || - attachment.mimeType.includes('csv') || - /\.(xlsx|csv)$/i.test(attachment.originalName); - if (!isExcel) throw new Error('附件不是 Excel 文件,无法生成导入向导'); + if (!isExcelAttachment(attachment)) throw new Error('附件不是 Excel 文件,无法生成导入向导'); const stages = Array.isArray(parsedRecord.stages) ? (parsedRecord.stages as ImportStageRequest[]) : []; @@ -51,6 +209,8 @@ export async function executeStartImportWizard( throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`); } } + const mapping = parseConfirmedMapping(parsedRecord.mapping); + const settings = parseConfirmedSettings(parsedRecord); if (!context.importsService) throw new Error('导入向导服务未配置'); const buffer = await context.attachmentService.readStoredBuffer(attachment); const detail = await context.importsService.createRun( @@ -68,6 +228,8 @@ export async function executeStartImportWizard( }, assistant.conversationId, stages, + mapping, + settings, ); const wizard = compactImportWizard(detail); assistant.metadata = { @@ -99,6 +261,59 @@ export async function executeStartImportWizard( } } +function parseConfirmedMapping(raw: unknown): Partial> | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('mapping 参数格式错误'); + const mapping: Partial> = {}; + for (const [stepKey, fields] of Object.entries(raw as Record)) { + if (!(IMPORT_STEP_KEYS as readonly string[]).includes(stepKey)) { + throw new Error(`mapping 包含未知业务类型:${stepKey}`); + } + if (fields === undefined || fields === null) continue; + if (typeof fields !== 'object' || Array.isArray(fields)) { + throw new Error(`mapping 中「${stepKey}」的列映射格式错误`); + } + const columnMapping: ColumnMapping = {}; + for (const [field, header] of Object.entries(fields as Record)) { + if (typeof field !== 'string' || !field.trim() || field.length > 50) continue; + if (typeof header !== 'string' || !header.trim()) continue; + columnMapping[field] = header.slice(0, 200); + } + mapping[stepKey as ImportStepKey] = columnMapping; + } + return mapping; +} + +function parseConfirmedSettings(parsedRecord: Record): ImportRunSettings { + const settings: ImportRunSettings = {}; + if (parsedRecord.organization !== undefined && parsedRecord.organization !== null) { + if (typeof parsedRecord.organization !== 'string') { + throw new Error('organization 必须是字符串'); + } + const organization = parsedRecord.organization.trim().slice(0, 100); + if (organization) settings.organization = organization; + } + if (parsedRecord.updateExisting !== undefined) { + if (typeof parsedRecord.updateExisting !== 'boolean') { + throw new Error('updateExisting 必须是布尔值'); + } + settings.updateExisting = parsedRecord.updateExisting; + } + if (parsedRecord.duplicatePolicy !== undefined) { + if (parsedRecord.duplicatePolicy !== 'error' && parsedRecord.duplicatePolicy !== 'skip') { + throw new Error('duplicatePolicy 只能是 error 或 skip'); + } + settings.duplicatePolicy = parsedRecord.duplicatePolicy; + } + if (parsedRecord.skipUnmatched !== undefined) { + if (typeof parsedRecord.skipUnmatched !== 'boolean') { + throw new Error('skipUnmatched 必须是布尔值'); + } + settings.skipUnmatched = parsedRecord.skipUnmatched; + } + return settings; +} + export function compactImportWizard(detail: any): { runId: string; fileName: string; diff --git a/apps/server/src/ai-chat/ai-chat.tool-office.ts b/apps/server/src/ai-chat/ai-chat.tool-office.ts index baea5eb..16ccf23 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-office.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-office.ts @@ -73,12 +73,6 @@ export async function executeOfficeAnalyze( } catch { payload = '{}'; } - const MAX_OFFICE_RESULT_CHARS = 96 * 1024; - let truncated = false; - if (payload.length > MAX_OFFICE_RESULT_CHARS) { - truncated = true; - payload = `${payload.slice(0, MAX_OFFICE_RESULT_CHARS)}\n\n[结果过大已截断,请缩小读取范围]`; - } let parsedData: unknown; try { parsedData = JSON.parse(payload); @@ -87,7 +81,7 @@ export async function executeOfficeAnalyze( } await finishToolRun(context, run, call, startedAt, { status: 'success', summary: context.summarize(result.data) }, emit); - return JSON.stringify({ status: 'success', data: parsedData, truncated }); + return JSON.stringify({ status: 'success', data: parsedData }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const failureSummary = context.redactText(errorMessage).slice(0, MAX_SUMMARY_CHARS); diff --git a/apps/server/src/ai-chat/ai-chat.tools.ts b/apps/server/src/ai-chat/ai-chat.tools.ts index af483e5..597b205 100644 --- a/apps/server/src/ai-chat/ai-chat.tools.ts +++ b/apps/server/src/ai-chat/ai-chat.tools.ts @@ -3,6 +3,8 @@ import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-cha import type { AiToolRun } from './entities'; import { executeOfficeAnalyze, + executeExcelAnalyze, + executePreflightImport, executeRenderChart, executeRenderForm, executeRenderReview, @@ -90,6 +92,12 @@ export async function executeTool( if (call.name === 'render_form') { return executeRenderForm(context, messageId, call, userId, emit); } + if (call.name === 'preflight_import') { + return executePreflightImport(context, messageId, call, userId, emit); + } + if (call.name === 'excel_analyze') { + return executeExcelAnalyze(context, messageId, call, userId, emit); + } if (call.name === 'start_import_wizard') { return executeStartImportWizard(context, messageId, call, agentContext, emit); } diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index f007ee2..07b4dc5 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -12,6 +12,7 @@ import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OfficeCliService } from './office-cli.service'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AiConversation, AiMessage, @@ -23,9 +24,6 @@ import { export { DEFAULT_TITLE, - MAX_ATTACHMENT_TEXT_CHARS, - MAX_CONTEXT_CHARS, - MAX_FOCUS_CONTENT_CHARS, MAX_GENERATED_CHARS, MAX_HISTORY_MESSAGES, MAX_SUMMARY_CHARS, @@ -98,6 +96,7 @@ export interface AiChatServiceContext { readonly excelReader?: AiExcelReaderService; readonly officeCli?: OfficeCliService; readonly importsService?: ImportsService; + readonly opLog?: OperationLogsService; listSkills(user: AuthenticatedUser): ReturnType; serializeMessage(message: AiMessage): Record; redactText(value: string): string; @@ -178,6 +177,7 @@ export type AiSseEventName = | 'ui.form' | 'ui.review' | 'ui.chart' + | 'ui.import_preflight' | 'ui.import_wizard' | 'attachment.processed' | 'message.completed' diff --git a/apps/server/src/ai-chat/ai-excel-reader.service.ts b/apps/server/src/ai-chat/ai-excel-reader.service.ts index 0589b7b..a970a23 100644 --- a/apps/server/src/ai-chat/ai-excel-reader.service.ts +++ b/apps/server/src/ai-chat/ai-excel-reader.service.ts @@ -38,10 +38,7 @@ export class AiExcelReaderService { } /** Sheet list + row counts + a short sample, small enough for prompts. */ - async overview( - buffer: Buffer, - sampleRows = 12, - ): Promise<{ sheets: ExcelSheetInfo[]; text: string }> { + async overview(buffer: Buffer): Promise<{ sheets: ExcelSheetInfo[]; text: string }> { const sheets = await this.loadSheets(buffer); const info = sheets.map((sheet) => ({ name: sheet.name, @@ -51,12 +48,7 @@ export class AiExcelReaderService { const lines: string[] = []; for (const sheet of sheets) { lines.push(`# ${sheet.name}(共 ${sheet.rows.length} 行)`); - for (const row of sheet.rows.slice(0, sampleRows)) { - lines.push(row.join('\t')); - } - if (sheet.rows.length > sampleRows) { - lines.push(`…(其余 ${sheet.rows.length - sampleRows} 行未显示)`); - } + for (const row of sheet.rows) lines.push(row.join('\t')); } return { sheets: info, text: lines.join('\n') }; } @@ -80,9 +72,9 @@ export class AiExcelReaderService { return { sheet: sheetName ?? '', rowCount: 0, startRow, rows: [], truncated: false }; } const from = Math.max(0, startRow - 1); - const limit = Math.min(rowCount, 200); + const limit = rowCount; const slice = sheet.rows.slice(from, from + limit); - const rows = slice.map((row) => row.slice(0, Math.min(maxColumns, 50))); + const rows = slice.map((row) => row.slice(0, maxColumns)); return { sheet: sheet.name, rowCount: sheet.rows.length, diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 2423e75..8816293 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -18,6 +18,7 @@ import { AddA2UiForms1784870000000 } from './migrations/1784870000000-AddA2UiFor import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiReviews'; import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns'; import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback'; +import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings'; const allMigrations = [ InitialSchema1784520727860, AddExamManagement1784600000000, @@ -29,6 +30,7 @@ const allMigrations = [ AddA2UiReviews1784880000000, AddImportRuns1784910000000, DropAiMessageFeedback1784920000000, + AddImportRunSettings1784930000000, ]; import { AuthorizationModule } from './authorization'; import { RbacModule } from './rbac/rbac.module'; diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index d9dcd6b..011a93b 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -87,6 +87,12 @@ export class ExpensesController { return this.service.getFormLookups(); } + @Get('student-lookups') + @RequirePermission('expense:view') + getStudentLookups() { + return this.service.getStudentLookups(); + } + @Post('student-utility') @RequirePermission('expense:create') async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) { diff --git a/apps/server/src/expenses/expenses.lookups.spec.ts b/apps/server/src/expenses/expenses.lookups.spec.ts index 25d2d9f..0ca2164 100644 --- a/apps/server/src/expenses/expenses.lookups.spec.ts +++ b/apps/server/src/expenses/expenses.lookups.spec.ts @@ -22,4 +22,30 @@ describe('ExpensesService permission-scoped lookups', () => { expect(roomRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'roomNumber', 'building'] })); expect(studentRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'name', 'studentNo'] })); }); + + it('returns active students for expense form student lookups', async () => { + const studentRepo = { + find: jest.fn().mockResolvedValue([ + { id: 2, name: '张三', studentNo: 'S2' }, + { id: 3, name: '李四', studentNo: 'S3' }, + ]), + }; + const service = new ExpensesService( + {} as never, + {} as never, + {} as never, + studentRepo as never, + ); + + await expect(service.getStudentLookups()).resolves.toEqual([ + { id: 2, name: '张三', studentNo: 'S2' }, + { id: 3, name: '李四', studentNo: 'S3' }, + ]); + expect(studentRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + select: ['id', 'name', 'studentNo'], + where: { status: 'active' }, + }), + ); + }); }); diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index df8e329..0a77692 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -42,6 +42,15 @@ export class ExpensesService { return { rooms, students }; } + /** 费用录入/编辑表单需要的在读学生下拉项。 */ + async getStudentLookups() { + return this.studentRepo.find({ + select: ['id', 'name', 'studentNo'], + where: { status: 'active' }, + order: { name: 'ASC' }, + }); + } + // 宿舍费用 async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) { this.assertValidPeriod(dto.periodStart, dto.periodEnd); diff --git a/apps/server/src/imports/entities/import-run.entity.ts b/apps/server/src/imports/entities/import-run.entity.ts index dba89fe..d7d03c7 100644 --- a/apps/server/src/imports/entities/import-run.entity.ts +++ b/apps/server/src/imports/entities/import-run.entity.ts @@ -23,6 +23,10 @@ export class ImportRun { @Column({ name: 'sheets_json', type: 'text' }) sheetsJson: string; + /** Serialized ImportRunSettings — confirmed mapping/policies from AI preflight. */ + @Column({ name: 'settings_json', type: 'text', nullable: true }) + settingsJson: string | null; + @Column({ type: 'varchar', length: 20, default: 'preparing' }) status: ImportRunStatus; diff --git a/apps/server/src/imports/imports.controller.spec.ts b/apps/server/src/imports/imports.controller.spec.ts new file mode 100644 index 0000000..baf1ccc --- /dev/null +++ b/apps/server/src/imports/imports.controller.spec.ts @@ -0,0 +1,74 @@ +import { ImportsController } from './imports.controller'; + +describe('ImportsController', () => { + const principalRequest = { + user: { id: 7, username: 'admin', permissions: [], isSuperAdmin: true }, + headers: { 'user-agent': 'jest-agent' }, + connection: { remoteAddress: '127.0.0.1' }, + }; + + it('提交导入阶段成功后写入操作日志', async () => { + const commitStep = jest.fn().mockResolvedValue({ + runId: 'run-1', + stepKey: 'students', + status: 'committed', + created: 2, + updated: 0, + skipped: 0, + failed: 0, + total: 2, + nextStepKey: null, + runStatus: 'committed', + message: '阶段「学生档案」提交完成:新建 2、更新 0、跳过 0、失败 0;全部阶段已完成', + }); + const opLog = { log: jest.fn().mockResolvedValue(undefined) }; + const controller = new ImportsController({ commitStep } as never, opLog as never); + + await controller.commit( + principalRequest as never, + 'run-1', + 'students', + { decisions: [] }, + ); + + expect(opLog.log).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 7, + username: 'admin', + module: '批量导入', + action: '提交导入阶段', + detail: expect.stringContaining('提交完成'), + targetType: 'import_run', + ipAddress: '127.0.0.1', + userAgent: 'jest-agent', + }), + ); + }); + + it('非 committed 回执(conflict/already_committed)不重复写日志', async () => { + const commitStep = jest.fn().mockResolvedValue({ + runId: 'run-1', + stepKey: 'students', + status: 'conflict', + created: 0, + updated: 0, + skipped: 0, + failed: 0, + total: 0, + nextStepKey: null, + runStatus: 'ready', + message: '请先完成前置阶段', + }); + const opLog = { log: jest.fn().mockResolvedValue(undefined) }; + const controller = new ImportsController({ commitStep } as never, opLog as never); + + await controller.commit( + principalRequest as never, + 'run-1', + 'transfers', + { decisions: [] }, + ); + + expect(opLog.log).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/imports/imports.controller.ts b/apps/server/src/imports/imports.controller.ts index 2edf23a..75110a0 100644 --- a/apps/server/src/imports/imports.controller.ts +++ b/apps/server/src/imports/imports.controller.ts @@ -15,6 +15,8 @@ import { FileInterceptor } from '@nestjs/platform-express'; import type { Request, Response } from 'express'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import type { AuthenticatedUser } from '../authorization'; +import { extractRequestInfo } from '../common/request-utils'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { IMPORT_STEP_KEYS, type ImportRowDecision, @@ -38,7 +40,10 @@ const IMPORT_GATE_PERMISSIONS = [ @Controller('imports') @RequirePermission(...IMPORT_GATE_PERMISSIONS) export class ImportsController { - constructor(private readonly importsService: ImportsService) {} + constructor( + private readonly importsService: ImportsService, + private readonly opLog: OperationLogsService, + ) {} @Post('runs') @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } })) @@ -69,6 +74,17 @@ export class ImportsController { throw new BadRequestException('mapping 参数格式错误'); } } + let settings: Record | undefined; + if (typeof body.settings === 'string' && body.settings.trim()) { + try { + const parsed = JSON.parse(body.settings) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + settings = parsed as Record; + } + } catch { + throw new BadRequestException('settings 参数格式错误'); + } + } const conversationId = body.conversationId !== undefined ? Number(body.conversationId) : undefined; const source = body.source === 'ai' ? 'ai' : 'manual'; @@ -84,6 +100,7 @@ export class ImportsController { Number.isFinite(conversationId) ? conversationId : undefined, stages, mapping, + settings, ); return { success: true, data }; } @@ -122,6 +139,19 @@ export class ImportsController { this.parseStepKey(stepKey), Array.isArray(body?.decisions) ? body.decisions : [], ); + if (data.status === 'committed') { + const { ipAddress, userAgent } = extractRequestInfo(req); + await this.opLog.log({ + userId: req.user.id, + username: req.user.username, + module: '批量导入', + action: '提交导入阶段', + detail: data.message, + targetType: 'import_run', + ipAddress, + userAgent, + }); + } return { success: true, data }; } diff --git a/apps/server/src/imports/imports.policies.ts b/apps/server/src/imports/imports.policies.ts new file mode 100644 index 0000000..941af67 --- /dev/null +++ b/apps/server/src/imports/imports.policies.ts @@ -0,0 +1,72 @@ +import type { + ImportRowAction, + ImportRowStatus, + ImportRunSettings, +} from './imports.types'; +import type { ValidatedRow } from './imports.rows'; + +export interface PolicyPreviewResult { + errors: string[]; + action: ImportRowAction | null; + status: ImportRowStatus; +} + +function isDuplicateError(error: string): boolean { + return ( + error.includes('请勿重复导入') || + error.includes('请勿重复换宿') || + error.includes('本次文件中已有') + ); +} + +function isReferenceError(error: string): boolean { + return ( + error.includes('未找到匹配学生') || + error.includes('缺少学生标识') || + error.includes('未找到宿舍') || + error.includes('未找到原宿舍') || + error.includes('未找到新宿舍') || + error.includes('未找到该学生在原宿舍的在住记录') + ); +} + +/** + * 把 AI 预检确认的策略应用到预览行: + * - updateExisting=false:已匹配行改为跳过; + * - duplicatePolicy=skip:文件内重复行跳过并保留提示; + * - skipUnmatched=true:关系表找不到学生/宿舍的行跳过并保留提示。 + */ +export function applyPreviewPolicies( + result: ValidatedRow, + settings: ImportRunSettings | null, +): PolicyPreviewResult { + const errors = [...result.errors]; + let action = result.action; + let status: ImportRowStatus = errors.length > 0 ? 'error' : 'valid'; + + const duplicatePolicy = settings?.duplicatePolicy ?? 'error'; + const updateExisting = settings?.updateExisting ?? true; + const skipUnmatched = settings?.skipUnmatched ?? false; + + if (duplicatePolicy === 'skip' && errors.some(isDuplicateError)) { + action = 'skip'; + status = 'valid'; + const keptErrors = errors.filter((error) => !isDuplicateError(error)); + keptErrors.push('文件内重复行,已按策略跳过'); + errors.splice(0, errors.length, ...keptErrors); + } + + if (!updateExisting && errors.length === 0 && action === 'update') { + action = 'skip'; + status = 'valid'; + errors.push('已匹配现有记录,按策略跳过更新'); + } + + if (skipUnmatched && status === 'error' && errors.every(isReferenceError)) { + action = 'skip'; + status = 'valid'; + errors.push('未匹配学生/宿舍,按策略跳过'); + } + + return { errors, action, status }; +} diff --git a/apps/server/src/imports/imports.preflight.spec.ts b/apps/server/src/imports/imports.preflight.spec.ts new file mode 100644 index 0000000..9345fd8 --- /dev/null +++ b/apps/server/src/imports/imports.preflight.spec.ts @@ -0,0 +1,195 @@ +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, + 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, + }), + ); + }); +}); diff --git a/apps/server/src/imports/imports.preflight.ts b/apps/server/src/imports/imports.preflight.ts new file mode 100644 index 0000000..a9884dc --- /dev/null +++ b/apps/server/src/imports/imports.preflight.ts @@ -0,0 +1,417 @@ +import { DataSource } from 'typeorm'; +import { Organization } from '../entities/organization.entity'; +import { buildLookups } from './imports.lookups'; +import { 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 = { + 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 = { + name: '姓名', + roomNumber: '宿舍号', + capacity: '容量', + checkInDate: '入住日期', + oldRoom: '原宿舍', + newRoom: '新宿舍', + transferDate: '换宿日期', + identity: '学号或手机号', +}; + +const NEXT_STEP_DEFS: Array = [ + { + 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(); + 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 { + const firstSheet = sheets[0]; + const mapping: ColumnMapping = suggestMapping(firstSheet.headers, stepKey); + 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(); + const unknownOrgs = new Set(); + const batchState: ImportBatchState = { + checkinStudentIds: new Set(), + transferStudentIds: new Set(), + }; + + for (const sheet of sheets) { + const lookups = await buildLookups(dataSource, stepKey, sheet.headers, sheet.rows, mapping); + for (let i = 0; i < sheet.rows.length; i += 1) { + const rawValues = sheet.rows[i]; + const fields: Record = {}; + for (const [field, header] of Object.entries(mapping)) { + 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: i + 2, + 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), + 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(); + const stepKeys = new Map>(); + const add = (code: PreflightBlockCode, stepKey: ImportStepKey, count: number) => { + counts.set(code, (counts.get(code) ?? 0) + count); + const keys = stepKeys.get(code) ?? new Set(); + 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 { + const grouped = new Map(); + 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(); + 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), + }; +} diff --git a/apps/server/src/imports/imports.preview.service.ts b/apps/server/src/imports/imports.preview.service.ts index dc71118..ef9ba19 100644 --- a/apps/server/src/imports/imports.preview.service.ts +++ b/apps/server/src/imports/imports.preview.service.ts @@ -8,6 +8,7 @@ import { IMPORT_STEP_LABELS } from './imports.types'; import type { CellValue, ColumnMapping, + ImportRunSettings, ImportStepKey, StepPreviewSummary, } from './imports.types'; @@ -16,6 +17,7 @@ import { assertMapping, suggestMapping } from './imports.mapping'; import { buildLookups } from './imports.lookups'; import { validateRow } from './imports.rows'; import type { ImportBatchState } from './imports.rows'; +import { applyPreviewPolicies } from './imports.policies'; import { findOwnedRun, findStep } from './imports.access'; import type { ImportPrincipal } from './imports.access'; @@ -55,6 +57,7 @@ export class ImportPreviewService { const sheetsData = parseJson>(run.sheetsJson) ?? []; + const settings = parseJson(run.settingsJson) ?? {}; const sheetNames = body.sheets?.length ? body.sheets : (parseJson(step.sheetsJson) ?? []); @@ -106,20 +109,22 @@ export class ImportPreviewService { fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null; } const result = validateRow(stepKey, fields, lookups, batchState); + const policy = applyPreviewPolicies(result, settings); const normalized = { ...result.normalized, ...result.resolvedIds }; summary.total += 1; - if (result.errors.length > 0) { + if (policy.status === 'error') { summary.error += 1; } else { summary.valid += 1; - if (result.action === 'create') summary.create += 1; - if (result.action === 'update') summary.update += 1; - if (result.action === 'create') { - const studentId = result.resolvedIds._studentId; - if (studentId !== undefined) { - if (stepKey === 'checkins') batchState.checkinStudentIds.add(studentId); - if (stepKey === 'transfers') batchState.transferStudentIds.add(studentId); - } + if (policy.action === 'create') summary.create += 1; + if (policy.action === 'update') summary.update += 1; + if (policy.action === 'skip') summary.skip += 1; + } + if (policy.status === 'valid' && policy.action === 'create') { + const studentId = result.resolvedIds._studentId; + if (studentId !== undefined) { + if (stepKey === 'checkins') batchState.checkinStudentIds.add(studentId); + if (stepKey === 'transfers') batchState.transferStudentIds.add(studentId); } } rowEntities.push( @@ -131,9 +136,9 @@ export class ImportPreviewService { rawJson: JSON.stringify(raw), normalizedJson: JSON.stringify(normalized), matchKey: result.matchKey, - action: result.action, - status: result.errors.length > 0 ? 'error' : 'valid', - errorsJson: result.errors.length > 0 ? JSON.stringify(result.errors) : null, + action: policy.action, + status: policy.status, + errorsJson: policy.errors.length > 0 ? JSON.stringify(policy.errors) : null, targetId: result.targetId ?? null, }), ); diff --git a/apps/server/src/imports/imports.run.service.ts b/apps/server/src/imports/imports.run.service.ts index 9d341cb..2d7f1a1 100644 --- a/apps/server/src/imports/imports.run.service.ts +++ b/apps/server/src/imports/imports.run.service.ts @@ -1,18 +1,14 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'node:crypto'; -import { Readable } from 'node:stream'; import { Repository } from 'typeorm'; -import * as ExcelJS from 'exceljs'; import { ImportRun } from './entities/import-run.entity'; import { ImportStep } from './entities/import-step.entity'; -import { - IMPORT_STEP_LABELS, - IMPORT_STEP_ORDER, -} from './imports.types'; +import { IMPORT_STEP_LABELS, IMPORT_STEP_ORDER } from './imports.types'; import type { CellValue, ColumnMapping, + ImportRunSettings, ImportRunSource, ImportStageRequest, ImportStepKey, @@ -20,8 +16,7 @@ import type { StepPreviewSummary, } from './imports.types'; import { parseJson } from './imports.helpers'; -import { extractSheets } from './imports.workbook'; -import type { ImportSheetData } from './imports.workbook'; +import { parseSheets } from './imports.workbook'; import { autoAssignedSheets, resolveAssignedSheets, suggestMapping, suggestStep } from './imports.mapping'; import { findOwnedRun } from './imports.access'; import type { ImportPrincipal } from './imports.access'; @@ -42,40 +37,12 @@ export class ImportRunService { conversationId?: number | null, stages?: ImportStageRequest[], mappingByStep?: Partial>, + settings?: ImportRunSettings, ) { if (!file.buffer || file.buffer.length === 0) { throw new BadRequestException('上传文件为空'); } - const isCsv = - /\.csv$/i.test(file.originalName) || - /csv/i.test(file.mimeType) || - /text\/(csv|plain)/i.test(file.mimeType); - const isXlsx = - /\.xlsx$/i.test(file.originalName) || - /spreadsheetml/i.test(file.mimeType) || - /excel/i.test(file.mimeType); - if (!isCsv && !isXlsx) { - throw new BadRequestException('仅支持 .xlsx / .csv 文件'); - } - if (/\.xls$/i.test(file.originalName) && !/\.xlsx$/i.test(file.originalName)) { - throw new BadRequestException('暂不支持 .xls,请另存为 .xlsx 或 .csv 后重试'); - } - - let sheets: ImportSheetData[]; - try { - const workbook = new ExcelJS.Workbook(); - if (isCsv) { - await workbook.csv.read(Readable.from(Buffer.from(file.buffer))); - } else { - await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer); - } - sheets = extractSheets(workbook); - } catch { - throw new BadRequestException('Excel 文件解析失败,请检查文件格式'); - } - if (!sheets.length) { - throw new BadRequestException('文件中没有可用的工作表数据'); - } + const sheets = await parseSheets(file.buffer, file.originalName, file.mimeType); const runId = randomUUID(); const run = this.runs.create({ @@ -85,6 +52,7 @@ export class ImportRunService { source, fileName: file.originalName.slice(0, 255), sheetsJson: JSON.stringify(sheets), + settingsJson: settings ? JSON.stringify(settings) : null, status: 'ready', currentStepKey: null, error: null, @@ -155,6 +123,7 @@ export class ImportRunService { rowCount: sheet.rows.length, suggestedStepKey: suggestStep(sheet.headers)?.stepKey ?? null, })), + settings: parseJson(run.settingsJson) ?? {}, steps: stepRecords.map((step) => ({ id: step.id, stepKey: step.stepKey, diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index bdf9ba9..ea26275 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -603,4 +603,211 @@ describe('ImportsService', () => { expect(result.rows[1].status).toBe('error'); expect(result.rows[1].errors.join(';')).toContain('请勿重复换宿'); }); + + it('预览学生阶段:updateExisting=false 时已匹配行改为跳过', async () => { + const existing = { + id: 88, + name: '张三', + studentNo: '2024001', + phone: '13800138000', + } as Student; + const run = { + id: 'run-1', + userId: 7, + source: 'ai', + fileName: 'students.xlsx', + sheetsJson: JSON.stringify([studentSheet()]), + settingsJson: JSON.stringify({ updateExisting: false }), + status: 'ready', + currentStepKey: 'students', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const step = { + id: 1, + runId: 'run-1', + stepKey: 'students', + sheetsJson: '["学生"]', + mappingJson: null, + status: 'pending', + } as ImportStep; + const rowsRepo = makeRowsRepo(); + const dataSource = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) }; + if (entity === Room) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; + return { find: jest.fn().mockResolvedValue([]) }; + }), + }; + const service = new ImportsService( + makeRunsRepo(run) as never, + makeStepsRepo(step) as never, + rowsRepo as never, + dataSource as never, + ); + + const result = await service.previewStep(principal, 'run-1', 'students', { + sheets: ['学生'], + mapping: { name: '姓名', studentNo: '学号', phone: '手机号' }, + }); + expect(result.summary).toMatchObject({ + total: 1, + valid: 1, + create: 0, + update: 0, + skip: 1, + error: 0, + }); + expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' }); + expect(result.rows[0].errors.join(';')).toContain('按策略跳过更新'); + }); + + it('预览入住阶段:duplicatePolicy=skip 时文件内重复行跳过并保留提示', async () => { + const existing = { + id: 88, + name: '张三', + studentNo: '2024001', + phone: '13800138000', + } as Student; + const room = { id: 5, roomNumber: 'A101' } as Room; + const run = { + id: 'run-2', + userId: 7, + source: 'ai', + fileName: 'checkins.xlsx', + sheetsJson: JSON.stringify([ + { + name: '入住', + headers: ['姓名', '手机号', '宿舍号', '入住日期'], + rows: [ + ['张三', '13800138000', 'A101', '2026-09-01'], + ['张三', '13800138000', 'A101', '2026-09-02'], + ], + }, + ]), + settingsJson: JSON.stringify({ duplicatePolicy: 'skip' }), + status: 'ready', + currentStepKey: 'checkins', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const step = { + id: 3, + runId: 'run-2', + stepKey: 'checkins', + sheetsJson: '["入住"]', + mappingJson: null, + status: 'pending', + } as ImportStep; + const rowsRepo = makeRowsRepo(); + const dataSource = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) }; + if (entity === Room) return { find: jest.fn().mockResolvedValue([room]) }; + if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; + return { find: jest.fn().mockResolvedValue([]) }; + }), + }; + const service = new ImportsService( + makeRunsRepo(run) as never, + makeStepsRepo(step) as never, + rowsRepo as never, + dataSource as never, + ); + + const result = await service.previewStep(principal, 'run-2', 'checkins', { + sheets: ['入住'], + mapping: { + name: '姓名', + phone: '手机号', + roomNumber: '宿舍号', + checkInDate: '入住日期', + }, + }); + expect(result.summary).toMatchObject({ + total: 2, + valid: 2, + error: 0, + create: 1, + skip: 1, + }); + expect(result.rows[1]).toMatchObject({ action: 'skip', status: 'valid' }); + expect(result.rows[1].errors.join(';')).toContain('已按策略跳过'); + }); + + it('预览入住阶段:skipUnmatched=true 时找不到宿舍的行跳过并保留提示', async () => { + const existing = { + id: 88, + name: '张三', + studentNo: '2024001', + phone: '13800138000', + } as Student; + const run = { + id: 'run-2', + userId: 7, + source: 'ai', + fileName: 'checkins.xlsx', + sheetsJson: JSON.stringify([ + { + name: '入住', + headers: ['姓名', '手机号', '宿舍号', '入住日期'], + rows: [['张三', '13800138000', 'A101', '2026-09-01']], + }, + ]), + settingsJson: JSON.stringify({ skipUnmatched: true }), + status: 'ready', + currentStepKey: 'checkins', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const step = { + id: 3, + runId: 'run-2', + stepKey: 'checkins', + sheetsJson: '["入住"]', + mappingJson: null, + status: 'pending', + } as ImportStep; + const rowsRepo = makeRowsRepo(); + const dataSource = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Student) return { find: jest.fn().mockResolvedValue([existing]) }; + if (entity === Room) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Organization) return { find: jest.fn().mockResolvedValue([]) }; + if (entity === Occupancy) return { find: jest.fn().mockResolvedValue([]) }; + return { find: jest.fn().mockResolvedValue([]) }; + }), + }; + const service = new ImportsService( + makeRunsRepo(run) as never, + makeStepsRepo(step) as never, + rowsRepo as never, + dataSource as never, + ); + + const result = await service.previewStep(principal, 'run-2', 'checkins', { + sheets: ['入住'], + mapping: { + name: '姓名', + phone: '手机号', + roomNumber: '宿舍号', + checkInDate: '入住日期', + }, + }); + expect(result.summary).toMatchObject({ + total: 1, + valid: 1, + error: 0, + create: 0, + skip: 1, + }); + expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' }); + expect(result.rows[0].errors.join(';')).toContain('按策略跳过'); + }); }); diff --git a/apps/server/src/imports/imports.service.ts b/apps/server/src/imports/imports.service.ts index e50ac90..89ca550 100644 --- a/apps/server/src/imports/imports.service.ts +++ b/apps/server/src/imports/imports.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { ImportRun } from './entities/import-run.entity'; @@ -7,11 +7,16 @@ import { ImportRow } from './entities/import-row.entity'; import { ImportRunService } from './imports.run.service'; import { ImportPreviewService } from './imports.preview.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 { ImportSheetMeta, ImportStepDetail, ImportRunDetail, + ImportRunSettings, + PreflightReport, StepPreviewResult, } from './imports.types'; @@ -66,6 +71,15 @@ export class ImportsService { return this.runsSvc.createRun(...args); } + /** 上传后的只读预检:解析文件并生成可插入性报告,不写库。 */ + async preflightFile(file: ParsedImportFile): Promise { + if (!file.buffer || file.buffer.length === 0) { + throw new BadRequestException('上传文件为空'); + } + const sheets = await parseSheets(file.buffer, file.originalName, file.mimeType); + return buildPreflightReport(this.dataSource, sheets); + } + async getRun(...args: Parameters) { return this.runsSvc.getRun(...args); } diff --git a/apps/server/src/imports/imports.types.ts b/apps/server/src/imports/imports.types.ts index 8eeee84..eeb38cb 100644 --- a/apps/server/src/imports/imports.types.ts +++ b/apps/server/src/imports/imports.types.ts @@ -130,6 +130,101 @@ export interface ImportRunDetail { createdAt: string; sheets: ImportSheetMeta[]; steps: ImportStepDetail[]; + settings: ImportRunSettings; +} + +/** 用户确认后写入导入任务的策略与映射。 */ +export interface ImportRunSettings { + /** 按阶段字段 -> 表头 的确认映射。 */ + mapping?: Partial>; + /** 学生校区归属(在检测到校区问题时由用户确认)。 */ + organization?: string | null; + /** 已匹配记录是否更新;默认 true。 */ + updateExisting?: boolean; + /** 文件内重复行策略:error 报错 / skip 跳过;默认 error。 */ + duplicatePolicy?: 'error' | 'skip'; + /** 关系表找不到学生/宿舍时是否跳过;默认 false。 */ + skipUnmatched?: boolean; +} + +export type PreflightVerdict = 'ready' | 'needs_input' | 'blocked'; + +export interface PreflightStageStat { + stepKey: ImportStepKey; + label: string; + sheetNames: 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[]; } export interface StepPreviewResult { diff --git a/apps/server/src/imports/imports.workbook.ts b/apps/server/src/imports/imports.workbook.ts index 9a5ac41..f633427 100644 --- a/apps/server/src/imports/imports.workbook.ts +++ b/apps/server/src/imports/imports.workbook.ts @@ -1,4 +1,6 @@ +import { BadRequestException } from '@nestjs/common'; import * as ExcelJS from 'exceljs'; +import { Readable } from 'node:stream'; import { cellValue, textValue } from './imports.helpers'; import type { CellValue } from './imports.types'; @@ -37,3 +39,50 @@ export function extractSheets(workbook: ExcelJS.Workbook): ImportSheetData[] { } return sheets; } + +export type WorkbookKind = 'csv' | 'xlsx'; + +export function detectWorkbookKind(originalName: string, mimeType: string): WorkbookKind | null { + const isCsv = + /\.csv$/i.test(originalName) || + /csv/i.test(mimeType) || + /text\/(csv|plain)/i.test(mimeType); + const isXlsx = + /\.xlsx$/i.test(originalName) || + /spreadsheetml/i.test(mimeType) || + /excel/i.test(mimeType); + if (isCsv) return 'csv'; + if (isXlsx) return 'xlsx'; + return null; +} + +/** 校验文件类型并解析为工作表数据;解析失败抛出可读错误。 */ +export async function parseSheets( + buffer: Buffer, + originalName: string, + mimeType: string, +): Promise { + const kind = detectWorkbookKind(originalName, mimeType); + if (!kind) { + throw new BadRequestException('仅支持 .xlsx / .csv 文件'); + } + if (/\.xls$/i.test(originalName) && !/\.xlsx$/i.test(originalName)) { + throw new BadRequestException('暂不支持 .xls,请另存为 .xlsx 或 .csv 后重试'); + } + try { + const workbook = new ExcelJS.Workbook(); + if (kind === 'csv') { + await workbook.csv.read(Readable.from(Buffer.from(buffer))); + } else { + await workbook.xlsx.load(buffer.buffer as ArrayBuffer); + } + const sheets = extractSheets(workbook); + if (sheets.length === 0) { + throw new BadRequestException('文件中没有可用的工作表数据'); + } + return sheets; + } catch (error) { + if (error instanceof BadRequestException) throw error; + throw new BadRequestException('Excel 文件解析失败,请检查文件格式'); + } +} diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts index f59f649..ad624ab 100644 --- a/apps/server/src/migration-runner.ts +++ b/apps/server/src/migration-runner.ts @@ -10,6 +10,7 @@ import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiR import { EnlargeAiReviewSections1784900000000 } from './migrations/1784900000000-EnlargeAiReviewSections'; import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns'; import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback'; +import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings'; import { config } from 'dotenv'; config(); @@ -35,6 +36,7 @@ export async function runMigrationsOnStartup(): Promise { EnlargeAiReviewSections1784900000000, AddImportRuns1784910000000, DropAiMessageFeedback1784920000000, + AddImportRunSettings1784930000000, ], }); diff --git a/apps/server/src/migrations/1784930000000-AddImportRunSettings.ts b/apps/server/src/migrations/1784930000000-AddImportRunSettings.ts new file mode 100644 index 0000000..c3ad701 --- /dev/null +++ b/apps/server/src/migrations/1784930000000-AddImportRunSettings.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * import_runs 增加 settings_json:保存 AI 预检确认后的 + * 列映射、校区归属、更新/重复/未匹配策略。 + */ +export class AddImportRunSettings1784930000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn('import_runs', 'settings_json'))) { + await queryRunner.query( + 'ALTER TABLE import_runs ADD COLUMN settings_json text NULL', + ); + } + } + + async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn('import_runs', 'settings_json')) { + await queryRunner.query('ALTER TABLE import_runs DROP COLUMN settings_json'); + } + } +} From 1a1e90c72f393ef8aa5e73043462017d32b7140a Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 21:14:08 +0800 Subject: [PATCH 02/20] =?UTF-8?q?fix(admin):=20=E8=A1=A5=E5=85=A8=20React?= =?UTF-8?q?=20hooks=20=E4=BE=9D=E8=B5=96=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 各页面 useCallback/useMemo 依赖补全(modal/mutation/setter 等),避免闭包过期 - ECharts 使用 optionRef、MainLayout 缓存菜单转换函数、main.tsx 增加挂载点校验 - 学生同步结果改用 recordsCount 展示 --- .../AiChat/useAiChatMessageActions.tsx | 2 +- apps/admin/src/components/ECharts.tsx | 4 +- apps/admin/src/hooks/useViewSensitive.ts | 2 +- apps/admin/src/layouts/MainLayout.tsx | 35 ++-- apps/admin/src/main.tsx | 5 +- apps/admin/src/pages/AiConfig/index.tsx | 4 +- apps/admin/src/pages/Attendance/admin.tsx | 7 +- apps/admin/src/pages/Bills/index.tsx | 119 +++++++------- apps/admin/src/pages/Classes/index.tsx | 85 +++++----- .../src/pages/ClassroomRentals/index.tsx | 2 +- .../src/pages/ClassroomSchedule/index.tsx | 2 +- apps/admin/src/pages/Classrooms/index.tsx | 99 ++++++----- apps/admin/src/pages/Dashboard/index.tsx | 3 +- apps/admin/src/pages/Occupancies/index.tsx | 37 +++-- apps/admin/src/pages/OperationLogs/index.tsx | 4 +- apps/admin/src/pages/Roles/index.tsx | 36 ++-- apps/admin/src/pages/RoomVisual/index.tsx | 4 +- apps/admin/src/pages/StudentProfile/index.tsx | 6 +- apps/admin/src/pages/Students/index.tsx | 154 ++++++++++-------- apps/admin/src/pages/Users/index.tsx | 126 ++++++++------ apps/admin/src/pages/Wallets/index.tsx | 44 ++--- 21 files changed, 439 insertions(+), 341 deletions(-) diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index 274633c..552cb4c 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -271,7 +271,7 @@ export function useAiChatMessageActions({ }, }); }, - [activeId, isRequesting, refreshConversations, removeMessage], + [activeId, isRequesting, refreshConversations, removeMessage, modal], ); const confirmEditMessage = useCallback( diff --git a/apps/admin/src/components/ECharts.tsx b/apps/admin/src/components/ECharts.tsx index 047acca..ab34151 100644 --- a/apps/admin/src/components/ECharts.tsx +++ b/apps/admin/src/components/ECharts.tsx @@ -50,13 +50,15 @@ interface EChartsProps { const ECharts: React.FC = ({ option, style, className, onReady }) => { const containerRef = useRef(null); + const optionRef = useRef(option); + optionRef.current = option; const onReadyRef = useRef(onReady); onReadyRef.current = onReady; useEffect(() => { if (!containerRef.current) return; const chart = echarts.init(containerRef.current); - chart.setOption(option); + chart.setOption(optionRef.current); onReadyRef.current?.(chart); const observer = new ResizeObserver(() => chart.resize()); observer.observe(containerRef.current); diff --git a/apps/admin/src/hooks/useViewSensitive.ts b/apps/admin/src/hooks/useViewSensitive.ts index 856f9e2..d1c2dfc 100644 --- a/apps/admin/src/hooks/useViewSensitive.ts +++ b/apps/admin/src/hooks/useViewSensitive.ts @@ -62,6 +62,6 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool }, }); }, - [studentId, module], + [studentId, module, modal], ); } diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 1d27a1d..89eff12 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -169,19 +169,22 @@ const MainLayout: React.FC = () => { navigate(key); if (usesDrawer) setDrawerOpen(false); }, - [navigate, usesDrawer], + [navigate, usesDrawer, setDrawerOpen], ); - const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => { - for (const item of items) { - if (item.key === pathname) return [item.key]; - if (item.children) { - const found = findSelectedKeys(item.children, pathname); - if (found.length > 0) return found; + const findSelectedKeys = useCallback( + (items: AppMenuItem[], pathname: string): string[] => { + for (const item of items) { + if (item.key === pathname) return [item.key]; + if (item.children) { + const found = findSelectedKeys(item.children, pathname); + if (found.length > 0) return found; + } } - } - return [pathname]; - }; + return [pathname]; + }, + [], + ); const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => { for (const item of items) { @@ -201,7 +204,7 @@ const MainLayout: React.FC = () => { const selectedKeys = useMemo( () => findSelectedKeys(menuItems, location.pathname), - [menuItems, location.pathname], + [menuItems, location.pathname, findSelectedKeys], ); // 路径变化时同步展开的菜单(不干扰用户手动展开/收起) useEffect(() => { @@ -210,20 +213,20 @@ const MainLayout: React.FC = () => { const routeOpenKeys = findOpenKeys(menuItems, location.pathname); setOpenKeys((currentKeys) => [...new Set([...currentKeys, ...routeOpenKeys])]); } - }, [location.pathname, menuItems]); + }, [location.pathname, menuItems, setOpenKeys]); const handleOpenChange = useCallback((keys: string[]) => { setOpenKeys(keys); - }, []); + }, [setOpenKeys]); - const transformToMenuItems = (items: AppMenuItem[]): any[] => { + const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => { return items.map((item) => ({ key: item.key, icon: item.icon ? iconMap[item.icon] : undefined, label: item.label, children: item.children ? transformToMenuItems(item.children) : undefined, })); - }; + }, []); const menuContent = useMemo( () => ( { style={{ border: 'none' }} /> ), - [selectedKeys, openKeys, menuItems, handleMenuClick], + [selectedKeys, openKeys, menuItems, handleMenuClick, handleOpenChange, transformToMenuItems], ); return ( diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index 7d05a2d..9a36429 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -37,7 +37,10 @@ const queryClient = new QueryClient({ }, }); -ReactDOM.createRoot(document.getElementById('root')!).render( +const rootElement = document.getElementById('root'); +if (!rootElement) throw new Error('未找到 #root 挂载点'); + +ReactDOM.createRoot(rootElement).render( diff --git a/apps/admin/src/pages/AiConfig/index.tsx b/apps/admin/src/pages/AiConfig/index.tsx index 2347cc9..325b649 100644 --- a/apps/admin/src/pages/AiConfig/index.tsx +++ b/apps/admin/src/pages/AiConfig/index.tsx @@ -276,7 +276,7 @@ const AiConfigPage: React.FC = () => { } finally { setTesting(false); } - }, [formValues, form, currentProvider]); + }, [formValues, form, currentProvider, refreshConfig]); // ── Clear key ── @@ -299,7 +299,7 @@ const AiConfigPage: React.FC = () => { } }, }); - }, [config, modal]); + }, [config, modal, clearKeyMutation]); // ── Step navigation ── diff --git a/apps/admin/src/pages/Attendance/admin.tsx b/apps/admin/src/pages/Attendance/admin.tsx index 1960c35..c5dc68f 100644 --- a/apps/admin/src/pages/Attendance/admin.tsx +++ b/apps/admin/src/pages/Attendance/admin.tsx @@ -115,10 +115,11 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit enabled: !!classId && !!attendanceDate, queryFn: async () => { try { + if (!attendanceDate) return []; return validateResponse( attendanceScheduleOptionsSchema, await api.get('/attendance-records/schedules', { - params: { classId, date: attendanceDate!.format('YYYY-MM-DD') }, + params: { classId, date: attendanceDate.format('YYYY-MM-DD') }, }), ); } catch (error: unknown) { @@ -214,7 +215,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit if (effectiveScheduleId) params.scheduleId = effectiveScheduleId; return params; }, - [page, pageSize, classId, attendanceDate, status, session, scheduleId], + [page, pageSize, classId, attendanceDate, status, session, effectiveScheduleId], ); const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery({ @@ -334,7 +335,7 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit } finally { setRefreshingDingTalk(false); } - }, [attendanceDate, classId, loadRecords, loadSyncStatus, session]); + }, [attendanceDate, classId, loadRecords, loadSyncStatus, session, refreshDingTalkMutation]); const resetFilters = () => { setClassId(undefined); diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index 24106d8..608c861 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { App, Table, @@ -139,7 +139,7 @@ const BillsPage: React.FC = () => { } }; - const showDetail = async (id: number) => { + const showDetail = useCallback(async (id: number) => { setDetailLoading(true); try { const res = await api.get(`/bills/${id}`); @@ -149,60 +149,69 @@ const BillsPage: React.FC = () => { } finally { setDetailLoading(false); } - }; + }, []); - const handleCancel = async (id: number) => { - let reason = ''; - modal.confirm({ - title: '取消账单并退回已扣余额', - content: ( - { - reason = event.target.value; - }} - /> - ), - okText: '确认取消', - cancelText: '返回', - onOk: async () => { - if (!reason.trim()) { - message.error('请输入取消原因'); - throw new Error('reason required'); - } - await cancelMutation.mutateAsync({ id, reason: reason.trim() }); - message.success('账单已取消,已扣余额已冲正退回'); - }, - }); - }; + const handleCancel = useCallback( + (id: number) => { + let reason = ''; + modal.confirm({ + title: '取消账单并退回已扣余额', + content: ( + { + reason = event.target.value; + }} + /> + ), + okText: '确认取消', + cancelText: '返回', + onOk: async () => { + if (!reason.trim()) { + message.error('请输入取消原因'); + throw new Error('reason required'); + } + await cancelMutation.mutateAsync({ id, reason: reason.trim() }); + message.success('账单已取消,已扣余额已冲正退回'); + }, + }); + }, + [modal, cancelMutation], + ); - const handleArchive = async (id: number) => { - try { - await archiveMutation.mutateAsync(id); - message.success('账单已归档'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; + const handleArchive = useCallback( + async (id: number) => { + try { + await archiveMutation.mutateAsync(id); + message.success('账单已归档'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [archiveMutation], + ); - const handlePurge = (id: number, studentName: string, period: string) => { - modal.confirm({ - title: `永久删除账单(${studentName} ${period})?`, - content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?', - okText: '永久删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await purgeMutation.mutateAsync(id); - message.success('已永久删除(不可恢复)'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }, - }); - }; + const handlePurge = useCallback( + (id: number, studentName: string, period: string) => { + modal.confirm({ + title: `永久删除账单(${studentName} ${period})?`, + content: '删除后不可恢复,该账单及其明细将被物理删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }, + [modal, purgeMutation], + ); const batchArchive = async () => { if (selectedRows.length === 0) return message.warning('请先选择账单'); @@ -226,7 +235,7 @@ const BillsPage: React.FC = () => { ); }; - const handleExportPdf = async (billId: number) => { + const handleExportPdf = useCallback(async (billId: number) => { const printWindow = window.open('', '_blank'); if (!printWindow) { message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试'); @@ -245,7 +254,7 @@ const BillsPage: React.FC = () => { printWindow.close(); message.error(error?.message || '账单加载失败'); } - }; + }, []); const columns = useMemo( () => [ diff --git a/apps/admin/src/pages/Classes/index.tsx b/apps/admin/src/pages/Classes/index.tsx index e12af51..460ca94 100644 --- a/apps/admin/src/pages/Classes/index.tsx +++ b/apps/admin/src/pages/Classes/index.tsx @@ -89,33 +89,6 @@ const ClassesPage: React.FC = () => { const [saving, setSaving] = useState(false); const [showArchived, setShowArchived] = useState(false); - const handleArchive = async (id: number, archive: boolean) => { - try { - await archiveMutation.mutateAsync({ id, archive }); - message.success(archive ? '已归档' : '已恢复'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; - - const handlePurge = (record: ClassItem) => { - modal.confirm({ - title: `永久删除班级「${record.name}」?`, - content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?', - okText: '永久删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await purgeMutation.mutateAsync(record.id); - message.success('已永久删除(不可恢复)'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }, - }); - }; - const { data = [], isLoading, @@ -160,6 +133,39 @@ const ClassesPage: React.FC = () => { { invalidate: [['classes']] }, ); + const handleArchive = useCallback( + async (id: number, archive: boolean) => { + try { + await archiveMutation.mutateAsync({ id, archive }); + message.success(archive ? '已归档' : '已恢复'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [archiveMutation], + ); + + const handlePurge = useCallback( + (record: ClassItem) => { + modal.confirm({ + title: `永久删除班级「${record.name}」?`, + content: '删除后不可恢复,存在学生、教师、排课、考试或考勤关联时将无法删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }, + [modal, purgeMutation], + ); + const filtered = useMemo(() => { if (!searchText) return data; const q = searchText.toLowerCase(); @@ -174,16 +180,19 @@ const ClassesPage: React.FC = () => { setModalOpen(true); }; - const handleEdit = (record: ClassItem) => { - setEditing(record); - form.setFieldsValue({ - ...record, - notes: record.notes ?? undefined, - startDate: record.startDate ? dayjs(record.startDate) : undefined, - endDate: record.endDate ? dayjs(record.endDate) : undefined, - }); - setModalOpen(true); - }; + const handleEdit = useCallback( + (record: ClassItem) => { + setEditing(record); + form.setFieldsValue({ + ...record, + notes: record.notes ?? undefined, + startDate: record.startDate ? dayjs(record.startDate) : undefined, + endDate: record.endDate ? dayjs(record.endDate) : undefined, + }); + setModalOpen(true); + }, + [form], + ); const handleSubmit = async () => { setSaving(true); @@ -369,7 +378,7 @@ const ClassesPage: React.FC = () => { ), }, ], - [saveCell, canPurgeClass, handlePurge], + [saveCell, canPurgeClass, handlePurge, navigate, handleEdit, handleArchive], ); return ( diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index d89d498..d2d5bc4 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -198,7 +198,7 @@ const ClassroomRentalsPage: React.FC = () => { } } }, - [], + [setUnavailableDates], ); const handleClassroomChange = (classroomId: number) => { diff --git a/apps/admin/src/pages/ClassroomSchedule/index.tsx b/apps/admin/src/pages/ClassroomSchedule/index.tsx index fd8101c..f6c2d10 100644 --- a/apps/admin/src/pages/ClassroomSchedule/index.tsx +++ b/apps/admin/src/pages/ClassroomSchedule/index.tsx @@ -65,7 +65,7 @@ const ClassroomSchedulePage: React.FC = () => { for (const c of data.classrooms) { const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}层` : ''}`; if (!map.has(key)) map.set(key, []); - map.get(key)!.push(c); + map.get(key)?.push(c); } return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms })); }, [data]); diff --git a/apps/admin/src/pages/Classrooms/index.tsx b/apps/admin/src/pages/Classrooms/index.tsx index ecc4e4f..869b901 100644 --- a/apps/admin/src/pages/Classrooms/index.tsx +++ b/apps/admin/src/pages/Classrooms/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; @@ -147,50 +147,63 @@ const ClassroomsPage: React.FC = () => { } }; - const saveCell = async (record: any, field: string, value: unknown) => { - try { - await saveCellMutation.mutateAsync({ record, field, value }); - message.success('已保存'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; + const saveCell = useCallback( + async (record: any, field: string, value: unknown) => { + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [saveCellMutation], + ); - const handleArchive = async (id: number) => { - try { - await archiveMutation.mutateAsync(id); - message.success('已归档'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; + const handleArchive = useCallback( + async (id: number) => { + try { + await archiveMutation.mutateAsync(id); + message.success('已归档'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [archiveMutation], + ); - const handleRestore = async (id: number) => { - try { - await restoreMutation.mutateAsync(id); - message.success('已恢复'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; + const handleRestore = useCallback( + async (id: number) => { + try { + await restoreMutation.mutateAsync(id); + message.success('已恢复'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [restoreMutation], + ); - const handlePurge = (id: number, name: string) => { - modal.confirm({ - title: `永久删除教室「${name}」?`, - content: '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?', - okText: '永久删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await purgeMutation.mutateAsync(id); - message.success('已永久删除(不可恢复)'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }, - }); - }; + const handlePurge = useCallback( + (id: number, name: string) => { + modal.confirm({ + title: `永久删除教室「${name}」?`, + content: + '删除后不可恢复,该教室及其排课、租赁、考勤机关联将被清除(存在关联数据时将无法删除)。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }, + [modal, purgeMutation], + ); const handleDownloadTemplate = () => { const baseURL = import.meta.env.PROD @@ -397,7 +410,7 @@ const ClassroomsPage: React.FC = () => { ), }, ], - [handlePurge, hasPermission], + [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form], ); return ( diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index 055555c..db8f3c1 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -186,7 +186,8 @@ const DashboardPage: React.FC = () => { aria-label="选择日期范围" value={[dayjs(period[0]), dayjs(period[1])]} onChange={(dates) => { - if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]); + if (dates?.[0] && dates?.[1]) + setPeriod([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]); }} /> diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 5b5abc1..f4d1f64 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -369,23 +369,26 @@ const OccupanciesPage: React.FC = () => { } }; - const handlePurge = (id: number, studentName: string) => { - modal.confirm({ - title: `永久删除入住记录(${studentName})?`, - content: '删除后不可恢复,该入住记录将被物理删除。确定继续?', - okText: '永久删除', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - try { - await purgeMutation.mutateAsync(id); - message.success('已永久删除(不可恢复)'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }, - }); - }; + const handlePurge = useCallback( + (id: number, studentName: string) => { + modal.confirm({ + title: `永久删除入住记录(${studentName})?`, + content: '删除后不可恢复,该入住记录将被物理删除。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }, + [modal, purgeMutation], + ); const handleBatchPurge = async () => { if (batchLoading) return; diff --git a/apps/admin/src/pages/OperationLogs/index.tsx b/apps/admin/src/pages/OperationLogs/index.tsx index 60d83b2..55d7ea8 100644 --- a/apps/admin/src/pages/OperationLogs/index.tsx +++ b/apps/admin/src/pages/OperationLogs/index.tsx @@ -153,8 +153,8 @@ const OperationLogsPage: React.FC = () => { placeholder={['开始日期', '结束日期']} format="YYYY-MM-DD" onChange={(dates) => { - if (dates) - setDateRange([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]); + if (dates?.[0] && dates?.[1]) + setDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]); else setDateRange(null); setPage(1); }} diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index e3da544..aeaa261 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -92,12 +92,15 @@ const RolesPage: React.FC = () => { setModalOpen(true); }; - const handleEdit = (record: RoleItem) => { - setEditing(record); - form.setFieldsValue({ name: record.name, description: record.description }); - setSelectedPermIds(record.permissions.map((p) => p.id)); - setModalOpen(true); - }; + const handleEdit = useCallback( + (record: RoleItem) => { + setEditing(record); + form.setFieldsValue({ name: record.name, description: record.description }); + setSelectedPermIds(record.permissions.map((p) => p.id)); + setModalOpen(true); + }, + [form], + ); const handleSubmit = async () => { setSaving(true); @@ -117,14 +120,17 @@ const RolesPage: React.FC = () => { } }; - const handleDisable = async (id: number) => { - try { - await disableMutation.mutateAsync(id); - message.success('角色已停用'); - } catch { - // 错误提示由 useApiMutation 统一处理 - } - }; + const handleDisable = useCallback( + async (id: number) => { + try { + await disableMutation.mutateAsync(id); + message.success('角色已停用'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + [disableMutation], + ); const groupNames: Record = { dashboard: '数据面板', @@ -264,7 +270,7 @@ const RolesPage: React.FC = () => { ), }, ], - [permissionOptions, saveCell], + [permissionOptions, saveCell, handleEdit, handleDisable], ); const handleGroupCheckAll = (group: string, checked: boolean) => { diff --git a/apps/admin/src/pages/RoomVisual/index.tsx b/apps/admin/src/pages/RoomVisual/index.tsx index 122471f..fd0c375 100644 --- a/apps/admin/src/pages/RoomVisual/index.tsx +++ b/apps/admin/src/pages/RoomVisual/index.tsx @@ -102,7 +102,7 @@ const RoomVisualPage: React.FC = () => { queryKey: ['rooms', 'visual', isHistorical, asOf], queryFn: async () => { try { - const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined; + const params = asOf ? { asOf: asOf.format('YYYY-MM-DD') } : undefined; return await api.get('/rooms/visual', { params }); } catch (e: unknown) { message.error(getErrorMessage(e, '加载失败,请稍后重试')); @@ -229,7 +229,7 @@ const RoomVisualPage: React.FC = () => { showIcon icon={} style={{ marginBottom: 16 }} - title={`正在查看 ${asOf!.format('YYYY年M月D日')} 的历史入住情况(含当日已归档房间),非实时数据`} + title={`正在查看 ${asOf ? asOf.format('YYYY年M月D日') : ''} 的历史入住情况(含当日已归档房间),非实时数据`} action={ + + )} {preflight.nextSteps.length > 0 && ( diff --git a/apps/admin/src/components/AiChat/api.integration.test.ts b/apps/admin/src/components/AiChat/api.integration.test.ts index 94d113e..5fdeb30 100644 --- a/apps/admin/src/components/AiChat/api.integration.test.ts +++ b/apps/admin/src/components/AiChat/api.integration.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import api from '../../api'; -import { aiChatApi } from './api'; +import { + aiChatApi, + resolveImportPreflight, + type ImportPreflightResolveUpdate, +} from './api'; describe('AI chat API adapter', () => { afterEach(() => vi.restoreAllMocks()); @@ -71,4 +75,55 @@ describe('AI chat API adapter', () => { '/ai/chat/reviews/review-1/types/checkins/confirm', ); }); + + it('resolveImportPreflight 流式解析 SSE 事件并回调预检卡/向导更新', async () => { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + 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」不在工作表表头中'); + }); }); diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index f336abc..c1683b9 100644 --- a/apps/admin/src/components/AiChat/api.ts +++ b/apps/admin/src/components/AiChat/api.ts @@ -1,8 +1,11 @@ import api from '../../api'; +import { useUserStore } from '../../store/user/userStore'; import type { AiApiResponse, AiAttachment, AiConversation, + AiImportPreflight, + AiImportWizard, AiMessagePage, AiReviewSchema, AiReviewSection, @@ -83,3 +86,89 @@ export const aiChatApi = { export function conversationStreamUrl(id: number): string { return `/api${basePath}/${id}/stream`; } + +export interface ResolveImportPreflightInput { + mapping?: Record>; + 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 { + 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; + try { + data = JSON.parse(dataLine) as Record; + } 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 }); + } + } + } +} diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx index 5b67c9d..b7fab93 100644 --- a/apps/admin/src/components/AiChat/bubble.integration.test.tsx +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -7,6 +7,7 @@ import { AiMessageContent } from './AiMessageContent'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; +import type { ResolveImportPreflightInput } from './api'; import type { AiChatMessage, AiChartSchema, AiReviewSchema } from './types'; let container: HTMLDivElement | null = null; @@ -532,6 +533,108 @@ describe('AI chat bubble rendering', () => { expect(openedRunId).toBe('run-1'); }); + it('preflight card resolves mapping and settings inline', async () => { + let resolvedMessageId: number | undefined; + let resolvedInput: ResolveImportPreflightInput | undefined; + const message: AiChatMessage = { + id: 42, + role: 'assistant', + content: '请在预检卡内确认列映射与策略。', + reasoningContent: '', + toolRuns: [], + attachments: [], + metadata: { + a2uiImportPreflight: { + verdict: 'blocked', + stages: [ + { + stepKey: 'students', + label: '学生档案', + sheetNames: ['学生'], + headers: ['姓名', '学号', '手机号'], + total: 2, + create: 2, + update: 0, + error: 0, + skip: 0, + mapping: { name: '姓名', studentNo: '学号' }, + missingRequired: [], + }, + { + stepKey: 'rooms', + label: '宿舍档案', + sheetNames: ['宿舍'], + headers: ['宿舍号', '楼栋', '容量'], + total: 1, + create: 1, + update: 0, + error: 0, + skip: 0, + mapping: { roomNumber: '宿舍号' }, + missingRequired: ['容量'], + }, + ], + blocks: [ + { + code: 'missing_columns', + label: '缺少必填列', + stepKeys: ['rooms'], + message: '宿舍阶段缺少容量列', + count: 1, + }, + ], + questions: [ + { + key: 'update', + type: 'update', + label: '文件中有 1 行已匹配现有记录', + options: [ + { label: '更新已有记录', value: 'true' }, + { label: '跳过已有记录', value: 'false' }, + ], + default: true, + }, + ], + nextSteps: [], + permittedSteps: ['students', 'rooms'], + resolved: false, + runId: null, + }, + }, + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + { + resolvedMessageId = messageId; + resolvedInput = input; + }} + />, + ); + }); + + expect(container.textContent).toContain('容量'); + const resolveButton = Array.from(container.querySelectorAll('button')).find((item) => + item.textContent?.includes('生成导入向导'), + ) as HTMLButtonElement | undefined; + expect(resolveButton).toBeDefined(); + await act(async () => { + resolveButton?.click(); + }); + + expect(resolvedMessageId).toBe(42); + expect(resolvedInput?.mapping).toMatchObject({ + students: { name: '姓名', studentNo: '学号' }, + rooms: { roomNumber: '宿舍号' }, + }); + expect(resolvedInput?.settings).toMatchObject({ updateExisting: true }); + }); + it('renders model retrying hint while waiting for the upstream retry', async () => { const message: AiChatMessage = { role: 'assistant', diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index 8987091..ac2fc59 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -121,6 +121,8 @@ export interface AiImportPreflightStage { stepKey: AiReviewSectionType; label: string; sheetNames: string[]; + /** 该阶段所有工作表的表头并集,供预检卡列映射选择。 */ + headers?: string[]; total: number; create: number; update: number; @@ -161,6 +163,11 @@ export interface AiImportPreflight { blocks: AiImportPreflightBlock[]; questions: AiImportPreflightQuestion[]; nextSteps: AiImportPreflightNextStep[]; + attachmentId?: number; + headerRow?: number; + permittedSteps?: AiReviewSectionType[]; + resolved?: boolean; + runId?: string | null; errorSamples?: Array<{ code: string; stepKey: AiReviewSectionType; diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index 552cb4c..a6607f9 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -6,7 +6,7 @@ import { App } from 'antd'; import type { UploadFile, UploadProps } from 'antd'; import { message } from '../../ui/app-message'; import { useSettingsStore } from '../../store/settings/settingsStore'; -import { aiChatApi } from './api'; +import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api'; import { AiMessageContent } from './AiMessageContent'; import { mapHistoryMessage } from './message-mappers'; import { GongxueAiChatProvider } from './provider'; @@ -24,6 +24,7 @@ import type { AiChatMessage, AiChatMessageStatus, AiFormSchema, + AiImportPreflight, AiReviewSchema, AiReviewSection, AiReviewSectionType, @@ -409,6 +410,31 @@ export function useAiChatMessageActions({ [provider, setMessage], ); + const resolvePreflight = useCallback( + async ( + messageId: number | undefined, + _preflight: AiImportPreflight, + input: ResolveImportPreflightInput, + ): Promise => { + 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>(async (options) => { const file = options.file as File; if (attachmentsRef.current.length >= 5) { @@ -524,6 +550,7 @@ export function useAiChatMessageActions({ onConfirmReviewStep={confirmReviewStep} onConfirmReviewGroup={confirmReviewGroup} onOpenImportWizard={setImportWizardRunId} + onResolveImportPreflight={resolvePreflight} /> ), })), @@ -537,6 +564,7 @@ export function useAiChatMessageActions({ isRequesting, messages, reloadMessage, + resolvePreflight, setImportWizardRunId, submitForm, submitReview, From 7f3e30ba38c60eb756b129cabd813b1f1f44b2e0 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 11:59:43 +0800 Subject: [PATCH 10/20] =?UTF-8?q?fix(archive):=20=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E8=81=9A=E5=90=88=E6=8E=A5=E5=8F=A3=20null=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=BD=92=E4=B8=80=E5=8C=96=E4=B8=BA=E7=A9=BA=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免可选列缺省返回 null 导致前端响应校验报「字段格式异常」 --- .../src/archive/archive.service.spec.ts | 50 +++++++++++++++++++ apps/server/src/archive/archive.service.ts | 15 +++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/server/src/archive/archive.service.spec.ts b/apps/server/src/archive/archive.service.spec.ts index 09c3451..643a458 100644 --- a/apps/server/src/archive/archive.service.spec.ts +++ b/apps/server/src/archive/archive.service.spec.ts @@ -36,4 +36,54 @@ describe('ArchiveService.getProfile', () => { expect(response).toMatchObject({ student, result, attendances: [] }); expect(response).not.toHaveProperty('resultArchive'); }); + + it('将学生可空字符串字段归一化为空串,避免前端校验报格式异常', async () => { + const student = { + id: 7, + name: '测试学生', + phone: null, + idNumber: null, + studentNo: null, + gender: null, + ethnicity: null, + emergencyContact: null, + emergencyPhone: null, + supervisor: null, + }; + const emptyRepos = { + profile: { findOne: jest.fn().mockResolvedValue(null) }, + enrollment: { find: jest.fn().mockResolvedValue([]) }, + exam: { find: jest.fn().mockResolvedValue([]) }, + learning: { find: jest.fn().mockResolvedValue([]) }, + result: { findOne: jest.fn().mockResolvedValue(null) }, + attachment: { find: jest.fn().mockResolvedValue([]) }, + attendance: { find: jest.fn().mockResolvedValue([]) }, + }; + const service = new ArchiveService( + { findOne: jest.fn().mockResolvedValue(student) } as never, + emptyRepos.profile as never, + emptyRepos.enrollment as never, + emptyRepos.exam as never, + emptyRepos.learning as never, + emptyRepos.result as never, + emptyRepos.attachment as never, + emptyRepos.attendance as never, + {} as never, + ); + + const response = await service.getProfile(7); + + expect(response.student).toMatchObject({ + id: 7, + name: '测试学生', + phone: '', + idNumber: '', + studentNo: '', + gender: '', + ethnicity: '', + emergencyContact: '', + emergencyPhone: '', + supervisor: '', + }); + }); }); diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index ac210b1..4cf083f 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -63,6 +63,19 @@ export class ArchiveService { relations: ['organization'], }); if (!student) throw new NotFoundException('学生不存在'); + // 前端档案契约要求这些字段为字符串;可空列缺省时归一化为空串, + // 避免接口返回 null 导致响应校验报「student.idNumber 格式异常」。 + const normalizedStudent = { + ...student, + phone: student.phone ?? '', + idNumber: student.idNumber ?? '', + studentNo: student.studentNo ?? '', + gender: student.gender ?? '', + ethnicity: student.ethnicity ?? '', + emergencyContact: student.emergencyContact ?? '', + emergencyPhone: student.emergencyPhone ?? '', + supervisor: student.supervisor ?? '', + }; const [ profileRaw, @@ -91,7 +104,7 @@ export class ArchiveService { ]); return { - student, + student: normalizedStudent, profile: profileRaw, enrollments, examScores, From 7f09d5271e461d4efffadbd125ef3743d9045018 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 11:59:51 +0800 Subject: [PATCH 11/20] =?UTF-8?q?feat(imports):=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=90=91=E5=AF=BC=E5=85=BC=E5=AE=B9=E6=97=A7=E7=89=88=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E7=B0=BF=E5=B9=B6=E5=AE=8C=E5=96=84=E6=89=A7=E8=A1=8C?= =?UTF-8?q?/=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 xls 工作簿回退解析与测试 - 执行/预览/权限校验逻辑完善 --- .../server/src/imports/imports.access.spec.ts | 37 ++++ apps/server/src/imports/imports.access.ts | 10 +- .../src/imports/imports.controller.spec.ts | 14 ++ apps/server/src/imports/imports.controller.ts | 2 +- apps/server/src/imports/imports.helpers.ts | 5 +- .../src/imports/imports.preflight.spec.ts | 1 + apps/server/src/imports/imports.preflight.ts | 3 +- .../src/imports/imports.preview.service.ts | 13 +- .../server/src/imports/imports.run.service.ts | 56 +++++- .../src/imports/imports.service.spec.ts | 98 +++++++++++ apps/server/src/imports/imports.service.ts | 7 +- apps/server/src/imports/imports.types.ts | 8 + .../src/imports/imports.workbook-fallback.ts | 160 ++++++++++++++++++ .../src/imports/imports.workbook.spec.ts | 104 ++++++++++++ apps/server/src/imports/imports.workbook.ts | 31 +++- 15 files changed, 532 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/imports/imports.access.spec.ts create mode 100644 apps/server/src/imports/imports.workbook-fallback.ts create mode 100644 apps/server/src/imports/imports.workbook.spec.ts diff --git a/apps/server/src/imports/imports.access.spec.ts b/apps/server/src/imports/imports.access.spec.ts new file mode 100644 index 0000000..33d1b1f --- /dev/null +++ b/apps/server/src/imports/imports.access.spec.ts @@ -0,0 +1,37 @@ +import { ForbiddenException } from '@nestjs/common'; +import { assertStepPermission, permittedStepKeys } from './imports.access'; + +describe('permittedStepKeys', () => { + it('超级管理员拥有全部导入阶段', () => { + expect( + permittedStepKeys({ id: 1, permissions: [], isSuperAdmin: true }), + ).toEqual(['students', 'rooms', 'checkins', 'transfers']); + }); + + it('按细分权限计算可提交阶段', () => { + expect( + permittedStepKeys({ + id: 1, + permissions: ['student:import', 'room:edit', 'occupancy:checkin'], + isSuperAdmin: false, + }), + ).toEqual(['students', 'rooms', 'checkins']); + }); + + it('无导入权限时仅返回空数组', () => { + expect( + permittedStepKeys({ id: 1, permissions: ['ai:chat:use'], isSuperAdmin: false }), + ).toEqual([]); + }); +}); + +describe('assertStepPermission', () => { + it('无对应细分权限时拒绝提交', () => { + expect(() => + assertStepPermission( + { id: 1, permissions: ['ai:chat:use'], isSuperAdmin: false }, + 'transfers', + ), + ).toThrow(ForbiddenException); + }); +}); diff --git a/apps/server/src/imports/imports.access.ts b/apps/server/src/imports/imports.access.ts index 0d77123..5118164 100644 --- a/apps/server/src/imports/imports.access.ts +++ b/apps/server/src/imports/imports.access.ts @@ -2,7 +2,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { ImportRun } from './entities/import-run.entity'; import { ImportStep } from './entities/import-step.entity'; -import { IMPORT_STEP_LABELS } from './imports.types'; +import { IMPORT_STEP_LABELS, IMPORT_STEP_ORDER } from './imports.types'; import type { ImportStepKey } from './imports.types'; export interface ImportPrincipal { @@ -45,3 +45,11 @@ export function assertStepPermission(principal: ImportPrincipal, stepKey: Import ); } } + +/** Step keys the current principal may actually commit. */ +export function permittedStepKeys(principal: ImportPrincipal): ImportStepKey[] { + if (principal.isSuperAdmin) return [...IMPORT_STEP_ORDER]; + return IMPORT_STEP_ORDER.filter((stepKey) => + STEP_PERMISSIONS[stepKey].some((code) => principal.permissions.includes(code)), + ); +} diff --git a/apps/server/src/imports/imports.controller.spec.ts b/apps/server/src/imports/imports.controller.spec.ts index baf1ccc..521d332 100644 --- a/apps/server/src/imports/imports.controller.spec.ts +++ b/apps/server/src/imports/imports.controller.spec.ts @@ -7,6 +7,20 @@ describe('ImportsController', () => { connection: { remoteAddress: '127.0.0.1' }, }; + it('路由门槛允许 AI 用户打开本人 run,同时保留原有导入权限', () => { + const permissions = Reflect.getMetadata('permissions', ImportsController) as string[]; + expect(permissions).toContain('ai:chat:use'); + expect(permissions).toEqual( + expect.arrayContaining([ + 'student:import', + 'room:create', + 'room:edit', + 'occupancy:checkin', + 'occupancy:transfer', + ]), + ); + }); + it('提交导入阶段成功后写入操作日志', async () => { const commitStep = jest.fn().mockResolvedValue({ runId: 'run-1', diff --git a/apps/server/src/imports/imports.controller.ts b/apps/server/src/imports/imports.controller.ts index 75110a0..c4e1024 100644 --- a/apps/server/src/imports/imports.controller.ts +++ b/apps/server/src/imports/imports.controller.ts @@ -38,7 +38,7 @@ const IMPORT_GATE_PERMISSIONS = [ ] as const; @Controller('imports') -@RequirePermission(...IMPORT_GATE_PERMISSIONS) +@RequirePermission('ai:chat:use', ...IMPORT_GATE_PERMISSIONS) export class ImportsController { constructor( private readonly importsService: ImportsService, diff --git a/apps/server/src/imports/imports.helpers.ts b/apps/server/src/imports/imports.helpers.ts index beeb9b6..0bec486 100644 --- a/apps/server/src/imports/imports.helpers.ts +++ b/apps/server/src/imports/imports.helpers.ts @@ -50,7 +50,10 @@ export function cellValue(cell: ExcelJS.Cell | undefined): CellValue { export function parseDateValue(value: CellValue): string | null { if (value instanceof Date && !Number.isNaN(value.getTime())) { - return value.toISOString().slice(0, 10); + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; } const raw = textValue(value); if (!raw) return null; diff --git a/apps/server/src/imports/imports.preflight.spec.ts b/apps/server/src/imports/imports.preflight.spec.ts index 9345fd8..cd4e218 100644 --- a/apps/server/src/imports/imports.preflight.spec.ts +++ b/apps/server/src/imports/imports.preflight.spec.ts @@ -52,6 +52,7 @@ describe('buildPreflightReport', () => { update: 0, error: 0, skip: 0, + headers: ['姓名', '学号', '手机号'], mapping: { name: '姓名', studentNo: '学号', phone: '手机号' }, }); expect(report.blocks).toEqual([]); diff --git a/apps/server/src/imports/imports.preflight.ts b/apps/server/src/imports/imports.preflight.ts index a9884dc..6cc586d 100644 --- a/apps/server/src/imports/imports.preflight.ts +++ b/apps/server/src/imports/imports.preflight.ts @@ -183,7 +183,7 @@ async function analyzeStage( code, stepKey, sheet: sheet.name, - rowNumber: i + 2, + rowNumber: sheet.rowNumbers?.[i] ?? (sheet.headerRow ?? 1) + i + 1, errors: result.errors, }); } @@ -209,6 +209,7 @@ async function analyzeStage( stepKey, label: IMPORT_STEP_LABELS[stepKey], sheetNames: sheets.map((sheet) => sheet.name), + headers: [...new Set(sheets.flatMap((sheet) => sheet.headers))], total, create, update, diff --git a/apps/server/src/imports/imports.preview.service.ts b/apps/server/src/imports/imports.preview.service.ts index ef9ba19..76ad5fe 100644 --- a/apps/server/src/imports/imports.preview.service.ts +++ b/apps/server/src/imports/imports.preview.service.ts @@ -55,8 +55,15 @@ export class ImportPreviewService { } const sheetsData = - parseJson>(run.sheetsJson) ?? - []; + parseJson< + Array<{ + name: string; + headers: string[]; + rows: CellValue[][]; + headerRow?: number; + rowNumbers?: number[]; + }> + >(run.sheetsJson) ?? []; const settings = parseJson(run.settingsJson) ?? {}; const sheetNames = body.sheets?.length ? body.sheets @@ -132,7 +139,7 @@ export class ImportPreviewService { runId, stepId: step.id, sheetName, - rowNumber: i + 2, + rowNumber: sheet.rowNumbers?.[i] ?? (sheet.headerRow ?? 1) + i + 1, rawJson: JSON.stringify(raw), normalizedJson: JSON.stringify(normalized), matchKey: result.matchKey, diff --git a/apps/server/src/imports/imports.run.service.ts b/apps/server/src/imports/imports.run.service.ts index 2d7f1a1..2e0d8ca 100644 --- a/apps/server/src/imports/imports.run.service.ts +++ b/apps/server/src/imports/imports.run.service.ts @@ -17,6 +17,7 @@ import type { } from './imports.types'; import { parseJson } from './imports.helpers'; import { parseSheets } from './imports.workbook'; +import type { ImportSheetData } from './imports.workbook'; import { autoAssignedSheets, resolveAssignedSheets, suggestMapping, suggestStep } from './imports.mapping'; import { findOwnedRun } from './imports.access'; import type { ImportPrincipal } from './imports.access'; @@ -42,7 +43,35 @@ export class ImportRunService { if (!file.buffer || file.buffer.length === 0) { throw new BadRequestException('上传文件为空'); } - const sheets = await parseSheets(file.buffer, file.originalName, file.mimeType); + const headerRows = + stages && stages.length > 0 + ? [...new Set(stages.map((stage) => stage.headerRow ?? 1))] + : [1]; + const parsedByHeaderRow = new Map(); + for (const headerRow of headerRows) { + parsedByHeaderRow.set( + headerRow, + await parseSheets(file.buffer, file.originalName, file.mimeType, headerRow), + ); + } + const views = new Map(); + if (stages && stages.length > 0) { + // v1 limitation: a sheet referenced by multiple stages with different + // header rows keeps the view of the last stage that named it. + for (const stage of stages) { + const sheetName = stage.sheet?.trim(); + if (!sheetName) continue; + const view = parsedByHeaderRow + .get(stage.headerRow ?? 1) + ?.find((sheet) => sheet.name === sheetName); + if (view) views.set(sheetName, view); + } + } else { + for (const sheet of parsedByHeaderRow.get(1) ?? []) { + views.set(sheet.name, sheet); + } + } + const sheets = [...views.values()]; const runId = randomUUID(); const run = this.runs.create({ @@ -85,6 +114,20 @@ export class ImportRunService { const firstSheet = sheets.find((s) => s.name === assigned[0]); const mapping = mappingByStep?.[stepKey] ?? suggestMapping(firstSheet?.headers ?? [], stepKey); + if (mapping) { + const allowedHeaders = new Set(); + for (const sheetName of assigned) { + const sheet = sheets.find((s) => s.name === sheetName); + for (const header of sheet?.headers ?? []) allowedHeaders.add(header); + } + for (const [field, header] of Object.entries(mapping)) { + if (header && allowedHeaders.size > 0 && !allowedHeaders.has(header)) { + throw new BadRequestException( + `「${IMPORT_STEP_LABELS[stepKey]}」列映射「${header}」(字段 ${field})不在工作表表头中`, + ); + } + } + } stepRecords.push( this.steps.create({ runId, @@ -108,8 +151,15 @@ export class ImportRunService { const run = await findOwnedRun(this.runs, userId, runId); const stepRecords = await this.steps.find({ where: { runId }, order: { id: 'ASC' } }); const sheets = - parseJson>(run.sheetsJson) ?? - []; + parseJson< + Array<{ + name: string; + headers: string[]; + rows: CellValue[][]; + headerRow?: number; + rowNumbers?: number[]; + }> + >(run.sheetsJson) ?? []; return { id: run.id, fileName: run.fileName, diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index ea26275..3e2ea9a 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -8,6 +8,7 @@ import { ImportRun } from './entities/import-run.entity'; import { ImportStep } from './entities/import-step.entity'; import { ImportRow } from './entities/import-row.entity'; import { ImportsService } from './imports.service'; +import * as workbookModule from './imports.workbook'; import type { ParsedImportFile } from './imports.types'; function makeRowsRepo() { @@ -810,4 +811,101 @@ describe('ImportsService', () => { expect(result.rows[0]).toMatchObject({ action: 'skip', status: 'valid' }); 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 () => { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('名单'); + worksheet.addRow(['标题行', null]); + worksheet.addRow(['姓名', '学号']); + worksheet.addRow(['', '']); + worksheet.addRow(['张三', '2024001']); + const buffer = (await workbook.xlsx.writeBuffer()) as Buffer; + const run = { + id: 'run-h', + userId: 7, + conversationId: null, + source: 'manual', + fileName: 'students.xlsx', + sheetsJson: '[]', + status: 'ready', + currentStepKey: null, + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const runsRepo = makeRunsRepo(run); + const stepsRepo = makeStepsRepo({} as ImportStep); + const service = new ImportsService( + runsRepo as never, + stepsRepo as never, + makeRowsRepo() as never, + {} as never, + ); + + await service.createRun( + principal, + 'manual', + fileOf('students.xlsx', buffer), + null, + [{ stepKey: 'students', sheet: '名单', headerRow: 2 }], + ); + + const created = runsRepo.create.mock.calls[0][0] as { sheetsJson: string }; + const sheets = JSON.parse(created.sheetsJson) as Array<{ + headers: string[]; + rows: unknown[][]; + headerRow: number; + rowNumbers: number[]; + }>; + expect(sheets).toHaveLength(1); + expect(sheets[0].headers).toEqual(['姓名', '学号']); + expect(sheets[0].rows).toEqual([['张三', '2024001']]); + expect(sheets[0].headerRow).toBe(2); + expect(sheets[0].rowNumbers).toEqual([4]); + }); + + it('createRun 拒绝映射到工作表表头之外的列名', async () => { + const buffer = await xlsxBuffer(studentSheet()); + const service = new ImportsService( + makeRunsRepo({} as ImportRun) as never, + makeStepsRepo({} as ImportStep) as never, + makeRowsRepo() as never, + {} as never, + ); + + await expect( + service.createRun( + principal, + 'manual', + fileOf('students.xlsx', buffer), + null, + [{ stepKey: 'students', sheet: '学生' }], + { students: { name: '姓名', studentNo: '不存在的列' } }, + ), + ).rejects.toThrow('不在工作表表头中'); + }); }); diff --git a/apps/server/src/imports/imports.service.ts b/apps/server/src/imports/imports.service.ts index 89ca550..1506d45 100644 --- a/apps/server/src/imports/imports.service.ts +++ b/apps/server/src/imports/imports.service.ts @@ -72,11 +72,14 @@ export class ImportsService { } /** 上传后的只读预检:解析文件并生成可插入性报告,不写库。 */ - async preflightFile(file: ParsedImportFile): Promise { + async preflightFile( + file: ParsedImportFile, + headerRow = 1, + ): Promise { if (!file.buffer || file.buffer.length === 0) { throw new BadRequestException('上传文件为空'); } - const sheets = await parseSheets(file.buffer, file.originalName, file.mimeType); + const sheets = await parseSheets(file.buffer, file.originalName, file.mimeType, headerRow); return buildPreflightReport(this.dataSource, sheets); } diff --git a/apps/server/src/imports/imports.types.ts b/apps/server/src/imports/imports.types.ts index eeb38cb..9182131 100644 --- a/apps/server/src/imports/imports.types.ts +++ b/apps/server/src/imports/imports.types.ts @@ -153,6 +153,8 @@ export interface PreflightStageStat { stepKey: ImportStepKey; label: string; sheetNames: string[]; + /** 该阶段所有工作表的表头并集,供前端预检卡渲染列映射选项。 */ + headers: string[]; total: number; create: number; update: number; @@ -225,6 +227,12 @@ export interface PreflightReport { questions: PreflightQuestion[]; nextSteps: PreflightNextStep[]; errorSamples: PreflightErrorSample[]; + /** 以下字段由 AI 预检卡使用,普通预检报告生成时不设置。 */ + attachmentId?: number; + headerRow?: number; + permittedSteps?: ImportStepKey[]; + resolved?: boolean; + runId?: string | null; } export interface StepPreviewResult { diff --git a/apps/server/src/imports/imports.workbook-fallback.ts b/apps/server/src/imports/imports.workbook-fallback.ts new file mode 100644 index 0000000..c561c27 --- /dev/null +++ b/apps/server/src/imports/imports.workbook-fallback.ts @@ -0,0 +1,160 @@ +import JSZip from 'jszip'; +import type { ImportSheetData } from './imports.workbook'; + +const MAX_SHEETS = 30; +const MAX_ROWS_PER_SHEET = 3000; +const MAX_COLS_PER_SHEET = 60; + +export interface ExcelFallbackSheetRows { + name: string; + rows: string[][]; +} + +/** + * Read an .xlsx workbook without relying on ExcelJS. WPS-produced files + * sometimes prefix every OOXML element with a namespace that ExcelJS cannot + * load; this fallback strips the prefixes and parses shared strings directly. + */ +export async function readXlsxSheetsFallback(buffer: Buffer): Promise { + const zip = await JSZip.loadAsync(buffer); + const readEntry = async (name: string): Promise => { + const entry = zip.file(name); + return entry ? entry.async('string') : null; + }; + const workbookXml = await readEntry('xl/workbook.xml'); + if (!workbookXml) throw new Error('workbook.xml missing'); + const stripPrefixes = (value: string): string => + value.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1'); + const relsXml = stripPrefixes((await readEntry('xl/_rels/workbook.xml.rels')) ?? ''); + const relTargets = new Map(); + for (const match of relsXml.matchAll( + /]*\bId="([^"]+)"[^>]*\bTarget="([^"]+)"/g, + )) { + const target = match[2].replace(/^\/+/, ''); + relTargets.set(match[1], target.startsWith('xl/') ? target : `xl/${target}`); + } + + const sharedStrings = await parseSharedStringsFallback(readEntry); + const sheets: ExcelFallbackSheetRows[] = []; + const cleanWorkbook = stripPrefixes(workbookXml); + for (const match of cleanWorkbook.matchAll(/]*\/?>/g)) { + const tag = match[0].replace(/$/, '>'); + const name = tag.match(/\bname="([^"]+)"/)?.[1]; + const rid = tag.match(/\br:id="([^"]+)"/)?.[1]; + if (!name || !rid) continue; + const target = relTargets.get(rid); + const sheetXml = target ? await readEntry(target) : null; + if (!sheetXml) continue; + sheets.push({ + name: unescapeXml(name), + rows: sheetRowsFromXmlFallback(sheetXml, sharedStrings), + }); + } + return sheets; +} + +/** Convert fallback rows (all physical rows as strings) into import sheet views. */ +export function fallbackSheetsToImportSheets( + sheets: ExcelFallbackSheetRows[], + headerRow = 1, +): ImportSheetData[] { + const result: ImportSheetData[] = []; + for (const sheet of sheets.slice(0, MAX_SHEETS)) { + const headerIndex = headerRow - 1; + const headers = + sheet.rows[headerIndex]?.slice(0, MAX_COLS_PER_SHEET).map((header) => header.trim()) ?? []; + if (!headers.some((header) => header.trim() !== '')) continue; + const rows: ImportSheetData['rows'] = []; + const rowNumbers: number[] = []; + for (let i = headerIndex + 1; i < sheet.rows.length && rows.length < MAX_ROWS_PER_SHEET; i += 1) { + const values = sheet.rows[i].slice(0, headers.length); + if (values.every((value) => value === '')) continue; + rows.push(values); + rowNumbers.push(i + 1); + } + if (rows.length === 0) continue; + result.push({ + name: sheet.name, + headers, + rows, + headerRow, + rowNumbers, + }); + } + return result; +} + +async function parseSharedStringsFallback( + readEntry: (name: string) => Promise, +): Promise { + const xml = await readEntry('xl/sharedStrings.xml'); + if (!xml) return []; + const clean = xml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1'); + const strings: string[] = []; + for (const match of clean.matchAll(/]*>([\s\S]*?)<\/si>/gs)) { + const texts = [...match[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => + unescapeXml(part[1]), + ); + strings.push(texts.join('')); + } + return strings; +} + +function sheetRowsFromXmlFallback(sheetXml: string, sharedStrings: string[]): string[][] { + const rows: string[][] = []; + const xml = sheetXml.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1'); + for (const rowMatch of xml.matchAll(/]*>([\s\S]*?)<\/row>/gs)) { + const cells = new Map(); + let maxColumn = -1; + for (const cellMatch of rowMatch[1].matchAll(/]*)\/?>([\s\S]*?)<\/c>/gs)) { + const attrs = cellMatch[1]; + const refMatch = attrs.match(/\br="([A-Z]+)\d+"/); + const column = refMatch ? columnIndex(refMatch[1]) : -1; + const type = attrs.match(/\bt="([^"]+)"/)?.[1] ?? 'n'; + const body = cellMatch[2] ?? ''; + let value = ''; + if (type === 's') { + const index = Number(body.match(/([^<]*)<\/v>/)?.[1] ?? ''); + value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : ''; + } else if (type === 'inlineStr') { + const texts = [...body.matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => + unescapeXml(part[1]), + ); + value = texts.join(''); + } else { + value = unescapeXml(body.match(/([\s\S]*?)<\/v>/)?.[1] ?? ''); + if (type === 'b') value = value === '1' ? 'true' : 'false'; + } + if (column >= 0) { + cells.set(column, value); + maxColumn = Math.max(maxColumn, column); + } + } + if (maxColumn < 0) continue; + const values = Array.from({ length: maxColumn + 1 }, (_, index) => cells.get(index) ?? ''); + if (values.every((value) => value === '')) continue; + rows.push(values); + } + return rows; +} + +function columnIndex(letters: string): number { + let index = 0; + for (const char of letters.toUpperCase()) { + index = index * 26 + (char.charCodeAt(0) - 64); + } + return index - 1; +} + +function unescapeXml(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') + .replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) => + String.fromCodePoint(Number.parseInt(hex, 16)), + ) + .replace(/&#(\d+);/g, (_all, dec: string) => String.fromCodePoint(Number(dec))); +} diff --git a/apps/server/src/imports/imports.workbook.spec.ts b/apps/server/src/imports/imports.workbook.spec.ts new file mode 100644 index 0000000..f8a7c50 --- /dev/null +++ b/apps/server/src/imports/imports.workbook.spec.ts @@ -0,0 +1,104 @@ +import { BadRequestException } from '@nestjs/common'; +import * as ExcelJS from 'exceljs'; +import JSZip from 'jszip'; +import { parseDateValue } from './imports.helpers'; +import { parseSheets } from './imports.workbook'; + +async function xlsxBuffer(rows: Array>): Promise { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('名单'); + rows.forEach((row, index) => worksheet.addRow(row)); + return (await workbook.xlsx.writeBuffer()) as Buffer; +} + +async function wpsNamespaceXlsxBuffer(): Promise { + const zip = new JSZip(); + zip.file('[Content_Types].xml', ''); + zip.file( + 'xl/workbook.xml', + ` + +`, + ); + zip.file( + 'xl/_rels/workbook.xml.rels', + ` + +`, + ); + zip.file( + 'xl/sharedStrings.xml', + `张三`, + ); + zip.file( + 'xl/worksheets/sheet1.xml', + ` + +姓名手机号 +013800138000 + +`, + ); + return Buffer.from(await zip.generateAsync({ type: 'nodebuffer' })); +} + +describe('parseSheets', () => { + it('从 headerRow 取表头,并保留空行后的物理行号', async () => { + const buffer = await xlsxBuffer( + [ + ['标题行', null], + ['姓名', '学号'], + ['', ''], + ['张三', '2024001'], + ['李四', '2024002'], + ], + ); + const sheets = await parseSheets( + buffer, + 'students.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 2, + ); + expect(sheets).toHaveLength(1); + expect(sheets[0].headers).toEqual(['姓名', '学号']); + expect(sheets[0].rows).toEqual([ + ['张三', '2024001'], + ['李四', '2024002'], + ]); + expect(sheets[0].rowNumbers).toEqual([4, 5]); + }); + + it('解析 CSV 并默认使用第一行作为表头', async () => { + const buffer = Buffer.from('姓名,学号\n张三,2024001\n', 'utf8'); + const sheets = await parseSheets(buffer, 'students.csv', 'text/csv'); + expect(sheets).toHaveLength(1); + expect(sheets[0].headers).toEqual(['姓名', '学号']); + expect(sheets[0].rows[0]).toEqual(['张三', 2024001]); + }); + + it('ExcelJS 失败时回退到 WPS 命名空间解析', async () => { + const buffer = await wpsNamespaceXlsxBuffer(); + const sheets = await parseSheets( + buffer, + 'students.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + expect(sheets).toHaveLength(1); + expect(sheets[0].name).toBe('名单'); + expect(sheets[0].headers).toEqual(['姓名', '手机号']); + expect(sheets[0].rows).toEqual([['张三', '13800138000']]); + }); + + it('拒绝 .xls 文件', async () => { + await expect( + parseSheets(Buffer.from('not excel'), 'a.xls', 'application/vnd.ms-excel'), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); + +describe('parseDateValue', () => { + it('按进程本地时区格式化为 YYYY-MM-DD(CST 午夜不偏移到前一天)', () => { + expect(parseDateValue(new Date(2026, 0, 1))).toBe('2026-01-01'); + expect(parseDateValue(new Date(2026, 11, 31))).toBe('2026-12-31'); + }); +}); diff --git a/apps/server/src/imports/imports.workbook.ts b/apps/server/src/imports/imports.workbook.ts index f633427..dcfeaa5 100644 --- a/apps/server/src/imports/imports.workbook.ts +++ b/apps/server/src/imports/imports.workbook.ts @@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import * as ExcelJS from 'exceljs'; import { Readable } from 'node:stream'; import { cellValue, textValue } from './imports.helpers'; +import { fallbackSheetsToImportSheets, readXlsxSheetsFallback } from './imports.workbook-fallback'; import type { CellValue } from './imports.types'; const MAX_SHEETS = 30; @@ -12,30 +13,38 @@ export interface ImportSheetData { name: string; headers: string[]; rows: CellValue[][]; + /** 1-based header row used to build this view; defaults to 1. */ + headerRow?: number; + /** Physical 1-based row numbers for each entry in `rows` (after headerRow). */ + rowNumbers?: number[]; } -export function extractSheets(workbook: ExcelJS.Workbook): ImportSheetData[] { +export function extractSheets(workbook: ExcelJS.Workbook, headerRow = 1): ImportSheetData[] { const sheets: ImportSheetData[] = []; for (const worksheet of workbook.worksheets) { if (sheets.length >= MAX_SHEETS) break; const headers: string[] = []; const rows: CellValue[][] = []; - const firstRow = worksheet.getRow(1); + const rowNumbers: number[] = []; + const firstRow = worksheet.getRow(headerRow); for (let col = 1; col <= Math.min(firstRow.cellCount, MAX_COLS_PER_SHEET); col += 1) { const header = textValue(cellValue(firstRow.getCell(col))); headers.push(header); } if (!headers.some(Boolean)) continue; worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { - if (rowNumber === 1 || rows.length >= MAX_ROWS_PER_SHEET) return; + if (rowNumber <= headerRow || rows.length >= MAX_ROWS_PER_SHEET) return; const values: CellValue[] = []; for (let col = 1; col <= headers.length; col += 1) { values.push(cellValue(row.getCell(col))); } if (values.every((v) => v === null || textValue(v) === '')) return; rows.push(values); + rowNumbers.push(rowNumber); }); - if (rows.length > 0) sheets.push({ name: worksheet.name, headers, rows }); + if (rows.length > 0) { + sheets.push({ name: worksheet.name, headers, rows, headerRow, rowNumbers }); + } } return sheets; } @@ -61,6 +70,7 @@ export async function parseSheets( buffer: Buffer, originalName: string, mimeType: string, + headerRow = 1, ): Promise { const kind = detectWorkbookKind(originalName, mimeType); if (!kind) { @@ -76,13 +86,24 @@ export async function parseSheets( } else { await workbook.xlsx.load(buffer.buffer as ArrayBuffer); } - const sheets = extractSheets(workbook); + const sheets = extractSheets(workbook, headerRow); if (sheets.length === 0) { throw new BadRequestException('文件中没有可用的工作表数据'); } return sheets; } catch (error) { if (error instanceof BadRequestException) throw error; + if (kind === 'xlsx') { + try { + const fallbackSheets = fallbackSheetsToImportSheets( + await readXlsxSheetsFallback(buffer), + headerRow, + ); + if (fallbackSheets.length > 0) return fallbackSheets; + } catch { + // fall through to the readable error below + } + } throw new BadRequestException('Excel 文件解析失败,请检查文件格式'); } } From 824c33a71c63413c276c26f8da44d433d79b99e3 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 12:13:37 +0800 Subject: [PATCH 12/20] =?UTF-8?q?refactor(ai-chat):=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E8=B6=85=E5=A4=A7=E6=96=87=E4=BB=B6=E5=B9=B6=E9=80=9A=E8=BF=87?= =?UTF-8?q?=20aislop=20100=20=E5=88=86=E9=97=A8=E6=A7=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiChatService 拆出抽象基类,职责不变 - submissions 按提交内容/评审确认/运行时/流程拆为 4 个模块并保持导出兼容 - 导入工具执行器抽取共享 runner,消除重复签名块 - 全仓 aislop 扫描 100/100,0 警告 --- .../src/ai-chat/ai-chat.review-confirm.ts | 128 +++++ .../src/ai-chat/ai-chat.service-base.ts | 372 +++++++++++++++ apps/server/src/ai-chat/ai-chat.service.ts | 360 ++------------ .../src/ai-chat/ai-chat.submissions.flow.ts | 142 ++++++ .../ai-chat/ai-chat.submissions.runtime.ts | 82 ++++ .../server/src/ai-chat/ai-chat.submissions.ts | 446 +----------------- .../src/ai-chat/ai-chat.submit-content.ts | 90 ++++ .../ai-chat/ai-chat.tool-actions.import.ts | 325 +++++++++++++ .../src/ai-chat/ai-chat.tool-actions.ts | 315 +------------ 9 files changed, 1202 insertions(+), 1058 deletions(-) create mode 100644 apps/server/src/ai-chat/ai-chat.review-confirm.ts create mode 100644 apps/server/src/ai-chat/ai-chat.service-base.ts create mode 100644 apps/server/src/ai-chat/ai-chat.submissions.flow.ts create mode 100644 apps/server/src/ai-chat/ai-chat.submissions.runtime.ts create mode 100644 apps/server/src/ai-chat/ai-chat.submit-content.ts create mode 100644 apps/server/src/ai-chat/ai-chat.tool-actions.import.ts diff --git a/apps/server/src/ai-chat/ai-chat.review-confirm.ts b/apps/server/src/ai-chat/ai-chat.review-confirm.ts new file mode 100644 index 0000000..c5f9630 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.review-confirm.ts @@ -0,0 +1,128 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { AiReview } from './entities/ai-review.entity'; +import type { AiReviewSectionType } from './entities/ai-review.entity'; +import type { AiChatServiceContext } from './ai-chat.types'; +import { reviewSectionType } from './ai-chat.types'; +import type { AuthenticatedUser } from '../authorization'; + +async function loadReviewForConfirm( + context: AiChatServiceContext, + user: AuthenticatedUser, + reviewId: string, +): Promise { + const review = await context.reviewService.findOwned(reviewId, user.id); + if (review.status === 'submitted') { + throw new ConflictException('导入已全部确认,无需重复确认'); + } + if (review.status === 'expired') { + throw new ConflictException('导入预览已失效,请重新生成预览'); + } + return review; +} + +async function finalizeReview( + context: AiChatServiceContext, + updated: AiReview, +): Promise> { + await context.markReviewSubmittedOnMessage( + updated.assistantMessageId, + updated.conversationId, + updated, + ); + return context.reviewService.serialize(updated); +} + +async function logImportOp( + context: AiChatServiceContext, + user: AuthenticatedUser, + action: string, + detail: string, +): Promise { + await context.opLog?.log({ + userId: user.id, + username: user.username, + module: '批量导入', + action, + detail, + targetType: 'ai_review', + status: 'success', + }); +} + +export function assertReviewImportPermissions( + context: AiChatServiceContext, + user: AuthenticatedUser, + review: AiReview, + sectionKey?: string, + sectionType?: AiReviewSectionType, +): void { + const sectionPermission: Record = { + students: 'student:create', + rooms: 'room:create', + transfers: 'occupancy:transfer', + checkins: 'occupancy:checkin', + }; + const ability = context.abilityFactory.createForUser(user); + const sections = context.reviewService.parseSections(review.sectionsJson); + const types = new Set(); + if (sectionType) { + types.add(sectionType); + } else if (sectionKey) { + const section = sections.find((item) => item.key === sectionKey); + if (!section) throw new NotFoundException(`分表不存在: ${sectionKey}`); + types.add(reviewSectionType(section)); + } else { + for (const section of sections) types.add(reviewSectionType(section)); + } + for (const type of types) { + context.authorization.assertPermission(ability, sectionPermission[type]); + } +} + +export async function confirmReviewStep( + context: AiChatServiceContext, + user: AuthenticatedUser, + reviewId: string, + sectionKey: string, +): Promise> { + const review = await loadReviewForConfirm(context, user, reviewId); + assertReviewImportPermissions(context, user, review, sectionKey); + const { review: updated, message } = await context.reviewService.submitSection( + review.id, + user.id, + sectionKey, + ); + await logImportOp( + context, + user, + '确认导入分表', + `「${review.title}」分表「${sectionKey}」:${message}`, + ); + return finalizeReview(context, updated); +} + +export async function confirmReviewGroup( + context: AiChatServiceContext, + user: AuthenticatedUser, + reviewId: string, + type: AiReviewSectionType, +): Promise> { + if (type !== 'students' && type !== 'rooms' && type !== 'transfers' && type !== 'checkins') { + throw new BadRequestException(`业务类型不支持: ${String(type)}`); + } + const review = await loadReviewForConfirm(context, user, reviewId); + assertReviewImportPermissions(context, user, review, undefined, type); + const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type); + const sectionTitles = context.reviewService + .parseSections(updated.sectionsJson) + .filter((section) => section.type === type) + .map((section) => section.title) + .join('、'); + await logImportOp( + context, + user, + '确认导入分组', + `「${review.title}」分组「${type}」:${sectionTitles}`, + ); + return finalizeReview(context, updated); +} diff --git a/apps/server/src/ai-chat/ai-chat.service-base.ts b/apps/server/src/ai-chat/ai-chat.service-base.ts new file mode 100644 index 0000000..20b633b --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.service-base.ts @@ -0,0 +1,372 @@ +import { DataSource, Repository } from 'typeorm'; +import { AiConfigService } from '../ai-config/ai-config.service'; +import { AgentToolExecutor } from '../agent-tools/agent-tool.executor'; +import { + AgentToolContextFactory, + type AgentSkillDescriptor, +} from '../agent-tools/agent-tool.types'; +import { AuthorizationService, CaslAbilityFactory, type AuthenticatedUser } from '../authorization'; +import { AiAttachmentService } from './ai-attachment.service'; +import { ImportsService } from '../imports/imports.service'; +import { AiChartService } from './ai-chart.service'; +import { AiExcelReaderService } from './ai-excel-reader.service'; +import { AiFormService } from './ai-form.service'; +import { AiReviewService } from './ai-review.service'; +import { AiModelStreamService } from './ai-model-stream.service'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { + AiConversation, + AiMessage, + AiReview, + AiToolRun, + type AiReviewSectionType, +} from './entities'; +import type { + AiChatServiceContext, + AiSseEmitter, + GenerationInput, + ModelContentPart, + ModelMessage, + ModelToolCall, + PublicConversation, +} from './ai-chat.types'; +import { + listConversations, + createConversation, + updateConversation, + deleteConversation, + deleteAllConversations, + getMessages, + deleteMessage, +} from './ai-chat.conversations'; +import { + streamMessage, + regenerateMessage, + editMessage, +} from './ai-chat.streaming'; +import { + resolveFormConversationId, + resolvePreflightConversationId, + resolveReviewConversationId, + resolveImportPreflight, + submitForm, + submitReview, + confirmReviewStep, + confirmReviewGroup, + assertReviewImportPermissions, + a2uiSubmitInfo, + buildFormSubmitModelContent, + markFormSubmittedOnMessage, + a2uiReviewSubmitInfo, + buildReviewSubmitModelContent, + markReviewSubmittedOnMessage, +} from './ai-chat.submissions'; +import { denyWriteTool, executeTool } from './ai-chat.tools'; +import { + executePreflightImport, + executeStartImportWizard, +} from './ai-chat.tool-actions'; +export abstract class AiChatServiceBase implements AiChatServiceContext { + readonly activeConversations = new Set(); + + abstract listSkills(user: AuthenticatedUser): AgentSkillDescriptor[]; + abstract serializeMessage(message: AiMessage): Record; + abstract redactText(value: string): string; + abstract summarize(value: unknown): string | null; + abstract safeStructured(value: unknown): unknown; + abstract parseToolArguments(value: string): unknown; + abstract safeToolName(name: string): string; + abstract throwIfAborted(signal: AbortSignal): void; + abstract errorCode(error: unknown): string; + abstract assertGeneratedLength(reasoning: string, content: string): void; + abstract buildContext( + conversationId: number, + focusUserMessageId: number, + focusContent: string | ModelContentPart[], + skillKey: string | null, + supportsVision: boolean, + ): Promise; + abstract buildUserContent( + text: string, + attachments: any[], + supportsVision: boolean, + ): Promise; + abstract truncateText(value: string, max: number): string; + abstract metadataSkillKey(metadata: Record | null): string | null; + abstract normalizeTitle(title?: string): string; + abstract titleFromMessage(message: string): string; + abstract assertSkillAvailable(user: AuthenticatedUser, skillKey?: string | null): void; + abstract requireOwnedConversation(userId: number, id: number): Promise; + abstract acquireConversation(conversationId: number): Promise; + abstract executeGeneration(input: GenerationInput): Promise; + + constructor( + readonly conversations: Repository, + readonly messages: Repository, + readonly toolRuns: Repository, + readonly dataSource: DataSource, + readonly configService: AiConfigService, + readonly toolExecutor: AgentToolExecutor, + readonly modelStream: AiModelStreamService, + readonly attachmentService: AiAttachmentService, + readonly formService: AiFormService, + readonly reviewService: AiReviewService, + readonly chartService: AiChartService, + readonly abilityFactory: CaslAbilityFactory, + readonly authorization: AuthorizationService, + readonly excelReader?: AiExcelReaderService, + readonly importsService?: ImportsService, + readonly opLog?: OperationLogsService, + ) {} + + a2uiSubmitInfo(metadata: Record | null) { + return a2uiSubmitInfo(metadata); + } + + a2uiReviewSubmitInfo(metadata: Record | null) { + return a2uiReviewSubmitInfo(metadata); + } + + buildFormSubmitModelContent(submit: { title: string; values: Record }): string { + return buildFormSubmitModelContent(submit); + } + + buildReviewSubmitModelContent(submit: { + reviewId: string; + reviewTitle: string; + resultMessage: string; + }): string { + return buildReviewSubmitModelContent(submit); + } + + markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise { + return markFormSubmittedOnMessage(this, assistantMessageId, conversationId); + } + + markReviewSubmittedOnMessage( + assistantMessageId: number, + conversationId: number, + review?: AiReview, + ): Promise { + return markReviewSubmittedOnMessage(this, assistantMessageId, conversationId, review); + } + + assertReviewImportPermissions( + user: AuthenticatedUser, + review: AiReview, + sectionKey?: string, + sectionType?: AiReviewSectionType, + ): void { + return assertReviewImportPermissions(this, user, review, sectionKey, sectionType); + } + + executeTool( + messageId: number, + call: ModelToolCall, + context: ReturnType, + allowedSkillKey: string | null, + allowWriteTools: boolean, + reviewSubmitted: boolean, + userId: number, + emit: AiSseEmitter, + ): Promise { + return executeTool( + this, + messageId, + call, + context, + allowedSkillKey, + allowWriteTools, + reviewSubmitted, + userId, + emit, + ); + } + + denyWriteTool( + messageId: number, + call: ModelToolCall, + emit: AiSseEmitter, + ): Promise { + return denyWriteTool(this, messageId, call, emit); + } + + executeStartImportWizard( + messageId: number, + call: ModelToolCall, + context: ReturnType, + emit: AiSseEmitter, + ): Promise { + return executeStartImportWizard(this, messageId, call, context, emit); + } + + executePreflightImport( + messageId: number, + call: ModelToolCall, + context: ReturnType, + emit: AiSseEmitter, + ): Promise { + return executePreflightImport(this, messageId, call, context, emit); + } + + listConversations(userId: number): Promise { + return listConversations(this, userId); + } + + createConversation( + user: AuthenticatedUser, + title?: string, + lockedSkillKey?: string | null, + ): Promise { + return createConversation(this, user, title, lockedSkillKey); + } + + updateConversation( + user: AuthenticatedUser, + id: number, + dto: { title?: string; lockedSkillKey?: string | null }, + ): Promise { + return updateConversation(this, user, id, dto); + } + + deleteConversation(userId: number, id: number): Promise { + return deleteConversation(this, userId, id); + } + + deleteAllConversations(userId: number): Promise { + return deleteAllConversations(this, userId); + } + + getMessages(userId: number, conversationId: number, page = 1, limit = 50) { + return getMessages(this, userId, conversationId, page, limit); + } + + deleteMessage( + userId: number, + conversationId: number, + messageId: number, + ): Promise<{ deletedIds: number[] }> { + return deleteMessage(this, userId, conversationId, messageId); + } + + streamMessage( + user: AuthenticatedUser, + conversationId: number, + dto: { + message: string; + attachmentIds?: number[]; + clientRequestId: string; + skillKey?: string | null; + reasoningEffort?: string | null; + }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return streamMessage(this, user, conversationId, dto, signal, emit, onReady); + } + + regenerateMessage( + user: AuthenticatedUser, + conversationId: number, + assistantMessageId: number, + clientRequestId: string, + reasoningEffort: string | null | undefined, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return regenerateMessage( + this, + user, + conversationId, + assistantMessageId, + clientRequestId, + reasoningEffort, + signal, + emit, + onReady, + ); + } + + editMessage( + user: AuthenticatedUser, + conversationId: number, + messageId: number, + dto: { content: string; clientRequestId: string; reasoningEffort?: string | null }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return editMessage(this, user, conversationId, messageId, dto, signal, emit, onReady); + } + + resolveFormConversationId(userId: number, formId: string): Promise { + return resolveFormConversationId(this, userId, formId); + } + + resolveReviewConversationId(userId: number, reviewId: string): Promise { + return resolveReviewConversationId(this, userId, reviewId); + } + + resolvePreflightConversationId(userId: number, messageId: number): Promise { + return resolvePreflightConversationId(this, userId, messageId); + } + + submitForm( + user: AuthenticatedUser, + formId: string, + dto: { + values: Record; + clientRequestId: string; + reasoningEffort?: string | null; + }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return submitForm(this, user, formId, dto, signal, emit, onReady); + } + + submitReview( + user: AuthenticatedUser, + reviewId: string, + dto: { clientRequestId: string; reasoningEffort?: string | null }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return submitReview(this, user, reviewId, dto, signal, emit, onReady); + } + + resolveImportPreflight( + user: AuthenticatedUser, + messageId: number, + dto: { + clientRequestId: string; + mapping?: Record; + settings?: Record; + }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, + ): Promise { + return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady); + } + + confirmReviewStep( + user: AuthenticatedUser, + reviewId: string, + sectionKey: string, + ): Promise> { + return confirmReviewStep(this, user, reviewId, sectionKey); + } + + confirmReviewGroup( + user: AuthenticatedUser, + reviewId: string, + type: AiReviewSectionType, + ): Promise> { + return confirmReviewGroup(this, user, reviewId, type); + } +} diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index de180fa..3899efa 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -16,30 +16,13 @@ import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { - AiConversation, - AiMessage, - AiReview, - AiToolRun, - type AiReviewSectionType, -} from './entities'; +import { AiConversation, AiMessage, AiToolRun } from './entities'; import type { - AiChatServiceContext, - AiSseEmitter, GenerationInput, ModelContentPart, ModelMessage, - ModelToolCall, - PublicConversation, } from './ai-chat.types'; import { - listConversations, - createConversation, - updateConversation, - deleteConversation, - deleteAllConversations, - getMessages, - deleteMessage, requireOwnedConversation, acquireConversation, normalizeTitle, @@ -49,35 +32,7 @@ import { truncateText, serializeMessage, } from './ai-chat.conversations'; -import { - streamMessage, - regenerateMessage, - editMessage, - buildContext, - buildUserContent, -} from './ai-chat.streaming'; -import { - resolveFormConversationId, - resolvePreflightConversationId, - resolveReviewConversationId, - resolveImportPreflight, - submitForm, - submitReview, - confirmReviewStep, - confirmReviewGroup, - assertReviewImportPermissions, - a2uiSubmitInfo, - buildFormSubmitModelContent, - markFormSubmittedOnMessage, - a2uiReviewSubmitInfo, - buildReviewSubmitModelContent, - markReviewSubmittedOnMessage, -} from './ai-chat.submissions'; -import { denyWriteTool, executeTool } from './ai-chat.tools'; -import { - executePreflightImport, - executeStartImportWizard, -} from './ai-chat.tool-actions'; +import { buildContext, buildUserContent } from './ai-chat.streaming'; import { executeGeneration } from './ai-chat.generation'; import { assertGeneratedLength, @@ -88,32 +43,52 @@ import { safeToolName, throwIfAborted, } from './ai-chat.helpers'; +import { AiChatServiceBase } from './ai-chat.service-base'; @Injectable() -export class AiChatService implements AiChatServiceContext { - readonly activeConversations = new Set(); +export class AiChatService extends AiChatServiceBase { + private readonly redactingReplacer = makeRedactingReplacer((value) => this.redactText(value)); constructor( @InjectRepository(AiConversation) - readonly conversations: Repository, + conversations: Repository, @InjectRepository(AiMessage) - readonly messages: Repository, + messages: Repository, @InjectRepository(AiToolRun) - readonly toolRuns: Repository, - readonly dataSource: DataSource, - readonly configService: AiConfigService, - readonly toolExecutor: AgentToolExecutor, - readonly modelStream: AiModelStreamService, - readonly attachmentService: AiAttachmentService, - readonly formService: AiFormService, - readonly reviewService: AiReviewService, - readonly chartService: AiChartService, - readonly abilityFactory: CaslAbilityFactory, - readonly authorization: AuthorizationService, - readonly excelReader?: AiExcelReaderService, - readonly importsService?: ImportsService, - readonly opLog?: OperationLogsService, - ) {} + toolRuns: Repository, + dataSource: DataSource, + configService: AiConfigService, + toolExecutor: AgentToolExecutor, + modelStream: AiModelStreamService, + attachmentService: AiAttachmentService, + formService: AiFormService, + reviewService: AiReviewService, + chartService: AiChartService, + abilityFactory: CaslAbilityFactory, + authorization: AuthorizationService, + excelReader?: AiExcelReaderService, + importsService?: ImportsService, + opLog?: OperationLogsService, + ) { + super( + conversations, + messages, + toolRuns, + dataSource, + configService, + toolExecutor, + modelStream, + attachmentService, + formService, + reviewService, + chartService, + abilityFactory, + authorization, + excelReader, + importsService, + opLog, + ); + } listSkills(user: AuthenticatedUser): AgentSkillDescriptor[] { return this.toolExecutor.listSkills(AgentToolContextFactory.fromAuthenticatedUser(user)); @@ -167,49 +142,6 @@ export class AiChatService implements AiChatServiceContext { return assertGeneratedLength(reasoning, content); } - private readonly redactingReplacer = makeRedactingReplacer((value) => this.redactText(value)); - - a2uiSubmitInfo(metadata: Record | null) { - return a2uiSubmitInfo(metadata); - } - - a2uiReviewSubmitInfo(metadata: Record | null) { - return a2uiReviewSubmitInfo(metadata); - } - - buildFormSubmitModelContent(submit: { title: string; values: Record }): string { - return buildFormSubmitModelContent(submit); - } - - buildReviewSubmitModelContent(submit: { - reviewId: string; - reviewTitle: string; - resultMessage: string; - }): string { - return buildReviewSubmitModelContent(submit); - } - - markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise { - return markFormSubmittedOnMessage(this, assistantMessageId, conversationId); - } - - markReviewSubmittedOnMessage( - assistantMessageId: number, - conversationId: number, - review?: AiReview, - ): Promise { - return markReviewSubmittedOnMessage(this, assistantMessageId, conversationId, review); - } - - assertReviewImportPermissions( - user: AuthenticatedUser, - review: AiReview, - sectionKey?: string, - sectionType?: AiReviewSectionType, - ): void { - return assertReviewImportPermissions(this, user, review, sectionKey, sectionType); - } - buildContext( conversationId: number, focusUserMessageId: number, @@ -256,217 +188,7 @@ export class AiChatService implements AiChatServiceContext { return acquireConversation(this, conversationId); } - executeTool( - messageId: number, - call: ModelToolCall, - context: ReturnType, - allowedSkillKey: string | null, - allowWriteTools: boolean, - reviewSubmitted: boolean, - userId: number, - emit: AiSseEmitter, - ): Promise { - return executeTool( - this, - messageId, - call, - context, - allowedSkillKey, - allowWriteTools, - reviewSubmitted, - userId, - emit, - ); - } - - denyWriteTool( - messageId: number, - call: ModelToolCall, - emit: AiSseEmitter, - ): Promise { - return denyWriteTool(this, messageId, call, emit); - } - - executeStartImportWizard( - messageId: number, - call: ModelToolCall, - context: ReturnType, - emit: AiSseEmitter, - ): Promise { - return executeStartImportWizard(this, messageId, call, context, emit); - } - - executePreflightImport( - messageId: number, - call: ModelToolCall, - context: ReturnType, - emit: AiSseEmitter, - ): Promise { - return executePreflightImport(this, messageId, call, context, emit); - } - executeGeneration(input: GenerationInput): Promise { return executeGeneration(this, input); } - - listConversations(userId: number): Promise { - return listConversations(this, userId); - } - - createConversation( - user: AuthenticatedUser, - title?: string, - lockedSkillKey?: string | null, - ): Promise { - return createConversation(this, user, title, lockedSkillKey); - } - - updateConversation( - user: AuthenticatedUser, - id: number, - dto: { title?: string; lockedSkillKey?: string | null }, - ): Promise { - return updateConversation(this, user, id, dto); - } - - deleteConversation(userId: number, id: number): Promise { - return deleteConversation(this, userId, id); - } - - deleteAllConversations(userId: number): Promise { - return deleteAllConversations(this, userId); - } - - getMessages(userId: number, conversationId: number, page = 1, limit = 50) { - return getMessages(this, userId, conversationId, page, limit); - } - - deleteMessage( - userId: number, - conversationId: number, - messageId: number, - ): Promise<{ deletedIds: number[] }> { - return deleteMessage(this, userId, conversationId, messageId); - } - - streamMessage( - user: AuthenticatedUser, - conversationId: number, - dto: { - message: string; - attachmentIds?: number[]; - clientRequestId: string; - skillKey?: string | null; - reasoningEffort?: string | null; - }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return streamMessage(this, user, conversationId, dto, signal, emit, onReady); - } - - regenerateMessage( - user: AuthenticatedUser, - conversationId: number, - assistantMessageId: number, - clientRequestId: string, - reasoningEffort: string | null | undefined, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return regenerateMessage( - this, - user, - conversationId, - assistantMessageId, - clientRequestId, - reasoningEffort, - signal, - emit, - onReady, - ); - } - - editMessage( - user: AuthenticatedUser, - conversationId: number, - messageId: number, - dto: { content: string; clientRequestId: string; reasoningEffort?: string | null }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return editMessage(this, user, conversationId, messageId, dto, signal, emit, onReady); - } - - resolveFormConversationId(userId: number, formId: string): Promise { - return resolveFormConversationId(this, userId, formId); - } - - resolveReviewConversationId(userId: number, reviewId: string): Promise { - return resolveReviewConversationId(this, userId, reviewId); - } - - resolvePreflightConversationId(userId: number, messageId: number): Promise { - return resolvePreflightConversationId(this, userId, messageId); - } - - submitForm( - user: AuthenticatedUser, - formId: string, - dto: { - values: Record; - clientRequestId: string; - reasoningEffort?: string | null; - }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return submitForm(this, user, formId, dto, signal, emit, onReady); - } - - submitReview( - user: AuthenticatedUser, - reviewId: string, - dto: { clientRequestId: string; reasoningEffort?: string | null }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return submitReview(this, user, reviewId, dto, signal, emit, onReady); - } - - resolveImportPreflight( - user: AuthenticatedUser, - messageId: number, - dto: { - clientRequestId: string; - mapping?: Record; - settings?: Record; - }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady); - } - - confirmReviewStep( - user: AuthenticatedUser, - reviewId: string, - sectionKey: string, - ): Promise> { - return confirmReviewStep(this, user, reviewId, sectionKey); - } - - confirmReviewGroup( - user: AuthenticatedUser, - reviewId: string, - type: AiReviewSectionType, - ): Promise> { - return confirmReviewGroup(this, user, reviewId, type); - } } diff --git a/apps/server/src/ai-chat/ai-chat.submissions.flow.ts b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts new file mode 100644 index 0000000..1251cd3 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts @@ -0,0 +1,142 @@ +import type { AiChatServiceContext, AiSseEmitter } from './ai-chat.types'; +import { DEFAULT_TITLE } from './ai-chat.types'; +import type { AuthenticatedUser } from '../authorization'; +import { assertReviewImportPermissions } from './ai-chat.review-confirm'; +import { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime'; + +export async function submitForm( + context: AiChatServiceContext, + user: AuthenticatedUser, + formId: string, + dto: { values: Record; clientRequestId: string; reasoningEffort?: string | null }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, +): Promise { + const form = await context.formService.findOwnedPending(formId, user.id); + const conversation = await context.requireOwnedConversation(user.id, form.conversationId); + const values = context.formService.validateValues(form, dto.values); + const effectiveSkillKey = conversation.lockedSkillKey ?? null; + context.assertSkillAvailable(user, effectiveSkillKey); + + await context.acquireConversation(conversation.id); + try { + const summary = `已提交表单「${form.title}」`; + const saved = await context.dataSource.transaction(async (manager) => + persistExchange( + context, + manager, + conversation, + user.id, + summary, + dto.clientRequestId, + effectiveSkillKey, + { a2uiSubmit: { formId: form.id, formTitle: form.title, values } }, + undefined, + conversation.title === DEFAULT_TITLE ? form.title.slice(0, 30) : undefined, + ), + ); + + await context.formService.markSubmitted(form, values); + await context.markFormSubmittedOnMessage(form.assistantMessageId, conversation.id); + + await runGenerationAndRelease(context, { + user, + conversation, + userMessage: saved.userMessage, + assistant: saved.assistantMessage, + clientRequestId: dto.clientRequestId, + effectiveSkillKey, + focusContent: summary, + reasoningEffort: dto.reasoningEffort ?? null, + signal, + emit, + onReady, + }, conversation.id); + } finally { + context.activeConversations.delete(conversation.id); + } +} + +export async function submitReview( + context: AiChatServiceContext, + user: AuthenticatedUser, + reviewId: string, + dto: { clientRequestId: string; reasoningEffort?: string | null }, + signal: AbortSignal, + emit: AiSseEmitter, + onReady: () => void, +): Promise { + const review = await context.reviewService.findOwnedPending(reviewId, user.id); + const conversation = await context.requireOwnedConversation(user.id, review.conversationId); + const effectiveSkillKey = conversation.lockedSkillKey ?? null; + context.assertSkillAvailable(user, effectiveSkillKey); + assertReviewImportPermissions(context, user, review); + + await context.acquireConversation(conversation.id); + try { + const { review: updatedReview, result } = await context.reviewService.submitAll( + review.id, + user.id, + ); + const summary = `已确认导入「${review.title}」:${result.message}`; + await context.opLog?.log({ + userId: user.id, + username: user.username, + module: '批量导入', + action: '确认导入全部', + detail: `「${review.title}」${result.message}`, + targetType: 'ai_review', + status: 'success', + }); + const saved = await context.dataSource.transaction(async (manager) => { + const exchange = await persistExchange( + context, + manager, + conversation, + user.id, + summary, + dto.clientRequestId, + effectiveSkillKey, + { + a2uiReviewSubmit: { + reviewId: review.id, + reviewTitle: review.title, + resultMessage: result.message, + }, + }, + undefined, + conversation.title === DEFAULT_TITLE ? review.title.slice(0, 30) : undefined, + ); + return { ...exchange, result }; + }); + + const serialized = context.reviewService.serialize(updatedReview); + onReady(); + emit('ui.review', { + messageId: updatedReview.assistantMessageId, + review: serialized, + }); + await context.markReviewSubmittedOnMessage( + updatedReview.assistantMessageId, + conversation.id, + updatedReview, + ); + + await runGenerationAndRelease(context, { + user, + conversation, + userMessage: saved.userMessage, + assistant: saved.assistantMessage, + clientRequestId: dto.clientRequestId, + effectiveSkillKey, + focusContent: saved.result.message, + reasoningEffort: dto.reasoningEffort ?? null, + signal, + emit, + onReady, + }, conversation.id); + } finally { + context.activeConversations.delete(conversation.id); + } +} diff --git a/apps/server/src/ai-chat/ai-chat.submissions.runtime.ts b/apps/server/src/ai-chat/ai-chat.submissions.runtime.ts new file mode 100644 index 0000000..23d8474 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.submissions.runtime.ts @@ -0,0 +1,82 @@ +import { EntityManager } from 'typeorm'; +import { AiConversation, AiMessage } from './entities'; +import type { + AiChatServiceContext, + AiSseEmitter, + ModelContentPart, +} from './ai-chat.types'; +import type { AuthenticatedUser } from '../authorization'; + +export async function persistExchange( + context: AiChatServiceContext, + manager: EntityManager, + conversation: AiConversation, + userId: number, + userContent: string, + clientRequestId: string | undefined, + skillKey: string | null, + metadata?: Record, + attachments?: any[], + titleUpdate?: string, +): Promise<{ userMessage: AiMessage; assistantMessage: AiMessage }> { + const userMessage = await manager.save( + AiMessage, + manager.create(AiMessage, { + conversationId: conversation.id, + role: 'user', + content: userContent, + reasoningContent: null, + status: 'completed', + errorCode: null, + replyToMessageId: null, + metadata: { clientRequestId, skillKey, ...metadata }, + attachments, + }), + ); + const assistantMessage = await manager.save( + AiMessage, + manager.create(AiMessage, { + conversationId: conversation.id, + role: 'assistant', + content: '', + reasoningContent: null, + status: 'pending', + errorCode: null, + replyToMessageId: userMessage.id, + metadata: { clientRequestId, skillKey }, + }), + ); + await manager.update( + AiConversation, + { id: conversation.id, userId }, + { + lastMessageAt: new Date(), + ...(titleUpdate ? { title: titleUpdate } : {}), + }, + ); + return { userMessage, assistantMessage }; +} + +export async function runGenerationAndRelease( + context: AiChatServiceContext, + input: { + user: AuthenticatedUser; + conversation: AiConversation; + userMessage: AiMessage; + assistant: AiMessage; + clientRequestId: string; + effectiveSkillKey: string | null; + focusContent: string | ModelContentPart[]; + reasoningEffort?: string | null; + signal: AbortSignal; + emit: AiSseEmitter; + onReady: () => void; + }, + conversationId: number, +): Promise { + try { + await context.executeGeneration(input); + } finally { + context.activeConversations.delete(conversationId); + } +} diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index 8479662..30397e8 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -1,17 +1,8 @@ -import { - BadRequestException, - ConflictException, - NotFoundException, -} from '@nestjs/common'; -import { EntityManager } from 'typeorm'; -import { AiReview } from './entities/ai-review.entity'; -import { AiConversation, AiMessage } from './entities'; -import type { AiReviewSectionType } from './entities/ai-review.entity'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import type { AiChatServiceContext, AiSseEmitter, } from './ai-chat.types'; -import { DEFAULT_TITLE, reviewSectionType } from './ai-chat.types'; import type { AuthenticatedUser } from '../authorization'; import type { ImportStageRequest, @@ -85,50 +76,6 @@ function isPreflightReport(value: object): value is PreflightReport { ); } -async function loadReviewForConfirm( - context: AiChatServiceContext, - user: AuthenticatedUser, - reviewId: string, -): Promise { - const review = await context.reviewService.findOwned(reviewId, user.id); - if (review.status === 'submitted') { - throw new ConflictException('导入已全部确认,无需重复确认'); - } - if (review.status === 'expired') { - throw new ConflictException('导入预览已失效,请重新生成预览'); - } - return review; -} - -async function finalizeReview( - context: AiChatServiceContext, - updated: AiReview, -): Promise> { - await context.markReviewSubmittedOnMessage( - updated.assistantMessageId, - updated.conversationId, - updated, - ); - return context.reviewService.serialize(updated); -} - -async function logImportOp( - context: AiChatServiceContext, - user: AuthenticatedUser, - action: string, - detail: string, -): Promise { - await context.opLog?.log({ - userId: user.id, - username: user.username, - module: '批量导入', - action, - detail, - targetType: 'ai_review', - status: 'success', - }); -} - export async function resolveImportPreflight( context: AiChatServiceContext, user: AuthenticatedUser, @@ -230,379 +177,18 @@ export async function resolveImportPreflight( } } -export function assertReviewImportPermissions( - context: AiChatServiceContext, - user: AuthenticatedUser, - review: AiReview, - sectionKey?: string, - sectionType?: AiReviewSectionType, -): void { - const sectionPermission: Record = { - students: 'student:create', - rooms: 'room:create', - transfers: 'occupancy:transfer', - checkins: 'occupancy:checkin', - }; - const ability = context.abilityFactory.createForUser(user); - const sections = context.reviewService.parseSections(review.sectionsJson); - const types = new Set(); - if (sectionType) { - types.add(sectionType); - } else if (sectionKey) { - const section = sections.find((item) => item.key === sectionKey); - if (!section) throw new NotFoundException(`分表不存在: ${sectionKey}`); - types.add(reviewSectionType(section)); - } else { - for (const section of sections) types.add(reviewSectionType(section)); - } - for (const type of types) { - context.authorization.assertPermission(ability, sectionPermission[type]); - } -} - -export async function submitForm( - context: AiChatServiceContext, - user: AuthenticatedUser, - formId: string, - dto: { values: Record; clientRequestId: string; reasoningEffort?: string | null }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, -): Promise { - const form = await context.formService.findOwnedPending(formId, user.id); - const conversation = await context.requireOwnedConversation(user.id, form.conversationId); - const values = context.formService.validateValues(form, dto.values); - const effectiveSkillKey = conversation.lockedSkillKey ?? null; - context.assertSkillAvailable(user, effectiveSkillKey); - - await context.acquireConversation(conversation.id); - try { - const summary = `已提交表单「${form.title}」`; - const saved = await context.dataSource.transaction(async (manager) => - persistExchange( - context, - manager, - conversation, - user.id, - summary, - dto.clientRequestId, - effectiveSkillKey, - { a2uiSubmit: { formId: form.id, formTitle: form.title, values } }, - undefined, - conversation.title === DEFAULT_TITLE ? form.title.slice(0, 30) : undefined, - ), - ); - - await context.formService.markSubmitted(form, values); - await context.markFormSubmittedOnMessage(form.assistantMessageId, conversation.id); - - await runGenerationAndRelease(context, { - user, - conversation, - userMessage: saved.userMessage, - assistant: saved.assistantMessage, - clientRequestId: dto.clientRequestId, - effectiveSkillKey, - focusContent: summary, - reasoningEffort: dto.reasoningEffort ?? null, - signal, - emit, - onReady, - }, conversation.id); - } finally { - context.activeConversations.delete(conversation.id); - } -} - -export async function submitReview( - context: AiChatServiceContext, - user: AuthenticatedUser, - reviewId: string, - dto: { clientRequestId: string; reasoningEffort?: string | null }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, -): Promise { - const review = await context.reviewService.findOwnedPending(reviewId, user.id); - const conversation = await context.requireOwnedConversation(user.id, review.conversationId); - const effectiveSkillKey = conversation.lockedSkillKey ?? null; - context.assertSkillAvailable(user, effectiveSkillKey); - assertReviewImportPermissions(context, user, review); - - await context.acquireConversation(conversation.id); - try { - const { review: updatedReview, result } = await context.reviewService.submitAll( - review.id, - user.id, - ); - const summary = `已确认导入「${review.title}」:${result.message}`; - await context.opLog?.log({ - userId: user.id, - username: user.username, - module: '批量导入', - action: '确认导入全部', - detail: `「${review.title}」${result.message}`, - targetType: 'ai_review', - status: 'success', - }); - const saved = await context.dataSource.transaction(async (manager) => { - const exchange = await persistExchange( - context, - manager, - conversation, - user.id, - summary, - dto.clientRequestId, - effectiveSkillKey, - { - a2uiReviewSubmit: { - reviewId: review.id, - reviewTitle: review.title, - resultMessage: result.message, - }, - }, - undefined, - conversation.title === DEFAULT_TITLE ? review.title.slice(0, 30) : undefined, - ); - return { ...exchange, result }; - }); - - const serialized = context.reviewService.serialize(updatedReview); - onReady(); - emit('ui.review', { - messageId: updatedReview.assistantMessageId, - review: serialized, - }); - await context.markReviewSubmittedOnMessage( - updatedReview.assistantMessageId, - conversation.id, - updatedReview, - ); - - await runGenerationAndRelease(context, { - user, - conversation, - userMessage: saved.userMessage, - assistant: saved.assistantMessage, - clientRequestId: dto.clientRequestId, - effectiveSkillKey, - focusContent: saved.result.message, - reasoningEffort: dto.reasoningEffort ?? null, - signal, - emit, - onReady, - }, conversation.id); - } finally { - context.activeConversations.delete(conversation.id); - } -} - -export async function confirmReviewStep( - context: AiChatServiceContext, - user: AuthenticatedUser, - reviewId: string, - sectionKey: string, -): Promise> { - const review = await loadReviewForConfirm(context, user, reviewId); - assertReviewImportPermissions(context, user, review, sectionKey); - const { review: updated, message } = await context.reviewService.submitSection( - review.id, - user.id, - sectionKey, - ); - await logImportOp( - context, - user, - '确认导入分表', - `「${review.title}」分表「${sectionKey}」:${message}`, - ); - return finalizeReview(context, updated); -} - -export async function confirmReviewGroup( - context: AiChatServiceContext, - user: AuthenticatedUser, - reviewId: string, - type: AiReviewSectionType, -): Promise> { - if (type !== 'students' && type !== 'rooms' && type !== 'transfers' && type !== 'checkins') { - throw new BadRequestException(`业务类型不支持: ${String(type)}`); - } - const review = await loadReviewForConfirm(context, user, reviewId); - assertReviewImportPermissions(context, user, review, undefined, type); - const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type); - const sectionTitles = context.reviewService - .parseSections(updated.sectionsJson) - .filter((section) => section.type === type) - .map((section) => section.title) - .join('、'); - await logImportOp( - context, - user, - '确认导入分组', - `「${review.title}」分组「${type}」:${sectionTitles}`, - ); - return finalizeReview(context, updated); -} - -export function a2uiSubmitInfo( - metadata: Record | null, -): { title: string; values: Record } | null { - const submit = metadata?.a2uiSubmit; - if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; - const record = submit as Record; - const title = typeof record.formTitle === 'string' ? record.formTitle : '表单'; - const values = - record.values && typeof record.values === 'object' && !Array.isArray(record.values) - ? (record.values as Record) - : {}; - return { title, values }; -} - -export function buildFormSubmitModelContent(submit: { - title: string; - values: Record; -}): string { - let json: string; - try { - json = JSON.stringify(submit.values); - } catch { - json = '[无法序列化]'; - } - return `【表单提交:${submit.title}】\n提交值(JSON):${json.slice(0, 32 * 1024)}\n用户已在表单中确认,你可以执行允许的写操作工具。`; -} - -export async function markFormSubmittedOnMessage( - context: AiChatServiceContext, - assistantMessageId: number, - conversationId: number, -): Promise { - const assistant = await context.messages.findOne({ - where: { id: assistantMessageId, conversationId }, - }); - const a2ui = assistant?.metadata?.a2uiForm; - if (assistant && a2ui && typeof a2ui === 'object' && !Array.isArray(a2ui)) { - assistant.metadata = { - ...assistant.metadata, - a2uiForm: { ...(a2ui as Record), status: 'submitted' }, - }; - await context.messages.save(assistant); - } -} - -export function a2uiReviewSubmitInfo( - metadata: Record | null, -): { reviewId: string; reviewTitle: string; resultMessage: string } | null { - const submit = metadata?.a2uiReviewSubmit; - if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; - const record = submit as Record; - if (typeof record.reviewId !== 'string') return null; - return { - reviewId: record.reviewId, - reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入', - resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成', - }; -} - -export function buildReviewSubmitModelContent(submit: { - reviewId: string; - reviewTitle: string; - resultMessage: string; -}): string { - return `【批量导入已确认:${submit.reviewTitle}】\n${submit.resultMessage}\n数据已由系统入库,不要再次调用写入工具,直接向用户汇报导入结果即可。`; -} - -export async function markReviewSubmittedOnMessage( - context: AiChatServiceContext, - assistantMessageId: number, - conversationId: number, - review?: AiReview, -): Promise { - const assistant = await context.messages.findOne({ - where: { id: assistantMessageId, conversationId }, - }); - const a2ui = assistant?.metadata?.a2uiReview; - if (assistant && a2ui && typeof a2ui === 'object' && !Array.isArray(a2ui)) { - assistant.metadata = { - ...assistant.metadata, - a2uiReview: review - ? context.reviewService.serialize(review) - : { ...(a2ui as Record), status: 'submitted' }, - }; - await context.messages.save(assistant); - } -} - -export async function persistExchange( - context: AiChatServiceContext, - manager: EntityManager, - conversation: AiConversation, - userId: number, - userContent: string, - clientRequestId: string | undefined, - skillKey: string | null, - metadata?: Record, - attachments?: any[], - titleUpdate?: string, -): Promise<{ userMessage: AiMessage; assistantMessage: AiMessage }> { - const userMessage = await manager.save( - AiMessage, - manager.create(AiMessage, { - conversationId: conversation.id, - role: 'user', - content: userContent, - reasoningContent: null, - status: 'completed', - errorCode: null, - replyToMessageId: null, - metadata: { clientRequestId, skillKey, ...metadata }, - attachments, - }), - ); - const assistantMessage = await manager.save( - AiMessage, - manager.create(AiMessage, { - conversationId: conversation.id, - role: 'assistant', - content: '', - reasoningContent: null, - status: 'pending', - errorCode: null, - replyToMessageId: userMessage.id, - metadata: { clientRequestId, skillKey }, - }), - ); - await manager.update( - AiConversation, - { id: conversation.id, userId }, - { - lastMessageAt: new Date(), - ...(titleUpdate ? { title: titleUpdate } : {}), - }, - ); - return { userMessage, assistantMessage }; -} - -export async function runGenerationAndRelease( - context: AiChatServiceContext, - input: { - user: AuthenticatedUser; - conversation: AiConversation; - userMessage: AiMessage; - assistant: AiMessage; - clientRequestId: string; - effectiveSkillKey: string | null; - focusContent: string | import('./ai-chat.types').ModelContentPart[]; - reasoningEffort?: string | null; - signal: AbortSignal; - emit: AiSseEmitter; - onReady: () => void; - }, - conversationId: number, -): Promise { - try { - await context.executeGeneration(input); - } finally { - context.activeConversations.delete(conversationId); - } -} +export { + assertReviewImportPermissions, + confirmReviewGroup, + confirmReviewStep, +} from './ai-chat.review-confirm'; +export { submitForm, submitReview } from './ai-chat.submissions.flow'; +export { + a2uiReviewSubmitInfo, + a2uiSubmitInfo, + buildFormSubmitModelContent, + buildReviewSubmitModelContent, + markFormSubmittedOnMessage, + markReviewSubmittedOnMessage, +} from './ai-chat.submit-content'; +export { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime'; diff --git a/apps/server/src/ai-chat/ai-chat.submit-content.ts b/apps/server/src/ai-chat/ai-chat.submit-content.ts new file mode 100644 index 0000000..b21668a --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.submit-content.ts @@ -0,0 +1,90 @@ +import { AiReview } from './entities/ai-review.entity'; +import type { AiChatServiceContext } from './ai-chat.types'; + +export function a2uiSubmitInfo( + metadata: Record | null, +): { title: string; values: Record } | null { + const submit = metadata?.a2uiSubmit; + if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; + const record = submit as Record; + const title = typeof record.formTitle === 'string' ? record.formTitle : '表单'; + const values = + record.values && typeof record.values === 'object' && !Array.isArray(record.values) + ? (record.values as Record) + : {}; + return { title, values }; +} + +export function buildFormSubmitModelContent(submit: { + title: string; + values: Record; +}): string { + let json: string; + try { + json = JSON.stringify(submit.values); + } catch { + json = '[无法序列化]'; + } + return `【表单提交:${submit.title}】\n提交值(JSON):${json.slice(0, 32 * 1024)}\n用户已在表单中确认,你可以执行允许的写操作工具。`; +} + +export async function markFormSubmittedOnMessage( + context: AiChatServiceContext, + assistantMessageId: number, + conversationId: number, +): Promise { + const assistant = await context.messages.findOne({ + where: { id: assistantMessageId, conversationId }, + }); + const a2ui = assistant?.metadata?.a2uiForm; + if (assistant && a2ui && typeof a2ui === 'object' && !Array.isArray(a2ui)) { + assistant.metadata = { + ...assistant.metadata, + a2uiForm: { ...(a2ui as Record), status: 'submitted' }, + }; + await context.messages.save(assistant); + } +} + +export function a2uiReviewSubmitInfo( + metadata: Record | null, +): { reviewId: string; reviewTitle: string; resultMessage: string } | null { + const submit = metadata?.a2uiReviewSubmit; + if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; + const record = submit as Record; + if (typeof record.reviewId !== 'string') return null; + return { + reviewId: record.reviewId, + reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入', + resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成', + }; +} + +export function buildReviewSubmitModelContent(submit: { + reviewId: string; + reviewTitle: string; + resultMessage: string; +}): string { + return `【批量导入已确认:${submit.reviewTitle}】\n${submit.resultMessage}\n数据已由系统入库,不要再次调用写入工具,直接向用户汇报导入结果即可。`; +} + +export async function markReviewSubmittedOnMessage( + context: AiChatServiceContext, + assistantMessageId: number, + conversationId: number, + review?: AiReview, +): Promise { + const assistant = await context.messages.findOne({ + where: { id: assistantMessageId, conversationId }, + }); + const a2ui = assistant?.metadata?.a2uiReview; + if (assistant && a2ui && typeof a2ui === 'object' && !Array.isArray(a2ui)) { + assistant.metadata = { + ...assistant.metadata, + a2uiReview: review + ? context.reviewService.serialize(review) + : { ...(a2ui as Record), status: 'submitted' }, + }; + await context.messages.save(assistant); + } +} diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts new file mode 100644 index 0000000..4f27e84 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts @@ -0,0 +1,325 @@ +import { + IMPORT_STEP_KEYS, + type ImportStageRequest, + type ImportStepKey, + type PreflightReport, +} from '../imports/imports.types'; +import { permittedStepKeys } from '../imports/imports.access'; +import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; +import type { AgentToolContext } from './ai-chat.tools'; +import { AiMessage } from './entities'; +import { finishToolRun, startToolRun } from './ai-chat.tools'; +import { + isExcelAttachment, + parseConfirmedMapping, + parseConfirmedSettings, +} from './ai-chat.import-confirm'; + +function parseAttachmentArgs( + parsedArgs: unknown, +): { parsedRecord: Record; attachmentId: number } { + const parsedRecord = + parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) + ? (parsedArgs as Record) + : {}; + if ( + typeof parsedRecord.attachmentId !== 'number' || + !Number.isInteger(parsedRecord.attachmentId) || + parsedRecord.attachmentId <= 0 + ) { + throw new Error('缺少附件 attachmentId'); + } + return { parsedRecord, attachmentId: parsedRecord.attachmentId }; +} + +async function beginImportToolRun( + context: AiChatServiceContext, + messageId: number, + call: ModelToolCall, + emit: AiSseEmitter, + toolName: string, +) { + return startToolRun(context, messageId, call, emit, { + toolName, + skillKey: null, + argumentsData: null, + }); +} + +interface ImportToolContext { + run: Awaited>['run']; + parsedArgs: Awaited>['parsedArgs']; + startedAt: Awaited>['startedAt']; + assistant: AiMessage; + agentContext: AgentToolContext; + context: AiChatServiceContext; + call: ModelToolCall; + emit: AiSseEmitter; + messageId: number; +} + +async function runImportTool( + context: AiChatServiceContext, + messageId: number, + call: ModelToolCall, + agentContext: AgentToolContext, + emit: AiSseEmitter, + toolName: string, + handler: (tool: ImportToolContext) => Promise, +): Promise { + const { run, parsedArgs, startedAt } = await beginImportToolRun( + context, + messageId, + call, + emit, + toolName, + ); + try { + const assistant = await context.messages.findOne({ where: { id: messageId } }); + if (!assistant) throw new Error('assistant message missing'); + return await handler({ run, parsedArgs, startedAt, assistant, agentContext, context, call, emit, messageId }); + } catch (error) { + const summary = error instanceof Error ? error.message.slice(0, 100) : `${toolName} 失败`; + await finishToolRun(context, run, call, startedAt, { + status: 'failed', + summary, + error: summary, + }, emit); + return JSON.stringify({ status: 'failed', error: run.resultSummary }); + } +} + +type ImportToolExecutor = ( + context: AiChatServiceContext, + messageId: number, + call: ModelToolCall, + agentContext: AgentToolContext, + emit: AiSseEmitter, +) => Promise; + +function makeImportToolExecutor( + toolName: 'preflight_import' | 'start_import_wizard', + handler: (tool: ImportToolContext) => Promise, +): ImportToolExecutor { + return (context, messageId, call, agentContext, emit) => + 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 }); + return preflightModelPayload(preflight, permittedSteps); + }); + +export const executeStartImportWizard = makeImportToolExecutor( + 'start_import_wizard', + async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => { + const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs); + const [attachment] = await context.attachmentService.requireReadyOwned(ac.userId, [ + attachmentId as number, + ]); + if (!isExcelAttachment(attachment)) throw new Error('附件不是 Excel 文件,无法生成导入向导'); + const stages = Array.isArray(parsedRecord.stages) + ? (parsedRecord.stages as ImportStageRequest[]) + : []; + if (stages.length === 0) throw new Error('缺少 stages 参数'); + for (const stage of stages) { + if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) { + throw new Error(`stages 包含未知业务类型:${String(stage.stepKey)}`); + } + if (!stage.sheet || !String(stage.sheet).trim()) { + throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`); + } + if ( + stage.headerRow !== undefined && + (!Number.isInteger(stage.headerRow) || stage.headerRow < 1 || stage.headerRow > 1000) + ) { + throw new Error(`stages 中「${stage.stepKey}」的 headerRow 必须是 1-1000 之间的整数`); + } + } + const mapping = parseConfirmedMapping(parsedRecord.mapping); + const settings = parseConfirmedSettings(parsedRecord); + if (!context.importsService) throw new Error('导入向导服务未配置'); + const buffer = await context.attachmentService.readStoredBuffer(attachment); + const detail = await context.importsService.createRun( + { + id: ac.userId, + permissions: [...ac.permissions], + isSuperAdmin: ac.isSuperAdmin, + }, + 'ai', + { + originalName: attachment.originalName, + mimeType: attachment.mimeType, + size: attachment.size, + buffer, + }, + assistant.conversationId, + stages, + mapping, + settings, + ); + const wizard = compactImportWizard(detail); + const preflightMeta = assistant.metadata?.a2uiImportPreflight; + assistant.metadata = { + ...assistant.metadata, + ...(preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta) + ? { + a2uiImportPreflight: { + ...(preflightMeta as Record), + resolved: true, + runId: detail.id, + }, + } + : {}), + a2uiImportWizard: wizard, + }; + await context.messages.save(assistant); + + await finishToolRun(context, run, call, startedAt, { + status: 'success', + summary: `已生成导入向导:${detail.steps + .filter((step) => step.status !== 'skipped') + .map((step) => step.label) + .join('、')}`, + }, emit); + if (preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)) { + emit('ui.import_preflight', { + messageId, + preflight: { + ...(preflightMeta as Record), + resolved: true, + runId: detail.id, + }, + }); + } + emit('ui.import_wizard', { messageId, wizard }); + return JSON.stringify({ + status: 'success', + runId: detail.id, + steps: detail.steps + .filter((step) => step.status !== 'skipped') + .map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets })), + permittedSteps: permittedStepKeys({ + id: ac.userId, + permissions: [...ac.permissions], + isSuperAdmin: ac.isSuperAdmin, + }), + message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库', + }); + }); + +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): { + runId: string; + fileName: string; + sheets: Array<{ + name: string; + suggestedStepKey: string | null; + headers: string[]; + rowCount: number; + }>; + steps: Array<{ stepKey: string; label: string; sheets: string[]; status: string }>; +} { + return { + runId: detail.id, + fileName: detail.fileName, + sheets: detail.sheets.map((sheet: any) => ({ + name: sheet.name, + suggestedStepKey: sheet.suggestedStepKey, + headers: sheet.headers, + rowCount: sheet.rowCount, + })), + steps: detail.steps.map((step: any) => ({ + stepKey: step.stepKey, + label: step.label, + sheets: step.sheets, + status: step.status, + })), + }; +} diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index 6ac659d..f3a5dec 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -1,315 +1,6 @@ import { AiReview } from './entities/ai-review.entity'; -import { - IMPORT_STEP_KEYS, - type ImportStageRequest, - type ImportStepKey, - type PreflightReport, -} from '../imports/imports.types'; -import { permittedStepKeys } from '../imports/imports.access'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; -import type { AgentToolContext } from './ai-chat.tools'; import { finishToolRun, startToolRun } from './ai-chat.tools'; -import { - isExcelAttachment, - parseConfirmedMapping, - parseConfirmedSettings, -} from './ai-chat.import-confirm'; - -function parseAttachmentArgs( - parsedArgs: unknown, -): { parsedRecord: Record; attachmentId: number } { - const parsedRecord = - parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs) - ? (parsedArgs as Record) - : {}; - if ( - typeof parsedRecord.attachmentId !== 'number' || - !Number.isInteger(parsedRecord.attachmentId) || - parsedRecord.attachmentId <= 0 - ) { - throw new Error('缺少附件 attachmentId'); - } - return { parsedRecord, attachmentId: parsedRecord.attachmentId }; -} - -async function beginImportToolRun( - context: AiChatServiceContext, - messageId: number, - call: ModelToolCall, - emit: AiSseEmitter, - toolName: string, -) { - return startToolRun(context, messageId, call, emit, { - toolName, - skillKey: null, - argumentsData: null, - }); -} - -export async function executePreflightImport( - context: AiChatServiceContext, - messageId: number, - call: ModelToolCall, - agentContext: AgentToolContext, - emit: AiSseEmitter, -): Promise { - const { run, parsedArgs, startedAt } = await beginImportToolRun( - context, - messageId, - call, - emit, - 'preflight_import', - ); - - try { - const assistant = await context.messages.findOne({ where: { id: messageId } }); - if (!assistant) throw new Error('assistant message missing'); - 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(agentContext.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: agentContext.userId, - permissions: [...agentContext.permissions], - isSuperAdmin: agentContext.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 }); - return preflightModelPayload(preflight, permittedSteps); - } catch (error) { - const summary = - error instanceof Error ? error.message.slice(0, 100) : '导入预检失败'; - await finishToolRun(context, run, call, startedAt, { - status: 'failed', - summary, - error: summary, - }, emit); - return JSON.stringify({ status: 'failed', error: run.resultSummary }); - } -} - -export async function executeStartImportWizard( - context: AiChatServiceContext, - messageId: number, - call: ModelToolCall, - agentContext: AgentToolContext, - emit: AiSseEmitter, -): Promise { - const { run, parsedArgs, startedAt } = await beginImportToolRun( - context, - messageId, - call, - emit, - 'start_import_wizard', - ); - - try { - const assistant = await context.messages.findOne({ where: { id: messageId } }); - if (!assistant) throw new Error('assistant message missing'); - const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs); - const [attachment] = await context.attachmentService.requireReadyOwned(agentContext.userId, [ - attachmentId as number, - ]); - if (!isExcelAttachment(attachment)) throw new Error('附件不是 Excel 文件,无法生成导入向导'); - const stages = Array.isArray(parsedRecord.stages) - ? (parsedRecord.stages as ImportStageRequest[]) - : []; - if (stages.length === 0) throw new Error('缺少 stages 参数'); - for (const stage of stages) { - if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) { - throw new Error(`stages 包含未知业务类型:${String(stage.stepKey)}`); - } - if (!stage.sheet || !String(stage.sheet).trim()) { - throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`); - } - if ( - stage.headerRow !== undefined && - (!Number.isInteger(stage.headerRow) || stage.headerRow < 1 || stage.headerRow > 1000) - ) { - throw new Error(`stages 中「${stage.stepKey}」的 headerRow 必须是 1-1000 之间的整数`); - } - } - const mapping = parseConfirmedMapping(parsedRecord.mapping); - const settings = parseConfirmedSettings(parsedRecord); - if (!context.importsService) throw new Error('导入向导服务未配置'); - const buffer = await context.attachmentService.readStoredBuffer(attachment); - const detail = await context.importsService.createRun( - { - id: agentContext.userId, - permissions: [...agentContext.permissions], - isSuperAdmin: agentContext.isSuperAdmin, - }, - 'ai', - { - originalName: attachment.originalName, - mimeType: attachment.mimeType, - size: attachment.size, - buffer, - }, - assistant.conversationId, - stages, - mapping, - settings, - ); - const wizard = compactImportWizard(detail); - const preflightMeta = assistant.metadata?.a2uiImportPreflight; - assistant.metadata = { - ...assistant.metadata, - ...(preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta) - ? { - a2uiImportPreflight: { - ...(preflightMeta as Record), - resolved: true, - runId: detail.id, - }, - } - : {}), - a2uiImportWizard: wizard, - }; - await context.messages.save(assistant); - - await finishToolRun(context, run, call, startedAt, { - status: 'success', - summary: `已生成导入向导:${detail.steps - .filter((step) => step.status !== 'skipped') - .map((step) => step.label) - .join('、')}`, - }, emit); - if (preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)) { - emit('ui.import_preflight', { - messageId, - preflight: { - ...(preflightMeta as Record), - resolved: true, - runId: detail.id, - }, - }); - } - emit('ui.import_wizard', { messageId, wizard }); - return JSON.stringify({ - status: 'success', - runId: detail.id, - steps: detail.steps - .filter((step) => step.status !== 'skipped') - .map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets })), - permittedSteps: permittedStepKeys({ - id: agentContext.userId, - permissions: [...agentContext.permissions], - isSuperAdmin: agentContext.isSuperAdmin, - }), - message: '导入向导已生成,请提示用户打开向导,按阶段预览并确认后系统才会入库', - }); - } catch (error) { - const summary = error instanceof Error ? error.message.slice(0, 100) : '生成导入向导失败'; - await finishToolRun(context, run, call, startedAt, { status: 'failed', summary, error: summary }, emit); - return JSON.stringify({ status: 'failed', error: run.resultSummary }); - } -} - -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): { - runId: string; - fileName: string; - sheets: Array<{ - name: string; - suggestedStepKey: string | null; - headers: string[]; - rowCount: number; - }>; - steps: Array<{ stepKey: string; label: string; sheets: string[]; status: string }>; -} { - return { - runId: detail.id, - fileName: detail.fileName, - sheets: detail.sheets.map((sheet: any) => ({ - name: sheet.name, - suggestedStepKey: sheet.suggestedStepKey, - headers: sheet.headers, - rowCount: sheet.rowCount, - })), - steps: detail.steps.map((step: any) => ({ - stepKey: step.stepKey, - label: step.label, - sheets: step.sheets, - status: step.status, - })), - }; -} - export async function executeRenderForm( context: AiChatServiceContext, messageId: number, @@ -498,3 +189,9 @@ export async function executeRenderChart( return JSON.stringify({ status: 'failed', error: '图表参数无效' }); } } + +export { + compactImportWizard, + executePreflightImport, + executeStartImportWizard, +} from './ai-chat.tool-actions.import'; From 259271f56c7977b95b32041e52bd3999173aa53a Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 12:20:24 +0800 Subject: [PATCH 13/20] =?UTF-8?q?fix(admin):=20Alert=20message=20=E5=BC=83?= =?UTF-8?q?=E7=94=A8=20API=20=E5=85=A8=E9=83=A8=E8=BF=81=E7=A7=BB=E4=B8=BA?= =?UTF-8?q?=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/components/AiChat/DynamicReview.tsx | 2 +- apps/admin/src/components/ImportWizard/ImportWizardModal.tsx | 2 +- apps/admin/src/pages/AiConfig/AiConfigSteps.tsx | 2 +- apps/admin/src/pages/IntegrationConfig/index.tsx | 2 +- apps/admin/src/pages/Organizations/index.tsx | 4 ++-- apps/admin/src/pages/Schedules/ScheduleModals.tsx | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/admin/src/components/AiChat/DynamicReview.tsx b/apps/admin/src/components/AiChat/DynamicReview.tsx index aac20bc..0f4a8de 100644 --- a/apps/admin/src/components/AiChat/DynamicReview.tsx +++ b/apps/admin/src/components/AiChat/DynamicReview.tsx @@ -183,7 +183,7 @@ const ReviewPreview: React.FC = ({ review, disabled, onActio )} diff --git a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx index 9ccba41..d0717f0 100644 --- a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx +++ b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx @@ -390,7 +390,7 @@ export const ImportWizardModal: React.FC = ({ 保存并测试} extra={}> diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 85470b3..b061b4a 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -171,7 +171,7 @@ const IntegrationConfigPage: React.FC = () => { diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx index 9603a8c..3b5f40b 100644 --- a/apps/admin/src/pages/Organizations/index.tsx +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -349,7 +349,7 @@ const OrganizationsPage: React.FC = () => { @@ -417,7 +417,7 @@ const OrganizationsPage: React.FC = () => { ) : null} diff --git a/apps/admin/src/pages/Schedules/ScheduleModals.tsx b/apps/admin/src/pages/Schedules/ScheduleModals.tsx index bf029bf..5a8098c 100644 --- a/apps/admin/src/pages/Schedules/ScheduleModals.tsx +++ b/apps/admin/src/pages/Schedules/ScheduleModals.tsx @@ -542,7 +542,7 @@ export const SyncModal: React.FC = ({ )} @@ -550,7 +550,7 @@ export const SyncModal: React.FC = ({ {syncStatus.activeSchedules === 0 && ( )} From ae88372ef8bda283756608a45399317d7086c1c8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 14:46:33 +0800 Subject: [PATCH 14/20] =?UTF-8?q?fix(imports):=20=E4=BF=AE=E5=A4=8D=20AI?= =?UTF-8?q?=20=E5=AF=BC=E5=85=A5=E5=90=91=E5=AF=BC=E5=A4=9A=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E8=A1=A8=E4=B8=8E=E8=A1=A8=E5=A4=B4=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI resolve 生成向导时按阶段携带全部 sheetNames,不再只取第一张表 - ImportStageRequest 支持 sheets 数组并兼容旧 sheet;手动重传同步修复 - headerMatches 收窄为单向包含,避免宿舍号被原/新宿舍号反向匹配 - suggestStep 增加入住/换宿显式表头信号,修复入住表误判为换宿 - 预检与预览按工作表逐表解析列映射,兼容异构表头 - 修复预检卡生成向导成功后按钮未复位 loading 的问题 - 补充 mapping/预检/run/ai-chat 多工作表测试 --- .../components/AiChat/ImportPreflightCard.tsx | 1 + .../ImportWizard/ImportWizardModal.tsx | 2 +- .../src/components/ImportWizard/types.ts | 3 + .../src/ai-chat/ai-chat.service.spec.ts | 97 ++++++++++++++++++- .../server/src/ai-chat/ai-chat.submissions.ts | 4 +- .../ai-chat/ai-chat.tool-actions.import.ts | 5 +- apps/server/src/imports/imports.helpers.ts | 4 +- .../src/imports/imports.mapping.spec.ts | 72 ++++++++++++++ apps/server/src/imports/imports.mapping.ts | 54 ++++++++++- .../src/imports/imports.preflight.spec.ts | 36 +++++++ apps/server/src/imports/imports.preflight.ts | 17 +++- .../src/imports/imports.preview.service.ts | 7 +- .../server/src/imports/imports.run.service.ts | 22 +++-- .../src/imports/imports.service.spec.ts | 55 +++++++++++ apps/server/src/imports/imports.types.ts | 3 + 15 files changed, 355 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/imports/imports.mapping.spec.ts diff --git a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx index 6ef111a..42bfe23 100644 --- a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx +++ b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx @@ -146,6 +146,7 @@ export const ImportPreflightCard: React.FC = ({ }); } catch (resolveError) { setError(resolveError instanceof Error ? resolveError.message : '导入向导生成失败'); + } finally { setSubmitting(false); } }; diff --git a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx index d0717f0..223c8ac 100644 --- a/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx +++ b/apps/admin/src/components/ImportWizard/ImportWizardModal.tsx @@ -259,7 +259,7 @@ export const ImportWizardModal: React.FC = ({ try { const detail = await createImportRun(file, { source: 'manual', - stages: [{ stepKey: activeStepKey, sheet: sheetSelection[activeStepKey]?.[0] }], + stages: [{ stepKey: activeStepKey, sheets: sheetSelection[activeStepKey] ?? [] }], mapping: { [activeStepKey]: mappingDraft[activeStepKey] ?? {} }, }); await loadRun(detail.id); diff --git a/apps/admin/src/components/ImportWizard/types.ts b/apps/admin/src/components/ImportWizard/types.ts index a2e2350..4d390d6 100644 --- a/apps/admin/src/components/ImportWizard/types.ts +++ b/apps/admin/src/components/ImportWizard/types.ts @@ -47,7 +47,10 @@ export interface ImportRunDetail { export interface ImportStageRequest { stepKey: ImportStepKey; + /** 兼容旧调用:单个工作表名。 */ sheet?: string; + /** 一个阶段可包含多张工作表;与 sheet 二选一(sheets 优先)。 */ + sheets?: string[]; headerRow?: number; } diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index b976e61..99c7449 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -1884,7 +1884,7 @@ describe('AiChatService', () => { 'ai', expect.objectContaining({ originalName: 'students.xlsx' }), 3, - [{ stepKey: 'students', sheet: '学生', headerRow: 1 }], + [{ stepKey: 'students', sheets: ['学生'], headerRow: 1 }], { students: { name: '姓名', studentNo: '学号' } }, { updateExisting: false }, ); @@ -1906,6 +1906,101 @@ describe('AiChatService', () => { ).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 = { diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index 30397e8..94dacc6 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -125,10 +125,10 @@ export async function resolveImportPreflight( } const stages: ImportStageRequest[] = preflight.stages.map((stage) => ({ stepKey: stage.stepKey, - sheet: stage.sheetNames[0], + sheets: stage.sheetNames, headerRow, })); - if (stages.some((stage) => !stage.sheet || !String(stage.sheet).trim())) { + if (stages.some((stage) => !stage.sheets || stage.sheets.length === 0)) { throw new BadRequestException('预检报告缺少工作表信息,请重新预检'); } const allowedHeadersByStep: Partial> = {}; diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts index 4f27e84..f37665d 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts @@ -5,6 +5,7 @@ import { type PreflightReport, } from '../imports/imports.types'; import { permittedStepKeys } from '../imports/imports.access'; +import { expandStageSheets } from '../imports/imports.mapping'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AgentToolContext } from './ai-chat.tools'; import { AiMessage } from './entities'; @@ -173,8 +174,8 @@ export const executeStartImportWizard = makeImportToolExecutor( if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) { throw new Error(`stages 包含未知业务类型:${String(stage.stepKey)}`); } - if (!stage.sheet || !String(stage.sheet).trim()) { - throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet,请指定 Excel 中对应的 sheet 名`); + if (expandStageSheets(stage).length === 0) { + throw new Error(`stages 中「${stage.stepKey}」缺少工作表 sheet/sheets,请指定 Excel 中对应的表名`); } if ( stage.headerRow !== undefined && diff --git a/apps/server/src/imports/imports.helpers.ts b/apps/server/src/imports/imports.helpers.ts index 0bec486..a4e9fe0 100644 --- a/apps/server/src/imports/imports.helpers.ts +++ b/apps/server/src/imports/imports.helpers.ts @@ -26,7 +26,9 @@ export function headerMatches(header: string, alias: string): boolean { const h = normalizeHeader(header); const a = normalizeHeader(alias); if (!h || !a) return false; - return h === a || h.includes(a) || a.includes(h); + // 只允许“表头包含别名”的单向匹配,避免短表头(如“宿舍号”) + // 被长复合别名(如“原宿舍号/新宿舍号”)反向包含而误判。 + return h === a || h.includes(a); } export function cellValue(cell: ExcelJS.Cell | undefined): CellValue { diff --git a/apps/server/src/imports/imports.mapping.spec.ts b/apps/server/src/imports/imports.mapping.spec.ts new file mode 100644 index 0000000..dc73225 --- /dev/null +++ b/apps/server/src/imports/imports.mapping.spec.ts @@ -0,0 +1,72 @@ +import { BadRequestException } from '@nestjs/common'; +import { + expandStageSheets, + resolveAssignedSheets, + resolveSheetMapping, + suggestMapping, +} from './imports.mapping'; +import type { ImportStageRequest } from './imports.types'; + +describe('expandStageSheets', () => { + it('sheets 优先并去重、去空', () => { + const stage: ImportStageRequest = { + stepKey: 'checkins', + sheet: '旧表', + sheets: ['四人间女', '四人间男', '', '四人间女'], + }; + expect(expandStageSheets(stage)).toEqual(['四人间女', '四人间男']); + }); + + it('无 sheets 时回退 sheet', () => { + expect(expandStageSheets({ stepKey: 'students', sheet: '学生' })).toEqual(['学生']); + }); + + it('两者都缺时返回空数组', () => { + expect(expandStageSheets({ stepKey: 'students' })).toEqual([]); + }); +}); + +describe('resolveAssignedSheets', () => { + const available = ['学生', '四人间女', '四人间男', '2号楼']; + + it('按 sheets 数组收集一个阶段的多张工作表', () => { + const stages: ImportStageRequest[] = [ + { stepKey: 'checkins', sheets: ['四人间女', '四人间男'] }, + { stepKey: 'checkins', sheets: ['2号楼'] }, + ]; + expect(resolveAssignedSheets(stages, 'checkins', available)).toEqual([ + '四人间女', + '四人间男', + '2号楼', + ]); + }); + + it('兼容旧 sheet 单表形态', () => { + expect(resolveAssignedSheets([{ stepKey: 'students', sheet: '学生' }], 'students', available)).toEqual([ + '学生', + ]); + }); + + it('引用不存在的工作表时报错', () => { + expect(() => + resolveAssignedSheets([{ stepKey: 'checkins', sheets: ['不存在'] }], 'checkins', available), + ).toThrow(BadRequestException); + }); +}); + +describe('resolveSheetMapping', () => { + it('确认映射表头存在时优先使用,否则回退该表建议', () => { + const confirmed = { name: '姓名', roomNumber: '宿舍号' }; + const mapping = resolveSheetMapping(confirmed, ['学生姓名', '房号', '入住日期'], 'checkins'); + expect(mapping).toEqual({ + name: '学生姓名', + roomNumber: '房号', + checkInDate: '入住日期', + }); + }); + + it('确认映射为空时退化为该表的 suggestMapping', () => { + const mapping = resolveSheetMapping(null, ['姓名', '学号', '宿舍号', '入住日期'], 'checkins'); + expect(mapping).toEqual(suggestMapping(['姓名', '学号', '宿舍号', '入住日期'], 'checkins')); + }); +}); diff --git a/apps/server/src/imports/imports.mapping.ts b/apps/server/src/imports/imports.mapping.ts index 4c3b9ef..d69a6fd 100644 --- a/apps/server/src/imports/imports.mapping.ts +++ b/apps/server/src/imports/imports.mapping.ts @@ -24,6 +24,40 @@ export function suggestMapping(headers: string[], stepKey: ImportStepKey): Colum return mapping; } +/** 归一化阶段的工作表清单:sheets 优先,否则回退 sheet 单表,都没有则返回空数组。 */ +export function expandStageSheets(stage: ImportStageRequest): string[] { + const names = stage.sheets?.map((name) => name?.trim()).filter(Boolean) ?? []; + if (names.length > 0) return [...new Set(names)]; + const single = stage.sheet?.trim(); + return single ? [single] : []; +} + +/** + * 按工作表解析列映射:字段优先使用确认映射(该表存在对应表头时), + * 否则回退该表自身的表头建议,避免异构表头导致整行取不到值。 + */ +export function resolveSheetMapping( + confirmed: ColumnMapping | null | undefined, + headers: string[], + stepKey: ImportStepKey, +): ColumnMapping { + const suggested = suggestMapping(headers, stepKey); + const mapping: ColumnMapping = {}; + const fieldNames = new Set([ + ...Object.keys(confirmed ?? {}), + ...Object.keys(suggested), + ]); + for (const field of fieldNames) { + const header = confirmed?.[field]; + if (header && headers.includes(header)) { + mapping[field] = header; + } else if (suggested[field]) { + mapping[field] = suggested[field]; + } + } + return mapping; +} + export function suggestStep(headers: string[]): ImportStageSuggestion | null { let best: ImportStageSuggestion | null = null; for (const stepKey of IMPORT_STEP_ORDER) { @@ -35,7 +69,21 @@ export function suggestStep(headers: string[]): ImportStageSuggestion | null { const required = IMPORT_STEP_REQUIRED_FIELDS[stepKey].filter( (field) => mapping[field], ).length; - const score = Object.keys(mapping).length + identity * 3 + required * 2; + // 显式信号优先:入住表出现“入住日期/姓名”时应优先归入入住; + // 换宿表出现“原宿舍/新宿舍/换宿日期”时应优先归入换宿, + // 避免“日期/宿舍号”这类模糊别名把入住表误判成换宿表。 + const hasAny = (aliases: string[]) => + headers.some((header) => aliases.some((alias) => headerMatches(header, alias))); + const signal = + stepKey === 'checkins' && + (hasAny(['入住日期', '入住时间']) || + (hasAny(['宿舍号', '房间号', '房号']) && hasAny(['入住日期', '入住时间', '日期']))) + ? 6 + : stepKey === 'transfers' && + hasAny(['原宿舍', '原房间', '原宿舍号', '新宿舍', '新房间', '新宿舍号', '换宿日期', '变更日期']) + ? 6 + : 0; + const score = Object.keys(mapping).length + identity * 3 + required * 2 + signal; if (!best || score > best.matchedFields) { best = { stepKey, mapping, matchedFields: score }; } @@ -58,8 +106,8 @@ export function resolveAssignedSheets( available: string[], ): string[] { const names = stages - .filter((stage) => stage.stepKey === stepKey && stage.sheet) - .map((stage) => stage.sheet as string); + .filter((stage) => stage.stepKey === stepKey) + .flatMap(expandStageSheets); const missing = names.filter((name) => !available.includes(name)); if (missing.length > 0) { throw new BadRequestException(`工作表不存在:${missing.join('、')}`); diff --git a/apps/server/src/imports/imports.preflight.spec.ts b/apps/server/src/imports/imports.preflight.spec.ts index cd4e218..01f10fc 100644 --- a/apps/server/src/imports/imports.preflight.spec.ts +++ b/apps/server/src/imports/imports.preflight.spec.ts @@ -193,4 +193,40 @@ describe('buildPreflightReport', () => { }), ); }); + + 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(/^入住日期|日期$/), + }); + }); }); diff --git a/apps/server/src/imports/imports.preflight.ts b/apps/server/src/imports/imports.preflight.ts index 6cc586d..dc0c8ff 100644 --- a/apps/server/src/imports/imports.preflight.ts +++ b/apps/server/src/imports/imports.preflight.ts @@ -1,7 +1,7 @@ import { DataSource } from 'typeorm'; import { Organization } from '../entities/organization.entity'; import { buildLookups } from './imports.lookups'; -import { suggestMapping, suggestStep } from './imports.mapping'; +import { resolveSheetMapping, suggestMapping, suggestStep } from './imports.mapping'; import { validateRow, type ImportBatchState } from './imports.rows'; import { IMPORT_STEP_IDENTITY_FIELDS, @@ -139,8 +139,14 @@ async function analyzeStage( stepKey: ImportStepKey, sheets: ImportSheetData[], ): Promise { - const firstSheet = sheets[0]; - const mapping: ColumnMapping = suggestMapping(firstSheet.headers, stepKey); + // 阶段级映射取各表建议的并集,供预检卡预填;实际按表解析在下方逐表进行。 + 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]) @@ -162,11 +168,12 @@ async function analyzeStage( }; for (const sheet of sheets) { - const lookups = await buildLookups(dataSource, stepKey, sheet.headers, sheet.rows, mapping); + 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 = {}; - for (const [field, header] of Object.entries(mapping)) { + for (const [field, header] of Object.entries(sheetMapping)) { fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null; } const result = validateRow(stepKey, fields, lookups, batchState); diff --git a/apps/server/src/imports/imports.preview.service.ts b/apps/server/src/imports/imports.preview.service.ts index 76ad5fe..1b27894 100644 --- a/apps/server/src/imports/imports.preview.service.ts +++ b/apps/server/src/imports/imports.preview.service.ts @@ -13,7 +13,7 @@ import type { StepPreviewSummary, } from './imports.types'; import { parseJson } from './imports.helpers'; -import { assertMapping, suggestMapping } from './imports.mapping'; +import { assertMapping, resolveSheetMapping, suggestMapping } from './imports.mapping'; import { buildLookups } from './imports.lookups'; import { validateRow } from './imports.rows'; import type { ImportBatchState } from './imports.rows'; @@ -98,12 +98,13 @@ export class ImportPreviewService { for (const sheetName of usedSheets) { const sheet = sheetsData.find((s) => s.name === sheetName); if (!sheet) continue; + const sheetMapping = resolveSheetMapping(mapping, sheet.headers, stepKey); const lookups = await buildLookups( this.dataSource, stepKey, sheet.headers, sheet.rows, - mapping, + sheetMapping, ); for (let i = 0; i < sheet.rows.length; i += 1) { const rawValues = sheet.rows[i]; @@ -112,7 +113,7 @@ export class ImportPreviewService { raw[header] = rawValues[index] ?? null; }); const fields: Record = {}; - for (const [field, header] of Object.entries(mapping)) { + for (const [field, header] of Object.entries(sheetMapping)) { fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null; } const result = validateRow(stepKey, fields, lookups, batchState); diff --git a/apps/server/src/imports/imports.run.service.ts b/apps/server/src/imports/imports.run.service.ts index 2e0d8ca..d4d04d1 100644 --- a/apps/server/src/imports/imports.run.service.ts +++ b/apps/server/src/imports/imports.run.service.ts @@ -18,7 +18,13 @@ import type { import { parseJson } from './imports.helpers'; import { parseSheets } from './imports.workbook'; import type { ImportSheetData } from './imports.workbook'; -import { autoAssignedSheets, resolveAssignedSheets, suggestMapping, suggestStep } from './imports.mapping'; +import { + autoAssignedSheets, + expandStageSheets, + resolveAssignedSheets, + suggestMapping, + suggestStep, +} from './imports.mapping'; import { findOwnedRun } from './imports.access'; import type { ImportPrincipal } from './imports.access'; @@ -56,15 +62,13 @@ export class ImportRunService { } const views = new Map(); if (stages && stages.length > 0) { - // v1 limitation: a sheet referenced by multiple stages with different - // header rows keeps the view of the last stage that named it. for (const stage of stages) { - const sheetName = stage.sheet?.trim(); - if (!sheetName) continue; - const view = parsedByHeaderRow - .get(stage.headerRow ?? 1) - ?.find((sheet) => sheet.name === sheetName); - if (view) views.set(sheetName, view); + for (const sheetName of expandStageSheets(stage)) { + const view = parsedByHeaderRow + .get(stage.headerRow ?? 1) + ?.find((sheet) => sheet.name === sheetName); + if (view) views.set(sheetName, view); + } } } else { for (const sheet of parsedByHeaderRow.get(1) ?? []) { diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index 3e2ea9a..1551747 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -166,6 +166,61 @@ describe('ImportsService', () => { }); }); + it('显式多工作表阶段时完整分配所有工作表', async () => { + const workbook = new ExcelJS.Workbook(); + const girls = workbook.addWorksheet('四人间女'); + girls.addRow(['姓名', '学号', '宿舍号', '入住日期']); + girls.addRow(['张三', '2024001', 'A101', '2026-09-01']); + const boys = workbook.addWorksheet('四人间男'); + boys.addRow(['姓名', '学号', '宿舍号', '入住日期']); + boys.addRow(['李四', '2024002', 'A101', '2026-09-02']); + const buffer = (await workbook.xlsx.writeBuffer()) as Buffer; + + const run = { + id: 'run-2', + userId: 7, + conversationId: null, + source: 'manual', + fileName: 'dorm.xlsx', + sheetsJson: '[]', + status: 'ready', + currentStepKey: 'checkins', + error: null, + createdAt: new Date(), + updatedAt: new Date(), + } as ImportRun; + const runsRepo = makeRunsRepo(run); + const stepsRepo = { + create: jest.fn((value: unknown) => value), + save: jest.fn(async (value: unknown) => value), + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn().mockResolvedValue([]), + }; + const service = new ImportsService( + runsRepo as never, + stepsRepo as never, + makeRowsRepo() as never, + {} as never, + ); + + await service.createRun( + principal, + 'manual', + fileOf('dorm.xlsx', buffer), + null, + [{ stepKey: 'checkins', sheets: ['四人间女', '四人间男'], headerRow: 1 }], + ); + + const savedRun = runsRepo.create.mock.calls[0][0] as { sheetsJson: string }; + const savedSheets = JSON.parse(savedRun.sheetsJson) as Array<{ name: string }>; + expect(savedSheets.map((sheet) => sheet.name)).toEqual(['四人间女', '四人间男']); + + const checkinStep = stepsRepo.create.mock.calls.find( + (call) => (call[0] as { stepKey: string }).stepKey === 'checkins', + )?.[0] as { sheetsJson: string }; + expect(JSON.parse(checkinStep.sheetsJson)).toEqual(['四人间女', '四人间男']); + }); + it('预览学生阶段:已有学号判为更新,并保留目标记录 ID', async () => { const existing = { id: 88, diff --git a/apps/server/src/imports/imports.types.ts b/apps/server/src/imports/imports.types.ts index 9182131..4b62040 100644 --- a/apps/server/src/imports/imports.types.ts +++ b/apps/server/src/imports/imports.types.ts @@ -46,7 +46,10 @@ export type ColumnMapping = Record; // aislop-ignore-next-line: duplicate-type-declaration -- 与前端 ImportWizard 的 API 契约保持一致 export interface ImportStageRequest { stepKey: ImportStepKey; + /** 兼容旧调用:单个工作表名。 */ sheet?: string; + /** 一个阶段可包含多张工作表;与 sheet 二选一(sheets 优先)。 */ + sheets?: string[]; headerRow?: number; } From d9c541dacc947dc55752a4f3a01d341ed43abd53 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 14:53:22 +0800 Subject: [PATCH 15/20] =?UTF-8?q?fix(admin):=20=E8=B7=A8=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E9=A1=B5=E6=9D=83=E9=99=90=E5=86=99=E5=85=A5=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=95=B4=E9=A1=B5=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storage 事件里 permissions 变更改为原地写入权限 store, 仅 gongxue-auth token 实际变化(登录/退出/切换账号)才 reload, 避免多标签页相互触发刷新风暴 --- apps/admin/src/layouts/MainLayout.tsx | 40 +++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 89eff12..4a210b7 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -36,6 +36,7 @@ import api from '../api'; import { useAppStore } from '../store/app/appStore'; import { usePermissionStore } from '../store/permission/permissionStore'; import { useUserStore } from '../store/user/userStore'; +import { AUTH_STORAGE_NAME, PERMISSION_STORAGE_NAME } from '../store/middleware/persist'; import NotificationBell from '../components/NotificationBell'; import RouteDock from '../components/RouteDock'; import RouteKeeper from '../components/RouteKeeper'; @@ -125,9 +126,42 @@ const MainLayout: React.FC = () => { }; const handleStorage = (event: StorageEvent) => { - if (event.key !== 'token' && event.key !== 'permissions') return; - usePermissionStore.getState().beginPermissionVerification(); - window.location.reload(); + if (event.key === PERMISSION_STORAGE_NAME) { + // 其他标签页的权限更新:原地应用,避免整页刷新造成刷新风暴。 + try { + if (event.newValue === null) { + usePermissionStore.getState().clearPermissions(); + return; + } + const parsed = JSON.parse(event.newValue) as { + state?: { permissions?: string[] }; + }; + const permissions = parsed?.state?.permissions; + if (Array.isArray(permissions)) { + usePermissionStore.getState().writePermissions(permissions); + } + } catch { + // 忽略无法解析的跨标签页权限写入 + } + return; + } + if (event.key === AUTH_STORAGE_NAME) { + // 仅当登录态(token)确实变化时才整页刷新:登录、退出或切换账号。 + const currentToken = useUserStore.getState().token; + let otherToken: string | null = null; + if (event.newValue) { + try { + const parsed = JSON.parse(event.newValue) as { + state?: { token?: string | null }; + }; + otherToken = parsed?.state?.token ?? null; + } catch { + otherToken = null; + } + } + if (otherToken === currentToken) return; + window.location.reload(); + } }; const handleOnline = () => verifyPermissions(); const handleVisibilityChange = () => { From 24e0ecbdaf19106628c3891ae9214b01ed4fa66b Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 15:23:23 +0800 Subject: [PATCH 16/20] =?UTF-8?q?feat(ai):=20=E4=B8=9A=E5=8A=A1=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E6=84=9F=E7=9F=A5=E4=B8=8E=20A2UI=20?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增代码内业务上下文元数据层(实体字典 + 三大闭环工作流) - 新增 get_business_context / get_entity_schema / get_pending_tasks 运行时工具 - SYSTEM_PROMPT 与技能目录改为先查业务流程/待办再执行 - A2UI 增加 ai_a2ui_submissions 幂等表、表单过期、ui.artifact 事件 - 提交回灌携带 submissionId / fieldErrors / 下一步建议 - 前端 uiArtifacts 归一化与过期表单禁用 --- .../components/AiChat/AiMessageContent.tsx | 3 + .../src/components/AiChat/DynamicForm.tsx | 5 +- .../AiChat/bubble.integration.test.tsx | 32 ++ .../message-mappers.integration.test.ts | 46 +++ .../src/components/AiChat/message-mappers.ts | 48 ++- .../AiChat/provider.integration.test.ts | 35 ++ apps/admin/src/components/AiChat/provider.ts | 70 ++++ apps/admin/src/components/AiChat/types.ts | 24 +- .../AiChat/useAiChatMessageActions.tsx | 7 +- .../business-context.registry.ts | 318 ++++++++++++++++++ .../business-context.service.spec.ts | 85 +++++ .../agent-context/business-context.service.ts | 180 ++++++++++ .../business-context.tools.spec.ts | 73 ++++ .../agent-context/business-context.types.ts | 66 ++++ .../get-business-context.tool.ts | 90 +++++ .../agent-context/get-pending-tasks.tool.ts | 52 +++ apps/server/src/agent-context/index.ts | 12 + .../pending-tasks.service.spec.ts | 103 ++++++ .../agent-context/pending-tasks.service.ts | 183 ++++++++++ .../src/agent-tools/agent-skill.catalog.ts | 16 +- .../src/agent-tools/agent-tools.module.ts | 15 + .../ai-a2ui-submissions.service.spec.ts | 69 ++++ .../ai-chat/ai-a2ui-submissions.service.ts | 48 +++ .../src/ai-chat/ai-a2ui.artifact.spec.ts | 36 ++ apps/server/src/ai-chat/ai-a2ui.artifact.ts | 58 ++++ apps/server/src/ai-chat/ai-chat.constants.ts | 9 +- apps/server/src/ai-chat/ai-chat.module.ts | 4 + .../src/ai-chat/ai-chat.service-base.ts | 11 +- .../src/ai-chat/ai-chat.service.spec.ts | 18 + apps/server/src/ai-chat/ai-chat.service.ts | 3 + .../src/ai-chat/ai-chat.submissions.flow.ts | 90 ++++- .../ai-chat/ai-chat.submit-content.spec.ts | 66 ++++ .../src/ai-chat/ai-chat.submit-content.ts | 47 ++- .../ai-chat/ai-chat.tool-actions.import.ts | 23 ++ .../src/ai-chat/ai-chat.tool-actions.ts | 78 +++++ apps/server/src/ai-chat/ai-chat.types.ts | 12 +- .../src/ai-chat/ai-form.service.spec.ts | 30 ++ apps/server/src/ai-chat/ai-form.service.ts | 22 +- .../entities/ai-a2ui-submission.entity.ts | 35 ++ .../src/ai-chat/entities/ai-form.entity.ts | 2 +- apps/server/src/ai-chat/entities/index.ts | 1 + apps/server/src/app.module.ts | 3 + apps/server/src/entities/index.ts | 1 + .../1786001000000-AddA2UiSubmissions.ts | 37 ++ 44 files changed, 2125 insertions(+), 41 deletions(-) create mode 100644 apps/server/src/agent-context/business-context.registry.ts create mode 100644 apps/server/src/agent-context/business-context.service.spec.ts create mode 100644 apps/server/src/agent-context/business-context.service.ts create mode 100644 apps/server/src/agent-context/business-context.tools.spec.ts create mode 100644 apps/server/src/agent-context/business-context.types.ts create mode 100644 apps/server/src/agent-context/get-business-context.tool.ts create mode 100644 apps/server/src/agent-context/get-pending-tasks.tool.ts create mode 100644 apps/server/src/agent-context/index.ts create mode 100644 apps/server/src/agent-context/pending-tasks.service.spec.ts create mode 100644 apps/server/src/agent-context/pending-tasks.service.ts create mode 100644 apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts create mode 100644 apps/server/src/ai-chat/ai-a2ui-submissions.service.ts create mode 100644 apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts create mode 100644 apps/server/src/ai-chat/ai-a2ui.artifact.ts create mode 100644 apps/server/src/ai-chat/ai-chat.submit-content.spec.ts create mode 100644 apps/server/src/ai-chat/entities/ai-a2ui-submission.entity.ts create mode 100644 apps/server/src/migrations/1786001000000-AddA2UiSubmissions.ts diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index b70aaf8..f9e10de 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -56,6 +56,9 @@ const toolLabels: Record = { search_classrooms: '查询教室', search_classroom_rentals: '查询教室租用', get_sync_status: '查询同步状态', + get_business_context: '读取业务流程', + get_entity_schema: '读取实体字典', + get_pending_tasks: '查询业务待办', }; const markdownComponents = { diff --git a/apps/admin/src/components/AiChat/DynamicForm.tsx b/apps/admin/src/components/AiChat/DynamicForm.tsx index 4de1f7d..8148f58 100644 --- a/apps/admin/src/components/AiChat/DynamicForm.tsx +++ b/apps/admin/src/components/AiChat/DynamicForm.tsx @@ -84,6 +84,7 @@ const FormPreview: React.FC = ({ form, disabled, onAction }) = ); if (!form) return null; const finished = Boolean(form.submitted) || form.status === 'submitted'; + const expired = form.status === 'expired'; const handleFinish = (values: Record) => { onAction?.('form:submit', { values: normalizeValues(form.fields, values) }); @@ -97,7 +98,9 @@ const FormPreview: React.FC = ({ form, disabled, onAction }) = {form.description} )} - {finished ? ( + {expired ? ( + + ) : finished ? ( ) : ( { expect(container.textContent).toContain('已提交'); }); + it('renders an expired A2UI form as disabled without submit action', async () => { + let submitted = false; + const el = document.createElement('div'); + container = el; + document.body.appendChild(el); + root = createRoot(el); + + await act(async () => { + root?.render( + { + submitted = true; + }} + />, + ); + }); + + expect(el.textContent).toContain('已失效'); + expect(el.querySelector('button[type="submit"]')).toBeNull(); + await act(async () => { + const buttons = Array.from(el.querySelectorAll('button')); + buttons.forEach((button) => button.click()); + }); + expect(submitted).toBe(false); + }); + it('renders an A2UI review card and submits via the confirm button', async () => { let submittedId: string | null = null; const review: AiReviewSchema = { diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index 60ea6ee..baf9410 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -78,6 +78,52 @@ describe('AI chat history mapper', () => { expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-9', title: '新增学生' }); }); + it('restores uiArtifacts from message metadata and derives legacy lists', () => { + const mapped = mapHistoryMessage({ + id: 8, + role: 'assistant', + content: '请处理', + reasoningContent: null, + status: 'completed', + errorCode: null, + createdAt: '2026-07-23T00:00:00.000Z', + metadata: { + uiArtifacts: [ + { + id: 'form-10', + type: 'form', + status: 'submitted', + messageId: 8, + conversationId: 3, + payload: { + id: 'form-10', + title: '新增学生', + status: 'submitted', + fields: [], + }, + }, + { + id: 'review-10', + type: 'review', + status: 'expired', + messageId: 8, + conversationId: 3, + payload: { + id: 'review-10', + title: '旧预览', + status: 'expired', + sections: [], + }, + }, + ], + }, + }); + + expect(mapped.message.uiArtifacts).toHaveLength(2); + expect(mapped.message.forms?.[0]).toMatchObject({ id: 'form-10', status: 'submitted' }); + expect(mapped.message.reviews?.[0]).toMatchObject({ id: 'review-10', status: 'expired' }); + }); + it('restores a persisted A2UI review from message metadata', () => { const mapped = mapHistoryMessage({ id: 6, diff --git a/apps/admin/src/components/AiChat/message-mappers.ts b/apps/admin/src/components/AiChat/message-mappers.ts index e3a1390..68688a8 100644 --- a/apps/admin/src/components/AiChat/message-mappers.ts +++ b/apps/admin/src/components/AiChat/message-mappers.ts @@ -3,11 +3,13 @@ import type { AiChatMessage, AiChatMessageStatus, AiChartSchema, + AiArtifactSchema, AiFormSchema, AiMessageRecord, AiReviewSchema, AiToolRun, } from './types'; +import { mergeArtifactIntoMessage } from './provider'; function mapStatus(record: AiMessageRecord): AiChatMessageStatus { if (record.status === 'pending') return 'loading'; @@ -50,24 +52,40 @@ function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined { return [a2uiChart as AiChartSchema]; } +function historyArtifacts(record: AiMessageRecord) { + const artifacts = record.metadata?.uiArtifacts; + if (!Array.isArray(artifacts)) return undefined; + return artifacts.filter( + (item): item is AiArtifactSchema => + Boolean(item) && typeof item === 'object' && typeof (item as AiArtifactSchema).id === 'string', + ); +} + export function mapHistoryMessage(record: AiMessageRecord): MessageInfo { + const artifacts = historyArtifacts(record); + const baseMessage = { + id: record.id, + role: record.role, + content: record.content || '', + reasoningContent: record.reasoningContent || '', + toolRuns: (record.toolRuns || []).map(normalizeToolRun), + attachments: record.attachments ?? [], + forms: historyForms(record), + reviews: historyReviews(record), + charts: historyCharts(record), + replyToMessageId: record.replyToMessageId, + metadata: record.metadata, + error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined, + cancelled: record.status === 'cancelled', + }; + if (artifacts) { + for (const artifact of artifacts) { + mergeArtifactIntoMessage(baseMessage as AiChatMessage, artifact); + } + } return { id: record.id, status: mapStatus(record), - message: { - id: record.id, - role: record.role, - content: record.content || '', - reasoningContent: record.reasoningContent || '', - toolRuns: (record.toolRuns || []).map(normalizeToolRun), - attachments: record.attachments ?? [], - forms: historyForms(record), - reviews: historyReviews(record), - charts: historyCharts(record), - replyToMessageId: record.replyToMessageId, - metadata: record.metadata, - error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined, - cancelled: record.status === 'cancelled', - }, + message: baseMessage as AiChatMessage, }; } diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index d1f1f43..c552dfe 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -154,6 +154,41 @@ describe('AI chat SSE message reducer', () => { expect(message.forms?.[1]).toMatchObject({ id: 'form-2' }); }); + it('merges ui.artifact events into uiArtifacts and legacy lists by id', () => { + let message = reduceAiSseMessage(undefined, { + event: 'ui.artifact', + data: JSON.stringify({ + messageId: 12, + artifact: { + id: 'form-1', + type: 'form', + status: 'pending', + messageId: 12, + conversationId: 3, + payload: { id: 'form-1', title: '新增学生', fields: [] }, + }, + }), + }); + message = reduceAiSseMessage(message, { + event: 'ui.artifact', + data: JSON.stringify({ + messageId: 12, + artifact: { + id: 'review-1', + type: 'review', + status: 'expired', + messageId: 12, + conversationId: 3, + payload: { id: 'review-1', title: '旧导入预览', status: 'expired', sections: [] }, + }, + }), + }); + + expect(message.uiArtifacts).toHaveLength(2); + expect(message.forms?.[0]).toMatchObject({ id: 'form-1', title: '新增学生' }); + 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', diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index e8f7309..970fdef 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -8,6 +8,7 @@ import { usePermissionStore } from '../../store/permission/permissionStore'; import { useUserStore } from '../../store/user/userStore'; import type { AiAttachment, + AiArtifactSchema, AiChatInput, AiChatMessage, AiChartSchema, @@ -34,6 +35,7 @@ interface AiSsePayload { durationMs?: number | null; attachment?: AiAttachment; form?: AiFormSchema; + artifact?: AiArtifactSchema; review?: AiReviewSchema; chart?: AiChartSchema; preflight?: AiImportPreflight; @@ -62,6 +64,7 @@ function emptyAssistant(): AiChatMessage { toolRuns: [], attachments: [], forms: [], + uiArtifacts: [], }; } @@ -165,10 +168,50 @@ function applyMessagePayload( message.charts, (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, ); + const artifacts = nested.metadata?.uiArtifacts; + if (Array.isArray(artifacts)) { + for (const artifact of artifacts) { + if (artifact && typeof artifact === 'object' && typeof artifact.id === 'string') { + mergeArtifactIntoMessage(message, artifact as AiArtifactSchema); + } + } + } message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId; message.metadata = nested.metadata ?? message.metadata; } +/** + * 将统一 artifact 归入 uiArtifacts,并按类型派发到 legacy 列表。 + */ +export function mergeArtifactIntoMessage( + message: AiChatMessage, + artifact: AiArtifactSchema, +): AiChatMessage { + message.uiArtifacts = mergeById(message.uiArtifacts, artifact); + const payload = + artifact.payload && typeof artifact.payload === 'object' + ? (artifact.payload as Record) + : {}; + if (artifact.type === 'form') { + message.forms = mergeForms(message.forms, payload as unknown as AiFormSchema); + } else if (artifact.type === 'review') { + message.reviews = mergeById( + message.reviews, + payload as unknown as AiReviewSchema, + ); + } else if (artifact.type === 'chart') { + message.charts = mergeById( + message.charts, + payload as unknown as AiChartSchema, + ); + } else if (artifact.type === 'import_preflight') { + message.metadata = { ...message.metadata, a2uiImportPreflight: payload }; + } else if (artifact.type === 'import_wizard') { + message.metadata = { ...message.metadata, a2uiImportWizard: payload }; + } + return message; +} + export function reduceAiSseMessage( originMessage: AiChatMessage | undefined, chunk?: AiSseChunk, @@ -198,6 +241,8 @@ export function reduceAiSseMessage( message.reviews = mergeById(message.reviews, payload.review); } else if (event === 'ui.chart' && payload.chart) { message.charts = mergeById(message.charts, payload.chart); + } else if (event === 'ui.artifact' && payload.artifact) { + mergeArtifactIntoMessage(message, payload.artifact); } else if (event === 'ui.import_preflight' && payload.preflight) { message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight }; } else if (event === 'ui.import_wizard' && payload.wizard) { @@ -319,6 +364,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider< > { /** Routes events that target another (already streamed) message. */ onExternalReview?: (messageId: number, review: AiReviewSchema) => void; + onExternalArtifact?: (messageId: number, artifact: AiArtifactSchema) => void; constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) { super({ @@ -404,6 +450,30 @@ export class GongxueAiChatProvider extends AbstractChatProvider< transformMessage(info: TransformMessage): AiChatMessage { const { event, payload } = parseSsePayload(info.chunk); + if ( + event === 'ui.artifact' && + payload.artifact && + typeof payload.messageId === 'number' && + info.originMessage?.id !== payload.messageId + ) { + this.onExternalArtifact?.(payload.messageId, payload.artifact); + return info.originMessage ?? emptyAssistant(); + } + if ( + event === 'ui.form' && + payload.form && + typeof payload.messageId === 'number' && + info.originMessage?.id !== payload.messageId + ) { + this.onExternalArtifact?.(payload.messageId, { + id: payload.form.id, + type: 'form', + status: payload.form.status ?? 'pending', + messageId: payload.messageId, + payload: payload.form, + }); + return info.originMessage ?? emptyAssistant(); + } if ( event === 'ui.review' && payload.review && diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index ac2fc59..31bac56 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -52,7 +52,7 @@ export interface AiFormSchema { description?: string | null; submitLabel?: string; fields: AiFormField[]; - status?: 'pending' | 'submitted'; + status?: 'pending' | 'submitted' | 'expired'; } export interface AiReviewColumn { @@ -98,6 +98,27 @@ export interface AiChartSchema { rows: AiReviewRow[]; } +export type AiArtifactType = + | 'form' + | 'review' + | 'chart' + | 'import_preflight' + | 'import_wizard'; + +export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled'; + +export interface AiArtifactSchema { + id: string; + type: AiArtifactType; + status: AiArtifactStatus; + messageId: number; + conversationId?: number; + payload: T; + createdAt?: string | null; + submittedAt?: string | null; + supersededBy?: string | null; +} + export interface AiImportWizard { runId: string; fileName: string; @@ -216,6 +237,7 @@ export interface AiChatMessage { forms?: AiFormSchema[]; reviews?: AiReviewSchema[]; charts?: AiChartSchema[]; + uiArtifacts?: AiArtifactSchema[]; replyToMessageId?: number | null; metadata?: Record | null; retrying?: AiModelRetryInfo | null; diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index a6607f9..91a42dc 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -9,7 +9,7 @@ import { useSettingsStore } from '../../store/settings/settingsStore'; import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api'; import { AiMessageContent } from './AiMessageContent'; import { mapHistoryMessage } from './message-mappers'; -import { GongxueAiChatProvider } from './provider'; +import { GongxueAiChatProvider, mergeArtifactIntoMessage } from './provider'; import { emptyAssistant, MessageHoverActions, @@ -111,6 +111,11 @@ export function useAiChatMessageActions({ }, })); }; + provider.onExternalArtifact = (messageId, artifact) => { + setMessage(messageId, (info) => ({ + message: mergeArtifactIntoMessage(info.message, artifact), + })); + }; }, [provider, setMessage]); requestingRef.current = isRequesting; diff --git a/apps/server/src/agent-context/business-context.registry.ts b/apps/server/src/agent-context/business-context.registry.ts new file mode 100644 index 0000000..c6a8939 --- /dev/null +++ b/apps/server/src/agent-context/business-context.registry.ts @@ -0,0 +1,318 @@ +import type { + BusinessEntity, + BusinessWorkflow, +} from './business-context.types'; + +/** + * 恭学系统业务上下文(代码内维护)。 + * + * v1 覆盖三大闭环:学生教学、住宿计费、教室租赁。实体字段字典用于 + * render_form 生成正确表单,工作流阶段/前置依赖/下一步建议用于引导 + * Agent 感知业务流程。 + */ +export const BUSINESS_ENTITIES: readonly BusinessEntity[] = [ + { + key: 'student', + name: '学生档案', + description: '学生基础档案(姓名、学号、手机号、性别等),是分班、入住、账单的前置数据。', + searchTool: 'search_students', + requiredPermissions: ['student:view'], + fields: [ + { key: 'name', label: '姓名', type: 'string', required: true }, + { key: 'studentNo', label: '学号', type: 'string' }, + { key: 'phone', label: '手机号', type: 'string' }, + { key: 'gender', label: '性别', type: 'enum', enumFrom: 'student.gender' }, + { key: 'idNumber', label: '身份证号', type: 'string' }, + ], + relations: [ + { entityKey: 'class', via: 'class_student', requiredFor: ['class'] }, + { entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] }, + { entityKey: 'bill', via: 'bill', requiredFor: ['bill'] }, + { entityKey: 'exam', via: 'exam_score', requiredFor: ['exam'] }, + ], + }, + { + key: 'class', + name: '班级', + description: '班级档案与在读分班关系,排课、考勤、考试依赖班级。', + searchTool: 'search_classes', + requiredPermissions: ['class:view'], + fields: [ + { key: 'name', label: '班级名称', type: 'string', required: true }, + { key: 'grade', label: '年级', type: 'string' }, + { key: 'headTeacher', label: '班主任', type: 'string' }, + ], + relations: [ + { entityKey: 'student', via: 'class_student', requiredFor: ['class'] }, + { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + ], + }, + { + key: 'schedule', + name: '排课/日程', + description: '班级与教室的课程安排,考勤和教室日程依赖排课。', + searchTool: 'search_schedules', + requiredPermissions: ['schedule:view'], + fields: [ + { key: 'classId', label: '班级', type: 'number' }, + { key: 'classroomId', label: '教室', type: 'number' }, + { key: 'weekDay', label: '星期', type: 'number' }, + { key: 'startTime', label: '开始时间', type: 'string' }, + { key: 'endTime', label: '结束时间', type: 'string' }, + ], + relations: [ + { entityKey: 'class', via: 'class_schedule', requiredFor: ['schedule'] }, + { entityKey: 'classroom', via: 'class_schedule', requiredFor: ['schedule'] }, + ], + }, + { + key: 'attendance', + name: '考勤', + description: '按班级与日期的考勤记录(出勤/迟到/缺勤/请假)。', + searchTool: 'get_attendance_summary', + requiredPermissions: ['attendance:view'], + fields: [ + { key: 'studentId', label: '学生', type: 'number', required: true }, + { key: 'date', label: '日期', type: 'date', required: true }, + { key: 'status', label: '状态', type: 'enum', enumFrom: 'attendance.status' }, + ], + relations: [ + { entityKey: 'class', via: 'class_schedule', requiredFor: ['attendance'] }, + { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['attendance'] }, + ], + }, + { + key: 'exam', + name: '考试/成绩', + description: '考试安排与成绩记录,依赖班级与学生档案。', + searchTool: 'search_exams', + requiredPermissions: ['exam:view'], + fields: [ + { key: 'name', label: '考试名称', type: 'string', required: true }, + { key: 'date', label: '考试日期', type: 'date' }, + { key: 'subject', label: '科目', type: 'string' }, + ], + relations: [ + { entityKey: 'class', via: 'exam_score', requiredFor: ['exam'] }, + { entityKey: 'student', via: 'exam_score', requiredFor: ['exam'] }, + ], + }, + { + key: 'room', + name: '宿舍档案', + description: '宿舍/床位基础档案,入住登记的前置数据。', + searchTool: 'search_rooms', + requiredPermissions: ['room:view'], + fields: [ + { key: 'roomNumber', label: '宿舍号', type: 'string', required: true }, + { key: 'building', label: '楼栋', type: 'string' }, + { key: 'floor', label: '楼层', type: 'number' }, + { key: 'capacity', label: '容量', type: 'number', required: true }, + { key: 'roomType', label: '房型', type: 'string' }, + { key: 'monthlyRate', label: '月租金', type: 'number' }, + ], + relations: [ + { entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] }, + ], + }, + { + key: 'occupancy', + name: '入住记录', + description: '学生入住/换宿/退宿记录,费用与账单依赖入住状态。', + searchTool: 'get_room_occupancy_summary', + requiredPermissions: ['occupancy:view'], + fields: [ + { key: 'studentId', label: '学生', type: 'number', required: true }, + { key: 'roomId', label: '宿舍', type: 'number', required: true }, + { key: 'checkInDate', label: '入住日期', type: 'date', required: true }, + { key: 'billingStartDate', label: '计费开始日期', type: 'date', required: true }, + { key: 'stayType', label: '住宿类型', type: 'enum', enumFrom: 'occupancy.stayType' }, + ], + relations: [ + { entityKey: 'student', via: 'occupancy', requiredFor: ['checkin'] }, + { entityKey: 'room', via: 'occupancy', requiredFor: ['checkin'] }, + { entityKey: 'bill', via: 'bill', requiredFor: ['bill'] }, + ], + }, + { + key: 'expense', + name: '费用', + description: '公共费用与个人费用,是生成账单的基础。', + searchTool: 'search_expenses', + requiredPermissions: ['expense:view'], + fields: [ + { key: 'type', label: '费用类型', type: 'string', required: true }, + { key: 'amount', label: '金额', type: 'number', required: true }, + { key: 'periodStart', label: '费用开始日期', type: 'date' }, + { key: 'periodEnd', label: '费用结束日期', type: 'date' }, + ], + relations: [ + { entityKey: 'occupancy', via: 'expense', requiredFor: ['expense'] }, + { entityKey: 'bill', via: 'bill_item', requiredFor: ['bill'] }, + ], + }, + { + key: 'bill', + name: '账单', + description: '按学生与账期生成的账单(公共+个人费用分摊),支持确认与付款。', + searchTool: 'search_bills', + requiredPermissions: ['bill:view'], + fields: [ + { key: 'studentId', label: '学生', type: 'number', required: true }, + { key: 'periodStart', label: '账期开始', type: 'date', required: true }, + { key: 'periodEnd', label: '账期结束', type: 'date', required: true }, + { key: 'totalAmount', label: '总金额', type: 'number', required: true }, + { key: 'status', label: '状态', type: 'enum', enumFrom: 'bill.status' }, + ], + relations: [ + { entityKey: 'student', via: 'bill', requiredFor: ['bill'] }, + { entityKey: 'occupancy', via: 'bill', requiredFor: ['bill'] }, + { entityKey: 'deposit', via: 'deposit', requiredFor: ['deposit'] }, + ], + }, + { + key: 'deposit', + name: '押金', + description: '押金收取与退还记录,通常在账单确认后处理。', + searchTool: 'search_deposits', + requiredPermissions: ['deposit:view'], + fields: [ + { key: 'studentId', label: '学生', type: 'number', required: true }, + { key: 'amount', label: '金额', type: 'number', required: true }, + { key: 'status', label: '状态', type: 'enum', enumFrom: 'deposit.status' }, + ], + relations: [ + { entityKey: 'student', via: 'deposit', requiredFor: ['deposit'] }, + { entityKey: 'bill', via: 'deposit', requiredFor: ['deposit'] }, + ], + }, + { + key: 'classroom', + name: '教室档案', + description: '教室基础档案,租赁与教室日程的前置数据。', + searchTool: 'search_classrooms', + requiredPermissions: ['classroom:view'], + fields: [ + { key: 'name', label: '教室名称', type: 'string', required: true }, + { key: 'building', label: '楼栋', type: 'string' }, + { key: 'capacity', label: '容量', type: 'number' }, + ], + relations: [ + { entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] }, + { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + ], + }, + { + key: 'organization', + name: '组织/校区', + description: '校区与组织归属,租赁双方与档案归属依赖组织。', + requiredPermissions: ['organization:view'], + fields: [ + { key: 'name', label: '名称', type: 'string', required: true }, + { key: 'code', label: '编码', type: 'string' }, + { key: 'isHost', label: '是否本部', type: 'boolean' }, + ], + relations: [ + { entityKey: 'student', via: 'organization', requiredFor: ['profile'] }, + { entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] }, + ], + }, + { + key: 'rental', + name: '教室租赁', + description: '教室租赁订单与合同(合同字段在租赁记录上),依赖教室与组织。', + searchTool: 'search_classroom_rentals', + requiredPermissions: ['rental:view'], + fields: [ + { key: 'classroomId', label: '教室', type: 'number', required: true }, + { key: 'lesseeOrganizationId', label: '承租方', type: 'number' }, + { key: 'startDate', label: '开始日期', type: 'date', required: true }, + { key: 'endDate', label: '结束日期', type: 'date', required: true }, + { key: 'dailyRate', label: '日租金', type: 'number' }, + { key: 'contractPath', label: '合同文件', type: 'string' }, + ], + relations: [ + { entityKey: 'classroom', via: 'classroom_rental', requiredFor: ['rental'] }, + { entityKey: 'organization', via: 'classroom_rental', requiredFor: ['rental'] }, + { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + ], + }, +]; + +export const BUSINESS_WORKFLOWS: readonly BusinessWorkflow[] = [ + { + key: 'student_teaching', + name: '学生教学闭环', + description: '学生档案 → 分班 → 排课 → 考勤 → 考试/成绩。', + requiredPermissions: ['student:view'], + stages: [ + { key: 'profile', label: '学生档案', entities: ['student'] }, + { key: 'class', label: '分班', entities: ['class', 'student'] }, + { key: 'schedule', label: '排课', entities: ['schedule', 'class'] }, + { key: 'attendance', label: '考勤', entities: ['attendance', 'schedule', 'class'] }, + { key: 'exam', label: '考试/成绩', entities: ['exam', 'class', 'student'] }, + ], + prerequisites: { + class: ['profile'], + schedule: ['class'], + attendance: ['schedule'], + exam: ['class'], + }, + nextSteps: [ + { key: 'after_profile', label: '学生档案完成 → 建议分班', after: ['profile'] }, + { key: 'after_class', label: '分班完成 → 建议排课', after: ['class'] }, + { key: 'after_attendance', label: '考勤稳定 → 建议记录考试成绩', after: ['attendance'] }, + ], + }, + { + key: 'dormitory_billing', + name: '住宿计费闭环', + description: '学生/宿舍档案 → 入住 → 费用录入 → 生成账单 → 押金。', + requiredPermissions: ['occupancy:view'], + stages: [ + { key: 'profile', label: '学生档案', entities: ['student'] }, + { key: 'room', label: '宿舍档案', entities: ['room'] }, + { key: 'checkin', label: '入住/换宿/退宿', entities: ['occupancy', 'student', 'room'] }, + { key: 'expense', label: '费用录入', entities: ['expense', 'occupancy'] }, + { key: 'bill', label: '生成账单', entities: ['bill', 'occupancy', 'expense'] }, + { key: 'deposit', label: '押金', entities: ['deposit', 'bill', 'student'] }, + ], + prerequisites: { + checkin: ['profile', 'room'], + expense: ['checkin'], + bill: ['checkin', 'expense'], + deposit: ['bill'], + }, + nextSteps: [ + { key: 'after_checkin', label: '入住完成 → 建议录入本月公共/个人费用', after: ['checkin'] }, + { key: 'after_expense', label: '费用录入完成 → 建议生成并确认账单', after: ['expense'] }, + { key: 'after_bill', label: '账单确认 → 建议标记已付并处理押金', after: ['bill'] }, + ], + }, + { + key: 'classroom_rental', + name: '教室租赁闭环', + description: '教室/组织档案 → 租赁订单 → 合同 → 教室日程。', + requiredPermissions: ['rental:view'], + stages: [ + { key: 'classroom', label: '教室档案', entities: ['classroom'] }, + { key: 'organization', label: '组织/校区', entities: ['organization'] }, + { key: 'rental', label: '租赁订单', entities: ['rental', 'classroom', 'organization'] }, + { key: 'contract', label: '合同', entities: ['rental'] }, + { key: 'schedule', label: '教室日程', entities: ['schedule', 'classroom', 'rental'] }, + ], + prerequisites: { + rental: ['classroom', 'organization'], + contract: ['rental'], + schedule: ['rental'], + }, + nextSteps: [ + { key: 'after_rental', label: '租赁订单生成 → 建议补充合同', after: ['rental'] }, + { key: 'after_contract', label: '合同归档 → 建议排定教室日程', after: ['contract'] }, + ], + }, +]; + +export const BUSINESS_WORKFLOW_KEYS: readonly string[] = BUSINESS_WORKFLOWS.map( + (workflow) => workflow.key, +); diff --git a/apps/server/src/agent-context/business-context.service.spec.ts b/apps/server/src/agent-context/business-context.service.spec.ts new file mode 100644 index 0000000..b8ecc3c --- /dev/null +++ b/apps/server/src/agent-context/business-context.service.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from '@jest/globals'; +import { BusinessContextService } from './business-context.service'; + +function principal(permissions: string[] = [], isSuperAdmin = false) { + return { permissions, isSuperAdmin }; +} + +describe('BusinessContextService', () => { + it('registry 中所有工作流/实体/权限引用都有效', () => { + const service = new BusinessContextService(); + expect(service.validateRegistry()).toEqual([]); + expect(() => service.assertValidRegistry()).not.toThrow(); + }); + + it('超级管理员可查看全部工作流与实体', () => { + const service = new BusinessContextService(); + const result = service.getBusinessContext(principal([], true)); + expect(result.workflows.length).toBeGreaterThanOrEqual(3); + expect(result.entities.length).toBeGreaterThanOrEqual(10); + expect(result.workflows.map((item) => item.key)).toEqual( + expect.arrayContaining(['student_teaching', 'dormitory_billing', 'classroom_rental']), + ); + }); + + it('按权限过滤实体,且只暴露有权限的工作流', () => { + const service = new BusinessContextService(); + const result = service.getBusinessContext(principal(['student:view', 'class:view'])); + const entityKeys = result.entities.map((item) => item.key); + expect(entityKeys).toContain('student'); + expect(entityKeys).toContain('class'); + expect(entityKeys).not.toContain('bill'); + expect(result.workflows.map((item) => item.key)).toEqual(['student_teaching']); + }); + + it('workflowKey 聚焦时只返回对应工作流与实体', () => { + const service = new BusinessContextService(); + const result = service.getBusinessContext( + principal(['room:view', 'occupancy:view', 'expense:view', 'bill:view', 'student:view'], false), + 'dormitory_billing', + ); + expect(result.workflows.map((item) => item.key)).toEqual(['dormitory_billing']); + expect(result.entities.map((item) => item.key)).toEqual( + expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'student']), + ); + }); + + it('未知或不可见 workflowKey 返回空结果', () => { + const service = new BusinessContextService(); + expect(service.getBusinessContext(principal([], false), 'unknown_loop').workflows).toEqual([]); + expect( + service.getBusinessContext(principal(['student:view'], false), 'dormitory_billing').workflows, + ).toEqual([]); + }); + + it('getEntitySchema 返回字段字典与关系,未知/无权限实体返回 null', () => { + const service = new BusinessContextService(); + const student = service.getEntitySchema(principal(['student:view']), 'student'); + expect(student?.fields.map((field) => field.key)).toEqual( + expect.arrayContaining(['name', 'phone', 'gender', 'studentNo']), + ); + expect(student?.relations.some((relation) => relation.entityKey === 'class')).toBe(true); + expect(service.getEntitySchema(principal(['student:view']), 'bill')).toBeNull(); + expect(service.getEntitySchema(principal(['student:view']), 'unknown')).toBeNull(); + }); + + it('suggestNextSteps 只返回已完成阶段对应的下一步建议', () => { + const service = new BusinessContextService(); + const steps = service.suggestNextSteps(principal([], true), ['profile', 'checkin']); + const labels = steps.map((item) => item.label); + expect(labels).toEqual( + expect.arrayContaining([expect.stringContaining('费用')]), + ); + expect(labels.some((item) => item.includes('账单'))).toBe(false); + expect(service.suggestNextSteps(principal([], true), ['profile']).some((item) => item.after.includes('checkin'))).toBe(false); + const afterExpense = service.suggestNextSteps(principal([], true), [ + 'profile', + 'room', + 'checkin', + 'expense', + ]); + expect(afterExpense.map((item) => item.label)).toEqual( + expect.arrayContaining([expect.stringContaining('账单')]), + ); + }); +}); diff --git a/apps/server/src/agent-context/business-context.service.ts b/apps/server/src/agent-context/business-context.service.ts new file mode 100644 index 0000000..803e9d6 --- /dev/null +++ b/apps/server/src/agent-context/business-context.service.ts @@ -0,0 +1,180 @@ +import { Injectable } from '@nestjs/common'; +import { + BUSINESS_ENTITIES, + BUSINESS_WORKFLOWS, +} from './business-context.registry'; +import type { + BusinessContextPrincipal, + BusinessContextResult, + BusinessEntity, + BusinessWorkflow, + BusinessWorkflowNextStep, +} from './business-context.types'; + +function hasPermission( + principal: BusinessContextPrincipal, + requiredPermissions: readonly string[], +): boolean { + if (principal.isSuperAdmin) return true; + return requiredPermissions.some((permission) => principal.permissions.includes(permission)); +} + +/** + * 根据已完成阶段返回后续建议(按权限过滤),供 A2UI 提交回灌等场景直接使用。 + */ +export function suggestNextStepsForPrincipal( + principal: BusinessContextPrincipal, + completedStageKeys: readonly string[], +): BusinessWorkflowNextStep[] { + const completed = new Set(completedStageKeys); + const steps: BusinessWorkflowNextStep[] = []; + for (const workflow of BUSINESS_WORKFLOWS) { + if (!hasPermission(principal, workflow.requiredPermissions)) continue; + for (const step of workflow.nextSteps) { + if (!step.after.every((stageKey) => completed.has(stageKey))) continue; + if (step.permission && !hasPermission(principal, [step.permission])) continue; + steps.push(step); + } + } + return steps; +} + +/** + * 业务上下文服务:把代码内维护的实体字典与工作流元数据按权限暴露给 + * Agent(纯逻辑,不访问数据库)。 + */ +@Injectable() +export class BusinessContextService { + /** + * 校验注册表引用完整性,返回所有问题;空数组表示合法。 + */ + validateRegistry(): string[] { + const problems: string[] = []; + const entityKeys = new Set(BUSINESS_ENTITIES.map((entity) => entity.key)); + const stageKeys = new Set( + BUSINESS_WORKFLOWS.flatMap((workflow) => workflow.stages.map((stage) => stage.key)), + ); + + const duplicateEntities = BUSINESS_ENTITIES + .map((entity) => entity.key) + .filter((key, index, all) => all.indexOf(key) !== index); + if (duplicateEntities.length) { + problems.push(`实体 key 重复: ${duplicateEntities.join(', ')}`); + } + + for (const entity of BUSINESS_ENTITIES) { + if (!entity.name || !entity.description) { + problems.push(`实体 ${entity.key} 缺少 name/description`); + } + if (entity.requiredPermissions.length === 0) { + problems.push(`实体 ${entity.key} 缺少权限点`); + } + const fieldKeys = new Set(); + for (const field of entity.fields) { + if (fieldKeys.has(field.key)) { + problems.push(`实体 ${entity.key} 字段重复: ${field.key}`); + } + fieldKeys.add(field.key); + if (field.type === 'enum' && !field.enumFrom && !field.options) { + problems.push(`实体 ${entity.key} 枚举字段 ${field.key} 缺少 enumFrom/options`); + } + } + for (const relation of entity.relations) { + if (!entityKeys.has(relation.entityKey)) { + problems.push(`实体 ${entity.key} 关系引用未知实体: ${relation.entityKey}`); + } + for (const stageKey of relation.requiredFor) { + if (!stageKeys.has(stageKey)) { + problems.push(`实体 ${entity.key} 关系 ${relation.via} 引用未知阶段: ${stageKey}`); + } + } + } + } + + for (const workflow of BUSINESS_WORKFLOWS) { + if (workflow.requiredPermissions.length === 0) { + problems.push(`工作流 ${workflow.key} 缺少权限点`); + } + const workflowStageKeys = new Set(workflow.stages.map((stage) => stage.key)); + if (workflowStageKeys.size !== workflow.stages.length) { + problems.push(`工作流 ${workflow.key} 存在重复阶段`); + } + for (const stage of workflow.stages) { + for (const entityKey of stage.entities) { + if (!entityKeys.has(entityKey)) { + problems.push(`工作流 ${workflow.key} 阶段 ${stage.key} 引用未知实体: ${entityKey}`); + } + } + } + for (const [stageKey, prereqs] of Object.entries(workflow.prerequisites)) { + if (!workflowStageKeys.has(stageKey)) { + problems.push(`工作流 ${workflow.key} prerequisites 引用未知阶段: ${stageKey}`); + } + for (const prereq of prereqs) { + if (!workflowStageKeys.has(prereq)) { + problems.push(`工作流 ${workflow.key} 阶段 ${stageKey} 前置引用未知阶段: ${prereq}`); + } + } + } + for (const step of workflow.nextSteps) { + for (const afterKey of step.after) { + if (!workflowStageKeys.has(afterKey)) { + problems.push(`工作流 ${workflow.key} 下一步 ${step.key} 引用未知阶段: ${afterKey}`); + } + } + } + } + + return [...new Set(problems)]; + } + + assertValidRegistry(): void { + const problems = this.validateRegistry(); + if (problems.length > 0) { + throw new Error(`业务上下文注册表无效: ${problems.join('; ')}`); + } + } + + /** + * 返回当前主体可见的工作流,以及这些工作流阶段引用的实体集合。 + * 传 workflowKey 时只返回该工作流(不可见则返回空)。 + */ + getBusinessContext( + principal: BusinessContextPrincipal, + workflowKey?: string, + ): BusinessContextResult { + let workflows = BUSINESS_WORKFLOWS.filter((workflow) => + hasPermission(principal, workflow.requiredPermissions), + ); + if (workflowKey) { + workflows = workflows.filter((workflow) => workflow.key === workflowKey); + } + const entityKeys = new Set( + workflows.flatMap((workflow) => workflow.stages.flatMap((stage) => stage.entities)), + ); + const entities = BUSINESS_ENTITIES.filter( + (entity) => entityKeys.has(entity.key) && hasPermission(principal, entity.requiredPermissions), + ); + return { workflows, entities }; + } + + /** 返回指定实体的字段字典与关系;无权限或未知返回 null。 */ + getEntitySchema(principal: BusinessContextPrincipal, entityKey: string): BusinessEntity | null { + const entity = BUSINESS_ENTITIES.find((item) => item.key === entityKey); + if (!entity || !hasPermission(principal, entity.requiredPermissions)) return null; + return entity; + } + + /** + * 根据已完成阶段返回后续建议(按权限过滤)。 + */ + suggestNextSteps( + principal: BusinessContextPrincipal, + completedStageKeys: readonly string[], + ): BusinessWorkflowNextStep[] { + return suggestNextStepsForPrincipal(principal, completedStageKeys); + } +} + +export { BUSINESS_ENTITIES, BUSINESS_WORKFLOWS }; +export type { BusinessContextPrincipal, BusinessWorkflow }; diff --git a/apps/server/src/agent-context/business-context.tools.spec.ts b/apps/server/src/agent-context/business-context.tools.spec.ts new file mode 100644 index 0000000..df3c6cb --- /dev/null +++ b/apps/server/src/agent-context/business-context.tools.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import type { AgentToolContext } from '../agent-tools/agent-tool.types'; +import { AgentToolContextFactory } from '../agent-tools/agent-tool.types'; +import type { AuthenticatedUser } from '../authorization'; +import type { BusinessContextService } from './business-context.service'; +import { GetBusinessContextTool, GetEntitySchemaTool } from './get-business-context.tool'; +import { GetPendingTasksTool } from './get-pending-tasks.tool'; + +function context(permissions: string[] = []): AgentToolContext { + const user: AuthenticatedUser = { + id: 7, + username: 'ops', + permissions, + isSuperAdmin: false, + roles: [], + }; + return AgentToolContextFactory.fromAuthenticatedUser(user); +} + +describe('agent business context tools', () => { + it('get_business_context 校验 workflowKey 并透传主体与过滤条件', async () => { + const service = { + getBusinessContext: jest.fn().mockReturnValue({ workflows: [], entities: [] }), + } as unknown as BusinessContextService; + const tool = new GetBusinessContextTool(service); + expect(tool.requiredPermission).toBe('ai:chat:use'); + expect(tool.validate({ workflowKey: 123 }).ok).toBe(false); + expect(tool.validate({ workflowKey: 'x'.repeat(51) }).ok).toBe(false); + expect(tool.validate({ debug: true }).ok).toBe(false); + const parsed = tool.validate({ workflowKey: 'dormitory_billing' }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + const ctx = context(['occupancy:view']); + await tool.execute(parsed.value, ctx); + expect(service.getBusinessContext).toHaveBeenCalledWith( + { permissions: ['occupancy:view'], isSuperAdmin: false }, + 'dormitory_billing', + ); + }); + + it('get_entity_schema 必须提供存在的 entityKey', async () => { + const service = { + getEntitySchema: jest.fn().mockReturnValue({ key: 'student', name: '学生档案' }), + } as unknown as BusinessContextService; + const tool = new GetEntitySchemaTool(service); + expect(tool.validate({}).ok).toBe(false); + expect(tool.validate({ entityKey: '' }).ok).toBe(false); + expect(tool.validate({ entityKey: 'student', extra: 1 }).ok).toBe(false); + const parsed = tool.validate({ entityKey: 'student' }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + await tool.execute(parsed.value, context()); + expect(service.getEntitySchema).toHaveBeenCalledWith( + { permissions: [], isSuperAdmin: false }, + 'student', + ); + }); + + it('get_pending_tasks 只接受已知工作流 key', async () => { + const service = { + getPendingTasks: jest.fn().mockResolvedValue([]), + }; + const tool = new GetPendingTasksTool(service as never); + expect(tool.requiredPermission).toBe('ai:chat:use'); + expect(tool.validate({ workflowKey: 'unknown_loop' }).ok).toBe(false); + expect(tool.validate({ workflowKey: 'dormitory_billing', limit: 1 }).ok).toBe(false); + const parsed = tool.validate({ workflowKey: 'dormitory_billing' }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + await tool.execute(parsed.value, context(['bill:view'])); + expect(service.getPendingTasks).toHaveBeenCalledWith(expect.anything(), 'dormitory_billing'); + }); +}); diff --git a/apps/server/src/agent-context/business-context.types.ts b/apps/server/src/agent-context/business-context.types.ts new file mode 100644 index 0000000..3b0b419 --- /dev/null +++ b/apps/server/src/agent-context/business-context.types.ts @@ -0,0 +1,66 @@ +/** 调用方主体验证(来自可信的 AgentToolContext / AuthenticatedUser)。 */ +export interface BusinessContextPrincipal { + readonly permissions: readonly string[]; + readonly isSuperAdmin: boolean; +} + +export type BusinessEntityFieldType = 'string' | 'number' | 'boolean' | 'date' | 'enum'; + +export interface BusinessEntityField { + key: string; + label: string; + type: BusinessEntityFieldType; + required?: boolean; + /** 枚举值来源(如 gender、room.status),供 render_form 生成 options。 */ + enumFrom?: string; + options?: Array<{ label: string; value: string }>; + description?: string; +} + +export interface BusinessEntityRelation { + entityKey: string; + via: string; + /** 需要该关系已建立的工作流阶段 key。 */ + requiredFor: string[]; +} + +export interface BusinessEntity { + key: string; + name: string; + description: string; + searchTool?: string; + requiredPermissions: readonly string[]; + fields: readonly BusinessEntityField[]; + relations: readonly BusinessEntityRelation[]; +} + +export interface BusinessWorkflowStage { + key: string; + label: string; + entities: readonly string[]; +} + +export interface BusinessWorkflowNextStep { + key: string; + label: string; + /** 满足这些阶段完成后才建议该步骤。 */ + after: readonly string[]; + /** 可选权限点,未授权用户不返回该建议。 */ + permission?: string; +} + +export interface BusinessWorkflow { + key: string; + name: string; + description: string; + requiredPermissions: readonly string[]; + stages: readonly BusinessWorkflowStage[]; + /** stageKey -> 前置阶段 key 列表。 */ + prerequisites: Readonly>; + nextSteps: readonly BusinessWorkflowNextStep[]; +} + +export interface BusinessContextResult { + workflows: readonly BusinessWorkflow[]; + entities: readonly BusinessEntity[]; +} diff --git a/apps/server/src/agent-context/get-business-context.tool.ts b/apps/server/src/agent-context/get-business-context.tool.ts new file mode 100644 index 0000000..0a256fe --- /dev/null +++ b/apps/server/src/agent-context/get-business-context.tool.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tools/agent-tool.types'; +import { rejectUnknownKeys, optionalString } from '../agent-tools/tools/tool-input'; +import type { BusinessContextService } from './business-context.service'; + +interface GetBusinessContextInput { + workflowKey?: string; +} + +/** + * 让 Agent 获取当前角色可见的业务流程、实体字典与依赖规则。 + */ +@Injectable() +export class GetBusinessContextTool implements ToolDef { + readonly name = 'get_business_context'; + readonly skillKey = 'assistant'; + readonly requiredPermission = 'ai:chat:use'; + readonly description = + '获取当前账号可见的业务流程(学生教学/住宿计费/教室租赁)、实体字典、阶段依赖与下一步建议。写入或导入前先调用本工具确认前置数据要求。'; + readonly inputSchema = { + type: 'object', + properties: { + workflowKey: { + type: 'string', + description: '可选:聚焦某个闭环(student_teaching / dormitory_billing / classroom_rental)', + maxLength: 50, + }, + }, + additionalProperties: false, + }; + + constructor(private readonly service: BusinessContextService) {} + + validate(input: Record): ToolInputResult { + const invalid = rejectUnknownKeys(input, ['workflowKey']); + if (invalid) return invalid; + const workflowKey = optionalString(input.workflowKey, 'workflowKey', 50); + if (!workflowKey.ok) return workflowKey; + return { ok: true, value: { workflowKey: workflowKey.value } }; + } + + async execute(input: GetBusinessContextInput, context: AgentToolContext): Promise { + return this.service.getBusinessContext( + { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, + input.workflowKey, + ); + } +} + +/** + * 让 Agent 获取指定业务实体的字段字典与关系,用于生成准确表单。 + */ +@Injectable() +export class GetEntitySchemaTool implements ToolDef<{ entityKey: string }> { + readonly name = 'get_entity_schema'; + readonly skillKey = 'assistant'; + readonly requiredPermission = 'ai:chat:use'; + readonly description = + '获取指定业务实体(student/class/room/occupancy/expense/bill/deposit/classroom/organization/rental 等)的字段字典、枚举来源与关系,render_form 前可按需调用以生成正确字段。'; + readonly inputSchema = { + type: 'object', + properties: { + entityKey: { type: 'string', minLength: 1, maxLength: 50 }, + }, + required: ['entityKey'], + additionalProperties: false, + }; + + constructor(private readonly service: BusinessContextService) {} + + validate(input: Record): ToolInputResult<{ entityKey: string }> { + const invalid = rejectUnknownKeys(input, ['entityKey']); + if (invalid) return invalid; + if (typeof input.entityKey !== 'string' || !input.entityKey.trim()) { + return { ok: false, error: 'entityKey 必须是字符串' }; + } + const entityKey = input.entityKey.trim(); + if (entityKey.length > 50) { + return { ok: false, error: 'entityKey 长度不能超过 50' }; + } + return { ok: true, value: { entityKey } }; + } + + async execute(input: { entityKey: string }, context: AgentToolContext): Promise { + return this.service.getEntitySchema( + { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, + input.entityKey, + ); + } +} diff --git a/apps/server/src/agent-context/get-pending-tasks.tool.ts b/apps/server/src/agent-context/get-pending-tasks.tool.ts new file mode 100644 index 0000000..1f68b97 --- /dev/null +++ b/apps/server/src/agent-context/get-pending-tasks.tool.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tools/agent-tool.types'; +import { rejectUnknownKeys } from '../agent-tools/tools/tool-input'; +import { BUSINESS_WORKFLOW_KEYS } from './business-context.registry'; +import type { PendingTasksService } from './pending-tasks.service'; + +interface GetPendingTasksInput { + workflowKey?: string; +} + +/** + * 让 Agent 查询业务完成度(未分班、未入住、未出账单、临期租赁等), + * 用于核实前置数据与给出有数据支撑的下一步建议。 + */ +@Injectable() +export class GetPendingTasksTool implements ToolDef { + readonly name = 'get_pending_tasks'; + readonly skillKey = 'overview'; + readonly requiredPermission = 'ai:chat:use'; + readonly description = + '获取当前账号可见范围内的业务待办计数(未分班学生、已建档未入住学生、在住未生成账单、租赁缺合同/临期到期等)。写入或导入前用它核实前置数据是否齐备,完成后用它判断后续待办。'; + readonly inputSchema = { + type: 'object', + properties: { + workflowKey: { + type: 'string', + description: '可选:聚焦某个闭环(student_teaching / dormitory_billing / classroom_rental)', + enum: [...BUSINESS_WORKFLOW_KEYS], + }, + }, + additionalProperties: false, + }; + + constructor(private readonly service: PendingTasksService) {} + + validate(input: Record): ToolInputResult { + const invalid = rejectUnknownKeys(input, ['workflowKey']); + if (invalid) return invalid; + if (input.workflowKey === undefined) return { ok: true, value: {} }; + if ( + typeof input.workflowKey !== 'string' || + !(BUSINESS_WORKFLOW_KEYS as readonly string[]).includes(input.workflowKey) + ) { + return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` }; + } + return { ok: true, value: { workflowKey: input.workflowKey } }; + } + + execute(input: GetPendingTasksInput, context: AgentToolContext): Promise { + return this.service.getPendingTasks(context, input.workflowKey); + } +} diff --git a/apps/server/src/agent-context/index.ts b/apps/server/src/agent-context/index.ts new file mode 100644 index 0000000..88564af --- /dev/null +++ b/apps/server/src/agent-context/index.ts @@ -0,0 +1,12 @@ +export { BusinessContextService } from './business-context.service'; +export { BUSINESS_ENTITIES, BUSINESS_WORKFLOWS } from './business-context.registry'; +export type { + BusinessContextPrincipal, + BusinessContextResult, + BusinessEntity, + BusinessEntityField, + BusinessEntityRelation, + BusinessWorkflow, + BusinessWorkflowNextStep, + BusinessWorkflowStage, +} from './business-context.types'; diff --git a/apps/server/src/agent-context/pending-tasks.service.spec.ts b/apps/server/src/agent-context/pending-tasks.service.spec.ts new file mode 100644 index 0000000..fa54f15 --- /dev/null +++ b/apps/server/src/agent-context/pending-tasks.service.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import type { DataSource } from 'typeorm'; +import { AgentToolContextFactory, type AgentToolContext } from '../agent-tools/agent-tool.types'; +import type { AuthenticatedUser } from '../authorization'; +import type { StudentAccessScopeFactory } from '../students/student-access-scope.factory'; +import { PendingTasksService } from './pending-tasks.service'; + +function context(permissions: string[], isSuperAdmin = false): AgentToolContext { + const user: AuthenticatedUser = { + id: 7, + username: 'ops', + permissions, + isSuperAdmin, + roles: [], + }; + return AgentToolContextFactory.fromAuthenticatedUser(user); +} + +function createService( + scopeType: 'manageAll' | 'teacher', + queryMock: jest.Mock>>>, +) { + const dataSource = { query: queryMock } as unknown as DataSource; + const scopeFactory = { + buildScope: jest.fn().mockReturnValue( + scopeType === 'manageAll' + ? { type: 'manageAll' } + : { type: 'teacher', userId: 7 }, + ), + } as unknown as StudentAccessScopeFactory; + return new PendingTasksService(dataSource, scopeFactory); +} + +const ROW = (cnt: unknown) => [{ cnt }] as Record[]; + +describe('PendingTasksService', () => { + it('manageAll 权限下返回全部待办计数', async () => { + const query = jest.fn().mockImplementation(async (sql: string) => { + if (sql.includes('NOT EXISTS') && sql.includes('class_student')) return ROW('3'); + if (sql.includes('LEFT JOIN occupancies o')) return ROW('5'); + if (sql.includes('FROM occupancies o')) return ROW('8'); + if (sql.includes('classroom_rentals r') && sql.includes('contract_path IS NULL')) return ROW('2'); + if (sql.includes('classroom_rentals r') && sql.includes('end_date BETWEEN')) return ROW('4'); + return ROW('0'); + }); + const service = createService('manageAll', query); + const tasks = await service.getPendingTasks( + context([ + 'student:view', + 'class:view', + 'occupancy:view', + 'bill:view', + 'rental:view', + ]), + ); + const byKey = Object.fromEntries(tasks.map((task) => [task.key, task])); + expect(byKey.students_without_class?.count).toBe(3); + expect(byKey.students_without_checkin?.count).toBe(5); + expect(byKey.occupancies_without_bill?.count).toBe(8); + expect(byKey.rentals_without_contract?.count).toBe(2); + expect(byKey.rentals_ending_soon?.count).toBe(4); + expect(byKey.students_without_class?.restricted).toBeUndefined(); + }); + + it('teacher 范围只统计本人班级学生,且未分班任务标记 restricted', async () => { + const query = jest.fn().mockImplementation(async (sql: string, params: unknown[]) => { + expect(sql).toContain('cs.class_id IN'); + expect(params).toEqual([7]); + if (sql.includes('LEFT JOIN occupancies o')) return ROW('2'); + if (sql.includes('FROM occupancies o')) return ROW('1'); + return ROW('0'); + }); + const service = createService('teacher', query); + const tasks = await service.getPendingTasks( + context(['student:view', 'occupancy:view', 'bill:view']), + ); + const byKey = Object.fromEntries(tasks.map((task) => [task.key, task])); + expect(byKey.students_without_class).toMatchObject({ count: null, restricted: true }); + expect(byKey.students_without_checkin?.count).toBe(2); + expect(byKey.occupancies_without_bill?.count).toBe(1); + expect(byKey.rentals_without_contract).toBeUndefined(); + }); + + it('workflowKey 只返回该闭环的待办', async () => { + const query = jest.fn().mockResolvedValue(ROW('0')); + const service = createService('manageAll', query); + const tasks = await service.getPendingTasks( + context([], true), + 'classroom_rental', + ); + expect(tasks.map((task) => task.key)).toEqual([ + 'rentals_without_contract', + 'rentals_ending_soon', + ]); + }); + + it('缺少权限的任务不返回', async () => { + const query = jest.fn().mockResolvedValue(ROW('0')); + const service = createService('manageAll', query); + const tasks = await service.getPendingTasks(context(['student:view'])); + expect(tasks.map((task) => task.key)).toEqual(['students_without_class']); + }); +}); diff --git a/apps/server/src/agent-context/pending-tasks.service.ts b/apps/server/src/agent-context/pending-tasks.service.ts new file mode 100644 index 0000000..41db622 --- /dev/null +++ b/apps/server/src/agent-context/pending-tasks.service.ts @@ -0,0 +1,183 @@ +import { Injectable } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; +import type { AgentToolContext } from '../agent-tools/agent-tool.types'; +import type { StudentAccessScope } from '../students/student-access-scope'; +import { StudentAccessScopeFactory } from '../students/student-access-scope.factory'; + +export interface PendingTask { + key: string; + label: string; + entity: string; + permission: string; + workflowKeys: string[]; + count: number | null; + restricted?: boolean; + detail?: string; +} + +interface TaskDefinition { + key: string; + label: string; + entity: string; + permission: string; + workflowKeys: string[]; + teacherRestricted?: boolean; + sql: (scope: StudentAccessScope) => { sql: string; params: unknown[] }; +} + +function can(context: AgentToolContext, permission: string): boolean { + return context.isSuperAdmin || context.permissions.includes(permission); +} + +function today(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function inDays(days: number): string { + const date = new Date(); + date.setDate(date.getDate() + days); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +const TASKS: readonly TaskDefinition[] = [ + { + key: 'students_without_class', + label: '未分班学生', + entity: 'student', + permission: 'student:view', + workflowKeys: ['student_teaching'], + teacherRestricted: true, + sql: () => ({ + sql: ` + SELECT COUNT(*) AS cnt FROM students s + WHERE s.status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM class_student cs + WHERE cs.student_id = s.id AND cs.status = 'active' + ) + `, + params: [], + }), + }, + { + key: 'students_without_checkin', + label: '已建档未入住学生', + entity: 'occupancy', + permission: 'occupancy:view', + workflowKeys: ['dormitory_billing'], + sql: (scope) => ({ + sql: ` + SELECT COUNT(DISTINCT s.id) AS cnt FROM students s + INNER JOIN class_student cs ON cs.student_id = s.id AND cs.status = 'active' + LEFT JOIN occupancies o ON o.student_id = s.id AND o.status = 'active' + WHERE s.status = 'active' + ${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''} + AND o.id IS NULL + `, + params: scope.type === 'teacher' ? [scope.userId] : [], + }), + }, + { + key: 'occupancies_without_bill', + label: '在住但未生成账单', + entity: 'bill', + permission: 'bill:view', + workflowKeys: ['dormitory_billing'], + sql: (scope) => ({ + sql: ` + SELECT COUNT(DISTINCT o.id) AS cnt FROM occupancies o + ${scope.type === 'teacher' ? 'INNER JOIN class_student cs ON cs.student_id = o.student_id AND cs.status = \'active\'' : ''} + LEFT JOIN bills b ON b.student_id = o.student_id + WHERE o.status = 'active' + ${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''} + AND b.id IS NULL + `, + params: scope.type === 'teacher' ? [scope.userId] : [], + }), + }, + { + key: 'rentals_without_contract', + label: '租赁未归档合同', + entity: 'rental', + permission: 'rental:view', + workflowKeys: ['classroom_rental'], + sql: () => ({ + sql: ` + SELECT COUNT(*) AS cnt FROM classroom_rentals r + WHERE r.status = 'active' AND r.contract_path IS NULL + `, + params: [], + }), + }, + { + key: 'rentals_ending_soon', + label: '七天内到期租赁', + entity: 'rental', + permission: 'rental:view', + workflowKeys: ['classroom_rental'], + sql: () => ({ + sql: ` + SELECT COUNT(*) AS cnt FROM classroom_rentals r + WHERE r.status = 'active' AND r.end_date BETWEEN ? AND ? + `, + params: [today(), inDays(7)], + }), + }, +]; + +/** + * 业务完成度查询:按权限与数据范围返回“待办”计数,供 Agent 在 + * 写入/导入前核实前置数据、完成后给出有数据支撑的下一步建议。 + */ +@Injectable() +export class PendingTasksService { + constructor( + private readonly dataSource: DataSource, + private readonly scopeFactory: StudentAccessScopeFactory, + ) {} + + async getPendingTasks( + context: AgentToolContext, + workflowKey?: string, + ): Promise { + const scope = this.scopeFactory.buildScope(context); + const tasks: PendingTask[] = []; + for (const definition of TASKS) { + if (!can(context, definition.permission)) continue; + if (workflowKey && !definition.workflowKeys.includes(workflowKey)) continue; + if (definition.teacherRestricted && scope.type === 'teacher') { + tasks.push({ + key: definition.key, + label: definition.label, + entity: definition.entity, + permission: definition.permission, + workflowKeys: definition.workflowKeys, + count: null, + restricted: true, + detail: '当前角色数据范围不适合统计该待办,建议由教务/运营角色处理', + }); + continue; + } + const { sql, params } = definition.sql(scope); + const rows = await this.dataSource.query(sql, params); + const count = Number((rows as Array>)[0]?.cnt ?? 0); + tasks.push({ + key: definition.key, + label: definition.label, + entity: definition.entity, + permission: definition.permission, + workflowKeys: definition.workflowKeys, + count, + }); + } + return tasks; + } + +} diff --git a/apps/server/src/agent-tools/agent-skill.catalog.ts b/apps/server/src/agent-tools/agent-skill.catalog.ts index dbeab4b..a956ec1 100644 --- a/apps/server/src/agent-tools/agent-skill.catalog.ts +++ b/apps/server/src/agent-tools/agent-skill.catalog.ts @@ -4,8 +4,8 @@ export const AGENT_SKILLS: readonly Omit[] = [ { key: 'overview', name: '经营总览', - description: '查看当前权限范围内的学生、班级和今日考勤概览。', - examples: ['今天整体运营情况怎么样?', '帮我汇总当前学生和班级数量'], + description: '查看当前权限范围内的学生、班级和今日考勤概览,以及业务待办(未分班、未入住、未出账单、临期租赁等)。', + examples: ['今天整体运营情况怎么样?', '有哪些业务待办需要处理?', '帮我汇总当前学生和班级数量'], }, { key: 'student', @@ -22,20 +22,20 @@ export const AGENT_SKILLS: readonly Omit[] = [ { key: 'dormitory', name: '宿舍管理', - description: '查询宿舍、入住数量和空余床位。', - examples: ['哪些房间还有空床?', '汇总当前宿舍入住情况'], + description: '查询宿舍、入住数量和空余床位,并按“学生/宿舍档案 → 入住 → 费用 → 账单 → 押金”闭环引导。', + examples: ['哪些房间还有空床?', '汇总当前宿舍入住情况', '我可以按 先学生、再入住、后账单 帮你完成'], }, { key: 'billing', name: '账单查询', - description: '查询账单编号、账期、金额和状态。', - examples: ['查找本月未支付账单', '查询张同学最近的账单'], + description: '查询账单编号、账期、金额和状态,并在入住/费用完成后建议生成与确认账单。', + examples: ['查找本月未支付账单', '查询张同学最近的账单', '哪些在住学生还没生成账单?'], }, { key: 'classroom', name: '教室与租用', - description: '查询教室信息、占用状态和租赁订单。', - examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?'], + description: '查询教室信息、占用状态和租赁订单,并按“教室/组织档案 → 租赁 → 合同 → 日程”闭环引导。', + examples: ['哪些教室空闲?', '本月教室租赁订单有哪些?', '哪些租赁还没归档合同?'], }, { key: 'sync', diff --git a/apps/server/src/agent-tools/agent-tools.module.ts b/apps/server/src/agent-tools/agent-tools.module.ts index 9afde4f..872a740 100644 --- a/apps/server/src/agent-tools/agent-tools.module.ts +++ b/apps/server/src/agent-tools/agent-tools.module.ts @@ -32,6 +32,10 @@ import { ExpensesModule } from '../expenses/expenses.module'; import { ClassroomsModule } from '../classrooms/classrooms.module'; import { ClassroomRentalsModule } from '../classroom-rentals/classroom-rentals.module'; import { SyncModule } from '../sync/sync.module'; +import { BusinessContextService } from '../agent-context/business-context.service'; +import { PendingTasksService } from '../agent-context/pending-tasks.service'; +import { GetBusinessContextTool, GetEntitySchemaTool } from '../agent-context/get-business-context.tool'; +import { GetPendingTasksTool } from '../agent-context/get-pending-tasks.tool'; /** * Agent Tools feature module. @@ -66,6 +70,11 @@ import { SyncModule } from '../sync/sync.module'; providers: [ AgentToolRegistry, AgentToolExecutor, + BusinessContextService, + PendingTasksService, + GetBusinessContextTool, + GetEntitySchemaTool, + GetPendingTasksTool, SearchStudentsTool, GetStudentBasicTool, AgentBusinessScopeFactory, @@ -107,9 +116,15 @@ export class AgentToolsModule implements OnModuleInit { private readonly searchClassroomsTool: SearchClassroomsTool, private readonly searchClassroomRentalsTool: SearchClassroomRentalsTool, private readonly getSyncStatusTool: GetSyncStatusTool, + private readonly businessContextTool: GetBusinessContextTool, + private readonly entitySchemaTool: GetEntitySchemaTool, + private readonly pendingTasksTool: GetPendingTasksTool, ) {} onModuleInit(): void { + this.registry.register(this.businessContextTool); + this.registry.register(this.entitySchemaTool); + this.registry.register(this.pendingTasksTool); this.registry.register(this.searchTool); this.registry.register(this.getTool); this.registry.register(this.searchClassesTool); diff --git a/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts b/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts new file mode 100644 index 0000000..560439f --- /dev/null +++ b/apps/server/src/ai-chat/ai-a2ui-submissions.service.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; + +function createService(overrides: Record = {}) { + const repo = { + findOne: jest.fn(), + save: jest.fn(async (value) => value), + create: jest.fn((value) => value), + ...overrides, + }; + return { service: new A2uiSubmissionsService(repo as never), repo }; +} + +const submission = { + id: 1, + artifactId: 'form-1', + clientRequestId: '4d5c1c2a-1111-4222-8333-444455556666', + status: 'created', + resultJson: '{"ok":true}', + createdAt: new Date(), +}; + +describe('A2uiSubmissionsService', () => { + it('recordSubmission 首次提交创建记录并标记 created', async () => { + const { service, repo } = createService({ + findOne: jest.fn().mockResolvedValue(null), + }); + const result = await service.recordSubmission({ + artifactId: submission.artifactId, + clientRequestId: submission.clientRequestId, + status: 'created', + resultJson: submission.resultJson, + }); + expect(result.created).toBe(true); + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ + artifactId: submission.artifactId, + clientRequestId: submission.clientRequestId, + }), + ); + }); + + it('recordSubmission 同一 clientRequestId 重复提交返回既有记录且不重复创建', async () => { + const { service, repo } = createService({ + findOne: jest.fn().mockResolvedValue(submission), + }); + const result = await service.recordSubmission({ + artifactId: submission.artifactId, + clientRequestId: submission.clientRequestId, + status: 'created', + resultJson: '{"ok":false}', + }); + expect(result.created).toBe(false); + expect(result.submission).toEqual(submission); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('findSubmission 透传查询条件', async () => { + const { service, repo } = createService({ + findOne: jest.fn().mockResolvedValue(submission), + }); + await expect( + service.findSubmission(submission.artifactId, submission.clientRequestId), + ).resolves.toEqual(submission); + expect(repo.findOne).toHaveBeenCalledWith({ + where: { artifactId: submission.artifactId, clientRequestId: submission.clientRequestId }, + }); + }); +}); diff --git a/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts b/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts new file mode 100644 index 0000000..ad2a706 --- /dev/null +++ b/apps/server/src/ai-chat/ai-a2ui-submissions.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AiA2uiSubmission } from './entities/ai-a2ui-submission.entity'; + +export interface RecordSubmissionInput { + artifactId: string; + clientRequestId: string; + status: string; + resultJson?: string | null; +} + +/** + * A2UI 提交幂等服务。 + */ +@Injectable() +export class A2uiSubmissionsService { + constructor( + @InjectRepository(AiA2uiSubmission) + private readonly submissions: Repository, + ) {} + + async recordSubmission(input: RecordSubmissionInput): Promise<{ + created: boolean; + submission: AiA2uiSubmission; + }> { + const existing = await this.findSubmission(input.artifactId, input.clientRequestId); + if (existing) return { created: false, submission: existing }; + const submission = await this.submissions.save( + this.submissions.create({ + artifactId: input.artifactId, + clientRequestId: input.clientRequestId, + status: input.status, + resultJson: input.resultJson ?? null, + }), + ); + return { created: true, submission }; + } + + async findSubmission( + artifactId: string, + clientRequestId: string, + ): Promise { + return this.submissions.findOne({ + where: { artifactId, clientRequestId }, + }); + } +} diff --git a/apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts b/apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts new file mode 100644 index 0000000..daf460f --- /dev/null +++ b/apps/server/src/ai-chat/ai-a2ui.artifact.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from '@jest/globals'; +import { buildA2uiArtifact } from './ai-a2ui.artifact'; + +describe('buildA2uiArtifact', () => { + it('构造统一 artifact 载荷(id/type/status/messageId/conversationId/payload)', () => { + const artifact = buildA2uiArtifact({ + type: 'form', + id: 'form-1', + status: 'pending', + messageId: 12, + conversationId: 3, + payload: { title: '新增学生' }, + }); + expect(artifact).toEqual({ + id: 'form-1', + type: 'form', + status: 'pending', + messageId: 12, + conversationId: 3, + payload: { title: '新增学生' }, + }); + }); + + it('未知类型被拒绝', () => { + expect(() => + buildA2uiArtifact({ + type: 'hacker' as never, + id: 'x', + status: 'pending', + messageId: 1, + conversationId: 1, + payload: {}, + }), + ).toThrow(); + }); +}); diff --git a/apps/server/src/ai-chat/ai-a2ui.artifact.ts b/apps/server/src/ai-chat/ai-a2ui.artifact.ts new file mode 100644 index 0000000..11689fd --- /dev/null +++ b/apps/server/src/ai-chat/ai-a2ui.artifact.ts @@ -0,0 +1,58 @@ +export type A2uiArtifactType = + | 'form' + | 'review' + | 'chart' + | 'import_preflight' + | 'import_wizard'; + +export type A2uiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled'; + +export interface A2uiArtifact { + id: string; + type: A2uiArtifactType; + status: A2uiArtifactStatus; + messageId: number; + conversationId: number; + payload: T; + createdAt?: string | null; + submittedAt?: string | null; + supersededBy?: string | null; +} + +const A2UI_ARTIFACT_TYPES = new Set([ + 'form', + 'review', + 'chart', + 'import_preflight', + 'import_wizard', +]); + +/** + * 构造统一的 A2UI artifact 载荷,供 `ui.artifact` SSE 事件与前端归一化。 + */ +export function buildA2uiArtifact(input: { + type: A2uiArtifactType; + id: string; + status: A2uiArtifactStatus; + messageId: number; + conversationId: number; + payload: T; + createdAt?: string | null; + submittedAt?: string | null; + supersededBy?: string | null; +}): A2uiArtifact { + if (!A2UI_ARTIFACT_TYPES.has(input.type)) { + throw new Error(`未知 A2UI artifact 类型: ${String(input.type)}`); + } + return { + id: input.id, + type: input.type, + status: input.status, + messageId: input.messageId, + conversationId: input.conversationId, + payload: input.payload, + ...(input.createdAt !== undefined ? { createdAt: input.createdAt } : {}), + ...(input.submittedAt !== undefined ? { submittedAt: input.submittedAt } : {}), + ...(input.supersededBy !== undefined ? { supersededBy: input.supersededBy } : {}), + }; +} diff --git a/apps/server/src/ai-chat/ai-chat.constants.ts b/apps/server/src/ai-chat/ai-chat.constants.ts index 746366f..5afa881 100644 --- a/apps/server/src/ai-chat/ai-chat.constants.ts +++ b/apps/server/src/ai-chat/ai-chat.constants.ts @@ -107,7 +107,7 @@ export const A2UI_TOOL_SCHEMAS = [ function: { name: 'render_form', description: - '生成一个确认表单显示给用户填写。当用户需要新增或修改业务数据、或需要用户输入/确认信息时调用;用户提交表单后才能执行写操作。', + '生成一个确认表单显示给用户填写。当用户需要新增或修改业务数据、或需要用户输入/确认信息时调用;用户提交表单后才能执行写操作。需要准确字段/枚举时,可先调用 get_entity_schema 获取实体字段字典。', parameters: { type: 'object', properties: { @@ -211,9 +211,10 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须 当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 render_chart 生成图表卡片(chartType 支持 line 折线/bar 柱状/pie 饼图/area 面积/scatter 散点/radar 雷达/gauge 仪表盘/funnel 漏斗,columns+rows 表格数据)。 上传的 Office 附件:上传时系统已自动提取附件文本并随消息提供(Excel 为“工作表名 + tab 分隔行”的文本,Word/PPT 为提取的文本),直接基于这些文本核对表头与数据、回答用户问题即可;没有单独的附件解析工具,不需要(也无法)主动读取附件原始文件。批量导入前如不确定列名,先调用 preflight_import(内部会解析文件并给出列映射、分阶段统计与错误样本),再向用户确认并生成导入向导。 业务工作流引导(重要): -- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织。常见闭环:学生、宿舍、教室、组织等基础档案先行;再建立分班、入住、租赁等关系;之后才有考勤、费用等运行数据;最后生成账单、押金等结算。 -- 执行任何写入或导入前,先判断该操作依赖的前置数据是否已存在(可用查询工具核实):入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。 -- 导入或录入完成后,主动给出下一步建议(例如:入住导入完成 → 建议录入本月公共费用 → 生成并确认账单;学生导入完成 → 建议分班或排课)。 +- 系统业务按“基础档案 → 业务关系 → 运行数据 → 结算”组织,包含三大闭环:学生教学(学生→分班→排课→考勤→考试)、住宿计费(学生/宿舍→入住→费用→账单→押金)、教室租赁(教室/组织→租赁→合同→日程)。 +- 不确定当前角色可用哪些业务流程与实体时,先调用 get_business_context 获取权限范围内的闭环、阶段依赖与实体字典;编写 render_form 字段前可按需调用 get_entity_schema。 +- 执行任何写入或导入前,先调用 get_pending_tasks 或现有查询工具核实前置数据是否已存在:入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。 +- 导入或录入完成后,根据完成阶段主动给出下一步建议(例如:入住完成 → 建议录入本月公共费用 → 生成并确认账单;学生档案完成 → 建议分班;租赁订单生成 → 建议补充合同),可用 get_pending_tasks 获取有数据支撑的待办。 - 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。 - 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。 不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`; diff --git a/apps/server/src/ai-chat/ai-chat.module.ts b/apps/server/src/ai-chat/ai-chat.module.ts index b2319b3..3578137 100644 --- a/apps/server/src/ai-chat/ai-chat.module.ts +++ b/apps/server/src/ai-chat/ai-chat.module.ts @@ -8,11 +8,13 @@ import { AiAttachmentService } from './ai-attachment.service'; import { AiChartService } from './ai-chart.service'; import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; +import { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiReviewService } from './ai-review.service'; import { AiChatService } from './ai-chat.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { AiAttachment, + AiA2uiSubmission, AiConversation, AiForm, AiMessage, @@ -24,6 +26,7 @@ import { imports: [ TypeOrmModule.forFeature([ AiAttachment, + AiA2uiSubmission, AiConversation, AiForm, AiMessage, @@ -40,6 +43,7 @@ import { AiChartService, AiExcelReaderService, AiFormService, + A2uiSubmissionsService, AiReviewService, AiChatService, AiModelStreamService, diff --git a/apps/server/src/ai-chat/ai-chat.service-base.ts b/apps/server/src/ai-chat/ai-chat.service-base.ts index 20b633b..33bdd5f 100644 --- a/apps/server/src/ai-chat/ai-chat.service-base.ts +++ b/apps/server/src/ai-chat/ai-chat.service-base.ts @@ -12,6 +12,7 @@ import { AiChartService } from './ai-chart.service'; import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; +import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { @@ -114,6 +115,7 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { readonly chartService: AiChartService, readonly abilityFactory: CaslAbilityFactory, readonly authorization: AuthorizationService, + readonly a2uiSubmissions?: A2uiSubmissionsService, readonly excelReader?: AiExcelReaderService, readonly importsService?: ImportsService, readonly opLog?: OperationLogsService, @@ -127,7 +129,12 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { return a2uiReviewSubmitInfo(metadata); } - buildFormSubmitModelContent(submit: { title: string; values: Record }): string { + buildFormSubmitModelContent(submit: { + title: string; + values: Record; + submissionId?: string; + fieldErrors?: Array<{ field: string; message: string }>; + }): string { return buildFormSubmitModelContent(submit); } @@ -135,6 +142,8 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { reviewId: string; reviewTitle: string; resultMessage: string; + submissionId?: string; + nextSteps?: Array<{ key: string; label: string }>; }): string { return buildReviewSubmitModelContent(submit); } diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index 99c7449..9b2fb64 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -53,6 +53,7 @@ function createService( {} as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -65,6 +66,7 @@ function createService( } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); return { service, conversations, reviewService }; } @@ -295,6 +297,7 @@ describe('AiChatService', () => { } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -314,6 +317,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; const run = service.streamMessage( @@ -456,6 +460,7 @@ describe('AiChatService', () => { } as never, { createForm: jest.fn().mockResolvedValue(formShape), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -475,6 +480,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; @@ -600,6 +606,7 @@ describe('AiChatService', () => { } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -619,6 +626,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; @@ -705,6 +713,7 @@ describe('AiChatService', () => { } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -727,6 +736,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission, canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const emitted: Array<{ event: string }> = []; @@ -842,6 +852,7 @@ describe('AiChatService', () => { } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -864,6 +875,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission, canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const order: string[] = []; const emitted: Array<{ event: string; data: Record }> = []; @@ -1114,6 +1126,7 @@ describe('AiChatService', () => { { removeOrphans } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -1133,6 +1146,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); await expect(service.deleteMessage(7, 3, 10)).resolves.toEqual({ deletedIds: [10, 11] }); @@ -1154,6 +1168,7 @@ describe('AiChatService', () => { {} as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -1173,6 +1188,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); (service as unknown as { activeConversations: Set }).activeConversations.add(3); await expect(service.deleteMessage(7, 3, 10)).rejects.toBeInstanceOf(ConflictException); @@ -1238,6 +1254,7 @@ describe('AiChatService', () => { { removeOrphans } as never, { createForm: jest.fn(), + expirePreviousForms: jest.fn().mockResolvedValue([]), findOwnedPending: jest.fn(), validateValues: jest.fn(), markSubmitted: jest.fn(), @@ -1257,6 +1274,7 @@ describe('AiChatService', () => { } as never, { createForUser: jest.fn().mockReturnValue({}) } as never, { assertPermission: jest.fn(), canPermission: jest.fn() } as never, + { recordSubmission: jest.fn().mockResolvedValue({ created: true, submission: { id: 1 } }), findSubmission: jest.fn() } as never, ); const emitted: Array<{ event: string; data: Record }> = []; diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index 3899efa..8f71896 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -14,6 +14,7 @@ import { AiChartService } from './ai-chart.service'; import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; +import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AiConversation, AiMessage, AiToolRun } from './entities'; @@ -66,6 +67,7 @@ export class AiChatService extends AiChatServiceBase { chartService: AiChartService, abilityFactory: CaslAbilityFactory, authorization: AuthorizationService, + a2uiSubmissions?: A2uiSubmissionsService, excelReader?: AiExcelReaderService, importsService?: ImportsService, opLog?: OperationLogsService, @@ -84,6 +86,7 @@ export class AiChatService extends AiChatServiceBase { chartService, abilityFactory, authorization, + a2uiSubmissions, excelReader, importsService, opLog, diff --git a/apps/server/src/ai-chat/ai-chat.submissions.flow.ts b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts index 1251cd3..a0e743f 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.flow.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.flow.ts @@ -1,9 +1,22 @@ import type { AiChatServiceContext, AiSseEmitter } from './ai-chat.types'; import { DEFAULT_TITLE } from './ai-chat.types'; import type { AuthenticatedUser } from '../authorization'; +import { suggestNextStepsForPrincipal } from '../agent-context/business-context.service'; +import { buildA2uiArtifact } from './ai-a2ui.artifact'; +import { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; +import { AiA2uiSubmission } from './entities'; import { assertReviewImportPermissions } from './ai-chat.review-confirm'; import { persistExchange, runGenerationAndRelease } from './ai-chat.submissions.runtime'; +function submissionsService( + context: AiChatServiceContext, +): A2uiSubmissionsService { + return ( + context.a2uiSubmissions ?? + new A2uiSubmissionsService(context.dataSource.getRepository(AiA2uiSubmission)) + ); +} + export async function submitForm( context: AiChatServiceContext, user: AuthenticatedUser, @@ -19,6 +32,18 @@ export async function submitForm( const effectiveSkillKey = conversation.lockedSkillKey ?? null; context.assertSkillAvailable(user, effectiveSkillKey); + const submissions = submissionsService(context); + const recorded = await submissions.recordSubmission({ + artifactId: formId, + clientRequestId: dto.clientRequestId, + status: 'created', + }); + if (!recorded.created) { + emit('error', { message: '该表单已提交过,请勿重复提交' }); + emit('done', {}); + return; + } + await context.acquireConversation(conversation.id); try { const summary = `已提交表单「${form.title}」`; @@ -31,7 +56,15 @@ export async function submitForm( summary, dto.clientRequestId, effectiveSkillKey, - { a2uiSubmit: { formId: form.id, formTitle: form.title, values } }, + { + a2uiSubmit: { + formId: form.id, + formTitle: form.title, + values, + submissionId: String(recorded.submission.id), + fieldErrors: [], + }, + }, undefined, conversation.title === DEFAULT_TITLE ? form.title.slice(0, 30) : undefined, ), @@ -39,6 +72,17 @@ export async function submitForm( await context.formService.markSubmitted(form, values); await context.markFormSubmittedOnMessage(form.assistantMessageId, conversation.id); + emit('ui.artifact', { + messageId: form.assistantMessageId, + artifact: buildA2uiArtifact({ + type: 'form', + id: form.id, + status: 'submitted', + messageId: form.assistantMessageId, + conversationId: conversation.id, + payload: { ...context.formService.serialize(form), status: 'submitted' }, + }), + }); await runGenerationAndRelease(context, { user, @@ -73,12 +117,43 @@ export async function submitReview( context.assertSkillAvailable(user, effectiveSkillKey); assertReviewImportPermissions(context, user, review); + const submissions = submissionsService(context); + const recorded = await submissions.recordSubmission({ + artifactId: reviewId, + clientRequestId: dto.clientRequestId, + status: 'created', + }); + if (!recorded.created) { + emit('error', { message: '该导入预览已确认过,请勿重复提交' }); + emit('done', {}); + return; + } + await context.acquireConversation(conversation.id); try { const { review: updatedReview, result } = await context.reviewService.submitAll( review.id, user.id, ); + const sectionTypes = context.reviewService + .parseSections(review.sectionsJson) + .map((section) => section.type) + .filter(Boolean); + const completedStageKeys = [ + ...new Set( + sectionTypes.map((type) => + type === 'students' + ? 'profile' + : type === 'rooms' + ? 'room' + : 'checkin', + ), + ), + ]; + const nextSteps = suggestNextStepsForPrincipal( + { permissions: user.permissions, isSuperAdmin: user.isSuperAdmin }, + completedStageKeys, + ).map((step) => ({ key: step.key, label: step.label })); const summary = `已确认导入「${review.title}」:${result.message}`; await context.opLog?.log({ userId: user.id, @@ -103,6 +178,8 @@ export async function submitReview( reviewId: review.id, reviewTitle: review.title, resultMessage: result.message, + submissionId: String(recorded.submission.id), + nextSteps, }, }, undefined, @@ -117,6 +194,17 @@ export async function submitReview( messageId: updatedReview.assistantMessageId, review: serialized, }); + emit('ui.artifact', { + messageId: updatedReview.assistantMessageId, + artifact: buildA2uiArtifact({ + type: 'review', + id: review.id, + status: 'submitted', + messageId: updatedReview.assistantMessageId, + conversationId: conversation.id, + payload: serialized, + }), + }); await context.markReviewSubmittedOnMessage( updatedReview.assistantMessageId, conversation.id, diff --git a/apps/server/src/ai-chat/ai-chat.submit-content.spec.ts b/apps/server/src/ai-chat/ai-chat.submit-content.spec.ts new file mode 100644 index 0000000..d7d1b12 --- /dev/null +++ b/apps/server/src/ai-chat/ai-chat.submit-content.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from '@jest/globals'; +import { + a2uiReviewSubmitInfo, + a2uiSubmitInfo, + buildFormSubmitModelContent, + buildReviewSubmitModelContent, +} from './ai-chat.submit-content'; + +describe('a2ui submit model content', () => { + it('表单提交信息解析 submissionId 与 fieldErrors', () => { + const info = a2uiSubmitInfo({ + a2uiSubmit: { + formId: 'form-1', + formTitle: '新增学生', + values: { name: '张三' }, + submissionId: 'sub-1', + fieldErrors: [{ field: 'phone', message: '手机号格式错误' }], + }, + }); + expect(info).toEqual({ + formId: 'form-1', + title: '新增学生', + values: { name: '张三' }, + submissionId: 'sub-1', + fieldErrors: [{ field: 'phone', message: '手机号格式错误' }], + }); + }); + + it('表单回灌内容包含提交 ID 与结构化错误', () => { + const content = buildFormSubmitModelContent({ + title: '新增学生', + values: { name: '张三' }, + submissionId: 'sub-1', + fieldErrors: [], + }); + expect(content).toContain('sub-1'); + expect(content).toContain('"name":"张三"'); + expect(content).toContain('fieldErrors'); + }); + + it('审阅提交信息解析 submissionId 与下一步建议', () => { + const info = a2uiReviewSubmitInfo({ + a2uiReviewSubmit: { + reviewId: 'review-1', + reviewTitle: '批量入住导入', + resultMessage: '已导入 12 条', + submissionId: 'sub-2', + nextSteps: [{ key: 'after_checkin', label: '入住完成 → 建议录入费用' }], + }, + }); + expect(info?.submissionId).toBe('sub-2'); + expect(info?.nextSteps).toHaveLength(1); + }); + + it('审阅回灌内容包含提交 ID 与下一步建议', () => { + const content = buildReviewSubmitModelContent({ + reviewId: 'review-1', + reviewTitle: '批量入住导入', + resultMessage: '已导入 12 条', + submissionId: 'sub-2', + nextSteps: [{ key: 'after_checkin', label: '入住完成 → 建议录入费用' }], + }); + expect(content).toContain('sub-2'); + expect(content).toContain('建议录入费用'); + }); +}); diff --git a/apps/server/src/ai-chat/ai-chat.submit-content.ts b/apps/server/src/ai-chat/ai-chat.submit-content.ts index b21668a..5fd291b 100644 --- a/apps/server/src/ai-chat/ai-chat.submit-content.ts +++ b/apps/server/src/ai-chat/ai-chat.submit-content.ts @@ -3,21 +3,35 @@ import type { AiChatServiceContext } from './ai-chat.types'; export function a2uiSubmitInfo( metadata: Record | null, -): { title: string; values: Record } | null { +): { + formId: string; + title: string; + values: Record; + submissionId?: string; + fieldErrors?: Array<{ field: string; message: string }>; +} | null { const submit = metadata?.a2uiSubmit; if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; const record = submit as Record; + const formId = typeof record.formId === 'string' ? record.formId : ''; const title = typeof record.formTitle === 'string' ? record.formTitle : '表单'; const values = record.values && typeof record.values === 'object' && !Array.isArray(record.values) ? (record.values as Record) : {}; - return { title, values }; + const submissionId = + typeof record.submissionId === 'string' ? record.submissionId : undefined; + const fieldErrors = Array.isArray(record.fieldErrors) + ? (record.fieldErrors as Array<{ field: string; message: string }>) + : []; + return { formId, title, values, submissionId, fieldErrors }; } export function buildFormSubmitModelContent(submit: { title: string; values: Record; + submissionId?: string; + fieldErrors?: Array<{ field: string; message: string }>; }): string { let json: string; try { @@ -25,7 +39,11 @@ export function buildFormSubmitModelContent(submit: { } catch { json = '[无法序列化]'; } - return `【表单提交:${submit.title}】\n提交值(JSON):${json.slice(0, 32 * 1024)}\n用户已在表单中确认,你可以执行允许的写操作工具。`; + const submissionId = submit.submissionId ? `\n提交ID:${submit.submissionId}` : ''; + const fieldErrors = submit.fieldErrors?.length + ? `\nfieldErrors: ${JSON.stringify(submit.fieldErrors)}` + : '\nfieldErrors: []'; + return `【表单提交:${submit.title}】${submissionId}\n提交值(JSON):${json.slice(0, 32 * 1024)}${fieldErrors}\n用户已在表单中确认,你可以执行允许的写操作工具。`; } export async function markFormSubmittedOnMessage( @@ -48,15 +66,28 @@ export async function markFormSubmittedOnMessage( export function a2uiReviewSubmitInfo( metadata: Record | null, -): { reviewId: string; reviewTitle: string; resultMessage: string } | null { +): { + reviewId: string; + reviewTitle: string; + resultMessage: string; + submissionId?: string; + nextSteps?: Array<{ key: string; label: string }>; +} | null { const submit = metadata?.a2uiReviewSubmit; if (!submit || typeof submit !== 'object' || Array.isArray(submit)) return null; const record = submit as Record; if (typeof record.reviewId !== 'string') return null; + const submissionId = + typeof record.submissionId === 'string' ? record.submissionId : undefined; + const nextSteps = Array.isArray(record.nextSteps) + ? (record.nextSteps as Array<{ key: string; label: string }>) + : []; return { reviewId: record.reviewId, reviewTitle: typeof record.reviewTitle === 'string' ? record.reviewTitle : '批量导入', resultMessage: typeof record.resultMessage === 'string' ? record.resultMessage : '导入已完成', + submissionId, + nextSteps, }; } @@ -64,8 +95,14 @@ export function buildReviewSubmitModelContent(submit: { reviewId: string; reviewTitle: string; resultMessage: string; + submissionId?: string; + nextSteps?: Array<{ key: string; label: string }>; }): string { - return `【批量导入已确认:${submit.reviewTitle}】\n${submit.resultMessage}\n数据已由系统入库,不要再次调用写入工具,直接向用户汇报导入结果即可。`; + const submissionId = submit.submissionId ? `\n提交ID:${submit.submissionId}` : ''; + const nextSteps = submit.nextSteps?.length + ? `\n下一步建议:${submit.nextSteps.map((step) => step.label).join(';')}` + : ''; + return `【批量导入已确认:${submit.reviewTitle}】${submissionId}\n${submit.resultMessage}${nextSteps}\n数据已由系统入库,不要再次调用写入工具,直接向用户汇报导入结果即可。`; } export async function markReviewSubmittedOnMessage( diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts index f37665d..25067d9 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts @@ -8,6 +8,7 @@ import { permittedStepKeys } from '../imports/imports.access'; import { expandStageSheets } from '../imports/imports.mapping'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AgentToolContext } from './ai-chat.tools'; +import { buildA2uiArtifact } from './ai-a2ui.artifact'; import { AiMessage } from './entities'; import { finishToolRun, startToolRun } from './ai-chat.tools'; import { @@ -155,6 +156,17 @@ export const executePreflightImport = makeImportToolExecutor( .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); }); @@ -241,6 +253,17 @@ export const executeStartImportWizard = makeImportToolExecutor( }); } emit('ui.import_wizard', { messageId, wizard }); + emit('ui.artifact', { + messageId, + artifact: buildA2uiArtifact({ + type: 'import_wizard', + id: `wizard-${detail.id}`, + status: 'pending', + messageId, + conversationId: assistant.conversationId, + payload: wizard, + }), + }); return JSON.stringify({ status: 'success', runId: detail.id, diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index f3a5dec..7b8fe3d 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -1,5 +1,6 @@ import { AiReview } from './entities/ai-review.entity'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; +import { buildA2uiArtifact } from './ai-a2ui.artifact'; import { finishToolRun, startToolRun } from './ai-chat.tools'; export async function executeRenderForm( context: AiChatServiceContext, @@ -20,6 +21,39 @@ export async function executeRenderForm( { userId, conversationId: assistant.conversationId, assistantMessageId: messageId }, parsedArgs, ); + const expiredForms = await context.formService.expirePreviousForms( + userId, + assistant.conversationId, + form.id, + ); + await Promise.all( + expiredForms.map(async (expired) => { + const oldAssistant = await context.messages.findOne({ + where: { id: expired.assistantMessageId, conversationId: assistant.conversationId }, + }); + const oldA2ui = oldAssistant?.metadata?.a2uiForm; + if (oldAssistant && oldA2ui && typeof oldA2ui === 'object' && !Array.isArray(oldA2ui)) { + oldAssistant.metadata = { + ...oldAssistant.metadata, + a2uiForm: context.formService.serialize(expired), + }; + await context.messages.save(oldAssistant); + } + const expiredPayload = context.formService.serialize(expired); + emit('ui.form', { messageId: expired.assistantMessageId, form: expiredPayload }); + emit('ui.artifact', { + messageId: expired.assistantMessageId, + artifact: buildA2uiArtifact({ + type: 'form', + id: expired.id, + status: 'expired', + messageId: expired.assistantMessageId, + conversationId: assistant.conversationId, + payload: expiredPayload, + }), + }); + }), + ); assistant.metadata = { ...assistant.metadata, a2uiForm: context.formService.serialize(form), @@ -31,6 +65,17 @@ export async function executeRenderForm( messageId, form: context.formService.serialize(form), }); + emit('ui.artifact', { + messageId, + artifact: buildA2uiArtifact({ + type: 'form', + id: form.id, + status: form.status === 'submitted' ? 'submitted' : 'pending', + messageId, + conversationId: assistant.conversationId, + payload: context.formService.serialize(form), + }), + }); return JSON.stringify({ status: 'success', formId: form.id, @@ -119,6 +164,17 @@ export async function executeRenderReview( messageId: expired.assistantMessageId, review: context.reviewService.serialize(expired), }); + emit('ui.artifact', { + messageId: expired.assistantMessageId, + artifact: buildA2uiArtifact({ + type: 'review', + id: expired.id, + status: expired.status === 'submitted' ? 'submitted' : 'expired', + messageId: expired.assistantMessageId, + conversationId: assistant.conversationId, + payload: context.reviewService.serialize(expired), + }), + }); }), ); assistant.metadata = { @@ -132,6 +188,17 @@ export async function executeRenderReview( messageId, review: context.reviewService.serialize(review), }); + emit('ui.artifact', { + messageId, + artifact: buildA2uiArtifact({ + type: 'review', + id: review.id, + status: review.status === 'submitted' ? 'submitted' : 'pending', + messageId, + conversationId: assistant.conversationId, + payload: context.reviewService.serialize(review), + }), + }); return JSON.stringify({ status: 'success', reviewId: review.id, @@ -179,6 +246,17 @@ export async function executeRenderChart( messageId, chart: context.chartService.serialize(chart), }); + emit('ui.artifact', { + messageId, + artifact: buildA2uiArtifact({ + type: 'chart', + id: chart.id, + status: 'pending', + messageId, + conversationId: assistant.conversationId, + payload: context.chartService.serialize(chart), + }), + }); return JSON.stringify({ status: 'success', chartId: chart.id, diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index b1108b1..e51cd7e 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -10,6 +10,7 @@ import { AiChartService } from './ai-chart.service'; import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; +import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { @@ -95,6 +96,7 @@ export interface AiChatServiceContext { readonly excelReader?: AiExcelReaderService; readonly importsService?: ImportsService; readonly opLog?: OperationLogsService; + readonly a2uiSubmissions?: A2uiSubmissionsService; listSkills(user: AuthenticatedUser): ReturnType; serializeMessage(message: AiMessage): Record; redactText(value: string): string; @@ -114,11 +116,18 @@ export interface AiChatServiceContext { reviewTitle: string; resultMessage: string; } | null; - buildFormSubmitModelContent(submit: { title: string; values: Record }): string; + buildFormSubmitModelContent(submit: { + title: string; + values: Record; + submissionId?: string; + fieldErrors?: Array<{ field: string; message: string }>; + }): string; buildReviewSubmitModelContent(submit: { reviewId: string; reviewTitle: string; resultMessage: string; + submissionId?: string; + nextSteps?: Array<{ key: string; label: string }>; }): string; markFormSubmittedOnMessage(assistantMessageId: number, conversationId: number): Promise; markReviewSubmittedOnMessage( @@ -175,6 +184,7 @@ export type AiSseEventName = | 'ui.form' | 'ui.review' | 'ui.chart' + | 'ui.artifact' | 'ui.import_preflight' | 'ui.import_wizard' | 'attachment.processed' diff --git a/apps/server/src/ai-chat/ai-form.service.spec.ts b/apps/server/src/ai-chat/ai-form.service.spec.ts index 1763b49..ffd97b3 100644 --- a/apps/server/src/ai-chat/ai-form.service.spec.ts +++ b/apps/server/src/ai-chat/ai-form.service.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { In } from 'typeorm'; import { AiFormService } from './ai-form.service'; function createService(overrides: Record = {}) { @@ -140,6 +141,35 @@ describe('AiFormService', () => { }); }); + describe('expirePreviousForms', () => { + it('过期同会话其他 pending 表单并返回过期记录', async () => { + const pending = [ + { id: 'form-a', userId: 7, conversationId: 3, status: 'pending' }, + { id: 'form-b', userId: 7, conversationId: 3, status: 'pending' }, + ]; + const { service, forms } = createService({ + find: jest.fn().mockResolvedValue(pending), + update: jest.fn().mockResolvedValue({}), + }); + const expired = await service.expirePreviousForms(7, 3, 'form-b'); + expect(expired.map((item) => item.id)).toEqual(['form-a']); + expect(expired[0].status).toBe('expired'); + expect(forms.update).toHaveBeenCalledWith( + { id: In(['form-a']) }, + { status: 'expired' }, + ); + }); + + it('没有其他 pending 表单时返回空数组且不更新', async () => { + const { service, forms } = createService({ + find: jest.fn().mockResolvedValue([{ id: 'form-b' }]), + update: jest.fn(), + }); + await expect(service.expirePreviousForms(7, 3, 'form-b')).resolves.toEqual([]); + expect(forms.update).not.toHaveBeenCalled(); + }); + }); + describe('validateValues', () => { const form = { fieldsJson: JSON.stringify(validSchema.fields), diff --git a/apps/server/src/ai-chat/ai-form.service.ts b/apps/server/src/ai-chat/ai-form.service.ts index 205c72e..a72488f 100644 --- a/apps/server/src/ai-chat/ai-form.service.ts +++ b/apps/server/src/ai-chat/ai-form.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; import { AiForm, type AiFormField } from './entities/ai-form.entity'; @@ -108,10 +108,28 @@ export class AiFormService { async findOwnedPending(formId: string, userId: number): Promise { const form = await this.forms.findOne({ where: { id: formId, userId, status: 'pending' } }); - if (!form) throw new NotFoundException('表单不存在或已提交'); + if (!form) throw new NotFoundException('表单不存在、已提交或已失效'); return form; } + /** + * 过期同会话其他 pending 表单(新表单渲染后旧表单不可再提交)。 + */ + async expirePreviousForms( + userId: number, + conversationId: number, + exceptFormId: string, + ): Promise { + const pending = await this.forms.find({ + where: { userId, conversationId, status: 'pending' }, + }); + const expired = pending.filter((form) => form.id !== exceptFormId); + if (expired.length === 0) return []; + const ids = expired.map((form) => form.id); + await this.forms.update({ id: In(ids) }, { status: 'expired' }); + return expired.map((form) => ({ ...form, status: 'expired' as const })); + } + async markSubmitted(form: AiForm, values: Record): Promise { form.status = 'submitted'; form.submittedValuesJson = JSON.stringify(values); diff --git a/apps/server/src/ai-chat/entities/ai-a2ui-submission.entity.ts b/apps/server/src/ai-chat/entities/ai-a2ui-submission.entity.ts new file mode 100644 index 0000000..9568b50 --- /dev/null +++ b/apps/server/src/ai-chat/entities/ai-a2ui-submission.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * A2UI 提交幂等记录:同一 artifact + clientRequestId 只允许执行一次, + * 重复提交返回既有结果,避免双击/重试导致二次写入或二次生成。 + */ +@Entity('ai_a2ui_submissions') +@Index('uk_ai_a2ui_submissions_artifact_client', ['artifactId', 'clientRequestId'], { + unique: true, +}) +export class AiA2uiSubmission { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'artifact_id', type: 'varchar', length: 100 }) + artifactId: string; + + @Column({ name: 'client_request_id', type: 'varchar', length: 36 }) + clientRequestId: string; + + @Column({ type: 'varchar', length: 20 }) + status: string; + + @Column({ name: 'result_json', type: 'text', nullable: true }) + resultJson: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'datetime' }) + createdAt: Date; +} diff --git a/apps/server/src/ai-chat/entities/ai-form.entity.ts b/apps/server/src/ai-chat/entities/ai-form.entity.ts index d77dc88..7226788 100644 --- a/apps/server/src/ai-chat/entities/ai-form.entity.ts +++ b/apps/server/src/ai-chat/entities/ai-form.entity.ts @@ -10,7 +10,7 @@ import { } from 'typeorm'; import { AiMessage } from './ai-message.entity'; -export type AiFormStatus = 'pending' | 'submitted'; +export type AiFormStatus = 'pending' | 'submitted' | 'expired'; export interface AiFormField { name: string; diff --git a/apps/server/src/ai-chat/entities/index.ts b/apps/server/src/ai-chat/entities/index.ts index 164aeb1..075580f 100644 --- a/apps/server/src/ai-chat/entities/index.ts +++ b/apps/server/src/ai-chat/entities/index.ts @@ -4,3 +4,4 @@ export * from './ai-tool-run.entity'; export * from './ai-attachment.entity'; export * from './ai-form.entity'; export * from './ai-review.entity'; +export * from './ai-a2ui-submission.entity'; diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 8816293..66dad62 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -19,6 +19,7 @@ import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiR import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns'; import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback'; import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings'; +import { AddA2UiSubmissions1786001000000 } from './migrations/1786001000000-AddA2UiSubmissions'; const allMigrations = [ InitialSchema1784520727860, AddExamManagement1784600000000, @@ -31,6 +32,7 @@ const allMigrations = [ AddImportRuns1784910000000, DropAiMessageFeedback1784920000000, AddImportRunSettings1784930000000, + AddA2UiSubmissions1786001000000, ]; import { AuthorizationModule } from './authorization'; import { RbacModule } from './rbac/rbac.module'; @@ -149,6 +151,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; Entities.AiAttachment, Entities.AiForm, Entities.AiReview, + Entities.AiA2uiSubmission, Entities.ImportRun, Entities.ImportStep, Entities.ImportRow, diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index ed8af6e..1eb8118 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -53,6 +53,7 @@ export { AiToolRun, AiForm, AiReview, + AiA2uiSubmission, } from '../ai-chat/entities'; export { ImportRun } from '../imports/entities/import-run.entity'; export { ImportStep } from '../imports/entities/import-step.entity'; diff --git a/apps/server/src/migrations/1786001000000-AddA2UiSubmissions.ts b/apps/server/src/migrations/1786001000000-AddA2UiSubmissions.ts new file mode 100644 index 0000000..64d810b --- /dev/null +++ b/apps/server/src/migrations/1786001000000-AddA2UiSubmissions.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * A2UI 提交幂等记录:同一 artifact + clientRequestId 只允许执行一次。 + */ +export class AddA2UiSubmissions1786001000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('ai_a2ui_submissions')) return; + await queryRunner.createTable( + new Table({ + name: 'ai_a2ui_submissions', + columns: [ + { name: 'id', type: 'int', isPrimary: true, isGenerated: true, generationStrategy: 'increment' }, + { name: 'artifact_id', type: 'varchar', length: '100' }, + { name: 'client_request_id', type: 'varchar', length: '36' }, + { name: 'status', type: 'varchar', length: '20' }, + { name: 'result_json', type: 'text', isNullable: true }, + { name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + ], + }), + ); + await queryRunner.createIndex( + 'ai_a2ui_submissions', + new TableIndex({ + name: 'uk_ai_a2ui_submissions_artifact_client', + columnNames: ['artifact_id', 'client_request_id'], + isUnique: true, + }), + ); + } + + async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('ai_a2ui_submissions')) { + await queryRunner.dropTable('ai_a2ui_submissions'); + } + } +} From c72ff2cb8a14b12f81880b2301dccda6488c45aa Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 15:35:12 +0800 Subject: [PATCH 17/20] =?UTF-8?q?fix(ai):=20=E6=B8=85=E7=90=86=20aislop=20?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分 SSE reducer 到 sseReducer.ts,provider.ts 降至 220 行 - artifact 合并工具抽到 uiArtifacts.ts,去除双重类型断言 - 业务上下文注册表改用 field/relation/entity 构建器收敛重复 - aislop 分数 71 → 81(Healthy),AI Slop 0 告警 --- .../src/components/AiChat/message-mappers.ts | 2 +- apps/admin/src/components/AiChat/provider.ts | 276 +--------------- .../admin/src/components/AiChat/sseReducer.ts | 208 ++++++++++++ .../src/components/AiChat/uiArtifacts.ts | 69 ++++ .../AiChat/useAiChatMessageActions.tsx | 3 +- .../business-context.registry.ts | 302 +++++++++--------- .../agent-context/business-context.types.ts | 2 +- 7 files changed, 435 insertions(+), 427 deletions(-) create mode 100644 apps/admin/src/components/AiChat/sseReducer.ts create mode 100644 apps/admin/src/components/AiChat/uiArtifacts.ts diff --git a/apps/admin/src/components/AiChat/message-mappers.ts b/apps/admin/src/components/AiChat/message-mappers.ts index 68688a8..ed7d0b0 100644 --- a/apps/admin/src/components/AiChat/message-mappers.ts +++ b/apps/admin/src/components/AiChat/message-mappers.ts @@ -9,7 +9,7 @@ import type { AiReviewSchema, AiToolRun, } from './types'; -import { mergeArtifactIntoMessage } from './provider'; +import { mergeArtifactIntoMessage } from './uiArtifacts'; function mapStatus(record: AiMessageRecord): AiChatMessageStatus { if (record.status === 'pending') return 'loading'; diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts index 970fdef..69b22f6 100644 --- a/apps/admin/src/components/AiChat/provider.ts +++ b/apps/admin/src/components/AiChat/provider.ts @@ -6,280 +6,10 @@ import { } from '@ant-design/x-sdk'; import { usePermissionStore } from '../../store/permission/permissionStore'; import { useUserStore } from '../../store/user/userStore'; -import type { - AiAttachment, - AiArtifactSchema, - AiChatInput, - AiChatMessage, - AiChartSchema, - AiFormSchema, - AiImportPreflight, - AiModelRetryInfo, - AiReviewSchema, - AiSseChunk, - AiToolRun, -} from './types'; +import type { AiArtifactSchema, AiChatInput, AiChatMessage, AiReviewSchema, AiSseChunk } from './types'; +import { emptyAssistant, parseSsePayload, reduceAiSseMessage } from './sseReducer'; -interface AiSsePayload { - messageId?: number; - userMessageId?: number; - assistantMessageId?: number; - delta?: string; - content?: string; - reasoningContent?: string | null; - toolCallId?: string; - toolName?: string; - skillKey?: string | null; - status?: string; - summary?: string | null; - durationMs?: number | null; - attachment?: AiAttachment; - form?: AiFormSchema; - artifact?: AiArtifactSchema; - review?: AiReviewSchema; - chart?: AiChartSchema; - preflight?: AiImportPreflight; - wizard?: unknown; - retry?: AiModelRetryInfo; - message?: - | string - | { - id?: number; - content?: string; - reasoningContent?: string | null; - status?: string; - toolRuns?: AiToolRun[]; - attachments?: AiAttachment[]; - replyToMessageId?: number | null; - metadata?: Record | null; - }; - error?: string; -} - -function emptyAssistant(): AiChatMessage { - return { - role: 'assistant', - content: '', - reasoningContent: '', - toolRuns: [], - attachments: [], - forms: [], - uiArtifacts: [], - }; -} - -function mergeForms( - current: AiFormSchema[] | undefined, - incoming: AiFormSchema | AiFormSchema[] | undefined, -): AiFormSchema[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) { - next.push(item); - } - } - return next; -} - -function mergeById( - current: T[] | undefined, - incoming: T | T[] | undefined, -): T[] { - const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; - if (!items.length) return current ?? []; - const next = [...(current ?? [])]; - for (const item of items) { - if (!item || typeof item !== 'object') continue; - const index = next.findIndex((existing) => existing.id === item.id); - if (index === -1) { - next.push(item); - } else { - next[index] = item; - } - } - return next; -} - -export function parseSsePayload(chunk?: AiSseChunk): { - event: string; - payload: AiSsePayload; -} { - if (!chunk) return { event: '', payload: {} }; - const event = chunk.event?.trim() || 'message'; - if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} }; - try { - const parsed: unknown = JSON.parse(chunk.data); - return { - event, - payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {}, - }; - } catch { - return { event, payload: { delta: chunk.data } }; - } -} - -function upsertToolRun( - toolRuns: AiToolRun[], - payload: AiSsePayload, - fallbackStatus: AiToolRun['status'], -): AiToolRun[] { - const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`; - const next: AiToolRun = { - toolCallId, - toolName: payload.toolName || '查询工具', - skillKey: payload.skillKey, - status: (payload.status as AiToolRun['status']) || fallbackStatus, - summary: payload.summary, - resultSummary: fallbackStatus === 'running' ? undefined : payload.summary, - argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined, - durationMs: payload.durationMs, - }; - const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId); - if (index === -1) return [...toolRuns, next]; - return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item)); -} - -function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] { - if (!toolRuns) return fallback; - return toolRuns.map((tool) => ({ - ...tool, - status: tool.status === 'error' ? 'failed' : tool.status, - summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary, - })); -} - -function applyMessagePayload( - message: AiChatMessage, - nested: AiSsePayload['message'], - payload: AiSsePayload, -): void { - if (typeof nested !== 'object' || nested === null) return; - message.forms = mergeForms( - message.forms, - (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, - ); - message.reviews = mergeById( - message.reviews, - (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, - ); - message.charts = mergeById( - message.charts, - (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, - ); - const artifacts = nested.metadata?.uiArtifacts; - if (Array.isArray(artifacts)) { - for (const artifact of artifacts) { - if (artifact && typeof artifact === 'object' && typeof artifact.id === 'string') { - mergeArtifactIntoMessage(message, artifact as AiArtifactSchema); - } - } - } - message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId; - message.metadata = nested.metadata ?? message.metadata; -} - -/** - * 将统一 artifact 归入 uiArtifacts,并按类型派发到 legacy 列表。 - */ -export function mergeArtifactIntoMessage( - message: AiChatMessage, - artifact: AiArtifactSchema, -): AiChatMessage { - message.uiArtifacts = mergeById(message.uiArtifacts, artifact); - const payload = - artifact.payload && typeof artifact.payload === 'object' - ? (artifact.payload as Record) - : {}; - if (artifact.type === 'form') { - message.forms = mergeForms(message.forms, payload as unknown as AiFormSchema); - } else if (artifact.type === 'review') { - message.reviews = mergeById( - message.reviews, - payload as unknown as AiReviewSchema, - ); - } else if (artifact.type === 'chart') { - message.charts = mergeById( - message.charts, - payload as unknown as AiChartSchema, - ); - } else if (artifact.type === 'import_preflight') { - message.metadata = { ...message.metadata, a2uiImportPreflight: payload }; - } else if (artifact.type === 'import_wizard') { - message.metadata = { ...message.metadata, a2uiImportWizard: payload }; - } - return message; -} - -export function reduceAiSseMessage( - originMessage: AiChatMessage | undefined, - chunk?: AiSseChunk, -): AiChatMessage { - const message = originMessage ? { ...originMessage } : emptyAssistant(); - const { event, payload } = parseSsePayload(chunk); - - if (event === 'message.created') { - const nested = typeof payload.message === 'object' ? payload.message : undefined; - message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id; - message.content = nested?.content ?? message.content; - message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; - message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); - message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); - } else if (event === 'reasoning.delta') { - message.retrying = null; - message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; - } else if (event === 'content.delta') { - message.retrying = null; - message.content += payload.delta ?? payload.content ?? ''; - } else if (event === 'model.retrying' && payload.retry) { - message.retrying = payload.retry; - } else if (event === 'ui.form' && payload.form) { - message.forms = mergeForms(message.forms, payload.form); - } else if (event === 'ui.review' && payload.review) { - message.reviews = mergeById(message.reviews, payload.review); - } else if (event === 'ui.chart' && payload.chart) { - message.charts = mergeById(message.charts, payload.chart); - } else if (event === 'ui.artifact' && payload.artifact) { - mergeArtifactIntoMessage(message, payload.artifact); - } else if (event === 'ui.import_preflight' && payload.preflight) { - message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight }; - } else if (event === 'ui.import_wizard' && payload.wizard) { - message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; - } else if (event === 'tool.started') { - message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running'); - } else if (event === 'tool.completed') { - message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success'); - } else if (event === 'tool.failed') { - message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed'); - } else if (event === 'attachment.processed' && payload.attachment) { - if (!message.attachments.some((item) => item.id === payload.attachment?.id)) { - message.attachments = [...message.attachments, payload.attachment]; - } - } else if (event === 'message.completed') { - const nested = typeof payload.message === 'object' ? payload.message : undefined; - message.id = nested?.id ?? payload.messageId ?? message.id; - message.content = nested?.content ?? payload.content ?? message.content; - message.reasoningContent = - nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; - message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); - message.attachments = nested?.attachments ?? message.attachments; - applyMessagePayload(message, nested, payload); - message.retrying = null; - } else if (event === 'message.cancelled') { - message.id = payload.messageId ?? message.id; - message.cancelled = true; - message.retrying = null; - } else if (event === 'error') { - message.retrying = null; - message.error = - (typeof payload.message === 'string' ? payload.message : undefined) || - payload.error || - 'AI 回答生成失败'; - } - return message; -} +export { parseSsePayload, reduceAiSseMessage }; export async function authenticatedFetch( input: RequestInfo | URL, diff --git a/apps/admin/src/components/AiChat/sseReducer.ts b/apps/admin/src/components/AiChat/sseReducer.ts new file mode 100644 index 0000000..6b1ac2e --- /dev/null +++ b/apps/admin/src/components/AiChat/sseReducer.ts @@ -0,0 +1,208 @@ +import type { + AiArtifactSchema, + AiAttachment, + AiChartSchema, + AiChatMessage, + AiFormSchema, + AiImportPreflight, + AiModelRetryInfo, + AiReviewSchema, + AiSseChunk, + AiToolRun, +} from './types'; +import { mergeArtifactIntoMessage, mergeById, mergeForms } from './uiArtifacts'; + +export interface AiSsePayload { + messageId?: number; + userMessageId?: number; + assistantMessageId?: number; + delta?: string; + content?: string; + reasoningContent?: string | null; + toolCallId?: string; + toolName?: string; + skillKey?: string | null; + status?: string; + summary?: string | null; + durationMs?: number | null; + attachment?: AiAttachment; + form?: AiFormSchema; + artifact?: AiArtifactSchema; + review?: AiReviewSchema; + chart?: AiChartSchema; + preflight?: AiImportPreflight; + wizard?: unknown; + retry?: AiModelRetryInfo; + message?: + | string + | { + id?: number; + content?: string; + reasoningContent?: string | null; + status?: string; + toolRuns?: AiToolRun[]; + attachments?: AiAttachment[]; + replyToMessageId?: number | null; + metadata?: Record | null; + }; + error?: string; +} + +export function emptyAssistant(): AiChatMessage { + return { + role: 'assistant', + content: '', + reasoningContent: '', + toolRuns: [], + attachments: [], + forms: [], + uiArtifacts: [], + }; +} + +export function parseSsePayload(chunk?: AiSseChunk): { + event: string; + payload: AiSsePayload; +} { + if (!chunk) return { event: '', payload: {} }; + const event = chunk.event?.trim() || 'message'; + if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} }; + try { + const parsed: unknown = JSON.parse(chunk.data); + return { + event, + payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {}, + }; + } catch { + return { event, payload: { delta: chunk.data } }; + } +} + +function upsertToolRun( + toolRuns: AiToolRun[], + payload: AiSsePayload, + fallbackStatus: AiToolRun['status'], +): AiToolRun[] { + const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`; + const next: AiToolRun = { + toolCallId, + toolName: payload.toolName || '查询工具', + skillKey: payload.skillKey, + status: (payload.status as AiToolRun['status']) || fallbackStatus, + summary: payload.summary, + resultSummary: fallbackStatus === 'running' ? undefined : payload.summary, + argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined, + durationMs: payload.durationMs, + }; + const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId); + if (index === -1) return [...toolRuns, next]; + return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item)); +} + +function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] { + if (!toolRuns) return fallback; + return toolRuns.map((tool) => ({ + ...tool, + status: tool.status === 'error' ? 'failed' : tool.status, + summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary, + })); +} + +function applyMessagePayload( + message: AiChatMessage, + nested: AiSsePayload['message'], + payload: AiSsePayload, +): void { + if (typeof nested !== 'object' || nested === null) return; + message.forms = mergeForms( + message.forms, + (nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form, + ); + message.reviews = mergeById( + message.reviews, + (nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review, + ); + message.charts = mergeById( + message.charts, + (nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart, + ); + const artifacts = nested.metadata?.uiArtifacts; + if (Array.isArray(artifacts)) { + for (const artifact of artifacts) { + if (artifact && typeof artifact === 'object' && typeof artifact.id === 'string') { + mergeArtifactIntoMessage(message, artifact as AiArtifactSchema); + } + } + } + message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId; + message.metadata = nested.metadata ?? message.metadata; +} + +export function reduceAiSseMessage( + originMessage: AiChatMessage | undefined, + chunk?: AiSseChunk, +): AiChatMessage { + const message = originMessage ? { ...originMessage } : emptyAssistant(); + const { event, payload } = parseSsePayload(chunk); + + if (event === 'message.created') { + const nested = typeof payload.message === 'object' ? payload.message : undefined; + message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id; + message.content = nested?.content ?? message.content; + message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; + message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); + message.attachments = nested?.attachments ?? message.attachments; + applyMessagePayload(message, nested, payload); + } else if (event === 'reasoning.delta') { + message.retrying = null; + message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; + } else if (event === 'content.delta') { + message.retrying = null; + message.content += payload.delta ?? payload.content ?? ''; + } else if (event === 'model.retrying' && payload.retry) { + message.retrying = payload.retry; + } else if (event === 'ui.form' && payload.form) { + message.forms = mergeForms(message.forms, payload.form); + } else if (event === 'ui.review' && payload.review) { + message.reviews = mergeById(message.reviews, payload.review); + } else if (event === 'ui.chart' && payload.chart) { + message.charts = mergeById(message.charts, payload.chart); + } else if (event === 'ui.artifact' && payload.artifact) { + mergeArtifactIntoMessage(message, payload.artifact); + } else if (event === 'ui.import_preflight' && payload.preflight) { + message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight }; + } else if (event === 'ui.import_wizard' && payload.wizard) { + message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; + } else if (event === 'tool.started') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running'); + } else if (event === 'tool.completed') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success'); + } else if (event === 'tool.failed') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed'); + } else if (event === 'attachment.processed' && payload.attachment) { + if (!message.attachments.some((item) => item.id === payload.attachment?.id)) { + message.attachments = [...message.attachments, payload.attachment]; + } + } else if (event === 'message.completed') { + const nested = typeof payload.message === 'object' ? payload.message : undefined; + message.id = nested?.id ?? payload.messageId ?? message.id; + message.content = nested?.content ?? payload.content ?? message.content; + message.reasoningContent = + nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; + message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); + message.attachments = nested?.attachments ?? message.attachments; + applyMessagePayload(message, nested, payload); + message.retrying = null; + } else if (event === 'message.cancelled') { + message.id = payload.messageId ?? message.id; + message.cancelled = true; + message.retrying = null; + } else if (event === 'error') { + message.retrying = null; + message.error = + (typeof payload.message === 'string' ? payload.message : undefined) || + payload.error || + 'AI 回答生成失败'; + } + return message; +} diff --git a/apps/admin/src/components/AiChat/uiArtifacts.ts b/apps/admin/src/components/AiChat/uiArtifacts.ts new file mode 100644 index 0000000..abb20e2 --- /dev/null +++ b/apps/admin/src/components/AiChat/uiArtifacts.ts @@ -0,0 +1,69 @@ +import type { + AiArtifactSchema, + AiChartSchema, + AiChatMessage, + AiFormSchema, + AiReviewSchema, +} from './types'; + +export function mergeById( + current: T[] | undefined, + incoming: T | T[] | undefined, +): T[] { + const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; + if (!items.length) return current ?? []; + const next = [...(current ?? [])]; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const index = next.findIndex((existing) => existing.id === item.id); + if (index === -1) { + next.push(item); + } else { + next[index] = item; + } + } + return next; +} + +export function mergeForms( + current: AiFormSchema[] | undefined, + incoming: AiFormSchema | AiFormSchema[] | undefined, +): AiFormSchema[] { + const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : []; + if (!items.length) return current ?? []; + const next = [...(current ?? [])]; + for (const item of items) { + if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) { + next.push(item); + } + } + return next; +} + +function payloadOf(artifact: AiArtifactSchema): unknown { + return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {}; +} + +/** + * 将统一 artifact 归入 uiArtifacts,并按类型派发到 legacy 列表。 + * payload 来自服务端契约(表单/审阅/图表/预检/向导),按类型做单次断言。 + */ +export function mergeArtifactIntoMessage( + message: AiChatMessage, + artifact: AiArtifactSchema, +): AiChatMessage { + message.uiArtifacts = mergeById(message.uiArtifacts, artifact); + const payload = payloadOf(artifact); + if (artifact.type === 'form') { + message.forms = mergeForms(message.forms, payload as AiFormSchema); + } else if (artifact.type === 'review') { + message.reviews = mergeById(message.reviews, payload as AiReviewSchema); + } else if (artifact.type === 'chart') { + message.charts = mergeById(message.charts, payload as AiChartSchema); + } else if (artifact.type === 'import_preflight') { + message.metadata = { ...message.metadata, a2uiImportPreflight: payload }; + } else if (artifact.type === 'import_wizard') { + message.metadata = { ...message.metadata, a2uiImportWizard: payload }; + } + return message; +} diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index 91a42dc..ad0b032 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -9,7 +9,8 @@ import { useSettingsStore } from '../../store/settings/settingsStore'; import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api'; import { AiMessageContent } from './AiMessageContent'; import { mapHistoryMessage } from './message-mappers'; -import { GongxueAiChatProvider, mergeArtifactIntoMessage } from './provider'; +import { GongxueAiChatProvider } from './provider'; +import { mergeArtifactIntoMessage } from './uiArtifacts'; import { emptyAssistant, MessageHoverActions, diff --git a/apps/server/src/agent-context/business-context.registry.ts b/apps/server/src/agent-context/business-context.registry.ts index c6a8939..cf15d17 100644 --- a/apps/server/src/agent-context/business-context.registry.ts +++ b/apps/server/src/agent-context/business-context.registry.ts @@ -1,8 +1,49 @@ import type { BusinessEntity, + BusinessEntityField, + BusinessEntityRelation, BusinessWorkflow, } from './business-context.types'; +function field( + key: string, + label: string, + type: BusinessEntityField['type'], + extra: Partial> = {}, +): BusinessEntityField { + return { key, label, type, ...extra }; +} + +function relation( + entityKey: string, + via: string, + requiredFor: readonly string[], +): BusinessEntityRelation { + return { entityKey, via, requiredFor }; +} + +function entity( + key: string, + name: string, + description: string, + options: { + searchTool?: string; + requiredPermissions: readonly string[]; + fields: readonly BusinessEntityField[]; + relations?: readonly BusinessEntityRelation[]; + }, +): BusinessEntity { + return { + key, + name, + description, + ...(options.searchTool ? { searchTool: options.searchTool } : {}), + requiredPermissions: options.requiredPermissions, + fields: options.fields, + relations: options.relations ?? [], + }; +} + /** * 恭学系统业务上下文(代码内维护)。 * @@ -11,232 +52,191 @@ import type { * Agent 感知业务流程。 */ export const BUSINESS_ENTITIES: readonly BusinessEntity[] = [ - { - key: 'student', - name: '学生档案', - description: '学生基础档案(姓名、学号、手机号、性别等),是分班、入住、账单的前置数据。', + entity('student', '学生档案', '学生基础档案(姓名、学号、手机号、性别等),是分班、入住、账单的前置数据。', { searchTool: 'search_students', requiredPermissions: ['student:view'], fields: [ - { key: 'name', label: '姓名', type: 'string', required: true }, - { key: 'studentNo', label: '学号', type: 'string' }, - { key: 'phone', label: '手机号', type: 'string' }, - { key: 'gender', label: '性别', type: 'enum', enumFrom: 'student.gender' }, - { key: 'idNumber', label: '身份证号', type: 'string' }, + field('name', '姓名', 'string', { required: true }), + field('studentNo', '学号', 'string'), + field('phone', '手机号', 'string'), + field('gender', '性别', 'enum', { enumFrom: 'student.gender' }), + field('idNumber', '身份证号', 'string'), ], relations: [ - { entityKey: 'class', via: 'class_student', requiredFor: ['class'] }, - { entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] }, - { entityKey: 'bill', via: 'bill', requiredFor: ['bill'] }, - { entityKey: 'exam', via: 'exam_score', requiredFor: ['exam'] }, + relation('class', 'class_student', ['class']), + relation('occupancy', 'occupancy', ['checkin']), + relation('bill', 'bill', ['bill']), + relation('exam', 'exam_score', ['exam']), ], - }, - { - key: 'class', - name: '班级', - description: '班级档案与在读分班关系,排课、考勤、考试依赖班级。', + }), + entity('class', '班级', '班级档案与在读分班关系,排课、考勤、考试依赖班级。', { searchTool: 'search_classes', requiredPermissions: ['class:view'], fields: [ - { key: 'name', label: '班级名称', type: 'string', required: true }, - { key: 'grade', label: '年级', type: 'string' }, - { key: 'headTeacher', label: '班主任', type: 'string' }, + field('name', '班级名称', 'string', { required: true }), + field('grade', '年级', 'string'), + field('headTeacher', '班主任', 'string'), ], relations: [ - { entityKey: 'student', via: 'class_student', requiredFor: ['class'] }, - { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + relation('student', 'class_student', ['class']), + relation('schedule', 'class_schedule', ['schedule']), ], - }, - { - key: 'schedule', - name: '排课/日程', - description: '班级与教室的课程安排,考勤和教室日程依赖排课。', + }), + entity('schedule', '排课/日程', '班级与教室的课程安排,考勤和教室日程依赖排课。', { searchTool: 'search_schedules', requiredPermissions: ['schedule:view'], fields: [ - { key: 'classId', label: '班级', type: 'number' }, - { key: 'classroomId', label: '教室', type: 'number' }, - { key: 'weekDay', label: '星期', type: 'number' }, - { key: 'startTime', label: '开始时间', type: 'string' }, - { key: 'endTime', label: '结束时间', type: 'string' }, + field('classId', '班级', 'number'), + field('classroomId', '教室', 'number'), + field('weekDay', '星期', 'number'), + field('startTime', '开始时间', 'string'), + field('endTime', '结束时间', 'string'), ], relations: [ - { entityKey: 'class', via: 'class_schedule', requiredFor: ['schedule'] }, - { entityKey: 'classroom', via: 'class_schedule', requiredFor: ['schedule'] }, + relation('class', 'class_schedule', ['schedule']), + relation('classroom', 'class_schedule', ['schedule']), ], - }, - { - key: 'attendance', - name: '考勤', - description: '按班级与日期的考勤记录(出勤/迟到/缺勤/请假)。', + }), + entity('attendance', '考勤', '按班级与日期的考勤记录(出勤/迟到/缺勤/请假)。', { searchTool: 'get_attendance_summary', requiredPermissions: ['attendance:view'], fields: [ - { key: 'studentId', label: '学生', type: 'number', required: true }, - { key: 'date', label: '日期', type: 'date', required: true }, - { key: 'status', label: '状态', type: 'enum', enumFrom: 'attendance.status' }, + field('studentId', '学生', 'number', { required: true }), + field('date', '日期', 'date', { required: true }), + field('status', '状态', 'enum', { enumFrom: 'attendance.status' }), ], relations: [ - { entityKey: 'class', via: 'class_schedule', requiredFor: ['attendance'] }, - { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['attendance'] }, + relation('class', 'class_schedule', ['attendance']), + relation('schedule', 'class_schedule', ['attendance']), ], - }, - { - key: 'exam', - name: '考试/成绩', - description: '考试安排与成绩记录,依赖班级与学生档案。', + }), + entity('exam', '考试/成绩', '考试安排与成绩记录,依赖班级与学生档案。', { searchTool: 'search_exams', requiredPermissions: ['exam:view'], fields: [ - { key: 'name', label: '考试名称', type: 'string', required: true }, - { key: 'date', label: '考试日期', type: 'date' }, - { key: 'subject', label: '科目', type: 'string' }, + field('name', '考试名称', 'string', { required: true }), + field('date', '考试日期', 'date'), + field('subject', '科目', 'string'), ], relations: [ - { entityKey: 'class', via: 'exam_score', requiredFor: ['exam'] }, - { entityKey: 'student', via: 'exam_score', requiredFor: ['exam'] }, + relation('class', 'exam_score', ['exam']), + relation('student', 'exam_score', ['exam']), ], - }, - { - key: 'room', - name: '宿舍档案', - description: '宿舍/床位基础档案,入住登记的前置数据。', + }), + entity('room', '宿舍档案', '宿舍/床位基础档案,入住登记的前置数据。', { searchTool: 'search_rooms', requiredPermissions: ['room:view'], fields: [ - { key: 'roomNumber', label: '宿舍号', type: 'string', required: true }, - { key: 'building', label: '楼栋', type: 'string' }, - { key: 'floor', label: '楼层', type: 'number' }, - { key: 'capacity', label: '容量', type: 'number', required: true }, - { key: 'roomType', label: '房型', type: 'string' }, - { key: 'monthlyRate', label: '月租金', type: 'number' }, + field('roomNumber', '宿舍号', 'string', { required: true }), + field('building', '楼栋', 'string'), + field('floor', '楼层', 'number'), + field('capacity', '容量', 'number', { required: true }), + field('roomType', '房型', 'string'), + field('monthlyRate', '月租金', 'number'), ], - relations: [ - { entityKey: 'occupancy', via: 'occupancy', requiredFor: ['checkin'] }, - ], - }, - { - key: 'occupancy', - name: '入住记录', - description: '学生入住/换宿/退宿记录,费用与账单依赖入住状态。', + relations: [relation('occupancy', 'occupancy', ['checkin'])], + }), + entity('occupancy', '入住记录', '学生入住/换宿/退宿记录,费用与账单依赖入住状态。', { searchTool: 'get_room_occupancy_summary', requiredPermissions: ['occupancy:view'], fields: [ - { key: 'studentId', label: '学生', type: 'number', required: true }, - { key: 'roomId', label: '宿舍', type: 'number', required: true }, - { key: 'checkInDate', label: '入住日期', type: 'date', required: true }, - { key: 'billingStartDate', label: '计费开始日期', type: 'date', required: true }, - { key: 'stayType', label: '住宿类型', type: 'enum', enumFrom: 'occupancy.stayType' }, + field('studentId', '学生', 'number', { required: true }), + field('roomId', '宿舍', 'number', { required: true }), + field('checkInDate', '入住日期', 'date', { required: true }), + field('billingStartDate', '计费开始日期', 'date', { required: true }), + field('stayType', '住宿类型', 'enum', { enumFrom: 'occupancy.stayType' }), ], relations: [ - { entityKey: 'student', via: 'occupancy', requiredFor: ['checkin'] }, - { entityKey: 'room', via: 'occupancy', requiredFor: ['checkin'] }, - { entityKey: 'bill', via: 'bill', requiredFor: ['bill'] }, + relation('student', 'occupancy', ['checkin']), + relation('room', 'occupancy', ['checkin']), + relation('bill', 'bill', ['bill']), ], - }, - { - key: 'expense', - name: '费用', - description: '公共费用与个人费用,是生成账单的基础。', + }), + entity('expense', '费用', '公共费用与个人费用,是生成账单的基础。', { searchTool: 'search_expenses', requiredPermissions: ['expense:view'], fields: [ - { key: 'type', label: '费用类型', type: 'string', required: true }, - { key: 'amount', label: '金额', type: 'number', required: true }, - { key: 'periodStart', label: '费用开始日期', type: 'date' }, - { key: 'periodEnd', label: '费用结束日期', type: 'date' }, + field('type', '费用类型', 'string', { required: true }), + field('amount', '金额', 'number', { required: true }), + field('periodStart', '费用开始日期', 'date'), + field('periodEnd', '费用结束日期', 'date'), ], relations: [ - { entityKey: 'occupancy', via: 'expense', requiredFor: ['expense'] }, - { entityKey: 'bill', via: 'bill_item', requiredFor: ['bill'] }, + relation('occupancy', 'expense', ['expense']), + relation('bill', 'bill_item', ['bill']), ], - }, - { - key: 'bill', - name: '账单', - description: '按学生与账期生成的账单(公共+个人费用分摊),支持确认与付款。', + }), + entity('bill', '账单', '按学生与账期生成的账单(公共+个人费用分摊),支持确认与付款。', { searchTool: 'search_bills', requiredPermissions: ['bill:view'], fields: [ - { key: 'studentId', label: '学生', type: 'number', required: true }, - { key: 'periodStart', label: '账期开始', type: 'date', required: true }, - { key: 'periodEnd', label: '账期结束', type: 'date', required: true }, - { key: 'totalAmount', label: '总金额', type: 'number', required: true }, - { key: 'status', label: '状态', type: 'enum', enumFrom: 'bill.status' }, + field('studentId', '学生', 'number', { required: true }), + field('periodStart', '账期开始', 'date', { required: true }), + field('periodEnd', '账期结束', 'date', { required: true }), + field('totalAmount', '总金额', 'number', { required: true }), + field('status', '状态', 'enum', { enumFrom: 'bill.status' }), ], relations: [ - { entityKey: 'student', via: 'bill', requiredFor: ['bill'] }, - { entityKey: 'occupancy', via: 'bill', requiredFor: ['bill'] }, - { entityKey: 'deposit', via: 'deposit', requiredFor: ['deposit'] }, + relation('student', 'bill', ['bill']), + relation('occupancy', 'bill', ['bill']), + relation('deposit', 'deposit', ['deposit']), ], - }, - { - key: 'deposit', - name: '押金', - description: '押金收取与退还记录,通常在账单确认后处理。', + }), + entity('deposit', '押金', '押金收取与退还记录,通常在账单确认后处理。', { searchTool: 'search_deposits', requiredPermissions: ['deposit:view'], fields: [ - { key: 'studentId', label: '学生', type: 'number', required: true }, - { key: 'amount', label: '金额', type: 'number', required: true }, - { key: 'status', label: '状态', type: 'enum', enumFrom: 'deposit.status' }, + field('studentId', '学生', 'number', { required: true }), + field('amount', '金额', 'number', { required: true }), + field('status', '状态', 'enum', { enumFrom: 'deposit.status' }), ], relations: [ - { entityKey: 'student', via: 'deposit', requiredFor: ['deposit'] }, - { entityKey: 'bill', via: 'deposit', requiredFor: ['deposit'] }, + relation('student', 'deposit', ['deposit']), + relation('bill', 'deposit', ['deposit']), ], - }, - { - key: 'classroom', - name: '教室档案', - description: '教室基础档案,租赁与教室日程的前置数据。', + }), + entity('classroom', '教室档案', '教室基础档案,租赁与教室日程的前置数据。', { searchTool: 'search_classrooms', requiredPermissions: ['classroom:view'], fields: [ - { key: 'name', label: '教室名称', type: 'string', required: true }, - { key: 'building', label: '楼栋', type: 'string' }, - { key: 'capacity', label: '容量', type: 'number' }, + field('name', '教室名称', 'string', { required: true }), + field('building', '楼栋', 'string'), + field('capacity', '容量', 'number'), ], relations: [ - { entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] }, - { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + relation('rental', 'classroom_rental', ['rental']), + relation('schedule', 'class_schedule', ['schedule']), ], - }, - { - key: 'organization', - name: '组织/校区', - description: '校区与组织归属,租赁双方与档案归属依赖组织。', + }), + entity('organization', '组织/校区', '校区与组织归属,租赁双方与档案归属依赖组织。', { requiredPermissions: ['organization:view'], fields: [ - { key: 'name', label: '名称', type: 'string', required: true }, - { key: 'code', label: '编码', type: 'string' }, - { key: 'isHost', label: '是否本部', type: 'boolean' }, + field('name', '名称', 'string', { required: true }), + field('code', '编码', 'string'), + field('isHost', '是否本部', 'boolean'), ], relations: [ - { entityKey: 'student', via: 'organization', requiredFor: ['profile'] }, - { entityKey: 'rental', via: 'classroom_rental', requiredFor: ['rental'] }, + relation('student', 'organization', ['profile']), + relation('rental', 'classroom_rental', ['rental']), ], - }, - { - key: 'rental', - name: '教室租赁', - description: '教室租赁订单与合同(合同字段在租赁记录上),依赖教室与组织。', + }), + entity('rental', '教室租赁', '教室租赁订单与合同(合同字段在租赁记录上),依赖教室与组织。', { searchTool: 'search_classroom_rentals', requiredPermissions: ['rental:view'], fields: [ - { key: 'classroomId', label: '教室', type: 'number', required: true }, - { key: 'lesseeOrganizationId', label: '承租方', type: 'number' }, - { key: 'startDate', label: '开始日期', type: 'date', required: true }, - { key: 'endDate', label: '结束日期', type: 'date', required: true }, - { key: 'dailyRate', label: '日租金', type: 'number' }, - { key: 'contractPath', label: '合同文件', type: 'string' }, + field('classroomId', '教室', 'number', { required: true }), + field('lesseeOrganizationId', '承租方', 'number'), + field('startDate', '开始日期', 'date', { required: true }), + field('endDate', '结束日期', 'date', { required: true }), + field('dailyRate', '日租金', 'number'), + field('contractPath', '合同文件', 'string'), ], relations: [ - { entityKey: 'classroom', via: 'classroom_rental', requiredFor: ['rental'] }, - { entityKey: 'organization', via: 'classroom_rental', requiredFor: ['rental'] }, - { entityKey: 'schedule', via: 'class_schedule', requiredFor: ['schedule'] }, + relation('classroom', 'classroom_rental', ['rental']), + relation('organization', 'classroom_rental', ['rental']), + relation('schedule', 'class_schedule', ['schedule']), ], - }, + }), ]; export const BUSINESS_WORKFLOWS: readonly BusinessWorkflow[] = [ diff --git a/apps/server/src/agent-context/business-context.types.ts b/apps/server/src/agent-context/business-context.types.ts index 3b0b419..3b68eb5 100644 --- a/apps/server/src/agent-context/business-context.types.ts +++ b/apps/server/src/agent-context/business-context.types.ts @@ -21,7 +21,7 @@ export interface BusinessEntityRelation { entityKey: string; via: string; /** 需要该关系已建立的工作流阶段 key。 */ - requiredFor: string[]; + requiredFor: readonly string[]; } export interface BusinessEntity { From 14db28afc636723073152ae400b49f0cf736fbd5 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 15:39:33 +0800 Subject: [PATCH 18/20] =?UTF-8?q?fix(ai):=20=E4=BF=AE=E5=A4=8D=20Nest=20DI?= =?UTF-8?q?=20=E5=90=AF=E5=8A=A8=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A2uiSubmissionsService/DataSource/BusinessContextService 等构造参数 由 import type 改为值导入,确保 Nest 能解析依赖 - 验证:dev 模式后端正常启动,相关 243 个测试通过 --- apps/server/src/agent-context/get-business-context.tool.ts | 2 +- apps/server/src/agent-context/get-pending-tasks.tool.ts | 2 +- apps/server/src/agent-context/pending-tasks.service.ts | 2 +- apps/server/src/ai-chat/ai-chat.service-base.ts | 2 +- apps/server/src/ai-chat/ai-chat.service.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/src/agent-context/get-business-context.tool.ts b/apps/server/src/agent-context/get-business-context.tool.ts index 0a256fe..b138eac 100644 --- a/apps/server/src/agent-context/get-business-context.tool.ts +++ b/apps/server/src/agent-context/get-business-context.tool.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tools/agent-tool.types'; import { rejectUnknownKeys, optionalString } from '../agent-tools/tools/tool-input'; -import type { BusinessContextService } from './business-context.service'; +import { BusinessContextService } from './business-context.service'; interface GetBusinessContextInput { workflowKey?: string; diff --git a/apps/server/src/agent-context/get-pending-tasks.tool.ts b/apps/server/src/agent-context/get-pending-tasks.tool.ts index 1f68b97..20fb37a 100644 --- a/apps/server/src/agent-context/get-pending-tasks.tool.ts +++ b/apps/server/src/agent-context/get-pending-tasks.tool.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tools/agent-tool.types'; import { rejectUnknownKeys } from '../agent-tools/tools/tool-input'; import { BUSINESS_WORKFLOW_KEYS } from './business-context.registry'; -import type { PendingTasksService } from './pending-tasks.service'; +import { PendingTasksService } from './pending-tasks.service'; interface GetPendingTasksInput { workflowKey?: string; diff --git a/apps/server/src/agent-context/pending-tasks.service.ts b/apps/server/src/agent-context/pending-tasks.service.ts index 41db622..4f310f8 100644 --- a/apps/server/src/agent-context/pending-tasks.service.ts +++ b/apps/server/src/agent-context/pending-tasks.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import type { DataSource } from 'typeorm'; +import { DataSource } from 'typeorm'; import type { AgentToolContext } from '../agent-tools/agent-tool.types'; import type { StudentAccessScope } from '../students/student-access-scope'; import { StudentAccessScopeFactory } from '../students/student-access-scope.factory'; diff --git a/apps/server/src/ai-chat/ai-chat.service-base.ts b/apps/server/src/ai-chat/ai-chat.service-base.ts index 33bdd5f..92e3c07 100644 --- a/apps/server/src/ai-chat/ai-chat.service-base.ts +++ b/apps/server/src/ai-chat/ai-chat.service-base.ts @@ -12,7 +12,7 @@ import { AiChartService } from './ai-chart.service'; import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; -import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; +import { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index 8f71896..ec390e1 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -14,7 +14,7 @@ import { AiChartService } from './ai-chart.service'; import { AiExcelReaderService } from './ai-excel-reader.service'; import { AiFormService } from './ai-form.service'; import { AiReviewService } from './ai-review.service'; -import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; +import { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AiConversation, AiMessage, AiToolRun } from './entities'; From 9048816abc161e15c7a80878d587242cdc17d104 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 15:50:38 +0800 Subject: [PATCH 19/20] =?UTF-8?q?feat(ai):=20=E7=A7=BB=E9=99=A4=20Excel=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E9=A2=84=E6=A3=80=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件 - 删除 imports.preflight 解析器与 PreflightReport 类型 - SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard - 前端同步移除预检类型/组件/测试,保留导入向导 --- .../components/AiChat/AiMessageContent.tsx | 30 - .../components/AiChat/ImportPreflightCard.tsx | 377 --------- .../components/AiChat/api.integration.test.ts | 57 +- apps/admin/src/components/AiChat/api.ts | 89 -- .../AiChat/bubble.integration.test.tsx | 239 ------ .../message-mappers.integration.test.ts | 21 - .../AiChat/provider.integration.test.ts | 40 - .../admin/src/components/AiChat/sseReducer.ts | 4 - apps/admin/src/components/AiChat/types.ts | 63 -- .../src/components/AiChat/uiArtifacts.ts | 2 - .../AiChat/useAiChatMessageActions.tsx | 30 +- apps/server/src/ai-chat/ai-a2ui.artifact.ts | 2 - apps/server/src/ai-chat/ai-chat.constants.ts | 43 +- apps/server/src/ai-chat/ai-chat.controller.ts | 18 - apps/server/src/ai-chat/ai-chat.generation.ts | 3 +- .../src/ai-chat/ai-chat.service-base.ts | 35 +- .../src/ai-chat/ai-chat.service.spec.ts | 789 ------------------ .../server/src/ai-chat/ai-chat.submissions.ts | 155 ---- .../ai-chat/ai-chat.tool-actions.import.ts | 126 +-- .../src/ai-chat/ai-chat.tool-actions.ts | 1 - apps/server/src/ai-chat/ai-chat.tools.ts | 4 - apps/server/src/ai-chat/ai-chat.types.ts | 1 - apps/server/src/ai-chat/dto/ai-chat.dto.ts | 13 - .../src/imports/entities/import-run.entity.ts | 2 +- .../src/imports/imports.preflight.spec.ts | 232 ----- apps/server/src/imports/imports.preflight.ts | 425 ---------- .../src/imports/imports.service.spec.ts | 23 - apps/server/src/imports/imports.service.ts | 16 - apps/server/src/imports/imports.types.ts | 88 -- 29 files changed, 14 insertions(+), 2914 deletions(-) delete mode 100644 apps/admin/src/components/AiChat/ImportPreflightCard.tsx delete mode 100644 apps/server/src/imports/imports.preflight.spec.ts delete mode 100644 apps/server/src/imports/imports.preflight.ts diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx index f9e10de..f176047 100644 --- a/apps/admin/src/components/AiChat/AiMessageContent.tsx +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -16,17 +16,14 @@ import { useUserStore } from '../../store/user/userStore'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; -import { ImportPreflightCard } from './ImportPreflightCard'; import { LiteCodeHighlighter } from './LiteCodeHighlighter'; import { LiteMermaid } from './LiteMermaid'; -import type { ResolveImportPreflightInput } from './api'; import type { AiAttachment, AiChatMessage, AiChatMessageStatus, AiChartSchema, AiFormSchema, - AiImportPreflight, AiImportWizard, AiReviewSection, AiReviewSchema, @@ -46,7 +43,6 @@ const toolLabels: Record = { render_form: '生成表单', render_review: '生成导入预览', render_chart: '生成图表', - preflight_import: '导入预检', start_import_wizard: '生成导入向导', create_student: '创建学生', search_exams: '查询考试', @@ -193,11 +189,6 @@ export interface AiMessageContentProps { type: AiReviewSectionType, ) => AiReviewSchema | Promise | void; onOpenImportWizard?: (runId: string) => void; - onResolveImportPreflight?: ( - messageId: number | undefined, - preflight: AiImportPreflight, - input: ResolveImportPreflightInput, - ) => Promise | void; } export const AiMessageContent: React.FC = ({ @@ -211,7 +202,6 @@ export const AiMessageContent: React.FC = ({ onConfirmReviewStep, onConfirmReviewGroup, onOpenImportWizard, - onResolveImportPreflight, }) => { const streaming = status === 'loading' || status === 'updating'; const formSubmission = message.metadata?.a2uiSubmit; @@ -324,26 +314,6 @@ export const AiMessageContent: React.FC = ({ {attachmentCards} )} - {(() => { - const preflight = message.metadata?.a2uiImportPreflight; - if (!preflight || typeof preflight !== 'object' || Array.isArray(preflight)) return null; - const typedPreflight = preflight as AiImportPreflight; - return ( - - onResolveImportPreflight( - typeof message.id === 'number' ? message.id : undefined, - typedPreflight, - input, - ) - : undefined - } - /> - ); - })()} {(() => { const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined; if (!wizard || !onOpenImportWizard) return null; diff --git a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx b/apps/admin/src/components/AiChat/ImportPreflightCard.tsx deleted file mode 100644 index 42bfe23..0000000 --- a/apps/admin/src/components/AiChat/ImportPreflightCard.tsx +++ /dev/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; -} - -/** - * 上传 Excel 后的“可插入性预检”交互卡:展示判定结论、分阶段统计与阻断原因, - * 并让用户直接在卡内确认列映射与导入策略,点击「生成导入向导」由服务端建任务。 - * 对应 A2UI demo 中“同一 surface 内完成用户交互 + 增量更新”的交互方式。 - */ -export const ImportPreflightCard: React.FC = ({ - 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>>( - () => - Object.fromEntries( - preflight.stages.map((stage) => [stage.stepKey, { ...stage.mapping }]), - ), - ); - const [settingsDraft, setSettingsDraft] = useState(() => - defaultSettings(preflight.questions), - ); - const [customOrganization, setCustomOrganization] = useState(false); - const [organizationInput, setOrganizationInput] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(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 ( - - - Excel 导入预检 - {verdict.label} - {resolved && ( - }> - 已生成向导 - - )} - - } - > - - {resolved && ( - - )} - {error && } - - {preflight.stages.length > 0 ? ( - - {preflight.stages.map((stage) => ( - - - {stage.label} - - {stage.sheetNames.join('、')} - - {stage.stepKey && !permitted.has(stage.stepKey) && ( - 无提交权限 - )} - - - 共 {stage.total} 行 - 新建 {stage.create} - 更新 {stage.update} - 错误 {stage.error} - 跳过 {stage.skip} - - - ))} - - ) : ( - 未识别到可导入的工作表 - )} - - {preflight.blocks.length > 0 && ( - - - 阻断原因 - - {preflight.blocks.map((block) => ( - - {block.label} - - {block.message}({block.count} 行) - - - ))} - - )} - - {showActions && ( - <> - {preflight.stages - .filter( - (stage) => - stage.missingRequired.length > 0 && - stage.stepKey && - permitted.has(stage.stepKey), - ) - .map((stage) => ( - - - 列映射 - - {stage.label} - - - 缺少:{stage.missingRequired.join('、')} - - - - {missingFieldKeys(stage.stepKey, stage.missingRequired).map((field) => { - const fieldMeta = STEP_FIELDS[stage.stepKey]?.find( - (item) => item.key === field, - ); - return ( - { - if (value === '__custom__') { - setCustomOrganization(true); - } else { - setCustomOrganization(false); - setSettingsDraft((prev) => ({ ...prev, organization: value })); - } - }} - /> - {customOrganization && ( - setOrganizationInput(event.target.value)} - /> - )} - - ) : ( - { - 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) => ( - - {option.label} - - ))} - - )} - - ))} - - - - 未确认的列可在向导中继续调整 - - - - - )} - - {preflight.nextSteps.length > 0 && ( - - - 下一步建议 - - {preflight.nextSteps.map((step) => ( - - {step.label} - - {step.description} - - - ))} - - )} - - - ); -}; diff --git a/apps/admin/src/components/AiChat/api.integration.test.ts b/apps/admin/src/components/AiChat/api.integration.test.ts index 5fdeb30..94d113e 100644 --- a/apps/admin/src/components/AiChat/api.integration.test.ts +++ b/apps/admin/src/components/AiChat/api.integration.test.ts @@ -1,10 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import api from '../../api'; -import { - aiChatApi, - resolveImportPreflight, - type ImportPreflightResolveUpdate, -} from './api'; +import { aiChatApi } from './api'; describe('AI chat API adapter', () => { afterEach(() => vi.restoreAllMocks()); @@ -75,55 +71,4 @@ describe('AI chat API adapter', () => { '/ai/chat/reviews/review-1/types/checkins/confirm', ); }); - - it('resolveImportPreflight 流式解析 SSE 事件并回调预检卡/向导更新', async () => { - const encoder = new TextEncoder(); - const body = new ReadableStream({ - 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」不在工作表表头中'); - }); }); diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts index c1683b9..f336abc 100644 --- a/apps/admin/src/components/AiChat/api.ts +++ b/apps/admin/src/components/AiChat/api.ts @@ -1,11 +1,8 @@ import api from '../../api'; -import { useUserStore } from '../../store/user/userStore'; import type { AiApiResponse, AiAttachment, AiConversation, - AiImportPreflight, - AiImportWizard, AiMessagePage, AiReviewSchema, AiReviewSection, @@ -86,89 +83,3 @@ export const aiChatApi = { export function conversationStreamUrl(id: number): string { return `/api${basePath}/${id}/stream`; } - -export interface ResolveImportPreflightInput { - mapping?: Record>; - 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 { - 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; - try { - data = JSON.parse(dataLine) as Record; - } 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 }); - } - } - } -} diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx index 46f1352..761bc73 100644 --- a/apps/admin/src/components/AiChat/bubble.integration.test.tsx +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -7,7 +7,6 @@ import { AiMessageContent } from './AiMessageContent'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; -import type { ResolveImportPreflightInput } from './api'; import type { AiChatMessage, AiChartSchema, AiReviewSchema } from './types'; let container: HTMLDivElement | null = null; @@ -429,244 +428,6 @@ describe('AI chat bubble rendering', () => { expect(container.textContent).toContain('26暑期文化课宿舍.xlsx'); }); - it('renders an import preflight card from assistant message metadata', async () => { - const message: AiChatMessage = { - role: 'assistant', - content: '这是预检结果', - reasoningContent: '', - toolRuns: [], - attachments: [], - metadata: { - a2uiImportPreflight: { - verdict: 'needs_input', - stages: [ - { - stepKey: 'students', - label: '学生档案', - sheetNames: ['学生'], - total: 2, - create: 1, - update: 1, - error: 0, - skip: 0, - mapping: { name: '姓名' }, - missingRequired: [], - }, - ], - blocks: [{ code: 'unknown_organization', label: '未知校区', stepKeys: ['students'], message: '校区不存在', count: 1 }], - questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }], - nextSteps: [ - { - key: 'students-next', - label: '分班 / 排课 / 入住', - description: '学生档案导入完成后可继续分班、排课或入住。', - after: ['students'], - }, - ], - }, - }, - }; - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - - 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( - { - 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( - { - resolvedMessageId = messageId; - resolvedInput = input; - }} - />, - ); - }); - - expect(container.textContent).toContain('容量'); - const resolveButton = Array.from(container.querySelectorAll('button')).find((item) => - item.textContent?.includes('生成导入向导'), - ) as HTMLButtonElement | undefined; - expect(resolveButton).toBeDefined(); - await act(async () => { - resolveButton?.click(); - }); - - expect(resolvedMessageId).toBe(42); - expect(resolvedInput?.mapping).toMatchObject({ - students: { name: '姓名', studentNo: '学号' }, - rooms: { roomNumber: '宿舍号' }, - }); - expect(resolvedInput?.settings).toMatchObject({ updateExisting: true }); - }); - it('renders model retrying hint while waiting for the upstream retry', async () => { const message: AiChatMessage = { role: 'assistant', diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts index baf9410..5363603 100644 --- a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -186,25 +186,4 @@ describe('AI chat history mapper', () => { expect(mapped.message.charts?.[0]).toMatchObject({ id: 'chart-9', chartType: 'bar' }); }); - it('keeps import preflight metadata on history messages', () => { - const preflight = { - verdict: 'needs_input', - stages: [], - blocks: [], - questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }], - nextSteps: [], - }; - const mapped = mapHistoryMessage({ - id: 8, - role: 'assistant', - content: '预检完成', - reasoningContent: null, - status: 'completed', - errorCode: null, - createdAt: '2026-07-23T00:00:00.000Z', - metadata: { a2uiImportPreflight: preflight }, - }); - - expect(mapped.message.metadata?.a2uiImportPreflight).toEqual(preflight); - }); }); diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts index c552dfe..a0761d0 100644 --- a/apps/admin/src/components/AiChat/provider.integration.test.ts +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -189,46 +189,6 @@ describe('AI chat SSE message reducer', () => { expect(message.reviews?.[0]).toMatchObject({ id: 'review-1', status: 'expired' }); }); - it('stores ui.import_preflight in message metadata and restores it from completed message', () => { - const preflight = { - verdict: 'needs_input', - stages: [ - { - stepKey: 'students', - label: '学生档案', - sheetNames: ['学生'], - total: 2, - create: 1, - update: 1, - error: 0, - skip: 0, - mapping: { name: '姓名' }, - missingRequired: [], - }, - ], - blocks: [], - questions: [{ key: 'update', type: 'update', label: '文件中有 1 行已匹配现有记录' }], - nextSteps: [{ key: 'students-next', label: '分班 / 排课 / 入住', description: '建议', after: ['students'] }], - }; - let message = reduceAiSseMessage(undefined, { - event: 'ui.import_preflight', - data: JSON.stringify({ messageId: 8, preflight }), - }); - expect(message.metadata?.a2uiImportPreflight).toEqual(preflight); - - message = reduceAiSseMessage(message, { - event: 'message.completed', - data: JSON.stringify({ - message: { - id: 8, - content: '预检完成', - status: 'completed', - metadata: { a2uiImportPreflight: preflight }, - }, - }), - }); - expect(message.metadata?.a2uiImportPreflight).toEqual(preflight); - }); it('restores a persisted form from message.completed metadata', () => { const message = reduceAiSseMessage(undefined, { diff --git a/apps/admin/src/components/AiChat/sseReducer.ts b/apps/admin/src/components/AiChat/sseReducer.ts index 6b1ac2e..e58a224 100644 --- a/apps/admin/src/components/AiChat/sseReducer.ts +++ b/apps/admin/src/components/AiChat/sseReducer.ts @@ -4,7 +4,6 @@ import type { AiChartSchema, AiChatMessage, AiFormSchema, - AiImportPreflight, AiModelRetryInfo, AiReviewSchema, AiSseChunk, @@ -30,7 +29,6 @@ export interface AiSsePayload { artifact?: AiArtifactSchema; review?: AiReviewSchema; chart?: AiChartSchema; - preflight?: AiImportPreflight; wizard?: unknown; retry?: AiModelRetryInfo; message?: @@ -169,8 +167,6 @@ export function reduceAiSseMessage( message.charts = mergeById(message.charts, payload.chart); } else if (event === 'ui.artifact' && payload.artifact) { mergeArtifactIntoMessage(message, payload.artifact); - } else if (event === 'ui.import_preflight' && payload.preflight) { - message.metadata = { ...message.metadata, a2uiImportPreflight: payload.preflight }; } else if (event === 'ui.import_wizard' && payload.wizard) { message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard }; } else if (event === 'tool.started') { diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts index 31bac56..aee0823 100644 --- a/apps/admin/src/components/AiChat/types.ts +++ b/apps/admin/src/components/AiChat/types.ts @@ -102,7 +102,6 @@ export type AiArtifactType = | 'form' | 'review' | 'chart' - | 'import_preflight' | 'import_wizard'; export type AiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled'; @@ -136,68 +135,6 @@ export interface AiImportWizard { }>; } -export type AiImportPreflightVerdict = 'ready' | 'needs_input' | 'blocked'; - -export interface AiImportPreflightStage { - stepKey: AiReviewSectionType; - label: string; - sheetNames: string[]; - /** 该阶段所有工作表的表头并集,供预检卡列映射选择。 */ - headers?: string[]; - total: number; - create: number; - update: number; - error: number; - skip: number; - mapping: Record; - missingRequired: string[]; -} - -export interface AiImportPreflightBlock { - code: string; - label: string; - stepKeys: AiReviewSectionType[]; - message: string; - count: number; -} - -export interface AiImportPreflightQuestion { - key: string; - type: 'mapping' | 'organization' | 'update' | 'duplicate' | 'reference'; - label: string; - description?: string; - stepKey?: AiReviewSectionType; - options?: Array<{ label: string; value: string }>; - default?: string | boolean; -} - -export interface AiImportPreflightNextStep { - key: string; - label: string; - description: string; - after: AiReviewSectionType[]; -} - -export interface AiImportPreflight { - verdict: AiImportPreflightVerdict; - stages: AiImportPreflightStage[]; - blocks: AiImportPreflightBlock[]; - questions: AiImportPreflightQuestion[]; - nextSteps: AiImportPreflightNextStep[]; - attachmentId?: number; - headerRow?: number; - permittedSteps?: AiReviewSectionType[]; - resolved?: boolean; - runId?: string | null; - errorSamples?: Array<{ - code: string; - stepKey: AiReviewSectionType; - sheet: string; - rowNumber: number; - errors: string[]; - }>; -} - export type AiToolRunStatus = | 'running' | 'success' diff --git a/apps/admin/src/components/AiChat/uiArtifacts.ts b/apps/admin/src/components/AiChat/uiArtifacts.ts index abb20e2..32f1f59 100644 --- a/apps/admin/src/components/AiChat/uiArtifacts.ts +++ b/apps/admin/src/components/AiChat/uiArtifacts.ts @@ -60,8 +60,6 @@ export function mergeArtifactIntoMessage( message.reviews = mergeById(message.reviews, payload as AiReviewSchema); } else if (artifact.type === 'chart') { message.charts = mergeById(message.charts, payload as AiChartSchema); - } else if (artifact.type === 'import_preflight') { - message.metadata = { ...message.metadata, a2uiImportPreflight: payload }; } else if (artifact.type === 'import_wizard') { message.metadata = { ...message.metadata, a2uiImportWizard: payload }; } diff --git a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx index ad0b032..4d7bdea 100644 --- a/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx +++ b/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx @@ -6,7 +6,7 @@ import { App } from 'antd'; import type { UploadFile, UploadProps } from 'antd'; import { message } from '../../ui/app-message'; import { useSettingsStore } from '../../store/settings/settingsStore'; -import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api'; +import { aiChatApi } from './api'; import { AiMessageContent } from './AiMessageContent'; import { mapHistoryMessage } from './message-mappers'; import { GongxueAiChatProvider } from './provider'; @@ -25,7 +25,6 @@ import type { AiChatMessage, AiChatMessageStatus, AiFormSchema, - AiImportPreflight, AiReviewSchema, AiReviewSection, AiReviewSectionType, @@ -416,31 +415,6 @@ export function useAiChatMessageActions({ [provider, setMessage], ); - const resolvePreflight = useCallback( - async ( - messageId: number | undefined, - _preflight: AiImportPreflight, - input: ResolveImportPreflightInput, - ): Promise => { - 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>(async (options) => { const file = options.file as File; if (attachmentsRef.current.length >= 5) { @@ -556,7 +530,6 @@ export function useAiChatMessageActions({ onConfirmReviewStep={confirmReviewStep} onConfirmReviewGroup={confirmReviewGroup} onOpenImportWizard={setImportWizardRunId} - onResolveImportPreflight={resolvePreflight} /> ), })), @@ -570,7 +543,6 @@ export function useAiChatMessageActions({ isRequesting, messages, reloadMessage, - resolvePreflight, setImportWizardRunId, submitForm, submitReview, diff --git a/apps/server/src/ai-chat/ai-a2ui.artifact.ts b/apps/server/src/ai-chat/ai-a2ui.artifact.ts index 11689fd..0600148 100644 --- a/apps/server/src/ai-chat/ai-a2ui.artifact.ts +++ b/apps/server/src/ai-chat/ai-a2ui.artifact.ts @@ -2,7 +2,6 @@ export type A2uiArtifactType = | 'form' | 'review' | 'chart' - | 'import_preflight' | 'import_wizard'; export type A2uiArtifactStatus = 'rendering' | 'pending' | 'submitted' | 'expired' | 'cancelled'; @@ -23,7 +22,6 @@ const A2UI_ARTIFACT_TYPES = new Set([ 'form', 'review', 'chart', - 'import_preflight', 'import_wizard', ]); diff --git a/apps/server/src/ai-chat/ai-chat.constants.ts b/apps/server/src/ai-chat/ai-chat.constants.ts index 5afa881..5948a65 100644 --- a/apps/server/src/ai-chat/ai-chat.constants.ts +++ b/apps/server/src/ai-chat/ai-chat.constants.ts @@ -13,31 +13,6 @@ const CELL_VALUE_ANY_OF = [ ]; 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, function: { @@ -71,7 +46,7 @@ export const A2UI_TOOL_SCHEMAS = [ mapping: { type: 'object', description: - '列映射确认结果:阶段 stepKey -> { 字段名: 工作表表头 }(字段名如 students.name/rooms.roomNumber/checkins.checkInDate/transfers.newRoom)。来自 preflight_import 报告的映射确认;未确认时省略,系统自动识别。', + '列映射确认结果:阶段 stepKey -> { 字段名: 工作表表头 }(字段名如 students.name/rooms.roomNumber/checkins.checkInDate/transfers.newRoom)。用户确认后传入;未确认时省略,系统自动识别。', additionalProperties: { type: 'object', description: '字段名 -> 工作表表头', @@ -80,7 +55,7 @@ export const A2UI_TOOL_SCHEMAS = [ }, organization: { type: 'string', - description: '确认后的校区名称(预检报告出现未知校区时由用户确认)', + description: '确认后的校区名称(出现未知校区时由用户确认)', maxLength: 100, }, updateExisting: { @@ -201,20 +176,18 @@ export const SYSTEM_PROMPT = `你是恭学系统的业务助理。回答必须 新增学生示例:render_form 的 fields 使用 name/phone/gender/studentNo。 修改学生示例:批量修改姓名/档案时,render_form 的字段可用 name_<学生ID> 等命名展示待修改内容,用户提交后再调用 update_students,每条更新必须带学生 id。 当用户上传 Excel 并需要批量导入(学生、宿舍、换宿、入住记录)时,按以下固定流程执行: -1. 先调用 preflight_import(传入 attachmentId)生成“可插入性预检报告”:报告给出分阶段行数(新建/更新/错误/跳过)、阻断原因(缺列、未找到学生/宿舍、文件内重复、已有在住、格式错误、未知校区)、需要确认的问题和导入后的下一步建议。 -2. 预检报告会以可交互卡片显示给用户:卡内已提供列映射控件和策略控件(更新已有记录、重复行策略、校区、未匹配行处理),并有「生成导入向导」按钮。引导用户在卡内完成确认并点击按钮即可生成向导,不要在聊天里反复确认卡内已覆盖的问题。你只需说明报告结论:blocked 时解释阻断原因并建议修正文件后重传(因缺少列映射而 blocked 时提示在卡内补全映射);needs_input 时说明需要确认的问题并提示在卡内选择;ready 时提示可直接在卡内生成向导。卡内未覆盖的自由输入(如自定义校区)才在聊天中向用户提问。不要替用户默认做出影响数据的决定。 - 报告只给汇总统计时,基于报告中的 errorSamples(工作表与行号、示例值)向用户解释具体错误原因(如某行缺少手机号、姓名带日期后缀、宿舍未建档等)。 -3. 仅当用户明确在聊天文本中给出确认(而非使用预检卡)时,才调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。若当前消息已通过预检卡生成向导,不要重复调用。 -4. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。 +1. 先根据消息附带的 Excel 提取文本(工作表名 + tab 分隔行)判断业务类型与表头,向用户说明将导入什么、依赖什么;需要确认的列映射、校区或策略先在聊天中与用户确认,不要替用户默认做出影响数据的决定。 +2. 用户确认后调用 start_import_wizard:必须传入 attachmentId 和 stages(业务类型 stepKey:students 学生 / rooms 宿舍 / checkins 入住 / transfers 换宿,以及对应工作表 sheet 名),并把确认结果一并传入(mapping 列映射、organization 校区、updateExisting 是否更新已有记录、duplicatePolicy 重复行策略 error/skip、skipUnmatched 是否跳过未匹配行)。系统直接从文件解析行数据,禁止把整表数据抄进工具参数或凭空补全。 +3. 生成向导后提示用户打开,按“基础档案(学生/宿舍)→ 关系(入住/换宿)”的顺序逐阶段预览,人工确认后系统才会入库;不要代替用户调用任何写工具直接插入。 工具结果中的 permittedSteps 表示当前用户可提交的阶段,只引导这些阶段,未列出的阶段不要建议提交或执行。 -每个回答回合最多调用一次 preflight_import 和一次 start_import_wizard;报告与导入完成后由你给出下一步建议,不要自动执行后续写操作。 +每个回答回合最多调用一次 start_import_wizard;导入完成后由你给出下一步建议,不要自动执行后续写操作。 当用户需要可视化数据(趋势、占比、对比、多维、完成率等)时,调用 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_pending_tasks 或现有查询工具核实前置数据是否已存在:入住依赖学生和宿舍,换宿依赖学生和宿舍,账单依赖入住记录和费用,考勤依赖班级和排课。前置缺失时,先向用户说明缺什么、建议先完成哪一步,再继续,不要机械地跳过依赖直接入库。 - 导入或录入完成后,根据完成阶段主动给出下一步建议(例如:入住完成 → 建议录入本月公共费用 → 生成并确认账单;学生档案完成 → 建议分班;租赁订单生成 → 建议补充合同),可用 get_pending_tasks 获取有数据支撑的待办。 -- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;疑似导入时先调用 preflight_import 生成预检报告,再引导用户在预检卡内确认并生成导入向导,按依赖顺序执行。 +- 用户上传 Excel 但未说明用途时,先根据表头判断包含哪些业务,向用户说明将导入什么、依赖什么;确认后调用 start_import_wizard 生成导入向导,按依赖顺序执行。 - 只引导当前角色权限范围内可执行的下一步,不得建议或执行用户无权操作。 不得扩大用户权限或猜测不可见数据。回答使用简洁中文 Markdown。`; diff --git a/apps/server/src/ai-chat/ai-chat.controller.ts b/apps/server/src/ai-chat/ai-chat.controller.ts index e092adc..49339f9 100644 --- a/apps/server/src/ai-chat/ai-chat.controller.ts +++ b/apps/server/src/ai-chat/ai-chat.controller.ts @@ -30,7 +30,6 @@ import { EditMessageDto, MessagePageQueryDto, RegenerateMessageDto, - ResolveImportPreflightDto, SendMessageDto, SubmitFormDto, 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 { - 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') async confirmReviewStep( @Req() req: AuthenticatedRequest, diff --git a/apps/server/src/ai-chat/ai-chat.generation.ts b/apps/server/src/ai-chat/ai-chat.generation.ts index 6a63bf6..1d86acd 100644 --- a/apps/server/src/ai-chat/ai-chat.generation.ts +++ b/apps/server/src/ai-chat/ai-chat.generation.ts @@ -73,8 +73,7 @@ export async function executeGeneration( tool.function.name !== 'create_student' && tool.function.name !== 'update_students' && tool.function.name !== 'render_form' && - tool.function.name !== 'start_import_wizard' && - tool.function.name !== 'preflight_import', + tool.function.name !== 'start_import_wizard', ); } tools.push(...A2UI_TOOL_SCHEMAS); diff --git a/apps/server/src/ai-chat/ai-chat.service-base.ts b/apps/server/src/ai-chat/ai-chat.service-base.ts index 92e3c07..6daeb0f 100644 --- a/apps/server/src/ai-chat/ai-chat.service-base.ts +++ b/apps/server/src/ai-chat/ai-chat.service-base.ts @@ -47,9 +47,7 @@ import { } from './ai-chat.streaming'; import { resolveFormConversationId, - resolvePreflightConversationId, resolveReviewConversationId, - resolveImportPreflight, submitForm, submitReview, confirmReviewStep, @@ -63,10 +61,7 @@ import { markReviewSubmittedOnMessage, } from './ai-chat.submissions'; import { denyWriteTool, executeTool } from './ai-chat.tools'; -import { - executePreflightImport, - executeStartImportWizard, -} from './ai-chat.tool-actions'; +import { executeStartImportWizard } from './ai-chat.tool-actions'; export abstract class AiChatServiceBase implements AiChatServiceContext { readonly activeConversations = new Set(); @@ -209,15 +204,6 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { return executeStartImportWizard(this, messageId, call, context, emit); } - executePreflightImport( - messageId: number, - call: ModelToolCall, - context: ReturnType, - emit: AiSseEmitter, - ): Promise { - return executePreflightImport(this, messageId, call, context, emit); - } - listConversations(userId: number): Promise { return listConversations(this, userId); } @@ -318,10 +304,6 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { return resolveReviewConversationId(this, userId, reviewId); } - resolvePreflightConversationId(userId: number, messageId: number): Promise { - return resolvePreflightConversationId(this, userId, messageId); - } - submitForm( user: AuthenticatedUser, formId: string, @@ -348,21 +330,6 @@ export abstract class AiChatServiceBase implements AiChatServiceContext { return submitReview(this, user, reviewId, dto, signal, emit, onReady); } - resolveImportPreflight( - user: AuthenticatedUser, - messageId: number, - dto: { - clientRequestId: string; - mapping?: Record; - settings?: Record; - }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, - ): Promise { - return resolveImportPreflight(this, user, messageId, dto, signal, emit, onReady); - } - confirmReviewStep( user: AuthenticatedUser, reviewId: string, diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index 9b2fb64..50a5f94 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -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 }> = []; - 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; - } - ).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 }), - ); - - 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; - } - ).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 }> = []; - - 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; - } - ).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 }), - ); - - 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 () => { const { service } = createService(); const toolRun = { id: 1, status: 'running' }; @@ -1813,542 +1560,6 @@ describe('AiChatService', () => { 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 }> = []; - 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 }), - 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 }> = []; - 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; - } - ).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 }), - ); - - 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); - }); }); diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index 94dacc6..ea8a9ad 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -1,20 +1,8 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; import type { AiChatServiceContext, AiSseEmitter, } from './ai-chat.types'; 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( context: AiChatServiceContext, @@ -34,149 +22,6 @@ export async function resolveReviewConversationId( return review.conversationId; } -export async function resolvePreflightConversationId( - context: AiChatServiceContext, - userId: number, - messageId: number, -): Promise { - 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 | 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; - 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; - settings?: Record; - }, - signal: AbortSignal, - emit: AiSseEmitter, - onReady: () => void, -): Promise { - 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> = {}; - 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 { assertReviewImportPermissions, confirmReviewGroup, diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts index 25067d9..d5e07b1 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts @@ -2,7 +2,6 @@ import { IMPORT_STEP_KEYS, type ImportStageRequest, type ImportStepKey, - type PreflightReport, } from '../imports/imports.types'; import { permittedStepKeys } from '../imports/imports.access'; import { expandStageSheets } from '../imports/imports.mapping'; @@ -100,76 +99,13 @@ type ImportToolExecutor = ( ) => Promise; function makeImportToolExecutor( - toolName: 'preflight_import' | 'start_import_wizard', + toolName: 'start_import_wizard', handler: (tool: ImportToolContext) => Promise, ): ImportToolExecutor { return (context, messageId, call, agentContext, emit) => 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( 'start_import_wizard', async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => { @@ -219,18 +155,8 @@ export const executeStartImportWizard = makeImportToolExecutor( settings, ); const wizard = compactImportWizard(detail); - const preflightMeta = assistant.metadata?.a2uiImportPreflight; assistant.metadata = { ...assistant.metadata, - ...(preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta) - ? { - a2uiImportPreflight: { - ...(preflightMeta as Record), - resolved: true, - runId: detail.id, - }, - } - : {}), a2uiImportWizard: wizard, }; await context.messages.save(assistant); @@ -242,16 +168,6 @@ export const executeStartImportWizard = makeImportToolExecutor( .map((step) => step.label) .join('、')}`, }, emit); - if (preflightMeta && typeof preflightMeta === 'object' && !Array.isArray(preflightMeta)) { - emit('ui.import_preflight', { - messageId, - preflight: { - ...(preflightMeta as Record), - resolved: true, - runId: detail.id, - }, - }); - } emit('ui.import_wizard', { messageId, wizard }); emit('ui.artifact', { 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): { runId: string; fileName: string; diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index 7b8fe3d..aea7f18 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -270,6 +270,5 @@ export async function executeRenderChart( export { compactImportWizard, - executePreflightImport, executeStartImportWizard, } from './ai-chat.tool-actions.import'; diff --git a/apps/server/src/ai-chat/ai-chat.tools.ts b/apps/server/src/ai-chat/ai-chat.tools.ts index 65376c0..3a0e6c1 100644 --- a/apps/server/src/ai-chat/ai-chat.tools.ts +++ b/apps/server/src/ai-chat/ai-chat.tools.ts @@ -2,7 +2,6 @@ import { AgentToolContextFactory } from '../agent-tools/agent-tool.types'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import type { AiToolRun } from './entities'; import { - executePreflightImport, executeRenderChart, executeRenderForm, executeStartImportWizard, @@ -89,9 +88,6 @@ export async function executeTool( if (call.name === 'render_form') { 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') { return executeStartImportWizard(context, messageId, call, agentContext, emit); } diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index e51cd7e..fff9ed6 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -185,7 +185,6 @@ export type AiSseEventName = | 'ui.review' | 'ui.chart' | 'ui.artifact' - | 'ui.import_preflight' | 'ui.import_wizard' | 'attachment.processed' | 'message.completed' diff --git a/apps/server/src/ai-chat/dto/ai-chat.dto.ts b/apps/server/src/ai-chat/dto/ai-chat.dto.ts index e4a5340..6b16241 100644 --- a/apps/server/src/ai-chat/dto/ai-chat.dto.ts +++ b/apps/server/src/ai-chat/dto/ai-chat.dto.ts @@ -110,19 +110,6 @@ export class SubmitReviewDto { reasoningEffort?: string | null; } -export class ResolveImportPreflightDto { - @IsUUID() - clientRequestId: string; - - @IsOptional() - @IsObject() - mapping?: Record; - - @IsOptional() - @IsObject() - settings?: Record; -} - export class MessagePageQueryDto { @IsOptional() @Type(() => Number) diff --git a/apps/server/src/imports/entities/import-run.entity.ts b/apps/server/src/imports/entities/import-run.entity.ts index 6654311..b0d2fb2 100644 --- a/apps/server/src/imports/entities/import-run.entity.ts +++ b/apps/server/src/imports/entities/import-run.entity.ts @@ -23,7 +23,7 @@ export class ImportRun { @Column({ name: 'sheets_json', type: 'mediumtext' }) 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 }) settingsJson: string | null; diff --git a/apps/server/src/imports/imports.preflight.spec.ts b/apps/server/src/imports/imports.preflight.spec.ts deleted file mode 100644 index 01f10fc..0000000 --- a/apps/server/src/imports/imports.preflight.spec.ts +++ /dev/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(/^入住日期|日期$/), - }); - }); -}); diff --git a/apps/server/src/imports/imports.preflight.ts b/apps/server/src/imports/imports.preflight.ts deleted file mode 100644 index dc0c8ff..0000000 --- a/apps/server/src/imports/imports.preflight.ts +++ /dev/null @@ -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 = { - 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 = { - name: '姓名', - roomNumber: '宿舍号', - capacity: '容量', - checkInDate: '入住日期', - oldRoom: '原宿舍', - newRoom: '新宿舍', - transferDate: '换宿日期', - identity: '学号或手机号', -}; - -const NEXT_STEP_DEFS: Array = [ - { - 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(); - 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 { - // 阶段级映射取各表建议的并集,供预检卡预填;实际按表解析在下方逐表进行。 - 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(); - const unknownOrgs = new Set(); - const batchState: ImportBatchState = { - checkinStudentIds: new Set(), - transferStudentIds: new Set(), - }; - - 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 = {}; - 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(); - const stepKeys = new Map>(); - const add = (code: PreflightBlockCode, stepKey: ImportStepKey, count: number) => { - counts.set(code, (counts.get(code) ?? 0) + count); - const keys = stepKeys.get(code) ?? new Set(); - 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 { - const grouped = new Map(); - 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(); - 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), - }; -} diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index 1551747..7a618d4 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -867,29 +867,6 @@ describe('ImportsService', () => { 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 () => { const workbook = new ExcelJS.Workbook(); diff --git a/apps/server/src/imports/imports.service.ts b/apps/server/src/imports/imports.service.ts index 1506d45..92e5136 100644 --- a/apps/server/src/imports/imports.service.ts +++ b/apps/server/src/imports/imports.service.ts @@ -7,16 +7,12 @@ import { ImportRow } from './entities/import-row.entity'; import { ImportRunService } from './imports.run.service'; import { ImportPreviewService } from './imports.preview.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 { ImportSheetMeta, ImportStepDetail, ImportRunDetail, ImportRunSettings, - PreflightReport, StepPreviewResult, } from './imports.types'; @@ -71,18 +67,6 @@ export class ImportsService { return this.runsSvc.createRun(...args); } - /** 上传后的只读预检:解析文件并生成可插入性报告,不写库。 */ - async preflightFile( - file: ParsedImportFile, - headerRow = 1, - ): Promise { - 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) { return this.runsSvc.getRun(...args); } diff --git a/apps/server/src/imports/imports.types.ts b/apps/server/src/imports/imports.types.ts index 4b62040..21ec3d3 100644 --- a/apps/server/src/imports/imports.types.ts +++ b/apps/server/src/imports/imports.types.ts @@ -150,94 +150,6 @@ export interface ImportRunSettings { 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 { stepKey: ImportStepKey; sheetNames: string[]; From bbeea440f958370fdbf16aa69d70a76146081c24 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 6 Aug 2026 16:11:13 +0800 Subject: [PATCH 20/20] =?UTF-8?q?fix(admin):=20dashboard=20=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=E5=8D=A1=E7=89=87=E7=82=B9=E5=87=BB=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=E4=B8=8E=E9=98=B2=E6=8A=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同一目标路径不再重复 navigate,避免历史记录堆叠 - 400ms 冷却期内忽略重复/幽灵点击,防止来回跳转 - 新增 shouldNavigateTodoCard 守卫单元测试 --- .../DashboardTodoCards.integration.test.ts | 16 ++++++++++ .../pages/Dashboard/DashboardTodoCards.tsx | 31 ++++++++++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 apps/admin/src/pages/Dashboard/DashboardTodoCards.integration.test.ts diff --git a/apps/admin/src/pages/Dashboard/DashboardTodoCards.integration.test.ts b/apps/admin/src/pages/Dashboard/DashboardTodoCards.integration.test.ts new file mode 100644 index 0000000..678b0f6 --- /dev/null +++ b/apps/admin/src/pages/Dashboard/DashboardTodoCards.integration.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { shouldNavigateTodoCard } from './DashboardTodoCards'; + +describe('DashboardTodoCards 点击导航守卫', () => { + it('目标路径与当前路径不同且冷却已过时允许导航', () => { + expect(shouldNavigateTodoCard('/dashboard', '/attendance', 1000, 1600)).toBe(true); + }); + + it('目标路径与当前路径相同时不允许导航', () => { + expect(shouldNavigateTodoCard('/attendance', '/attendance', 1000, 1600)).toBe(false); + }); + + it('冷却期内忽略重复点击,防止产生多条历史记录', () => { + expect(shouldNavigateTodoCard('/dashboard', '/attendance', 1000, 1300)).toBe(false); + }); +}); diff --git a/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx b/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx index 9b71270..db49ce0 100644 --- a/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx +++ b/apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Card, Col, Row } from 'antd'; import { ArrowRightOutlined, @@ -6,9 +6,22 @@ import { DollarOutlined, ExclamationCircleOutlined, } from '@ant-design/icons'; -import { useNavigate } from 'react-router'; +import { useLocation, useNavigate } from 'react-router'; import { MARGIN_BOTTOM_16_STYLE, TODO_CARD_DANGER, TODO_CARD_DRAFT, TODO_CARD_OK, TODO_CARD_WARN } from './Dashboard.types'; +/** 冷却期内忽略重复点击,避免快速/幽灵点击压入多条历史记录。 */ +const TODO_CLICK_LOCK_MS = 400; + +export function shouldNavigateTodoCard( + currentPath: string, + targetPath: string, + lastNavAt: number, + now: number, +): boolean { + if (currentPath === targetPath) return false; + return now - lastNavAt >= TODO_CLICK_LOCK_MS; +} + export const DashboardTodoCards: React.FC<{ absentCount: number; draftCount: number; @@ -16,6 +29,14 @@ export const DashboardTodoCards: React.FC<{ pendingDeposits: number; }> = ({ absentCount, draftCount, draftTotal, pendingDeposits }) => { const navigate = useNavigate(); + const location = useLocation(); + const lastNavAtRef = useRef(0); + const go = (target: string) => { + const now = Date.now(); + if (!shouldNavigateTodoCard(location.pathname, target, lastNavAtRef.current, now)) return; + lastNavAtRef.current = now; + navigate(target); + }; return ( @@ -23,7 +44,7 @@ export const DashboardTodoCards: React.FC<{ 0 ? TODO_CARD_WARN : TODO_CARD_OK} styles={{ body: { padding: 16 } }} - onClick={() => navigate('/attendance')} + onClick={() => go('/attendance')} >
0 ? TODO_CARD_DRAFT : TODO_CARD_OK} styles={{ body: { padding: 16 } }} - onClick={() => navigate('/bills')} + onClick={() => go('/bills')} >
0 ? TODO_CARD_DANGER : TODO_CARD_OK} styles={{ body: { padding: 16 } }} - onClick={() => navigate('/deposits')} + onClick={() => go('/deposits')} >