feat: 新增风险图谱算法与系统仪表盘及操作反馈体系

后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-30 15:46:51 +08:00
parent 4c59941ec6
commit 7989f3a159
314 changed files with 30073 additions and 20626 deletions

View File

@@ -3,12 +3,20 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildOperationFeedbackPayload,
normalizeOperationFeedbackContext
} from '../src/composables/useOperationFeedback.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
buildMessageMeta,
buildWelcomeInsight,
buildWelcomeMessage
buildWelcomeMessage,
createMessage,
filterVisibleMessageMeta
} from '../src/views/scripts/travelReimbursementConversationModel.js'
const appShellRouteView = readFileSync(
@@ -23,10 +31,26 @@ const assistantScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const assistantSubmitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const assistantTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
const messageItemTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
'utf8'
)
const chatViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/ChatView.vue', import.meta.url)),
'utf8'
)
const operationFeedbackInlineTemplate = readFileSync(
fileURLToPath(new URL('../src/components/shared/OperationFeedbackInlineCard.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"/)
@@ -45,7 +69,8 @@ test('application entry keeps its own assistant source without creating a separa
})
test('financial assistant toolbar renders four isolated assistant sessions', () => {
assert.match(assistantScript, /ASSISTANT_SESSION_MODE_OPTIONS\.map/)
assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/)
assert.match(assistantScript, /visibleModes\.map/)
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/)
assert.match(assistantTemplate, /:class="\{ active: shortcut\.active \}"/)
@@ -79,3 +104,88 @@ test('financial assistant welcome copy differentiates application intent from re
assert.equal(applicationInsight.metricValue, '申请助手')
assert.equal(applicationInsight.title, '申请助手')
})
test('assistant message meta hides internal routing and permission chips', () => {
const meta = buildMessageMeta(
{
selected_agent: 'user_agent',
permission_level: 'draft_write',
run_id: 'run-001',
trace_summary: {
tool_count: 3,
degraded: true
},
requires_confirmation: true
},
['invoice.pdf']
)
assert.deepEqual(meta, ['已降级', '待确认', '附件: 1'])
assert.deepEqual(
filterVisibleMessageMeta(['Agent: user_agent', '权限: draft_write', 'Run: run-001', '工具: 3', '等待确认']),
['等待确认']
)
assert.deepEqual(
createMessage('assistant', '测试', [], { meta: ['Agent: user_agent', '权限: draft_write', '处理中'] }).meta,
['处理中']
)
assert.doesNotMatch(messageItemTemplate, /message-meta-row|message-meta-chip/)
assert.doesNotMatch(chatViewTemplate, /agent-meta-row|agent-meta-chip/)
})
test('assistant operation feedback is inline and persists run context', () => {
assert.doesNotMatch(appShellRouteView, /<OperationFeedbackDialog/)
assert.doesNotMatch(appShellRouteView, /@operation-completed="handleOperationCompleted"/)
assert.doesNotMatch(appShellComposable, /useOperationFeedback/)
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /class="message-feedback-bubble"/)
assert.match(messageItemTemplate, /:submitted="Boolean\(message\.operationFeedback\?\.submitted\)"/)
assert.match(messageItemTemplate, /:submitted-rating="Number\(message\.operationFeedback\?\.rating \|\| 0\)"/)
assert.match(assistantScript, /emits:\s*\['close', 'draft-saved', 'request-updated'\]/)
assert.match(appShellRouteView, /@request-updated="handleRequestUpdated"/)
assert.match(assistantScript, /function submitOperationFeedbackForMessage/)
assert.match(assistantScript, /createOperationFeedback/)
assert.match(assistantScript, /normalizeOperationFeedbackContext/)
assert.match(assistantScript, /&& !feedback\.dismissed/)
assert.doesNotMatch(assistantScript, /&& !feedback\.submitted/)
assert.match(assistantScript, /submitted:\s*true/)
assert.match(assistantScript, /dismissed:\s*false/)
assert.doesNotMatch(assistantScript, /emit\('operation-completed'/)
assert.match(assistantSubmitComposerScript, /emitOperationCompleted\?\.\(payload/)
assert.match(assistantSubmitComposerScript, /operationFeedback:\s*buildOperationFeedbackState/)
assert.match(assistantSubmitComposerScript, /rating:\s*0/)
assert.match(operationFeedbackInlineTemplate, /v-for="option in ratingOptions"/)
assert.match(operationFeedbackInlineTemplate, /is-submitted/)
assert.match(operationFeedbackInlineTemplate, /submittedRating/)
assert.match(operationFeedbackInlineTemplate, /感谢您的反馈。谢谢/)
assert.match(operationFeedbackInlineTemplate, /busy \|\| submitted/)
assert.match(operationFeedbackInlineTemplate, /role="radiogroup"/)
assert.match(operationFeedbackInlineTemplate, /handleRatingKeydown/)
assert.match(operationFeedbackInlineTemplate, /operation-feedback-stars/)
assert.match(operationFeedbackInlineTemplate, /score > 3/)
assert.match(operationFeedbackInlineTemplate, /v-if="showReasonInput"/)
assert.match(operationFeedbackInlineTemplate, /稍后/)
const context = normalizeOperationFeedbackContext(
{
run_id: 'run-001',
conversation_id: 'conv-001',
selected_agent: 'user_agent',
session_type: 'application',
operation_status: 'succeeded',
route_reason: 'model_route',
result: { answer: '处理完成' }
},
{ username: 'wenjing.li' }
)
const payload = buildOperationFeedbackPayload(context, { rating: 2, reason: '识别错了' })
assert.equal(context.runId, 'run-001')
assert.equal(context.userId, 'wenjing.li')
assert.equal(payload.run_id, 'run-001')
assert.equal(payload.conversation_id, 'conv-001')
assert.equal(payload.agent, 'user_agent')
assert.equal(payload.rating, 2)
assert.equal(payload.reason, '识别错了')
assert.equal(payload.context_json.low_rating, true)
})

View File

@@ -0,0 +1,31 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const shell = readFileSync(
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', 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('top-level app routes are eagerly imported', () => {
assert.doesNotMatch(router, /\(\)\s*=>\s*import\(/)
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

@@ -89,10 +89,15 @@ test('saving a draft keeps the financial assistant open for continued work', ()
)?.[0]
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-documents' \}\)/)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*if \(isApplicationDocument\) \{[\s\S]*return/)
assert.match(handleDraftSavedBlock, /smartEntryOpen\.value = false[\s\S]*router\.push\(\{ name: 'app-documents' \}\)/)
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
const applicationSubmittedIndex = handleDraftSavedBlock.indexOf('if (isApplicationDocument)')
const applicationSubmittedReturnIndex = handleDraftSavedBlock.indexOf('return', applicationSubmittedIndex)
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', applicationSubmittedIndex) > applicationSubmittedReturnIndex, true)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", applicationSubmittedIndex) > applicationSubmittedReturnIndex, true)
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", draftSuccessIndex), -1)

View File

@@ -14,6 +14,14 @@ test('app route guard allows stale healthy state when health check times out', (
assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/)
})
test('authenticated in-app navigation does not wait for backend health check', () => {
assert.match(routerScript, /function isAuthenticatedAppNavigation\(to, from\)/)
assert.match(
routerScript,
/if \(isAuthenticatedAppNavigation\(to, from\)\) \{[\s\S]*scheduleBackgroundBackendHealthCheck\(\)[\s\S]*return true[\s\S]*\}/
)
})
test('backend health timeout does not block app rendering when stale fallback is allowed', async () => {
const originalFetch = global.fetch

View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
extractWorkRecordToolSummary,
resolveWorkRecordModuleLabel,
resolveWorkRecordProductKind,
resolveWorkRecordTaskType,
resolveWorkRecordTitle
} from '../src/views/scripts/digitalEmployeeWorkRecordsModel.js'
const runProductsComponent = readFileSync(
fileURLToPath(new URL('../src/components/audit/DigitalEmployeeRunProducts.vue', import.meta.url)),
'utf8'
)
const digitalEmployeeDetailComponent = readFileSync(
fileURLToPath(new URL('../src/components/audit/AuditDigitalEmployeeDetail.vue', import.meta.url)),
'utf8'
)
test('digital employee risk graph run resolves structured product metadata', () => {
const run = {
route_json: {
task_code: 'task.hermes.global_risk_scan',
task_name: '财务风险图谱巡检'
},
tool_calls: [
{
tool_name: 'digital_employee.financial_risk_graph.scan',
request_json: { task_type: 'global_risk_scan' },
response_json: {
scanned_claim_count: 3,
risk_observation_count: 2,
graph_node_count: 7,
graph_edge_count: 6
}
}
]
}
assert.equal(resolveWorkRecordTaskType(run), 'global_risk_scan')
assert.equal(resolveWorkRecordProductKind(run), 'risk_graph')
assert.equal(resolveWorkRecordModuleLabel(run), '财务风险图谱巡检')
assert.equal(resolveWorkRecordTitle(run), '财务风险图谱巡检')
assert.equal(extractWorkRecordToolSummary(run).risk_observation_count, 2)
})
test('digital employee profile run resolves from tool request when route is sparse', () => {
const run = {
route_json: { selected_agent: 'hermes' },
tool_calls: [
{
tool_name: 'digital_employee.employee_behavior_profile.scan',
request_json: { task_type: 'employee_behavior_profile_scan' },
response_json: {
target_employee_count: 4,
snapshot_count: 16,
high_attention_employee_count: 1
}
}
]
}
assert.equal(resolveWorkRecordTaskType(run), 'employee_behavior_profile_scan')
assert.equal(resolveWorkRecordProductKind(run), 'employee_profile')
assert.equal(resolveWorkRecordModuleLabel(run), '员工行为画像巡检')
assert.equal(extractWorkRecordToolSummary(run).snapshot_count, 16)
})
test('digital employee work record product supports scoped observation expansion', () => {
assert.match(runProductsComponent, /activeObservationKey/)
assert.match(runProductsComponent, /toggleObservation/)
assert.match(runProductsComponent, /异常关系/)
assert.doesNotMatch(runProductsComponent, /KnowledgeIngestGraphView/)
})
test('digital employee skill detail does not render knowledge graph component', () => {
assert.doesNotMatch(digitalEmployeeDetailComponent, /KnowledgeIngestGraphView/)
assert.doesNotMatch(digitalEmployeeDetailComponent, /LightRAG 知识图谱/)
})

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { ref } from 'vue'
import {
buildApplicationPreviewFooterMessage,
@@ -21,6 +22,7 @@ import {
createMessage as createConversationMessage,
hasMeaningfulSessionMessages
} from '../src/views/scripts/travelReimbursementConversationModel.js'
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
@@ -42,6 +44,10 @@ const messageItemStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-item.css', import.meta.url)),
'utf8'
)
const applicationMessageStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-application.css', import.meta.url)),
'utf8'
)
const conversationModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
'utf8'
@@ -50,6 +56,38 @@ const previewEditorScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)),
'utf8'
)
const flowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
function createFlowHarness() {
return useTravelReimbursementFlow({
activeSessionType: ref('application'),
reviewDrawerMode: ref(''),
insightPanelCollapsed: ref(true),
isKnowledgeSession: ref(false),
fetchAgentRunDetail: async () => null,
buildLocalIntentPreview: () => '本地意图预览',
buildLocalExtractionProgressMessages: () => ['正在抽取信息'],
summarizeSemanticIntentDetail: () => '模型已完成意图识别',
summarizeSemanticParseDetail: () => '模型已完成信息抽取',
SCENARIO_LABELS: {},
INTENT_LABELS: {},
EXPENSE_TYPE_LABELS: {},
FLOW_STEP_FALLBACKS: {
intent: { title: '意图识别', tool: 'SemanticRouter', runningText: '正在识别业务意图...', completedText: '已识别业务意图' },
extraction: { title: '信息抽取', tool: 'SemanticExtractor', runningText: '正在抽取关键信息...', completedText: '已抽取关键信息' },
'application-submit-success': { title: '申请单提交成功', tool: 'ApplicationSubmit', runningText: '正在提交申请单...', completedText: '申请单提交成功' }
},
REVIEW_DRAWER_MODE_FLOW: 'flow',
REVIEW_DRAWER_MODE_REVIEW: 'review',
FLOW_STEP_STATUS_PENDING: 'pending',
FLOW_STEP_STATUS_RUNNING: 'running',
FLOW_STEP_STATUS_COMPLETED: 'completed',
FLOW_STEP_STATUS_FAILED: 'failed'
})
}
test('application intent uses local preview instead of immediate orchestrator call', () => {
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目出差3天高铁预计金额2358元'
@@ -119,8 +157,11 @@ test('application preview cleans empty time labels and keeps only business reaso
const preview = buildLocalApplicationPreview('发生时间去九江出差3天服务美团业务部署预计费用1800元火车', {
name: '李文静',
grade: 'P5'
}, {
today: '2026-05-29'
})
assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31')
assert.equal(preview.fields.location, '九江')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.fields.reason, '服务美团业务部署')
@@ -130,7 +171,7 @@ test('application preview cleans empty time labels and keeps only business reaso
test('application preview can be refined by ontology model extraction', () => {
const rawText = '发生时间去九江出差3天服务美团业务部署预计费用1800元火车'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' }, { today: '2026-05-29' })
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
@@ -153,11 +194,23 @@ test('application preview can be refined by ontology model extraction', () => {
assert.equal(refinedPreview.parseStrategy, 'llm_primary')
assert.equal(refinedPreview.modelReviewStatus, 'completed')
assert.equal(refinedPreview.fields.applicationType, '差旅费用申请')
assert.equal(refinedPreview.fields.time, '')
assert.equal(refinedPreview.fields.time, '2026-05-29 至 2026-05-31')
assert.equal(refinedPreview.fields.reason, '服务美团业务部署')
assert.equal(refinedPreview.fields.transportMode, '火车')
})
test('application preview precomputes a date range from today when only days are provided', () => {
const preview = buildLocalApplicationPreview(
'去北京出差3天支撑国网仿生产环境部署飞机预计费用12000元',
{ name: '李文静', grade: 'P5' },
{ today: '2026-05-29' }
)
assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31')
assert.equal(preview.fields.days, '3天')
assert.equal(preview.readyToSubmit, true)
})
test('application preview keeps rule fallback distinct from model reviewed result', () => {
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署出差3天火车预计费用1800元'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
@@ -279,8 +332,22 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /补齐后我再帮您提交申请。/)
assert.match(messageItemTemplate, /class="application-preview-footer message-answer-content message-answer-markdown"/)
assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/)
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
assert.match(messageItemTemplate, /application-preview-date-chip/)
assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /application-draft-head/)
assert.match(messageItemTemplate, /mdi mdi-file-document-check-outline/)
assert.match(messageItemTemplate, /'is-primary': item\.label === '单号'/)
assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/)
assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/)
assert.match(messageItemTemplate, /ui\.openApplicationDraftDetail\(message\)/)
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/)
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/)
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
assert.match(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.draftValue"/)
assert.doesNotMatch(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.singleDate"/)
assert.match(messageItemTemplate, /application-preview-select/)
assert.match(messageItemTemplate, /resolveApplicationPreviewEditorOptions/)
assert.match(messageItemTemplate, /row\.editable && !ui\.isApplicationPreviewEditing\(message, row\.key\).*ui\.openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
@@ -291,13 +358,26 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /openApplicationPreviewEditor/)
assert.match(messageItemTemplate, /commitApplicationPreviewEditor/)
assert.match(createViewScript, /resolveApplicationPreviewMissingFields/)
assert.match(createViewScript, /function applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
assert.doesNotMatch(createViewTemplate, /@click="applyComposerDateSelection"/)
assert.match(previewEditorScript, /normalizeApplicationPreview/)
assert.match(previewEditorScript, /APPLICATION_TRANSPORT_MODE_OPTIONS/)
assert.match(previewEditorScript, /getTodayDateValue/)
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
assert.match(previewEditorScript, /targetRow\.editable === false/)
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
assert.match(previewEditorScript, /fieldKey === 'time'\) return 'date'/)
assert.match(previewEditorScript, /commitApplicationPreviewDateEditor/)
assert.match(messageItemStyles, /@import "\.\/travel-reimbursement-message-application\.css";/)
assert.match(messageItemStyles, /\.application-preview-row\.missing \{[\s\S]*--theme-primary-rgb/)
assert.match(messageItemStyles, /\.application-preview-table \{[\s\S]*border: 1px solid #d7e4f2;[\s\S]*background: #ffffff;/)
assert.match(messageItemStyles, /\.application-preview-row \{[\s\S]*grid-template-columns: 108px minmax\(0, 1fr\);/)
@@ -307,6 +387,65 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-action-link\) \{[\s\S]*text-decoration: underline;/)
assert.match(messageItemStyles, /\.application-preview-footer-missing \{[\s\S]*margin-top: 48px;[\s\S]*background: transparent;/)
assert.match(messageItemStyles, /\.application-preview-missing-chip \{[\s\S]*background: rgba\(var\(--theme-primary-rgb/)
assert.doesNotMatch(applicationMessageStyles, /\.application-date-editor-layer/)
assert.match(applicationMessageStyles, /\.application-draft-preview \.application-draft-head \{[\s\S]*grid-template-columns: 36px minmax\(0, 1fr\) auto;/)
assert.match(applicationMessageStyles, /\.application-draft-brief \{[\s\S]*border: 1px solid #d7e4f2;/)
assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/)
assert.match(flowScript, /application-submit-success/)
assert.match(flowScript, /function shouldHideToolCall/)
assert.match(flowScript, /semantic_ontology/)
assert.match(flowScript, /return null/)
assert.match(flowScript, /申请单提交成功/)
assert.match(flowScript, /function resolveDurationFromFields/)
assert.match(flowScript, /function resolveStartedTimestamp/)
assert.match(flowScript, /function resolveFinishedTimestamp/)
assert.match(flowScript, /syntheticTiming/)
assert.match(flowScript, /refreshCompleted/)
})
test('flow panel durations use backend timing instead of local preview delay', () => {
const flow = createFlowHarness()
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
flow.startFlowStep('intent', '正在识别业务意图...')
flow.completeFlowStep('intent', '本地预览完成', 80)
flow.startFlowStep('extraction', '正在抽取关键信息...')
flow.completeFlowStep('extraction', '本地抽取完成', 90)
flow.startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交申请单...'
})
flow.completeFlowStep('application-submit-success', '本地提交完成', 100)
flow.mergeFlowRunDetail({
started_at: '2026-05-29T00:00:00.000Z',
finished_at: '2026-05-29T00:00:05.000Z',
status: 'succeeded',
semantic_parse: {},
ontology_json: {},
tool_calls: [
{
id: 'submit-1',
run_id: 'run-1',
tool_type: 'application',
tool_name: 'application.submit',
request_json: {},
response_json: { status: 'submitted', draft_payload: { status: 'submitted' } },
status: 'succeeded',
duration_ms: 2360,
created_at: '2026-05-29T00:00:04.000Z'
}
]
})
const durationByKey = Object.fromEntries(flow.flowSteps.value.map((step) => [step.key, step.durationMs]))
assert.equal(durationByKey.intent, 1400)
assert.equal(durationByKey.extraction, 2600)
assert.equal(durationByKey['application-submit-success'], 2360)
assert.equal(flow.flowTotalDurationText.value, '5.0s')
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: 0 }), '--')
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: null }), '--')
})
test('assistant markdown tables render with component-scoped table styling', () => {

View File

@@ -29,7 +29,7 @@ test('expense application fields use labeled reason and filter resolved missing
{ name: '申请员工', departmentName: '交付部' }
)
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-28')
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-27')
assert.equal(fields.location, '上海')
assert.equal(fields.reason, '支撑国网服务器部署')
assert.deepEqual(
@@ -65,8 +65,8 @@ test('expense application expands a single selected date with natural days', ()
'去上海出差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')
assert.equal(expandApplicationTimeWithDays('2026-05-25', 3), '2026-05-25 至 2026-05-27')
assert.equal(resolveApplicationTimeRange({ time_range: {} }, prompt), '2026-05-25 至 2026-05-27')
})
test('expense application keeps explicit time range before applying days', () => {

View File

@@ -3,6 +3,12 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { assistantCapabilities } from '../src/data/personalWorkbench.js'
import {
buildWorkbenchCapabilityAssistantPayload,
resolveWorkbenchCapabilityAssistantEntry
} from '../src/utils/personalWorkbenchAssistantEntry.js'
const workbench = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
'utf8'
@@ -11,8 +17,50 @@ 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, /我会自动识别您的意图,协助完成费用申请、报销查询制度问答等业务工作/)
assert.match(workbench, /const assistantGreetingName = computed/)
assert.match(workbench, /嗨,\{\{ displayUserName \}\},我是您的 <span>AI 费用助手<\/span>/)
assert.match(workbench, /placeholder="请输入费用申请、报销问题、预算查询制度问答\.\.\."/)
assert.match(workbench, /const displayUserName = computed/)
assert.match(workbench, /user\.name/)
})
test('workbench capability cards open assistant without injecting canned prompts', () => {
assert.match(workbench, /@click="openCapabilityAssistant\(item\)"/)
assert.doesNotMatch(workbench, /openPromptAssistant\(item\.prompt\)/)
for (const item of assistantCapabilities) {
const payload = buildWorkbenchCapabilityAssistantPayload(item, {
prompt: '',
source: 'workbench',
files: []
})
assert.equal(payload.prompt, '')
assert.equal(payload.conversation, null)
assert.notEqual(payload.prompt, item.prompt)
assert.ok(resolveWorkbenchCapabilityAssistantEntry(item).sessionType)
}
})
test('workbench capability cards keep user-entered context only', () => {
const [expenseApplication] = assistantCapabilities
const files = [{ name: 'invoice.pdf' }]
const payload = buildWorkbenchCapabilityAssistantPayload(expenseApplication, {
prompt: '我需要申请下周出差费用',
source: 'workbench',
files
})
assert.equal(payload.prompt, '我需要申请下周出差费用')
assert.equal(payload.source, 'application')
assert.equal(payload.sessionType, 'application')
assert.equal(payload.files, files)
})
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"/)
})

View File

@@ -13,10 +13,23 @@ function testReceiptFolderViewSurface() {
assert.match(view, /未关联票据/)
assert.match(view, /已关联票据/)
assert.match(view, /label: '全部'/)
assert.match(view, /一键关联票据/)
assert.match(view, /基本票据信息/)
assert.match(view, /票据关键字段/)
assert.match(view, /原始文件/)
assert.match(view, /返回列表/)
assert.match(view, /keyReceiptFields/)
assert.match(view, /editableOtherFields/)
assert.match(view, /class="receipt-key-grid"/)
assert.match(view, /class="receipt-other-collapse"/)
assert.match(view, /class="receipt-other-scroll"/)
assert.match(view, /其他信息/)
assert.match(view, /ElCollapse/)
assert.doesNotMatch(view, /新增字段/)
assert.match(view, /const isTrainTicket = computed/)
assert.doesNotMatch(view, /打开源文件/)
assert.doesNotMatch(view, /openSourceFile/)
assert.match(view, /返回票据夹/)
assert.doesNotMatch(view, /返回列表/)
assert.match(view, /删除票据/)
assert.match(view, /ElCheckboxGroup/)
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
@@ -41,11 +54,16 @@ function testReceiptFolderServiceContract() {
function testAppShellWiresReceiptFolder() {
const shell = readProjectFile('web/src/views/AppShellRouteView.vue')
const topBar = readProjectFile('web/src/components/layout/TopBar.vue')
assert.match(shell, /activeView === 'receiptFolder'/)
assert.match(shell, /ReceiptFolderView/)
assert.match(shell, /@open-assistant="openSmartEntry"/)
assert.match(shell, /@detail-open-change="receiptFolderDetailOpen = \$event"/)
assert.match(shell, /@detail-topbar-change="detailTopBarPayload = \$event"/)
assert.match(shell, /receipt-folder-workarea/)
assert.match(topBar, /receiptFolder/)
assert.match(topBar, /eyebrowLabel/)
}
function testSharedDocumentListStyleReuse() {
@@ -64,11 +82,61 @@ function testSharedDocumentListStyleReuse() {
assert.doesNotMatch(receiptStyles, /\.list-foot\b/)
}
function testReceiptFolderDetailLayoutAdjustments() {
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
const receiptStyles = readProjectFile('web/src/assets/styles/views/receipt-folder-view.css')
const fieldModel = readProjectFile('web/src/views/scripts/receiptFolderDetailFields.js')
assert.match(receiptView, /showStatusColumn/)
assert.match(receiptView, /<col v-if="showStatusColumn" class="col-status">/)
assert.match(receiptView, /<th v-if="showStatusColumn">/)
assert.match(receiptView, /<th>票据日期<\/th>/)
assert.match(receiptView, /<td>{{ row\.document_date \|\| '待补充' }}<\/td>/)
assert.match(receiptView, /<td>\s*<strong class="doc-id">/)
assert.match(receiptView, /<td v-if="showStatusColumn">\s*<span class="status-tag"/)
assert.match(receiptView, /const activeStatus = ref\('all'\)/)
assert.match(receiptView, /import EnterpriseDetailCard/)
assert.match(receiptView, /import EnterpriseDetailPage/)
assert.match(receiptView, /<EnterpriseDetailPage/)
assert.match(receiptView, /variant="receipt-folder-detail"/)
assert.match(receiptView, /<EnterpriseDetailCard class="receipt-basic-panel"/)
assert.match(receiptView, /<EnterpriseDetailCard class="receipt-preview-panel"/)
assert.match(receiptView, /createReceiptDetailFieldModel/)
assert.match(receiptView, /buildDetailPayload\(\)/)
assert.match(receiptView, /receiptDetailSubtitle/)
assert.match(receiptView, /receiptDetailTopBarPayload/)
assert.match(receiptView, /eyebrow: '票据详情'/)
assert.match(receiptView, /detail-topbar-change/)
assert.doesNotMatch(receiptView, /<article v-else class="receipt-folder-detail/)
assert.doesNotMatch(receiptView, /class="back-btn"/)
assert.doesNotMatch(receiptView, /receipt-detail-head/)
assert.doesNotMatch(receiptView, /detail-actions receipt-detail-foot/)
assert.match(receiptStyles, /\.receipt-folder-list th:first-child/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-grid\)/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-actions\)/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.enterprise-detail-card \.card-head\)/)
assert.match(receiptStyles, /\.receipt-key-grid/)
assert.match(receiptStyles, /\.receipt-other-collapse/)
assert.match(receiptStyles, /\.receipt-other-scroll/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-head\b/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-layout\b/)
assert.doesNotMatch(receiptStyles, /\.detail-loading\b/)
assert.doesNotMatch(receiptStyles, /\.back-btn\b/)
assert.doesNotMatch(receiptStyles, /\.danger-btn\b/)
assert.match(fieldModel, /TRAIN_KEY_FIELD_DEFINITIONS/)
assert.match(fieldModel, /label: '发票号码'/)
assert.match(fieldModel, /label: '开票日期'/)
assert.match(fieldModel, /label: '票价'/)
assert.match(fieldModel, /label: '姓名'/)
assert.match(fieldModel, /syncEditableFieldsToTopLevel/)
}
function run() {
testReceiptFolderViewSurface()
testReceiptFolderServiceContract()
testAppShellWiresReceiptFolder()
testSharedDocumentListStyleReuse()
testReceiptFolderDetailLayoutAdjustments()
console.log('receipt folder view tests passed')
}

View File

@@ -9,6 +9,9 @@ const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279'
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
const RETURNED = '\u9000\u56de'
const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4'
const LINKED_APPLICATION = '\u5173\u8054\u5355\u636e'
const PAID = '\u5df2\u4ed8\u6b3e'
const ARCHIVED = '\u5df2\u5f52\u6863'
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d'
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
@@ -191,6 +194,47 @@ test('approved application claims complete after budget approval', () => {
assert.equal(request.progressSteps.find((step) => step.label === '预算管理者审批')?.time, '赵预算通过')
})
test('application claims hide budget step when leader approval also covers budget approval', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-merged-budget',
claim_no: 'APP-20260525-MERGED',
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: APPROVAL_COMPLETED,
risk_flags_json: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: '李预算经理',
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: APPROVAL_COMPLETED,
budget_approval_merged: true,
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED]
)
assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.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()
@@ -230,7 +274,7 @@ test('progress steps show approval operator time and current stay duration', ()
const firstStep = request.progressSteps[0]
assert.equal(request.riskSummary, '无')
assert.equal(firstStep.label, '创建单据')
assert.equal(firstStep.label, LINKED_APPLICATION)
assert.equal(leaderStep.time, '李经理通过')
assert.match(leaderStep.detail, /2026-05-20/)
assert.match(leaderStep.title, /李经理审批通过/)
@@ -479,19 +523,48 @@ test('paid reimbursement marks payment progress step as complete', () => {
previous_approval_stage: '待付款',
next_approval_stage: '已付款',
created_at: '2026-05-20T05:00:00.000Z'
},
{
source: 'application_handoff',
event_type: 'expense_application_to_reimbursement_draft',
application_claim_id: 'application-1',
application_claim_no: 'APP-20260520-001',
application_detail: {
application_type: '差旅费用申请',
application_content: '差旅费用申请 / 北京',
application_reason: '支撑国网仿生产环境部署',
application_days: '3 天',
application_location: '北京',
application_amount: '3000',
application_time: '2026-05-20T00:00:00.000Z',
application_transport_mode: '高铁'
}
}
],
items: []
})
const paymentStep = request.progressSteps.find((step) => step.label === '待付款')
const paidStep = request.progressSteps.find((step) => step.label === '已付款')
const paidStep = request.progressSteps.find((step) => step.label === PAID)
const archivedStep = request.progressSteps.find((step) => step.label === ARCHIVED)
const linkedStep = request.progressSteps.find((step) => step.label === LINKED_APPLICATION)
assert.equal(request.workflowNode, '已付款')
assert.equal(request.approvalStatus, '已付款')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[LINKED_APPLICATION, '待提交', 'AI预审', '直属领导审批', '财务审批', '待付款', PAID, ARCHIVED]
)
assert.equal(paymentStep.time, '待付款')
assert.equal(paidStep.time, '已付款')
assert.equal(paidStep.done, true)
assert.equal(archivedStep.time, ARCHIVED)
assert.equal(archivedStep.done, true)
assert.equal(linkedStep.time, '已关联 APP-20260520-001')
assert.equal(request.relatedApplication.claimNo, 'APP-20260520-001')
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
assert.equal(request.relatedApplication.days, '3 天')
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
})
test('current direct manager step shows how long the claim has stayed there', () => {

View File

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { normalizeRiskObservationDashboard } from '../src/services/riskObservations.js'
const dashboardComponent = readFileSync(
fileURLToPath(new URL('../src/components/dashboard/RiskObservationDashboard.vue', import.meta.url)),
'utf8'
)
const overviewViewModel = readFileSync(
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
'utf8'
)
const overviewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8'
)
test('risk dashboard normalizes amount, distributions, and ranking fields', () => {
const dashboard = normalizeRiskObservationDashboard({
total_observations: 5,
total_amount: 12800,
department_distribution: { 风控部: 3 },
expense_type_distribution: { travel: 2 },
risk_type_distribution: { duplicate_invoice: 2 },
supplier_distribution: { 上海差旅供应商: 1 },
employee_grade_distribution: { P6: 2 },
top_departments: [{ name: '风控部', count: 3, amount: 8800 }],
top_employees: [{ name: '风险员工', count: 2, amount: 6200 }],
top_suppliers: [{ name: '上海差旅供应商', count: 1, amount: 1200 }],
top_expense_types: [{ name: 'travel', count: 2, amount: 4600 }],
top_rules: [{ name: 'policy.duplicate_invoice', count: 2, amount: 3000 }]
})
assert.equal(dashboard.totalAmount, 12800)
assert.equal(dashboard.departmentDistribution['风控部'], 3)
assert.equal(dashboard.expenseTypeDistribution.travel, 2)
assert.equal(dashboard.riskTypeDistribution.duplicate_invoice, 2)
assert.equal(dashboard.supplierDistribution['上海差旅供应商'], 1)
assert.equal(dashboard.employeeGradeDistribution.P6, 2)
assert.equal(dashboard.topDepartments[0].amount, 8800)
assert.equal(dashboard.topRules[0].name, 'policy.duplicate_invoice')
})
test('risk dashboard renders overview amount and multi-dimension panels', () => {
assert.match(overviewViewModel, /label: '新增风险数'/)
assert.match(overviewViewModel, /label: '涉及金额'/)
assert.match(overviewViewModel, /label: '已确认风险'/)
assert.match(overviewViewModel, /label: '误报数量'/)
assert.match(dashboardComponent, /业务维度分布/)
assert.match(dashboardComponent, /异常排行/)
assert.match(dashboardComponent, /departmentDistribution/)
assert.match(dashboardComponent, /expenseTypeDistribution/)
assert.match(dashboardComponent, /supplierDistribution/)
assert.match(dashboardComponent, /employeeGradeDistribution/)
assert.match(dashboardComponent, /topDepartments/)
assert.match(dashboardComponent, /topEmployees/)
assert.match(dashboardComponent, /topSuppliers/)
assert.match(dashboardComponent, /topRules/)
})
test('risk dashboard wires window filter to trend, ranking, and cards data source', () => {
assert.match(overviewViewModel, /const activeRiskWindowDays = ref\(30\)/)
assert.match(overviewViewModel, /windowDays: activeRiskWindowDays\.value/)
assert.match(overviewViewModel, /watch\(activeRiskWindowDays/)
assert.match(overviewViewModel, /setRiskWindowDays/)
assert.match(overviewTemplate, /:window-options="riskWindowOptions"/)
assert.match(overviewTemplate, /:active-window-days="activeRiskWindowDays"/)
assert.match(overviewTemplate, /@update:window-days="setRiskWindowDays"/)
assert.match(dashboardComponent, /EnterpriseSelect/)
assert.match(dashboardComponent, /aria-label="风险看板时间窗口"/)
assert.match(dashboardComponent, /emit\('update:windowDays', \$event\)/)
assert.match(dashboardComponent, /RiskDailyTrendChart/)
assert.match(dashboardComponent, /rankingGroups/)
})

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const component = readFileSync(
fileURLToPath(new URL('../src/components/travel/RiskObservationEvidenceCard.vue', import.meta.url)),
'utf8'
)
const styles = readFileSync(
fileURLToPath(
new URL('../src/assets/styles/components/risk-observation-evidence-card.css', import.meta.url)
),
'utf8'
)
test('risk observation evidence card supports switching active observation details', () => {
assert.match(component, /当前风险观察详情/)
assert.match(component, /const detailRegionId = 'risk-observation-active-detail'/)
assert.match(component, /:aria-controls="detailRegionId"/)
assert.match(component, /:aria-pressed="isActiveObservation\(item, index\)"/)
assert.match(component, /@click="selectObservation\(item, index\)"/)
assert.match(component, /const feedbackRows = computed/)
assert.match(component, /反馈历史/)
assert.match(styles, /\.risk-evidence-current-head/)
assert.match(styles, /\.risk-observation-row:focus-visible/)
})
test('risk observation evidence card remains scoped to current claim data', () => {
assert.match(component, /fetchClaimRiskObservations\(claimId\)/)
assert.doesNotMatch(component, /fetchEmployeeProfile/)
assert.doesNotMatch(component, /employeeBehaviorProfile/)
assert.doesNotMatch(component, /KnowledgeIngestGraphView/)
assert.doesNotMatch(component, /LightRAG/)
})
test('risk observation evidence card keeps empty observation state out of main flow', () => {
assert.match(component, /const visible = computed/)
assert.match(component, /observations\.value\.length > 0/)
assert.match(component, /暂无证据明细。/)
assert.match(component, /暂无人工反馈。/)
})

View File

@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const auditAssetList = readFileSync(
fileURLToPath(new URL('../src/components/audit/AuditAssetList.vue', import.meta.url)),
'utf8'
)
const auditViewStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/audit-view.css', import.meta.url)),
'utf8'
)
const digitalEmployeeList = readFileSync(
fileURLToPath(new URL('../src/components/audit/DigitalEmployeeListPanel.vue', import.meta.url)),
'utf8'
)
const digitalEmployeeStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/digital-employees-view.css', import.meta.url)),
'utf8'
)
test('audit rule name column stays left aligned', () => {
assert.match(auditAssetList, /<table class="audit-asset-table">/)
assert.match(auditViewStyles, /\.audit-asset-table th:first-child,[\s\S]*?text-align: left;/)
assert.match(auditViewStyles, /\.skill-name-cell \{[\s\S]*?text-align: left;/)
})
test('digital employee skill name keeps text avatar prefix', () => {
assert.match(digitalEmployeeList, /class="digital-skill-cell"/)
assert.match(digitalEmployeeList, /class="digital-skill-avatar"/)
assert.match(digitalEmployeeStyles, /\.digital-skill-cell \{[\s\S]*?text-align: left;/)
assert.match(digitalEmployeeStyles, /\.digital-skill-cell \{[\s\S]*?grid-template-columns: 38px minmax\(0, 1fr\);/)
assert.match(digitalEmployeeStyles, /\.digital-skill-avatar \{[\s\S]*?width: 38px;[\s\S]*?border-radius: 11px;/)
assert.match(digitalEmployeeStyles, /\.digital-skill-avatar\.blue \{[\s\S]*?linear-gradient\(135deg, #3b82f6, #2563eb\);/)
})

View File

@@ -11,6 +11,14 @@ const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
const composerToolsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementComposerTools.js', import.meta.url)),
'utf8'
)
test('composer formats date-picker expense text into readable structured fields', () => {
const formatted = buildStructuredComposerSubmitText(
@@ -56,6 +64,45 @@ test('composer extracts destination and reason from compact travel text', () =>
)
})
test('composer strips raw date picker prefix before building structured display', () => {
const formatted = buildStructuredComposerSubmitText(
'2026-05-20 至 2026-05-23去上海支撑上海国电的服务器部署出差3天',
{
mode: 'range',
start_date: '2026-05-20',
end_date: '2026-05-23',
business_time: '2026-05-20 至 2026-05-23'
}
)
assert.equal(
formatted,
[
'发生时间2026-05-20 至 2026-05-23',
'地点:上海',
'事由:支撑上海国电的服务器部署',
'天数3天'
].join('\n')
)
})
test('composer date selection shows raw date capsule labels', () => {
assert.match(createViewTemplate, /aria-label="选择日期"/)
assert.match(createViewTemplate, /aria-label="日期选择"/)
assert.match(createViewTemplate, /aria-label="移除日期"/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
assert.doesNotMatch(createViewTemplate, /@click="applyComposerDateSelection"/)
assert.doesNotMatch(createViewTemplate, /aria-label="选择业务发生时间"/)
assert.doesNotMatch(createViewTemplate, /aria-label="移除业务发生时间"/)
assert.doesNotMatch(composerToolsScript, /return `发生时间:\$\{composerSingleDate\.value\}`/)
assert.match(composerToolsScript, /return composerSingleDate\.value/)
assert.match(composerToolsScript, /return `\$\{composerRangeStartDate\.value\} 至 \$\{composerRangeEndDate\.value\}`/)
assert.match(composerToolsScript, /function commitComposerDateSelection/)
assert.match(composerToolsScript, /onComposerDateSelection\?\.\(selection\)/)
})
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

@@ -132,6 +132,26 @@ test('assistant session scope guard keeps business boundaries isolated', () => {
assert.equal(expenseInApplication.suggestedActions[0].payload.carry_text, '我想报销的士票')
assert.equal(resolveAssistantScopeGuard('我想发起一笔费用申请', SESSION_TYPE_APPLICATION), null)
assert.equal(
resolveAssistantScopeGuard('去北京出差3天支撑国网仿生产环境部署', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPLICATION
)
assert.equal(
resolveAssistantScopeGuard('去国网出差3天协助仿生产环境部署', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPLICATION
)
assert.equal(
resolveAssistantScopeGuard('下周去上海支撑客户系统上线预计3天', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPLICATION
)
assert.equal(
resolveAssistantScopeGuard('安排去深圳客户现场验收项目', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPLICATION
)
assert.equal(
resolveAssistantScopeGuard('我要报销去北京出差的费用', SESSION_TYPE_APPLICATION).targetSessionType,
SESSION_TYPE_EXPENSE
)
assert.equal(
resolveAssistantScopeGuard('帮我查询待我审核的单据', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPROVAL

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { ref } from 'vue'
import {
buildLocallySyncedReviewPayload,
@@ -10,6 +11,7 @@ import {
isTravelReviewPayload,
resolveReviewFooterActions
} from '../src/views/scripts/travelReimbursementReviewModel.js'
import { useTravelReimbursementAttachments } from '../src/views/scripts/useTravelReimbursementAttachments.js'
import { renderMarkdown } from '../src/utils/markdown.js'
const createViewTemplate = readFileSync(
@@ -407,7 +409,7 @@ test('review drawer save action is disabled while receipt recognition is submitt
test('draft creation starts composer attachment persistence after response rendering', () => {
assert.match(
submitComposerScript,
/void syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\.then\(\(\) => \{\s*persistSessionState\(\)\s*\}\)\s*\.catch\(\(error\) => \{/s
/void syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\.then\(\(syncResult\) => \{[\s\S]*persistSessionState\(\)[\s\S]*emitRequestUpdated\?\.\(\{/s
)
assert.doesNotMatch(
submitComposerScript,
@@ -422,10 +424,84 @@ test('draft creation starts composer attachment persistence after response rende
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
assert.match(
attachmentsScript,
/const targetItem = nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch/
/nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch \|\| emptyFallbackMatch/
)
})
test('detail smart-entry receipt sync uploads files to existing empty items and creates a row when needed', async () => {
const uploadCalls = []
let createCount = 0
const claimSnapshots = [
{
items: [
{ id: 'item-empty-1', item_type: 'hotel_ticket', invoice_id: '' },
{ id: 'item-persisted', item_type: 'train_ticket', invoice_id: 'claim-1/item-persisted/old.pdf' }
]
},
{
items: [
{ id: 'item-empty-1', item_type: 'hotel_ticket', invoice_id: '' },
{ id: 'item-persisted', item_type: 'train_ticket', invoice_id: 'claim-1/item-persisted/old.pdf' }
]
}
]
const attachments = useTravelReimbursementAttachments({
isKnowledgeSession: ref(false),
reviewFilePreviews: ref([]),
linkedRequest: ref({}),
draftClaimId: ref('claim-1'),
activeReviewPayload: ref(null),
reviewInlinePendingFiles: ref([]),
reviewInlineForm: ref({}),
reviewInlineEditorKey: ref(''),
composerUploadIntent: ref(''),
submitting: ref(false),
reviewActionBusy: ref(false),
toast: () => {},
fileInputRef: ref(null),
createExpenseClaimItem: async () => {
createCount += 1
return {
items: [
{ id: 'item-created-1', item_type: 'taxi_receipt', invoice_id: '' }
]
}
},
fetchExpenseClaimDetail: async () => claimSnapshots.shift() || { items: [] },
fetchExpenseClaimItemAttachmentMeta: async () => null,
fetchExpenseClaimAttachmentAsset: async () => new Blob(['preview']),
uploadExpenseClaimItemAttachment: async (claimId, itemId, file) => {
uploadCalls.push({ claimId, itemId, fileName: file.name })
return {}
},
extractReviewAttachmentNames: () => [],
mergeFilesWithLimit: (existing, incoming) => ({ files: [...existing, ...incoming], overflowCount: 0 }),
mergeFilePreviews: (existing, incoming) => [...existing, ...incoming],
isTemporaryPreviewUrl: () => false,
resolveAttachmentPreviewKind: () => '',
resolveDocumentPreview: () => null,
buildFilePreviews: () => [],
buildFileIdentity: (file) => file.name,
MAX_ATTACHMENTS: 5,
VISIBLE_ATTACHMENT_CHIPS: 3,
clearInlineReviewFieldError: () => {}
})
const result = await attachments.syncComposerFilesToDraft('claim-1', [
{ name: 'hotel.pdf' },
{ name: 'taxi.pdf' }
])
assert.deepEqual(uploadCalls, [
{ claimId: 'claim-1', itemId: 'item-empty-1', fileName: 'hotel.pdf' },
{ claimId: 'claim-1', itemId: 'item-created-1', fileName: 'taxi.pdf' }
])
assert.equal(createCount, 1)
assert.equal(result.uploadedCount, 2)
assert.equal(result.skippedCount, 0)
})
test('review summary renders markdown and save draft relies on backend response only', () => {
assert.match(
createViewTemplate,

View File

@@ -422,17 +422,24 @@ test('expense detail table shows the amount total below detail rows', () => {
assert.doesNotMatch(detailViewTemplate, /\{\{ expenseSummaryText \}\}/)
})
test('additional note is shown above expense details as travel purpose text', () => {
test('related application information is shown above expense details for reimbursement check', () => {
assert.ok(
detailViewTemplate.indexOf('<h3>附加说明</h3>')
detailViewTemplate.indexOf('<h3>关联单据信息</h3>')
< detailViewTemplate.indexOf("isApplicationDocument ? '申请详情' : '费用明细'")
)
assert.match(detailViewTemplate, /<article v-if="!isApplicationDocument" class="detail-card panel">/)
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
assert.match(detailViewTemplate, /v-model="detailNoteEditorView"/)
assert.match(detailViewTemplate, /提交后将作为明确说明展示/)
assert.match(detailViewTemplate, /展示本次报销关联的前置申请/)
assert.match(detailViewTemplate, /relatedApplicationFactItems/)
assert.match(detailViewTemplate, /暂未识别到关联申请单/)
assert.match(detailViewScript, /buildRelatedApplicationFactItems/)
assert.match(requestsComposableScript, /const RELATED_APPLICATION_STEP_LABEL = '关联单据'/)
assert.match(requestsComposableScript, /const ARCHIVED_STEP_LABEL = '已归档'/)
assert.match(detailViewStyle, /\.related-application-empty/)
assert.doesNotMatch(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
assert.doesNotMatch(detailViewTemplate, /v-model="detailNoteEditorView"/)
})
test('detail note model is retained for risk override persistence', () => {
assert.match(detailViewScript, /const canEditDetailNote = computed\(\(\) => isDraftRequest\.value\)/)
assert.match(detailViewScript, /function normalizeDetailNoteDraftValue\(value\)/)
assert.match(detailViewScript, /function stripRiskTagsForDisplay\(value\)/)

View File

@@ -72,7 +72,8 @@ test('detail submit requires override reasons for high-risk claims', () => {
test('detail header and fallback progress use reimbursement wording', () => {
assert.match(detailViewScript, /label:\s*'单据申请日期'/)
assert.match(detailExpenseModelScript, /label:\s*'创建单据'/)
assert.match(detailExpenseModelScript, /label:\s*'关联单据'/)
assert.match(detailExpenseModelScript, /label:\s*'已归档'/)
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
})
@@ -80,6 +81,6 @@ test('archived detail delete action is gated by admin-only permission', () => {
assert.match(detailViewScript, /canDeleteArchivedExpenseClaims/)
assert.match(detailViewScript, /isArchivedRequestView/)
assert.match(detailViewScript, /if \(isArchivedRequest\.value\) {\s*return canDeleteArchivedExpenseClaims\(currentUser\.value\)/)
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canDeleteRequest"/)
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/)
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
})

View File

@@ -0,0 +1,130 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
ASSISTANT_SCOPE_SESSION_APPLICATION,
ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_KNOWLEDGE,
inferAssistantScopeTarget
} from '../src/utils/assistantSessionScope.js'
import {
resolveWorkbenchSessionTypeFromOntology
} from '../src/utils/workbenchAssistantIntent.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'
)
test('workbench prompt applies travel phrases to application assistant scope', () => {
assert.equal(inferAssistantScopeTarget('申请出差'), ASSISTANT_SCOPE_SESSION_APPLICATION)
assert.equal(
inferAssistantScopeTarget('去北京出差3天支撑国网仿生产环境部署'),
ASSISTANT_SCOPE_SESSION_APPLICATION
)
assert.equal(
inferAssistantScopeTarget('去国网出差3天协助仿生产环境部署'),
ASSISTANT_SCOPE_SESSION_APPLICATION
)
assert.equal(
inferAssistantScopeTarget('下周去上海支撑客户系统上线预计3天'),
ASSISTANT_SCOPE_SESSION_APPLICATION
)
assert.equal(
inferAssistantScopeTarget('安排去深圳客户现场验收项目'),
ASSISTANT_SCOPE_SESSION_APPLICATION
)
assert.equal(
inferAssistantScopeTarget('准备去国网现场做仿生产环境部署差旅3天'),
ASSISTANT_SCOPE_SESSION_APPLICATION
)
assert.equal(
inferAssistantScopeTarget('我要报销去北京的费用'),
ASSISTANT_SCOPE_SESSION_EXPENSE
)
assert.equal(
inferAssistantScopeTarget('我要报销去北京出差的费用'),
ASSISTANT_SCOPE_SESSION_EXPENSE
)
assert.equal(
inferAssistantScopeTarget('去北京出差报销标准是多少'),
ASSISTANT_SCOPE_SESSION_KNOWLEDGE
)
assert.notEqual(
inferAssistantScopeTarget('昨天去北京出差花了1000元'),
ASSISTANT_SCOPE_SESSION_APPLICATION
)
assert.match(appShellComposable, /fetchOntologyParse/)
assert.match(appShellComposable, /resolveWorkbenchSessionTypeFromOntology/)
assert.match(appShellComposable, /resolveWorkbenchSessionTypeFallback/)
assert.match(appShellRouteView, /:initial-session-type="smartEntryContext\.sessionType"/)
assert.match(assistantScript, /initialSessionType:\s*\{[\s\S]*type:\s*String/)
})
test('workbench model routing maps ontology result before entering assistant', () => {
const travelOntology = {
scenario: 'expense',
intent: 'draft',
entities: [
{ type: 'expense_type', normalized_value: 'travel' }
]
}
const reimbursementOntology = {
scenario: 'expense',
intent: 'draft',
entities: [
{ type: 'expense_type', normalized_value: 'travel' }
]
}
const applicationOntology = {
scenario: 'expense',
intent: 'draft',
entities: [
{ type: 'document_type', normalized_value: 'expense_application' },
{ type: 'workflow_stage', normalized_value: 'pre_approval' }
]
}
assert.equal(
resolveWorkbenchSessionTypeFromOntology(
travelOntology,
'下周去上海支撑客户系统上线预计3天',
ASSISTANT_SCOPE_SESSION_EXPENSE
),
ASSISTANT_SCOPE_SESSION_APPLICATION
)
assert.equal(
resolveWorkbenchSessionTypeFromOntology(
reimbursementOntology,
'我要报销去北京出差的费用',
ASSISTANT_SCOPE_SESSION_APPLICATION
),
ASSISTANT_SCOPE_SESSION_EXPENSE
)
assert.equal(
resolveWorkbenchSessionTypeFromOntology(
{ scenario: 'expense', intent: 'query', entities: reimbursementOntology.entities },
'去北京出差报销标准是多少',
ASSISTANT_SCOPE_SESSION_KNOWLEDGE
),
ASSISTANT_SCOPE_SESSION_KNOWLEDGE
)
assert.equal(
resolveWorkbenchSessionTypeFromOntology(
applicationOntology,
'国网仿生产环境部署',
ASSISTANT_SCOPE_SESSION_EXPENSE
),
ASSISTANT_SCOPE_SESSION_APPLICATION
)
})

View File

@@ -0,0 +1,99 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildWorkbenchDateLabel,
canApplyWorkbenchDateSelection,
getTodayDateValue,
mergeWorkbenchDateLabelIntoDraft,
stripWorkbenchDateLabelFromDraft
} from '../src/utils/workbenchComposerDate.js'
const workbench = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
'utf8'
)
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/)
assert.match(workbenchDateComposable, /const workbenchSingleDate = ref\(getTodayDateValue\(\)\)/)
assert.match(workbenchDateComposable, /const workbenchDateTagLabel = ref\(''\)/)
assert.match(workbenchDateComposable, /const today = getTodayDateValue\(\)[\s\S]*workbenchSingleDate\.value = today/)
assert.match(workbenchDateComposable, /function handleWorkbenchDateInputChange/)
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', () => {
assert.equal(getTodayDateValue(new Date(2026, 4, 9, 12)), '2026-05-09')
assert.equal(
buildWorkbenchDateLabel({
mode: 'single',
singleDate: '2026-05-29'
}),
'2026-05-29'
)
assert.equal(
buildWorkbenchDateLabel({
mode: 'range',
rangeStartDate: '2026-05-29',
rangeEndDate: '2026-05-31'
}),
'2026-05-29 至 2026-05-31'
)
assert.equal(
mergeWorkbenchDateLabelIntoDraft('申请出差', '2026-05-29'),
'2026-05-29申请出差'
)
assert.equal(
mergeWorkbenchDateLabelIntoDraft('发生时间2026-05-28申请出差', '2026-05-29'),
'2026-05-29申请出差'
)
assert.equal(
mergeWorkbenchDateLabelIntoDraft('2026-05-28申请出差', '2026-05-29'),
'2026-05-29申请出差'
)
assert.equal(
stripWorkbenchDateLabelFromDraft('2026-05-28 至 2026-05-30申请出差'),
'申请出差'
)
assert.equal(canApplyWorkbenchDateSelection({ mode: 'range', rangeStartDate: '2026-06-01', rangeEndDate: '2026-05-31' }), false)
})