/** * 统一刷新(票 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} 本轮结论(`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, } })