build-manifest.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. // 依据维护者最终命名切图生成运行时 manifest(批次 1,第三轮)
  2. //
  3. // 素材全部由维护者从蓝湖导出并以中文语义命名,直接放在
  4. // public/cockpit/assets/icons/ 下。映射原则:**一个文件一个 key**(按文件语义),
  5. // 多个消费方(面板/行业/chip)经 domain/icons.js 的注册表共享同一 key。
  6. // 路径保留原中文(浏览器会自动编码请求 URL;manifest 内不做 encode,磁盘校验才对得上)。
  7. import { readFileSync, writeFileSync, existsSync } from 'node:fs'
  8. import { createHash } from 'node:crypto'
  9. import { join } from 'node:path'
  10. const publicDir = 'public/cockpit/assets'
  11. const ICON_MAP = {
  12. // ---- 顶栏品牌位 ----
  13. 'icon.brand': { file: 'brand-title.png', frame: { width: 470, height: 114 }, meaning: '品牌主标题(城市主生命线驾驶舱系统 + 光带)', core: true },
  14. // 票 17 第六轮:品牌区装饰素材(蓝湖 D2 Group 5061 的「光」层与 header 底边光路)。
  15. // 标题改 HTML 大字后,光带/光路单独消费,避免整块切图缩放导致文字过小。
  16. 'icon.brand.glow': { file: 'brand-glow.png', frame: { width: 442, height: 70 }, meaning: '品牌标题下方光带(蓝湖「光」层 221×35@2x,实测亮线在高度 44%)' },
  17. 'icon.brand.glow.path': { file: 'brand-glow-path.png', frame: { width: 2302, height: 44 }, meaning: '顶栏底部分隔光路(左段高位、25%–59% 处下沉的折线光线)' },
  18. // 票 17 第七轮:一级模式按钮选中态底板(蓝湖 D2 `nav/title-bg` 90×34@2x:顶部青色亮线 + 浅蓝渐变板)。
  19. 'icon.nav.active-bg': { file: 'title-select-bg.png', frame: { width: 180, height: 68 }, meaning: '模式按钮选中态底板(nav/title-bg,青色亮线 + 浅蓝渐变板)' },
  20. // 票 18(批次 5):主画板品牌标题后方「光池」——椭圆多层渐晕 + 中心亮斑的复杂位图光效,
  21. // 按规格 03「不可 CSS/SVG 无损复现才资源化」判据入库。设计稿 D6 realFrame 489×128,
  22. // 相对品牌标题左上 (-19,-1)。同源小档(brand-glow-1/3 = 300×80 / 442×116)无独立消费点,
  23. // 不入运行时(无死资源纪律),原文件移入设计归档区 unconsumed-decor/。
  24. 'icon.brand.pool': { file: 'brand-pool.png', frame: { width: 489, height: 128 }, meaning: '品牌标题后方光池(蓝湖「光」层 489×128@2x 位图,复杂光效不可 CSS 无损复现)' },
  25. // ---- 地图摘要四个 chip(维护者指派的语义对应)----
  26. 'icon.layers': { file: 'pipeline.png', frame: { width: 256, height: 256 }, meaning: '管线图层(管网设施)' },
  27. 'icon.points': { file: 'monitoring.png', frame: { width: 256, height: 256 }, meaning: '监测点(预警处置)' },
  28. 'icon.event': { file: 'emergency-event.png', frame: { width: 256, height: 256 }, meaning: '事件定位(应急指挥)' },
  29. 'icon.resource': { file: 'resource-asset.png', frame: { width: 256, height: 256 }, meaning: '应急资源(规模种类)' },
  30. // ---- 行业筛选 ----
  31. 'icon.industry.gas': { file: 'industry-gas.png', frame: { width: 15, height: 15 }, meaning: '燃气(火焰)' },
  32. 'icon.industry.water': { file: 'industry-water.png', frame: { width: 16, height: 15 }, meaning: '供水(水滴)' },
  33. 'icon.industry.drainage': { file: 'industry-drainage.png', frame: { width: 32, height: 32 }, meaning: '排水(水波)' },
  34. 'icon.manhole': { file: 'manhole.png', frame: { width: 15, height: 15 }, meaning: '窨井/井盖(紫色网格球)' },
  35. // ---- 面板小标题(第三轮:Texture/Icon 的 I/Pipe/Q 系列切图,去色 + 主蓝 #01abea 染色统一风格)----
  36. // 旧按文件语义的 8 个 key(equipment/heartbeat/alert/device/danger/history/check/list)
  37. // 已被 panel 系列取代并移除。同一源图在不同面板复用同一 key(PANEL_ICONS 里指向它),
  38. // 不为「同图不同面板」重复建 key——panel-infrastructure 等镜像文件已删,只留 11 个唯一图。
  39. 'icon.panel.situation': { file: 'panel/panel-situation.png', frame: { width: 256, height: 256 }, meaning: '态势总览/基础设施(设备机柜)' },
  40. 'icon.panel.monitoring': { file: 'panel/panel-monitoring.png', frame: { width: 256, height: 256 }, meaning: '运行监测/运行信息/监测概览(在线)' },
  41. 'icon.panel.alert': { file: 'panel/panel-alert.png', frame: { width: 256, height: 256 }, meaning: '预警设置(报警等级)' },
  42. 'icon.panel.archive': { file: 'panel/panel-archive.png', frame: { width: 256, height: 256 }, meaning: '基础档案/管理信息(设备开发)' },
  43. 'icon.panel.emergency': { file: 'panel/panel-emergency.png', frame: { width: 256, height: 256 }, meaning: '应急保障/当前预警(报警产生)' },
  44. 'icon.panel.distribution': { file: 'panel/panel-distribution.png', frame: { width: 256, height: 256 }, meaning: '监测分布(资源整合)' },
  45. 'icon.panel.ledger': { file: 'panel/panel-ledger.png', frame: { width: 256, height: 256 }, meaning: '监测点台账(设备)' },
  46. 'icon.panel.history': { file: 'panel/panel-history.png', frame: { width: 256, height: 256 }, meaning: '历史分析(时间)' },
  47. 'icon.panel.efficiency': { file: 'panel/panel-efficiency.png', frame: { width: 256, height: 256 }, meaning: '预警效率(协议实施)' },
  48. 'icon.panel.analysis': { file: 'panel/panel-analysis.png', frame: { width: 256, height: 256 }, meaning: '预警分析(规划设计)' },
  49. // ---- 状态标识(地图图例行,各状态用语义色染色:--lv1/lv2/lv3/lv4/neutral)----
  50. 'icon.status.alarm': { file: 'status/status-alarm.png', frame: { width: 256, height: 256 }, meaning: '报警(铃铛警示 @#ff3b52)' },
  51. 'icon.status.warning': { file: 'status/status-warning.png', frame: { width: 256, height: 256 }, meaning: '预警(警灯等级 @#ffb020)' },
  52. 'icon.status.workOrder': { file: 'status/status-workOrder.png', frame: { width: 256, height: 256 }, meaning: '工单处置(解除对勾 @#01abea)' },
  53. 'icon.status.normal': { file: 'status/status-normal.png', frame: { width: 256, height: 256 }, meaning: '正常(在线信号 @#27d17a)' },
  54. 'icon.status.offline': { file: 'status/status-offline.png', frame: { width: 256, height: 256 }, meaning: '离线(断线信号 @#5680b0)' },
  55. 'icon.status.empty': { file: 'status/status-empty.png', frame: { width: 256, height: 256 }, meaning: '无设备(设备机柜 @中性色)' },
  56. 'icon.status.unknown': { file: 'status/status-unknown.png', frame: { width: 256, height: 256 }, meaning: '状态未发布(问号铃 @中性色)' },
  57. 'icon.status.unassociated': { file: 'status/status-unassociated.png', frame: { width: 256, height: 256 }, meaning: '无关联设备(中性色)' },
  58. // ---- 位置符号(批次 3:双层水滴徽标,规格 10)----
  59. 'marker.master.solid': { file: 'T_MarkPoint_WhiteFull.png', frame: { width: 512, height: 512 }, meaning: '位置符号母版·实心(纯白剪影,监测点与关联设备)', core: true },
  60. 'marker.master.hollow': { file: 'T_MarkPoint_White.png', frame: { width: 512, height: 512 }, meaning: '位置符号母版·镂空(纯白剪影,EMPTY 点位与未关联设备)', core: true },
  61. 'marker.inner.gas': { file: 'markers/marker-gas.png', frame: { width: 256, height: 256 }, meaning: '内层行业图标·燃气' },
  62. 'marker.inner.water': { file: 'markers/marker-water.png', frame: { width: 256, height: 256 }, meaning: '内层行业图标·供水' },
  63. 'marker.inner.drainage': { file: 'markers/marker-drainage.png', frame: { width: 256, height: 256 }, meaning: '内层行业图标·排水' },
  64. 'marker.inner.manhole': { file: 'markers/marker-manhole.png', frame: { width: 256, height: 256 }, meaning: '内层行业图标·窨井' },
  65. }
  66. const RELEASE = '2026.10-lanhu-final-icons'
  67. const entries = []
  68. for (const [key, cfg] of Object.entries(ICON_MAP)) {
  69. const abs = join(publicDir, 'icons', cfg.file)
  70. if (!existsSync(abs)) throw new Error(`切图缺失: ${abs}`)
  71. const buf = readFileSync(abs)
  72. entries.push({
  73. key,
  74. // path 相对 public/cockpit(解析层拼 `${BASE_URL}cockpit/${path}`),保留中文原名。
  75. path: `assets/icons/${cfg.file}`,
  76. source: { board: null, delivery: 'user-imported-lanhu-slices', layer: cfg.file, meaning: cfg.meaning },
  77. format: 'png',
  78. dimensions: cfg.frame,
  79. pixelRatio: 1,
  80. transparent: true,
  81. renderMode: 'image',
  82. version: RELEASE,
  83. hash: `sha256:${createHash('sha256').update(buf).digest('hex')}`,
  84. status: 'formal',
  85. appliesTo: { areas: [key], states: ['default'], meaning: cfg.meaning },
  86. fallbackKey: null,
  87. core: key === 'icon.brand',
  88. })
  89. console.log(` ${key} | ${cfg.file}`)
  90. }
  91. // blank:设计稿未覆盖,保留原占位几何(temporary)
  92. const blankBuf = readFileSync(join(publicDir, 'icons/blank.svg'))
  93. entries.push({
  94. key: 'icon.blank',
  95. path: 'assets/icons/blank.svg',
  96. source: { board: null, delivery: 'placeholder-migrated-from-inline', layer: 'blank.svg', meaning: '空面板占位图示(设计稿未覆盖)' },
  97. format: 'svg',
  98. dimensions: { width: 48, height: 32 },
  99. pixelRatio: 1,
  100. transparent: true,
  101. renderMode: 'mask',
  102. version: RELEASE,
  103. hash: `sha256:${createHash('sha256').update(blankBuf).digest('hex')}`,
  104. status: 'temporary',
  105. appliesTo: { areas: ['panel-placeholder'], states: ['default'], meaning: '空面板占位图示(设计稿未覆盖)' },
  106. fallbackKey: null,
  107. core: false,
  108. })
  109. // 保留已存在的 fonts 块(批次 4 / 规格 11):本脚本只负责图标 entries,但整文件重写会把
  110. // 其他批次登记的顶层块一起抹掉。2026-10-13 实测踩过一次——重跑本脚本后字体授权门禁
  111. // 拿不到 fonts 块,浏览器静默回退系统字体(页面"看起来没异常"但字体已失效)。
  112. // 因此重写前先读回旧 manifest,把未知顶层键原样保留,只替换 entries/release/source。
  113. // 票 18(批次 5)加固:**entries 内未知 key 同样保留**——其他批次/会话(如票 37 的
  114. // icon.kpi.*)可能已把条目写进 manifest 而 ICON_MAP 未同步;直接重建会抹掉它们,
  115. // 与 fonts 块被抹是同一类事故。闸门:遇未知条目即中止,先同步 ICON_MAP 再重跑。
  116. const manifestPath = join(publicDir, 'manifest.json')
  117. let previous = {}
  118. try {
  119. previous = JSON.parse(readFileSync(manifestPath, 'utf8'))
  120. } catch {
  121. previous = {}
  122. }
  123. const PRESERVED_TOP_LEVEL = Object.fromEntries(
  124. Object.entries(previous).filter(([key]) => !['schemaVersion', 'release', 'source', 'entries'].includes(key))
  125. )
  126. const knownKeys = new Set(Object.keys(ICON_MAP).concat(['icon.blank']))
  127. const foreignEntries = (previous.entries || []).filter((e) => e && e.key && !knownKeys.has(e.key))
  128. if (foreignEntries.length) {
  129. console.error(
  130. `中止:manifest 中存在本脚本未登记的条目(${foreignEntries.map((e) => e.key).join(', ')})。` +
  131. '整文件重建会抹掉它们(fonts 块同类事故)。请先把对应映射补进 ICON_MAP(含 frame/meaning),再重跑本脚本。'
  132. )
  133. process.exit(1)
  134. }
  135. writeFileSync(manifestPath, JSON.stringify({
  136. schemaVersion: 1,
  137. release: RELEASE,
  138. source: { project: '城市生命线驾驶舱', importedBy: '维护者(蓝湖切图全量导入并按语义命名)' },
  139. ...PRESERVED_TOP_LEVEL,
  140. entries,
  141. }, null, 2) + '\n')
  142. const preservedKeys = Object.keys(PRESERVED_TOP_LEVEL)
  143. console.log(`\nmanifest 写入完成:${entries.length} 条,release ${RELEASE}` + (preservedKeys.length ? `;保留顶层块:${preservedKeys.join(', ')}` : ''))