Files
X-Financial/web/src/views/scripts/TravelReimbursementCreateView.js
caoxiaozhu 8f65661809 feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
2026-05-21 09:28:33 +08:00

5686 lines
203 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}