refactor(web): update view scripts
- AuditView.js: update audit view logic - EmployeeManagementView.js: update employee management logic - PoliciesView.js: update policies view logic - RequestsView.js: update requests view logic - TravelReimbursementCreateView.js: update travel form logic - TravelRequestDetailView.js: update travel detail view logic
This commit is contained in:
@@ -1,139 +1,311 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { deleteExpenseClaim, submitExpenseClaim, updateExpenseClaimItem } from '../../services/reimbursements.js'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
{ value: 'entertainment', label: '业务招待费' },
|
||||
{ value: 'office', label: '办公费' },
|
||||
{ value: 'meeting', label: '会务费' },
|
||||
{ value: 'training', label: '培训费' },
|
||||
{ value: 'hotel', label: '住宿费' },
|
||||
{ value: 'transport', label: '交通费' },
|
||||
{ value: 'meal', label: '餐费' },
|
||||
{ value: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
function parseCurrency(value) {
|
||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function buildFallbackProgressSteps() {
|
||||
return [
|
||||
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
|
||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||
{ index: 3, label: 'AI验审', time: '待处理' },
|
||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||
{ index: 5, label: '财务审批', time: '待处理' },
|
||||
{ index: 6, label: '归档入账', time: '待处理' }
|
||||
]
|
||||
}
|
||||
|
||||
function buildFallbackExpenseItems(request) {
|
||||
return [
|
||||
{
|
||||
id: 'fallback-1',
|
||||
itemDate: '',
|
||||
itemType: request.typeCode || 'other',
|
||||
itemReason: request.reason,
|
||||
itemLocation: request.sceneTarget,
|
||||
itemAmount: parseCurrency(request.amountDisplay),
|
||||
invoiceId: '',
|
||||
time: '待补充',
|
||||
dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日',
|
||||
name: request.typeLabel,
|
||||
category: request.typeLabel,
|
||||
desc: request.reason,
|
||||
detail: request.sceneTarget,
|
||||
amount: request.amountDisplay,
|
||||
status: '待补充',
|
||||
tone: 'bad',
|
||||
attachmentStatus: '待上传',
|
||||
attachmentHint: '请在此单据中继续补充附件',
|
||||
attachmentTone: 'missing',
|
||||
attachments: [],
|
||||
riskLabel: '待补材料',
|
||||
riskText: request.riskSummary,
|
||||
riskTone: 'medium'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function isPlaceholderValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
function isValidIsoDate(value) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || '').trim())) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextDate = new Date(`${value}T00:00:00`)
|
||||
return !Number.isNaN(nextDate.getTime()) && nextDate.toISOString().slice(0, 10) === value
|
||||
}
|
||||
|
||||
function buildExpenseDraftIssues(item) {
|
||||
const issues = []
|
||||
|
||||
if (!isValidIsoDate(item.itemDate)) {
|
||||
issues.push('缺少日期')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemType)) {
|
||||
issues.push('缺少费用项目')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemReason)) {
|
||||
issues.push('缺少说明')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemLocation)) {
|
||||
issues.push('缺少地点')
|
||||
}
|
||||
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
||||
issues.push('缺少金额')
|
||||
}
|
||||
if (isPlaceholderValue(item.invoiceId)) {
|
||||
issues.push('缺少票据标识')
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function buildDraftBlockingIssues(request, expenseItems) {
|
||||
const issues = []
|
||||
|
||||
if (isPlaceholderValue(request.profileName)) {
|
||||
issues.push('申请人未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.profileDepartment)) {
|
||||
issues.push('所属部门未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.typeLabel)) {
|
||||
issues.push('报销类型未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.reason)) {
|
||||
issues.push('报销事由未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.location)) {
|
||||
issues.push('业务地点未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.occurredDisplay)) {
|
||||
issues.push('发生时间未完善')
|
||||
}
|
||||
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
|
||||
issues.push('报销金额未完善')
|
||||
}
|
||||
if (!expenseItems.length) {
|
||||
issues.push('费用明细不能为空')
|
||||
}
|
||||
|
||||
expenseItems.forEach((item, index) => {
|
||||
buildExpenseDraftIssues(item).forEach((issue) => {
|
||||
issues.push(`费用明细第 ${index + 1} 条${issue}`)
|
||||
})
|
||||
})
|
||||
|
||||
return [...new Set(issues)]
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'TravelRequestDetailView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
},
|
||||
props: {
|
||||
request: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['backToRequests', 'openAssistant'] ,
|
||||
request: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
||||
setup(props, { emit }) {
|
||||
const { toast } = useToast()
|
||||
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([
|
||||
const editingExpenseId = ref('')
|
||||
const savingExpenseId = ref('')
|
||||
const submitBusy = ref(false)
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const expenseEditor = reactive({
|
||||
itemDate: '',
|
||||
itemType: 'other',
|
||||
itemReason: '',
|
||||
itemLocation: '',
|
||||
itemAmount: '',
|
||||
invoiceId: ''
|
||||
})
|
||||
|
||||
const request = computed(() => {
|
||||
const normalized = normalizeRequestForUi(props.request)
|
||||
|
||||
return (
|
||||
normalized || {
|
||||
id: 'EXP-202605-000',
|
||||
claimId: '',
|
||||
reason: '待补充报销事由',
|
||||
typeLabel: '其他费用',
|
||||
typeCode: 'other',
|
||||
detailVariant: 'general',
|
||||
sceneTarget: '待补充',
|
||||
location: '待补充',
|
||||
occurredDisplay: '待补充',
|
||||
applyTime: '待补充',
|
||||
amountDisplay: '¥0',
|
||||
amountValue: 0,
|
||||
node: '待提交',
|
||||
approval: '草稿',
|
||||
approvalKey: 'draft',
|
||||
approvalTone: 'draft',
|
||||
secondaryStatusLabel: '票据状态',
|
||||
secondaryStatusValue: '待补充',
|
||||
secondaryStatusTone: 'warning',
|
||||
relatedCustomer: '待补充',
|
||||
attachmentSummary: '待补充',
|
||||
riskSummary: '待补充',
|
||||
note: '',
|
||||
profileName: '当前申请人',
|
||||
profileDepartment: '待补充部门',
|
||||
profileAvatar: '申'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||
const actionBusy = computed(() => Boolean(savingExpenseId.value) || submitBusy.value || deleteBusy.value)
|
||||
|
||||
const profile = computed(() => ({
|
||||
name: request.value.profileName,
|
||||
department: request.value.profileDepartment,
|
||||
avatar: request.value.profileAvatar
|
||||
}))
|
||||
|
||||
const expenseItems = ref([])
|
||||
|
||||
watch(
|
||||
request,
|
||||
(nextRequest) => {
|
||||
expenseItems.value =
|
||||
Array.isArray(nextRequest.expenseItems) && nextRequest.expenseItems.length
|
||||
? nextRequest.expenseItems
|
||||
: buildFallbackExpenseItems(nextRequest)
|
||||
expandedExpenseId.value = null
|
||||
editingExpenseId.value = ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const heroStats = computed(() => [
|
||||
{
|
||||
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'
|
||||
label: '金额',
|
||||
value: request.value.amountDisplay,
|
||||
kind: 'text'
|
||||
},
|
||||
{
|
||||
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'
|
||||
label: '当前节点',
|
||||
value: request.value.node,
|
||||
kind: 'pill',
|
||||
className: 'state-pill',
|
||||
tone: request.value.approvalTone
|
||||
},
|
||||
{
|
||||
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'
|
||||
label: '审批状态',
|
||||
value: request.value.approval,
|
||||
kind: 'pill',
|
||||
className: 'approval-pill',
|
||||
tone: request.value.approvalTone
|
||||
},
|
||||
{
|
||||
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'
|
||||
label: request.value.secondaryStatusLabel,
|
||||
value: request.value.secondaryStatusValue,
|
||||
kind: 'pill',
|
||||
className: 'risk-pill',
|
||||
tone: request.value.secondaryStatusTone
|
||||
}
|
||||
])
|
||||
|
||||
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 heroSummaryItems = computed(() => {
|
||||
const commonItems = [
|
||||
{ label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' },
|
||||
{ label: '报销类型', value: request.value.typeLabel, icon: 'mdi mdi-tag-multiple' }
|
||||
]
|
||||
|
||||
const profile = {
|
||||
name: '张晓明',
|
||||
department: '财务管理员',
|
||||
avatar: '张'
|
||||
}
|
||||
if (isTravelRequest.value) {
|
||||
return [
|
||||
...commonItems,
|
||||
{ label: '出行路线', value: request.value.sceneTarget, icon: 'mdi mdi-map-marker-path' },
|
||||
{ label: '出差区间', value: request.value.occurredDisplay, icon: 'mdi mdi-clock-outline' },
|
||||
{ label: '关联客户', value: request.value.relatedCustomer, icon: 'mdi mdi-domain' },
|
||||
{ label: '票据关联', value: request.value.attachmentSummary, icon: 'mdi mdi-file-document-multiple-outline' },
|
||||
{ label: '出差事由', value: request.value.reason, icon: 'mdi mdi-briefcase-outline' }
|
||||
]
|
||||
}
|
||||
|
||||
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' }
|
||||
]
|
||||
return [
|
||||
...commonItems,
|
||||
{ label: '业务地点', value: request.value.sceneTarget, icon: 'mdi mdi-map-marker-outline' },
|
||||
{ label: '发生时间', value: request.value.occurredDisplay, icon: 'mdi mdi-calendar-month-outline' },
|
||||
{ label: '关联客户', value: request.value.relatedCustomer, icon: 'mdi mdi-domain' },
|
||||
{ label: '票据关联', value: request.value.attachmentSummary, icon: 'mdi mdi-paperclip' },
|
||||
{ label: '风险提示', value: request.value.riskSummary, icon: 'mdi mdi-shield-alert-outline' }
|
||||
]
|
||||
})
|
||||
|
||||
const heroSummaryItems = computed(() => [
|
||||
{ label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' },
|
||||
{ label: '申请类型', value: '差旅费申请/报销', icon: 'mdi mdi-tag-multiple' },
|
||||
...summaryItems
|
||||
])
|
||||
const progressSteps = computed(() =>
|
||||
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
|
||||
? request.value.progressSteps
|
||||
: buildFallbackProgressSteps()
|
||||
)
|
||||
|
||||
const currentProgressRingMotion = {
|
||||
initial: {
|
||||
scale: 1,
|
||||
opacity: 0.34,
|
||||
opacity: 0.34
|
||||
},
|
||||
enter: {
|
||||
scale: [1, 1.42, 1.78],
|
||||
@@ -144,41 +316,186 @@ export default {
|
||||
repeatType: 'loop',
|
||||
repeatDelay: 0.85,
|
||||
ease: 'easeOut',
|
||||
times: [0, 0.5, 1],
|
||||
},
|
||||
},
|
||||
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 = '本次出差用于客户方案汇报与现场沟通,需覆盖往返交通、住宿及市内交通费用。已完成主要票据上传,待补酒店入住清单后即可进入完整审批流程。'
|
||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||
const expenseSummaryText = computed(
|
||||
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
|
||||
)
|
||||
const detailNote = computed(
|
||||
() =>
|
||||
request.value.note
|
||||
|| (isTravelRequest.value
|
||||
? '该差旅报销单尚未补充完整说明,请继续完善后再提交审批。'
|
||||
: '该报销单尚未补充完整说明,请继续完善后再提交审批。')
|
||||
)
|
||||
const draftBlockingIssues = computed(() =>
|
||||
isDraftRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
)
|
||||
const canSubmit = computed(() => isDraftRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
|
||||
const validationTone = computed(() => (canSubmit.value ? 'ready' : 'pending'))
|
||||
const validationSummary = computed(() =>
|
||||
canSubmit.value
|
||||
? '当前草稿信息完整,可以提交审批。'
|
||||
: '当前草稿仍有未完善字段,提交按钮会保持禁用。'
|
||||
)
|
||||
|
||||
function toggleExpenseAttachments(id) {
|
||||
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
|
||||
}
|
||||
|
||||
function resolveExpenseIssues(item) {
|
||||
return buildExpenseDraftIssues(item)
|
||||
}
|
||||
|
||||
function showExpenseRisk(item) {
|
||||
return Boolean(item.riskText)
|
||||
return Boolean(resolveExpenseIssues(item).length || item.riskText)
|
||||
}
|
||||
|
||||
function startExpenseEdit(item) {
|
||||
if (!isDraftRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
editingExpenseId.value = item.id
|
||||
expenseEditor.itemDate = item.itemDate || ''
|
||||
expenseEditor.itemType = item.itemType || 'other'
|
||||
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
|
||||
expenseEditor.itemLocation = item.itemLocation || (item.detail === '待补充' ? '' : item.detail)
|
||||
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
||||
expenseEditor.invoiceId = item.invoiceId || ''
|
||||
expandedExpenseId.value = null
|
||||
}
|
||||
|
||||
function cancelExpenseEdit() {
|
||||
editingExpenseId.value = ''
|
||||
}
|
||||
|
||||
function validateExpenseEditor() {
|
||||
if (!isValidIsoDate(expenseEditor.itemDate)) {
|
||||
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
|
||||
}
|
||||
if (isPlaceholderValue(expenseEditor.itemType)) {
|
||||
return '请选择费用项目。'
|
||||
}
|
||||
if (isPlaceholderValue(expenseEditor.itemReason)) {
|
||||
return '请输入费用说明。'
|
||||
}
|
||||
if (isPlaceholderValue(expenseEditor.itemLocation)) {
|
||||
return '请输入业务地点。'
|
||||
}
|
||||
|
||||
const amount = Number(expenseEditor.itemAmount)
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return '请输入大于 0 的费用金额。'
|
||||
}
|
||||
if (isPlaceholderValue(expenseEditor.invoiceId)) {
|
||||
return '请输入票据标识或附件名称。'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
async function saveExpenseEdit(item) {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法保存费用明细。')
|
||||
return
|
||||
}
|
||||
|
||||
const validationError = validateExpenseEditor()
|
||||
if (validationError) {
|
||||
toast(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
savingExpenseId.value = item.id
|
||||
try {
|
||||
await updateExpenseClaimItem(request.value.claimId, item.id, {
|
||||
item_date: expenseEditor.itemDate,
|
||||
item_type: expenseEditor.itemType,
|
||||
item_reason: expenseEditor.itemReason.trim(),
|
||||
item_location: expenseEditor.itemLocation.trim(),
|
||||
item_amount: Number(expenseEditor.itemAmount),
|
||||
invoice_id: expenseEditor.invoiceId.trim()
|
||||
})
|
||||
editingExpenseId.value = ''
|
||||
toast('费用明细已保存。')
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '费用明细保存失败,请稍后重试。')
|
||||
} finally {
|
||||
savingExpenseId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canSubmit.value) {
|
||||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||||
return
|
||||
}
|
||||
|
||||
submitBusy.value = true
|
||||
try {
|
||||
await submitExpenseClaim(request.value.claimId)
|
||||
toast(`${request.value.id} 已提交审批。`)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '提交审批失败,请稍后重试。')
|
||||
} finally {
|
||||
submitBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDraft() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法删除。')
|
||||
return
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (deleteBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmDeleteDraft() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法删除。')
|
||||
return
|
||||
}
|
||||
|
||||
deleteBusy.value = true
|
||||
try {
|
||||
const payload = await deleteExpenseClaim(request.value.claimId)
|
||||
deleteDialogOpen.value = false
|
||||
toast(payload?.message || `${request.value.id} 草稿已删除。`)
|
||||
emit('request-deleted', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '删除草稿失败,请稍后重试。')
|
||||
} finally {
|
||||
deleteBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAiEntry() {
|
||||
aiEntryOpen.value = false
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
prompt: '',
|
||||
@@ -186,266 +503,45 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
actionBusy,
|
||||
canSubmit,
|
||||
closeDeleteDialog,
|
||||
confirmDeleteDraft,
|
||||
currentProgressRingMotion,
|
||||
progressSteps,
|
||||
expenseTotal,
|
||||
uploadedExpenseCount,
|
||||
canSendAiEntry,
|
||||
deleteBusy,
|
||||
deleteDialogOpen,
|
||||
detailNote,
|
||||
toggleExpenseAttachments,
|
||||
showExpenseRisk,
|
||||
draftBlockingIssues,
|
||||
editingExpenseId,
|
||||
expenseEditor,
|
||||
expenseItems,
|
||||
expenseSummaryText,
|
||||
expenseTotal,
|
||||
expandedExpenseId,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
handleDeleteDraft,
|
||||
handleSubmit,
|
||||
heroStats,
|
||||
heroSummaryItems,
|
||||
isDraftRequest,
|
||||
isTravelRequest,
|
||||
openAiEntry,
|
||||
closeAiEntry,
|
||||
aiMessages,
|
||||
sendAiEntry,
|
||||
regenerateAiEntry,
|
||||
applyAiExpense,
|
||||
triggerAiUpload,
|
||||
handleAiFilesChange
|
||||
profile,
|
||||
progressSteps,
|
||||
request,
|
||||
resolveExpenseIssues,
|
||||
savingExpenseId,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
submitBusy,
|
||||
toggleExpenseAttachments,
|
||||
uploadedExpenseCount,
|
||||
validationSummary,
|
||||
validationTone,
|
||||
cancelExpenseEdit,
|
||||
saveExpenseEdit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user