|
|
@@ -9,6 +9,12 @@ const memoryCache = reactive(new Map<string, SimCardMonthlyData>())
|
|
|
/** 正在请求中的 key 集合(响应式,驱动表格 loading 状态) */
|
|
|
const loadingSet = reactive(new Set<string>())
|
|
|
|
|
|
+/**
|
|
|
+ * 请求失败的 key 集合(响应式)。
|
|
|
+ * 用于让失败项停止转圈、展示为 "-",而不是因未写入 memoryCache 而永久 loading。
|
|
|
+ */
|
|
|
+const failedSet = reactive(new Set<string>())
|
|
|
+
|
|
|
// ─── 队列控制 ───
|
|
|
let fetchQueue: Array<{ giccid: string; month: string }> = []
|
|
|
let currentVersion = 0
|
|
|
@@ -29,71 +35,113 @@ function getPrevMonth(month: string): string {
|
|
|
|
|
|
// ─── 核心:请求单条数据并写入双层缓存 ───
|
|
|
|
|
|
-async function fetchAndCache(giccid: string, month: string): Promise<SimCardMonthlyData> {
|
|
|
+function fetchAndCache(giccid: string, month: string): Promise<SimCardMonthlyData> {
|
|
|
const key = cacheKey(giccid, month)
|
|
|
|
|
|
// 1. 内存缓存命中
|
|
|
const memHit = memoryCache.get(key)
|
|
|
- if (memHit) return memHit
|
|
|
+ if (memHit) return Promise.resolve(memHit)
|
|
|
|
|
|
- // 2. IndexedDB 缓存命中(带惰性过期)
|
|
|
- const dbHit = await simCardCacheDB.get(giccid, month)
|
|
|
- if (dbHit) {
|
|
|
- memoryCache.set(key, dbHit)
|
|
|
- return dbHit
|
|
|
- }
|
|
|
+ // 2. IndexedDB 缓存命中(带惰性过期)→ 命中则写入内存后返回;未命中再走网络请求
|
|
|
+ return simCardCacheDB.get(giccid, month).then((dbHit) => {
|
|
|
+ if (dbHit) {
|
|
|
+ memoryCache.set(key, dbHit)
|
|
|
+ return dbHit
|
|
|
+ }
|
|
|
|
|
|
- // 3. 网络请求
|
|
|
- loadingSet.add(key)
|
|
|
- try {
|
|
|
- const data = await getSimCardMonthlyData(giccid, month)
|
|
|
- memoryCache.set(key, data)
|
|
|
- await simCardCacheDB.set(giccid, month, data)
|
|
|
- return data
|
|
|
- } finally {
|
|
|
- loadingSet.delete(key)
|
|
|
- }
|
|
|
+ // 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)
|
|
|
+ })
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
// ─── 队列处理循环 ───
|
|
|
|
|
|
-async function processQueue(version: number) {
|
|
|
- if (isProcessing) return
|
|
|
- isProcessing = true
|
|
|
-
|
|
|
- while (fetchQueue.length > 0) {
|
|
|
- // 版本号不匹配 → 当前请求完成后停止
|
|
|
- if (currentVersion !== version) break
|
|
|
+/**
|
|
|
+ * 处理队列中的单个 item:请求数据,并在必要时追加上月数据到队列。
|
|
|
+ * 单条失败仅记录日志,返回一个必定 resolve 的 Promise,绝不 reject,
|
|
|
+ * 从而保证队列不会因单条请求出错而中断。
|
|
|
+ */
|
|
|
+function processQueueItem(item: { giccid: string; month: string }): Promise<void> {
|
|
|
+ const key = cacheKey(item.giccid, item.month)
|
|
|
|
|
|
- const item = fetchQueue.shift()!
|
|
|
- const key = cacheKey(item.giccid, item.month)
|
|
|
+ // 已在内存中则跳过
|
|
|
+ if (memoryCache.has(key)) return Promise.resolve()
|
|
|
|
|
|
- // 已在内存中则跳过
|
|
|
- if (memoryCache.has(key)) continue
|
|
|
+ // 记录发起请求时的版本,用于异步返回后判断队列是否已被重建
|
|
|
+ const version = currentVersion
|
|
|
|
|
|
- await fetchAndCache(item.giccid, item.month)
|
|
|
+ return fetchAndCache(item.giccid, item.month)
|
|
|
+ .then(() => {
|
|
|
+ // 版本已变(页面/搜索条件切换)→ 当前项结果保留在缓存中,
|
|
|
+ // 但不再基于旧上下文追加「上月数据」。
|
|
|
+ if (currentVersion !== version) return
|
|
|
|
|
|
- // 检查版本号是否仍有效(fetchAndCache 是异步的)
|
|
|
- if (currentVersion !== version) break
|
|
|
+ // 如果获取到的数据最新日是1号,追加上月数据到队列
|
|
|
+ const data = memoryCache.get(key)
|
|
|
+ if (!data) return
|
|
|
|
|
|
- // 如果获取到的数据最新日是1号,追加上月数据到队列
|
|
|
- const data = memoryCache.get(key)
|
|
|
- if (data) {
|
|
|
const days = Object.keys(data.dailyUsage).map(Number)
|
|
|
- if (days.length > 0) {
|
|
|
- const latestDay = Math.max(...days)
|
|
|
- if (latestDay === 1) {
|
|
|
- const prevMonth = getPrevMonth(item.month)
|
|
|
- const prevKey = cacheKey(item.giccid, prevMonth)
|
|
|
- if (!memoryCache.has(prevKey)) {
|
|
|
- fetchQueue.push({ giccid: item.giccid, month: prevMonth })
|
|
|
- }
|
|
|
- }
|
|
|
+ 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<void> => {
|
|
|
+ const item = fetchQueue.shift()
|
|
|
+ if (!item) return Promise.resolve()
|
|
|
+ return processQueueItem(item).then(step)
|
|
|
}
|
|
|
|
|
|
- isProcessing = false
|
|
|
+ step().finally(() => {
|
|
|
+ isProcessing = false
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
// ─── 计算日增量 ───
|
|
|
@@ -112,7 +160,7 @@ function computeDailyIncrement(giccid: string, month: string): number | null {
|
|
|
const days = Object.keys(data.dailyUsage).map(Number).sort((a, b) => a - b)
|
|
|
if (days.length === 0) return null
|
|
|
|
|
|
- const latestDay = days[days.length - 1]
|
|
|
+ const latestDay = days.at(-1)!
|
|
|
const latestUsage = data.dailyUsage[latestDay].dataUsage
|
|
|
|
|
|
if (latestDay === 1) {
|
|
|
@@ -125,7 +173,7 @@ function computeDailyIncrement(giccid: string, month: string): number | null {
|
|
|
const prevDays = Object.keys(prevData.dailyUsage).map(Number).sort((a, b) => a - b)
|
|
|
if (prevDays.length === 0) return null
|
|
|
|
|
|
- const lastDayOfPrevMonth = prevDays[prevDays.length - 1]
|
|
|
+ const lastDayOfPrevMonth = prevDays.at(-1)!
|
|
|
return latestUsage - prevData.dailyUsage[lastDayOfPrevMonth].dataUsage
|
|
|
} else {
|
|
|
// 非1日:取前一天
|
|
|
@@ -142,33 +190,36 @@ function computeDailyIncrement(giccid: string, month: string): number | null {
|
|
|
* 已缓存的数据会被跳过,只补充未缓存的
|
|
|
*/
|
|
|
function onPageChange(simCards: SimCardInfo[]) {
|
|
|
+ // 递增版本号:正在跑的循环会在下一轮迭代感知到并切换到新队列
|
|
|
currentVersion++
|
|
|
- const version = currentVersion
|
|
|
|
|
|
// 重建队列
|
|
|
fetchQueue = []
|
|
|
for (const sim of simCards) {
|
|
|
const key = cacheKey(sim.giccid, sim.month)
|
|
|
- if (!memoryCache.has(key) && !loadingSet.has(key)) {
|
|
|
+ // 已缓存 / 正在请求 / 已知失败的都跳过,避免重复请求与无谓的重复失败
|
|
|
+ if (!memoryCache.has(key) && !loadingSet.has(key) && !failedSet.has(key)) {
|
|
|
fetchQueue.push({ giccid: sim.giccid, month: sim.month })
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- processQueue(version)
|
|
|
+ // 若没有循环在跑则启动;若已有循环在跑,它会自动接管新队列
|
|
|
+ processQueue()
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 点击查看月数据时调用(优先级最高)
|
|
|
* 直接发请求,不等待队列
|
|
|
*/
|
|
|
-async function getWithPriority(giccid: string, month: string): Promise<SimCardMonthlyData> {
|
|
|
- return await fetchAndCache(giccid, month)
|
|
|
+function getWithPriority(giccid: string, month: string): Promise<SimCardMonthlyData> {
|
|
|
+ return fetchAndCache(giccid, month)
|
|
|
}
|
|
|
|
|
|
export function useSimCardMonthlyCache() {
|
|
|
return {
|
|
|
memoryCache,
|
|
|
loadingSet,
|
|
|
+ failedSet,
|
|
|
cacheKey,
|
|
|
computeDailyIncrement,
|
|
|
onPageChange,
|