refactor(travel): split reimbursement create workflow
完整修改内容: - 拆分 TravelReimbursementCreateView:提取审核面板纯模型、消息操作、建议动作处理、生命周期 watcher/UI 映射、小财管家运行时、续办流程和运行时文本模型,减少主组件继续堆叠业务分支。 - 调整申请预览链路:新增本地申请意图 gate,完善复杂差旅申请的大模型复核判断、交通方式缺失/候选识别、规则中心交通费用预估合并和申请冲突处理。 - 优化小财管家流程:抽出 steward typewriter 分段策略,避免 Markdown 表格逐字闪烁;补齐跨助手 carry、字段补齐续办、文本确认提交和行程规划推荐动作。 - 调整消息与样式:移除申请预览日期 chip 样式,收敛申请卡片/报销草稿消息的展示与复制、朗读、反馈入口逻辑。 - 更新测试:将源码锚点迁移到新模块,覆盖申请预览、提交确认、小财管家续办、引导流和审核抽屉相关断言。 验证: - node --check web/src/views/scripts/TravelReimbursementCreateView.js 及新增拆分模块 - npm --prefix web run build - node --test web/tests/expense-application-fast-preview.test.mjs web/tests/expense-application-submit-rich-confirm.test.mjs web/tests/travel-reimbursement-guided-flow.test.mjs 说明: - 后端/规则/容器配置/Audit 页面等工作区已有改动未纳入本提交。 - 容器内后端定向 pytest 曾运行 timeout 180s /tmp/x-financial-server-venv/bin/pytest -q <相关后端测试>,180 秒超时且超时前已有失败标记,未作为通过项记录。 - TravelReimbursementCreateView 当前仍超过 800 行,后续仍需继续拆分;本提交先把新增职责模块控制在 800 行内,阻止主类/主模块继续膨胀。
This commit is contained in:
447
web/src/views/scripts/useTravelReimbursementSuggestedActions.js
Normal file
447
web/src/views/scripts/useTravelReimbursementSuggestedActions.js
Normal file
@@ -0,0 +1,447 @@
|
||||
import {
|
||||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||
normalizeApplicationPreview,
|
||||
normalizeTransportModeOption
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
mergeComposerPrefill,
|
||||
resolveSuggestedActionPrefill
|
||||
} from '../../utils/assistantSuggestedActionPrefill.js'
|
||||
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
|
||||
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||
import {
|
||||
TRAVEL_PLANNING_ACTION_GENERATE,
|
||||
TRAVEL_PLANNING_ACTION_SKIP,
|
||||
buildTravelPlanningRecommendation
|
||||
} from '../../utils/travelApplicationPlanning.js'
|
||||
import {
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_BUDGET,
|
||||
canUseBudgetAssistantSession
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
|
||||
import {
|
||||
buildStewardFieldCompletionContinuation,
|
||||
buildStewardFieldCompletionRawText
|
||||
} from './stewardFieldCompletionModel.js'
|
||||
import { MAX_ATTACHMENTS, VISIBLE_ATTACHMENT_CHIPS } from './travelReimbursementAttachmentModel.js'
|
||||
|
||||
export const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||||
|
||||
export function useTravelReimbursementSuggestedActions({
|
||||
applicationPreviewEditor,
|
||||
attachedFiles,
|
||||
buildExpenseSceneSelectionActions,
|
||||
buildExpenseSceneSelectionMessage,
|
||||
commitApplicationPreviewEditor,
|
||||
composerDraft,
|
||||
composerFilesExpanded,
|
||||
composerTextareaRef,
|
||||
createMessage,
|
||||
currentUser,
|
||||
emit,
|
||||
handleGuidedShortcut,
|
||||
handleGuidedSuggestedAction,
|
||||
handleSceneSelectionApplicationGate,
|
||||
lockSuggestedActionMessage,
|
||||
mergeFilesWithLimit,
|
||||
messages,
|
||||
nextTick,
|
||||
openApplicationPreviewEditor,
|
||||
persistSessionState,
|
||||
resolveApplicationPreviewMissingFields,
|
||||
reviewActionBusy,
|
||||
router,
|
||||
scrollToBottom,
|
||||
sessionSwitchBusy,
|
||||
startExpenseSceneSelectionAfterIntentConfirmation,
|
||||
submitComposer,
|
||||
submitComposerInternal,
|
||||
submitting,
|
||||
switchSessionType,
|
||||
toast,
|
||||
adjustComposerTextareaHeight
|
||||
}) {
|
||||
async function runShortcut(shortcut) {
|
||||
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
|
||||
if (shortcut.targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
|
||||
toast('目前暂无权限访问预算编制助手')
|
||||
return
|
||||
}
|
||||
if (shortcut.active) {
|
||||
return
|
||||
}
|
||||
await switchSessionType(shortcut.targetSessionType)
|
||||
return
|
||||
}
|
||||
if (await handleGuidedShortcut(shortcut)) {
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = String(shortcut?.prompt || '').trim()
|
||||
if (!prompt) return
|
||||
composerDraft.value = prompt
|
||||
submitComposer()
|
||||
}
|
||||
|
||||
function isSuggestedActionSelected(message, action) {
|
||||
const selectedKey = String(message?.selectedSuggestedActionKey || '').trim()
|
||||
return Boolean(selectedKey) && selectedKey === buildSuggestedActionKey(action)
|
||||
}
|
||||
|
||||
function buildApplicationPreviewFieldAppliedText(message, fieldLabel = '', value = '') {
|
||||
const missingFields = resolveApplicationPreviewMissingFields(message)
|
||||
const resolvedFieldLabel = String(fieldLabel || '补充项').trim()
|
||||
const resolvedValue = String(value || '').trim()
|
||||
if (missingFields.length) {
|
||||
return [
|
||||
`已更新:**${resolvedFieldLabel}:${resolvedValue}**。`,
|
||||
'',
|
||||
`我重新检查了一遍,当前还需要补充:**${missingFields.join('、')}**。`,
|
||||
'',
|
||||
'请继续补齐下方核对表里的待补充项;补齐后我再继续推进申请提交。'
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`已更新:**${resolvedFieldLabel}:${resolvedValue}**。`,
|
||||
'',
|
||||
'我已经重新同步下方申请核对表和费用测算。',
|
||||
'',
|
||||
'请继续核查表格内容;如果信息无误,点击确认进入审批环节。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function isStewardApplicationPreviewFieldCompletion(targetMessage, payload = {}) {
|
||||
return Boolean(
|
||||
payload.steward_delegated_field_completion ||
|
||||
String(targetMessage?.assistantName || '').trim() === STEWARD_ASSISTANT_NAME ||
|
||||
targetMessage?.stewardPlan
|
||||
)
|
||||
}
|
||||
|
||||
async function continueStewardApplicationFieldCompletion({
|
||||
targetMessage,
|
||||
action,
|
||||
sourcePreview,
|
||||
fieldKey,
|
||||
fieldLabel,
|
||||
value
|
||||
}) {
|
||||
if (!lockSuggestedActionMessage(targetMessage, action)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const continuation = buildStewardFieldCompletionContinuation(
|
||||
targetMessage?.stewardContinuation || null,
|
||||
fieldKey,
|
||||
value
|
||||
)
|
||||
const userText = `选择${fieldLabel || '补充项'}:${value}`
|
||||
const carryText = buildStewardFieldCompletionRawText({
|
||||
preview: sourcePreview,
|
||||
fieldKey,
|
||||
fieldLabel,
|
||||
value,
|
||||
continuation
|
||||
})
|
||||
|
||||
if (!action?.suppressUserEcho) {
|
||||
messages.value.push(createMessage('user', userText))
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
await submitComposerInternal({
|
||||
rawText: carryText,
|
||||
userText,
|
||||
pendingText: '小财管家正在根据补齐信息查询票据并测算费用...',
|
||||
files: [],
|
||||
skipScopeGuard: true,
|
||||
skipApplicationModelReview: true,
|
||||
skipStewardPlan: true,
|
||||
skipUserMessage: true,
|
||||
sessionTypeOverride: SESSION_TYPE_APPLICATION,
|
||||
stewardContinuation: continuation
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
async function applyApplicationPreviewFieldAction(message, action) {
|
||||
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const fieldKey = String(payload.field_key || payload.fieldKey || '').trim()
|
||||
const fieldLabel = String(payload.field_label || payload.fieldLabel || action?.label || '').trim()
|
||||
let value = String(payload.value || action?.label || '').trim()
|
||||
const targetMessage = messages.value.find((item) => String(item.id || '') === String(message?.id || '')) || message
|
||||
const sourcePreview = targetMessage?.applicationPreview ||
|
||||
payload.applicationPreview ||
|
||||
payload.application_preview ||
|
||||
payload.preview ||
|
||||
null
|
||||
if (!sourcePreview || !fieldKey || !value) {
|
||||
return false
|
||||
}
|
||||
if (fieldKey === 'transportMode') {
|
||||
value = normalizeTransportModeOption(value, '')
|
||||
}
|
||||
if (fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(value)) {
|
||||
toast('请选择有效的出行方式。')
|
||||
return true
|
||||
}
|
||||
if (isStewardApplicationPreviewFieldCompletion(targetMessage, payload)) {
|
||||
return continueStewardApplicationFieldCompletion({
|
||||
targetMessage,
|
||||
action,
|
||||
sourcePreview,
|
||||
fieldKey,
|
||||
fieldLabel,
|
||||
value
|
||||
})
|
||||
}
|
||||
if (!lockSuggestedActionMessage(targetMessage, action)) {
|
||||
return true
|
||||
}
|
||||
|
||||
targetMessage.applicationPreview = normalizeApplicationPreview(sourcePreview)
|
||||
messages.value.push(createMessage('user', `选择${fieldLabel || '补充项'}:${value}`))
|
||||
openApplicationPreviewEditor(targetMessage, fieldKey, targetMessage.applicationPreview?.fields?.[fieldKey] || '')
|
||||
applicationPreviewEditor.value = {
|
||||
...applicationPreviewEditor.value,
|
||||
draftValue: value
|
||||
}
|
||||
await commitApplicationPreviewEditor(targetMessage)
|
||||
if (String(targetMessage.assistantName || '').trim() === STEWARD_ASSISTANT_NAME || targetMessage.stewardPlan) {
|
||||
targetMessage.assistantName = STEWARD_ASSISTANT_NAME
|
||||
targetMessage.text = buildApplicationPreviewFieldAppliedText(targetMessage, fieldLabel, value)
|
||||
const nextMeta = Array.isArray(targetMessage.meta) ? targetMessage.meta : []
|
||||
targetMessage.meta = Array.from(new Set([
|
||||
STEWARD_ASSISTANT_NAME,
|
||||
resolveApplicationPreviewMissingFields(targetMessage).length ? '等待补充' : '等待用户确认',
|
||||
...nextMeta.filter((item) => String(item || '').trim() && item !== STEWARD_ASSISTANT_NAME)
|
||||
])).slice(0, 4)
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
return true
|
||||
}
|
||||
|
||||
function pushExpenseSceneSelectionPrompt(originalMessage) {
|
||||
const sourceText = String(originalMessage || '').trim()
|
||||
if (!sourceText) {
|
||||
return
|
||||
}
|
||||
|
||||
startExpenseSceneSelectionAfterIntentConfirmation(sourceText)
|
||||
messages.value.push(createMessage('user', '我要报销'))
|
||||
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), [], {
|
||||
meta: ['等待选择场景'],
|
||||
suggestedActions: buildExpenseSceneSelectionActions(sourceText)
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
function applySuggestedActionPrefill(action) {
|
||||
const prefillText = resolveSuggestedActionPrefill(action)
|
||||
if (!prefillText) {
|
||||
return false
|
||||
}
|
||||
|
||||
composerDraft.value = mergeComposerPrefill(composerDraft.value, prefillText)
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
composerTextareaRef.value?.focus()
|
||||
})
|
||||
persistSessionState()
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSuggestedAction(message, action) {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
||||
if (message?.suggestedActionsLocked) return
|
||||
if (applySuggestedActionPrefill(action)) return
|
||||
if (await handleGuidedSuggestedAction(message, action)) return
|
||||
if (await handleSceneSelectionApplicationGate(message, action)) return
|
||||
|
||||
if (actionType === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
|
||||
await applyApplicationPreviewFieldAction(message, action)
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'open_application_detail') {
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
|
||||
if (!claimId) {
|
||||
toast('当前没有可查看的申请单据。')
|
||||
return
|
||||
}
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await router.push({
|
||||
name: 'app-document-detail',
|
||||
params: { requestId: claimId }
|
||||
})
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'open_receipt_folder') {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await router.push({ name: 'app-receiptFolder' })
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'continue_upload_with_unlinked_receipts') {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
await submitComposer({
|
||||
rawText: String(actionPayload.raw_text || composerDraft.value || '').trim(),
|
||||
files: Array.from(attachedFiles.value || []),
|
||||
skipReceiptFolderUnlinkedPrompt: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
|
||||
const sourceDraftPayload = action?.payload?.draftPayload || action?.payload?.draft_payload || null
|
||||
const recommendation = buildTravelPlanningRecommendation(sourcePreview, sourceDraftPayload)
|
||||
if (recommendation) {
|
||||
messages.value.push(createMessage('user', '生成行程规划'))
|
||||
messages.value.push(createMessage('assistant', recommendation, [], {
|
||||
meta: ['行程规划建议']
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === TRAVEL_PLANNING_ACTION_SKIP) {
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
messages.value.push(createMessage('assistant', '好的,本次先保留申请结果。后续需要规划交通或酒店时,可以继续在这里告诉我。', [], {
|
||||
meta: ['暂不规划']
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
persistSessionState()
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const targetSessionType = String(actionPayload.session_type || '').trim()
|
||||
if (!targetSessionType) return
|
||||
if (targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
|
||||
toast('目前暂无权限访问预算编制助手')
|
||||
return
|
||||
}
|
||||
const carryText = String(actionPayload.carry_text || '').trim()
|
||||
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
if (String(actionPayload.steward_plan_id || '').trim()) {
|
||||
const confirmedByText = Boolean(action.confirmedByText)
|
||||
delete action.confirmedByText
|
||||
await submitComposerInternal({
|
||||
rawText: carryText,
|
||||
userText: action.label || '确定',
|
||||
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
|
||||
? '小财管家正在调用申请助手生成申请单核对结果...'
|
||||
: '小财管家正在调用报销助手整理报销核对结果...',
|
||||
files: carryFiles,
|
||||
skipScopeGuard: true,
|
||||
skipApplicationModelReview: targetSessionType === SESSION_TYPE_APPLICATION,
|
||||
skipStewardSlotDecision: targetSessionType === SESSION_TYPE_APPLICATION,
|
||||
skipStewardPlan: true,
|
||||
skipUserMessage: confirmedByText,
|
||||
sessionTypeOverride: targetSessionType,
|
||||
stewardContinuation: {
|
||||
planId: String(actionPayload.steward_plan_id || '').trim(),
|
||||
currentTaskId: String(actionPayload.steward_next_task_id || '').trim(),
|
||||
currentTask: actionPayload.steward_current_task || null,
|
||||
remainingTasks: Array.isArray(actionPayload.steward_remaining_tasks)
|
||||
? actionPayload.steward_remaining_tasks
|
||||
: []
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
await switchSessionType(targetSessionType)
|
||||
if (carryText) {
|
||||
composerDraft.value = carryText
|
||||
}
|
||||
if (carryFiles.length) {
|
||||
const fileMergeResult = mergeFilesWithLimit([], carryFiles, MAX_ATTACHMENTS)
|
||||
attachedFiles.value = fileMergeResult.files
|
||||
composerFilesExpanded.value = fileMergeResult.files.length > VISIBLE_ATTACHMENT_CHIPS
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
persistSessionState()
|
||||
if (actionPayload.auto_submit && carryText) {
|
||||
await submitComposer({
|
||||
rawText: carryText,
|
||||
userText: action.label || '确认继续处理',
|
||||
pendingText: '正在按确认内容继续处理...',
|
||||
files: carryFiles,
|
||||
skipScopeGuard: true
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType === 'confirm_expense_intent') {
|
||||
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
|
||||
if (!originalMessage) return
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
pushExpenseSceneSelectionPrompt(originalMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (actionType !== 'select_expense_type') {
|
||||
const fallbackText = String(action?.description || action?.label || '').trim()
|
||||
if (!fallbackText) return
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await submitComposer({
|
||||
rawText: fallbackText,
|
||||
userText: fallbackText,
|
||||
pendingText: '正在继续处理...'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const expenseType = String(actionPayload.expense_type || '').trim()
|
||||
const expenseTypeLabel = String(actionPayload.expense_type_label || action?.label || '').trim()
|
||||
const originalMessage = String(actionPayload.original_message || message?.text || '').trim()
|
||||
if (!expenseTypeLabel || !originalMessage) return
|
||||
|
||||
if (!lockSuggestedActionMessage(message, action)) return
|
||||
await submitComposer({
|
||||
rawText: `${originalMessage}\n用户选择报销场景:${expenseTypeLabel}`,
|
||||
userText: `选择${expenseTypeLabel}`,
|
||||
pendingText: `已选择${expenseTypeLabel},正在按该场景识别...`,
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
draft_claim_id: '',
|
||||
user_input_text: originalMessage,
|
||||
expense_scene_selection: {
|
||||
expense_type: expenseType,
|
||||
expense_type_label: expenseTypeLabel,
|
||||
original_message: originalMessage
|
||||
},
|
||||
review_form_values: {
|
||||
expense_type: expenseTypeLabel
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
handleSuggestedAction,
|
||||
isSuggestedActionSelected,
|
||||
runShortcut
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user