import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { createExpenseClaimItem, deleteExpenseClaimItem, deleteExpenseClaimItemAttachment, deleteExpenseClaim, fetchExpenseClaimItemAttachmentMeta, fetchExpenseClaimItemAttachmentPreview, returnExpenseClaim, submitExpenseClaim, uploadExpenseClaimItemAttachment, updateExpenseClaimItem } from '../../services/reimbursements.js' import { canManageExpenseClaims } from '../../utils/accessControl.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: '其他费用' } ] const DOCUMENT_TYPE_LABELS = { flight_itinerary: '机票/航班行程单', train_ticket: '火车/高铁票', hotel_invoice: '酒店住宿票据', taxi_receipt: '出租车/网约车票据', parking_toll_receipt: '停车/通行费票据', meal_receipt: '餐饮票据', office_invoice: '办公用品票据', meeting_invoice: '会议/会务票据', training_invoice: '培训票据', vat_invoice: '增值税发票', receipt: '一般收据/凭证', other: '其他单据' } const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ 'travel', 'meeting', 'entertainment' ]) 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 normalizeExpenseType(value) { return String(value || '').trim() || 'other' } function resolveExpenseTypeLabel(value) { return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用' } function resolveDocumentTypeLabel(value) { return DOCUMENT_TYPE_LABELS[String(value || '').trim()] || DOCUMENT_TYPE_LABELS.other } function isLocationRequiredExpenseType(value) { return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value)) } function resolveLocationInputPlaceholder(value) { return isLocationRequiredExpenseType(value) ? '输入业务地点' : '输入采购/收货地点(可选)' } function resolveLocationSummaryLabel(value) { return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点' } function resolveLocationDisplay(value, expenseType) { if (!isLocationRequiredExpenseType(expenseType) && isPlaceholderValue(value)) { return '非必填' } return isPlaceholderValue(value) ? '待补充' : 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 [ buildExpenseItemViewModel({ 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: resolveLocationDisplay(request.sceneTarget, request.typeCode), amount: request.amountDisplay, status: '待补充', tone: 'bad', attachmentStatus: '待上传', attachmentHint: '请在此单据中继续补充附件', attachmentTone: 'missing', attachments: [], riskLabel: '待补材料', riskText: request.riskSummary, riskTone: 'medium' }, 0, request) ] } function isPlaceholderValue(value) { const text = String(value || '').trim() if (!text) { return true } return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, '')) } function isValidIsoDate(value) { const normalized = String(value || '').trim() if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { return false } const [yearText, monthText, dayText] = normalized.split('-') const year = Number(yearText) const month = Number(monthText) const day = Number(dayText) if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { return false } const candidate = new Date(Date.UTC(year, month - 1, day)) return ( candidate.getUTCFullYear() === year && candidate.getUTCMonth() === month - 1 && candidate.getUTCDate() === day ) } function normalizeIsoDateValue(value) { const normalized = String(value || '').trim() if (isValidIsoDate(normalized)) { return normalized } const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/) if (match && isValidIsoDate(match[1])) { return match[1] } const candidate = value instanceof Date ? value : new Date(normalized) if (Number.isNaN(candidate.getTime())) { return '' } const year = candidate.getFullYear() const month = String(candidate.getMonth() + 1).padStart(2, '0') const day = String(candidate.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } function resolveExpenseUploadHint(value) { const normalized = String(value || '').trim() return normalized || '支持上传 JPG、PNG、PDF,未上传也可先保存草稿' } function extractAttachmentDisplayName(value) { const normalized = String(value || '').trim() if (!normalized) { return '' } return normalized.split('/').filter(Boolean).pop() || normalized } function buildExpenseItemViewModel(source, index, requestModel) { const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other') const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim() const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim() const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date) const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount) const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim() const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim() const attachments = invoiceId ? [attachmentName || invoiceId] : [] const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充' const riskText = String(source?.riskText || '').trim() return { id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`), itemDate, itemType, itemReason, itemLocation, itemAmount, invoiceId, time: itemDate || '待补充', dayLabel: requestModel?.detailVariant === 'travel' ? `第 ${index + 1} 项` : '业务发生项', name: resolveExpenseTypeLabel(itemType), category: resolveExpenseTypeLabel(itemType), desc: itemReason || '待补充', detail: resolveLocationDisplay(itemLocation, itemType), amount: amountDisplay, status: attachments.length ? '已识别' : '待补充', tone: attachments.length ? 'ok' : 'bad', attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传', attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(), attachmentTone: attachments.length ? 'ok' : 'missing', attachments, riskLabel: String(source?.riskLabel || '').trim() || '无', riskText, riskTone: String(source?.riskTone || '').trim() || 'low' } } function rebuildExpenseItems(items, requestModel) { return items.map((item, index) => buildExpenseItemViewModel(item, index, requestModel)) } function buildExpenseDraftIssues(item) { const issues = [] const locationRequired = isLocationRequiredExpenseType(item.itemType) if (!isValidIsoDate(item.itemDate)) { issues.push('缺少日期') } if (isPlaceholderValue(item.itemType)) { issues.push('缺少费用项目') } if (isPlaceholderValue(item.itemReason)) { issues.push('缺少说明') } if (locationRequired && 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 = [] const locationRequired = isLocationRequiredExpenseType(request.typeCode) if (isPlaceholderValue(request.profileName)) { issues.push('申请人未完善') } if (isPlaceholderValue(request.typeLabel)) { issues.push('报销类型未完善') } if (isPlaceholderValue(request.reason)) { issues.push('报销事由未完善') } if (locationRequired && 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)] } function mapIssueToAdvice(issue) { const text = String(issue || '').trim() if (!text) { return '' } if (text === '费用明细不能为空') { return '先新增至少 1 条费用明细,再补充金额、用途和附件。' } if (text === '申请人未完善') { return '补充申请人信息,确保审批单据归属明确。' } if (text === '所属部门未完善') { return '补充所属部门,便于财务和审批人识别成本归属。' } if (text === '报销类型未完善') { return '选择报销类型,明确本次费用归类。' } if (text === '报销事由未完善') { return '补充报销事由,说明本次费用用途。' } if (text === '业务地点未完善') { return '补充业务地点,方便审核业务发生场景。' } if (text === '发生时间未完善') { return '补充费用发生时间,确保单据时间完整。' } if (text === '报销金额未完善') { return '补充报销金额,并与费用明细金额保持一致。' } const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/) if (!itemMatch) { return text } const [, indexText, fieldText] = itemMatch const labelPrefix = `完善第 ${indexText} 条费用明细` if (fieldText === '缺少日期') { return `${labelPrefix}的发生日期。` } if (fieldText === '缺少费用项目') { return `${labelPrefix}的费用项目。` } if (fieldText === '缺少说明') { return `${labelPrefix}的用途说明。` } if (fieldText === '缺少地点') { return `${labelPrefix}的业务地点。` } if (fieldText === '缺少金额') { return `${labelPrefix}的金额。` } if (fieldText === '缺少票据标识') { return `为第 ${indexText} 条费用明细上传或关联票据附件。` } return `${labelPrefix}。` } 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 { currentUser } = useSystemState() const editingExpenseId = ref('') const savingExpenseId = ref('') const creatingExpense = ref(false) const uploadingExpenseId = ref('') const deletingAttachmentId = ref('') const deletingExpenseId = ref('') const pendingUploadExpenseId = ref('') const submitBusy = ref(false) const deleteBusy = ref(false) const deleteDialogOpen = ref(false) const returnBusy = ref(false) const returnDialogOpen = ref(false) const expenseUploadInput = ref(null) const expenseAttachmentMeta = reactive({}) const attachmentPreviewOpen = ref(false) const attachmentPreviewLoading = ref(false) const attachmentPreviewError = ref('') const attachmentPreviewUrl = ref('') const attachmentPreviewName = ref('') const attachmentPreviewMediaType = ref('') 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: '', profileIdentity: '员工', profilePosition: '待补充', profileGrade: '待补充', profileManager: '待补充', profileName: '当前申请人', profileDepartment: '待补充部门', profileAvatar: '申' } ) }) const isTravelRequest = computed(() => request.value.detailVariant === 'travel') const isDraftRequest = computed(() => request.value.approvalKey === 'draft') const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey)) const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value)) const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value) const canReturnRequest = computed(() => canManageCurrentClaim.value && request.value.approvalKey === 'in_progress' && Boolean(request.value.claimId) ) const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据')) const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`) const deleteDialogDescription = computed(() => isDraftRequest.value ? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。' : '删除后该报销单及费用明细将不可恢复,请确认本次操作。' ) const actionBusy = computed(() => Boolean(savingExpenseId.value) || submitBusy.value || deleteBusy.value || returnBusy.value || creatingExpense.value || Boolean(uploadingExpenseId.value) || Boolean(deletingAttachmentId.value) || Boolean(deletingExpenseId.value) ) const profile = computed(() => ({ name: request.value.profileName, identity: request.value.profileIdentity, position: request.value.profilePosition, department: request.value.profileDepartment, grade: request.value.profileGrade, manager: request.value.profileManager, avatar: request.value.profileAvatar })) const expenseItems = ref([]) watch( request, (nextRequest, previousRequest) => { expenseItems.value = Array.isArray(nextRequest.expenseItems) ? rebuildExpenseItems(nextRequest.expenseItems, nextRequest) : buildFallbackExpenseItems(nextRequest) if (nextRequest.claimId !== previousRequest?.claimId) { Object.keys(expenseAttachmentMeta).forEach((key) => { delete expenseAttachmentMeta[key] }) closeAttachmentPreview() } pendingUploadExpenseId.value = '' uploadingExpenseId.value = '' deletingExpenseId.value = '' editingExpenseId.value = '' void syncExpenseAttachmentMeta() }, { immediate: true } ) const heroFactItems = computed(() => [ { key: 'document', label: '报销单号', value: request.value.documentNo || request.value.id, icon: 'mdi mdi-camera-outline', valueClass: '' }, { key: 'date', label: '日期', value: request.value.applyTime || request.value.occurredDisplay, icon: 'mdi mdi-calendar-month-outline', valueClass: '' }, { key: 'amount', label: '报销金额', value: request.value.amountDisplay, icon: '', valueClass: 'amount' }, { key: 'type', label: isTravelRequest.value ? '差旅类型' : '报销类型', value: request.value.typeLabel, icon: '', valueClass: '' }, { key: 'status', label: '当前状态', value: request.value.node, icon: '', valueClass: 'status' } ]) 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 hasExpenseRiskColumn = computed(() => expenseItems.value.some((item) => item.attachments.length)) const expenseTableColumnCount = computed( () => 5 + (hasExpenseRiskColumn.value ? 1 : 0) + (isEditableRequest.value ? 1 : 0) ) const expenseSummaryText = computed( () => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。' ) const detailNote = computed( () => request.value.note || '暂无附加说明。可在这里补充特殊背景、例外原因、补件计划或其他需要财务和审批人重点关注的信息。' ) const draftBlockingIssues = computed(() => isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : [] ) const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value) const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType)) function applyLocalExpenseItemPatch(itemId, patch) { expenseItems.value = rebuildExpenseItems( expenseItems.value.map((item) => (item.id === itemId ? { ...item, ...patch } : item)), request.value ) } function resolveAttachmentMeta(item) { return expenseAttachmentMeta[item.id] || null } function resolveAttachmentDisplayName(item) { const metadata = resolveAttachmentMeta(item) return String(metadata?.file_name || item.attachmentHint || '').trim() } function resolveAttachmentRecognition(item) { const metadata = resolveAttachmentMeta(item) const documentInfo = metadata?.document_info const requirementCheck = metadata?.requirement_check if (!documentInfo && !requirementCheck) { return null } const fields = Array.isArray(documentInfo?.fields) ? documentInfo.fields .map((field) => ({ label: String(field?.label || '').trim(), value: String(field?.value || '').trim() })) .filter((field) => field.label && field.value) : [] return { documentTypeLabel: String(documentInfo?.document_type_label || '').trim() || resolveDocumentTypeLabel(documentInfo?.document_type), requirementLabel: requirementCheck ? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型') : '待校验附件类型', requirementTone: requirementCheck ? (requirementCheck.matches ? 'pass' : 'high') : 'medium', message: String(requirementCheck?.message || '').trim(), fields: fields.slice(0, 4).map((field) => `${field.label}:${field.value}`) } } function buildAttachmentRiskNotice(attachment) { const analysis = attachment?.analysis const severity = String(analysis?.severity || '').trim() if (!analysis || severity === 'pass') { return '' } const label = String(analysis?.label || '').trim() || (severity === 'high' ? '高风险' : severity === 'medium' ? '中风险' : '低风险') const summary = String(analysis?.summary || analysis?.headline || '').trim() || '附件存在待核对风险。' return `${label}:${summary}` } async function refreshExpenseAttachmentMeta(itemId) { if (!request.value.claimId || !itemId) { return null } const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, itemId) expenseAttachmentMeta[itemId] = payload return payload } function canPreviewAttachment(item) { const metadata = resolveAttachmentMeta(item) return Boolean(item.invoiceId && metadata?.previewable) } function revokeAttachmentPreviewUrl() { if (attachmentPreviewUrl.value && attachmentPreviewUrl.value.startsWith('blob:')) { URL.revokeObjectURL(attachmentPreviewUrl.value) } attachmentPreviewUrl.value = '' } function closeAttachmentPreview() { attachmentPreviewOpen.value = false attachmentPreviewLoading.value = false attachmentPreviewError.value = '' attachmentPreviewName.value = '' attachmentPreviewMediaType.value = '' revokeAttachmentPreviewUrl() } async function syncExpenseAttachmentMeta() { if (!request.value.claimId) { return } const tasks = expenseItems.value .filter((item) => item.invoiceId) .map(async (item) => { try { const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, item.id) expenseAttachmentMeta[item.id] = payload } catch { delete expenseAttachmentMeta[item.id] } }) Object.keys(expenseAttachmentMeta).forEach((itemId) => { if (!expenseItems.value.some((item) => item.id === itemId && item.invoiceId)) { delete expenseAttachmentMeta[itemId] } }) await Promise.allSettled(tasks) } function resolveExpenseIssues(item) { return buildExpenseDraftIssues(item) } function resolveExpenseRiskState(item) { if (!item.invoiceId) { return null } if (uploadingExpenseId.value === item.id) { return { label: 'AI识别中', tone: 'medium', headline: 'AI提示:正在分析附件内容', summary: '附件已上传,系统正在识别票据内容与风险点,请稍候。', points: [], suggestion: '' } } const metadata = resolveAttachmentMeta(item) const analysis = metadata?.analysis if (analysis) { return { label: analysis.label || '已上传', tone: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low', headline: analysis.headline || 'AI提示', summary: analysis.summary || '', points: Array.isArray(analysis.points) ? analysis.points : [], suggestion: analysis.suggestion || '' } } return { label: '已上传', tone: 'low', headline: 'AI提示:附件已上传', summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。', points: [], suggestion: '' } } function showExpenseRisk(item) { return Boolean(resolveExpenseRiskState(item)) } const aiAdvice = computed(() => { const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean) const riskItems = expenseItems.value .map((item, index) => { const state = resolveExpenseRiskState(item) if (!state || !['medium', 'high'].includes(state.tone)) { return '' } const adviceText = String(state.suggestion || state.summary || '').trim() const prefix = state.tone === 'high' ? '优先整改' : '继续核对' return `第 ${index + 1} 条附件需${prefix}:${adviceText || '请根据系统提示补充或更换附件。'}` }) .filter(Boolean) if (!completionItems.length && !riskItems.length) { return { tone: 'ready', badge: '可直接提交', summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。', items: [ '点击右下角“提交审批”进入流程。', '提交前再核对一次合计金额与各条费用明细金额是否一致。', '如有特殊业务背景或例外情况,可在下方附加说明中补充。' ] } } const hasHighRisk = expenseItems.value.some((item) => resolveExpenseRiskState(item)?.tone === 'high') return { tone: hasHighRisk ? 'warning' : 'pending', badge: hasHighRisk ? '优先整改' : '待补信息', summary: completionItems.length ? '建议先补齐必填信息,再处理附件核验项,完成后即可提交审批。' : '草稿信息已基本齐全,建议先处理附件风险后再提交审批。', items: [...completionItems, ...riskItems] } }) function startExpenseEdit(item) { if (!isEditableRequest.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 || (['待补充', '非必填'].includes(item.detail) ? '' : item.detail) expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : '' expenseEditor.invoiceId = item.invoiceId || '' } 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 ( isLocationRequiredExpenseType(expenseEditor.itemType) && isPlaceholderValue(expenseEditor.itemLocation) ) { return '请输入业务地点。' } const amount = Number(expenseEditor.itemAmount) if (!Number.isFinite(amount) || amount <= 0) { return '请输入大于 0 的费用金额。' } return '' } async function handleAddExpenseItem() { if (!isEditableRequest.value || actionBusy.value) { return } if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法新增费用明细。') return } creatingExpense.value = true try { const existingIds = new Set(expenseItems.value.map((item) => item.id)) const claim = await createExpenseClaimItem(request.value.claimId, {}) const createdItem = Array.isArray(claim?.items) ? claim.items.find((entry) => !existingIds.has(String(entry?.id || ''))) : null if (!createdItem) { throw new Error('新增费用明细失败,请稍后重试。') } const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value) expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value) creatingExpense.value = false startExpenseEdit(nextItem) toast('已新增一条费用明细,请继续填写。') } catch (error) { toast(error?.message || '新增费用明细失败,请稍后重试。') } finally { creatingExpense.value = false } } function triggerExpenseUpload(item) { if (!isEditableRequest.value || actionBusy.value) { return } if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法上传附件。') return } pendingUploadExpenseId.value = item.id if (expenseUploadInput.value) { expenseUploadInput.value.value = '' expenseUploadInput.value.click() } } async function openAttachmentPreview(item) { if (!request.value.claimId || !canPreviewAttachment(item)) { return } closeAttachmentPreview() attachmentPreviewOpen.value = true attachmentPreviewLoading.value = true attachmentPreviewName.value = resolveAttachmentDisplayName(item) const metadata = resolveAttachmentMeta(item) attachmentPreviewMediaType.value = String(metadata?.preview_kind || '').trim() === 'image' ? 'image/png' : String(metadata?.media_type || '').trim() try { const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id) revokeAttachmentPreviewUrl() attachmentPreviewUrl.value = URL.createObjectURL(blob) attachmentPreviewMediaType.value = blob.type || attachmentPreviewMediaType.value } catch (error) { attachmentPreviewError.value = error?.message || '附件预览失败,请稍后重试。' } finally { attachmentPreviewLoading.value = false } } async function uploadExpenseFile(item, file) { if (!item || !file) { return } uploadingExpenseId.value = item.id try { const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file) expenseAttachmentMeta[item.id] = payload?.attachment || null applyLocalExpenseItemPatch(item.id, { invoiceId: String(payload?.invoice_id || '').trim(), attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim() }) if (editingExpenseId.value === item.id) { expenseEditor.invoiceId = String(payload?.invoice_id || '').trim() } emit('request-updated', { claimId: request.value.claimId }) const riskNotice = buildAttachmentRiskNotice(payload?.attachment) toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`) } catch (error) { toast(error?.message || '附件上传失败,请稍后重试。') } finally { uploadingExpenseId.value = '' } } async function removeExpenseAttachment(item) { if (!request.value.claimId || !item?.invoiceId || actionBusy.value) { return } deletingAttachmentId.value = item.id try { const payload = await deleteExpenseClaimItemAttachment(request.value.claimId, item.id) delete expenseAttachmentMeta[item.id] applyLocalExpenseItemPatch(item.id, { invoiceId: '', attachmentHint: resolveExpenseUploadHint() }) if (editingExpenseId.value === item.id) { expenseEditor.invoiceId = '' } if (attachmentPreviewOpen.value) { closeAttachmentPreview() } emit('request-updated', { claimId: request.value.claimId }) toast(payload?.message || '附件已删除。') } catch (error) { toast(error?.message || '附件删除失败,请稍后重试。') } finally { deletingAttachmentId.value = '' } } async function handleExpenseFileChange(event) { const target = event?.target const file = target?.files?.[0] const itemId = pendingUploadExpenseId.value pendingUploadExpenseId.value = '' if (target) { target.value = '' } if (!file || !itemId) { return } const item = expenseItems.value.find((entry) => entry.id === itemId) if (!item) { toast('未找到对应的费用明细,请刷新后重试。') return } await uploadExpenseFile(item, file) } async function removeExpenseItem(item) { if (!request.value.claimId || !item?.id || actionBusy.value) { return } deletingExpenseId.value = item.id try { const payload = await deleteExpenseClaimItem(request.value.claimId, item.id) delete expenseAttachmentMeta[item.id] expenseItems.value = rebuildExpenseItems( expenseItems.value.filter((entry) => entry.id !== item.id), request.value ) if (editingExpenseId.value === item.id) { editingExpenseId.value = '' expenseEditor.itemDate = '' expenseEditor.itemType = 'other' expenseEditor.itemReason = '' expenseEditor.itemLocation = '' expenseEditor.itemAmount = '' expenseEditor.invoiceId = '' } if (pendingUploadExpenseId.value === item.id) { pendingUploadExpenseId.value = '' } if (attachmentPreviewOpen.value) { closeAttachmentPreview() } emit('request-updated', { claimId: request.value.claimId }) toast(payload?.message || '费用明细已删除。') } catch (error) { toast(error?.message || '费用明细删除失败,请稍后重试。') } finally { deletingExpenseId.value = '' } } async function saveExpenseEdit(item) { if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法保存费用明细。') return } const validationError = validateExpenseEditor() if (validationError) { toast(validationError) return } savingExpenseId.value = item.id try { const nextInvoiceId = expenseEditor.invoiceId.trim() 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: nextInvoiceId }) applyLocalExpenseItemPatch(item.id, { itemDate: expenseEditor.itemDate, itemType: expenseEditor.itemType, itemReason: expenseEditor.itemReason.trim(), itemLocation: expenseEditor.itemLocation.trim(), itemAmount: Number(expenseEditor.itemAmount), invoiceId: nextInvoiceId }) let riskNotice = '' if (nextInvoiceId) { try { const attachment = await refreshExpenseAttachmentMeta(item.id) riskNotice = buildAttachmentRiskNotice(attachment) } catch { delete expenseAttachmentMeta[item.id] } } else { delete expenseAttachmentMeta[item.id] } editingExpenseId.value = '' toast(riskNotice || '费用明细已保存。') 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 { const payload = await submitExpenseClaim(request.value.claimId) const claimStatus = String(payload?.status || '').trim().toLowerCase() const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim() if (claimStatus === 'submitted') { toast(`${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`) } else if (claimStatus === 'supplement') { toast(`${request.value.id} AI预审未通过,已转待补充。`) } else { toast(`${request.value.id} 提交结果已更新。`) } emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '提交审批失败,请稍后重试。') } finally { submitBusy.value = false } } async function handleDeleteRequest() { if (!request.value.claimId) { toast('当前单据缺少 claimId,暂时无法删除。') return } if (!canDeleteRequest.value) { toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。') return } deleteDialogOpen.value = true } function closeDeleteDialog() { if (deleteBusy.value) { return } deleteDialogOpen.value = false } async function confirmDeleteRequest() { 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 handleReturnRequest() { if (!request.value.claimId) { toast('当前单据缺少 claimId,暂时无法退回。') return } if (!canReturnRequest.value) { toast('当前状态不支持退回。') return } returnDialogOpen.value = true } function closeReturnDialog() { if (returnBusy.value) { return } returnDialogOpen.value = false } async function confirmReturnRequest() { if (!request.value.claimId) { toast('当前单据缺少 claimId,暂时无法退回。') return } returnBusy.value = true try { await returnExpenseClaim(request.value.claimId, { reason: '详情页退回,请申请人调整后重新提交。' }) returnDialogOpen.value = false toast(`${request.value.id} 已退回待提交。`) emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '退回单据失败,请稍后重试。') } finally { returnBusy.value = false } } function openAiEntry() { emit('openAssistant', { source: 'detail', prompt: '', request: request.value, restoreLatestConversation: true }) } onBeforeUnmount(() => { closeAttachmentPreview() }) return { emit, actionBusy, aiAdvice, attachmentPreviewError, attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen, attachmentPreviewUrl, canDeleteRequest, canManageCurrentClaim, canReturnRequest, canSubmit, canPreviewAttachment, closeDeleteDialog, closeAttachmentPreview, closeReturnDialog, confirmDeleteRequest, confirmReturnRequest, currentProgressRingMotion, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen, deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor, expenseItems, expenseSummaryText, expenseTableColumnCount, expenseTotal, expenseUploadInput, expenseTypeOptions: EXPENSE_TYPE_OPTIONS, handleAddExpenseItem, handleDeleteRequest, handleExpenseFileChange, handleReturnRequest, handleSubmit, hasExpenseRiskColumn, heroFactItems, isDraftRequest, isEditableRequest, isTravelRequest, locationInputPlaceholder, openAiEntry, openAttachmentPreview, profile, progressSteps, removeExpenseItem, request, removeExpenseAttachment, resolveAttachmentDisplayName, resolveAttachmentRecognition, resolveExpenseRiskState, resolveExpenseIssues, returnBusy, returnDialogOpen, savingExpenseId, showExpenseRisk, startExpenseEdit, submitBusy, triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, cancelExpenseEdit, saveExpenseEdit } } }