# 批次 4 验收说明:字体接入 关联:[执行-批次4:字体接入](issues/17-exec-batch4-fonts.md) 引用规格:[规格 11 字体授权与缺失资源降级](issues/11-font-and-fallback-spec.md)、[规格 09 运行时资产 manifest](issues/09-runtime-asset-manifest-spec.md) 本文档只讲**怎么验收**;改动理由、白名单扩展裁定与取证过程见票 17。 --- ## 0. 一句话:改了什么 驾驶舱接入 **OFL 开源子集字体**,并把「字体授权门禁」做成结构上无法绕过的: | 语义槽位(票 03 锁定,未改名) | 改动前取值(全是系统字体) | 改动后取值 | | --- | --- | --- | | `--font`(正文) | `"Microsoft YaHei UI", ...` | **`"Noto Sans SC Subset",`** `"Microsoft YaHei UI", ...`(Web 字体插在系统回退链**之前**,回退链原样保留) | | `--num`(数字/等宽) | `"Bahnschrift SemiBold", ...` | **`"Rajdhani Subset", "Share Tech Mono Subset",`** `"Bahnschrift SemiBold", ...` | 关键点: - **只嵌「有消费点」的字族**(正文 + 数字/等宽),不嵌没人用的字体,避免死资源; - 白名单里其余三族(思源黑体 / 思源宋体 / Crimson Text)**许可证据已归档**、登记为「白名单内、本批未嵌入」; - **子集化**:中文单字重 10–18 MB → 子集后 207–212 KB,五个文件合计 **446 KB**; - **系统字体一个都没打包**:回退链只在 CSS 里写名字,产物里没有系统字体文件; - **授权门禁**:没有许可证据的字体**根本不会生成 `@font-face`**,浏览器解析不到该字族 → 自动落到系统回退链。 --- ## 1. 验收方式一:静态自查(不跑任何服务,1 分钟) ```powershell # 1.1 字体自查:证据齐全性 + 门禁分支唯一 + 文件可达与 hash + 回退链 + 对抗式反证 node .scratch/lanhu-cockpit-asset-replacement/tools/check-fonts.mjs # 1.2 图标 manifest 未被本批破坏(批次 1 的既有断言面) node .scratch/lanhu-cockpit-asset-replacement/research/check-manifest.mjs # 1.3 数字度量核对(KPI 不跳格) set FONTTOOLS_LIBS= python .scratch/lanhu-cockpit-asset-replacement/tools/check-num-metrics.py # 1.4 产物里没有系统字体 Get-ChildItem public/cockpit/assets/fonts -Recurse -File | Select-Object Name, Length ``` **判定**: - 1.1 打印 `字体自查通过(… + 对抗式反证)`,**退出码 0**;门禁逐条 5 个 `=ok`; - 1.2 打印 `manifest 校验通过`,仍是 **18 条** 图标(本批未动图标); - 1.3 打印 `PASS 未引入新的数字抖动`,并列出 16 项逐字差供人工判定; - 1.4 只有 **5 个 `.woff2` + 6 个 `licenses/*.txt`**,没有 `msyh*` / `PingFang` / `bahnschrift` 之类的系统字体文件。 --- ## 2. 验收方式二:看证据文件(30 秒) | 看什么 | 位置 | 应看到 | | --- | --- | --- | | 授权证据 | `public/cockpit/assets/fonts/licenses/*.txt` | 六份 OFL 1.1 文本,首行是各自 Copyright(Adobe / Indian Type Foundry / Carrois …) | | 登记事实 | `public/cockpit/assets/manifest.json` 的 `fonts` 块 | `licenses` 6 条 + `entries` 5 条;每条含 `licenseId`、`hash`、`version`、`holder` | | 构建脚本 | `.scratch/…/tools/build-fonts.py` | 版本/权利人**取自字体二进制与许可证文本**,不是手抄 | > **为什么要专门说这一点**:本批第一版把版本与权利人**手写**在脚本里,随后被取证推翻——Rajdhani 版本手写成 `2.000`(实为 **1.201**)、Share Tech Mono 与 Crimson Text 的权利人张冠李戴。已改为全部从取证源派生:版本读字体 `name` 表(ID 5),权利人读归档许可证文本的 Copyright 行(含续行拼接,Adobe 的版权行会折行)。 --- ## 3. 验收方式三:跑起来看真实效果 **真实运行地址:`http://127.0.0.1:8000/#/cockpit`** ### ⚠ 三个必须知道的坑 1. **入口遮罩**:打开后先点画面中央「**点击进入系统**」。 2. **dev server 会缓存 manifest 的转译结果**:本批第一次走查时页面**一个字体都没请求**——原因是 Vite 把 `public/cockpit/assets/manifest.json` 的旧转译结果缓存在内存里,模块里根本没有 `fonts` 块,于是门禁如实地报了「无字体」。**这是本次新发现的坑**,处置见 §5。 3. **字体请求只有在字族被真正使用后才会发出**:所以「Network 里出现 5 条 `.woff2`」本身就是「字体已生效」的证据;反之,一条都没有就说明 `@font-face` 没注入。 ### 走查清单(真实页面) **① 字体真的加载了** - [ ] Network 过滤 `woff2`,看到 **5 条**:`noto-sans-sc-400` / `noto-sans-sc-700` / `rajdhani-400` / `rajdhani-700` / `share-tech-mono-400` - [ ] 每条 `Content-Type` 是 `font/woff2`(**不是** `text/html`——本仓 dev 会把不存在的路径兜底成 200 + HTML) - [ ] 宿主读数:`.cockpit-shell` 上 `data-font-gate="ok"`、`data-font-faces="5"` **② 动态文本无豆腐块(本批最关心的一项)** - [ ] 左右栏面板里的中文标签、设备名、点位名、事件文本全部可读,**没有方块/空白** - [ ] 面板里的**动态**文本(后端来的设备名/点位名)与静态标签混排时,无明显断字或错位 - 说明:子集只覆盖驾驶舱**静态用字**(1435 字),后端动态汉字**本就不在子集内**,按设计落到系统字体(Microsoft YaHei 等)——这是规格 11 Q14 明确要求的行为,不是缺陷 **③ 大屏 KPI 数字不跳格** - [ ] 左右栏各面板的数字读数(设施底数 / 在线率 / 预警 29 / 报警 66 / 处置率 0.0% 等)排版不溢出、不换行 - [ ] 数字刷新前后,整串数字宽度没有肉眼可见的跳动 - ⚠ 本项**需人工判定**:实测新旧字体逐字差见 §4,其中 `'7'` 差 −0.12 em、`'-'` 差 −0.17 em(0.05 em 以上共 16 项)。代码只保证**数字内部等宽性不劣于原字体**(Rajdhani 0.203 em vs Bahnschrift 0.236 em,Share Tech Mono 为 0.000 em 真等宽),**不假装能判定观感** **④ 结构未受影响(重点回归)** - [ ] 四个一级模式切换正常,面板内容随之变化 - [ ] 16 个 `panelId` 与既有断言面不变 - [ ] 行业/范围筛选、地图标记、点选与定位行为与改动前一致 - [ ] 控制台无 Vue 报错、无字体 404 - [ ] `--font` / `--num` **槽位名未改**,只是取值多了前置的 Web 字族 ### 读数断言(浏览器控制台执行,用于留证) ```js // 门禁读数:应为 "ok" / "5" const shell = document.querySelector('.cockpit-shell') console.log(shell.dataset.fontGate, shell.dataset.fontFaces) // 实际生效的字体栈(应看到 Subset 字族排在系统字体之前) const cs = getComputedStyle(shell) console.log(cs.getPropertyValue('--font')) console.log(cs.getPropertyValue('--num')) // 已加载的字体面 Array.from(document.fonts).map(f => `${f.family} ${f.weight} ${f.status}`) ``` **期望**:`ok` / `5`;`--font` 以 `"Noto Sans SC Subset"` 开头、`--num` 以 `"Rajdhani Subset"` 开头,且**后面仍跟着原来的系统回退链**;`document.fonts` 里五个面 `status` 均为 `loaded`。 截图留存:总体概览一张(本目录 `cockpit-overview-desktop-after-fonts.png`)、任一含 KPI 数字的面板特写一张。 --- ## 4. 实测数据(供走查参考) **体积**(子集 vs 原始): | 字体 | 原始 | 子集后 | | --- | --- | --- | | Noto Sans SC Regular | 16.95 MB | 207.2 KB | | Noto Sans SC Bold | 16.95 MB | 212.2 KB | | Rajdhani Regular | 0.36 MB | 8.9 KB | | Rajdhani Bold | 0.38 MB | 9.3 KB | | Share Tech Mono | 0.04 MB | 8.2 KB | | **合计** | **≈34.7 MB** | **446 KB** | **数字度量**(对照 `--num` 原首位 Bahnschrift): | 字体 | 数字 0–9 内部宽差 | 结论 | | --- | --- | --- | | Bahnschrift(旧) | 0.236 em | 基准 | | Rajdhani 400(新首位) | 0.203 em | 不劣于基准 | | Rajdhani 700(新) | 0.208 em | 不劣于基准 | | Share Tech Mono(新次位) | 0.000 em | 真等宽 | **逐字差 > 0.05 em 的字符(16 项,交人工判定)**:Rajdhani 的 `'5' '7' '-' '(' ')'`(−0.06 ~ −0.17 em)、`'%'`(+0.05);Share Tech Mono 的 `'1' '.' ':' '/' '(' ')' '%' '-'`(−0.15 ~ +0.31 em)。完整表由 `check-num-metrics.py` 打印。 --- ## 5. 本次新发现的坑:字体静默回退到系统字体(三类,均已实测) > **2026-10-13 维护者人工验收时抓到真身。** 维护者反馈「没发现异常,但不确定字体是否被替换回初版」——这个怀疑**完全正确**:字体确实回退了,而且是静默的。排查后确认有**三个互相独立**的坑,都会造成同一症状。 ### 坑 A:dev server 缓存旧 manifest 转译(磁盘改了,转译没跟上) **现象**:manifest.json 已含 fonts 块,但 dev server 返回的转译模块里没有。 **根因**:`public/cockpit/assets/manifest.json` 被 `runtimeAssets.js` 以 `import` 引用,Vite 会把它转译成 ES 模块并**缓存在内存**。文件被外部脚本(`build-manifest.mjs`)改写后,该转译结果可能未失效,页面拿到**没有 `fonts` 块的旧模块**。 **处置**:触碰文件让 watcher 失效缓存,或在 dev server 控制台按 `r` / 重启: ```powershell (Get-Item public/cockpit/assets/manifest.json).LastWriteTime = Get-Date ``` ### 坑 B:浏览器 HMR 模块图陈旧(转译是新的,浏览器加载的是旧模块) **现象**:dev server 转译已含 fonts 块(curl `?import` 可见),但**页面里字体请求为 0**——仍然回退系统字体。 **根因**(抓包证据):HMR 只热更新了边界组件 `CockpitShell.vue?t=...`,**没有重新请求它传递依赖的 `manifest.json` 模块**。于是浏览器模块图里跑的仍是**旧 manifest(无 fonts 块)** → `resolveFont()` 判定不可用 → `fontFaces.js` 不生成 `@font-face` → 字体请求 0。 **这一类比坑 A 更隐蔽**:磁盘对、转译对、代码对,只有浏览器里的模块图是旧的。 **处置**:**硬刷新页面**(Ctrl+Shift+R,或浏览器插件的 hard reload),让浏览器重新加载整个模块图。硬刷新后实测字体请求恢复 5/5。 ### 坑 C:qiankun 宿主下相对 URL 按宿主文档解析(防御性修复,保留;但经维护者纠正,**不是**独立页「没生效」的原因) > **归因更正(2026-10-13 第三轮)**:我一度判断维护者在看 5173 宿主页——**错了**,维护者一直开的是独立页 `http://127.0.0.1:8000/#/cockpit`。本节修复(`defaultBase()` 的 qiankun 注入分支)对**将来把驾驶舱嵌入宿主**是真实必要的,予以保留;但独立页上「看起来没生效」请按坑 B(旧标签页硬刷新)+ 下方「一句话判定」处理。 **场景**:驾驶舱被 qiankun 宿主(如 `localhost:5173` 地下管网管控平台)嵌入时。 **根因**:`@font-face` 的 `src` 与 `` 的 `src` 都是相对 URL,浏览器按**文档基址**解析。微前端下文档是宿主的(`localhost:5173`),于是 `./cockpit/assets/fonts/...` 被解析成 `localhost:5173/cockpit/...` → **404 到宿主域名**。`import.meta.env.BASE_URL` 在构建期被静态替换为 `./`,它不知道运行时被嵌进了哪个宿主。 **修复**(`domain/runtimeAssets.js` 的 `defaultBase()`):qiankun 注入路径优先—— ```js if (scope.__POWERED_BY_QIANKUN__ && scope.__INJECTED_PUBLIC_PATH_BY_QIANKUN__) { return scope.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ } ``` 这与本仓 `services/cesiumAssets.js` 的 `pickBase()` **同一口径**(Cesium 资源在宿主内已验证可用),不是新发明。**同一修复顺带覆盖了图标**(`resolveAsset` 共用 `defaultBase`)——宿主内批次 1 的图标若也有加载失败,根因相同。 **验证方式**:宿主页面**整页刷新**(宿主文档重载 → 微应用重新挂载 → 注入路径生效),Network 里字体请求应指向 `127.0.0.1:8000/cockpit/assets/fonts/*.woff2`(微应用源),不再是 `localhost:5173/...`(宿主源)。 ### 为什么三者都是静默的 门禁的设计立场是「无证据字体在解析阶段不可用」——这个立场在**数据源陈旧**时同样如实生效:manifest 没有 fonts 块,门禁就判定「无字体」,于是不注入 `@font-face`,页面退回系统字体。**门禁是对的,是喂给它的数据陈旧了。** 这也是为什么 `data-font-gate` 读数设计成 `fonts-unavailable` 而不是别的——它正是为区分「真没字体」与「字体没生效」而存在的。 ### 自动化验收必须补的一项 **「字体请求为 0」必须判 FAIL**,不能当作「页面没用到字体」放过——因为 `--font` / `--num` 里 Web 字族排在首位,页面文本一定用到它们,请求为 0 就是回退了。走查时先看 Network 过滤 `woff2` 是否有 5 条,再看别的。 --- ## 6. 判定标准 | 结果 | 结论 | | --- | --- | | 静态全过 + 走查 ①②④ 全过 | 批次 4 通过,可关闭票 17 | | ③ 数字观感不可接受 | 记为视觉偏差,登记票 06 缺口清单;可将 `--num` 回退为不含 Rajdhani | | ① 一条 `.woff2` 都没有 | **缺陷**:检查 `data-font-gate`;若为 `fonts-unavailable` 先按 §5 失效缓存 | | ④ 出现模式/面板/地图行为变化 | **阻断**:改动越界,需回退并复查 | --- ## 7. 不通过怎么回滚 ```powershell # 回滚全部本批改动(含字体文件、manifest 的 fonts 块、tokens 取值、门禁代码) git checkout -- src/modules/cockpit public/cockpit/assets/manifest.json git clean -f public/cockpit/assets/fonts ``` 回滚后:`--font` / `--num` 恢复纯系统字体栈,页面观感与批次 3 之后一致;图标与位置符号不受影响(资产层改动隔离在 `runtimeAssets.js` / `fontFaces.js` / `tokens.css` 三处)。 --- ## 8. 本次未做的事(避免误判) - **未嵌入白名单内无消费点的三族**(思源黑体 / 思源宋体 / Crimson Text):许可证据已归档并登记,但没有语义槽位消费它们,嵌入即为死资源。它们仍在白名单内,随时可启用。 - **未改**:`--font` / `--num` 的槽位名与语义(票 03 归口)、字号/间距 token(批次 2)、四个一级模式、16 个 `panelId`、状态词表、地图与定位契约。 - **未引入任何商用字体**;不存在商用授权索取工作项(规格 11 Q10)。 - **代码不判定授权合法性**:门禁只校验**许可证据是否齐全**(标识/名称/版本/权利人/覆盖范围/证据文件 hash),合法性属法律事实,由证据文件与责任人负责。 - **子集只覆盖静态用字**:后端动态汉字按设计走系统回退,属规格 11 Q14 的既定行为。