Compare commits

..

34 Commits

Author SHA1 Message Date
jxxghp
615c162663 插件图标使用缓存 2025-07-03 17:09:56 +08:00
jxxghp
c4bd15e5a0 fix storage save 2025-07-03 15:41:44 +08:00
jxxghp
edc92905f7 在MediaInfoCard组件中添加web_source信息的显示 2025-07-03 14:02:53 +08:00
jxxghp
bf5bbd3689 添加SMB网络共享支持 2025-07-03 12:43:42 +08:00
jxxghp
eb70ca233b 重构DefaultLayout.vue组件 2025-07-03 08:48:44 +08:00
jxxghp
8718816fce 将多个组件中的VFab按钮包裹在Teleport中,以确保在移动设备上正确显示 2025-07-03 07:18:31 +08:00
jxxghp
7d36330b4b 在PluginDataDialog组件中添加show_switch属性的绑定 2025-07-02 21:55:02 +08:00
jxxghp
1fa0474fef 调整DownloaderCard、MediaServerCard和StorageCard组件中图标的上边距 2025-07-02 21:49:42 +08:00
jxxghp
4070b27148 调整QuickAccess.vue组件的过渡时间为0.6秒 2025-07-02 21:39:41 +08:00
jxxghp
3892b0ed05 添加PluginDataDialog组件的show_switch属性 2025-07-02 21:30:44 +08:00
jxxghp
a06cf69d7a 优化QuickAccess.vue组件样式 2025-07-02 20:43:33 +08:00
jxxghp
61dc2568e8 优化快速访问组件 2025-07-02 20:28:58 +08:00
jxxghp
ac6362e698 更新 QuickAccess.vue 2025-07-02 17:55:19 +08:00
jxxghp
94afdf5495 更新样式和布局 2025-07-02 17:41:58 +08:00
jxxghp
d96f8acdbc 优化默认布局和快速访问组件 2025-07-02 17:12:14 +08:00
jxxghp
d39c795f92 更新快速访问组件的导入方式 2025-07-02 16:11:12 +08:00
jxxghp
8e12e0562b 更改快速访问组件的导入路径 2025-07-02 16:08:27 +08:00
jxxghp
7a1babb418 重构插件快速访问组件 2025-07-02 16:07:18 +08:00
jxxghp
8d65f0c2a8 优化快速访问插件的下拉手势逻辑 2025-07-02 15:59:11 +08:00
jxxghp
b8dff560f0 添加插件快速访问功能,支持下拉手势触发 2025-07-02 14:18:58 +08:00
jxxghp
b48c26ee73 调整日历视图的背景颜色 2025-07-02 12:31:30 +08:00
jxxghp
8328e51ae0 调整存储添加逻辑 2025-07-02 08:58:16 +08:00
jxxghp
7070eb8a7d 更改流媒体平台的源芯片背景颜色 2025-07-01 17:32:24 +08:00
jxxghp
d0aa26441c 单独显示流媒体平台 2025-07-01 17:14:03 +08:00
jxxghp
1bba7103c8 调整主题背景颜色为深灰色以提升视觉效果 2025-07-01 12:54:01 +08:00
jxxghp
7f8dd744f2 调整表格和输入框的背景颜色以适应透明主题 2025-07-01 12:39:44 +08:00
jxxghp
2f4a707498 为筛选菜单添加内边距样式 2025-07-01 11:58:57 +08:00
jxxghp
569bc3c8ec 站点添加筛选功能 2025-07-01 11:38:00 +08:00
jxxghp
b01421aa94 优化组件加载逻辑 2025-06-30 20:38:50 +08:00
jxxghp
30d933bd85 更新 package.json 2025-06-30 20:16:14 +08:00
jxxghp
377998335b 简化导航状态管理 2025-06-30 20:14:31 +08:00
jxxghp
21d21aa438 优化图片加载逻辑,添加导航状态管理 2025-06-30 19:55:27 +08:00
jxxghp
18cf1ea3d7 更新 FileList.vue、FileNavigator.vue 和 FileToolbar.vue 中 axios 属性的类型定义为 Function 2025-06-30 19:39:02 +08:00
jxxghp
60ea884fe2 添加全局请求和图片优化器 2025-06-30 17:37:30 +08:00
50 changed files with 2496 additions and 487 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.5.9-1",
"version": "2.6.0",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -41,26 +41,33 @@ declare global {
}
}
if (window.Apex) {
// 数据标签
window.Apex.dataLabels = {
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
// 如果有小数点,保留两位小数,否则保留整
const data = w.config.series[seriesIndex]
return data.toFixed(data % 1 === 0 ? 0 : 1)
},
}
// 图例
window.Apex.legend = {
labels: {
useSeriesColors: true,
},
}
// 标题
window.Apex.title = {
style: {
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
},
// 配置 ApexCharts 全局选项
function configureApexCharts() {
if (typeof window !== 'undefined' && window.Apex) {
try {
// 数据标签
window.Apex.dataLabels = {
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
// 如果有小数点,保留两位小数,否则保留整数
const data = w.config.series[seriesIndex]
return data.toFixed(data % 1 === 0 ? 0 : 1)
},
}
// 图例
window.Apex.legend = {
labels: {
useSeriesColors: true,
},
}
// 标题
window.Apex.title = {
style: {
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
},
}
} catch (error) {
console.warn('ApexCharts 全局配置失败:', error)
}
}
}
@@ -74,10 +81,13 @@ function updateHtmlThemeAttribute(themeName: string) {
// 获取背景图片
async function fetchBackgroundImages() {
try {
backgroundImages.value = await api.get(`/login/wallpapers`)
const controller = new AbortController()
backgroundImages.value = await api.get(`/login/wallpapers`, {
signal: controller.signal,
})
activeImageIndex.value = 0
} catch (e) {
console.error(e)
throw e
}
}
@@ -85,7 +95,6 @@ async function fetchBackgroundImages() {
function startBackgroundRotation() {
// 清除轮换定时器
if (backgroundRotationTimer) clearInterval(backgroundRotationTimer)
if (backgroundImages.value.length > 1) {
backgroundRotationTimer = setInterval(() => {
// 计算下一个图片索引
@@ -131,7 +140,6 @@ function animateAndRemoveLoader() {
if (loadingBg) {
// 先添加完成动画类
loadingBg.classList.add('loading-complete')
// 等待动画完成后再移除元素
setTimeout(() => {
removeEl('#loading-bg')
@@ -144,20 +152,27 @@ function animateAndRemoveLoader() {
}
// 加载背景图片
async function loadBackgroundImages() {
await fetchBackgroundImages()
.then(() => {
startBackgroundRotation()
})
.catch(() => {
// 3秒后重试
async function loadBackgroundImages(retryCount = 0) {
const maxRetries = 3
try {
await fetchBackgroundImages()
startBackgroundRotation()
} catch (error: any) {
const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED'
if (retryCount < maxRetries) {
const baseDelay = isAbortError ? 1000 : 3000
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
setTimeout(() => {
loadBackgroundImages()
}, 3000)
})
loadBackgroundImages(retryCount + 1)
}, retryDelay)
}
}
}
onMounted(async () => {
// 配置 ApexCharts
configureApexCharts()
// 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value)
@@ -165,7 +180,7 @@ onMounted(async () => {
show.value = false
// 加载背景图片
await loadBackgroundImages()
loadBackgroundImages()
// 移除加载动画
ensureRenderComplete(() => {
@@ -173,7 +188,6 @@ onMounted(async () => {
setTimeout(() => {
// 移除加载动画,显示页面
animateAndRemoveLoader()
// 页面完全显示后,检查未读消息
setTimeout(() => {
checkAndEmitUnreadMessages()
@@ -185,11 +199,14 @@ onMounted(async () => {
// 添加页面可见性变化监听
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
loadBackgroundImages()
// 页面恢复可见时检查未读消息
// 页面恢复可见时,稍作延迟以确保状态稳定
setTimeout(() => {
checkAndEmitUnreadMessages()
}, 500)
loadBackgroundImages()
// 检查未读消息
setTimeout(() => {
checkAndEmitUnreadMessages()
}, 300)
}, 100)
}
})
@@ -197,11 +214,14 @@ onMounted(async () => {
window.addEventListener('pageshow', event => {
// persisted属性为true表示页面是从bfcache中恢复的
if (event.persisted) {
loadBackgroundImages()
// PWA恢复时检查未读消息
// PWA恢复时稍作延迟以确保状态稳定
setTimeout(() => {
checkAndEmitUnreadMessages()
}, 500)
loadBackgroundImages()
// 检查未读消息
setTimeout(() => {
checkAndEmitUnreadMessages()
}, 300)
}, 100)
}
})
})
@@ -211,7 +231,6 @@ onUnmounted(() => {
document.removeEventListener('visibilitychange', () => {})
// 移除PWA的页面恢复事件监听
window.removeEventListener('pageshow', () => {})
// 清除轮换定时器
if (backgroundRotationTimer) {
clearInterval(backgroundRotationTimer)

View File

@@ -26,6 +26,11 @@ export const storageAttributes = [
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'smb',
icon: 'mdi-folder-network-outline',
remote: true,
},
]
export const storageIconDict = storageAttributes.reduce((dict, item) => {

View File

@@ -1,6 +1,7 @@
import axios from 'axios'
import router from '@/router'
import { useAuthStore } from '@/stores'
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
// 创建axios实例
const api = axios.create({
@@ -17,6 +18,9 @@ declare global {
// 将 API 实例暴露到全局,供插件使用
window.MoviePilotAPI = api
// 初始化请求优化器(必须在其他拦截器之前)
initializeRequestOptimizer(api)
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store

View File

@@ -769,6 +769,8 @@ export interface MetaInfo {
audio_term: string
// 资源类型+特效
edition: string
// 流媒体平台
web_source: string
// 应用的自定义识别词
apply_words: string[]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -187,7 +187,7 @@ onUnmounted(() => {
</div>
</div>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
</div>
</VCardText>
</VCard>

View File

@@ -8,7 +8,7 @@ import { useToast } from 'vue-toastification'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
import router, { registerAbortController } from '@/router'
import router from '@/router'
import { useUserStore } from '@/stores'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
@@ -232,9 +232,6 @@ async function handleCheckSubscribe() {
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
@@ -243,7 +240,6 @@ async function handleCheckExists() {
season: props.media?.season,
mtype: props.media?.type,
},
signal,
})
if (result.success) isExists.value = true
@@ -255,16 +251,13 @@ async function handleCheckExists() {
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) {
try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
// AbortController 现在由全局请求优化器自动管理
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season,
title: props.media?.title,
},
signal,
})
return result.id || null

View File

@@ -87,6 +87,9 @@ function openTmdbPage(type: string, tmdbId: number) {
{{ context?.media_info?.tmdb_id }}
</VChip>
<!-- meta_info -->
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
{{ context?.meta_info?.web_source }}
</VChip>
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.edition }}
</VChip>

View File

@@ -200,7 +200,7 @@ onMounted(() => {
<span class="me-2 mb-1">自定义媒体服务器</span>
</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>

View File

@@ -106,7 +106,7 @@ const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}&cache=true`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})

View File

@@ -170,7 +170,7 @@ const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}&cache=true`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
@@ -180,7 +180,7 @@ const authorPath: Ref<string> = computed(() => {
// 网络图片则使用代理后返回
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.author_url + '.png',
)}`
)}&cache=true`
})
// 重置插件

View File

@@ -24,10 +24,11 @@ const { t } = useI18n()
const cardProps = defineProps({
site: Object as PropType<Site>,
data: Object as PropType<SiteUserData>,
stats: Object as PropType<SiteStatistic>,
})
// 定义触发的自定义事件
const emit = defineEmits(['update', 'remove'])
const emit = defineEmits(['update', 'remove', 'refresh-stats'])
// 确认框
const createConfirm = useConfirm()
@@ -56,9 +57,6 @@ const resourceDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 站点使用统计
const siteStats = ref<SiteStatistic>({})
// 查询站点图标
async function getSiteIcon() {
try {
@@ -84,16 +82,8 @@ async function testSite() {
testButtonText.value = t('site.testConnectivity')
testButtonDisable.value = false
getSiteStats()
} catch (error) {
console.error(error)
}
}
// 查询站点使用统计
async function getSiteStats() {
try {
siteStats.value = await api.get(`site/statistic/${cardProps.site?.domain}`)
// 测试完成后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
} catch (error) {
console.error(error)
}
@@ -140,16 +130,17 @@ async function deleteSiteInfo() {
// 根据站点状态显示不同的状态图标
const statColor = computed(() => {
if (isNullOrEmptyObject(siteStats.value)) {
if (!cardProps.stats || isNullOrEmptyObject(cardProps.stats)) {
return 'secondary'
}
if (siteStats.value?.lst_state == 1) {
if (cardProps.stats?.lst_state === 1) {
return 'error'
} else if (siteStats.value?.lst_state == 0) {
if (!siteStats.value?.seconds) return 'secondary'
if (siteStats.value?.seconds >= 5) return 'warning'
} else if (cardProps.stats?.lst_state === 0) {
if (!cardProps.stats?.seconds) return 'secondary'
if (cardProps.stats?.seconds >= 5) return 'warning'
return 'success'
}
return 'secondary'
})
// 数据百分比计算
@@ -185,19 +176,20 @@ function saveSite() {
// 更新站点Cookie UA后的回调
function onSiteCookieUpdated() {
siteCookieDialog.value = false
getSiteStats()
// Cookie更新后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
}
// 资源浏览弹窗关闭后的回调
function onSiteResourceDone() {
resourceDialog.value = false
getSiteStats()
// 资源操作完成后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
getSiteStats()
})
</script>

View File

@@ -7,15 +7,16 @@ import u115_png from '@images/misc/u115.png'
import rclone_png from '@images/misc/rclone.png'
import alist_png from '@images/misc/openlist.svg'
import custom_png from '@images/misc/database.png'
import smb_png from '@images/misc/smb.png'
import api from '@/api'
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
import SmbConfigDialog from '../dialog/SmbConfigDialog.vue'
import { useToast } from 'vue-toastification'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { storageIconDict } from '@/api/constants'
import { useDisplay } from 'vuetify'
// 显示器宽度
@@ -66,6 +67,8 @@ const u115AuthDialog = ref(false)
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// SMB配置对话框
const smbConfigDialog = ref(false)
// 自定义存储配置对话框
const customConfigDialog = ref(false)
@@ -84,6 +87,9 @@ function openStorageDialog() {
case 'alist':
aListConfigDialog.value = true
break
case 'smb':
smbConfigDialog.value = true
break
case 'local':
$toast.info(t('storage.noConfigNeeded'))
break
@@ -106,6 +112,8 @@ const getIcon = computed(() => {
return rclone_png
case 'alist':
return alist_png
case 'smb':
return smb_png
default:
return custom_png
}
@@ -144,6 +152,7 @@ function handleDone() {
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
smbConfigDialog.value = false
customConfigDialog.value = false
// 更新存储
storage_ref.value.name = customName.value
@@ -163,14 +172,14 @@ function onClose() {
<template>
<div>
<VCard variant="tonal" @click="openStorageDialog">
<VDialogCloseBtn v-if="!storageIconDict[storage.type]" @click="onClose" />
<VDialogCloseBtn @click="onClose" class="absolute top-1 right-1" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
</div>
<VImg :src="getIcon" cover class="mt-7" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-8" max-width="3rem" min-width="3rem" />
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
@@ -204,6 +213,13 @@ function onClose() {
@close="aListConfigDialog = false"
@done="handleDone"
/>
<SmbConfigDialog
v-if="smbConfigDialog"
v-model="smbConfigDialog"
:conf="props.storage.config || {}"
@close="smbConfigDialog = false"
@done="handleDone"
/>
<VDialog
v-if="customConfigDialog"
v-model="customConfigDialog"

View File

@@ -204,6 +204,11 @@ onMounted(() => {
<!-- 资源标签区 -->
<div class="d-flex flex-wrap gap-1 mb-2">
<!-- 流媒体平台 -->
<VChip v-if="meta?.web_source" class="chip-web-source rounded-sm" size="x-small" variant="elevated">
{{ meta?.web_source }}
</VChip>
<!-- 版本标签 -->
<VChip v-if="meta?.edition" class="chip-edition rounded-sm" size="x-small" variant="elevated">
{{ meta?.edition }}
@@ -412,6 +417,11 @@ onMounted(() => {
color: white;
}
.chip-web-source {
background-color: #8000FF;
color: white;
}
.chip-edition {
background-color: #f44336;
color: white;

View File

@@ -161,6 +161,11 @@ onMounted(() => {
</div>
<div class="d-flex flex-wrap gap-1 mb-2">
<!-- 流媒体平台 -->
<VChip v-if="meta?.web_source" class="chip-web-source rounded-sm" size="x-small" variant="elevated">
{{ meta?.web_source }}
</VChip>
<!-- 版本标签 -->
<VChip v-if="meta?.edition" class="chip-edition rounded-sm" size="x-small" variant="elevated">
{{ meta?.edition }}
@@ -260,6 +265,11 @@ onMounted(() => {
color: white;
}
.chip-web-source {
background-color: #8000ff;
color: white;
}
.chip-edition {
background-color: #f44336;
color: white;

View File

@@ -10,6 +10,10 @@ const props = defineProps({
plugin: {
type: Object as PropType<Plugin>,
},
show_switch: {
type: Boolean,
default: true,
},
})
// 定义事件
@@ -130,6 +134,7 @@ onMounted(() => {
</div>
</VCardText>
<VFab
v-if="show_switch"
icon="mdi-cog"
location="bottom"
size="x-large"
@@ -146,6 +151,7 @@ onMounted(() => {
<component
:is="dynamicComponent"
:api="api"
:show_switch="show_switch"
@action="handleAction"
@switch="emit('switch')"
@close="emit('close')"

View File

@@ -253,6 +253,8 @@ async function fetchSiteUserData() {
try {
const result: { [key: string]: any } = await api.get(`site/userdata/${props.site?.id}`)
if (result.success) {
// 使用nextTick确保DOM更新完成后再更新图表数据
await nextTick()
siteDatas.value = result.data.sort((a: { updated_day: any }, b: { updated_day: any }) =>
(a.updated_day || '').localeCompare(b.updated_day || ''),
)
@@ -276,8 +278,11 @@ async function refreshSiteData() {
progressDialog.value = false
}
onBeforeMount(async () => {
await fetchSiteUserData()
onBeforeMount(() => {
// 延迟加载,确保组件完全挂载
nextTick(() => {
fetchSiteUserData()
})
})
</script>

View File

@@ -0,0 +1,131 @@
<script lang="ts" setup>
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 多语言支持
const { t } = useI18n()
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done', 'close'])
// 完成
async function handleDone() {
await saveSmbConfig()
emit('done')
}
// 重置配置
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/smb')
if (result.success) {
// 重置成功
handleDone()
}
} catch (e) {
console.error(e)
}
}
// 保存 SMB 设置
async function saveSmbConfig() {
try {
await api.post(`storage/save/smb`, props.conf)
} catch (e) {
console.error(e)
}
}
</script>
<template>
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<template #prepend>
<VIcon icon="mdi-folder-network-outline" class="me-2" />
</template>
<VCardTitle>
{{ t('dialog.smbConfig.title') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="props.conf.host"
:hint="t('dialog.smbConfig.hostHint')"
:label="t('dialog.smbConfig.host')"
persistent-hint
prepend-inner-icon="mdi-server"
placeholder="192.168.1.100"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="props.conf.share"
:hint="t('dialog.smbConfig.shareHint')"
:label="t('dialog.smbConfig.share')"
persistent-hint
prepend-inner-icon="mdi-folder-network"
placeholder="shared_folder"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="props.conf.username"
:hint="t('dialog.smbConfig.usernameHint')"
:label="t('dialog.smbConfig.username')"
persistent-hint
prepend-inner-icon="mdi-account"
placeholder="your_username"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="props.conf.password"
:hint="t('dialog.smbConfig.passwordHint')"
:label="t('dialog.smbConfig.password')"
persistent-hint
prepend-inner-icon="mdi-lock"
placeholder="your_password"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="props.conf.domain"
:hint="t('dialog.smbConfig.domainHint')"
:label="t('dialog.smbConfig.domain')"
persistent-hint
prepend-inner-icon="mdi-domain"
placeholder="WORKGROUP"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.smbConfig.reset') }}
</VBtn>
<VSpacer />
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.smbConfig.complete') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -24,7 +24,7 @@ const inProps = defineProps({
storage: String,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Object as PropType<any>,
type: Function,
required: true,
},
refreshpending: Boolean,
@@ -554,196 +554,202 @@ onMounted(() => {
</script>
<template>
<VCard class="d-flex flex-column w-full h-full rounded-t-0" :class="{ 'rounded-s-0': showTree }">
<div v-if="!loading" class="flex">
<IconBtn v-if="display.mdAndUp.value">
<VIcon v-if="showTree" icon="mdi-file-tree" @click="switchFileTree(false)" />
<VIcon v-else icon="mdi-file-tree-outline" @click="switchFileTree(true)" />
</IconBtn>
<VTextField
v-if="!isFile"
v-model="filter"
hide-details
flat
density="compact"
variant="plain"
:placeholder="t('common.search')"
prepend-inner-icon="mdi-filter-outline"
class="mx-2"
rounded
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="!isFile" @click="changeSelectMode">
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
<VIcon color="primary" v-else>mdi-select</VIcon>
</IconBtn>
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
<VIcon color="primary"> mdi-text-recognition </VIcon>
</IconBtn>
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
<VIcon color="primary"> mdi-download </VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="list_files">
<VIcon color="primary"> mdi-refresh </VIcon>
</IconBtn>
<!-- 批量操作按钮 -->
<span v-if="selected.length > 0">
<IconBtn @click.stop="batchScrape">
<VIcon color="primary" icon="mdi-auto-fix" />
<div>
<VCard class="d-flex flex-column w-full h-full rounded-t-0" :class="{ 'rounded-s-0': showTree }">
<div v-if="!loading" class="flex">
<IconBtn v-if="display.mdAndUp.value">
<VIcon v-if="showTree" icon="mdi-file-tree" @click="switchFileTree(false)" />
<VIcon v-else icon="mdi-file-tree-outline" @click="switchFileTree(true)" />
</IconBtn>
<IconBtn @click.stop="showBatchTransfer">
<VIcon color="primary" icon="mdi-folder-arrow-right" />
<VTextField
v-if="!isFile"
v-model="filter"
hide-details
flat
density="compact"
variant="plain"
:placeholder="t('common.search')"
prepend-inner-icon="mdi-filter-outline"
class="mx-2"
rounded
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="!isFile" @click="changeSelectMode">
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
<VIcon color="primary" v-else>mdi-select</VIcon>
</IconBtn>
<IconBtn @click.stop="batchDelete">
<VIcon icon="mdi-delete-outline" color="error" />
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
<VIcon color="primary"> mdi-text-recognition </VIcon>
</IconBtn>
</span>
</div>
<LoadingBanner v-if="loading" />
<!-- 文件详情 -->
<VCardText v-else-if="isFile && !isImage && items.length > 0" class="text-center break-all">
<div v-if="items[0]?.thumbnail" class="flex justify-center">
<VImg max-width="15rem" cover :src="items[0]?.thumbnail" class="rounded border">
<template #placeholder>
<VSkeletonLoader class="object-cover w-full h-full" />
</template>
</VImg>
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
<VIcon color="primary"> mdi-download </VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="list_files">
<VIcon color="primary"> mdi-refresh </VIcon>
</IconBtn>
<!-- 批量操作按钮 -->
<span v-if="selected.length > 0">
<IconBtn @click.stop="batchScrape">
<VIcon color="primary" icon="mdi-auto-fix" />
</IconBtn>
<IconBtn @click.stop="showBatchTransfer">
<VIcon color="primary" icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn @click.stop="batchDelete">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</span>
</div>
<div class="text-xl text-high-emphasis mt-3">{{ items[0]?.name }}</div>
<p class="mt-2" v-if="items[0]?.size && items[0].modify_time">
{{ t('file.size') }}{{ formatBytes(items[0]?.size || 0) }}<br />
{{ t('file.modifyTime') }}{{ formatTime(items[0]?.modify_time || 0) }}
</p>
</VCardText>
<!-- 图片 -->
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
<VImg :src="currentImgLink" max-width="100%" max-height="100%" />
</VCardText>
<!-- 目录和文件列表 -->
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList class="text-high-emphasis">
<VVirtualScroll :items="[...dirs, ...files]" :style="listStyle">
<template #default="{ item }">
<VHover>
<template #default="hover">
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
<template #prepend>
<VListItemAction v-if="selectMode">
<VCheckbox v-model="selected" :value="item" />
</VListItemAction>
<template v-else>
<VIcon
v-if="inProps.icons && item.extension"
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
/>
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder" />
<VIcon v-else icon="mdi-file-outline" />
</template>
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle v-if="item.size">
{{ formatBytes(item.size) }}
</VListItemSubtitle>
<template #append>
<IconBtn v-if="display.smAndDown.value && !selectMode">
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(menu, i) in dropdownItems" :key="i">
<VListItem v-if="menu.show" :base-color="menu.props.color" @click="menu.props.click(item)">
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
<IconBtn @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn @click.stop="scrape(item)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
<IconBtn @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</span>
</template>
</VListItem>
</template>
</VHover>
</template>
</VVirtualScroll>
</VList>
</VCardText>
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
{{ t('file.noFiles') }}
</VCardText>
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5">
{{ t('file.emptyDirectory') }}
</VCardText>
</VCard>
<!-- 重命名弹窗 -->
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="35rem">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="renamePopper = false" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-model="newName"
:label="t('file.newName')"
:loading="renameLoading"
prepend-inner-icon="mdi-format-text"
/>
</VCol>
<VCol cols="12" v-if="currentItem && currentItem.type == 'dir'">
<VSwitch v-model="renameAll" :label="t('file.includeSubfolders')" />
</VCol>
</VRow>
<LoadingBanner v-if="loading" />
<!-- 文件详情 -->
<VCardText v-else-if="isFile && !isImage && items.length > 0" class="text-center break-all">
<div v-if="items[0]?.thumbnail" class="flex justify-center">
<VImg max-width="15rem" cover :src="items[0]?.thumbnail" class="rounded border">
<template #placeholder>
<VSkeletonLoader class="object-cover w-full h-full" />
</template>
</VImg>
</div>
<div class="text-xl text-high-emphasis mt-3">{{ items[0]?.name }}</div>
<p class="mt-2" v-if="items[0]?.size && items[0].modify_time">
{{ t('file.size') }}{{ formatBytes(items[0]?.size || 0) }}<br />
{{ t('file.modifyTime') }}{{ formatTime(items[0]?.modify_time || 0) }}
</p>
</VCardText>
<!-- 图片 -->
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
<VImg :src="currentImgLink" max-width="100%" max-height="100%" />
</VCardText>
<!-- 目录和文件列表 -->
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList class="text-high-emphasis">
<VVirtualScroll :items="[...dirs, ...files]" :style="listStyle">
<template #default="{ item }">
<VHover>
<template #default="hover">
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
<template #prepend>
<VListItemAction v-if="selectMode">
<VCheckbox v-model="selected" :value="item" />
</VListItemAction>
<template v-else>
<VIcon
v-if="inProps.icons && item.extension"
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
/>
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder" />
<VIcon v-else icon="mdi-file-outline" />
</template>
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle v-if="item.size">
{{ formatBytes(item.size) }}
</VListItemSubtitle>
<template #append>
<IconBtn v-if="display.smAndDown.value && !selectMode">
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(menu, i) in dropdownItems" :key="i">
<VListItem
v-if="menu.show"
:base-color="menu.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
<IconBtn @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn @click.stop="scrape(item)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
<IconBtn @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</span>
</template>
</VListItem>
</template>
</VHover>
</template>
</VVirtualScroll>
</VList>
</VCardText>
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
{{ t('file.noFiles') }}
</VCardText>
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5">
{{ t('file.emptyDirectory') }}
</VCardText>
<VCardActions>
<VBtn color="success" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
{{ t('file.autoRecognizeName') }}
</VBtn>
<VBtn :disabled="!newName" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 文件整理弹窗 -->
<ReorganizeDialog
v-if="transferPopper"
v-model="transferPopper"
:items="transferItems"
:target_storage="inProps.storage"
@done="transferDone"
@close="transferPopper = false"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- 识别结果对话框 -->
<MediaInfoDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
:context="nameTestResult"
@close="nameTestDialog = false"
/>
<!-- 重命名弹窗 -->
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="35rem">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="renamePopper = false" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-model="newName"
:label="t('file.newName')"
:loading="renameLoading"
prepend-inner-icon="mdi-format-text"
/>
</VCol>
<VCol cols="12" v-if="currentItem && currentItem.type == 'dir'">
<VSwitch v-model="renameAll" :label="t('file.includeSubfolders')" />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="success" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
{{ t('file.autoRecognizeName') }}
</VBtn>
<VBtn :disabled="!newName" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 文件整理弹窗 -->
<ReorganizeDialog
v-if="transferPopper"
v-model="transferPopper"
:items="transferItems"
:target_storage="inProps.storage"
@done="transferDone"
@close="transferPopper = false"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- 识别结果对话框 -->
<MediaInfoDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
:context="nameTestResult"
@close="nameTestDialog = false"
/>
</div>
</template>

View File

@@ -27,7 +27,7 @@ const props = defineProps({
},
endpoints: Object,
axios: {
type: Object as PropType<any>,
type: Function,
required: true,
},
})

View File

@@ -24,7 +24,7 @@ const inProps = defineProps({
},
endpoints: Object as PropType<EndPoints>,
axios: {
type: Object as PropType<any>,
type: Function,
required: true,
},
})

View File

@@ -0,0 +1,255 @@
import { ref, computed, onMounted, onBeforeUnmount, inject, readonly } from 'vue'
import { useDisplay } from 'vuetify'
import { useRoute } from 'vue-router'
// 下拉手势配置类型
export interface PullDownConfig {
START_THRESHOLD: number // 开始下拉的最小距离
SHOW_INDICATOR: number // 显示指示器的距离
TRIGGER_THRESHOLD: number // 触发回调的距离
MAX_PULL_DISTANCE: number // 最大下拉距离
PULL_RESISTANCE: number // 下拉阻力系数
CONTENT_FOLLOW_RATIO: number // 页面内容跟随比例
TOLERANCE: number // 手指抖动容忍度
}
// 下拉手势选项
export interface PullDownOptions {
config?: Partial<PullDownConfig>
// 检查是否可以使用下拉手势的函数
canUsePullGesture?: () => boolean
// 触发回调
onTrigger?: () => void
// 是否启用默认true
enabled?: boolean
}
// 默认配置
const DEFAULT_CONFIG: PullDownConfig = {
START_THRESHOLD: 20,
SHOW_INDICATOR: 60,
TRIGGER_THRESHOLD: 100,
MAX_PULL_DISTANCE: 200,
PULL_RESISTANCE: 0.75,
CONTENT_FOLLOW_RATIO: 0.4,
TOLERANCE: 80,
}
export function usePullDownGesture(options: PullDownOptions = {}) {
const display = useDisplay()
const route = useRoute()
const appMode = inject('pwaMode')
// 合并配置
const config = { ...DEFAULT_CONFIG, ...options.config }
// 状态管理
const isPulling = ref(false)
const startY = ref(0)
const pullDistance = ref(0)
const initialScrollTop = ref(0)
const hasDialogOpen = ref(false)
const lastDialogCheckTime = ref(0)
const DIALOG_CHECK_INTERVAL = 500
// 计算属性
const contentTransform = computed(() => {
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
const moveDistance = pullDistance.value * config.CONTENT_FOLLOW_RATIO
return `translateY(${moveDistance}px)`
})
const contentTransition = computed(() => {
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 >= config.SHOW_INDICATOR
})
const indicatorRotation = computed(() => {
if (!isPulling.value) return 0
const progress = Math.min(
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
1,
)
return progress * 180
})
const indicatorOpacity = computed(() => {
if (!isPulling.value) return 0
const progress = Math.min(
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
1,
)
return 0.7 + progress * 0.3
})
const indicatorTransform = computed(() => {
return `translate(-50%, ${Math.min(20 + pullDistance.value - config.SHOW_INDICATOR, 50)}px)`
})
// 弹窗检测函数
const hasOpenDialog = (excludeSelector?: string) => {
try {
const dialogSelectors = [
'.v-overlay--active:not(.v-overlay--scroll-blocked)',
'.v-dialog--active',
'.v-menu--active',
'.v-bottom-sheet--active',
'.v-snackbar--active',
'[role="dialog"]:not([style*="display: none"])',
'.modal:not(.d-none):not([style*="display: none"])',
'[aria-modal="true"]:not([style*="display: none"])',
]
for (const selector of dialogSelectors) {
const elements = document.querySelectorAll(selector)
if (elements.length > 0) {
// 如果需要排除特定元素如QuickAccess面板
if (excludeSelector && elements.length === 1) {
const element = elements[0]
if (element.closest(excludeSelector)) {
continue
}
}
return true
}
}
return false
} catch (error) {
console.warn('检测弹窗状态时出错:', error)
return true
}
}
// 事件处理函数
const handleTouchStart = (event: TouchEvent) => {
if (!appMode || !display.mdAndDown.value || !options.enabled) return
// 检查是否可以使用下拉手势
if (options.canUsePullGesture && !options.canUsePullGesture()) return
// 检查是否有弹窗打开
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
lastDialogCheckTime.value = Date.now()
if (hasDialogOpen.value) return
const touch = event.touches[0]
startY.value = touch.clientY
// 重置下拉状态
isPulling.value = false
pullDistance.value = 0
// 记录开始时的滚动位置
initialScrollTop.value = window.scrollY || document.documentElement.scrollTop || 0
}
const handleTouchMove = (event: TouchEvent) => {
if (!appMode || !display.mdAndDown.value || !options.enabled) return
// 检查是否可以使用下拉手势
if (options.canUsePullGesture && !options.canUsePullGesture()) return
// 只在必要时重新检测弹窗
const currentTime = Date.now()
if (currentTime - lastDialogCheckTime.value > DIALOG_CHECK_INTERVAL) {
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
lastDialogCheckTime.value = currentTime
}
if (hasDialogOpen.value) {
isPulling.value = false
pullDistance.value = 0
return
}
const touch = event.touches[0]
const deltaY = touch.clientY - startY.value
if (isPulling.value) {
if (deltaY > -config.TOLERANCE) {
pullDistance.value = Math.max(0, Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE))
event.preventDefault()
} else {
isPulling.value = false
pullDistance.value = 0
}
} else {
if (deltaY > config.START_THRESHOLD) {
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
isPulling.value = true
pullDistance.value = Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE)
event.preventDefault()
}
}
}
}
const handleTouchEnd = () => {
if (!appMode || !display.mdAndDown.value || !options.enabled) return
// 检查是否可以使用下拉手势
if (options.canUsePullGesture && !options.canUsePullGesture()) return
// 重置弹窗检测标志
hasDialogOpen.value = false
lastDialogCheckTime.value = 0
if (isPulling.value && pullDistance.value >= config.TRIGGER_THRESHOLD) {
// 达到触发阈值,执行回调
options.onTrigger?.()
}
// 停止拖拽状态
isPulling.value = false
// 延迟重置其他状态
setTimeout(() => {
pullDistance.value = 0
startY.value = 0
}, 300)
}
// 生命周期管理
onMounted(() => {
if (appMode && display.mdAndDown.value) {
document.addEventListener('touchstart', handleTouchStart, { passive: false })
document.addEventListener('touchmove', handleTouchMove, { passive: false })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
}
})
onBeforeUnmount(() => {
if (appMode && display.mdAndDown.value) {
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('touchend', handleTouchEnd)
}
})
return {
// 状态
isPulling: readonly(isPulling),
pullDistance: readonly(pullDistance),
// 计算属性
contentTransform,
contentTransition,
showPullIndicator,
indicatorRotation,
indicatorOpacity,
indicatorTransform,
// 配置
config,
// 工具函数
hasOpenDialog,
}
}

View File

@@ -0,0 +1,113 @@
import type { Plugin } from '@/api/types'
const RECENT_PLUGINS_KEY = 'moviepilot_recent_plugins'
const MAX_RECENT_PLUGINS = 3
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.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,
}
}

View File

@@ -0,0 +1,159 @@
import { ref, watch, onBeforeUnmount, readonly } from 'vue'
// 滚动锁定配置选项
export interface ScrollLockOptions {
// 是否在组件卸载时自动恢复滚动默认true
autoRestore?: boolean
// 是否保存和恢复滚动位置默认true
preserveScrollPosition?: boolean
// 自定义锁定时的样式
lockStyles?: {
overflow?: string
position?: string
width?: string
}
}
// 默认配置
const DEFAULT_OPTIONS: Required<ScrollLockOptions> = {
autoRestore: true,
preserveScrollPosition: true,
lockStyles: {
overflow: 'hidden',
position: 'fixed',
width: '100%',
},
}
export function useScrollLock(options: ScrollLockOptions = {}) {
const config = { ...DEFAULT_OPTIONS, ...options }
// 状态管理
const isLocked = ref(false)
const savedScrollPosition = ref(0)
const originalBodyStyles = ref<{ [key: string]: string }>({})
const originalDocumentStyles = ref<{ [key: string]: string }>({})
// 保存当前滚动位置
const saveScrollPosition = () => {
if (config.preserveScrollPosition) {
savedScrollPosition.value =
window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
}
}
// 保存原始样式
const saveOriginalStyles = () => {
// 保存 body 样式
originalBodyStyles.value = {
overflow: document.body.style.overflow,
position: document.body.style.position,
top: document.body.style.top,
width: document.body.style.width,
}
// 保存 documentElement 样式
originalDocumentStyles.value = {
overflow: document.documentElement.style.overflow,
}
}
// 锁定滚动
const lockScroll = () => {
if (isLocked.value) return
// 保存当前状态
saveScrollPosition()
saveOriginalStyles()
// 应用锁定样式
document.body.style.overflow = config.lockStyles.overflow || 'hidden'
document.body.style.position = config.lockStyles.position || 'fixed'
document.body.style.width = config.lockStyles.width || '100%'
document.documentElement.style.overflow = config.lockStyles.overflow || 'hidden'
// 如果需要保持滚动位置设置top偏移
if (config.preserveScrollPosition) {
document.body.style.top = `-${savedScrollPosition.value}px`
}
isLocked.value = true
}
// 恢复滚动
const restoreScroll = () => {
if (!isLocked.value) return
// 恢复原始样式
document.body.style.overflow = originalBodyStyles.value.overflow || ''
document.body.style.position = originalBodyStyles.value.position || ''
document.body.style.top = originalBodyStyles.value.top || ''
document.body.style.width = originalBodyStyles.value.width || ''
document.documentElement.style.overflow = originalDocumentStyles.value.overflow || ''
// 恢复滚动位置
if (config.preserveScrollPosition) {
window.scrollTo(0, savedScrollPosition.value)
}
isLocked.value = false
}
// 切换滚动锁定状态
const toggleScrollLock = (lock?: boolean) => {
const shouldLock = lock !== undefined ? lock : !isLocked.value
if (shouldLock) {
lockScroll()
} else {
restoreScroll()
}
}
// 监听响应式值的变化
const watchTarget = (target: any) => {
return watch(
target,
newValue => {
toggleScrollLock(!!newValue)
},
{ immediate: false },
)
}
// 生命周期清理
onBeforeUnmount(() => {
if (config.autoRestore && isLocked.value) {
restoreScroll()
}
})
return {
// 状态
isLocked: readonly(isLocked),
savedScrollPosition: readonly(savedScrollPosition),
// 方法
lockScroll,
restoreScroll,
toggleScrollLock,
watchTarget,
// 工具方法
saveScrollPosition,
}
}
// 便捷的自动监听版本
export function useScrollLockWithWatch(target: any, options: ScrollLockOptions = {}) {
const scrollLock = useScrollLock(options)
// 自动监听目标值的变化
const stopWatcher = scrollLock.watchTarget(target)
// 返回所有功能 + 停止监听的方法
return {
...scrollLock,
stopWatcher,
}
}

View File

@@ -7,17 +7,22 @@ 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 QuickAccess from '@/layouts/components/QuickAccess.vue'
import { useUserStore } from '@/stores'
import { getNavMenus } from '@/router/i18n-menu'
import { NavMenu } from '@/@layouts/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { filterMenusByPermission } from '@/utils/permission'
import { onUnreadMessage } from '@/utils/badge'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
import { useScrollLockWithWatch } from '@/composables/useScrollLock'
const display = useDisplay()
const appMode = inject('pwaMode')
const { t } = useI18n()
const route = useRoute()
// 用户 Store
const userStore = useUserStore()
@@ -49,6 +54,42 @@ const organizeMenus = ref<NavMenu[]>([])
// 系统菜单项
const systemMenus = ref<NavMenu[]>([])
// 插件快速访问相关状态
const showPluginQuickAccess = ref(false)
// 使用滚动锁定 composable自动监听showPluginQuickAccess的变化
useScrollLockWithWatch(showPluginQuickAccess)
// 检查是否可以使用下拉手势
const canUsePullGesture = () => {
// 检查是否在dashboard页面
const isDashboard = route.name === 'dashboard' || route.path === '/dashboard'
// 检查是否是管理员
const isAdmin = superUser.value
// 检查插件快速访问面板是否已显示
const quickAccessOpen = showPluginQuickAccess.value
return isDashboard && isAdmin && !quickAccessOpen
}
// 使用下拉手势 composable
const {
pullDistance,
contentTransform,
contentTransition,
showPullIndicator,
indicatorRotation,
indicatorOpacity,
indicatorTransform,
config: PULL_CONFIG,
} = usePullDownGesture({
enabled: true,
canUsePullGesture,
onTrigger: () => {
showPluginQuickAccess.value = true
},
})
// 根据分类获取菜单列表
const getMenuList = (header: string) => {
// 使用国际化菜单
@@ -74,6 +115,16 @@ function handleUnreadMessage(count: number) {
}
}
// 关闭插件快速访问
function handleClosePluginQuickAccess() {
showPluginQuickAccess.value = false
}
// 点击插件后关闭
function handlePluginClick() {
showPluginQuickAccess.value = false
}
onMounted(() => {
// 获取菜单列表
startMenus.value = getMenuList(t('menu.start'))
@@ -93,6 +144,30 @@ onMounted(() => {
</script>
<template>
<!-- 👉 Pull Down Indicator -->
<div
v-if="appMode && showPullIndicator"
class="pull-indicator"
:style="{
opacity: indicatorOpacity,
transform: indicatorTransform,
}"
>
<div
class="indicator-icon"
:style="{
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 >= PULL_CONFIG.TRIGGER_THRESHOLD ? 'success' : 'primary'"
/>
</div>
</div>
<VerticalNavLayout>
<!-- 👉 Navbar -->
<template #navbar="{ toggleVerticalOverlayNavActive }">
@@ -155,22 +230,97 @@ onMounted(() => {
</template>
<template #after-vertical-nav-items />
<!-- 👉 Pages -->
<slot />
<!-- 👉 下拉跟随动画 -->
<div
class="main-content-wrapper"
:style="{
transform: contentTransform,
transition: contentTransition,
}"
>
<slot />
</div>
<!-- 👉 Footer -->
<template #footer>
<Footer />
</template>
</VerticalNavLayout>
<!-- 👉 Plugin Quick Access -->
<QuickAccess
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%;
transform: translateZ(0);
will-change: transform;
}
.pull-indicator {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 50%;
backdrop-filter: blur(20px);
background: rgba(var(--v-theme-surface), 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 10%), 0 1px 3px rgba(0, 0, 0, 6%);
inset-block-start: 80px;
inset-inline-start: 50%;
pointer-events: none;
transform: translateX(-50%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.indicator-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--v-theme-primary), 0.08);
block-size: 40px;
inline-size: 40px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 透明主题适配 */
html[class*='transparent'] .pull-indicator,
html[class*='mica'] .pull-indicator,
html[class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 20%);
background: rgba(255, 255, 255, 95%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 12%), 0 4px 16px rgba(0, 0, 0, 8%);
}
html[class*='transparent'] .indicator-icon,
html[class*='mica'] .indicator-icon,
html[class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.12);
}
html[data-theme='dark'][class*='transparent'] .pull-indicator,
html[data-theme='dark'][class*='mica'] .pull-indicator,
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 10%);
background: rgba(18, 18, 18, 95%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 30%), 0 4px 16px rgba(0, 0, 0, 20%);
}
html[data-theme='dark'][class*='transparent'] .indicator-icon,
html[data-theme='dark'][class*='mica'] .indicator-icon,
html[data-theme='dark'][class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.15);
}
</style>

View File

@@ -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%);
}
}

View File

@@ -0,0 +1,709 @@
<script setup lang="ts">
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'
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
import { VCard } from 'vuetify/components'
import { getDominantColor } from '@/@core/utils/image'
// 国际化
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 noImage
if (pluginIconLoadError.value[plugin.id]) return noImage
// 如果是网络图片则使用代理后返回
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
}
// 监听可见性变化,加载数据
watch(
() => isVisible.value,
visible => {
if (visible) {
fetchPluginsWithPage()
loadRecentPlugins()
}
},
{ immediate: true },
)
onMounted(() => {
if (isVisible.value) {
fetchPluginsWithPage()
loadRecentPlugins()
}
})
// 处理触摸开始
function handleTouchStart(event: TouchEvent) {
if (!props.visible) return
const touch = event.touches[0]
if (!touch) return
// 检查是否从 bottom-drag-area 开始触摸
const target = event.target as HTMLElement
startedFromBottomArea.value = !!target.closest('.bottom-drag-area')
startY.value = touch.clientY
lastY.value = touch.clientY
lastTime.value = Date.now()
velocity.value = 0
// 重置拖拽状态
isDraggingToClose.value = false
dragOffset.value = 0
}
// 处理触摸移动
function handleTouchMove(event: TouchEvent) {
if (!props.visible) return
const touch = event.touches[0]
if (!touch) return
// 只有从 bottom-drag-area 开始的触摸才处理上滑关闭
if (!startedFromBottomArea.value) return
const currentY = touch.clientY
const currentTime = Date.now()
const deltaY = startY.value - currentY // 向上为正值
const timeDelta = currentTime - lastTime.value
// 计算速度
if (timeDelta > 0) {
const moveDistance = lastY.value - currentY
velocity.value = moveDistance / timeDelta
}
// 如果已经开始拖拽,继续拖拽
if (isDraggingToClose.value) {
if (deltaY >= 0) {
// 向上拖拽,更新偏移量
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
event.preventDefault()
} else {
// 向下拖拽,停止拖拽
isDraggingToClose.value = false
dragOffset.value = 0
}
} else {
// 还没开始拖拽,检查是否应该开始
if (deltaY > SWIPE_CONFIG.START_THRESHOLD) {
isDraggingToClose.value = true
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
event.preventDefault()
}
}
lastY.value = currentY
lastTime.value = currentTime
}
// 处理触摸结束
function handleTouchEnd() {
if (!props.visible) return
// 只有从 bottom-drag-area 开始的触摸才处理上滑关闭
if (!startedFromBottomArea.value) return
if (isDraggingToClose.value) {
// 判断是否应该关闭:距离超过阈值或者快速上滑
const shouldClose =
dragOffset.value >= SWIPE_CONFIG.CLOSE_THRESHOLD || velocity.value >= SWIPE_CONFIG.VELOCITY_THRESHOLD
if (shouldClose) {
emit('close')
}
// 重置拖拽状态
isDraggingToClose.value = false
dragOffset.value = 0
}
// 重置所有状态
startY.value = 0
lastY.value = 0
velocity.value = 0
startedFromBottomArea.value = false
}
// 点击底部空白区域关闭
function handleBackdropClick(event: MouseEvent) {
const target = event.target as HTMLElement
// 点击根容器或底部提示区域时关闭
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-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 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;
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;
&::-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-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
}
.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-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>

View File

@@ -19,6 +19,11 @@ export default {
noData: 'No data',
noContent: 'No relevant content found',
all: 'All',
active: 'Active',
inactive: 'Inactive',
filter: 'Filter',
noMatchingData: 'No matching data',
tryChangingFilters: 'Try changing filters',
default: 'Default',
name: 'Name',
create: 'Create',
@@ -775,6 +780,7 @@ export default {
u115: '115 Cloud',
rclone: 'RClone',
alist: 'OpenList',
smb: 'SMB Network Share',
custom: 'Custom',
},
filterRules: {
@@ -884,6 +890,10 @@ export default {
testing: 'Testing ...',
testSuccess: '{name} connectivity test successful, ready to use!',
testFailed: '{name} connectivity test failed: {message}',
connectionNormal: 'Connection Normal',
connectionSlow: 'Connection Slow',
connectionFailed: 'Connection Failed',
connectionUnknown: 'Connection Unknown',
deleteConfirm: 'Are you sure you want to delete this site?',
deleteSuccess: '{name} deleted successfully!',
deleteFailed: '{name} deletion failed: {message}',
@@ -1669,6 +1679,21 @@ export default {
complete: 'Complete',
reset: 'Reset',
},
smbConfig: {
title: 'SMB Network Share Configuration',
host: 'SMB Server Address',
hostHint: 'IP address or hostname of the SMB server',
share: 'Share Name',
shareHint: 'Name of the shared folder to connect to',
username: 'Username',
usernameHint: 'SMB login username',
password: 'Password',
passwordHint: 'SMB login password',
domain: 'Domain',
domainHint: 'SMB domain name, such as WORKGROUP or domain controller name',
complete: 'Complete',
reset: 'Reset',
},
workflowAddEdit: {
addTitle: 'Add Workflow',
editTitle: 'Edit Workflow',
@@ -2173,6 +2198,12 @@ 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',
recentlyUsed: 'Recently Used',
allPlugins: 'All Plugins',
noRecentPlugins: 'None',
},
profile: {
personalInfo: 'Personal Information',

View File

@@ -19,6 +19,11 @@ export default {
noData: '暂无数据',
noContent: '没有找到相关内容',
all: '全部',
active: '激活',
inactive: '未激活',
filter: '筛选',
noMatchingData: '没有符合条件的数据',
tryChangingFilters: '请尝试更改筛选条件',
default: '默认',
name: '名称',
create: '新建',
@@ -772,6 +777,7 @@ export default {
u115: '115网盘',
rclone: 'RClone',
alist: 'OpenList',
smb: 'SMB网络共享',
custom: '自定义',
},
filterRules: {
@@ -881,6 +887,10 @@ export default {
testing: '测试中 ...',
testSuccess: '{name} 连通性测试成功,可正常使用!',
testFailed: '{name} 连通性测试失败:{message}',
connectionNormal: '连接正常',
connectionSlow: '连接缓慢',
connectionFailed: '连接失败',
connectionUnknown: '连接未知',
deleteConfirm: '是否确认删除站点?',
deleteSuccess: '{name} 删除成功!',
deleteFailed: '{name} 删除失败:{message}',
@@ -1647,6 +1657,21 @@ export default {
complete: '完成',
reset: '重置',
},
smbConfig: {
title: 'SMB网络共享配置',
host: 'SMB服务器地址',
hostHint: 'SMB服务器的IP地址或主机名',
share: '共享名称',
shareHint: '要连接的共享文件夹名称',
username: '用户名',
usernameHint: 'SMB登录用户名',
password: '密码',
passwordHint: 'SMB登录密码',
domain: '域名',
domainHint: 'SMB域名如WORKGROUP或域控制器名称',
complete: '完成',
reset: '重置',
},
workflowAddEdit: {
addTitle: '添加工作流',
editTitle: '编辑工作流',
@@ -2148,6 +2173,12 @@ export default {
cloneFailed: '插件分身创建失败:{message}',
cloneFailedGeneral: '插件分身创建失败',
logTitle: '插件日志',
quickAccess: '快速访问',
tapToOpen: '点击返回主界面',
noPluginsWithPage: '暂无可用插件',
recentlyUsed: '最近使用',
allPlugins: '所有插件',
noRecentPlugins: '无',
},
profile: {
personalInfo: '个人信息',

View File

@@ -19,6 +19,11 @@ export default {
noData: '暫無數據',
noContent: '沒有找到相關內容',
all: '全部',
active: '激活',
inactive: '未激活',
filter: '篩選',
noMatchingData: '沒有符合條件的數據',
tryChangingFilters: '請嘗試更改篩選條件',
default: '默認',
name: '名稱',
create: '新建',
@@ -770,6 +775,7 @@ export default {
u115: '115網盤',
rclone: 'RClone',
alist: 'OpenList',
smb: 'SMB網路共享',
custom: '自定義',
},
@@ -880,6 +886,10 @@ export default {
testing: '測試中 ...',
testSuccess: '{name} 連通性測試成功,可正常使用!',
testFailed: '{name} 連通性測試失敗:{message}',
connectionNormal: '連接正常',
connectionSlow: '連接緩慢',
connectionFailed: '連接失敗',
connectionUnknown: '連接未知',
deleteConfirm: '是否確認刪除站點?',
deleteSuccess: '{name} 刪除成功!',
deleteFailed: '{name} 刪除失敗:{message}',
@@ -1646,6 +1656,21 @@ export default {
complete: '完成',
reset: '重置',
},
smbConfig: {
title: 'SMB網路共享配置',
host: 'SMB伺服器地址',
hostHint: 'SMB伺服器的IP地址或主機名',
share: '共享名稱',
shareHint: '要連接的共享資料夾名稱',
username: '用戶名',
usernameHint: 'SMB登入用戶名',
password: '密碼',
passwordHint: 'SMB登入密碼',
domain: '域名',
domainHint: 'SMB域名如WORKGROUP或域控制器名稱',
complete: '完成',
reset: '重置',
},
workflowAddEdit: {
addTitle: '新增工作流',
editTitle: '編輯工作流',
@@ -2147,6 +2172,12 @@ export default {
cloneFailed: '插件分身創建失敗:{message}',
cloneFailedGeneral: '插件分身創建失敗',
logTitle: '插件日誌',
quickAccess: '快速訪問',
noPluginsWithPage: '暫無可展示的插件',
tapToOpen: '點擊返回主界面',
recentlyUsed: '最近使用',
allPlugins: '所有插件',
noRecentPlugins: '無',
},
profile: {
personalInfo: '個人信息',

View File

@@ -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>

View File

@@ -342,16 +342,18 @@ onDeactivated(() => {
</draggable>
<!-- 底部操作按钮只在非移动设备上显示 -->
<VFab
v-if="!appMode"
icon="mdi-view-dashboard-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="dialog = true"
/>
<Teleport to="body">
<VFab
v-if="!appMode"
icon="mdi-view-dashboard-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="dialog = true"
/>
</Teleport>
<!-- 弹窗根据配置生成选项 -->
<VDialog v-if="dialog" v-model="dialog" max-width="35rem" :fullscreen="!display.mdAndUp.value" scrollable>

View File

@@ -168,7 +168,7 @@ const theme: VuetifyOptions['theme'] = {
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#000000',
'background': '#1C1C1C',
'on-background': '#E7E3FC',
'surface': 'rgba(30, 30, 30, 0.3)',
'on-surface': '#E7E3FC',

View File

@@ -1,6 +1,7 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { configureNProgress } from '@/api/nprogress'
import { useAuthStore } from '@/stores'
import { setNavigatingState as setRequestNavigatingState } from '@/utils/requestOptimizer'
// Nprogress
configureNProgress()
@@ -208,23 +209,11 @@ const router = createRouter({
],
})
const abortControllers = new Set<AbortController>()
// 注册中止控制器
function registerAbortController(controller: AbortController) {
abortControllers.add(controller)
}
// 中止所有组件的任务
function abortAllControllers() {
for (const controller of abortControllers) {
controller.abort()
}
abortControllers.clear()
}
// 路由导航守卫
router.beforeEach(async (to: any, from: any, next: any) => {
// 设置导航状态 - 同时中断API请求
setRequestNavigatingState(true)
// 认证 Store
const authStore = useAuthStore()
// 总是记录非login路由
@@ -233,15 +222,19 @@ router.beforeEach(async (to: any, from: any, next: any) => {
if (to.meta.requiresAuth && !isAuthenticated) {
// 用户未登录,重定向到登录页
setRequestNavigatingState(false)
next('/login')
} else {
// 清理所有中止控制器
abortAllControllers()
next()
}
})
// 路由导航完成后
router.afterEach(() => {
setTimeout(() => {
setRequestNavigatingState(false)
}, 100)
})
// 导出默认对象
export default router
// 另行导出其他功能
export { registerAbortController }

View File

@@ -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;
@@ -356,7 +361,11 @@ html.v-overlay-scroll-blocked {
// 表格
.v-table {
border-radius: 0;
background-color: rgba(var(--v-theme-surface), 0.3);
background-color: rgba(var(--v-theme-surface), 0);
.v-table__wrapper > table > thead {
background-color: rgba(var(--v-theme-surface), 0.3);
}
}
// 页脚
@@ -396,11 +405,17 @@ html.v-overlay-scroll-blocked {
.v-skeleton-loader {
background-color: rgba(var(--v-theme-surface), 0.3);
}
// 输入框和搜索框
.v-field {
background-color: rgba(var(--v-theme-surface), 0);
}
}
// 透明主题下的弹出窗口样式
html[data-theme="transparent"] {
.v-overlay__content {
.v-overlay__content {
border-radius: 12px !important;
backdrop-filter: blur(10px) !important;
@@ -414,8 +429,8 @@ html[data-theme="transparent"] {
background-color: rgb(var(--v-theme-surface), 0.5) !important;
}
.v-table thead {
background-color: rgb(var(--v-theme-surface), 0.5) !important;
.v-table__wrapper table thead {
background-color: rgba(var(--v-theme-surface), 0.3);
}
}
}

View File

@@ -0,0 +1,98 @@
// 全局请求优化器
// 自动管理所有API请求的中断无需手动注册
let isNavigating = false
const activeRequests = new Set<AbortController>()
// 监听路由状态
export function setNavigatingState(navigating: boolean) {
isNavigating = navigating
if (navigating) {
// 路由切换时,中断所有未完成的请求
console.log('Navigation started - aborting active requests')
abortAllActiveRequests()
}
}
// 中断所有活跃的请求
function abortAllActiveRequests() {
for (const controller of activeRequests) {
if (!controller.signal.aborted) {
controller.abort()
}
}
activeRequests.clear()
}
// 清理已完成的请求控制器
function cleanupController(controller: AbortController) {
activeRequests.delete(controller)
}
// 初始化请求优化器
export function initializeRequestOptimizer(axiosInstance: any) {
// 拦截请求,自动添加 AbortController
axiosInstance.interceptors.request.use(
(config: any) => {
// 如果请求已经有 signal跳过避免覆盖手动设置的
if (config.signal) {
return config
}
// 创建新的 AbortController
const controller = new AbortController()
config.signal = controller.signal
// 将控制器添加到活跃列表
activeRequests.add(controller)
// 监听请求完成事件来清理控制器
const cleanup = () => cleanupController(controller)
// 监听中断事件
controller.signal.addEventListener('abort', cleanup, { once: true })
return config
},
(error: any) => {
return Promise.reject(error)
},
)
// 拦截响应,清理对应的控制器
axiosInstance.interceptors.response.use(
(response: any) => {
// 从配置中获取 signal 对应的控制器并清理
if (response.config?.signal) {
const controller = Array.from(activeRequests).find(ctrl => ctrl.signal === response.config.signal)
if (controller) {
cleanupController(controller)
}
}
return response
},
(error: any) => {
// 错误时也要清理控制器
if (error.config?.signal) {
const controller = Array.from(activeRequests).find(ctrl => ctrl.signal === error.config.signal)
if (controller) {
cleanupController(controller)
}
}
return Promise.reject(error)
},
)
console.log('Request optimizer initialized - all requests will be auto-managed')
}
// 获取当前活跃请求数量(调试用)
export function getActiveRequestsCount() {
return activeRequests.size
}
// 手动中断所有请求(备用方法)
export function abortAllRequests() {
abortAllActiveRequests()
}

View File

@@ -112,6 +112,8 @@ async function getCpuUsage() {
try {
// 请求数据
current.value = (await api.get('dashboard/cpu')) ?? 0
// 使用nextTick确保DOM更新完成后再更新图表数据
await nextTick()
// 添加到序列
series.value[0].data.push(current.value)
// 序列超过30条记录时清掉前面的
@@ -122,10 +124,13 @@ async function getCpuUsage() {
}
onMounted(() => {
getCpuUsage() // 启动定时器
refreshTimer = setInterval(() => {
// 延迟启动,确保组件完全挂载
nextTick(() => {
getCpuUsage()
}, 2000)
refreshTimer = setInterval(() => {
getCpuUsage()
}, 2000)
})
})
// 组件卸载时停止定时器
@@ -137,7 +142,9 @@ onUnmounted(() => {
})
onActivated(() => {
chartKey.value += 1
nextTick(() => {
chartKey.value += 1
})
})
</script>

View File

@@ -118,6 +118,8 @@ async function getMemorgUsage() {
try {
// 请求数据
;[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')
// 使用nextTick确保DOM更新完成后再更新图表数据
await nextTick()
series.value[0].data.push(memoryUsage.value)
// 序列超过30条记录时清掉前面的
if (series.value[0].data.length > 30) series.value[0].data.shift()
@@ -127,11 +129,14 @@ async function getMemorgUsage() {
}
onMounted(() => {
getMemorgUsage()
// 启动定时器
refreshTimer = setInterval(() => {
// 延迟启动,确保组件完全挂载
nextTick(() => {
getMemorgUsage()
}, 3000)
// 启动定时器
refreshTimer = setInterval(() => {
getMemorgUsage()
}, 3000)
})
})
// 组件卸载时停止定时器
@@ -143,7 +148,10 @@ onUnmounted(() => {
})
onActivated(() => {
chartKey.value += 1
// 使用nextTick确保DOM准备完成后再更新chartKey
nextTick(() => {
chartKey.value += 1
})
})
</script>

View File

@@ -107,7 +107,8 @@ const totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0
async function getWeeklyData() {
try {
const res: number[] = await api.get('dashboard/transfer')
// 使用nextTick确保DOM更新完成后再更新图表数据
await nextTick()
series.value = [{ data: res }]
} catch (e) {
console.log(e)
@@ -115,11 +116,17 @@ async function getWeeklyData() {
}
onMounted(() => {
getWeeklyData()
// 延迟启动,确保组件完全挂载
nextTick(() => {
getWeeklyData()
})
})
onActivated(() => {
getWeeklyData()
// 使用nextTick确保DOM准备完成后再获取数据
nextTick(() => {
getWeeklyData()
})
})
</script>

View File

@@ -3,7 +3,6 @@ import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import SlideView from '@/components/slide/SlideView.vue'
import { registerAbortController } from '@/router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -28,10 +27,7 @@ const dataList = ref<MediaInfo[]>([])
async function fetchData() {
try {
if (!props.apipath) return
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
dataList.value = await api.get(props.apipath, { signal })
dataList.value = await api.get(props.apipath)
if (dataList.value.length > 0) componentLoaded.value = true
} catch (error) {
console.error(error)

View File

@@ -603,7 +603,7 @@ function pluginIcon(item: Plugin) {
if (pluginIconLoaded.value[item.id || '0'] === false) return noImage
// 如果是网络图片则使用代理后返回
if (item?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}&cache=true`
return `./plugin_icon/${item?.plugin_icon}`
}
@@ -1504,18 +1504,20 @@ function onDragStartPlugin(evt: any) {
<div v-if="isRefreshed">
<!-- 插件搜索图标 -->
<VFab
v-if="!appMode"
icon="mdi-magnify"
color="info"
location="bottom"
size="x-large"
fixed
app
appear
@click="SearchDialog = true"
:class="{ 'mb-12': appMode }"
/>
<Teleport to="body">
<VFab
v-if="!appMode"
icon="mdi-magnify"
color="info"
location="bottom"
size="x-large"
fixed
app
appear
@click="SearchDialog = true"
:class="{ 'mb-12': appMode }"
/>
</Teleport>
</div>
<!-- 插件市场设置窗口 -->
<PluginMarketSettingDialog

View File

@@ -698,29 +698,31 @@ onMounted(() => {
</VCard>
<!-- 底部操作按钮 -->
<div v-if="isRefreshed && selected.length > 0">
<VFab
icon="mdi-trash-can-outline"
color="error"
location="bottom"
size="x-large"
fixed
app
appear
@click="removeHistoryBatch"
:class="appMode ? 'mb-28' : 'mb-16'"
/>
<VFab
:class="appMode ? 'mb-44' : 'mb-32'"
icon="mdi-redo-variant"
location="bottom"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
</div>
<Teleport to="body">
<div v-if="isRefreshed && selected.length > 0">
<VFab
icon="mdi-trash-can-outline"
color="error"
location="bottom"
size="x-large"
fixed
app
appear
@click="removeHistoryBatch"
:class="appMode ? 'mb-28' : 'mb-16'"
/>
<VFab
:class="appMode ? 'mb-44' : 'mb-32'"
icon="mdi-redo-variant"
location="bottom"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
</div>
</Teleport>
<!-- 底部弹窗 -->
<VBottomSheet v-model="deleteConfirmDialog" inset>
<VCard class="text-center">

View File

@@ -9,6 +9,7 @@ import DirectoryCard from '@/components/cards/DirectoryCard.vue'
import StorageCard from '@/components/cards/StorageCard.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { storageAttributes } from '@/api/constants'
const { t } = useI18n()
@@ -33,6 +34,17 @@ const sourceItems = [
{ 'title': '豆瓣', 'value': 'douban' },
]
// 存储选项(排除已添加的)
const storageOptions = computed(() => {
const existingTypes = storages.value.map(storage => storage.type)
return storageAttributes
.filter(item => !existingTypes.includes(item.type))
.map(item => ({
title: t(`storage.${item.type}`),
value: item.type,
}))
})
// 系统设置
const SystemSettings = ref<any>({
Basic: {
@@ -156,10 +168,27 @@ async function loadMediaCategories() {
}
// 添加存储
function addStorage() {
function addStorage(storageType = 'custom') {
let name: string
let type: string
if (storageType === 'custom') {
// 自定义存储需要数字序号
name = `${t(`storage.${storageType}`)} ${storages.value.length + 1}`
while (storages.value.some(item => item.name === name)) {
const num = parseInt(name.match(/\d+$/)?.[0] || '1') + 1
name = `${t(`storage.${storageType}`)} ${num}`
}
type = `custom${storages.value.length + 1}`
} else {
// 预定义存储类型直接使用类型名称
name = t(`storage.${storageType}`)
type = storageType
}
storages.value.push({
name: `${t('storage.custom')} ${storages.value.length + 1}`,
type: `custom${storages.value.length + 1}`,
name: name,
type: type,
config: {},
})
}
@@ -172,14 +201,6 @@ function removeStorage(storage: StorageConf) {
}
}
// 更新存储
async function updatedStorage(storage: StorageConf) {
const index = storages.value.indexOf(storage)
if (index > -1) {
storages.value[index] = storage
}
}
// 保存设置
async function saveSystemSettings(value: any) {
try {
@@ -218,7 +239,7 @@ onMounted(() => {
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
>
<template #item="{ element }">
<StorageCard :storage="element" @close="removeStorage(element)" @done="updatedStorage" />
<StorageCard :storage="element" @close="removeStorage(element)" @done="loadStorages" />
</template>
</draggable>
</VCardText>
@@ -228,8 +249,18 @@ onMounted(() => {
<VBtn type="submit" class="me-2" @click="saveStorages" prepend-icon="mdi-content-save">
{{ t('common.save') }}
</VBtn>
<VBtn color="success" variant="tonal" @click="addStorage">
<VBtn color="success" variant="tonal">
<VIcon icon="mdi-plus" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem v-for="item in storageOptions" :key="item.value" @click="addStorage(item.value)">
<VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem>
<VListItem @click="addStorage('custom')">
<VListItemTitle>{{ t('storage.custom') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</div>
</VForm>

View File

@@ -22,6 +22,9 @@ const siteList = ref<Site[]>([])
// 站点数据列表
const userDataList = ref<SiteUserData[]>([])
// 站点统计数据列表
const siteStatsList = ref<{ [domain: string]: any }>({})
// 是否刷新过
const isRefreshed = ref(false)
@@ -31,6 +34,44 @@ const loading = ref(false)
// 新增站点对话框
const siteAddDialog = ref(false)
// 筛选相关
const filterMenu = ref(false)
const filterOption = ref('all') // all, active, inactive, connected, slow, failed, unknown
// 筛选选项
const filterOptions = computed(() => [
{ value: 'all', label: t('common.all'), icon: 'mdi-format-list-bulleted' },
{ value: 'active', label: t('common.active'), icon: 'mdi-check-circle', color: 'success' },
{ value: 'inactive', label: t('common.inactive'), icon: 'mdi-stop-circle', color: 'error' },
{ value: 'connected', label: t('site.connectionNormal'), icon: 'mdi-wifi', color: 'success' },
{ value: 'slow', label: t('site.connectionSlow'), icon: 'mdi-wifi-strength-2', color: 'warning' },
{ value: 'failed', label: t('site.connectionFailed'), icon: 'mdi-wifi-off', color: 'error' },
{ value: 'unknown', label: t('site.connectionUnknown'), icon: 'mdi-help-circle', color: 'secondary' },
])
// 筛选后的站点列表
const filteredSiteList = computed(() => {
if (filterOption.value === 'all') {
return siteList.value
}
return siteList.value.filter(site => {
if (filterOption.value === 'active') {
return site.is_active
} else if (filterOption.value === 'inactive') {
return !site.is_active
} else if (['connected', 'slow', 'failed', 'unknown'].includes(filterOption.value)) {
const connectionStatus = getConnectionStatus(site.domain)
return connectionStatus === filterOption.value
}
return true
})
})
// 当前筛选选项的显示信息
const currentFilter = computed(() => {
return filterOptions.value.find(option => option.value === filterOption.value)
})
// 获取站点列表数据
async function fetchData() {
try {
@@ -38,6 +79,8 @@ async function fetchData() {
siteList.value = await api.get('site/')
loading.value = false
isRefreshed.value = true
// 获取站点列表后,获取统计数据
await fetchSiteStats()
} catch (error) {
console.error(error)
}
@@ -52,10 +95,54 @@ async function fetchUserData() {
}
}
// 获取站点统计数据
async function fetchSiteStats() {
try {
// 使用批量接口一次性获取所有站点统计数据
const response = await api.get('site/statistic')
const stats = response.data || response
// 将数组转换为以domain为键的对象
const statsMap: { [domain: string]: any } = {}
if (Array.isArray(stats)) {
stats.forEach((stat: any) => {
if (stat.domain) {
statsMap[stat.domain] = stat
}
})
}
siteStatsList.value = statsMap
} catch (error) {
console.error('Failed to fetch site statistics:', error)
siteStatsList.value = {}
}
}
// 根据站点统计数据判断连接状态
function getConnectionStatus(domain: string) {
const stats = siteStatsList.value[domain]
if (!stats || Object.keys(stats).length === 0) {
return 'unknown'
}
if (stats.lst_state === 1) {
return 'failed'
} else if (stats.lst_state === 0) {
if (!stats.seconds) return 'unknown'
if (stats.seconds >= 5) return 'slow'
return 'connected'
}
return 'unknown'
}
// 保存站点排序
async function savaSitesPriority() {
// 只在显示全部站点时允许排序
if (filterOption.value !== 'all') {
return
}
// 重新排序
const priorities = siteList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
const priorities = filteredSiteList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
try {
const result: { [key: string]: any } = await api.post('site/priorities', priorities)
if (result.success) {
@@ -71,12 +158,39 @@ function getUserData(domain: string) {
return userDataList.value.find(userData => userData.domain === domain)
}
// 根据站点域名获取统计数据
function getSiteStats(domain: string) {
return siteStatsList.value[domain] || {}
}
// 处理站点统计数据刷新请求
async function handleRefreshStats(domain?: string) {
if (domain) {
// 刷新特定站点的统计数据
try {
const stats = await api.get(`site/statistic/${domain}`)
siteStatsList.value[domain] = stats
} catch (error) {
console.error(`Failed to refresh stats for ${domain}:`, error)
}
} else {
// 刷新所有站点统计数据
await fetchSiteStats()
}
}
// 更新站点事件时
function onSiteSave() {
siteAddDialog.value = false
fetchData()
}
// 选择筛选选项
function selectFilter(value: string) {
filterOption.value = value
filterMenu.value = false
}
// 加载时获取数据
onBeforeMount(() => {
fetchData()
@@ -101,41 +215,92 @@ useDynamicButton({
<template>
<div class="card-list-container">
<!-- 页面标题 -->
<VPageContentTitle :title="t('navItems.siteManager')" />
<!-- 页面标题和筛选按钮 -->
<div class="d-flex justify-space-between align-center mb-4">
<VPageContentTitle :title="t('navItems.siteManager')" class="mb-0" />
<!-- 筛选按钮 -->
<VMenu v-model="filterMenu" offset-y :close-on-content-click="false" location="bottom end">
<template #activator="{ props }">
<VBtn
v-bind="props"
:icon="display.smAndDown.value"
:variant="filterOption === 'all' ? 'text' : 'tonal'"
:color="currentFilter?.color"
>
<VIcon :icon="currentFilter?.icon || 'mdi-filter'" />
<span v-if="!display.smAndDown.value" class="ml-2">
{{ currentFilter?.label }}
</span>
<VIcon v-if="!display.smAndDown.value" icon="mdi-chevron-down" class="ml-1" />
</VBtn>
</template>
<!-- 筛选菜单 -->
<VCard min-width="200">
<VList class="px-2">
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
<VListItem
v-for="option in filterOptions"
:key="option.value"
:active="filterOption === option.value"
@click="selectFilter(option.value)"
>
<template #prepend>
<VIcon :icon="option.icon" :color="option.color" />
</template>
<VListItemTitle>{{ option.label }}</VListItemTitle>
<template #append>
<VIcon v-if="filterOption === option.value" icon="mdi-check" color="primary" />
</template>
</VListItem>
</VList>
</VCard>
</VMenu>
</div>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<draggable
v-if="siteList.length > 0"
v-model="siteList"
v-if="filteredSiteList.length > 0"
v-model="filteredSiteList"
@end="savaSitesPriority"
handle=".cursor-move"
item-key="id"
tag="div"
:component-data="{ 'class': 'grid gap-4 grid-site-card px-2' }"
:disabled="filterOption !== 'all'"
>
<template #item="{ element }">
<SiteCard :site="element" :data="getUserData(element.domain)" @remove="fetchData" @update="fetchData" />
<SiteCard
:site="element"
:data="getUserData(element.domain)"
:stats="getSiteStats(element.domain)"
@remove="fetchData"
@update="fetchData"
@refresh-stats="handleRefreshStats"
/>
</template>
</draggable>
</div>
<NoDataFound
v-if="siteList.length === 0 && isRefreshed"
v-if="filteredSiteList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('site.noSites')"
:error-description="t('site.sitesWillBeShownHere')"
:error-title="filterOption === 'all' ? t('site.noSites') : t('common.noMatchingData')"
:error-description="filterOption === 'all' ? t('site.sitesWillBeShownHere') : t('common.tryChangingFilters')"
/>
<!-- 新增站点按钮 -->
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-web-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="siteAddDialog = true"
:class="{ 'mb-12': appMode }"
/>
<Teleport to="body">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-web-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="siteAddDialog = true"
:class="{ 'mb-12': appMode }"
/>
</Teleport>
<!-- 新增站点弹窗 -->
<SiteAddEditDialog
v-if="siteAddDialog"

View File

@@ -213,9 +213,9 @@ onActivated(() => {
.v-application .fc {
--fc-today-bg-color: rgba(var(--v-theme-on-surface), 0.04);
--fc-border-color: rgba(var(--v-border-color), var(--v-border-opacity));
--fc-neutral-bg-color: rgb(var(--v-theme-background));
--fc-neutral-bg-color: rgb(var(--v-theme-background), 0.3);
--fc-list-event-hover-bg-color: rgba(var(--v-theme-on-surface), 0.02);
--fc-page-bg-color: rgb(var(--v-theme-surface));
--fc-page-bg-color: rgb(var(--v-theme-background), 0.3);
--fc-event-border-color: currentcolor;
}
@@ -232,6 +232,16 @@ onActivated(() => {
padding: 0;
}
.v-application .fc .fc-toolbar-title {
display: inline-block;
overflow: hidden;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.25rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.v-application .fc .fc-col-header-cell-cushion {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.875rem;
@@ -309,6 +319,22 @@ onActivated(() => {
row-gap: 0.5rem;
}
.v-application .fc .fc-button-primary {
border: none;
background-color: transparent;
color: var(--v-theme-on-surface);
outline: none;
&:hover {
background-color: transparent;
color: rgb(var(--v-theme-primary));
}
}
.v-application .fc .fc-toolbar-chunk .fc-button-group {
align-items: center;
}
.v-application .fc .fc-toolbar-chunk {
display: flex;
align-items: center;
@@ -322,10 +348,6 @@ onActivated(() => {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary:focus {
box-shadow: none !important;
}
.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group {
border: 0.0625rem solid rgba(var(--v-theme-primary), var(--v-overlay-scrim-opacity));
border-radius: 0.375rem;
@@ -349,16 +371,6 @@ onActivated(() => {
color: rgb(var(--v-theme-primary));
}
.v-application .fc .fc-toolbar-title {
display: inline-block;
overflow: hidden;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.25rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.v-application .fc .fc-scrollgrid-section th {
border-inline: 0;
}
@@ -424,10 +436,6 @@ onActivated(() => {
font-weight: 500;
}
.v-application .fc .fc-toolbar-chunk .fc-button-group {
align-items: center;
}
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button .fc-icon {
vertical-align: bottom;
}
@@ -483,18 +491,6 @@ onActivated(() => {
background-color: transparent;
}
.v-application .fc .fc-button-primary {
border: none;
background-color: transparent;
color: var(--v-theme-on-surface);
outline: none;
}
.v-application .fc .fc-button-primary:hover {
background-color: transparent;
color: rgb(var(--v-theme-primary));
}
@media (width <= 776px) {
.fc-daygrid-event-harness {
display: flex;

View File

@@ -181,20 +181,22 @@ useDynamicButton({
:error-description="keyword ? t('subscribe.noFilterData') : t('subscribe.noSubscribeData')"
/>
<!-- 底部操作按钮 -->
<div v-if="isRefreshed">
<VFab
v-if="userStore.superUser && !appMode"
icon="mdi-history"
color="info"
location="bottom"
:class="{ 'mb-12': appMode }"
size="x-large"
fixed
app
appear
@click="historyDialog = true"
/>
</div>
<Teleport to="body">
<div v-if="isRefreshed">
<VFab
v-if="userStore.superUser && !appMode"
icon="mdi-history"
color="info"
location="bottom"
:class="{ 'mb-12': appMode }"
size="x-large"
fixed
app
appear
@click="historyDialog = true"
/>
</div>
</Teleport>
<!-- 历史记录弹窗 -->
<SubscribeHistoryDialog
v-if="historyDialog"

View File

@@ -121,7 +121,7 @@ onBeforeUnmount(() => {
:mode="!isLoaded ? 'intersect' : 'manual'"
side="start"
:items="messages"
class="overflow-visible message-scroll h-full"
class="overflow-auto h-full"
@load="loadMessages"
:load-more-text="t('message.loadMore') + ' ...'"
>
@@ -143,9 +143,3 @@ onBeforeUnmount(() => {
</div>
</VInfiniteScroll>
</template>
<style scoped>
.message-scroll {
overflow-y: auto !important;
}
</style>

View File

@@ -96,17 +96,19 @@ useDynamicButton({
</div>
<!-- 新增用户按钮 -->
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-account-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="openAddUserDialog"
:class="{ 'mb-12': appMode }"
/>
<Teleport to="body">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-account-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="openAddUserDialog"
:class="{ 'mb-12': appMode }"
/>
</Teleport>
<!-- 用户添加弹窗 -->
<UserAddEditDialog

View File

@@ -72,17 +72,19 @@ useDynamicButton({
</div>
<!-- 新增按钮 -->
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-plus"
location="bottom"
size="x-large"
fixed
app
appear
:class="{ 'mb-12': appMode }"
@click="addDialog = true"
/>
<Teleport to="body">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-plus"
location="bottom"
size="x-large"
fixed
app
appear
:class="{ 'mb-12': appMode }"
@click="addDialog = true"
/>
</Teleport>
<!-- 新增对话框 -->
<WorkflowAddEditDialog v-if="addDialog" v-model="addDialog" @close="addDialog = false" @save="addDone" />
</template>