fix: 优化报销创建页面样式与洞察面板交互

修复侧边栏和审计视图样式细节,完善差旅报销洞察面板和消息
组件布局,优化报销创建页面会话管理和流程状态持久化,增强
申请预览工具函数和导航图标,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-27 10:32:08 +08:00
parent 2dcc72102d
commit b1a9c8a194
21 changed files with 922 additions and 148 deletions

View File

@@ -163,16 +163,19 @@ import {
HOT_KNOWLEDGE_QUESTIONS,
INTENT_LABELS,
SCENARIO_LABELS,
SESSION_TYPE_BUDGET,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
canUseBudgetAssistantSession,
aiAvatar,
buildExpenseIntentConfirmationMessage,
buildExpenseSceneSelectionMessage,
buildMessageMeta,
buildWelcomeInsight,
createMessage,
filterAssistantSessionModes,
resolveAssistantSessionMode,
resolveKnowledgeRankLabel,
resolveKnowledgeRankTone,
@@ -610,11 +613,12 @@ export default {
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
const assistantHeaderTitle = computed(() => activeAssistantMode.value?.label || '财务助手')
const assistantHeaderDescription = computed(() => activeAssistantMode.value?.description || '个人财务中心')
const assistantHeaderTitle = computed(() => '个人工作台')
const assistantHeaderDescription = computed(() => '个人工作窗,一站式费控解决枢纽')
const {
flowRunId,
flowSteps,
visibleFlowSteps,
flowRefreshBusy,
completedFlowStepCount,
flowOverallStatusTone,
@@ -1084,7 +1088,7 @@ export default {
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
const shortcuts = computed(() =>
ASSISTANT_SESSION_MODE_OPTIONS.map((mode) => ({
filterAssistantSessionModes(ASSISTANT_SESSION_MODE_OPTIONS, currentUser.value).map((mode) => ({
label: mode.label,
icon: mode.icon,
action: 'switch_view',
@@ -1099,7 +1103,11 @@ export default {
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
// ? REVIEW_DRAWER_MODE_RISK
// : REVIEW_DRAWER_MODE_REVIEW
const shouldKeepFlowDrawer = reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && flowSteps.value.length > 0
resetReviewDrawerFromPayload(payload)
if (shouldKeepFlowDrawer) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
}
},
{ immediate: true }
)
@@ -1359,6 +1367,10 @@ export default {
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
}
@@ -1444,6 +1456,10 @@ export default {
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
@@ -1714,6 +1730,14 @@ export default {
return buildApplicationPreviewFooterMessage(message.applicationPreview)
}
function resolveApplicationPreviewMissingFields(message) {
if (!message?.applicationPreview) {
return []
}
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
return Array.isArray(normalizedPreview.missingFields) ? normalizedPreview.missingFields : []
}
function openApplicationSubmitConfirm(message) {
if (!message) {
return
@@ -2128,6 +2152,7 @@ export default {
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveApplicationPreviewMissingFields,
isApplicationPreviewEditing,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
@@ -2193,6 +2218,7 @@ export default {
flowRefreshBusy: flowRefreshBusy.value,
refreshFlowRunDetail,
flowSteps: flowSteps.value,
visibleFlowSteps: visibleFlowSteps.value,
resolveFlowStepStatusLabel,
formatFlowStepDuration,
resolveFlowStepDetail,

View File

@@ -2,6 +2,7 @@ import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
import { isBudgetMonitorUser, isExecutiveUser } from '../../utils/accessControl.js'
import {
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_START_APPLICATION,
@@ -13,12 +14,14 @@ export const SESSION_TYPE_EXPENSE = 'expense'
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 ASSISTANT_SESSION_TYPES = [
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_KNOWLEDGE
SESSION_TYPE_KNOWLEDGE,
SESSION_TYPE_BUDGET
]
export const ASSISTANT_SESSION_MODE_OPTIONS = [
@@ -45,9 +48,39 @@ export const ASSISTANT_SESSION_MODE_OPTIONS = [
label: '财务知识助手',
icon: 'mdi mdi-book-open-page-variant-outline',
description: '只处理财务制度、标准规则、票据要求和政策解释'
},
{
key: SESSION_TYPE_BUDGET,
label: '预算编制助手',
icon: 'mdi mdi-calculator-variant-outline',
description: '帮助你进行预算编制与预算相关问题的整理'
}
]
export function canUseBudgetAssistantSession(user = null) {
return Boolean(isBudgetMonitorUser(user) || isExecutiveUser(user))
}
function canUseAssistantSessionType(sessionType, user = null) {
const normalized = String(sessionType || '').trim()
if (normalized === SESSION_TYPE_BUDGET) {
return canUseBudgetAssistantSession(user)
}
return true
}
export function filterAssistantSessionModes(sessionModes = [], user = null) {
return Array.isArray(sessionModes)
? sessionModes.filter((mode) => canUseAssistantSessionType(mode?.key, user))
: []
}
export function filterAssistantSessionTypes(sessionTypes = [], user = null) {
return Array.isArray(sessionTypes)
? sessionTypes.filter((sessionType) => canUseAssistantSessionType(String(sessionType || '').trim(), user))
: []
}
export function normalizeAssistantSessionType(sessionType, fallback = SESSION_TYPE_EXPENSE) {
const normalized = String(sessionType || '').trim()
if (ASSISTANT_SESSION_TYPES.includes(normalized)) {

View File

@@ -100,6 +100,16 @@ export function useTravelReimbursementFlow({
const runningFlowStep = computed(
() => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null
)
const visibleFlowSteps = computed(() => {
const visibleSteps = []
for (const step of flowSteps.value) {
visibleSteps.push(step)
if (step.status !== FLOW_STEP_STATUS_COMPLETED) {
break
}
}
return visibleSteps
})
const flowOverallStatusTone = computed(() => {
if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) {
return 'failed'
@@ -209,7 +219,8 @@ export function useTravelReimbursementFlow({
durationMs: normalizedPatch.durationMs ?? null,
startedAt: normalizedPatch.startedAt || 0,
finishedAt: normalizedPatch.finishedAt || 0,
error: normalizedPatch.error || ''
error: normalizedPatch.error || '',
deferredCompletion: Boolean(normalizedPatch.deferredCompletion)
}
}
@@ -262,8 +273,15 @@ export function useTravelReimbursementFlow({
startedAt,
finishedAt: now,
durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt),
error: ''
error: '',
deferredCompletion: false
})
if (
flowSteps.value.length
&& flowSteps.value.every((step) => [FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
) {
flowFinishedAt.value = now
}
}
function failFlowStep(key, detail = '', error = '', patch = {}) {
@@ -291,19 +309,18 @@ export function useTravelReimbursementFlow({
const normalizedDuration = Number(durationMs)
const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0
if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) {
if (!hasMeasuredDuration && !currentStep?.startedAt) {
upsertFlowStep(key, {
...patch,
status: FLOW_STEP_STATUS_COMPLETED,
detail: detail || findFlowDefinition(key)?.completedText || '',
startedAt: 0,
finishedAt: 0,
durationMs: null,
error: ''
})
return
}
startFlowStep(key, patch)
const revealOrder = flowSteps.value.length
startFlowStep(key, { ...patch, deferredCompletion: true })
const completionTimer = window.setTimeout(() => {
completeFlowStep(
key,
detail || findFlowDefinition(key)?.completedText || '',
hasMeasuredDuration ? normalizedDuration : null,
{ deferredCompletion: false }
)
}, 280 + Math.min(revealOrder, 4) * 180)
flowSimulationTimers.push(completionTimer)
return
}
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch)
}
@@ -606,16 +623,18 @@ export function useTravelReimbursementFlow({
const sceneSelectionPending = isExpenseSceneSelectionResult(payload)
flowSteps.value
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
.filter((step) => !step.deferredCompletion)
.forEach((step) => {
const detail = sceneSelectionPending && step.key === 'expense-scene-selection'
? '已暂停后续识别,请先在主对话中选择报销场景。'
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
completeFlowStep(step.key, detail)
})
flowFinishedAt.value = Date.now()
if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && !sceneSelectionPending) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
}
flowFinishedAt.value = flowSteps.value.some(
(step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)
)
? 0
: Date.now()
}
async function refreshFlowRunDetail() {
@@ -679,6 +698,7 @@ export function useTravelReimbursementFlow({
flowStartedAt,
flowFinishedAt,
flowSteps,
visibleFlowSteps,
flowRefreshBusy,
flowTick,
completedFlowStepCount,

View File

@@ -12,6 +12,7 @@ import {
} from './travelReimbursementAttachmentModel.js'
import {
ASSISTANT_SESSION_TYPES,
filterAssistantSessionTypes,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE,
buildInitialInsightFromConversation,
@@ -43,6 +44,25 @@ export function useTravelReimbursementSessionState({
scrollToBottom,
getSessionRuntimeRefs = () => ({})
}) {
function resolveAccessibleSessionTypes() {
return filterAssistantSessionTypes(ASSISTANT_SESSION_TYPES, currentUser.value)
}
function resolveAccessibleSessionType(rawSessionType, fallback = resolveDefaultSessionTypeFromEntry()) {
const normalized = normalizeAssistantSessionType(rawSessionType, fallback)
const accessible = resolveAccessibleSessionTypes()
if (accessible.includes(normalized)) {
return normalized
}
const fallbackNormalized = String(fallback || '').trim()
if (accessible.includes(fallbackNormalized)) {
return fallbackNormalized
}
return accessible[0] || SESSION_TYPE_APPLICATION
}
function resolveDefaultSessionTypeFromEntry() {
return props.entrySource === 'application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE
}
@@ -65,7 +85,10 @@ export function useTravelReimbursementSessionState({
}
function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
const sessionType = resolveInitialSessionType(conversation, fallbackSessionType)
const sessionType = resolveAccessibleSessionType(
resolveInitialSessionType(conversation, fallbackSessionType),
fallbackSessionType
)
const restoredMessages = refreshWelcomeQuickActions(normalizeInitialConversationMessages(conversation), sessionType)
const initialInsight = buildInitialInsightFromConversation(conversation)
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
@@ -90,7 +113,10 @@ export function useTravelReimbursementSessionState({
}
function buildEmptySessionState(sessionType) {
const normalizedSessionType = normalizeAssistantSessionType(sessionType, resolveDefaultSessionTypeFromEntry())
const normalizedSessionType = resolveAccessibleSessionType(
sessionType,
resolveDefaultSessionTypeFromEntry()
)
return {
sessionType: normalizedSessionType,
messages: [
@@ -120,7 +146,7 @@ export function useTravelReimbursementSessionState({
return null
}
const sessionType = normalizeAssistantSessionType(
const sessionType = resolveAccessibleSessionType(
state.sessionType || snapshot.sessionType || fallbackSessionType,
fallbackSessionType
)
@@ -159,9 +185,12 @@ export function useTravelReimbursementSessionState({
}
const defaultInitialSessionType = resolveDefaultSessionTypeFromEntry()
const initialSessionType = props.initialConversation
? resolveInitialSessionType(props.initialConversation, defaultInitialSessionType)
: defaultInitialSessionType
const initialSessionType = resolveAccessibleSessionType(
props.initialConversation
? resolveInitialSessionType(props.initialConversation, defaultInitialSessionType)
: defaultInitialSessionType,
defaultInitialSessionType
)
const shouldPersistLocalSnapshot = props.entrySource !== 'detail'
const conversationInitialState = props.initialConversation
? buildConversationSessionState(props.initialConversation, initialSessionType)
@@ -188,7 +217,7 @@ export function useTravelReimbursementSessionState({
const draftClaimId = ref(initialSessionState.draftClaimId)
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
const sessionSnapshots = ref(
ASSISTANT_SESSION_TYPES.reduce((result, sessionType) => {
resolveAccessibleSessionTypes().reduce((result, sessionType) => {
result[sessionType] = null
return result
}, {})
@@ -202,7 +231,7 @@ export function useTravelReimbursementSessionState({
function buildPersistableSessionState(sessionState) {
const state = sessionState || captureCurrentSessionState()
return {
sessionType: normalizeAssistantSessionType(state.sessionType, resolveDefaultSessionTypeFromEntry()),
sessionType: resolveAccessibleSessionType(state.sessionType, resolveDefaultSessionTypeFromEntry()),
messages: serializeSessionMessages(state.messages),
conversationId: String(state.conversationId || '').trim(),
draftClaimId: String(state.draftClaimId || '').trim(),
@@ -258,7 +287,10 @@ export function useTravelReimbursementSessionState({
function applySessionState(sessionState) {
const runtimeRefs = getSessionRuntimeRefs()
const nextState = sessionState || buildEmptySessionState(activeSessionType.value)
activeSessionType.value = normalizeAssistantSessionType(nextState.sessionType, resolveDefaultSessionTypeFromEntry())
activeSessionType.value = resolveAccessibleSessionType(
nextState.sessionType,
resolveDefaultSessionTypeFromEntry()
)
messages.value = Array.isArray(nextState.messages) && nextState.messages.length
? nextState.messages
: [
@@ -301,7 +333,7 @@ export function useTravelReimbursementSessionState({
}
async function loadLatestSessionState(targetSessionType) {
const normalizedTarget = normalizeAssistantSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
const normalizedTarget = resolveAccessibleSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
const payload = await fetchLatestConversation(resolveCurrentUserId(), normalizedTarget, {
preferRecoverable: normalizedTarget === SESSION_TYPE_EXPENSE
})
@@ -312,7 +344,7 @@ export function useTravelReimbursementSessionState({
}
async function switchSessionType(targetSessionType) {
const normalizedTarget = normalizeAssistantSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
const normalizedTarget = resolveAccessibleSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) {
return
}