diff --git a/index.html b/index.html index 02372852..f05a5787 100644 --- a/index.html +++ b/index.html @@ -34,7 +34,7 @@ - + @@ -91,10 +91,6 @@ - - - - @@ -254,114 +346,15 @@
+ +
diff --git a/package.json b/package.json index 8eee3b2a..1c7c8d4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moviepilot", - "version": "2.8.8", + "version": "2.9.4", "private": true, "type": "module", "bin": "dist/service.js", @@ -51,11 +51,13 @@ "http-proxy-middleware": "^3.0.0", "js-cookie": "^3.0.5", "lodash-es": "^4.17.21", + "markdown-it": "^14.1.0", + "markdown-it-link-attributes": "^4.0.1", "mousetrap": "^1.6.5", "nprogress": "^0.2.0", "pinia": "^3.0.1", "pinia-plugin-persistedstate": "^4.2.0", - "qrcode.vue": "^3.6.0", + "qrcode": "^1.5.4", "sass": "^1.83.4", "tailwindcss": "^ 3.4.17", "vue": "^3.5.13", @@ -77,9 +79,12 @@ "@tailwindcss/aspect-ratio": "^0.4.2", "@types/body-scroll-lock": "^3.1.2", "@types/lodash-es": "^4.17.12", + "@types/markdown-it": "^14.1.2", + "@types/markdown-it-link-attributes": "^3.0.5", "@types/mousetrap": "^1.6.15", "@types/node": "^20.1.4", "@types/nprogress": "^0.2.3", + "@types/qrcode": "^1.5.6", "@types/webfontloader": "^1.6.34", "@typescript-eslint/eslint-plugin": "^8.20.0", "@typescript-eslint/parser": "^8.20.0", diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png deleted file mode 100644 index d7aac7be..00000000 Binary files a/public/apple-touch-icon-precomposed.png and /dev/null differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index d7aac7be..c319eabf 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/shims.d.ts b/shims.d.ts index 35041540..c044f34a 100644 --- a/shims.d.ts +++ b/shims.d.ts @@ -12,3 +12,4 @@ declare module 'vue-prism-component' { export default component } declare module 'vue-shepherd'; +declare module 'colorthief'; diff --git a/src/@core/scss/_mixins.scss b/src/@core/scss/_mixins.scss index bdf641e9..301ed153 100644 --- a/src/@core/scss/_mixins.scss +++ b/src/@core/scss/_mixins.scss @@ -1,5 +1,5 @@ @use "sass:map"; -@use "vuetify/lib/styles/settings" as vuetify_settings; +@use "vuetify/lib/styles/settings/_index.sass" as vuetify_settings; @use "@styles/variables/_vuetify.scss" as vuetify; @mixin themed($property, $light-value, $dark-value) { diff --git a/src/@core/utils/navigator.ts b/src/@core/utils/navigator.ts index d2bb3fb6..e75c243b 100644 --- a/src/@core/utils/navigator.ts +++ b/src/@core/utils/navigator.ts @@ -35,6 +35,19 @@ export function urlBase64ToUint8Array(base64String: string) { return outputArray } +// Uint8Array 转 Base64URL +export function bufferToBase64Url(buffer: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +// Base64URL 转 Uint8Array +export function base64UrlToUint8Array(base64Url: string): Uint8Array { + return Uint8Array.from(atob(base64Url.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) +} + // 判断是否为PWA export const isPWA = async (): Promise => { if ('serviceWorker' in navigator) { diff --git a/src/@validators/index.ts b/src/@validators/index.ts index 58cef60b..0ce5a144 100644 --- a/src/@validators/index.ts +++ b/src/@validators/index.ts @@ -1,4 +1,4 @@ -import type { ValidationRule } from 'vuetify/types/services/validation' +type ValidationRule = (value: any) => string | boolean // 必输校验 export const requiredValidator: ValidationRule = (value: any) => { diff --git a/src/App.vue b/src/App.vue index 588e571c..29da4893 100644 --- a/src/App.vue +++ b/src/App.vue @@ -15,7 +15,7 @@ import { themeManager } from '@/utils/themeManager' // 生效主题 const { global: globalTheme } = useTheme() -let themeValue = localStorage.getItem('theme') || 'light' +let themeValue = localStorage.getItem('theme') || 'auto' const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light' globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue @@ -237,6 +237,14 @@ async function loadBackgroundImages(retryCount = 0) { } onMounted(async () => { + // 移除URL中的时间戳参数 + const url = new URL(window.location.href) + if (url.searchParams.has('_t')) { + url.searchParams.delete('_t') + const newUrl = url.pathname + url.search + url.hash + window.history.replaceState(null, '', newUrl) + } + // 配置 ApexCharts configureApexCharts() diff --git a/src/api/types.ts b/src/api/types.ts index 0c4134dc..e9dd2908 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -861,6 +861,16 @@ export interface User { nickname?: string } +// 通行密钥 +export interface PassKey { + id: number + name: string + created_at: string + last_used_at?: string + aaguid?: string + transports?: string +} + // 存储空间 export interface Storage { // 总空间 @@ -1429,3 +1439,10 @@ export interface SubscribeShareStatistics { // 总复用人次 total_reuse_count?: number } + +// 通用API响应 +export interface ApiResponse { + success: boolean + message?: string + data: T +} \ No newline at end of file diff --git a/src/components/FileBrowser.vue b/src/components/FileBrowser.vue index c8f1817c..eec6b6a5 100644 --- a/src/components/FileBrowser.vue +++ b/src/components/FileBrowser.vue @@ -4,6 +4,7 @@ import FileToolbar from './filebrowser/FileToolbar.vue' import FileNavigator from './filebrowser/FileNavigator.vue' import type { EndPoints, FileItem, StorageConf } from '@/api/types' import { storageIconDict } from '@/api/constants' +import type { AxiosInstance } from 'axios' // LocalStorage keys const SORT_KEY = 'fileBrowser.sort' @@ -16,7 +17,7 @@ const props = defineProps({ tree: Boolean, endpoints: Object as PropType, axios: { - type: Function, + type: Object as PropType, required: true, }, axiosconfig: Object, diff --git a/src/components/cards/MessageCard.vue b/src/components/cards/MessageCard.vue index 290662b5..daad6290 100644 --- a/src/components/cards/MessageCard.vue +++ b/src/components/cards/MessageCard.vue @@ -1,4 +1,6 @@ @@ -85,19 +102,23 @@ function replaceNewLine(value: string) {
-

{{ props.message?.text }}

+
- + - {{ key + 1 }}. {{ value.title_year }} + {{ Number(key) + 1 }}. {{ value.title_year }} - {{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }} + {{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }} 类型:{{ value.type }} 评分:{{ value.vote_average }} @@ -116,3 +137,78 @@ function replaceNewLine(value: string) { }}
+ + diff --git a/src/components/dialog/AboutDialog.vue b/src/components/dialog/AboutDialog.vue index a1a80c37..04ac0fdf 100644 --- a/src/components/dialog/AboutDialog.vue +++ b/src/components/dialog/AboutDialog.vue @@ -1,12 +1,16 @@ + + @@ -348,4 +706,10 @@ onMounted(async () => { backdrop-filter: blur(10px) !important; background: rgba(var(--v-theme-surface), 0.7) !important; } + +.v-theme--light { + .passkey-btn.v-btn--variant-tonal { + color: rgb(86, 170, 0) !important; + } +} diff --git a/src/pages/recommend.vue b/src/pages/recommend.vue index 505c50ed..45e04032 100644 --- a/src/pages/recommend.vue +++ b/src/pages/recommend.vue @@ -231,12 +231,23 @@ registerHeaderTab({ ], }) +// 页面是否准备就绪 +const isReady = ref(false) + +// 定时器 +let timer: ReturnType + onBeforeMount(async () => { await loadConfig() initializeColors() }) onMounted(async () => { + // 延迟渲染内容,避免阻塞页面切换动画 + timer = setTimeout(() => { + isReady.value = true + }, 400) + await loadExtraRecommendSources() // 为新增的数据源也生成颜色 extraRecommendSources.value.forEach(source => { @@ -246,6 +257,10 @@ onMounted(async () => { }) }) +onUnmounted(() => { + if (timer) clearTimeout(timer) +}) + onActivated(async () => { await loadExtraRecommendSources() }) @@ -256,10 +271,16 @@ onActivated(async () => {
- + -
+

{{ t('recommend.noCategoryContent') }}

diff --git a/src/pages/setting.vue b/src/pages/setting.vue index 738050b3..9bf58d67 100644 --- a/src/pages/setting.vue +++ b/src/pages/setting.vue @@ -12,10 +12,11 @@ import AccountSettingRule from '@/views/setting/AccountSettingRule.vue' import { getSettingTabs } from '@/router/i18n-menu' import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab' +const { t } = useI18n() const route = useRoute() const activeTab = ref((route.query.tab as string) || '') -const settingTabs = computed(() => getSettingTabs()) +const settingTabs = computed(() => getSettingTabs(t)) // 使用动态标签页 const { registerHeaderTab } = useDynamicHeaderTab() diff --git a/src/pages/subscribe.vue b/src/pages/subscribe.vue index 66be37fb..038fb520 100644 --- a/src/pages/subscribe.vue +++ b/src/pages/subscribe.vue @@ -22,9 +22,9 @@ const shareViewKey = ref(0) // 获取标签页 const subscribeTabs = computed(() => { if (subType === '电影') { - return getSubscribeMovieTabs() + return getSubscribeMovieTabs(t) } else { - return getSubscribeTvTabs() + return getSubscribeTvTabs(t) } }) diff --git a/src/pages/workflow.vue b/src/pages/workflow.vue index f7480d93..0be74387 100644 --- a/src/pages/workflow.vue +++ b/src/pages/workflow.vue @@ -17,7 +17,7 @@ const listViewKey = ref(0) // 获取标签页 const workflowTabs = computed(() => { - return getWorkflowTabs() + return getWorkflowTabs(t) }) // 新增工作流对话框 diff --git a/src/plugins/vuetify/icons.ts b/src/plugins/vuetify/icons.ts index 6015c736..605a1a81 100644 --- a/src/plugins/vuetify/icons.ts +++ b/src/plugins/vuetify/icons.ts @@ -1,5 +1,5 @@ import { Icon } from '@iconify/vue' -import { aliases } from 'vuetify/lib/iconsets/mdi' +import { aliases } from 'vuetify/iconsets/mdi' const alertTypeIcon = { success: 'mdi-check-circle-outline', diff --git a/src/router/i18n-menu.ts b/src/router/i18n-menu.ts index 50976c88..9efa7737 100644 --- a/src/router/i18n-menu.ts +++ b/src/router/i18n-menu.ts @@ -1,9 +1,8 @@ -import { useI18n } from 'vue-i18n' import { useGlobalSettingsStore } from '@/stores' +import type { Composer } from 'vue-i18n' // 构建路由菜单,每次调用时使用当前的语言环境 -export function getNavMenus() { - const { t } = useI18n() +export function getNavMenus(t: Composer['t']) { const globalSettingsStore = useGlobalSettingsStore() // 检查是否为高级模式 @@ -148,9 +147,7 @@ export function getNavMenus() { } // 获取设置标签页 -export function getSettingTabs() { - const { t } = useI18n() - +export function getSettingTabs(t: Composer['t']) { return [ { title: t('settingTabs.system.title'), @@ -204,9 +201,7 @@ export function getSettingTabs() { } // 获取电影订阅标签页 -export function getSubscribeMovieTabs() { - const { t } = useI18n() - +export function getSubscribeMovieTabs(t: Composer['t']) { return [ { title: t('subscribeTabs.movie.mysub'), @@ -222,9 +217,7 @@ export function getSubscribeMovieTabs() { } // 获取电视剧订阅标签页 -export function getSubscribeTvTabs() { - const { t } = useI18n() - +export function getSubscribeTvTabs(t: Composer['t']) { return [ { title: t('subscribeTabs.tv.mysub'), @@ -245,9 +238,7 @@ export function getSubscribeTvTabs() { } // 获取插件标签页 -export function getPluginTabs() { - const { t } = useI18n() - +export function getPluginTabs(t: Composer['t']) { return [ { title: t('pluginTabs.installed'), @@ -263,9 +254,7 @@ export function getPluginTabs() { } // 获取发现标签页 -export function getDiscoverTabs() { - const { t } = useI18n() - +export function getDiscoverTabs(t: Composer['t']) { return [ { name: t('discoverTabs.themoviedb'), @@ -286,9 +275,7 @@ export function getDiscoverTabs() { } // 获取工作流标签页 -export function getWorkflowTabs() { - const { t } = useI18n() - +export function getWorkflowTabs(t: Composer['t']) { return [ { title: t('workflowTabs.list'), diff --git a/src/service-worker.ts b/src/service-worker.ts index 0c97bbd0..0ad855f1 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -1,32 +1,45 @@ import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' +import { registerRoute, setCatchHandler } from 'workbox-routing' +import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies' +import { ExpirationPlugin } from 'workbox-expiration' +import { CacheableResponsePlugin } from 'workbox-cacheable-response' +import * as navigationPreload from 'workbox-navigation-preload' // Service Worker 类型声明 declare let self: ServiceWorkerGlobalScope & { - __WB_MANIFEST: Array<{ url: string; revision?: string }> + readonly __WB_MANIFEST: Array<{ url: string; revision?: string }> } // 缓存版本控制 -const CACHE_VERSION = 'v13' -const CACHE_NAMES = { - appShell: `app-shell-${CACHE_VERSION}`, - static: `static-resources-${CACHE_VERSION}`, - images: `image-cache-${CACHE_VERSION}`, - fonts: `font-cache-${CACHE_VERSION}`, - api: `api-cache-${CACHE_VERSION}`, - tmdb: `tmdb-image-cache-${CACHE_VERSION}`, - pages: `pages-cache-${CACHE_VERSION}`, -} +const RESOURCE_VERSION = 'V2' +const CACHE_VERSION = `${__APP_VERSION__}-${__BUILD_TIME__}` // 开发环境下无法使用此环境变量,生产环境正常 -// 缓存大小限制 -const CACHE_SIZE_LIMITS = { - appShell: { maxEntries: 10, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天 - static: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30天 - images: { maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30天 - fonts: { maxEntries: 50, maxAgeSeconds: 365 * 24 * 60 * 60 }, // 1年 - api: { maxEntries: 500, maxAgeSeconds: 24 * 60 * 60 }, // 24小时 - tmdb: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天 - pages: { maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天 -} +// 启用导航预载 +navigationPreload.enable() + +// 自动清理旧的预缓存 +cleanupOutdatedCaches() + +// 预缓存并路由 +precacheAndRoute(self.__WB_MANIFEST) + +// 监听安装事件 +self.addEventListener('install', () => { + // 强制等待中的 Service Worker 立即激活 + self.skipWaiting() +}) + +// 监听激活事件 +self.addEventListener('activate', event => { + // 让 Service Worker 立即接管页面 + event.waitUntil( + (async () => { + await self.clients.claim() + // 清理旧版本的运行时缓存 + await cleanupRuntimeCaches(true) + })(), + ) +}) // 通知选项 const options = { @@ -38,100 +51,229 @@ const options = { // 存储未读消息数量的键名 const UNREAD_COUNT_KEY = 'mp_unread_count' -// 从IndexedDB获取未读消息数量 -async function getStoredUnreadCount(): Promise { - try { - const count = await get(UNREAD_COUNT_KEY) - return count || 0 - } catch (error) { - console.error('Failed to get stored unread count:', error) - return 0 +// --- 缓存策略配置 --- + +// 导航请求与 App Shell - 优先网络 +registerRoute( + ({ request, url }) => request.mode === 'navigate' || url.pathname === '/' || url.pathname === '/index.html', + new NetworkFirst({ + cacheName: `app-shell-${CACHE_VERSION}`, + plugins: [ + new ExpirationPlugin({ + maxEntries: 10, + maxAgeSeconds: 7 * 24 * 60 * 60, // 7天 + }), + ], + }), +) + +// 静态资源 (JS, CSS, HTML) - 优先缓存 +registerRoute( + ({ request }) => ['style', 'script', 'worker'].includes(request.destination), + new StaleWhileRevalidate({ + cacheName: `static-resources-${CACHE_VERSION}`, + plugins: [ + new CacheableResponsePlugin({ + statuses: [0, 200], + }), + ], + }), +) + +// 图片资源 - 优先缓存 +registerRoute( + ({ request }) => request.destination === 'image', + new CacheFirst({ + cacheName: `image-cache-${RESOURCE_VERSION}`, + plugins: [ + new CacheableResponsePlugin({ + statuses: [0, 200], + }), + new ExpirationPlugin({ + maxEntries: 200, + maxAgeSeconds: 30 * 24 * 60 * 60, // 30天 + }), + ], + }), +) + +// 字体资源 - 优先缓存 +registerRoute( + ({ request }) => request.destination === 'font', + new CacheFirst({ + cacheName: `font-cache-${RESOURCE_VERSION}`, + plugins: [ + new CacheableResponsePlugin({ + statuses: [0, 200], + }), + new ExpirationPlugin({ + maxEntries: 50, + maxAgeSeconds: 365 * 24 * 60 * 60, // 1年 + }), + ], + }), +) + +// TMDB 图片 - 优先缓存 +registerRoute( + ({ url }) => url.hostname === 'image.tmdb.org', + new CacheFirst({ + cacheName: `tmdb-image-cache-${RESOURCE_VERSION}`, + plugins: [ + new CacheableResponsePlugin({ + statuses: [0, 200], + }), + new ExpirationPlugin({ + maxEntries: 300, + maxAgeSeconds: 7 * 24 * 60 * 60, // 7天 + }), + ], + }), +) + +// API GET 请求 - 优先网络 +registerRoute( + ({ url, request }) => + url.pathname.includes('/api/v1/') && + request.method === 'GET' && + !url.pathname.includes('/api/v1/system/message') && // SSE实时消息流 + !url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流 + !url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流 + !url.pathname.includes('/api/v1/message/') && // 用户消息接口 + !url.pathname.includes('/api/v1/system/global') && // 系统配置接口 + !url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口 + !url.pathname.includes('/api/v1/dashboard/'), // Dashboard实时监控数据 + new NetworkFirst({ + cacheName: `api-cache-${CACHE_VERSION}`, + networkTimeoutSeconds: 5, + plugins: [ + new CacheableResponsePlugin({ + statuses: [0, 200], + }), + new ExpirationPlugin({ + maxEntries: 500, + maxAgeSeconds: 24 * 60 * 60, // 24小时 + }), + ], + }), +) + +// 设置默认离线页面 +setCatchHandler(async ({ request }) => { + if (request?.destination === 'document') { + return (await caches.match('/offline.html')) || Response.error() } + return Response.error() +}) + +// --- 辅助函数 (通知与徽章) --- + +// 清理运行时缓存 +async function cleanupRuntimeCaches(onlyOld: boolean = false) { + const cacheNames = await caches.keys() + const runtimeCachePrefixes = [ + 'app-shell', + 'static-resources', + 'image-cache', + 'font-cache', + 'api-cache', + 'tmdb-image-cache', + ] + + // 当前版本的缓存全名 + const currentCacheNames = [ + `app-shell-${CACHE_VERSION}`, + `static-resources-${CACHE_VERSION}`, + `image-cache-${RESOURCE_VERSION}`, + `font-cache-${RESOURCE_VERSION}`, + `tmdb-image-cache-${RESOURCE_VERSION}`, + `api-cache-${CACHE_VERSION}`, + ] + + await Promise.all( + cacheNames.map(cacheName => { + const isRuntimeCache = runtimeCachePrefixes.some(prefix => cacheName.startsWith(prefix)) + if (isRuntimeCache) { + if (!onlyOld || !currentCacheNames.includes(cacheName)) { + console.log('[SW] Deleting runtime cache:', cacheName) + return caches.delete(cacheName) + } + } + return Promise.resolve() + }), + ) } -// 保存未读消息数量到IndexedDB -async function setStoredUnreadCount(count: number): Promise { - try { - await set(UNREAD_COUNT_KEY, count) - } catch (error) { - console.error('Failed to set stored unread count:', error) - } -} - -// 简单的IndexedDB包装器 +// 简单的 IndexedDB 包装器 (用于未读计数) async function openDB(): Promise { - // Bump the version to add the new "sync" store while keeping existing data intact return new Promise((resolve, reject) => { const request = indexedDB.open('mp_badge_db', 2) - request.onerror = () => reject(request.error) request.onsuccess = () => resolve(request.result) - request.onupgradeneeded = event => { const db = (event.target as IDBOpenDBRequest).result - - // Badge store (existing) if (!db.objectStoreNames.contains('badge')) { db.createObjectStore('badge') } - - // Dedicated store for offline-sync items - if (!db.objectStoreNames.contains('sync')) { - db.createObjectStore('sync') - } } }) } -// 获取IndexedDB中的数据 async function get(key: string, storeName: string = 'badge'): Promise { - const db = await openDB() - return new Promise((resolve, reject) => { - const tx = db.transaction([storeName], 'readonly') - const store = tx.objectStore(storeName) - const request = store.get(key) - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve(request.result) - }) + try { + const db = await openDB() + return new Promise((resolve, reject) => { + if (!db.objectStoreNames.contains(storeName)) { + resolve(null) + return + } + const tx = db.transaction([storeName], 'readonly') + const store = tx.objectStore(storeName) + const request = store.get(key) + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve(request.result) + }) + } catch (e) { + return null + } } -// 保存数据到IndexedDB async function set(key: string, value: any, storeName: string = 'badge'): Promise { - const db = await openDB() - return new Promise((resolve, reject) => { - const tx = db.transaction([storeName], 'readwrite') - const store = tx.objectStore(storeName) - - store.put(value, key) - - tx.oncomplete = () => resolve() - tx.onerror = () => reject(tx.error) - }) + try { + const db = await openDB() + return new Promise((resolve, reject) => { + if (!db.objectStoreNames.contains(storeName)) { + console.warn(`Store ${storeName} not found`) + resolve() + return + } + const tx = db.transaction([storeName], 'readwrite') + const store = tx.objectStore(storeName) + store.put(value, key) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } catch (e) { + console.error(`[SW] Failed to set IndexedDB key "${key}" in store "${storeName}":`, e) + } } -// 删除IndexedDB中的数据(确保事务完成) -async function del(key: string, storeName: string = 'badge'): Promise { - const db = await openDB() - return new Promise((resolve, reject) => { - const tx = db.transaction([storeName], 'readwrite') - const store = tx.objectStore(storeName) - - store.delete(key) - - tx.oncomplete = () => resolve() - tx.onerror = () => reject(tx.error) - }) +async function getStoredUnreadCount(): Promise { + const count = await get(UNREAD_COUNT_KEY) + return typeof count === 'number' ? count : 0 +} + +async function setStoredUnreadCount(count: number): Promise { + await set(UNREAD_COUNT_KEY, count) } -// 更新桌面图标徽章 async function updateBadge(count: number) { - if ('setAppBadge' in navigator) { + if ('setAppBadge' in self.navigator) { try { if (count > 0) { - await navigator.setAppBadge!(count) + await self.navigator.setAppBadge(count) } else { - await navigator.clearAppBadge!() + await self.navigator.clearAppBadge() } } catch (error) { console.error('Failed to update app badge:', error) @@ -139,11 +281,10 @@ async function updateBadge(count: number) { } } -// 清除桌面图标徽章 async function clearBadge() { - if ('clearAppBadge' in navigator) { + if ('clearAppBadge' in self.navigator) { try { - await navigator.clearAppBadge!() + await self.navigator.clearAppBadge() await setStoredUnreadCount(0) } catch (error) { console.error('Failed to clear app badge:', error) @@ -151,352 +292,91 @@ async function clearBadge() { } } -// 清理旧版本缓存 -async function deleteOldCaches() { - const cacheWhitelist = Object.values(CACHE_NAMES) - const cacheNames = await caches.keys() - - await Promise.all( - cacheNames.map(async cacheName => { - if (!cacheWhitelist.includes(cacheName)) { - console.log('Deleting old cache:', cacheName) - return caches.delete(cacheName) - } - }), - ) -} - -// 获取缓存大小 -async function getCacheSize(cacheName: string): Promise { - if (!('estimate' in navigator.storage)) { - return 0 - } - - try { - const cache = await caches.open(cacheName) - const keys = await cache.keys() - let totalSize = 0 - - for (const request of keys) { - const response = await cache.match(request) - if (response) { - const blob = await response.blob() - totalSize += blob.size - } - } - - return totalSize - } catch (error) { - console.error('Failed to get cache size:', error) - return 0 - } -} - // 监控缓存大小 async function monitorCacheSize() { const cacheSizes: Record = {} - let totalSize = 0 + let calculatedTotalSize = 0 - for (const [key, cacheName] of Object.entries(CACHE_NAMES)) { - const size = await getCacheSize(cacheName) - cacheSizes[key] = size - totalSize += size - } + try { + const cacheNames = await caches.keys() - // 发送缓存统计信息给客户端 - const clients = await self.clients.matchAll() - clients.forEach(client => { - client.postMessage({ - type: 'CACHE_SIZE_UPDATE', - data: { - cacheSizes, - totalSize, - totalSizeMB: (totalSize / 1024 / 1024).toFixed(2), - }, - }) - }) + // 并行处理所有缓存 + await Promise.all( + cacheNames.map(async cacheName => { + const cache = await caches.open(cacheName) + const requests = await cache.keys() + let cacheSize = 0 - return { cacheSizes, totalSize } -} - -// 清理过期缓存条目 -async function cleanupExpiredCaches() { - for (const [key, cacheName] of Object.entries(CACHE_NAMES)) { - const limit = CACHE_SIZE_LIMITS[key as keyof typeof CACHE_SIZE_LIMITS] - if (!limit) continue - - try { - const cache = await caches.open(cacheName) - const keys = await cache.keys() - - // 如果缓存条目超过限制,删除最老的条目 - if (keys.length > limit.maxEntries) { - const deleteCount = keys.length - limit.maxEntries - console.log(`Cleaning up ${deleteCount} entries from ${cacheName}`) - - // 删除最老的条目(假设数组开头是最老的) - for (let i = 0; i < deleteCount; i++) { - await cache.delete(keys[i]) + // 遍历请求以获取响应头部,避免 matchAll 一次性加载大量响应对象到内存 + for (const request of requests) { + const response = await cache.match(request) + if (response) { + const contentLength = response.headers.get('content-length') + if (contentLength) { + cacheSize += parseInt(contentLength, 10) + } + } } - } - } catch (error) { - console.error(`Failed to cleanup cache ${cacheName}:`, error) + cacheSizes[cacheName] = cacheSize + }), + ) + + calculatedTotalSize = Object.values(cacheSizes).reduce((acc, size) => acc + size, 0) + + // 获取系统级存储估算 + let quota = 0 + let usage = 0 + if (self.navigator.storage && self.navigator.storage.estimate) { + const estimate = await self.navigator.storage.estimate() + quota = estimate.quota || 0 + usage = estimate.usage || 0 } - } -} -// 安装事件 -self.addEventListener('install', () => { - // 强制等待中的Service Worker立即成为活动的Service Worker - self.skipWaiting() -}) - -// 激活事件 -self.addEventListener('activate', event => { - event.waitUntil( - (async () => { - // 启用导航预载功能以提高性能 - if ('navigationPreload' in self.registration) { - await self.registration.navigationPreload.enable() - } - - // 清理旧版本的缓存 - await deleteOldCaches() - - // 清理过期的缓存条目 - await cleanupExpiredCaches() - - // 监控缓存大小 - await monitorCacheSize() - })(), - ) - // 告诉活动的Service Worker立即控制页面 - self.clients.claim() -}) - -// 处理API请求,当离线时发送消息到客户端 -self.addEventListener('fetch', event => { - const url = new URL(event.request.url) - - // 处理API请求 - if (event.request.url.includes('/api/v1/')) { - // GET请求:尝试从缓存返回 - if (event.request.method === 'GET') { - event.respondWith( - (async () => { - try { - // 尝试网络请求 - const networkResponse = await fetch(event.request) - return networkResponse - } catch (error) { - // 网络错误时,通知客户端当前处于离线状态 - if (self.clients) { - self.clients.matchAll().then(clients => { - clients.forEach(client => { - client.postMessage({ - type: 'OFFLINE_STATUS', - offline: true, - }) - }) - }) - } - - // 尝试返回缓存的响应 - const cache = await caches.open(CACHE_NAMES.api) - const cachedResponse = await cache.match(event.request) - if (cachedResponse) { - return cachedResponse - } - - // 如果没有缓存,抛出错误 - throw error - } - })(), - ) + // 构造结果:满足 useCacheManager.ts 的需求 + const result = { + cacheSizes, + // 优先使用准确的 usage (真实磁盘占用),如果不可用则退回到计算值 + totalSize: usage || calculatedTotalSize, + totalSizeMB: ((usage || calculatedTotalSize) / 1024 / 1024).toFixed(2), + // 额外信息保留,供未来扩展 + quota, + usage, + quotaMB: (quota / 1024 / 1024).toFixed(2), + usageMB: (usage / 1024 / 1024).toFixed(2), + calculatedTotalSize, } - // POST/PUT/DELETE请求:离线时加入同步队列 - else if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(event.request.method)) { - event.respondWith( - (async () => { - try { - // 尝试网络请求 - const networkResponse = await fetch(event.request) - return networkResponse - } catch (error) { - // 网络错误时,加入同步队列 - await addToSyncQueue(event.request) - // 通知客户端请求已加入队列 - if (self.clients) { - self.clients.matchAll().then(clients => { - clients.forEach(client => { - client.postMessage({ - type: 'REQUEST_QUEUED', - url: event.request.url, - method: event.request.method, - }) - }) - }) - } - - // 返回一个假的成功响应 - return new Response( - JSON.stringify({ - success: true, - queued: true, - message: '请求已加入离线队列,将在网络恢复后自动同步', - }), - { - status: 202, - headers: { 'Content-Type': 'application/json' }, - }, - ) - } - })(), - ) - } - return - } -}) - -// 后台同步队列 -const syncQueue: Array<{ - id: string - url: string - method: string - data?: any - timestamp: number -}> = [] - -// 添加请求到同步队列 -async function addToSyncQueue(request: Request) { - const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` - const url = request.url - const method = request.method - - let data: any = null - if (method !== 'GET' && method !== 'HEAD') { - try { - data = await request.clone().text() - } catch (e) { - console.error('Failed to read request body:', e) - } - } - - const syncItem = { - id, - url, - method, - data, - timestamp: Date.now(), - } - - // 保存到IndexedDB (使用专用的 "sync" store) - await set(id, syncItem, 'sync') - syncQueue.push(syncItem) - - // 注册后台同步 - if ('sync' in self.registration) { - await self.registration.sync.register('sync-data') - } -} - -// 执行同步队列中的请求 -async function processSyncQueue() { - const db = await openDB() - - // 先用只读事务获取所有同步项 - const items: Array = await new Promise((resolve, reject) => { - const tx = db.transaction(['sync'], 'readonly') - const store = tx.objectStore('sync') - const req = store.getAll() - req.onsuccess = () => resolve(req.result) - req.onerror = () => reject(req.error) - }) - - // 收集需要删除的项目ID - const itemsToDelete: string[] = [] - const itemsToDeleteExpired: string[] = [] - - for (const syncItem of items) { - const key = syncItem.id - try { - // 构建请求 - const init: RequestInit = { - method: syncItem.method, - headers: { - 'Content-Type': 'application/json', - }, - } - - if (syncItem.data) { - init.body = syncItem.data - } - - // 发送请求 - const response = await fetch(syncItem.url, init) - - if (response.ok) { - // 成功后标记为需要删除 - itemsToDelete.push(key) - - // 通知客户端同步成功 - const clients = await self.clients.matchAll() - clients.forEach(client => { - client.postMessage({ - type: 'SYNC_SUCCESS', - syncId: syncItem.id, - url: syncItem.url, - }) - }) - } else { - throw new Error(`HTTP ${response.status}`) - } - } catch (error) { - console.error('Sync failed for item:', key, error) - - // 如果该同步项已存在超过 24 小时,则标记为需要删除 - if (Date.now() - syncItem.timestamp > 24 * 60 * 60 * 1000) { - itemsToDeleteExpired.push(key) - } - } - } - - // 批量删除所有成功处理的项目和过期项目 - const allItemsToDelete = [...itemsToDelete, ...itemsToDeleteExpired] - if (allItemsToDelete.length > 0) { - await new Promise((resolve, reject) => { - const tx = db.transaction(['sync'], 'readwrite') - const store = tx.objectStore('sync') - - // 批量删除所有标记的项目 - allItemsToDelete.forEach(id => { - store.delete(id) + // 发送缓存统计信息给客户端 + const clients = await self.clients.matchAll() + clients.forEach(client => { + client.postMessage({ + type: 'CACHE_SIZE_UPDATE', + data: result, }) - - tx.oncomplete = () => resolve() - tx.onerror = () => reject(tx.error) }) + + return result + } catch (error) { + console.error('Failed to monitor cache size:', error) + return { + cacheSizes: {}, + totalSize: 0, + totalSizeMB: '0.00', + quota: 0, + usage: 0, + quotaMB: '0.00', + usageMB: '0.00', + } } } -// 初始化 Workbox -cleanupOutdatedCaches() -precacheAndRoute(self.__WB_MANIFEST) +// --- 事件监听 --- -// 监听 sync 事件,处理后台同步 -self.addEventListener('sync', (event: SyncEvent) => { - if (event.tag === 'sync-data') { - event.waitUntil(processSyncQueue()) - } -}) - -// 监听 push 事件,显示通知 +// 监听 push 事件 self.addEventListener('push', function (event) { if (!event.data) { return } - // 解析获取推送消息 let payload try { payload = event.data?.json() @@ -505,7 +385,7 @@ self.addEventListener('push', function (event) { title: event.data?.text(), } } - // 根据推送消息生成桌面通知并展现出来 + try { const content = { body: payload.body || '', @@ -515,7 +395,6 @@ self.addEventListener('push', function (event) { actions: options.actions, } - // 增加未读消息计数并持久化存储 event.waitUntil( (async () => { const currentCount = await getStoredUnreadCount() @@ -525,11 +404,11 @@ self.addEventListener('push', function (event) { })(), ) } catch (e) { - // 静默处理错误 + // 忽略错误 } }) -// 监听通知点击事件 +// 监听通知点击 self.addEventListener('notificationclick', function (event) { const info = event.notification if (event.action === 'close') { @@ -539,10 +418,9 @@ self.addEventListener('notificationclick', function (event) { } }) -// 监听来自主应用的消息,用于清除徽章或更新徽章数量 +// 监听消息 self.addEventListener('message', function (event) { if (event.data && event.data.type === 'CLEAR_BADGE') { - // 清除徽章 clearBadge() .then(() => { event.ports[0]?.postMessage({ success: true }) @@ -551,7 +429,6 @@ self.addEventListener('message', function (event) { event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) }) }) } else if (event.data && event.data.type === 'UPDATE_BADGE') { - // 更新徽章数量 const count = event.data.count || 0 setStoredUnreadCount(count) .then(() => updateBadge(count)) @@ -562,25 +439,27 @@ self.addEventListener('message', function (event) { event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) }) }) } else if (event.data && event.data.type === 'GET_UNREAD_COUNT') { - // 获取未读消息数量 getStoredUnreadCount() .then(count => { event.ports[0]?.postMessage({ count }) }) - .catch(error => { + .catch(() => { event.ports[0]?.postMessage({ count: 0 }) }) } else if (event.data && event.data.type === 'CLEANUP_CACHES') { - // 手动触发缓存清理 - Promise.all([deleteOldCaches(), cleanupExpiredCaches(), monitorCacheSize()]) - .then(([, , cacheInfo]) => { + // 手动清理: 清理所有运行时缓存 + const performCleanup = async () => { + await cleanupRuntimeCaches(false) + return await monitorCacheSize() + } + performCleanup() + .then(cacheInfo => { event.ports[0]?.postMessage({ success: true, cacheInfo }) }) .catch(error => { event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) }) }) } else if (event.data && event.data.type === 'GET_CACHE_INFO') { - // 获取缓存信息 monitorCacheSize() .then(cacheInfo => { event.ports[0]?.postMessage({ success: true, cacheInfo }) @@ -588,5 +467,7 @@ self.addEventListener('message', function (event) { .catch(error => { event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) }) }) + } else if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting() } }) diff --git a/src/stores/global.ts b/src/stores/global.ts index 13eac221..50cce208 100644 --- a/src/stores/global.ts +++ b/src/stores/global.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import type { globalSettingsState } from '@/stores/types' import { fetchGlobalSettings } from '@/utils/globalSetting' +import { useVersionChecker } from '@/composables/useVersionChecker' export const useGlobalSettingsStore = defineStore('globalSettings', { state: (): globalSettingsState => ({ @@ -18,6 +19,12 @@ export const useGlobalSettingsStore = defineStore('globalSettings', { const result = await fetchGlobalSettings() this.data = result || {} this.initialized = true + + // 检查版本更新 + if (result.FRONTEND_VERSION) { + const { checkVersion } = useVersionChecker() + await checkVersion(result.FRONTEND_VERSION) + } } catch (error) { console.error('Failed to initialize global settings', error) } finally { diff --git a/src/styles/common.scss b/src/styles/common.scss index ccee94bf..e3f2769b 100644 --- a/src/styles/common.scss +++ b/src/styles/common.scss @@ -74,17 +74,17 @@ html.v-overlay-scroll-blocked body { // 路由过渡动画 .fade-slide-leave-active, .fade-slide-enter-active { - transition: all 0.6s; + transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1); } .fade-slide-enter-from { opacity: 0; - transform: translateY(-45px); + transform: translateX(20px); } .fade-slide-leave-to { opacity: 0; - transform: translateY(45px); + transform: translateX(20px); } // 网格布局样式 diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 01ebeff1..23650204 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,5 +1,8 @@ // PWA Badge API 类型定义 declare global { + const __APP_VERSION__: string + const __BUILD_TIME__: string + interface Navigator { /** * 设置应用徽章数量 diff --git a/src/views/discover/MediaCardSlideView.vue b/src/views/discover/MediaCardSlideView.vue index f381d53f..16e61500 100644 --- a/src/views/discover/MediaCardSlideView.vue +++ b/src/views/discover/MediaCardSlideView.vue @@ -4,6 +4,7 @@ import type { MediaInfo } from '@/api/types' import MediaCard from '@/components/cards/MediaCard.vue' import SlideView from '@/components/slide/SlideView.vue' import { useI18n } from 'vue-i18n' +import { useIntersectionObserver, until } from '@vueuse/core' const { t } = useI18n() @@ -12,6 +13,10 @@ const props = defineProps({ apipath: String, linkurl: String, title: String, + ready: { + type: Boolean, + default: true, + }, }) // 提供给子组件的属性 @@ -19,38 +24,71 @@ provide('rankingPropsKey', reactive({ ...props })) // 组件加载完成 const componentLoaded = ref(false) +// 是否已尝试加载 +const hasTriedLoading = ref(false) // 数据列表 const dataList = ref([]) +// 容器引用 +const containerRef = ref(null) + // 获取订阅列表数据 async function fetchData() { try { if (!props.apipath) return dataList.value = await api.get(props.apipath) - if (dataList.value.length > 0) componentLoaded.value = true + if (dataList.value.length > 0) { + // 数据获取后,等待 ready 信号再渲染,避免阻塞动画 + await until(() => props.ready).toBe(true) + } + componentLoaded.value = true } catch (error) { console.error(error) + componentLoaded.value = true + } finally { + hasTriedLoading.value = true } } -// 加载时获取数据 -onMounted(() => { - fetchData() -}) +// 使用 IntersectionObserver 实现懒加载 +const { stop } = useIntersectionObserver( + containerRef, + ([{ isIntersecting }]) => { + if (isIntersecting) { + fetchData() + stop() + } + }, + { + rootMargin: '300px', // 提前加载距离 + }, +) + onActivated(() => { - if (dataList.value.length == 0) { + if (dataList.value.length == 0 && hasTriedLoading.value) { fetchData() } })