feat: 完善文档中心与报销申请交互及侧边栏重构

后端优化编排器报销查询和本体检测精度,增强报销单草稿保
存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导
航,完善文档中心状态筛选和详情提示,报销创建和审批详情
页优化会话管理和费用明细交互,新增助手应用服务和预设动
作工具函数,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-25 13:35:39 +08:00
parent 50b1c3f9a9
commit d0e946cf47
59 changed files with 5117 additions and 416 deletions

View File

@@ -98,3 +98,29 @@ test('detail topbar still flags real manual rows without required ticket info',
assert.equal(hasPendingInfo(request), true)
assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息'])
})
test('application detail topbar does not ask for receipt attachments', () => {
const request = {
id: 'APP-20260525-ABC123',
claimNo: 'APP-20260525-ABC123',
documentTypeCode: 'application',
node: '直属领导审批',
approvalKey: 'in_progress',
typeCode: 'travel_application',
typeLabel: '差旅费用申请',
reason: '支撑国网服务器上线部署',
location: '上海',
city: '上海',
occurredDisplay: '2026-05-25 ~ 2026-05-28',
amountValue: 12000,
attachmentSummary: '申请单',
secondaryStatusValue: '已进入审批流程',
expenseItems: []
}
const alerts = buildDetailAlerts(request).map((item) => item.label)
assert.equal(hasMissingAttachment(request), false)
assert.equal(alerts.includes('缺少票据'), false)
assert.deepEqual(alerts, ['直属领导审批'])
})

View File

@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
buildWelcomeInsight,
buildWelcomeMessage
} from '../src/views/scripts/travelReimbursementConversationModel.js'
const appShellRouteView = readFileSync(
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const appShellComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
)
const assistantScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const assistantTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
test('application and reimbursement entries open the same financial assistant modal', () => {
assert.match(appShellRouteView, /<TravelReimbursementCreateView[\s\S]*:entry-source="smartEntryContext\.source"/)
assert.match(appShellRouteView, /@create-request="openTravelCreate"/)
assert.match(appShellRouteView, /@create-application="openExpenseApplicationCreate"/)
assert.match(appShellRouteView, /@new-application="openExpenseApplicationCreate"/)
assert.doesNotMatch(appShellRouteView, /ExpenseApplicationDialog/)
})
test('application entry keeps its own assistant source without creating a separate dialog', () => {
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)
assert.match(appShellComposable, /function openTravelCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_REIMBURSEMENT\)/)
assert.match(appShellComposable, /openExpenseApplicationCreate,/)
assert.match(assistantScript, /activeSessionType\.value === SESSION_TYPE_APPLICATION[\s\S]*我想先申请一笔差旅费用/)
})
test('financial assistant toolbar renders four isolated assistant sessions', () => {
assert.match(assistantScript, /ASSISTANT_SESSION_MODE_OPTIONS\.map/)
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/)
assert.match(assistantTemplate, /:class="\{ active: shortcut\.active \}"/)
assert.match(assistantTemplate, /:aria-pressed="shortcut\.active \? 'true' : 'false'"/)
assert.match(assistantTemplate, /:disabled="shortcut\.active \|\| submitting/)
})
test('financial assistant welcome copy differentiates application intent from reimbursement entry', () => {
const user = { name: '李文静', username: 'wenjing.li', grade: 'P5' }
const applicationWelcome = buildWelcomeMessage('application', null, SESSION_TYPE_APPLICATION, user)
const reimbursementWelcome = buildWelcomeMessage('topbar', null, SESSION_TYPE_EXPENSE, user)
const knowledgeWelcome = buildWelcomeMessage('topbar', null, SESSION_TYPE_KNOWLEDGE, user)
const applicationInsight = buildWelcomeInsight('application', null, SESSION_TYPE_APPLICATION, user)
assert.match(applicationWelcome, /申请助手/)
assert.match(applicationWelcome, /费用申请、报销申请还是其他财务事项/)
assert.match(reimbursementWelcome, /报销助手/)
assert.match(reimbursementWelcome, /报销发起、票据识别、草稿归集、报销信息核对/)
assert.match(knowledgeWelcome, /财务知识助手/)
assert.notEqual(applicationWelcome, reimbursementWelcome)
assert.equal(applicationInsight.metricValue, '申请助手')
assert.equal(applicationInsight.title, '申请助手')
})

View File

@@ -90,12 +90,12 @@ test('saving a draft keeps the financial assistant open for continued work', ()
assert.ok(handleDraftSavedBlock)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*smartEntryOpen\.value = false/)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: 'app-requests' \}\)/)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: activeView\.value === 'documents' \? 'app-documents' : 'app-requests' \}\)/)
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-requests' })", draftSuccessIndex), -1)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })", draftSuccessIndex), -1)
})
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {

View File

@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../src/utils/assistantSuggestedActionPrefill.js'
test('suggested action prefill uses backend prompt payload', () => {
assert.equal(
resolveSuggestedActionPrefill({
action_type: 'prefill_composer',
payload: {
application_field: 'time',
prompt_prefill: '申请时间段:'
}
}),
'申请时间段:'
)
})
test('suggested action prefill falls back to application field templates', () => {
assert.equal(
resolveSuggestedActionPrefill({
action_type: 'prefill_composer',
payload: { application_field: 'amount' }
}),
'预计总费用:'
)
assert.equal(
resolveSuggestedActionPrefill({
action_type: 'ask_clarification',
payload: { application_field: 'amount' }
}),
''
)
})
test('composer prefill appends to existing draft without duplication', () => {
assert.equal(mergeComposerPrefill('', '事由:'), '事由:')
assert.equal(mergeComposerPrefill('地点:上海', '事由:'), '地点:上海\n事由')
assert.equal(mergeComposerPrefill('地点:上海\n事由', '事由:'), '地点:上海\n事由')
})

View File

@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
countNewDocuments,
isNewDocument,
markDocumentViewed,
readDocumentScope,
readViewedDocumentKeys,
resolveDocumentNewKey,
writeDocumentScope
} from '../src/utils/documentCenterNewState.js'
function createMemoryStorage(initial = {}) {
const store = new Map(Object.entries(initial))
return {
getItem(key) {
return store.has(key) ? store.get(key) : null
},
setItem(key, value) {
store.set(key, value)
}
}
}
test('document center new state resolves source scoped document keys', () => {
assert.equal(resolveDocumentNewKey({ source: 'archive', claimId: 'claim-1' }), 'archive:claim-1')
assert.equal(resolveDocumentNewKey({ source: 'approval', documentNo: 'EXP-1' }), 'approval:EXP-1')
})
test('document center new state counts unseen documents and persists viewed rows', () => {
const storage = createMemoryStorage()
const rows = [
{ source: 'archive', claimId: 'claim-1' },
{ source: 'archive', claimId: 'claim-2' }
]
let viewedKeys = readViewedDocumentKeys(storage)
assert.equal(countNewDocuments(rows, viewedKeys), 2)
assert.equal(isNewDocument(rows[0], viewedKeys), true)
viewedKeys = markDocumentViewed(rows[0], viewedKeys, storage)
assert.equal(countNewDocuments(rows, viewedKeys), 1)
assert.equal(isNewDocument(rows[0], viewedKeys), false)
assert.deepEqual([...readViewedDocumentKeys(storage)], ['archive:claim-1'])
})
test('document center scope state restores only allowed tabs', () => {
const storage = createMemoryStorage()
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']
assert.equal(readDocumentScope('全部', scopes, storage), '全部')
writeDocumentScope('归档', scopes, storage)
assert.equal(readDocumentScope('全部', scopes, storage), '归档')
writeDocumentScope('不存在', scopes, storage)
assert.equal(readDocumentScope('全部', scopes, storage), '归档')
})

View File

@@ -25,24 +25,28 @@ test('documents center keeps only the top scope tabs and renders status as a dro
assert.match(documentsCenterView, /@click="selectStatusTab\(option\.value\)"/)
})
test('documents center top tabs start from application and show document category labels', () => {
assert.doesNotMatch(documentsCenterView, /const DOCUMENT_SCOPE_ALL = '全部'/)
test('documents center top tabs start from all and show document category labels', () => {
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ALL = '全部'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_APPLICATION = '申请单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
assert.match(documentsCenterView, /const activeScopeTab = ref\(DOCUMENT_SCOPE_APPLICATION\)/)
assert.match(documentsCenterView, /const activeScopeTab = ref\(readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)\)/)
assert.match(
documentsCenterView,
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_ALL[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
)
assert.doesNotMatch(documentsCenterView, /DOCUMENT_SCOPE_ALL/)
})
test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
/activeScopeTab\.value === DOCUMENT_SCOPE_ALL[\s\S]*return nonArchivedRows\.value/
)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
)
assert.match(
documentsCenterView,
@@ -56,7 +60,22 @@ test('documents center category tabs map to the intended row sources', () => {
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_ARCHIVE[\s\S]*return archiveRows\.value/
)
assert.match(documentsCenterView, /return allSummaryRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\)/)
assert.match(documentsCenterView, /return nonArchivedRows\.value/)
})
test('documents center preserves application document type from mapped requests', () => {
assert.match(
documentsCenterView,
/const documentTypeCode = normalized\.documentTypeCode \|\| DOCUMENT_TYPE_REIMBURSEMENT/
)
assert.match(
documentsCenterView,
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/
)
assert.doesNotMatch(
documentsCenterView,
/documentTypeCode:\s*DOCUMENT_TYPE_REIMBURSEMENT,[\s\S]*documentTypeLabel:\s*'报销单'/
)
})
test('documents center list shows created time and conditional stay time columns', () => {
@@ -91,25 +110,70 @@ test('documents center action buttons are scoped to application and reimbursemen
})
test('documents center category tabs render bubble counts for new documents', () => {
assert.match(documentsCenterView, /readViewedDocumentKeys/)
assert.match(documentsCenterView, /const viewedDocumentKeys = ref\(readViewedDocumentKeys\(\)\)/)
assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/)
assert.match(documentsCenterView, /<span v-if="tab\.badgeCount > 0" class="scope-tab-badge"/)
assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/)
assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: countNewDocuments\(nonArchivedRows\.value, viewedDocumentKeys\.value\)/)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\)\.length/
/\[DOCUMENT_SCOPE_APPLICATION\]: countNewDocuments\(nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\), viewedDocumentKeys\.value\)/
)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: approvalRows\.value\.length/)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: archiveRows\.value\.length/)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: countNewDocuments\(ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\), viewedDocumentKeys\.value\)/
)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: countNewDocuments\(approvalRows\.value, viewedDocumentKeys\.value\)/)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: countNewDocuments\(archiveRows\.value, viewedDocumentKeys\.value\)/)
assert.match(
documentsCenterView,
/const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/
)
})
test('documents center rows show NEW marker until the row is opened', () => {
assert.match(documentsCenterView, /<span v-if="row\.isNewDocument" class="new-document-badge">NEW<\/span>/)
assert.match(documentsCenterView, /isNewDocument: isNewDocument\(/)
assert.match(
documentsCenterView,
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
)
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*background:\s*#fff5f5;/)
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*border:\s*1px solid #fecaca;/)
assert.match(documentsCenterStyles, /\.new-document-badge::before\s*\{[\s\S]*background:\s*#ef4444;/)
assert.doesNotMatch(documentsCenterStyles, /newDocumentPulse/)
})
test('documents center empty states stay emerald across all scope tabs', () => {
const emptyStateBlock = documentsCenterView.match(/const emptyState = computed\(\(\) => \{[\s\S]*?\n\}\)/)?.[0] || ''
assert.match(emptyStateBlock, /eyebrow: '申请单'[\s\S]*tone: 'emerald'/)
assert.match(emptyStateBlock, /title: filtered \? '没有符合当前条件的单据'[\s\S]*tone: 'emerald'/)
assert.doesNotMatch(emptyStateBlock, /tone:\s*'sky'/)
assert.doesNotMatch(emptyStateBlock, /tone:\s*'slate'/)
assert.doesNotMatch(emptyStateBlock, /tone:\s*'amber'/)
})
test('documents center empty states do not render small action buttons', () => {
const emptyStateBlock = documentsCenterView.match(/const emptyState = computed\(\(\) => \{[\s\S]*?\n\}\)/)?.[0] || ''
assert.match(emptyStateBlock, /actionLabel:\s*''/)
assert.match(emptyStateBlock, /actionIcon:\s*''/)
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*filtered/)
assert.doesNotMatch(emptyStateBlock, /actionIcon:\s*filtered/)
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'发起申请'/)
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'发起报销'/)
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'清空筛选'/)
})
test('documents center switches filter conditions by category tab', () => {
assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/)
assert.doesNotMatch(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: \{/)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_ALL\]: \{[\s\S]*sceneFallbackLabel: '单据场景'[\s\S]*statusTitle: '单据状态'[\s\S]*showDocumentType: true/
)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '申请状态'[\s\S]*showDocumentType: false/

View File

@@ -0,0 +1,69 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildApplicationFieldsFromOntology,
expandApplicationTimeWithDays,
resolveApplicationReason,
resolveApplicationTimeRange,
resolvePromptField
} from '../src/utils/expenseApplicationOntology.js'
const structuredApplicationPrompt = [
'发生时间2026-05-25',
'地点:上海',
'事由:支撑国网服务器部署',
'天数3天'
].join('\n')
test('expense application fields use labeled reason and filter resolved missing slots', () => {
const fields = buildApplicationFieldsFromOntology(
{
scenario: 'expense',
intent: 'draft',
entities: [],
time_range: {},
missing_slots: ['time_range', 'location', 'reason', 'amount']
},
structuredApplicationPrompt,
{ name: '申请员工', departmentName: '交付部' }
)
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-28')
assert.equal(fields.location, '上海')
assert.equal(fields.reason, '支撑国网服务器部署')
assert.deepEqual(
fields.missingSlots.map((item) => item.key),
['amount']
)
})
test('expense application prompt field parser supports multiline labels', () => {
assert.equal(resolvePromptField(structuredApplicationPrompt, ['事由']), '支撑国网服务器部署')
assert.equal(resolveApplicationReason(structuredApplicationPrompt), '支撑国网服务器部署')
})
test('expense application expands a single selected date with natural days', () => {
const prompt = [
'发生时间2026-05-25',
'去上海出差3天支撑国网服务器部署'
].join('\n')
assert.equal(expandApplicationTimeWithDays('2026-05-25', 3), '2026-05-25 至 2026-05-28')
assert.equal(resolveApplicationTimeRange({ time_range: {} }, prompt), '2026-05-25 至 2026-05-28')
})
test('expense application keeps explicit time range before applying days', () => {
assert.equal(
resolveApplicationTimeRange(
{
time_range: {
start_date: '2026-05-25',
end_date: '2026-05-27'
}
},
'去上海出差3天支撑国网服务器部署'
),
'2026-05-25 至 2026-05-27'
)
})

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'
import { renderMarkdown } from '../src/utils/markdown.js'
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
test('expense application submit uses rich text link and confirm dialog', () => {
const copy = '请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。'
const rendered = renderMarkdown(copy)
assert.match(
rendered,
/<a href="#application-submit" class="markdown-action-link markdown-action-link-confirm">确认<\/a>/
)
assert.match(createViewTemplate, /:open="applicationSubmitConfirmDialog\.open"/)
assert.match(createViewTemplate, /title="确认提交当前费用申请?"/)
assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,并同步纳入预算管理口径/)
assert.match(createViewTemplate, /@confirm="confirmApplicationSubmit"/)
assert.match(createViewScript, /const APPLICATION_SUBMIT_HREF = '#application-submit'/)
assert.match(
createViewScript,
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
)
assert.match(
createViewScript,
/async function confirmApplicationSubmit\(\)[\s\S]*rawText: '确认提交'[\s\S]*systemGenerated: true/
)
assert.match(
createViewScript,
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/
)
assert.match(
createViewScript,
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
)
})

View File

@@ -11,7 +11,8 @@ const workbench = readFileSync(
test('workbench assistant greets the current employee without the old helper tag', () => {
assert.doesNotMatch(workbench, /assistant-tag/)
assert.doesNotMatch(workbench, /AI 报销助手/)
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\},描述费用或上传票据AI 直接帮你判断怎么报/)
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\},描述您想做的事AI 直接帮您处理/)
assert.match(workbench, /我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作/)
assert.match(workbench, /const assistantGreetingName = computed/)
assert.match(workbench, /user\.name/)
})

View File

@@ -79,6 +79,15 @@ test('business activity without expense intent asks for reimbursement confirmati
assert.equal(shouldRequestExpenseSceneSelection(businessMessage), false)
})
test('non-reimbursement assistant sessions do not trigger reimbursement scene selection', () => {
const ambiguousMessage = '业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销'
assert.equal(shouldRequestExpenseSceneSelection(ambiguousMessage, { sessionType: 'application' }), false)
assert.equal(shouldRequestExpenseIntentConfirmation('去上海电力支撑项目部署', { sessionType: 'approval' }), false)
assert.match(buildLocalIntentPreview(ambiguousMessage, 'application'), /费用申请事项/)
assert.match(buildLocalIntentPreview('查一下待我审核的单据', 'approval'), /审核处理事项/)
})
test('explicit technical operation does not ask for reimbursement confirmation', () => {
const operationMessage = '去上海电力支撑项目部署,帮我整理服务器部署步骤'

View File

@@ -3,6 +3,83 @@ import test from 'node:test'
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
test('application claims are mapped as application documents', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-1',
claim_no: 'APP-20260525-ABC123',
employee_name: '张三',
department_name: '交付部',
expense_type: 'travel_application',
reason: '支撑国网服务器上线部署',
location: '上海',
amount: 12000,
invoice_count: 0,
occurred_at: '2026-05-25T00:00:00.000Z',
submitted_at: '2026-05-25T02:00:00.000Z',
created_at: '2026-05-25T01:30:00.000Z',
updated_at: '2026-05-25T02:00:00.000Z',
status: 'submitted',
approval_stage: '直属领导审批',
risk_flags_json: [],
items: []
})
assert.equal(request.documentTypeCode, 'application')
assert.equal(request.documentTypeLabel, '申请单')
assert.equal(request.typeLabel, '差旅费用申请')
assert.equal(request.secondaryStatusLabel, '申请材料')
assert.equal(request.secondaryStatusValue, '已进入审批流程')
assert.equal(request.expenseTableSummary, '预计金额已纳入预算管理口径')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
['创建申请', '直属领导审批', '审批完成']
)
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.current, true)
})
test('approved application claims complete after direct manager approval only', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-approved',
claim_no: 'APP-20260525-DONE01',
employee_name: '张三',
department_name: '交付部',
manager_name: '李经理',
expense_type: 'travel_application',
reason: '支撑国网服务器上线部署',
location: '上海',
amount: 12000,
invoice_count: 0,
occurred_at: '2026-05-25T00:00:00.000Z',
submitted_at: '2026-05-25T02:00:00.000Z',
created_at: '2026-05-25T01:30:00.000Z',
updated_at: '2026-05-25T03:00:00.000Z',
status: 'approved',
approval_stage: '审批完成',
risk_flags_json: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: '李经理',
previous_approval_stage: '直属领导审批',
next_approval_stage: '审批完成',
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.equal(request.documentTypeCode, 'application')
assert.equal(request.workflowNode, '审批完成')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
['创建申请', '直属领导审批', '审批完成']
)
assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过')
})
test('progress steps show approval operator time and current stay duration', () => {
const originalNow = Date.now
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()

View File

@@ -0,0 +1,41 @@
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 sidebar = readFileSync(
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', 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, /:collapsed="sidebarCollapsed"/)
assert.match(appShell, /class="app-sidebar"/)
assert.match(appShell, /@toggle-collapse="toggleSidebarCollapsed"/)
assert.match(appShell, /function toggleSidebarCollapsed\(\)/)
assert.match(appShell, /sidebarCollapsed\.value = !sidebarCollapsed\.value/)
assert.match(sidebar, /collapsed:\s*\{\s*type: Boolean/)
assert.match(sidebar, /'toggle-collapse'/)
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(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

@@ -34,6 +34,28 @@ test('composer formats date-picker expense text into readable structured fields'
)
})
test('composer extracts destination and reason from compact travel text', () => {
const formatted = buildStructuredComposerSubmitText(
'出差上海,支撑国网服务器上线部署',
{
mode: 'single',
start_date: '2026-05-25',
end_date: '2026-05-25',
business_time: '2026-05-25'
}
)
assert.equal(
formatted,
[
'发生时间2026-05-25',
'地点:上海',
'事由:支撑国网服务器上线部署',
'天数1天'
].join('\n')
)
})
test('composer keeps backend raw text but displays structured user message', () => {
assert.match(submitComposerScript, /const rawText = resolveComposerSubmitText\(options\.rawText\)\.trim\(\)/)
assert.match(submitComposerScript, /resolveComposerDisplaySubmitText\(rawText\)/)

View File

@@ -4,7 +4,12 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
APPLICATION_WELCOME_QUICK_ACTIONS,
APPROVAL_WELCOME_QUICK_ACTIONS,
ASSISTANT_SESSION_MODE_OPTIONS,
EXPENSE_WELCOME_QUICK_ACTIONS,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
buildWelcomeQuickActions
@@ -40,6 +45,10 @@ import {
selectGuidedQueryMode,
shouldConfirmGuidedInterruption
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
import {
ASSISTANT_SCOPE_ACTION_SWITCH,
resolveAssistantScopeGuard
} from '../src/utils/assistantSessionScope.js'
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
@@ -53,8 +62,16 @@ const sessionStateScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
'utf8'
)
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
test('welcome quick actions are reduced to three guided local actions', () => {
test('assistant session modes expose independent quick actions', () => {
assert.deepEqual(
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
['申请助手', '报销助手', '审核助手', '财务知识助手']
)
assert.deepEqual(
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
['快速发起报销', '查询单据状态', '差旅计算器']
@@ -69,6 +86,14 @@ test('welcome quick actions are reduced to three guided local actions', () => {
)
assert.ok(EXPENSE_WELCOME_QUICK_ACTIONS.every((item) => !item.prompt))
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_EXPENSE).length, 3)
assert.deepEqual(
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).map((item) => item.label),
APPLICATION_WELCOME_QUICK_ACTIONS.map((item) => item.label)
)
assert.deepEqual(
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).map((item) => item.label),
APPROVAL_WELCOME_QUICK_ACTIONS.map((item) => item.label)
)
assert.notDeepEqual(
buildWelcomeQuickActions(SESSION_TYPE_KNOWLEDGE).map((item) => item.label),
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
@@ -76,6 +101,34 @@ test('welcome quick actions are reduced to three guided local actions', () => {
)
})
test('assistant session scope guard keeps business boundaries isolated', () => {
const expenseInApplication = resolveAssistantScopeGuard('我想报销的士票', SESSION_TYPE_APPLICATION)
assert.equal(expenseInApplication.targetSessionType, SESSION_TYPE_EXPENSE)
assert.match(expenseInApplication.text, /申请助手/)
assert.match(expenseInApplication.text, /报销助手/)
assert.equal(expenseInApplication.suggestedActions[0].action_type, ASSISTANT_SCOPE_ACTION_SWITCH)
assert.equal(expenseInApplication.suggestedActions[0].payload.session_type, SESSION_TYPE_EXPENSE)
assert.equal(expenseInApplication.suggestedActions[0].payload.carry_text, '我想报销的士票')
assert.equal(resolveAssistantScopeGuard('我想发起一笔费用申请', SESSION_TYPE_APPLICATION), null)
assert.equal(
resolveAssistantScopeGuard('帮我查询待我审核的单据', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPROVAL
)
assert.equal(
resolveAssistantScopeGuard('差旅住宿标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_KNOWLEDGE
)
assert.equal(
resolveAssistantScopeGuard('报销标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_KNOWLEDGE
)
assert.equal(
resolveAssistantScopeGuard('解释这张单据酒店超标风险', SESSION_TYPE_EXPENSE, { hasActiveReviewPayload: true }),
null
)
})
test('guided reimbursement asks type first and walks travel fields in order', () => {
const typeActions = buildGuidedExpenseTypeActions()
assert.deepEqual(
@@ -177,6 +230,9 @@ test('guided flow state is serializable and restored through session state', ()
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
assert.match(sessionStateScript, /ASSISTANT_SESSION_TYPES\.reduce/)
assert.match(sessionStateScript, /props\.entrySource === 'application' \? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE/)
assert.match(sessionStateScript, /const canRestorePersistedInitialState =[\s\S]*shouldPersistLocalSnapshot/)
})
test('guided flow is local until final confirmation or collected query handoff', () => {
@@ -184,6 +240,10 @@ test('guided flow is local until final confirmation or collected query handoff',
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
assert.match(createViewScript, /actionPayload\.carry_text/)
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
assert.match(submitComposerScript, /skipScopeGuard/)
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
assert.match(guidedFlowScript, /submitExistingComposer\(\{[\s\S]*pendingText:\s*'正在查询单据状态\.\.\.'/)
})

View File

@@ -15,7 +15,9 @@ import {
} from '../src/views/scripts/travelRequestDetailInsights.js'
import {
buildExpenseItemViewModel,
buildDraftBlockingIssues
buildDraftBlockingIssues,
buildOptionalTravelReceiptRiskCards,
isApplicationDocumentRequest
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
const detailViewTemplate = readFileSync(
@@ -412,7 +414,7 @@ test('expense attachment actions keep preview as the only recognition entry poin
})
test('expense detail table shows the amount total below detail rows', () => {
assert.match(detailViewTemplate, /<div class="detail-expense-table">/)
assert.match(detailViewTemplate, /<div[^>]*class="detail-expense-table"/)
assert.match(detailViewTemplate, /当前还没有费用明细/)
assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
assert.match(detailViewTemplate, /class="expense-total-under-table"[\s\S]*金额合计[\s\S]*\{\{ expenseTotal \}\}/)
@@ -421,7 +423,10 @@ test('expense detail table shows the amount total below detail rows', () => {
})
test('additional note is shown above expense details as travel purpose text', () => {
assert.ok(detailViewTemplate.indexOf('<h3>附加说明</h3>') < detailViewTemplate.indexOf('<h3>费用明细</h3>'))
assert.ok(
detailViewTemplate.indexOf('<h3>附加说明</h3>')
< detailViewTemplate.indexOf("isApplicationDocument ? '申请预算' : '费用明细'")
)
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
@@ -514,6 +519,7 @@ test('expense detail edit keeps delete but removes cancel and allows draft place
test('travel detail AI advice adds low risk reminders for optional receipts', () => {
assert.match(detailViewScript, /function buildOptionalTravelReceiptRiskCards\(requestModel, items\)/)
assert.match(detailViewScript, /isApplicationDocumentRequest\(requestModel\)[\s\S]*return \[\]/)
assert.match(detailViewScript, /id: 'travel-optional-hotel-ticket'[\s\S]*tone: 'low'[\s\S]*住宿票据提醒/)
assert.match(detailViewScript, /不要忘记补充酒店住宿票据/)
assert.match(detailViewScript, /id: 'travel-optional-ride-ticket'[\s\S]*tone: 'low'[\s\S]*乘车票据提醒/)
@@ -539,6 +545,33 @@ test('expense detail save is blocked while attachment recognition is running', (
)
})
test('application detail uses application labels instead of reimbursement labels', () => {
assert.match(detailViewTemplate, /isApplicationDocument \? '申请进度'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请预算' : '费用明细'/)
assert.match(detailViewTemplate, /无需补充任何报销票据/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)
assert.match(detailViewTemplate, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
})
test('application detail does not show optional travel receipt reminders', () => {
const request = {
documentTypeCode: 'application',
claimNo: 'APP-20260525-ABC123',
typeCode: 'travel_application',
detailVariant: 'travel'
}
assert.equal(isApplicationDocumentRequest(request), true)
assert.deepEqual(
buildOptionalTravelReceiptRiskCards(request, [
{ id: 'allowance', itemType: 'travel_allowance', isSystemGenerated: true, invoiceId: '' }
]),
[]
)
})
test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
const issues = buildDraftBlockingIssues(
{