|
|
@@ -0,0 +1,192 @@
|
|
|
+/**
|
|
|
+ * 统一刷新(票 61)—— 一次动作刷新「地图摘要 + 当前一级模式的面板分区」。
|
|
|
+ *
|
|
|
+ * 为什么要有这个 store(票 61 验收第 2 条「刷新动作只有一处入口」):
|
|
|
+ * 右上角按钮与新鲜度轮询(`START_FRESHNESS` 的 tick)**都只调本文件的 requestRefresh()**,
|
|
|
+ * 所以"刷新到底做了什么"整页只有一份定义,不存在按钮一套、定时一套。外壳只做两件事:
|
|
|
+ * 把真实取数接缝 `configure` 进来,以及把它绑到按钮/tick 上。
|
|
|
+ *
|
|
|
+ * 四条纪律(逐条对着票面要求):
|
|
|
+ * ① **并发**:地图摘要与面板互不依赖,同时发起 —— 任一失败都拖不住另一路;
|
|
|
+ * ② **分区独立成败**:逐项结果各自记(`lastRun.parts`),一块面板失败只让它自己出现在
|
|
|
+ * `failed` 里,其余照常更新;不整页报错、不回退 mock;
|
|
|
+ * ③ **单飞**:进行中再次调用返回**同一轮**,连点或与轮询重叠都不会重复发请求;
|
|
|
+ * ④ **记账同源**:本轮成败写进 freshnessService 那一本账(`recordFreshness` →
|
|
|
+ * `recordExternal`),不在本文件另存一份「最近刷新时间」。
|
|
|
+ *
|
|
|
+ * 判据不在这里:哪些面板算失败、整轮怎么结论、文案怎么说,全在
|
|
|
+ * `domain/refreshRun.js`(纯函数,可脱浏览器断言)。
|
|
|
+ */
|
|
|
+import { computed, ref } from 'vue'
|
|
|
+import { defineStore } from 'pinia'
|
|
|
+import {
|
|
|
+ REFRESH_PART,
|
|
|
+ REFRESH_STATE,
|
|
|
+ judgeMapRefresh,
|
|
|
+ judgePanels,
|
|
|
+ planRefresh,
|
|
|
+ refreshText,
|
|
|
+ summarizeRefresh,
|
|
|
+} from '../domain/refreshRun.js'
|
|
|
+
|
|
|
+/** 取数接缝(外壳 configure 注入真实现;逻辑闸门注入假服务,不依赖网络)。 */
|
|
|
+function emptyDeps() {
|
|
|
+ return { refreshMap: null, refreshPanels: null, recordFreshness: null }
|
|
|
+}
|
|
|
+
|
|
|
+/** 接缝没接上时如实失败,而不是静默跳过(跳过会被读成"刷新成功")。 */
|
|
|
+function callDependency(fn, plan, missing) {
|
|
|
+ if (typeof fn !== 'function') throw new Error(missing)
|
|
|
+ return fn(plan)
|
|
|
+}
|
|
|
+
|
|
|
+/** 单路取数隔离:任一路抛错都收成 `{ ok:false, reason }`,不影响另一路。 */
|
|
|
+async function guard(run) {
|
|
|
+ try {
|
|
|
+ return { ok: true, value: await run() }
|
|
|
+ } catch (error) {
|
|
|
+ return { ok: false, reason: String((error && error.message) || error) }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export const useRefreshStore = defineStore('refreshStore', () => {
|
|
|
+ /** 是否正有一轮统一刷新在跑(按钮禁用 + aria-busy 的唯一依据)。 */
|
|
|
+ const refreshing = ref(false)
|
|
|
+
|
|
|
+ /** 状态机:idle / running / ok / partial / failed。 */
|
|
|
+ const state = ref(REFRESH_STATE.IDLE)
|
|
|
+
|
|
|
+ /** 本轮(或上一轮)的刷新计划:地图摘要 + 当前模式的全部分区。 */
|
|
|
+ const plan = ref(null)
|
|
|
+
|
|
|
+ /** 上一轮结论(逐项结果、失败项、文案);走查与排查读它。 */
|
|
|
+ const lastRun = ref(null)
|
|
|
+
|
|
|
+ let deps = emptyDeps()
|
|
|
+ let inFlight = null
|
|
|
+ /** 轮次纪元:`reset()` 作废在跑的那一轮,迟到的结论不再写状态、不再记账(票 14 的同款纪律)。 */
|
|
|
+ let runEpoch = 0
|
|
|
+
|
|
|
+ /** 注入取数接缝(合并语义:与 mapStore.setup 同一条纪律,后到的不抹掉先到的)。 */
|
|
|
+ function configure(next = {}) {
|
|
|
+ deps = { ...deps, ...next }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 可见进度/说明文案(按钮 aria-label 与 title 读它,界面不自己拼)。 */
|
|
|
+ const text = computed(() => refreshText(state.value, {
|
|
|
+ plan: plan.value,
|
|
|
+ parts: (lastRun.value && lastRun.value.parts) || [],
|
|
|
+ failed: (lastRun.value && lastRun.value.failed) || [],
|
|
|
+ }))
|
|
|
+
|
|
|
+ /** 上一轮失败项的 key(`data-refresh-failed` 断言面;空串 = 没有失败)。 */
|
|
|
+ const failedKeys = computed(() => ((lastRun.value && lastRun.value.failed) || []).join(','))
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 统一刷新:地图摘要 + 当前模式面板。
|
|
|
+ * @param {object} [options] `{ mode, account }`
|
|
|
+ * `mode` 当前一级模式(计划据此取面板清单);
|
|
|
+ * `account` 是否记新鲜度(默认 true;轮询那条路传 false —— 本轮记账归
|
|
|
+ * `freshnessService` 的周期,同一本账不重复记)
|
|
|
+ * @returns {Promise<object>} 本轮结论(`summarizeRefresh` 的结果)
|
|
|
+ */
|
|
|
+ function requestRefresh(options = {}) {
|
|
|
+ if (inFlight) return inFlight
|
|
|
+ const current = planRefresh(options.mode)
|
|
|
+ const mine = ++runEpoch
|
|
|
+ plan.value = current
|
|
|
+ state.value = REFRESH_STATE.RUNNING
|
|
|
+ refreshing.value = true
|
|
|
+ inFlight = runOnce(current, options, mine)
|
|
|
+ return inFlight
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 跑一轮:两路并发 → 逐项判据 → 整轮结论 → 记账。
|
|
|
+ *
|
|
|
+ * 这里刻意**不写 try 包住业务**:单路抛错已在 `guard` 里收成结果;
|
|
|
+ * 只有判据/计划本身出问题(编程错误)才会落到外层兜底,且仍如实报失败、绝不外抛。
|
|
|
+ */
|
|
|
+ async function runOnce(current, options, mine) {
|
|
|
+ let outcome = null
|
|
|
+ try {
|
|
|
+ const [mapResult, panelResult] = await Promise.all([
|
|
|
+ guard(() => callDependency(deps.refreshMap, current, '地图摘要取数未接入')),
|
|
|
+ guard(() => callDependency(deps.refreshPanels, current, '面板取数未接入')),
|
|
|
+ ])
|
|
|
+ outcome = summarizeRefresh({
|
|
|
+ mode: current.mode,
|
|
|
+ plan: current,
|
|
|
+ parts: [mapPartOf(current, mapResult), ...panelPartsOf(current, panelResult)],
|
|
|
+ })
|
|
|
+ } catch (error) {
|
|
|
+ // 兜底:连计划/判据都炸了也要给出一轮"全失败"的结论(界面不能停在刷新中)。
|
|
|
+ const reason = String((error && error.message) || error)
|
|
|
+ outcome = summarizeRefresh({
|
|
|
+ mode: current.mode,
|
|
|
+ plan: current,
|
|
|
+ parts: current.parts.map((part) => ({ ...part, ok: false, reason })),
|
|
|
+ })
|
|
|
+ } finally {
|
|
|
+ if (mine === runEpoch) {
|
|
|
+ refreshing.value = false
|
|
|
+ inFlight = null
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 被 reset() 作废的轮次:结论丢弃,不再写状态、不再记新鲜度(离开外壳后不该再动 store)。
|
|
|
+ if (mine !== runEpoch) return outcome
|
|
|
+ lastRun.value = outcome
|
|
|
+ state.value = outcome.state
|
|
|
+ if (options.account !== false && typeof deps.recordFreshness === 'function') {
|
|
|
+ deps.recordFreshness(outcome.ok, outcome.reason)
|
|
|
+ }
|
|
|
+ return outcome
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 地图摘要那一项的逐项结果(取数抛错时按抛错原因记失败)。 */
|
|
|
+ function mapPartOf(current, result) {
|
|
|
+ const base = current.parts.find((part) => part.kind === 'map') || { key: REFRESH_PART.MAP, kind: 'map', label: '地图摘要' }
|
|
|
+ if (!result.ok) return { ...base, ok: false, reason: result.reason }
|
|
|
+ return { ...base, ...judgeMapRefresh(result.value) }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 面板各项的逐项结果。
|
|
|
+ *
|
|
|
+ * `refreshPanels` 的契约:取完数后回一个「panelId → 分区」的查表函数,
|
|
|
+ * 由 `domain/refreshRun.js` 按分区状态判成败(判据只有一处,外壳不自己数)。
|
|
|
+ * 整路取数就抛错时,该模式的面板**全部**如实记失败并带同一个原因。
|
|
|
+ */
|
|
|
+ function panelPartsOf(current, result) {
|
|
|
+ const bases = current.parts.filter((part) => part.kind === 'panel')
|
|
|
+ if (!result.ok) return bases.map((base) => ({ ...base, ok: false, state: '', reason: result.reason }))
|
|
|
+ const judged = judgePanels(current.panels, result.value)
|
|
|
+ return bases.map((base) => {
|
|
|
+ const item = judged.find((entry) => entry.key === base.key)
|
|
|
+ return { ...base, ok: Boolean(item && item.ok), state: (item && item.state) || '', reason: (item && item.reason) || 'unavailable' }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 仅测试用/离开外壳用:作废在跑的那一轮并回到未刷新状态。 */
|
|
|
+ function reset() {
|
|
|
+ runEpoch += 1
|
|
|
+ inFlight = null
|
|
|
+ refreshing.value = false
|
|
|
+ state.value = REFRESH_STATE.IDLE
|
|
|
+ plan.value = null
|
|
|
+ lastRun.value = null
|
|
|
+ deps = emptyDeps()
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ refreshing,
|
|
|
+ state,
|
|
|
+ plan,
|
|
|
+ lastRun,
|
|
|
+ text,
|
|
|
+ failedKeys,
|
|
|
+ configure,
|
|
|
+ requestRefresh,
|
|
|
+ reset,
|
|
|
+ }
|
|
|
+})
|