Files
HomeDash/AGENTS.md
JianFeeeee 270445a516 feat(navi): Enter 唤起搜索框 + Space 激活焦点卡片;监听器幂等守卫
- bindSearchKeys:Enter 展开搜索并聚焦(搜索框已展开/焦点在可编辑元素时放行),
  Space 激活焦点所在的 a[href]/[role=button](preventDefault 防滚动)
- bindSearchKeys/enablePeekGuard/mountViewSwitch 加幂等守卫:
  DOM 抖动场景下 HEAD 版泄漏 914 个 keydown 监听器,修复后 0
- MutationObserver 自触发加 selfWrite 计数 + 100ms 节流
- showWorkspaceBlockedModal 的 Escape 从 document 改挂 overlay
- 验收脚本:verify-search-key.js(真实按键,页面内计数断言)、
  verify-swipe.js(CDP 注入真实触摸,断言分组真的切换)
- README/AGENTS:快捷键表、手势验收方法、改样式前先确认设计基准(§9)
2026-10-02 11:24:55 +08:00

405 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AGENTS.md — 部署与配置执行手册(写给 AI agent / 自动化脚本)
本文件是**可机械执行**的版本:每条命令都给出**期望输出**,判断标准明确,不做叙述性解释。
人类读者请看 [README.md](README.md)。
---
## 0. 铁律(违反会造成线上故障,先读完再动手)
| # | 铁律 | 原因 |
| --- | --- | --- |
| 1 | **首装与升级脚本不可混用**:新机器用 `deploy/install-host.sh`;已有部署用 `deploy/zd-host.sh <版本>` | `zd-host.sh` 要求已存在 host 网络的 `navi` 容器,否则拒绝运行 |
| 2 | **绝不要重置门户口令**,除非用户明确要求 | 口令是所有受保护域名(含用户访问 agent 界面的入口)的唯一凭据;重置后没拿到新口令 = 把用户锁在门外。默认只清理明文 env,不动口令 |
| 3 | **改完路由表必须 `apply`**,且 `apply` 后是**异步 reload**:要轮询等入口就绪再探测 | 立即探测会拿到旧结果(表现为"apply 了却 404") |
| 4 | **管理接口前缀是 `/admin/api`**;根路径 `/api/` 会返回 200 + Dashy 的 HTML | 只看状态码会把"打到错误服务"误判成成功,判断命中要看返回体是否是 JSON |
| 5 | **路由表接口是整表替换**:必须提交完整 `{"routes":[…]}`;清空非空表要 `force:true` | 只提交单条会把整张表换掉 |
| 6 | **不要提交任何凭据/域名/内网地址/数据卷文件**(见 §6 脱敏清单) | 仓库会被发布 |
| 7 | **不要 `sed -i` 修改单文件 bind mount**(如 `portal/nginx/nginx.conf`) | inode 替换会导致全站 502;要用同 inode 写入(`cat >`)或重启容器 |
| 8 | **部署串行执行**,不要并发两次部署 | 并发会算错目标端口/镜像,导致半成品状态 |
---
## 1. 场景判定(先跑这段)
```bash
docker ps -a --filter name=^navi$ --format '{{.Names}} {{.Image}} {{.Status}}'
```
| 输出 | 场景 | 跳到 |
| --- | --- | --- |
| 空 | 全新机器 | §2 首装 |
| `navi` 存在且 `NetworkMode=host` | 已有部署 | §3 升级 |
| `navi` 存在但不是 host 网络 | 旧的桥接形态 | 先按 §3 的注意事项确认端口/网络,再升级 |
---
## 2. 首装(干净机器)
### 2.1 前置检查
```bash
docker info >/dev/null && echo DOCKER_OK # 期望:DOCKER_OK
ss -H -lnt | awk '{print $4}' | grep -E ':(3080|3081|8443)$' # 期望:无输出(端口空闲)
ls /vol1/docker/cert/fullchain.pem /vol1/docker/cert/privkey.pem # 有证书则列出;没有则用 NAVI_TLS=0
df -h /tmp | awk 'NR==2{print $4}' # 期望:≥2G(构建需要约 250M)
```
### 2.2 执行
```bash
# 有证书:
NAVI_DOMAIN=<你的域名> NAVI_SYNC_TOKEN=<随机> NAVI_ADMIN_TOKEN=<随机> \
bash deploy/install-host.sh
# 无证书(内网先跑通):
NAVI_DOMAIN=<你的域名> NAVI_TLS=0 NAVI_SYNC_TOKEN=<随机> NAVI_ADMIN_TOKEN=<随机> \
bash deploy/install-host.sh
# 非默认端口/目录:
NAVI_ENTRY_PORT=13080 NAVI_TLS_PORT=18443 NAVI_REDIRECT_PORT=13081 \
NAVI_DATA_DIR=/srv/navi-data NAVI_CERT_DIR=/srv/cert bash deploy/install-host.sh
```
### 2.3 期望输出(逐项核对,任一 ✗ 即失败)
```text
== 1/6 前置检查 == 数据目录 / 证书目录 / 端口回显
== 2/6 令牌 == 只打印一次,自行保存
== 3/6 镜像 == Successfully tagged homedash:ui-install
== 4/6 初始网关配置 == 已生成 <数据目录>/gateway.yml(含门户路由 …)
== 5/6 启动容器 == healthz 就绪(≤60s)
== 6/6 应用配置与自检 == apply: {"routes":2,"status":"applied"}
✓ 后台 /admin → 200 ✓ 内部配置 /conf.yml → 200
✓ 未知域名 → 自定义 404 ✓ 门户 <域名> → 200
✓ TLS 已生效(配置含 ssl_certificate) ← 仅 NAVI_TLS=1 时出现
✓ http→https 跳转 → 302 ← 仅 NAVI_TLS=1 时出现
首装完成。 ← 出现这行才算成功;退出码必须为 0
```
**失败处置**(脚本任一步失败即停,容器保留便于排查):
| 报错 | 原因 | 处理 |
| --- | --- | --- |
| `已存在名为 navi 的容器` | 该机器已有部署 | 改走 §3 升级,或先 `docker rm -f navi` |
| `端口 N 已被占用` | 端口冲突 | 用 `NAVI_*_PORT` 换端口 |
| `缺少证书 …/fullchain.pem` | 证书缺失/命名不符 | 放置 `fullchain.pem`+`privkey.pem`,或用 `NAVI_TLS=0` |
| `生成的配置里没有 ssl_certificate` | `gateway.yml` 字段名被改坏 | 检查 `listenHttps` / `listenTlsPort`(**大小写敏感**,写错会被 YAML 静默忽略) |
| `配置未通过 nginx 校验` | 生成的配置非法 | 界面/接口返回里带 nginx 报错原文;**旧配置会被保留**,入口不会中断 |
---
## 3. 升级(已有部署)
### 3.1 硬性前提
```bash
# ① 旧容器是否已有 cookie 域?没有就必须在命令里传 NAVI_COOKIE_DOMAIN
docker inspect navi --format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^PORTAL_COOKIE_DOMAIN='
```
若上面**无输出**,则升级命令**必须**带 `NAVI_COOKIE_DOMAIN`,否则脚本会拒绝执行(这是刻意的:
新版已删除代码里写死的默认域名,不带就会让登录 cookie 退化为 host-only → 跨子域登录失效)。
### 3.2 执行(从开发机推送镜像)
```bash
NAVI_COOKIE_DOMAIN=.example.com NAVI_HOME_URL=https://example.com/ \
NAVI_HOST=user@host NAVI_SSH_PASS='<密码>' \
bash deploy/zd-host.sh <版本号>
```
- 开发机需 `sshpass`(或用密钥并自行去掉 `-p`)。
- 升级会有**数秒中断**(入口即应用容器),失败自动回滚。
- **不要**传 `NAVI_ROTATE_PORTAL_PASSWORD=1`,除非用户明确要求改门户口令。
- 需要指定自检域名时传 `NAVI_CHECK_HOST=<已配置的域名>`;默认自动取数据卷 `gateway.yml` 路由表首条。
### 3.3 期望输出
```text
== 1/3 本地构建 homedash:ui-<版本> == 镜像与包就绪: 222M
== 2/3 上传 ==
== 3/3 远端切换 ==
ready http://127.0.0.1:18080/healthz
ready http://127.0.0.1:18080/conf.yml
ready http://127.0.0.1:18080/admin
自检探测域名: <域名>
已配置域名 <域名> → 200|302
入口自检通过(3080/3081)
已重新生成网关配置: {"file":"…","routes":N,"status":"applied"}
DEPLOY_OK tag=homedash:ui-<版本> mode=host health=healthy
```
### 3.4 收尾断言(必做)
```bash
docker inspect navi --format '{{.Config.Image}} {{.State.Status}}' # 期望:homedash:ui-<版本> running
docker ps -a --filter name=navi-next --format '{{.Names}}' # 期望:无输出
```
若容器停在 `navi-next`(切换中途中断的残留,服务通常仍正常):
```bash
# 先用 navi-next 跑完 §5 验收,全绿后再改名
docker rename navi-next navi
```
---
## 4. 配置(agent 直接用接口改,不必读代码)
### 4.1 先取清单
```bash
A=http://127.0.0.1:18080/admin/api # 经入口;容器内可直连 http://127.0.0.1:3005/api
TOK=<NAVI_ADMIN_TOKEN>
curl -s "$A/schema" | head -c 400
```
返回 `items`(单值配置项 + 当前值 + 写入口 + 请求体示例)与 `resources`(结构化资源及其写入口)。
**当前为空的键也会列出**,所以不要凭猜测字段名。
### 4.2 常用写入(照抄即可)
```bash
# 站点标题 / 各页文案 / 备案号(空串 = 回落内置默认)
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"title":"我的导航","portalTitle":"我的门户","heroTitle":"欢迎","icp":"京ICP备00000000号-1","authLogo":"JF"}' \
"$A/pageinfo"
# 门户页服务卡片(空串 = 恢复内置卡片)
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"html":"<a class=\"card\" href=\"https://example.com\">示例</a>"}' "$A/portal-cards"
# 路由表(整表替换)→ 必须 apply
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"routes":[{"host":"app.example.com","upstream":"http://127.0.0.1:3000","requireLogin":true,"websocket":false,"kind":"proxy"}]}' \
"$A/gateway/routes"
curl -s -X POST -H "Authorization: Bearer $TOK" "$A/gateway/apply"
# 导航分组 / 条目
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"name":"常用服务"}' "$A/section"
> ⚠️ **两个分组是系统管理的,不要往里手工加条目**:
> - `常用服务`:收录**所有被点击过**的导航项,按点击次数降序(**不置顶任何服务、不设条数上限**),服务端重建;
> 计数来自 `POST /api/history/nav-visit`(导航页点击自动上报,落盘 `nav-visits.json`),**不使用浏览器历史**
> - `历史访问`:按浏览器同步的浏览记录重建,**不限条数、且包含站内服务**(相当于"最近访问",安装入口由它自己放在首位)。
> 数据量取决于浏览器扩展的同步(`history.json` 的 mtime 可判断是否还在同步)
>
> 因此 `navEnabled` 的 `navSection` 必须指向**用户分组**(如 `AI & 工具`、`系统管理`)。
> 实测教训:把新服务直接塞进 `常用服务` 会污染频次榜,且该条目会被下一次重建挤掉。
# 导航条目是**嵌套形状**(实测:扁平 {"name","url"} 会返回 "title and url required"):
# 先查分组下标,再提交 {"sectionIndex":N,"item":{...}}
IDX=$(curl -s -H "Authorization: Bearer $TOK" "$A/sections" | \
python3 -c 'import sys,json;d=json.load(sys.stdin);print([i for i,s in enumerate(d) if s["name"]=="常用服务"][0])')
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d "{\"sectionIndex\":$IDX,\"item\":{\"title\":\"我的应用\",\"url\":\"https://app.example.com\",\"icon\":\"auto\",\"description\":\"说明\"}}" "$A/item"
# 删除条目:**DELETE + JSON body**(POST 会返回 method not allowed),下标从 /sections 里数
curl -s -X DELETE -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"sectionIndex":0,"itemIndex":4}' "$A/item/delete"
> 形状不确定时先取 `GET /api/schema`(§4.1)或读 `admin-server/main.go` 里的处理函数:
> 本手册初版就把 `/api/item` 写成了扁平形状,实测被拒——**文档里的示例必须实测过**。
# 导航项点击计数(「常用服务」排序的唯一依据;前端点击时自动上报,这里用于人工/脚本触发)
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"url":"https://app.example.com/"}' "$A/history/nav-visit"
# 壁纸 / 门户口令 / 历史
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"url":"https://x/bg.jpg"}' "$A/wallpaper"
curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"username":"admin","password":"<新口令>"}' "$A/portal-auth"
curl -s -X DELETE -H "Authorization: Bearer $TOK" "$A/history/item?url=<编码后的URL>"
```
### 4.3 写完必须验证(不要以"接口返回 200"当成功)
```bash
HOST=<刚改的域名>
PORT=${NAVI_ENTRY_PORT:-3080}; SCHEME=${NAVI_TLS_SCHEME:-https}
for i in $(seq 1 20); do # apply 后 reload 是异步的
code=$(curl -sk -o /dev/null -w '%{http_code}' -m 3 --resolve "$HOST:$PORT:127.0.0.1" "$SCHEME://$HOST:$PORT/")
[ "$code" != "000" ] && break; sleep 0.5
done
echo "$code" # 期望:200(门户)或 302(受保护)
```
---
## 5. 验收清单(部署后 / 改配置后都跑一遍)
把 `<HOST>` 换成路由表里任一已配置域名。**端口必须用变量**:写死 3080 时,非默认端口
部署(例如 `NAVI_ENTRY_PORT=13080`)会全部探测到 `000` —— 这是实测踩过的坑。
```bash
PORT=${NAVI_ENTRY_PORT:-3080} # 入口端口(非 3080 部署必须设)
RT=${NAVI_REDIRECT_PORT:-3081} # http→https 跳转端口
SCHEME=${NAVI_TLS_SCHEME:-https} # NAVI_TLS=0 的部署用 http
T=$(curl -sk -o /dev/null -w '%{http_code}' -m 8 --resolve "$HOST:$PORT:127.0.0.1" "$SCHEME://$HOST:$PORT/")
U=$(curl -sk -o /dev/null -w '%{http_code}' -m 8 --resolve nobody.invalid:$PORT:127.0.0.1 "$SCHEME://nobody.invalid:$PORT/")
H=$(curl -s -o /dev/null -w '%{http_code}' -m 8 http://127.0.0.1:18080/healthz)
S=$(curl -s -o /dev/null -w '%{http_code}' -m 8 http://127.0.0.1:18080/admin/api/schema)
R=$(curl -s -o /dev/null -w '%{http_code}' -m 8 -H "Host: $HOST" http://127.0.0.1:$RT/)
printf '已配置域名=%s 未知域名=%s healthz=%s schema=%s 跳转=%s\n' "$T" "$U" "$H" "$S" "$R"
```
> `跳转=302` 只在**启用 TLS** 的部署上适用;`NAVI_TLS=0` 的安装没有跳转端口,该项会得到 `000`,属预期。
| 项 | 期望 | 不符合时的含义 |
| --- | --- | --- |
| 已配置域名 | `200` 或 `302` | `404`=不在路由表;`000`=没监听/证书问题;`5xx`=上游或配置坏了 |
| 未知域名 | `404` | 不是 404 说明未知域名兜底配置丢了 |
| healthz | `200` | 容器内服务异常 |
| schema | `200` | 镜像过旧(不含新接口),或前缀打成 `/api/` |
| HTTP→HTTPS 跳转 | `302` | 跳转端口/开关配置问题 |
**交互类改动必须用真实输入验证,不能只查属性**(例如 `/minimal` 的左右滑动切换分组):
用 CDP 注入触摸(`Emulation.setTouchEmulationEnabled` + `Input.dispatchTouchEvent`)、
真实鼠标 `mouse.down/move/up`、`mouse.wheel(deltaX, 0)`;断言"切换真的发生"
(读 `.minimal-headings-row > .minimal-section-heading.headings` 中 `.selected` 的下标变化),
并要求 `<html>` 上 `data-navi-swipe="on"`、`pageerror` 为 0。
只断言"事件到了 / 监听器挂了"**不算通过** —— 本项目实测踩过:事件全都到达,
但自建拦截器把自己的切换 click 吞了,表现为"滑了没反应"。
附加(有数据时):
```bash
curl -s -H "Authorization: Bearer $TOK" http://127.0.0.1:3005/api/gateway/routes | grep -o '"host"' | wc -l # 路由条数应与升级前一致
docker exec navi sh -c 'ls /app/data/portal-auth.json' # 存在 → 登录凭据仍在数据卷
```
---
## 6. 提交前脱敏清单(把仓库发布出去之前必跑)
```bash
# 期望:全部为 0(本文件自身含检测用的正则,故排除自身)
git grep -nIE '(NAVI_(SYNC|ADMIN)_TOKEN|PORTAL_PASSWORD|PORTAL_SESSION_SECRET)=[0-9A-Za-z_-]{12,}' | grep -v '^AGENTS.md' | wc -l # 真实令牌/口令(按长度+字符集识别,不写具体值)
git grep -nIE '192\.168\.[0-9]+\.[0-9]+' | wc -l # 内网地址
git grep -nIE '[京沪粤豫苏浙川鲁闽湘鄂]ICP备[0-9]{6,}' | grep -v '^AGENTS.md' | wc -l # 备案号(省简称+ICP备+数字)
git grep -nIE '公网安备[0-9]{8,}' | grep -v '^AGENTS.md' | wc -l # 公网安备
git grep -nIE '(^|[^a-z])([a-z0-9-]+\.)+(xyz|top|club|vip)([^a-z]|$)' | grep -v '^AGENTS.md' | wc -l # 真实域名后缀(按需保留白名单)
git ls-files | grep -cE '(^|/)(conf\.yml|gateway\.yml|history\.json|portal-auth\.json|\.env)$' # 数据卷文件必须为 0
git ls-files | grep -ciE '\.(pem|key|crt|p12|pfx)$' # 私钥/证书必须为 0
```
> ⚠️ **不要把真实令牌、口令、备案号、域名当作"示例模式"写进任何文档**(本手册初版就犯过:
> 把真实值写进自检命令,等于把凭据提交进仓库)。检测一律用"长度/字符集/格式"特征来写。
#### 6.1 这三条命令**会报非零**,那不是泄漏(已核对,别重复排查)
上面第 2/3/4 条在本仓**当前**就会命中,且都是**示例/测试值**,不是真值:
| 命令 | 当前命中 | 为什么是安全的 |
| --- | --- | --- |
| 内网地址 | `README.md` ×5 | 全部是 `192.168.1.100` / `192.168.1.10` —— RFC1918 文档示例段,与 `example.com` 同级 |
| ICP 备案 | 6 | `admin-server/*_test.go` 与 `admin/index.html` 的 `placeholder` 用 `京ICP备00000000/12345678`,格式验证必需 |
| 公网安备 | 6 | 同上,测试夹具与 placeholder |
判断口径:**看具体值是否可回溯到某人**,而不是看正则是否命中。新增文档示例时
沿用文档段地址(`example.com` / `192.168.1.100`)与全零/顺序号(`00000000` / `12345678`),
就不会引入新的真实值。
其余命令(令牌、数据卷文件、证书)当前均为 **0**,这三个必须保持 0。
**绝不可提交**:`conf.yml`、`gateway.yml`、`history.json`、`portal-auth.json`、`.env`、
`*.pem|key|crt`、任何真实令牌与口令、真实域名/内网地址/备案号。
**可配置项不要写死在代码里**:文案走 `pageInfo`、备案号走 `pageInfo.icp/gongan`、
域名走环境变量(`PORTAL_COOKIE_DOMAIN` / `PORTAL_HOME_URL` / `gateway.yml` 的 `loginUrl`)、
门户卡片走数据卷 `portal/cards.html`。
---
## 7. 回滚
```bash
# 升级失败会自动回滚(脚本内建);手动回滚到上一个版本:
docker ps -a --filter name=navi-prev --format '{{.Names}} {{.Image}}' | tail -3
docker rm -f navi # 或 docker rename navi navi-broken
docker rename navi-prev-<时间戳> navi
docker start navi
# 验证:跑 §5 清单
```
保留几天再清理历史回滚点;`/app/data` 数据卷与 `gateway.yml.bak-*`、`conf.yml.bak-*` 是配置级回滚点。
---
## 8. 已知坑(都是实测踩过的,别重复踩)
| 现象 | 真因 | 处理 |
| --- | --- | --- |
| 改配置后探测 404,其实成功了 | `apply` 的 reload 是异步的 | 轮询等待入口就绪(§4.3) |
| 接口返回 200 但什么都没改 | 打到了根 `/api/`(Dashy) | 用 `/admin/api`,看返回体是否 JSON |
| "HTTPS 端口"其实是明文 | `gateway.yml` 字段名大小写不符(`listenHttps`),YAML 静默忽略 | 对齐字段名;脚本会断言配置含 `ssl_certificate` |
| 提交一条路由后端口/其它路由全变了 | 旧接口整份配置重置(已修) | 升级到含修复的版本;提交完整 `{"routes":[…]}` |
| 脚本报 `HOST: unbound variable` | 必填校验写在赋值之前 + `set -u` | 升级脚本(已修);或先给变量赋值再校验 |
| 容器名叫 `navi-next` | 切换中途中断 | 验收通过后 `docker rename navi-next navi` |
| `no space left on device` | `/tmp` 是 tmpfs,构建包 ~222M | 构建前 `rm -rf /tmp/homedash-*` |
| `常用服务` 顺序不像"最常点的" | 它只认导航项点击计数,不认浏览器历史 | 用上面的 nav-visit 接口触发/查看;计数文件 `nav-visits.json` |
| 照抄验收命令却全是 000 | 文档里的端口写死 3080,而部署用了别的端口 | 用 `PORT=${NAVI_ENTRY_PORT:-3080}`(本手册已改) |
| 脱敏自检把文档自己判为"命中" | 自检命令里写了真实值当模式 | 用长度/字符集特征检测,并排除文档自身(本手册已改) |
| 部署脚本用 `bash -s` 传参不生效 | 远端 heredoc 是引号形式,不展开本地变量 | 在 ssh 命令行显式传(脚本已处理) |
## 9. 改样式前必读:别拿示例数据当基准
> 2026-10-02 实测踩坑。改 `web/navi.css` 前先读完这一节,能省掉一次线上回归。
### 9.1 线上与「Dashy 示例数据」是两套环境
同一个 `.item.size-medium`(同一个选择器),实测值完全不同:
| 属性 | 线上真实版 | 示例数据 fixture |
| --- | --- | --- |
| 主题 | `jianf-glass`(自定义)+ masonry | 示例配置 |
| 壁纸 | 有(白 `.35` 渐变 + 图) | 无 |
| 分组容器底 | `rgba(255,255,255,.4)` 白玻璃,**24px** 圆角,`blur(20px)` | `rgb(5,7,14)` **深色** |
| 卡片底 | `rgba(255,255,255,.52)` 白玻璃 | `rgb(5,7,14)` 深色 |
| 卡片圆角 | **18px** | **5px** |
| 卡片内距 | `8px`(首页) | `2px` |
| 分组标题 | `<h3>` **18.72px / 700** | 18.72px |
| 分组名(极简) | `.section-title` 15px / 700 | 15px / 400 |
⇒ 在示例数据上量到的**颜色、圆角、内距、字重**拿去改线上,会把玻璃拟态改成直角深色。
### 9.2 三条硬规则
1. **颜色/对比度必须在真实底色上实测**,不能推算。
本次真实翻车:用 fixture 的深色底算出「分组标题要浅色字」,
部署到线上(白玻璃底)变成 **1.2:1,标题几乎消失**。
⚠️ 半透明底要**先与壁纸实际像素合成**再算 WCAG。
2. **改动前先起「同数据镜像」**:把线上 `conf.yml` + `/wallpapers/*` 拉下来当 fixture,
再把待测的 `navi.css`/`navi.js` 放进去。这样测出的结论才等于线上。
3. **“统一风格”不是“把所有值改成同一个”**。
本次我把首页卡片圆角从 18px “统一”到 5px、标题从 700 “统一”到 400,
看着一致了,实际把线上那版玻璃拟态的外观毁了。**先问哪个是基准,再动手。**
### 9.3 内部直连(绕过网关登录门禁)
线上 `authUrl` 指向统一门户,导航站域名本身 `requireLogin=true`,
共享浏览器没有该实例 cookie ⇒ 测渲染必须走容器直连:
```bash
B=http://<host>:18080 # 非 https,绕过网关
curl -s "$B/conf.yml" -o /tmp/mirror-conf.yml
curl -s "$B/navi.css" -o /tmp/线上-navi.css # 先确认线上版本
curl -s "$B/navi.js" -o /tmp/线上-navi.js
```
### 9.4 验收要双向
```bash
# 正向:待测版本 vs 线上版本,期望【像素差 = 0】
python3 - <<'PY'
from PIL import Image, ImageChops
a,b=Image.open('A.png').convert('RGB'),Image.open('B.png').convert('RGB')
d=ImageChops.difference(a,b)
print('差异像素', sum(1 for p in d.get_flattened_data() if p!=(0,0,0)), '/', a.size[0]*a.size[1])
PY
```
反向:把 `navi.css` 换成 `git show HEAD:web/navi.css` 重跑,断言必须 **FAIL**。
两条都过才算真有效(只过正向的,大概率是假绿)。