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,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 元/)
})

View 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元/)
})

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

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

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

View File

@@ -102,8 +102,8 @@ test('workbench summary merges approval inbox requests without polluting documen
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 handleRequestUpdated\(\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
assert.doesNotMatch(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail/)
assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/)
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', () => {
@@ -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, /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, /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 \|\| ''\)\)/)
})

View File

@@ -1,6 +1,9 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
ASSISTANT_SCOPE_ACTION_FILL_COMPOSER
} from '../src/utils/assistantSessionScope.js'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
@@ -41,3 +44,23 @@ test('composer prefill appends to existing draft without duplication', () => {
assert.equal(mergeComposerPrefill('地点:上海', '事由:'), '地点:上海\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: {}
}),
''
)
})

View File

@@ -5,12 +5,15 @@ import { fileURLToPath } from 'node:url'
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
attachReceiptFolderIdsToFiles,
buildAttachmentAssociationConfirmationMessage,
buildOcrFilePreviews,
buildReviewFilePreviewsFromReviewPayload,
buildUnsavedDraftAttachmentConfirmationMessage,
collectReceiptFiles,
filterPersistableFilePreviews,
mergeFilePreviews
mergeFilePreviews,
normalizeOcrDocuments
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
import {
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 }])
})
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', () => {
const merged = mergeFilePreviews(
[

View File

@@ -53,9 +53,11 @@ import {
} from '../src/views/scripts/budgetAssistantReportModel.js'
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
import {
ASSISTANT_SCOPE_ACTION_SWITCH,
ASSISTANT_SCOPE_SESSION_APPLICATION,
ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_STEWARD,
buildUnsupportedBusinessScopeConversation,
inferAssistantScopeTarget,
resolveAssistantScopeGuard
} from '../src/utils/assistantSessionScope.js'
@@ -231,16 +233,42 @@ test('application intent uses local preview instead of immediate orchestrator ca
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', () => {
const greetingGuard = 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.targetSessionType, '')
assert.match(guard.text, /此意图系统不支持/)
assert.match(guard.text, /当前系统支持的业务范围/)
assert.deepEqual(guard.suggestedActions, [])
})
test('assistant scope guard routes related business intent instead of blocking', () => {
const guard = resolveAssistantScopeGuard('帮我查一下报销单状态', ASSISTANT_SCOPE_SESSION_APPLICATION)

View File

@@ -88,6 +88,20 @@ test('daily amount trend uses stacked category bars with clear unit and legend',
assert.match(trendChart, /legendItems/)
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, /stack: 'expenseAmount'/)
})

View File

@@ -38,9 +38,6 @@ const workbenchInsightStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-insights.css', import.meta.url)),
'utf8'
)
const heroBackgroundAsset = fileURLToPath(
new URL('../src/assets/images/hero-3d-banner.png', import.meta.url)
)
const capabilityGlassAsset = fileURLToPath(
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)
)
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, /AI 报销助手/)
assert.match(workbench, /\{\{ typedTitlePrefix \}\}<span v-if="titleTypingDone">小财管家<\/span>/)
assert.match(workbench, /const heroTitleText = computed\(\(\) => `嗨,\$\{displayUserName\.value\},我是您的 `\)/)
assert.match(workbench, /placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行\.\.\."/)
assert.doesNotMatch(workbench, /class="panel assistant-hero workbench-trend-hero"/)
assert.match(workbench, /class="workbench-trend-card"/)
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, /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', () => {
@@ -91,17 +115,22 @@ test('workbench capability cards keep user-entered context only', () => {
assert.equal(payload.files, files)
})
test('workbench hero uses theme-tintable background image', () => {
assert.match(workbench, /hero-3d-banner\.png/)
test('workbench trend panel is a single compact surface without background art overlay', () => {
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.match(workbench, /--assistant-bg-image.*workbenchHeroBackground/)
assert.match(workbenchStyles, /--assistant-theme-tint:[\s\S]*--theme-primary-rgb/)
assert.match(workbenchStyles, /url\("\.\.\/\.\.\/images\/workbench-hero-right-bg\.png"\) var\(--assistant-bg-position\) \/ var\(--assistant-decor-width\) auto no-repeat/)
assert.match(workbenchStyles, /\.assistant-hero::after\s*\{[\s\S]*content:\s*"";/)
assert.match(workbenchResponsiveStyles, /--assistant-bg-position:\s*right center;/)
assert.doesNotMatch(workbenchStyles, /url\("\.\.\/\.\.\/images\/workbench-hero-right-bg\.png"\)/)
assert.doesNotMatch(workbenchStyles, /\.assistant-hero::after/)
assert.doesNotMatch(workbenchResponsiveStyles, /--assistant-bg-position/)
assert.doesNotMatch(workbenchResponsiveStyles, /\.assistant-hero/)
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', () => {
@@ -141,13 +170,16 @@ test('workbench cards use layered glass material instead of texture-led cards',
assert.ok(statSync(panelGlassAsset).size < 24 * 1024)
})
test('workbench submit shows intent recognition feedback before assistant opens', () => {
assert.match(workbench, /class="assistant-intent-status"/)
assert.match(workbench, /aria-live="polite"/)
assert.match(workbench, /正在识别意图,准备进入对应助手/)
assert.match(workbench, /startPendingAction\('intent'\)/)
assert.match(workbench, /if \(open\) \{\s*clearPendingAction\(\)/)
assert.match(workbench, /:readonly="isComposerPending"/)
test('traditional workbench no longer keeps composer pending state', () => {
assert.doesNotMatch(workbench, /class="assistant-intent-status"/)
assert.doesNotMatch(workbench, /正在识别意图,准备进入对应助手/)
assert.doesNotMatch(workbench, /startPendingAction\('intent'\)/)
assert.doesNotMatch(workbench, /isComposerPending/)
assert.doesNotMatch(workbench, /assistantDraft/)
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', () => {

View File

@@ -8,20 +8,26 @@ const responsiveStyles = readFileSync(
'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(
responsiveStyles,
/@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-padding-bottom:\s*14px;/)
assert.match(responsiveStyles, /--hero-title-size:\s*24px;/)
assert.match(responsiveStyles, /--composer-min-height:\s*92px;/)
assert.match(responsiveStyles, /--composer-textarea-height:\s*38px;/)
assert.match(responsiveStyles, /--capability-row-height:\s*82px;/)
assert.match(responsiveStyles, /\.assistant-copy h1\s*\{[\s\S]*font-size:\s*var\(--hero-title-size\);/)
assert.match(responsiveStyles, /\.assistant-composer\s*\{[\s\S]*padding:\s*var\(--composer-padding-block\) 14px 8px;/)
assert.match(responsiveStyles, /\.quick-prompts button\s*\{[\s\S]*min-height:\s*24px;/)
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, /@media \(max-width: 760px\)[\s\S]*\.workbench\s*\{[\s\S]*grid-template-rows:\s*none;/)
assert.match(responsiveStyles, /--hero-title-size:\s*30px;/)
assert.match(responsiveStyles, /--trend-card-min-height:\s*232px;/)
assert.match(responsiveStyles, /--capability-row-height:\s*102px;/)
assert.match(responsiveStyles, /\.workbench-trend-hero\s*\{[\s\S]*padding:\s*24px 18px 10px 18px;/)
assert.match(responsiveStyles, /\.trend-summary-panel h1\s*\{[\s\S]*font-size:\s*var\(--hero-title-size\);/)
assert.match(responsiveStyles, /\.workbench-trend-card\s*\{[\s\S]*min-height:\s*0;/)
assert.match(responsiveStyles, /\.trend-chart-panel\s*\{[\s\S]*min-height:\s*0;/)
assert.match(responsiveStyles, /\.trend-total\s*\{[\s\S]*font-size:\s*42px;/)
assert.match(responsiveStyles, /\.trend-chart-head strong\s*\{[\s\S]*font-size:\s*14px;/)
assert.match(responsiveStyles, /\.trend-summary-panel small\s*\{[\s\S]*display:\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\)/)
})

View File

@@ -13,14 +13,20 @@ const sidebar = readFileSync(
'utf8'
)
const sidebarStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/sidebar-rail.css', import.meta.url)),
'utf8'
)
const appStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
'utf8'
)
test('sidebar supports smooth animated collapsed layout', () => {
assert.match(appShell, /sidebarCollapsed = ref\(true\)/)
assert.match(appShell, /:class="\{ 'sidebar-collapsed': sidebarCollapsed \}"/)
assert.match(appShell, /sidebarCollapsed = ref\(false\)/)
assert.match(appShell, /'sidebar-collapsed': sidebarCollapsed/)
assert.match(appShell, /'workbench-ai-sidebar-active': isAiShellMode/)
assert.match(appShell, /:collapsed="sidebarCollapsed"/)
assert.match(appShell, /class="app-sidebar"/)
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, /折叠侧边栏/)
assert.match(sidebar, /展开侧边栏/)
assert.match(sidebar, /--rail-motion-duration: 320ms/)
assert.match(sidebar, /opacity var\(--rail-fade-duration\)/)
assert.match(sidebar, /class="user-actions"/)
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, /\.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\)/)

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

View 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, '我要报销')
})

View File

@@ -2,9 +2,44 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildStewardPlanMessageText,
buildStewardSuggestedActions
} 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', () => {
const actions = buildStewardSuggestedActions({
plan_id: 'steward-plan-pending-flow',

View File

@@ -22,7 +22,7 @@ const OFF_TOPIC_PLAN = {
attachment_groups: [],
confirmation_groups: [],
candidate_flows: [],
summary: '这看起来跟财务任务没什么关系...',
summary: '很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。',
suggested_prompts: [
'我想要申请明天去北京出差3天支撑客户现场实施',
'我要报销上周去上海的高铁票',
@@ -64,24 +64,60 @@ test('isOffTopicStewardPlan returns true only for off_topic plan status', () =>
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)
assert.match(text, /小财管家没看懂这件事/)
assert.equal(text, OFF_TOPIC_PLAN.summary)
// 推荐话术本身不在正文里展示,而是作为按钮单独渲染,避免重复。
for (const prompt of OFF_TOPIC_PLAN.suggested_prompts) {
assert.equal(text.includes(prompt), false, `正文不应包含推荐话术:${prompt}`)
}
})
test('buildStewardPlanMessageText keeps off_topic branch ahead of pending flow branch', () => {
// 即使 summary 缺省,也走 off_topic 分支而非默认任务文案
const text = buildStewardPlanMessageText({
plan_id: 'p-off-topic-default',
test('buildStewardPlanMessageText adapts greeting vs meaningless vs off_business summaries', () => {
// 问候场景:礼貌回应主人
const greetingText = buildStewardPlanMessageText({
plan_id: 'p-greeting',
plan_status: 'off_topic',
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, /费用申请.*费用报销|费用报销.*费用申请/)
})

View 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;/)
})

View File

@@ -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(suggestedActionsScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
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, /skipScopeGuard/)
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)

View File

@@ -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', () => {
assert.match(stageRiskAdviceCard, /employee-risk-decision-panel/)
assert.match(stageRiskAdviceCard, /综合审核结论/)
assert.match(stageRiskAdviceCard, /建议结论/)
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
assert.match(stageRiskAdviceCard, /是否建议通过/)
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, /stageBasisTitle/)
assert.match(stageRiskAdviceCard, /stageBasisHint/)
assert.match(stageRiskAdviceCard, /employee-risk-profile-section/)
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, /stripEmbeddedExplanationText/)
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
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-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.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
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.doesNotMatch(stageRiskAdviceCard, /人员行为画像/)
assert.doesNotMatch(stageRiskAdviceCard, /部门预算执行/)
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 showAiAdvicePanel = computed\(\(\) => \(/)
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, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.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-point"/)
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, /risk-advice-more/)
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.doesNotMatch(detailViewStyle, /\.risk-note-tag/)
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/)
})

View 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\)/)
})

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

View File

@@ -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', () => {
const fastPathIndex = appShellComposable.indexOf(
'fallbackSessionType === ASSISTANT_SCOPE_SESSION_STEWARD'
)
const ontologyParseIndex = appShellComposable.indexOf('fetchOntologyParse(')
test('workbench smart entry blocks unsupported non-business input before ontology parsing', () => {
const openSmartEntryStart = appShellComposable.indexOf('async function openSmartEntry(payload')
const closeSmartEntryStart = appShellComposable.indexOf('function closeSmartEntry(')
assert.ok(openSmartEntryStart >= 0, 'expected an openSmartEntry entry point')
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(
fastPathIndex < ontologyParseIndex,
'expected steward fallback to return before slow ontology parsing'
blockedIndex < sessionTypeResolveIndex,
'expected blocked inputs to stop before ontology-driven session resolution'
)
assert.ok(
conversationIndex < sessionTypeResolveIndex,
'expected unsupported input guidance to be prepared before session resolution'
)
})

View File

@@ -19,33 +19,19 @@ const workbenchDateComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useWorkbenchComposerDate.js', import.meta.url)),
'utf8'
)
const workbenchStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench.css', import.meta.url)),
'utf8'
)
const workbenchDateStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-composer-date.css', import.meta.url)),
'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', () => {
assert.match(workbench, /aria-label="上传附件"[\s\S]*class="workbench-date-anchor"/)
assert.match(workbench, /aria-label="选择日期"/)
assert.match(workbench, /class="workbench-date-chip"/)
assert.match(workbench, /removeWorkbenchDateTag/)
assert.match(workbench, /composer-date-popover/)
assert.match(workbench, /setWorkbenchDateMode\('single'\)/)
assert.match(workbench, /setWorkbenchDateMode\('range'\)/)
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/)
test('traditional workbench no longer renders the old composer date picker', () => {
assert.doesNotMatch(workbench, /aria-label="上传附件"[\s\S]*class="workbench-date-anchor"/)
assert.doesNotMatch(workbench, /aria-label="选择日期"/)
assert.doesNotMatch(workbench, /class="workbench-date-chip"/)
assert.doesNotMatch(workbench, /removeWorkbenchDateTag/)
assert.doesNotMatch(workbench, /composer-date-popover/)
assert.doesNotMatch(workbench, /setWorkbenchDateMode\('single'\)/)
assert.doesNotMatch(workbench, /useWorkbenchComposerDate/)
assert.match(workbenchDateComposable, /const workbenchSingleDate = ref\(getTodayDateValue\(\)\)/)
assert.match(workbenchDateComposable, /const workbenchDateTagLabel = ref\(''\)/)
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-chip/)
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', () => {

View File

@@ -89,6 +89,52 @@ test('workbench summary builds real user notifications and progress from request
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(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', () => {