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