diff --git a/src/layouts/components/DefaultLayout.vue b/src/layouts/components/DefaultLayout.vue index f9117554..634ca58c 100644 --- a/src/layouts/components/DefaultLayout.vue +++ b/src/layouts/components/DefaultLayout.vue @@ -13,12 +13,14 @@ import { getNavMenus } from '@/router/i18n-menu' import { NavMenu } from '@/@layouts/types' import { useDisplay } from 'vuetify' import { useI18n } from 'vue-i18n' +import { useRoute } from 'vue-router' import { filterMenusByPermission } from '@/utils/permission' import { onUnreadMessage } from '@/utils/badge' const display = useDisplay() const appMode = inject('pwaMode') const { t } = useI18n() +const route = useRoute() // 用户 Store const userStore = useUserStore() @@ -53,17 +55,80 @@ const systemMenus = ref([]) // 插件快速访问相关状态 const showPluginQuickAccess = ref(false) +// 下拉手势配置常量 (iOS风格) +const PULL_CONFIG = { + START_THRESHOLD: 20, // 开始下拉的最小距离 + SHOW_INDICATOR: 60, // 显示指示器的距离 + TRIGGER_THRESHOLD: 100, // 触发快速访问的距离 + MAX_PULL_DISTANCE: 200, // 最大下拉距离 + PULL_RESISTANCE: 0.75, // 下拉阻力系数 + CONTENT_FOLLOW_RATIO: 0.4, // 页面内容跟随比例 + TOLERANCE: 80, // 手指抖动容忍度 +} + // 下拉检测相关状态 const isPulling = ref(false) const startY = ref(0) const pullDistance = ref(0) const initialScrollTop = ref(0) +// 检查是否有弹窗打开的函数 +const hasOpenDialog = () => { + try { + // 检查 Vuetify 的各种弹窗组件 + const vuetifyOverlays = document.querySelectorAll('.v-overlay--active:not(.v-overlay--scroll-blocked)') + const dialogs = document.querySelectorAll('.v-dialog--active') + const menus = document.querySelectorAll('.v-menu--active') + const bottomSheets = document.querySelectorAll('.v-bottom-sheet--active') + const snackbars = document.querySelectorAll('.v-snackbar--active') + + // 检查自定义弹窗元素 + const customDialogs = document.querySelectorAll('[role="dialog"]:not([style*="display: none"])') + const modalElements = document.querySelectorAll('.modal:not(.d-none):not([style*="display: none"])') + + // 检查具有弹窗特征的元素 + const dialogElements = document.querySelectorAll('[aria-modal="true"]:not([style*="display: none"])') + + // 计算有效的弹窗数量 + let totalDialogs = + vuetifyOverlays.length + + dialogs.length + + menus.length + + bottomSheets.length + + snackbars.length + + customDialogs.length + + modalElements.length + + dialogElements.length + + // 如果 QuickAccess 面板打开,不算作阻止下拉的弹窗 + if (showPluginQuickAccess.value) { + totalDialogs = Math.max(0, totalDialogs - 1) + } + + return totalDialogs > 0 + } catch (error) { + console.warn('检测弹窗状态时出错:', error) + // 出错时保守处理,认为有弹窗打开 + return true + } +} + +// 检查是否可以使用下拉手势 +const canUsePullGesture = computed(() => { + // 检查是否在dashboard页面 + const isDashboard = route.name === 'dashboard' || route.path === '/dashboard' + + // 检查是否是管理员 + const isAdmin = superUser.value + + return isDashboard && isAdmin +}) + // 计算页面内容的transform const contentTransform = computed(() => { if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)' - // 页面内容的移动距离是下拉距离的35%,与新的下拉距离相匹配 - const moveDistance = pullDistance.value * 0.35 + // 页面内容跟随下拉距离,使用配置的跟随比例 + const moveDistance = pullDistance.value * PULL_CONFIG.CONTENT_FOLLOW_RATIO return `translateY(${moveDistance}px)` }) @@ -75,20 +140,29 @@ const contentTransition = computed(() => { // 计算下拉指示器的显示状态 const showPullIndicator = computed(() => { - return isPulling.value && pullDistance.value > 30 + return canUsePullGesture.value && isPulling.value && pullDistance.value >= PULL_CONFIG.SHOW_INDICATOR }) // 计算下拉指示器的旋转角度 const indicatorRotation = computed(() => { if (!isPulling.value) return 0 - const progress = Math.min(pullDistance.value / 120, 1) + // 从显示指示器开始计算旋转,到触发阈值时旋转180度 + const progress = Math.min( + (pullDistance.value - PULL_CONFIG.SHOW_INDICATOR) / (PULL_CONFIG.TRIGGER_THRESHOLD - PULL_CONFIG.SHOW_INDICATOR), + 1, + ) return progress * 180 // 0到180度的旋转 }) // 计算下拉指示器的透明度 const indicatorOpacity = computed(() => { if (!isPulling.value) return 0 - return Math.min(pullDistance.value / 80, 1) + // 从显示指示器开始计算透明度 + const progress = Math.min( + (pullDistance.value - PULL_CONFIG.SHOW_INDICATOR) / (PULL_CONFIG.TRIGGER_THRESHOLD - PULL_CONFIG.SHOW_INDICATOR), + 1, + ) + return 0.7 + progress * 0.3 // 0.7到1.0的透明度 }) // 根据分类获取菜单列表 @@ -120,6 +194,12 @@ function handleUnreadMessage(count: number) { function handleTouchStart(event: TouchEvent) { if (!appMode || !display.mdAndDown.value) return + // 检查是否满足下拉手势的条件 + if (!canUsePullGesture.value) return + + // 实时检查是否有弹窗打开 + if (hasOpenDialog()) return + // 如果插件快速访问面板已显示,不处理下拉手势 if (showPluginQuickAccess.value) return @@ -138,6 +218,17 @@ function handleTouchStart(event: TouchEvent) { function handleTouchMove(event: TouchEvent) { if (!appMode || !display.mdAndDown.value) return + // 检查是否满足下拉手势的条件 + if (!canUsePullGesture.value) return + + // 实时检查是否有弹窗打开 + if (hasOpenDialog()) { + // 如果检测到弹窗打开,立即停止下拉 + isPulling.value = false + pullDistance.value = 0 + return + } + // 如果插件快速访问面板已显示,不处理下拉手势 if (showPluginQuickAccess.value) return @@ -147,27 +238,27 @@ function handleTouchMove(event: TouchEvent) { // 如果已经开始下拉,继续保持下拉状态,避免中途中断 if (isPulling.value) { // 继续下拉,但要确保是向下移动 - if (deltaY > -5) { - // 允许轻微的向上偏移(-5px),避免手指抖动导致中断 - pullDistance.value = Math.max(0, Math.min(deltaY * 0.7, 250)) + if (deltaY > -PULL_CONFIG.TOLERANCE) { + // 允许轻微的向上偏移,避免手指抖动导致中断 + pullDistance.value = Math.max(0, Math.min(deltaY * PULL_CONFIG.PULL_RESISTANCE, PULL_CONFIG.MAX_PULL_DISTANCE)) // 阻止默认滚动行为 event.preventDefault() } else { - // 如果向上移动超过阈值,停止下拉 + // 如果向上移动超过容忍度,停止下拉 isPulling.value = false pullDistance.value = 0 } } else { // 还没开始下拉,检查是否应该开始 - if (deltaY > 5) { + if (deltaY > PULL_CONFIG.START_THRESHOLD) { // 检查当前的滚动位置 const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0 - // 必须同时满足:1. 向下拖拽超过5px 2. 当前在页面顶部 3. 从顶部开始拖拽 + // 必须同时满足:1. 向下拖拽超过阈值 2. 当前在页面顶部 3. 从顶部开始拖拽 if (currentScrollTop <= 100 && initialScrollTop.value <= 100) { // 向下拖拽且在页面顶部附近,开始下拉 isPulling.value = true - pullDistance.value = Math.min(deltaY * 0.7, 250) + pullDistance.value = Math.min(deltaY * PULL_CONFIG.PULL_RESISTANCE, PULL_CONFIG.MAX_PULL_DISTANCE) // 阻止默认滚动 event.preventDefault() } @@ -179,12 +270,23 @@ function handleTouchMove(event: TouchEvent) { function handleTouchEnd() { if (!appMode || !display.mdAndDown.value) return + // 检查是否满足下拉手势的条件 + if (!canUsePullGesture.value) return + + // 实时检查是否有弹窗打开 + if (hasOpenDialog()) { + // 如果检测到弹窗打开,立即停止下拉并重置状态 + isPulling.value = false + pullDistance.value = 0 + startY.value = 0 + return + } + // 如果插件快速访问面板已显示,不处理下拉手势 if (showPluginQuickAccess.value) return - if (isPulling.value && pullDistance.value > 120) { - // 增加触发阈值到120px - // 触发插件快速访问 + if (isPulling.value && pullDistance.value >= PULL_CONFIG.TRIGGER_THRESHOLD) { + // 达到触发阈值,触发插件快速访问 showPluginQuickAccess.value = true } @@ -279,16 +381,22 @@ onMounted(() => { class="pull-indicator" :style="{ opacity: indicatorOpacity, - transform: `translate(-50%, ${Math.min(pullDistance * 0.3, 30)}px)`, + transform: `translate(-50%, ${Math.min((pullDistance - PULL_CONFIG.SHOW_INDICATOR) * 0.5, 40)}px)`, }" >
- +
diff --git a/src/layouts/components/QuickAccess.vue b/src/layouts/components/QuickAccess.vue index f68b0bca..d8fe703a 100644 --- a/src/layouts/components/QuickAccess.vue +++ b/src/layouts/components/QuickAccess.vue @@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n' import { useRecentPlugins } from '@/composables/useRecentPlugins' import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue' import { VCard } from 'vuetify/components' +import { getDominantColor } from '@/@core/utils/image' // 国际化 const { t } = useI18n() @@ -41,11 +42,27 @@ const recentPlugins = ref([]) const loading = ref(false) // 各插件的图标加载状态 -const pluginIconLoading = ref>({}) +const pluginIconLoadError = ref>({}) + +// 各插件的背景颜色 +const pluginBackgroundColors = ref>({}) + +// 上滑关闭配置常量 +const SWIPE_CONFIG = { + START_THRESHOLD: 10, // 开始检测上滑的最小距离 + CLOSE_THRESHOLD: 100, // 触发关闭的距离 + MAX_DRAG_DISTANCE: 1000, // 最大拖拽距离 + VELOCITY_THRESHOLD: 0.8, // 快速滑动速度阈值 (px/ms) +} // 上滑关闭相关状态 const isDraggingToClose = ref(false) const dragOffset = ref(0) +const startY = ref(0) +const lastY = ref(0) +const lastTime = ref(0) +const velocity = ref(0) +const startedFromBottomArea = ref(false) // 插件弹窗相关状态 const showPluginDataDialog = ref(false) @@ -58,7 +75,41 @@ const isVisible = computed(() => { // 处理插件图标加载错误 function handleIconError(plugin: Plugin) { - pluginIconLoading.value[plugin.id] = false + pluginIconLoadError.value[plugin.id] = true +} + +// 处理插件图标加载完成 +async function handleIconLoaded(src: string | undefined, plugin: Plugin) { + if (!src) return + + try { + // 创建一个临时的img元素来获取图片数据 + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = async () => { + try { + // 从图片中提取背景色 + const backgroundColor = await getDominantColor(img) + pluginBackgroundColors.value[plugin.id] = backgroundColor + } catch (error) { + // 如果提取失败,使用默认颜色 + pluginBackgroundColors.value[plugin.id] = '#28A9E1' + } + } + img.onerror = () => { + // 如果加载失败,使用默认颜色 + pluginBackgroundColors.value[plugin.id] = '#28A9E1' + } + img.src = src + } catch (error) { + // 如果提取失败,使用默认颜色 + pluginBackgroundColors.value[plugin.id] = '#28A9E1' + } +} + +// 获取插件背景颜色 +function getPluginBackgroundColor(plugin: Plugin): string { + return pluginBackgroundColors.value[plugin.id] || '#28A9E1' } // 计算整个组件的transform(包含拖动偏移) @@ -70,31 +121,23 @@ const componentTransform = computed(() => { baseTransform = 'translateY(-100%)' } - // 如果正在拖动关闭,添加拖动偏移 + // 如果正在拖动关闭,添加拖动偏移(向上拖拽为负值,让面板向上移动) if (isDraggingToClose.value) { - return `${baseTransform} translateY(${dragOffset.value}px)` + return `${baseTransform} translateY(-${dragOffset.value}px)` } return baseTransform }) -// 计算组件透明度(包含拖动透明度变化) +// 计算组件透明度 const componentOpacity = computed(() => { - let baseOpacity = props.visible ? 1 : 0 - - // 如果正在拖动关闭,根据拖动距离调整透明度 - if (isDraggingToClose.value) { - const dragProgress = Math.min(dragOffset.value / 200, 1) - return baseOpacity * (1 - dragProgress * 0.3) - } - - return baseOpacity + return props.visible ? 1 : 0 }) // 计算插件图标路径 function getPluginIcon(plugin: Plugin): string { if (!plugin.plugin_icon) return noImage - if (!pluginIconLoading.value[plugin.id]) return noImage + if (pluginIconLoadError.value[plugin.id]) return noImage // 如果是网络图片则使用代理后返回 if (plugin?.plugin_icon?.startsWith('http')) @@ -117,7 +160,7 @@ async function fetchPluginsWithPage() { // 只保留有详情页面且已启用的插件 pluginsWithPage.value = allPlugins - .filter(plugin => plugin.has_page && plugin.state) + .filter(plugin => plugin.has_page) .sort((a, b) => { // 按插件名称排序 return (a.plugin_name || '').localeCompare(b.plugin_name || '') @@ -179,6 +222,100 @@ onMounted(() => { } }) +// 处理触摸开始 +function handleTouchStart(event: TouchEvent) { + if (!props.visible) return + + const touch = event.touches[0] + if (!touch) return + + // 检查是否从 bottom-drag-area 开始触摸 + const target = event.target as HTMLElement + startedFromBottomArea.value = !!target.closest('.bottom-drag-area') + + startY.value = touch.clientY + lastY.value = touch.clientY + lastTime.value = Date.now() + velocity.value = 0 + + // 重置拖拽状态 + isDraggingToClose.value = false + dragOffset.value = 0 +} + +// 处理触摸移动 +function handleTouchMove(event: TouchEvent) { + if (!props.visible) return + + const touch = event.touches[0] + if (!touch) return + + // 只有从 bottom-drag-area 开始的触摸才处理上滑关闭 + if (!startedFromBottomArea.value) return + + const currentY = touch.clientY + const currentTime = Date.now() + const deltaY = startY.value - currentY // 向上为正值 + const timeDelta = currentTime - lastTime.value + + // 计算速度 + if (timeDelta > 0) { + const moveDistance = lastY.value - currentY + velocity.value = moveDistance / timeDelta + } + + // 如果已经开始拖拽,继续拖拽 + if (isDraggingToClose.value) { + if (deltaY >= 0) { + // 向上拖拽,更新偏移量 + dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE) + event.preventDefault() + } else { + // 向下拖拽,停止拖拽 + isDraggingToClose.value = false + dragOffset.value = 0 + } + } else { + // 还没开始拖拽,检查是否应该开始 + if (deltaY > SWIPE_CONFIG.START_THRESHOLD) { + isDraggingToClose.value = true + dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE) + event.preventDefault() + } + } + + lastY.value = currentY + lastTime.value = currentTime +} + +// 处理触摸结束 +function handleTouchEnd() { + if (!props.visible) return + + // 只有从 bottom-drag-area 开始的触摸才处理上滑关闭 + if (!startedFromBottomArea.value) return + + if (isDraggingToClose.value) { + // 判断是否应该关闭:距离超过阈值或者快速上滑 + const shouldClose = + dragOffset.value >= SWIPE_CONFIG.CLOSE_THRESHOLD || velocity.value >= SWIPE_CONFIG.VELOCITY_THRESHOLD + + if (shouldClose) { + emit('close') + } + + // 重置拖拽状态 + isDraggingToClose.value = false + dragOffset.value = 0 + } + + // 重置所有状态 + startY.value = 0 + lastY.value = 0 + velocity.value = 0 + startedFromBottomArea.value = false +} + // 点击底部空白区域关闭 function handleBackdropClick(event: MouseEvent) { const target = event.target as HTMLElement @@ -205,16 +342,17 @@ function handleBackdropClick(event: MouseEvent) { transition: isDraggingToClose ? 'none' : 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', }" @click="handleBackdropClick" + @touchstart="handleTouchStart" + @touchmove="handleTouchMove" + @touchend="handleTouchEnd" > -
-
-
+
{{ t('plugin.quickAccess') }}
- +
@@ -237,24 +375,30 @@ function handleBackdropClick(event: MouseEvent) { class="plugin-item" @click="handlePluginClick(plugin)" > -
- - - - - - -
-
+ +
+ +
+
{{ plugin.plugin_name }}
-
{{ t('plugin.noRecentPlugins') }}
+
@@ -269,17 +413,29 @@ function handleBackdropClick(event: MouseEvent) { class="plugin-item" @click="handlePluginClick(plugin)" > -
- - - - - - -
-
+ +
+ +
+
{{ plugin.plugin_name }}
@@ -294,9 +450,22 @@ function handleBackdropClick(event: MouseEvent) {
- - @@ -342,12 +511,22 @@ function handleBackdropClick(event: MouseEvent) { justify-content: center; padding-block: 12px 8px; padding-inline: 0; +} - .indicator-bar { +// 底部相关样式 +.bottom-indicator { + display: flex; + justify-content: center; + padding-block: 8px 12px; + padding-inline: 0; + + .indicator-bar.bottom { border-radius: 2px; background: rgba(var(--v-theme-on-surface), 0.12); block-size: 4px; - inline-size: 36px; + inline-size: 30vw; + transform-origin: center; + transition: all 0.2s ease; } } @@ -389,21 +568,6 @@ function handleBackdropClick(event: MouseEvent) { touch-action: pan-y; } -.loading-container { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - grid-column: 1 / -1; - padding-block: 40px; - padding-inline: 0; - - .loading-text { - color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); - font-size: 14px; - } -} - .section-header { display: flex; align-items: center; @@ -427,19 +591,13 @@ function handleBackdropClick(event: MouseEvent) { display: flex; align-items: center; justify-content: center; - padding-block: 16px; padding-inline: 0; - - .no-recent-text { - color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); - font-size: 14px; - } } .recent-plugins-row { display: grid; gap: 16px; - grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); padding-block: 0 8px; padding-inline: 0; } @@ -447,7 +605,7 @@ function handleBackdropClick(event: MouseEvent) { .all-plugins-grid { display: grid; gap: 20px; - grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); } .plugin-item { @@ -455,12 +613,11 @@ function handleBackdropClick(event: MouseEvent) { flex-direction: column; align-items: center; justify-content: center; + padding: 8px; border-radius: 12px; - block-size: 100px; + block-size: 120px; cursor: pointer; - gap: 6px; - padding-block: 8px; - padding-inline: 8px; + gap: 8px; transition: all 0.2s ease; &:hover { @@ -477,34 +634,18 @@ function handleBackdropClick(event: MouseEvent) { .plugin-icon { position: relative; display: flex; - flex-shrink: 0; /* 防止图标被压缩 */ + overflow: hidden; + flex-shrink: 0; align-items: center; justify-content: center; + padding: 4px; + border-radius: 16px; + block-size: 64px; + inline-size: 64px; + transition: all 0.2s ease; - .plugin-avatar { - box-shadow: 0 1px 3px rgba(0, 0, 0, 10%); - transition: box-shadow 0.2s ease; - - .plugin-item:hover & { - box-shadow: 0 2px 6px rgba(0, 0, 0, 15%); - } - } - - .status-dot { - position: absolute; - z-index: 1; - border: 2px solid rgba(var(--v-theme-surface), 1); - border-radius: 50%; - background: rgba(var(--v-theme-on-surface), 0.3); - block-size: 12px; - inline-size: 12px; - inset-block-start: -2px; - inset-inline-end: -2px; - transition: background-color 0.2s ease; - - &.active { - background: #4caf50; - } + .plugin-item:hover & { + transform: scale(1.02); } } @@ -547,40 +688,6 @@ function handleBackdropClick(event: MouseEvent) { padding-inline: 20px; } -.drag-handle { - display: flex; - justify-content: center; - inline-size: 100%; - padding-block: 12px; - padding-inline: 0; -} - -.drag-bar { - border-radius: 3px; - background: rgba(var(--v-theme-on-surface), 0.3); - block-size: 5px; - inline-size: 36px; - transition: all 0.2s ease; -} - -.bottom-drag-area:active .drag-bar { - background: rgba(var(--v-theme-on-surface), 0.5); - transform: scaleY(1.2); -} - -.footer-hint { - border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08); - inline-size: 100%; - padding-block: 16px; - padding-inline: 0; - - .hint-text { - color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); - font-size: 14px; - text-align: center; - } -} - @media (hover: none) and (pointer: coarse) { .plugin-item:hover { background: transparent; @@ -595,9 +702,5 @@ function handleBackdropClick(event: MouseEvent) { // 深色模式适配 html[data-theme='dark'] .plugin-quick-access { background: rgba(var(--v-theme-surface), 0.9); - - .plugin-icon .plugin-avatar { - border-color: rgba(var(--v-theme-on-surface), 0.2); - } }