Initial commit

This commit is contained in:
libertyspy
2026-02-08 15:38:50 +08:00
parent b67f493678
commit 015030d650
27 changed files with 1496 additions and 35 deletions

View File

@@ -0,0 +1,61 @@
/*
* 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.
*/
import type { NullableString } from '@/types';
import type { ModelParameters } from '@/views/ai/model/types';
export interface DraggableElement {
id: number | null,
key?: NullableString,
name: NullableString,
description: NullableString,
category: NullableString,
draggable: boolean,
parent?: DraggableElement,
children: DraggableElement[]
[key: string]: unknown;
}
export interface ModelElementPosition {
x: number;
y: number;
}
export interface ModelElementEdge {
key: NullableString;
sourceKey: NullableString;
sourceName: NullableString;
targetKey: NullableString;
targetName: NullableString;
}
export interface ModelBaseElement {
key: string;
name: string;
type: string;
width: number;
height: number;
position: ModelElementPosition;
category: NullableString;
element?: DraggableElement;
[key: string]: unknown;
}
export interface ModelElement extends ModelBaseElement {
// 连线
edges: ModelElementEdge[];
// 模型参数设置
parameters: ModelParameters;
}
export interface SavedGraphData {
nodes: ModelElement[];
edges: any[];
}

View File

@@ -0,0 +1,177 @@
/*
* This file is part of the kernelstudio package.
*
* (c) 2014-2025 zlin <admin@kernelstudio.com>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/
import { Edge, Graph, Path, Selection } from '@antv/x6';
import type { ModelElement } from './element';
import type { Connecting } from '@antv/x6/lib/graph/options';
import { createLineOptions } from './line';
Graph.registerConnector(
'sequenceFlowConnector',
(s, e) => {
const offset = 4;
const deltaY = Math.abs(e.y - s.y);
const control = Math.floor((deltaY / 3) * 2);
const v1 = { x: s.x, y: s.y + offset + control };
const v2 = { x: e.x, y: e.y - offset - control };
return Path.parse(
`
M ${s.x} ${s.y}
L ${s.x} ${s.y + offset}
C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${e.x} ${e.y - offset}
L ${e.x} ${e.y}
`,
).serialize();
},
true,
);
export const createGraphConnectingAttributes = (): Partial<Connecting> => {
const lineOptions = createLineOptions();
return {
snap: true, // 当 snap 设置为 true 时连线的过程中距离节点或者连接桩 50px 时会触发自动吸附
allowBlank: false, // 是否允许连接到画布空白位置的点,默认为 true
allowLoop: false, // 是否允许创建循环连线,即边的起始节点和终止节点为同一节点,默认为 true
highlight: true, // 当连接到节点时,通过 sourceAnchor 来指定源节点的锚点。
connector: 'smooth',
connectionPoint: 'anchor', // 指定连接点,默认值为 boundary。
anchor: 'center',
// validateMagnet({ magnet }) {
// return magnet.getAttribute('port-group') !== 'top'
// },
// 验证连接
createEdge(this: Graph) {
const edge: Edge = this.createEdge({
shape: 'edge',
...lineOptions, // 应用动画配置
attrs: lineOptions.attrs,
animation: lineOptions.animation,
markup: lineOptions.markup,
})
return edge;
},
validateConnection(this: Graph, { sourceCell, targetCell }) {
console.error('validateConnection');
if (!sourceCell || !targetCell) return false;
// 核心逻辑:禁止节点连接自己(自环)
if (sourceCell === targetCell) {
return false;
}
// const sourceData = sourceCell.getData() as GraphElement;
const targetData = targetCell.getData() as ModelElement;
// 根节点不能作为子节点
if (targetData.type === 'startEvent') {
return false;
}
// 4. 新增核心逻辑:检查源节点是否已有出边(已连接其他节点)
// const hasOutgoingEdge = this.getOutgoingEdges(sourceCell);
// if (hasOutgoingEdge && hasOutgoingEdge.length > 1) {
// return false;
// }
// 检查是否已存在相同连接
// const edges: Edge[] = this.getEdges();
// const existingConnection = edges.find(edge =>
// edge.getSourceCell() === sourceCell &&
// edge.getTargetCell() === targetCell,
// );
//
// return !existingConnection;
return true;
},
};
};
export const createGraphCanvas = (container: HTMLDivElement, readonly: boolean = false): Graph => {
const graph = new Graph({
container: container,
grid: {
size: 20,
visible: true,
type: 'dot',
// color: '#e5e7eb'
},
// 确保启用了异步渲染
async: true,
panning: {
enabled: true,
eventTypes: ['leftMouseDown', 'mouseWheel'],
},
mousewheel: {
enabled: true,
modifiers: 'ctrl',
factor: 1.1,
maxScale: 1.5,
minScale: 0.5,
},
highlighting: {
magnetAdsorbed: {
name: 'stroke',
args: {
attrs: {
fill: '#fff',
stroke: '#31d0c6',
strokeWidth: 4,
},
},
},
},
connecting: createGraphConnectingAttributes(),
scaling: {
min: 0.5,
max: 2,
},
// 背景配置
background: {},
// 交互配置
interacting: (cellView) => {
if (readonly) {
return false; // 只读模式下禁用所有交互
}
// 确保边edge的顶点交互权限开启
if (cellView.cell.isEdge()) {
return {
vertexAddable: true, // 允许添加顶点
vertexMovable: true, // 允许移动顶点
vertexDeletable: true, // 允许删除顶点
edgeMovable: true, // 允许整体拖动连线
arrowheadMovable: true, // 允许拖动箭头调整端点
};
}
// 节点的交互配置(保持不变)
return {
nodeMovable: true,
};
},
});
graph.use(
new Selection({
multiple: true,
rubberEdge: true,
rubberNode: true,
modifiers: 'shift',
rubberband: true,
}),
);
return graph;
};

View File

@@ -0,0 +1,250 @@
/*
* This file is part of the kernelstudio package.
*
* (c) 2014-2025 zlin <admin@kernelstudio.com>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/
import { computed, type ComputedRef, ref, type Ref } from 'vue';
import { type Dom, Graph, Node } from '@antv/x6';
import type { NodeViewPositionEventArgs } from '@antv/x6/es/view/node/type';
import { createGraphCanvas } from './graph';
import { EventListener } from '@/utils/event';
import type { ModelElement } from './element';
// import {createLineOptions} from './line'
export interface UseGraphCanvas {
container: Ref<HTMLDivElement | null>;
readonly: boolean;
eventListener: EventListener;
currentZoom: Ref<number>;
graph: ComputedRef<Graph>;
zoomIn: () => void;
zoomOut: () => void;
fitToScreen: () => void;
centerContent: () => void;
resizeCanvas: () => void;
handleGraphEvent: (name: string, fn: Function) => void;
emitGraphEvent: (name: string, options?: any) => void;
createCanvas: (c: HTMLDivElement) => Graph;
}
export const useGraphCanvas = (readonly: boolean = false): UseGraphCanvas => {
const graph = ref<Graph | null>(null);
const container = ref<HTMLDivElement | null>(null);
const eventListener = new EventListener();
const currentZoom = ref<number>(0);
const handleGraphEvent = (name: string, fn: Function) => {
eventListener.on(name, fn);
};
const emitGraphEvent = (name: string, options?: any): void => {
eventListener.emit(name, options);
};
const zoomIn = (): void => {
if (graph.value) {
const zoom = graph.value.zoom();
graph.value.zoom(zoom + 0.1);
}
};
const zoomOut = (): void => {
if (graph.value) {
const zoom = graph.value.zoom();
graph.value.zoom(Math.max(0.1, zoom - 0.1));
}
};
const fitToScreen = () => {
if (graph.value) {
graph.value.zoomToFit({ padding: 20 });
}
};
const centerContent = () => {
if (graph.value) {
graph.value.centerContent();
}
};
const resizeCanvas = () => {
if (graph.value) {
graph.value?.resize();
}
};
const initEvents = () => {
if (!graph.value) {
return;
}
graph.value.on('keydown', ({ e }: any) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
const cells = graph.value?.getSelectedCells();
if (cells && cells.length) {
graph.value?.removeCells(cells);
// 通知父组件更新状态
emitGraphEvent('cells:removed', cells);
}
}
});
graph.value.on('scale', (ctx) => {
currentZoom.value = ctx.sx;
emitGraphEvent('scale', ctx);
});
// 监听画布空白点击
graph.value.on('blank:click', () => {
graph.value?.cleanSelection();
emitGraphEvent('blank:click');
});
// 监听连线删除
graph.value.on('edge:removed', ({ edge }) => {
emitGraphEvent('edge:removed', edge); // 添加此行
});
graph.value.on('edge:added', ({ edge }) => {
// const lineOptions = createLineOptions();
// edge.setAttrs(lineOptions.attrs);
// edge.set(lineOptions.animation);
// edge.setMarkup(lineOptions.markup);
emitGraphEvent('edge:added', edge); // 添加此行
});
graph.value.on('edge:contextmenu', (ctx) => {
emitGraphEvent('edge:contextmenu', ctx);
});
graph.value.on('edge:connect:abort', () => {
// 当连线被拖拽到空白区域后释放,自动移除这条无效连线
const edges = graph.value?.getEdges();
const invalidEdges = (edges ?? []).filter(edge =>
!edge.getSourceCell() || !edge.getTargetCell(),
);
if (invalidEdges.length) {
graph.value?.removeCells(invalidEdges);
}
});
// 监听连接失败
graph.value.on('edge:connect:invalid', () => {
emitGraphEvent('edge:connect:invalid');
});
graph.value.on('edge:click', (edge) => {
emitGraphEvent('edge:connect:invalid', edge);
});
graph.value.on('edge:connected', ({ edge }) => {
const sourceNode = edge.getSourceCell() as Node;
const targetNode = edge.getTargetCell() as Node;
if (sourceNode && targetNode) {
const sourceData = sourceNode.getData() as ModelElement;
const targetData = targetNode.getData() as ModelElement;
// 将连线存储到节点数据中
const sourceEdges = sourceData.edges || [];
const existingEdge = sourceEdges.find(e => e.targetKey === targetNode.id);
if (!existingEdge) {
sourceEdges.push({
key: edge.id,
sourceKey: sourceNode.id,
sourceName: sourceData.name,
targetKey: targetNode.id,
targetName: targetData.name,
});
sourceNode.replaceData({ ...sourceData, edges: sourceEdges });
}
}
edge.attr('line/stroke', '#3b82f6'); // 显式设置颜色
edge.attr('line/strokeWidth', 2); // 显式设置宽度
// edge.refresh() // 刷新连线
// graph.paint() // 重绘画布
emitGraphEvent('edge:connected', edge);
});
graph.value.on('node:click', (ctx: NodeViewPositionEventArgs<Dom.ClickEvent>) => {
console.info('node click', ctx);
emitGraphEvent('node:click', ctx);
});
// 监听节点选中事件
graph.value.on('node:selected', ({ node }) => {
console.info('node select', node);
emitGraphEvent('node:selected', node);
});
// 监听节点取消选中
graph.value.on('node:unselected', () => {
emitGraphEvent('node:unselected');
});
// 监听节点鼠标移入,显示连接点
graph.value.on('node:mouseenter', (ctx) => {
emitGraphEvent('node:mouseenter', ctx);
});
// 监听节点鼠标移出,隐藏连接点
graph.value.on('node:mouseleave', (ctx) => {
emitGraphEvent('node:mouseleave', ctx);
});
// 监听节点状态变化
graph.value.on('node:change:data', (ctx) => {
const edges = graph.value?.getIncomingEdges(ctx.node);
const { status } = ctx.node.getData();
edges?.forEach((edge) => {
if (status === 'running') {
edge.attr('line/strokeDasharray', 5);
edge.attr('line/style/animation', 'running-line 30s infinite linear');
} else {
edge.attr('line/strokeDasharray', '');
edge.attr('line/style/animation', '');
}
});
emitGraphEvent('node:change:data', ctx);
});
};
const createCanvas = (c: HTMLDivElement): Graph => {
container.value = c;
graph.value = createGraphCanvas(c, readonly);
initEvents();
emitGraphEvent('created', graph.value);
return graph.value as Graph;
};
const graphInstance = computed(() => graph.value);
return {
container,
readonly,
graph: graphInstance,
eventListener,
currentZoom,
emitGraphEvent,
handleGraphEvent,
zoomIn,
zoomOut,
fitToScreen,
centerContent,
resizeCanvas,
createCanvas,
} as UseGraphCanvas;
};

View File

@@ -0,0 +1,11 @@
/*
* This file is part of the kernelstudio package.
*
* (c) 2014-2025 zlin <admin@kernelstudio.com>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/
export { createGraphCanvas } from './graph';
export * from './hooks';

View File

@@ -0,0 +1,47 @@
/*
* 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.
*/
import { Shape } from '@antv/x6';
export const createLineOptions = (): any => {
return {
markup: [
{
tagName: 'circle',
selector: 'marker',
attrs: {
stroke: 'none',
r: 3,
},
},
...Shape.Edge.getMarkup() as any,
],
attrs: {
line: {
stroke: '#5da0df',
strokeWidth: 2,
strokeDasharray: ' ',
strokeDashoffset: 0,
},
marker: {
fill: '#5da0df',
atConnectionRatio: 0,
},
},
animation: [
[
{ 'attrs/marker/atConnectionRatio': 1 },
{
duration: 2000,
iterations: Infinity,
},
],
],
}
}

View File

@@ -0,0 +1,322 @@
<template>
<a-dropdown :trigger="['contextmenu']" @openChange="handleVisibleChange">
<a-card
:class="[
'ks-designer-node',
`ks-designer-${element?.category ?? 'model'}-node`
]"
hoverable
>
<template #title>
<a-space>
<span class="ks-designer-node-icon"></span>
<span class="ks-designer-node-title">{{ element?.name ?? '-' }}</span>
</a-space>
</template>
<!-- 节点内容区域 -->
<div class="w-full" v-if="element?.category !== 'component'">
<div class="ks-designer-node-content">
<div
v-for="(item, index) in element?.element?.children || []"
:key="item.id || index"
class="ks-designer-node-row"
>
<div
:data-port="`in-${item.id || index}`"
:title="`入桩: ${item.name}`"
class="port port-in"
magnet="passive"
></div>
<!-- child名称 -->
<div class="ks-designer-node-name">
{{ item.name }}
</div>
<!-- 右侧出桩只能作为连线源 -->
<div
:data-port="`out-${item.id || index}`"
:title="`出桩: ${item.name}`"
class="port port-out"
magnet="active"
></div>
</div>
<div v-if="!(element?.element?.children && element?.element?.children?.length > 0)" class="ks-designer-node-row">
<div class="port port-in" data-port="in-0" magnet="passive"></div>
<div class="ks-designer-node-name">
{{ element?.name ?? '-' }}
</div>
<div class="port port-out" data-port="out-0" magnet="active"></div>
</div>
</div>
</div>
<div class="w-full" v-else>
<p>隐藏纬度: {{ element?.parameters?.hiddenLatitude ?? '-' }}</p>
<p>激活函数: {{ element?.parameters?.activationFunction ?? '-' }}</p>
</div>
</a-card>
<template #overlay>
<a-menu @click="handleMenuClick">
<a-menu-item key="delete">
<template #icon>
<DeleteOutlined />
</template>
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
import { elementProps } from './props';
import type { ModelElement } from './element';
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons-vue';
import type { Graph } from '@antv/x6';
export default defineComponent({
name: 'ModelElement',
components: {
SettingOutlined,
DeleteOutlined,
},
props: elementProps,
setup(_props) {
const element = ref<ModelElement | null>(
_props.node ? (_props.node.getData() as ModelElement) : null,
);
const updateKey = ref(0);
const isMenuVisible = ref(false);
// 获取画布实例
const getGraph = (): Graph | null => {
return _props.graph as Graph || null;
};
// 监听节点数据变化
const handleDataChange = () => {
if (_props.node) {
element.value = _props.node.getData() as ModelElement;
} else {
element.value = null;
}
updateKey.value++;
};
const handleVisibleChange = (visible: boolean) => {
isMenuVisible.value = visible;
};
const handleMenuClick = ({ key }: { key: string }) => {
if (key === 'delete') {
handleDelete();
}
};
const handleDelete = () => {
if (!_props.node) return;
const graph = getGraph();
if (graph) {
try {
// 先删除关联边
const connectedEdges = graph.getConnectedEdges(_props.node);
connectedEdges.forEach(edge => graph.removeEdge(edge));
// 再删除节点
graph.removeNode(_props.node);
console.info(`节点 ${_props.node.id} 已删除`);
} catch (error) {
console.error('删除节点失败:', error);
}
}
isMenuVisible.value = false;
};
onMounted(() => {
_props.node?.on('change:data', handleDataChange);
});
onUnmounted(() => {
_props.node?.off('change:data', handleDataChange);
});
return {
element,
handleMenuClick,
handleVisibleChange,
};
},
});
</script>
<style lang="less">
.ks-designer-node {
background: linear-gradient(150deg, #093866 1%, #1f69b3 55%);
border: 0;
border-radius: 8px;
width: 100%;
height: 100%;
cursor: pointer;
position: relative;
.ant-card-head {
border: 0;
height: 38px;
min-height: 38px;
border-radius: 0;
color: #ddd;
font-size: 12px;
font-weight: normal;
padding: 0 20px;
}
.ks-designer-node-icon {
width: 15px;
height: 15px;
display: block;
position: absolute;
left: 8px;
top: 13px;
background: url('@/assets/icons/model-4.svg') center / 100% 100%;
}
.ks-designer-node-title {
font-size: 13px;
}
.ant-card-body {
color: #fff;
height: calc(100% - 38px);
border-radius: 0;
font-size: 12px;
padding: 8px 15px;
overflow-y: auto;
border-top: 1px solid #195693;
}
&.ks-designer-task-node {
background: linear-gradient(150deg, #20421b 1%, #4a6646 55%);
.ant-card-body {
border-top: 1px solid #466741;
}
.ks-designer-node-icon {
background: url('@/assets/icons/m-02.png') center / 100% 100%;
}
}
&.ks-designer-input-node {
background: linear-gradient(150deg, #083058 1%, #1e5d9b 55%);
.ant-card-body {
border-top: 1px solid #105ca7;
}
.ks-designer-node-icon {
background: url('@/assets/icons/icon-model-input.png') center / 100% 100%;
}
}
&.ks-designer-action-node {
background: linear-gradient(150deg, #343207 1%, #485010 55%);
.ant-card-body {
border-top: 1px solid #59550e;
}
.ks-designer-node-icon {
background: url('@/assets/icons/bg-fk-point.png') center / 100% 100%;
}
}
&.ks-designer-component-node {
background: linear-gradient(150deg, #06226b 1%, #1a43a7 55%);
.ant-card-body {
border-top: 1px solid #26448c;
}
}
&.ks-designer-control-node {
background: linear-gradient(150deg, #1d4f32 1%, #326a5d 55%);
.ant-card-body {
border-top: 1px solid #326a5d;
}
.ks-designer-node-icon {
background: url('@/assets/icons/bg-model-builder-card-title.png') center / 100% 100%;
}
}
// 连接桩容器样式
.ks-designer-node-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px; // 每个child行之间的间距
}
// 每个child行包含左右桩+文本)
.ks-designer-node-row {
width: 100%;
display: flex;
align-items: center;
position: relative;
height: 24px; // 固定行高,保证桩对齐
}
// 连接桩基础样式
.port {
width: 12px;
height: 12px;
border-radius: 50%;
cursor: crosshair;
flex-shrink: 0;
box-shadow: 0 0 0 2px rgb(74 114 214 / 80%);
z-index: 10; // 确保桩在最上层
// X6 标记为可连线的磁体
magnet: true;
}
// 左侧入桩样式
.port-in {
background-color: #093866; // 青色:入桩
margin-right: 8px; // 与文本的间距
//border: 1px solid #093866;
// X6 只能作为连线目标(入)
magnet: passive;
box-shadow: none;
width: 20px;
height: 20px;
display: block;
background: url('@/assets/icons/point.svg') center / 100% 100%;
}
// 右侧出桩样式
.port-out {
margin-left: 8px; // 与文本的间距
margin-right: 5px;
// X6 只能作为连线源(出)
magnet: active;
box-shadow: none;
width: 20px;
height: 20px;
display: block;
background: url('@/assets/icons/arrow-right.svg') center / 100% 100%;
}
// 节点文本样式
.ks-designer-node-name {
flex: 1; // 占满中间空间
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -0,0 +1,51 @@
/*
* This file is part of the kernelstudio package.
*
* (c) 2014-2025 zlin <admin@kernelstudio.com>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/
export const createPort = (name: string = 'top', args: Record<any, any> = { dx: 0 }) => {
return {
position: { name: name, args: args },
attrs: {
circle: {
r: 4, // 大小
magnet: true,
stroke: '#1b5e9f', // 边框颜色
strokeWidth: 1, // 边框大小
fill: '#3578bf', // 填充颜色
style: {
visibility: 'visible', // 是否可见
},
},
},
};
};
export const createPorts = (top: boolean = true, right: boolean = true, bottom: boolean = true, left: boolean = true) => {
const groups: any = {};
const items: any = [];
if (top) {
groups['top'] = createPort('top');
items.push({ group: 'top', id: 'top' });
}
if (right) {
groups['right'] = createPort('right');
items.push({ group: 'right', id: 'right' });
}
if (bottom) {
groups['bottom'] = createPort('bottom');
items.push({ group: 'bottom', id: 'bottom' });
}
if (left) {
groups['left'] = createPort('left');
items.push({ group: 'left', id: 'left' });
}
return {
groups: groups,
items: items,
};
};

View File

@@ -0,0 +1,29 @@
/*
* This file is part of the kernelstudio package.
*
* (c) 2014-2025 zlin <admin@kernelstudio.com>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/
import { Graph, Node } from '@antv/x6';
import { type ExtractPropTypes, type PropType } from 'vue';
import type { ModelElement } from './element';
export const elementProps = {
node: {
type: Object as PropType<Node>,
required: true,
},
graph: {
type: Object as PropType<Graph>,
required: true,
},
element: {
type: Object as PropType<ModelElement>,
required: false,
},
};
export type ElementPropsType = ExtractPropTypes<typeof elementProps>

View File

@@ -0,0 +1,50 @@
/*
* This file is part of the kernelstudio package.
*
* (c) 2014-2025 zlin <admin@kernelstudio.com>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/
/*
* This file is part of the kernelstudio package.
*
* (c) 2014-2025 zlin <admin@kernelstudio.com>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/
import { register } from '@antv/x6-vue-shape';
import ModelElement from './node.vue';
export const registerNodeElement = () => {
console.info('registerNodeElement');
register({
shape: 'model',
component: ModelElement,
width: 120,
attrs: {
body: {
stroke: 'transparent',
strokeWidth: 0,
fill: 'transparent',
rx: 4,
ry: 4,
},
},
dragging: {
enabled: true,
},
// 配置端口识别规则,
portMarkup: [
{
tagName: 'div',
selector: 'port-body',
},
],
// 告诉 X6 如何识别 Vue 组件内的端口
portAttribute: 'data-port',
// ports: createPorts(),
});
};