From dbeea6afcc690f2d9cb6be05ba90542a0c69f5e0 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 9 May 2026 08:32:14 +0800 Subject: [PATCH] perf: reduce frontend memory pressure and startup cost Limit long-lived page and component retention while virtualizing large card views to keep runtime memory lower. Defer heavy editor, chart, workflow, calendar, and icon code so the app loads less JavaScript up front. --- package.json | 1 + src/@iconify/build-icons.ts | 86 ++-- src/@layouts/components/VerticalNavLayout.vue | 19 +- src/App.vue | 50 +-- src/components/cards/MediaCard.vue | 65 ++- src/components/filebrowser/FileList.vue | 17 + src/components/misc/VirtualCardGrid.vue | 184 ++++++++ src/components/slide/VirtualSlideView.vue | 414 ++++++++++++++++++ src/composables/useBackgroundOptimization.ts | 85 +++- src/layouts/components/UserNotification.vue | 4 + src/layouts/default.vue | 2 +- src/main.ts | 68 +-- src/router/index.ts | 6 - src/types/iconify-bundle.d.ts | 1 + src/utils/apexCharts.ts | 40 ++ src/utils/mediaStatusCache.ts | 77 ++++ src/utils/sseManager.ts | 67 ++- src/utils/themeManager.ts | 24 +- src/views/discover/MediaCardListView.vue | 90 ++-- src/views/discover/MediaCardSlideView.vue | 19 +- src/views/discover/PersonCardListView.vue | 41 +- src/views/discover/PersonCardSlideView.vue | 56 ++- vite.config.ts | 29 ++ yarn.lock | 27 +- 24 files changed, 1224 insertions(+), 248 deletions(-) create mode 100644 src/components/misc/VirtualCardGrid.vue create mode 100644 src/components/slide/VirtualSlideView.vue create mode 100644 src/types/iconify-bundle.d.ts create mode 100644 src/utils/apexCharts.ts create mode 100644 src/utils/mediaStatusCache.ts 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()