feat: 重构报销单服务并完善前端提交与审核交互

重构 expense_claims 服务模块结构并优化差旅票据审核逻辑,
增强用户代理服务的票据类型识别,前端报销创建页面拆分为
附件模型和会话模型模块,重构提交编排器和草稿关联确认流
程,更新知识库索引,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-22 08:58:59 +08:00
parent f6f787ff38
commit 5fe3b201d9
42 changed files with 13697 additions and 9496 deletions

View File

@@ -88,9 +88,10 @@
<time>{{ message.time }}</time>
</header>
<div
v-if="message.text && message.role === 'assistant' && message.reviewPayload"
v-if="message.text && message.role === 'assistant' && message.reviewPayload && buildReviewMainMessageText(message)"
class="review-summary message-answer-content message-answer-markdown"
v-html="renderMarkdown(message.text)"
v-html="renderMarkdown(buildReviewMainMessageText(message))"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
@@ -103,6 +104,7 @@
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content message-answer-markdown"
v-html="renderMarkdown(message.text)"
@click="handleAssistantMarkdownClick($event, message)"
></div>
<div
@@ -298,7 +300,15 @@
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
:key="`${message.id}-review-followup`"
>
<p class="review-plain-lead">{{ followup.lead }}</p>
<h3
class="review-plain-lead"
:class="{ danger: followup.tone === 'danger' }"
>
{{ followup.lead }}
</h3>
<p v-if="followup.summary" class="review-plain-summary">
{{ followup.summary }}
</p>
<ul v-if="followup.items.length" class="review-plain-list">
<li
v-for="item in followup.items"

View File

@@ -120,6 +120,7 @@ import {
VISIBLE_ATTACHMENT_CHIPS,
buildAgentInsight,
buildErrorInsight,
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildFileIdentity,
buildFilePreviews,
buildOcrDocumentsFromReviewPayload,
@@ -431,6 +432,19 @@ function buildReviewRiskConversationText(item) {
return lines.join('\n')
}
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
function buildReviewMainMessageText(message) {
const text = String(message?.text || '')
if (!message?.reviewPayload) {
return text
}
return text
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export default {
name: 'TravelReimbursementCreateView',
components: {
@@ -779,7 +793,10 @@ export default {
attachedFiles,
composerFilesExpanded
}
const { submitComposerInternal } = useTravelReimbursementSubmitComposer({
const {
confirmPendingAttachmentAssociationInternal,
submitComposerInternal
} = useTravelReimbursementSubmitComposer({
MAX_ATTACHMENTS,
activeReviewPayload,
activeSessionType,
@@ -1303,7 +1320,8 @@ export default {
skipUploadDecisionPrompt: true,
extraContext: {
draft_claim_id: claimId,
selected_claim_id: claimId
selected_claim_id: claimId,
selected_claim_no: String(record?.claimNo || '').trim()
}
})
}
@@ -1443,6 +1461,27 @@ export default {
// submitting.value = false
return submitComposerInternal(options)
}
async function handleAssistantMarkdownClick(event, message) {
const anchor = event?.target?.closest?.('a')
if (!anchor || !message || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
return
}
const href = String(anchor.getAttribute('href') || '').trim()
if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) {
return
}
event.preventDefault()
reviewActionBusy.value = true
try {
await confirmPendingAttachmentAssociationInternal(message)
} finally {
reviewActionBusy.value = false
}
}
async function handleReviewAction(message, action) {
const actionType = String(action?.action_type || '').trim()
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
@@ -1481,11 +1520,11 @@ export default {
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges, uploadDecisionDialogOpen,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
requestCloseWorkbench, emitCloseAfterLeave, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, closeUploadDecisionDialog, continueExistingUpload, createNewUploadDocument, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}

View File

@@ -37,6 +37,7 @@ function resolveStatusTone(status) {
export const MAX_ATTACHMENTS = 10
export const MAX_OCR_DOCUMENTS = 10
export const VISIBLE_ATTACHMENT_CHIPS = 2
export const ATTACHMENT_ASSOCIATION_CONFIRM_HREF = '#confirm-attachment-association'
export function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
@@ -85,6 +86,88 @@ export function buildOcrSummaryFromDocuments(documents) {
.join('')
}
function resolveAssociationDocumentTypeLabel(document) {
const explicitLabel = String(document?.document_type_label || '').trim()
if (explicitLabel) {
return explicitLabel
}
const sceneLabel = String(document?.scene_label || '').trim()
if (sceneLabel) {
return sceneLabel
}
const typeLabel = resolveDocumentTypeLabel(document?.document_type)
return String(typeLabel || '').trim() || '其他票据'
}
function buildAssociationDocumentContentLines(document) {
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
const fieldLines = fields
.map((field) => {
const label = String(field?.label || '').trim()
const value = String(field?.value || '').trim()
return label && value ? `- ${label}${value}` : ''
})
.filter(Boolean)
if (fieldLines.length) {
return fieldLines.slice(0, 8)
}
const summary = String(document?.summary || document?.text || '').trim()
if (summary) {
return [`- 识别内容:${summary}`]
}
return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。']
}
export function buildAttachmentAssociationConfirmationMessage({
claimNo = '',
claimTitle = '',
fileNames = [],
ocrDocuments = []
} = {}) {
const documents = Array.isArray(ocrDocuments) && ocrDocuments.length
? ocrDocuments
: (Array.isArray(fileNames) ? fileNames : [])
.map((filename) => ({ filename }))
.filter((item) => String(item.filename || '').trim())
const targetLines = [
claimNo ? `- 草稿单号:${claimNo}` : '',
claimTitle ? `- 单据说明:${claimTitle}` : '',
`- 本次待归集附件:${documents.length || fileNames.length || 0}`
].filter(Boolean)
const documentBlocks = documents.map((document, index) => {
const filename = String(document?.filename || '').trim() || `附件 ${index + 1}`
const typeLabel = resolveAssociationDocumentTypeLabel(document)
const contentLines = buildAssociationDocumentContentLines(document)
return [
`附件 ${index + 1}${filename}`,
'',
`附件类型:${typeLabel}`,
'',
...contentLines
].join('\n')
})
return [
'已识别附件信息:',
'',
documentBlocks.join('\n\n'),
'',
'请问是否确定将票据信息归集到单据:',
'',
targetLines.join('\n'),
'',
`如果 [确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}) 该信息,我将直接将票据进行归集。`
]
.filter((part) => String(part || '').trim())
.join('\n')
}
export function normalizeReviewDocumentFieldKey(label) {
const compact = String(label || '').replace(/\s+/g, '').toLowerCase()
if (!compact) return ''

View File

@@ -167,6 +167,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
draftPayload: null,
reviewPayload: null,
riskFlags: [],
pendingAttachmentAssociation: null,
...extras
}
}
@@ -666,6 +667,7 @@ export function serializeSessionMessages(messages) {
draftPayload: message.draftPayload || null,
reviewPayload: message.reviewPayload || null,
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
assistantName: message.assistantName || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []

View File

@@ -224,14 +224,22 @@ export function resolveReviewMissingSlotCards(reviewPayload) {
export function resolveReviewExtraMissingLabels(reviewPayload) {
const labels = Array.isArray(reviewPayload?.missing_slots)
? reviewPayload.missing_slots.map((item) => String(item || '').trim()).filter(Boolean)
? reviewPayload.missing_slots
.map((item) => {
if (item && typeof item === 'object') {
return String(item.label || item.title || item.key || '').trim()
}
return String(item || '').trim()
})
.filter(Boolean)
: []
if (!labels.length) return []
const slotLabels = new Set(
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : [])
.map((item) => String(item?.label || item?.key || '').trim())
.filter(Boolean)
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).flatMap((item) => [
String(item?.label || '').trim(),
String(item?.key || '').trim()
]).filter(Boolean)
)
return labels.filter((label) => !slotLabels.has(label))
}
@@ -1239,23 +1247,66 @@ function buildReviewPlainFollowupItem(item, pendingMode) {
}
}
const REVIEW_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `当前还有 ${issueSummary}。请核查对话中的文字说明;如果想先暂存,也可以点击对话文字中的“草稿”。`,
({ issueSummary }) => `我这边看到还有 ${issueSummary},建议先把下方内容核对一下;暂时不处理也没关系,可以点击“草稿”先保存。`,
({ issueSummary }) => `下方还有 ${issueSummary},需要你确认。信息没补齐前可以先核查说明,后续需要暂存时点“草稿”。`,
({ issueSummary }) => `这笔报销还有 ${issueSummary},尚未完全确认。请先看一下下面的补充项;需要中途保存时,可以点“草稿”。`,
({ issueSummary }) => `目前还有 ${issueSummary}。你可以先按下面的提示补充,也可以稍后再处理,点击“草稿”即可暂存当前信息。`,
({ issueSummary }) => `还有 ${issueSummary},建议先核对下面说明;如果票据或金额暂时不全,可以通过“草稿”保留当前进度。`,
({ issueSummary }) => `这次识别结果里还有 ${issueSummary}。请重点看下面几项,暂不提交时可以点“草稿”保存。`,
({ issueSummary }) => `我还需要你确认 ${issueSummary}。下面列出了具体内容;如果现在不方便补齐,可以先点“草稿”。`,
({ issueSummary }) => `当前还有 ${issueSummary},需要进一步处理。请根据下面提示核查,待补充完再继续;临时保存可点击“草稿”。`,
({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
]
function buildStableTemplateIndex(signature, total) {
const source = String(signature || '')
let hash = 0
for (let index = 0; index < source.length; index += 1) {
hash = ((hash << 5) - hash + source.charCodeAt(index)) >>> 0
}
return total ? hash % total : 0
}
function buildReviewPendingSummary(pendingCount, riskCount, signature = '') {
const issueParts = []
if (pendingCount) {
issueParts.push(`${pendingCount} 项信息待补充`)
}
if (riskCount) {
issueParts.push(`${riskCount} 条风险提醒`)
}
const issueSummary = issueParts.length ? issueParts.join('、') : '一些细节还需要进一步确认'
const templateIndex = buildStableTemplateIndex(signature || issueSummary, REVIEW_PENDING_SUMMARY_TEMPLATES.length)
return REVIEW_PENDING_SUMMARY_TEMPLATES[templateIndex]({ issueSummary })
}
export function buildReviewPlainFollowupCopy(reviewPayload) {
const todoItems = buildReviewTodoItems(reviewPayload)
const pendingCount = countReviewPendingItems(reviewPayload)
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length
if (pendingCount || resolveReviewExtraMissingLabels(reviewPayload).length) {
if (pendingCount || extraMissingCount) {
const summarySignature = [
pendingCount || extraMissingCount,
riskBriefs.length,
...todoItems.map((item) => `${item.key}:${item.title}:${item.status}`)
].join('|')
return {
lead: '我还需要你核查或补充下面这些信息:',
lead: '补充信息:',
tone: 'danger',
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature),
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
notes: riskBriefs.length
? [`另外还有 ${riskBriefs.length} 条风险提醒,提交前建议一起确认。`]
: []
notes: []
}
}
return {
lead: todoItems.length ? '我已整理出当前识别到的关键信息:' : '当前关键信息已基本整理完成。',
tone: 'neutral',
summary: '',
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, false)),
notes: [
reviewPayload?.can_proceed ? '确认无误后,可以继续下一步。' : '',

View File

@@ -103,15 +103,15 @@ export function useTravelReimbursementReviewDrawer({
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
const reviewDrawerTitle = computed(() => (
isReviewDocumentDrawer.value
? '绁ㄦ嵁璇嗗埆缁撴灉'
? '票据识别结果'
: isReviewRiskDrawer.value
? '椋庨櫓鎻愮ず'
? '风险提示'
: isReviewFlowDrawer.value
? '璋冪敤娴佺▼'
: '鎶ラ攢璇嗗埆鏍稿'
? '执行流程'
: '报销识别核对'
))
const reviewDocumentDrawerLabel = computed(() => (
'鍗曟嵁璇嗗埆'
'单据识别'
))
const reviewDocumentDrawerIcon = computed(() => (
isReviewDocumentDrawer.value
@@ -119,7 +119,7 @@ export function useTravelReimbursementReviewDrawer({
: 'mdi mdi-file-document-multiple-outline'
))
const reviewRiskDrawerLabel = computed(() => (
'鏄剧ず椋庨櫓'
'显示风险'
))
const reviewRiskDrawerIcon = computed(() => (
isReviewRiskDrawer.value
@@ -127,7 +127,7 @@ export function useTravelReimbursementReviewDrawer({
: 'mdi mdi-shield-alert-outline'
))
const reviewFlowDrawerLabel = computed(() => (
'璋冪敤娴佺▼'
'执行流程'
))
const reviewFlowDrawerIcon = computed(() => (
isReviewFlowDrawer.value
@@ -253,7 +253,7 @@ export function useTravelReimbursementReviewDrawer({
) {
nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim()
if (!nextForm.reason_value) {
setInlineReviewFieldError('scene', '璇烽€夋嫨鈥滃叾浠栧満鏅€濆悗锛岃琛ュ厖鍏蜂綋浜嬬敱')
setInlineReviewFieldError('scene', '请选择“其他场景”后,请补充具体事由')
reviewInlineForm.value = nextForm
return false
}
@@ -262,14 +262,14 @@ export function useTravelReimbursementReviewDrawer({
}
if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) {
setInlineReviewFieldError('occurred_date', `璇疯緭鍏ユ纭殑鏃堕棿鏍煎紡锛?{DATE_INPUT_FORMAT}`)
setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`)
return false
}
if (activeEditorKey === 'amount' && nextForm.amount) {
const normalizedAmount = normalizeAmountValue(nextForm.amount)
if (!normalizedAmount) {
setInlineReviewFieldError('amount', '璇疯緭鍏ユ纭殑鏁板瓧閲戦锛屼緥濡?200 鎴?200.50')
setInlineReviewFieldError('amount', '请输入正确的数字金额,例如 200 200.50')
return false
}
nextForm.amount = normalizedAmount

View File

@@ -1,3 +1,8 @@
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage
} from './travelReimbursementAttachmentModel.js'
export function useTravelReimbursementSubmitComposer(ctx) {
const {
MAX_ATTACHMENTS,
@@ -74,6 +79,87 @@ export function useTravelReimbursementSubmitComposer(ctx) {
uploadDecisionDialogOpen,
toast
} = ctx
const pendingAttachmentAssociations = new Map()
function createPendingAttachmentAssociationId() {
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function normalizeRecognizedAttachmentData(data) {
if (!data || typeof data !== 'object') {
return null
}
const documents = Array.isArray(data.ocrDocuments) ? data.ocrDocuments : []
if (!documents.length) {
return null
}
return {
ocrPayload: data.ocrPayload || null,
ocrSummary: String(data.ocrSummary || '').trim(),
ocrDocuments: documents,
ocrFilePreviews: Array.isArray(data.ocrFilePreviews) ? data.ocrFilePreviews : []
}
}
function buildConfirmedAssociationText(message) {
return String(message?.text || '').replace(
`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`,
'已确认'
)
}
async function confirmPendingAttachmentAssociation(message) {
if (submitting.value || sessionSwitchBusy.value) return null
const pending = message?.pendingAttachmentAssociation && typeof message.pendingAttachmentAssociation === 'object'
? message.pendingAttachmentAssociation
: null
const associationId = String(pending?.id || '').trim()
if (!associationId || pending?.status === 'confirmed') {
return null
}
const runtime = pendingAttachmentAssociations.get(associationId)
if (!runtime || !Array.isArray(runtime.files) || !runtime.files.length) {
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
return null
}
pending.status = 'confirmed'
message.pendingAttachmentAssociation = pending
message.text = buildConfirmedAssociationText(message)
message.meta = ['已确认归集']
persistSessionState()
return submitComposer({
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`,
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
files: runtime.files,
uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true,
skipDraftAssociationPrompt: true,
pendingText: runtime.claimNo
? `正在将票据归集到草稿 ${runtime.claimNo}...`
: '正在将票据归集到当前草稿...',
associationConfirmed: true,
recognizedAttachmentData: {
ocrPayload: runtime.ocrPayload,
ocrSummary: runtime.ocrSummary,
ocrDocuments: runtime.ocrDocuments,
ocrFilePreviews: runtime.ocrFilePreviews
},
extraContext: {
...runtime.extraContext,
review_action: 'link_to_existing_draft',
draft_claim_id: runtime.claimId,
selected_claim_id: runtime.claimId,
selected_claim_no: runtime.claimNo,
attachment_association_confirmed: true
}
})
}
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
const parts = []
const normalizedText = String(rawText || '').trim()
@@ -128,6 +214,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? initialExtraContext
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim()
const attachmentAssociationConfirmed = Boolean(
options.associationConfirmed ||
extraContext.attachment_association_confirmed ||
reviewAction === 'link_to_existing_draft'
)
const hasSelectedExpenseType = Boolean(
extraContext.expense_scene_selection ||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
@@ -305,21 +396,100 @@ export function useTravelReimbursementSubmitComposer(ctx) {
let ocrSummary = ''
let ocrDocuments = []
let ocrFilePreviews = []
const recognizedAttachmentData = normalizeRecognizedAttachmentData(options.recognizedAttachmentData)
if (files.length) {
const ocrStartedAt = Date.now()
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
try {
ocrPayload = await recognizeOcrFiles(files)
ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
if (recognizedAttachmentData) {
ocrPayload = recognizedAttachmentData.ocrPayload
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
rememberFilePreviews(ocrFilePreviews)
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
} catch (error) {
console.warn('OCR request failed:', error)
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt)
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
} else {
try {
ocrPayload = await recognizeOcrFiles(files, {
timeoutMs: 90000,
timeoutMessage: '票据 OCR 识别超时,已继续使用附件名称处理。'
})
ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
rememberFilePreviews(ocrFilePreviews)
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
} catch (error) {
console.warn('OCR request failed:', error)
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt)
}
}
if (resolvedUploadDisposition === 'continue_existing') {
replaceMessage(pendingMessage.id, {
...pendingMessage,
text: attachmentAssociationConfirmed
? '票据识别已完成,正在把本次附件归集到已选择的草稿...'
: '票据识别已完成,正在整理归集前确认信息...',
meta: attachmentAssociationConfirmed ? ['正在归集'] : ['等待确认归集']
})
persistSessionState()
}
}
const associationTargetClaimId = String(extraContext.draft_claim_id || draftClaimId.value || '').trim()
const associationTargetClaimNo = String(
extraContext.selected_claim_no ||
extraContext.draft_claim_no ||
''
).trim()
if (
files.length &&
resolvedUploadDisposition === 'continue_existing' &&
associationTargetClaimId &&
!attachmentAssociationConfirmed
) {
const associationId = createPendingAttachmentAssociationId()
const pendingAssociation = {
id: associationId,
status: 'pending',
claimId: associationTargetClaimId,
claimNo: associationTargetClaimNo,
fileNames
}
pendingAttachmentAssociations.set(associationId, {
files,
fileNames,
ocrPayload,
ocrSummary,
ocrDocuments,
ocrFilePreviews,
filePreviews,
claimId: associationTargetClaimId,
claimNo: associationTargetClaimNo,
extraContext: {
...extraContext,
draft_claim_id: associationTargetClaimId,
selected_claim_id: associationTargetClaimId,
selected_claim_no: associationTargetClaimNo
}
})
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildAttachmentAssociationConfirmationMessage({
claimNo: associationTargetClaimNo,
fileNames,
ocrDocuments
}),
[],
{
meta: ['等待确认归集'],
pendingAttachmentAssociation: pendingAssociation
}
))
persistSessionState()
nextTick(scrollToBottom)
return null
}
let effectiveFileNames = [...fileNames]
@@ -359,6 +529,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
})
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
const orchestratorOptions = isKnowledgeSession.value
? {
timeoutMs: 18000,
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
}
: {
timeoutMs: 120000,
timeoutMessage: '票据归集处理超时,当前仍停留在原草稿,请稍后重试或重新选择附件。'
}
const payload = await runOrchestrator(
{
source: 'user_message',
@@ -393,12 +573,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
...extraContext
}
},
isKnowledgeSession.value
? {
timeoutMs: 18000,
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
}
: {}
orchestratorOptions
)
responsePayload = payload
flowRunId.value = String(payload?.run_id || '').trim()
@@ -413,35 +588,42 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? ''
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
replaceMessage(
pendingMessage.id,
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
meta: buildMessageMeta(payload, effectiveFileNames),
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
? payload.result.suggested_actions
: [],
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
})
)
const reviewActionResult = String(extraContext.review_action || '').trim()
const resultClaimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}` : '已将本次上传的票据关联到现有草稿。')
: '智能体已完成处理。'
const assistantMessage = createMessage('assistant', payload?.result?.answer || payload?.result?.message || fallbackAnswer, [], {
meta: buildMessageMeta(payload, effectiveFileNames),
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
? payload.result.suggested_actions
: [],
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
})
replaceMessage(pendingMessage.id, assistantMessage)
currentInsight.value = buildAgentInsight(
payload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
completeFlowResult(payload, flowRunDetail)
persistSessionState()
nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
try {
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
}
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
.then(() => {
persistSessionState()
})
.catch((error) => {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
})
}
} catch (error) {
clearFlowSimulationTimers()
@@ -458,6 +640,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
)
)
currentInsight.value = buildErrorInsight(error, fileNames)
persistSessionState()
} finally {
submitting.value = false
composerUploadIntent.value = ''
@@ -469,6 +652,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return {
confirmPendingAttachmentAssociationInternal: confirmPendingAttachmentAssociation,
submitComposerInternal: submitComposer
}
}