import { reactive } from 'vue' import { getSimCardMonthlyData } from '@/service/simCardManage' import { simCardCacheDB } from '@/service/simCardManage/simCardCache' import type { SimCardMonthlyData, SimCardInfo } from '@/service/simCardManage/interface' /** 响应式内存缓存 key: `${giccid}_${month}` */ const memoryCache = reactive(new Map()) /** 正在请求中的 key 集合(响应式,驱动表格 loading 状态) */ const loadingSet = reactive(new Set()) /** * 请求失败的 key 集合(响应式)。 * 用于让失败项停止转圈、展示为 "-",而不是因未写入 memoryCache 而永久 loading。 */ const failedSet = reactive(new Set()) // ─── 队列控制 ─── let fetchQueue: Array<{ giccid: string; month: string }> = [] let currentVersion = 0 let isProcessing = false // ─── 工具函数 ─── export function cacheKey(giccid: string, month: string): string { return `${giccid}_${month}` } /** 获取上月 month 字符串 (输入 "2026-08" → 输出 "2026-07") */ function getPrevMonth(month: string): string { const [year, mon] = month.split('-').map(Number) const date = new Date(year, mon - 2, 1) return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` } // ─── 核心:请求单条数据并写入双层缓存 ─── function fetchAndCache(giccid: string, month: string): Promise { const key = cacheKey(giccid, month) // 1. 内存缓存命中 const memHit = memoryCache.get(key) if (memHit) return Promise.resolve(memHit) // 2. IndexedDB 缓存命中(带惰性过期)→ 命中则写入内存后返回;未命中再走网络请求 return simCardCacheDB.get(giccid, month).then((dbHit) => { if (dbHit) { memoryCache.set(key, dbHit) return dbHit } // 3. 网络请求 loadingSet.add(key) // 发起请求前清除历史失败标记(可能是重试) failedSet.delete(key) return getSimCardMonthlyData(giccid, month) .then((data) => { memoryCache.set(key, data) // 写入 IndexedDB 后再返回,保持与原逻辑一致 return simCardCacheDB.set(giccid, month, data).then(() => data) }) .catch((err: unknown) => { // 标记失败:让列渲染停止转圈、显示 "-" failedSet.add(key) // 继续向上抛出,交由调用方(队列 catch / 点击处理)决定后续 throw err }) .finally(() => { loadingSet.delete(key) }) }) } // ─── 队列处理循环 ─── /** * 处理队列中的单个 item:请求数据,并在必要时追加上月数据到队列。 * 单条失败仅记录日志,返回一个必定 resolve 的 Promise,绝不 reject, * 从而保证队列不会因单条请求出错而中断。 */ function processQueueItem(item: { giccid: string; month: string }): Promise { const key = cacheKey(item.giccid, item.month) // 已在内存中则跳过 if (memoryCache.has(key)) return Promise.resolve() // 记录发起请求时的版本,用于异步返回后判断队列是否已被重建 const version = currentVersion return fetchAndCache(item.giccid, item.month) .then(() => { // 版本已变(页面/搜索条件切换)→ 当前项结果保留在缓存中, // 但不再基于旧上下文追加「上月数据」。 if (currentVersion !== version) return // 如果获取到的数据最新日是1号,追加上月数据到队列 const data = memoryCache.get(key) if (!data) return const days = Object.keys(data.dailyUsage).map(Number) if (days.length === 0) return const latestDay = Math.max(...days) if (latestDay !== 1) return const prevMonth = getPrevMonth(item.month) const prevKey = cacheKey(item.giccid, prevMonth) if (!memoryCache.has(prevKey)) { fetchQueue.push({ giccid: item.giccid, month: prevMonth }) } }) .catch((err: unknown) => { // 单条请求失败不能中断整个队列,否则后续项会永久卡在 loading。 console.error('[SimCardCache] 预加载失败:', key, err) }) } /** * 处理预加载队列(递归式 Promise 链,不使用 async/await 与 try)。 * * 设计要点(修复「切换 pageSize / 翻页后加载卡住」的竞态死锁): * - 全局同一时刻只允许一个链在跑(isProcessing 保证)。 * - 链不绑定某个固定版本,而是始终跟随全局的 fetchQueue。 * 当 onPageChange 递增 currentVersion 并重建 fetchQueue 后: * · 若已有链在跑 → 它处理完当前项后会读取到新的 fetchQueue,无缝接管; * · 若没有链在跑 → onPageChange 调用 processQueue() 启动新链。 * 这样绝不会出现「旧链退出、新链被 isProcessing 挡住」的死锁。 * - processQueueItem 内部已吞掉单条错误,链本身不会 reject; * 仍用 .finally 兜底复位 isProcessing。 */ function processQueue() { if (isProcessing) return isProcessing = true // 递归步进:取出一项处理,完成后再处理下一项,队列空则收尾 const step = (): Promise => { const item = fetchQueue.shift() if (!item) return Promise.resolve() return processQueueItem(item).then(step) } step().finally(() => { isProcessing = false }) } // ─── 计算日增量 ─── /** * 计算某 SIM 卡当月的日增量 * - 取最新一天的用量 - 前一天的用量 * - 1号时需要上月最后一天数据 * - 前一日数据不存在返回 null(展示 "-") */ function computeDailyIncrement(giccid: string, month: string): number | null { const key = cacheKey(giccid, month) const data = memoryCache.get(key) if (!data) return null const days = Object.keys(data.dailyUsage).map(Number).sort((a, b) => a - b) if (days.length === 0) return null const latestDay = days.at(-1)! const latestUsage = data.dailyUsage[latestDay].dataUsage if (latestDay === 1) { // 1日:读取上月最后一天 const prevMonth = getPrevMonth(month) const prevKey = cacheKey(giccid, prevMonth) const prevData = memoryCache.get(prevKey) if (!prevData) return null const prevDays = Object.keys(prevData.dailyUsage).map(Number).sort((a, b) => a - b) if (prevDays.length === 0) return null const lastDayOfPrevMonth = prevDays.at(-1)! return latestUsage - prevData.dailyUsage[lastDayOfPrevMonth].dataUsage } else { // 非1日:取前一天 const prevDayData = data.dailyUsage[latestDay - 1] if (!prevDayData) return null return latestUsage - prevDayData.dataUsage } } // ─── 对外暴露的方法 ─── /** * 翻页 / 搜索后调用:重建预加载队列 * 已缓存的数据会被跳过,只补充未缓存的 */ function onPageChange(simCards: SimCardInfo[]) { // 递增版本号:正在跑的循环会在下一轮迭代感知到并切换到新队列 currentVersion++ // 重建队列 fetchQueue = [] for (const sim of simCards) { const key = cacheKey(sim.giccid, sim.month) // 已缓存 / 正在请求 / 已知失败的都跳过,避免重复请求与无谓的重复失败 if (!memoryCache.has(key) && !loadingSet.has(key) && !failedSet.has(key)) { fetchQueue.push({ giccid: sim.giccid, month: sim.month }) } } // 若没有循环在跑则启动;若已有循环在跑,它会自动接管新队列 processQueue() } /** * 点击查看月数据时调用(优先级最高) * 直接发请求,不等待队列 */ function getWithPriority(giccid: string, month: string): Promise { return fetchAndCache(giccid, month) } export function useSimCardMonthlyCache() { return { memoryCache, loadingSet, failedSet, cacheKey, computeDailyIncrement, onPageChange, getWithPriority, } }