feat: 增强工作流侧边栏,支持移动端显示和组件点击事件处理

This commit is contained in:
jxxghp
2025-04-10 21:12:48 +08:00
parent f58d4fcb7e
commit f85ac34753
6 changed files with 512 additions and 107 deletions

View File

@@ -10,7 +10,7 @@ import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
const { onConnect, addEdges, nodes, edges } = useVueFlow()
const { onConnect, addEdges, nodes, edges, addNodes, screenToFlowCoordinate } = useVueFlow()
const { onDragOver, onDrop, onDragLeave, isDragOver } = useDragAndDrop()
@@ -98,6 +98,43 @@ const $toast = useToast()
// 导入代码对话框
const importCodeDialog = ref(false)
// 为移动端生成节点ID
function getId() {
return 'act_' + Math.random().toString(36).substr(2, 9)
}
// 处理移动端组件点击事件
function handleComponentClick(action: any) {
// 计算当前视图中心点
const centerX = window.innerWidth / 2
const centerY = window.innerHeight / 3
// 转换为画布坐标
const position = screenToFlowCoordinate({
x: centerX,
y: centerY,
})
// 生成一个新节点ID
const nodeId = getId()
// 创建新节点
const newNode = {
id: nodeId,
type: action.type,
name: action.name,
description: action.desc || '',
position,
data: {},
}
// 添加节点到画布
addNodes(newNode)
// 显示提示
$toast.success('已添加组件到画布')
}
// 调用API 编辑任务
async function updateWorkflow() {
// 更新节点和流程
@@ -157,32 +194,34 @@ const isMacOS = computed(() => {
<template>
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
<VCard>
<VCard class="workflow-dialog">
<!-- Toolbar -->
<div>
<VToolbar color="primary">
<VToolbarItems>
<VBtn icon @click="emit('close')" class="ms-3">
<VIcon size="large" color="white" icon="mdi-close" />
</VBtn>
</VToolbarItems>
<VToolbarTitle> 编辑流程 - {{ workflow?.name }} </VToolbarTitle>
<VToolbarItems>
<VBtn icon @click="importCodeDialog = true">
<VIcon size="large" color="white" icon="mdi-import" />
</VBtn>
<VBtn icon @click="shareWorkflow">
<VIcon size="large" color="white" icon="mdi-share" />
</VBtn>
<VBtn icon @click="updateWorkflow" class="mx-5">
<VIcon size="large" color="white" icon="mdi-content-save" />
</VBtn>
</VToolbarItems>
</VToolbar>
</div>
<VDivider />
<VCardText class="px-0 py-0">
<div class="dnd-flow" @drop="onDrop">
<VToolbar color="primary">
<VToolbarItems>
<VBtn icon @click="emit('close')" class="ms-3">
<VIcon size="large" color="white" icon="mdi-close" />
</VBtn>
</VToolbarItems>
<VToolbarTitle class="text-truncate"> 编辑流程 - {{ workflow?.name }} </VToolbarTitle>
<VSpacer></VSpacer>
<VToolbarItems>
<VBtn icon variant="text" @click="importCodeDialog = true" class="ms-2">
<VIcon size="24" color="white" icon="mdi-import" />
<VTooltip activator="parent" location="bottom">导入流程代码</VTooltip>
</VBtn>
<VBtn icon variant="text" @click="shareWorkflow" class="ms-2">
<VIcon size="24" color="white" icon="mdi-share" />
<VTooltip activator="parent" location="bottom">分享流程代码</VTooltip>
</VBtn>
<VBtn icon variant="text" @click="updateWorkflow" class="ms-2 me-3">
<VIcon size="24" color="white" icon="mdi-content-save" />
<VTooltip activator="parent" location="bottom">保存流程</VTooltip>
</VBtn>
</VToolbarItems>
</VToolbar>
<VCardText class="workflow-content pa-0">
<div class="workflow-canvas" @drop="onDrop">
<VueFlow
:nodes="nodes"
:edges="edges"
@@ -204,10 +243,11 @@ const isMacOS = computed(() => {
>
</DropzoneBackground>
</VueFlow>
<WorkflowSidebar />
<WorkflowSidebar @component-click="handleComponentClick" />
</div>
</VCardText>
</VCard>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
@@ -218,88 +258,43 @@ const isMacOS = computed(() => {
/>
</VDialog>
</template>
<style>
<style lang="scss">
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/controls/dist/style.css';
@import '@vue-flow/minimap/dist/style.css';
@import '@vue-flow/node-resizer/dist/style.css';
.vue-flow__minimap {
transform: scale(75%);
transform-origin: bottom right;
}
.dnd-flow {
.workflow-dialog {
display: flex;
overflow: hidden;
flex-direction: column;
block-size: 100%;
}
.dnd-flow aside {
background: #10b981bf;
border-inline-end: 1px solid #eee;
box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 30%);
box-shadow: 0 5px 10px #0000004d;
color: #fff;
font-size: 12px;
font-weight: 700;
padding-block: 15px;
padding-inline: 10px;
.workflow-content {
position: relative;
overflow: hidden;
flex: 1;
}
.dnd-flow aside .nodes > * {
box-shadow: 5px 5px 10px 2px rgba(0, 0, 0, 25%);
box-shadow: 5px 5px 10px 2px #00000040;
cursor: grab;
font-weight: 500;
margin-block-end: 10px;
}
.dnd-flow aside .description {
margin-block-end: 10px;
}
.dnd-flow .vue-flow-wrapper {
flex-grow: 1;
block-size: 100%;
}
@media screen and (width >= 640px) {
.dnd-flow {
flex-direction: row;
}
.dnd-flow aside {
max-inline-size: 25%;
}
}
@media screen and (width <= 639px) {
.dnd-flow aside .nodes {
display: flex;
flex-direction: row;
gap: 5px;
}
}
.dropzone-background {
.workflow-canvas {
position: relative;
block-size: 100%;
inline-size: 100%;
}
.dropzone-background .overlay {
position: absolute;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
block-size: 100%;
inline-size: 100%;
inset-block-start: 0;
inset-inline-start: 0;
pointer-events: none;
.vue-flow__minimap {
overflow: hidden;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 0.8);
box-shadow: 0 4px 15px rgba(var(--v-shadow-key-umbra-color), 0.1);
inset-block-end: 20px;
inset-inline-end: 20px;
transform: scale(75%);
transform-origin: bottom right;
}
.vue-flow__handle {
@@ -320,4 +315,39 @@ const isMacOS = computed(() => {
.vue-flow__handle-right {
background-color: rgb(var(--v-theme-error));
}
// 自定义节点样式
.vue-flow__node {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 12px;
&:hover {
box-shadow: 0 8px 16px rgba(var(--v-shadow-key-umbra-color), 0.15) !important;
transform: translateY(-2px);
}
&.selected {
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary)) !important;
}
}
// 自定义动作连线样式
.vue-flow__edge.animation {
.vue-flow__edge-path {
stroke: rgb(var(--v-theme-primary));
}
&.selected {
.vue-flow__edge-path {
stroke: rgb(var(--v-theme-primary));
stroke-width: 4;
}
}
}
@media screen and (width <= 600px) {
.vue-flow__minimap {
display: none;
}
}
</style>

View File

@@ -44,7 +44,7 @@ onMounted(() => {
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-download-box-outline" size="x-large"></VIcon>
<VIcon icon="mdi-download" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>添加下载</VCardTitle>

View File

@@ -19,7 +19,7 @@ defineProps({
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-star-check" size="x-large"></VIcon>
<VIcon icon="mdi-star-plus" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>添加订阅</VCardTitle>

View File

@@ -110,7 +110,7 @@ onMounted(() => {
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-multimedia" size="x-large"></VIcon>
<VIcon icon="mdi-movie-search" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>获取媒体数据</VCardTitle>

View File

@@ -20,7 +20,7 @@ defineProps({
<VCardItem>
<template v-slot:prepend>
<VAvatar>
<VIcon icon="mdi-file-move" size="x-large"></VIcon>
<VIcon icon="mdi-folder-search" size="x-large"></VIcon>
</VAvatar>
</template>
<VCardTitle>扫描目录</VCardTitle>

View File

@@ -1,11 +1,29 @@
<script lang="ts" setup>
import api from '@/api'
import useDragAndDrop from '@core/utils/workflow'
import { useDisplay } from 'vuetify'
interface ActionItem {
name: string
type: string
desc?: string
}
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
const { onDragStart } = useDragAndDrop()
// 组件列表
const actions = ref([])
const actions = ref<ActionItem[]>([])
// 侧边栏是否收起 (仅在桌面端有效)
const isSidebarCollapsed = ref(false)
// 侧边栏在移动端是否显示
const showMobileSidebar = ref(false)
// 定义emit
const emit = defineEmits(['component-click'])
// 加载组件列表
async function load_actions() {
@@ -16,25 +34,382 @@ async function load_actions() {
}
}
// 切换侧边栏收起状态
function toggleSidebar() {
isSidebarCollapsed.value = !isSidebarCollapsed.value
}
// 切换移动端侧边栏显示状态
function toggleMobileSidebar() {
showMobileSidebar.value = !showMobileSidebar.value
}
// 处理移动端点击组件事件
function handleComponentClick(action: ActionItem) {
// 向父组件发送事件
emit('component-click', action)
// 关闭侧边栏
showMobileSidebar.value = false
}
// 根据动作类型获取图标
function getActionIcon(type: string): string {
const iconMap: Record<string, string> = {
'AddSubscribeAction': 'mdi-star-plus',
'AddDownloadAction': 'mdi-download',
'FetchDownloadsAction': 'mdi-progress-download',
'FetchMediasAction': 'mdi-movie-search',
'FetchRssAction': 'mdi-rss',
'FetchTorrentsAction': 'mdi-search-web',
'FilterMediasAction': 'mdi-filter-check',
'FilterTorrentsAction': 'mdi-filter-multiple',
'ScanFileAction': 'mdi-folder-search',
'ScrapeFileAction': 'mdi-file-find',
'SendEventAction': 'mdi-send-check',
'SendMessageAction': 'mdi-message-arrow-right',
'TransferFileAction': 'mdi-file-move',
}
return iconMap[type] || 'mdi-puzzle-outline'
}
// 计算侧边栏类名
const sidebarClasses = computed(() => {
return {
'sidebar-collapsed': isSidebarCollapsed.value && !display.smAndDown.value,
'sidebar-mobile': display.smAndDown.value,
'sidebar-mobile-open': showMobileSidebar.value && display.smAndDown.value,
}
})
// 监听屏幕尺寸变化,自动关闭移动端侧边栏
watch(
() => display.smAndDown.value,
isMobile => {
if (!isMobile) {
showMobileSidebar.value = false
}
},
)
onMounted(() => {
load_actions()
})
</script>
<template>
<aside>
<div class="mb-3"><VLabel>可选动作组件</VLabel></div>
<!-- 移动端触发按钮 -->
<div
v-if="display.smAndDown.value"
class="workflow-sidebar-trigger"
:class="appMode ? 'right-4 bottom-28' : 'right-4 bottom-4'"
@click="toggleMobileSidebar"
>
<VBtn icon size="large" class="workflow-sidebar-fab">
<VIcon :icon="showMobileSidebar ? 'mdi-close' : 'mdi-plus'" />
</VBtn>
</div>
<div class="nodes flex flex-wrap justify-center">
<div
class="vue-flow__node-default cursor-grab mx-1"
v-for="(action, index) in actions"
:key="index"
:draggable="true"
@dragstart="onDragStart($event, action)"
>
{{ action['name'] }}
<!-- 侧边栏 -->
<aside class="workflow-sidebar" :class="sidebarClasses">
<div class="sidebar-container">
<!-- 侧边栏头部 -->
<div class="sidebar-header">
<div class="header-content">
<VAvatar size="36" class="workflow-logo">
<VIcon icon="mdi-puzzle" />
</VAvatar>
<span v-if="!isSidebarCollapsed || display.smAndDown.value" class="header-title">动作组件</span>
<IconBtn v-if="!display.smAndDown.value" @click="toggleSidebar" class="collapse-btn">
<VIcon :icon="isSidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left'" />
</IconBtn>
</div>
</div>
<!-- 组件列表 -->
<div class="components-container">
<div
v-for="(action, index) in actions"
:key="index"
class="component-item"
:draggable="!display.smAndDown.value"
@dragstart="!display.smAndDown.value && onDragStart($event, action)"
@click="display.smAndDown.value && handleComponentClick(action)"
>
<div class="component-card">
<VAvatar size="36" class="component-avatar">
<VIcon :icon="getActionIcon(action.type)" size="18" />
</VAvatar>
<div v-if="!isSidebarCollapsed || display.smAndDown.value" class="component-info">
<div class="component-name">{{ action.name }}</div>
<div class="component-desc">{{ display.smAndDown.value ? '点击添加' : '拖动到画布' }}</div>
</div>
</div>
</div>
</div>
<!-- 底部提示 -->
<div class="sidebar-footer">
<VBtn block class="drag-btn">
<div class="btn-content">
<VIcon v-if="isSidebarCollapsed && !display.smAndDown.value" class="footer-icon" icon="mdi-gesture-swipe" />
<template v-else>
<VIcon :icon="display.smAndDown.value ? 'mdi-gesture-tap' : 'mdi-gesture-swipe'" class="me-2" />
<span>{{ display.smAndDown.value ? '点击组件添加到画布' : '拖动组件到画布' }}</span>
</template>
</div>
</VBtn>
</div>
</div>
</aside>
</template>
<style lang="scss" scoped>
@use 'sass:color';
.workflow-sidebar {
position: absolute;
z-index: 100;
overflow: hidden;
background-color: #f5f5f7;
box-shadow: 0 0 15px rgba(0, 0, 0, 8%);
inline-size: 280px;
inset-block: 0;
inset-inline-start: 0;
transition: all 0.3s ease;
&.sidebar-collapsed {
inline-size: 70px;
}
&.sidebar-mobile {
inline-size: 240px;
transform: translateX(-100%);
&.sidebar-mobile-open {
transform: translateX(0);
}
}
}
.sidebar-container {
display: flex;
flex-direction: column;
block-size: 100%;
}
.sidebar-header {
flex-shrink: 0;
padding: 16px;
background-color: #fff;
border-block-end: 1px solid rgba(0, 0, 0, 6%);
.header-content {
position: relative;
display: flex;
align-items: center;
}
.workflow-logo {
background-color: #8c58f5;
color: white;
margin-inline-end: 10px;
}
.header-title {
color: #1a1a1a;
font-size: 18px;
font-weight: 600;
}
.collapse-btn {
position: absolute;
color: #8c58f5;
inset-block-start: 0;
inset-inline-end: 0;
}
}
.components-container {
flex: 1;
padding: 12px;
overflow-y: auto;
&::-webkit-scrollbar {
inline-size: 5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
background-color: rgba(140, 88, 245, 30%);
}
}
.component-item {
cursor: grab;
margin-block-end: 10px;
&:active {
cursor: grabbing;
}
}
.component-card {
display: flex;
align-items: center;
padding: 10px;
border-radius: 12px;
background-color: #e4e4e7;
transition: all 0.2s ease;
&:hover {
background-color: #d4d4d8;
transform: translateY(-2px);
}
}
.component-avatar {
flex-shrink: 0;
background-color: #8c58f5;
color: white;
margin-inline-end: 12px;
.v-icon {
color: white !important;
opacity: 1 !important;
}
}
.component-info {
overflow: hidden;
max-inline-size: calc(100% - 48px);
}
.component-name {
overflow: hidden;
color: #1a1a1a;
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.component-desc {
overflow: hidden;
color: #71717a;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-footer {
flex-shrink: 0;
padding: 12px;
background-color: #fff;
border-block-start: 1px solid rgba(0, 0, 0, 6%);
.drag-btn {
background-color: #8c58f5;
block-size: 44px;
color: white;
font-weight: 500;
letter-spacing: normal;
text-transform: none;
&:hover {
background-color: color.adjust(#8c58f5, $lightness: -5%);
}
.btn-content {
display: flex;
align-items: center;
justify-content: center;
inline-size: 100%;
}
.footer-icon {
font-size: 20px;
}
}
}
// 移动端悬浮按钮
.workflow-sidebar-trigger {
position: fixed;
z-index: 100;
}
.workflow-sidebar-fab {
background-color: #8c58f5;
box-shadow: 0 4px 10px rgba(140, 88, 245, 40%);
color: white;
&:hover {
background-color: color.adjust(#8c58f5, $lightness: -5%);
}
}
.sidebar-collapsed {
.component-card {
justify-content: center;
padding: 8px;
}
.component-avatar {
block-size: 40px !important;
inline-size: 40px !important;
margin-inline-end: 0;
.v-icon {
font-size: 20px !important;
}
}
.sidebar-footer {
padding-block: 10px;
padding-inline: 6px;
.drag-btn {
padding: 0;
border-radius: 10px;
block-size: 48px;
inline-size: 100%;
min-inline-size: 0;
.btn-content {
inline-size: 100%;
}
}
}
}
@media (width <= 600px) {
.component-card {
padding: 8px;
}
.component-item {
margin-block-end: 8px;
}
.components-container {
padding: 8px;
}
.sidebar-header {
padding: 12px;
}
.sidebar-footer {
padding: 8px;
.drag-btn {
block-size: 40px;
}
}
}
</style>