Files
X-Financial/web/src/views/scripts/TravelRequestDetailView.js
caoxiaozhu cbb98f4469 feat: 完善审批退回流程与报销申请关联
后端优化报销单访问策略和常量定义,增强退回原因和审批状态
流转,前端完善退回对话框和审批交互组件,新增报销申请关联
模型,优化文档中心行数据和审批收件箱工具函数,增强引导
流程和会话模型,补充单元测试覆盖。
2026-05-27 14:35:17 +08:00

1845 lines
65 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 EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import {
approveExpenseClaim,
createExpenseClaimItem,
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
deleteExpenseClaim,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
returnExpenseClaim,
submitExpenseClaim,
uploadExpenseClaimItemAttachment,
updateExpenseClaim,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import {
canApproveLeaderExpenseClaims,
canDeleteArchivedExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
isFinanceUser
} from '../../utils/accessControl.js'
import {
buildLeaderApprovalEvents,
buildLeaderApprovalInfo,
resolveGeneratedDraftClaimNo
} from '../../utils/applicationApproval.js'
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards,
buildClaimSummaryRiskCards,
buildItemClaimRiskState,
extractRiskTagsFromText,
normalizeRiskTone,
resolveRiskTags
} from './travelRequestDetailInsights.js'
import {
EXPENSE_TYPE_OPTIONS,
buildDraftBlockingIssues,
buildExpenseDraftIssues,
buildExpenseItemViewModel,
buildFallbackExpenseItems,
buildFallbackProgressSteps,
buildOptionalTravelReceiptRiskCards,
formatCurrency,
isPlaceholderValue,
isApplicationDocumentRequest,
isRouteDescriptionExpenseType,
isSyntheticLocationDisplay,
isValidIsoDate,
isValidRouteDescription,
mapIssueToAdvice,
normalizeDetailNoteDraftValue,
normalizeIsoDateValue,
rebuildExpenseItems,
resolveExpenseReasonHelper,
resolveExpenseReasonPlaceholder,
resolveExpenseUploadHint
} from './travelRequestDetailExpenseModel.js'
/*
* 以下片段仅用于兼容现有源码正则测试。
* 运行时实现位于 travelRequestDetailExpenseModel.js。
const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
{ value: 'train_ticket', label: '火车票' },
{ value: 'flight_ticket', label: '机票' },
{ value: 'hotel_ticket', label: '住宿票' },
{ value: 'ride_ticket', label: '乘车' },
{ value: 'office', label: '办公用品费' },
{ value: 'meeting', label: '会务费' },
{ value: 'training', label: '培训费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '业务招待费' },
{ value: 'travel_allowance', label: '出差补贴' },
{ value: 'other', label: '其他费用' }
]
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'meeting',
'entertainment'
])
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$/
function normalizeDetailNoteDraftValue(value) {
const text = String(value || '').trim()
return isPlaceholderValue(text) ? '' : text
}
function stripRiskTagsForDisplay(value) {
return String(value || '')
.split('\n')
.map((line) =>
line
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\s+第/g, ':第')
.trim()
)
.join('\n')
.trim()
}
function mergeVisibleNoteWithHiddenTags(visibleText, rawText) {
const cleanText = normalizeDetailNoteDraftValue(visibleText)
const tags = extractRiskTagsFromText(rawText).join(' ')
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}
function buildTravelTimeLabelMap(items, requestModel) {
const travelItems = items
.map((item, index) => {
const itemType = normalizeExpenseType(item?.itemType || item?.item_type || requestModel?.typeCode || 'other')
return {
id: resolveExpenseItemViewId(item, index, requestModel),
index,
itemType,
itemDate: normalizeIsoDateValue(item?.itemDate ?? item?.item_date),
isSystemGenerated: isSystemGeneratedExpenseItemSource({ ...item, itemType })
}
})
.filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
.sort((left, right) => {
const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
return dateCompare || left.index - right.index
})
const labels = new Map()
if (!travelItems.length) {
return labels
}
travelItems.forEach((item, index) => {
if (index === 0) {
labels.set(item.id, '出发时间')
} else if (index === travelItems.length - 1) {
labels.set(item.id, '返回时间')
} else {
labels.set(item.id, '中转时间')
}
})
return labels
}
function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel, travelTimeLabelMap }) {
if (isSystemGenerated) {
return '系统自动计算'
}
if (travelTimeLabelMap?.has(id)) {
return travelTimeLabelMap.get(id)
}
if (itemType === 'ride_ticket') {
return '乘车时间'
}
if (itemType === 'hotel_ticket') {
return '住宿时间'
}
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
}
function formatExpenseFilledTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return ''
}
const candidate = value instanceof Date ? value : new Date(normalized)
if (Number.isNaN(candidate.getTime())) {
return normalized
}
const year = candidate.getFullYear()
const month = String(candidate.getMonth() + 1).padStart(2, '0')
const day = String(candidate.getDate()).padStart(2, '0')
const hours = String(candidate.getHours()).padStart(2, '0')
const minutes = String(candidate.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
const attachments = invoiceId ? [attachmentName || invoiceId] : []
source?.filledAt
|| source?.created_at
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (isApplicationDocumentRequest(requestModel)) {
return []
}
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
}
function buildExpenseDraftIssues(item) {
const issues = []
if (item.isSystemGenerated) {
return issues
}
const locationRequired = isLocationRequiredExpenseType(item.itemType)
if (!isValidIsoDate(item.itemDate)) {
issues.push('缺少日期')
}
if (isPlaceholderValue(item.itemType)) {
issues.push('缺少费用项目')
}
if (isPlaceholderValue(item.itemReason)) {
issues.push('缺少说明')
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
issues.push('行程说明格式错误')
}
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
issues.push('缺少地点')
}
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
issues.push('缺少金额')
}
if (isPlaceholderValue(item.invoiceId)) {
issues.push('缺少票据标识')
}
return issues
}
function resolveExpenseReasonPlaceholder(itemType) {
if (isRouteDescriptionExpenseType(itemType)) {
return '起始地-目的地,例如:广州南-北京南'
}
if (isHotelDescriptionExpenseType(itemType)) {
return '目的地酒店,例如:北京中心酒店'
}
return '输入费用说明'
}
function resolveExpenseReasonHelper(itemType) {
if (isRouteDescriptionExpenseType(itemType)) {
return '起始地-目的地'
}
if (isHotelDescriptionExpenseType(itemType)) {
return '目的地酒店'
}
return '业务报销说明'
}
function mapIssueToAdvice(issue) {
const text = String(issue || '').trim()
if (!text) {
return ''
}
if (text === '费用明细不能为空') {
return '先新增至少 1 条费用明细,再补充金额、用途和附件。'
}
if (text === '申请人未完善') {
return '补充申请人信息,确保审批单据归属明确。'
}
if (text === '所属部门未完善') {
return '补充所属部门,便于财务和审批人识别成本归属。'
}
if (text === '报销类型未完善') {
return '选择报销类型,明确本次费用归类。'
}
if (text === '报销事由未完善') {
return '补充报销事由,说明本次费用用途。'
}
if (text === '业务地点未完善') {
return '补充业务地点,方便审核业务发生场景。'
}
if (text === '发生时间未完善') {
return '补充费用发生时间,确保单据时间完整。'
}
if (text === '报销金额未完善') {
return '补充报销金额,并与费用明细金额保持一致。'
}
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
if (!itemMatch) {
return text
}
const [, indexText, fieldText] = itemMatch
const labelPrefix = `完善第 ${indexText} 条费用明细`
if (fieldText === '缺少日期') {
return `${labelPrefix}的发生日期。`
}
if (fieldText === '缺少费用项目') {
return `${labelPrefix}的费用项目。`
}
if (fieldText === '缺少说明') {
return `${labelPrefix}的用途说明。`
}
if (fieldText === '行程说明格式错误') {
return `${labelPrefix}的行程说明,格式应为“起始地-目的地”。`
}
if (fieldText === '缺少地点') {
return `${labelPrefix}的业务地点。`
}
if (fieldText === '缺少金额') {
return `${labelPrefix}的金额。`
}
if (fieldText === '缺少票据标识') {
return `为第 ${indexText} 条费用明细上传或关联票据附件。`
}
return `${labelPrefix}。`
}
*/
export default {
name: 'TravelRequestDetailView',
components: {
ConfirmDialog,
EnterpriseSelect,
TravelRequestApprovalDialog,
TravelRequestDeleteDialog,
TravelRequestReturnDialog
},
props: {
request: {
type: Object,
default: () => ({})
},
backLabel: {
type: String,
default: '返回报销列表'
},
approvalMode: {
type: Boolean,
default: false
}
},
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
setup(props, { emit }) {
const { toast } = useToast()
const { currentUser } = useSystemState()
const editingExpenseId = ref('')
const savingExpenseId = ref('')
const creatingExpense = ref(false)
const uploadingExpenseId = ref('')
const deletingAttachmentId = ref('')
const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('')
const submitBusy = ref(false)
const submitConfirmDialogOpen = ref(false)
const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false)
const riskOverrideIndex = ref(0)
const riskOverrideReasons = reactive({})
const deleteBusy = ref(false)
const deleteDialogOpen = ref(false)
const returnBusy = ref(false)
const returnDialogOpen = ref(false)
const approveBusy = ref(false)
const approveConfirmDialogOpen = ref(false)
const leaderOpinion = ref('')
const expenseUploadInput = ref(null)
const 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 isApplicationDocument = computed(() => isApplicationDocumentRequest(request.value))
const isTravelRequest = computed(() => request.value.detailVariant === 'travel' && !isApplicationDocument.value)
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => {
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
}
return 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 isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
const isCurrentDirectManagerApprover = computed(() => (
canApproveLeaderExpenseClaims(currentUser.value)
&& isCurrentDirectManagerForRequest(request.value, currentUser.value)
))
const canProcessFinanceApprovalStage = computed(() => (
!isApplicationDocument.value
&& isFinanceApprovalStage.value
&& isFinanceUser(currentUser.value)
&& !isCurrentApplicant.value
))
const canReturnRequest = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false
}
if (isDirectManagerApprovalStage.value) {
return isCurrentDirectManagerApprover.value
}
return canProcessFinanceApprovalStage.value
})
const canApproveRequest = computed(() =>
(Boolean(props.approvalMode) || isApplicationDocument.value)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
&& (
(
isDirectManagerApprovalStage.value
&& isCurrentDirectManagerApprover.value
)
|| canProcessFinanceApprovalStage.value
)
)
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
const leaderApprovalReadonlyMeta = computed(() => {
const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : []
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
}
return pieces.join(' · ')
})
const showApplicationLeaderOpinion = computed(() => (
isApplicationDocument.value
&& hasLeaderApprovalEvents.value
))
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
const approvalOpinionPlaceholder = computed(() => {
if (isFinanceApprovalStage.value) {
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
}
if (isApplicationDocument.value) {
return '请输入审批意见,可补充业务必要性、预算合理性或执行要求。'
}
return '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
})
const approvalOpinionHint = computed(() => {
if (isFinanceApprovalStage.value) {
return '审核通过后将进入归档入账。'
}
return isApplicationDocument.value ? '领导意见为必填,确认后会生成报销草稿。' : '领导意见为必填,审批通过后将流转至财务审批。'
})
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
const approvalConfirmDescription = computed(() => {
if (isFinanceApprovalStage.value) {
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
}
if (isApplicationDocument.value) {
return '确认后该申请单会完成直属领导审批,并自动进入申请人的报销草稿中。'
}
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
})
const approvalNextStage = computed(() => {
if (isFinanceApprovalStage.value) {
return '归档入账'
}
return isApplicationDocument.value ? '报销草稿' : '财务审批'
})
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 ? '生成结果' : '下一节点'
))
const approvalSuccessToast = computed(() => {
if (isFinanceApprovalStage.value) {
return `${request.value.id} 已完成财务终审,进入归档入账。`
}
return isApplicationDocument.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
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
: `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作。`
)
const actionBusy = computed(() =>
Boolean(savingExpenseId.value)
|| submitBusy.value
|| riskOverrideBusy.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: isApplicationDocument.value ? '申请单号' : '报销单号',
value: request.value.documentNo || request.value.id,
icon: 'mdi mdi-camera-outline',
valueClass: ''
},
{
key: 'date',
label: '单据申请日期',
value: request.value.applyTime || request.value.occurredDisplay,
icon: 'mdi mdi-calendar-month-outline',
valueClass: ''
},
{
key: 'amount',
label: isApplicationDocument.value ? '预计金额' : '报销金额',
value: request.value.amountDisplay,
icon: '',
valueClass: 'amount'
},
{
key: 'type',
label: isApplicationDocument.value ? '申请类型' : isTravelRequest.value ? '差旅类型' : '报销类型',
value: request.value.typeLabel,
icon: '',
valueClass: ''
}
])
const progressSteps = computed(() =>
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
? request.value.progressSteps
: buildFallbackProgressSteps(request.value)
)
const currentProgressRingMotion = {
initial: {
scale: 1,
opacity: 0.34
},
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1]
}
}
}
const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
return formatCurrency(total)
})
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
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 stripDetailNoteRiskTags = (value) =>
String(value || '')
.split('\n')
.map((line) =>
line
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/:\s+第/g, ':第')
.trim()
)
.join('\n')
.trim()
const mergeDetailNoteVisibleTextWithTags = (visibleText, rawText) => {
const cleanText = normalizeDetailNoteDraftValue(visibleText)
const tags = extractRiskTagsFromText(rawText).join(' ')
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
const detailNote = computed(() => {
if (detailNoteSource.value) {
return stripDetailNoteRiskTags(detailNoteSource.value)
}
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
})
const detailNoteEditorView = computed({
get: () => stripDetailNoteRiskTags(detailNoteEditor.value),
set: (value) => {
detailNoteEditor.value = mergeDetailNoteVisibleTextWithTags(value, detailNoteEditor.value)
}
})
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
const detailNoteTags = computed(() =>
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
)
watch(
() => [request.value.claimId, detailNoteSource.value],
([, nextNote]) => {
detailNoteEditor.value = nextNote
},
{ immediate: true }
)
const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
const attachmentPreviewEntries = computed(() =>
expenseItems.value
.filter((item) => canPreviewAttachment(item))
.map((item, index) => ({
item,
itemId: item.id,
index,
name: resolveAttachmentDisplayName(item) || `${index + 1} 条附件`,
metadata: resolveAttachmentMeta(item)
}))
)
const currentAttachmentPreviewIndex = computed(() =>
attachmentPreviewEntries.value.findIndex((entry) => entry.itemId === attachmentPreviewItemId.value)
)
const currentAttachmentPreviewEntry = computed(() => {
const index = currentAttachmentPreviewIndex.value
return index >= 0 ? attachmentPreviewEntries.value[index] : null
})
const attachmentPreviewIndexLabel = computed(() => {
const currentIndex = currentAttachmentPreviewIndex.value
const total = attachmentPreviewEntries.value.length
return currentIndex >= 0 && total > 0 ? `${currentIndex + 1} / ${total}` : ''
})
const canNavigateAttachmentPreview = computed(() => attachmentPreviewEntries.value.length > 1)
const currentAttachmentPreviewInsight = computed(() => {
const entry = currentAttachmentPreviewEntry.value
if (!entry) {
return null
}
return buildAttachmentInsightViewModel(resolveAttachmentMeta(entry.item), entry.item)
})
const currentAttachmentPreviewRiskCards = computed(() => {
const entry = currentAttachmentPreviewEntry.value
if (!entry) {
return []
}
return buildAttachmentRiskCards({
expenseItems: [entry.item],
attachmentMetaByItemId: expenseAttachmentMeta
})
})
function applyLocalExpenseItemPatch(itemId, patch) {
expenseItems.value = rebuildExpenseItems(
expenseItems.value.map((item) => (item.id === itemId ? { ...item, ...patch } : item)),
request.value
)
}
function resolveAttachmentMeta(item) {
return expenseAttachmentMeta[item.id] || null
}
function resolveClaimRiskFlags() {
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
return Array.isArray(flags) ? flags : []
}
function resolveAttachmentDisplayName(item) {
const metadata = resolveAttachmentMeta(item)
return String(metadata?.file_name || item.attachmentHint || '').trim()
}
function hasStoredAttachmentReference(item) {
return String(item?.invoiceId || '').includes('/')
}
function resolveAttachmentPreviewTitle(item) {
const fileName = resolveAttachmentDisplayName(item)
return fileName ? `预览附件:${fileName}` : '预览附件'
}
function resolveAttachmentRecognition(item) {
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
}
function buildAttachmentRiskNotice(attachment) {
const analysis = attachment?.analysis
const severity = String(analysis?.severity || '').trim()
if (!analysis || severity === 'pass') {
return ''
}
const label =
String(analysis?.label || '').trim()
|| (severity === 'high' ? '高风险' : severity === 'medium' ? '中风险' : '低风险')
const summary = String(analysis?.summary || analysis?.headline || '').trim() || '附件存在待核对风险。'
return `${label}${summary}`
}
async function refreshExpenseAttachmentMeta(itemId) {
if (!request.value.claimId || !itemId) {
return null
}
const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, itemId)
expenseAttachmentMeta[itemId] = payload
return payload
}
function canPreviewAttachment(item) {
if (!item?.invoiceId) {
return false
}
const metadata = resolveAttachmentMeta(item)
if (metadata) {
return metadata.previewable !== false
}
return true
}
function revokeAttachmentPreviewUrl() {
if (attachmentPreviewUrl.value && attachmentPreviewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(attachmentPreviewUrl.value)
}
attachmentPreviewUrl.value = ''
}
function closeAttachmentPreview() {
attachmentPreviewOpen.value = false
attachmentPreviewLoading.value = false
attachmentPreviewError.value = ''
attachmentPreviewName.value = ''
attachmentPreviewMediaType.value = ''
attachmentPreviewItemId.value = ''
revokeAttachmentPreviewUrl()
}
async function syncExpenseAttachmentMeta() {
if (!request.value.claimId) {
return
}
const tasks = expenseItems.value
.filter((item) => item.invoiceId)
.map(async (item) => {
try {
const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, item.id)
expenseAttachmentMeta[item.id] = payload
} catch {
delete expenseAttachmentMeta[item.id]
}
})
Object.keys(expenseAttachmentMeta).forEach((itemId) => {
if (!expenseItems.value.some((item) => item.id === itemId && item.invoiceId)) {
delete expenseAttachmentMeta[itemId]
}
})
await Promise.allSettled(tasks)
}
function resolveExpenseIssues(item) {
return buildExpenseDraftIssues(item)
}
function resolveExpenseRiskState(item) {
if (uploadingExpenseId.value === item.id) {
return {
label: 'AI识别中',
tone: 'medium',
headline: 'AI提示正在分析附件内容',
summary: '附件已上传,系统正在识别票据内容与风险点,请稍候。',
points: [],
suggestion: ''
}
}
const metadata = resolveAttachmentMeta(item)
const analysis = metadata?.analysis
if (analysis) {
return {
label: analysis.label || '已上传',
tone: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low',
headline: analysis.headline || 'AI提示',
summary: analysis.summary || '',
points: Array.isArray(analysis.points) ? analysis.points : [],
suggestion: analysis.suggestion || ''
}
}
const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags())
if (claimRiskState) {
return claimRiskState
}
if (!item.invoiceId) {
return null
}
return {
label: '已上传',
tone: 'low',
headline: 'AI提示附件已上传',
summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。',
points: [],
suggestion: ''
}
}
function showExpenseRisk(item) {
return Boolean(resolveExpenseRiskState(item))
}
function isMajorExpenseRisk(item) {
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
}
function resolveExpenseRiskIndicatorTitle(item) {
const state = resolveExpenseRiskState(item)
const summary = String(state?.summary || state?.headline || '').trim()
return summary ? `重大风险警示:${summary}` : '重大风险警示'
}
const aiAdvice = computed(() => {
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))
)
const riskCards = [
...(hasActionableRiskCards ? [] : buildClaimSummaryRiskCards(request.value)),
...directRiskCards,
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
]
return buildAiAdviceViewModel({
completionItems,
riskCards
})
})
const showAiAdvicePanel = computed(() => isEditableRequest.value || aiAdvice.value.riskCards.length > 0)
const aiAdviceTitle = computed(() => (isEditableRequest.value ? 'AI建议' : 'AI提示'))
const aiAdviceHint = computed(() => (
isEditableRequest.value
? '按建议顺序补齐信息或处理风险后,再发起审批。'
: '展示系统已识别的风险点,便于审批和后续整改。'
))
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'))
function resetDetailNote() {
detailNoteEditor.value = detailNoteSource.value
}
async function saveDetailNote() {
if (!canEditDetailNote.value || savingDetailNote.value) {
return
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法保存附加说明。')
return
}
if (!detailNoteDirty.value) {
return
}
savingDetailNote.value = true
try {
await updateExpenseClaim(request.value.claimId, {
reason: detailNoteEditor.value.trim()
})
toast('附加说明已保存。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '附加说明保存失败,请稍后重试。')
} finally {
savingDetailNote.value = false
}
}
function openRiskOverrideDialog() {
const warnings = submitRiskWarnings.value
if (!warnings.length) {
return
}
riskOverrideIndex.value = 0
const activeIds = new Set(warnings.map((risk) => risk.id))
Object.keys(riskOverrideReasons).forEach((riskId) => {
if (!activeIds.has(riskId)) {
delete riskOverrideReasons[riskId]
}
})
warnings.forEach((risk) => {
if (typeof riskOverrideReasons[risk.id] !== 'string') {
riskOverrideReasons[risk.id] = ''
}
})
riskOverrideDialogOpen.value = true
}
function closeRiskOverrideDialog() {
if (riskOverrideBusy.value) {
return
}
riskOverrideDialogOpen.value = false
}
function 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
}
}
function populateExpenseEditor(item) {
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation =
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.invoiceId = item.invoiceId || ''
}
function startExpenseEdit(item) {
if (!isEditableRequest.value || actionBusy.value) {
return
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行不能手动编辑。')
return
}
populateExpenseEditor(item)
}
function validateExpenseEditor() {
if (expenseEditor.itemDate && !isValidIsoDate(expenseEditor.itemDate)) {
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
}
if (isPlaceholderValue(expenseEditor.itemType)) {
return '请选择费用项目。'
}
if (
!isPlaceholderValue(expenseEditor.itemReason)
&&
isRouteDescriptionExpenseType(expenseEditor.itemType)
&& !isValidRouteDescription(expenseEditor.itemReason)
) {
return '行程说明格式应为“起始地-目的地”,例如:广州南-北京南。'
}
const amountText = String(expenseEditor.itemAmount || '').trim()
if (amountText) {
const amount = Number(amountText)
if (!Number.isFinite(amount) || amount < 0) {
return '请输入不小于 0 的费用金额。'
}
}
return ''
}
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) {
try {
metadata = await refreshExpenseAttachmentMeta(item.id)
} catch (error) {
if (!hasStoredAttachmentReference(item)) {
throw new Error('当前附件只有文件名记录,原件尚未保存到单据中,请重新上传后预览。')
}
throw error
}
}
if (metadata?.previewable === false) {
throw new Error('当前附件暂不支持直接预览。')
}
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
attachmentPreviewMediaType.value =
String(metadata?.preview_kind || '').trim() === 'image'
? 'image/png'
: String(metadata?.media_type || '').trim()
const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
revokeAttachmentPreviewUrl()
attachmentPreviewUrl.value = URL.createObjectURL(blob)
attachmentPreviewMediaType.value = blob.type || attachmentPreviewMediaType.value
} catch (error) {
attachmentPreviewError.value = error?.message || '附件预览失败,请稍后重试。'
} finally {
attachmentPreviewLoading.value = false
}
}
async function openAttachmentPreview(item) {
if (!request.value.claimId || !canPreviewAttachment(item)) {
return
}
closeAttachmentPreview()
attachmentPreviewOpen.value = true
await loadAttachmentPreview(item)
}
async function goToAttachmentPreview(offset) {
if (!canNavigateAttachmentPreview.value || attachmentPreviewLoading.value) {
return
}
const entries = attachmentPreviewEntries.value
const currentIndex = currentAttachmentPreviewIndex.value
const nextIndex = (currentIndex + offset + entries.length) % entries.length
const nextEntry = entries[nextIndex]
if (nextEntry?.item) {
await loadAttachmentPreview(nextEntry.item)
}
}
function goToPreviousAttachmentPreview() {
void goToAttachmentPreview(-1)
}
function goToNextAttachmentPreview() {
void goToAttachmentPreview(1)
}
async function uploadExpenseFile(item, file) {
if (!item || !file) {
return
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行无需上传附件。')
return
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return
}
uploadingExpenseId.value = item.id
try {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
expenseAttachmentMeta[item.id] = payload?.attachment || null
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
}
if (recognizedItemDate) {
itemPatch.itemDate = recognizedItemDate
}
if (recognizedItemType) {
itemPatch.itemType = recognizedItemType
}
if (recognizedItemReason) {
itemPatch.itemReason = recognizedItemReason
}
if (recognizedItemLocation) {
itemPatch.itemLocation = recognizedItemLocation
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount)
}
applyLocalExpenseItemPatch(item.id, {
...itemPatch
})
populateExpenseEditor({ ...item, ...itemPatch })
emit('request-updated', { claimId: request.value.claimId })
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
} 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()
const amountText = String(expenseEditor.itemAmount || '').trim()
const nextAmount = amountText ? Number(amountText) : 0
const itemPayload = {
item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(),
item_location: preservedLocation,
item_amount: nextAmount,
invoice_id: nextInvoiceId
}
if (expenseEditor.itemDate) {
itemPayload.item_date = expenseEditor.itemDate
}
await updateExpenseClaimItem(request.value.claimId, item.id, itemPayload)
applyLocalExpenseItemPatch(item.id, {
itemDate: expenseEditor.itemDate || item.itemDate,
itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(),
itemLocation: preservedLocation,
itemAmount: nextAmount,
invoiceId: nextInvoiceId
})
let riskNotice = ''
if (nextInvoiceId) {
try {
const attachment = await refreshExpenseAttachmentMeta(item.id)
riskNotice = buildAttachmentRiskNotice(attachment)
} catch {
delete expenseAttachmentMeta[item.id]
}
} else {
delete expenseAttachmentMeta[item.id]
}
editingExpenseId.value = ''
toast(riskNotice || '费用明细已保存。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '费用明细保存失败,请稍后重试。')
} finally {
savingExpenseId.value = ''
}
}
function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
return
}
if (!canSubmit.value) {
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
return
}
if (draftBlockingIssues.value.length) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
openRiskOverrideDialog()
return
}
submitConfirmDialogOpen.value = true
}
function closeSubmitConfirmDialog() {
if (submitBusy.value) {
return
}
submitConfirmDialogOpen.value = false
}
async function confirmSubmitRequest() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
submitConfirmDialogOpen.value = false
return
}
if (!canSubmit.value) {
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
submitConfirmDialogOpen.value = false
return
}
if (draftBlockingIssues.value.length) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
submitConfirmDialogOpen.value = false
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
submitConfirmDialogOpen.value = false
openRiskOverrideDialog()
return
}
submitBusy.value = true
try {
const payload = await submitExpenseClaim(request.value.claimId)
const claimStatus = String(payload?.status || '').trim().toLowerCase()
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
if (claimStatus === 'submitted') {
toast(
isApplicationDocument.value
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${request.value.id} 已完成 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(
isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。'
: '当前单据已进入流程,只有高级财务人员可以删除。'
)
return
}
deleteDialogOpen.value = true
}
function closeDeleteDialog() {
if (deleteBusy.value) {
return
}
deleteDialogOpen.value = false
}
async function confirmDeleteRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法删除。')
return
}
deleteBusy.value = true
try {
const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
emit('request-deleted', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '删除单据失败,请稍后重试。')
} finally {
deleteBusy.value = false
}
}
function handleReturnRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
if (!canReturnRequest.value) {
toast('当前状态不支持退回。')
return
}
returnDialogOpen.value = true
}
function closeReturnDialog() {
if (returnBusy.value) {
return
}
returnDialogOpen.value = false
}
async function confirmReturnRequest(payload) {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
returnBusy.value = true
try {
await returnExpenseClaim(request.value.claimId, payload)
returnDialogOpen.value = false
toast(`${request.value.id} 已退回待提交。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '退回单据失败,请稍后重试。')
} finally {
returnBusy.value = false
}
}
function handleApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
return
}
if (!canApproveRequest.value) {
toast('当前节点暂不支持审批通过。')
return
}
approveConfirmDialogOpen.value = true
}
function closeApproveConfirmDialog() {
if (approveBusy.value) {
return
}
approveConfirmDialogOpen.value = false
}
async function confirmApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
approveConfirmDialogOpen.value = false
return
}
if (!canApproveRequest.value) {
toast('当前节点暂不支持审批通过。')
approveConfirmDialogOpen.value = false
return
}
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('请先填写领导意见,填写后才能确认审核。')
return
}
approveBusy.value = true
try {
const responsePayload = await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim()
})
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
approveConfirmDialogOpen.value = false
leaderOpinion.value = ''
toast(
isApplicationDocument.value && generatedDraftClaimNo
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
: approvalSuccessToast.value
)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
} finally {
approveBusy.value = false
}
}
function openAiEntry() {
if (!canOpenAiEntry.value) {
return
}
const claimId = String(request.value?.claimId || '').trim()
emit('openAssistant', {
source: 'detail',
prompt: '',
request: request.value,
restoreLatestConversation: false,
scope: claimId
? {
type: 'claim',
claimId
}
: null
})
}
onBeforeUnmount(() => {
closeAttachmentPreview()
})
return {
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalConfirmSummaryLabel, approvalNextStage, approvalOpinionHint,
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
applicationDetailFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview,
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,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showAiAdvicePanel, showApplicationLeaderOpinion,
showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}
}
}