feat: Excel 导入预检与动态问答,AI 聊天/文件解析体验修复
- imports: 新增 preflight_import 预检报告(判定/分阶段统计/阻断归因/问题/下一步/错误示例),导入任务 settings 落库(映射/校区/更新/重复/未匹配策略),预览应用策略,向导提交写操作日志 - ai-chat: 新增 excel_analyze(ExcelJS)工具,移除附件/上下文截断,start_import_wizard 支持确认参数,ui.import_preflight SSE,预览确认写操作日志 - admin: ImportPreflightCard 渲染与持久化,聊天抽屉布局/侧边栏修复,考勤页 CSS 引入,费用/学生页接口 schema 校验修复
This commit is contained in:
@@ -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<string> {
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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<string> {
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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<Record<ImportStepKey, ColumnMapping>> | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) throw new Error('mapping 参数格式错误');
|
||||
const mapping: Partial<Record<ImportStepKey, ColumnMapping>> = {};
|
||||
for (const [stepKey, fields] of Object.entries(raw as Record<string, unknown>)) {
|
||||
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<string, unknown>)) {
|
||||
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<string, unknown>): 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;
|
||||
|
||||
Reference in New Issue
Block a user