feat(ai): unify verified expense application workflow

This commit is contained in:
caoxiaozhu
2026-07-14 16:03:05 +08:00
parent 5b24630710
commit 211f85d981
33 changed files with 2793 additions and 405 deletions

View File

@@ -1131,6 +1131,52 @@
margin: 0;
}
.application-preview-decision-status {
margin-top: 28px;
padding: 10px 12px;
border: 1px solid rgba(217, 119, 6, 0.2);
border-radius: 10px;
background: rgba(245, 158, 11, 0.08);
color: #92400e;
font-size: 12px;
font-weight: 720;
line-height: 1.6;
}
.application-preview-action-row {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
.application-preview-secondary-btn {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 12px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3);
border-radius: 8px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
color: var(--theme-primary-active, #255b7d);
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.application-preview-secondary-btn:hover:not(:disabled),
.application-preview-secondary-btn:focus-visible {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.52);
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
}
.application-preview-secondary-btn:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.message-answer-markdown :deep(a) {
color: var(--theme-primary-active, #255b7d);
font-weight: 850;

View File

@@ -246,11 +246,47 @@
<span class="application-preview-missing-suffix">补齐后我再帮您提交申请</span>
</div>
<div
v-else-if="ui.buildApplicationPreviewFooterText(message)"
v-else-if="ui.canSubmitApplicationPreview(message) && ui.buildApplicationPreviewFooterText(message)"
class="application-preview-footer message-answer-content message-answer-markdown"
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
@click="ui.handleAssistantMarkdownClick($event, message)"
></div>
<div
v-else-if="message.applicationPreview.readyToSubmit"
class="application-preview-decision-status"
role="status"
>
<span v-if="message.applicationPreview.decisionTrackingStatus === 'registering'">
正在由服务端核验并签发申请预览请稍候
</span>
<span v-else>
当前申请预览尚未通过服务端签发为避免重复或错误建单保存与提交已暂停
</span>
</div>
<div
v-if="message.applicationPreview.readyToSubmit"
class="application-preview-action-row"
>
<button
v-if="ui.canRetryApplicationPreviewDecision(message)"
type="button"
class="application-preview-secondary-btn"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.retryApplicationPreviewDecision(message)"
>
<i class="mdi mdi-refresh"></i>
<span>重新签发</span>
</button>
<button
type="button"
class="application-preview-secondary-btn"
:disabled="!ui.canSaveApplicationPreviewDraft(message)"
@click="ui.saveApplicationPreviewDraft(message)"
>
<i class="mdi mdi-content-save-outline"></i>
<span>保存草稿</span>
</button>
</div>
</div>
</Transition>

View File

@@ -53,6 +53,15 @@ export function useWorkbenchAiActionRouter({
const actionType = String(action?.action_type || '').trim()
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
if (
actionPayload.steward_execute_action
&& String(actionPayload.steward_current_task?.task_type || '').trim() === 'expense_application'
) {
// 费用申请必须先进入结构化预览签发流程,不能从 Steward 计划直接执行保存/提交。
aiExpenseDraft.value = null
void expenseFlow.startAiApplicationPreviewFromAction(actionPayload)
return
}
if (actionPayload.steward_execute_action) {
return executeInlineStewardAction(action, sourceMessage)
}

View File

@@ -105,7 +105,10 @@ export function buildAiApplicationPreviewActionPayload({
currentUser = {},
conversationId = '',
draftPayload = null,
requestId = ''
requestId = '',
entrySource = 'workbench_ai_inline',
source = 'workbench',
requestSource = 'user_message'
} = {}) {
const normalizedPreview = normalizeApplicationPreview(applicationPreview || {})
const message = buildAiApplicationPreviewActionText(actionType, normalizedPreview)
@@ -128,7 +131,7 @@ export function buildAiApplicationPreviewActionPayload({
: []
return {
source: 'user_message',
source: normalizeText(requestSource) || 'user_message',
user_id: username,
conversation_id: normalizeText(conversationId) || null,
action_type: isSubmit ? 'submit' : 'save_draft',
@@ -158,8 +161,8 @@ export function buildAiApplicationPreviewActionPayload({
finance_owner_name: resolveUserValue(currentUser, 'financeOwnerName', 'finance_owner_name'),
...buildClientTimeContext(),
session_type: 'application',
entry_source: 'workbench_ai_inline',
source: 'workbench',
entry_source: normalizeText(entrySource) || 'workbench_ai_inline',
source: normalizeText(source) || 'workbench',
document_type: 'expense_application',
application_stage: 'expense_application',
user_input_text: message,

View File

@@ -29,6 +29,7 @@ import { useTravelReimbursementMessageActions } from './useTravelReimbursementMe
import { useTravelReimbursementSuggestedActions } from './useTravelReimbursementSuggestedActions.js'
import { useStewardPlanFlow } from './useStewardPlanFlow.js'
import { useTravelReimbursementApplicationSubmitConfirm } from './useTravelReimbursementApplicationSubmitConfirm.js'
import { useTravelReimbursementApplicationPreviewActions } from './useTravelReimbursementApplicationPreviewActions.js'
import { useTravelReimbursementStewardRuntimeDecision } from './useTravelReimbursementStewardRuntimeDecision.js'
import { useTravelReimbursementApplicationPreviewDateEditor } from './useTravelReimbursementApplicationPreviewDateEditor.js'
import { buildStewardFieldItems, formatStewardMissingFieldList, formatStewardOntologyFields } from './stewardPlanModel.js'
@@ -238,6 +239,27 @@ export default {
calculateTravelReimbursement,
currentUser
})
const {
canRetryApplicationPreviewDecision,
canSaveApplicationPreviewDraft,
canSubmitApplicationPreview,
registerApplicationPreviewDecision,
retryApplicationPreviewDecision,
runApplicationPreviewAction,
saveApplicationPreviewDraft
} = useTravelReimbursementApplicationPreviewActions({
conversationId,
currentUser,
draftClaimId,
emitDraftSaved: (payload) => emit('draft-saved', payload),
entrySource: props.entrySource,
linkedRequest,
persistSessionState,
reviewActionBusy,
sessionSwitchBusy,
submitting,
toast
})
let applyLinkedApplicationPreviewDateSelection = () => false
let openApplicationPreviewEditorFromUi = openApplicationPreviewEditor
@@ -376,7 +398,7 @@ export default {
resolveCurrentUserId
})
const { confirmPendingAttachmentAssociationInternal, submitComposerInternal } = useTravelReimbursementSubmitComposer({
MAX_ATTACHMENTS, activeReviewPayload, activeSessionType, adjustComposerTextareaHeight, attachedFiles, buildAgentInsight, buildClientTimeContext, buildComposerBusinessTimeContext, buildComposerFilePreviews, buildDraftAssociationQueryPayload, buildErrorInsight, buildExpenseIntentConfirmationActions, buildExpenseIntentConfirmationMessage, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, buildMessageMeta, buildOcrDocumentsFromReviewPayload, buildOcrSummaryFromDocuments, buildReviewFormContextFromPayload, clearAttachedFiles, clearFlowSimulationTimers, completeFlowResult, completeFlowStep, composerBusinessTimeDraftTouched, composerBusinessTimeTags, composerDraft, composerUploadIntent, conversationId, createMessage, currentInsight, currentUser, refreshCurrentUserFromBackend, draftClaimId, extractReviewAttachmentNames, failCurrentFlowStep, fetchExpenseClaims, fileInputRef, flowRunId, insightPanelCollapsed, isKnowledgeSession, linkedRequest, mergeBusinessTimeIntoExtraContext, mergeFilePreviews, mergeFilesWithLimit, mergeUploadAttachmentNames, mergeUploadOcrDocuments, messages, nextTick, normalizeExpenseQueryPayload, persistSessionState, props, recognizeOcrFiles, refreshFlowRunDetail, rememberFilePreviews, replaceMessage, resolveComposerDisplaySubmitText, resetFlowRun, resolveComposerSubmitText, reviewInlineForm, runOrchestrator, scrollToBottom, sessionSwitchBusy, shouldRequestExpenseIntentConfirmation, shouldRequestExpenseSceneSelection, startExpenseClaimDraftFlowStep, startExpenseIntentConfirmationFlowPreview, startExpenseSceneSelectionFlowPreview, startFlowStep, startSemanticFlowPreview, submitting, syncComposerFilesToDraft, emitOperationCompleted, emitDraftSaved: (payload) => emit('draft-saved', payload), emitRequestUpdated: (payload) => emit('request-updated', payload), toast
MAX_ATTACHMENTS, activeReviewPayload, activeSessionType, adjustComposerTextareaHeight, attachedFiles, buildAgentInsight, buildClientTimeContext, buildComposerBusinessTimeContext, buildComposerFilePreviews, buildDraftAssociationQueryPayload, buildErrorInsight, buildExpenseIntentConfirmationActions, buildExpenseIntentConfirmationMessage, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, buildMessageMeta, buildOcrDocumentsFromReviewPayload, buildOcrSummaryFromDocuments, buildReviewFormContextFromPayload, clearAttachedFiles, clearFlowSimulationTimers, completeFlowResult, completeFlowStep, composerBusinessTimeDraftTouched, composerBusinessTimeTags, composerDraft, composerUploadIntent, conversationId, createMessage, currentInsight, currentUser, refreshCurrentUserFromBackend, draftClaimId, extractReviewAttachmentNames, failCurrentFlowStep, fetchExpenseClaims, fileInputRef, flowRunId, insightPanelCollapsed, isKnowledgeSession, linkedRequest, mergeBusinessTimeIntoExtraContext, mergeFilePreviews, mergeFilesWithLimit, mergeUploadAttachmentNames, mergeUploadOcrDocuments, messages, nextTick, normalizeExpenseQueryPayload, persistSessionState, props, recognizeOcrFiles, refreshFlowRunDetail, registerApplicationPreviewDecision, rememberFilePreviews, replaceMessage, resolveComposerDisplaySubmitText, resetFlowRun, resolveComposerSubmitText, reviewInlineForm, runOrchestrator, scrollToBottom, sessionSwitchBusy, shouldRequestExpenseIntentConfirmation, shouldRequestExpenseSceneSelection, startExpenseClaimDraftFlowStep, startExpenseIntentConfirmationFlowPreview, startExpenseSceneSelectionFlowPreview, startFlowStep, startSemanticFlowPreview, submitting, syncComposerFilesToDraft, emitOperationCompleted, emitDraftSaved: (payload) => emit('draft-saved', payload), emitRequestUpdated: (payload) => emit('request-updated', payload), toast
})
let submitComposerFromMessageHandlers = null
async function submitComposer(options = {}) {
@@ -551,7 +573,7 @@ export default {
const { closeApplicationSubmitConfirm, confirmApplicationSubmit, openApplicationSubmitConfirm, resolveStewardMissingFieldItems } = useTravelReimbursementApplicationSubmitConfirm({
activeSessionType, applicationSubmitConfirmDialog, buildStewardFieldItems, createMessage, emitDraftSaved: (payload) => emit('draft-saved', payload), formatStewardMissingFieldList, formatStewardOntologyFields, linkedRequest, messages, nextTick, persistSessionState, reviewActionBusy, scrollToBottom, submitComposer, submitting, toast
activeSessionType, applicationSubmitConfirmDialog, buildStewardFieldItems, createMessage, emitDraftSaved: (payload) => emit('draft-saved', payload), formatStewardMissingFieldList, formatStewardOntologyFields, linkedRequest, messages, nextTick, persistSessionState, reviewActionBusy, runApplicationPreviewAction, scrollToBottom, submitComposer, submitting, toast
})
const { handleApplicationSubmitConfirmationText, handleStewardRuntimeDecision } = useTravelReimbursementStewardRuntimeDecision({
@@ -568,7 +590,7 @@ export default {
submitComposerFromMessageHandlers = submitComposerMessageHandler
const { insightPanelUi, messageItemUi } = useTravelReimbursementCreateViewUi({
ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps
ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canRetryApplicationPreviewDecision, canSaveApplicationPreviewDraft, canSubmitApplicationPreview, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, retryApplicationPreviewDecision, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveApplicationPreviewDraft, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps
})
return {

View File

@@ -121,7 +121,8 @@ export function normalizeInitialConversationMessages(conversation) {
queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null,
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null,
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : []
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [],
applicationPreview: item.role === 'assistant' ? result?.application_preview || null : null
})
})
return markResolvedSuggestedActionMessages(restoredMessages)
@@ -135,6 +136,15 @@ export function normalizeSnapshotMessage(message) {
delete extras.role
delete extras.text
delete extras.attachments
if (!extras.applicationPreview && extras.pendingApplicationPreviewDecision) {
extras.applicationPreview = {
...extras.pendingApplicationPreviewDecision,
decisionId: '',
decisionTrackingStatus: 'registration_failed',
decisionRegistrationError: '上次签发请求未完成,请重新签发。'
}
}
delete extras.pendingApplicationPreviewDecision
return createMessage(role, text, attachments, extras)
}
@@ -164,12 +174,14 @@ export function serializeSessionMessages(messages) {
draftPayload: message.draftPayload || null,
reviewPayload: message.reviewPayload || null,
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
applicationPreview: message.applicationPreview || null,
budgetReport: message.budgetReport || null,
stewardPlan: message.stewardPlan || null,
operationFeedback: message.operationFeedback || null,
assistantName: message.assistantName || '',
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
applicationPreview: message.applicationPreview || null,
pendingApplicationPreviewDecision: message.pendingApplicationPreviewDecision || null,
applicationSubmitConfirmed: Boolean(message.applicationSubmitConfirmed),
budgetReport: message.budgetReport || null,
stewardPlan: message.stewardPlan || null,
operationFeedback: message.operationFeedback || null,
assistantName: message.assistantName || '',
assistantVariant: message.assistantVariant || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []

View File

@@ -34,6 +34,7 @@ export async function handleLocalApplicationPreviewFlow({
options,
persistSessionState,
rawText,
registerApplicationPreviewDecision = async ({ applicationPreview }) => applicationPreview,
replaceMessage,
resetFlowRun,
resetStewardDelegatedInsightState,
@@ -108,7 +109,7 @@ export async function handleLocalApplicationPreviewFlow({
submitting.value = true
try {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
const { applicationPreview: generatedApplicationPreview, meta } = await buildApplicationPreviewWithModelReview(
rawText,
selectedBusinessTimeContext,
effectiveSessionType,
@@ -120,7 +121,7 @@ export async function handleLocalApplicationPreviewFlow({
let applicationDateConflict = null
try {
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims)
applicationDateConflict = findOverlappingApplicationClaim(generatedApplicationPreview, existingClaims)
} catch (error) {
console.warn('Failed to check overlapping application dates:', error)
}
@@ -157,7 +158,7 @@ export async function handleLocalApplicationPreviewFlow({
{
sessionType: effectiveSessionType,
rawText,
applicationPreview,
applicationPreview: generatedApplicationPreview,
stewardContinuation: options.stewardContinuation || null
}
)
@@ -179,6 +180,7 @@ export async function handleLocalApplicationPreviewFlow({
Date.now() - reviewStartedAt
)
}
let applicationPreview = generatedApplicationPreview
if (stewardDelegated) {
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
applicationPreview,
@@ -197,6 +199,13 @@ export async function handleLocalApplicationPreviewFlow({
const pauseForMissingFields = slotDecision
? String(slotDecision.next_action || '').trim() === 'ask_user'
: localPauseForMissingFields
if (!pauseForMissingFields) {
applicationPreview = await registerApplicationPreviewDecision({
applicationPreview,
sourceText: rawText,
targetMessage: pendingMessage
})
}
const stewardApplicationText = buildStewardSlotDecisionMessage(
slotDecision,
applicationPreview,
@@ -221,6 +230,11 @@ export async function handleLocalApplicationPreviewFlow({
}
)
} else {
applicationPreview = await registerApplicationPreviewDecision({
applicationPreview,
sourceText: rawText,
targetMessage: pendingMessage
})
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildLocalApplicationPreviewMessage(applicationPreview),

View File

@@ -0,0 +1,354 @@
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
createAiApplicationRequestId,
registerAiApplicationPreviewDecision,
runAiApplicationPreviewAction
} from '../../services/aiApplicationPreviewActions.js'
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
const DECISION_STATUS_REGISTERED = 'registered'
const DECISION_STATUS_REGISTERING = 'registering'
const DECISION_STATUS_FAILED = 'registration_failed'
const DECISION_STATUS_UNREGISTERED = 'unregistered'
function resolveRefValue(value, fallback = null) {
if (value && typeof value === 'object' && 'value' in value) {
return value.value ?? fallback
}
return value ?? fallback
}
function normalizeText(value) {
return String(value || '').trim()
}
function resolveErrorMessage(error, fallback) {
return normalizeText(error?.message || error) || fallback
}
function resolveDecisionSourceText(preview = {}, sourceText = '') {
return normalizeText(
sourceText
|| preview.decisionSourceText
|| preview.sourceText
|| preview.originalMessage
)
}
function resolveApplicationDraftPayload(message = {}, draftClaimId = '', linkedRequest = null) {
if (message?.draftPayload && typeof message.draftPayload === 'object') {
return message.draftPayload
}
const request = resolveRefValue(linkedRequest, {}) || {}
const claimId = normalizeText(
draftClaimId
|| request.claimId
|| request.claim_id
)
if (!claimId) return null
return {
claim_id: claimId,
claim_no: normalizeText(request.claimNo || request.claim_no || request.id)
}
}
export function useTravelReimbursementApplicationPreviewActions({
conversationId,
currentUser,
draftClaimId,
emitDraftSaved,
entrySource = 'application',
linkedRequest,
persistSessionState = () => {},
reviewActionBusy,
sessionSwitchBusy,
submitting,
toast = () => {}
} = {}) {
function isInteractionBusy() {
return Boolean(
resolveRefValue(submitting, false)
|| resolveRefValue(reviewActionBusy, false)
|| resolveRefValue(sessionSwitchBusy, false)
)
}
function isRegisteredApplicationPreview(message) {
const preview = normalizeApplicationPreview(message?.applicationPreview || {})
return Boolean(
preview.readyToSubmit
&& normalizeText(preview.decisionId)
&& preview.decisionTrackingStatus === DECISION_STATUS_REGISTERED
)
}
function canSubmitApplicationPreview(message) {
return isRegisteredApplicationPreview(message)
}
function canSaveApplicationPreviewDraft(message) {
return !isInteractionBusy() && isRegisteredApplicationPreview(message)
}
function canRetryApplicationPreviewDecision(message) {
if (isInteractionBusy() || !message?.applicationPreview) return false
if (message.applicationSubmitConfirmed) return false
if (normalizeText(message?.draftPayload?.status) === 'submitted') return false
const preview = normalizeApplicationPreview(message.applicationPreview)
return Boolean(
preview.readyToSubmit
&& !normalizeText(preview.decisionId)
&& [DECISION_STATUS_FAILED, DECISION_STATUS_UNREGISTERED, 'degraded', 'consumed', ''].includes(
normalizeText(preview.decisionTrackingStatus)
)
)
}
function buildPendingDecisionPreview(applicationPreview = {}, sourceText = '') {
const normalized = normalizeApplicationPreview(applicationPreview)
return normalizeApplicationPreview({
...normalized,
decisionId: '',
decisionIssueRequestId: normalizeText(normalized.decisionIssueRequestId)
|| createAiApplicationRequestId('preview'),
decisionActionRequestId: normalizeText(normalized.decisionActionRequestId)
|| createAiApplicationRequestId('action'),
decisionSourceText: resolveDecisionSourceText(normalized, sourceText),
decisionTrackingStatus: DECISION_STATUS_REGISTERING,
decisionRegistrationError: ''
})
}
async function registerApplicationPreviewDecision({
applicationPreview,
sourceText = '',
targetMessage = null,
preserveCurrentFields = false
} = {}) {
const normalized = normalizeApplicationPreview(applicationPreview || {})
if (!normalized.readyToSubmit) {
return normalizeApplicationPreview({
...normalized,
decisionId: '',
decisionSourceText: resolveDecisionSourceText(normalized, sourceText),
decisionTrackingStatus: DECISION_STATUS_UNREGISTERED
})
}
if (
normalizeText(normalized.decisionId)
&& normalized.decisionTrackingStatus === DECISION_STATUS_REGISTERED
) {
return normalized
}
const pendingPreview = buildPendingDecisionPreview(normalized, sourceText)
if (targetMessage) {
// 网络请求前先把幂等键写入本地会话;超时或刷新后仍可用同一请求继续签发。
targetMessage.pendingApplicationPreviewDecision = pendingPreview
persistSessionState()
}
try {
const registered = await registerAiApplicationPreviewDecision({
applicationPreview: pendingPreview,
conversationId: normalizeText(resolveRefValue(conversationId, '')),
message: pendingPreview.decisionSourceText,
requestId: pendingPreview.decisionIssueRequestId
})
const nextPreview = normalizeApplicationPreview({
...registered,
...(preserveCurrentFields ? { fields: pendingPreview.fields } : {}),
decisionIssueRequestId: pendingPreview.decisionIssueRequestId,
decisionActionRequestId: pendingPreview.decisionActionRequestId,
decisionSourceText: pendingPreview.decisionSourceText,
decisionTrackingStatus: DECISION_STATUS_REGISTERED,
decisionRegistrationError: ''
})
if (targetMessage) {
delete targetMessage.pendingApplicationPreviewDecision
targetMessage.applicationPreview = nextPreview
persistSessionState()
}
return nextPreview
} catch (error) {
const failedPreview = normalizeApplicationPreview({
...pendingPreview,
decisionId: '',
decisionSource: '',
decisionExpiresAt: '',
decisionTrackingStatus: DECISION_STATUS_FAILED,
decisionRegistrationError: resolveErrorMessage(error, '服务端暂未完成申请预览签发。')
})
if (targetMessage) {
delete targetMessage.pendingApplicationPreviewDecision
targetMessage.applicationPreview = failedPreview
persistSessionState()
}
return failedPreview
}
}
async function retryApplicationPreviewDecision(message) {
if (!canRetryApplicationPreviewDecision(message)) return false
reviewActionBusy.value = true
try {
const preview = await registerApplicationPreviewDecision({
applicationPreview: message.applicationPreview,
sourceText: resolveDecisionSourceText(message.applicationPreview),
targetMessage: message,
preserveCurrentFields: true
})
if (preview.decisionTrackingStatus === DECISION_STATUS_REGISTERED) {
message.text = message.text || ''
toast('申请预览已重新签发,可以保存或提交。')
return true
}
toast(preview.decisionRegistrationError || '申请预览签发失败,请稍后重试。')
return false
} finally {
reviewActionBusy.value = false
persistSessionState()
}
}
async function runApplicationPreviewAction(actionType, message) {
if (!message?.applicationPreview) {
throw new Error('当前没有可处理的申请预览。')
}
let preview = normalizeApplicationPreview(message.applicationPreview)
if (!preview.readyToSubmit) {
throw new Error(`请先补充:${preview.missingFields.join('、') || '申请必填信息'}`)
}
if (
!normalizeText(preview.decisionId)
|| preview.decisionTrackingStatus !== DECISION_STATUS_REGISTERED
) {
// structured preview 必须持有服务端签发结果,禁止降级为客户端观察动作。
throw new Error('申请预览尚未通过服务端签发,请先点击“重新签发”。')
}
const actionRequestId = normalizeText(preview.decisionActionRequestId)
|| createAiApplicationRequestId('action')
preview = normalizeApplicationPreview({
...preview,
decisionActionRequestId: actionRequestId
})
message.applicationPreview = preview
persistSessionState()
let payload
try {
payload = await runAiApplicationPreviewAction({
actionType,
applicationPreview: preview,
currentUser: resolveRefValue(currentUser, {}) || {},
conversationId: normalizeText(resolveRefValue(conversationId, '')),
draftPayload: resolveApplicationDraftPayload(
message,
normalizeText(resolveRefValue(draftClaimId, '')),
linkedRequest
),
requestId: actionRequestId,
entrySource: normalizeText(entrySource) || 'application',
source: 'financial_assistant'
})
} catch (error) {
const errorText = resolveErrorMessage(error, '申请动作执行失败。')
if (/已过期|已被.*消费|不可使用|不存在或不属于|重新生成预览/.test(errorText)) {
message.applicationPreview = normalizeApplicationPreview({
...preview,
decisionId: '',
decisionSource: '',
decisionExpiresAt: '',
decisionTrackingStatus: DECISION_STATUS_FAILED,
decisionRegistrationError: `${errorText} 请重新签发后继续。`,
decisionIssueRequestId: createAiApplicationRequestId('preview'),
decisionActionRequestId: ''
})
persistSessionState()
}
throw error
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const nextDraftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: null
if (nextDraftPayload) {
message.draftPayload = nextDraftPayload
const nextClaimId = normalizeText(nextDraftPayload.claim_id || nextDraftPayload.claimId)
if (nextClaimId && draftClaimId && typeof draftClaimId === 'object' && 'value' in draftClaimId) {
draftClaimId.value = nextClaimId
}
}
const nextDecisionId = normalizeText(result.decision_id)
const isSaveDraft = actionType === AI_APPLICATION_ACTION_SAVE_DRAFT
message.applicationPreview = normalizeApplicationPreview({
...preview,
decisionId: nextDecisionId,
decisionSource: nextDecisionId ? 'server_draft' : '',
decisionExpiresAt: normalizeText(result.decision_expires_at),
decisionTrackingStatus: nextDecisionId
? DECISION_STATUS_REGISTERED
: isSaveDraft
? DECISION_STATUS_FAILED
: 'consumed',
decisionRegistrationError: isSaveDraft && !nextDecisionId
? '草稿已保存,但后续申请预览签发失败,请重新签发后再提交。'
: '',
decisionIssueRequestId: isSaveDraft && !nextDecisionId
? createAiApplicationRequestId('preview')
: '',
decisionActionRequestId: nextDecisionId ? createAiApplicationRequestId('action') : ''
})
persistSessionState()
return payload
}
async function saveApplicationPreviewDraft(message) {
if (!canSaveApplicationPreviewDraft(message)) return null
reviewActionBusy.value = true
try {
const payload = await runApplicationPreviewAction(
AI_APPLICATION_ACTION_SAVE_DRAFT,
message
)
const draftPayload = payload?.result?.draft_payload || {}
const claimId = normalizeText(draftPayload.claim_id || draftPayload.claimId)
const claimNo = normalizeText(draftPayload.claim_no || draftPayload.claimNo)
if (claimId || claimNo) {
emitDraftSaved?.({
claimId,
claimNo,
status: 'draft',
approvalStage: normalizeText(draftPayload.approval_stage) || '待提交',
documentType: 'application'
})
}
toast('申请草稿已保存。')
return payload
} catch (error) {
toast(resolveErrorMessage(error, '申请草稿保存失败,请稍后重试。'))
return null
} finally {
reviewActionBusy.value = false
persistSessionState()
}
}
return {
canRetryApplicationPreviewDecision,
canSaveApplicationPreviewDraft,
canSubmitApplicationPreview,
registerApplicationPreviewDecision,
retryApplicationPreviewDecision,
runApplicationPreviewAction,
saveApplicationPreviewDraft
}
}
export {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT
}

View File

@@ -9,6 +9,7 @@ import {
} from '../../utils/travelApplicationPlanning.js'
import { SESSION_TYPE_APPLICATION } from './travelReimbursementConversationModel.js'
import { useTravelReimbursementStewardFollowupFlow } from './travelReimbursementStewardFollowupFlow.js'
import { AI_APPLICATION_ACTION_SUBMIT } from './useTravelReimbursementApplicationPreviewActions.js'
export function useTravelReimbursementApplicationSubmitConfirm({
activeSessionType,
@@ -23,6 +24,7 @@ export function useTravelReimbursementApplicationSubmitConfirm({
nextTick,
persistSessionState,
reviewActionBusy,
runApplicationPreviewAction,
scrollToBottom,
submitComposer,
submitting,
@@ -103,31 +105,36 @@ export function useTravelReimbursementApplicationSubmitConfirm({
const stewardSubmitContinuation = message?.stewardContinuation || null
reviewActionBusy.value = true
try {
const payload = await submitComposer({
rawText: applicationSubmitText,
userText: String(options.userText || '').trim() || '确认提交',
skipUserMessage: Boolean(options.skipUserMessage),
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
skipStewardPlan: true,
stewardContinuation: stewardSubmitContinuation,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText,
...(applicationEditClaimId
? {
application_edit_claim_id: applicationEditClaimId,
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
application_edit_mode: true,
draft_claim_id: applicationEditClaimId,
selected_claim_id: applicationEditClaimId
}
: {})
}
})
const payload = applicationPreview
? await runApplicationPreviewAction(
AI_APPLICATION_ACTION_SUBMIT,
message
)
: await submitComposer({
rawText: applicationSubmitText,
userText: String(options.userText || '').trim() || '确认提交',
skipUserMessage: Boolean(options.skipUserMessage),
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
skipStewardPlan: true,
stewardContinuation: stewardSubmitContinuation,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText,
...(applicationEditClaimId
? {
application_edit_claim_id: applicationEditClaimId,
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
application_edit_mode: true,
draft_claim_id: applicationEditClaimId,
selected_claim_id: applicationEditClaimId
}
: {})
}
})
const draftPayload = payload?.result?.draft_payload || {}
const claimNo = String(draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claim_id || '').trim()
@@ -140,6 +147,7 @@ export function useTravelReimbursementApplicationSubmitConfirm({
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
documentType: 'application'
})
persistSessionState()
}
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
@@ -162,6 +170,8 @@ export function useTravelReimbursementApplicationSubmitConfirm({
if (stewardFollowup) {
await pushStewardContinuationMessage(stewardFollowup)
}
} catch (error) {
toast(String(error?.message || '').trim() || '申请提交失败,请稍后重试。')
} finally {
reviewActionBusy.value = false
}

View File

@@ -27,6 +27,11 @@ export function useTravelReimbursementCreateViewUi(ctx) {
canApplyApplicationPreviewDateSelection: ctx.canApplyApplicationPreviewDateSelection,
handleApplicationPreviewEditorKeydown: ctx.handleApplicationPreviewEditorKeydown,
buildApplicationPreviewFooterText: ctx.buildApplicationPreviewFooterText,
canRetryApplicationPreviewDecision: ctx.canRetryApplicationPreviewDecision,
canSaveApplicationPreviewDraft: ctx.canSaveApplicationPreviewDraft,
canSubmitApplicationPreview: ctx.canSubmitApplicationPreview,
retryApplicationPreviewDecision: ctx.retryApplicationPreviewDecision,
saveApplicationPreviewDraft: ctx.saveApplicationPreviewDraft,
isApplicationDraftPayload: ctx.isApplicationDraftPayload,
resolveApplicationDraftStatusLabel: ctx.resolveApplicationDraftStatusLabel,
buildApplicationDraftSummaryItems: ctx.buildApplicationDraftSummaryItems,

View File

@@ -74,6 +74,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
normalizeExpenseQueryPayload,
persistSessionState,
props,
registerApplicationPreviewDecision,
recognizeOcrFiles,
refreshCurrentUserFromBackend,
refreshFlowRunDetail,
@@ -329,6 +330,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
options,
persistSessionState,
rawText,
registerApplicationPreviewDecision,
replaceMessage,
resetFlowRun,
resetStewardDelegatedInsightState,