feat(web): 优化差旅详情、风险建议卡片与文档中心交互

- 拆分阶段风险建议卡片样式到独立文件
- 完善差旅申请审批对话框与详情视图交互
- 调整文档中心列表共享样式与状态筛选
- 同步应用外壳、视图初始化与系统状态 composables
This commit is contained in:
caoxiaozhu
2026-06-17 14:39:12 +08:00
parent a3e5295915
commit 0fac8b615f
19 changed files with 1415 additions and 558 deletions

View File

@@ -31,7 +31,6 @@ import {
import {
canApproveBudgetExpenseApplications,
canApproveLeaderExpenseClaims,
canDeleteArchivedExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isCurrentDirectManagerForRequest,
@@ -97,7 +96,8 @@ import {
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
filterSubmitterResolvedRiskCards as filterSubmitterResolvedRiskCardsModel,
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel,
resolveExpenseItemsForRiskCard as resolveExpenseItemsForRiskCardModel
} from './travelRequestDetailStandardAdjustment.js'
import {
buildEmployeeProfileAdviceItems,
@@ -626,6 +626,7 @@ export default {
const returnDialogOpen = ref(false)
const approveBusy = ref(false)
const approveConfirmDialogOpen = ref(false)
const approvalRiskConfirmed = ref(false)
const leaderOpinion = ref('')
const expenseUploadInput = ref(null)
const smartEntryUploadInput = ref(null)
@@ -712,18 +713,7 @@ export default {
))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => {
if (isApplicationDocument.value) {
return isPlatformAdminUser(currentUser.value) || (isEditableRequest.value && isCurrentApplicant.value)
}
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
}
if (canManageCurrentClaim.value) {
return true
}
return isEditableRequest.value && isCurrentApplicant.value
})
const canDeleteRequest = computed(() => isPlatformAdminUser(currentUser.value))
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
@@ -828,12 +818,30 @@ export default {
isApplicationDocument.value
&& hasLeaderApprovalEvents.value
))
const requiresApprovalOpinion = computed(() => false)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '附加意见'))
const budgetApprovalOpinionRequired = computed(() => (
isBudgetApprovalStage.value
&& hasBudgetApprovalWarning(request.value)
))
const requiresApprovalOpinion = computed(() => budgetApprovalOpinionRequired.value)
const approvalOpinionTitle = computed(() => {
if (isFinanceApprovalStage.value) {
return '财务意见'
}
if (isBudgetApprovalStage.value) {
return '预算审批意见'
}
return '附加意见'
})
const approvalOpinionPlaceholder = computed(() => {
if (isFinanceApprovalStage.value) {
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
}
if (budgetApprovalOpinionRequired.value) {
return '预算已超过警戒值,请写明预算审批意见、通过依据或后续控制要求。'
}
if (isBudgetApprovalStage.value) {
return '可选填预算审批补充说明;未超过预算警戒值时不填写默认为同意。'
}
if (isApplicationDocument.value) {
return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。'
}
@@ -844,10 +852,35 @@ export default {
return '审核通过后将进入待付款。'
}
if (isBudgetApprovalStage.value) {
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。'
return budgetApprovalOpinionRequired.value
? '预算已超过警戒值,需填写预算审批意见后才能通过。'
: '未超过预算警戒值时不填写意见将默认同意,确认后按流程继续流转。'
}
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
})
const approvalRiskConfirmItems = computed(() =>
aiAdvice.value.riskCards
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.slice(0, 4)
.map((card, index) => ({
id: String(card?.id || `approval-risk-${index + 1}`),
tone: normalizeRiskTone(card?.tone),
label: normalizeRiskTone(card?.tone) === 'high' ? '高风险' : '中风险',
title: String(card?.title || card?.label || '风险提示').trim(),
description: String(
card?.relatedExplanationSummary
|| card?.risk
|| card?.summary
|| card?.suggestion
|| '请核对该风险点对应的说明和佐证材料。'
).trim()
}))
)
const approvalRiskConfirmRequired = computed(() =>
canApproveRequest.value
&& canViewApprovalRiskAdvice.value
&& approvalRiskConfirmItems.value.length > 0
)
const approvalConfirmBadge = computed(() => {
if (isFinanceApprovalStage.value) {
return '财务终审'
@@ -1183,6 +1216,10 @@ export default {
return resolveExpenseItemForRiskCardModel(card, expenseItems.value)
}
function resolveExpenseItemsForRiskCard(card) {
return resolveExpenseItemsForRiskCardModel(card, expenseItems.value)
}
function filterSubmitterResolvedRiskCards(cards, businessStage) {
const viewerContext = riskViewerContext.value || {}
return filterSubmitterResolvedRiskCardsModel({
@@ -1205,9 +1242,16 @@ export default {
return isRiskCardMissingExpenseNoteModel(card, expenseItems.value)
}
function resolveRiskWarningNotes(card) {
const notes = resolveExpenseItemsForRiskCard(card)
.map((item) => String(item?.itemNote || '').trim())
.filter(Boolean)
return [...new Set(notes)]
}
async function buildStandardAdjustmentPayload() {
return buildStandardAdjustmentPayloadModel({
warnings: submitRiskWarnings.value,
warnings: submitRiskCards.value,
expenseItems: expenseItems.value,
request: request.value,
calculateTravelReimbursement
@@ -1733,24 +1777,72 @@ export default {
}))
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
isApplicationDocument: isApplicationDocument.value,
hasHighRiskWarnings: submitRiskWarnings.value.length > 0
hasHighRiskWarnings: aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
}))
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
const submitRiskWarnings = computed(() =>
const submitRiskCards = computed(() =>
aiAdvice.value.riskCards
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.filter((card) => isRiskCardMissingExpenseNote(card))
.map((card, index) => ({
...card,
id: String(card.id || `submit-risk-${index}`),
tags: resolveRiskTags(card)
}))
)
const currentSubmitRiskWarning = computed(() => submitRiskWarnings.value[riskOverrideIndex.value] || null)
const riskOverrideIndexLabel = computed(() =>
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
const submitConfirmSecondaryText = computed(() => (
!isApplicationDocument.value && submitRiskCards.value.length
? '按职级标准报销'
: ''
))
const submitRiskWarnings = computed(() =>
submitRiskCards.value.filter((card) => isRiskCardMissingExpenseNote(card))
)
const submitExplainedRiskWarnings = computed(() =>
submitRiskCards.value.filter((card) => !isRiskCardMissingExpenseNote(card))
)
const hasMissingSubmitRiskWarnings = computed(() => submitRiskWarnings.value.length > 0)
const submitRiskReviewWarnings = computed(() =>
hasMissingSubmitRiskWarnings.value ? submitRiskWarnings.value : submitExplainedRiskWarnings.value
)
const currentSubmitRiskWarning = computed(() => submitRiskReviewWarnings.value[riskOverrideIndex.value] || null)
const currentSubmitRiskWarningNotes = computed(() =>
currentSubmitRiskWarning.value ? resolveRiskWarningNotes(currentSubmitRiskWarning.value) : []
)
const riskOverrideIndexLabel = computed(() =>
submitRiskReviewWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskReviewWarnings.value.length}` : ''
)
const riskOverrideBadgeTone = computed(() => hasMissingSubmitRiskWarnings.value ? 'danger' : 'warning')
const riskOverrideDialogTitle = computed(() => (
hasMissingSubmitRiskWarnings.value
? `当前存在 ${submitRiskWarnings.value.length} 条需说明的风险`
: `请确认 ${submitExplainedRiskWarnings.value.length} 条风险及异常说明`
))
const riskOverrideDialogDescription = computed(() => (
hasMissingSubmitRiskWarnings.value
? '请回到费用明细的异常说明列补充原因后再提交;如果不补充说明,可选择按职级最高可报销金额重新计算。'
: '请核对风险点与已填写的异常说明,确认后进入提交确认。'
))
const riskOverrideCancelText = computed(() => (
hasMissingSubmitRiskWarnings.value ? '返回整改' : '返回核对'
))
const riskOverrideConfirmText = computed(() =>
hasMissingSubmitRiskWarnings.value ? '按职级标准重算' : '确认说明'
)
const riskOverrideConfirmTone = computed(() => hasMissingSubmitRiskWarnings.value ? 'danger' : 'primary')
const riskOverrideConfirmIcon = computed(() =>
hasMissingSubmitRiskWarnings.value ? 'mdi mdi-calculator-variant-outline' : 'mdi mdi-check-circle-outline'
)
const riskOverrideGuidanceTitle = computed(() => (
hasMissingSubmitRiskWarnings.value
? '请在费用明细的“异常说明”列补充原因后再提交。'
: '已填写异常说明,请确认说明会随单据进入审批。'
))
const riskOverrideGuidanceText = computed(() => (
hasMissingSubmitRiskWarnings.value
? '如果不补充说明,可直接选择按职级标准重算,超出标准的部分由员工自担。'
: '确认后系统会继续进入提交确认,领导和财务可看到这些风险及对应说明。'
))
function resetDetailNote() {
detailNoteEditor.value = detailNoteSource.value
@@ -1783,7 +1875,7 @@ export default {
}
function openRiskOverrideDialog() {
const warnings = submitRiskWarnings.value
const warnings = submitRiskReviewWarnings.value
if (!warnings.length) {
return
}
@@ -1799,18 +1891,34 @@ export default {
}
function goToPreviousSubmitRisk() {
if (!submitRiskWarnings.value.length) {
if (!submitRiskReviewWarnings.value.length) {
return
}
riskOverrideIndex.value =
(riskOverrideIndex.value - 1 + submitRiskWarnings.value.length) % submitRiskWarnings.value.length
(riskOverrideIndex.value - 1 + submitRiskReviewWarnings.value.length) % submitRiskReviewWarnings.value.length
}
function goToNextSubmitRisk() {
if (!submitRiskWarnings.value.length) {
if (!submitRiskReviewWarnings.value.length) {
return
}
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskReviewWarnings.value.length
}
function confirmRiskExplanation() {
if (riskOverrideBusy.value || submitBusy.value) {
return
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
}
function confirmRiskOverrideDialog() {
if (hasMissingSubmitRiskWarnings.value) {
confirmStandardAdjustment()
return
}
confirmRiskExplanation()
}
function confirmStandardAdjustment() {
@@ -1824,6 +1932,7 @@ export default {
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = false
standardAdjustmentBusy.value = true
const taskSeq = ++standardAdjustmentTaskSeq
toast('\u6b63\u5728\u540e\u53f0\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u65b0\u6d4b\u7b97\u8d39\u7528\u3002')
@@ -2308,7 +2417,7 @@ export default {
return
}
if (submitRiskWarnings.value.length) {
if (submitRiskReviewWarnings.value.length) {
openRiskOverrideDialog()
return
}
@@ -2490,6 +2599,7 @@ export default {
return
}
approvalRiskConfirmed.value = !approvalRiskConfirmRequired.value
approveConfirmDialogOpen.value = true
}
@@ -2522,6 +2632,16 @@ export default {
return
}
if (approvalRiskConfirmRequired.value && !approvalRiskConfirmed.value) {
toast('请先确认已核对风险说明和佐证材料,再继续审批。')
return
}
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('预算已超过警戒值,请填写预算审批意见后再通过。')
return
}
approveBusy.value = true
try {
const responsePayload = await approveExpenseClaim(request.value.claimId, {
@@ -2529,13 +2649,17 @@ export default {
})
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
approveConfirmDialogOpen.value = false
approvalRiskConfirmed.value = false
leaderOpinion.value = ''
toast(
isApplicationDocument.value && generatedDraftClaimNo
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
: approvalSuccessToast.value
)
emit('request-updated', { claimId: request.value.claimId })
emit('request-updated', {
claimId: request.value.claimId,
claim: responsePayload
})
emit('backToRequests')
} catch (error) {
toast(resolveApproveErrorMessage(error))
@@ -2636,6 +2760,7 @@ export default {
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalOpinionHint,
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
approvalRiskConfirmed, approvalRiskConfirmItems, approvalRiskConfirmRequired,
applicationDetailFactItems, relatedApplicationFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview,
@@ -2643,10 +2768,10 @@ export default {
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmStandardAdjustment, confirmSmartEntryUpload,
confirmPayRequest, confirmRiskExplanation, confirmRiskOverrideDialog, confirmStandardAdjustment, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning,
currentSubmitRiskWarning, currentSubmitRiskWarningNotes,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, expenseEditor,
@@ -2668,7 +2793,10 @@ export default {
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBadgeTone, riskOverrideBusy,
riskOverrideCancelText, riskOverrideConfirmIcon, riskOverrideConfirmText, riskOverrideConfirmTone,
riskOverrideDialogDescription, riskOverrideDialogOpen, riskOverrideDialogTitle,
riskOverrideGuidanceText, riskOverrideGuidanceTitle, riskOverrideIndexLabel,
requiresApprovalOpinion,
saveDetailNote, savingDetailNote, savingExpenseId,
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
@@ -2678,8 +2806,56 @@ export default {
showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmSecondaryText, submitConfirmText,
submitExplainedRiskWarnings, submitRiskReviewWarnings, submitRiskWarnings, hasMissingSubmitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}
}
}
function hasBudgetApprovalWarning(request = {}) {
const flags = Array.isArray(request?.riskFlags)
? request.riskFlags
: Array.isArray(request?.risk_flags_json)
? request.risk_flags_json
: []
return flags.some((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
const routeDecision = flag.route_decision || flag.routeDecision || {}
const directBudgetResult = flag.budget_result || flag.budgetResult
const routeBudgetResult = routeDecision?.budget_result || routeDecision?.budgetResult
const budgetResult = routeBudgetResult || directBudgetResult
if (!budgetResult || typeof budgetResult !== 'object') {
return false
}
return budgetResultExceedsWarning(budgetResult)
})
}
function budgetResultExceedsWarning(budgetResult = {}) {
const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
const context = budgetResult.budget_context && typeof budgetResult.budget_context === 'object'
? budgetResult.budget_context
: budgetResult.budgetContext && typeof budgetResult.budgetContext === 'object'
? budgetResult.budgetContext
: {}
const overBudgetAmount = parseBudgetNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
if (overBudgetAmount > 0) {
return true
}
const afterUsageRate = parseBudgetNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
const claimAmountRatio = parseBudgetNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
const warningThreshold = parseBudgetNumber(context.warning_threshold ?? context.warningThreshold, 80)
return Math.max(afterUsageRate, claimAmountRatio) >= warningThreshold
}
function parseBudgetNumber(value, fallback = 0) {
const number = Number(value)
return Number.isFinite(number) ? number : fallback
}