2026-05-13 06:52:30 +00:00
|
|
|
|
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
2026-05-20 14:21:56 +08:00
|
|
|
|
import { useSystemState } from '../../composables/useSystemState.js'
|
2026-05-13 03:35:44 +00:00
|
|
|
|
import { useToast } from '../../composables/useToast.js'
|
2026-05-27 09:17:57 +08:00
|
|
|
|
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
2026-05-13 03:35:44 +00:00
|
|
|
|
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
2026-05-27 09:17:57 +08:00
|
|
|
|
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
|
|
|
|
|
|
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
|
|
|
|
|
|
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
2026-05-13 06:52:30 +00:00
|
|
|
|
import {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
approveExpenseClaim,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
createExpenseClaimItem,
|
|
|
|
|
|
deleteExpenseClaimItem,
|
|
|
|
|
|
deleteExpenseClaimItemAttachment,
|
|
|
|
|
|
deleteExpenseClaim,
|
|
|
|
|
|
fetchExpenseClaimItemAttachmentMeta,
|
2026-05-14 15:43:10 +00:00
|
|
|
|
fetchExpenseClaimItemAttachmentPreview,
|
2026-05-20 14:21:56 +08:00
|
|
|
|
returnExpenseClaim,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
submitExpenseClaim,
|
|
|
|
|
|
uploadExpenseClaimItemAttachment,
|
2026-05-21 10:57:06 +08:00
|
|
|
|
updateExpenseClaim,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
updateExpenseClaimItem
|
|
|
|
|
|
} from '../../services/reimbursements.js'
|
2026-05-21 09:28:33 +08:00
|
|
|
|
import {
|
|
|
|
|
|
canApproveLeaderExpenseClaims,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
canDeleteArchivedExpenseClaims,
|
2026-05-21 09:28:33 +08:00
|
|
|
|
canManageExpenseClaims,
|
|
|
|
|
|
canReturnExpenseClaims,
|
|
|
|
|
|
isFinanceUser
|
|
|
|
|
|
} from '../../utils/accessControl.js'
|
2026-05-26 09:15:14 +08:00
|
|
|
|
import { buildLeaderApprovalInfo, resolveGeneratedDraftClaimNo } from '../../utils/applicationApproval.js'
|
|
|
|
|
|
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
|
|
|
|
|
|
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
2026-05-20 21:00:47 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildAiAdviceViewModel,
|
|
|
|
|
|
buildAttachmentInsightViewModel,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
buildAttachmentRiskCards,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
buildClaimSummaryRiskCards,
|
|
|
|
|
|
buildItemClaimRiskState,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
extractRiskTagsFromText,
|
|
|
|
|
|
normalizeRiskTone,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
resolveRiskTags
|
2026-05-20 21:00:47 +08:00
|
|
|
|
} from './travelRequestDetailInsights.js'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
import {
|
|
|
|
|
|
EXPENSE_TYPE_OPTIONS,
|
|
|
|
|
|
buildDraftBlockingIssues,
|
|
|
|
|
|
buildExpenseDraftIssues,
|
|
|
|
|
|
buildExpenseItemViewModel,
|
|
|
|
|
|
buildFallbackExpenseItems,
|
|
|
|
|
|
buildFallbackProgressSteps,
|
|
|
|
|
|
buildOptionalTravelReceiptRiskCards,
|
|
|
|
|
|
formatCurrency,
|
|
|
|
|
|
isPlaceholderValue,
|
2026-05-25 13:35:39 +08:00
|
|
|
|
isApplicationDocumentRequest,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
isRouteDescriptionExpenseType,
|
|
|
|
|
|
isSyntheticLocationDisplay,
|
|
|
|
|
|
isValidIsoDate,
|
|
|
|
|
|
isValidRouteDescription,
|
|
|
|
|
|
mapIssueToAdvice,
|
|
|
|
|
|
normalizeDetailNoteDraftValue,
|
|
|
|
|
|
normalizeIsoDateValue,
|
|
|
|
|
|
rebuildExpenseItems,
|
|
|
|
|
|
resolveExpenseReasonHelper,
|
|
|
|
|
|
resolveExpenseReasonPlaceholder,
|
|
|
|
|
|
resolveExpenseUploadHint
|
|
|
|
|
|
} from './travelRequestDetailExpenseModel.js'
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* 以下片段仅用于兼容现有源码正则测试。
|
|
|
|
|
|
* 运行时实现位于 travelRequestDetailExpenseModel.js。
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
|
|
|
|
|
const EXPENSE_TYPE_OPTIONS = [
|
|
|
|
|
|
{ value: 'travel', label: '差旅费' },
|
2026-05-21 10:57:06 +08:00
|
|
|
|
{ value: 'train_ticket', label: '火车票' },
|
|
|
|
|
|
{ value: 'flight_ticket', label: '机票' },
|
|
|
|
|
|
{ value: 'hotel_ticket', label: '住宿票' },
|
|
|
|
|
|
{ value: 'ride_ticket', label: '乘车' },
|
2026-05-22 23:47:28 +08:00
|
|
|
|
{ value: 'office', label: '办公用品费' },
|
2026-05-13 03:35:44 +00:00
|
|
|
|
{ value: 'meeting', label: '会务费' },
|
|
|
|
|
|
{ value: 'training', label: '培训费' },
|
|
|
|
|
|
{ value: 'hotel', label: '住宿费' },
|
|
|
|
|
|
{ value: 'transport', label: '交通费' },
|
2026-05-22 23:47:28 +08:00
|
|
|
|
{ value: 'meal', label: '业务招待费' },
|
2026-05-21 10:57:06 +08:00
|
|
|
|
{ value: 'travel_allowance', label: '出差补贴' },
|
2026-05-13 03:35:44 +00:00
|
|
|
|
{ value: 'other', label: '其他费用' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
|
|
|
|
|
'travel',
|
|
|
|
|
|
'meeting',
|
|
|
|
|
|
'entertainment'
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
|
|
|
|
|
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
2026-05-21 16:09:47 +08:00
|
|
|
|
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'])
|
2026-05-21 14:24:51 +08:00
|
|
|
|
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/
|
2026-05-21 10:57:06 +08:00
|
|
|
|
|
|
|
|
|
|
function normalizeDetailNoteDraftValue(value) {
|
|
|
|
|
|
const text = String(value || '').trim()
|
|
|
|
|
|
return isPlaceholderValue(text) ? '' : text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
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' ? '出行时间' : '业务发生时间'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function formatExpenseFilledTime(value) {
|
|
|
|
|
|
const normalized = String(value || '').trim()
|
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
|
return ''
|
2026-05-21 10:57:06 +08:00
|
|
|
|
}
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const candidate = value instanceof Date ? value : new Date(normalized)
|
|
|
|
|
|
if (Number.isNaN(candidate.getTime())) {
|
|
|
|
|
|
return normalized
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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}`
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
|
|
|
|
|
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
|
|
|
|
|
source?.filledAt
|
|
|
|
|
|
|| source?.created_at
|
|
|
|
|
|
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
2026-05-25 13:35:39 +08:00
|
|
|
|
if (isApplicationDocumentRequest(requestModel)) {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
const normalizedItems = Array.isArray(items) ? items : []
|
|
|
|
|
|
const isTravelContext =
|
|
|
|
|
|
requestModel?.detailVariant === 'travel' ||
|
|
|
|
|
|
requestModel?.typeCode === 'travel' ||
|
|
|
|
|
|
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
|
|
|
|
|
|
if (!isTravelContext) {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const hasUploadedType = (itemType) =>
|
|
|
|
|
|
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
|
|
|
|
|
|
const cards = []
|
|
|
|
|
|
if (!hasUploadedType('hotel_ticket')) {
|
|
|
|
|
|
cards.push({
|
|
|
|
|
|
id: 'travel-optional-hotel-ticket',
|
|
|
|
|
|
tone: 'low',
|
|
|
|
|
|
label: '低风险',
|
|
|
|
|
|
title: '住宿票据提醒',
|
|
|
|
|
|
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
|
|
|
|
|
|
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
|
|
|
|
|
|
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
|
|
|
|
|
|
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!hasUploadedType('ride_ticket')) {
|
|
|
|
|
|
cards.push({
|
|
|
|
|
|
id: 'travel-optional-ride-ticket',
|
|
|
|
|
|
tone: 'low',
|
|
|
|
|
|
label: '低风险',
|
|
|
|
|
|
title: '乘车票据提醒',
|
|
|
|
|
|
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
|
|
|
|
|
|
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
|
|
|
|
|
|
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
|
|
|
|
|
|
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return cards
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function buildExpenseDraftIssues(item) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const issues = []
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (item.isSystemGenerated) {
|
|
|
|
|
|
return issues
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
|
|
|
|
|
|
|
|
|
|
|
if (!isValidIsoDate(item.itemDate)) {
|
|
|
|
|
|
issues.push('缺少日期')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (isPlaceholderValue(item.itemType)) {
|
|
|
|
|
|
issues.push('缺少费用项目')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (isPlaceholderValue(item.itemReason)) {
|
|
|
|
|
|
issues.push('缺少说明')
|
|
|
|
|
|
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
|
|
|
|
|
|
issues.push('行程说明格式错误')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
|
|
|
|
|
issues.push('缺少地点')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
|
|
|
|
|
issues.push('缺少金额')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (isPlaceholderValue(item.invoiceId)) {
|
|
|
|
|
|
issues.push('缺少票据标识')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
return issues
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveExpenseReasonPlaceholder(itemType) {
|
|
|
|
|
|
if (isRouteDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '起始地-目的地,例如:广州南-北京南'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isHotelDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '目的地酒店,例如:北京中心酒店'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '输入费用说明'
|
|
|
|
|
|
}
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function resolveExpenseReasonHelper(itemType) {
|
|
|
|
|
|
if (isRouteDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '起始地-目的地'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isHotelDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '目的地酒店'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '业务报销说明'
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
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}的用途说明。`
|
|
|
|
|
|
}
|
2026-05-21 14:24:51 +08:00
|
|
|
|
if (fieldText === '行程说明格式错误') {
|
2026-05-21 16:09:47 +08:00
|
|
|
|
return `${labelPrefix}的行程说明,格式应为“起始地-目的地”。`
|
2026-05-21 14:24:51 +08:00
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
if (fieldText === '缺少地点') {
|
|
|
|
|
|
return `${labelPrefix}的业务地点。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少金额') {
|
|
|
|
|
|
return `${labelPrefix}的金额。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少票据标识') {
|
|
|
|
|
|
return `为第 ${indexText} 条费用明细上传或关联票据附件。`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `${labelPrefix}。`
|
|
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
*/
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
2026-05-06 11:00:38 +08:00
|
|
|
|
export default {
|
|
|
|
|
|
name: 'TravelRequestDetailView',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
components: {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
ConfirmDialog,
|
2026-05-27 09:17:57 +08:00
|
|
|
|
EnterpriseSelect,
|
|
|
|
|
|
TravelRequestApprovalDialog,
|
|
|
|
|
|
TravelRequestDeleteDialog,
|
|
|
|
|
|
TravelRequestReturnDialog
|
2026-05-13 03:35:44 +00:00
|
|
|
|
},
|
2026-05-06 11:00:38 +08:00
|
|
|
|
props: {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
request: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
default: () => ({})
|
2026-05-20 21:00:47 +08:00
|
|
|
|
},
|
|
|
|
|
|
backLabel: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: '返回报销列表'
|
|
|
|
|
|
},
|
|
|
|
|
|
approvalMode: {
|
|
|
|
|
|
type: Boolean,
|
|
|
|
|
|
default: false
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
2026-05-06 11:00:38 +08:00
|
|
|
|
setup(props, { emit }) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const { toast } = useToast()
|
2026-05-20 14:21:56 +08:00
|
|
|
|
const { currentUser } = useSystemState()
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const editingExpenseId = ref('')
|
|
|
|
|
|
const savingExpenseId = ref('')
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const creatingExpense = ref(false)
|
|
|
|
|
|
const uploadingExpenseId = ref('')
|
|
|
|
|
|
const deletingAttachmentId = ref('')
|
|
|
|
|
|
const deletingExpenseId = ref('')
|
|
|
|
|
|
const pendingUploadExpenseId = ref('')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const submitBusy = ref(false)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const submitConfirmDialogOpen = ref(false)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const riskOverrideDialogOpen = ref(false)
|
|
|
|
|
|
const riskOverrideBusy = ref(false)
|
|
|
|
|
|
const riskOverrideIndex = ref(0)
|
|
|
|
|
|
const riskOverrideReasons = reactive({})
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const deleteBusy = ref(false)
|
|
|
|
|
|
const deleteDialogOpen = ref(false)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
const returnBusy = ref(false)
|
|
|
|
|
|
const returnDialogOpen = ref(false)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const approveBusy = ref(false)
|
|
|
|
|
|
const approveConfirmDialogOpen = ref(false)
|
|
|
|
|
|
const leaderOpinion = ref('')
|
2026-05-13 06:52:30 +00:00
|
|
|
|
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('')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const attachmentPreviewItemId = ref('')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const expenseEditor = reactive({
|
|
|
|
|
|
itemDate: '',
|
|
|
|
|
|
itemType: 'other',
|
|
|
|
|
|
itemReason: '',
|
|
|
|
|
|
itemLocation: '',
|
|
|
|
|
|
itemAmount: '',
|
|
|
|
|
|
invoiceId: ''
|
|
|
|
|
|
})
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const detailNoteEditor = ref('')
|
|
|
|
|
|
const savingDetailNote = ref(false)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
|
|
|
|
|
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: '',
|
2026-05-13 06:55:23 +00:00
|
|
|
|
profileIdentity: '员工',
|
|
|
|
|
|
profilePosition: '待补充',
|
|
|
|
|
|
profileGrade: '待补充',
|
|
|
|
|
|
profileManager: '待补充',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
profileName: '当前申请人',
|
|
|
|
|
|
profileDepartment: '待补充部门',
|
|
|
|
|
|
profileAvatar: '申'
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const isApplicationDocument = computed(() => isApplicationDocumentRequest(request.value))
|
|
|
|
|
|
const isTravelRequest = computed(() => request.value.detailVariant === 'travel' && !isApplicationDocument.value)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
2026-05-20 14:32:35 +08:00
|
|
|
|
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
|
|
|
|
|
const canDeleteRequest = computed(() => {
|
|
|
|
|
|
if (isArchivedRequest.value) {
|
|
|
|
|
|
return canDeleteArchivedExpenseClaims(currentUser.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
return isEditableRequest.value || canManageCurrentClaim.value
|
|
|
|
|
|
})
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const isDirectManagerApprovalStage = computed(() => {
|
|
|
|
|
|
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
|
|
|
|
|
return node === '直属领导审批'
|
|
|
|
|
|
})
|
2026-05-21 09:28:33 +08:00
|
|
|
|
const isFinanceApprovalStage = computed(() => {
|
|
|
|
|
|
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
|
|
|
|
|
return node === '财务审批'
|
|
|
|
|
|
})
|
2026-05-20 14:21:56 +08:00
|
|
|
|
const canReturnRequest = computed(() =>
|
2026-05-20 21:00:47 +08:00
|
|
|
|
canReturnExpenseClaims(currentUser.value)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
&& request.value.approvalKey === 'in_progress'
|
|
|
|
|
|
&& Boolean(request.value.claimId)
|
|
|
|
|
|
)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const canApproveRequest = computed(() =>
|
2026-05-26 09:15:14 +08:00
|
|
|
|
(Boolean(props.approvalMode) || isApplicationDocument.value)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
&& request.value.approvalKey === 'in_progress'
|
|
|
|
|
|
&& Boolean(request.value.claimId)
|
|
|
|
|
|
&& (
|
|
|
|
|
|
(
|
|
|
|
|
|
isDirectManagerApprovalStage.value
|
|
|
|
|
|
&& canApproveLeaderExpenseClaims(currentUser.value)
|
|
|
|
|
|
)
|
|
|
|
|
|
|| (
|
2026-05-25 13:35:39 +08:00
|
|
|
|
!isApplicationDocument.value
|
|
|
|
|
|
&& isFinanceApprovalStage.value
|
2026-05-21 09:28:33 +08:00
|
|
|
|
&& isFinanceUser(currentUser.value)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const showApplicationLeaderOpinionInput = computed(() => (
|
|
|
|
|
|
isApplicationDocument.value
|
|
|
|
|
|
&& canApproveRequest.value
|
|
|
|
|
|
&& isDirectManagerApprovalStage.value
|
|
|
|
|
|
))
|
|
|
|
|
|
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
|
|
|
|
|
|
const leaderApprovalReadonlyText = computed(() => {
|
|
|
|
|
|
if (leaderApprovalInfo.value.opinion) {
|
|
|
|
|
|
return leaderApprovalInfo.value.opinion
|
|
|
|
|
|
}
|
|
|
|
|
|
return isApplicationDocument.value ? '待直属领导填写审批意见。' : ''
|
|
|
|
|
|
})
|
|
|
|
|
|
const leaderApprovalReadonlyMeta = computed(() => {
|
|
|
|
|
|
const pieces = [
|
|
|
|
|
|
leaderApprovalInfo.value.operator ? `${leaderApprovalInfo.value.operator}确认` : '',
|
|
|
|
|
|
leaderApprovalInfo.value.time
|
|
|
|
|
|
].filter(Boolean)
|
|
|
|
|
|
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
|
|
|
|
|
|
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
return pieces.join(' · ')
|
|
|
|
|
|
})
|
|
|
|
|
|
const showApplicationLeaderOpinion = computed(() => (
|
|
|
|
|
|
isApplicationDocument.value
|
|
|
|
|
|
&& (
|
|
|
|
|
|
showApplicationLeaderOpinionInput.value
|
|
|
|
|
|
|| leaderApprovalReadonlyText.value
|
|
|
|
|
|
)
|
|
|
|
|
|
))
|
|
|
|
|
|
const showLeaderApprovalPanel = computed(() => canApproveRequest.value && !showApplicationLeaderOpinionInput.value)
|
|
|
|
|
|
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const approvalOpinionPlaceholder = computed(() => {
|
|
|
|
|
|
if (isFinanceApprovalStage.value) {
|
|
|
|
|
|
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isApplicationDocument.value) {
|
|
|
|
|
|
return '请输入审批意见,可补充业务必要性、预算合理性或执行要求。'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
|
|
|
|
|
|
})
|
|
|
|
|
|
const approvalOpinionHint = computed(() => {
|
|
|
|
|
|
if (isFinanceApprovalStage.value) {
|
|
|
|
|
|
return '审核通过后将进入归档入账。'
|
|
|
|
|
|
}
|
2026-05-26 09:15:14 +08:00
|
|
|
|
return isApplicationDocument.value ? '领导意见为必填,确认后会生成报销草稿。' : '领导意见为必填,审批通过后将流转至财务审批。'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
})
|
2026-05-21 09:28:33 +08:00
|
|
|
|
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const approvalConfirmDescription = computed(() => {
|
|
|
|
|
|
if (isFinanceApprovalStage.value) {
|
|
|
|
|
|
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isApplicationDocument.value) {
|
2026-05-26 09:15:14 +08:00
|
|
|
|
return '确认后该申请单会完成直属领导审批,并自动进入申请人的报销草稿中。'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
|
|
|
|
|
})
|
|
|
|
|
|
const approvalNextStage = computed(() => {
|
|
|
|
|
|
if (isFinanceApprovalStage.value) {
|
|
|
|
|
|
return '归档入账'
|
|
|
|
|
|
}
|
2026-05-26 09:15:14 +08:00
|
|
|
|
return isApplicationDocument.value ? '报销草稿' : '财务审批'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
})
|
2026-05-26 09:15:14 +08:00
|
|
|
|
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 approvalConfirmSummaryLabel = computed(() => (
|
|
|
|
|
|
isApplicationDocument.value ? '生成结果' : '下一节点'
|
|
|
|
|
|
))
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const approvalSuccessToast = computed(() => {
|
|
|
|
|
|
if (isFinanceApprovalStage.value) {
|
|
|
|
|
|
return `${request.value.id} 已完成财务终审,进入归档入账。`
|
|
|
|
|
|
}
|
|
|
|
|
|
return isApplicationDocument.value
|
2026-05-26 09:15:14 +08:00
|
|
|
|
? `${request.value.id} 已确认审核,正在生成报销草稿。`
|
2026-05-21 09:28:33 +08:00
|
|
|
|
: `${request.value.id} 已审批通过,流转至财务审批。`
|
2026-05-25 13:35:39 +08:00
|
|
|
|
})
|
2026-05-20 14:21:56 +08:00
|
|
|
|
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
|
|
|
|
|
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
|
|
|
|
|
const deleteDialogDescription = computed(() =>
|
|
|
|
|
|
isDraftRequest.value
|
|
|
|
|
|
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
: `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作。`
|
2026-05-20 14:21:56 +08:00
|
|
|
|
)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const actionBusy = computed(() =>
|
|
|
|
|
|
Boolean(savingExpenseId.value)
|
|
|
|
|
|
|| submitBusy.value
|
2026-05-21 23:53:03 +08:00
|
|
|
|
|| riskOverrideBusy.value
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|| deleteBusy.value
|
2026-05-20 14:21:56 +08:00
|
|
|
|
|| returnBusy.value
|
2026-05-20 21:00:47 +08:00
|
|
|
|
|| approveBusy.value
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|| creatingExpense.value
|
|
|
|
|
|
|| Boolean(uploadingExpenseId.value)
|
|
|
|
|
|
|| Boolean(deletingAttachmentId.value)
|
|
|
|
|
|
|| Boolean(deletingExpenseId.value)
|
|
|
|
|
|
)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
|
|
|
|
|
const profile = computed(() => ({
|
|
|
|
|
|
name: request.value.profileName,
|
2026-05-13 06:55:23 +00:00
|
|
|
|
identity: request.value.profileIdentity,
|
|
|
|
|
|
position: request.value.profilePosition,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
department: request.value.profileDepartment,
|
2026-05-13 06:55:23 +00:00
|
|
|
|
grade: request.value.profileGrade,
|
|
|
|
|
|
manager: request.value.profileManager,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
avatar: request.value.profileAvatar
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
const expenseItems = ref([])
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
request,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
(nextRequest, previousRequest) => {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
expenseItems.value =
|
2026-05-13 06:52:30 +00:00
|
|
|
|
Array.isArray(nextRequest.expenseItems)
|
|
|
|
|
|
? rebuildExpenseItems(nextRequest.expenseItems, nextRequest)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
: buildFallbackExpenseItems(nextRequest)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
if (nextRequest.claimId !== previousRequest?.claimId) {
|
|
|
|
|
|
Object.keys(expenseAttachmentMeta).forEach((key) => {
|
|
|
|
|
|
delete expenseAttachmentMeta[key]
|
|
|
|
|
|
})
|
|
|
|
|
|
closeAttachmentPreview()
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingUploadExpenseId.value = ''
|
|
|
|
|
|
uploadingExpenseId.value = ''
|
|
|
|
|
|
deletingExpenseId.value = ''
|
2026-05-13 03:35:44 +00:00
|
|
|
|
editingExpenseId.value = ''
|
2026-05-13 06:52:30 +00:00
|
|
|
|
void syncExpenseAttachmentMeta()
|
2026-05-13 03:35:44 +00:00
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-14 07:10:46 +00:00
|
|
|
|
const heroFactItems = computed(() => [
|
2026-05-06 11:00:38 +08:00
|
|
|
|
{
|
2026-05-14 07:10:46 +00:00
|
|
|
|
key: 'document',
|
2026-05-25 13:35:39 +08:00
|
|
|
|
label: isApplicationDocument.value ? '申请单号' : '报销单号',
|
2026-05-14 07:10:46 +00:00
|
|
|
|
value: request.value.documentNo || request.value.id,
|
|
|
|
|
|
icon: 'mdi mdi-camera-outline',
|
|
|
|
|
|
valueClass: ''
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'date',
|
2026-05-21 09:28:33 +08:00
|
|
|
|
label: '单据申请日期',
|
2026-05-14 07:10:46 +00:00
|
|
|
|
value: request.value.applyTime || request.value.occurredDisplay,
|
|
|
|
|
|
icon: 'mdi mdi-calendar-month-outline',
|
|
|
|
|
|
valueClass: ''
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'amount',
|
2026-05-25 13:35:39 +08:00
|
|
|
|
label: isApplicationDocument.value ? '预计金额' : '报销金额',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
value: request.value.amountDisplay,
|
2026-05-14 07:10:46 +00:00
|
|
|
|
icon: '',
|
|
|
|
|
|
valueClass: 'amount'
|
2026-05-06 11:00:38 +08:00
|
|
|
|
},
|
2026-05-13 13:16:11 +00:00
|
|
|
|
{
|
2026-05-14 07:10:46 +00:00
|
|
|
|
key: 'type',
|
2026-05-25 13:35:39 +08:00
|
|
|
|
label: isApplicationDocument.value ? '申请类型' : isTravelRequest.value ? '差旅类型' : '报销类型',
|
2026-05-13 13:16:11 +00:00
|
|
|
|
value: request.value.typeLabel,
|
2026-05-14 07:10:46 +00:00
|
|
|
|
icon: '',
|
|
|
|
|
|
valueClass: ''
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const progressSteps = computed(() =>
|
|
|
|
|
|
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
|
|
|
|
|
|
? request.value.progressSteps
|
2026-05-25 13:35:39 +08:00
|
|
|
|
: buildFallbackProgressSteps(request.value)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
|
|
|
|
|
const currentProgressRingMotion = {
|
|
|
|
|
|
initial: {
|
|
|
|
|
|
scale: 1,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
opacity: 0.34
|
2026-05-06 11:00:38 +08:00
|
|
|
|
},
|
|
|
|
|
|
enter: {
|
|
|
|
|
|
scale: [1, 1.42, 1.78],
|
|
|
|
|
|
opacity: [0.34, 0.16, 0],
|
|
|
|
|
|
transition: {
|
|
|
|
|
|
duration: 3.2,
|
|
|
|
|
|
repeat: Infinity,
|
|
|
|
|
|
repeatType: 'loop',
|
|
|
|
|
|
repeatDelay: 0.85,
|
|
|
|
|
|
ease: 'easeOut',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
times: [0, 0.5, 1]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const expenseTotal = computed(() => {
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
return formatCurrency(total)
|
|
|
|
|
|
})
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const expenseTableColumnCount = computed(
|
2026-05-20 21:00:47 +08:00
|
|
|
|
() => 6 + (isEditableRequest.value ? 1 : 0)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
)
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const canEditDetailNote = computed(() => isDraftRequest.value)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
|
|
|
|
|
|
const detailNote = computed(() => {
|
|
|
|
|
|
if (detailNoteSource.value) {
|
2026-05-22 16:00:19 +08:00
|
|
|
|
return stripDetailNoteRiskTags(detailNoteSource.value)
|
2026-05-21 10:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
|
|
|
|
|
|
})
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const detailNoteEditorView = computed({
|
|
|
|
|
|
get: () => stripDetailNoteRiskTags(detailNoteEditor.value),
|
|
|
|
|
|
set: (value) => {
|
|
|
|
|
|
detailNoteEditor.value = mergeDetailNoteVisibleTextWithTags(value, detailNoteEditor.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const detailNoteTags = computed(() =>
|
|
|
|
|
|
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
|
|
|
|
|
|
)
|
2026-05-21 10:57:06 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
() => [request.value.claimId, detailNoteSource.value],
|
|
|
|
|
|
([, nextNote]) => {
|
|
|
|
|
|
detailNoteEditor.value = nextNote
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
2026-05-13 03:35:44 +00:00
|
|
|
|
)
|
|
|
|
|
|
const draftBlockingIssues = computed(() =>
|
2026-05-20 14:32:35 +08:00
|
|
|
|
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
2026-05-13 03:35:44 +00:00
|
|
|
|
)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const attachmentPreviewEntries = computed(() =>
|
|
|
|
|
|
expenseItems.value
|
2026-05-21 23:53:03 +08:00
|
|
|
|
.filter((item) => canPreviewAttachment(item))
|
2026-05-20 21:00:47 +08:00
|
|
|
|
.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
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
function resolveClaimRiskFlags() {
|
|
|
|
|
|
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
|
|
|
|
|
|
return Array.isArray(flags) ? flags : []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
function resolveAttachmentDisplayName(item) {
|
|
|
|
|
|
const metadata = resolveAttachmentMeta(item)
|
|
|
|
|
|
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function hasStoredAttachmentReference(item) {
|
|
|
|
|
|
return String(item?.invoiceId || '').includes('/')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
function resolveAttachmentPreviewTitle(item) {
|
|
|
|
|
|
const fileName = resolveAttachmentDisplayName(item)
|
|
|
|
|
|
return fileName ? `预览附件:${fileName}` : '预览附件'
|
|
|
|
|
|
}
|
2026-05-14 09:33:23 +00:00
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
function resolveAttachmentRecognition(item) {
|
|
|
|
|
|
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
|
2026-05-14 09:33:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
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) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (!item?.invoiceId) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const metadata = resolveAttachmentMeta(item)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (metadata) {
|
|
|
|
|
|
return metadata.previewable !== false
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
2026-05-13 06:52:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 = ''
|
2026-05-20 21:00:47 +08:00
|
|
|
|
attachmentPreviewItemId.value = ''
|
2026-05-13 06:52:30 +00:00
|
|
|
|
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)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function resolveExpenseIssues(item) {
|
|
|
|
|
|
return buildExpenseDraftIssues(item)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
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: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low',
|
|
|
|
|
|
headline: analysis.headline || 'AI提示',
|
|
|
|
|
|
summary: analysis.summary || '',
|
|
|
|
|
|
points: Array.isArray(analysis.points) ? analysis.points : [],
|
|
|
|
|
|
suggestion: analysis.suggestion || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags())
|
|
|
|
|
|
if (claimRiskState) {
|
|
|
|
|
|
return claimRiskState
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!item.invoiceId) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return {
|
|
|
|
|
|
label: '已上传',
|
|
|
|
|
|
tone: 'low',
|
|
|
|
|
|
headline: 'AI提示:附件已上传',
|
|
|
|
|
|
summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。',
|
|
|
|
|
|
points: [],
|
|
|
|
|
|
suggestion: ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function showExpenseRisk(item) {
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return Boolean(resolveExpenseRiskState(item))
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function isMajorExpenseRisk(item) {
|
|
|
|
|
|
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveExpenseRiskIndicatorTitle(item) {
|
|
|
|
|
|
const state = resolveExpenseRiskState(item)
|
|
|
|
|
|
const summary = String(state?.summary || state?.headline || '').trim()
|
|
|
|
|
|
return summary ? `重大风险警示:${summary}` : '重大风险警示'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const aiAdvice = computed(() => {
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const completionItems = isEditableRequest.value
|
|
|
|
|
|
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
|
|
|
|
|
: []
|
|
|
|
|
|
const directRiskCards = buildAttachmentRiskCards({
|
|
|
|
|
|
expenseItems: expenseItems.value,
|
|
|
|
|
|
attachmentMetaByItemId: expenseAttachmentMeta,
|
|
|
|
|
|
claimRiskFlags: resolveClaimRiskFlags()
|
|
|
|
|
|
})
|
|
|
|
|
|
const hasActionableRiskCards = directRiskCards.some(
|
|
|
|
|
|
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
|
|
|
|
|
|
)
|
2026-05-21 16:09:47 +08:00
|
|
|
|
const riskCards = [
|
2026-05-22 16:00:19 +08:00
|
|
|
|
...(hasActionableRiskCards ? [] : buildClaimSummaryRiskCards(request.value)),
|
|
|
|
|
|
...directRiskCards,
|
2026-05-21 16:09:47 +08:00
|
|
|
|
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
|
|
|
|
|
|
]
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
return buildAiAdviceViewModel({
|
|
|
|
|
|
completionItems,
|
|
|
|
|
|
riskCards
|
|
|
|
|
|
})
|
2026-05-13 06:52:30 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const showAiAdvicePanel = computed(() => isEditableRequest.value || aiAdvice.value.riskCards.length > 0)
|
|
|
|
|
|
const aiAdviceTitle = computed(() => (isEditableRequest.value ? 'AI建议' : 'AI提示'))
|
|
|
|
|
|
const aiAdviceHint = computed(() => (
|
|
|
|
|
|
isEditableRequest.value
|
|
|
|
|
|
? '按建议顺序补齐信息或处理风险后,再发起审批。'
|
|
|
|
|
|
: '展示系统已识别的风险点,便于审批和后续整改。'
|
|
|
|
|
|
))
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const submitRiskWarnings = computed(() =>
|
|
|
|
|
|
aiAdvice.value.riskCards
|
|
|
|
|
|
.filter((card) => normalizeRiskTone(card?.tone) === 'high')
|
|
|
|
|
|
.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}` : ''
|
|
|
|
|
|
)
|
|
|
|
|
|
const hasRiskOverrideExplanation = computed(() => detailNoteTags.value.includes('#high_risk'))
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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 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 buildRiskOverrideAppendix() {
|
|
|
|
|
|
return submitRiskWarnings.value
|
|
|
|
|
|
.map((risk, index) => {
|
|
|
|
|
|
const reason = String(riskOverrideReasons[risk.id] || '').trim()
|
|
|
|
|
|
const tags = resolveRiskTags(risk).join(' ')
|
|
|
|
|
|
const title = String(risk.title || risk.label || '重大风险').trim()
|
|
|
|
|
|
return `超标说明:${tags} 第${index + 1}条 ${title}:${reason}`
|
|
|
|
|
|
})
|
|
|
|
|
|
.join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 appendix = buildRiskOverrideAppendix()
|
|
|
|
|
|
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
|
|
|
|
|
|
if (nextNote.length > 500) {
|
|
|
|
|
|
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
riskOverrideBusy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateExpenseClaim(request.value.claimId, {
|
|
|
|
|
|
reason: nextNote
|
|
|
|
|
|
})
|
|
|
|
|
|
detailNoteEditor.value = nextNote
|
|
|
|
|
|
riskOverrideDialogOpen.value = false
|
|
|
|
|
|
submitConfirmDialogOpen.value = true
|
|
|
|
|
|
toast('违规提交原因已写入附加说明。')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '风险原因保存失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
riskOverrideBusy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
function populateExpenseEditor(item) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
editingExpenseId.value = item.id
|
|
|
|
|
|
expenseEditor.itemDate = item.itemDate || ''
|
|
|
|
|
|
expenseEditor.itemType = item.itemType || 'other'
|
|
|
|
|
|
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
expenseEditor.itemLocation =
|
2026-05-21 16:09:47 +08:00
|
|
|
|
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
|
|
|
|
|
expenseEditor.invoiceId = item.invoiceId || ''
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
function startExpenseEdit(item) {
|
|
|
|
|
|
if (!isEditableRequest.value || actionBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item?.isSystemGenerated) {
|
|
|
|
|
|
toast('系统自动计算的补贴行不能手动编辑。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
populateExpenseEditor(item)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function validateExpenseEditor() {
|
2026-05-21 16:09:47 +08:00
|
|
|
|
if (expenseEditor.itemDate && !isValidIsoDate(expenseEditor.itemDate)) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isPlaceholderValue(expenseEditor.itemType)) {
|
|
|
|
|
|
return '请选择费用项目。'
|
|
|
|
|
|
}
|
2026-05-21 14:24:51 +08:00
|
|
|
|
if (
|
2026-05-21 16:09:47 +08:00
|
|
|
|
!isPlaceholderValue(expenseEditor.itemReason)
|
|
|
|
|
|
&&
|
2026-05-21 14:24:51 +08:00
|
|
|
|
isRouteDescriptionExpenseType(expenseEditor.itemType)
|
|
|
|
|
|
&& !isValidRouteDescription(expenseEditor.itemReason)
|
|
|
|
|
|
) {
|
2026-05-21 16:09:47 +08:00
|
|
|
|
return '行程说明格式应为“起始地-目的地”,例如:广州南-北京南。'
|
2026-05-21 14:24:51 +08:00
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
const amountText = String(expenseEditor.itemAmount || '').trim()
|
|
|
|
|
|
if (amountText) {
|
|
|
|
|
|
const amount = Number(amountText)
|
|
|
|
|
|
if (!Number.isFinite(amount) || amount < 0) {
|
|
|
|
|
|
return '请输入不小于 0 的费用金额。'
|
|
|
|
|
|
}
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleAddExpenseItem() {
|
2026-05-20 14:32:35 +08:00
|
|
|
|
if (!isEditableRequest.value || actionBusy.value) {
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法新增费用明细。')
|
|
|
|
|
|
return
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
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) {
|
2026-05-20 14:32:35 +08:00
|
|
|
|
if (!isEditableRequest.value || actionBusy.value) {
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!request.value.claimId) {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法上传单据。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
if (item?.isSystemGenerated) {
|
|
|
|
|
|
toast('系统自动计算的补贴行无需上传附件。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
if (item?.invoiceId) {
|
|
|
|
|
|
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pendingUploadExpenseId.value = item.id
|
|
|
|
|
|
if (expenseUploadInput.value) {
|
|
|
|
|
|
expenseUploadInput.value.value = ''
|
|
|
|
|
|
expenseUploadInput.value.click()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
async function loadAttachmentPreview(item) {
|
|
|
|
|
|
if (!request.value.claimId || !item?.invoiceId) {
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
attachmentPreviewLoading.value = true
|
2026-05-20 21:00:47 +08:00
|
|
|
|
attachmentPreviewError.value = ''
|
|
|
|
|
|
attachmentPreviewItemId.value = item.id
|
2026-05-13 06:52:30 +00:00
|
|
|
|
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
let metadata = resolveAttachmentMeta(item)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
if (!metadata) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
try {
|
|
|
|
|
|
metadata = await refreshExpenseAttachmentMeta(item.id)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (!hasStoredAttachmentReference(item)) {
|
|
|
|
|
|
throw new Error('当前附件只有文件名记录,原件尚未保存到单据中,请重新上传后预览。')
|
|
|
|
|
|
}
|
|
|
|
|
|
throw error
|
|
|
|
|
|
}
|
2026-05-20 21:00:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
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()
|
2026-05-14 15:43:10 +00:00
|
|
|
|
const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
revokeAttachmentPreviewUrl()
|
|
|
|
|
|
attachmentPreviewUrl.value = URL.createObjectURL(blob)
|
|
|
|
|
|
attachmentPreviewMediaType.value = blob.type || attachmentPreviewMediaType.value
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
attachmentPreviewError.value = error?.message || '附件预览失败,请稍后重试。'
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
attachmentPreviewLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
async function uploadExpenseFile(item, file) {
|
|
|
|
|
|
if (!item || !file) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-21 10:57:06 +08:00
|
|
|
|
if (item?.isSystemGenerated) {
|
|
|
|
|
|
toast('系统自动计算的补贴行无需上传附件。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
if (item?.invoiceId) {
|
|
|
|
|
|
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
uploadingExpenseId.value = item.id
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
|
|
|
|
|
|
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
2026-05-21 09:28:33 +08:00
|
|
|
|
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
2026-05-21 16:09:47 +08:00
|
|
|
|
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()
|
2026-05-21 09:28:33 +08:00
|
|
|
|
const itemPatch = {
|
2026-05-13 06:52:30 +00:00
|
|
|
|
invoiceId: String(payload?.invoice_id || '').trim(),
|
|
|
|
|
|
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
|
2026-05-21 09:28:33 +08:00
|
|
|
|
}
|
2026-05-21 16:09:47 +08:00
|
|
|
|
if (recognizedItemDate) {
|
|
|
|
|
|
itemPatch.itemDate = recognizedItemDate
|
|
|
|
|
|
}
|
|
|
|
|
|
if (recognizedItemType) {
|
|
|
|
|
|
itemPatch.itemType = recognizedItemType
|
|
|
|
|
|
}
|
|
|
|
|
|
if (recognizedItemReason) {
|
|
|
|
|
|
itemPatch.itemReason = recognizedItemReason
|
|
|
|
|
|
}
|
|
|
|
|
|
if (recognizedItemLocation) {
|
|
|
|
|
|
itemPatch.itemLocation = recognizedItemLocation
|
|
|
|
|
|
}
|
2026-05-21 09:28:33 +08:00
|
|
|
|
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
|
|
|
|
|
itemPatch.itemAmount = recognizedItemAmount
|
|
|
|
|
|
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
|
|
|
|
|
}
|
|
|
|
|
|
applyLocalExpenseItemPatch(item.id, {
|
|
|
|
|
|
...itemPatch
|
2026-05-13 06:52:30 +00:00
|
|
|
|
})
|
2026-05-21 16:09:47 +08:00
|
|
|
|
populateExpenseEditor({ ...item, ...itemPatch })
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const fileList = target?.files
|
|
|
|
|
|
const fileCount = fileList?.length || 0
|
|
|
|
|
|
const file = fileList?.[0]
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const itemId = pendingUploadExpenseId.value
|
|
|
|
|
|
pendingUploadExpenseId.value = ''
|
|
|
|
|
|
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
target.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
if (fileCount > 1) {
|
|
|
|
|
|
toast('一条费用明细只能上传一张单据,请只选择一个文件。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-21 10:57:06 +08:00
|
|
|
|
if (item?.isSystemGenerated) {
|
|
|
|
|
|
toast('系统自动计算的补贴行不能删除。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
|
|
|
|
|
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 = ''
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
async function saveExpenseEdit(item) {
|
2026-05-21 14:24:51 +08:00
|
|
|
|
if (actionBusy.value) {
|
|
|
|
|
|
toast(uploadingExpenseId.value ? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法保存费用明细。')
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const validationError = validateExpenseEditor()
|
|
|
|
|
|
if (validationError) {
|
|
|
|
|
|
toast(validationError)
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
savingExpenseId.value = item.id
|
|
|
|
|
|
try {
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const nextInvoiceId = expenseEditor.invoiceId.trim()
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
|
2026-05-21 16:09:47 +08:00
|
|
|
|
const amountText = String(expenseEditor.itemAmount || '').trim()
|
|
|
|
|
|
const nextAmount = amountText ? Number(amountText) : 0
|
|
|
|
|
|
const itemPayload = {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
item_type: expenseEditor.itemType,
|
|
|
|
|
|
item_reason: expenseEditor.itemReason.trim(),
|
2026-05-20 21:00:47 +08:00
|
|
|
|
item_location: preservedLocation,
|
2026-05-21 16:09:47 +08:00
|
|
|
|
item_amount: nextAmount,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
invoice_id: nextInvoiceId
|
2026-05-21 16:09:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (expenseEditor.itemDate) {
|
|
|
|
|
|
itemPayload.item_date = expenseEditor.itemDate
|
|
|
|
|
|
}
|
|
|
|
|
|
await updateExpenseClaimItem(request.value.claimId, item.id, itemPayload)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
applyLocalExpenseItemPatch(item.id, {
|
2026-05-21 16:09:47 +08:00
|
|
|
|
itemDate: expenseEditor.itemDate || item.itemDate,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
itemType: expenseEditor.itemType,
|
|
|
|
|
|
itemReason: expenseEditor.itemReason.trim(),
|
2026-05-20 21:00:47 +08:00
|
|
|
|
itemLocation: preservedLocation,
|
2026-05-21 16:09:47 +08:00
|
|
|
|
itemAmount: nextAmount,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
invoiceId: nextInvoiceId
|
2026-05-13 03:35:44 +00:00
|
|
|
|
})
|
2026-05-13 06:52:30 +00:00
|
|
|
|
let riskNotice = ''
|
|
|
|
|
|
if (nextInvoiceId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const attachment = await refreshExpenseAttachmentMeta(item.id)
|
|
|
|
|
|
riskNotice = buildAttachmentRiskNotice(attachment)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
delete expenseAttachmentMeta[item.id]
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
delete expenseAttachmentMeta[item.id]
|
|
|
|
|
|
}
|
2026-05-13 03:35:44 +00:00
|
|
|
|
editingExpenseId.value = ''
|
2026-05-13 06:52:30 +00:00
|
|
|
|
toast(riskNotice || '费用明细已保存。')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '费用明细保存失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
savingExpenseId.value = ''
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
function handleSubmit() {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法提交。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!canSubmit.value) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (draftBlockingIssues.value.length) {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
|
|
|
|
|
openRiskOverrideDialog()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
submitConfirmDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeSubmitConfirmDialog() {
|
|
|
|
|
|
if (submitBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
submitConfirmDialogOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmSubmitRequest() {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法提交。')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
submitConfirmDialogOpen.value = false
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!canSubmit.value) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
|
|
|
|
|
|
submitConfirmDialogOpen.value = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (draftBlockingIssues.value.length) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
submitConfirmDialogOpen.value = false
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
|
|
|
|
|
submitConfirmDialogOpen.value = false
|
|
|
|
|
|
openRiskOverrideDialog()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
submitBusy.value = true
|
|
|
|
|
|
try {
|
2026-05-15 06:57:07 +00:00
|
|
|
|
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') {
|
2026-05-25 13:35:39 +08:00
|
|
|
|
toast(
|
|
|
|
|
|
isApplicationDocument.value
|
|
|
|
|
|
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`
|
|
|
|
|
|
: `${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`
|
|
|
|
|
|
)
|
2026-05-15 06:57:07 +00:00
|
|
|
|
} else if (claimStatus === 'supplement') {
|
2026-05-20 09:36:01 +08:00
|
|
|
|
toast(`${request.value.id} AI预审未通过,已转待补充。`)
|
2026-05-15 06:57:07 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
toast(`${request.value.id} 提交结果已更新。`)
|
|
|
|
|
|
}
|
2026-05-20 21:00:47 +08:00
|
|
|
|
submitConfirmDialogOpen.value = false
|
2026-05-13 03:35:44 +00:00
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '提交审批失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
submitBusy.value = false
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 14:21:56 +08:00
|
|
|
|
async function handleDeleteRequest() {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!request.value.claimId) {
|
2026-05-20 14:21:56 +08:00
|
|
|
|
toast('当前单据缺少 claimId,暂时无法删除。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!canDeleteRequest.value) {
|
2026-05-26 09:15:14 +08:00
|
|
|
|
toast(
|
|
|
|
|
|
isArchivedRequest.value
|
|
|
|
|
|
? '已归档单据不能删除,只有高级管理员可以执行删除。'
|
2026-05-26 17:29:35 +08:00
|
|
|
|
: '当前单据已进入流程,只有高级财务人员可以删除。'
|
2026-05-26 09:15:14 +08:00
|
|
|
|
)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
deleteDialogOpen.value = true
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function closeDeleteDialog() {
|
|
|
|
|
|
if (deleteBusy.value) {
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
deleteDialogOpen.value = false
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 14:21:56 +08:00
|
|
|
|
async function confirmDeleteRequest() {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!request.value.claimId) {
|
2026-05-20 14:21:56 +08:00
|
|
|
|
toast('当前单据缺少 claimId,暂时无法删除。')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
deleteBusy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await deleteExpenseClaim(request.value.claimId)
|
|
|
|
|
|
deleteDialogOpen.value = false
|
2026-05-25 13:35:39 +08:00
|
|
|
|
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
emit('request-deleted', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
2026-05-20 14:21:56 +08:00
|
|
|
|
toast(error?.message || '删除单据失败,请稍后重试。')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
deleteBusy.value = false
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 14:21:56 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
async function confirmReturnRequest(payload) {
|
2026-05-20 14:21:56 +08:00
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前单据缺少 claimId,暂时无法退回。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
returnBusy.value = true
|
|
|
|
|
|
try {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
await returnExpenseClaim(request.value.claimId, payload)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
returnDialogOpen.value = false
|
2026-05-20 14:32:35 +08:00
|
|
|
|
toast(`${request.value.id} 已退回待提交。`)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '退回单据失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
returnBusy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
function handleApproveRequest() {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!canApproveRequest.value) {
|
2026-05-21 09:28:33 +08:00
|
|
|
|
toast('当前节点暂不支持审批通过。')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
|
|
|
|
|
|
toast('请先填写领导意见,填写后才能确认审核。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
approveConfirmDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeApproveConfirmDialog() {
|
|
|
|
|
|
if (approveBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
approveConfirmDialogOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmApproveRequest() {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
|
|
|
|
|
approveConfirmDialogOpen.value = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!canApproveRequest.value) {
|
2026-05-21 09:28:33 +08:00
|
|
|
|
toast('当前节点暂不支持审批通过。')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
approveConfirmDialogOpen.value = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
|
|
|
|
|
|
toast('请先填写领导意见,填写后才能确认审核。')
|
|
|
|
|
|
approveConfirmDialogOpen.value = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
approveBusy.value = true
|
|
|
|
|
|
try {
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const responsePayload = await approveExpenseClaim(request.value.claimId, {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
opinion: leaderOpinion.value.trim()
|
|
|
|
|
|
})
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
approveConfirmDialogOpen.value = false
|
|
|
|
|
|
leaderOpinion.value = ''
|
2026-05-26 09:15:14 +08:00
|
|
|
|
toast(
|
|
|
|
|
|
isApplicationDocument.value && generatedDraftClaimNo
|
|
|
|
|
|
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
|
|
|
|
|
|
: approvalSuccessToast.value
|
|
|
|
|
|
)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '审批通过失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
approveBusy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function openAiEntry() {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
if (!canOpenAiEntry.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const claimId = String(request.value?.claimId || '').trim()
|
2026-05-13 03:35:44 +00:00
|
|
|
|
emit('openAssistant', {
|
|
|
|
|
|
source: 'detail',
|
|
|
|
|
|
prompt: '',
|
2026-05-14 15:43:10 +00:00
|
|
|
|
request: request.value,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
restoreLatestConversation: false,
|
|
|
|
|
|
scope: claimId
|
|
|
|
|
|
? {
|
|
|
|
|
|
type: 'claim',
|
|
|
|
|
|
claimId
|
|
|
|
|
|
}
|
|
|
|
|
|
: null
|
2026-05-13 03:35:44 +00:00
|
|
|
|
})
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
closeAttachmentPreview()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-06 11:00:38 +08:00
|
|
|
|
return {
|
2026-05-22 16:00:19 +08:00
|
|
|
|
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
|
|
|
|
|
|
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
approvalConfirmDescription, approvalConfirmSummaryLabel, approvalNextStage, approvalOpinionHint,
|
|
|
|
|
|
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
|
|
|
|
|
|
applicationDetailFactItems,
|
|
|
|
|
|
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
|
|
|
|
|
canNavigateAttachmentPreview,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
canOpenAiEntry, canApproveRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
|
|
|
|
|
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closeSubmitConfirmDialog,
|
|
|
|
|
|
closeRiskOverrideDialog,
|
|
|
|
|
|
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
|
|
|
|
|
confirmRiskOverrideReasons,
|
|
|
|
|
|
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
|
|
|
|
|
|
currentSubmitRiskWarning,
|
|
|
|
|
|
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
|
|
|
|
|
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
|
|
|
|
|
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
2026-05-25 13:35:39 +08:00
|
|
|
|
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
isMajorExpenseRisk,
|
|
|
|
|
|
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
|
|
|
|
|
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
leaderApprovalReadonlyMeta, leaderApprovalReadonlyText,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
resolveExpenseRiskIndicatorTitle,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
|
|
|
|
|
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
|
|
|
|
|
requiresApprovalOpinion,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
showAiAdvicePanel, showApplicationLeaderOpinion, showApplicationLeaderOpinionInput,
|
|
|
|
|
|
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
submitRiskWarnings,
|
|
|
|
|
|
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|