| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- /**
- * 统一刷新(票 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,
- }
- })
|