feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源 - 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore 及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿 - 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局 - 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
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'/)
|
||||
})
|
||||
Reference in New Issue
Block a user