mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
优化快速访问组件
This commit is contained in:
@@ -13,12 +13,14 @@ import { getNavMenus } from '@/router/i18n-menu'
|
|||||||
import { NavMenu } from '@/@layouts/types'
|
import { NavMenu } from '@/@layouts/types'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { filterMenusByPermission } from '@/utils/permission'
|
import { filterMenusByPermission } from '@/utils/permission'
|
||||||
import { onUnreadMessage } from '@/utils/badge'
|
import { onUnreadMessage } from '@/utils/badge'
|
||||||
|
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
const appMode = inject('pwaMode')
|
const appMode = inject('pwaMode')
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
// 用户 Store
|
// 用户 Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -53,17 +55,80 @@ const systemMenus = ref<NavMenu[]>([])
|
|||||||
// 插件快速访问相关状态
|
// 插件快速访问相关状态
|
||||||
const showPluginQuickAccess = ref(false)
|
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 isPulling = ref(false)
|
||||||
const startY = ref(0)
|
const startY = ref(0)
|
||||||
const pullDistance = ref(0)
|
const pullDistance = ref(0)
|
||||||
const initialScrollTop = 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
|
// 计算页面内容的transform
|
||||||
const contentTransform = computed(() => {
|
const contentTransform = computed(() => {
|
||||||
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
|
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)`
|
return `translateY(${moveDistance}px)`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -75,20 +140,29 @@ const contentTransition = computed(() => {
|
|||||||
|
|
||||||
// 计算下拉指示器的显示状态
|
// 计算下拉指示器的显示状态
|
||||||
const showPullIndicator = computed(() => {
|
const showPullIndicator = computed(() => {
|
||||||
return isPulling.value && pullDistance.value > 30
|
return canUsePullGesture.value && isPulling.value && pullDistance.value >= PULL_CONFIG.SHOW_INDICATOR
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算下拉指示器的旋转角度
|
// 计算下拉指示器的旋转角度
|
||||||
const indicatorRotation = computed(() => {
|
const indicatorRotation = computed(() => {
|
||||||
if (!isPulling.value) return 0
|
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度的旋转
|
return progress * 180 // 0到180度的旋转
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算下拉指示器的透明度
|
// 计算下拉指示器的透明度
|
||||||
const indicatorOpacity = computed(() => {
|
const indicatorOpacity = computed(() => {
|
||||||
if (!isPulling.value) return 0
|
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) {
|
function handleTouchStart(event: TouchEvent) {
|
||||||
if (!appMode || !display.mdAndDown.value) return
|
if (!appMode || !display.mdAndDown.value) return
|
||||||
|
|
||||||
|
// 检查是否满足下拉手势的条件
|
||||||
|
if (!canUsePullGesture.value) return
|
||||||
|
|
||||||
|
// 实时检查是否有弹窗打开
|
||||||
|
if (hasOpenDialog()) return
|
||||||
|
|
||||||
// 如果插件快速访问面板已显示,不处理下拉手势
|
// 如果插件快速访问面板已显示,不处理下拉手势
|
||||||
if (showPluginQuickAccess.value) return
|
if (showPluginQuickAccess.value) return
|
||||||
|
|
||||||
@@ -138,6 +218,17 @@ function handleTouchStart(event: TouchEvent) {
|
|||||||
function handleTouchMove(event: TouchEvent) {
|
function handleTouchMove(event: TouchEvent) {
|
||||||
if (!appMode || !display.mdAndDown.value) return
|
if (!appMode || !display.mdAndDown.value) return
|
||||||
|
|
||||||
|
// 检查是否满足下拉手势的条件
|
||||||
|
if (!canUsePullGesture.value) return
|
||||||
|
|
||||||
|
// 实时检查是否有弹窗打开
|
||||||
|
if (hasOpenDialog()) {
|
||||||
|
// 如果检测到弹窗打开,立即停止下拉
|
||||||
|
isPulling.value = false
|
||||||
|
pullDistance.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 如果插件快速访问面板已显示,不处理下拉手势
|
// 如果插件快速访问面板已显示,不处理下拉手势
|
||||||
if (showPluginQuickAccess.value) return
|
if (showPluginQuickAccess.value) return
|
||||||
|
|
||||||
@@ -147,27 +238,27 @@ function handleTouchMove(event: TouchEvent) {
|
|||||||
// 如果已经开始下拉,继续保持下拉状态,避免中途中断
|
// 如果已经开始下拉,继续保持下拉状态,避免中途中断
|
||||||
if (isPulling.value) {
|
if (isPulling.value) {
|
||||||
// 继续下拉,但要确保是向下移动
|
// 继续下拉,但要确保是向下移动
|
||||||
if (deltaY > -5) {
|
if (deltaY > -PULL_CONFIG.TOLERANCE) {
|
||||||
// 允许轻微的向上偏移(-5px),避免手指抖动导致中断
|
// 允许轻微的向上偏移,避免手指抖动导致中断
|
||||||
pullDistance.value = Math.max(0, Math.min(deltaY * 0.7, 250))
|
pullDistance.value = Math.max(0, Math.min(deltaY * PULL_CONFIG.PULL_RESISTANCE, PULL_CONFIG.MAX_PULL_DISTANCE))
|
||||||
// 阻止默认滚动行为
|
// 阻止默认滚动行为
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
} else {
|
} else {
|
||||||
// 如果向上移动超过阈值,停止下拉
|
// 如果向上移动超过容忍度,停止下拉
|
||||||
isPulling.value = false
|
isPulling.value = false
|
||||||
pullDistance.value = 0
|
pullDistance.value = 0
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 还没开始下拉,检查是否应该开始
|
// 还没开始下拉,检查是否应该开始
|
||||||
if (deltaY > 5) {
|
if (deltaY > PULL_CONFIG.START_THRESHOLD) {
|
||||||
// 检查当前的滚动位置
|
// 检查当前的滚动位置
|
||||||
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
|
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
|
||||||
|
|
||||||
// 必须同时满足:1. 向下拖拽超过5px 2. 当前在页面顶部 3. 从顶部开始拖拽
|
// 必须同时满足:1. 向下拖拽超过阈值 2. 当前在页面顶部 3. 从顶部开始拖拽
|
||||||
if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
|
if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
|
||||||
// 向下拖拽且在页面顶部附近,开始下拉
|
// 向下拖拽且在页面顶部附近,开始下拉
|
||||||
isPulling.value = true
|
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()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
@@ -179,12 +270,23 @@ function handleTouchMove(event: TouchEvent) {
|
|||||||
function handleTouchEnd() {
|
function handleTouchEnd() {
|
||||||
if (!appMode || !display.mdAndDown.value) return
|
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 (showPluginQuickAccess.value) return
|
||||||
|
|
||||||
if (isPulling.value && pullDistance.value > 120) {
|
if (isPulling.value && pullDistance.value >= PULL_CONFIG.TRIGGER_THRESHOLD) {
|
||||||
// 增加触发阈值到120px
|
// 达到触发阈值,触发插件快速访问
|
||||||
// 触发插件快速访问
|
|
||||||
showPluginQuickAccess.value = true
|
showPluginQuickAccess.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,16 +381,22 @@ onMounted(() => {
|
|||||||
class="pull-indicator"
|
class="pull-indicator"
|
||||||
:style="{
|
:style="{
|
||||||
opacity: indicatorOpacity,
|
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)`,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="indicator-icon"
|
class="indicator-icon"
|
||||||
:style="{
|
:style="{
|
||||||
transform: `scale(${Math.min(1 + pullDistance / 400, 1.15)}) rotate(${indicatorRotation}deg)`,
|
transform: `scale(${
|
||||||
|
1 + Math.min((pullDistance - PULL_CONFIG.SHOW_INDICATOR) / PULL_CONFIG.MAX_PULL_DISTANCE, 0.5) * 0.3
|
||||||
|
}) rotate(${indicatorRotation}deg)`,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<VIcon icon="mdi-gesture-swipe-down" size="24" :color="pullDistance > 120 ? 'success' : 'primary'" />
|
<VIcon
|
||||||
|
icon="mdi-gesture-swipe-down"
|
||||||
|
size="24"
|
||||||
|
:color="pullDistance >= PULL_CONFIG.TRIGGER_THRESHOLD ? 'success' : 'primary'"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VerticalNavLayout>
|
<VerticalNavLayout>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useRecentPlugins } from '@/composables/useRecentPlugins'
|
import { useRecentPlugins } from '@/composables/useRecentPlugins'
|
||||||
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
|
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
|
||||||
import { VCard } from 'vuetify/components'
|
import { VCard } from 'vuetify/components'
|
||||||
|
import { getDominantColor } from '@/@core/utils/image'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -41,11 +42,27 @@ const recentPlugins = ref<Plugin[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// 各插件的图标加载状态
|
// 各插件的图标加载状态
|
||||||
const pluginIconLoading = ref<Record<string, boolean>>({})
|
const pluginIconLoadError = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
// 各插件的背景颜色
|
||||||
|
const pluginBackgroundColors = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
// 上滑关闭配置常量
|
||||||
|
const SWIPE_CONFIG = {
|
||||||
|
START_THRESHOLD: 10, // 开始检测上滑的最小距离
|
||||||
|
CLOSE_THRESHOLD: 100, // 触发关闭的距离
|
||||||
|
MAX_DRAG_DISTANCE: 1000, // 最大拖拽距离
|
||||||
|
VELOCITY_THRESHOLD: 0.8, // 快速滑动速度阈值 (px/ms)
|
||||||
|
}
|
||||||
|
|
||||||
// 上滑关闭相关状态
|
// 上滑关闭相关状态
|
||||||
const isDraggingToClose = ref(false)
|
const isDraggingToClose = ref(false)
|
||||||
const dragOffset = ref(0)
|
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)
|
const showPluginDataDialog = ref(false)
|
||||||
@@ -58,7 +75,41 @@ const isVisible = computed(() => {
|
|||||||
|
|
||||||
// 处理插件图标加载错误
|
// 处理插件图标加载错误
|
||||||
function handleIconError(plugin: Plugin) {
|
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(包含拖动偏移)
|
// 计算整个组件的transform(包含拖动偏移)
|
||||||
@@ -70,31 +121,23 @@ const componentTransform = computed(() => {
|
|||||||
baseTransform = 'translateY(-100%)'
|
baseTransform = 'translateY(-100%)'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果正在拖动关闭,添加拖动偏移
|
// 如果正在拖动关闭,添加拖动偏移(向上拖拽为负值,让面板向上移动)
|
||||||
if (isDraggingToClose.value) {
|
if (isDraggingToClose.value) {
|
||||||
return `${baseTransform} translateY(${dragOffset.value}px)`
|
return `${baseTransform} translateY(-${dragOffset.value}px)`
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseTransform
|
return baseTransform
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算组件透明度(包含拖动透明度变化)
|
// 计算组件透明度
|
||||||
const componentOpacity = computed(() => {
|
const componentOpacity = computed(() => {
|
||||||
let baseOpacity = props.visible ? 1 : 0
|
return props.visible ? 1 : 0
|
||||||
|
|
||||||
// 如果正在拖动关闭,根据拖动距离调整透明度
|
|
||||||
if (isDraggingToClose.value) {
|
|
||||||
const dragProgress = Math.min(dragOffset.value / 200, 1)
|
|
||||||
return baseOpacity * (1 - dragProgress * 0.3)
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseOpacity
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算插件图标路径
|
// 计算插件图标路径
|
||||||
function getPluginIcon(plugin: Plugin): string {
|
function getPluginIcon(plugin: Plugin): string {
|
||||||
if (!plugin.plugin_icon) return noImage
|
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'))
|
if (plugin?.plugin_icon?.startsWith('http'))
|
||||||
@@ -117,7 +160,7 @@ async function fetchPluginsWithPage() {
|
|||||||
|
|
||||||
// 只保留有详情页面且已启用的插件
|
// 只保留有详情页面且已启用的插件
|
||||||
pluginsWithPage.value = allPlugins
|
pluginsWithPage.value = allPlugins
|
||||||
.filter(plugin => plugin.has_page && plugin.state)
|
.filter(plugin => plugin.has_page)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// 按插件名称排序
|
// 按插件名称排序
|
||||||
return (a.plugin_name || '').localeCompare(b.plugin_name || '')
|
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) {
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement
|
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)',
|
transition: isDraggingToClose ? 'none' : 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
}"
|
}"
|
||||||
@click="handleBackdropClick"
|
@click="handleBackdropClick"
|
||||||
|
@touchstart="handleTouchStart"
|
||||||
|
@touchmove="handleTouchMove"
|
||||||
|
@touchend="handleTouchEnd"
|
||||||
>
|
>
|
||||||
<!-- 顶部指示器 -->
|
<!-- 顶部指示器 -->
|
||||||
<div class="top-indicator">
|
<div class="top-indicator"></div>
|
||||||
<div class="indicator-bar"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 标题栏 -->
|
<!-- 标题栏 -->
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="header-title">{{ t('plugin.quickAccess') }}</div>
|
<div class="header-title">{{ t('plugin.quickAccess') }}</div>
|
||||||
<VBtn icon variant="text" size="small" @click="handleClose" class="close-btn">
|
<VBtn icon variant="text" @click="handleClose" class="close-btn">
|
||||||
<VIcon icon="mdi-close" />
|
<VIcon icon="mdi-close" />
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,24 +375,30 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
class="plugin-item"
|
class="plugin-item"
|
||||||
@click="handlePluginClick(plugin)"
|
@click="handlePluginClick(plugin)"
|
||||||
>
|
>
|
||||||
<div class="plugin-icon">
|
<VBadge dot :color="plugin.state ? 'success' : 'secondary'" location="top end">
|
||||||
<VAvatar size="48" class="plugin-avatar">
|
<div
|
||||||
<VImg :src="getPluginIcon(plugin)" :alt="plugin.plugin_name" cover @error="handleIconError(plugin)">
|
class="plugin-icon"
|
||||||
<template #error>
|
:style="{
|
||||||
<VIcon icon="mdi-puzzle" size="24" />
|
background: `${getPluginBackgroundColor(plugin)}`,
|
||||||
</template>
|
}"
|
||||||
</VImg>
|
>
|
||||||
</VAvatar>
|
<VImg
|
||||||
<!-- 运行状态指示 -->
|
:src="getPluginIcon(plugin)"
|
||||||
<div class="status-dot" :class="{ 'active': plugin.state }"></div>
|
:alt="plugin.plugin_name"
|
||||||
</div>
|
cover
|
||||||
|
@error="handleIconError(plugin)"
|
||||||
|
@load="src => handleIconLoaded(src, plugin)"
|
||||||
|
class="rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VBadge>
|
||||||
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 没有最近访问时显示"无" -->
|
<!-- 没有最近访问时显示"无" -->
|
||||||
<div v-else class="no-recent-plugins">
|
<div v-else class="no-recent-plugins">
|
||||||
<div class="no-recent-text">{{ t('plugin.noRecentPlugins') }}</div>
|
<VIcon icon="mdi-puzzle-outline" size="24" color="grey" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 所有插件 -->
|
<!-- 所有插件 -->
|
||||||
@@ -269,17 +413,29 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
class="plugin-item"
|
class="plugin-item"
|
||||||
@click="handlePluginClick(plugin)"
|
@click="handlePluginClick(plugin)"
|
||||||
>
|
>
|
||||||
<div class="plugin-icon">
|
<VBadge
|
||||||
<VAvatar size="48" class="plugin-avatar">
|
dot
|
||||||
<VImg :src="getPluginIcon(plugin)" :alt="plugin.plugin_name" cover>
|
:color="plugin.state ? 'success' : 'secondary'"
|
||||||
<template #error>
|
location="top end"
|
||||||
<VIcon icon="mdi-puzzle" size="24" />
|
:offset-x="-1"
|
||||||
</template>
|
:offset-y="-1"
|
||||||
</VImg>
|
>
|
||||||
</VAvatar>
|
<div
|
||||||
<!-- 运行状态指示 -->
|
class="plugin-icon"
|
||||||
<div class="status-dot" :class="{ 'active': plugin.state }"></div>
|
:style="{
|
||||||
</div>
|
background: `${getPluginBackgroundColor(plugin)}`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<VImg
|
||||||
|
:src="getPluginIcon(plugin)"
|
||||||
|
:alt="plugin.plugin_name"
|
||||||
|
cover
|
||||||
|
@load="src => handleIconLoaded(src, plugin)"
|
||||||
|
@error="handleIconError(plugin)"
|
||||||
|
class="rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</VBadge>
|
||||||
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,9 +450,22 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
|
|
||||||
<!-- 底部拖动区域 -->
|
<!-- 底部拖动区域 -->
|
||||||
<div class="bottom-drag-area" @click="handleBackdropClick">
|
<div class="bottom-drag-area" @click="handleBackdropClick">
|
||||||
<!-- 底部提示 -->
|
<!-- 底部指示器 -->
|
||||||
<div class="footer-hint">
|
<div class="bottom-indicator">
|
||||||
<div class="hint-text">{{ t('plugin.tapToOpen') }}</div>
|
<div
|
||||||
|
class="indicator-bar bottom"
|
||||||
|
:class="{ 'dragging': isDraggingToClose }"
|
||||||
|
:style="{
|
||||||
|
transform: isDraggingToClose
|
||||||
|
? `scaleX(${Math.min(dragOffset / SWIPE_CONFIG.CLOSE_THRESHOLD, 1.5)})`
|
||||||
|
: 'scaleX(1)',
|
||||||
|
background: isDraggingToClose
|
||||||
|
? dragOffset >= SWIPE_CONFIG.CLOSE_THRESHOLD
|
||||||
|
? 'rgba(var(--v-theme-success), 0.8)'
|
||||||
|
: 'rgba(var(--v-theme-primary), 0.8)'
|
||||||
|
: 'rgba(var(--v-theme-on-surface), 0.12)',
|
||||||
|
}"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
@@ -342,12 +511,22 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-block: 12px 8px;
|
padding-block: 12px 8px;
|
||||||
padding-inline: 0;
|
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;
|
border-radius: 2px;
|
||||||
background: rgba(var(--v-theme-on-surface), 0.12);
|
background: rgba(var(--v-theme-on-surface), 0.12);
|
||||||
block-size: 4px;
|
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;
|
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 {
|
.section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -427,19 +591,13 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-block: 16px;
|
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
|
|
||||||
.no-recent-text {
|
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recent-plugins-row {
|
.recent-plugins-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
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-block: 0 8px;
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
@@ -447,7 +605,7 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
.all-plugins-grid {
|
.all-plugins-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-item {
|
.plugin-item {
|
||||||
@@ -455,12 +613,11 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
block-size: 100px;
|
block-size: 120px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
padding-block: 8px;
|
|
||||||
padding-inline: 8px;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -477,34 +634,18 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
.plugin-icon {
|
.plugin-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0; /* 防止图标被压缩 */
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 16px;
|
||||||
|
block-size: 64px;
|
||||||
|
inline-size: 64px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
.plugin-avatar {
|
.plugin-item:hover & {
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 10%);
|
transform: scale(1.02);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,40 +688,6 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
padding-inline: 20px;
|
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) {
|
@media (hover: none) and (pointer: coarse) {
|
||||||
.plugin-item:hover {
|
.plugin-item:hover {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -595,9 +702,5 @@ function handleBackdropClick(event: MouseEvent) {
|
|||||||
// 深色模式适配
|
// 深色模式适配
|
||||||
html[data-theme='dark'] .plugin-quick-access {
|
html[data-theme='dark'] .plugin-quick-access {
|
||||||
background: rgba(var(--v-theme-surface), 0.9);
|
background: rgba(var(--v-theme-surface), 0.9);
|
||||||
|
|
||||||
.plugin-icon .plugin-avatar {
|
|
||||||
border-color: rgba(var(--v-theme-on-surface), 0.2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user