feat: 增强知识库索引与设置页面模块化拆分
扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优 化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件 和 Hermes 员工同步子面板并重构样式,新增日志详情组件和 知识入库日志模型,补充单元测试覆盖。
This commit is contained in:
@@ -7,7 +7,10 @@ import {
|
||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||
buildAttachmentAssociationConfirmationMessage,
|
||||
buildOcrFilePreviews,
|
||||
buildReviewFilePreviewsFromReviewPayload
|
||||
buildReviewFilePreviewsFromReviewPayload,
|
||||
buildUnsavedDraftAttachmentConfirmationMessage,
|
||||
filterPersistableFilePreviews,
|
||||
mergeFilePreviews
|
||||
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
buildDraftAssociationQueryPayload,
|
||||
@@ -99,6 +102,14 @@ test('attachment upload association uses conversation selection instead of legac
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const flowSource = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const conversationSource = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/)
|
||||
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
|
||||
@@ -112,6 +123,26 @@ test('attachment upload association uses conversation selection instead of legac
|
||||
submitComposerSource,
|
||||
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
|
||||
)
|
||||
assert.match(submitComposerSource, /mode:\s*'save_then_associate'/)
|
||||
assert.match(submitComposerSource, /review_action:\s*'save_draft'[\s\S]*review_action:\s*'link_to_existing_draft'/)
|
||||
assert.match(submitComposerSource, /appendToCurrentFlow:\s*true/)
|
||||
assert.match(submitComposerSource, /const appendToCurrentFlow = Boolean\(options\.appendToCurrentFlow\)/)
|
||||
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(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)
|
||||
})
|
||||
|
||||
test('unsaved review attachment prompt asks for explicit rich-text confirmation', () => {
|
||||
const message = buildUnsavedDraftAttachmentConfirmationMessage({
|
||||
fileNames: ['taxi.pdf']
|
||||
})
|
||||
const rendered = renderMarkdown(message)
|
||||
|
||||
assert.match(message, /当前这笔报销信息还没有保存为草稿/)
|
||||
assert.match(message, /本次待归集附件:1 份/)
|
||||
assert.match(message, new RegExp(`\\*\\*\\[确定\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)\\*\\*`))
|
||||
assert.match(rendered, /<strong><a href="#confirm-attachment-association" class="markdown-action-link markdown-action-link-confirm">确定<\/a><\/strong>/)
|
||||
})
|
||||
|
||||
test('OCR preview builders keep hotel receipt image previews when preview kind is omitted', () => {
|
||||
@@ -137,6 +168,26 @@ test('OCR preview builders keep hotel receipt image previews when preview kind i
|
||||
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
||||
})
|
||||
|
||||
test('file preview cache replaces temporary object urls and never persists them', () => {
|
||||
const merged = mergeFilePreviews(
|
||||
[
|
||||
{ filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/old-preview' },
|
||||
{ filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' }
|
||||
],
|
||||
[
|
||||
{ filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/new-preview' },
|
||||
{ filename: 'hotel.png', kind: 'image' }
|
||||
]
|
||||
)
|
||||
|
||||
assert.equal(merged.length, 2)
|
||||
assert.equal(merged[0].url, 'blob:http://localhost/new-preview')
|
||||
assert.equal(merged[1].url, 'data:image/png;base64,stable')
|
||||
assert.deepEqual(filterPersistableFilePreviews(merged), [
|
||||
{ filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' }
|
||||
])
|
||||
})
|
||||
|
||||
test('draft association query keeps a single candidate selectable in the conversation', () => {
|
||||
const payload = buildDraftAssociationQueryPayload([
|
||||
{
|
||||
@@ -182,6 +233,30 @@ test('expense query payload keeps structured risk items for claim-level risk dri
|
||||
assert.equal(payload.records[0].riskItems[0].summary, '住宿金额超过城市标准')
|
||||
})
|
||||
|
||||
test('expense query info items render as prompts instead of low risk', () => {
|
||||
const payload = normalizeExpenseQueryPayload({
|
||||
result_type: 'expense_claim_list',
|
||||
records: [
|
||||
{
|
||||
claim_id: 'claim-info',
|
||||
claim_no: 'EXP-202605-010',
|
||||
amount: 59.1,
|
||||
risk_flags: [
|
||||
{
|
||||
key: 'normal-tip',
|
||||
level: 'info',
|
||||
title: '票据提示',
|
||||
summary: '票据已识别,当前没有异常。'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(payload.records[0].riskItems[0].levelLabel, '提示')
|
||||
assert.notEqual(payload.records[0].riskItems[0].levelLabel, '低风险')
|
||||
})
|
||||
|
||||
test('expense query hint guides users to the reimbursement center after the top five results', () => {
|
||||
const payload = normalizeExpenseQueryPayload({
|
||||
result_type: 'expense_claim_list',
|
||||
|
||||
117
web/tests/knowledge-ingest-log-model.test.mjs
Normal file
117
web/tests/knowledge-ingest-log-model.test.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
buildKnowledgeIngestLogModel,
|
||||
isKnowledgeIngestRun
|
||||
} from '../src/utils/knowledgeIngestLogModel.js'
|
||||
|
||||
function buildRun() {
|
||||
return {
|
||||
status: 'running',
|
||||
route_json: {
|
||||
job_type: 'knowledge_index_sync',
|
||||
folder: '制度文件',
|
||||
phase: 'indexing',
|
||||
progress: {
|
||||
total_documents: 2,
|
||||
completed_documents: 1,
|
||||
failed_documents: 0,
|
||||
percent: 55
|
||||
},
|
||||
knowledge_ingest: {
|
||||
status: 'running',
|
||||
phase: 'indexing',
|
||||
current_document_id: 'doc-2',
|
||||
graph: {
|
||||
chunk_count: 5,
|
||||
entity_count: 3,
|
||||
relation_count: 2,
|
||||
entities: ['远光软件', '支出管理'],
|
||||
relations: [{ source: '远光软件', target: '支出管理', type: '关联' }]
|
||||
},
|
||||
documents: [
|
||||
{
|
||||
document_id: 'doc-1',
|
||||
name: '公司支出管理办法.pdf',
|
||||
folder: '制度文件',
|
||||
extension: 'pdf',
|
||||
status: 'succeeded',
|
||||
phase: 'indexed',
|
||||
chunk_count: 3,
|
||||
entity_count: 2,
|
||||
relation_count: 1,
|
||||
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围' }],
|
||||
sections: [{ title: '第一章 总则', excerpt: '适用于公司支出。' }],
|
||||
events: [{ at: '2026-05-22T08:00:00Z', level: 'info', message: '完成' }]
|
||||
},
|
||||
{
|
||||
document_id: 'doc-2',
|
||||
name: '费用审批台账.xlsx',
|
||||
folder: '制度文件',
|
||||
extension: 'xlsx',
|
||||
status: 'running',
|
||||
phase: 'indexing',
|
||||
chunk_count: 2,
|
||||
entity_count: 1,
|
||||
relation_count: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function testDetectsKnowledgeIngestRun() {
|
||||
assert.equal(isKnowledgeIngestRun(buildRun()), true)
|
||||
assert.equal(isKnowledgeIngestRun({ route_json: { job_type: 'daily_check' } }), false)
|
||||
}
|
||||
|
||||
function testBuildsInteractiveModel() {
|
||||
const model = buildKnowledgeIngestLogModel(buildRun())
|
||||
|
||||
assert.equal(model.available, true)
|
||||
assert.equal(model.folder, '制度文件')
|
||||
assert.equal(model.selectedDocumentId, 'doc-2')
|
||||
assert.equal(model.documents.length, 2)
|
||||
assert.equal(model.documents[0].statusLabel, '已完成')
|
||||
assert.equal(model.documents[0].chunks[0].summary, '支出管理范围')
|
||||
assert.equal(model.graph.entityCount, 3)
|
||||
assert.equal(model.graph.relations[0].source, '远光软件')
|
||||
assert.equal(model.metrics[1].value, '5')
|
||||
}
|
||||
|
||||
function testFallsBackToToolCallDocuments() {
|
||||
const model = buildKnowledgeIngestLogModel({
|
||||
status: 'succeeded',
|
||||
route_json: {
|
||||
job_type: 'knowledge_index_sync',
|
||||
requested_document_ids: ['doc-1']
|
||||
},
|
||||
tool_calls: [
|
||||
{
|
||||
response_json: {
|
||||
documents: [
|
||||
{
|
||||
document_id: 'doc-1',
|
||||
name: '归集结果.docx',
|
||||
status: 'succeeded',
|
||||
chunk_count: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(model.documents[0].name, '归集结果.docx')
|
||||
assert.equal(model.graph.chunkCount, 1)
|
||||
}
|
||||
|
||||
function run() {
|
||||
testDetectsKnowledgeIngestRun()
|
||||
testBuildsInteractiveModel()
|
||||
testFallsBackToToolCallDocuments()
|
||||
console.log('knowledge ingest log model tests passed')
|
||||
}
|
||||
|
||||
run()
|
||||
32
web/tests/navigation-route-resolution.test.mjs
Normal file
32
web/tests/navigation-route-resolution.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
resolveAppViewFromRoute,
|
||||
resolveTargetRouteName
|
||||
} from '../src/composables/useNavigation.js'
|
||||
|
||||
function testDerivesViewFromRouteName() {
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'logs')
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'requests')
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'logs' } }), 'policies')
|
||||
}
|
||||
|
||||
function testFallsBackToValidMeta() {
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'custom', meta: { appView: 'employees' } }), 'employees')
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'custom', meta: { appView: 'unknown' } }), 'overview')
|
||||
}
|
||||
|
||||
function testResolvesMainRouteNames() {
|
||||
assert.equal(resolveTargetRouteName('logs'), 'app-logs')
|
||||
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
|
||||
assert.equal(resolveTargetRouteName('missing'), 'app-overview')
|
||||
}
|
||||
|
||||
function run() {
|
||||
testDerivesViewFromRouteName()
|
||||
testFallsBackToValidMeta()
|
||||
testResolvesMainRouteNames()
|
||||
console.log('navigation route resolution tests passed')
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -24,6 +24,19 @@ test('local flow intent preview names transport expense for riding fare text', (
|
||||
(item) => item.includes('交通出行') && item.includes('交通费')
|
||||
)
|
||||
)
|
||||
assert.ok(
|
||||
buildLocalExtractionProgressMessages(ridingFareMessage).some(
|
||||
(item) => item.includes('正在判断待补项') && !item.includes('客户名称') && !item.includes('参与人员')
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
test('local flow recognizes broader reimbursement scene keywords', () => {
|
||||
assert.equal(inferLocalFlowCandidates('报销会议场地费').expenseType, '会务费')
|
||||
assert.equal(inferLocalFlowCandidates('报销打印纸和硒鼓').expenseType, '办公用品费')
|
||||
assert.equal(inferLocalFlowCandidates('报销培训课程费').expenseType, '培训费')
|
||||
assert.equal(inferLocalFlowCandidates('报销手机话费和流量费').expenseType, '通讯费')
|
||||
assert.equal(inferLocalFlowCandidates('报销员工体检费').expenseType, '福利费')
|
||||
})
|
||||
|
||||
test('semantic intent detail includes recognized expense type', () => {
|
||||
|
||||
@@ -3,7 +3,14 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { buildReviewPlainFollowupCopy } from '../src/views/scripts/travelReimbursementReviewModel.js'
|
||||
import {
|
||||
buildLocallySyncedReviewPayload,
|
||||
buildReviewNextStepRichCopy,
|
||||
buildReviewPlainFollowupCopy,
|
||||
isTravelReviewPayload,
|
||||
resolveReviewFooterActions
|
||||
} from '../src/views/scripts/travelReimbursementReviewModel.js'
|
||||
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||
|
||||
const createViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
@@ -33,6 +40,26 @@ const attachmentsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const sessionStateScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewBaseStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewPart2Styles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part2.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewPart3Styles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part3.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewPart4Styles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part4.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"/)
|
||||
@@ -58,6 +85,157 @@ test('review drawer tool buttons switch modes instead of toggling the active mod
|
||||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||||
})
|
||||
|
||||
test('document review drawer fills sidebar height and preview dialog is centered', () => {
|
||||
assert.match(createViewTemplate, /class="insight-body"[\s\S]*:class="\{ 'document-review-body': isReviewDocumentDrawer \}"/)
|
||||
assert.match(createViewBaseStyles, /\.insight-panel-shell\s*\{[\s\S]*display:\s*flex;[\s\S]*min-height:\s*0;/)
|
||||
assert.match(createViewPart2Styles, /\.insight-body\.document-review-body\s*\{[\s\S]*display:\s*flex;[\s\S]*overflow:\s*hidden;/)
|
||||
assert.match(createViewPart2Styles, /\.review-ticket-drawer\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);[\s\S]*height:\s*100%;[\s\S]*overflow:\s*hidden;/)
|
||||
assert.match(createViewPart2Styles, /\.review-document-stage\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\);/)
|
||||
assert.match(createViewPart2Styles, /\.review-document-scroll\s*\{[\s\S]*max-height:\s*none;[\s\S]*min-height:\s*0;/)
|
||||
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image\s*\{[\s\S]*place-items:\s*center;[\s\S]*min-height:\s*220px;/)
|
||||
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*height:\s*auto;[\s\S]*object-fit:\s*contain;/)
|
||||
assert.doesNotMatch(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*object-fit:\s*cover;/)
|
||||
assert.match(createViewPart4Styles, /\.review-overlay\s*\{[\s\S]*align-items:\s*center;[\s\S]*justify-content:\s*center;/)
|
||||
assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/)
|
||||
})
|
||||
|
||||
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"/)
|
||||
assert.match(reviewDrawerScript, /renderKey:\s*''/)
|
||||
assert.match(reviewDrawerScript, /renderKey:\s*\[[\s\S]*Date\.now\(\)[\s\S]*\]\.join\('__'\)/)
|
||||
assert.match(attachmentsScript, /isTemporaryPreviewUrl/)
|
||||
assert.match(attachmentsScript, /existingPreview\?\.url && !isTemporaryPreviewUrl\(existingPreview\.url\)/)
|
||||
assert.match(sessionStateScript, /filterPersistableFilePreviews\(state\.reviewFilePreviews\)/)
|
||||
assert.doesNotMatch(sessionStateScript, /filterPersistableFilePreviews\(nextState\.reviewFilePreviews\)/)
|
||||
})
|
||||
|
||||
test('local transport review no longer uses the travel hotel template', () => {
|
||||
const reviewPayload = {
|
||||
slot_cards: [
|
||||
{
|
||||
key: 'expense_type',
|
||||
label: '报销类型',
|
||||
value: '交通费',
|
||||
normalized_value: 'transport',
|
||||
status: 'identified'
|
||||
}
|
||||
],
|
||||
document_cards: [
|
||||
{
|
||||
document_type: 'taxi_receipt',
|
||||
suggested_expense_type: 'transport',
|
||||
scene_label: '交通费'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert.equal(isTravelReviewPayload(reviewPayload, { expense_type: '交通费' }), false)
|
||||
assert.match(createViewScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
|
||||
assert.doesNotMatch(
|
||||
createViewScript,
|
||||
/key:\s*'customer_name'[\s\S]{0,220}placeholder:\s*'请输入客户名称'[\s\S]{0,80}\},\s*\{[\s\S]{0,80}key:\s*'attachments'/
|
||||
)
|
||||
assert.match(createViewTemplate, /placeholder="例如:出租车\/网约车票据 \/ 火车\/高铁票"/)
|
||||
assert.doesNotMatch(createViewTemplate, /票据场景[\s\S]{0,260}例如:业务招待费 \/ 差旅费/)
|
||||
})
|
||||
|
||||
test('local save of changed reimbursement category updates edit fields too', () => {
|
||||
const nextPayload = buildLocallySyncedReviewPayload(
|
||||
{
|
||||
can_proceed: false,
|
||||
edit_fields: [
|
||||
{ key: 'expense_type', label: '报销分类', value: '交通费' },
|
||||
{ key: 'reason', label: '事由', value: '打车去客户现场' }
|
||||
],
|
||||
slot_cards: [
|
||||
{
|
||||
key: 'expense_type',
|
||||
label: '报销类型',
|
||||
value: '交通费',
|
||||
normalized_value: 'transport',
|
||||
required: true,
|
||||
status: 'identified'
|
||||
}
|
||||
],
|
||||
confirmation_actions: []
|
||||
},
|
||||
{
|
||||
expense_type: '办公用品费',
|
||||
reason_value: '右侧核对后改为办公用品费'
|
||||
}
|
||||
)
|
||||
|
||||
const expenseTypeField = nextPayload.edit_fields.find((item) => item.key === 'expense_type')
|
||||
assert.equal(expenseTypeField.value, '办公用品费')
|
||||
assert.equal(nextPayload.slot_cards[0].value, '办公用品费')
|
||||
})
|
||||
|
||||
test('next step action uses rich text guidance and confirm dialog instead of footer button', () => {
|
||||
const reviewPayload = {
|
||||
can_proceed: true,
|
||||
risk_briefs: [
|
||||
{ level: 'low', title: '票据提示', content: '普通提示' }
|
||||
],
|
||||
confirmation_actions: [
|
||||
{ action_type: 'save_draft', label: '保存为草稿' },
|
||||
{ action_type: 'next_step', label: '继续下一步', emphasis: 'primary' }
|
||||
]
|
||||
}
|
||||
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/requests/claim-1' })
|
||||
const rendered = renderMarkdown(copy)
|
||||
|
||||
assert.match(copy, /系统识别您的单据已经填写完所有已知信息/)
|
||||
assert.match(copy, /现存在 1 条低风险,0 条中风险,0 条高风险/)
|
||||
assert.doesNotMatch(copy, /#review-risk-low/)
|
||||
assert.doesNotMatch(copy, /#review-risk-medium/)
|
||||
assert.doesNotMatch(copy, /#review-risk-high/)
|
||||
assert.match(copy, /\[右侧\]\(#review-risk-panel\) 风险信息提示窗/)
|
||||
assert.match(copy, /\[继续下一步\]\(#review-next-step\)/)
|
||||
assert.match(copy, /\[快速修改单据信息\]\(\/app\/requests\/claim-1\)/)
|
||||
assert.doesNotMatch(rendered, /markdown-risk-link-/)
|
||||
assert.match(rendered, /<span class="markdown-risk-text-low">低风险<\/span>/)
|
||||
assert.match(rendered, /<span class="markdown-risk-text-medium">中风险<\/span>/)
|
||||
assert.match(rendered, /<span class="markdown-risk-text-high">高风险<\/span>/)
|
||||
assert.doesNotMatch(rendered, /href="#review-risk-low"/)
|
||||
assert.doesNotMatch(rendered, /href="#review-risk-medium"/)
|
||||
assert.doesNotMatch(rendered, /href="#review-risk-high"/)
|
||||
assert.match(rendered, /markdown-action-link-risk/)
|
||||
assert.match(rendered, /markdown-action-link-next/)
|
||||
assert.deepEqual(resolveReviewFooterActions(reviewPayload), [])
|
||||
|
||||
const highRiskCopy = buildReviewNextStepRichCopy(
|
||||
{
|
||||
...reviewPayload,
|
||||
risk_briefs: [{ level: 'high', title: '金额超标' }]
|
||||
},
|
||||
{ detailHref: '/app/requests/claim-1' }
|
||||
)
|
||||
assert.doesNotMatch(highRiskCopy, /\[继续下一步\]\(#review-next-step\)/)
|
||||
|
||||
assert.match(createViewTemplate, /class="review-next-step-rich-copy message-answer-markdown"[\s\S]*renderMarkdown\(buildReviewNextStepRichCopyForMessage\(message\)\)/)
|
||||
assert.match(createViewTemplate, /class="message-bubble" :class="buildMessageBubbleClass\(message\)"/)
|
||||
assert.match(createViewTemplate, /:open="nextStepConfirmDialog\.open"[\s\S]*title="确认提交当前单据?"[\s\S]*confirm-text="确认提交"/)
|
||||
assert.match(createViewScript, /const REVIEW_NEXT_STEP_HREF = '#review-next-step'/)
|
||||
assert.match(createViewScript, /buildReviewRiskLevelCounts/)
|
||||
assert.match(createViewScript, /function buildMessageBubbleClass\(message\)/)
|
||||
assert.match(createViewScript, /message-bubble-review-risk-high/)
|
||||
assert.match(createViewScript, /message-bubble-review-risk-medium/)
|
||||
assert.match(createViewScript, /message-bubble-review-risk-low/)
|
||||
assert.match(createViewScript, /function openReviewNextStepConfirm\(message\)/)
|
||||
assert.match(createViewScript, /async function confirmReviewNextStepSubmit\(\)/)
|
||||
assert.match(createViewScript, /href === REVIEW_NEXT_STEP_HREF[\s\S]*openReviewNextStepConfirm\(message\)/)
|
||||
assert.match(createViewScript, /href\.startsWith\(REVIEW_RISK_PANEL_HREF_PREFIX\)[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
|
||||
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-low\s*\{[\s\S]*border-color:\s*rgba\(37,\s*99,\s*235,/)
|
||||
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-medium\s*\{[\s\S]*border-color:\s*rgba\(217,\s*119,\s*6,/)
|
||||
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-high\s*\{[\s\S]*border-color:\s*rgba\(220,\s*38,\s*38,/)
|
||||
assert.doesNotMatch(createViewBaseStyles, /markdown-risk-link-low/)
|
||||
assert.match(createViewBaseStyles, /\.markdown-risk-text-low[\s\S]*color:\s*#2563eb/)
|
||||
assert.match(createViewBaseStyles, /\.markdown-risk-text-medium[\s\S]*color:\s*#d97706/)
|
||||
assert.match(createViewBaseStyles, /\.markdown-risk-text-high[\s\S]*color:\s*#dc2626/)
|
||||
assert.match(createViewPart3Styles, /\.review-next-step-rich-copy\s*\{[\s\S]*margin-top:\s*30px;/)
|
||||
})
|
||||
|
||||
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
|
||||
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/)
|
||||
assert.ok(riskItemsBlock, 'risk item builder should be present')
|
||||
@@ -80,8 +258,12 @@ test('review risk drawer lists risk briefs without score and posts details into
|
||||
)
|
||||
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
|
||||
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
|
||||
assert.match(createViewScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
|
||||
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
||||
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
||||
assert.match(createViewScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
|
||||
assert.match(createViewScript, /\$\{isInfo \? '提示内容' : '风险点'\}:\$\{summary\}/)
|
||||
assert.match(createViewScript, /\$\{isInfo \? '处理建议' : '修改建议'\}:\$\{suggestion\}/)
|
||||
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
|
||||
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
|
||||
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
||||
@@ -98,7 +280,8 @@ test('review risk drawer lists risk briefs without score and posts details into
|
||||
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
||||
)
|
||||
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
|
||||
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
|
||||
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
|
||||
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
|
||||
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
|
||||
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
|
||||
assert.match(createViewScript, /function appendExpenseQueryRiskToConversation\(record, risk\) \{[\s\S]*进入 \$\{claimNo\} 详情重新填写/)
|
||||
|
||||
@@ -67,15 +67,15 @@ const attachmentMeta = {
|
||||
severity: 'high',
|
||||
label: '高风险',
|
||||
headline: '票据类型不匹配',
|
||||
summary: '交通票据挂在办公费明细下。',
|
||||
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公费'],
|
||||
summary: '交通票据挂在办公用品费明细下。',
|
||||
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公用品费'],
|
||||
suggestion: '把费用项目调整为交通费,或更换为办公用品票据。'
|
||||
}
|
||||
}
|
||||
|
||||
test('attachment insight exposes recognition fields and rule basis', () => {
|
||||
const insight = buildAttachmentInsightViewModel(attachmentMeta, {
|
||||
name: '办公费',
|
||||
name: '办公用品费',
|
||||
itemType: 'office'
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ test('AI advice card splits every attachment risk point with basis and suggestio
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '办公费',
|
||||
name: '办公用品费',
|
||||
invoiceId: 'taxi-invoice.pdf'
|
||||
}
|
||||
],
|
||||
@@ -278,7 +278,7 @@ test('AI advice view model exposes grouped completion and risk sections', () =>
|
||||
tone: 'high',
|
||||
label: '高风险',
|
||||
title: '票据类型不匹配',
|
||||
risk: '交通票据挂在办公费明细下。',
|
||||
risk: '交通票据挂在办公用品费明细下。',
|
||||
ruleBasis: ['附件类型与当前费用项目不匹配。'],
|
||||
suggestion: '把费用项目调整为交通费。'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user