feat: 增强知识库索引与设置页面模块化拆分

扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优
化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件
和 Hermes 员工同步子面板并重构样式,新增日志详情组件和
知识入库日志模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 23:47:28 +08:00
parent 88ff04bef8
commit 5b388d08c0
84 changed files with 10170 additions and 2599 deletions

View File

@@ -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\} 详情重新填写/)