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

@@ -78,6 +78,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
const {
applicationPreviewEditor,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorDateMax,
resolveApplicationPreviewEditorDateMin,
resolveApplicationPreviewEditorOptions,
refreshApplicationPreviewEstimate,
isApplicationPreviewEditing,
@@ -112,7 +114,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
const aiModeActionItems = AI_MODE_ACTION_ITEMS
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value))
const displayUserName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
@@ -161,9 +162,19 @@ export function usePersonalWorkbenchAiMode(props, emit) {
scrollInlineConversationToBottom,
sending,
streamOrSetInlineAssistantContent,
notifyRequestUpdated: (payload) => emit('request-updated', payload),
toast
})
watch(selectedFiles, (files) => {
attachmentFlow.primeAiModeReceiptContext(files)
})
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({
...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
})))
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
activateInlineConversation,
applicationPreviewEditor,
@@ -189,6 +200,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
refreshApplicationPreviewEstimate,
removeWorkbenchDateTag,
replaceInlineMessage,
resolveApplicationPreviewEditorDateMax,
resolveApplicationPreviewEditorDateMin,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveInlineThinkingEvents,
@@ -776,6 +789,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
resolveInlineApplicationPreviewEditorDateMax: applicationFlow.resolveInlineApplicationPreviewEditorDateMax,
resolveInlineApplicationPreviewEditorDateMin: applicationFlow.resolveInlineApplicationPreviewEditorDateMin,
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
resolveInlineAttachmentOcrDocuments,

View File

@@ -68,6 +68,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
refreshApplicationPreviewEstimate,
removeWorkbenchDateTag,
replaceInlineMessage,
resolveApplicationPreviewEditorDateMax,
resolveApplicationPreviewEditorDateMin,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveInlineThinkingEvents,
@@ -105,8 +107,15 @@ export function useWorkbenchAiApplicationPreviewFlow({
}
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
const control = resolveApplicationPreviewEditorControl(fieldKey)
return control === 'date' ? 'text' : control
return resolveApplicationPreviewEditorControl(fieldKey)
}
function resolveInlineApplicationPreviewEditorDateMin(message, fieldKey) {
return resolveApplicationPreviewEditorDateMin?.(message, fieldKey) || ''
}
function resolveInlineApplicationPreviewEditorDateMax(message, fieldKey) {
return resolveApplicationPreviewEditorDateMax?.(message, fieldKey) || ''
}
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
@@ -180,6 +189,14 @@ export function useWorkbenchAiApplicationPreviewFlow({
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
}
function buildInlineApplicationActionFailureText(error, isSubmit) {
return [
isSubmit ? '### 申请提交失败' : '### 申请草稿保存失败',
error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'),
'我已保留当前申请核对表,您可以修改后重试,也可以稍后再次保存。'
].join('\n\n')
}
function resolveLatestApplicationPreviewMessage() {
return [...conversationMessages.value]
.reverse()
@@ -385,8 +402,14 @@ export function useWorkbenchAiApplicationPreviewFlow({
} catch (error) {
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
createInlineMessage('assistant', buildInlineApplicationActionFailureText(error, isSubmit), {
id: pendingMessage.id,
applicationPreview: targetMessage.applicationPreview,
draftPayload: targetMessage.draftPayload || options.draftPayload || null,
suggestedActions: buildInlineApplicationPreviewSuggestedActions(
targetMessage.applicationPreview,
targetMessage.draftPayload || options.draftPayload || null
),
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
@@ -504,6 +527,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
openApplicationPreviewEditor,
resolveApplicationPreviewEditorOptions,
resolveInlineApplicationPreviewEditorControl,
resolveInlineApplicationPreviewEditorDateMax,
resolveInlineApplicationPreviewEditorDateMin,
resolveInlineApplicationPreviewMissingFields,
resolveInlineApplicationPreviewRows,
startAiApplicationPreview

View File

@@ -1,6 +1,11 @@
import { reactive } from 'vue'
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
import {
buildFileIdentity,
collectReceiptFiles
} from '../../views/scripts/travelReimbursementAttachmentModel.js'
import {
createExpenseClaimItem,
extractExpenseClaimItems,
@@ -76,42 +81,256 @@ export function useWorkbenchAiAttachmentAssociationFlow({
scrollInlineConversationToBottom,
sending,
streamOrSetInlineAssistantContent,
notifyRequestUpdated,
toast
}) {
async function collectAiModeReceiptContext(files = []) {
const safeFiles = Array.isArray(files) ? files : []
const aiModeReceiptContextCache = new Map()
const aiModeReceiptRecognitionState = reactive({})
function resolveAiModeReceiptRecognitionStateKey(file) {
return buildFileIdentity(file)
}
function pruneAiModeReceiptRecognitionState(files = []) {
const activeKeys = new Set(
(Array.isArray(files) ? files : [])
.filter((file) => isLikelyAiModeOcrFile(file))
.map((file) => resolveAiModeReceiptRecognitionStateKey(file))
.filter(Boolean)
)
Object.keys(aiModeReceiptRecognitionState).forEach((key) => {
if (!activeKeys.has(key)) {
delete aiModeReceiptRecognitionState[key]
}
})
}
function setAiModeReceiptRecognitionState(files = [], patch = {}) {
const recognitionFiles = (Array.isArray(files) ? files : [])
.filter((file) => isLikelyAiModeOcrFile(file))
recognitionFiles.forEach((file) => {
const key = resolveAiModeReceiptRecognitionStateKey(file)
if (!key) {
return
}
aiModeReceiptRecognitionState[key] = {
...(aiModeReceiptRecognitionState[key] || {}),
fileName: String(file?.name || '').trim(),
...patch
}
})
}
function findAiModeReceiptDocumentForFile(file = {}, documents = [], index = 0) {
const fileName = String(file?.name || '').trim()
if (fileName) {
const exactDocument = documents.find((document) => (
String(document?.filename || document?.name || '').trim() === fileName
))
if (exactDocument) {
return exactDocument
}
}
return documents[index] || null
}
function buildAiModeReceiptRecognitionPendingState() {
return {
status: 'recognizing',
label: '智能录入识别中',
title: '正在调用智能录入 OCR 识别票据内容'
}
}
function buildAiModeReceiptRecognitionDoneState(document = null) {
const detail = String(
document?.document_type_label ||
document?.scene_label ||
document?.document_type ||
''
).trim()
return {
status: 'recognized',
label: detail ? `已识别票据 · ${detail}` : '已识别票据',
title: detail ? `智能录入已完成,识别为${detail}` : '智能录入已完成'
}
}
function applyAiModeReceiptRecognitionResult(files = [], context = {}) {
const documents = Array.isArray(context?.ocrDocuments) ? context.ocrDocuments : []
const recognitionFiles = (Array.isArray(files) ? files : [])
.filter((file) => isLikelyAiModeOcrFile(file))
recognitionFiles.forEach((file, index) => {
const document = findAiModeReceiptDocumentForFile(file, documents, index)
setAiModeReceiptRecognitionState([file], buildAiModeReceiptRecognitionDoneState(document))
})
}
function resolveAiModeReceiptRecognitionState(file) {
const key = resolveAiModeReceiptRecognitionStateKey(file)
return key ? aiModeReceiptRecognitionState[key] || null : null
}
function buildAiModeReceiptBaseContext(safeFiles = [], ocrFiles = []) {
const attachmentNames = safeFiles
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const ocrSourceFileNames = ocrFiles
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
const baseContext = {
return {
attachmentNames,
attachmentCount: attachmentNames.length,
ocrSourceFileNames,
ocrSummary: '',
ocrDocuments: []
}
}
function buildAiModeReceiptContextCacheKey(ocrFiles = []) {
return (Array.isArray(ocrFiles) ? ocrFiles : [])
.map((file) => buildFileIdentity(file))
.filter(Boolean)
.join('|')
}
function buildAiModeReceiptContextFromCollected(baseContext = {}, collected = {}) {
return {
...baseContext,
ocrPayload: collected.ocrPayload || { documents: collected.ocrDocuments || [] },
ocrSummary: String(collected.ocrSummary || '').trim(),
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : [],
ocrFilePreviews: Array.isArray(collected.ocrFilePreviews) ? collected.ocrFilePreviews : []
}
}
function rememberAiModeReceiptContext(cacheKey, context) {
if (!cacheKey) {
return
}
aiModeReceiptContextCache.set(cacheKey, {
status: 'resolved',
context: {
ocrPayload: context.ocrPayload,
ocrSummary: context.ocrSummary,
ocrDocuments: context.ocrDocuments,
ocrFilePreviews: context.ocrFilePreviews
}
})
if (aiModeReceiptContextCache.size > 20) {
aiModeReceiptContextCache.delete(aiModeReceiptContextCache.keys().next().value)
}
}
function startAiModeReceiptRecognition(files = []) {
const safeFiles = Array.isArray(files) ? files : []
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const cacheKey = buildAiModeReceiptContextCacheKey(ocrFiles)
if (!ocrFiles.length || !cacheKey) {
return null
}
const cached = aiModeReceiptContextCache.get(cacheKey)
if (cached?.status === 'resolved') {
applyAiModeReceiptRecognitionResult(ocrFiles, cached.context)
return null
}
if (cached?.status === 'pending' && cached.promise) {
setAiModeReceiptRecognitionState(ocrFiles, buildAiModeReceiptRecognitionPendingState())
return cached.promise
}
setAiModeReceiptRecognitionState(ocrFiles, buildAiModeReceiptRecognitionPendingState())
const promise = collectReceiptFiles({
files: ocrFiles,
recognizeOcrFiles
}).then((collected) => {
const context = buildAiModeReceiptContextFromCollected(
buildAiModeReceiptBaseContext(safeFiles, ocrFiles),
collected
)
rememberAiModeReceiptContext(cacheKey, context)
applyAiModeReceiptRecognitionResult(ocrFiles, context)
return context
}).catch((error) => {
aiModeReceiptContextCache.delete(cacheKey)
setAiModeReceiptRecognitionState(ocrFiles, {
status: 'failed',
label: '识别失败',
title: error?.message || '智能录入 OCR 识别失败'
})
throw error
})
aiModeReceiptContextCache.set(cacheKey, {
status: 'pending',
promise
})
return promise
}
function primeAiModeReceiptContext(files = []) {
pruneAiModeReceiptRecognitionState(files)
const promise = startAiModeReceiptRecognition(files)
if (promise && typeof promise.catch === 'function') {
promise.catch((error) => {
console.warn('AI mode OCR preload failed:', error)
})
}
}
async function collectAiModeReceiptContext(files = []) {
const safeFiles = Array.isArray(files) ? files : []
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const baseContext = buildAiModeReceiptBaseContext(safeFiles, ocrFiles)
if (!ocrFiles.length) {
return baseContext
}
const cacheKey = buildAiModeReceiptContextCacheKey(ocrFiles)
const cached = cacheKey ? aiModeReceiptContextCache.get(cacheKey) : null
if (cached?.status === 'resolved') {
applyAiModeReceiptRecognitionResult(ocrFiles, cached.context)
return {
...baseContext,
...cached.context
}
}
if (cached?.status === 'pending' && cached.promise) {
try {
const cachedContext = await cached.promise
applyAiModeReceiptRecognitionResult(ocrFiles, cachedContext)
return {
...baseContext,
ocrPayload: cachedContext.ocrPayload,
ocrSummary: cachedContext.ocrSummary,
ocrDocuments: cachedContext.ocrDocuments,
ocrFilePreviews: cachedContext.ocrFilePreviews
}
} catch (error) {
console.warn('AI mode OCR preload result unavailable:', error)
}
}
try {
setAiModeReceiptRecognitionState(ocrFiles, buildAiModeReceiptRecognitionPendingState())
const collected = await collectReceiptFiles({
files: ocrFiles,
recognizeOcrFiles
})
return {
...baseContext,
ocrSummary: String(collected.ocrSummary || '').trim(),
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
}
const context = buildAiModeReceiptContextFromCollected(baseContext, collected)
rememberAiModeReceiptContext(cacheKey, context)
applyAiModeReceiptRecognitionResult(ocrFiles, context)
return context
} catch (error) {
console.warn('AI mode OCR request failed:', error)
setAiModeReceiptRecognitionState(ocrFiles, {
status: 'failed',
label: '识别失败',
title: error?.message || '智能录入 OCR 识别失败'
})
return {
...baseContext,
ocrError: error?.message || 'OCR识别失败已继续使用附件名称。'
@@ -220,6 +439,13 @@ export function useWorkbenchAiAttachmentAssociationFlow({
createExpenseClaimItem,
uploadExpenseClaimItemAttachment
})
notifyRequestUpdated?.({
claimId: runtime.claimId,
claimNo: runtime.claimNo,
source: 'ai-workbench-attachment-association-sync',
uploadedCount: Number(syncResult?.uploadedCount || 0),
skippedCount: Number(syncResult?.skippedCount || 0)
})
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
claimNo: runtime.claimNo,
fileNames: runtime.fileNames,
@@ -281,10 +507,7 @@ export function useWorkbenchAiAttachmentAssociationFlow({
scrollInlineConversationToBottom()
try {
const collected = await collectReceiptFiles({
files,
recognizeOcrFiles
})
const collected = await collectAiModeReceiptContext(files)
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
const claims = extractExpenseClaimItems(claimsPayload)
@@ -351,7 +574,9 @@ export function useWorkbenchAiAttachmentAssociationFlow({
return {
collectAiModeReceiptContext,
confirmAiAttachmentAssociation,
primeAiModeReceiptContext,
requestAiAttachmentAssociationReply,
resolveAiModeReceiptRecognitionState,
resolveAiAttachmentAssociationClaimNo
}
}

View File

@@ -15,6 +15,9 @@ import {
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
import {
buildInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js'
function shouldCheckAiRequiredApplicationGate(prompt) {
const compact = String(prompt || '').replace(/\s+/g, '')
@@ -269,6 +272,7 @@ export function useWorkbenchAiStewardFlow({
}
const receiptContext = await collectAiModeReceiptContext(files)
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(receiptContext, files)
const planRequest = buildStewardPlanRequest({
rawText: prompt,
files,
@@ -330,7 +334,8 @@ export function useWorkbenchAiStewardFlow({
},
suggestedActions: requiredApplicationContinuationFlow
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
: buildStewardSuggestedActions(plan)
: buildStewardSuggestedActions(plan),
attachmentOcrDetails
})
)
persistCurrentConversation()