import { computed, nextTick, onMounted, ref } from 'vue' export default { name: 'TravelReimbursementCreateView', props: { initialPrompt: { type: String, default: '' }, entrySource: { type: String, default: 'requests' }, requestContext: { type: Object, default: null } }, emits: ['close'] , setup(props, { emit }) { const DEFAULT_REQUEST = { id: 'BR240712001', reason: '客户方案汇报', city: '上海', period: '07-08 ~ 07-11', applyTime: '2024-07-07', amount: '¥3,680.00', node: '财务复核', approval: '主管审批中', travel: '已订酒店 / 机票' } const SOURCE_LABELS = { workbench: '来自个人工作台', topbar: '来自发起报销', detail: '来自智能录入', upload: '来自附件上传', requests: '来自报销列表' } let messageSeed = 0 const fileInputRef = ref(null) const messageListRef = ref(null) const composerDraft = ref('') const attachedFiles = ref([]) const messages = ref([]) const currentInsight = ref({ intent: 'welcome', confidence: 0, title: '', summary: '', welcome: { cards: [] } }) const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台') const canSubmit = computed(() => Boolean(composerDraft.value.trim() || attachedFiles.value.length)) const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome') const composerPlaceholder = computed(() => { if (props.entrySource === 'detail') { return `例如:帮我看一下 ${linkedRequest.value.id} 现在到哪个审批节点,或者补充超标说明。` } return '例如:帮我发起差旅报销、查一下审批节点,或者识别我刚上传的票据。' }) const currentIntentLabel = computed(() => { const labels = { welcome: '等待输入', draft: '报销草稿', approval: '审批查询', recognition: '单据识别', note: '补充说明' } return labels[currentInsight.value.intent] ?? 'AI 处理中' }) const shortcuts = computed(() => [ { label: '查审批节点', icon: 'mdi mdi-timeline-clock-outline', prompt: `帮我看一下 ${linkedRequest.value.id} 现在到哪个审批节点了` }, { label: '识别上传单据', icon: 'mdi mdi-file-search-outline', prompt: '我上传了几张票据,帮我识别并给出录入结果' }, { label: '补充报销说明', icon: 'mdi mdi-text-box-edit-outline', prompt: `帮我给 ${linkedRequest.value.id} 补一段费用说明` }, { label: '生成报销草稿', icon: 'mdi mdi-file-document-edit-outline', prompt: '我要发起一笔差旅费申请报销,请帮我先生成草稿' } ]) messages.value = [ createMessage( 'assistant', buildGreeting(), [] ) ] onMounted(() => { currentInsight.value = buildWelcomeInsight() if (props.initialPrompt?.trim()) { composerDraft.value = props.initialPrompt.trim() submitComposer() } else { nextTick(scrollToBottom) } }) function sanitizeRequest(request) { if (!request) return { ...DEFAULT_REQUEST } return { id: request.id ?? DEFAULT_REQUEST.id, reason: request.reason ?? DEFAULT_REQUEST.reason, city: request.city ?? DEFAULT_REQUEST.city, period: request.period ?? DEFAULT_REQUEST.period, applyTime: request.applyTime ?? DEFAULT_REQUEST.applyTime, amount: request.amount ?? DEFAULT_REQUEST.amount, node: request.node ?? DEFAULT_REQUEST.node, approval: request.approval ?? DEFAULT_REQUEST.approval, travel: request.travel ?? DEFAULT_REQUEST.travel } } function buildGreeting() { if (props.entrySource === 'detail') { return `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。你可以直接问审批节点、补充说明,或继续上传票据。` } return '这里是统一对话入口。你可以直接发起报销、查询审批节点,或者上传单据让我识别。' } function buildWelcomeInsight() { return { intent: 'welcome', confidence: 86, title: props.entrySource === 'detail' ? `已关联 ${linkedRequest.value.id}` : '先告诉我你要处理什么', summary: props.entrySource === 'detail' ? '右侧会跟随你的提问切换成审批状态、识别结果或补充说明界面。' : '无论是发起报销、查审批还是识别票据,这里都共用一个对话入口。', welcome: { cards: [ { icon: 'mdi mdi-timeline-clock-outline', title: '审批查询', desc: '识别到审批、节点、状态等意图时,右侧切到流程状态。' }, { icon: 'mdi mdi-file-search-outline', title: '票据识别', desc: '上传附件后展示识别结果、建议金额和缺失材料。' }, { icon: 'mdi mdi-text-box-check-outline', title: '补充说明', desc: '补充超标、夜间交通、业务招待等说明时,右侧给出结构化备注。' } ] } } } function createMessage(role, text, attachments = []) { messageSeed += 1 return { id: `msg-${messageSeed}`, role, text, attachments, time: nowTime() } } function nowTime() { return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } function triggerFileUpload() { fileInputRef.value?.click() } function handleFilesChange(event) { attachedFiles.value = Array.from(event.target.files ?? []) } function runShortcut(prompt) { composerDraft.value = prompt submitComposer() } function submitComposer() { if (!canSubmit.value) return const rawText = composerDraft.value.trim() const fileNames = attachedFiles.value.map((file) => file.name) const userText = rawText || `我上传了 ${fileNames.length} 份单据,请帮我识别并录入。` messages.value.push(createMessage('user', userText, fileNames)) const insight = analyzeIntent(userText, fileNames) currentInsight.value = insight messages.value.push(createMessage('assistant', insight.reply)) composerDraft.value = '' attachedFiles.value = [] if (fileInputRef.value) { fileInputRef.value.value = '' } nextTick(scrollToBottom) } function scrollToBottom() { if (!messageListRef.value) return messageListRef.value.scrollTop = messageListRef.value.scrollHeight } function analyzeIntent(text, files) { if (isRecognitionIntent(text, files)) return buildRecognitionInsight(text, files) if (isApprovalIntent(text)) return buildApprovalInsight(text) if (isNoteIntent(text)) return buildNoteInsight(text) return buildDraftInsight(text, files) } function isRecognitionIntent(text, files) { return files.length > 0 || /(上传|附件|票据|发票|单据|识别|ocr)/i.test(text) } function isApprovalIntent(text) { return /(审批|节点|状态|进度|流程|卡在哪|到哪了|通过了吗|驳回)/.test(text) } function isNoteIntent(text) { return /(说明|备注|补充|原因|超标|夜间|特殊情况|备注一下)/.test(text) } function buildApprovalInsight(text) { const requestId = extractRequestId(text) || linkedRequest.value.id const timeline = [ { label: '提交申请', time: `${linkedRequest.value.applyTime} 09:18`, state: 'done' }, { label: '票据识别', time: `${linkedRequest.value.applyTime} 09:22`, state: 'done' }, { label: '直属主管审批', time: '今天 10:46', state: 'done' }, { label: linkedRequest.value.node, time: '进行中', state: 'current' }, { label: '归档入账', time: '待处理', state: 'pending' } ] return { intent: 'approval', confidence: 95, title: `${requestId} 的审批状态`, summary: `当前在 ${linkedRequest.value.node},右侧已经切到流程状态界面。`, reply: `我识别到你是在查询审批节点。${requestId} 当前处于 ${linkedRequest.value.node},下一步预计由财务在今天 17:30 前处理。`, status: { requestId, currentStatus: linkedRequest.value.approval, currentNode: linkedRequest.value.node, nextOwner: '财务共享中心 · 王敏', eta: '今天 17:30 前', timeline, actions: [ '若 17:30 后仍未推进,可提醒财务共享中心处理。', '当前不建议重复提交,避免流程串单。', '如果要补充说明,直接在当前对话里继续输入即可。' ] } } } function buildRecognitionInsight(text, files) { const requestId = extractRequestId(text) || linkedRequest.value.id const receipts = buildReceiptItems(text, files) const total = receipts.reduce((sum, item) => sum + parseCurrency(item.amount), 0) const amount = formatCurrency(total || guessAmount(text) || 3680) const completeness = files.length >= 2 ? '资料较完整' : '仍需补件' return { intent: 'recognition', confidence: files.length ? 97 : 90, title: '已切换到单据识别视图', summary: `识别到 ${receipts.length} 条候选费用,建议关联到 ${requestId}。`, reply: `我识别到你是在上传或识别单据。右侧已经展示识别结果、建议金额和缺失材料。`, recognition: { state: files.length ? '识别完成' : '待补附件', requestId, fileCount: Math.max(files.length, 1), amount, completeness, receipts, suggestions: [ files.length ? '可直接生成费用明细草稿。' : '建议补传票据原件,识别结果会更稳定。', '金额和费用分类已经给出,确认后即可写入报销单。', '如果有多张单据属于同一行程,可以继续上传,右侧会合并结果。' ] } } } function buildNoteInsight(text) { const requestId = extractRequestId(text) || linkedRequest.value.id const noteType = /超标|夜间/.test(text) ? '特殊场景说明' : '补充报销说明' const generatedNote = /超标|夜间/.test(text) ? '因客户会议结束较晚,产生夜间交通费用,已保留行程截图与打车凭证,申请按实际发生金额报销。' : '本次费用与客户现场沟通及方案汇报直接相关,单据与行程已对应关联,请按当前草稿继续流转。' return { intent: 'note', confidence: 93, title: `${requestId} 的补充说明`, summary: `识别到你是在补充备注,右侧切到说明整理界面。`, reply: `我识别到你是在补充说明。右侧已经生成结构化备注,可直接作为对应单号的附加说明。`, note: { requestId, state: noteType, generatedNote, impacts: [ '会同步显示给当前审批节点处理人。', '若涉及超标或夜间交通,审批意见会优先查看这段说明。', '继续补充金额、参与人或业务背景时,我会自动更新说明版本。' ], owner: linkedRequest.value.node, nextAction: '继续补充或提交当前说明' } } } function buildDraftInsight(text, files) { const requestId = linkedRequest.value.id const items = buildDraftItems(text, files) const total = items.reduce((sum, item) => sum + parseCurrency(item.amount), 0) return { intent: 'draft', confidence: 91, title: '已切换到报销草稿视图', summary: '识别到你是在发起报销或继续填写草稿,右侧展示当前建议明细。', reply: '我识别到你是在发起或继续整理报销。右侧已经切到草稿视图,展示建议费用明细和待补信息。', draft: { state: files.length ? '可继续完善' : '草稿已生成', requestId, type: inferDraftType(text), amount: formatCurrency(total || guessAmount(text) || 3280), progress: files.length ? '已录入基础信息' : '待补票据', items, missing: [ '补充至少一份原始票据或行程截图。', '确认出差事由、城市和发生日期是否完整。', '如有业务招待或特殊交通,请补充关联说明。' ] } } } function extractRequestId(text) { return text.match(/BR\d{6,}/i)?.[0] ?? '' } function inferDraftType(text) { if (/招待|客户|用餐/.test(text)) return '业务招待报销' if (/交通|打车|高铁|机票/.test(text)) return '交通费用报销' return '差旅费申请报销' } function buildDraftItems(text, files) { const items = [] if (/高铁|火车|车票/.test(text)) { items.push({ name: '高铁 / 火车票', desc: '建议录入为城际交通', amount: '¥236.00', tag: '交通' }) } if (/机票|航班/.test(text)) { items.push({ name: '机票', desc: '建议录入为航空出行', amount: '¥1,280.00', tag: '交通' }) } if (/酒店|住宿/.test(text)) { items.push({ name: '酒店住宿', desc: '建议录入为住宿费用', amount: '¥780.00', tag: '住宿' }) } if (/打车|出租车|网约车/.test(text)) { items.push({ name: '市内交通', desc: '建议合并同日打车订单', amount: '¥126.00', tag: '交通' }) } if (/餐|招待|客户/.test(text)) { items.push({ name: '业务招待', desc: '建议补充参与人和业务目的', amount: '¥860.00', tag: '招待' }) } if (!items.length) { items.push({ name: '差旅综合费用', desc: files.length ? '已根据附件生成候选明细' : '根据描述先生成一版草稿', amount: files.length ? '¥3,280.00' : '¥2,680.00', tag: '草稿' }) } return items } function buildReceiptItems(text, files) { if (files.length) { return files.map((file, index) => { const type = inferFileType(file, text, index) const baseAmount = guessAmount(file) || guessAmount(text) || (index + 1) * 180 + 120 return { name: file, type, amount: formatCurrency(baseAmount), confidence: `${92 - index}%` } }) } return buildDraftItems(text, files).map((item, index) => ({ name: item.name, type: item.tag, amount: item.amount, confidence: `${94 - index}%` })) } function inferFileType(fileName, text, index) { const name = `${fileName} ${text}` if (/酒店|住宿/.test(name)) return '住宿单据' if (/机票|航班/.test(name)) return '航空出行' if (/高铁|火车|车票/.test(name)) return '城际交通' if (/打车|出租车|网约车/.test(name)) return '市内交通' return index === 0 ? '费用主票据' : '补充附件' } function guessAmount(text) { const match = String(text).match(/(\d+(?:\.\d{1,2})?)/) return match ? Number.parseFloat(match[1]) : 0 } function parseCurrency(value) { return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 } function formatCurrency(value) { return `¥${Number(value).toFixed(2)}` } return { emit, fileInputRef, messageListRef, composerDraft, attachedFiles, messages, currentInsight, linkedRequest, sourceLabel, canSubmit, showInsightPanel, composerPlaceholder, currentIntentLabel, shortcuts, triggerFileUpload, handleFilesChange, runShortcut, submitComposer } } }