mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2026-03-05 06:55:26 +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,
|
||||
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 TextNode from './nodes/common/TextNode.vue';
|
||||
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 { useGlobalMessage } from '@/ts/useGlobalMessage';
|
||||
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||
@@ -669,6 +671,7 @@ function registerNodes(lfInstance: LogicFlow) {
|
||||
register({ type: 'imageNode', component: ImageNode }, lfInstance);
|
||||
register({ type: 'assetSelector', component: AssetSelectorNode }, lfInstance);
|
||||
register({ type: 'textNode', component: TextNode, model: TextNodeModel }, lfInstance);
|
||||
register({ type: 'vectorNode', component: VectorNode, model: VectorNodeModel }, lfInstance);
|
||||
}
|
||||
|
||||
// 初始化 LogicFlow
|
||||
|
||||
@@ -5,6 +5,7 @@ import ImagePanel from './panels/ImagePanel.vue';
|
||||
import TextPanel from './panels/TextPanel.vue';
|
||||
import StylePanel from './panels/StylePanel.vue';
|
||||
import AssetSelectorPanel from './panels/AssetSelectorPanel.vue';
|
||||
import VectorPanel from './panels/VectorPanel.vue';
|
||||
import { ASSET_LIBRARIES } from '@/types/nodeTypes';
|
||||
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
|
||||
|
||||
@@ -33,7 +34,8 @@ const panelMap: Record<string, any> = {
|
||||
propertySelect: PropertyRulePanel,
|
||||
imageNode: ImagePanel,
|
||||
textNode: TextPanel,
|
||||
assetSelector: AssetSelectorPanel
|
||||
assetSelector: AssetSelectorPanel,
|
||||
vectorNode: VectorPanel
|
||||
};
|
||||
|
||||
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;
|
||||
image?: { url: string; fit?: 'fill'|'contain'|'cover' };
|
||||
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 };
|
||||
yuhun?: { name: string; type: string; avatar: string; shortName?: string };
|
||||
property?: Record<string, any>;
|
||||
|
||||
Reference in New Issue
Block a user