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:
2026-02-17 21:50:24 +08:00
parent 3091ef063c
commit 47fc8928d8
8 changed files with 427 additions and 2 deletions

View File

@@ -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
}
}
]
},

View File

@@ -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

View File

@@ -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);

View 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>

View 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;

View 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
View 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;
}
}
},
},
});

View File

@@ -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>;