diff --git a/src/components/misc/PluginQuickAccess.vue b/src/components/misc/PluginQuickAccess.vue new file mode 100644 index 00000000..951138c3 --- /dev/null +++ b/src/components/misc/PluginQuickAccess.vue @@ -0,0 +1,591 @@ + + + + + + + + + + + + {{ t('plugin.quickAccess') }} + + + + + + + + + + + + + + {{ t('plugin.recentlyUsed') }} + + + + + + + + + + + + + + + + {{ plugin.plugin_name }} + + + + + + {{ t('plugin.noRecentPlugins') }} + + + + + {{ t('plugin.allPlugins') }} + + + + + + + + + + + + + + + + {{ plugin.plugin_name }} + + + + + + + {{ t('plugin.noPluginsWithPage') }} + + + + + + + + + + + + + diff --git a/src/composables/useRecentPlugins.ts b/src/composables/useRecentPlugins.ts new file mode 100644 index 00000000..072bd5f3 --- /dev/null +++ b/src/composables/useRecentPlugins.ts @@ -0,0 +1,113 @@ +import type { Plugin } from '@/api/types' + +const RECENT_PLUGINS_KEY = 'moviepilot_recent_plugins' +const MAX_RECENT_PLUGINS = 5 + +interface RecentPlugin { + id: string + plugin_name: string + plugin_icon?: string + has_page: boolean + state: boolean + plugin_id: string + access_time: number +} + +// 将Plugin转换为RecentPlugin +function pluginToRecentPlugin(plugin: Plugin): RecentPlugin { + return { + id: plugin.id || '', + plugin_name: plugin.plugin_name || '', + plugin_icon: plugin.plugin_icon, + has_page: plugin.has_page || false, + state: plugin.state || false, + plugin_id: plugin.plugin_id || '', + access_time: Date.now(), + } +} + +// 将RecentPlugin转换为Plugin +function recentPluginToPlugin(recentPlugin: RecentPlugin): Plugin { + return { + id: recentPlugin.id, + plugin_name: recentPlugin.plugin_name, + plugin_icon: recentPlugin.plugin_icon, + has_page: recentPlugin.has_page, + state: recentPlugin.state, + plugin_id: recentPlugin.plugin_id, + } as Plugin +} + +export function useRecentPlugins() { + // 获取最近访问的插件 + function getRecentPlugins(): Plugin[] { + try { + const stored = localStorage.getItem(RECENT_PLUGINS_KEY) + if (!stored) return [] + + const recentPlugins: RecentPlugin[] = JSON.parse(stored) + + // 按访问时间倒序排列 + return recentPlugins.sort((a, b) => b.access_time - a.access_time).map(recentPluginToPlugin) + } catch (error) { + console.error('获取最近访问插件失败:', error) + return [] + } + } + + // 添加插件到最近访问 + function addRecentPlugin(plugin: Plugin) { + try { + if (!plugin.id || !plugin.has_page) return + + const stored = localStorage.getItem(RECENT_PLUGINS_KEY) + let recentPlugins: RecentPlugin[] = stored ? JSON.parse(stored) : [] + + // 移除已存在的相同插件(如果有的话) + recentPlugins = recentPlugins.filter(p => p.id !== plugin.id) + + // 添加新的插件到开头 + recentPlugins.unshift(pluginToRecentPlugin(plugin)) + + // 限制最大数量 + if (recentPlugins.length > MAX_RECENT_PLUGINS) { + recentPlugins = recentPlugins.slice(0, MAX_RECENT_PLUGINS) + } + + localStorage.setItem(RECENT_PLUGINS_KEY, JSON.stringify(recentPlugins)) + } catch (error) { + console.error('保存最近访问插件失败:', error) + } + } + + // 清除所有最近访问记录 + function clearRecentPlugins() { + try { + localStorage.removeItem(RECENT_PLUGINS_KEY) + } catch (error) { + console.error('清除最近访问插件失败:', error) + } + } + + // 移除特定插件 + function removeRecentPlugin(pluginId: string) { + try { + const stored = localStorage.getItem(RECENT_PLUGINS_KEY) + if (!stored) return + + let recentPlugins: RecentPlugin[] = JSON.parse(stored) + recentPlugins = recentPlugins.filter(p => p.id !== pluginId) + + localStorage.setItem(RECENT_PLUGINS_KEY, JSON.stringify(recentPlugins)) + } catch (error) { + console.error('移除最近访问插件失败:', error) + } + } + + return { + getRecentPlugins, + addRecentPlugin, + clearRecentPlugins, + removeRecentPlugin, + } +} diff --git a/src/layouts/components/DefaultLayout.vue b/src/layouts/components/DefaultLayout.vue index 43d2c78d..c6c38571 100644 --- a/src/layouts/components/DefaultLayout.vue +++ b/src/layouts/components/DefaultLayout.vue @@ -7,6 +7,7 @@ import UserNofification from '@/layouts/components/UserNotification.vue' import SearchBar from '@/layouts/components/SearchBar.vue' import ShortcutBar from '@/layouts/components/ShortcutBar.vue' import UserProfile from '@/layouts/components/UserProfile.vue' +import PluginQuickAccess from '@/components/misc/PluginQuickAccess.vue' import { useUserStore } from '@/stores' import { getNavMenus } from '@/router/i18n-menu' import { NavMenu } from '@/@layouts/types' @@ -49,6 +50,46 @@ const organizeMenus = ref([]) // 系统菜单项 const systemMenus = ref([]) +// 插件快速访问相关状态 +const showPluginQuickAccess = ref(false) + +// 下拉检测相关状态 +const isPulling = ref(false) +const startY = ref(0) +const pullDistance = ref(0) + +// 计算页面内容的transform +const contentTransform = computed(() => { + if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)' + // 页面内容的移动距离是下拉距离的30%,提供更自然的阻尼感 + const moveDistance = pullDistance.value * 0.3 + return `translateY(${moveDistance}px)` +}) + +// 计算页面内容的transition +const contentTransition = computed(() => { + // 拖拽时不使用transition,松手后使用transition回弹 + return isPulling.value ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)' +}) + +// 计算下拉指示器的显示状态 +const showPullIndicator = computed(() => { + return isPulling.value && pullDistance.value > 20 +}) + +// 计算下拉指示器的旋转角度 +const indicatorRotation = computed(() => { + if (!isPulling.value) return 0 + const progress = Math.min(pullDistance.value / 120, 1) + return progress * 180 // 0到180度的旋转 +}) + +// 计算下拉指示器的透明度 +const indicatorOpacity = computed(() => { + if (!isPulling.value) return 0 + return Math.min(pullDistance.value / 60, 1) +}) + // 根据分类获取菜单列表 const getMenuList = (header: string) => { // 使用国际化菜单 @@ -74,6 +115,96 @@ function handleUnreadMessage(count: number) { } } +// 检查是否在页面顶部 +function isAtTop(): boolean { + return window.scrollY <= 5 +} + +// 处理触摸开始 +function handleTouchStart(event: TouchEvent) { + if (!appMode || !display.mdAndDown.value || !isAtTop()) return + + const touch = event.touches[0] + startY.value = touch.clientY + isPulling.value = false + pullDistance.value = 0 +} + +// 处理触摸移动 +function handleTouchMove(event: TouchEvent) { + if (!appMode || !display.mdAndDown.value || !isAtTop()) return + + const touch = event.touches[0] + const deltaY = touch.clientY - startY.value + + if (deltaY > 0 && isAtTop()) { + // 向下拖拽且在页面顶部 + isPulling.value = true + pullDistance.value = Math.min(deltaY * 0.6, 150) // 增加最大距离到150px + + // 阻止默认滚动 + event.preventDefault() + } +} + +// 处理触摸结束 +function handleTouchEnd() { + if (!appMode || !display.mdAndDown.value) return + + if (isPulling.value && pullDistance.value > 120) { + // 增加触发阈值到120px + // 触发插件快速访问 + showPluginQuickAccess.value = true + } + + // 先停止拖拽状态,触发回弹动画 + isPulling.value = false + + // 延迟重置其他状态,让动画完成 + setTimeout(() => { + pullDistance.value = 0 + startY.value = 0 + }, 300) // 与transition时间匹配 +} + +// 关闭插件快速访问 +function handleClosePluginQuickAccess() { + showPluginQuickAccess.value = false +} + +// 点击插件后关闭 +function handlePluginClick() { + showPluginQuickAccess.value = false +} + +// 阻止滚动的函数 +function preventScroll(e: TouchEvent) { + e.preventDefault() +} + +// 监听插件快速访问的显示状态,控制背景滚动 +watch(showPluginQuickAccess, visible => { + if (visible) { + // 显示时锁定背景滚动 - 使用更强的锁定方式 + document.body.style.overflow = 'hidden' + document.body.style.position = 'fixed' + document.body.style.width = '100%' + document.body.style.height = '100%' + document.documentElement.style.overflow = 'hidden' + // 禁用触摸滚动 + document.addEventListener('touchmove', preventScroll, { passive: false }) + } else { + // 隐藏时恢复滚动 + document.body.style.overflow = '' + document.body.style.position = '' + document.body.style.width = '' + document.body.style.height = '' + document.documentElement.style.overflow = '' + // 恢复触摸滚动 + document.removeEventListener('touchmove', preventScroll) + } +}) + onMounted(() => { // 获取菜单列表 startMenus.value = getMenuList(t('menu.start')) @@ -85,14 +216,54 @@ onMounted(() => { // 监听全局未读消息事件 const unsubscribe = onUnreadMessage(handleUnreadMessage) + // 只在appMode下添加触摸事件监听 + if (appMode && display.mdAndDown.value) { + document.addEventListener('touchstart', handleTouchStart, { passive: false }) + document.addEventListener('touchmove', handleTouchMove, { passive: false }) + document.addEventListener('touchend', handleTouchEnd, { passive: true }) + } + // 组件卸载时清理监听 onBeforeUnmount(() => { unsubscribe() + // 恢复body滚动样式 + document.body.style.overflow = '' + document.body.style.position = '' + document.body.style.width = '' + document.body.style.height = '' + document.documentElement.style.overflow = '' + document.removeEventListener('touchmove', preventScroll) + if (appMode && display.mdAndDown.value) { + document.removeEventListener('touchstart', handleTouchStart) + document.removeEventListener('touchmove', handleTouchMove) + document.removeEventListener('touchend', handleTouchEnd) + } }) }) + + + + + + + {{ pullDistance > 120 ? t('plugin.releaseToOpen') : t('plugin.pullToOpen') }} + + @@ -155,22 +326,87 @@ onMounted(() => { - - + + + + + + + + + diff --git a/src/layouts/components/HeaderTab.vue b/src/layouts/components/HeaderTab.vue index 0e72d6fd..a3bf715e 100644 --- a/src/layouts/components/HeaderTab.vue +++ b/src/layouts/components/HeaderTab.vue @@ -191,6 +191,7 @@ onUnmounted(() => { .header-tab-icon { color: rgba(var(--v-theme-on-background), 0.6); margin-inline-end: 6px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 10%); transition: color 0.2s ease; } @@ -206,6 +207,7 @@ onUnmounted(() => { font-weight: 600; padding-block: 6px; padding-inline: 14px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 10%); transition: all 0.2s ease; white-space: nowrap; @@ -224,6 +226,7 @@ onUnmounted(() => { &.active { color: rgb(var(--v-theme-primary)); + text-shadow: 0 1px 3px rgba(0, 0, 0, 15%); &::after { transform: translateX(-50%) scaleX(1); @@ -231,6 +234,7 @@ onUnmounted(() => { .header-tab-icon { color: rgb(var(--v-theme-primary)); + text-shadow: 0 1px 3px rgba(0, 0, 0, 15%); } } diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index f7902289..b2b493e3 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -2182,6 +2182,14 @@ export default { cloneFailed: 'Plugin clone creation failed: {message}', cloneFailedGeneral: 'Plugin clone creation failed', logTitle: 'Plugin Logging', + quickAccess: 'Quick Access', + noPluginsWithPage: 'No plugins with detail pages available', + tapToOpen: 'Tap to Return', + pullToOpen: 'Pull to Open Quick Access', + releaseToOpen: 'Release to Open Quick Access', + recentlyUsed: 'Recently Used', + allPlugins: 'All Plugins', + noRecentPlugins: 'None', }, profile: { personalInfo: 'Personal Information', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 2ed66c24..3165af2d 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -2157,6 +2157,14 @@ export default { cloneFailed: '插件分身创建失败:{message}', cloneFailedGeneral: '插件分身创建失败', logTitle: '插件日志', + quickAccess: '快速访问', + tapToOpen: '点击顶部可回到主界面', + pullToOpen: '下拉打开快速访问', + noPluginsWithPage: '暂无可用插件', + recentlyUsed: '最近使用', + allPlugins: '所有插件', + releaseToOpen: '松手打开插件', + noRecentPlugins: '无', }, profile: { personalInfo: '个人信息', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 7fc64452..c08fb71c 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -2156,6 +2156,14 @@ export default { cloneFailed: '插件分身創建失敗:{message}', cloneFailedGeneral: '插件分身創建失敗', logTitle: '插件日誌', + quickAccess: '快速訪問', + noPluginsWithPage: '暫無可展示的插件', + tapToOpen: '點擊返回主界面', + pullToOpen: '下拉打開快速訪問', + releaseToOpen: '松手打開快速訪問', + recentlyUsed: '最近使用', + allPlugins: '所有插件', + noRecentPlugins: '無', }, profile: { personalInfo: '個人信息', diff --git a/src/main.ts b/src/main.ts index 05018750..99c22b27 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,6 +41,7 @@ import MediaIdSelector from './components/misc/MediaIdSelector.vue' import CronField from './components/field/CronField.vue' import PathField from './components/field/PathField.vue' import HeaderTab from './layouts/components/HeaderTab.vue' +import PluginQuickAccess from './components/misc/PluginQuickAccess.vue' // 7. 样式文件 - 合并为单一导入 import '@/styles/main.scss' @@ -95,6 +96,7 @@ initializeApp().then(() => { .component('VPathField', PathField) .component('VHeaderTab', HeaderTab) .component('VPageContentTitle', PageContentTitle) + .component('VPluginQuickAccess', PluginQuickAccess) // 5. 注册其他插件 app diff --git a/src/pages/[...all].vue b/src/pages/[...all].vue index c608082f..984481db 100644 --- a/src/pages/[...all].vue +++ b/src/pages/[...all].vue @@ -7,11 +7,13 @@ const { t } = useI18n() - - - - {{ t('notFound.backButton') }} - - - + + + + + {{ t('notFound.backButton') }} + + + + diff --git a/src/styles/custom.scss b/src/styles/custom.scss index 77024f67..916617e1 100644 --- a/src/styles/custom.scss +++ b/src/styles/custom.scss @@ -8,6 +8,11 @@ html.v-overlay-scroll-blocked { position: fixed; } +/* 防止Chrome移动端下拉刷新干扰 */ +body { + overscroll-behavior: none; +} + @media (width <= 768px){ html.v-overlay-scroll-blocked { position: relative;