feat: 报销预审会话状态管理与工作台交互增强
- 新增差旅报销会话状态管理与对话模型重构 - 增强风险观测服务与运行时聊天上下文作用域 - 优化工作台图标资源、助理意图识别与摘要工具 - 完善报销创建视图样式与差旅详情页标准调整交互 - 补充风险观测、运行时聊天与报销端点测试覆盖
This commit is contained in:
@@ -184,7 +184,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer-row" :class="{ 'knowledge-mode': isKnowledgeSession }">
|
||||
<div v-if="isStewardSession" class="composer-row steward-composer-row">
|
||||
<div class="composer-leading-actions steward-composer-leading-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-btn composer-side-btn"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
aria-label="上传附件"
|
||||
title="上传附件"
|
||||
@click="triggerFileUpload"
|
||||
>
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="composer-shell steward-composer-shell">
|
||||
<div class="composer-shell-body">
|
||||
<textarea
|
||||
ref="composerTextareaRef"
|
||||
v-model="composerDraft"
|
||||
rows="1"
|
||||
:placeholder="composerPlaceholder"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
@input="handleComposerInput"
|
||||
@keydown.enter.exact.prevent="handleComposerEnter"
|
||||
@keydown.ctrl.enter.prevent="submitComposer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="send-btn composer-side-btn" type="submit" :disabled="!canSubmit || reviewActionBusy || sessionSwitchBusy" aria-label="发送">
|
||||
<i :class="submitting ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="composer-row" :class="{ 'knowledge-mode': isKnowledgeSession }">
|
||||
<div v-if="!isKnowledgeSession" class="composer-leading-actions">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -184,6 +184,14 @@
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>{{ smartEntryRecognitionText }}</span>
|
||||
</div>
|
||||
<div v-if="standardAdjustmentBusy" class="expense-recognition-banner standard-adjustment-banner">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在重新测算费用,请稍候。明细和合计会在后台完成后自动更新。</span>
|
||||
</div>
|
||||
<div v-if="submitBusy" class="expense-recognition-banner submit-progress-banner">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>正在提交审批,请稍候。系统正在完成自动检测、预算占用和审批流转。</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -280,7 +288,10 @@
|
||||
<template v-else>
|
||||
<div v-if="item.hasStandardAdjustment" class="expense-adjusted-amount">
|
||||
<span class="expense-original-amount">{{ item.originalAmountDisplay || item.amount }}</span>
|
||||
<strong class="expense-reimbursable-amount">{{ item.reimbursableAmountDisplay }}</strong>
|
||||
<strong class="expense-reimbursable-amount">
|
||||
<span class="expense-reimbursable-label">职级测算</span>
|
||||
{{ item.reimbursableAmountDisplay }}
|
||||
</strong>
|
||||
<em v-if="item.employeeAbsorbedAmountDisplay">自担 {{ item.employeeAbsorbedAmountDisplay }}</em>
|
||||
</div>
|
||||
<strong v-else>{{ item.amount }}</strong>
|
||||
@@ -754,6 +765,7 @@
|
||||
:open="submitConfirmDialogOpen"
|
||||
badge="提交确认"
|
||||
badge-tone="warning"
|
||||
size="review"
|
||||
:title="`确认提交 ${request.id} 吗?`"
|
||||
:description="submitConfirmDescription"
|
||||
cancel-text="返回核对"
|
||||
@@ -788,8 +800,9 @@
|
||||
:open="riskOverrideDialogOpen"
|
||||
badge="异常说明"
|
||||
badge-tone="danger"
|
||||
size="review"
|
||||
:title="`当前存在 ${submitRiskWarnings.length} 条需说明的风险`"
|
||||
description="请先补充异常说明后提交领导审批;也可以不填写说明,选择按职级最高可报销金额重新计算。"
|
||||
description="请回到费用明细的异常说明列补充原因后再提交;如果不补充说明,可选择按职级最高可报销金额重新计算。"
|
||||
cancel-text="返回整改"
|
||||
confirm-text="按职级标准重算"
|
||||
busy-text="处理中..."
|
||||
@@ -827,27 +840,10 @@
|
||||
<strong>{{ currentSubmitRiskWarning.title }}</strong>
|
||||
</div>
|
||||
<p>{{ currentSubmitRiskWarning.risk }}</p>
|
||||
<textarea
|
||||
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
|
||||
class="risk-note-editor-textarea"
|
||||
rows="1"
|
||||
maxlength="160"
|
||||
placeholder="请说明原因,例如客户指定酒店、会议高峰、协议酒店满房等"
|
||||
aria-label="异常说明"
|
||||
@input="resizeExpenseNoteInput"
|
||||
@keydown.enter="resizeExpenseNoteInput"
|
||||
></textarea>
|
||||
</article>
|
||||
<div class="risk-override-submit-row">
|
||||
<button
|
||||
class="risk-override-save-btn"
|
||||
type="button"
|
||||
:disabled="riskOverrideBusy"
|
||||
@click="confirmRiskOverrideReasons"
|
||||
>
|
||||
保存说明并继续提交
|
||||
</button>
|
||||
<span>不填写说明时,系统会按职级最高报销标准重算金额。</span>
|
||||
<div class="risk-override-guidance">
|
||||
<strong>请在费用明细的“异常说明”列补充原因后再提交。</strong>
|
||||
<span>如果不补充说明,可直接选择按职级标准重算,超出标准的部分由员工自担。</span>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementRevi
|
||||
import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js'
|
||||
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
|
||||
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
|
||||
import { useStewardPlanFlow } from './useStewardPlanFlow.js'
|
||||
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
|
||||
import {
|
||||
buildOperationFeedbackPayload,
|
||||
@@ -24,6 +25,7 @@ import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
|
||||
import { createOperationFeedback } from '../../services/operationFeedback.js'
|
||||
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
|
||||
import { fetchStewardPlan, fetchStewardPlanStream } from '../../services/steward.js'
|
||||
import { renderMarkdown } from '../../utils/markdown.js'
|
||||
import { clearAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js'
|
||||
import {
|
||||
@@ -182,6 +184,7 @@ import {
|
||||
SESSION_TYPE_APPROVAL,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
SESSION_TYPE_STEWARD,
|
||||
canUseBudgetAssistantSession,
|
||||
aiAvatar,
|
||||
buildExpenseIntentConfirmationMessage,
|
||||
@@ -674,9 +677,12 @@ export default {
|
||||
|
||||
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
|
||||
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
|
||||
const isStewardSession = computed(() => activeSessionType.value === SESSION_TYPE_STEWARD)
|
||||
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
|
||||
const assistantHeaderTitle = computed(() => '个人工作台')
|
||||
const assistantHeaderDescription = computed(() => '个人工作窗,一站式费控解决枢纽')
|
||||
const assistantHeaderTitle = computed(() => (isStewardSession.value ? '小财管家' : '个人工作台'))
|
||||
const assistantHeaderDescription = computed(() =>
|
||||
isStewardSession.value ? '统一财务任务编排入口' : '个人工作窗,一站式费控解决枢纽'
|
||||
)
|
||||
const {
|
||||
flowRunId,
|
||||
flowSteps,
|
||||
@@ -747,6 +753,9 @@ export default {
|
||||
showInsightPanel.value ? '隐藏详细信息' : '展开详细信息'
|
||||
)
|
||||
const composerPlaceholder = computed(() => {
|
||||
if (isStewardSession.value) {
|
||||
return '例如:申请7月2日去北京出差,同时报销昨天交通费和6月3日上海出差费用。'
|
||||
}
|
||||
if (isKnowledgeSession.value) {
|
||||
return '例如:差旅住宿标准是什么?发票抬头不一致还能报销吗?'
|
||||
}
|
||||
@@ -1213,6 +1222,9 @@ export default {
|
||||
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
|
||||
|
||||
const shortcuts = computed(() => {
|
||||
if (isStewardSession.value) {
|
||||
return []
|
||||
}
|
||||
const accessibleModes = filterAssistantSessionModes(ASSISTANT_SESSION_MODE_OPTIONS, currentUser.value)
|
||||
const visibleModes = props.entrySource === 'budget'
|
||||
? accessibleModes.filter((mode) => mode.key === SESSION_TYPE_BUDGET)
|
||||
@@ -1416,6 +1428,7 @@ export default {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleComposerDatePickerOutside)
|
||||
clearStewardThinkingTimers()
|
||||
stopFlowRuntime()
|
||||
stopAttachmentRuntime()
|
||||
})
|
||||
@@ -1518,6 +1531,27 @@ export default {
|
||||
messages.value.splice(index, 1, nextMessage)
|
||||
}
|
||||
|
||||
const { submitStewardPlan, clearStewardThinkingTimers } = useStewardPlanFlow({
|
||||
activeSessionType,
|
||||
attachedFiles,
|
||||
composerDraft,
|
||||
currentUser,
|
||||
fileInputRef,
|
||||
messages,
|
||||
createMessage,
|
||||
fetchStewardPlan,
|
||||
fetchStewardPlanStream,
|
||||
nextTick,
|
||||
persistSessionState,
|
||||
replaceMessage,
|
||||
scrollToBottom,
|
||||
adjustComposerTextareaHeight,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
sessionSwitchBusy,
|
||||
toast
|
||||
})
|
||||
|
||||
async function runShortcut(shortcut) {
|
||||
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
|
||||
if (shortcut.targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
|
||||
@@ -1675,6 +1709,15 @@ export default {
|
||||
scrollToBottom()
|
||||
})
|
||||
persistSessionState()
|
||||
if (actionPayload.auto_submit && carryText) {
|
||||
await submitComposer({
|
||||
rawText: carryText,
|
||||
userText: action.label || '确认继续处理',
|
||||
pendingText: '正在按确认内容继续处理...',
|
||||
files: carryFiles,
|
||||
skipScopeGuard: true
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2406,6 +2449,9 @@ export default {
|
||||
// submitting.value = true
|
||||
// recognizeOcrFiles(files)
|
||||
// submitting.value = false
|
||||
if (isStewardSession.value && await submitStewardPlan(options)) {
|
||||
return null
|
||||
}
|
||||
if (await handleGuidedComposerSubmit(options)) {
|
||||
return null
|
||||
}
|
||||
@@ -2651,7 +2697,7 @@ export default {
|
||||
return {
|
||||
emit, messageItemUi, insightPanelUi, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection,
|
||||
toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText,
|
||||
attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions,
|
||||
attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, isStewardSession, hotKnowledgeQuestions,
|
||||
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, assistantHeaderTitle, assistantHeaderDescription, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
|
||||
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
|
||||
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||
|
||||
@@ -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,
|
||||
|
||||
173
web/src/views/scripts/stewardPlanModel.js
Normal file
173
web/src/views/scripts/stewardPlanModel.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
} from '../../utils/assistantSessionScope.js'
|
||||
import {
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_EXPENSE
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
|
||||
const TASK_TYPE_LABELS = {
|
||||
expense_application: '费用申请',
|
||||
reimbursement: '费用报销'
|
||||
}
|
||||
|
||||
const AGENT_LABELS = {
|
||||
application_assistant: '申请助手',
|
||||
reimbursement_assistant: '报销助手'
|
||||
}
|
||||
|
||||
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) {
|
||||
const safeFiles = Array.isArray(files) ? files : []
|
||||
return {
|
||||
message: String(rawText || '').trim(),
|
||||
user_id: String(currentUser.username || currentUser.name || 'anonymous').trim() || 'anonymous',
|
||||
client_now_iso: new Date().toISOString(),
|
||||
attachments: safeFiles.map((file) => ({
|
||||
name: String(file?.name || '').trim(),
|
||||
media_type: String(file?.type || '').trim()
|
||||
})).filter((item) => item.name),
|
||||
context_json: {
|
||||
entry_source: 'workbench',
|
||||
session_type: 'steward',
|
||||
role_codes: Array.isArray(currentUser.roleCodes) ? currentUser.roleCodes : [],
|
||||
username: currentUser.username || '',
|
||||
name: currentUser.name || currentUser.username || '',
|
||||
department_name: currentUser.departmentName || currentUser.department || '',
|
||||
employee_grade: currentUser.grade || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeStewardPlan(rawPlan = {}, options = {}) {
|
||||
const visibleThinkingEventCount = Number.isFinite(options.visibleThinkingEventCount)
|
||||
? Number(options.visibleThinkingEventCount)
|
||||
: Number(rawPlan.visibleThinkingEventCount || rawPlan.visible_thinking_event_count || 0)
|
||||
return {
|
||||
planId: String(rawPlan.plan_id || rawPlan.planId || ''),
|
||||
planStatus: String(rawPlan.plan_status || rawPlan.planStatus || ''),
|
||||
summary: String(rawPlan.summary || ''),
|
||||
visibleThinkingEventCount,
|
||||
thinkingEvents: Array.isArray(rawPlan.thinking_events)
|
||||
? rawPlan.thinking_events.map((item) => ({
|
||||
eventId: String(item.event_id || item.eventId || ''),
|
||||
stage: String(item.stage || ''),
|
||||
title: String(item.title || ''),
|
||||
content: String(item.content || ''),
|
||||
status: String(item.status || 'completed')
|
||||
}))
|
||||
: [],
|
||||
tasks: Array.isArray(rawPlan.tasks)
|
||||
? rawPlan.tasks.map((item) => ({
|
||||
taskId: String(item.task_id || item.taskId || ''),
|
||||
taskType: String(item.task_type || item.taskType || ''),
|
||||
taskTypeLabel: TASK_TYPE_LABELS[String(item.task_type || item.taskType || '')] || '财务任务',
|
||||
assignedAgent: String(item.assigned_agent || item.assignedAgent || ''),
|
||||
assignedAgentLabel: AGENT_LABELS[String(item.assigned_agent || item.assignedAgent || '')] || '财务助手',
|
||||
title: String(item.title || ''),
|
||||
summary: String(item.summary || ''),
|
||||
status: String(item.status || ''),
|
||||
confidence: Number(item.confidence || 0),
|
||||
ontologyFields: item.ontology_fields || item.ontologyFields || {},
|
||||
missingFields: Array.isArray(item.missing_fields || item.missingFields)
|
||||
? item.missing_fields || item.missingFields
|
||||
: [],
|
||||
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
|
||||
}))
|
||||
: [],
|
||||
attachmentGroups: Array.isArray(rawPlan.attachment_groups)
|
||||
? rawPlan.attachment_groups.map((item) => ({
|
||||
groupId: String(item.group_id || item.groupId || ''),
|
||||
targetTaskId: String(item.target_task_id || item.targetTaskId || ''),
|
||||
scene: String(item.scene || ''),
|
||||
sceneLabel: String(item.scene_label || item.sceneLabel || ''),
|
||||
attachmentNames: Array.isArray(item.attachment_names || item.attachmentNames)
|
||||
? item.attachment_names || item.attachmentNames
|
||||
: [],
|
||||
excludedAttachmentNames: Array.isArray(item.excluded_attachment_names || item.excludedAttachmentNames)
|
||||
? item.excluded_attachment_names || item.excludedAttachmentNames
|
||||
: [],
|
||||
confidence: Number(item.confidence || 0),
|
||||
rationale: String(item.rationale || ''),
|
||||
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
|
||||
}))
|
||||
: [],
|
||||
confirmationGroups: Array.isArray(rawPlan.confirmation_groups)
|
||||
? rawPlan.confirmation_groups
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStewardPlanMessageText(plan) {
|
||||
const normalized = normalizeStewardPlan(plan)
|
||||
const taskLines = normalized.tasks.map((task, index) =>
|
||||
`${index + 1}. ${task.title || task.taskTypeLabel},交给${task.assignedAgentLabel}。`
|
||||
)
|
||||
return [
|
||||
'**小财管家已完成任务拆解。**',
|
||||
'',
|
||||
normalized.summary || `我识别到 ${normalized.tasks.length} 个待处理任务,请确认后继续执行。`,
|
||||
'',
|
||||
...taskLines
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildStewardSuggestedActions(plan) {
|
||||
const normalized = normalizeStewardPlan(plan)
|
||||
const taskById = new Map(normalized.tasks.map((task) => [task.taskId, task]))
|
||||
const groupById = new Map(normalized.attachmentGroups.map((group) => [group.groupId, group]))
|
||||
return normalized.confirmationGroups.map((action) => {
|
||||
const actionType = String(action.action_type || action.actionType || '').trim()
|
||||
const taskId = String(action.target_task_id || action.targetTaskId || '').trim()
|
||||
const groupId = String(action.attachment_group_id || action.attachmentGroupId || '').trim()
|
||||
const task = taskById.get(taskId)
|
||||
const group = groupById.get(groupId)
|
||||
const targetSessionType = actionType === 'confirm_create_application'
|
||||
? SESSION_TYPE_APPLICATION
|
||||
: SESSION_TYPE_EXPENSE
|
||||
return {
|
||||
label: String(action.label || '确认继续处理'),
|
||||
description: String(action.description || ''),
|
||||
icon: actionType === 'confirm_create_application'
|
||||
? 'mdi mdi-file-plus-outline'
|
||||
: actionType === 'confirm_attachment_group'
|
||||
? 'mdi mdi-folder-check-outline'
|
||||
: 'mdi mdi-receipt-text-plus-outline',
|
||||
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
payload: {
|
||||
session_type: targetSessionType,
|
||||
carry_text: buildStewardCarryText(actionType, task, group),
|
||||
carry_files: actionType !== 'confirm_create_application',
|
||||
auto_submit: true,
|
||||
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
|
||||
steward_plan_id: normalized.planId
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildStewardCarryText(actionType, task, group) {
|
||||
if (actionType === 'confirm_attachment_group' && group) {
|
||||
return [
|
||||
`我确认将以下附件归集为${group.sceneLabel || '当前报销任务'},请继续整理报销核对信息。`,
|
||||
`附件:${group.attachmentNames.join('、') || '待确认'}`,
|
||||
group.excludedAttachmentNames.length
|
||||
? `暂不归集:${group.excludedAttachmentNames.join('、')}`
|
||||
: ''
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return '我确认继续处理这项财务任务,请按现有流程核对信息。'
|
||||
}
|
||||
|
||||
const fields = Object.entries(task.ontologyFields || {})
|
||||
.filter(([, value]) => String(value || '').trim())
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
return [
|
||||
`我确认处理“小财管家”识别的任务:${task.title || task.taskTypeLabel}。`,
|
||||
task.summary ? `任务摘要:${task.summary}` : '',
|
||||
fields.length ? `本体字段:${fields.join(';')}` : '',
|
||||
task.missingFields.length ? `待补充字段:${task.missingFields.join('、')}` : '',
|
||||
'请按现有流程生成核对结果,并在需要入库、绑定附件或提交审批前让我再次确认。'
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
@@ -16,8 +16,10 @@ export const SESSION_TYPE_APPLICATION = 'application'
|
||||
export const SESSION_TYPE_APPROVAL = 'approval'
|
||||
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||
export const SESSION_TYPE_BUDGET = 'budget'
|
||||
export const SESSION_TYPE_STEWARD = 'steward'
|
||||
|
||||
export const ASSISTANT_SESSION_TYPES = [
|
||||
SESSION_TYPE_STEWARD,
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_APPROVAL,
|
||||
@@ -26,6 +28,12 @@ export const ASSISTANT_SESSION_TYPES = [
|
||||
]
|
||||
|
||||
export const ASSISTANT_SESSION_MODE_OPTIONS = [
|
||||
{
|
||||
key: SESSION_TYPE_STEWARD,
|
||||
label: '小财管家',
|
||||
icon: 'mdi mdi-account-tie-outline',
|
||||
description: '统一拆解多任务、归集附件,并调度申请助手和报销助手'
|
||||
},
|
||||
{
|
||||
key: SESSION_TYPE_APPLICATION,
|
||||
label: '申请助手',
|
||||
@@ -323,6 +331,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
pendingAttachmentAssociation: null,
|
||||
applicationPreview: null,
|
||||
budgetReport: null,
|
||||
stewardPlan: null,
|
||||
operationFeedback: null,
|
||||
...extras
|
||||
}
|
||||
@@ -574,6 +583,21 @@ export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedR
|
||||
}))
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_STEWARD) {
|
||||
return [
|
||||
{
|
||||
label: '申请出差并报销票据',
|
||||
prompt: '我想申请下周去北京出差,并报销昨天的交通费。',
|
||||
icon: 'mdi mdi-account-tie-outline'
|
||||
},
|
||||
{
|
||||
label: '归集多张附件',
|
||||
prompt: '我上传了多张票据,请先帮我判断哪些属于差旅报销。',
|
||||
icon: 'mdi mdi-folder-multiple-outline'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
||||
return APPLICATION_WELCOME_QUICK_ACTIONS
|
||||
}
|
||||
@@ -606,6 +630,18 @@ export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SE
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_STEWARD) {
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
'**欢迎来到个人财务中心 · 小财管家。** 我会先拆解您的一句话多任务,归集附件,再把确认后的任务分派给申请助手或报销助手。',
|
||||
'',
|
||||
'业务范围:多任务识别、附件归集、确认点管理、申请助手和报销助手调度。创建单据、绑定附件和提交审批都会先让您确认。',
|
||||
'',
|
||||
'您可以一次性描述多个申请或报销事项,也可以先上传附件让我归集。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
@@ -678,6 +714,17 @@ export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SE
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_STEWARD) {
|
||||
return {
|
||||
intent: 'welcome',
|
||||
metricLabel: '当前入口',
|
||||
metricValue: '小财管家',
|
||||
title: '小财管家',
|
||||
summary: `${ctx.honorific},这里会先拆解多任务和归集附件,再把确认后的事项交给申请助手或报销助手处理。`,
|
||||
agent: null
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
|
||||
return {
|
||||
intent: 'welcome',
|
||||
@@ -890,6 +937,7 @@ export function serializeSessionMessages(messages) {
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
budgetReport: message.budgetReport || null,
|
||||
stewardPlan: message.stewardPlan || null,
|
||||
operationFeedback: message.operationFeedback || null,
|
||||
assistantName: message.assistantName || '',
|
||||
isWelcome: Boolean(message.isWelcome),
|
||||
@@ -913,6 +961,7 @@ export function hasMeaningfulSessionMessages(messages) {
|
||||
|| message.draftPayload
|
||||
|| message.applicationPreview
|
||||
|| message.budgetReport
|
||||
|| message.stewardPlan
|
||||
|| message.operationFeedback
|
||||
|| message.pendingAttachmentAssociation
|
||||
|| (Array.isArray(message.riskFlags) && message.riskFlags.length)
|
||||
|
||||
@@ -12,6 +12,15 @@ function normalizeAmount(value) {
|
||||
return Number.isFinite(amount) && amount > 0 ? amount : 0
|
||||
}
|
||||
|
||||
function parseDayCount(value) {
|
||||
const match = String(value || '').match(/\d{1,3}/)
|
||||
if (!match) {
|
||||
return 0
|
||||
}
|
||||
const days = Number(match[0])
|
||||
return Number.isFinite(days) && days > 0 ? Math.max(1, Math.min(365, Math.floor(days))) : 0
|
||||
}
|
||||
|
||||
export function buildCurrentStandardAdjustmentMap(request = {}, riskFlags = []) {
|
||||
return buildStandardAdjustmentMap({
|
||||
...request,
|
||||
@@ -94,7 +103,20 @@ function resolveParsedStandardAmount(card, item) {
|
||||
return candidates.length ? Math.max(...candidates) : null
|
||||
}
|
||||
|
||||
function extractRiskCardNightCount(card) {
|
||||
function extractRequestApplicationDays(request = {}) {
|
||||
return parseDayCount(
|
||||
request?.relatedApplication?.days
|
||||
|| request?.application_days
|
||||
|| request?.applicationDays
|
||||
|| request?.days
|
||||
)
|
||||
}
|
||||
|
||||
function extractRiskCardNightCount(card, request = {}) {
|
||||
const applicationDays = extractRequestApplicationDays(request)
|
||||
if (applicationDays) {
|
||||
return applicationDays
|
||||
}
|
||||
const corpus = [card?.risk, card?.summary, card?.suggestion, card?.title]
|
||||
.map(normalizeText)
|
||||
.join(' ')
|
||||
@@ -112,7 +134,7 @@ async function resolveTravelStandardAmount({ card, item, request, calculateTrave
|
||||
return null
|
||||
}
|
||||
const grade = normalizeText(request?.employeeGrade || request?.profileGrade)
|
||||
const days = extractRiskCardNightCount(card)
|
||||
const days = extractRiskCardNightCount(card, request)
|
||||
try {
|
||||
const result = await calculateTravelReimbursement({ days, location, grade })
|
||||
const hotelAmount = Number(result?.hotel_amount ?? result?.hotelAmount)
|
||||
@@ -167,6 +189,7 @@ export async function buildStandardAdjustmentPayload({
|
||||
item_id: item.id,
|
||||
title: warning.title,
|
||||
risk: warning.risk || warning.summary,
|
||||
application_days: extractRiskCardNightCount(warning, request),
|
||||
original_amount: originalAmount,
|
||||
reimbursable_amount: reimbursableAmount
|
||||
})
|
||||
|
||||
158
web/src/views/scripts/useStewardPlanFlow.js
Normal file
158
web/src/views/scripts/useStewardPlanFlow.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
buildStewardPlanMessageText,
|
||||
buildStewardPlanRequest,
|
||||
buildStewardSuggestedActions,
|
||||
normalizeStewardPlan
|
||||
} from './stewardPlanModel.js'
|
||||
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
|
||||
|
||||
export function useStewardPlanFlow({
|
||||
activeSessionType,
|
||||
attachedFiles,
|
||||
composerDraft,
|
||||
currentUser,
|
||||
fileInputRef,
|
||||
messages,
|
||||
createMessage,
|
||||
fetchStewardPlan,
|
||||
fetchStewardPlanStream,
|
||||
nextTick,
|
||||
persistSessionState,
|
||||
replaceMessage,
|
||||
scrollToBottom,
|
||||
adjustComposerTextareaHeight,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
sessionSwitchBusy,
|
||||
toast
|
||||
}) {
|
||||
function isStewardSession() {
|
||||
return String(activeSessionType.value || '').trim() === SESSION_TYPE_STEWARD
|
||||
}
|
||||
|
||||
function clearStewardThinkingTimers() {
|
||||
// 保留给页面卸载调用;流式版不再使用前端延时器。
|
||||
}
|
||||
|
||||
async function submitStewardPlan(options = {}) {
|
||||
if (!isStewardSession()) return false
|
||||
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return true
|
||||
|
||||
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
|
||||
const files = Array.from(options.files ?? attachedFiles.value ?? [])
|
||||
if (!rawText && !files.length) return true
|
||||
|
||||
const fileNames = files.map((file) => file.name).filter(Boolean)
|
||||
const userText = String(options.userText || rawText || `我上传了 ${fileNames.length} 份附件,请小财管家先归集任务。`).trim()
|
||||
submitting.value = true
|
||||
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
const pendingPlan = normalizeStewardPlan({
|
||||
plan_status: 'streaming',
|
||||
summary: '',
|
||||
thinking_events: []
|
||||
})
|
||||
const pendingMessage = createMessage('assistant', '', [], {
|
||||
assistantName: '小财管家',
|
||||
meta: ['小财管家', '流式分析中'],
|
||||
stewardPlan: {
|
||||
...pendingPlan,
|
||||
streamStatus: 'streaming'
|
||||
}
|
||||
})
|
||||
messages.value.push(pendingMessage)
|
||||
composerDraft.value = ''
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
|
||||
try {
|
||||
const requestPayload = buildStewardPlanRequest({
|
||||
rawText,
|
||||
files,
|
||||
currentUser: currentUser.value || {}
|
||||
})
|
||||
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload)
|
||||
const normalizedPlan = normalizeStewardPlan(plan, {
|
||||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER
|
||||
})
|
||||
replaceMessage(pendingMessage.id, createMessage('assistant', buildStewardPlanMessageText(plan), [], {
|
||||
assistantName: '小财管家',
|
||||
meta: ['小财管家', '等待确认'],
|
||||
stewardPlan: {
|
||||
...normalizedPlan,
|
||||
streamStatus: 'completed'
|
||||
},
|
||||
suggestedActions: buildStewardSuggestedActions(plan)
|
||||
}))
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
} catch (error) {
|
||||
replaceMessage(pendingMessage.id, createMessage('assistant', error?.message || '小财管家规划失败,请稍后重试。', [], {
|
||||
assistantName: '小财管家',
|
||||
meta: ['小财管家', '规划失败']
|
||||
}))
|
||||
toast(error?.message || '小财管家规划失败,请稍后重试。')
|
||||
persistSessionState()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function fetchPlanWithStreaming(messageId, requestPayload) {
|
||||
if (typeof fetchStewardPlanStream === 'function') {
|
||||
return fetchStewardPlanStream(requestPayload, {
|
||||
onEvent: (event) => handleStreamEvent(messageId, event)
|
||||
}, {
|
||||
timeoutMs: 20000,
|
||||
timeoutMessage: '小财管家任务规划超时,请稍后重试。'
|
||||
})
|
||||
}
|
||||
|
||||
return fetchStewardPlan(requestPayload, {
|
||||
timeoutMs: 16000,
|
||||
timeoutMessage: '小财管家任务规划超时,请稍后重试。'
|
||||
})
|
||||
}
|
||||
|
||||
function handleStreamEvent(messageId, event) {
|
||||
if (event.event !== 'thinking') {
|
||||
return
|
||||
}
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message?.stewardPlan) return
|
||||
const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents)
|
||||
? message.stewardPlan.thinkingEvents
|
||||
: []
|
||||
const normalizedPlan = normalizeStewardPlan({
|
||||
...message.stewardPlan,
|
||||
thinking_events: [...existingEvents, event.data]
|
||||
}, {
|
||||
visibleThinkingEventCount: existingEvents.length + 1
|
||||
})
|
||||
message.stewardPlan = {
|
||||
...message.stewardPlan,
|
||||
...normalizedPlan,
|
||||
streamStatus: 'streaming'
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
return {
|
||||
isStewardSession,
|
||||
submitStewardPlan,
|
||||
clearStewardThinkingTimers
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_BUDGET,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_STEWARD,
|
||||
buildInitialInsightFromConversation,
|
||||
buildWelcomeInsight,
|
||||
buildWelcomeQuickActions,
|
||||
@@ -35,6 +36,15 @@ import {
|
||||
normalizeGuidedFlowState
|
||||
} from './travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
const STEWARD_IDLE_INSIGHT = {
|
||||
intent: 'idle',
|
||||
metricLabel: '',
|
||||
metricValue: '',
|
||||
title: '',
|
||||
summary: '',
|
||||
agent: null
|
||||
}
|
||||
|
||||
export function useTravelReimbursementSessionState({
|
||||
props,
|
||||
currentUser,
|
||||
@@ -79,6 +89,9 @@ export function useTravelReimbursementSessionState({
|
||||
if (!Array.isArray(messages) || !messages.length) {
|
||||
return []
|
||||
}
|
||||
if (isStewardSessionType(sessionType)) {
|
||||
return messages.filter((message) => !message?.isWelcome)
|
||||
}
|
||||
const currentActions = buildWelcomeQuickActions(
|
||||
sessionType,
|
||||
currentUser.value,
|
||||
@@ -92,6 +105,27 @@ export function useTravelReimbursementSessionState({
|
||||
))
|
||||
}
|
||||
|
||||
function isStewardSessionType(sessionType) {
|
||||
return normalizeAssistantSessionType(sessionType) === SESSION_TYPE_STEWARD
|
||||
}
|
||||
|
||||
function buildSessionMessages(restoredMessages, sessionType) {
|
||||
if (Array.isArray(restoredMessages) && restoredMessages.length) {
|
||||
return restoredMessages
|
||||
}
|
||||
if (isStewardSessionType(sessionType)) {
|
||||
return []
|
||||
}
|
||||
return [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)]
|
||||
}
|
||||
|
||||
function buildSessionInsight(sessionType) {
|
||||
if (isStewardSessionType(sessionType)) {
|
||||
return { ...STEWARD_IDLE_INSIGHT }
|
||||
}
|
||||
return buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
|
||||
}
|
||||
|
||||
function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
|
||||
const sessionType = resolveAccessibleSessionType(
|
||||
resolveInitialSessionType(conversation, fallbackSessionType),
|
||||
@@ -103,13 +137,12 @@ export function useTravelReimbursementSessionState({
|
||||
|
||||
return {
|
||||
sessionType,
|
||||
messages: restoredMessages.length
|
||||
? restoredMessages
|
||||
: [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)],
|
||||
messages: buildSessionMessages(restoredMessages, sessionType),
|
||||
conversationId: resolveInitialConversationId(conversation),
|
||||
draftClaimId: resolveInitialDraftClaimId(conversation),
|
||||
currentInsight:
|
||||
initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
|
||||
currentInsight: isStewardSessionType(sessionType)
|
||||
? buildSessionInsight(sessionType)
|
||||
: initialInsight || buildSessionInsight(sessionType),
|
||||
reviewFilePreviews: restoredReviewFilePreviews,
|
||||
composerDraft: '',
|
||||
attachedFiles: [],
|
||||
@@ -127,17 +160,10 @@ export function useTravelReimbursementSessionState({
|
||||
)
|
||||
return {
|
||||
sessionType: normalizedSessionType,
|
||||
messages: [
|
||||
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, normalizedSessionType, currentUser.value)
|
||||
],
|
||||
messages: buildSessionMessages([], normalizedSessionType),
|
||||
conversationId: '',
|
||||
draftClaimId: '',
|
||||
currentInsight: buildWelcomeInsight(
|
||||
props.entrySource,
|
||||
linkedRequest.value,
|
||||
normalizedSessionType,
|
||||
currentUser.value
|
||||
),
|
||||
currentInsight: buildSessionInsight(normalizedSessionType),
|
||||
reviewFilePreviews: [],
|
||||
composerDraft: '',
|
||||
attachedFiles: [],
|
||||
@@ -169,14 +195,12 @@ export function useTravelReimbursementSessionState({
|
||||
|
||||
return {
|
||||
sessionType,
|
||||
messages: restoredMessages.length
|
||||
? restoredMessages
|
||||
: [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)],
|
||||
messages: buildSessionMessages(restoredMessages, sessionType),
|
||||
conversationId: String(state.conversationId || '').trim(),
|
||||
draftClaimId: String(state.draftClaimId || '').trim(),
|
||||
currentInsight:
|
||||
state.currentInsight
|
||||
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
|
||||
currentInsight: isStewardSessionType(sessionType)
|
||||
? buildSessionInsight(sessionType)
|
||||
: state.currentInsight || buildSessionInsight(sessionType),
|
||||
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
|
||||
composerDraft: String(state.composerDraft || ''),
|
||||
attachedFiles: [],
|
||||
@@ -301,26 +325,15 @@ export function useTravelReimbursementSessionState({
|
||||
nextState.sessionType,
|
||||
resolveDefaultSessionTypeFromEntry()
|
||||
)
|
||||
messages.value = Array.isArray(nextState.messages) && nextState.messages.length
|
||||
? nextState.messages
|
||||
: [
|
||||
createWelcomeAssistantMessage(
|
||||
props.entrySource,
|
||||
linkedRequest.value,
|
||||
activeSessionType.value,
|
||||
currentUser.value
|
||||
)
|
||||
]
|
||||
messages.value = buildSessionMessages(
|
||||
refreshWelcomeQuickActions(nextState.messages, activeSessionType.value),
|
||||
activeSessionType.value
|
||||
)
|
||||
conversationId.value = String(nextState.conversationId || '').trim()
|
||||
draftClaimId.value = String(nextState.draftClaimId || '').trim()
|
||||
currentInsight.value =
|
||||
nextState.currentInsight
|
||||
|| buildWelcomeInsight(
|
||||
props.entrySource,
|
||||
linkedRequest.value,
|
||||
activeSessionType.value,
|
||||
currentUser.value
|
||||
)
|
||||
currentInsight.value = isStewardSessionType(activeSessionType.value)
|
||||
? buildSessionInsight(activeSessionType.value)
|
||||
: nextState.currentInsight || buildSessionInsight(activeSessionType.value)
|
||||
reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : []
|
||||
composerDraft.value = String(nextState.composerDraft || '')
|
||||
if (runtimeRefs.attachedFiles) {
|
||||
|
||||
Reference in New Issue
Block a user