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

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

View File

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