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 { recognizeOcrFiles } from '../../services/ocr.js' import { fetchAgentRunDetail } from '../../services/agentAssets.js' import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js' import { renderMarkdown } from '../../utils/markdown.js' import { buildLocalExtractionProgressMessages, buildLocalIntentPreview, summarizeSemanticIntentDetail, TRANSPORT_KEYWORD_PATTERN } from '../../utils/reimbursementTextInference.js' import { calculateTravelReimbursement, fetchExpenseClaimAttachmentAsset, fetchExpenseClaimDetail, fetchExpenseClaimItemAttachmentMeta, uploadExpenseClaimItemAttachment } from '../../services/reimbursements.js' const aiAvatar = '/assets/header.png' const userAvatar = '/assets/person.png' const SOURCE_LABELS = { workbench: '来自个人工作台', topbar: '来自发起报销', detail: '来自智能录入', upload: '来自附件上传', requests: '来自报销列表' } const SCENARIO_LABELS = { expense: '报销', accounts_receivable: '应收', accounts_payable: '应付', knowledge: '知识', unknown: '通用' } const INTENT_LABELS = { query: '查询', explain: '解释', compare: '对比', risk_check: '风险检查', draft: '草稿生成', operate: '动作请求' } const REVIEW_RISK_LEVEL_META = { high: { label: '高风险', icon: 'mdi mdi-alert-octagon-outline', suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。' }, medium: { label: '中风险', icon: 'mdi mdi-alert-circle-outline', suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。' }, low: { label: '低风险', icon: 'mdi mdi-information-outline', suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。' } } const DOCUMENT_TYPE_LABELS = { travel_ticket: '行程单/机票/车票', flight_itinerary: '机票/航班行程单', train_ticket: '火车/高铁票', hotel_invoice: '酒店住宿票据', taxi_receipt: '出租车/网约车票据', parking_toll_receipt: '停车/通行费票据', transport_receipt: '交通出行票据', meal_receipt: '餐饮票据', office_invoice: '办公用品票据', meeting_invoice: '会议/会务票据', training_invoice: '培训票据', vat_invoice: '增值税发票', receipt: '一般收据/凭证', other: '其他单据' } const EXPENSE_TYPE_LABELS = { travel: '差旅费', hotel: '住宿费', transport: '交通费', meal: '伙食费', meeting: '会务费', entertainment: '业务招待费', office: '办公费', training: '培训费', communication: '通讯费', welfare: '福利费', other: '其他费用' } const REVIEW_SLOT_CONFIG = { expense_type: { title: '报销分类', hint: '请选择本次报销分类', status: '待确认', icon: 'mdi mdi-shape-outline' }, customer_name: { title: '关联客户', hint: '请补充客户单位全称', status: '待补充', icon: 'mdi mdi-domain' }, time_range: { title: '发生时间', hint: '请按 YYYY-MM-DD 补充业务发生日期', status: '待补充', icon: 'mdi mdi-calendar-month-outline' }, location: { title: '业务地点', hint: '请补充业务发生地点', status: '待补充', icon: 'mdi mdi-map-marker-outline' }, merchant_name: { title: '酒店/商户', hint: '请补充酒店或商户名称', status: '待补充', icon: 'mdi mdi-storefront-outline' }, amount: { title: '金额', hint: '请补充本次费用金额', status: '待补充', icon: 'mdi mdi-cash' }, reason: { title: '场景 / 事由', hint: '请补充本次费用场景或事由', status: '待补充', icon: 'mdi mdi-text-box-outline' }, participants: { title: '同行人员', hint: '请至少填写 1 名同行人员', status: '待补充', icon: 'mdi mdi-account-group-outline' }, attachments: { title: '票据状态', hint: '请上传发票/收据等票据附件', status: '未上传', icon: 'mdi mdi-paperclip' } } const REVIEW_FALLBACK_GROUP_CODES = [ 'other', 'travel', 'transport', 'hotel', 'meal', 'meeting', 'entertainment', 'office', 'training', 'communication', 'welfare' ] const REVIEW_CATEGORY_PRESET_OPTIONS = [ { key: 'travel', label: '差旅费' }, { key: 'transport', label: '交通费' }, { key: 'hotel', label: '住宿费' }, { key: 'meal', label: '餐费' }, { key: 'entertainment', label: '业务招待费' }, { key: 'other_trigger', label: '其他类型', is_other: true } ] const REVIEW_OTHER_CATEGORY_OPTIONS = [ { key: 'meeting', label: '会务费' }, { key: 'office', label: '办公费' }, { key: 'training', label: '培训费' }, { key: 'communication', label: '通讯费' }, { key: 'welfare', label: '福利费' }, { key: 'other', label: '其他费用' } ] const REVIEW_SCENE_OTHER_OPTION = '其他场景' const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', REVIEW_SCENE_OTHER_OPTION] const EXPENSE_CODE_TO_PRESET_SCENE = { travel: '出差行程', hotel: '住宿报销', transport: '交通出行', meeting: '会务活动', entertainment: '请客户吃饭', meal: '请客户吃饭' } const DATE_INPUT_FORMAT = 'YYYY-MM-DD' const MAX_ATTACHMENTS = 10 const MAX_OCR_DOCUMENTS = 10 const VISIBLE_ATTACHMENT_CHIPS = 2 const COMPOSER_TEXTAREA_HEIGHT = 36 const COMPOSER_MAX_ROWS = 5 const EXPENSE_QUERY_PAGE_SIZE = 5 const SESSION_TYPE_EXPENSE = 'expense' const SESSION_TYPE_KNOWLEDGE = 'knowledge' 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 FLOW_STEP_STATUS_PENDING = 'pending' const FLOW_STEP_STATUS_RUNNING = 'running' const FLOW_STEP_STATUS_COMPLETED = 'completed' const FLOW_STEP_STATUS_FAILED = 'failed' const FLOW_STEP_FALLBACKS = { intent: { title: '意图识别', tool: 'IntentRecognizer', runningText: '正在识别业务意图...', completedText: '意图识别完成' }, extraction: { title: '信息提取', tool: 'SemanticExtractor', runningText: '正在提取时间、金额、费用类型和待补项...', completedText: '信息提取完成' }, ocr: { title: '票据/OCR识别', tool: 'OCRService', runningText: '正在识别票据附件...', completedText: '票据识别完成' }, 'expense-claim-draft': { title: '报销草稿处理', tool: 'database.expense_claims.save_or_submit', runningText: '正在根据识别结果更新草稿和右侧核对信息...', completedText: '草稿和核对信息已更新' } } const ASSISTANT_DISPLAY_NAME = '财务助手' const EXPENSE_WELCOME_QUICK_ACTIONS = [ { label: '发起差旅报销', prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。', icon: 'mdi mdi-bag-suitcase-outline' }, { label: '招待费报销', prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。', icon: 'mdi mdi-food-fork-drink' }, { label: '交通费报销', prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。', icon: 'mdi mdi-car-outline' }, { label: '上传票据识别', prompt: '我已准备好票据,请帮我识别并生成报销草稿。', icon: 'mdi mdi-file-upload-outline' }, { label: '查询近期报销', prompt: '帮我查询近10天的报销记录和金额汇总。', icon: 'mdi mdi-chart-timeline-variant' }, { label: '解释报销风险', prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。', icon: 'mdi mdi-shield-alert-outline' } ] const HOT_KNOWLEDGE_QUESTIONS = [ '差旅住宿标准按什么规则执行?', '酒店超标后如何申请例外报销?', '招待费报销需要哪些凭证?', '发票抬头不一致还能报销吗?', '电子发票验真失败怎么处理?', '借款多久内需要冲销?', '预算不足还能先提交报销吗?', '会议费和招待费如何区分?', '跨部门项目费用应该怎么归集?', '员工退票手续费是否可以报销?' ] const CATEGORY_CONFIDENCE_KEYWORDS = { travel: [/出差|差旅|行程|机票|火车|高铁|航班/], hotel: [/住宿|酒店|宾馆|民宿/], transport: [TRANSPORT_KEYWORD_PATTERN], meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/], meeting: [/会务|会议|论坛|展会|参会|会场/], entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/], office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/], training: [/培训|授课|讲师|课程|签到|讲义/], communication: [/通讯|电话|流量|话费|宽带|网络/], welfare: [/福利|体检|团建|节日|慰问|关怀/] } const FLOW_MISSING_SLOT_LABELS = { expense_type: '报销类型', customer_name: '客户名称', time_range: '发生时间', location: '地点', merchant_name: '酒店/商户', amount: '金额', reason: '事由说明', participants: '参与人员', attachments: '票据附件' } const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意'] let messageSeed = 0 function nowTime() { return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } function createMessage(role, text, attachments = [], extras = {}) { messageSeed += 1 return { id: `msg-${messageSeed}`, role, text, attachments, time: nowTime(), meta: [], citations: [], suggestedActions: [], queryPayload: null, draftPayload: null, reviewPayload: null, riskFlags: [], ...extras } } function formatMessageTime(value) { if (!value) { return nowTime() } const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) { return nowTime() } return parsed.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } function formatSemanticEntityValue(entity) { const normalizedValue = String(entity?.normalized_value || '').trim() const rawValue = String(entity?.value || '').trim() const entityType = String(entity?.type || '').trim() if (entityType === 'amount') { const numericValue = Number(normalizedValue || rawValue) if (Number.isFinite(numericValue) && numericValue > 0) { return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` } } return rawValue || normalizedValue } function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) { if (!semanticParse || typeof semanticParse !== 'object') { return FLOW_STEP_FALLBACKS.extraction.completedText } const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : [] const entityMap = new Map() for (const item of entities) { const entityType = String(item?.type || '').trim() if (!entityType || entityMap.has(entityType)) continue entityMap.set(entityType, item) } const extractedParts = [] const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object' ? semanticParse.time_range_json : {} const startDate = String(timeRange.start_date || '').trim() const endDate = String(timeRange.end_date || '').trim() if (startDate) { extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`) } const amountEntity = entityMap.get('amount') if (amountEntity) { const amountValue = formatSemanticEntityValue(amountEntity) if (amountValue) { extractedParts.push(`金额 ${amountValue}`) } } const expenseTypeEntity = entityMap.get('expense_type') if (expenseTypeEntity) { const expenseTypeLabel = resolveExpenseTypeLabel( String(expenseTypeEntity?.normalized_value || '').trim(), String(expenseTypeEntity?.value || '').trim() ) if (expenseTypeLabel) { extractedParts.push(`费用类型 ${expenseTypeLabel}`) } } const customerEntity = entityMap.get('customer') if (customerEntity) { const customerValue = formatSemanticEntityValue(customerEntity) if (customerValue) { extractedParts.push(`客户 ${customerValue}`) } } const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : [] const missingLabels = missingSlots .map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()) .filter(Boolean) if (extractedParts.length && missingLabels.length) { return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}` } if (extractedParts.length) { return `已提取${extractedParts.join('、')}` } if (missingLabels.length) { return `已完成信息提取;待补充 ${missingLabels.join('、')}` } return FLOW_STEP_FALLBACKS.extraction.completedText } function formatFlowDuration(ms) { const numericValue = Number(ms) if (!Number.isFinite(numericValue) || numericValue < 0) { return '--' } if (numericValue < 1000) { return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s` } if (numericValue < 10000) { return `${(numericValue / 1000).toFixed(1)}s` } return `${Math.round(numericValue / 1000)}s` } function parseFlowTimestamp(value) { const timestamp = new Date(value || '').getTime() return Number.isFinite(timestamp) ? timestamp : 0 } function resolveSemanticPhaseDurations(run) { const runStart = parseFlowTimestamp(run?.started_at) const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] const firstToolStartedAt = toolCalls .map((item) => parseFlowTimestamp(item?.created_at)) .filter((value) => value > 0) .sort((left, right) => left - right)[0] || 0 const runFinishedAt = parseFlowTimestamp(run?.finished_at) const semanticFinishedAt = firstToolStartedAt || runFinishedAt if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) { return { intentMs: null, extractionMs: null } } const totalMs = semanticFinishedAt - runStart const intentMs = Math.max(120, Math.round(totalMs * 0.35)) const extractionMs = Math.max(160, totalMs - intentMs) return { intentMs, extractionMs } } function resolveToolCallDurationMs(toolCall, index, toolCalls, run) { const explicitDuration = Number(toolCall?.duration_ms) if (Number.isFinite(explicitDuration) && explicitDuration > 0) { return explicitDuration } const startedAt = parseFlowTimestamp(toolCall?.created_at) if (!startedAt) { return null } const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at) const runFinishedAt = parseFlowTimestamp(run?.finished_at) const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0) if (!finishedAt || finishedAt <= startedAt) { return null } return finishedAt - startedAt } function sanitizeRequest(request) { if (!request || typeof request !== 'object') return null const normalized = { id: String(request.id || '').trim(), typeLabel: String(request.typeLabel || request.category || '').trim(), reason: String(request.reason || request.title || '').trim(), entity: String(request.entity || '').trim(), city: String(request.city || request.location || '').trim(), period: String(request.period || '').trim(), applyTime: String(request.applyTime || request.occurredAt || '').trim(), amount: String(request.amount || '').trim(), node: String(request.node || '').trim(), approval: String(request.approval || '').trim(), travel: String(request.travel || '').trim() } return Object.values(normalized).some(Boolean) ? normalized : null } function resolveStatusLabel(status) { if (status === 'succeeded') return '已完成' if (status === 'blocked') return '已阻断' return '失败' } function resolveStatusTone(status) { if (status === 'succeeded') return 'success' if (status === 'blocked') return 'warning' return 'note' } function buildMessageMeta(payload, fileNames = []) { const items = [] if (payload?.selected_agent) { items.push(`Agent: ${payload.selected_agent}`) } if (payload?.permission_level) { items.push(`权限: ${payload.permission_level}`) } if (payload?.trace_summary?.tool_count) { items.push(`工具: ${payload.trace_summary.tool_count}`) } if (payload?.trace_summary?.degraded) { items.push('已降级') } if (payload?.requires_confirmation) { items.push('待确认') } if (payload?.run_id) { items.push(`Run: ${payload.run_id}`) } if (fileNames.length) { items.push(`附件: ${fileNames.length}`) } return items } function buildStoredMessageMeta(messageJson, attachmentNames = []) { const payload = messageJson?.orchestrator_payload if (payload) { return buildMessageMeta(payload, attachmentNames) } const items = [] if (messageJson?.status) { items.push(`状态: ${messageJson.status}`) } if (attachmentNames.length) { items.push(`附件: ${attachmentNames.length}`) } return items } function normalizeOcrDocuments(payload) { const documents = Array.isArray(payload?.documents) ? payload.documents : [] return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({ filename: item.filename, summary: item.summary, text: String(item.text || '').slice(0, 240), avg_score: Number(item.avg_score || 0), line_count: Number(item.line_count || 0), document_type: String(item.document_type || 'other').trim() || 'other', document_type_label: String(item.document_type_label || '').trim(), scene_code: String(item.scene_code || 'other').trim() || 'other', scene_label: String(item.scene_label || '').trim(), preview_kind: String(item.preview_kind || '').trim(), preview_data_url: String(item.preview_data_url || '').trim(), preview_url: String(item.preview_url || '').trim(), document_fields: Array.isArray(item.document_fields) ? item.document_fields .map((field) => ({ key: String(field?.key || '').trim(), label: String(field?.label || '').trim(), value: String(field?.value || '').trim() })) .filter((field) => field.key && field.label && field.value) : [], warnings: Array.isArray(item.warnings) ? item.warnings : [] })) } function buildOcrSummary(payload) { return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload)) } function buildOcrSummaryFromDocuments(documents) { return (Array.isArray(documents) ? documents : []) .slice(0, MAX_OCR_DOCUMENTS) .map((item) => { const filename = String(item?.filename || '').trim() const summary = String(item?.summary || item?.text || '').trim() if (filename && summary) { return `${filename}:${summary}` } return filename || summary }) .filter(Boolean) .join(';') } function normalizeReviewDocumentFieldKey(label) { const compact = String(label || '').replace(/\s+/g, '').toLowerCase() if (!compact) return '' if ( ['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) => compact.includes(token.toLowerCase()) ) ) { return 'amount' } if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) { return 'date' } if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) { return 'merchant_name' } if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) { return 'invoice_number' } if (compact.includes('发票代码')) { return 'invoice_code' } if (compact.includes('车次') || compact.includes('航班')) { return 'trip_no' } if (compact.includes('行程') || compact.includes('路线')) { return 'route' } return compact } function buildOcrDocumentsFromReviewPayload(reviewPayload) { const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => { const fields = Array.isArray(item?.fields) ? item.fields .map((field) => { const label = String(field?.label || '').trim() const value = String(field?.value || '').trim() if (!label || !value) { return null } return { key: normalizeReviewDocumentFieldKey(label), label, value } }) .filter(Boolean) : [] return { filename: String(item?.filename || '').trim(), summary: String(item?.summary || '').trim(), text: [ String(item?.scene_label || '').trim(), String(item?.summary || '').trim(), ...fields.map((field) => `${field.label}:${field.value}`) ] .filter(Boolean) .join(' ') .slice(0, 240), avg_score: Number(item?.avg_score || 0), document_type: String(item?.document_type || 'other').trim() || 'other', document_type_label: resolveDocumentTypeLabel(item?.document_type), scene_code: resolveExpenseTypeCode(item?.suggested_expense_type), scene_label: String(item?.scene_label || '').trim(), document_fields: fields, warnings: Array.isArray(item?.warnings) ? item.warnings : [] } }).filter((item) => item.filename) } function mergeUploadAttachmentNames(existingNames, incomingNames) { const merged = [] const seen = new Set() for (const value of [...(existingNames || []), ...(incomingNames || [])]) { const normalized = String(value || '').trim() if (!normalized || seen.has(normalized)) continue seen.add(normalized) merged.push(normalized) if (merged.length >= MAX_ATTACHMENTS) { break } } return merged } function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) { const merged = [] const seen = new Set() for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) { const filename = String(item?.filename || '').trim() if (!filename || seen.has(filename)) continue seen.add(filename) merged.push(item) if (merged.length >= MAX_OCR_DOCUMENTS) { break } } return merged } function inferPreviewKind(file) { const mediaType = String(file?.type || '').toLowerCase() const filename = String(file?.name || '').toLowerCase() if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) { return 'image' } if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) { return 'pdf' } return 'file' } function buildFilePreviews(files, previewRegistry) { return files.map((file) => { const kind = inferPreviewKind(file) if (!['image', 'pdf'].includes(kind)) { return { filename: file.name, kind } } const url = URL.createObjectURL(file) previewRegistry.push(url) return { filename: file.name, kind, url } }) } function resolveDocumentPreview(filePreviews, filename) { if (!Array.isArray(filePreviews)) return null const matches = filePreviews.filter((item) => item.filename === filename) if (!matches.length) { return null } return ( matches.find((item) => item.kind === 'image' && item.url) || matches.find((item) => item.url) || matches[0] ) } function buildFileIdentity(file) { return [file?.name, file?.size, file?.lastModified, file?.type].join('__') } function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) { const nextFiles = [] const seen = new Set() for (const file of Array.isArray(existingFiles) ? existingFiles : []) { const key = buildFileIdentity(file) if (seen.has(key)) continue seen.add(key) nextFiles.push(file) } let duplicateCount = 0 let overflowCount = 0 for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) { const key = buildFileIdentity(file) if (seen.has(key)) { duplicateCount += 1 continue } if (nextFiles.length >= limit) { overflowCount += 1 continue } seen.add(key) nextFiles.push(file) } return { files: nextFiles, duplicateCount, overflowCount } } function mergeFilePreviews(existingPreviews, incomingPreviews) { const result = [] const seen = new Set() for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) { const key = [preview?.filename, preview?.kind].join('__') if (!preview?.filename || seen.has(key)) continue seen.add(key) result.push(preview) } return result } function buildOcrFilePreviews(payload) { const documents = Array.isArray(payload?.documents) ? payload.documents : [] return documents .map((item) => ({ filename: String(item?.filename || '').trim(), kind: String(item?.preview_kind || '').trim(), url: String(item?.preview_url || item?.preview_data_url || '').trim() })) .filter((item) => item.filename && item.kind === 'image' && item.url) } function buildReviewFilePreviewsFromReviewPayload(reviewPayload) { const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] return documents .map((item) => ({ filename: String(item?.filename || '').trim(), kind: String(item?.preview_kind || '').trim(), url: String(item?.preview_url || item?.preview_data_url || '').trim() })) .filter((item) => item.filename && item.kind === 'image' && item.url) } function buildReviewFilePreviewsFromMessages(messages) { const previews = [] for (const message of Array.isArray(messages) ? messages : []) { previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload)) } return mergeFilePreviews([], previews) } function resolveAttachmentPreviewKind(metadata) { const explicitKind = String(metadata?.preview_kind || '').trim() if (explicitKind) { return explicitKind } const mediaType = String(metadata?.media_type || '').trim().toLowerCase() if (mediaType.startsWith('image/')) { return 'image' } if (mediaType === 'application/pdf') { return 'pdf' } return '' } function extractReviewAttachmentNames(reviewPayload) { const documentNames = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean) : [] if (documentNames.length) { return documentNames } const slotMap = buildReviewSlotMap(reviewPayload) const attachmentValue = String(slotMap.attachments?.value || '').trim() if (!attachmentValue) { return [] } return attachmentValue.split(/[、,,]/).map((item) => item.trim()).filter(Boolean) } function cloneReviewDocumentDrafts(items) { return (Array.isArray(items) ? items : []).map((item) => ({ ...item, warnings: Array.isArray(item?.warnings) ? [...item.warnings] : [], fields: Array.isArray(item?.fields) ? item.fields.map((field) => ({ label: String(field?.label || '').trim(), value: String(field?.value || ''), source: String(field?.source || 'ocr').trim() || 'ocr' })) : [] })) } function buildReviewDocumentDrafts(reviewPayload) { return buildReviewDocumentSummaries(reviewPayload).map((item) => ({ index: Number(item.index || 0), filename: String(item.filename || '').trim(), document_type: String(item.document_type || 'other').trim() || 'other', suggested_expense_type: String(item.suggested_expense_type || 'other').trim() || 'other', scene_label: String(item.scene_label || '').trim(), summary: String(item.summary || '').trim(), confidenceLabel: String(item.confidenceLabel || '').trim(), documentTypeLabel: String(item.documentTypeLabel || '').trim(), expenseTypeLabel: String(item.expenseTypeLabel || '').trim(), preview_kind: String(item.preview_kind || '').trim(), preview_data_url: String(item.preview_data_url || '').trim(), warnings: Array.isArray(item.warnings) ? [...item.warnings] : [], fields: Array.isArray(item.fields) ? item.fields.map((field) => ({ label: String(field?.label || '').trim(), value: String(field?.value || ''), source: String(field?.source || 'ocr').trim() || 'ocr' })) : [] })) } function normalizeReviewDocumentComparableValue(item) { return { index: Number(item?.index || 0), filename: String(item?.filename || '').trim(), scene_label: String(item?.scene_label || '').trim(), summary: String(item?.summary || '').trim(), fields: (Array.isArray(item?.fields) ? item.fields : []).map((field) => ({ label: String(field?.label || '').trim(), value: String(field?.value || '').trim() })) } } function buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) { const baseMap = new Map( cloneReviewDocumentDrafts(baseDrafts).map((item) => [`${item.index}:${item.filename}`, item]) ) return cloneReviewDocumentDrafts(nextDrafts).reduce((lines, item) => { const key = `${item.index}:${item.filename}` const base = baseMap.get(key) const changes = [] const nextSceneLabel = String(item.scene_label || '').trim() const baseSceneLabel = String(base?.scene_label || '').trim() const nextSummary = String(item.summary || '').trim() const baseSummary = String(base?.summary || '').trim() if (nextSceneLabel !== baseSceneLabel) { changes.push(`票据场景:${nextSceneLabel || '待补充'}`) } if (nextSummary !== baseSummary) { changes.push(`识别摘要:${nextSummary || '待补充'}`) } const baseFieldMap = new Map( (Array.isArray(base?.fields) ? base.fields : []).map((field) => [ String(field?.label || '').trim(), String(field?.value || '').trim() ]) ) for (const field of Array.isArray(item.fields) ? item.fields : []) { const label = String(field?.label || '').trim() if (!label) continue const nextValue = String(field?.value || '').trim() const baseValue = baseFieldMap.get(label) || '' if (nextValue !== baseValue) { changes.push(`${label}:${nextValue || '待补充'}`) } } if (changes.length) { lines.push(`第${item.index}张票据(${item.filename}):${changes.join(';')}`) } return lines }, []) } function buildReviewDocumentCorrectionMessage(baseDrafts, nextDrafts) { const lines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) if (!lines.length) { return '' } return `请同步修正逐票据识别结果:\n${lines.join('\n')}` } function buildReviewDocumentCorrectionContext(drafts) { return cloneReviewDocumentDrafts(drafts).map((item) => ({ index: item.index, filename: item.filename, scene_label: String(item.scene_label || '').trim(), summary: String(item.summary || '').trim(), fields: item.fields.map((field) => ({ label: String(field.label || '').trim(), value: String(field.value || '').trim() })) })) } function buildWelcomeUserContext(user = {}) { const username = String(user.username || '').trim() const name = String(user.name || username || '同事').trim() const grade = String(user.grade || '').trim() const position = String(user.position || '').trim() const role = String(user.role || '').trim() const roleCodes = Array.isArray(user.roleCodes) ? user.roleCodes : [] const isAdmin = Boolean(user.isAdmin) || username.toLowerCase() === 'admin' || roleCodes.some((item) => /admin|manager/i.test(String(item || ''))) || /管理员|系统管理/.test(position) || /管理员|系统管理/.test(role) const now = new Date() const dateLine = now.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }) let honorific = name if (isAdmin) { honorific = name && !/^admin$/i.test(name) ? `${name} 管理员` : '管理员' } else { const prefix = [grade, position].filter(Boolean).join(' ') honorific = prefix ? `${prefix} ${name}`.trim() : name } return { name, username, grade, position, role, isAdmin, honorific, dateLine } } function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) { if (sessionType === SESSION_TYPE_KNOWLEDGE) { return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({ label: question.length > 20 ? `${question.slice(0, 20)}…` : question, prompt: question, icon: 'mdi mdi-comment-question-outline' })) } if (entrySource === 'detail' && linkedRequest?.id) { return [ { label: '补充当前单据票据', prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`, icon: 'mdi mdi-file-plus-outline' }, { label: '解释本单风险', prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`, icon: 'mdi mdi-shield-alert-outline' }, ...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4) ] } return EXPENSE_WELCOME_QUICK_ACTIONS } function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { const ctx = buildWelcomeUserContext(user || {}) const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}` if (sessionType === SESSION_TYPE_KNOWLEDGE) { return [ `${greeting}!今日是 **${ctx.dateLine}**。`, '', '欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。', '', '您可以直接输入问题,或点击下方「猜你想问」快速开始。' ].join('\n') } if (entrySource === 'detail' && linkedRequest?.id) { return [ `${greeting}!今日是 **${ctx.dateLine}**。`, '', `我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`, '', '如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。' ].join('\n') } return [ `${greeting}!今日是 **${ctx.dateLine}**。`, '', '**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销草稿整理、待补项提醒和风险说明。', '', '您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。' ].join('\n') } function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { const ctx = buildWelcomeUserContext(user || {}) if (sessionType === SESSION_TYPE_KNOWLEDGE) { return { intent: 'welcome', metricLabel: '今日', metricValue: ctx.dateLine.split(' ')[0] || '—', title: '财务知识问答', summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`, agent: null } } return { intent: 'welcome', metricLabel: '助手状态', metricValue: '待您吩咐', title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心', summary: entrySource === 'detail' && linkedRequest?.id ? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。` : `${ctx.honorific},描述费用场景或上传票据后,我会在右侧展示识别结果,并在对话中提示待补信息与风险。`, agent: null } } function createWelcomeAssistantMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) { return createMessage('assistant', buildWelcomeMessage(entrySource, linkedRequest, sessionType, user), [], { assistantName: ASSISTANT_DISPLAY_NAME, isWelcome: true, welcomeQuickActions: buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) }) } function resolveInitialSessionType(conversation) { const stateJson = conversation?.state_json || conversation?.stateJson || {} const sessionType = String(stateJson?.session_type || '').trim() return sessionType || SESSION_TYPE_EXPENSE } function buildInitialInsightFromConversation(conversation) { const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] for (let index = rawMessages.length - 1; index >= 0; index -= 1) { const item = rawMessages[index] const messageJson = item?.message_json || item?.messageJson || {} const orchestratorPayload = messageJson?.orchestrator_payload || null if (!orchestratorPayload) continue const attachmentNames = Array.isArray(messageJson?.attachment_names) ? messageJson.attachment_names.filter(Boolean) : [] return buildAgentInsight( orchestratorPayload, attachmentNames, buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload) ) } return null } function resolveInitialConversationId(conversation) { return String(conversation?.conversation_id || conversation?.conversationId || '').trim() } function resolveInitialDraftClaimId(conversation) { return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim() } function resolveKnowledgeRankLabel(index) { return String(index + 1) } function resolveKnowledgeRankTone(index) { if (index === 0) return 'gold' if (index === 1) return 'silver' if (index === 2) return 'bronze' return 'default' } function parseConversationMessageSequence(message) { const messageJson = message?.message_json || message?.messageJson || {} const sequence = Number.parseInt(messageJson?.sequence, 10) return Number.isFinite(sequence) && sequence > 0 ? sequence : null } function parseConversationMessageTime(message) { const rawValue = message?.created_at || message?.createdAt || '' const timestamp = new Date(rawValue).getTime() return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER } function resolveConversationMessageRolePriority(message) { return String(message?.role || '').trim() === 'user' ? 0 : 1 } function sortConversationMessages(messages) { return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => { const leftSequence = parseConversationMessageSequence(left) const rightSequence = parseConversationMessageSequence(right) if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) { return leftSequence - rightSequence } const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right) if (timeDiff !== 0) { return timeDiff } const leftRunId = String(left?.run_id || left?.runId || '').trim() const rightRunId = String(right?.run_id || right?.runId || '').trim() if (leftRunId && rightRunId && leftRunId === rightRunId) { const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right) if (roleDiff !== 0) { return roleDiff } } return String(left?.id || '').localeCompare(String(right?.id || '')) }) } function normalizeInitialConversationMessages(conversation) { const rawMessages = sortConversationMessages(conversation?.messages) return rawMessages.map((item) => { const messageJson = item?.message_json || item?.messageJson || {} const attachmentNames = Array.isArray(messageJson?.attachment_names) ? messageJson.attachment_names.filter(Boolean) : [] const orchestratorPayload = messageJson?.orchestrator_payload || null const result = orchestratorPayload?.result || {} return createMessage(item.role, item.content, attachmentNames, { id: `restored-${item.id || ++messageSeed}`, time: formatMessageTime(item.created_at || item.createdAt), meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [], citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [], suggestedActions: item.role === 'assistant' && Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null, draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null, reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null, riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [] }) }) } function cloneReviewEditFields(fields) { const items = Array.isArray(fields) ? fields : [] return items.map((item) => ({ key: String(item?.key || '').trim(), label: String(item?.label || '').trim(), value: String(item?.value || ''), placeholder: String(item?.placeholder || ''), required: Boolean(item?.required), field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text', group: String(item?.group || 'basic').trim() || 'basic' })) } function buildReviewFormValues(fields) { return cloneReviewEditFields(fields).reduce((result, item) => { if (!item.key) { return result } result[item.key] = String(item.value || '').trim() return result }, {}) } 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 buildReviewEditFieldMap(fields) { return cloneReviewEditFields(fields).reduce((result, item) => { if (!item.key) return result result[item.key] = item return result }, {}) } function createEmptyInlineReviewState() { return { occurred_date: '', amount: '', transport_type: '', scene_label: '', reason_value: '', customer_name: '', location: '', merchant_name: '', participants: '', attachment_names: '', attachment_count: 0, pending_attachment_count: 0, expense_type: '' } } function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) { const expenseType = resolveExpenseTypeCode( inlineState?.expense_type || buildReviewSlotMap(reviewPayload).expense_type?.normalized_value || buildReviewSlotMap(reviewPayload).expense_type?.value || '' ) if (['travel', 'hotel', 'transport'].includes(expenseType)) { return true } return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => { const documentType = String(item?.document_type || '').trim().toLowerCase() const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '') return ( ['flight_itinerary', 'train_ticket', 'hotel_invoice', 'taxi_receipt', 'transport_receipt'].includes(documentType) || ['travel', 'hotel', 'transport'].includes(suggestedType) ) }) } function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') { const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] const labels = [] const appendLabel = (label) => { if (label && !labels.includes(label)) { labels.push(label) } } for (const item of documents) { const documentType = String(item?.document_type || '').trim().toLowerCase() const text = [ item?.filename, item?.summary, item?.scene_label, item?.suggested_expense_type, ...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : []) ].join(' ') const compact = text.replace(/\s+/g, '') if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) { appendLabel('飞机') } else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) { appendLabel('火车/高铁') } else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) { appendLabel('打车/网约车') } } const fallback = String(fallbackText || '').replace(/\s+/g, '') if (!labels.length) { if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机') if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁') if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车') } return labels.join('、') } function buildClientTimeContext() { const now = new Date() const locale = typeof navigator !== 'undefined' && typeof navigator.language === 'string' ? navigator.language : 'zh-CN' return { client_now_iso: now.toISOString(), client_timezone_offset_minutes: now.getTimezoneOffset(), client_locale: locale } } function formatDraftApplyTime(date = new Date()) { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') const hours = String(date.getHours()).padStart(2, '0') const minutes = String(date.getMinutes()).padStart(2, '0') return `${year}-${month}-${day} ${hours}:${minutes}` } function formatDateInputValue(date = new Date()) { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } function buildDraftSavedPayload({ draftPayload, reviewPayload, inlineState, linkedRequest, currentUser }) { const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] const riskItems = buildReviewRiskItems(reviewPayload) const missingItems = resolveReviewMissingSlotCards(reviewPayload) const typeCode = resolveExpenseTypeCode(inlineState?.expense_type) const amountNumber = parseAmountNumber(inlineState?.amount) const location = String(inlineState?.location || linkedRequest?.city || '').trim() const customerName = String(inlineState?.customer_name || '').trim() const typeLabel = String(inlineState?.expense_type || linkedRequest?.typeLabel || resolveExpenseTypeLabel(typeCode)).trim() const title = String(inlineState?.reason_value || '').trim() || String(inlineState?.scene_label || '').trim() || String(draftPayload?.title || '').trim() || `${typeLabel}报销草稿` const sceneLabel = String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel, reviewPayload)).trim() || typeLabel const attachmentSummary = documents.length ? `${documents.length} 条识别票据 / ${documents.length} 份材料` : String(inlineState?.attachment_names || '').trim() ? '1 条识别票据 / 1 份材料' : '待上传票据' return { claimId: String(draftPayload?.claim_id || '').trim(), claimNo: String(draftPayload?.claim_no || '').trim(), status: String(draftPayload?.status || '').trim(), approvalStage: String(draftPayload?.approval_stage || '').trim(), person: String(currentUser?.name || '').trim() || '当前用户', dept: String(currentUser?.department || currentUser?.departmentName || '').trim() || '待补充部门', entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.', typeCode, typeLabel, detailVariant: typeCode === 'travel' ? 'travel' : 'general', title, sceneLabel, sceneTarget: location || customerName || '待补充', location, relatedCustomer: customerName, occurredDisplay: String(inlineState?.occurred_date || '').trim() || '待补充', applyTime: formatDraftApplyTime(), amount: amountNumber === null ? 0 : amountNumber, secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态', secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据', secondaryStatusTone: documents.length ? 'warning' : 'neutral', riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'), attachmentSummary, expenseTableSummary: documents.length ? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认` : '当前尚未上传票据,请在报销页继续补充附件', note: String(draftPayload?.status || '').trim() === 'submitted' ? '该报销单已由 AI 工作台提交审批,可在个人报销页面持续跟踪进度。' : '该草稿由 AI 工作台根据当前识别结果生成,可在个人报销页面继续补充明细、票据与说明。' } } function resolveReviewRecognizedSlotCards(reviewPayload) { return Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards.filter((item) => item.status !== 'missing') : [] } function resolveReviewMissingSlotCards(reviewPayload) { return Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards.filter((item) => item.status === 'missing') : [] } 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 formatConfidenceLabel(value) { const score = Number(value || 0) if (!score) return '待补充' return `${Math.round(score * 100)}%` } function resolveDocumentTypeLabel(type) { return DOCUMENT_TYPE_LABELS[String(type || '').trim()] || DOCUMENT_TYPE_LABELS.other } function resolveExpenseTypeLabel(type, fallbackLabel = '') { const normalized = String(type || '').trim() return EXPENSE_TYPE_LABELS[normalized] || String(fallbackLabel || '').trim() || EXPENSE_TYPE_LABELS.other } function buildReviewRecognizedLines(reviewPayload) { return resolveReviewRecognizedSlotCards(reviewPayload) .filter((item) => String(item?.value || '').trim()) .map((item) => `${item.label}:${item.value}`) } function buildReviewSlotMap(reviewPayload) { return Object.fromEntries( (Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).map((item) => [item.key, item]) ) } function resolveExpenseTypeCode(value) { const normalized = String(value || '').trim() if (!normalized) return 'other' if (EXPENSE_TYPE_LABELS[normalized]) return normalized const matched = Object.entries(EXPENSE_TYPE_LABELS).find(([, label]) => label === normalized) return matched?.[0] || 'other' } function isValidIsoDateString(value) { const normalized = String(value || '').trim() if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { return false } const [yearText, monthText, dayText] = normalized.split('-') const year = Number(yearText) const month = Number(monthText) const day = Number(dayText) if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { return false } const candidate = new Date(Date.UTC(year, month - 1, day)) return ( candidate.getUTCFullYear() === year && candidate.getUTCMonth() === month - 1 && candidate.getUTCDate() === day ) } function parseAmountNumber(value) { const normalized = String(value || '') .replace(/[,,\s]/g, '') .replace(/[¥¥]/g, '') .replace(/元/g, '') .trim() if (!normalized || !/^\d+(?:\.\d+)?$/.test(normalized)) { return null } const amount = Number(normalized) return Number.isFinite(amount) ? amount : null } function normalizeAmountValue(value) { const amount = parseAmountNumber(value) if (amount === null) { return '' } return Number.isInteger(amount) ? `${amount}元` : `${amount.toFixed(2).replace(/\.?0+$/, '')}元` } function extractAmountInputValue(value) { const amount = parseAmountNumber(value) if (amount === null) { return String(value || '').trim() } return Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, '') } function formatAmountDisplay(value) { const amount = parseAmountNumber(value) if (amount === null) { return String(value || '').trim() } return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', minimumFractionDigits: Number.isInteger(amount) ? 0 : 2, maximumFractionDigits: Number.isInteger(amount) ? 0 : 2 }).format(amount) } function normalizeExpenseQueryStatusGroup(item) { if (!item || typeof item !== 'object') { return null } const rawCount = Number(item.count || 0) return { key: String(item.key || 'other').trim() || 'other', label: String(item.label || '其他状态').trim() || '其他状态', count: Number.isFinite(rawCount) ? Math.max(0, rawCount) : 0 } } function normalizeExpenseQueryRecord(item) { if (!item || typeof item !== 'object') { return null } const amount = Number(item.amount || 0) const amountValue = Number.isFinite(amount) ? amount : 0 const expenseTypeLabel = String(item.expense_type_label || item.expense_type || '报销').trim() || '报销' const reason = String(item.reason || '').trim() const documentDate = String(item.document_date || '').trim() const occurredAt = String(item.occurred_at || '').trim() return { claimId: String(item.claim_id || '').trim(), claimNo: String(item.claim_no || '').trim() || '未编号', employeeName: String(item.employee_name || '').trim(), expenseType: String(item.expense_type || '').trim(), expenseTypeLabel, amount: amountValue, amountDisplay: formatAmountDisplay(amountValue), status: String(item.status || '').trim(), statusLabel: String(item.status_label || '处理中').trim() || '处理中', statusGroup: String(item.status_group || 'other').trim() || 'other', statusGroupLabel: String(item.status_group_label || '其他状态').trim() || '其他状态', approvalStage: String(item.approval_stage || '').trim(), documentDate, occurredAt, reason, location: String(item.location || '').trim(), summary: reason || `${expenseTypeLabel}报销`, dateDisplay: documentDate || occurredAt || '待补充日期' } } function normalizeExpenseQueryPayload(payload) { if (!payload || typeof payload !== 'object') { return null } const resultType = String(payload.result_type || '').trim() if (resultType && resultType !== 'expense_claim_list') { return null } const records = (Array.isArray(payload.records) ? payload.records : []) .map(normalizeExpenseQueryRecord) .filter(Boolean) const statusGroups = (Array.isArray(payload.status_groups) ? payload.status_groups : []) .map(normalizeExpenseQueryStatusGroup) .filter(Boolean) const rawRecordCount = Number(payload.record_count || 0) const rawPreviewCount = Number(payload.preview_count || records.length) const rawOlderRecordCount = Number(payload.older_record_count || 0) const totalAmount = Number(payload.total_amount || 0) const rawWindowDays = Number(payload.window_days || 0) const windowStartDate = String(payload.window_start_date || '').trim() const windowEndDate = String(payload.window_end_date || '').trim() return { resultType: 'expense_claim_list', scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单', recentWindowApplied: Boolean(payload.recent_window_applied), windowDays: payload.window_days === null || payload.window_days === undefined || payload.window_days === '' ? null : (Number.isFinite(rawWindowDays) ? Math.max(1, rawWindowDays) : null), windowStartDate: windowStartDate || '', windowEndDate: windowEndDate || '', recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0, previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length, olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0, hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more), totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0, statusGroups, records, currentPage: 1 } } function buildExpenseQueryWindowLabel(queryPayload) { if (!queryPayload) { return '' } if (queryPayload.windowStartDate && queryPayload.windowEndDate) { return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}` } if (queryPayload.recentWindowApplied && queryPayload.windowDays) { return `近 ${queryPayload.windowDays} 日内` } return '当前条件下' } function getExpenseQueryTotalPages(queryPayload) { const recordCount = Array.isArray(queryPayload?.records) ? queryPayload.records.length : 0 return Math.max(1, Math.ceil(recordCount / EXPENSE_QUERY_PAGE_SIZE)) } function getExpenseQueryActivePage(queryPayload) { const totalPages = getExpenseQueryTotalPages(queryPayload) const rawPage = Number(queryPayload?.currentPage || 1) if (!Number.isFinite(rawPage)) { return 1 } return Math.min(Math.max(1, Math.round(rawPage)), totalPages) } function getExpenseQueryVisibleRecords(queryPayload) { const records = Array.isArray(queryPayload?.records) ? queryPayload.records : [] const activePage = getExpenseQueryActivePage(queryPayload) const start = (activePage - 1) * EXPENSE_QUERY_PAGE_SIZE return records.slice(start, start + EXPENSE_QUERY_PAGE_SIZE) } function buildExpenseQueryHint(queryPayload) { if (!queryPayload) { return '' } const parts = [] const windowText = buildExpenseQueryWindowLabel(queryPayload) if (Array.isArray(queryPayload.records) && queryPayload.records.length > EXPENSE_QUERY_PAGE_SIZE) { parts.push(`当前共整理 ${queryPayload.records.length} 笔单据,可左右切换查看`) } if (queryPayload.hasMoreInWindow && queryPayload.previewCount < queryPayload.recordCount) { parts.push(`${windowText}共 ${queryPayload.recordCount} 笔,当前先整理最近 ${queryPayload.previewCount} 笔`) } if (queryPayload.olderRecordCount > 0 && queryPayload.windowDays) { parts.push(`另有 ${queryPayload.olderRecordCount} 笔超过 ${queryPayload.windowDays} 日的单据,请前往个人报销中心查看`) } return parts.join('。') } function countReviewPendingItems(reviewPayload) { return resolveReviewMissingSlotCards(reviewPayload).length } function countReviewRiskItems(reviewPayload) { return resolveReviewRiskBriefs(reviewPayload).length } function buildReviewHeadline(reviewPayload) { if (countReviewPendingItems(reviewPayload)) { return '待补充信息' } if (reviewPayload?.can_proceed) { return '识别结果已整理完成' } return '识别结果摘要' } function buildReviewSubline(reviewPayload) { const pendingCount = countReviewPendingItems(reviewPayload) if (pendingCount) { return `请先展开查看 ${pendingCount} 项待补充内容,再决定继续处理、修改信息或保存草稿。` } if (reviewPayload?.can_proceed) { return '当前关键信息已基本齐全,展开确认无误后可以继续下一步。' } return '已为您整理本轮识别结果,展开后可查看当前识别摘要。' } function buildReviewStateLabel(reviewPayload) { const pendingCount = countReviewPendingItems(reviewPayload) if (pendingCount) return `待补充 ${pendingCount} 项` if (reviewPayload?.can_proceed) return '可继续处理' return '已识别' } function buildReviewStateTone(reviewPayload) { return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload) ? 'ready' : 'pending' } function buildReviewDisclosureTitle(reviewPayload) { const pendingCount = countReviewPendingItems(reviewPayload) if (pendingCount) { return `当前有 ${pendingCount} 项待补充,点击展开查看` } return '当前信息已齐全,可展开查看识别摘要' } function buildReviewDisclosureHint(reviewPayload) { const pendingCount = countReviewPendingItems(reviewPayload) if (pendingCount) { return '展开后可查看待补充字段和处理建议' } return '展开后可查看本轮已识别的关键信息' } function shouldOpenReviewDisclosure(reviewPayload) { return !countReviewPendingItems(reviewPayload) } function buildReviewTodoSectionTitle(reviewPayload) { return resolveReviewMissingSlotCards(reviewPayload).length ? '待补充内容' : '已识别信息' } function buildReviewTodoSectionMeta(reviewPayload) { const count = buildReviewTodoItems(reviewPayload).length if (resolveReviewMissingSlotCards(reviewPayload).length) { return count ? `${count} 项` : '待确认' } return count ? `${count} 项` : '已齐全' } function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') { if (slotKey === 'customer_name') { return expenseTypeLabel === '业务招待费' ? '业务招待费需补充关联客户' : '缺少关联客户' } if (slotKey === 'participants') return '缺少同行人员' if (slotKey === 'attachments') return '票据状态待补充' if (slotKey === 'amount') return '金额待确认' if (slotKey === 'time_range') return '发生时间待确认' if (slotKey === 'reason') return '场景 / 事由待补充' if (slotKey === 'expense_type') return '报销类型待确认' if (slotKey === 'location') return '业务地点待补充' if (slotKey === 'merchant_name') return '酒店/商户待补充' return '仍有信息待补充' } function buildReviewAlertChips(reviewPayload) { const slotMap = buildReviewSlotMap(reviewPayload) const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim() const chips = [] for (const item of resolveReviewMissingSlotCards(reviewPayload).slice(0, 3)) { chips.push({ key: item.key, label: buildReviewAlertLabel(item.key, expenseTypeLabel), tone: 'warning' }) } if (chips.length < 3) { for (const risk of resolveReviewRiskBriefs(reviewPayload)) { if (chips.some((item) => item.label === risk.title)) continue chips.push({ key: risk.title, label: risk.title, tone: risk.level === 'high' ? 'danger' : 'warning' }) if (chips.length >= 3) break } } if (!chips.length && reviewPayload?.can_proceed) { chips.push({ key: 'ready', label: '当前识别信息已可继续处理', tone: 'success' }) } return chips } function buildReviewTodoItems(reviewPayload) { const missingItems = resolveReviewMissingSlotCards(reviewPayload) if (missingItems.length) { return missingItems.map((item) => { const config = REVIEW_SLOT_CONFIG[item.key] || {} return { key: item.key, icon: config.icon || 'mdi mdi-form-select', title: config.title || item.label, hint: item.hint || config.hint || `请补充${item.label}`, status: config.status || '待补充', tone: 'warning' } }) } return resolveReviewRecognizedSlotCards(reviewPayload) .filter((item) => String(item?.value || '').trim()) .slice(0, 3) .map((item) => { const config = REVIEW_SLOT_CONFIG[item.key] || {} return { key: item.key, icon: config.icon || 'mdi mdi-check-circle-outline', title: config.title || item.label, hint: `已识别:${item.value}`, status: '已识别', tone: 'ready' } }) } function resolveReviewPrimaryAction(reviewPayload) { return ( (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( (item) => item.emphasis === 'primary' || ['save_draft', 'next_step'].includes(String(item?.action_type || '')) ) || null ) } function resolveReviewSubmitActions(reviewPayload) { return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => { const actionType = String(item?.action_type || '').trim() return actionType && !['cancel_review', 'edit_review'].includes(actionType) }) } function resolveReviewEditAction(reviewPayload) { return ( (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( (item) => String(item?.action_type || '') === 'edit_review' ) || null ) } function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) { const action = resolveReviewPrimaryAction(reviewPayload) if (!action) return '确认' if (action.action_type === 'save_draft') { return draftPayload?.claim_no ? '保存为草稿' : '保存为草稿' } if (action.action_type === 'next_step') { return '继续下一步' } if (action.action_type === 'link_to_existing_draft') { return action.label || '关联到现有草稿' } if (action.action_type === 'create_new_claim_from_documents') { return action.label || '单独建立报销单' } return action.label || '确认' } function buildReviewIntentText(reviewPayload) { const slotMap = buildReviewSlotMap(reviewPayload) const expenseType = String(slotMap.expense_type?.value || '').trim() if (expenseType) { return `报销一笔${expenseType}` } return '发起一笔报销' } function buildReviewSceneValue(reviewPayload) { const slotMap = buildReviewSlotMap(reviewPayload) const reason = String(slotMap.reason?.raw_value || slotMap.reason?.value || '').trim() const expenseType = String(slotMap.expense_type?.value || slotMap.expense_type?.normalized_value || '').trim() return inferPresetSceneFromReview(reviewPayload, reason, expenseType) } function matchPresetSceneFromReason(reason) { const compactReason = String(reason || '').trim().replace(/\s+/g, '') if (!compactReason) { return '' } if (/请客户.*吃饭|客户.*吃饭|招待|宴请|接待|客户接待/.test(compactReason)) { return '请客户吃饭' } if (/出差行程|住宿报销|交通出行|会务活动/.test(compactReason)) { const matchedPreset = REVIEW_SCENE_OPTIONS.find((option) => compactReason.includes(option.replace(/\s+/g, ''))) if (matchedPreset && matchedPreset !== REVIEW_SCENE_OTHER_OPTION) { return matchedPreset } } if (/出差|差旅/.test(compactReason)) { return '出差行程' } if (/酒店|住宿/.test(compactReason)) { return '住宿报销' } if (TRANSPORT_KEYWORD_PATTERN.test(compactReason)) { return '交通出行' } if (/会务|会议|参会|论坛|展会/.test(compactReason)) { return '会务活动' } return '' } function mapOcrSceneLabelToPresetScene(sceneLabel, suggestedExpenseType = '') { const fromCode = EXPENSE_CODE_TO_PRESET_SCENE[resolveExpenseTypeCode(suggestedExpenseType)] if (fromCode) { return fromCode } const compactLabel = String(sceneLabel || '').trim().replace(/\s+/g, '') if (!compactLabel) { return '' } if (/差旅|出差/.test(compactLabel)) { return '出差行程' } if (/住宿|酒店/.test(compactLabel)) { return '住宿报销' } if (/交通/.test(compactLabel)) { return '交通出行' } if (/招待|餐饮|餐费|伙食/.test(compactLabel)) { return '请客户吃饭' } if (/会务|会议/.test(compactLabel)) { return '会务活动' } return '' } function mapExpenseTypeLabelToPresetScene(expenseType) { const code = resolveExpenseTypeCode(expenseType) if (EXPENSE_CODE_TO_PRESET_SCENE[code]) { return EXPENSE_CODE_TO_PRESET_SCENE[code] } const compactLabel = String(expenseType || '').trim().replace(/\s+/g, '') if (!compactLabel) { return '' } if (compactLabel.includes('差旅') || compactLabel.includes('出差')) { return '出差行程' } if (compactLabel.includes('住宿') || compactLabel.includes('酒店')) { return '住宿报销' } if (compactLabel.includes('交通')) { return '交通出行' } if (compactLabel.includes('招待') || compactLabel.includes('餐饮') || compactLabel.includes('伙食')) { return '请客户吃饭' } if (compactLabel.includes('会务') || compactLabel.includes('会议')) { return '会务活动' } return matchPresetSceneFromReason(expenseType) } function inferPresetSceneFromReview(reviewPayload, reasonValue = '', expenseType = '') { const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] if (documents.length) { const votes = new Map() for (const document of documents) { const preset = mapOcrSceneLabelToPresetScene(document.scene_label, document.suggested_expense_type) || mapExpenseTypeLabelToPresetScene(document.suggested_expense_type) if (!preset) { continue } votes.set(preset, (votes.get(preset) || 0) + 1) } if (votes.size) { return [...votes.entries()].sort((left, right) => right[1] - left[1])[0][0] } } const claimGroups = Array.isArray(reviewPayload?.claim_groups) ? reviewPayload.claim_groups : [] if (claimGroups.length === 1) { const group = claimGroups[0] const preset = mapExpenseTypeLabelToPresetScene(group.expense_type) || mapOcrSceneLabelToPresetScene(group.scene_label, group.expense_type) if (preset) { return preset } } const fromReason = matchPresetSceneFromReason(reasonValue) if (fromReason) { return fromReason } const fromExpenseType = mapExpenseTypeLabelToPresetScene(expenseType) if (fromExpenseType) { return fromExpenseType } if (String(reasonValue || '').trim()) { return REVIEW_SCENE_OTHER_OPTION } return '待补充' } function formatReviewSceneDisplayValue(inlineState) { const scene = String(inlineState?.scene_label || '').trim() if (!scene || scene === '待补充') { return '待补充' } if (scene === REVIEW_SCENE_OTHER_OPTION) { const detail = String(inlineState?.reason_value || '').trim() if (!detail) { return REVIEW_SCENE_OTHER_OPTION } return detail.length > 18 ? `${REVIEW_SCENE_OTHER_OPTION}:${detail.slice(0, 18)}...` : `${REVIEW_SCENE_OTHER_OPTION}:${detail}` } return scene } function summarizeReviewScene(reason, expenseType = '', reviewPayload = null) { return inferPresetSceneFromReview(reviewPayload, reason, expenseType) } function buildInlineReviewState(reviewPayload) { const slotMap = buildReviewSlotMap(reviewPayload) const editFieldMap = buildReviewEditFieldMap(reviewPayload?.edit_fields) const attachmentNames = String( editFieldMap.attachment_names?.value || slotMap.attachments?.value || (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => item.filename).join('、') : '') ).trim() const attachmentCount = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.length : attachmentNames ? attachmentNames.split('、').filter(Boolean).length : 0 const expenseType = String(editFieldMap.expense_type?.value || slotMap.expense_type?.value || '').trim() const reasonValue = String( editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || '' ).trim() const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType) const transportType = String( editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue) ).trim() return { occurred_date: String( editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || '' ).trim(), amount: normalizeAmountValue( String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim() ), transport_type: transportType, scene_label: sceneLabel, reason_value: sceneLabel === REVIEW_SCENE_OTHER_OPTION ? reasonValue : String(slotMap.reason?.raw_value || '').trim() || reasonValue, customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(), location: String( editFieldMap.business_location?.value || editFieldMap.location?.value || slotMap.location?.normalized_value || slotMap.location?.value || '' ).trim(), merchant_name: String(editFieldMap.merchant_name?.value || slotMap.merchant_name?.value || '').trim(), participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(), attachment_names: attachmentNames, attachment_count: attachmentCount, pending_attachment_count: 0, expense_type: expenseType } } function buildReviewAttachmentStatus(reviewPayload) { const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] if (!documents.length) return '未上传' return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length} 份` } function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') { const slotMap = buildReviewSlotMap(reviewPayload) const slot = slotMap[slotKey] return Boolean(String(value || slot?.normalized_value || slot?.value || '').trim()) || slot?.status === 'missing' } 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: 'customer_name', label: '关联客户', value: String(inlineState.customer_name || '').trim() || '待补充', icon: 'mdi mdi-domain', editor: 'text', modelKey: 'customer_name', placeholder: '请输入客户名称' }, { key: 'attachments', label: '票据状态', value: attachmentStatus, icon: 'mdi mdi-file-document-outline', editor: 'upload', modelKey: 'attachment_names', placeholder: '' } ] if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) { cards.splice(4, 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 buildReviewEvidenceText(reviewPayload, inlineState = createEmptyInlineReviewState()) { const slotMap = buildReviewSlotMap(reviewPayload) const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] return [ String(inlineState.reason_value || '').trim(), String(inlineState.scene_label || '').trim(), String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim(), ...documents.map((item) => [item.scene_label, item.summary, item.filename, ...(Array.isArray(item.warnings) ? item.warnings : [])] .filter(Boolean) .join(' ') ) ] .filter(Boolean) .join(' ') .toLowerCase() } function resolveReviewCategoryTextScore(text, categoryCode) { const patterns = CATEGORY_CONFIDENCE_KEYWORDS[categoryCode] if (!patterns?.length || !text) { return 0 } return patterns.some((pattern) => pattern.test(text)) ? { travel: 0.84, hotel: 0.82, transport: 0.8, meal: 0.76, meeting: 0.78, entertainment: 0.88, office: 0.74, training: 0.77, communication: 0.7, welfare: 0.72 }[categoryCode] || 0 : 0 } function resolveReviewCategoryDocumentScore(reviewPayload, categoryCode) { const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] const matchedScores = documents .filter((item) => resolveExpenseTypeCode(item?.suggested_expense_type) === categoryCode) .map((item) => Number(item?.avg_score || 0)) .filter((score) => Number.isFinite(score) && score > 0) if (!matchedScores.length) { return 0 } return matchedScores.reduce((sum, score) => sum + score, 0) / matchedScores.length } function resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { const normalizedLabel = String(selectedLabel || '').trim() if (!normalizedLabel) { return 0 } const selectedCode = resolveExpenseTypeCode(normalizedLabel) const slotMap = buildReviewSlotMap(reviewPayload) const expenseSlot = slotMap.expense_type const recognizedCode = resolveExpenseTypeCode(expenseSlot?.normalized_value || expenseSlot?.value || '') let score = 0 if (recognizedCode === selectedCode) { score = Math.max(score, Number(expenseSlot?.confidence || 0)) } score = Math.max(score, resolveReviewCategoryDocumentScore(reviewPayload, selectedCode)) score = Math.max(score, resolveReviewCategoryTextScore(buildReviewEvidenceText(reviewPayload, inlineState), selectedCode)) if (!score && normalizedLabel) { score = selectedCode === 'other' ? 0.52 : 0.58 } return Math.max(0, Math.min(0.98, Number(score.toFixed(2)))) } function buildReviewCategoryOptions(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) { const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({ ...item, active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel, confidenceLabel: item.is_other ? formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState)) : formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState)), caption: item.is_other ? selectedLabel && !presetLabels.includes(selectedLabel) ? `${selectedLabel} · ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))}` : '点击选择更多类型' : `置信度 ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState))}`, groupLabel: index === 0 ? '常用' : index < 5 ? '常用' : '更多' })) } function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInlineReviewState()) { return formatConfidenceLabel( resolveReviewCategoryConfidenceScore(reviewPayload, inlineState.expense_type, inlineState) ) } function buildMissingRiskLine(slotKey, expenseTypeLabel = '') { if (slotKey === 'customer_name') { return expenseTypeLabel === '业务招待费' ? '业务招待费需补充客户单位名称,以便进行合规校验。' : '当前仍缺少客户单位名称,建议补充后再提交。' } if (slotKey === 'participants') { return '缺少同行人员信息,建议补充至少 1 名。' } if (slotKey === 'attachments') { return '尚未上传票据附件,当前无法完成票据核对。' } if (slotKey === 'amount') { return '报销金额仍待确认,提交前需补齐金额信息。' } if (slotKey === 'time_range') { return '业务发生时间仍待确认,建议补充准确日期。' } if (slotKey === 'reason') { return '报销事由说明仍不完整,建议补充业务背景。' } return '当前仍有识别信息待补充,建议先核对后再处理。' } function buildReviewRiskSummary(reviewPayload) { if (resolveReviewRiskBriefs(reviewPayload).length) { return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。' } return '当前没有需要额外处理的结构化风险点。' } function normalizeReviewRiskLevel(level) { const normalized = String(level || '').trim().toLowerCase() if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high' if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium' if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low' if (normalized === 'high') return normalized return 'low' } 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) { 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 lines = [`${title}`] if (summary) { lines.push('', `风险点:${summary}`) } if (detail && detail !== summary) { lines.push('', `规则依据:${detail}`) } if (suggestion) { lines.push('', `修改建议:${suggestion}`) } return lines.join('\n') } function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) { const state = inlineState || createEmptyInlineReviewState() if (slotKey === 'expense_type') return String(state.expense_type || '').trim() if (slotKey === 'customer_name') return String(state.customer_name || '').trim() if (slotKey === 'time_range') return String(state.occurred_date || '').trim() if (slotKey === 'location') return String(state.location || '').trim() if (slotKey === 'merchant_name') return String(state.merchant_name || '').trim() if (slotKey === 'amount') return String(state.amount || '').trim() if (slotKey === 'reason') return String(state.reason_value || state.scene_label || '').trim() if (slotKey === 'participants') return String(state.participants || '').trim() if (slotKey === 'attachments') { return String(state.attachment_names || '').trim() || (Number(state.attachment_count || 0) > 0 ? `${Number(state.attachment_count)} 份附件` : '') || (Number(state.pending_attachment_count || 0) > 0 ? `${Number(state.pending_attachment_count)} 份待上传附件` : '') } return '' } function buildLocallySyncedReviewActions(reviewPayload, canProceed) { const actions = Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions.map((item) => ({ ...item })) : [] const actionTypes = new Set(actions.map((item) => String(item?.action_type || '').trim())) const associationPending = actionTypes.has('link_to_existing_draft') || actionTypes.has('create_new_claim_from_documents') if (!canProceed || associationPending) { return actions } return [ ...actions.filter((item) => !['save_draft', 'next_step'].includes(String(item?.action_type || '').trim())), { label: '继续下一步', action_type: 'next_step', description: '当前信息已齐全,进入 AI 预审、风险校验和审批路径确认。', emphasis: 'primary' } ] } function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) { if (!reviewPayload || typeof reviewPayload !== 'object') { return reviewPayload } const nextSlotCards = (Array.isArray(reviewPayload.slot_cards) ? reviewPayload.slot_cards : []).map((slot) => { const value = resolveInlineReviewSlotValue(slot.key, inlineState) const required = Boolean(slot.required) const filled = Boolean(value) return { ...slot, value: value || slot.value || '', normalized_value: value || slot.normalized_value || '', raw_value: value || slot.raw_value || '', source: filled ? 'user_form' : slot.source, source_label: filled ? '用户修改' : slot.source_label, confidence: filled ? Math.max(Number(slot.confidence || 0), 0.98) : Number(slot.confidence || 0), confirmed: filled || Boolean(slot.confirmed), status: required && !filled ? 'missing' : filled ? 'identified' : slot.status, hint: required && !filled ? slot.hint : '' } }) const missingSlots = nextSlotCards .filter((slot) => slot.required && slot.status === 'missing') .map((slot) => slot.label || slot.key) const canProceed = missingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true) return { ...reviewPayload, can_proceed: canProceed, missing_slots: missingSlots, slot_cards: nextSlotCards, confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed) } } function buildLocalReviewCompletionMessage(reviewPayload) { const missingSlots = Array.isArray(reviewPayload?.missing_slots) ? reviewPayload.missing_slots : [] if (reviewPayload?.can_proceed && !missingSlots.length) { return '当前所有必填信息已处理完成,可以点击“继续下一步”进入 AI 预审。' } if (missingSlots.length) { return `当前还剩 ${missingSlots.length} 项待补充:${missingSlots.join('、')}。` } return '当前信息已保存,可以继续核对右侧状态。' } function normalizeInlineReviewComparableState(state) { const source = state && typeof state === 'object' ? state : {} return { occurred_date: String(source.occurred_date || '').trim(), amount: String(source.amount || '').trim(), transport_type: String(source.transport_type || '').trim(), scene_label: String(source.scene_label || '').trim(), reason_value: String(source.reason_value || '').trim(), customer_name: String(source.customer_name || '').trim(), location: String(source.location || '').trim(), merchant_name: String(source.merchant_name || '').trim(), participants: String(source.participants || '').trim(), attachment_names: String(source.attachment_names || '').trim(), pending_attachment_count: Math.max(0, Number(source.pending_attachment_count || 0)), expense_type: String(source.expense_type || '').trim() } } function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) { const base = normalizeInlineReviewComparableState(baseState) const next = normalizeInlineReviewComparableState(nextState) const lines = [] if (base.occurred_date !== next.occurred_date) { lines.push(`发生时间 ${next.occurred_date || '待补充'}`) } if (base.amount !== next.amount) { lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`) } if (base.transport_type !== next.transport_type) { lines.push(`交通类型 ${next.transport_type || '待确认'}`) } if (base.scene_label !== next.scene_label) { lines.push(`场景 ${next.scene_label || '待补充'}`) } if (base.customer_name !== next.customer_name) { lines.push(`关联客户 ${next.customer_name || '待补充'}`) } if (base.location !== next.location) { lines.push(`业务地点 ${next.location || '待补充'}`) } if (base.merchant_name !== next.merchant_name) { lines.push(`酒店/商户 ${next.merchant_name || '待补充'}`) } if (base.participants !== next.participants) { lines.push(`同行人员 ${next.participants || '待补充'}`) } if (base.expense_type !== next.expense_type) { lines.push(`报销分类 ${next.expense_type || '待补充'}`) } if (base.attachment_names !== next.attachment_names || pendingFiles.length) { lines.push(`票据 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`) } return lines } function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = []) { const base = normalizeInlineReviewComparableState(baseState) const next = normalizeInlineReviewComparableState(nextState) const fieldConfigs = [ { key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' }, { key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' }, { key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' }, { key: 'scene_label', label: '场景', format: (value) => value || '待补充' }, { key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' }, { key: 'location', label: '业务地点', format: (value) => value || '待补充' }, { key: 'merchant_name', label: '酒店/商户', format: (value) => value || '待补充' }, { key: 'participants', label: '同行人员', format: (value) => value || '待补充' }, { key: 'expense_type', label: '报销分类', format: (value) => value || '待补充' } ] const phrases = fieldConfigs.reduce((result, item) => { if (base[item.key] !== next[item.key]) { result.push(`${item.label}修改为 ${item.format(next[item.key])}`) } return result }, []) if (base.attachment_names !== next.attachment_names || pendingFiles.length) { phrases.push(`票据修改为 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`) } return phrases } function buildLocalReviewSavedMessage(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) { const phrases = buildInlineReviewChangePhrases(baseState, nextState, pendingFiles) const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) if (documentLines.length) { phrases.push(`${documentLines.length} 张票据识别信息更新为最新修改`) } if (!phrases.length) { return '右侧核对信息已保存。' } return `已将${phrases.join(',')}。` } function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) { const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) if (!lines.length) { return '我已修改识别信息,请按最新内容更新。' } return `我已修改识别信息:${lines.join(',')}。请按最新内容更新。` } function buildReviewSubmitUserText(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) { const inlineLines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) if (!inlineLines.length && !documentLines.length) { return '我已修改识别信息,请按最新内容更新。' } const parts = [] if (inlineLines.length) { parts.push(inlineLines.join(',')) } if (documentLines.length) { parts.push(`修正了 ${documentLines.length} 张票据识别信息`) } return `我已修改识别信息:${parts.join(';')}。请按最新内容更新。` } function mergeInlineReviewFields(baseFields, inlineState) { const merged = cloneReviewEditFields(baseFields) const updateMap = { expense_type: inlineState.expense_type, transport_type: inlineState.transport_type, occurred_date: inlineState.occurred_date, amount: inlineState.amount, customer_name: inlineState.customer_name, business_location: inlineState.location, merchant_name: inlineState.merchant_name, participants: inlineState.participants, reason: inlineState.reason_value || inlineState.scene_label, attachment_names: inlineState.attachment_names } for (const item of merged) { if (!(item.key in updateMap)) continue item.value = String(updateMap[item.key] || '').trim() } return merged } function buildReviewRecognitionNotes(reviewPayload) { const recognized = resolveReviewRecognizedSlotCards(reviewPayload) const notes = [] const timeSlot = recognized.find((item) => item.key === 'time_range') const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))] if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) { notes.push(`时间已按你的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`) } if (sourceLabels.length) { notes.push(`本轮主要依据:${sourceLabels.join('、')}`) } const documentCards = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] if (documentCards.length) { notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`) } else { notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别') } return notes } function buildReviewDocumentSummaries(reviewPayload) { const docs = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] return docs.map((item) => { const fields = Array.isArray(item.fields) ? item.fields : [] return { ...item, documentTypeLabel: resolveDocumentTypeLabel(item.document_type), expenseTypeLabel: resolveExpenseTypeLabel(item.suggested_expense_type, item.scene_label), confidenceLabel: formatConfidenceLabel(item.avg_score), lines: fields .filter((field) => String(field?.value || '').trim()) .map((field) => `${field.label}:${field.value}`) } }) } function buildReviewDecisionHint(reviewPayload) { const missingSlots = resolveReviewMissingSlotCards(reviewPayload) const riskBriefs = resolveReviewRiskBriefs(reviewPayload) if (reviewPayload?.can_proceed) { return riskBriefs.length ? `我已经把信息整理好了。你可以直接进入下一步,提交前再看一下下方 ${riskBriefs.length} 条提醒。` : '我已经把信息整理好了。你确认无误后,可以直接进入下一步。' } if (missingSlots.length) { return `我先完成了当前这轮识别,还差 ${missingSlots.length} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。` } return '如果你觉得识别结果有偏差,点“修改识别信息”直接校正,我会按新内容重新识别。' } function buildReviewMissingHint(reviewPayload) { const missingSlots = resolveReviewMissingSlotCards(reviewPayload) if (!missingSlots.length) { return '' } if (reviewPayload?.can_proceed) { return '当前关键信息已经齐全,这里无需再补充。' } return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。' } function buildReviewRiskHint(reviewPayload) { const riskBriefs = resolveReviewRiskBriefs(reviewPayload) if (!riskBriefs.length) { return '' } return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。' } function buildReviewActionHint(reviewPayload) { if (reviewPayload?.can_proceed) { return '如果识别无误,直接点“下一步”;如果有偏差,先修改识别信息。' } return '如果现在信息还不完整,可以先保存草稿;识别错了就点“修改识别信息”。' } function buildReviewStatusTag(reviewPayload) { const missingCount = resolveReviewMissingSlotCards(reviewPayload).length if (reviewPayload?.can_proceed) { return '可继续处理' } if (missingCount > 0) { return `待补充 ${missingCount} 项` } return '待确认' } function buildErrorInsight(error, fileNames = []) { return { intent: 'agent', metricLabel: '运行状态', metricValue: '失败', title: '智能体调用失败', summary: error?.message || '无法连接后端 Orchestrator。', agent: { runId: '未生成', selectedAgent: 'orchestrator', scenario: '未知', intent: '未知', permissionLevel: 'unknown', routeReason: 'request_failed', requiresConfirmation: false, degraded: false, fileNames, citations: [], suggestedActions: [], queryPayload: null, draftPayload: null, reviewPayload: null, riskFlags: [], toolCount: 0, failedToolCount: 0, selectedCapabilityCodes: [], filePreviews: [], statusLabel: '失败', statusTone: 'note' } } } function buildAgentInsight(payload, fileNames = [], filePreviews = []) { const trace = payload?.trace_summary || {} const result = payload?.result || {} const statusLabel = resolveStatusLabel(payload?.status) return { intent: 'agent', metricLabel: '运行状态', metricValue: statusLabel, title: result?.draft_payload?.title || `${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`, summary: result?.answer || result?.message || '智能体已完成处理。', agent: { runId: payload?.run_id || '未生成', selectedAgent: payload?.selected_agent || 'orchestrator', scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知', intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知', permissionLevel: payload?.permission_level || 'unknown', routeReason: payload?.route_reason || 'unknown', requiresConfirmation: Boolean(payload?.requires_confirmation), degraded: Boolean(trace?.degraded), fileNames, citations: Array.isArray(result?.citations) ? result.citations : [], suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], queryPayload: normalizeExpenseQueryPayload(result?.query_payload), draftPayload: result?.draft_payload || null, reviewPayload: result?.review_payload || null, riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [], toolCount: Number(trace?.tool_count || 0), failedToolCount: Number(trace?.failed_tool_count || 0), selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes) ? trace.selected_capability_codes : [], filePreviews, statusLabel, statusTone: resolveStatusTone(payload?.status) } } } 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 } }, 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 fileInputMode = ref('composer') const messageListRef = ref(null) const composerDraft = ref('') const composerDatePickerOpen = ref(false) const composerDateMode = ref('single') const composerSingleDate = ref(formatDateInputValue()) const composerRangeStartDate = ref(formatDateInputValue()) const composerRangeEndDate = ref(formatDateInputValue()) const composerBusinessTimeTags = ref([]) const composerBusinessTimeDraftTouched = ref(false) const travelCalculatorOpen = ref(false) const travelCalculatorBusy = ref(false) const travelCalculatorError = ref('') const travelCalculatorResult = ref(null) const travelCalculatorForm = ref({ days: '1', location: '' }) const attachedFiles = ref([]) const composerFilesExpanded = ref(false) const submitting = ref(false) const workbenchVisible = ref(false) const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) const initialSessionType = resolveInitialSessionType(props.initialConversation) const initialSessionState = props.initialConversation ? buildConversationSessionState(props.initialConversation, initialSessionType) : buildEmptySessionState(initialSessionType) const activeSessionType = ref(initialSessionState.sessionType) const messages = ref(initialSessionState.messages) const conversationId = ref(initialSessionState.conversationId) const draftClaimId = ref(initialSessionState.draftClaimId) const previewRegistry = [] const restoredDraftPreviewClaims = new Set() const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews) const sessionSnapshots = ref({ [SESSION_TYPE_EXPENSE]: null, [SESSION_TYPE_KNOWLEDGE]: null }) const currentInsight = ref(initialSessionState.currentInsight) const reviewCancelDialogOpen = ref(false) const reviewEditDialogOpen = ref(false) const uploadDecisionDialogOpen = ref(false) const deleteSessionDialogOpen = ref(false) const reviewActionBusy = ref(false) const deleteSessionBusy = ref(false) const reviewEditFields = ref([]) const reviewActionMessageId = ref('') const reviewInlineForm = ref(createEmptyInlineReviewState()) const reviewInlineBaseForm = ref(createEmptyInlineReviewState()) const reviewInlineBaseFields = ref([]) const reviewInlinePendingFiles = ref([]) const reviewInlineEditorKey = ref('') const reviewInlineErrors = ref({}) const reviewOtherCategoryOpen = ref(false) const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim()) const reviewDocumentDrafts = ref([]) const reviewDocumentBaseDrafts = ref([]) const activeReviewDocumentIndex = ref(0) const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW) const insightPanelCollapsed = ref(false) const documentPreviewDialog = ref({ open: false, filename: '', kind: 'file', url: '' }) const sessionSwitchBusy = ref(false) const flowRunId = ref('') const flowStartedAt = ref(0) const flowFinishedAt = ref(0) const flowSteps = ref([]) const flowRefreshBusy = ref(false) const flowTick = ref(Date.now()) let flowTickTimer = 0 const flowSimulationTimers = [] const canSubmit = computed( () => !submitting.value && !sessionSwitchBusy.value && Boolean( composerDraft.value.trim() || attachedFiles.value.length || composerBusinessTimeTags.value.length ) ) const composerCanApplyDateSelection = computed(() => { if (composerDateMode.value === 'single') { return Boolean(composerSingleDate.value) } return Boolean( composerRangeStartDate.value && composerRangeEndDate.value && composerRangeStartDate.value <= composerRangeEndDate.value ) }) const travelCalculatorCanSubmit = computed(() => !travelCalculatorBusy.value && Number(travelCalculatorForm.value.days) >= 1 && Boolean(String(travelCalculatorForm.value.location || '').trim()) ) const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE) const completedFlowStepCount = computed( () => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length ) const runningFlowStep = computed( () => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null ) const flowOverallStatusTone = computed(() => { if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) { return 'failed' } if (runningFlowStep.value) { return 'running' } if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) { return 'completed' } return 'pending' }) const flowOverallStatusText = computed(() => { const total = flowSteps.value.length const completed = completedFlowStepCount.value if (flowOverallStatusTone.value === 'failed') { return `异常 ${completed}/${total}` } if (flowOverallStatusTone.value === 'completed') { return `已完成 ${total}/${total}` } if (flowOverallStatusTone.value === 'running') { return `执行中 ${completed}/${total}` } return total ? `待执行 0/${total}` : '暂无流程' }) const flowTotalDurationText = computed(() => { if (!flowStartedAt.value) { return '--' } const finishedAt = flowFinishedAt.value || (runningFlowStep.value ? flowTick.value : 0) if (finishedAt > flowStartedAt.value) { return formatFlowDuration(finishedAt - flowStartedAt.value) } const measuredDuration = flowSteps.value.reduce((total, step) => { const duration = Number(step.durationMs) return total + (Number.isFinite(duration) && duration > 0 ? duration : 0) }, 0) return measuredDuration ? formatFlowDuration(measuredDuration) : '--' }) const hasInsightPanelContent = computed( () => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' || 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} 的报销风险,或帮我生成处理意见草稿。` } return '例如:查一下近10日报销金额、解释酒店超标风险,或根据附件生成报销草稿。' }) const currentIntentLabel = computed(() => { if (isKnowledgeSession.value && currentInsight.value.intent === 'welcome') { return '热门问题' } const labels = isKnowledgeSession.value ? { welcome: '热门问题', agent: '知识回答' } : { welcome: '财务助手', agent: '处理中' } return labels[currentInsight.value.intent] ?? 'AI 处理中' }) let knowledgeSessionResetPromise = Promise.resolve() 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) ?? null ) const activeReviewPayload = computed( () => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null ) const activeReviewFilePreviews = computed(() => reviewFilePreviews.value) const visibleAttachedFiles = computed(() => attachedFiles.value.slice(0, VISIBLE_ATTACHMENT_CHIPS)) const hiddenAttachedFileCount = computed(() => Math.max(0, attachedFiles.value.length - VISIBLE_ATTACHMENT_CHIPS)) const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value)) const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value)) const reviewCategoryOptions = computed(() => buildReviewCategoryOptions(activeReviewPayload.value, reviewInlineForm.value.expense_type, reviewInlineForm.value) ) const reviewOtherCategoryOptions = computed(() => REVIEW_OTHER_CATEGORY_OPTIONS.map((item) => ({ ...item, confidenceLabel: formatConfidenceLabel( resolveReviewCategoryConfidenceScore(activeReviewPayload.value, item.label, reviewInlineForm.value) ) })) ) const reviewSelectedOtherCategory = computed(() => { const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) return presetLabels.includes(reviewInlineForm.value.expense_type) ? '' : reviewInlineForm.value.expense_type }) const reviewInlineDirty = computed( () => buildInlineReviewChangedLines( reviewInlineBaseForm.value, reviewInlineForm.value, reviewInlinePendingFiles.value ).length > 0 ) const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value)) const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value)) const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value)) const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length) const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0) const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value) const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0) const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value)) const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value)) const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value)) const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length) const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW) const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) 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 ? 'mdi mdi-file-document-multiple' : 'mdi mdi-file-document-multiple-outline' )) const reviewRiskDrawerLabel = computed(() => ( '显示风险' )) const reviewRiskDrawerIcon = computed(() => ( isReviewRiskDrawer.value ? 'mdi mdi-shield-alert' : 'mdi mdi-shield-alert-outline' )) const reviewFlowDrawerLabel = computed(() => ( '调用流程' )) const reviewFlowDrawerIcon = computed(() => ( isReviewFlowDrawer.value ? 'mdi mdi-timeline-clock' : 'mdi mdi-timeline-clock-outline' )) const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null) const activeReviewDocumentPreview = computed(() => activeReviewDocument.value ? ( resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename) || ( activeReviewDocument.value.preview_kind === 'image' && activeReviewDocument.value.preview_data_url ? { filename: activeReviewDocument.value.filename, kind: activeReviewDocument.value.preview_kind, url: activeReviewDocument.value.preview_data_url } : null ) ) : null ) const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url)) const reviewDocumentDirty = computed(() => { const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue)) const nextValue = JSON.stringify(reviewDocumentDrafts.value.map(normalizeReviewDocumentComparableValue)) return baseValue !== nextValue }) const reviewHasUnsavedChanges = computed(() => reviewInlineDirty.value || reviewDocumentDirty.value) const hotKnowledgeQuestions = computed(() => HOT_KNOWLEDGE_QUESTIONS) const shortcuts = computed(() => [ { label: isKnowledgeSession.value ? '切换为个人工作台' : '切换为财务知识问答', icon: isKnowledgeSession.value ? 'mdi mdi-briefcase-outline' : 'mdi mdi-book-open-page-variant-outline', action: 'switch_view', targetSessionType: isKnowledgeSession.value ? SESSION_TYPE_EXPENSE : SESSION_TYPE_KNOWLEDGE } ]) function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) { const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType const restoredMessages = normalizeInitialConversationMessages(conversation) const initialInsight = buildInitialInsightFromConversation(conversation) const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages) return { sessionType, messages: restoredMessages.length ? restoredMessages : [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)], conversationId: resolveInitialConversationId(conversation), draftClaimId: resolveInitialDraftClaimId(conversation), currentInsight: initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value), reviewFilePreviews: restoredReviewFilePreviews, composerDraft: '', attachedFiles: [], composerFilesExpanded: false, composerUploadIntent: '', insightPanelCollapsed: false } } function buildEmptySessionState(sessionType) { return { sessionType, messages: [ createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value) ], conversationId: '', draftClaimId: '', currentInsight: buildWelcomeInsight( props.entrySource, linkedRequest.value, sessionType, currentUser.value ), reviewFilePreviews: [], composerDraft: '', attachedFiles: [], composerFilesExpanded: false, composerUploadIntent: '', insightPanelCollapsed: false } } function resolveCurrentUserId() { const user = currentUser.value || {} return String(user.username || user.name || 'anonymous').trim() || 'anonymous' } function captureCurrentSessionState() { return { sessionType: activeSessionType.value, messages: messages.value, conversationId: conversationId.value, draftClaimId: draftClaimId.value, currentInsight: currentInsight.value, reviewFilePreviews: reviewFilePreviews.value, composerDraft: composerDraft.value, attachedFiles: attachedFiles.value, composerFilesExpanded: composerFilesExpanded.value, composerUploadIntent: composerUploadIntent.value, insightPanelCollapsed: insightPanelCollapsed.value } } function applySessionState(sessionState) { const nextState = sessionState || buildEmptySessionState(activeSessionType.value) activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE messages.value = Array.isArray(nextState.messages) && nextState.messages.length ? nextState.messages : [ createWelcomeAssistantMessage( props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value ) ] conversationId.value = String(nextState.conversationId || '').trim() draftClaimId.value = String(nextState.draftClaimId || '').trim() currentInsight.value = nextState.currentInsight || buildWelcomeInsight( props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value ) reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : [] composerDraft.value = String(nextState.composerDraft || '') attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : [] composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded) composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim() insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed) uploadDecisionDialogOpen.value = false nextTick(() => { adjustComposerTextareaHeight() scrollToBottom() }) } async function loadLatestSessionState(targetSessionType) { const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, { preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE }) if (payload?.found && payload.conversation) { return buildConversationSessionState(payload.conversation, targetSessionType) } return buildEmptySessionState(targetSessionType) } function resetKnowledgeSessionSnapshot() { const emptyKnowledgeState = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE) sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = emptyKnowledgeState if (activeSessionType.value === SESSION_TYPE_KNOWLEDGE) { applySessionState(emptyKnowledgeState) } } function clearKnowledgeSessionOnEntry() { resetKnowledgeSessionSnapshot() knowledgeSessionResetPromise = clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE) .catch((error) => { console.warn('Failed to clear knowledge session on entry:', error) }) .finally(() => { resetKnowledgeSessionSnapshot() }) return knowledgeSessionResetPromise } async function switchSessionType(targetSessionType) { const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) { return } sessionSnapshots.value[activeSessionType.value] = captureCurrentSessionState() if (sessionSnapshots.value[normalizedTarget]) { applySessionState(sessionSnapshots.value[normalizedTarget]) return } sessionSwitchBusy.value = true try { const nextState = await loadLatestSessionState(normalizedTarget) sessionSnapshots.value[normalizedTarget] = nextState applySessionState(nextState) } catch (error) { const emptyState = buildEmptySessionState(normalizedTarget) sessionSnapshots.value[normalizedTarget] = emptyState applySessionState(emptyState) toast(error?.message || '加载会话失败,已为你打开新的会话。') } finally { sessionSwitchBusy.value = false } } sessionSnapshots.value[initialSessionState.sessionType] = captureCurrentSessionState() watch( () => activeReviewPayload.value, (payload) => { rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload)) const nextInlineState = buildInlineReviewState(payload) reviewInlineForm.value = { ...nextInlineState } reviewInlineBaseForm.value = { ...nextInlineState } reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields) const nextDocumentDrafts = buildReviewDocumentDrafts(payload) reviewDocumentDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) activeReviewDocumentIndex.value = nextDocumentDrafts.length ? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1) : 0 reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length ? REVIEW_DRAWER_MODE_RISK : REVIEW_DRAWER_MODE_REVIEW reviewInlinePendingFiles.value = [] reviewInlineEditorKey.value = '' reviewInlineErrors.value = {} reviewOtherCategoryOpen.value = false }, { 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( () => [activeSessionType.value, resolveActiveClaimId()], ([sessionType, claimId]) => { if (sessionType !== SESSION_TYPE_EXPENSE || !claimId) { return } void restorePersistedDraftAttachmentPreviews(claimId) }, { immediate: true } ) onMounted(() => { document.addEventListener('click', handleComposerDatePickerOutside) flowTickTimer = window.setInterval(() => { flowTick.value = Date.now() }, 250) nextTick(() => { workbenchVisible.value = true }) void clearKnowledgeSessionOnEntry() 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() } else { nextTick(() => { adjustComposerTextareaHeight() scrollToBottom() }) } }) onBeforeUnmount(() => { document.removeEventListener('click', handleComposerDatePickerOutside) if (flowTickTimer) { window.clearInterval(flowTickTimer) } clearFlowSimulationTimers() for (const url of previewRegistry) { URL.revokeObjectURL(url) } }) function scrollToBottom() { if (!messageListRef.value) return messageListRef.value.scrollTop = messageListRef.value.scrollHeight } function resetCurrentSessionState() { const emptyState = buildEmptySessionState(activeSessionType.value) sessionSnapshots.value[activeSessionType.value] = emptyState applySessionState(emptyState) clearFlowSimulationTimers() flowRunId.value = '' flowStartedAt.value = 0 flowFinishedAt.value = 0 flowSteps.value = [] } 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 clearFlowSimulationTimers() { while (flowSimulationTimers.length) { const timerId = flowSimulationTimers.pop() window.clearTimeout(timerId) window.clearInterval(timerId) } } function resetFlowRun() { clearFlowSimulationTimers() flowRunId.value = '' flowStartedAt.value = Date.now() flowFinishedAt.value = 0 reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW insightPanelCollapsed.value = false flowSteps.value = [] } function findFlowDefinition(key) { return FLOW_STEP_FALLBACKS[key] || null } function normalizeFlowStepPatch(key, patch = {}) { const definition = findFlowDefinition(key) || {} const normalizedPatch = typeof patch === 'string' ? { detail: patch } : { ...patch } return { title: normalizedPatch.title || definition.title || '智能体工具调用', tool: normalizedPatch.tool || definition.tool || 'AgentTool', detail: normalizedPatch.detail || definition.runningText || '', ...normalizedPatch } } function createFlowStep(key, patch = {}) { const normalizedPatch = normalizeFlowStepPatch(key, patch) return { key, index: flowSteps.value.length + 1, title: normalizedPatch.title, tool: normalizedPatch.tool, status: normalizedPatch.status || FLOW_STEP_STATUS_PENDING, detail: normalizedPatch.detail || '', durationMs: normalizedPatch.durationMs ?? null, startedAt: normalizedPatch.startedAt || 0, finishedAt: normalizedPatch.finishedAt || 0, error: normalizedPatch.error || '' } } function normalizeFlowStepIndexes(steps) { return steps.map((step, index) => ({ ...step, index: index + 1 })) } function upsertFlowStep(key, patch) { const existingStep = flowSteps.value.find((step) => step.key === key) if (!existingStep) { const nextStep = createFlowStep(key, patch) flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep]) return } const normalizedPatch = normalizeFlowStepPatch(key, patch) flowSteps.value = flowSteps.value.map((step) => ( step.key === key ? { ...step, ...normalizedPatch } : step )) } function startFlowStep(key, patch = {}) { const normalizedPatch = normalizeFlowStepPatch(key, patch) const explicitStartedAt = Number(normalizedPatch.startedAt) const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0 ? explicitStartedAt : Date.now() upsertFlowStep(key, { ...normalizedPatch, status: FLOW_STEP_STATUS_RUNNING, detail: normalizedPatch.detail, startedAt, finishedAt: 0, durationMs: null, error: '' }) } function completeFlowStep(key, detail = '', durationMs = null, patch = {}) { const now = Date.now() const definition = findFlowDefinition(key) const currentStep = flowSteps.value.find((step) => step.key === key) const explicitDuration = Number(durationMs) const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0 const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now) upsertFlowStep(key, { ...patch, status: FLOW_STEP_STATUS_COMPLETED, detail: detail || definition?.completedText || '', startedAt, finishedAt: now, durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt), error: '' }) } function failFlowStep(key, detail = '', error = '', patch = {}) { const now = Date.now() const definition = findFlowDefinition(key) const currentStep = flowSteps.value.find((step) => step.key === key) const startedAt = currentStep?.startedAt || now upsertFlowStep(key, { ...patch, status: FLOW_STEP_STATUS_FAILED, detail: detail || error || '调用失败', startedAt, finishedAt: now, durationMs: now - startedAt, error: String(error || definition?.title || '').trim() }) flowFinishedAt.value = now } function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) { const currentStep = flowSteps.value.find((step) => step.key === key) if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) { return } const normalizedDuration = Number(durationMs) const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0 if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) { if (!hasMeasuredDuration && !currentStep?.startedAt) { upsertFlowStep(key, { ...patch, status: FLOW_STEP_STATUS_COMPLETED, detail: detail || findFlowDefinition(key)?.completedText || '', startedAt: 0, finishedAt: 0, durationMs: null, error: '' }) return } startFlowStep(key, patch) } completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch) } function failCurrentFlowStep(error) { clearFlowSimulationTimers() const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING) failFlowStep( currentStep?.key || 'orchestrator-error', error?.message || '智能体调用失败', error?.message || '', currentStep ? {} : { title: '流程调用', tool: 'Orchestrator' } ) } function startSemanticFlowPreview(rawText, options = {}) { clearFlowSimulationTimers() const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS }) const extractionMessages = buildLocalExtractionProgressMessages(rawText, options) const completeIntentTimer = window.setTimeout(() => { const currentStep = flowSteps.value.find((step) => step.key === 'intent') if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { return } completePendingFlowStep('intent', intentPreview, null) }, 260) flowSimulationTimers.push(completeIntentTimer) const startExtractionTimer = window.setTimeout(() => { const currentStep = flowSteps.value.find((step) => step.key === 'extraction') if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) { return } startFlowStep('extraction', extractionMessages[0] || FLOW_STEP_FALLBACKS.extraction.runningText) if (extractionMessages.length <= 1) { return } let index = 1 const detailTimer = window.setInterval(() => { const runningStep = flowSteps.value.find((step) => step.key === 'extraction') if (!runningStep || runningStep.status !== FLOW_STEP_STATUS_RUNNING) { window.clearInterval(detailTimer) return } upsertFlowStep('extraction', { detail: extractionMessages[index] || extractionMessages[extractionMessages.length - 1] }) index = Math.min(index + 1, extractionMessages.length - 1) }, 650) flowSimulationTimers.push(detailTimer) }, 420) flowSimulationTimers.push(startExtractionTimer) } function startReviewActionFlowStep(reviewAction) { if (reviewAction !== 'next_step') { return } startFlowStep('pre-submit-review', { title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim', detail: '正在校验财务规则、风险规则和审批路径...' }) } function startExpenseClaimDraftFlowStep(reviewAction, options = {}) { if (isKnowledgeSession.value) { return } if (reviewAction === 'next_step') { startReviewActionFlowStep(reviewAction) return } const attachmentCount = Math.max(0, Number(options.attachmentCount || 0)) const configs = { save_draft: { title: '报销草稿保存', detail: '正在保存当前核对结果...' }, link_to_existing_draft: { title: '票据关联草稿', detail: '正在把本次票据关联到现有草稿...' }, create_new_claim_from_documents: { title: '新建报销草稿', detail: '正在根据当前票据新建报销草稿...' } } const config = configs[reviewAction] || { title: '报销草稿处理', detail: attachmentCount ? '正在根据 OCR 结果更新草稿和右侧核对信息...' : '正在更新草稿和右侧核对信息...' } startFlowStep('expense-claim-draft', { title: config.title, tool: 'database.expense_claims.save_or_submit', detail: config.detail }) } function resolveToolCallFlowMeta(toolCall, index) { const toolType = String(toolCall?.tool_type || '').toLowerCase() const toolName = String(toolCall?.tool_name || '').toLowerCase() const response = toolCall?.response_json && typeof toolCall.response_json === 'object' ? toolCall.response_json : {} const responseMessage = String(response.message || '').trim() const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}` if (toolType.includes('rule')) { return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' } } if (toolType.includes('mcp')) { return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' } } if (toolName.includes('knowledge')) { return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' } } if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) { if ( response.submission_blocked || String(response.status || '').trim() === 'submitted' || responseMessage.includes('AI预审') || responseMessage.includes('审批') ) { return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' } } return { key: 'expense-claim-draft', title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' } } if (toolType.includes('database')) { return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' } } if (toolType.includes('llm') || toolName.includes('user_agent')) { return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' } } return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' } } function summarizeFlowToolCall(toolCall) { const response = toolCall?.response_json && typeof toolCall.response_json === 'object' ? toolCall.response_json : {} if (String(response.status || '').trim() === 'submitted') { return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}` } if (response.submission_blocked) { return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批' } return ( String(response.message || response.summary || response.result_summary || '').trim() || String(toolCall?.tool_name || '').trim() || '工具调用完成' ) } function mergeFlowRunDetail(run) { const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) { clearFlowSimulationTimers() const semanticDurations = resolveSemanticPhaseDurations(run) const intentStep = flowSteps.value.find((step) => step.key === 'intent') const extractionStep = flowSteps.value.find((step) => step.key === 'extraction') completePendingFlowStep( 'intent', summarizeSemanticIntentDetail(run.semantic_parse, { scenarioLabels: SCENARIO_LABELS, intentLabels: INTENT_LABELS, expenseTypeLabels: EXPENSE_TYPE_LABELS, fallbackText: FLOW_STEP_FALLBACKS.intent.completedText }), intentStep?.startedAt ? null : semanticDurations.intentMs ) completePendingFlowStep( 'extraction', summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}), extractionStep?.startedAt ? null : semanticDurations.extractionMs ) } toolCalls.forEach((toolCall, index) => { const meta = resolveToolCallFlowMeta(toolCall, index) const failed = String(toolCall?.status || '').toLowerCase() === 'failed' if (failed) { failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta) } else { const toolDurationMs = resolveToolCallDurationMs(toolCall, index, toolCalls, run) completePendingFlowStep( meta.key, summarizeFlowToolCall(toolCall), toolDurationMs, meta ) } }) if (String(run?.status || '').toLowerCase() === 'failed') { failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' }) return } } function completeFlowResult(payload, run = null) { const answer = String(payload?.result?.answer || payload?.result?.message || '').trim() if (!answer && !payload?.result) { return } flowSteps.value .filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)) .forEach((step) => { completeFlowStep(step.key, resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })) }) flowFinishedAt.value = Date.now() if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) { reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW } } async function refreshFlowRunDetail() { if (!flowRunId.value || flowRefreshBusy.value) { return null } flowRefreshBusy.value = true try { const run = await fetchAgentRunDetail(flowRunId.value) mergeFlowRunDetail(run) return run } catch (error) { console.warn('Failed to refresh agent run detail:', error) return null } finally { flowRefreshBusy.value = false } } function formatFlowStepDuration(step) { if (step?.status === FLOW_STEP_STATUS_RUNNING && step.startedAt) { return formatFlowDuration(flowTick.value - step.startedAt) } return formatFlowDuration(step?.durationMs) } function resolveFlowStepStatusLabel(step) { const status = String(step?.status || '').trim() if (status === FLOW_STEP_STATUS_COMPLETED) { return '完成' } if (status === FLOW_STEP_STATUS_RUNNING) { return '执行中' } if (status === FLOW_STEP_STATUS_FAILED) { return '异常' } return '待执行' } function resolveFlowStepDetail(step) { const detail = String(step?.detail || '').trim() if (detail) { return detail } const definition = findFlowDefinition(step?.key) if (step?.status === FLOW_STEP_STATUS_COMPLETED) { return definition?.completedText || '步骤已完成' } if (step?.status === FLOW_STEP_STATUS_RUNNING) { return definition?.runningText || '正在执行当前步骤...' } if (step?.status === FLOW_STEP_STATUS_FAILED) { return step?.error || '步骤执行异常' } return definition?.runningText ? `等待${definition.title || '当前步骤'}...` : '等待智能体调度...' } function buildComposerBusinessTimeLabel() { if (composerDateMode.value === 'single') { return `业务发生时间:${composerSingleDate.value}` } if (composerRangeStartDate.value === composerRangeEndDate.value) { return `业务发生时间:${composerRangeStartDate.value}` } return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}` } function hasComposerBusinessTimeSelection() { return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value } function buildComposerBusinessTimeContext() { if (!hasComposerBusinessTimeSelection()) { return null } const mode = composerDateMode.value === 'range' ? 'range' : 'single' const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim() const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim() if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) { return null } const displayValue = mode === 'range' && startDate !== endDate ? `${startDate} 至 ${endDate}` : startDate return { mode, start_date: startDate, end_date: endDate, occurred_date: startDate, time_range: displayValue, business_time: displayValue, time_range_raw: buildComposerBusinessTimeLabel() } } function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) { if (!businessTimeContext) { return extraContext } const baseReviewFormValues = extraContext.review_form_values && typeof extraContext.review_form_values === 'object' ? extraContext.review_form_values : {} return { ...extraContext, occurred_date: businessTimeContext.occurred_date, business_time: businessTimeContext.business_time, business_time_context: { mode: businessTimeContext.mode, start_date: businessTimeContext.start_date, end_date: businessTimeContext.end_date, display_value: businessTimeContext.business_time }, review_form_values: { ...baseReviewFormValues, occurred_date: businessTimeContext.occurred_date, time_range: businessTimeContext.time_range, business_time: businessTimeContext.business_time, time_range_raw: businessTimeContext.time_range_raw } } } function syncComposerBusinessTimeToReviewCard(businessTimeContext) { if (!businessTimeContext || !activeReviewPayload.value) { return } const nextInlineState = { ...reviewInlineForm.value, occurred_date: businessTimeContext.occurred_date } const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState) reviewInlineForm.value = nextInlineState if (latestReviewMessage.value) { latestReviewMessage.value.reviewPayload = nextReviewPayload } if (currentInsight.value?.agent) { currentInsight.value = { ...currentInsight.value, agent: { ...currentInsight.value.agent, reviewPayload: nextReviewPayload } } } } function resolveComposerSubmitText(explicitRawText) { const draftPart = String(explicitRawText ?? composerDraft.value).trim() const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join(',') if (!tagPart) { return draftPart } if (!draftPart) { return tagPart } return `${tagPart},${draftPart}` } function toggleComposerDatePicker() { composerDatePickerOpen.value = !composerDatePickerOpen.value if (composerDatePickerOpen.value) { travelCalculatorOpen.value = false } } function closeComposerDatePicker() { composerDatePickerOpen.value = false } function setComposerDateMode(mode) { composerDateMode.value = mode === 'range' ? 'range' : 'single' } function handleComposerDateInputChange() { composerBusinessTimeDraftTouched.value = true syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext()) } function removeComposerBusinessTimeTag(tagId) { composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId) if (!composerBusinessTimeTags.value.length) { composerBusinessTimeDraftTouched.value = false } } function handleComposerDatePickerOutside(event) { if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) { return } if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) { return } if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) { return } if (composerDatePickerOpen.value) { composerDatePickerOpen.value = false } if (travelCalculatorOpen.value && !travelCalculatorBusy.value) { travelCalculatorOpen.value = false } } async function applyComposerDateSelection() { if (!composerCanApplyDateSelection.value) { return } composerBusinessTimeDraftTouched.value = true composerBusinessTimeTags.value = [ { id: `biz-time-${Date.now()}`, label: buildComposerBusinessTimeLabel() } ] syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext()) composerDatePickerOpen.value = false await nextTick() adjustComposerTextareaHeight() composerTextareaRef.value?.focus() } function resolveTravelCalculatorInitialDays() { const businessTimeContext = buildComposerBusinessTimeContext() if (!businessTimeContext) { return 1 } const startDate = businessTimeContext.start_date const endDate = businessTimeContext.end_date || startDate if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) { return 1 } const startAt = Date.parse(`${startDate}T00:00:00Z`) const endAt = Date.parse(`${endDate}T00:00:00Z`) if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) { return 1 } return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1) } function resolveTravelCalculatorInitialLocation() { const slotMap = buildReviewSlotMap(activeReviewPayload.value) const candidates = [ reviewInlineForm.value.location, slotMap.business_location?.normalized_value, slotMap.business_location?.value, slotMap.location?.normalized_value, slotMap.location?.value, currentUser.value?.location ] return String(candidates.find((item) => String(item || '').trim()) || '').trim() } function openTravelCalculator() { closeComposerDatePicker() travelCalculatorError.value = '' travelCalculatorResult.value = null travelCalculatorForm.value = { days: String(resolveTravelCalculatorInitialDays()), location: resolveTravelCalculatorInitialLocation() } travelCalculatorOpen.value = true } function toggleTravelCalculator() { if (travelCalculatorOpen.value) { closeTravelCalculator() return } openTravelCalculator() } function closeTravelCalculator() { if (travelCalculatorBusy.value) { return } travelCalculatorOpen.value = false } function formatTravelCalculatorMoney(value) { const amount = Number(value) if (!Number.isFinite(amount)) { return String(value || '0') } return amount.toFixed(2) } function buildTravelCalculatorResultText(result) { const days = Number(result?.days) || 1 const location = String(result?.location || '').trim() || '未填写地点' const matchedCity = String(result?.matched_city || location).trim() const grade = String(result?.grade || '').trim() || '当前职级' const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位' const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域' const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则' const ruleVersion = String(result?.rule_version || '').trim() const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate) const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount) const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate) const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate) const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate) const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount) const totalAmount = formatTravelCalculatorMoney(result?.total_amount) const ruleVersionText = ruleVersion ? `(${ruleVersion})` : '' const user = currentUser.value || {} const displayName = String(user.name || user.display_name || user.username || '').trim() const greeting = displayName ? `您好,${displayName},` : '您好,' return [ `${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`, '', `**参考可报销合计:${totalAmount} 元**`, '', '| 项目 | 标准口径 | 天数 | 小计 |', '| --- | --- | ---: | ---: |', `| 住宿费 | ${matchedCity} / ${grade}(${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`, `| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`, '', '**计算过程**', `1. 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元`, `2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount} 元`, `3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount} 元`, '', `**规则依据**:${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`, '', '这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。' ].join('\n') } async function submitTravelCalculator() { if (!travelCalculatorCanSubmit.value) { travelCalculatorError.value = '请填写出差天数和地点后再计算。' return } travelCalculatorBusy.value = true travelCalculatorError.value = '' try { const user = currentUser.value || {} const payload = await calculateTravelReimbursement({ days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1), location: String(travelCalculatorForm.value.location || '').trim(), grade: String(user.grade || '').trim() }) travelCalculatorResult.value = payload messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], { meta: ['差旅计算器'], metaTone: 'low' })) travelCalculatorOpen.value = false nextTick(scrollToBottom) } catch (error) { travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。' } finally { travelCalculatorBusy.value = false } } function rememberFilePreviews(filePreviews) { reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) } function trackPreviewObjectUrl(url) { if (!url || !String(url).startsWith('blob:')) { return } previewRegistry.push(url) } function resolveActiveClaimId() { return String(draftClaimId.value || linkedRequest.value?.claimId || '').trim() } async function buildPersistedAttachmentPreview(metadata) { const filename = String(metadata?.file_name || '').trim() const kind = resolveAttachmentPreviewKind(metadata) const previewPath = String(metadata?.preview_url || '').trim() if (!filename || !kind || !previewPath) { return null } const blob = await fetchExpenseClaimAttachmentAsset(previewPath) const url = URL.createObjectURL(blob) trackPreviewObjectUrl(url) return { filename, kind, url } } async function restorePersistedDraftAttachmentPreviews(claimId, options = {}) { const normalizedClaimId = String(claimId || '').trim() if (!normalizedClaimId || isKnowledgeSession.value) { return } const force = Boolean(options.force) if (!force && restoredDraftPreviewClaims.has(normalizedClaimId)) { return } try { const claim = await fetchExpenseClaimDetail(normalizedClaimId) const items = Array.isArray(claim?.items) ? claim.items : [] const previews = [] for (const item of items) { const itemId = String(item?.id || '').trim() if (!itemId) continue let metadata = null try { metadata = await fetchExpenseClaimItemAttachmentMeta(normalizedClaimId, itemId) } catch { continue } const filename = String(metadata?.file_name || '').trim() if (!metadata?.previewable || !filename || resolveDocumentPreview(reviewFilePreviews.value, filename)) { continue } try { const preview = await buildPersistedAttachmentPreview(metadata) if (preview) { previews.push(preview) } } catch (error) { console.warn('Failed to load persisted attachment preview:', error) } } if (previews.length) { rememberFilePreviews(previews) } restoredDraftPreviewClaims.add(normalizedClaimId) } catch (error) { console.warn('Failed to restore persisted draft attachment previews:', error) } } async function syncComposerFilesToDraft(claimId, files) { const normalizedClaimId = String(claimId || '').trim() if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) { return } const claim = await fetchExpenseClaimDetail(normalizedClaimId) const items = Array.isArray(claim?.items) ? claim.items : [] const exactMatchBuckets = new Map() const placeholderQueue = [] const usedItemIds = new Set() for (const item of items) { const itemId = String(item?.id || '').trim() const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim() if (!itemId) continue if (invoiceId && !invoiceId.includes('/')) { placeholderQueue.push(item) } if (!invoiceId) continue const bucket = exactMatchBuckets.get(invoiceId) || [] bucket.push(item) exactMatchBuckets.set(invoiceId, bucket) } for (const file of files) { const exactBucket = exactMatchBuckets.get(file.name) || [] const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim())) const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim())) const targetItem = nextExactMatch || fallbackMatch const targetItemId = String(targetItem?.id || '').trim() if (!targetItemId) { continue } usedItemIds.add(targetItemId) await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file) } await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true }) } 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) } function triggerFileUpload(mode = 'composer') { if (submitting.value || reviewActionBusy.value) return fileInputMode.value = mode fileInputRef.value?.click() } function handleFilesChange(event) { const files = Array.from(event.target.files ?? []) if (fileInputMode.value === 'inline-review' && activeReviewPayload.value) { const existingNames = extractReviewAttachmentNames(activeReviewPayload.value) const remainingSlots = Math.max(MAX_ATTACHMENTS - existingNames.length, 0) const mergeResult = mergeFilesWithLimit(reviewInlinePendingFiles.value, files, remainingSlots) if (!remainingSlots && files.length) { toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,当前票据数量已到上限。`) } else if (mergeResult.overflowCount > 0) { toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,新增票据已按上限截断。`) } reviewInlinePendingFiles.value = mergeResult.files const allAttachmentNames = [...existingNames, ...mergeResult.files.map((file) => file.name)] reviewInlineForm.value = { ...reviewInlineForm.value, attachment_names: allAttachmentNames.join('、'), attachment_count: allAttachmentNames.length, pending_attachment_count: mergeResult.files.length } clearInlineReviewFieldError('attachments') reviewInlineEditorKey.value = '' } else { if (isKnowledgeSession.value) { toast('财务知识问答暂不支持上传附件。') fileInputMode.value = 'composer' if (fileInputRef.value) { fileInputRef.value.value = '' } return } const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS) attachedFiles.value = mergeResult.files if (fileInputMode.value === 'composer-continue' && files.length) { composerUploadIntent.value = 'continue_existing' } if (mergeResult.overflowCount > 0) { toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) } if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { composerFilesExpanded.value = false } } fileInputMode.value = 'composer' if (fileInputRef.value) { fileInputRef.value.value = '' } } function toggleAttachedFilesExpanded() { composerFilesExpanded.value = !composerFilesExpanded.value } function removeAttachedFile(targetFile) { const fileKey = buildFileIdentity(targetFile) attachedFiles.value = attachedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey) if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { composerFilesExpanded.value = false } if (!attachedFiles.value.length) { composerUploadIntent.value = '' } } function clearAttachedFiles() { attachedFiles.value = [] composerFilesExpanded.value = false composerUploadIntent.value = '' if (fileInputRef.value) { fileInputRef.value.value = '' } } function closeUploadDecisionDialog() { if (submitting.value || reviewActionBusy.value) return uploadDecisionDialogOpen.value = false } async function continueExistingUpload() { if (submitting.value || reviewActionBusy.value) return uploadDecisionDialogOpen.value = false composerUploadIntent.value = 'continue_existing' await submitComposer({ uploadDisposition: 'continue_existing', skipUploadDecisionPrompt: true }) } async function createNewUploadDocument() { if (submitting.value || reviewActionBusy.value) return uploadDecisionDialogOpen.value = false composerUploadIntent.value = '' await submitComposer({ uploadDisposition: 'new_document', skipUploadDecisionPrompt: true }) } async function runShortcut(shortcut) { if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) { await switchSessionType(shortcut.targetSessionType) return } const prompt = String(shortcut?.prompt || '').trim() if (!prompt) return composerDraft.value = prompt submitComposer() } function toggleInsightPanel() { if (!hasInsightPanelContent.value) { return } insightPanelCollapsed.value = !insightPanelCollapsed.value } function switchReviewDrawerMode(mode) { if (reviewDrawerMode.value === mode) { return } reviewDrawerMode.value = mode } function switchToReviewOverviewDrawer() { 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 setInlineReviewFieldError(key, message) { reviewInlineErrors.value = { ...reviewInlineErrors.value, [key]: String(message || '').trim() } } function clearInlineReviewFieldError(key) { if (!reviewInlineErrors.value[key]) { return } const nextErrors = { ...reviewInlineErrors.value } delete nextErrors[key] reviewInlineErrors.value = nextErrors } function openInlineReviewEditor(key) { if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return if (key === 'attachments') { triggerFileUpload('inline-review') return } if (reviewInlineEditorKey.value && reviewInlineEditorKey.value !== key && !commitInlineReviewEditor()) { return } if (reviewInlineEditorKey.value === key) { commitInlineReviewEditor() return } if (key === 'amount') { reviewInlineForm.value = { ...reviewInlineForm.value, amount: extractAmountInputValue(reviewInlineForm.value.amount) } } clearInlineReviewFieldError(key) reviewInlineEditorKey.value = key if (key !== 'expense_type') { reviewOtherCategoryOpen.value = false } } function closeInlineReviewEditor() { reviewInlineEditorKey.value = '' reviewOtherCategoryOpen.value = false } function commitInlineReviewEditor() { const activeEditorKey = reviewInlineEditorKey.value const nextForm = { ...reviewInlineForm.value, occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(), amount: String(reviewInlineForm.value.amount || '').trim(), transport_type: String(reviewInlineForm.value.transport_type || '').trim(), customer_name: String(reviewInlineForm.value.customer_name || '').trim(), location: String(reviewInlineForm.value.location || '').trim(), merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(), participants: String(reviewInlineForm.value.participants || '').trim(), scene_label: String(reviewInlineForm.value.scene_label || '').trim(), reason_value: String(reviewInlineForm.value.reason_value || reviewInlineForm.value.scene_label || '').trim(), expense_type: String(reviewInlineForm.value.expense_type || '').trim() } if ( activeEditorKey === 'scene' && nextForm.scene_label === REVIEW_SCENE_OTHER_OPTION ) { nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim() if (!nextForm.reason_value) { setInlineReviewFieldError('scene', '请选择“其他场景”后,请补充具体事由') reviewInlineForm.value = nextForm return false } } else if (activeEditorKey === 'scene') { nextForm.reason_value = nextForm.scene_label } if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) { 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') return false } nextForm.amount = normalizedAmount } if (activeEditorKey) { clearInlineReviewFieldError(activeEditorKey) } reviewInlineForm.value = nextForm reviewInlineEditorKey.value = '' return true } function selectInlineScene(scene) { const normalizedScene = String(scene || '').trim() reviewInlineForm.value = { ...reviewInlineForm.value, scene_label: normalizedScene, reason_value: normalizedScene === REVIEW_SCENE_OTHER_OPTION ? '' : normalizedScene } clearInlineReviewFieldError('scene') if (normalizedScene !== REVIEW_SCENE_OTHER_OPTION) { reviewInlineEditorKey.value = '' } } function selectReviewCategory(option) { if (!option) return if (option.is_other) { reviewOtherCategoryOpen.value = !reviewOtherCategoryOpen.value return } reviewInlineForm.value = { ...reviewInlineForm.value, expense_type: option.label } reviewOtherCategoryOpen.value = false } function selectReviewOtherCategory(option) { if (!option) return reviewInlineForm.value = { ...reviewInlineForm.value, expense_type: option.label } reviewOtherCategoryOpen.value = false } 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), [], { meta: [item.sourceLabel || item.levelLabel || '风险提示'], metaTone: item.level || 'low' })) nextTick(scrollToBottom) } function goReviewDocument(direction) { const total = reviewDocumentCount.value if (!total) return const nextIndex = activeReviewDocumentIndex.value + Number(direction || 0) activeReviewDocumentIndex.value = Math.max(0, Math.min(total - 1, nextIndex)) } function openActiveReviewDocumentPreview() { if (!activeReviewDocument.value || !activeReviewDocumentPreview.value?.url) return documentPreviewDialog.value = { open: true, filename: activeReviewDocument.value.filename, kind: activeReviewDocumentPreview.value.kind, url: activeReviewDocumentPreview.value.url } } function closeDocumentPreview() { documentPreviewDialog.value = { ...documentPreviewDialog.value, open: false } } function requestCloseWorkbench() { workbenchVisible.value = false } function emitCloseAfterLeave() { emit('close') } function openExpenseQueryRecord(record) { const claimId = String(record?.claimId || '').trim() if (!claimId) { return } router.push({ name: 'app-request-detail', params: { requestId: claimId } }) emit('close') } 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()) } resetCurrentSessionState() deleteSessionDialogOpen.value = false toast('当前会话已删除。') } catch (error) { toast(error?.message || '删除当前会话失败,请稍后重试。') } finally { deleteSessionBusy.value = false } } function saveInlineReviewChanges() { if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || reviewActionBusy.value) return if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { return } reviewActionBusy.value = true try { const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, reviewInlineForm.value) const messageText = `${buildLocalReviewSavedMessage( reviewInlineBaseForm.value, reviewInlineForm.value, reviewInlinePendingFiles.value, reviewDocumentBaseDrafts.value, reviewDocumentDrafts.value )} ${buildLocalReviewCompletionMessage(nextReviewPayload)}` reviewInlineBaseFields.value = cloneReviewEditFields(fields) reviewInlineBaseForm.value = { ...reviewInlineForm.value } reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(reviewDocumentDrafts.value) if (latestReviewMessage.value) { latestReviewMessage.value.reviewPayload = nextReviewPayload } if (currentInsight.value?.agent) { currentInsight.value = { ...currentInsight.value, agent: { ...currentInsight.value.agent, reviewPayload: nextReviewPayload } } } messages.value.push(createMessage('assistant', messageText, [], { meta: ['本地修改'], draftPayload: latestReviewMessage.value?.draftPayload || null, reviewPayload: nextReviewPayload })) nextTick(scrollToBottom) } finally { reviewActionBusy.value = false } } 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: '正在整理财务知识答案...' }) } 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 (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 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 reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value) const hasExistingDocumentEvent = Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0 if ( !isKnowledgeSession.value && files.length && hasExistingDocumentEvent && !resolvedUploadDisposition && !options.skipUploadDecisionPrompt && !reviewAction ) { uploadDecisionDialogOpen.value = true return null } resetFlowRun() if (rawText && !reviewAction) { startFlowStep('intent', '正在识别业务意图...') startSemanticFlowPreview(rawText, { attachmentCount: files.length }) } const fileNames = files.map((file) => file.name) const filePreviews = buildFilePreviews(files, previewRegistry) rememberFilePreviews(filePreviews) const userText = String(options.userText || '').trim() || rawText || (isKnowledgeSession.value ? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。` : resolvedUploadDisposition === 'continue_existing' ? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。` : resolvedUploadDisposition === 'new_document' ? `新上传 ${fileNames.length} 份票据,请单独建立报销单。` : `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`) // 只有在非静默模式下才添加用户消息 if (!options.skipUserMessage) { messages.value.push(createMessage('user', userText, fileNames)) } 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' 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 }) 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) { 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 } function openCancelReviewDialog(message) { reviewActionMessageId.value = String(message?.id || '') reviewCancelDialogOpen.value = true } function closeCancelReviewDialog() { if (reviewActionBusy.value) return reviewCancelDialogOpen.value = false reviewActionMessageId.value = '' } function confirmCancelReview() { if (reviewActionBusy.value) return reviewCancelDialogOpen.value = false emit('close') } function openEditReviewDialog(message) { const sourceFields = reviewInlineBaseFields.value.length ? mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) : cloneReviewEditFields(message?.reviewPayload?.edit_fields) reviewEditFields.value = cloneReviewEditFields(sourceFields) reviewActionMessageId.value = String(message?.id || '') reviewEditDialogOpen.value = true } function closeEditReviewDialog() { if (reviewActionBusy.value) return reviewEditDialogOpen.value = false reviewEditFields.value = [] reviewActionMessageId.value = '' } function applyEditedReview() { if (reviewActionBusy.value) return reviewActionBusy.value = true try { const fields = cloneReviewEditFields(reviewEditFields.value) const nextInlineState = buildInlineReviewState({ ...(activeReviewPayload.value || {}), edit_fields: fields }) const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState) const messageText = `${buildLocalReviewSavedMessage( reviewInlineForm.value, nextInlineState, [], reviewDocumentBaseDrafts.value, reviewDocumentDrafts.value )} ${buildLocalReviewCompletionMessage(nextReviewPayload)}` reviewInlineForm.value = { ...nextInlineState } reviewInlineBaseForm.value = { ...nextInlineState } reviewInlineBaseFields.value = cloneReviewEditFields(fields) if (latestReviewMessage.value) { latestReviewMessage.value.reviewPayload = nextReviewPayload } if (currentInsight.value?.agent) { currentInsight.value = { ...currentInsight.value, agent: { ...currentInsight.value.agent, reviewPayload: nextReviewPayload } } } messages.value.push(createMessage('assistant', messageText, [], { meta: ['本地修改'], draftPayload: latestReviewMessage.value?.draftPayload || null, reviewPayload: nextReviewPayload })) nextTick(scrollToBottom) } finally { reviewActionBusy.value = false } closeEditReviewDialog() } async function handleReviewAction(message, action) { const actionType = String(action?.action_type || '').trim() if (!actionType || reviewActionBusy.value) return if (actionType === 'cancel_review') { openCancelReviewDialog(message) return } if (actionType === 'edit_review') { openEditReviewDialog(message) return } if (!['save_draft', 'next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) { return } if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { return } if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) { await handleSaveDraftDirectly(message, actionType) return } reviewActionBusy.value = true try { const baseFields = reviewInlineBaseFields.value.length ? reviewInlineBaseFields.value : cloneReviewEditFields(message?.reviewPayload?.edit_fields) const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) const reviewChangedUserText = reviewHasUnsavedChanges.value ? buildReviewSubmitUserText( reviewInlineBaseForm.value, reviewInlineForm.value, reviewInlinePendingFiles.value, reviewDocumentBaseDrafts.value, reviewDocumentDrafts.value ) : '' const documentCorrectionMessage = buildReviewDocumentCorrectionMessage( reviewDocumentBaseDrafts.value, reviewDocumentDrafts.value ) const payload = await submitComposer({ rawText: [ reviewHasUnsavedChanges.value ? buildReviewCorrectionMessage(fields) : '', reviewHasUnsavedChanges.value ? documentCorrectionMessage : '', '我已核对右侧识别结果,请进入下一步。' ] .filter(Boolean) .join('\n'), userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。', files: reviewInlinePendingFiles.value, pendingText: '正在进入下一步...', systemGenerated: true, extraContext: { review_action: actionType, review_form_values: buildReviewFormValues(fields), review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) } }) if (payload?.result?.draft_payload?.status === 'submitted') { emit( 'draft-saved', buildDraftSavedPayload({ draftPayload: payload.result.draft_payload, reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, inlineState: reviewInlineForm.value, linkedRequest: linkedRequest.value, currentUser: currentUser.value }) ) } } finally { reviewActionBusy.value = false } } async function handleSaveDraftDirectly(message, actionType = 'save_draft') { reviewActionBusy.value = true let savingMessage = null const actionConfig = { save_draft: { rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', pendingText: '正在保存当前草稿...', helperText: '正在保存草稿...', successMeta: '草稿已保存', successMessage: (payload) => { const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() return claimNo ? `草稿已保存,单号:${claimNo}` : '草稿保存完成' } }, link_to_existing_draft: { rawText: '请把当前上传的票据合并到现有报销草稿中。', pendingText: '正在关联到现有草稿...', helperText: '正在关联现有草稿...', successMeta: '已关联草稿', successMessage: (payload) => { const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() return claimNo ? `已关联到草稿 ${claimNo}` : '已关联到现有草稿' } }, create_new_claim_from_documents: { rawText: '请基于当前上传的多张票据,单独建立一张新的报销草稿。', pendingText: '正在建立新的报销草稿...', helperText: '正在建立新报销草稿...', successMeta: '新草稿已建立', successMessage: (payload) => { const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim() return claimNo ? `已建立新草稿 ${claimNo}` : '已建立新的报销草稿' } } }[actionType] || { rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', pendingText: '正在保存当前草稿...', helperText: '正在保存草稿...', successMeta: '草稿已保存', successMessage: () => '草稿保存完成' } try { const baseFields = reviewInlineBaseFields.value.length ? reviewInlineBaseFields.value : cloneReviewEditFields(message?.reviewPayload?.edit_fields) const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) savingMessage = createMessage('assistant', actionConfig.helperText, [], { meta: ['处理中'] }) messages.value.push(savingMessage) nextTick(scrollToBottom) const payload = await submitComposer({ rawText: actionConfig.rawText, userText: '', skipUserMessage: true, files: reviewInlinePendingFiles.value, pendingText: actionConfig.pendingText, systemGenerated: true, extraContext: { review_action: actionType, review_form_values: buildReviewFormValues(fields), review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) } }) const tempIndex = messages.value.findIndex((msg) => msg === savingMessage) if (tempIndex !== -1) { messages.value.splice(tempIndex, 1) } if (payload?.result?.draft_payload?.claim_no) { messages.value.push( createMessage('assistant', actionConfig.successMessage(payload), [], { meta: [actionConfig.successMeta] }) ) emit( 'draft-saved', buildDraftSavedPayload({ draftPayload: payload.result.draft_payload, reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value, inlineState: reviewInlineForm.value, linkedRequest: linkedRequest.value, currentUser: currentUser.value }) ) } else { messages.value.push(createMessage('assistant', actionConfig.successMessage(payload), [], { meta: [actionConfig.successMeta] })) } nextTick(scrollToBottom) } catch (error) { if (savingMessage) { const tempIndex = messages.value.findIndex((msg) => msg === savingMessage) if (tempIndex !== -1) { messages.value.splice(tempIndex, 1) } } messages.value.push(createMessage('assistant', '保存失败,请稍后重试。', [], { meta: ['错误'] })) nextTick(scrollToBottom) } finally { reviewActionBusy.value = false } } 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, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer, reviewDrawerTitle, 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, reviewCancelDialogOpen, reviewEditDialogOpen, uploadDecisionDialogOpen, travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, reviewEditFields, documentPreviewDialog, shortcuts, resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewDisclosureTitle, buildReviewDisclosureHint, shouldOpenReviewDisclosure, buildReviewTodoSectionTitle, buildReviewTodoSectionMeta, buildReviewAlertChips, buildReviewTodoItems, resolveReviewSubmitActions, resolveReviewPrimaryAction, resolveReviewEditAction, buildReviewPrimaryButtonLabel, buildReviewDecisionHint, buildReviewMissingHint, buildReviewRiskHint, buildReviewActionHint, buildReviewStatusTag, renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles, requestCloseWorkbench, emitCloseAfterLeave, openExpenseQueryRecord, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, closeUploadDecisionDialog, continueExistingUpload, createNewUploadDocument, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleReviewAction, handleSaveDraftDirectly, closeCancelReviewDialog, confirmCancelReview, closeEditReviewDialog, applyEditedReview } } }