feat(web): 工作台 AI 模式与差旅/风险建议交互优化

- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
caoxiaozhu
2026-06-18 22:12:24 +08:00
parent a6674a1e76
commit 0cde1f8990
65 changed files with 8011 additions and 1608 deletions

View File

@@ -15,7 +15,7 @@
}
.app {
--sidebar-expanded-width: 184px;
--sidebar-expanded-width: 304px;
--sidebar-collapsed-width: 64px;
--sidebar-motion: 220ms cubic-bezier(0.4, 0, 0.2, 1);
@@ -43,8 +43,8 @@
}
.app.sidebar-collapsed .app-sidebar {
flex-basis: var(--sidebar-collapsed-width);
width: var(--sidebar-collapsed-width);
flex-basis: var(--sidebar-collapsed-width);
overflow: visible;
z-index: 200;
}
@@ -54,6 +54,19 @@
z-index: 1;
}
.sidebar-mode-fade-enter-active,
.sidebar-mode-fade-leave-active {
transition:
opacity 180ms var(--ease),
transform 180ms var(--ease);
}
.sidebar-mode-fade-enter-from,
.sidebar-mode-fade-leave-to {
opacity: 0;
transform: translateX(-8px);
}
.app > .main {
flex: 1 1 auto;
min-width: 0;
@@ -133,7 +146,7 @@
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
letter-spacing: 0;
}
.boot-badge-error {
@@ -217,6 +230,10 @@
background-size: 100% 100%, 100% 100%, 32px 32px, 32px 32px;
background-attachment: local;
}
.workarea.workbench-workarea.workbench-workarea-ai-mode {
padding: 0;
background: transparent;
}
.workarea.settings-workarea {
padding: 0;
background: #fff;
@@ -312,6 +329,7 @@
}
.workarea.workbench-workarea { overflow: auto; padding: 16px; }
.workarea.workbench-workarea.workbench-workarea-ai-mode { padding: 0; }
.mobile-overlay {
position: fixed;

View File

@@ -0,0 +1,676 @@
.ai-rail {
--ai-rail-bg: #f7f9fc;
--ai-rail-panel: rgba(255, 255, 255, 0.76);
--ai-rail-line: rgba(148, 163, 184, 0.14);
--ai-rail-text: #162033;
--ai-rail-muted: #738097;
--ai-rail-accent: #2d72d9;
--ai-rail-amber: #b76b16;
--ai-rail-green: #2f8d7b;
--ai-rail-ink-soft: #41506a;
--ai-rail-accent-soft: rgba(45, 114, 217, 0.08);
position: sticky;
top: 0;
width: 100%;
height: var(--desktop-stage-height, 100dvh);
min-height: var(--desktop-stage-height, 100dvh);
display: grid;
grid-template-rows: auto auto auto auto auto minmax(0, 1fr) auto;
overflow: hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(247, 249, 252, 0.96) 62%, rgba(244, 247, 251, 0.98)),
var(--ai-rail-bg);
border-right: 1px solid rgba(203, 213, 225, 0.54);
box-shadow:
inset -1px 0 0 rgba(255, 255, 255, 0.64),
1px 0 0 rgba(15, 23, 42, 0.02);
color: var(--ai-rail-text);
contain: layout paint;
}
.ai-rail::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.56), transparent 16%),
repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.12) 0,
rgba(255, 255, 255, 0.12) 1px,
transparent 1px,
transparent 20px
);
opacity: 0.22;
}
.ai-rail > * {
position: relative;
z-index: 1;
}
.ai-rail-section {
min-width: 0;
}
.ai-rail-brand {
min-width: 0;
min-height: 74px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 16px 18px 10px;
}
.ai-brand-logo {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid rgba(45, 114, 217, 0.12);
border-radius: 13px;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.84), rgba(239, 246, 255, 0.7)),
rgba(255, 255, 255, 0.72);
color: var(--ai-rail-accent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.86),
0 8px 18px rgba(45, 114, 217, 0.055);
}
.ai-brand-logo img {
width: 28px;
height: 28px;
object-fit: contain;
}
.ai-brand-logo svg {
width: 26px;
height: 26px;
fill: currentColor;
}
.ai-brand-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.ai-brand-copy strong {
overflow: hidden;
color: #162033;
font-size: 14px;
font-weight: 820;
line-height: 1.22;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-brand-copy small {
overflow: hidden;
color: var(--ai-rail-muted);
font-size: 12px;
font-weight: 560;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-rail-quick {
display: grid;
gap: 6px;
padding: 8px 18px 12px;
}
.ai-quick-btn,
.ai-nav-btn,
.ai-recent-item,
.ai-user-action {
width: 100%;
min-width: 0;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
transition:
background 180ms var(--ease),
border-color 180ms var(--ease),
box-shadow 180ms var(--ease),
color 180ms var(--ease),
transform 180ms var(--ease);
}
.ai-quick-btn {
min-height: 48px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 0 4px;
color: #111827;
font-size: 14px;
font-weight: 780;
background: transparent;
border-color: transparent;
box-shadow: none;
}
.ai-quick-btn i {
width: 28px;
display: inline-flex;
justify-content: center;
color: #536277;
font-size: 18px;
line-height: 1;
}
.ai-quick-btn.primary {
background: transparent;
border-color: transparent;
box-shadow: none;
}
.ai-quick-btn.active {
color: #173d78;
background: rgba(45, 114, 217, 0.055);
border-color: rgba(45, 114, 217, 0.12);
}
.ai-quick-btn.primary i {
color: var(--ai-rail-amber);
}
.ai-nav-btn:hover,
.ai-recent-item:hover,
.ai-user-action:hover {
background: rgba(255, 255, 255, 0.78);
border-color: rgba(148, 163, 184, 0.28);
box-shadow: 0 8px 18px rgba(31, 48, 68, 0.045);
transform: translateY(-1px);
}
.ai-quick-btn:hover {
color: #0f172a;
background: rgba(15, 23, 42, 0.035);
border-color: transparent;
box-shadow: none;
transform: translateX(2px);
}
.ai-quick-btn:hover i {
color: var(--ai-rail-accent);
}
.ai-quick-btn.primary:hover i {
color: var(--ai-rail-amber);
}
.ai-conversation-search {
min-width: 0;
min-height: 48px;
height: 48px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 28px;
align-items: center;
gap: 4px;
padding: 0 6px 0 4px;
border: 1px solid rgba(45, 114, 217, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.8),
0 8px 18px rgba(45, 114, 217, 0.035);
}
.ai-conversation-search > i {
color: #64748b;
font-size: 17px;
line-height: 1;
}
.ai-conversation-search input {
min-width: 0;
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: #162033;
font-size: 13px;
font-weight: 650;
letter-spacing: 0;
}
.ai-conversation-search input::placeholder {
color: rgba(115, 128, 151, 0.78);
}
.ai-conversation-search button {
width: 28px;
height: 28px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
}
.ai-conversation-search button:hover {
background: rgba(15, 23, 42, 0.055);
color: #173d78;
}
.ai-rail-divider {
height: 1px;
margin: 0 18px;
background: var(--ai-rail-line);
}
.ai-section-heading {
margin: 0;
padding: 0 10px 8px;
color: #7d8796;
font-size: 12px;
font-weight: 760;
letter-spacing: 0;
}
.ai-rail-nav {
display: grid;
padding: 18px 18px 20px;
}
.ai-nav-list {
position: relative;
display: grid;
gap: 6px;
padding: 2px 0;
}
.ai-nav-btn {
position: relative;
min-height: 48px;
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 7px 10px;
color: var(--ai-rail-ink-soft);
border-radius: 12px;
background: transparent;
box-shadow: none;
}
.ai-nav-btn::before {
content: "";
position: absolute;
left: 0;
width: 3px;
height: 22px;
border-radius: 999px;
background: transparent;
transition:
background 180ms var(--ease),
opacity 180ms var(--ease);
opacity: 0;
}
.ai-nav-btn.active {
border-color: rgba(45, 114, 217, 0.13);
background:
linear-gradient(90deg, rgba(45, 114, 217, 0.095), rgba(255, 255, 255, 0.74)),
var(--ai-rail-panel);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
0 8px 18px rgba(45, 114, 217, 0.045);
color: #173d78;
}
.ai-nav-btn.active::before {
background: linear-gradient(180deg, var(--ai-rail-accent), var(--ai-rail-green));
opacity: 1;
}
.ai-nav-btn:not(.active):hover::before {
background: rgba(45, 114, 217, 0.36);
opacity: 1;
}
.ai-nav-icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 10px;
background: transparent;
color: #64748b;
transition:
background 180ms var(--ease),
color 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.ai-nav-btn.active .ai-nav-icon {
background:
linear-gradient(145deg, rgba(45, 114, 217, 0.12), rgba(255, 255, 255, 0.72)),
rgba(255, 255, 255, 0.52);
color: var(--ai-rail-accent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
}
.ai-nav-icon i {
font-size: 18px;
line-height: 1;
}
.ai-nav-copy {
min-width: 0;
}
.ai-nav-copy strong,
.ai-recent-title {
min-width: 0;
overflow: hidden;
color: currentColor;
font-size: 14px;
font-weight: 780;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-nav-btn.active .ai-nav-copy strong {
font-weight: 820;
}
.ai-recent-desc {
min-width: 0;
overflow: hidden;
color: var(--ai-rail-muted);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-rail-recents {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
overflow: hidden;
padding: 18px 12px 12px;
}
.ai-recents-list {
min-height: 0;
display: grid;
align-content: start;
gap: 5px;
overflow-y: auto;
padding: 1px 4px 1px 0;
-ms-overflow-style: none;
scrollbar-width: none;
}
.ai-recents-list::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.ai-recents-empty {
margin: 10px 8px 0 12px;
padding: 14px 12px;
border: 1px dashed rgba(148, 163, 184, 0.22);
border-radius: 12px;
color: rgba(115, 128, 151, 0.84);
font-size: 12px;
font-weight: 650;
line-height: 1.5;
}
.ai-recent-item {
min-height: 56px;
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
padding: 8px 10px 8px 18px;
}
.ai-recent-item:focus-visible {
outline: 2px solid rgba(45, 114, 217, 0.32);
outline-offset: 2px;
}
.ai-recent-item::before {
content: "";
position: absolute;
left: 6px;
top: 16px;
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(115, 128, 151, 0.38);
}
.ai-recent-main {
min-width: 0;
display: grid;
gap: 4px;
}
.ai-recent-item:hover .ai-recent-title,
.ai-recent-item.active .ai-recent-title {
color: #173d78;
}
.ai-recent-item:hover::before,
.ai-recent-item.active::before {
background: var(--ai-rail-accent);
}
.ai-recent-item.active {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(183, 107, 22, 0.1);
box-shadow:
0 10px 22px rgba(31, 48, 68, 0.055),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.ai-recent-time {
color: rgba(107, 114, 128, 0.82);
font-size: 11px;
font-weight: 680;
line-height: 1.35;
}
.ai-recent-title-input {
width: 100%;
min-width: 0;
height: 24px;
padding: 0 6px;
border: 1px solid rgba(45, 114, 217, 0.22);
border-radius: 7px;
outline: 0;
background: rgba(255, 255, 255, 0.86);
color: #173d78;
font-size: 14px;
font-weight: 780;
line-height: 1.2;
letter-spacing: 0;
box-shadow: 0 0 0 3px rgba(45, 114, 217, 0.06);
}
.ai-rail-user {
box-sizing: border-box;
min-width: 0;
height: 72px;
min-height: 72px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 44px;
align-items: center;
gap: 12px;
margin: 0;
padding: 12px 14px 12px 18px;
border-top: 1px solid rgba(203, 213, 225, 0.55);
border-radius: 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(247, 250, 252, 0.9)),
rgba(255, 255, 255, 0.72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84);
}
.ai-user-avatar {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 2px solid rgba(255, 255, 255, 0.92);
border-radius: 50%;
background:
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.22), transparent 32%),
linear-gradient(135deg, #1f4f96, #2f8d7b);
color: #fff;
font-size: 15px;
font-weight: 820;
box-shadow:
0 8px 16px rgba(45, 114, 217, 0.13),
inset 0 -1px 0 rgba(15, 23, 42, 0.08);
}
.ai-user-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.ai-user-copy strong {
overflow: hidden;
color: #182237;
font-size: 13px;
font-weight: 760;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-user-copy span {
overflow: hidden;
color: var(--ai-rail-muted);
font-size: 12px;
font-weight: 520;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-user-actions {
display: grid;
grid-template-columns: 44px;
justify-content: end;
}
.ai-user-action {
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
color: #708096;
border-radius: 8px;
background: transparent;
box-shadow: none;
}
.ai-user-action i {
font-size: 19px;
}
.ai-rail.rail-collapsed {
grid-template-rows: auto auto auto auto minmax(0, 1fr) auto;
}
.ai-rail.rail-collapsed .ai-rail-brand {
grid-template-columns: 1fr;
justify-items: center;
min-height: 70px;
padding: 14px 10px 8px;
}
.ai-rail.rail-collapsed .ai-rail-quick {
padding: 4px 10px 16px;
}
.ai-rail.rail-collapsed .ai-nav-list {
grid-template-columns: 1fr;
gap: 8px;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.ai-rail.rail-collapsed .ai-nav-list::before,
.ai-rail.rail-collapsed .ai-nav-btn::before {
display: none;
}
.ai-rail.rail-collapsed .ai-quick-btn,
.ai-rail.rail-collapsed .ai-nav-btn {
min-height: 44px;
grid-template-rows: auto;
justify-content: center;
grid-template-columns: 1fr;
align-content: center;
padding: 8px;
}
.ai-rail.rail-collapsed .ai-nav-btn.active {
grid-column: auto;
min-height: 44px;
grid-template-columns: 1fr;
}
.ai-rail.rail-collapsed .ai-quick-btn span,
.ai-rail.rail-collapsed .ai-conversation-search,
.ai-rail.rail-collapsed .ai-brand-copy,
.ai-rail.rail-collapsed .ai-section-heading,
.ai-rail.rail-collapsed .ai-nav-copy,
.ai-rail.rail-collapsed .ai-rail-recents,
.ai-rail.rail-collapsed .ai-user-copy,
.ai-rail.rail-collapsed .ai-user-actions {
display: none;
}
.ai-rail.rail-collapsed .ai-quick-btn i,
.ai-rail.rail-collapsed .ai-brand-logo,
.ai-rail.rail-collapsed .ai-nav-icon,
.ai-rail.rail-collapsed .ai-user-avatar {
margin: 0 auto;
}
.ai-rail.rail-collapsed .ai-rail-user {
grid-template-columns: 1fr;
padding: 12px 10px 14px;
}
@media (max-width: 760px) {
.ai-rail {
max-width: min(320px, 82vw);
}
.ai-rail-quick {
padding-top: 18px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +1,40 @@
/* 1080p / 小高度屏:进一步压缩 AI 助手卡片高度 (排除手机端) */
/* 1080p / 小高度屏:让传统模式顶部趋势卡更紧凑 */
@media (max-height: 980px) and (min-width: 761px) {
.workbench {
--hero-padding-top: 20px;
--hero-padding-bottom: 20px;
--hero-title-size: 28px;
--hero-copy-gap: 16px;
--hero-title-bottom-gap: 10px;
--composer-min-height: 108px;
--composer-textarea-height: 48px;
--composer-padding-block: 10px;
--quick-prompts-gap-top: 8px;
--capability-row-height: 96px;
--hero-title-size: 31px;
--trend-card-min-height: 232px;
--capability-row-height: 106px;
gap: 9px;
}
.assistant-hero {
--assistant-bg-position: right center;
--assistant-decor-width: clamp(760px, 66vw, 980px);
--assistant-decor-opacity: 0.86;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
.workbench-trend-hero {
padding: 24px 20px 10px 20px;
}
.assistant-copy p {
font-size: 14px;
line-height: 1.5;
margin-bottom: 0;
.workbench-trend-card {
}
.assistant-composer textarea {
font-size: 15px;
}
.composer-icon-button,
.composer-send-button {
height: 32px;
}
.composer-send-button {
width: 50px;
.trend-chart-panel {
min-height: 128px;
}
}
/* 2K 宽屏但内容区仍偏高时,略收紧(避免 hero 独占过多纵向空间) */
@media (min-width: 1920px) and (max-height: 1100px) {
.workbench {
--hero-padding-top: 22px;
--hero-padding-bottom: 22px;
--hero-title-size: 29px;
--composer-min-height: 114px;
--composer-textarea-height: 50px;
--capability-row-height: 100px;
--hero-title-size: 32px;
--trend-card-min-height: 236px;
--capability-row-height: 108px;
}
}
@media (max-width: 1440px) {
.workbench {
grid-template-rows: auto var(--capability-row-height) minmax(0, 1fr);
gap: 10px;
}
.assistant-hero {
--assistant-bg-position: right center;
--assistant-decor-width: clamp(760px, 66vw, 980px);
--assistant-decor-opacity: 0.9;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
}
.assistant-copy {
width: min(940px, 92%);
}
.assistant-copy h1 {
font-size: 33px;
.trend-summary-panel h1 {
font-size: 32px;
}
.capability-grid--privileged {
@@ -83,7 +46,7 @@
}
.capability-card {
padding: 17px 12px 17px 22px;
padding: 18px 14px 18px 22px;
}
.capability-copy {
@@ -109,24 +72,15 @@
.workbench {
height: auto;
min-height: 100%;
grid-template-rows: auto auto auto;
gap: 12px;
}
.assistant-hero {
--assistant-bg-position: right center;
--assistant-decor-width: clamp(620px, 74vw, 860px);
--assistant-decor-opacity: 0.62;
--assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.5) 58%, rgba(255, 255, 255, 0.06) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.09) 100%);
backdrop-filter: blur(10px) saturate(1.12);
-webkit-backdrop-filter: blur(10px) saturate(1.12);
.workbench-trend-card {
grid-template-columns: 1fr;
}
.assistant-copy {
width: min(820px, 92%);
.trend-summary-panel {
align-content: start;
}
.capability-grid--privileged {
@@ -149,126 +103,91 @@
@media (min-width: 961px) and (max-width: 1440px),
(min-width: 961px) and (max-height: 820px) {
.workbench {
--hero-padding-top: 14px;
--hero-padding-bottom: 14px;
--hero-title-size: 24px;
--hero-copy-gap: 14px;
--hero-title-bottom-gap: 8px;
--composer-min-height: 92px;
--composer-textarea-height: 38px;
--composer-padding-block: 8px;
--quick-prompts-gap-top: 5px;
--capability-row-height: 82px;
--hero-title-size: 30px;
--trend-card-min-height: 232px;
--capability-row-height: 102px;
gap: 8px;
}
.assistant-hero {
--assistant-decor-width: clamp(680px, 60vw, 880px);
--assistant-decor-opacity: 0.72;
padding: var(--hero-padding-top) 16px var(--hero-padding-bottom) 34px;
.workbench-trend-hero {
padding: 24px 18px 10px 18px;
}
.assistant-copy {
width: min(900px, 92%);
.workbench-trend-card {
min-height: 0;
}
.assistant-copy h1 {
margin-bottom: var(--hero-title-bottom-gap);
.trend-summary-panel {
gap: 7px;
}
.trend-summary-panel h1 {
margin-bottom: 28px;
font-size: var(--hero-title-size);
line-height: 1.14;
}
.assistant-composer {
min-height: var(--composer-min-height);
gap: 4px;
padding: var(--composer-padding-block) 14px 8px;
.trend-total {
font-size: 42px;
}
.assistant-composer textarea {
height: var(--composer-textarea-height);
min-height: var(--composer-textarea-height);
max-height: var(--composer-textarea-height);
.trend-summary-panel small {
display: none;
}
.trend-chart-panel {
min-height: 0;
}
.trend-chart-head strong {
font-size: 14px;
line-height: 1.42;
}
.composer-toolbar {
gap: 8px;
}
.composer-icon-button,
.composer-send-button {
height: 30px;
}
.composer-icon-button {
width: 30px;
font-size: 17px;
}
.composer-send-button {
width: 46px;
font-size: 16px;
}
.composer-count {
font-size: 12px;
}
.quick-prompts {
gap: 8px;
margin-top: var(--quick-prompts-gap-top);
.trend-chart-source {
font-size: 12.5px;
}
.quick-prompts button {
min-height: 24px;
padding: 0 10px;
font-size: 12px;
}
.capability-grid {
gap: 10px;
}
.capability-card {
grid-template-columns: 34px minmax(0, 1fr) 14px;
gap: 10px;
padding: 12px 12px 12px 16px;
grid-template-columns: 40px minmax(0, 1fr) 16px;
gap: 12px;
padding: 15px 14px 15px 18px;
}
.capability-icon {
--workbench-list-icon-size: 34px;
--workbench-list-icon-art-size: 20px;
width: 34px;
height: 34px;
--workbench-list-icon-size: 40px;
--workbench-list-icon-art-size: 24px;
width: 40px;
height: 40px;
}
.capability-copy {
gap: 2px;
gap: 3px;
}
.capability-copy strong {
font-size: 13px;
font-size: 14px;
line-height: 1.2;
}
.capability-copy small {
font-size: 11px;
font-size: 12px;
line-height: 1.22;
}
.capability-arrow {
width: 14px;
min-width: 14px;
font-size: 16px;
width: 16px;
min-width: 16px;
font-size: 17px;
}
}
@media (max-width: 760px) {
.workbench {
height: auto;
grid-template-rows: none;
gap: 14px;
overflow: visible;
--workbench-glass-base:
@@ -279,47 +198,36 @@
--workbench-glass-blur: blur(14px) saturate(1.2);
}
.assistant-hero {
min-height: auto;
--assistant-bg-position: right center;
--assistant-decor-width: min(620px, 118vw);
--assistant-decor-opacity: 0.36;
--assistant-readability-mask:
linear-gradient(180deg, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.76) 100%),
linear-gradient(90deg, rgba(255, 255, 255, 0.88) 0%, rgba(255, 255, 255, 0.52) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 100%);
padding: 24px 18px 24px;
backdrop-filter: blur(9px) saturate(1.1);
-webkit-backdrop-filter: blur(9px) saturate(1.1);
.workbench-trend-hero {
padding: 16px;
}
.assistant-copy {
width: 100%;
.workbench-trend-card {
grid-template-columns: 1fr;
gap: 18px;
}
.assistant-copy h1 {
.trend-summary-panel h1 {
max-width: 320px;
font-size: 28px;
}
.assistant-composer {
padding: 14px;
.trend-summary-panel {
transform: none;
}
.composer-toolbar {
.trend-total {
font-size: 34px;
}
.trend-chart-head {
align-items: flex-start;
flex-direction: column;
gap: 8px;
flex-wrap: wrap;
}
.composer-count {
order: 4;
width: 100%;
margin-left: 0;
}
.composer-send-button {
margin-left: auto;
.trend-chart-panel {
min-height: 148px;
}
.capability-grid,
@@ -356,88 +264,33 @@
}
}
/* 针对低高度视口(如低于 840px包含大部分笔记本 768px 高度),解除 height: 100% 限制,让内容流式高度,防止纵向元素被过度压扁 (排除手机端) */
/* 针对低高度视口,解除 height: 100% 限制,防止纵向元素被过度压扁 */
@media (max-height: 840px) and (min-width: 761px) {
.workbench {
height: auto;
min-height: 100%;
grid-template-rows: auto var(--capability-row-height) auto;
}
}
/* 手机端/窄屏自适应优化 (560px 以下) */
@media (max-width: 560px) {
/* 常用提问横向滑动展示,避免折行过多撑爆高度 */
.quick-prompts {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
gap: 8px;
padding-bottom: 2px;
}
.quick-prompts span {
display: none; /* 隐藏“常用提问:”前缀,以最大化利用横向空间 */
}
.quick-prompts button {
flex-shrink: 0;
padding: 0 10px;
min-height: 26px;
font-size: 12px;
}
/* 隐藏常用提问横滑条的原生滚动条,保持精致视觉 */
.quick-prompts::-webkit-scrollbar {
display: none;
}
.assistant-hero {
--assistant-bg-position: 72% center;
padding: 20px 14px 20px;
.workbench-trend-hero {
padding: 14px;
}
}
/* 手机端/窄屏自适应优化 (480px 以下) */
@media (max-width: 480px) {
/* 输入框更小巧 */
.assistant-composer {
padding: 10px 12px;
min-height: 94px;
.trend-summary-panel h1 {
font-size: 24px;
}
.assistant-composer textarea {
font-size: 14px;
height: 42px;
min-height: 42px;
.trend-total {
font-size: 30px;
}
.composer-toolbar {
gap: 6px;
.trend-chart-panel {
min-height: 132px;
}
.composer-icon-button,
.composer-send-button {
height: 30px;
font-size: 13px;
}
.composer-icon-button {
width: 30px;
}
.composer-send-button {
width: 46px;
}
/* 限制上传的附件文件芯片的最大宽度,防止溢出 */
.assistant-file-chip {
max-width: 110px;
}
/* AI 财务助手卡片尺寸更精致 */
.capability-card {
padding: 12px 10px 12px 14px;
gap: 8px;
@@ -463,7 +316,6 @@
font-size: 11px;
}
/* 重点优化费用进度行的网格区域Grid Area双行重构 */
.progress-row {
display: grid;
grid-template-columns: minmax(70px, auto) 1fr minmax(74px, auto);
@@ -506,7 +358,7 @@
.progress-result {
grid-area: result;
width: 100%;
justify-items: end; /* 金额和状态右对齐 */
justify-items: end;
gap: 2px;
}
@@ -515,9 +367,9 @@
}
.progress-status {
font-size: 11px;
min-height: 18px;
padding: 0 5px;
font-size: 11px;
}
.progress-steps {
@@ -526,7 +378,6 @@
margin-top: 4px;
}
/* 缩小步骤图图标与连线 */
.progress-step i {
width: 14px;
height: 14px;
@@ -541,7 +392,6 @@
top: 7px;
}
/* 侧边分析栏优化 */
.side-panel {
padding: 8px 10px;
gap: 4px;

View File

@@ -1,14 +1,11 @@
.workbench {
--hero-padding-top: 26px;
--hero-padding-bottom: 26px;
--hero-title-size: 30px;
--hero-title-size: 34px;
--hero-copy-gap: 6px;
--hero-title-bottom-gap: 18px;
--composer-min-height: 122px;
--composer-textarea-height: 54px;
--composer-padding-block: 12px;
--quick-prompts-gap-top: 10px;
--capability-row-height: 104px;
--trend-card-min-height: 260px;
--capability-row-height: 116px;
--workbench-ink: var(--ink, #1e293b);
--workbench-text: var(--text, #334155);
--workbench-muted: var(--muted, #64748b);
@@ -30,8 +27,8 @@
margin: 0 auto;
height: 100%;
min-width: 0;
display: grid;
grid-template-rows: auto var(--capability-row-height) minmax(0, 1fr);
display: flex;
flex-direction: column;
gap: 10px;
overflow: visible;
color: var(--workbench-ink);
@@ -41,7 +38,7 @@
background-color: var(--workbench-surface-soft);
}
.workbench :where(button, textarea) {
.workbench :where(button) {
font: inherit;
}
@@ -58,338 +55,139 @@
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
.assistant-hero {
--assistant-bg-position: right center;
--assistant-decor-width: clamp(860px, 62vw, 1180px);
--assistant-decor-opacity: 0.92;
--assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.74) 0%, rgba(255, 255, 255, 0.34) 46%, rgba(255, 255, 255, 0) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.025) 54%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.075));
.workbench-trend-hero {
position: relative;
z-index: 2;
flex: 0 0 var(--trend-card-min-height);
height: var(--trend-card-min-height);
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
overflow: visible;
padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px;
padding: 24px 28px;
overflow: hidden;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16);
border-radius: 12px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.54)),
var(--assistant-theme-tint);
background-color: rgba(247, 252, 255, 0.72);
backdrop-filter: blur(14px) saturate(1.18);
-webkit-backdrop-filter: blur(14px) saturate(1.18);
linear-gradient(120deg, rgba(255, 255, 255, 0.85), rgba(249, 252, 255, 0.7)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 68%);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
box-shadow:
0 12px 28px rgba(15, 23, 42, 0.045),
inset 0 1px 0 rgba(255, 255, 255, 0.86),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07);
isolation: isolate;
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
0 16px 32px rgba(15, 23, 42, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.94);
animation: workbenchItemIn 520ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: 0ms;
}
.assistant-hero::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 82%;
min-width: 760px;
background: url("../../images/workbench-hero-right-bg.png") var(--assistant-bg-position) / var(--assistant-decor-width) auto no-repeat;
opacity: var(--assistant-decor-opacity);
pointer-events: none;
z-index: 0;
}
.assistant-hero::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
var(--assistant-readability-mask),
linear-gradient(120deg, rgba(255, 255, 255, 0.36), transparent 22%, transparent 72%, rgba(255, 255, 255, 0.18)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.05), transparent 58%);
pointer-events: none;
z-index: 1;
}
.assistant-copy {
.workbench-trend-card {
position: relative;
z-index: 3;
width: min(980px, 94%);
z-index: 1;
display: grid;
gap: var(--hero-copy-gap);
grid-template-columns: minmax(200px, 0.28fr) minmax(0, 1fr);
align-items: stretch;
gap: 16px;
width: 100%;
height: 100%;
min-height: 0;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.assistant-copy h1 {
margin: 0 0 var(--hero-title-bottom-gap);
.trend-summary-panel {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.trend-summary-panel h1 {
margin: 0 0 44px 0;
color: var(--workbench-ink);
font-size: var(--hero-title-size);
line-height: 1.18;
line-height: 1.16;
font-weight: 880;
}
.trend-summary-panel p {
margin: 0 0 4px;
color: var(--workbench-muted);
font-size: 14px;
font-weight: 650;
}
.trend-total {
background: linear-gradient(110deg, var(--workbench-ink) 20%, var(--workbench-primary-active) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: var(--workbench-ink);
font-size: clamp(38px, 3.3vw, 54px);
line-height: 1;
font-weight: 860;
letter-spacing: -0.5px;
filter: drop-shadow(0 2px 8px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12));
}
.trend-change {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 26px;
color: var(--workbench-primary-active);
font-size: 13px;
font-weight: 800;
}
.trend-change.is-down {
color: #b45309;
}
.trend-summary-panel small {
color: color-mix(in srgb, var(--workbench-muted) 80%, #ffffff);
font-size: 12px;
font-weight: 650;
}
.trend-chart-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
align-content: stretch;
gap: 8px;
min-width: 0;
min-height: 0;
}
.trend-chart-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 0;
color: var(--workbench-ink);
}
.trend-chart-head strong {
font-size: 15px;
font-weight: 850;
}
.assistant-copy h1 span:not(.typing-cursor) {
color: var(--workbench-primary-active);
display: inline-block;
animation: workbenchItemIn 400ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
}
.typing-cursor {
display: inline-block;
color: var(--workbench-primary-active);
font-weight: 400;
margin-left: 2px;
animation: cursorBlink 0.9s step-end infinite;
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.assistant-copy p {
max-width: 680px;
margin: 0 0 2px;
.trend-chart-source {
color: var(--workbench-muted);
font-size: 15px;
line-height: 1.6;
font-weight: 600;
}
.assistant-copy > * {
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
}
.assistant-copy > h1 { animation-delay: 80ms; }
.assistant-copy > p { animation-delay: 160ms; }
.assistant-copy > .assistant-composer { animation-delay: 240ms; }
.assistant-copy > .assistant-file-strip { animation-delay: 320ms; }
.assistant-copy > .quick-prompts { animation-delay: 320ms; }
.assistant-file-input { display: none; }
.assistant-composer {
position: relative;
z-index: 20;
display: grid;
gap: 6px;
max-width: 920px;
min-height: var(--composer-min-height);
padding: var(--composer-padding-block) 18px 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.74)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.045), rgba(255, 255, 255, 0.18));
box-shadow:
0 10px 24px rgba(15, 23, 42, 0.045),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06);
backdrop-filter: blur(10px) saturate(1.14);
-webkit-backdrop-filter: blur(10px) saturate(1.14);
transition:
border-color 180ms var(--ease),
background 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.assistant-composer::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background:
linear-gradient(110deg, rgba(255, 255, 255, 0.32), transparent 32%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 42%);
pointer-events: none;
}
.assistant-composer > * {
position: relative;
z-index: 1;
}
.assistant-composer:focus-within {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.58);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.78)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06), rgba(255, 255, 255, 0.22));
box-shadow:
0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11),
0 14px 30px rgba(15, 23, 42, 0.055),
inset 0 1px 0 rgba(255, 255, 255, 0.94),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
}
.assistant-composer textarea {
width: 100%;
min-width: 0;
height: var(--composer-textarea-height);
min-height: var(--composer-textarea-height);
max-height: var(--composer-textarea-height);
resize: none;
border: 0;
padding: 0;
background: transparent;
color: var(--workbench-ink);
font-size: 16px;
line-height: 1.55;
overflow: hidden;
}
.assistant-composer textarea::placeholder {
color: color-mix(in srgb, var(--workbench-muted) 70%, #ffffff);
}
.assistant-composer textarea:focus { outline: none; }
.assistant-composer textarea[readonly] {
color: color-mix(in srgb, var(--workbench-ink) 72%, #ffffff);
cursor: progress;
}
.assistant-intent-status {
display: inline-flex;
align-items: center;
width: fit-content;
max-width: 100%;
min-height: 28px;
gap: 8px;
padding: 0 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
color: var(--workbench-primary-active);
font-size: 12px;
font-weight: 750;
line-height: 1.35;
}
.assistant-intent-status i {
font-size: 15px;
}
.composer-toolbar {
display: flex;
align-items: center;
gap: 12px;
}
.composer-icon-button,
.composer-send-button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
border-radius: 4px;
white-space: nowrap;
}
.composer-icon-button {
width: 36px;
border: 1px solid var(--workbench-line);
background: var(--workbench-surface);
color: var(--workbench-text);
font-size: 19px;
}
.composer-count {
margin-left: auto;
color: color-mix(in srgb, var(--workbench-muted) 75%, #ffffff);
font-size: 13px;
font-weight: 650;
}
.composer-send-button {
width: 56px;
background: var(--workbench-primary-active);
color: #fff;
font-size: 18px;
box-shadow: 0 6px 14px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16);
}
.assistant-file-strip {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.assistant-file-note,
.assistant-file-chip {
display: inline-flex;
align-items: center;
max-width: 220px;
min-height: 28px;
padding: 0 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 750;
}
.assistant-file-note {
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
}
.assistant-file-chip {
overflow: hidden;
border: 1px solid var(--workbench-line);
background: var(--workbench-surface);
color: var(--workbench-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.assistant-file-clear {
color: var(--workbench-muted);
font-size: 12px;
font-weight: 750;
}
.quick-prompts {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: var(--quick-prompts-gap-top);
margin-bottom: 0;
color: var(--workbench-text);
font-size: 14px;
font-weight: 700;
white-space: nowrap;
}
.quick-prompts button {
min-height: 28px;
padding: 0 14px;
border: 1px solid var(--workbench-line);
border-radius: 4px;
background: rgba(255, 255, 255, 0.86);
color: var(--workbench-text);
font-size: 13px;
font-weight: 650;
}
.quick-prompts .quick-more {
display: inline-flex;
align-items: center;
gap: 4px;
border-color: transparent;
background: transparent;
color: var(--workbench-primary-active);
font-weight: 800;
.workbench-trend-chart {
min-height: 0;
}
.capability-grid {
position: relative;
z-index: 1;
flex: 0 0 var(--capability-row-height);
display: grid;
gap: 16px;
min-height: 0;
@@ -407,11 +205,11 @@
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 18px;
grid-template-columns: 44px minmax(0, 1fr) 18px;
align-items: center;
gap: 14px;
gap: 16px;
min-height: 0;
padding: 16px 18px 16px 22px;
padding: 18px 20px 18px 24px;
overflow: visible;
text-align: left;
border: 1px solid rgba(255, 255, 255, 0.9);
@@ -450,10 +248,10 @@
}
.capability-icon {
--workbench-list-icon-size: 40px;
--workbench-list-icon-art-size: 24px;
width: 40px;
height: 40px;
--workbench-list-icon-size: 44px;
--workbench-list-icon-art-size: 26px;
width: 44px;
height: 44px;
color: var(--capability-color);
}
@@ -467,7 +265,7 @@
.capability-copy strong {
color: var(--workbench-ink);
font-size: 14px;
font-size: 15px;
font-weight: 850;
line-height: 1.25;
overflow: hidden;
@@ -479,7 +277,7 @@
.capability-copy small {
overflow: hidden;
color: var(--workbench-muted);
font-size: 12px;
font-size: 12.5px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
@@ -529,6 +327,7 @@
}
.workbench-content-grid {
flex: 1 1 auto;
display: grid;
grid-template-columns: minmax(640px, 1.8fr) minmax(260px, 0.55fr);
gap: 14px;
@@ -1034,9 +833,7 @@
}
.capability-card:hover,
.progress-row:hover,
.quick-prompts button:hover,
.composer-icon-button:hover {
.progress-row:hover {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
color: var(--workbench-primary-active);
}
@@ -1053,9 +850,10 @@
}
@media (prefers-reduced-motion: reduce) {
.assistant-hero,
.workbench-trend-hero,
.capability-card,
.workbench-card {
animation: none !important;
}
}

View File

@@ -276,51 +276,43 @@
}
.rail-user {
position: relative;
box-sizing: border-box;
min-width: 0;
min-height: 78px;
margin: 0;
padding: 16px 20px 18px;
border-top: 1px solid #edf2f7;
transition: padding var(--rail-motion-duration) var(--rail-motion-ease);
}
.user-summary {
position: relative;
min-width: 0;
min-height: 42px;
display: flex;
height: 72px;
min-height: 72px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 44px;
align-items: center;
gap: 10px;
padding: 4px;
color: #64748b;
border-radius: 4px;
cursor: pointer;
gap: 12px;
margin: 0;
padding: 12px 14px 12px 18px;
border-top: 1px solid rgba(203, 213, 225, 0.55);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(247, 250, 252, 0.9)),
rgba(255, 255, 255, 0.72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84);
transition:
gap var(--rail-motion-duration) var(--rail-motion-ease),
padding var(--rail-motion-duration) var(--rail-motion-ease),
background 180ms var(--ease);
}
.rail-user:hover .user-summary {
background: rgba(255, 255, 255, 0.72);
grid-template-columns var(--rail-motion-duration) var(--rail-motion-ease),
padding var(--rail-motion-duration) var(--rail-motion-ease);
}
.user-avatar {
flex: 0 0 36px;
width: 36px;
height: 36px;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 2px solid #fff;
border-radius: 999px;
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active));
box-shadow: 0 6px 14px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border: 2px solid rgba(255, 255, 255, 0.92);
border-radius: 50%;
background:
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.22), transparent 32%),
linear-gradient(135deg, #1f4f96, #2f8d7b);
box-shadow:
0 8px 16px rgba(45, 114, 217, 0.13),
inset 0 -1px 0 rgba(15, 23, 42, 0.08);
color: #fff;
font-size: 14px;
font-weight: 800;
font-size: 15px;
font-weight: 820;
transition:
flex-basis var(--rail-motion-duration) var(--rail-motion-ease),
width var(--rail-motion-duration) var(--rail-motion-ease),
height var(--rail-motion-duration) var(--rail-motion-ease);
}
@@ -328,9 +320,7 @@
.user-copy {
flex: 1;
min-width: 0;
max-width: 116px;
display: flex;
flex-direction: column;
display: grid;
gap: 2px;
opacity: 1;
transition:
@@ -341,9 +331,10 @@
}
.user-copy strong {
color: #334155;
font-size: 14px;
font-weight: 750;
color: #182237;
font-size: 13px;
font-weight: 760;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -352,57 +343,47 @@
.user-copy span {
color: #64748b;
font-size: 12px;
font-weight: 520;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-summary .mdi {
flex: 0 0 18px;
font-size: 18px;
transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease) var(--rail-label-delay);
will-change: max-width, opacity;
.user-actions {
display: grid;
grid-template-columns: 44px;
justify-content: end;
}
.user-menu {
position: absolute;
right: 20px;
bottom: calc(100% - 6px);
min-width: 132px;
padding: 8px;
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.1);
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: all 180ms var(--ease);
z-index: 4;
}
.rail-user:hover .user-menu {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.user-menu-item {
.user-action {
width: 100%;
height: 38px;
display: flex;
min-width: 0;
height: 44px;
display: grid;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 4px;
justify-content: center;
padding: 0;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: #64748b;
cursor: pointer;
transition:
background 180ms var(--ease),
border-color 180ms var(--ease),
color 180ms var(--ease);
}
.user-action:hover {
border-color: rgba(148, 163, 184, 0.28);
background: rgba(255, 255, 255, 0.78);
color: #dc2626;
font-size: 13px;
font-weight: 700;
transition: all 180ms var(--ease);
}
.user-action i {
font-size: 20px;
line-height: 1;
}
/* ========================================= */
@@ -489,33 +470,14 @@
}
.rail-collapsed .rail-user {
position: relative;
z-index: 6;
padding: 14px 8px;
overflow: visible;
}
.rail-collapsed .user-summary {
grid-template-columns: 42px;
justify-content: center;
padding: 4px;
gap: 0;
padding: 14px 8px;
}
.rail-user-menu-floating {
position: fixed;
z-index: 12000;
min-width: 132px;
padding: 8px;
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.14);
transform: translateY(-50%);
animation: railUserMenuIn 180ms var(--rail-motion-ease) both;
}
.rail-user-menu-floating .user-menu-item {
width: 100%;
.rail-collapsed .user-copy,
.rail-collapsed .user-actions {
display: none;
}
:global(.rail-tooltip-popper) {

View File

@@ -1,7 +1,7 @@
.employee-risk-profile-card {
display: grid;
gap: 10px;
padding: 12px 14px;
gap: 12px;
padding: 14px 16px;
}
.employee-risk-head {
@@ -74,28 +74,28 @@
.employee-risk-body {
display: grid;
gap: 10px;
gap: 12px;
}
.employee-risk-decision-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 32%);
grid-template-columns: minmax(0, 1.15fr) minmax(220px, .85fr);
align-items: stretch;
gap: 12px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 2px;
background: #ffffff;
}
.employee-risk-decision-panel.medium {
border-color: #fed7aa;
background: #fff7ed;
border-color: #f3e8d9;
background: #fffcf7;
}
.employee-risk-decision-panel.high {
border-color: #fecaca;
background: #fef2f2;
background: #fff7f7;
}
.employee-risk-decision-main {
@@ -110,13 +110,15 @@
font-size: 10px;
font-weight: 850;
line-height: 1.5;
letter-spacing: .03em;
text-transform: uppercase;
}
.employee-risk-decision-main strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
font-size: 15px;
font-weight: 900;
overflow-wrap: anywhere;
}
@@ -143,8 +145,8 @@
justify-content: center;
gap: 5px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
border: 1px solid #e5e7eb;
border-radius: 2px;
background: #fff;
}
@@ -152,8 +154,8 @@
min-width: 0;
color: #0f172a;
font-size: 12px;
font-weight: 800;
line-height: 1.5;
font-weight: 900;
line-height: 1.45;
overflow-wrap: anywhere;
}
@@ -165,12 +167,75 @@
color: #b91c1c;
}
.employee-risk-decision-action p {
margin: 0;
color: #475569;
font-size: 12px;
line-height: 1.5;
}
.employee-risk-review-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0;
}
.employee-risk-review-item {
min-width: 0;
flex: 1 1 180px;
display: grid;
gap: 4px;
padding: 9px 10px;
border: 1px solid #e5e7eb;
border-radius: 2px;
background: #fff;
}
.employee-risk-review-item.medium {
border-color: #f3e8d9;
background: #fffcf7;
}
.employee-risk-review-item.high {
border-color: #fecaca;
background: #fff7f7;
}
.employee-risk-review-item dt,
.employee-risk-review-item dd {
margin: 0;
}
.employee-risk-review-item dt {
color: #64748b;
font-size: 10px;
font-weight: 850;
line-height: 1.4;
}
.employee-risk-review-item dd {
color: #334155;
font-size: 12px;
font-weight: 700;
line-height: 1.5;
overflow-wrap: anywhere;
}
.employee-risk-review-item.high dd {
color: #991b1b;
}
.employee-risk-review-item.medium dd {
color: #9a3412;
}
.employee-risk-profile-section {
display: grid;
gap: 8px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
border: 1px solid #e5e7eb;
border-radius: 2px;
background: #fff;
}
@@ -205,16 +270,16 @@
.employee-risk-evidence-row {
min-width: 0;
display: grid;
gap: 5px;
padding: 8px;
gap: 0;
border: 1px solid #e2e8f0;
border-radius: 4px;
border-radius: 2px;
background: #f8fafc;
overflow: hidden;
}
.employee-risk-evidence-row.medium {
border-color: #fed7aa;
background: #fffbf5;
border-color: #f3e8d9;
background: #fffcf7;
}
.employee-risk-evidence-row.high {
@@ -222,12 +287,26 @@
background: #fff7f7;
}
.employee-risk-evidence-row[open] {
background: #fff;
}
.employee-risk-evidence-row summary {
list-style: none;
cursor: pointer;
}
.employee-risk-evidence-row summary::-webkit-details-marker {
display: none;
}
.employee-risk-evidence-title {
min-height: 20px;
min-height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
color: #0f172a;
font-size: 11px;
font-weight: 850;
@@ -262,13 +341,26 @@
color: #b91c1c;
}
.employee-risk-evidence-title::after {
content: '展开';
flex: 0 0 auto;
color: #94a3b8;
font-size: 10px;
font-weight: 800;
}
.employee-risk-evidence-row[open] .employee-risk-evidence-title::after {
content: '收起';
}
.employee-risk-evidence-row ul {
display: grid;
gap: 3px;
margin: 0;
padding: 0;
padding: 0 10px 10px 10px;
list-style: none;
align-content: start;
border-top: 1px solid #e2e8f0;
}
.employee-risk-evidence-row li {
@@ -291,6 +383,10 @@
grid-template-columns: 1fr;
}
.employee-risk-review-item {
flex-basis: 100%;
}
.employee-risk-title-wrap,
.employee-risk-section-head {
flex-wrap: wrap;

View File

@@ -380,6 +380,14 @@
min-width: 0;
}
.topbar-utility-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
}
.topbar-icon-btn {
position: relative;
width: 34px;
@@ -1113,6 +1121,68 @@
font-size: 16px;
}
.topbar-ai-mode-toggle {
flex: 0 0 38px;
width: 38px;
height: 38px;
display: inline-grid;
place-items: center;
padding: 0;
border: 2px solid transparent;
border-radius: 50%;
background:
linear-gradient(#ffffff, #ffffff) padding-box,
conic-gradient(from 210deg, #15b8c8, #4f6fef, #b65cff, #ec4899, #f59e0b, #15b8c8) border-box;
box-shadow:
0 8px 18px rgba(79, 111, 239, 0.16),
0 0 0 1px rgba(255, 255, 255, 0.78) inset;
transition:
transform 180ms var(--ease),
box-shadow 180ms var(--ease),
filter 180ms var(--ease);
}
.topbar-ai-mode-toggle__glyph {
display: inline-block;
background: linear-gradient(135deg, #0ea5b7 4%, #4f6fef 34%, #a855f7 58%, #ec4899 76%, #f59e0b 96%);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
font-size: 15px;
font-weight: 950;
line-height: 1;
letter-spacing: 0;
}
.topbar-ai-mode-toggle:hover,
.topbar-ai-mode-toggle:focus-visible {
transform: translateY(-1px);
box-shadow:
0 12px 24px rgba(79, 111, 239, 0.2),
0 0 0 4px rgba(236, 72, 153, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.86) inset;
}
.topbar-ai-mode-toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--theme-primary-active) 72%, #ffffff);
outline-offset: 3px;
}
.topbar-ai-mode-toggle.active {
filter: saturate(1.1);
box-shadow:
0 12px 24px rgba(79, 111, 239, 0.22),
0 0 0 4px rgba(14, 165, 183, 0.09),
0 0 0 1px rgba(255, 255, 255, 0.88) inset;
}
.topbar-ai-mode-toggle:not(.active) {
filter: saturate(0.82);
box-shadow:
0 6px 14px rgba(15, 23, 42, 0.1),
0 0 0 1px rgba(255, 255, 255, 0.82) inset;
}
.kpi-chip {
display: grid;
grid-template-columns: auto auto;
@@ -1259,6 +1329,10 @@
gap: 12px;
}
.topbar-utility-actions {
gap: 8px;
}
.topbar-icon-btn {
width: 30px;
height: 30px;
@@ -1271,6 +1345,16 @@
font-size: 12px;
}
.topbar-ai-mode-toggle {
flex: 0 0 34px;
width: 34px;
height: 34px;
}
.topbar-ai-mode-toggle__glyph {
font-size: 14px;
}
.kpi-chips {
gap: 8px;
}
@@ -1329,6 +1413,7 @@
.search-wrap,
.search-wrap.wide,
.topbar-toolset,
.topbar-utility-actions,
.detail-alert-strip,
.month-chip,
.qa-filter,
@@ -1344,6 +1429,15 @@
justify-content: flex-end;
}
.topbar-utility-actions {
justify-content: flex-end;
}
.topbar-ai-mode-toggle {
width: 34px;
height: 34px;
}
.range-shell {
flex: 1;
}
@@ -1505,6 +1599,10 @@
justify-content: space-between;
}
.topbar-ai-mode-toggle {
flex: 0 0 34px;
}
.range-combo {
display: grid;
gap: 8px;

View File

@@ -56,16 +56,52 @@
object-fit: cover;
}
.message-bubble {
max-width: min(100%, 760px);
padding: 12px 14px;
border: 1px solid #d8e4f0;
border-radius: 4px;
background: #ffffff;
color: #24324a;
font-size: var(--wb-fs-bubble, 13px);
line-height: 1.62;
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14);
.message-bubble-compact-guidance {
max-width: min(100%, 640px);
padding: 10px 12px;
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 8px 18px rgba(148, 163, 184, 0.12);
}
.message-bubble-compact-guidance .message-meta {
margin-bottom: 6px;
}
.message-bubble-compact-guidance .message-meta strong {
font-size: 12px;
}
.message-bubble-compact-guidance .message-answer-content {
font-size: 12px;
}
.message-bubble-compact-guidance .message-answer-markdown {
display: grid;
gap: 6px;
}
.message-bubble-compact-guidance .message-answer-markdown :deep(h3) {
margin: 0;
padding-left: 8px;
border-left: 3px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.42);
color: #17324a;
font-size: 12px;
font-weight: 860;
line-height: 1.4;
}
.message-bubble-compact-guidance .message-answer-markdown :deep(ul),
.message-bubble-compact-guidance .message-answer-markdown :deep(ol) {
gap: 4px;
}
.message-bubble-compact-guidance .message-answer-markdown :deep(li) {
line-height: 1.55;
}
.message-bubble-compact-guidance .message-suggested-actions {
margin-top: 8px;
}
.message-row.has-steward-plan .message-bubble {
@@ -135,7 +171,7 @@
.steward-intent-event-list {
margin: 0;
padding: 0 12px 12px 30px;
padding: 0 12px 12px 44px;
display: grid;
gap: 7px;
}
@@ -274,6 +310,42 @@
color: #24324a;
}
.message-answer-markdown {
display: grid;
gap: 8px;
word-break: break-word;
overflow-wrap: anywhere;
}
.message-answer-markdown :deep(h1),
.message-answer-markdown :deep(h2),
.message-answer-markdown :deep(h3),
.message-answer-markdown :deep(h4) {
margin: 0;
color: #0f172a;
line-height: 1.35;
}
.message-answer-markdown :deep(h1) {
font-size: 15px;
font-weight: 860;
}
.message-answer-markdown :deep(h2) {
font-size: 14px;
font-weight: 850;
}
.message-answer-markdown :deep(h3) {
font-size: 13px;
font-weight: 840;
}
.message-answer-markdown :deep(h4) {
font-size: 12px;
font-weight: 820;
}
.message-answer-markdown :deep(p),
.message-answer-markdown :deep(li),
.message-answer-markdown :deep(td),
@@ -281,16 +353,66 @@
.message-answer-markdown :deep(blockquote) {
margin: 0;
color: inherit;
line-height: 1.62;
line-height: 1.6;
}
.message-answer-markdown :deep(p + p),
.message-answer-markdown :deep(p + ul),
.message-answer-markdown :deep(p + ol),
.message-answer-markdown :deep(ul + p),
.message-answer-markdown :deep(ol + p) {
.message-answer-markdown :deep(ol + p),
.message-answer-markdown :deep(blockquote + p) {
margin-top: 8px;
}
.message-answer-markdown :deep(ul),
.message-answer-markdown :deep(ol) {
margin: 0;
padding-left: 1.2em;
display: grid;
gap: 6px;
}
.message-answer-markdown :deep(li) {
margin: 0;
}
.message-answer-markdown :deep(li > p) {
margin: 0;
}
.message-answer-markdown :deep(blockquote) {
padding: 8px 10px;
border-left: 3px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.38);
border-radius: 4px;
background: #f8fbff;
color: #475569;
}
.message-answer-markdown :deep(code) {
padding: 0 5px;
border-radius: 4px;
background: #eef6fb;
color: #1d4ed8;
font-size: 0.95em;
}
.message-answer-markdown :deep(pre) {
margin: 0;
padding: 10px 12px;
overflow: auto;
border-radius: 4px;
background: #0f172a;
color: #e2e8f0;
}
.message-answer-markdown :deep(pre code) {
padding: 0;
background: transparent;
color: inherit;
font-size: 12px;
}
.message-answer-markdown :deep(strong) {
color: #0f172a;
font-weight: 850;
@@ -649,6 +771,40 @@
gap: 8px;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions {
grid-template-columns: repeat(auto-fit, minmax(136px, 1fr));
gap: 6px;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-btn {
min-height: 40px;
padding: 8px 10px;
grid-template-columns: 22px minmax(0, 1fr);
gap: 8px;
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
background: #ffffff;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-icon {
width: 22px;
height: 22px;
font-size: 13px;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-copy {
gap: 0;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-title {
font-size: 12px;
line-height: 1.35;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-btn small,
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-arrow {
display: none;
}
.structured-card-reveal-enter-active {
transition:
opacity 220ms cubic-bezier(0.2, 0, 0, 1),

View File

@@ -0,0 +1,40 @@
.workbench-mode-fade-enter-active,
.workbench-mode-fade-leave-active {
transition:
opacity 220ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)),
transform 220ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)),
filter 220ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1));
transform-origin: 50% 24px;
will-change: opacity, transform, filter;
}
.workbench-mode-fade-enter-from,
.workbench-mode-fade-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.992);
filter: blur(2px);
}
.workbench-mode-fade-enter-to,
.workbench-mode-fade-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
@media (prefers-reduced-motion: reduce) {
.workbench-mode-fade-enter-active,
.workbench-mode-fade-leave-active {
transition: none;
will-change: auto;
}
.workbench-mode-fade-enter-from,
.workbench-mode-fade-leave-to,
.workbench-mode-fade-enter-to,
.workbench-mode-fade-leave-from {
opacity: 1;
transform: none;
filter: none;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -7,168 +7,35 @@
note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。"
/>
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
<div class="assistant-copy">
<h1 class="assistant-hero-title">
{{ typedTitlePrefix }}<span v-if="titleTypingDone">小财管家</span><span v-if="!titleTypingDone" class="typing-cursor">|</span>
</h1>
<article class="panel workbench-trend-hero">
<div class="workbench-trend-card" aria-label="报销趋势同比">
<div class="trend-summary-panel">
<h1>报销趋势</h1>
<p>{{ reimbursementTrendRangeLabel }}</p>
<strong class="trend-total">{{ reimbursementTrendTotalLabel }}</strong>
<span class="trend-change" :class="reimbursementTrendGrowthTone">
<i :class="reimbursementTrendGrowthIcon" aria-hidden="true"></i>
{{ reimbursementTrendGrowthLabel }} 同比去年同期
</span>
<small>{{ displayUserName }} · {{ reimbursementTrendSignalLabel }}</small>
</div>
<input
ref="fileInputRef"
class="assistant-file-input"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleWorkbenchFilesChange"
/>
<div class="trend-chart-panel">
<div class="trend-chart-head">
<strong>月度报销明细</strong>
<span class="trend-chart-source">与分析看板同源</span>
</div>
<div class="assistant-composer">
<textarea
ref="assistantInputRef"
v-model="assistantDraft"
maxlength="1000"
rows="2"
placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行..."
:readonly="isComposerPending"
@keydown.enter.prevent="handleWorkbenchEnter"
<TrendChart
class="workbench-trend-chart"
mode="compareAmount"
:labels="reimbursementTrendLabels"
:claim-amount="reimbursementTrendAmounts"
:comparison-amount="reimbursementTrendPreviousAmounts"
primary-label="本期"
comparison-label="去年同期"
compact
/>
<div
v-if="composerPendingLabel"
class="assistant-intent-status"
role="status"
aria-live="polite"
>
<i class="mdi mdi-loading mdi-spin"></i>
<span>{{ composerPendingLabel }}</span>
</div>
<div v-if="workbenchDateTagLabel" class="workbench-date-chip-row">
<span class="workbench-date-chip">
<i class="mdi mdi-calendar-check"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button
type="button"
aria-label="移除日期"
:disabled="Boolean(pendingAction)"
@click="removeWorkbenchDateTag"
>
<i class="mdi mdi-close"></i>
</button>
</span>
</div>
<div class="composer-toolbar">
<button
type="button"
class="composer-icon-button"
title="上传附件"
aria-label="上传附件"
:disabled="Boolean(pendingAction)"
@click="triggerFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<div class="workbench-date-anchor">
<button
type="button"
class="composer-icon-button"
:class="{ active: workbenchDatePickerOpen }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="Boolean(pendingAction)"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="composer-date-popover"
role="dialog"
aria-label="日期选择"
@click.stop
>
<div class="composer-date-mode-tabs">
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: workbenchDateMode === 'single' }"
@click="setWorkbenchDateMode('single')"
>
当天
</button>
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: workbenchDateMode === 'range' }"
@click="setWorkbenchDateMode('range')"
>
时间段
</button>
</div>
<div v-if="workbenchDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="workbenchSingleDate" type="date" @change="handleWorkbenchDateInputChange('single')" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="workbenchRangeStartDate" type="date" @change="handleWorkbenchDateInputChange('range-start')" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
<span>结束</span>
<input v-model="workbenchRangeEndDate" type="date" :min="workbenchRangeStartDate" @change="handleWorkbenchDateInputChange('range-end')" />
</label>
</div>
<p v-if="workbenchDateMode === 'range' && !workbenchCanApplyDateSelection" class="composer-date-hint">
请确认结束日期不早于开始日期
</p>
</div>
</div>
<span class="composer-count">{{ assistantDraft.length }}/1000</span>
<button
type="button"
class="composer-send-button"
:disabled="Boolean(pendingAction)"
:aria-label="composerPendingLabel || expenseActionLabel"
@click="handleExpenseConversationAction"
>
<i :class="pendingAction ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
</button>
</div>
</div>
<div v-if="selectedFiles.length" class="assistant-file-strip">
<span class="assistant-file-note">已带入 {{ selectedFiles.length }} 份附件</span>
<span v-for="file in selectedFiles" :key="file.name" class="assistant-file-chip">{{ file.name }}</span>
<button type="button" class="assistant-file-clear" @click="clearSelectedFiles">清空</button>
</div>
<div class="quick-prompts" aria-label="常用提问">
<span>常用提问</span>
<button
v-for="prompt in quickPromptItems"
:key="prompt"
type="button"
@click="applyQuickPrompt(prompt)"
>
{{ prompt }}
</button>
<button type="button" class="quick-more" @click="emit('open-assistant')">
更多
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
</div>
</article>
@@ -303,29 +170,21 @@
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import TrendChart from '../charts/TrendChart.vue'
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.vue'
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'
import {
buildExpenseStatItems,
filterAssistantCapabilitiesForUser,
quickPromptItems,
resolveWorkbenchCapabilityGridClass,
} from '../../data/personalWorkbench.js'
import { fetchAgentRuns } from '../../services/agentAssets.js'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
import { fetchCurrentEmployeeLatestProfile } from '../../services/reimbursements.js'
import {
ASSISTANT_SESSION_SNAPSHOT_EVENT,
hasAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
import { buildWorkbenchCapabilityAssistantPayload } from '../../utils/personalWorkbenchAssistantEntry.js'
import {
buildProfileOperationsFromAgentRuns,
@@ -344,35 +203,6 @@ const props = defineProps({
const emit = defineEmits(['open-assistant', 'open-document'])
const { currentUser } = useSystemState()
const { toast } = useToast()
const assistantDraft = ref('')
const assistantInputRef = ref(null)
const fileInputRef = ref(null)
const selectedFiles = ref([])
const pendingAction = ref('')
let pendingActionTimer = 0
const {
workbenchDatePickerOpen,
workbenchDateMode,
workbenchSingleDate,
workbenchRangeStartDate,
workbenchRangeEndDate,
workbenchDateTagLabel,
workbenchCanApplyDateSelection,
clearWorkbenchDateSelection,
toggleWorkbenchDatePicker,
closeWorkbenchDatePicker,
setWorkbenchDateMode,
handleWorkbenchDatePickerOutside,
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
} = useWorkbenchComposerDate({
draft: assistantDraft,
focusInput: focusAssistantInput
})
const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
const expenseStatsModalOpen = ref(false)
const expenseProfileModalOpen = ref(false)
const employeeProfile = ref(null)
@@ -380,59 +210,13 @@ const employeeProfileRuns = ref([])
const employeeProfileLoading = ref(false)
const employeeProfileError = ref('')
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)
|| hasLocalExpenseSnapshot.value
)
const displayUserName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
})
const heroTitleText = computed(() => `嗨,${displayUserName.value},我是您的 `)
const typedTitlePrefix = ref('')
const titleTypingDone = ref(false)
let typingInterval = null
const startTypewriter = () => {
typedTitlePrefix.value = ''
titleTypingDone.value = false
clearInterval(typingInterval)
let i = 0
const text = heroTitleText.value
typingInterval = setInterval(() => {
if (i < text.length) {
typedTitlePrefix.value += text.charAt(i)
i++
} else {
clearInterval(typingInterval)
titleTypingDone.value = true
}
}, 60)
}
watch(displayUserName, (newVal, oldVal) => {
if (oldVal !== newVal && titleTypingDone.value) {
typedTitlePrefix.value = `嗨,${newVal},我是您的 `
}
})
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const isComposerPending = computed(() => Boolean(pendingAction.value))
const composerPendingLabel = computed(() => {
if (pendingAction.value === 'intent') {
return '正在识别意图,准备进入对应助手...'
}
if (pendingAction.value === 'expense') {
return '正在恢复最近报销会话...'
}
return ''
})
const visibleAssistantCapabilities = computed(() => filterAssistantCapabilitiesForUser(currentUser.value))
const capabilityGridClass = computed(() => resolveWorkbenchCapabilityGridClass(currentUser.value))
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
@@ -468,133 +252,100 @@ const currentUserProfileKey = computed(() => {
const user = currentUser.value || {}
return [user.username, user.email, user.name, user.employeeNo, user.employee_no].map((item) => String(item || '').trim()).filter(Boolean).join('|')
})
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
function formatCurrencyValue(value) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(Number(value) || 0)
}
function mergeSelectedFiles(existingFiles, incomingFiles) {
const nextFiles = []
const seen = new Set()
for (const file of existingFiles) {
const key = buildSelectedFileKey(file)
if (seen.has(key)) continue
seen.add(key)
nextFiles.push(file)
}
let overflowCount = 0
for (const file of incomingFiles) {
const key = buildSelectedFileKey(file)
if (seen.has(key)) continue
if (nextFiles.length >= MAX_ATTACHMENTS) {
overflowCount += 1
continue
function normalizeTrendRows(rows = []) {
return rows.map((row, index) => {
const amount = Number(row?.amount || 0)
const previousAmount = Number(row?.previousAmount || row?.previous_amount || 0)
return {
key: String(row?.key || `trend-${index}`),
label: String(row?.label || `${index + 1}`),
amount,
amountLabel: String(row?.amountLabel || row?.amount_label || formatCurrencyValue(amount)),
previousKey: String(row?.previousKey || row?.previous_key || `previous-${index}`),
previousAmount,
previousAmountLabel: String(
row?.previousAmountLabel || row?.previous_amount_label || formatCurrencyValue(previousAmount)
)
}
seen.add(key)
nextFiles.push(file)
}
return {
files: nextFiles,
overflowCount
}
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
})
}
const sourceReimbursementTrendRows = computed(() => normalizeTrendRows(props.workbenchSummary.reimbursementTrendRows || []))
const reimbursementTrendHasSignal = computed(() =>
sourceReimbursementTrendRows.value.some((item) => item.amount > 0 || item.previousAmount > 0)
)
const reimbursementTrendRows = computed(() => sourceReimbursementTrendRows.value)
const reimbursementTrendSignalLabel = computed(() =>
reimbursementTrendHasSignal.value ? '来自你的真实单据' : '暂无单据时展示空走势'
)
const reimbursementTrendLabels = computed(() => reimbursementTrendRows.value.map((item) => item.label))
const reimbursementTrendAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.amount))
const reimbursementTrendPreviousAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.previousAmount))
const reimbursementTrendTotal = computed(() =>
reimbursementTrendRows.value.reduce((total, item) => total + item.amount, 0)
)
const reimbursementTrendPreviousTotal = computed(() =>
reimbursementTrendRows.value.reduce((total, item) => total + item.previousAmount, 0)
)
const reimbursementTrendTotalLabel = computed(() => formatCurrencyValue(reimbursementTrendTotal.value))
const reimbursementTrendRangeLabel = computed(() => {
const rows = reimbursementTrendRows.value
const first = rows[0]
const last = rows[rows.length - 1]
if (!first || !last) {
return '近 6 个月'
}
return `${first.label} - ${last.label}`
})
const reimbursementTrendGrowthRate = computed(() => {
const previousTotal = reimbursementTrendPreviousTotal.value
if (previousTotal > 0) {
return ((reimbursementTrendTotal.value - previousTotal) / previousTotal) * 100
}
return reimbursementTrendTotal.value > 0 ? 100 : 0
})
const reimbursementTrendGrowthLabel = computed(() => {
const value = reimbursementTrendGrowthRate.value
const prefix = value >= 0 ? '+' : ''
return `${prefix}${value.toFixed(1)}%`
})
const reimbursementTrendGrowthTone = computed(() =>
reimbursementTrendGrowthRate.value >= 0 ? 'is-up' : 'is-down'
)
const reimbursementTrendGrowthIcon = computed(() =>
reimbursementTrendGrowthRate.value >= 0 ? 'mdi mdi-arrow-up-right' : 'mdi mdi-arrow-down-right'
)
function buildAssistantPayload() {
return {
prompt: buildWorkbenchPromptText(),
prompt: '',
source: 'workbench',
sessionType: SESSION_TYPE_STEWARD,
files: Array.from(selectedFiles.value)
files: []
}
}
function clearSelectedFiles() {
selectedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function resetWorkbenchDraft() {
assistantDraft.value = ''
clearSelectedFiles()
clearWorkbenchDateSelection()
}
function clearPendingAction() {
pendingAction.value = ''
if (pendingActionTimer) {
window.clearTimeout(pendingActionTimer)
pendingActionTimer = 0
}
}
function startPendingAction(action) {
clearPendingAction()
pendingAction.value = action
pendingActionTimer = window.setTimeout(() => {
if (pendingAction.value !== action) {
return
}
clearPendingAction()
toast('进入助手耗时较长,请稍后重试。')
}, 16000)
}
function shouldShowIntentPending(payload = {}) {
return !props.assistantModalOpen
&& String(payload.prompt || '').trim()
&& String(payload.source || 'workbench').trim() === 'workbench'
&& !String(payload.sessionType || '').trim()
}
function emitAssistant(payload) {
emit('open-assistant', payload)
resetWorkbenchDraft()
}
async function loadLatestConversation() {
const payload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return payload?.found ? payload.conversation || null : null
}
function focusAssistantInput() {
nextTick(() => {
assistantInputRef.value?.focus()
})
}
function applyQuickPrompt(prompt) {
assistantDraft.value = String(prompt || '').trim()
focusAssistantInput()
}
function openPromptAssistant(prompt, sessionType = SESSION_TYPE_STEWARD) {
if (pendingAction.value) {
return
}
const payload = {
prompt: buildWorkbenchPromptText(prompt),
emitAssistant({
prompt: String(prompt || '').trim(),
source: 'workbench',
sessionType,
files: Array.from(selectedFiles.value),
files: [],
conversation: null
}
if (shouldShowIntentPending(payload)) {
startPendingAction('intent')
}
emitAssistant(payload)
})
}
function openWorkbenchTarget(item) {
@@ -614,10 +365,6 @@ function openWorkbenchTarget(item) {
}
function openCapabilityAssistant(item) {
if (pendingAction.value) {
return
}
emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload()))
}
@@ -669,122 +416,10 @@ function closeExpenseProfileModal() {
expenseProfileModalOpen.value = false
}
function handleWorkbenchEnter(event) {
if (event.isComposing) {
return
}
handleExpenseConversationAction()
}
function triggerFileUpload() {
fileInputRef.value?.click()
}
function handleWorkbenchFilesChange(event) {
const mergeResult = mergeSelectedFiles(selectedFiles.value, Array.from(event.target.files ?? []))
selectedFiles.value = mergeResult.files
if (mergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
async function refreshLatestExpenseConversation() {
refreshLocalExpenseSnapshot()
try {
latestExpenseConversation.value = await loadLatestConversation()
} catch (error) {
console.warn('Failed to refresh latest expense conversation:', error)
latestExpenseConversation.value = null
}
}
function refreshLocalExpenseSnapshot() {
hasLocalExpenseSnapshot.value = hasAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
}
function handleAssistantSessionSnapshotChange(event) {
const sessionType = String(event?.detail?.sessionType || '').trim()
if (!sessionType || sessionType === SESSION_TYPE_EXPENSE) {
refreshLocalExpenseSnapshot()
}
}
async function clearKnowledgeHistoryBeforeExpense() {
await clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
}
async function handleExpenseConversationAction() {
if (pendingAction.value) {
return
}
const nextPayload = buildAssistantPayload()
const shouldOpenImmediately = Boolean(nextPayload.prompt || nextPayload.files.length)
if (shouldOpenImmediately) {
if (shouldShowIntentPending(nextPayload)) {
startPendingAction('intent')
}
emitAssistant({
...nextPayload,
conversation: null
})
void clearKnowledgeHistoryBeforeExpense().catch((error) => {
console.warn('Failed to clear knowledge history before expense:', error)
})
return
}
startPendingAction('expense')
try {
await clearKnowledgeHistoryBeforeExpense()
const conversation = await loadLatestConversation()
latestExpenseConversation.value = conversation
emitAssistant({
...nextPayload,
conversation
})
} catch (error) {
console.warn('Failed to open expense conversation:', error)
toast(error?.message || '打开报销会话失败,请稍后重试。')
} finally {
clearPendingAction()
}
}
onMounted(() => {
startTypewriter()
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
loadCurrentEmployeeProfile()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
onBeforeUnmount(() => {
clearInterval(typingInterval)
clearPendingAction()
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
watch(
() => props.assistantModalOpen,
(open, previous) => {
if (open) {
clearPendingAction()
}
if (previous && !open) {
refreshLatestExpenseConversation()
}
}
)
watch(currentUserProfileKey, (nextKey, previousKey) => {
if (nextKey && nextKey !== previousKey) {
loadCurrentEmployeeProfile()
@@ -794,6 +429,5 @@ watch(currentUserProfileKey, (nextKey, previousKey) => {
<style scoped src="../../assets/styles/components/personal-workbench.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-glass.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-composer-date.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<template>
<div class="trend-chart">
<div class="trend-chart" :class="{ 'trend-chart-compact': compact, 'trend-chart-dark': dark }">
<div class="chart-toolbar">
<div class="chart-legend">
<span
@@ -39,6 +39,10 @@ const props = defineProps({
claimCount: { type: Array, default: () => [] },
claimAmount: { type: Array, default: () => [] },
categoryAmountSeries: { type: Array, default: () => [] },
comparisonAmount: { type: Array, default: () => [] },
primaryLabel: { type: String, default: '报销金额' },
comparisonLabel: { type: String, default: '去年同期' },
compact: { type: Boolean, default: false },
applications: { type: Array, default: () => [] },
approved: { type: Array, default: () => [] }
})
@@ -46,6 +50,7 @@ const props = defineProps({
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const isCountMode = computed(() => props.mode === 'count')
const isComparisonMode = computed(() => props.mode === 'compareAmount')
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue,
@@ -93,14 +98,30 @@ const stackedAmountData = computed(() => props.labels.map((_, index) => [
index,
...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0))
]))
const activeColor = computed(() => (
isCountMode.value ? chartColors.value.primary : chartColors.value.blue
))
const activeColor = computed(() => {
return isCountMode.value ? chartColors.value.primary : chartColors.value.blue
})
const comparisonColor = computed(() => '#cbd5e1')
const legendLabel = computed(() => (
isCountMode.value ? '报销数量' : '报销金额'
isCountMode.value ? '报销数量' : (isComparisonMode.value ? props.primaryLabel : '报销金额')
))
const unitLabel = computed(() => (isCountMode.value ? '单位:单' : '单位:元'))
const legendItems = computed(() => {
if (isComparisonMode.value) {
return [
{
name: props.primaryLabel,
color: activeColor.value,
title: `${props.primaryLabel} ${unitLabel.value}`
},
{
name: props.comparisonLabel,
color: comparisonColor.value,
title: `${props.comparisonLabel} ${unitLabel.value}`
}
]
}
if (amountCategorySeries.value.length) {
return amountCategorySeries.value.map((item, index) => ({
name: item.name || `费用类型 ${index + 1}`,
@@ -114,23 +135,144 @@ const legendItems = computed(() => {
title: `${legendLabel.value} ${unitLabel.value}`
}]
})
const maxValue = computed(() => Math.max(...activeSeries.value.map((value) => Number(value || 0)), 1))
const comparisonSeries = computed(() => (
Array.isArray(props.comparisonAmount) ? props.comparisonAmount : []
))
const maxValue = computed(() => {
const values = [
...activeSeries.value.map((value) => Number(value || 0)),
...(isComparisonMode.value ? comparisonSeries.value.map((value) => Number(value || 0)) : [])
]
const rawMax = Math.max(...values, 0)
if (isCountMode.value) {
return Math.max(rawMax, 5)
}
return Math.max(rawMax, 100)
})
const compactScale = computed(() => ({
axisLabelSize: props.compact ? 12 : 11,
comparisonLineWidth: props.compact ? 3 : 2.5,
comparisonSymbolSize: props.compact ? 7.5 : 6,
defaultLineWidth: props.compact ? 3 : 2.5,
defaultSymbolSize: props.compact ? 8 : 7,
gridBottom: props.compact ? 18 : 22,
gridLeft: props.compact ? 42 : 36,
gridRight: props.compact ? 28 : 24,
gridTop: props.compact ? 10 : 12,
primaryLineWidth: props.compact ? 3.8 : 3,
primarySymbolSize: props.compact ? 8.5 : 7
}))
const chartGrid = computed(() => ({
top: compactScale.value.gridTop,
right: compactScale.value.gridRight,
bottom: compactScale.value.gridBottom,
left: compactScale.value.gridLeft,
containLabel: true
}))
const stackedMaxValue = computed(() => {
if (!amountCategorySeries.value.length) {
if (isComparisonMode.value || !amountCategorySeries.value.length) {
return maxValue.value
}
const dailyTotals = props.labels.map((_, index) => amountCategorySeries.value
.reduce((sum, item) => sum + Number(item.data?.[index] || 0), 0))
return Math.max(...dailyTotals, 1)
const rawMax = Math.max(...dailyTotals, 0)
if (isCountMode.value) {
return Math.max(rawMax, 5)
}
return Math.max(rawMax, 100)
})
function getFormattedMax(val, isCount) {
if (isCount) {
const base = Math.max(val, 4)
if (base <= 4) return 4
if (base <= 6) return 6
if (base <= 10) return 10
return Math.ceil(base / 2) * 2
} else {
const base = Math.max(val, 100)
if (base <= 100) return 100
if (base <= 200) return 200
if (base <= 500) return 500
if (base <= 1000) return 1000
if (base <= 2000) return 2000
if (base <= 5000) return 5000
return Math.ceil(base / 1000) * 1000
}
}
const yAxisMax = computed(() => {
const calculatedMax = Math.ceil(stackedMaxValue.value * 1.18)
return getFormattedMax(calculatedMax, isCountMode.value)
})
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
isCountMode.value
isComparisonMode.value
? `${label}${props.primaryLabel}${formatCurrency(claimAmountSeries.value[index] || 0)}${props.comparisonLabel}${formatCurrency(comparisonSeries.value[index] || 0)}`
: isCountMode.value
? `${label}报销${claimCountSeries.value[index] || 0}`
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
)).join('')
)
const chartSeries = computed(() => {
if (isComparisonMode.value) {
return [
{
name: props.primaryLabel,
type: 'line',
data: claimAmountSeries.value,
smooth: true,
symbol: 'circle',
symbolSize: compactScale.value.primarySymbolSize,
lineStyle: {
width: compactScale.value.primaryLineWidth,
color: activeColor.value
},
itemStyle: {
color: '#ffffff',
borderColor: activeColor.value,
borderWidth: props.compact ? 3 : 2.5
},
areaStyle: {
opacity: 1,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(activeColor.value, 0.12) },
{ offset: 1, color: toRgba(activeColor.value, 0.01) }
]
}
},
tooltip: {
valueFormatter: (value) => formatCurrency(value)
}
},
{
name: props.comparisonLabel,
type: 'line',
data: comparisonSeries.value,
smooth: true,
symbol: 'circle',
symbolSize: compactScale.value.comparisonSymbolSize,
lineStyle: {
width: compactScale.value.comparisonLineWidth,
color: comparisonColor.value,
type: 'dashed'
},
itemStyle: {
color: '#ffffff',
borderColor: comparisonColor.value,
borderWidth: props.compact ? 2.5 : 2
},
tooltip: {
valueFormatter: (value) => formatCurrency(value)
}
}
]
}
if (!isCountMode.value && amountCategorySeries.value.length) {
return [{
name: '费用类型占比',
@@ -151,15 +293,15 @@ const chartSeries = computed(() => {
barWidth: 16,
smooth: isCountMode.value,
symbol: isCountMode.value ? 'circle' : 'none',
symbolSize: 7,
symbolSize: compactScale.value.defaultSymbolSize,
lineStyle: {
width: 2.5,
width: compactScale.value.defaultLineWidth,
color: activeColor.value
},
itemStyle: {
color: isCountMode.value ? '#ffffff' : activeColor.value,
borderColor: activeColor.value,
borderWidth: isCountMode.value ? 2.5 : 0,
borderWidth: isCountMode.value ? (props.compact ? 3 : 2.5) : 0,
borderRadius: [4, 4, 0, 0]
},
areaStyle: {
@@ -190,13 +332,7 @@ const chartOptions = computed(() => ({
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 12,
right: 24,
bottom: 22,
left: 36,
containLabel: true
},
grid: chartGrid.value,
tooltip: {
trigger: 'axis',
confine: true,
@@ -221,20 +357,22 @@ const chartOptions = computed(() => ({
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontSize: compactScale.value.axisLabelSize,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
max: Math.ceil(stackedMaxValue.value * 1.18),
splitNumber: 5,
max: yAxisMax.value,
interval: props.compact ? (yAxisMax.value / 2) : undefined,
splitNumber: props.compact ? 2 : 5,
name: '',
axisLabel: {
color: '#64748b',
fontSize: 11,
fontSize: compactScale.value.axisLabelSize,
fontWeight: 700,
margin: props.compact ? 12 : 8,
formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value))
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
@@ -352,6 +490,16 @@ function formatTooltip(params) {
if (!first) {
return ''
}
if (isComparisonMode.value) {
const index = Number(first.dataIndex || 0)
const label = props.labels[index] || first.axisValueLabel || first.name || ''
return [
label,
`${props.primaryLabel}${formatCurrency(claimAmountSeries.value[index] || 0)}`,
`${props.comparisonLabel}${formatCurrency(comparisonSeries.value[index] || 0)}`
].join('<br/>')
}
if (!isCountMode.value && amountCategorySeries.value.length) {
return formatStackedTooltip(first)
}
@@ -406,6 +554,11 @@ function formatAxisCurrency(value) {
flex-direction: column;
}
.trend-chart-compact {
height: 100%;
min-height: 124px;
}
.chart-toolbar {
min-height: 30px;
display: flex;
@@ -465,4 +618,39 @@ function formatAxisCurrency(value) {
flex: 1;
min-height: 0;
}
.trend-chart-compact .chart-toolbar {
min-height: 28px;
margin-bottom: 6px;
}
.trend-chart-compact .chart-legend {
gap: 6px 14px;
font-size: 13px;
}
.trend-chart-compact .legend-pill {
max-width: 128px;
}
.trend-chart-compact .chart-legend i {
width: 9px;
height: 9px;
}
.trend-chart-compact .chart-unit {
padding: 2px 8px;
font-size: 12.5px;
}
.trend-chart-dark .chart-legend,
.trend-chart-dark .legend-pill {
color: #64748b;
}
.trend-chart-dark .chart-unit {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #64748b;
}
</style>

View File

@@ -0,0 +1,321 @@
<template>
<aside class="ai-rail" :class="{ 'rail-collapsed': collapsed }" aria-label="AI模式导航">
<section class="ai-rail-brand" aria-label="当前产品标识">
<span class="ai-brand-logo" aria-hidden="true">
<img v-if="brandLogo" :src="brandLogo" alt="" />
<svg v-else viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
</span>
<span class="ai-brand-copy">
<strong>{{ displayBrandName }}</strong>
<small>AI 财务工作台</small>
</span>
</section>
<section class="ai-rail-section ai-rail-quick" aria-label="对话操作">
<template v-for="action in quickActions" :key="action.event">
<label
v-if="action.event === 'search' && conversationSearchOpen"
class="ai-conversation-search"
>
<i class="mdi mdi-magnify" aria-hidden="true"></i>
<input
ref="conversationSearchInputRef"
v-model="conversationSearchQuery"
type="search"
placeholder="搜索对话标题"
@keydown.esc.prevent="closeConversationSearch"
/>
<button
type="button"
:aria-label="conversationSearchQuery ? '清空对话搜索' : '关闭对话搜索'"
@click="handleConversationSearchAuxAction"
>
<i class="mdi mdi-close" aria-hidden="true"></i>
</button>
</label>
<button
v-else
type="button"
class="ai-quick-btn"
:class="{ primary: action.primary }"
@click="handleQuickAction(action.event)"
>
<i :class="action.icon" aria-hidden="true"></i>
<span>{{ action.label }}</span>
</button>
</template>
</section>
<div class="ai-rail-divider"></div>
<nav class="ai-rail-section ai-rail-nav" aria-label="业务导航">
<div class="ai-nav-list">
<button
v-for="item in businessNavItems"
:key="item.id"
type="button"
class="ai-nav-btn"
:class="{ active: activeView === item.id }"
:aria-current="activeView === item.id ? 'page' : undefined"
@click="emit('navigate', item.id)"
>
<span class="ai-nav-icon" aria-hidden="true">
<i :class="item.aiIcon"></i>
</span>
<span class="ai-nav-copy">
<strong>{{ item.displayLabel }}</strong>
</span>
</button>
</div>
</nav>
<div class="ai-rail-divider"></div>
<section class="ai-rail-section ai-rail-recents" aria-label="最近对话">
<h2 class="ai-section-heading">最近对话</h2>
<div class="ai-recents-list">
<div
v-for="recent in filteredConversationHistory"
:key="recent.id"
role="button"
tabindex="0"
class="ai-recent-item"
:class="{ active: activeConversationId === recent.id }"
:aria-current="activeConversationId === recent.id ? 'true' : undefined"
@click="handleRecentClick(recent)"
@dblclick.stop="startEditingRecentTitle(recent)"
@keydown.enter.prevent="emit('open-recent', recent)"
@keydown.space.prevent="emit('open-recent', recent)"
>
<span class="ai-recent-main">
<input
v-if="editingConversationId === recent.id"
ref="editingTitleInputRef"
v-model="editingConversationTitle"
class="ai-recent-title-input"
type="text"
aria-label="编辑对话标题"
@click.stop
@dblclick.stop
@keydown.enter.prevent="commitRecentTitleEdit(recent)"
@keydown.esc.prevent="cancelRecentTitleEdit"
@blur="commitRecentTitleEdit(recent)"
/>
<span v-else class="ai-recent-title">{{ recent.title }}</span>
<span class="ai-recent-desc">{{ recent.desc }}</span>
</span>
<span class="ai-recent-time">{{ recent.time }}</span>
</div>
<p v-if="!normalizedConversationHistory.length" class="ai-recents-empty">暂无历史对话</p>
<p v-else-if="!filteredConversationHistory.length" class="ai-recents-empty">没有匹配的对话</p>
</div>
</section>
<section class="ai-rail-user" aria-label="当前用户">
<div class="ai-user-avatar" aria-hidden="true">{{ displayUser.avatar }}</div>
<div class="ai-user-copy">
<strong>{{ displayUser.name }}</strong>
<span>{{ displayUser.subtitle }}</span>
</div>
<div class="ai-user-actions" aria-label="用户操作">
<button type="button" class="ai-user-action ai-user-logout" aria-label="退出系统" @click="emit('logout')">
<i class="mdi mdi-logout-variant" aria-hidden="true"></i>
</button>
</div>
</section>
</aside>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { resolveAiSidebarBusinessViewIds } from '../../utils/aiSidebarBusinessAccess.js'
const props = defineProps({
navItems: { type: Array, required: true },
activeView: { type: String, required: true },
activeConversationId: { type: String, default: '' },
brandName: { type: String, default: '' },
brandLogo: { type: String, default: '' },
currentUser: {
type: Object,
default: () => ({
name: '系统管理员',
role: '管理员',
avatar: '管'
})
},
collapsed: {
type: Boolean,
default: false
},
conversationHistory: { type: Array, default: () => [] }
})
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'])
const conversationSearchOpen = ref(false)
const conversationSearchQuery = ref('')
const conversationSearchInputRef = ref(null)
const editingConversationId = ref('')
const editingConversationTitle = ref('')
const editingTitleInputRef = ref(null)
let recentClickTimer = null
const quickActions = [
{
label: '新建对话',
icon: 'mdi mdi-plus',
event: 'new-chat',
primary: true
},
{
label: '查询对话',
icon: 'mdi mdi-magnify',
event: 'search'
}
]
const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控')
const sidebarMeta = {
overview: { label: '分析看板', icon: 'mdi mdi-chart-line-variant' },
documents: { label: '单据中心', icon: 'mdi mdi-file-document-outline' },
receiptFolder: { label: '票据夹', icon: 'mdi mdi-receipt-text-outline' },
budget: { label: '预算管理', icon: 'mdi mdi-chart-donut' },
policies: { label: '财务政策', icon: 'mdi mdi-book-open-page-variant-outline' },
audit: { label: '规则管理', icon: 'mdi mdi-shield-check-outline' },
digitalEmployees: { label: '数字员工', icon: 'mdi mdi-robot-outline' },
employees: { label: '员工管理', icon: 'mdi mdi-account-group-outline' },
settings: { label: '系统设置', icon: 'mdi mdi-tune-vertical' }
}
const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser)))
const businessNavItems = computed(() =>
props.navItems
.filter((item) => aiBusinessViewIds.value.has(item.id))
.map((item) => ({
...item,
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
aiIcon: sidebarMeta[item.id]?.icon ?? 'mdi mdi-circle-outline'
}))
)
const normalizedConversationHistory = computed(() => (
Array.isArray(props.conversationHistory) ? props.conversationHistory : []
))
const filteredConversationHistory = computed(() => {
const query = conversationSearchQuery.value.trim().toLowerCase()
if (!query) {
return normalizedConversationHistory.value
}
return normalizedConversationHistory.value.filter((recent) => (
String(recent.title || '').toLowerCase().includes(query)
))
})
const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员',
subtitle:
props.currentUser?.email ||
(props.currentUser?.username && props.currentUser?.username !== props.currentUser?.name ? props.currentUser.username : '') ||
props.currentUser?.role ||
'审批负责人',
avatar: props.currentUser?.avatar || String(props.currentUser?.name || '管').trim().slice(0, 1) || '管'
}))
function handleQuickAction(event) {
if (event === 'new-chat') {
emit('new-chat')
return
}
if (event === 'search') {
openConversationSearch()
}
}
function openConversationSearch() {
conversationSearchOpen.value = true
void nextTick(() => {
resolveInputElement(conversationSearchInputRef.value)?.focus()
})
}
function closeConversationSearch() {
conversationSearchOpen.value = false
conversationSearchQuery.value = ''
}
function handleConversationSearchAuxAction() {
if (conversationSearchQuery.value) {
conversationSearchQuery.value = ''
return
}
closeConversationSearch()
}
function startEditingRecentTitle(recent = {}) {
clearRecentClickTimer()
editingConversationId.value = String(recent.id || '').trim()
editingConversationTitle.value = String(recent.title || '').trim()
void nextTick(() => {
const input = resolveInputElement(editingTitleInputRef.value)
input?.focus()
input?.select()
})
}
function handleRecentClick(recent = {}) {
clearRecentClickTimer()
recentClickTimer = window.setTimeout(() => {
emit('open-recent', recent)
recentClickTimer = null
}, 180)
}
function clearRecentClickTimer() {
if (recentClickTimer) {
window.clearTimeout(recentClickTimer)
recentClickTimer = null
}
}
function cancelRecentTitleEdit() {
editingConversationId.value = ''
editingConversationTitle.value = ''
}
function commitRecentTitleEdit(recent = {}) {
if (editingConversationId.value !== String(recent.id || '').trim()) {
return
}
const title = editingConversationTitle.value.trim()
cancelRecentTitleEdit()
if (!title || title === String(recent.title || '').trim()) {
return
}
emit('rename-conversation', {
id: recent.id,
title
})
}
function resolveInputElement(value) {
return Array.isArray(value) ? value[0] : value
}
onBeforeUnmount(() => {
clearRecentClickTimer()
})
</script>
<style scoped src="../../assets/styles/components/ai-sidebar-rail.css"></style>

View File

@@ -56,63 +56,24 @@
</ElTooltip>
</nav>
<div
class="rail-user"
@mouseenter="openCollapsedUserMenu"
@mouseleave="closeCollapsedUserMenu"
@focusin="openCollapsedUserMenu"
@focusout="handleUserFocusOut"
>
<div v-if="!collapsed" class="user-menu" role="menu" aria-label="用户菜单">
<button class="user-menu-item" type="button" @click="emit('logout')">
<i class="mdi mdi-logout-variant"></i>
<span>退出系统</span>
<section class="rail-user" aria-label="当前用户">
<div class="user-avatar" aria-hidden="true">{{ displayUser.avatar }}</div>
<div class="user-copy">
<strong>{{ displayUser.name }}</strong>
<span>{{ displayUser.subtitle }}</span>
</div>
<div class="user-actions" aria-label="用户操作">
<button type="button" class="user-action user-logout" aria-label="退出系统" @click="emit('logout')">
<i class="mdi mdi-logout-variant" aria-hidden="true"></i>
</button>
</div>
<Teleport to="body">
<div
v-if="collapsed && userMenuOpen"
class="rail-user-menu-floating"
:style="userMenuStyle"
role="menu"
aria-label="用户菜单"
@mouseenter="clearUserMenuCloseTimer"
@mouseleave="closeCollapsedUserMenu"
>
<button class="user-menu-item" type="button" @click="handleLogout">
<i class="mdi mdi-logout-variant"></i>
<span>退出系统</span>
</button>
</div>
</Teleport>
<ElTooltip
:content="userTooltipContent"
placement="top"
effect="light"
:disabled="!collapsed || userMenuOpen"
:show-after="180"
:hide-after="0"
:offset="10"
popper-class="rail-tooltip-popper"
>
<div class="user-summary" tabindex="0" :aria-label="userTooltipContent">
<span class="user-avatar">{{ displayUser.avatar }}</span>
<span class="user-copy">
<strong>{{ displayUser.name }}</strong>
<span>{{ displayUser.role }}</span>
</span>
<i class="mdi mdi-chevron-up"></i>
</div>
</ElTooltip>
</div>
</section>
</aside>
</template>
<script setup>
import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { computed } from 'vue'
const props = defineProps({
navItems: { type: Array, required: true },
@@ -164,99 +125,16 @@ const decoratedNavItems = computed(() =>
const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员',
role: props.currentUser?.role || '管理员',
avatar: props.currentUser?.avatar || '管'
subtitle:
props.currentUser?.email ||
(props.currentUser?.username && props.currentUser?.username !== props.currentUser?.name ? props.currentUser.username : '') ||
props.currentUser?.role ||
'管理员',
avatar: props.currentUser?.avatar || String(props.currentUser?.name || '管').trim().slice(0, 1) || '管'
}))
const displayCompanyName = computed(() => props.companyName || '易财费控')
const collapseTooltipContent = computed(() => (props.collapsed ? '展开侧边栏' : '折叠侧边栏'))
const userTooltipContent = computed(() => [displayUser.value.name, displayUser.value.role].filter(Boolean).join(' · '))
const userMenuOpen = ref(false)
let userMenuCloseTimer = null
const userMenuPosition = reactive({
top: 0,
left: 0
})
const userMenuStyle = computed(() => ({
top: `${userMenuPosition.top}px`,
left: `${userMenuPosition.left}px`
}))
function resolveUserMenuAnchor(element) {
return element?.querySelector?.('.user-summary') || element
}
function clearUserMenuCloseTimer() {
if (userMenuCloseTimer) {
clearTimeout(userMenuCloseTimer)
userMenuCloseTimer = null
}
}
function openCollapsedUserMenu(event) {
if (!props.collapsed) {
return
}
clearUserMenuCloseTimer()
const anchor = resolveUserMenuAnchor(event?.currentTarget)
if (!anchor?.getBoundingClientRect) {
return
}
const rect = anchor.getBoundingClientRect()
userMenuPosition.top = rect.top + rect.height / 2
userMenuPosition.left = rect.right + 12
userMenuOpen.value = true
}
function closeCollapsedUserMenu() {
clearUserMenuCloseTimer()
userMenuCloseTimer = setTimeout(() => {
userMenuOpen.value = false
userMenuCloseTimer = null
}, 120)
}
function closeCollapsedUserMenuNow() {
clearUserMenuCloseTimer()
userMenuOpen.value = false
}
function handleUserFocusOut(event) {
if (!props.collapsed) {
return
}
const container = event.currentTarget
const nextTarget = event.relatedTarget
if (nextTarget && container?.contains(nextTarget)) {
return
}
closeCollapsedUserMenuNow()
}
function handleLogout() {
closeCollapsedUserMenuNow()
emit('logout')
}
watch(
() => props.collapsed,
(isCollapsed) => {
if (!isCollapsed) {
closeCollapsedUserMenuNow()
}
}
)
onBeforeUnmount(() => {
closeCollapsedUserMenuNow()
})
</script>

View File

@@ -1,10 +1,11 @@
<template>
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
<div class="title-group">
<div v-if="!isWorkbenchAiHome" class="title-group">
<div class="eyebrow">{{ eyebrowLabel }}</div>
<h1>{{ currentView.title }}</h1>
<p>{{ currentView.desc }}</p>
</div>
<div v-else class="title-group" aria-hidden="true"></div>
<div class="top-actions">
<template v-if="isChat">
@@ -278,12 +279,23 @@
</Transition>
</div>
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
</template>
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<button
type="button"
class="topbar-ai-mode-toggle"
:class="{ active: isTopbarAiMode }"
:aria-pressed="isTopbarAiMode"
:aria-label="topbarWorkbenchModeTitle"
:title="topbarWorkbenchModeTitle"
@click="toggleTopbarWorkbenchMode"
>
<span class="topbar-ai-mode-toggle__glyph">AI</span>
</button>
</div>
</template>
<template v-else-if="isDocuments">
<div class="kpi-chips">
@@ -345,18 +357,36 @@
</div>
</template>
<template v-else-if="isEmployees">
<div class="kpi-chips">
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
</template>
</div>
</header>
</template>
<template v-else-if="isEmployees">
<div class="kpi-chips">
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
</template>
<div v-if="showAiModeUtilityActions" class="topbar-utility-actions" aria-label="AI模式快捷操作">
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<button
type="button"
class="topbar-ai-mode-toggle"
:class="{ active: isTopbarAiMode }"
:aria-pressed="isTopbarAiMode"
:aria-label="topbarWorkbenchModeTitle"
:title="topbarWorkbenchModeTitle"
@click="toggleTopbarWorkbenchMode"
>
<span class="topbar-ai-mode-toggle__glyph">AI</span>
</button>
</div>
</div>
</header>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
@@ -394,14 +424,18 @@ const props = defineProps({
type: Object,
default: () => null
},
workbenchSummary: {
type: Object,
default: () => null
},
companyName: {
type: String,
default: ''
},
workbenchSummary: {
type: Object,
default: () => null
},
workbenchMode: {
type: String,
default: 'traditional'
},
companyName: {
type: String,
default: ''
},
detailMode: {
type: Boolean,
default: false
@@ -431,10 +465,11 @@ const emit = defineEmits([
'update:overviewDashboard',
'batchApprove',
'openChat',
'newApplication',
'openDocument',
'navigate'
])
'newApplication',
'openDocument',
'navigate',
'toggleWorkbenchMode'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
@@ -444,12 +479,16 @@ const isRequests = computed(() => props.activeView === 'requests')
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
const isEmployees = computed(() => props.activeView === 'employees')
const eyebrowLabel = computed(() => (
String(props.currentView?.eyebrow || '').trim()
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
))
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
const isTopbarAiMode = computed(() => props.workbenchMode === 'ai')
const topbarWorkbenchModeTitle = computed(() => (isTopbarAiMode.value ? 'AI 模式,点击切换传统模式' : '传统模式,点击切换 AI 模式'))
const isWorkbenchAiHome = computed(() => isWorkbench.value && isTopbarAiMode.value)
const showAiModeUtilityActions = computed(() => isTopbarAiMode.value && !isWorkbench.value)
const MAX_NOTIFICATION_ITEMS = 30
const {
markDocumentInboxRowRead,
@@ -576,12 +615,16 @@ const readNotifications = computed(() => notificationItems.value.filter((item) =
const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
))
const topbarNotificationCount = computed(() => {
const count = unreadNotifications.value.length
return count > 0 ? Math.min(count, 99) : 0
})
function clearDocumentInboxInitialRefreshTimer() {
const topbarNotificationCount = computed(() => {
const count = unreadNotifications.value.length
return count > 0 ? Math.min(count, 99) : 0
})
function toggleTopbarWorkbenchMode() {
emit('toggleWorkbenchMode')
}
function clearDocumentInboxInitialRefreshTimer() {
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
window.clearTimeout(documentInboxInitialRefreshTimer)
documentInboxInitialRefreshTimer = null

View File

@@ -18,32 +18,45 @@
<p>{{ decisionDescription }}</p>
</div>
<div class="employee-risk-decision-action">
<span>建议结论</span>
<strong :class="decisionTone">{{ decisionAction }}</strong>
<span>是否建议通过</span>
<strong :class="decisionTone">{{ decisionBadgeLabel }}</strong>
<p>{{ decisionAction }}</p>
</div>
</section>
<section class="employee-risk-profile-section" aria-label="单据风险依据">
<dl class="employee-risk-review-summary" aria-label="审核建议摘要">
<div
v-for="item in reviewSummaryItems"
:key="item.key"
:class="['employee-risk-review-item', item.tone]"
>
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
<section class="employee-risk-profile-section" aria-label="单据关键依据">
<div class="employee-risk-section-head">
<span>{{ stageBasisTitle }}</span>
<small>{{ stageBasisHint }}</small>
</div>
<div v-if="compactEvidenceItems.length" class="employee-risk-profile-list">
<article
v-for="item in compactEvidenceItems"
<details
v-for="(item, index) in compactEvidenceItems"
:key="item.code"
:class="['employee-risk-evidence-row', item.tone]"
:open="index === 0"
>
<div class="employee-risk-evidence-title">
<summary class="employee-risk-evidence-title">
<span>{{ item.label }}</span>
<strong>{{ item.status }}</strong>
</div>
</summary>
<ul v-if="item.evidence.length">
<li v-for="basis in item.evidence" :key="basis">{{ basis }}</li>
</ul>
</article>
</details>
</div>
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据风险依据</p>
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据依据</p>
</section>
</div>
</article>
@@ -95,12 +108,12 @@ export default {
}
return 'normal'
})
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议')
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单风险依据' : '报销单风险依据')
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : 'AI建议')
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单关键依据' : '报销单关键依据')
const stageBasisHint = computed(() => (
props.isApplicationDocument
? '仅展示申请单本身的金额、预算触发、事由和规则命中依据。'
: '仅展示报销单本身的票据、金额、行程和规则命中依据。'
? '默认只展开最关键的申请依据,其他细节点开查看。'
: '默认只展开最关键的报销依据,其他细节点开查看。'
))
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
const decisionAction = computed(() => {
@@ -111,25 +124,26 @@ export default {
})
const decisionBadgeLabel = computed(() => {
if (decisionTone.value === 'high') {
return '高风险'
return '不通过'
}
if (decisionTone.value === 'medium') {
return '需关注'
return '待补充'
}
return '可审批'
return '可通过'
})
const decisionDescription = computed(() => {
const riskCount = currentRiskCards.value.length
const subject = props.isApplicationDocument ? '申请' : '报销'
if (riskCount) {
if (!props.isApplicationDocument && riskExplanationItems.value.length) {
return `当前报销已识别 ${riskCount} 个需核对风险点,用户已补充异常说明,审批人应核对说明与票据佐证是否充分`
return `当前${subject}识别 ${riskCount} 个需核对风险点,已补充说明但仍建议先核对票据与行程`
}
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}识别 ${riskCount} 个需核对风险点,审批人应优先查看高风险依据。`
return `当前${subject}识别 ${riskCount} 个需核对风险点,优先查看高风险依据。`
}
if (materialIssues.value.length || sceneIssues.value.length) {
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}存在材料或业务说明不完整,建议补齐后再继续处理。`
return `当前${subject}存在材料或业务说明不完整,建议补齐后再处理。`
}
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。`
return `当前${subject}未发现中高风险阻断项,可按流程继续处理。`
})
const stageEvidenceItems = computed(() => (
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
@@ -139,6 +153,38 @@ export default {
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
return sourceItems.map((item) => ({ ...item }))
})
const stageRiskFactSummary = computed(() => buildStageRiskFactSummary({
isApplicationDocument: props.isApplicationDocument,
riskCount: currentRiskCards.value.length,
highCount: highRiskCards.value.length,
mediumCount: mediumRiskCards.value.length,
materialIssueCount: materialIssues.value.length,
sceneIssueCount: sceneIssues.value.length
}))
const stageReviewBasisSummary = computed(() => buildStageReviewBasisSummary(
compactEvidenceItems.value,
props.isApplicationDocument
))
const reviewSummaryItems = computed(() => [
{
key: 'fact',
label: '风险概览',
tone: decisionTone.value,
value: stageRiskFactSummary.value
},
{
key: 'basis',
label: '重点依据',
tone: decisionTone.value,
value: stageReviewBasisSummary.value
},
{
key: 'action',
label: '审核建议',
tone: decisionTone.value,
value: decisionAction.value
}
])
function buildApplicationEvidence() {
const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card)))
@@ -217,28 +263,68 @@ export default {
decisionDescription,
decisionAction,
decisionTitle,
reviewSummaryItems,
stageBasisHint,
stageBasisTitle,
stageEvidenceItems,
stageReviewBasisSummary,
stageRiskFactSummary,
stageTitle
}
}
}
function buildStageRiskFactSummary({
isApplicationDocument,
riskCount = 0,
highCount = 0,
mediumCount = 0,
materialIssueCount = 0,
sceneIssueCount = 0
} = {}) {
const subject = isApplicationDocument ? '申请单' : '报销单'
if (riskCount > 0) {
return `${subject}识别 ${riskCount} 个需核对风险点,高风险 ${highCount} 个,中风险 ${mediumCount} 个。`
}
const issueCount = materialIssueCount + sceneIssueCount
if (issueCount > 0) {
return `${subject}暂无中高风险命中,但仍有 ${issueCount} 个材料或业务说明项需要补齐。`
}
return `${subject}未识别到中高风险阻断项。`
}
function buildStageReviewBasisSummary(evidenceItems = [], isApplicationDocument = false) {
const abnormalLabels = evidenceItems
.filter((item) => isAbnormalEvidence(item))
.map((item) => String(item?.label || '').trim())
.filter(Boolean)
if (abnormalLabels.length) {
return `重点核对${abnormalLabels.join('、')}`
}
return isApplicationDocument
? '重点看申请金额、预算触发和事由是否一致。'
: '重点看票据、金额、行程和附件是否一致。'
}
function resolveDecision(tone, isApplicationDocument) {
const subject = isApplicationDocument ? '申请' : '报销'
const map = {
normal: {
title: `当前${subject}未发现中高风险阻断项`,
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`
title: '建议通过',
action: isApplicationDocument
? '可按权限继续审核,系统会按预算结果决定是否进入下一步。'
: '可按权限继续审批,后续进入财务或付款流程。'
},
medium: {
title: `当前${subject}存在中风险,建议核对后处理`,
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。'
title: '建议补充后通过',
action: isApplicationDocument
? '建议补充预算占用、申请事由和金额依据后再通过。'
: '建议补充票据、金额或业务说明后再通过。'
},
high: {
title: `当前${subject}存在高风险,不建议直接通过`,
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。'
title: '不建议通过',
action: isApplicationDocument
? '建议退回补充申请依据,或要求预算管理者复核。'
: '建议退回补充票据、行程说明或超标原因。'
}
}
return map[tone] || map.normal

View File

@@ -278,6 +278,7 @@
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
:class="{ 'compact-guidance-actions': message.assistantVariant === 'compact_guidance' }"
>
<button
v-for="action in message.suggestedActions"

View File

@@ -9,7 +9,11 @@ import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../servi
import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { ASSISTANT_SCOPE_SESSION_STEWARD } from '../utils/assistantSessionScope.js'
import {
ASSISTANT_SCOPE_SESSION_STEWARD,
buildUnsupportedBusinessScopeConversation,
resolveAssistantScopeGuard
} from '../utils/assistantSessionScope.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import {
@@ -293,10 +297,11 @@ export function useAppShell() {
view === 'documents'
&& activeView.value === 'documents'
&& route.name === 'app-documents'
setView(view)
const navigation = setView(view)
if (shouldRefreshCurrentDocumentCenter) {
void reloadDocumentCenterRequests()
}
return navigation
}
function openFinancialAssistantCreate(source) {
@@ -459,6 +464,36 @@ export function useAppShell() {
smartEntryRevealToken.value += 1
return
}
const prompt = String(payload.prompt || '').trim()
const files = Array.isArray(payload.files) ? payload.files : []
const scopeGuard = prompt
? resolveAssistantScopeGuard(prompt, String(payload.sessionType || '').trim(), {
attachmentCount: files.length
})
: null
if (scopeGuard?.blocked) {
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files,
conversation: buildUnsupportedBusinessScopeConversation(prompt, {
attachmentCount: files.length
}),
scope: null,
sessionType: ASSISTANT_SCOPE_SESSION_STEWARD,
budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object'
? payload.budgetContext
: null,
initialPromptAutoSubmit: false,
initialApplicationPreview: null
}
smartEntrySessionId.value += 1
return
}
const [conversation, sessionType] = await Promise.all([
resolveSmartEntryConversation(payload),
resolveSmartEntrySessionType(payload)

View File

@@ -147,7 +147,7 @@ export function useNavigation() {
return resolveAppViewFromRoute(route)
},
set(view) {
setView(view)
void setView(view)
}
})
@@ -159,10 +159,10 @@ export function useNavigation() {
const targetName = resolveTargetRouteName(view)
if (route.name === targetName) {
return
return Promise.resolve()
}
router.push({ name: targetName, params: {}, query: {}, hash: '' })
return router.push({ name: targetName, params: {}, query: {}, hash: '' })
}
return { activeView, currentView, setView, navItems }

View File

@@ -0,0 +1,79 @@
// AI 模式下的申请单草稿模型。
// 独立于旧的引导式报销/申请状态机,只在 AI 对话页内驱动逐项收集,
// 不调用旧视图的申请模板/预览钩子,也不复用 buildLocalApplicationPreview。
const DEFAULT_FIELD_STEPS = [
{ key: 'reason', label: '出差事由', prompt: '先告诉我这次出差的事由,例如:去上海支持上海电力部署项目。' },
{ key: 'time_range', label: '出差时间/天数', prompt: '出差时间和天数是什么例如2026-06-20 至 2026-06-22出差 3 天。' },
{ key: 'location', label: '出差地点', prompt: '出差地点是哪里?可以填城市或具体客户地点。' },
{ key: 'amount', label: '预计金额', prompt: '预计金额是多少?如果还没有汇总,可以回复“待核算”。' }
]
const SUMMARY_STEP_KEY = 'summary'
function normalizeAnswer(value) {
return String(value || '').trim()
}
export function getAiApplicationSteps() {
return DEFAULT_FIELD_STEPS
}
export function createAiApplicationDraft(expenseType, expenseTypeLabel) {
return {
expenseType: normalizeAnswer(expenseType),
expenseTypeLabel: normalizeAnswer(expenseTypeLabel),
values: {},
stepKey: DEFAULT_FIELD_STEPS[0].key
}
}
export function getAiApplicationCurrentStep(draft) {
const stepKey = normalizeAnswer(draft?.stepKey)
return DEFAULT_FIELD_STEPS.find((step) => step.key === stepKey) || DEFAULT_FIELD_STEPS[0]
}
export function buildAiApplicationStepPrompt(draft) {
const step = getAiApplicationCurrentStep(draft)
const label = normalizeAnswer(draft?.expenseTypeLabel) || '出差申请'
const stepIndex = Math.max(0, DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key))
return [
`好的,那我们先把${label}申请在当前对话里理一下。`,
'',
`${stepIndex + 1} 步 · ${step.label}`,
step.prompt
].join('\n')
}
export function applyAiApplicationAnswer(draft, answer, files = []) {
const current = draft && typeof draft === 'object' ? draft : createAiApplicationDraft()
const step = getAiApplicationCurrentStep(current)
const nextValues = { ...(current.values || {}) }
nextValues[step.key] = normalizeAnswer(answer)
const currentIndex = DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key)
const nextStep = DEFAULT_FIELD_STEPS[currentIndex + 1]
return {
...current,
values: nextValues,
stepKey: nextStep ? nextStep.key : SUMMARY_STEP_KEY
}
}
export function isAiApplicationDraftComplete(draft) {
return Boolean(draft) && normalizeAnswer(draft?.stepKey) === SUMMARY_STEP_KEY
}
export function buildAiApplicationSummary(draft) {
const label = normalizeAnswer(draft?.expenseTypeLabel) || '出差申请'
const values = draft?.values || {}
const lines = [`已完成「${label}」的要点收集,请核对:`, '']
DEFAULT_FIELD_STEPS.forEach((step) => {
const value = normalizeAnswer(values[step.key])
lines.push(`- ${step.label}${value || '待补充'}`)
})
lines.push('', '如果哪一项需要修改,直接告诉我;确认无误后我会帮你整理成申请草稿内容,再提交到申请助手生成单据。')
return lines.join('\n')
}

View File

@@ -0,0 +1,113 @@
// AI 模式下的报销草稿模型。
// 独立于旧的引导式报销状态机,只在 AI 对话页内驱动逐项收集,
// 不调用 steward、不复用 guidedFlow 的编排流程。
const DEFAULT_FIELD_STEPS = [
{ key: 'reason', label: '事由', prompt: '先告诉我这笔报销的事由,例如:项目现场支持、客户接待。' },
{ key: 'time_range', label: '发生时间', prompt: '费用是什么时候发生的例如2026-06-15。' },
{ key: 'location', label: '地点/对象', prompt: '费用发生的地点或对象是哪里?' },
{ key: 'amount', label: '金额', prompt: '本次报销金额是多少?' },
{ key: 'attachments', label: '票据', prompt: '票据可以现在上传,或回复“稍后上传”。' }
]
const SUMMARY_STEP_KEY = 'summary'
function normalizeAnswer(value) {
return String(value || '').trim()
}
function normalizeFileNames(files) {
return Array.from(files || [])
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
}
export function getAiExpenseSteps() {
return DEFAULT_FIELD_STEPS
}
export function createAiExpenseDraft(expenseType, expenseTypeLabel) {
return {
expenseType: normalizeAnswer(expenseType),
expenseTypeLabel: normalizeAnswer(expenseTypeLabel),
applicationClaim: null,
values: {},
stepKey: DEFAULT_FIELD_STEPS[0].key
}
}
export function getAiExpenseCurrentStep(draft) {
const stepKey = normalizeAnswer(draft?.stepKey)
return DEFAULT_FIELD_STEPS.find((step) => step.key === stepKey) || DEFAULT_FIELD_STEPS[0]
}
export function buildAiExpenseStepPrompt(draft) {
const step = getAiExpenseCurrentStep(draft)
const label = normalizeAnswer(draft?.expenseTypeLabel) || '报销'
const stepIndex = Math.max(0, DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key))
return [
`已选择「${label}」,我们逐项把信息理一下。`,
'',
`${stepIndex + 1} 步 · ${step.label}`,
step.prompt
].join('\n')
}
export function applyAiExpenseAnswer(draft, answer, files = []) {
const current = draft && typeof draft === 'object' ? draft : createAiExpenseDraft()
const step = getAiExpenseCurrentStep(current)
const nextValues = { ...(current.values || {}) }
const fileNames = normalizeFileNames(files)
if (step.key === 'attachments') {
if (fileNames.length) {
nextValues.attachment_names = Array.from(
new Set([...(nextValues.attachment_names || []), ...fileNames])
)
}
nextValues.attachments = normalizeAnswer(answer)
|| (nextValues.attachment_names?.length ? `已选择 ${nextValues.attachment_names.length} 份附件` : '稍后上传')
} else {
nextValues[step.key] = normalizeAnswer(answer)
}
const currentIndex = DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key)
const nextStep = DEFAULT_FIELD_STEPS[currentIndex + 1]
return {
...current,
values: nextValues,
stepKey: nextStep ? nextStep.key : SUMMARY_STEP_KEY
}
}
export function isAiExpenseDraftComplete(draft) {
return Boolean(draft) && normalizeAnswer(draft?.stepKey) === SUMMARY_STEP_KEY
}
export function buildAiExpenseSummary(draft) {
const label = normalizeAnswer(draft?.expenseTypeLabel) || '报销'
const values = draft?.values || {}
const application = draft?.applicationClaim || null
const lines = [`已完成「${label}」的信息收集,请核对:`, '']
if (application && normalizeAnswer(application.application_claim_no)) {
const parts = [
application.application_claim_no,
application.application_reason,
application.application_business_time,
application.application_location
].map(normalizeAnswer).filter(Boolean)
lines.push(`- 关联申请单:${parts.join(' / ')}`)
}
DEFAULT_FIELD_STEPS.forEach((step) => {
const value = step.key === 'attachments'
? (values.attachment_names?.length
? values.attachment_names.join('、')
: normalizeAnswer(values.attachments) || '稍后上传')
: normalizeAnswer(values[step.key])
lines.push(`- ${step.label}${value || '待补充'}`)
})
return lines.join('\n')
}

View File

@@ -0,0 +1,81 @@
export const AI_SIDEBAR_BASE_BUSINESS_VIEW_IDS = ['documents', 'receiptFolder', 'policies']
const ROLE_VIEW_ADDITIONS = {
budget: ['budget'],
finance: ['overview', 'audit', 'digitalEmployees'],
manager: ['overview', 'employees'],
executive: ['budget', 'overview'],
admin: ['budget', 'overview', 'audit', 'digitalEmployees', 'employees']
}
function normalizeRoleCode(value) {
const normalized = String(value || '').trim().toLowerCase()
return normalized === 'auditor' ? 'budget_monitor' : normalized
}
function normalizedRoleCodes(user = {}) {
return Array.isArray(user.roleCodes)
? user.roleCodes.map((item) => normalizeRoleCode(item)).filter(Boolean)
: []
}
function normalizeProfileText(user = {}) {
return [
user.role,
user.position,
user.department,
user.departmentName,
user.department_name,
user.grade
]
.map((item) => String(item || '').trim().toLowerCase())
.filter(Boolean)
.join(' ')
}
function isPlatformAdminProfile(user = {}, roleCodes = []) {
const username = String(user.username || user.account || '').trim().toLowerCase()
const role = String(user.role || '').trim().toLowerCase()
return (
Boolean(user.isAdmin)
|| username === 'admin'
|| role === 'admin'
|| role === '管理员'
|| role === '系统管理员'
|| roleCodes.includes('admin')
)
}
function addViewIds(target, ids = []) {
ids.forEach((id) => target.add(id))
}
export function resolveAiSidebarBusinessViewIds(user = {}) {
const roleCodes = normalizedRoleCodes(user)
const roleCodeSet = new Set(roleCodes)
const profileText = normalizeProfileText(user)
const viewIds = new Set(AI_SIDEBAR_BASE_BUSINESS_VIEW_IDS)
if (isPlatformAdminProfile(user, roleCodes)) {
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.admin)
}
if (roleCodeSet.has('budget_monitor') || profileText.includes('预算')) {
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.budget)
}
if (roleCodeSet.has('finance') || profileText.includes('财务')) {
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.finance)
}
if (roleCodeSet.has('manager') || profileText.includes('经理') || profileText.includes('主管')) {
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.manager)
}
if (roleCodeSet.has('executive') || profileText.includes('高管') || profileText.includes('总监')) {
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.executive)
}
return Array.from(viewIds)
}

View File

@@ -0,0 +1,155 @@
const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations'
const MAX_CONVERSATION_HISTORY = 30
const MAX_STORED_MESSAGES = 80
function safeString(value) {
return String(value || '').trim()
}
function resolveUserStorageKey(user = {}) {
const identity = safeString(user.username || user.email || user.name || 'anonymous')
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
}
function canUseStorage() {
return typeof window !== 'undefined' && Boolean(window.localStorage)
}
function readStoredList(user = {}) {
if (!canUseStorage()) {
return []
}
try {
const payload = JSON.parse(window.localStorage.getItem(resolveUserStorageKey(user)) || '[]')
return Array.isArray(payload) ? payload : []
} catch {
return []
}
}
function writeStoredList(user = {}, conversations = []) {
if (!canUseStorage()) {
return
}
const normalized = conversations
.map((item) => normalizeConversation(item))
.filter((item) => item.id)
.sort((left, right) => Number(right.updatedAt || 0) - Number(left.updatedAt || 0))
.slice(0, MAX_CONVERSATION_HISTORY)
window.localStorage.setItem(resolveUserStorageKey(user), JSON.stringify(normalized))
}
function normalizeMessage(message = {}) {
return {
id: safeString(message.id) || `${Date.now()}-${Math.random().toString(16).slice(2)}`,
role: safeString(message.role) || 'assistant',
content: safeString(message.content),
pending: false,
feedback: safeString(message.feedback),
stewardPlan: message.stewardPlan && typeof message.stewardPlan === 'object'
? {
...message.stewardPlan,
streamStatus: safeString(message.stewardPlan.streamStatus) || 'completed'
}
: null,
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : []
}
}
function normalizeConversation(conversation = {}) {
const updatedAt = Number(conversation.updatedAt || conversation.updated_at || Date.now())
const messages = Array.isArray(conversation.messages)
? conversation.messages.slice(-MAX_STORED_MESSAGES).map((message) => normalizeMessage(message))
: []
const title = safeString(conversation.title) || buildConversationTitle(messages)
const desc = safeString(conversation.desc || conversation.description) || buildConversationDescription(messages)
return {
id: safeString(conversation.id || conversation.conversationId),
title,
desc,
time: formatConversationTime(updatedAt),
prompt: safeString(conversation.prompt) || resolveFirstUserPrompt(messages),
source: safeString(conversation.source) || 'workbench',
sessionType: safeString(conversation.sessionType) || 'steward',
conversationId: safeString(conversation.conversationId || conversation.id),
stewardState: conversation.stewardState && typeof conversation.stewardState === 'object'
? conversation.stewardState
: null,
messages,
updatedAt
}
}
function resolveStoredConversationId(conversation = {}) {
return safeString(conversation.id || conversation.conversationId)
}
function buildConversationTitle(messages = []) {
const firstUserMessage = messages.find((message) => message.role === 'user' && message.content)
return safeString(firstUserMessage?.content).slice(0, 18) || '新对话'
}
function buildConversationDescription(messages = []) {
const lastMessage = [...messages].reverse().find((message) => safeString(message.content))
return safeString(lastMessage?.content).replace(/\s+/g, ' ').slice(0, 32) || '小财管家对话'
}
function resolveFirstUserPrompt(messages = []) {
const firstUserMessage = messages.find((message) => message.role === 'user' && message.content)
return safeString(firstUserMessage?.content)
}
function formatConversationTime(timestamp) {
const date = new Date(Number(timestamp || Date.now()))
if (Number.isNaN(date.getTime())) {
return ''
}
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000
const timeText = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
const value = date.getTime()
if (value >= startOfToday) {
return `今天 ${timeText}`
}
if (value >= startOfYesterday) {
return '昨天'
}
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
export function loadAiWorkbenchConversationHistory(user = {}) {
return readStoredList(user)
.map((item) => normalizeConversation(item))
.filter((item) => item.id)
.sort((left, right) => Number(right.updatedAt || 0) - Number(left.updatedAt || 0))
}
export function saveAiWorkbenchConversation(user = {}, conversation = {}) {
const normalized = normalizeConversation({
...conversation,
updatedAt: conversation.updatedAt || Date.now()
})
if (!normalized.id || !normalized.messages.length) {
return loadAiWorkbenchConversationHistory(user)
}
const nextList = [
normalized,
...readStoredList(user).filter((item) => resolveStoredConversationId(item) !== normalized.id)
]
writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user)
}
export function deleteAiWorkbenchConversation(user = {}, conversationId = '') {
const normalizedId = safeString(conversationId)
const nextList = readStoredList(user).filter((item) => resolveStoredConversationId(item) !== normalizedId)
writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user)
}

View File

@@ -1,14 +1,14 @@
const EXPENSE_SCENE_SELECTION_OPTIONS = [
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline' },
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline' },
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline' },
{ key: 'meal', label: '业务招待费', description: '客户接待、工作餐、加班餐、餐饮票据等费用', icon: 'mdi mdi-food-fork-drink' },
{ key: 'meeting', label: '会务费', description: '会议、论坛、会场、参会等费用', icon: 'mdi mdi-account-tie-voice-outline' },
{ key: 'office', label: '办公用品费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' },
{ key: 'training', label: '培训费', description: '培训课程、讲师费、教材认证等费用', icon: 'mdi mdi-school-outline' },
{ key: 'communication', label: '通讯费', description: '话费、流量、宽带、网络等费用', icon: 'mdi mdi-cellphone-message' },
{ key: 'welfare', label: '福利费', description: '团建、体检、慰问、节日福利等费用', icon: 'mdi mdi-gift-outline' },
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline', requires_application_before_reimbursement: true, next_session_type: 'application' },
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'meal', label: '业务招待费', description: '客户接待、工作餐、加班餐、餐饮票据等费用', icon: 'mdi mdi-food-fork-drink', requires_application_before_reimbursement: true, next_session_type: 'application' },
{ key: 'meeting', label: '会务费', description: '会议、论坛、会场、参会等费用', icon: 'mdi mdi-account-tie-voice-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'office', label: '办公用品费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'training', label: '培训费', description: '培训课程、讲师费、教材认证等费用', icon: 'mdi mdi-school-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'communication', label: '通讯费', description: '话费、流量、宽带、网络等费用', icon: 'mdi mdi-cellphone-message', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'welfare', label: '福利费', description: '团建、体检、慰问、节日福利等费用', icon: 'mdi mdi-gift-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' }
]
const EXPENSE_INTENT_CONFIRMATION_ACTION = {
@@ -28,7 +28,9 @@ export function buildExpenseSceneSelectionActions(rawText) {
payload: {
expense_type: option.key,
expense_type_label: option.label,
original_message: originalMessage
original_message: originalMessage,
requires_application_before_reimbursement: option.requires_application_before_reimbursement,
next_session_type: option.next_session_type
}
}))
}

View File

@@ -89,6 +89,22 @@ function padDatePart(value) {
return String(value).padStart(2, '0')
}
function formatMonthKey(date) {
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}`
}
function formatMonthLabel(date) {
return `${date.getMonth() + 1}`
}
function shiftMonth(date, offset) {
return new Date(date.getFullYear(), date.getMonth() + offset, 1)
}
function resolveMonthStart(date) {
return new Date(date.getFullYear(), date.getMonth(), 1)
}
function formatDateTimeLabel(value) {
if (value instanceof Date) {
return [
@@ -558,6 +574,55 @@ function buildExpenseOperationRows(todoItems, notifications, progressItems) {
.slice(0, 8)
}
function buildMonthlyAmountMap(ownedRequests) {
const rows = new Map()
for (const request of ownedRequests) {
const date = toDate(resolveClaimDate(request))
if (!date) {
continue
}
const key = formatMonthKey(date)
rows.set(key, (rows.get(key) || 0) + parseNumber(request?.amount))
}
return rows
}
function resolveTrendAnchorDate(ownedRequests) {
const dates = ownedRequests
.map((request) => toDate(resolveClaimDate(request)))
.filter(Boolean)
.sort((left, right) => right.getTime() - left.getTime())
return resolveMonthStart(dates[0] || new Date())
}
function buildReimbursementTrendRows(ownedRequests) {
const monthlyAmountMap = buildMonthlyAmountMap(ownedRequests)
const anchor = resolveTrendAnchorDate(ownedRequests)
return Array.from({ length: 6 }, (_, index) => {
const month = shiftMonth(anchor, index - 5)
const previousMonth = shiftMonth(month, -12)
const key = formatMonthKey(month)
const previousKey = formatMonthKey(previousMonth)
const amount = monthlyAmountMap.get(key) || 0
const previousAmount = monthlyAmountMap.get(previousKey) || 0
return {
key,
label: formatMonthLabel(month),
amount,
amountLabel: formatCurrency(amount),
previousKey,
previousAmount,
previousAmountLabel: formatCurrency(previousAmount)
}
})
}
export function buildWorkbenchSummary(requests, currentUser) {
const allRequests = Array.isArray(requests)
? requests
@@ -602,6 +667,7 @@ export function buildWorkbenchSummary(requests, currentUser) {
highRiskCount,
todoItems,
progressItems,
reimbursementTrendRows: buildReimbursementTrendRows(ownedRequests),
notifications,
expenseStatsDetail,
unreadNotificationCount: notifications.filter((item) => item.unread).length

View File

@@ -3,6 +3,7 @@
class="app"
:class="{
'sidebar-collapsed': sidebarCollapsed,
'workbench-ai-sidebar-active': isAiShellMode,
'mobile-sidebar-open': mobileSidebarOpen,
'login-entry-active': loginEntryAnimating
}"
@@ -29,18 +30,39 @@
</div>
</Transition>
<div class="app-sidebar">
<SidebarRail
:nav-items="filteredNavItems"
:active-view="activeView"
:company-name="PRODUCT_DISPLAY_NAME"
:company-logo="companyProfile.logo"
:current-user="currentUser"
:collapsed="sidebarCollapsed"
@open-chat="openSmartEntry"
@logout="handleLogout"
@toggle-collapse="toggleSidebarCollapsed"
@navigate="handleNavigateWithMobileClose"
/>
<Transition name="sidebar-mode-fade" mode="out-in">
<AiSidebarRail
v-if="isAiShellMode"
key="ai-sidebar"
:nav-items="filteredNavItems"
:active-view="activeView"
:active-conversation-id="aiActiveConversationId"
:conversation-history="aiConversationHistory"
:current-user="currentUser"
:brand-name="PRODUCT_DISPLAY_NAME"
:brand-logo="companyProfile.logo"
:collapsed="sidebarCollapsed"
@navigate="handleNavigateWithMobileClose"
@new-chat="openAiSidebarNewChat"
@open-recent="openAiSidebarRecent"
@rename-conversation="handleAiConversationRename"
@logout="handleLogout"
/>
<SidebarRail
v-else
key="standard-sidebar"
:nav-items="filteredNavItems"
:active-view="activeView"
:company-name="PRODUCT_DISPLAY_NAME"
:company-logo="companyProfile.logo"
:current-user="currentUser"
:collapsed="sidebarCollapsed"
@open-chat="openSmartEntry"
@logout="handleLogout"
@toggle-collapse="toggleSidebarCollapsed"
@navigate="handleNavigateWithMobileClose"
/>
</Transition>
</div>
<main
@@ -72,6 +94,7 @@
:request-summary="requestSummary"
:document-summary="documentSummary"
:workbench-summary="workbenchSummary"
:workbench-mode="workbenchMode"
:digital-employee-summary="digitalEmployeeSummary"
:company-name="ENTERPRISE_DISPLAY_NAME"
:detail-mode="resolvedDetailMode"
@@ -87,6 +110,7 @@
@new-application="openExpenseApplicationCreate"
@open-document="openWorkbenchDocument"
@navigate="handleNavigate"
@toggle-workbench-mode="toggleWorkbenchMode"
/>
<FilterBar
@@ -104,6 +128,7 @@
'documents-workarea': activeView === 'documents',
'receipt-folder-workarea': activeView === 'receiptFolder',
'workbench-workarea': activeView === 'workbench',
'workbench-workarea-ai-mode': isWorkbenchAiMode,
'budget-workarea': activeView === 'budget',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
@@ -126,6 +151,10 @@
v-else-if="activeView === 'workbench'"
:assistant-modal-open="smartEntryOpen"
:workbench-summary="workbenchSummary"
:workbench-mode="workbenchMode"
:ai-sidebar-command="aiSidebarCommand"
@ai-conversation-change="handleAiConversationChange"
@ai-conversation-history-change="handleAiConversationHistoryChange"
@open-assistant="openSmartEntry"
@open-document="openWorkbenchDocument"
/>
@@ -207,8 +236,9 @@
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
@@ -229,6 +259,7 @@ import TravelRequestDetailView from './TravelRequestDetailView.vue'
import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess } from '../utils/accessControl.js'
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js'
const employeeSummary = ref(null)
@@ -241,9 +272,15 @@ const digitalEmployeeDetailOpen = ref(false)
const receiptFolderDetailOpen = ref(false)
const budgetDetailOpen = ref(false)
const loginEntryAnimating = ref(false)
const sidebarCollapsed = ref(true)
const sidebarCollapsed = ref(false)
const sidebarCollapsedBeforeAiMode = ref(false)
const mobileSidebarOpen = ref(false)
const overviewDashboard = ref('finance')
const workbenchMode = ref('traditional')
const aiSidebarCommandSeq = ref(0)
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
const aiActiveConversationId = ref('')
const aiConversationHistory = ref([])
let loginEntryTimer = null
function stopLoginEntryAnimation() {
@@ -269,10 +306,23 @@ function toggleSidebarCollapsed() {
}
function handleNavigateWithMobileClose(viewId) {
handleNavigate(viewId)
void handleNavigate(viewId)
mobileSidebarOpen.value = false
}
function toggleWorkbenchMode() {
const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai'
if (nextMode === 'ai') {
sidebarCollapsedBeforeAiMode.value = sidebarCollapsed.value
workbenchMode.value = nextMode
sidebarCollapsed.value = false
return
}
workbenchMode.value = nextMode
sidebarCollapsed.value = sidebarCollapsedBeforeAiMode.value
}
const {
activeRange,
activeView,
@@ -319,6 +369,8 @@ const { companyProfile, currentUser, logout } = useSystemState()
const PRODUCT_DISPLAY_NAME = '易财费控'
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
const isAiShellMode = computed(() => workbenchMode.value === 'ai')
const isWorkbenchAiMode = computed(() => activeView.value === 'workbench' && workbenchMode.value === 'ai')
const DETAIL_TOPBAR_FALLBACKS = {
audit: {
title: '规则中心详情',
@@ -382,10 +434,82 @@ function openWorkbenchDocument(payload = {}) {
openRequestDetail(request || payload, { returnTo })
}
function dispatchAiSidebarCommand(type, payload = null) {
aiSidebarCommandSeq.value += 1
aiSidebarCommand.value = {
seq: aiSidebarCommandSeq.value,
type,
payload
}
}
async function openAiConversationWorkspace(type, payload = null) {
if (activeView.value !== 'workbench') {
const navigation = handleNavigate('workbench')
if (navigation && typeof navigation.then === 'function') {
await navigation
}
await nextTick()
}
dispatchAiSidebarCommand(type, payload)
}
function openAiSidebarNewChat() {
aiActiveConversationId.value = ''
void openAiConversationWorkspace('new-chat')
}
function openAiSidebarRecent(item = {}) {
aiActiveConversationId.value = String(item.id || '').trim()
void openAiConversationWorkspace('open-recent', item)
}
function handleAiConversationChange(payload = {}) {
aiActiveConversationId.value = String(payload.id || '').trim()
}
function handleAiConversationHistoryChange(payload = []) {
aiConversationHistory.value = Array.isArray(payload) ? payload : []
}
function handleAiConversationRename(payload = {}) {
const conversationId = String(payload.id || '').trim()
const title = String(payload.title || '').trim()
if (!conversationId || !title) {
return
}
const target = aiConversationHistory.value.find((item) => String(item.id || '').trim() === conversationId)
if (!target) {
return
}
aiConversationHistory.value = saveAiWorkbenchConversation(currentUser.value || {}, {
...target,
title
})
if (aiActiveConversationId.value === conversationId) {
dispatchAiSidebarCommand('open-recent', {
...target,
title
})
}
}
function handleLogout() {
logout('manual')
}
watch(
() => currentUser.value,
(user) => {
aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {})
},
{ immediate: true }
)
onMounted(() => {
playLoginEntryAnimation()
})
@@ -393,10 +517,4 @@ onMounted(() => {
onBeforeUnmount(() => {
stopLoginEntryAnimation()
})
watch(activeView, (newView) => {
if (newView === 'workbench') {
sidebarCollapsed.value = true
}
}, { immediate: true })
</script>

View File

@@ -1,20 +1,36 @@
<template>
<PersonalWorkbench
:show-header="false"
:assistant-modal-open="assistantModalOpen"
:workbench-summary="workbenchSummary"
@open-assistant="emit('open-assistant', $event)"
@open-document="emit('open-document', $event)"
/>
<Transition name="workbench-mode-fade" mode="out-in" appear>
<PersonalWorkbenchAiMode
v-if="workbenchMode === 'ai'"
key="ai"
:sidebar-command="aiSidebarCommand"
@conversation-change="emit('ai-conversation-change', $event)"
@conversation-history-change="emit('ai-conversation-history-change', $event)"
/>
<PersonalWorkbench
v-else
key="traditional"
:show-header="false"
:assistant-modal-open="assistantModalOpen"
:workbench-summary="workbenchSummary"
@open-assistant="emit('open-assistant', $event)"
@open-document="emit('open-document', $event)"
/>
</Transition>
</template>
<script setup>
import PersonalWorkbenchAiMode from '../components/business/PersonalWorkbenchAiMode.vue'
import PersonalWorkbench from '../components/business/PersonalWorkbench.vue'
defineProps({
assistantModalOpen: { type: Boolean, default: false },
workbenchSummary: { type: Object, default: () => ({}) }
workbenchSummary: { type: Object, default: () => ({}) },
workbenchMode: { type: String, default: 'traditional' },
aiSidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
})
const emit = defineEmits(['open-assistant', 'open-document'])
const emit = defineEmits(['open-assistant', 'open-document', 'ai-conversation-change', 'ai-conversation-history-change'])
</script>
<style scoped src="../assets/styles/views/personal-workbench-view.css"></style>

View File

@@ -24,7 +24,8 @@ import {
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import {
buildStewardFieldCompletionContinuation,
buildStewardFieldCompletionRawText
buildStewardFieldCompletionRawText,
resolveStewardRuntimeFieldCompletion
} from './stewardFieldCompletionModel.js'
import {
buildOperationFeedbackPayload,
@@ -169,8 +170,6 @@ import {
buildFileIdentity,
buildFilePreviews,
buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments,
buildReviewFilePreviewsFromReviewPayload,
extractReviewAttachmentNames,
@@ -179,7 +178,6 @@ import {
mergeFilesWithLimit,
mergeUploadAttachmentNames,
mergeUploadOcrDocuments,
normalizeOcrDocuments,
resolveAttachmentPreviewKind,
resolveDocumentPreview
} from './travelReimbursementAttachmentModel.js'
@@ -1121,8 +1119,6 @@ export default {
buildExpenseSceneSelectionMessage,
buildMessageMeta,
buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments,
buildReviewFormContextFromPayload,
clearAttachedFiles,
@@ -1155,7 +1151,6 @@ export default {
messages,
nextTick,
normalizeExpenseQueryPayload,
normalizeOcrDocuments,
persistSessionState,
props,
recognizeOcrFiles,
@@ -1904,6 +1899,10 @@ export default {
})
return
}
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
pushExpenseSceneSelectionPrompt(carryText)
return
}
if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText
@@ -2141,6 +2140,9 @@ export default {
}
function buildMessageBubbleClass(message) {
if (message?.role === 'assistant' && message?.assistantVariant === 'compact_guidance') {
return 'message-bubble-compact-guidance'
}
if (message?.role === 'assistant' && message?.budgetReport) {
return 'message-bubble-budget-report'
}
@@ -2965,6 +2967,10 @@ export default {
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
}
}
const fieldCompletionDecision = resolveStewardRuntimeFieldCompletion(normalizedText, runtimeState)
if (fieldCompletionDecision) {
return fieldCompletionDecision
}
}
return null
}
@@ -3082,6 +3088,39 @@ export default {
})
return true
}
if (nextAction === 'fill_current_application_field') {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetMessage = targetMessageId
? messages.value.find((message) => String(message.id || '') === targetMessageId)
: findLatestApplicationPreviewMessage()
if (!targetMessage?.applicationPreview) {
return false
}
const fieldKey = String(decision?.field_key || decision?.fieldKey || '').trim()
const fieldLabel = String(decision?.field_label || decision?.fieldLabel || '').trim()
const fieldValue = String(decision?.field_value || decision?.fieldValue || rawText).trim()
if (!fieldKey || !fieldValue) {
return false
}
await continueStewardApplicationFieldCompletion({
targetMessage,
action: {
label: fieldValue,
suppressUserEcho: userMessageAlreadyAdded,
payload: {
steward_delegated_field_completion: true,
field_key: fieldKey,
field_label: fieldLabel,
value: fieldValue
}
},
sourcePreview: targetMessage.applicationPreview,
fieldKey,
fieldLabel,
value: fieldValue
})
return true
}
if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') {
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
return true

View File

@@ -1751,12 +1751,12 @@ export default {
const aiAdviceTitle = computed(() => {
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
return '报销风险提示'
return '风险提示'
}
if (isEditableRequest.value && isApplicationDocument.value) {
return '表单自查提示'
}
return isEditableRequest.value ? 'AI建议' : 'AI提示'
return isEditableRequest.value ? 'AI建议' : '风险提示'
})
const aiAdviceHint = computed(() => (
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value

View File

@@ -24,6 +24,35 @@ const APPLICATION_PREVIEW_FIELD_LABEL_MAP = {
grade: '职级'
}
const STEWARD_RUNTIME_FIELD_COMPLETION_RULES = [
{ fieldKey: 'reason', fieldLabel: '事由', pattern: /事由|申请事由|出差事由|原因|用途/ },
{ fieldKey: 'transportMode', fieldLabel: '出行方式', pattern: /出行方式|交通方式|交通工具|出行工具/ },
{ fieldKey: 'time', fieldLabel: '申请时间', pattern: /申请时间|发生时间|业务发生时间|出发时间|返回时间|时间/ },
{ fieldKey: 'location', fieldLabel: '地点', pattern: /地点|业务地点|发生地点|目的地/ },
{ fieldKey: 'days', fieldLabel: '天数', pattern: /天数|出差天数|申请天数/ },
{ fieldKey: 'amount', fieldLabel: '系统预估费用', pattern: /系统预估费用|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额/ }
]
const APPLICATION_TYPE_DISPLAY_MAP = {
travel: '差旅费用申请',
travel_application: '差旅费用申请',
expense_application: '费用申请',
application: '费用申请',
transportation: '交通费用申请',
traffic: '交通费用申请',
transport: '交通费用申请',
accommodation: '住宿费用申请',
hotel: '住宿费用申请',
meeting: '会务费用申请',
conference: '会务费用申请',
purchase: '采购费用申请',
procurement: '采购费用申请',
training: '培训费用申请',
business_entertainment: '业务招待申请',
entertainment: '业务招待申请',
office: '办公费用申请'
}
function compactValue(value = '') {
return String(value || '').trim()
}
@@ -48,6 +77,22 @@ function resolveFieldValue(...candidates) {
return ''
}
function resolveApplicationTypeDisplay(value = '') {
const rawValue = compactValue(value)
if (!rawValue) return ''
const normalizedKey = rawValue.toLowerCase()
if (APPLICATION_TYPE_DISPLAY_MAP[normalizedKey]) {
return APPLICATION_TYPE_DISPLAY_MAP[normalizedKey]
}
if (/^(?:差旅费|差旅|出差)$/.test(rawValue)) return '差旅费用申请'
if (/^(?:交通费|交通)$/.test(rawValue)) return '交通费用申请'
if (/^(?:住宿费|住宿|酒店)$/.test(rawValue)) return '住宿费用申请'
if (/^(?:会务|会议|会务费)$/.test(rawValue)) return '会务费用申请'
if (/^(?:采购|采购费|办公用品)$/.test(rawValue)) return '采购费用申请'
return rawValue
}
function buildUpdatedTask(task = null, fieldKey = '', value = '') {
if (!task || typeof task !== 'object') {
return null
@@ -75,6 +120,29 @@ function buildUpdatedTask(task = null, fieldKey = '', value = '') {
}
}
function buildFieldCompletionScopeHints(fieldKey = '', selectedValue = '') {
const hints = [
'本轮是对当前申请单字段的补充/更新,不是新建申请或切换任务。'
]
if (fieldKey === 'reason') {
hints.push(
`请将“${compactValue(selectedValue)}”作为当前出差申请的事由继续处理,不要把它改判为新的 IT 部署申请。`
)
}
return hints
}
function resolveFieldRuleByKey(fieldKey = '') {
const normalizedKey = compactValue(fieldKey)
return STEWARD_RUNTIME_FIELD_COMPLETION_RULES.find((rule) => rule.fieldKey === normalizedKey) || null
}
function resolveFieldRuleByLabel(label = '') {
const normalizedLabel = compactValue(label)
if (!normalizedLabel) return null
return STEWARD_RUNTIME_FIELD_COMPLETION_RULES.find((rule) => rule.pattern.test(normalizedLabel)) || null
}
export function buildStewardFieldCompletionContinuation(continuation = null, fieldKey = '', value = '') {
const source = continuation && typeof continuation === 'object' ? continuation : {}
const currentTask = resolveStewardCurrentTask(source)
@@ -89,6 +157,50 @@ export function buildStewardFieldCompletionContinuation(continuation = null, fie
}
}
export function resolveStewardRuntimeFieldCompletion(rawText = '', runtimeState = {}) {
const value = compactValue(rawText)
if (!value || compactValue(runtimeState?.waiting_for) !== 'application_field_completion') {
return null
}
const slotAction = runtimeState?.pending_slot_action || runtimeState?.pendingSlotAction || null
const slotPayload = slotAction?.payload && typeof slotAction.payload === 'object' ? slotAction.payload : {}
const slotFieldKey = compactValue(slotPayload.field_key || slotPayload.fieldKey || slotAction?.field_key || slotAction?.fieldKey)
const slotRule = resolveFieldRuleByKey(slotFieldKey)
if (slotRule) {
return {
next_action: 'fill_current_application_field',
target_message_id: compactValue(slotAction?.message_id || slotAction?.messageId),
field_key: slotRule.fieldKey,
field_label: slotRule.fieldLabel,
field_value: value
}
}
const pendingApplication = runtimeState?.pending_application || runtimeState?.pendingApplication || null
const missingFields = Array.isArray(pendingApplication?.missing_fields)
? pendingApplication.missing_fields
: Array.isArray(pendingApplication?.missingFields)
? pendingApplication.missingFields
: []
if (missingFields.length !== 1) {
return null
}
const rule = resolveFieldRuleByLabel(missingFields[0])
if (!rule) {
return null
}
return {
next_action: 'fill_current_application_field',
target_message_id: compactValue(pendingApplication?.message_id || pendingApplication?.messageId),
field_key: rule.fieldKey,
field_label: rule.fieldLabel,
field_value: value
}
}
export function buildStewardFieldCompletionRawText({
preview = {},
fieldKey = '',
@@ -107,7 +219,12 @@ export function buildStewardFieldCompletionRawText({
: resolveFieldValue(fields.transportMode, ontologyFields.transport_mode)
const knownLines = [
['申请类型', resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')],
[
'申请类型',
resolveApplicationTypeDisplay(
resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')
)
],
['时间', resolveFieldValue(fields.time, ontologyFields.time_range)],
['地点', resolveFieldValue(fields.location, ontologyFields.location)],
['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)],
@@ -120,6 +237,7 @@ export function buildStewardFieldCompletionRawText({
return [
'小财管家继续执行申请单字段补齐。',
`用户已补充:${selectedLabel}${selectedValue}`,
...buildFieldCompletionScopeHints(fieldKey, selectedValue),
currentTask?.summary ? `任务摘要:${currentTask.summary}` : '',
'',
'已识别信息:',

View File

@@ -99,6 +99,10 @@ const FIELD_VALUE_DISPLAY_CONFIG = {
}
}
const FLOW_EXPENSE_TYPE_LABELS = {
travel: '差旅费'
}
export function buildStewardPlanRequest({
rawText = '',
files = [],
@@ -216,6 +220,10 @@ export function buildStewardPlanMessageText(plan) {
if (isPendingFlowConfirmationPlan(normalized)) {
return buildPendingFlowConfirmationMessageText(normalized)
}
const genericReimbursementTask = normalized.tasks.find((task) => isGenericReimbursementTask(task))
if (genericReimbursementTask && normalized.tasks.length === 1) {
return buildGenericReimbursementIntentMessageText(genericReimbursementTask)
}
const nextContext = resolveNextActionContext(normalized)
const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
const taskLines = orderedTasks.map((task, index) =>
@@ -289,6 +297,42 @@ export function formatStewardOntologyFields(fields = {}, taskType = '') {
.join('')
}
function buildStewardOntologyFieldRows(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return {
label: field.label,
value: formatStewardFieldDisplayValue(field.key, value)
}
})
}
function escapeMarkdownTableCell(value) {
return String(value || '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim()
}
function formatStewardOntologyFieldsTable(fields = {}, taskType = '') {
const rows = buildStewardOntologyFieldRows(fields, taskType)
if (!rows.length) {
return ''
}
return [
'| 字段 | 内容 |',
'| --- | --- |',
...rows.map((row) => `| ${escapeMarkdownTableCell(row.label)} | ${escapeMarkdownTableCell(row.value)} |`)
].join('\n')
}
function resolveCandidateFlowExpenseType(flow = {}) {
const rawType = String(flow?.ontologyFields?.expense_type || flow?.ontologyFields?.expenseType || '').trim()
if (rawType === '差旅' || rawType === 'travel') {
return 'travel'
}
return rawType
}
export function buildStewardSuggestedActions(plan) {
const normalized = normalizeStewardPlan(plan)
if (isOffTopicPlan(normalized)) {
@@ -304,26 +348,32 @@ export function buildStewardSuggestedActions(plan) {
}))
}
if (isPendingFlowConfirmationPlan(normalized)) {
return normalized.candidateFlows.map((flow) => ({
label: flow.label,
description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
icon: flow.flowId === 'travel_application'
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
steward_confirm_flow: true,
steward_plan_id: normalized.planId,
flow_id: flow.flowId,
session_type: flow.flowId === 'travel_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE,
selected_flow_label: flow.label,
carry_text: flow.label,
auto_submit: true,
steward_state: normalized.stewardState || null
return normalized.candidateFlows.map((flow) => {
const expenseType = resolveCandidateFlowExpenseType(flow)
return {
label: flow.label,
description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
icon: flow.flowId === 'travel_application'
? 'mdi mdi-file-plus-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
steward_confirm_flow: true,
steward_plan_id: normalized.planId,
flow_id: flow.flowId,
session_type: flow.flowId === 'travel_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE,
selected_flow_label: flow.label,
expense_type: expenseType,
expense_type_label: FLOW_EXPENSE_TYPE_LABELS[expenseType] || '',
requires_application_before_reimbursement: flow.flowId === 'travel_reimbursement' && expenseType === 'travel',
carry_text: flow.flowId === 'travel_reimbursement' && expenseType === 'travel' ? '我要报销' : flow.label,
auto_submit: true,
steward_state: normalized.stewardState || null
}
}
}))
})
}
const nextContext = resolveNextActionContext(normalized)
if (!nextContext) {
@@ -335,7 +385,7 @@ export function buildStewardSuggestedActions(plan) {
: SESSION_TYPE_EXPENSE
return [
{
label: buildNextActionLabel(actionType),
label: buildNextActionLabel(actionType, task),
description: buildNextActionDescription(actionType, normalized, task, group),
icon: actionType === 'confirm_create_application'
? 'mdi mdi-file-plus-outline'
@@ -411,40 +461,58 @@ export function isOffTopicStewardPlan(rawPlan) {
}
function buildOffTopicMessageText(normalized) {
// off_topic 计划的引导文案完全由后端生成(含 ### 标题 + 正文 + 引导句),
// 前端透传 summary 即可,避免重复拼接导致与后端表达不一致。
const summary = String(normalized?.summary || '').trim()
const summaryLine = summary && summary !== '这看起来跟财务任务没什么关系...'
? summary
: '这看起来跟财务任务没什么关系,我目前只能帮你处理**费用申请**和**费用报销**两类事项。'
return [
'### 小财管家没看懂这件事',
'',
summaryLine,
'',
'你可以试试下面这些方式告诉我:'
].join('\n')
if (summary) {
return summary
}
return (
'### 这句话我暂时没识别到财务事项\n\n' +
'很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。\n\n' +
'要不您换种说法告诉我:'
)
}
function buildPendingFlowConfirmationMessageText(normalized) {
const fields = normalized.candidateFlows[0]?.ontologyFields || {}
const knownParts = formatStewardOntologyFields(fields, 'expense_application')
const knownTable = formatStewardOntologyFieldsTable(fields, 'expense_application')
const candidateLines = normalized.candidateFlows.map((flow, index) =>
`${index + 1}. **${flow.label}**${flow.reason ? `\n - ${flow.reason}` : ''}`
)
const singleCandidate = normalized.candidateFlows.length === 1
return [
'### 需要先确认流程方向',
'',
knownParts
? `我识别到这是一项财务事项,已提取到:**${knownParts}**。`
knownTable
? ['我识别到这是一项财务事项,已提取到:', '', knownTable].join('\n')
: '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。',
'',
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。',
'',
...candidateLines,
'',
'请先选择一个方向,我会继续整理对应材料。'
singleCandidate
? `请先点击下方 **${normalized.candidateFlows[0].label}**,我会继续整理对应材料。`
: '请先选择一个方向,我会继续整理对应材料。'
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
}
function buildGenericReimbursementIntentMessageText() {
return [
'### 我来带你发起报销',
'',
'你现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带你填。',
'',
'1. **先选报销场景**',
' - 例如差旅费、交通费、住宿费、业务招待费或办公用品费,不同场景需要的材料不一样。',
'2. **再补关键材料**',
' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮你核对是否需要关联事前申请。',
'',
'点击下面的 **确定,选择报销场景**,我会进入报销助手继续引导。'
].join('\n')
}
function resolveNextActionContext(normalized) {
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
const applicationAction = applicationTask
@@ -566,6 +634,9 @@ function buildTaskOrderActionDescription(task) {
return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。`
}
if (task.taskType === 'reimbursement') {
if (isGenericReimbursementTask(task)) {
return `我会请${agent}先带你选择报销场景,再逐步补齐事由、时间、金额和票据。`
}
return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。`
}
return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。`
@@ -603,13 +674,16 @@ function buildNextTaskLead(task) {
return `处理“${task.title || task.taskTypeLabel}`
}
function buildNextActionLabel(actionType) {
function buildNextActionLabel(actionType, task = null) {
if (actionType === 'confirm_create_application') {
return '确定,先创建申请单'
}
if (actionType === 'confirm_attachment_group') {
return '确定,确认附件归集'
}
if (isGenericReimbursementTask(task)) {
return '确定,选择报销场景'
}
return '确定,继续填写报销单'
}
@@ -627,7 +701,29 @@ function buildNextActionDescription(actionType, normalized, task, group) {
}
return group?.attachmentNames?.length
? `报销助手会带入 ${group.attachmentNames.length} 份相关附件生成核对结果。`
: '报销助手会根据当前任务生成报销核对结果。'
: isGenericReimbursementTask(task)
? '先进入报销助手选择具体费用类型,再按场景补齐事由、时间、金额和票据。'
: '报销助手会根据当前任务生成报销核对结果。'
}
function isGenericReimbursementTask(task) {
if (!task || task.taskType !== 'reimbursement') {
return false
}
const fields = task.ontologyFields || {}
const expenseType = String(fields.expense_type || '').trim()
const hasSpecificField = ['time_range', 'location', 'amount', 'attachments', 'transport_mode']
.some((key) => String(fields[key] || '').trim())
|| isSpecificReimbursementReason(fields.reason)
return !hasSpecificField && (!expenseType || expenseType === 'other')
}
function isSpecificReimbursementReason(value) {
const text = String(value || '').trim().replace(/\s+/g, '')
if (!text) {
return false
}
return !/^(?:我想要|我想|我要|还需要|需要|请帮我|帮我)?报销(?:费用|报销单|报销流程)?$/.test(text)
}
function buildStewardCarryText(actionType, task, group, normalized = null) {
@@ -644,6 +740,9 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
if (!task) {
return '我确认继续处理这项财务任务,请按现有流程核对信息。'
}
if (actionType === 'confirm_create_reimbursement_draft' && isGenericReimbursementTask(task)) {
return '我要报销'
}
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
const missingFields = formatStewardMissingFieldList(

View File

@@ -74,6 +74,10 @@ export function normalizeOcrDocuments(payload) {
preview_kind: String(item.preview_kind || '').trim(),
preview_data_url: String(item.preview_data_url || '').trim(),
preview_url: String(item.preview_url || '').trim(),
receipt_id: String(item.receipt_id || item.receiptId || '').trim(),
receipt_status: String(item.receipt_status || item.receiptStatus || '').trim(),
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
document_fields: Array.isArray(item.document_fields)
? item.document_fields
.map((field) => ({
@@ -87,6 +91,87 @@ export function normalizeOcrDocuments(payload) {
}))
}
function defineFileReceiptId(file, receiptId) {
const normalizedReceiptId = String(receiptId || '').trim()
if (!file || !normalizedReceiptId) {
return false
}
try {
Object.defineProperty(file, 'receiptId', {
value: normalizedReceiptId,
enumerable: false,
configurable: true
})
return true
} catch {
try {
file.receiptId = normalizedReceiptId
return String(file.receiptId || '').trim() === normalizedReceiptId
} catch {
return false
}
}
}
export function attachReceiptFolderIdsToFiles(files = [], payload = null) {
const safeFiles = Array.isArray(files) ? files : []
const documents = Array.isArray(payload?.documents) ? payload.documents : []
let attachedCount = 0
safeFiles.slice(0, documents.length).forEach((file, index) => {
const document = documents[index] || {}
const receiptId = String(document.receipt_id || document.receiptId || '').trim()
if (receiptId && defineFileReceiptId(file, receiptId)) {
attachedCount += 1
}
})
return attachedCount
}
export async function collectReceiptFiles({
files = [],
recognizedAttachmentData = null,
recognizeOcrFiles,
timeoutMs = 90000,
timeoutMessage = '票据 OCR 识别超时,已继续使用附件名称处理。'
} = {}) {
const safeFiles = Array.isArray(files) ? files : []
const reusedData = recognizedAttachmentData && typeof recognizedAttachmentData === 'object'
? recognizedAttachmentData
: null
if (reusedData) {
const ocrDocuments = Array.isArray(reusedData.ocrDocuments) ? [...reusedData.ocrDocuments] : []
const ocrPayload = reusedData.ocrPayload || { documents: ocrDocuments }
attachReceiptFolderIdsToFiles(safeFiles, ocrPayload)
return {
ocrPayload,
ocrSummary: String(reusedData.ocrSummary || '').trim() || buildOcrSummaryFromDocuments(ocrDocuments),
ocrDocuments,
ocrFilePreviews: Array.isArray(reusedData.ocrFilePreviews) ? [...reusedData.ocrFilePreviews] : []
}
}
if (typeof recognizeOcrFiles !== 'function') {
throw new Error('票据采集服务未配置。')
}
const ocrPayload = await recognizeOcrFiles(safeFiles, {
timeoutMs,
timeoutMessage
})
attachReceiptFolderIdsToFiles(safeFiles, ocrPayload)
return {
ocrPayload,
ocrSummary: buildOcrSummary(ocrPayload),
ocrDocuments: normalizeOcrDocuments(ocrPayload),
ocrFilePreviews: buildOcrFilePreviews(ocrPayload)
}
}
export function buildOcrSummary(payload) {
return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
}

View File

@@ -358,8 +358,9 @@ export function buildExpenseSceneSelectionMessage(rawText) {
: '我已识别到这是报销申请。'
return [
`${prefix}但现在还不能确定具体报销场景,所以我先暂停信息抽取`,
'请先选择本次要发起的报销场景,选择后我再按对应规则继续识别并整理核对信息。'
`${prefix}先选一下这笔费用属于哪一类,我再按对应流程继续`,
'差旅和业务招待通常需要先关联申请单;交通、住宿、办公用品这类一般可以直接继续填写。',
'选完后我会把下一步需要准备的内容整理给你。'
].join('\n')
}
@@ -882,6 +883,8 @@ export function normalizeInitialConversationMessages(conversation) {
return createMessage(item.role, item.content, attachmentNames, {
id: `restored-${item.id || ++messageSeed}`,
time: formatMessageTime(item.created_at || item.createdAt),
assistantName: String(messageJson?.assistant_name || messageJson?.assistantName || '').trim(),
assistantVariant: String(messageJson?.assistant_variant || messageJson?.assistantVariant || '').trim(),
meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [],
citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [],
suggestedActions:
@@ -940,6 +943,7 @@ export function serializeSessionMessages(messages) {
stewardPlan: message.stewardPlan || null,
operationFeedback: message.operationFeedback || null,
assistantName: message.assistantName || '',
assistantVariant: message.assistantVariant || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
}))

View File

@@ -1,7 +1,8 @@
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage,
buildUnsavedDraftAttachmentConfirmationMessage
buildUnsavedDraftAttachmentConfirmationMessage,
collectReceiptFiles
} from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import {
@@ -312,8 +313,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
buildExpenseSceneSelectionMessage,
buildMessageMeta,
buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments,
buildReviewFormContextFromPayload,
clearAttachedFiles,
@@ -348,7 +347,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
messages,
nextTick,
normalizeExpenseQueryPayload,
normalizeOcrDocuments,
persistSessionState,
props,
recognizeOcrFiles,
@@ -1825,23 +1823,28 @@ export function useTravelReimbursementSubmitComposer(ctx) {
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
}
if (recognizedAttachmentData) {
ocrPayload = recognizedAttachmentData.ocrPayload
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
const collected = await collectReceiptFiles({
files,
recognizedAttachmentData
})
ocrPayload = collected.ocrPayload
ocrSummary = collected.ocrSummary
ocrDocuments = collected.ocrDocuments
ocrFilePreviews = collected.ocrFilePreviews
rememberFilePreviews(ocrFilePreviews)
if (!stewardDelegated) {
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
}
} else {
try {
ocrPayload = await recognizeOcrFiles(files, {
timeoutMs: 90000,
timeoutMessage: '票据 OCR 识别超时,已继续使用附件名称处理。'
const collected = await collectReceiptFiles({
files,
recognizeOcrFiles
})
ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
ocrPayload = collected.ocrPayload
ocrSummary = collected.ocrSummary
ocrDocuments = collected.ocrDocuments
ocrFilePreviews = collected.ocrFilePreviews
rememberFilePreviews(ocrFilePreviews)
if (!stewardDelegated) {
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)

View File

@@ -339,6 +339,10 @@ export function useTravelReimbursementSuggestedActions({
const carryText = String(actionPayload.carry_text || '').trim()
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
pushExpenseSceneSelectionPrompt(carryText)
return
}
if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText