/** * 分区状态词汇(票 05):一个分区(面板或详情分区)当前是「有数据 / 空 / 过期 / 失败 / 不可用」。 * * 为什么要单独一套词汇:票 05 验收要求「401 或凭据类失败只把受影响分区标为数据不可用, * 保留已成功分区与地图骨架」,票 16 要求「数据新鲜、过期、不可用和部分失败状态可区分」。 * 判定逻辑散在各面板里就会各写一套措辞与色键,所以收在这里,纯函数、可脱浏览器断言。 * * 与后端的关系(调研 §5 结论:`CockpitSectionState` 是全后端统一模式): * 后端四态 `SUCCESS`/`EMPTY`/`STALE`/`FAILED` **1:1** 映射;前端只多一个 `UNAVAILABLE`—— * 它表示「凭据或链路问题,这批数据根本没取到」(ADR 0005 的 401 语义), * 与 `FAILED`(取到了但是坏的/分区自己失败)分开,界面措辞也不同。 * * 注意 `STALE` 与 `EMPTY` 的区别(票 16 原句「过期数据保留最后已知值」): * `STALE` 是**有值但过期**(`retained: true`,界面必须带过期标识,不得伪装成实时), * `EMPTY` 是**确实没有值**(界面显示占位符,不显示虚假零值)。 */ import { DATA_UNAVAILABLE_LABEL } from './configStatus.js' import { REQUEST_REASON } from './requestReason.js' /** 前端分区状态(也是面板 `data-section-state` 的取值)。 */ export const SECTION_STATE = { SUCCESS: 'success', EMPTY: 'empty', STALE: 'stale', FAILED: 'failed', UNAVAILABLE: 'unavailable', } /** 后端 `CockpitSectionState` 的四态原文,仅在转换时出现。 */ export const BACKEND_SECTION_STATE = { SUCCESS: 'SUCCESS', EMPTY: 'EMPTY', STALE: 'STALE', FAILED: 'FAILED', } const BACKEND_TO_SECTION = { [BACKEND_SECTION_STATE.SUCCESS]: SECTION_STATE.SUCCESS, // 票 17:基础设施三切片的**子分区**用另一组词表示同两件事 —— // `READY` = 这份数据就绪(同 `SUCCESS`); // `UNAVAILABLE` = 这段数据没取到(同前端特有的「数据不可用」,见本文件抬头)。 // 后端 `CockpitInfrastructureEmergencySupportService` 第 374-379 行明确区分这两者, // 所以这是**翻译同义词**,不是放宽判定:认不出的取值仍然一律落 `FAILED`(见 A8 断言)。 READY: SECTION_STATE.SUCCESS, UNAVAILABLE: SECTION_STATE.UNAVAILABLE, [BACKEND_SECTION_STATE.EMPTY]: SECTION_STATE.EMPTY, [BACKEND_SECTION_STATE.STALE]: SECTION_STATE.STALE, [BACKEND_SECTION_STATE.FAILED]: SECTION_STATE.FAILED, } /** 新鲜度原文 → 分区状态:过期但仍保留旧值(`retained`)算 `STALE`,不是 `EMPTY`。 */ const FRESHNESS_TO_SECTION = { CURRENT: SECTION_STATE.SUCCESS, FRESH: SECTION_STATE.SUCCESS, STALE: SECTION_STATE.STALE, EXPIRED: SECTION_STATE.STALE, EMPTY: SECTION_STATE.EMPTY, } /** 白名单只认**自有键**:普通对象下标会把 `toString`/`__proto__` 这类继承键也命中, * 于是后端一个随便的状态串就能把函数漏成「分区状态」。与 mock 分支同一纪律。 */ const owns = (table, key) => Object.prototype.hasOwnProperty.call(table, key) /** 后端状态字符串 → 前端分区状态;认不出来的一律当失败,不静默混进「已就绪」。 */ export function sectionStateFromBackend(state) { return owns(BACKEND_TO_SECTION, state) ? BACKEND_TO_SECTION[state] : SECTION_STATE.FAILED } /** * 失败原因 → 分区状态。 * 凭据类(401/403)与「没注册适配器/没配地址」都归 `UNAVAILABLE`:数据没取到, * 界面显示统一文案,**不跳登录页**(ADR 0005)。 * @param {string} reason services/cockpitApi.js 的 REQUEST_REASON */ export function sectionStateFromReason(reason) { if (reason === REQUEST_REASON.UNAUTHORIZED) return SECTION_STATE.UNAVAILABLE if (reason === REQUEST_REASON.MOCK_MISSING) return SECTION_STATE.UNAVAILABLE if (reason === REQUEST_REASON.NO_BASE_URL) return SECTION_STATE.UNAVAILABLE return SECTION_STATE.FAILED } /** 新鲜度字符串 → 分区状态;未给出的返回 null(由其它判据决定)。 */ export function sectionStateFromFreshness(freshness) { return owns(FRESHNESS_TO_SECTION, freshness) ? FRESHNESS_TO_SECTION[freshness] : null } /** * 单个分区的描述对象。所有适配器都回这个形状,界面与断言只认它。 * @param {string} state SECTION_STATE 之一 * @param {object} [extra] */ export function section(state, extra = {}) { const value = state || SECTION_STATE.FAILED return { state: value, reason: extra.reason || '', // 数据来源:mock / real(走查与排查时分辨「这份数据是谁给的」)。 mode: extra.mode || '', // 有值但过期时保留的最后已知数据(ticket 16:保留但不伪装成实时)。 data: extra.data === undefined ? null : extra.data, // 字段缺失清单(领域适配层记的,供排查;不直接上界面)。 missing: extra.missing || [], freshness: extra.freshness || '', retained: extra.retained === true, } } /** * 唯一入口:把 `requestCockpitJson` 的结果 + 领域载荷判据翻成一个分区。 * * @param {object} result services/cockpitApi.js 的返回 * @param {object} [options] * @param {(payload: any) => boolean} [options.isEmpty] 载荷是否算「空数据」(如列表长度为 0) * @param {(payload: any) => string} [options.freshnessOf] 从载荷里取新鲜度字符串 * @param {(payload: any) => any} [options.toData] 载荷 → 领域对象(失败/空时不会被调用) * @returns {object} section() */ export function sectionFromResult(result, options = {}) { const current = result || {} if (!current.ok) { const state = sectionStateFromReason(current.reason) return section(state, { reason: current.reason || '', mode: current.mode || '' }) } const payload = current.data const freshness = typeof options.freshnessOf === 'function' ? options.freshnessOf(payload) || '' : '' const staleState = sectionStateFromFreshness(freshness) const isEmpty = typeof options.isEmpty === 'function' ? options.isEmpty(payload) : false // 过期优先于空:后端可能给「过期但保留旧值」,那是有值,不能当空。 if (staleState === SECTION_STATE.STALE) { return section(SECTION_STATE.STALE, { mode: current.mode, freshness, retained: true, data: typeof options.toData === 'function' ? options.toData(payload) : payload, }) } if (isEmpty) { return section(SECTION_STATE.EMPTY, { mode: current.mode, freshness }) } const data = typeof options.toData === 'function' ? options.toData(payload) : payload const missing = (data && data.missing) || [] return section(staleState || SECTION_STATE.SUCCESS, { mode: current.mode, freshness, data, missing }) } /** 分区状态 → 界面用的色键与文案(色键来自票 06 的 token 词典)。 */ export function sectionView(value) { const state = (value && value.state) || SECTION_STATE.FAILED if (state === SECTION_STATE.SUCCESS) { return { tone: 'lv4', label: '数据已就绪', detail: '' } } if (state === SECTION_STATE.STALE) { return { tone: 'lv2', label: '数据已过期', detail: '显示最后一次成功数据,不是实时值' } } if (state === SECTION_STATE.EMPTY) { return { tone: 'neutral', label: '无数据', detail: '当前范围内没有记录' } } if (state === SECTION_STATE.UNAVAILABLE) { // 票 04 定下的统一文案:凭据类失败只标分区,不跳登录页。 return { tone: 'lv1', label: DATA_UNAVAILABLE_LABEL, detail: reasonDetail(value && value.reason) } } return { tone: 'lv1', label: '数据加载失败', detail: reasonDetail(value && value.reason) } } /** * 失败原因 → 给人读的说明。 * 界面上**不出现**内部原因码(`unavailable` 这类字符串是接口契约词汇,不是文案); * 认不出的原因回空串,宁可只说「数据加载失败」,也不把代号甩给值班人员。 */ function reasonDetail(reason) { const dictionary = { [REQUEST_REASON.UNAUTHORIZED]: '凭据未通过校验(由部署层注入)', [REQUEST_REASON.NO_BASE_URL]: '未配置后端地址', [REQUEST_REASON.MOCK_MISSING]: '该分区还没有注册数据源', [REQUEST_REASON.UNREACHABLE]: '无法连接数据源', [REQUEST_REASON.BAD_PAYLOAD]: '数据源返回的内容无法解析', [REQUEST_REASON.UNAVAILABLE]: '数据源暂不可用', } return dictionary[reason] || '' } /** * 一组分区的整体状态(票 16 的「部分失败状态可区分」)。 * @param {object} sections id → 分区 */ export function overallState(sections) { const values = Object.values(sections || {}) if (!values.length) return SECTION_STATE.EMPTY const count = (state) => values.filter((item) => (item && item.state) === state).length const good = count(SECTION_STATE.SUCCESS) + count(SECTION_STATE.STALE) const bad = count(SECTION_STATE.FAILED) + count(SECTION_STATE.UNAVAILABLE) if (bad === 0) return good === 0 ? SECTION_STATE.EMPTY : SECTION_STATE.SUCCESS if (good > 0) return 'partial' return bad === count(SECTION_STATE.UNAVAILABLE) ? SECTION_STATE.UNAVAILABLE : SECTION_STATE.FAILED } /** 整体状态文案(顶栏/浮标用)。 */ export function overallStateText(state) { if (state === 'partial') return '部分分区数据不可用' if (state === SECTION_STATE.SUCCESS) return '全部分区数据已就绪' if (state === SECTION_STATE.EMPTY) return '暂无分区数据' if (state === SECTION_STATE.UNAVAILABLE) return DATA_UNAVAILABLE_LABEL return '分区数据加载失败' }