| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- /**
- * 分区状态词汇(票 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 '分区数据加载失败'
- }
|