refreshStore.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. /**
  2. * 统一刷新(票 61)—— 一次动作刷新「地图摘要 + 当前一级模式的面板分区」。
  3. *
  4. * 为什么要有这个 store(票 61 验收第 2 条「刷新动作只有一处入口」):
  5. * 右上角按钮与新鲜度轮询(`START_FRESHNESS` 的 tick)**都只调本文件的 requestRefresh()**,
  6. * 所以"刷新到底做了什么"整页只有一份定义,不存在按钮一套、定时一套。外壳只做两件事:
  7. * 把真实取数接缝 `configure` 进来,以及把它绑到按钮/tick 上。
  8. *
  9. * 四条纪律(逐条对着票面要求):
  10. * ① **并发**:地图摘要与面板互不依赖,同时发起 —— 任一失败都拖不住另一路;
  11. * ② **分区独立成败**:逐项结果各自记(`lastRun.parts`),一块面板失败只让它自己出现在
  12. * `failed` 里,其余照常更新;不整页报错、不回退 mock;
  13. * ③ **单飞**:进行中再次调用返回**同一轮**,连点或与轮询重叠都不会重复发请求;
  14. * ④ **记账同源**:本轮成败写进 freshnessService 那一本账(`recordFreshness` →
  15. * `recordExternal`),不在本文件另存一份「最近刷新时间」。
  16. *
  17. * 判据不在这里:哪些面板算失败、整轮怎么结论、文案怎么说,全在
  18. * `domain/refreshRun.js`(纯函数,可脱浏览器断言)。
  19. */
  20. import { computed, ref } from 'vue'
  21. import { defineStore } from 'pinia'
  22. import {
  23. REFRESH_PART,
  24. REFRESH_STATE,
  25. judgeMapRefresh,
  26. judgePanels,
  27. planRefresh,
  28. refreshText,
  29. summarizeRefresh,
  30. } from '../domain/refreshRun.js'
  31. /** 取数接缝(外壳 configure 注入真实现;逻辑闸门注入假服务,不依赖网络)。 */
  32. function emptyDeps() {
  33. return { refreshMap: null, refreshPanels: null, recordFreshness: null }
  34. }
  35. /** 接缝没接上时如实失败,而不是静默跳过(跳过会被读成"刷新成功")。 */
  36. function callDependency(fn, plan, missing) {
  37. if (typeof fn !== 'function') throw new Error(missing)
  38. return fn(plan)
  39. }
  40. /** 单路取数隔离:任一路抛错都收成 `{ ok:false, reason }`,不影响另一路。 */
  41. async function guard(run) {
  42. try {
  43. return { ok: true, value: await run() }
  44. } catch (error) {
  45. return { ok: false, reason: String((error && error.message) || error) }
  46. }
  47. }
  48. export const useRefreshStore = defineStore('refreshStore', () => {
  49. /** 是否正有一轮统一刷新在跑(按钮禁用 + aria-busy 的唯一依据)。 */
  50. const refreshing = ref(false)
  51. /** 状态机:idle / running / ok / partial / failed。 */
  52. const state = ref(REFRESH_STATE.IDLE)
  53. /** 本轮(或上一轮)的刷新计划:地图摘要 + 当前模式的全部分区。 */
  54. const plan = ref(null)
  55. /** 上一轮结论(逐项结果、失败项、文案);走查与排查读它。 */
  56. const lastRun = ref(null)
  57. let deps = emptyDeps()
  58. let inFlight = null
  59. /** 轮次纪元:`reset()` 作废在跑的那一轮,迟到的结论不再写状态、不再记账(票 14 的同款纪律)。 */
  60. let runEpoch = 0
  61. /** 注入取数接缝(合并语义:与 mapStore.setup 同一条纪律,后到的不抹掉先到的)。 */
  62. function configure(next = {}) {
  63. deps = { ...deps, ...next }
  64. }
  65. /** 可见进度/说明文案(按钮 aria-label 与 title 读它,界面不自己拼)。 */
  66. const text = computed(() => refreshText(state.value, {
  67. plan: plan.value,
  68. parts: (lastRun.value && lastRun.value.parts) || [],
  69. failed: (lastRun.value && lastRun.value.failed) || [],
  70. }))
  71. /** 上一轮失败项的 key(`data-refresh-failed` 断言面;空串 = 没有失败)。 */
  72. const failedKeys = computed(() => ((lastRun.value && lastRun.value.failed) || []).join(','))
  73. /**
  74. * 统一刷新:地图摘要 + 当前模式面板。
  75. * @param {object} [options] `{ mode, account }`
  76. * `mode` 当前一级模式(计划据此取面板清单);
  77. * `account` 是否记新鲜度(默认 true;轮询那条路传 false —— 本轮记账归
  78. * `freshnessService` 的周期,同一本账不重复记)
  79. * @returns {Promise<object>} 本轮结论(`summarizeRefresh` 的结果)
  80. */
  81. function requestRefresh(options = {}) {
  82. if (inFlight) return inFlight
  83. const current = planRefresh(options.mode)
  84. const mine = ++runEpoch
  85. plan.value = current
  86. state.value = REFRESH_STATE.RUNNING
  87. refreshing.value = true
  88. inFlight = runOnce(current, options, mine)
  89. return inFlight
  90. }
  91. /**
  92. * 跑一轮:两路并发 → 逐项判据 → 整轮结论 → 记账。
  93. *
  94. * 这里刻意**不写 try 包住业务**:单路抛错已在 `guard` 里收成结果;
  95. * 只有判据/计划本身出问题(编程错误)才会落到外层兜底,且仍如实报失败、绝不外抛。
  96. */
  97. async function runOnce(current, options, mine) {
  98. let outcome = null
  99. try {
  100. const [mapResult, panelResult] = await Promise.all([
  101. guard(() => callDependency(deps.refreshMap, current, '地图摘要取数未接入')),
  102. guard(() => callDependency(deps.refreshPanels, current, '面板取数未接入')),
  103. ])
  104. outcome = summarizeRefresh({
  105. mode: current.mode,
  106. plan: current,
  107. parts: [mapPartOf(current, mapResult), ...panelPartsOf(current, panelResult)],
  108. })
  109. } catch (error) {
  110. // 兜底:连计划/判据都炸了也要给出一轮"全失败"的结论(界面不能停在刷新中)。
  111. const reason = String((error && error.message) || error)
  112. outcome = summarizeRefresh({
  113. mode: current.mode,
  114. plan: current,
  115. parts: current.parts.map((part) => ({ ...part, ok: false, reason })),
  116. })
  117. } finally {
  118. if (mine === runEpoch) {
  119. refreshing.value = false
  120. inFlight = null
  121. }
  122. }
  123. // 被 reset() 作废的轮次:结论丢弃,不再写状态、不再记新鲜度(离开外壳后不该再动 store)。
  124. if (mine !== runEpoch) return outcome
  125. lastRun.value = outcome
  126. state.value = outcome.state
  127. if (options.account !== false && typeof deps.recordFreshness === 'function') {
  128. deps.recordFreshness(outcome.ok, outcome.reason)
  129. }
  130. return outcome
  131. }
  132. /** 地图摘要那一项的逐项结果(取数抛错时按抛错原因记失败)。 */
  133. function mapPartOf(current, result) {
  134. const base = current.parts.find((part) => part.kind === 'map') || { key: REFRESH_PART.MAP, kind: 'map', label: '地图摘要' }
  135. if (!result.ok) return { ...base, ok: false, reason: result.reason }
  136. return { ...base, ...judgeMapRefresh(result.value) }
  137. }
  138. /**
  139. * 面板各项的逐项结果。
  140. *
  141. * `refreshPanels` 的契约:取完数后回一个「panelId → 分区」的查表函数,
  142. * 由 `domain/refreshRun.js` 按分区状态判成败(判据只有一处,外壳不自己数)。
  143. * 整路取数就抛错时,该模式的面板**全部**如实记失败并带同一个原因。
  144. */
  145. function panelPartsOf(current, result) {
  146. const bases = current.parts.filter((part) => part.kind === 'panel')
  147. if (!result.ok) return bases.map((base) => ({ ...base, ok: false, state: '', reason: result.reason }))
  148. const judged = judgePanels(current.panels, result.value)
  149. return bases.map((base) => {
  150. const item = judged.find((entry) => entry.key === base.key)
  151. return { ...base, ok: Boolean(item && item.ok), state: (item && item.state) || '', reason: (item && item.reason) || 'unavailable' }
  152. })
  153. }
  154. /** 仅测试用/离开外壳用:作废在跑的那一轮并回到未刷新状态。 */
  155. function reset() {
  156. runEpoch += 1
  157. inFlight = null
  158. refreshing.value = false
  159. state.value = REFRESH_STATE.IDLE
  160. plan.value = null
  161. lastRun.value = null
  162. deps = emptyDeps()
  163. }
  164. return {
  165. refreshing,
  166. state,
  167. plan,
  168. lastRun,
  169. text,
  170. failedKeys,
  171. configure,
  172. requestRefresh,
  173. reset,
  174. }
  175. })