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

@@ -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">