feat(web): 票据夹资产缓存接入与 AI 工作台附件流程完善

- ReceiptFolderView 删除票据后提示已关联附件副本保留,接入 useToast;fetchReceiptFolderAsset 加 no-store 避免预览缓存
- PersonalWorkbenchAiMode 附件区/对话气泡适配资产缓存,personal-workbench-ai-mode.css 调整布局
- usePersonalWorkbenchAiMode/useWorkbenchAiApplicationPreviewFlow/useWorkbenchAiAttachmentAssociationFlow/useWorkbenchAiStewardFlow 完善附件草稿选择与关联流程
- travelRequestDetailSmartEntryRecognition 智能识别增强,AppShellRouteView/PersonalWorkbenchView/useApplicationPreviewEditor/useTravelReimbursementSubmitComposer 等配套适配
- 新增 expense-attachment-draft-selection、receipt-folder-asset-cache、travel-request-detail-smart-entry-recognition 测试,更新 attachment-association-confirmation、expense-application-fast-preview、workbench-ai-mode-switch 测试
This commit is contained in:
caoxiaozhu
2026-06-23 09:42:13 +08:00
parent 84a8998e59
commit e725b7f19c
22 changed files with 850 additions and 70 deletions

View File

@@ -263,6 +263,36 @@ test('OCR documents keep full recognized text for backend context', () => {
assert.match(documents[0].text, /电子客票号E1234567890/)
})
test('OCR documents normalize receipt-folder field shapes for AI cards', () => {
const documents = normalizeOcrDocuments({
documents: [
{
filename: 'train-ticket.png',
document_info: {
fields: [
{ label: '身份证号', value: '4201061987****1615' },
{ key: 'seat_no', label: '座位号', value: '01B' }
]
}
},
{
filename: 'hotel.png',
fields: [
{ name: 'amount', label: '金额', value: '450元' }
]
}
]
})
assert.deepEqual(documents[0].document_fields, [
{ key: '身份证号', label: '身份证号', value: '4201061987****1615' },
{ key: 'seat_no', label: '座位号', value: '01B' }
])
assert.deepEqual(documents[1].document_fields, [
{ key: 'amount', label: '金额', value: '450元' }
])
})
test('receipt files are collected through a single OCR persistence entry before draft association', async () => {
const files = [
{ name: 'invoice.png' }

View File

@@ -1902,6 +1902,80 @@ test('application preview editor can edit return date from inline table input',
assert.equal(message.applicationPreview.fields.days, '5\u5929')
})
test('application preview editor opens date fields with native date input values', () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\u5929'
}
})
const message = {
id: 'application-preview-editor-native-date-message',
applicationPreview: preview,
text: ''
}
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {}
})
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
assert.equal(editor.resolveApplicationPreviewEditorControl('time'), 'date')
assert.equal(editor.applicationPreviewEditor.value.draftValue, '2026-02-20')
assert.equal(editor.resolveApplicationPreviewEditorDateMax(message, 'time'), '2026-02-23')
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
assert.equal(editor.resolveApplicationPreviewEditorControl('time_return'), 'date')
assert.equal(editor.applicationPreviewEditor.value.draftValue, '2026-02-23')
assert.equal(editor.resolveApplicationPreviewEditorDateMin(message, 'time_return'), '2026-02-20')
})
test('application preview editor blocks invalid date ranges', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\u5929',
transportMode: '\u706b\u8f66',
amount: ''
}
})
const message = {
id: 'application-preview-editor-invalid-date-message',
applicationPreview: preview,
text: ''
}
const toastMessages = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: (messageText) => {
toastMessages.push(messageText)
}
})
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
editor.applicationPreviewEditor.value.draftValue = '2026-02-19'
const returnCommitted = await editor.commitApplicationPreviewEditor(message)
assert.equal(returnCommitted, false)
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(message.applicationPreview.fields.days, '4\u5929')
assert.equal(toastMessages.at(-1), '\u51fa\u53d1\u65f6\u95f4\u4e0d\u80fd\u665a\u4e8e\u8fd4\u56de\u65f6\u95f4\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9\u3002')
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
editor.applicationPreviewEditor.value.draftValue = '2026-02-24'
const startCommitted = await editor.commitApplicationPreviewEditor(message)
assert.equal(startCommitted, false)
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(toastMessages.at(-1), '\u51fa\u53d1\u65f6\u95f4\u4e0d\u80fd\u665a\u4e8e\u8fd4\u56de\u65f6\u95f4\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9\u3002')
})
test('application preview editor estimates after shorthand return date input', async () => {
const preview = normalizeApplicationPreview({
fields: {

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { useTravelReimbursementCreateViewControls } from '../src/views/scripts/useTravelReimbursementCreateViewControls.js'
function ref(value) {
return { value }
}
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
test('选择候选草稿时直接确认归集并带入附件原件', async () => {
const submitCalls = []
const attachedFiles = [{ name: '2月20 武汉-上海.pdf' }]
const message = {
queryPayload: {
selectionMode: 'draft_association'
}
}
const controls = useTravelReimbursementCreateViewControls({
activeSessionType: ref('expense'),
attachedFiles: ref(attachedFiles),
clearAssistantSessionSnapshot: () => {},
closeAfterBusy: ref(false),
conversationId: ref('conversation-1'),
deleteConversation: async () => {},
deleteSessionBusy: ref(false),
deleteSessionDialogOpen: ref(false),
draftClaimId: ref(''),
emitClose: () => {},
getExpenseQueryActivePage: () => 1,
getExpenseQueryTotalPages: () => 1,
persistSessionState: () => {},
resetCurrentSessionState: () => {},
reviewActionBusy: ref(false),
router: { push: () => {} },
resolveCurrentUserId: () => 'user-1',
sessionSwitchBusy: ref(false),
submitComposer: async (options) => {
submitCalls.push(options)
return { ok: true }
},
submitting: ref(false),
toast: () => {},
workbenchVisible: ref(true)
})
await controls.handleExpenseQueryRecordClick(message, {
claimId: 'claim-1',
claimNo: 'R74CB7C2R'
})
assert.equal(submitCalls.length, 1)
assert.equal(submitCalls[0].associationConfirmed, true)
assert.equal(submitCalls[0].skipDraftAssociationPrompt, true)
assert.equal(submitCalls[0].uploadDisposition, 'continue_existing')
assert.deepEqual(submitCalls[0].files, attachedFiles)
assert.equal(submitCalls[0].files[0], attachedFiles[0])
assert.equal(submitCalls[0].extraContext.review_action, 'link_to_existing_draft')
assert.equal(submitCalls[0].extraContext.attachment_association_confirmed, true)
assert.equal(submitCalls[0].extraContext.draft_claim_id, 'claim-1')
assert.equal(message.queryPayload.selectionLocked, true)
assert.equal(message.queryPayload.selectedClaimId, 'claim-1')
})
test('确认归集到现有草稿时先同步附件再渲染最终结果', () => {
assert.match(
submitComposerScript,
/let attachmentSyncCompleted = false/
)
assert.match(
submitComposerScript,
/if \(\s*reviewActionResult === 'link_to_existing_draft'[\s\S]*await persistComposerFilesToDraft\(\)[\s\S]*attachmentSyncCompleted = true[\s\S]*\}/
)
assert.ok(
submitComposerScript.indexOf('await persistComposerFilesToDraft()') <
submitComposerScript.indexOf('const assistantMessage = createMessage('),
'附件同步应先于最终助手消息,避免详情页先展示空明细和旧风险'
)
assert.match(
submitComposerScript,
/if \(!attachmentSyncCompleted\) \{\s*const persistTask = persistComposerFilesToDraft\(\)/
)
})

View File

@@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
const root = process.cwd()
test('receipt folder asset fetch bypasses stale preview cache', () => {
const service = readFileSync(join(root, 'web/src/services/receiptFolder.js'), 'utf8')
assert.match(service, /export function fetchReceiptFolderAsset/)
assert.match(service, /cache: 'no-store'/)
assert.match(service, /responseType: 'blob'/)
})

View File

@@ -0,0 +1,35 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
resolveCreatedSmartEntryRecognitionItem
} from '../src/views/scripts/travelRequestDetailSmartEntryRecognition.js'
test('智能录入创建明细后跳过系统补贴行', () => {
const createdItem = resolveCreatedSmartEntryRecognitionItem([
{
id: 'allowance-item',
item_type: 'travel_allowance',
invoice_id: ''
},
{
id: 'business-item',
item_type: 'travel',
invoice_id: ''
}
], new Set())
assert.equal(createdItem?.id, 'business-item')
})
test('智能录入创建明细后没有可上传业务行时返回空', () => {
const createdItem = resolveCreatedSmartEntryRecognitionItem([
{
id: 'allowance-item',
item_type: 'travel_allowance',
invoice_id: ''
}
], new Set())
assert.equal(createdItem, null)
})

View File

@@ -229,12 +229,17 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /rows="3"/)
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
assert.match(aiModeSurface, /<article v-for="file in selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
assert.match(aiModeStyles, /\.workbench-ai-file-card__ocr/)
assert.match(aiModeStyles, /workbenchAiOcrSpin/)
assert.match(aiModeSurface, /:aria-label="`移除附件 \$\{file\.name\}`"/)
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
assert.match(aiModeSurface, /const selectedFileCards = computed/)
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
assert.match(aiModeSurface, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
assert.match(aiModeSurface, /import \{ collectReceiptFiles \} from '\.\.\/\.\.\/views\/scripts\/travelReimbursementAttachmentModel\.js'/)
assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch/)
@@ -261,7 +266,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /function findAiAttachmentAssociationRuntime\(options = \{\}\)/)
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/)
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
assert.match(aiModeSurface, /const collected = await collectAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /const claims = extractExpenseClaimItems\(claimsPayload\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationRuntime\.set\(associationId/)
@@ -278,6 +283,14 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /mdi mdi-calendar-range/)
assert.match(aiModeSurface, /workbench-ai-date-popover/)
assert.match(aiModeSurface, /type="date"/)
assert.match(aiModeSurface, /:min="resolveInlineApplicationPreviewEditorDateMin\(message, row\.key\)"/)
assert.match(aiModeSurface, /:max="resolveInlineApplicationPreviewEditorDateMax\(message, row\.key\)"/)
assert.match(aiModeSurface, /resolveInlineApplicationPreviewEditorControl\(row\.key\) === 'date'/)
assert.match(aiModeSurface, /class="\['application-preview-input', 'application-preview-date-input', `application-preview-input--\$\{row\.key\}`\]"/)
assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorControl\(fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorControl\(fieldKey\)/)
assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorDateMin\(message, fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorDateMin\?\.\(message, fieldKey\) \|\| ''/)
assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorDateMax\(message, fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorDateMax\?\.\(message, fieldKey\) \|\| ''/)
assert.doesNotMatch(aiModeSurface, /return control === 'date' \? 'text' : control/)
assert.doesNotMatch(aiModeSurface, /mdi mdi-web/)
assert.match(aiModeSurface, /mdi mdi-microphone-outline/)
assert.match(aiModeSurface, /mdi mdi-arrow-up/)
@@ -342,6 +355,9 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
assert.match(aiModeStyles, /\.application-preview-date-input\s*\{[\s\S]*width:\s*min\(100%,\s*188px\);/)
assert.match(aiModeStyles, /\.application-preview-input--location\s*\{[\s\S]*width:\s*min\(100%,\s*220px\);/)
assert.match(aiModeStyles, /\.application-preview-input--reason\s*\{[\s\S]*width:\s*min\(100%,\s*680px\);/)
assert.match(aiModeSurface, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
assert.match(aiModeSurface, /fetchStewardPlan,[\s\S]*fetchStewardPlanStream[\s\S]*services\/steward\.js'/)
assert.match(aiModeSurface, /import \{ useWorkbenchComposerDate \} from '\.\.\/useWorkbenchComposerDate\.js'/)
@@ -354,7 +370,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /buildStewardPlanRequest/)
assert.match(aiModeSurface, /buildStewardPlanMessageText/)
assert.match(aiModeSurface, /buildStewardSuggestedActions/)
assert.match(aiModeSurface, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change', 'open-document'\]\)/)
assert.match(aiModeSurface, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'\]\)/)
assert.match(aiModeSurface, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
assert.match(aiModeSurface, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
assert.match(aiModeSurface, /persistCurrentConversation\(\)/)
@@ -370,6 +386,13 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
assert.match(aiModeSurface, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
assert.match(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
assert.match(aiModeSurface, /function buildInlineApplicationActionFailureText\(error, isSubmit\)/)
assert.match(aiModeSurface, /我已保留当前申请核对表/)
assert.match(aiModeSurface, /applicationPreview:\s*targetMessage\.applicationPreview/)
assert.match(
aiModeSurface,
/suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\([\s\S]*targetMessage\.applicationPreview/
)
assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/)
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
@@ -489,14 +512,51 @@ test('AI mode screen follows the approved reference structure', () => {
assert.ok(pngPresentation.minimumForegroundHeightRatio > 0.9)
})
test('AI attachment association notifies shell to refresh the target detail page', () => {
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
const appShellRouteView = readSource('../src/views/AppShellRouteView.vue')
const aiModeComposable = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
const attachmentFlow = readSource('../src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js')
assert.match(aiModeComponent, /defineEmits\(\[[^\]]*'request-updated'/)
assert.match(workbenchView, /@request-updated="emit\('request-updated', \$event\)"/)
assert.match(workbenchView, /defineEmits\(\[[^\]]*'request-updated'/)
assert.match(appShellRouteView, /<PersonalWorkbenchView[\s\S]*@request-updated="handleRequestUpdated"/)
assert.match(
aiModeComposable,
/notifyRequestUpdated:\s*\(payload\)\s*=>\s*emit\('request-updated', payload\)/
)
assert.match(
attachmentFlow,
/notifyRequestUpdated\?\.\(\{[\s\S]*claimId:[\s\S]*runtime\.claimId[\s\S]*uploadedCount:[\s\S]*syncResult\?\.uploadedCount/
)
})
test('AI mode normal assistant requests include OCR context for uploaded receipts', () => {
assert.match(aiModeSurface, /function isLikelyAiModeOcrFile\(file = \{\}\)/)
assert.match(aiModeSurface, /const aiModeReceiptContextCache = new Map\(\)/)
assert.match(aiModeSurface, /const aiModeReceiptRecognitionState = reactive\(\{\}\)/)
assert.match(aiModeSurface, /function resolveAiModeReceiptRecognitionState\(file\)/)
assert.match(aiModeSurface, /resolveAiModeReceiptRecognitionState\(selectedFiles\.value\[index\]\)/)
assert.match(aiModeSurface, /status:\s*'recognizing'[\s\S]*label:\s*'智能录入识别中'/)
assert.match(aiModeSurface, /status:\s*'recognized'[\s\S]*label:\s*detail \? `已识别票据/)
assert.match(aiModeSurface, /status:\s*'failed'[\s\S]*label:\s*'识别失败'/)
assert.match(aiModeSurface, /function primeAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\]\)/)
assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/)
assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
assert.match(aiModeSurface, /buildFileIdentity\(file\)/)
assert.match(aiModeSurface, /watch\(selectedFiles, \(files\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /async function collectAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /cached\?\.status === 'pending'[\s\S]*await cached\.promise/)
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
assert.match(aiModeSurface, /const receiptContext = await collectAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /const attachmentOcrDetails = buildInlineAttachmentOcrDetails\(receiptContext, files\)/)
assert.match(aiModeSurface, /ocr_summary:\s*receiptContext\.ocrSummary/)
assert.match(aiModeSurface, /ocr_documents:\s*receiptContext\.ocrDocuments/)
assert.match(aiModeSurface, /attachment_names:\s*receiptContext\.attachmentNames/)
assert.match(aiModeSurface, /attachment_count:\s*receiptContext\.attachmentCount/)
assert.match(aiModeSurface, /ocr_source_file_names:\s*receiptContext\.ocrSourceFileNames/)
assert.match(aiModeSurface, /attachmentOcrDetails/)
})