Merge branch 'refs/heads/develop'

This commit is contained in:
MHW
2026-05-07 16:56:27 +08:00
12 changed files with 1443 additions and 149 deletions

View File

@@ -11,6 +11,7 @@ import com.solution.rule.domain.config.RuleConfig;
import com.solution.rule.domain.config.RuleConfigExcelRow;
import com.solution.rule.domain.config.RuleConfigQuery;
import com.solution.rule.domain.config.RuleParamMeta;
import com.solution.rule.domain.config.vo.RuleDecisionTreeVO;
import com.solution.rule.domain.config.vo.RuleFourBlocksGraphVO;
import com.solution.rule.domain.config.vo.RuleGraphVO;
import com.solution.rule.service.IRuleService;
@@ -93,6 +94,16 @@ import java.util.List;
return success(graph);
}
@PreAuthorize("@ss.hasPermi('system:rule:list')")
@GetMapping("/config/decision-tree")
@ApiOperation("规则决策树(围绕装备/目标/阵位/航迹四类规则,展示参数进入后的决策路径)")
public AjaxResult configDecisionTree(RuleConfigQuery query) {
startPage();
List<RuleConfig> list = ruleConfigService.selectRuleConfigList(query);
RuleDecisionTreeVO tree = ruleConfigService.buildDecisionTree(list);
return success(tree);
}
@PreAuthorize("@ss.hasPermi('system:rule:list')")
@GetMapping("/config/graph/four-blocks")
@ApiOperation("四块规则知识图谱(装备/目标/阵位/航迹;参数值与运行时 globalParams 一致)")

View File

@@ -0,0 +1,39 @@
package com.solution.rule.domain.config.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
@ApiModel("单个规则决策树分块")
public class RuleDecisionBlockVO implements Serializable {
@ApiModelProperty("分块标识equipment/target/position/track")
private String blockId;
@ApiModelProperty("模块编码")
private String moduleCode;
@ApiModelProperty("标题")
private String title;
@ApiModelProperty("Drools 规则名")
private String droolsRuleName;
@ApiModelProperty("salience")
private Integer salience;
@ApiModelProperty("树节点")
private List<RuleDecisionNodeVO> nodes;
public List<RuleDecisionNodeVO> safeNodes() {
if (nodes == null) {
nodes = new ArrayList<>();
}
return nodes;
}
}

View File

@@ -0,0 +1,39 @@
package com.solution.rule.domain.config.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
@ApiModel("规则决策树节点")
public class RuleDecisionNodeVO implements Serializable {
@ApiModelProperty("节点唯一键")
private String key;
@ApiModelProperty("节点标题")
private String title;
@ApiModelProperty("节点类型")
private String nodeType;
@ApiModelProperty("补充说明")
private String description;
@ApiModelProperty("右侧值文本")
private String valueText;
@ApiModelProperty("子节点")
private List<RuleDecisionNodeVO> children;
public List<RuleDecisionNodeVO> safeChildren() {
if (children == null) {
children = new ArrayList<>();
}
return children;
}
}

View File

@@ -0,0 +1,36 @@
package com.solution.rule.domain.config.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Data
@ApiModel("规则决策树")
public class RuleDecisionTreeVO implements Serializable {
@ApiModelProperty("与运行时 globalParams 一致的参数快照")
private Map<String, Object> globalParamsPreview;
@ApiModelProperty("四大规则分块")
private List<RuleDecisionBlockVO> blocks;
public Map<String, Object> safeGlobalParamsPreview() {
if (globalParamsPreview == null) {
globalParamsPreview = new LinkedHashMap<>();
}
return globalParamsPreview;
}
public List<RuleDecisionBlockVO> safeBlocks() {
if (blocks == null) {
blocks = new ArrayList<>();
}
return blocks;
}
}

View File

@@ -5,6 +5,7 @@ import com.solution.rule.domain.config.RuleConfigExcelRow;
import com.solution.rule.domain.config.RuleConfigQuery;
import com.solution.rule.domain.config.RuleDictItem;
import com.solution.rule.domain.config.RuleParamMeta;
import com.solution.rule.domain.config.vo.RuleDecisionTreeVO;
import com.solution.rule.domain.config.vo.RuleFourBlocksGraphVO;
import com.solution.rule.domain.config.vo.RuleGraphVO;
@@ -38,6 +39,8 @@ public interface IRuleConfigService {
*/
RuleGraphVO buildKnowledgeGraph(List<RuleConfig> ruleConfigs);
RuleDecisionTreeVO buildDecisionTree(List<RuleConfig> ruleConfigs);
/**
* 四块规则知识图谱(装备/目标/阵位/航迹):中枢 If/Then + 环形参数,参数值与 {@link #loadEnabledGlobalParams()} 一致。
*/

View File

@@ -14,6 +14,9 @@ import com.solution.rule.domain.config.graph.RuleFourBlockDefinition;
import com.solution.rule.domain.config.graph.RuleFourBlockRuleOutputCatalog;
import com.solution.rule.domain.config.graph.RuleParamOutputHint;
import com.solution.rule.domain.config.vo.RuleFourBlockClusterVO;
import com.solution.rule.domain.config.vo.RuleDecisionBlockVO;
import com.solution.rule.domain.config.vo.RuleDecisionNodeVO;
import com.solution.rule.domain.config.vo.RuleDecisionTreeVO;
import com.solution.rule.domain.config.vo.RuleFourBlockParamRowVO;
import com.solution.rule.domain.config.vo.RuleFourBlocksGraphVO;
import com.solution.rule.domain.config.vo.RuleGraphEdgeVO;
@@ -389,6 +392,58 @@ public class RuleConfigServiceImpl implements IRuleConfigService {
return graph;
}
@Override
public RuleDecisionTreeVO buildDecisionTree(List<RuleConfig> ruleConfigs) {
RuleDecisionTreeVO out = new RuleDecisionTreeVO();
Map<String, Object> globalPreview = loadEnabledGlobalParams();
out.setGlobalParamsPreview(new LinkedHashMap<>(globalPreview));
List<RuleConfig> source = ruleConfigs != null ? ruleConfigs : Collections.emptyList();
List<String> ruleCodes = source.stream()
.filter(r -> r != null && ObjectUtil.isNotEmpty(r.getRuleCode()))
.map(RuleConfig::getRuleCode)
.distinct()
.collect(Collectors.toList());
Map<String, List<RuleConfigParam>> paramsByRule = ruleCodes.isEmpty()
? new LinkedHashMap<>()
: ruleConfigMapper.selectParamsByRuleCodes(ruleCodes).stream()
.filter(p -> p != null && ObjectUtil.isNotEmpty(p.getRuleCode()))
.collect(Collectors.groupingBy(RuleConfigParam::getRuleCode, LinkedHashMap::new, Collectors.toList()));
Map<String, List<String>> taskTypesByRule = new LinkedHashMap<>();
if (!ruleCodes.isEmpty()) {
List<RuleConfigTaskTypeRow> taskRows = ruleConfigMapper.selectTaskTypesByRuleCodes(ruleCodes);
for (RuleConfigTaskTypeRow row : taskRows) {
if (row == null || ObjectUtil.isEmpty(row.getRuleCode()) || ObjectUtil.isEmpty(row.getTaskTypeCode())) {
continue;
}
taskTypesByRule.computeIfAbsent(row.getRuleCode(), k -> new ArrayList<>()).add(row.getTaskTypeCode());
}
}
Map<String, List<RuleConfig>> byModule = source.stream()
.filter(r -> r != null && ObjectUtil.isNotEmpty(r.getModuleCode()))
.collect(Collectors.groupingBy(RuleConfig::getModuleCode, LinkedHashMap::new, Collectors.toList()));
List<RuleDecisionBlockVO> blocks = new ArrayList<>();
for (RuleFourBlockDefinition def : RuleFourBlockDefinition.ordered()) {
List<RuleConfig> moduleRules = new ArrayList<>(byModule.getOrDefault(def.getModuleCode(), Collections.emptyList()));
moduleRules.sort(Comparator.comparing(RuleConfig::getPriorityNo, Comparator.nullsLast(Integer::compareTo)));
RuleDecisionBlockVO block = new RuleDecisionBlockVO();
block.setBlockId(def.getBlockId());
block.setModuleCode(def.getModuleCode());
block.setTitle(def.getDroolsRuleName());
block.setDroolsRuleName(def.getDroolsRuleName());
block.setSalience(def.getSalience());
block.setNodes(buildDecisionBlockNodes(def, moduleRules, paramsByRule, taskTypesByRule, globalPreview));
blocks.add(block);
}
out.setBlocks(blocks);
return out;
}
@Override
public RuleFourBlocksGraphVO buildFourBlocksKnowledgeGraph() {
RuleFourBlocksGraphVO out = new RuleFourBlocksGraphVO();
@@ -446,6 +501,272 @@ public class RuleConfigServiceImpl implements IRuleConfigService {
return out;
}
private List<RuleDecisionNodeVO> buildDecisionBlockNodes(RuleFourBlockDefinition def,
List<RuleConfig> moduleRules,
Map<String, List<RuleConfigParam>> paramsByRule,
Map<String, List<String>> taskTypesByRule,
Map<String, Object> globalPreview) {
List<RuleDecisionNodeVO> nodes = new ArrayList<>();
nodes.add(buildDecisionInputNode(def, moduleRules, paramsByRule, globalPreview));
nodes.add(buildDecisionStepNode(def));
nodes.add(buildDecisionRulesNode(def, moduleRules, paramsByRule, taskTypesByRule, globalPreview));
nodes.add(buildDecisionOutcomeNode(def, moduleRules, paramsByRule, globalPreview));
return nodes;
}
private RuleDecisionNodeVO buildDecisionInputNode(RuleFourBlockDefinition def,
List<RuleConfig> moduleRules,
Map<String, List<RuleConfigParam>> paramsByRule,
Map<String, Object> globalPreview) {
RuleDecisionNodeVO root = decisionNode(
"decision:" + def.getBlockId() + ":input",
"输入参数",
"input_group",
"当前块启用参数会先进入 globalParams再被规则与 Java 算子读取",
"" + countEnabledParams(moduleRules, paramsByRule) + "");
Map<String, RuleDecisionNodeVO> uniqueByKey = new LinkedHashMap<>();
for (RuleConfig rule : moduleRules) {
if (rule == null || ObjectUtil.isEmpty(rule.getRuleCode())) {
continue;
}
List<RuleConfigParam> plist = paramsByRule.getOrDefault(rule.getRuleCode(), Collections.emptyList());
for (RuleConfigParam param : plist) {
if (param == null || ObjectUtil.isEmpty(param.getParamKey())) {
continue;
}
if (param.getEnabled() != null && param.getEnabled() == 0) {
continue;
}
uniqueByKey.computeIfAbsent(param.getParamKey(), key -> {
RuleParamMeta meta = resolveMeta(key);
Object effective = globalPreview.get(key);
String description = meta != null && ObjectUtil.isNotEmpty(meta.getDescription())
? meta.getDescription()
: "由命中的规则参数写入运行时参数池";
String valueText = "生效值: " + String.valueOf(effective != null ? effective : param.getParamVal());
return decisionNode(
"decision:" + def.getBlockId() + ":input:" + key,
ObjectUtil.defaultIfBlank(param.getParamName(), key),
"input_param",
description,
valueText);
});
}
}
if (uniqueByKey.isEmpty()) {
root.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":input:empty",
"当前没有启用参数",
"empty",
"导入或新增参数后,这里会展示参与该块运算的参数",
null));
} else {
root.safeChildren().addAll(uniqueByKey.values());
}
return root;
}
private RuleDecisionNodeVO buildDecisionStepNode(RuleFourBlockDefinition def) {
RuleDecisionNodeVO root = decisionNode(
"decision:" + def.getBlockId() + ":steps",
"运算步骤",
"step_group",
"展示该规则块在运算中的主路径",
"salience=" + def.getSalience());
List<String> steps = def.getComputationSteps();
for (int i = 0; i < steps.size(); i++) {
String step = steps.get(i);
root.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":step:" + i,
"步骤 " + (i + 1),
"step",
step,
null));
}
return root;
}
private RuleDecisionNodeVO buildDecisionRulesNode(RuleFourBlockDefinition def,
List<RuleConfig> moduleRules,
Map<String, List<RuleConfigParam>> paramsByRule,
Map<String, List<String>> taskTypesByRule,
Map<String, Object> globalPreview) {
RuleDecisionNodeVO root = decisionNode(
"decision:" + def.getBlockId() + ":rules",
"规则链",
"rule_group",
"按优先级展示该块会如何判断、读取参数并产生产出",
"" + moduleRules.size() + "");
if (CollUtil.isEmpty(moduleRules)) {
root.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":rules:empty",
"当前没有规则项",
"empty",
"该块暂无可展示的规则配置",
null));
return root;
}
for (RuleConfig rule : moduleRules) {
if (rule == null || ObjectUtil.isEmpty(rule.getRuleCode())) {
continue;
}
String ruleCode = rule.getRuleCode();
RuleDecisionNodeVO ruleNode = decisionNode(
"decision:" + def.getBlockId() + ":rule:" + ruleCode,
ObjectUtil.defaultIfBlank(rule.getRuleName(), ruleCode),
"rule",
ObjectUtil.defaultIfBlank(rule.getRemark(), "根据条件命中后执行当前规则"),
"优先级 " + String.valueOf(rule.getPriorityNo() != null ? rule.getPriorityNo() : "-"));
ruleNode.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":rule:" + ruleCode + ":condition",
"命中条件",
"condition",
ObjectUtil.defaultIfBlank(rule.getConditionExpr(), def.getWhenExpr()),
null));
List<String> taskTypes = taskTypesByRule.getOrDefault(ruleCode, Collections.emptyList());
ruleNode.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":rule:" + ruleCode + ":task-types",
"适用任务",
"task_types",
taskTypes.isEmpty() ? "未单独限定任务类型" : joinTaskTypes(taskTypes),
taskTypes.isEmpty() ? "全部/默认" : "" + taskTypes.size() + ""));
RuleDecisionNodeVO paramGroup = decisionNode(
"decision:" + def.getBlockId() + ":rule:" + ruleCode + ":params",
"读取参数",
"param_group",
"规则命中后会读取这些参数参与计算",
null);
List<RuleConfigParam> plist = new ArrayList<>(paramsByRule.getOrDefault(ruleCode, Collections.emptyList()));
plist.sort(Comparator
.comparing((RuleConfigParam p) -> p.getSortNo() == null ? Integer.MAX_VALUE : p.getSortNo())
.thenComparing(RuleConfigParam::getParamKey, Comparator.nullsLast(String::compareTo)));
for (RuleConfigParam param : plist) {
if (param == null || ObjectUtil.isEmpty(param.getParamKey())) {
continue;
}
if (param.getEnabled() != null && param.getEnabled() == 0) {
continue;
}
String key = param.getParamKey();
RuleParamMeta meta = resolveMeta(key);
String desc = meta != null && ObjectUtil.isNotEmpty(meta.getDescription())
? meta.getDescription()
: RuleParamOutputHint.effectLine(key, def.getModuleCode());
Object effective = globalPreview.get(key);
paramGroup.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":rule:" + ruleCode + ":param:" + key,
ObjectUtil.defaultIfBlank(param.getParamName(), key),
"param",
desc,
"参数键=" + key + ";生效值=" + String.valueOf(effective != null ? effective : param.getParamVal())));
}
if (CollUtil.isEmpty(paramGroup.getChildren())) {
paramGroup.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":rule:" + ruleCode + ":params:empty",
"无参数",
"empty",
"当前规则未配置可展示参数",
null));
}
ruleNode.safeChildren().add(paramGroup);
RuleDecisionNodeVO outputGroup = decisionNode(
"decision:" + def.getBlockId() + ":rule:" + ruleCode + ":outputs",
"产出结果",
"output_group",
ObjectUtil.defaultIfBlank(rule.getActionExpr(), def.getThenAction()),
null);
List<String> outputs = RuleFourBlockRuleOutputCatalog.outputsForRule(def.getModuleCode(), ruleCode);
if (CollUtil.isEmpty(outputs)) {
outputGroup.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":rule:" + ruleCode + ":outputs:default",
"执行块运算",
"output",
def.getThenAction(),
"写入结果对象"));
} else {
for (int i = 0; i < outputs.size(); i++) {
outputGroup.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":rule:" + ruleCode + ":output:" + i,
"结果 " + (i + 1),
"output",
outputs.get(i),
null));
}
}
ruleNode.safeChildren().add(outputGroup);
root.safeChildren().add(ruleNode);
}
return root;
}
private RuleDecisionNodeVO buildDecisionOutcomeNode(RuleFourBlockDefinition def,
List<RuleConfig> moduleRules,
Map<String, List<RuleConfigParam>> paramsByRule,
Map<String, Object> globalPreview) {
RuleDecisionNodeVO root = decisionNode(
"decision:" + def.getBlockId() + ":outcome",
"最终结果",
"outcome_group",
"汇总该块在命中规则后可能产出的业务结果",
null);
LinkedHashSet<String> lines = new LinkedHashSet<>();
for (RuleConfig rule : moduleRules) {
if (rule == null || ObjectUtil.isEmpty(rule.getRuleCode())) {
continue;
}
List<String> outputs = RuleFourBlockRuleOutputCatalog.outputsForRule(def.getModuleCode(), rule.getRuleCode());
lines.addAll(outputs);
for (RuleConfigParam param : paramsByRule.getOrDefault(rule.getRuleCode(), Collections.emptyList())) {
if (param == null || ObjectUtil.isEmpty(param.getParamKey())) {
continue;
}
if (param.getEnabled() != null && param.getEnabled() == 0) {
continue;
}
Object effective = globalPreview.get(param.getParamKey());
lines.add(ObjectUtil.defaultIfBlank(param.getParamName(), param.getParamKey())
+ " -> "
+ RuleParamOutputHint.effectLine(param.getParamKey(), def.getModuleCode())
+ (effective != null ? "(当前值: " + effective + "" : ""));
}
}
if (lines.isEmpty()) {
root.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":outcome:empty",
"暂无结果描述",
"empty",
"当前块暂无可汇总的产出",
null));
} else {
int index = 0;
for (String line : lines) {
root.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":outcome:" + index,
"结果说明 " + (index + 1),
"outcome",
line,
null));
index++;
}
}
root.safeChildren().add(decisionNode(
"decision:" + def.getBlockId() + ":outcome:end",
"输出 / 结束",
"outcome",
"当前规则块的决策链在这里收束,结果写入执行对象或作为后续环节输入",
null));
return root;
}
private List<RuleFourBlockParamRowVO> buildFourBlockParamRows(RuleFourBlockDefinition def,
Map<String, RuleConfig> ruleByCode,
List<RuleConfigParam> plist,
@@ -664,6 +985,35 @@ public class RuleConfigServiceImpl implements IRuleConfigService {
return graph;
}
private int countEnabledParams(List<RuleConfig> moduleRules, Map<String, List<RuleConfigParam>> paramsByRule) {
int count = 0;
for (RuleConfig rule : moduleRules) {
if (rule == null || ObjectUtil.isEmpty(rule.getRuleCode())) {
continue;
}
for (RuleConfigParam param : paramsByRule.getOrDefault(rule.getRuleCode(), Collections.emptyList())) {
if (param == null || ObjectUtil.isEmpty(param.getParamKey())) {
continue;
}
if (param.getEnabled() != null && param.getEnabled() == 0) {
continue;
}
count++;
}
}
return count;
}
private RuleDecisionNodeVO decisionNode(String key, String title, String nodeType, String description, String valueText) {
RuleDecisionNodeVO node = new RuleDecisionNodeVO();
node.setKey(key);
node.setTitle(title);
node.setNodeType(nodeType);
node.setDescription(description);
node.setValueText(valueText);
return node;
}
private List<RuleDictItem> safeDict(String dictType) {
List<RuleDictItem> list = ruleConfigMapper.selectDictByType(dictType);
return list != null ? list : Collections.emptyList();

View File

@@ -0,0 +1,415 @@
<template>
<div class="rule-decision-tree">
<div class="rule-decision-tree__toolbar">
<a-popover placement="bottomLeft" trigger="click" overlay-class-name="rule-decision-tree-snapshot-popover">
<template #content>
<pre class="rule-decision-tree__popover-json">{{ previewJson }}</pre>
</template>
<a-button type="link" size="small" class="rule-decision-tree__snapshot-btn">
全局参数快照{{ previewEntryCount }}
</a-button>
</a-popover>
</div>
<div v-if="errorMsg" class="rule-decision-tree__banner rule-decision-tree__banner--error">
{{ errorMsg }}
</div>
<div v-else-if="emptyHint" class="rule-decision-tree__banner">
{{ emptyHint }}
</div>
<div v-else-if="payload" class="rule-decision-tree__grid">
<section
v-for="block in payload.blocks"
:key="block.blockId"
class="rule-decision-tree__pane"
>
<header class="rule-decision-tree__pane-header">
<div class="rule-decision-tree__pane-title">{{ block.title || block.blockId }}</div>
<div class="rule-decision-tree__pane-meta">
模块{{ block.moduleCode || '-' }} / salience{{ block.salience ?? '-' }}
</div>
</header>
<div
:ref="(el) => registerHost(block.blockId, el)"
class="rule-decision-tree__canvas"
/>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { Graph } from '@antv/g6';
import { message } from 'ant-design-vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { findRuleDecisionTree } from './api';
import type { RuleConfigRequest, RuleDecisionBlock, RuleDecisionNode, RuleDecisionTreePayload } from './types';
const props = defineProps<{
query: RuleConfigRequest,
refreshKey: number,
}>();
const payload = ref<RuleDecisionTreePayload | null>(null);
const errorMsg = ref<string | null>(null);
const emptyHint = ref<string | null>(null);
const hostMap = new Map<string, HTMLDivElement>();
const graphMap = new Map<string, Graph>();
const resizeMap = new Map<string, ResizeObserver>();
const renderVersion = ref(0);
const previewEntryCount = computed(() => {
const p = payload.value?.globalParamsPreview;
return p ? Object.keys(p).length : 0;
});
const previewJson = computed(() => {
const p = payload.value?.globalParamsPreview;
if (!p) {
return '';
}
try {
return JSON.stringify(p, null, 2);
} catch {
return String(p);
}
});
const BLOCK_COLORS: Record<string, string> = {
equipment: '#5B8FF9',
target: '#F6903D',
position: '#61DDAA',
track: '#9270CA',
};
const NODE_COLORS: Record<string, string> = {
root: '#5B8FF9',
input_group: '#5B8FF9',
input_param: '#4fb3ff',
step_group: '#61DDAA',
step: '#49c59c',
rule_group: '#F6903D',
rule: '#ff9c4a',
condition: '#ffd166',
task_types: '#9ad0f5',
param_group: '#3db2ff',
param: '#5ab9ff',
output_group: '#f08c6c',
output: '#ffb366',
outcome_group: '#9270CA',
outcome: '#a98cf0',
end: '#e8684a',
empty: '#6f8194',
};
const registerHost = (blockId: string, el: unknown) => {
if (!(el instanceof HTMLDivElement)) {
disposeBlock(blockId);
hostMap.delete(blockId);
return;
}
hostMap.set(blockId, el);
};
const flattenTree = (block: RuleDecisionBlock) => {
const nodes: Array<{ id: string; label: string; nodeType: string; valueText?: string | null; description?: string | null }> = [];
const edges: Array<{ source: string; target: string }> = [];
const rootId = `block:${block.blockId}:root`;
nodes.push({
id: rootId,
label: block.title || block.blockId,
nodeType: 'root',
valueText: block.salience != null ? `salience=${block.salience}` : undefined,
description: block.droolsRuleName || undefined,
});
const visit = (node: RuleDecisionNode, parentId: string) => {
const desc = node.description ? (node.valueText ? `${node.description} | ${node.valueText}` : node.description) : (node.valueText ?? '');
nodes.push({
id: node.key,
label: node.title,
nodeType: node.nodeType,
description: desc || undefined,
});
edges.push({ source: parentId, target: node.key });
(node.children ?? []).forEach((child) => visit(child, node.key));
};
(block.nodes ?? []).forEach((node) => visit(node, rootId));
return { nodes, edges };
};
const disposeBlock = (blockId: string) => {
resizeMap.get(blockId)?.disconnect();
resizeMap.delete(blockId);
const graph = graphMap.get(blockId);
if (graph) {
try {
graph.destroy();
} catch {
/* ignore */
}
graphMap.delete(blockId);
}
const host = hostMap.get(blockId);
if (host) {
host.replaceChildren();
}
};
const renderBlock = async (block: RuleDecisionBlock, version: number) => {
const host = hostMap.get(block.blockId);
if (!host) {
return;
}
disposeBlock(block.blockId);
if (version !== renderVersion.value) {
return;
}
const width = Math.max(host.clientWidth, 320);
const height = Math.max(host.clientHeight, 240);
const accent = BLOCK_COLORS[block.blockId] ?? '#5B8FF9';
const data = flattenTree(block);
const graph = new Graph({
container: host,
width,
height,
data: {
nodes: data.nodes.map((node) => ({
id: node.id,
data: node,
})),
edges: data.edges,
},
layout: {
type: 'antv-dagre',
rankdir: 'TB',
nodesep: 20,
ranksep: 42,
},
node: {
type: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'input_group') return 'rect';
if (t === 'end') return 'diamond';
return 'circle';
},
style: {
size: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'root') return 18;
if (t === 'input_group' || t === 'end') return 20;
return 14;
},
fill: (model: { data?: { nodeType?: string } }) => NODE_COLORS[model.data?.nodeType ?? ''] ?? accent,
stroke: '#0d1f2c',
lineWidth: 1.5,
labelText: (model: { data?: { label?: string; description?: string } }) => {
const label = model.data?.label ?? '';
const description = model.data?.description;
return description ? `${label}\n${description}` : label;
},
labelFill: '#e8f4f8',
labelFontSize: 11,
labelLineHeight: 15,
labelWordWrap: true,
labelMaxWidth: 160,
},
},
edge: {
style: {
stroke: 'rgba(120, 170, 200, 0.52)',
lineWidth: 1.2,
endArrow: true,
},
},
behaviors: ['drag-canvas', 'zoom-canvas'],
});
await graph.render();
if (version !== renderVersion.value) {
try {
graph.destroy();
} catch {
/* ignore */
}
return;
}
await graph.fitView({ when: 'always' });
graphMap.set(block.blockId, graph);
const observer = new ResizeObserver(() => {
const nextWidth = Math.max(host.clientWidth, 320);
const nextHeight = Math.max(host.clientHeight, 240);
graph.setSize(nextWidth, nextHeight);
void graph.fitView({ when: 'overflow' });
});
observer.observe(host);
resizeMap.set(block.blockId, observer);
};
const renderAll = async () => {
if (!payload.value?.blocks?.length) {
return;
}
await nextTick();
const version = renderVersion.value;
for (const block of payload.value.blocks) {
await renderBlock(block, version);
}
};
const load = async () => {
errorMsg.value = null;
emptyHint.value = null;
payload.value = null;
renderVersion.value += 1;
Array.from(graphMap.keys()).forEach(disposeBlock);
try {
const r = await findRuleDecisionTree({
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 ?? '加载决策树失败';
return;
}
if (!r.data.blocks?.length) {
emptyHint.value = '当前没有可展示的规则决策树数据';
return;
}
payload.value = r.data;
await renderAll();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
errorMsg.value = `决策树请求失败:${msg}`;
message.error(errorMsg.value);
}
};
watch(
() => [props.query.pageNum, props.query.pageSize, props.refreshKey],
() => {
void load();
},
{ immediate: true },
);
onBeforeUnmount(() => {
renderVersion.value += 1;
Array.from(graphMap.keys()).forEach(disposeBlock);
});
</script>
<style scoped lang="less">
.rule-decision-tree {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
background: transparent;
}
.rule-decision-tree__toolbar {
flex: 0 0 auto;
padding: 0 2px;
}
.rule-decision-tree__snapshot-btn {
padding-left: 0;
font-size: 11px;
color: #8fafbd;
}
.rule-decision-tree__banner {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
font-size: 13px;
color: #a2b1ba;
text-align: center;
}
.rule-decision-tree__banner--error {
color: #ff9c9c;
}
.rule-decision-tree__grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.rule-decision-tree__pane {
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid rgba(80, 120, 150, 0.25);
background: linear-gradient(180deg, rgba(8, 20, 30, 0.78) 0%, rgba(5, 16, 29, 0.9) 100%);
overflow: hidden;
}
.rule-decision-tree__pane-header {
flex: 0 0 auto;
padding: 8px 10px 6px;
border-bottom: 1px solid rgba(80, 120, 150, 0.2);
background: rgba(10, 28, 40, 0.78);
}
.rule-decision-tree__pane-title {
font-size: 14px;
color: #d8edf6;
}
.rule-decision-tree__pane-meta {
margin-top: 2px;
font-size: 11px;
color: #7f9aaa;
}
.rule-decision-tree__canvas {
flex: 1;
min-height: 0;
width: 100%;
background: transparent;
}
</style>
<style lang="less">
.rule-decision-tree-snapshot-popover {
.ant-popover-inner-content {
max-width: min(520px, 90vw);
max-height: 70vh;
overflow: auto;
background: #081e3b;
}
.ant-popover-inner {
background: #081e3b;
}
}
.rule-decision-tree__popover-json {
margin: 0;
font-size: 10px;
line-height: 1.35;
color: #c9dfe8;
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,466 @@
<template>
<div class="rule-decision-tree-simple">
<div class="rule-decision-tree-simple__toolbar">
<a-popover placement="bottomLeft" trigger="click" overlay-class-name="rule-decision-tree-simple-snapshot-popover">
<template #content>
<pre class="rule-decision-tree-simple__popover-json">{{ previewJson }}</pre>
</template>
<a-button type="link" size="small" class="rule-decision-tree-simple__snapshot-btn">
全局参数快照{{ previewEntryCount }}
</a-button>
</a-popover>
</div>
<div v-if="errorMsg" class="rule-decision-tree-simple__banner rule-decision-tree-simple__banner--error">
{{ errorMsg }}
</div>
<div v-else-if="emptyHint" class="rule-decision-tree-simple__banner">
{{ emptyHint }}
</div>
<div v-else ref="hostRef" class="rule-decision-tree-simple__canvas" />
</div>
</template>
<script setup lang="ts">
import { Graph } from '@antv/g6';
import { message } from 'ant-design-vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { findRuleDecisionTree } from './api';
import type { RuleConfigRequest, RuleDecisionBlock, RuleDecisionNode, RuleDecisionTreePayload } from './types';
const props = defineProps<{
query: RuleConfigRequest,
refreshKey: number,
}>();
type GraphNode = {
id: string,
label: string,
nodeType: string,
description?: string,
fullText?: string,
};
const hostRef = ref<HTMLDivElement | null>(null);
const errorMsg = ref<string | null>(null);
const emptyHint = ref<string | null>(null);
const payload = ref<RuleDecisionTreePayload | null>(null);
const renderVersion = ref(0);
let graph: Graph | null = null;
let resizeObserver: ResizeObserver | null = null;
const previewEntryCount = computed(() => {
const p = payload.value?.globalParamsPreview;
return p ? Object.keys(p).length : 0;
});
const previewJson = computed(() => {
const p = payload.value?.globalParamsPreview;
if (!p) {
return '';
}
try {
return JSON.stringify(p, null, 2);
} catch {
return String(p);
}
});
const NODE_COLORS: Record<string, string> = {
start: '#4fb3ff',
block: '#5B8FF9',
params: '#49c59c',
rules: '#ff9c4a',
outputs: '#a98cf0',
end: '#e8684a',
};
const BLOCK_TITLE: Record<string, string> = {
equipment: '装备规则',
target: '目标规则',
position: '阵位规则',
track: '航迹规则',
};
const flatten = (nodes: RuleDecisionNode[] | null | undefined): RuleDecisionNode[] => {
const out: RuleDecisionNode[] = [];
const visit = (items: RuleDecisionNode[] | null | undefined) => {
(items ?? []).forEach((item) => {
out.push(item);
visit(item.children ?? []);
});
};
visit(nodes);
return out;
};
const scoreParamNode = (node: RuleDecisionNode) => {
const text = `${node.title ?? ''} ${node.description ?? ''} ${node.valueText ?? ''}`.toLowerCase();
let score = 0;
const keywords = [
'模式', '类型', '目标', '装备', '阵位', '航迹', '选择', '分配', '距离', '阈值', '权重',
'比例', '数量', '规则', 'mode', 'type', 'target', 'equipment', 'position', 'track',
'select', 'assign', 'distance', 'threshold', 'weight', 'ratio', 'count', 'limit', 'min', 'max',
];
keywords.forEach((keyword) => {
if (text.includes(keyword)) {
score += 2;
}
});
if (text.includes('生效值')) {
score += 1;
}
return score;
};
const pickImportantParams = (flatNodes: RuleDecisionNode[]) => {
return flatNodes
.filter((node) => node.nodeType === 'input_param' || node.nodeType === 'param')
.sort((a, b) => scoreParamNode(b) - scoreParamNode(a))
.slice(0, 4);
};
const pickCoreRules = (flatNodes: RuleDecisionNode[]) => {
return flatNodes
.filter((node) => node.nodeType === 'rule')
.slice(0, 2);
};
const pickOutputs = (flatNodes: RuleDecisionNode[]) => {
const list = flatNodes.filter((node) => node.nodeType === 'output' || node.nodeType === 'outcome');
return list.slice(0, 3);
};
const buildMergedTree = (tree: RuleDecisionTreePayload) => {
const nodes: GraphNode[] = [];
const edges: Array<{ source: string; target: string }> = [];
const startId = 'simple:start';
nodes.push({
id: startId,
label: '输入参数',
nodeType: 'start',
description: `汇总四类规则块共享的运行时参数(${Object.keys(tree.globalParamsPreview ?? {}).length}项)`,
});
let previousMainId = startId;
const blocks = [...(tree.blocks ?? [])];
for (const block of blocks) {
const mainId = `simple:block:${block.blockId}`;
nodes.push({
id: mainId,
label: BLOCK_TITLE[block.blockId] ?? (block.title || block.blockId),
nodeType: 'block',
description: `salience=${block.salience ?? '-'};保留核心规则与关键参数`,
});
edges.push({ source: previousMainId, target: mainId });
const flatNodes = flatten(block.nodes);
const importantParams = pickImportantParams(flatNodes);
const coreRules = pickCoreRules(flatNodes);
const outputs = pickOutputs(flatNodes);
const paramId = `simple:block:${block.blockId}:params`;
nodes.push({
id: paramId,
label: '关键参数',
nodeType: 'params',
description: importantParams.length > 0
? importantParams.map((item) => `${item.title}${item.valueText ? `${item.valueText}` : ''}`).join('')
: '无关键参数',
fullText: importantParams.length > 0
? `关键参数:${importantParams.map((item) => `${item.title}${item.valueText ? `=${item.valueText}` : ''}`).join('')}`
: '关键参数:无',
});
edges.push({ source: mainId, target: paramId });
const ruleId = `simple:block:${block.blockId}:rules`;
nodes.push({
id: ruleId,
label: '核心规则',
nodeType: 'rules',
description: coreRules.length > 0
? coreRules.map((item) => item.title).join('')
: '无核心规则',
fullText: coreRules.length > 0
? `核心规则:${coreRules.map((item) => item.title).join('')}`
: '核心规则:无',
});
edges.push({ source: mainId, target: ruleId });
const outputId = `simple:block:${block.blockId}:outputs`;
nodes.push({
id: outputId,
label: '阶段输出',
nodeType: 'outputs',
description: outputs.length > 0
? outputs.map((item) => String(item.title || item.description || '')).join('')
: '无阶段输出',
fullText: outputs.length > 0
? `阶段输出:${outputs.map((item) => String(item.title || item.description || '')).join('')}`
: '阶段输出:无',
});
edges.push({ source: mainId, target: outputId });
previousMainId = mainId;
}
nodes.push({
id: 'simple:end',
label: '输出 / 结束',
nodeType: 'end',
description: '四类规则块汇总后输出最终结果',
fullText: '输出 / 结束:四类规则块汇总后输出最终结果',
});
edges.push({ source: previousMainId, target: 'simple:end' });
return { nodes, edges };
};
const disposeGraph = () => {
resizeObserver?.disconnect();
resizeObserver = null;
if (graph) {
try {
graph.destroy();
} catch {
/* ignore */
}
graph = null;
}
hostRef.value?.replaceChildren();
};
const renderGraph = async (tree: RuleDecisionTreePayload, version: number) => {
const host = hostRef.value;
if (!host) {
return;
}
disposeGraph();
if (version !== renderVersion.value) {
return;
}
const data = buildMergedTree(tree);
graph = new Graph({
container: host,
width: Math.max(host.clientWidth, 320),
height: Math.max(host.clientHeight, 240),
data: {
nodes: data.nodes.map((node) => ({
id: node.id,
data: node,
})),
edges: data.edges,
},
layout: {
type: 'antv-dagre',
rankdir: 'LR',
nodesep: 28,
ranksep: 56,
},
node: {
type: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'start') return 'rect';
if (t === 'end') return 'diamond';
return 'circle';
},
style: {
size: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'start' || t === 'end') return 26;
if (t === 'block') return 20;
if (t === 'params' || t === 'rules' || t === 'outputs') return 16;
return 16;
},
fill: (model: { data?: { nodeType?: string } }) => NODE_COLORS[model.data?.nodeType ?? 'block'] ?? '#5B8FF9',
stroke: '#0d1f2c',
lineWidth: 1.5,
labelText: (model: { data?: { label?: string; description?: string; fullText?: string; nodeType?: string } }) => {
const text = model.data?.fullText;
if (text) {
return text;
}
const label = model.data?.label ?? '';
const description = model.data?.description;
return description ? `${label}${description}` : label;
},
labelFill: '#e8f4f8',
labelFontSize: 11,
labelLineHeight: 15,
labelWordWrap: true,
labelMaxWidth: 360,
labelPlacement: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'params' || t === 'rules' || t === 'outputs') {
return 'right';
}
return 'bottom';
},
labelOffsetX: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'params' || t === 'rules' || t === 'outputs') {
return 14;
}
return 0;
},
labelTextAlign: (model: { data?: { nodeType?: string } }) => {
const t = model.data?.nodeType;
if (t === 'params' || t === 'rules' || t === 'outputs') {
return 'left';
}
return 'center';
},
labelBackground: true,
labelBackgroundFill: 'rgba(6, 21, 40, 0.88)',
labelBackgroundRadius: 4,
labelPadding: [6, 8],
},
},
edge: {
style: {
stroke: 'rgba(120, 170, 200, 0.52)',
lineWidth: 1.2,
endArrow: true,
},
},
behaviors: ['drag-canvas', 'zoom-canvas'],
});
await graph.render();
if (version !== renderVersion.value) {
disposeGraph();
return;
}
await graph.fitView({ when: 'always' });
resizeObserver = new ResizeObserver(() => {
if (!graph || !hostRef.value) {
return;
}
graph.setSize(Math.max(hostRef.value.clientWidth, 320), Math.max(hostRef.value.clientHeight, 240));
void graph.fitView({ when: 'overflow' });
});
resizeObserver.observe(host);
};
const load = async () => {
errorMsg.value = null;
emptyHint.value = null;
payload.value = null;
renderVersion.value += 1;
disposeGraph();
try {
const r = await findRuleDecisionTree({
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 ?? '加载简化决策树失败';
return;
}
if (!r.data.blocks?.length) {
emptyHint.value = '当前没有可展示的简化决策树数据';
return;
}
payload.value = r.data;
await nextTick();
await renderGraph(r.data, renderVersion.value);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
errorMsg.value = `简化决策树请求失败:${msg}`;
message.error(errorMsg.value);
}
};
watch(
() => [props.query.pageNum, props.query.pageSize, props.refreshKey],
() => {
void load();
},
{ immediate: true },
);
onBeforeUnmount(() => {
renderVersion.value += 1;
disposeGraph();
});
</script>
<style scoped lang="less">
.rule-decision-tree-simple {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
background: transparent;
}
.rule-decision-tree-simple__toolbar {
flex: 0 0 auto;
padding: 0 2px;
}
.rule-decision-tree-simple__snapshot-btn {
padding-left: 0;
font-size: 11px;
color: #8fafbd;
}
.rule-decision-tree-simple__banner {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
font-size: 13px;
color: #a2b1ba;
text-align: center;
}
.rule-decision-tree-simple__banner--error {
color: #ff9c9c;
}
.rule-decision-tree-simple__canvas {
flex: 1;
min-height: 0;
width: 100%;
border: 1px solid rgba(80, 120, 150, 0.22);
border-radius: 4px;
background: rgba(4, 18, 28, 0.35);
}
</style>
<style lang="less">
.rule-decision-tree-simple-snapshot-popover {
.ant-popover-inner-content {
max-width: min(520px, 90vw);
max-height: 70vh;
overflow: auto;
background: #081e3b;
}
.ant-popover-inner {
background: #081e3b;
}
}
.rule-decision-tree-simple__popover-json {
margin: 0;
font-size: 10px;
line-height: 1.35;
color: #c9dfe8;
white-space: pre-wrap;
}
</style>

View File

@@ -12,20 +12,28 @@
class="rule-knowledge-graph"
:class="{
'rule-knowledge-graph--four-blocks': density === 'four-blocks',
'rule-knowledge-graph--decision-tree': density === 'decision-tree',
'rule-knowledge-graph--fullscreen': isFullscreen,
}"
>
<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-button value="decision-tree-simple">决策树简化</a-radio-button>
<a-radio-button value="decision-tree">决策树</a-radio-button>
<a-radio-button value="full">完整图谱</a-radio-button>
<a-radio-button value="four-blocks">四块分区</a-radio-button>
</a-radio-group>
<span v-if="density !== 'four-blocks'" class="rule-knowledge-graph__hint">
简要仅层级种类模块规则完整含参数任务类型与执行顺序边四块业务运算步骤 + 规则项 + 参数 globalParams 一致
<span v-if="density === 'decision-tree-simple'" class="rule-knowledge-graph__hint">
决策树简化把装备目标阵位航迹四个规则块合并成一棵总树只保留关键参数核心规则和阶段输出
</span>
<span v-else-if="density === 'decision-tree'" class="rule-knowledge-graph__hint">
决策树按装备目标阵位航迹四个规则块展示输入参数运算步骤命中规则与结果产出
</span>
<span v-else-if="density === 'full'" class="rule-knowledge-graph__hint">
完整图谱包含参数任务类型与执行顺序
</span>
<span v-else class="rule-knowledge-graph__hint rule-knowledge-graph__hint--compact">
宫格 · 拖拽画布 / 滚轮缩放
块分区可拖动画布滚轮缩放
</span>
<a-button type="default" size="small" class="rule-knowledge-graph__fullscreen-btn" @click="toggleFullscreen">
<template #icon>
@@ -35,7 +43,9 @@
{{ isFullscreen ? '退出全屏' : '全屏' }}
</a-button>
</div>
<RuleFourBlocksPanel v-if="density === 'four-blocks'" :refresh-key="refreshKey" />
<RuleDecisionTreeSimplePanel v-if="density === 'decision-tree-simple'" :query="query" :refresh-key="refreshKey" />
<RuleDecisionTreePanel v-else-if="density === 'decision-tree'" :query="query" :refresh-key="refreshKey" />
<RuleFourBlocksPanel v-else-if="density === 'four-blocks'" :refresh-key="refreshKey" />
<template v-else>
<div v-if="errorMsg" class="rule-knowledge-graph__banner rule-knowledge-graph__banner--error">
{{ errorMsg }}
@@ -54,10 +64,12 @@ import { Graph } from '@antv/g6';
import { message } from 'ant-design-vue';
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { findRuleConfigGraph } from './api';
import RuleDecisionTreeSimplePanel from './RuleDecisionTreeSimplePanel.vue';
import RuleDecisionTreePanel from './RuleDecisionTreePanel.vue';
import RuleFourBlocksPanel from './RuleFourBlocksPanel.vue';
import type { RuleConfigRequest, RuleGraphEdge, RuleGraphNode, RuleGraphPayload } from './types';
import type { RuleConfigRequest, RuleGraphPayload } from './types';
type RuleGraphDensityMode = 'overview' | 'full' | 'four-blocks';
type RuleGraphDensityMode = 'decision-tree-simple' | 'decision-tree' | 'full' | 'four-blocks';
const emit = defineEmits<{
densityChange: [RuleGraphDensityMode],
@@ -73,7 +85,7 @@ const hostRef = ref<HTMLDivElement | null>(null);
const isFullscreen = ref(false);
const errorMsg = ref<string | null>(null);
const emptyHint = ref<string | null>(null);
const density = ref<RuleGraphDensityMode>('overview');
const density = ref<RuleGraphDensityMode>('decision-tree-simple');
const lastPayload = ref<RuleGraphPayload | null>(null);
let graph: Graph | null = null;
@@ -88,56 +100,6 @@ const NODE_COLORS: Record<string, string> = {
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,
@@ -213,56 +175,36 @@ const disposeGraph = async () => {
}
};
const buildGraph = async (payload: RuleGraphPayload, mode: 'overview' | 'full') => {
const buildGraph = async (payload: RuleGraphPayload) => {
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) {
const data = toGraphData(payload);
if (!payload.nodes.length) {
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,
layout: {
type: 'd3-force' as const,
},
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;
},
size: (d: { data?: { nodeType?: string } }) => (d.data?.nodeType === '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,
labelFontSize: 9,
labelMaxWidth: 100,
labelWordWrap: true,
lineWidth: 1.5,
stroke: '#0d1f2c',
@@ -270,19 +212,11 @@ const buildGraph = async (payload: RuleGraphPayload, mode: 'overview' | 'full')
},
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)';
},
stroke: (d: { data?: { edgeType?: string } }) =>
(d.data?.edgeType === 'rule_exec_before' ? '#faad14' : 'rgba(150, 175, 190, 0.35)'),
lineWidth: (d: { data?: { edgeType?: string } }) =>
(d.data?.edgeType === 'rule_exec_before' ? 2 : 1),
endArrow: mode !== 'overview',
endArrow: true,
},
},
behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'],
@@ -302,18 +236,11 @@ const buildGraph = async (payload: RuleGraphPayload, mode: 'overview' | 'full')
};
const renderFromCache = async () => {
if (density.value === 'four-blocks') {
return;
}
if (!lastPayload.value) {
return;
}
if (!lastPayload.value.nodes?.length) {
if (density.value !== 'full' || !lastPayload.value || !lastPayload.value.nodes?.length) {
return;
}
await nextTick();
const mode: 'overview' | 'full' = density.value === 'full' ? 'full' : 'overview';
await buildGraph(lastPayload.value, mode);
await buildGraph(lastPayload.value);
};
const load = async () => {
@@ -336,13 +263,12 @@ const load = async () => {
lastPayload.value = null;
return;
}
const payload = r.data;
if (!payload.nodes?.length) {
if (!r.data.nodes?.length) {
emptyHint.value = '当前页无规则数据,图谱为空';
lastPayload.value = null;
return;
}
lastPayload.value = payload;
lastPayload.value = r.data;
await renderFromCache();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
@@ -355,7 +281,7 @@ const load = async () => {
watch(
() => [props.query.pageNum, props.query.pageSize, props.refreshKey],
() => {
if (density.value === 'four-blocks') {
if (density.value !== 'full') {
return;
}
void load();
@@ -365,11 +291,11 @@ watch(
watch(density, async (mode, prev) => {
emit('densityChange', mode);
if (mode === 'four-blocks') {
if (mode !== 'full') {
await disposeGraph();
return;
}
if (prev === 'four-blocks') {
if (prev !== 'full') {
await load();
return;
}
@@ -418,42 +344,10 @@ onBeforeUnmount(() => {
font-size: 11px;
color: #7a8d96;
line-height: 1.35;
max-width: 100%;
}
.rule-knowledge-graph__hint--compact {
font-size: 10px;
color: #6d8290;
}
.rule-knowledge-graph--four-blocks {
min-height: 0;
}
.rule-knowledge-graph--fullscreen {
box-sizing: border-box;
width: 100vw;
height: 100vh;
max-height: 100vh;
padding: 10px 12px;
gap: 10px;
background: #0d1f2c;
}
.rule-knowledge-graph--fullscreen .rule-knowledge-graph__host {
min-height: 0;
flex: 1;
}
.rule-knowledge-graph--fullscreen :deep(.rule-four-blocks) {
flex: 1;
min-height: 0;
}
.rule-knowledge-graph__host {
flex: 1;
min-height: 200px;
position: relative;
letter-spacing: 0.2px;
}
.rule-knowledge-graph__banner {
@@ -470,4 +364,18 @@ onBeforeUnmount(() => {
.rule-knowledge-graph__banner--error {
color: #ff9c9c;
}
.rule-knowledge-graph__host {
flex: 1;
min-height: 0;
width: 100%;
border: 1px solid rgba(80, 120, 150, 0.18);
border-radius: 4px;
background: rgba(4, 18, 28, 0.35);
}
.rule-knowledge-graph--fullscreen {
background: #061522;
padding: 12px;
}
</style>

View File

@@ -10,7 +10,7 @@
import { HttpRequestClient, originalAxios } from '@/utils/request';
import type { ApiDataResponse, BasicResponse } from '@/types';
import type { AxiosResponse } from 'axios';
import type { RuleConfig, RuleConfigPageableResponse, RuleConfigRequest, RuleDictItem, RuleFourBlocksPayload, RuleGraphPayload, RuleParamMeta } from './types';
import type { RuleConfig, RuleConfigPageableResponse, RuleConfigRequest, RuleDecisionTreePayload, RuleDictItem, RuleFourBlocksPayload, RuleGraphPayload, RuleParamMeta } from './types';
const req = HttpRequestClient.create<BasicResponse>({
baseURL: '/api',
@@ -52,6 +52,10 @@ export const findRuleFourBlocksGraph = (): Promise<ApiDataResponse<RuleFourBlock
return req.get('/system/rule/config/graph/four-blocks');
};
export const findRuleDecisionTree = (query: Partial<RuleConfigRequest> = {}): Promise<ApiDataResponse<RuleDecisionTreePayload>> => {
return req.get('/system/rule/config/decision-tree', query);
};
export const exportRuleConfig = (query: Partial<RuleConfigRequest> = {}): Promise<AxiosResponse<Blob>> => {
return originalAxios.post('/api/system/rule/config/export', query, {
responseType: 'blob',

View File

@@ -300,7 +300,7 @@ const clampRightPanelWidth = (n: number) => {
return Math.min(dynamicMax, Math.max(RIGHT_PANEL_MIN, Math.round(n)));
};
const onRuleGraphDensityChange = (mode: 'overview' | 'full' | 'four-blocks') => {
const onRuleGraphDensityChange = (mode: 'decision-tree-simple' | 'decision-tree' | 'full' | 'four-blocks') => {
if (mode === 'four-blocks') {
if (graphPanePercentBeforeFourBlocks.value === null) {
graphPanePercentBeforeFourBlocks.value = graphPanePercent.value;

View File

@@ -135,3 +135,26 @@ export interface RuleFourBlocksPayload {
globalParamsPreview: Record<string, unknown>,
blocks: RuleFourBlockCluster[],
}
export interface RuleDecisionNode {
key: string,
title: string,
nodeType: string,
description?: string | null,
valueText?: string | null,
children?: RuleDecisionNode[] | null,
}
export interface RuleDecisionBlock {
blockId: string,
moduleCode: NullableString,
title: NullableString,
droolsRuleName: NullableString,
salience: number | null,
nodes: RuleDecisionNode[],
}
export interface RuleDecisionTreePayload {
globalParamsPreview: Record<string, unknown>,
blocks: RuleDecisionBlock[],
}