diff --git a/package.json b/package.json index 8defd4fd..6b48939a 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@iconify-json/lucide": "^1.2.85", "@iconify-json/material-symbols": "^1.2.51", "@iconify-json/mdi": "^1.1.52", + "@iconify-json/tabler": "^1.2.23", "@iconify/tools": "^4.0.4", "@iconify/vue": "^4.3.0", "@intlify/unplugin-vue-i18n": "^6.0.3", diff --git a/src/@iconify/build-icons.ts b/src/@iconify/build-icons.ts index 519407d8..f6ced384 100644 --- a/src/@iconify/build-icons.ts +++ b/src/@iconify/build-icons.ts @@ -17,6 +17,7 @@ import { createRequire } from 'node:module' // Get current directory const __dirname = dirname(fileURLToPath(import.meta.url)) +const projectSrcDir = join(__dirname, '..') // Create require function for importing JSON files in ESM const require = createRequire(import.meta.url) @@ -86,36 +87,12 @@ const sources: BundleScriptConfig = { ], icons: [ - // 'mdi:home', - // 'mdi:account', - // 'mdi:login', - // 'mdi:logout', - // 'octicon:book-24', - // 'octicon:code-square-24', 'lucide:sparkles', 'material-symbols:passkey', 'line-md:loading-twotone-loop', ], - json: [ - // Custom JSON file - // 'json/gg.json', - - // Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename) - require.resolve('@iconify-json/mdi/icons.json'), - - // Custom file with only few icons - // { - // filename: require.resolve('@iconify-json/line-md/icons.json'), - // icons: [ - // 'home-twotone-alt', - // 'github', - // 'document-list', - // 'document-code', - // 'image-twotone', - // ], - // }, - ], + json: [], } // Iconify component (this changes import statement in generated file) @@ -133,6 +110,15 @@ const target = join(__dirname, 'icons-bundle.js'); */ // eslint-disable-next-line sonarjs/cognitive-complexity (async function () { + const scannedIcons = await collectUsedIcons(projectSrcDir) + + if (sources.icons) { + sources.icons.push(...scannedIcons) + sources.icons = Array.from(new Set(sources.icons)).sort() + } else { + sources.icons = scannedIcons + } + let bundle = commonJS ? `const { addCollection } = require('${component}');\n\n` : `import { addCollection } from '${component}';\n\n` @@ -280,6 +266,56 @@ const target = join(__dirname, 'icons-bundle.js'); console.error(err) }) +async function collectUsedIcons(rootDir: string): Promise { + const icons = new Set() + const files = await walkDirectory(rootDir) + const sourceFiles = files.filter(file => /\.(vue|ts|js|tsx|jsx)$/.test(file)) + + for (const file of sourceFiles) { + if (file.includes('/@iconify/')) { + continue + } + + const content = await fs.readFile(file, 'utf8') + + for (const match of content.matchAll(/\b(lucide|material-symbols|line-md|tabler):([a-z0-9-]+)\b/g)) { + icons.add(`${match[1]}:${match[2]}`) + } + + for (const match of content.matchAll(/\bmdi:([a-z0-9-]+)\b/g)) { + icons.add(`mdi:${match[1]}`) + } + + for (const match of content.matchAll(/\btabler-([a-z0-9-]+)\b/g)) { + icons.add(`tabler:${match[1]}`) + } + + for (const match of content.matchAll(/\bmdi-([a-z0-9-]+)\b/g)) { + icons.add(`mdi:${match[1]}`) + } + } + + return Array.from(icons).sort() +} + +async function walkDirectory(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + + if (entry.isDirectory()) { + files.push(...(await walkDirectory(fullPath))) + continue + } + + files.push(fullPath) + } + + return files +} + /** * Remove metadata from icon set */ diff --git a/src/@layouts/components/VerticalNavLayout.vue b/src/@layouts/components/VerticalNavLayout.vue index 0397d48c..61e8b55b 100644 --- a/src/@layouts/components/VerticalNavLayout.vue +++ b/src/@layouts/components/VerticalNavLayout.vue @@ -19,6 +19,11 @@ export default defineComponent({ const scrollDistance = ref(window.scrollY) const isDialogOpen = ref(false) const wasScrolledBeforeDialog = ref(false) + let dialogObserver: MutationObserver | null = null + + const handleScroll = () => { + scrollDistance.value = window.scrollY + } // 监听弹窗状态变化 const checkDialogState = () => { @@ -32,21 +37,25 @@ export default defineComponent({ } onMounted(() => { - window.addEventListener('scroll', () => { - scrollDistance.value = window.scrollY - }) + window.addEventListener('scroll', handleScroll) // 初始检查弹窗状态 checkDialogState() // 监听 DOM 变化以检测弹窗状态 - const observer = new MutationObserver(checkDialogState) - observer.observe(document.documentElement, { + dialogObserver = new MutationObserver(checkDialogState) + dialogObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'], }) }) + onBeforeUnmount(() => { + window.removeEventListener('scroll', handleScroll) + dialogObserver?.disconnect() + dialogObserver = null + }) + return () => { // 👉 Vertical nav const verticalNav = h( diff --git a/src/App.vue b/src/App.vue index ccc5e112..f8c88f0f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,6 +12,7 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue' import { themeManager } from '@/utils/themeManager' +import { configureApexChartsTheme } from '@/utils/apexCharts' // 生效主题 const { global: globalTheme } = useTheme() @@ -41,13 +42,6 @@ const isTransparentTheme = computed(() => globalTheme.name.value === 'transparen // 心跳检测 let heartbeatInterval: number | null = null -// ApexCharts 全局配置 -declare global { - interface Window { - Apex: any - } -} - // 启动心跳 const startHeartbeat = () => { // 如果已经有心跳,则先停止 @@ -75,44 +69,6 @@ const stopHeartbeat = () => { } } -// 配置 ApexCharts 全局选项 -function configureApexCharts() { - if (typeof window !== 'undefined' && window.Apex) { - try { - // 获取当前主题 - const currentTheme = globalTheme.name.value - const isDark = currentTheme === 'dark' || currentTheme === 'transparent' - - // 数据标签 - 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))', - }, - } - // 鼠标悬浮提示 - window.Apex.tooltip = { - theme: isDark ? 'dark' : 'light', - } - } catch (error) { - console.warn('ApexCharts 全局配置失败:', error) - } - } -} - // 更新data-theme属性以便CSS选择器能正确匹配 function updateHtmlThemeAttribute(themeName: string) { document.documentElement.setAttribute('data-theme', themeName) @@ -250,7 +206,7 @@ onMounted(async () => { } // 配置 ApexCharts - configureApexCharts() + configureApexChartsTheme(globalTheme.name.value) // 初始化data-theme属性 updateHtmlThemeAttribute(globalTheme.name.value) @@ -265,7 +221,7 @@ onMounted(async () => { // 更新HTML主题属性 updateHtmlThemeAttribute(newTheme) // 重新配置ApexCharts以适应新主题 - configureApexCharts() + configureApexChartsTheme(newTheme) }, ) diff --git a/src/components/cards/MediaCard.vue b/src/components/cards/MediaCard.vue index 5fb8642a..fcf59935 100644 --- a/src/components/cards/MediaCard.vue +++ b/src/components/cards/MediaCard.vue @@ -14,6 +14,12 @@ import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue' import { useI18n } from 'vue-i18n' import { mediaTypeDict } from '@/api/constants' import { hasPermission } from '@/utils/permission' +import { + getCachedMediaExistsStatus, + getCachedMediaSubscribeStatus, + setCachedMediaExistsStatus, + setCachedMediaSubscribeStatus, +} from '@/utils/mediaStatusCache' // 国际化 const { t } = useI18n() @@ -123,6 +129,22 @@ function getMediaId() { else return `${props.media?.mediaid_prefix}:${props.media?.media_id}` } +function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) { + return `${getMediaId()}::${season ?? 'all'}` +} + +function getExistsStatusKey() { + return [ + props.media?.tmdb_id ?? '', + props.media?.title ?? '', + props.media?.year ?? '', + props.media?.season ?? '', + props.media?.type ?? '', + props.media?.mediaid_prefix ?? '', + props.media?.media_id ?? '', + ].join('::') +} + // 角标颜色 function getChipColor(type: string) { if (type === '电影') return 'border-blue-500 bg-blue-600' @@ -167,6 +189,7 @@ async function addSubscribe(season: number | null = null, best_version: number = if (result.success) { // 订阅成功 isSubscribed.value = true + setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true) } // 提示 @@ -213,6 +236,7 @@ async function removeSubscribe() { if (result.success) { isSubscribed.value = false + setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false) $toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`) } else { $toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`) @@ -227,8 +251,10 @@ async function removeSubscribe() { // 查询当前媒体是否已订阅 async function handleCheckSubscribe() { try { - const result = await checkSubscribe(props.media?.season ?? null) - if (result) isSubscribed.value = true + const subscribed = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), () => + checkSubscribe(props.media?.season ?? null), + ) + isSubscribed.value = subscribed } catch (error) { console.error(error) } @@ -237,17 +263,22 @@ async function handleCheckSubscribe() { // 查询当前媒体是否已入库 async function handleCheckExists() { try { - const result: { [key: string]: any } = await api.get('mediaserver/exists', { - params: { - tmdbid: props.media?.tmdb_id, - title: props.media?.title, - year: props.media?.year, - season: props.media?.season, - mtype: props.media?.type, - }, + const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => { + const result: { [key: string]: any } = await api.get('mediaserver/exists', { + params: { + tmdbid: props.media?.tmdb_id, + title: props.media?.title, + year: props.media?.year, + season: props.media?.season, + mtype: props.media?.type, + }, + }) + + return Boolean(result.success) }) - if (result.success) isExists.value = true + isExists.value = exists + setCachedMediaExistsStatus(getExistsStatusKey(), exists) } catch (error) { console.error(error) } @@ -265,12 +296,14 @@ async function checkSubscribe(season: number | null) { }, }) - return result.id || null - } catch (error) { - console.error(error) - } + return Boolean(result.id) + } catch (error: any) { + if (error?.response?.status === 404) { + return false + } - return null + throw error + } } // 查询订阅弹窗规则 diff --git a/src/components/filebrowser/FileList.vue b/src/components/filebrowser/FileList.vue index dc4de279..b046d33a 100644 --- a/src/components/filebrowser/FileList.vue +++ b/src/components/filebrowser/FileList.vue @@ -148,7 +148,12 @@ const transferItems = ref([]) // 当前图片地址 const currentImgLink = ref('') +function revokeCurrentImgLink() { + if (!currentImgLink.value) return + URL.revokeObjectURL(currentImgLink.value) + currentImgLink.value = '' +} // 是否为图片文件 const isImage = computed(() => { @@ -287,6 +292,9 @@ async function download(item: FileItem) { if (result) { const downloadUrl = URL.createObjectURL(result) window.open(downloadUrl, '_blank') + setTimeout(() => { + URL.revokeObjectURL(downloadUrl) + }, 60000) } } @@ -304,6 +312,7 @@ async function getImgLink(item: FileItem) { const result: Blob = (await inProps.axios.request(config)) if (result) { // 创建图片地址 + revokeCurrentImgLink() currentImgLink.value = URL.createObjectURL(result) } } @@ -314,7 +323,10 @@ watch( async () => { if (isImage.value && isFile.value) { await getImgLink(inProps.item) + return } + + revokeCurrentImgLink() }, { immediate: true }, ) @@ -597,6 +609,11 @@ function stopLoadingProgress() { onMounted(() => { list_files() }) + +onUnmounted(() => { + revokeCurrentImgLink() + stopLoadingProgress() +}) diff --git a/src/components/slide/VirtualSlideView.vue b/src/components/slide/VirtualSlideView.vue new file mode 100644 index 00000000..658f8bfc --- /dev/null +++ b/src/components/slide/VirtualSlideView.vue @@ -0,0 +1,414 @@ + + + + + diff --git a/src/composables/useBackgroundOptimization.ts b/src/composables/useBackgroundOptimization.ts index 5eedb81e..90198b63 100644 --- a/src/composables/useBackgroundOptimization.ts +++ b/src/composables/useBackgroundOptimization.ts @@ -28,11 +28,24 @@ export function useBackgroundOptimization() { // 使用独立的SSE管理器,确保每个监听器都有独立的连接 const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options) const isConnected = ref(false) + let connectTimer: ReturnType | null = null + + const cleanup = () => { + if (connectTimer) { + clearTimeout(connectTimer) + connectTimer = null + } + + manager.removeMessageListener(listenerId) + sseManagerSingleton.closeIndependentManager(url, listenerId) + isConnected.value = false + } onMounted(() => { // 延迟建立连接,确保组件完全挂载 const connectDelay = options?.connectDelay || 100 - setTimeout(() => { + connectTimer = setTimeout(() => { + connectTimer = null try { manager.addMessageListener(listenerId, event => { messageHandler(event) @@ -44,15 +57,12 @@ export function useBackgroundOptimization() { }, connectDelay) }) - onUnmounted(() => { - manager.removeMessageListener(listenerId) - isConnected.value = false - }) + onUnmounted(cleanup) return { manager, readyState: () => manager.readyState, - close: () => manager.removeMessageListener(listenerId), + close: cleanup, isConnected, forceReconnect: () => manager.forceReconnect(), } @@ -104,21 +114,31 @@ export function useBackgroundOptimization() { ) => { // 使用独立的SSE管理器,确保每个监听器都有独立的连接 const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options) + let connectTimer: ReturnType | null = null + + const cleanup = () => { + if (connectTimer) { + clearTimeout(connectTimer) + connectTimer = null + } + + manager.removeMessageListener(listenerId) + sseManagerSingleton.closeIndependentManager(url, listenerId) + } onMounted(() => { - setTimeout(() => { + connectTimer = setTimeout(() => { + connectTimer = null manager.addMessageListener(listenerId, messageHandler) }, delay) }) - onUnmounted(() => { - manager.removeMessageListener(listenerId) - }) + onUnmounted(cleanup) return { manager, readyState: () => manager.readyState, - close: () => manager.removeMessageListener(listenerId), + close: cleanup, } } @@ -135,31 +155,50 @@ export function useBackgroundOptimization() { listenerId: string, isActive: Ref, ) => { - // 使用独立的SSE管理器,确保每个监听器都有独立的连接 - const manager = sseManagerSingleton.getIndependentManager(url, listenerId, { - backgroundCloseDelay: 1000, // 进度SSE更快关闭 - reconnectDelay: 1000, - maxReconnectAttempts: 5, - }) + const getManager = () => + sseManagerSingleton.getIndependentManager(url, listenerId, { + backgroundCloseDelay: 1000, // 进度SSE更快关闭 + reconnectDelay: 1000, + maxReconnectAttempts: 5, + }) + + let manager: ReturnType | null = null + let isListening = false const startProgress = () => { - if (isActive.value) { - manager.addMessageListener(listenerId, messageHandler) - } + if (!isActive.value || isListening) return + + manager ??= getManager() + manager.addMessageListener(listenerId, messageHandler) + isListening = true } - const stopProgress = () => { + const stopProgress = (destroyManager = true) => { + if (!manager) { + isListening = false + return + } + manager.removeMessageListener(listenerId) + + if (destroyManager) { + sseManagerSingleton.closeIndependentManager(url, listenerId) + manager = null + } + + isListening = false } onUnmounted(() => { - stopProgress() + stopProgress(true) }) return { start: startProgress, stop: stopProgress, - manager, + get manager() { + return manager + }, } } diff --git a/src/layouts/components/UserNotification.vue b/src/layouts/components/UserNotification.vue index 672217f2..b9b0a101 100644 --- a/src/layouts/components/UserNotification.vue +++ b/src/layouts/components/UserNotification.vue @@ -12,6 +12,7 @@ const hasNewMessage = ref(false) // 通知列表 const notificationList = ref([]) +const MAX_NOTIFICATIONS = 100 // 弹窗 const appsMenu = ref(false) @@ -31,6 +32,9 @@ function handleMessage(event: MessageEvent) { if (event.data) { const noti: SystemNotification = JSON.parse(event.data) notificationList.value.unshift(noti) + if (notificationList.value.length > MAX_NOTIFICATIONS) { + notificationList.value.length = MAX_NOTIFICATIONS + } hasNewMessage.value = true } } diff --git a/src/layouts/default.vue b/src/layouts/default.vue index 27616387..5019cc69 100644 --- a/src/layouts/default.vue +++ b/src/layouts/default.vue @@ -7,7 +7,7 @@ const route = useRoute()