Files
X-Financial/web/src/views/scripts/TravelRequestDetailView.js
caoxiaozhu 46644d429f 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
2026-05-13 03:35:44 +00:00

548 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-updated', 'request-deleted'],
setup(props, { emit }) {
const { toast } = useToast()
const expandedExpenseId = ref(null)
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(() => [
{
label: '金额',
value: request.value.amountDisplay,
kind: 'text'
},
{
label: '当前节点',
value: request.value.node,
kind: 'pill',
className: 'state-pill',
tone: request.value.approvalTone
},
{
label: '审批状态',
value: request.value.approval,
kind: 'pill',
className: 'approval-pill',
tone: request.value.approvalTone
},
{
label: request.value.secondaryStatusLabel,
value: request.value.secondaryStatusValue,
kind: 'pill',
className: 'risk-pill',
tone: request.value.secondaryStatusTone
}
])
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' }
]
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' }
]
}
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()
)
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 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 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(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() {
emit('openAssistant', {
source: 'detail',
prompt: '',
request: request.value
})
}
return {
emit,
actionBusy,
canSubmit,
closeDeleteDialog,
confirmDeleteDraft,
currentProgressRingMotion,
deleteBusy,
deleteDialogOpen,
detailNote,
draftBlockingIssues,
editingExpenseId,
expenseEditor,
expenseItems,
expenseSummaryText,
expenseTotal,
expandedExpenseId,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleDeleteDraft,
handleSubmit,
heroStats,
heroSummaryItems,
isDraftRequest,
isTravelRequest,
openAiEntry,
profile,
progressSteps,
request,
resolveExpenseIssues,
savingExpenseId,
showExpenseRisk,
startExpenseEdit,
submitBusy,
toggleExpenseAttachments,
uploadedExpenseCount,
validationSummary,
validationTone,
cancelExpenseEdit,
saveExpenseEdit
}
}
}