cockpitConfig.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. /**
  2. * 驾驶舱配置服务(票 04):后端地址、天地图、Cesium、WebSocket 与凭据的唯一读入口。
  3. *
  4. * 为什么要有这一层(spec 第 13/14 条、ADR 0005):
  5. * 前端不硬编码账号、密钥或环境地址;配置由部署层注入;页面与 store 只读归一化后的结果。
  6. *
  7. * 分层(后者覆盖前者,深合并):
  8. * 1. 内置默认 —— `dataSource: mock`(spec:默认 mock,真接口必须显式开启)
  9. * 2. 构建期环境变量 —— `import.meta.env.VITE_COCKPIT_*`(本机联调写 `.env.local`,已 gitignore)
  10. * 3. 运行时配置文件 —— `<base>cockpit/config.json`(部署时放/改,同一个构建多环境)
  11. * 4. 宿主注入对象 —— `globalThis.__COCKPIT_CONFIG__`(qiankun 主应用 / 网关注入;给了就不再拉文件)
  12. *
  13. * 三态:`ready`(载入成功)/ `missing`(没有任何配置来源,用内置默认)/ `failed`(拿到了但不可用)。
  14. *
  15. * 运行时文件**不会**在每次加载时盲试:没有显式配置 URL 就不发请求(否则每页都会刷一个 404,
  16. * 生产日志也会变脏)。要启用文件层,二选一:
  17. * · 宿主页加一行 `window.__COCKPIT_CONFIG_URL__ = 'auto'`(`auto` = 按应用基址取 `cockpit/config.json`,
  18. * qiankun / 子目录部署下都对);给绝对 URL 则直接从后端或别处拉配置;给空串 = 这个页面不要配置文件;
  19. * · 构建期设 `VITE_COCKPIT_CONFIG_URL`(同样接受 `auto` 或具体 URL)。宿主页的键优先于构建期。
  20. * 文件不存在(404)或载荷是 HTML(静态服务器的错误页)都判为缺失态,不当作故障。
  21. */
  22. import { CONFIG_STATE, CREDENTIAL, DATA_SOURCE } from '../domain/configStatus.js'
  23. import { BASEMAP_SOURCE } from '../domain/basemap.js'
  24. /** 部署把配置文件放在这个相对路径下:`public/cockpit/config.json` → 产物 `cockpit/config.json`。 */
  25. export const CONFIG_FILE_PATH = 'cockpit/config.json'
  26. /** 显式 URL 的特殊取值:按应用基址取约定路径(qiankun / 子目录部署下也正确)。 */
  27. export const CONFIG_URL_AUTO = 'auto'
  28. /** 宿主注入用的全局键。 */
  29. export const INJECTED_CONFIG_KEY = '__COCKPIT_CONFIG__'
  30. export const INJECTED_CONFIG_URL_KEY = '__COCKPIT_CONFIG_URL__'
  31. /** 构建期环境变量键(Vite 只把 `VITE_` 前缀暴露给客户端)。 */
  32. export const ENV_KEYS = {
  33. dataSource: 'VITE_COCKPIT_DATA_SOURCE',
  34. apiBaseUrl: 'VITE_COCKPIT_API_BASE_URL',
  35. apiToken: 'VITE_COCKPIT_API_TOKEN',
  36. wsUrl: 'VITE_COCKPIT_WS_URL',
  37. wsToken: 'VITE_COCKPIT_WS_TOKEN',
  38. tiandituToken: 'VITE_COCKPIT_TIANDITU_TOKEN',
  39. cesiumBaseUrl: 'VITE_COCKPIT_CESIUM_BASE_URL',
  40. basemapSource: 'VITE_COCKPIT_BASEMAP_SOURCE',
  41. basemapUrl: 'VITE_COCKPIT_BASEMAP_URL',
  42. configUrl: 'VITE_COCKPIT_CONFIG_URL',
  43. }
  44. function isPlainObject(value) {
  45. return !!value && typeof value === 'object' && !Array.isArray(value)
  46. }
  47. /** 内置默认:没有任何配置来源时也必须是可跑的状态(mock + 不伪造凭据)。 */
  48. export function builtinConfig() {
  49. return {
  50. version: 1,
  51. dataSource: DATA_SOURCE.MOCK,
  52. api: { baseUrl: '', token: '' },
  53. ws: { url: '', token: '' },
  54. map: {
  55. basemap: {
  56. source: BASEMAP_SOURCE.NONE,
  57. url: '',
  58. subdomains: [],
  59. minimumLevel: null,
  60. maximumLevel: null,
  61. credit: '',
  62. },
  63. tianditu: { token: '', layer: 'img' },
  64. terrain: { url: '', requestVertexNormals: true },
  65. overlays: [],
  66. tilesets: [],
  67. home: { lon: null, lat: null, height: null, heading: null, pitch: null, range: null, label: '' },
  68. intro: { enabled: true, title: '', subtitle: '', spinSeconds: null },
  69. // 票 21:地面/管线视图的取值覆盖(不配就用 domain/viewModes.js 的默认)
  70. viewMode: { pitch: null, rangeFactor: null, terrainAlpha: null, tilesetAlpha: null },
  71. // 票 32:UI 形态。null = 跟随构建模式(dev 显示调试信息,生产构建自动收敛)
  72. ui: { debug: null },
  73. cesium: { enabled: true, baseUrl: '' },
  74. },
  75. }
  76. }
  77. function readString(value, fallback, issues, label) {
  78. if (value === undefined || value === null || value === '') return fallback
  79. if (typeof value !== 'string') {
  80. issues.push(`${label} 应为字符串,收到 ${typeof value},已退回默认`)
  81. return fallback
  82. }
  83. return value.trim()
  84. }
  85. function readBoolean(value, fallback, issues, label) {
  86. if (value === undefined || value === null) return fallback
  87. if (typeof value !== 'boolean') {
  88. issues.push(`${label} 应为布尔值,收到 ${typeof value},已退回默认`)
  89. return fallback
  90. }
  91. return value
  92. }
  93. function readStringArray(value, issues, label) {
  94. if (value === undefined || value === null || value === '') return []
  95. if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
  96. issues.push(`${label} 应为字符串数组,已忽略`)
  97. return []
  98. }
  99. return value.map((item) => item.trim()).filter(Boolean)
  100. }
  101. function readLevel(value, issues, label) {
  102. if (value === undefined || value === null || value === '') return null
  103. if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
  104. issues.push(`${label} 应为非负整数,收到 ${JSON.stringify(value)},已忽略`)
  105. return null
  106. }
  107. return value
  108. }
  109. function readNumber(value, issues, label) {
  110. if (value === undefined || value === null || value === '') return null
  111. if (typeof value !== 'number' || !Number.isFinite(value)) {
  112. issues.push(`${label} 应为数字,收到 ${JSON.stringify(value)},已忽略`)
  113. return null
  114. }
  115. return value
  116. }
  117. /**
  118. * 归一化 + 宽松校验:不认识的取值退回默认并记一条 issue,不因为一个错字段整份配置作废。
  119. * @param {object} raw 合并后的原始配置
  120. * @returns {{config: object, issues: string[]}}
  121. */
  122. export function normalizeConfig(raw) {
  123. const config = builtinConfig()
  124. const issues = []
  125. if (!isPlainObject(raw)) {
  126. if (raw !== undefined && raw !== null) issues.push('配置不是对象,已全部退回内置默认')
  127. return { config, issues }
  128. }
  129. if (raw.dataSource !== undefined && raw.dataSource !== null) {
  130. if (raw.dataSource === DATA_SOURCE.REAL || raw.dataSource === DATA_SOURCE.MOCK) {
  131. config.dataSource = raw.dataSource
  132. } else {
  133. issues.push(`dataSource 只认 mock/real,收到 ${JSON.stringify(raw.dataSource)},已退回 mock`)
  134. }
  135. }
  136. const api = isPlainObject(raw.api) ? raw.api : {}
  137. const ws = isPlainObject(raw.ws) ? raw.ws : {}
  138. const map = isPlainObject(raw.map) ? raw.map : {}
  139. const basemap = isPlainObject(map.basemap) ? map.basemap : {}
  140. const tianditu = isPlainObject(map.tianditu) ? map.tianditu : {}
  141. const cesium = isPlainObject(map.cesium) ? map.cesium : {}
  142. config.api.baseUrl = readString(api.baseUrl, config.api.baseUrl, issues, 'api.baseUrl')
  143. config.api.token = readString(api.token, config.api.token, issues, 'api.token')
  144. config.ws.url = readString(ws.url, config.ws.url, issues, 'ws.url')
  145. config.ws.token = readString(ws.token, config.ws.token, issues, 'ws.token')
  146. const knownSources = Object.values(BASEMAP_SOURCE)
  147. if (basemap.source !== undefined && basemap.source !== null && basemap.source !== '') {
  148. if (knownSources.includes(basemap.source)) {
  149. config.map.basemap.source = basemap.source
  150. } else {
  151. issues.push(`map.basemap.source 只认 ${knownSources.join('/')},收到 ${JSON.stringify(basemap.source)},已退回 none`)
  152. }
  153. }
  154. config.map.basemap.url = readString(basemap.url, '', issues, 'map.basemap.url')
  155. config.map.basemap.credit = readString(basemap.credit, '', issues, 'map.basemap.credit')
  156. config.map.basemap.subdomains = readStringArray(basemap.subdomains, issues, 'map.basemap.subdomains')
  157. config.map.basemap.minimumLevel = readLevel(basemap.minimumLevel, issues, 'map.basemap.minimumLevel')
  158. config.map.basemap.maximumLevel = readLevel(basemap.maximumLevel, issues, 'map.basemap.maximumLevel')
  159. config.map.tianditu.token = readString(tianditu.token, '', issues, 'map.tianditu.token')
  160. if (tianditu.layer !== undefined && tianditu.layer !== null && tianditu.layer !== '') {
  161. if (['img', 'vec', 'ter'].includes(tianditu.layer)) {
  162. config.map.tianditu.layer = tianditu.layer
  163. } else {
  164. issues.push(`map.tianditu.layer 只认 img/vec/ter,收到 ${JSON.stringify(tianditu.layer)},已退回 img`)
  165. }
  166. }
  167. config.map.cesium.enabled = readBoolean(cesium.enabled, config.map.cesium.enabled, issues, 'map.cesium.enabled')
  168. config.map.cesium.baseUrl = readString(cesium.baseUrl, '', issues, 'map.cesium.baseUrl')
  169. // 票 21:地形(本地/甲方 DEM)与叠加影像(本地精细切片)
  170. const terrain = isPlainObject(map.terrain) ? map.terrain : {}
  171. config.map.terrain.url = readString(terrain.url, '', issues, 'map.terrain.url')
  172. config.map.terrain.requestVertexNormals = readBoolean(
  173. terrain.requestVertexNormals,
  174. config.map.terrain.requestVertexNormals,
  175. issues,
  176. 'map.terrain.requestVertexNormals',
  177. )
  178. if (map.overlays !== undefined && map.overlays !== null && !Array.isArray(map.overlays)) {
  179. issues.push('map.overlays 应为数组,已忽略')
  180. } else {
  181. config.map.overlays = (map.overlays || []).map((item, index) => {
  182. const overlay = isPlainObject(item) ? item : {}
  183. const bounds = Array.isArray(overlay.bounds) && overlay.bounds.length === 4 && overlay.bounds.every((n) => typeof n === 'number')
  184. if (overlay.bounds !== undefined && overlay.bounds !== null && !bounds) {
  185. issues.push(`map.overlays[${index}].bounds 应为 [west, south, east, north] 四个数字,已忽略`)
  186. }
  187. return {
  188. url: readString(overlay.url, '', issues, `map.overlays[${index}].url`),
  189. minimumLevel: readLevel(overlay.minimumLevel, issues, `map.overlays[${index}].minimumLevel`),
  190. maximumLevel: readLevel(overlay.maximumLevel, issues, `map.overlays[${index}].maximumLevel`),
  191. bounds: bounds ? overlay.bounds.slice() : null,
  192. credit: readString(overlay.credit, '', issues, `map.overlays[${index}].credit`),
  193. }
  194. })
  195. }
  196. // 3D Tiles / 倾斜摄影入口(票 21):每项一个 tileset URL
  197. if (map.tilesets !== undefined && map.tilesets !== null && !Array.isArray(map.tilesets)) {
  198. issues.push('map.tilesets 应为数组,已忽略')
  199. } else {
  200. config.map.tilesets = (map.tilesets || []).map((item, index) => {
  201. const tileset = isPlainObject(item) ? item : {}
  202. return {
  203. url: readString(tileset.url, '', issues, `map.tilesets[${index}].url`),
  204. maximumScreenSpaceError: readNumber(
  205. tileset.maximumScreenSpaceError,
  206. issues,
  207. `map.tilesets[${index}].maximumScreenSpaceError`,
  208. ),
  209. credit: readString(tileset.credit, '', issues, `map.tilesets[${index}].credit`),
  210. }
  211. })
  212. }
  213. // 视角预设与进入引导(票 30):坐标/开关都由配置给,组件不硬编码
  214. const home = isPlainObject(map.home) ? map.home : {}
  215. config.map.home = {
  216. lon: readNumber(home.lon, issues, 'map.home.lon'),
  217. lat: readNumber(home.lat, issues, 'map.home.lat'),
  218. height: readNumber(home.height, issues, 'map.home.height'),
  219. heading: readNumber(home.heading, issues, 'map.home.heading'),
  220. pitch: readNumber(home.pitch, issues, 'map.home.pitch'),
  221. range: readNumber(home.range, issues, 'map.home.range'),
  222. label: readString(home.label, '', issues, 'map.home.label'),
  223. }
  224. const intro = isPlainObject(map.intro) ? map.intro : {}
  225. config.map.intro = {
  226. enabled: readBoolean(intro.enabled, config.map.intro.enabled, issues, 'map.intro.enabled'),
  227. title: readString(intro.title, '', issues, 'map.intro.title'),
  228. subtitle: readString(intro.subtitle, '', issues, 'map.intro.subtitle'),
  229. spinSeconds: readNumber(intro.spinSeconds, issues, 'map.intro.spinSeconds'),
  230. }
  231. // 票 21:地面/管线视图的取值覆盖(缺省用 domain/viewModes.js 的默认;脏值忽略)
  232. const viewMode = isPlainObject(map.viewMode) ? map.viewMode : {}
  233. const pipeline = isPlainObject(viewMode.pipeline) ? viewMode.pipeline : {}
  234. config.map.viewMode = {
  235. pitch: readNumber(pipeline.pitch, issues, 'map.viewMode.pipeline.pitch'),
  236. rangeFactor: readNumber(pipeline.rangeFactor, issues, 'map.viewMode.pipeline.rangeFactor'),
  237. terrainAlpha: readNumber(pipeline.terrainAlpha, issues, 'map.viewMode.pipeline.terrainAlpha'),
  238. tilesetAlpha: readNumber(pipeline.tilesetAlpha, issues, 'map.viewMode.pipeline.tilesetAlpha'),
  239. }
  240. // 票 32:UI 形态开关。缺省 null = 跟随构建模式(`import.meta.env.DEV`),
  241. // 生产构建自动收敛为「干净模式」;要在地产现场调试生产包时可显式写 true。
  242. const ui = isPlainObject(map.ui) ? map.ui : {}
  243. config.map.ui = {
  244. debug: readBoolean(ui.debug, null, issues, 'map.ui.debug'),
  245. }
  246. return { config, issues }
  247. }
  248. /** 构建期环境变量 → 原始配置形状(纯函数,便于单测直接喂一个 env 对象)。 */
  249. export function readEnvConfig(env) {
  250. if (!isPlainObject(env)) return {}
  251. const out = {}
  252. if (env[ENV_KEYS.dataSource]) out.dataSource = env[ENV_KEYS.dataSource]
  253. const api = {}
  254. if (env[ENV_KEYS.apiBaseUrl]) api.baseUrl = env[ENV_KEYS.apiBaseUrl]
  255. if (env[ENV_KEYS.apiToken]) api.token = env[ENV_KEYS.apiToken]
  256. if (Object.keys(api).length) out.api = api
  257. const ws = {}
  258. if (env[ENV_KEYS.wsUrl]) ws.url = env[ENV_KEYS.wsUrl]
  259. if (env[ENV_KEYS.wsToken]) ws.token = env[ENV_KEYS.wsToken]
  260. if (Object.keys(ws).length) out.ws = ws
  261. const map = {}
  262. if (env[ENV_KEYS.tiandituToken]) map.tianditu = { token: env[ENV_KEYS.tiandituToken] }
  263. if (env[ENV_KEYS.cesiumBaseUrl]) map.cesium = { baseUrl: env[ENV_KEYS.cesiumBaseUrl] }
  264. const basemap = {}
  265. if (env[ENV_KEYS.basemapSource]) basemap.source = env[ENV_KEYS.basemapSource]
  266. if (env[ENV_KEYS.basemapUrl]) basemap.url = env[ENV_KEYS.basemapUrl]
  267. if (Object.keys(basemap).length) map.basemap = basemap
  268. if (Object.keys(map).length) out.map = map
  269. return out
  270. }
  271. /** 深合并(只对普通对象递归),后面的来源覆盖前面的。 */
  272. export function mergeConfig(base, patch) {
  273. if (!isPlainObject(patch)) return base
  274. const out = { ...base }
  275. for (const [key, value] of Object.entries(patch)) {
  276. out[key] = isPlainObject(value) && isPlainObject(base[key]) ? mergeConfig(base[key], value) : value
  277. }
  278. return out
  279. }
  280. /**
  281. * 解析配置文件的地址。
  282. * @param {object} [options]
  283. * @param {string} [options.base] 应用基址(`import.meta.env.BASE_URL` / qiankun 注入)
  284. * @param {string} [options.documentBase] `document.baseURI`
  285. * @param {string} [options.explicitUrl] 显式 URL;`'auto'` 表示按应用基址取约定路径
  286. * @returns {string} 绝对 URL
  287. */
  288. export function resolveConfigUrl({ base = './', documentBase = 'http://localhost/', explicitUrl = '' } = {}) {
  289. const docBase = documentBase || 'http://localhost/'
  290. if (explicitUrl && explicitUrl !== CONFIG_URL_AUTO) return new URL(explicitUrl, docBase).href
  291. return new URL(CONFIG_FILE_PATH, new URL(base || './', docBase)).href
  292. }
  293. /** 读一个配置来源的响应体;返回原始对象或一个失败原因。 */
  294. export async function readConfigPayload(response) {
  295. if (!response || typeof response.text !== 'function') return { ok: false, reason: 'no-response' }
  296. if (!response.ok) {
  297. // 404 = 部署没放这个文件;其余非 2xx = 放了但服务端是坏的。
  298. return { ok: false, reason: response.status === 404 ? 'missing' : `http-${response.status}` }
  299. }
  300. const text = await response.text()
  301. const trimmed = text.trim()
  302. // dev 的 SPA 回退 / 静态服务器的错误页都会返回 HTML,一律按「文件不存在」处理。
  303. if (!trimmed || trimmed.startsWith('<')) return { ok: false, reason: 'missing' }
  304. try {
  305. const raw = JSON.parse(trimmed)
  306. if (!isPlainObject(raw)) return { ok: false, reason: 'not-an-object' }
  307. return { ok: true, raw }
  308. } catch {
  309. return { ok: false, reason: 'invalid-json' }
  310. }
  311. }
  312. function readViteEnv() {
  313. return import.meta && import.meta.env ? import.meta.env : {}
  314. }
  315. function readDocumentBase() {
  316. return typeof document !== 'undefined' && document.baseURI ? document.baseURI : 'http://localhost/'
  317. }
  318. let cached = null
  319. let inFlight = null
  320. /**
  321. * 载入配置(幂等、进程内缓存一次)。
  322. * 所有依赖都可注入,便于脱浏览器断言:`{ scope, env, fetchImpl, base, documentBase, now }`。
  323. * @returns {Promise<object>} `{ state, source, url, config, issues, error, credential, loadedAt }`
  324. */
  325. export function loadCockpitConfig(options = {}) {
  326. if (cached) return Promise.resolve(cached)
  327. if (inFlight) return inFlight
  328. inFlight = resolveCockpitConfig(options)
  329. .then((result) => {
  330. cached = result
  331. inFlight = null
  332. return result
  333. })
  334. .catch((error) => {
  335. // 兜底:任何意外都收敛成 failed,绝不向外抛(页面不该因为配置服务崩掉而白屏)。
  336. inFlight = null
  337. cached = {
  338. state: CONFIG_STATE.FAILED,
  339. source: 'defaults',
  340. url: '',
  341. config: builtinConfig(),
  342. issues: [],
  343. error: String((error && error.message) || error),
  344. credential: CREDENTIAL.NONE,
  345. loadedAt: new Date().toISOString(),
  346. }
  347. return cached
  348. })
  349. return inFlight
  350. }
  351. async function resolveCockpitConfig({ scope, env, fetchImpl, base, documentBase, now } = {}) {
  352. const globalScope = scope || (typeof globalThis !== 'undefined' ? globalThis : {})
  353. const viteEnv = env !== undefined ? env : readViteEnv()
  354. const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null)
  355. const loadedAt = new Date(now === undefined ? Date.now() : now).toISOString()
  356. const injected = isPlainObject(globalScope[INJECTED_CONFIG_KEY]) ? globalScope[INJECTED_CONFIG_KEY] : null
  357. // 宿主页显式给了 URL 键就以它为准 —— 给空串表示「这个页面不要运行时配置文件」,
  358. // 这样宿主能在构建期开关之外逐页控制(走查的「默认无配置」用例也靠它自隔离)。
  359. const injectedUrl = globalScope[INJECTED_CONFIG_URL_KEY]
  360. const explicitUrl =
  361. injectedUrl === undefined || injectedUrl === null
  362. ? readString(viteEnv ? viteEnv[ENV_KEYS.configUrl] : '', '', [], 'VITE_COCKPIT_CONFIG_URL')
  363. : readString(injectedUrl, '', [], 'window.__COCKPIT_CONFIG_URL__')
  364. // 1) + 2) 内置默认由 normalizeConfig 兜底,这里先叠构建期环境变量。
  365. const envRaw = readEnvConfig(viteEnv)
  366. let raw = mergeConfig({}, envRaw)
  367. // 构建期环境变量也算「有配置」;只有一份来源都没有时才是缺失态(内置默认)。
  368. const hasEnvConfig = Object.keys(envRaw).length > 0
  369. let source = hasEnvConfig ? 'env' : 'defaults'
  370. let state = hasEnvConfig ? CONFIG_STATE.READY : CONFIG_STATE.MISSING
  371. let url = ''
  372. let error = ''
  373. const issues = []
  374. if (injected) {
  375. // 4) 宿主注入优先级最高:给了就不再拉文件。
  376. raw = mergeConfig(raw, injected)
  377. source = 'window'
  378. state = CONFIG_STATE.READY
  379. } else if (!explicitUrl) {
  380. // 没有显式配置 URL:不发请求(避免每次加载都刷 404),维持在「缺失 / 仅环境变量」态。
  381. url = ''
  382. } else {
  383. // 3) 运行时配置文件(显式 URL,或 'auto' = 应用基址下的约定路径)。
  384. url = resolveConfigUrl({
  385. base: base === undefined ? (viteEnv && viteEnv.BASE_URL) || './' : base,
  386. documentBase: documentBase === undefined ? readDocumentBase() : documentBase,
  387. explicitUrl,
  388. })
  389. if (!doFetch) {
  390. error = '当前环境没有 fetch,无法加载配置文件'
  391. state = CONFIG_STATE.FAILED
  392. issues.push(error)
  393. } else {
  394. try {
  395. const payload = await readConfigPayload(await doFetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' }))
  396. if (payload.ok) {
  397. raw = mergeConfig(raw, payload.raw)
  398. source = 'file'
  399. state = CONFIG_STATE.READY
  400. } else if (payload.reason === 'missing' || payload.reason === 'no-response') {
  401. // 文件不在:环境变量层给的配置仍然有效,状态维持原样(缺失 / 已就绪)。
  402. if (!hasEnvConfig) state = CONFIG_STATE.MISSING
  403. } else {
  404. state = CONFIG_STATE.FAILED
  405. error = `配置文件不可用:${payload.reason}`
  406. }
  407. } catch (fetchError) {
  408. state = CONFIG_STATE.FAILED
  409. error = `配置文件请求失败:${String((fetchError && fetchError.message) || fetchError)}`
  410. }
  411. }
  412. }
  413. const { config, issues: normalizeIssues } = normalizeConfig(raw)
  414. return {
  415. state,
  416. source,
  417. url,
  418. config,
  419. issues: issues.concat(normalizeIssues),
  420. error,
  421. credential: config.api.token ? CREDENTIAL.CONFIG : CREDENTIAL.NONE,
  422. loadedAt,
  423. }
  424. }
  425. /** 同步读归一化后的配置;未载入时给内置默认(页面不必等配置就能渲染骨架)。 */
  426. export function getCockpitConfig() {
  427. return cached ? cached.config : builtinConfig()
  428. }
  429. /** 同步读完整加载结果;未载入时按「缺失」对待。 */
  430. export function getCockpitConfigState() {
  431. if (cached) return cached
  432. return {
  433. state: CONFIG_STATE.MISSING,
  434. source: 'defaults',
  435. url: '',
  436. config: builtinConfig(),
  437. issues: [],
  438. error: '',
  439. credential: CREDENTIAL.NONE,
  440. loadedAt: '',
  441. }
  442. }
  443. /** 仅测试用:清掉缓存,让下一次 loadCockpitConfig 重新解析。 */
  444. export function resetCockpitConfigForTest() {
  445. cached = null
  446. inFlight = null
  447. }