2026-05-12 15:16:18 +00:00
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
2026-05-12 01:27:49 +00:00
|
|
|
|
|
|
|
|
|
|
import { useSystemState } from '../../composables/useSystemState.js'
|
2026-05-12 03:05:51 +00:00
|
|
|
|
import { recognizeOcrFiles } from '../../services/ocr.js'
|
2026-05-12 01:27:49 +00:00
|
|
|
|
import { runOrchestrator } from '../../services/orchestrator.js'
|
|
|
|
|
|
|
2026-05-12 07:25:45 +00:00
|
|
|
|
const aiAvatar = '/assets/header.png'
|
|
|
|
|
|
const userAvatar = '/assets/person.png'
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
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: '动作请求'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 15:16:18 +00:00
|
|
|
|
const DOCUMENT_TYPE_LABELS = {
|
|
|
|
|
|
travel_ticket: '行程单/机票/车票',
|
|
|
|
|
|
hotel_invoice: '酒店住宿票据',
|
|
|
|
|
|
transport_receipt: '交通出行票据',
|
|
|
|
|
|
meal_receipt: '餐饮票据',
|
|
|
|
|
|
other: '其他单据'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const EXPENSE_TYPE_LABELS = {
|
|
|
|
|
|
travel: '差旅费',
|
|
|
|
|
|
hotel: '住宿费',
|
|
|
|
|
|
transport: '交通费',
|
|
|
|
|
|
meal: '伙食费',
|
|
|
|
|
|
meeting: '会务费',
|
|
|
|
|
|
entertainment: '业务招待费',
|
|
|
|
|
|
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: '请确认费用发生日期',
|
|
|
|
|
|
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', 'entertainment']
|
|
|
|
|
|
|
|
|
|
|
|
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 = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', '其他场景']
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
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: [],
|
|
|
|
|
|
draftPayload: null,
|
2026-05-12 06:40:19 +00:00
|
|
|
|
reviewPayload: null,
|
2026-05-12 01:27:49 +00:00
|
|
|
|
riskFlags: [],
|
|
|
|
|
|
...extras
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
2026-05-12 01:27:49 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 06:40:19 +00:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
function sanitizeRequest(request) {
|
2026-05-12 07:22:11 +00:00
|
|
|
|
if (!request || typeof request !== 'object') return null
|
|
|
|
|
|
|
|
|
|
|
|
const normalized = {
|
|
|
|
|
|
id: String(request.id || '').trim(),
|
|
|
|
|
|
reason: String(request.reason || request.title || '').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()
|
2026-05-12 01:27:49 +00:00
|
|
|
|
}
|
2026-05-12 07:22:11 +00:00
|
|
|
|
|
|
|
|
|
|
return Object.values(normalized).some(Boolean) ? normalized : null
|
2026-05-12 01:27:49 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 06:40:19 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 03:05:51 +00:00
|
|
|
|
function normalizeOcrDocuments(payload) {
|
|
|
|
|
|
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
|
|
|
|
|
return documents.slice(0, 5).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),
|
|
|
|
|
|
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(';')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 06:40:19 +00:00
|
|
|
|
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 (kind !== 'image') {
|
|
|
|
|
|
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
|
|
|
|
|
|
return filePreviews.find((item) => item.filename === filename) ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
function buildWelcomeInsight(entrySource, linkedRequest) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
intent: 'welcome',
|
2026-05-12 07:22:11 +00:00
|
|
|
|
metricLabel: '当前状态',
|
|
|
|
|
|
metricValue: '待识别',
|
|
|
|
|
|
title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '等待识别内容',
|
2026-05-12 01:27:49 +00:00
|
|
|
|
summary:
|
2026-05-12 07:22:11 +00:00
|
|
|
|
entrySource === 'detail' && linkedRequest?.id
|
2026-05-12 15:16:18 +00:00
|
|
|
|
? '发送消息后会直接结合当前单据上下文识别报销语义,右侧展示已识别内容,主对话区展示待补项和风险提示。'
|
|
|
|
|
|
: '请输入费用场景或上传票据,右侧会展示已识别内容,主对话区会提示待补信息和风险注意事项。',
|
2026-05-12 01:27:49 +00:00
|
|
|
|
agent: null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 07:22:11 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 06:40:19 +00:00
|
|
|
|
function resolveInitialConversationId(conversation) {
|
|
|
|
|
|
return String(conversation?.conversation_id || conversation?.conversationId || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveInitialDraftClaimId(conversation) {
|
|
|
|
|
|
return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeInitialConversationMessages(conversation) {
|
|
|
|
|
|
const rawMessages = Array.isArray(conversation?.messages) ? 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
|
|
|
|
|
|
: [],
|
|
|
|
|
|
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 : []
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 07:22:11 +00:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 15:16:18 +00:00
|
|
|
|
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: '',
|
|
|
|
|
|
attachment_names: '',
|
|
|
|
|
|
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 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 formatAmountDisplay(value) {
|
|
|
|
|
|
const normalized = String(value || '').trim()
|
|
|
|
|
|
const match = normalized.match(/^(\d+(?:\.\d+)?)元$/)
|
|
|
|
|
|
if (!match) return normalized
|
|
|
|
|
|
|
|
|
|
|
|
const amount = Number(match[1])
|
|
|
|
|
|
if (!Number.isFinite(amount)) return normalized
|
|
|
|
|
|
return Number.isInteger(amount) ? `${amount}元` : `${amount.toFixed(2).replace(/\.?0+$/, '')}元`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildReviewHeadline(reviewPayload, draftPayload) {
|
|
|
|
|
|
const claimNo = String(draftPayload?.claim_no || '').trim()
|
|
|
|
|
|
if (claimNo) {
|
|
|
|
|
|
return `已为你创建报销草稿 ${claimNo}`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (reviewPayload?.can_proceed) {
|
|
|
|
|
|
return '已为你整理好本次报销信息'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '已为你整理报销草稿信息'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildReviewSubline(reviewPayload, draftPayload) {
|
|
|
|
|
|
const claimNo = String(draftPayload?.claim_no || '').trim()
|
|
|
|
|
|
if (claimNo) {
|
|
|
|
|
|
return `草稿已保存为 draft,你可以继续补充费用明细、客户单位和票据附件。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (reviewPayload?.can_proceed) {
|
|
|
|
|
|
return '当前关键信息基本齐全,核对无误后可以继续下一步处理。'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '当前已识别的信息已经整理完成,你可以继续补充缺失项,或者先保存草稿。'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildReviewStateLabel(reviewPayload, draftPayload) {
|
|
|
|
|
|
if (draftPayload?.claim_no) return '草稿已创建'
|
|
|
|
|
|
if (reviewPayload?.can_proceed) return '可继续处理'
|
|
|
|
|
|
return '待补充'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildReviewStateTone(reviewPayload, draftPayload) {
|
|
|
|
|
|
return reviewPayload?.can_proceed || draftPayload?.claim_no ? 'ready' : 'pending'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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: 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(),
|
|
|
|
|
|
attachment_names: attachmentNames,
|
|
|
|
|
|
attachment_count: attachmentCount,
|
|
|
|
|
|
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 buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
|
|
|
|
|
const attachmentStatus =
|
|
|
|
|
|
inlineState.attachment_count > 0
|
|
|
|
|
|
? `待保存 ${inlineState.attachment_count} 份`
|
|
|
|
|
|
: buildReviewAttachmentStatus(reviewPayload)
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'occurred_date',
|
|
|
|
|
|
label: '发生时间',
|
|
|
|
|
|
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
|
|
|
|
|
icon: 'mdi mdi-calendar-month-outline',
|
|
|
|
|
|
editor: 'date'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'amount',
|
|
|
|
|
|
label: '金额',
|
|
|
|
|
|
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
|
|
|
|
|
icon: 'mdi mdi-cash',
|
|
|
|
|
|
editor: 'text'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'scene',
|
|
|
|
|
|
label: '场景',
|
|
|
|
|
|
value: String(inlineState.scene_label || '').trim() || '待补充',
|
|
|
|
|
|
icon: 'mdi mdi-silverware-fork-knife',
|
|
|
|
|
|
editor: 'select'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'customer_name',
|
|
|
|
|
|
label: '关联客户',
|
|
|
|
|
|
value: String(inlineState.customer_name || '').trim() || '待补充',
|
|
|
|
|
|
icon: 'mdi mdi-domain',
|
|
|
|
|
|
editor: 'text'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'attachments',
|
|
|
|
|
|
label: '票据状态',
|
|
|
|
|
|
value: attachmentStatus,
|
|
|
|
|
|
icon: 'mdi mdi-file-document-outline',
|
|
|
|
|
|
editor: 'upload'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildReviewCategoryOptions(selectedLabel = '') {
|
|
|
|
|
|
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,
|
|
|
|
|
|
caption: index === 0 ? '常用' : index < 5 ? '常用' : '更多'
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildReviewPanelConfidence(reviewPayload) {
|
|
|
|
|
|
const recognized = resolveReviewRecognizedSlotCards(reviewPayload).filter((item) =>
|
|
|
|
|
|
['expense_type', 'time_range', 'amount', 'customer_name', 'attachments'].includes(item.key)
|
|
|
|
|
|
)
|
|
|
|
|
|
if (!recognized.length) return '0%'
|
|
|
|
|
|
const average = recognized.reduce((sum, item) => sum + Number(item.confidence || 0), 0) / recognized.length
|
|
|
|
|
|
return formatConfidenceLabel(average)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildReviewRiskScore(reviewPayload) {
|
|
|
|
|
|
const missingCount = resolveReviewMissingSlotCards(reviewPayload).length
|
|
|
|
|
|
const riskPenalty = resolveReviewRiskBriefs(reviewPayload).reduce((sum, item) => {
|
|
|
|
|
|
if (item.level === 'high') return sum + 10
|
|
|
|
|
|
if (item.level === 'warning') return sum + 6
|
|
|
|
|
|
return sum + 3
|
|
|
|
|
|
}, 0)
|
|
|
|
|
|
const score = 92 - missingCount * 9 - riskPenalty
|
|
|
|
|
|
return Math.max(28, Math.min(98, 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 (resolveReviewMissingSlotCards(reviewPayload).length) {
|
|
|
|
|
|
return '存在一定合规风险,请尽快补充完整信息以降低风险。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (resolveReviewRiskBriefs(reviewPayload).length) {
|
|
|
|
|
|
return '当前识别结果可继续处理,但提交前仍建议核对以下提醒。'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '当前未发现明显阻断项,确认无误后可以继续下一步。'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildReviewRiskItems(reviewPayload) {
|
|
|
|
|
|
const slotMap = buildReviewSlotMap(reviewPayload)
|
|
|
|
|
|
const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim()
|
|
|
|
|
|
const items = []
|
|
|
|
|
|
|
|
|
|
|
|
for (const slot of resolveReviewMissingSlotCards(reviewPayload)) {
|
|
|
|
|
|
items.push(buildMissingRiskLine(slot.key, expenseTypeLabel))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const brief of resolveReviewRiskBriefs(reviewPayload)) {
|
|
|
|
|
|
if (items.includes(brief.content)) continue
|
|
|
|
|
|
items.push(brief.content)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return items.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(),
|
|
|
|
|
|
attachment_names: String(source.attachment_names || '').trim(),
|
|
|
|
|
|
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.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 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,
|
|
|
|
|
|
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 '待确认'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
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: [],
|
|
|
|
|
|
draftPayload: null,
|
2026-05-12 06:40:19 +00:00
|
|
|
|
reviewPayload: null,
|
2026-05-12 01:27:49 +00:00
|
|
|
|
riskFlags: [],
|
|
|
|
|
|
toolCount: 0,
|
|
|
|
|
|
failedToolCount: 0,
|
|
|
|
|
|
selectedCapabilityCodes: [],
|
2026-05-12 06:40:19 +00:00
|
|
|
|
filePreviews: [],
|
2026-05-12 01:27:49 +00:00
|
|
|
|
statusLabel: '失败',
|
|
|
|
|
|
statusTone: 'note'
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
2026-05-12 01:27:49 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-12 06:40:19 +00:00
|
|
|
|
function buildAgentInsight(payload, fileNames = [], filePreviews = []) {
|
2026-05-12 01:27:49 +00:00
|
|
|
|
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 : [],
|
|
|
|
|
|
draftPayload: result?.draft_payload || null,
|
2026-05-12 06:40:19 +00:00
|
|
|
|
reviewPayload: result?.review_payload || null,
|
2026-05-12 01:27:49 +00:00
|
|
|
|
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
|
|
|
|
|
|
: [],
|
2026-05-12 06:40:19 +00:00
|
|
|
|
filePreviews,
|
2026-05-12 01:27:49 +00:00
|
|
|
|
statusLabel,
|
|
|
|
|
|
statusTone: resolveStatusTone(payload?.status)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
2026-05-12 01:27:49 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
export default {
|
|
|
|
|
|
name: 'TravelReimbursementCreateView',
|
|
|
|
|
|
props: {
|
|
|
|
|
|
initialPrompt: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: ''
|
|
|
|
|
|
},
|
|
|
|
|
|
initialFiles: {
|
|
|
|
|
|
type: Array,
|
|
|
|
|
|
default: () => []
|
|
|
|
|
|
},
|
2026-05-12 06:40:19 +00:00
|
|
|
|
initialConversation: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
default: null
|
|
|
|
|
|
},
|
2026-05-12 01:27:49 +00:00
|
|
|
|
entrySource: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: 'requests'
|
|
|
|
|
|
},
|
|
|
|
|
|
requestContext: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
default: null
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
emits: ['close'],
|
|
|
|
|
|
setup(props, { emit }) {
|
|
|
|
|
|
const { currentUser } = useSystemState()
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
|
|
|
|
|
const fileInputRef = ref(null)
|
2026-05-12 15:16:18 +00:00
|
|
|
|
const fileInputMode = ref('composer')
|
2026-05-06 11:00:38 +08:00
|
|
|
|
const messageListRef = ref(null)
|
|
|
|
|
|
const composerDraft = ref('')
|
|
|
|
|
|
const attachedFiles = ref([])
|
2026-05-12 01:27:49 +00:00
|
|
|
|
const submitting = ref(false)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
|
2026-05-12 06:40:19 +00:00
|
|
|
|
const restoredMessages = normalizeInitialConversationMessages(props.initialConversation)
|
2026-05-12 07:22:11 +00:00
|
|
|
|
const initialInsight = buildInitialInsightFromConversation(props.initialConversation)
|
2026-05-12 06:40:19 +00:00
|
|
|
|
const messages = ref(
|
|
|
|
|
|
restoredMessages.length
|
|
|
|
|
|
? restoredMessages
|
|
|
|
|
|
: [
|
|
|
|
|
|
createMessage(
|
|
|
|
|
|
'assistant',
|
2026-05-12 07:22:11 +00:00
|
|
|
|
props.entrySource === 'detail' && linkedRequest.value?.id
|
|
|
|
|
|
? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。请描述费用场景或补充票据。`
|
2026-05-12 06:40:19 +00:00
|
|
|
|
: '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。'
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
const conversationId = ref(resolveInitialConversationId(props.initialConversation))
|
|
|
|
|
|
const draftClaimId = ref(resolveInitialDraftClaimId(props.initialConversation))
|
|
|
|
|
|
const previewRegistry = []
|
2026-05-12 01:27:49 +00:00
|
|
|
|
|
2026-05-12 07:22:11 +00:00
|
|
|
|
const currentInsight = ref(initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value))
|
|
|
|
|
|
const reviewCancelDialogOpen = ref(false)
|
|
|
|
|
|
const reviewEditDialogOpen = ref(false)
|
|
|
|
|
|
const reviewActionBusy = ref(false)
|
|
|
|
|
|
const reviewEditFields = ref([])
|
|
|
|
|
|
const reviewActionMessageId = ref('')
|
2026-05-12 15:16:18 +00:00
|
|
|
|
const reviewInlineForm = ref(createEmptyInlineReviewState())
|
|
|
|
|
|
const reviewInlineBaseForm = ref(createEmptyInlineReviewState())
|
|
|
|
|
|
const reviewInlineBaseFields = ref([])
|
|
|
|
|
|
const reviewInlinePendingFiles = ref([])
|
|
|
|
|
|
const reviewInlineEditorKey = ref('')
|
|
|
|
|
|
const reviewOtherCategoryOpen = ref(false)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台')
|
2026-05-12 01:27:49 +00:00
|
|
|
|
const canSubmit = computed(
|
|
|
|
|
|
() => !submitting.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length)
|
|
|
|
|
|
)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome')
|
|
|
|
|
|
const composerPlaceholder = computed(() => {
|
2026-05-12 07:22:11 +00:00
|
|
|
|
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
2026-05-12 01:27:49 +00:00
|
|
|
|
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
2026-05-12 01:27:49 +00:00
|
|
|
|
return '例如:查一下本周报销金额、解释酒店超标风险,或根据附件生成报销草稿。'
|
2026-05-06 11:00:38 +08:00
|
|
|
|
})
|
|
|
|
|
|
const currentIntentLabel = computed(() => {
|
|
|
|
|
|
const labels = {
|
|
|
|
|
|
welcome: '等待输入',
|
2026-05-12 01:27:49 +00:00
|
|
|
|
agent: '真实智能体'
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
return labels[currentInsight.value.intent] ?? 'AI 处理中'
|
|
|
|
|
|
})
|
2026-05-12 07:22:11 +00:00
|
|
|
|
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(
|
|
|
|
|
|
() => currentInsight.value.agent?.filePreviews || []
|
|
|
|
|
|
)
|
2026-05-12 15:16:18 +00:00
|
|
|
|
const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value))
|
|
|
|
|
|
const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value))
|
|
|
|
|
|
const reviewCategoryOptions = computed(() => buildReviewCategoryOptions(reviewInlineForm.value.expense_type))
|
|
|
|
|
|
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
|
2026-05-12 07:22:11 +00:00
|
|
|
|
)
|
2026-05-12 15:16:18 +00:00
|
|
|
|
const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value))
|
|
|
|
|
|
const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value))
|
|
|
|
|
|
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
|
|
|
|
|
|
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
|
|
|
|
|
|
const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value))
|
|
|
|
|
|
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
|
|
|
|
|
|
const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value))
|
|
|
|
|
|
|
|
|
|
|
|
const shortcuts = computed(() => [
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '快速生成报销草稿',
|
|
|
|
|
|
icon: 'mdi mdi-file-document-edit-outline',
|
|
|
|
|
|
prompt:
|
|
|
|
|
|
props.entrySource === 'detail' && linkedRequest.value?.id
|
|
|
|
|
|
? `请基于当前关联单据 ${linkedRequest.value.id} 快速生成报销草稿`
|
|
|
|
|
|
: '帮我快速生成一份报销草稿'
|
2026-05-12 01:27:49 +00:00
|
|
|
|
}
|
2026-05-12 15:16:18 +00:00
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => activeReviewPayload.value,
|
|
|
|
|
|
(payload) => {
|
|
|
|
|
|
const nextInlineState = buildInlineReviewState(payload)
|
|
|
|
|
|
reviewInlineForm.value = { ...nextInlineState }
|
|
|
|
|
|
reviewInlineBaseForm.value = { ...nextInlineState }
|
|
|
|
|
|
reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields)
|
|
|
|
|
|
reviewInlinePendingFiles.value = []
|
|
|
|
|
|
reviewInlineEditorKey.value = ''
|
|
|
|
|
|
reviewOtherCategoryOpen.value = false
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
|
)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
2026-05-12 07:22:11 +00:00
|
|
|
|
currentInsight.value = initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value)
|
2026-05-12 01:27:49 +00:00
|
|
|
|
if (props.initialPrompt?.trim() || props.initialFiles.length) {
|
2026-05-06 11:00:38 +08:00
|
|
|
|
composerDraft.value = props.initialPrompt.trim()
|
2026-05-12 01:27:49 +00:00
|
|
|
|
attachedFiles.value = Array.from(props.initialFiles)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
submitComposer()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
nextTick(scrollToBottom)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-12 06:40:19 +00:00
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
for (const url of previewRegistry) {
|
|
|
|
|
|
URL.revokeObjectURL(url)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
function scrollToBottom() {
|
|
|
|
|
|
if (!messageListRef.value) return
|
|
|
|
|
|
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
function replaceMessage(messageId, nextMessage) {
|
|
|
|
|
|
const index = messages.value.findIndex((item) => item.id === messageId)
|
|
|
|
|
|
if (index === -1) {
|
|
|
|
|
|
messages.value.push(nextMessage)
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
2026-05-12 01:27:49 +00:00
|
|
|
|
messages.value.splice(index, 1, nextMessage)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 15:16:18 +00:00
|
|
|
|
function triggerFileUpload(mode = 'composer') {
|
|
|
|
|
|
if (submitting.value || reviewActionBusy.value) return
|
|
|
|
|
|
fileInputMode.value = mode
|
2026-05-06 11:00:38 +08:00
|
|
|
|
fileInputRef.value?.click()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleFilesChange(event) {
|
2026-05-12 15:16:18 +00:00
|
|
|
|
const files = Array.from(event.target.files ?? [])
|
|
|
|
|
|
|
|
|
|
|
|
if (fileInputMode.value === 'inline-review' && activeReviewPayload.value) {
|
|
|
|
|
|
reviewInlinePendingFiles.value = files
|
|
|
|
|
|
reviewInlineForm.value = {
|
|
|
|
|
|
...reviewInlineForm.value,
|
|
|
|
|
|
attachment_names: files.map((file) => file.name).join('、'),
|
|
|
|
|
|
attachment_count: files.length
|
|
|
|
|
|
}
|
|
|
|
|
|
reviewInlineEditorKey.value = ''
|
|
|
|
|
|
} else {
|
|
|
|
|
|
attachedFiles.value = files
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fileInputMode.value = 'composer'
|
|
|
|
|
|
if (fileInputRef.value) {
|
|
|
|
|
|
fileInputRef.value.value = ''
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function runShortcut(prompt) {
|
|
|
|
|
|
composerDraft.value = prompt
|
|
|
|
|
|
submitComposer()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 15:16:18 +00:00
|
|
|
|
function openInlineReviewEditor(key) {
|
|
|
|
|
|
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
|
|
|
|
|
|
if (key === 'attachments') {
|
|
|
|
|
|
triggerFileUpload('inline-review')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
reviewInlineEditorKey.value = reviewInlineEditorKey.value === key ? '' : key
|
|
|
|
|
|
if (key !== 'expense_type') {
|
|
|
|
|
|
reviewOtherCategoryOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeInlineReviewEditor() {
|
|
|
|
|
|
reviewInlineEditorKey.value = ''
|
|
|
|
|
|
reviewOtherCategoryOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function commitInlineReviewEditor() {
|
|
|
|
|
|
reviewInlineForm.value = {
|
|
|
|
|
|
...reviewInlineForm.value,
|
|
|
|
|
|
occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(),
|
|
|
|
|
|
amount: String(reviewInlineForm.value.amount || '').trim(),
|
|
|
|
|
|
customer_name: String(reviewInlineForm.value.customer_name || '').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()
|
|
|
|
|
|
}
|
|
|
|
|
|
reviewInlineEditorKey.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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: '查看全部风险项'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveInlineReviewChanges() {
|
|
|
|
|
|
if (!activeReviewPayload.value || !reviewInlineDirty.value || reviewActionBusy.value) return
|
|
|
|
|
|
|
|
|
|
|
|
reviewActionBusy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
|
|
|
|
|
|
await submitComposer({
|
|
|
|
|
|
rawText: buildReviewCorrectionMessage(fields),
|
|
|
|
|
|
userText: buildInlineReviewUserText(
|
|
|
|
|
|
reviewInlineBaseForm.value,
|
|
|
|
|
|
reviewInlineForm.value,
|
|
|
|
|
|
reviewInlinePendingFiles.value
|
|
|
|
|
|
),
|
|
|
|
|
|
pendingText: '正在保存修改并刷新右侧核对信息...',
|
|
|
|
|
|
files: reviewInlinePendingFiles.value,
|
|
|
|
|
|
extraContext: {
|
|
|
|
|
|
review_action: 'edit_review',
|
|
|
|
|
|
review_form_values: buildReviewFormValues(fields)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
reviewActionBusy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 03:05:51 +00:00
|
|
|
|
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
|
2026-05-12 01:27:49 +00:00
|
|
|
|
const parts = []
|
|
|
|
|
|
const normalizedText = String(rawText || '').trim()
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
if (normalizedText) {
|
|
|
|
|
|
parts.push(normalizedText)
|
|
|
|
|
|
} else if (fileNames.length) {
|
|
|
|
|
|
parts.push(`我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并尽量生成草稿。`)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
if (fileNames.length) {
|
|
|
|
|
|
parts.push(`附件名称:${fileNames.join('、')}`)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 03:05:51 +00:00
|
|
|
|
if (ocrSummary) {
|
|
|
|
|
|
parts.push(`OCR摘要:${ocrSummary}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 07:22:11 +00:00
|
|
|
|
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
2026-05-12 01:27:49 +00:00
|
|
|
|
parts.push(`关联单号:${linkedRequest.value.id}`)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
return parts.join('\n')
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 07:22:11 +00:00
|
|
|
|
async function submitComposer(options = {}) {
|
|
|
|
|
|
const rawText = String(options.rawText ?? composerDraft.value).trim()
|
|
|
|
|
|
const files = Array.from(options.files ?? attachedFiles.value)
|
|
|
|
|
|
if (!rawText && !files.length) return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
const fileNames = files.map((file) => file.name)
|
2026-05-12 06:40:19 +00:00
|
|
|
|
const filePreviews = buildFilePreviews(files, previewRegistry)
|
2026-05-12 01:27:49 +00:00
|
|
|
|
const userText =
|
2026-05-12 07:22:11 +00:00
|
|
|
|
String(options.userText || '').trim() ||
|
|
|
|
|
|
rawText ||
|
|
|
|
|
|
`我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`
|
|
|
|
|
|
const extraContext = options.extraContext && typeof options.extraContext === 'object'
|
|
|
|
|
|
? options.extraContext
|
|
|
|
|
|
: {}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
messages.value.push(createMessage('user', userText, fileNames))
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-12 07:22:11 +00:00
|
|
|
|
const pendingMessage = createMessage('assistant', options.pendingText || '正在识别并更新右侧核对信息...', [], {
|
|
|
|
|
|
meta: ['处理中']
|
2026-05-12 01:27:49 +00:00
|
|
|
|
})
|
|
|
|
|
|
messages.value.push(pendingMessage)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
composerDraft.value = ''
|
|
|
|
|
|
attachedFiles.value = []
|
|
|
|
|
|
if (fileInputRef.value) {
|
|
|
|
|
|
fileInputRef.value.value = ''
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
submitting.value = true
|
|
|
|
|
|
nextTick(scrollToBottom)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const user = currentUser.value || {}
|
2026-05-12 03:05:51 +00:00
|
|
|
|
let ocrPayload = null
|
|
|
|
|
|
let ocrSummary = ''
|
|
|
|
|
|
let ocrDocuments = []
|
|
|
|
|
|
|
|
|
|
|
|
if (files.length) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
ocrPayload = await recognizeOcrFiles(files)
|
|
|
|
|
|
ocrSummary = buildOcrSummary(ocrPayload)
|
|
|
|
|
|
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn('OCR request failed:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const backendMessage = buildBackendMessage(rawText, fileNames, ocrSummary)
|
2026-05-12 01:27:49 +00:00
|
|
|
|
const payload = await runOrchestrator({
|
|
|
|
|
|
source: 'user_message',
|
|
|
|
|
|
user_id: user.username || user.name || 'anonymous',
|
2026-05-12 06:40:19 +00:00
|
|
|
|
conversation_id: conversationId.value || null,
|
2026-05-12 01:27:49 +00:00
|
|
|
|
message: backendMessage,
|
|
|
|
|
|
context_json: {
|
|
|
|
|
|
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
|
|
|
|
|
|
is_admin: Boolean(user.isAdmin),
|
|
|
|
|
|
name: user.name || '',
|
|
|
|
|
|
role: user.role || '',
|
2026-05-12 15:16:18 +00:00
|
|
|
|
...buildClientTimeContext(),
|
2026-05-12 01:27:49 +00:00
|
|
|
|
entry_source: props.entrySource,
|
|
|
|
|
|
attachment_names: fileNames,
|
2026-05-12 03:05:51 +00:00
|
|
|
|
attachment_count: fileNames.length,
|
2026-05-12 06:40:19 +00:00
|
|
|
|
draft_claim_id: draftClaimId.value || undefined,
|
2026-05-12 03:05:51 +00:00
|
|
|
|
ocr_summary: ocrSummary,
|
2026-05-12 07:22:11 +00:00
|
|
|
|
ocr_documents: ocrDocuments,
|
|
|
|
|
|
...(linkedRequest.value ? { request_context: linkedRequest.value } : {}),
|
|
|
|
|
|
...extraContext
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-12 06:40:19 +00:00
|
|
|
|
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
|
|
|
|
|
|
draftClaimId.value =
|
|
|
|
|
|
String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
|
|
|
|
|
|
2026-05-12 01:27:49 +00:00
|
|
|
|
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
|
|
|
|
|
|
: [],
|
|
|
|
|
|
draftPayload: payload?.result?.draft_payload || null,
|
2026-05-12 06:40:19 +00:00
|
|
|
|
reviewPayload: payload?.result?.review_payload || null,
|
2026-05-12 01:27:49 +00:00
|
|
|
|
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
2026-05-12 06:40:19 +00:00
|
|
|
|
currentInsight.value = buildAgentInsight(payload, fileNames, filePreviews)
|
2026-05-12 01:27:49 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
replaceMessage(
|
|
|
|
|
|
pendingMessage.id,
|
|
|
|
|
|
createMessage(
|
|
|
|
|
|
'assistant',
|
|
|
|
|
|
error?.message || '无法连接后端 Orchestrator,请稍后重试。',
|
|
|
|
|
|
[],
|
|
|
|
|
|
{
|
|
|
|
|
|
meta: ['调用失败']
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
currentInsight.value = buildErrorInsight(error, fileNames)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
submitting.value = false
|
|
|
|
|
|
nextTick(scrollToBottom)
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 07:22:11 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
reviewActionBusy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const fields = cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
|
|
|
|
|
await submitComposer({
|
|
|
|
|
|
rawText:
|
|
|
|
|
|
actionType === 'save_draft'
|
|
|
|
|
|
? '请按当前已识别信息先保存草稿,缺失字段后续再补。'
|
|
|
|
|
|
: '我已核对右侧识别结果,请进入下一步。',
|
|
|
|
|
|
userText: actionType === 'save_draft' ? '我先按当前信息保存草稿。' : '我确认当前识别结果,继续下一步。',
|
|
|
|
|
|
pendingText: actionType === 'save_draft' ? '正在保存当前草稿...' : '正在进入下一步...',
|
|
|
|
|
|
extraContext: {
|
|
|
|
|
|
review_action: actionType,
|
|
|
|
|
|
review_form_values: buildReviewFormValues(fields)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
reviewActionBusy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 11:00:38 +08:00
|
|
|
|
return {
|
|
|
|
|
|
emit,
|
2026-05-12 07:22:11 +00:00
|
|
|
|
aiAvatar,
|
|
|
|
|
|
userAvatar,
|
2026-05-06 11:00:38 +08:00
|
|
|
|
fileInputRef,
|
|
|
|
|
|
messageListRef,
|
|
|
|
|
|
composerDraft,
|
|
|
|
|
|
attachedFiles,
|
2026-05-12 01:27:49 +00:00
|
|
|
|
submitting,
|
2026-05-06 11:00:38 +08:00
|
|
|
|
messages,
|
|
|
|
|
|
currentInsight,
|
|
|
|
|
|
linkedRequest,
|
|
|
|
|
|
sourceLabel,
|
|
|
|
|
|
canSubmit,
|
|
|
|
|
|
showInsightPanel,
|
|
|
|
|
|
composerPlaceholder,
|
|
|
|
|
|
currentIntentLabel,
|
2026-05-12 07:22:11 +00:00
|
|
|
|
latestReviewMessage,
|
|
|
|
|
|
activeReviewPayload,
|
|
|
|
|
|
activeReviewFilePreviews,
|
2026-05-12 15:16:18 +00:00
|
|
|
|
reviewIntentText,
|
|
|
|
|
|
reviewFactCards,
|
|
|
|
|
|
reviewCategoryOptions,
|
|
|
|
|
|
reviewSelectedOtherCategory,
|
|
|
|
|
|
reviewInlineDirty,
|
|
|
|
|
|
reviewInlineForm,
|
|
|
|
|
|
reviewInlineEditorKey,
|
|
|
|
|
|
reviewOtherCategoryOpen,
|
|
|
|
|
|
reviewInlinePendingFiles,
|
|
|
|
|
|
REVIEW_SCENE_OPTIONS,
|
|
|
|
|
|
REVIEW_OTHER_CATEGORY_OPTIONS,
|
|
|
|
|
|
reviewPanelConfidence,
|
|
|
|
|
|
reviewRiskScore,
|
|
|
|
|
|
reviewRiskSummary,
|
|
|
|
|
|
reviewRiskItems,
|
|
|
|
|
|
recognizedNarratives,
|
|
|
|
|
|
reviewRecognitionNotes,
|
|
|
|
|
|
reviewDocumentSummaries,
|
2026-05-12 07:22:11 +00:00
|
|
|
|
reviewCancelDialogOpen,
|
|
|
|
|
|
reviewEditDialogOpen,
|
|
|
|
|
|
reviewActionBusy,
|
|
|
|
|
|
reviewEditFields,
|
2026-05-06 11:00:38 +08:00
|
|
|
|
shortcuts,
|
2026-05-12 15:16:18 +00:00
|
|
|
|
resolveReviewMissingSlotCards,
|
|
|
|
|
|
resolveReviewRiskBriefs,
|
|
|
|
|
|
buildReviewHeadline,
|
|
|
|
|
|
buildReviewSubline,
|
|
|
|
|
|
buildReviewStateLabel,
|
|
|
|
|
|
buildReviewStateTone,
|
|
|
|
|
|
buildReviewAlertChips,
|
|
|
|
|
|
buildReviewTodoItems,
|
|
|
|
|
|
resolveReviewPrimaryAction,
|
|
|
|
|
|
resolveReviewEditAction,
|
|
|
|
|
|
buildReviewPrimaryButtonLabel,
|
|
|
|
|
|
buildReviewDecisionHint,
|
|
|
|
|
|
buildReviewMissingHint,
|
|
|
|
|
|
buildReviewRiskHint,
|
|
|
|
|
|
buildReviewActionHint,
|
|
|
|
|
|
buildReviewStatusTag,
|
2026-05-12 06:40:19 +00:00
|
|
|
|
resolveDocumentPreview,
|
2026-05-06 11:00:38 +08:00
|
|
|
|
triggerFileUpload,
|
|
|
|
|
|
handleFilesChange,
|
|
|
|
|
|
runShortcut,
|
2026-05-12 15:16:18 +00:00
|
|
|
|
openInlineReviewEditor,
|
|
|
|
|
|
closeInlineReviewEditor,
|
|
|
|
|
|
commitInlineReviewEditor,
|
|
|
|
|
|
selectInlineScene,
|
|
|
|
|
|
selectReviewCategory,
|
|
|
|
|
|
selectReviewOtherCategory,
|
|
|
|
|
|
queryDraftByClaimNo,
|
|
|
|
|
|
explainCurrentReviewRisk,
|
|
|
|
|
|
saveInlineReviewChanges,
|
2026-05-12 07:22:11 +00:00
|
|
|
|
submitComposer,
|
|
|
|
|
|
handleReviewAction,
|
|
|
|
|
|
closeCancelReviewDialog,
|
|
|
|
|
|
confirmCancelReview,
|
|
|
|
|
|
closeEditReviewDialog,
|
|
|
|
|
|
applyEditedReview
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|