mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-27 03:09:45 +08:00
添加插件快速访问功能,支持下拉手势触发
This commit is contained in:
591
src/components/misc/PluginQuickAccess.vue
Normal file
591
src/components/misc/PluginQuickAccess.vue
Normal file
@@ -0,0 +1,591 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRecentPlugins } from '@/composables/useRecentPlugins'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
|
||||
// 最近访问插件管理
|
||||
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 swipeStartY = ref(0)
|
||||
const isDraggingToClose = ref(false)
|
||||
const dragOffset = ref(0)
|
||||
|
||||
// 计算显示状态
|
||||
const isVisible = computed(() => {
|
||||
return props.visible // 只基于visible属性显示,不考虑pullDistance
|
||||
})
|
||||
|
||||
// 计算整个组件的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(() => {
|
||||
let baseOpacity = props.visible ? 1 : 0 // 只基于visible属性决定透明度
|
||||
|
||||
// 如果正在拖动关闭,根据拖动距离调整透明度
|
||||
if (isDraggingToClose.value) {
|
||||
const dragProgress = Math.min(dragOffset.value / 200, 1)
|
||||
return baseOpacity * (1 - dragProgress * 0.3)
|
||||
}
|
||||
|
||||
return baseOpacity
|
||||
})
|
||||
|
||||
// 计算插件图标路径
|
||||
function getPluginIcon(plugin: Plugin): string {
|
||||
if (!plugin.plugin_icon) return noImage
|
||||
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(plugin?.plugin_icon)}`
|
||||
|
||||
return `${import.meta.env.VITE_API_BASE_URL}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 && plugin.state)
|
||||
.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)
|
||||
// 跳转到插件页面并自动打开详情
|
||||
router.push({
|
||||
path: '/plugins',
|
||||
query: {
|
||||
tab: 'installed',
|
||||
id: plugin.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭面板
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 监听可见性变化,加载数据
|
||||
watch(
|
||||
() => isVisible.value,
|
||||
visible => {
|
||||
if (visible) {
|
||||
if (pluginsWithPage.value.length === 0) {
|
||||
fetchPluginsWithPage()
|
||||
}
|
||||
loadRecentPlugins()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (isVisible.value) {
|
||||
fetchPluginsWithPage()
|
||||
loadRecentPlugins()
|
||||
}
|
||||
})
|
||||
|
||||
// 点击底部空白区域关闭
|
||||
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>
|
||||
<div
|
||||
class="plugin-quick-access"
|
||||
:class="{ 'visible': isVisible }"
|
||||
:style="{
|
||||
opacity: componentOpacity,
|
||||
transform: componentTransform,
|
||||
transition: isDraggingToClose ? 'none' : 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}"
|
||||
@click="handleBackdropClick"
|
||||
>
|
||||
<!-- 顶部指示器 -->
|
||||
<div class="top-indicator">
|
||||
<div class="indicator-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- 标题栏 -->
|
||||
<div class="header">
|
||||
<div class="header-title">{{ t('plugin.quickAccess') }}</div>
|
||||
<VBtn icon variant="text" size="small" @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)"
|
||||
>
|
||||
<div class="plugin-icon">
|
||||
<VAvatar size="48" class="plugin-avatar">
|
||||
<VImg :src="getPluginIcon(plugin)" :alt="plugin.plugin_name" cover>
|
||||
<template #error>
|
||||
<VIcon icon="mdi-puzzle" size="24" />
|
||||
</template>
|
||||
</VImg>
|
||||
</VAvatar>
|
||||
<!-- 运行状态指示 -->
|
||||
<div class="status-dot" :class="{ 'active': plugin.state }"></div>
|
||||
</div>
|
||||
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 没有最近访问时显示"无" -->
|
||||
<div v-else class="no-recent-plugins">
|
||||
<div class="no-recent-text">{{ t('plugin.noRecentPlugins') }}</div>
|
||||
</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-grid">
|
||||
<div
|
||||
v-for="plugin in pluginsWithPage"
|
||||
:key="plugin.id"
|
||||
class="plugin-item"
|
||||
@click="handlePluginClick(plugin)"
|
||||
>
|
||||
<div class="plugin-icon">
|
||||
<VAvatar size="48" class="plugin-avatar">
|
||||
<VImg :src="getPluginIcon(plugin)" :alt="plugin.plugin_name" cover>
|
||||
<template #error>
|
||||
<VIcon icon="mdi-puzzle" size="24" />
|
||||
</template>
|
||||
</VImg>
|
||||
</VAvatar>
|
||||
<!-- 运行状态指示 -->
|
||||
<div class="status-dot" :class="{ 'active': plugin.state }"></div>
|
||||
</div>
|
||||
<div class="plugin-name">{{ plugin.plugin_name }}</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="footer-hint">
|
||||
<div class="hint-text">{{ t('plugin.tapToOpen') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-quick-access {
|
||||
position: fixed;
|
||||
z-index: 9999; /* 提高z-index确保在最上层 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(20px);
|
||||
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;
|
||||
|
||||
.indicator-bar {
|
||||
border-radius: 2px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.12);
|
||||
block-size: 4px;
|
||||
inline-size: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.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 20px;
|
||||
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;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
/* 优化滚动体验 */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-y: auto;
|
||||
padding-block: 24px;
|
||||
padding-inline: 20px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 12px;
|
||||
margin-block: 0 16px;
|
||||
margin-inline: 0;
|
||||
|
||||
&.with-margin {
|
||||
margin-block-start: 24px;
|
||||
}
|
||||
|
||||
.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-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: flex;
|
||||
gap: 16px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: none;
|
||||
overflow-x: auto;
|
||||
padding-block: 0 8px;
|
||||
padding-inline: 0;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
/* 隐藏滚动条但保持功能 */
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.all-plugins-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
}
|
||||
|
||||
.plugin-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
padding-block: 12px;
|
||||
padding-inline: 8px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.recent-plugins-row .plugin-item {
|
||||
flex-shrink: 0;
|
||||
min-inline-size: 80px;
|
||||
}
|
||||
|
||||
.plugin-icon {
|
||||
position: relative;
|
||||
|
||||
.plugin-avatar {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 10%);
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
.plugin-item:hover & {
|
||||
box-shadow: 0 4px 12px 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-name {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
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);
|
||||
|
||||
.plugin-icon .plugin-avatar {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
113
src/composables/useRecentPlugins.ts
Normal file
113
src/composables/useRecentPlugins.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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<NavMenu[]>([])
|
||||
// 系统菜单项
|
||||
const systemMenus = ref<NavMenu[]>([])
|
||||
|
||||
// 插件快速访问相关状态
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 👉 Pull Down Indicator -->
|
||||
<div
|
||||
v-if="appMode && showPullIndicator"
|
||||
class="pull-indicator"
|
||||
:style="{
|
||||
opacity: indicatorOpacity,
|
||||
transform: `translate(-50%, ${Math.min(pullDistance * 0.5, 50)}px)`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="indicator-icon"
|
||||
:style="{
|
||||
transform: `rotate(${indicatorRotation}deg)`,
|
||||
}"
|
||||
>
|
||||
<VIcon icon="mdi-chevron-down" size="large" :color="pullDistance > 120 ? 'success' : 'primary'" />
|
||||
</div>
|
||||
<div class="indicator-text">
|
||||
{{ pullDistance > 120 ? t('plugin.releaseToOpen') : t('plugin.pullToOpen') }}
|
||||
</div>
|
||||
</div>
|
||||
<VerticalNavLayout>
|
||||
<!-- 👉 Navbar -->
|
||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||||
@@ -155,22 +326,87 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template #after-vertical-nav-items />
|
||||
<!-- 👉 Pages -->
|
||||
<slot />
|
||||
|
||||
<!-- 👉 Pages - 添加下拉跟随动画 -->
|
||||
<div
|
||||
class="main-content-wrapper"
|
||||
:style="{
|
||||
transform: contentTransform,
|
||||
transition: contentTransition,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 👉 Footer -->
|
||||
<template #footer>
|
||||
<Footer />
|
||||
</template>
|
||||
</VerticalNavLayout>
|
||||
|
||||
<!-- 👉 Plugin Quick Access -->
|
||||
<PluginQuickAccess
|
||||
v-if="appMode"
|
||||
:visible="showPluginQuickAccess"
|
||||
:pull-distance="pullDistance"
|
||||
@close="handleClosePluginQuickAccess"
|
||||
@plugin-click="handlePluginClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.meta-key {
|
||||
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
block-size: 1.5625rem;
|
||||
line-height: 1.3125rem;
|
||||
padding-block: 0.125rem;
|
||||
padding-inline: 0.25rem;
|
||||
/* 主内容包装器样式 */
|
||||
.main-content-wrapper {
|
||||
/* 在下拉状态下优化渲染性能 */
|
||||
backface-visibility: hidden;
|
||||
block-size: 100%;
|
||||
|
||||
/* 确保包装器不影响原有布局 */
|
||||
inline-size: 100%;
|
||||
|
||||
/* 使用GPU加速来优化动画性能 */
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 下拉指示器样式 */
|
||||
.pull-indicator {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
backdrop-filter: none;
|
||||
|
||||
/* 完全透明,无背景装饰 */
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
gap: 4px;
|
||||
inset-block-start: 64px; /* 紧贴导航栏下方 */
|
||||
inset-inline-start: 50%;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
block-size: 28px;
|
||||
inline-size: 28px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.indicator-text {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 30%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2157,6 +2157,14 @@ export default {
|
||||
cloneFailed: '插件分身创建失败:{message}',
|
||||
cloneFailedGeneral: '插件分身创建失败',
|
||||
logTitle: '插件日志',
|
||||
quickAccess: '快速访问',
|
||||
tapToOpen: '点击顶部可回到主界面',
|
||||
pullToOpen: '下拉打开快速访问',
|
||||
noPluginsWithPage: '暂无可用插件',
|
||||
recentlyUsed: '最近使用',
|
||||
allPlugins: '所有插件',
|
||||
releaseToOpen: '松手打开插件',
|
||||
noRecentPlugins: '无',
|
||||
},
|
||||
profile: {
|
||||
personalInfo: '个人信息',
|
||||
|
||||
@@ -2156,6 +2156,14 @@ export default {
|
||||
cloneFailed: '插件分身創建失敗:{message}',
|
||||
cloneFailedGeneral: '插件分身創建失敗',
|
||||
logTitle: '插件日誌',
|
||||
quickAccess: '快速訪問',
|
||||
noPluginsWithPage: '暫無可展示的插件',
|
||||
tapToOpen: '點擊返回主界面',
|
||||
pullToOpen: '下拉打開快速訪問',
|
||||
releaseToOpen: '松手打開快速訪問',
|
||||
recentlyUsed: '最近使用',
|
||||
allPlugins: '所有插件',
|
||||
noRecentPlugins: '無',
|
||||
},
|
||||
profile: {
|
||||
personalInfo: '個人信息',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,11 +7,13 @@ const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NoDataFound error-code="404" :error-title="t('notFound.title')" :error-description="t('notFound.description')">
|
||||
<template #button>
|
||||
<VBtn to="/" class="mt-10" prepend-icon="mdi-home">
|
||||
{{ t('notFound.backButton') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</NoDataFound>
|
||||
<div class="pt-10">
|
||||
<NoDataFound error-code="404" :error-title="t('notFound.title')" :error-description="t('notFound.description')">
|
||||
<template #button>
|
||||
<VBtn to="/" class="mt-10" prepend-icon="mdi-home">
|
||||
{{ t('notFound.backButton') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</NoDataFound>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user