diff --git a/auto-imports.d.ts b/auto-imports.d.ts index 45d5ebe..3148e71 100644 --- a/auto-imports.d.ts +++ b/auto-imports.d.ts @@ -10,6 +10,7 @@ declare global { const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate const asyncComputed: typeof import('@vueuse/core').asyncComputed const autoResetRef: typeof import('@vueuse/core').autoResetRef + const clearExpiredCaches: typeof import('./src/composables/useStorageCache').clearExpiredCaches const computed: typeof import('vue').computed const computedAsync: typeof import('@vueuse/core').computedAsync const computedEager: typeof import('@vueuse/core').computedEager @@ -42,6 +43,7 @@ declare global { const formatAmountWithUnit: typeof import('./src/utils/helper').formatAmountWithUnit const formatBalance: typeof import('./src/utils/helper').formatBalance const getActivePinia: typeof import('pinia').getActivePinia + const getCacheRemainingTime: typeof import('./src/composables/useStorageCache').getCacheRemainingTime const getCurrentInstance: typeof import('vue').getCurrentInstance const getCurrentScope: typeof import('vue').getCurrentScope const getCurrentWatcher: typeof import('vue').getCurrentWatcher @@ -49,6 +51,7 @@ declare global { const ignorableWatch: typeof import('@vueuse/core').ignorableWatch const inject: typeof import('vue').inject const injectLocal: typeof import('@vueuse/core').injectLocal + const isCacheExpired: typeof import('./src/composables/useStorageCache').isCacheExpired const isDefined: typeof import('@vueuse/core').isDefined const isFunction: typeof import('./src/utils/is').isFunction const isPromise: typeof import('./src/utils/is').isPromise @@ -104,6 +107,7 @@ declare global { const refManualReset: typeof import('@vueuse/core').refManualReset const refThrottled: typeof import('@vueuse/core').refThrottled const refWithControl: typeof import('@vueuse/core').refWithControl + const refreshCacheExpire: typeof import('./src/composables/useStorageCache').refreshCacheExpire const resolveComponent: typeof import('vue').resolveComponent const resolveRef: typeof import('@vueuse/core').resolveRef const setActivePinia: typeof import('pinia').setActivePinia @@ -272,6 +276,7 @@ declare global { const useStepper: typeof import('@vueuse/core').useStepper const useStorage: typeof import('@vueuse/core').useStorage const useStorageAsync: typeof import('@vueuse/core').useStorageAsync + const useStorageCache: typeof import('./src/composables/useStorageCache').useStorageCache const useStyleTag: typeof import('@vueuse/core').useStyleTag const useSubscribeModal: typeof import('./src/composables/useSubscribeModal').useSubscribeModal const useSupported: typeof import('@vueuse/core').useSupported @@ -363,6 +368,7 @@ declare module 'vue' { readonly acceptHMRUpdate: UnwrapRef readonly asyncComputed: UnwrapRef readonly autoResetRef: UnwrapRef + readonly clearExpiredCaches: UnwrapRef readonly computed: UnwrapRef readonly computedAsync: UnwrapRef readonly computedEager: UnwrapRef @@ -394,6 +400,7 @@ declare module 'vue' { readonly formatAmount: UnwrapRef readonly formatBalance: UnwrapRef readonly getActivePinia: UnwrapRef + readonly getCacheRemainingTime: UnwrapRef readonly getCurrentInstance: UnwrapRef readonly getCurrentScope: UnwrapRef readonly getCurrentWatcher: UnwrapRef @@ -401,6 +408,7 @@ declare module 'vue' { readonly ignorableWatch: UnwrapRef readonly inject: UnwrapRef readonly injectLocal: UnwrapRef + readonly isCacheExpired: UnwrapRef readonly isDefined: UnwrapRef readonly isFunction: UnwrapRef readonly isPromise: UnwrapRef @@ -456,6 +464,7 @@ declare module 'vue' { readonly refManualReset: UnwrapRef readonly refThrottled: UnwrapRef readonly refWithControl: UnwrapRef + readonly refreshCacheExpire: UnwrapRef readonly resolveComponent: UnwrapRef readonly resolveRef: UnwrapRef readonly setActivePinia: UnwrapRef @@ -624,6 +633,7 @@ declare module 'vue' { readonly useStepper: UnwrapRef readonly useStorage: UnwrapRef readonly useStorageAsync: UnwrapRef + readonly useStorageCache: UnwrapRef readonly useStyleTag: UnwrapRef readonly useSupported: UnwrapRef readonly useSwipe: UnwrapRef diff --git a/src/composables/useLanguage.ts b/src/composables/useLanguage.ts index 1ceec3b..6df88f5 100644 --- a/src/composables/useLanguage.ts +++ b/src/composables/useLanguage.ts @@ -43,7 +43,21 @@ export function useLanguage() { } function loadRemoteLanguage() { + const storageKey = `remote-lang-${language.value}`; + const remoteLangJson = useStorageCache(storageKey, "", { ttl: 24 * 60 * 60 }); // 缓存 1 天 + if (remoteLangJson.value) { + try { + const messages = JSON.parse(remoteLangJson.value); + mergeLocaleMessage(locale.value, messages); + return; + } + catch (e) { + console.error("Failed to parse remote language JSON:", e); + } + } safeClient(client.api.error_messages({ lang: language.value }).get()).then((res) => { + clearExpiredCaches([storageKey]); + remoteLangJson.value = JSON.stringify(res.data.value); mergeLocaleMessage(locale.value, res.data.value); }); } diff --git a/src/composables/useStorageCache.ts b/src/composables/useStorageCache.ts new file mode 100644 index 0000000..ae0a514 --- /dev/null +++ b/src/composables/useStorageCache.ts @@ -0,0 +1,248 @@ +import type { RemovableRef, StorageLike } from "@vueuse/core"; + +interface CacheData { + value: T; + expireTime?: number; // 过期时间戳(毫秒) +} + +interface UseStorageCacheOptions { + /** + * 缓存过期时间(秒) + * @default undefined 永不过期 + */ + ttl?: number; + /** + * 存储介质 + * @default localStorage + */ + storage?: StorageLike; + /** + * 是否在过期后自动删除 + * @default true + */ + autoRemoveOnExpire?: boolean; +} + +/** + * 使用支持过期时间的 Storage + * @param key 存储键 + * @param initialValue 初始值 + * @param options 配置项 + * @returns 响应式存储引用 + * + * @example + * ```ts + * // 创建一个 5 分钟后过期的缓存 + * const token = useStorageCache('user-token', '', { ttl: 300 }) + * + * // 设置值 + * token.value = 'new-token' + * + * // 检查是否过期 + * if (isExpired(token)) { + * console.log('Token 已过期') + * } + * + * // 刷新过期时间 + * refreshExpire(token, 600) // 延长到 10 分钟 + * ``` + */ +export function useStorageCache( + key: string, + initialValue: T, + options: UseStorageCacheOptions = {}, +): RemovableRef { + const { + ttl, + storage = localStorage, + autoRemoveOnExpire = true, + } = options; + + // 尝试从 storage 读取缓存数据 + const rawData = storage.getItem(key); + let cachedData: CacheData | null = null; + + if (rawData) { + try { + cachedData = JSON.parse(rawData) as CacheData; + + // 检查是否过期 + if (cachedData.expireTime && Date.now() > cachedData.expireTime) { + // 已过期 + if (autoRemoveOnExpire) { + storage.removeItem(key); + } + cachedData = null; + } + } + catch (error) { + console.error(`解析缓存数据失败 [${key}]:`, error); + cachedData = null; + } + } + + // 计算初始值 + const defaultValue = cachedData ? cachedData.value : initialValue; + + // 使用 useStorage 创建响应式引用 + const storageRef = useStorage( + key, + defaultValue, + storage, + { + serializer: { + read: (raw: string) => { + try { + const data = JSON.parse(raw) as CacheData; + // 再次检查过期时间(防止并发问题) + if (data.expireTime && Date.now() > data.expireTime) { + if (autoRemoveOnExpire) { + storage.removeItem(key); + } + return initialValue; + } + return data.value; + } + catch { + return initialValue; + } + }, + write: (value: T) => { + const cacheData: CacheData = { + value, + }; + + // 如果设置了 ttl,计算过期时间 + if (ttl && ttl > 0) { + cacheData.expireTime = Date.now() + ttl * 1000; + } + + return JSON.stringify(cacheData); + }, + }, + }, + ); + + return storageRef; +} + +/** + * 检查缓存是否过期 + * @param key 存储键或存储引用 + * @param storage 存储介质 + * @returns 是否过期 + */ +export function isCacheExpired( + key: string | RemovableRef, + storage: StorageLike = localStorage, +): boolean { + const cacheKey = typeof key === "string" ? key : key.value; + + try { + const rawData = storage.getItem(cacheKey); + if (!rawData) { + return true; + } + + const data = JSON.parse(rawData) as CacheData; + if (!data.expireTime) { + return false; // 没有设置过期时间,永不过期 + } + + return Date.now() > data.expireTime; + } + catch { + return true; + } +} + +/** + * 刷新缓存过期时间 + * @param key 存储键 + * @param ttl 新的过期时间(秒) + * @param storage 存储介质 + */ +export function refreshCacheExpire( + key: string, + ttl: number, + storage: StorageLike = localStorage, +): void { + try { + const rawData = storage.getItem(key); + if (!rawData) { + return; + } + + const data = JSON.parse(rawData) as CacheData; + data.expireTime = Date.now() + ttl * 1000; + + storage.setItem(key, JSON.stringify(data)); + } + catch (error) { + console.error(`刷新缓存过期时间失败 [${key}]:`, error); + } +} + +/** + * 获取缓存剩余有效时间(秒) + * @param key 存储键 + * @param storage 存储介质 + * @returns 剩余时间(秒),如果已过期返回 0,如果永不过期返回 Infinity + */ +export function getCacheRemainingTime( + key: string, + storage: StorageLike = localStorage, +): number { + try { + const rawData = storage.getItem(key); + if (!rawData) { + return 0; + } + + const data = JSON.parse(rawData) as CacheData; + if (!data.expireTime) { + return Infinity; // 永不过期 + } + + const remaining = Math.max(0, data.expireTime - Date.now()); + return Math.floor(remaining / 1000); + } + catch { + return 0; + } +} + +/** + * 清除指定键的过期缓存 + * @param keys 要检查的键列表 + * @param storage 存储介质 + * @returns 被删除的键数量 + */ +export function clearExpiredCaches( + keys: string[], + storage: StorageLike = localStorage, +): number { + let removedCount = 0; + + keys.forEach((key) => { + try { + const rawData = storage.getItem(key); + if (!rawData) { + return; + } + + const data = JSON.parse(rawData) as CacheData; + + // 检查是否过期 + if (data.expireTime && Date.now() > data.expireTime) { + storage.removeItem(key); + removedCount++; + } + } + catch { + // 忽略解析错误 + } + }); + + return removedCount; +}