sectionState.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. /**
  2. * 分区状态词汇(票 05):一个分区(面板或详情分区)当前是「有数据 / 空 / 过期 / 失败 / 不可用」。
  3. *
  4. * 为什么要单独一套词汇:票 05 验收要求「401 或凭据类失败只把受影响分区标为数据不可用,
  5. * 保留已成功分区与地图骨架」,票 16 要求「数据新鲜、过期、不可用和部分失败状态可区分」。
  6. * 判定逻辑散在各面板里就会各写一套措辞与色键,所以收在这里,纯函数、可脱浏览器断言。
  7. *
  8. * 与后端的关系(调研 §5 结论:`CockpitSectionState` 是全后端统一模式):
  9. * 后端四态 `SUCCESS`/`EMPTY`/`STALE`/`FAILED` **1:1** 映射;前端只多一个 `UNAVAILABLE`——
  10. * 它表示「凭据或链路问题,这批数据根本没取到」(ADR 0005 的 401 语义),
  11. * 与 `FAILED`(取到了但是坏的/分区自己失败)分开,界面措辞也不同。
  12. *
  13. * 注意 `STALE` 与 `EMPTY` 的区别(票 16 原句「过期数据保留最后已知值」):
  14. * `STALE` 是**有值但过期**(`retained: true`,界面必须带过期标识,不得伪装成实时),
  15. * `EMPTY` 是**确实没有值**(界面显示占位符,不显示虚假零值)。
  16. */
  17. import { DATA_UNAVAILABLE_LABEL } from './configStatus.js'
  18. import { REQUEST_REASON } from './requestReason.js'
  19. /** 前端分区状态(也是面板 `data-section-state` 的取值)。 */
  20. export const SECTION_STATE = {
  21. SUCCESS: 'success',
  22. EMPTY: 'empty',
  23. STALE: 'stale',
  24. FAILED: 'failed',
  25. UNAVAILABLE: 'unavailable',
  26. }
  27. /** 后端 `CockpitSectionState` 的四态原文,仅在转换时出现。 */
  28. export const BACKEND_SECTION_STATE = {
  29. SUCCESS: 'SUCCESS',
  30. EMPTY: 'EMPTY',
  31. STALE: 'STALE',
  32. FAILED: 'FAILED',
  33. }
  34. const BACKEND_TO_SECTION = {
  35. [BACKEND_SECTION_STATE.SUCCESS]: SECTION_STATE.SUCCESS,
  36. // 票 17:基础设施三切片的**子分区**用另一组词表示同两件事 ——
  37. // `READY` = 这份数据就绪(同 `SUCCESS`);
  38. // `UNAVAILABLE` = 这段数据没取到(同前端特有的「数据不可用」,见本文件抬头)。
  39. // 后端 `CockpitInfrastructureEmergencySupportService` 第 374-379 行明确区分这两者,
  40. // 所以这是**翻译同义词**,不是放宽判定:认不出的取值仍然一律落 `FAILED`(见 A8 断言)。
  41. READY: SECTION_STATE.SUCCESS,
  42. UNAVAILABLE: SECTION_STATE.UNAVAILABLE,
  43. [BACKEND_SECTION_STATE.EMPTY]: SECTION_STATE.EMPTY,
  44. [BACKEND_SECTION_STATE.STALE]: SECTION_STATE.STALE,
  45. [BACKEND_SECTION_STATE.FAILED]: SECTION_STATE.FAILED,
  46. }
  47. /** 新鲜度原文 → 分区状态:过期但仍保留旧值(`retained`)算 `STALE`,不是 `EMPTY`。 */
  48. const FRESHNESS_TO_SECTION = {
  49. CURRENT: SECTION_STATE.SUCCESS,
  50. FRESH: SECTION_STATE.SUCCESS,
  51. STALE: SECTION_STATE.STALE,
  52. EXPIRED: SECTION_STATE.STALE,
  53. EMPTY: SECTION_STATE.EMPTY,
  54. }
  55. /** 白名单只认**自有键**:普通对象下标会把 `toString`/`__proto__` 这类继承键也命中,
  56. * 于是后端一个随便的状态串就能把函数漏成「分区状态」。与 mock 分支同一纪律。 */
  57. const owns = (table, key) => Object.prototype.hasOwnProperty.call(table, key)
  58. /** 后端状态字符串 → 前端分区状态;认不出来的一律当失败,不静默混进「已就绪」。 */
  59. export function sectionStateFromBackend(state) {
  60. return owns(BACKEND_TO_SECTION, state) ? BACKEND_TO_SECTION[state] : SECTION_STATE.FAILED
  61. }
  62. /**
  63. * 失败原因 → 分区状态。
  64. * 凭据类(401/403)与「没注册适配器/没配地址」都归 `UNAVAILABLE`:数据没取到,
  65. * 界面显示统一文案,**不跳登录页**(ADR 0005)。
  66. * @param {string} reason services/cockpitApi.js 的 REQUEST_REASON
  67. */
  68. export function sectionStateFromReason(reason) {
  69. if (reason === REQUEST_REASON.UNAUTHORIZED) return SECTION_STATE.UNAVAILABLE
  70. if (reason === REQUEST_REASON.MOCK_MISSING) return SECTION_STATE.UNAVAILABLE
  71. if (reason === REQUEST_REASON.NO_BASE_URL) return SECTION_STATE.UNAVAILABLE
  72. return SECTION_STATE.FAILED
  73. }
  74. /** 新鲜度字符串 → 分区状态;未给出的返回 null(由其它判据决定)。 */
  75. export function sectionStateFromFreshness(freshness) {
  76. return owns(FRESHNESS_TO_SECTION, freshness) ? FRESHNESS_TO_SECTION[freshness] : null
  77. }
  78. /**
  79. * 单个分区的描述对象。所有适配器都回这个形状,界面与断言只认它。
  80. * @param {string} state SECTION_STATE 之一
  81. * @param {object} [extra]
  82. */
  83. export function section(state, extra = {}) {
  84. const value = state || SECTION_STATE.FAILED
  85. return {
  86. state: value,
  87. reason: extra.reason || '',
  88. // 数据来源:mock / real(走查与排查时分辨「这份数据是谁给的」)。
  89. mode: extra.mode || '',
  90. // 有值但过期时保留的最后已知数据(ticket 16:保留但不伪装成实时)。
  91. data: extra.data === undefined ? null : extra.data,
  92. // 字段缺失清单(领域适配层记的,供排查;不直接上界面)。
  93. missing: extra.missing || [],
  94. freshness: extra.freshness || '',
  95. retained: extra.retained === true,
  96. }
  97. }
  98. /**
  99. * 唯一入口:把 `requestCockpitJson` 的结果 + 领域载荷判据翻成一个分区。
  100. *
  101. * @param {object} result services/cockpitApi.js 的返回
  102. * @param {object} [options]
  103. * @param {(payload: any) => boolean} [options.isEmpty] 载荷是否算「空数据」(如列表长度为 0)
  104. * @param {(payload: any) => string} [options.freshnessOf] 从载荷里取新鲜度字符串
  105. * @param {(payload: any) => any} [options.toData] 载荷 → 领域对象(失败/空时不会被调用)
  106. * @returns {object} section()
  107. */
  108. export function sectionFromResult(result, options = {}) {
  109. const current = result || {}
  110. if (!current.ok) {
  111. const state = sectionStateFromReason(current.reason)
  112. return section(state, { reason: current.reason || '', mode: current.mode || '' })
  113. }
  114. const payload = current.data
  115. const freshness = typeof options.freshnessOf === 'function' ? options.freshnessOf(payload) || '' : ''
  116. const staleState = sectionStateFromFreshness(freshness)
  117. const isEmpty = typeof options.isEmpty === 'function' ? options.isEmpty(payload) : false
  118. // 过期优先于空:后端可能给「过期但保留旧值」,那是有值,不能当空。
  119. if (staleState === SECTION_STATE.STALE) {
  120. return section(SECTION_STATE.STALE, {
  121. mode: current.mode,
  122. freshness,
  123. retained: true,
  124. data: typeof options.toData === 'function' ? options.toData(payload) : payload,
  125. })
  126. }
  127. if (isEmpty) {
  128. return section(SECTION_STATE.EMPTY, { mode: current.mode, freshness })
  129. }
  130. const data = typeof options.toData === 'function' ? options.toData(payload) : payload
  131. const missing = (data && data.missing) || []
  132. return section(staleState || SECTION_STATE.SUCCESS, { mode: current.mode, freshness, data, missing })
  133. }
  134. /** 分区状态 → 界面用的色键与文案(色键来自票 06 的 token 词典)。 */
  135. export function sectionView(value) {
  136. const state = (value && value.state) || SECTION_STATE.FAILED
  137. if (state === SECTION_STATE.SUCCESS) {
  138. return { tone: 'lv4', label: '数据已就绪', detail: '' }
  139. }
  140. if (state === SECTION_STATE.STALE) {
  141. return { tone: 'lv2', label: '数据已过期', detail: '显示最后一次成功数据,不是实时值' }
  142. }
  143. if (state === SECTION_STATE.EMPTY) {
  144. return { tone: 'neutral', label: '无数据', detail: '当前范围内没有记录' }
  145. }
  146. if (state === SECTION_STATE.UNAVAILABLE) {
  147. // 票 04 定下的统一文案:凭据类失败只标分区,不跳登录页。
  148. return { tone: 'lv1', label: DATA_UNAVAILABLE_LABEL, detail: reasonDetail(value && value.reason) }
  149. }
  150. return { tone: 'lv1', label: '数据加载失败', detail: reasonDetail(value && value.reason) }
  151. }
  152. /**
  153. * 失败原因 → 给人读的说明。
  154. * 界面上**不出现**内部原因码(`unavailable` 这类字符串是接口契约词汇,不是文案);
  155. * 认不出的原因回空串,宁可只说「数据加载失败」,也不把代号甩给值班人员。
  156. */
  157. function reasonDetail(reason) {
  158. const dictionary = {
  159. [REQUEST_REASON.UNAUTHORIZED]: '凭据未通过校验(由部署层注入)',
  160. [REQUEST_REASON.NO_BASE_URL]: '未配置后端地址',
  161. [REQUEST_REASON.MOCK_MISSING]: '该分区还没有注册数据源',
  162. [REQUEST_REASON.UNREACHABLE]: '无法连接数据源',
  163. [REQUEST_REASON.BAD_PAYLOAD]: '数据源返回的内容无法解析',
  164. [REQUEST_REASON.UNAVAILABLE]: '数据源暂不可用',
  165. }
  166. return dictionary[reason] || ''
  167. }
  168. /**
  169. * 一组分区的整体状态(票 16 的「部分失败状态可区分」)。
  170. * @param {object} sections id → 分区
  171. */
  172. export function overallState(sections) {
  173. const values = Object.values(sections || {})
  174. if (!values.length) return SECTION_STATE.EMPTY
  175. const count = (state) => values.filter((item) => (item && item.state) === state).length
  176. const good = count(SECTION_STATE.SUCCESS) + count(SECTION_STATE.STALE)
  177. const bad = count(SECTION_STATE.FAILED) + count(SECTION_STATE.UNAVAILABLE)
  178. if (bad === 0) return good === 0 ? SECTION_STATE.EMPTY : SECTION_STATE.SUCCESS
  179. if (good > 0) return 'partial'
  180. return bad === count(SECTION_STATE.UNAVAILABLE) ? SECTION_STATE.UNAVAILABLE : SECTION_STATE.FAILED
  181. }
  182. /** 整体状态文案(顶栏/浮标用)。 */
  183. export function overallStateText(state) {
  184. if (state === 'partial') return '部分分区数据不可用'
  185. if (state === SECTION_STATE.SUCCESS) return '全部分区数据已就绪'
  186. if (state === SECTION_STATE.EMPTY) return '暂无分区数据'
  187. if (state === SECTION_STATE.UNAVAILABLE) return DATA_UNAVAILABLE_LABEL
  188. return '分区数据加载失败'
  189. }