CMVR-BID/src/views/flow/nodes/function/switch.vue
zhanghao a59028227f feat: update styles for a retro theme across the application
- 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.
2026-08-28 08:47:39 +08:00

322 lines
9.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
* 文件说明:条件分支节点,展示已保存的条件摘要并同步每个条件对应的画布锚点。
* 作用范围:仅服务于流程设计器模块;条件编辑由 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>