feat: 新增风险规则生成引擎与知识图谱可视化

后端新增风险规则自动生成和模板执行服务,支持从规则资产
批量生成并持久化风险规则文件;知识库入库日志增强图谱
查询和本地 RAG 回退,前端审计页面增加风险规则模型和流
程图组件,知识入库面板拆分为图谱可视化子组件,报销创
建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
caoxiaozhu
2026-05-23 19:54:42 +08:00
parent 5b388d08c0
commit 575f093c74
63 changed files with 35497 additions and 1517 deletions

View File

@@ -0,0 +1,487 @@
<template>
<section class="risk-rule-flow-figure" aria-label="风险规则流程说明" :style="accentStyle">
<div class="risk-rule-flow-content">
<aside class="risk-rule-flow-explainer">
<div class="risk-rule-section-title risk-rule-flow-copy-head">
<strong>流程解释</strong>
<span>{{ flowModel.severityLabel }}</span>
</div>
<ol class="risk-rule-flow-steps" aria-label="文字流程说明">
<li v-for="(item, index) in flowSteps" :key="item.title">
<span class="risk-rule-step-index">{{ index + 1 }}</span>
<div>
<strong>{{ item.title }}</strong>
<p>{{ item.text }}</p>
<div v-if="item.fields?.length" class="risk-rule-field-list">
<span v-for="field in item.fields" :key="field">{{ field }}</span>
</div>
</div>
</li>
</ol>
<div class="risk-rule-flow-branches" aria-label="是或否分支">
<div v-for="item in flowBranches" :key="item.answer" class="risk-rule-flow-branch">
<span class="risk-rule-branch-answer" :class="`answer-${item.tone}`">
{{ item.answer }}
</span>
<div>
<strong>{{ item.title }}</strong>
<p>{{ item.text }}</p>
</div>
</div>
</div>
</aside>
<div class="risk-rule-flow-visual">
<div class="risk-rule-section-title risk-rule-flow-visual-title">
<strong>流程图</strong>
</div>
<img
v-if="src"
class="risk-rule-flow-image"
:src="src"
alt="风险规则流程说明"
draggable="false"
/>
<div
v-else
class="risk-rule-flow-svg"
role="img"
aria-label="风险规则流程说明"
v-html="displaySvg"
></div>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
svg: { type: String, default: '' },
src: { type: String, default: '' },
flow: { type: Object, default: () => ({}) },
fields: { type: Array, default: () => [] },
severity: { type: String, default: 'medium' },
severityLabel: { type: String, default: '中风险' }
})
const FONT =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica Neue, 'PingFang SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif"
const TEXT = '#0d0d0d'
const MUTED = '#6e6e80'
const NEUTRAL_LINE = '#cbd5e1'
const NEUTRAL_BORDER = '#e2e8f0'
const PALETTES = {
low: {
accent: '#2563eb',
accentDark: '#1d4ed8',
border: '#bfdbfe',
surface: '#eff6ff'
},
medium: {
accent: '#f97316',
accentDark: '#c2410c',
border: '#fed7aa',
surface: '#fff7ed'
},
high: {
accent: '#dc2626',
accentDark: '#b91c1c',
border: '#fecaca',
surface: '#fef2f2'
}
}
function normalizeText(value, fallback = '') {
return String(value || fallback || '').trim()
}
function escapeSvg(value) {
return normalizeText(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function isSafeSvg(value) {
const text = normalizeText(value)
if (!text.startsWith('<svg') || !text.endsWith('</svg>')) {
return false
}
return !/(<script|<foreignObject|<iframe|<object|<embed|\son\w+=|javascript:)/i.test(text)
}
function isCurrentDisplaySvg(value) {
return isSafeSvg(value) && value.includes('data-risk-flow-style="review-node-only"')
}
function resolvePalette(severity) {
return PALETTES[normalizeText(severity).toLowerCase()] || PALETTES.medium
}
function formatFieldDisplay(item) {
const key = normalizeText(item?.key)
const label = normalizeText(item?.label || key)
if (label && key && label !== key) {
return `${label}[${key}]`
}
return label || key
}
function wrapText(value, width, maxLines) {
const text = normalizeText(value)
if (!text) {
return ['']
}
const lines = []
for (let index = 0; index < text.length; index += width) {
lines.push(text.slice(index, index + width))
}
if (lines.length > maxLines) {
return [...lines.slice(0, maxLines - 1), `${lines[maxLines - 1].slice(0, width - 1)}`]
}
return lines
}
function textLines(lines, x, y, anchor = 'middle', color = MUTED, fontSize = 13) {
return lines
.map(
(line, index) =>
`<text x="${x}" y="${y + index * (fontSize + 5)}" text-anchor="${anchor}" fill="${color}" font-family="${FONT}" font-size="${fontSize}" font-weight="400">${escapeSvg(line)}</text>`
)
.join('')
}
function node(title, body, x, y, width, height, currentPalette = null) {
const border = currentPalette?.border || NEUTRAL_BORDER
const stripe = currentPalette?.accent || NEUTRAL_LINE
const surface = currentPalette?.surface || '#ffffff'
return `<g>
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="7" ry="7" fill="${surface}" stroke="${border}" stroke-width="1.2"/>
<rect x="${x}" y="${y}" width="3.5" height="${height}" rx="1.75" ry="1.75" fill="${stripe}"/>
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="${TEXT}" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', MUTED, 11)}
</g>`
}
function diamond(title, body, x, y, width, height) {
const cx = x + width / 2
const cy = y + height / 2
const points = `${cx},${y} ${x + width},${cy} ${cx},${y + height} ${x},${cy}`
return `<g>
<polygon points="${points}" fill="#ffffff" stroke="${NEUTRAL_BORDER}" stroke-width="1.25"/>
<text x="${cx}" y="${cy - 10}" text-anchor="middle" fill="${TEXT}" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, 8, 2), cx, cy + 11, 'middle', MUTED, 10.2)}
</g>`
}
function note(body) {
return `<g>
<rect x="214" y="218" width="290" height="36" rx="7" ry="7" fill="#ffffff" stroke="${NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="226" y="240" fill="${MUTED}" font-family="${FONT}" font-size="10" font-weight="500">BASIS</text>
${textLines(wrapText(body, 22, 1), 268, 240, 'start', TEXT, 10.2)}
</g>`
}
const palette = computed(() => resolvePalette(props.severity))
const accentStyle = computed(() => ({
'--risk-flow-accent': palette.value.accent,
'--risk-flow-accent-dark': palette.value.accentDark,
'--risk-flow-border': palette.value.border,
'--risk-flow-surface': palette.value.surface
}))
const fieldDisplays = computed(() =>
(Array.isArray(props.fields) ? props.fields : []).map(formatFieldDisplay).filter(Boolean)
)
const fieldSummary = computed(() => {
const fields = fieldDisplays.value
if (!fields.length) {
return '规则字段'
}
if (fields.length <= 4) {
return fields.join('、')
}
return `${fields.slice(0, 4).join('、')}${fields.length} 项字段`
})
const flowModel = computed(() => {
const severityLabel = normalizeText(props.severityLabel, '中风险')
return {
severityLabel,
start: normalizeText(props.flow?.start, '业务单据提交'),
evidence: normalizeText(props.flow?.evidence, '读取规则字段'),
decision: normalizeText(props.flow?.decision, '判断是否命中风险'),
basis: normalizeText(props.flow?.basis || props.flow?.decision, '根据规则字段判断是否命中风险'),
pass: normalizeText(props.flow?.pass, '未命中风险,继续流转'),
fail: normalizeText(props.flow?.fail, `命中${severityLabel},进入人工复核`)
}
})
const flowSteps = computed(() => [
{
title: '业务输入',
text: flowModel.value.start
},
{
title: '字段取数',
text: `读取规则所需字段,并将字段证据送入判断节点。字段:${fieldSummary.value}`,
fields: fieldDisplays.value
},
{
title: '判断依据',
text: flowModel.value.basis || flowModel.value.decision
}
])
const flowBranches = computed(() => [
{
answer: '否',
tone: 'pass',
title: '不命中风险',
text: flowModel.value.pass
},
{
answer: '是',
tone: 'risk',
title: `命中${flowModel.value.severityLabel}`,
text: flowModel.value.fail
}
])
const displaySvg = computed(() => {
const providedSvg = normalizeText(props.svg)
if (isCurrentDisplaySvg(providedSvg)) {
return providedSvg
}
const flow = flowModel.value
const currentPalette = palette.value
return `<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-label="风险规则流程说明">
<defs>
<marker id="arrow-neutral" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="${NEUTRAL_LINE}"/>
</marker>
</defs>
<rect width="760" height="280" fill="#ffffff"/>
<rect x="18" y="18" width="724" height="244" rx="8" ry="8" fill="none" stroke="${NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="34" y="43" fill="${MUTED}" font-family="${FONT}" font-size="11" font-weight="500">RULE FLOW</text>
${node('业务输入', flow.start, 48, 118, 124, 60)}
${node('字段取数', '读取字段证据', 214, 118, 132, 60)}
${diamond('判断依据', flow.decision, 392, 92, 112, 112)}
${node('继续流转', flow.pass, 562, 74, 126, 60)}
${node('进入复核', flow.fail, 562, 190, 126, 62, currentPalette)}
${note(flow.basis)}
<line x1="172" y1="148" x2="214" y2="148" stroke="${NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<line x1="346" y1="148" x2="392" y2="148" stroke="${NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="${NEUTRAL_LINE}" stroke-width="1.35" marker-end="url(#arrow-neutral)"/>
<text x="534" y="119" text-anchor="middle" fill="${MUTED}" font-family="${FONT}" font-size="10.5" font-weight="400">否</text>
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="${NEUTRAL_LINE}" stroke-width="1.8" marker-end="url(#arrow-neutral)"/>
<text x="534" y="195" text-anchor="middle" fill="${MUTED}" font-family="${FONT}" font-size="10.5" font-weight="600">是</text>
</svg>`
})
</script>
<style scoped>
.risk-rule-flow-figure {
width: 100%;
min-height: 0;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
padding: 14px;
cursor: default;
}
.risk-rule-flow-content {
width: 100%;
display: grid;
grid-template-columns: minmax(260px, 0.78fr) minmax(0, 1.22fr);
gap: 16px;
align-items: start;
}
.risk-rule-flow-explainer {
min-width: 0;
align-self: stretch;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 16px;
border-right: 1px solid #e2e8f0;
}
.risk-rule-section-title {
min-height: 24px;
display: flex;
align-items: center;
width: 100%;
line-height: 1.4;
}
.risk-rule-flow-copy-head {
justify-content: space-between;
gap: 10px;
}
.risk-rule-section-title strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
line-height: 1.4;
}
.risk-rule-flow-copy-head span {
color: var(--risk-flow-accent-dark);
font-size: 12px;
font-weight: 800;
}
.risk-rule-flow-steps {
display: grid;
gap: 11px;
margin: 0;
padding: 0;
list-style: none;
}
.risk-rule-flow-steps li,
.risk-rule-flow-branch {
min-width: 0;
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.risk-rule-step-index,
.risk-rule-branch-answer {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.risk-rule-step-index,
.answer-pass {
background: #f8fafc;
color: #64748b;
border: 1px solid #e2e8f0;
}
.answer-risk {
background: var(--risk-flow-surface);
color: var(--risk-flow-accent-dark);
border: 1px solid var(--risk-flow-border);
}
.risk-rule-flow-steps strong,
.risk-rule-flow-branch strong {
display: block;
color: #111827;
font-size: 12.5px;
font-weight: 800;
line-height: 1.35;
}
.risk-rule-flow-explainer p {
margin: 4px 0 0;
color: #475569;
font-size: 12px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.risk-rule-field-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.risk-rule-field-list span {
max-width: 100%;
padding: 3px 7px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #f8fafc;
color: #334155;
font-size: 11px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.risk-rule-flow-branches {
display: grid;
gap: 10px;
padding-top: 12px;
border-top: 1px solid #e2e8f0;
}
.risk-rule-flow-visual {
min-width: 0;
display: grid;
gap: 8px;
place-items: center;
overflow: hidden;
}
.risk-rule-flow-visual-title {
justify-self: start;
justify-content: flex-start;
}
.risk-rule-flow-image,
.risk-rule-flow-svg {
width: min(760px, 100%);
display: block;
pointer-events: none;
user-select: none;
}
.risk-rule-flow-image {
height: auto;
object-fit: contain;
}
.risk-rule-flow-svg :deep(svg) {
width: 100%;
height: auto;
display: block;
}
.risk-rule-flow-svg :deep(*) {
pointer-events: none !important;
user-select: none;
}
@media (max-width: 980px) {
.risk-rule-flow-content {
grid-template-columns: 1fr;
}
.risk-rule-flow-explainer {
padding-right: 0;
padding-bottom: 12px;
border-right: 0;
border-bottom: 1px solid #e2e8f0;
}
}
@media (max-width: 760px) {
.risk-rule-flow-figure {
padding: 10px;
}
}
</style>