自定义节点注册,属性编辑对话框交互,持久化配置

This commit is contained in:
2025-07-16 16:17:47 +08:00
parent 99231ce52c
commit f083f8065b
7 changed files with 399 additions and 609 deletions

View File

@@ -123,6 +123,21 @@ watch(
} }
); );
const handleDropOnCanvas = (event: DragEvent) => {
event.preventDefault();
const nodeData = JSON.parse(event.dataTransfer?.getData('application/json') || '{}');
// 计算画布坐标(这里简单用鼠标坐标,后续可结合 LogicFlow 视口变换优化)
const rect = (event.target as HTMLElement).getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const id = `node-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
filesStore.addNode({ id, type: nodeData.type, x, y, ...nodeData.data });
};
const handleDragOverOnCanvas = (event: DragEvent) => {
event.preventDefault();
};
</script> </script>
<template> <template>
@@ -149,7 +164,10 @@ watch(
:name="file.name.toString()" :name="file.name.toString()"
/> />
</el-tabs> </el-tabs>
<div id="main-container" :style="{ height: contentHeight, overflow: 'auto' }"> <div id="main-container" :style="{ height: contentHeight, overflow: 'auto' }"
@dragover="handleDragOverOnCanvas"
@drop="handleDropOnCanvas"
>
<FlowEditor <FlowEditor
ref="flowEditorRef" ref="flowEditorRef"
:height="contentHeight" :height="contentHeight"

View File

@@ -16,11 +16,8 @@ const filesStore = useFilesStore();
:currentShikigami="dialogs.shikigami.data" :currentShikigami="dialogs.shikigami.data"
@closeSelectShikigami="closeDialog('shikigami')" @closeSelectShikigami="closeDialog('shikigami')"
@updateShikigami="data => { @updateShikigami="data => {
if (dialogs.shikigami.node?.id) { dialogs.shikigami.callback?.(data);
filesStore.updateNode(dialogs.shikigami.node.id, { data: { ...dialogs.shikigami.node.data, shikigami: data } }) closeDialog('shikigami');
}
dialogs.shikigami.callback?.(data, dialogs.shikigami.node)
closeDialog('shikigami')
}" }"
/> />
<YuhunSelect <YuhunSelect
@@ -29,11 +26,8 @@ const filesStore = useFilesStore();
:currentYuhun="dialogs.yuhun.data" :currentYuhun="dialogs.yuhun.data"
@closeSelectYuhun="closeDialog('yuhun')" @closeSelectYuhun="closeDialog('yuhun')"
@updateYuhun="data => { @updateYuhun="data => {
if (dialogs.yuhun.node?.id) { dialogs.yuhun.callback?.(data);
filesStore.updateNode(dialogs.yuhun.node.id, { data: { ...dialogs.yuhun.node.data, yuhun: data } }) closeDialog('yuhun');
}
dialogs.yuhun.callback?.(data, dialogs.yuhun.node);
closeDialog('yuhun')
}" }"
/> />
<PropertySelect <PropertySelect
@@ -42,11 +36,8 @@ const filesStore = useFilesStore();
:currentProperty="dialogs.property.data" :currentProperty="dialogs.property.data"
@closePropertySelect="closeDialog('property')" @closePropertySelect="closeDialog('property')"
@updateProperty="data => { @updateProperty="data => {
if (dialogs.property.node?.id) { dialogs.property.callback?.(data);
filesStore.updateNode(dialogs.property.node.id, { data: { ...dialogs.property.node.data, property: data } }) closeDialog('property');
}
dialogs.property.callback?.(data, dialogs.property.node);
closeDialog('property')
}" }"
/> />
</template> </template>

View File

@@ -1,76 +1,125 @@
<template> <template>
<div class="container" ref="containerRef" :style="{ height }"></div> <div class="editor-layout" :style="{ height }">
<!-- 中间流程图区域 -->
<div class="flow-container">
<div class="container" ref="containerRef" :style="{ height: '100%' }"></div>
<!-- 右键菜单 -->
<Teleport to="body">
<div v-if="contextMenu.show"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop>
<div class="menu-item" @click="handleLayerOrder('bringToFront')">移至最前</div>
<div class="menu-item" @click="handleLayerOrder('sendToBack')">移至最后</div>
<div class="menu-item" @click="handleLayerOrder('bringForward')">上移一层</div>
<div class="menu-item" @click="handleLayerOrder('sendBackward')">下移一层</div>
</div>
</Teleport>
</div>
<!-- 右侧属性面板 -->
<PropertyPanel :height="height" :node="selectedNode" :lf="lf" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, defineExpose } from 'vue'; import { ref, watch, onMounted, onBeforeUnmount, defineExpose } from 'vue';
import type { PropType } from 'vue'; import LogicFlow, { EventType } from '@logicflow/core';
import LogicFlow from '@logicflow/core';
import '@logicflow/core/lib/style/index.css'; import '@logicflow/core/lib/style/index.css';
import { register } from '@logicflow/vue-node-registry';
// 类型定义放在 import 之后,避免顶层 await 错误 import ShikigamiSelectNode from './nodes/yys/ShikigamiSelectNode.vue';
import YuhunSelectNode from './nodes/yys/YuhunSelectNode.vue';
type NodeData = { import PropertySelectNode from './nodes/yys/PropertySelectNode.vue';
id: string; // import ImageNode from './nodes/common/ImageNode.vue';
type: string; // import TextNode from './nodes/common/TextNode.vue';
x: number; import PropertyPanel from './PropertyPanel.vue';
y: number; import {useFilesStore} from "@/ts/useStore";
text?: string;
properties?: Record<string, any>;
};
type EdgeData = {
id: string;
type: string;
sourceNodeId: string;
targetNodeId: string;
properties?: Record<string, any>;
};
type Viewport = {
x: number;
y: number;
zoom: number;
};
const props = defineProps<{ const props = defineProps<{
nodes: NodeData[]; nodes: any[];
edges: EdgeData[]; edges: any[];
viewport?: Viewport; viewport?: { x: number; y: number; zoom: number };
height?: string; height?: string;
}>(); }>();
const filesStore = useFilesStore();
const containerRef = ref<HTMLElement | null>(null); const containerRef = ref<HTMLElement | null>(null);
let lf: LogicFlow | null = null; const lf = ref<LogicFlow | null>(null);
// 右键菜单相关
const contextMenu = ref({
show: false,
x: 0,
y: 0,
nodeId: null
});
// 当前选中节点
const selectedNode = ref<any>(null);
// 注册自定义节点
function registerNodes(lfInstance: LogicFlow) {
register({ type: 'shikigamiSelect', component: ShikigamiSelectNode }, lfInstance);
register({ type: 'yuhunSelect', component: YuhunSelectNode }, lfInstance);
register({ type: 'propertySelect', component: PropertySelectNode }, lfInstance);
// register({ type: 'imageNode', component: ImageNode }, lfInstance);
// register({ type: 'textNode', component: TextNode }, lfInstance);
}
// 初始化 LogicFlow // 初始化 LogicFlow
onMounted(() => { onMounted(() => {
lf = new LogicFlow({ lf.value = new LogicFlow({
container: containerRef.value as HTMLElement, container: containerRef.value as HTMLElement,
grid: true, grid: true,
}); });
// lf.zoom(2); registerNodes(lf.value);
// zoom(2);
renderFlow(); renderFlow();
filesStore.setLogicFlowInstance(lf.value);
// 监听节点点击事件,更新 selectedNode
lf.value.on(EventType.NODE_CLICK, ({ data }) => {
selectedNode.value = data;
});
// if (props.viewport) setViewport(props.viewport); // 监听空白点击事件,取消选中
lf.value.on(EventType.BLANK_CLICK, () => {
selectedNode.value = null;
});
// 节点属性改变,如果当前节点是选中节点,则同步更新 selectedNode
lf.value.on(EventType.NODE_PROPERTIES_CHANGE, (data) => {
const nodeId = data.id || (data.value && data.value.id);
if (selectedNode.value && nodeId === selectedNode.value.id) {
if (data.value) {
selectedNode.value = data.value;
} else if (data.properties) {
selectedNode.value = {
...selectedNode.value,
properties: data.properties
};
}
}
});
// 右键事件
lf.value.on('node:contextmenu', handleNodeContextMenu);
lf.value.on('blank:contextmenu', handlePaneContextMenu);
}); });
// 销毁 LogicFlow // 销毁 LogicFlow
onBeforeUnmount(() => { onBeforeUnmount(() => {
lf?.destroy(); lf.value?.destroy();
lf = null; lf.value = null;
}); });
// 响应式更新 nodes/edges // 响应式更新 nodes/edges
watch( // watch(
() => [props.nodes, props.edges], // () => [props.nodes, props.edges],
() => { // () => {
renderFlow(); // renderFlow();
}, // },
{ deep: true } // { deep: true }
); // );
// 响应式更新 viewport // 响应式更新 viewport
watch( watch(
@@ -81,22 +130,22 @@ watch(
); );
function renderFlow() { function renderFlow() {
if (!lf) return; if (!lf.value) return;
lf.render({ lf.value.render({
nodes: props.nodes, nodes: props.nodes,
edges: props.edges, edges: props.edges,
}); });
} }
function setViewport(viewport: Viewport) { function setViewport(viewport?: { x: number; y: number; zoom: number }) {
if (!lf || !viewport) return; if (!lf.value || !viewport) return;
lf.zoom(viewport.zoom); lf.value.zoom(viewport.zoom);
// lf.focusOn({ x: viewport.x, y: viewport.y }); // lf.value.focusOn({ x: viewport.x, y: viewport.y });
} }
function getViewport(): Viewport { function getViewport() {
if (!lf) return { x: 0, y: 0, zoom: 1 }; if (!lf.value) return { x: 0, y: 0, zoom: 1 };
const t = lf.getTransform(); const t = lf.value.getTransform();
return { return {
x: t.TRANSLATE_X, x: t.TRANSLATE_X,
y: t.TRANSLATE_Y, y: t.TRANSLATE_Y,
@@ -104,34 +153,82 @@ function getViewport(): Viewport {
}; };
} }
function handleAddNode(node: NodeData) { // 右键菜单相关
if (!lf) return; function handleNodeContextMenu({ data, e }: { data: any; e: MouseEvent }) {
lf.addNode(node); e.preventDefault();
e.stopPropagation();
contextMenu.value = {
show: true,
x: e.clientX,
y: e.clientY,
nodeId: data.id
};
}
function handlePaneContextMenu({ e }: { e: MouseEvent }) {
e.preventDefault();
e.stopPropagation();
contextMenu.value.show = false;
}
function handleLayerOrder(action: string) {
// 这里需要结合你的 store 或数据结构实现节点顺序调整
contextMenu.value.show = false;
} }
function getGraphRawData() { function getGraphRawData() {
if (!lf) return null; if (!lf) return null;
return lf.getGraphRawData(); return lf.value.getGraphRawData();
} }
function renderRawData(data: any) { function renderRawData(data: any) {
if (!lf) return; if (!lf) return;
lf.renderRawData(data); lf.value.renderRawData(data);
} }
defineExpose({ defineExpose({
getViewport, getViewport,
setViewport, // 新增暴露 setViewport,
handleAddNode, renderFlow,
getGraphRawData, getGraphRawData,
renderRawData, renderRawData,
}); });
</script> </script>
<style scoped> <style scoped>
.editor-layout {
display: flex;
height: 100%;
}
.flow-container {
flex: 1;
position: relative;
overflow: hidden;
}
.container { .container {
width: 100%; width: 100%;
min-height: 300px; min-height: 300px;
background: #fff; background: #fff;
height: 100%;
}
.context-menu {
position: fixed;
background: white;
border: 1px solid #dcdfe6;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 5px 0;
z-index: 9999;
min-width: 120px;
user-select: none;
}
.menu-item {
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
color: #606266;
white-space: nowrap;
}
.menu-item:hover {
background-color: #f5f7fa;
color: #409eff;
} }
</style> </style>

View File

@@ -1,83 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'; import { ref, watch, onMounted, inject } from 'vue';
import { Handle, Position, useVueFlow } from '@vue-flow/core'; import { EventType } from '@logicflow/core';
import { NodeResizer } from '@vue-flow/node-resizer'
import '@vue-flow/node-resizer/dist/style.css';
const props = defineProps({ const currentProperty = ref({ type: '未选择', priority: '可选' });
data: Object,
id: String,
selected: Boolean
});
// 获取Vue Flow的实例和节点更新方法 const getNode = inject('getNode') as (() => any) | undefined;
const { findNode, updateNode } = useVueFlow(); const getGraph = inject('getGraph') as (() => any) | undefined;
// 属性信息保存在节点数据中
const currentProperty = ref({
type: '未选择',
value: 0,
valueType: '',
priority: '可选',
levelRequired: "40",
skillRequiredMode: "all",
skillRequired: ["5", "5", "5"],
yuhun: {
yuhunSetEffect: [],
target: "1",
property2: ["Attack"],
property4: ["Attack"],
property6: ["Crit", "CritDamage"],
},
expectedDamage: 0,
survivalRate: 50,
damageType: "balanced",
description: '',
});
// 节点尺寸
const nodeWidth = ref(180);
const nodeHeight = ref(180);
// 监听props.data的变化
watch(() => props.data, (newData) => {
if (newData && newData.property) {
currentProperty.value = newData.property;
}
}, { immediate: true });
// 更新属性信息的方法将由App.vue调用
const updateNodeProperty = (property) => {
currentProperty.value = property;
};
// 备用方案:通过全局事件总线监听更新
const handlePropertyUpdate = (event) => {
const { nodeId, property } = event.detail;
if (nodeId === props.id) {
updateNodeProperty(property);
}
};
onMounted(() => { onMounted(() => {
console.log('PropertySelectNode mounted:', props.id); const node = getNode?.();
// 添加全局事件监听 const graph = getGraph?.();
window.addEventListener('update-property', handlePropertyUpdate);
// 初始化时检查是否有数据 if (node?.properties?.property) {
if (props.data && props.data.property) { currentProperty.value = node.properties.property;
currentProperty.value = props.data.property; }
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, (eventData: any) => {
if (eventData.id === node.id && eventData.properties?.property) {
currentProperty.value = eventData.properties.property;
} }
}); });
onUnmounted(() => {
// 移除全局事件监听器
window.removeEventListener('update-property', handlePropertyUpdate);
}); });
// 获取属性类型显示名称 // 辅助函数
const getPropertyTypeName = () => { const getPropertyTypeName = () => {
const typeMap = { const typeMap: Record<string, string> = {
'attack': '攻击', 'attack': '攻击',
'health': '生命', 'health': '生命',
'defense': '防御', 'defense': '防御',
@@ -88,165 +36,38 @@ const getPropertyTypeName = () => {
'effectResist': '效果抵抗', 'effectResist': '效果抵抗',
'未选择': '未选择' '未选择': '未选择'
}; };
return typeMap[currentProperty.value.type] || currentProperty.value.type; return typeMap[currentProperty.value.type] || currentProperty.value.type;
}; };
// 获取优先级显示名称
const getPriorityName = () => { const getPriorityName = () => {
const priorityMap = { const priorityMap: Record<string, string> = {
'required': '必须', 'required': '必须',
'recommended': '推荐', 'recommended': '推荐',
'optional': '可选' 'optional': '可选'
}; };
return priorityMap[currentProperty.value.priority] || currentProperty.value.priority; return priorityMap[currentProperty.value.priority] || currentProperty.value.priority;
}; };
// 获取格式化的属性值显示
const getFormattedValue = () => {
const type = currentProperty.value.type;
if (type === '未选择') return '';
// 根据属性类型获取相应的值
let value = 0;
let isPercentage = false;
switch (type) {
case 'attack':
value = currentProperty.value.attackValue || 0;
isPercentage = currentProperty.value.attackType === 'percentage';
break;
case 'health':
value = currentProperty.value.healthValue || 0;
isPercentage = currentProperty.value.healthType === 'percentage';
break;
case 'defense':
value = currentProperty.value.defenseValue || 0;
isPercentage = currentProperty.value.defenseType === 'percentage';
break;
case 'speed':
value = currentProperty.value.speedValue || 0;
break;
case 'crit':
value = currentProperty.value.critRate || 0;
isPercentage = true;
break;
case 'critDmg':
value = currentProperty.value.critDmg || 0;
isPercentage = true;
break;
case 'effectHit':
value = currentProperty.value.effectHitValue || 0;
isPercentage = true;
break;
case 'effectResist':
value = currentProperty.value.effectResistValue || 0;
isPercentage = true;
break;
}
return isPercentage ? `${value}%` : value.toString();
};
// 获取式神技能要求显示
const getSkillRequirementText = () => {
const mode = currentProperty.value.skillRequiredMode;
if (mode === 'all') {
return '技能: 全满';
} else if (mode === '111') {
return '技能: 111';
} else if (mode === 'custom') {
return `技能: ${currentProperty.value.skillRequired.join('/')}`;
}
return '';
};
// 获取御魂套装信息
const getYuhunSetInfo = () => {
const sets = currentProperty.value.yuhun?.yuhunSetEffect;
if (!sets || sets.length === 0) return '';
return `御魂: ${sets.length}`;
};
// 获取显示的等级信息
const getLevelText = () => {
const level = currentProperty.value.levelRequired;
return level === '0' ? '等级: 献祭' : `等级: ${level}`;
};
// 处理调整大小
const handleResize = (event, { width, height }) => {
// 更新本地状态
nodeWidth.value = width;
nodeHeight.value = height;
// 更新Vue Flow中的节点
const node = findNode(props.id);
if (node) {
const updatedNode = {
...node,
style: {
...node.style,
width: `${width}px`,
height: `${height}px`
}
};
updateNode(props.id, updatedNode);
}
};
// 导出方法,使父组件可以调用
defineExpose({
updateNodeProperty
});
</script> </script>
<template> <template>
<NodeResizer
v-if="selected"
:min-width="150"
:min-height="150"
:max-width="300"
:max-height="300"
/>
<div class="property-node" :class="[currentProperty.priority ? `priority-${currentProperty.priority}` : '']"> <div class="property-node" :class="[currentProperty.priority ? `priority-${currentProperty.priority}` : '']">
<!-- 输入连接点 -->
<Handle type="target" position="left" :id="`${id}-target`" />
<div class="node-content"> <div class="node-content">
<div class="node-header"> <div class="node-header">
<div class="node-title">属性要求</div> <div class="node-title">属性要求</div>
</div> </div>
<div class="node-body"> <div class="node-body">
<div class="property-main"> <div class="property-main">
<div class="property-type">{{ getPropertyTypeName() }}</div> <div class="property-type">{{ getPropertyTypeName() }}</div>
<div v-if="currentProperty.type !== '未选择'" class="property-value">{{ getFormattedValue() }}</div> <div v-if="currentProperty.type !== '未选择'" class="property-value">{{ currentProperty.value }}</div>
<div v-else class="property-placeholder">点击设置属性</div> <div v-else class="property-placeholder">点击设置属性</div>
</div> </div>
<div class="property-details" v-if="currentProperty.type !== '未选择'"> <div class="property-details" v-if="currentProperty.type !== '未选择'">
<div class="property-priority">优先级: {{ getPriorityName() }}</div> <div class="property-priority">优先级: {{ getPriorityName() }}</div>
<!-- 额外信息展示 -->
<div class="property-extra-info" v-if="currentProperty.levelRequired">
<div>{{ getLevelText() }}</div>
<div>{{ getSkillRequirementText() }}</div>
<div>{{ getYuhunSetInfo() }}</div>
</div>
<div class="property-description" v-if="currentProperty.description"> <div class="property-description" v-if="currentProperty.description">
{{ currentProperty.description }} {{ currentProperty.description }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 输出连接点 -->
<Handle type="source" position="right" :id="`${id}-source`" />
</div> </div>
</template> </template>
@@ -258,7 +79,6 @@ defineExpose({
min-width: 180px; min-width: 180px;
min-height: 180px; min-height: 180px;
} }
.node-content { .node-content {
position: relative; position: relative;
background-color: white; background-color: white;
@@ -266,89 +86,28 @@ defineExpose({
border-radius: 4px; border-radius: 4px;
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 180px; min-width: 180px;
min-height: 180px; min-height: 180px;
} }
:deep(.vue-flow__node-resizer) {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
}
:deep(.vue-flow__node-resizer-handle) {
position: absolute;
width: 8px;
height: 8px;
background-color: #409EFF;
border: 1px solid white;
border-radius: 50%;
pointer-events: all;
}
:deep(.vue-flow__node-resizer-handle.top-left) {
top: -4px;
left: -4px;
cursor: nwse-resize;
}
:deep(.vue-flow__node-resizer-handle.top-right) {
top: -4px;
right: -4px;
cursor: nesw-resize;
}
:deep(.vue-flow__node-resizer-handle.bottom-left) {
bottom: -4px;
left: -4px;
cursor: nesw-resize;
}
:deep(.vue-flow__node-resizer-handle.bottom-right) {
bottom: -4px;
right: -4px;
cursor: nwse-resize;
}
.priority-required {
border: 2px solid #f56c6c;
}
.priority-recommended {
border: 2px solid #67c23a;
}
.priority-optional {
border: 1px solid #dcdfe6;
}
.node-header { .node-header {
padding: 8px 10px; padding: 8px 10px;
background-color: #f0f7ff; background-color: #f0f7ff;
border-bottom: 1px solid #dcdfe6; border-bottom: 1px solid #dcdfe6;
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
} }
.node-title { .node-title {
font-weight: bold; font-weight: bold;
font-size: 14px; font-size: 14px;
} }
.node-body { .node-body {
padding: 10px; padding: 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
.property-main { .property-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -356,20 +115,17 @@ defineExpose({
width: 100%; width: 100%;
margin-bottom: 8px; margin-bottom: 8px;
} }
.property-type { .property-type {
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
margin-bottom: 5px; margin-bottom: 5px;
} }
.property-value { .property-value {
font-size: 16px; font-size: 16px;
color: #409eff; color: #409eff;
font-weight: bold; font-weight: bold;
margin-bottom: 5px; margin-bottom: 5px;
} }
.property-placeholder { .property-placeholder {
width: 120px; width: 120px;
height: 40px; height: 40px;
@@ -383,29 +139,16 @@ defineExpose({
margin: 8px 0; margin: 8px 0;
transition: width 0.2s, height 0.2s; transition: width 0.2s, height 0.2s;
} }
.property-details { .property-details {
width: 100%; width: 100%;
border-top: 1px dashed #ebeef5; border-top: 1px dashed #ebeef5;
padding-top: 8px; padding-top: 8px;
} }
.property-priority { .property-priority {
font-size: 12px; font-size: 12px;
color: #606266; color: #606266;
margin-bottom: 5px; margin-bottom: 5px;
} }
.property-extra-info {
font-size: 11px;
color: #909399;
margin-bottom: 5px;
}
.property-extra-info > div {
margin-bottom: 2px;
}
.property-description { .property-description {
font-size: 11px; font-size: 11px;
color: #606266; color: #606266;

View File

@@ -1,78 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'; import {ref, onMounted, inject, watch} from 'vue';
import { Handle, Position, useVueFlow } from '@vue-flow/core'; import { EventType } from '@logicflow/core';
import { NodeResizer } from '@vue-flow/node-resizer';
import '@vue-flow/node-resizer/dist/style.css';
const props = defineProps({
data: Object,
id: String,
selected: Boolean
});
// 获取Vue Flow的实例和节点更新方法
const { findNode, updateNode } = useVueFlow();
// 式神信息保存在节点数据中
const currentShikigami = ref({ name: '未选择式神', avatar: '', rarity: '' }); const currentShikigami = ref({ name: '未选择式神', avatar: '', rarity: '' });
// 节点尺寸 const getNode = inject('getNode') as (() => any) | undefined;
const nodeWidth = ref(180); const getGraph = inject('getGraph') as (() => any) | undefined;
const nodeHeight = ref(180);
// 监听props.data的变化
watch(() => props.data, (newData) => {
if (newData && newData.shikigami) {
currentShikigami.value = newData.shikigami;
}
}, { immediate: true });
// 更新式神信息的方法将由App.vue调用
const updateNodeShikigami = (shikigami) => {
currentShikigami.value = shikigami;
};
// 备用方案:通过全局事件总线监听更新
const handleShikigamiUpdate = (event) => {
const { nodeId, shikigami } = event.detail;
if (nodeId === props.id) {
updateNodeShikigami(shikigami);
}
};
onMounted(() => { onMounted(() => {
console.log('ShikigamiSelectNode mounted:', props.id); const node = getNode?.();
// 添加全局事件监听 const graph = getGraph?.();
window.addEventListener('update-shikigami', handleShikigamiUpdate);
// 初始化时检查是否有数据 // 初始化
if (props.data && props.data.shikigami) { if (node?.properties?.shikigami) {
currentShikigami.value = props.data.shikigami; currentShikigami.value = node.properties.shikigami;
}
// 监听属性变化
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, (eventData: any) => {
if (eventData.id === node.id && eventData.properties?.shikigami) {
currentShikigami.value = eventData.properties.shikigami;
} }
}); });
onUnmounted(() => {
// 移除全局事件监听器
window.removeEventListener('update-shikigami', handleShikigamiUpdate);
}); });
// 导出方法,使父组件可以调用
defineExpose({
updateNodeShikigami
});
// Add Position enum usage to fix type error
const position = Position;
</script> </script>
<template> <template>
<NodeResizer <div
v-if="selected" class="node-content"
/> :style="{
boxSizing: 'border-box',
<Handle type="target" :position="position.Left" :id="`${id}-target`" /> background: '#fff',
borderRadius: '8px'
<div class="node-content"> }"
>
<img <img
v-if="currentShikigami.avatar" v-if="currentShikigami.avatar"
:src="currentShikigami.avatar" :src="currentShikigami.avatar"
@@ -82,8 +45,6 @@ const position = Position;
<div v-else class="placeholder-text">点击选择式神</div> <div v-else class="placeholder-text">点击选择式神</div>
<div class="name-text">{{ currentShikigami.name }}</div> <div class="name-text">{{ currentShikigami.name }}</div>
</div> </div>
<Handle type="source" :position="position.Right" :id="`${id}-source`" />
</template> </template>
<style scoped> <style scoped>
@@ -93,38 +54,18 @@ const position = Position;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.shikigami-image { .shikigami-image {
width: 85%; width: 85%;
height: 85%; height: 85%;
object-fit: cover; object-fit: cover;
} }
.placeholder-text { .placeholder-text {
color: #909399; color: #909399;
font-size: 12px; font-size: 12px;
} }
.name-text { .name-text {
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
margin-top: 8px; margin-top: 8px;
} }
:deep(.vue-flow__node-resizer) {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
}
:deep(.vue-flow__node-resizer-handle) {
width: 8px;
height: 8px;
background-color: #409EFF;
border-radius: 50%;
pointer-events: all;
}
</style> </style>

View File

@@ -1,99 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref, onMounted, onUnmounted, watch} from 'vue'; import { ref, watch, onMounted, inject } from 'vue';
import {Handle, Position, useVueFlow} from '@vue-flow/core'; import { EventType } from '@logicflow/core';
import {NodeResizer} from '@vue-flow/node-resizer'
import '@vue-flow/node-resizer/dist/style.css';
const props = defineProps({
data: Object,
id: String,
selected: Boolean
});
// 获取Vue Flow的实例和节点更新方法
const {findNode, updateNode} = useVueFlow();
// 御魂信息保存在节点数据中
const currentYuhun = ref({ name: '未选择御魂', avatar: '', type: '' }); const currentYuhun = ref({ name: '未选择御魂', avatar: '', type: '' });
// 节点尺寸 const getNode = inject('getNode') as (() => any) | undefined;
const nodeWidth = ref(180); const getGraph = inject('getGraph') as (() => any) | undefined;
const nodeHeight = ref(180);
// 监听props.data的变化
watch(() => props.data, (newData) => {
if (newData && newData.yuhun) {
currentYuhun.value = newData.yuhun;
}
}, {immediate: true});
// 更新御魂信息的方法将由App.vue调用
const updateNodeYuhun = (yuhun) => {
currentYuhun.value = yuhun;
};
// 备用方案:通过全局事件总线监听更新
const handleYuhunUpdate = (event) => {
const {nodeId, yuhun} = event.detail;
if (nodeId === props.id) {
updateNodeYuhun(yuhun);
}
};
// 处理调整大小
const handleResize = (event, {width, height}) => {
// 更新本地状态
nodeWidth.value = width;
nodeHeight.value = height;
// 更新Vue Flow中的节点
const node = findNode(props.id);
if (node) {
const updatedNode = {
...node,
style: {
...node.style,
width: `${width}px`,
height: `${height}px`
}
};
updateNode(props.id, updatedNode);
}
};
onMounted(() => { onMounted(() => {
console.log('YuhunSelectNode mounted:', props.id); const node = getNode?.();
// 添加全局事件监听 const graph = getGraph?.();
window.addEventListener('update-yuhun', handleYuhunUpdate);
// 初始化时检查是否有数据 if (node?.properties?.yuhun) {
if (props.data && props.data.yuhun) { currentYuhun.value = node.properties.yuhun;
currentYuhun.value = props.data.yuhun; }
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, (eventData: any) => {
if (eventData.id === node.id && eventData.properties?.yuhun) {
currentYuhun.value = eventData.properties.yuhun;
} }
}); });
onUnmounted(() => {
// 移除全局事件监听器
window.removeEventListener('update-yuhun', handleYuhunUpdate);
});
// 导出方法,使父组件可以调用
defineExpose({
updateNodeYuhun
}); });
</script> </script>
<template> <template>
<NodeResizer
v-if="selected"
:min-width="150"
:min-height="150"
:max-width="300"
:max-height="300"
/>
<Handle type="target" :position="Position.Left" :id="`${id}-target`" />
<div class="node-content"> <div class="node-content">
<img <img
v-if="currentYuhun.avatar" v-if="currentYuhun.avatar"
@@ -105,59 +37,32 @@ defineExpose({
<div class="name-text">{{ currentYuhun.name }}</div> <div class="name-text">{{ currentYuhun.name }}</div>
<div v-if="currentYuhun.type" class="type-text">{{ currentYuhun.type }}</div> <div v-if="currentYuhun.type" class="type-text">{{ currentYuhun.type }}</div>
</div> </div>
<Handle type="source" :position="Position.Right" :id="`${id}-source`" />
</template> </template>
<style scoped> <style scoped>
.node-content { .node-content {
width: 100%;
height: 100%;
min-width: 180px;
min-height: 180px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.yuhun-image { .yuhun-image {
width: 85%; width: 85%;
height: 85%; height: 85%;
object-fit: cover; object-fit: cover;
} }
.placeholder-text { .placeholder-text {
color: #909399; color: #909399;
font-size: 12px; font-size: 12px;
} }
.name-text { .name-text {
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
margin-top: 8px; margin-top: 8px;
} }
.type-text { .type-text {
font-size: 12px; font-size: 12px;
color: #909399; color: #909399;
margin-top: 4px; margin-top: 4px;
} }
:deep(.vue-flow__node-resizer) {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
}
:deep(.vue-flow__node-resizer-handle) {
width: 8px;
height: 8px;
background-color: #409EFF;
border-radius: 50%;
pointer-events: all;
}
</style> </style>

View File

@@ -6,6 +6,12 @@ import { useGlobalMessage } from "./useGlobalMessage";
const { showMessage } = useGlobalMessage(); const { showMessage } = useGlobalMessage();
// LogicFlow 实例全局引用
let logicFlowInstance: any = null;
function setLogicFlowInstance(lf: any) {
logicFlowInstance = lf;
}
function getDefaultState() { function getDefaultState() {
return { return {
fileList: [ fileList: [
@@ -36,6 +42,69 @@ function getDefaultState() {
x: 350, x: 350,
y: 120, y: 120,
text: "File1-圆形节点" text: "File1-圆形节点"
},
{
id: "node-3",
type: "shikigamiSelect",
x: 200,
y: 300,
properties: {
shikigami: {
name: "时曜泷夜叉姬",
avatar: "/assets/Shikigami/sp/584.png",
rarity: "SP"
},
width: 100,
height: 80
}
},
{
id: "node-yuhun-1",
type: "yuhunSelect",
x: 300,
y: 200,
properties: {
yuhun: {
name: "针女",
avatar: "/assets/Yuhun/针女.png",
type: "攻击类"
},
width: 100,
height: 80
}
},
{
id: "node-property-1",
type: "propertySelect",
x: 500,
y: 300,
properties: {
property: {
type: "attack",
priority: "required",
attackType: "fixed",
attackValue: 3000,
description: "主输出式神,需高攻击",
levelRequired: "40",
skillRequiredMode: "all",
skillRequired: ["5", "5", "5"],
yuhun: {
yuhunSetEffect: [
{ name: "破势", avatar: "/assets/Yuhun/破势.png" },
{ name: "荒骷髅", avatar: "/assets/Yuhun/荒骷髅.png" }
],
target: "1",
property2: ["Attack"],
property4: ["Attack"],
property6: ["Crit", "CritDamage"]
},
expectedDamage: 10000,
survivalRate: 50,
damageType: "balanced"
},
width: 120,
height: 100
}
} }
], ],
edges: [ edges: [
@@ -130,13 +199,26 @@ interface FileGroup {
details: string; details: string;
} }
interface LogicFlowNode {
id: string;
type: string;
x: number;
y: number;
text?: string | object;
properties?: Record<string, any>;
}
interface FlowFile { interface FlowFile {
label: string; label: string;
name: string; name: string;
visible: boolean; visible: boolean;
type: string; type: string;
groups: FileGroup[]; groups: FileGroup[];
flowData?: any; flowData?: {
nodes: LogicFlowNode[];
edges: any[];
viewport: any;
};
} }
export const useFilesStore = defineStore('files', () => { export const useFilesStore = defineStore('files', () => {
@@ -190,7 +272,7 @@ export const useFilesStore = defineStore('files', () => {
}; };
// 添加节点 // 添加节点
const addNode = (node: Node) => { const addNode = (node: LogicFlowNode) => {
const file = fileList.value.find(f => f.name === activeFile.value); const file = fileList.value.find(f => f.name === activeFile.value);
if (!file) return; if (!file) return;
if (!file.flowData) file.flowData = { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }; if (!file.flowData) file.flowData = { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
@@ -198,15 +280,26 @@ export const useFilesStore = defineStore('files', () => {
}; };
// 更新节点 // 更新节点
const updateNode = (nodeId: string, updateData: Partial<Node>) => { const updateNode = (nodeId: string, updateData: Partial<LogicFlowNode>) => {
const file = fileList.value.find(f => f.name === activeFile.value); const file = fileList.value.find(f => f.name === activeFile.value);
if (!file || !file.flowData || !file.flowData.nodes) return; if (!file || !file.flowData || !file.flowData.nodes) return;
const nodeIndex = file.flowData.nodes.findIndex(n => n.id === nodeId); const nodeIndex = file.flowData.nodes.findIndex((n: LogicFlowNode) => n.id === nodeId);
if (nodeIndex === -1) return; if (nodeIndex === -1) return;
file.flowData.nodes[nodeIndex] = {
...file.flowData.nodes[nodeIndex], const oldNode = file.flowData.nodes[nodeIndex];
...updateData, const mergedNode = { ...oldNode, ...updateData };
};
// Deep merge properties
if (updateData.properties && oldNode.properties) {
mergedNode.properties = { ...oldNode.properties, ...updateData.properties };
}
file.flowData.nodes[nodeIndex] = mergedNode;
// 同步 LogicFlow 画布
if (logicFlowInstance && mergedNode.properties) {
// setProperties overwrites, so we pass the fully merged properties object
logicFlowInstance.setProperties(nodeId, mergedNode.properties);
}
}; };
// 删除节点 // 删除节点
@@ -239,14 +332,15 @@ export const useFilesStore = defineStore('files', () => {
const updateNodePosition = (nodeId: string, position: { x: number; y: number }) => { const updateNodePosition = (nodeId: string, position: { x: number; y: number }) => {
const file = fileList.value.find(f => f.name === activeFile.value); const file = fileList.value.find(f => f.name === activeFile.value);
if (!file || !file.flowData || !file.flowData.nodes) return; if (!file || !file.flowData || !file.flowData.nodes) return;
const node = file.flowData.nodes.find(n => n.id === nodeId); const node = file.flowData.nodes.find((n: LogicFlowNode) => n.id === nodeId);
if (node) { if (node) {
node.position = position; node.x = position.x;
node.y = position.y;
} }
}; };
// 更新节点顺序 // 更新节点顺序
const updateNodesOrder = (nodes: Node[]) => { const updateNodesOrder = (nodes: LogicFlowNode[]) => {
const file = fileList.value.find(f => f.name === activeFile.value); const file = fileList.value.find(f => f.name === activeFile.value);
if (!file || !file.flowData) return; if (!file || !file.flowData) return;
file.flowData.nodes = nodes; file.flowData.nodes = nodes;
@@ -422,5 +516,6 @@ export const useFilesStore = defineStore('files', () => {
setupAutoSave, setupAutoSave,
exportData, exportData,
importData, importData,
setLogicFlowInstance,
}; };
}); });