Forráskód Böngészése

feat(cockpit): unify refresh action — map summary + current-mode panels (#61)

ChenJZ 1 napja
szülő
commit
01008b4472

+ 146 - 0
src/modules/cockpit/domain/refreshRun.js

@@ -0,0 +1,146 @@
+/**
+ * 统一刷新(票 61)—— 计划、逐项成败与文案。**纯函数层**:不 import Vue/store/service,
+ * 不发请求、不起定时器,所以能脱浏览器断言(见 verify/t61-unified-refresh.mjs)。
+ *
+ * 分工:
+ *   · 本文件只回答三件事 —— 这次刷新覆盖哪几处(`planRefresh`)、每一处成没成
+ *     (`judgeMapRefresh` / `judgePanels`)、整轮怎么结论(`summarizeRefresh`);
+ *   · 真正取数在 stores(地图摘要归 `mapStore`,面板归各模式 store);
+ *   · 并发、单飞、响应式状态与新鲜度记账在 `stores/refreshStore.js`。
+ *
+ * 票面纪律(票 05/16/61):
+ *   · **分区独立成败**:一块面板失败只标它自己,其余照旧;不整页报错、不回退 mock;
+ *   · **空 ≠ 失败**:`empty`(确实没有数据)与 `stale`(有值但过期)都不是本轮取数失败;
+ *   · **新鲜度记账同源**:一轮只有「地图摘要取到没有」决定记账成败(与 `freshnessService`
+ *     的 tick 同一条口径);面板失分由各分区状态与顶栏 `overallFreshness` 如实显示。
+ */
+import { labelForPanel, panelsForMode } from './panelCatalog.js'
+import { MAP_SUMMARY_PHASE } from './mapSummary.js'
+import { SECTION_STATE } from './sectionState.js'
+
+/** 一次统一刷新覆盖的两类部件(`kind` 是判据分派键,`key` 是断言面)。 */
+export const REFRESH_PART = {
+    MAP: 'map-summary',
+    PANEL: 'panel',
+}
+
+/** 刷新状态机(按钮 `data-refresh-state` 的取值;界面不自己拼第二套词)。 */
+export const REFRESH_STATE = {
+    IDLE: 'idle',
+    RUNNING: 'running',
+    OK: 'ok',
+    PARTIAL: 'partial',
+    FAILED: 'failed',
+}
+
+/** 算「取到了数据」的分区状态:有值 / 空 / 有值但过期 —— 都不是本轮取数失败。 */
+const PANEL_OK_STATES = [SECTION_STATE.SUCCESS, SECTION_STATE.EMPTY, SECTION_STATE.STALE]
+
+/**
+ * 当前一级模式的刷新计划:**地图摘要 + 该模式的全部分区**。
+ *
+ * 面板清单直接取自 `panelCatalog.panelsForMode()` —— 与左右两栏渲染同一份目录,
+ * 所以「哪些面板可见」只有一处定义,本文件不另写一张表(票 61 验收第 2 条的精神)。
+ * @param {string} mode 一级模式键(未知模式直接抛错:静默返回空计划会伪装成"刷新成功")
+ */
+export function planRefresh(mode) {
+    const panels = panelsForMode(mode)
+    return {
+        mode,
+        panels,
+        parts: [
+            { key: REFRESH_PART.MAP, kind: 'map', label: '地图摘要' },
+            ...panels.map((panelId) => ({ key: panelId, kind: 'panel', label: labelForPanel(panelId) })),
+        ],
+    }
+}
+
+/**
+ * 地图摘要装载状态 → 这一项的成败。
+ *
+ * `failed`(取不到)与 `expired`(快照失效、这一份数据作废)都算**没取到**;
+ * `empty` 是后端确实说没有对象 —— 那是成功。没有状态对象时如实算失败,不当成功。
+ * @param {object} state `mapStore.summary`(装载器状态)
+ */
+export function judgeMapRefresh(state) {
+    if (!state || typeof state !== 'object') return { ok: false, reason: 'no-state' }
+    if (state.phase === MAP_SUMMARY_PHASE.FAILED) return { ok: false, reason: state.reason || 'failed' }
+    if (state.phase === MAP_SUMMARY_PHASE.EXPIRED) return { ok: false, reason: state.reason || 'snapshot-expired' }
+    return { ok: true, reason: '' }
+}
+
+/**
+ * 各面板取数后的分区 → 逐项成败。判据用**分区自己的状态**(票 05 的唯一收口),
+ * 本文件不另造一套「哪些面板算坏」。分区缺失(没取过/查不到)如实算失败。
+ * @param {Array<string>} panelIds 面板 id 清单(计划里的顺序)
+ * @param {(panelId: string) => object|null} sectionOf 面板 id → 分区
+ */
+export function judgePanels(panelIds, sectionOf) {
+    const ids = Array.isArray(panelIds) ? panelIds : []
+    return ids.map((panelId) => {
+        const section = typeof sectionOf === 'function' ? sectionOf(panelId) : null
+        const state = (section && section.state) || ''
+        const ok = PANEL_OK_STATES.includes(state)
+        return {
+            key: panelId,
+            kind: REFRESH_PART.PANEL,
+            ok,
+            state,
+            reason: ok ? '' : (section && section.reason) || 'unavailable',
+        }
+    })
+}
+
+/** 逐项结果 → 整轮结论(`ok`/`reason` 是新鲜度记账口径,见文件抬头)。 */
+export function summarizeRefresh(run = {}) {
+    const parts = Array.isArray(run.parts) ? run.parts : []
+    const map = parts.find((item) => item.kind === 'map') || null
+    const failed = parts.filter((item) => !item.ok)
+    const mapOk = Boolean(map && map.ok)
+    const state = !mapOk
+        ? REFRESH_STATE.FAILED
+        : failed.length ? REFRESH_STATE.PARTIAL : REFRESH_STATE.OK
+    const failedKeys = failed.map((item) => item.key)
+    return {
+        mode: run.mode || '',
+        plan: run.plan || null,
+        parts,
+        map,
+        failed: failedKeys,
+        total: parts.length,
+        succeeded: parts.length - failed.length,
+        state,
+        // 与 freshnessService 的 tick 同源口径:地图摘要取到即算这一轮成功。
+        ok: mapOk,
+        reason: mapOk ? '' : ((map && map.reason) || 'map-summary'),
+        text: refreshText(state, { plan: run.plan, parts, failed: failedKeys }),
+    }
+}
+
+/** 失败项 key → 给人读的名字(取计划里那份标签,认不出就显示 key)。 */
+function labelOf(parts, key) {
+    const found = (Array.isArray(parts) ? parts : []).find((item) => item.key === key)
+    return (found && found.label) || key
+}
+
+/**
+ * 「用户能看出这是一次统一刷新」的那一句(按钮的 aria-label / title 读它)。
+ *
+ * 进行中与落定各一句,且都点明覆盖范围(地图摘要 + N 个面板)——
+ * 旧版只说「手动刷新快照」,用户没法知道面板是否也在刷新(票 61 的动机)。
+ * @param {string} state REFRESH_STATE 之一
+ * @param {object} [context] `{ plan, parts, failed }`
+ */
+export function refreshText(state, context = {}) {
+    const plan = context.plan || null
+    const panelCount = plan && Array.isArray(plan.panels) ? plan.panels.length : 0
+    const scope = panelCount > 0 ? `地图摘要 + ${panelCount} 个面板` : '地图摘要'
+    const failedLabels = (context.failed || []).map((key) => labelOf(context.parts, key))
+    if (state === REFRESH_STATE.RUNNING) return `统一刷新中:${scope}`
+    if (state === REFRESH_STATE.OK) return `统一刷新完成:${scope}`
+    if (state === REFRESH_STATE.PARTIAL) return `统一刷新完成:${scope},${failedLabels.join('、')}未更新`
+    if (state === REFRESH_STATE.FAILED) {
+        return `统一刷新失败:${failedLabels.join('、') || '地图摘要'}未更新`
+    }
+    return `刷新地图摘要与当前模式的面板`
+}

+ 192 - 0
src/modules/cockpit/stores/refreshStore.js

@@ -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,
+    }
+})