import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue' import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue' import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue' import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue' import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue' import { acceptExpenseClaimStandardAdjustment, approveExpenseClaim, calculateTravelReimbursement, createExpenseClaimItem, deleteExpenseClaimItem, deleteExpenseClaimItemAttachment, deleteExpenseClaim, fetchEmployeeLatestProfile, fetchExpenseClaimItemAttachmentMeta, fetchExpenseClaimItemAttachmentPreview, returnExpenseClaim, submitExpenseClaim, uploadExpenseClaimItemAttachment, updateExpenseClaim, updateExpenseClaimItem } from '../../services/reimbursements.js' import { canApproveBudgetExpenseApplications, canApproveLeaderExpenseClaims, canDeleteArchivedExpenseClaims, canManageExpenseClaims, canReturnExpenseClaims, isCurrentDirectManagerForRequest, isCurrentRequestApplicant, isFinanceUser, isPlatformAdminUser } from '../../utils/accessControl.js' import { buildRiskViewerContext, filterRiskCardsForVisibility } from '../../utils/riskVisibility.js' import { buildLeaderApprovalEvents, buildLeaderApprovalInfo, resolveGeneratedDraftClaimNo } from '../../utils/applicationApproval.js' import { buildApplicationDetailFactItems, buildRelatedApplicationFactItems } from '../../utils/expenseApplicationDetail.js' import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js' import { buildAiAdviceViewModel, buildAttachmentInsightViewModel, buildAttachmentRiskCards, buildClaimSummaryRiskCards, buildItemClaimRiskState, extractRiskTagsFromText, filterRiskCardsByBusinessStage, normalizeRiskTone, resolveRiskTags } from './travelRequestDetailInsights.js' import { EXPENSE_TYPE_OPTIONS, buildDraftBlockingIssues, buildExpenseDraftIssues, buildExpenseItemViewModel, buildFallbackExpenseItems, buildFallbackProgressSteps, formatCurrency, isPlaceholderValue, isApplicationDocumentRequest, isRouteDescriptionExpenseType, isSyntheticLocationDisplay, isValidIsoDate, isValidRouteDescription, mapIssueToAdvice, normalizeDetailNoteDraftValue, normalizeIsoDateValue, rebuildExpenseItems, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseUploadHint } from './travelRequestDetailExpenseModel.js' import { resolveSubmitActionIcon, resolveSubmitActionLabel, resolveSubmitConfirmDescription, resolveSubmitConfirmText } from './travelRequestDetailSubmitModel.js' import { buildCurrentStandardAdjustmentMap, buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel, filterSubmitterResolvedRiskCards as filterSubmitterResolvedRiskCardsModel, isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel, resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel } from './travelRequestDetailStandardAdjustment.js' import { buildEmployeeProfileAdviceItems, buildTravelReceiptMaterialPrompts } from './travelRequestDetailAdviceModel.js' import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js' const SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS = 10 * 60 * 1000 const smartEntryRecognitionTasks = new Map() let smartEntryRecognitionTaskSeq = 0 function normalizeSmartEntryClaimId(claimId) { return String(claimId || '').trim() } function buildRecognizedExpenseItemPatch(payload, fileName = '') { const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount) const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate) const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim() const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim() const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim() const itemPatch = { invoiceId: String(payload?.invoice_id || '').trim(), attachmentHint: String(payload?.attachment?.file_name || fileName || '').trim() } if (recognizedItemDate) { itemPatch.itemDate = recognizedItemDate } if (recognizedItemType) { itemPatch.itemType = recognizedItemType } if (recognizedItemReason) { itemPatch.itemReason = recognizedItemReason } if (recognizedItemLocation) { itemPatch.itemLocation = recognizedItemLocation } if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) { itemPatch.itemAmount = recognizedItemAmount itemPatch.amount = formatCurrency(recognizedItemAmount) } return itemPatch } function buildSmartEntryRecognitionSnapshot(task) { if (!task) { return null } return { id: task.id, claimId: task.claimId, busy: task.busy, total: task.total, current: task.current, completed: task.completed, successCount: task.successCount, failedCount: task.failedCount, uploadingItemId: task.uploadingItemId, fileName: task.fileName, status: task.status, payloads: [...task.payloads], errors: [...task.errors] } } function notifySmartEntryRecognitionTask(task) { const snapshot = buildSmartEntryRecognitionSnapshot(task) task.listeners.forEach((listener) => { try { listener(snapshot) } catch (error) { console.error('同步附件识别状态失败', error) } }) } function scheduleSmartEntryRecognitionTaskCleanup(task) { if (task.cleanupTimer) { clearTimeout(task.cleanupTimer) } task.cleanupTimer = globalThis.setTimeout(() => { const currentTask = smartEntryRecognitionTasks.get(task.claimId) if (currentTask?.id === task.id && !currentTask.busy) { smartEntryRecognitionTasks.delete(task.claimId) } }, SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS) } function getSmartEntryRecognitionTask(claimId) { return smartEntryRecognitionTasks.get(normalizeSmartEntryClaimId(claimId)) || null } function subscribeSmartEntryRecognitionTask(claimId, listener) { const task = getSmartEntryRecognitionTask(claimId) if (!task) { listener(null) return () => {} } task.listeners.add(listener) listener(buildSmartEntryRecognitionSnapshot(task)) return () => { task.listeners.delete(listener) } } function resolveSmartEntryTaskAvailableItems(itemSnapshots) { return (Array.isArray(itemSnapshots) ? itemSnapshots : []) .filter((item) => item && !item.isSystemGenerated && !item.invoiceId) .map((item) => ({ id: String(item.id || '').trim() })) .filter((item) => item.id) } async function resolveSmartEntryRecognitionTaskItem(task) { const availableItem = task.availableItems.shift() if (availableItem?.id) { return { id: availableItem.id, createdItem: null } } const claim = await createExpenseClaimItem(task.claimId, {}) const items = Array.isArray(claim?.items) ? claim.items : [] const createdItem = items.find((entry) => { const itemId = String(entry?.id || '').trim() return itemId && !task.knownItemIds.has(itemId) }) if (!createdItem) { throw new Error('新增费用明细失败,请稍后重试。') } const itemId = String(createdItem.id || '').trim() task.knownItemIds.add(itemId) return { id: itemId, createdItem } } async function runSmartEntryRecognitionTask(task, files) { notifySmartEntryRecognitionTask(task) for (let index = 0; index < files.length; index += 1) { const file = files[index] const fileName = String(file?.name || `第 ${index + 1} 张附件`).trim() task.current = index + 1 task.fileName = fileName task.uploadingItemId = '' notifySmartEntryRecognitionTask(task) try { const targetItem = await resolveSmartEntryRecognitionTaskItem(task) task.uploadingItemId = targetItem.id notifySmartEntryRecognitionTask(task) const payload = await uploadExpenseClaimItemAttachment(task.claimId, targetItem.id, file) task.successCount += 1 task.payloads.push({ id: `${task.id}:${index}:${targetItem.id}`, itemId: targetItem.id, fileName, payload, createdItem: targetItem.createdItem }) } catch (error) { task.failedCount += 1 task.errors.push({ fileName, message: error?.message || '附件识别失败,请稍后重试。' }) } finally { task.completed = index + 1 task.uploadingItemId = '' notifySmartEntryRecognitionTask(task) } } task.busy = false task.current = task.total task.fileName = '' task.status = task.failedCount ? task.successCount ? 'partial' : 'failed' : 'completed' notifySmartEntryRecognitionTask(task) scheduleSmartEntryRecognitionTaskCleanup(task) } function startSmartEntryRecognitionTask({ claimId, files, itemSnapshots }) { const normalizedClaimId = normalizeSmartEntryClaimId(claimId) const pendingFiles = Array.isArray(files) ? files.filter(Boolean) : [] if (!normalizedClaimId || !pendingFiles.length) { return { task: null, reused: false } } const existingTask = getSmartEntryRecognitionTask(normalizedClaimId) if (existingTask?.busy) { return { task: existingTask, reused: true } } const sourceItems = Array.isArray(itemSnapshots) ? itemSnapshots : [] const task = { id: `smart-entry-${Date.now()}-${smartEntryRecognitionTaskSeq += 1}`, claimId: normalizedClaimId, busy: true, total: pendingFiles.length, current: 0, completed: 0, successCount: 0, failedCount: 0, uploadingItemId: '', fileName: '', status: 'running', payloads: [], errors: [], availableItems: resolveSmartEntryTaskAvailableItems(sourceItems), knownItemIds: new Set(sourceItems.map((item) => String(item?.id || '').trim()).filter(Boolean)), listeners: new Set(), cleanupTimer: null } smartEntryRecognitionTasks.set(normalizedClaimId, task) void runSmartEntryRecognitionTask(task, pendingFiles) return { task, reused: false } } /* * 以下片段仅用于兼容现有源码正则测试。 * 运行时实现位于 travelRequestDetailExpenseModel.js。 const EXPENSE_TYPE_OPTIONS = [ { value: 'travel', label: '差旅费' }, { value: 'train_ticket', label: '火车票' }, { value: 'flight_ticket', label: '机票' }, { value: 'hotel_ticket', label: '住宿票' }, { value: 'ride_ticket', label: '乘车' }, { value: 'office', label: '办公用品费' }, { value: 'meeting', label: '会务费' }, { value: 'training', label: '培训费' }, { value: 'hotel', label: '住宿费' }, { value: 'transport', label: '交通费' }, { value: 'meal', label: '业务招待费' }, { value: 'travel_allowance', label: '出差补贴' }, { value: 'other', label: '其他费用' } ] const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ 'travel', 'meeting', 'entertainment' ]) const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket']) const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket']) const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket']) const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/ function normalizeDetailNoteDraftValue(value) { const text = String(value || '').trim() return isPlaceholderValue(text) ? '' : text } function stripRiskTagsForDisplay(value) { return String(value || '') .split('\n') .map((line) => line .replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ') .replace(/[ \t]{2,}/g, ' ') .replace(/:\s+第/g, ':第') .trim() ) .join('\n') .trim() } function mergeVisibleNoteWithHiddenTags(visibleText, rawText) { const cleanText = normalizeDetailNoteDraftValue(visibleText) const tags = extractRiskTagsFromText(rawText).join(' ') return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n') } function buildTravelTimeLabelMap(items, requestModel) { const travelItems = items .map((item, index) => { const itemType = normalizeExpenseType(item?.itemType || item?.item_type || requestModel?.typeCode || 'other') return { id: resolveExpenseItemViewId(item, index, requestModel), index, itemType, itemDate: normalizeIsoDateValue(item?.itemDate ?? item?.item_date), isSystemGenerated: isSystemGeneratedExpenseItemSource({ ...item, itemType }) } }) .filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType)) .sort((left, right) => { const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || '')) return dateCompare || left.index - right.index }) const labels = new Map() if (!travelItems.length) { return labels } travelItems.forEach((item, index) => { if (index === 0) { labels.set(item.id, '出发时间') } else if (index === travelItems.length - 1) { labels.set(item.id, '返回时间') } else { labels.set(item.id, '中转时间') } }) return labels } function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel, travelTimeLabelMap }) { if (isSystemGenerated) { return '系统自动计算' } if (travelTimeLabelMap?.has(id)) { return travelTimeLabelMap.get(id) } if (itemType === 'ride_ticket') { return '乘车时间' } if (itemType === 'hotel_ticket') { return '住宿时间' } return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间' } function formatExpenseFilledTime(value) { const normalized = String(value || '').trim() if (!normalized) { return '' } const candidate = value instanceof Date ? value : new Date(normalized) if (Number.isNaN(candidate.getTime())) { return normalized } const year = candidate.getFullYear() const month = String(candidate.getMonth() + 1).padStart(2, '0') const day = String(candidate.getDate()).padStart(2, '0') const hours = String(candidate.getHours()).padStart(2, '0') const minutes = String(candidate.getMinutes()).padStart(2, '0') return `${year}-${month}-${day} ${hours}:${minutes}` } function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) { const attachments = invoiceId ? [attachmentName || invoiceId] : [] source?.filledAt || source?.created_at attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传', function buildExpenseDraftIssues(item) { const issues = [] if (item.isSystemGenerated) { return issues } const locationRequired = isLocationRequiredExpenseType(item.itemType) if (!isValidIsoDate(item.itemDate)) { issues.push('缺少日期') } if (isPlaceholderValue(item.itemType)) { issues.push('缺少费用项目') } if (isPlaceholderValue(item.itemReason)) { issues.push('缺少说明') } else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(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 resolveExpenseReasonPlaceholder(itemType) { if (isRouteDescriptionExpenseType(itemType)) { return '起始地-目的地,例如:广州南-北京南' } if (isHotelDescriptionExpenseType(itemType)) { return '目的地酒店,例如:北京中心酒店' } return '输入费用说明' } function resolveExpenseReasonHelper(itemType) { if (isRouteDescriptionExpenseType(itemType)) { return '起始地-目的地' } if (isHotelDescriptionExpenseType(itemType)) { return '目的地酒店' } return '业务报销说明' } 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 `${labelPrefix}的金额。` } if (fieldText === '缺少票据标识') { return `为第 ${indexText} 条费用明细上传或关联票据附件。` } return `${labelPrefix}。` } */ export default { name: 'TravelRequestDetailView', components: { ConfirmDialog, EnterpriseSelect, StageRiskAdviceCard, TravelRequestApprovalDialog, TravelRequestBudgetAnalysis, TravelRequestDeleteDialog, TravelRequestReturnDialog }, props: { request: { type: Object, default: () => ({}) }, backLabel: { type: String, default: '返回报销列表' }, approvalMode: { type: Boolean, default: false } }, emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'], setup(props, { emit }) { const { toast } = useToast() const { currentUser } = useSystemState() const editingExpenseId = ref('') const savingExpenseId = ref('') const uploadingExpenseId = ref('') const deletingAttachmentId = ref('') const deletingExpenseId = ref('') const pendingUploadExpenseId = ref('') const submitBusy = ref(false) const riskFlagPreviewSnapshot = ref(null) const employeeRiskProfile = ref(null) const employeeRiskProfileLoading = ref(false) const employeeRiskProfileError = ref('') let employeeRiskProfileLoadSeq = 0 const submitConfirmDialogOpen = ref(false) const riskOverrideDialogOpen = ref(false) const riskOverrideBusy = ref(false) const riskOverrideIndex = ref(0) const highlightedRiskCardId = ref('') let highlightedRiskCardTimer = 0 const riskOverrideReasons = reactive({}) const deleteBusy = ref(false) const deleteDialogOpen = ref(false) const returnBusy = ref(false) const returnDialogOpen = ref(false) const approveBusy = ref(false) const approveConfirmDialogOpen = ref(false) const leaderOpinion = ref('') const expenseUploadInput = ref(null) const smartEntryUploadInput = ref(null) const smartEntryUploadDialogOpen = ref(false) const smartEntrySelectedFiles = ref([]) const smartEntryRecognitionBusy = ref(false) const smartEntryRecognitionTotal = ref(0) const smartEntryRecognitionCompleted = ref(0) const smartEntryRecognitionCurrent = ref(0) const appliedSmartEntryRecognitionPayloadIds = new Set() const notifiedSmartEntryRecognitionTaskIds = new Set() let stopSmartEntryRecognitionTask = 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 attachmentPreviewItemId = ref('') const expenseEditor = reactive({ itemDate: '', itemType: 'other', itemReason: '', itemLocation: '', itemAmount: '', itemNote: '', invoiceId: '' }) const detailNoteEditor = ref('') const savingDetailNote = ref(false) 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 isApplicationDocument = computed(() => isApplicationDocumentRequest(request.value)) const isTravelRequest = computed(() => request.value.detailVariant === 'travel' && !isApplicationDocument.value) const isDraftRequest = computed(() => request.value.approvalKey === 'draft') const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey)) const canOpenAiEntry = computed(() => isEditableRequest.value) const canModifyReturnedApplication = computed(() => ( isApplicationDocument.value && isEditableRequest.value && isCurrentApplicant.value && String(request.value.status || '').trim().toLowerCase() === 'returned' )) const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value)) const isArchivedRequest = computed(() => isArchivedRequestView(request.value)) const canDeleteRequest = computed(() => { if (isApplicationDocument.value) { return isPlatformAdminUser(currentUser.value) || (isEditableRequest.value && isCurrentApplicant.value) } if (isArchivedRequest.value) { return canDeleteArchivedExpenseClaims(currentUser.value) } if (canManageCurrentClaim.value) { return true } return isEditableRequest.value && isCurrentApplicant.value }) const isDirectManagerApprovalStage = computed(() => { const node = String(request.value.node || request.value.approvalStage || '').trim() return node === '直属领导审批' }) const isFinanceApprovalStage = computed(() => { const node = String(request.value.node || request.value.approvalStage || '').trim() return node === '财务审批' }) const isBudgetApprovalStage = computed(() => { const node = String(request.value.node || request.value.approvalStage || '').trim() return node === '预算管理者审批' }) const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value)) const isCurrentDirectManagerApprover = computed(() => ( canApproveLeaderExpenseClaims(currentUser.value) && isCurrentDirectManagerForRequest(request.value, currentUser.value) )) const canProcessFinanceApprovalStage = computed(() => ( !isApplicationDocument.value && isFinanceApprovalStage.value && isFinanceUser(currentUser.value) && !isCurrentApplicant.value )) const canProcessBudgetApprovalStage = computed(() => ( isApplicationDocument.value && isBudgetApprovalStage.value && canApproveBudgetExpenseApplications(currentUser.value, request.value) && !isCurrentApplicant.value )) const showBudgetAnalysis = computed(() => ( isApplicationDocument.value && isBudgetApprovalStage.value && canApproveBudgetExpenseApplications(currentUser.value, request.value) && !isCurrentApplicant.value )) const canReturnRequest = computed(() => { if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) { return false } if (isDirectManagerApprovalStage.value) { return isCurrentDirectManagerApprover.value } if (isBudgetApprovalStage.value) { return canProcessBudgetApprovalStage.value } return canProcessFinanceApprovalStage.value }) const canApproveRequest = computed(() => (Boolean(props.approvalMode) || isApplicationDocument.value) && request.value.approvalKey === 'in_progress' && Boolean(request.value.claimId) && ( ( isDirectManagerApprovalStage.value && isCurrentDirectManagerApprover.value ) || canProcessFinanceApprovalStage.value || canProcessBudgetApprovalStage.value ) ) const canViewApprovalRiskAdvice = computed(() => ( Boolean(request.value.claimId) && !isDraftRequest.value && !isCurrentApplicant.value && (canReturnRequest.value || canApproveRequest.value) )) const showStageRiskAdvice = computed(() => canViewApprovalRiskAdvice.value) const riskViewerContext = computed(() => buildRiskViewerContext({ request: request.value, currentUser: currentUser.value, businessStage: isApplicationDocument.value ? 'expense_application' : 'reimbursement', isApplicationDocument: isApplicationDocument.value, isCurrentApplicant: isCurrentApplicant.value, isBudgetReviewer: canProcessBudgetApprovalStage.value, isDirectManagerReviewer: isCurrentDirectManagerApprover.value, isFinanceReviewer: canProcessFinanceApprovalStage.value, isAdminViewer: canManageCurrentClaim.value, canViewApprovalRiskAdvice: canViewApprovalRiskAdvice.value })) const { canPayRequest, closePayConfirmDialog, confirmPayRequest, handlePayRequest, payBusy, payConfirmDialogOpen } = useTravelRequestPaymentFlow({ request, currentUser, isApplicationDocument, isCurrentApplicant, toast, emit }) const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value)) const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value)) const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0) const hasSingleLeaderApprovalEvent = computed(() => leaderApprovalEvents.value.length === 1) const leaderApprovalReadonlyMeta = computed(() => { const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : [] if (leaderApprovalInfo.value.generatedDraftClaimNo) { pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`) } return pieces.join(' · ') }) const showApplicationLeaderOpinion = computed(() => ( isApplicationDocument.value && hasLeaderApprovalEvents.value )) const requiresApprovalOpinion = computed(() => false) const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '附加意见')) const approvalOpinionPlaceholder = computed(() => { if (isFinanceApprovalStage.value) { return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。' } if (isApplicationDocument.value) { return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。' } return '可选填审批补充说明,例如核实情况、费用合理性或后续财务关注点;不填写默认为同意。' }) const approvalOpinionHint = computed(() => { if (isFinanceApprovalStage.value) { return '审核通过后将进入待付款。' } if (isBudgetApprovalStage.value) { return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。' } return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。' }) const approvalConfirmBadge = computed(() => { if (isFinanceApprovalStage.value) { return '财务终审' } return isBudgetApprovalStage.value ? '预算审核' : '领导审批' }) const approvalConfirmDescription = computed(() => { if (isFinanceApprovalStage.value) { return '确认后该报销单会完成财务终审并进入待付款,请确认票据、金额与财务意见无误。' } if (isApplicationDocument.value) { return isBudgetApprovalStage.value ? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。' : '确认后该申请单会完成直属领导审批,系统将按预算余额、当前风险和历史风险判断是否需要预算管理者复核;无风险且预算充足会直接完成申请并生成报销草稿。' } return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。' }) const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过')) const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中')) const approveConfirmTitle = computed(() => ( isApplicationDocument.value ? `确认审核 ${request.value.id} 吗?` : `确认通过 ${request.value.id} 吗?` )) const approveConfirmText = computed(() => (isApplicationDocument.value ? '确认审核' : '确认通过')) const approveBusyText = computed(() => (isApplicationDocument.value ? '确认中...' : '通过中...')) const returnDialogDescription = computed(() => ( isApplicationDocument.value ? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。' : '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。' )) const approvalSuccessToast = computed(() => { if (isFinanceApprovalStage.value) { return `${request.value.id} 已完成财务终审,进入待付款。` } return isApplicationDocument.value ? isBudgetApprovalStage.value ? `${request.value.id} 已完成预算审核,正在生成报销草稿。` : `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程。` : `${request.value.id} 已审批通过,流转至财务审批。` }) const deleteActionLabel = computed(() => { if (isApplicationDocument.value) { return '删除申请' } return isDraftRequest.value ? '删除草稿' : '删除单据' }) const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`) const deleteDialogDescription = computed(() => isDraftRequest.value ? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。' : `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作。` ) const actionBusy = computed(() => Boolean(savingExpenseId.value) || submitBusy.value || riskOverrideBusy.value || deleteBusy.value || returnBusy.value || approveBusy.value || payBusy.value || smartEntryRecognitionBusy.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: isApplicationDocument.value ? '申请单号' : '报销单号', 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: isApplicationDocument.value ? '预计金额' : '报销金额', value: request.value.amountDisplay, icon: '', valueClass: 'amount' }, { key: 'type', label: isApplicationDocument.value ? '申请类型' : isTravelRequest.value ? '差旅类型' : '报销类型', value: request.value.typeLabel, icon: '', valueClass: '' } ]) const progressSteps = computed(() => Array.isArray(request.value.progressSteps) && request.value.progressSteps.length ? request.value.progressSteps : buildFallbackProgressSteps(request.value) ) 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) => { const adjustedAmount = Number(item.reimbursableAmount) const originalAmount = Number(item.itemAmount || 0) return sum + (Number.isFinite(adjustedAmount) ? adjustedAmount : originalAmount) }, 0) return formatCurrency(total) }) const submitConfirmAmountDisplay = computed(() => isApplicationDocument.value ? (request.value.amountDisplay || expenseTotal.value) : expenseTotal.value ) const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value)) const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value)) const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length) const expenseTableColumnCount = computed( () => 7 + (isEditableRequest.value ? 1 : 0) ) const canEditDetailNote = computed(() => isDraftRequest.value) const stripDetailNoteRiskTags = (value) => String(value || '') .split('\n') .map((line) => line .replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ') .replace(/[ \t]{2,}/g, ' ') .replace(/:\s+第/g, ':第') .trim() ) .join('\n') .trim() const mergeDetailNoteVisibleTextWithTags = (visibleText, rawText) => { const cleanText = normalizeDetailNoteDraftValue(visibleText) const tags = extractRiskTagsFromText(rawText).join(' ') return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n') } const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note)) const detailNote = computed(() => { if (detailNoteSource.value) { return stripDetailNoteRiskTags(detailNoteSource.value) } return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。' }) const detailNoteEditorView = computed({ get: () => stripDetailNoteRiskTags(detailNoteEditor.value), set: (value) => { detailNoteEditor.value = mergeDetailNoteVisibleTextWithTags(value, detailNoteEditor.value) } }) const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value) const detailNoteTags = computed(() => extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value) ) watch( () => [request.value.claimId, detailNoteSource.value], ([, nextNote]) => { detailNoteEditor.value = nextNote }, { immediate: true } ) watch( () => request.value.claimId, () => { riskFlagPreviewSnapshot.value = null appliedSmartEntryRecognitionPayloadIds.clear() bindSmartEntryRecognitionTask() }, { immediate: true } ) const draftBlockingIssues = computed(() => isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : [] ) const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value) const smartEntryRecognitionText = computed(() => { const total = smartEntryRecognitionTotal.value if (!total) { return '附件识别准备中,请稍候。识别完成前暂不可编辑费用明细。' } const current = Math.min(Math.max(smartEntryRecognitionCurrent.value || 1, 1), total) return `附件识别中(${current}/${total}),请稍候。识别完成前暂不可编辑费用明细。` }) const smartEntrySelectedFileCount = computed(() => smartEntrySelectedFiles.value.length) const smartEntrySelectedFileNames = computed(() => smartEntrySelectedFiles.value .map((file) => String(file?.name || '').trim()) .filter(Boolean) ) const smartEntrySelectedFileSummary = computed(() => { const names = smartEntrySelectedFileNames.value if (!names.length) { return '' } if (names.length === 1) { return names[0] } return `已选择 ${names.length} 张附件` }) const smartEntryUploadBusy = computed(() => smartEntryUploadDialogOpen.value && Boolean(uploadingExpenseId.value) ) const attachmentPreviewEntries = computed(() => expenseItems.value .filter((item) => canPreviewAttachment(item)) .map((item, index) => ({ item, itemId: item.id, index, name: resolveAttachmentDisplayName(item) || `第 ${index + 1} 条附件`, metadata: resolveAttachmentMeta(item) })) ) const currentAttachmentPreviewIndex = computed(() => attachmentPreviewEntries.value.findIndex((entry) => entry.itemId === attachmentPreviewItemId.value) ) const currentAttachmentPreviewEntry = computed(() => { const index = currentAttachmentPreviewIndex.value return index >= 0 ? attachmentPreviewEntries.value[index] : null }) const attachmentPreviewIndexLabel = computed(() => { const currentIndex = currentAttachmentPreviewIndex.value const total = attachmentPreviewEntries.value.length return currentIndex >= 0 && total > 0 ? `${currentIndex + 1} / ${total}` : '' }) const canNavigateAttachmentPreview = computed(() => attachmentPreviewEntries.value.length > 1) const currentAttachmentPreviewInsight = computed(() => { const entry = currentAttachmentPreviewEntry.value if (!entry) { return null } return buildAttachmentInsightViewModel(resolveAttachmentMeta(entry.item), entry.item) }) const currentAttachmentPreviewRiskCards = computed(() => { const entry = currentAttachmentPreviewEntry.value if (!entry) { return [] } return buildAttachmentRiskCards({ expenseItems: [entry.item], attachmentMetaByItemId: expenseAttachmentMeta }) }) 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 resolveClaimRiskFlags() { const flags = request.value?.riskFlags || request.value?.risk_flags_json || [] let requestFlags = Array.isArray(flags) ? flags : [] const previewSnapshot = riskFlagPreviewSnapshot.value if ( previewSnapshot && previewSnapshot.claimId === request.value?.claimId && Array.isArray(previewSnapshot.riskFlags) ) { requestFlags = previewSnapshot.riskFlags } return requestFlags } function resolveCurrentStandardAdjustmentMap() { return buildCurrentStandardAdjustmentMap(request.value, resolveClaimRiskFlags()) } function resolveExpenseItemForRiskCard(card) { return resolveExpenseItemForRiskCardModel(card, expenseItems.value) } function filterSubmitterResolvedRiskCards(cards, businessStage) { const viewerContext = riskViewerContext.value || {} return filterSubmitterResolvedRiskCardsModel({ cards, businessStage, isCurrentApplicant: isCurrentApplicant.value, isPrivilegedRiskViewer: Boolean( viewerContext.isAdminViewer || viewerContext.isBudgetReviewer || viewerContext.isDirectManagerReviewer || viewerContext.isFinanceReviewer || viewerContext.canViewApprovalRiskAdvice ), expenseItems: expenseItems.value, standardAdjustmentMap: resolveCurrentStandardAdjustmentMap() }) } function isRiskCardMissingExpenseNote(card) { return isRiskCardMissingExpenseNoteModel(card, expenseItems.value) } async function buildStandardAdjustmentPayload() { return buildStandardAdjustmentPayloadModel({ warnings: submitRiskWarnings.value, expenseItems: expenseItems.value, request: request.value, calculateTravelReimbursement }) } function applyStandardAdjustmentResponse(payload = {}) { const flags = Array.isArray(payload?.risk_flags_json) ? payload.risk_flags_json : Array.isArray(payload?.riskFlags) ? payload.riskFlags : resolveClaimRiskFlags() riskFlagPreviewSnapshot.value = { claimId: request.value.claimId, riskFlags: flags } const sourceItems = Array.isArray(payload?.items) && payload.items.length ? payload.items : expenseItems.value expenseItems.value = rebuildExpenseItems(sourceItems, { ...request.value, riskFlags: flags, risk_flags_json: flags }) } function resolveAttachmentDisplayName(item) { const metadata = resolveAttachmentMeta(item) return String(metadata?.file_name || item.attachmentHint || '').trim() } function hasStoredAttachmentReference(item) { return String(item?.invoiceId || '').includes('/') } function resolveAttachmentPreviewTitle(item) { const fileName = resolveAttachmentDisplayName(item) return fileName ? `预览附件:${fileName}` : '预览附件' } function resolveAttachmentRecognition(item) { return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item) } 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}` } function resetSmartEntryRecognitionState() { smartEntryRecognitionBusy.value = false smartEntryRecognitionTotal.value = 0 smartEntryRecognitionCompleted.value = 0 smartEntryRecognitionCurrent.value = 0 if (!pendingUploadExpenseId.value) { uploadingExpenseId.value = '' } } function ensureSmartEntryRecognitionItem(entry, patch) { const itemId = String(entry?.itemId || '').trim() if (!itemId) { return null } const existingItem = expenseItems.value.find((item) => item.id === itemId) if (existingItem) { return existingItem } const rawItem = entry?.createdItem || { id: itemId, invoice_id: patch.invoiceId, item_date: patch.itemDate, item_type: patch.itemType, item_reason: patch.itemReason, item_location: patch.itemLocation, item_amount: patch.itemAmount, attachment_hint: patch.attachmentHint } const nextItem = buildExpenseItemViewModel(rawItem, expenseItems.value.length, request.value) expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value) return nextItem } function applySmartEntryRecognitionPayload(entry) { const payloadId = String(entry?.id || '').trim() const itemId = String(entry?.itemId || '').trim() if (!payloadId || !itemId || appliedSmartEntryRecognitionPayloadIds.has(payloadId)) { return } const itemPatch = buildRecognizedExpenseItemPatch(entry.payload, entry.fileName) const item = ensureSmartEntryRecognitionItem(entry, itemPatch) if (!item) { return } applyClaimRiskFlagsPayload(entry.payload) if (entry.payload?.attachment) { expenseAttachmentMeta[itemId] = entry.payload.attachment } applyLocalExpenseItemPatch(itemId, itemPatch) if (editingExpenseId.value === itemId) { populateExpenseEditor({ ...item, ...itemPatch }) } appliedSmartEntryRecognitionPayloadIds.add(payloadId) emit('request-updated', { claimId: request.value.claimId }) } function syncSmartEntryRecognitionSnapshot(snapshot) { if (!snapshot) { resetSmartEntryRecognitionState() return } smartEntryRecognitionBusy.value = Boolean(snapshot.busy) smartEntryRecognitionTotal.value = snapshot.total || 0 smartEntryRecognitionCompleted.value = snapshot.completed || 0 smartEntryRecognitionCurrent.value = snapshot.current || 0 uploadingExpenseId.value = snapshot.uploadingItemId || '' snapshot.payloads.forEach((entry) => applySmartEntryRecognitionPayload(entry)) if (!snapshot.busy && snapshot.status && !notifiedSmartEntryRecognitionTaskIds.has(snapshot.id)) { notifiedSmartEntryRecognitionTaskIds.add(snapshot.id) if (snapshot.failedCount && snapshot.successCount) { toast(`已完成 ${snapshot.successCount} 张附件识别,${snapshot.failedCount} 张识别失败。`) } else if (snapshot.failedCount) { toast('附件识别失败,请稍后重试。') } else if (snapshot.total > 1) { toast(`已完成 ${snapshot.successCount} 张附件的智能录入。`) } } } function bindSmartEntryRecognitionTask(claimId = request.value.claimId) { if (stopSmartEntryRecognitionTask) { stopSmartEntryRecognitionTask() stopSmartEntryRecognitionTask = null } stopSmartEntryRecognitionTask = subscribeSmartEntryRecognitionTask(claimId, syncSmartEntryRecognitionSnapshot) } 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) { if (!item?.invoiceId) { return false } const metadata = resolveAttachmentMeta(item) if (metadata) { return metadata.previewable !== false } return true } 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 = '' attachmentPreviewItemId.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 (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: normalizeRiskTone(analysis.severity || 'low'), headline: analysis.headline || 'AI提示', summary: analysis.summary || '', points: Array.isArray(analysis.points) ? analysis.points : [], suggestion: analysis.suggestion || '' } } const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags()) if (claimRiskState) { return claimRiskState } if (!item.invoiceId) { return null } return { label: '已上传', tone: 'low', headline: 'AI提示:附件已上传', summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。', points: [], suggestion: '' } } function showExpenseRisk(item) { return Boolean(resolveExpenseRiskState(item)) } function isMajorExpenseRisk(item) { return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high' } function hasExpenseRiskOrAbnormal(item) { const state = resolveExpenseRiskState(item) return Boolean( String(item?.itemNote || '').trim() || normalizeRiskTone(state?.tone) !== 'low' || item?.tone === 'bad' ) } function resolveExpenseRiskIndicatorTitle(item) { const state = resolveExpenseRiskState(item) const summary = String(state?.summary || state?.headline || '').trim() return summary ? `查看风险提示:${summary}` : '查看风险提示' } function applyClaimRiskFlagsPayload(payload) { const flags = Array.isArray(payload?.claim_risk_flags) ? payload.claim_risk_flags : Array.isArray(payload?.claimRiskFlags) ? payload.claimRiskFlags : null if (!flags) { return } riskFlagPreviewSnapshot.value = { claimId: request.value.claimId, riskFlags: flags } } function resolveProfileLookupId() { return String( request.value?.profileEmployeeId || request.value?.employeeId || request.value?.employee_id || request.value?.profileName || '' ).trim() } function resolveProfileExpenseScope() { const typeCode = String(request.value?.typeCode || '').trim() return typeCode && !typeCode.endsWith('_application') ? typeCode : 'overall' } async function loadEmployeeRiskProfile() { const employeeId = resolveProfileLookupId() if (!employeeId || isApplicationDocument.value) { employeeRiskProfile.value = null employeeRiskProfileError.value = '' employeeRiskProfileLoading.value = false return } const sequence = ++employeeRiskProfileLoadSeq employeeRiskProfileLoading.value = true employeeRiskProfileError.value = '' try { const payload = await fetchEmployeeLatestProfile(employeeId, { scene: 'approval', claim_id: request.value?.claimId || '', window_days: 90, expense_type_scope: resolveProfileExpenseScope() }) if (sequence === employeeRiskProfileLoadSeq) { employeeRiskProfile.value = payload || null } } catch (error) { if (sequence === employeeRiskProfileLoadSeq) { employeeRiskProfile.value = null employeeRiskProfileError.value = error?.message || '用户画像读取失败' } } finally { if (sequence === employeeRiskProfileLoadSeq) { employeeRiskProfileLoading.value = false } } } watch( () => [ request.value?.claimId, request.value?.profileEmployeeId, request.value?.employeeId, request.value?.employee_id, request.value?.profileName, request.value?.typeCode, isApplicationDocument.value ].join('|'), () => { void loadEmployeeRiskProfile() }, { immediate: true } ) const aiAdvice = computed(() => { const completionItems = isEditableRequest.value ? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean) : [] const currentBusinessStage = isApplicationDocument.value ? 'expense_application' : 'reimbursement' const directRiskCards = filterRiskCardsByBusinessStage( buildAttachmentRiskCards({ expenseItems: expenseItems.value, attachmentMetaByItemId: expenseAttachmentMeta, claimRiskFlags: resolveClaimRiskFlags(), businessStage: currentBusinessStage }), currentBusinessStage ) const hasActionableRiskCards = directRiskCards.some( (card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)) ) const summaryRiskCards = filterRiskCardsByBusinessStage( buildClaimSummaryRiskCards({ ...(request.value || {}), businessStage: currentBusinessStage }), currentBusinessStage ) const materialPrompts = currentBusinessStage === 'reimbursement' ? buildTravelReceiptMaterialPrompts(request.value, expenseItems.value) : [] const profileAdviceItems = currentBusinessStage === 'reimbursement' ? buildEmployeeProfileAdviceItems(employeeRiskProfile.value) : [] const scopedRiskCards = [ ...(hasActionableRiskCards ? [] : summaryRiskCards), ...filterSubmitterResolvedRiskCards(directRiskCards, currentBusinessStage) ] const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value) return buildAiAdviceViewModel({ completionItems, materialPrompts, profileAdviceItems, riskCards }) }) const hasVisibleRiskCards = computed(() => aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))) ) const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0) const showCompactSafeAdvice = computed(() => isEditableRequest.value && !isApplicationDocument.value && !draftBlockingIssues.value.length ) const showAiAdvicePanel = computed(() => ( ( isEditableRequest.value && ( hasAdviceSections.value || showCompactSafeAdvice.value ) ) || (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0) || (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value) )) function normalizeRiskDomId(value) { return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '-') || 'unknown' } function resolveRiskCardDomId(card) { return `detail-risk-card-${normalizeRiskDomId(card?.id)}` } function isHighlightedRiskCard(card) { return Boolean(card?.id) && String(card.id) === highlightedRiskCardId.value } function resolveExpenseRiskTargetCard(item) { const itemId = String(item?.id || '').trim() const invoiceId = String(item?.invoiceId || '').trim() const itemIndex = expenseItems.value.findIndex((entry) => entry.id === item?.id) + 1 const cards = Array.isArray(aiAdvice.value?.riskCards) ? aiAdvice.value.riskCards : [] const actionableCards = cards.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))) return actionableCards.find((card) => String(card?.itemId || card?.item_id || '').trim() === itemId) || actionableCards.find((card) => invoiceId && String(card?.invoiceId || card?.invoice_id || '').trim() === invoiceId) || actionableCards.find((card) => Number(card?.itemIndex || card?.item_index || 0) === itemIndex) || actionableCards.find((card) => itemIndex > 0 && String(card?.title || '').includes(`第 ${itemIndex} 条`)) || null } function hasExpenseRiskIndicator(item) { return Boolean(resolveExpenseRiskTargetCard(item)) } async function focusExpenseRisk(item) { const card = resolveExpenseRiskTargetCard(item) const riskSection = document.querySelector('.validation-section--risk') if (!card && !riskSection) { toast('当前费用明细暂无可定位的风险点。') return } highlightedRiskCardId.value = card?.id ? String(card.id) : '' await nextTick() const target = card ? document.getElementById(resolveRiskCardDomId(card)) : riskSection target?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) if (highlightedRiskCardTimer) { window.clearTimeout(highlightedRiskCardTimer) } highlightedRiskCardTimer = window.setTimeout(() => { highlightedRiskCardId.value = '' highlightedRiskCardTimer = 0 }, 1800) } const aiAdviceTitle = computed(() => { if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) { return '报销风险提示' } if (isEditableRequest.value && isApplicationDocument.value) { return '表单自查提示' } return isEditableRequest.value ? 'AI建议' : 'AI提示' }) const aiAdviceHint = computed(() => ( !isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value ? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。' : isEditableRequest.value ? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。') : '展示系统已识别的风险点,便于审批和后续整改。' )) const submitActionLabel = computed(() => { return resolveSubmitActionLabel({ isApplicationDocument: isApplicationDocument.value, submitBusy: submitBusy.value }) }) const submitActionIcon = computed(() => resolveSubmitActionIcon({ isApplicationDocument: isApplicationDocument.value })) const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({ isApplicationDocument: isApplicationDocument.value, hasHighRiskWarnings: submitRiskWarnings.value.length > 0 })) const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value)) const submitRiskWarnings = computed(() => aiAdvice.value.riskCards .filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))) .filter((card) => isRiskCardMissingExpenseNote(card)) .map((card, index) => ({ ...card, id: String(card.id || `submit-risk-${index}`), tags: resolveRiskTags(card) })) ) const currentSubmitRiskWarning = computed(() => submitRiskWarnings.value[riskOverrideIndex.value] || null) const riskOverrideIndexLabel = computed(() => submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : '' ) function resetDetailNote() { detailNoteEditor.value = detailNoteSource.value } async function saveDetailNote() { if (!canEditDetailNote.value || savingDetailNote.value) { return } if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法保存附加说明。') return } if (!detailNoteDirty.value) { return } savingDetailNote.value = true try { await updateExpenseClaim(request.value.claimId, { reason: detailNoteEditor.value.trim() }) toast('附加说明已保存。') emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '附加说明保存失败,请稍后重试。') } finally { savingDetailNote.value = false } } function openRiskOverrideDialog() { const warnings = submitRiskWarnings.value if (!warnings.length) { return } riskOverrideIndex.value = 0 const activeIds = new Set(warnings.map((risk) => risk.id)) Object.keys(riskOverrideReasons).forEach((riskId) => { if (!activeIds.has(riskId)) { delete riskOverrideReasons[riskId] } }) warnings.forEach((risk) => { if (typeof riskOverrideReasons[risk.id] !== 'string') { riskOverrideReasons[risk.id] = '' } }) riskOverrideDialogOpen.value = true } function closeRiskOverrideDialog() { if (riskOverrideBusy.value) { return } riskOverrideDialogOpen.value = false } function resizeExpenseNoteInput(event) { const target = event?.target if (!target || typeof window === 'undefined') { return } const style = window.getComputedStyle(target) const lineHeight = Number.parseFloat(style.lineHeight) || 18 const maxHeight = lineHeight * 3 + 18 target.style.height = 'auto' target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px` } function goToPreviousSubmitRisk() { if (!submitRiskWarnings.value.length) { return } riskOverrideIndex.value = (riskOverrideIndex.value - 1 + submitRiskWarnings.value.length) % submitRiskWarnings.value.length } function goToNextSubmitRisk() { if (!submitRiskWarnings.value.length) { return } riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length } function mergeDetailNoteWithRiskOverride(appendix) { const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n') } async function confirmRiskOverrideReasons() { if (riskOverrideBusy.value) { return } const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim()) if (missingIndex >= 0) { riskOverrideIndex.value = missingIndex toast('请为每一条风险填写异常说明。') return } const itemNoteGroups = new Map() const claimLevelRisks = [] submitRiskWarnings.value.forEach((risk, index) => { const reason = String(riskOverrideReasons[risk.id] || '').trim() const item = resolveExpenseItemForRiskCard(risk) if (item?.id) { const currentGroup = itemNoteGroups.get(item.id) || { item, reasons: [] } currentGroup.reasons.push(reason) itemNoteGroups.set(item.id, currentGroup) } else { const title = String(risk.title || risk.label || '风险').trim() claimLevelRisks.push(`异常说明:第${index + 1}条 ${title}:${reason}`) } }) riskOverrideBusy.value = true try { await Promise.all( [...itemNoteGroups.entries()].map(([itemId, group]) => { const existingNote = String(group.item?.itemNote || '').trim() const nextNote = [ existingNote, ...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason)) ].filter(Boolean).join('\n') return updateExpenseClaimItem(request.value.claimId, itemId, { item_note: nextNote }) }) ) itemNoteGroups.forEach((group, itemId) => { const existingNote = String(group.item?.itemNote || '').trim() const nextNote = [ existingNote, ...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason)) ].filter(Boolean).join('\n') applyLocalExpenseItemPatch(itemId, { itemNote: nextNote }) }) if (claimLevelRisks.length) { const appendix = claimLevelRisks.join('\n') const nextNote = mergeDetailNoteWithRiskOverride(appendix) if (nextNote.length > 500) { toast('附加说明最多 500 字,请精简风险原因后再继续提交。') return } await updateExpenseClaim(request.value.claimId, { reason: nextNote }) detailNoteEditor.value = nextNote } riskOverrideDialogOpen.value = false submitConfirmDialogOpen.value = true toast('异常说明已保存,可继续提交审批。') emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '异常说明保存失败,请稍后重试。') } finally { riskOverrideBusy.value = false } } async function confirmStandardAdjustment() { if (riskOverrideBusy.value) { return } riskOverrideBusy.value = true try { const payload = await buildStandardAdjustmentPayload() if (!payload.risks.length) { toast('当前风险暂未匹配到可重算的费用明细,请先补充异常说明。') return } const response = await acceptExpenseClaimStandardAdjustment(request.value.claimId, payload) applyStandardAdjustmentResponse(response) riskOverrideDialogOpen.value = false submitConfirmDialogOpen.value = true toast('已按职级最高报销标准重算实际报销金额。') emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '按职级标准重算失败,请稍后重试。') } finally { riskOverrideBusy.value = false } } function populateExpenseEditor(item) { 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 || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail) expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : '' expenseEditor.itemNote = item.itemNote || '' expenseEditor.invoiceId = item.invoiceId || '' } function startExpenseEdit(item) { if (!isEditableRequest.value || actionBusy.value) { return } if (item?.isSystemGenerated) { toast('系统自动计算的补贴行不能手动编辑。') return } populateExpenseEditor(item) void nextTick(() => { const textarea = document.querySelector('.risk-note-editor-textarea') resizeExpenseNoteInput({ target: textarea }) }) } function validateExpenseEditor() { if (expenseEditor.itemDate && !isValidIsoDate(expenseEditor.itemDate)) { return '请输入正确的费用日期,格式为 YYYY-MM-DD。' } if (isPlaceholderValue(expenseEditor.itemType)) { return '请选择费用项目。' } if ( !isPlaceholderValue(expenseEditor.itemReason) && isRouteDescriptionExpenseType(expenseEditor.itemType) && !isValidRouteDescription(expenseEditor.itemReason) ) { return '行程说明格式应为“起始地-目的地”,例如:广州南-北京南。' } const amountText = String(expenseEditor.itemAmount || '').trim() if (amountText) { const amount = Number(amountText) if (!Number.isFinite(amount) || amount < 0) { return '请输入不小于 0 的费用金额。' } } return '' } function triggerSmartEntryUpload() { if (!isEditableRequest.value || actionBusy.value) { return } if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法上传单据。') return } smartEntrySelectedFiles.value = [] smartEntryUploadDialogOpen.value = true } function closeSmartEntryUploadDialog() { if (smartEntryUploadBusy.value) { return } smartEntryUploadDialogOpen.value = false clearSmartEntryFile() } function chooseSmartEntryFile() { if (smartEntryUploadBusy.value) { return } if (smartEntryUploadInput.value) { smartEntryUploadInput.value.value = '' smartEntryUploadInput.value.click() } } function clearSmartEntryFile() { smartEntrySelectedFiles.value = [] if (smartEntryUploadInput.value) { smartEntryUploadInput.value.value = '' } } function handleSmartEntryFileChange(event) { const target = event?.target const fileList = target?.files const files = Array.from(fileList || []) if (target) { target.value = '' } if (!files.length) { return } smartEntrySelectedFiles.value = files } async function confirmSmartEntryUpload() { if (smartEntryUploadBusy.value) { return } const files = [...smartEntrySelectedFiles.value] if (!files.length) { toast('请先选择需要智能录入的附件。') return } smartEntryUploadDialogOpen.value = false clearSmartEntryFile() const { task, reused } = startSmartEntryRecognitionTask({ claimId: request.value.claimId, files, itemSnapshots: expenseItems.value }) if (!task) { toast('当前草稿缺少 claimId,暂时无法识别附件。') return } bindSmartEntryRecognitionTask(request.value.claimId) toast(reused ? '当前单据已有附件识别任务,请等待识别完成。' : '附件已转入后台识别,费用明细将在识别完成后自动更新。') } function triggerExpenseUpload(item) { if (!isEditableRequest.value || actionBusy.value) { return } if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法上传单据。') return } if (item?.isSystemGenerated) { toast('系统自动计算的补贴行无需上传附件。') return } if (item?.invoiceId) { toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。') return } pendingUploadExpenseId.value = item.id if (expenseUploadInput.value) { expenseUploadInput.value.value = '' expenseUploadInput.value.click() } } async function loadAttachmentPreview(item) { if (!request.value.claimId || !item?.invoiceId) { return } attachmentPreviewLoading.value = true attachmentPreviewError.value = '' attachmentPreviewItemId.value = item.id attachmentPreviewName.value = resolveAttachmentDisplayName(item) let metadata = resolveAttachmentMeta(item) try { if (!metadata) { try { metadata = await refreshExpenseAttachmentMeta(item.id) } catch (error) { if (!hasStoredAttachmentReference(item)) { throw new Error('当前附件只有文件名记录,原件尚未保存到单据中,请重新上传后预览。') } throw error } } if (metadata?.previewable === false) { throw new Error('当前附件暂不支持直接预览。') } attachmentPreviewName.value = resolveAttachmentDisplayName(item) attachmentPreviewMediaType.value = String(metadata?.preview_kind || '').trim() === 'image' ? 'image/png' : String(metadata?.media_type || '').trim() 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 openAttachmentPreview(item) { if (!request.value.claimId || !canPreviewAttachment(item)) { return } closeAttachmentPreview() attachmentPreviewOpen.value = true await loadAttachmentPreview(item) } async function goToAttachmentPreview(offset) { if (!canNavigateAttachmentPreview.value || attachmentPreviewLoading.value) { return } const entries = attachmentPreviewEntries.value const currentIndex = currentAttachmentPreviewIndex.value const nextIndex = (currentIndex + offset + entries.length) % entries.length const nextEntry = entries[nextIndex] if (nextEntry?.item) { await loadAttachmentPreview(nextEntry.item) } } function goToPreviousAttachmentPreview() { void goToAttachmentPreview(-1) } function goToNextAttachmentPreview() { void goToAttachmentPreview(1) } async function uploadExpenseFile(item, file) { if (!item || !file) { return } if (item?.isSystemGenerated) { toast('系统自动计算的补贴行无需上传附件。') return } if (item?.invoiceId) { toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。') return } uploadingExpenseId.value = item.id try { const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file) applyClaimRiskFlagsPayload(payload) expenseAttachmentMeta[item.id] = payload?.attachment || null const itemPatch = buildRecognizedExpenseItemPatch(payload, file.name) applyLocalExpenseItemPatch(item.id, { ...itemPatch }) populateExpenseEditor({ ...item, ...itemPatch }) emit('request-updated', { claimId: request.value.claimId }) const riskNotice = buildAttachmentRiskNotice(payload?.attachment) toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`) return true } catch (error) { toast(error?.message || '附件上传失败,请稍后重试。') return false } 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) applyClaimRiskFlagsPayload(payload) 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 fileList = target?.files const fileCount = fileList?.length || 0 const file = fileList?.[0] const itemId = pendingUploadExpenseId.value pendingUploadExpenseId.value = '' if (target) { target.value = '' } if (fileCount > 1) { toast('一条费用明细只能上传一张单据,请只选择一个文件。') return } 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 } if (item?.isSystemGenerated) { toast('系统自动计算的补贴行不能删除。') 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.itemNote = '' 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 (actionBusy.value) { toast(uploadingExpenseId.value ? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。') return } 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() const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim() const amountText = String(expenseEditor.itemAmount || '').trim() const nextAmount = amountText ? Number(amountText) : 0 const itemPayload = { item_type: expenseEditor.itemType, item_reason: expenseEditor.itemReason.trim(), item_location: preservedLocation, item_note: expenseEditor.itemNote.trim(), item_amount: nextAmount, invoice_id: nextInvoiceId } if (expenseEditor.itemDate) { itemPayload.item_date = expenseEditor.itemDate } await updateExpenseClaimItem(request.value.claimId, item.id, itemPayload) applyLocalExpenseItemPatch(item.id, { itemDate: expenseEditor.itemDate || item.itemDate, itemType: expenseEditor.itemType, itemReason: expenseEditor.itemReason.trim(), itemLocation: preservedLocation, itemNote: expenseEditor.itemNote.trim(), itemAmount: nextAmount, 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('当前单据正在保存或处理附件,请稍后再提交审批。') return } if (draftBlockingIssues.value.length) { toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。') return } if (submitRiskWarnings.value.length) { openRiskOverrideDialog() return } submitConfirmDialogOpen.value = true } function closeSubmitConfirmDialog() { if (submitBusy.value) { return } submitConfirmDialogOpen.value = false } async function confirmSubmitRequest() { if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法提交。') submitConfirmDialogOpen.value = false return } if (!canSubmit.value) { toast('当前单据正在保存或处理附件,请稍后再提交审批。') submitConfirmDialogOpen.value = false return } if (draftBlockingIssues.value.length) { toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。') submitConfirmDialogOpen.value = false 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( isApplicationDocument.value ? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。` : `${request.value.id} 已提交审批${approvalStage ? `,当前节点:${approvalStage}` : '。'}` ) } else if (claimStatus === 'supplement') { toast(`${request.value.id} 自动检测未通过,已转待补充。`) } else { toast(`${request.value.id} 提交结果已更新。`) } submitConfirmDialogOpen.value = false 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( isArchivedRequest.value ? '已归档单据不能删除,只有高级管理员可以执行删除。' : isApplicationDocument.value ? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。' : '当前单据已进入流程,只有高级财务人员可以删除。' ) 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} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`) 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(payload) { if (!request.value.claimId) { toast('当前单据缺少 claimId,暂时无法退回。') return } returnBusy.value = true try { await returnExpenseClaim(request.value.claimId, payload) returnDialogOpen.value = false toast(`${request.value.id} 已退回待提交。`) emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '退回单据失败,请稍后重试。') } finally { returnBusy.value = false } } function handleApproveRequest() { if (!request.value.claimId) { toast('当前单据缺少 claimId,暂时无法审批通过。') return } if (!canApproveRequest.value) { toast('当前节点暂不支持审批通过。') return } approveConfirmDialogOpen.value = true } function closeApproveConfirmDialog() { if (approveBusy.value) { return } approveConfirmDialogOpen.value = false } function resolveApproveErrorMessage(error) { const message = String(error?.message || '').trim() if (message.includes('未找到同部门 P8 预算审批人')) { return '当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。' } return message || '审批通过失败,请稍后重试。' } async function confirmApproveRequest() { if (!request.value.claimId) { toast('当前单据缺少 claimId,暂时无法审批通过。') approveConfirmDialogOpen.value = false return } if (!canApproveRequest.value) { toast('当前节点暂不支持审批通过。') approveConfirmDialogOpen.value = false return } approveBusy.value = true try { const responsePayload = await approveExpenseClaim(request.value.claimId, { opinion: leaderOpinion.value.trim() || '同意' }) const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload) approveConfirmDialogOpen.value = false leaderOpinion.value = '' toast( isApplicationDocument.value && generatedDraftClaimNo ? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。` : approvalSuccessToast.value ) emit('request-updated', { claimId: request.value.claimId }) emit('backToRequests') } catch (error) { toast(resolveApproveErrorMessage(error)) } finally { approveBusy.value = false } } function buildApplicationEditPreview() { const factEntries = applicationDetailFactItems.value .map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()]) .filter(([label, value]) => label && value) const facts = new Map(factEntries) const pickFact = (...labels) => { for (const label of labels) { const value = facts.get(label) if (value) { return value } } return '' } const tripStart = pickFact('出发时间') const tripReturn = pickFact('返回时间') const time = tripStart && tripReturn && tripStart !== tripReturn ? `${tripStart} 至 ${tripReturn}` : pickFact('行程时间', '申请时间', '招待时间', '发生时间') || tripStart return { sourceText: '修改申请', modelReviewStatus: 'template', fields: { applicationType: pickFact('申请类型') || request.value.typeLabel || '费用申请', applicant: request.value.profileName || request.value.person || request.value.applicant || '', grade: pickFact('职级') || request.value.profileGrade || '', department: request.value.profileDepartment || request.value.departmentName || request.value.department || '', position: request.value.profilePosition || request.value.employeePosition || request.value.position || '', managerName: request.value.profileManager || request.value.managerName || request.value.manager || '', time, location: pickFact('地点') || request.value.location || request.value.city || '', reason: pickFact('事由') || request.value.reason || '', days: pickFact('天数'), transportMode: pickFact('出行方式'), lodgingDailyCap: pickFact('住宿上限/天'), subsidyDailyCap: pickFact('补贴标准/天'), transportPolicy: pickFact('交通费用口径'), policyEstimate: pickFact('规则测算参考'), amount: pickFact('系统预估费用', '用户预估费用', '预计金额') || request.value.amountDisplay || request.value.amount || '' } } } function handleModifyApplication() { if (!canModifyReturnedApplication.value) { return } const claimId = String(request.value?.claimId || '').trim() emit('openAssistant', { source: 'application', sessionType: 'application', prompt: '', applicationPreview: buildApplicationEditPreview(), request: { ...request.value, applicationEditMode: true }, restoreLatestConversation: false, initialPromptAutoSubmit: false, scope: claimId ? { type: 'claim', claimId } : null }) } onBeforeUnmount(() => { if (highlightedRiskCardTimer) { window.clearTimeout(highlightedRiskCardTimer) highlightedRiskCardTimer = 0 } if (stopSmartEntryRecognitionTask) { stopSmartEntryRecognitionTask() stopSmartEntryRecognitionTask = null } closeAttachmentPreview() }) return { emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel, attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen, attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge, approvalConfirmDescription, approvalOpinionHint, approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel, applicationDetailFactItems, relatedApplicationFactItems, approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim, canNavigateAttachmentPreview, canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment, closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog, closeRiskOverrideDialog, closeSmartEntryUploadDialog, closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest, confirmPayRequest, confirmRiskOverrideReasons, confirmStandardAdjustment, confirmSmartEntryUpload, chooseSmartEntryFile, clearSmartEntryFile, currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion, currentSubmitRiskWarning, canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen, deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty, detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, expenseEditor, expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput, expenseTypeOptions: EXPENSE_TYPE_OPTIONS, goToNextSubmitRisk, goToPreviousSubmitRisk, focusExpenseRisk, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange, handleModifyApplication, handlePayRequest, handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest, isMajorExpenseRisk, hasExpenseRiskIndicator, hasExpenseRiskOrAbnormal, triggerSmartEntryUpload, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview, payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem, hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta, resolveExpenseRiskIndicatorTitle, resetDetailNote, resizeExpenseNoteInput, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues, resolveRiskCardDomId, isHighlightedRiskCard, returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel, requiresApprovalOpinion, riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId, smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary, smartEntryRecognitionBusy, smartEntryRecognitionText, smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput, showAiAdvicePanel, showApplicationLeaderOpinion, showBudgetAnalysis, showStageRiskAdvice, showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy, submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings, triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit } } }