feat: 报销预审会话状态管理与工作台交互增强

- 新增差旅报销会话状态管理与对话模型重构
- 增强风险观测服务与运行时聊天上下文作用域
- 优化工作台图标资源、助理意图识别与摘要工具
- 完善报销创建视图样式与差旅详情页标准调整交互
- 补充风险观测、运行时聊天与报销端点测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-04 11:03:29 +08:00
parent 87da5df91b
commit 1cbf3fee44
60 changed files with 4156 additions and 393 deletions

View File

@@ -612,10 +612,10 @@ export default {
const submitConfirmDialogOpen = ref(false)
const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false)
const standardAdjustmentBusy = ref(false)
const riskOverrideIndex = ref(0)
const highlightedRiskCardId = ref('')
let highlightedRiskCardTimer = 0
const riskOverrideReasons = reactive({})
const deleteBusy = ref(false)
const deleteDialogOpen = ref(false)
const returnBusy = ref(false)
@@ -653,6 +653,8 @@ export default {
})
const detailNoteEditor = ref('')
const savingDetailNote = ref(false)
let standardAdjustmentTaskSeq = 0
let submitTaskSeq = 0
const request = computed(() => {
const normalized = normalizeRequestForUi(props.request)
@@ -901,7 +903,6 @@ export default {
const actionBusy = computed(() =>
Boolean(savingExpenseId.value)
|| submitBusy.value
|| riskOverrideBusy.value
|| deleteBusy.value
|| returnBusy.value
|| approveBusy.value
@@ -935,6 +936,10 @@ export default {
Object.keys(expenseAttachmentMeta).forEach((key) => {
delete expenseAttachmentMeta[key]
})
standardAdjustmentTaskSeq += 1
standardAdjustmentBusy.value = false
submitTaskSeq += 1
submitBusy.value = false
closeAttachmentPreview()
}
pendingUploadExpenseId.value = ''
@@ -1776,17 +1781,6 @@ export default {
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
}
@@ -1824,105 +1818,43 @@ export default {
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
}
function mergeDetailNoteWithRiskOverride(appendix) {
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value
return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
function confirmStandardAdjustment() {
if (riskOverrideBusy.value || standardAdjustmentBusy.value) {
return
}
const claimId = String(request.value?.claimId || '').trim()
if (!claimId) {
toast('\u5f53\u524d\u8349\u7a3f\u7f3a\u5c11 claimId\uff0c\u6682\u65f6\u65e0\u6cd5\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u7b97\u3002')
return
}
riskOverrideDialogOpen.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')
void runStandardAdjustmentRecalculation(claimId, taskSeq)
}
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 itemNoteGroups = new Map()
const claimLevelRisks = []
submitRiskWarnings.value.forEach((risk, index) => {
const reason = String(riskOverrideReasons[risk.id] || '').trim()
const item = resolveExpenseItemForRiskCard(risk)
if (item?.id) {
const currentGroup = itemNoteGroups.get(item.id) || { item, reasons: [] }
currentGroup.reasons.push(reason)
itemNoteGroups.set(item.id, currentGroup)
} else {
const title = String(risk.title || risk.label || '风险').trim()
claimLevelRisks.push(`异常说明:第${index + 1}${title}${reason}`)
}
})
riskOverrideBusy.value = true
try {
await Promise.all(
[...itemNoteGroups.entries()].map(([itemId, group]) => {
const existingNote = String(group.item?.itemNote || '').trim()
const nextNote = [
existingNote,
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
].filter(Boolean).join('\n')
return updateExpenseClaimItem(request.value.claimId, itemId, {
item_note: nextNote
})
})
)
itemNoteGroups.forEach((group, itemId) => {
const existingNote = String(group.item?.itemNote || '').trim()
const nextNote = [
existingNote,
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
].filter(Boolean).join('\n')
applyLocalExpenseItemPatch(itemId, {
itemNote: nextNote
})
})
if (claimLevelRisks.length) {
const appendix = claimLevelRisks.join('\n')
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
if (nextNote.length > 500) {
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
return
}
await updateExpenseClaim(request.value.claimId, {
reason: nextNote
})
detailNoteEditor.value = nextNote
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
toast('异常说明已保存,可继续提交审批。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '异常说明保存失败,请稍后重试。')
} finally {
riskOverrideBusy.value = false
}
}
async function confirmStandardAdjustment() {
if (riskOverrideBusy.value) {
return
}
riskOverrideBusy.value = true
async function runStandardAdjustmentRecalculation(claimId, taskSeq) {
try {
const payload = await buildStandardAdjustmentPayload()
if (!payload.risks.length) {
toast('当前风险暂未匹配到可重算的费用明细,请先补充异常说明。')
toast('\u5f53\u524d\u98ce\u9669\u6682\u672a\u5339\u914d\u5230\u53ef\u91cd\u7b97\u7684\u8d39\u7528\u660e\u7ec6\uff0c\u8bf7\u5148\u8865\u5145\u5f02\u5e38\u8bf4\u660e\u3002')
return
}
const response = await acceptExpenseClaimStandardAdjustment(claimId, payload)
if (taskSeq !== standardAdjustmentTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
return
}
const response = await acceptExpenseClaimStandardAdjustment(request.value.claimId, payload)
applyStandardAdjustmentResponse(response)
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
toast('已按职级最高报销标准重算实际报销金额。')
toast('\u5df2\u6309\u804c\u7ea7\u6700\u9ad8\u62a5\u9500\u6807\u51c6\u91cd\u7b97\u5b9e\u9645\u62a5\u9500\u91d1\u989d\uff0c\u53ef\u7ee7\u7eed\u63d0\u4ea4\u5ba1\u6279\u3002')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '按职级标准重算失败,请稍后重试。')
toast(error?.message || '\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u7b97\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002')
} finally {
riskOverrideBusy.value = false
if (taskSeq === standardAdjustmentTaskSeq) {
standardAdjustmentBusy.value = false
}
}
}
@@ -2375,6 +2307,11 @@ export default {
return
}
if (standardAdjustmentBusy.value) {
toast('费用正在按职级标准重新测算,完成后再提交审批。')
return
}
if (draftBlockingIssues.value.length) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
return
@@ -2396,7 +2333,7 @@ export default {
submitConfirmDialogOpen.value = false
}
async function confirmSubmitRequest() {
function confirmSubmitRequest() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
submitConfirmDialogOpen.value = false
@@ -2409,34 +2346,57 @@ export default {
return
}
if (standardAdjustmentBusy.value) {
toast('费用正在按职级标准重新测算,完成后再提交审批。')
submitConfirmDialogOpen.value = false
return
}
if (draftBlockingIssues.value.length) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
submitConfirmDialogOpen.value = false
return
}
const claimId = String(request.value.claimId || '').trim()
const documentNo = request.value.id
const isApplication = isApplicationDocument.value
submitBusy.value = true
submitConfirmDialogOpen.value = false
const taskSeq = ++submitTaskSeq
toast('\u6b63\u5728\u540e\u53f0\u63d0\u4ea4\u5ba1\u6279\uff0c\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u66f4\u65b0\u5355\u636e\u72b6\u6001\u3002')
void runSubmitRequest(claimId, documentNo, isApplication, taskSeq)
}
async function runSubmitRequest(claimId, documentNo, isApplication, taskSeq) {
try {
const payload = await submitExpenseClaim(request.value.claimId)
const payload = await submitExpenseClaim(claimId)
if (taskSeq !== submitTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
return
}
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} 已提交审批${approvalStage ? `,当前节点:${approvalStage}` : '。'}`
isApplication
? `${documentNo} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${documentNo} 已提交审批${approvalStage ? `,当前节点:${approvalStage}` : '。'}`
)
} else if (claimStatus === 'supplement') {
toast(`${request.value.id} 自动检测未通过,已转待补充。`)
toast(`${documentNo} 自动检测未通过,已转待补充。`)
} else {
toast(`${request.value.id} 提交结果已更新。`)
toast(`${documentNo} 提交结果已更新。`)
}
submitConfirmDialogOpen.value = false
emit('request-updated', { claimId: request.value.claimId })
emit('request-updated', { claimId })
} catch (error) {
toast(error?.message || '提交审批失败,请稍后重试。')
if (taskSeq === submitTaskSeq) {
toast(error?.message || '提交审批失败,请稍后重试。')
}
} finally {
submitBusy.value = false
if (taskSeq === submitTaskSeq) {
submitBusy.value = false
}
}
}
@@ -2664,6 +2624,10 @@ export default {
}
onBeforeUnmount(() => {
standardAdjustmentTaskSeq += 1
standardAdjustmentBusy.value = false
submitTaskSeq += 1
submitBusy.value = false
if (highlightedRiskCardTimer) {
window.clearTimeout(highlightedRiskCardTimer)
highlightedRiskCardTimer = 0
@@ -2688,7 +2652,7 @@ export default {
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmRiskOverrideReasons, confirmStandardAdjustment, confirmSmartEntryUpload,
confirmPayRequest, confirmStandardAdjustment, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning,
@@ -2715,10 +2679,11 @@ export default {
resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
saveDetailNote, savingDetailNote, savingExpenseId,
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
smartEntryRecognitionBusy, smartEntryRecognitionText,
smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput,
standardAdjustmentBusy,
showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,