mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-03-05 15:05:27 +00:00
feat: 实现矢量节点 MVP 功能
- 扩展 NodeProperties 接口,添加 vector 字段定义 - 创建 VectorNode.vue 组件,使用 SVG Pattern 实现自动平铺 - 创建 VectorNodeModel.ts 数据模型,处理节点初始化和 resize - 创建 VectorPanel.vue 属性面板,支持图形类型、平铺尺寸、颜色等配置 - 在 FlowEditor.vue 中注册 vectorNode - 在 ComponentsPanel.vue 中添加到组件库 - 在 PropertyPanel.vue 中注册属性面板 功能特性: - 支持 5 种图形类型(矩形/椭圆/多边形/路径/自定义SVG) - 节点缩放时矢量图自动重复平铺 - 可调整平铺尺寸(10-500px) - 支持填充和描边颜色配置 - 实时预览,属性修改立即生效
This commit is contained in:
@@ -51,6 +51,24 @@ const componentGroups = [
|
|||||||
width: 200,
|
width: 200,
|
||||||
height: 120
|
height: 120
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vector',
|
||||||
|
name: '矢量图块',
|
||||||
|
type: 'vectorNode',
|
||||||
|
description: '可平铺的矢量图形,用于边框装饰',
|
||||||
|
data: {
|
||||||
|
vector: {
|
||||||
|
kind: 'rect',
|
||||||
|
tileWidth: 50,
|
||||||
|
tileHeight: 50,
|
||||||
|
fill: '#409EFF',
|
||||||
|
stroke: '#303133',
|
||||||
|
strokeWidth: 1
|
||||||
|
},
|
||||||
|
width: 200,
|
||||||
|
height: 200
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ import ImageNode from './nodes/common/ImageNode.vue';
|
|||||||
import AssetSelectorNode from './nodes/common/AssetSelectorNode.vue';
|
import AssetSelectorNode from './nodes/common/AssetSelectorNode.vue';
|
||||||
import TextNode from './nodes/common/TextNode.vue';
|
import TextNode from './nodes/common/TextNode.vue';
|
||||||
import TextNodeModel from './nodes/common/TextNodeModel';
|
import TextNodeModel from './nodes/common/TextNodeModel';
|
||||||
|
import VectorNode from './nodes/common/VectorNode.vue';
|
||||||
|
import VectorNodeModel from './nodes/common/VectorNodeModel';
|
||||||
import PropertyPanel from './PropertyPanel.vue';
|
import PropertyPanel from './PropertyPanel.vue';
|
||||||
import { useGlobalMessage } from '@/ts/useGlobalMessage';
|
import { useGlobalMessage } from '@/ts/useGlobalMessage';
|
||||||
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
|
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
@@ -669,6 +671,7 @@ function registerNodes(lfInstance: LogicFlow) {
|
|||||||
register({ type: 'imageNode', component: ImageNode }, lfInstance);
|
register({ type: 'imageNode', component: ImageNode }, lfInstance);
|
||||||
register({ type: 'assetSelector', component: AssetSelectorNode }, lfInstance);
|
register({ type: 'assetSelector', component: AssetSelectorNode }, lfInstance);
|
||||||
register({ type: 'textNode', component: TextNode, model: TextNodeModel }, lfInstance);
|
register({ type: 'textNode', component: TextNode, model: TextNodeModel }, lfInstance);
|
||||||
|
register({ type: 'vectorNode', component: VectorNode, model: VectorNodeModel }, lfInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化 LogicFlow
|
// 初始化 LogicFlow
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ImagePanel from './panels/ImagePanel.vue';
|
|||||||
import TextPanel from './panels/TextPanel.vue';
|
import TextPanel from './panels/TextPanel.vue';
|
||||||
import StylePanel from './panels/StylePanel.vue';
|
import StylePanel from './panels/StylePanel.vue';
|
||||||
import AssetSelectorPanel from './panels/AssetSelectorPanel.vue';
|
import AssetSelectorPanel from './panels/AssetSelectorPanel.vue';
|
||||||
|
import VectorPanel from './panels/VectorPanel.vue';
|
||||||
import { ASSET_LIBRARIES } from '@/types/nodeTypes';
|
import { ASSET_LIBRARIES } from '@/types/nodeTypes';
|
||||||
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
|
|
||||||
@@ -33,7 +34,8 @@ const panelMap: Record<string, any> = {
|
|||||||
propertySelect: PropertyRulePanel,
|
propertySelect: PropertyRulePanel,
|
||||||
imageNode: ImagePanel,
|
imageNode: ImagePanel,
|
||||||
textNode: TextPanel,
|
textNode: TextPanel,
|
||||||
assetSelector: AssetSelectorPanel
|
assetSelector: AssetSelectorPanel,
|
||||||
|
vectorNode: VectorPanel
|
||||||
};
|
};
|
||||||
|
|
||||||
const panelComponent = computed(() => panelMap[nodeType.value] || null);
|
const panelComponent = computed(() => panelMap[nodeType.value] || null);
|
||||||
|
|||||||
106
src/components/flow/nodes/common/VectorNode.vue
Normal file
106
src/components/flow/nodes/common/VectorNode.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useNodeAppearance } from '@/ts/useNodeAppearance';
|
||||||
|
|
||||||
|
const vectorConfig = ref({
|
||||||
|
kind: 'rect',
|
||||||
|
svgContent: '',
|
||||||
|
path: '',
|
||||||
|
tileWidth: 50,
|
||||||
|
tileHeight: 50,
|
||||||
|
fill: '#409EFF',
|
||||||
|
stroke: '#303133',
|
||||||
|
strokeWidth: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeSize = ref({ width: 200, height: 200 });
|
||||||
|
|
||||||
|
const { containerStyle } = useNodeAppearance({
|
||||||
|
onPropsChange(props, node) {
|
||||||
|
// 同步矢量配置
|
||||||
|
if (props.vector) {
|
||||||
|
Object.assign(vectorConfig.value, props.vector);
|
||||||
|
}
|
||||||
|
// 同步节点尺寸
|
||||||
|
if (node) {
|
||||||
|
nodeSize.value.width = node.width;
|
||||||
|
nodeSize.value.height = node.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成唯一的 pattern ID
|
||||||
|
const patternId = computed(() => `vector-pattern-${Math.random().toString(36).substr(2, 9)}`);
|
||||||
|
|
||||||
|
// 生成 SVG 内容
|
||||||
|
const svgContent = computed(() => {
|
||||||
|
const { kind, path, tileWidth, tileHeight, fill, stroke, strokeWidth } = vectorConfig.value;
|
||||||
|
|
||||||
|
let shapeElement = '';
|
||||||
|
switch (kind) {
|
||||||
|
case 'rect':
|
||||||
|
shapeElement = `<rect x="0" y="0" width="${tileWidth}" height="${tileHeight}"
|
||||||
|
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`;
|
||||||
|
break;
|
||||||
|
case 'ellipse':
|
||||||
|
shapeElement = `<ellipse cx="${tileWidth/2}" cy="${tileHeight/2}"
|
||||||
|
rx="${tileWidth/2 - strokeWidth}" ry="${tileHeight/2 - strokeWidth}"
|
||||||
|
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`;
|
||||||
|
break;
|
||||||
|
case 'path':
|
||||||
|
shapeElement = `<path d="${path || 'M 0 0'}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`;
|
||||||
|
break;
|
||||||
|
case 'svg':
|
||||||
|
shapeElement = vectorConfig.value.svgContent || '';
|
||||||
|
break;
|
||||||
|
case 'polygon':
|
||||||
|
// 默认三角形
|
||||||
|
const points = `0,${tileHeight} ${tileWidth/2},0 ${tileWidth},${tileHeight}`;
|
||||||
|
shapeElement = `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" />`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg width="${nodeSize.value.width}" height="${nodeSize.value.height}"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<pattern id="${patternId.value}"
|
||||||
|
x="0" y="0"
|
||||||
|
width="${tileWidth}"
|
||||||
|
height="${tileHeight}"
|
||||||
|
patternUnits="userSpaceOnUse">
|
||||||
|
${shapeElement}
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#${patternId.value})" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="vector-node" :style="containerStyle">
|
||||||
|
<div class="vector-content" v-html="svgContent"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vector-node {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vector-content {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vector-content :deep(svg) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
44
src/components/flow/nodes/common/VectorNodeModel.ts
Normal file
44
src/components/flow/nodes/common/VectorNodeModel.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { HtmlNodeModel } from '@logicflow/core';
|
||||||
|
|
||||||
|
class VectorNodeModel extends HtmlNodeModel {
|
||||||
|
initNodeData(data: any) {
|
||||||
|
super.initNodeData(data);
|
||||||
|
|
||||||
|
// 从 properties 读取宽高
|
||||||
|
if (data.properties?.width) {
|
||||||
|
this.width = data.properties.width;
|
||||||
|
} else {
|
||||||
|
this.width = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.properties?.height) {
|
||||||
|
this.height = data.properties.height;
|
||||||
|
} else {
|
||||||
|
this.height = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化默认矢量配置
|
||||||
|
if (!data.properties?.vector) {
|
||||||
|
this.setProperty('vector', {
|
||||||
|
kind: 'rect',
|
||||||
|
tileWidth: 50,
|
||||||
|
tileHeight: 50,
|
||||||
|
fill: '#409EFF',
|
||||||
|
stroke: '#303133',
|
||||||
|
strokeWidth: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(deltaX: number, deltaY: number) {
|
||||||
|
const result = super.resize?.(deltaX, deltaY);
|
||||||
|
|
||||||
|
// 持久化宽高到 properties
|
||||||
|
this.setProperty('width', this.width);
|
||||||
|
this.setProperty('height', this.height);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VectorNodeModel;
|
||||||
207
src/components/flow/panels/VectorPanel.vue
Normal file
207
src/components/flow/panels/VectorPanel.vue
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue';
|
||||||
|
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||||
|
|
||||||
|
const props = defineProps<{ node: any }>();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
kind: 'rect',
|
||||||
|
tileWidth: 50,
|
||||||
|
tileHeight: 50,
|
||||||
|
fill: '#409EFF',
|
||||||
|
stroke: '#303133',
|
||||||
|
strokeWidth: 1,
|
||||||
|
path: '',
|
||||||
|
svgContent: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// 从节点同步数据
|
||||||
|
watch(
|
||||||
|
() => props.node,
|
||||||
|
(node) => {
|
||||||
|
if (node?.properties?.vector) {
|
||||||
|
Object.assign(form, node.properties.vector);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 应用更改
|
||||||
|
const applyChanges = (partial: Record<string, any>) => {
|
||||||
|
const lf = getLogicFlowInstance();
|
||||||
|
if (!lf || !props.node) return;
|
||||||
|
|
||||||
|
const currentVector = props.node.properties?.vector || {};
|
||||||
|
lf.setProperties(props.node.id, {
|
||||||
|
...props.node.properties,
|
||||||
|
vector: {
|
||||||
|
...currentVector,
|
||||||
|
...partial
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const kindOptions = [
|
||||||
|
{ label: '矩形', value: 'rect' },
|
||||||
|
{ label: '椭圆', value: 'ellipse' },
|
||||||
|
{ label: '多边形', value: 'polygon' },
|
||||||
|
{ label: '路径', value: 'path' },
|
||||||
|
{ label: '自定义SVG', value: 'svg' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="property-section">
|
||||||
|
<div class="section-header">矢量配置</div>
|
||||||
|
|
||||||
|
<!-- 图形类型 -->
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">图形类型</div>
|
||||||
|
<div class="property-value">
|
||||||
|
<el-select
|
||||||
|
v-model="form.kind"
|
||||||
|
size="small"
|
||||||
|
@change="() => applyChanges({ kind: form.kind })"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in kindOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 平铺尺寸 -->
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">平铺尺寸 (宽×高)</div>
|
||||||
|
<div class="property-value row">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.tileWidth"
|
||||||
|
:min="10"
|
||||||
|
:max="500"
|
||||||
|
size="small"
|
||||||
|
@change="() => applyChanges({ tileWidth: form.tileWidth })"
|
||||||
|
/>
|
||||||
|
<span>×</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.tileHeight"
|
||||||
|
:min="10"
|
||||||
|
:max="500"
|
||||||
|
size="small"
|
||||||
|
@change="() => applyChanges({ tileHeight: form.tileHeight })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 填充颜色 -->
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">填充颜色</div>
|
||||||
|
<div class="property-value">
|
||||||
|
<el-color-picker
|
||||||
|
v-model="form.fill"
|
||||||
|
size="small"
|
||||||
|
@change="() => applyChanges({ fill: form.fill })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描边颜色 -->
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">描边颜色</div>
|
||||||
|
<div class="property-value">
|
||||||
|
<el-color-picker
|
||||||
|
v-model="form.stroke"
|
||||||
|
size="small"
|
||||||
|
@change="() => applyChanges({ stroke: form.stroke })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描边宽度 -->
|
||||||
|
<div class="property-item">
|
||||||
|
<div class="property-label">描边宽度</div>
|
||||||
|
<div class="property-value">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.strokeWidth"
|
||||||
|
:min="0"
|
||||||
|
:max="20"
|
||||||
|
size="small"
|
||||||
|
@change="() => applyChanges({ strokeWidth: form.strokeWidth })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Path 数据(仅当 kind='path' 时显示) -->
|
||||||
|
<div v-if="form.kind === 'path'" class="property-item">
|
||||||
|
<div class="property-label">Path 数据</div>
|
||||||
|
<div class="property-value">
|
||||||
|
<el-input
|
||||||
|
v-model="form.path"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
size="small"
|
||||||
|
placeholder="M 0 0 L 50 50 Z"
|
||||||
|
@change="() => applyChanges({ path: form.path })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SVG 内容(仅当 kind='svg' 时显示) -->
|
||||||
|
<div v-if="form.kind === 'svg'" class="property-item">
|
||||||
|
<div class="property-label">SVG 内容</div>
|
||||||
|
<div class="property-value">
|
||||||
|
<el-input
|
||||||
|
v-model="form.svgContent"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
size="small"
|
||||||
|
placeholder="<rect x='0' y='0' width='50' height='50' />"
|
||||||
|
@change="() => applyChanges({ svgContent: form.svgContent })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.property-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #ecf5ff;
|
||||||
|
border-bottom: 1px solid #dcdfe6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-item {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-value {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
35
src/stores/files.ts
Normal file
35
src/stores/files.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
export const useFilesStore = defineStore('files', {
|
||||||
|
state: () => ({
|
||||||
|
fileList: [{ label: 'File 1', name: 1, visible: false }, { label: 'File 2', name: 2, visible: false }],
|
||||||
|
activeFile: -1,
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
visibleFiles: (state) => state.fileList.filter(file => file.visible),
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
addFile(file: { label: string; name: number }) {
|
||||||
|
this.fileList.push({ ...file, visible: false });
|
||||||
|
},
|
||||||
|
setActiveFile(fileId: number) {
|
||||||
|
this.activeFile = fileId;
|
||||||
|
},
|
||||||
|
setVisible(fileId: number, visibility: boolean) {
|
||||||
|
const file = this.fileList.find(file => file.name === fileId);
|
||||||
|
if (file) {
|
||||||
|
file.visible = visibility;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeTab(fileId: number) {
|
||||||
|
const file = this.fileList.find(file => file.name === fileId);
|
||||||
|
if (file) {
|
||||||
|
file.visible = false;
|
||||||
|
if (this.activeFile === fileId) {
|
||||||
|
const nextVisibleFile = this.visibleFiles[0];
|
||||||
|
this.activeFile = nextVisibleFile ? nextVisibleFile.name : -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -58,7 +58,17 @@ export interface NodeProperties {
|
|||||||
meta?: NodeMeta;
|
meta?: NodeMeta;
|
||||||
image?: { url: string; fit?: 'fill'|'contain'|'cover' };
|
image?: { url: string; fit?: 'fill'|'contain'|'cover' };
|
||||||
text?: { content: string; rich?: boolean };
|
text?: { content: string; rich?: boolean };
|
||||||
vector?: { kind: 'path'|'rect'|'ellipse'|'polygon'; path?: string; points?: Array<[number, number]> };
|
vector?: {
|
||||||
|
kind: 'path' | 'rect' | 'ellipse' | 'polygon' | 'svg';
|
||||||
|
svgContent?: string;
|
||||||
|
path?: string;
|
||||||
|
points?: Array<[number, number]>;
|
||||||
|
tileWidth: number;
|
||||||
|
tileHeight: number;
|
||||||
|
fill?: string;
|
||||||
|
stroke?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
};
|
||||||
shikigami?: { name: string; avatar: string; rarity: string };
|
shikigami?: { name: string; avatar: string; rarity: string };
|
||||||
yuhun?: { name: string; type: string; avatar: string; shortName?: string };
|
yuhun?: { name: string; type: string; avatar: string; shortName?: string };
|
||||||
property?: Record<string, any>;
|
property?: Record<string, any>;
|
||||||
|
|||||||
Reference in New Issue
Block a user