refactor: standardize floating action buttons with a compact stack layout and migrate menu items to key-based i18n resolution

This commit is contained in:
jxxghp
2026-04-19 13:00:04 +08:00
parent e9b214cff8
commit 20bdb940cd
11 changed files with 276 additions and 151 deletions

View File

@@ -322,15 +322,15 @@ function stopDrag() {
</div>
<Teleport to="body" v-if="!appMode && showFloatingNewFolderAction">
<VFab
icon="mdi-folder-plus-outline"
location="bottom"
size="x-large"
fixed
app
appear
@click="openNewFolderDialog"
/>
<div class="compact-fab-stack">
<VFab
icon="mdi-folder-plus-outline"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openNewFolderDialog"
/>
</div>
</Teleport>
</template>

View File

@@ -23,8 +23,10 @@ declare global {
type MaybeRefValue<T> = T | Ref<T> | ComputedRef<T>
interface DynamicButtonMenuItem {
title: string
export interface DynamicButtonMenuItem {
title?: string
titleKey?: string
titleParams?: Record<string, unknown>
icon?: string
color?: string
action: () => void

View File

@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores'
import { filterMenusByPermission } from '@/utils/permission'
import { usePWA } from '@/composables/usePWA'
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
// 是否显示的输入参数
defineProps({
@@ -120,12 +121,7 @@ interface DynamicButton {
action: () => void
show: boolean
routePath?: string // 添加路径属性,用于标识哪个路由注册的
menuItems?: {
title: string
icon?: string
color?: string
action: () => void
}[]
menuItems?: DynamicButtonMenuItem[]
}
// 提供动态按钮注册和获取的方法
@@ -176,6 +172,28 @@ const showDynamicButton = computed(() => {
})
const hasDynamicButtonMenu = computed(() => Boolean(dynamicButton.value?.menuItems?.length))
const legacyDynamicMenuTitleKeyMap: Record<string, string> = {
'components.subscribeHistory.title': 'dialog.subscribeHistory.title',
'components.subscribeEdit.titleDefault': 'dialog.subscribeEdit.titleDefault',
'components.transferQueue.title': 'dialog.transferQueue.title',
'components.pluginMarketSetting.title': 'dialog.pluginMarketSetting.title',
}
function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
if (item.titleKey) {
return t(item.titleKey, item.titleParams as any)
}
if (!item.title) {
return ''
}
const normalizedTitleKey = legacyDynamicMenuTitleKeyMap[item.title] || item.title
const looksLikeI18nKey = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/i.test(normalizedTitleKey)
return looksLikeI18nKey ? t(normalizedTitleKey, item.titleParams as any) : item.title
}
</script>
<template>
@@ -253,14 +271,14 @@ const hasDynamicButtonMenu = computed(() => Boolean(dynamicButton.value?.menuIte
<VList>
<VListItem
v-for="(item, index) in dynamicButton?.menuItems"
:key="index"
:key="item.titleKey || item.title || index"
:base-color="item.color"
@click="item.action()"
>
<template #prepend>
<VIcon v-if="item.icon" :icon="item.icon" />
</template>
<VListItemTitle>{{ item.title }}</VListItemTitle>
<VListItemTitle>{{ resolveDynamicMenuItemTitle(item) }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>

View File

@@ -376,16 +376,15 @@ onDeactivated(() => {
<!-- 底部操作按钮只在非移动设备上显示 -->
<Teleport to="body" v-if="route.path === '/dashboard'">
<VFab
v-if="!appMode"
icon="mdi-view-dashboard-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="dialog = true"
/>
<div v-if="!appMode" class="compact-fab-stack">
<VFab
icon="mdi-view-dashboard-edit"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="dialog = true"
/>
</div>
</Teleport>
<!-- 弹窗根据配置生成选项 -->

View File

@@ -14,7 +14,7 @@ import { useUserStore } from '@/stores'
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
// 国际化
const { t, locale } = useI18n()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
@@ -141,23 +141,27 @@ onUnmounted(() => {
})
const subscribeDynamicMenuItems = computed(() => {
locale.value
if (!appMode.value) return undefined
if (activeTab.value === 'mysub') {
const items: Array<{ title: string; icon: string; action: () => void }> = []
const items: Array<{
titleKey: string
titleParams?: Record<string, unknown>
icon: string
action: () => void
}> = []
if (showSubscribeHistoryAction.value) {
items.push({
title: t('components.subscribeHistory.title', { type: subType }),
titleKey: 'dialog.subscribeHistory.title',
titleParams: { type: subType },
icon: 'mdi-history',
action: openSubscribeHistoryDialog,
})
}
items.push({
title: t('components.subscribeEdit.titleDefault'),
titleKey: 'dialog.subscribeEdit.titleDefault',
icon: 'mdi-clipboard-edit-outline',
action: openDefaultRuleDialog,
})
@@ -353,39 +357,30 @@ onMounted(() => {
</Teleport>
<Teleport to="body" v-if="!appMode && route.path.startsWith(`/subscribe/${subType === '电影' ? 'movie' : 'tv'}`)">
<div>
<div class="compact-fab-stack">
<VFab
v-if="showSubscribeHistoryAction"
icon="mdi-history"
color="info"
location="bottom"
size="x-large"
fixed
app
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@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 }"
class="compact-fab compact-fab--primary"
@click="openDefaultRuleDialog"
/>
<VFab
v-if="showShareStatisticsAction"
icon="mdi-chart-line"
color="info"
location="bottom"
size="x-large"
fixed
app
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openShareStatisticsDialog"
/>
</div>

View File

@@ -136,15 +136,15 @@ onMounted(() => {
</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"
/>
<div class="compact-fab-stack">
<VFab
icon="mdi-plus"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openAddWorkflowDialog"
/>
</div>
</Teleport>
</div>
</template>

View File

@@ -238,6 +238,122 @@ html.v-overlay-scroll-blocked body {
opacity:0.75;
}
// 紧凑型悬浮操作按钮
.compact-fab-stack {
position: fixed;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
inset-block-end: max(1rem, calc(env(safe-area-inset-bottom) + 1rem));
inset-inline-end: max(1rem, calc(env(safe-area-inset-right) + 1rem));
pointer-events: none;
}
.compact-fab-stack > * {
pointer-events: auto;
}
.compact-fab-stack--history {
inset-block-end: max(4.5rem, calc(env(safe-area-inset-bottom) + 4.5rem));
}
.compact-fab.v-fab {
display: inline-flex;
overflow: visible;
flex: none;
min-inline-size: 0 !important;
pointer-events: auto;
}
.compact-fab .v-fab__container {
position: static;
display: inline-flex;
overflow: visible;
margin: 0 !important;
}
.compact-fab .v-btn {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
backdrop-filter: blur(14px);
box-shadow:
0 16px 34px rgb(15 23 42 / 16%),
0 6px 16px rgb(15 23 42 / 10%);
opacity: 0.98;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
filter 0.18s ease,
opacity 0.18s ease;
}
.compact-fab--primary .v-btn {
block-size: 3.5rem !important;
box-shadow:
0 20px 40px rgb(15 23 42 / 20%),
0 8px 18px rgb(15 23 42 / 12%);
inline-size: 3.5rem !important;
}
.compact-fab--secondary .v-btn {
block-size: 3rem !important;
inline-size: 3rem !important;
}
.compact-fab--primary .v-icon {
font-size: 1.75rem !important;
}
.compact-fab--secondary .v-icon {
font-size: 1.5rem !important;
}
@media (hover: hover) {
.compact-fab .v-btn:hover {
box-shadow:
0 22px 42px rgb(15 23 42 / 22%),
0 8px 18px rgb(15 23 42 / 12%);
filter: saturate(1.03);
transform: translateY(-2px);
}
.compact-fab--primary .v-btn:hover {
box-shadow:
0 26px 46px rgb(15 23 42 / 24%),
0 10px 22px rgb(15 23 42 / 14%);
}
}
.compact-fab .v-btn:active {
box-shadow:
0 10px 22px rgb(15 23 42 / 16%),
0 3px 8px rgb(15 23 42 / 10%);
transform: translateY(0) scale(0.98);
}
@media (width <= 768px) {
.compact-fab-stack {
gap: 0.625rem;
inset-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.875rem));
inset-inline-end: max(0.875rem, calc(env(safe-area-inset-right) + 0.875rem));
}
.compact-fab-stack--history {
inset-block-end: max(4rem, calc(env(safe-area-inset-bottom) + 4rem));
}
.compact-fab--primary .v-btn {
block-size: 3.5rem !important;
inline-size: 3.5rem !important;
}
.compact-fab--secondary .v-btn {
block-size: 3rem !important;
inline-size: 3rem !important;
}
}
.apexcharts-title-text {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
}
@@ -311,7 +427,28 @@ html.v-overlay-scroll-blocked body {
.settings-icon-button {
flex-shrink: 0;
min-inline-size: auto;
border-radius: 0.95rem;
block-size: 2.75rem;
inline-size: 2.75rem;
margin-inline-start: 0.25rem;
min-inline-size: 2.75rem;
}
.settings-icon-button .v-icon {
font-size: 1.35rem;
}
@media (width <= 768px) {
.settings-icon-button {
border-radius: 0.825rem;
block-size: 2.5rem;
inline-size: 2.5rem;
min-inline-size: 2.5rem;
}
.settings-icon-button .v-icon {
font-size: 1.25rem;
}
}
.v-infinite-scroll__side {

View File

@@ -17,7 +17,7 @@ import { usePWA } from '@/composables/usePWA'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
// 国际化
const { t, locale } = useI18n()
const { t } = useI18n()
const route = useRoute()
@@ -908,14 +908,12 @@ const showNewFolderAction = computed(() => activeTab.value === 'installed' && !c
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'),
titleKey: 'plugin.searchPlugins',
icon: 'mdi-magnify',
action: openPluginSearchDialog,
},
@@ -923,7 +921,7 @@ const pluginDynamicMenuItems = computed(() => {
if (showNewFolderAction.value) {
items.push({
title: t('plugin.newFolder'),
titleKey: 'plugin.newFolder',
icon: 'mdi-folder-plus',
action: showNewFolderDialog,
})
@@ -931,7 +929,7 @@ const pluginDynamicMenuItems = computed(() => {
if (showMarketSettingAction.value) {
items.push({
title: t('components.pluginMarketSetting.title'),
titleKey: 'dialog.pluginMarketSetting.title',
icon: 'mdi-store-cog',
action: openMarketSettingDialog,
})
@@ -1609,41 +1607,32 @@ function onDragStartPlugin(evt: any) {
<!-- 插件搜索图标 -->
<Teleport to="body" v-if="route.path === '/plugins'">
<div v-if="isRefreshed && !appMode && showSearchAction">
<VFab
icon="mdi-magnify"
color="info"
location="bottom"
size="x-large"
fixed
app
appear
@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"
/>
<div v-if="isRefreshed && !appMode && showSearchAction" class="compact-fab-stack">
<VFab
v-if="showMarketSettingAction"
icon="mdi-store-cog"
color="warning"
location="bottom"
size="x-large"
fixed
app
variant="tonal"
appear
class="mb-16"
class="compact-fab compact-fab--secondary"
@click="openMarketSettingDialog"
/>
<VFab
v-if="showNewFolderAction"
icon="mdi-folder-plus"
color="success"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="showNewFolderDialog"
/>
<VFab
icon="mdi-magnify"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openPluginSearchDialog"
/>
</div>
</Teleport>
<!-- 插件市场设置窗口 -->

View File

@@ -18,7 +18,7 @@ import { useBackgroundOptimization } from '@/composables/useBackgroundOptimizati
import { useGlobalSettingsStore } from '@/stores'
// i18n
const { t, locale } = useI18n()
const { t } = useI18n()
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
@@ -643,27 +643,25 @@ const toggleGroupSelection = (checked: boolean | null, items: readonly any[]) =>
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'),
titleKey: 'dialog.transferQueue.title',
icon: 'mdi-timer-sand-paused',
action: () => {
transferQueueDialog.value = true
},
},
{
title: t('transferHistory.actions.batchRedo'),
titleKey: 'transferHistory.actions.batchRedo',
icon: 'mdi-redo-variant',
action: () => {
retransferBatch()
},
},
{
title: t('transferHistory.actions.batchDelete'),
titleKey: 'transferHistory.actions.batchDelete',
icon: 'mdi-trash-can-outline',
color: 'error',
action: () => {
@@ -980,40 +978,31 @@ onUnmounted(() => {
<!-- app 模式下的 FAB 按钮 -->
<Teleport to="body" v-if="!appMode && route.path === '/history'">
<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"
/>
<div v-if="isRefreshed" class="compact-fab-stack compact-fab-stack--history">
<VFab
v-if="selected.length > 0"
icon="mdi-trash-can-outline"
color="error"
location="bottom"
size="x-large"
fixed
app
color="warning"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="removeHistoryBatch"
class="mb-32"
/>
<VFab
v-if="selected.length > 0"
icon="mdi-redo-variant"
color="success"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="retransferBatch"
/>
<VFab
icon="mdi-timer-sand-paused"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="transferQueueDialog = true"
/>
</div>
</Teleport>

View File

@@ -393,17 +393,15 @@ useDynamicButton({
/>
<!-- 新增站点按钮 -->
<Teleport to="body" v-if="route.path === '/site'">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-web-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="siteAddDialog = true"
:class="{ 'mb-12': appMode }"
/>
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
<VFab
icon="mdi-web-plus"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="siteAddDialog = true"
/>
</div>
</Teleport>
<!-- 新增站点弹窗 -->
<SiteAddEditDialog

View File

@@ -99,17 +99,15 @@ useDynamicButton({
<!-- 新增用户按钮 -->
<Teleport to="body" v-if="route.path === '/user'">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-account-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="openAddUserDialog"
:class="{ 'mb-12': appMode }"
/>
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
<VFab
icon="mdi-account-plus"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openAddUserDialog"
/>
</div>
</Teleport>
<!-- 用户添加弹窗 -->