mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-11 10:00:08 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
615c162663 | ||
|
|
c4bd15e5a0 | ||
|
|
edc92905f7 | ||
|
|
bf5bbd3689 | ||
|
|
eb70ca233b | ||
|
|
8718816fce | ||
|
|
7d36330b4b | ||
|
|
1fa0474fef | ||
|
|
4070b27148 | ||
|
|
3892b0ed05 | ||
|
|
a06cf69d7a | ||
|
|
61dc2568e8 | ||
|
|
ac6362e698 | ||
|
|
94afdf5495 | ||
|
|
d96f8acdbc | ||
|
|
d39c795f92 | ||
|
|
8e12e0562b | ||
|
|
7a1babb418 | ||
|
|
8d65f0c2a8 | ||
|
|
b8dff560f0 | ||
|
|
b48c26ee73 | ||
|
|
8328e51ae0 | ||
|
|
7070eb8a7d | ||
|
|
d0aa26441c | ||
|
|
1bba7103c8 | ||
|
|
7f8dd744f2 | ||
|
|
2f4a707498 | ||
|
|
569bc3c8ec | ||
|
|
b01421aa94 | ||
|
|
30d933bd85 | ||
|
|
377998335b | ||
|
|
21d21aa438 | ||
|
|
18cf1ea3d7 | ||
|
|
60ea884fe2 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.5.9-1",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
109
src/App.vue
109
src/App.vue
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -769,6 +769,8 @@ export interface MetaInfo {
|
||||
audio_term: string
|
||||
// 资源类型+特效
|
||||
edition: string
|
||||
// 流媒体平台
|
||||
web_source: string
|
||||
// 应用的自定义识别词
|
||||
apply_words: string[]
|
||||
}
|
||||
|
||||
BIN
src/assets/images/misc/smb.png
Normal file
BIN
src/assets/images/misc/smb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
|
||||
@@ -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`
|
||||
})
|
||||
|
||||
// 重置插件
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
131
src/components/dialog/SmbConfigDialog.vue
Normal file
131
src/components/dialog/SmbConfigDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -27,7 +27,7 @@ const props = defineProps({
|
||||
},
|
||||
endpoints: Object,
|
||||
axios: {
|
||||
type: Object as PropType<any>,
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ const inProps = defineProps({
|
||||
},
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Object as PropType<any>,
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
255
src/composables/usePullDownGesture.ts
Normal file
255
src/composables/usePullDownGesture.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
113
src/composables/useRecentPlugins.ts
Normal file
113
src/composables/useRecentPlugins.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
|
||||
const RECENT_PLUGINS_KEY = 'moviepilot_recent_plugins'
|
||||
const MAX_RECENT_PLUGINS = 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,
|
||||
}
|
||||
}
|
||||
159
src/composables/useScrollLock.ts
Normal file
159
src/composables/useScrollLock.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
709
src/layouts/components/QuickAccess.vue
Normal file
709
src/layouts/components/QuickAccess.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '个人信息',
|
||||
|
||||
@@ -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: '個人信息',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
98
src/utils/requestOptimizer.ts
Normal file
98
src/utils/requestOptimizer.ts
Normal 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()
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user