import type { PiniaPluginContext } from 'pinia' import localforage from 'localforage' export type PersistOption = boolean | { key?: string debounce?: number } type PersistStoreExt = { $persistNow?: () => Promise $clearPersisted?: () => Promise } type PiniaStorageConfig = LocalForageOptions & { mode?: 'single' | 'multiple' debounce?: number } const baseConfig: PiniaStorageConfig = { driver: localforage.INDEXEDDB, name: 'DB', version: 1, description: 'pinia persisted storage', storeName: 'pinia-storage', debounce: 300 } let singleStore: LocalForage | null = null const multipleStores = new Map() const getStore = (mode: 'single' | 'multiple', storeName: string, config: LocalForageOptions) => { if (mode === 'single') { if (!singleStore) { singleStore = localforage.createInstance({ ...baseConfig, ...config }) } return singleStore } if (!multipleStores.has(storeName)) { multipleStores.set( storeName, localforage.createInstance({ ...baseConfig, ...config, storeName }) ) } return multipleStores.get(storeName) as LocalForage } const resolvePersistOptions = (persist: PersistOption | undefined) => { if (!persist) return null if (persist === true) return {} return persist } const trimStringValuesDeep = (value: unknown): unknown => { if (typeof value === 'string') return value.trim() if (Array.isArray(value)) return value.map(item => trimStringValuesDeep(item)) if (value && typeof value === 'object') { const normalized: Record = {} for (const [key, child] of Object.entries(value as Record)) { normalized[key] = trimStringValuesDeep(child) } return normalized } return value } export default (config?: PiniaStorageConfig) => { const finalConfig = { ...baseConfig, ...(config || {}) } const { mode = 'single', debounce = 300, ...forageConfig } = finalConfig return (context: PiniaPluginContext) => { const persistOptions = resolvePersistOptions(context.options.persist as PersistOption | undefined) if (!persistOptions) return const storeId = context.store.$id const baseStoreName = forageConfig.storeName || baseConfig.storeName || 'pinia-storage' const resolvedStoreName = mode === 'multiple' ? `${baseStoreName}-${storeId}` : baseStoreName const key = persistOptions.key || (mode === 'single' ? `${baseStoreName}-${storeId}` : resolvedStoreName) const lf = getStore(mode, resolvedStoreName, forageConfig) const persistDebounce = persistOptions.debounce ?? debounce let timer: ReturnType | null = null let hydrating = false let userMutatedBeforeHydrate = false const writeState = (state: unknown) => { const clonedState = JSON.parse(JSON.stringify(state)) return lf.setItem(key, trimStringValuesDeep(clonedState)) } const storeExt = context.store as typeof context.store & PersistStoreExt storeExt.$persistNow = async () => { if (timer) { clearTimeout(timer) timer = null } try { await writeState(context.store.$state) } catch (error) { console.error('pinia persist failed:', error) } } storeExt.$clearPersisted = async () => { if (timer) { clearTimeout(timer) timer = null } try { await lf.removeItem(key) } catch (error) { console.error('pinia clear persisted failed:', error) } } context.store.$subscribe( (_mutation, state) => { if (!hydrating) userMutatedBeforeHydrate = true if (timer) clearTimeout(timer) timer = setTimeout(() => { void writeState(state).catch(error => { console.error('pinia persist failed:', error) }) }, Math.max(0, persistDebounce)) }, { detached: true } ) hydrating = true void lf.getItem>(key) .then(state => { if (!state || typeof state !== 'object') return // 若在异步hydrate返回前,store已被用户修改(如removeTab),不再回填旧缓存覆盖当前状态。 if (userMutatedBeforeHydrate) return context.store.$patch(state as any) }) .catch(error => { console.error('pinia hydrate failed:', error) }) .finally(() => { hydrating = false }) } }