实体补充

This commit is contained in:
MHW
2026-04-20 10:31:09 +08:00
parent 931804555f
commit 71bb45f6a0
9 changed files with 1061 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
package com.solution.rule.domain.config.graph;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 与 {@code rules/rule.drl} 中「装备/目标/阵位/航迹」四个主规则一一对应,仅用于知识图谱分块展示(与 module_code 一致)。
*/
public enum RuleFourBlockDefinition {
EQUIPMENT(
"equipment",
"装备匹配",
100,
Arrays.asList(
"抽取蓝方任务与武器文案、红方装备类型与名称等,拼接为可对齐的蓝/红文本特征",
"规则槽关键词与兼容层叠加计分,再乘以全局权重得到每件红装的匹配总分",
"低于最低入选分则丢弃;并列最高分按既定策略决胜,并写入火力输入与任务名后缀"),
"DroolsFact(task != null)",
"mergeDefaultParams(globalParams); equipmentRule($fact, globalParams);"),
TARGET(
"target",
"目标匹配",
90,
Arrays.asList(
"根据命中率映射、射程解析与分配模式,确定每套红方可承载的目标数量与目标标识",
"向任务 execute 写入目标列表与默认行动类型等执行侧字段",
"命中率低于阈值时,在轮次与件数限制内补充选取装备并更新任务"),
"DroolsFact(task != null)",
"mergeDefaultParams(globalParams); target($fact, globalParams);"),
POSITION(
"position",
"阵位匹配",
80,
Arrays.asList(
"结合锚点模式、航向与部署距离区间、编队样式与间距,计算红方平台目标点位",
"按平台最小间距离散多个平台坐标,必要时叠加上述默认高度与跟随比例",
"若启用作战区约束,将越界阵位沿策略回拉到战争区内"),
"DroolsFact(task != null)",
"mergeDefaultParams(globalParams); position($fact, globalParams);"),
TRACK(
"track",
"航迹匹配",
70,
Arrays.asList(
"依据蓝方航迹与数据类型判定空中/地面航迹类别,选择对应变形算法生成航迹折线",
"生成航迹标识并与任务 execute 中的目标绑定移动路由",
"若启用航迹作战区约束,将航迹点约束在战争区多边形内"),
"DroolsFact(task != null)",
"mergeDefaultParams(globalParams); trackRoute($fact, globalParams);");

View File

@@ -0,0 +1,87 @@
package com.solution.rule.domain.config.graph;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/**
* 冻结「参数键 → 四大展示块」归属(与 rule.drl / {@code 002_rule_seed_from_drl.sql} 中的 rule_item.module_code 一致)。
* 图谱优先按库表 rule_item_param.rule_code 所属模块归类;若某键仅存在于 globalParams 快照且无法关联模块时,再用本映射兜底。
*/
public final class RuleFourBlockParamMapping {
private static final Set<String> EQUIPMENT = set(
"weight", "minSelectedScore", "tieBreak", "outputDrawNameSuffix", "ruleSlotCount",
"blueRuleKeywords_1", "redRuleKeywords_1", "ruleScore_1",
"blueRuleKeywords_2", "redRuleKeywords_2", "ruleScore_2",
"blueRuleKeywords_3", "redRuleKeywords_3", "ruleScore_3",
"bluePlatformKeywords_air", "redPreferredWhenBlueAir", "airScore",
"airTaskKeywords", "airTaskScore",
"groundTaskKeywords", "redPreferredWhenGround", "groundScore",
"tankKeywords", "redMatchKeywords_tank", "tankScore",
"missileKeywords", "redMatchKeywords_missile", "missileScore");
private static final Set<String> TARGET = set(
"executeTypeDefault", "targetPickMode", "minTargetsPerRed", "maxTargetsPerRedCap",
"radToTargetsCsv", "rangeParseRegex", "rangeUnit", "minRangeToAllowAssignKm",
"redHitRateThreshold", "maxExtraWeaponsPerTask", "maxSupplementRounds", "extraPickMinScore");
private static final Set<String> POSITION = set(
"positionRuleEnabled", "positionAnchorMode", "trackPointDirectionMode",
"fallbackBearingDeg", "deployDistanceKmMin", "deployDistanceKmMax", "deployDistanceKmDefault",
"distanceByPlatformCsv", "formationType", "formationSpacingMeters", "formationHeadingOffsetDeg",
"defaultDeployHeight", "heightFollowBlueRatio", "enableWarZoneClamp", "warZoneClampMode",
"minInterPlatformDistanceMeters");
private static final Set<String> TRACK = set(
"trackRuleEnabled", "trackRouteAlgorithm", "trackRouteNameSuffix",
"trackAirDataTypeCsv", "trackAirKeywordsCsv", "trackGroundTrackType",
"trackFallbackBearingDeg", "enableTrackWarZoneClamp",
"trackExtraNodesMax", "trackShortPathSegments",
"trackFlankOffsetMeters", "trackFlankSideMode",
"trackJamWobbleMeters", "trackJamSegments");
private RuleFourBlockParamMapping() {
}
private static Set<String> set(String... keys) {
return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(keys)));
}
/**
* @param paramKey 参数键
* @param slotRuleKeys 形如 blueRuleKeywords_i / redRuleKeywords_i / ruleScore_i视为装备块
*/
public static RuleFourBlockDefinition fallbackBlockForKey(String paramKey, boolean slotRuleKeys) {
if (paramKey == null || paramKey.isEmpty()) {
return RuleFourBlockDefinition.EQUIPMENT;
}
String k = paramKey.trim();
if (slotRuleKeys) {
return RuleFourBlockDefinition.EQUIPMENT;
}
if (EQUIPMENT.contains(k)) {
return RuleFourBlockDefinition.EQUIPMENT;
}
if (TARGET.contains(k)) {
return RuleFourBlockDefinition.TARGET;
}
if (POSITION.contains(k)) {
return RuleFourBlockDefinition.POSITION;
}
if (TRACK.contains(k)) {
return RuleFourBlockDefinition.TRACK;
}
String lower = k.toLowerCase(Locale.ROOT);
if (lower.startsWith("track")) {
return RuleFourBlockDefinition.TRACK;
}
if (lower.startsWith("position") || lower.startsWith("deploy") || lower.startsWith("formation")
|| lower.contains("warzone") || lower.contains("platform")) {
return RuleFourBlockDefinition.POSITION;
}
if (lower.contains("target") || lower.contains("execute") || lower.contains("assign") || lower.contains("supplement")) {
return RuleFourBlockDefinition.TARGET;
}
return RuleFourBlockDefinition.EQUIPMENT;
}
}

View File

@@ -0,0 +1,91 @@
package com.solution.rule.domain.config.graph;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 四块图谱展示用:每条 rule_item 对应的「运行时产出/写回」中文说明(静态目录,与 RuleFunction 行为一致即可)。
*/
public final class RuleFourBlockRuleOutputCatalog {
private static final Map<String, Map<String, List<String>>> BY_MODULE_AND_RULE = new HashMap<>();
static {
Map<String, List<String>> equipment = new HashMap<>();
equipment.put("R_TASK_SELECT_BASE", list(
"汇总匹配结果并写入火力规则输入fireRuleInputs 等匹配行)",
"为任务名称追加输出后缀outputDrawNameSuffix"));
equipment.put("R_TASK_SELECT_SLOT_1", list(
"若槽1蓝红关键词同时命中为当前候选装备累加 ruleScore_1×weight 分值"));
equipment.put("R_TASK_SELECT_SLOT_2", list(
"若槽2蓝红关键词同时命中累加 ruleScore_2×weight 分值"));
equipment.put("R_TASK_SELECT_SLOT_3", list(
"若槽3蓝红关键词同时命中累加 ruleScore_3×weight 分值"));
equipment.put("R_TASK_REL_AIR_PLATFORM", list(
"空中平台关键词命中时累加 airScore×weight 兼容层分值"));
equipment.put("R_TASK_REL_AIR_TASK", list(
"空中任务文案命中时累加 airTaskScore×weight 分值"));
equipment.put("R_TASK_REL_GROUND_TASK", list(
"地面任务与红方偏好同时命中时累加 groundScore×weight 分值"));
equipment.put("R_TASK_REL_TANK", list(
"坦克类关键词匹配时累加 tankScore×weight 分值"));
equipment.put("R_TASK_REL_MISSILE", list(
"导弹类关键词匹配时累加 missileScore×weight 分值"));
BY_MODULE_AND_RULE.put("equipment", Collections.unmodifiableMap(equipment));
Map<String, List<String>> target = new HashMap<>();
target.put("R_TASK_ASSIGN_TARGET", list(
"填充任务 execute 中目标列表与默认行动类型executeTypeDefault 等)",
"按命中率映射与分配模式更新 targetId / 目标数量"));
target.put("R_TASK_LIMIT_SUPPLEMENT", list(
"命中率低于阈值时按轮次与件数上限补充选取装备并回写任务武器列表"));
BY_MODULE_AND_RULE.put("target", Collections.unmodifiableMap(target));
Map<String, List<String>> position = new HashMap<>();
position.put("R_PLATFORM_DEPLOY", list(
"计算并写回红方 SubComponents.platform[].positions部署点、编队间距与高度"));
position.put("R_PLATFORM_SPACETIME", list(
"在启用作战区约束时,将越界阵位回拉到 warZoneLocation 多边形内"));
BY_MODULE_AND_RULE.put("position", Collections.unmodifiableMap(position));
Map<String, List<String>> track = new HashMap<>();
track.put("R_ACTION_TRACK_ROUTE", list(
"生成/更新 TrackParam 中航迹节点,并为 execute 绑定 moveRouteId",
"按算法followBlue 等)变形蓝方航迹并写航迹名称后缀"));
track.put("R_ACTION_TRACK_SPACETIME", list(
"在启用约束时将航迹点限制在作战区边界内"));
BY_MODULE_AND_RULE.put("track", Collections.unmodifiableMap(track));
}
private RuleFourBlockRuleOutputCatalog() {
}
private static List<String> list(String... lines) {
List<String> l = new ArrayList<>();
for (String s : lines) {
if (s != null && !s.isEmpty()) {
l.add(s);
}
}
return Collections.unmodifiableList(l);
}
/**
* @param moduleCode equipment / target / position / track
* @param ruleCode rule_item.rule_code
*/
public static List<String> outputsForRule(String moduleCode, String ruleCode) {
if (moduleCode == null || ruleCode == null) {
return Collections.emptyList();
}
Map<String, List<String>> m = BY_MODULE_AND_RULE.get(moduleCode);
if (m == null) {
return Collections.emptyList();
}
List<String> list = m.get(ruleCode);
return list != null ? list : Collections.emptyList();
}
}

View File

@@ -0,0 +1,72 @@
package com.solution.rule.domain.config.graph;
import cn.hutool.core.util.ObjectUtil;
/**
* 参数在「产出」列中的一行静态说明(与 globalParams 生效值拼接展示)。
*/
public final class RuleParamOutputHint {
private static final String GENERIC = "写入 globalParams 并由当前块对应 Java 算子读取";
private RuleParamOutputHint() {
}
public static String effectLine(String paramKey, String moduleCode) {
if (ObjectUtil.isEmpty(paramKey)) {
return GENERIC;
}
String k = paramKey.trim();
switch (k) {
case "weight":
return "参与装备/目标等环节的评分加权";
case "minSelectedScore":
return "低于该分则本规则对应环节不选中/不生效";
case "tieBreak":
return "同分时的决胜策略(如按装备 ID";
case "outputDrawNameSuffix":
return "影响任务/输出名称后缀展示";
case "ruleSlotCount":
return "规则槽数量上限(装备匹配)";
case "executeTypeDefault":
return "写入任务 execute 的默认行动类型";
case "targetPickMode":
return "目标分配挑选模式(轮询/随机等)";
case "radToTargetsCsv":
return "命中率到目标数量的映射";
case "redHitRateThreshold":
return "命中率阈值,触发补拿等逻辑";
case "maxExtraWeaponsPerTask":
case "maxSupplementRounds":
case "extraPickMinScore":
return "补拿装备轮次与分数约束";
case "positionRuleEnabled":
case "trackRuleEnabled":
case "groupRuleEnabled":
return "控制对应环节是否执行";
case "deployDistanceKmMin":
case "deployDistanceKmMax":
case "deployDistanceKmDefault":
return "部署距离区间与默认值(阵位)";
case "formationType":
case "formationSpacingMeters":
return "编队几何与间距";
case "enableWarZoneClamp":
case "enableTrackWarZoneClamp":
return "作战区约束开关";
case "trackRouteAlgorithm":
return "航迹生成算法选择";
default:
if (k.startsWith("blueRuleKeywords_") || k.startsWith("redRuleKeywords_") || k.startsWith("ruleScore_")) {
return "规则槽关键词/分值,参与装备匹配打分";
}
if (ObjectUtil.isNotEmpty(moduleCode) && "track".equals(moduleCode) && k.toLowerCase().startsWith("track")) {
return "航迹生成与约束相关配置";
}
if (ObjectUtil.isNotEmpty(moduleCode) && "position".equals(moduleCode)) {
return "阵位/部署与空间约束相关配置";
}
return GENERIC;
}
}
}

View File

@@ -0,0 +1,40 @@
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.List;
@Data
@ApiModel("单个分块(环形参数 + If/Then 中枢)")
public class RuleFourBlockClusterVO implements Serializable {
@ApiModelProperty("块标识equipment/target/position/track")
private String blockId;
@ApiModelProperty("模块编码,对应 rule_item.module_code")
private String moduleCode;
@ApiModelProperty("界面标题(如 装备匹配)")
private String title;
@ApiModelProperty("Drools 规则名(如 装备匹配)")
private String droolsRuleName;
@ApiModelProperty("salience")
private Integer salience;
@ApiModelProperty("Drools when 表达式摘要")
private String whenExpr;
@ApiModelProperty("then 动作(含 mergeDefaultParams + Java 调用)")
private String thenAction;
@ApiModelProperty("中枢 + 环形参数节点与边")
private RuleGraphVO graph;
@ApiModelProperty("该块内每条规则的每个参数When/Then/产出(表格)")
private List<RuleFourBlockParamRowVO> paramRows;
}

View File

@@ -0,0 +1,33 @@
package com.solution.rule.domain.config.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@Data
@ApiModel("四块分区内单条参数展示行")
public class RuleFourBlockParamRowVO implements Serializable {
@ApiModelProperty("规则编码")
private String ruleCode;
@ApiModelProperty("规则名称")
private String ruleName;
@ApiModelProperty("参数键")
private String paramKey;
@ApiModelProperty("参数业务名")
private String paramName;
@ApiModelProperty("When规则条件或块级回退")
private String whenText;
@ApiModelProperty("Then规则动作或块级回退")
private String thenText;
@ApiModelProperty("产出说明(生效值 + 静态提示)")
private String outputText;
}

View File

@@ -0,0 +1,28 @@
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Data
@ApiModel("四块规则知识图谱(装备/目标/阵位/航迹)")
public class RuleFourBlocksGraphVO implements Serializable {
@ApiModelProperty("与运行时 KieSession globalParams 一致的参数快照(启用规则与参数合并后)")
private Map<String, Object> globalParamsPreview;
@ApiModelProperty("四个分块,每块含中枢 If/Then 与子图节点边")
private List<RuleFourBlockClusterVO> blocks;
public Map<String, Object> safeGlobalParamsPreview() {
if (globalParamsPreview == null) {
globalParamsPreview = new LinkedHashMap<>();
}
return globalParamsPreview;
}
}

View File

@@ -0,0 +1,309 @@
<!--
- 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-block-neo-graph">
<div v-if="emptyHint" class="rule-block-neo-graph__empty">{{ emptyHint }}</div>
<div v-show="!emptyHint" ref="hostRef" class="rule-block-neo-graph__host" />
</div>
</template>
<script setup lang="ts">
import { Graph } from '@antv/g6';
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { RuleGraphEdge, RuleGraphNode, RuleGraphPayload } from './types';
const props = defineProps<{
payload: RuleGraphPayload,
accent?: string,
}>();
const hostRef = ref<HTMLDivElement | null>(null);
const emptyHint = ref<string | null>(null);
let graph: Graph | null = null;
let resizeObserver: ResizeObserver | null = null;
const toGraphData = (payload: RuleGraphPayload) => ({
nodes: (payload.nodes ?? []).map((n: RuleGraphNode) => ({
id: n.id,
data: {
label: n.label,
nodeType: n.nodeType,
payload: n.payload ?? {},
},
})),
edges: (payload.edges ?? []).map((e: RuleGraphEdge) => ({
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 nodeSize = (nodeType?: string) => {
if (nodeType === 'drools_facade' || nodeType === 'logicCore') {
return 36;
}
if (nodeType === 'compute_step') {
return 30;
}
if (nodeType === 'rule_row') {
return 26;
}
if (nodeType === 'param') {
return 20;
}
if (nodeType === 'param_condition') {
return 15;
}
if (nodeType === 'rule_output') {
return 19;
}
return 22;
};
const buildGraph = async () => {
await disposeGraph();
await nextTick();
const el = hostRef.value;
const raw = props.payload;
if (!el || !raw?.nodes?.length) {
emptyHint.value = raw && raw.nodes?.length === 0 ? '暂无图谱节点' : null;
return;
}
emptyHint.value = null;
const accent = props.accent ?? '#5B8FF9';
const fillFor = (nodeType?: string) => {
if (nodeType === 'drools_facade' || nodeType === 'logicCore') {
return accent;
}
if (nodeType === 'compute_step') {
return '#ffe58f';
}
if (nodeType === 'rule_row') {
return '#36cfc9';
}
if (nodeType === 'param') {
return '#b37feb';
}
if (nodeType === 'param_condition') {
return '#5cdbd3';
}
if (nodeType === 'rule_output') {
return '#fa8c16';
}
return '#8c8c8c';
};
const labelMaxFor = (nodeType?: string) => {
if (nodeType === 'compute_step') {
return 200;
}
if (nodeType === 'rule_output') {
return 190;
}
if (nodeType === 'param_condition') {
return 120;
}
return 140;
};
const labelSizeFor = (nodeType?: string) => {
if (nodeType === 'compute_step') {
return 8;
}
if (nodeType === 'rule_output') {
return 8;
}
if (nodeType === 'param_condition') {
return 7;
}
if (nodeType === 'drools_facade' || nodeType === 'logicCore') {
return 11;
}
return 9;
};
const width = Math.max(el.clientWidth, 160);
const height = Math.max(el.clientHeight, 260);
const data = toGraphData(raw);
const useRadial = raw.layoutHint === 'radial_hub' && !!raw.focusNodeId;
const layoutOpts = useRadial
? {
type: 'radial' as const,
focusNode: raw.focusNodeId,
unitRadius: 88,
preventOverlap: true,
}
: {
type: 'd3-force' as const,
};
graph = new Graph({
container: el,
width,
height,
data,
layout: layoutOpts,
node: {
style: {
size: (d: { data?: { nodeType?: string } }) => nodeSize(d.data?.nodeType),
fill: (d: { data?: { nodeType?: string } }) => fillFor(d.data?.nodeType),
labelText: (d: { data?: { label?: string }; id: string }) => d.data?.label ?? String(d.id),
labelFill: (d: { data?: { nodeType?: string } }) => {
const t = d.data?.nodeType;
if (t === 'compute_step') {
return '#3d2f00';
}
if (t === 'rule_output') {
return '#2b1604';
}
if (t === 'param_condition') {
return '#06302b';
}
return '#e8f4f8';
},
labelFontSize: (d: { data?: { nodeType?: string } }) => labelSizeFor(d.data?.nodeType),
labelMaxWidth: (d: { data?: { nodeType?: string } }) => labelMaxFor(d.data?.nodeType),
labelWordWrap: true,
lineWidth: 1.2,
stroke: '#050d14',
},
},
edge: {
style: {
stroke: (d: { data?: { edgeType?: string } }) => {
const t = d.data?.edgeType;
if (t === 'compute_flow') {
return 'rgba(149, 222, 100, 0.85)';
}
if (t === 'rule_priority_next') {
return '#faad14';
}
if (t === 'drools_contains') {
return 'rgba(91, 143, 249, 0.75)';
}
if (t === 'rule_has_param') {
return 'rgba(179, 127, 235, 0.55)';
}
if (t === 'rule_produces') {
return 'rgba(250, 173, 20, 0.75)';
}
if (t === 'condition_applies') {
return 'rgba(92, 219, 211, 0.65)';
}
return 'rgba(150, 175, 190, 0.45)';
},
lineWidth: (d: { data?: { edgeType?: string } }) => {
const t = d.data?.edgeType;
if (t === 'rule_priority_next' || t === 'compute_flow') {
return 2;
}
if (t === 'rule_produces') {
return 1.5;
}
if (t === 'condition_applies') {
return 1.2;
}
return 1;
},
endArrow: true,
labelText: (d: { data?: { label?: string } }) => {
const s = d.data?.label;
return s && String(s).length > 0 ? String(s) : '';
},
labelFill: '#9fb8c4',
labelFontSize: 9,
},
},
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, 160);
const h = Math.max(hostRef.value.clientHeight, 260);
graph.setSize(w, h);
void graph.fitView();
});
resizeObserver.observe(el);
};
watch(
() => [props.payload, props.accent],
() => {
void buildGraph();
},
{ deep: true },
);
onMounted(() => {
void buildGraph();
});
onBeforeUnmount(() => {
void disposeGraph();
});
</script>
<style scoped lang="less">
.rule-block-neo-graph {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
}
.rule-block-neo-graph__host {
flex: 1;
min-height: 260px;
width: 100%;
border-radius: 8px;
background: #0a1620;
border: 1px solid rgba(80, 120, 150, 0.35);
}
.rule-block-neo-graph__empty {
flex: 1;
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #7a8d96;
border-radius: 8px;
background: #0a1620;
border: 1px dashed rgba(80, 120, 150, 0.35);
}
</style>

View File

@@ -0,0 +1,294 @@
<!--
- 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-four-blocks">
<div class="rule-four-blocks__toolbar">
<a-popover placement="bottomLeft" trigger="click" overlay-class-name="rule-four-blocks-snapshot-popover">
<template #content>
<pre class="rule-four-blocks__popover-json">{{ previewJson }}</pre>
</template>
<a-button type="link" size="small" class="rule-four-blocks__snapshot-btn">
全局参数快照{{ previewEntryCount }}
</a-button>
</a-popover>
</div>
<div v-if="errorMsg" class="rule-four-blocks__banner rule-four-blocks__banner--error">
{{ errorMsg }}
</div>
<div v-else-if="emptyHint" class="rule-four-blocks__banner">
{{ emptyHint }}
</div>
<div v-else-if="payload" class="rule-four-blocks__grid">
<div
v-for="block in payload.blocks"
:key="block.blockId"
class="rule-four-blocks__pane"
:class="{ 'rule-four-blocks__pane--fullscreen': isPaneFullscreen(block.blockId) }"
:ref="(el) => registerPaneRef(block.blockId, el)"
>
<div class="rule-four-blocks__pane-bar">
<a-button
type="link"
size="small"
class="rule-four-blocks__pane-fs-btn"
:title="isPaneFullscreen(block.blockId) ? '退出本格全屏' : '本格全屏'"
@click="togglePaneFullscreen(block.blockId)"
>
<template #icon>
<FullscreenExitOutlined v-if="isPaneFullscreen(block.blockId)" />
<FullscreenOutlined v-else />
</template>
</a-button>
</div>
<div class="rule-four-blocks__pane-body">
<RuleBlockNeoGraph
class="rule-four-blocks__cell"
:payload="block.graph"
:accent="blockColor(block.blockId)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { findRuleFourBlocksGraph } from './api';
import RuleBlockNeoGraph from './RuleBlockNeoGraph.vue';
import type { RuleFourBlocksPayload } from './types';
const props = defineProps<{
refreshKey: number,
}>();
const payload = ref<RuleFourBlocksPayload | null>(null);
const errorMsg = ref<string | null>(null);
const emptyHint = ref<string | null>(null);
const paneRefs = new Map<string, HTMLElement>();
const paneFsTick = ref(0);
const BLOCK_COLORS: Record<string, string> = {
equipment: '#5B8FF9',
target: '#F6903D',
position: '#61DDAA',
track: '#9270CA',
};
const blockColor = (blockId: string) => BLOCK_COLORS[blockId] ?? '#65789B';
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 registerPaneRef = (blockId: string, el: unknown) => {
if (el == null) {
paneRefs.delete(blockId);
return;
}
if (el instanceof HTMLElement) {
paneRefs.set(blockId, el);
}
};
const isPaneFullscreen = (blockId: string) => {
void paneFsTick.value;
const node = paneRefs.get(blockId);
return Boolean(node && document.fullscreenElement === node);
};
const onDocumentFullscreenChange = () => {
paneFsTick.value += 1;
void nextTick(() => {
window.dispatchEvent(new Event('resize'));
window.setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 120);
});
};
const togglePaneFullscreen = async (blockId: string) => {
const el = paneRefs.get(blockId);
if (!el) {
return;
}
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
} catch {
message.warning('无法切换本格全屏,请重试或使用 Chrome / Edge');
}
};
const load = async () => {
errorMsg.value = null;
emptyHint.value = null;
payload.value = null;
try {
const r = await findRuleFourBlocksGraph();
if (r.code !== 200 || !r.data) {
errorMsg.value = r.msg ?? '加载四块图谱失败';
return;
}
const data = r.data;
if (!data.blocks?.length) {
emptyHint.value = '暂无四块图谱数据';
return;
}
payload.value = data;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
errorMsg.value = `四块图谱请求失败:${msg}`;
message.error(errorMsg.value);
}
};
watch(
() => props.refreshKey,
() => {
void load();
},
{ immediate: true },
);
onMounted(() => {
document.addEventListener('fullscreenchange', onDocumentFullscreenChange);
});
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', onDocumentFullscreenChange);
});
</script>
<style scoped lang="less">
.rule-four-blocks {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
}
.rule-four-blocks__toolbar {
flex: 0 0 auto;
padding: 0 2px;
}
.rule-four-blocks__snapshot-btn {
padding-left: 0;
font-size: 11px;
color: #8fafbd;
}
.rule-four-blocks__banner {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
font-size: 13px;
color: #a2b1ba;
text-align: center;
}
.rule-four-blocks__banner--error {
color: #ff9c9c;
}
.rule-four-blocks__grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 6px;
width: 100%;
}
.rule-four-blocks__pane {
display: flex;
flex-direction: column;
min-height: max(0px, calc(50vh - 56px));
border-radius: 6px;
border: 1px solid rgba(80, 120, 150, 0.25);
background: rgba(8, 20, 30, 0.5);
overflow: hidden;
}
.rule-four-blocks__pane--fullscreen {
border-color: transparent;
background: #0d1f2c;
}
.rule-four-blocks__pane-bar {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 2px 4px 0;
}
.rule-four-blocks__pane-fs-btn {
color: #8fafbd;
padding: 0 4px;
}
.rule-four-blocks__pane-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 6px 6px;
}
.rule-four-blocks__cell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
</style>
<style lang="less">
.rule-four-blocks-snapshot-popover {
.ant-popover-inner-content {
max-width: min(520px, 90vw);
max-height: 70vh;
overflow: auto;
}
}
.rule-four-blocks__popover-json {
margin: 0;
font-size: 10px;
line-height: 1.35;
color: #c9dfe8;
white-space: pre-wrap;
}
</style>