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

View File

@@ -5,6 +5,8 @@ import FileNavigator from './filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageIconDict } from '@/api/constants'
import type { AxiosInstance } from 'axios'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
// LocalStorage keys
const SORT_KEY = 'fileBrowser.sort'
@@ -33,6 +35,9 @@ const props = defineProps({
// 对外事件
const emit = defineEmits(['pathchanged'])
const route = useRoute()
const { appMode } = usePWA()
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
const fileIcons = {
// 压缩包
@@ -123,6 +128,18 @@ const fileIcons = {
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)
@@ -254,12 +271,14 @@ function stopDrag() {
<div class="mx-auto" :loading="loading > 0">
<div v-if="item">
<FileToolbar
ref="toolbarRef"
:sort="sort"
:item="item"
:itemstack="itemstack"
:storages="storagesArray"
:endpoints="endpoints"
:axios="axios"
:show-new-folder-button="!showFloatingNewFolderAction"
@storagechanged="storageChanged"
@pathchanged="pathChanged"
@foldercreated="refreshPending = true"
@@ -301,6 +320,18 @@ function stopDrag() {
</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>
<style scoped>

View File

@@ -30,6 +30,10 @@ const inProps = defineProps({
type: String,
default: 'name',
},
showNewFolderButton: {
type: Boolean,
default: true,
},
})
// 对外事件
@@ -109,11 +113,20 @@ async function mkdir() {
emit('foldercreated')
}
function openNewFolderDialog() {
newFolderName.value = ''
newFolderPopper.value = true
}
// 计算排序图标
const sortIcon = computed(() => {
if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline'
else return 'mdi-sort-alphabetical-ascending'
})
defineExpose({
openNewFolderDialog,
})
</script>
<template>
@@ -165,9 +178,9 @@ const sortIcon = computed(() => {
</IconBtn>
<!-- 新建文件夹 -->
<VDialog v-model="newFolderPopper" max-width="35rem">
<template #activator="{ props }">
<IconBtn>
<VIcon v-bind="props" icon="mdi-folder-plus-outline" />
<template v-if="showNewFolderButton" #activator="{ props }">
<IconBtn v-bind="props">
<VIcon icon="mdi-folder-plus-outline" />
</IconBtn>
</template>
<VCard>

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 {
interface Window {
__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: {
icon: string
onClick: () => void
icon: MaybeRefValue<string>
onClick?: () => void
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
show?: MaybeRefValue<boolean>
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)
@@ -36,22 +67,42 @@ export function useDynamicButton(options: {
// 按钮注册状态
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() {
// 避免重复注册
if (dynamicButtonRegistered.value) return
if (!componentActive.value) return
const button = buildDynamicButton()
if (!button.show) {
cleanupDynamicButton()
return
}
// 确保注册方法存在
if (!registerDynamicButton) {
// 尝试获取全局注册方法
const tryUseGlobalMethod = () => {
if (!componentActive.value) return false
if (typeof window !== 'undefined' && window.__VUE_INJECT_DYNAMIC_BUTTON__) {
window.__VUE_INJECT_DYNAMIC_BUTTON__({
icon,
action: onClick,
show: true,
})
window.__VUE_INJECT_DYNAMIC_BUTTON__(button)
dynamicButtonRegistered.value = true
return true
}
@@ -68,11 +119,9 @@ export function useDynamicButton(options: {
// 如果注册方法存在,直接注册
nextTick(() => {
registerDynamicButton({
icon,
action: onClick,
show: true,
})
if (!componentActive.value) return
registerDynamicButton(button)
dynamicButtonRegistered.value = true
})
}
@@ -82,17 +131,24 @@ export function useDynamicButton(options: {
if (unregisterDynamicButton && dynamicButtonRegistered.value) {
unregisterDynamicButton()
dynamicButtonRegistered.value = false
return
}
if (typeof window !== 'undefined' && window.__VUE_UNINJECT_DYNAMIC_BUTTON__) {
window.__VUE_UNINJECT_DYNAMIC_BUTTON__()
dynamicButtonRegistered.value = false
}
}
// 暴露方法:手动打开对话框
function openDialog() {
onClick()
onClick?.()
}
// 生命周期钩子
if (autoRegister) {
onMounted(() => {
componentActive.value = true
// 延迟执行确保Footer组件已加载
setTimeout(() => {
setupDynamicButton()
@@ -100,18 +156,27 @@ export function useDynamicButton(options: {
})
onActivated(() => {
componentActive.value = true
// 重置注册状态,确保每次激活时都重新注册
dynamicButtonRegistered.value = false
setupDynamicButton()
})
onDeactivated(() => {
componentActive.value = false
cleanupDynamicButton()
})
onUnmounted(() => {
componentActive.value = false
cleanupDynamicButton()
})
watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {
if (!componentActive.value) return
setupDynamicButton()
}, { deep: true })
}
// 返回控制函数和状态

View File

@@ -147,6 +147,7 @@ const unregisterDynamicButton = () => {
if (typeof window !== 'undefined') {
// 确保在浏览器环境中
;(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') {
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"
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>
<VMenu v-if="hasDynamicButtonMenu" activator="parent" location="top end" close-on-content-click>
<VList>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.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 { useI18n } from 'vue-i18n'
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'
// 国际化
const { t } = useI18n()
const { t, locale } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const { appMode } = usePWA()
const subType = route.meta.subType?.toString()
const subId = ref(route.query.id 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(() => {
@@ -48,12 +54,7 @@ const subscribeStatusFilter = ref<string | null>(null)
// 分享搜索词
const shareKeyword = ref('')
// 搜索分享
const searchShares = () => {
searchShareDialog.value = false
shareViewKey.value++
}
const shareKeywordInput = ref('')
// 筛选选项
const filterOptions = computed(() => {
@@ -103,7 +104,98 @@ function selectFilter(value: string) {
// VMenu activator选择器
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()
@@ -137,37 +229,16 @@ registerHeaderTab({
show: computed(() => activeTab.value === 'mysub'),
},
{
icon: 'mdi-chart-line',
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: 'gray',
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'statistics-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',
dataAttr: 'share-filter-btn',
action: () => {
searchShareDialog.value = true
},
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>
<div>
<SubscribeListView
ref="subscribeListViewRef"
:type="subType"
:subid="subId"
:keyword="subscribeFilter"
@@ -205,7 +277,7 @@ onMounted(() => {
<VWindowItem value="share">
<transition name="fade-slide" appear>
<div>
<SubscribeShareView :keyword="shareKeyword" :key="shareViewKey" />
<SubscribeShareView :keyword="shareKeyword" />
</div>
</transition>
</VWindowItem>
@@ -260,30 +332,65 @@ onMounted(() => {
<Teleport to="body" v-if="searchShareDialog">
<VMenu
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
:activator="searchActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
{{ t('subscribe.searchShares') }}
</VCardTitle>
<VDialogCloseBtn @click="searchShareDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable">
<template #append>
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
</template>
</VTextField>
</VCardText>
<VCard min-width="260" max-width="320">
<div class="px-3 pt-3 pb-1">
<VTextField
v-model="shareKeywordInput"
:placeholder="t('subscribe.keyword')"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
hide-details
clearable
/>
</div>
</VCard>
</VMenu>
</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
v-if="subscribeEditDialog"

View File

@@ -1,42 +1,66 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import WorkflowListView from '@/views/workflow/WorkflowListView.vue'
import WorkflowShareView from '@/views/workflow/WorkflowShareView.vue'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { getWorkflowTabs } from '@/router/i18n-menu'
// 国际化
const { t } = useI18n()
const route = useRoute()
const { appMode } = usePWA()
const activeTab = ref((route.query.tab as string) || 'list')
const shareViewKey = ref(0)
const listViewKey = ref(0)
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
// 获取标签页
const workflowTabs = computed(() => {
return getWorkflowTabs(t)
})
// 新增工作流对话框
const addWorkflowDialog = ref(false)
// 分享搜索词
const shareKeyword = ref('')
const shareKeywordInput = ref('')
// 搜索分享对话框
const searchShareDialog = ref(false)
// 搜索分享激活器
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
// 搜索分享
const searchShares = () => {
shareViewKey.value++
function openAddWorkflowDialog() {
workflowListViewRef.value?.openAddDialog()
}
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()
@@ -46,11 +70,11 @@ registerHeaderTab({
modelValue: activeTab,
appendButtons: [
{
icon: 'mdi-search',
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'search-btn',
dataAttr: 'share-filter-btn',
show: computed(() => activeTab.value === 'share'),
action: () => {
searchShareDialog.value = true
@@ -74,54 +98,54 @@ onMounted(() => {
<VWindowItem value="list">
<transition name="fade-slide" appear>
<div>
<WorkflowListView :key="listViewKey" />
<WorkflowListView ref="workflowListViewRef" :key="listViewKey" />
</div>
</transition>
</VWindowItem>
<VWindowItem value="share">
<transition name="fade-slide" appear>
<div>
<WorkflowShareView :keyword="shareKeyword" :key="shareViewKey" @update="listViewKey++" />
<WorkflowShareView :keyword="shareKeyword" @update="listViewKey++" />
</div>
</transition>
</VWindowItem>
</VWindow>
<!-- 新增工作流对话框 -->
<WorkflowAddEditDialog
v-if="addWorkflowDialog"
v-model="addWorkflowDialog"
@close="addWorkflowDialog = false"
@save="addWorkflowDialog = false"
/>
<!-- 搜索工作流分享弹窗 -->
<Teleport to="body" v-if="searchShareDialog">
<VMenu
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
:activator="searchActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
{{ t('workflow.searchShares') }}
</VCardTitle>
<VDialogCloseBtn @click="searchShareDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="shareKeyword" :label="t('workflow.searchShares')" clearable density="comfortable">
<template #append>
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
</template>
</VTextField>
</VCardText>
<VCard min-width="260" max-width="320">
<div class="px-3 pt-3 pb-1">
<VTextField
v-model="shareKeywordInput"
:placeholder="t('workflow.searchShares')"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
hide-details
clearable
/>
</div>
</VCard>
</VMenu>
</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>
</template>

View File

@@ -17,7 +17,7 @@ import { usePWA } from '@/composables/usePWA'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
// 国际化
const { t } = useI18n()
const { t, locale } = useI18n()
const route = useRoute()
@@ -79,26 +79,6 @@ registerHeaderTab({
},
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',
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({
icon: 'mdi-magnify',
onClick: () => {
SearchDialog.value = true
},
onClick: openPluginSearchDialog,
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'">
<div v-if="isRefreshed">
<div v-if="isRefreshed && !appMode && showSearchAction">
<VFab
v-if="!appMode"
icon="mdi-magnify"
color="info"
location="bottom"
@@ -1595,8 +1618,31 @@ function onDragStartPlugin(evt: any) {
fixed
app
appear
@click="SearchDialog = true"
:class="{ 'mb-12': appMode }"
@click="openPluginSearchDialog"
/>
<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>
</Teleport>

View File

@@ -12,70 +12,13 @@ import { useDisplay } from 'vuetify'
import { formatFileSize } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useGlobalSettingsStore } from '@/stores'
// i18n
const { t } = 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 { t, locale } = useI18n()
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
@@ -698,32 +641,55 @@ const toggleGroupSelection = (checked: boolean | null, items: readonly any[]) =>
}
}
// 监听选中项变化,更新动态按钮
watch(
() => selected.value.length,
newLength => {
if (appMode) {
registerHistoryDynamicButton(newLength > 0)
}
const historyDynamicIcon = computed(() => (selected.value.length > 0 ? 'mdi-chevron-up' : 'mdi-timer-sand-paused'))
const historyDynamicMenuItems = computed(() => {
locale.value
if (selected.value.length === 0) return undefined
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(() => {
loadStorages()
fetchData()
// 仅在 Docker 模式下注册动态按钮
if (appMode) {
registerHistoryDynamicButton(selected.value.length > 0)
}
})
onUnmounted(() => {
stopAiRedoProgress()
if (appMode) {
unregisterHistoryDynamicButton()
}
})
</script>
@@ -754,7 +720,6 @@ onUnmounted(() => {
</VCol>
<VCol cols="4" md="6" class="text-end">
<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" />
</VBtnGroup>
</VCol>
@@ -1013,10 +978,33 @@ onUnmounted(() => {
<!-- 整理队列进度弹窗 -->
<TransferQueueDialog v-if="transferQueueDialog" v-model="transferQueueDialog" @close="transferQueueDialog = false" />
<!-- Docker 模式下的 FAB 按钮 -->
<!-- app 模式下的 FAB 按钮 -->
<Teleport to="body" v-if="!appMode && route.path === '/history'">
<div v-if="isRefreshed && selected.length > 0">
<div v-if="isRefreshed">
<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"
color="error"
location="bottom"
@@ -1025,17 +1013,7 @@ onUnmounted(() => {
app
appear
@click="removeHistoryBatch"
class="mb-16"
/>
<VFab
class="mb-32"
icon="mdi-redo-variant"
location="bottom"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
</div>
</Teleport>

View File

@@ -6,21 +6,13 @@ import NoDataFound from '@/components/NoDataFound.vue'
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
import { useUserStore } from '@/stores'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
// 国际化
const { t } = useI18n()
// 路由
const route = useRoute()
// PWA模式检测
const { appMode } = usePWA()
// 用户 Store
const userStore = useUserStore()
@@ -185,6 +177,10 @@ function historyDone() {
fetchData()
}
function openHistoryDialog() {
historyDialog.value = true
}
// 批量管理相关函数
// 切换批量模式
function toggleBatchMode() {
@@ -381,12 +377,8 @@ onActivated(async () => {
}
})
// 使用动态按钮钩子
useDynamicButton({
icon: 'mdi-history',
onClick: () => {
historyDialog.value = true
},
defineExpose({
openHistoryDialog,
})
</script>
@@ -477,23 +469,6 @@ useDynamicButton({
:error-title="errorTitle"
: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
v-if="historyDialog"

View File

@@ -5,8 +5,6 @@ import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useDynamicButton } from '@/composables/useDynamicButton'
// 国际化
const { t } = useI18n()
@@ -14,12 +12,6 @@ const { t } = useI18n()
// 是否刷新
const isRefreshed = ref(false)
// 路由
const route = useRoute()
// PWA模式检测
const { appMode } = usePWA()
// 新增对话框
const addDialog = ref(false)
@@ -54,14 +46,6 @@ function addDone() {
fetchData()
}
// 使用动态按钮钩子 新增
useDynamicButton({
icon: 'mdi-plus',
onClick: () => {
addDialog.value = true
},
})
onMounted(() => {
loadEventTypes()
fetchData()
@@ -70,6 +54,14 @@ onMounted(() => {
onActivated(() => {
fetchData()
})
function openAddDialog() {
addDialog.value = true
}
defineExpose({
openAddDialog,
})
</script>
<template>
<div>
@@ -83,20 +75,6 @@ onActivated(() => {
:error-title="t('workflow.noWorkflow')"
: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" />
</div>

View File

@@ -30,6 +30,7 @@ const page = ref(1)
// 搜索关键字
const keyword = ref(props.keyword)
const currentKey = ref(0)
// 是否加载中
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() {
let params = {
@@ -141,7 +153,7 @@ onActivated(() => {
<template>
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}${keyword}`" />
<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 #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-workflow-share-card" tabindex="0">