feat: 优化差旅报销预审流程与个人工作台 UI 体系

- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 14:01:51 +08:00
parent 92444e7eae
commit ca691f3ee0
107 changed files with 5663 additions and 1542 deletions

View File

@@ -8,7 +8,6 @@ import TravelRequestApprovalDialog from '../../components/travel/TravelRequestAp
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
import RiskObservationEvidenceCard from '../../components/travel/RiskObservationEvidenceCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import {
approveExpenseClaim,
@@ -16,9 +15,9 @@ import {
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
deleteExpenseClaim,
fetchEmployeeLatestProfile,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
preReviewExpenseClaim,
returnExpenseClaim,
submitExpenseClaim,
uploadExpenseClaimItemAttachment,
@@ -33,7 +32,8 @@ import {
canReturnExpenseClaims,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
isFinanceUser
isFinanceUser,
isPlatformAdminUser
} from '../../utils/accessControl.js'
import {
buildRiskViewerContext,
@@ -67,7 +67,6 @@ import {
buildExpenseItemViewModel,
buildFallbackExpenseItems,
buildFallbackProgressSteps,
buildOptionalTravelReceiptRiskCards,
formatCurrency,
isPlaceholderValue,
isApplicationDocumentRequest,
@@ -84,16 +83,15 @@ import {
resolveExpenseUploadHint
} from './travelRequestDetailExpenseModel.js'
import {
buildAiPreReviewSnapshot,
findLatestAiPreReviewEvent,
isAiPreReviewFlag,
isAiPreReviewPassed,
resolveAiPreReviewToast,
resolveSubmitActionIcon,
resolveSubmitActionLabel,
resolveSubmitConfirmDescription,
resolveSubmitConfirmText
} from './travelRequestDetailPreReviewModel.js'
} from './travelRequestDetailSubmitModel.js'
import {
buildEmployeeProfileAdviceItems,
buildTravelReceiptMaterialPrompts
} from './travelRequestDetailAdviceModel.js'
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
/*
@@ -229,50 +227,6 @@ function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelM
|| 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) {
@@ -394,7 +348,6 @@ export default {
ConfirmDialog,
EnterpriseSelect,
StageRiskAdviceCard,
RiskObservationEvidenceCard,
TravelRequestApprovalDialog,
TravelRequestBudgetAnalysis,
TravelRequestDeleteDialog,
@@ -426,8 +379,11 @@ export default {
const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('')
const submitBusy = ref(false)
const aiPreReviewSnapshot = ref(null)
const riskFlagPreviewSnapshot = ref(null)
const employeeRiskProfile = ref(null)
const employeeRiskProfileLoading = ref(false)
const employeeRiskProfileError = ref('')
let employeeRiskProfileLoadSeq = 0
const submitConfirmDialogOpen = ref(false)
const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false)
@@ -507,6 +463,9 @@ export default {
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => {
if (isApplicationDocument.value) {
return isPlatformAdminUser(currentUser.value)
}
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
}
@@ -612,6 +571,7 @@ export default {
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
const hasSingleLeaderApprovalEvent = computed(() => leaderApprovalEvents.value.length === 1)
const leaderApprovalReadonlyMeta = computed(() => {
const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : []
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
@@ -682,7 +642,12 @@ export default {
: `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程。`
: `${request.value.id} 已审批通过,流转至财务审批。`
})
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteActionLabel = computed(() => {
if (isApplicationDocument.value) {
return '删除申请'
}
return isDraftRequest.value ? '删除草稿' : '删除单据'
})
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() =>
isDraftRequest.value
@@ -726,7 +691,6 @@ export default {
Object.keys(expenseAttachmentMeta).forEach((key) => {
delete expenseAttachmentMeta[key]
})
aiPreReviewSnapshot.value = null
closeAttachmentPreview()
}
pendingUploadExpenseId.value = ''
@@ -923,15 +887,6 @@ export default {
) {
requestFlags = previewSnapshot.riskFlags
}
const snapshot = aiPreReviewSnapshot.value
if (
snapshot
&& snapshot.claimId === request.value?.claimId
&& Array.isArray(snapshot.riskFlags)
&& !requestFlags.some(isAiPreReviewFlag)
) {
return snapshot.riskFlags
}
return requestFlags
}
@@ -1093,10 +1048,6 @@ export default {
return summary ? `重大风险警示:${summary}` : '重大风险警示'
}
function applyAiPreReviewPayload(payload) {
aiPreReviewSnapshot.value = buildAiPreReviewSnapshot(payload, request.value.claimId)
}
function applyClaimRiskFlagsPayload(payload) {
const flags = Array.isArray(payload?.claim_risk_flags)
? payload.claim_risk_flags
@@ -1112,11 +1063,69 @@ export default {
}
}
const requiresAiPreReview = computed(() => isEditableRequest.value && !isApplicationDocument.value)
const aiPreReviewEvent = computed(() => findLatestAiPreReviewEvent(resolveClaimRiskFlags()))
const hasAiPreReviewResult = computed(() => !requiresAiPreReview.value || Boolean(aiPreReviewEvent.value))
const aiPreReviewPassed = computed(() =>
isAiPreReviewPassed(aiPreReviewEvent.value, requiresAiPreReview.value)
function resolveProfileLookupId() {
return String(
request.value?.profileEmployeeId
|| request.value?.employeeId
|| request.value?.employee_id
|| request.value?.profileName
|| ''
).trim()
}
function resolveProfileExpenseScope() {
const typeCode = String(request.value?.typeCode || '').trim()
return typeCode && !typeCode.endsWith('_application') ? typeCode : 'overall'
}
async function loadEmployeeRiskProfile() {
const employeeId = resolveProfileLookupId()
if (!employeeId || isApplicationDocument.value) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = ''
employeeRiskProfileLoading.value = false
return
}
const sequence = ++employeeRiskProfileLoadSeq
employeeRiskProfileLoading.value = true
employeeRiskProfileError.value = ''
try {
const payload = await fetchEmployeeLatestProfile(employeeId, {
scene: 'approval',
claim_id: request.value?.claimId || '',
window_days: 90,
expense_type_scope: resolveProfileExpenseScope()
})
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = payload || null
}
} catch (error) {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = error?.message || '用户画像读取失败'
}
} finally {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfileLoading.value = false
}
}
}
watch(
() => [
request.value?.claimId,
request.value?.profileEmployeeId,
request.value?.employeeId,
request.value?.employee_id,
request.value?.profileName,
request.value?.typeCode,
isApplicationDocument.value
].join('|'),
() => {
void loadEmployeeRiskProfile()
},
{ immediate: true }
)
const aiAdvice = computed(() => {
@@ -1143,19 +1152,22 @@ export default {
}),
currentBusinessStage
)
const optionalRiskCards = filterRiskCardsByBusinessStage(
buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value),
currentBusinessStage
)
const materialPrompts = currentBusinessStage === 'reimbursement'
? buildTravelReceiptMaterialPrompts(request.value, expenseItems.value)
: []
const profileAdviceItems = currentBusinessStage === 'reimbursement'
? buildEmployeeProfileAdviceItems(employeeRiskProfile.value)
: []
const scopedRiskCards = [
...(hasActionableRiskCards ? [] : summaryRiskCards),
...directRiskCards,
...optionalRiskCards
...directRiskCards
]
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
return buildAiAdviceViewModel({
completionItems,
materialPrompts,
profileAdviceItems,
riskCards
})
})
@@ -1164,12 +1176,17 @@ export default {
aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
)
const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0)
const showCompactSafeAdvice = computed(() =>
isEditableRequest.value
&& !isApplicationDocument.value
&& !draftBlockingIssues.value.length
)
const showAiAdvicePanel = computed(() => (
(
isEditableRequest.value
&& (
(requiresAiPreReview.value && hasAiPreReviewResult.value)
|| hasAdviceSections.value
hasAdviceSections.value
|| showCompactSafeAdvice.value
)
)
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
@@ -1188,24 +1205,22 @@ export default {
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value
? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
: isEditableRequest.value
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : 'AI预审已完成请按风险提示补充原因或进入下一步。')
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。')
: '展示系统已识别的风险点,便于审批和后续整改。'
))
const submitActionLabel = computed(() => {
return resolveSubmitActionLabel({
isApplicationDocument: isApplicationDocument.value,
hasAiPreReviewResult: hasAiPreReviewResult.value,
submitBusy: submitBusy.value
})
})
const submitActionIcon = computed(() => resolveSubmitActionIcon({
isApplicationDocument: isApplicationDocument.value,
hasAiPreReviewResult: hasAiPreReviewResult.value
isApplicationDocument: isApplicationDocument.value
}))
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
isApplicationDocument: isApplicationDocument.value,
aiPreReviewPassed: aiPreReviewPassed.value
hasHighRiskWarnings: submitRiskWarnings.value.length > 0
}))
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
@@ -1751,21 +1766,6 @@ export default {
}
}
async function runAiPreReview() {
submitBusy.value = true
try {
const payload = await preReviewExpenseClaim(request.value.claimId)
applyAiPreReviewPayload(payload)
const event = findLatestAiPreReviewEvent(payload?.risk_flags_json || [])
toast(resolveAiPreReviewToast(event))
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || 'AI预审失败请稍后重试。')
} finally {
submitBusy.value = false
}
}
async function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
@@ -1782,11 +1782,6 @@ export default {
return
}
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
await runAiPreReview()
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
openRiskOverrideDialog()
return
@@ -1822,12 +1817,6 @@ export default {
return
}
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
submitConfirmDialogOpen.value = false
await runAiPreReview()
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
submitConfirmDialogOpen.value = false
openRiskOverrideDialog()
@@ -1843,10 +1832,10 @@ export default {
toast(
isApplicationDocument.value
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${request.value.id}完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`
: `${request.value.id}提交审批${approvalStage ? `,当前节点:${approvalStage}` : ''}`
)
} else if (claimStatus === 'supplement') {
toast(`${request.value.id} AI预审未通过,已转待补充。`)
toast(`${request.value.id} 自动检测未通过,已转待补充。`)
} else {
toast(`${request.value.id} 提交结果已更新。`)
}
@@ -2062,7 +2051,7 @@ export default {
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, leaderApprovalEvents, leaderApprovalReadonlyMeta,
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,