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

@@ -145,6 +145,7 @@
@ai-conversation-history-change="handleAiConversationHistoryChange"
@open-assistant="openSmartEntry"
@open-document="openWorkbenchDocument"
@request-updated="handleRequestUpdated"
/>
<TravelRequestDetailView

View File

@@ -7,6 +7,7 @@
@conversation-change="emit('ai-conversation-change', $event)"
@conversation-history-change="emit('ai-conversation-history-change', $event)"
@open-document="emit('open-document', $event)"
@request-updated="emit('request-updated', $event)"
/>
<PersonalWorkbench
v-else
@@ -31,7 +32,7 @@ defineProps({
aiSidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
})
const emit = defineEmits(['open-assistant', 'open-document', 'ai-conversation-change', 'ai-conversation-history-change'])
const emit = defineEmits(['open-assistant', 'open-document', 'ai-conversation-change', 'ai-conversation-history-change', 'request-updated'])
</script>
<style scoped src="../assets/styles/views/personal-workbench-view.css"></style>

View File

@@ -367,6 +367,7 @@ import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useToast } from '../composables/useToast.js'
import { fetchExpenseClaims } from '../services/reimbursements.js'
import {
buildReceiptFile,
@@ -383,6 +384,7 @@ import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListF
const NEW_CLAIM_VALUE = '__new_claim__'
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
const emit = defineEmits(['open-assistant', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast()
const activeStatus = ref('all')
const keyword = ref('')
@@ -687,6 +689,7 @@ async function deleteCurrentReceipt() {
await deleteReceiptFolderItem(selectedReceipt.value.id)
backToList()
await reloadReceipts()
toast('已从票据夹删除;已关联到报销单的附件副本会保留。')
} finally {
deleting.value = false
}

View File

@@ -59,6 +59,24 @@ export function buildUnsavedDraftAttachmentConfirmationMessage({ fileNames = []
].join('\n').trim()
}
function normalizeOcrDocumentFields(item = {}) {
const sources = [
item?.document_fields,
item?.fields,
item?.document_info?.fields,
item?.metadata?.fields
]
const fields = sources.find((source) => Array.isArray(source)) || []
return fields
.map((field) => {
const label = String(field?.label || field?.name || field?.key || '').trim()
const key = String(field?.key || field?.name || label || '').trim()
const value = String(field?.value || field?.text || '').trim()
return { key, label, value }
})
.filter((field) => field.key && field.label && field.value)
}
export function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
@@ -78,15 +96,7 @@ export function normalizeOcrDocuments(payload) {
receipt_status: String(item.receipt_status || item.receiptStatus || '').trim(),
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
document_fields: Array.isArray(item.document_fields)
? item.document_fields
.map((field) => ({
key: String(field?.key || '').trim(),
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.key && field.label && field.value)
: [],
document_fields: normalizeOcrDocumentFields(item),
warnings: Array.isArray(item.warnings) ? item.warnings : []
}))
}

View File

@@ -4,6 +4,7 @@ import {
} from '../../services/reimbursements.js'
import {
formatCurrency,
isSystemGeneratedExpenseItemSource,
normalizeIsoDateValue
} from './travelRequestDetailExpenseModel.js'
@@ -109,11 +110,24 @@ export function subscribeSmartEntryRecognitionTask(claimId, listener) {
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
return (Array.isArray(itemSnapshots) ? itemSnapshots : [])
.filter((item) => item && !item.isSystemGenerated && !item.invoiceId)
.filter((item) => item && !isSystemGeneratedExpenseItemSource(item) && !item.invoiceId)
.map((item) => ({ id: String(item.id || '').trim() }))
.filter((item) => item.id)
}
export function resolveCreatedSmartEntryRecognitionItem(items = [], knownItemIds = new Set()) {
return (Array.isArray(items) ? items : []).find((entry) => {
const itemId = String(entry?.id || '').trim()
const invoiceId = String(entry?.invoiceId || entry?.invoice_id || '').trim()
return (
itemId
&& !knownItemIds.has(itemId)
&& !invoiceId
&& !isSystemGeneratedExpenseItemSource(entry)
)
}) || null
}
async function resolveSmartEntryRecognitionTaskItem(task) {
const availableItem = task.availableItems.shift()
if (availableItem?.id) {
@@ -122,10 +136,7 @@ async function resolveSmartEntryRecognitionTaskItem(task) {
const claim = await createExpenseClaimItem(task.claimId, {})
const items = Array.isArray(claim?.items) ? claim.items : []
const createdItem = items.find((entry) => {
const itemId = String(entry?.id || '').trim()
return itemId && !task.knownItemIds.has(itemId)
})
const createdItem = resolveCreatedSmartEntryRecognitionItem(items, task.knownItemIds)
if (!createdItem) {
throw new Error('新增费用明细失败,请稍后重试。')

View File

@@ -156,6 +156,35 @@ function buildEmptyEditor() {
}
}
function resolveApplicationPreviewDateDraftValue(fieldKey = '', dateState = {}) {
if (fieldKey === 'time_return') {
return dateState.rangeEndDate || dateState.singleDate || getTodayDateValue()
}
return dateState.rangeStartDate || dateState.singleDate || getTodayDateValue()
}
function validateApplicationPreviewDateRange(value = '') {
const dates = parseEditorDateMatches(value)
if (dates.length < 2) {
return {
valid: true,
message: ''
}
}
const startDate = dates[0]
const endDate = dates[dates.length - 1]
if (startDate > endDate) {
return {
valid: false,
message: '出发时间不能晚于返回时间,请重新选择。'
}
}
return {
valid: true,
message: ''
}
}
function shouldRefreshTransportEstimate(fieldKey) {
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
}
@@ -271,6 +300,18 @@ export function useApplicationPreviewEditor({
return fieldKey === 'transportMode' ? APPLICATION_TRANSPORT_MODE_OPTIONS : []
}
function resolveApplicationPreviewEditorDateMin(message, fieldKey) {
if (fieldKey !== 'time_return') return ''
const dateState = parseEditorDateValue(message?.applicationPreview?.fields?.time)
return dateState.rangeStartDate || dateState.singleDate || ''
}
function resolveApplicationPreviewEditorDateMax(message, fieldKey) {
if (fieldKey !== 'time') return ''
const dateState = parseEditorDateValue(message?.applicationPreview?.fields?.time)
return dateState.dateMode === 'range' ? dateState.rangeEndDate : ''
}
function isApplicationPreviewEditing(message, fieldKey) {
return (
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
@@ -288,12 +329,15 @@ export function useApplicationPreviewEditor({
const dateState = isApplicationPreviewDateField(fieldKey)
? parseEditorDateValue(fields.time || normalizedValue)
: {}
const draftValue = isApplicationPreviewDateField(fieldKey)
? resolveApplicationPreviewDateDraftValue(fieldKey, dateState)
: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
? ''
: normalizedValue
applicationPreviewEditor.value = {
messageId: String(message.id || ''),
fieldKey,
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
? ''
: normalizedValue,
draftValue,
committing: false,
...dateState
}
@@ -351,6 +395,17 @@ export function useApplicationPreviewEditor({
}
return false
}
const dateValidation = isApplicationPreviewDateField(editor.fieldKey)
? validateApplicationPreviewDateRange(nextValue)
: { valid: true, message: '' }
if (!dateValidation.valid) {
toast?.(dateValidation.message || '请确认返回时间不早于出发时间。')
applicationPreviewEditor.value = {
...applicationPreviewEditor.value,
committing: false
}
return false
}
const nextPreview = normalizeApplicationPreview({
...message.applicationPreview,
fields: buildEditedApplicationPreviewFields(
@@ -403,6 +458,8 @@ export function useApplicationPreviewEditor({
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveApplicationPreviewEditorDateMin,
resolveApplicationPreviewEditorDateMax,
refreshApplicationPreviewEstimate,
isApplicationPreviewEditing,
isApplicationPreviewDateEditorOpen,

View File

@@ -80,10 +80,14 @@ export function useTravelReimbursementCreateViewControls({
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
files,
uploadDisposition: 'continue_existing',
skipDraftAssociationPrompt: true,
associationConfirmed: true,
extraContext: {
review_action: 'link_to_existing_draft',
draft_claim_id: claimId,
selected_claim_id: claimId,
selected_claim_no: String(record?.claimNo || '').trim()
selected_claim_no: String(record?.claimNo || '').trim(),
attachment_association_confirmed: true
}
})
}

View File

@@ -640,6 +640,36 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}` : '已将本次上传的票据关联到现有草稿。')
: '智能体已完成处理。'
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
let attachmentSyncCompleted = false
const persistComposerFilesToDraft = async () => {
try {
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
persistSessionState()
if (detailScopedUpload) {
emitRequestUpdated?.({
claimId: resolvedDraftClaimId,
source: 'detail-smart-entry-attachment-sync',
uploadedCount: Number(syncResult?.uploadedCount || 0),
skippedCount: Number(syncResult?.skippedCount || 0)
})
}
return syncResult
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
return null
}
}
if (
reviewActionResult === 'link_to_existing_draft' &&
!effectiveIsKnowledgeSession &&
resolvedDraftClaimId &&
files.length
) {
await persistComposerFilesToDraft()
attachmentSyncCompleted = true
}
const operationFeedbackContext = String(payload?.status || '').trim() === 'succeeded'
? emitOperationCompleted?.(payload, {
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
@@ -702,31 +732,15 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
persistSessionState()
nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
const persistComposerFilesToDraft = async () => {
try {
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
persistSessionState()
if (detailScopedUpload) {
emitRequestUpdated?.({
claimId: resolvedDraftClaimId,
source: 'detail-smart-entry-attachment-sync',
uploadedCount: Number(syncResult?.uploadedCount || 0),
skippedCount: Number(syncResult?.skippedCount || 0)
})
}
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
if (!attachmentSyncCompleted) {
const persistTask = persistComposerFilesToDraft()
if (detailScopedUpload) {
await persistTask
} else {
void persistTask
}
}
const persistTask = persistComposerFilesToDraft()
if (detailScopedUpload) {
await persistTask
} else {
void persistTask
}
}
} catch (error) {
clearFlowSimulationTimers()