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

3343 lines
117 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 { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.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 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_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', '其他场景']
const DATE_INPUT_FORMAT = 'YYYY-MM-DD'
const MAX_ATTACHMENTS = 10
const MAX_OCR_DOCUMENTS = 10
const VISIBLE_ATTACHMENT_CHIPS = 2
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 HOT_KNOWLEDGE_QUESTIONS = [
'差旅住宿标准按什么规则执行?',
'酒店超标后如何申请例外报销?',
'招待费报销需要哪些凭证?',
'发票抬头不一致还能报销吗?',
'电子发票验真失败怎么处理?',
'借款多久内需要冲销?',
'预算不足还能先提交报销吗?',
'会议费和招待费如何区分?',
'跨部门项目费用应该怎么归集?',
'员工退票手续费是否可以报销?'
]
const CATEGORY_CONFIDENCE_KEYWORDS = {
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
hotel: [/住宿|酒店|宾馆|民宿/],
transport: [/交通|打车|网约车|出租车|车费|地铁|公交|停车|过路费/],
meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
meeting: [/会务|会议|论坛|展会|参会|会场/],
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
training: [/培训|授课|讲师|课程|签到|讲义/],
communication: [/通讯|电话|流量|话费|宽带|网络/],
welfare: [/福利|体检|团建|节日|慰问|关怀/]
}
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 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(),
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) {
const parts = normalizeOcrDocuments(payload)
.map((item) => `${item.filename}${item.summary || item.text}`)
.filter(Boolean)
return parts.join('')
}
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_data_url || '').trim()
}))
.filter((item) => item.filename && item.kind === 'image' && item.url)
}
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(),
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 buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE) {
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
return {
intent: 'welcome',
metricLabel: '当前模式',
metricValue: '知识问答',
title: '财务知识问答',
summary: '这里适合处理制度解释、报销规则、票据规范和常见财务问题,右侧提供 Top 10 热门问题可直接追问。',
agent: null
}
}
return {
intent: 'welcome',
metricLabel: '当前状态',
metricValue: '待识别',
title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '等待识别内容',
summary:
entrySource === 'detail' && linkedRequest?.id
? '发送消息后会直接结合当前单据上下文识别报销语义,右侧展示已识别内容,主对话区展示待补项和风险提示。'
: '请输入费用场景或上传票据,右侧会展示已识别内容,主对话区会提示待补信息和风险注意事项。',
agent: null
}
}
function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE) {
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
return '已切换到财务知识问答会话。你可以直接提问制度、报销规则、票据要求或常见财务问题。'
}
return entrySource === 'detail' && linkedRequest?.id
? `已进入财务AI工作台当前关联单据 ${linkedRequest.id}。请描述费用场景或补充票据。`
: '这里是财务AI工作台。你发送的内容会直接进入真实 Orchestrator 和 User Agent。'
}
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, [])
}
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: '',
scene_label: '',
reason_value: '',
customer_name: '',
location: '',
merchant_name: '',
participants: '',
attachment_names: '',
attachment_count: 0,
pending_attachment_count: 0,
expense_type: ''
}
}
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 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)).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(),
person: String(currentUser?.name || '').trim() || '当前用户',
dept: String(currentUser?.role || '').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] || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
attachmentSummary,
expenseTableSummary: documents.length
? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认`
: '当前尚未上传票据,请在报销页继续补充附件',
note: '该草稿由 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) {
return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []
}
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: item.key === 'attachments' ? 'danger' : '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: item.key === 'attachments' ? 'danger' : '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 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 '继续下一步'
}
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?.value || slotMap.reason?.raw_value || '').trim()
const expenseType = String(slotMap.expense_type?.value || '').trim()
return summarizeReviewScene(reason, expenseType)
}
function summarizeReviewScene(reason, expenseType = '') {
const normalizedReason = String(reason || '').trim()
const normalizedExpenseType = String(expenseType || '').trim()
const compactReason = normalizedReason.replace(/\s+/g, '')
if (compactReason) {
if (/请客户.*吃饭|客户.*吃饭|招待|宴请/.test(compactReason)) return '请客户吃饭'
if (/出差|差旅/.test(compactReason)) return '出差行程'
if (/酒店|住宿/.test(compactReason)) return '住宿报销'
if (/交通|打车|车费|停车/.test(compactReason)) return '交通出行'
if (/会务|会议|参会/.test(compactReason)) return '会务活动'
return compactReason.length > 12 ? `${compactReason.slice(0, 12)}...` : compactReason
}
if (normalizedExpenseType === '业务招待费') return '请客户吃饭'
if (normalizedExpenseType === '差旅费') return '出差行程'
if (normalizedExpenseType === '住宿费') return '住宿报销'
if (normalizedExpenseType === '交通费') return '交通出行'
if (normalizedExpenseType === '会务费') return '会务活动'
if (normalizedExpenseType) return normalizedExpenseType
return '待补充'
}
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?.value || slotMap.reason?.raw_value || ''
).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()
),
scene_label: summarizeReviewScene(reasonValue, expenseType),
reason_value: 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)
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: String(inlineState.scene_label || '').trim() || '待补充',
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 buildReviewRiskScore(reviewPayload) {
const score = Number(reviewPayload?.risk_score)
if (!Number.isFinite(score) || score <= 0) {
return null
}
return Math.max(0, Math.min(100, Math.round(score)))
}
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 buildReviewRiskItems(reviewPayload) {
return resolveReviewRiskBriefs(reviewPayload)
.map((brief) => String(brief?.content || '').trim())
.filter(Boolean)
.slice(0, 4)
}
function normalizeInlineReviewComparableState(state) {
const source = state && typeof state === 'object' ? state : {}
return {
occurred_date: String(source.occurred_date || '').trim(),
amount: String(source.amount || '').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.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 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,
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 attachedFiles = ref([])
const composerFilesExpanded = ref(false)
const submitting = 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 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 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 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 canSubmit = computed(
() => !submitting.value && !sessionSwitchBusy.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length)
)
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const hasInsightPanelContent = computed(
() => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome'
)
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 reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value))
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
const reviewRiskActionAvailable = computed(() => reviewRiskItems.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 isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS)
const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK)
const reviewDrawerTitle = computed(() => (
isReviewDocumentDrawer.value
? '票据识别结果'
: isReviewRiskDrawer.value
? '风险提示'
: '报销识别核对'
))
const reviewDocumentDrawerLabel = computed(() => (
isReviewDocumentDrawer.value ? '显示核对' : '显示票据'
))
const reviewDocumentDrawerIcon = computed(() => (
isReviewDocumentDrawer.value
? 'mdi mdi-file-document-multiple'
: 'mdi mdi-file-document-multiple-outline'
))
const reviewRiskDrawerLabel = computed(() => (
isReviewRiskDrawer.value ? '显示核对' : '显示风险'
))
const reviewRiskDrawerIcon = computed(() => (
isReviewRiskDrawer.value
? 'mdi mdi-shield-alert'
: 'mdi mdi-shield-alert-outline'
))
const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null)
const activeReviewDocumentPreview = computed(() =>
activeReviewDocument.value
? resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename)
: 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)
return {
sessionType,
messages: restoredMessages.length
? restoredMessages
: [createMessage('assistant', buildWelcomeMessage(props.entrySource, linkedRequest.value, sessionType))],
conversationId: resolveInitialConversationId(conversation),
draftClaimId: resolveInitialDraftClaimId(conversation),
currentInsight: initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType),
reviewFilePreviews: [],
composerDraft: '',
attachedFiles: [],
composerFilesExpanded: false,
insightPanelCollapsed: false
}
}
function buildEmptySessionState(sessionType) {
return {
sessionType,
messages: [createMessage('assistant', buildWelcomeMessage(props.entrySource, linkedRequest.value, sessionType))],
conversationId: '',
draftClaimId: '',
currentInsight: buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType),
reviewFilePreviews: [],
composerDraft: '',
attachedFiles: [],
composerFilesExpanded: false,
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,
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
: [createMessage('assistant', buildWelcomeMessage(props.entrySource, linkedRequest.value, activeSessionType.value))]
conversationId.value = String(nextState.conversationId || '').trim()
draftClaimId.value = String(nextState.draftClaimId || '').trim()
currentInsight.value = nextState.currentInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.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)
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
async function loadLatestSessionState(targetSessionType) {
const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType)
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) => {
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 = 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(
() => composerDraft.value,
() => {
nextTick(adjustComposerTextareaHeight)
}
)
onMounted(() => {
void clearKnowledgeSessionOnEntry()
currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.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(() => {
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)
}
function adjustComposerTextareaHeight() {
if (!composerTextareaRef.value) return
composerTextareaRef.value.style.height = 'auto'
const styles = window.getComputedStyle(composerTextareaRef.value)
const lineHeight = Number.parseFloat(styles.lineHeight) || 24
const verticalPadding =
Number.parseFloat(styles.paddingTop || '0') + Number.parseFloat(styles.paddingBottom || '0')
const maxHeight = lineHeight * COMPOSER_MAX_ROWS + verticalPadding
composerTextareaRef.value.style.height = `${Math.min(composerTextareaRef.value.scrollHeight, maxHeight)}px`
composerTextareaRef.value.style.overflowY =
composerTextareaRef.value.scrollHeight > maxHeight ? 'auto' : 'hidden'
}
function handleComposerInput() {
adjustComposerTextareaHeight()
}
function rememberFilePreviews(filePreviews) {
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
}
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 (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
}
}
function clearAttachedFiles() {
attachedFiles.value = []
composerFilesExpanded.value = false
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
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 toggleReviewDocumentDrawer() {
if (!reviewDocumentDrawerAvailable.value) {
return
}
reviewDrawerMode.value =
reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_DOCUMENTS
}
function toggleReviewRiskDrawer() {
if (!reviewRiskDrawerAvailable.value) {
return
}
reviewDrawerMode.value =
reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_RISK
}
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(),
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 === '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) {
reviewInlineForm.value = {
...reviewInlineForm.value,
scene_label: String(scene || '').trim(),
reason_value: String(scene || '').trim()
}
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}`
})
}
function explainCurrentReviewRisk() {
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
submitComposer({
rawText: '请解释一下当前这笔报销的合规风险和待补充项。',
userText: '查看全部风险项'
})
}
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 = {
open: false,
filename: '',
kind: 'file',
url: ''
}
}
function requestCloseWorkbench() {
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
}
}
async 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 documentCorrectionMessage = buildReviewDocumentCorrectionMessage(
reviewDocumentBaseDrafts.value,
reviewDocumentDrafts.value
)
await submitComposer({
rawText: [buildReviewCorrectionMessage(fields), documentCorrectionMessage].filter(Boolean).join('\n'),
userText: buildReviewSubmitUserText(
reviewInlineBaseForm.value,
reviewInlineForm.value,
reviewInlinePendingFiles.value,
reviewDocumentBaseDrafts.value,
reviewDocumentDrafts.value
),
pendingText: '正在保存修改并刷新右侧核对信息...',
files: reviewInlinePendingFiles.value,
extraContext: {
review_action: 'edit_review',
review_form_values: buildReviewFormValues(fields),
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
}
})
} 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 = String(options.rawText ?? composerDraft.value).trim()
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
const files = fileMergeResult.files
if (fileMergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (!rawText && !files.length) return
const fileNames = files.map((file) => file.name)
const filePreviews = buildFilePreviews(files, previewRegistry)
rememberFilePreviews(filePreviews)
const userText =
String(options.userText || '').trim() ||
rawText ||
(isKnowledgeSession.value
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`)
const extraContext = options.extraContext && typeof options.extraContext === 'object'
? options.extraContext
: {}
// 只有在非静默模式下才添加用户消息
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 = ''
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) {
try {
ocrPayload = await recognizeOcrFiles(files)
ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
rememberFilePreviews(ocrFilePreviews)
} catch (error) {
console.warn('OCR request failed:', error)
}
}
const backendMessage = buildBackendMessage(rawText, fileNames, ocrSummary)
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 || '',
...buildClientTimeContext(),
session_type: activeSessionType.value,
entry_source: props.entrySource,
attachment_names: fileNames,
attachment_count: fileNames.length,
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
ocr_summary: ocrSummary,
ocr_documents: ocrDocuments,
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
...extraContext
}
})
responsePayload = payload
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, fileNames),
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,
fileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
} catch (error) {
replaceMessage(
pendingMessage.id,
createMessage(
'assistant',
error?.message || '无法连接后端 Orchestrator请稍后重试。',
[],
{
meta: ['调用失败']
}
)
)
currentInsight.value = buildErrorInsight(error, fileNames)
} finally {
submitting.value = false
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) {
reviewEditFields.value = cloneReviewEditFields(message?.reviewPayload?.edit_fields)
reviewActionMessageId.value = String(message?.id || '')
reviewEditDialogOpen.value = true
}
function closeEditReviewDialog() {
if (reviewActionBusy.value) return
reviewEditDialogOpen.value = false
reviewEditFields.value = []
reviewActionMessageId.value = ''
}
async function applyEditedReview() {
if (reviewActionBusy.value) return
reviewActionBusy.value = true
try {
const fields = cloneReviewEditFields(reviewEditFields.value)
await submitComposer({
rawText: buildReviewCorrectionMessage(fields),
userText: '我已修改识别信息,请按最新内容更新。',
pendingText: '正在根据修改内容重新识别...',
extraContext: {
review_action: 'edit_review',
review_form_values: buildReviewFormValues(fields)
}
})
} 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'].includes(actionType)) {
return
}
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
return
}
// 保存草稿直接处理,不显示对话
if (actionType === 'save_draft') {
await handleSaveDraftDirectly(message)
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: '正在进入下一步...',
extraContext: {
review_action: actionType,
review_form_values: buildReviewFormValues(fields),
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
}
})
} finally {
reviewActionBusy.value = false
}
}
// 新增:直接保存草稿的函数,不显示对话
async function handleSaveDraftDirectly(message) {
reviewActionBusy.value = true
// 记录当前消息数量,用于后续移除 submitComposer 添加的消息
const messageCountBefore = messages.value.length
try {
const baseFields = reviewInlineBaseFields.value.length
? reviewInlineBaseFields.value
: cloneReviewEditFields(message?.reviewPayload?.edit_fields)
const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value)
// 先显示一个临时的"正在保存"消息
const savingMessage = createMessage('assistant', '正在保存草稿...', [], { meta: ['处理中'] })
messages.value.push(savingMessage)
nextTick(scrollToBottom)
// 调用保存逻辑,不通过对话
const payload = await submitComposer({
rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。',
userText: '', // 不显示用户消息
skipUserMessage: true, // 跳过添加用户消息
files: reviewInlinePendingFiles.value,
pendingText: '正在保存当前草稿...',
extraContext: {
review_action: 'save_draft',
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', `✅ 草稿已保存,单号:${payload.result.draft_payload.claim_no}`, [], {
meta: ['草稿已保存']
})
)
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', '草稿保存完成', [], { meta: ['草稿已保存'] }))
}
nextTick(scrollToBottom)
} catch (error) {
// 移除临时消息
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,
aiAvatar,
userAvatar,
fileInputRef,
composerTextareaRef,
messageListRef,
composerDraft,
attachedFiles,
composerFilesExpanded,
visibleAttachedFiles,
hiddenAttachedFileCount,
submitting,
sessionSwitchBusy,
messages,
currentInsight,
linkedRequest,
canSubmit,
activeSessionType,
isKnowledgeSession,
hotKnowledgeQuestions,
hasInsightPanelContent,
showInsightPanel,
insightPanelToggleLabel,
composerPlaceholder,
currentIntentLabel,
canDeleteCurrentSession,
latestReviewMessage,
activeReviewPayload,
activeReviewFilePreviews,
reviewDrawerMode,
isReviewDocumentDrawer,
isReviewRiskDrawer,
reviewDrawerTitle,
reviewDocumentDrawerAvailable,
reviewRiskDrawerAvailable,
reviewDocumentDrawerLabel,
reviewDocumentDrawerIcon,
reviewRiskDrawerLabel,
reviewRiskDrawerIcon,
activeReviewDocument,
activeReviewDocumentIndex,
activeReviewDocumentPreview,
canPreviewActiveReviewDocument,
reviewIntentText,
reviewFactCards,
reviewCategoryOptions,
reviewOtherCategoryOptions,
reviewSelectedOtherCategory,
reviewInlineDirty,
reviewInlineForm,
reviewInlineEditorKey,
reviewInlineErrors,
reviewOtherCategoryOpen,
reviewInlinePendingFiles,
DATE_INPUT_FORMAT,
REVIEW_SCENE_OPTIONS,
REVIEW_OTHER_CATEGORY_OPTIONS,
reviewPanelConfidence,
reviewRiskScore,
reviewRiskSummary,
reviewRiskItems,
reviewRiskEmpty,
reviewRiskActionAvailable,
recognizedNarratives,
reviewRecognitionNotes,
reviewDocumentSummaries,
reviewDocumentCount,
reviewDocumentDirty,
reviewHasUnsavedChanges,
reviewCancelDialogOpen,
reviewEditDialogOpen,
deleteSessionDialogOpen,
reviewActionBusy,
deleteSessionBusy,
reviewEditFields,
documentPreviewDialog,
shortcuts,
resolveReviewMissingSlotCards,
resolveReviewRiskBriefs,
buildReviewHeadline,
buildReviewSubline,
buildReviewStateLabel,
buildReviewStateTone,
buildReviewDisclosureTitle,
buildReviewDisclosureHint,
shouldOpenReviewDisclosure,
buildReviewTodoSectionTitle,
buildReviewTodoSectionMeta,
buildReviewAlertChips,
buildReviewTodoItems,
resolveReviewPrimaryAction,
resolveReviewEditAction,
buildReviewPrimaryButtonLabel,
buildReviewDecisionHint,
buildReviewMissingHint,
buildReviewRiskHint,
buildReviewActionHint,
buildReviewStatusTag,
buildExpenseQueryWindowLabel,
buildExpenseQueryHint,
getExpenseQueryActivePage,
getExpenseQueryTotalPages,
getExpenseQueryVisibleRecords,
resolveDocumentPreview,
triggerFileUpload,
handleFilesChange,
handleComposerInput,
runShortcut,
askHotKnowledgeQuestion,
resolveKnowledgeRankLabel,
resolveKnowledgeRankTone,
toggleInsightPanel,
toggleReviewDocumentDrawer,
toggleReviewRiskDrawer,
toggleAttachedFilesExpanded,
removeAttachedFile,
clearAttachedFiles,
requestCloseWorkbench,
openExpenseQueryRecord,
setExpenseQueryPage,
shiftExpenseQueryPage,
openDeleteSessionDialog,
closeDeleteSessionDialog,
confirmDeleteCurrentSession,
openInlineReviewEditor,
closeInlineReviewEditor,
commitInlineReviewEditor,
clearInlineReviewFieldError,
selectInlineScene,
selectReviewCategory,
selectReviewOtherCategory,
queryDraftByClaimNo,
explainCurrentReviewRisk,
goReviewDocument,
openActiveReviewDocumentPreview,
closeDocumentPreview,
saveInlineReviewChanges,
submitComposer,
handleReviewAction,
handleSaveDraftDirectly,
closeCancelReviewDialog,
confirmCancelReview,
closeEditReviewDialog,
applyEditedReview
}
}
}