feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装 - 增强预算助理报告与风险建议卡片交互 - 重构登录页视觉样式与移动端响应式适配 - 优化个人工作台、文档中心、政策中心、员工管理等页面布局 - 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型 - 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
@@ -127,7 +127,7 @@ test('application detail topbar does not ask for receipt attachments', () => {
|
||||
|
||||
test('detail topbar surfaces stored medium and high risk flags first', () => {
|
||||
const highAlerts = buildDetailAlerts({
|
||||
node: 'AI预审',
|
||||
node: '待提交',
|
||||
approvalKey: 'draft',
|
||||
riskFlags: [
|
||||
{
|
||||
@@ -146,7 +146,7 @@ test('detail topbar surfaces stored medium and high risk flags first', () => {
|
||||
expenseItems: []
|
||||
})
|
||||
const mediumAlerts = buildDetailAlerts({
|
||||
node: 'AI预审',
|
||||
node: '待提交',
|
||||
approvalKey: 'draft',
|
||||
riskFlags: [
|
||||
{
|
||||
|
||||
42
web/tests/app-shell-mobile-browser.test.mjs
Normal file
42
web/tests/app-shell-mobile-browser.test.mjs
Normal 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 appShellView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const appCss = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const assistantResponsiveCss = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part4.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('手机浏览器存在应用导航入口', () => {
|
||||
assert.match(appShellView, /class="mobile-hamburger-btn"/)
|
||||
assert.match(appShellView, /aria-label="打开移动端导航"/)
|
||||
assert.match(appShellView, /:aria-expanded="mobileSidebarOpen \? 'true' : 'false'"/)
|
||||
assert.match(appShellView, /@click="mobileSidebarOpen = true"/)
|
||||
|
||||
assert.match(appCss, /\.mobile-hamburger-btn\s*{\s*display:\s*none;/s)
|
||||
assert.match(appCss, /@media \(max-width:\s*760px\)[\s\S]*\.mobile-hamburger-btn\s*{[\s\S]*display:\s*flex;/)
|
||||
})
|
||||
|
||||
test('报销智能体在手机浏览器下使用全屏工作台和稳定输入区', () => {
|
||||
const mobileBlockStart = assistantResponsiveCss.indexOf('@media (max-width: 760px)')
|
||||
assert.notEqual(mobileBlockStart, -1)
|
||||
const mobileBlock = assistantResponsiveCss.slice(mobileBlockStart)
|
||||
|
||||
assert.match(mobileBlock, /:global\(\.assistant-el-overlay \.el-overlay-dialog\)[\s\S]*padding:\s*0;/)
|
||||
assert.match(mobileBlock, /\.assistant-modal-stage\s*{[\s\S]*height:\s*100dvh;[\s\S]*border:\s*0;/)
|
||||
assert.match(mobileBlock, /\.assistant-layout\s*{[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\);/)
|
||||
assert.match(mobileBlock, /\.dialog-panel\s*{[\s\S]*border:\s*0;[\s\S]*border-radius:\s*0;/)
|
||||
assert.match(mobileBlock, /\.insight-panel-shell\s*{[\s\S]*position:\s*absolute;[\s\S]*transform:\s*translateX\(100%\);/)
|
||||
assert.match(mobileBlock, /\.assistant-layout\.has-insight \.insight-panel-shell\s*{[\s\S]*transform:\s*translateX\(0\);/)
|
||||
assert.match(mobileBlock, /\.composer-row\s*{[\s\S]*display:\s*grid;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) var\(--composer-control-size,\s*40px\);/)
|
||||
assert.match(mobileBlock, /\.composer-leading-actions\s*{[\s\S]*grid-column:\s*1 \/ -1;[\s\S]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\);/)
|
||||
})
|
||||
@@ -130,7 +130,10 @@ test('attachment upload association uses conversation selection instead of legac
|
||||
assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/)
|
||||
assert.match(flowSource, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/)
|
||||
assert.match(flowSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
|
||||
assert.match(flowSource, /'draft-risk-review'/)
|
||||
assert.match(flowSource, /草稿风险识别/)
|
||||
assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)
|
||||
assert.match(conversationSource, /'draft-risk-review':\s*\{[\s\S]*title:\s*'草稿风险识别'/)
|
||||
})
|
||||
|
||||
test('unsaved review attachment prompt asks for explicit rich-text confirmation', () => {
|
||||
|
||||
@@ -32,8 +32,8 @@ test('document center new state resolves source scoped document keys', () => {
|
||||
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' }
|
||||
{ source: 'owned', claimId: 'claim-1' },
|
||||
{ source: 'approval', claimId: 'claim-2' }
|
||||
]
|
||||
let viewedKeys = readViewedDocumentKeys(storage)
|
||||
|
||||
@@ -44,7 +44,21 @@ test('document center new state counts unseen documents and persists viewed rows
|
||||
|
||||
assert.equal(countNewDocuments(rows, viewedKeys), 1)
|
||||
assert.equal(isNewDocument(rows[0], viewedKeys), false)
|
||||
assert.deepEqual([...readViewedDocumentKeys(storage)], ['archive:claim-1'])
|
||||
assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1'])
|
||||
})
|
||||
|
||||
test('document center archive rows are never marked as new', () => {
|
||||
const viewedKeys = readViewedDocumentKeys(createMemoryStorage())
|
||||
const rows = [
|
||||
{ source: 'archive', claimId: 'archived-1' },
|
||||
{ archived: true, source: 'owned', claimId: 'archived-2' },
|
||||
{ isNewDocument: false, source: 'owned', claimId: 'archived-3' }
|
||||
]
|
||||
|
||||
assert.equal(countNewDocuments(rows, viewedKeys), 0)
|
||||
assert.equal(isNewDocument(rows[0], viewedKeys), false)
|
||||
assert.equal(isNewDocument(rows[1], viewedKeys), false)
|
||||
assert.equal(isNewDocument(rows[2], viewedKeys), false)
|
||||
})
|
||||
|
||||
test('document center sidebar inbox shares source scoped document keys', () => {
|
||||
|
||||
@@ -12,6 +12,10 @@ const documentsCenterStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const documentListSharedStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('documents center keeps only the top scope tabs and renders status as a dropdown filter', () => {
|
||||
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
|
||||
@@ -88,9 +92,9 @@ test('documents center list shows created time and conditional stay time columns
|
||||
assert.match(documentsCenterView, /<col class="col-initiator">/)
|
||||
assert.match(documentsCenterView, /<th>单号<\/th>[\s\S]*<th>创建时间<\/th>[\s\S]*<th v-if="showStayTimeColumn">停留时间<\/th>/)
|
||||
assert.match(documentsCenterView, /<th>费用场景<\/th>[\s\S]*<th>发起人<\/th>[\s\S]*<th>事项<\/th>/)
|
||||
assert.match(documentsCenterView, /<td>\{\{ row\.createdAtDisplay \}\}<\/td>/)
|
||||
assert.match(documentsCenterView, /<td v-if="showStayTimeColumn">\{\{ row\.stayTimeDisplay \}\}<\/td>/)
|
||||
assert.match(documentsCenterView, /<td>\{\{ row\.initiatorName \}\}<\/td>/)
|
||||
assert.match(documentsCenterView, /<td data-label="创建时间">\{\{ row\.createdAtDisplay \}\}<\/td>/)
|
||||
assert.match(documentsCenterView, /<td v-if="showStayTimeColumn" data-label="停留时间">\{\{ row\.stayTimeDisplay \}\}<\/td>/)
|
||||
assert.match(documentsCenterView, /<td data-label="发起人">\{\{ row\.initiatorName \}\}<\/td>/)
|
||||
assert.match(
|
||||
documentsCenterView,
|
||||
/const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/
|
||||
@@ -147,7 +151,7 @@ test('documents center category tabs render bubble counts for new documents', ()
|
||||
|
||||
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, /isNewDocument: archived\s*\?\s*false\s*:\s*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\)/
|
||||
@@ -228,9 +232,9 @@ test('documents center status dropdown derives labels and closes after selection
|
||||
|
||||
test('documents center status dropdown uses compact filter styling', () => {
|
||||
assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/)
|
||||
assert.match(documentsCenterStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/)
|
||||
assert.match(documentsCenterStyles, /\.scope-tab-badge\s*\{[\s\S]*border-radius:\s*999px;/)
|
||||
assert.match(documentsCenterStyles, /min-width:\s*1420px;/)
|
||||
assert.match(documentListSharedStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/)
|
||||
assert.match(documentListSharedStyles, /\.scope-tab-badge\s*\{[\s\S]*border-radius:\s*999px;/)
|
||||
assert.match(documentListSharedStyles, /min-width:\s*1420px;/)
|
||||
assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/)
|
||||
assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/)
|
||||
assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
applyApplicationBusinessTimeContext,
|
||||
buildApplicationPreviewFooterMessage,
|
||||
buildApplicationPreviewRows,
|
||||
buildApplicationPreviewSubmitText,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
buildLocalApplicationPreviewMessage,
|
||||
buildModelRefinedApplicationPreview,
|
||||
normalizeApplicationPreview,
|
||||
resolveApplicationTimeLabel,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../src/utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
@@ -162,8 +164,10 @@ test('application preview renders ordered editable rows and submit text uses edi
|
||||
const rows = buildApplicationPreviewRows(editedPreview)
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.label),
|
||||
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
|
||||
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '行程时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
|
||||
)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /行程时间:2026-05-25 至 2026-05-28/)
|
||||
assert.doesNotMatch(buildApplicationPreviewSubmitText(editedPreview), /发生时间:/)
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.editable, false)
|
||||
@@ -220,6 +224,39 @@ test('application estimate builds deterministic mock transport amount and total'
|
||||
assert.equal(datedTotalEstimate.totalAmountDisplay, '3,260')
|
||||
})
|
||||
|
||||
test('application preview uses selected date range and business-specific time label', () => {
|
||||
const preview = applyApplicationBusinessTimeContext(
|
||||
buildLocalApplicationPreview(
|
||||
'去上海出差4天,支撑国网仿生产环境部署,飞机',
|
||||
{
|
||||
name: '曹笑竹',
|
||||
departmentName: '技术部',
|
||||
position: '财务智能化产品经理',
|
||||
managerName: '向万红',
|
||||
grade: 'P5'
|
||||
},
|
||||
{ today: '2026-06-02' }
|
||||
),
|
||||
{
|
||||
mode: 'range',
|
||||
start_date: '2026-02-20',
|
||||
end_date: '2026-02-23',
|
||||
business_time: '2026-02-20 至 2026-02-23'
|
||||
}
|
||||
)
|
||||
const rows = buildApplicationPreviewRows(preview)
|
||||
const submitText = buildApplicationPreviewSubmitText(preview)
|
||||
|
||||
assert.equal(resolveApplicationTimeLabel(preview.fields.applicationType), '行程时间')
|
||||
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
|
||||
assert.equal(preview.fields.days, '4天')
|
||||
assert.equal(preview.fields.reason, '支撑国网仿生产环境部署')
|
||||
assert.equal(rows.find((row) => row.key === 'time')?.label, '行程时间')
|
||||
assert.match(submitText, /行程时间:2026-02-20 至 2026-02-23/)
|
||||
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
|
||||
assert.doesNotMatch(submitText, /发生时间:/)
|
||||
})
|
||||
|
||||
test('application preview cleans empty time labels and keeps only business reason', () => {
|
||||
const preview = buildLocalApplicationPreview('发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车', {
|
||||
name: '李文静',
|
||||
@@ -407,8 +444,20 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
|
||||
assert.match(messageItemTemplate, /application-preview-date-chip/)
|
||||
assert.match(messageItemTemplate, /申请单据已生成/)
|
||||
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
|
||||
assert.match(messageItemTemplate, /报销草稿已生成/)
|
||||
assert.match(messageItemTemplate, /ui\.resolveReimbursementDraftClaimNo\(message\.draftPayload\)/)
|
||||
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
|
||||
assert.match(messageItemTemplate, /查看详情/)
|
||||
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
|
||||
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
|
||||
assert.ok(
|
||||
messageItemTemplate.indexOf('class="draft-preview application-draft-preview"')
|
||||
< messageItemTemplate.indexOf('class="message-detail-block review-message-block"')
|
||||
)
|
||||
assert.match(messageItemTemplate, /application-draft-head/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-file-document-check-outline/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-file-document-edit-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/)
|
||||
@@ -416,6 +465,8 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/)
|
||||
assert.match(submitComposerScript, /employee_grade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
|
||||
assert.match(submitComposerScript, /employeeGrade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
|
||||
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
|
||||
assert.match(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.draftValue"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.singleDate"/)
|
||||
@@ -464,12 +515,19 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief-item \{[\s\S]*border: 0;[\s\S]*background: #ffffff;/)
|
||||
assert.doesNotMatch(applicationMessageStyles, /\.application-draft-brief-item:nth-child\(even\)/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-preview\.reimbursement-draft-preview \{[\s\S]*max-width: 520px;/)
|
||||
assert.match(applicationMessageStyles, /\.reimbursement-draft-card \{[\s\S]*grid-template-columns: 30px minmax\(0, 1fr\);/)
|
||||
assert.match(applicationMessageStyles, /\.reimbursement-draft-link \{[\s\S]*text-decoration: underline;/)
|
||||
|
||||
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(submitComposerScript, /const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'/)
|
||||
assert.match(submitComposerScript, /if \(isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
|
||||
assert.match(submitComposerScript, /else if \(rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
|
||||
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation\) \{[\s\S]*startExpenseClaimDraftFlowStep/)
|
||||
assert.match(flowScript, /function resolveDurationFromFields/)
|
||||
assert.match(flowScript, /function resolveStartedTimestamp/)
|
||||
assert.match(flowScript, /function resolveFinishedTimestamp/)
|
||||
@@ -521,6 +579,64 @@ test('flow panel durations use backend timing instead of local preview delay', (
|
||||
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: null }), '--')
|
||||
})
|
||||
|
||||
test('application submit confirmation flow only shows submit success step', () => {
|
||||
const flow = createFlowHarness()
|
||||
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
|
||||
flow.startFlowStep('application-submit-success', {
|
||||
title: '申请单提交成功',
|
||||
tool: 'ApplicationSubmit',
|
||||
detail: '正在提交费用申请...'
|
||||
})
|
||||
|
||||
flow.completeFlowResult({
|
||||
status: 'succeeded',
|
||||
result: {
|
||||
answer: '申请单据已生成,并已进入审批流程。',
|
||||
draft_payload: {
|
||||
draft_type: 'expense_application',
|
||||
status: 'submitted',
|
||||
claim_no: 'AP-20260602010101-ABCDEFGH',
|
||||
approval_stage: '直属领导审批'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert.deepEqual(flow.flowSteps.value.map((step) => step.key), ['application-submit-success'])
|
||||
assert.deepEqual(flow.visibleFlowSteps.value.map((step) => step.key), ['application-submit-success'])
|
||||
const submitStep = flow.flowSteps.value[0]
|
||||
assert.equal(submitStep.status, 'completed')
|
||||
assert.match(submitStep.detail, /AP-20260602010101-ABCDEFGH/)
|
||||
assert.doesNotMatch(flow.flowSteps.value.map((step) => step.key).join(','), /intent|extraction/)
|
||||
})
|
||||
|
||||
test('application duplicate confirmation flow marks submit step as blocked duplicate', () => {
|
||||
const flow = createFlowHarness()
|
||||
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
|
||||
flow.startFlowStep('application-submit-success', {
|
||||
title: '申请单提交成功',
|
||||
tool: 'ApplicationSubmit',
|
||||
detail: '正在提交费用申请...'
|
||||
})
|
||||
|
||||
flow.completeFlowResult({
|
||||
status: 'succeeded',
|
||||
result: {
|
||||
answer: [
|
||||
'检测到同一申请人、同一申请类型、同一行程时间已存在申请单,系统没有重复创建。',
|
||||
'已有申请单号:AP-20260602010101-ABCDEFGH',
|
||||
'当前节点:直属领导审批'
|
||||
].join('\n')
|
||||
}
|
||||
})
|
||||
|
||||
assert.deepEqual(flow.flowSteps.value.map((step) => step.key), ['application-submit-success'])
|
||||
const submitStep = flow.flowSteps.value[0]
|
||||
assert.equal(submitStep.status, 'completed')
|
||||
assert.equal(submitStep.title, '重复申请已拦截')
|
||||
assert.match(submitStep.detail, /AP-20260602010101-ABCDEFGH/)
|
||||
assert.doesNotMatch(submitStep.detail, /提交成功/)
|
||||
})
|
||||
|
||||
test('assistant markdown tables render with component-scoped table styling', () => {
|
||||
const rendered = renderMarkdown([
|
||||
'| 项目 | 标准口径 | 天数 | 小计 |',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
@@ -13,6 +13,32 @@ const workbench = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchGlassStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-glass.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchCardStyles = `${workbenchStyles}\n${workbenchGlassStyles}`
|
||||
const workbenchResponsiveStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-responsive.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchInsightStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-insights.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const heroBackgroundAsset = fileURLToPath(
|
||||
new URL('../src/assets/personal-workbench-hero-bg-theme-base.webp', import.meta.url)
|
||||
)
|
||||
const capabilityGlassAsset = fileURLToPath(
|
||||
new URL('../src/assets/personal-workbench-card-glass-capability.webp', import.meta.url)
|
||||
)
|
||||
const panelGlassAsset = fileURLToPath(
|
||||
new URL('../src/assets/personal-workbench-card-glass-panel.webp', import.meta.url)
|
||||
)
|
||||
|
||||
test('workbench assistant greets the current employee without the old helper tag', () => {
|
||||
assert.doesNotMatch(workbench, /assistant-tag/)
|
||||
@@ -56,6 +82,56 @@ test('workbench capability cards keep user-entered context only', () => {
|
||||
assert.equal(payload.files, files)
|
||||
})
|
||||
|
||||
test('workbench hero uses theme-tintable background image', () => {
|
||||
assert.match(workbench, /personal-workbench-hero-bg-theme-base\.webp/)
|
||||
assert.doesNotMatch(workbench, /personal-workbench-hero-bg-theme-base\.png/)
|
||||
assert.match(workbench, /--assistant-bg-image.*workbenchHeroBackground/)
|
||||
assert.match(workbenchStyles, /--assistant-theme-tint:[\s\S]*--theme-primary-rgb/)
|
||||
assert.match(workbenchStyles, /var\(--assistant-bg-image\) var\(--assistant-bg-position\) \/ var\(--assistant-bg-size\) no-repeat/)
|
||||
assert.match(workbenchStyles, /background-blend-mode:\s*normal,\s*color,\s*luminosity;/)
|
||||
assert.match(workbenchStyles, /\.assistant-hero::after\s*\{[\s\S]*content:\s*none;/)
|
||||
assert.match(workbenchResponsiveStyles, /--assistant-bg-position:\s*68% center;/)
|
||||
assert.doesNotMatch(workbenchResponsiveStyles, /homepage_backgraound/)
|
||||
assert.ok(statSync(heroBackgroundAsset).size < 120 * 1024)
|
||||
})
|
||||
|
||||
test('workbench cards use layered glass material instead of texture-led cards', () => {
|
||||
assert.match(workbench, /personal-workbench-glass\.css/)
|
||||
assert.match(workbenchGlassStyles, /--workbench-capability-bg-image:\s*url\("\.\.\/\.\.\/personal-workbench-card-glass-capability\.webp"\)/)
|
||||
assert.match(workbenchGlassStyles, /--workbench-panel-bg-image:\s*url\("\.\.\/\.\.\/personal-workbench-card-glass-panel\.webp"\)/)
|
||||
assert.match(workbenchGlassStyles, /--workbench-glass-base:/)
|
||||
assert.match(workbenchGlassStyles, /--workbench-glass-highlight:/)
|
||||
assert.match(workbenchGlassStyles, /--workbench-glass-noise-opacity:\s*0\.012;/)
|
||||
assert.match(workbenchGlassStyles, /--workbench-glass-blur:\s*blur\(18px\) saturate\(1\.28\);/)
|
||||
assert.match(workbenchGlassStyles, /\.capability-card\s*\{[\s\S]*background-color:\s*rgba\(255,\s*255,\s*255,\s*0\.64\);[\s\S]*backdrop-filter:\s*var\(--workbench-glass-blur\)/)
|
||||
assert.match(workbenchGlassStyles, /\.workbench-card\s*\{[\s\S]*background-color:\s*rgba\(255,\s*255,\s*255,\s*0\.66\);[\s\S]*backdrop-filter:\s*var\(--workbench-glass-blur\)/)
|
||||
assert.match(workbenchGlassStyles, /\.capability-card::before,[\s\S]*\.capability-card::after/)
|
||||
assert.match(workbenchGlassStyles, /\.capability-card::before\s*\{[\s\S]*var\(--workbench-capability-bg-image\) 0 0 \/ var\(--workbench-capability-tile-size\) repeat;[\s\S]*opacity:\s*var\(--workbench-glass-noise-opacity\);/)
|
||||
assert.match(workbenchGlassStyles, /\.workbench-card::before\s*\{[\s\S]*var\(--workbench-panel-bg-image\) 0 0 \/ var\(--workbench-panel-tile-size\) repeat;[\s\S]*opacity:\s*calc\(var\(--workbench-glass-noise-opacity\) \* 0\.8\);/)
|
||||
assert.match(workbenchGlassStyles, /\.capability-card::after\s*\{[\s\S]*var\(--workbench-glass-highlight\)/)
|
||||
assert.match(workbenchGlassStyles, /\.workbench-card::after\s*\{[\s\S]*var\(--workbench-glass-highlight\)/)
|
||||
assert.doesNotMatch(workbenchGlassStyles, /\.capability-card::after\s*\{[^}]*radial-gradient/)
|
||||
assert.doesNotMatch(workbenchGlassStyles, /\.workbench-card::after\s*\{[^}]*radial-gradient/)
|
||||
assert.match(workbenchGlassStyles, /\.workbench-card > \*\s*\{[\s\S]*z-index:\s*1;/)
|
||||
assert.match(workbenchGlassStyles, /--workbench-capability-tile-size:\s*384px 384px;/)
|
||||
assert.match(workbenchGlassStyles, /--workbench-panel-tile-size:\s*512px 512px;/)
|
||||
assert.doesNotMatch(workbenchCardStyles, /var\(--workbench-capability-bg-image\)[^;]*cover no-repeat/)
|
||||
assert.doesNotMatch(workbenchCardStyles, /var\(--workbench-panel-bg-image\)[^;]*cover no-repeat/)
|
||||
assert.match(workbenchGlassStyles, /--workbench-glass-theme-tint:[\s\S]*--theme-primary-rgb/)
|
||||
assert.doesNotMatch(workbenchCardStyles, /background-blend-mode:\s*normal,\s*color,\s*normal;/)
|
||||
assert.match(workbenchResponsiveStyles, /--workbench-glass-noise-opacity:\s*0\.008;/)
|
||||
assert.match(workbenchResponsiveStyles, /--workbench-glass-blur:\s*blur\(14px\) saturate\(1\.2\);/)
|
||||
assert.match(workbenchGlassStyles, /\.todo-row,[\s\S]*\.progress-row\s*\{[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*inset 0 1px 0 rgba\(var\(--theme-primary-rgb/)
|
||||
assert.doesNotMatch(workbenchGlassStyles, /\.todo-row\s*\{[\s\S]*border-top:\s*1px solid var\(--workbench-line-soft\)/)
|
||||
assert.doesNotMatch(workbenchGlassStyles, /\.progress-row\s*\{[\s\S]*border-top:\s*1px solid var\(--workbench-line-soft\)/)
|
||||
assert.match(workbenchInsightStyles, /\.insight-metric-row,[\s\S]*\.insight-profile-card\s*\{[\s\S]*backdrop-filter:\s*blur\(10px\) saturate\(1\.16\)/)
|
||||
assert.doesNotMatch(workbenchInsightStyles, /background:\s*#ffffff;/)
|
||||
assert.ok(statSync(capabilityGlassAsset).size > 1024)
|
||||
assert.ok(statSync(panelGlassAsset).size > 1024)
|
||||
assert.ok(statSync(capabilityGlassAsset).size < 24 * 1024)
|
||||
assert.ok(statSync(panelGlassAsset).size < 24 * 1024)
|
||||
})
|
||||
|
||||
test('workbench submit shows intent recognition feedback before assistant opens', () => {
|
||||
assert.match(workbench, /class="assistant-intent-status"/)
|
||||
assert.match(workbench, /aria-live="polite"/)
|
||||
|
||||
@@ -418,7 +418,6 @@ test('progress steps show approval operator time and current stay duration', ()
|
||||
|
||||
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
|
||||
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
|
||||
const aiStep = request.progressSteps.find((step) => step.label === 'AI预审')
|
||||
const firstStep = request.progressSteps[0]
|
||||
|
||||
assert.equal(request.riskSummary, '无')
|
||||
@@ -426,8 +425,7 @@ test('progress steps show approval operator time and current stay duration', ()
|
||||
assert.equal(leaderStep.time, '李经理通过')
|
||||
assert.match(leaderStep.detail, /2026-05-20/)
|
||||
assert.match(leaderStep.title, /李经理审批通过/)
|
||||
assert.equal(aiStep.time, 'AI预审通过')
|
||||
assert.match(aiStep.detail, /2026-05-20/)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||
assert.equal(financeStep.current, true)
|
||||
assert.equal(financeStep.time, '停留 1小时30分钟')
|
||||
} finally {
|
||||
@@ -701,7 +699,7 @@ test('paid reimbursement marks payment progress step as complete', () => {
|
||||
assert.equal(request.approvalStatus, '已付款')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[LINKED_APPLICATION, '待提交', 'AI预审', '直属领导审批', '财务审批', '待付款', PAID, ARCHIVED]
|
||||
[LINKED_APPLICATION, '待提交', '直属领导审批', '财务审批', '待付款', PAID, ARCHIVED]
|
||||
)
|
||||
assert.equal(paymentStep.time, '待付款')
|
||||
assert.equal(paidStep.time, '已付款')
|
||||
|
||||
@@ -69,6 +69,10 @@ const guidedFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementGuidedFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const guidedModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementGuidedFlowModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const sessionStateScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -226,6 +230,7 @@ test('guided reimbursement requires application selection for travel and enterta
|
||||
reason: '去上海支持项目部署',
|
||||
location: '上海',
|
||||
amount: 1800,
|
||||
occurred_at: '2026-05-20T08:00:00Z',
|
||||
status: 'approved',
|
||||
created_at: '2026-05-20T08:00:00Z'
|
||||
},
|
||||
@@ -277,14 +282,25 @@ test('guided reimbursement requires application selection for travel and enterta
|
||||
assert.equal(state.applicationCandidates[0].claim_no, 'AP-202605-001')
|
||||
|
||||
state = selectGuidedRequiredApplication(state, actions[0].payload)
|
||||
assert.equal(state.stepKey, 'reason')
|
||||
assert.equal(state.stepKey, 'summary')
|
||||
assert.equal(isGuidedReimbursementReadyForReview(state), true)
|
||||
assert.equal(state.values.application_claim_no, 'AP-202605-001')
|
||||
assert.match(buildGuidedReimbursementSummaryText(state), /关联申请单:AP-202605-001/)
|
||||
const summaryText = buildGuidedReimbursementSummaryText(state)
|
||||
assert.match(summaryText, /关联申请单:AP-202605-001/)
|
||||
assert.match(summaryText, /草稿详情中上传对应票据/)
|
||||
assert.doesNotMatch(summaryText, /事由:待补充/)
|
||||
|
||||
const submitOptions = buildGuidedReviewSubmitOptions(state)
|
||||
assert.equal(submitOptions.extraContext.review_action, 'save_draft')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_claim_no, 'AP-202605-001')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.reason, '去上海支持项目部署')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.business_location, '上海')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.amount, '')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_amount, '1800')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_business_time, '2026-05-20')
|
||||
assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')
|
||||
assert.match(submitOptions.rawText, /关联申请单:AP-202605-001/)
|
||||
assert.doesNotMatch(submitOptions.rawText, /事由:待补充/)
|
||||
})
|
||||
|
||||
test('guided reimbursement interrupts suspicious questions before expensive flow', () => {
|
||||
@@ -356,9 +372,11 @@ test('guided flow state is serializable and restored through session state', ()
|
||||
test('guided flow is local until final confirmation or collected query handoff', () => {
|
||||
assert.doesNotMatch(guidedFlowScript, /runOrchestrator/)
|
||||
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
||||
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
|
||||
assert.match(guidedModelScript, /review_action:\s*['"]save_draft['"]/)
|
||||
assert.match(guidedFlowScript, /fetchExpenseClaims/)
|
||||
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
|
||||
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(guidedFlowState\.value\)[\s\S]*pushReimbursementSummary\(\)/)
|
||||
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(currentState\) && fileNames\.length[\s\S]*buildGuidedReviewSubmitOptions\(currentState, mergedFiles\)[\s\S]*skipDraftAssociationPrompt:\s*true[\s\S]*skipUserMessage:\s*true[\s\S]*submitExistingComposer\(submitOptions\)/)
|
||||
assert.match(guidedFlowScript, /if \(!applications\.length\) \{[\s\S]*guidedFlowState\.value = createEmptyGuidedFlowState\(\)[\s\S]*meta: \['缺少可关联申请单'\][\s\S]*\}\)/)
|
||||
assert.doesNotMatch(guidedFlowScript, /meta: \['缺少可关联申请单'\],[\s\S]{0,120}suggestedActions: buildGuidedExpenseTypeActions\(\)/)
|
||||
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
|
||||
|
||||
@@ -22,10 +22,22 @@ const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageItemTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const insightPanelTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementInsightPanel.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reimbursementService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reimbursementFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reviewActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -62,6 +74,10 @@ const createViewPart4Styles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part4.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const insightPanelStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-insight-panel.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
||||
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewOverviewDrawerAvailable"[\s\S]*title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
||||
@@ -101,6 +117,14 @@ test('document review drawer fills sidebar height and preview dialog is centered
|
||||
assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/)
|
||||
})
|
||||
|
||||
test('document review OCR result card header keeps copy and navigation separated', () => {
|
||||
assert.match(insightPanelTemplate, /class="review-side-head-copy"[\s\S]*票据识别结果卡片[\s\S]*逐张查看 OCR 结果/)
|
||||
assert.match(insightPanelStyles, /\.review-document-switch-head\s*\{[\s\S]*display:\s*grid;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) auto;/)
|
||||
assert.match(insightPanelStyles, /\.review-side-head-copy\s*\{[\s\S]*min-width:\s*0;[\s\S]*display:\s*grid;/)
|
||||
assert.match(insightPanelStyles, /\.review-side-head-copy p\s*\{[\s\S]*overflow-wrap:\s*anywhere;/)
|
||||
assert.match(insightPanelStyles, /\.review-document-nav\s*\{[\s\S]*flex:\s*0 0 auto;[\s\S]*flex-wrap:\s*nowrap;[\s\S]*white-space:\s*nowrap;/)
|
||||
})
|
||||
|
||||
test('document preview avoids restored stale object urls', () => {
|
||||
assert.match(createViewTemplate, /v-if="documentPreviewDialog\.kind === 'image'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
|
||||
assert.match(createViewTemplate, /v-else-if="documentPreviewDialog\.kind === 'pdf'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
|
||||
@@ -406,6 +430,15 @@ test('review drawer save action is disabled while receipt recognition is submitt
|
||||
)
|
||||
})
|
||||
|
||||
test('flow run detail refresh has timeout so composer submit is not held open', () => {
|
||||
assert.match(reimbursementFlowScript, /FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS\s*=\s*3000/)
|
||||
assert.match(
|
||||
reimbursementFlowScript,
|
||||
/await Promise\.race\(\[[\s\S]*fetchAgentRunDetail\(flowRunId\.value\)[\s\S]*globalThis\.setTimeout\(\(\) => resolve\(null\), FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS\)/
|
||||
)
|
||||
assert.match(reimbursementFlowScript, /if \(!run\) \{\s*return null\s*}/)
|
||||
})
|
||||
|
||||
test('draft creation keeps detail-scoped attachment persistence alive before close', () => {
|
||||
assert.match(
|
||||
submitComposerScript,
|
||||
@@ -529,11 +562,29 @@ test('saved draft review messages stop showing the save-draft prompt', () => {
|
||||
}
|
||||
const followup = buildReviewPlainFollowupCopy(reviewPayload, { savedDraft: true })
|
||||
|
||||
assert.equal(followup.lead, '补充信息:')
|
||||
assert.match(followup.summary, /草稿/)
|
||||
assert.match(followup.summary, /关联|补充|提交/)
|
||||
assert.equal(followup.lead, '后续处理:')
|
||||
assert.match(followup.summary, /自动检测/)
|
||||
assert.match(followup.summary, /继续上传/)
|
||||
assert.equal(followup.items.length, 0)
|
||||
assert.doesNotMatch(followup.summary, /当前草稿待完善|必须/)
|
||||
assert.doesNotMatch(followup.summary, /点击|点“草稿”|保存为草稿|临时保存|暂存/)
|
||||
assert.match(createViewTemplate, /buildReviewPlainFollowupForMessage\(message\)/)
|
||||
assert.match(messageItemTemplate, /buildReviewPlainFollowupForMessage\(message\)/)
|
||||
assert.match(createViewScript, /function isDraftSavedReviewMessage\(message\)/)
|
||||
assert.match(createViewScript, /function canUseInlineSaveDraft\(message\)[\s\S]*isDraftSavedReviewMessage\(message\)/)
|
||||
})
|
||||
|
||||
test('guided save draft emits refresh and exposes reimbursement draft detail card', () => {
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/emitDraftSaved:\s*\(payload\)\s*=>\s*emit\('draft-saved', payload\)/
|
||||
)
|
||||
assert.match(submitComposerScript, /function emitSavedDraftRefresh\(draftPayload\)/)
|
||||
assert.match(
|
||||
submitComposerScript,
|
||||
/emitSavedDraftRefresh\(payload\?\.result\?\.draft_payload \|\| null\)/
|
||||
)
|
||||
assert.match(createViewScript, /function shouldShowDraftSavedCard\(message\)/)
|
||||
assert.match(createViewScript, /function resolveReimbursementDraftClaimNo\(draftPayload\)/)
|
||||
assert.doesNotMatch(createViewScript, /function buildReimbursementDraftSummaryItems\(draftPayload\)/)
|
||||
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
|
||||
})
|
||||
|
||||
@@ -78,6 +78,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
assert.match(detailScript, /buildLeaderApprovalInfo/)
|
||||
assert.match(detailScript, /const leaderApprovalEvents = computed/)
|
||||
assert.match(detailScript, /const hasLeaderApprovalEvents = computed/)
|
||||
assert.match(detailScript, /const hasSingleLeaderApprovalEvent = computed\(\(\) => leaderApprovalEvents\.value\.length === 1\)/)
|
||||
assert.match(
|
||||
detailScript,
|
||||
/const showApplicationLeaderOpinion = computed\(\(\) => \(\s*isApplicationDocument\.value\s*&& hasLeaderApprovalEvents\.value\s*\)\)/
|
||||
@@ -104,6 +105,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
assert.match(detailTemplate, /class="application-leader-opinion"/)
|
||||
assert.match(detailTemplate, /v-if="hasLeaderApprovalEvents"/)
|
||||
assert.match(detailTemplate, /class="application-leader-opinion-timeline"/)
|
||||
assert.match(detailTemplate, /:class="\{ 'is-single': hasSingleLeaderApprovalEvent \}"/)
|
||||
assert.match(detailTemplate, /v-for="event in leaderApprovalEvents"/)
|
||||
assert.match(detailTemplate, /class="application-leader-opinion-event"/)
|
||||
assert.match(detailTemplate, /event\.type === 'returned'/)
|
||||
@@ -154,6 +156,8 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
assert.doesNotMatch(detailStyles, /\.leader-approval-card/)
|
||||
assert.doesNotMatch(detailStyles, /\.inline-leader-opinion/)
|
||||
assert.match(detailStyles, /\.application-leader-opinion-timeline \{/)
|
||||
assert.match(detailStyles, /\.application-leader-opinion-timeline\.is-single \{[\s\S]*padding-left: 0;/)
|
||||
assert.match(detailStyles, /\.application-leader-opinion-timeline\.is-single::before,[\s\S]*\.application-leader-opinion-timeline\.is-single \.application-leader-opinion-event::before \{[\s\S]*display: none;/)
|
||||
assert.match(detailStyles, /\.application-leader-opinion-event \{/)
|
||||
assert.match(detailStyles, /\.application-leader-opinion-event\.danger::before \{/)
|
||||
assert.match(detailStyles, /\.application-leader-opinion-event\.success::before \{/)
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
import {
|
||||
buildExpenseItemViewModel,
|
||||
buildDraftBlockingIssues,
|
||||
buildOptionalTravelReceiptRiskCards,
|
||||
isApplicationDocumentRequest
|
||||
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
|
||||
import {
|
||||
buildEmployeeProfileAdviceItems,
|
||||
buildTravelReceiptMaterialPrompts
|
||||
} from '../src/views/scripts/travelRequestDetailAdviceModel.js'
|
||||
|
||||
const detailViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
@@ -33,6 +36,10 @@ const detailViewInsights = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailInsights.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailExpenseModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailViewStyle = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -201,7 +208,7 @@ test('risk card badge only shows severity while title keeps business risk name',
|
||||
|
||||
test('AI advice falls back to claim risk summary instead of showing an empty risk area', () => {
|
||||
const riskCards = buildClaimSummaryRiskCards({
|
||||
riskSummary: 'AI预审发现 1 条中风险附件,已随单流转给审批人复核。'
|
||||
riskSummary: '自动检测发现 1 条中风险附件,已随单流转给审批人复核。'
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
@@ -283,7 +290,13 @@ test('stage risk advice card exposes direct reviewer action suggestion', () => {
|
||||
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
|
||||
assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
|
||||
assert.match(stageRiskAdviceCard, /compactAdviceItems/)
|
||||
assert.ok(
|
||||
stageRiskAdviceCard.indexOf('class="employee-risk-advice-list"')
|
||||
< stageRiskAdviceCard.indexOf('class="employee-risk-action"')
|
||||
)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
|
||||
assert.match(stageRiskAdviceCard, /\.employee-risk-ai-note \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/)
|
||||
assert.match(stageRiskAdviceCard, /\.employee-risk-action \{[\s\S]*align-items: center;[\s\S]*justify-content: center;[\s\S]*text-align: center;/)
|
||||
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
|
||||
assert.match(stageRiskAdviceCard, /可按权限继续审批/)
|
||||
})
|
||||
@@ -446,15 +459,35 @@ test('AI advice view model omits empty sections', () => {
|
||||
})
|
||||
|
||||
assert.deepEqual(readyAdvice.sections, [])
|
||||
assert.equal(readyAdvice.badge, '可直接提交')
|
||||
assert.equal(readyAdvice.badge, '可以提交')
|
||||
assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
|
||||
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险(1项)'])
|
||||
})
|
||||
|
||||
test('AI advice separates material prompts and profile advice from risk cards', () => {
|
||||
const advice = buildAiAdviceViewModel({
|
||||
completionItems: [],
|
||||
materialPrompts: ['当前包含 1 条住宿费用明细,但暂未关联住宿发票或酒店水单。'],
|
||||
profileAdviceItems: ['历史退单建议:近 90 天存在 1 次退单或退回记录。'],
|
||||
riskCards: []
|
||||
})
|
||||
|
||||
assert.equal(advice.riskCards.length, 0)
|
||||
assert.equal(advice.badge, '建议关注')
|
||||
assert.deepEqual(
|
||||
advice.sections.map((section) => ({ kind: section.kind, title: section.title })),
|
||||
[
|
||||
{ kind: 'material', title: '材料补充提示' },
|
||||
{ kind: 'profile', title: '历史操作建议' }
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('AI advice template renders grouped section titles with completion before risk', () => {
|
||||
assert.match(detailViewTemplate, /v-if="showAiAdvicePanel" class="detail-card panel validation-card"/)
|
||||
assert.match(detailViewTemplate, /<h3>\{\{ aiAdviceTitle \}\}<\/h3>/)
|
||||
assert.match(detailViewTemplate, /<p>\{\{ aiAdviceHint \}\}<\/p>/)
|
||||
assert.match(detailViewTemplate, /<p v-if="aiAdviceHint">\{\{ aiAdviceHint \}\}<\/p>/)
|
||||
assert.doesNotMatch(detailViewScript, /AI预审已完成,请按风险提示补充原因或进入下一步。/)
|
||||
assert.match(detailViewScript, /businessStage: currentBusinessStage/)
|
||||
assert.match(detailViewScript, /filterRiskCardsByBusinessStage/)
|
||||
assert.match(detailViewScript, /const summaryRiskCards = filterRiskCardsByBusinessStage/)
|
||||
@@ -462,19 +495,23 @@ test('AI advice template renders grouped section titles with completion before r
|
||||
assert.match(detailViewScript, /const canViewApprovalRiskAdvice = computed/)
|
||||
assert.match(detailViewScript, /!isCurrentApplicant\.value/)
|
||||
assert.match(detailViewScript, /const hasVisibleRiskCards = computed/)
|
||||
assert.match(detailViewScript, /const showCompactSafeAdvice = computed/)
|
||||
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
|
||||
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
|
||||
assert.match(detailViewScript, /return '报销风险提示'/)
|
||||
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)
|
||||
assert.match(detailViewScript, /hasAiPreReviewResult\.value/)
|
||||
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
|
||||
assert.match(detailViewScript, /buildEmployeeProfileAdviceItems\(employeeRiskProfile\.value\)/)
|
||||
assert.match(detailViewScript, /fetchEmployeeLatestProfile\(employeeId/)
|
||||
assert.doesNotMatch(detailViewScript, /hasAiPreReviewResult\.value/)
|
||||
assert.match(detailViewTemplate, /v-if="aiAdvice\.sections\.length" class="validation-sections"/)
|
||||
assert.match(detailViewTemplate, /v-for="section in aiAdvice\.sections"/)
|
||||
assert.match(detailViewTemplate, /validation-section--\$\{section\.kind\}/)
|
||||
assert.match(detailViewTemplate, /<h4 class="validation-section-title">\{\{ section\.title \}\}<\/h4>/)
|
||||
assert.match(detailViewTemplate, /v-if="section\.kind === 'completion'" class="validation-list"/)
|
||||
assert.match(detailViewTemplate, /v-if="section\.kind !== 'risk'" class="validation-list"/)
|
||||
assert.match(detailViewTemplate, /v-else class="risk-advice-list"/)
|
||||
assert.ok(
|
||||
detailViewTemplate.indexOf("section.kind === 'completion'") < detailViewTemplate.indexOf('risk-advice-card')
|
||||
detailViewTemplate.indexOf("section.kind !== 'risk'") < detailViewTemplate.indexOf('risk-advice-card')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -600,6 +637,7 @@ test('ticket item types and system allowance row are visible but read only', ()
|
||||
assert.match(detailViewScript, /value: 'ride_ticket', label: '乘车'/)
|
||||
assert.match(detailViewScript, /value: 'travel_allowance', label: '出差补贴'/)
|
||||
assert.match(detailViewScript, /const SYSTEM_GENERATED_EXPENSE_TYPES = new Set\(\['travel_allowance'\]\)/)
|
||||
assert.match(detailExpenseModelScript, /const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set\(\['ride_ticket', 'travel_allowance'\]\)/)
|
||||
assert.match(detailViewTemplate, /'system-generated-row': item\.isSystemGenerated/)
|
||||
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-row-lock"/)
|
||||
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-attachment-note"/)
|
||||
@@ -665,16 +703,76 @@ test('expense detail edit keeps delete but removes cancel and allows draft place
|
||||
assert.match(detailViewScript, /if \(expenseEditor\.itemDate\) \{[\s\S]*itemPayload\.item_date = expenseEditor\.itemDate/)
|
||||
})
|
||||
|
||||
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]*乘车票据提醒/)
|
||||
assert.match(detailViewScript, /可以继续补充票据报销/)
|
||||
assert.match(
|
||||
detailViewScript,
|
||||
/const optionalRiskCards = filterRiskCardsByBusinessStage\([\s\S]*buildOptionalTravelReceiptRiskCards\(request\.value, expenseItems\.value\)[\s\S]*currentBusinessStage/
|
||||
test('travel detail AI advice uses material prompts only for required hotel receipts', () => {
|
||||
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
|
||||
assert.doesNotMatch(detailViewScript, /buildOptionalTravelReceiptRiskCards/)
|
||||
assert.doesNotMatch(detailViewScript, /travel-optional-ride-ticket/)
|
||||
assert.deepEqual(
|
||||
buildTravelReceiptMaterialPrompts(
|
||||
{ typeCode: 'travel', detailVariant: 'travel' },
|
||||
[{ id: 'ride', itemType: 'ride_ticket', itemReason: '打车', invoiceId: '' }]
|
||||
),
|
||||
[]
|
||||
)
|
||||
assert.deepEqual(
|
||||
buildTravelReceiptMaterialPrompts(
|
||||
{ typeCode: 'travel', detailVariant: 'travel' },
|
||||
[{ id: 'hotel', itemType: 'hotel_ticket', itemReason: '住宿', invoiceId: '' }]
|
||||
),
|
||||
['当前包含 1 条住宿费用明细,但暂未关联住宿发票或酒店水单。请补充住宿材料,避免后续被退回补件。']
|
||||
)
|
||||
assert.deepEqual(
|
||||
buildDraftBlockingIssues(
|
||||
{
|
||||
profileName: '张三',
|
||||
typeCode: 'transport',
|
||||
typeLabel: '交通费',
|
||||
reason: '客户现场打车',
|
||||
occurredDisplay: '2026-06-01',
|
||||
amountValue: 42
|
||||
},
|
||||
[
|
||||
buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'ride',
|
||||
itemType: 'ride_ticket',
|
||||
itemReason: '园区-客户现场',
|
||||
itemDate: '2026-06-01',
|
||||
itemAmount: 42,
|
||||
invoiceId: ''
|
||||
},
|
||||
0,
|
||||
{ typeCode: 'transport' }
|
||||
)
|
||||
]
|
||||
),
|
||||
[]
|
||||
)
|
||||
assert.ok(
|
||||
buildDraftBlockingIssues(
|
||||
{
|
||||
profileName: '张三',
|
||||
typeCode: 'hotel',
|
||||
typeLabel: '住宿费',
|
||||
reason: '住宿报销',
|
||||
occurredDisplay: '2026-06-01',
|
||||
amountValue: 450
|
||||
},
|
||||
[
|
||||
buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'hotel',
|
||||
itemType: 'hotel_ticket',
|
||||
itemReason: '北京中心酒店',
|
||||
itemDate: '2026-06-01',
|
||||
itemAmount: 450,
|
||||
invoiceId: ''
|
||||
},
|
||||
0,
|
||||
{ typeCode: 'hotel' }
|
||||
)
|
||||
]
|
||||
).some((item) => item.includes('缺少票据标识'))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -717,13 +815,36 @@ test('application detail does not show optional travel receipt reminders', () =>
|
||||
|
||||
assert.equal(isApplicationDocumentRequest(request), true)
|
||||
assert.deepEqual(
|
||||
buildOptionalTravelReceiptRiskCards(request, [
|
||||
buildTravelReceiptMaterialPrompts(request, [
|
||||
{ id: 'allowance', itemType: 'travel_allowance', isSystemGenerated: true, invoiceId: '' }
|
||||
]),
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
test('employee profile advice highlights prior return and material quality issues', () => {
|
||||
const items = buildEmployeeProfileAdviceItems({
|
||||
profiles: [
|
||||
{
|
||||
profile_type: 'process_quality',
|
||||
metrics: {
|
||||
return_count: 2,
|
||||
missing_attachment_count: 1,
|
||||
missing_business_context_count: 1,
|
||||
invoice_mismatch_count: 1
|
||||
}
|
||||
}
|
||||
],
|
||||
review_suggestions: [
|
||||
{ message: '申请人近期材料质量波动较高,建议重点核对附件、事由和票据一致性。' }
|
||||
]
|
||||
})
|
||||
|
||||
assert.ok(items.some((item) => item.includes('历史退单建议')))
|
||||
assert.ok(items.some((item) => item.includes('材料完整性建议')))
|
||||
assert.ok(items.some((item) => item.includes('票据一致性建议')))
|
||||
})
|
||||
|
||||
test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
|
||||
const issues = buildDraftBlockingIssues(
|
||||
{
|
||||
|
||||
@@ -48,7 +48,7 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
||||
assert.match(detailViewTemplate, /@click="handleSubmit"/)
|
||||
|
||||
assert.match(detailViewScript, /const submitConfirmDialogOpen = ref\(false\)/)
|
||||
assert.match(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/)
|
||||
assert.doesNotMatch(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/)
|
||||
assert.match(detailViewScript, /const submitActionLabel = computed/)
|
||||
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
|
||||
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = false/)
|
||||
@@ -59,7 +59,7 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
||||
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
|
||||
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
|
||||
assert.doesNotMatch(handleSubmit, /submitExpenseClaim/)
|
||||
assert.match(handleSubmit, /runAiPreReview\(\)/)
|
||||
assert.doesNotMatch(handleSubmit, /runAiPreReview\(\)/)
|
||||
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
|
||||
})
|
||||
|
||||
@@ -93,6 +93,9 @@ test('archived detail delete action is gated by admin-only permission', () => {
|
||||
|
||||
test('editable detail delete action is limited to applicant or claim manager', () => {
|
||||
assert.match(detailViewScript, /const isCurrentApplicant = computed/)
|
||||
assert.match(detailViewScript, /isPlatformAdminUser/)
|
||||
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return isPlatformAdminUser\(currentUser\.value\)\s*}/)
|
||||
assert.match(detailViewScript, /if \(canManageCurrentClaim\.value\) {\s*return true\s*}/)
|
||||
assert.match(detailViewScript, /return isEditableRequest\.value && isCurrentApplicant\.value/)
|
||||
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user