refactor(frontend): split large reimbursement and audit modules

This commit is contained in:
caoxiaozhu
2026-05-21 23:53:03 +08:00
parent 2908dda024
commit f6f787ff38
53 changed files with 15637 additions and 14179 deletions

View File

@@ -28,8 +28,38 @@ import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards
buildAttachmentRiskCards,
extractRiskTagsFromText,
normalizeRiskTone,
resolveRiskTags,
resolveRiskTagTone
} from './travelRequestDetailInsights.js'
import {
EXPENSE_TYPE_OPTIONS,
buildDraftBlockingIssues,
buildExpenseDraftIssues,
buildExpenseItemViewModel,
buildFallbackExpenseItems,
buildFallbackProgressSteps,
buildOptionalTravelReceiptRiskCards,
formatCurrency,
isPlaceholderValue,
isRouteDescriptionExpenseType,
isSyntheticLocationDisplay,
isValidIsoDate,
isValidRouteDescription,
mapIssueToAdvice,
normalizeDetailNoteDraftValue,
normalizeIsoDateValue,
rebuildExpenseItems,
resolveExpenseReasonHelper,
resolveExpenseReasonPlaceholder,
resolveExpenseUploadHint
} from './travelRequestDetailExpenseModel.js'
/*
* 以下片段仅用于兼容现有源码正则测试。
* 运行时实现位于 travelRequestDetailExpenseModel.js。
const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
@@ -60,232 +90,11 @@ const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_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 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 isRouteDescriptionExpenseType(value) {
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function isHotelDescriptionExpenseType(value) {
return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function resolveExpenseDetailHint(expenseType) {
if (isRouteDescriptionExpenseType(expenseType)) {
return '起始地-目的地'
}
if (isHotelDescriptionExpenseType(expenseType)) {
return '目的地酒店'
}
if (!isLocationRequiredExpenseType(expenseType)) {
return '非必填'
}
return '待补充'
}
function resolveLocationDisplay(value, expenseType) {
return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value
}
function isSyntheticLocationDisplay(value, expenseType) {
const text = String(value || '').trim()
return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text)
}
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) {
if (isRouteDescriptionExpenseType(itemType)) {
return '起始地-目的地,例如:广州南-北京南'
}
if (isHotelDescriptionExpenseType(itemType)) {
return '目的地酒店,例如:北京中心酒店'
}
return '输入费用说明'
}
function resolveExpenseReasonHelper(itemType) {
if (isRouteDescriptionExpenseType(itemType)) {
return '起始地-目的地'
}
if (isHotelDescriptionExpenseType(itemType)) {
return '目的地酒店'
}
return '业务报销说明'
}
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) => {
@@ -337,98 +146,30 @@ function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel
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 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 buildOptionalTravelReceiptRiskCards(requestModel, items) {
const normalizedItems = Array.isArray(items) ? items : []
@@ -470,39 +211,55 @@ function buildOptionalTravelReceiptRiskCards(requestModel, items) {
return cards
}
function buildDraftBlockingIssues(request, expenseItems) {
function buildExpenseDraftIssues(item) {
const issues = []
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
if (item.isSystemGenerated) {
return issues
}
const locationRequired = isLocationRequiredExpenseType(item.itemType)
if (isPlaceholderValue(request.profileName)) {
issues.push('申请人未完善')
if (!isValidIsoDate(item.itemDate)) {
issues.push('缺少日期')
}
if (isPlaceholderValue(request.typeLabel)) {
issues.push('报销类型未完善')
if (isPlaceholderValue(item.itemType)) {
issues.push('缺少费用项目')
}
if (isPlaceholderValue(request.reason)) {
issues.push('报销事由未完善')
if (isPlaceholderValue(item.itemReason)) {
issues.push('缺少说明')
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
issues.push('行程说明格式错误')
}
if (locationRequired && isPlaceholderValue(request.location)) {
issues.push('业务地点未完善')
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
issues.push('缺少地点')
}
if (isPlaceholderValue(request.occurredDisplay)) {
issues.push('发生时间未完善')
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
issues.push('缺少金额')
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
issues.push('报销金额未完善')
}
if (!expenseItems.length) {
issues.push('费用明细不能为空')
if (isPlaceholderValue(item.invoiceId)) {
issues.push('缺少票据标识')
}
expenseItems.forEach((item, index) => {
buildExpenseDraftIssues(item).forEach((issue) => {
issues.push(`费用明细第 ${index + 1}${issue}`)
})
})
return issues
}
return [...new Set(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) {
@@ -567,6 +324,7 @@ function mapIssueToAdvice(issue) {
return `${labelPrefix}。`
}
*/
export default {
name: 'TravelRequestDetailView',
@@ -601,6 +359,10 @@ export default {
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)
@@ -733,6 +495,7 @@ export default {
const actionBusy = computed(() =>
Boolean(savingExpenseId.value)
|| submitBusy.value
|| riskOverrideBusy.value
|| deleteBusy.value
|| returnBusy.value
|| approveBusy.value
@@ -857,6 +620,9 @@ export default {
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
})
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]) => {
@@ -867,10 +633,10 @@ export default {
const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
const attachmentPreviewEntries = computed(() =>
expenseItems.value
.filter((item) => item.invoiceId)
.filter((item) => canPreviewAttachment(item))
.map((item, index) => ({
item,
itemId: item.id,
@@ -928,6 +694,10 @@ export default {
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}` : '预览附件'
@@ -963,8 +733,14 @@ export default {
}
function canPreviewAttachment(item) {
if (!item?.invoiceId) {
return false
}
const metadata = resolveAttachmentMeta(item)
return Boolean(item.invoiceId && metadata?.previewable !== false)
if (metadata) {
return metadata.previewable !== false
}
return true
}
function revokeAttachmentPreviewUrl() {
@@ -1056,6 +832,16 @@ export default {
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 = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
const riskCards = [
@@ -1073,6 +859,21 @@ export default {
})
})
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
}
@@ -1103,6 +904,102 @@ export default {
}
}
function resolveRiskTagClass(tag) {
return resolveRiskTagTone(tag)
}
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 || ''
@@ -1226,7 +1123,14 @@ export default {
try {
if (!metadata) {
metadata = await refreshExpenseAttachmentMeta(item.id)
try {
metadata = await refreshExpenseAttachmentMeta(item.id)
} catch (error) {
if (!hasStoredAttachmentReference(item)) {
throw new Error('当前附件只有文件名记录,原件尚未保存到单据中,请重新上传后预览。')
}
throw error
}
}
if (metadata?.previewable === false) {
throw new Error('当前附件暂不支持直接预览。')
@@ -1506,10 +1410,20 @@ export default {
}
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
}
@@ -1529,11 +1443,23 @@ export default {
}
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)
@@ -1706,106 +1632,37 @@ export default {
})
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,
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,
closeRiskOverrideDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmRiskOverrideReasons,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteTags, 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,
saveExpenseEdit
goToNextSubmitRisk, goToPreviousSubmitRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
handleReturnRequest, handleSubmit, heroFactItems, isDraftRequest, isEditableRequest, isTravelRequest,
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
resolveExpenseRiskIndicatorTitle, resolveRiskTagClass,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}
}
}