/** * 驾驶舱配置服务(票 04):后端地址、天地图、Cesium、WebSocket 与凭据的唯一读入口。 * * 为什么要有这一层(spec 第 13/14 条、ADR 0005): * 前端不硬编码账号、密钥或环境地址;配置由部署层注入;页面与 store 只读归一化后的结果。 * * 分层(后者覆盖前者,深合并): * 1. 内置默认 —— `dataSource: mock`(spec:默认 mock,真接口必须显式开启) * 2. 构建期环境变量 —— `import.meta.env.VITE_COCKPIT_*`(本机联调写 `.env.local`,已 gitignore) * 3. 运行时配置文件 —— `cockpit/config.json`(部署时放/改,同一个构建多环境) * 4. 宿主注入对象 —— `globalThis.__COCKPIT_CONFIG__`(qiankun 主应用 / 网关注入;给了就不再拉文件) * * 三态:`ready`(载入成功)/ `missing`(没有任何配置来源,用内置默认)/ `failed`(拿到了但不可用)。 * * 运行时文件**不会**在每次加载时盲试:没有显式配置 URL 就不发请求(否则每页都会刷一个 404, * 生产日志也会变脏)。要启用文件层,二选一: * · 宿主页加一行 `window.__COCKPIT_CONFIG_URL__ = 'auto'`(`auto` = 按应用基址取 `cockpit/config.json`, * qiankun / 子目录部署下都对);给绝对 URL 则直接从后端或别处拉配置;给空串 = 这个页面不要配置文件; * · 构建期设 `VITE_COCKPIT_CONFIG_URL`(同样接受 `auto` 或具体 URL)。宿主页的键优先于构建期。 * 文件不存在(404)或载荷是 HTML(静态服务器的错误页)都判为缺失态,不当作故障。 */ import { CONFIG_STATE, CREDENTIAL, DATA_SOURCE } from '../domain/configStatus.js' import { BASEMAP_SOURCE } from '../domain/basemap.js' /** 部署把配置文件放在这个相对路径下:`public/cockpit/config.json` → 产物 `cockpit/config.json`。 */ export const CONFIG_FILE_PATH = 'cockpit/config.json' /** 显式 URL 的特殊取值:按应用基址取约定路径(qiankun / 子目录部署下也正确)。 */ export const CONFIG_URL_AUTO = 'auto' /** 宿主注入用的全局键。 */ export const INJECTED_CONFIG_KEY = '__COCKPIT_CONFIG__' export const INJECTED_CONFIG_URL_KEY = '__COCKPIT_CONFIG_URL__' /** 构建期环境变量键(Vite 只把 `VITE_` 前缀暴露给客户端)。 */ export const ENV_KEYS = { dataSource: 'VITE_COCKPIT_DATA_SOURCE', apiBaseUrl: 'VITE_COCKPIT_API_BASE_URL', apiToken: 'VITE_COCKPIT_API_TOKEN', wsUrl: 'VITE_COCKPIT_WS_URL', wsToken: 'VITE_COCKPIT_WS_TOKEN', tiandituToken: 'VITE_COCKPIT_TIANDITU_TOKEN', cesiumBaseUrl: 'VITE_COCKPIT_CESIUM_BASE_URL', basemapSource: 'VITE_COCKPIT_BASEMAP_SOURCE', basemapUrl: 'VITE_COCKPIT_BASEMAP_URL', configUrl: 'VITE_COCKPIT_CONFIG_URL', } function isPlainObject(value) { return !!value && typeof value === 'object' && !Array.isArray(value) } /** 内置默认:没有任何配置来源时也必须是可跑的状态(mock + 不伪造凭据)。 */ export function builtinConfig() { return { version: 1, dataSource: DATA_SOURCE.MOCK, api: { baseUrl: '', token: '' }, ws: { url: '', token: '' }, map: { basemap: { source: BASEMAP_SOURCE.NONE, url: '', subdomains: [], minimumLevel: null, maximumLevel: null, credit: '', }, tianditu: { token: '', layer: 'img' }, terrain: { url: '', requestVertexNormals: true }, overlays: [], tilesets: [], home: { lon: null, lat: null, height: null, heading: null, pitch: null, range: null, label: '' }, intro: { enabled: true, title: '', subtitle: '', spinSeconds: null }, // 票 21:地面/管线视图的取值覆盖(不配就用 domain/viewModes.js 的默认) viewMode: { pitch: null, rangeFactor: null, terrainAlpha: null, tilesetAlpha: null }, // 票 32:UI 形态。null = 跟随构建模式(dev 显示调试信息,生产构建自动收敛) ui: { debug: null }, cesium: { enabled: true, baseUrl: '' }, }, } } function readString(value, fallback, issues, label) { if (value === undefined || value === null || value === '') return fallback if (typeof value !== 'string') { issues.push(`${label} 应为字符串,收到 ${typeof value},已退回默认`) return fallback } return value.trim() } function readBoolean(value, fallback, issues, label) { if (value === undefined || value === null) return fallback if (typeof value !== 'boolean') { issues.push(`${label} 应为布尔值,收到 ${typeof value},已退回默认`) return fallback } return value } function readStringArray(value, issues, label) { if (value === undefined || value === null || value === '') return [] if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { issues.push(`${label} 应为字符串数组,已忽略`) return [] } return value.map((item) => item.trim()).filter(Boolean) } function readLevel(value, issues, label) { if (value === undefined || value === null || value === '') return null if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { issues.push(`${label} 应为非负整数,收到 ${JSON.stringify(value)},已忽略`) return null } return value } function readNumber(value, issues, label) { if (value === undefined || value === null || value === '') return null if (typeof value !== 'number' || !Number.isFinite(value)) { issues.push(`${label} 应为数字,收到 ${JSON.stringify(value)},已忽略`) return null } return value } /** * 归一化 + 宽松校验:不认识的取值退回默认并记一条 issue,不因为一个错字段整份配置作废。 * @param {object} raw 合并后的原始配置 * @returns {{config: object, issues: string[]}} */ export function normalizeConfig(raw) { const config = builtinConfig() const issues = [] if (!isPlainObject(raw)) { if (raw !== undefined && raw !== null) issues.push('配置不是对象,已全部退回内置默认') return { config, issues } } if (raw.dataSource !== undefined && raw.dataSource !== null) { if (raw.dataSource === DATA_SOURCE.REAL || raw.dataSource === DATA_SOURCE.MOCK) { config.dataSource = raw.dataSource } else { issues.push(`dataSource 只认 mock/real,收到 ${JSON.stringify(raw.dataSource)},已退回 mock`) } } const api = isPlainObject(raw.api) ? raw.api : {} const ws = isPlainObject(raw.ws) ? raw.ws : {} const map = isPlainObject(raw.map) ? raw.map : {} const basemap = isPlainObject(map.basemap) ? map.basemap : {} const tianditu = isPlainObject(map.tianditu) ? map.tianditu : {} const cesium = isPlainObject(map.cesium) ? map.cesium : {} config.api.baseUrl = readString(api.baseUrl, config.api.baseUrl, issues, 'api.baseUrl') config.api.token = readString(api.token, config.api.token, issues, 'api.token') config.ws.url = readString(ws.url, config.ws.url, issues, 'ws.url') config.ws.token = readString(ws.token, config.ws.token, issues, 'ws.token') const knownSources = Object.values(BASEMAP_SOURCE) if (basemap.source !== undefined && basemap.source !== null && basemap.source !== '') { if (knownSources.includes(basemap.source)) { config.map.basemap.source = basemap.source } else { issues.push(`map.basemap.source 只认 ${knownSources.join('/')},收到 ${JSON.stringify(basemap.source)},已退回 none`) } } config.map.basemap.url = readString(basemap.url, '', issues, 'map.basemap.url') config.map.basemap.credit = readString(basemap.credit, '', issues, 'map.basemap.credit') config.map.basemap.subdomains = readStringArray(basemap.subdomains, issues, 'map.basemap.subdomains') config.map.basemap.minimumLevel = readLevel(basemap.minimumLevel, issues, 'map.basemap.minimumLevel') config.map.basemap.maximumLevel = readLevel(basemap.maximumLevel, issues, 'map.basemap.maximumLevel') config.map.tianditu.token = readString(tianditu.token, '', issues, 'map.tianditu.token') if (tianditu.layer !== undefined && tianditu.layer !== null && tianditu.layer !== '') { if (['img', 'vec', 'ter'].includes(tianditu.layer)) { config.map.tianditu.layer = tianditu.layer } else { issues.push(`map.tianditu.layer 只认 img/vec/ter,收到 ${JSON.stringify(tianditu.layer)},已退回 img`) } } config.map.cesium.enabled = readBoolean(cesium.enabled, config.map.cesium.enabled, issues, 'map.cesium.enabled') config.map.cesium.baseUrl = readString(cesium.baseUrl, '', issues, 'map.cesium.baseUrl') // 票 21:地形(本地/甲方 DEM)与叠加影像(本地精细切片) const terrain = isPlainObject(map.terrain) ? map.terrain : {} config.map.terrain.url = readString(terrain.url, '', issues, 'map.terrain.url') config.map.terrain.requestVertexNormals = readBoolean( terrain.requestVertexNormals, config.map.terrain.requestVertexNormals, issues, 'map.terrain.requestVertexNormals', ) if (map.overlays !== undefined && map.overlays !== null && !Array.isArray(map.overlays)) { issues.push('map.overlays 应为数组,已忽略') } else { config.map.overlays = (map.overlays || []).map((item, index) => { const overlay = isPlainObject(item) ? item : {} const bounds = Array.isArray(overlay.bounds) && overlay.bounds.length === 4 && overlay.bounds.every((n) => typeof n === 'number') if (overlay.bounds !== undefined && overlay.bounds !== null && !bounds) { issues.push(`map.overlays[${index}].bounds 应为 [west, south, east, north] 四个数字,已忽略`) } return { url: readString(overlay.url, '', issues, `map.overlays[${index}].url`), minimumLevel: readLevel(overlay.minimumLevel, issues, `map.overlays[${index}].minimumLevel`), maximumLevel: readLevel(overlay.maximumLevel, issues, `map.overlays[${index}].maximumLevel`), bounds: bounds ? overlay.bounds.slice() : null, credit: readString(overlay.credit, '', issues, `map.overlays[${index}].credit`), } }) } // 3D Tiles / 倾斜摄影入口(票 21):每项一个 tileset URL if (map.tilesets !== undefined && map.tilesets !== null && !Array.isArray(map.tilesets)) { issues.push('map.tilesets 应为数组,已忽略') } else { config.map.tilesets = (map.tilesets || []).map((item, index) => { const tileset = isPlainObject(item) ? item : {} return { url: readString(tileset.url, '', issues, `map.tilesets[${index}].url`), maximumScreenSpaceError: readNumber( tileset.maximumScreenSpaceError, issues, `map.tilesets[${index}].maximumScreenSpaceError`, ), credit: readString(tileset.credit, '', issues, `map.tilesets[${index}].credit`), } }) } // 视角预设与进入引导(票 30):坐标/开关都由配置给,组件不硬编码 const home = isPlainObject(map.home) ? map.home : {} config.map.home = { lon: readNumber(home.lon, issues, 'map.home.lon'), lat: readNumber(home.lat, issues, 'map.home.lat'), height: readNumber(home.height, issues, 'map.home.height'), heading: readNumber(home.heading, issues, 'map.home.heading'), pitch: readNumber(home.pitch, issues, 'map.home.pitch'), range: readNumber(home.range, issues, 'map.home.range'), label: readString(home.label, '', issues, 'map.home.label'), } const intro = isPlainObject(map.intro) ? map.intro : {} config.map.intro = { enabled: readBoolean(intro.enabled, config.map.intro.enabled, issues, 'map.intro.enabled'), title: readString(intro.title, '', issues, 'map.intro.title'), subtitle: readString(intro.subtitle, '', issues, 'map.intro.subtitle'), spinSeconds: readNumber(intro.spinSeconds, issues, 'map.intro.spinSeconds'), } // 票 21:地面/管线视图的取值覆盖(缺省用 domain/viewModes.js 的默认;脏值忽略) const viewMode = isPlainObject(map.viewMode) ? map.viewMode : {} const pipeline = isPlainObject(viewMode.pipeline) ? viewMode.pipeline : {} config.map.viewMode = { pitch: readNumber(pipeline.pitch, issues, 'map.viewMode.pipeline.pitch'), rangeFactor: readNumber(pipeline.rangeFactor, issues, 'map.viewMode.pipeline.rangeFactor'), terrainAlpha: readNumber(pipeline.terrainAlpha, issues, 'map.viewMode.pipeline.terrainAlpha'), tilesetAlpha: readNumber(pipeline.tilesetAlpha, issues, 'map.viewMode.pipeline.tilesetAlpha'), } // 票 32:UI 形态开关。缺省 null = 跟随构建模式(`import.meta.env.DEV`), // 生产构建自动收敛为「干净模式」;要在地产现场调试生产包时可显式写 true。 const ui = isPlainObject(map.ui) ? map.ui : {} config.map.ui = { debug: readBoolean(ui.debug, null, issues, 'map.ui.debug'), } return { config, issues } } /** 构建期环境变量 → 原始配置形状(纯函数,便于单测直接喂一个 env 对象)。 */ export function readEnvConfig(env) { if (!isPlainObject(env)) return {} const out = {} if (env[ENV_KEYS.dataSource]) out.dataSource = env[ENV_KEYS.dataSource] const api = {} if (env[ENV_KEYS.apiBaseUrl]) api.baseUrl = env[ENV_KEYS.apiBaseUrl] if (env[ENV_KEYS.apiToken]) api.token = env[ENV_KEYS.apiToken] if (Object.keys(api).length) out.api = api const ws = {} if (env[ENV_KEYS.wsUrl]) ws.url = env[ENV_KEYS.wsUrl] if (env[ENV_KEYS.wsToken]) ws.token = env[ENV_KEYS.wsToken] if (Object.keys(ws).length) out.ws = ws const map = {} if (env[ENV_KEYS.tiandituToken]) map.tianditu = { token: env[ENV_KEYS.tiandituToken] } if (env[ENV_KEYS.cesiumBaseUrl]) map.cesium = { baseUrl: env[ENV_KEYS.cesiumBaseUrl] } const basemap = {} if (env[ENV_KEYS.basemapSource]) basemap.source = env[ENV_KEYS.basemapSource] if (env[ENV_KEYS.basemapUrl]) basemap.url = env[ENV_KEYS.basemapUrl] if (Object.keys(basemap).length) map.basemap = basemap if (Object.keys(map).length) out.map = map return out } /** 深合并(只对普通对象递归),后面的来源覆盖前面的。 */ export function mergeConfig(base, patch) { if (!isPlainObject(patch)) return base const out = { ...base } for (const [key, value] of Object.entries(patch)) { out[key] = isPlainObject(value) && isPlainObject(base[key]) ? mergeConfig(base[key], value) : value } return out } /** * 解析配置文件的地址。 * @param {object} [options] * @param {string} [options.base] 应用基址(`import.meta.env.BASE_URL` / qiankun 注入) * @param {string} [options.documentBase] `document.baseURI` * @param {string} [options.explicitUrl] 显式 URL;`'auto'` 表示按应用基址取约定路径 * @returns {string} 绝对 URL */ export function resolveConfigUrl({ base = './', documentBase = 'http://localhost/', explicitUrl = '' } = {}) { const docBase = documentBase || 'http://localhost/' if (explicitUrl && explicitUrl !== CONFIG_URL_AUTO) return new URL(explicitUrl, docBase).href return new URL(CONFIG_FILE_PATH, new URL(base || './', docBase)).href } /** 读一个配置来源的响应体;返回原始对象或一个失败原因。 */ export async function readConfigPayload(response) { if (!response || typeof response.text !== 'function') return { ok: false, reason: 'no-response' } if (!response.ok) { // 404 = 部署没放这个文件;其余非 2xx = 放了但服务端是坏的。 return { ok: false, reason: response.status === 404 ? 'missing' : `http-${response.status}` } } const text = await response.text() const trimmed = text.trim() // dev 的 SPA 回退 / 静态服务器的错误页都会返回 HTML,一律按「文件不存在」处理。 if (!trimmed || trimmed.startsWith('<')) return { ok: false, reason: 'missing' } try { const raw = JSON.parse(trimmed) if (!isPlainObject(raw)) return { ok: false, reason: 'not-an-object' } return { ok: true, raw } } catch { return { ok: false, reason: 'invalid-json' } } } function readViteEnv() { return import.meta && import.meta.env ? import.meta.env : {} } function readDocumentBase() { return typeof document !== 'undefined' && document.baseURI ? document.baseURI : 'http://localhost/' } let cached = null let inFlight = null /** * 载入配置(幂等、进程内缓存一次)。 * 所有依赖都可注入,便于脱浏览器断言:`{ scope, env, fetchImpl, base, documentBase, now }`。 * @returns {Promise} `{ state, source, url, config, issues, error, credential, loadedAt }` */ export function loadCockpitConfig(options = {}) { if (cached) return Promise.resolve(cached) if (inFlight) return inFlight inFlight = resolveCockpitConfig(options) .then((result) => { cached = result inFlight = null return result }) .catch((error) => { // 兜底:任何意外都收敛成 failed,绝不向外抛(页面不该因为配置服务崩掉而白屏)。 inFlight = null cached = { state: CONFIG_STATE.FAILED, source: 'defaults', url: '', config: builtinConfig(), issues: [], error: String((error && error.message) || error), credential: CREDENTIAL.NONE, loadedAt: new Date().toISOString(), } return cached }) return inFlight } async function resolveCockpitConfig({ scope, env, fetchImpl, base, documentBase, now } = {}) { const globalScope = scope || (typeof globalThis !== 'undefined' ? globalThis : {}) const viteEnv = env !== undefined ? env : readViteEnv() const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null) const loadedAt = new Date(now === undefined ? Date.now() : now).toISOString() const injected = isPlainObject(globalScope[INJECTED_CONFIG_KEY]) ? globalScope[INJECTED_CONFIG_KEY] : null // 宿主页显式给了 URL 键就以它为准 —— 给空串表示「这个页面不要运行时配置文件」, // 这样宿主能在构建期开关之外逐页控制(走查的「默认无配置」用例也靠它自隔离)。 const injectedUrl = globalScope[INJECTED_CONFIG_URL_KEY] const explicitUrl = injectedUrl === undefined || injectedUrl === null ? readString(viteEnv ? viteEnv[ENV_KEYS.configUrl] : '', '', [], 'VITE_COCKPIT_CONFIG_URL') : readString(injectedUrl, '', [], 'window.__COCKPIT_CONFIG_URL__') // 1) + 2) 内置默认由 normalizeConfig 兜底,这里先叠构建期环境变量。 const envRaw = readEnvConfig(viteEnv) let raw = mergeConfig({}, envRaw) // 构建期环境变量也算「有配置」;只有一份来源都没有时才是缺失态(内置默认)。 const hasEnvConfig = Object.keys(envRaw).length > 0 let source = hasEnvConfig ? 'env' : 'defaults' let state = hasEnvConfig ? CONFIG_STATE.READY : CONFIG_STATE.MISSING let url = '' let error = '' const issues = [] if (injected) { // 4) 宿主注入优先级最高:给了就不再拉文件。 raw = mergeConfig(raw, injected) source = 'window' state = CONFIG_STATE.READY } else if (!explicitUrl) { // 没有显式配置 URL:不发请求(避免每次加载都刷 404),维持在「缺失 / 仅环境变量」态。 url = '' } else { // 3) 运行时配置文件(显式 URL,或 'auto' = 应用基址下的约定路径)。 url = resolveConfigUrl({ base: base === undefined ? (viteEnv && viteEnv.BASE_URL) || './' : base, documentBase: documentBase === undefined ? readDocumentBase() : documentBase, explicitUrl, }) if (!doFetch) { error = '当前环境没有 fetch,无法加载配置文件' state = CONFIG_STATE.FAILED issues.push(error) } else { try { const payload = await readConfigPayload(await doFetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' })) if (payload.ok) { raw = mergeConfig(raw, payload.raw) source = 'file' state = CONFIG_STATE.READY } else if (payload.reason === 'missing' || payload.reason === 'no-response') { // 文件不在:环境变量层给的配置仍然有效,状态维持原样(缺失 / 已就绪)。 if (!hasEnvConfig) state = CONFIG_STATE.MISSING } else { state = CONFIG_STATE.FAILED error = `配置文件不可用:${payload.reason}` } } catch (fetchError) { state = CONFIG_STATE.FAILED error = `配置文件请求失败:${String((fetchError && fetchError.message) || fetchError)}` } } } const { config, issues: normalizeIssues } = normalizeConfig(raw) return { state, source, url, config, issues: issues.concat(normalizeIssues), error, credential: config.api.token ? CREDENTIAL.CONFIG : CREDENTIAL.NONE, loadedAt, } } /** 同步读归一化后的配置;未载入时给内置默认(页面不必等配置就能渲染骨架)。 */ export function getCockpitConfig() { return cached ? cached.config : builtinConfig() } /** 同步读完整加载结果;未载入时按「缺失」对待。 */ export function getCockpitConfigState() { if (cached) return cached return { state: CONFIG_STATE.MISSING, source: 'defaults', url: '', config: builtinConfig(), issues: [], error: '', credential: CREDENTIAL.NONE, loadedAt: '', } } /** 仅测试用:清掉缓存,让下一次 loadCockpitConfig 重新解析。 */ export function resetCockpitConfigForTest() { cached = null inFlight = null }