walkthrough-17-batch4.md 15 KB

批次 4 验收说明:字体接入

关联:执行-批次4:字体接入 引用规格:规格 11 字体授权与缺失资源降级、规格 09 运行时资产 manifest 本文档只讲怎么验收;改动理由、白名单扩展裁定与取证过程见票 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 分钟)

# 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=<fontTools 安装目录>
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 字族

读数断言(浏览器控制台执行,用于留证)

// 门禁读数:应为 "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 / 重启:

(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 与 <img> 的 src 都是相对 URL,浏览器按文档基址解析。微前端下文档是宿主的(localhost:5173),于是 ./cockpit/assets/fonts/... 被解析成 localhost:5173/cockpit/... → 404 到宿主域名。import.meta.env.BASE_URL 在构建期被静态替换为 ./,它不知道运行时被嵌进了哪个宿主。

修复(domain/runtimeAssets.js 的 defaultBase()):qiankun 注入路径优先——

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. 不通过怎么回滚

# 回滚全部本批改动(含字体文件、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 的既定行为。