refactor: split project into web and server directories
- Move frontend to web/ directory - Add server/ directory for backend - Restructure project for前后端分离架构 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
451
web/src/views/scripts/TravelRequestDetailView.js
Normal file
451
web/src/views/scripts/TravelRequestDetailView.js
Normal file
@@ -0,0 +1,451 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user