refactor: enhance dynamic button system to support menus, reactive properties, and improved PWA floating action button integration

This commit is contained in:
jxxghp
2026-04-19 12:29:02 +08:00
parent 54f5fb2877
commit e9b214cff8
11 changed files with 523 additions and 288 deletions
+31
View File
@@ -5,6 +5,8 @@ import FileNavigator from './filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types' import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageIconDict } from '@/api/constants' import { storageIconDict } from '@/api/constants'
import type { AxiosInstance } from 'axios' import type { AxiosInstance } from 'axios'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
// LocalStorage keys // LocalStorage keys
const SORT_KEY = 'fileBrowser.sort' const SORT_KEY = 'fileBrowser.sort'
@@ -33,6 +35,9 @@ const props = defineProps({
// 对外事件 // 对外事件
const emit = defineEmits(['pathchanged']) const emit = defineEmits(['pathchanged'])
const route = useRoute()
const { appMode } = usePWA()
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
const fileIcons = { const fileIcons = {
// 压缩包 // 压缩包
@@ -123,6 +128,18 @@ const fileIcons = {
other: 'mdi-file-outline', other: 'mdi-file-outline',
} }
function openNewFolderDialog() {
toolbarRef.value?.openNewFolderDialog()
}
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
useDynamicButton({
icon: 'mdi-folder-plus-outline',
onClick: openNewFolderDialog,
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
})
// 加载次数 // 加载次数
const loading = ref(0) const loading = ref(0)
@@ -254,12 +271,14 @@ function stopDrag() {
<div class="mx-auto" :loading="loading > 0"> <div class="mx-auto" :loading="loading > 0">
<div v-if="item"> <div v-if="item">
<FileToolbar <FileToolbar
ref="toolbarRef"
:sort="sort" :sort="sort"
:item="item" :item="item"
:itemstack="itemstack" :itemstack="itemstack"
:storages="storagesArray" :storages="storagesArray"
:endpoints="endpoints" :endpoints="endpoints"
:axios="axios" :axios="axios"
:show-new-folder-button="!showFloatingNewFolderAction"
@storagechanged="storageChanged" @storagechanged="storageChanged"
@pathchanged="pathChanged" @pathchanged="pathChanged"
@foldercreated="refreshPending = true" @foldercreated="refreshPending = true"
@@ -301,6 +320,18 @@ function stopDrag() {
</div> </div>
</div> </div>
</div> </div>
<Teleport to="body" v-if="!appMode && showFloatingNewFolderAction">
<VFab
icon="mdi-folder-plus-outline"
location="bottom"
size="x-large"
fixed
app
appear
@click="openNewFolderDialog"
/>
</Teleport>
</template> </template>
<style scoped> <style scoped>
+16 -3
View File
@@ -30,6 +30,10 @@ const inProps = defineProps({
type: String, type: String,
default: 'name', default: 'name',
}, },
showNewFolderButton: {
type: Boolean,
default: true,
},
}) })
// 对外事件 // 对外事件
@@ -109,11 +113,20 @@ async function mkdir() {
emit('foldercreated') emit('foldercreated')
} }
function openNewFolderDialog() {
newFolderName.value = ''
newFolderPopper.value = true
}
// 计算排序图标 // 计算排序图标
const sortIcon = computed(() => { const sortIcon = computed(() => {
if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline' if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline'
else return 'mdi-sort-alphabetical-ascending' else return 'mdi-sort-alphabetical-ascending'
}) })
defineExpose({
openNewFolderDialog,
})
</script> </script>
<template> <template>
@@ -165,9 +178,9 @@ const sortIcon = computed(() => {
</IconBtn> </IconBtn>
<!-- 新建文件夹 --> <!-- 新建文件夹 -->
<VDialog v-model="newFolderPopper" max-width="35rem"> <VDialog v-model="newFolderPopper" max-width="35rem">
<template #activator="{ props }"> <template v-if="showNewFolderButton" #activator="{ props }">
<IconBtn> <IconBtn v-bind="props">
<VIcon v-bind="props" icon="mdi-folder-plus-outline" /> <VIcon icon="mdi-folder-plus-outline" />
</IconBtn> </IconBtn>
</template> </template>
<VCard> <VCard>
+82 -17
View File
@@ -1,12 +1,41 @@
import { ref, inject, nextTick, onMounted, onActivated, onDeactivated, onUnmounted } from 'vue' import {
computed,
inject,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref,
unref,
watch,
type ComputedRef,
type Ref,
} from 'vue'
// 声明全局变量类型 // 声明全局变量类型
declare global { declare global {
interface Window { interface Window {
__VUE_INJECT_DYNAMIC_BUTTON__?: (button: any) => void __VUE_INJECT_DYNAMIC_BUTTON__?: (button: any) => void
__VUE_UNINJECT_DYNAMIC_BUTTON__?: () => void
} }
} }
type MaybeRefValue<T> = T | Ref<T> | ComputedRef<T>
interface DynamicButtonMenuItem {
title: string
icon?: string
color?: string
action: () => void
}
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined): T | undefined
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined, fallback: T): T
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined, fallback?: T) {
return value !== undefined ? unref(value) : fallback
}
/** /**
* 动态按钮钩子函数 * 动态按钮钩子函数
* *
@@ -23,12 +52,14 @@ declare global {
* }) * })
*/ */
export function useDynamicButton(options: { export function useDynamicButton(options: {
icon: string icon: MaybeRefValue<string>
onClick: () => void onClick?: () => void
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
show?: MaybeRefValue<boolean>
autoRegister?: boolean // 是否自动注册,默认为true autoRegister?: boolean // 是否自动注册,默认为true
}) { }) {
// 提取配置 // 提取配置
const { icon, onClick, autoRegister = true } = options const { icon, onClick, menuItems, show, autoRegister = true } = options
// 动态按钮相关 // 动态按钮相关
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null) const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
@@ -36,22 +67,42 @@ export function useDynamicButton(options: {
// 按钮注册状态 // 按钮注册状态
const dynamicButtonRegistered = ref(false) const dynamicButtonRegistered = ref(false)
const componentActive = ref(false)
const resolvedIcon = computed(() => resolveMaybeRef(icon, 'mdi-plus'))
const resolvedShow = computed(() => resolveMaybeRef(show, true))
const resolvedMenuItems = computed(() => resolveMaybeRef(menuItems))
function buildDynamicButton() {
const buttonMenuItems = resolvedMenuItems.value
return {
icon: resolvedIcon.value,
action: onClick || (() => {}),
show: resolvedShow.value,
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
}
}
// 注册动态按钮 // 注册动态按钮
function setupDynamicButton() { function setupDynamicButton() {
// 避免重复注册 if (!componentActive.value) return
if (dynamicButtonRegistered.value) return
const button = buildDynamicButton()
if (!button.show) {
cleanupDynamicButton()
return
}
// 确保注册方法存在 // 确保注册方法存在
if (!registerDynamicButton) { if (!registerDynamicButton) {
// 尝试获取全局注册方法 // 尝试获取全局注册方法
const tryUseGlobalMethod = () => { const tryUseGlobalMethod = () => {
if (!componentActive.value) return false
if (typeof window !== 'undefined' && window.__VUE_INJECT_DYNAMIC_BUTTON__) { if (typeof window !== 'undefined' && window.__VUE_INJECT_DYNAMIC_BUTTON__) {
window.__VUE_INJECT_DYNAMIC_BUTTON__({ window.__VUE_INJECT_DYNAMIC_BUTTON__(button)
icon,
action: onClick,
show: true,
})
dynamicButtonRegistered.value = true dynamicButtonRegistered.value = true
return true return true
} }
@@ -68,11 +119,9 @@ export function useDynamicButton(options: {
// 如果注册方法存在,直接注册 // 如果注册方法存在,直接注册
nextTick(() => { nextTick(() => {
registerDynamicButton({ if (!componentActive.value) return
icon,
action: onClick, registerDynamicButton(button)
show: true,
})
dynamicButtonRegistered.value = true dynamicButtonRegistered.value = true
}) })
} }
@@ -82,17 +131,24 @@ export function useDynamicButton(options: {
if (unregisterDynamicButton && dynamicButtonRegistered.value) { if (unregisterDynamicButton && dynamicButtonRegistered.value) {
unregisterDynamicButton() unregisterDynamicButton()
dynamicButtonRegistered.value = false dynamicButtonRegistered.value = false
return
}
if (typeof window !== 'undefined' && window.__VUE_UNINJECT_DYNAMIC_BUTTON__) {
window.__VUE_UNINJECT_DYNAMIC_BUTTON__()
dynamicButtonRegistered.value = false
} }
} }
// 暴露方法:手动打开对话框 // 暴露方法:手动打开对话框
function openDialog() { function openDialog() {
onClick() onClick?.()
} }
// 生命周期钩子 // 生命周期钩子
if (autoRegister) { if (autoRegister) {
onMounted(() => { onMounted(() => {
componentActive.value = true
// 延迟执行,确保Footer组件已加载 // 延迟执行,确保Footer组件已加载
setTimeout(() => { setTimeout(() => {
setupDynamicButton() setupDynamicButton()
@@ -100,18 +156,27 @@ export function useDynamicButton(options: {
}) })
onActivated(() => { onActivated(() => {
componentActive.value = true
// 重置注册状态,确保每次激活时都重新注册 // 重置注册状态,确保每次激活时都重新注册
dynamicButtonRegistered.value = false dynamicButtonRegistered.value = false
setupDynamicButton() setupDynamicButton()
}) })
onDeactivated(() => { onDeactivated(() => {
componentActive.value = false
cleanupDynamicButton() cleanupDynamicButton()
}) })
onUnmounted(() => { onUnmounted(() => {
componentActive.value = false
cleanupDynamicButton() cleanupDynamicButton()
}) })
watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {
if (!componentActive.value) return
setupDynamicButton()
}, { deep: true })
} }
// 返回控制函数和状态 // 返回控制函数和状态
+7 -1
View File
@@ -147,6 +147,7 @@ const unregisterDynamicButton = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// 确保在浏览器环境中 // 确保在浏览器环境中
;(window as any).__VUE_INJECT_DYNAMIC_BUTTON__ = registerDynamicButton ;(window as any).__VUE_INJECT_DYNAMIC_BUTTON__ = registerDynamicButton
;(window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__ = unregisterDynamicButton
} }
// 提供给其他组件使用 // 提供给其他组件使用
@@ -160,6 +161,7 @@ onUnmounted(() => {
// 清理全局方法 // 清理全局方法
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
delete (window as any).__VUE_INJECT_DYNAMIC_BUTTON__ delete (window as any).__VUE_INJECT_DYNAMIC_BUTTON__
delete (window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__
} }
}) })
@@ -241,7 +243,11 @@ const hasDynamicButtonMenu = computed(() => Boolean(dynamicButton.value?.menuIte
rounded="pill" rounded="pill"
class="footer-nav-btn" class="footer-nav-btn"
> >
<VIcon color="secondary" :icon="dynamicButton?.icon || 'mdi-plus'" size="28"></VIcon> <VIcon
color="secondary"
:icon="hasDynamicButtonMenu ? 'mdi-chevron-up' : dynamicButton?.icon || 'mdi-plus'"
size="28"
></VIcon>
</VBtn> </VBtn>
<VMenu v-if="hasDynamicButtonMenu" activator="parent" location="top end" close-on-content-click> <VMenu v-if="hasDynamicButtonMenu" activator="parent" location="top end" close-on-content-click>
<VList> <VList>
+157 -50
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { debounce } from 'lodash-es'
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue' import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue' import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue' import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
@@ -6,18 +7,23 @@ import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue' import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab' import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { useUserStore } from '@/stores'
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu' import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
// 国际化 // 国际化
const { t } = useI18n() const { t, locale } = useI18n()
const route = useRoute() const route = useRoute()
const userStore = useUserStore()
const { appMode } = usePWA()
const subType = route.meta.subType?.toString() const subType = route.meta.subType?.toString()
const subId = ref(route.query.id as string) const subId = ref(route.query.id as string)
const activeTab = ref((route.query.tab as string) || '') const activeTab = ref((route.query.tab as string) || '')
const shareViewKey = ref(0) const subscribeListViewRef = ref<InstanceType<typeof SubscribeListView> | null>(null)
// 获取标签页 // 获取标签页
const subscribeTabs = computed(() => { const subscribeTabs = computed(() => {
@@ -48,12 +54,7 @@ const subscribeStatusFilter = ref<string | null>(null)
// 分享搜索词 // 分享搜索词
const shareKeyword = ref('') const shareKeyword = ref('')
const shareKeywordInput = ref('')
// 搜索分享
const searchShares = () => {
searchShareDialog.value = false
shareViewKey.value++
}
// 筛选选项 // 筛选选项
const filterOptions = computed(() => { const filterOptions = computed(() => {
@@ -103,7 +104,98 @@ function selectFilter(value: string) {
// VMenu activator选择器 // VMenu activator选择器
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]') const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
const searchActivator = computed(() => '[data-menu-activator="search-btn"]') const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub')
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && userStore.superUser)
const showShareStatisticsAction = computed(() => activeTab.value === 'share')
function openDefaultRuleDialog() {
subscribeEditDialog.value = true
}
function openSubscribeHistoryDialog() {
subscribeListViewRef.value?.openHistoryDialog()
}
function openShareStatisticsDialog() {
shareStatisticsDialog.value = true
}
const shareKeywordUpdater = debounce((keyword: string) => {
shareKeyword.value = keyword.trim()
}, 300)
watch(shareKeywordInput, newKeyword => {
shareKeywordUpdater(newKeyword || '')
})
watch(activeTab, newTab => {
if (newTab !== 'share') {
searchShareDialog.value = false
}
})
onUnmounted(() => {
shareKeywordUpdater.cancel()
})
const subscribeDynamicMenuItems = computed(() => {
locale.value
if (!appMode.value) return undefined
if (activeTab.value === 'mysub') {
const items: Array<{ title: string; icon: string; action: () => void }> = []
if (showSubscribeHistoryAction.value) {
items.push({
title: t('components.subscribeHistory.title', { type: subType }),
icon: 'mdi-history',
action: openSubscribeHistoryDialog,
})
}
items.push({
title: t('components.subscribeEdit.titleDefault'),
icon: 'mdi-clipboard-edit-outline',
action: openDefaultRuleDialog,
})
return items.length > 1 ? items : undefined
}
return undefined
})
const subscribeDynamicIcon = computed(() => {
if (showShareStatisticsAction.value) return 'mdi-chart-line'
if (showSubscribeHistoryAction.value) return 'mdi-history'
return 'mdi-clipboard-edit-outline'
})
function handleSubscribeDynamicAction() {
if (showShareStatisticsAction.value) {
openShareStatisticsDialog()
return
}
if (showSubscribeHistoryAction.value) {
openSubscribeHistoryDialog()
return
}
if (showDefaultRuleAction.value) {
openDefaultRuleDialog()
}
}
useDynamicButton({
icon: subscribeDynamicIcon,
onClick: handleSubscribeDynamicAction,
menuItems: subscribeDynamicMenuItems,
show: computed(() => appMode.value && (showDefaultRuleAction.value || showShareStatisticsAction.value)),
})
// 使用动态标签页 // 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab() const { registerHeaderTab } = useDynamicHeaderTab()
@@ -137,37 +229,16 @@ registerHeaderTab({
show: computed(() => activeTab.value === 'mysub'), show: computed(() => activeTab.value === 'mysub'),
}, },
{ {
icon: 'mdi-chart-line', icon: 'mdi-filter-multiple-outline',
variant: 'text', variant: 'text',
color: 'gray', color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
class: 'settings-icon-button', class: 'settings-icon-button',
dataAttr: 'statistics-btn', dataAttr: 'share-filter-btn',
action: () => {
shareStatisticsDialog.value = true
},
show: computed(() => activeTab.value === 'share'),
},
{
icon: 'mdi-movie-search-outline',
variant: 'text',
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'search-btn',
action: () => { action: () => {
searchShareDialog.value = true searchShareDialog.value = true
}, },
show: computed(() => activeTab.value === 'share'), show: computed(() => activeTab.value === 'share'),
}, },
{
icon: 'mdi-clipboard-edit-outline',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
subscribeEditDialog.value = true
},
show: computed(() => activeTab.value === 'mysub'),
},
], ],
}) })
@@ -187,6 +258,7 @@ onMounted(() => {
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<SubscribeListView <SubscribeListView
ref="subscribeListViewRef"
:type="subType" :type="subType"
:subid="subId" :subid="subId"
:keyword="subscribeFilter" :keyword="subscribeFilter"
@@ -205,7 +277,7 @@ onMounted(() => {
<VWindowItem value="share"> <VWindowItem value="share">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<SubscribeShareView :keyword="shareKeyword" :key="shareViewKey" /> <SubscribeShareView :keyword="shareKeyword" />
</div> </div>
</transition> </transition>
</VWindowItem> </VWindowItem>
@@ -260,30 +332,65 @@ onMounted(() => {
<Teleport to="body" v-if="searchShareDialog"> <Teleport to="body" v-if="searchShareDialog">
<VMenu <VMenu
v-model="searchShareDialog" v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false" :close-on-content-click="false"
:activator="searchActivator" :activator="searchActivator"
location="bottom end" location="bottom end"
> >
<VCard> <VCard min-width="260" max-width="320">
<VCardItem> <div class="px-3 pt-3 pb-1">
<VCardTitle> <VTextField
<VIcon icon="mdi-movie-search-outline" class="mr-2" /> v-model="shareKeywordInput"
{{ t('subscribe.searchShares') }} :placeholder="t('subscribe.keyword')"
</VCardTitle> prepend-inner-icon="mdi-magnify"
<VDialogCloseBtn @click="searchShareDialog = false" /> density="compact"
</VCardItem> variant="outlined"
<VCardText> hide-details
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable"> clearable
<template #append> />
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn> </div>
</template>
</VTextField>
</VCardText>
</VCard> </VCard>
</VMenu> </VMenu>
</Teleport> </Teleport>
<Teleport to="body" v-if="!appMode && route.path.startsWith(`/subscribe/${subType === '电影' ? 'movie' : 'tv'}`)">
<div>
<VFab
v-if="showSubscribeHistoryAction"
icon="mdi-history"
color="info"
location="bottom"
size="x-large"
fixed
app
appear
@click="openSubscribeHistoryDialog"
/>
<VFab
v-if="showDefaultRuleAction"
icon="mdi-clipboard-edit-outline"
color="primary"
location="bottom"
size="x-large"
fixed
app
appear
:class="{ 'mb-16': showSubscribeHistoryAction }"
@click="openDefaultRuleDialog"
/>
<VFab
v-if="showShareStatisticsAction"
icon="mdi-chart-line"
color="info"
location="bottom"
size="x-large"
fixed
app
appear
@click="openShareStatisticsDialog"
/>
</div>
</Teleport>
<!-- 订阅编辑弹窗 --> <!-- 订阅编辑弹窗 -->
<SubscribeEditDialog <SubscribeEditDialog
v-if="subscribeEditDialog" v-if="subscribeEditDialog"
+62 -38
View File
@@ -1,42 +1,66 @@
<script setup lang="ts"> <script setup lang="ts">
import { debounce } from 'lodash-es'
import WorkflowListView from '@/views/workflow/WorkflowListView.vue' import WorkflowListView from '@/views/workflow/WorkflowListView.vue'
import WorkflowShareView from '@/views/workflow/WorkflowShareView.vue' import WorkflowShareView from '@/views/workflow/WorkflowShareView.vue'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab' import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { getWorkflowTabs } from '@/router/i18n-menu' import { getWorkflowTabs } from '@/router/i18n-menu'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const { appMode } = usePWA()
const activeTab = ref((route.query.tab as string) || 'list') const activeTab = ref((route.query.tab as string) || 'list')
const shareViewKey = ref(0)
const listViewKey = ref(0) const listViewKey = ref(0)
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
// 获取标签页 // 获取标签页
const workflowTabs = computed(() => { const workflowTabs = computed(() => {
return getWorkflowTabs(t) return getWorkflowTabs(t)
}) })
// 新增工作流对话框
const addWorkflowDialog = ref(false)
// 分享搜索词 // 分享搜索词
const shareKeyword = ref('') const shareKeyword = ref('')
const shareKeywordInput = ref('')
// 搜索分享对话框 // 搜索分享对话框
const searchShareDialog = ref(false) const searchShareDialog = ref(false)
// 搜索分享激活器 // 搜索分享激活器
const searchActivator = computed(() => '[data-menu-activator="search-btn"]') const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
// 搜索分享 function openAddWorkflowDialog() {
const searchShares = () => { workflowListViewRef.value?.openAddDialog()
shareViewKey.value++
} }
const shareKeywordUpdater = debounce((keyword: string) => {
shareKeyword.value = keyword.trim()
}, 300)
watch(shareKeywordInput, newKeyword => {
shareKeywordUpdater(newKeyword || '')
})
watch(activeTab, newTab => {
if (newTab !== 'share') {
searchShareDialog.value = false
}
})
onUnmounted(() => {
shareKeywordUpdater.cancel()
})
useDynamicButton({
icon: 'mdi-plus',
onClick: openAddWorkflowDialog,
show: computed(() => appMode.value && activeTab.value === 'list'),
})
// 使用动态标签页 // 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab() const { registerHeaderTab } = useDynamicHeaderTab()
@@ -46,11 +70,11 @@ registerHeaderTab({
modelValue: activeTab, modelValue: activeTab,
appendButtons: [ appendButtons: [
{ {
icon: 'mdi-search', icon: 'mdi-filter-multiple-outline',
variant: 'text', variant: 'text',
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')), color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
class: 'settings-icon-button', class: 'settings-icon-button',
dataAttr: 'search-btn', dataAttr: 'share-filter-btn',
show: computed(() => activeTab.value === 'share'), show: computed(() => activeTab.value === 'share'),
action: () => { action: () => {
searchShareDialog.value = true searchShareDialog.value = true
@@ -74,54 +98,54 @@ onMounted(() => {
<VWindowItem value="list"> <VWindowItem value="list">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<WorkflowListView :key="listViewKey" /> <WorkflowListView ref="workflowListViewRef" :key="listViewKey" />
</div> </div>
</transition> </transition>
</VWindowItem> </VWindowItem>
<VWindowItem value="share"> <VWindowItem value="share">
<transition name="fade-slide" appear> <transition name="fade-slide" appear>
<div> <div>
<WorkflowShareView :keyword="shareKeyword" :key="shareViewKey" @update="listViewKey++" /> <WorkflowShareView :keyword="shareKeyword" @update="listViewKey++" />
</div> </div>
</transition> </transition>
</VWindowItem> </VWindowItem>
</VWindow> </VWindow>
<!-- 新增工作流对话框 -->
<WorkflowAddEditDialog
v-if="addWorkflowDialog"
v-model="addWorkflowDialog"
@close="addWorkflowDialog = false"
@save="addWorkflowDialog = false"
/>
<!-- 搜索工作流分享弹窗 --> <!-- 搜索工作流分享弹窗 -->
<Teleport to="body" v-if="searchShareDialog"> <Teleport to="body" v-if="searchShareDialog">
<VMenu <VMenu
v-model="searchShareDialog" v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false" :close-on-content-click="false"
:activator="searchActivator" :activator="searchActivator"
location="bottom end" location="bottom end"
> >
<VCard> <VCard min-width="260" max-width="320">
<VCardItem> <div class="px-3 pt-3 pb-1">
<VCardTitle> <VTextField
<VIcon icon="mdi-movie-search-outline" class="mr-2" /> v-model="shareKeywordInput"
{{ t('workflow.searchShares') }} :placeholder="t('workflow.searchShares')"
</VCardTitle> prepend-inner-icon="mdi-magnify"
<VDialogCloseBtn @click="searchShareDialog = false" /> density="compact"
</VCardItem> variant="outlined"
<VCardText> hide-details
<VTextField v-model="shareKeyword" :label="t('workflow.searchShares')" clearable density="comfortable"> clearable
<template #append> />
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn> </div>
</template>
</VTextField>
</VCardText>
</VCard> </VCard>
</VMenu> </VMenu>
</Teleport> </Teleport>
<Teleport to="body" v-if="!appMode && route.path === '/workflow' && activeTab === 'list'">
<VFab
icon="mdi-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="openAddWorkflowDialog"
/>
</Teleport>
</div> </div>
</template> </template>
+75 -29
View File
@@ -17,7 +17,7 @@ import { usePWA } from '@/composables/usePWA'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab' import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
// 国际化 // 国际化
const { t } = useI18n() const { t, locale } = useI18n()
const route = useRoute() const route = useRoute()
@@ -79,26 +79,6 @@ registerHeaderTab({
}, },
show: computed(() => activeTab.value === 'market'), show: computed(() => activeTab.value === 'market'),
}, },
{
icon: 'mdi-store-cog',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
MarketSettingDialog.value = true
},
show: computed(() => activeTab.value === 'market'),
},
{
icon: 'mdi-folder-plus',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
showNewFolderDialog()
},
show: computed(() => activeTab.value === 'installed' && !currentFolder.value),
},
{ {
icon: 'mdi-arrow-left', icon: 'mdi-arrow-left',
variant: 'text', variant: 'text',
@@ -915,12 +895,56 @@ onMounted(async () => {
} }
}) })
// 使用动态按钮钩子 function openPluginSearchDialog() {
SearchDialog.value = true
}
function openMarketSettingDialog() {
MarketSettingDialog.value = true
}
const showSearchAction = computed(() => activeTab.value === 'installed' || activeTab.value === 'market')
const showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value)
const showMarketSettingAction = computed(() => activeTab.value === 'market')
const pluginDynamicMenuItems = computed(() => {
locale.value
if (!appMode.value) return undefined
if (!showSearchAction.value) return undefined
const items = [
{
title: t('plugin.searchPlugins'),
icon: 'mdi-magnify',
action: openPluginSearchDialog,
},
]
if (showNewFolderAction.value) {
items.push({
title: t('plugin.newFolder'),
icon: 'mdi-folder-plus',
action: showNewFolderDialog,
})
}
if (showMarketSettingAction.value) {
items.push({
title: t('components.pluginMarketSetting.title'),
icon: 'mdi-store-cog',
action: openMarketSettingDialog,
})
}
return items.length > 1 ? items : undefined
})
useDynamicButton({ useDynamicButton({
icon: 'mdi-magnify', icon: 'mdi-magnify',
onClick: () => { onClick: openPluginSearchDialog,
SearchDialog.value = true menuItems: pluginDynamicMenuItems,
}, show: computed(() => appMode.value && showSearchAction.value && isRefreshed.value),
}) })
// 获取插件文件夹配置 // 获取插件文件夹配置
@@ -1585,9 +1609,8 @@ function onDragStartPlugin(evt: any) {
<!-- 插件搜索图标 --> <!-- 插件搜索图标 -->
<Teleport to="body" v-if="route.path === '/plugins'"> <Teleport to="body" v-if="route.path === '/plugins'">
<div v-if="isRefreshed"> <div v-if="isRefreshed && !appMode && showSearchAction">
<VFab <VFab
v-if="!appMode"
icon="mdi-magnify" icon="mdi-magnify"
color="info" color="info"
location="bottom" location="bottom"
@@ -1595,8 +1618,31 @@ function onDragStartPlugin(evt: any) {
fixed fixed
app app
appear appear
@click="SearchDialog = true" @click="openPluginSearchDialog"
:class="{ 'mb-12': appMode }" />
<VFab
v-if="showNewFolderAction"
icon="mdi-folder-plus"
color="primary"
location="bottom"
size="x-large"
fixed
app
appear
class="mb-16"
@click="showNewFolderDialog"
/>
<VFab
v-if="showMarketSettingAction"
icon="mdi-store-cog"
color="warning"
location="bottom"
size="x-large"
fixed
app
appear
class="mb-16"
@click="openMarketSettingDialog"
/> />
</div> </div>
</Teleport> </Teleport>
+66 -88
View File
@@ -12,70 +12,13 @@ import { useDisplay } from 'vuetify'
import { formatFileSize } from '@/@core/utils/formatters' import { formatFileSize } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA' import { usePWA } from '@/composables/usePWA'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useAvailableHeight } from '@/composables/useAvailableHeight' import { useAvailableHeight } from '@/composables/useAvailableHeight'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization' import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
// i18n // i18n
const { t } = useI18n() const { t, locale } = useI18n()
// 动态按钮相关
interface DynamicButton {
icon: string
action: () => void
show: boolean
routePath?: string
menuItems?: {
title: string
icon?: string
color?: string
action: () => void
}[]
}
const injectedRegisterDynamicButton = inject<((button: DynamicButton) => void) | null>('registerDynamicButton', null)
const injectedUnregisterDynamicButton = inject<(() => void) | null>('unregisterDynamicButton', null)
function registerHistoryDynamicButton(show: boolean) {
const button: DynamicButton = {
icon: 'mdi-chevron-up',
show,
action: () => {},
menuItems: [
{
title: t('transferHistory.actions.batchRedo'),
icon: 'mdi-redo-variant',
action: () => {
retransferBatch()
},
},
{
title: t('transferHistory.actions.batchDelete'),
icon: 'mdi-trash-can-outline',
color: 'error',
action: () => {
removeHistoryBatch()
},
},
],
}
if (injectedRegisterDynamicButton) {
injectedRegisterDynamicButton(button)
return
}
if (typeof window !== 'undefined' && (window as any).__VUE_INJECT_DYNAMIC_BUTTON__) {
;(window as any).__VUE_INJECT_DYNAMIC_BUTTON__(button)
}
}
function unregisterHistoryDynamicButton() {
if (injectedUnregisterDynamicButton) {
injectedUnregisterDynamicButton()
return
}
}
// 全局设置 // 全局设置
const globalSettingsStore = useGlobalSettingsStore() const globalSettingsStore = useGlobalSettingsStore()
@@ -698,32 +641,55 @@ const toggleGroupSelection = (checked: boolean | null, items: readonly any[]) =>
} }
} }
// 监听选中项变化,更新动态按钮 const historyDynamicIcon = computed(() => (selected.value.length > 0 ? 'mdi-chevron-up' : 'mdi-timer-sand-paused'))
watch( const historyDynamicMenuItems = computed(() => {
() => selected.value.length, locale.value
newLength => {
if (appMode) { if (selected.value.length === 0) return undefined
registerHistoryDynamicButton(newLength > 0)
} return [
{
title: t('components.transferQueue.title'),
icon: 'mdi-timer-sand-paused',
action: () => {
transferQueueDialog.value = true
},
},
{
title: t('transferHistory.actions.batchRedo'),
icon: 'mdi-redo-variant',
action: () => {
retransferBatch()
},
},
{
title: t('transferHistory.actions.batchDelete'),
icon: 'mdi-trash-can-outline',
color: 'error',
action: () => {
removeHistoryBatch()
},
},
]
})
useDynamicButton({
icon: historyDynamicIcon,
onClick: () => {
transferQueueDialog.value = true
}, },
) menuItems: historyDynamicMenuItems,
show: computed(() => appMode.value),
})
// 初始加载数据 // 初始加载数据
onMounted(() => { onMounted(() => {
loadStorages() loadStorages()
fetchData() fetchData()
// 仅在 Docker 模式下注册动态按钮
if (appMode) {
registerHistoryDynamicButton(selected.value.length > 0)
}
}) })
onUnmounted(() => { onUnmounted(() => {
stopAiRedoProgress() stopAiRedoProgress()
if (appMode) {
unregisterHistoryDynamicButton()
}
}) })
</script> </script>
@@ -754,7 +720,6 @@ onUnmounted(() => {
</VCol> </VCol>
<VCol cols="4" md="6" class="text-end"> <VCol cols="4" md="6" class="text-end">
<VBtnGroup variant="outlined" divided rounded> <VBtnGroup variant="outlined" divided rounded>
<VBtn icon="mdi-timer-sand-paused" @click="transferQueueDialog = true" />
<VBtn :icon="group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'" @click="group = !group" /> <VBtn :icon="group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'" @click="group = !group" />
</VBtnGroup> </VBtnGroup>
</VCol> </VCol>
@@ -1013,10 +978,33 @@ onUnmounted(() => {
<!-- 整理队列进度弹窗 --> <!-- 整理队列进度弹窗 -->
<TransferQueueDialog v-if="transferQueueDialog" v-model="transferQueueDialog" @close="transferQueueDialog = false" /> <TransferQueueDialog v-if="transferQueueDialog" v-model="transferQueueDialog" @close="transferQueueDialog = false" />
<!-- Docker 模式下的 FAB 按钮 --> <!-- app 模式下的 FAB 按钮 -->
<Teleport to="body" v-if="!appMode && route.path === '/history'"> <Teleport to="body" v-if="!appMode && route.path === '/history'">
<div v-if="isRefreshed && selected.length > 0"> <div v-if="isRefreshed">
<VFab <VFab
icon="mdi-timer-sand-paused"
color="info"
location="bottom"
size="x-large"
fixed
app
appear
@click="transferQueueDialog = true"
/>
<VFab
v-if="selected.length > 0"
class="mb-16"
icon="mdi-redo-variant"
color="primary"
location="bottom"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
<VFab
v-if="selected.length > 0"
icon="mdi-trash-can-outline" icon="mdi-trash-can-outline"
color="error" color="error"
location="bottom" location="bottom"
@@ -1025,17 +1013,7 @@ onUnmounted(() => {
app app
appear appear
@click="removeHistoryBatch" @click="removeHistoryBatch"
class="mb-16"
/>
<VFab
class="mb-32" class="mb-32"
icon="mdi-redo-variant"
location="bottom"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/> />
</div> </div>
</Teleport> </Teleport>
+6 -31
View File
@@ -6,21 +6,13 @@ import NoDataFound from '@/components/NoDataFound.vue'
import SubscribeCard from '@/components/cards/SubscribeCard.vue' import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue' import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
// 路由
const route = useRoute()
// PWA模式检测
const { appMode } = usePWA()
// 用户 Store // 用户 Store
const userStore = useUserStore() const userStore = useUserStore()
@@ -185,6 +177,10 @@ function historyDone() {
fetchData() fetchData()
} }
function openHistoryDialog() {
historyDialog.value = true
}
// 批量管理相关函数 // 批量管理相关函数
// 切换批量模式 // 切换批量模式
function toggleBatchMode() { function toggleBatchMode() {
@@ -381,12 +377,8 @@ onActivated(async () => {
} }
}) })
// 使用动态按钮钩子 defineExpose({
useDynamicButton({ openHistoryDialog,
icon: 'mdi-history',
onClick: () => {
historyDialog.value = true
},
}) })
</script> </script>
@@ -477,23 +469,6 @@ useDynamicButton({
:error-title="errorTitle" :error-title="errorTitle"
:error-description="errorDescription" :error-description="errorDescription"
/> />
<!-- 底部操作按钮 -->
<Teleport to="body" v-if="route.path.startsWith(`/subscribe/${props.type === '电影' ? 'movie' : 'tv'}`)">
<div v-if="isRefreshed">
<VFab
v-if="userStore.superUser && !appMode"
icon="mdi-history"
color="info"
location="bottom"
:class="{ 'mb-12': appMode }"
size="x-large"
fixed
app
appear
@click="historyDialog = true"
/>
</div>
</Teleport>
<!-- 历史记录弹窗 --> <!-- 历史记录弹窗 -->
<SubscribeHistoryDialog <SubscribeHistoryDialog
v-if="historyDialog" v-if="historyDialog"
+8 -30
View File
@@ -5,8 +5,6 @@ import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue' import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
import NoDataFound from '@/components/NoDataFound.vue' import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useDynamicButton } from '@/composables/useDynamicButton'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -14,12 +12,6 @@ const { t } = useI18n()
// 是否刷新 // 是否刷新
const isRefreshed = ref(false) const isRefreshed = ref(false)
// 路由
const route = useRoute()
// PWA模式检测
const { appMode } = usePWA()
// 新增对话框 // 新增对话框
const addDialog = ref(false) const addDialog = ref(false)
@@ -54,14 +46,6 @@ function addDone() {
fetchData() fetchData()
} }
// 使用动态按钮钩子 新增
useDynamicButton({
icon: 'mdi-plus',
onClick: () => {
addDialog.value = true
},
})
onMounted(() => { onMounted(() => {
loadEventTypes() loadEventTypes()
fetchData() fetchData()
@@ -70,6 +54,14 @@ onMounted(() => {
onActivated(() => { onActivated(() => {
fetchData() fetchData()
}) })
function openAddDialog() {
addDialog.value = true
}
defineExpose({
openAddDialog,
})
</script> </script>
<template> <template>
<div> <div>
@@ -83,20 +75,6 @@ onActivated(() => {
:error-title="t('workflow.noWorkflow')" :error-title="t('workflow.noWorkflow')"
:error-description="t('workflow.noWorkflowDescription')" :error-description="t('workflow.noWorkflowDescription')"
/> />
<!-- 新增按钮 -->
<Teleport to="body" v-if="route.path === '/workflow'">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-plus"
location="bottom"
size="x-large"
fixed
app
appear
:class="{ 'mb-12': appMode }"
@click="addDialog = true"
/>
</Teleport>
<!-- 新增对话框 --> <!-- 新增对话框 -->
<WorkflowAddEditDialog v-if="addDialog" v-model="addDialog" @close="addDialog = false" @save="addDone" /> <WorkflowAddEditDialog v-if="addDialog" v-model="addDialog" @close="addDialog = false" @save="addDone" />
</div> </div>
+13 -1
View File
@@ -30,6 +30,7 @@ const page = ref(1)
// 搜索关键字 // 搜索关键字
const keyword = ref(props.keyword) const keyword = ref(props.keyword)
const currentKey = ref(0)
// 是否加载中 // 是否加载中
const loading = ref(false) const loading = ref(false)
@@ -53,6 +54,17 @@ async function loadEventTypes() {
} }
} }
watch(
() => props.keyword,
newKeyword => {
keyword.value = newKeyword || ''
page.value = 1
dataList.value = []
isRefreshed.value = false
currentKey.value++
},
)
// 拼装参数 // 拼装参数
function getParams() { function getParams() {
let params = { let params = {
@@ -141,7 +153,7 @@ onActivated(() => {
<template> <template>
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}${keyword}`" /> <VPageContentTitle v-if="keyword" :title="`${t('common.search')}${keyword}`" />
<LoadingBanner v-if="!isRefreshed" class="mt-12" /> <LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData"> <VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData" :key="currentKey">
<template #loading /> <template #loading />
<template #empty /> <template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-workflow-share-card" tabindex="0"> <div v-if="dataList.length > 0" class="grid gap-4 grid-workflow-share-card" tabindex="0">