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:
2026-08-05 21:12:50 +08:00
parent b70f45fb04
commit ab4765cee5
44 changed files with 2449 additions and 166 deletions

View File

@@ -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<string>;
}
).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<string, unknown> }> = [];
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<string>;
}
).executePreflightImport(
42,
{
id: 'call-1',
name: 'preflight_import',
arguments: JSON.stringify({ attachmentId: 9 }),
},
7,
(event, data) => emitted.push({ event, data: (data ?? {}) as Record<string, unknown> }),
);
const parsed = JSON.parse(result) as { status: string; report: unknown };
expect(parsed.status).toBe('success');
expect(parsed.report).toEqual(report);
expect(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<string>;
}
).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<string>;
}
).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();
});
});