Merge pull request #14 from Powerful-517/feature/logic-flow

Feature/logic flow
This commit is contained in:
rookie
2025-07-30 17:05:33 +08:00
committed by GitHub
29 changed files with 4744 additions and 1582 deletions

View File

@@ -1,81 +1,75 @@
<script setup lang="ts">
import Yys from './components/Yys.vue';
import Toolbar from './components/Toolbar.vue';
import ProjectExplorer from './components/ProjectExplorer.vue';
import {computed, ref, onMounted, onUnmounted} from "vue";
import {useFilesStore} from "@/ts/files";
import ComponentsPanel from './components/flow/ComponentsPanel.vue';
import {computed, ref, onMounted, onUnmounted, onBeforeUpdate, reactive, provide, inject, watch} from "vue";
import {useFilesStore} from "@/ts/useStore";
import Vue3DraggableResizable from 'vue3-draggable-resizable';
import {TabPaneName, TabsPaneContext} from "element-plus";
import YysRank from "@/components/YysRank.vue";
import FlowEditor from './components/flow/FlowEditor.vue';
import ShikigamiSelect from './components/flow/nodes/yys/ShikigamiSelect.vue';
import YuhunSelect from './components/flow/nodes/yys/YuhunSelect.vue';
import PropertySelect from './components/flow/nodes/yys/PropertySelect.vue';
// import { useVueFlow } from '@vue-flow/core';
import DialogManager from './components/DialogManager.vue';
import {getLogicFlowInstance} from "@/ts/useLogicFlow";
const filesStore = useFilesStore();
// const { updateNode,toObject,fromObject } = useVueFlow();
const yysRef = ref(null);
const width = ref('100%');
const height = ref('100vh');
const toolbarHeight = 48; // 工具栏的高度
const windowHeight = ref(window.innerHeight);
const contentHeight = computed(() => `${windowHeight.value - toolbarHeight}px`);
const onResizing = (x, y, width, height) => {
width.value = width;
height.value = height;
};
const element = ref({
x: 400,
y: 20,
width: 1080,
height: windowHeight.value - toolbarHeight,
isActive: false,
});
const handleTabsEdit = (
targetName: String | undefined,
targetName: string | undefined,
action: 'remove' | 'add'
) => {
if (action === 'remove') {
filesStore.closeTab(targetName);
filesStore.removeTab(targetName);
} else if (action === 'add') {
const newFileName = `File ${filesStore.fileList.length + 1}`;
filesStore.addFile({
label: newFileName,
name: newFileName,
visible: true,
type: 'PVE',
groups: [
{
shortDescription: " ",
groupInfo: [{}, {}, {}, {}, {}],
details: ''
},
{
shortDescription: '',
groupInfo: [{}, {}, {}, {}, {}],
details: ''
}
]
});
filesStore.addTab();
}
};
onMounted(() => {
window.addEventListener('resize', () => {
windowHeight.value = window.innerHeight;
});
// 初始化自动保存功能
filesStore.initializeWithPrompt();
filesStore.setupAutoSave();
});
onUnmounted(() => {
window.removeEventListener('resize', () => {
windowHeight.value = window.innerHeight;
});
});
const activeFileGroups = computed(() => {
const activeFile = filesStore.fileList.find(file => file.name === filesStore.activeFile);
return activeFile ? activeFile.groups : [];
});
watch(
() => filesStore.activeFile,
async (newVal, oldVal) => {
// 保存旧 tab 数据
if (oldVal) {
filesStore.updateTab(oldVal);
}
// 渲染新 tab 数据
if (newVal) {
const logicFlowInstance = getLogicFlowInstance();
const currentTab = filesStore.getTab(newVal);
if (logicFlowInstance && currentTab?.graphRawData) {
try {
logicFlowInstance.render(currentTab.graphRawData);
logicFlowInstance.zoom(currentTab.transform.SCALE_X, [currentTab.transform.TRANSLATE_X, currentTab.transform.TRANSLATE_Y]);
} catch (error) {
console.warn('渲染画布数据失败:', error);
}
}
}
}
);
</script>
<template>
@@ -85,10 +79,7 @@ const activeFileGroups = computed(() => {
<!-- 侧边栏和工作区 -->
<div class="main-content">
<!-- 侧边栏 -->
<aside class="sidebar">
<ProjectExplorer :allFiles="filesStore.fileList"/>
</aside>
<ComponentsPanel/>
<!-- 工作区 -->
<div class="workspace">
<el-tabs
@@ -103,15 +94,16 @@ const activeFileGroups = computed(() => {
:key="`${file.name}-${filesStore.activeFile}`"
:label="file.label"
:name="file.name.toString()"
>
<main id="main-container" :style="{ height: contentHeight, overflow: 'auto' }">
<Yys class="yys" :groups="activeFileGroups" v-if="file.type === 'PVE' "/>
<YysRank :groups="activeFileGroups" v-else-if="file.type === 'PVP' "/>
</main>
</el-tab-pane>
/>
</el-tabs>
<div id="main-container" :style="{ height: contentHeight, overflow: 'auto' }">
<FlowEditor
:height="contentHeight"
/>
</div>
</div>
</div>
<DialogManager/>
</div>
</template>
@@ -136,7 +128,7 @@ const activeFileGroups = computed(() => {
}
.sidebar {
width: 20%; /* 侧边栏宽度 */
width: 230px; /* 侧边栏宽度 */
background-color: #f0f0f0; /* 背景色 */
flex-shrink: 0; /* 防止侧边栏被压缩 */
overflow-y: auto; /* 允许侧边栏内容滚动 */
@@ -153,8 +145,8 @@ const activeFileGroups = computed(() => {
height: 100%; /* 确保内容区域占满父容器 */
overflow-y: auto; /* 允许内容滚动 */
min-height: 100vh; /* 允许容器扩展 */
//display: inline-block;
max-width: 100%;
}
</style>
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { useDialogs } from '../ts/useDialogs'
import ShikigamiSelect from './flow/nodes/yys/ShikigamiSelect.vue'
import YuhunSelect from './flow/nodes/yys/YuhunSelect.vue'
import PropertySelect from './flow/nodes/yys/PropertySelect.vue'
import { useFilesStore } from '../ts/useStore'
const { dialogs, closeDialog } = useDialogs();
const filesStore = useFilesStore();
</script>
<template>
<ShikigamiSelect
v-if="dialogs.shikigami.show"
:showSelectShikigami="dialogs.shikigami.show"
:currentShikigami="dialogs.shikigami.data"
@closeSelectShikigami="closeDialog('shikigami')"
@updateShikigami="data => {
dialogs.shikigami.callback?.(data);
closeDialog('shikigami');
}"
/>
<YuhunSelect
v-if="dialogs.yuhun.show"
:showSelectYuhun="dialogs.yuhun.show"
:currentYuhun="dialogs.yuhun.data"
@closeSelectYuhun="closeDialog('yuhun')"
@updateYuhun="data => {
dialogs.yuhun.callback?.(data);
closeDialog('yuhun');
}"
/>
<PropertySelect
v-if="dialogs.property.show"
:showPropertySelect="dialogs.property.show"
:currentProperty="dialogs.property.data"
@closePropertySelect="closeDialog('property')"
@updateProperty="data => {
dialogs.property.callback?.(data);
closeDialog('property');
}"
/>
</template>

View File

@@ -34,7 +34,7 @@
<script setup lang="ts">
import {defineProps, defineEmits, ref} from 'vue';
import {useFilesStore} from "@/ts/files";
import {useFilesStore} from "@/ts/useStore";
import {ElTree, ElButton, ElDropdownMenu, ElDropdownItem} from 'element-plus';
const filesStore = useFilesStore();

View File

@@ -1,88 +0,0 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
<a href="https://on.cypress.io/component" target="_blank" rel="noopener"
>Cypress Component Testing</a
>.
<br />
More instructions are available in <code>README.md</code>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
Discord server, or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also subscribe to
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
the official
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
twitter account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -77,13 +77,14 @@
<script setup lang="ts">
import {ref, reactive, onMounted} from 'vue';
import html2canvas from "html2canvas";
import {useI18n} from 'vue-i18n';
import updateLogs from "../data/updateLog.json"
import filesStoreExample from "../data/filesStoreExample.json"
import {useFilesStore} from "@/ts/files";
import {useFilesStore} from "@/ts/useStore";
import {ElMessageBox} from "element-plus";
import {useGlobalMessage} from "@/ts/useGlobalMessage";
import { getLogicFlowInstance } from "@/ts/useLogicFlow";
// import { useScreenshot } from '@/ts/useScreenshot';
import { getCurrentInstance } from 'vue';
const filesStore = useFilesStore();
const { showMessage } = useGlobalMessage();
@@ -100,6 +101,23 @@ const state = reactive({
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态
});
// 重新渲染 LogicFlow 画布的通用方法
const refreshLogicFlowCanvas = (message?: string) => {
setTimeout(() => {
const logicFlowInstance = getLogicFlowInstance();
if (logicFlowInstance) {
// 获取当前活动文件的数据
const currentFileData = filesStore.getTab(filesStore.activeFile);
if (currentFileData) {
// 清空画布并重新渲染
logicFlowInstance.clearData();
logicFlowInstance.render(currentFileData);
console.log(message || 'LogicFlow 画布已重新渲染');
}
}
}, 100); // 延迟一点确保数据更新完成
};
const loadExample = () => {
ElMessageBox.confirm(
'加载样例会覆盖当前数据,是否覆盖?',
@@ -110,7 +128,30 @@ const loadExample = () => {
type: 'warning',
}
).then(() => {
filesStore.$patch({fileList: filesStoreExample});
// 使用默认状态作为示例
const defaultState = {
fileList: [{
"label": "示例文件",
"name": "example",
"visible": true,
"type": "FLOW",
"groups": [
{
"shortDescription": "示例组",
"groupInfo": [{}, {}, {}, {}, {}],
"details": "这是一个示例文件"
}
],
"flowData": {
"nodes": [],
"edges": [],
"viewport": { "x": 0, "y": 0, "zoom": 1 }
}
}],
activeFile: "example"
};
filesStore.importData(defaultState);
refreshLogicFlowCanvas('LogicFlow 画布已重新渲染(示例数据)');
showMessage('success', '数据已恢复');
}).catch(() => {
showMessage('info', '选择了不恢复旧数据');
@@ -139,14 +180,13 @@ const showFeedbackForm = () => {
};
const handleExport = () => {
const dataStr = JSON.stringify(filesStore.fileList, null, 2);
const blob = new Blob([dataStr], {type: 'application/json;charset=utf-8'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'files.json';
link.click();
URL.revokeObjectURL(url);
// 导出前先更新当前数据,确保不丢失最新修改
filesStore.updateTab();
// 延迟一点确保更新完成后再导出
setTimeout(() => {
filesStore.exportData();
}, 2000);
};
const handleImport = () => {
@@ -154,27 +194,19 @@ const handleImport = () => {
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = e.target.files[0];
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result as string);
if (data[0].visible === true) {
// 新版本格式:直接替换 fileList
filesStore.$patch({fileList: data});
} else {
// 旧版本格式:仅包含 groups 数组
const newFile = {
label: `File ${filesStore.fileList.length + 1}`,
name: String(filesStore.fileList.length + 1),
visible: true,
groups: data
};
filesStore.addFile(newFile);
}
const target = e.target as FileReader;
const data = JSON.parse(target.result as string);
filesStore.importData(data);
// refreshLogicFlowCanvas('LogicFlow 画布已重新渲染(导入数据)');
} catch (error) {
console.error('Failed to import file', error);
showMessage('error', '文件格式错误');
}
};
reader.readAsText(file);
@@ -205,162 +237,46 @@ const applyWatermarkSettings = () => {
};
// 计算视觉总高度
function calculateVisualHeight(selector) {
// 1. 获取所有目标元素
const elements = Array.from(document.querySelectorAll(selector));
// 获取 App 根实例,便于跨组件获取 flowEditorRef
const appInstance = getCurrentInstance();
// 2. 获取元素位置信息并排序
const rects = elements.map(el => {
const rect = el.getBoundingClientRect();
return {
el,
top: rect.top,
bottom: rect.bottom,
height: rect.height
};
}).sort((a, b) => a.top - b.top); // 按垂直位置排序
// 3. 动态分组同行元素
const rows = [];
rects.forEach(rect => {
let placed = false;
// 尝试将元素加入已有行
for (const row of rows) {
if (
rect.top < row.bottom && // 元素顶部在行底部上方
rect.bottom > row.top // 元素底部在行顶部下方
) {
row.elements.push(rect);
row.bottom = Math.max(row.bottom, rect.bottom); // 扩展行底部
row.maxHeight = Math.max(row.maxHeight, rect.height);
placed = true;
break;
}
}
// 未加入则创建新行
if (!placed) {
rows.push({
elements: [rect],
top: rect.top,
bottom: rect.bottom,
maxHeight: rect.height
});
}
});
// 4. 累加每行最大高度
return rows.reduce((sum, row) => sum + row.maxHeight, 0);
}
const ignoreElements = (element) => {
return element.classList.contains('ql-toolbar') || element.classList.contains('el-tabs__header');
};
// const { captureFlow, dataUrl } = useScreenshot();
const prepareCapture = async () => {
state.previewVisible = true;
// 创建临时样式
const style = document.createElement('style');
style.textContent = `
.ql-container.ql-snow {
border: none !important;
}
#main-container {
position: relative;
height: 100%;
overflow-y: auto;
min-height: 100vh;
display: inline-block;
max-width: 100%;
}`;
document.head.appendChild(style);
// 获取目标元素
const element = document.querySelector('#main-container');
if (!element) {
console.error('Element not found');
return;
}
// 保存原始 overflow 样式
const originalOverflow = element.style.overflow;
// 获取 FlowEditor 实例
// 这里假设 App.vue 已将 flowEditorRef 作为全局 property 或 provide
// 或者你可以通过 window.__VUE_DEVTOOLS_GLOBAL_HOOK__.$vm0.$refs.flowEditorRef 方式调试
let flowEditor = null;
try {
// 临时隐藏 overflow 样式
element.style.overflow = 'visible';
// 计算需要忽略的元素高度
let totalHeight = calculateVisualHeight('[data-html2canvas-ignore]') + calculateVisualHeight('.ql-toolbar');
console.log('所有携带指定属性的元素高度之和:', totalHeight);
console.log('主元素宽度', element.scrollWidth);
console.log('主元素高度', element.scrollHeight);
// 1. 生成原始截图
const canvas = await html2canvas(element, {
ignoreElements: ignoreElements,
scrollX: 0,
scrollY: 0,
width: element.scrollWidth,
height: element.scrollHeight - totalHeight,
});
// 2. 创建新Canvas添加水印
const watermarkedCanvas = document.createElement('canvas');
const ctx = watermarkedCanvas.getContext('2d');
// 设置新Canvas尺寸
watermarkedCanvas.width = canvas.width;
watermarkedCanvas.height = canvas.height;
// 绘制原始截图
ctx.drawImage(canvas, 0, 0);
// 添加水印
ctx.font = `${watermark.fontSize}px Arial`;
ctx.fillStyle = watermark.color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 计算每个水印的位置间隔
const colSpace = watermarkedCanvas.width / (watermark.cols + 1);
const rowSpace = watermarkedCanvas.height / (watermark.rows + 1);
// 保存原始画布状态
ctx.save();
// 循环绘制多个水印
for (let row = 1; row <= watermark.rows; row++) {
for (let col = 1; col <= watermark.cols; col++) {
ctx.save(); // 保存当前状态
const x = col * colSpace;
const y = row * rowSpace;
// 移动到目标位置并旋转
ctx.translate(x, y);
ctx.rotate((watermark.angle * Math.PI) / 180);
// 绘制水印文字
ctx.fillText(watermark.text, 0, 0);
ctx.restore(); // 恢复状态
}
// 通过 DOM 查找
const flowEditorDom = document.querySelector('#main-container .flow-editor');
if (!flowEditorDom) {
showMessage('error', '未找到流程图编辑器');
return;
}
ctx.restore(); // 恢复原始状态
// 3. 存储带水印的图片
state.previewImage = watermarkedCanvas.toDataURL();
} catch (error) {
console.error('Capture failed', error);
} finally {
// 恢复原始 overflow 样式
element.style.overflow = originalOverflow;
// 移除临时样式
document.head.removeChild(style);
// 通过 ref 获取 vueflow 根元素
const vueflowRoot = flowEditorDom.querySelector('.vue-flow');
if (!vueflowRoot || !(vueflowRoot instanceof HTMLElement)) {
showMessage('error', '未找到 VueFlow 画布');
return;
}
state.previewVisible = true;
// 截图
const img = await captureFlow(vueflowRoot as HTMLElement, {
type: 'png',
shouldDownload: false,
watermark: {
text: watermark.text,
fontSize: watermark.fontSize,
color: watermark.color,
angle: watermark.angle,
rows: watermark.rows,
cols: watermark.cols,
},
});
state.previewImage = img;
} catch (e) {
showMessage('error', '截图失败: ' + (e?.message || e));
}
};
@@ -368,9 +284,9 @@ const downloadImage = () => {
if (state.previewImage) {
const link = document.createElement('a');
link.href = state.previewImage;
link.download = 'screenshot.png'; // 设置下载的文件名
link.download = 'screenshot.png';
link.click();
state.previewVisible = false; // 关闭预览弹窗
state.previewVisible = false;
}
};

View File

@@ -1,111 +0,0 @@
<template>
<el-dialog v-model="show" :title="t('yuhunSelect')" @close="cancel">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>当前选择{{ current.name }}</span>
<el-button type="danger" icon="Delete" round @click="remove()"></el-button>
</div>
<div style="display: flex; align-items: center;">
<el-input
placeholder="请输入内容"
v-model="searchText"
style="width: 200px; margin-right: 10px;"
/>
</div>
<el-tabs v-model="activeName" type="card" class="demo-tabs" @tab-click="handleTabClick">
<el-tab-pane v-for="type in yuhunTypes" :key="type.name" :label="type.label" :name="type.name">
<div style="max-height: 500px; overflow-y: auto;">
<el-space wrap size="large" style="">
<div v-for="yuhun in filterYuhunByTypeAndSearch(activeName,searchText)" :key="yuhun.name">
<el-button style="width: 100px; height: 100px;" @click="confirm(yuhun)">
<img :src="yuhun.avatar" style="width: 99px; height: 99px;">
</el-button>
<span style="text-align: center; display: block;">{{yuhun.name}}</span>
</div>
</el-space>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>
<script setup lang="ts">
import {ref, watch, computed} from 'vue';
import shikigamiData from '../data/Shikigami.json';
import yuhunData from '../data/Yuhun.json';
import {useI18n} from 'vue-i18n'
// 获取当前的 i18n 实例
const {t} = useI18n()
const props = defineProps({
currentShikigami: {
type: Object,
default: () => ({})
},
showYuhunSelect: Boolean
});
const emit = defineEmits(['updateYuhunSelect', 'closeYuhunSelect']);
const searchText = ref('') // 新增搜索文本
const show = ref(false);
const activeName = ref('ALL');
const current = ref(props.currentShikigami);
const yuhunTypes = [
{label: '全部', name: 'ALL'},
{label: '攻击加成', name: 'Attack'},
{label: '暴击', name: 'Crit'},
{label: '生命加成', name: 'Health'},
{label: '防御加成', name: 'Defense'},
{label: '效果命中', name: 'ControlHit'},
{label: '效果抵抗', name: 'ControlMiss'},
{label: '暴击伤害', name: 'CritDamage'},
{label: '首领御魂', name: 'PVE'}
];
watch(() => props.showYuhunSelect, (newVal) => {
show.value = newVal;
});
watch(() => props.currentShikigami, (newVal) => {
current.value = newVal;
});
const handleTabClick = (tab) => {
console.log(tab.paneName);
};
const filterYuhunByTypeAndSearch = (type: string, search: string) => {
let filteredList = yuhunData;
// 按类型过滤
if (type.toLowerCase() !== 'all') {
filteredList = filteredList.filter(item =>
item.type.toLowerCase() === type.toLowerCase()
);
}
// 按搜索关键字过滤
if (search.trim() !== '') {
filteredList = filteredList.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase())
);
}
return filteredList;
};
const cancel = () => {
emit('closeYuhunSelect');
};
const confirm = (item) => {
emit('updateYuhunSelect', JSON.parse(JSON.stringify(item)), 'Update');
searchText.value=''
activeName.value = 'ALL'
};
const remove = () => {
emit('updateYuhunSelect',undefined,'Remove')
}
</script>

View File

@@ -113,8 +113,8 @@
<script setup lang="ts">
import {ref, reactive, toRefs, nextTick} from 'vue';
import draggable from 'vuedraggable';
import ShikigamiSelect from './ShikigamiSelect.vue';
import ShikigamiProperty from './ShikigamiProperty.vue';
import ShikigamiSelect from './flow/nodes/yys/ShikigamiSelect.vue';
import ShikigamiProperty from './flow/nodes/yys/ShikigamiProperty.vue';
import html2canvas from 'html2canvas';
import {useI18n} from 'vue-i18n'
import { Quill, QuillEditor } from '@vueup/vue-quill'

View File

@@ -81,8 +81,8 @@
<script setup lang="ts">
import {ref, reactive, onMounted} from 'vue';
import shikigami from "../data/Shikigami.json"
import ShikigamiSelect from "@/components/ShikigamiSelect.vue";
import ShikigamiProperty from "@/components/ShikigamiProperty.vue";
import ShikigamiSelect from "@/components/flow/nodes/yys/ShikigamiSelect.vue";
import ShikigamiProperty from "@/components/flow/nodes/yys/ShikigamiProperty.vue";
import _ from "lodash";
import {useI18n} from "vue-i18n";
import draggable from 'vuedraggable';

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref } from 'vue';
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
// 使用嵌套结构定义组件分组
const componentGroups = [
{
id: 'basic',
title: '基础组件',
components: [
{
id: 'rect',
name: '长方形',
type: 'rect',
description: '基础长方形节点',
data: {
width: 150,
height: 150,
style: { background: '#fff', border: '2px solid black' }
}
},
{
id: 'ellipse',
name: '圆形',
type: 'ellipse',
description: '基础圆形节点',
data: {
width: 150,
height: 150,
style: { background: '#fff', border: '2px solid black', borderRadius: '50%' }
}
},
{
id: 'image',
name: '图片',
type: 'imageNode',
description: '可上传图片的节点',
data: {
url: '',
width: 180,
height: 120
}
},
{
id: 'text',
name: '文字编辑框',
type: 'textNode',
description: '可编辑富文本的节点',
data: {
html: '<div>双击右侧可编辑文字</div>',
width: 200,
height: 120
}
}
]
},
{
id: 'yys',
title: '阴阳师',
components: [
{
id: 'shikigami-select',
name: '式神选择器',
type: 'shikigamiSelect',
description: '用于选择式神的组件',
data: {
shikigami: { name: '未选择式神', avatar: '', rarity: '' }
}
},
{
id: 'yuhun-select',
name: '御魂选择器',
type: 'yuhunSelect',
description: '用于选择御魂的组件',
data: {
yuhun: { name: '未选择御魂', avatar: '', type: '' }
}
},
{
id: 'property-select',
name: '属性选择器',
type: 'propertySelect',
description: '用于设置属性要求的组件',
data: {
property: {
type: '未选择',
priority: 'optional',
description: '',
value: 0,
valueType: '',
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"
}
}
}
]
},
// 可以轻松添加新的游戏组件组
{
id: 'other-game',
title: '其他游戏',
components: []
}
];
// 处理组件点击 - 可选:可直接创建节点
const handleComponentClick = (component) => {
// 可选:实现点击直接添加节点到画布
};
const handleMouseDown = (e, component) => {
e.preventDefault(); // 阻止文字选中
const lf = getLogicFlowInstance();
if (!lf) return;
lf.dnd.startDrag({
type: component.type,
properties: component.data
});
};
</script>
<template>
<div class="components-panel">
<h3>组件库</h3>
<!-- 使用两层遍历生成界面元素 -->
<div
v-for="group in componentGroups"
:key="group.id"
class="components-group"
>
<div class="group-title">{{ group.title }}</div>
<div class="components-list">
<div
v-for="component in group.components"
:key="component.id"
class="component-item"
@click="handleComponentClick(component)"
@mousedown="(e) => handleMouseDown(e, component)"
>
<div class="component-icon">
<i class="el-icon-plus"></i>
</div>
<div class="component-info">
<div class="component-name">{{ component.name }}</div>
<div class="component-desc">{{ component.description }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.components-panel {
padding: 10px;
background-color: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 10px;
width: 200px;
display: flex;
flex-direction: column;
}
.components-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.component-item {
display: flex;
align-items: center;
padding: 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
background-color: white;
transition: all 0.3s;
}
.component-item:hover {
background-color: #f2f6fc;
border-color: #c6e2ff;
}
.component-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: #ecf5ff;
border-radius: 4px;
margin-right: 10px;
}
.component-info {
flex: 1;
}
.component-name {
font-weight: bold;
margin-bottom: 4px;
}
.component-desc {
font-size: 12px;
color: #909399;
}
.components-group {
margin-bottom: 18px;
}
.group-title {
font-weight: bold;
font-size: 15px;
margin-bottom: 6px;
color: #333;
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<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>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, defineExpose } from 'vue';
import LogicFlow, { EventType } from '@logicflow/core';
import '@logicflow/core/lib/style/index.css';
import { register } from '@logicflow/vue-node-registry';
import ShikigamiSelectNode from './nodes/yys/ShikigamiSelectNode.vue';
import YuhunSelectNode from './nodes/yys/YuhunSelectNode.vue';
import PropertySelectNode from './nodes/yys/PropertySelectNode.vue';
// import ImageNode from './nodes/common/ImageNode.vue';
// import TextNode from './nodes/common/TextNode.vue';
import PropertyPanel from './PropertyPanel.vue';
import { useFilesStore } from "@/ts/useStore";
import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlow';
const props = defineProps<{
height?: string;
}>();
const containerRef = ref<HTMLElement | 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
onMounted(() => {
lf.value = new LogicFlow({
container: containerRef.value,
// container: document.querySelector('#container'),
grid: true,
allowResize: true,
allowRotate : true
});
registerNodes(lf.value);
setLogicFlowInstance(lf.value);
lf.value.render({
// 渲染的数据
})
// 监听节点点击事件,更新 selectedNode
lf.value.on(EventType.NODE_CLICK, ({ data }) => {
selectedNode.value = data;
});
// 监听空白点击事件,取消选中
lf.value.on(EventType.BLANK_CLICK, () => {
selectedNode.value = null;
});
// 节点属性改变,如果当前节点是选中节点,则同步更新 selectedNode
lf.value.on(EventType.NODE_PROPERTIES_CHANGE, (data) => {
const nodeId = data.id;
if (selectedNode.value && nodeId === selectedNode.value.id) {
if (data.properties) {
selectedNode.value = {
...selectedNode.value,
properties: data.properties
};
}
}
});
// 右键事件
lf.value.on('node:contextmenu', handleNodeContextMenu);
lf.value.on('blank:contextmenu', handlePaneContextMenu);
});
// 销毁 LogicFlow
onBeforeUnmount(() => {
lf.value?.destroy();
lf.value = null;
destroyLogicFlowInstance();
});
// 右键菜单相关
function handleNodeContextMenu({ data, e }: { data: any; e: MouseEvent }) {
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;
}
</script>
<style scoped>
.editor-layout {
display: flex;
height: 100%;
}
.flow-container {
flex: 1;
position: relative;
overflow: hidden;
}
.container {
width: 100%;
min-height: 300px;
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>

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed } from 'vue';
import type LogicFlow from '@logicflow/core';
// import { useVueFlow } from '@vue-flow/core';
import { QuillEditor } from '@vueup/vue-quill';
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { useDialogs } from '../../ts/useDialogs';
import { useFilesStore } from '@/ts/useStore';
import { getLogicFlowInstance } from '@/ts/useLogicFlow';
const props = defineProps({
height: {
type: String,
default: '100%'
},
node: {
type: Object,
default: null
}
});
const filesStore = useFilesStore();
const { openDialog } = useDialogs();
const selectedNode = computed(() => props.node);
const hasNodeSelected = computed(() => !!selectedNode.value);
const nodeType = computed(() => {
if (!selectedNode.value) return '';
return selectedNode.value.type || 'default';
});
// 通用的弹窗处理方法
const handleOpenDialog = (type: 'shikigami' | 'yuhun' | 'property') => {
const lf = getLogicFlowInstance();
if (selectedNode.value && lf) {
const node = selectedNode.value;
// 取 properties 下的 type 字段
const currentData = node.properties && node.properties[type] ? node.properties[type] : undefined;
openDialog(
type,
currentData,
node,
(updatedData) => {
lf.setProperties(node.id, {
...node.properties,
[type]: updatedData
});
}
);
}
};
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
// updateNodeData('url', evt.target.result);
};
reader.readAsDataURL(file);
};
const quillToolbar = [
[{ header: 1 }, { header: 2 }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ list: 'bullet' }, { list: 'ordered' }],
[{ align: [] }],
['clean']
];
</script>
<template>
<div class="property-panel" :style="{ height }">
<div class="panel-header">
<h3>属性编辑</h3>
</div>
<div v-if="!hasNodeSelected" class="no-selection">
<p>请选择一个节点以编辑其属性</p>
</div>
<div v-else class="property-content">
<div class="property-section">
<div class="section-header">基本信息</div>
<div class="property-item">
<div class="property-label">节点ID</div>
<div class="property-value">{{ selectedNode.id }}</div>
</div>
<div class="property-item">
<div class="property-label">节点类型</div>
<div class="property-value">{{ nodeType }}</div>
</div>
</div>
<!-- 式神选择节点的特定属性 -->
<div v-if="nodeType === 'shikigamiSelect'" class="property-section">
<div class="section-header">式神属性</div>
<div class="property-item">
<span>当前选择式神{{ selectedNode.properties?.shikigami?.name || '未选择' }}</span>
<el-button
type="primary"
@click="handleOpenDialog('shikigami')"
style="width: 100%"
>
选择式神
</el-button>
</div>
</div>
<!-- 御魂选择节点的特定属性 -->
<div v-if="nodeType === 'yuhunSelect'" class="property-section">
<div class="section-header">御魂属性</div>
<div class="property-item">
<el-button
type="primary"
@click="handleOpenDialog('yuhun')"
style="width: 100%"
>
选择御魂
</el-button>
</div>
</div>
<!-- 属性选择节点的特定属性 -->
<div v-if="nodeType === 'propertySelect'" class="property-section">
<div class="section-header">属性设置</div>
<div class="property-item">
<el-button
type="primary"
@click="handleOpenDialog('property')"
style="width: 100%"
>
设置属性
</el-button>
</div>
</div>
<!-- 图片节点属性 -->
<div v-if="nodeType === 'imageNode'" class="property-section">
<div class="section-header">图片设置</div>
<div class="property-item">
<input type="file" accept="image/*" @change="handleImageUpload" />
<div v-if="selectedNode.value.properties && selectedNode.value.properties.url" style="margin-top:8px;">
<img :src="selectedNode.value.properties.url" alt="预览" style="max-width:100%;max-height:100px;" />
</div>
</div>
</div>
<!-- 文本节点属性 -->
<div v-if="nodeType === 'textNode'" class="property-section">
<div class="section-header">文本编辑</div>
<div class="property-item">
<!-- <QuillEditor-->
<!-- v-model:content="selectedNode.value.properties.html"-->
<!-- contentType="html"-->
<!-- :toolbar="quillToolbar"-->
<!-- theme="snow"-->
<!-- style="height:120px;"-->
<!-- @update:content="val => updateNodeData('html', val)"-->
<!-- />-->
</div>
</div>
</div>
</div>
</template>
<style scoped>
.property-panel {
background-color: #f5f7fa;
border-left: 1px solid #e4e7ed;
width: 280px;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.panel-header {
padding: 10px;
border-bottom: 1px solid #e4e7ed;
background-color: #e4e7ed;
}
.panel-header h3 {
margin: 0;
font-size: 16px;
color: #303133;
}
.no-selection {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
font-size: 14px;
padding: 20px;
text-align: center;
}
.property-content {
padding: 10px;
}
.property-section {
margin-bottom: 20px;
background-color: white;
border-radius: 4px;
border: 1px solid #dcdfe6;
overflow: hidden;
}
.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 {
font-size: 14px;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,65 @@
<!--<script setup lang="ts">-->
<!--import {ref, watch} from 'vue';-->
<!--import {Handle, useVueFlow} from '@vue-flow/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-->
<!--});-->
<!--const nodeWidth = ref(180);-->
<!--const nodeHeight = ref(120);-->
<!--// 监听props.data变化支持外部更新图片-->
<!--watch(() => props.data, (newData) => {-->
<!-- if (newData && newData.width) nodeWidth.value = newData.width;-->
<!-- if (newData && newData.height) nodeHeight.value = newData.height;-->
<!--}, {immediate: true});-->
<!--</script>-->
<!--<template>-->
<!-- <NodeResizer v-if="selected" :min-width="60" :min-height="60" :max-width="400" :max-height="400"/>-->
<!-- <div class="image-node">-->
<!-- <Handle type="target" position="left" :id="`${id}-target`"/>-->
<!-- <div class="image-content">-->
<!-- <img v-if="props.data && props.data.url" :src="props.data.url" alt="图片节点"-->
<!-- style="width:100%;height:100%;object-fit:contain;"/>-->
<!-- <div v-else class="image-placeholder">未上传图片</div>-->
<!-- </div>-->
<!-- <Handle type="source" position="right" :id="`${id}-source`"/>-->
<!-- </div>-->
<!--</template>-->
<!--<style scoped>-->
<!--.image-node {-->
<!-- background: #fff;-->
<!-- border: 1px solid #dcdfe6;-->
<!-- border-radius: 4px;-->
<!-- display: flex;-->
<!-- flex-direction: column;-->
<!-- align-items: center;-->
<!-- justify-content: center;-->
<!-- overflow: hidden;-->
<!-- position: relative;-->
<!-- width: 100%;-->
<!-- height: 100%;-->
<!-- min-width: 180px;-->
<!-- min-height: 180px;-->
<!--}-->
<!--.image-content {-->
<!-- position: relative;-->
<!-- width: 100%;-->
<!-- height: 100%;-->
<!-- display: flex;-->
<!-- align-items: center;-->
<!-- justify-content: center;-->
<!--}-->
<!--.image-placeholder {-->
<!-- color: #bbb;-->
<!-- font-size: 14px;-->
<!--}-->
<!--</style>-->

View File

@@ -0,0 +1,51 @@
<!--<script setup lang="ts">-->
<!--import { ref, watch } from 'vue';-->
<!--import { Handle, useVueFlow } from '@vue-flow/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-->
<!--});-->
<!--const nodeWidth = ref(200);-->
<!--const nodeHeight = ref(120);-->
<!--const html = ref('');-->
<!--watch(() => props.data, (newData) => {-->
<!-- if (newData && newData.html !== undefined) html.value = newData.html;-->
<!-- if (newData && newData.width) nodeWidth.value = newData.width;-->
<!-- if (newData && newData.height) nodeHeight.value = newData.height;-->
<!--}, { immediate: true });-->
<!--</script>-->
<!--<template>-->
<!-- <div class="text-node" :style="{ width: `${nodeWidth}px`, height: `${nodeHeight}px` }">-->
<!-- <NodeResizer v-if="selected" :min-width="80" :min-height="40" :max-width="400" :max-height="400" />-->
<!-- <Handle type="target" position="left" :id="`${id}-target`" />-->
<!-- <div class="text-content" v-html="html"></div>-->
<!-- <Handle type="source" position="right" :id="`${id}-source`" />-->
<!-- </div>-->
<!--</template>-->
<!--<style scoped>-->
<!--.text-node {-->
<!-- background: #fff;-->
<!-- border: 1px solid #dcdfe6;-->
<!-- border-radius: 4px;-->
<!-- display: flex;-->
<!-- flex-direction: column;-->
<!-- align-items: center;-->
<!-- justify-content: center;-->
<!-- overflow: hidden;-->
<!--}-->
<!--.text-content {-->
<!-- width: 100%;-->
<!-- height: 100%;-->
<!-- padding: 8px;-->
<!-- font-size: 15px;-->
<!-- color: #333;-->
<!-- word-break: break-all;-->
<!-- overflow: auto;-->
<!--}-->
<!--</style>-->

View File

@@ -0,0 +1,341 @@
<template>
<YuhunSelect
:showSelectYuhun="showYuhunSelect"
:currentYuhun="currentYuhun"
@closeSelectYuhun="closeYuhunSelect"
@updateYuhun="updateYuhunSelect"
/>
<el-dialog
v-model="show"
title="属性选择器"
width="80%"
>
<el-form :model="property" label-width="120px">
<el-tabs v-model="activeTab">
<!-- 基础属性选项卡 -->
<el-tab-pane label="基础属性" name="basic">
<el-form-item label="属性类型">
<el-select v-model="property.type" @change="handleTypeChange">
<el-option v-for="type in propertyTypes" :key="type.value" :label="type.label" :value="type.value"/>
</el-select>
</el-form-item>
<!-- 攻击属性 -->
<div v-if="property.type === 'attack'">
<el-form-item label="攻击值类型">
<el-radio-group v-model="property.attackType">
<el-radio label="fixed" size="large">固定值</el-radio>
<el-radio label="percentage" size="large">百分比</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="攻击值">
<el-input-number v-model="property.attackValue" :min="0" :precision="property.attackType === 'percentage' ? 1 : 0" />
<span v-if="property.attackType === 'percentage'">%</span>
</el-form-item>
</div>
<!-- 生命属性 -->
<div v-if="property.type === 'health'">
<el-form-item label="生命值类型">
<el-radio-group v-model="property.healthType">
<el-radio label="fixed" size="large">固定值</el-radio>
<el-radio label="percentage" size="large">百分比</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="生命值">
<el-input-number v-model="property.healthValue" :min="0" :precision="property.healthType === 'percentage' ? 1 : 0" />
<span v-if="property.healthType === 'percentage'">%</span>
</el-form-item>
</div>
<!-- 防御属性 -->
<div v-if="property.type === 'defense'">
<el-form-item label="防御值类型">
<el-radio-group v-model="property.defenseType">
<el-radio label="fixed" size="large">固定值</el-radio>
<el-radio label="percentage" size="large">百分比</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="防御值">
<el-input-number v-model="property.defenseValue" :min="0" :precision="property.defenseType === 'percentage' ? 1 : 0" />
<span v-if="property.defenseType === 'percentage'">%</span>
</el-form-item>
</div>
<!-- 速度属性 -->
<div v-if="property.type === 'speed'">
<el-form-item label="速度值">
<el-input-number v-model="property.speedValue" :min="0" :precision="0" />
</el-form-item>
</div>
<!-- 暴击相关属性 -->
<div v-if="property.type === 'crit'">
<el-form-item label="暴击率">
<el-input-number v-model="property.critRate" :min="0" :max="100" :precision="1" />
<span>%</span>
</el-form-item>
</div>
<div v-if="property.type === 'critDmg'">
<el-form-item label="暴击伤害">
<el-input-number v-model="property.critDmg" :min="0" :precision="1" />
<span>%</span>
</el-form-item>
</div>
<!-- 效果命中与抵抗 -->
<div v-if="property.type === 'effectHit'">
<el-form-item label="效果命中">
<el-input-number v-model="property.effectHitValue" :min="0" :precision="1" />
<span>%</span>
</el-form-item>
</div>
<div v-if="property.type === 'effectResist'">
<el-form-item label="效果抵抗">
<el-input-number v-model="property.effectResistValue" :min="0" :precision="1" />
<span>%</span>
</el-form-item>
</div>
<!-- 所有属性都显示的字段 -->
<el-form-item label="优先级">
<el-select v-model="property.priority">
<el-option label="必须" value="required"/>
<el-option label="推荐" value="recommended"/>
<el-option label="可选" value="optional"/>
</el-select>
</el-form-item>
</el-tab-pane>
<!-- 式神要求选项卡 -->
<el-tab-pane label="式神要求" name="shikigami">
<el-form-item label="等级要求">
<el-radio-group v-model="property.levelRequired" class="ml-4">
<el-radio value="40" size="large">40</el-radio>
<el-radio value="0" size="large">献祭</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="技能要求">
<el-radio-group v-model="property.skillRequiredMode" class="ml-4">
<el-radio value="all" size="large">全满</el-radio>
<el-radio value="111" size="large">111</el-radio>
<el-radio value="custom" size="large">自定义</el-radio>
</el-radio-group>
<div v-if="property.skillRequiredMode === 'custom'" style="display: flex; flex-direction: row; gap: 10px; width: 100%;">
<el-select v-for="(value, index) in property.skillRequired" :key="index" :placeholder="value"
style="margin-bottom: 10px;" @change="updateSkillRequired(index, $event)">
<el-option label="*" value="X"/>
<el-option label="1" value="1"/>
<el-option label="2" value="2"/>
<el-option label="3" value="3"/>
<el-option label="4" value="4"/>
<el-option label="5" value="5"/>
</el-select>
</div>
</el-form-item>
</el-tab-pane>
<!-- 御魂要求选项卡 -->
<el-tab-pane label="御魂要求" name="yuhun">
<div style="display: flex; flex-direction: row; width: 100%;">
<div style="display: flex; flex-direction: column; width: 50%;">
<el-form-item label="御魂套装">
<div style="display: flex; flex-direction: row; flex-wrap: wrap; gap: 5px;">
<img
v-for="(effect, index) in property.yuhun.yuhunSetEffect"
:key="index"
style="width: 50px; height: 50px;"
:src="effect.avatar"
class="image"
@click="openYuhunSelect(index)"
/>
<el-button type="primary" @click="openYuhunSelect(-1)">
<el-icon :size="20">
<CirclePlus/>
</el-icon>
</el-button>
</div>
</el-form-item>
<el-form-item label="御魂效果目标">
<el-select v-model="yuhunTarget" @change="handleYuhunTargetChange">
<el-option v-for="option in yuhunTargetOptions" :key="option.value" :label="t(option.label)" :value="option.value"/>
</el-select>
</el-form-item>
</div>
<div style="display: flex; flex-direction: column; width: 50%;">
<el-form-item label="2号位主属性">
<el-select multiple collapse-tags collapse-tags-tooltip :max-collapse-tags="2"
v-model="property.yuhun.property2">
<el-option label="攻击加成" value="Attack"/>
<el-option label="防御加成" value="Defense"/>
<el-option label="生命加成" value="Health"/>
<el-option label="速度" value="Speed"/>
</el-select>
</el-form-item>
<el-form-item label="4号位主属性">
<el-select multiple collapse-tags collapse-tags-tooltip :max-collapse-tags="2"
v-model="property.yuhun.property4">
<el-option label="攻击加成" value="Attack"/>
<el-option label="防御加成" value="Defense"/>
<el-option label="生命加成" value="Health"/>
<el-option label="效果命中" value="ControlHit"/>
<el-option label="效果抵抗" value="ControlMiss"/>
</el-select>
</el-form-item>
<el-form-item label="6号位主属性">
<el-select multiple collapse-tags collapse-tags-tooltip :max-collapse-tags="2"
v-model="property.yuhun.property6">
<el-option label="攻击加成" value="Attack"/>
<el-option label="防御加成" value="Defense"/>
<el-option label="生命加成" value="Health"/>
<el-option label="暴击" value="Crit"/>
<el-option label="暴击伤害" value="CritDamage"/>
</el-select>
</el-form-item>
</div>
</div>
</el-tab-pane>
<!-- 效果指标选项卡 -->
<el-tab-pane label="效果指标" name="effect">
<el-form-item label="伤害期望">
<el-input-number v-model="property.expectedDamage" :min="0" />
</el-form-item>
<el-form-item label="生存能力">
<el-slider v-model="property.survivalRate" :step="10" :marks="{0: '弱', 50: '中', 100: '强'}" />
</el-form-item>
<el-form-item label="输出偏向">
<el-select v-model="property.damageType">
<el-option label="普攻" value="normal"/>
<el-option label="技能" value="skill"/>
<el-option label="均衡" value="balanced"/>
</el-select>
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-form-item label="额外描述">
<el-input v-model="property.description" type="textarea"/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="confirm">确认</el-button>
<el-button @click="emit('closePropertySelect')">取消</el-button>
</el-form-item>
</el-form>
</el-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CirclePlus } from '@element-plus/icons-vue';
import YuhunSelect from "@/components/flow/nodes/yys/YuhunSelect.vue";
// 获取当前的 i18n 实例
const { t } = useI18n();
const props = defineProps({
currentProperty: {
type: Object,
default: () => ({ type: '未选择属性', priority: 'optional', description: '' })
},
showPropertySelect: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['closePropertySelect', 'updateProperty']);
const show = computed({
get() {
return props.showPropertySelect
},
set(value) {
if (!value) {
emit('closePropertySelect')
}
}
})
const property = ref({});
const activeTab = ref('basic');
const yuhunTarget = ref('');
const yuhunTargetOptions = ref([]);
const showYuhunSelect = ref(false);
const currentYuhun = ref({});
const yuhunSelectIndex = ref(-1);
const propertyTypes = ref([
{ label: '攻击', value: 'attack' },
{ label: '生命', value: 'health' },
{ label: '防御', value: 'defense' },
{ label: '速度', value: 'speed' },
{ label: '暴击', value: 'crit' },
{ label: '暴击伤害', value: 'critDmg' },
{ label: '效果命中', value: 'effectHit' },
{ label: '效果抵抗', value: 'effectResist' }
]);
watch(() => props.currentProperty, (newVal) => {
if (newVal) {
property.value = JSON.parse(JSON.stringify(newVal));
}
}, { deep: true, immediate: true });
const handleTypeChange = (newType) => {
// Reset related fields when type changes
};
const updateSkillRequired = (index, value) => {
property.value.skillRequired[index] = value;
};
const openYuhunSelect = (index) => {
yuhunSelectIndex.value = index;
showYuhunSelect.value = true;
};
const closeYuhunSelect = () => {
showYuhunSelect.value = false;
};
const updateYuhunSelect = (yuhun) => {
if (yuhunSelectIndex.value === -1) {
property.value.yuhun.yuhunSetEffect.push(yuhun);
} else {
property.value.yuhun.yuhunSetEffect[yuhunSelectIndex.value] = yuhun;
}
closeYuhunSelect();
};
const handleYuhunTargetChange = (value) => {
// Handle change
};
const confirm = () => {
emit('updateProperty', property.value);
};
</script>
<style scoped>
.el-form-item {
margin-bottom: 18px;
}
.image {
border-radius: 4px;
border: 1px solid #dcdfe6;
}
</style>

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { ref, watch, onMounted, inject } from 'vue';
import { EventType } from '@logicflow/core';
const currentProperty = ref({ type: '未选择', priority: '可选' });
const getNode = inject('getNode') as (() => any) | undefined;
const getGraph = inject('getGraph') as (() => any) | undefined;
onMounted(() => {
const node = getNode?.();
const graph = getGraph?.();
if (node?.properties?.property) {
currentProperty.value = node.properties.property;
}
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, (eventData: any) => {
if (eventData.id === node.id && eventData.properties?.property) {
currentProperty.value = eventData.properties.property;
}
});
});
// 辅助函数
const getPropertyTypeName = () => {
const typeMap: Record<string, string> = {
'attack': '攻击',
'health': '生命',
'defense': '防御',
'speed': '速度',
'crit': '暴击率',
'critDmg': '暴击伤害',
'effectHit': '效果命中',
'effectResist': '效果抵抗',
'未选择': '未选择'
};
return typeMap[currentProperty.value.type] || currentProperty.value.type;
};
const getPriorityName = () => {
const priorityMap: Record<string, string> = {
'required': '必须',
'recommended': '推荐',
'optional': '可选'
};
return priorityMap[currentProperty.value.priority] || currentProperty.value.priority;
};
</script>
<template>
<div class="property-node" :class="[currentProperty.priority ? `priority-${currentProperty.priority}` : '']">
<div class="node-content">
<div class="node-header">
<div class="node-title">属性要求</div>
</div>
<div class="node-body">
<div class="property-main">
<div class="property-type">{{ getPropertyTypeName() }}</div>
<div v-if="currentProperty.type !== '未选择'" class="property-value">{{ currentProperty.value }}</div>
<div v-else class="property-placeholder">点击设置属性</div>
</div>
<div class="property-details" v-if="currentProperty.type !== '未选择'">
<div class="property-priority">优先级: {{ getPriorityName() }}</div>
<div class="property-description" v-if="currentProperty.description">
{{ currentProperty.description }}
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.property-node {
position: relative;
width: 100%;
height: 100%;
min-width: 180px;
min-height: 180px;
}
.node-content {
position: relative;
background-color: white;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%;
height: 100%;
min-width: 180px;
min-height: 180px;
}
.node-header {
padding: 8px 10px;
background-color: #f0f7ff;
border-bottom: 1px solid #dcdfe6;
border-radius: 4px 4px 0 0;
}
.node-title {
font-weight: bold;
font-size: 14px;
}
.node-body {
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
}
.property-main {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin-bottom: 8px;
}
.property-type {
font-size: 16px;
font-weight: bold;
margin-bottom: 5px;
}
.property-value {
font-size: 16px;
color: #409eff;
font-weight: bold;
margin-bottom: 5px;
}
.property-placeholder {
width: 120px;
height: 40px;
border: 1px dashed #c0c4cc;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 12px;
border-radius: 4px;
margin: 8px 0;
transition: width 0.2s, height 0.2s;
}
.property-details {
width: 100%;
border-top: 1px dashed #ebeef5;
padding-top: 8px;
}
.property-priority {
font-size: 12px;
color: #606266;
margin-bottom: 5px;
}
.property-description {
font-size: 11px;
color: #606266;
margin-top: 5px;
border-top: 1px dashed #ebeef5;
padding-top: 5px;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,627 @@
<template>
<ShikigamiSelect
:showSelectShikigami="state.showSelectShikigami"
:currentShikigami="state.currentShikigami"
@closeSelectShikigami="closeSelectShikigami"
@updateShikigami="updateShikigami"
/>
<ShikigamiProperty
:showProperty="state.showProperty"
:currentShikigami="state.currentShikigami"
@closeProperty="closeProperty"
@updateProperty="updateProperty"
/>
<div class="group-item">
<div class="group-header">
<div class="group-opt" data-html2canvas-ignore="true">
<div class="opt-left">
<el-button type="primary" icon="CopyDocument" @click="copy(group.shortDescription)">{{ t('Copy') }}
</el-button>
<el-button type="primary" icon="Document" @click="paste(groupIndex,'shortDescription')">{{
t('Paste')
}}
</el-button>
</div>
<div class="opt-right">
<el-button class="drag-handle" type="primary" icon="Rank" circle></el-button>
<el-button type="primary" @click="addGroup">{{ t('AddGroup') }}</el-button>
<el-button type="primary" @click="addGroupElement(groupIndex)">{{ t('AddShikigami') }}</el-button>
<el-button type="danger" icon="Delete" circle @click="removeGroup(groupIndex)"></el-button>
</div>
</div>
<QuillEditor ref="shortDescriptionEditor" v-model:content="group.shortDescription" contentType="html" theme="snow"
:toolbar="toolbarOptions"/>
</div>
<div class="group-body">
<draggable :list="props.group.groupInfo" item-key="name" class="body-content">
<template #item="{element : position, index:positionIndex}">
<div>
<el-col>
<el-card class="group-card" shadow="never">
<div class="opt-btn" data-html2canvas-ignore="true">
<!-- Add delete button here -->
<el-button type="danger" icon="Delete" circle @click="removeGroupElement(positionIndex)"/>
<!-- <el-button type="primary" icon="Plus" circle @click="addGroupElement(groupIndex)"/> -->
</div>
<div class="avatar-container">
<!-- 头像图片 -->
<img :src="position.avatar || '/assets/Shikigami/default.png'"
style="cursor: pointer; vertical-align: bottom;"
class="avatar-image"
@click="editShikigami(positionIndex)"/>
<!-- 文字图层 -->
<span v-if="position.properties">{{
position.properties.levelRequired
}} {{ position.properties.skillRequired.join('') }}</span>
</div>
<div class="property-wrap">
<div style="display: flex; justify-content: center;" data-html2canvas-ignore="true">
<span>{{ position.name || "" }}</span>
</div>
<div style="display: flex; justify-content: center;" class="bottom" data-html2canvas-ignore="true">
<el-button @click="editProperty(groupIndex,positionIndex)">{{ t('editProperties') }}
</el-button>
</div>
<div v-if="position.properties">
<div style="display: flex; justify-content: center;">
<span
style="width: 100px;height: 50px;background-color: #666;
border-radius: 5px; margin-right: 5px; color: white;
text-align: center; white-space: pre-wrap; display: flex; align-items: center; justify-content: center; flex-direction: column ">
{{ getYuhunNames(position.properties.yuhun.yuhunSetEffect) }}<br/>{{
t('yuhun_target.shortName.' + position.properties.yuhun.target)
}}·{{ getYuhunPropertyNames(position.properties.yuhun) }}
</span>
</div>
<div>
<span
style="display: inline-block; width: 100px; height: 30px; border-radius: 5px; margin-right: 5px; color: red; text-align: center; white-space: pre-wrap; display: flex; align-items: center; justify-content: center; flex-direction: column ">
{{ position.properties.desc }}
</span>
</div>
</div>
</div>
</el-card>
</el-col>
</div>
</template>
</draggable>
</div>
<div class="group-footer">
<div class="group-opt" data-html2canvas-ignore="true">
<div class="opt-left">
<el-button type="primary" icon="CopyDocument" @click="copy(group.details)">{{ t('Copy') }}</el-button>
<el-button type="primary" icon="Document" @click="paste(groupIndex,'details')">{{
t('Paste')
}}
</el-button>
</div>
</div>
<QuillEditor ref="detailsEditor" v-model:content="group.details" contentType="html" theme="snow"
:toolbar="toolbarOptions"/>
</div>
</div>
<div class="divider-horizontal"></div>
</template>
<script setup lang="ts">
import {ref, reactive, toRefs, nextTick} from 'vue';
import ShikigamiSelect from './ShikigamiSelect.vue';
import ShikigamiProperty from './ShikigamiProperty.vue';
import html2canvas from 'html2canvas';
import {useI18n} from 'vue-i18n'
import { Quill, QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.bubble.css'
import '@vueup/vue-quill/dist/vue-quill.snow.css' // 引入样式文件
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import shikigamiData from '../../../../data/Shikigami.json';
import _ from 'lodash';
import {Action, ElMessage, ElMessageBox} from "element-plus";
import { useGlobalMessage } from '../../../../ts/useGlobalMessage';
import draggable from 'vuedraggable';
const props = defineProps<{
groups:any[];
group: any;
groupIndex;
}>();
const groupIndex = props.groupIndex
// 定义响应式数据
const state = reactive({
showSelectShikigami: false,
showProperty: false,
groupIndex: 0,
positionIndex: 0,
currentShikigami: {}
});
const clipboard = ref('');
const dialogTableVisible = ref(false)
const {showMessage} = useGlobalMessage();
// 获取当前的 i18n 实例
const {t} = useI18n()
// 定义 QuillEditor 的 ref
const shortDescriptionEditor = ref<InstanceType<typeof QuillEditor>>()
const detailsEditor = ref<InstanceType<typeof QuillEditor>>()
const removeGroupElement = async ( positionIndex: number) => {
if (props.group.groupInfo.length === 1) {
showMessage('warning', '无法删除');
return;
}
try {
await ElMessageBox.confirm('确定要删除此元素吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
props.group.groupInfo.splice(positionIndex, 1);
showMessage('success', '删除成功!');
} catch (error) {
showMessage('info', '已取消删除');
}
};
const removeGroup = async (groupIndex: number) => {
if (props.groups.length === 1) {
showMessage('warning', '无法删除最后一个队伍');
return;
}
try {
await ElMessageBox.confirm('确定要删除此组吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
props.groups.splice(groupIndex, 1);
showMessage('success', '删除成功!');
} catch (error) {
showMessage('info', '已取消删除');
}
};
const addGroup = () => {
props.group.groupInfo.push({
shortDescription: '',
groupInfo: [{}, {}, {}, {}, {}],
details: ''
});
const container = document.getElementById('main-container');
nextTick(() => {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth' // 可选平滑滚动
});
})
};
const addGroupElement = (groupIndex) => {
props.group.groupInfo.push({});
editShikigami(props.group.groupInfo.length - 1);
};
const editShikigami = ( positionIndex) => {
console.log("==== 选择式神 ===", groupIndex, positionIndex);
state.showSelectShikigami = true;
state.positionIndex = positionIndex;
state.currentShikigami = props.group.groupInfo[positionIndex];
};
const getYuhunNames = (yuhunSetEffect) => {
const names = yuhunSetEffect.map(item => item.name).join('');
if (names.length <= 6) {
return names;
} else {
return yuhunSetEffect.map(item => item.shortName || item.name).join('');
}
}
const getYuhunPropertyNames = (yuhun) => {
// 根据条件处理 yuhun.property2
let property2Value, property4Value, property6Value;
if (yuhun.property2.length >= 4) {
property2Value = 'X';
} else {
property2Value = t('yuhun_property.shortName.' + yuhun.property2[0]);
}
if (yuhun.property4.length >= 5) {
property4Value = 'X';
} else {
property4Value = t('yuhun_property.shortName.' + yuhun.property4[0]);
}
if (yuhun.property6.length >= 5) {
property6Value = 'X';
} else {
property6Value = t('yuhun_property.shortName.' + yuhun.property6[0]);
}
// 构建 propertyNames 字符串
const propertyNames =
property2Value + property4Value + property6Value
return propertyNames;
}
const copy = (str) => {
clipboard.value = str
}
const paste = (groupIndex, type) => {
console.log("paste", groupIndex, type, clipboard.value)
if ('shortDescription' == type)
props.group.shortDescription = clipboard.value
else if ('details' == type)
props.group.details = clipboard.value
}
const registerFonts = () => {
const Font = Quill.import('attributors/style/font')
Font.whitelist = ['SimSun', 'SimHei', 'KaiTi', 'FangSong', 'Microsoft YaHei', 'PingFang SC']
Quill.register(Font, true)
}
// 自定义字号注册
const registerSizes = () => {
const Size = Quill.import('attributors/style/size')
Size.whitelist = ['12px', '14px', '16px', '18px', '21px', '29px', '32px', '34px']
Quill.register(Size, true)
}
// 执行注册
registerFonts()
registerSizes()
// 工具栏配置
const toolbarOptions = ref([
[{font: ['SimSun', 'SimHei', 'KaiTi', 'FangSong', 'Microsoft YaHei', 'PingFang SC']}],
[{header: 1}, {header: 2}],
[{size: ['12px', '14px', '16px', '18px', '21px', '29px', '32px', '34px']}],
['bold', 'italic', 'underline', 'strike'],
[{color: []}, {background: []}],
// ['blockquote', 'code-block'],
[{list: 'bullet'}, {list: 'ordered'}, {'list': 'check'}],
[{indent: '-1'}, {indent: '+1'}],
[{align: []}],
[{direction: 'rtl'}],
// [{ header: [1, 2, 3, 4, 5, 6, false] }],
// ['link', 'image', 'video'],
// ['clean']
] as const)
const saveQuillDesc = async (): Promise<string> => {
if (!shortDescriptionEditor.value) {
throw new Error('Quill editor instance not found')
}
return shortDescriptionEditor.value.getHTML()
}
// 保存方法
const saveQuillDetail = async (): Promise<string> => {
if (!detailsEditor.value) {
throw new Error('Quill detailsEditor instance not found')
}
return detailsEditor.value.getHTML()
}
const updateShikigami = (shikigami) => {
state.showSelectShikigami = false;
const oldProperties = props.group.groupInfo[state.positionIndex].properties;
props.group.groupInfo[state.positionIndex] = _.cloneDeep(shikigami);
props.group.groupInfo[state.positionIndex].properties = oldProperties;
};
const editProperty = (groupIndex, positionIndex) => {
state.showProperty = true;
state.groupIndex = groupIndex;
state.positionIndex = positionIndex;
state.currentShikigami = props.group.groupInfo[positionIndex];
};
const closeProperty = () => {
state.showProperty = false;
state.currentShikigami = {};
};
const updateProperty = (property) => {
state.showProperty = false;
state.currentShikigami = {};
props.group.groupInfo[state.positionIndex].properties = _.cloneDeep(property);
};
const closeSelectShikigami = () => {
console.log("close select ====");
state.showSelectShikigami = false;
state.currentShikigami = {};
};
// 暴露方法给父组件
defineExpose({
saveQuillDesc,
saveQuillDetail
});
</script>
<style scoped>
.drag-handle {
cursor: move;
}
.position-drag-handle {
cursor: move;
}
.ql-toolbar {
display: none;
}
/* 正方形容器 */
.avatar-wrapper {
width: 100px; /* 正方形边长 */
height: 100px; /* 与宽度相同 */
position: relative;
overflow: hidden; /* 隐藏超出部分 */
border-radius: 50%; /* 圆形裁剪 */
//border: 2px solid #fff; /* 可选:添加边框 */ //box-shadow: 0 2px 8px rgba(0,0,0,0.1); /* 可选:添加阴影 */
}
/* 图片样式 */
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover; /* 关键属性:保持比例填充容器 */
object-position: center; /* 居中显示 */
display: block;
cursor: pointer;
}
.el-card {
border: 0;
}
.group-header {
margin: 10px;
padding: 10px;
}
.group-opt {
padding: 10px;
display: flex;
justify-content: space-between;
}
.group-item {
width: 100%;
}
.group-body {
padding: 20px;
width: 80%;
}
.body-content {
display: flex;
flex-direction: row;
width: 20%;
}
.group-card {
position: relative;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
.avatar-container {
position: relative;
min-width: 100px;
}
.avatar-container span {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%) translateY(50%);
font-size: 24px;
color: white;
text-shadow: -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black, 1px 1px 0 black;
white-space: nowrap;
padding: 0 8px;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.opt-btn {
position: absolute;
top: 0px;
right: 0px;
z-index: 10;
opacity: 0;
}
}
.property-wrap {
margin: 10px 0;
}
/* 当鼠标悬停在容器上时显示按钮 */
.group-card:hover .opt-btn {
opacity: 1;
}
.group-footer {
margin: 10px;
padding: 10px;
}
</style>
<style>
.ql-container {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
.ql-toolbar {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
.ql-tooltip[data-mode="link"]::before {
content: "链接地址:";
}
.ql-tooltip[data-mode="video"]::before {
content: "视频地址:";
}
.ql-tooltip.ql-editing {
a.ql-action::after {
content: "保存";
border-right: 0px;
padding-right: 0px;
}
}
.ql-picker.ql-font {
.ql-picker-label[data-value=SimSun]::before,
.ql-picker-item[data-value=SimSun]::before {
content: "宋体";
font-family: SimSun;
font-size: 15px;
}
.ql-picker-label[data-value=SimHei]::before,
.ql-picker-item[data-value=SimHei]::before {
content: "黑体";
font-family: SimHei;
font-size: 15px;
}
.ql-picker-label[data-value=KaiTi]::before,
.ql-picker-item[data-value=KaiTi]::before {
content: "楷体";
font-family: KaiTi;
font-size: 15px;
}
.ql-picker-label[data-value=FangSong]::before,
.ql-picker-item[data-value=FangSong]::before {
content: "仿宋";
font-family: FangSong;
font-size: 15px;
}
.ql-picker-label[data-value="Microsoft YaHei"]::before,
.ql-picker-item[data-value="Microsoft YaHei"]::before {
content: "微软雅黑";
font-family: 'Microsoft YaHei';
font-size: 15px;
}
.ql-picker-label[data-value="PingFang SC"]::before,
.ql-picker-item[data-value="PingFang SC"]::before {
content: "苹方";
font-family: 'PingFang SC';
font-size: 15px;
}
}
.ql-picker.ql-size {
.ql-picker-label::before,
.ql-picker-item::before {
font-size: 14px !important;
content: "五号" !important;
}
.ql-picker-label[data-value="12px"]::before {
content: "小五" !important;
}
.ql-picker-item[data-value="12px"]::before {
font-size: 12px;
content: "小五" !important;
}
.ql-picker-label[data-value="16px"]::before {
content: "小四" !important;
}
.ql-picker-item[data-value="16px"]::before {
font-size: 16px;
content: "小四" !important;
}
.ql-picker-label[data-value="18px"]::before {
content: "四号" !important;
}
.ql-picker-item[data-value="18px"]::before {
font-size: 18px;
content: "四号" !important;
}
.ql-picker-label[data-value="21px"]::before {
content: "三号" !important;
}
.ql-picker-item[data-value="21px"]::before {
font-size: 21px;
content: "三号" !important;
}
.ql-picker-label[data-value="24px"]::before {
content: "小二" !important;
}
.ql-picker-item[data-value="24px"]::before {
font-size: 24px;
content: "小二" !important;
}
.ql-picker-label[data-value="29px"]::before {
content: "二号" !important;
}
.ql-picker-item[data-value="29px"]::before {
font-size: 29px;
content: "二号" !important;
}
.ql-picker-label[data-value="32px"]::before {
content: "小一" !important;
}
.ql-picker-item[data-value="32px"]::before {
font-size: 32px;
content: "小一" !important;
}
.ql-picker-label[data-value="34px"]::before {
content: "一号" !important;
}
.ql-picker-item[data-value="34px"]::before {
font-size: 34px;
content: "一号" !important;
}
}
}
</style>

View File

@@ -123,10 +123,10 @@
</template>
<script setup>
import propertyData from "../data/property.json";
import propertyData from "../../../../data/property.json";
import {ref, watch, computed} from 'vue'
import ShikigamiSelect from "@/components/ShikigamiSelect.vue";
import YuhunSelect from "@/components/YuhunSelect.vue";
import ShikigamiSelect from "@/components/flow/nodes/yys/ShikigamiSelect.vue";
import YuhunSelect from "@/components/flow/nodes/yys/YuhunSelect.vue";
import {useI18n} from 'vue-i18n'
// import YuhunSelect from "./YuhunSelect.vue";

View File

@@ -2,10 +2,8 @@
<el-dialog
v-model="show"
title="请选择式神"
@close="cancel"
:before-close="cancel"
>
<span>当前选择式神{{ current.name }}</span>
<span>当前选择式神{{ props.currentShikigami.name }}</span>
<div style="display: flex; align-items: center;">
<el-input
placeholder="请输入内容"
@@ -45,9 +43,9 @@
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ref, computed } from 'vue'
import type { TabsPaneContext } from 'element-plus'
import shikigamiData from "../data/Shikigami.json"
import shikigamiData from "../../../../data/Shikigami.json"
interface Shikigami {
name: string
@@ -58,7 +56,7 @@ interface Shikigami {
const props = defineProps({
currentShikigami: {
type: Object as () => Shikigami,
default: () => ({ name: '' })
default: () => ({ name: '未选择式神', avatar: '', rarity: '' })
},
showSelectShikigami: {
type: Boolean,
@@ -68,10 +66,19 @@ const props = defineProps({
const emit = defineEmits(['closeSelectShikigami', 'updateShikigami'])
const searchText = ref('') //
const show = computed({
get() {
return props.showSelectShikigami
},
set(value) {
if (!value) {
emit('closeSelectShikigami')
}
}
})
const searchText = ref('')
const activeName = ref('ALL')
let current = ref({name:''})
const show = ref(false)
const rarityLevels = [
{ label: "全部", name: "ALL" },
@@ -84,34 +91,16 @@ const rarityLevels = [
{ label: "呱太", name: "G" },
]
watch(() => props.showSelectShikigami, (newVal) => {
show.value = newVal
})
watch(() => props.currentShikigami, (newVal) => {
console.log("ShikigamiSelect.vue" + current.value.name)
current.value = newVal
console.log("ShikigamiSelect.vue" + current.value.name)
}, {deep: true})
const handleClick = (tab: TabsPaneContext) => {
console.log('Tab clicked:', tab)
}
const cancel = () => {
emit('closeSelectShikigami')
show.value = false
}
const confirm = (shikigami: Shikigami) => {
emit('updateShikigami', shikigami)
searchText.value=''
activeName.value='ALL'
// cancel()
searchText.value = ''
activeName.value = 'ALL'
}
//
const filterShikigamiByRarityAndSearch = (rarity: string, search: string) => {
let filteredList = shikigamiData;

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import {ref, onMounted, inject, watch} from 'vue';
import { EventType } from '@logicflow/core';
const currentShikigami = ref({ name: '未选择式神', avatar: '', rarity: '' });
const getNode = inject('getNode') as (() => any) | undefined;
const getGraph = inject('getGraph') as (() => any) | undefined;
onMounted(() => {
const node = getNode?.();
const graph = getGraph?.();
// 初始化
if (node?.properties?.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;
}
});
});
</script>
<template>
<div
class="node-content"
:style="{
boxSizing: 'border-box',
background: '#fff',
borderRadius: '8px'
}"
>
<img
v-if="currentShikigami.avatar"
:src="currentShikigami.avatar"
:alt="currentShikigami.name"
class="shikigami-image"
draggable="false"
/>
<div v-else class="placeholder-text">点击选择式神</div>
<div class="name-text">{{ currentShikigami.name }}</div>
</div>
</template>
<style scoped>
.node-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.shikigami-image {
width: 85%;
height: 85%;
object-fit: cover;
}
.placeholder-text {
color: #909399;
font-size: 12px;
}
.name-text {
font-size: 14px;
text-align: center;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<el-dialog
v-model="show"
title="请选择御魂"
>
<span>当前选择御魂{{ props.currentYuhun.name }}</span>
<div style="display: flex; align-items: center;">
<el-input
placeholder="请输入内容"
v-model="searchText"
style="width: 200px; margin-right: 10px;"
/>
</div>
<el-tabs
v-model="activeName"
type="card"
class="demo-tabs"
@tab-click="handleClick"
editable
>
<el-tab-pane
v-for="(type, index) in yuhunTypes"
:key="index"
:label="type.label"
:name="type.name"
>
<div style="max-height: 600px; overflow-y: auto;">
<el-space wrap size="large">
<div style="display: flex;flex-direction: column;justify-content: center" v-for="i in filterYuhunByTypeAndSearch(type.name, searchText)" :key="i.name">
<el-button
style="width: 100px; height: 100px;"
@click.stop="confirm(i)"
>
<img :src="i.avatar" style="width: 99px; height: 99px;">
</el-button>
<span style="text-align: center; display: block;">{{i.name}}</span>
</div>
</el-space>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { TabsPaneContext } from 'element-plus'
import yuhunData from "../../../../data/Yuhun.json"
interface Yuhun {
name: string
shortName?: string
type: string
avatar: string
}
const props = defineProps({
currentYuhun: {
type: Object as () => Yuhun,
default: () => ({ name: '未选择御魂', type: '', avatar: '' })
},
showSelectYuhun: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['closeSelectYuhun', 'updateYuhun'])
const show = computed({
get() {
return props.showSelectYuhun
},
set(value) {
if (!value) {
emit('closeSelectYuhun')
}
}
})
const searchText = ref('') // 搜索文本
const activeName = ref('ALL')
const yuhunTypes = [
{ label: "全部", name: "ALL" },
{ label: "攻击类", name: "attack" },
{ label: "暴击类", name: "Crit" },
{ label: "生命类", name: "Health" },
{ label: "防御类", name: "Defense" },
{ label: "效果命中", name: "Effect" },
{ label: "效果抵抗", name: "EffectResist" },
{ label: "特殊类", name: "Special" }
]
const handleClick = (tab: TabsPaneContext) => {
console.log('Tab clicked:', tab)
}
const confirm = (yuhun: Yuhun) => {
emit('updateYuhun', yuhun)
searchText.value = ''
activeName.value = 'ALL'
}
// 过滤函数
const filterYuhunByTypeAndSearch = (type: string, search: string) => {
let filteredList = yuhunData;
if (type.toLowerCase() !== 'all') {
filteredList = filteredList.filter(item =>
item.type.toLowerCase() === type.toLowerCase()
);
}
if (search.trim() !== '') {
return filteredList.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase())
);
}
return filteredList;
}
</script>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { ref, watch, onMounted, inject } from 'vue';
import { EventType } from '@logicflow/core';
const currentYuhun = ref({ name: '未选择御魂', avatar: '', type: '' });
const getNode = inject('getNode') as (() => any) | undefined;
const getGraph = inject('getGraph') as (() => any) | undefined;
onMounted(() => {
const node = getNode?.();
const graph = getGraph?.();
if (node?.properties?.yuhun) {
currentYuhun.value = node.properties.yuhun;
}
graph?.eventCenter.on(EventType.NODE_PROPERTIES_CHANGE, (eventData: any) => {
if (eventData.id === node.id && eventData.properties?.yuhun) {
currentYuhun.value = eventData.properties.yuhun;
}
});
});
</script>
<template>
<div class="node-content">
<img
v-if="currentYuhun.avatar"
:src="currentYuhun.avatar"
:alt="currentYuhun.name"
class="yuhun-image"
draggable="false"
/>
<div v-else class="placeholder-text">点击选择御魂</div>
<div class="name-text">{{ currentYuhun.name }}</div>
<div v-if="currentYuhun.type" class="type-text">{{ currentYuhun.type }}</div>
</div>
</template>
<style scoped>
.node-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.yuhun-image {
width: 85%;
height: 85%;
object-fit: cover;
}
.placeholder-text {
color: #909399;
font-size: 12px;
}
.name-text {
font-size: 14px;
text-align: center;
margin-top: 8px;
}
.type-text {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@@ -16,7 +16,7 @@ import zh from './locales/zh.json'
import ja from './locales/ja.json'
import { createPinia } from 'pinia' // 导入 Pinia
import {useFilesStore} from "@/ts/files";
import { useFilesStore } from './ts/useStore';
const app = createApp(App)
@@ -62,6 +62,4 @@ app.use(pinia) // 使用 Pinia
.use(Vue3DraggableResizable)
.mount('#app')
const filesStore = useFilesStore();
filesStore.setupAutoSave();
filesStore.initializeWithPrompt();
const filesStore = useFilesStore();

View File

@@ -1,130 +0,0 @@
import {defineStore} from 'pinia';
import {ElMessageBox} from "element-plus";
import {useGlobalMessage} from "./useGlobalMessage";
const { showMessage } = useGlobalMessage();
function getDefaultState() {
return {
fileList: [{
"label": "File 1",
"name": "1",
"visible": true,
"type":"PVE",
"groups": [
{
"shortDescription": "",
"groupInfo": [
{}, {}, {}, {}, {}
],
"details": ""
}
]
}],
activeFile: "1",
};
}
function saveStateToLocalStorage(state) {
localStorage.setItem('filesStore', JSON.stringify(state));
}
function clearFilesStoreLocalStorage() {
localStorage.removeItem('filesStore')
}
function loadStateFromLocalStorage() {
return JSON.parse(localStorage.getItem('filesStore'));
}
export const useFilesStore = defineStore('files', {
state: () => getDefaultState(),
getters: {
visibleFiles: (state) => state.fileList.filter(file => file.visible),
},
actions: {
initializeWithPrompt() {
const savedState = loadStateFromLocalStorage();
const defaultState = getDefaultState();
const isSame = JSON.stringify(savedState) === JSON.stringify(defaultState);
if (savedState && !isSame) {
ElMessageBox.confirm(
'检测到有未保存的旧数据,是否恢复?',
'提示',
{
confirmButtonText: '恢复',
cancelButtonText: '不恢复',
type: 'warning',
}
).then(() => {
this.fileList = savedState.fileList || [];
this.activeFile = savedState.activeFile || "1";
showMessage('success', '数据已恢复');
}).catch(() => {
clearFilesStoreLocalStorage();
showMessage('info', '选择了不恢复旧数据');
});
}
},
setupAutoSave() {
setInterval(() => {
saveStateToLocalStorage(this.$state);
}, 30000); // 设置间隔时间为30秒
},
addFile(file) {
this.fileList.push({...file, visible: true});
this.activeFile = file.name;
},
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(fileName: String) {
const file = this.fileList.find(file => file.name === fileName);
if (file) {
file.visible = false;
if (this.activeFile === fileName) {
const nextVisibleFile = this.visibleFiles[0];
this.activeFile = nextVisibleFile ? nextVisibleFile.name : -1;
}
}
},
async deleteFile(fileId: string) {
try {
if (this.fileList.length === 1) {
showMessage('warning', '无法删除');
return;
}
await ElMessageBox.confirm('确定要删除此文件吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
const index = this.fileList.findIndex(file => file.name === fileId);
if (index > -1) {
this.fileList.splice(index, 1);
if (this.activeFile === fileId) {
const nextVisibleFile = this.visibleFiles[0];
this.activeFile = nextVisibleFile ? nextVisibleFile.name : "-1";
}
}
showMessage('success', '删除成功!');
} catch (error) {
showMessage('info', '已取消删除');
}
},
renameFile(fileId, newName) {
const file = this.fileList.find(file => file.name === fileId);
if (file) {
file.label = newName;
}
},
},
});

29
src/ts/useDialogs.ts Normal file
View File

@@ -0,0 +1,29 @@
import { reactive } from 'vue'
const dialogs = reactive({
shikigami: { show: false, data: null, node: null, callback: null },
yuhun: { show: false, data: null, node: null, callback: null },
property: { show: false, data: null, node: null, callback: null }
})
function openDialog(type: string, data = null, node = null, callback = null) {
dialogs[type].show = true
dialogs[type].data = data
dialogs[type].node = node
dialogs[type].callback = callback
}
function closeDialog(type: string) {
dialogs[type].show = false
dialogs[type].data = null
dialogs[type].node = null
dialogs[type].callback = null
}
export function useDialogs() {
return {
dialogs,
openDialog,
closeDialog
}
}

16
src/ts/useLogicFlow.ts Normal file
View File

@@ -0,0 +1,16 @@
import type LogicFlow from '@logicflow/core';
let logicFlowInstance: LogicFlow | null = null;
export function setLogicFlowInstance(lf: LogicFlow) {
logicFlowInstance = lf;
}
export function getLogicFlowInstance(): LogicFlow | null {
return logicFlowInstance;
}
export function destroyLogicFlowInstance() {
logicFlowInstance?.destroy();
logicFlowInstance = null;
}

326
src/ts/useStore.ts Normal file
View File

@@ -0,0 +1,326 @@
import {defineStore} from 'pinia';
import {ref, computed} from 'vue';
// import type { Edge, Node, ViewportTransform } from '@vue-flow/core';
import {ElMessageBox} from "element-plus";
import {useGlobalMessage} from "./useGlobalMessage";
import {getLogicFlowInstance} from "./useLogicFlow";
const {showMessage} = useGlobalMessage();
// localStorage 防抖定时器
let localStorageDebounceTimer: NodeJS.Timeout | null = null;
const LOCALSTORAGE_DEBOUNCE_DELAY = 1000; // 1秒防抖
interface FlowFile {
label: string;
name: string;
visible: boolean;
type: string;
graphRawData?: object;
transform?: {
"SCALE_X": number,
"SCALE_Y": number,
"TRANSLATE_X": number,
"TRANSLATE_Y": number
};
}
function getDefaultState() {
return {
"fileList": [
{
"label": "File 1",
"name": "File 1",
"visible": true,
"type": "FLOW",
"graphRawData": {
"nodes": [],
"edges": []
},
"transform": {
"SCALE_X": 1,
"SCALE_Y": 1,
"TRANSLATE_X": 0,
"TRANSLATE_Y": 0
}
}
],
"activeFile": "File 1"
};
}
function clearFilesStoreLocalStorage() {
localStorage.removeItem('filesStore');
}
function loadStateFromLocalStorage() {
try {
const data = localStorage.getItem('filesStore');
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('从 localStorage 加载数据失败:', error);
return null;
}
}
function saveStateToLocalStorage(state: any) {
// 清除之前的防抖定时器
if (localStorageDebounceTimer) {
clearTimeout(localStorageDebounceTimer);
}
// 设置新的防抖定时器
localStorageDebounceTimer = setTimeout(() => {
try {
localStorage.setItem('filesStore', JSON.stringify(state));
console.log('数据已防抖保存到 localStorage');
} catch (error) {
console.error('保存到 localStorage 失败:', error);
// 如果 localStorage 满了,尝试清理一些数据
try {
localStorage.clear();
localStorage.setItem('filesStore', JSON.stringify(state));
} catch (clearError) {
console.error('清理 localStorage 后仍无法保存:', clearError);
}
}
}, LOCALSTORAGE_DEBOUNCE_DELAY);
}
export const useFilesStore = defineStore('files', () => {
// 文件列表状态
const fileList = ref<FlowFile[]>([]);
const activeFile = ref<string>('');
// 计算属性:获取可见的文件
const visibleFiles = computed(() => {
return fileList.value.filter(file => file.visible);
});
// 导入数据
const importData = (data: any) => {
try {
if (data.fileList && Array.isArray(data.fileList)) {
// 新版本格式:包含 fileList 和 activeFile
fileList.value = data.fileList;
activeFile.value = data.activeFile || data[0]?.name;
showMessage('success', '数据导入成功');
} else if (Array.isArray(data) && data[0]?.visible === true) {
// 兼容旧版本格式:直接是 fileList 数组
fileList.value = data;
activeFile.value = data[0]?.name || "1";
showMessage('success', '数据导入成功');
} else {
// 兼容更旧版本格式:仅包含 groups 数组
const newFile = {
label: `File ${fileList.value.length + 1}`,
name: String(fileList.value.length + 1),
visible: true,
type: "FLOW",
groups: data,
graphRawData: {
nodes: [],
edges: []
},
transform: {
SCALE_X: 1,
SCALE_Y: 1,
TRANSLATE_X: 0,
TRANSLATE_Y: 0
}
};
fileList.value.push(newFile);
activeFile.value = newFile.name;
showMessage('success', '数据导入成功');
}
} catch (error) {
console.error('Failed to import file', error);
showMessage('error', '数据导入失败');
}
};
// 导出数据
const exportData = () => {
try {
const dataStr = JSON.stringify({
fileList: fileList.value,
activeFile: activeFile.value
}, null, 2);
const blob = new Blob([dataStr], {type: 'application/json;charset=utf-8'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'yys-editor-files.json';
link.click();
URL.revokeObjectURL(url);
showMessage('success', '数据导出成功');
} catch (error) {
console.error('导出数据失败:', error);
showMessage('error', '数据导出失败');
}
};
// 初始化时检查是否有未保存的数据
const initializeWithPrompt = () => {
const savedState = loadStateFromLocalStorage();
const defaultState = getDefaultState();
// 如果没有保存的数据,使用默认状态
if (!savedState) {
fileList.value = defaultState.fileList;
activeFile.value = defaultState.activeFile;
return;
}
const isSame = JSON.stringify(savedState) === JSON.stringify(defaultState);
if (savedState && !isSame) {
ElMessageBox.confirm(
'检测到有未保存的旧数据,是否恢复?',
'提示',
{
confirmButtonText: '恢复',
cancelButtonText: '不恢复',
type: 'warning',
}
).then(() => {
fileList.value = savedState.fileList || [];
activeFile.value = savedState.activeFile || "1";
showMessage('success', '数据已恢复');
}).catch(() => {
clearFilesStoreLocalStorage();
fileList.value = defaultState.fileList;
activeFile.value = defaultState.activeFile;
showMessage('info', '选择了不恢复旧数据');
});
} else {
// 如果有保存的数据且与默认状态相同,直接使用保存的数据
fileList.value = savedState.fileList || defaultState.fileList;
activeFile.value = savedState.activeFile || defaultState.activeFile;
}
};
// 设置自动更新
const setupAutoSave = () => {
console.log('自动更新功能已启动每30秒更新一次');
setInterval(() => {
updateTab(); // 使用统一的更新方法
}, 30000); // 设置间隔时间为30秒
};
// 添加新文件
const addTab = () => {
// 添加文件前先保存
updateTab();
requestAnimationFrame(() => {
const newFileName = `File ${fileList.value.length + 1}`;
const newFile = {
label: newFileName,
name: newFileName,
visible: true,
type: 'FLOW',
graphRawData: {},
transform: {
SCALE_X: 1,
SCALE_Y: 1,
TRANSLATE_X: 0,
TRANSLATE_Y: 0
}
};
fileList.value.push(newFile);
activeFile.value = newFileName;
});
};
// 关闭文件标签
const removeTab = (fileName: string | undefined) => {
if (!fileName) return;
const index = fileList.value.findIndex(file => file.name === fileName);
if (index === -1) return;
fileList.value.splice(index, 1);
// 如果关闭的是当前活动文件,则切换到其他文件
if (activeFile.value === fileName) {
activeFile.value = fileList.value[Math.max(0, index - 1)]?.name || '';
}
// 关闭文件后立即更新
updateTab();
};
// 更新指定 Tab - 内存操作即时localStorage 操作防抖
const updateTab = (fileName?: string) => {
try {
const targetFile = fileName || activeFile.value;
// 先同步 LogicFlow 数据到内存
syncLogicFlowDataToStore(targetFile);
// 再保存到 localStorage带防抖
const state = {
fileList: fileList.value,
activeFile: activeFile.value
};
saveStateToLocalStorage(state);
} catch (error) {
console.error('更新 Tab 失败:', error);
showMessage('error', '数据更新失败');
}
};
// 获取当前 Tab 数据
const getTab = (fileName?: string) => {
const targetFile = fileName || activeFile.value;
return fileList.value.find(f => f.name === targetFile);
};
// 同步 LogicFlow 画布数据到 store 的内部方法
const syncLogicFlowDataToStore = (fileName?: string) => {
const logicFlowInstance = getLogicFlowInstance();
const targetFile = fileName || activeFile.value;
if (logicFlowInstance && targetFile) {
try {
// 获取画布最新数据
const graphData = logicFlowInstance.getGraphRawData();
const transform = logicFlowInstance.getTransform();
if (graphData) {
// 直接保存原始数据到 GraphRawData
const file = fileList.value.find(f => f.name === targetFile);
if (file) {
file.graphRawData = graphData;
file.transform = transform;
console.log(`已同步画布数据到文件 "${targetFile}"`);
}
}
} catch (error) {
console.warn('同步画布数据失败:', error);
}
}
};
return {
importData,
exportData,
initializeWithPrompt,
setupAutoSave,
addTab,
removeTab,
updateTab,
getTab,
fileList,
activeFile,
visibleFiles,
};
});