
不换框架的界面改版:拒绝 Next.js、设计令牌、和被实测推翻的 1.6:1
需求方要求“优化界面,加上 Next.js”。本文记录我为什么拒绝它、为什么只引入一个依赖、设计令牌如何落地,以及一次口算被 WCAG 实测推翻(1.6:1 实为 1.16:1)后我对问题的重判。
IStarry
10 min read
不换框架的界面改版
0. 原始诉求:一句话里的两个问题
2026-09-12,需求方提出:「优化界面,加上 Next.js,以 https://www.deepseek.com/harness/en/ 为范例」。
这句话把两件重要性差很远的事捆在了一起。我做的第一个判断就是把它拆开——要的是那个观感,还是那个框架(docs/ui-redesign-plan.md:13):
| 诉求 | 拆出来的真实问题 | 我的处理 |
|---|---|---|
| 「加上 Next.js」 | 需求方以为需要换框架才能得到那个观感 | 不引入——与项目已锁定的约束硬冲突 |
| 「界面以该页为范例」 | 真正需要的是设计语言(配色/字阶/留白/边框/动效) | 落地为设计令牌 + 逐页改版 |
由此定下一条我在这类需求上一直沿用的口径:设计语言可以批,技术栈变更必须单独立项、单独给证据。理由很直接——换框架带来的那几条代价(浏览器基线、Node 版本、React 大版本)全都与观感无关,见下节的逐条对账。
先把结论放在这里(docs/ui-redesign-plan.md:55,原文):
视觉观感来自 CSS 与设计令牌,不来自框架。 换 Next.js 不会让任何一个像素变好看,却会引入构建链替换、全站路由重写、React/antd 大版本升级与 Win7 兼容破坏。
这句话今天看起来像是常识,但它是被证据逼出来的,不是被偏好挑出来的。下面把证据链摊开。拒绝一个技术选型时,我要能把理由写成可核验的证据,而不是"我觉得不合适"。
1. 拒绝 Next.js 的完整证据链
1.1 四条冲突,逐条对账
方案里给了一张逐条对照表(docs/ui-redesign-plan.md:43-49),我把它逐行讲清楚。评估基准是 Next.js 16.3.5(2026-08-25 文档):
| # | 项目现状(已锁定) | Next.js 16 的要求 | 结论 |
|---|---|---|---|
| 1 | 交付需支持 Win7 SP1,前端按 Chrome 109 / Firefox ESR 115 能力构建(AGENTS.md 决策 11) | 官方浏览器基线 Chrome 111+ / Edge 111+ / Firefox 111+ / Safari 16.4+ | ❌ 硬冲突:Chrome 109 < 111,目标浏览器不在支持基线内 |
| 2 | 单 exe 交付,目标机不装 Node | 构建需 Node.js 20.9+(Node 18 不再支持) | ❌ Node 20 无法装在 Win7 上,Win7 机器永久失去构建能力 |
| 3 | React 18.2.0 + antd 5.8.6(web/package.json) | App Router 基于 React 19.2 canary | ⚠️ 被迫升级 React 19 + antd 大版本,波及全部 12 个页面组件与 ECharts 集成 |
| 4 | base: './' + target: 'chrome109';go:embed 内嵌 internal/webui/dist;api.go 的 NoRoute 做 SPA 回退 | 静态导出产出 out/,资源默认绝对路径 /_next/...;每路由一个 HTML + RSC 载荷 | ⚠️ 需重配 basePath/assetPrefix/trailingSlash、改 embed.go 布局、重写 SPA 回退路由表 |
| 5 | — | 静态导出下不可用:Server Actions、Cookies/Headers、Rewrites/Redirects、ISR、动态路由、默认 next/image 优化 | ❌ 只剩下「文件路由 + 另一套构建工具」,纯成本 |
第 1 条是唯一一条真正一票否决的。它是这个项目 Win7 约束的直接传导结果:Win7 SP1 能装的最新 Chrome 是 109(AGENTS.md 决策 11),而 Next.js 16 的工作下限是 111。这不是"可能有问题",这是目标浏览器根本不在官方支持基线内——官方文档不会为它做任何兼容承诺。
第 2 条同样致命但方向不同:它不是运行时冲突,而是构建能力的丧失。这个项目的交付形态是一个 exe(Go go:embed 内嵌前端产物),构建是在开发机上做的;但一旦框架把构建门槛提到 Node 20.9+,那么"在有 Win7 的机器上重建这个项目"这条路就被永久关闭了。而"能不能在目标环境里重建"这件事,在离线、单机、无网络依赖的交付语境下是很重的约束。
第 3 条是范围爆炸:App Router 基于 React 19.2 canary。项目锁的 React 18.2 + antd 5.8.6 是能跑的组合;升到 React 19 意味着 antd 大版本、ECharts 集成、全部 12 个页面组件一起动。为了一个"观感",代价是整条前端依赖链。
第 4、5 条是**"换了也没得到你想要的":既然交付形态是单 exe + go:embed + NoRoute SPA 回退,那 Next.js 最有价值的那一半能力(服务端渲染、Server Actions、ISR、动态路由、图片优化)在静态导出下全部不可用。剩下的是"文件路由"和"另一套构建工具"——也就是说,换了框架,得到的恰好是这个项目已经有的东西**(路由),外加一堆需要重配的路径与布局。
1.2 那条"降级"的退路,为什么不走
诚实的做法是把退路也评估一遍:降级到 Next 14 可以避开冲突 1 和 2(它的浏览器基线与 Node 要求都更低)。但方案给出的结论是收益为零(docs/ui-redesign-plan.md:57):
降级到 Next 14 可避开冲突 1、2,但要把前端框架锁死在两代以前的旧版本,仍承担冲突 4 的改造,收益为零 —— 违反
engineering-persona§20/§21(技术选型/依赖原则)与equipment-management§32.4(不引入未要求的基础设施),故不采纳。
这句话的推理结构值得单独记一笔:"用降级换兼容"只有在"降级后你仍然得到了你要的东西"时才成立。 而这里冲突 4、5 一个都没解决——go:embed 布局、SPA 回退、静态导出下用不到的能力,全都还在。于是"降级"的唯一效果是:把项目锁在框架的两代之前,去交换一个本来就不存在的收益。
这也顺带回答了一个更常见的问法:"如果不用 Next,那路由怎么办?"下面就是答案。
2. 最终只引入了一个依赖
改版全程只新增了一个前端依赖:react-router-dom,精确锁定 6.30.6(AGENTS.md:43、决策 20 决策 ③)。其余一律不加——不引入 Tailwind、不引入 CSS-in-JS 库、不引入新的 UI 组件库。
理由很具体。改版前的现状是(docs/ui-redesign-plan.md:70):无路由——App.tsx 用一个 useState 切菜单,刷新即回 Dashboard。这带来两个真实的使用问题:页面地址不可分享、F5 之后不记得你在哪一页。
这一问题只需要路由,不需要框架。而 react-router-dom 恰好是"只给你路由"的那个粒度。至于为什么锁 v6 而不是最新的 v7,方案里有一条明确的取舍记录(docs/ui-redesign-plan.md:152,原文):
依赖版本选
react-router-dom@6.30.6(精确锁定)而非 v7:v7 已转向 framework/data-router 模式,对本项目 10 页 SPA 属多余复杂度;v6 行为面更小、与 React 18 组合最稳。
落地时新增的 web/src/routes.tsx 是路由与菜单的唯一来源(key / path / label / description / icon),一处定义驱动三处:侧栏菜单、页面头标题与说明、document.title(web/src/routes.tsx:1-6)。所以把一个页面接进导航,只需要在两个地方各加一条,而不是"菜单一份、标题一份、文档一份"(web/src/routes.tsx:34-112 共 10 条路由,web/src/routes.tsx:114-115 定义默认落地页):
/** 路由与菜单的**唯一来源**(v1.4 Phase 2)。一处定义,三处复用:侧栏菜单、页面头、document.title。 */
export const ROUTES: RouteMeta[] = [
{ key: 'dashboard', path: '/dashboard', label: 'Dashboard',
description: '设备总数、状态分布、班组占用、外借与逾期提醒的总览', icon: <DashboardOutlined /> },
// …共 10 条
];
export const DEFAULT_PATH = '/dashboard'; // 含全部未知路径的回退目标一个容易被忽略的连带决定:深链接刷新是后端行为,所以它必须由后端测试守住。 新增的 internal/api/spa_test.go(3 用例)断言三件事:10 条前端路由 + / + 未登记路径全部 200 且回退 index.html;/api/... 未知路径仍返回 JSON 404(不被 SPA 回退吞成 HTML);从 index.html 动态提取 ./assets/* 引用并逐个请求均 200 且非空(docs/ui-redesign-plan.md:154-160)。
最后一条尤其重要:它不硬编码构建哈希。因为实测确认前端产物不是逐字节可复现的——同一份源码连续两次 npm run build 得到的 JS 哈希不同(index-4bdcd131.js vs index-4bdc1984.js,差 45 字节),所以任何"把产物名当构建身份"的断言都会随机失败(docs/ui-review-plan.md:234-242)。
3. 设计令牌:色值只能有一个来源
拒绝框架之后,观感这件事得有人来干。答案是两份文件加一条强制规则。
3.1 令牌不是"我调出来的",是采样来的
第一件反直觉的事:令牌值不该靠手感调,应该去采(docs/ui-design-tokens.md:8,原文):
不猜测原则:本文所有色值、字阶、字族均实测取得,非目测或记忆。采样方法见第 1 节,可原样复现。
做法是:从本机已安装的 DSH 设计系统包里用正则把静态色板与语义别名抽出来(docs/ui-design-tokens.md:16-27 给了完整 PowerShell 命令);范例站只用来取版式与节奏(大标题 + 一句说明、卡片网格、发丝线分区、大留白),色值一律来自设计系统。判定近似的深浅两套值用的也是事实而非感觉:--dsw-alias-bg-base 的第二个值是 neutral-bluish-950(#151517),那是深色调。
这一步的价值在于可复现:任何人跑一遍那段正则,都应得到同一张色板。对比另一种常见做法——"抄一组 hex 进代码"——后者的问题是半年后没人知道这个 #232324 为什么是 #232324,也没人能验证它是否达标。
3.2 落地的形态:两份别名 + 运行时写 CSS 变量
落地核心是 web/src/theme.ts 里的两个 ThemeAlias 对象(DARK / LIGHT)、applyCssVariables() 与 buildAntdTheme(),加一条强制规则(web/src/theme.ts:8-11):
* 1. 全站颜色只在本文件定义;页面/组件一律使用语义 CSS 变量(var(--...))或 antd 令牌,禁止硬编码 hex。
* 2. applyCssVariables(mode) 在启动时把令牌写入 document.documentElement;
* global.css 只引用 var(--...),不写字面色值(避免双份令牌漂移)。深色一份、浅色一份,逐一对应到语义变量(左边是深色的真值):
const DARK: ThemeAlias = {
bgBase: '#151517', // 页面底
bgLayer1: '#232324', // 卡片 / 顶栏 / 侧栏
bgLayer2: '#2c2c2e', // 浮层 / 下拉 / tooltip
bgSunken: '#1b1b1c', // 代码块 / 内嵌区
textPrimary: '#f9fafb', textSecondary: '#cfd3d6', textTertiary: '#adb2b8',
statusInStock: '#4ed17e', statusBorrowed: '#f7ad31', statusMaintenance: '#f25a5a',
// …
};applyCssVariables() 把当前模式的令牌一行不留地写进根元素,并设上 data-theme 供 CSS 做模式分支;antd 那一侧由 buildAntdTheme() 映射同一批值,包括算法切换(web/src/theme.ts:191-264):
root.setAttribute('data-theme', mode);
for (const key of Object.keys(CSS_VAR_NAMES) as (keyof ThemeAlias)[]) {
root.style.setProperty(CSS_VAR_NAMES[key], alias[key]);
}
// buildAntdTheme:
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: { colorPrimary: a.brand, colorBgBase: a.bgBase, colorBgContainer: a.bgLayer1,
colorText: a.textPrimary, borderRadiusLG: RADIUS.card },3.3 深色默认 + 一键切浅色 + localStorage 记忆
需求方在这个点上的口径很明确:默认深色 + 一键切换浅色(决策 20 决策 ②)。实现分三小块(web/src/theme.ts:18-20、web/src/themeMode.tsx:19-40、web/src/App.tsx:75-85):
export const DEFAULT_MODE: ThemeMode = 'dark';
export const MODE_STORAGE_KEY = 'equipment-theme-mode';读取记忆失败时不静默吞错——项目一贯的纪律:catch 里 console.warn 后回退默认模式;切换时把令牌与记忆一起落地:
useEffect(() => {
applyCssVariables(mode);
try { window.localStorage.setItem(MODE_STORAGE_KEY, mode); }
catch (e) { console.warn('[theme] 保存主题记忆失败(不影响当前会话)', e); }
}, [mode]);顶栏上就是一枚按钮,Tooltip 与标签按当前模式反向提示({mode === 'dark' ? '浅色' : '深色'})。
3.4 谁来守住"只有一个来源"
规则写得再好,没有闸门就会漂。改版期间同步长出来的 scripts/check-web-conventions.ps1 里就有一组断言:web/src 除 theme.ts 外不得出现硬编码 #hex(docs/ui-redesign-plan.md:243-252 列 5 组断言;scripts/check-web-conventions.ps1:37-43 是失败计数,:155-160 是退出码——全过 exit 0,否则 exit 1)。
我实测跑了这条检查的等价查询:web/src 下除 theme.ts 外,#hex 只命中 2 处,且都在 global.css 的注释里(解释浅色下侧栏折叠按钮为何改底色),生效色值确实全部收敛在 theme.ts。
同一条纪律也用在业务状态上:六状态的中文名、antd Tag 预设色、填充变量只在 web/src/status.ts 一份(:27-64 定义 6 条 STATUS_META),页面不得再写内联色值(:4-7)。这带来一个意外收获:收敛之后发现 ImportPage 原先用嵌套三元写的状态色把"维修"和"报废"都渲染成蓝色——统一到 statusTagColor() 后自动修正为 red / default(docs/ui-redesign-plan.md:233)。
4. 口算的 1.6:1,实测是 1.16:1
下面这段是我自己最看重的一段,因为它不是"我做对了什么",而是我以为自己对,然后被仪器推翻了。
4.1 我的口算
深色主题的常规做法是:页面底暗、卡片亮,靠明度差表达层级。按这个思路,我给深色定的是页面底 #151517、卡片 #232324(两者都取自设计系统的实测色板)。一眼看去 #232324 明显比 #151517 亮——我的口算是它们大约有 1.6:1 的对比度,够用。
4.2 实测算出来的是 1.16:1
v1.4.0 发布后,项目对真实渲染做了一轮设计评审:10 张截图(深/浅 × 5 页,精确 1366×768 视口,真实 2148 台数据)+ 像素/ASCII 版面度量,并且把 WCAG 对比度实算了一遍(docs/ui-review-plan.md:50-58):
| 组合 | WCAG 对比 | 结论 |
|---|---|---|
页面底 #151517 vs 卡片 #232324 | 1.16 : 1 | 深色"面/底"几乎无差 |
表头 #1B1B1C vs 卡片 #232324 | 1.10 : 1 | 表头靠明度也分不出 |
| 候选更深的底/卡(三组) | 1.26 – 1.33 : 1 | 深色+深色拉不出层级——数学上做不到 2.5:1 |
同一条结论也记进了治理文件(AGENTS.md 决策 21,:97):
关键实测结论(推翻两处口算):深色页面底
#151517与卡片#232324的真实 WCAG 对比仅 1.16:1(原口算 1.6:1),且任何「深色+深色」组合最高只到 1.33:1 → 深色主题无法用底色表达层级,有效手段是描边 + 留白 + 局部色条。
1.6 → 1.16:差了不是一点点,是把"够用"变成了"等于没有"。而第三行更关键——换色也救不了:我试过三组更深的底/卡组合,最好的一组也只有 1.33:1。这不是"我这两个值选得不够狠",而是深色底上的两个深色面,在数学上就不可能拉开 2.5:1。这个结论把"调色"这条路整体关掉了。
4.3 结论改写成设计约束
于是原方案里那条"C1 提高面/底对比"被改写了(docs/ui-review-plan.md:58,原文):
结论:深色主题不能用底色表达层级。有效手段是描边、留白、局部色条。因此原拟"C1 提高面/底对比"改为"分离侧栏与内容 + 保持并强化描边 + 加大留白"。
这条改写后来被需求方追认成了正式决策(docs/ui-review-plan.md:16):
① 保持默认深色,把深色做扎实:不改为默认浅色;深色的层级靠描边 + 留白 + 内容密度,不靠底色。
针对"1.16:1 等于没有"这个现实,实际落地的是三件不靠底色的手段:
- 描边 + 分离侧栏(C1):
App.tsx侧栏底色由--bg-layer-1改为--bg-base,右侧加 1px--border-l3描边。测出来的"侧栏 vs 卡片亮度差"从 0 → 14(阈值 ≥8)(docs/ui-review-plan.md:314、:170-174)。 - 状态色做签名(C2):台账/外借每行左侧 2px 状态色条,用
inset box-shadow实现,不改单元格宽度、不影响截断判定;覆盖检查是"行数 = 竖条数"而不是抽查(实测 20/20)(docs/ui-review-plan.md:315)。 - 留白优先:层级用间距(32/40)与分组线表达;设计原则的原文是"深色模式靠 1px 极淡描边(
--border-l2)而非阴影区分层次"(docs/ui-design-tokens.md:132)。
顺带说一句描边也不是万能的:描边能画出边界(这也是它管用的原因),但它表达不了"这一块比那一块更重要"。真正的优先级只能靠内容和留白——这就引出了下一节。
4.4 主要矛盾被重判
度量做完之后,两张图放在一起,结论就变了(docs/ui-review-plan.md:82-87):
像素说"75–85% 的屏幕是卡片",截图说"大片空白 / 零值 / 空卡"——同一个病:版面被等权重的容器占满,而容器里没内容。 因此:主要矛盾是内容密度与信息优先级,不是配色。
具体数字(docs/ui-review-plan.md:24-35,AGENTS.md:97):
| 指标 | Dashboard | 设备台账 | 外借 | 设置 |
|---|---|---|---|---|
卡片面 #232324 占比 | 74.8% | 74.6% | 75.6% | 75.4% |
页面底色 #151517 占比 | 17.9% | 11.3% | 11.3% | 13.6% |
| 内容密度(深色:亮字像素占比) | 3.6% | 4.1% | 3.2% | 2.2% |
首个 <table> 视口 y | 885(在 768 折叠线以下) | — | — | — |
卡片面占屏 74.8–75.6%,内容密度只有 2.2–4.1%。 再加一条要命的:Dashboard 上第一张表格的位置是 y=885,而视口高度是 768——用户打开这个页面,第一屏里一张能办事的表都没有。导入明细页更极端:页面底 53% / 卡片 29.8% / 内容 3.0%,约 2/3 是空白。
所以那一轮改版真正做的事,大头不是调色,是减法:删与页头重复的卡片标题、删设置页恒为 0 的「排序」列和内部「ID」列、列表时间去掉秒(19 字符 → 10 字符)、7 张 KPI 里 5 张零值降为一行灰字、空图表卡不再占位、Dashboard 顺序改成 Hero → 当前外借 → 最近流转 → KPI → 图表(docs/ui-review-plan.md:277-281)。结果 Dashboard 首个表格从 y=885 提到 y=400(门槛 ≤560),首屏能完整读完当前外借。
4.5 一个更深的教训:代理指标会骗你
这一节还有第二层,比 1.16 更值得记:不是所有指标都在度量你以为的东西。
评审工具里有个指标叫「亮内容占比」(深色下统计亮度 >120 的像素比例),看起来是"信息量"的代理。实际表现是(docs/ui-review-plan.md:290-296):Dashboard 3.6% → 2.0%,而同一次改动让首屏从"图表 + 大数字"变成"可读的行动表"——因为 ECharts 填充色亮度 >120,整块被算成"内容",图表移出首屏后这部分像素立即消失;更直接的证据是把并排两表改成各占整行(消除了 3–4 行换行,可读性变好)之后,指标由 2.4% 再降到 2.0%——同一段文字换行越少、落笔像素越少。
该指标此刻在奖励"拥挤换行"、惩罚"排版变好"。
同一轮里还有第二个被推翻的数字:我在 Phase B 报告里给出「C1 后页面底色 ≈ 29.0%」,这个数字算错了——我把侧栏里的菜单文字/图标像素(占屏幕 2.2%)也算进了"会被改成页面底色",但文字颜色不随侧栏底色变化。正确预期是 12.4 + 14.4 = 26.8%,实测 25.8%(docs/ui-review-plan.md:331-333)。
这两次更正的共同点:错误都披着精确的外衣。它们不是粗略估计,而是小数点后一位的"精确值"——正因为看起来精确,才更危险。项目最后把「亮内容占比」和另外两个像素占比从门槛降级为 INFO 参考值,门槛全部改由行为类断言承担(docs/ui-review-plan.md:158-160、scripts/ui-review.ps1:409-419)。
5. 把"界面有没有变好"变成可复测的数字
上一节之所以可能,是因为改版之前先做了一件事:把评审本身工具化。这是那轮改版里最重要的顺序决定(docs/ui-review-plan.md:109,原文):
为什么先做:没有可复测的度量,"变好看了"无法验收。
产出是两个脚本:scripts/ui-review.ps1(24,111 B,编排:构建 → 起被测实例 → 起 Chrome → 采集 → 像素度量 → 判定)与 scripts/ui-review-cdp.mjs(10,917 B,CDP 采集器:强制视口 → 注入主题 → 截图 → DOM 指标 → 写 metrics.json)。
5.1 它到底测什么
截图侧:深/浅两模式 × 5 页 = 10 张 PNG。视口精确 1366×768——这条是被教训换来的:headless 的 --window-size 含窗口装饰,实测得到的是 1344×670,于是改用 CDP Emulation.setDeviceMetricsOverride 强制视口;修正后"台账 16 处截断"这个结论消失了,那是窄视口造成的假象(docs/ui-review-plan.md:226)。同为 1366×768 还有个现实原因:那是 Win7 常见分辨率。
浅色模式不能靠点按钮(headless 里没人点),做法是在每个新文档执行前注入 localStorage(scripts/ui-review-cdp.mjs:196-198):
await cmd('Page.addScriptToEvaluateOnNewDocument', {
source: `try{localStorage.setItem('equipment-theme-mode','${mode}')}catch(e){}`,
});DOM 断言侧(scripts/ui-review-cdp.mjs:35-154 的 METRICS_EXPR):单元格是否被裁剪、button.ant-btn-primary 数量、表格内行内链接数、首个有表体的 <table> 的视口 y 与其完整可见行数、状态色竖条覆盖行数、暂无数据 元素数、零值 KPI 数、横向溢出。
这里有一个判定口径必须先自证的故事,比工具本身更值得记:初版用 td.scrollWidth > td.clientWidth 判截断,结果 antd 的固定列用 ::after 画阴影,这个绝对定位伪元素被计入 scrollWidth,导致 fixed:'left' 的显示编号列 10 行全部误报"截断"(截图显示文字完整)。改成用 Range 量内容自身需要的宽度,并且只有"被裁剪(overflow 非 visible 或 text-overflow: ellipsis)"才算信息丢失,只写溢出不裁剪的单独记为 spillCount(仅提示,不判 FAIL)(scripts/ui-review-cdp.mjs:41-79)。
然后它做了一件我认为是整件事的关键:证明新口径既不误报、也不漏报——把外借「名称」列宽临时改成 30px 重跑,工具报"被裁剪 外借 = 10(名称×10)",与修复前的原始缺陷签名完全一致(docs/ui-review-plan.md:262)。改判定口径这件事,本身也必须被检验,否则你只是把闸门关掉了。
5.2 它输出什么、退出码是什么
期望色值从 web/src/theme.ts 解析,不写死——所以后面改令牌,工具不需要跟着改(scripts/ui-review.ps1:216-238):Token-In 从 DARK / LIGHT 两个块里取出 bgBase / bgLayer1 与四个文字令牌。
判定部分把检查项分成三类:Add-Check(判定项,PASS/FAIL)、Add-Info(参考值,INFO),以及必须非空的数值断言(scripts/ui-review.ps1:350-376):
# 为什么需要它们:PowerShell 里 `$null -le 1` 为 **True**($null 按 0 处理),
# 于是当 CDP 采集不完整(指标为 $null)时,「无横向溢出」「主按钮 ≤1」会被**误判 PASS**。
function Test-MetricLe($value, [double]$limit) {
if ($null -eq $value) { return $false }
if ($value -isnot [ValueType]) { return $false }
return ([double]$value -le $limit)
}这是典型的"工具自己会骗自己":$null -le 1 在 PowerShell 里是 True,所以采集失败时闸门会"绿着放行"。修法不是加注释,是把"缺指标"定义成失败——宁可报"指标缺失",也绝不放过。
退出码是这套东西能被验收的前提(scripts/ui-review.ps1:430-439):-Mode baseline 恒 exit 0(只报数,用来冻结基线);-Mode assert 有任何一项 FAIL 就 exit 1。
$fail = @($checks | Where-Object { $_.'判定' -eq 'FAIL' })
if ($Mode -eq 'baseline') { exit 0 }
if ($fail.Count -eq 0) { Write-Host '全部 UI 目标达成'; exit 0 }
Write-Host ('有 {0} 项未达标' -f $fail.Count) -ForegroundColor Red
exit 1于是"界面改版完成没有"这个问题的答案,从一段文字描述变成了一个进程退出码。
5.3 门槛为什么最终不是像素占比
有一点必须如实说:最初的门槛里包含"卡片面 ≤60% / 页面底色 ≥30%"这类像素占比,跑完之后它们被降级为参考值,不再参与退出码(docs/ui-review-plan.md:158-160)。原因有两个:①如上节所述,这类占比并不描述"能不能办事";②"≤60%"在不删掉行动表的前提下不可达——C1 之后实测 63.4%,而删表去凑指标会直接违背"首屏要有可读的行动表"。
最终门槛全部是行为类断言(docs/ui-review-plan.md:164-175):首屏行动表 top ≤560 且完整可见行数 ≥5、表格单元格截断 0、每屏 primary 按钮 ≤1、表格内蓝色行内链接 0、状态色竖条覆盖全部行、零值 KPI 降级、无空态占位卡、侧栏与卡片亮度差 ≥8、横向溢出 0、深色文字对比度 ≥4.5。
最终断言结果(docs/ui-review-plan.md:335):-Mode assert → 17 项 PASS + 4 项 INFO,未达标 0(exit 0);从基线一路收敛:19 项检查 / 11 项未达标 → Phase A 末 9 → Phase B 末 4 → Phase C 末 0。
⚠️ 诚实说明:本节数字来自项目内文档与脚本本身(脚本源码我逐行读过),是 v1.5 阶段的结果——
AGENTS.md§6 Phase 7「仍未完成」明确记着ui-review.ps1 -Mode assert未跑(会与产物重建互相干扰),本篇没有复跑它。仓库当前工作区也没有ui-review.ps1的历史输出目录(.tmp/ui-review不存在),因此我没有在本机重跑这 10 张截图去独立复现。改版的视觉结论本身也明确标注为"待用户在浏览器验收"(本环境无浏览器)。
6. 代价与已知偏差
一篇讲"改版"的文章如果只写收获,可信度是零。下面这份清单来自项目自己的诚实记录。
6.1 令牌层面的已知偏差(docs/ui-design-tokens.md §11)
| # | 偏差 | 影响 | 处置 |
|---|---|---|---|
| 1 | #dd8629 对比度 2.79(未达 AA 4.5) | ✅ 已解决:不再用纯彩色文字承载该数值,改为 antd <Tag color="orange"> 胶囊(自带底色,两模式均可读)。色板中确实不存在可过 AA 的更深琥珀色文字色,故改用容器承载而非发明新色值 | |
| 2 | 浅色模式三级文字 #81858c 对比度 3.71 | 次要说明文字 | 该值为设计系统原值,保持与系统一致;不擅自发明新灰阶 |
| 3 | 浅色模式状态填色对白底对比度 2.15–4.10 | 仅用于非文本图形(圆点/色块/图表),按 WCAG 1.4.11 需 3:1;「在库」2.28、「报废」2.73、「外借」2.15 略低于 3:1 | 深色为主用模式;浅色这些填色仅出现在 antd Tag(由 antd 自行保证可读性)与图表,图表叠加描边增强边界辨识 |
第 1 项的处理方式是全篇最"有纪律"的一个决定:发现颜色不达标时,第一反应不是"我调一个新色值",而是"我看过的色板里没有能过 AA 的琥珀色,所以我改承载方式"——不发明系统里没有的东西。第 2 项是另一种纪律:明知不达标但选择不改,并写明理由。它比"偷偷调成 4.5"更诚实:一旦为过指标而发明一个偏离设计系统的灰阶,就制造了第二份令牌来源——那正是 §3.2 那条强制规则要防的事。
6.2 结构性代价:深色主题换来的和失去的
- 失去:无法用底色表达层级(1.16:1,上限 1.33:1)。所有"这块比那块更靠前"的语义只能靠描边、留白、局部色条、字号与内容本身承载。这意味着信息层级的设计不能再偷懒——没法靠"再包一层卡片"把结构糊过去。
- 失去:浅色模式不是"一等公民",它是在深色之后被验证的。§11 的 3 项偏差里有 2 项只出现在浅色下。
- 换来的:深色模式六个业务状态色全部达 WCAG AA(实测 5.23–9.53),文字色阶 4.92–17.45 全部 ≥4.5(
docs/ui-design-tokens.md:152-161、:178-185)。这是"先把深色做扎实"的直接结果。
6.3 未完成项(不隐藏)
- Win7 实机回归至今未做,
release/equipment/install/目前为空——Chrome 109 离线安装包尚未放入(本环境无法访问 dl.google.com)。决策 11 里的 Win7 支持是目标与约束,不是"已实测通过"。 - 吸顶表头(sticky ×18/29)只有静态计数与约定脚本证据,无浏览器截图证据——"生产库保护铁律"禁止在真实库上做视觉实测,而这批改动发生时评审工具尚未覆盖该项。
- 部分观感结论仍是"待用户实机验收":Hero/KPI 在 1366×768 下的折行与留白、
DangerNotice在弹窗中的观感、报废弹窗危险色按钮的实际显示,文档里都标着"需浏览器目视"。 - 前端产物不是逐字节可复现(同一源码两次构建 JS 哈希不同,差异来自 Rollup 对 CJS 依赖
dayjs的包装决策)。对策是不引入额外插件去"修"它(零业务收益且要动构建链),也不用产物哈希当"构建身份"。
7. 不变量:改界面不许改行为
"界面改版"最危险的地方不是不好看,而是顺手改坏了业务。所以改版开始前,我先把不变量钉死(docs/ui-redesign-plan.md:379-385):
- SQLite 数据与迁移、GORM 模型、
transaction/flow_record写入语义; - 状态机与全部业务规则;导入/对账链路;备份恢复;导出;
- Win7 兼容策略(Go ≤1.20、
CGO_ENABLED=0、target: chrome109); - 交付形态:单 exe +
go:embed+NoRouteSPA 回退 + 离线运行; - 技术栈:React / TypeScript / Vite / Ant Design / ECharts(+ 经批准的
react-router-dom)。
v1.5 那一轮把这份清单又补了两条业务口径(docs/ui-review-plan.md:187-189):分页口径(台账 20 / 其他 10)、display_no 规则,以及 v1.4 Phase 3 立下的「Dashboard 数值零变化」。
"Dashboard 数值零变化"这一条值得单独讲,因为它把"改样式"最常见的坏结果钉成了断言。改版前先记录每个元素的真实值,改版后逐项比对(docs/ui-redesign-plan.md:174-190):设备总数 2148、在库 1815 / 外借 333、逾期 0、类别 9 行、最近流转 8 行、当前外借 20 行——列与字段完全不变。
然后它用可复现的方式证明这件事:启动预览实例 → 抓真实 /api/dashboard 响应 → 用仓库内 esbuild 把纯函数模块打成 CJS → 在 Node 里跑数值断言。为此专门新增了 web/src/dashboardModel.ts,刻意不含任何 import(纯函数、无副作用),所以能直接在 Node 里跑,不必拉起浏览器或 antd。断言表里最关键的两条是:KPI 卡片条目数 === 后端 by_status 条数(不丢条目)、by_status 求和 === total(2148 vs 2148)——状态口径自洽(docs/ui-redesign-plan.md:200-210)。
不变量还要有机械化的验证手段(docs/ui-review-plan.md:191-195),否则它只是愿望:
git diff --name-only -- internal cmd go.mod必须为空;- 接线审计(
git diff逐行):不得出现fetch(//api//params.set/onSearch/setPage(/pageSize的改动; go test ./... -count=1全绿 +scripts/check-web-conventions.ps16 组全 PASS;scripts/ui-review.ps1 -Mode assert达标。
第一条特别有力:"没碰后端"这件事可以用一条命令证伪。 它比"我确认过了"强得多——因为它是别人可以重跑的。
Related Articles
一台只跑 Win7 的机器、2148 台设备、一份手工台账
系列总纲:在 Win7 单机、单 exe、无网络、无登录的约束下,用 Go + SQLite + React 做一个设备管理系统——讲清约束清单、系统边界、技术选型的传导链与全景数字。
Building a Digital Garden with Next.js 15
How I built this digital garden using Next.js 15 App Router, React Server Components, and Tailwind CSS v4 — from project scaffolding to deployment on Vercel.
Dark Mode in Tailwind CSS v4: The Right Way
Tailwind v4 changed how dark mode works. Here's how to set up class-based dark mode with next-themes and avoid hydration mismatches in Next.js 15.
Knowledge Relations
Related Articles
Win7 如何一路锁死技术选型(以及一个 131072 字节的空库事故)
一句“顺便支持一下 Win7”,决定了后端工具链锁定 Go 1.20、无 CGO、纯 Go SQLite,前端按 Chrome 109 构建。以及一个把数据写进错位目录的空库事故,和交付脚本的自检化。
一次全库只读审查:134KB 报告、P0×4 / P1×16,以及审查方自己的 4 处更正
v1.5.0 发布后我做了一次全库只读审查:不许改代码、只在副本上实测、逐条可核实,交回 134KB 报告。记录 4 个 P0、16 个 P1、两个治理机制(生产库保护铁律、已知欠账豁免)、审查方自己的 4 处更正,以及一份诚实的未完成清单。