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

22 KiB
Raw Blame History

AGENTS.md — 部署与配置执行手册(写给 AI agent / 自动化脚本)

本文件是可机械执行的版本:每条命令都给出期望输出,判断标准明确,不做叙述性解释。 人类读者请看 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. 场景判定(先跑这段)

docker ps -a --filter name=^navi$ --format '{{.Names}} {{.Image}} {{.Status}}'
输出 场景 跳到
空 全新机器 §2 首装
navi 存在且 NetworkMode=host 已有部署 §3 升级
navi 存在但不是 host 网络 旧的桥接形态 先按 §3 的注意事项确认端口/网络,再升级

2. 首装(干净机器)

2.1 前置检查

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 执行

# 有证书:
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 期望输出(逐项核对,任一 ✗ 即失败)

== 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 硬性前提

# ① 旧容器是否已有 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 执行(从开发机推送镜像)

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 期望输出

== 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 收尾断言(必做)

docker inspect navi --format '{{.Config.Image}} {{.State.Status}}'   # 期望:homedash:ui-<版本> running
docker ps -a --filter name=navi-next --format '{{.Names}}'           # 期望:无输出

若容器停在 navi-next(切换中途中断的残留,服务通常仍正常):

# 先用 navi-next 跑完 §5 验收,全绿后再改名
docker rename navi-next navi

4. 配置(agent 直接用接口改,不必读代码)

4.1 先取清单

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 常用写入(照抄即可)

# 站点标题 / 各页文案 / 备案号(空串 = 回落内置默认)
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"当成功)

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 —— 这是实测踩过的坑。

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 吞了,表现为"滑了没反应"。

附加(有数据时):

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. 提交前脱敏清单(把仓库发布出去之前必跑)

# 期望:全部为 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. 回滚

# 升级失败会自动回滚(脚本内建);手动回滚到上一个版本:
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 ⇒ 测渲染必须走容器直连:

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 验收要双向

# 正向:待测版本 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。 两条都过才算真有效(只过正向的,大概率是假绿)。