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

@@ -0,0 +1,69 @@
---
description:
globs:
alwaysApply: true
---
This is a game guide editor that imitates draw.io, which introduces some game-specific interactive components into traditional flowchart editing. For instance, Shikigami selection and Yuhun selection - in essence, these are pre-set images.
The project previously utilized a large amount of AI-generated code, making it difficult to maintain in its current state. I will now follow the official Logic-Flow core component examples to refactor the project, improving both my understanding and future development efficiency.
You are an expert in TypeScript, Node.js, NuxtJS, Vue 3, Shadcn Vue, Radix Vue, VueUse, and Tailwind.
Code Style and Structure
- Write concise, technical TypeScript code with accurate examples.
- Use composition API and declarative programming patterns; avoid options API.
- Prefer iteration and modularization over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
- Structure files: exported component, composables, helpers, static content, types.
Naming Conventions
- Use lowercase with dashes for directories (e.g., components/auth-wizard).
- Use PascalCase for component names (e.g., AuthWizard.vue).
- Use camelCase for composables (e.g., useAuthState.ts).
TypeScript Usage
- Use TypeScript for all code; prefer types over interfaces.
- Avoid enums; use const objects instead.
- Use Vue 3 with TypeScript, leveraging defineComponent and PropType.
Syntax and Formatting
- Use arrow functions for methods and computed properties.
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Use template syntax for declarative rendering.
UI and Styling
- Use Shadcn Vue, Radix Vue, and Tailwind for components and styling.
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
Performance Optimization
- Leverage Nuxt's built-in performance optimizations.
- Use Suspense for asynchronous components.
- Implement lazy loading for routes and components.
- Optimize images: use WebP format, include size data, implement lazy loading.
Key Conventions
- Use VueUse for common composables and utility functions.
- Use Pinia for state management.
- Optimize Web Vitals (LCP, CLS, FID).
- Utilize Nuxt's auto-imports feature for components and composables.
Nuxt-specific Guidelines
- Follow Nuxt 3 directory structure (e.g., pages/, components/, composables/).
- Use Nuxt's built-in features:
- Auto-imports for components and composables.
- File-based routing in the pages/ directory.
- Server routes in the server/ directory.
- Leverage Nuxt plugins for global functionality.
- Use useFetch and useAsyncData for data fetching.
- Implement SEO best practices using Nuxt's useHead and useSeoMeta.
Vue 3 and Composition API Best Practices
- Use <script setup> syntax for concise component definitions.
- Leverage ref, reactive, and computed for reactive state management.
- Use provide/inject for dependency injection when appropriate.
- Implement custom composables for reusable logic.
Follow the official Nuxt.js and Vue.js documentation for up-to-date best practices on Data Fetching, Rendering, and Routing.

2883
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,11 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@logicflow/core": "^2.0.16",
"@logicflow/engine": "^0.1.1",
"@logicflow/extension": "^2.0.21",
"@logicflow/vue-node-registry": "^1.0.18",
"@tailwindcss/postcss": "^4.1.11",
"@vueup/vue-quill": "^1.2.0", "@vueup/vue-quill": "^1.2.0",
"element-plus": "^2.9.1", "element-plus": "^2.9.1",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",

View File

@@ -1,81 +1,75 @@
<script setup lang="ts"> <script setup lang="ts">
import Yys from './components/Yys.vue';
import Toolbar from './components/Toolbar.vue'; import Toolbar from './components/Toolbar.vue';
import ProjectExplorer from './components/ProjectExplorer.vue'; import ProjectExplorer from './components/ProjectExplorer.vue';
import {computed, ref, onMounted, onUnmounted} from "vue"; import ComponentsPanel from './components/flow/ComponentsPanel.vue';
import {useFilesStore} from "@/ts/files"; import {computed, ref, onMounted, onUnmounted, onBeforeUpdate, reactive, provide, inject, watch} from "vue";
import {useFilesStore} from "@/ts/useStore";
import Vue3DraggableResizable from 'vue3-draggable-resizable'; import Vue3DraggableResizable from 'vue3-draggable-resizable';
import {TabPaneName, TabsPaneContext} from "element-plus"; 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 filesStore = useFilesStore();
// const { updateNode,toObject,fromObject } = useVueFlow();
const yysRef = ref(null);
const width = ref('100%'); const width = ref('100%');
const height = ref('100vh'); const height = ref('100vh');
const toolbarHeight = 48; // 工具栏的高度 const toolbarHeight = 48; // 工具栏的高度
const windowHeight = ref(window.innerHeight); const windowHeight = ref(window.innerHeight);
const contentHeight = computed(() => `${windowHeight.value - toolbarHeight}px`); 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 = ( const handleTabsEdit = (
targetName: String | undefined, targetName: string | undefined,
action: 'remove' | 'add' action: 'remove' | 'add'
) => { ) => {
if (action === 'remove') { if (action === 'remove') {
filesStore.closeTab(targetName); filesStore.removeTab(targetName);
} else if (action === 'add') { } else if (action === 'add') {
const newFileName = `File ${filesStore.fileList.length + 1}`; filesStore.addTab();
filesStore.addFile({
label: newFileName,
name: newFileName,
visible: true,
type: 'PVE',
groups: [
{
shortDescription: " ",
groupInfo: [{}, {}, {}, {}, {}],
details: ''
},
{
shortDescription: '',
groupInfo: [{}, {}, {}, {}, {}],
details: ''
}
]
});
} }
}; };
onMounted(() => { onMounted(() => {
window.addEventListener('resize', () => { // 初始化自动保存功能
windowHeight.value = window.innerHeight; filesStore.initializeWithPrompt();
}); filesStore.setupAutoSave();
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', () => {
windowHeight.value = window.innerHeight;
});
}); });
const activeFileGroups = computed(() => {
const activeFile = filesStore.fileList.find(file => file.name === filesStore.activeFile); watch(
return activeFile ? activeFile.groups : []; () => 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> </script>
<template> <template>
@@ -85,10 +79,7 @@ const activeFileGroups = computed(() => {
<!-- 侧边栏和工作区 --> <!-- 侧边栏和工作区 -->
<div class="main-content"> <div class="main-content">
<!-- 侧边栏 --> <!-- 侧边栏 -->
<aside class="sidebar"> <ComponentsPanel/>
<ProjectExplorer :allFiles="filesStore.fileList"/>
</aside>
<!-- 工作区 --> <!-- 工作区 -->
<div class="workspace"> <div class="workspace">
<el-tabs <el-tabs
@@ -103,16 +94,17 @@ const activeFileGroups = computed(() => {
:key="`${file.name}-${filesStore.activeFile}`" :key="`${file.name}-${filesStore.activeFile}`"
:label="file.label" :label="file.label"
:name="file.name.toString()" :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> </el-tabs>
<div id="main-container" :style="{ height: contentHeight, overflow: 'auto' }">
<FlowEditor
:height="contentHeight"
/>
</div> </div>
</div> </div>
</div> </div>
<DialogManager/>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -136,7 +128,7 @@ const activeFileGroups = computed(() => {
} }
.sidebar { .sidebar {
width: 20%; /* 侧边栏宽度 */ width: 230px; /* 侧边栏宽度 */
background-color: #f0f0f0; /* 背景色 */ background-color: #f0f0f0; /* 背景色 */
flex-shrink: 0; /* 防止侧边栏被压缩 */ flex-shrink: 0; /* 防止侧边栏被压缩 */
overflow-y: auto; /* 允许侧边栏内容滚动 */ overflow-y: auto; /* 允许侧边栏内容滚动 */
@@ -153,8 +145,8 @@ const activeFileGroups = computed(() => {
height: 100%; /* 确保内容区域占满父容器 */ height: 100%; /* 确保内容区域占满父容器 */
overflow-y: auto; /* 允许内容滚动 */ overflow-y: auto; /* 允许内容滚动 */
min-height: 100vh; /* 允许容器扩展 */ 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"> <script setup lang="ts">
import {defineProps, defineEmits, ref} from 'vue'; import {defineProps, defineEmits, ref} from 'vue';
import {useFilesStore} from "@/ts/files"; import {useFilesStore} from "@/ts/useStore";
import {ElTree, ElButton, ElDropdownMenu, ElDropdownItem} from 'element-plus'; import {ElTree, ElButton, ElDropdownMenu, ElDropdownItem} from 'element-plus';
const filesStore = useFilesStore(); 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"> <script setup lang="ts">
import {ref, reactive, onMounted} from 'vue'; import {ref, reactive, onMounted} from 'vue';
import html2canvas from "html2canvas";
import {useI18n} from 'vue-i18n'; import {useI18n} from 'vue-i18n';
import updateLogs from "../data/updateLog.json" import updateLogs from "../data/updateLog.json"
import filesStoreExample from "../data/filesStoreExample.json" import {useFilesStore} from "@/ts/useStore";
import {useFilesStore} from "@/ts/files";
import {ElMessageBox} from "element-plus"; import {ElMessageBox} from "element-plus";
import {useGlobalMessage} from "@/ts/useGlobalMessage"; import {useGlobalMessage} from "@/ts/useGlobalMessage";
import { getLogicFlowInstance } from "@/ts/useLogicFlow";
// import { useScreenshot } from '@/ts/useScreenshot';
import { getCurrentInstance } from 'vue';
const filesStore = useFilesStore(); const filesStore = useFilesStore();
const { showMessage } = useGlobalMessage(); const { showMessage } = useGlobalMessage();
@@ -100,6 +101,23 @@ const state = reactive({
showFeedbackFormDialog: false, // 控制反馈表单对话框的显示状态 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 = () => { const loadExample = () => {
ElMessageBox.confirm( ElMessageBox.confirm(
'加载样例会覆盖当前数据,是否覆盖?', '加载样例会覆盖当前数据,是否覆盖?',
@@ -110,7 +128,30 @@ const loadExample = () => {
type: 'warning', type: 'warning',
} }
).then(() => { ).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', '数据已恢复'); showMessage('success', '数据已恢复');
}).catch(() => { }).catch(() => {
showMessage('info', '选择了不恢复旧数据'); showMessage('info', '选择了不恢复旧数据');
@@ -139,14 +180,13 @@ const showFeedbackForm = () => {
}; };
const handleExport = () => { const handleExport = () => {
const dataStr = JSON.stringify(filesStore.fileList, null, 2); // 导出前先更新当前数据,确保不丢失最新修改
const blob = new Blob([dataStr], {type: 'application/json;charset=utf-8'}); filesStore.updateTab();
const url = URL.createObjectURL(blob);
const link = document.createElement('a'); // 延迟一点确保更新完成后再导出
link.href = url; setTimeout(() => {
link.download = 'files.json'; filesStore.exportData();
link.click(); }, 2000);
URL.revokeObjectURL(url);
}; };
const handleImport = () => { const handleImport = () => {
@@ -154,27 +194,19 @@ const handleImport = () => {
input.type = 'file'; input.type = 'file';
input.accept = '.json'; input.accept = '.json';
input.onchange = (e) => { input.onchange = (e) => {
const file = e.target.files[0]; const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) { if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
try { try {
const data = JSON.parse(e.target.result as string); const target = e.target as FileReader;
if (data[0].visible === true) { const data = JSON.parse(target.result as string);
// 新版本格式:直接替换 fileList filesStore.importData(data);
filesStore.$patch({fileList: data}); // refreshLogicFlowCanvas('LogicFlow 画布已重新渲染(导入数据)');
} else {
// 旧版本格式:仅包含 groups 数组
const newFile = {
label: `File ${filesStore.fileList.length + 1}`,
name: String(filesStore.fileList.length + 1),
visible: true,
groups: data
};
filesStore.addFile(newFile);
}
} catch (error) { } catch (error) {
console.error('Failed to import file', error); console.error('Failed to import file', error);
showMessage('error', '文件格式错误');
} }
}; };
reader.readAsText(file); reader.readAsText(file);
@@ -205,162 +237,46 @@ const applyWatermarkSettings = () => {
}; };
// 计算视觉总高度 // 获取 App 根实例,便于跨组件获取 flowEditorRef
function calculateVisualHeight(selector) { const appInstance = getCurrentInstance();
// 1. 获取所有目标元素
const elements = Array.from(document.querySelectorAll(selector));
// 2. 获取元素位置信息并排序 // const { captureFlow, dataUrl } = useScreenshot();
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 prepareCapture = async () => { const prepareCapture = async () => {
state.previewVisible = true; // 获取 FlowEditor 实例
// 这里假设 App.vue 已将 flowEditorRef 作为全局 property 或 provide
// 创建临时样式 // 或者你可以通过 window.__VUE_DEVTOOLS_GLOBAL_HOOK__.$vm0.$refs.flowEditorRef 方式调试
const style = document.createElement('style'); let flowEditor = null;
style.textContent = ` try {
.ql-container.ql-snow { // 通过 DOM 查找
border: none !important; const flowEditorDom = document.querySelector('#main-container .flow-editor');
} if (!flowEditorDom) {
#main-container { showMessage('error', '未找到流程图编辑器');
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; return;
} }
// 通过 ref 获取 vueflow 根元素
// 保存原始 overflow 样式 const vueflowRoot = flowEditorDom.querySelector('.vue-flow');
const originalOverflow = element.style.overflow; if (!vueflowRoot || !(vueflowRoot instanceof HTMLElement)) {
showMessage('error', '未找到 VueFlow 画布');
try { return;
// 临时隐藏 overflow 样式 }
element.style.overflow = 'visible'; state.previewVisible = true;
// 截图
// 计算需要忽略的元素高度 const img = await captureFlow(vueflowRoot as HTMLElement, {
let totalHeight = calculateVisualHeight('[data-html2canvas-ignore]') + calculateVisualHeight('.ql-toolbar'); type: 'png',
console.log('所有携带指定属性的元素高度之和:', totalHeight); shouldDownload: false,
watermark: {
console.log('主元素宽度', element.scrollWidth); text: watermark.text,
console.log('主元素高度', element.scrollHeight); fontSize: watermark.fontSize,
color: watermark.color,
// 1. 生成原始截图 angle: watermark.angle,
const canvas = await html2canvas(element, { rows: watermark.rows,
ignoreElements: ignoreElements, cols: watermark.cols,
scrollX: 0, },
scrollY: 0,
width: element.scrollWidth,
height: element.scrollHeight - totalHeight,
}); });
state.previewImage = img;
// 2. 创建新Canvas添加水印 } catch (e) {
const watermarkedCanvas = document.createElement('canvas'); showMessage('error', '截图失败: ' + (e?.message || e));
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(); // 恢复状态
}
}
ctx.restore(); // 恢复原始状态
// 3. 存储带水印的图片
state.previewImage = watermarkedCanvas.toDataURL();
} catch (error) {
console.error('Capture failed', error);
} finally {
// 恢复原始 overflow 样式
element.style.overflow = originalOverflow;
// 移除临时样式
document.head.removeChild(style);
} }
}; };
@@ -368,9 +284,9 @@ const downloadImage = () => {
if (state.previewImage) { if (state.previewImage) {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = state.previewImage; link.href = state.previewImage;
link.download = 'screenshot.png'; // 设置下载的文件名 link.download = 'screenshot.png';
link.click(); 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"> <script setup lang="ts">
import {ref, reactive, toRefs, nextTick} from 'vue'; import {ref, reactive, toRefs, nextTick} from 'vue';
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import ShikigamiSelect from './ShikigamiSelect.vue'; import ShikigamiSelect from './flow/nodes/yys/ShikigamiSelect.vue';
import ShikigamiProperty from './ShikigamiProperty.vue'; import ShikigamiProperty from './flow/nodes/yys/ShikigamiProperty.vue';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import { Quill, QuillEditor } from '@vueup/vue-quill' import { Quill, QuillEditor } from '@vueup/vue-quill'

View File

@@ -81,8 +81,8 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref, reactive, onMounted} from 'vue'; import {ref, reactive, onMounted} from 'vue';
import shikigami from "../data/Shikigami.json" import shikigami from "../data/Shikigami.json"
import ShikigamiSelect from "@/components/ShikigamiSelect.vue"; import ShikigamiSelect from "@/components/flow/nodes/yys/ShikigamiSelect.vue";
import ShikigamiProperty from "@/components/ShikigamiProperty.vue"; import ShikigamiProperty from "@/components/flow/nodes/yys/ShikigamiProperty.vue";
import _ from "lodash"; import _ from "lodash";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
import draggable from 'vuedraggable'; 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> </template>
<script setup> <script setup>
import propertyData from "../data/property.json"; import propertyData from "../../../../data/property.json";
import {ref, watch, computed} from 'vue' import {ref, watch, computed} from 'vue'
import ShikigamiSelect from "@/components/ShikigamiSelect.vue"; import ShikigamiSelect from "@/components/flow/nodes/yys/ShikigamiSelect.vue";
import YuhunSelect from "@/components/YuhunSelect.vue"; import YuhunSelect from "@/components/flow/nodes/yys/YuhunSelect.vue";
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
// import YuhunSelect from "./YuhunSelect.vue"; // import YuhunSelect from "./YuhunSelect.vue";

View File

@@ -2,10 +2,8 @@
<el-dialog <el-dialog
v-model="show" v-model="show"
title="请选择式神" title="请选择式神"
@close="cancel"
:before-close="cancel"
> >
<span>当前选择式神{{ current.name }}</span> <span>当前选择式神{{ props.currentShikigami.name }}</span>
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
<el-input <el-input
placeholder="请输入内容" placeholder="请输入内容"
@@ -45,9 +43,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed } from 'vue' import { ref, computed } from 'vue'
import type { TabsPaneContext } from 'element-plus' import type { TabsPaneContext } from 'element-plus'
import shikigamiData from "../data/Shikigami.json" import shikigamiData from "../../../../data/Shikigami.json"
interface Shikigami { interface Shikigami {
name: string name: string
@@ -58,7 +56,7 @@ interface Shikigami {
const props = defineProps({ const props = defineProps({
currentShikigami: { currentShikigami: {
type: Object as () => Shikigami, type: Object as () => Shikigami,
default: () => ({ name: '' }) default: () => ({ name: '未选择式神', avatar: '', rarity: '' })
}, },
showSelectShikigami: { showSelectShikigami: {
type: Boolean, type: Boolean,
@@ -68,10 +66,19 @@ const props = defineProps({
const emit = defineEmits(['closeSelectShikigami', 'updateShikigami']) 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') const activeName = ref('ALL')
let current = ref({name:''})
const show = ref(false)
const rarityLevels = [ const rarityLevels = [
{ label: "全部", name: "ALL" }, { label: "全部", name: "ALL" },
@@ -84,34 +91,16 @@ const rarityLevels = [
{ label: "呱太", name: "G" }, { 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) => { const handleClick = (tab: TabsPaneContext) => {
console.log('Tab clicked:', tab) console.log('Tab clicked:', tab)
} }
const cancel = () => {
emit('closeSelectShikigami')
show.value = false
}
const confirm = (shikigami: Shikigami) => { const confirm = (shikigami: Shikigami) => {
emit('updateShikigami', shikigami) emit('updateShikigami', shikigami)
searchText.value = '' searchText.value = ''
activeName.value = 'ALL' activeName.value = 'ALL'
// cancel()
} }
// //
const filterShikigamiByRarityAndSearch = (rarity: string, search: string) => { const filterShikigamiByRarityAndSearch = (rarity: string, search: string) => {
let filteredList = shikigamiData; 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 ja from './locales/ja.json'
import { createPinia } from 'pinia' // 导入 Pinia import { createPinia } from 'pinia' // 导入 Pinia
import {useFilesStore} from "@/ts/files"; import { useFilesStore } from './ts/useStore';
const app = createApp(App) const app = createApp(App)
@@ -63,5 +63,3 @@ app.use(pinia) // 使用 Pinia
.mount('#app') .mount('#app')
const filesStore = useFilesStore(); const filesStore = useFilesStore();
filesStore.setupAutoSave();
filesStore.initializeWithPrompt();

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