Files
X-Financial/web/src/views/scripts/TravelRequestDetailView.js
caoxiaozhu f28d7e6d16 feat: 完善差旅票据行程提取与费用明细回填逻辑
增强文档智能识别的票据场景关键词和字段提取能力,优化
会话关联草稿报销单的解析路径,修复费用明细合并和票据
去重边界问题,前端改进报销创建和审批详情交互,补充单
元测试覆盖。
2026-05-21 14:24:51 +08:00

1721 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import ReturnReasonDialog from '../../components/shared/ReturnReasonDialog.vue'
import {
approveExpenseClaim,
createExpenseClaimItem,
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
deleteExpenseClaim,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
returnExpenseClaim,
submitExpenseClaim,
uploadExpenseClaimItemAttachment,
updateExpenseClaim,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import {
canApproveLeaderExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isFinanceUser
} from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards
} from './travelRequestDetailInsights.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: 'entertainment', 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', 'ride_ticket'])
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$/
function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function formatCurrency(value) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
}).format(value)
}
function normalizeExpenseType(value) {
return String(value || '').trim() || 'other'
}
function resolveExpenseTypeLabel(value) {
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
}
function isSystemGeneratedExpenseItemSource(source) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
}
function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function resolveLocationSummaryLabel(value) {
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
}
function resolveLocationDisplay(value, expenseType) {
if (!isLocationRequiredExpenseType(expenseType) && isPlaceholderValue(value)) {
return '非必填'
}
return isPlaceholderValue(value) ? '待补充' : value
}
function isRouteDescriptionExpenseType(value) {
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function isValidRouteDescription(value) {
const text = String(value || '').trim()
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
}
function resolveExpenseReasonPlaceholder(itemType) {
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地,例如:广州南-北京南' : '输入费用说明'
}
function resolveExpenseReasonHelper(itemType) {
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地' : '业务报销说明'
}
function buildFallbackProgressSteps() {
return [
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
{ index: 3, label: 'AI预审', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' },
{ index: 5, label: '财务审批', time: '待处理' },
{ index: 6, label: '归档入账', time: '待处理' }
]
}
function buildFallbackExpenseItems(request) {
return [
buildExpenseItemViewModel({
id: 'fallback-1',
itemDate: '',
itemType: request.typeCode || 'other',
itemReason: request.reason,
itemLocation: request.sceneTarget,
itemAmount: parseCurrency(request.amountDisplay),
invoiceId: '',
time: '待补充',
dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日',
name: request.typeLabel,
category: request.typeLabel,
desc: request.reason,
detail: resolveLocationDisplay(request.sceneTarget, request.typeCode),
amount: request.amountDisplay,
status: '待补充',
tone: 'bad',
attachmentStatus: '待上传',
attachmentHint: '请在此单据中继续补充附件',
attachmentTone: 'missing',
attachments: [],
riskLabel: '待补材料',
riskText: request.riskSummary,
riskTone: 'medium'
}, 0, request)
]
}
function isPlaceholderValue(value) {
const text = String(value || '').trim()
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function normalizeDetailNoteDraftValue(value) {
const text = String(value || '').trim()
return isPlaceholderValue(text) ? '' : text
}
function isValidIsoDate(value) {
const normalized = String(value || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return false
}
const [yearText, monthText, dayText] = normalized.split('-')
const year = Number(yearText)
const month = Number(monthText)
const day = Number(dayText)
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return false
}
const candidate = new Date(Date.UTC(year, month - 1, day))
return (
candidate.getUTCFullYear() === year &&
candidate.getUTCMonth() === month - 1 &&
candidate.getUTCDate() === day
)
}
function normalizeIsoDateValue(value) {
const normalized = String(value || '').trim()
if (isValidIsoDate(normalized)) {
return normalized
}
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/)
if (match && isValidIsoDate(match[1])) {
return match[1]
}
const candidate = value instanceof Date ? value : new Date(normalized)
if (Number.isNaN(candidate.getTime())) {
return ''
}
const year = candidate.getFullYear()
const month = String(candidate.getMonth() + 1).padStart(2, '0')
const day = String(candidate.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function 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 resolveExpenseUploadHint(value) {
const normalized = String(value || '').trim()
return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据'
}
function extractAttachmentDisplayName(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return ''
}
return normalized.split('/').filter(Boolean).pop() || normalized
}
function resolveExpenseItemViewId(source, index, requestModel) {
return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`)
}
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 buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
const id = resolveExpenseItemViewId(source, index, requestModel)
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
const attachments = invoiceId ? [attachmentName || invoiceId] : []
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
const riskText = String(source?.riskText || '').trim()
const filledAt = formatExpenseFilledTime(
source?.filledAt
|| source?.filled_at
|| source?.createdAt
|| source?.created_at
)
return {
id,
itemDate,
itemType,
itemReason,
itemLocation,
itemAmount,
invoiceId,
isSystemGenerated,
time: itemDate || '待补充',
filledAt: filledAt || '待同步',
dayLabel: resolveExpenseTimeLabel({
id,
itemType,
isSystemGenerated,
requestModel,
travelTimeLabelMap
}),
name: resolveExpenseTypeLabel(itemType),
category: resolveExpenseTypeLabel(itemType),
desc: itemReason || '待补充',
detail: resolveLocationDisplay(itemLocation, itemType),
amount: amountDisplay,
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
attachments,
riskLabel: String(source?.riskLabel || '').trim() || '无',
riskText,
riskTone: String(source?.riskTone || '').trim() || 'low'
}
}
function rebuildExpenseItems(items, requestModel) {
const sortedItems = [...items]
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
}
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 buildDraftBlockingIssues(request, expenseItems) {
const issues = []
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
if (isPlaceholderValue(request.profileName)) {
issues.push('申请人未完善')
}
if (isPlaceholderValue(request.typeLabel)) {
issues.push('报销类型未完善')
}
if (isPlaceholderValue(request.reason)) {
issues.push('报销事由未完善')
}
if (locationRequired && isPlaceholderValue(request.location)) {
issues.push('业务地点未完善')
}
if (isPlaceholderValue(request.occurredDisplay)) {
issues.push('发生时间未完善')
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
issues.push('报销金额未完善')
}
if (!expenseItems.length) {
issues.push('费用明细不能为空')
}
expenseItems.forEach((item, index) => {
buildExpenseDraftIssues(item).forEach((issue) => {
issues.push(`费用明细第 ${index + 1}${issue}`)
})
})
return [...new Set(issues)]
}
function mapIssueToAdvice(issue) {
const text = String(issue || '').trim()
if (!text) {
return ''
}
if (text === '费用明细不能为空') {
return '先新增至少 1 条费用明细,再补充金额、用途和附件。'
}
if (text === '申请人未完善') {
return '补充申请人信息,确保审批单据归属明确。'
}
if (text === '所属部门未完善') {
return '补充所属部门,便于财务和审批人识别成本归属。'
}
if (text === '报销类型未完善') {
return '选择报销类型,明确本次费用归类。'
}
if (text === '报销事由未完善') {
return '补充报销事由,说明本次费用用途。'
}
if (text === '业务地点未完善') {
return '补充业务地点,方便审核业务发生场景。'
}
if (text === '发生时间未完善') {
return '补充费用发生时间,确保单据时间完整。'
}
if (text === '报销金额未完善') {
return '补充报销金额,并与费用明细金额保持一致。'
}
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
if (!itemMatch) {
return text
}
const [, indexText, fieldText] = itemMatch
const labelPrefix = `完善第 ${indexText} 条费用明细`
if (fieldText === '缺少日期') {
return `${labelPrefix}的发生日期。`
}
if (fieldText === '缺少费用项目') {
return `${labelPrefix}的费用项目。`
}
if (fieldText === '缺少说明') {
return `${labelPrefix}的用途说明。`
}
if (fieldText === '行程说明格式错误') {
return `${labelPrefix}的行程说明,格式应为“始发地-目的地”。`
}
if (fieldText === '缺少地点') {
return `${labelPrefix}的业务地点。`
}
if (fieldText === '缺少金额') {
return `${labelPrefix}的金额。`
}
if (fieldText === '缺少票据标识') {
return `为第 ${indexText} 条费用明细上传或关联票据附件。`
}
return `${labelPrefix}`
}
export default {
name: 'TravelRequestDetailView',
components: {
ConfirmDialog,
ReturnReasonDialog
},
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 creatingExpense = ref(false)
const uploadingExpenseId = ref('')
const deletingAttachmentId = ref('')
const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('')
const submitBusy = ref(false)
const submitConfirmDialogOpen = ref(false)
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 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: '',
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 isTravelRequest = computed(() => request.value.detailVariant === 'travel')
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.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 canReturnRequest = computed(() =>
canReturnExpenseClaims(currentUser.value)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
)
const canApproveRequest = computed(() =>
Boolean(props.approvalMode)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
&& (
(
isDirectManagerApprovalStage.value
&& canApproveLeaderExpenseClaims(currentUser.value)
)
|| (
isFinanceApprovalStage.value
&& isFinanceUser(currentUser.value)
)
)
)
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
const approvalOpinionPlaceholder = computed(() =>
isFinanceApprovalStage.value
? '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
: '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
)
const approvalOpinionHint = computed(() =>
isFinanceApprovalStage.value ? '审核通过后将进入归档入账。' : '审批通过后将流转至财务审批。'
)
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
const approvalConfirmDescription = computed(() =>
isFinanceApprovalStage.value
? '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
: '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
)
const approvalNextStage = computed(() => (isFinanceApprovalStage.value ? '归档入账' : '财务审批'))
const approvalSuccessToast = computed(() =>
isFinanceApprovalStage.value
? `${request.value.id} 已完成财务终审,进入归档入账。`
: `${request.value.id} 已审批通过,流转至财务审批。`
)
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() =>
isDraftRequest.value
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
)
const actionBusy = computed(() =>
Boolean(savingExpenseId.value)
|| submitBusy.value
|| deleteBusy.value
|| returnBusy.value
|| approveBusy.value
|| creatingExpense.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
|| Boolean(deletingExpenseId.value)
)
const profile = computed(() => ({
name: request.value.profileName,
identity: request.value.profileIdentity,
position: request.value.profilePosition,
department: request.value.profileDepartment,
grade: request.value.profileGrade,
manager: request.value.profileManager,
avatar: request.value.profileAvatar
}))
const expenseItems = ref([])
watch(
request,
(nextRequest, previousRequest) => {
expenseItems.value =
Array.isArray(nextRequest.expenseItems)
? rebuildExpenseItems(nextRequest.expenseItems, nextRequest)
: buildFallbackExpenseItems(nextRequest)
if (nextRequest.claimId !== previousRequest?.claimId) {
Object.keys(expenseAttachmentMeta).forEach((key) => {
delete expenseAttachmentMeta[key]
})
closeAttachmentPreview()
}
pendingUploadExpenseId.value = ''
uploadingExpenseId.value = ''
deletingExpenseId.value = ''
editingExpenseId.value = ''
void syncExpenseAttachmentMeta()
},
{ immediate: true }
)
const heroFactItems = computed(() => [
{
key: 'document',
label: '报销单号',
value: request.value.documentNo || request.value.id,
icon: 'mdi mdi-camera-outline',
valueClass: ''
},
{
key: 'date',
label: '单据申请日期',
value: request.value.applyTime || request.value.occurredDisplay,
icon: 'mdi mdi-calendar-month-outline',
valueClass: ''
},
{
key: 'amount',
label: '报销金额',
value: request.value.amountDisplay,
icon: '',
valueClass: 'amount'
},
{
key: 'type',
label: isTravelRequest.value ? '差旅类型' : '报销类型',
value: request.value.typeLabel,
icon: '',
valueClass: ''
},
{
key: 'status',
label: '当前状态',
value: request.value.node,
icon: '',
valueClass: 'status'
}
])
const progressSteps = computed(() =>
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
? request.value.progressSteps
: buildFallbackProgressSteps()
)
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34
},
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1]
}
}
}
const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
return formatCurrency(total)
})
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const expenseTableColumnCount = computed(
() => 6 + (isEditableRequest.value ? 1 : 0)
)
const canEditDetailNote = computed(() => isDraftRequest.value)
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
const detailNote = computed(() => {
if (detailNoteSource.value) {
return detailNoteSource.value
}
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
})
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
watch(
() => [request.value.claimId, detailNoteSource.value],
([, nextNote]) => {
detailNoteEditor.value = nextNote
},
{ immediate: true }
)
const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
const attachmentPreviewEntries = computed(() =>
expenseItems.value
.filter((item) => item.invoiceId)
.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 resolveAttachmentDisplayName(item) {
const metadata = resolveAttachmentMeta(item)
return String(metadata?.file_name || item.attachmentHint || '').trim()
}
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}`
}
async function refreshExpenseAttachmentMeta(itemId) {
if (!request.value.claimId || !itemId) {
return null
}
const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, itemId)
expenseAttachmentMeta[itemId] = payload
return payload
}
function canPreviewAttachment(item) {
const metadata = resolveAttachmentMeta(item)
return Boolean(item.invoiceId && metadata?.previewable !== false)
}
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 (!item.invoiceId) {
return null
}
if (uploadingExpenseId.value === item.id) {
return {
label: 'AI识别中',
tone: 'medium',
headline: 'AI提示正在分析附件内容',
summary: '附件已上传,系统正在识别票据内容与风险点,请稍候。',
points: [],
suggestion: ''
}
}
const metadata = resolveAttachmentMeta(item)
const analysis = metadata?.analysis
if (analysis) {
return {
label: analysis.label || '已上传',
tone: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low',
headline: analysis.headline || 'AI提示',
summary: analysis.summary || '',
points: Array.isArray(analysis.points) ? analysis.points : [],
suggestion: analysis.suggestion || ''
}
}
return {
label: '已上传',
tone: 'low',
headline: 'AI提示附件已上传',
summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。',
points: [],
suggestion: ''
}
}
function showExpenseRisk(item) {
return Boolean(resolveExpenseRiskState(item))
}
const aiAdvice = computed(() => {
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
const riskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
})
return buildAiAdviceViewModel({
completionItems,
riskCards
})
})
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 startExpenseEdit(item) {
if (!isEditableRequest.value || actionBusy.value) {
return
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行不能手动编辑。')
return
}
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation =
item.itemLocation || (['待补充', '非必填'].includes(item.detail) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.invoiceId = item.invoiceId || ''
}
function cancelExpenseEdit() {
editingExpenseId.value = ''
}
function validateExpenseEditor() {
if (!isValidIsoDate(expenseEditor.itemDate)) {
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
}
if (isPlaceholderValue(expenseEditor.itemType)) {
return '请选择费用项目。'
}
if (isPlaceholderValue(expenseEditor.itemReason)) {
return '请输入费用说明。'
}
if (
isRouteDescriptionExpenseType(expenseEditor.itemType)
&& !isValidRouteDescription(expenseEditor.itemReason)
) {
return '行程说明格式应为“始发地-目的地”,例如:广州南-北京南。'
}
const amount = Number(expenseEditor.itemAmount)
if (!Number.isFinite(amount) || amount <= 0) {
return '请输入大于 0 的费用金额。'
}
return ''
}
async function handleAddExpenseItem() {
if (!isEditableRequest.value || actionBusy.value) {
return
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法新增费用明细。')
return
}
creatingExpense.value = true
try {
const existingIds = new Set(expenseItems.value.map((item) => item.id))
const claim = await createExpenseClaimItem(request.value.claimId, {})
const createdItem = Array.isArray(claim?.items)
? claim.items.find((entry) => !existingIds.has(String(entry?.id || '')))
: null
if (!createdItem) {
throw new Error('新增费用明细失败,请稍后重试。')
}
const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value)
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
creatingExpense.value = false
startExpenseEdit(nextItem)
toast('已新增一条费用明细,请继续填写。')
} catch (error) {
toast(error?.message || '新增费用明细失败,请稍后重试。')
} finally {
creatingExpense.value = false
}
}
function triggerExpenseUpload(item) {
if (!isEditableRequest.value || actionBusy.value) {
return
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法上传单据。')
return
}
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) {
metadata = await refreshExpenseAttachmentMeta(item.id)
}
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)
expenseAttachmentMeta[item.id] = payload?.attachment || null
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount)
}
applyLocalExpenseItemPatch(item.id, {
...itemPatch
})
if (editingExpenseId.value === item.id) {
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
expenseEditor.itemAmount = String(recognizedItemAmount)
}
}
emit('request-updated', { claimId: request.value.claimId })
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
} catch (error) {
toast(error?.message || '附件上传失败,请稍后重试。')
} finally {
uploadingExpenseId.value = ''
}
}
async function removeExpenseAttachment(item) {
if (!request.value.claimId || !item?.invoiceId || actionBusy.value) {
return
}
deletingAttachmentId.value = item.id
try {
const payload = await deleteExpenseClaimItemAttachment(request.value.claimId, item.id)
delete expenseAttachmentMeta[item.id]
applyLocalExpenseItemPatch(item.id, {
invoiceId: '',
attachmentHint: resolveExpenseUploadHint()
})
if (editingExpenseId.value === item.id) {
expenseEditor.invoiceId = ''
}
if (attachmentPreviewOpen.value) {
closeAttachmentPreview()
}
emit('request-updated', { claimId: request.value.claimId })
toast(payload?.message || '附件已删除。')
} catch (error) {
toast(error?.message || '附件删除失败,请稍后重试。')
} finally {
deletingAttachmentId.value = ''
}
}
async function handleExpenseFileChange(event) {
const target = event?.target
const 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.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()
await updateExpenseClaimItem(request.value.claimId, item.id, {
item_date: expenseEditor.itemDate,
item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(),
item_location: preservedLocation,
item_amount: Number(expenseEditor.itemAmount),
invoice_id: nextInvoiceId
})
applyLocalExpenseItemPatch(item.id, {
itemDate: expenseEditor.itemDate,
itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(),
itemLocation: preservedLocation,
itemAmount: Number(expenseEditor.itemAmount),
invoiceId: nextInvoiceId
})
let riskNotice = ''
if (nextInvoiceId) {
try {
const attachment = await refreshExpenseAttachmentMeta(item.id)
riskNotice = buildAttachmentRiskNotice(attachment)
} catch {
delete expenseAttachmentMeta[item.id]
}
} else {
delete expenseAttachmentMeta[item.id]
}
editingExpenseId.value = ''
toast(riskNotice || '费用明细已保存。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '费用明细保存失败,请稍后重试。')
} finally {
savingExpenseId.value = ''
}
}
function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
return
}
if (!canSubmit.value) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
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(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(`${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
} else if (claimStatus === 'supplement') {
toast(`${request.value.id} AI预审未通过已转待补充。`)
} 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('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
return
}
deleteDialogOpen.value = true
}
function closeDeleteDialog() {
if (deleteBusy.value) {
return
}
deleteDialogOpen.value = false
}
async function confirmDeleteRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法删除。')
return
}
deleteBusy.value = true
try {
const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} 报销单已删除。`)
emit('request-deleted', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '删除单据失败,请稍后重试。')
} finally {
deleteBusy.value = false
}
}
function handleReturnRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
if (!canReturnRequest.value) {
toast('当前状态不支持退回。')
return
}
returnDialogOpen.value = true
}
function closeReturnDialog() {
if (returnBusy.value) {
return
}
returnDialogOpen.value = false
}
async function confirmReturnRequest(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
}
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 {
await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim()
})
approveConfirmDialogOpen.value = false
leaderOpinion.value = ''
toast(approvalSuccessToast.value)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
} finally {
approveBusy.value = false
}
}
function openAiEntry() {
if (!canOpenAiEntry.value) {
return
}
emit('openAssistant', {
source: 'detail',
prompt: '',
request: request.value,
restoreLatestConversation: true
})
}
onBeforeUnmount(() => {
closeAttachmentPreview()
})
return {
emit,
actionBusy,
aiAdvice,
attachmentPreviewError,
attachmentPreviewIndexLabel,
attachmentPreviewLoading,
attachmentPreviewMediaType,
attachmentPreviewName,
attachmentPreviewOpen,
attachmentPreviewUrl,
approveBusy,
approveConfirmDialogOpen,
approvalConfirmBadge,
approvalConfirmDescription,
approvalNextStage,
approvalOpinionHint,
approvalOpinionPlaceholder,
approvalOpinionTitle,
canDeleteRequest,
canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry,
canApproveRequest,
canReturnRequest,
canSubmit,
canPreviewAttachment,
closeApproveConfirmDialog,
closeDeleteDialog,
closeAttachmentPreview,
closeSubmitConfirmDialog,
closeReturnDialog,
confirmApproveRequest,
confirmDeleteRequest,
confirmSubmitRequest,
confirmReturnRequest,
currentAttachmentPreviewInsight,
currentAttachmentPreviewRiskCards,
currentProgressRingMotion,
canEditDetailNote,
deleteActionLabel,
deleteBusy,
deleteDialogDescription,
deleteDialogOpen,
deleteDialogTitle,
deletingAttachmentId,
deletingExpenseId,
detailNote,
detailNoteDirty,
detailNoteEditor,
draftBlockingIssues,
editingExpenseId,
creatingExpense,
expenseEditor,
expenseItems,
expenseTableColumnCount,
expenseTotal,
expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleAddExpenseItem,
handleApproveRequest,
handleDeleteRequest,
handleExpenseFileChange,
handleReturnRequest,
handleSubmit,
heroFactItems,
isDraftRequest,
isEditableRequest,
isTravelRequest,
openAiEntry,
openAttachmentPreview,
goToNextAttachmentPreview,
goToPreviousAttachmentPreview,
profile,
progressSteps,
request,
leaderOpinion,
removeExpenseAttachment,
removeExpenseItem,
resetDetailNote,
resolveAttachmentDisplayName,
resolveAttachmentPreviewTitle,
resolveAttachmentRecognition,
resolveExpenseReasonHelper,
resolveExpenseReasonPlaceholder,
resolveExpenseRiskState,
resolveExpenseIssues,
returnBusy,
returnDialogOpen,
saveDetailNote,
savingDetailNote,
savingExpenseId,
showLeaderApprovalPanel,
showExpenseRisk,
startExpenseEdit,
submitBusy,
submitConfirmDialogOpen,
triggerExpenseUpload,
uploadedExpenseCount,
uploadingExpenseId,
cancelExpenseEdit,
saveExpenseEdit
}
}
}