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:
@@ -145,6 +145,7 @@
|
||||
@ai-conversation-history-change="handleAiConversationHistoryChange"
|
||||
@open-assistant="openSmartEntry"
|
||||
@open-document="openWorkbenchDocument"
|
||||
@request-updated="handleRequestUpdated"
|
||||
/>
|
||||
|
||||
<TravelRequestDetailView
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 : []
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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('新增费用明细失败,请稍后重试。')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user