feat(web): 工作台 AI 模式与差旅/风险建议交互优化

- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
caoxiaozhu
2026-06-18 22:12:24 +08:00
parent a6674a1e76
commit 0cde1f8990
65 changed files with 8011 additions and 1608 deletions

View File

@@ -5,12 +5,15 @@ import { fileURLToPath } from 'node:url'
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
attachReceiptFolderIdsToFiles,
buildAttachmentAssociationConfirmationMessage,
buildOcrFilePreviews,
buildReviewFilePreviewsFromReviewPayload,
buildUnsavedDraftAttachmentConfirmationMessage,
collectReceiptFiles,
filterPersistableFilePreviews,
mergeFilePreviews
mergeFilePreviews,
normalizeOcrDocuments
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
import {
buildDraftAssociationQueryPayload,
@@ -171,6 +174,90 @@ test('OCR preview builders keep hotel receipt image previews when preview kind i
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
})
test('OCR receipt folder ids are kept for final draft attachment association', () => {
const files = [
{ name: 'invoice.png' },
{ name: 'taxi.pdf' }
]
const ocrPayload = {
documents: [
{
filename: 'invoice.png',
receipt_id: 'receipt-1',
receipt_status: 'unlinked',
receipt_preview_url: '/receipt-folder/receipt-1/preview',
receipt_source_url: '/receipt-folder/receipt-1/source'
},
{
filename: 'taxi.pdf',
receipt_id: 'receipt-2',
receipt_status: 'unlinked',
receipt_preview_url: '/receipt-folder/receipt-2/preview',
receipt_source_url: '/receipt-folder/receipt-2/source'
}
]
}
const documents = normalizeOcrDocuments(ocrPayload)
assert.equal(documents[0].receipt_id, 'receipt-1')
assert.equal(documents[0].receipt_status, 'unlinked')
assert.equal(documents[0].receipt_preview_url, '/receipt-folder/receipt-1/preview')
assert.equal(documents[0].receipt_source_url, '/receipt-folder/receipt-1/source')
assert.equal(attachReceiptFolderIdsToFiles(files, ocrPayload), 2)
assert.equal(files[0].receiptId, 'receipt-1')
assert.equal(files[1].receiptId, 'receipt-2')
assert.equal(Object.getOwnPropertyDescriptor(files[0], 'receiptId')?.enumerable, false)
})
test('receipt files are collected through a single OCR persistence entry before draft association', async () => {
const files = [
{ name: 'invoice.png' }
]
let recognizeCallCount = 0
const collected = await collectReceiptFiles({
files,
recognizeOcrFiles: async (inputFiles, options) => {
recognizeCallCount += 1
assert.equal(inputFiles, files)
assert.equal(options.timeoutMs, 90000)
return {
documents: [
{
filename: 'invoice.png',
summary: '发票金额 100 元',
preview_kind: 'image',
preview_data_url: 'data:image/png;base64,abc123',
receipt_id: 'receipt-collect-1',
receipt_status: 'unlinked',
receipt_preview_url: '/receipt-folder/receipt-collect-1/preview',
receipt_source_url: '/receipt-folder/receipt-collect-1/source'
}
]
}
}
})
assert.equal(recognizeCallCount, 1)
assert.equal(files[0].receiptId, 'receipt-collect-1')
assert.equal(collected.ocrPayload.documents[0].receipt_id, 'receipt-collect-1')
assert.equal(collected.ocrDocuments[0].receipt_id, 'receipt-collect-1')
assert.equal(collected.ocrSummary, 'invoice.png发票金额 100 元')
assert.deepEqual(collected.ocrFilePreviews, [
{ filename: 'invoice.png', kind: 'image', url: 'data:image/png;base64,abc123' }
])
const submitComposerSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
assert.match(submitComposerSource, /collectReceiptFiles\(/)
assert.doesNotMatch(submitComposerSource, /recognizeOcrFiles\(files,[\s\S]*attachReceiptFolderIdsToFiles/)
assert.doesNotMatch(submitComposerSource, /ocrDocuments = normalizeOcrDocuments\(ocrPayload\)/)
assert.doesNotMatch(submitComposerSource, /ocrFilePreviews = buildOcrFilePreviews\(ocrPayload\)/)
})
test('file preview cache replaces temporary object urls and never persists them', () => {
const merged = mergeFilePreviews(
[