mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
feat: 更新拖放功能,重构状态管理,优化工作流组件,添加节点和边的确认删除功能
This commit is contained in:
+12
-12
@@ -7,24 +7,24 @@ let id = 0
|
|||||||
* @returns {string} - A unique id.
|
* @returns {string} - A unique id.
|
||||||
*/
|
*/
|
||||||
function getId() {
|
function getId() {
|
||||||
return `dndnode_${id++}`
|
return `act_${id++}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In a real world scenario you'd want to avoid creating refs in a global scope like this as they might not be cleaned up properly.
|
* In a real world scenario you'd want to avoid creating refs in a global scope like this as they might not be cleaned up properly.
|
||||||
* @type {{draggedType: Ref<string|null>, isDragOver: Ref<boolean>, isDragging: Ref<boolean>}}
|
* @type {{draggedData: Ref<object|null>, isDragOver: Ref<boolean>, isDragging: Ref<boolean>}}
|
||||||
*/
|
*/
|
||||||
const state = {
|
const state = {
|
||||||
/**
|
/**
|
||||||
* The type of the node being dragged.
|
* The type of the node being dragged.
|
||||||
*/
|
*/
|
||||||
draggedType: ref(null),
|
draggedData: ref(null),
|
||||||
isDragOver: ref(false),
|
isDragOver: ref(false),
|
||||||
isDragging: ref(false),
|
isDragging: ref(false),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useDragAndDrop() {
|
export default function useDragAndDrop() {
|
||||||
const { draggedType, isDragOver, isDragging } = state
|
const { draggedData, isDragOver, isDragging } = state
|
||||||
|
|
||||||
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
|
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
|
||||||
|
|
||||||
@@ -32,13 +32,13 @@ export default function useDragAndDrop() {
|
|||||||
document.body.style.userSelect = dragging ? 'none' : ''
|
document.body.style.userSelect = dragging ? 'none' : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function onDragStart(event: any, type: any) {
|
function onDragStart(event: any, data: any) {
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
event.dataTransfer.setData('application/vueflow', type)
|
event.dataTransfer.setData('application/vueflow', data)
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
}
|
}
|
||||||
|
|
||||||
draggedType.value = type
|
draggedData.value = data
|
||||||
isDragging.value = true
|
isDragging.value = true
|
||||||
|
|
||||||
document.addEventListener('drop', onDragEnd)
|
document.addEventListener('drop', onDragEnd)
|
||||||
@@ -52,7 +52,7 @@ export default function useDragAndDrop() {
|
|||||||
function onDragOver(event: any) {
|
function onDragOver(event: any) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (draggedType.value) {
|
if (draggedData.value) {
|
||||||
isDragOver.value = true
|
isDragOver.value = true
|
||||||
|
|
||||||
if (event.dataTransfer) {
|
if (event.dataTransfer) {
|
||||||
@@ -68,7 +68,7 @@ export default function useDragAndDrop() {
|
|||||||
function onDragEnd() {
|
function onDragEnd() {
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
isDragOver.value = false
|
isDragOver.value = false
|
||||||
draggedType.value = null
|
draggedData.value = null
|
||||||
document.removeEventListener('drop', onDragEnd)
|
document.removeEventListener('drop', onDragEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,9 +87,9 @@ export default function useDragAndDrop() {
|
|||||||
|
|
||||||
const newNode = {
|
const newNode = {
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
type: draggedType.value || undefined,
|
type: undefined,
|
||||||
position,
|
position,
|
||||||
data: { label: nodeId },
|
data: draggedData.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,7 +109,7 @@ export default function useDragAndDrop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
draggedType,
|
draggedData,
|
||||||
isDragOver,
|
isDragOver,
|
||||||
isDragging,
|
isDragging,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
|
|||||||
+2
-30
@@ -1262,34 +1262,6 @@ export interface SiteCategory {
|
|||||||
desc: string
|
desc: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动作
|
|
||||||
export interface Action {
|
|
||||||
// 动作ID (类名)
|
|
||||||
id?: string
|
|
||||||
// 动作名称
|
|
||||||
name?: string
|
|
||||||
// 动作描述
|
|
||||||
description?: string
|
|
||||||
// 是否需要循环
|
|
||||||
loop?: boolean
|
|
||||||
// 循环间隔 (秒)
|
|
||||||
loop_interval?: number
|
|
||||||
// 参数
|
|
||||||
params?: { [key: string]: any }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工作流
|
|
||||||
export interface ActionFlow {
|
|
||||||
// ID
|
|
||||||
id?: string
|
|
||||||
// 源动作
|
|
||||||
source?: string
|
|
||||||
// 目标动作
|
|
||||||
target?: string
|
|
||||||
// 是否动画流程
|
|
||||||
animated?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工作流
|
// 工作流
|
||||||
export interface Workflow {
|
export interface Workflow {
|
||||||
// 工作流ID
|
// 工作流ID
|
||||||
@@ -1309,9 +1281,9 @@ export interface Workflow {
|
|||||||
// 已执行次数
|
// 已执行次数
|
||||||
run_count?: number
|
run_count?: number
|
||||||
// 动作列表
|
// 动作列表
|
||||||
actions?: Action[]
|
actions?: any[]
|
||||||
// 动作流
|
// 动作流
|
||||||
flows?: ActionFlow[]
|
flows?: any[]
|
||||||
// 创建时间
|
// 创建时间
|
||||||
add_time?: string
|
add_time?: string
|
||||||
// 最后执行时间
|
// 最后执行时间
|
||||||
|
|||||||
@@ -1,19 +1,59 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||||||
import Sidebar from '../workflow/Sidebar.vue'
|
import { MiniMap } from '@vue-flow/minimap'
|
||||||
import DropzoneBackground from '../workflow/DropzoneBackground.vue'
|
|
||||||
import useDragAndDrop from '@core/utils/workflow'
|
import useDragAndDrop from '@core/utils/workflow'
|
||||||
import { Workflow } from '@/api/types'
|
import { Workflow } from '@/api/types'
|
||||||
|
import { useToast } from 'vue-toast-notification'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useConfirm } from 'vuetify-use-dialog'
|
||||||
|
import Sidebar from '../workflow/Sidebar.vue'
|
||||||
|
import DropzoneBackground from '../workflow/DropzoneBackground.vue'
|
||||||
|
|
||||||
const { onConnect, addEdges } = useVueFlow()
|
const { onConnect, addEdges, nodes, edges, onNodesChange, applyNodeChanges, onEdgesChange, applyEdgeChanges } =
|
||||||
|
useVueFlow()
|
||||||
|
|
||||||
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
|
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
|
||||||
|
|
||||||
const nodes = ref([])
|
|
||||||
|
|
||||||
onConnect(addEdges)
|
onConnect(addEdges)
|
||||||
|
|
||||||
|
onNodesChange(async (changes: any) => {
|
||||||
|
const nextChanges = []
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.type === 'remove') {
|
||||||
|
const isConfirmed = await createConfirm({
|
||||||
|
title: '确认',
|
||||||
|
content: `确定要删除该节点吗?`,
|
||||||
|
})
|
||||||
|
if (!isConfirmed) {
|
||||||
|
nextChanges.push(change)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextChanges.push(change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyNodeChanges(nextChanges)
|
||||||
|
})
|
||||||
|
|
||||||
|
onEdgesChange(async (changes: any) => {
|
||||||
|
const nextChanges = []
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.type === 'remove') {
|
||||||
|
const isConfirmed = await createConfirm({
|
||||||
|
title: '确认',
|
||||||
|
content: `确定要删除该节点吗?`,
|
||||||
|
})
|
||||||
|
if (isConfirmed) {
|
||||||
|
nextChanges.push(change)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextChanges.push(change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEdgeChanges(nextChanges)
|
||||||
|
})
|
||||||
|
|
||||||
// 定义输入参数
|
// 定义输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
workflow: Object as PropType<Workflow>,
|
workflow: Object as PropType<Workflow>,
|
||||||
@@ -21,6 +61,41 @@ const props = defineProps({
|
|||||||
|
|
||||||
// 定义事件
|
// 定义事件
|
||||||
const emit = defineEmits(['close', 'save'])
|
const emit = defineEmits(['close', 'save'])
|
||||||
|
|
||||||
|
// 站点编辑表单数据
|
||||||
|
const workflowForm = ref<any>(props.workflow || {})
|
||||||
|
|
||||||
|
// 提示框
|
||||||
|
const $toast = useToast()
|
||||||
|
|
||||||
|
// 确认框
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
|
// 调用API 编辑任务
|
||||||
|
async function updateWorkflow() {
|
||||||
|
// 更新节点和流程
|
||||||
|
workflowForm.value.actions = nodes
|
||||||
|
workflowForm.value.flows = edges
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||||
|
if (result.success) {
|
||||||
|
$toast.success(`保存任务流程成功!`)
|
||||||
|
emit('save')
|
||||||
|
} else {
|
||||||
|
$toast.error(`保存任务流程失败:${result.message}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.workflow) {
|
||||||
|
nodes.value = props.workflow.actions ?? []
|
||||||
|
edges.value = props.workflow.flows ?? []
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -35,24 +110,23 @@ const emit = defineEmits(['close', 'save'])
|
|||||||
</VBtn>
|
</VBtn>
|
||||||
</VToolbarItems>
|
</VToolbarItems>
|
||||||
<VToolbarTitle> 编辑流程 - {{ workflow?.name }} </VToolbarTitle>
|
<VToolbarTitle> 编辑流程 - {{ workflow?.name }} </VToolbarTitle>
|
||||||
<VSpacer />
|
|
||||||
<VToolbarItems>
|
<VToolbarItems>
|
||||||
<VBtn icon @click="emit('save')" class="me-5">
|
<VBtn icon @click="updateWorkflow" class="me-5">
|
||||||
<VIcon size="large" color="white" icon="mdi-content-save" />
|
<VIcon size="large" color="white" icon="mdi-content-save" />
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VToolbarItems>
|
</VToolbarItems>
|
||||||
</VToolbar>
|
</VToolbar>
|
||||||
</div>
|
</div>
|
||||||
<VCardText>
|
<VCardText class="px-0 py-0">
|
||||||
<div class="dnd-flow" @drop="onDrop">
|
<div class="dnd-flow" @drop="onDrop">
|
||||||
<VueFlow :nodes="nodes" @dragover="onDragOver" @dragleave="onDragLeave">
|
<VueFlow :nodes="nodes" :edges="edges" @dragover="onDragOver" @dragleave="onDragLeave">
|
||||||
|
<MiniMap />
|
||||||
<DropzoneBackground
|
<DropzoneBackground
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: isDragOver ? '#e7f3ff' : 'transparent',
|
backgroundColor: isDragOver ? '#e7f3ff' : 'transparent',
|
||||||
transition: 'background-color 0.2s ease',
|
transition: 'background-color 0.2s ease',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<p v-if="isDragOver">Drop here</p>
|
|
||||||
</DropzoneBackground>
|
</DropzoneBackground>
|
||||||
</VueFlow>
|
</VueFlow>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -67,4 +141,80 @@ const emit = defineEmits(['close', 'save'])
|
|||||||
@import '@vue-flow/controls/dist/style.css';
|
@import '@vue-flow/controls/dist/style.css';
|
||||||
@import '@vue-flow/minimap/dist/style.css';
|
@import '@vue-flow/minimap/dist/style.css';
|
||||||
@import '@vue-flow/node-resizer/dist/style.css';
|
@import '@vue-flow/node-resizer/dist/style.css';
|
||||||
|
|
||||||
|
.vue-flow__minimap {
|
||||||
|
transform: scale(75%);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dnd-flow {
|
||||||
|
flex-direction: column;
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dnd-flow aside {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
border-right: 1px solid #eee;
|
||||||
|
padding: 15px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #10b981bf;
|
||||||
|
-webkit-box-shadow: 0px 5px 10px 0px rgba(0, 0, 0, 0.3);
|
||||||
|
box-shadow: 0 5px 10px #0000004d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dnd-flow aside .nodes > * {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: grab;
|
||||||
|
font-weight: 500;
|
||||||
|
-webkit-box-shadow: 5px 5px 10px 2px rgba(0, 0, 0, 0.25);
|
||||||
|
box-shadow: 5px 5px 10px 2px #00000040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dnd-flow aside .description {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dnd-flow .vue-flow-wrapper {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 640px) {
|
||||||
|
.dnd-flow {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dnd-flow aside {
|
||||||
|
min-width: 25%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 639px) {
|
||||||
|
.dnd-flow aside .nodes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-background {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-background .overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Background } from '@vue-flow/background'
|
|||||||
<template>
|
<template>
|
||||||
<div class="dropzone-background">
|
<div class="dropzone-background">
|
||||||
<Background :size="2" :gap="20" pattern-color="#BDBDBD" />
|
<Background :size="2" :gap="20" pattern-color="#BDBDBD" />
|
||||||
|
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,20 +2,73 @@
|
|||||||
import useDragAndDrop from '@core/utils/workflow'
|
import useDragAndDrop from '@core/utils/workflow'
|
||||||
|
|
||||||
const { onDragStart } = useDragAndDrop()
|
const { onDragStart } = useDragAndDrop()
|
||||||
|
|
||||||
|
// 组件列表
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
type: 'AddDownloadAction',
|
||||||
|
label: '添加下载资源',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'AddSubscribeAction',
|
||||||
|
label: '添加订阅',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'FetchDownloadsAction',
|
||||||
|
label: '获取下载任务',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'FetchMediasAction',
|
||||||
|
label: '获取媒体数据',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'FetchRssAction',
|
||||||
|
label: '获取RSS数据',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'FetchTorrentsAction',
|
||||||
|
label: '搜索站点资源',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'FilterMediasAction',
|
||||||
|
label: '过滤媒体数据',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'FilterTorrentsAction',
|
||||||
|
label: '过滤资源数据',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'ScrapeFileAction',
|
||||||
|
label: '刮削文件',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'SendEventAction',
|
||||||
|
label: '发送事件',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'SendMessageAction',
|
||||||
|
label: '发送消息',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TransferFileAction',
|
||||||
|
label: '整理文件',
|
||||||
|
},
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside>
|
<aside>
|
||||||
<div class="description">You can drag these nodes to the pane.</div>
|
<div class="mb-3"><VLabel>可选动作组件:</VLabel></div>
|
||||||
|
|
||||||
<div class="nodes">
|
<div class="nodes">
|
||||||
<div class="vue-flow__node-input" :draggable="true" @dragstart="onDragStart($event, 'input')">Input Node</div>
|
<div
|
||||||
|
class="vue-flow__node-default"
|
||||||
<div class="vue-flow__node-default" :draggable="true" @dragstart="onDragStart($event, 'default')">
|
v-for="action in actions"
|
||||||
Default Node
|
:draggable="true"
|
||||||
|
@dragstart="onDragStart($event, action)"
|
||||||
|
>
|
||||||
|
{{ action['label'] }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vue-flow__node-output" :draggable="true" @dragstart="onDragStart($event, 'output')">Output Node</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ const appMode = inject('pwaMode') && display.mdAndDown.value
|
|||||||
// 是否刷新
|
// 是否刷新
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
// 自动刷新定时器
|
|
||||||
const autoRefresh = ref<NodeJS.Timeout | null>(null)
|
|
||||||
|
|
||||||
// 新增对话框
|
// 新增对话框
|
||||||
const addDialog = ref(false)
|
const addDialog = ref(false)
|
||||||
|
|
||||||
@@ -40,13 +37,6 @@ function addDone() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
autoRefresh.value = setInterval(fetchData, 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (autoRefresh.value) {
|
|
||||||
clearInterval(autoRefresh.value)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user