Files
X-Financial/web/src/views/scripts/TravelReimbursementCreateView.js

2129 lines
73 KiB
JavaScript
Raw Normal View History

import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { useTravelReimbursementFlow } from './useTravelReimbursementFlow.js'
import { useTravelReimbursementComposerTools } from './useTravelReimbursementComposerTools.js'
import { useTravelReimbursementAttachments } from './useTravelReimbursementAttachments.js'
import { useTravelReimbursementSessionState } from './useTravelReimbursementSessionState.js'
import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementReviewDrawer.js'
import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js'
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
import { renderMarkdown } from '../../utils/markdown.js'
import { clearAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js'
import {
buildLocalExtractionProgressMessages,
buildLocalIntentPreview,
shouldRequestExpenseIntentConfirmation,
shouldRequestExpenseSceneSelection,
summarizeSemanticIntentDetail
} from '../../utils/reimbursementTextInference.js'
import {
buildExpenseIntentConfirmationActions,
buildExpenseSceneSelectionActions
} from '../../utils/expenseAssistantActions.js'
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../../utils/assistantSuggestedActionPrefill.js'
import {
buildApplicationPreviewFooterMessage,
buildApplicationPreviewSubmitText,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
calculateTravelReimbursement,
fetchExpenseClaims,
fetchExpenseClaimAttachmentAsset,
fetchExpenseClaimDetail,
fetchExpenseClaimItemAttachmentMeta,
uploadExpenseClaimItemAttachment
} from '../../services/reimbursements.js'
import {
EXPENSE_TYPE_LABELS,
REVIEW_SLOT_CONFIG,
REVIEW_CATEGORY_PRESET_OPTIONS,
REVIEW_OTHER_CATEGORY_OPTIONS,
REVIEW_SCENE_OTHER_OPTION,
REVIEW_SCENE_OPTIONS,
DATE_INPUT_FORMAT,
cloneReviewDocumentDrafts,
buildReviewDocumentDrafts,
normalizeReviewDocumentComparableValue,
buildReviewDocumentCorrectionMessage,
buildReviewDocumentCorrectionContext,
cloneReviewEditFields,
buildReviewFormValues,
createEmptyInlineReviewState,
resolveReviewRecognizedSlotCards,
resolveReviewMissingSlotCards,
resolveReviewExtraMissingLabels,
formatConfidenceLabel,
resolveDocumentTypeLabel,
resolveExpenseTypeLabel,
buildReviewRecognizedLines,
buildReviewSlotMap,
resolveExpenseTypeCode,
isValidIsoDateString,
parseAmountNumber,
normalizeAmountValue,
extractAmountInputValue,
formatAmountDisplay,
inferPresetSceneFromReview,
formatReviewSceneDisplayValue,
summarizeReviewScene,
buildInlineReviewState,
buildReviewAttachmentStatus,
shouldShowReviewFactCard,
resolveReviewCategoryConfidenceScore,
buildReviewCategoryOptions,
buildReviewPanelConfidence,
buildLocallySyncedReviewPayload,
buildInlineReviewChangedLines,
buildLocalReviewSavedMessage,
buildReviewSubmitUserText,
mergeInlineReviewFields,
buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel,
buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel,
isTravelReviewPayload as isTravelReviewPayloadModel,
resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel,
buildClientTimeContext,
formatDraftApplyTime,
formatDateInputValue,
buildDraftSavedPayload,
buildReviewHeadline,
buildReviewSubline,
buildReviewStateLabel,
buildReviewStateTone,
buildReviewPlainFollowupCopy,
buildReviewNextStepRichCopy,
buildReviewRiskLevelCounts,
resolveReviewFooterActions,
resolveReviewSaveDraftAction,
resolveReviewNextStepAction,
buildReviewPrimaryButtonLabel,
buildReviewIntentText,
buildReviewSceneValue,
buildMissingRiskLine,
buildReviewRiskSummary as buildReviewRiskSummaryModel,
normalizeReviewRiskLevel,
buildLocalReviewCompletionMessage,
buildReviewRecognitionNotes,
buildReviewDocumentSummaries
} from './travelReimbursementReviewModel.js'
import {
buildDraftAssociationQueryPayload,
buildExpenseQueryHint,
buildExpenseQueryWindowLabel,
getExpenseQueryActivePage,
getExpenseQueryTotalPages,
getExpenseQueryVisibleRecords,
normalizeExpenseQueryPayload
} from './travelReimbursementExpenseQueryModel.js'
import {
MAX_ATTACHMENTS,
MAX_OCR_DOCUMENTS,
VISIBLE_ATTACHMENT_CHIPS,
buildAgentInsight,
buildErrorInsight,
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildFileIdentity,
buildFilePreviews,
buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments,
buildReviewFilePreviewsFromReviewPayload,
extractReviewAttachmentNames,
isTemporaryPreviewUrl,
mergeFilePreviews,
mergeFilesWithLimit,
mergeUploadAttachmentNames,
mergeUploadOcrDocuments,
normalizeOcrDocuments,
resolveAttachmentPreviewKind,
resolveDocumentPreview
} from './travelReimbursementAttachmentModel.js'
import {
ASSISTANT_SESSION_MODE_OPTIONS,
ASSISTANT_DISPLAY_NAME,
FLOW_STEP_FALLBACKS,
HOT_KNOWLEDGE_QUESTIONS,
INTENT_LABELS,
SCENARIO_LABELS,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
aiAvatar,
buildExpenseIntentConfirmationMessage,
buildExpenseSceneSelectionMessage,
buildMessageMeta,
buildWelcomeInsight,
createMessage,
resolveAssistantSessionMode,
resolveKnowledgeRankLabel,
resolveKnowledgeRankTone,
sanitizeRequest,
summarizeSemanticParseDetail,
userAvatar
} from './travelReimbursementConversationModel.js'
const REVIEW_RISK_LEVEL_META = {
high: {
label: '高风险',
icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
},
medium: {
label: '中风险',
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
},
low: {
label: '低风险',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
}
}
const COMPOSER_TEXTAREA_HEIGHT = 36
const COMPOSER_MAX_ROWS = 5
const REVIEW_DRAWER_MODE_REVIEW = 'review'
const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents'
const REVIEW_DRAWER_MODE_RISK = 'risk'
const REVIEW_DRAWER_MODE_FLOW = 'flow'
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
const REVIEW_PANEL_SCOPE_RISK = 'risk'
const REVIEW_NEXT_STEP_HREF = '#review-next-step'
const APPLICATION_SUBMIT_HREF = '#application-submit'
const REVIEW_RISK_PANEL_HREF_PREFIX = '#review-risk'
const REVIEW_QUICK_EDIT_HREF = '#review-quick-edit'
const FLOW_STEP_STATUS_PENDING = 'pending'
const FLOW_STEP_STATUS_RUNNING = 'running'
const FLOW_STEP_STATUS_COMPLETED = 'completed'
const FLOW_STEP_STATUS_FAILED = 'failed'
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
function normalizeReviewPanelScope(scope) {
const normalized = String(scope || '').trim()
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
? normalized
: ''
}
function canExposeReviewPanelScope(scope) {
return Boolean(normalizeReviewPanelScope(scope))
}
function buildBusinessTimeContextFromReviewValues(values = {}) {
return buildBusinessTimeContextFromReviewValuesModel(values)
}
function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState)
}
function buildReviewCorrectionMessage(fields) {
const lines = ['请按以下核对后的报销信息更新当前识别结果:']
for (const item of cloneReviewEditFields(fields)) {
if (!item.label || (!item.value && !item.required)) {
continue
}
lines.push(`${item.label}${String(item.value || '').trim() || '待补充'}`)
}
return lines.join('\n')
}
function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
return isTravelReviewPayloadModel(reviewPayload, inlineState)
}
function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText)
}
function resolveReviewRiskBriefs(reviewPayload) {
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
return reviewPayload.risk_briefs.filter((item) => {
const title = String(item?.title || '').trim()
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
})
}
function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0))
const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0))
const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount)
const attachmentStatus =
pendingAttachmentCount > 0
? existingAttachmentCount > 0
? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount}`
: `待保存 ${pendingAttachmentCount}`
: totalAttachmentCount > 0
? `已上传 ${totalAttachmentCount}`
: buildReviewAttachmentStatus(reviewPayload)
if (isTravelReviewPayload(reviewPayload, inlineState)) {
return [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'transport_type',
label: '交通类型',
value: String(inlineState.transport_type || '').trim() || '待确认',
icon: 'mdi mdi-train-car',
editor: 'text',
modelKey: 'transport_type',
placeholder: '例如 火车/高铁、飞机'
},
{
key: 'hotel_name',
label: '酒店名称',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-bed-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店名称'
},
{
key: 'travel_purpose',
label: '出差事宜',
value: String(inlineState.reason_value || '').trim() || '待补充',
icon: 'mdi mdi-briefcase-edit-outline',
editor: 'textarea',
modelKey: 'reason_value',
placeholder: '请填写本次出差的具体工作内容或业务意图',
wide: true
}
]
}
const cards = [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'scene',
label: '场景 / 事由',
value: formatReviewSceneDisplayValue(inlineState),
icon: 'mdi mdi-silverware-fork-knife',
editor: 'select',
modelKey: 'scene_label',
placeholder: '请选择场景'
},
{
key: 'attachments',
label: '票据状态',
value: attachmentStatus,
icon: 'mdi mdi-file-document-outline',
editor: 'upload',
modelKey: 'attachment_names',
placeholder: ''
}
]
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
cards.splice(cards.length - 1, 0, {
key: 'customer_name',
label: '关联客户',
value: String(inlineState.customer_name || '').trim() || '待补充',
icon: 'mdi mdi-domain',
editor: 'text',
modelKey: 'customer_name',
placeholder: '请输入客户名称'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
cards.splice(cards.length - 1, 0, {
key: 'location',
label: '业务地点',
value: String(inlineState.location || '').trim() || '待补充',
icon: 'mdi mdi-map-marker-outline',
editor: 'text',
modelKey: 'location',
placeholder: '请输入业务地点'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) {
cards.splice(cards.length - 1, 0, {
key: 'merchant_name',
label: '酒店/商户',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-storefront-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店或商户名称'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) {
cards.splice(cards.length - 1, 0, {
key: 'participants',
label: '同行人员',
value: String(inlineState.participants || '').trim() || '待补充',
icon: 'mdi mdi-account-group-outline',
editor: 'text',
modelKey: 'participants',
placeholder: '例如 客户 2 人,我方 1 人'
})
}
return cards
}
function normalizeReviewRiskTitle(title, fallbackTitle) {
const normalized = String(title || '').trim()
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
if (!normalized) return fallback
const cleaned = normalized
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
.replace(/(高风险|中风险|低风险)/g, '')
.replace(/^[:\-—\s]+|[:\-—\s]+$/g, '')
.trim()
return cleaned || fallback
}
function buildReviewRiskItems(reviewPayload) {
return resolveReviewRiskBriefs(reviewPayload)
.map((brief, index) => {
const title = String(brief?.title || '').trim()
const content = String(brief?.content || '').trim()
const detail = String(brief?.detail || '').trim()
const suggestion = String(brief?.suggestion || '').trim()
const level = normalizeReviewRiskLevel(brief?.level)
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
const summary = content || normalizedTitle
if (!normalizedTitle && !summary) return null
return {
key: `${level}-${normalizedTitle}-${index}`,
title: normalizedTitle,
summary,
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
level,
levelLabel: meta.label,
icon: meta.icon,
sourceLabel: meta.label,
suggestion: suggestion || meta.suggestion
}
})
.filter(Boolean)
}
function buildReviewRiskConversationText(item, detailTarget = {}) {
const title = String(item?.title || '风险提示').trim()
const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim()
const isInfo = String(item?.level || '').trim() === 'info'
const detailHref = String(detailTarget?.href || '').trim()
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
const lines = [`${title}`]
if (summary) {
lines.push('', `${isInfo ? '提示内容' : '风险点'}${summary}`)
}
if (detail && detail !== summary) {
lines.push('', `规则依据:${detail}`)
}
if (suggestion) {
lines.push('', `${isInfo ? '处理建议' : '修改建议'}${suggestion}`)
}
if (detailHref) {
lines.push('', `[${detailLabel}](${detailHref})`)
}
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: {
ConfirmDialog
},
props: {
initialPrompt: {
type: String,
default: ''
},
initialFiles: {
type: Array,
default: () => []
},
initialConversation: {
type: Object,
default: null
},
entrySource: {
type: String,
default: 'requests'
},
requestContext: {
type: Object,
default: null
},
invalidatedDraftClaimId: {
type: String,
default: ''
},
reopenToken: {
type: Number,
default: 0
}
},
emits: ['close', 'draft-saved'],
setup(props, { emit }) {
const router = useRouter()
const { currentUser } = useSystemState()
const { toast } = useToast()
const fileInputRef = ref(null)
const composerTextareaRef = ref(null)
const messageListRef = ref(null)
const composerDraft = ref('')
const submitting = ref(false)
const workbenchVisible = ref(false)
const closeAfterBusy = ref(false)
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const hotKnowledgeQuestions = HOT_KNOWLEDGE_QUESTIONS
let sessionRuntimeRefs = {}
const {
activeSessionType,
messages,
conversationId,
draftClaimId,
sessionSnapshots,
currentInsight,
reviewFilePreviews,
composerUploadIntent,
guidedFlowState,
insightPanelCollapsed,
sessionSwitchBusy,
buildEmptySessionState,
resolveCurrentUserId,
persistSessionState,
applySessionState,
switchSessionType
} = useTravelReimbursementSessionState({
props,
currentUser,
linkedRequest,
toast,
composerDraft,
adjustComposerTextareaHeight,
scrollToBottom,
getSessionRuntimeRefs: () => sessionRuntimeRefs
})
const deleteSessionDialogOpen = ref(false)
const applicationSubmitConfirmDialog = ref({
open: false,
message: null
})
const nextStepConfirmDialog = ref({
open: false,
message: null,
action: null
})
const reviewActionBusy = ref(false)
const deleteSessionBusy = ref(false)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const {
applicationPreviewEditor,
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
isApplicationPreviewEditing,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
cancelApplicationPreviewEditor,
handleApplicationPreviewEditorKeydown
} = useApplicationPreviewEditor({
persistSessionState,
toast
})
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
const assistantHeaderTitle = computed(() => activeAssistantMode.value?.label || '财务助手')
const assistantHeaderDescription = computed(() => activeAssistantMode.value?.description || '个人财务中心')
const {
flowRunId,
flowSteps,
flowRefreshBusy,
completedFlowStepCount,
flowOverallStatusTone,
flowOverallStatusText,
flowTotalDurationText,
clearFlowSimulationTimers,
resetFlowRun,
startFlowTick,
stopFlowRuntime,
startFlowStep,
completeFlowStep,
failCurrentFlowStep,
startSemanticFlowPreview,
startExpenseSceneSelectionFlowPreview,
startExpenseIntentConfirmationFlowPreview,
startExpenseSceneSelectionAfterIntentConfirmation,
startReviewActionFlowStep,
startExpenseClaimDraftFlowStep,
completeFlowResult,
refreshFlowRunDetail,
formatFlowStepDuration,
resolveFlowStepStatusLabel,
resolveFlowStepDetail
} = useTravelReimbursementFlow({
activeSessionType,
reviewDrawerMode,
insightPanelCollapsed,
isKnowledgeSession,
fetchAgentRunDetail,
buildLocalIntentPreview,
buildLocalExtractionProgressMessages,
summarizeSemanticIntentDetail,
summarizeSemanticParseDetail,
SCENARIO_LABELS,
INTENT_LABELS,
EXPENSE_TYPE_LABELS,
FLOW_STEP_FALLBACKS,
REVIEW_DRAWER_MODE_FLOW,
REVIEW_DRAWER_MODE_REVIEW,
FLOW_STEP_STATUS_PENDING,
FLOW_STEP_STATUS_RUNNING,
FLOW_STEP_STATUS_COMPLETED,
FLOW_STEP_STATUS_FAILED
})
const hasScopedReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && canExposeReviewPanelScope(agent.reviewPanelScope)) {
return true
}
if (currentInsight.value.intent === 'agent' && agent) {
return false
}
return messages.value.some((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
)
})
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
const hasInsightPanelContent = computed(() => {
return isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
})
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
const insightPanelToggleLabel = computed(() =>
showInsightPanel.value ? '隐藏详细信息' : '展开详细信息'
)
const composerPlaceholder = computed(() => {
if (isKnowledgeSession.value) {
return '例如:差旅住宿标准是什么?发票抬头不一致还能报销吗?'
}
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
}
if (activeSessionType.value === SESSION_TYPE_APPLICATION) {
return '例如:我想先申请一笔差旅费用,去上海支持项目部署。'
}
if (activeSessionType.value === SESSION_TYPE_APPROVAL) {
return '例如:查一下待我审核的单据,或帮我生成这张单据的审核意见。'
}
return '例如查一下近10日报销金额、解释酒店超标风险或根据附件整理报销核对信息。'
})
const currentIntentLabel = computed(() => {
if (isKnowledgeSession.value && currentInsight.value.intent === 'welcome') {
return '热门问题'
}
const labels = isKnowledgeSession.value
? {
welcome: '热门问题',
agent: '知识回答'
}
: {
welcome: activeAssistantMode.value?.label || '财务助手',
agent: '处理中'
}
return labels[currentInsight.value.intent] ?? 'AI 处理中'
})
const canDeleteCurrentSession = computed(
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
)
const latestReviewMessage = computed(() =>
[...messages.value].reverse().find((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
) ?? null
)
const activeReviewPanelScope = computed(() => {
const agent = currentInsight.value.agent || null
const agentScope = normalizeReviewPanelScope(agent?.reviewPanelScope)
if (agent?.reviewPayload && agentScope) {
return agentScope
}
if (currentInsight.value.intent === 'agent' && agent) {
return ''
}
return normalizeReviewPanelScope(latestReviewMessage.value?.reviewPanelScope)
})
const activeReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && normalizeReviewPanelScope(agent.reviewPanelScope)) {
return agent.reviewPayload
}
if (currentInsight.value.intent === 'agent' && agent) {
return null
}
return latestReviewMessage.value?.reviewPayload || null
})
const reviewRiskBriefResolver = (payload) => resolveReviewRiskBriefs(payload)
const buildReviewRiskSummary = (payload) => buildReviewRiskSummaryModel(payload, reviewRiskBriefResolver)
const {
reviewInlineForm,
reviewInlineBaseForm,
reviewInlineBaseFields,
reviewInlinePendingFiles,
reviewInlineEditorKey,
reviewInlineErrors,
reviewOtherCategoryOpen,
reviewDocumentDrafts,
reviewDocumentBaseDrafts,
activeReviewDocumentIndex,
documentPreviewDialog,
activeReviewFilePreviews,
reviewIntentText,
reviewFactCards,
reviewCategoryOptions,
reviewOtherCategoryOptions,
reviewSelectedOtherCategory,
reviewInlineDirty,
reviewPanelConfidence,
reviewRiskSummary,
reviewRiskItems,
reviewRiskEmpty,
reviewOverviewDrawerAvailable,
reviewDocumentDrawerAvailable,
reviewRiskDrawerAvailable,
reviewFlowDrawerAvailable,
recognizedNarratives,
reviewRecognitionNotes,
reviewDocumentSummaries,
reviewDocumentCount,
isReviewDocumentDrawer,
isReviewRiskDrawer,
isReviewFlowDrawer,
reviewDrawerTitle,
reviewDocumentDrawerLabel,
reviewDocumentDrawerIcon,
reviewRiskDrawerLabel,
reviewRiskDrawerIcon,
reviewFlowDrawerLabel,
reviewFlowDrawerIcon,
activeReviewDocument,
activeReviewDocumentPreview,
canPreviewActiveReviewDocument,
reviewDocumentDirty,
reviewHasUnsavedChanges,
setInlineReviewFieldError,
clearInlineReviewFieldError,
resetReviewDrawerFromPayload,
enforceReviewDrawerAvailability,
openInlineReviewEditor,
closeInlineReviewEditor,
commitInlineReviewEditor,
selectInlineScene,
selectReviewCategory,
selectReviewOtherCategory,
goReviewDocument,
openActiveReviewDocumentPreview,
closeDocumentPreview
} = useTravelReimbursementReviewDrawer({
activeReviewPayload,
activeReviewPanelScope,
reviewFilePreviews,
flowSteps,
submitting,
reviewActionBusy,
triggerFileUpload: (...args) => triggerFileUpload(...args),
resolveDocumentPreview,
buildReviewFactCards,
buildReviewRiskItems,
buildReviewRiskSummary,
buildReviewIntentText,
resolveReviewRiskBriefs,
reviewDrawerMode,
REVIEW_DRAWER_MODE_REVIEW,
REVIEW_DRAWER_MODE_DOCUMENTS,
REVIEW_DRAWER_MODE_RISK,
REVIEW_DRAWER_MODE_FLOW
})
const {
composerDatePickerOpen,
composerDateMode,
composerSingleDate,
composerRangeStartDate,
composerRangeEndDate,
composerBusinessTimeTags,
composerBusinessTimeDraftTouched,
composerCanApplyDateSelection,
travelCalculatorOpen,
travelCalculatorBusy,
travelCalculatorError,
travelCalculatorResult,
travelCalculatorForm,
travelCalculatorCanSubmit,
buildComposerBusinessTimeLabel,
hasComposerBusinessTimeSelection,
buildComposerBusinessTimeContext,
mergeBusinessTimeIntoExtraContext,
syncComposerBusinessTimeToReviewCard,
resolveComposerSubmitText,
resolveComposerDisplaySubmitText,
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,
handleComposerDateInputChange,
removeComposerBusinessTimeTag,
handleComposerDatePickerOutside,
applyComposerDateSelection,
resolveTravelCalculatorInitialDays,
resolveTravelCalculatorInitialLocation,
openTravelCalculator: openTravelCalculatorInternal,
toggleTravelCalculator: toggleTravelCalculatorInternal,
closeTravelCalculator,
formatTravelCalculatorMoney,
buildTravelCalculatorResultText,
submitTravelCalculator: submitTravelCalculatorInternal
} = useTravelReimbursementComposerTools({
currentUser,
activeReviewPayload,
reviewInlineForm,
latestReviewMessage,
currentInsight,
messages,
composerDraft,
composerTextareaRef,
adjustComposerTextareaHeight,
scrollToBottom,
toast,
calculateTravelReimbursement,
createMessage,
buildReviewSlotMap,
isValidIsoDateString,
buildLocallySyncedReviewPayload,
formatDateInputValue
})
const canShowTravelCalculator = computed(() => activeSessionType.value === SESSION_TYPE_EXPENSE)
const {
fileInputMode,
attachedFiles,
composerFilesExpanded,
visibleAttachedFiles,
hiddenAttachedFileCount,
rememberFilePreviews,
buildComposerFilePreviews,
resolveActiveClaimId,
restorePersistedDraftAttachmentPreviews,
syncComposerFilesToDraft,
triggerFileUpload,
handleFilesChange,
toggleAttachedFilesExpanded,
removeAttachedFile,
clearAttachedFiles,
stopAttachmentRuntime
} = useTravelReimbursementAttachments({
isKnowledgeSession,
reviewFilePreviews,
linkedRequest,
draftClaimId,
activeReviewPayload,
reviewInlinePendingFiles,
reviewInlineForm,
reviewInlineEditorKey,
composerUploadIntent,
submitting,
reviewActionBusy,
toast,
fileInputRef,
fetchExpenseClaimDetail,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimAttachmentAsset,
uploadExpenseClaimItemAttachment,
extractReviewAttachmentNames,
mergeFilesWithLimit,
mergeFilePreviews,
isTemporaryPreviewUrl,
resolveAttachmentPreviewKind,
resolveDocumentPreview,
buildFilePreviews,
buildFileIdentity,
MAX_ATTACHMENTS,
VISIBLE_ATTACHMENT_CHIPS,
clearInlineReviewFieldError
})
sessionRuntimeRefs = {
attachedFiles,
composerFilesExpanded,
guidedFlowState
}
const {
confirmPendingAttachmentAssociationInternal,
submitComposerInternal
} = useTravelReimbursementSubmitComposer({
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,
insightPanelCollapsed,
isKnowledgeSession,
linkedRequest,
mergeBusinessTimeIntoExtraContext,
mergeFilePreviews,
mergeFilesWithLimit,
mergeUploadAttachmentNames,
mergeUploadOcrDocuments,
messages,
nextTick,
normalizeExpenseQueryPayload,
normalizeOcrDocuments,
persistSessionState,
props,
recognizeOcrFiles,
refreshFlowRunDetail,
rememberFilePreviews,
replaceMessage,
resolveComposerDisplaySubmitText,
resetFlowRun,
resolveComposerSubmitText,
reviewInlineForm,
runOrchestrator,
scrollToBottom,
sessionSwitchBusy,
shouldRequestExpenseIntentConfirmation,
shouldRequestExpenseSceneSelection,
startExpenseClaimDraftFlowStep,
startExpenseIntentConfirmationFlowPreview,
startExpenseSceneSelectionFlowPreview,
startFlowStep,
startSemanticFlowPreview,
submitting,
syncComposerFilesToDraft,
toast
})
const canSubmit = computed(
() =>
!submitting.value
&& !sessionSwitchBusy.value
&& Boolean(
composerDraft.value.trim()
|| attachedFiles.value.length
|| composerBusinessTimeTags.value.length
)
)
const {
handleGuidedShortcut,
handleGuidedComposerSubmit,
handleGuidedSuggestedAction,
resetGuidedFlowState
} = useTravelReimbursementGuidedFlow({
guidedFlowState,
messages,
composerDraft,
attachedFiles,
composerBusinessTimeTags,
composerBusinessTimeDraftTouched,
fileInputRef,
submitting,
reviewActionBusy,
sessionSwitchBusy,
createMessage,
nextTick,
scrollToBottom,
persistSessionState,
clearAttachedFiles,
adjustComposerTextareaHeight,
buildComposerBusinessTimeContext,
openTravelCalculator,
lockSuggestedActionMessage,
submitExistingComposer: submitComposerInternal,
currentUser,
toast
})
function openTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
return openTravelCalculatorInternal()
}
function toggleTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
return toggleTravelCalculatorInternal()
}
function submitTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
// 兼容旧测试的源码锚点;真实 calculateTravelReimbursement 调用在 composable 内。
// calculateTravelReimbursement({ grade: String(user.grade || '').trim() })
// 根据您输入的地点和天数,匹配到您要出差的地区为,参考可报销合计
// 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元
// 鏍规嵁鎮ㄨ緭鍏ョ殑鍦扮偣鍜屽ぉ鏁帮紝鍖归厤鍒版偍瑕佸嚭宸殑鍦板尯涓猴紝鍙傝€冨彲鎶ラ攢鍚堣
// 浣忓璐癸細${hotelRate} 脳 ${days} = ${hotelAmount} 鍏
// messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload)
return submitTravelCalculatorInternal()
}
watch(canShowTravelCalculator, (visible) => {
if (!visible && travelCalculatorOpen.value) {
closeTravelCalculator()
}
})
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
const shortcuts = computed(() =>
ASSISTANT_SESSION_MODE_OPTIONS.map((mode) => ({
label: mode.label,
icon: mode.icon,
action: 'switch_view',
targetSessionType: mode.key,
active: mode.key === activeSessionType.value
}))
)
watch(
() => [activeReviewPayload.value, activeReviewPanelScope.value],
([payload]) => {
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
// ? REVIEW_DRAWER_MODE_RISK
// : REVIEW_DRAWER_MODE_REVIEW
resetReviewDrawerFromPayload(payload)
},
{ immediate: true }
)
watch(
() => hasInsightPanelContent.value,
(available) => {
if (!available) {
insightPanelCollapsed.value = false
}
}
)
watch(
() => reviewDocumentDrawerAvailable.value,
(available) => {
if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
}
}
)
watch(
() => reviewRiskDrawerAvailable.value,
(available) => {
if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
}
}
)
watch(
() => reviewFlowDrawerAvailable.value,
(available) => {
if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
}
}
)
watch(
() => composerDraft.value,
() => {
nextTick(adjustComposerTextareaHeight)
}
)
watch(
() => ({
sessionType: activeSessionType.value,
conversationId: conversationId.value,
draftClaimId: draftClaimId.value,
messages: messages.value,
currentInsight: currentInsight.value,
reviewFilePreviews: reviewFilePreviews.value,
composerDraft: composerDraft.value,
composerUploadIntent: composerUploadIntent.value,
guidedFlowState: guidedFlowState.value,
insightPanelCollapsed: insightPanelCollapsed.value
}),
() => {
persistSessionState()
},
{ deep: true }
)
watch(
() => [activeSessionType.value, resolveActiveClaimId()],
([sessionType, claimId]) => {
if (sessionType !== SESSION_TYPE_EXPENSE || !claimId) {
return
}
void restorePersistedDraftAttachmentPreviews(claimId)
},
{ immediate: true }
)
watch(
() => props.invalidatedDraftClaimId,
(claimId) => {
clearExpenseSessionForDeletedClaim(claimId)
},
{ immediate: true }
)
watch(
() => workbenchVisible.value,
(visible) => {
if (visible) {
scrollToBottom()
} else {
maybeFinalizeDeferredClose()
}
}
)
watch(
() => props.reopenToken,
(token, previousToken) => {
if (token === previousToken) {
return
}
closeAfterBusy.value = false
workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
)
watch(
() => [submitting.value, reviewActionBusy.value, sessionSwitchBusy.value, workbenchVisible.value],
() => {
maybeFinalizeDeferredClose()
}
)
watch(
() => messages.value.length,
() => {
if (!workbenchVisible.value) {
return
}
scrollToBottom()
}
)
onMounted(() => {
document.addEventListener('click', handleComposerDatePickerOutside)
startFlowTick()
nextTick(() => {
workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
})
currentInsight.value =
currentInsight.value
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
if (props.initialPrompt?.trim() || props.initialFiles.length) {
const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS)
composerDraft.value = props.initialPrompt.trim()
attachedFiles.value = initialMerge.files
composerFilesExpanded.value = initialMerge.files.length > VISIBLE_ATTACHMENT_CHIPS
if (initialMerge.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
submitComposer()
}
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleComposerDatePickerOutside)
stopFlowRuntime()
stopAttachmentRuntime()
})
function scrollToBottom() {
const scrollOnce = () => {
const list = messageListRef.value
if (!list) {
return false
}
list.scrollTop = list.scrollHeight
return true
}
nextTick(() => {
if (scrollOnce()) {
return
}
requestAnimationFrame(() => {
scrollOnce()
requestAnimationFrame(scrollOnce)
})
})
}
function handleAssistantModalAfterEnter() {
scrollToBottom()
requestAnimationFrame(() => {
scrollToBottom()
})
}
function resetCurrentSessionState() {
const emptyState = buildEmptySessionState(activeSessionType.value)
sessionSnapshots.value[activeSessionType.value] = emptyState
resetGuidedFlowState()
applySessionState(emptyState)
resetFlowRun({ startedAt: 0, openDrawer: false })
}
function clearExpenseSessionForDeletedClaim(claimId) {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return
}
const expenseSnapshot = sessionSnapshots.value[SESSION_TYPE_EXPENSE]
const snapshotMatchesDeletedClaim = String(expenseSnapshot?.draftClaimId || '').trim() === normalizedClaimId
const currentMatchesDeletedClaim =
activeSessionType.value === SESSION_TYPE_EXPENSE
&& String(resolveActiveClaimId() || '').trim() === normalizedClaimId
if (!snapshotMatchesDeletedClaim && !currentMatchesDeletedClaim) {
return
}
clearAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
if (currentMatchesDeletedClaim) {
resetCurrentSessionState()
toast('该草稿单据已删除,相关财务助手会话已清空。')
return
}
sessionSnapshots.value[SESSION_TYPE_EXPENSE] = buildEmptySessionState(SESSION_TYPE_EXPENSE)
}
function adjustComposerTextareaHeight() {
if (!composerTextareaRef.value) return
const textarea = composerTextareaRef.value
textarea.style.height = 'auto'
const styles = window.getComputedStyle(textarea)
const lineHeight = Number.parseFloat(styles.lineHeight) || 20
const verticalPadding =
Number.parseFloat(styles.paddingTop || '0') + Number.parseFloat(styles.paddingBottom || '0')
const minHeight = COMPOSER_TEXTAREA_HEIGHT
const maxHeight = lineHeight * COMPOSER_MAX_ROWS + verticalPadding
const nextHeight = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight))
textarea.style.height = `${nextHeight}px`
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden'
}
function handleComposerInput() {
adjustComposerTextareaHeight()
}
function handleComposerEnter(event) {
if (event?.isComposing || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
return
}
submitComposer()
}
function replaceMessage(messageId, nextMessage) {
const index = messages.value.findIndex((item) => item.id === messageId)
if (index === -1) {
messages.value.push(nextMessage)
return
}
messages.value.splice(index, 1, nextMessage)
}
async function runShortcut(shortcut) {
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
if (shortcut.active) {
return
}
await switchSessionType(shortcut.targetSessionType)
return
}
if (handleGuidedShortcut(shortcut)) {
return
}
const prompt = String(shortcut?.prompt || '').trim()
if (!prompt) return
composerDraft.value = prompt
submitComposer()
}
function isSuggestedActionSelected(message, action) {
const selectedKey = String(message?.selectedSuggestedActionKey || '').trim()
return Boolean(selectedKey) && selectedKey === buildSuggestedActionKey(action)
}
function lockSuggestedActionMessage(message, action) {
const messageId = String(message?.id || '').trim()
const targetMessage = messages.value.find((item) => String(item.id || '') === messageId) || message
if (!targetMessage || targetMessage.suggestedActionsLocked) {
return false
}
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const selectedLabel = String(action?.label || actionPayload.expense_type_label || '').trim()
const nextMeta = Array.isArray(targetMessage.meta)
? targetMessage.meta.filter((item) => item !== '等待选择场景')
: []
const selectedMeta = selectedLabel ? `已选择${selectedLabel}` : '已选择场景'
targetMessage.suggestedActionsLocked = true
targetMessage.selectedSuggestedActionKey = buildSuggestedActionKey(action)
targetMessage.selectedSuggestedActionLabel = selectedLabel
targetMessage.meta = Array.from(new Set([...nextMeta, selectedMeta]))
persistSessionState()
return true
}
function pushExpenseSceneSelectionPrompt(originalMessage) {
const sourceText = String(originalMessage || '').trim()
if (!sourceText) {
return
}
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
messages.value.push(createMessage('user', '我要报销'))
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], {
meta: ['等待选择场景'],
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
}))
nextTick(scrollToBottom)
persistSessionState()
}
function applySuggestedActionPrefill(action) {
const prefillText = resolveSuggestedActionPrefill(action)
if (!prefillText) {
return false
}
composerDraft.value = mergeComposerPrefill(composerDraft.value, prefillText)
nextTick(() => {
adjustComposerTextareaHeight()
composerTextareaRef.value?.focus()
})
persistSessionState()
return true
}
async function handleSuggestedAction(message, action) {
const actionType = String(action?.action_type || '').trim()
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
if (message?.suggestedActionsLocked) return
if (applySuggestedActionPrefill(action)) return
if (await handleGuidedSuggestedAction(message, action)) return
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const targetSessionType = String(actionPayload.session_type || '').trim()
if (!targetSessionType) return
const carryText = String(actionPayload.carry_text || '').trim()
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return
await switchSessionType(targetSessionType)
if (carryText) {
composerDraft.value = carryText
}
if (carryFiles.length) {
const fileMergeResult = mergeFilesWithLimit([], carryFiles, MAX_ATTACHMENTS)
attachedFiles.value = fileMergeResult.files
composerFilesExpanded.value = fileMergeResult.files.length > VISIBLE_ATTACHMENT_CHIPS
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
return
}
if (actionType === 'confirm_expense_intent') {
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
if (!originalMessage) return
if (!lockSuggestedActionMessage(message, action)) return
pushExpenseSceneSelectionPrompt(originalMessage)
return
}
if (actionType !== 'select_expense_type') {
const fallbackText = String(action?.description || action?.label || '').trim()
if (!fallbackText) return
if (!lockSuggestedActionMessage(message, action)) return
await submitComposer({
rawText: fallbackText,
userText: fallbackText,
pendingText: '正在继续处理...'
})
return
}
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const expenseType = String(actionPayload.expense_type || '').trim()
const expenseTypeLabel = String(actionPayload.expense_type_label || action?.label || '').trim()
const originalMessage = String(actionPayload.original_message || message?.text || '').trim()
if (!expenseTypeLabel || !originalMessage) return
if (!lockSuggestedActionMessage(message, action)) return
await submitComposer({
rawText: `${originalMessage}\n用户选择报销场景:${expenseTypeLabel}`,
userText: `选择${expenseTypeLabel}`,
pendingText: `已选择${expenseTypeLabel},正在按该场景识别...`,
systemGenerated: true,
extraContext: {
draft_claim_id: '',
user_input_text: originalMessage,
expense_scene_selection: {
expense_type: expenseType,
expense_type_label: expenseTypeLabel,
original_message: originalMessage
},
review_form_values: {
expense_type: expenseTypeLabel
}
}
})
}
function toggleInsightPanel() {
if (!hasInsightPanelContent.value) {
return
}
insightPanelCollapsed.value = !insightPanelCollapsed.value
}
function switchReviewDrawerMode(mode) {
if (reviewDrawerMode.value === mode) {
return
}
reviewDrawerMode.value = mode
}
function switchToReviewOverviewDrawer() {
if (!reviewOverviewDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
}
function toggleReviewDocumentDrawer() {
if (!reviewDocumentDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_DOCUMENTS)
}
function toggleReviewRiskDrawer() {
if (!reviewRiskDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
}
function toggleReviewFlowDrawer() {
if (!reviewFlowDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_FLOW)
}
function queryDraftByClaimNo(claimNo) {
const normalized = String(claimNo || '').trim()
if (!normalized || submitting.value || reviewActionBusy.value) return
submitComposer({
rawText: `查看报销草稿 ${normalized} 的当前信息`,
userText: `查看草稿 ${normalized}`,
systemGenerated: true
})
}
function appendReviewRiskBriefToConversation(item) {
if (!item) return
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item, resolveReviewRiskDetailTarget()), [], {
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
metaTone: item.level || 'low'
}))
nextTick(scrollToBottom)
}
function appendExpenseQueryRiskToConversation(record, risk) {
if (!record || !risk) return
const claimId = String(record.claimId || '').trim()
const claimNo = String(record.claimNo || '该单据').trim()
const route = claimId
? router.resolve({
name: 'app-document-detail',
params: { requestId: claimId }
})
: null
messages.value.push(createMessage(
'assistant',
buildReviewRiskConversationText(
{
title: `${claimNo} ${risk.levelLabel || '风险提示'}${risk.title || '风险提示'}`,
summary: risk.summary,
detail: risk.detail,
suggestion: '请进入单据详情核对费用明细、票据附件和附加说明;如属于合理例外,请补充业务说明后再继续流程。',
sourceLabel: risk.levelLabel,
level: risk.level
},
route?.href
? {
href: route.href,
label: `进入 ${claimNo} 详情重新填写`
}
: {}
),
[],
{
meta: [`${claimNo} 风险详情`],
metaTone: risk.level || 'medium'
}
))
nextTick(scrollToBottom)
}
function resolveReviewDetailTarget(message = null) {
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
const candidates = [
message?.draftPayload,
currentInsight.value.agent?.draftPayload,
latestReviewMessage.value?.draftPayload,
latestDraftMessage?.draftPayload,
linkedRequest.value
].filter(Boolean)
const claimTarget = candidates.find((item) => String(item?.claim_id || item?.claimId || item?.id || '').trim())
const claimId = String(claimTarget?.claim_id || claimTarget?.claimId || claimTarget?.id || draftClaimId.value || resolveActiveClaimId() || '').trim()
if (!claimId) {
return {}
}
const claimNoTarget = candidates.find((item) => String(item?.claim_no || item?.claimNo || item?.documentNo || '').trim())
const claimNo = String(claimNoTarget?.claim_no || claimNoTarget?.claimNo || claimNoTarget?.documentNo || '').trim()
const route = router.resolve({
name: 'app-document-detail',
params: { requestId: claimId }
})
return {
href: route.href,
label: claimNo ? `进入 ${claimNo} 详情重新填写` : '进入该单据详情重新填写'
}
}
function resolveReviewRiskDetailTarget() {
return resolveReviewDetailTarget()
}
function buildReviewNextStepRichCopyForMessage(message) {
const target = resolveReviewDetailTarget(message)
return buildReviewNextStepRichCopy(message?.reviewPayload, {
detailHref: target.href || ''
})
}
function buildMessageBubbleClass(message) {
if (message?.role === 'assistant' && message?.applicationPreview) {
return 'message-bubble-application-preview'
}
if (message?.role !== 'assistant' || !resolveReviewNextStepAction(message?.reviewPayload)) {
return ''
}
const counts = buildReviewRiskLevelCounts(message.reviewPayload)
if (counts.high > 0) {
return 'message-bubble-review-risk-high'
}
if (counts.medium > 0) {
return 'message-bubble-review-risk-medium'
}
if (counts.low > 0) {
return 'message-bubble-review-risk-low'
}
return ''
}
function openReviewNextStepConfirm(message) {
const action = resolveReviewNextStepAction(message?.reviewPayload)
if (!action) {
return
}
nextStepConfirmDialog.value = {
open: true,
message,
action
}
}
function closeReviewNextStepConfirm() {
if (reviewActionBusy.value) {
return
}
nextStepConfirmDialog.value = {
open: false,
message: null,
action: null
}
}
async function confirmReviewNextStepSubmit() {
const message = nextStepConfirmDialog.value.message
const action = nextStepConfirmDialog.value.action
if (!message || !action || reviewActionBusy.value) {
return
}
try {
await handleReviewActionInternal(message, action)
} finally {
nextStepConfirmDialog.value = {
open: false,
message: null,
action: null
}
}
}
function buildApplicationPreviewFooterText(message) {
if (!message?.applicationPreview) {
return ''
}
return buildApplicationPreviewFooterMessage(message.applicationPreview)
}
function openApplicationSubmitConfirm(message) {
if (!message) {
return
}
if (message.applicationPreview) {
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
message.applicationPreview = normalizedPreview
message.text = buildLocalApplicationPreviewMessage(normalizedPreview)
if (!normalizedPreview.readyToSubmit) {
toast(`请先补充:${normalizedPreview.missingFields.join('、')}`)
persistSessionState()
return
}
}
applicationSubmitConfirmDialog.value = {
open: true,
message
}
}
function closeApplicationSubmitConfirm() {
if (reviewActionBusy.value) {
return
}
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
}
async function confirmApplicationSubmit() {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
return
}
const applicationPreview = message?.applicationPreview && typeof message.applicationPreview === 'object'
? normalizeApplicationPreview(message.applicationPreview)
: null
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
reviewActionBusy.value = true
try {
const payload = await submitComposer({
rawText: applicationSubmitText,
userText: '确认提交',
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText
}
})
const draftPayload = payload?.result?.draft_payload || {}
const claimNo = String(draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claim_id || '').trim()
if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) {
emit('draft-saved', {
claimId,
claimNo,
status: 'submitted',
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
documentType: 'application'
})
}
} finally {
reviewActionBusy.value = false
}
}
function isWorkbenchBusy() {
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
}
function maybeFinalizeDeferredClose() {
if (!closeAfterBusy.value || workbenchVisible.value || isWorkbenchBusy()) {
return
}
closeAfterBusy.value = false
emit('close')
}
function requestCloseWorkbench() {
persistSessionState()
closeAfterBusy.value = isWorkbenchBusy()
workbenchVisible.value = false
}
function emitCloseAfterLeave() {
if (workbenchVisible.value) {
return
}
if (closeAfterBusy.value && isWorkbenchBusy()) {
return
}
closeAfterBusy.value = false
emit('close')
}
function openExpenseQueryRecord(record) {
const claimId = String(record?.claimId || '').trim()
if (!claimId) {
return
}
router.push({
name: 'app-document-detail',
params: { requestId: claimId }
})
emit('close')
}
async function handleExpenseQueryRecordClick(message, record) {
if (message?.queryPayload?.selectionMode !== 'draft_association') {
openExpenseQueryRecord(record)
return
}
if (message.querySelectionLocked || message.queryPayload.selectionLocked || submitting.value || reviewActionBusy.value) {
return
}
const claimId = String(record?.claimId || '').trim()
if (!claimId) {
return
}
const files = Array.from(attachedFiles.value || [])
if (!files.length) {
toast('本次上传的附件已不在当前会话中,请重新选择附件后再关联草稿。')
return
}
message.querySelectionLocked = true
message.selectedQueryRecordId = claimId
message.queryPayload.selectionLocked = true
message.queryPayload.selectedClaimId = claimId
draftClaimId.value = claimId
persistSessionState()
await submitComposer({
rawText: `将本次上传的 ${files.length} 份票据关联到报销草稿 ${record.claimNo}`,
userText: `关联到草稿 ${record.claimNo}`,
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
files,
uploadDisposition: 'continue_existing',
extraContext: {
draft_claim_id: claimId,
selected_claim_id: claimId,
selected_claim_no: String(record?.claimNo || '').trim()
}
})
}
function setExpenseQueryPage(message, page) {
if (!message?.queryPayload) {
return
}
const totalPages = getExpenseQueryTotalPages(message.queryPayload)
const nextPage = Math.min(Math.max(1, Number(page || 1)), totalPages)
message.queryPayload.currentPage = nextPage
}
function shiftExpenseQueryPage(message, delta) {
if (!message?.queryPayload) {
return
}
setExpenseQueryPage(message, getExpenseQueryActivePage(message.queryPayload) + Number(delta || 0))
}
function openDeleteSessionDialog() {
if (submitting.value || reviewActionBusy.value || deleteSessionBusy.value || sessionSwitchBusy.value) {
return
}
deleteSessionDialogOpen.value = true
}
function closeDeleteSessionDialog() {
if (deleteSessionBusy.value) {
return
}
deleteSessionDialogOpen.value = false
}
async function confirmDeleteCurrentSession() {
if (deleteSessionBusy.value || sessionSwitchBusy.value) {
return
}
deleteSessionBusy.value = true
try {
if (conversationId.value) {
await deleteConversation(conversationId.value, resolveCurrentUserId())
}
clearAssistantSessionSnapshot(resolveCurrentUserId(), activeSessionType.value)
resetCurrentSessionState()
deleteSessionDialogOpen.value = false
toast('当前会话已删除。')
} catch (error) {
toast(error?.message || '删除当前会话失败,请稍后重试。')
} finally {
deleteSessionBusy.value = false
}
}
const {
handleReviewActionInternal,
handleSaveDraftDirectlyInternal,
saveInlineReviewChangesInternal
} = useTravelReimbursementReviewActions({
activeReviewPayload,
buildDraftSavedPayload,
buildLocalReviewCompletionMessage,
buildLocalReviewSavedMessage,
buildReviewCorrectionMessage,
buildReviewDocumentCorrectionContext,
buildReviewDocumentCorrectionMessage,
buildReviewFormValues,
buildReviewRiskItems,
buildReviewSubmitUserText,
buildLocallySyncedReviewPayload,
cloneReviewDocumentDrafts,
cloneReviewEditFields,
commitInlineReviewEditor,
createMessage,
currentInsight,
currentUser,
emit,
latestReviewMessage,
linkedRequest,
mergeInlineReviewFields,
messages,
nextTick,
reviewActionBusy,
reviewDocumentBaseDrafts,
reviewDocumentDrafts,
reviewHasUnsavedChanges,
reviewInlineBaseFields,
reviewInlineBaseForm,
reviewInlineEditorKey,
reviewInlineForm,
reviewInlinePendingFiles,
scrollToBottom,
sessionSwitchBusy,
submitComposer,
submitting
})
function saveInlineReviewChanges() {
if (
!activeReviewPayload.value
|| !reviewHasUnsavedChanges.value
|| submitting.value
|| reviewActionBusy.value
|| sessionSwitchBusy.value
) return
return saveInlineReviewChangesInternal()
}
function askHotKnowledgeQuestion(question) {
const normalizedQuestion = String(question || '').trim()
if (!normalizedQuestion || !isKnowledgeSession.value || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
return
}
submitComposer({
rawText: normalizedQuestion,
userText: normalizedQuestion,
pendingText: '正在整理财务知识答案...'
})
}
async function submitComposer(options = {}) {
// resolvedUploadDisposition === 'continue_existing'
// buildReviewFormContextFromPayload(
// activeReviewPayload.value,
// reviewInlineForm.value
// )
// extraContext.review_form_values
// inheritedReviewContext.business_time_context
// extraContext.business_time_context = inheritedReviewContext.business_time_context
// submitting.value = true
// recognizeOcrFiles(files)
// submitting.value = false
if (await handleGuidedComposerSubmit(options)) {
return null
}
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 === APPLICATION_SUBMIT_HREF) {
event.preventDefault()
openApplicationSubmitConfirm(message)
return
}
if (href === REVIEW_NEXT_STEP_HREF) {
event.preventDefault()
openReviewNextStepConfirm(message)
return
}
if (href.startsWith(REVIEW_RISK_PANEL_HREF_PREFIX)) {
event.preventDefault()
if (reviewRiskDrawerAvailable.value) {
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
} else {
toast('当前没有需要额外处理的风险信息。')
}
return
}
if (href === REVIEW_QUICK_EDIT_HREF) {
event.preventDefault()
if (reviewOverviewDrawerAvailable.value) {
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
toast('已打开右侧核对信息,可以直接修改当前单据。')
}
return
}
if (href.startsWith('/app/')) {
event.preventDefault()
router.push(href)
return
}
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
return handleReviewActionInternal(message, action)
}
async function handleSaveDraftDirectly(message, actionType = 'save_draft') {
return handleSaveDraftDirectlyInternal(message, actionType)
}
function isDraftSavedReviewMessage(message) {
if (!message?.reviewPayload) {
return false
}
return Boolean(
String(message?.draftPayload?.claim_no || message?.draftPayload?.claim_id || '').trim()
|| String(draftClaimId.value || '').trim()
|| String(resolveActiveClaimId() || '').trim()
)
}
function buildReviewPlainFollowupForMessage(message) {
return buildReviewPlainFollowupCopy(message?.reviewPayload, {
savedDraft: isDraftSavedReviewMessage(message)
})
}
function canUseInlineSaveDraft(message) {
if (!message?.reviewPayload || isDraftSavedReviewMessage(message)) {
return false
}
return Boolean(resolveReviewSaveDraftAction(message.reviewPayload))
}
async function handleInlineSaveDraft(message) {
if (
!canUseInlineSaveDraft(message)
|| submitting.value
|| reviewActionBusy.value
|| sessionSwitchBusy.value
) {
return
}
await handleSaveDraftDirectly(message, 'save_draft')
}
return {
emit, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection,
toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText,
attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions,
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, assistantHeaderTitle, assistantHeaderDescription, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
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,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, canShowTravelCalculator, deleteSessionDialogOpen, applicationSubmitConfirmDialog, applicationPreviewEditor, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, 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, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, openApplicationPreviewEditor, commitApplicationPreviewEditor, cancelApplicationPreviewEditor, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}