diff --git a/nginx/default.conf b/nginx/default.conf index 70379d8..28feb06 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -6,7 +6,7 @@ server { # Load configuration files for the default server block. include /etc/nginx/default.d/*.conf; - location ~* \.(js|css)$ { + location ~* \.(js|css|png|jpg|jpeg|gif|webp|svg|ico|ttf|otf|woff|woff2|mp4)$ { # 缓存30天 add_header Cache-Control "public, max-age=2592000, immutable"; @@ -28,4 +28,4 @@ server { error_page 500 502 503 504 /50x.html; location = /50x.html { } -} \ No newline at end of file +} diff --git a/nginx/default_hooka.conf b/nginx/default_hooka.conf index 125d3cc..9f3e200 100644 --- a/nginx/default_hooka.conf +++ b/nginx/default_hooka.conf @@ -6,6 +6,13 @@ server { # Load configuration files for the default server block. include /etc/nginx/default.d/*.conf; + location ~* \.(js|css|png|jpg|jpeg|gif|webp|svg|ico|ttf|otf|woff|woff2|mp4)$ { + add_header Cache-Control "public, max-age=2592000, immutable"; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + gzip_vary on; + } location / { # 此处的 @router 实际上是引用下面的转发,否则在 Vue 路由刷新时可能会抛出 404 try_files $uri $uri/ @router; diff --git a/src/config/imageCacheVersion.js b/src/config/imageCacheVersion.js index d8a342f..eea5e56 100644 --- a/src/config/imageCacheVersion.js +++ b/src/config/imageCacheVersion.js @@ -1,18 +1,23 @@ -// src/config/imageCacheVersion.js -// 图片缓存版本号配置 +// 图片缓存版本控制。 +// +// 推荐用法: +// 1. 普通发版保持 version 不变。 +// 2. 只替换单张图片且不改文件名时,在 assetVersions 中添加对应图片版本。 +// 3. 只有大批量资源迁移或缓存污染时,才更新 version 触发清理旧缓存。 export const IMAGE_CACHE_VERSION = { - // 图片缓存版本号,仅在图片资源有重大变化时更新 - version: '1.0.18', // 可以根据需要更新此版本号 - - // 缓存类型控制:'images'、'assets' 或 'all' - // 'images': 只清除图片缓存 (likei-images-v) - // 'assets': 只清除资产缓存 (likei-assets-v) - // 'all': 清除所有缓存 - cacheType: 'all', // 默认清除所有缓存,可根据需要修改 - - // 版本更新说明(用于记录更新原因) + version: '1.0.18', + cacheType: 'all', updateReason: 'Initial image cache version for major release', + updateTime: '2026-05-14T00:00:00.000Z', - // 更新时间戳 - updateTime: new Date().toISOString(), + // 精确资源路径或目录前缀 -> 资源版本。 + // 示例: + // 'Activities/LuckyDollars/Season6/bg0.png': '20260514', + // 'Activities/LuckyDollars/Season6/': 'season6-20260514', + assetVersions: {}, + + cacheLimits: { + maxItems: 120, + maxSizeMB: 80, + }, } diff --git a/src/config/imagePaths.js b/src/config/imagePaths.js index fc8264f..c00f0f9 100644 --- a/src/config/imagePaths.js +++ b/src/config/imagePaths.js @@ -1,10 +1,40 @@ import { protectAssetUrl } from '@/utils/protectedAssets.js' +import { IMAGE_CACHE_VERSION } from '@/config/imageCacheVersion.js' export const OSS_BASE_URL = process.env.NODE_ENV === 'development' ? '/oss/h5/Azizi/' : 'https://cdn.azizichat.com/h5/Azizi/' function buildAssetUrl(imagePath, filename, extension) { - return `${OSS_BASE_URL}${imagePath}${filename}.${extension}` + const assetPath = `${imagePath}${filename}.${extension}` + const url = `${OSS_BASE_URL}${assetPath}` + const version = getAssetVersion(assetPath) + + if (!version) { + return url + } + + const separator = url.includes('?') ? '&' : '?' + return `${url}${separator}iv=${encodeURIComponent(version)}` +} + +function getAssetVersion(assetPath) { + const versions = IMAGE_CACHE_VERSION.assetVersions || {} + + if (versions[assetPath]) { + return versions[assetPath] + } + + let bestPrefix = '' + let bestVersion = '' + + Object.entries(versions).forEach(([prefix, version]) => { + if (assetPath.startsWith(prefix) && prefix.length > bestPrefix.length) { + bestPrefix = prefix + bestVersion = version + } + }) + + return bestVersion } export const getRawPngUrl = (imagePath, filename) => buildAssetUrl(imagePath, filename, 'png') diff --git a/src/main.js b/src/main.js index 903abff..6f18b44 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,6 @@ -// src/main.js import './assets/main.css' import './styles/fonts.css' -import './styles/global.css' // 引入全局样式 +import './styles/global.css' import { logEnvInfo } from './utils/env.js' import { installProtectedAssetRuntime } from './utils/protectedAssetRuntime.js' @@ -18,57 +17,34 @@ import LoadingSpinner from './components/LoadingSpinner.vue' import router from './router' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' -// 初始化环境信息 logEnvInfo() - -// 检查版本更新(首先判断执行一次对缓存的操作,最后返回检测结果) installRuntimeSecurity() installProtectedAssetRuntime() -if (await versionChecker.checkVersion()) { - console.log('版本已更新') -} - -// 初始化权限系统 -console.debug('🔐 Permission system initialized') const pinia = createPinia() -pinia.use(piniaPluginPersistedstate) //将插件添加到 pinia 实例上 +pinia.use(piniaPluginPersistedstate) const app = createApp(App) - -// 注册智能图片指令 app.directive('smart-img', smartImage) app.component('LoadingSpinner', LoadingSpinner) app.use(router) app.use(pinia) -// 然后初始化 i18n(此时 Pinia 已可用) -// 获取 langStore 并传递给 initI18n -const langStore = useLangStore() // 直接使用 import 的函数 +const langStore = useLangStore() -//排行榜 -const RankingPage = [] - -// 活动页面 -const ACTIVITIES = [ - '/recharge-reward', //充值奖励页面 -] - -// 固定语言(布局)的页面 -const enPages = [...RankingPage, ...ACTIVITIES] +const rankingPages = [] +const activities = ['/recharge-reward'] +const enPages = [...rankingPages, ...activities] const arPages = [] -// 获取当前页面路径 const hashPath = window.location.hash.replace('#', '') -const currentPage = hashPath.split('?')[0] // 提取纯路径部分,去除查询参数 - -// 检测URL参数并设置语言 +const currentPage = hashPath.split('?')[0] const queryParams = hashPath.split('?')[1] const urlParams = queryParams ? new URLSearchParams(queryParams) : new URLSearchParams() const langParam = urlParams.get('lang') -let finalLang = null // 最终使用的语言 +let finalLang = null if (langParam) { if (langParam === 'en') { finalLang = 'en' @@ -81,38 +57,54 @@ if (langParam) { } } -// 检查当前页面是否在固定英语的页面列表中 if (enPages.includes(currentPage)) { - // 强制设置为英语,不受URL参数影响 setLocale(langStore, 'en') -} -// 检查当前页面是否在固定阿拉伯语的页面列表中 -else if (arPages.includes(currentPage)) { - // 强制设置为阿拉伯语,不受URL参数影响 +} else if (arPages.includes(currentPage)) { setLocale(langStore, 'ar') -} -// 对于其他页面,如果有URL参数则按参数设置语言 -else if (finalLang) { - console.log('langParam:', langParam) - console.log('finalLang:', finalLang) +} else if (finalLang) { + console.log('langParam:', langParam) + console.log('finalLang:', finalLang) setLocale(langStore, finalLang) } initI18n(langStore) -// 最后使用 i18n app.use(i18n) - app.mount('#app') -// 定时检查是否更新 +function runWhenIdle(callback) { + if (typeof window.requestIdleCallback === 'function') { + window.requestIdleCallback(callback, { timeout: 2000 }) + return + } + + window.setTimeout(callback, 0) +} + +async function checkVersionInBackground() { + try { + if (await versionChecker.checkVersion()) { + console.log('Version updated') + } + } catch (error) { + console.warn('Version check failed:', error) + } +} + +runWhenIdle(() => { + void checkVersionInBackground() +}) + if (versionChecker.enableCheck) { setInterval( async () => { - const versionResult = await versionChecker.checkServerVersion() - if (versionResult.shouldUpdate) { - // 自动处理更新,不需要用户交互 - await versionChecker.handleVersionUpdate(versionResult.versionType) + try { + const versionResult = await versionChecker.checkServerVersion() + if (versionResult.shouldUpdate) { + await versionChecker.handleVersionUpdate(versionResult.versionType) + } + } catch (error) { + console.warn('Scheduled version check failed:', error) } }, 5 * 60 * 1000, diff --git a/src/utils/image/imageCacheManager.js b/src/utils/image/imageCacheManager.js index bc62045..42beb35 100644 --- a/src/utils/image/imageCacheManager.js +++ b/src/utils/image/imageCacheManager.js @@ -13,8 +13,8 @@ class ImageCacheManager { this.imageCacheName = `likei-images-v${this.currentImageVersion}` // OSS图片 this.assetCacheName = `likei-assets-v${this.currentImageVersion}` // 本地assets - this.maxCacheItems = 200 // 最大缓存数量(200张) - this.maxCacheSize = 200 * 1024 * 1024 // 最大缓存大小(200MB) + this.maxCacheItems = IMAGE_CACHE_VERSION.cacheLimits?.maxItems || 120 + this.maxCacheSize = (IMAGE_CACHE_VERSION.cacheLimits?.maxSizeMB || 80) * 1024 * 1024 this.currentImageCacheSize = 0 // 当前image缓存大小(字节) this.currentAssetCacheSize = 0 // 当前asset缓存大小(字节) @@ -363,9 +363,9 @@ class ImageCacheManager { cachedResponse = await cache.match(normalizedUrl) } - // 忽略查询参数的 URL 匹配 - // 通过 urlObj.origin 和 urlObj.pathname 拼接生成 urlWithoutQuery,去除查询参数(如时间戳、token 等)后进行匹配。用于兼容同一资源因携带不同查询参数而导致 URL 不同,但实际资源内容相同的场景。 - if (!cachedResponse) { + // 只允许无版本参数的 URL 回退匹配无 query 缓存。 + // 带 iv 的版本化资源不能回退命中旧的无版本缓存。 + if (!cachedResponse && !new URL(normalizedUrl).search) { const urlObj = new URL(normalizedUrl) const urlWithoutQuery = `${urlObj.origin}${urlObj.pathname}` cachedResponse = await cache.match(urlWithoutQuery) diff --git a/src/utils/image/imagePreloader.js b/src/utils/image/imagePreloader.js index 231d9b4..e5a9946 100644 --- a/src/utils/image/imagePreloader.js +++ b/src/utils/image/imagePreloader.js @@ -1,34 +1,107 @@ -// src/utils/imagePreloader.js import { imageCacheManager } from '@/utils/image/imageCacheManager' -export const preloadImages = async (sources) => { +const DEFAULT_PRELOAD_CONCURRENCY = 3 + +function normalizeSources(sources) { let urls = [] - // 支持数组和对象形式的图片源 if (Array.isArray(sources)) { urls = sources - } else if (typeof sources === 'object') { + } else if (sources && typeof sources === 'object') { urls = Object.values(sources) } - // 去重:确保每个 URL 只处理一次 - const uniqueUrls = [...new Set(urls)] - - // 创建预加载 Promise 数组 - const preloadPromises = uniqueUrls.map((url) => { - if (imageCacheManager.isBuildAsset(url)) { - return imageCacheManager.scheduleRequest(url) // 使用调度器统一管理 - } else { - return imageCacheManager.scheduleRequest(url) - } - }) - - // 等待所有预加载完成 - try { - await Promise.all(preloadPromises) - console.log('图片预加载完成') - } catch (error) { - console.error('预加载图片失败:', error) - throw error - } + return [...new Set(urls.filter((url) => typeof url === 'string' && url.trim()))] +} + +function getSafeConcurrency(concurrency) { + const value = Number(concurrency) + + if (!Number.isFinite(value) || value <= 0) { + return DEFAULT_PRELOAD_CONCURRENCY + } + + return Math.max(1, Math.min(Math.floor(value), 6)) +} + +async function runWithConcurrency(items, concurrency, worker) { + const results = new Array(items.length) + let cursor = 0 + + async function runNext() { + const currentIndex = cursor + cursor += 1 + + if (currentIndex >= items.length) { + return + } + + try { + results[currentIndex] = { + status: 'fulfilled', + value: await worker(items[currentIndex], currentIndex), + } + } catch (error) { + results[currentIndex] = { + status: 'rejected', + reason: error, + } + } + + await runNext() + } + + const workers = Array.from({ length: Math.min(concurrency, items.length) }, runNext) + await Promise.all(workers) + + return results +} + +function waitForIdle(timeout = 1200) { + if (typeof window === 'undefined') { + return Promise.resolve() + } + + if (typeof window.requestIdleCallback === 'function') { + return new Promise((resolve) => { + window.requestIdleCallback(resolve, { timeout }) + }) + } + + return new Promise((resolve) => { + window.setTimeout(resolve, 16) + }) +} + +export const preloadImages = async (sources, options = {}) => { + const uniqueUrls = normalizeSources(sources) + + if (!uniqueUrls.length) { + return [] + } + + if (options.idle) { + await waitForIdle(options.idleTimeout) + } + + const results = await runWithConcurrency( + uniqueUrls, + getSafeConcurrency(options.concurrency), + (url) => imageCacheManager.scheduleRequest(url), + ) + + const failedResults = results.filter((result) => result?.status === 'rejected') + + if (failedResults.length && options.throwOnError) { + throw failedResults[0].reason + } + + return results +} + +export const preloadImagesIdle = (sources, options = {}) => { + return preloadImages(sources, { + ...options, + idle: true, + }) } diff --git a/vite.config.js b/vite.config.js index 0c055dc..e00af73 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,7 +7,6 @@ import { existsSync } from 'node:fs' import { isAbsolute, relative, resolve } from 'node:path' import { VantResolver } from 'unplugin-vue-components/resolvers' -const buildTimestamp = Date.now() const PRODUCTION_LIKE_MODES = new Set(['production', 'production-atu']) const STRIPPABLE_CONSOLE_METHODS = ['log', 'debug', 'info'] const PUBLIC_DIR = fileURLToPath(new URL('./public', import.meta.url)) @@ -293,17 +292,17 @@ export default defineConfig(({ mode }) => { }, rollupOptions: { output: { - entryFileNames: `js/[hash]-${buildTimestamp}.js`, - chunkFileNames: `js/[hash]-${buildTimestamp}.js`, + entryFileNames: 'js/[hash].js', + chunkFileNames: 'js/[hash].js', assetFileNames: (assetInfo) => { const info = assetInfo.name.split('.') const ext = info[info.length - 1] if (/\.(css)$/.test(assetInfo.name)) { - return `css/[hash]-${buildTimestamp}.[ext]` + return 'css/[hash].[ext]' } - return `assets/[hash]-${buildTimestamp}.[ext]` + return 'assets/[hash].[ext]' }, }, },