接口新增,知识图谱

This commit is contained in:
MHW
2026-04-17 14:52:10 +08:00
parent b56d57af44
commit 1e38170420
19 changed files with 1867 additions and 25 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@antv/g6": "^5.0.49",
"@antv/x6": "^3.1.2",
"@antv/x6-vue-shape": "^3.0.2",
"ant-design-vue": "^4.2.6",

View File

@@ -1917,3 +1917,149 @@
padding-left: 5px;
}
}
/* rule-config 页:侧栏与主内容区视觉统一(仅带 .rule-config-sidebar 时生效) */
.rule-config-sidebar.ks-layout-sidebar {
background-color: #020a14;
background-image:
linear-gradient(180deg, rgba(2, 10, 20, 0.94) 0%, rgba(2, 12, 24, 0.9) 50%, rgba(1, 8, 18, 0.92) 100%),
url('@/assets/icons/bg-fk.png');
background-size: 100% 100%, 100% 100%;
background-position: center, center;
border-inline-end: 1px solid rgba(55, 126, 173, 0.35);
.ks-sidebar-header {
background: url('@/assets/icons/bg-fk-title.png') center / 100% 100%;
}
.ant-tree {
background: transparent;
color: #eee;
.ant-tree-node-content-wrapper {
color: #eee;
&:hover {
background: rgba(9, 38, 75, 0.55);
}
}
.ant-tree-node-content-wrapper.ant-tree-node-selected {
background: rgba(17, 55, 126, 0.65);
}
.ant-tree-switcher {
color: #a2b1ba;
}
.ant-tree-title {
color: inherit;
}
}
.ant-pagination {
color: #a2b1ba;
.ant-pagination-item-link,
.ant-pagination-item a {
color: #a2b1ba;
}
}
}
.rule-config-main-split {
display: flex;
flex-direction: row;
align-items: stretch;
overflow: hidden;
padding-right: 0;
}
.rule-config-graph-placeholder {
flex-shrink: 0;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed rgba(55, 126, 173, 0.45);
margin: 8px 0 8px 8px;
border-radius: 2px;
color: #a2b1ba;
background: rgba(8, 29, 54, 0.25);
}
.rule-config-graph-placeholder--chart {
align-items: stretch;
justify-content: stretch;
padding: 6px;
}
.rule-config-main-split-resizer {
flex: 0 0 6px;
width: 6px;
margin: 8px 0;
cursor: col-resize;
flex-shrink: 0;
border-radius: 2px;
background: rgba(55, 126, 173, 0.25);
&:hover {
background: rgba(55, 126, 173, 0.5);
}
}
.rule-config-right-cluster {
display: flex;
flex: 1 1 auto;
min-width: 0;
align-items: stretch;
}
.rule-config-graph-placeholder__text {
font-size: 14px;
}
.rule-config-panel-toggle {
flex: 0 0 20px;
width: 20px;
margin: 8px 0;
padding: 0;
border: 0;
border-radius: 2px 0 0 2px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #eee;
background: rgba(9, 38, 75, 0.75);
&:hover {
background: rgba(17, 55, 126, 0.85);
}
}
.rule-config-right-panel {
flex: 0 0 min(480px, 42vw);
width: min(480px, 42vw);
min-width: 320px;
max-width: 55%;
transition: flex-basis 0.2s ease, width 0.2s ease, opacity 0.2s ease, min-width 0.2s ease;
overflow: hidden;
border-left: 1px solid rgba(71, 95, 113, 0.6);
}
.rule-config-right-panel--collapsed {
flex: 0 0 0;
width: 0;
min-width: 0;
max-width: 0;
opacity: 0;
border-left: 0;
pointer-events: none;
}
.rule-config-right-panel__inner {
height: 100%;
overflow-y: auto;
padding: 10px 10px 10px 4px;
}

View File

@@ -4,7 +4,7 @@
<Header />
<a-layout class="ks-layout-body">
<slot name="body">
<a-layout-sider class="ks-layout-sidebar" width="300">
<a-layout-sider class="ks-layout-sidebar" :class="sidebarClass" width="300">
<slot name="sidebar" />
</a-layout-sider>
<a-layout-content class="ks-layout-main">
@@ -21,4 +21,9 @@
<script lang="ts" setup>
import { Wrapper } from '@/components/wrapper';
import Header from './header.vue';
defineProps<{
/** 追加在 ks-layout-sidebar 上的类名,用于单页侧栏皮肤 */
sidebarClass?: string;
}>();
</script>

View File

@@ -0,0 +1,341 @@
<!--
- This file is part of the kernelstudio package.
-
- (c) 2014-2026 zlin <admin@kernelstudio.com>
-
- For the full copyright and license information, please view the LICENSE file
- that was distributed with this source code.
-->
<template>
<div class="rule-knowledge-graph">
<div class="rule-knowledge-graph__toolbar">
<a-radio-group v-model:value="density" size="small" button-style="solid">
<a-radio-button value="overview">简要结构</a-radio-button>
<a-radio-button value="full">完整</a-radio-button>
</a-radio-group>
<span class="rule-knowledge-graph__hint">
简要仅层级种类模块规则完整含参数任务类型与执行顺序边
</span>
</div>
<div v-if="errorMsg" class="rule-knowledge-graph__banner rule-knowledge-graph__banner--error">
{{ errorMsg }}
</div>
<div v-else-if="emptyHint" class="rule-knowledge-graph__banner">
{{ emptyHint }}
</div>
<div v-show="!errorMsg && !emptyHint" ref="hostRef" class="rule-knowledge-graph__host" />
</div>
</template>
<script setup lang="ts">
import { Graph } from '@antv/g6';
import { message } from 'ant-design-vue';
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { findRuleConfigGraph } from './api';
import type { RuleConfigRequest, RuleGraphEdge, RuleGraphNode, RuleGraphPayload } from './types';
const props = defineProps<{
query: RuleConfigRequest,
refreshKey: number,
}>();
const hostRef = ref<HTMLDivElement | null>(null);
const errorMsg = ref<string | null>(null);
const emptyHint = ref<string | null>(null);
const density = ref<'overview' | 'full'>('overview');
const lastPayload = ref<RuleGraphPayload | null>(null);
let graph: Graph | null = null;
let resizeObserver: ResizeObserver | null = null;
const NODE_COLORS: Record<string, string> = {
level: '#5B8FF9',
kind: '#61DDAA',
module: '#65789B',
rule: '#F6903D',
param: '#9270CA',
taskType: '#269A99',
};
const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/** 简要:只保留结构主干,边改为 level → kind → module → rule 便于分层布局 */
const buildOverviewPayload = (payload: RuleGraphPayload): RuleGraphPayload => {
const keepTypes = new Set(['level', 'kind', 'module', 'rule']);
const nodes = payload.nodes.filter((n) => keepTypes.has(n.nodeType));
const nodeIds = new Set(nodes.map((n) => n.id));
const edgeSeen = new Set<string>();
const edges: RuleGraphEdge[] = [];
const addEdge = (source: string, target: string, suffix: string) => {
if (!nodeIds.has(source) || !nodeIds.has(target)) {
return;
}
const key = `${source}|${target}`;
if (edgeSeen.has(key)) {
return;
}
edgeSeen.add(key);
edges.push({
id: `ov:${suffix}:${key}`,
source,
target,
edgeType: 'overview_hierarchy',
label: null,
});
};
for (const r of nodes) {
if (r.nodeType !== 'rule' || !r.payload) {
continue;
}
const levelCode = str(r.payload.levelCode);
const kindCode = str(r.payload.kindCode);
const moduleCode = str(r.payload.moduleCode);
if (!levelCode || !kindCode || !moduleCode) {
continue;
}
const lid = `level:${levelCode}`;
const kid = `kind:${levelCode}:${kindCode}`;
const mid = `module:${moduleCode}`;
addEdge(lid, kid, 'lk');
addEdge(kid, mid, 'km');
addEdge(mid, r.id, 'mr');
}
return { nodes, edges };
};
const toGraphData = (payload: RuleGraphPayload) => ({
nodes: payload.nodes.map((n) => ({
id: n.id,
data: {
label: n.label,
nodeType: n.nodeType,
payload: n.payload ?? {},
},
})),
edges: payload.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
data: {
edgeType: e.edgeType ?? '',
label: e.label ?? '',
},
})),
});
const disposeGraph = async () => {
resizeObserver?.disconnect();
resizeObserver = null;
if (graph) {
try {
graph.destroy();
} catch {
/* ignore */
}
graph = null;
}
};
const buildGraph = async (payload: RuleGraphPayload, mode: 'overview' | 'full') => {
await disposeGraph();
const el = hostRef.value;
if (!el) {
return;
}
const raw = mode === 'overview' ? buildOverviewPayload(payload) : payload;
const data = toGraphData(raw);
if (raw.nodes.length === 0) {
return;
}
const width = Math.max(el.clientWidth, 280);
const height = Math.max(el.clientHeight, 240);
const layout =
mode === 'overview'
? {
type: 'antv-dagre' as const,
rankdir: 'TB',
ranksep: 56,
nodesep: 36,
}
: {
type: 'd3-force' as const,
};
graph = new Graph({
container: el,
width,
height,
data,
layout,
node: {
style: {
size: (d: { data?: { nodeType?: string } }) => {
const t = d.data?.nodeType;
if (mode === 'overview') {
if (t === 'rule') return 20;
if (t === 'module') return 18;
return 16;
}
return t === 'param' ? 5 : 11;
},
fill: (d: { data?: { nodeType?: string } }) => NODE_COLORS[d.data?.nodeType ?? ''] ?? '#8B8B8B',
labelText: (d: { data?: { label?: string }; id: string }) => d.data?.label ?? String(d.id),
labelFill: '#e8f4f8',
labelFontSize: (d: { data?: { nodeType?: string } }) =>
(mode === 'overview' ? (d.data?.nodeType === 'rule' ? 11 : 12) : 9),
labelMaxWidth: mode === 'overview' ? 200 : 100,
labelWordWrap: true,
lineWidth: 1.5,
stroke: '#0d1f2c',
},
},
edge: {
style: {
stroke: (d: { data?: { edgeType?: string } }) => {
const t = d.data?.edgeType;
if (t === 'overview_hierarchy') {
return 'rgba(120, 170, 200, 0.55)';
}
if (t === 'rule_exec_before') {
return '#faad14';
}
return 'rgba(150, 175, 190, 0.35)';
},
lineWidth: (d: { data?: { edgeType?: string } }) =>
(d.data?.edgeType === 'rule_exec_before' ? 2 : 1),
endArrow: mode !== 'overview',
},
},
behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'],
});
await graph.render();
await graph.fitView();
resizeObserver = new ResizeObserver(() => {
if (!graph || !hostRef.value) {
return;
}
const w = Math.max(hostRef.value.clientWidth, 280);
const h = Math.max(hostRef.value.clientHeight, 240);
graph.setSize(w, h);
void graph.fitView();
});
resizeObserver.observe(el);
};
const renderFromCache = async () => {
if (!lastPayload.value) {
return;
}
if (!lastPayload.value.nodes?.length) {
return;
}
await nextTick();
await buildGraph(lastPayload.value, density.value);
};
const load = async () => {
errorMsg.value = null;
emptyHint.value = null;
await disposeGraph();
try {
const r = await findRuleConfigGraph({
pageNum: props.query.pageNum,
pageSize: props.query.pageSize,
ruleCode: props.query.ruleCode ?? undefined,
ruleName: props.query.ruleName ?? undefined,
levelCode: props.query.levelCode ?? undefined,
kindCode: props.query.kindCode ?? undefined,
moduleCode: props.query.moduleCode ?? undefined,
enabled: props.query.enabled ?? undefined,
});
if (r.code !== 200 || !r.data) {
errorMsg.value = r.msg ?? '加载知识图谱失败';
lastPayload.value = null;
return;
}
const payload = r.data;
if (!payload.nodes?.length) {
emptyHint.value = '当前页无规则数据,图谱为空';
lastPayload.value = null;
return;
}
lastPayload.value = payload;
await renderFromCache();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
errorMsg.value = `图谱请求失败:${msg}`;
lastPayload.value = null;
message.error(errorMsg.value);
}
};
watch(
() => [props.query.pageNum, props.query.pageSize, props.refreshKey],
() => {
void load();
},
{ immediate: true },
);
watch(density, () => {
void renderFromCache();
});
onBeforeUnmount(() => {
void disposeGraph();
});
</script>
<style scoped lang="less">
.rule-knowledge-graph {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.rule-knowledge-graph__toolbar {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 0 2px 4px;
}
.rule-knowledge-graph__hint {
font-size: 11px;
color: #7a8d96;
line-height: 1.35;
max-width: 100%;
}
.rule-knowledge-graph__host {
flex: 1;
min-height: 200px;
position: relative;
}
.rule-knowledge-graph__banner {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
font-size: 13px;
color: #a2b1ba;
text-align: center;
}
.rule-knowledge-graph__banner--error {
color: #ff9c9c;
}
</style>

View File

@@ -9,7 +9,7 @@
import { HttpRequestClient } from '@/utils/request';
import type { ApiDataResponse, BasicResponse } from '@/types';
import type { RuleConfig, RuleConfigPageableResponse, RuleConfigRequest, RuleDictItem, RuleParamMeta } from './types';
import type { RuleConfig, RuleConfigPageableResponse, RuleConfigRequest, RuleDictItem, RuleGraphPayload, RuleParamMeta } from './types';
const req = HttpRequestClient.create<BasicResponse>({
baseURL: '/api',
@@ -42,3 +42,7 @@ export const findRuleDictByType = (dictType: string): Promise<ApiDataResponse<Ru
export const findRuleParamMeta = (): Promise<ApiDataResponse<RuleParamMeta[]>> => {
return req.get('/system/rule/config/param-meta');
};
export const findRuleConfigGraph = (query: Partial<RuleConfigRequest> = {}): Promise<ApiDataResponse<RuleGraphPayload>> => {
return req.get('/system/rule/config/graph', query);
};

View File

@@ -1,5 +1,5 @@
<template>
<Layout>
<Layout sidebar-class="rule-config-sidebar">
<template #sidebar>
<div class="ks-sidebar-header">
<a-flex class="ks-sidebar-title">
@@ -39,9 +39,41 @@
</a-space>
</template>
<div class="ks-scrollable" style="height: 80.5vh;overflow-y: auto;padding-right: 10px">
<a-row :gutter="15">
<a-col :span="16">
<div
ref="splitRootRef"
class="ks-scrollable rule-config-main-split"
style="height: 80.5vh;"
>
<div
class="rule-config-graph-placeholder rule-config-graph-placeholder--chart"
:style="{
flex: `0 0 ${graphPanePercent}%`,
minWidth: '140px',
maxWidth: '78%',
}"
>
<RuleKnowledgeGraph :query="query" :refresh-key="graphRevision" />
</div>
<div
class="rule-config-main-split-resizer"
title="拖动调节图谱与参数区占比"
@mousedown="onGraphSplitMouseDown"
/>
<div class="rule-config-right-cluster">
<button
type="button"
class="rule-config-panel-toggle"
:title="rightPanelExpanded ? '收起参数面板' : '展开参数面板'"
@click="rightPanelExpanded = !rightPanelExpanded"
>
<LeftOutlined v-if="rightPanelExpanded" />
<RightOutlined v-else />
</button>
<div
class="rule-config-right-panel"
:class="{ 'rule-config-right-panel--collapsed': !rightPanelExpanded }"
>
<div class="rule-config-right-panel__inner">
<a-form
ref="formRef"
:label-col="{ span: 6 }"
@@ -138,8 +170,9 @@
</a-space>
</a-form-item>
</a-form>
</a-col>
</a-row>
</div>
</div>
</div>
</div>
</a-card>
</div>
@@ -149,8 +182,9 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue';
import { type FormInstance, message } from 'ant-design-vue';
import { MinusCircleOutlined, PlusCircleOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { LeftOutlined, MinusCircleOutlined, PlusCircleOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons-vue';
import Layout from '../layout.vue';
import RuleKnowledgeGraph from './RuleKnowledgeGraph.vue';
import { createRuleConfig, deleteRuleConfig, findRuleConfigByCode, findRuleConfigByQuery, findRuleParamMeta, updateRuleConfig } from './api';
import type { RuleConfig, RuleConfigParam, RuleConfigRequest, RuleParamMeta } from './types';
@@ -207,10 +241,45 @@ const paramMetaOptions = ref<{ label: string; value: string }[]>([]);
const levelOptions = [
{ code: 'task', name: '任务级' },
{ code: 'platform', name: '平台级' },
{ code: 'action', name: '行动级' },
{ code: 'platform', name: '平台级' },
];
const rightPanelExpanded = ref(true);
const GRAPH_PANE_STORAGE_KEY = 'rule-config-graph-pane-percent';
const GRAPH_PANE_MIN = 18;
const GRAPH_PANE_MAX = 70;
const graphPanePercent = ref(32);
const splitRootRef = ref<HTMLElement | null>(null);
const graphRevision = ref(0);
const clampGraphPercent = (n: number) =>
Math.min(GRAPH_PANE_MAX, Math.max(GRAPH_PANE_MIN, Math.round(n)));
const onGraphSplitMouseDown = (e: MouseEvent) => {
e.preventDefault();
const root = splitRootRef.value;
if (!root) return;
const rect = root.getBoundingClientRect();
const onMove = (ev: MouseEvent) => {
const x = ev.clientX - rect.left;
const p = (x / rect.width) * 100;
graphPanePercent.value = clampGraphPercent(p);
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
document.body.style.removeProperty('cursor');
document.body.style.removeProperty('user-select');
sessionStorage.setItem(GRAPH_PANE_STORAGE_KEY, String(graphPanePercent.value));
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
const kindOptions = [
{ code: 'select', name: '选择' },
{ code: 'assign', name: '分配' },
@@ -233,12 +302,14 @@ const buildTreeData = () => {
const params = detail?.params?.filter((item) => item.paramKey) ?? [];
if (params.length === 0) return;
group[levelCode] = group[levelCode] ?? {};
group[levelCode][kindCode] = group[levelCode][kindCode] ?? [];
const levelEntry = group[levelCode] ?? {};
group[levelCode] = levelEntry;
const kindList = levelEntry[kindCode] ?? [];
levelEntry[kindCode] = kindList;
params.forEach((param, index) => {
const paramTitle = param.paramName || param.paramKey || `参数${index + 1}`;
group[levelCode][kindCode].push({
kindList.push({
title: paramTitle,
key: `param:${rule.ruleCode}:${param.paramKey || index}`,
isLeaf: true,
@@ -302,6 +373,7 @@ const load = async () => {
}),
);
buildTreeData();
graphRevision.value += 1;
};
const loadParamMeta = async () => {
@@ -476,8 +548,9 @@ const handleChange = async (page: number, pageSize: number) => {
const handleAddParam = () => {
const row = defaultParam();
if (paramMetaOptions.value.length > 0) {
row.paramKey = paramMetaOptions.value[0].value;
const firstOpt = paramMetaOptions.value[0];
if (firstOpt) {
row.paramKey = firstOpt.value;
handleParamKeyChange(row);
}
selectedRuleConfig.value.params.push(row);
@@ -494,6 +567,13 @@ const handleMinusParam = (index: number) => {
};
onMounted(() => {
const raw = sessionStorage.getItem(GRAPH_PANE_STORAGE_KEY);
if (raw) {
const v = Number(raw);
if (!Number.isNaN(v)) {
graphPanePercent.value = clampGraphPercent(v);
}
}
loadParamMeta().then(() => load());
});
@@ -517,7 +597,10 @@ const handleParamKeyChange = (item: RuleConfigParam) => {
item.valType = 'string';
if (meta.valueType === 'enum' && Array.isArray(meta.enumOptions) && meta.enumOptions.length > 0) {
if (!meta.enumOptions.includes(String(item.paramVal ?? ''))) {
item.paramVal = meta.enumOptions[0];
const first = meta.enumOptions[0];
if (first !== undefined) {
item.paramVal = first;
}
}
}
};

View File

@@ -86,3 +86,23 @@ export interface RuleConfigRequest extends Partial<RuleConfig> {
}
export interface RuleConfigPageableResponse extends PageableResponse<RuleConfig> {}
export interface RuleGraphNode {
id: string,
label: string,
nodeType: string,
payload?: Record<string, unknown> | null,
}
export interface RuleGraphEdge {
id: string,
source: string,
target: string,
edgeType?: string | null,
label?: string | null,
}
export interface RuleGraphPayload {
nodes: RuleGraphNode[],
edges: RuleGraphEdge[],
}