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

@@ -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/)
})