forked from SmartBench/CMVR-IOT-UI
- Changed edge colors in vue-flow adapter for a cohesive retro look. - Updated feature colors and backgrounds in index.vue to match the new theme. - Enhanced login page styles with retro textures and colors. - Introduced a new retro.css file to define a comprehensive color palette and styles. - Adjusted various components to utilize the new retro styles, including buttons, inputs, and cards. - Improved responsiveness and accessibility in the login form and other UI elements.
322 lines
9.6 KiB
Vue
322 lines
9.6 KiB
Vue
<!--
|
||
* 文件说明:条件分支节点,展示已保存的条件摘要并同步每个条件对应的画布锚点。
|
||
* 作用范围:仅服务于流程设计器模块;条件编辑由 SwitchNodeParams 组件独立负责。
|
||
-->
|
||
<template>
|
||
<div class="node__container" :class="props.model.id" ref="switchRef">
|
||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||
<template #input>
|
||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||
</template>
|
||
<template #output>
|
||
<div v-if="nodeOperatingStatus !== 'FAILED'">
|
||
<JsonViewer :value="outputJsonData" copyable boxed sort theme="light" />
|
||
</div>
|
||
<div v-else>{{ errorInfoData }}</div>
|
||
</template>
|
||
</NodeState>
|
||
|
||
<NodeTitle
|
||
:icon="switchSvg"
|
||
:nodeId="props.model.id"
|
||
:nodeProperties="props.properties"
|
||
:nodeName="props.properties?.name || '分支'"
|
||
nodeDesc="双击节点配置分支条件;流程将按顺序匹配条件,并只运行首个匹配条件对应的分支"
|
||
@setNodeName="setNodeName"
|
||
/>
|
||
|
||
<div class="condition-summary">
|
||
<div class="condition-summary__title">
|
||
<span>分支条件</span>
|
||
<span class="condition-summary__count">{{ conditions.length }} 个</span>
|
||
</div>
|
||
|
||
<div
|
||
v-for="(condition, index) in conditions"
|
||
:id="condition.id"
|
||
:key="condition.id"
|
||
:data-branch-anchor-id="condition.id"
|
||
class="condition-summary__item"
|
||
>
|
||
<span class="condition-summary__order">{{ condition.label || `条件 ${index + 1}` }}</span>
|
||
<span class="condition-summary__text">{{ conditionSummary(condition) }}</span>
|
||
</div>
|
||
<div
|
||
:id="`${props.model.id}_else`"
|
||
:data-branch-anchor-id="`${props.model.id}_else`"
|
||
class="condition-summary__item condition-summary__item--else"
|
||
>
|
||
<span class="condition-summary__order">Else</span>
|
||
<span class="condition-summary__text">以上条件均不满足时执行</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||
import NodeTitle from "../../components/NodeTitle.vue";
|
||
import NodeState from "../../components/NodeState.vue";
|
||
import switchSvg from "../../icon/switch.svg";
|
||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||
|
||
const props = defineProps({
|
||
model: Object,
|
||
graphModel: Object,
|
||
properties: Object,
|
||
nowTime: Number,
|
||
});
|
||
|
||
const emits = defineEmits(["contentChange", "syncAnchors"]);
|
||
const { onFlowEvent } = useFlowEvents();
|
||
const switchRef = ref(null);
|
||
const nodeStateRef = ref(null);
|
||
const conditions = computed(() => props.properties?.nodeParams || props.properties?.conditions || []);
|
||
|
||
const nodeOperatingStatus = ref("NORMAL");
|
||
const runtimes = ref(0);
|
||
const inputJsonData = ref({});
|
||
const outputJsonData = ref({});
|
||
const errorInfoData = ref("");
|
||
let anchorSyncFrame = null;
|
||
let resizeObserver = null;
|
||
|
||
const conditionTextMap = {
|
||
equal: "等于",
|
||
notEqualTo: "不等于",
|
||
null: "为空",
|
||
notNull: "不为空",
|
||
include: "包含",
|
||
notInclude: "不包含",
|
||
greaterThan: "大于",
|
||
greaterThanOrEqual: "大于等于",
|
||
lessThan: "小于",
|
||
lessThanOrEqual: "小于等于",
|
||
};
|
||
|
||
/**
|
||
* 生成画布卡片中的完整条件说明,多个判断项按配置的 AND/OR 关系连接。
|
||
* @param {object} condition 当前条件组
|
||
* @returns {string} 可直接展示的条件表达式
|
||
*/
|
||
const conditionSummary = (condition) => {
|
||
if (!condition.list?.length) return "未配置判断";
|
||
const relation = condition.withOr === "or" ? " OR " : " AND ";
|
||
return condition.list.map((item) => {
|
||
const left = item.nameType === "quote"
|
||
? formatQuote(item.nameQuote)
|
||
: String(item.name || "未填写变量");
|
||
const operator = conditionTextMap[item.condition] || "未选择判断";
|
||
if (["null", "notNull"].includes(item.condition)) return `${left} ${operator}`;
|
||
const right = item.type === "quote"
|
||
? formatQuote(item.quote)
|
||
: String(item.input ?? "未填写比较值");
|
||
return `${left} ${operator} ${right}`;
|
||
}).join(relation);
|
||
};
|
||
|
||
/**
|
||
* 将级联引用路径格式化为易读文本。
|
||
* @param {string|string[]} quote 引用路径
|
||
* @returns {string} 点号连接的引用名称
|
||
*/
|
||
const formatQuote = (quote) => {
|
||
const path = Array.isArray(quote)
|
||
? quote.filter(Boolean)
|
||
: String(quote || "").split(".").filter(Boolean);
|
||
if (!path.length) return "未选择引用";
|
||
|
||
const [nodeId, direction, ...fieldPath] = path;
|
||
const graphNode = window.lf?.getGraphData().nodes.find((node) => node.id === nodeId);
|
||
const nodeName = graphNode?.properties?.name || graphNode?.name || nodeId;
|
||
const directionName = direction === "input"
|
||
? "输入"
|
||
: direction === "output" ? "输出" : direction;
|
||
return [nodeName, directionName, ...fieldPath].filter(Boolean).join(".");
|
||
};
|
||
|
||
/**
|
||
* 根据摘要项的实际纵向中心同步分支锚点;范围仅限当前分支节点。
|
||
*/
|
||
const syncAnchorPositions = () => {
|
||
const root = switchRef.value;
|
||
if (!root) return;
|
||
// Handle 的 top 相对于 FlowNode 外壳定位,不能使用 switch 根元素作为坐标原点。
|
||
// 外层卡片存在 padding;若以 rootRect.top 计算,所有锚点都会统一向上偏移。
|
||
const positioningParent = root.closest(".flow-node-shell");
|
||
if (!positioningParent) return;
|
||
const parentRect = positioningParent.getBoundingClientRect();
|
||
const scale = positioningParent.offsetWidth > 0
|
||
? parentRect.width / positioningParent.offsetWidth
|
||
: 1;
|
||
const positions = [...root.querySelectorAll("[data-branch-anchor-id]")].map((element) => {
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
id: element.dataset.branchAnchorId,
|
||
height: Math.round(((rect.top - parentRect.top + rect.height / 2) / scale) * 100) / 100,
|
||
};
|
||
});
|
||
emits("syncAnchors", positions);
|
||
};
|
||
|
||
/**
|
||
* 将同一帧内的多次尺寸变化合并为一次锚点同步;范围仅限当前分支节点。
|
||
*/
|
||
const scheduleAnchorSync = () => {
|
||
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
|
||
anchorSyncFrame = requestAnimationFrame(() => {
|
||
anchorSyncFrame = null;
|
||
syncAnchorPositions();
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 保存节点名称;范围仅限当前分支节点。
|
||
* @param {string} name 新的节点名称
|
||
*/
|
||
const setNodeName = (name) => {
|
||
const properties = window.lf.getProperties(props.model.id);
|
||
window.lf.setProperties(props.model.id, { ...properties, name });
|
||
emits("contentChange");
|
||
};
|
||
|
||
/**
|
||
* 关闭节点运行状态浮层;范围仅限当前分支节点。
|
||
*/
|
||
const closePopover = () => {
|
||
nodeStateRef.value?.closePopover();
|
||
};
|
||
|
||
watch(
|
||
() => conditions.value.map((condition) => condition.id),
|
||
() => nextTick(scheduleAnchorSync),
|
||
{ immediate: true },
|
||
);
|
||
|
||
onMounted(() => {
|
||
nextTick(() => {
|
||
scheduleAnchorSync();
|
||
resizeObserver = new ResizeObserver(scheduleAnchorSync);
|
||
if (switchRef.value) resizeObserver.observe(switchRef.value);
|
||
});
|
||
|
||
onFlowEvent("changeNodeState", (data) => {
|
||
if (data.nodeId !== props.model.id) return;
|
||
nodeOperatingStatus.value = data.status;
|
||
if (data.endTime && data.startTime) runtimes.value = data.endTime - data.startTime;
|
||
errorInfoData.value = data?.message || "";
|
||
try {
|
||
inputJsonData.value = JSON.parse(data.paramsIn) || {};
|
||
outputJsonData.value = JSON.parse(data.paramsOut) || {};
|
||
} catch {
|
||
inputJsonData.value = data.paramsIn || {};
|
||
outputJsonData.value = data.paramsOut || {};
|
||
}
|
||
emits("contentChange");
|
||
});
|
||
|
||
onFlowEvent("contentChange", (data) => {
|
||
if (data.id !== props.model.id) return;
|
||
nodeOperatingStatus.value = "NORMAL";
|
||
inputJsonData.value = {};
|
||
outputJsonData.value = {};
|
||
emits("contentChange");
|
||
});
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
resizeObserver?.disconnect();
|
||
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
|
||
});
|
||
|
||
defineExpose({ closePopover });
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.node__container {
|
||
width: 100%;
|
||
min-height: 180px;
|
||
padding: 16px;
|
||
border: 2px solid var(--retro-border-dark);
|
||
border-left: 6px solid var(--node-accent, #a06a1e);
|
||
border-radius: 0;
|
||
background-color: var(--retro-bg-white);
|
||
background-image: var(--retro-paper-texture);
|
||
// box-shadow: 0 8px 24px rgba(31, 41, 55, 0.09);
|
||
box-sizing: border-box;
|
||
cursor: default;
|
||
}
|
||
|
||
.condition-summary {
|
||
margin-top: 14px;
|
||
padding-top: 12px;
|
||
border-top: 1px dashed var(--retro-border-dark);
|
||
}
|
||
|
||
.condition-summary__title,
|
||
.condition-summary__item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.condition-summary__title {
|
||
margin-bottom: 8px;
|
||
color: var(--retro-text-primary);
|
||
font-family: var(--retro-font-display);
|
||
font-size: 13px;
|
||
font-weight: 650;
|
||
}
|
||
|
||
.condition-summary__count {
|
||
color: var(--retro-text-secondary);
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.condition-summary__item {
|
||
min-height: 44px;
|
||
margin-top: 6px;
|
||
padding: 0 10px;
|
||
border: 1px solid var(--retro-border-dark);
|
||
border-radius: 0;
|
||
background: #f0e6cf;
|
||
position: relative;
|
||
|
||
&::after {
|
||
content: "";
|
||
width: 17px;
|
||
position: absolute;
|
||
top: 50%;
|
||
right: -18px;
|
||
border-top: 2px solid var(--node-accent, #a06a1e);
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.condition-summary__item--else {
|
||
background: #f1dec0;
|
||
}
|
||
|
||
.condition-summary__order {
|
||
width: 96px;
|
||
flex: 0 0 96px;
|
||
color: var(--retro-primary-dark);
|
||
font-size: 12px;
|
||
font-weight: 650;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.condition-summary__text {
|
||
flex: 1;
|
||
color: var(--retro-text-secondary);
|
||
font-size: 12px;
|
||
line-height: 18px;
|
||
overflow-wrap: anywhere;
|
||
text-align: right;
|
||
white-space: normal;
|
||
}
|
||
</style>
|