Files
MoviePilot-Frontend/src/layouts/default/components/QuickAccess.vue
2026-06-23 20:16:39 +08:00

829 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import api from '@/api'
import type { Plugin } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
import { useRecentPlugins } from '@/composables/useRecentPlugins'
import { openSharedDialog } from '@/composables/useSharedDialog'
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
import { VCard } from 'vuetify/components'
import { getDominantColor } from '@/@core/utils/image'
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'
// 国际化
const { t } = useI18n()
// 最近访问插件管理
const { getRecentPlugins, addRecentPlugin } = useRecentPlugins()
// 输入参数
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
pullDistance: {
type: Number,
default: 0,
},
})
// 事件
const emit = defineEmits<{
(e: 'close'): void
(e: 'plugin-click', plugin: Plugin): void
}>()
// 有详情页面的插件列表
const pluginsWithPage = ref<Plugin[]>([])
// 最近访问的插件列表
const recentPlugins = ref<Plugin[]>([])
// 是否加载中
const loading = ref(false)
// 各插件的图标加载状态
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 dragOffset = ref(0)
const startY = ref(0)
const lastY = ref(0)
const lastTime = ref(0)
const velocity = ref(0)
const startedFromBottomArea = ref(false)
const quickAccessRef = ref<HTMLElement | { $el?: HTMLElement } | null>(null)
// Vuetify 组件 ref 在不同构建下可能返回组件实例,这里统一解析为真实 DOM 节点。
function getQuickAccessElement() {
const element = quickAccessRef.value
if (!element) return null
return element instanceof HTMLElement ? element : element.$el ?? null
}
// 计算显示状态
const isVisible = computed(() => {
return props.visible
})
// 处理插件图标加载错误
function handleIconError(plugin: Plugin) {
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包含拖动偏移
const componentTransform = computed(() => {
let baseTransform = ''
if (props.visible) {
baseTransform = 'translateY(0)'
} else {
baseTransform = 'translateY(-100%)'
}
// 如果正在拖动关闭,添加拖动偏移(向上拖拽为负值,让面板向上移动)
if (isDraggingToClose.value) {
return `${baseTransform} translateY(-${dragOffset.value}px)`
}
return baseTransform
})
// 计算组件透明度
const componentOpacity = computed(() => {
return props.visible ? 1 : 0
})
// 计算插件图标路径
function getPluginIcon(plugin: Plugin): string {
if (!plugin.plugin_icon) return getLogoUrl('plugin')
if (pluginIconLoadError.value[plugin.id]) return getLogoUrl('plugin')
// 如果是网络图片则使用代理后返回
if (plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
plugin?.plugin_icon,
)}&cache=true`
return `./plugin_icon/${plugin?.plugin_icon}`
}
// 获取有详情页面的插件
async function fetchPluginsWithPage() {
if (loading.value) return
try {
loading.value = true
const allPlugins: Plugin[] = await api.get('plugin/', {
params: {
state: 'installed',
},
})
// 只保留有详情页面且已启用的插件
pluginsWithPage.value = allPlugins
.filter(plugin => plugin.has_page)
.sort((a, b) => {
// 按插件名称排序
return (a.plugin_name || '').localeCompare(b.plugin_name || '')
})
} catch (error) {
console.error('获取插件列表失败:', error)
} finally {
loading.value = false
}
}
// 加载最近访问的插件
function loadRecentPlugins() {
recentPlugins.value = getRecentPlugins()
}
// 点击插件
function handlePluginClick(plugin: Plugin) {
// 添加到最近访问列表
addRecentPlugin(plugin)
// 更新最近访问列表显示
loadRecentPlugins()
emit('plugin-click', plugin)
openSharedDialog(
PluginDataDialog,
{
plugin,
show_switch: false,
},
{},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 关闭面板
function handleClose() {
emit('close')
}
// 管理滚动状态
function manageScrollLock() {
if (isVisible.value) {
// 使用 nextTick 确保 DOM 已经更新
nextTick(() => {
const panelElement = getQuickAccessElement()
if (!panelElement) return
// 锁定整层快捷入口,只有插件列表内部允许惯性滚动,避免底部手势漏给首页背景。
disableBodyScroll(panelElement, {
allowTouchMove: el => Boolean((el as HTMLElement).closest('.quick-access-scroll')),
})
if (typeof document !== 'undefined') {
document.documentElement.classList.add('quick-access-scroll-locked')
}
})
} else {
// 恢复背景滚动
const panelElement = getQuickAccessElement()
if (panelElement) {
enableBodyScroll(panelElement)
}
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('quick-access-scroll-locked')
}
}
}
// 监听可见性变化,加载数据
watch(
() => isVisible.value,
visible => {
if (visible) {
fetchPluginsWithPage()
loadRecentPlugins()
manageScrollLock()
} else {
manageScrollLock()
}
},
{ immediate: true },
)
onMounted(() => {
if (isVisible.value) {
fetchPluginsWithPage()
loadRecentPlugins()
manageScrollLock()
}
})
// 组件卸载时确保恢复背景滚动
onUnmounted(() => {
const panelElement = getQuickAccessElement()
if (panelElement) {
enableBodyScroll(panelElement)
}
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('quick-access-scroll-locked')
}
})
// 处理触摸开始
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')
// 如果触摸发生在插件网格内,不处理拖拽关闭
if (target.closest('.plugin-grid')) {
startedFromBottomArea.value = false
return
}
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
// 底部关闭手势从第一帧开始接管,防止 iOS 将早期位移传递给背景页面滚动。
event.preventDefault()
event.stopPropagation()
// 检查当前触摸是否在插件网格内,如果是则不处理拖拽关闭
const target = event.target as HTMLElement
if (target.closest('.plugin-grid')) {
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)
} 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)
}
}
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 handleBottomTouchStart(event: TouchEvent) {
if (!props.visible) return
event.stopPropagation()
handleTouchStart(event)
}
function handleBottomTouchMove(event: TouchEvent) {
if (!props.visible) return
handleTouchMove(event)
}
function handleBottomTouchEnd(event: TouchEvent) {
if (!props.visible) return
event.stopPropagation()
handleTouchEnd()
}
// 点击底部空白区域关闭
function handleBackdropClick(event: MouseEvent) {
const target = event.target as HTMLElement
// 点击根容器或底部提示区域时关闭
if (
target.classList.contains('plugin-quick-access') ||
target.classList.contains('footer-hint') ||
target.classList.contains('hint-text') ||
target.classList.contains('bottom-drag-area')
) {
emit('close')
}
}
</script>
<template>
<VCard
ref="quickAccessRef"
:ripple="false"
class="plugin-quick-access app-surface-square app-surface-flat app-surface-borderless"
:class="{ 'visible': isVisible }"
:style="{
opacity: componentOpacity,
transform: componentTransform,
transition: isDraggingToClose ? 'none' : 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
}"
@click="handleBackdropClick"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- 顶部指示器 -->
<div class="top-indicator"></div>
<!-- 标题栏 -->
<div class="header">
<div class="header-title">{{ t('plugin.quickAccess') }}</div>
<VBtn icon variant="text" @click="handleClose" class="close-btn">
<VIcon icon="mdi-close" />
</VBtn>
</div>
<!-- 插件网格 -->
<div class="plugin-grid quick-access-scroll">
<!-- 加载状态 -->
<LoadingBanner v-if="loading" />
<!-- 最近访问 -->
<template v-else>
<div class="section-header">
<div class="section-title">{{ t('plugin.recentlyUsed') }}</div>
</div>
<div v-if="recentPlugins.length > 0" class="recent-plugins-row">
<div
v-for="plugin in recentPlugins"
:key="`recent-${plugin.id}`"
class="plugin-item app-surface-shape"
@click="handlePluginClick(plugin)"
>
<VBadge dot :color="plugin.state ? 'success' : 'secondary'" location="top end">
<div
class="plugin-icon"
:style="{
background: `${getPluginBackgroundColor(plugin)}`,
}"
>
<VImg
:src="getPluginIcon(plugin)"
:alt="plugin.plugin_name"
cover
@error="handleIconError(plugin)"
@load="src => handleIconLoaded(src, plugin)"
class="rounded-lg"
/>
</div>
</VBadge>
<div class="plugin-name">{{ plugin.plugin_name }}</div>
</div>
</div>
<!-- 没有最近访问时显示"无" -->
<div v-else class="no-recent-plugins">
<VIcon icon="mdi-puzzle-outline" size="24" color="grey" />
</div>
<!-- 所有插件 -->
<div v-if="pluginsWithPage.length > 0" class="section-header with-margin">
<div class="section-title">{{ t('plugin.allPlugins') }}</div>
</div>
<div v-if="pluginsWithPage.length > 0" class="all-plugins-container">
<div class="all-plugins-grid quick-access-scroll">
<div
v-for="plugin in pluginsWithPage"
:key="plugin.id"
class="plugin-item app-surface-shape"
@click="handlePluginClick(plugin)"
>
<VBadge
dot
:color="plugin.state ? 'success' : 'secondary'"
location="top end"
:offset-x="-1"
:offset-y="-1"
>
<div
class="plugin-icon"
:style="{
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>
</div>
</div>
<!-- 空状态只有在没有插件时显示 -->
<div v-else-if="pluginsWithPage.length === 0" class="empty-state">
<VIcon icon="mdi-puzzle-outline" size="48" color="grey" />
<div class="empty-text">{{ t('plugin.noPluginsWithPage') }}</div>
</div>
</template>
</div>
<!-- 底部拖动区域 -->
<div
class="bottom-drag-area"
@click="handleBackdropClick"
@touchstart.stop="handleBottomTouchStart"
@touchmove.prevent.stop="handleBottomTouchMove"
@touchend.stop="handleBottomTouchEnd"
@touchcancel.stop="handleBottomTouchEnd"
>
<!-- 底部指示器 -->
<div class="bottom-indicator">
<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>
</VCard>
</template>
<style lang="scss" scoped>
.plugin-quick-access {
position: fixed;
z-index: 9999;
display: flex;
overflow: hidden;
flex-direction: column;
backdrop-filter: blur(32px);
background: rgba(var(--v-theme-surface), 0.95);
block-size: 100vh;
block-size: 100dvh;
inset-block-start: 0;
inset-inline: 0;
opacity: 0;
padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
pointer-events: none;
transform: translateY(-100%);
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
&.visible {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
}
.top-indicator {
display: flex;
justify-content: center;
padding-block: 12px 8px;
padding-inline: 0;
}
// 底部相关样式
.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: 30vw;
transform-origin: center;
transition: all 0.2s ease;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 0 16px;
padding-inline: 20px;
.header-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 20px;
font-weight: 600;
}
.close-btn {
opacity: 0.6;
&:hover {
background: rgba(var(--v-theme-on-surface), 0.04);
opacity: 1;
}
}
}
.plugin-grid {
display: flex;
overflow: hidden auto;
flex: 1;
flex-direction: column;
gap: 16px;
max-block-size: calc(100vh - 200px); // 确保有最大高度限制
min-block-size: 0;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none; // IE/Edge
overscroll-behavior: contain;
padding-block: 24px;
padding-inline: 20px;
// 隐藏滚动条
scrollbar-width: none; // Firefox
touch-action: pan-y;
will-change: scroll-position;
&::-webkit-scrollbar {
display: none; // WebKit 浏览器
}
}
.section-header {
display: flex;
align-items: center;
gap: 12px;
margin-inline: 0;
.section-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 16px;
font-weight: 600;
white-space: nowrap;
}
}
.no-recent-plugins {
display: flex;
align-items: center;
justify-content: center;
padding-inline: 0;
}
.recent-plugins-row {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
padding-block: 0;
padding-inline: 0;
}
.all-plugins-container {
display: flex;
overflow: hidden;
flex: 1;
flex-direction: column;
min-block-size: 0;
}
.all-plugins-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
max-block-size: 100%;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none; // IE/Edge
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 8px;
padding-inline: 0;
// 隐藏滚动条
scrollbar-width: none; // Firefox
touch-action: pan-y;
will-change: scroll-position;
&::-webkit-scrollbar {
display: none; // WebKit 浏览器
}
}
.plugin-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
block-size: 120px;
cursor: pointer;
gap: 4px;
transition: all 0.2s ease;
&:hover {
background: rgba(var(--v-theme-on-surface), 0.04);
transform: translateY(-2px);
}
&:active {
background: rgba(var(--v-theme-on-surface), 0.08);
transform: translateY(0);
}
}
.plugin-icon {
position: relative;
display: flex;
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-item:hover & {
transform: scale(1.02);
}
}
.plugin-name {
display: -webkit-box;
overflow: hidden;
flex-shrink: 0;
-webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 12px;
font-weight: 500;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.2;
max-block-size: 2.4em;
text-align: center;
word-break: break-all;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
grid-column: 1 / -1;
padding-block: 40px;
padding-inline: 0;
.empty-text {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 14px;
}
}
.bottom-drag-area {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding-block: 8px 0;
padding-inline: 20px;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
@media (hover: none) and (pointer: coarse) {
.plugin-item:hover {
background: transparent;
transform: none;
}
.plugin-item:active {
background: rgba(var(--v-theme-on-surface), 0.08);
}
}
// 深色模式适配
html[data-theme='dark'] .plugin-quick-access {
background: rgba(var(--v-theme-surface), 0.9);
}
</style>