import { computed, ref } from 'vue' export default { name: 'TravelRequestDetailView', props: { request: { type: Object, default: () => ({}) } }, emits: ['backToRequests', 'openAssistant'] , setup(props, { emit }) { const expandedExpenseId = ref(null) const aiEntryOpen = ref(false) const aiDraft = ref('') const aiFileInput = ref(null) const aiEntrySeed = ref(2) const pendingAiExpense = ref(null) const uploadedAiFiles = ref([]) const expenseItems = ref([ { id: 'exp-1', time: '07-08', dayLabel: '第 1 天', name: '高铁票', category: '交通', desc: '上海虹桥 -> 杭州东', detail: '客户方案汇报前往现场', amount: '¥236.00', status: '规则通过', tone: 'ok', attachmentStatus: '2 份附件', attachmentHint: '车票 + 行程单', attachmentTone: 'ok', attachments: ['高铁票.pdf', '行程单.pdf'], riskLabel: '规则通过', riskText: '票据与行程匹配', riskTone: 'low' }, { id: 'exp-2', time: '07-09', dayLabel: '第 2 天', name: '酒店住宿', category: '住宿', desc: '杭州西湖商务酒店', detail: '1 晚住宿,含早餐', amount: '¥1,180.00', status: '待补材料', tone: 'bad', attachmentStatus: '缺 1 份', attachmentHint: '缺少入住清单', attachmentTone: 'partial', attachments: ['酒店发票.jpg'], riskLabel: '待补材料', riskText: '需补酒店入住清单', riskTone: 'medium' }, { id: 'exp-3', time: '07-10', dayLabel: '第 3 天', name: '出租车', category: '市内交通', desc: '客户公司往返酒店', detail: '含夜间打车 2 次', amount: '¥128.00', status: '需说明', tone: 'bad', attachmentStatus: '3 份附件', attachmentHint: '发票已上传', attachmentTone: 'ok', attachments: ['出租车发票1.jpg', '出租车发票2.jpg', '打车订单.png'], riskLabel: '超标说明', riskText: '1 笔夜间交通需补充说明', riskTone: 'medium' }, { id: 'exp-4', time: '07-11', dayLabel: '第 4 天', name: '餐补', category: '补贴', desc: '差旅餐补', detail: '按 4 天标准自动计算', amount: '¥320.00', status: '规则通过', tone: 'ok', attachmentStatus: '系统生成', attachmentHint: '无需上传附件', attachmentTone: 'neutral', attachments: [], riskLabel: '规则通过', riskText: '补贴标准校验通过', riskTone: 'low' } ]) const request = computed(() => ({ id: props.request?.id ?? 'BR240712001', reason: props.request?.reason ?? '客户方案汇报', city: props.request?.city ?? '上海', period: props.request?.period ?? '07-08~07-11 (4天)', applyTime: props.request?.applyTime ?? '2024-07-07', amount: props.request?.amount ?? '¥3,680.00', node: props.request?.node ?? '财务审核', approval: props.request?.approval ?? '审批中', approvalTone: props.request?.approvalTone ?? 'info', travel: props.request?.travel ?? '已订酒店/机票', travelTone: props.request?.travelTone ?? 'low' })) const profile = { name: '张晓明', department: '财务管理员', avatar: '张' } const summaryItems = [ { label: '出差城市', value: request.value.city, icon: 'mdi mdi-map-marker-path' }, { label: '出差区间', value: request.value.period, icon: 'mdi mdi-clock-outline' }, { label: '票据关联', value: '6 条明细 / 5 份材料', icon: 'mdi mdi-file-document-multiple-outline' }, { label: '商旅状态', value: request.value.travel, icon: 'mdi mdi-airplane' }, { label: '出差事由', value: request.value.reason, icon: 'mdi mdi-briefcase-outline' } ] const heroSummaryItems = computed(() => [ { label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' }, { label: '申请类型', value: '差旅费申请/报销', icon: 'mdi mdi-tag-multiple' }, ...summaryItems ]) const currentProgressRingMotion = { initial: { scale: 1, opacity: 0.34, }, enter: { scale: [1, 1.42, 1.78], opacity: [0.34, 0.16, 0], transition: { duration: 3.2, repeat: Infinity, repeatType: 'loop', repeatDelay: 0.85, ease: 'easeOut', times: [0, 0.5, 1], }, }, } const progressSteps = computed(() => { return [ { index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true }, { index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true }, { index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true }, { index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true }, { index: 5, label: '财务审批', time: '进行中', active: true, current: true }, { index: 6, label: '归档入账', time: '待处理' } ] }) const expenseTotal = computed(() => { const total = expenseItems.value.reduce((sum, item) => sum + parseCurrency(item.amount), 0) return formatCurrency(total) }) const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length) const canSendAiEntry = computed(() => Boolean(aiDraft.value.trim() || uploadedAiFiles.value.length)) const detailNote = '本次出差用于客户方案汇报与现场沟通,需覆盖往返交通、住宿及市内交通费用。已完成主要票据上传,待补酒店入住清单后即可进入完整审批流程。' function toggleExpenseAttachments(id) { expandedExpenseId.value = expandedExpenseId.value === id ? null : id } function showExpenseRisk(item) { return Boolean(item.riskText) } function openAiEntry() { aiEntryOpen.value = false emit('openAssistant', { source: 'detail', prompt: '', request: request.value }) } function closeAiEntry() { aiEntryOpen.value = false aiDraft.value = '' pendingAiExpense.value = null uploadedAiFiles.value = [] if (aiFileInput.value) { aiFileInput.value.value = '' } } function parseCurrency(value) { return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 } function formatCurrency(value) { return `¥${value.toFixed(2)}` } function buildNextExpenseId() { aiEntrySeed.value += 1 return `exp-ai-${aiEntrySeed.value}` } function inferExpenseCategory(text) { if (/高铁|火车|机票|航班|打车|出租车|地铁|公交|交通/.test(text)) return '交通' if (/酒店|住宿|房费/.test(text)) return '住宿' if (/餐|午饭|晚饭|早餐|餐补/.test(text)) return '餐饮' return '其他' } function inferExpenseName(text, category) { if (/高铁/.test(text)) return '高铁票' if (/机票|航班/.test(text)) return '机票' if (/出租车|打车/.test(text)) return '出租车' if (/酒店|住宿/.test(text)) return '酒店住宿' if (/餐补/.test(text)) return '餐补' if (/餐|午饭|晚饭|早餐/.test(text)) return '餐饮' return `${category}费用` } function inferAttachments(text, uploadedFiles = []) { if (uploadedFiles.length) { return { status: `${uploadedFiles.length} 份附件`, hint: uploadedFiles.map((file) => file.name).join(' + '), tone: 'ok', files: uploadedFiles.map((file) => file.name), } } if (/无需|免附件|系统生成/.test(text)) { return { status: '系统生成', hint: '无需上传附件', tone: 'neutral', files: [], } } const uploaded = /已上传|上传了|附上|附件/.test(text) const receipt = /发票/.test(text) const itinerary = /行程单/.test(text) const ticket = /车票|机票/.test(text) const hotelList = /入住清单/.test(text) const files = [] if (receipt) files.push('发票.jpg') if (itinerary) files.push('行程单.pdf') if (ticket && !files.includes('票据.pdf')) files.push('票据.pdf') if (hotelList) files.push('入住清单.pdf') if (uploaded || files.length) { return { status: `${Math.max(files.length, 1)} 份附件`, hint: files.length ? files.join(' + ') : '已上传附件待识别', tone: 'ok', files: files.length ? files : ['附件1.jpg'], } } return { status: '缺 1 份', hint: '待补上传票据原件', tone: 'missing', files: [], } } function inferRisk(text, attachmentTone) { if (/夜间|超标|说明/.test(text)) { return { status: '需说明', tone: 'bad', riskLabel: '超标说明', riskText: '识别到特殊场景,建议补充费用说明', riskTone: 'medium', } } if (attachmentTone === 'missing' || attachmentTone === 'partial') { return { status: '待补材料', tone: 'bad', riskLabel: '待补材料', riskText: '附件不完整,需补齐后再提交审批', riskTone: 'medium', } } return { status: '规则通过', tone: 'ok', riskLabel: '规则通过', riskText: 'AI 识别通过,字段已结构化', riskTone: 'low', } } function extractDateLabel(text) { const match = text.match(/(\d{1,2})月(\d{1,2})日|(\d{1,2})[-/.](\d{1,2})/) if (!match) { return { time: '07-12', dayLabel: `第 ${expenseItems.value.length + 1} 天` } } const month = String(match[1] || match[3] || '07').padStart(2, '0') const day = String(match[2] || match[4] || '12').padStart(2, '0') return { time: `${month}-${day}`, dayLabel: `第 ${expenseItems.value.length + 1} 天` } } function extractAmount(text) { const match = text.match(/(\d+(?:\.\d{1,2})?)\s*元/) return formatCurrency(Number.parseFloat(match?.[1] || '0')) } function buildAiExpense(text) { const category = inferExpenseCategory(text) const name = inferExpenseName(text, category) const dateInfo = extractDateLabel(text) const attachments = inferAttachments(text, uploadedAiFiles.value) const risk = inferRisk(text, attachments.tone) return { id: buildNextExpenseId(), time: dateInfo.time, dayLabel: dateInfo.dayLabel, name, category, desc: text.slice(0, 24), detail: text, amount: extractAmount(text), status: risk.status, tone: risk.tone, attachmentStatus: attachments.status, attachmentHint: attachments.hint, attachmentTone: attachments.tone, attachments: attachments.files, riskLabel: risk.riskLabel, riskText: risk.riskText, riskTone: risk.riskTone, } } const aiMessages = ref([ { id: 'ai-msg-1', role: 'assistant', text: '请直接描述费用场景、日期、金额和是否已上传票据,我会整理成费用明细。', }, ]) function sendAiEntry() { const text = aiDraft.value.trim() || `已上传 ${uploadedAiFiles.value.length} 份单据,请根据附件识别费用。` if (!text && !uploadedAiFiles.value.length) return aiMessages.value.push({ id: `ai-msg-user-${Date.now()}`, role: 'user', text: uploadedAiFiles.value.length ? `${text}\n附件:${uploadedAiFiles.value.map((file) => file.name).join('、')}` : text, }) pendingAiExpense.value = buildAiExpense(text) aiMessages.value.push({ id: `ai-msg-assistant-${Date.now()}`, role: 'assistant', text: `已识别为 ${pendingAiExpense.value.name},金额 ${pendingAiExpense.value.amount},可直接加入费用明细。`, }) aiDraft.value = '' } function regenerateAiEntry() { if (!pendingAiExpense.value) return const sourceText = pendingAiExpense.value.detail pendingAiExpense.value = buildAiExpense(sourceText.replace('待补上传票据原件', '已上传发票')) aiMessages.value.push({ id: `ai-msg-regenerate-${Date.now()}`, role: 'assistant', text: '已重新整理识别结果,你可以继续确认后加入费用明细。', }) } function applyAiExpense() { if (!pendingAiExpense.value) return expenseItems.value.push({ ...pendingAiExpense.value }) expandedExpenseId.value = pendingAiExpense.value.id aiMessages.value.push({ id: `ai-msg-apply-${Date.now()}`, role: 'assistant', text: '该费用条目已加入下方费用明细表。', }) pendingAiExpense.value = null aiDraft.value = '' uploadedAiFiles.value = [] if (aiFileInput.value) { aiFileInput.value.value = '' } aiEntryOpen.value = false } function triggerAiUpload() { aiFileInput.value?.click() } function handleAiFilesChange(event) { const files = Array.from(event.target.files ?? []) uploadedAiFiles.value = files } return { emit, expandedExpenseId, aiEntryOpen, aiDraft, aiFileInput, aiEntrySeed, pendingAiExpense, uploadedAiFiles, expenseItems, request, profile, summaryItems, heroSummaryItems, currentProgressRingMotion, progressSteps, expenseTotal, uploadedExpenseCount, canSendAiEntry, detailNote, toggleExpenseAttachments, showExpenseRisk, openAiEntry, closeAiEntry, aiMessages, sendAiEntry, regenerateAiEntry, applyAiExpense, triggerAiUpload, handleAiFilesChange } } }