mirror of
https://github.com/Powerful-517/yys-editor.git
synced 2025-10-14 06:20:58 +00:00
Merge pull request #14 from Powerful-517/feature/logic-flow
Feature/logic flow
This commit is contained in:
69
.cursor/rules/vue-rules.mdc
Normal file
69
.cursor/rules/vue-rules.mdc
Normal 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.
|
||||||
|
|
||||||
|
|
||||||
|
|
2885
package-lock.json
generated
2885
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
120
src/App.vue
120
src/App.vue
@@ -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,15 +94,16 @@ 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>
|
||||||
|
<DialogManager/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
43
src/components/DialogManager.vue
Normal file
43
src/components/DialogManager.vue
Normal 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>
|
@@ -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();
|
||||||
|
@@ -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>
|
|
||||||
|
|
||||||
Vue’s
|
|
||||||
<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>
|
|
@@ -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 = `
|
|
||||||
.ql-container.ql-snow {
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
#main-container {
|
|
||||||
position: relative;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: inline-block;
|
|
||||||
max-width: 100%;
|
|
||||||
}`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
|
|
||||||
// 获取目标元素
|
|
||||||
const element = document.querySelector('#main-container');
|
|
||||||
if (!element) {
|
|
||||||
console.error('Element not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存原始 overflow 样式
|
|
||||||
const originalOverflow = element.style.overflow;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 临时隐藏 overflow 样式
|
// 通过 DOM 查找
|
||||||
element.style.overflow = 'visible';
|
const flowEditorDom = document.querySelector('#main-container .flow-editor');
|
||||||
|
if (!flowEditorDom) {
|
||||||
// 计算需要忽略的元素高度
|
showMessage('error', '未找到流程图编辑器');
|
||||||
let totalHeight = calculateVisualHeight('[data-html2canvas-ignore]') + calculateVisualHeight('.ql-toolbar');
|
return;
|
||||||
console.log('所有携带指定属性的元素高度之和:', totalHeight);
|
|
||||||
|
|
||||||
console.log('主元素宽度', element.scrollWidth);
|
|
||||||
console.log('主元素高度', element.scrollHeight);
|
|
||||||
|
|
||||||
// 1. 生成原始截图
|
|
||||||
const canvas = await html2canvas(element, {
|
|
||||||
ignoreElements: ignoreElements,
|
|
||||||
scrollX: 0,
|
|
||||||
scrollY: 0,
|
|
||||||
width: element.scrollWidth,
|
|
||||||
height: element.scrollHeight - totalHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 创建新Canvas添加水印
|
|
||||||
const watermarkedCanvas = document.createElement('canvas');
|
|
||||||
const ctx = watermarkedCanvas.getContext('2d');
|
|
||||||
|
|
||||||
// 设置新Canvas尺寸
|
|
||||||
watermarkedCanvas.width = canvas.width;
|
|
||||||
watermarkedCanvas.height = canvas.height;
|
|
||||||
|
|
||||||
// 绘制原始截图
|
|
||||||
ctx.drawImage(canvas, 0, 0);
|
|
||||||
|
|
||||||
// 添加水印
|
|
||||||
ctx.font = `${watermark.fontSize}px Arial`;
|
|
||||||
ctx.fillStyle = watermark.color;
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
|
|
||||||
// 计算每个水印的位置间隔
|
|
||||||
const colSpace = watermarkedCanvas.width / (watermark.cols + 1);
|
|
||||||
const rowSpace = watermarkedCanvas.height / (watermark.rows + 1);
|
|
||||||
|
|
||||||
// 保存原始画布状态
|
|
||||||
ctx.save();
|
|
||||||
|
|
||||||
// 循环绘制多个水印
|
|
||||||
for (let row = 1; row <= watermark.rows; row++) {
|
|
||||||
for (let col = 1; col <= watermark.cols; col++) {
|
|
||||||
ctx.save(); // 保存当前状态
|
|
||||||
const x = col * colSpace;
|
|
||||||
const y = row * rowSpace;
|
|
||||||
|
|
||||||
// 移动到目标位置并旋转
|
|
||||||
ctx.translate(x, y);
|
|
||||||
ctx.rotate((watermark.angle * Math.PI) / 180);
|
|
||||||
|
|
||||||
// 绘制水印文字
|
|
||||||
ctx.fillText(watermark.text, 0, 0);
|
|
||||||
ctx.restore(); // 恢复状态
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// 通过 ref 获取 vueflow 根元素
|
||||||
ctx.restore(); // 恢复原始状态
|
const vueflowRoot = flowEditorDom.querySelector('.vue-flow');
|
||||||
|
if (!vueflowRoot || !(vueflowRoot instanceof HTMLElement)) {
|
||||||
// 3. 存储带水印的图片
|
showMessage('error', '未找到 VueFlow 画布');
|
||||||
state.previewImage = watermarkedCanvas.toDataURL();
|
return;
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Capture failed', error);
|
state.previewVisible = true;
|
||||||
} finally {
|
// 截图
|
||||||
// 恢复原始 overflow 样式
|
const img = await captureFlow(vueflowRoot as HTMLElement, {
|
||||||
element.style.overflow = originalOverflow;
|
type: 'png',
|
||||||
|
shouldDownload: false,
|
||||||
// 移除临时样式
|
watermark: {
|
||||||
document.head.removeChild(style);
|
text: watermark.text,
|
||||||
|
fontSize: watermark.fontSize,
|
||||||
|
color: watermark.color,
|
||||||
|
angle: watermark.angle,
|
||||||
|
rows: watermark.rows,
|
||||||
|
cols: watermark.cols,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
state.previewImage = img;
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('error', '截图失败: ' + (e?.message || e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -368,9 +284,9 @@ const downloadImage = () => {
|
|||||||
if (state.previewImage) {
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -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>
|
|
@@ -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'
|
||||||
|
@@ -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';
|
||||||
|
232
src/components/flow/ComponentsPanel.vue
Normal file
232
src/components/flow/ComponentsPanel.vue
Normal 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>
|
179
src/components/flow/FlowEditor.vue
Normal file
179
src/components/flow/FlowEditor.vue
Normal 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>
|
243
src/components/flow/PropertyPanel.vue
Normal file
243
src/components/flow/PropertyPanel.vue
Normal 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>
|
65
src/components/flow/nodes/common/ImageNode.vue
Normal file
65
src/components/flow/nodes/common/ImageNode.vue
Normal 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>-->
|
51
src/components/flow/nodes/common/TextNode.vue
Normal file
51
src/components/flow/nodes/common/TextNode.vue
Normal 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>-->
|
341
src/components/flow/nodes/yys/PropertySelect.vue
Normal file
341
src/components/flow/nodes/yys/PropertySelect.vue
Normal 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>
|
160
src/components/flow/nodes/yys/PropertySelectNode.vue
Normal file
160
src/components/flow/nodes/yys/PropertySelectNode.vue
Normal 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>
|
627
src/components/flow/nodes/yys/ShikigamiGroup.vue
Normal file
627
src/components/flow/nodes/yys/ShikigamiGroup.vue
Normal 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>
|
@@ -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";
|
||||||
|
|
@@ -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;
|
72
src/components/flow/nodes/yys/ShikigamiSelectNode.vue
Normal file
72
src/components/flow/nodes/yys/ShikigamiSelectNode.vue
Normal 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>
|
120
src/components/flow/nodes/yys/YuhunSelect.vue
Normal file
120
src/components/flow/nodes/yys/YuhunSelect.vue
Normal 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>
|
69
src/components/flow/nodes/yys/YuhunSelectNode.vue
Normal file
69
src/components/flow/nodes/yys/YuhunSelectNode.vue
Normal 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>
|
@@ -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();
|
|
130
src/ts/files.ts
130
src/ts/files.ts
@@ -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
29
src/ts/useDialogs.ts
Normal 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
16
src/ts/useLogicFlow.ts
Normal 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
326
src/ts/useStore.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
Reference in New Issue
Block a user