cockpitConfig.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  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. import { planPrimaryArea, PRIMARY_AREA_OFF_SUFFIX } from '../domain/primaryArea.js'
  25. import { parseHexColor } from '../domain/fog.js'
  26. /** 部署把配置文件放在这个相对路径下:`public/cockpit/config.json` → 产物 `cockpit/config.json`。 */
  27. export const CONFIG_FILE_PATH = 'cockpit/config.json'
  28. /** 显式 URL 的特殊取值:按应用基址取约定路径(qiankun / 子目录部署下也正确)。 */
  29. export const CONFIG_URL_AUTO = 'auto'
  30. /** 宿主注入用的全局键。 */
  31. export const INJECTED_CONFIG_KEY = '__COCKPIT_CONFIG__'
  32. export const INJECTED_CONFIG_URL_KEY = '__COCKPIT_CONFIG_URL__'
  33. /** 构建期环境变量键(Vite 只把 `VITE_` 前缀暴露给客户端)。 */
  34. export const ENV_KEYS = {
  35. dataSource: 'VITE_COCKPIT_DATA_SOURCE',
  36. apiBaseUrl: 'VITE_COCKPIT_API_BASE_URL',
  37. apiToken: 'VITE_COCKPIT_API_TOKEN',
  38. wsUrl: 'VITE_COCKPIT_WS_URL',
  39. wsToken: 'VITE_COCKPIT_WS_TOKEN',
  40. tiandituToken: 'VITE_COCKPIT_TIANDITU_TOKEN',
  41. cesiumBaseUrl: 'VITE_COCKPIT_CESIUM_BASE_URL',
  42. basemapSource: 'VITE_COCKPIT_BASEMAP_SOURCE',
  43. basemapUrl: 'VITE_COCKPIT_BASEMAP_URL',
  44. configUrl: 'VITE_COCKPIT_CONFIG_URL',
  45. }
  46. function isPlainObject(value) {
  47. return !!value && typeof value === 'object' && !Array.isArray(value)
  48. }
  49. /** 内置默认:没有任何配置来源时也必须是可跑的状态(mock + 不伪造凭据)。 */
  50. export function builtinConfig() {
  51. return {
  52. version: 1,
  53. dataSource: DATA_SOURCE.MOCK,
  54. api: { baseUrl: '', token: '' },
  55. ws: { url: '', token: '' },
  56. // 票 16:刷新周期(秒)。`refreshSeconds` = 一个「周期」的长度;轮询与新鲜度判定都读它。
  57. // 默认 30 秒(决策:前端轮询 + WS 断线也算失败周期)。
  58. runtime: { refreshSeconds: 30, polling: true },
  59. map: {
  60. basemap: {
  61. source: BASEMAP_SOURCE.NONE,
  62. url: '',
  63. subdomains: [],
  64. minimumLevel: null,
  65. maximumLevel: null,
  66. credit: '',
  67. },
  68. tianditu: { token: '', layer: 'img' },
  69. terrain: { url: '', requestVertexNormals: true },
  70. overlays: [],
  71. tilesets: [],
  72. home: { lon: null, lat: null, height: null, heading: null, pitch: null, range: null, label: '' },
  73. intro: { enabled: true, title: '', subtitle: '', spinSeconds: null },
  74. locate: {
  75. point: { heading: null, pitch: null, range: null },
  76. device: { heading: null, pitch: null, range: null },
  77. },
  78. // 票 36:主场景区矩形。`configured=false` = 本部署没给(限位与自建雾随之关闭);
  79. // 内置默认里**故意不预置任何地理常量**(决策票 01 Q3=A:兜底由部署期算、不写死源码)。
  80. primaryArea: { bounds: null, configured: false },
  81. // 票 21:地面/管线视图的取值覆盖(不配就用 domain/viewModes.js 的默认)
  82. viewMode: { pitch: null, rangeFactor: null, terrainAlpha: null, tilesetAlpha: null },
  83. // 票 38:区内视距雾气。null/'' = 用 `domain/fog.js` 的**规格默认**
  84. // (18,000/42,000 m、#bdcdd6);即"不配就是规格定的那一套",与票 21 同一立场。
  85. fog: {
  86. enabled: true,
  87. startDistance: null,
  88. endDistance: null,
  89. color: '',
  90. fadeInMs: null,
  91. fadeOutMs: null,
  92. // 相机高度收口(见 `domain/fog.js` 的 `fogAltitudeFactor`):
  93. // 不设它,相机升到高空时会把整个地球涂成雾色(维护者 2026-09-29 实报)。
  94. altitudeFullM: null,
  95. altitudeZeroM: null,
  96. // 这层收口的**时间限速**(见 `approachAltitudeFactor`):不设它,
  97. // 返回全球时相机头一帧就越过整条雾带 ⇒ 雾"一瞬间就散了"(同日第二次实报)。
  98. altitudeSlewMs: null,
  99. },
  100. // 票 39:粗→精影像交接。null = 用 `domain/imageryHandover.js` 的默认
  101. // (渐显 380 ms、边缘遮蔽率 ≥0.9、要求瓦片就绪)。`enabled=false` = 精细层常显。
  102. handover: {
  103. enabled: true,
  104. fadeMs: null,
  105. minEdgeOcclusion: null,
  106. requireTilesLoaded: null,
  107. },
  108. // 一张图调色(科技蓝滤镜)。null/'' = 用 `domain/sceneGrade.js` 的默认
  109. // (降饱和 0.82 / 亮度 0.96 / 对比 1.08 / #2fa8ff / 阴影混合 0.18);
  110. // `enabled=false` = 整级关掉。色彩口径(含"不做双色"红线)全在 domain,这里只透传。
  111. grade: {
  112. enabled: true,
  113. saturation: null,
  114. brightness: null,
  115. contrast: null,
  116. tint: '',
  117. tintStrength: null,
  118. },
  119. // 票 45:重叠设备展开阈值(K>maxFan 降级为聚合符号 + 清单提示)。
  120. overlap: { maxFan: 10 },
  121. // 票 47:设备状态动效(决策票 07)。`enabled: null` = 跟随构建形态;
  122. // `abnormalLimit` = 同屏异常设备上限,超过即动画全停并降级为静态 + 计数。
  123. effects: { enabled: null, abnormalLimit: 30 },
  124. // 票 32:UI 形态。null = 跟随构建模式(dev 显示调试信息,生产构建自动收敛)
  125. ui: { debug: null },
  126. cesium: { enabled: true, baseUrl: '' },
  127. },
  128. }
  129. }
  130. function readString(value, fallback, issues, label) {
  131. if (value === undefined || value === null || value === '') return fallback
  132. if (typeof value !== 'string') {
  133. issues.push(`${label} 应为字符串,收到 ${typeof value},已退回默认`)
  134. return fallback
  135. }
  136. return value.trim()
  137. }
  138. function readBoolean(value, fallback, issues, label) {
  139. if (value === undefined || value === null) return fallback
  140. if (typeof value !== 'boolean') {
  141. issues.push(`${label} 应为布尔值,收到 ${typeof value},已退回默认`)
  142. return fallback
  143. }
  144. return value
  145. }
  146. function readStringArray(value, issues, label) {
  147. if (value === undefined || value === null || value === '') return []
  148. if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
  149. issues.push(`${label} 应为字符串数组,已忽略`)
  150. return []
  151. }
  152. return value.map((item) => item.trim()).filter(Boolean)
  153. }
  154. function readLevel(value, issues, label) {
  155. if (value === undefined || value === null || value === '') return null
  156. if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
  157. issues.push(`${label} 应为非负整数,收到 ${JSON.stringify(value)},已忽略`)
  158. return null
  159. }
  160. return value
  161. }
  162. function readNumber(value, issues, label) {
  163. if (value === undefined || value === null || value === '') return null
  164. if (typeof value !== 'number' || !Number.isFinite(value)) {
  165. issues.push(`${label} 应为数字,收到 ${JSON.stringify(value)},已忽略`)
  166. return null
  167. }
  168. return value
  169. }
  170. function readNonNegativeNumber(value, issues, label) {
  171. const number = readNumber(value, issues, label)
  172. if (number !== null && number < 0) {
  173. issues.push(`${label} 应为非负数字,收到 ${JSON.stringify(value)},已忽略`)
  174. return null
  175. }
  176. return number
  177. }
  178. /**
  179. * DEV 走查桥(票 76,与 `?overlay=0` 同一入口风格):`?dataSource=mock|real` 覆盖配置的 dataSource。
  180. *
  181. * 为什么放在配置层:dataSource 决定 `planCockpitStartup()` 走哪条启动分支(只有 mock 注册契约样例),
  182. * 覆盖必须发生在**归一化之后、启动计划之前**;放在页面里会让两处各判一次。
  183. * 仅 DEV 生效,值只认 mock/real,其余原样返回(不猜、不改正式路径默认值)。
  184. *
  185. * 用法:`http://127.0.0.1:8000/?dataSource=mock&scenario=device-layer-overlap-degraded#/cockpit`
  186. * —— `scenario` 由 `mocks/index.js` 的 `readScenario()` 读,用来出 K>1 聚合场景。
  187. *
  188. * @param {object} config 已归一化配置
  189. * @param {Location} [location] 可注入(脱浏览器断言用)
  190. * @param {boolean} [isDev] 是否开发构建
  191. */
  192. export function withUrlDataSourceOverride(config, location = null, isDev = false) {
  193. if (!isDev || !config) return config
  194. const loc = location || (typeof globalThis !== 'undefined' ? globalThis.location : null)
  195. if (!loc) return config
  196. let value = ''
  197. try {
  198. const hashQuery = String(loc.hash || '').split('?')[1] || ''
  199. value = new URLSearchParams(loc.search || '').get('dataSource')
  200. || new URLSearchParams(hashQuery).get('dataSource')
  201. || ''
  202. } catch (error) {
  203. value = ''
  204. }
  205. const next = String(value || '').toLowerCase()
  206. if (next !== DATA_SOURCE.MOCK && next !== DATA_SOURCE.REAL) return config
  207. return { ...config, dataSource: next }
  208. }
  209. /**
  210. * 归一化 + 宽松校验:不认识的取值退回默认并记一条 issue,不因为一个错字段整份配置作废。
  211. * @param {object} raw 合并后的原始配置
  212. * @returns {{config: object, issues: string[]}}
  213. */
  214. export function normalizeConfig(raw) {
  215. const config = builtinConfig()
  216. const issues = []
  217. if (!isPlainObject(raw)) {
  218. if (raw !== undefined && raw !== null) issues.push('配置不是对象,已全部退回内置默认')
  219. return { config, issues }
  220. }
  221. if (raw.dataSource !== undefined && raw.dataSource !== null) {
  222. if (raw.dataSource === DATA_SOURCE.REAL || raw.dataSource === DATA_SOURCE.MOCK) {
  223. config.dataSource = raw.dataSource
  224. } else {
  225. issues.push(`dataSource 只认 mock/real,收到 ${JSON.stringify(raw.dataSource)},已退回 mock`)
  226. }
  227. }
  228. const api = isPlainObject(raw.api) ? raw.api : {}
  229. const ws = isPlainObject(raw.ws) ? raw.ws : {}
  230. const map = isPlainObject(raw.map) ? raw.map : {}
  231. const runtime = isPlainObject(raw.runtime) ? raw.runtime : {}
  232. const basemap = isPlainObject(map.basemap) ? map.basemap : {}
  233. const tianditu = isPlainObject(map.tianditu) ? map.tianditu : {}
  234. const cesium = isPlainObject(map.cesium) ? map.cesium : {}
  235. config.api.baseUrl = readString(api.baseUrl, config.api.baseUrl, issues, 'api.baseUrl')
  236. config.api.token = readString(api.token, config.api.token, issues, 'api.token')
  237. config.ws.url = readString(ws.url, config.ws.url, issues, 'ws.url')
  238. config.ws.token = readString(ws.token, config.ws.token, issues, 'ws.token')
  239. // 票 16:刷新周期(秒)。必须是正数 —— 0 或负数会让「连续两个周期」永远不成立。
  240. const refreshSeconds = readNumber(runtime.refreshSeconds, issues, 'runtime.refreshSeconds')
  241. if (refreshSeconds !== null) {
  242. if (refreshSeconds > 0) config.runtime.refreshSeconds = refreshSeconds
  243. else issues.push(`runtime.refreshSeconds 应为正数,收到 ${JSON.stringify(runtime.refreshSeconds)},已退回 ${config.runtime.refreshSeconds}`)
  244. }
  245. config.runtime.polling = readBoolean(runtime.polling, config.runtime.polling, issues, 'runtime.polling')
  246. const knownSources = Object.values(BASEMAP_SOURCE)
  247. if (basemap.source !== undefined && basemap.source !== null && basemap.source !== '') {
  248. if (knownSources.includes(basemap.source)) {
  249. config.map.basemap.source = basemap.source
  250. } else {
  251. issues.push(`map.basemap.source 只认 ${knownSources.join('/')},收到 ${JSON.stringify(basemap.source)},已退回 none`)
  252. }
  253. }
  254. config.map.basemap.url = readString(basemap.url, '', issues, 'map.basemap.url')
  255. config.map.basemap.credit = readString(basemap.credit, '', issues, 'map.basemap.credit')
  256. config.map.basemap.subdomains = readStringArray(basemap.subdomains, issues, 'map.basemap.subdomains')
  257. config.map.basemap.minimumLevel = readLevel(basemap.minimumLevel, issues, 'map.basemap.minimumLevel')
  258. config.map.basemap.maximumLevel = readLevel(basemap.maximumLevel, issues, 'map.basemap.maximumLevel')
  259. config.map.tianditu.token = readString(tianditu.token, '', issues, 'map.tianditu.token')
  260. if (tianditu.layer !== undefined && tianditu.layer !== null && tianditu.layer !== '') {
  261. if (['img', 'vec', 'ter'].includes(tianditu.layer)) {
  262. config.map.tianditu.layer = tianditu.layer
  263. } else {
  264. issues.push(`map.tianditu.layer 只认 img/vec/ter,收到 ${JSON.stringify(tianditu.layer)},已退回 img`)
  265. }
  266. }
  267. config.map.cesium.enabled = readBoolean(cesium.enabled, config.map.cesium.enabled, issues, 'map.cesium.enabled')
  268. config.map.cesium.baseUrl = readString(cesium.baseUrl, '', issues, 'map.cesium.baseUrl')
  269. // 票 21:地形(本地/甲方 DEM)与叠加影像(本地精细切片)
  270. const terrain = isPlainObject(map.terrain) ? map.terrain : {}
  271. config.map.terrain.url = readString(terrain.url, '', issues, 'map.terrain.url')
  272. config.map.terrain.requestVertexNormals = readBoolean(
  273. terrain.requestVertexNormals,
  274. config.map.terrain.requestVertexNormals,
  275. issues,
  276. 'map.terrain.requestVertexNormals',
  277. )
  278. if (map.overlays !== undefined && map.overlays !== null && !Array.isArray(map.overlays)) {
  279. issues.push('map.overlays 应为数组,已忽略')
  280. } else {
  281. config.map.overlays = (map.overlays || []).map((item, index) => {
  282. const overlay = isPlainObject(item) ? item : {}
  283. const bounds = Array.isArray(overlay.bounds) && overlay.bounds.length === 4 && overlay.bounds.every((n) => typeof n === 'number')
  284. if (overlay.bounds !== undefined && overlay.bounds !== null && !bounds) {
  285. issues.push(`map.overlays[${index}].bounds 应为 [west, south, east, north] 四个数字,已忽略`)
  286. }
  287. return {
  288. url: readString(overlay.url, '', issues, `map.overlays[${index}].url`),
  289. minimumLevel: readLevel(overlay.minimumLevel, issues, `map.overlays[${index}].minimumLevel`),
  290. maximumLevel: readLevel(overlay.maximumLevel, issues, `map.overlays[${index}].maximumLevel`),
  291. bounds: bounds ? overlay.bounds.slice() : null,
  292. credit: readString(overlay.credit, '', issues, `map.overlays[${index}].credit`),
  293. }
  294. })
  295. }
  296. // 3D Tiles / 倾斜摄影入口(票 21):每项一个 tileset URL
  297. if (map.tilesets !== undefined && map.tilesets !== null && !Array.isArray(map.tilesets)) {
  298. issues.push('map.tilesets 应为数组,已忽略')
  299. } else {
  300. config.map.tilesets = (map.tilesets || []).map((item, index) => {
  301. const tileset = isPlainObject(item) ? item : {}
  302. return {
  303. url: readString(tileset.url, '', issues, `map.tilesets[${index}].url`),
  304. maximumScreenSpaceError: readNonNegativeNumber(
  305. tileset.maximumScreenSpaceError,
  306. issues,
  307. `map.tilesets[${index}].maximumScreenSpaceError`,
  308. ),
  309. maximumDistance: readNonNegativeNumber(
  310. tileset.maximumDistance,
  311. issues,
  312. `map.tilesets[${index}].maximumDistance`,
  313. ),
  314. loadDistance: readNonNegativeNumber(
  315. tileset.loadDistance,
  316. issues,
  317. `map.tilesets[${index}].loadDistance`,
  318. ),
  319. eager: readBoolean(tileset.eager, true, issues, `map.tilesets[${index}].eager`),
  320. role: readString(tileset.role, '', issues, `map.tilesets[${index}].role`),
  321. credit: readString(tileset.credit, '', issues, `map.tilesets[${index}].credit`),
  322. regionManifest: readString(tileset.regionManifest, '', issues, `map.tilesets[${index}].regionManifest`),
  323. progressiveRefinement: readBoolean(tileset.progressiveRefinement, false, issues, `map.tilesets[${index}].progressiveRefinement`),
  324. ...(tileset.lighting === undefined ? {} : { lighting: readBoolean(tileset.lighting, false, issues, `map.tilesets[${index}].lighting`) }),
  325. }
  326. })
  327. }
  328. // 视角预设与进入引导(票 30):坐标/开关都由配置给,组件不硬编码
  329. const home = isPlainObject(map.home) ? map.home : {}
  330. config.map.home = {
  331. lon: readNumber(home.lon, issues, 'map.home.lon'),
  332. lat: readNumber(home.lat, issues, 'map.home.lat'),
  333. height: readNumber(home.height, issues, 'map.home.height'),
  334. heading: readNumber(home.heading, issues, 'map.home.heading'),
  335. pitch: readNumber(home.pitch, issues, 'map.home.pitch'),
  336. range: readNumber(home.range, issues, 'map.home.range'),
  337. label: readString(home.label, '', issues, 'map.home.label'),
  338. }
  339. const locate = isPlainObject(map.locate) ? map.locate : {}
  340. for (const tier of ['point', 'device']) {
  341. const source = isPlainObject(locate[tier]) ? locate[tier] : {}
  342. config.map.locate[tier] = {
  343. heading: readNumber(source.heading, issues, `map.locate.${tier}.heading`),
  344. pitch: readNumber(source.pitch, issues, `map.locate.${tier}.pitch`),
  345. range: readNumber(source.range, issues, `map.locate.${tier}.range`),
  346. }
  347. if (config.map.locate[tier].range !== null && config.map.locate[tier].range <= 0) {
  348. issues.push(`map.locate.${tier}.range 应为正数,已退回默认`)
  349. config.map.locate[tier].range = null
  350. }
  351. }
  352. const intro = isPlainObject(map.intro) ? map.intro : {}
  353. config.map.intro = {
  354. enabled: readBoolean(intro.enabled, config.map.intro.enabled, issues, 'map.intro.enabled'),
  355. title: readString(intro.title, '', issues, 'map.intro.title'),
  356. subtitle: readString(intro.subtitle, '', issues, 'map.intro.subtitle'),
  357. spinSeconds: readNumber(intro.spinSeconds, issues, 'map.intro.spinSeconds'),
  358. }
  359. // 票 36:主场景区(`map.primaryArea.bounds` = `[west, south, east, north]` 度)。
  360. //
  361. // 判定收在 `domain/primaryArea.js`(形状 / `west<east`、`south<north`),这里只做两件事:
  362. // ① 原样收下部署给的数组 —— **绝不自动翻转**(决策票 01 Q2=A:写好顺序是部署的责任,
  363. // 运行时翻转会让「配置抄错」在运行期隐身);
  364. // ② 不可用时记进配置问题清单并**保留原因**,运行期据此走关闭路径(限位与自建雾一起关)。
  365. // 没给 `map.primaryArea` 时维持内置默认的 `configured: false`(= 未配置,同样关闭)。
  366. if (map.primaryArea !== undefined && map.primaryArea !== null && !isPlainObject(map.primaryArea)) {
  367. issues.push('map.primaryArea 应为对象,已忽略(主场景区限位与雾气关闭)')
  368. } else if (isPlainObject(map.primaryArea)) {
  369. const bounds = map.primaryArea.bounds
  370. config.map.primaryArea = { bounds: Array.isArray(bounds) ? bounds.slice() : null, configured: true }
  371. const areaPlan = planPrimaryArea(config.map)
  372. if (!areaPlan.enabled) {
  373. issues.push(`map.primaryArea.bounds ${areaPlan.reason},已按关闭处理(${PRIMARY_AREA_OFF_SUFFIX})`)
  374. }
  375. }
  376. // 票 38:区内视距雾气(不配就用 `domain/fog.js` 的规格默认)。
  377. // 只做「宽松归一化」:数值/色值不合法就置空 → 由计划层退回规格默认并留原因,
  378. // **不在这里判业务口径**(曲线端点语义、色相判据都在 domain,只有一处)。
  379. const fog = isPlainObject(map.fog) ? map.fog : {}
  380. config.map.fog = {
  381. enabled: readBoolean(fog.enabled, true, issues, 'map.fog.enabled'),
  382. startDistance: readNumber(fog.startDistance, issues, 'map.fog.startDistance'),
  383. endDistance: readNumber(fog.endDistance, issues, 'map.fog.endDistance'),
  384. color: readString(fog.color, '', issues, 'map.fog.color'),
  385. fadeInMs: readNumber(fog.fadeInMs, issues, 'map.fog.fadeInMs'),
  386. fadeOutMs: readNumber(fog.fadeOutMs, issues, 'map.fog.fadeOutMs'),
  387. altitudeFullM: readNumber(fog.altitudeFullM, issues, 'map.fog.altitudeFullM'),
  388. altitudeZeroM: readNumber(fog.altitudeZeroM, issues, 'map.fog.altitudeZeroM'),
  389. altitudeSlewMs: readNumber(fog.altitudeSlewMs, issues, 'map.fog.altitudeSlewMs'),
  390. }
  391. // 色值写法错了要报出来(否则会静默用默认色,维护者以为改生效了)。
  392. if (config.map.fog.color && !parseHexColor(config.map.fog.color)) {
  393. issues.push(`map.fog.color 应为 #rrggbb,收到 ${JSON.stringify(config.map.fog.color)},已退回默认色`)
  394. config.map.fog.color = ''
  395. }
  396. if (Number.isFinite(config.map.fog.startDistance) && Number.isFinite(config.map.fog.endDistance)
  397. && config.map.fog.endDistance <= config.map.fog.startDistance) {
  398. issues.push('map.fog.endDistance 必须大于 map.fog.startDistance,已退回规格默认端点')
  399. config.map.fog.startDistance = null
  400. config.map.fog.endDistance = null
  401. }
  402. // 相机高度收口的**两端也必须同向**:`altitudeZeroM` 必须大于 `altitudeFullM`。
  403. // 写反(例如 full=160000 / zero=2000)会让 `fogAltitudeFactor` 恒为 1 —— 等于**关掉**这层保护,
  404. // 而它正是"退出时整个地球变白"的防护(见 `FOG_DEFAULTS.altitudeFullM` 的注释)。
  405. //
  406. // 与上面那对不同,这里**只报不改**:`full` 与 `zero` 两个数各自都是合法的(不像
  407. // endDistance 写小了就是退化),"要不要在某个高度之上保留雾"是部署的取舍,
  408. // 替它选一边就是拿假值覆盖真意图。但**绝不能静默**:不然维护者会以为保护还在。
  409. if (Number.isFinite(config.map.fog.altitudeFullM) && Number.isFinite(config.map.fog.altitudeZeroM)
  410. && config.map.fog.altitudeZeroM <= config.map.fog.altitudeFullM) {
  411. issues.push(
  412. 'map.fog.altitudeZeroM 必须大于 map.fog.altitudeFullM,'
  413. + '否则相机高度收口恒为 1(等于关闭该保护,退出时可能出现整个地球变白)',
  414. )
  415. }
  416. // 票 39:粗→精影像交接(不配就用 `domain/imageryHandover.js` 的默认)。
  417. const handover = isPlainObject(map.handover) ? map.handover : {}
  418. config.map.handover = {
  419. enabled: readBoolean(handover.enabled, true, issues, 'map.handover.enabled'),
  420. fadeMs: readNumber(handover.fadeMs, issues, 'map.handover.fadeMs'),
  421. minEdgeOcclusion: readNumber(handover.minEdgeOcclusion, issues, 'map.handover.minEdgeOcclusion'),
  422. requireTilesLoaded: readBoolean(handover.requireTilesLoaded, null, issues, 'map.handover.requireTilesLoaded'),
  423. }
  424. // 遮蔽率是 0–1 的比例;给个 >1 或负数会静默让闸门永远(不)通过 —— 退回默认并记 issue。
  425. if (Number.isFinite(config.map.handover.minEdgeOcclusion)
  426. && (config.map.handover.minEdgeOcclusion < 0 || config.map.handover.minEdgeOcclusion > 1)) {
  427. issues.push('map.handover.minEdgeOcclusion 应在 0–1 之间,已退回默认(0.9)')
  428. config.map.handover.minEdgeOcclusion = null
  429. }
  430. // 一张图调色(科技蓝滤镜):宽松归一化(同雾/交接)—— 数值/色值不合法就置空,
  431. // 由 `domain/sceneGrade.js` 的计划层退回默认;"不做双色"等口径全在 domain,只此一份。
  432. const gradeConf = isPlainObject(map.grade) ? map.grade : {}
  433. config.map.grade = {
  434. enabled: readBoolean(gradeConf.enabled, true, issues, 'map.grade.enabled'),
  435. saturation: readNumber(gradeConf.saturation, issues, 'map.grade.saturation'),
  436. brightness: readNumber(gradeConf.brightness, issues, 'map.grade.brightness'),
  437. contrast: readNumber(gradeConf.contrast, issues, 'map.grade.contrast'),
  438. tint: readString(gradeConf.tint, '', issues, 'map.grade.tint'),
  439. tintStrength: readNumber(gradeConf.tintStrength, issues, 'map.grade.tintStrength'),
  440. }
  441. // 色值写法错了要报出来(否则会静默用默认色,维护者以为改生效了)—— 与 map.fog.color 同款。
  442. if (config.map.grade.tint && !parseHexColor(config.map.grade.tint)) {
  443. issues.push(`map.grade.tint 应为 #rrggbb,收到 ${JSON.stringify(config.map.grade.tint)},已退回默认色`)
  444. config.map.grade.tint = ''
  445. }
  446. // 票 21:地面/管线视图的取值覆盖(缺省用 domain/viewModes.js 的默认;脏值忽略)
  447. const viewMode = isPlainObject(map.viewMode) ? map.viewMode : {}
  448. const pipeline = isPlainObject(viewMode.pipeline) ? viewMode.pipeline : {}
  449. config.map.viewMode = {
  450. pitch: readNumber(pipeline.pitch, issues, 'map.viewMode.pipeline.pitch'),
  451. rangeFactor: readNumber(pipeline.rangeFactor, issues, 'map.viewMode.pipeline.rangeFactor'),
  452. terrainAlpha: readNumber(pipeline.terrainAlpha, issues, 'map.viewMode.pipeline.terrainAlpha'),
  453. tilesetAlpha: readNumber(pipeline.tilesetAlpha, issues, 'map.viewMode.pipeline.tilesetAlpha'),
  454. }
  455. // 片区采用正式编码,旧 regionMap 部署映射已退役。
  456. // 票 47:设备状态动效开关(决策票 07 的问题 6)。
  457. // 与票 32 的 `map.ui.debug` **完全同构**:缺省 `null` = 跟随构建形态
  458. // (开发构建开特效、生产构建关,正式环境不该有动画压满屏);
  459. // 现场要在生产包里看特效就显式写 `true`。脏值退回默认并记 issue,不静默吞掉。
  460. const effects = isPlainObject(map.effects) ? map.effects : {}
  461. const abnormalLimit = effects.abnormalLimit
  462. config.map.effects = {
  463. enabled: readBoolean(effects.enabled, null, issues, 'map.effects.enabled'),
  464. // 同屏异常设备上限(决策票 07 定 30):超过则动画全停、降级为静态异常标记 + 计数。
  465. abnormalLimit: abnormalLimit === undefined || abnormalLimit === null
  466. ? 30
  467. : (Number.isInteger(abnormalLimit) && abnormalLimit >= 1
  468. ? abnormalLimit
  469. : (issues.push(`map.effects.abnormalLimit 应为正整数,收到 ${JSON.stringify(abnormalLimit)},已退回 30`), 30)),
  470. }
  471. const overlap = isPlainObject(map.overlap) ? map.overlap : {}
  472. const maxFan = overlap.maxFan
  473. if (maxFan === undefined || maxFan === null) {
  474. config.map.overlap = { maxFan: 10 }
  475. } else if (Number.isInteger(maxFan) && maxFan >= 1) {
  476. config.map.overlap = { maxFan }
  477. } else {
  478. issues.push(`map.overlap.maxFan 应为正整数,收到 ${JSON.stringify(maxFan)},已退回 10`)
  479. config.map.overlap = { maxFan: 10 }
  480. }
  481. // 票 32:UI 形态开关。缺省 null = 跟随构建模式(`import.meta.env.DEV`),
  482. // 生产构建自动收敛为「干净模式」;要在地产现场调试生产包时可显式写 true。
  483. const ui = isPlainObject(map.ui) ? map.ui : {}
  484. config.map.ui = {
  485. debug: readBoolean(ui.debug, null, issues, 'map.ui.debug'),
  486. }
  487. return { config, issues }
  488. }
  489. /** 构建期环境变量 → 原始配置形状(纯函数,便于单测直接喂一个 env 对象)。 */
  490. export function readEnvConfig(env) {
  491. if (!isPlainObject(env)) return {}
  492. const out = {}
  493. if (env[ENV_KEYS.dataSource]) out.dataSource = env[ENV_KEYS.dataSource]
  494. const api = {}
  495. if (env[ENV_KEYS.apiBaseUrl]) api.baseUrl = env[ENV_KEYS.apiBaseUrl]
  496. if (env[ENV_KEYS.apiToken]) api.token = env[ENV_KEYS.apiToken]
  497. if (Object.keys(api).length) out.api = api
  498. const ws = {}
  499. if (env[ENV_KEYS.wsUrl]) ws.url = env[ENV_KEYS.wsUrl]
  500. if (env[ENV_KEYS.wsToken]) ws.token = env[ENV_KEYS.wsToken]
  501. if (Object.keys(ws).length) out.ws = ws
  502. const map = {}
  503. if (env[ENV_KEYS.tiandituToken]) map.tianditu = { token: env[ENV_KEYS.tiandituToken] }
  504. if (env[ENV_KEYS.cesiumBaseUrl]) map.cesium = { baseUrl: env[ENV_KEYS.cesiumBaseUrl] }
  505. const basemap = {}
  506. if (env[ENV_KEYS.basemapSource]) basemap.source = env[ENV_KEYS.basemapSource]
  507. if (env[ENV_KEYS.basemapUrl]) basemap.url = env[ENV_KEYS.basemapUrl]
  508. if (Object.keys(basemap).length) map.basemap = basemap
  509. if (Object.keys(map).length) out.map = map
  510. return out
  511. }
  512. /** 深合并(只对普通对象递归),后面的来源覆盖前面的。 */
  513. export function mergeConfig(base, patch) {
  514. if (!isPlainObject(patch)) return base
  515. const out = { ...base }
  516. for (const [key, value] of Object.entries(patch)) {
  517. out[key] = isPlainObject(value) && isPlainObject(base[key]) ? mergeConfig(base[key], value) : value
  518. }
  519. return out
  520. }
  521. /**
  522. * 解析配置文件的地址。
  523. * @param {object} [options]
  524. * @param {string} [options.base] 应用基址(`import.meta.env.BASE_URL` / qiankun 注入)
  525. * @param {string} [options.documentBase] `document.baseURI`
  526. * @param {string} [options.explicitUrl] 显式 URL;`'auto'` 表示按应用基址取约定路径
  527. * @returns {string} 绝对 URL
  528. */
  529. export function resolveConfigUrl({ base = './', documentBase = 'http://localhost/', explicitUrl = '' } = {}) {
  530. const docBase = documentBase || 'http://localhost/'
  531. if (explicitUrl && explicitUrl !== CONFIG_URL_AUTO) return new URL(explicitUrl, docBase).href
  532. return new URL(CONFIG_FILE_PATH, new URL(base || './', docBase)).href
  533. }
  534. /** 读一个配置来源的响应体;返回原始对象或一个失败原因。 */
  535. export async function readConfigPayload(response) {
  536. if (!response || typeof response.text !== 'function') return { ok: false, reason: 'no-response' }
  537. if (!response.ok) {
  538. // 404 = 部署没放这个文件;其余非 2xx = 放了但服务端是坏的。
  539. return { ok: false, reason: response.status === 404 ? 'missing' : `http-${response.status}` }
  540. }
  541. const text = await response.text()
  542. const trimmed = text.trim()
  543. // dev 的 SPA 回退 / 静态服务器的错误页都会返回 HTML,一律按「文件不存在」处理。
  544. if (!trimmed || trimmed.startsWith('<')) return { ok: false, reason: 'missing' }
  545. try {
  546. const raw = JSON.parse(trimmed)
  547. if (!isPlainObject(raw)) return { ok: false, reason: 'not-an-object' }
  548. return { ok: true, raw }
  549. } catch {
  550. return { ok: false, reason: 'invalid-json' }
  551. }
  552. }
  553. function readViteEnv() {
  554. return import.meta && import.meta.env ? import.meta.env : {}
  555. }
  556. function readDocumentBase() {
  557. return typeof document !== 'undefined' && document.baseURI ? document.baseURI : 'http://localhost/'
  558. }
  559. let cached = null
  560. let inFlight = null
  561. /**
  562. * 载入配置(幂等、进程内缓存一次)。
  563. * 所有依赖都可注入,便于脱浏览器断言:`{ scope, env, fetchImpl, base, documentBase, now }`。
  564. * @returns {Promise<object>} `{ state, source, url, config, issues, error, credential, loadedAt }`
  565. */
  566. export function loadCockpitConfig(options = {}) {
  567. if (cached) return Promise.resolve(cached)
  568. if (inFlight) return inFlight
  569. inFlight = resolveCockpitConfig(options)
  570. .then((result) => {
  571. cached = result
  572. inFlight = null
  573. return result
  574. })
  575. .catch((error) => {
  576. // 兜底:任何意外都收敛成 failed,绝不向外抛(页面不该因为配置服务崩掉而白屏)。
  577. inFlight = null
  578. cached = {
  579. state: CONFIG_STATE.FAILED,
  580. source: 'defaults',
  581. url: '',
  582. config: builtinConfig(),
  583. issues: [],
  584. error: String((error && error.message) || error),
  585. credential: CREDENTIAL.NONE,
  586. loadedAt: new Date().toISOString(),
  587. }
  588. return cached
  589. })
  590. return inFlight
  591. }
  592. async function resolveCockpitConfig({ scope, env, fetchImpl, base, documentBase, now } = {}) {
  593. const globalScope = scope || (typeof globalThis !== 'undefined' ? globalThis : {})
  594. const viteEnv = env !== undefined ? env : readViteEnv()
  595. const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null)
  596. const loadedAt = new Date(now === undefined ? Date.now() : now).toISOString()
  597. const injected = isPlainObject(globalScope[INJECTED_CONFIG_KEY]) ? globalScope[INJECTED_CONFIG_KEY] : null
  598. // 宿主页显式给了 URL 键就以它为准 —— 给空串表示「这个页面不要运行时配置文件」,
  599. // 这样宿主能在构建期开关之外逐页控制(走查的「默认无配置」用例也靠它自隔离)。
  600. const injectedUrl = globalScope[INJECTED_CONFIG_URL_KEY]
  601. const explicitUrl =
  602. injectedUrl === undefined || injectedUrl === null
  603. ? readString(viteEnv ? viteEnv[ENV_KEYS.configUrl] : '', '', [], 'VITE_COCKPIT_CONFIG_URL')
  604. : readString(injectedUrl, '', [], 'window.__COCKPIT_CONFIG_URL__')
  605. // 1) + 2) 内置默认由 normalizeConfig 兜底,这里先叠构建期环境变量。
  606. const envRaw = readEnvConfig(viteEnv)
  607. let raw = mergeConfig({}, envRaw)
  608. // 构建期环境变量也算「有配置」;只有一份来源都没有时才是缺失态(内置默认)。
  609. const hasEnvConfig = Object.keys(envRaw).length > 0
  610. let source = hasEnvConfig ? 'env' : 'defaults'
  611. let state = hasEnvConfig ? CONFIG_STATE.READY : CONFIG_STATE.MISSING
  612. let url = ''
  613. let error = ''
  614. const issues = []
  615. if (injected) {
  616. // 4) 宿主注入优先级最高:给了就不再拉文件。
  617. raw = mergeConfig(raw, injected)
  618. source = 'window'
  619. state = CONFIG_STATE.READY
  620. } else if (!explicitUrl) {
  621. // 没有显式配置 URL:不发请求(避免每次加载都刷 404),维持在「缺失 / 仅环境变量」态。
  622. url = ''
  623. } else {
  624. // 3) 运行时配置文件(显式 URL,或 'auto' = 应用基址下的约定路径)。
  625. url = resolveConfigUrl({
  626. base: base === undefined ? (viteEnv && viteEnv.BASE_URL) || './' : base,
  627. documentBase: documentBase === undefined ? readDocumentBase() : documentBase,
  628. explicitUrl,
  629. })
  630. if (!doFetch) {
  631. error = '当前环境没有 fetch,无法加载配置文件'
  632. state = CONFIG_STATE.FAILED
  633. issues.push(error)
  634. } else {
  635. try {
  636. const payload = await readConfigPayload(await doFetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' }))
  637. if (payload.ok) {
  638. raw = mergeConfig(raw, payload.raw)
  639. source = 'file'
  640. state = CONFIG_STATE.READY
  641. } else if (payload.reason === 'missing' || payload.reason === 'no-response') {
  642. // 文件不在:环境变量层给的配置仍然有效,状态维持原样(缺失 / 已就绪)。
  643. if (!hasEnvConfig) state = CONFIG_STATE.MISSING
  644. } else {
  645. state = CONFIG_STATE.FAILED
  646. error = `配置文件不可用:${payload.reason}`
  647. }
  648. } catch (fetchError) {
  649. state = CONFIG_STATE.FAILED
  650. error = `配置文件请求失败:${String((fetchError && fetchError.message) || fetchError)}`
  651. }
  652. }
  653. }
  654. const { config: normalized, issues: normalizeIssues } = normalizeConfig(raw)
  655. // 票 76:DEV 走查桥必须落在**全局配置**上 —— `requestCockpitJson()` 读的是
  656. // `getCockpitConfig()`,只覆盖启动计划那份只会「注册 mock 但请求仍走 real」。
  657. const config = withUrlDataSourceOverride(normalized, null, !!(import.meta && import.meta.env && import.meta.env.DEV))
  658. return {
  659. state,
  660. source,
  661. url,
  662. config,
  663. issues: issues.concat(normalizeIssues),
  664. error,
  665. // ADR 0005(2026-09-28 修订):驾驶舱 REST 免鉴权,前端不携带任何 REST 凭据;
  666. // WS token 走 `ws.token`(runtimeStore 直读),不再经由 api.token 兜底。
  667. credential: CREDENTIAL.NONE,
  668. loadedAt,
  669. }
  670. }
  671. /** 同步读归一化后的配置;未载入时给内置默认(页面不必等配置就能渲染骨架)。 */
  672. export function getCockpitConfig() {
  673. return cached ? cached.config : builtinConfig()
  674. }
  675. /** 同步读完整加载结果;未载入时按「缺失」对待。 */
  676. export function getCockpitConfigState() {
  677. if (cached) return cached
  678. return {
  679. state: CONFIG_STATE.MISSING,
  680. source: 'defaults',
  681. url: '',
  682. config: builtinConfig(),
  683. issues: [],
  684. error: '',
  685. credential: CREDENTIAL.NONE,
  686. loadedAt: '',
  687. }
  688. }
  689. /** 仅测试用:清掉缓存,让下一次 loadCockpitConfig 重新解析。 */
  690. export function resetCockpitConfigForTest() {
  691. cached = null
  692. inFlight = null
  693. }