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

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,107 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" width="100%" height="100%">
<defs>
<!-- Soft, large shadow for the main cards -->
<filter id="shadow-lg" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="24" stdDeviation="32" flood-color="#0f172a" flood-opacity="0.08"/>
</filter>
<!-- Tighter shadow for floating chips -->
<filter id="shadow-sm" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="16" flood-color="#3a7ca5" flood-opacity="0.12"/>
</filter>
<!-- Glowing effect for spheres -->
<filter id="glow">
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="glass-base" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#f8fafc" stop-opacity="0.65"/>
</linearGradient>
<linearGradient id="blue-primary" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3a7ca5" stop-opacity="1"/>
<stop offset="100%" stop-color="#255b7d" stop-opacity="1"/>
</linearGradient>
<linearGradient id="amber-accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#b58b4c" stop-opacity="1"/>
<stop offset="100%" stop-color="#d4a359" stop-opacity="1"/>
</linearGradient>
</defs>
<g transform="translate(40, 20)">
<!-- Background Document (Left) -->
<g transform="translate(20, 60) rotate(-12)" filter="url(#shadow-lg)">
<rect width="260" height="340" rx="16" fill="url(#glass-base)" stroke="#ffffff" stroke-width="2"/>
<!-- UI Skeleton Lines -->
<text x="30" y="50" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="20" font-weight="bold" fill="#64748b">支出分析</text>
<rect x="30" y="70" width="200" height="8" rx="4" fill="#e2e8f0" opacity="0.7"/>
<rect x="30" y="90" width="160" height="8" rx="4" fill="#e2e8f0" opacity="0.7"/>
<!-- Bar Chart Component -->
<rect x="30" y="140" width="24" height="80" rx="6" fill="#e2e8f0" opacity="0.8"/>
<rect x="70" y="100" width="24" height="120" rx="6" fill="#e2e8f0" opacity="0.8"/>
<rect x="110" y="160" width="24" height="60" rx="6" fill="#e2e8f0" opacity="0.8"/>
<rect x="150" y="80" width="24" height="140" rx="6" fill="url(#blue-primary)"/>
<rect x="190" y="120" width="24" height="100" rx="6" fill="#e2e8f0" opacity="0.8"/>
</g>
<!-- Center Floating Sphere -->
<circle cx="280" cy="90" r="30" fill="url(#amber-accent)" opacity="0.85" filter="url(#glow)"/>
<!-- Main Foreground Document (Right Focus) -->
<g transform="translate(320, 10) rotate(6)" filter="url(#shadow-lg)">
<!-- Main Card Body -->
<rect width="320" height="400" rx="20" fill="url(#glass-base)" stroke="#ffffff" stroke-width="3"/>
<!-- Header Section -->
<circle cx="44" cy="50" r="18" fill="url(#blue-primary)"/>
<text x="76" y="58" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="800" fill="#3a7ca5">费用趋势</text>
<!-- Area Chart Widget -->
<rect x="30" y="100" width="260" height="130" rx="12" fill="#f1f5f9" opacity="0.5"/>
<path d="M 30 200 L 40 180 Q 90 120 140 150 T 260 90 L 290 90 L 290 230 L 30 230 Z" fill="#3a7ca5" opacity="0.1"/>
<path d="M 40 180 Q 90 120 140 150 T 260 90" fill="none" stroke="#3a7ca5" stroke-width="4" stroke-linecap="round"/>
<!-- Chart Nodes -->
<circle cx="140" cy="150" r="6" fill="#ffffff" stroke="#3a7ca5" stroke-width="3"/>
<circle cx="260" cy="90" r="6" fill="#ffffff" stroke="#3a7ca5" stroke-width="3"/>
<!-- Data List Rows -->
<g transform="translate(30, 250)" filter="url(#shadow-sm)">
<rect width="260" height="40" rx="10" fill="#ffffff" opacity="0.95"/>
<rect x="16" y="16" width="60" height="8" rx="4" fill="#94a3b8"/>
<rect x="200" y="16" width="44" height="8" rx="4" fill="url(#blue-primary)"/>
</g>
<g transform="translate(30, 306)" filter="url(#shadow-sm)">
<rect width="260" height="40" rx="10" fill="#ffffff" opacity="0.95"/>
<rect x="16" y="16" width="80" height="8" rx="4" fill="#cbd5e1"/>
<rect x="210" y="16" width="34" height="8" rx="4" fill="url(#blue-primary)"/>
</g>
</g>
<!-- Floating UI Chip 1 (Top Left) -->
<g transform="translate(140, 0) rotate(-6)" filter="url(#shadow-sm)">
<rect width="150" height="48" rx="24" fill="#ffffff" stroke="#f1f5f9" stroke-width="2"/>
<circle cx="24" cy="24" r="10" fill="#10b981"/>
<rect x="44" y="20" width="80" height="8" rx="4" fill="#334155"/>
</g>
<!-- Floating UI Chip 2 (Bottom Right) -->
<g transform="translate(580, 280) rotate(14)" filter="url(#shadow-sm)">
<rect width="130" height="52" rx="14" fill="url(#blue-primary)"/>
<rect x="20" y="22" width="90" height="8" rx="4" fill="#ffffff" opacity="0.9"/>
</g>
<!-- Decorative small spheres -->
<circle cx="120" cy="350" r="8" fill="#3a7ca5" opacity="0.4" filter="url(#glow)"/>
<circle cx="680" cy="120" r="12" fill="#b58b4c" opacity="0.3" filter="url(#glow)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

View File

@@ -70,13 +70,8 @@
}
.capability-icon {
border: 1px solid color-mix(in srgb, var(--capability-color) 18%, rgba(255, 255, 255, 0.68));
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.24)),
color-mix(in srgb, var(--capability-soft) 72%, transparent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
--workbench-list-icon-size: 40px;
--workbench-list-icon-art-size: 23px;
}
.workbench-card {

View File

@@ -61,6 +61,8 @@
grid-template-rows: auto minmax(0, 1fr);
}
/* .expense-stats-panel 的特殊背景已转移至全局的 .workbench-card */
.insight-metric-list,
.insight-profile-list {
min-height: 0;
@@ -102,6 +104,23 @@
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
}
/* 局部改造让费用统计内层的小卡片也变为低透明度透镜形成双层液态玻璃Double Glassmorphism的极品手感 */
.expense-stats-panel .insight-metric-row {
background: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
border-left-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.03),
inset 0 1px 0 rgba(255, 255, 255, 0.3) !important;
}
.expense-stats-panel .insight-metric-row:hover {
background: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.4) !important;
}
.insight-metric-icon,
.insight-profile-icon {
display: inline-flex;

View File

@@ -304,9 +304,10 @@
}
.capability-icon {
--workbench-list-icon-size: 34px;
--workbench-list-icon-art-size: 20px;
width: 34px;
height: 34px;
font-size: 20px;
}
.capability-copy {

View File

@@ -33,6 +33,10 @@
gap: 10px;
overflow: visible;
color: var(--workbench-ink);
/* 恢复极简纯净的页面底层 */
background: transparent;
background-color: var(--workbench-surface-soft);
}
.workbench :where(button, textarea) {
@@ -53,33 +57,33 @@
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
.assistant-hero {
--assistant-bg-position: center right;
--assistant-bg-size: cover;
--assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.86) 42%, rgba(255, 255, 255, 0.44) 68%, rgba(255, 255, 255, 0.18) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07) 52%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16) 100%);
position: relative;
z-index: 2;
min-height: 0;
overflow: hidden;
padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px;
border: 1px solid color-mix(in srgb, var(--workbench-primary) 14%, var(--workbench-line));
border-radius: 4px;
background:
var(--assistant-readability-mask),
var(--assistant-theme-tint),
var(--assistant-bg-image) var(--assistant-bg-position) / var(--assistant-bg-size) no-repeat;
background-color: color-mix(in srgb, var(--workbench-primary-soft) 42%, #ffffff);
background-blend-mode: normal, color, luminosity;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 16px;
background:
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
background-color: transparent;
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.04), inset 0 2px 4px rgba(255, 255, 255, 1);
isolation: isolate;
}
.assistant-hero::after {
content: none;
content: "";
position: absolute;
top: 0;
right: 100px;
bottom: 0;
width: 50%;
min-width: 400px;
background: url("../../images/hero-financial-decor.svg") right center / auto 100% no-repeat;
pointer-events: none;
z-index: 0;
}
.assistant-hero::before {
@@ -140,6 +144,14 @@
backdrop-filter: blur(4px);
}
.assistant-composer:focus-within {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.85);
box-shadow:
0 0 0 4px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14),
0 16px 36px rgba(15, 23, 42, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.96);
}
.assistant-composer textarea {
width: 100%;
min-width: 0;
@@ -317,19 +329,26 @@
.capability-card {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 10px;
align-items: center;
gap: 14px;
min-height: 0;
padding: 17px 12px 17px 26px;
overflow: hidden;
border: 1px solid var(--workbench-line);
border-left: 3px solid color-mix(in srgb, var(--capability-color) 54%, var(--workbench-line));
border-radius: 4px;
background: var(--workbench-surface);
overflow: visible;
border: 1px solid rgba(255, 255, 255, 0.9);
border-left: 3px solid color-mix(in srgb, var(--capability-color) 80%, rgba(255, 255, 255, 0.9));
border-radius: 12px;
background:
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
background-color: transparent;
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
text-align: left;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
box-shadow:
0 16px 32px rgba(0, 0, 0, 0.04),
inset 0 2px 4px rgba(255, 255, 255, 1);
transition:
border-color 180ms var(--ease),
box-shadow 180ms var(--ease),
@@ -337,21 +356,33 @@
transform 180ms var(--ease);
}
.capability-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
pointer-events: none;
z-index: 0;
}
.capability-card > * {
position: relative;
z-index: 1;
}
.capability-card::after {
display: none;
}
.capability-icon {
--workbench-list-icon-size: 40px;
--workbench-list-icon-art-size: 23px;
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--capability-color) 20%, #ffffff);
background: var(--capability-soft);
color: var(--capability-color);
font-size: 24px;
box-shadow: none;
}
.capability-copy {
@@ -417,7 +448,7 @@
.workbench-content-grid {
display: grid;
grid-template-columns: minmax(560px, 1.45fr) minmax(320px, 0.82fr);
grid-template-columns: minmax(640px, 1.8fr) minmax(260px, 0.55fr);
gap: 14px;
align-items: stretch;
min-height: 0;
@@ -425,14 +456,38 @@
}
.workbench-card {
position: relative;
isolation: isolate;
min-height: 0;
height: 100%;
overflow: hidden;
padding: 12px 14px;
border: 1px solid var(--workbench-line);
border-radius: 4px;
background: var(--workbench-surface);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
background:
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
background-color: transparent;
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 16px;
box-shadow:
0 16px 32px rgba(0, 0, 0, 0.04),
inset 0 2px 4px rgba(255, 255, 255, 1);
}
.workbench-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
pointer-events: none;
z-index: 1;
}
.workbench-card > * {
position: relative;
z-index: 2;
}
.progress-panel,

View File

@@ -472,20 +472,24 @@
.notification-popover {
position: absolute;
top: calc(100% + 8px);
top: calc(100% + 12px);
right: 0;
z-index: 60;
width: clamp(340px, 34vw, 420px);
width: 380px;
max-width: calc(100vw - 24px);
max-height: min(520px, calc(100vh - 96px));
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
background: #ffffff;
box-shadow:
0 16px 36px rgba(0, 0, 0, 0.08),
0 4px 12px rgba(0, 0, 0, 0.03),
0 0 1px rgba(0, 0, 0, 0.1);
overscroll-behavior-y: contain;
}
.notification-popover::before {
@@ -665,9 +669,11 @@
max-height: min(336px, calc(100vh - 226px));
overflow-x: hidden;
overflow-y: auto;
padding: 6px 0;
padding: 4px 0 12px;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f8fafc;
overscroll-behavior-y: contain;
scrollbar-gutter: stable;
}
.notification-list::-webkit-scrollbar {
@@ -687,13 +693,13 @@
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 16px;
align-items: center;
gap: 9px;
min-height: 0;
padding: 10px 14px;
gap: 12px;
min-height: 68px;
padding: 12px 16px;
border: 0;
border-left: 3px solid transparent;
border-radius: 0;
background: transparent;
background: #ffffff;
flex-shrink: 0;
text-align: left;
transition:
background 180ms var(--ease),
@@ -705,16 +711,27 @@
}
.notification-row.unread {
border-left-color: var(--theme-primary-active);
background: linear-gradient(90deg, var(--theme-primary-light-9) 0%, #fff 42%);
background: #f8fafc;
position: relative;
}
.notification-row.unread::before {
content: '';
position: absolute;
left: 0;
top: 16px;
bottom: 16px;
width: 3px;
border-radius: 0 4px 4px 0;
background: var(--theme-primary-active);
}
.notification-row:hover {
background: #f8fafc;
background: #f1f5f9;
}
.notification-row.unread:hover {
background: linear-gradient(90deg, var(--theme-primary-light-8) 0%, #f8fafc 48%);
background: #f1f5f9;
}
.notification-type-icon {
@@ -722,11 +739,12 @@
height: 34px;
display: grid;
place-items: center;
border: 1px solid var(--theme-primary-light-6);
border-radius: 4px;
background: #fff;
border: 1px solid rgba(0,0,0,0.04);
border-radius: 8px;
background: #ffffff;
color: var(--theme-primary-active);
font-size: 18px;
font-size: 16px;
box-shadow: 0 1.5px 4px rgba(0,0,0,0.03);
}
.notification-type-icon.danger {
@@ -755,28 +773,29 @@
.notification-copy {
min-width: 0;
display: grid;
display: flex;
flex-direction: column;
gap: 4px;
}
.notification-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-top: 1px;
padding-bottom: 1px;
}
.notification-title-line {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
}
.notification-copy strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 800;
font-size: 13.5px;
font-weight: 750;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-title-line b {
@@ -796,22 +815,24 @@
}
.notification-copy small {
display: -webkit-box;
overflow: hidden;
color: #475569;
color: #64748b;
font-size: 12px;
line-height: 1.4;
white-space: normal;
-webkit-box-orient: vertical;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.notification-meta {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 8px;
margin-top: 2px;
}
.notification-meta em,
@@ -827,7 +848,7 @@
}
.notification-meta em {
flex: 1 1 auto;
display: none;
}
.notification-meta time {

View File

@@ -22,6 +22,18 @@
border-color: rgba(96, 165, 250, 0.3);
}
.message-stack {
min-width: 0;
display: grid;
justify-items: start;
gap: 8px;
}
.message-row.user .message-stack {
order: 1;
justify-items: end;
}
.message-avatar {
width: 38px;
height: 38px;
@@ -52,6 +64,94 @@
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14);
}
.steward-intent-bubble {
width: min(100%, 680px);
border: 1px solid #c9ddea;
border-radius: 4px;
background: #eef6fb;
color: #1f3f5b;
box-shadow: 0 8px 18px rgba(148, 163, 184, 0.12);
}
.steward-intent-bubble[open] {
background: #f3f9fd;
}
.steward-intent-bubble summary {
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
cursor: pointer;
list-style: none;
}
.steward-intent-bubble summary::-webkit-details-marker {
display: none;
}
.steward-intent-bubble summary > span {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
color: #234a68;
font-size: 12px;
font-weight: 820;
}
.steward-intent-bubble summary > span i {
color: #3a7ca5;
font-size: 14px;
}
.steward-intent-bubble summary small {
margin-left: auto;
color: #6f8295;
font-size: 12px;
font-weight: 720;
}
.steward-intent-bubble summary > i {
color: #64748b;
transition: transform 180ms ease;
}
.steward-intent-bubble[open] summary > i {
transform: rotate(180deg);
}
.steward-intent-event-list {
margin: 0;
padding: 0 12px 12px 30px;
display: grid;
gap: 7px;
}
.steward-intent-event-list li strong {
display: block;
color: #274b68;
font-size: 12px;
font-weight: 820;
}
.steward-intent-event-list li span {
display: block;
margin-top: 2px;
color: #5c7185;
font-size: 12px;
line-height: 1.55;
}
.steward-intent-empty {
margin: 0;
padding: 0 12px 12px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.message-bubble-application-preview {
max-width: min(100%, 980px);
}

View File

@@ -396,6 +396,32 @@
flex: 1 1 auto;
}
.steward-composer-row {
align-items: flex-end;
gap: 8px;
}
.steward-composer-leading-actions {
display: flex;
align-items: center;
gap: 8px;
}
.steward-composer-shell {
min-height: 48px;
}
.steward-composer-shell .composer-shell-body {
min-height: 48px;
padding: 5px 12px;
}
.steward-composer-shell textarea {
min-height: 38px;
max-height: 150px;
padding: 9px 4px;
}
.composer-side-btn,
.composer-row .tool-btn,
.composer-row .send-btn {

View File

@@ -62,6 +62,97 @@
background: #fff;
}
.steward-plan-block {
margin-top: 12px;
display: grid;
gap: 10px;
}
.steward-task-card,
.steward-attachment-card {
border: 1px solid #dbe7f2;
border-radius: 4px;
background: #f8fbfe;
}
.steward-task-list,
.steward-attachment-list {
display: grid;
gap: 8px;
}
.steward-task-card,
.steward-attachment-card {
padding: 10px 12px;
}
.steward-task-card header,
.steward-attachment-card header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.steward-task-card header span,
.steward-attachment-card header span {
color: #24618a;
font-size: 12px;
font-weight: 700;
}
.steward-task-card header small,
.steward-attachment-card header small {
margin-left: auto;
color: #71879b;
font-size: 12px;
}
.steward-task-card > strong {
display: block;
color: #1f3448;
font-size: 14px;
}
.steward-task-card p,
.steward-attachment-card p {
margin: 5px 0 0;
color: #5c7185;
font-size: 12px;
line-height: 1.55;
}
.steward-task-meta,
.steward-attachment-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.steward-task-meta span,
.steward-attachment-chip {
border: 1px solid #d5e2ee;
border-radius: 4px;
background: #fff;
color: #49677f;
font-size: 12px;
line-height: 1.4;
padding: 3px 7px;
}
.steward-attachment-chip.include {
border-color: #c7deef;
background: #eef7fc;
color: #24618a;
}
.steward-attachment-chip.exclude {
border-color: #ecd6c4;
background: #fff8f2;
color: #8a5a24;
}
.welcome-quick-actions {
margin-top: 14px;
padding-top: 12px;

View File

@@ -884,6 +884,18 @@
font-size: 16px;
}
.standard-adjustment-banner {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.submit-progress-banner {
border-color: #c7d2fe;
background: #eef2ff;
color: #3730a3;
}
.detail-expense-table table {
width: 100%;
min-width: 0;
@@ -1094,12 +1106,28 @@
}
.expense-reimbursable-amount {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
color: #0f172a;
font-size: 13px;
font-weight: 880;
white-space: nowrap;
}
.expense-reimbursable-label {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 5px;
border-radius: 3px;
background: #eef2ff;
color: #3730a3;
font-size: 10px;
font-weight: 850;
}
.expense-adjusted-amount em {
color: #991b1b;
font-size: 11px;
@@ -1989,51 +2017,26 @@
line-height: 1.6;
}
.risk-override-card textarea {
min-height: 88px;
border-color: #fecaca;
background: #fff;
color: #0f172a;
}
.risk-override-card textarea.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
resize: none;
}
.risk-override-card textarea:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, .12);
outline: none;
}
.risk-override-submit-row {
.risk-override-guidance {
display: grid;
gap: 6px;
}
.risk-override-save-btn {
min-height: 34px;
border: 1px solid #bfdbfe;
gap: 4px;
padding: 10px 12px;
border: 1px solid #dbeafe;
border-radius: 4px;
background: #eff6ff;
color: #1d4ed8;
}
.risk-override-guidance strong {
color: #1e40af;
font-size: 12px;
font-weight: 850;
cursor: pointer;
line-height: 1.45;
}
.risk-override-save-btn:disabled {
cursor: not-allowed;
opacity: .58;
}
.risk-override-submit-row span {
color: #64748b;
.risk-override-guidance span {
color: #475569;
font-size: 12px;
line-height: 1.5;
text-align: center;
}
.validation-card {

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8.5 4.5h7A1.5 1.5 0 0 1 17 6v13a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 19V6a1.5 1.5 0 0 1 1.5-1.5h2"/>
<path d="M8.5 4.5A2 2 0 0 1 10.5 3h2A2 2 0 0 1 14.5 4.5v1h-6Z"/>
<path d="M8.5 12.8 11 15.2l5-5.2"/>
<path d="M8 18h7"/>
</svg>

After

Width:  |  Height:  |  Size: 431 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4.5v7h7A7 7 0 1 1 12 4.5Z"/>
<path d="M14.5 3.8A7.2 7.2 0 0 1 20.2 9h-5.7Z"/>
<path d="M7.5 16.5h4M7.5 13.5H10"/>
</svg>

After

Width:  |  Height:  |  Size: 320 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 3.5h7l3 3v13a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5v-15A1.5 1.5 0 0 1 7.5 3.5Z"/>
<path d="M14 3.5V7h3.5"/>
<path d="M9 11h6M9 14h3"/>
<path d="M15.5 13.5v4M13.5 15.5h4"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4.5 19.5h15"/>
<path d="M6 16.5V8.8M10 16.5V5.5M14 16.5v-6M18 16.5V7"/>
<path d="M6 12.5c2.2-3.2 4-1.2 6.2-4.1 1.5-2 3-1.3 5.3-3.4"/>
<path d="m17.6 5 .4 3.2-3.1-.5"/>
</svg>

After

Width:  |  Height:  |  Size: 373 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5.5 5.5A2.5 2.5 0 0 1 8 3h12v16.5H8A2.5 2.5 0 0 0 5.5 22Z"/>
<path d="M5.5 5.5v16"/>
<path d="M9 7.5h7M9 10.5h6M9 14.5h4"/>
<path d="M17 3v16.5"/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6.5 4.5h9.8A1.7 1.7 0 0 1 18 6.2v13.3l-2.2-1.2-2.2 1.2-2.2-1.2-2.2 1.2-2.2-1.2-2.2 1.2V6.2a1.7 1.7 0 0 1 1.7-1.7Z"/>
<path d="M8 9h8M8 12h6"/>
<path d="M9 15.5h2.7c1.8 0 3.3-1.4 3.3-3.2v-.8"/>
<path d="m13.3 10 1.7 1.7 1.7-1.7"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

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)"

138
web/src/services/steward.js Normal file
View File

@@ -0,0 +1,138 @@
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
export function fetchStewardPlan(payload, options = {}) {
return apiRequest('/steward/plans', {
method: 'POST',
body: JSON.stringify(payload),
...options
})
}
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
const {
timeoutMs = 0,
timeoutMessage = '小财管家任务规划超时,请稍后重试。'
} = options
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
const timeoutId = controller && Number(timeoutMs) > 0
? globalThis.setTimeout(() => controller.abort(), Number(timeoutMs))
: 0
let response
try {
response = await fetch(`${getRuntimeApiBaseUrl()}/steward/plans/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
signal: controller?.signal
})
} catch (error) {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
if (error?.name === 'AbortError') {
throw new Error(timeoutMessage)
}
throw new Error('无法连接小财管家流式服务,请确认后端已启动。')
}
if (!response.ok) {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
throw new Error(await resolveStreamError(response))
}
if (!response.body?.getReader) {
const text = await response.text()
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
return consumeNdjsonText(text, handlers)
}
const decoder = new TextDecoder('utf-8')
const reader = response.body.getReader()
let buffer = ''
let finalPlan = null
try {
while (true) {
const { value, done } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const event = parseStreamLine(line)
if (!event) continue
finalPlan = handleStreamEvent(event, handlers) || finalPlan
}
}
buffer += decoder.decode()
if (buffer.trim()) {
const event = parseStreamLine(buffer)
if (event) {
finalPlan = handleStreamEvent(event, handlers) || finalPlan
}
}
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(timeoutMessage)
}
throw error
} finally {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
}
if (!finalPlan) {
throw new Error('小财管家流式结果缺少最终任务计划。')
}
return finalPlan
}
async function resolveStreamError(response) {
try {
const payload = await response.json()
return String(payload?.detail || payload?.message || '').trim() || '小财管家流式接口请求失败。'
} catch {
return '小财管家流式接口请求失败。'
}
}
function consumeNdjsonText(text, handlers) {
let finalPlan = null
String(text || '').split('\n').forEach((line) => {
const event = parseStreamLine(line)
if (!event) return
finalPlan = handleStreamEvent(event, handlers) || finalPlan
})
return finalPlan
}
function parseStreamLine(line) {
const normalized = String(line || '').trim()
if (!normalized) {
return null
}
return JSON.parse(normalized)
}
function handleStreamEvent(event, handlers) {
if (event.event === 'error') {
throw new Error(String(event.data?.message || '').trim() || '小财管家规划失败,请稍后重试。')
}
handlers.onEvent?.(event)
if (event.event === 'plan') {
return event.data
}
return null
}

View File

@@ -4,8 +4,14 @@ export const ASSISTANT_SCOPE_SESSION_APPLICATION = 'application'
export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense'
export const ASSISTANT_SCOPE_SESSION_APPROVAL = 'approval'
export const ASSISTANT_SCOPE_SESSION_KNOWLEDGE = 'knowledge'
export const ASSISTANT_SCOPE_SESSION_STEWARD = 'steward'
const SESSION_SCOPE_CONFIG = {
[ASSISTANT_SCOPE_SESSION_STEWARD]: {
label: '小财管家',
icon: 'mdi mdi-account-tie-outline',
scope: '多任务拆解、附件归集、申请助手和报销助手统一调度'
},
[ASSISTANT_SCOPE_SESSION_APPLICATION]: {
label: '申请助手',
icon: 'mdi mdi-file-plus-outline',
@@ -105,6 +111,10 @@ export function inferAssistantScopeTarget(rawText, options = {}) {
const approvalMatched = APPROVAL_PATTERN.test(text)
const knowledgeMatched = KNOWLEDGE_PATTERN.test(text)
if (applicationMatched && expenseMatched) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
if (approvalMatched && /(待我审核|待审|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|处理意见)/.test(text)) {
return ASSISTANT_SCOPE_SESSION_APPROVAL
}

View File

@@ -2,6 +2,7 @@ import {
ASSISTANT_SCOPE_SESSION_APPLICATION,
ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_KNOWLEDGE,
ASSISTANT_SCOPE_SESSION_STEWARD,
hasExpenseApplicationIntentSignal,
hasReimbursementIntentSignal,
inferAssistantScopeTarget
@@ -63,6 +64,10 @@ export function resolveWorkbenchSessionTypeFromOntology(ontology, rawText, fallb
return fallback
}
if (applicationSignal && reimbursementSignal) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
if (hasApplicationDocumentEntity(ontology)) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}

View File

@@ -1,6 +1,12 @@
import briefcaseIcon from '../assets/workbench-icons/outline-briefcase.svg?raw'
import approvalIcon from '../assets/workbench-icons/outline-approval.svg?raw'
import budgetIcon from '../assets/workbench-icons/outline-budget.svg?raw'
import documentTextIcon from '../assets/workbench-icons/outline-document-text.svg?raw'
import expenseApplicationIcon from '../assets/workbench-icons/outline-expense-application.svg?raw'
import financeAnalysisIcon from '../assets/workbench-icons/outline-finance-analysis.svg?raw'
import paperAirplaneIcon from '../assets/workbench-icons/outline-paper-airplane.svg?raw'
import policyIcon from '../assets/workbench-icons/outline-policy.svg?raw'
import reimbursementIcon from '../assets/workbench-icons/outline-reimbursement.svg?raw'
import shoppingBagIcon from '../assets/workbench-icons/outline-shopping-bag.svg?raw'
import truckIcon from '../assets/workbench-icons/outline-truck.svg?raw'
import usersIcon from '../assets/workbench-icons/outline-users.svg?raw'
@@ -13,6 +19,12 @@ function prepareHeroiconMarkup(svgRaw) {
}
export const workbenchIconMap = {
'expense-application': { markup: prepareHeroiconMarkup(expenseApplicationIcon), style: 'outline' },
'quick-reimbursement': { markup: prepareHeroiconMarkup(reimbursementIcon), style: 'outline' },
'budget-planning': { markup: prepareHeroiconMarkup(budgetIcon), style: 'outline' },
'quick-approval': { markup: prepareHeroiconMarkup(approvalIcon), style: 'outline' },
'finance-analysis': { markup: prepareHeroiconMarkup(financeAnalysisIcon), style: 'outline' },
'company-policy': { markup: prepareHeroiconMarkup(policyIcon), style: 'outline' },
hospitality: { markup: prepareHeroiconMarkup(usersIcon), style: 'outline' },
travelDraft: { markup: prepareHeroiconMarkup(briefcaseIcon), style: 'outline' },
receipts: { markup: prepareHeroiconMarkup(documentTextIcon), style: 'outline' },

View File

@@ -245,10 +245,14 @@ function buildProgressItems(ownedRequests) {
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
const isApplication = title.includes('申请') || (requestId || '').toUpperCase().startsWith('SQ') || (requestId || '').toUpperCase().startsWith('CL')
const documentTypeLabel = isApplication ? '申请单' : '报销单'
return {
id: requestId,
requestId,
title,
documentTypeLabel,
expenseTypeLabel: resolveExpenseCategory(request),
amount: formatCurrency(request?.amount),
status,

View File

@@ -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"

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,

View 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')
}

View File

@@ -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)

View File

@@ -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
})

View 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
}
}

View File

@@ -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) {