refactor: consolidate finance workflow modules

This commit is contained in:
caoxiaozhu
2026-06-23 11:21:18 +08:00
parent 1f40ce3df3
commit 73966b3a7b
52 changed files with 3468 additions and 2865 deletions

View File

@@ -97,7 +97,7 @@ test('AI sidebar has quick actions, business navigation and conversation history
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, /item\.aiIconPaths/)
assert.match(aiSidebar, /aria-current/)
assert.doesNotMatch(aiSidebar, /displayHint/)
assert.doesNotMatch(aiSidebar, /个人工作台/)
@@ -136,7 +136,7 @@ test('AI sidebar has quick actions, business navigation and conversation history
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.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view'\]\)/)
assert.doesNotMatch(aiSidebar, /search-chat/)
assert.doesNotMatch(aiSidebar, /打开系统设置/)
assert.doesNotMatch(aiSidebar, /mdi-chevron-up/)
@@ -154,10 +154,21 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', ()
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(aiSidebar, /const tablerIconPaths = \{/)
assert.match(aiSidebar, /plus:\s*\[/)
assert.match(aiSidebar, /search:\s*\[/)
assert.match(aiSidebar, /fileText:\s*\[/)
assert.match(aiSidebar, /book2:\s*\[/)
assert.match(aiSidebar, /iconPaths:\s*tablerIconPaths\.plus/)
assert.match(aiSidebar, /aiIconPaths:\s*sidebarMeta\[item\.id\]\?\.iconPaths/)
assert.doesNotMatch(aiSidebar, /icon:\s*'mdi mdi-plus'/)
assert.doesNotMatch(aiSidebar, /mdi mdi-file-document-outline/)
assert.match(aiSidebarStyles, /\.ai-sidebar-tabler-icon\s*\{[\s\S]*stroke-width:\s*1\.85;/)
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, /grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
assert.match(quickButtonBlock, /gap:\s*12px;/)
assert.match(quickButtonBlock, /padding:\s*7px 10px;/)
assert.match(quickButtonBlock, /background:\s*transparent;/)
assert.match(quickButtonBlock, /border-color:\s*transparent;/)
assert.match(quickButtonBlock, /box-shadow:\s*none;/)
@@ -170,7 +181,12 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', ()
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-quick-btn:hover,\s*\.ai-quick-btn\.active,\s*\.ai-nav-btn:hover,\s*\.ai-nav-btn\.active\s*\{[\s\S]*background:\s*rgba\(15,\s*23,\s*42,\s*0\.035\);/)
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn::before/)
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active::before/)
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active \.ai-nav-copy/)
assert.doesNotMatch(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-btn\.active/)
assert.doesNotMatch(aiSidebarStyles, /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;/)

View File

@@ -8,23 +8,65 @@ const shell = readFileSync(
'utf8'
)
const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8')
const sidebarRail = readFileSync(
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
'utf8'
)
const aiSidebarRail = readFileSync(
fileURLToPath(new URL('../src/components/layout/AiSidebarRail.vue', import.meta.url)),
'utf8'
)
test('app shell main route views are eagerly imported', () => {
assert.doesNotMatch(shell, /defineAsyncRouteView/)
assert.doesNotMatch(shell, /defineAsyncComponent/)
assert.doesNotMatch(shell, /loadingComponent:/)
assert.doesNotMatch(shell, /\u9875\u9762\u5207\u6362\u4e2d/)
assert.doesNotMatch(shell, /floating:\s*true/)
assert.doesNotMatch(shell, /blocking:\s*true/)
assert.match(shell, /import AuditView from '\.\/AuditView\.vue'/)
assert.match(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
assert.match(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
assert.match(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
assert.match(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
test('app shell lazily loads heavy business views with an in-workarea loading state', () => {
assert.match(shell, /defineAsyncRouteView\('audit'\)/)
assert.match(shell, /defineAsyncRouteView\('documents'\)/)
assert.match(shell, /defineAsyncRouteView\('workbench'\)/)
assert.match(shell, /defineAsyncModalView\('travelCreate'\)/)
assert.match(shell, /function prefetchAppView\(viewId\)/)
assert.match(shell, /@prefetch-view="prefetchAppView"/)
assert.doesNotMatch(shell, /import AuditView from '\.\/AuditView\.vue'/)
assert.doesNotMatch(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
assert.doesNotMatch(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
assert.doesNotMatch(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
assert.doesNotMatch(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
})
test('top-level app routes are eagerly imported', () => {
assert.doesNotMatch(router, /\(\)\s*=>\s*import\(/)
test('app view preloading is triggered from both standard and AI sidebars', () => {
assert.match(sidebarRail, /'prefetch-view'/)
assert.match(sidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/)
assert.match(sidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/)
assert.match(aiSidebarRail, /'prefetch-view'/)
assert.match(aiSidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/)
assert.match(aiSidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/)
})
test('async app view loader keeps transitions nonblocking and visible', () => {
const asyncViews = readFileSync(
fileURLToPath(new URL('../src/views/scripts/appShellAsyncViews.js', import.meta.url)),
'utf8'
)
const loadingState = readFileSync(
fileURLToPath(new URL('../src/components/shared/AppViewLoadingState.vue', import.meta.url)),
'utf8'
)
const modalLoadingState = readFileSync(
fileURLToPath(new URL('../src/components/shared/AppModalLoadingState.vue', import.meta.url)),
'utf8'
)
assert.match(asyncViews, /defineAsyncComponent/)
assert.match(asyncViews, /loadingComponent:\s*options\.loadingComponent \|\| AppViewLoadingState/)
assert.match(asyncViews, /loadingComponent:\s*AppModalLoadingState/)
assert.match(asyncViews, /suspensible:\s*false/)
assert.match(asyncViews, /requestIdleCallback/)
assert.match(loadingState, /正在加载页面内容/)
assert.match(loadingState, /app-view-loading-skeleton/)
assert.match(modalLoadingState, /Teleport to="body"/)
assert.match(modalLoadingState, /正在打开智能工作台/)
})
test('top-level shell routes stay eager so the layout does not blank during navigation', () => {
assert.doesNotMatch(router, /component:\s*\(\)\s*=>\s*import\(\s*'\.\.\/views\/AppShellRouteView\.vue'/)
assert.match(router, /import AppShellRouteView from '\.\.\/views\/AppShellRouteView\.vue'/)
assert.match(router, /import LoginRouteView from '\.\.\/views\/LoginRouteView\.vue'/)
assert.match(router, /import SetupRouteView from '\.\.\/views\/SetupRouteView\.vue'/)

View File

@@ -0,0 +1,61 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
extractTrustedHtmlBlocks,
normalizeConversationText,
restoreTrustedHtmlBlocks
} from '../src/utils/conversationTrustedHtml.js'
test('conversation trusted html helper preserves valid document cards', () => {
const trustedBlock = [
'<!-- ai-trusted-html:start -->',
'<section class="ai-document-card-list" aria-label="单据结果">',
'<article class="ai-document-card" aria-label="单据详情">',
'<strong>差旅申请</strong>',
'<a class="ai-html-action-link" data-ai-action="open-document-detail" href="#ai-open-document-detail:CL-1">查看</a>',
'</article>',
'</section>',
'<!-- ai-trusted-html:end -->'
].join('')
const extracted = extractTrustedHtmlBlocks(`结果如下:\n\n${trustedBlock}`)
assert.equal(extracted.trustedHtmlBlocks.length, 1)
assert.match(extracted.content, /AI_TRUSTED_HTML_BLOCK_0/)
const restored = restoreTrustedHtmlBlocks(
'<p>AI_TRUSTED_HTML_BLOCK_0</p>\n',
extracted.trustedHtmlBlocks
)
assert.match(restored, /class="ai-document-card-list"/)
assert.match(restored, /href="#ai-open-document-detail:CL-1"/)
assert.doesNotMatch(restored, /AI_TRUSTED_HTML_BLOCK_0/)
})
test('conversation trusted html helper rejects unsafe trusted blocks', () => {
const extracted = extractTrustedHtmlBlocks([
'<!-- ai-trusted-html:start -->',
'<section class="ai-document-card-list">',
'<a href="javascript:alert(1)" onclick="alert(1)">危险</a>',
'</section>',
'<!-- ai-trusted-html:end -->'
].join(''))
assert.equal(extracted.trustedHtmlBlocks.length, 0)
assert.equal(extracted.content.trim(), '')
})
test('conversation trusted html helper normalizes business copy outside fences', () => {
const normalized = normalizeConversationText([
'基础信息识别结果:请核对',
'时间2026-02-20',
'',
'```',
'金额:不要改代码块',
'```'
].join('\n'), { trim: true })
assert.match(normalized, /### 基础信息识别结果/)
assert.match(normalized, /- \*\*时间\*\*2026-02-20/)
assert.match(normalized, /```\n金额不要改代码块\n```/)
})

View File

@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
DOCUMENT_TYPE_APPLICATION,
DOCUMENT_TYPE_LABELS,
DOCUMENT_TYPE_REIMBURSEMENT,
INLINE_APPLICATION_STATUS_LABELS,
resolveDocumentTypeLabel
} from '../src/constants/documentProtocol.js'
import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs'
test('document protocol constants centralize document types and labels', () => {
assert.equal(DOCUMENT_TYPE_APPLICATION, 'application')
assert.equal(DOCUMENT_TYPE_REIMBURSEMENT, 'reimbursement')
assert.equal(DOCUMENT_TYPE_LABELS[DOCUMENT_TYPE_APPLICATION], '申请单')
assert.equal(DOCUMENT_TYPE_LABELS[DOCUMENT_TYPE_REIMBURSEMENT], '报销单')
assert.equal(resolveDocumentTypeLabel('application'), '申请单')
assert.equal(resolveDocumentTypeLabel('expense_application'), '申请单')
assert.equal(resolveDocumentTypeLabel('unknown', '单据'), '单据')
})
test('inline application status labels live in the shared document protocol', () => {
assert.equal(INLINE_APPLICATION_STATUS_LABELS.draft, '草稿')
assert.equal(INLINE_APPLICATION_STATUS_LABELS.submitted, '审批中')
assert.equal(INLINE_APPLICATION_STATUS_LABELS.pending_payment, '待付款')
})
test('source surface helper loads one or more source files for source assertions', () => {
const model = readSourceFile('constants/documentProtocol.js')
assert.match(model, /DOCUMENT_TYPE_APPLICATION/)
const combined = readSourceSurface([
'constants/documentProtocol.js',
'composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
])
assert.match(combined, /INLINE_APPLICATION_STATUS_LABELS/)
assert.match(combined, /normalizeInlineApplicationStatusLabel/)
})

View File

@@ -1,38 +1,19 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const documentsCenterView = readFileSync(
fileURLToPath(new URL('../src/views/DocumentsCenterView.vue', import.meta.url)),
'utf8'
)
const documentsCenterViewModel = readFileSync(
fileURLToPath(new URL('../src/utils/documentCenterViewModel.js', import.meta.url)),
'utf8'
)
const documentsCenterLogic = `${documentsCenterView}\n${documentsCenterViewModel}`
import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs'
const documentsCenterStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)),
'utf8'
)
const documentListSharedStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)),
'utf8'
)
const tableLoadingState = readFileSync(
fileURLToPath(new URL('../src/components/shared/TableLoadingState.vue', import.meta.url)),
'utf8'
)
const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
)
const requestsComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
'utf8'
)
const documentsCenterView = readSourceFile('views/DocumentsCenterView.vue')
const documentsCenterViewModel = readSourceFile('utils/documentCenterViewModel.js')
const documentsCenterLogic = readSourceSurface([
'views/DocumentsCenterView.vue',
'utils/documentCenterViewModel.js'
])
const documentsCenterStyles = readSourceFile('assets/styles/views/documents-center-view.css')
const documentListSharedStyles = readSourceFile('assets/styles/components/document-list-shared.css')
const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue')
const reimbursementService = readSourceFile('services/reimbursements.js')
const requestsComposable = readSourceFile('composables/useRequests.js')
test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
@@ -145,7 +126,7 @@ test('documents center preserves application document type from mapped requests'
)
assert.match(
documentsCenterLogic,
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/
/resolveDocumentTypeLabel\(documentTypeCode\)/
)
assert.doesNotMatch(
documentsCenterLogic,

View File

@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
export function readSourceFile(relativePath) {
return readFileSync(
fileURLToPath(new URL(`../../src/${relativePath}`, import.meta.url)),
'utf8'
)
}
export function readSourceSurface(relativePaths = []) {
return relativePaths.map((relativePath) => readSourceFile(relativePath)).join('\n')
}

View File

@@ -82,37 +82,64 @@ test('topbar bell owns document center unread notifications', () => {
assert.match(topbar, /startDocumentInboxPolling\(\)/)
assert.match(topbar, /stopDocumentInboxPolling\(\)/)
assert.match(topbar, /class="notification-clear-btn"/)
assert.match(topbar, /function clearAllNotifications\(\)/)
assert.match(topbar, /notificationBulkActionLabel/)
assert.match(topbar, /notificationBulkActionDisabled/)
assert.match(topbar, /function handleNotificationBulkAction\(\)/)
assert.match(topbar, /function markUnreadNotificationsRead\(\)/)
assert.match(topbar, /function deleteReadNotifications\(\)/)
assert.match(topbar, /function markNotificationRead\(item\)/)
assert.match(topbar, /class="notification-type-icon" :class="item\.tone"/)
assert.match(topbar, /class="notification-avatar" :class="item\.tone"/)
assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/)
assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(520px,\s*calc\(100vh - 96px\)\);/)
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(336px,\s*calc\(100vh - 226px\)\);[\s\S]*overflow-y:\s*auto;/)
assert.match(topbarStyles, /\.notification-copy small\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(560px,\s*calc\(100vh - 68px\)\);/)
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(420px,\s*calc\(100vh - 166px\)\);[\s\S]*overflow-y:\s*auto;/)
assert.match(topbarStyles, /\.notification-preview\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.notification-popover\s*\{[\s\S]*position:\s*fixed;/)
assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*4px;/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 16px;/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
})
test('topbar notification popover uses inbox-style rows with formatted time labels', () => {
assert.match(topbar, /class="notification-row-main"/)
assert.match(topbar, /class="notification-row-head"/)
test('topbar notification bulk action label follows active tab semantics', () => {
assert.match(topbar, />\s*\{\{ notificationBulkActionLabel \}\}\s*<\/button>/)
assert.match(topbar, /:disabled="notificationBulkActionDisabled"/)
assert.match(topbar, /@click="handleNotificationBulkAction"/)
assert.match(topbar, /const notificationBulkActionLabel = computed\(\(\) => \(\s*notificationTab\.value === 'unread' \? '全部已读' : '删除已读'\s*\)\)/)
assert.match(topbar, /const notificationBulkActionDisabled = computed\(\(\) => \(\s*notificationTab\.value === 'unread'\s*\? unreadNotifications\.value\.length === 0\s*: readNotifications\.value\.length === 0\s*\)\)/)
assert.match(topbar, /function handleNotificationBulkAction\(\) \{[\s\S]*if \(notificationTab\.value === 'unread'\) \{[\s\S]*markUnreadNotificationsRead\(\)[\s\S]*return[\s\S]*deleteReadNotifications\(\)[\s\S]*\}/)
assert.match(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*const currentItems = unreadNotifications\.value[\s\S]*markDocumentInboxRowsRead\(documentRows\)[\s\S]*markNotificationStatesRead\(currentItems\)/)
assert.match(topbar, /function deleteReadNotifications\(\) \{[\s\S]*const currentItems = readNotifications\.value[\s\S]*hideNotificationStates\(currentItems\)/)
assert.doesNotMatch(topbar, />\s*清空通知\s*<\/button>/)
})
test('topbar notification popover uses reference-style avatar message rows', () => {
assert.match(topbar, /class="notification-avatar" :class="item\.tone"/)
assert.match(topbar, /class="notification-avatar-label"/)
assert.match(topbar, /class="notification-avatar-badge"/)
assert.match(topbar, /class="notification-row-content"/)
assert.match(topbar, /class="notification-row-top"/)
assert.match(topbar, /class="notification-row-title"/)
assert.match(topbar, /class="notification-context"/)
assert.match(topbar, /class="notification-row-foot"/)
assert.match(topbar, /class="notification-category-pill"/)
assert.match(topbar, /class="notification-preview"/)
assert.match(topbar, /class="notification-time"/)
assert.match(topbar, /class="notification-row-action"/)
assert.match(topbar, /avatarLabel: resolveDocumentNotificationAvatarLabel\(row\)/)
assert.match(topbar, /avatarLabel: resolveNotificationAvatarLabel\(item\)/)
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(row\.updatedAt \|\| row\.createdAt\)/)
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(item\.time \|\| item\.updatedAt \|\| item\.due\)/)
assert.doesNotMatch(topbar, /class="notification-category-pill"/)
assert.doesNotMatch(topbar, /class="notification-row-action"/)
assert.doesNotMatch(topbar, /<time>\{\{ item\.time \}\}<\/time>/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*36px minmax\(0,\s*1fr\);/)
assert.match(topbarStyles, /\.notification-context\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /\.notification-row-foot\s*\{[\s\S]*justify-content:\s*space-between;/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
assert.match(topbarStyles, /\.notification-avatar\s*\{[\s\S]*border-radius:\s*999px;/)
assert.match(topbarStyles, /\.notification-preview\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /\.notification-row-top\s*\{[\s\S]*justify-content:\s*space-between;/)
assert.match(topbarStyles, /\.notification-time\s*\{[\s\S]*font-variant-numeric:\s*tabular-nums;/)
assert.match(topbarStyles, /\.notification-row-action\s*\{[\s\S]*width:\s*28px;[\s\S]*height:\s*28px;/)
assert.doesNotMatch(topbarStyles, /\.notification-category-pill/)
assert.doesNotMatch(topbarStyles, /\.notification-row-action/)
})
test('topbar notification popover does not render a top accent line', () => {
assert.doesNotMatch(topbarStyles, /\.notification-popover::before/)
assert.doesNotMatch(topbarStyles, /height:\s*2px;[\s\S]*background:\s*var\(--theme-primary-active\)/)
})
test('topbar notification state is persisted through backend API with local fallback', () => {
@@ -124,9 +151,20 @@ test('topbar notification state is persisted through backend API with local fall
assert.match(topbarNotificationStates, /NOTIFICATION_HIDDEN_STORAGE_KEY/)
assert.match(topbarNotificationStates, /applyRemoteStates/)
assert.match(topbarNotificationStates, /markNotificationStateRead/)
assert.match(topbarNotificationStates, /markNotificationStatesRead/)
assert.match(topbarNotificationStates, /hideNotificationStates/)
})
test('topbar notification bulk actions are wired to backend state API', () => {
assert.match(topbar, /markNotificationStatesRead/)
assert.match(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*const currentItems = unreadNotifications\.value[\s\S]*markDocumentInboxRowsRead\(documentRows\)[\s\S]*markNotificationStatesRead\(currentItems\)/)
assert.doesNotMatch(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*currentItems\.forEach\(\(item\) => \{[\s\S]*markNotificationStateRead\(item\)/)
assert.match(topbar, /function deleteReadNotifications\(\) \{[\s\S]*const currentItems = readNotifications\.value[\s\S]*hideNotificationStates\(currentItems\)/)
assert.match(topbarNotificationStates, /function markNotificationStatesRead\(items\) \{[\s\S]*buildPatch\(item, \{ read: true, hidden: false \}\)[\s\S]*return syncNotificationPatches\(patches\)/)
assert.match(topbarNotificationStates, /function hideNotificationStates\(items\) \{[\s\S]*buildPatch\(item, \{ read: true, hidden: true \}\)[\s\S]*return syncNotificationPatches\(patches\)/)
assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'[\s\S]*body:\s*JSON\.stringify\(\{ states: batch \}\)/)
})
test('document inbox reuses document center viewed-key state', () => {
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentInbox, /fetchNotificationStates/)

View File

@@ -40,6 +40,10 @@ const reviewPanelModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
'utf8'
)
const createReviewModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementCreateReviewModel.js', import.meta.url)),
'utf8'
)
const messageItemTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
'utf8'
@@ -130,6 +134,13 @@ test('review drawer tools expose the default review tab before conditional docum
)
})
test('create review model remains a thin compatibility layer over review panel model', () => {
assert.match(createReviewModelScript, /export \{[\s\S]*buildReviewFactCards[\s\S]*buildReviewRiskItems[\s\S]*\} from '\.\/travelReimbursementReviewPanelModel\.js'/)
assert.doesNotMatch(createReviewModelScript, /function buildReviewFactCards/)
assert.doesNotMatch(createReviewModelScript, /function buildReviewRiskItems/)
assert.doesNotMatch(createReviewModelScript, /const REVIEW_RISK_LEVEL_META/)
})
test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => {
assert.match(createViewScriptSurface, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
assert.match(createViewScriptSurface, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)

View File

@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
isOrphanInlineApplicationPreviewMessage,
isReimbursementCreationIntent,
resolveInlineApplicationPreviewTextAction,
resolveLatestApplicationPreviewMessage,
resolveLatestOrphanApplicationPreviewMessage
} from '../src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT
} from '../src/services/aiApplicationPreviewActions.js'
test('workbench application gate detects reimbursement creation without catching policy questions', () => {
assert.equal(isReimbursementCreationIntent('我要报销'), true)
assert.equal(isReimbursementCreationIntent('帮我新建一笔报账'), true)
assert.equal(isReimbursementCreationIntent('报销一下'), true)
assert.equal(isReimbursementCreationIntent('报销制度是什么'), false)
assert.equal(isReimbursementCreationIntent('帮我查询报销进度'), false)
assert.equal(isReimbursementCreationIntent('这张票能不能报销'), false)
})
test('workbench application gate resolves save and submit text actions consistently', () => {
assert.equal(resolveInlineApplicationPreviewTextAction('保存草稿'), AI_APPLICATION_ACTION_SAVE_DRAFT)
assert.equal(resolveInlineApplicationPreviewTextAction(' 先保存 '), AI_APPLICATION_ACTION_SAVE_DRAFT)
assert.equal(resolveInlineApplicationPreviewTextAction('确认提交'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('直接提交'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('继续修改'), '')
})
test('workbench application gate resolves latest live or orphan preview message', () => {
const messages = [
{ id: 'user-1', role: 'user', content: '2月去上海出差' },
{ id: 'assistant-orphan', role: 'assistant', content: '这是申请核对表,下方表格点击对应行即可直接编辑。' },
{ id: 'assistant-other', role: 'assistant', content: '普通回复' }
]
assert.equal(isOrphanInlineApplicationPreviewMessage(messages[1]), true)
assert.equal(resolveLatestApplicationPreviewMessage(messages), null)
assert.equal(resolveLatestOrphanApplicationPreviewMessage(messages)?.id, 'assistant-orphan')
messages.push({
id: 'assistant-preview',
role: 'assistant',
content: '申请核对表',
applicationPreview: { fields: { location: '上海' } }
})
assert.equal(resolveLatestApplicationPreviewMessage(messages)?.id, 'assistant-preview')
})

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
function readSource(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
const composerComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
function countOccurrences(source, pattern) {
return source.match(pattern)?.length || 0
}
test('personal workbench AI mode reuses shared composer and file strip components', () => {
assert.match(aiModeComponent, /import \{ proxyRefs \} from 'vue'/)
assert.match(aiModeComponent, /import WorkbenchAiComposer from '\.\/workbench-ai\/WorkbenchAiComposer\.vue'/)
assert.match(aiModeComponent, /import WorkbenchAiFileStrip from '\.\/workbench-ai\/WorkbenchAiFileStrip\.vue'/)
assert.match(aiModeComponent, /const aiModeRuntime = usePersonalWorkbenchAiMode\(props, emit\)/)
assert.match(aiModeComponent, /const workbenchAiRuntime = proxyRefs\(aiModeRuntime\)/)
assert.equal(countOccurrences(aiModeTemplate, /<WorkbenchAiComposer\b/g), 2)
assert.equal(countOccurrences(aiModeTemplate, /<WorkbenchAiFileStrip\b/g), 2)
assert.doesNotMatch(aiModeTemplate, /<form class="workbench-ai-composer"/)
assert.doesNotMatch(aiModeTemplate, /<article v-for="file in selectedFileCards"/)
})
test('shared workbench composer keeps the parent input focus ref writable', () => {
assert.match(composerComponent, /:ref="runtime\.setAssistantInputRef"/)
assert.match(aiModeRuntime, /function setAssistantInputRef\(element\)/)
assert.match(aiModeRuntime, /assistantInputRef\.value = element/)
assert.match(aiModeRuntime, /setAssistantInputRef,/)
})
test('shared workbench file strip preserves OCR status badges', () => {
assert.match(fileStripComponent, /file\.ocrState\?\.label/)
assert.match(fileStripComponent, /class="workbench-ai-file-card__ocr"/)
assert.match(fileStripComponent, /file\.ocrState\.status === 'recognizing'/)
assert.match(fileStripComponent, /mdi mdi-text-recognition/)
assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/)
})

View File

@@ -156,13 +156,15 @@ const appShell = readSource('../src/views/AppShellRouteView.vue')
const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
const aiModeComposer = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
const aiModeFileStrip = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
const aiModeRuntime = readdirSync(aiModeRuntimeDir)
.filter((file) => file.endsWith('.js'))
.sort()
.map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8'))
.join('\n')
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeRuntime}`
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeComposer}\n${aiModeFileStrip}\n${aiModeRuntime}`
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')
@@ -228,7 +230,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /费用测算中,请稍等/)
assert.match(aiModeSurface, /rows="3"/)
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
assert.match(aiModeSurface, /<article v-for="file in selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /<article v-for="file in runtime\.selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
@@ -301,8 +303,9 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /解释制度/)
assert.match(aiModeSurface, /催办审批/)
assert.match(aiModeSurface, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
assert.match(aiModeSurface, /@submit\.prevent="submitAiModePrompt"/)
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 2)
assert.match(aiModeSurface, /@submit\.prevent="runtime\.submitAiModePrompt"/)
assert.equal((aiModeSurface.match(/<WorkbenchAiComposer\b/g) || []).length, 2)
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 1)
assert.match(aiModeSurface, /class="workbench-ai-conversation"/)
assert.match(aiModeSurface, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
assert.match(aiModeSurface, /workbench-ai-answer-card/)
@@ -393,6 +396,14 @@ test('AI mode screen follows the approved reference structure', () => {
aiModeSurface,
/suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\([\s\S]*targetMessage\.applicationPreview/
)
assert.match(aiModeSurface, /function isOrphanInlineApplicationPreviewMessage\(message = \{\}\)/)
assert.match(aiModeSurface, /function resolveLatestOrphanApplicationPreviewMessage\(messages = \[\]\)/)
assert.match(aiModeSurface, /function resolveLatestOrphanInlineApplicationPreviewMessage\(\)/)
assert.match(aiModeSurface, /当前申请核对表状态不完整,我先重新生成可编辑表格。/)
assert.match(
aiModeSurface,
/const previewSourceText = resolveLatestInlineUserPrompt\(\)[\s\S]*pushInlineApplicationActionUserMessage\(prompt\)[\s\S]*startAiApplicationPreview\('travel', '差旅费', previewSourceText/
)
assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/)
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)

View File

@@ -314,7 +314,10 @@ test('linked application selection can create reimbursement draft from associati
})
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
assert.match(personalWorkbenchAiMode, /function isReimbursementCreationIntent\(prompt = ''\)/)
assert.match(
personalWorkbenchAiMode,
/import \{ isReimbursementCreationIntent \} from '\.\/workbenchAiApplicationGateModel\.js'/
)
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)
const stewardIndex = personalWorkbenchAiMode.indexOf('stewardFlow.requestInlineAssistantReply(cleanPrompt', startConversationIndex)