Files
MoviePilot-Frontend/src/layouts/components/QuickAccess.vue
2025-09-12 20:57:29 +08:00

788 lines
20 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 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 showPluginDataDialog = ref(false)
const currentPlugin = ref<Plugin | null>(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)
// 设置当前插件并显示数据弹窗
currentPlugin.value = plugin
showPluginDataDialog.value = true
}
// 关闭面板
function handleClose() {
emit('close')
}
// 关闭插件数据弹窗
function handleClosePluginDataDialog() {
showPluginDataDialog.value = false
currentPlugin.value = null
}
// 管理滚动状态
function manageScrollLock() {
if (isVisible.value) {
// 使用 nextTick 确保 DOM 已经更新
nextTick(() => {
// 先恢复之前的锁定状态,避免重复锁定
const scrollableElement = document.querySelector('.all-plugins-grid')
if (scrollableElement) {
// 确保元素存在且可见
if ((scrollableElement as HTMLElement).offsetHeight > 0) {
disableBodyScroll(scrollableElement as HTMLElement)
}
}
})
} else {
// 恢复背景滚动
const scrollableElement = document.querySelector('.all-plugins-grid')
if (scrollableElement) {
enableBodyScroll(scrollableElement as HTMLElement)
}
}
}
// 监听可见性变化,加载数据
watch(
() => isVisible.value,
visible => {
if (visible) {
fetchPluginsWithPage()
loadRecentPlugins()
manageScrollLock()
} else {
manageScrollLock()
}
},
{ immediate: true },
)
onMounted(() => {
if (isVisible.value) {
fetchPluginsWithPage()
loadRecentPlugins()
manageScrollLock()
}
})
// 组件卸载时确保恢复背景滚动
onUnmounted(() => {
const scrollableElement = document.querySelector('.all-plugins-grid')
if (scrollableElement) {
enableBodyScroll(scrollableElement as HTMLElement)
}
})
// 处理触摸开始
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
// 检查当前触摸是否在插件网格内,如果是则不处理拖拽关闭
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)
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
// 点击根容器或底部提示区域时关闭
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
:ripple="false"
class="plugin-quick-access"
: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">
<!-- 加载状态 -->
<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"
@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">
<div
v-for="plugin in pluginsWithPage"
:key="plugin.id"
class="plugin-item"
@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">
<!-- 底部指示器 -->
<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>
<!-- 插件数据弹窗 -->
<PluginDataDialog
v-if="showPluginDataDialog && currentPlugin"
v-model="showPluginDataDialog"
:plugin="currentPlugin"
:show_switch="false"
@close="handleClosePluginDataDialog"
/>
</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;
border-radius: 12px;
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;
}
@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>