useSimCardMonthlyCache.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import { reactive } from 'vue'
  2. import { getSimCardMonthlyData } from '@/service/simCardManage'
  3. import { simCardCacheDB } from '@/service/simCardManage/simCardCache'
  4. import type { SimCardMonthlyData, SimCardInfo } from '@/service/simCardManage/interface'
  5. /** 响应式内存缓存 key: `${giccid}_${month}` */
  6. const memoryCache = reactive(new Map<string, SimCardMonthlyData>())
  7. /** 正在请求中的 key 集合(响应式,驱动表格 loading 状态) */
  8. const loadingSet = reactive(new Set<string>())
  9. /**
  10. * 请求失败的 key 集合(响应式)。
  11. * 用于让失败项停止转圈、展示为 "-",而不是因未写入 memoryCache 而永久 loading。
  12. */
  13. const failedSet = reactive(new Set<string>())
  14. // ─── 队列控制 ───
  15. let fetchQueue: Array<{ giccid: string; month: string }> = []
  16. let currentVersion = 0
  17. let isProcessing = false
  18. // ─── 工具函数 ───
  19. export function cacheKey(giccid: string, month: string): string {
  20. return `${giccid}_${month}`
  21. }
  22. /** 获取上月 month 字符串 (输入 "2026-08" → 输出 "2026-07") */
  23. function getPrevMonth(month: string): string {
  24. const [year, mon] = month.split('-').map(Number)
  25. const date = new Date(year, mon - 2, 1)
  26. return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
  27. }
  28. // ─── 核心:请求单条数据并写入双层缓存 ───
  29. function fetchAndCache(giccid: string, month: string): Promise<SimCardMonthlyData> {
  30. const key = cacheKey(giccid, month)
  31. // 1. 内存缓存命中
  32. const memHit = memoryCache.get(key)
  33. if (memHit) return Promise.resolve(memHit)
  34. // 2. IndexedDB 缓存命中(带惰性过期)→ 命中则写入内存后返回;未命中再走网络请求
  35. return simCardCacheDB.get(giccid, month).then((dbHit) => {
  36. if (dbHit) {
  37. memoryCache.set(key, dbHit)
  38. return dbHit
  39. }
  40. // 3. 网络请求
  41. loadingSet.add(key)
  42. // 发起请求前清除历史失败标记(可能是重试)
  43. failedSet.delete(key)
  44. return getSimCardMonthlyData(giccid, month)
  45. .then((data) => {
  46. memoryCache.set(key, data)
  47. // 写入 IndexedDB 后再返回,保持与原逻辑一致
  48. return simCardCacheDB.set(giccid, month, data).then(() => data)
  49. })
  50. .catch((err: unknown) => {
  51. // 标记失败:让列渲染停止转圈、显示 "-"
  52. failedSet.add(key)
  53. // 继续向上抛出,交由调用方(队列 catch / 点击处理)决定后续
  54. throw err
  55. })
  56. .finally(() => {
  57. loadingSet.delete(key)
  58. })
  59. })
  60. }
  61. // ─── 队列处理循环 ───
  62. /**
  63. * 处理队列中的单个 item:请求数据,并在必要时追加上月数据到队列。
  64. * 单条失败仅记录日志,返回一个必定 resolve 的 Promise,绝不 reject,
  65. * 从而保证队列不会因单条请求出错而中断。
  66. */
  67. function processQueueItem(item: { giccid: string; month: string }): Promise<void> {
  68. const key = cacheKey(item.giccid, item.month)
  69. // 已在内存中则跳过
  70. if (memoryCache.has(key)) return Promise.resolve()
  71. // 记录发起请求时的版本,用于异步返回后判断队列是否已被重建
  72. const version = currentVersion
  73. return fetchAndCache(item.giccid, item.month)
  74. .then(() => {
  75. // 版本已变(页面/搜索条件切换)→ 当前项结果保留在缓存中,
  76. // 但不再基于旧上下文追加「上月数据」。
  77. if (currentVersion !== version) return
  78. // 如果获取到的数据最新日是1号,追加上月数据到队列
  79. const data = memoryCache.get(key)
  80. if (!data) return
  81. const days = Object.keys(data.dailyUsage).map(Number)
  82. if (days.length === 0) return
  83. const latestDay = Math.max(...days)
  84. if (latestDay !== 1) return
  85. const prevMonth = getPrevMonth(item.month)
  86. const prevKey = cacheKey(item.giccid, prevMonth)
  87. if (!memoryCache.has(prevKey)) {
  88. fetchQueue.push({ giccid: item.giccid, month: prevMonth })
  89. }
  90. })
  91. .catch((err: unknown) => {
  92. // 单条请求失败不能中断整个队列,否则后续项会永久卡在 loading。
  93. console.error('[SimCardCache] 预加载失败:', key, err)
  94. })
  95. }
  96. /**
  97. * 处理预加载队列(递归式 Promise 链,不使用 async/await 与 try)。
  98. *
  99. * 设计要点(修复「切换 pageSize / 翻页后加载卡住」的竞态死锁):
  100. * - 全局同一时刻只允许一个链在跑(isProcessing 保证)。
  101. * - 链不绑定某个固定版本,而是始终跟随全局的 fetchQueue。
  102. * 当 onPageChange 递增 currentVersion 并重建 fetchQueue 后:
  103. * · 若已有链在跑 → 它处理完当前项后会读取到新的 fetchQueue,无缝接管;
  104. * · 若没有链在跑 → onPageChange 调用 processQueue() 启动新链。
  105. * 这样绝不会出现「旧链退出、新链被 isProcessing 挡住」的死锁。
  106. * - processQueueItem 内部已吞掉单条错误,链本身不会 reject;
  107. * 仍用 .finally 兜底复位 isProcessing。
  108. */
  109. function processQueue() {
  110. if (isProcessing) return
  111. isProcessing = true
  112. // 递归步进:取出一项处理,完成后再处理下一项,队列空则收尾
  113. const step = (): Promise<void> => {
  114. const item = fetchQueue.shift()
  115. if (!item) return Promise.resolve()
  116. return processQueueItem(item).then(step)
  117. }
  118. step().finally(() => {
  119. isProcessing = false
  120. })
  121. }
  122. // ─── 计算日增量 ───
  123. /**
  124. * 计算某 SIM 卡当月的日增量
  125. * - 取最新一天的用量 - 前一天的用量
  126. * - 1号时需要上月最后一天数据
  127. * - 前一日数据不存在返回 null(展示 "-")
  128. */
  129. function computeDailyIncrement(giccid: string, month: string): number | null {
  130. const key = cacheKey(giccid, month)
  131. const data = memoryCache.get(key)
  132. if (!data) return null
  133. const days = Object.keys(data.dailyUsage).map(Number).sort((a, b) => a - b)
  134. if (days.length === 0) return null
  135. const latestDay = days.at(-1)!
  136. const latestUsage = data.dailyUsage[latestDay].dataUsage
  137. if (latestDay === 1) {
  138. // 1日:读取上月最后一天
  139. const prevMonth = getPrevMonth(month)
  140. const prevKey = cacheKey(giccid, prevMonth)
  141. const prevData = memoryCache.get(prevKey)
  142. if (!prevData) return null
  143. const prevDays = Object.keys(prevData.dailyUsage).map(Number).sort((a, b) => a - b)
  144. if (prevDays.length === 0) return null
  145. const lastDayOfPrevMonth = prevDays.at(-1)!
  146. return latestUsage - prevData.dailyUsage[lastDayOfPrevMonth].dataUsage
  147. } else {
  148. // 非1日:取前一天
  149. const prevDayData = data.dailyUsage[latestDay - 1]
  150. if (!prevDayData) return null
  151. return latestUsage - prevDayData.dataUsage
  152. }
  153. }
  154. // ─── 对外暴露的方法 ───
  155. /**
  156. * 翻页 / 搜索后调用:重建预加载队列
  157. * 已缓存的数据会被跳过,只补充未缓存的
  158. */
  159. function onPageChange(simCards: SimCardInfo[]) {
  160. // 递增版本号:正在跑的循环会在下一轮迭代感知到并切换到新队列
  161. currentVersion++
  162. // 重建队列
  163. fetchQueue = []
  164. for (const sim of simCards) {
  165. const key = cacheKey(sim.giccid, sim.month)
  166. // 已缓存 / 正在请求 / 已知失败的都跳过,避免重复请求与无谓的重复失败
  167. if (!memoryCache.has(key) && !loadingSet.has(key) && !failedSet.has(key)) {
  168. fetchQueue.push({ giccid: sim.giccid, month: sim.month })
  169. }
  170. }
  171. // 若没有循环在跑则启动;若已有循环在跑,它会自动接管新队列
  172. processQueue()
  173. }
  174. /**
  175. * 点击查看月数据时调用(优先级最高)
  176. * 直接发请求,不等待队列
  177. */
  178. function getWithPriority(giccid: string, month: string): Promise<SimCardMonthlyData> {
  179. return fetchAndCache(giccid, month)
  180. }
  181. export function useSimCardMonthlyCache() {
  182. return {
  183. memoryCache,
  184. loadingSet,
  185. failedSet,
  186. cacheKey,
  187. computeDailyIncrement,
  188. onPageChange,
  189. getWithPriority,
  190. }
  191. }