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

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

View File

@@ -9,7 +9,7 @@
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
<div class="assistant-copy">
<h1>{{ displayUserName }}我是您的 <span>AI 费用助手</span></h1>
<h1>{{ displayUserName }}我是您的 <span>小财管家</span></h1>
<input
ref="fileInputRef"
@@ -26,7 +26,7 @@
v-model="assistantDraft"
maxlength="1000"
rows="2"
placeholder="请输入费用申请、报销问题、预算查询或制度问答..."
placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行..."
:readonly="isComposerPending"
@keydown.enter.prevent="handleWorkbenchEnter"
/>
@@ -180,7 +180,12 @@
:class="`capability-card--${item.tone}`"
@click="openCapabilityAssistant(item)"
>
<span class="capability-icon"><i :class="item.icon"></i></span>
<WorkbenchListIcon
class="capability-icon"
:icon-key="item.key"
color="var(--capability-color)"
accent="var(--capability-soft)"
/>
<span class="capability-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.primary }}</small>
@@ -221,8 +226,8 @@
<small>{{ item.id }}</small>
</span>
<span class="progress-type" :title="item.expenseTypeLabel || '其他费用'">
<strong>{{ item.expenseTypeLabel || '其他费用' }}</strong>
<span class="progress-type" :title="`${item.documentTypeLabel} · ${item.expenseTypeLabel || '其他费用'}`">
<strong>{{ item.documentTypeLabel }} · {{ item.expenseTypeLabel || '其他费用' }}</strong>
</span>
<span class="progress-steps" aria-hidden="true">
@@ -349,9 +354,10 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
@@ -425,6 +431,7 @@ let employeeProfileLoadSeq = 0
const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const SESSION_TYPE_STEWARD = 'steward'
const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
@@ -607,6 +614,7 @@ function buildAssistantPayload() {
return {
prompt: buildWorkbenchPromptText(),
source: 'workbench',
sessionType: SESSION_TYPE_STEWARD,
files: Array.from(selectedFiles.value)
}
}
@@ -674,7 +682,7 @@ function applyQuickPrompt(prompt) {
focusAssistantInput()
}
function openPromptAssistant(prompt) {
function openPromptAssistant(prompt, sessionType = SESSION_TYPE_STEWARD) {
if (pendingAction.value) {
return
}
@@ -682,6 +690,7 @@ function openPromptAssistant(prompt) {
const payload = {
prompt: buildWorkbenchPromptText(prompt),
source: 'workbench',
sessionType,
files: Array.from(selectedFiles.value),
conversation: null
}
@@ -704,7 +713,7 @@ function openWorkbenchTarget(item) {
return
}
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`)
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`, SESSION_TYPE_EXPENSE)
}
function openCapabilityAssistant(item) {

View File

@@ -99,29 +99,33 @@ function handleCancel() {
</script>
<style scoped>
.shared-confirm-mask {
position: fixed;
inset: 0;
z-index: 10020;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.shared-confirm-card {
width: min(480px, 100%);
.shared-confirm-mask {
position: fixed;
inset: 0;
z-index: 10020;
display: grid;
place-items: center;
padding: 20px;
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.shared-confirm-card {
width: min(480px, calc(100vw - 40px));
max-height: calc(100vh - 40px);
max-height: calc(100dvh - 40px);
display: flex;
flex-direction: column;
gap: 14px;
padding: 24px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
border-radius: 8px;
border-radius: 4px;
background:
radial-gradient(circle at top left, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.10), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98));
box-shadow: 0 28px 56px rgba(15, 23, 42, 0.18);
overflow: hidden;
}
.shared-confirm-badge {
@@ -165,12 +169,18 @@ function handleCancel() {
line-height: 1.7;
}
.shared-confirm-body {
display: grid;
gap: 10px;
}
.shared-confirm-body {
display: grid;
gap: 10px;
min-height: 0;
max-height: min(380px, calc(100dvh - 300px));
overflow-y: auto;
padding-right: 2px;
scrollbar-width: thin;
}
.shared-confirm-actions {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
gap: 10px;
@@ -257,12 +267,22 @@ function handleCancel() {
.shared-confirm-card--compact {
width: min(360px, 100%);
max-height: calc(100vh - 36px);
max-height: calc(100dvh - 36px);
gap: 8px;
padding: 16px;
border-radius: 6px;
border-radius: 4px;
background: #fff;
}
.shared-confirm-card--review {
width: min(560px, calc(100vw - 40px));
}
.shared-confirm-card--review .shared-confirm-body {
max-height: min(420px, calc(100dvh - 292px));
}
.shared-confirm-card--compact h4 {
font-size: 15px;
line-height: 1.35;
@@ -287,16 +307,23 @@ function handleCancel() {
@media (max-width: 720px) {
.shared-confirm-mask {
padding: 18px;
}
.shared-confirm-card {
padding: 20px;
border-radius: 8px;
padding: 14px;
}
.shared-confirm-card h4 {
font-size: 19px;
.shared-confirm-card {
width: min(100%, calc(100vw - 28px));
max-height: calc(100vh - 28px);
max-height: calc(100dvh - 28px);
padding: 20px;
border-radius: 4px;
}
.shared-confirm-body {
max-height: min(360px, calc(100dvh - 260px));
}
.shared-confirm-card h4 {
font-size: 19px;
}
.shared-confirm-actions {

View File

@@ -31,22 +31,21 @@ const iconStyle = computed(() => iconMeta.value.style)
<style scoped>
.workbench-list-icon {
position: relative;
width: 56px;
height: 56px;
width: var(--workbench-list-icon-size, 48px);
height: var(--workbench-list-icon-size, 48px);
flex-shrink: 0;
color: var(--icon-color, var(--theme-primary));
}
.workbench-list-icon__halo {
position: absolute;
inset: -3px;
border-radius: 20px;
background: radial-gradient(
circle at 50% 40%,
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 24%, transparent) 0%,
transparent 72%
);
opacity: 0.7;
top: 8px;
bottom: 8px;
left: 0;
width: 3px;
border-radius: 2px;
background: color-mix(in srgb, var(--icon-color, var(--theme-primary)) 78%, #ffffff);
opacity: 0.72;
}
.workbench-list-icon__panel {
@@ -57,26 +56,25 @@ const iconStyle = computed(() => iconMeta.value.style)
display: grid;
place-items: center;
overflow: hidden;
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 18%, var(--line, #e2e8f0));
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 22%, var(--line, #e2e8f0));
background:
radial-gradient(circle at 24% 16%, rgba(255, 255, 255, 0.98), transparent 46%),
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.44)),
linear-gradient(
160deg,
color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 72%, #fff) 0%,
#fff 44%,
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 7%, var(--surface-soft, #f8fafc)) 100%
135deg,
color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 64%, #fff) 0%,
#fff 52%,
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 8%, var(--surface-soft, #f8fafc)) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.98),
0 1px 2px rgba(15, 23, 42, 0.04),
0 10px 20px color-mix(in srgb, var(--icon-color, var(--theme-primary)) 12%, transparent);
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 1px 2px rgba(15, 23, 42, 0.045);
}
.workbench-list-icon__shine {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.55), transparent 38%);
background: linear-gradient(110deg, rgba(255, 255, 255, 0.42), transparent 44%);
pointer-events: none;
}
@@ -85,16 +83,15 @@ const iconStyle = computed(() => iconMeta.value.style)
z-index: 1;
display: grid;
place-items: center;
width: 30px;
height: 30px;
width: var(--workbench-list-icon-art-size, 28px);
height: var(--workbench-list-icon-art-size, 28px);
}
.workbench-list-icon__art :deep(.workbench-heroicon) {
width: 30px;
height: 30px;
width: var(--workbench-list-icon-art-size, 28px);
height: var(--workbench-list-icon-art-size, 28px);
display: block;
color: color-mix(in srgb, var(--icon-color, var(--theme-primary)) 86%, var(--theme-primary-active));
filter: drop-shadow(0 2px 5px color-mix(in srgb, var(--icon-color, var(--theme-primary)) 18%, transparent));
}
.workbench-list-icon--outline .workbench-list-icon__art :deep(.workbench-heroicon) {

View File

@@ -10,11 +10,43 @@
/>
</span>
<div class="message-bubble" :class="ui.buildMessageBubbleClass(message)">
<div class="message-stack">
<details
v-if="message.role === 'assistant' && message.stewardPlan && (message.stewardPlan.streamStatus === 'streaming' || message.stewardPlan.thinkingEvents?.length)"
class="steward-intent-bubble"
:open="message.stewardPlan.streamStatus === 'streaming'"
aria-label="小财管家意图识别智能体"
>
<summary>
<span>
<i class="mdi mdi-brain"></i>
意图识别智能体
</span>
<small>{{ message.stewardPlan.streamStatus === 'streaming' ? '识别中' : `${message.stewardPlan.thinkingEvents?.length || 0}` }}</small>
<i class="mdi mdi-chevron-down"></i>
</summary>
<ol v-if="message.stewardPlan.thinkingEvents?.length" class="steward-intent-event-list">
<li
v-for="event in (message.stewardPlan.thinkingEvents || []).slice(0, message.stewardPlan.visibleThinkingEventCount || message.stewardPlan.thinkingEvents?.length || 0)"
:key="`${message.id}-${event.eventId}`"
>
<strong>{{ event.title }}</strong>
<span>{{ event.content }}</span>
</li>
</ol>
<p v-else class="steward-intent-empty">正在建立任务上下文...</p>
</details>
<div
v-if="!message.stewardPlan || message.stewardPlan.streamStatus !== 'streaming' || message.text"
class="message-bubble"
:class="ui.buildMessageBubbleClass(message)"
>
<header class="message-meta">
<strong>{{ message.role === 'assistant' ? (message.assistantName || ui.ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
<time>{{ message.time }}</time>
</header>
<div
v-if="message.text && message.role === 'assistant' && message.reviewPayload && ui.buildReviewMainMessageText(message)"
class="review-summary message-answer-content message-answer-markdown"
@@ -40,6 +72,59 @@
:report="message.budgetReport"
/>
<div
v-if="message.role === 'assistant' && message.stewardPlan && message.stewardPlan.streamStatus !== 'streaming'"
class="steward-plan-block"
role="group"
aria-label="小财管家任务计划"
>
<div v-if="message.stewardPlan.tasks?.length" class="steward-task-list">
<article
v-for="task in message.stewardPlan.tasks"
:key="`${message.id}-${task.taskId}`"
class="steward-task-card"
>
<header>
<span>{{ task.taskTypeLabel }}</span>
<small>{{ task.assignedAgentLabel }}</small>
</header>
<strong>{{ task.title }}</strong>
<p>{{ task.summary }}</p>
<div class="steward-task-meta">
<span>置信度 {{ Math.round((task.confidence || 0) * 100) }}%</span>
<span v-if="task.missingFields?.length">待补充 {{ task.missingFields.join('') }}</span>
<span v-else>字段已齐备</span>
</div>
</article>
</div>
<div v-if="message.stewardPlan.attachmentGroups?.length" class="steward-attachment-list">
<article
v-for="group in message.stewardPlan.attachmentGroups"
:key="`${message.id}-${group.groupId}`"
class="steward-attachment-card"
>
<header>
<span>{{ group.sceneLabel }}</span>
<small>{{ Math.round((group.confidence || 0) * 100) }}%</small>
</header>
<p>{{ group.rationale }}</p>
<div class="steward-attachment-chip-row">
<span
v-for="name in group.attachmentNames"
:key="`${group.groupId}-in-${name}`"
class="steward-attachment-chip include"
>{{ name }}</span>
<span
v-for="name in group.excludedAttachmentNames"
:key="`${group.groupId}-out-${name}`"
class="steward-attachment-chip exclude"
>排除{{ name }}</span>
</div>
</article>
</div>
</div>
<div
v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-table"
@@ -472,6 +557,7 @@
</span>
</div>
</div>
</div>
<div
v-if="ui.isOperationFeedbackVisible(message)"