feat: 新增风险规则生成引擎与知识图谱可视化
后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
643
web/src/components/logs/KnowledgeIngestGraphView.vue
Normal file
643
web/src/components/logs/KnowledgeIngestGraphView.vue
Normal file
@@ -0,0 +1,643 @@
|
||||
<template>
|
||||
<section class="knowledge-graph-space">
|
||||
<header class="graph-head">
|
||||
<div>
|
||||
<span class="graph-eyebrow">LightRAG 知识图谱</span>
|
||||
<h4>实体关系空间</h4>
|
||||
</div>
|
||||
<div class="graph-toolbar">
|
||||
<label class="graph-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input
|
||||
v-model.trim="graphQuery"
|
||||
type="search"
|
||||
placeholder="搜索实体"
|
||||
@keydown.enter.prevent="focusMatchedNode"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" title="定位匹配实体" @click="focusMatchedNode">
|
||||
<i class="mdi mdi-crosshairs-gps"></i>
|
||||
</button>
|
||||
<button type="button" title="缩小" @click="zoomGraph(0.86)">
|
||||
<i class="mdi mdi-magnify-minus-outline"></i>
|
||||
</button>
|
||||
<button type="button" title="放大" @click="zoomGraph(1.16)">
|
||||
<i class="mdi mdi-magnify-plus-outline"></i>
|
||||
</button>
|
||||
<button type="button" title="适配画布" @click="fitGraph">
|
||||
<i class="mdi mdi-fit-to-page-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="graph-stats">
|
||||
<span>{{ graphSummary.entityCount }} 实体</span>
|
||||
<span>{{ graphSummary.relationCount }} 关系</span>
|
||||
<span>{{ graphSummary.visibleNodeCount }} 节点可见</span>
|
||||
<span>{{ graphSummary.visibleEdgeCount }} 连线可见</span>
|
||||
</div>
|
||||
|
||||
<div class="graph-body" :class="{ empty: !graphData.nodes.length }">
|
||||
<div class="graph-theater">
|
||||
<div v-if="graphData.nodes.length" ref="graphContainer" class="g6-canvas"></div>
|
||||
<div v-else class="graph-empty">
|
||||
<strong>暂无图谱数据</strong>
|
||||
<span>本次归集还没有返回可展示的实体关系。</span>
|
||||
</div>
|
||||
|
||||
<div v-if="graphData.nodes.length" class="graph-hud">
|
||||
<span>G6 Force</span>
|
||||
<span>拖拽节点</span>
|
||||
<span>滚轮缩放</span>
|
||||
<span>点击查看详情</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="graph-inspector">
|
||||
<template v-if="selectedNode">
|
||||
<div class="inspector-title">
|
||||
<span>节点详情</span>
|
||||
<h5>{{ selectedNode.name }}</h5>
|
||||
</div>
|
||||
|
||||
<div class="node-facts">
|
||||
<div>
|
||||
<span>关系数</span>
|
||||
<strong>{{ selectedNode.degree }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>入边</span>
|
||||
<strong>{{ selectedNodeIncoming.length }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>出边</span>
|
||||
<strong>{{ selectedNodeOutgoing.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node-meta">
|
||||
<span>类型</span>
|
||||
<strong>{{ selectedNode.type || '实体' }}</strong>
|
||||
</div>
|
||||
|
||||
<KnowledgeIngestNodeDetails
|
||||
:node="selectedNode"
|
||||
:incoming="selectedNodeIncoming"
|
||||
:outgoing="selectedNodeOutgoing"
|
||||
:relations="selectedNodeRelations"
|
||||
@focus-peer="focusRelationPeer"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else class="inspector-empty">
|
||||
<strong>选择一个实体</strong>
|
||||
<span>点击图谱中的节点,查看它的入边、出边和关系明细。</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Graph, NodeEvent } from '@antv/g6'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import KnowledgeIngestNodeDetails from './KnowledgeIngestNodeDetails.vue'
|
||||
import { useKnowledgeIngestGraph } from './useKnowledgeIngestGraph.js'
|
||||
|
||||
const props = defineProps({
|
||||
graph: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
documents: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const graphContainer = ref(null)
|
||||
let graphInstance = null
|
||||
let resizeObserver = null
|
||||
|
||||
const {
|
||||
activeNodeId,
|
||||
graphData,
|
||||
graphQuery,
|
||||
graphSummary,
|
||||
selectNodeById,
|
||||
focusFirstMatch,
|
||||
selectRelationPeer,
|
||||
selectedNode,
|
||||
selectedNodeIncoming,
|
||||
selectedNodeOutgoing,
|
||||
selectedNodeRelations
|
||||
} = useKnowledgeIngestGraph(props)
|
||||
|
||||
onMounted(() => {
|
||||
initGraph()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
graphInstance?.destroy()
|
||||
resizeObserver = null
|
||||
graphInstance = null
|
||||
})
|
||||
|
||||
watch(
|
||||
graphData,
|
||||
() => {
|
||||
renderGraph()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(activeNodeId, (nodeId) => {
|
||||
syncGraphSelection(nodeId)
|
||||
})
|
||||
|
||||
async function initGraph() {
|
||||
if (!graphData.value.nodes.length) return
|
||||
await nextTick()
|
||||
if (!graphContainer.value || graphInstance) return
|
||||
|
||||
graphInstance = new Graph(buildGraphOptions())
|
||||
graphInstance.on(NodeEvent.CLICK, async (event) => {
|
||||
const nodeId = String(event?.target?.id || '').trim()
|
||||
if (!nodeId) return
|
||||
selectNodeById(nodeId)
|
||||
await focusGraphNode(nodeId, { focus: false })
|
||||
})
|
||||
await graphInstance.render()
|
||||
await syncGraphSelection(activeNodeId.value)
|
||||
observeResize()
|
||||
await fitGraph()
|
||||
}
|
||||
|
||||
function buildGraphOptions() {
|
||||
return {
|
||||
container: graphContainer.value,
|
||||
autoResize: true,
|
||||
background: 'transparent',
|
||||
zoomRange: [0.22, 2.8],
|
||||
autoFit: {
|
||||
type: 'view',
|
||||
options: { padding: [86, 122, 86, 122] },
|
||||
animation: { duration: 520, easing: 'ease-in-out' }
|
||||
},
|
||||
data: graphData.value,
|
||||
layout: {
|
||||
type: 'd3-force',
|
||||
preventOverlap: true,
|
||||
centerStrength: 0.34,
|
||||
linkDistance: (edge) => (edge?.data?.weight > 2 ? 155 : 210),
|
||||
edgeStrength: 0.22,
|
||||
nodeStrength: (node) => (node?.data?.degree >= 5 ? -520 : -360),
|
||||
collide: {
|
||||
radius: (node) => 40 + Math.sqrt(Math.max(node?.data?.degree || 1, 1)) * 12,
|
||||
strength: 0.88,
|
||||
iterations: 3
|
||||
},
|
||||
manyBody: {
|
||||
strength: (node) => (node?.data?.degree >= 5 ? -620 : -420),
|
||||
distanceMax: 620
|
||||
},
|
||||
center: {
|
||||
strength: 0.24
|
||||
},
|
||||
alpha: 0.88,
|
||||
alphaDecay: 0.035,
|
||||
velocityDecay: 0.32,
|
||||
iterations: 360
|
||||
},
|
||||
node: {
|
||||
type: 'circle',
|
||||
style: (datum) => datum.style,
|
||||
state: {
|
||||
selected: {
|
||||
lineWidth: 4,
|
||||
stroke: '#1d4ed8',
|
||||
halo: true,
|
||||
haloStroke: '#60a5fa',
|
||||
haloLineWidth: 24,
|
||||
haloStrokeOpacity: 0.24,
|
||||
shadowBlur: 22
|
||||
},
|
||||
active: {
|
||||
lineWidth: 3,
|
||||
halo: true,
|
||||
haloLineWidth: 16,
|
||||
haloStrokeOpacity: 0.28
|
||||
},
|
||||
inactive: {
|
||||
opacity: 0.2
|
||||
},
|
||||
dimmed: {
|
||||
opacity: 0.18
|
||||
}
|
||||
}
|
||||
},
|
||||
edge: {
|
||||
type: 'line',
|
||||
style: (datum) => datum.style,
|
||||
state: {
|
||||
selected: {
|
||||
stroke: '#2563eb',
|
||||
lineWidth: 2.8,
|
||||
opacity: 0.96,
|
||||
halo: true,
|
||||
haloStroke: '#93c5fd',
|
||||
haloLineWidth: 8,
|
||||
haloStrokeOpacity: 0.2
|
||||
},
|
||||
active: {
|
||||
stroke: '#60a5fa',
|
||||
lineWidth: 2.4,
|
||||
opacity: 0.9
|
||||
},
|
||||
inactive: {
|
||||
opacity: 0.14
|
||||
}
|
||||
}
|
||||
},
|
||||
behaviors: [
|
||||
{ type: 'drag-canvas', key: 'drag-canvas' },
|
||||
{ type: 'zoom-canvas', key: 'zoom-canvas', sensitivity: 1.15 },
|
||||
{ type: 'drag-element-force', key: 'drag-element-force', fixed: true }
|
||||
],
|
||||
animation: { duration: 420, easing: 'ease-out' }
|
||||
}
|
||||
}
|
||||
|
||||
async function renderGraph() {
|
||||
if (!graphData.value.nodes.length) return
|
||||
if (!graphInstance) {
|
||||
await initGraph()
|
||||
return
|
||||
}
|
||||
graphInstance.setData(graphData.value)
|
||||
await graphInstance.render()
|
||||
await syncGraphSelection(activeNodeId.value)
|
||||
await fitGraph()
|
||||
}
|
||||
|
||||
async function syncGraphSelection(nodeId) {
|
||||
if (!graphInstance || graphInstance.destroyed || !nodeId) return
|
||||
const stateMap = {}
|
||||
for (const node of graphData.value.nodes) {
|
||||
if (node.id === nodeId) {
|
||||
stateMap[node.id] = ['selected']
|
||||
} else if (node.states?.includes('dimmed')) {
|
||||
stateMap[node.id] = ['dimmed']
|
||||
} else {
|
||||
stateMap[node.id] = []
|
||||
}
|
||||
}
|
||||
for (const edge of graphData.value.edges) {
|
||||
stateMap[edge.id] = edge.source === nodeId || edge.target === nodeId ? ['selected'] : ['inactive']
|
||||
}
|
||||
await graphInstance.setElementState(stateMap, false)
|
||||
}
|
||||
|
||||
async function fitGraph() {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
await graphInstance.fitView(
|
||||
{ padding: [84, 118, 84, 118] },
|
||||
{ duration: 420, easing: 'ease-in-out' }
|
||||
)
|
||||
}
|
||||
|
||||
async function zoomGraph(ratio) {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
await graphInstance.zoomBy(ratio, { duration: 220, easing: 'ease-out' })
|
||||
}
|
||||
|
||||
async function focusMatchedNode() {
|
||||
const nodeId = focusFirstMatch()
|
||||
if (nodeId) await focusGraphNode(nodeId)
|
||||
}
|
||||
|
||||
async function focusRelationPeer(relation) {
|
||||
const nodeId = selectRelationPeer(relation)
|
||||
if (nodeId) await focusGraphNode(nodeId)
|
||||
}
|
||||
|
||||
async function focusGraphNode(nodeId, options = {}) {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
await syncGraphSelection(nodeId)
|
||||
if (options.focus === false) return
|
||||
await graphInstance.focusElement(nodeId, { duration: 360, easing: 'ease-in-out' })
|
||||
}
|
||||
|
||||
function observeResize() {
|
||||
if (!graphContainer.value || typeof ResizeObserver === 'undefined') return
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!graphInstance || graphInstance.destroyed) return
|
||||
graphInstance.setSize(graphContainer.value.clientWidth, graphContainer.value.clientHeight)
|
||||
})
|
||||
resizeObserver.observe(graphContainer.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge-graph-space {
|
||||
min-height: 1080px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(1020px, 1fr);
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.graph-eyebrow {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.graph-head h4 {
|
||||
margin: 4px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.graph-toolbar,
|
||||
.graph-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.graph-search {
|
||||
min-width: 230px;
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.graph-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.graph-toolbar button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.graph-toolbar button:hover {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.graph-stats span {
|
||||
min-height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.graph-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-theater {
|
||||
position: relative;
|
||||
min-height: 1020px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 54%, #eef4fb 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.82);
|
||||
}
|
||||
|
||||
.graph-theater::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(148, 163, 184, 0.18) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.18) 1px, transparent 1px);
|
||||
background-size: 34px 34px;
|
||||
mask-image: radial-gradient(circle at center, black 0%, black 58%, transparent 100%);
|
||||
}
|
||||
|
||||
.g6-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.g6-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-hud {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 12px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-hud span {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(203, 213, 225, 0.86);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.graph-inspector {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid #dbe6ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.inspector-title span {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.inspector-title h5 {
|
||||
margin: 4px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.node-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.node-facts div,
|
||||
.node-meta {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 9px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.node-meta {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-facts span,
|
||||
.node-meta span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-facts strong,
|
||||
.node-meta strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.graph-empty,
|
||||
.inspector-empty {
|
||||
min-height: 120px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.graph-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.graph-empty strong,
|
||||
.inspector-empty strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inspector-empty strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.graph-empty span,
|
||||
.inspector-empty span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.graph-toolbar button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.knowledge-graph-space {
|
||||
min-height: 980px;
|
||||
grid-template-rows: auto auto minmax(900px, 1fr);
|
||||
}
|
||||
|
||||
.graph-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.graph-body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(780px, 1fr) minmax(220px, auto);
|
||||
}
|
||||
|
||||
.graph-theater {
|
||||
min-height: 780px;
|
||||
}
|
||||
|
||||
.graph-inspector {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.graph-search {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user