feat: 新增风险规则生成引擎与知识图谱可视化
后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
487
web/src/components/shared/RiskRuleFlowDiagram.vue
Normal file
487
web/src/components/shared/RiskRuleFlowDiagram.vue
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user