refactor(frontend): split large reimbursement and audit modules

This commit is contained in:
caoxiaozhu
2026-05-21 23:53:03 +08:00
parent 2908dda024
commit f6f787ff38
53 changed files with 15637 additions and 14179 deletions

View File

@@ -0,0 +1,474 @@
export function useTravelReimbursementSubmitComposer(ctx) {
const {
MAX_ATTACHMENTS,
activeReviewPayload,
activeSessionType,
adjustComposerTextareaHeight,
attachedFiles,
buildAgentInsight,
buildClientTimeContext,
buildComposerBusinessTimeContext,
buildComposerFilePreviews,
buildDraftAssociationQueryPayload,
buildErrorInsight,
buildExpenseIntentConfirmationActions,
buildExpenseIntentConfirmationMessage,
buildExpenseSceneSelectionActions,
buildExpenseSceneSelectionMessage,
buildMessageMeta,
buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments,
buildReviewFormContextFromPayload,
clearAttachedFiles,
clearFlowSimulationTimers,
completeFlowResult,
completeFlowStep,
composerBusinessTimeDraftTouched,
composerBusinessTimeTags,
composerDraft,
composerUploadIntent,
conversationId,
createMessage,
currentInsight,
currentUser,
draftClaimId,
extractReviewAttachmentNames,
failCurrentFlowStep,
fetchExpenseClaims,
fileInputRef,
flowRunId,
isKnowledgeSession,
linkedRequest,
mergeBusinessTimeIntoExtraContext,
mergeFilePreviews,
mergeFilesWithLimit,
mergeUploadAttachmentNames,
mergeUploadOcrDocuments,
messages,
nextTick,
normalizeExpenseQueryPayload,
normalizeOcrDocuments,
persistSessionState,
props,
recognizeOcrFiles,
refreshFlowRunDetail,
rememberFilePreviews,
replaceMessage,
resetFlowRun,
resolveComposerSubmitText,
reviewInlineForm,
runOrchestrator,
scrollToBottom,
sessionSwitchBusy,
shouldRequestExpenseIntentConfirmation,
shouldRequestExpenseSceneSelection,
startExpenseClaimDraftFlowStep,
startExpenseIntentConfirmationFlowPreview,
startExpenseSceneSelectionFlowPreview,
startFlowStep,
startSemanticFlowPreview,
submitting,
syncComposerFilesToDraft,
uploadDecisionDialogOpen,
toast
} = ctx
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
const parts = []
const normalizedText = String(rawText || '').trim()
if (normalizedText) {
parts.push(normalizedText)
} else if (fileNames.length) {
parts.push(
isKnowledgeSession.value
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
)
}
if (fileNames.length) {
parts.push(`附件名称:${fileNames.join('、')}`)
}
if (ocrSummary) {
parts.push(`OCR摘要${ocrSummary}`)
}
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
parts.push(`关联单号:${linkedRequest.value.id}`)
}
return parts.join('\n')
}
async function submitComposer(options = {}) {
if (submitting.value || sessionSwitchBusy.value) return null
const rawText = resolveComposerSubmitText(options.rawText).trim()
const systemGenerated = Boolean(options.systemGenerated)
const resolvedUploadDisposition =
String(options.uploadDisposition || '').trim() ||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '')
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
const files = fileMergeResult.files
if (fileMergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (!rawText && !files.length) return
const fileNames = files.map((file) => file.name)
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
? { ...options.extraContext }
: {}
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
const extraContext = isKnowledgeSession.value
? initialExtraContext
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim()
const hasSelectedExpenseType = Boolean(
extraContext.expense_scene_selection ||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
)
const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed)
const waitForExpenseIntentConfirmation = shouldRequestExpenseIntentConfirmation(rawText, {
sessionType: activeSessionType.value,
attachmentCount: files.length,
reviewAction,
hasSelectedExpenseType,
hasConfirmedExpenseIntent
})
const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
sessionType: activeSessionType.value,
attachmentCount: files.length,
reviewAction,
hasSelectedExpenseType
})
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
const hasExistingDocumentEvent =
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
const userText =
String(options.userText || '').trim() ||
rawText ||
(isKnowledgeSession.value
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
: resolvedUploadDisposition === 'continue_existing'
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
: resolvedUploadDisposition === 'new_document'
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
if (
!isKnowledgeSession.value &&
files.length &&
hasExistingDocumentEvent &&
!resolvedUploadDisposition &&
!options.skipUploadDecisionPrompt &&
!reviewAction
) {
uploadDecisionDialogOpen.value = true
return null
}
if (
!isKnowledgeSession.value &&
files.length &&
!hasExistingDocumentEvent &&
!resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt &&
!reviewAction
) {
try {
const claims = await fetchExpenseClaims()
const queryPayload = buildDraftAssociationQueryPayload(claims)
if (queryPayload?.records?.length) {
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
`我找到 ${queryPayload.records.length} 张可关联的草稿/待补单据。请先选择这批附件要归集到哪张单据,我再开始识别附件。`,
[],
{
meta: ['等待选择关联单据'],
queryPayload
}
))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
return null
}
} catch (error) {
console.warn('Failed to load draft claims before attachment recognition:', error)
toast(error?.message || '查询可关联草稿失败,已继续按新单据识别。')
}
}
resetFlowRun()
if (rawText && !reviewAction) {
startFlowStep('intent', '正在识别业务意图...')
if (waitForExpenseIntentConfirmation) {
startExpenseIntentConfirmationFlowPreview(rawText)
} else if (waitForExpenseSceneSelection) {
startExpenseSceneSelectionFlowPreview(rawText)
} else {
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
}
}
const filePreviews = buildComposerFilePreviews(files)
rememberFilePreviews(filePreviews)
// 只有在非静默模式下才添加用户消息
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
if (waitForExpenseIntentConfirmation) {
messages.value.push(createMessage('assistant', buildExpenseIntentConfirmationMessage(rawText), [], {
meta: ['等待确认意图'],
suggestedActions: buildExpenseIntentConfirmationActions(rawText)
}))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
return null
}
if (waitForExpenseSceneSelection) {
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(rawText), [], {
meta: ['等待选择场景'],
suggestedActions: buildExpenseSceneSelectionActions(rawText)
}))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
return null
}
const pendingMessage = createMessage(
'assistant',
options.pendingText || (
isKnowledgeSession.value
? '正在整理财务知识答案...'
: '正在识别并整理右侧核对信息...'
),
[],
{
meta: ['处理中']
}
)
messages.value.push(pendingMessage)
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(adjustComposerTextareaHeight)
submitting.value = true
nextTick(scrollToBottom)
let responsePayload = null
try {
const user = currentUser.value || {}
let ocrPayload = null
let ocrSummary = ''
let ocrDocuments = []
let ocrFilePreviews = []
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)
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)
}
}
let effectiveFileNames = [...fileNames]
let effectiveOcrDocuments = [...ocrDocuments]
let effectiveOcrSummary = ocrSummary
if (resolvedUploadDisposition === 'continue_existing') {
extraContext.review_action = 'link_to_existing_draft'
const inheritedReviewContext = buildReviewFormContextFromPayload(
activeReviewPayload.value,
reviewInlineForm.value
)
if (inheritedReviewContext.review_form_values) {
extraContext.review_form_values = {
...inheritedReviewContext.review_form_values,
...(extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
? extraContext.review_form_values
: {})
}
}
if (inheritedReviewContext.business_time_context && !extraContext.business_time_context) {
extraContext.business_time_context = inheritedReviewContext.business_time_context
}
effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames)
effectiveOcrDocuments = mergeUploadOcrDocuments(
buildOcrDocumentsFromReviewPayload(activeReviewPayload.value),
ocrDocuments
)
effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments)
} else if (resolvedUploadDisposition === 'new_document') {
extraContext.review_action = 'create_new_claim_from_documents'
}
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
attachmentCount: effectiveFileNames.length,
waitForSceneSelection: waitForExpenseSceneSelection
})
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
const payload = await runOrchestrator(
{
source: 'user_message',
user_id: user.username || user.name || 'anonymous',
conversation_id: conversationId.value || null,
message: backendMessage,
context_json: {
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
is_admin: Boolean(user.isAdmin),
name: user.name || '',
role: user.role || '',
department: user.department || user.departmentName || '',
department_name: user.department || user.departmentName || '',
position: user.position || '',
grade: user.grade || '',
employee_no: user.employeeNo || user.employee_no || '',
manager_name: user.managerName || user.manager_name || '',
employee_location: user.location || '',
cost_center: user.costCenter || user.cost_center || '',
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
...buildClientTimeContext(),
session_type: activeSessionType.value,
entry_source: props.entrySource,
user_input_text: systemGenerated ? '' : rawText,
attachment_names: effectiveFileNames,
attachment_count: effectiveFileNames.length,
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
ocr_summary: effectiveOcrSummary,
ocr_documents: effectiveOcrDocuments,
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
...extraContext
}
},
isKnowledgeSession.value
? {
timeoutMs: 18000,
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
}
: {}
)
responsePayload = payload
flowRunId.value = String(payload?.run_id || '').trim()
let flowRunDetail = null
if (flowRunId.value) {
flowRunDetail = await refreshFlowRunDetail()
}
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
draftClaimId.value =
isKnowledgeSession.value
? ''
: 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 : []
})
)
currentInsight.value = buildAgentInsight(
payload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
completeFlowResult(payload, flowRunDetail)
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 || '票据已识别,但附件原件保存失败,请重试上传。')
}
}
} catch (error) {
clearFlowSimulationTimers()
failCurrentFlowStep(error)
replaceMessage(
pendingMessage.id,
createMessage(
'assistant',
error?.message || '无法连接后端 Orchestrator请稍后重试。',
[],
{
meta: ['调用失败']
}
)
)
currentInsight.value = buildErrorInsight(error, fileNames)
} finally {
submitting.value = false
composerUploadIntent.value = ''
nextTick(scrollToBottom)
}
return responsePayload
}
return {
submitComposerInternal: submitComposer
}
}