Commit Graph

167 Commits

Author SHA1 Message Date
aae3fb7065 fix(deploy): 历史条数不再假报 0,明文令牌的 /tmp env 文件必删
两处都是「看起来成功、实际给了错误信息/留下凭据」的问题。

A. 历史条数假数据(行为变化)
   旧逻辑:ADMIN_TOK=$(grep ... || true) 取不到时仍发一个空的 `Bearer `
   请求;第 1 步把 history-server 改成 fail-closed 后它必然回 401 JSON,
   而 `grep -o '"url"' | wc -l` 对错误响应数出 0,再被 HIST=${HIST:-0}
   兜一层,于是对外打印「历史条数 0」—— 一个假数据,看起来像历史被清空。
   现在抽成 human_history_count(),三条路径分开:
     · 未配置 NAVI_ADMIN_TOKEN → 「未知(未配置…跳过探测)」,不发任何请求
     · 非 200(401/502/连不上的 000) → 「未知(HTTP xxx)」,不数 body
     · 只有 200 才数 '"url"' 出现次数
   ⚠️ 行为变化:token 取不到时历史条数显示「未知」而不是「0」;
   真实的 0 条(200 + 空列表)仍然显示 0。仍旧绝不阻断升级。

B. 明文令牌的 env 文件残留
   /tmp/navi-host-<stamp>.env 含 NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 明文,
   chmod 600 只防别的用户读,不防它一直躺在 /tmp。现在:
     · 统一为 $ENV_FILE(含 .t / .new 中间文件),不再散落字面量路径
     · 新增 cleanup_env_file(),同时删三个文件
     · 注册 trap ... EXIT(任何 exit 都删)+ fail() 回滚路径显式调用
       + 成功收尾显式调用(三处)
   删它不影响已起容器:docker run 已在启动时把值注入容器 env。

顺带确认(未改动,仅补注释):重置门户口令后打印一次 $NEWPW 是有意设计
——那是管理员唯一一次拿到它的机会(数据卷只存哈希),注释已写明
「只打印这一次」,且不写日志文件、不进任何 env。

新增 deploy/zd-host_test.sh(bash deploy/zd-host_test.sh,退出码 0=全绿):
1. bash -n 语法检查;
2. 静态护栏:轮换只打印 ${#VAR} 长度、无 set -x、无 HIST=${HIST:-0}、
   无「grep url | wc -l || echo 0」旧写法、必须校验 HTTP 状态码、
   env 文件在 fail() 与 EXIT trap 与成功路径上都有删除、中间文件也删;
3. 用 BEGIN/END 标记把 human_history_count 原样抠出来,配 stub curl 单测
   空 token / 401 / 502 / 连接失败 / 200 三条 / 200 空列表 六种情形,
   并断言空 token 时根本没发请求、有 token 时带了 Authorization 头。

反向验证:把历史条数换回旧写法并删掉三处 env 文件清理后,
测试从 26 通过 / 0 失败变成 10 通过 / 16 失败(列出「仍有 HIST=${HIST:-0}」
「fail() 回滚路径没有删 env 文件」「空 token 时仍然调用了 curl」等)。
另外用第 1 步的真实 history-server + 模拟两层鉴权的反代做了端到端复核,
旧写法在 401/连接失败下都报 0,新写法分别报「未知(HTTP 401)」「未知(HTTP 000)」,
200 + 3 条报 3,200 + 空列表报 0。
2026-10-22 23:25:26 +08:00
5562d584d3 fix(gateway): https 上游不再无条件关闭证书校验
审计第 2 条:writeProxyLocation(主 location / bypass 段)与 writeRouteServer
的 favicon 段原本都是

    if r.InsecureTLS || strings.HasPrefix(strings.ToLower(upstream), "https://") {
        proxy_ssl_verify off;
    }

即「只要上游是 https 就关掉上游证书校验」,对所有 https 上游一律不验签 ——
中间人可冒充任意 https 后端,用户提交的凭据与 cookie 全部可截。

现在条件收敛为 `r.InsecureTLS && isHTTPSUpstream(upstream)`:
「上游是 https」不再是关闭校验的理由,只有**显式勾选 insecureTls** 的路由
才会输出 proxy_ssl_verify off。两处(writeProxyLocation 用 r.Upstream、
favicon 段用局部变量 up)都改到了,并抽出 isHTTPSUpstream 消除重复判断。

⚠️ 运维需知的行为变化(有意为之,不要偷偷改回去):
- 普通 https 上游现在走 nginx 默认:**校验上游证书**,使用系统 CA 库。
  公共 CA(Let's Encrypt 等)签发的上游不受影响,无需任何配置。
- 仓库内没有 proxy_ssl_trusted_certificate 的配置,因此**自签证书的上游现在会 502**。
  这是预期的 fail-closed 结果:要接自签上游,请在该路由上显式勾选 insecureTls
  (等价于确认「我知道这条链路不验签」)。
- http 上游行为不变(本来就不会有 TLS 握手);本次也顺带保证不会给它写这行噪声配置。

测试(admin-server/gateway_tls_verify_test.go,沿用 gateway_test.go 的
RenderGatewayConf + section 取值风格):
1. https 上游 + InsecureTLS=false → 主 location 不含 proxy_ssl_verify off;
2. https 上游 + InsecureTLS=true  → 含;
3. http 上游(两种情况)→ 都不含;
4. favicon 段(location = /favicon.ico 与 /favicon.svg)逐条断言 1/2 两种行为,
   防止只改主 location 漏掉 favicon 段;
5. 计数断言:整份配置里 proxy_ssl_verify off 的出现次数 == 勾了 insecureTls 的
   https 路由贡献的 location 数(主/bypass + 2 个 favicon),兜住别处同类短路;
6. 源码护栏:不得再出现两种旧写法,且注释必须保留「自签默认 502 是预期」的说明。

反向验证:把两处判断改回 `|| https://` 后,1/3/4/5/6 全部报红(详见
`go test -run 'TestHTTPSUpstream|TestHTTPUpstream|TestFaviconPassthrough|TestSSLVerifyOff|TestInsecureTLSBehaviour'`),
计数用例给出 13 次 vs 期望 7 次;恢复后全绿。
2026-10-22 23:14:32 +08:00
857ec074aa fix(history): 鉴权改为 fail-closed,未配置 token 时拒绝一切请求
审计发现的方式与 admin-server 同源:requireToken 在 expected 为空时
直接 return true,等于「没配 NAVI_SYNC_TOKEN / NAVI_ADMIN_TOKEN 就没有
任何鉴权」。nginx 的 location /api/history/ 没有 auth_request,线上因此
可匿名读取全量浏览历史、DELETE 清空、逐条删除。

改法与 admin-server 的 requireAdminToken 保持同一套语义(不另起风格):
- 配了 token → 必须带对的 Bearer token
- 未配 token → 401 + JSON {"error":...},并在日志打出
  「未配置,已按 fail-closed 拒绝请求」的可诊断信息(只报事实,不打印 token 值,
  且按 key 去重避免刷日志)
- 只有显式 NAVI_HISTORY_ALLOW_UNAUTHENTICATED=1(1/true/yes/on)才放行,
  专用于本机调试,并会打警告日志

顺带收掉同一文件里等价的鉴权短路:新增 requireAdminToken 统一管理类入口
(DELETE / 清空、/item 逐条删除、/rebuild 重建)。它不再把「adminToken 为空」
当成免校验的短路:adminToken 优先,未配置但配了 syncToken 时接受 syncToken
(兼容旧部署,插件本来就用它写入),两者都没有则回到 requireToken 的
fail-closed 分支。

测试(history-server/auth_test.go)覆盖:
- 未配置 token → 401 且不写入任何数据,诊断日志含「未配置/拒绝」且不含 token 值
- 配置了 token:无头/错误 token/缺 Bearer 前缀 → 401;正确 token → 放行
- requireAdminToken 三种配置组合的判定顺序
- 显式放开开关可用但有警告
- 源码静态护栏:不得残留「未配置 token 则不校验」
反向验证过:把 requireToken 改回 fail-open 后,上述用例全部 FAIL。
2026-10-22 23:08:32 +08:00
71d0c06b67 fix(deploy): 历史条数探测不应阻断升级
管理 API 改为默认拒绝后 /admin/api/history 需要登录,
原本裸 curl + grep 在 set -e 下会让整个部署判为失败并回滚
(实测 v107 首次部署就撞上这一次)。

改成:带 Bearer token、限时、任何异常都回落 0 并继续。
历史条数本来就只是给人对比用的参考值。
2026-10-22 14:18:00 +08:00
19b4238cb1 feat(deploy): zd-host.sh 支持部署时轮换管理/同步令牌
渗透报告证明旧 NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 已在公网泄露
(/api/sync-info 曾明文回显 syncToken)。但本脚本只从旧容器继承 env,
轮换没有落地入口。

新增 NAVI_ROTATE_ADMIN_TOKEN / NAVI_ROTATE_SYNC_TOKEN:传入后覆盖
env 文件里的旧值,只打印长度不回显内容,最短 32 字符。
2026-10-22 14:09:28 +08:00
dea722f51c fix(security): 管理 API 默认拒绝 + 堵住渗透测试报告的 5 个攻击面
2026-10-22 渗透测试报告(report.md)实测确认:统一入口的管理 API 在公网
完全未鉴权,可匿名改路由表、拆登录墙、改门户口令、写门户 HTML、读全部
浏览历史、做内网 SSRF 探测。根因有两层,本次一并修复。

## 根因 1:requireAdminToken 默认放行(代码层)

    if !requireTokenForWrite { return true }

线上没有 NAVI_ADMIN_REQUIRE_TOKEN,于是所有"调了鉴权"的 handler 实际全部
放行——形同虚设。现在改为 fail-closed:

  - 配了 NAVI_ADMIN_TOKEN        → 校验 Bearer token
  - 未配且显式 NAVI_ADMIN_ALLOW_UNAUTHENTICATED=1 → 放行(仅本机调试)
  - 其它                          → 401 拒绝

## 根因 2:login 域没有任何 auth_request(部署层)

kind=auth 的 server 块全站 requireLogin=false(它本身就是登录服务,这点
合理),但 location / 把 /api/、/admin/ 原样反代到 127.0.0.1:3005。两层
都没管 → 管理 API 裸奔。

新增 Route.AdminGuard:开启后 server 级挂 auth_request,只白名单放行登录
流程(/_portal/login|logout|verify、/login、/portal-page、favicon),
其余 /api/、/admin/ 一律要求已登录。

## 同时修掉的 4 个具体问题

1. /api/sync-info 明文回显 syncToken → 不再回显,且要求鉴权。
   泄露该 token 等于浏览器历史的全读写权。
2. /api/history GET 无鉴权 → 与 DELETE 分支一致,要求管理员令牌。
3. /api/frameable 未鉴权 SSRF → 加鉴权 + 内网/回环黑名单。除 IP 字面量外
   还对域名解析结果做校验,防止 localtest.me 之类指向 127.0.0.1 的绕过。
4. dashy 域 bypassPaths 里的 "/api/" 过宽 → 收窄到 "/api/history/"
   (本意只放行浏览记录上报,实际把整个管理 API 暴露了)。

## 回归护栏

新增 admin-server/security_test.go(10 条)与 main_test_setup_test.go。
每条断言都是"改回旧行为就会失败",已用 3 次变异测试验证:
  - 恢复默认放行        → TestAdminAPIFailsClosedByDefault FAIL
  - sync-info 回显 token → TestSyncInfoDoesNotLeakToken FAIL
  - 移除内网黑名单      → TestFrameableBlocksInternalTargets FAIL
  - /api/ 豁免认证      → TestAuthKindWithAdminGuardProtectsAPI FAIL
  - 不给 server 级 auth → 同上 FAIL

测试 56 项全绿。

## 顺带

searxng-settings.yml:停用 360search / ddgw / wikidata / wikipedia
(各自的实测理由见文件内注释),request_timeout 8.0→4.0。
Dockerfile:给 duckduckgo_web 引擎硬编码的 timeout=2 打补丁(经代理必超时)。
2026-10-22 14:08:06 +08:00
96e771d546 feat(cert): cert-agent 支持多证书组 + 多目标推送(全量接管)
用户要求「部署时接管全部证书」。原来只签一组(self-only)。

## 多证书组(按证书而非按域名组织)
CERT_GROUPS 每行 '<组名>:<域名1,域名2,...>',例如:
  wild:*.jianfgit.xyz,jianfgit.xyz
  introduce:introduce.homeagent.jianfgit.xyz
  sdk:sdk.homeagent.jianfgit.xyz
每组独立落到 /app/data/certs/<组名>/,签发/续期/推送都以证书为单位
(同一张证书覆盖多个 SAN)。不配置时回落 gateway.yml 的 host(旧行为)。

## 多目标推送
CERT_PUSH_TARGETS 每行 '<host>:<远端cert>:<远端key>:<reload 命令>',
把证书推到其它机器(云主机 SafeLine 等)并触发其 reload。
认证走 CERT_PUSH_SSH_KEY / CERT_PUSH_SSH_PASS,都没有则免密。

## 途中修掉的 bug
① for 循环按**空格**分词,把含空格的 reload 命令("nginx -s reload")
   连同整行目标拆成 host/"-s"/"reload" 三片,产生三条无意义推送。
   改为 IFS=换行 + for,恢复 IFS。
② 凭据检查要求 CERT_DNS_API 与 CERT_DNS_CRED 同时存在,但插件名本来
   就能按凭据键名推断 —— 只需 CERT_DNS_CRED。
③ 改造时误删 LE_RELOAD_CMD / le_reload 定义(set -u 下直接
   "parameter not set" 中断)。已补回。

## 测试
沙箱 stub 覆盖:三组各自签发、通配符 SAN 正确、产出目录结构完整、
推送四步(mkdir→写cert→写key+chmod600→reload)全部执行。
真实签发此前已用 staging CA 验证通过(CN=dashy…, LE_ReloadCmd 已写入)。
2026-10-04 08:52:47 +08:00
12c07034a0 fix(cert): cert-agent 真实签发验证后修的三处问题
在测试容器里用真实凭据跑通了完整签发(staging CA),暴露三个真问题:

## ① CERT_DNS_CRED 没导出成 acme.sh 期望的变量名(致命)
脚本只把凭据解析出插件名 dns_ali,却从没导出 Ali_Key/Ali_Secret。
acme.sh 的 dns_ali.sh 读的是 $Ali_Key/$Ali_Secret,所以 DNS 步骤
报 'Error adding TXT record',而 ACME 其它步骤全部正常 ——
极易误判成"凭据无效/网络问题"。**stub 测试完全没暴露这个**,
只有真跑才看得见。现改为把 CERT_DNS_CRED 按逗号拆开逐个 export。

## ② 镜像内置 acme.sh,不再运行时下载
installer 内部从 raw.githubusercontent.com 取源码,而该域名境内直连超时
(实测直连 8.2s 失败 / 经 .60:7890 代理 0.53s 成功),
首次启动会静默失败、之后永远起不来。Dockerfile 新增 acmebuild stage
内置 acme.sh v3.1.6,运行时安装降级为兜底。

## ③ 下载重试 + 安装后校验
容器出网偶发 Connection reset by peer(同一 URL 隔几秒即 200),
加了 3 次重试与「安装后确认可执行」,避免半截状态。

## 真实测试证据(staging CA,零正式配额消耗)
TXT 写入 → Verifying → Success → Successfully removed → Cert success;
签出 CN=dashy.jianfgit.xyz(Let's Encrypt YE1,90 天);
Le_ReloadCmd 已写入 ⇒ 续期后自动 nginx -t && nginx -s reload。

测试环境已清理(含凭据文件),线上 v106 未受影响。
2026-10-04 08:29:35 +08:00
e7578aa607 feat(cert): cert-agent —— 容器内自签自续 HomeDash 自己的证书
用户决策:scope=self-only,strategy=full-acme(容器内跑完整 acme.sh)。

## 动机
此前 HomeDash 的证书由外部链路提供(.106 acme.sh → .60 → 云主机),
而容器是**只读**挂载 /etc/nginx/certs(RW=false),续期后没人告诉它的
nginx 重新握手。本 agent 让 HomeDash 自己闭环:签发 → 写数据卷 → reload。

## 实现
- deploy/cert-agent.sh:supervisord 新增 [program:cert-agent]
  - 域名从 /app/data/gateway.yml 的 host 提取(或 CERT_DOMAINS 显式指定)
  - acme.sh 装进**数据卷** /app/data/acme(容器重建不丢续期状态)
  - install-cert 到 /app/data/certs/,--reloadcmd 设为 nginx -t && nginx -s reload
  - 续期后比对公钥指纹,变了才 reload(不盲目 reload)
  - 未配置 CERT_DNS_CRED 时安静退出,行为与现在完全一致
- 证书目录挂载去掉 :ro(自签需要写;此前只读是刻意的保护)
- zd-host.sh 的 env 白名单加 CERT_ 前缀,否则升级时凭据传不进新容器
- install-host.sh 同步支持 CERT_DNS_CRED,避免两套脚本行为不一致
- 证书路径改为数据卷 /app/data/certs/(gateway.yml 指向它)

## 测试(沙箱 stub,非真实签发)
- 无凭据 → 安静退出、exit 0、不调 acme、不报错
- 有凭据 → 正确从 gateway.yml 提取 2 个域名、识别 dns_ali、
  签发参数正确、指纹变化后触发 reload

## 途中修掉自己的两个真 bug
① run_acme 里用 `set -- $DOMAINS` 改函数位置参数,dash 下与后续变量
   展开冲突
② LE_RELOAD 是命令串,直接当命令名执行 → "LE_RELOAD: not found",
   容器里 reload 会**静默失效**;改为函数 + eval

## 边界
只管 HomeDash 自己;introduce/sdk.homeagent 等仍走外部链路。
凭据只从环境变量读,不落盘不入库。
2026-10-04 08:02:41 +08:00
681393c8ec feat(icon): 补齐 PWA / 错误页 / 登录页图标
漏掉的四处(都实测确认过):
- PWA manifest 的主图标是 /web-icons/dashy-logo.png(512x512),以及
  dashy-pwa_{48,72,96,144,192,512}.png —— 全部仍是上游 Dashy 品牌,
  浏览器「安装到桌面/主屏」时用的就是这批。由 favicon.svg 重新派生。
- portal/404.html、500.html 没有任何 icon 标签(浏览器只能退化去找
  /favicon.ico)。实测两条路径都能取到图标,但仍显式声明更稳妥。
- 登录页模板(admin-server/auth.go)同样缺 icon 标签。

所有派生 PNG 都逐个解码验证(颜色数 111~1603,非空非纯色)。
2026-10-03 08:45:47 +08:00
7ebebbc3a9 fix(icon): 补 /icons/favicon.ico —— 标签栏无图标的真因
上一版只改 index.html 注入 /favicon.svg,实测仍不显示。用 MutationObserver
在真实浏览器里观察到:**Dashy 前端 JS 在运行时把 <link rel=icon> 改写成
/icons/favicon.ico**,服务端注入的标签被它覆盖;而镜像里从来不存在
/icons/ 目录 ⇒ 请求落到 try_files 兜底的 index.html,content-type 是
text/html,浏览器当无效图标忽略(于是表现为「图标消失」)。

静态改 index.html 治不了本,只能把这个路径补上:
Dockerfile 与 zd-host.sh 都 COPY favicon.{ico,svg} 到 /app/dashy/icons/。
已先在 106 上热修(只 cp 不重启,无闪断)恢复显示。

实测:/icons/favicon.ico → 200 image/x-icon,浏览器解码 16x16 成功;
apple-touch-icon 180x180 正常。
2026-10-03 08:43:35 +08:00
fe1c14b7ae chore(extension/firefox): 版本升 1.1.5(纸飞机图标) 2026-10-03 08:38:11 +08:00
e9097e5893 feat(icon): 纸飞机图标(站点 favicon + 扩展各尺寸)
站点首页此前显示的是 Dashy 上游的 logo —— index.html 里那几条
<link rel=icon href=/web-icons/favicon-*.png> 优先级高于我们的门户图标,
而扩展的 PNG 是占位图(128px 只有 9 种颜色、alpha 通道还是乱的)。

- portal/favicon.svg:纸飞机(圆角渐变底 + 白色折纸机头朝右上 + 两道尾迹),
  几何收在 8..56 安全区,避免贴边被圆角裁切
- 由该 SVG 派生 favicon.ico(16/32/48)、apple-touch-icon(180)、
  web-icons/favicon-{16,32,64}、扩展 icon{16,48,96,128}
- patch-index.py:删掉上游 icon link 并显式指向 /favicon.svg(幂等,
  重复执行 unchanged);上游 web-icons 那批也覆盖一份作双保险
- Dockerfile 与 zd-host.sh 都 COPY 这些图标进镜像
- 验证:nginx 沙箱实测 /favicon.svg、/web-icons/*、/apple-touch-icon.png 均 200;
  16px 下人眼仍可辨识为纸飞机
- .amo-upload-uuid(web-ext 生成的 UUID)改为忽略
2026-10-03 08:37:54 +08:00
63c588145b fix(extension): 同步失败时真实原因被吞成「失败: undefined」
background 的「未配置站点」分支只返回 {ok:false, reason:'not configured'},
而 options.js 只读 result.error || result.status —— 两个字段都没有,
于是模板插值出 undefined,用户看到「失败: undefined」,真实原因看不到。
实测服务端日志里没有任何 /api/history/sync 请求,确认请求根本没发出。

修:两版 options.js 改为 error || reason || status || '未知错误',
两版 background.js 的 reason 换成中文可操作提示
「未配置站点地址,请先填写并保存」。
firefox 版本升到 1.1.4(已签名的 1.1.3 内容已冻结,须重签)。
2026-10-03 08:28:26 +08:00
1d86e81d42 feat(extension): Firefox 包改为优先供给数据卷里的 AMO 已签名包
正式版 Firefox 要求扩展经 AMO 签名,未签名一律拒绝且报成「插件损坏」。
签名包不进镜像:它是 AMO 针对具体内容生成的产物,提交进仓库会与源码脱节
(改代码就得重新签名)且是二进制。放数据卷可跨部署存活,更新只覆盖一个文件。
数据卷没有时回落镜像内未签名包(开发/调试仍可用)。

实测三条路径(本地 nginx 沙箱,nginx -t + 真实请求):
  数据卷有签名包 → SIGNED;数据卷无 → 回落镜像内未签名包;两者都无 → 404。
注:用 root 而非 alias,因为 alias 配 try_files 会把 URI 再拼一次导致 404。
2026-10-03 08:19:17 +08:00
5fa35cd7ea chore(extension/firefox): 版本升到 1.1.3(AMO 已签名 1.1.3)
AMO 签名成功:自动安全审查通过,产出已签名 xpi
(META-INF/mozilla.rsa + cose.manifest + cose.sig 齐备,
addons-linter 复验 0 errors)。
1.1.2 是用户手动上传的那份,1.1.3 由 web-ext sign 走 API 凭据生成。
2026-10-03 08:16:34 +08:00
2c48f561e0 fix(extension/firefox): 补 data_collection_permissions —— AMO 新增强制项
AMO 自 2025-11-03 起要求新扩展声明数据收集,缺失直接拒收(lint 里是 notice,
上传时是硬错误)。按扩展实际行为如实声明:
- required: browsingActivity —— 扩展用 history 权限把访问过的 URL、页面标题、
  访问时间同步到用户自己的服务器(自建门户),属浏览活动数据
- optional: technicalAndInteraction —— 该项按规范只能放 optional
不涉及个人身份/位置/金融等,也不出售或传给第三方,故未列入。
web-ext lint 现为 0 errors / 0 notices / 0 warnings,版本升到 1.1.2。
2026-10-02 23:31:08 +08:00
9678c9e01b docs(extension): 安装页说明「未签名」拒绝的正解(用户实测踩到)
正式版 Firefox 在 about:addons 的「从文件安装」强制要求 Mozilla 签名,
自签 xpi 会报「附加组件未签名 / not verified」。唯一免签路径是
about:debugging#/runtime/this-firefox 的「临时加载附加组件」。

同时说明临时加载的代价:浏览器重启即失效,而这个扩展靠 alarms 每 30 秒
同步历史、必须常驻,所以要长期用得上必须走 AMO 签名发布。
2026-10-02 15:56:58 +08:00
e91f69cdec fix(extension/firefox): manifest 补 gecko.id —— 缺它火狐安装时报「插件损坏」
火狐安装 xpi 必须有 browser_specific_settings.gecko.id,缺失时 AMO/临时加载
都直接拒绝并把原因笼统地报成「the add-on is corrupt / 插件损坏」。
用户实测装不上,zip 本身 CRC 完好、文件齐全,根因就是这一项缺失。
顺手把 strict_min_version 定到 102(contentScripts.register 需要的最低线)。
2026-10-02 11:56:50 +08:00
c84461d828 fix(deploy): zd-host 增量构建重打扩展包(此前继承基镜像 3 周前的旧 zip/xpi)
实测踩坑:增量 Dockerfile 只 COPY install.html,navi-chrome.zip/navi-firefox.xpi
全部来自基镜像 jianf-navi:latest。v99 升级后容器里的 chrome 包仍是旧版
(e6b8f9bf ≠ 仓库 51347771),扩展的修复永远上不了线。
现从当前源码重打两个包并 COPY 进镜像。
2026-10-02 11:53:20 +08:00
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
b67b014d49 feat(conf): 示例配置对齐线上实例(jianf-glass 玻璃拟态主题)
- customCss 换成线上实际使用的版本(卡片 18px 圆角、白玻璃、分组 24px+blur20)
- cssThemes/dayTheme/nightTheme/statusCheck 同步
- 壁纸块由 admin-server 保存时动态注入,模板不预置(避免真实文件名入库)
2026-10-02 11:24:55 +08:00
495e4ee20e fix(admin): 壁纸注入补测试;测试夹具真实备案号换占位值;后台历史条数展示 2026-10-02 11:24:55 +08:00
85abe9f728 fix(extension): Chrome background 改为能力探测;README 纠正检测描述 2026-10-02 11:24:55 +08:00
292e77097d fix(history): 访问计数按设备去重 + 后台返回全量
- Entry 增加 DeviceVisits:同一 URL 多次/多设备上报取各设备最大值再求和,
  不再平方级放大(30 次访问曾被计成 465 次)
- handleGet 不再截断 100 条,返回 {items,total},后台显示当前条数
- 清理死代码(hostVisits/hostOf/visitCount/isCodeHostVal/dashySection)
- 守门测试:重复上报、多设备求和、陈旧数据、持久化、统计、门户文案、第 101 条可见性
2026-10-02 11:24:55 +08:00
b256a8d7f1 fix(favicon): 失败重试间隔不再挡住"缓存文件丢失后的重抓"
实测:删掉 item-icons 里三个被污染的图标后,rebuild 不再重抓,图标永久缺失
(只有重启容器才恢复)。根因:faviconWorker 在**每次**尝试后都写 faviconAttempted,
而 queueFavicon 会用 faviconRetryGap 挡住"近期尝试过"的 host —— 于是
cachedFavicon() 为空(文件确实不存在)也照样被跳过。

修法:成功时 delete(faviconAttempted, hostport),只给失败保留节流记录。
这样缓存文件被手工清理或数据卷恢复后,下一次 rebuild 就能立即重抓。
2026-09-13 09:02:50 +08:00
ca80ba2510 fix(gateway): auth 类路由重复写 /favicon.ico 导致 nginx -t 失败(v96 部署被回滚的真因)
v96 部署时脚本报"网关配置重新生成失败 → 回滚到 ui-v95"。根因:auth 类路由的 Upstream
是在 writeErrorPages 之后才赋值的,所以它先按"门户自己渲染"拿到了门户 favicon,
随后新的"favicon 直通上游"块又写了同一个 location → nginx 拒绝重复 location。

修法:直通块对 auth 类跳过(门户页/登录页继续用门户自己的图标)。
另加回归测试 TestNoDuplicateExactLocations:解析渲染结果,断言任一 server 块内
不得出现重复的 `location = X`;已用"临时移除守卫"做过反向对照,确认该测试真能抓到。
2026-09-13 08:58:41 +08:00
9c0d9f2bb5 fix(gateway): 被代理站点的 favicon 免登录直通上游(而非门户静态文件/登录墙)
承接上一提交:把门户 favicon 从代理块里去掉后暴露两个问题:
- requireLogin 的站点 /favicon.ico 变成 302(浏览器标签与图标抓取都取不到);
- 而 favicon 本来就不该要求登录。

改为:代理块显式写
    location = /favicon.ico  { auth_request off; proxy_pass <上游>; ... }
    location = /favicon.svg  { auth_request off; proxy_pass <上游>; ... }
(https/自签上游附带 proxy_ssl_verify off)。
效果:浏览器标签显示各站点自己的图标、无需登录;抓取端也因此能在未登录时取到真实图标。
门户页/登录页/默认 404 块仍提供门户自己的 favicon。测试断言同步改为"直通上游"语义。
2026-09-13 08:52:41 +08:00
3d1ed788f3 fix(gateway/favicon): 被代理域名的 /favicon.ico 被门户图标顶掉(图标全错的真因)
用户问"同时被路由的网站为什么图标不正确"。实测三处证据:
  ① gateway.conf 里**每个** server 块都有
       location = /favicon.ico { auth_request off; try_files $uri =404; }
     而 try_files 在门户自己的 root(/app/portal) 里找文件;
  ② 于是 https://pi|ittools|vikunja.jianfgit.xyz/favicon.ico 三者返回**同一个**
     门户图标(md5 80498a88…),而它们内部上游的真实图标各不相同
     (ittools dc36588f… / vikunja 6d9507b6… / pi cf3332ac…);
  ③ 导航图标抓取优先抓 /favicon.ico → pi/ittools/vikunja 存下了同一张 1091B 门户图标。

修法两处:
- admin-server/gateway.go:门户 favicon 的两个 location 只在"门户自己渲染"的
  server 块(kind=portal/auth、默认 404 块)里输出;代理到上游的块不再输出,
  让 /favicon.ico 正常走 location / 落到上游。新增断言测试。
- history-server/favicon.go:抓取改为**优先走内部上游**(读 gateway.yml 把对外域名
  映射到内部地址,带 Host 头、容忍自签证书),失败才回落对外地址 —— 这样受统一登录
  保护的站点也能拿到真实图标,且不再依赖门户的 favicon 行为。
2026-09-13 08:48:42 +08:00
30aff4ffd8 docs: 同步「常用服务 / 历史访问」的新规则(不再限制条数、历史含站内服务)
- 常用服务:从"点击次数前 5"改为"所有被点击过的导航项(按次数降序,不限条数)"
- 历史访问:不限条数,且不再排除站内服务(相当于"最近访问");并提示可用 history.json 的 mtime 判断扩展是否还在同步
- README 三处 + AGENTS 一处的过时说明一并更新
2026-09-13 08:39:00 +08:00
bcc05bd352 feat(nav): 历史访问不再排除站内服务(用户:尽最大可能显示)
用户要求:历史访问应尽最大可能显示,不要限制条目;并确认取消"排除已是导航项的站点"。

此前该组会跳过所有已是导航项的 host(避免与其它分组重复),副作用是**站内服务的真实
访问记录永远进不来**,这一组长期只有 1 条(外加插件安装入口)。现改为:整份浏览器历史
全部收录,按访问次数降序;首条仍是插件安装入口。条数上限此前已移除。

注:常用服务的候选集本就排除「历史访问」分组自身,所以站内服务进了历史访问后
不会反过来污染常用服务的计数与排序。
2026-09-13 08:36:07 +08:00
305eb27acc feat(nav): 「常用服务」与「历史访问」不再限制条目数
用户要求:这两个聚合分组应当尽最大可能显示,而不应限制条数。

- 常用服务:原先截断为 favTopN=5,现改为展示**所有被点击过**(visits>0)的导航项,
  仍按访问频次降序、同频次保持原序、按 host 去重。0 次点击的不算"常用",
  不塞进来(否则等同于把所有分组复制一份)。
- 历史访问:原先截断为 20 条(histItems >= 21 即 break),现改为全部非导航历史主机。
  首条仍是插件安装入口,排序仍按访问次数降序。
- 注释同步更新(rebuildDashySections 的 4 条说明与容量提示)。

数据实况(改动前):nav-visits.json 有 10 个 host 有计数,其中 8 个是导航项
→ 常用服务从 5 项变 8 项;history.json 去重主机仅 1 个 → 历史访问上限本就未触顶。
2026-09-13 08:29:57 +08:00
3d18fb59ce fix(ui): 登录页页脚被当纯文本显示(html/template 转义)+ 三处页脚统一
用户发现登录页出现 `<div class="navi-footer">…` 这串标记文字。

① 根因:auth.go 把 beianFooter() 的字符串塞进 Go html/template(map[string]string),
   模板自动转义整段 HTML → 页面上直接显示标记源码。
   修法:改用 map[string]any + template.HTML(beianFooter 内部已对备案号做 html.EscapeString,
   注入合法)。
② 登录页与门户页此前**都无视**后台的「页脚」自定义字段(后台提示语写的是"填写后以这里为准")。
   新增统一入口 siteFooter(info):自定义 footer 优先,否则按 icp/gongan/座右铭生成;
   pages.go(门户页)与 auth.go(登录页)都改走它。
③ 页脚座右铭此前只由 navi.js 在 Dashy 页客户端注入 → 登录页/门户页永远没有。
   新增 pageInfo 字段 motto(换行或 | 分行,留空用内置默认),
   footerMarkup() 生成时一并输出 .navi-quote 两行;navi.js 已有幂等判断不会重复注入。
   后台「站点信息」新增座右铭输入框,并登记进 /api/schema。
④ 登录页 / 门户页补页脚样式:左列两行备案号、右列两行座右铭;窄屏退化为居中单列。
2026-09-13 07:56:38 +08:00
ba22d3a1de docs: 记录极简页分组切换手势(README)+ 交互类改动的真实输入验证要求(AGENTS)
README 新增「极简页分组切换(点击 / 滑动 / 键盘)」:四种输入、判定阈值、clamp 边界、
只对页框内元素做动画、页签行跟随(含实测数字 1306 > 828)、防误触、以及
"接 Dashy DOM 契约、类名变化会静默失效 + data-navi-swipe 自检"这一限制。
AGENTS §5 补充:交互类改动必须用真实输入(CDP 触摸注入/真实鼠标/wheel deltaX)验证,
断言"切换真的发生"而非"监听器已挂载"——本项目实测踩过事件全到达但被自建拦截器吞掉的坑。
2026-09-12 22:10:05 +08:00
86aced0c4a fix(ui): 滑动切换只淡出页框内元素(页签行不动)+ 页签行跟随选中项
用户反馈两点,均已实测定位:
① 整框(含顶部页签行)跟着淡出淡入 —— 不该动页签。
   原实现把 opacity 打在 .item-group-container 上(它同时包含页签行与内容)。
   改为只对 .minimal-section-inner.selected > *(.section-items + .minimal-widget-wrap)
   做 keyframes 淡出/淡入;框体与页签行保持不动。
   用 keyframes 而非 transition:这些子元素自带 `transition: all`,改 transition 会
   覆盖它们原有的 hover 过渡。
② 页签行不联动 —— 实测 .minimal-headings-row 可横向滚动
   (scrollWidth 1306 > clientWidth 828)但 scrollLeft 恒为 0,
   切到第 7/8 个分组时选中页签落在视口外(x=1374..1532 > 1280),
   用户看不出切换发生。新增 swipeRevealTab() 把选中项滚进可视区(居中,reduced-motion 下瞬时),
   手势切换与手动点页签都会触发。
另:边界回弹提示也改为只动页框内容,不再带动页签行。
2026-09-12 22:06:26 +08:00
f27f46099c fix(ui): 修正滑动手势的两个确凿缺陷(触屏不切换 / 触控板方向反)
v89 实测 12/16,且已测出事件确实到达(触摸 touch+pointer 各 7 个、wheel deltaX=40 共 4 次),
证明不是"事件没到",而是逻辑把结果吃掉了:

① 防误触拦截器把自己吞了:滑动后 400ms 内抑制 click(防误开卡片),
   而切换手势发的正是 tabs[j].click() → 被自己拦掉 → 表现为"滑了没反应"。
   修法:程序化切换 click 期间置 swipeSelfClick 放行,并清掉本次抑制窗口。
② 触控板方向反了:两指向左滑时浏览器给 deltaX > 0(内容右移),
   原实现映射成"上一个",在第一个分组会被 clamp → 看起来完全没反应。
   修法:deltaX > 0 → 下一个分组。
2026-09-12 21:59:11 +08:00
3deffc1a77 feat(ui): 极简页支持左右滑动切换分组(触屏/触控板/鼠标拖拽)
需求:极简页的内容框此前只有竖向滚动手势,加入横滑切换"页面"(分组)。

实现(web/navi.js 新增 bindTabSwipe + web/navi.css v89):
 · 契约:.minimal-headings-row > .minimal-section-heading.headings(当前项 .selected),
   切换 = 对该元素派发真实 click(实测有效,不触碰 Dashy 内部状态)。
 · 三种输入:pointer 事件覆盖触屏与鼠标拖拽;wheel 的 deltaX 累积覆盖触控板双指横滑。
 · 判定:|dx| >= 56 且 |dx| > 1.5|dy| 才切换;先判 |dy| > |dx| 则直接让给竖向滚动。
 · 边界:clamp —— 到头不动,给一次轻微回弹提示(prefers-reduced-motion 时改为淡闪)。
 · 动画:纯淡入淡出(不位移),并遵守 prefers-reduced-motion。
 · 防误触:横拖超过 20px 后吞掉紧随的一次 click,避免误开卡片链接。
 · 不抢横滑:手势起点若落在可横向滚动的容器内、或输入框内,直接放行。
 · 可访问性:键盘 ←/→ 也能切换;touch-action 用 pan-y pinch-zoom(保留双指缩放)。
 · 自检:匹配不到标签时在 <html> 上置 data-navi-swipe="unavailable"(不刷控制台)。
2026-09-12 21:54:35 +08:00
6062c9cdc2 fix(ui): 工作空间页脚的 2×2 网格未生效 —— 通用选择器特异性不足
v87 实测:/workspace 页脚 display 仍是 flex(四段挤在一行,行数=1)。
原因:网格规则里的通用选择器 html[data-theme] .navi-footer 为 (0,2,1),
低于 EOF 处 html[data-theme] body .navi-footer 的 (0,2,2),
而工作空间的页脚父级是 .navi-footer-host(页面里没有 <footer> 元素),
前两条结构选择器都不匹配 → 网格规则整条落败。
修法:补两条结构路径 body > .navi-footer-host > .navi-footer((0,3,2))
与 body .navi-footer-host > .navi-footer。
2026-09-12 20:31:55 +08:00
d327864348 fix(ui): 清除 v80 里遗留的全部"页脚 fixed 浮层"规则(共 5 处)
v80 的 CSS 含有 v77/矮视口/EOF 覆盖等 5 处把 .navi-footer-host 或
footer > .navi-footer 设为 position:fixed 的规则,它们位于文件更靠后、
特异性更高,会把 v87 的 static 改动全部盖掉(用户看到的"又悬浮了")。
本版在源码处逐条改为静态布局行(flex:0 0 auto + width:100%),
并消除 26px 高度上限;同时把首页 #dashy > footer 的底部内边距清零。
2026-09-12 20:28:29 +08:00
4c61952cf1 fix(ui): 页脚改为布局中的最后一行(去掉悬浮),三视图结构一致
用户反馈"怎么又变成悬浮在底部了?而且好丑" —— 确认 position:fixed 浮层不可接受。
本版在源码处把页脚从浮层改为 body 纵向 flex 的最后一"行":
 - minimal/workspace 的 .navi-footer-host: position:static + flex:0 0 auto
   (内容链 flex:1 min-height:0,内部自行滚动 → 页脚常驻视口底部但不覆盖内容)
 - workspace 页脚不再参与 0.55 半透明渐隐、不再 translateY 藏匿
 - 删除为"固定页脚"预留的 margin/padding(26px / 40px / 96px)
 - 恢复 v80 的 2×2 网格版式与文字加深(上一版误删)

判据:position 必须为 static(非悬浮)+ 四段文字完整在视口内 + 页面不出现滚动条。
2026-09-12 20:26:51 +08:00
f9d539ad42 fix(ui): 撤除 v77–v85 页脚定位实验,回到 v76 的文档流形态
用户反馈"怎么又变成悬浮在底部了?而且好丑",确认 fixed 不符合预期。
v77–v85 这一串在 fixed/static 之间反复摇摆,且 v81 用 :not(:has(...))
造出 (0,11,x) 高特异性 static 钉子,使后续所有 position 修改全部失效,
导致 /workspace 页脚始终被撑出视口(bottom=740 > 720)。

本版一次性删除这些实验段,恢复为:页脚静态排在内容流末尾(v76 形态),
保留用户已确认的内容类改动:
 - v79/v80 两列两行版式(左两行备案 / 右两行座右铭)
 - v78 文字加深(备案 7.6:1、一言 4.8:1)
 - 座右铭固定文案(不再是随机的 hitokoto)
 - 悬浮按钮无灰底、切换视图菜单可点(v74/v75,属独立修复,不动)
2026-09-12 20:19:14 +08:00
e9fab61a2e fix(ui): 删除 v81 的高特异性 static 钉子,页脚统一固定贴底
真凶(打印命中规则并按特异性排序后确认):
 v81 引入了
   html[data-theme] body:has(.minimal-home) .navi-footer-host,
   html[data-theme] body:not(:has(.minimal-home)) .navi-footer-host { position: static !important }
 特异性 (0,11,4)~(0,11,5),压过此后所有 fixed 规则 (0,4,4),
 使 /workspace 页脚永远停在文档流位置:bottom=737 > 视口 720(第二行掉出视口)、
 页面被撑出滚动条。用户实测症状:"工作区整体可以滑动""底栏只显示备案号""mini 底部看不到"。

本版:整段删除 v81(文档流方案),并以简单选择器统一
 .navi-footer-host / footer > .navi-footer 为 position:fixed; bottom:0,
 内容容器 #app 留 44px 净空避免遮挡。

教训:用 :not(:has(...)) 构造的"通用覆盖"会形成无法覆盖的高特异性钉子。
2026-09-12 20:00:47 +08:00
c77a1795a3 fix(ui): 页脚改回固定贴底(文档流方案会让页脚掉出视口)+ 内容留净空
用户实测反馈三点,经真实视口复现确认:
① /workspace 不滚动时页脚 bottom=737 > 视口 720 → 第二行(豫公网安备/无谓前路之沧桑)
   落在视口外,且页面被撑高 → '工作区整体能滑动'、'底栏只显示备案号';
② 文档流方案(v81-v83)在视口较矮或内容较多时页脚必然掉出视口,
   用户评价'不如上一版'—— 因为上一版是固定层,任何视口都可见;
③ 我此前用'距文档底=0px'当成功判据,但那只证明它是文档最后一项,
   **不能证明它在视口内可见** —— 判据再次用错。

本版:恢复 position:fixed/bottom:0(同 v76 做法),
并成对给内容容器留出 44px 底部净空避免遮挡;
固定层不参与文档高度,避免页面被撑出滚动条。
2026-09-12 19:53:47 +08:00
cb82a938f6 fix(ui): 页脚贴住页面最底 —— 消除 #app 吞掉剩余空间
实测(滚到最底时的 host.bottom / 文档高):
  /         720 / 720  → 0px ✓
  /minimal  717 / 720  → 余 3px
  /workspace 674 / 720 → **余 46px**
根因:body 为 display:flex/column,其中 #app 是 flex:1 1 auto,
吃掉全部剩余高度,使页脚只能排在 #app 之后的中途位置。
修法:body 直接含 .navi-footer-host 时令 #app 改为 flex:0 0 auto(不再伸展),
页脚用 margin-top:auto 落到最底;并清掉 minimal 内容链末尾的余量。
2026-09-12 19:38:12 +08:00
b017c3a4b8 fix(ui): 三视图页脚真正落到页面最底 + workspace 的 grid 生效
父链实测(v81,三个视图结构不同):
  /          .navi-footer[grid] < FOOTER[pb:8px] < #dashy < #app < BODY
  /minimal   .navi-footer[grid] < .navi-footer-host[flex] < BODY
  /workspace .navi-footer[flex ✗] < .navi-footer-host[flex] < BODY
问题:① / 多出 8px 空隙(来自 <footer> 的 padding-bottom);
     ② /workspace 页脚停在 y=624,下方仍有 80px 空;
     ③ /workspace 页脚未被 grid 规则命中(写成 flex),四子项挤在同一行。
改动:
 - 补 body > .navi-footer-host > .navi-footer 的 grid 规则与 2×2 显式定位
   (覆盖 host 直接挂 body 的视图);
 - body > .navi-footer-host 用 margin-top:auto 推到页面最底、不留空;
 - 首页清掉 #dashy > footer 的 padding-bottom(8px 空隙来源)。
选择器均按实测父链结构编写,非猜测。
2026-09-12 19:10:53 +08:00
a2bead912f fix(ui): 页脚回归文档流(用户要求:位于页面底部,而非悬浮于底部)
实测 v80:三视图 .navi-footer-host/.navi-footer 均 position:fixed + z-index:3,
是永远贴住视口、盖在内容之上的悬浮层(/workspace 还因此底部出血 8px)。
用户明确要求页脚应是**页面底部的内容**,滚到最底才出现,不悬在内容上。

改动:host 与 .navi-footer 一律 position:static(回到正常文档流),
取消 z-index、取消为固定页脚预留的 margin-bottom(26/40px),
pointer-events 恢复 auto(不再是浮层无需穿透),
透明/无边框/无模糊保持不变。
2026-09-12 19:00:20 +08:00
e3770cd10a fix(ui): 修 v79 页脚三行问题(隐藏分隔符仍占网格格位)
实测 v79:grid 算成 3 行(y=671/688/705),左列多出一行。
原因:.navi-footer-sep 虽设 display:none,但被更高特异性规则压过,
仍占据 grid 第 2 行格位,把「豫公网安备」挤到第 3 行。
修法:同级别选择器彻底隐藏分隔符(display:none + 绝对定位移出流外),
并用 grid-column/grid-row 显式定位:左列 1/2 行 = 两个备案号,
右列 1/2 行 = 两句座右铭;grid-auto-rows:0 防止隐式行撑高。
2026-09-12 18:54:52 +08:00
38467a1724 feat(ui): 页脚改为「左两行备案 / 右两行一言」,一言换成用户座右铭
用户要求:
① 左侧两行 = 两个备案号;右侧两行 = 一言两句;
② 一言不用随机 API(原 hitokoto + 随机兜底,观感中二且不可控),
   改为固定座右铭「不念往昔之繁华 / 无谓前路之沧桑」。

实现:
- navi.js:installDailyQuote 重写为固定文案,移除 fetch(hitokoto) 与随机 FALLBACK。
- navi.css:.navi-footer 改 CSS Grid(2 列 × 2 行 + grid-auto-flow:column),
  使「备案A / 备案B」落入左列两行、「quote1 / quote2」落入右列两行,
  一言列右对齐;隐藏原分隔符;内容框底部预留提升到 40px 以容纳两行页脚。
2026-09-12 18:51:19 +08:00
8cc94e91f0 fix(ui): 页脚文字加深 —— 备案号 #94a3b8→#475569、一言 #b6c2d2→#64748b
用户反馈「文字颜色过浅」。实测前景色与亮度:
  备案号/分隔符 rgb(148,163,184)=#94a3b8(亮度 160)
  每日一言     rgb(182,194,210)=#b6c2d2(亮度 187)
均为 11px 小字 + 透明背景(叠在浅色页面底图上),对比度偏低、观感发虚。
改为中性 slate:#475569(对白底约 8.9:1)、一言 #64748b;
并去掉一言的斜体(小字号下进一步降低可读性);hover 加深为 #1e293b + 下划线。
深色主题改用 #cbd5e1 / #94a3b8 保持可读。
2026-09-12 18:35:56 +08:00
175788e4eb fix(ui): 首页/工作台页脚对齐 —— 首页固定贴底、workspace 不再出血
复核发现(同一套判据,1280×720):
① /:.navi-footer 位于 Dashy <footer> 内、position:static、bottomPx=1092,
   页脚在文档流里 → 必须滚动才可见('备案号各视图可见'被破坏);
   且该视图无 .navi-footer-host 包裹层,结构与其他视图不一致。
② /workspace:.navi-footer-host 虽 fixed bottom:0,但 bottomPx=733 > 视口 720,
   底部约 13px 出血。

修法:
- 首页:对 footer > .navi-footer 直接固定贴底(flex row + 两端分布 + 透明一行),
  不改动 DOM 结构;
- workspace:显式 bottom:0 并限制 footer 高度上限 20px。
两视图的悬浮按钮经复核已是 idle α=0、无阴影(v75 已修),菜单在 / 上可点且真跳转。
2026-09-12 18:20:15 +08:00