2026-05-13 03:35:44 +00:00
|
|
|
|
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)]
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
|
name: 'TravelRequestDetailView',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
components: {
|
|
|
|
|
|
ConfirmDialog
|
|
|
|
|
|
},
|
2026-05-06 11:00:38 +08:00
|
|
|
|
props: {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
request: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
default: () => ({})
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
2026-05-06 11:00:38 +08:00
|
|
|
|
setup(props, { emit }) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const { toast } = useToast()
|
2026-05-06 11:00:38 +08:00
|
|
|
|
const expandedExpenseId = ref(null)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
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(() => [
|
2026-05-06 11:00:38 +08:00
|
|
|
|
{
|
2026-05-13 03:35:44 +00:00
|
|
|
|
label: '金额',
|
|
|
|
|
|
value: request.value.amountDisplay,
|
|
|
|
|
|
kind: 'text'
|
2026-05-06 11:00:38 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-05-13 03:35:44 +00:00
|
|
|
|
label: '当前节点',
|
|
|
|
|
|
value: request.value.node,
|
|
|
|
|
|
kind: 'pill',
|
|
|
|
|
|
className: 'state-pill',
|
|
|
|
|
|
tone: request.value.approvalTone
|
2026-05-06 11:00:38 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-05-13 03:35:44 +00:00
|
|
|
|
label: '审批状态',
|
|
|
|
|
|
value: request.value.approval,
|
|
|
|
|
|
kind: 'pill',
|
|
|
|
|
|
className: 'approval-pill',
|
|
|
|
|
|
tone: request.value.approvalTone
|
2026-05-06 11:00:38 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-05-13 03:35:44 +00:00
|
|
|
|
label: request.value.secondaryStatusLabel,
|
|
|
|
|
|
value: request.value.secondaryStatusValue,
|
|
|
|
|
|
kind: 'pill',
|
|
|
|
|
|
className: 'risk-pill',
|
|
|
|
|
|
tone: request.value.secondaryStatusTone
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
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' }
|
|
|
|
|
|
]
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
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' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
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 progressSteps = computed(() =>
|
|
|
|
|
|
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
|
|
|
|
|
|
? request.value.progressSteps
|
|
|
|
|
|
: buildFallbackProgressSteps()
|
|
|
|
|
|
)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
|
|
|
|
|
const currentProgressRingMotion = {
|
|
|
|
|
|
initial: {
|
|
|
|
|
|
scale: 1,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
opacity: 0.34
|
2026-05-06 11:00:38 +08:00
|
|
|
|
},
|
|
|
|
|
|
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',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
times: [0, 0.5, 1]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const expenseTotal = computed(() => {
|
|
|
|
|
|
const total = expenseItems.value.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
|
|
|
|
|
|
return formatCurrency(total)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
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
|
|
|
|
|
|
? '当前草稿信息完整,可以提交审批。'
|
|
|
|
|
|
: '当前草稿仍有未完善字段,提交按钮会保持禁用。'
|
|
|
|
|
|
)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
|
|
|
|
|
function toggleExpenseAttachments(id) {
|
|
|
|
|
|
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function resolveExpenseIssues(item) {
|
|
|
|
|
|
return buildExpenseDraftIssues(item)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function showExpenseRisk(item) {
|
|
|
|
|
|
return Boolean(resolveExpenseIssues(item).length || item.riskText)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function startExpenseEdit(item) {
|
|
|
|
|
|
if (!isDraftRequest.value || actionBusy.value) {
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
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
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function cancelExpenseEdit() {
|
|
|
|
|
|
editingExpenseId.value = ''
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
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 '请输入业务地点。'
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const amount = Number(expenseEditor.itemAmount)
|
|
|
|
|
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|
|
|
|
|
return '请输入大于 0 的费用金额。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isPlaceholderValue(expenseEditor.invoiceId)) {
|
|
|
|
|
|
return '请输入票据标识或附件名称。'
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return ''
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
async function saveExpenseEdit(item) {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法保存费用明细。')
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const validationError = validateExpenseEditor()
|
|
|
|
|
|
if (validationError) {
|
|
|
|
|
|
toast(validationError)
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
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 = ''
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
async function handleSubmit() {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法提交。')
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!canSubmit.value) {
|
|
|
|
|
|
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
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
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
async function handleDeleteDraft() {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法删除。')
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
deleteDialogOpen.value = true
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function closeDeleteDialog() {
|
|
|
|
|
|
if (deleteBusy.value) {
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
deleteDialogOpen.value = false
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
async function confirmDeleteDraft() {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法删除。')
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function openAiEntry() {
|
|
|
|
|
|
emit('openAssistant', {
|
|
|
|
|
|
source: 'detail',
|
|
|
|
|
|
prompt: '',
|
|
|
|
|
|
request: request.value
|
|
|
|
|
|
})
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
emit,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
actionBusy,
|
|
|
|
|
|
canSubmit,
|
|
|
|
|
|
closeDeleteDialog,
|
|
|
|
|
|
confirmDeleteDraft,
|
|
|
|
|
|
currentProgressRingMotion,
|
|
|
|
|
|
deleteBusy,
|
|
|
|
|
|
deleteDialogOpen,
|
|
|
|
|
|
detailNote,
|
|
|
|
|
|
draftBlockingIssues,
|
|
|
|
|
|
editingExpenseId,
|
|
|
|
|
|
expenseEditor,
|
2026-05-06 11:00:38 +08:00
|
|
|
|
expenseItems,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
expenseSummaryText,
|
|
|
|
|
|
expenseTotal,
|
|
|
|
|
|
expandedExpenseId,
|
|
|
|
|
|
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
|
|
|
|
|
handleDeleteDraft,
|
|
|
|
|
|
handleSubmit,
|
|
|
|
|
|
heroStats,
|
2026-05-06 11:00:38 +08:00
|
|
|
|
heroSummaryItems,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
isDraftRequest,
|
|
|
|
|
|
isTravelRequest,
|
|
|
|
|
|
openAiEntry,
|
|
|
|
|
|
profile,
|
2026-05-06 11:00:38 +08:00
|
|
|
|
progressSteps,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
request,
|
|
|
|
|
|
resolveExpenseIssues,
|
|
|
|
|
|
savingExpenseId,
|
2026-05-06 11:00:38 +08:00
|
|
|
|
showExpenseRisk,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
startExpenseEdit,
|
|
|
|
|
|
submitBusy,
|
|
|
|
|
|
toggleExpenseAttachments,
|
|
|
|
|
|
uploadedExpenseCount,
|
|
|
|
|
|
validationSummary,
|
|
|
|
|
|
validationTone,
|
|
|
|
|
|
cancelExpenseEdit,
|
|
|
|
|
|
saveExpenseEdit
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|