feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源 - 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore 及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿 - 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局 - 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
BIN
web/UI/AI模式.png
Normal file
BIN
web/UI/AI模式.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
--sidebar-expanded-width: 184px;
|
--sidebar-expanded-width: 304px;
|
||||||
--sidebar-collapsed-width: 64px;
|
--sidebar-collapsed-width: 64px;
|
||||||
--sidebar-motion: 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
--sidebar-motion: 220ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app.sidebar-collapsed .app-sidebar {
|
.app.sidebar-collapsed .app-sidebar {
|
||||||
flex-basis: var(--sidebar-collapsed-width);
|
|
||||||
width: var(--sidebar-collapsed-width);
|
width: var(--sidebar-collapsed-width);
|
||||||
|
flex-basis: var(--sidebar-collapsed-width);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
}
|
}
|
||||||
@@ -54,6 +54,19 @@
|
|||||||
z-index: 1;
|
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 {
|
.app > .main {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -133,7 +146,7 @@
|
|||||||
color: var(--theme-primary-active);
|
color: var(--theme-primary-active);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boot-badge-error {
|
.boot-badge-error {
|
||||||
@@ -217,6 +230,10 @@
|
|||||||
background-size: 100% 100%, 100% 100%, 32px 32px, 32px 32px;
|
background-size: 100% 100%, 100% 100%, 32px 32px, 32px 32px;
|
||||||
background-attachment: local;
|
background-attachment: local;
|
||||||
}
|
}
|
||||||
|
.workarea.workbench-workarea.workbench-workarea-ai-mode {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
.workarea.settings-workarea {
|
.workarea.settings-workarea {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -312,6 +329,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workarea.workbench-workarea { overflow: auto; padding: 16px; }
|
.workarea.workbench-workarea { overflow: auto; padding: 16px; }
|
||||||
|
.workarea.workbench-workarea.workbench-workarea-ai-mode { padding: 0; }
|
||||||
|
|
||||||
.mobile-overlay {
|
.mobile-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
676
web/src/assets/styles/components/ai-sidebar-rail.css
Normal file
676
web/src/assets/styles/components/ai-sidebar-rail.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1446
web/src/assets/styles/components/personal-workbench-ai-mode.css
Normal file
1446
web/src/assets/styles/components/personal-workbench-ai-mode.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,77 +1,40 @@
|
|||||||
/* 1080p / 小高度屏:进一步压缩 AI 助手卡片高度 (排除手机端) */
|
/* 1080p / 小高度屏:让传统模式顶部趋势卡更紧凑 */
|
||||||
@media (max-height: 980px) and (min-width: 761px) {
|
@media (max-height: 980px) and (min-width: 761px) {
|
||||||
.workbench {
|
.workbench {
|
||||||
--hero-padding-top: 20px;
|
--hero-title-size: 31px;
|
||||||
--hero-padding-bottom: 20px;
|
--trend-card-min-height: 232px;
|
||||||
--hero-title-size: 28px;
|
--capability-row-height: 106px;
|
||||||
--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;
|
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-hero {
|
.workbench-trend-hero {
|
||||||
--assistant-bg-position: right center;
|
padding: 24px 20px 10px 20px;
|
||||||
--assistant-decor-width: clamp(760px, 66vw, 980px);
|
|
||||||
--assistant-decor-opacity: 0.86;
|
|
||||||
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-copy p {
|
.workbench-trend-card {
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-composer textarea {
|
.trend-chart-panel {
|
||||||
font-size: 15px;
|
min-height: 128px;
|
||||||
}
|
|
||||||
|
|
||||||
.composer-icon-button,
|
|
||||||
.composer-send-button {
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-send-button {
|
|
||||||
width: 50px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2K 宽屏但内容区仍偏高时,略收紧(避免 hero 独占过多纵向空间) */
|
/* 2K 宽屏但内容区仍偏高时,略收紧(避免 hero 独占过多纵向空间) */
|
||||||
@media (min-width: 1920px) and (max-height: 1100px) {
|
@media (min-width: 1920px) and (max-height: 1100px) {
|
||||||
.workbench {
|
.workbench {
|
||||||
--hero-padding-top: 22px;
|
--hero-title-size: 32px;
|
||||||
--hero-padding-bottom: 22px;
|
--trend-card-min-height: 236px;
|
||||||
--hero-title-size: 29px;
|
--capability-row-height: 108px;
|
||||||
--composer-min-height: 114px;
|
|
||||||
--composer-textarea-height: 50px;
|
|
||||||
--capability-row-height: 100px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1440px) {
|
@media (max-width: 1440px) {
|
||||||
.workbench {
|
.workbench {
|
||||||
grid-template-rows: auto var(--capability-row-height) minmax(0, 1fr);
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-hero {
|
.trend-summary-panel h1 {
|
||||||
--assistant-bg-position: right center;
|
font-size: 32px;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-grid--privileged {
|
.capability-grid--privileged {
|
||||||
@@ -83,7 +46,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.capability-card {
|
.capability-card {
|
||||||
padding: 17px 12px 17px 22px;
|
padding: 18px 14px 18px 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-copy {
|
.capability-copy {
|
||||||
@@ -109,24 +72,15 @@
|
|||||||
.workbench {
|
.workbench {
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
grid-template-rows: auto auto auto;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-hero {
|
.workbench-trend-card {
|
||||||
--assistant-bg-position: right center;
|
grid-template-columns: 1fr;
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-copy {
|
.trend-summary-panel {
|
||||||
width: min(820px, 92%);
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-grid--privileged {
|
.capability-grid--privileged {
|
||||||
@@ -149,126 +103,91 @@
|
|||||||
@media (min-width: 961px) and (max-width: 1440px),
|
@media (min-width: 961px) and (max-width: 1440px),
|
||||||
(min-width: 961px) and (max-height: 820px) {
|
(min-width: 961px) and (max-height: 820px) {
|
||||||
.workbench {
|
.workbench {
|
||||||
--hero-padding-top: 14px;
|
--hero-title-size: 30px;
|
||||||
--hero-padding-bottom: 14px;
|
--trend-card-min-height: 232px;
|
||||||
--hero-title-size: 24px;
|
--capability-row-height: 102px;
|
||||||
--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;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-hero {
|
.workbench-trend-hero {
|
||||||
--assistant-decor-width: clamp(680px, 60vw, 880px);
|
padding: 24px 18px 10px 18px;
|
||||||
--assistant-decor-opacity: 0.72;
|
|
||||||
padding: var(--hero-padding-top) 16px var(--hero-padding-bottom) 34px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-copy {
|
.workbench-trend-card {
|
||||||
width: min(900px, 92%);
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-copy h1 {
|
.trend-summary-panel {
|
||||||
margin-bottom: var(--hero-title-bottom-gap);
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-summary-panel h1 {
|
||||||
|
margin-bottom: 28px;
|
||||||
font-size: var(--hero-title-size);
|
font-size: var(--hero-title-size);
|
||||||
line-height: 1.14;
|
line-height: 1.14;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-composer {
|
.trend-total {
|
||||||
min-height: var(--composer-min-height);
|
font-size: 42px;
|
||||||
gap: 4px;
|
|
||||||
padding: var(--composer-padding-block) 14px 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-composer textarea {
|
.trend-summary-panel small {
|
||||||
height: var(--composer-textarea-height);
|
display: none;
|
||||||
min-height: var(--composer-textarea-height);
|
}
|
||||||
max-height: var(--composer-textarea-height);
|
|
||||||
|
.trend-chart-panel {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-chart-head strong {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.42;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-toolbar {
|
.trend-chart-source {
|
||||||
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);
|
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-prompts button {
|
|
||||||
min-height: 24px;
|
|
||||||
padding: 0 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.capability-grid {
|
.capability-grid {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-card {
|
.capability-card {
|
||||||
grid-template-columns: 34px minmax(0, 1fr) 14px;
|
grid-template-columns: 40px minmax(0, 1fr) 16px;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
padding: 12px 12px 12px 16px;
|
padding: 15px 14px 15px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-icon {
|
.capability-icon {
|
||||||
--workbench-list-icon-size: 34px;
|
--workbench-list-icon-size: 40px;
|
||||||
--workbench-list-icon-art-size: 20px;
|
--workbench-list-icon-art-size: 24px;
|
||||||
width: 34px;
|
width: 40px;
|
||||||
height: 34px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-copy {
|
.capability-copy {
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-copy strong {
|
.capability-copy strong {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-copy small {
|
.capability-copy small {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
line-height: 1.22;
|
line-height: 1.22;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-arrow {
|
.capability-arrow {
|
||||||
width: 14px;
|
width: 16px;
|
||||||
min-width: 14px;
|
min-width: 16px;
|
||||||
font-size: 16px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.workbench {
|
.workbench {
|
||||||
height: auto;
|
height: auto;
|
||||||
grid-template-rows: none;
|
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
--workbench-glass-base:
|
--workbench-glass-base:
|
||||||
@@ -279,47 +198,36 @@
|
|||||||
--workbench-glass-blur: blur(14px) saturate(1.2);
|
--workbench-glass-blur: blur(14px) saturate(1.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-hero {
|
.workbench-trend-hero {
|
||||||
min-height: auto;
|
padding: 16px;
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-copy {
|
.workbench-trend-card {
|
||||||
width: 100%;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-copy h1 {
|
.trend-summary-panel h1 {
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-composer {
|
.trend-summary-panel {
|
||||||
padding: 14px;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-toolbar {
|
.trend-total {
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-chart-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-count {
|
.trend-chart-panel {
|
||||||
order: 4;
|
min-height: 148px;
|
||||||
width: 100%;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-send-button {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-grid,
|
.capability-grid,
|
||||||
@@ -356,88 +264,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对低高度视口(如低于 840px,包含大部分笔记本 768px 高度),解除 height: 100% 限制,让内容流式高度,防止纵向元素被过度压扁 (排除手机端) */
|
/* 针对低高度视口,解除 height: 100% 限制,防止纵向元素被过度压扁 */
|
||||||
@media (max-height: 840px) and (min-width: 761px) {
|
@media (max-height: 840px) and (min-width: 761px) {
|
||||||
.workbench {
|
.workbench {
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
grid-template-rows: auto var(--capability-row-height) auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 手机端/窄屏自适应优化 (560px 以下) */
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
/* 常用提问横向滑动展示,避免折行过多撑爆高度 */
|
.workbench-trend-hero {
|
||||||
.quick-prompts {
|
padding: 14px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 手机端/窄屏自适应优化 (480px 以下) */
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
/* 输入框更小巧 */
|
.trend-summary-panel h1 {
|
||||||
.assistant-composer {
|
font-size: 24px;
|
||||||
padding: 10px 12px;
|
|
||||||
min-height: 94px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-composer textarea {
|
.trend-total {
|
||||||
font-size: 14px;
|
font-size: 30px;
|
||||||
height: 42px;
|
|
||||||
min-height: 42px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-toolbar {
|
.trend-chart-panel {
|
||||||
gap: 6px;
|
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 {
|
.capability-card {
|
||||||
padding: 12px 10px 12px 14px;
|
padding: 12px 10px 12px 14px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -463,7 +316,6 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 重点优化:费用进度行的网格区域(Grid Area)双行重构 */
|
|
||||||
.progress-row {
|
.progress-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(70px, auto) 1fr minmax(74px, auto);
|
grid-template-columns: minmax(70px, auto) 1fr minmax(74px, auto);
|
||||||
@@ -506,7 +358,7 @@
|
|||||||
.progress-result {
|
.progress-result {
|
||||||
grid-area: result;
|
grid-area: result;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-items: end; /* 金额和状态右对齐 */
|
justify-items: end;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,9 +367,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-status {
|
.progress-status {
|
||||||
font-size: 11px;
|
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-steps {
|
.progress-steps {
|
||||||
@@ -526,7 +378,6 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 缩小步骤图图标与连线 */
|
|
||||||
.progress-step i {
|
.progress-step i {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
@@ -541,7 +392,6 @@
|
|||||||
top: 7px;
|
top: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 侧边分析栏优化 */
|
|
||||||
.side-panel {
|
.side-panel {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
.workbench {
|
.workbench {
|
||||||
--hero-padding-top: 26px;
|
--hero-padding-top: 26px;
|
||||||
--hero-padding-bottom: 26px;
|
--hero-padding-bottom: 26px;
|
||||||
--hero-title-size: 30px;
|
--hero-title-size: 34px;
|
||||||
--hero-copy-gap: 6px;
|
--hero-copy-gap: 6px;
|
||||||
--hero-title-bottom-gap: 18px;
|
--hero-title-bottom-gap: 18px;
|
||||||
--composer-min-height: 122px;
|
--trend-card-min-height: 260px;
|
||||||
--composer-textarea-height: 54px;
|
--capability-row-height: 116px;
|
||||||
--composer-padding-block: 12px;
|
|
||||||
--quick-prompts-gap-top: 10px;
|
|
||||||
--capability-row-height: 104px;
|
|
||||||
--workbench-ink: var(--ink, #1e293b);
|
--workbench-ink: var(--ink, #1e293b);
|
||||||
--workbench-text: var(--text, #334155);
|
--workbench-text: var(--text, #334155);
|
||||||
--workbench-muted: var(--muted, #64748b);
|
--workbench-muted: var(--muted, #64748b);
|
||||||
@@ -30,8 +27,8 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-rows: auto var(--capability-row-height) minmax(0, 1fr);
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
color: var(--workbench-ink);
|
color: var(--workbench-ink);
|
||||||
@@ -41,7 +38,7 @@
|
|||||||
background-color: var(--workbench-surface-soft);
|
background-color: var(--workbench-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench :where(button, textarea) {
|
.workbench :where(button) {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,338 +55,139 @@
|
|||||||
|
|
||||||
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
|
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
|
||||||
|
|
||||||
.assistant-hero {
|
.workbench-trend-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));
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
flex: 0 0 var(--trend-card-min-height);
|
||||||
|
height: var(--trend-card-min-height);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
padding: 24px 28px;
|
||||||
flex-direction: column;
|
overflow: hidden;
|
||||||
justify-content: center;
|
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16);
|
||||||
overflow: visible;
|
border-radius: 12px;
|
||||||
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;
|
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.54)),
|
linear-gradient(120deg, rgba(255, 255, 255, 0.85), rgba(249, 252, 255, 0.7)),
|
||||||
var(--assistant-theme-tint);
|
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 68%);
|
||||||
background-color: rgba(247, 252, 255, 0.72);
|
backdrop-filter: blur(12px) saturate(140%);
|
||||||
backdrop-filter: blur(14px) saturate(1.18);
|
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||||
-webkit-backdrop-filter: blur(14px) saturate(1.18);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 12px 28px rgba(15, 23, 42, 0.045),
|
0 16px 32px rgba(15, 23, 42, 0.04),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.86),
|
inset 0 1px 0 rgba(255, 255, 255, 0.94);
|
||||||
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07);
|
animation: workbenchItemIn 520ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
||||||
isolation: isolate;
|
|
||||||
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
|
|
||||||
animation-delay: 0ms;
|
animation-delay: 0ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-hero::after {
|
.workbench-trend-card {
|
||||||
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 {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 1;
|
||||||
width: min(980px, 94%);
|
|
||||||
display: grid;
|
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 {
|
.trend-summary-panel {
|
||||||
margin: 0 0 var(--hero-title-bottom-gap);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-summary-panel h1 {
|
||||||
|
margin: 0 0 44px 0;
|
||||||
color: var(--workbench-ink);
|
color: var(--workbench-ink);
|
||||||
font-size: var(--hero-title-size);
|
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;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-copy h1 span:not(.typing-cursor) {
|
.trend-chart-source {
|
||||||
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;
|
|
||||||
color: var(--workbench-muted);
|
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-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;
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-prompts button {
|
.workbench-trend-chart {
|
||||||
min-height: 28px;
|
min-height: 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.capability-grid {
|
.capability-grid {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
flex: 0 0 var(--capability-row-height);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -407,11 +205,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px minmax(0, 1fr) 18px;
|
grid-template-columns: 44px minmax(0, 1fr) 18px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 16px 18px 16px 22px;
|
padding: 18px 20px 18px 24px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||||
@@ -450,10 +248,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.capability-icon {
|
.capability-icon {
|
||||||
--workbench-list-icon-size: 40px;
|
--workbench-list-icon-size: 44px;
|
||||||
--workbench-list-icon-art-size: 24px;
|
--workbench-list-icon-art-size: 26px;
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
color: var(--capability-color);
|
color: var(--capability-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +265,7 @@
|
|||||||
|
|
||||||
.capability-copy strong {
|
.capability-copy strong {
|
||||||
color: var(--workbench-ink);
|
color: var(--workbench-ink);
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -479,7 +277,7 @@
|
|||||||
.capability-copy small {
|
.capability-copy small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--workbench-muted);
|
color: var(--workbench-muted);
|
||||||
font-size: 12px;
|
font-size: 12.5px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -529,6 +327,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workbench-content-grid {
|
.workbench-content-grid {
|
||||||
|
flex: 1 1 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(640px, 1.8fr) minmax(260px, 0.55fr);
|
grid-template-columns: minmax(640px, 1.8fr) minmax(260px, 0.55fr);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -1034,9 +833,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.capability-card:hover,
|
.capability-card:hover,
|
||||||
.progress-row:hover,
|
.progress-row:hover {
|
||||||
.quick-prompts button:hover,
|
|
||||||
.composer-icon-button:hover {
|
|
||||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
|
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
|
||||||
color: var(--workbench-primary-active);
|
color: var(--workbench-primary-active);
|
||||||
}
|
}
|
||||||
@@ -1053,9 +850,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.assistant-hero,
|
.workbench-trend-hero,
|
||||||
.capability-card,
|
.capability-card,
|
||||||
.workbench-card {
|
.workbench-card {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,51 +276,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rail-user {
|
.rail-user {
|
||||||
position: relative;
|
box-sizing: border-box;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 78px;
|
height: 72px;
|
||||||
margin: 0;
|
min-height: 72px;
|
||||||
padding: 16px 20px 18px;
|
display: grid;
|
||||||
border-top: 1px solid #edf2f7;
|
grid-template-columns: 42px minmax(0, 1fr) 44px;
|
||||||
transition: padding var(--rail-motion-duration) var(--rail-motion-ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-summary {
|
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 42px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
padding: 4px;
|
margin: 0;
|
||||||
color: #64748b;
|
padding: 12px 14px 12px 18px;
|
||||||
border-radius: 4px;
|
border-top: 1px solid rgba(203, 213, 225, 0.55);
|
||||||
cursor: pointer;
|
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:
|
transition:
|
||||||
gap var(--rail-motion-duration) var(--rail-motion-ease),
|
grid-template-columns var(--rail-motion-duration) var(--rail-motion-ease),
|
||||||
padding 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
flex: 0 0 36px;
|
width: 42px;
|
||||||
width: 36px;
|
height: 42px;
|
||||||
height: 36px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 2px solid #fff;
|
border: 2px solid rgba(255, 255, 255, 0.92);
|
||||||
border-radius: 999px;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active));
|
background:
|
||||||
box-shadow: 0 6px 14px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
|
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;
|
color: #fff;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 800;
|
font-weight: 820;
|
||||||
transition:
|
transition:
|
||||||
flex-basis var(--rail-motion-duration) var(--rail-motion-ease),
|
|
||||||
width 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);
|
height var(--rail-motion-duration) var(--rail-motion-ease);
|
||||||
}
|
}
|
||||||
@@ -328,9 +320,7 @@
|
|||||||
.user-copy {
|
.user-copy {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 116px;
|
display: grid;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition:
|
transition:
|
||||||
@@ -341,9 +331,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-copy strong {
|
.user-copy strong {
|
||||||
color: #334155;
|
color: #182237;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 750;
|
font-weight: 760;
|
||||||
|
line-height: 1.25;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -352,57 +343,47 @@
|
|||||||
.user-copy span {
|
.user-copy span {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 520;
|
||||||
|
line-height: 1.25;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-summary .mdi {
|
.user-actions {
|
||||||
flex: 0 0 18px;
|
display: grid;
|
||||||
font-size: 18px;
|
grid-template-columns: 44px;
|
||||||
transition:
|
justify-content: end;
|
||||||
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-menu {
|
.user-action {
|
||||||
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 {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 38px;
|
min-width: 0;
|
||||||
display: flex;
|
height: 44px;
|
||||||
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
padding: 0 12px;
|
padding: 0;
|
||||||
border: 0;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 10px;
|
||||||
background: transparent;
|
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;
|
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 {
|
.rail-collapsed .rail-user {
|
||||||
position: relative;
|
grid-template-columns: 42px;
|
||||||
z-index: 6;
|
|
||||||
padding: 14px 8px;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-collapsed .user-summary {
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 4px;
|
padding: 14px 8px;
|
||||||
gap: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-user-menu-floating {
|
.rail-collapsed .user-copy,
|
||||||
position: fixed;
|
.rail-collapsed .user-actions {
|
||||||
z-index: 12000;
|
display: none;
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.rail-tooltip-popper) {
|
:global(.rail-tooltip-popper) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.employee-risk-profile-card {
|
.employee-risk-profile-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
padding: 12px 14px;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-head {
|
.employee-risk-head {
|
||||||
@@ -74,28 +74,28 @@
|
|||||||
|
|
||||||
.employee-risk-body {
|
.employee-risk-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-decision-panel {
|
.employee-risk-decision-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 32%);
|
grid-template-columns: minmax(0, 1.15fr) minmax(220px, .85fr);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
background: #f8fafc;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-decision-panel.medium {
|
.employee-risk-decision-panel.medium {
|
||||||
border-color: #fed7aa;
|
border-color: #f3e8d9;
|
||||||
background: #fff7ed;
|
background: #fffcf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-decision-panel.high {
|
.employee-risk-decision-panel.high {
|
||||||
border-color: #fecaca;
|
border-color: #fecaca;
|
||||||
background: #fef2f2;
|
background: #fff7f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-decision-main {
|
.employee-risk-decision-main {
|
||||||
@@ -110,13 +110,15 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-decision-main strong {
|
.employee-risk-decision-main strong {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-weight: 850;
|
font-weight: 900;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,8 +145,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,8 +154,8 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 900;
|
||||||
line-height: 1.5;
|
line-height: 1.45;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,12 +167,75 @@
|
|||||||
color: #b91c1c;
|
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 {
|
.employee-risk-profile-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,16 +270,16 @@
|
|||||||
.employee-risk-evidence-row {
|
.employee-risk-evidence-row {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 0;
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-evidence-row.medium {
|
.employee-risk-evidence-row.medium {
|
||||||
border-color: #fed7aa;
|
border-color: #f3e8d9;
|
||||||
background: #fffbf5;
|
background: #fffcf7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-evidence-row.high {
|
.employee-risk-evidence-row.high {
|
||||||
@@ -222,12 +287,26 @@
|
|||||||
background: #fff7f7;
|
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 {
|
.employee-risk-evidence-title {
|
||||||
min-height: 20px;
|
min-height: 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
@@ -262,13 +341,26 @@
|
|||||||
color: #b91c1c;
|
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 {
|
.employee-risk-evidence-row ul {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0 10px 10px 10px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-evidence-row li {
|
.employee-risk-evidence-row li {
|
||||||
@@ -291,6 +383,10 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.employee-risk-review-item {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.employee-risk-title-wrap,
|
.employee-risk-title-wrap,
|
||||||
.employee-risk-section-head {
|
.employee-risk-section-head {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -380,6 +380,14 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-utility-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-icon-btn {
|
.topbar-icon-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 34px;
|
width: 34px;
|
||||||
@@ -1113,6 +1121,68 @@
|
|||||||
font-size: 16px;
|
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 {
|
.kpi-chip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto auto;
|
grid-template-columns: auto auto;
|
||||||
@@ -1259,6 +1329,10 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-utility-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-icon-btn {
|
.topbar-icon-btn {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
@@ -1271,6 +1345,16 @@
|
|||||||
font-size: 12px;
|
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 {
|
.kpi-chips {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -1329,6 +1413,7 @@
|
|||||||
.search-wrap,
|
.search-wrap,
|
||||||
.search-wrap.wide,
|
.search-wrap.wide,
|
||||||
.topbar-toolset,
|
.topbar-toolset,
|
||||||
|
.topbar-utility-actions,
|
||||||
.detail-alert-strip,
|
.detail-alert-strip,
|
||||||
.month-chip,
|
.month-chip,
|
||||||
.qa-filter,
|
.qa-filter,
|
||||||
@@ -1344,6 +1429,15 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-utility-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-ai-mode-toggle {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
.range-shell {
|
.range-shell {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -1505,6 +1599,10 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-ai-mode-toggle {
|
||||||
|
flex: 0 0 34px;
|
||||||
|
}
|
||||||
|
|
||||||
.range-combo {
|
.range-combo {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -56,16 +56,52 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.message-bubble-compact-guidance {
|
||||||
max-width: min(100%, 760px);
|
max-width: min(100%, 640px);
|
||||||
padding: 12px 14px;
|
padding: 10px 12px;
|
||||||
border: 1px solid #d8e4f0;
|
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
|
||||||
border-radius: 4px;
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
background: #ffffff;
|
box-shadow: 0 8px 18px rgba(148, 163, 184, 0.12);
|
||||||
color: #24324a;
|
}
|
||||||
font-size: var(--wb-fs-bubble, 13px);
|
|
||||||
line-height: 1.62;
|
.message-bubble-compact-guidance .message-meta {
|
||||||
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14);
|
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 {
|
.message-row.has-steward-plan .message-bubble {
|
||||||
@@ -135,7 +171,7 @@
|
|||||||
|
|
||||||
.steward-intent-event-list {
|
.steward-intent-event-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 12px 12px 30px;
|
padding: 0 12px 12px 44px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
@@ -274,6 +310,42 @@
|
|||||||
color: #24324a;
|
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(p),
|
||||||
.message-answer-markdown :deep(li),
|
.message-answer-markdown :deep(li),
|
||||||
.message-answer-markdown :deep(td),
|
.message-answer-markdown :deep(td),
|
||||||
@@ -281,16 +353,66 @@
|
|||||||
.message-answer-markdown :deep(blockquote) {
|
.message-answer-markdown :deep(blockquote) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
line-height: 1.62;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-answer-markdown :deep(p + p),
|
.message-answer-markdown :deep(p + p),
|
||||||
.message-answer-markdown :deep(p + ul),
|
.message-answer-markdown :deep(p + ul),
|
||||||
|
.message-answer-markdown :deep(p + ol),
|
||||||
.message-answer-markdown :deep(ul + p),
|
.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;
|
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) {
|
.message-answer-markdown :deep(strong) {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
@@ -649,6 +771,40 @@
|
|||||||
gap: 8px;
|
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 {
|
.structured-card-reveal-enter-active {
|
||||||
transition:
|
transition:
|
||||||
opacity 220ms cubic-bezier(0.2, 0, 0, 1),
|
opacity 220ms cubic-bezier(0.2, 0, 0, 1),
|
||||||
|
|||||||
40
web/src/assets/styles/views/personal-workbench-view.css
Normal file
40
web/src/assets/styles/views/personal-workbench-view.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/src/assets/workbench-ai-mode-orb-icon.gif
Normal file
BIN
web/src/assets/workbench-ai-mode-orb-icon.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
web/src/assets/workbench-ai-mode-orb-icon.png
Normal file
BIN
web/src/assets/workbench-ai-mode-orb-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
BIN
web/src/assets/workbench-ai-mode-robot-bg.png
Normal file
BIN
web/src/assets/workbench-ai-mode-robot-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -7,168 +7,35 @@
|
|||||||
note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。"
|
note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
|
<article class="panel workbench-trend-hero">
|
||||||
<div class="assistant-copy">
|
<div class="workbench-trend-card" aria-label="报销趋势同比">
|
||||||
<h1 class="assistant-hero-title">
|
<div class="trend-summary-panel">
|
||||||
{{ typedTitlePrefix }}<span v-if="titleTypingDone">小财管家</span><span v-if="!titleTypingDone" class="typing-cursor">|</span>
|
<h1>报销趋势</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
|
<div class="trend-chart-panel">
|
||||||
ref="fileInputRef"
|
<div class="trend-chart-head">
|
||||||
class="assistant-file-input"
|
<strong>月度报销明细</strong>
|
||||||
type="file"
|
<span class="trend-chart-source">与分析看板同源</span>
|
||||||
multiple
|
</div>
|
||||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
|
|
||||||
@change="handleWorkbenchFilesChange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="assistant-composer">
|
<TrendChart
|
||||||
<textarea
|
class="workbench-trend-chart"
|
||||||
ref="assistantInputRef"
|
mode="compareAmount"
|
||||||
v-model="assistantDraft"
|
:labels="reimbursementTrendLabels"
|
||||||
maxlength="1000"
|
:claim-amount="reimbursementTrendAmounts"
|
||||||
rows="2"
|
:comparison-amount="reimbursementTrendPreviousAmounts"
|
||||||
placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行..."
|
primary-label="本期"
|
||||||
:readonly="isComposerPending"
|
comparison-label="去年同期"
|
||||||
@keydown.enter.prevent="handleWorkbenchEnter"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -303,29 +170,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 PanelHead from '../shared/PanelHead.vue'
|
||||||
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
|
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
|
||||||
|
import TrendChart from '../charts/TrendChart.vue'
|
||||||
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
|
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
|
||||||
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
||||||
import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.vue'
|
import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.vue'
|
||||||
import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
|
|
||||||
import { useSystemState } from '../../composables/useSystemState.js'
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
import { useToast } from '../../composables/useToast.js'
|
|
||||||
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
|
|
||||||
import {
|
import {
|
||||||
buildExpenseStatItems,
|
buildExpenseStatItems,
|
||||||
filterAssistantCapabilitiesForUser,
|
filterAssistantCapabilitiesForUser,
|
||||||
quickPromptItems,
|
|
||||||
resolveWorkbenchCapabilityGridClass,
|
resolveWorkbenchCapabilityGridClass,
|
||||||
} from '../../data/personalWorkbench.js'
|
} from '../../data/personalWorkbench.js'
|
||||||
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
||||||
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
|
|
||||||
import { fetchCurrentEmployeeLatestProfile } from '../../services/reimbursements.js'
|
import { fetchCurrentEmployeeLatestProfile } from '../../services/reimbursements.js'
|
||||||
import {
|
|
||||||
ASSISTANT_SESSION_SNAPSHOT_EVENT,
|
|
||||||
hasAssistantSessionSnapshot
|
|
||||||
} from '../../utils/assistantSessionSnapshot.js'
|
|
||||||
import { buildWorkbenchCapabilityAssistantPayload } from '../../utils/personalWorkbenchAssistantEntry.js'
|
import { buildWorkbenchCapabilityAssistantPayload } from '../../utils/personalWorkbenchAssistantEntry.js'
|
||||||
import {
|
import {
|
||||||
buildProfileOperationsFromAgentRuns,
|
buildProfileOperationsFromAgentRuns,
|
||||||
@@ -344,35 +203,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['open-assistant', 'open-document'])
|
const emit = defineEmits(['open-assistant', 'open-document'])
|
||||||
const { currentUser } = useSystemState()
|
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 expenseStatsModalOpen = ref(false)
|
||||||
const expenseProfileModalOpen = ref(false)
|
const expenseProfileModalOpen = ref(false)
|
||||||
const employeeProfile = ref(null)
|
const employeeProfile = ref(null)
|
||||||
@@ -380,59 +210,13 @@ const employeeProfileRuns = ref([])
|
|||||||
const employeeProfileLoading = ref(false)
|
const employeeProfileLoading = ref(false)
|
||||||
const employeeProfileError = ref('')
|
const employeeProfileError = ref('')
|
||||||
let employeeProfileLoadSeq = 0
|
let employeeProfileLoadSeq = 0
|
||||||
const MAX_ATTACHMENTS = 10
|
|
||||||
const SESSION_TYPE_EXPENSE = 'expense'
|
const SESSION_TYPE_EXPENSE = 'expense'
|
||||||
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
|
||||||
const SESSION_TYPE_STEWARD = 'steward'
|
const SESSION_TYPE_STEWARD = 'steward'
|
||||||
|
|
||||||
const hasExpenseConversation = computed(() =>
|
|
||||||
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
|
|
||||||
|| hasLocalExpenseSnapshot.value
|
|
||||||
)
|
|
||||||
const displayUserName = computed(() => {
|
const displayUserName = computed(() => {
|
||||||
const user = currentUser.value || {}
|
const user = currentUser.value || {}
|
||||||
return String(user.name || user.username || '同事').trim() || '同事'
|
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 visibleAssistantCapabilities = computed(() => filterAssistantCapabilitiesForUser(currentUser.value))
|
||||||
const capabilityGridClass = computed(() => resolveWorkbenchCapabilityGridClass(currentUser.value))
|
const capabilityGridClass = computed(() => resolveWorkbenchCapabilityGridClass(currentUser.value))
|
||||||
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
|
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
|
||||||
@@ -468,133 +252,100 @@ const currentUserProfileKey = computed(() => {
|
|||||||
const user = currentUser.value || {}
|
const user = currentUser.value || {}
|
||||||
return [user.username, user.email, user.name, user.employeeNo, user.employee_no].map((item) => String(item || '').trim()).filter(Boolean).join('|')
|
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) {
|
function normalizeTrendRows(rows = []) {
|
||||||
const nextFiles = []
|
return rows.map((row, index) => {
|
||||||
const seen = new Set()
|
const amount = Number(row?.amount || 0)
|
||||||
|
const previousAmount = Number(row?.previousAmount || row?.previous_amount || 0)
|
||||||
for (const file of existingFiles) {
|
return {
|
||||||
const key = buildSelectedFileKey(file)
|
key: String(row?.key || `trend-${index}`),
|
||||||
if (seen.has(key)) continue
|
label: String(row?.label || `${index + 1}月`),
|
||||||
seen.add(key)
|
amount,
|
||||||
nextFiles.push(file)
|
amountLabel: String(row?.amountLabel || row?.amount_label || formatCurrencyValue(amount)),
|
||||||
}
|
previousKey: String(row?.previousKey || row?.previous_key || `previous-${index}`),
|
||||||
|
previousAmount,
|
||||||
let overflowCount = 0
|
previousAmountLabel: String(
|
||||||
|
row?.previousAmountLabel || row?.previous_amount_label || formatCurrencyValue(previousAmount)
|
||||||
for (const file of incomingFiles) {
|
)
|
||||||
const key = buildSelectedFileKey(file)
|
|
||||||
if (seen.has(key)) continue
|
|
||||||
if (nextFiles.length >= MAX_ATTACHMENTS) {
|
|
||||||
overflowCount += 1
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
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() {
|
function buildAssistantPayload() {
|
||||||
return {
|
return {
|
||||||
prompt: buildWorkbenchPromptText(),
|
prompt: '',
|
||||||
source: 'workbench',
|
source: 'workbench',
|
||||||
sessionType: SESSION_TYPE_STEWARD,
|
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) {
|
function emitAssistant(payload) {
|
||||||
emit('open-assistant', 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) {
|
function openPromptAssistant(prompt, sessionType = SESSION_TYPE_STEWARD) {
|
||||||
if (pendingAction.value) {
|
emitAssistant({
|
||||||
return
|
prompt: String(prompt || '').trim(),
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
prompt: buildWorkbenchPromptText(prompt),
|
|
||||||
source: 'workbench',
|
source: 'workbench',
|
||||||
sessionType,
|
sessionType,
|
||||||
files: Array.from(selectedFiles.value),
|
files: [],
|
||||||
conversation: null
|
conversation: null
|
||||||
}
|
})
|
||||||
if (shouldShowIntentPending(payload)) {
|
|
||||||
startPendingAction('intent')
|
|
||||||
}
|
|
||||||
emitAssistant(payload)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openWorkbenchTarget(item) {
|
function openWorkbenchTarget(item) {
|
||||||
@@ -614,10 +365,6 @@ function openWorkbenchTarget(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCapabilityAssistant(item) {
|
function openCapabilityAssistant(item) {
|
||||||
if (pendingAction.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload()))
|
emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,122 +416,10 @@ function closeExpenseProfileModal() {
|
|||||||
expenseProfileModalOpen.value = false
|
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(() => {
|
onMounted(() => {
|
||||||
startTypewriter()
|
|
||||||
refreshLocalExpenseSnapshot()
|
|
||||||
refreshLatestExpenseConversation()
|
|
||||||
loadCurrentEmployeeProfile()
|
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) => {
|
watch(currentUserProfileKey, (nextKey, previousKey) => {
|
||||||
if (nextKey && nextKey !== previousKey) {
|
if (nextKey && nextKey !== previousKey) {
|
||||||
loadCurrentEmployeeProfile()
|
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.css"></style>
|
||||||
<style scoped src="../../assets/styles/components/personal-workbench-glass.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-insights.css"></style>
|
||||||
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>
|
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>
|
||||||
|
|||||||
1666
web/src/components/business/PersonalWorkbenchAiMode.vue
Normal file
1666
web/src/components/business/PersonalWorkbenchAiMode.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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-toolbar">
|
||||||
<div class="chart-legend">
|
<div class="chart-legend">
|
||||||
<span
|
<span
|
||||||
@@ -39,6 +39,10 @@ const props = defineProps({
|
|||||||
claimCount: { type: Array, default: () => [] },
|
claimCount: { type: Array, default: () => [] },
|
||||||
claimAmount: { type: Array, default: () => [] },
|
claimAmount: { type: Array, default: () => [] },
|
||||||
categoryAmountSeries: { 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: () => [] },
|
applications: { type: Array, default: () => [] },
|
||||||
approved: { type: Array, default: () => [] }
|
approved: { type: Array, default: () => [] }
|
||||||
})
|
})
|
||||||
@@ -46,6 +50,7 @@ const props = defineProps({
|
|||||||
const chartElement = shallowRef(null)
|
const chartElement = shallowRef(null)
|
||||||
const themeColors = useThemeColors()
|
const themeColors = useThemeColors()
|
||||||
const isCountMode = computed(() => props.mode === 'count')
|
const isCountMode = computed(() => props.mode === 'count')
|
||||||
|
const isComparisonMode = computed(() => props.mode === 'compareAmount')
|
||||||
const chartColors = computed(() => ({
|
const chartColors = computed(() => ({
|
||||||
primary: themeColors.value.chartPrimary,
|
primary: themeColors.value.chartPrimary,
|
||||||
blue: themeColors.value.chartBlue,
|
blue: themeColors.value.chartBlue,
|
||||||
@@ -93,14 +98,30 @@ const stackedAmountData = computed(() => props.labels.map((_, index) => [
|
|||||||
index,
|
index,
|
||||||
...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0))
|
...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0))
|
||||||
]))
|
]))
|
||||||
const activeColor = computed(() => (
|
const activeColor = computed(() => {
|
||||||
isCountMode.value ? chartColors.value.primary : chartColors.value.blue
|
return isCountMode.value ? chartColors.value.primary : chartColors.value.blue
|
||||||
))
|
})
|
||||||
|
const comparisonColor = computed(() => '#cbd5e1')
|
||||||
const legendLabel = computed(() => (
|
const legendLabel = computed(() => (
|
||||||
isCountMode.value ? '报销数量' : '报销金额'
|
isCountMode.value ? '报销数量' : (isComparisonMode.value ? props.primaryLabel : '报销金额')
|
||||||
))
|
))
|
||||||
const unitLabel = computed(() => (isCountMode.value ? '单位:单' : '单位:元'))
|
const unitLabel = computed(() => (isCountMode.value ? '单位:单' : '单位:元'))
|
||||||
const legendItems = computed(() => {
|
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) {
|
if (amountCategorySeries.value.length) {
|
||||||
return amountCategorySeries.value.map((item, index) => ({
|
return amountCategorySeries.value.map((item, index) => ({
|
||||||
name: item.name || `费用类型 ${index + 1}`,
|
name: item.name || `费用类型 ${index + 1}`,
|
||||||
@@ -114,23 +135,144 @@ const legendItems = computed(() => {
|
|||||||
title: `${legendLabel.value} ${unitLabel.value}`
|
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(() => {
|
const stackedMaxValue = computed(() => {
|
||||||
if (!amountCategorySeries.value.length) {
|
if (isComparisonMode.value || !amountCategorySeries.value.length) {
|
||||||
return maxValue.value
|
return maxValue.value
|
||||||
}
|
}
|
||||||
const dailyTotals = props.labels.map((_, index) => amountCategorySeries.value
|
const dailyTotals = props.labels.map((_, index) => amountCategorySeries.value
|
||||||
.reduce((sum, item) => sum + Number(item.data?.[index] || 0), 0))
|
.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(() =>
|
const ariaLabel = computed(() =>
|
||||||
props.labels.map((label, index) => (
|
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}报销${claimCountSeries.value[index] || 0}单`
|
||||||
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
|
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
|
||||||
)).join(',')
|
)).join(',')
|
||||||
)
|
)
|
||||||
const chartSeries = computed(() => {
|
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) {
|
if (!isCountMode.value && amountCategorySeries.value.length) {
|
||||||
return [{
|
return [{
|
||||||
name: '费用类型占比',
|
name: '费用类型占比',
|
||||||
@@ -151,15 +293,15 @@ const chartSeries = computed(() => {
|
|||||||
barWidth: 16,
|
barWidth: 16,
|
||||||
smooth: isCountMode.value,
|
smooth: isCountMode.value,
|
||||||
symbol: isCountMode.value ? 'circle' : 'none',
|
symbol: isCountMode.value ? 'circle' : 'none',
|
||||||
symbolSize: 7,
|
symbolSize: compactScale.value.defaultSymbolSize,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
width: 2.5,
|
width: compactScale.value.defaultLineWidth,
|
||||||
color: activeColor.value
|
color: activeColor.value
|
||||||
},
|
},
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: isCountMode.value ? '#ffffff' : activeColor.value,
|
color: isCountMode.value ? '#ffffff' : activeColor.value,
|
||||||
borderColor: 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]
|
borderRadius: [4, 4, 0, 0]
|
||||||
},
|
},
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
@@ -190,13 +332,7 @@ const chartOptions = computed(() => ({
|
|||||||
animationDurationUpdate: 1200,
|
animationDurationUpdate: 1200,
|
||||||
animationEasing: 'linear',
|
animationEasing: 'linear',
|
||||||
animationEasingUpdate: 'linear',
|
animationEasingUpdate: 'linear',
|
||||||
grid: {
|
grid: chartGrid.value,
|
||||||
top: 12,
|
|
||||||
right: 24,
|
|
||||||
bottom: 22,
|
|
||||||
left: 36,
|
|
||||||
containLabel: true
|
|
||||||
},
|
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
confine: true,
|
confine: true,
|
||||||
@@ -221,20 +357,22 @@ const chartOptions = computed(() => ({
|
|||||||
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
|
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
fontSize: 11,
|
fontSize: compactScale.value.axisLabelSize,
|
||||||
fontWeight: 700
|
fontWeight: 700
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: Math.ceil(stackedMaxValue.value * 1.18),
|
max: yAxisMax.value,
|
||||||
splitNumber: 5,
|
interval: props.compact ? (yAxisMax.value / 2) : undefined,
|
||||||
|
splitNumber: props.compact ? 2 : 5,
|
||||||
name: '',
|
name: '',
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
fontSize: 11,
|
fontSize: compactScale.value.axisLabelSize,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
|
margin: props.compact ? 12 : 8,
|
||||||
formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value))
|
formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value))
|
||||||
},
|
},
|
||||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
||||||
@@ -352,6 +490,16 @@ function formatTooltip(params) {
|
|||||||
if (!first) {
|
if (!first) {
|
||||||
return ''
|
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) {
|
if (!isCountMode.value && amountCategorySeries.value.length) {
|
||||||
return formatStackedTooltip(first)
|
return formatStackedTooltip(first)
|
||||||
}
|
}
|
||||||
@@ -406,6 +554,11 @@ function formatAxisCurrency(value) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trend-chart-compact {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-toolbar {
|
.chart-toolbar {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -465,4 +618,39 @@ function formatAxisCurrency(value) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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>
|
</style>
|
||||||
|
|||||||
321
web/src/components/layout/AiSidebarRail.vue
Normal file
321
web/src/components/layout/AiSidebarRail.vue
Normal 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>
|
||||||
@@ -56,63 +56,24 @@
|
|||||||
</ElTooltip>
|
</ElTooltip>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div
|
<section class="rail-user" aria-label="当前用户">
|
||||||
class="rail-user"
|
<div class="user-avatar" aria-hidden="true">{{ displayUser.avatar }}</div>
|
||||||
@mouseenter="openCollapsedUserMenu"
|
<div class="user-copy">
|
||||||
@mouseleave="closeCollapsedUserMenu"
|
<strong>{{ displayUser.name }}</strong>
|
||||||
@focusin="openCollapsedUserMenu"
|
<span>{{ displayUser.subtitle }}</span>
|
||||||
@focusout="handleUserFocusOut"
|
</div>
|
||||||
>
|
<div class="user-actions" aria-label="用户操作">
|
||||||
<div v-if="!collapsed" class="user-menu" role="menu" aria-label="用户菜单">
|
<button type="button" class="user-action user-logout" aria-label="退出系统" @click="emit('logout')">
|
||||||
<button class="user-menu-item" type="button" @click="emit('logout')">
|
<i class="mdi mdi-logout-variant" aria-hidden="true"></i>
|
||||||
<i class="mdi mdi-logout-variant"></i>
|
|
||||||
<span>退出系统</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<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>
|
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs'
|
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({
|
const props = defineProps({
|
||||||
navItems: { type: Array, required: true },
|
navItems: { type: Array, required: true },
|
||||||
@@ -164,99 +125,16 @@ const decoratedNavItems = computed(() =>
|
|||||||
|
|
||||||
const displayUser = computed(() => ({
|
const displayUser = computed(() => ({
|
||||||
name: props.currentUser?.name || '系统管理员',
|
name: props.currentUser?.name || '系统管理员',
|
||||||
role: props.currentUser?.role || '管理员',
|
subtitle:
|
||||||
avatar: props.currentUser?.avatar || '管'
|
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 displayCompanyName = computed(() => props.companyName || '易财费控')
|
||||||
const collapseTooltipContent = computed(() => (props.collapsed ? '展开侧边栏' : '折叠侧边栏'))
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
|
<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>
|
<div class="eyebrow">{{ eyebrowLabel }}</div>
|
||||||
<h1>{{ currentView.title }}</h1>
|
<h1>{{ currentView.title }}</h1>
|
||||||
<p>{{ currentView.desc }}</p>
|
<p>{{ currentView.desc }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="title-group" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<template v-if="isChat">
|
<template v-if="isChat">
|
||||||
@@ -278,12 +279,23 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="company-switcher" type="button" aria-label="切换公司">
|
<button class="company-switcher" type="button" aria-label="切换公司">
|
||||||
<span>{{ displayCompanyName }}</span>
|
<span>{{ displayCompanyName }}</span>
|
||||||
<i class="mdi mdi-chevron-down"></i>
|
<i class="mdi mdi-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</template>
|
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">
|
<template v-else-if="isDocuments">
|
||||||
<div class="kpi-chips">
|
<div class="kpi-chips">
|
||||||
@@ -345,18 +357,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="isEmployees">
|
<template v-else-if="isEmployees">
|
||||||
<div class="kpi-chips">
|
<div class="kpi-chips">
|
||||||
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
<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-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
|
||||||
<span class="chip-label">{{ kpi.label }}</span>
|
<span class="chip-label">{{ kpi.label }}</span>
|
||||||
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
|
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
</header>
|
<div v-if="showAiModeUtilityActions" class="topbar-utility-actions" aria-label="AI模式快捷操作">
|
||||||
</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>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
@@ -394,14 +424,18 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
workbenchSummary: {
|
workbenchSummary: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
companyName: {
|
workbenchMode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: 'traditional'
|
||||||
},
|
},
|
||||||
|
companyName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
detailMode: {
|
detailMode: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
@@ -431,10 +465,11 @@ const emit = defineEmits([
|
|||||||
'update:overviewDashboard',
|
'update:overviewDashboard',
|
||||||
'batchApprove',
|
'batchApprove',
|
||||||
'openChat',
|
'openChat',
|
||||||
'newApplication',
|
'newApplication',
|
||||||
'openDocument',
|
'openDocument',
|
||||||
'navigate'
|
'navigate',
|
||||||
])
|
'toggleWorkbenchMode'
|
||||||
|
])
|
||||||
const isChat = computed(() => props.activeView === 'chat')
|
const isChat = computed(() => props.activeView === 'chat')
|
||||||
const isOverview = computed(() => props.activeView === 'overview')
|
const isOverview = computed(() => props.activeView === 'overview')
|
||||||
const isWorkbench = computed(() => props.activeView === 'workbench')
|
const isWorkbench = computed(() => props.activeView === 'workbench')
|
||||||
@@ -444,12 +479,16 @@ const isRequests = computed(() => props.activeView === 'requests')
|
|||||||
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
|
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
|
||||||
const isApproval = computed(() => props.activeView === 'approval')
|
const isApproval = computed(() => props.activeView === 'approval')
|
||||||
const isPolicies = computed(() => props.activeView === 'policies')
|
const isPolicies = computed(() => props.activeView === 'policies')
|
||||||
const isEmployees = computed(() => props.activeView === 'employees')
|
const isEmployees = computed(() => props.activeView === 'employees')
|
||||||
const eyebrowLabel = computed(() => (
|
const eyebrowLabel = computed(() => (
|
||||||
String(props.currentView?.eyebrow || '').trim()
|
String(props.currentView?.eyebrow || '').trim()
|
||||||
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
|
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
|
||||||
))
|
))
|
||||||
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
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 MAX_NOTIFICATION_ITEMS = 30
|
||||||
const {
|
const {
|
||||||
markDocumentInboxRowRead,
|
markDocumentInboxRowRead,
|
||||||
@@ -576,12 +615,16 @@ const readNotifications = computed(() => notificationItems.value.filter((item) =
|
|||||||
const activeNotifications = computed(() => (
|
const activeNotifications = computed(() => (
|
||||||
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
|
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
|
||||||
))
|
))
|
||||||
const topbarNotificationCount = computed(() => {
|
const topbarNotificationCount = computed(() => {
|
||||||
const count = unreadNotifications.value.length
|
const count = unreadNotifications.value.length
|
||||||
return count > 0 ? Math.min(count, 99) : 0
|
return count > 0 ? Math.min(count, 99) : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
function clearDocumentInboxInitialRefreshTimer() {
|
function toggleTopbarWorkbenchMode() {
|
||||||
|
emit('toggleWorkbenchMode')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDocumentInboxInitialRefreshTimer() {
|
||||||
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
|
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
|
||||||
window.clearTimeout(documentInboxInitialRefreshTimer)
|
window.clearTimeout(documentInboxInitialRefreshTimer)
|
||||||
documentInboxInitialRefreshTimer = null
|
documentInboxInitialRefreshTimer = null
|
||||||
|
|||||||
@@ -18,32 +18,45 @@
|
|||||||
<p>{{ decisionDescription }}</p>
|
<p>{{ decisionDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="employee-risk-decision-action">
|
<div class="employee-risk-decision-action">
|
||||||
<span>建议结论</span>
|
<span>是否建议通过</span>
|
||||||
<strong :class="decisionTone">{{ decisionAction }}</strong>
|
<strong :class="decisionTone">{{ decisionBadgeLabel }}</strong>
|
||||||
|
<p>{{ decisionAction }}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div class="employee-risk-section-head">
|
||||||
<span>{{ stageBasisTitle }}</span>
|
<span>{{ stageBasisTitle }}</span>
|
||||||
<small>{{ stageBasisHint }}</small>
|
<small>{{ stageBasisHint }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="compactEvidenceItems.length" class="employee-risk-profile-list">
|
<div v-if="compactEvidenceItems.length" class="employee-risk-profile-list">
|
||||||
<article
|
<details
|
||||||
v-for="item in compactEvidenceItems"
|
v-for="(item, index) in compactEvidenceItems"
|
||||||
:key="item.code"
|
:key="item.code"
|
||||||
:class="['employee-risk-evidence-row', item.tone]"
|
: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>
|
<span>{{ item.label }}</span>
|
||||||
<strong>{{ item.status }}</strong>
|
<strong>{{ item.status }}</strong>
|
||||||
</div>
|
</summary>
|
||||||
<ul v-if="item.evidence.length">
|
<ul v-if="item.evidence.length">
|
||||||
<li v-for="basis in item.evidence" :key="basis">{{ basis }}</li>
|
<li v-for="basis in item.evidence" :key="basis">{{ basis }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据风险依据。</p>
|
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据依据。</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -95,12 +108,12 @@ export default {
|
|||||||
}
|
}
|
||||||
return 'normal'
|
return 'normal'
|
||||||
})
|
})
|
||||||
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议')
|
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : 'AI建议')
|
||||||
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单风险依据' : '报销单风险依据')
|
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单关键依据' : '报销单关键依据')
|
||||||
const stageBasisHint = computed(() => (
|
const stageBasisHint = computed(() => (
|
||||||
props.isApplicationDocument
|
props.isApplicationDocument
|
||||||
? '仅展示申请单本身的金额、预算触发、事由和规则命中依据。'
|
? '默认只展开最关键的申请依据,其他细节点开查看。'
|
||||||
: '仅展示报销单本身的票据、金额、行程和规则命中依据。'
|
: '默认只展开最关键的报销依据,其他细节点开查看。'
|
||||||
))
|
))
|
||||||
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
|
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
|
||||||
const decisionAction = computed(() => {
|
const decisionAction = computed(() => {
|
||||||
@@ -111,25 +124,26 @@ export default {
|
|||||||
})
|
})
|
||||||
const decisionBadgeLabel = computed(() => {
|
const decisionBadgeLabel = computed(() => {
|
||||||
if (decisionTone.value === 'high') {
|
if (decisionTone.value === 'high') {
|
||||||
return '高风险'
|
return '不通过'
|
||||||
}
|
}
|
||||||
if (decisionTone.value === 'medium') {
|
if (decisionTone.value === 'medium') {
|
||||||
return '需关注'
|
return '待补充'
|
||||||
}
|
}
|
||||||
return '可审批'
|
return '可通过'
|
||||||
})
|
})
|
||||||
const decisionDescription = computed(() => {
|
const decisionDescription = computed(() => {
|
||||||
const riskCount = currentRiskCards.value.length
|
const riskCount = currentRiskCards.value.length
|
||||||
|
const subject = props.isApplicationDocument ? '申请' : '报销'
|
||||||
if (riskCount) {
|
if (riskCount) {
|
||||||
if (!props.isApplicationDocument && riskExplanationItems.value.length) {
|
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) {
|
if (materialIssues.value.length || sceneIssues.value.length) {
|
||||||
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}存在材料或业务说明不完整,建议补齐后再继续处理。`
|
return `当前${subject}存在材料或业务说明不完整,建议补齐后再处理。`
|
||||||
}
|
}
|
||||||
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。`
|
return `当前${subject}未发现中高风险阻断项,可按流程继续处理。`
|
||||||
})
|
})
|
||||||
const stageEvidenceItems = computed(() => (
|
const stageEvidenceItems = computed(() => (
|
||||||
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
|
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
|
||||||
@@ -139,6 +153,38 @@ export default {
|
|||||||
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
|
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
|
||||||
return sourceItems.map((item) => ({ ...item }))
|
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() {
|
function buildApplicationEvidence() {
|
||||||
const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card)))
|
const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card)))
|
||||||
@@ -217,28 +263,68 @@ export default {
|
|||||||
decisionDescription,
|
decisionDescription,
|
||||||
decisionAction,
|
decisionAction,
|
||||||
decisionTitle,
|
decisionTitle,
|
||||||
|
reviewSummaryItems,
|
||||||
stageBasisHint,
|
stageBasisHint,
|
||||||
stageBasisTitle,
|
stageBasisTitle,
|
||||||
stageEvidenceItems,
|
stageEvidenceItems,
|
||||||
|
stageReviewBasisSummary,
|
||||||
|
stageRiskFactSummary,
|
||||||
stageTitle
|
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) {
|
function resolveDecision(tone, isApplicationDocument) {
|
||||||
const subject = isApplicationDocument ? '申请' : '报销'
|
|
||||||
const map = {
|
const map = {
|
||||||
normal: {
|
normal: {
|
||||||
title: `当前${subject}未发现中高风险阻断项`,
|
title: '建议通过',
|
||||||
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`
|
action: isApplicationDocument
|
||||||
|
? '可按权限继续审核,系统会按预算结果决定是否进入下一步。'
|
||||||
|
: '可按权限继续审批,后续进入财务或付款流程。'
|
||||||
},
|
},
|
||||||
medium: {
|
medium: {
|
||||||
title: `当前${subject}存在中风险,建议核对后处理`,
|
title: '建议补充后通过',
|
||||||
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。'
|
action: isApplicationDocument
|
||||||
|
? '建议补充预算占用、申请事由和金额依据后再通过。'
|
||||||
|
: '建议补充票据、金额或业务说明后再通过。'
|
||||||
},
|
},
|
||||||
high: {
|
high: {
|
||||||
title: `当前${subject}存在高风险,不建议直接通过`,
|
title: '不建议通过',
|
||||||
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。'
|
action: isApplicationDocument
|
||||||
|
? '建议退回补充申请依据,或要求预算管理者复核。'
|
||||||
|
: '建议退回补充票据、行程说明或超标原因。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map[tone] || map.normal
|
return map[tone] || map.normal
|
||||||
|
|||||||
@@ -278,6 +278,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
|
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
|
||||||
class="message-suggested-actions"
|
class="message-suggested-actions"
|
||||||
|
:class="{ 'compact-guidance-actions': message.assistantVariant === 'compact_guidance' }"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="action in message.suggestedActions"
|
v-for="action in message.suggestedActions"
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../servi
|
|||||||
import { fetchOntologyParse } from '../services/ontology.js'
|
import { fetchOntologyParse } from '../services/ontology.js'
|
||||||
import { fetchLatestConversation } from '../services/orchestrator.js'
|
import { fetchLatestConversation } from '../services/orchestrator.js'
|
||||||
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.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 { buildDetailAlerts } from '../utils/detailAlerts.js'
|
||||||
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||||
import {
|
import {
|
||||||
@@ -293,10 +297,11 @@ export function useAppShell() {
|
|||||||
view === 'documents'
|
view === 'documents'
|
||||||
&& activeView.value === 'documents'
|
&& activeView.value === 'documents'
|
||||||
&& route.name === 'app-documents'
|
&& route.name === 'app-documents'
|
||||||
setView(view)
|
const navigation = setView(view)
|
||||||
if (shouldRefreshCurrentDocumentCenter) {
|
if (shouldRefreshCurrentDocumentCenter) {
|
||||||
void reloadDocumentCenterRequests()
|
void reloadDocumentCenterRequests()
|
||||||
}
|
}
|
||||||
|
return navigation
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFinancialAssistantCreate(source) {
|
function openFinancialAssistantCreate(source) {
|
||||||
@@ -459,6 +464,36 @@ export function useAppShell() {
|
|||||||
smartEntryRevealToken.value += 1
|
smartEntryRevealToken.value += 1
|
||||||
return
|
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([
|
const [conversation, sessionType] = await Promise.all([
|
||||||
resolveSmartEntryConversation(payload),
|
resolveSmartEntryConversation(payload),
|
||||||
resolveSmartEntrySessionType(payload)
|
resolveSmartEntrySessionType(payload)
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function useNavigation() {
|
|||||||
return resolveAppViewFromRoute(route)
|
return resolveAppViewFromRoute(route)
|
||||||
},
|
},
|
||||||
set(view) {
|
set(view) {
|
||||||
setView(view)
|
void setView(view)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,10 +159,10 @@ export function useNavigation() {
|
|||||||
const targetName = resolveTargetRouteName(view)
|
const targetName = resolveTargetRouteName(view)
|
||||||
|
|
||||||
if (route.name === targetName) {
|
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 }
|
return { activeView, currentView, setView, navItems }
|
||||||
|
|||||||
79
web/src/utils/aiApplicationDraftModel.js
Normal file
79
web/src/utils/aiApplicationDraftModel.js
Normal 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')
|
||||||
|
}
|
||||||
113
web/src/utils/aiExpenseDraftModel.js
Normal file
113
web/src/utils/aiExpenseDraftModel.js
Normal 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')
|
||||||
|
}
|
||||||
81
web/src/utils/aiSidebarBusinessAccess.js
Normal file
81
web/src/utils/aiSidebarBusinessAccess.js
Normal 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)
|
||||||
|
}
|
||||||
155
web/src/utils/aiWorkbenchConversationStore.js
Normal file
155
web/src/utils/aiWorkbenchConversationStore.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
const EXPENSE_SCENE_SELECTION_OPTIONS = [
|
const EXPENSE_SCENE_SELECTION_OPTIONS = [
|
||||||
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-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' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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' }
|
{ 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 = {
|
const EXPENSE_INTENT_CONFIRMATION_ACTION = {
|
||||||
@@ -28,7 +28,9 @@ export function buildExpenseSceneSelectionActions(rawText) {
|
|||||||
payload: {
|
payload: {
|
||||||
expense_type: option.key,
|
expense_type: option.key,
|
||||||
expense_type_label: option.label,
|
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
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,22 @@ function padDatePart(value) {
|
|||||||
return String(value).padStart(2, '0')
|
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) {
|
function formatDateTimeLabel(value) {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
return [
|
return [
|
||||||
@@ -558,6 +574,55 @@ function buildExpenseOperationRows(todoItems, notifications, progressItems) {
|
|||||||
.slice(0, 8)
|
.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) {
|
export function buildWorkbenchSummary(requests, currentUser) {
|
||||||
const allRequests = Array.isArray(requests)
|
const allRequests = Array.isArray(requests)
|
||||||
? requests
|
? requests
|
||||||
@@ -602,6 +667,7 @@ export function buildWorkbenchSummary(requests, currentUser) {
|
|||||||
highRiskCount,
|
highRiskCount,
|
||||||
todoItems,
|
todoItems,
|
||||||
progressItems,
|
progressItems,
|
||||||
|
reimbursementTrendRows: buildReimbursementTrendRows(ownedRequests),
|
||||||
notifications,
|
notifications,
|
||||||
expenseStatsDetail,
|
expenseStatsDetail,
|
||||||
unreadNotificationCount: notifications.filter((item) => item.unread).length
|
unreadNotificationCount: notifications.filter((item) => item.unread).length
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
class="app"
|
class="app"
|
||||||
:class="{
|
:class="{
|
||||||
'sidebar-collapsed': sidebarCollapsed,
|
'sidebar-collapsed': sidebarCollapsed,
|
||||||
|
'workbench-ai-sidebar-active': isAiShellMode,
|
||||||
'mobile-sidebar-open': mobileSidebarOpen,
|
'mobile-sidebar-open': mobileSidebarOpen,
|
||||||
'login-entry-active': loginEntryAnimating
|
'login-entry-active': loginEntryAnimating
|
||||||
}"
|
}"
|
||||||
@@ -29,18 +30,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<div class="app-sidebar">
|
<div class="app-sidebar">
|
||||||
<SidebarRail
|
<Transition name="sidebar-mode-fade" mode="out-in">
|
||||||
:nav-items="filteredNavItems"
|
<AiSidebarRail
|
||||||
:active-view="activeView"
|
v-if="isAiShellMode"
|
||||||
:company-name="PRODUCT_DISPLAY_NAME"
|
key="ai-sidebar"
|
||||||
:company-logo="companyProfile.logo"
|
:nav-items="filteredNavItems"
|
||||||
:current-user="currentUser"
|
:active-view="activeView"
|
||||||
:collapsed="sidebarCollapsed"
|
:active-conversation-id="aiActiveConversationId"
|
||||||
@open-chat="openSmartEntry"
|
:conversation-history="aiConversationHistory"
|
||||||
@logout="handleLogout"
|
:current-user="currentUser"
|
||||||
@toggle-collapse="toggleSidebarCollapsed"
|
:brand-name="PRODUCT_DISPLAY_NAME"
|
||||||
@navigate="handleNavigateWithMobileClose"
|
: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>
|
</div>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
@@ -72,6 +94,7 @@
|
|||||||
:request-summary="requestSummary"
|
:request-summary="requestSummary"
|
||||||
:document-summary="documentSummary"
|
:document-summary="documentSummary"
|
||||||
:workbench-summary="workbenchSummary"
|
:workbench-summary="workbenchSummary"
|
||||||
|
:workbench-mode="workbenchMode"
|
||||||
:digital-employee-summary="digitalEmployeeSummary"
|
:digital-employee-summary="digitalEmployeeSummary"
|
||||||
:company-name="ENTERPRISE_DISPLAY_NAME"
|
:company-name="ENTERPRISE_DISPLAY_NAME"
|
||||||
:detail-mode="resolvedDetailMode"
|
:detail-mode="resolvedDetailMode"
|
||||||
@@ -87,6 +110,7 @@
|
|||||||
@new-application="openExpenseApplicationCreate"
|
@new-application="openExpenseApplicationCreate"
|
||||||
@open-document="openWorkbenchDocument"
|
@open-document="openWorkbenchDocument"
|
||||||
@navigate="handleNavigate"
|
@navigate="handleNavigate"
|
||||||
|
@toggle-workbench-mode="toggleWorkbenchMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -104,6 +128,7 @@
|
|||||||
'documents-workarea': activeView === 'documents',
|
'documents-workarea': activeView === 'documents',
|
||||||
'receipt-folder-workarea': activeView === 'receiptFolder',
|
'receipt-folder-workarea': activeView === 'receiptFolder',
|
||||||
'workbench-workarea': activeView === 'workbench',
|
'workbench-workarea': activeView === 'workbench',
|
||||||
|
'workbench-workarea-ai-mode': isWorkbenchAiMode,
|
||||||
'budget-workarea': activeView === 'budget',
|
'budget-workarea': activeView === 'budget',
|
||||||
'policies-workarea': activeView === 'policies',
|
'policies-workarea': activeView === 'policies',
|
||||||
'audit-workarea': activeView === 'audit',
|
'audit-workarea': activeView === 'audit',
|
||||||
@@ -126,6 +151,10 @@
|
|||||||
v-else-if="activeView === 'workbench'"
|
v-else-if="activeView === 'workbench'"
|
||||||
:assistant-modal-open="smartEntryOpen"
|
:assistant-modal-open="smartEntryOpen"
|
||||||
:workbench-summary="workbenchSummary"
|
:workbench-summary="workbenchSummary"
|
||||||
|
:workbench-mode="workbenchMode"
|
||||||
|
:ai-sidebar-command="aiSidebarCommand"
|
||||||
|
@ai-conversation-change="handleAiConversationChange"
|
||||||
|
@ai-conversation-history-change="handleAiConversationHistoryChange"
|
||||||
@open-assistant="openSmartEntry"
|
@open-assistant="openSmartEntry"
|
||||||
@open-document="openWorkbenchDocument"
|
@open-document="openWorkbenchDocument"
|
||||||
/>
|
/>
|
||||||
@@ -207,8 +236,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 SidebarRail from '../components/layout/SidebarRail.vue'
|
||||||
import TopBar from '../components/layout/TopBar.vue'
|
import TopBar from '../components/layout/TopBar.vue'
|
||||||
import FilterBar from '../components/layout/FilterBar.vue'
|
import FilterBar from '../components/layout/FilterBar.vue'
|
||||||
@@ -229,6 +259,7 @@ import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
|||||||
import { useAppShell } from '../composables/useAppShell.js'
|
import { useAppShell } from '../composables/useAppShell.js'
|
||||||
import { useSystemState } from '../composables/useSystemState.js'
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
||||||
|
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
|
||||||
import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js'
|
import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js'
|
||||||
|
|
||||||
const employeeSummary = ref(null)
|
const employeeSummary = ref(null)
|
||||||
@@ -241,9 +272,15 @@ const digitalEmployeeDetailOpen = ref(false)
|
|||||||
const receiptFolderDetailOpen = ref(false)
|
const receiptFolderDetailOpen = ref(false)
|
||||||
const budgetDetailOpen = ref(false)
|
const budgetDetailOpen = ref(false)
|
||||||
const loginEntryAnimating = ref(false)
|
const loginEntryAnimating = ref(false)
|
||||||
const sidebarCollapsed = ref(true)
|
const sidebarCollapsed = ref(false)
|
||||||
|
const sidebarCollapsedBeforeAiMode = ref(false)
|
||||||
const mobileSidebarOpen = ref(false)
|
const mobileSidebarOpen = ref(false)
|
||||||
const overviewDashboard = ref('finance')
|
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
|
let loginEntryTimer = null
|
||||||
|
|
||||||
function stopLoginEntryAnimation() {
|
function stopLoginEntryAnimation() {
|
||||||
@@ -269,10 +306,23 @@ function toggleSidebarCollapsed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleNavigateWithMobileClose(viewId) {
|
function handleNavigateWithMobileClose(viewId) {
|
||||||
handleNavigate(viewId)
|
void handleNavigate(viewId)
|
||||||
mobileSidebarOpen.value = false
|
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 {
|
const {
|
||||||
activeRange,
|
activeRange,
|
||||||
activeView,
|
activeView,
|
||||||
@@ -319,6 +369,8 @@ const { companyProfile, currentUser, logout } = useSystemState()
|
|||||||
const PRODUCT_DISPLAY_NAME = '易财费控'
|
const PRODUCT_DISPLAY_NAME = '易财费控'
|
||||||
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
|
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
|
||||||
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
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 = {
|
const DETAIL_TOPBAR_FALLBACKS = {
|
||||||
audit: {
|
audit: {
|
||||||
title: '规则中心详情',
|
title: '规则中心详情',
|
||||||
@@ -382,10 +434,82 @@ function openWorkbenchDocument(payload = {}) {
|
|||||||
openRequestDetail(request || payload, { returnTo })
|
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() {
|
function handleLogout() {
|
||||||
logout('manual')
|
logout('manual')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => currentUser.value,
|
||||||
|
(user) => {
|
||||||
|
aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {})
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
playLoginEntryAnimation()
|
playLoginEntryAnimation()
|
||||||
})
|
})
|
||||||
@@ -393,10 +517,4 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopLoginEntryAnimation()
|
stopLoginEntryAnimation()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(activeView, (newView) => {
|
|
||||||
if (newView === 'workbench') {
|
|
||||||
sidebarCollapsed.value = true
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<PersonalWorkbench
|
<Transition name="workbench-mode-fade" mode="out-in" appear>
|
||||||
:show-header="false"
|
<PersonalWorkbenchAiMode
|
||||||
:assistant-modal-open="assistantModalOpen"
|
v-if="workbenchMode === 'ai'"
|
||||||
:workbench-summary="workbenchSummary"
|
key="ai"
|
||||||
@open-assistant="emit('open-assistant', $event)"
|
:sidebar-command="aiSidebarCommand"
|
||||||
@open-document="emit('open-document', $event)"
|
@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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import PersonalWorkbenchAiMode from '../components/business/PersonalWorkbenchAiMode.vue'
|
||||||
import PersonalWorkbench from '../components/business/PersonalWorkbench.vue'
|
import PersonalWorkbench from '../components/business/PersonalWorkbench.vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
assistantModalOpen: { type: Boolean, default: false },
|
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>
|
</script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/views/personal-workbench-view.css"></style>
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
|
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
|
||||||
import {
|
import {
|
||||||
buildStewardFieldCompletionContinuation,
|
buildStewardFieldCompletionContinuation,
|
||||||
buildStewardFieldCompletionRawText
|
buildStewardFieldCompletionRawText,
|
||||||
|
resolveStewardRuntimeFieldCompletion
|
||||||
} from './stewardFieldCompletionModel.js'
|
} from './stewardFieldCompletionModel.js'
|
||||||
import {
|
import {
|
||||||
buildOperationFeedbackPayload,
|
buildOperationFeedbackPayload,
|
||||||
@@ -169,8 +170,6 @@ import {
|
|||||||
buildFileIdentity,
|
buildFileIdentity,
|
||||||
buildFilePreviews,
|
buildFilePreviews,
|
||||||
buildOcrDocumentsFromReviewPayload,
|
buildOcrDocumentsFromReviewPayload,
|
||||||
buildOcrFilePreviews,
|
|
||||||
buildOcrSummary,
|
|
||||||
buildOcrSummaryFromDocuments,
|
buildOcrSummaryFromDocuments,
|
||||||
buildReviewFilePreviewsFromReviewPayload,
|
buildReviewFilePreviewsFromReviewPayload,
|
||||||
extractReviewAttachmentNames,
|
extractReviewAttachmentNames,
|
||||||
@@ -179,7 +178,6 @@ import {
|
|||||||
mergeFilesWithLimit,
|
mergeFilesWithLimit,
|
||||||
mergeUploadAttachmentNames,
|
mergeUploadAttachmentNames,
|
||||||
mergeUploadOcrDocuments,
|
mergeUploadOcrDocuments,
|
||||||
normalizeOcrDocuments,
|
|
||||||
resolveAttachmentPreviewKind,
|
resolveAttachmentPreviewKind,
|
||||||
resolveDocumentPreview
|
resolveDocumentPreview
|
||||||
} from './travelReimbursementAttachmentModel.js'
|
} from './travelReimbursementAttachmentModel.js'
|
||||||
@@ -1121,8 +1119,6 @@ export default {
|
|||||||
buildExpenseSceneSelectionMessage,
|
buildExpenseSceneSelectionMessage,
|
||||||
buildMessageMeta,
|
buildMessageMeta,
|
||||||
buildOcrDocumentsFromReviewPayload,
|
buildOcrDocumentsFromReviewPayload,
|
||||||
buildOcrFilePreviews,
|
|
||||||
buildOcrSummary,
|
|
||||||
buildOcrSummaryFromDocuments,
|
buildOcrSummaryFromDocuments,
|
||||||
buildReviewFormContextFromPayload,
|
buildReviewFormContextFromPayload,
|
||||||
clearAttachedFiles,
|
clearAttachedFiles,
|
||||||
@@ -1155,7 +1151,6 @@ export default {
|
|||||||
messages,
|
messages,
|
||||||
nextTick,
|
nextTick,
|
||||||
normalizeExpenseQueryPayload,
|
normalizeExpenseQueryPayload,
|
||||||
normalizeOcrDocuments,
|
|
||||||
persistSessionState,
|
persistSessionState,
|
||||||
props,
|
props,
|
||||||
recognizeOcrFiles,
|
recognizeOcrFiles,
|
||||||
@@ -1904,6 +1899,10 @@ export default {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
|
||||||
|
pushExpenseSceneSelectionPrompt(carryText)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (String(actionPayload.steward_plan_id || '').trim()) {
|
if (String(actionPayload.steward_plan_id || '').trim()) {
|
||||||
const confirmedByText = Boolean(action.confirmedByText)
|
const confirmedByText = Boolean(action.confirmedByText)
|
||||||
delete action.confirmedByText
|
delete action.confirmedByText
|
||||||
@@ -2141,6 +2140,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildMessageBubbleClass(message) {
|
function buildMessageBubbleClass(message) {
|
||||||
|
if (message?.role === 'assistant' && message?.assistantVariant === 'compact_guidance') {
|
||||||
|
return 'message-bubble-compact-guidance'
|
||||||
|
}
|
||||||
if (message?.role === 'assistant' && message?.budgetReport) {
|
if (message?.role === 'assistant' && message?.budgetReport) {
|
||||||
return 'message-bubble-budget-report'
|
return 'message-bubble-budget-report'
|
||||||
}
|
}
|
||||||
@@ -2965,6 +2967,10 @@ export default {
|
|||||||
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
|
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const fieldCompletionDecision = resolveStewardRuntimeFieldCompletion(normalizedText, runtimeState)
|
||||||
|
if (fieldCompletionDecision) {
|
||||||
|
return fieldCompletionDecision
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -3082,6 +3088,39 @@ export default {
|
|||||||
})
|
})
|
||||||
return true
|
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') {
|
if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') {
|
||||||
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
|
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1751,12 +1751,12 @@ export default {
|
|||||||
|
|
||||||
const aiAdviceTitle = computed(() => {
|
const aiAdviceTitle = computed(() => {
|
||||||
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
|
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
|
||||||
return '报销风险提示'
|
return '风险提示'
|
||||||
}
|
}
|
||||||
if (isEditableRequest.value && isApplicationDocument.value) {
|
if (isEditableRequest.value && isApplicationDocument.value) {
|
||||||
return '表单自查提示'
|
return '表单自查提示'
|
||||||
}
|
}
|
||||||
return isEditableRequest.value ? 'AI建议' : 'AI提示'
|
return isEditableRequest.value ? 'AI建议' : '风险提示'
|
||||||
})
|
})
|
||||||
const aiAdviceHint = computed(() => (
|
const aiAdviceHint = computed(() => (
|
||||||
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value
|
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value
|
||||||
|
|||||||
@@ -24,6 +24,35 @@ const APPLICATION_PREVIEW_FIELD_LABEL_MAP = {
|
|||||||
grade: '职级'
|
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 = '') {
|
function compactValue(value = '') {
|
||||||
return String(value || '').trim()
|
return String(value || '').trim()
|
||||||
}
|
}
|
||||||
@@ -48,6 +77,22 @@ function resolveFieldValue(...candidates) {
|
|||||||
return ''
|
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 = '') {
|
function buildUpdatedTask(task = null, fieldKey = '', value = '') {
|
||||||
if (!task || typeof task !== 'object') {
|
if (!task || typeof task !== 'object') {
|
||||||
return null
|
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 = '') {
|
export function buildStewardFieldCompletionContinuation(continuation = null, fieldKey = '', value = '') {
|
||||||
const source = continuation && typeof continuation === 'object' ? continuation : {}
|
const source = continuation && typeof continuation === 'object' ? continuation : {}
|
||||||
const currentTask = resolveStewardCurrentTask(source)
|
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({
|
export function buildStewardFieldCompletionRawText({
|
||||||
preview = {},
|
preview = {},
|
||||||
fieldKey = '',
|
fieldKey = '',
|
||||||
@@ -107,7 +219,12 @@ export function buildStewardFieldCompletionRawText({
|
|||||||
: resolveFieldValue(fields.transportMode, ontologyFields.transport_mode)
|
: resolveFieldValue(fields.transportMode, ontologyFields.transport_mode)
|
||||||
|
|
||||||
const knownLines = [
|
const knownLines = [
|
||||||
['申请类型', resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')],
|
[
|
||||||
|
'申请类型',
|
||||||
|
resolveApplicationTypeDisplay(
|
||||||
|
resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')
|
||||||
|
)
|
||||||
|
],
|
||||||
['时间', resolveFieldValue(fields.time, ontologyFields.time_range)],
|
['时间', resolveFieldValue(fields.time, ontologyFields.time_range)],
|
||||||
['地点', resolveFieldValue(fields.location, ontologyFields.location)],
|
['地点', resolveFieldValue(fields.location, ontologyFields.location)],
|
||||||
['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)],
|
['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)],
|
||||||
@@ -120,6 +237,7 @@ export function buildStewardFieldCompletionRawText({
|
|||||||
return [
|
return [
|
||||||
'小财管家继续执行申请单字段补齐。',
|
'小财管家继续执行申请单字段补齐。',
|
||||||
`用户已补充:${selectedLabel}:${selectedValue}。`,
|
`用户已补充:${selectedLabel}:${selectedValue}。`,
|
||||||
|
...buildFieldCompletionScopeHints(fieldKey, selectedValue),
|
||||||
currentTask?.summary ? `任务摘要:${currentTask.summary}` : '',
|
currentTask?.summary ? `任务摘要:${currentTask.summary}` : '',
|
||||||
'',
|
'',
|
||||||
'已识别信息:',
|
'已识别信息:',
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ const FIELD_VALUE_DISPLAY_CONFIG = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FLOW_EXPENSE_TYPE_LABELS = {
|
||||||
|
travel: '差旅费'
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStewardPlanRequest({
|
export function buildStewardPlanRequest({
|
||||||
rawText = '',
|
rawText = '',
|
||||||
files = [],
|
files = [],
|
||||||
@@ -216,6 +220,10 @@ export function buildStewardPlanMessageText(plan) {
|
|||||||
if (isPendingFlowConfirmationPlan(normalized)) {
|
if (isPendingFlowConfirmationPlan(normalized)) {
|
||||||
return buildPendingFlowConfirmationMessageText(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 nextContext = resolveNextActionContext(normalized)
|
||||||
const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
|
const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
|
||||||
const taskLines = orderedTasks.map((task, index) =>
|
const taskLines = orderedTasks.map((task, index) =>
|
||||||
@@ -289,6 +297,42 @@ export function formatStewardOntologyFields(fields = {}, taskType = '') {
|
|||||||
.join(';')
|
.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) {
|
export function buildStewardSuggestedActions(plan) {
|
||||||
const normalized = normalizeStewardPlan(plan)
|
const normalized = normalizeStewardPlan(plan)
|
||||||
if (isOffTopicPlan(normalized)) {
|
if (isOffTopicPlan(normalized)) {
|
||||||
@@ -304,26 +348,32 @@ export function buildStewardSuggestedActions(plan) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (isPendingFlowConfirmationPlan(normalized)) {
|
if (isPendingFlowConfirmationPlan(normalized)) {
|
||||||
return normalized.candidateFlows.map((flow) => ({
|
return normalized.candidateFlows.map((flow) => {
|
||||||
label: flow.label,
|
const expenseType = resolveCandidateFlowExpenseType(flow)
|
||||||
description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
|
return {
|
||||||
icon: flow.flowId === 'travel_application'
|
label: flow.label,
|
||||||
? 'mdi mdi-file-plus-outline'
|
description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
|
||||||
: 'mdi mdi-receipt-text-plus-outline',
|
icon: flow.flowId === 'travel_application'
|
||||||
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
? 'mdi mdi-file-plus-outline'
|
||||||
payload: {
|
: 'mdi mdi-receipt-text-plus-outline',
|
||||||
steward_confirm_flow: true,
|
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||||
steward_plan_id: normalized.planId,
|
payload: {
|
||||||
flow_id: flow.flowId,
|
steward_confirm_flow: true,
|
||||||
session_type: flow.flowId === 'travel_application'
|
steward_plan_id: normalized.planId,
|
||||||
? SESSION_TYPE_APPLICATION
|
flow_id: flow.flowId,
|
||||||
: SESSION_TYPE_EXPENSE,
|
session_type: flow.flowId === 'travel_application'
|
||||||
selected_flow_label: flow.label,
|
? SESSION_TYPE_APPLICATION
|
||||||
carry_text: flow.label,
|
: SESSION_TYPE_EXPENSE,
|
||||||
auto_submit: true,
|
selected_flow_label: flow.label,
|
||||||
steward_state: normalized.stewardState || null
|
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)
|
const nextContext = resolveNextActionContext(normalized)
|
||||||
if (!nextContext) {
|
if (!nextContext) {
|
||||||
@@ -335,7 +385,7 @@ export function buildStewardSuggestedActions(plan) {
|
|||||||
: SESSION_TYPE_EXPENSE
|
: SESSION_TYPE_EXPENSE
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: buildNextActionLabel(actionType),
|
label: buildNextActionLabel(actionType, task),
|
||||||
description: buildNextActionDescription(actionType, normalized, task, group),
|
description: buildNextActionDescription(actionType, normalized, task, group),
|
||||||
icon: actionType === 'confirm_create_application'
|
icon: actionType === 'confirm_create_application'
|
||||||
? 'mdi mdi-file-plus-outline'
|
? 'mdi mdi-file-plus-outline'
|
||||||
@@ -411,40 +461,58 @@ export function isOffTopicStewardPlan(rawPlan) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildOffTopicMessageText(normalized) {
|
function buildOffTopicMessageText(normalized) {
|
||||||
|
// off_topic 计划的引导文案完全由后端生成(含 ### 标题 + 正文 + 引导句),
|
||||||
|
// 前端透传 summary 即可,避免重复拼接导致与后端表达不一致。
|
||||||
const summary = String(normalized?.summary || '').trim()
|
const summary = String(normalized?.summary || '').trim()
|
||||||
const summaryLine = summary && summary !== '这看起来跟财务任务没什么关系...'
|
if (summary) {
|
||||||
? summary
|
return summary
|
||||||
: '这看起来跟财务任务没什么关系,我目前只能帮你处理**费用申请**和**费用报销**两类事项。'
|
}
|
||||||
return [
|
return (
|
||||||
'### 小财管家没看懂这件事',
|
'### 这句话我暂时没识别到财务事项\n\n' +
|
||||||
'',
|
'很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。\n\n' +
|
||||||
summaryLine,
|
'要不您换种说法告诉我:'
|
||||||
'',
|
)
|
||||||
'你可以试试下面这些方式告诉我:'
|
|
||||||
].join('\n')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPendingFlowConfirmationMessageText(normalized) {
|
function buildPendingFlowConfirmationMessageText(normalized) {
|
||||||
const fields = normalized.candidateFlows[0]?.ontologyFields || {}
|
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) =>
|
const candidateLines = normalized.candidateFlows.map((flow, index) =>
|
||||||
`${index + 1}. **${flow.label}**${flow.reason ? `\n - ${flow.reason}` : ''}`
|
`${index + 1}. **${flow.label}**${flow.reason ? `\n - ${flow.reason}` : ''}`
|
||||||
)
|
)
|
||||||
|
const singleCandidate = normalized.candidateFlows.length === 1
|
||||||
return [
|
return [
|
||||||
'### 需要先确认流程方向',
|
'### 需要先确认流程方向',
|
||||||
'',
|
'',
|
||||||
knownParts
|
knownTable
|
||||||
? `我识别到这是一项财务事项,已提取到:**${knownParts}**。`
|
? ['我识别到这是一项财务事项,已提取到:', '', knownTable].join('\n')
|
||||||
: '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。',
|
: '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。',
|
||||||
'',
|
'',
|
||||||
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。',
|
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。',
|
||||||
'',
|
'',
|
||||||
...candidateLines,
|
...candidateLines,
|
||||||
'',
|
'',
|
||||||
'请先选择一个方向,我会继续整理对应材料。'
|
singleCandidate
|
||||||
|
? `请先点击下方 **${normalized.candidateFlows[0].label}**,我会继续整理对应材料。`
|
||||||
|
: '请先选择一个方向,我会继续整理对应材料。'
|
||||||
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
|
].filter((line, index, lines) => line || lines[index - 1]).join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildGenericReimbursementIntentMessageText() {
|
||||||
|
return [
|
||||||
|
'### 我来带你发起报销',
|
||||||
|
'',
|
||||||
|
'你现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带你填。',
|
||||||
|
'',
|
||||||
|
'1. **先选报销场景**',
|
||||||
|
' - 例如差旅费、交通费、住宿费、业务招待费或办公用品费,不同场景需要的材料不一样。',
|
||||||
|
'2. **再补关键材料**',
|
||||||
|
' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮你核对是否需要关联事前申请。',
|
||||||
|
'',
|
||||||
|
'点击下面的 **确定,选择报销场景**,我会进入报销助手继续引导。'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
function resolveNextActionContext(normalized) {
|
function resolveNextActionContext(normalized) {
|
||||||
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
|
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
|
||||||
const applicationAction = applicationTask
|
const applicationAction = applicationTask
|
||||||
@@ -566,6 +634,9 @@ function buildTaskOrderActionDescription(task) {
|
|||||||
return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。`
|
return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。`
|
||||||
}
|
}
|
||||||
if (task.taskType === 'reimbursement') {
|
if (task.taskType === 'reimbursement') {
|
||||||
|
if (isGenericReimbursementTask(task)) {
|
||||||
|
return `我会请${agent}先带你选择报销场景,再逐步补齐事由、时间、金额和票据。`
|
||||||
|
}
|
||||||
return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。`
|
return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。`
|
||||||
}
|
}
|
||||||
return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。`
|
return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。`
|
||||||
@@ -603,13 +674,16 @@ function buildNextTaskLead(task) {
|
|||||||
return `处理“${task.title || task.taskTypeLabel}”`
|
return `处理“${task.title || task.taskTypeLabel}”`
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNextActionLabel(actionType) {
|
function buildNextActionLabel(actionType, task = null) {
|
||||||
if (actionType === 'confirm_create_application') {
|
if (actionType === 'confirm_create_application') {
|
||||||
return '确定,先创建申请单'
|
return '确定,先创建申请单'
|
||||||
}
|
}
|
||||||
if (actionType === 'confirm_attachment_group') {
|
if (actionType === 'confirm_attachment_group') {
|
||||||
return '确定,确认附件归集'
|
return '确定,确认附件归集'
|
||||||
}
|
}
|
||||||
|
if (isGenericReimbursementTask(task)) {
|
||||||
|
return '确定,选择报销场景'
|
||||||
|
}
|
||||||
return '确定,继续填写报销单'
|
return '确定,继续填写报销单'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,7 +701,29 @@ function buildNextActionDescription(actionType, normalized, task, group) {
|
|||||||
}
|
}
|
||||||
return group?.attachmentNames?.length
|
return group?.attachmentNames?.length
|
||||||
? `报销助手会带入 ${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) {
|
function buildStewardCarryText(actionType, task, group, normalized = null) {
|
||||||
@@ -644,6 +740,9 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
|
|||||||
if (!task) {
|
if (!task) {
|
||||||
return '我确认继续处理这项财务任务,请按现有流程核对信息。'
|
return '我确认继续处理这项财务任务,请按现有流程核对信息。'
|
||||||
}
|
}
|
||||||
|
if (actionType === 'confirm_create_reimbursement_draft' && isGenericReimbursementTask(task)) {
|
||||||
|
return '我要报销'
|
||||||
|
}
|
||||||
|
|
||||||
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
|
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
|
||||||
const missingFields = formatStewardMissingFieldList(
|
const missingFields = formatStewardMissingFieldList(
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ export function normalizeOcrDocuments(payload) {
|
|||||||
preview_kind: String(item.preview_kind || '').trim(),
|
preview_kind: String(item.preview_kind || '').trim(),
|
||||||
preview_data_url: String(item.preview_data_url || '').trim(),
|
preview_data_url: String(item.preview_data_url || '').trim(),
|
||||||
preview_url: String(item.preview_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)
|
document_fields: Array.isArray(item.document_fields)
|
||||||
? item.document_fields
|
? item.document_fields
|
||||||
.map((field) => ({
|
.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) {
|
export function buildOcrSummary(payload) {
|
||||||
return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
|
return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,8 +358,9 @@ export function buildExpenseSceneSelectionMessage(rawText) {
|
|||||||
: '我已识别到这是报销申请。'
|
: '我已识别到这是报销申请。'
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`${prefix}但现在还不能确定具体报销场景,所以我先暂停信息抽取。`,
|
`${prefix}先选一下这笔费用属于哪一类,我再按对应流程继续。`,
|
||||||
'请先选择本次要发起的报销场景,选择后我再按对应规则继续识别并整理核对信息。'
|
'差旅和业务招待通常需要先关联申请单;交通、住宿、办公用品这类一般可以直接继续填写。',
|
||||||
|
'选完后我会把下一步需要准备的内容整理给你。'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,6 +883,8 @@ export function normalizeInitialConversationMessages(conversation) {
|
|||||||
return createMessage(item.role, item.content, attachmentNames, {
|
return createMessage(item.role, item.content, attachmentNames, {
|
||||||
id: `restored-${item.id || ++messageSeed}`,
|
id: `restored-${item.id || ++messageSeed}`,
|
||||||
time: formatMessageTime(item.created_at || item.createdAt),
|
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) : [],
|
meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [],
|
||||||
citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [],
|
citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [],
|
||||||
suggestedActions:
|
suggestedActions:
|
||||||
@@ -940,6 +943,7 @@ export function serializeSessionMessages(messages) {
|
|||||||
stewardPlan: message.stewardPlan || null,
|
stewardPlan: message.stewardPlan || null,
|
||||||
operationFeedback: message.operationFeedback || null,
|
operationFeedback: message.operationFeedback || null,
|
||||||
assistantName: message.assistantName || '',
|
assistantName: message.assistantName || '',
|
||||||
|
assistantVariant: message.assistantVariant || '',
|
||||||
isWelcome: Boolean(message.isWelcome),
|
isWelcome: Boolean(message.isWelcome),
|
||||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||||
buildAttachmentAssociationConfirmationMessage,
|
buildAttachmentAssociationConfirmationMessage,
|
||||||
buildUnsavedDraftAttachmentConfirmationMessage
|
buildUnsavedDraftAttachmentConfirmationMessage,
|
||||||
|
collectReceiptFiles
|
||||||
} from './travelReimbursementAttachmentModel.js'
|
} from './travelReimbursementAttachmentModel.js'
|
||||||
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
|
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
|
||||||
import {
|
import {
|
||||||
@@ -312,8 +313,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
buildExpenseSceneSelectionMessage,
|
buildExpenseSceneSelectionMessage,
|
||||||
buildMessageMeta,
|
buildMessageMeta,
|
||||||
buildOcrDocumentsFromReviewPayload,
|
buildOcrDocumentsFromReviewPayload,
|
||||||
buildOcrFilePreviews,
|
|
||||||
buildOcrSummary,
|
|
||||||
buildOcrSummaryFromDocuments,
|
buildOcrSummaryFromDocuments,
|
||||||
buildReviewFormContextFromPayload,
|
buildReviewFormContextFromPayload,
|
||||||
clearAttachedFiles,
|
clearAttachedFiles,
|
||||||
@@ -348,7 +347,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
messages,
|
messages,
|
||||||
nextTick,
|
nextTick,
|
||||||
normalizeExpenseQueryPayload,
|
normalizeExpenseQueryPayload,
|
||||||
normalizeOcrDocuments,
|
|
||||||
persistSessionState,
|
persistSessionState,
|
||||||
props,
|
props,
|
||||||
recognizeOcrFiles,
|
recognizeOcrFiles,
|
||||||
@@ -1825,23 +1823,28 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||||
}
|
}
|
||||||
if (recognizedAttachmentData) {
|
if (recognizedAttachmentData) {
|
||||||
ocrPayload = recognizedAttachmentData.ocrPayload
|
const collected = await collectReceiptFiles({
|
||||||
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
|
files,
|
||||||
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
|
recognizedAttachmentData
|
||||||
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
|
})
|
||||||
|
ocrPayload = collected.ocrPayload
|
||||||
|
ocrSummary = collected.ocrSummary
|
||||||
|
ocrDocuments = collected.ocrDocuments
|
||||||
|
ocrFilePreviews = collected.ocrFilePreviews
|
||||||
rememberFilePreviews(ocrFilePreviews)
|
rememberFilePreviews(ocrFilePreviews)
|
||||||
if (!stewardDelegated) {
|
if (!stewardDelegated) {
|
||||||
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
|
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
ocrPayload = await recognizeOcrFiles(files, {
|
const collected = await collectReceiptFiles({
|
||||||
timeoutMs: 90000,
|
files,
|
||||||
timeoutMessage: '票据 OCR 识别超时,已继续使用附件名称处理。'
|
recognizeOcrFiles
|
||||||
})
|
})
|
||||||
ocrSummary = buildOcrSummary(ocrPayload)
|
ocrPayload = collected.ocrPayload
|
||||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
ocrSummary = collected.ocrSummary
|
||||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
ocrDocuments = collected.ocrDocuments
|
||||||
|
ocrFilePreviews = collected.ocrFilePreviews
|
||||||
rememberFilePreviews(ocrFilePreviews)
|
rememberFilePreviews(ocrFilePreviews)
|
||||||
if (!stewardDelegated) {
|
if (!stewardDelegated) {
|
||||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||||
|
|||||||
@@ -339,6 +339,10 @@ export function useTravelReimbursementSuggestedActions({
|
|||||||
const carryText = String(actionPayload.carry_text || '').trim()
|
const carryText = String(actionPayload.carry_text || '').trim()
|
||||||
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
|
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
|
||||||
if (!lockSuggestedActionMessage(message, action)) return
|
if (!lockSuggestedActionMessage(message, action)) return
|
||||||
|
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
|
||||||
|
pushExpenseSceneSelectionPrompt(carryText)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (String(actionPayload.steward_plan_id || '').trim()) {
|
if (String(actionPayload.steward_plan_id || '').trim()) {
|
||||||
const confirmedByText = Boolean(action.confirmedByText)
|
const confirmedByText = Boolean(action.confirmedByText)
|
||||||
delete action.confirmedByText
|
delete action.confirmedByText
|
||||||
|
|||||||
51
web/tests/ai-application-draft-model.test.mjs
Normal file
51
web/tests/ai-application-draft-model.test.mjs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyAiApplicationAnswer,
|
||||||
|
buildAiApplicationStepPrompt,
|
||||||
|
buildAiApplicationSummary,
|
||||||
|
createAiApplicationDraft,
|
||||||
|
getAiApplicationCurrentStep,
|
||||||
|
isAiApplicationDraftComplete
|
||||||
|
} from '../src/utils/aiApplicationDraftModel.js'
|
||||||
|
|
||||||
|
test('application draft starts at the reason step', () => {
|
||||||
|
const draft = createAiApplicationDraft('travel', '差旅费')
|
||||||
|
assert.equal(draft.expenseType, 'travel')
|
||||||
|
assert.equal(draft.expenseTypeLabel, '差旅费')
|
||||||
|
assert.equal(draft.stepKey, 'reason')
|
||||||
|
assert.equal(getAiApplicationCurrentStep(draft).key, 'reason')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('answers advance through fields in order and reach summary', () => {
|
||||||
|
let draft = createAiApplicationDraft('travel', '差旅费')
|
||||||
|
draft = applyAiApplicationAnswer(draft, '去上海支持项目部署', [])
|
||||||
|
assert.equal(draft.stepKey, 'time_range')
|
||||||
|
draft = applyAiApplicationAnswer(draft, '2026-06-20 至 2026-06-22,出差 3 天', [])
|
||||||
|
assert.equal(draft.stepKey, 'location')
|
||||||
|
draft = applyAiApplicationAnswer(draft, '上海', [])
|
||||||
|
assert.equal(draft.stepKey, 'amount')
|
||||||
|
draft = applyAiApplicationAnswer(draft, '约 2358 元', [])
|
||||||
|
assert.ok(isAiApplicationDraftComplete(draft))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('step prompt names the type and the current field', () => {
|
||||||
|
const draft = createAiApplicationDraft('travel', '差旅费')
|
||||||
|
const prompt = buildAiApplicationStepPrompt(draft)
|
||||||
|
assert.match(prompt, /差旅费/)
|
||||||
|
assert.match(prompt, /事由/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('summary lists every filled field', () => {
|
||||||
|
let draft = createAiApplicationDraft('travel', '差旅费')
|
||||||
|
draft = applyAiApplicationAnswer(draft, '去上海支持项目部署', [])
|
||||||
|
draft = applyAiApplicationAnswer(draft, '2026-06-20 至 2026-06-22,出差 3 天', [])
|
||||||
|
draft = applyAiApplicationAnswer(draft, '上海', [])
|
||||||
|
draft = applyAiApplicationAnswer(draft, '约 2358 元', [])
|
||||||
|
const summary = buildAiApplicationSummary(draft)
|
||||||
|
assert.match(summary, /差旅费/)
|
||||||
|
assert.match(summary, /去上海支持项目部署/)
|
||||||
|
assert.match(summary, /2026-06-20 至 2026-06-22,出差 3 天/)
|
||||||
|
assert.match(summary, /约 2358 元/)
|
||||||
|
})
|
||||||
73
web/tests/ai-expense-draft-model.test.mjs
Normal file
73
web/tests/ai-expense-draft-model.test.mjs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyAiExpenseAnswer,
|
||||||
|
buildAiExpenseStepPrompt,
|
||||||
|
buildAiExpenseSummary,
|
||||||
|
createAiExpenseDraft,
|
||||||
|
getAiExpenseCurrentStep,
|
||||||
|
isAiExpenseDraftComplete
|
||||||
|
} from '../src/utils/aiExpenseDraftModel.js'
|
||||||
|
|
||||||
|
test('draft starts at the reason step regardless of expense type', () => {
|
||||||
|
const draft = createAiExpenseDraft('transport', '交通费')
|
||||||
|
assert.equal(draft.expenseType, 'transport')
|
||||||
|
assert.equal(draft.expenseTypeLabel, '交通费')
|
||||||
|
assert.equal(draft.stepKey, 'reason')
|
||||||
|
assert.equal(getAiExpenseCurrentStep(draft).key, 'reason')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('answers advance through fields in order and reach the summary step', () => {
|
||||||
|
let draft = createAiExpenseDraft('office', '办公用品费')
|
||||||
|
draft = applyAiExpenseAnswer(draft, '项目现场临时采购', [])
|
||||||
|
assert.equal(draft.stepKey, 'time_range')
|
||||||
|
draft = applyAiExpenseAnswer(draft, '2026-06-15', [])
|
||||||
|
assert.equal(draft.stepKey, 'location')
|
||||||
|
draft = applyAiExpenseAnswer(draft, '京东', [])
|
||||||
|
assert.equal(draft.stepKey, 'amount')
|
||||||
|
draft = applyAiExpenseAnswer(draft, '320元', [])
|
||||||
|
assert.equal(draft.stepKey, 'attachments')
|
||||||
|
draft = applyAiExpenseAnswer(draft, '稍后上传', [])
|
||||||
|
assert.ok(isAiExpenseDraftComplete(draft))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('attachments step collects uploaded file names', () => {
|
||||||
|
let draft = createAiExpenseDraft('office', '办公用品费')
|
||||||
|
draft = applyAiExpenseAnswer(draft, '事由', [])
|
||||||
|
draft = applyAiExpenseAnswer(draft, '2026-06-15', [])
|
||||||
|
draft = applyAiExpenseAnswer(draft, '京东', [])
|
||||||
|
draft = applyAiExpenseAnswer(draft, '320元', [])
|
||||||
|
draft = applyAiExpenseAnswer(draft, '', [{ name: '发票.pdf' }])
|
||||||
|
assert.deepEqual(draft.values.attachment_names, ['发票.pdf'])
|
||||||
|
assert.ok(isAiExpenseDraftComplete(draft))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('step prompt names the type and the current field', () => {
|
||||||
|
const draft = createAiExpenseDraft('transport', '交通费')
|
||||||
|
const prompt = buildAiExpenseStepPrompt(draft)
|
||||||
|
assert.match(prompt, /交通费/)
|
||||||
|
assert.match(prompt, /事由/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('summary lists every filled field and the linked application', () => {
|
||||||
|
let draft = createAiExpenseDraft('transport', '交通费')
|
||||||
|
draft = {
|
||||||
|
...draft,
|
||||||
|
applicationClaim: {
|
||||||
|
application_claim_no: 'AP-202606-001',
|
||||||
|
application_reason: '送客户去机场',
|
||||||
|
application_business_time: '2026-06-15',
|
||||||
|
application_location: '公司至机场'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draft = applyAiExpenseAnswer(draft, '送客户去机场', [])
|
||||||
|
draft = applyAiExpenseAnswer(draft, '2026-06-15', [])
|
||||||
|
draft = applyAiExpenseAnswer(draft, '公司至机场', [])
|
||||||
|
draft = applyAiExpenseAnswer(draft, '85元', [])
|
||||||
|
draft = applyAiExpenseAnswer(draft, '稍后上传', [])
|
||||||
|
const summary = buildAiExpenseSummary(draft)
|
||||||
|
assert.match(summary, /交通费/)
|
||||||
|
assert.match(summary, /AP-202606-001/)
|
||||||
|
assert.match(summary, /85元/)
|
||||||
|
})
|
||||||
39
web/tests/ai-sidebar-business-access.test.mjs
Normal file
39
web/tests/ai-sidebar-business-access.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { resolveAiSidebarBusinessViewIds } from '../src/utils/aiSidebarBusinessAccess.js'
|
||||||
|
|
||||||
|
test('AI sidebar shows three business entries for regular employees', () => {
|
||||||
|
assert.deepEqual(resolveAiSidebarBusinessViewIds({ name: '普通员工', roleCodes: [] }), [
|
||||||
|
'documents',
|
||||||
|
'receiptFolder',
|
||||||
|
'policies'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI sidebar adds budget management for budget monitors', () => {
|
||||||
|
assert.deepEqual(resolveAiSidebarBusinessViewIds({ name: '预算管理员', roleCodes: ['budget_monitor'] }), [
|
||||||
|
'documents',
|
||||||
|
'receiptFolder',
|
||||||
|
'policies',
|
||||||
|
'budget'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI sidebar adds finance capabilities for finance users', () => {
|
||||||
|
assert.deepEqual(resolveAiSidebarBusinessViewIds({ name: '财务负责人', roleCodes: ['finance'] }), [
|
||||||
|
'documents',
|
||||||
|
'receiptFolder',
|
||||||
|
'policies',
|
||||||
|
'overview',
|
||||||
|
'audit',
|
||||||
|
'digitalEmployees'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI sidebar keeps workbench and settings out of the steward business layer', () => {
|
||||||
|
const viewIds = resolveAiSidebarBusinessViewIds({ username: 'admin', isAdmin: true, roleCodes: ['admin'] })
|
||||||
|
|
||||||
|
assert.equal(viewIds.includes('workbench'), false)
|
||||||
|
assert.equal(viewIds.includes('settings'), false)
|
||||||
|
})
|
||||||
193
web/tests/ai-sidebar-rail-mode.test.mjs
Normal file
193
web/tests/ai-sidebar-rail-mode.test.mjs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const appShell = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const aiSidebar = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/layout/AiSidebarRail.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const aiSidebarStyles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/components/ai-sidebar-rail.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const aiBusinessAccess = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/utils/aiSidebarBusinessAccess.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const appStyles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const extractCssBlock = (source, selector) => source.match(new RegExp(`${selector}\\s*\\{([\\s\\S]*?)\\n\\}`))?.[1] || ''
|
||||||
|
|
||||||
|
test('workbench AI mode swaps the traditional rail for the AI three-layer rail', () => {
|
||||||
|
assert.match(appShell, /import AiSidebarRail from '\.\.\/components\/layout\/AiSidebarRail\.vue'/)
|
||||||
|
assert.match(appShell, /<Transition[\s\S]*name="sidebar-mode-fade"[\s\S]*mode="out-in"/)
|
||||||
|
assert.match(appShell, /<AiSidebarRail[\s\S]*v-if="isAiShellMode"[\s\S]*key="ai-sidebar"/)
|
||||||
|
assert.match(appShell, /const isAiShellMode = computed\(\(\) => workbenchMode\.value === 'ai'\)/)
|
||||||
|
assert.match(appShell, /const isWorkbenchAiMode = computed\(\(\) => activeView\.value === 'workbench' && workbenchMode\.value === 'ai'\)/)
|
||||||
|
assert.match(appShell, /@new-chat="openAiSidebarNewChat"/)
|
||||||
|
assert.match(appShell, /@open-recent="openAiSidebarRecent"/)
|
||||||
|
assert.match(appShell, /@rename-conversation="handleAiConversationRename"/)
|
||||||
|
assert.match(appShell, /@logout="handleLogout"/)
|
||||||
|
assert.match(appShell, /import \{ loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation \} from '\.\.\/utils\/aiWorkbenchConversationStore\.js'/)
|
||||||
|
assert.match(appShell, /function openAiSidebarNewChat\(\)/)
|
||||||
|
assert.match(appShell, /function openAiSidebarRecent\(item = \{\}\)/)
|
||||||
|
assert.match(appShell, /function handleAiConversationRename\(payload = \{\}\)/)
|
||||||
|
assert.match(appShell, /import \{ computed, nextTick, onBeforeUnmount, onMounted, ref, watch \} from 'vue'/)
|
||||||
|
assert.match(appShell, /async function openAiConversationWorkspace\(type, payload = null\)/)
|
||||||
|
assert.match(appShell, /const navigation = handleNavigate\('workbench'\)/)
|
||||||
|
assert.match(appShell, /if \(navigation && typeof navigation\.then === 'function'\)[\s\S]*await navigation/)
|
||||||
|
assert.match(appShell, /await nextTick\(\)/)
|
||||||
|
assert.match(appShell, /dispatchAiSidebarCommand\(type, payload\)/)
|
||||||
|
assert.match(appShell, /void openAiConversationWorkspace\('new-chat'\)/)
|
||||||
|
assert.match(appShell, /void openAiConversationWorkspace\('open-recent', item\)/)
|
||||||
|
assert.doesNotMatch(appShell, /openAiSidebarSearchChat/)
|
||||||
|
assert.match(appShell, /const aiSidebarCommand = ref\(\{ seq: 0, type: '', payload: null \}\)/)
|
||||||
|
assert.match(appShell, /const aiConversationHistory = ref\(\[\]\)/)
|
||||||
|
assert.match(appShell, /:active-conversation-id="aiActiveConversationId"/)
|
||||||
|
assert.match(appShell, /:conversation-history="aiConversationHistory"/)
|
||||||
|
assert.match(appShell, /:brand-name="PRODUCT_DISPLAY_NAME"/)
|
||||||
|
assert.match(appShell, /:brand-logo="companyProfile\.logo"/)
|
||||||
|
assert.match(appShell, /:company-name="ENTERPRISE_DISPLAY_NAME"/)
|
||||||
|
assert.match(appShell, /:ai-sidebar-command="aiSidebarCommand"/)
|
||||||
|
assert.match(appShell, /@ai-conversation-change="handleAiConversationChange"/)
|
||||||
|
assert.match(appShell, /@ai-conversation-history-change="handleAiConversationHistoryChange"/)
|
||||||
|
assert.match(appShell, /function dispatchAiSidebarCommand\(type, payload = null\)/)
|
||||||
|
assert.match(appShell, /function handleAiConversationHistoryChange\(payload = \[\]\)/)
|
||||||
|
assert.match(appShell, /loadAiWorkbenchConversationHistory\(user \|\| \{\}\)/)
|
||||||
|
assert.match(appShell, /saveAiWorkbenchConversation\(currentUser\.value \|\| \{\},[\s\S]*\.\.\.target,[\s\S]*title/)
|
||||||
|
assert.match(appShell, /:current-user="currentUser"/)
|
||||||
|
assert.doesNotMatch(appShell, /restoreLatestConversation:\s*true/)
|
||||||
|
assert.match(appShell, /'workbench-ai-sidebar-active': isAiShellMode/)
|
||||||
|
assert.match(appShell, /sidebarCollapsedBeforeAiMode\.value = sidebarCollapsed\.value/)
|
||||||
|
assert.match(appShell, /sidebarCollapsed\.value = false/)
|
||||||
|
assert.match(appShell, /sidebarCollapsed\.value = sidebarCollapsedBeforeAiMode\.value/)
|
||||||
|
assert.match(appStyles, /\.app\s*\{[\s\S]*--sidebar-expanded-width:\s*304px;/)
|
||||||
|
assert.match(appStyles, /\.sidebar-mode-fade-enter-active,[\s\S]*\.sidebar-mode-fade-leave-active\s*\{[\s\S]*opacity 180ms/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI sidebar has quick actions, business navigation and conversation history layers', () => {
|
||||||
|
assert.match(aiSidebar, /aria-label="AI模式导航"/)
|
||||||
|
assert.match(aiSidebar, /class="ai-rail-brand"/)
|
||||||
|
assert.match(aiSidebar, /aria-label="当前产品标识"/)
|
||||||
|
assert.match(aiSidebar, /displayBrandName/)
|
||||||
|
assert.match(aiSidebar, /brandName:\s*\{\s*type:\s*String/)
|
||||||
|
assert.match(aiSidebar, /brandLogo:\s*\{\s*type:\s*String/)
|
||||||
|
assert.match(aiSidebar, /String\(props\.brandName \|\| '易财费控'\)/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /远光软件股份有限公司/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /AI Workbench/)
|
||||||
|
assert.match(aiSidebar, /aria-label="对话操作"/)
|
||||||
|
assert.match(aiSidebar, /新建对话/)
|
||||||
|
assert.match(aiSidebar, /查询对话/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /自定义/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /业务工作舱/)
|
||||||
|
assert.match(aiSidebar, /resolveAiSidebarBusinessViewIds/)
|
||||||
|
assert.match(aiSidebar, /\.filter\(\(item\) => aiBusinessViewIds\.value\.has\(item\.id\)\)/)
|
||||||
|
assert.match(aiSidebar, /class="ai-nav-list"/)
|
||||||
|
assert.match(aiSidebar, /v-for="item in businessNavItems"/)
|
||||||
|
assert.match(aiSidebar, /ai-nav-copy/)
|
||||||
|
assert.match(aiSidebar, /item\.aiIcon/)
|
||||||
|
assert.match(aiSidebar, /aria-current/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /displayHint/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /个人工作台/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /待办与助手/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /v-html="item\.icon"/)
|
||||||
|
assert.match(aiSidebar, /最近对话/)
|
||||||
|
assert.match(aiSidebar, /conversationHistory:\s*\{ type:\s*Array,\s*default:\s*\(\) => \[\] \}/)
|
||||||
|
assert.match(aiSidebar, /const conversationSearchOpen = ref\(false\)/)
|
||||||
|
assert.match(aiSidebar, /const conversationSearchQuery = ref\(''\)/)
|
||||||
|
assert.match(aiSidebar, /function openConversationSearch\(\)/)
|
||||||
|
assert.match(aiSidebar, /<template[\s\S]*v-for="action in quickActions"[\s\S]*:key="action\.event"/)
|
||||||
|
assert.match(aiSidebar, /v-if="action\.event === 'search' && conversationSearchOpen"[\s\S]*class="ai-conversation-search"/)
|
||||||
|
assert.match(aiSidebar, /v-else[\s\S]*class="ai-quick-btn"/)
|
||||||
|
assert.match(aiSidebar, /placeholder="搜索对话标题"/)
|
||||||
|
assert.match(aiSidebar, /v-model="conversationSearchQuery"/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /<\/button>\s*<label v-if="conversationSearchOpen" class="ai-conversation-search"/)
|
||||||
|
assert.match(aiSidebar, /filteredConversationHistory/)
|
||||||
|
assert.match(aiSidebar, /String\(recent\.title \|\| ''\)\.toLowerCase\(\)\.includes\(query\)/)
|
||||||
|
assert.match(aiSidebar, /normalizedConversationHistory/)
|
||||||
|
assert.match(aiSidebar, /v-for="recent in filteredConversationHistory"/)
|
||||||
|
assert.match(aiSidebar, /activeConversationId === recent\.id/)
|
||||||
|
assert.match(aiSidebar, /ai-recent-main/)
|
||||||
|
assert.match(aiSidebar, /emit\('open-recent', recent\)/)
|
||||||
|
assert.match(aiSidebar, /@dblclick\.stop="startEditingRecentTitle\(recent\)"/)
|
||||||
|
assert.match(aiSidebar, /v-if="editingConversationId === recent\.id"/)
|
||||||
|
assert.match(aiSidebar, /v-model="editingConversationTitle"/)
|
||||||
|
assert.match(aiSidebar, /@keydown\.enter\.prevent="commitRecentTitleEdit\(recent\)"/)
|
||||||
|
assert.match(aiSidebar, /emit\('rename-conversation'/)
|
||||||
|
assert.match(aiSidebar, /暂无历史对话/)
|
||||||
|
assert.match(aiSidebar, /没有匹配的对话/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /差旅报销口径核对/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /预算占用预警分析/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /票据识别结果复核/)
|
||||||
|
assert.match(aiSidebar, /aria-label="当前用户"/)
|
||||||
|
assert.match(aiSidebar, /displayUser\.avatar/)
|
||||||
|
assert.match(aiSidebar, /displayUser\.subtitle/)
|
||||||
|
assert.match(aiSidebar, /aria-label="用户操作"/)
|
||||||
|
assert.match(aiSidebar, /emit\('logout'\)/)
|
||||||
|
assert.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'\]\)/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /search-chat/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /打开系统设置/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /mdi-chevron-up/)
|
||||||
|
assert.doesNotMatch(aiSidebar, /mockRecents/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI sidebar visual treatment keeps a restrained three-layer workspace', () => {
|
||||||
|
const quickButtonBlock = extractCssBlock(aiSidebarStyles, '\\.ai-quick-btn')
|
||||||
|
const quickPrimaryBlock = extractCssBlock(aiSidebarStyles, '\\.ai-quick-btn\\.primary')
|
||||||
|
const navListBlock = extractCssBlock(aiSidebarStyles, '\\.ai-nav-list')
|
||||||
|
|
||||||
|
assert.match(aiSidebarStyles, /--ai-rail-bg:\s*#f7f9fc;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail\s*\{[\s\S]*grid-template-rows:\s*auto auto auto auto auto minmax\(0,\s*1fr\) auto;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail::before\s*\{[\s\S]*repeating-linear-gradient/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail-brand\s*\{[\s\S]*min-height:\s*74px;[\s\S]*grid-template-columns:\s*42px minmax\(0,\s*1fr\);/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-brand-logo\s*\{[\s\S]*width:\s*42px;[\s\S]*height:\s*42px;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-brand-logo svg\s*\{[\s\S]*width:\s*26px;[\s\S]*height:\s*26px;/)
|
||||||
|
assert.match(aiSidebar, /icon:\s*'mdi mdi-plus'/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail-quick\s*\{[\s\S]*gap:\s*6px;[\s\S]*padding:\s*8px 18px 12px;/)
|
||||||
|
assert.match(quickButtonBlock, /min-height:\s*48px;/)
|
||||||
|
assert.match(quickButtonBlock, /grid-template-columns:\s*28px minmax\(0,\s*1fr\);/)
|
||||||
|
assert.match(quickButtonBlock, /background:\s*transparent;/)
|
||||||
|
assert.match(quickButtonBlock, /border-color:\s*transparent;/)
|
||||||
|
assert.match(quickButtonBlock, /box-shadow:\s*none;/)
|
||||||
|
assert.match(quickPrimaryBlock, /background:\s*transparent;/)
|
||||||
|
assert.match(quickPrimaryBlock, /box-shadow:\s*none;/)
|
||||||
|
assert.doesNotMatch(quickButtonBlock, /rgba\(255,\s*255,\s*255/)
|
||||||
|
assert.match(navListBlock, /gap:\s*6px;/)
|
||||||
|
assert.doesNotMatch(navListBlock, /grid-template-columns:\s*repeat\(2/)
|
||||||
|
assert.doesNotMatch(navListBlock, /border-radius:/)
|
||||||
|
assert.doesNotMatch(navListBlock, /box-shadow:/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*min-height:\s*48px;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-nav-btn\.active\s*\{[\s\S]*background:[\s\S]*linear-gradient\(90deg,\s*rgba\(45,\s*114,\s*217,\s*0\.095\)/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-list\s*\{[\s\S]*grid-template-columns:\s*1fr;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail-recents\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-recents-list\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-recents-list::-webkit-scrollbar\s*\{[\s\S]*display:\s*none;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-recents-empty\s*\{[\s\S]*border:\s*1px dashed/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-recent-item\s*\{[\s\S]*min-height:\s*56px;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail-user\s*\{[\s\S]*height:\s*72px;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail-user\s*\{[\s\S]*border-radius:\s*0;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-user-actions\s*\{[\s\S]*grid-template-columns:\s*44px;/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-rail-recents,/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-user-copy,/)
|
||||||
|
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-user-actions\s*\{[\s\S]*display:\s*none;/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI sidebar business layer is role scoped for Xiaocai steward mode', () => {
|
||||||
|
assert.match(aiBusinessAccess, /AI_SIDEBAR_BASE_BUSINESS_VIEW_IDS = \['documents', 'receiptFolder', 'policies'\]/)
|
||||||
|
assert.match(aiBusinessAccess, /ROLE_VIEW_ADDITIONS = \{[\s\S]*budget:\s*\['budget'\]/)
|
||||||
|
assert.match(aiBusinessAccess, /roleCodeSet\.has\('budget_monitor'\)[\s\S]*ROLE_VIEW_ADDITIONS\.budget/)
|
||||||
|
assert.doesNotMatch(aiBusinessAccess, /'workbench'/)
|
||||||
|
})
|
||||||
69
web/tests/ai-workbench-conversation-store.test.mjs
Normal file
69
web/tests/ai-workbench-conversation-store.test.mjs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteAiWorkbenchConversation,
|
||||||
|
loadAiWorkbenchConversationHistory,
|
||||||
|
saveAiWorkbenchConversation
|
||||||
|
} from '../src/utils/aiWorkbenchConversationStore.js'
|
||||||
|
|
||||||
|
function installLocalStorageMock() {
|
||||||
|
const store = new Map()
|
||||||
|
globalThis.window = {
|
||||||
|
localStorage: {
|
||||||
|
getItem(key) {
|
||||||
|
return store.has(key) ? store.get(key) : null
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
store.set(key, String(value))
|
||||||
|
},
|
||||||
|
removeItem(key) {
|
||||||
|
store.delete(key)
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
store.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
test('AI workbench conversation store persists scoped history for sidebar sessions', () => {
|
||||||
|
installLocalStorageMock()
|
||||||
|
const user = { username: 'caoxiaozhu', email: 'caoxiaozhu@xf.com', name: '曹笑竹' }
|
||||||
|
const anotherUser = { username: 'budget-user' }
|
||||||
|
|
||||||
|
saveAiWorkbenchConversation(user, {
|
||||||
|
id: 'conv-first',
|
||||||
|
title: '',
|
||||||
|
updatedAt: Date.now() - 3000,
|
||||||
|
messages: [
|
||||||
|
{ id: 'u1', role: 'user', content: '帮我核对差旅报销口径' },
|
||||||
|
{ id: 'a1', role: 'assistant', content: '我会根据制度和票据要求继续核对。' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
saveAiWorkbenchConversation(user, {
|
||||||
|
id: 'conv-second',
|
||||||
|
title: '预算占用分析',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
stewardState: { intent: 'budget_check' },
|
||||||
|
messages: [
|
||||||
|
{ id: 'u2', role: 'user', content: '分析本月预算占用' },
|
||||||
|
{ id: 'a2', role: 'assistant', content: '本月预算占用需要结合部门额度和已提交单据。' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const history = loadAiWorkbenchConversationHistory(user)
|
||||||
|
assert.equal(history.length, 2)
|
||||||
|
assert.equal(history[0].id, 'conv-second')
|
||||||
|
assert.equal(history[0].title, '预算占用分析')
|
||||||
|
assert.equal(history[0].stewardState.intent, 'budget_check')
|
||||||
|
assert.equal(history[1].title, '帮我核对差旅报销口径')
|
||||||
|
assert.equal(history[1].prompt, '帮我核对差旅报销口径')
|
||||||
|
assert.ok(history[0].time)
|
||||||
|
assert.deepEqual(loadAiWorkbenchConversationHistory(anotherUser), [])
|
||||||
|
|
||||||
|
const nextHistory = deleteAiWorkbenchConversation(user, 'conv-second')
|
||||||
|
assert.equal(nextHistory.length, 1)
|
||||||
|
assert.equal(nextHistory[0].id, 'conv-first')
|
||||||
|
})
|
||||||
@@ -102,8 +102,8 @@ test('workbench summary merges approval inbox requests without polluting documen
|
|||||||
|
|
||||||
test('workbench progress refreshes after homepage create or detail updates', () => {
|
test('workbench progress refreshes after homepage create or detail updates', () => {
|
||||||
assert.match(appShellComposable, /async function handleDraftSaved\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)/)
|
assert.match(appShellComposable, /async function handleDraftSaved\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)/)
|
||||||
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/)
|
||||||
assert.doesNotMatch(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail/)
|
assert.doesNotMatch(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench progress refresh is silent to avoid homepage flashing', () => {
|
test('workbench progress refresh is silent to avoid homepage flashing', () => {
|
||||||
@@ -136,7 +136,7 @@ test('document detail refreshes claim detail instead of relying on stale list ca
|
|||||||
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
|
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
|
||||||
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
|
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
|
||||||
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
|
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
|
||||||
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/)
|
||||||
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
ASSISTANT_SCOPE_ACTION_FILL_COMPOSER
|
||||||
|
} from '../src/utils/assistantSessionScope.js'
|
||||||
import {
|
import {
|
||||||
mergeComposerPrefill,
|
mergeComposerPrefill,
|
||||||
resolveSuggestedActionPrefill
|
resolveSuggestedActionPrefill
|
||||||
@@ -41,3 +44,23 @@ test('composer prefill appends to existing draft without duplication', () => {
|
|||||||
assert.equal(mergeComposerPrefill('地点:上海', '事由:'), '地点:上海\n事由:')
|
assert.equal(mergeComposerPrefill('地点:上海', '事由:'), '地点:上海\n事由:')
|
||||||
assert.equal(mergeComposerPrefill('地点:上海\n事由:', '事由:'), '地点:上海\n事由:')
|
assert.equal(mergeComposerPrefill('地点:上海\n事由:', '事由:'), '地点:上海\n事由:')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('fill_composer action resolves payload.fill_text as prefill', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSuggestedActionPrefill({
|
||||||
|
action_type: ASSISTANT_SCOPE_ACTION_FILL_COMPOSER,
|
||||||
|
payload: { fill_text: '我想要申请明天去北京出差3天' }
|
||||||
|
}),
|
||||||
|
'我想要申请明天去北京出差3天'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fill_composer action without fill_text falls back to empty (no application_field lookup)', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSuggestedActionPrefill({
|
||||||
|
action_type: ASSISTANT_SCOPE_ACTION_FILL_COMPOSER,
|
||||||
|
payload: {}
|
||||||
|
}),
|
||||||
|
''
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { fileURLToPath } from 'node:url'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||||
|
attachReceiptFolderIdsToFiles,
|
||||||
buildAttachmentAssociationConfirmationMessage,
|
buildAttachmentAssociationConfirmationMessage,
|
||||||
buildOcrFilePreviews,
|
buildOcrFilePreviews,
|
||||||
buildReviewFilePreviewsFromReviewPayload,
|
buildReviewFilePreviewsFromReviewPayload,
|
||||||
buildUnsavedDraftAttachmentConfirmationMessage,
|
buildUnsavedDraftAttachmentConfirmationMessage,
|
||||||
|
collectReceiptFiles,
|
||||||
filterPersistableFilePreviews,
|
filterPersistableFilePreviews,
|
||||||
mergeFilePreviews
|
mergeFilePreviews,
|
||||||
|
normalizeOcrDocuments
|
||||||
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
|
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
|
||||||
import {
|
import {
|
||||||
buildDraftAssociationQueryPayload,
|
buildDraftAssociationQueryPayload,
|
||||||
@@ -171,6 +174,90 @@ test('OCR preview builders keep hotel receipt image previews when preview kind i
|
|||||||
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('OCR receipt folder ids are kept for final draft attachment association', () => {
|
||||||
|
const files = [
|
||||||
|
{ name: 'invoice.png' },
|
||||||
|
{ name: 'taxi.pdf' }
|
||||||
|
]
|
||||||
|
const ocrPayload = {
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
filename: 'invoice.png',
|
||||||
|
receipt_id: 'receipt-1',
|
||||||
|
receipt_status: 'unlinked',
|
||||||
|
receipt_preview_url: '/receipt-folder/receipt-1/preview',
|
||||||
|
receipt_source_url: '/receipt-folder/receipt-1/source'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: 'taxi.pdf',
|
||||||
|
receipt_id: 'receipt-2',
|
||||||
|
receipt_status: 'unlinked',
|
||||||
|
receipt_preview_url: '/receipt-folder/receipt-2/preview',
|
||||||
|
receipt_source_url: '/receipt-folder/receipt-2/source'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = normalizeOcrDocuments(ocrPayload)
|
||||||
|
|
||||||
|
assert.equal(documents[0].receipt_id, 'receipt-1')
|
||||||
|
assert.equal(documents[0].receipt_status, 'unlinked')
|
||||||
|
assert.equal(documents[0].receipt_preview_url, '/receipt-folder/receipt-1/preview')
|
||||||
|
assert.equal(documents[0].receipt_source_url, '/receipt-folder/receipt-1/source')
|
||||||
|
assert.equal(attachReceiptFolderIdsToFiles(files, ocrPayload), 2)
|
||||||
|
assert.equal(files[0].receiptId, 'receipt-1')
|
||||||
|
assert.equal(files[1].receiptId, 'receipt-2')
|
||||||
|
assert.equal(Object.getOwnPropertyDescriptor(files[0], 'receiptId')?.enumerable, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('receipt files are collected through a single OCR persistence entry before draft association', async () => {
|
||||||
|
const files = [
|
||||||
|
{ name: 'invoice.png' }
|
||||||
|
]
|
||||||
|
let recognizeCallCount = 0
|
||||||
|
|
||||||
|
const collected = await collectReceiptFiles({
|
||||||
|
files,
|
||||||
|
recognizeOcrFiles: async (inputFiles, options) => {
|
||||||
|
recognizeCallCount += 1
|
||||||
|
assert.equal(inputFiles, files)
|
||||||
|
assert.equal(options.timeoutMs, 90000)
|
||||||
|
return {
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
filename: 'invoice.png',
|
||||||
|
summary: '发票金额 100 元',
|
||||||
|
preview_kind: 'image',
|
||||||
|
preview_data_url: 'data:image/png;base64,abc123',
|
||||||
|
receipt_id: 'receipt-collect-1',
|
||||||
|
receipt_status: 'unlinked',
|
||||||
|
receipt_preview_url: '/receipt-folder/receipt-collect-1/preview',
|
||||||
|
receipt_source_url: '/receipt-folder/receipt-collect-1/source'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(recognizeCallCount, 1)
|
||||||
|
assert.equal(files[0].receiptId, 'receipt-collect-1')
|
||||||
|
assert.equal(collected.ocrPayload.documents[0].receipt_id, 'receipt-collect-1')
|
||||||
|
assert.equal(collected.ocrDocuments[0].receipt_id, 'receipt-collect-1')
|
||||||
|
assert.equal(collected.ocrSummary, 'invoice.png:发票金额 100 元')
|
||||||
|
assert.deepEqual(collected.ocrFilePreviews, [
|
||||||
|
{ filename: 'invoice.png', kind: 'image', url: 'data:image/png;base64,abc123' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const submitComposerSource = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
assert.match(submitComposerSource, /collectReceiptFiles\(/)
|
||||||
|
assert.doesNotMatch(submitComposerSource, /recognizeOcrFiles\(files,[\s\S]*attachReceiptFolderIdsToFiles/)
|
||||||
|
assert.doesNotMatch(submitComposerSource, /ocrDocuments = normalizeOcrDocuments\(ocrPayload\)/)
|
||||||
|
assert.doesNotMatch(submitComposerSource, /ocrFilePreviews = buildOcrFilePreviews\(ocrPayload\)/)
|
||||||
|
})
|
||||||
|
|
||||||
test('file preview cache replaces temporary object urls and never persists them', () => {
|
test('file preview cache replaces temporary object urls and never persists them', () => {
|
||||||
const merged = mergeFilePreviews(
|
const merged = mergeFilePreviews(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ import {
|
|||||||
} from '../src/views/scripts/budgetAssistantReportModel.js'
|
} from '../src/views/scripts/budgetAssistantReportModel.js'
|
||||||
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
|
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
|
||||||
import {
|
import {
|
||||||
|
ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||||
ASSISTANT_SCOPE_SESSION_APPLICATION,
|
ASSISTANT_SCOPE_SESSION_APPLICATION,
|
||||||
ASSISTANT_SCOPE_SESSION_EXPENSE,
|
ASSISTANT_SCOPE_SESSION_EXPENSE,
|
||||||
ASSISTANT_SCOPE_SESSION_STEWARD,
|
ASSISTANT_SCOPE_SESSION_STEWARD,
|
||||||
|
buildUnsupportedBusinessScopeConversation,
|
||||||
inferAssistantScopeTarget,
|
inferAssistantScopeTarget,
|
||||||
resolveAssistantScopeGuard
|
resolveAssistantScopeGuard
|
||||||
} from '../src/utils/assistantSessionScope.js'
|
} from '../src/utils/assistantSessionScope.js'
|
||||||
@@ -231,16 +233,42 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
|||||||
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('unsupported business guidance opens in assistant conversation form', () => {
|
||||||
|
const conversation = buildUnsupportedBusinessScopeConversation('你好')
|
||||||
|
|
||||||
|
assert.equal(conversation.state_json.session_type, ASSISTANT_SCOPE_SESSION_STEWARD)
|
||||||
|
assert.equal(conversation.messages.length, 1)
|
||||||
|
assert.equal(conversation.messages[0].role, 'assistant')
|
||||||
|
assert.match(conversation.messages[0].content, /小财管家暂时不处理「你好」/)
|
||||||
|
assert.equal(conversation.messages[0].assistantName, '小财管家')
|
||||||
|
assert.match(conversation.messages[0].content, /### 当前可继续的场景/)
|
||||||
|
assert.equal(
|
||||||
|
conversation.messages[0].message_json.orchestrator_payload.result.suggested_actions.length,
|
||||||
|
4
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test('assistant scope guard blocks unsupported non-financial intent', () => {
|
test('assistant scope guard blocks unsupported non-financial intent', () => {
|
||||||
|
const greetingGuard = resolveAssistantScopeGuard('你好', ASSISTANT_SCOPE_SESSION_APPLICATION)
|
||||||
const guard = resolveAssistantScopeGuard('帮我写一首诗,主题是春天', ASSISTANT_SCOPE_SESSION_APPLICATION)
|
const guard = resolveAssistantScopeGuard('帮我写一首诗,主题是春天', ASSISTANT_SCOPE_SESSION_APPLICATION)
|
||||||
|
|
||||||
|
assert.equal(greetingGuard.blocked, true)
|
||||||
|
assert.equal(greetingGuard.targetSessionType, '')
|
||||||
|
assert.equal(greetingGuard.suggestedActions.length, 4)
|
||||||
|
assert.deepEqual(
|
||||||
|
greetingGuard.suggestedActions.map((item) => item.action_type),
|
||||||
|
Array.from({ length: 4 }, () => ASSISTANT_SCOPE_ACTION_SWITCH)
|
||||||
|
)
|
||||||
|
assert.match(greetingGuard.text, /小财管家暂时不处理「你好」/)
|
||||||
|
assert.match(greetingGuard.text, /你可以直接点下面的场景继续/)
|
||||||
|
assert.equal(guard.suggestedActions.length, 4)
|
||||||
assert.equal(guard.blocked, true)
|
assert.equal(guard.blocked, true)
|
||||||
assert.equal(guard.targetSessionType, '')
|
assert.equal(guard.targetSessionType, '')
|
||||||
assert.match(guard.text, /此意图系统不支持/)
|
assert.match(guard.text, /此意图系统不支持/)
|
||||||
assert.match(guard.text, /当前系统支持的业务范围/)
|
assert.match(guard.text, /当前系统支持的业务范围/)
|
||||||
assert.deepEqual(guard.suggestedActions, [])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('assistant scope guard routes related business intent instead of blocking', () => {
|
test('assistant scope guard routes related business intent instead of blocking', () => {
|
||||||
const guard = resolveAssistantScopeGuard('帮我查一下报销单状态', ASSISTANT_SCOPE_SESSION_APPLICATION)
|
const guard = resolveAssistantScopeGuard('帮我查一下报销单状态', ASSISTANT_SCOPE_SESSION_APPLICATION)
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ test('daily amount trend uses stacked category bars with clear unit and legend',
|
|||||||
assert.match(trendChart, /legendItems/)
|
assert.match(trendChart, /legendItems/)
|
||||||
assert.match(trendChart, /单位:元/)
|
assert.match(trendChart, /单位:元/)
|
||||||
assert.match(trendChart, /单位:单/)
|
assert.match(trendChart, /单位:单/)
|
||||||
|
assert.match(trendChart, /comparisonAmount/)
|
||||||
|
assert.match(trendChart, /isComparisonMode/)
|
||||||
|
assert.match(trendChart, /props\.mode === 'compareAmount'/)
|
||||||
|
assert.match(trendChart, /props\.comparisonLabel/)
|
||||||
|
assert.match(trendChart, /const compactScale = computed/)
|
||||||
|
assert.match(trendChart, /const chartGrid = computed/)
|
||||||
|
assert.match(trendChart, /gridBottom:\s*props\.compact \? 18 : 22/)
|
||||||
|
assert.match(trendChart, /gridTop:\s*props\.compact \? 10 : 12/)
|
||||||
|
assert.match(trendChart, /splitNumber:\s*props\.compact \? 2 : 5/)
|
||||||
|
assert.match(trendChart, /primaryLineWidth:\s*props\.compact \? 3\.8 : 3/)
|
||||||
|
assert.match(trendChart, /axisLabelSize:\s*props\.compact \? 12 : 11/)
|
||||||
|
assert.match(trendChart, /lineStyle:[\s\S]*type:\s*'dashed'/)
|
||||||
|
assert.match(trendChart, /\.trend-chart-compact\s*\{[\s\S]*min-height:\s*124px;/)
|
||||||
|
assert.match(trendChart, /\.trend-chart-compact \.chart-legend\s*\{[\s\S]*font-size:\s*13px;/)
|
||||||
assert.doesNotMatch(trendChart, /name:\s*isCountMode\.value/)
|
assert.doesNotMatch(trendChart, /name:\s*isCountMode\.value/)
|
||||||
assert.doesNotMatch(trendChart, /stack: 'expenseAmount'/)
|
assert.doesNotMatch(trendChart, /stack: 'expenseAmount'/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ const workbenchInsightStyles = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-insights.css', import.meta.url)),
|
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-insights.css', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
const heroBackgroundAsset = fileURLToPath(
|
|
||||||
new URL('../src/assets/images/hero-3d-banner.png', import.meta.url)
|
|
||||||
)
|
|
||||||
const capabilityGlassAsset = fileURLToPath(
|
const capabilityGlassAsset = fileURLToPath(
|
||||||
new URL('../src/assets/personal-workbench-card-glass-capability.webp', import.meta.url)
|
new URL('../src/assets/personal-workbench-card-glass-capability.webp', import.meta.url)
|
||||||
)
|
)
|
||||||
@@ -48,14 +45,41 @@ const panelGlassAsset = fileURLToPath(
|
|||||||
new URL('../src/assets/personal-workbench-card-glass-panel.webp', import.meta.url)
|
new URL('../src/assets/personal-workbench-card-glass-panel.webp', import.meta.url)
|
||||||
)
|
)
|
||||||
|
|
||||||
test('workbench assistant greets the current employee without the old helper tag', () => {
|
test('traditional workbench uses compact real reimbursement trend chart instead of assistant composer', () => {
|
||||||
assert.doesNotMatch(workbench, /assistant-tag/)
|
assert.doesNotMatch(workbench, /assistant-tag/)
|
||||||
assert.doesNotMatch(workbench, /AI 报销助手/)
|
assert.doesNotMatch(workbench, /AI 报销助手/)
|
||||||
assert.match(workbench, /\{\{ typedTitlePrefix \}\}<span v-if="titleTypingDone">小财管家<\/span>/)
|
assert.doesNotMatch(workbench, /class="panel assistant-hero workbench-trend-hero"/)
|
||||||
assert.match(workbench, /const heroTitleText = computed\(\(\) => `嗨,\$\{displayUserName\.value\},我是您的 `\)/)
|
assert.match(workbench, /class="workbench-trend-card"/)
|
||||||
assert.match(workbench, /placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行\.\.\."/)
|
assert.match(workbench, /报销趋势/)
|
||||||
|
assert.match(workbench, /import TrendChart from '\.\.\/charts\/TrendChart\.vue'/)
|
||||||
|
assert.match(workbench, /<TrendChart[\s\S]*mode="compareAmount"/)
|
||||||
|
assert.match(workbench, /:labels="reimbursementTrendLabels"/)
|
||||||
|
assert.match(workbench, /:claim-amount="reimbursementTrendAmounts"/)
|
||||||
|
assert.match(workbench, /:comparison-amount="reimbursementTrendPreviousAmounts"/)
|
||||||
|
assert.match(workbench, /compact/)
|
||||||
|
assert.match(workbench, /与分析看板同源/)
|
||||||
|
assert.doesNotMatch(workbench, /class="trend-chart-svg"/)
|
||||||
|
assert.doesNotMatch(workbench, /currentTrendPath/)
|
||||||
|
assert.doesNotMatch(workbench, /comparisonTrendPath/)
|
||||||
|
assert.doesNotMatch(workbench, /reimbursementTrendPoints/)
|
||||||
|
assert.doesNotMatch(workbench, /FALLBACK_REIMBURSEMENT_TREND_ROWS/)
|
||||||
|
assert.doesNotMatch(workbench, /assistant-composer/)
|
||||||
|
assert.doesNotMatch(workbench, /textarea/)
|
||||||
|
assert.doesNotMatch(workbench, /quick-prompts/)
|
||||||
|
assert.doesNotMatch(workbench, /useWorkbenchComposerDate/)
|
||||||
assert.match(workbench, /const displayUserName = computed/)
|
assert.match(workbench, /const displayUserName = computed/)
|
||||||
assert.match(workbench, /user\.name/)
|
assert.match(workbench, /user\.name/)
|
||||||
|
assert.match(workbenchStyles, /--hero-title-size:\s*34px;/)
|
||||||
|
assert.match(workbenchStyles, /--trend-card-min-height:\s*260px;/)
|
||||||
|
assert.match(workbenchStyles, /\.workbench\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;/)
|
||||||
|
assert.match(workbenchStyles, /\.workbench-trend-hero\s*\{[\s\S]*height:\s*var\(--trend-card-min-height\);[\s\S]*min-height:\s*0;/)
|
||||||
|
assert.match(workbenchStyles, /\.workbench-trend-card\s*\{[\s\S]*grid-template-columns:\s*minmax\(200px,\s*0\.28fr\) minmax\(0,\s*1fr\);/)
|
||||||
|
assert.match(workbenchStyles, /\.workbench-trend-card\s*\{[\s\S]*height:\s*100%;[\s\S]*min-height:\s*0;/)
|
||||||
|
assert.match(workbenchStyles, /\.workbench-trend-card\s*\{[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*none;/)
|
||||||
|
assert.match(workbenchStyles, /\.trend-total\s*\{[\s\S]*font-size:\s*clamp\(38px,\s*3\.3vw,\s*54px\);/)
|
||||||
|
assert.match(workbenchStyles, /\.capability-grid\s*\{[\s\S]*flex:\s*0 0 var\(--capability-row-height\);/)
|
||||||
|
assert.match(workbenchStyles, /\.workbench-content-grid\s*\{[\s\S]*flex:\s*1 1 auto;/)
|
||||||
|
assert.match(workbenchStyles, /\.trend-chart-panel\s*\{[\s\S]*min-height:\s*0;/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench capability cards open assistant without injecting canned prompts', () => {
|
test('workbench capability cards open assistant without injecting canned prompts', () => {
|
||||||
@@ -91,17 +115,22 @@ test('workbench capability cards keep user-entered context only', () => {
|
|||||||
assert.equal(payload.files, files)
|
assert.equal(payload.files, files)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench hero uses theme-tintable background image', () => {
|
test('workbench trend panel is a single compact surface without background art overlay', () => {
|
||||||
assert.match(workbench, /hero-3d-banner\.png/)
|
assert.match(workbench, /class="workbench-trend-card"/)
|
||||||
|
assert.doesNotMatch(workbench, /hero-3d-banner\.png/)
|
||||||
assert.doesNotMatch(workbench, /personal-workbench-hero-bg-theme-base\.(webp|png)/)
|
assert.doesNotMatch(workbench, /personal-workbench-hero-bg-theme-base\.(webp|png)/)
|
||||||
assert.match(workbench, /--assistant-bg-image.*workbenchHeroBackground/)
|
assert.doesNotMatch(workbenchStyles, /url\("\.\.\/\.\.\/images\/workbench-hero-right-bg\.png"\)/)
|
||||||
assert.match(workbenchStyles, /--assistant-theme-tint:[\s\S]*--theme-primary-rgb/)
|
assert.doesNotMatch(workbenchStyles, /\.assistant-hero::after/)
|
||||||
assert.match(workbenchStyles, /url\("\.\.\/\.\.\/images\/workbench-hero-right-bg\.png"\) var\(--assistant-bg-position\) \/ var\(--assistant-decor-width\) auto no-repeat/)
|
assert.doesNotMatch(workbenchResponsiveStyles, /--assistant-bg-position/)
|
||||||
assert.match(workbenchStyles, /\.assistant-hero::after\s*\{[\s\S]*content:\s*"";/)
|
assert.doesNotMatch(workbenchResponsiveStyles, /\.assistant-hero/)
|
||||||
assert.match(workbenchResponsiveStyles, /--assistant-bg-position:\s*right center;/)
|
|
||||||
assert.doesNotMatch(workbenchResponsiveStyles, /homepage_backgraound/)
|
assert.doesNotMatch(workbenchResponsiveStyles, /homepage_backgraound/)
|
||||||
assert.ok(statSync(heroBackgroundAsset).size > 1024)
|
})
|
||||||
assert.ok(statSync(heroBackgroundAsset).size < 600 * 1024)
|
|
||||||
|
test('workbench hero does not own the global AI mode switch', () => {
|
||||||
|
assert.doesNotMatch(workbench, /workbench-mode-switch/)
|
||||||
|
assert.doesNotMatch(workbench, /ai-mode-toggle/)
|
||||||
|
assert.doesNotMatch(workbenchStyles, /workbench-mode-switch/)
|
||||||
|
assert.doesNotMatch(workbenchResponsiveStyles, /workbench-mode-switch/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench cards use layered glass material instead of texture-led cards', () => {
|
test('workbench cards use layered glass material instead of texture-led cards', () => {
|
||||||
@@ -141,13 +170,16 @@ test('workbench cards use layered glass material instead of texture-led cards',
|
|||||||
assert.ok(statSync(panelGlassAsset).size < 24 * 1024)
|
assert.ok(statSync(panelGlassAsset).size < 24 * 1024)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench submit shows intent recognition feedback before assistant opens', () => {
|
test('traditional workbench no longer keeps composer pending state', () => {
|
||||||
assert.match(workbench, /class="assistant-intent-status"/)
|
assert.doesNotMatch(workbench, /class="assistant-intent-status"/)
|
||||||
assert.match(workbench, /aria-live="polite"/)
|
assert.doesNotMatch(workbench, /正在识别意图,准备进入对应助手/)
|
||||||
assert.match(workbench, /正在识别意图,准备进入对应助手/)
|
assert.doesNotMatch(workbench, /startPendingAction\('intent'\)/)
|
||||||
assert.match(workbench, /startPendingAction\('intent'\)/)
|
assert.doesNotMatch(workbench, /isComposerPending/)
|
||||||
assert.match(workbench, /if \(open\) \{\s*clearPendingAction\(\)/)
|
assert.doesNotMatch(workbench, /assistantDraft/)
|
||||||
assert.match(workbench, /:readonly="isComposerPending"/)
|
assert.doesNotMatch(workbench, /fetchLatestConversation/)
|
||||||
|
assert.doesNotMatch(workbench, /clearUserConversations/)
|
||||||
|
assert.match(workbench, /function openCapabilityAssistant\(item\)/)
|
||||||
|
assert.match(workbench, /buildWorkbenchCapabilityAssistantPayload\(item,\s*buildAssistantPayload\(\)\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench document progress has range filter, document types and empty state', () => {
|
test('workbench document progress has range filter, document types and empty state', () => {
|
||||||
|
|||||||
@@ -8,20 +8,26 @@ const responsiveStyles = readFileSync(
|
|||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
|
||||||
test('personal workbench compacts hero input and capability cards on laptop screens', () => {
|
test('personal workbench compacts trend chart and capability cards on laptop screens', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
responsiveStyles,
|
responsiveStyles,
|
||||||
/@media \(min-width: 961px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 961px\) and \(max-height: 820px\)/
|
/@media \(min-width: 961px\) and \(max-width: 1440px\),\s*\n\s*\(min-width: 961px\) and \(max-height: 820px\)/
|
||||||
)
|
)
|
||||||
assert.match(responsiveStyles, /--hero-padding-top:\s*14px;/)
|
assert.match(responsiveStyles, /--hero-title-size:\s*30px;/)
|
||||||
assert.match(responsiveStyles, /--hero-padding-bottom:\s*14px;/)
|
assert.match(responsiveStyles, /--trend-card-min-height:\s*232px;/)
|
||||||
assert.match(responsiveStyles, /--hero-title-size:\s*24px;/)
|
assert.match(responsiveStyles, /--capability-row-height:\s*102px;/)
|
||||||
assert.match(responsiveStyles, /--composer-min-height:\s*92px;/)
|
assert.match(responsiveStyles, /\.workbench-trend-hero\s*\{[\s\S]*padding:\s*24px 18px 10px 18px;/)
|
||||||
assert.match(responsiveStyles, /--composer-textarea-height:\s*38px;/)
|
assert.match(responsiveStyles, /\.trend-summary-panel h1\s*\{[\s\S]*font-size:\s*var\(--hero-title-size\);/)
|
||||||
assert.match(responsiveStyles, /--capability-row-height:\s*82px;/)
|
assert.match(responsiveStyles, /\.workbench-trend-card\s*\{[\s\S]*min-height:\s*0;/)
|
||||||
assert.match(responsiveStyles, /\.assistant-copy h1\s*\{[\s\S]*font-size:\s*var\(--hero-title-size\);/)
|
assert.match(responsiveStyles, /\.trend-chart-panel\s*\{[\s\S]*min-height:\s*0;/)
|
||||||
assert.match(responsiveStyles, /\.assistant-composer\s*\{[\s\S]*padding:\s*var\(--composer-padding-block\) 14px 8px;/)
|
assert.match(responsiveStyles, /\.trend-total\s*\{[\s\S]*font-size:\s*42px;/)
|
||||||
assert.match(responsiveStyles, /\.quick-prompts button\s*\{[\s\S]*min-height:\s*24px;/)
|
assert.match(responsiveStyles, /\.trend-chart-head strong\s*\{[\s\S]*font-size:\s*14px;/)
|
||||||
assert.match(responsiveStyles, /\.capability-card\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 14px;[\s\S]*padding:\s*12px 12px 12px 16px;/)
|
assert.match(responsiveStyles, /\.trend-summary-panel small\s*\{[\s\S]*display:\s*none;/)
|
||||||
assert.match(responsiveStyles, /@media \(max-width: 760px\)[\s\S]*\.workbench\s*\{[\s\S]*grid-template-rows:\s*none;/)
|
assert.doesNotMatch(responsiveStyles, /\.assistant-hero/)
|
||||||
|
assert.doesNotMatch(responsiveStyles, /--assistant-bg-position/)
|
||||||
|
assert.match(responsiveStyles, /\.capability-card\s*\{[\s\S]*grid-template-columns:\s*40px minmax\(0,\s*1fr\) 16px;[\s\S]*padding:\s*15px 14px 15px 18px;/)
|
||||||
|
assert.doesNotMatch(responsiveStyles, /--composer-min-height/)
|
||||||
|
assert.match(responsiveStyles, /@media \(max-width: 760px\)[\s\S]*\.workbench\s*\{[\s\S]*height:\s*auto;/)
|
||||||
|
assert.match(responsiveStyles, /@media \(max-width: 760px\)[\s\S]*\.trend-summary-panel\s*\{[\s\S]*transform:\s*none;/)
|
||||||
|
assert.doesNotMatch(responsiveStyles, /grid-template-rows:\s*auto var\(--capability-row-height\)/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,14 +13,20 @@ const sidebar = readFileSync(
|
|||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sidebarStyles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/components/sidebar-rail.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
const appStyles = readFileSync(
|
const appStyles = readFileSync(
|
||||||
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
|
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
|
||||||
test('sidebar supports smooth animated collapsed layout', () => {
|
test('sidebar supports smooth animated collapsed layout', () => {
|
||||||
assert.match(appShell, /sidebarCollapsed = ref\(true\)/)
|
assert.match(appShell, /sidebarCollapsed = ref\(false\)/)
|
||||||
assert.match(appShell, /:class="\{ 'sidebar-collapsed': sidebarCollapsed \}"/)
|
assert.match(appShell, /'sidebar-collapsed': sidebarCollapsed/)
|
||||||
|
assert.match(appShell, /'workbench-ai-sidebar-active': isAiShellMode/)
|
||||||
assert.match(appShell, /:collapsed="sidebarCollapsed"/)
|
assert.match(appShell, /:collapsed="sidebarCollapsed"/)
|
||||||
assert.match(appShell, /class="app-sidebar"/)
|
assert.match(appShell, /class="app-sidebar"/)
|
||||||
assert.match(appShell, /@toggle-collapse="toggleSidebarCollapsed"/)
|
assert.match(appShell, /@toggle-collapse="toggleSidebarCollapsed"/)
|
||||||
@@ -32,9 +38,15 @@ test('sidebar supports smooth animated collapsed layout', () => {
|
|||||||
assert.match(sidebar, /rail-collapsed/)
|
assert.match(sidebar, /rail-collapsed/)
|
||||||
assert.match(sidebar, /折叠侧边栏/)
|
assert.match(sidebar, /折叠侧边栏/)
|
||||||
assert.match(sidebar, /展开侧边栏/)
|
assert.match(sidebar, /展开侧边栏/)
|
||||||
assert.match(sidebar, /--rail-motion-duration: 320ms/)
|
assert.match(sidebar, /class="user-actions"/)
|
||||||
assert.match(sidebar, /opacity var\(--rail-fade-duration\)/)
|
assert.match(sidebar, /class="user-action user-logout"/)
|
||||||
|
assert.doesNotMatch(sidebar, /mdi-chevron-up/)
|
||||||
|
assert.match(sidebarStyles, /--rail-motion-duration: 220ms/)
|
||||||
|
assert.match(sidebarStyles, /opacity var\(--rail-fade-duration\)/)
|
||||||
|
assert.match(sidebarStyles, /\.rail-user\s*\{[\s\S]*height:\s*72px;[\s\S]*grid-template-columns:\s*42px minmax\(0,\s*1fr\) 44px;/)
|
||||||
|
assert.match(sidebarStyles, /\.user-actions\s*\{[\s\S]*grid-template-columns:\s*44px;/)
|
||||||
|
|
||||||
|
assert.match(appStyles, /--sidebar-expanded-width:\s*304px/)
|
||||||
assert.match(appStyles, /--sidebar-collapsed-width: 64px/)
|
assert.match(appStyles, /--sidebar-collapsed-width: 64px/)
|
||||||
assert.match(appStyles, /\.app-sidebar\s*\{[^}]*transition:\s*width var\(--sidebar-motion\)/)
|
assert.match(appStyles, /\.app-sidebar\s*\{[^}]*transition:\s*width var\(--sidebar-motion\)/)
|
||||||
assert.match(appStyles, /\.app\.sidebar-collapsed\s+\.app-sidebar\s*\{\s*width:\s*var\(--sidebar-collapsed-width\)/)
|
assert.match(appStyles, /\.app\.sidebar-collapsed\s+\.app-sidebar\s*\{\s*width:\s*var\(--sidebar-collapsed-width\)/)
|
||||||
|
|||||||
90
web/tests/steward-field-completion-model.test.mjs
Normal file
90
web/tests/steward-field-completion-model.test.mjs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildStewardFieldCompletionContinuation,
|
||||||
|
buildStewardFieldCompletionRawText,
|
||||||
|
resolveStewardRuntimeFieldCompletion
|
||||||
|
} from '../src/views/scripts/stewardFieldCompletionModel.js'
|
||||||
|
|
||||||
|
test('steward field completion maps ontology travel type and anchors reason update', () => {
|
||||||
|
const continuation = buildStewardFieldCompletionContinuation(
|
||||||
|
{
|
||||||
|
currentTask: {
|
||||||
|
summary: '2月20-23日上海出差',
|
||||||
|
ontology_fields: {
|
||||||
|
expense_type: 'travel',
|
||||||
|
time_range: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海市'
|
||||||
|
},
|
||||||
|
missing_fields: ['reason']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'reason',
|
||||||
|
'辅助国网仿生产服务器部署'
|
||||||
|
)
|
||||||
|
|
||||||
|
const rawText = buildStewardFieldCompletionRawText({
|
||||||
|
preview: {
|
||||||
|
fields: {
|
||||||
|
applicationType: '',
|
||||||
|
time: '2026-02-20 至 2026-02-23',
|
||||||
|
location: '上海市',
|
||||||
|
reason: '',
|
||||||
|
days: '4天',
|
||||||
|
transportMode: '火车'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fieldKey: 'reason',
|
||||||
|
fieldLabel: '事由',
|
||||||
|
value: '辅助国网仿生产服务器部署',
|
||||||
|
continuation
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(continuation.currentTask.ontology_fields.reason, '辅助国网仿生产服务器部署')
|
||||||
|
assert.deepEqual(continuation.currentTask.missing_fields, [])
|
||||||
|
assert.match(rawText, /申请类型:差旅费用申请/)
|
||||||
|
assert.doesNotMatch(rawText, /申请类型:travel/)
|
||||||
|
assert.match(rawText, /用户已补充:事由:辅助国网仿生产服务器部署。/)
|
||||||
|
assert.match(rawText, /当前申请单字段的补充\/更新/)
|
||||||
|
assert.match(rawText, /不是新建申请或切换任务/)
|
||||||
|
assert.match(rawText, /不要把它改判为新的 IT 部署申请/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('steward runtime treats bare reason reply as current application field completion', () => {
|
||||||
|
const decision = resolveStewardRuntimeFieldCompletion(
|
||||||
|
'辅助国网仿生产服务器部署',
|
||||||
|
{
|
||||||
|
waiting_for: 'application_field_completion',
|
||||||
|
pending_application: {
|
||||||
|
message_id: 'application-preview-1',
|
||||||
|
ready_to_submit: false,
|
||||||
|
missing_fields: ['事由']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(decision, {
|
||||||
|
next_action: 'fill_current_application_field',
|
||||||
|
target_message_id: 'application-preview-1',
|
||||||
|
field_key: 'reason',
|
||||||
|
field_label: '事由',
|
||||||
|
field_value: '辅助国网仿生产服务器部署'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('steward runtime keeps multi-field free text ambiguous for normal decision flow', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveStewardRuntimeFieldCompletion(
|
||||||
|
'辅助国网仿生产服务器部署',
|
||||||
|
{
|
||||||
|
waiting_for: 'application_field_completion',
|
||||||
|
pending_application: {
|
||||||
|
message_id: 'application-preview-1',
|
||||||
|
missing_fields: ['地点', '事由']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
})
|
||||||
73
web/tests/steward-plan-message-copy.test.mjs
Normal file
73
web/tests/steward-plan-message-copy.test.mjs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildStewardPlanMessageText,
|
||||||
|
buildStewardSuggestedActions
|
||||||
|
} from '../src/views/scripts/stewardPlanModel.js'
|
||||||
|
|
||||||
|
test('steward plan summary uses warm guidance copy for application flow', () => {
|
||||||
|
const message = buildStewardPlanMessageText({
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
task_id: 'task-1',
|
||||||
|
task_type: 'expense_application',
|
||||||
|
title: '费用申请 差旅',
|
||||||
|
assigned_agent: 'application_assistant',
|
||||||
|
confirmation_required: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
next_action: 'confirm_create_application'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.match(message, /我先帮你把步骤理清楚/)
|
||||||
|
assert.match(message, /我先看了一下,你这次主要是 \*\*1 个事项\*\*/)
|
||||||
|
assert.match(message, /为了不让步骤混在一起/)
|
||||||
|
assert.match(message, /我会请申请助手先把申请单草稿整理出来/)
|
||||||
|
assert.match(message, /你看这个顺序是否合适/)
|
||||||
|
assert.match(message, /需要补充的信息会在具体步骤里再温和提醒你/)
|
||||||
|
assert.doesNotMatch(message, /我会这样推进/)
|
||||||
|
assert.doesNotMatch(message, /不会一次性把所有动作都执行掉/)
|
||||||
|
assert.doesNotMatch(message, /交给申请助手生成申请单核对结果/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('steward plan summary guides bare reimbursement intent into scene selection', () => {
|
||||||
|
const plan = {
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
task_id: 'task-reim-1',
|
||||||
|
task_type: 'reimbursement',
|
||||||
|
title: '费用报销 1',
|
||||||
|
assigned_agent: 'reimbursement_assistant',
|
||||||
|
confirmation_required: true,
|
||||||
|
ontology_fields: {
|
||||||
|
expense_type: 'other',
|
||||||
|
reason: '我要报销'
|
||||||
|
},
|
||||||
|
missing_fields: ['time_range', 'reason']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
confirmation_groups: [
|
||||||
|
{
|
||||||
|
confirmation_id: 'confirm-task-reim-1',
|
||||||
|
action_type: 'confirm_create_reimbursement_draft',
|
||||||
|
target_task_id: 'task-reim-1'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
next_action: 'confirm_task'
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = buildStewardPlanMessageText(plan)
|
||||||
|
|
||||||
|
assert.match(message, /我来带你发起报销/)
|
||||||
|
assert.match(message, /你现在只说了要报销/)
|
||||||
|
assert.match(message, /先选报销场景/)
|
||||||
|
assert.match(message, /差旅费、交通费、住宿费/)
|
||||||
|
assert.doesNotMatch(message, /步骤混在一起/)
|
||||||
|
assert.doesNotMatch(message, /核对“费用报销 1”/)
|
||||||
|
|
||||||
|
const [action] = buildStewardSuggestedActions(plan)
|
||||||
|
assert.equal(action.label, '确定,选择报销场景')
|
||||||
|
assert.match(action.description, /先进入报销助手选择具体费用类型/)
|
||||||
|
assert.equal(action.payload.carry_text, '我要报销')
|
||||||
|
})
|
||||||
@@ -2,9 +2,44 @@ import assert from 'node:assert/strict'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildStewardPlanMessageText,
|
||||||
buildStewardSuggestedActions
|
buildStewardSuggestedActions
|
||||||
} from '../src/views/scripts/stewardPlanModel.js'
|
} from '../src/views/scripts/stewardPlanModel.js'
|
||||||
|
|
||||||
|
test('steward pending flow confirmation renders extracted fields as table', () => {
|
||||||
|
const message = buildStewardPlanMessageText({
|
||||||
|
plan_id: 'steward-plan-pending-flow',
|
||||||
|
plan_status: 'needs_flow_confirmation',
|
||||||
|
next_action: 'confirm_flow',
|
||||||
|
pending_flow_confirmation: {
|
||||||
|
status: 'pending',
|
||||||
|
reason: '已先查询可关联申请单,当前仍需确认下一步。',
|
||||||
|
candidate_flows: [
|
||||||
|
{
|
||||||
|
flow_id: 'travel_application',
|
||||||
|
label: '先发起出差申请',
|
||||||
|
confidence: 0.82,
|
||||||
|
ontology_fields: {
|
||||||
|
time_range: '2026-02-20',
|
||||||
|
location: '上海',
|
||||||
|
expense_type: 'travel',
|
||||||
|
reason: '辅助国网仿生产服务器部署'
|
||||||
|
},
|
||||||
|
missing_fields: ['transport_mode']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.match(message, /\| 字段 \| 内容 \|/)
|
||||||
|
assert.match(message, /\| 费用类型 \| 差旅 \|/)
|
||||||
|
assert.match(message, /\| 发生时间 \| 2026-02-20 \|/)
|
||||||
|
assert.match(message, /\| 地点 \| 上海 \|/)
|
||||||
|
assert.match(message, /\| 事由 \| 辅助国网仿生产服务器部署 \|/)
|
||||||
|
assert.doesNotMatch(message, /已提取到:\*\*/)
|
||||||
|
assert.match(message, /请先点击下方/)
|
||||||
|
})
|
||||||
|
|
||||||
test('steward pending flow confirmation builds candidate actions', () => {
|
test('steward pending flow confirmation builds candidate actions', () => {
|
||||||
const actions = buildStewardSuggestedActions({
|
const actions = buildStewardSuggestedActions({
|
||||||
plan_id: 'steward-plan-pending-flow',
|
plan_id: 'steward-plan-pending-flow',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const OFF_TOPIC_PLAN = {
|
|||||||
attachment_groups: [],
|
attachment_groups: [],
|
||||||
confirmation_groups: [],
|
confirmation_groups: [],
|
||||||
candidate_flows: [],
|
candidate_flows: [],
|
||||||
summary: '这看起来跟财务任务没什么关系...',
|
summary: '很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。',
|
||||||
suggested_prompts: [
|
suggested_prompts: [
|
||||||
'我想要申请明天去北京出差3天,支撑客户现场实施',
|
'我想要申请明天去北京出差3天,支撑客户现场实施',
|
||||||
'我要报销上周去上海的高铁票',
|
'我要报销上周去上海的高铁票',
|
||||||
@@ -64,24 +64,60 @@ test('isOffTopicStewardPlan returns true only for off_topic plan status', () =>
|
|||||||
assert.equal(isOffTopicStewardPlan({}), false)
|
assert.equal(isOffTopicStewardPlan({}), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildStewardPlanMessageText renders friendly off_topic guidance', () => {
|
test('buildStewardPlanMessageText renders backend-provided off_topic summary verbatim', () => {
|
||||||
|
// off_topic 文案由后端(含 LLM)生成,前端只负责透传,不再拼接标题/引导
|
||||||
const text = buildStewardPlanMessageText(OFF_TOPIC_PLAN)
|
const text = buildStewardPlanMessageText(OFF_TOPIC_PLAN)
|
||||||
assert.match(text, /小财管家没看懂这件事/)
|
assert.equal(text, OFF_TOPIC_PLAN.summary)
|
||||||
// 推荐话术本身不在正文里展示,而是作为按钮单独渲染,避免重复。
|
// 推荐话术本身不在正文里展示,而是作为按钮单独渲染,避免重复。
|
||||||
for (const prompt of OFF_TOPIC_PLAN.suggested_prompts) {
|
for (const prompt of OFF_TOPIC_PLAN.suggested_prompts) {
|
||||||
assert.equal(text.includes(prompt), false, `正文不应包含推荐话术:${prompt}`)
|
assert.equal(text.includes(prompt), false, `正文不应包含推荐话术:${prompt}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('buildStewardPlanMessageText keeps off_topic branch ahead of pending flow branch', () => {
|
test('buildStewardPlanMessageText adapts greeting vs meaningless vs off_business summaries', () => {
|
||||||
// 即使 summary 缺省,也走 off_topic 分支而非默认任务文案
|
// 问候场景:礼貌回应主人
|
||||||
const text = buildStewardPlanMessageText({
|
const greetingText = buildStewardPlanMessageText({
|
||||||
plan_id: 'p-off-topic-default',
|
plan_id: 'p-greeting',
|
||||||
plan_status: 'off_topic',
|
plan_status: 'off_topic',
|
||||||
next_action: 'none',
|
next_action: 'none',
|
||||||
suggested_prompts: ['申请出差']
|
summary: '### 您好主人,很高兴为您服务\n\n请问您今天要办理什么业务?',
|
||||||
|
suggested_prompts: ['我想要申请明天去北京出差3天']
|
||||||
})
|
})
|
||||||
assert.match(text, /小财管家没看懂这件事/)
|
assert.match(greetingText, /您好主人/)
|
||||||
|
assert.match(greetingText, /请问您今天要办理什么业务/)
|
||||||
|
|
||||||
|
// 无意义场景:温和解释 + 引导换种说法
|
||||||
|
const meaninglessText = buildStewardPlanMessageText({
|
||||||
|
plan_id: 'p-meaningless',
|
||||||
|
plan_status: 'off_topic',
|
||||||
|
next_action: 'none',
|
||||||
|
summary: '### 这句话我暂时没识别到财务事项\n\n很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**。',
|
||||||
|
suggested_prompts: ['我想要申请明天去北京出差3天']
|
||||||
|
})
|
||||||
|
assert.match(meaninglessText, /这句话我暂时没识别到财务事项/)
|
||||||
|
assert.match(meaninglessText, /很抱歉主人/)
|
||||||
|
|
||||||
|
// 有意义但非业务场景:LLM 生成的文案(这里 mock 模拟)
|
||||||
|
const llmText = '### 抱歉主人,这句话我暂时帮不上忙\n\n主人聊的是天气,小财管家目前只能帮您整理**费用申请**和**费用报销**。'
|
||||||
|
const offBusinessText = buildStewardPlanMessageText({
|
||||||
|
plan_id: 'p-off-business',
|
||||||
|
plan_status: 'off_topic',
|
||||||
|
next_action: 'none',
|
||||||
|
summary: llmText,
|
||||||
|
suggested_prompts: ['我想要申请明天去北京出差3天']
|
||||||
|
})
|
||||||
|
assert.equal(offBusinessText, llmText)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildStewardPlanMessageText falls back to client template when summary is missing', () => {
|
||||||
|
// 后端 summary 缺失时,前端有兜底文案保证体验不空白
|
||||||
|
const text = buildStewardPlanMessageText({
|
||||||
|
plan_id: 'p-empty',
|
||||||
|
plan_status: 'off_topic',
|
||||||
|
next_action: 'none',
|
||||||
|
suggested_prompts: []
|
||||||
|
})
|
||||||
|
assert.match(text, /这句话我暂时没识别到财务事项/)
|
||||||
assert.match(text, /费用申请.*费用报销|费用报销.*费用申请/)
|
assert.match(text, /费用申请.*费用报销|费用报销.*费用申请/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
39
web/tests/topbar-ai-mode-switch.test.mjs
Normal file
39
web/tests/topbar-ai-mode-switch.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const topbar = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const topbarStyles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/components/top-bar.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
test('workbench topbar places the colorful AI mode button after the company switcher', () => {
|
||||||
|
assert.match(topbar, /<button class="company-switcher"[\s\S]*aria-label="切换公司"[\s\S]*<\/button>\s*<button[\s\S]*class="topbar-ai-mode-toggle"/)
|
||||||
|
assert.match(topbar, /class="topbar-ai-mode-toggle__glyph">AI<\/span>/)
|
||||||
|
assert.match(topbar, /@click="toggleTopbarWorkbenchMode"/)
|
||||||
|
assert.match(topbar, /:aria-pressed="isTopbarAiMode"/)
|
||||||
|
assert.match(topbar, /:title="topbarWorkbenchModeTitle"/)
|
||||||
|
assert.match(topbar, /workbenchMode:\s*\{[\s\S]*type:\s*String,[\s\S]*default:\s*'traditional'/)
|
||||||
|
assert.doesNotMatch(topbar, /const topbarWorkbenchMode = ref/)
|
||||||
|
assert.match(topbar, /const isTopbarAiMode = computed\(\(\) => props\.workbenchMode === 'ai'\)/)
|
||||||
|
assert.match(topbar, /const topbarWorkbenchModeTitle = computed/)
|
||||||
|
assert.match(topbar, /const showAiModeUtilityActions = computed\(\(\) => isTopbarAiMode\.value && !isWorkbench\.value\)/)
|
||||||
|
assert.match(topbar, /<div v-if="showAiModeUtilityActions" class="topbar-utility-actions"/)
|
||||||
|
assert.match(topbar, /function toggleTopbarWorkbenchMode\(\)/)
|
||||||
|
assert.match(topbar, /emit\('toggleWorkbenchMode'\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('topbar AI mode button keeps a circular colorful text treatment', () => {
|
||||||
|
assert.match(topbarStyles, /\.topbar-ai-mode-toggle\s*\{[\s\S]*width:\s*38px;[\s\S]*height:\s*38px;[\s\S]*border-radius:\s*50%;/)
|
||||||
|
assert.match(topbarStyles, /\.topbar-ai-mode-toggle\s*\{[\s\S]*conic-gradient\(from 210deg,[\s\S]*border-box;/)
|
||||||
|
assert.match(topbarStyles, /\.topbar-ai-mode-toggle__glyph\s*\{[\s\S]*linear-gradient\(135deg,[\s\S]*background-clip:\s*text;[\s\S]*letter-spacing:\s*0;/)
|
||||||
|
assert.match(topbarStyles, /\.topbar-ai-mode-toggle:hover,[\s\S]*\.topbar-ai-mode-toggle:focus-visible\s*\{[\s\S]*transform:\s*translateY\(-1px\);/)
|
||||||
|
assert.match(topbarStyles, /\.topbar-utility-actions\s*\{[\s\S]*display:\s*inline-flex;[\s\S]*justify-content:\s*flex-end;/)
|
||||||
|
assert.match(topbarStyles, /@media \(max-width: 960px\)[\s\S]*\.topbar-ai-mode-toggle\s*\{[\s\S]*width:\s*34px;[\s\S]*height:\s*34px;/)
|
||||||
|
assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.topbar-ai-mode-toggle\s*\{[\s\S]*flex:\s*0 0 34px;/)
|
||||||
|
})
|
||||||
@@ -454,6 +454,8 @@ test('guided flow is local until final confirmation or collected query handoff',
|
|||||||
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||||||
assert.match(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
assert.match(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
||||||
assert.match(suggestedActionsScript, /actionPayload\.carry_text/)
|
assert.match(suggestedActionsScript, /actionPayload\.carry_text/)
|
||||||
|
assert.match(createViewScript, /targetSessionType === SESSION_TYPE_EXPENSE[\s\S]*carryText === '我要报销'[\s\S]*pushExpenseSceneSelectionPrompt\(carryText\)/)
|
||||||
|
assert.match(suggestedActionsScript, /targetSessionType === SESSION_TYPE_EXPENSE[\s\S]*carryText === '我要报销'[\s\S]*pushExpenseSceneSelectionPrompt\(carryText\)/)
|
||||||
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
|
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
|
||||||
assert.match(submitComposerScript, /skipScopeGuard/)
|
assert.match(submitComposerScript, /skipScopeGuard/)
|
||||||
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
||||||
|
|||||||
@@ -300,19 +300,34 @@ test('risk cards carry structured business stage for approval advice filtering',
|
|||||||
test('stage risk advice card focuses on document risks without profile or budget boards', () => {
|
test('stage risk advice card focuses on document risks without profile or budget boards', () => {
|
||||||
assert.match(stageRiskAdviceCard, /employee-risk-decision-panel/)
|
assert.match(stageRiskAdviceCard, /employee-risk-decision-panel/)
|
||||||
assert.match(stageRiskAdviceCard, /综合审核结论/)
|
assert.match(stageRiskAdviceCard, /综合审核结论/)
|
||||||
assert.match(stageRiskAdviceCard, /建议结论/)
|
assert.match(stageRiskAdviceCard, /是否建议通过/)
|
||||||
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
|
assert.match(stageRiskAdviceCard, /\{\{ decisionBadgeLabel \}\}/)
|
||||||
|
assert.match(stageRiskAdviceCard, /employee-risk-review-summary/)
|
||||||
|
assert.match(stageRiskAdviceCard, /reviewSummaryItems/)
|
||||||
|
assert.match(stageRiskAdviceCard, /风险概览/)
|
||||||
|
assert.match(stageRiskAdviceCard, /重点依据/)
|
||||||
|
assert.match(stageRiskAdviceCard, /审核建议/)
|
||||||
|
assert.match(stageRiskAdviceCard, /stageRiskFactSummary/)
|
||||||
|
assert.match(stageRiskAdviceCard, /stageReviewBasisSummary/)
|
||||||
assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
|
assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
|
||||||
assert.match(stageRiskAdviceCard, /stageBasisTitle/)
|
assert.match(stageRiskAdviceCard, /stageBasisTitle/)
|
||||||
assert.match(stageRiskAdviceCard, /stageBasisHint/)
|
assert.match(stageRiskAdviceCard, /stageBasisHint/)
|
||||||
assert.match(stageRiskAdviceCard, /employee-risk-profile-section/)
|
assert.match(stageRiskAdviceCard, /employee-risk-profile-section/)
|
||||||
assert.match(stageRiskAdviceCard, /employee-risk-profile-list/)
|
assert.match(stageRiskAdviceCard, /employee-risk-profile-list/)
|
||||||
|
assert.match(stageRiskAdviceCard, /<details/)
|
||||||
|
assert.match(stageRiskAdviceCard, /申请审核建议/)
|
||||||
|
assert.match(stageRiskAdviceCard, /AI建议/)
|
||||||
|
assert.doesNotMatch(stageRiskAdviceCard, /报销审核建议/)
|
||||||
assert.match(stageRiskAdviceCard, /classifyReimbursementRiskCards/)
|
assert.match(stageRiskAdviceCard, /classifyReimbursementRiskCards/)
|
||||||
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
|
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
|
||||||
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
|
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
|
||||||
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
|
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
|
||||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(220px, 32%\);/)
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1\.15fr\) minmax\(220px, \.85fr\);/)
|
||||||
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: flex;[\s\S]*flex-wrap: wrap;/)
|
||||||
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*flex: 1 1 180px;/)
|
||||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
|
||||||
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row summary \{[\s\S]*cursor: pointer;/)
|
||||||
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title::after \{[\s\S]*content: '展开';/)
|
||||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
|
||||||
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
|
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
|
||||||
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
|
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
|
||||||
@@ -320,8 +335,8 @@ test('stage risk advice card focuses on document risks without profile or budget
|
|||||||
assert.match(stageRiskAdviceCard, /请核对已补充说明是否覆盖风险点/)
|
assert.match(stageRiskAdviceCard, /请核对已补充说明是否覆盖风险点/)
|
||||||
assert.match(stageRiskAdviceCard, /已补充异常说明/)
|
assert.match(stageRiskAdviceCard, /已补充异常说明/)
|
||||||
assert.match(stageRiskAdviceCard, /可按权限继续审批/)
|
assert.match(stageRiskAdviceCard, /可按权限继续审批/)
|
||||||
assert.match(stageRiskAdviceCard, /申请单风险依据/)
|
assert.match(stageRiskAdviceCard, /申请单关键依据/)
|
||||||
assert.match(stageRiskAdviceCard, /报销单风险依据/)
|
assert.match(stageRiskAdviceCard, /报销单关键依据/)
|
||||||
assert.doesNotMatch(stageRiskAdviceCard, /人员行为画像/)
|
assert.doesNotMatch(stageRiskAdviceCard, /人员行为画像/)
|
||||||
assert.doesNotMatch(stageRiskAdviceCard, /部门预算执行/)
|
assert.doesNotMatch(stageRiskAdviceCard, /部门预算执行/)
|
||||||
assert.doesNotMatch(stageRiskAdviceCard, /title: '说明与佐证'/)
|
assert.doesNotMatch(stageRiskAdviceCard, /title: '说明与佐证'/)
|
||||||
@@ -631,7 +646,9 @@ test('AI advice template renders grouped section titles with completion before r
|
|||||||
assert.match(detailViewScript, /const showCompactSafeAdvice = computed/)
|
assert.match(detailViewScript, /const showCompactSafeAdvice = computed/)
|
||||||
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
|
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
|
||||||
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
|
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
|
||||||
assert.match(detailViewScript, /return '报销风险提示'/)
|
assert.match(detailViewScript, /return '风险提示'/)
|
||||||
|
assert.match(detailViewScript, /return isEditableRequest\.value \? 'AI建议' : '风险提示'/)
|
||||||
|
assert.doesNotMatch(detailViewScript, /return '报销风险提示'/)
|
||||||
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)
|
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)
|
||||||
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
|
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
|
||||||
assert.match(detailViewScript, /buildEmployeeProfileAdviceItems\(employeeRiskProfile\.value\)/)
|
assert.match(detailViewScript, /buildEmployeeProfileAdviceItems\(employeeRiskProfile\.value\)/)
|
||||||
@@ -648,9 +665,13 @@ test('AI advice template renders grouped section titles with completion before r
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('AI advice risk section uses compact card styling hooks', () => {
|
test('AI advice risk section keeps compact risk prompt styling', () => {
|
||||||
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone, \{ 'is-highlighted': isHighlightedRiskCard\(card\) \}\]"/)
|
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone, \{ 'is-highlighted': isHighlightedRiskCard\(card\) \}\]"/)
|
||||||
|
assert.match(detailViewTemplate, /class="risk-advice-point"/)
|
||||||
assert.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
|
assert.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
|
||||||
|
assert.match(detailViewTemplate, /\{\{ card\.ruleBasis\[0\] \}\}/)
|
||||||
|
assert.doesNotMatch(detailViewTemplate, /risk-advice-detail-grid/)
|
||||||
|
assert.doesNotMatch(detailViewTemplate, /<dt>风险事实<\/dt>/)
|
||||||
assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
|
assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
|
||||||
assert.doesNotMatch(detailViewTemplate, /risk-advice-more/)
|
assert.doesNotMatch(detailViewTemplate, /risk-advice-more/)
|
||||||
assert.doesNotMatch(detailViewTemplate, /card\.tags\?\.length/)
|
assert.doesNotMatch(detailViewTemplate, /card\.tags\?\.length/)
|
||||||
@@ -667,6 +688,7 @@ test('AI advice risk section uses compact card styling hooks', () => {
|
|||||||
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
|
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
|
||||||
assert.doesNotMatch(detailViewStyle, /\.risk-note-tag/)
|
assert.doesNotMatch(detailViewStyle, /\.risk-note-tag/)
|
||||||
assert.match(detailViewStyle, /\.risk-advice-compact-meta span,\s*\.risk-advice-compact-meta em \{\s*margin: 0;/)
|
assert.match(detailViewStyle, /\.risk-advice-compact-meta span,\s*\.risk-advice-compact-meta em \{\s*margin: 0;/)
|
||||||
|
assert.doesNotMatch(detailViewStyle, /\.risk-advice-detail-grid/)
|
||||||
assert.doesNotMatch(detailViewStyle, /\.risk-advice-more/)
|
assert.doesNotMatch(detailViewStyle, /\.risk-advice-more/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
80
web/tests/workbench-ai-mode-expense-scene-action.test.mjs
Normal file
80
web/tests/workbench-ai-mode-expense-scene-action.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { buildExpenseSceneSelectionActions } from '../src/utils/expenseAssistantActions.js'
|
||||||
|
import { buildExpenseSceneSelectionMessage } from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||||
|
|
||||||
|
const aiMode = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
test('expense scene selection message asks for type first and mentions application gate', () => {
|
||||||
|
const text = buildExpenseSceneSelectionMessage('帮我发起一笔报销,并检查需要准备哪些票据材料。')
|
||||||
|
|
||||||
|
assert.match(text, /先选|选择.*报销类型|报销场景/)
|
||||||
|
assert.match(text, /差旅|招待|申请单|关联申请单/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('expense scene actions mark travel and meal as requiring application', () => {
|
||||||
|
const actions = buildExpenseSceneSelectionActions('帮我发起一笔报销,并检查需要准备哪些票据材料。')
|
||||||
|
const travel = actions.find((action) => action.payload.expense_type === 'travel')
|
||||||
|
const meal = actions.find((action) => action.payload.expense_type === 'meal')
|
||||||
|
const transport = actions.find((action) => action.payload.expense_type === 'transport')
|
||||||
|
|
||||||
|
assert.equal(travel.payload.requires_application_before_reimbursement, true)
|
||||||
|
assert.equal(travel.payload.next_session_type, 'application')
|
||||||
|
assert.equal(meal.payload.requires_application_before_reimbursement, true)
|
||||||
|
assert.equal(meal.payload.next_session_type, 'application')
|
||||||
|
assert.equal(transport.payload.requires_application_before_reimbursement, false)
|
||||||
|
assert.equal(transport.payload.next_session_type, 'expense')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode quick reimbursement card opens scene selection before steward plan', () => {
|
||||||
|
assert.match(
|
||||||
|
aiMode,
|
||||||
|
/function runAiModeAction\(item\) {[\s\S]{0,220}pushInlineExpenseSceneSelectionPrompt\(item\.prompt, item\.label\)/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode expense scene selection stays in the inline conversation without opening the create view', () => {
|
||||||
|
assert.match(aiMode, /actionType === 'select_expense_type'/)
|
||||||
|
assert.doesNotMatch(aiMode, /emit\('open-assistant'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode offers an inline application shortcut when no candidate application exists', () => {
|
||||||
|
assert.match(aiMode, /!candidates\.length/)
|
||||||
|
assert.match(aiMode, /ai_application_start_inline/)
|
||||||
|
assert.match(aiMode, /buildRequiredApplicationMissingText/)
|
||||||
|
assert.match(aiMode, /function startAiApplicationDraft/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode steward reimbursement action opens expense scene selection locally', () => {
|
||||||
|
assert.match(aiMode, /buildExpenseSceneSelectionMessage/)
|
||||||
|
assert.match(aiMode, /buildExpenseSceneSelectionActions/)
|
||||||
|
assert.match(aiMode, /SESSION_TYPE_EXPENSE/)
|
||||||
|
assert.match(aiMode, /function pushInlineExpenseSceneSelectionPrompt/)
|
||||||
|
assert.match(aiMode, /payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
|
||||||
|
assert.match(aiMode, /pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)/)
|
||||||
|
assert.match(
|
||||||
|
aiMode,
|
||||||
|
/SESSION_TYPE_EXPENSE[\s\S]{0,140}pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)[\s\S]{0,40}return/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode attaches required application lookup result before steward planning', () => {
|
||||||
|
assert.match(aiMode, /async function attachAiRequiredApplicationGate\(planRequest, prompt\)/)
|
||||||
|
assert.match(aiMode, /fetchExpenseClaims\(\)/)
|
||||||
|
assert.match(aiMode, /filterRequiredApplicationCandidates\(claims, 'travel', currentUser\.value \|\| \{\}\)/)
|
||||||
|
assert.match(aiMode, /required_application_gate/)
|
||||||
|
assert.match(aiMode, /await attachAiRequiredApplicationGate\(planRequest, prompt\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode automatically continues required application gate decisions from steward plan', () => {
|
||||||
|
assert.match(aiMode, /function continueAiRequiredApplicationGateFromPlan\(normalizedPlan\)/)
|
||||||
|
assert.match(aiMode, /flow\.flowId === 'travel_application'[\s\S]*startAiApplicationDraft\('travel', '差旅费'/)
|
||||||
|
assert.match(aiMode, /flow\.flowId === 'travel_reimbursement'[\s\S]*startAiExpenseDraft\('travel', '差旅费', true/)
|
||||||
|
assert.match(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan\)/)
|
||||||
|
})
|
||||||
386
web/tests/workbench-ai-mode-switch.test.mjs
Normal file
386
web/tests/workbench-ai-mode-switch.test.mjs
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { readFileSync, statSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
function readSource(path) {
|
||||||
|
try {
|
||||||
|
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRuleBody(source, selector) {
|
||||||
|
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const match = source.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\}`))
|
||||||
|
return match?.[1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function countGifFrameBlocks(buffer) {
|
||||||
|
let count = 0
|
||||||
|
for (let index = 0; index < buffer.length - 2; index += 1) {
|
||||||
|
if (buffer[index] === 0x21 && buffer[index + 1] === 0xf9 && buffer[index + 2] === 0x04) {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureGifMotion(assetPath) {
|
||||||
|
const script = `
|
||||||
|
from PIL import Image, ImageSequence
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
image = Image.open(sys.argv[1])
|
||||||
|
frames = [frame.convert("RGB").resize((64, 64)) for frame in ImageSequence.Iterator(image)]
|
||||||
|
|
||||||
|
def delta(left, right):
|
||||||
|
left_pixels = left.load()
|
||||||
|
right_pixels = right.load()
|
||||||
|
total = 0
|
||||||
|
for y in range(64):
|
||||||
|
for x in range(64):
|
||||||
|
a = left_pixels[x, y]
|
||||||
|
b = right_pixels[x, y]
|
||||||
|
total += abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])
|
||||||
|
return total / (64 * 64 * 3)
|
||||||
|
|
||||||
|
adjacent = [delta(frames[index], frames[index + 1]) for index in range(len(frames) - 1)]
|
||||||
|
adjacent_sorted = sorted(adjacent)
|
||||||
|
median = adjacent_sorted[len(adjacent_sorted) // 2]
|
||||||
|
print(json.dumps({
|
||||||
|
"medianAdjacentDelta": median,
|
||||||
|
"seamDelta": delta(frames[-1], frames[0])
|
||||||
|
}))
|
||||||
|
`
|
||||||
|
return JSON.parse(execFileSync('python3', ['-', assetPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: script
|
||||||
|
}).trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureGifDuration(assetPath) {
|
||||||
|
const script = `
|
||||||
|
from PIL import Image
|
||||||
|
import sys
|
||||||
|
|
||||||
|
image = Image.open(sys.argv[1])
|
||||||
|
total = 0
|
||||||
|
for index in range(getattr(image, "n_frames", 1)):
|
||||||
|
image.seek(index)
|
||||||
|
total += image.info.get("duration", 0)
|
||||||
|
print(total)
|
||||||
|
`
|
||||||
|
return Number(execFileSync('python3', ['-', assetPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: script
|
||||||
|
}).trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureOrbAssetPresentation(assetPath) {
|
||||||
|
const script = `
|
||||||
|
from PIL import Image
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
image = Image.open(sys.argv[1])
|
||||||
|
frame_count = getattr(image, "n_frames", 1)
|
||||||
|
width, height = image.size
|
||||||
|
minimum_corner_luma = 255
|
||||||
|
maximum_corner_luma = 0
|
||||||
|
minimum_background_similarity_ratio = 1
|
||||||
|
minimum_foreground_width_ratio = 1
|
||||||
|
minimum_foreground_height_ratio = 1
|
||||||
|
|
||||||
|
for index in range(frame_count):
|
||||||
|
if frame_count > 1:
|
||||||
|
image.seek(index)
|
||||||
|
rgb = image.convert("RGB")
|
||||||
|
corners = [
|
||||||
|
rgb.getpixel((0, 0)),
|
||||||
|
rgb.getpixel((width - 1, 0)),
|
||||||
|
rgb.getpixel((0, height - 1)),
|
||||||
|
rgb.getpixel((width - 1, height - 1)),
|
||||||
|
]
|
||||||
|
corner_lumas = [sum(pixel) / 3 for pixel in corners]
|
||||||
|
minimum_corner_luma = min(minimum_corner_luma, min(corner_lumas))
|
||||||
|
maximum_corner_luma = max(maximum_corner_luma, max(corner_lumas))
|
||||||
|
background = tuple(round(sum(pixel[channel] for pixel in corners) / len(corners)) for channel in range(3))
|
||||||
|
foreground_mask = Image.new("L", (width, height), 0)
|
||||||
|
foreground_pixels = foreground_mask.load()
|
||||||
|
background_similarity = 0
|
||||||
|
rgb_pixels = rgb.load()
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
pixel = rgb_pixels[x, y]
|
||||||
|
diff = sum(abs(pixel[channel] - background[channel]) for channel in range(3))
|
||||||
|
if diff > 22:
|
||||||
|
foreground_pixels[x, y] = 255
|
||||||
|
if diff <= 12:
|
||||||
|
background_similarity += 1
|
||||||
|
foreground_box = foreground_mask.getbbox()
|
||||||
|
if foreground_box:
|
||||||
|
minimum_foreground_width_ratio = min(
|
||||||
|
minimum_foreground_width_ratio,
|
||||||
|
(foreground_box[2] - foreground_box[0]) / width
|
||||||
|
)
|
||||||
|
minimum_foreground_height_ratio = min(
|
||||||
|
minimum_foreground_height_ratio,
|
||||||
|
(foreground_box[3] - foreground_box[1]) / height
|
||||||
|
)
|
||||||
|
minimum_background_similarity_ratio = min(
|
||||||
|
minimum_background_similarity_ratio,
|
||||||
|
background_similarity / (width * height)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"minimumCornerLuma": minimum_corner_luma,
|
||||||
|
"maximumCornerLuma": maximum_corner_luma,
|
||||||
|
"minimumBackgroundSimilarityRatio": minimum_background_similarity_ratio,
|
||||||
|
"minimumForegroundWidthRatio": minimum_foreground_width_ratio,
|
||||||
|
"minimumForegroundHeightRatio": minimum_foreground_height_ratio,
|
||||||
|
"width": width,
|
||||||
|
"height": height
|
||||||
|
}))
|
||||||
|
`
|
||||||
|
return JSON.parse(execFileSync('python3', ['-', assetPath], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
input: script
|
||||||
|
}).trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const appShell = readSource('../src/views/AppShellRouteView.vue')
|
||||||
|
const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
|
||||||
|
const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
|
||||||
|
const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css')
|
||||||
|
const workbenchViewStyles = readSource('../src/assets/styles/views/personal-workbench-view.css')
|
||||||
|
const appStyles = readSource('../src/assets/styles/app.css')
|
||||||
|
const aiBackgroundRule = readRuleBody(aiModeStyles, '.workbench-ai-mode::after')
|
||||||
|
const orbRule = readRuleBody(aiModeStyles, '.workbench-ai-orb')
|
||||||
|
const orbImageRule = readRuleBody(aiModeStyles, '.workbench-ai-orb__image')
|
||||||
|
const composerRule = readRuleBody(aiModeStyles, '.workbench-ai-composer')
|
||||||
|
const composerTextareaRule = readRuleBody(aiModeStyles, '.workbench-ai-composer textarea')
|
||||||
|
const orbIconAsset = fileURLToPath(
|
||||||
|
new URL('../src/assets/workbench-ai-mode-orb-icon.gif', import.meta.url)
|
||||||
|
)
|
||||||
|
const orbIconPngAsset = fileURLToPath(
|
||||||
|
new URL('../src/assets/workbench-ai-mode-orb-icon.png', import.meta.url)
|
||||||
|
)
|
||||||
|
const orbIconBuffer = readFileSync(orbIconAsset)
|
||||||
|
|
||||||
|
test('app shell owns the workbench mode and wires it through topbar and content', () => {
|
||||||
|
assert.match(appShell, /const workbenchMode = ref\('traditional'\)/)
|
||||||
|
assert.match(appShell, /function toggleWorkbenchMode\(\)/)
|
||||||
|
assert.match(appShell, /const nextMode = workbenchMode\.value === 'ai' \? 'traditional' : 'ai'/)
|
||||||
|
assert.match(appShell, /sidebarCollapsedBeforeAiMode\.value = sidebarCollapsed\.value/)
|
||||||
|
assert.match(appShell, /workbenchMode\.value = nextMode/)
|
||||||
|
assert.match(appShell, /sidebarCollapsed\.value = sidebarCollapsedBeforeAiMode\.value/)
|
||||||
|
assert.match(appShell, /<TopBar[\s\S]*:workbench-mode="workbenchMode"[\s\S]*@toggle-workbench-mode="toggleWorkbenchMode"/)
|
||||||
|
assert.match(appShell, /<PersonalWorkbenchView[\s\S]*:workbench-mode="workbenchMode"/)
|
||||||
|
assert.match(appShell, /const isAiShellMode = computed\(\(\) => workbenchMode\.value === 'ai'\)/)
|
||||||
|
assert.match(appShell, /const isWorkbenchAiMode = computed\(\(\) => activeView\.value === 'workbench' && workbenchMode\.value === 'ai'\)/)
|
||||||
|
assert.match(appShell, /'workbench-ai-sidebar-active': isAiShellMode/)
|
||||||
|
assert.match(appShell, /'workbench-workarea-ai-mode': isWorkbenchAiMode/)
|
||||||
|
assert.match(appStyles, /\.workarea\.workbench-workarea\.workbench-workarea-ai-mode\s*\{[\s\S]*padding:\s*0;[\s\S]*background:\s*transparent;/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('personal workbench view swaps the traditional dashboard with the AI mode screen', () => {
|
||||||
|
assert.match(workbenchView, /import PersonalWorkbenchAiMode from '\.\.\/components\/business\/PersonalWorkbenchAiMode\.vue'/)
|
||||||
|
assert.match(workbenchView, /<Transition[\s\S]*name="workbench-mode-fade"[\s\S]*mode="out-in"/)
|
||||||
|
assert.match(workbenchView, /<PersonalWorkbenchAiMode[\s\S]*v-if="workbenchMode === 'ai'"[\s\S]*key="ai"/)
|
||||||
|
assert.match(workbenchView, /:sidebar-command="aiSidebarCommand"/)
|
||||||
|
assert.match(workbenchView, /@conversation-change="emit\('ai-conversation-change', \$event\)"/)
|
||||||
|
assert.match(workbenchView, /@conversation-history-change="emit\('ai-conversation-history-change', \$event\)"/)
|
||||||
|
assert.match(workbenchView, /<PersonalWorkbench[\s\S]*v-else[\s\S]*key="traditional"/)
|
||||||
|
assert.match(workbenchView, /workbenchMode:\s*\{[\s\S]*type:\s*String,[\s\S]*default:\s*'traditional'/)
|
||||||
|
assert.match(workbenchView, /aiSidebarCommand:\s*\{[\s\S]*type:\s*Object/)
|
||||||
|
assert.match(workbenchView, /personal-workbench-view\.css/)
|
||||||
|
assert.match(workbenchViewStyles, /\.workbench-mode-fade-enter-active,[\s\S]*\.workbench-mode-fade-leave-active\s*\{[\s\S]*transition:/)
|
||||||
|
assert.match(workbenchViewStyles, /\.workbench-mode-fade-enter-from,[\s\S]*\.workbench-mode-fade-leave-to\s*\{[\s\S]*opacity:\s*0;[\s\S]*transform:\s*translateY\(10px\) scale\(0\.992\);/)
|
||||||
|
assert.match(workbenchViewStyles, /@media \(prefers-reduced-motion:\s*reduce\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode screen follows the approved reference structure', () => {
|
||||||
|
assert.match(aiMode, /personal-workbench-ai-mode\.css/)
|
||||||
|
assert.doesNotMatch(aiMode, /workbench-ai-mode-robot-bg\.png/)
|
||||||
|
assert.match(aiMode, /workbench-ai-mode-orb-icon\.gif/)
|
||||||
|
assert.match(aiMode, /<img[\s\S]*class="workbench-ai-orb__image"/)
|
||||||
|
assert.match(aiMode, /小财管家/)
|
||||||
|
assert.match(aiMode, /我是您的小财管家/)
|
||||||
|
assert.match(aiMode, /placeholder="今天我能帮您做点什么?"/)
|
||||||
|
assert.match(aiMode, /rows="3"/)
|
||||||
|
assert.match(aiMode, /workbench-ai-composer-toolbar/)
|
||||||
|
assert.match(aiMode, /Axiom Ultra 3\.1/)
|
||||||
|
assert.match(aiMode, /mdi mdi-calendar-range/)
|
||||||
|
assert.match(aiMode, /workbench-ai-date-popover/)
|
||||||
|
assert.match(aiMode, /type="date"/)
|
||||||
|
assert.doesNotMatch(aiMode, /mdi mdi-web/)
|
||||||
|
assert.match(aiMode, /mdi mdi-microphone-outline/)
|
||||||
|
assert.match(aiMode, /mdi mdi-arrow-up/)
|
||||||
|
assert.match(aiMode, /快速开始/)
|
||||||
|
assert.match(aiMode, /action-icon-wrapper/)
|
||||||
|
assert.match(aiMode, /发起报销/)
|
||||||
|
assert.match(aiMode, /查询预算/)
|
||||||
|
assert.match(aiMode, /解释制度/)
|
||||||
|
assert.match(aiMode, /催办审批/)
|
||||||
|
assert.match(aiMode, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
|
||||||
|
assert.match(aiMode, /@submit\.prevent="submitAiModePrompt"/)
|
||||||
|
assert.equal((aiMode.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 2)
|
||||||
|
assert.match(aiMode, /class="workbench-ai-conversation"/)
|
||||||
|
assert.match(aiMode, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
|
||||||
|
assert.match(aiMode, /workbench-ai-answer-card/)
|
||||||
|
assert.match(aiMode, /workbench-ai-answer-markdown/)
|
||||||
|
assert.match(aiMode, /v-html="renderInlineMarkdown\(message\.content\)"/)
|
||||||
|
assert.match(aiMode, /workbench-ai-message-actions/)
|
||||||
|
assert.match(aiMode, /workbench-ai-conversation-actions/)
|
||||||
|
assert.match(aiMode, /scrollInlineConversationToTop/)
|
||||||
|
assert.match(aiMode, /requestDeleteCurrentConversation/)
|
||||||
|
assert.match(aiMode, /confirmDeleteConversation/)
|
||||||
|
assert.match(aiMode, /workbench-ai-confirm-dialog/)
|
||||||
|
assert.match(aiMode, /workbench-ai-thinking-toggle/)
|
||||||
|
assert.match(aiMode, /小财业务思考/)
|
||||||
|
assert.match(aiMode, /class="workbench-ai-thinking-expanded"/)
|
||||||
|
assert.match(aiMode, /class="workbench-ai-thinking-collapse-btn"/)
|
||||||
|
assert.match(aiMode, /class="workbench-ai-thinking-collapse-btn"[\s\S]*@click="toggleInlineThinking\(message\)"/)
|
||||||
|
assert.doesNotMatch(aiMode, /:disabled="message\.pending"/)
|
||||||
|
assert.match(aiMode, /isInlineThinkingExpanded/)
|
||||||
|
assert.match(aiMode, /toggleInlineThinking/)
|
||||||
|
assert.match(aiMode, /const thinkingCollapsedMessageIds = ref\(new Set\(\)\)/)
|
||||||
|
assert.match(aiMode, /thinkingCollapsedMessageIds\.value\.has\(message\.id\)/)
|
||||||
|
assert.match(aiMode, /nextCollapsedIds\.add\(message\.id\)/)
|
||||||
|
assert.match(aiMode, /nextCollapsedIds\.delete\(message\.id\)/)
|
||||||
|
assert.match(aiMode, /message\.pending && !hasInlineThinking\(message\)/)
|
||||||
|
assert.doesNotMatch(aiMode, /小财管家正在思考/)
|
||||||
|
assert.doesNotMatch(aiMode, /思考过程/)
|
||||||
|
assert.doesNotMatch(aiMode, /message\.pending \?/)
|
||||||
|
assert.match(aiMode, /placeholder="继续和小财管家对话\.\.\."/)
|
||||||
|
assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
|
||||||
|
assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/)
|
||||||
|
assert.match(aiMode, /import \{ useWorkbenchComposerDate \} from '\.\.\/\.\.\/composables\/useWorkbenchComposerDate\.js'/)
|
||||||
|
assert.match(aiMode, /loadAiWorkbenchConversationHistory/)
|
||||||
|
assert.match(aiMode, /saveAiWorkbenchConversation/)
|
||||||
|
assert.match(aiMode, /deleteAiWorkbenchConversation/)
|
||||||
|
assert.match(aiMode, /import \{ renderMarkdown \} from '\.\.\/\.\.\/utils\/markdown\.js'/)
|
||||||
|
assert.match(aiMode, /buildStewardPlanRequest/)
|
||||||
|
assert.match(aiMode, /buildStewardPlanMessageText/)
|
||||||
|
assert.match(aiMode, /buildStewardSuggestedActions/)
|
||||||
|
assert.match(aiMode, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change'\]\)/)
|
||||||
|
assert.match(aiMode, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
|
||||||
|
assert.match(aiMode, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
|
||||||
|
assert.match(aiMode, /persistCurrentConversation\(\)/)
|
||||||
|
assert.match(aiMode, /refreshConversationHistory\(\)/)
|
||||||
|
assert.match(aiMode, /fetchStewardPlanStream\(/)
|
||||||
|
assert.match(aiMode, /fetchStewardPlan\(/)
|
||||||
|
assert.match(aiMode, /const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6/)
|
||||||
|
assert.match(aiMode, /function updateInlineMessageContent\(message, content\)/)
|
||||||
|
assert.match(aiMode, /async function streamInlineAssistantContent\(messageId, content\)/)
|
||||||
|
assert.match(aiMode, /const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow\(normalizedPlan\)/)
|
||||||
|
assert.match(aiMode, /const finalMessageText = requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateAutoMessage\(normalizedPlan, requiredApplicationContinuationFlow\)[\s\S]*buildStewardPlanMessageText\(plan\)/)
|
||||||
|
assert.match(aiMode, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/)
|
||||||
|
assert.match(aiMode, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
|
||||||
|
assert.doesNotMatch(aiMode, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
||||||
|
assert.doesNotMatch(aiMode, /runOrchestrator\(/)
|
||||||
|
assert.doesNotMatch(aiMode, /buildFallbackAnswer/)
|
||||||
|
assert.doesNotMatch(aiMode, /已使用本地回复/)
|
||||||
|
assert.doesNotMatch(aiMode, /emit\('open-assistant'/)
|
||||||
|
assert.match(aiModeStyles, /--ai-theme-rgb:\s*var\(--theme-primary-rgb/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-mode\s*\{[\s\S]*min-height:\s*100%;[\s\S]*background:/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-mode\.has-conversation\s*\{[\s\S]*place-items:\s*stretch;[\s\S]*padding:\s*0;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-composer\s*\{[\s\S]*border-radius:\s*20px;[\s\S]*box-shadow:/)
|
||||||
|
assert.match(composerRule, /min-height:\s*154px;/)
|
||||||
|
assert.match(composerRule, /grid-template-rows:\s*minmax\(80px,\s*1fr\) auto;/)
|
||||||
|
assert.match(composerTextareaRule, /min-height:\s*80px;/)
|
||||||
|
assert.doesNotMatch(aiModeStyles, /--workbench-ai-robot-image/)
|
||||||
|
assert.match(aiBackgroundRule, /inset:\s*0;/)
|
||||||
|
assert.match(aiBackgroundRule, /linear-gradient\(90deg,\s*rgba\(var\(--ai-theme-rgb\)/)
|
||||||
|
assert.match(aiBackgroundRule, /background-size:\s*56px 56px,\s*56px 56px,\s*auto,\s*auto;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-orb\s*\{[\s\S]*border-radius:\s*50%;/)
|
||||||
|
assert.match(orbRule, /rgba\(255,\s*255,\s*255,\s*0\.98\)/)
|
||||||
|
assert.match(orbRule, /rgba\(47,\s*124,\s*255,\s*0\.18\)/)
|
||||||
|
assert.match(orbRule, /width:\s*clamp\(118px,\s*8vw,\s*132px\);/)
|
||||||
|
assert.match(orbRule, /height:\s*clamp\(118px,\s*8vw,\s*132px\);/)
|
||||||
|
assert.match(orbRule, /animation:\s*workbenchAiControlIn/)
|
||||||
|
assert.match(orbImageRule, /width:\s*100%;/)
|
||||||
|
assert.match(orbImageRule, /height:\s*100%;/)
|
||||||
|
assert.match(orbImageRule, /object-fit:\s*contain;/)
|
||||||
|
assert.match(orbImageRule, /object-position:\s*center center;/)
|
||||||
|
assert.doesNotMatch(orbImageRule, /transform:/)
|
||||||
|
assert.match(aiModeStyles, /@keyframes workbenchAiControlIn\s*\{[\s\S]*opacity:\s*0;[\s\S]*translateY\(18px\)[\s\S]*opacity:\s*1;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-copy\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-composer\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-composer textarea\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-icon-btn\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-send-btn\s*\{[\s\S]*animation:\s*workbenchAiControlIn/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-action:nth-child\(4\)\s*\{[\s\S]*animation-delay:\s*520ms;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-conversation\s*\{[\s\S]*grid-template-rows:\s*minmax\(0,\s*1fr\) auto;/)
|
||||||
|
assert.match(aiMode, /const inlineConversationAutoScrollPinned = ref\(true\)/)
|
||||||
|
assert.match(aiMode, /const INLINE_AUTO_SCROLL_THRESHOLD = 96/)
|
||||||
|
assert.match(aiMode, /const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260/)
|
||||||
|
assert.match(aiMode, /function isInlineConversationNearBottom\(\)/)
|
||||||
|
assert.match(aiMode, /function handleInlineConversationScroll\(\)\s*\{[\s\S]*inlineConversationAutoScrollPinned\.value = isInlineConversationNearBottom\(\)[\s\S]*\}/)
|
||||||
|
assert.match(aiMode, /function forceInlineConversationToBottom\(\)/)
|
||||||
|
assert.match(aiMode, /el\.scrollTop = el\.scrollHeight/)
|
||||||
|
assert.match(aiMode, /function scrollInlineConversationToBottom\(options = \{\}\)/)
|
||||||
|
assert.match(aiMode, /const shouldScroll = options\.force !== false/)
|
||||||
|
assert.match(aiMode, /if \(!shouldScroll\) \{[\s\S]*return[\s\S]*\}/)
|
||||||
|
assert.match(aiMode, /window\.requestAnimationFrame\(\(\) => \{[\s\S]*forceInlineConversationToBottom\(\)[\s\S]*\}\)/)
|
||||||
|
assert.match(aiMode, /window\.setTimeout\(\(\) => \{[\s\S]*if \(inlineConversationAutoScrollPinned\.value\) \{[\s\S]*forceInlineConversationToBottom\(\)[\s\S]*\}[\s\S]*\}, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS\)/)
|
||||||
|
assert.match(aiMode, /const shouldAutoScroll = inlineConversationAutoScrollPinned\.value[\s\S]*updateInlineMessageContent\(message, streamedContent\)[\s\S]*scrollInlineConversationToBottom\(\{ force: shouldAutoScroll \}\)/)
|
||||||
|
assert.match(aiMode, /const shouldAutoScroll = inlineConversationAutoScrollPinned\.value[\s\S]*appendInlineMessageContent\(message, data\.delta \|\| data\.content \|\| data\.text \|\| ''\)[\s\S]*scrollInlineConversationToBottom\(\{ force: shouldAutoScroll \}\)/)
|
||||||
|
assert.match(aiMode, /inlineConversationAutoScrollPinned\.value = true[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user', cleanPrompt\)\)/)
|
||||||
|
assert.match(aiMode, /function openInlineRecentConversation\(item = \{\}\) \{[\s\S]*inlineConversationAutoScrollPinned\.value = true[\s\S]*conversationMessages\.value =/)
|
||||||
|
assert.doesNotMatch(aiMode, /scrollTo\(\{ top: el\.scrollHeight, behavior: 'smooth' \}\)/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-thread\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-direction:\s*column;[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-thread\s*>\s*:first-child\s*\{[\s\S]*margin-top:\s*auto;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-message\s*\{[\s\S]*flex:\s*0 0 auto;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-empty-thread\s*\{[\s\S]*flex:\s*0 0 auto;/)
|
||||||
|
assert.doesNotMatch(aiModeStyles, /align-content:\s*end;/)
|
||||||
|
assert.doesNotMatch(aiModeStyles, /\.workbench-ai-thread\s*\{[\s\S]*scroll-behavior:\s*smooth;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-thread::-webkit-scrollbar\s*\{[\s\S]*display:\s*none;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-conversation-bottom\s*\{[\s\S]*position:\s*relative;[\s\S]*z-index:\s*6;/)
|
||||||
|
assert.doesNotMatch(aiModeStyles, /\.workbench-ai-conversation-bottom\s*\{[\s\S]*position:\s*sticky;/)
|
||||||
|
assert.doesNotMatch(aiModeStyles, /\.workbench-ai-conversation-bottom\s*\{[\s\S]*bottom:\s*0;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-conversation-bottom::before\s*\{[\s\S]*display:\s*none;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-thinking-panel\s*\{[\s\S]*display:\s*grid;[\s\S]*border:\s*1px solid rgba\(191,\s*219,\s*254,\s*0\.58\);/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-thinking-toggle\s*\{[\s\S]*border:\s*0;[\s\S]*background:\s*transparent;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-thinking-list\s*\{[\s\S]*border:\s*0;[\s\S]*background:\s*transparent;[\s\S]*overflow:\s*visible;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-thinking-item\s*\{[\s\S]*grid-template-columns:\s*18px minmax\(0,\s*1fr\);/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-thinking-dot\s*\{[\s\S]*justify-self:\s*center;/)
|
||||||
|
assert.doesNotMatch(aiModeStyles, /\.workbench-ai-thinking-collapse-btn:disabled/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-thinking-collapse-enter-active,[\s\S]*\.workbench-ai-thinking-collapse-leave-active\s*\{[\s\S]*max-height 220ms ease/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-confirm-dialog\s*\{[\s\S]*border-radius:\s*18px;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-answer-card\s*\{[\s\S]*box-shadow:\s*none;[\s\S]*backdrop-filter:\s*none;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown\s*\{[\s\S]*line-height:\s*1\.86;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(h3\)\s*\{[\s\S]*font-size:\s*21px;/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-date-popover\s*\{[\s\S]*animation:\s*workbenchAiPopoverIn/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-send-btn:not\(:disabled\)\s*\{[\s\S]*linear-gradient\(135deg,[\s\S]*#1d4ed8/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-composer--inline\s*\{[\s\S]*min-height:\s*126px;[\s\S]*box-shadow:\s*none;/)
|
||||||
|
assert.match(aiModeStyles, /@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*\.workbench-ai-action,[\s\S]*\.workbench-ai-message,[\s\S]*\.workbench-ai-composer--inline,[\s\S]*\.workbench-ai-date-popover,[\s\S]*\.workbench-ai-thinking-dot\s*\{[\s\S]*animation:\s*none;/)
|
||||||
|
assert.ok(statSync(orbIconAsset).size > 100 * 1024)
|
||||||
|
assert.ok(statSync(orbIconAsset).size < 3 * 1024 * 1024)
|
||||||
|
assert.ok(statSync(orbIconPngAsset).size > 100 * 1024)
|
||||||
|
assert.equal(orbIconBuffer.subarray(0, 6).toString('ascii'), 'GIF89a')
|
||||||
|
assert.ok(countGifFrameBlocks(orbIconBuffer) >= 120)
|
||||||
|
const gifMotion = measureGifMotion(orbIconAsset)
|
||||||
|
assert.ok(gifMotion.seamDelta > gifMotion.medianAdjacentDelta * 0.35)
|
||||||
|
assert.ok(gifMotion.seamDelta < gifMotion.medianAdjacentDelta * 1.8)
|
||||||
|
assert.ok(measureGifDuration(orbIconAsset) >= 8000)
|
||||||
|
assert.ok(measureGifDuration(orbIconAsset) / countGifFrameBlocks(orbIconBuffer) <= 75)
|
||||||
|
const gifPresentation = measureOrbAssetPresentation(orbIconAsset)
|
||||||
|
assert.equal(gifPresentation.width, 192)
|
||||||
|
assert.equal(gifPresentation.height, 192)
|
||||||
|
assert.ok(gifPresentation.minimumCornerLuma > 225)
|
||||||
|
assert.ok(gifPresentation.maximumCornerLuma < 250)
|
||||||
|
assert.ok(gifPresentation.minimumBackgroundSimilarityRatio > 0.25)
|
||||||
|
assert.ok(gifPresentation.minimumForegroundWidthRatio > 0.9)
|
||||||
|
assert.ok(gifPresentation.minimumForegroundHeightRatio > 0.9)
|
||||||
|
const pngPresentation = measureOrbAssetPresentation(orbIconPngAsset)
|
||||||
|
assert.ok(pngPresentation.minimumCornerLuma > 225)
|
||||||
|
assert.ok(pngPresentation.maximumCornerLuma < 250)
|
||||||
|
assert.ok(pngPresentation.minimumBackgroundSimilarityRatio > 0.25)
|
||||||
|
assert.ok(pngPresentation.minimumForegroundWidthRatio > 0.9)
|
||||||
|
assert.ok(pngPresentation.minimumForegroundHeightRatio > 0.9)
|
||||||
|
})
|
||||||
@@ -142,15 +142,29 @@ test('workbench model routing maps ontology result before entering assistant', (
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench ambiguous travel flow uses steward fast path before ontology parsing', () => {
|
test('workbench smart entry blocks unsupported non-business input before ontology parsing', () => {
|
||||||
const fastPathIndex = appShellComposable.indexOf(
|
const openSmartEntryStart = appShellComposable.indexOf('async function openSmartEntry(payload')
|
||||||
'fallbackSessionType === ASSISTANT_SCOPE_SESSION_STEWARD'
|
const closeSmartEntryStart = appShellComposable.indexOf('function closeSmartEntry(')
|
||||||
)
|
assert.ok(openSmartEntryStart >= 0, 'expected an openSmartEntry entry point')
|
||||||
const ontologyParseIndex = appShellComposable.indexOf('fetchOntologyParse(')
|
assert.ok(closeSmartEntryStart > openSmartEntryStart, 'expected closeSmartEntry to follow openSmartEntry')
|
||||||
|
|
||||||
assert.ok(fastPathIndex >= 0, 'expected steward fallback fast path in smart entry routing')
|
const openSmartEntryBlock = appShellComposable.slice(openSmartEntryStart, closeSmartEntryStart)
|
||||||
|
const guardIndex = openSmartEntryBlock.indexOf('resolveAssistantScopeGuard(')
|
||||||
|
const blockedIndex = openSmartEntryBlock.indexOf('scopeGuard?.blocked')
|
||||||
|
const conversationIndex = openSmartEntryBlock.indexOf('buildUnsupportedBusinessScopeConversation(prompt')
|
||||||
|
const sessionTypeResolveIndex = openSmartEntryBlock.indexOf('resolveSmartEntrySessionType(payload)')
|
||||||
|
|
||||||
|
assert.ok(guardIndex >= 0, 'expected smart entry to use the business scope guard')
|
||||||
|
assert.ok(blockedIndex >= 0, 'expected smart entry to short-circuit blocked inputs')
|
||||||
|
assert.ok(conversationIndex >= 0, 'expected blocked smart entry inputs to seed an assistant conversation')
|
||||||
|
assert.ok(sessionTypeResolveIndex >= 0, 'expected smart entry to delegate session resolution')
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fastPathIndex < ontologyParseIndex,
|
blockedIndex < sessionTypeResolveIndex,
|
||||||
'expected steward fallback to return before slow ontology parsing'
|
'expected blocked inputs to stop before ontology-driven session resolution'
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
conversationIndex < sessionTypeResolveIndex,
|
||||||
|
'expected unsupported input guidance to be prepared before session resolution'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -19,33 +19,19 @@ const workbenchDateComposable = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/composables/useWorkbenchComposerDate.js', import.meta.url)),
|
fileURLToPath(new URL('../src/composables/useWorkbenchComposerDate.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
const workbenchStyles = readFileSync(
|
|
||||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench.css', import.meta.url)),
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
const workbenchDateStyles = readFileSync(
|
const workbenchDateStyles = readFileSync(
|
||||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-composer-date.css', import.meta.url)),
|
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-composer-date.css', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
const workbenchResponsiveStyles = readFileSync(
|
|
||||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-responsive.css', import.meta.url)),
|
|
||||||
'utf8'
|
|
||||||
)
|
|
||||||
|
|
||||||
test('workbench composer renders date picker beside attachment upload', () => {
|
test('traditional workbench no longer renders the old composer date picker', () => {
|
||||||
assert.match(workbench, /aria-label="上传附件"[\s\S]*class="workbench-date-anchor"/)
|
assert.doesNotMatch(workbench, /aria-label="上传附件"[\s\S]*class="workbench-date-anchor"/)
|
||||||
assert.match(workbench, /aria-label="选择日期"/)
|
assert.doesNotMatch(workbench, /aria-label="选择日期"/)
|
||||||
assert.match(workbench, /class="workbench-date-chip"/)
|
assert.doesNotMatch(workbench, /class="workbench-date-chip"/)
|
||||||
assert.match(workbench, /removeWorkbenchDateTag/)
|
assert.doesNotMatch(workbench, /removeWorkbenchDateTag/)
|
||||||
assert.match(workbench, /composer-date-popover/)
|
assert.doesNotMatch(workbench, /composer-date-popover/)
|
||||||
assert.match(workbench, /setWorkbenchDateMode\('single'\)/)
|
assert.doesNotMatch(workbench, /setWorkbenchDateMode\('single'\)/)
|
||||||
assert.match(workbench, /setWorkbenchDateMode\('range'\)/)
|
assert.doesNotMatch(workbench, /useWorkbenchComposerDate/)
|
||||||
assert.match(workbench, /handleWorkbenchDateInputChange\('single'\)/)
|
|
||||||
assert.match(workbench, /handleWorkbenchDateInputChange\('range-start'\)/)
|
|
||||||
assert.match(workbench, /handleWorkbenchDateInputChange\('range-end'\)/)
|
|
||||||
assert.doesNotMatch(workbench, /@click="applyWorkbenchDateSelection"/)
|
|
||||||
assert.doesNotMatch(workbench, /插入标签/)
|
|
||||||
assert.match(workbench, /useWorkbenchComposerDate/)
|
|
||||||
assert.match(workbenchDateComposable, /const workbenchSingleDate = ref\(getTodayDateValue\(\)\)/)
|
assert.match(workbenchDateComposable, /const workbenchSingleDate = ref\(getTodayDateValue\(\)\)/)
|
||||||
assert.match(workbenchDateComposable, /const workbenchDateTagLabel = ref\(''\)/)
|
assert.match(workbenchDateComposable, /const workbenchDateTagLabel = ref\(''\)/)
|
||||||
assert.match(workbenchDateComposable, /const today = getTodayDateValue\(\)[\s\S]*workbenchSingleDate\.value = today/)
|
assert.match(workbenchDateComposable, /const today = getTodayDateValue\(\)[\s\S]*workbenchSingleDate\.value = today/)
|
||||||
@@ -53,13 +39,6 @@ test('workbench composer renders date picker beside attachment upload', () => {
|
|||||||
assert.match(workbenchDateStyles, /\.workbench-date-anchor/)
|
assert.match(workbenchDateStyles, /\.workbench-date-anchor/)
|
||||||
assert.match(workbenchDateStyles, /\.workbench-date-chip/)
|
assert.match(workbenchDateStyles, /\.workbench-date-chip/)
|
||||||
assert.match(workbenchDateStyles, /\.composer-date-popover/)
|
assert.match(workbenchDateStyles, /\.composer-date-popover/)
|
||||||
assert.match(workbenchStyles, /\.assistant-composer\s*\{[\s\S]*position:\s*relative/)
|
|
||||||
assert.match(workbenchDateStyles, /\.composer-date-popover\s*\{[\s\S]*top:\s*calc\(100% \+ 8px\)/)
|
|
||||||
assert.doesNotMatch(workbenchDateStyles, /bottom:\s*calc\(100%/)
|
|
||||||
assert.doesNotMatch(workbench, /composer-related-button/)
|
|
||||||
assert.doesNotMatch(workbenchStyles, /\.composer-related-button/)
|
|
||||||
assert.doesNotMatch(workbenchDateStyles, /\.composer-related-button/)
|
|
||||||
assert.doesNotMatch(workbenchResponsiveStyles, /\.composer-related-button/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench date helper builds labels and inserts them into draft text', () => {
|
test('workbench date helper builds labels and inserts them into draft text', () => {
|
||||||
|
|||||||
@@ -89,6 +89,52 @@ test('workbench summary builds real user notifications and progress from request
|
|||||||
assert.equal(summary.expenseStatsDetail.processingRows[0].stepCount, 5)
|
assert.equal(summary.expenseStatsDetail.processingRows[0].stepCount, 5)
|
||||||
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '待办'))
|
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '待办'))
|
||||||
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '进度'))
|
assert.ok(summary.expenseStatsDetail.operationRows.some((item) => item.source === '进度'))
|
||||||
|
assert.ok(Array.isArray(summary.reimbursementTrendRows))
|
||||||
|
assert.equal(summary.reimbursementTrendRows.length, 6)
|
||||||
|
assert.equal(summary.reimbursementTrendRows.at(-1).key, '2026-06')
|
||||||
|
assert.equal(summary.reimbursementTrendRows.at(-1).amount, 1280)
|
||||||
|
assert.equal(summary.reimbursementTrendRows.at(-1).previousKey, '2025-06')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench reimbursement trend compares monthly totals with last year same period', () => {
|
||||||
|
const summary = buildWorkbenchSummary(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'BX-202606',
|
||||||
|
claimNo: 'BX-202606',
|
||||||
|
person: currentUser.name,
|
||||||
|
title: '六月报销',
|
||||||
|
amount: 1280,
|
||||||
|
createdAt: '2026-06-15T10:00:00+08:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'BX-202605',
|
||||||
|
claimNo: 'BX-202605',
|
||||||
|
person: currentUser.name,
|
||||||
|
title: '五月报销',
|
||||||
|
amount: 860,
|
||||||
|
createdAt: '2026-05-10T10:00:00+08:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'BX-202506',
|
||||||
|
claimNo: 'BX-202506',
|
||||||
|
person: currentUser.name,
|
||||||
|
title: '去年六月报销',
|
||||||
|
amount: 920,
|
||||||
|
createdAt: '2025-06-12T10:00:00+08:00'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
currentUser
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
summary.reimbursementTrendRows.slice(-2).map((item) => item.label),
|
||||||
|
['5月', '6月']
|
||||||
|
)
|
||||||
|
assert.equal(summary.reimbursementTrendRows.at(-2).amount, 860)
|
||||||
|
assert.equal(summary.reimbursementTrendRows.at(-2).previousAmount, 0)
|
||||||
|
assert.equal(summary.reimbursementTrendRows.at(-1).amount, 1280)
|
||||||
|
assert.equal(summary.reimbursementTrendRows.at(-1).previousAmount, 920)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench progress keeps application document type for AP claims', () => {
|
test('workbench progress keeps application document type for AP claims', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user