Compare commits

...
3 Commits
Author SHA1 Message Date
tamakyi 3077250a82 记忆:完整回归结论 + 验收必须在安静环境跑
- 记下本次完整验证链:verify.sh 348 例 0 skip、fog 包 -race 22 例、JS 34/34、320/390 溢出全 0。
- ⚠️ 新纪律(details 三十二):`db.Open` 的 ping 写死 10s,库被压着时
  `TestOpenCreatesMissingDatabase` 会以「建库后首次连接超时」失败 —— 实测与 CDP 审计并发时 10.02s 超时,
  停掉审计单独跑 4.1s 就过。这条测试失败还会把临时库 `rond_test_autocreate_*` 留在库里
  (清库的 defer 在 Open 成功之后才注册),要手动 DROP 清掉。
- 记下「原有代码受影响」专项检查的方法与结果(自定义属性 0 处用了没定义、模板函数无真孤儿、
  19 个模板 diff 只有预期改动;`.g3/.g4` 是 HEAD 里的既有死类)。
2026-10-09 17:02:05 +08:00
tamakyi ab7ccd22ce 前端改造:设计令牌 + 卡片层级 + 图表可读性 + 类型色收口 + 窄屏导航
先出方案(frontend-plan-2026-10-09.md)再动手,四批按顺序落地。方法上先存基线、再用同一份
探针在改动前后逐值 diff —— 这批里有 6 处问题只有「量」才看得见,看截图全是正常的。

A 设计令牌(app.css 为主)
- 字阶收敛到 6 档 --fs-xs/sm/md/lg/xl/2xl = 11/13/15/21/26/34px。原来 app.css 有 19 种字号、
  模板里另有 54 处内联字号,光 12~15px 之间就挤了 4 档 —— 层级挤不出来,只能靠加粗硬顶。
- 两个「字外档」不是漏的:--fs-control: 16px(窄屏表单控件必须 ≥16px,否则 iOS 聚焦放大整页,
  属功能约束);--glyph-*(✕/箭头/空态图标按控件尺寸定,硬塞进字阶只会把按钮撑坏)。
- 字重收敛到 400/500/600(删掉 650 与 700)。
- 圆角 4 档 --r-*、阴影 3 档 --sh-1/2/3(暗色整套覆盖)、间距 --sp-1..5、
  卡片内边距单列 --card-pad(与折叠卡片标题行的负 margin 绑在一起,改一个即改两处)。
- 门禁 TestNoBareFontSizePx:静态扫 app.css 与全部模板,出现 font-size: <数字>px 即红。

B 卡片分主次
- .card--hero / .card(默认)/ .card--quiet 三档。quiet 走「页面底色 + 细边 + 无阴影」——
  第一版拿 --bg-2 当底色,实测它与页面底 #f6f7f8 只差 4/255,看不出边界、像破了个洞。
- 首屏 KPI 从「4×2 等重白盒」改成 .kpi-lead(主数 + 一行上下文)+ 5 个常规指标,8 个数字一个没少。

C 图表说人话
- 月度分布:柱底加基线;峰值柱不透明拉满、柱顶标次数(.pv),副标题里不再重复写「最高 N 次」。
- 一天中的分布:把「0/6/12/18 四个数字」换成 6 小时一段的标尺(深夜/上午/下午/晚上),
  用同一套 24 列网格,段边界与柱子严格对齐。
- 首页月度图同样加峰值锚点(给 HomeData 补 MaxMonthly,与统计页同口径 maxOf)。
- 空态没动:查下来已经统一(8 页共用 emptystate、局部走 .empty)。

D1 类型色语义化(结论与计划不同,按实测改了)
- 先算对比度:白底上 yellow 1.87 / mint 2.12 / orange 2.20 / green 2.22 / cyan 2.54 / teal 2.57,
  6 个色连图形对象 3:1 都到不了;暗底只有 indigo 2.90 不够。
- 所以**没有**把类型色用到 chips / 表格类型列 / KPI 强调 —— 那几处要的是文字色,只会更难读。
  这也解释了全站为什么一直只用 8px 小圆点做身份标记。浅色那 6 个色一个没动:
  它们是手机端与地图上的同一套身份色,改暗会破坏对应关系。
- 实际做的:色值收口成 --cat-* / --cat-*-soft(26 个变量);模板 colorof → csscolor / csssoft(19 处);
  暗色只提亮 indigo(2.90 → 3.55:1);colorOf 成了死代码,删掉。
- ⚠️ 返回值必须是 template.CSS:html/template 的 CSS 过滤器不认识 var(),普通 string 会被静默
  换成 ZgotmplZ —— 页面上所有圆点当场变透明,而且不报错、模板测试照过。挂了它等于关掉那道过滤,
  所以数据侧色值先过严格校验(只有 # + 6 位 hex 放行),否则 #"><img… 能逃出 style 属性。
  守卫 TestCSSColorSurvivesTemplateSanitizer 把两个方向都钉住(做过变异验证)。

D2 窄屏导航
- 改前 ≤620px 靠横向滚动兜底:390 溢 63px、320 溢 133px,「统计」被切成半个字紧贴后面的按钮,
  渐隐提示实际看不出来。现在链接本体抽成 {{define "navitems"}} 渲染两遍:桌面 .nav-links 平铺、
  窄屏 .nav-menu(原生 <details>,不靠 JS 也能开合);JS 只补点外面 / Esc / 点链接后收起。
- ⚠️ 顶栏 --z-nav 抬到 1200:菜单是 .nav 的子元素、自己抬不动层叠上下文,不抬就会被地图页的
  --z-map-overlay(1100) 盖住。已在 /map 上用 elementFromPoint 实测。
- 守卫 TestNarrowNavCollapsesToMenu 钉「CSS 两半 + 模板两个类 + 渲染两遍」的成对关系。

途中发现并一并修掉的(都只有量 DOM 才看得见)
- Leaflet 的 .leaflet-container 字号是 0.75rem、跟着根字号漂:根 14 → 13px 后地图署名/比例尺
  掉到 9.8px(低于本站 11px 下限)、弹窗正文也跟着缩。已按自己的字阶钉死。
- h1..h4 原来只设了字重与 margin、没设字号,靠浏览器默认的 2em/1.5em/1.17em —— 同样跟着根字号漂:
  /stats 那两个 h2 从 21px 变成 19.5px,比同页卡片标题大一圈。已显式给出字号。
- <b>/<strong> 浏览器默认 700,比全站最高层级(标题 600)还重,已钉成 600。

回归
- ./scripts/verify.sh:PASS=348 FAIL=0 SKIP=0
- internal/fog 加 -race:PASS=22 FAIL=0 SKIP=0
- JS 单测 34/34
- 移动端 320 / 390 溢出全 0(改前 /admin 在 320 还溢 34px);桌面 1440 与改前一致(−15 是滚动条宽)
- 全站 7 个页面都没有低于 11px 的文字

说明:A/B 与 C/D 本想拆两笔提交,但 app.css / layout.html / view.go 里两批改动交织,按文件拆不开、
按 hunk 拆又容易做出「中间那笔编译不过」的历史,所以合成一笔、在消息里分段。
2026-10-09 17:01:08 +08:00
tamakyi d4e4257a23 启动模板自检 + 站长导航放行 + 统计脚本关口收紧
排查:/admin/settings 渲染到一半就断
- 现象:页面在「统计脚本」那张卡片处截断,没有保存按钮、没有 </form></html>,而 HTTP 仍是 200。
- 根因:跑的是旧二进制(改动前编译),磁盘上的模板却是新的。debug=true 下 pageSet()
  每次渲染都从磁盘重解析模板,于是新 settings.html 引用了旧 setting.Settings 里没有的
  TrackEnabled —— 执行期报错,而 render 只把错误写进日志,客户端只看到半张页面。
- 判据:strings <二进制> | grep -c cleanTrackScript(旧 0 / 新 2)。别用 strings | grep
  TrackScript —— config 里本来就有这个名字,旧二进制也命中,是个假信号。

启动自检(防复发)
- template_smoke.go:pageProbe 给 24 个页面各配一份最小可用数据,New() 里全部渲染一遍,
  任一页失败就拒绝启动。pageProbe 与 pageFiles 双向校验,新加页面必须补探针,
  漏配按失败处理(不是省事)。
- smokeOne 用 recover 兜住方法里的 panic,报「哪一页坏了」而不是崩栈。
- 实测:往 settings.html 插一个不存在的字段 → 启动即报
  「模板自检未通过…admin/settings: can't evaluate field …」,退出码 1。
- 门禁 template_smoke_test.go:一条跑全量自检,一条变异验证(探针换成字段更少的类型
  必须报错且点名该页)。

站长导航被页面开关误伤
- guardPage 只拦访客(站长始终能访问全部公开页),但导航写的是 {{if .Settings.HasPage "x"}},
  没有 IsAdmin 分支 ⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
  改成 {{if or .IsAdmin (.Settings.HasPage "x")}}(map/places/timeline/stats/report 五处)。
- 门禁 nav_visibility_test.go:三向不变量(站长仍有全部入口 / 访客一个都没有 /
  全开放时访客也看得到),做过变异验证。
- 访客侧「只有主页」是按后台配置应有的状态,没有动数据。

统计脚本关口收紧(cleanTrackScript)
- src= 只在 <script …> 开标签内部找。原来对整段做 strings.Index,脚本体里一句
  `img.src=u` 就被命中、整段被判危险而清空 —— 页面照样提示「设置已保存」,站长看不出没挂上。
- 开闭标签从「只拒 shut > open」改成严格配平:末尾未闭合的 <script> 会把页面收尾整段吞进脚本。
- 不再静默:签名改成 (script, warn),说明码经 ?warn= 回显成人话;判为危险时整段不采纳、
  库里原脚本与开关都不动(把站长手上好用的脚本一并清掉更糟)。
- 门禁 TestCleanTrackScript 重写:逐条边界 + 定义性不变量(采纳了非空结果时,
  里面开闭标签数必定相等)+ TestTrackWarnMessage(未识别码不得回显)。变异验证做过。
- 文案订正:settings.html 的「conf/app.ini 的 track_script 会作为这里的默认值」有歧义 ——
  它不回填文本框,而是「这个框留空时、注入那一刻才回落到 ini」。

其余
- bin/ 目录删除(15 个 exe / 412MB),二进制只留仓库根 rond.exe;
  技能 rond-web-verify 与 memory details 十八里 bin/rond.exe 的写法同步改掉。
- 验收:./scripts/verify.sh 全绿且 0 skip(PASS=346 FAIL=0 SKIP=0)。
2026-10-09 11:06:41 +08:00
38 changed files with 1596 additions and 355 deletions
+219
View File
@@ -0,0 +1,219 @@
# 2026-10-09
## 排查:/admin/settings 报错(旧二进制 + 新模板)
**现象**:「统计脚本做成后台开关」那次提交(4015ddf)之后,访问 /admin/settings 报错。
**根因(已实测复现)**:跑的是**旧二进制**(根目录 `rond.exe`,10-08 18:06 编译,早于 18:27 的提交),
磁盘上的模板却是新的。`debug=true` 下 `pageSet()` 每次渲染都从磁盘重解析模板,
于是新 `settings.html:148` 引用了旧 `setting.Settings` 结构体里没有的 `TrackEnabled` → 执行期报错:
```
渲染 admin/settings 失败: template: settings.html:148:85: executing "content"
at <.Settings.TrackEnabled>: can't evaluate field TrackEnabled in type setting.Settings
```
`render` 是流式直写 ⇒ header 已发(HTTP 200)但表单在「统计脚本」卡片处截断,
没保存按钮、没 `</form></html>`。这就是用户看到的「报错」。
**判据**:`strings <二进制> | grep -c cleanTrackScript` —— 旧根二进制 0,新编译的 2。
(注意别用 `strings | grep TrackScript` 判定,config 里本来就有这个名字,旧二进制也命中。)
**修复**:`go build -o rond.exe .`(+ `bin/rond.exe`),重启。旧二进制备份到
`bin/rond.exe.old-20261008-stale`。修后 /admin/settings 完整(19642B,含 `</html>`/保存按钮)。
**教训**:Go 结构与模板字段是成对改的,`debug=true` 的热加载只覆盖模板 —— 改 Go 必须重编译重启,
否则就是「新模板 + 旧结构 = 执行期报错」。想根治可在启动时把所有页面各渲染一遍做冒烟(现在没有)。
## 其他发现(未改代码)
1. **P1 待确认**:`settings` 行 `pages=null`(写入时间 2026-10-08 18:14:05)⇒ 访客侧
`/map /places /timeline /stats /report` 全部 404、导航只剩 1 个链接。代码语义是
「全不选 = 访客只能看主页」,属合法状态,像是测 404 分档时留下的。**没动数据,等确认**。
2. **P2**:`cleanTrackScript` 的 `src=` 检查是**全文 `strings.Index`**,会误伤脚本体内的
`src=`。实测:`<script>var i=new Image();i.src=u;</script>` → 返回 `""`(整段被静默清空),
而页面仍提示「设置已保存」⇒ 静默失效。建议只在标签属性里查 src,或把「被清空」回传前端显式提示。
3. **P2**:开闭标签只拒 `shut > open`;末尾未闭合 `<script>`(`open > shut`)会吞掉后面内容。
实测 `<script>x</script><script>y` 与 `<script>y` 都被放行。建议改成要求 `open == shut`。
4. **运维**:根 `rond.exe` 与 `bin/rond.exe` 两份二进制容易走偏(这次根目录那份就是旧的)。
考虑只保留 `bin/`,或在启动日志打印构建时间便于比对。
**回归**:`go vet` 干净、`gofmt -l` 空、JS 单测 34/34、`go test ./...` 13 包全 ok 0 FAIL。
(注:`internal/web` 里 110 例是 DB 门控的,不开 `ROND_TEST_CONF`/`ROND_SRC` 会 skip,不算真跑过。)
---
## 用户确认后的收尾(四件)
用户反馈:「管理员访问也只剩主页,这好像不对」+ P2 按建议修 + 二进制只留根目录 + 防复发一并做。
### 1. P1 更正:不是配置问题,是导航缺 IsAdmin 分支
上面第 1 条判断得不完整。实测:站长访问 /map /places /timeline /stats /report **全是 200**
(`guardPage` 只拦访客),但导航里只有「主页」⇒ `layout.html` 的页面显隐写的是
`{{if .Settings.HasPage "x"}}`,**没有 IsAdmin 分支**,与 `guardPage` 口径不一致。
改成 `{{if or .IsAdmin (.Settings.HasPage "x")}}` 五处。门禁 `nav_visibility_test.go`
(三向不变量 + 变异验证)。**访客侧的 pages=null 没动**(仍是「只对访客关」的配置状态)。
### 2. P2 修复:`cleanTrackScript` 重写
- `src=` 只在 `<script …>` **开标签内部**找(原来对整段 `strings.Index`,`i.src=u` 被误伤清空)。
- 开闭标签从「只拒 `shut > open`」改成**严格配平**(末尾未闭合的开标签原来会被放行)。
- 签名改 `(script, warn string)`,说明码经 `?warn=` 回显成人话;判危险时**整段不采纳、
库里原脚本与开关不动**。
- 实测(测试实例 8081 / `rond_fog_test`,用完已还原):`i.src=u` 现在原样落库;
不配平 → `?warn=unbalanced` 且库值不变;`javascript:` → `?warn=badsrc` 且库值不变;
5000 字符 → `?warn=truncated` 且落库 4096。注入范围精确复核:只在公开页(/、/map),
后台与登录页 0。
### 3. `bin/` 删除
15 个 exe、412MB 全删;只保留根 `rond.exe`。技能 `rond-web-verify` 与 details 十八的
`bin/rond.exe` 写法已同步改掉。
### 4. 防复发:启动自检 `smokeRender`
`template_smoke.go`:`pageProbe`(24 个页面各一份最小可用数据,键与 `pageFiles` 双向校验)
+ `New()` 里全量渲染一遍,失败即 `log.Fatalf` 拒启。实测:往 settings.html 插一个不存在的
字段 → 启动报 `模板自检未通过…admin/settings: can't evaluate field …`,退出码 1。
门禁 `template_smoke_test.go`(含变异验证:把探针换成字段更少的类型必须报错)。
### 5. 用户裁决 + 文案订正
- **访客侧 `pages=null` 不动**:用户明确说「访客那一侧是根据我在后台配置的显示范围公开的,
倒不如说原来默认就是这样才对」⇒ 那是**预期配置**,不是故障。已把这个结论写进
details 四十一,免得后续会话又去「修」数据。
- **settings.html 的统计脚本文案订正**(P3):原文两处不准 ——
①「多一个 `</script>` 会被判为粘贴错误」漏了「少一个也会拒」(判据已改成严格配平);
②「`conf/app.ini` 的 `track_script` 会作为这里的默认值」有歧义:它**不会回填文本框**,
而是「这个框留空时,注入时回落到 ini 的值」。已改成准确说法。
实测热加载生效(debug=true,无需重编),页面 19996B、含 `</html>`,旧文案 0 命中。
- 验收:官方 `./scripts/verify.sh` → **PASS=346 FAIL=0 SKIP=0**(改动前那次全量;
之后只动了这一个模板文案,重跑了全部模板门禁与 `TestSmokeRender*`,全绿)。
## 提交 + 前端现代化方案
**提交**:`d4e4257`「启动模板自检 + 站长导航放行 + 统计脚本关口收紧」(12 文件,+564/−75)。
⚠️ **`shots/` 里的截图是旧状态,做视觉判断前必须现场重截**:`mobile-home.png` 还显示顶栏
折成 3 行、右按钮竖排,实测现在 390 下品牌名只留 🐾、按钮「后台」、`navH=51` 且不溢出。
差点拿旧图当现状写进方案。
**现状实测(CDP,真实视口)**:
- 卡片:17 个盒子(`.card`/`.kpi`/`.filters-card`)**只有 2 种样式指纹** ⇒ 层级扁平。
- 字阶 8 档(10/12/13/14/15/18/21/23),10–15px 挤了 5 档;字重 400/500/600/**650**。
- 正文色只有 4 种;类型色 10 种只用在圆点与柱子,没进界面语义层。
- 站长导航 `scrollWidth=277`:430→254(溢 23)、390→214(溢 63)、320→144(溢 133);
390 下「统计」被切成半个字紧贴「后台」,`.overflow-right` 的渐隐看不见。
- 已有优点别改坏:KPI 已是 `tabular-nums`、`--text-3` 按 AA 调过、暗色模式已有 3 段 media。
**产出**:`frontend-plan-2026-10-09.md`(仓库根,与 `audit-2026-09-21.md` 并列)。
四批:A 设计令牌收敛(字阶砍到 6 档、字重 3 档、间距/圆角/阴影令牌)→ B 卡片分 hero/常规/次级
+ 首屏 1 个大数 → C 手写图表加基线/峰值/轴标(不上图表库)→ D 类型色语义化 + 窄屏导航。
**明确不做**:前端框架/构建链、图表库、信息架构重构。建议先做 A+B(约 1.5 天,纯 CSS + 少量 class)。
方案**未改任何代码**,等选批次。
---
## 落地:A + B 批次(用户选了「先按 A-B 去做」)
**做法**:先跑基线存档(`ab.js` 探针 + `audit/run_all.py --mobile 390` / `--overflow 1440`),
再改,再用同一份探针跑一遍逐值 diff。CSS 改动量 187+/150−,另有 19 个模板共 54 处内联字号并档。
**关键结果**(同一探针,改动前 → 后):
- `/` 的 (字号,字重) 组合 390:**13 → 10**、1440:**15 → 10**;`/places` 10 → **5**;
`/timeline` 10 → **6**;`/stats` 15 → **9**;`/admin/settings` 10 → **7**。
- 最小字号到处都 ≥11px(原来有 10 / 10.5px)。
- 卡片样式档:`/` 2 → **3**,`/admin/settings` 1 → **2**。
- 溢出:移动端审计**逐值一致**;桌面 1440 唯一差异是 `/map` 露外元素 9 → 8。
- 验收 `./scripts/verify.sh`:**PASS=347 FAIL=0 SKIP=0**。
**踩到的四处**(都只有「量 DOM 的 (字号,字重) 组合」才看得见,截图上看都正常):
1. **Leaflet 字号跟着根字号漂**:vendor `.leaflet-container { font-size: .75rem }`,
根 14 → 13px 后地图署名/比例尺掉到 **9.8px**、弹窗正文也跟着缩。
已在 app.css 钉死(容器 `--fs-sm`、署名/比例尺 `--fs-xs`)。
2. **项目自带的静态守卫抓到我**:`TestNavLinksNarrowRuleHasNoDeadPadding` 写死了 `font-size: 13px`,
并档后它红了。守卫是对的,改判据为「钉规则还在、且设了字号」;另补 `TestNoBareFontSizePx`
(静态扫 app.css + 模板禁裸 px 字号,做过变异验证)。
3. **`h1..h4` 从没设过字号**(原来只有字重与 margin),靠浏览器默认 `2em/1.5em/1.17em` ⇒ 也跟着根字号漂:
根 14 → 13px 后,`/stats` 里没被 `.card > h2` 命中的两个 h2 从 21px 变成 19.5px,比同页卡片标题(15px)大一圈。
已显式给出 h1/h2/h3 字号,实测 12 个 h2 统一 `15px/600`。
4. **`<b>/<strong>` 浏览器默认 700**,比全站最高层级(标题 600)还重 ⇒ 已钉成 600。
**教训(已写进 details 四十二)**:quiet 卡片第一版用 `--bg-2`(#fafbfb),
与页面底 #f6f7f8 只差 4/255 ⇒ 实测看不出边界、像「破了个洞」。**配色差异必须量,不能凭「应该能看出来」。**
**运维提醒**:测试库跑到 180MB / 21 万死元组时 `verify.sh` 会从 8 分钟拖到 12 分钟以上
(脚本 200MB 才自动 VACUUM);手动 `VACUUM (ANALYZE)` 后回到 ~10 分钟、137MB / 0 死元组。
**发现未修**:7 个模板工作区里是 CRLF,而 `.gitattributes` 要求 `eol=lf`
(`admin/index.html`、`admin/login.html`、`admin/twofa-login.html`、`admin/twofa.html`、
`map.html`、`places.html`、`timeline.html`)。本批按字节替换保住了各自行尾、diff 无行尾噪声,
要不要统一另开一次清理。
---
## 落地:C + D 批次(用户选了「C和D继续做」)
**C 图表**:月度分布加基线 + 峰值柱标次数(`.pv`,副标题不再重复写「最高 N 次」);
一天中的分布把「0/6/12/18 四个数字」换成 **6 小时一段的标尺**(深夜/上午/下午/晚上),
用**同一套 24 列 grid** 保证段边界与柱子严格对齐;首页月度图同样加峰值(给 `HomeData` 补了 `MaxMonthly`)。
**空态那条划掉了** —— 查下来已经统一(8 页共用 `emptystate`、局部走 `.empty`)。
**D1 类型色**:先算对比度,结论推翻了原计划 ——
- 白底上 6 个色低于图形 3:1(yellow 1.87 / mint 2.12 / orange 2.20 / green 2.22 / cyan 2.54 / teal 2.57)
- 暗底只有 indigo 2.90 不够
所以**没有**把类型色做成 chips / 表格类型列 / KPI 强调(那几处要的是文字色,会更难读)——
这恰好解释了全站为什么一直只用 8px 小圆点做标记。浅色 6 个色一个没动(是手机端/地图的同一套身份色)。
实际做了:色值收口成 `--cat-*` + `--cat-*-soft`(26 个变量)、模板 `colorof` → `csscolor`/`csssoft`(19 处)、
暗色只提亮 indigo。`colorOf` 已成死代码,删掉。
⚠️ **踩到 `ZgotmplZ`**:`html/template` 的 CSS 过滤器不认识 `var()`,函数返回普通 string 会被替换成
`ZgotmplZ`,页面上所有圆点当场变透明、**且不报错、模板测试照过**。
必须返回 `template.CSS`;而挂了它等于关掉过滤,所以数据侧色值先过严格校验(只有 `#`+6 位 hex 放行)。
已加守卫 `TestCSSColorSurvivesTemplateSanitizer`(两个方向都钉,做过变异验证)。
**D2 窄屏导航**:≤620px 整行收进 `.nav-menu`(原生 `<details>`,JS 只补点外面/Esc/点链接后收起)。
⚠️ 顶栏 z-index 抬到 `--z-nav: 1200`(必须高于地图浮层 1100,否则展开的菜单被筛选面板盖住),
已在 `/map` 用 `elementFromPoint` 实测。改前 390 溢 63px、320 溢 133px(「统计」被切半个字)。
**回归**:`verify.sh` **PASS=348 FAIL=0 SKIP=0**;320/390 溢出**全 0**(改前 `/admin` 在 320 还溢 34px);
桌面 1440 与改前一致;**7 个页面都没有低于 11px 的文字**;暗色下菜单/类型色/阴影均按暗色变量生效。
---
## 提交 + 完整回归(含「原有代码是否受影响」专项)
**提交**:`ab7ccd2`「前端改造:设计令牌 + 卡片层级 + 图表可读性 + 类型色收口 + 窄屏导航」
(32 文件,+997/−283)。A/B 与 C/D 本想拆两笔,但 `app.css`/`layout.html`/`view.go` 里两批改动交织,
按文件拆不开、按 hunk 拆容易做出「中间那笔编译不过」的历史,**合并成一笔、消息里分段**,并在提交消息里写明原因。
**完整验证链**(一次跑齐,全部串行):
- `./scripts/verify.sh`:**PASS=348 FAIL=0 SKIP=0**
- `./scripts/verify.sh --pkg ./internal/fog/ --race`:**PASS=22 FAIL=0 SKIP=0**(mingw 在 PATH 里,-race 可用)
- JS 单测 34/34
- 移动端 320 / 390 + 桌面 1440 审计:**320/390 溢出全 0**;1440 只有 −15(滚动条宽,既有口径)
**「原有代码受影响」专项检查**(静态扫描 + 逐文件回读 diff):
- 自定义属性:定义 90 / 使用 61,**「用了但没定义」为 0**(先前一轮报的 12 个全是我正则的假阳性:
CSS 一行写多个属性、`--cat-*` 是 Go 侧动态拼的、模板 `:root` 里也有定义)。
- 定义了没人用的:只剩 28 个 `--cat-*`(Go 动态拼接,运行时实测 14 种颜色都渲染出来了)——
另外揪出 `--sp-5` 真没人用,已用到「停一下」页的留白上(正好是大间距场景)。
- 模板函数:**没有真孤儿**(先前列表是正则把 `appleColors` 的键当成了函数名)。
- 已删项残留:`colorof`/`--radius`/`--shadow`/旧测试名 全部 0 残留(`app.css` 里那句注释也顺手改对了)。
- 逐文件回读:19 个模板的 diff 只有字号替换与 `colorof`→`csscolor`,没有误伤;
Go 侧 diff 只有 `MaxMonthly`、`cssColor/cssColorSoft`、注册表与 `regexp` 导入。
- 顺带发现(**既有、与本批无关**):`.g3`/`.g4` 是 HEAD 里就有的死类(模板/JS 都没引用);
`.notice-card` 等不是死类 —— 它在 `notice.go` 里由 Go 拼 HTML,我的模板侧扫描覆盖不到。
**验证中抓到的一例失败(已实证是环境抖动)**:
`TestOpenCreatesMissingDatabase` 报「建库后首次连接超时」10.02s。根因是 `db.Open` 的 ping **写死 10s**,
当时库正被验收 + autovacuum + CDP 审计一起压着;停掉审计单独跑 **4.1s 通过**。
⚠️ 这条测试失败还会把临时库 `rond_test_autocreate_*` **留在库里**(清库的 defer 在 `Open` 成功之后才注册)——已清掉残留。
两条结论都写进了 details 三十二的验收小节。
+146 -9
View File
@@ -110,7 +110,7 @@ zip 包,**条目内容本身是 zlib 流**;`Model/*/` = 头 32768B + 每块
## 十八、重启与操作纪律
- **重编重启**:`go build -o bin/rond.exe .` → 停掉占 8080 的进程 → `./bin/rond.exe -c conf/app.ini`。`taskkill //F` 的双斜杠参数会被 Git Bash 吃掉,改用 PowerShell 按端口找 PID + `Stop-Process`。
- **重编重启**:`go build -o rond.exe .`(**只构建到仓库根,`bin/` 已废弃、2026-10-09 删除**)→ 停掉占 8080 的进程 → `./rond.exe -c conf/app.ini`。`taskkill //F` 的双斜杠参数会被 Git Bash 吃掉,改用 PowerShell 按端口找 PID + `Stop-Process`。
- 生产 `life.shiroko.one` **只走 IPv4**(curl 必须 `-4`);会话 cookie `rond_session=uid|token`(HMAC);HTTP 303 **不能跟随**(Location 里带 `ok=`/`err=`)。`/tmpfog/` 不是运行必需,`tmpfog/_ck.txt` 是**死票**(实打接口被 302)⇒ 不必轮换 `secret_key`。
- **`gofmt -l` 会报假警**:仓库存 LF、`core.autocrlf=true` 把工作区换成 CRLF(已加 `.gitattributes` 钉住);**用 python `open(p,'w')` 回写 Go/CSS 会把行尾变 CRLF**,要二进制模式读写。
- **同一文件绝不并行改**:多条 Edit 一起发给同一文件时,各自「读整份-替换-写整份」会互相覆盖——工具**报 success 但磁盘上没落**(实测一次发 3 条给 `map.html`,`map-count` 那条丢了)。**一个文件串行改,改完 `grep` 复核关键标记,再编译。**
@@ -534,6 +534,11 @@ zip 包,**条目内容本身是 zlib 流**;`Model/*/` = 头 32768B + 每块
- 验收四件套 `ROND_TEST_CONF`+`ROND_SRC`+`FOG_SRC`+`ROND_DSN`(须指 `rond_fog_test`);`-count=1 -v ./...` 要 **0 skip**。两个假绿坑:缺 `ROND_SRC` 静默 Skip;共用库 `Import` 让 `internal/stats` 假 skip ⇒ 用专用库,`t.Cleanup` 在 `defer` **之后**才跑。
- 静态守卫走 `moduleRoot(t)`/`pkgFile(t,name)`;`-race` 需 mingw 在 PATH。纪律:只走 IPv4、303 不跟随;**同一文件绝不并行发多条 Edit**;新回归测试做**变异验证**。
- 📐 覆盖率**必须 `-coverpkg=./internal/...`**(`-cover` 只算本包 ⇒ rond/geo 假报 0%,真值 82.2%/92.9%);解析 profile 按 `(文件,行段)` 取 **max 别求和**;**死代码删掉、别给它补测试**。关键页冒烟 `scripts/smoke.py`(`verify.sh --smoke`/`--all`):关键区块不能用 `nav.nav`、用 `.card` 不用 `div.card`。`details 廿九`
- ⚠️ **验收要在「安静环境」跑,别和别的东西并发**:`db.Open` 的 ping 是**写死 10s**(`db.go:52`),
库被压着时 `TestOpenCreatesMissingDatabase` 会以「建库后的首次连接超时」失败 ——
2026-10-09 实测:和 CDP 审计并发时 10.02s 超时挂掉,把审计停掉**单独跑 4.1s 就过**(环境抖动,不是代码问题)。
另外这条测试失败时会把临时库 `rond_test_autocreate_*` **留在库里**(清库的 defer 在 `Open` **成功之后**才注册),
跑完记得手动 `DROP DATABASE` 清掉,别当成「库被占用 / 测试有 bug」。
---
@@ -879,14 +884,146 @@ Esc 关闭、焦点自动进输入框、其余部分自动 inert。旧浏览器
`settingsSubmit()` 的表单读取。`Load()` 里布尔项一律用 `present[key]` 判:
历史配置没有这个键时若直接套零值,语义会悄悄反转。
### `cleanTrackScript` 的判据(这里踩过一次)
### `cleanTrackScript` 的判据(这里踩过两次,2026-10-09 重写)
⚠️ **不能用「出现 `</script` 就拒」当规则** —— 百度统计、Umami 贴进来的就是完整
HTML,一行里必然带 `</script>`,一拒就把正常用法挡死了(我第一版就是这么写的,
被自己的测试当场抓住)。真正的注入面只有一种:**未闭合的 `<script>` 里出现
`</script>` 会提前闭合**,后面内容当正文渲染。判据改成**开闭标签数配平**
(`shut > open` 才拒),配平则整段原样放行。
另挡 `src=` 的 `javascript:` / `data:` 伪协议(只放行 http/https 与 `/` 开头),
超 4096 截断。
门禁 `TestCleanTrackScript` 除了逐条边界,还钉了**定义性不变量**:
任何输入都不得产出「含 `</script` 的非空结果」。
被自己的测试当场抓住)。
现行判据两条:
1. **开闭 `<script>` 标签必须严格配平(`open != shut` 就拒)**。少一个 `</script>`
会提前闭合、后面内容当正文渲染;多一个 `<script>` 会把页面剩下的收尾整段吞进脚本。
⚠️ 只拒 `shut > open` 是不够的(初版就是),末尾未闭合的开标签会被放行 ——
`<script>x</script><script>y` 实测通过,已改成严格配平。
2. **`src=` 只在 `<script …>` 开标签内部找**,只放行 `http(s)://` 与 `/` 开头的相对路径。
⚠️ 初版对**整段**做 `strings.Index(low, "src=")`,脚本体里一句 `img.src=u` 就被命中、
整段被判危险而清空 —— 而页面照样提示「设置已保存」,站长完全看不出脚本没挂上。
实测:`<script>var i=new Image();i.src=u;</script>` 旧版返回 `""`,现在原样放行。
超 4096 截断。
**收尾要显式**:函数签名改成 `(script, warn string)`,说明码
(`truncated` / `unbalanced` / `badsrc`)经 `?warn=` 跳到设置页、由 `trackWarnMessage`
翻成人话显示。用**固定码**而不是回显查询串原文(避免反射内容)。
判为危险时**整段不采纳、库里原脚本与开关都不动**(把站长手上好用的脚本一并清掉更糟)。
门禁 `TestCleanTrackScript` 逐条钉边界 + **定义性不变量**:
采纳了非空结果时,里面的开闭标签数必定相等(不是「不得含 `</script`」——整段贴进来的
本来就带闭合标签)。
## 四十一、2026-10-09 三处修复:导航口径 / 启动自检 / 二进制只留根目录
### 1. 导航的页面显隐**只管访客**(`layout.html`)
`guardPage`(`server.go`)拦的只是访客:`userFrom(ctx)==nil && !sets.HasPage(nav)` → 404;
站长始终能访问全部公开页。但导航写的是 `{{if .Settings.HasPage "map"}}`,**没有 IsAdmin 分支**
⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
已改成 `{{if or .IsAdmin (.Settings.HasPage "x")}}`(map/places/timeline/stats/report 五处)。
门禁 `nav_visibility_test.go`:直接执行 `nav` 模板,钉「关掉开关后站长仍有 5 个入口、
访客一个都没有、全开放时访客也看得到」三向不变量(做过变异验证)。
⚠️ **访客侧「只有主页」是配置出来的预期状态,别去"修"数据**:库里 `settings.pages = null`
(= 站长把公开页面全关了),访客 `/map /places /timeline /stats /report` 一律 404。
2026-10-09 用户明确确认:**访客能看哪些页就是按后台配的范围公开,这样才对**。
要改就改后台的「公开页面」开关,不要直接动 `settings` 行。
### 2. 启动自检 `smokeRender`(`template_smoke.go`)
⚠️ **「模板能解析」不等于「渲染得完」**:字段不存在、nil 解引用都只在**执行期**暴露,
而 `render` 只把错误写进日志 ⇒ 线上是「HTTP 200 + 渲染到一半的半张页面」。
2026-10-09 的 /admin/settings 截断就是这么坏的(提交里同时改了 `setting.Settings` 和
`settings.html`,跑的是改动前编译的二进制,debug 下模板走磁盘热加载 → 新模板 + 旧结构体)。
做法:`pageProbe`(每个页面一份「最小可用数据」)+ `smokeRender` 在 `New()` 里把
**所有页面各渲染一遍**,任一失败就 `New` 返回 error → main `log.Fatalf` **拒绝启动**。
- `pageProbe` 的键必须与 `pageFiles` **双向一致**:缺项按失败处理(新加页面必须补探针)。
- 探针值只求「类型对、不炸」——页面本来就得能渲染空数据。
- `smokeOne` 里 `recover()` 兜住方法里的 panic,报出「哪一页坏了」而不是崩栈。
- 门禁 `template_smoke_test.go`:一个跑全量自检;一个**变异验证**(把 `admin/settings`
的探针换成字段更少的 `Page`,必须报错且点名该页)。
- 实测:往 `settings.html` 插一个不存在的字段 → 启动即报
`初始化服务失败: 模板自检未通过…admin/settings: …can't evaluate field …`,退出码 1。
### 3. 二进制只留在仓库根(`bin/` 已删,2026-10-09)
`go build -o rond.exe .` 一个位置。`bin/` 整套(15 个 exe、412MB)已删;
`.gitignore` 的 `/bin` 保留作为兜底。`scripts/build-linux.sh` 照旧只产
`dist/rond-linux-amd64`。相关技能(`rond-web-verify`)与 details 十八已同步改掉
`bin/rond.exe` 的写法。
## 四十二、前端设计令牌(A/B 批次,2026-10-09)
完整方案与实测数据见仓库根 `frontend-plan-2026-10-09.md`。这里只记**以后改前端必须守的规矩**。
### 字阶 / 圆角 / 阴影一律走令牌,别写 px
- 文本 6 档:`--fs-xs/sm/md/lg/xl/2xl` = 11 / 13 / 15 / 21 / 26 / 34px。
- `--fs-control: 16px`:**窄屏表单控件必须 ≥16px**(iOS 聚焦 <16px 会把整页放大)。这是功能约束,
所以它独立于字阶;窄屏媒体查询里用 `--field-fs` 覆盖。**别把它并进字阶。**
- `--glyph-*` 6 档:✕ / 箭头 / 空态图标 / 表情的字号。**字形不是文字层级**,按控件尺寸定;
硬塞进字阶(比如把 22px 的 ✕ 提到 26px)只会把按钮撑坏。
- 圆角 `--r-xs/sm/md/lg` = 3 / 6 / 8 / 12px。`50%` 与 `999px` 是形状(圆 / 胶囊),不算圆角档。
- 阴影 `--sh-1/2/3`:⚠️ **暗色模式必须整套覆盖**(原来只覆盖一个 `--shadow`,拆档后漏一个就会
在暗色下用错档)。
- 间距 `--sp-1..5` 本批**只迁移了卡片内边距这类主节奏**,其余没做全量替换。
- `--card-pad` 与折叠卡片标题行的负 margin 绑在一起(`calc(var(--card-pad) * -1)`),改一个即改两处。
**门禁** `TestNoBareFontSizePx`:静态扫 app.css 与全部模板,出现 `font-size: <数字>px` 就红。
### 卡片三档
`.card--hero`(首屏地图 / 主数区)/ `.card`(默认)/ `.card--quiet`(只读说明)。
⚠️ quiet **别拿 `--bg-2` 当底色**:它与页面底 `--bg`(#f6f7f8) 只差 4/255,实测看不出边界,
卡片会像「破了个洞」而不是「轻了一档」。要「页面底色 + 保留细边 + 无阴影」。
首屏主数用 `.kpi-lead`(不是 `.kpi`):主数 + 一行上下文 + 左侧 3px 主色条。
### ⚠️ 改根字号会带动 vendor 库与「没设字号的默认标签」
`html, body` 的 font-size 一改,至少三处会跟着漂:
1. **Leaflet**:vendor 里 `.leaflet-container { font-size: 0.75rem }` 是**相对根字号**的,
根从 14 → 13px 后地图署名/比例尺掉到 **9.8px**、弹窗正文也跟着缩。
app.css 里已钉死(容器 `--fs-sm`、署名/比例尺 `--fs-xs`)。
2. **`h1..h4`**:app.css 原来只给它们设了字重与 margin、**没设字号**,于是靠浏览器默认的
2em / 1.5em / 1.17em —— 同样跟着根字号漂。已显式给出 h1/h2/h3。
3. **`<b> / <strong>`**:浏览器默认 bold(700),比全站最高层级(标题 600)还重。已钉成 600。
判据:**量 DOM 的 (字号,字重) 组合**。只看截图不会发现这三处(页面「看着正常」,
只是有点不齐)。其它 rem/em 的 vendor 样式同理要查。
## 四十三、图表 / 类型色 / 窄屏导航(C、D 批次,2026-10-09)
### 图表(手写 .cols / .bars,不引图表库)
- `.cols` 有基线(`border-bottom`)与 `padding-top: 16px` —— 后者是给峰值柱顶上那个数字(`.pv`)留的位置,
改高度时别把 padding 弄丢(`height` 走 border-box,内容区才是柱子的 100% 基准)。
- 峰值柱:模板里 `{{if eq .Count $.MaxMonthly}} peak{{end}}`,柱顶标次数;副标题里就不再重复写「最高 N 次」。
- `.cols-band` 是「6 小时一段」的标尺,**必须用 24 列 grid**(不是 `flex: 6`),否则段边界与柱子对不齐。
它取代了原来那行只有一个数字的坐标轴。
- 首页的月度图峰值靠 `HomeData.MaxMonthly`(与统计页同口径 `maxOf`),别只改一边。
### 类型色:只在 CSS 变量里用,别当文字色
- 模板用 `csscolor` / `csssoft`(`colorof` 已删)。
⚠️ 这两个函数**必须返回 `template.CSS`**:`html/template` 的 CSS 过滤器不认识 `var()`,
返回普通 string 会被换成 **`ZgotmplZ`** —— 页面上所有圆点当场变透明,而且不报错、模板测试照过(踩过)。
守卫 `TestCSSColorSurvivesTemplateSanitizer`。
- 但挂了 `template.CSS` 就等于关掉那道过滤,所以**数据侧色值必须先过严格校验**(只有 `#` + 6 位十六进制放行),
否则 `#"><img…` 能直接逃出 style 属性。
- ⚠️ **这套色连图形对象 3:1 都到不了**:白底上 yellow 1.87、mint 2.12、orange 2.20、green 2.22、
cyan 2.54、teal 2.57。所以它们**只能当身份标记(圆点 / 柱条),绝不能当文字色或承载唯一信息的图形**。
实测过、别再被「做成彩色标签更好看」带走。
- 浅色那 6 个色**不要改暗**:它们是手机端与地图上的同一套身份色,改暗会破坏对应关系。
- 地图那份调色板(`rond-map.js` 的 `APPLE`)**故意独立**:瓦片在暗色下仍是浅色,标记要用浅色底那套值。
- 暗色只提亮了 `--cat-indigo`(2.90 → 3.55:1),其余一律不动。
### 窄屏导航(≤620px)
- 链接本体是 `{{define "navitems"}}`,渲染两遍:桌面 `.nav-links` 平铺、窄屏 `.nav-menu`(原生 `<details>`,
不靠 JS 也能开合;JS 只补点外面 / Esc / 点链接后收起)。
守卫 `TestNarrowNavCollapsesToMenu` 钉「CSS 两半 + 模板两个类 + 渲染两遍」的成对关系。
- ⚠️ 顶栏 `--z-nav: 1200`,**必须高于 `--z-map-overlay`(1100)**:菜单是 `.nav` 的子元素、
自己抬不动层叠上下文,不抬就会被地图的筛选面板盖住(`/map` 上用 `elementFromPoint` 实测验证过)。
+3 -1
View File
@@ -36,7 +36,9 @@
写模板时人站在站长视角下笔,「我的结论 / 你自己 / 我推荐的地点」露给访客就没人读得懂。
照 `place.html` 投票区与 `rond-map.js` 气泡的写法。门禁 `TestVisitorPagesUseThirdPersonCopy`
(以访客 ctx 渲染 `/`、`/stats`、`/report` 逐个断言禁用词,并确认站长那份仍说「我的」)。
- 改 Go 必须重启;模板/CSS/JS 只在 `debug=true` 热加载(`AssetV` 启动哈希)。
- 改 Go 必须重启;模板/CSS/JS 只在 `debug=true` 热加载(`AssetV` 启动哈希)。⚠️ **新模板+旧二进制**=「渲染到一半仍 200」;启动自检 `smokeRender` 会拒启(四十一)。
- 字阶/圆角/阴影**只走令牌**,裸 `font-size: 13px` 有静态守卫 `TestNoBareFontSizePx`;卡片三档 `.card--hero/--quiet`。⚠️ 改**根字号**要查 Leaflet(`.75rem` 会跟着漂),(四十二)。
- 类型色走 `csscolor/csssoft`(**必须返回 `template.CSS`**,否则 `var()` 被换成 `ZgotmplZ` 且不报错);这套色连 3:1 都不到,**只能当圆点标记、别当文字色**。窄屏导航收进 `.nav-menu`,顶栏 `--z-nav` 必须高于地图浮层(四十三)。
- 拼查询串走返回 `template.URL` 的方法(`PlaceLink`/`EditPlaceLink`/`MapLink`/`EditLink`),勿手工转义。
- 窄屏覆盖写 `app.css` **末尾那块**;宽表堆叠类名**成对** `class="tb tb-stack"`;触控 44px。`tb_stack_test.go`
- 加表格列:`PlaceCols`/`scanPlace` **成对**、新列追加最后;CJK 挤换行只抬该表 `min-width`。
+210
View File
@@ -0,0 +1,210 @@
# 前端现代化方案
- **日期**:2026-10-09
- **范围**:视觉层升级 —— `app.css`(1114 行)+ 24 个模板的样式类使用;**不动**信息架构、路由、模板数据结构
- **方法**:先用无头 Chrome 在真实视口量现状(390 / 430 / 1440),再定方案。下面每个数字都是量的,不是估的
- **前提约束**(这些都是现状,方案不得破坏):无前端构建链(服务端渲染 + 单 CSS + 手写 JS)/ 1C1G 服务器 / 已有暗色模式(`prefers-color-scheme` 3 段)/ 已按 WCAG AA 调过 `--text-3`
> ⚠️ `shots/` 里的截图(`mobile-home.png` 等)是**旧状态**:那张顶栏折成 3 行、右按钮竖排的图早就不成立了,
> 现在 390 下品牌名只留 🐾、按钮是「后台」。做视觉判断前一律**现场重新截图**,别拿 `shots/` 当现状。
## 一、现状实测
| 观测 | 实测值 | 说明 |
|---|---|---|
| 卡片样式数 | 17 个卡片盒子(`.card`/`.kpi`/`.filters-card`)→ **只有 2 种样式指纹** | 全站一个「白盒 + 1px 边 + 浅阴影 + 10px 圆角」用到底,KPI / 地图 / 图表 / 表格 / 筛选器全等重 ⇒ 眼睛没有落点 |
| 字阶 | 字号 **8 档**:10/12/13/14/15/18/21/23px,其中 **10–15px 挤了 5 档** | 中段过密、大段太少;层级挤不出来,只能靠加粗 |
| 字重 | 400 / 500 / 600 / **650** | 650 是半档,等于没规范 |
| 正文色 | **4 种**(`--text`/`--text-2`/`--text-3` + 链接)+ 1 个主色 | 整页几乎单色 |
| 图表 | 手写 `.bars`(无图表库),月度/一天分布只有色块 | 无基线、无峰值标注、无静止态数值 |
| 类型色 | 10 种(家/工作/餐厅/…)**只用在 8px 圆点和柱子上** | 没进入界面语义层(chips、表格类型列、KPI 都用不到) |
| 窄屏导航 | 站长导航(6 项)`scrollWidth=277`,可用宽:**430→254(溢 23)**、**390→214(溢 63)**、**320→144(溢 133)** | 靠 `.nav-links` 横向滚动兜底;390 下「统计」被切成半个字紧贴「后台」按钮,渐隐提示实际看不见 ⇒ 像坏了 |
| 暗色模式 | 已存在(586/590/742 三段 media) | 任何配色改动要同步两套变量 |
| 已有优点(别改坏) | KPI 数字已是 `tabular-nums`;`--text-3` 按 AA 调过;地图/时间线的 z-index 与安全区已有处理 | 这些是已经踩过坑换来的 |
## 二、为什么不重写(明确排除项)
- **不引入前端框架 / 构建链**(Vue/React/Tailwind/Vite):现状是 Go 模板直出 + 单 CSS + 无 npm。引构建链会让「改一行字要跑 node」、部署变复杂,而站点是个人足迹站、并发极低 —— 收益不抵成本。
- **不上图表库**:为几个柱图多背 100KB+ 与一个依赖,不值得。`.bars` 手写继续用,只升级表达力。
- **不重做信息架构**:页面、路由、模板数据结构都不动;只改「长什么样、怎么看」。
## 三、四批方案(由低风险到高收益)
### 批次 A:设计令牌收敛(纯 CSS,约半天)
1. **字阶砍到 6 档**:`11 / 13 / 15 / 21 / 26 / 34`
- 11 = 脚注/单位,13 = 正文(默认),15 = 卡片标题,21 = 页面标题,26 = KPI,34 = 首屏主数
- 现在 10/12/14/18/23 全部并档;`--text-3` 的灰阶不动(它承载 a11y 口径)
2. **字重砍到 3 档**:400 / 500 / 600(删掉 650)。层级主要靠**字号 + 颜色**,不靠加粗 —— 这更接近现代做法
3. **间距令牌**:`--sp-1..5` = 4/8/12/16/24,替换散落的 padding/margin 字面量
4. **圆角令牌**:`--r-sm/md/lg` = 6/10/14(现在只有 `--radius:10px` 和一个 8px)
5. **阴影分 3 档**:`--sh-1`(卡片)/`--sh-2`(浮层)/`--sh-3`(hero);现在只有 1 档
> 判据:`cardVariants` 2 → ≥3;(字号/字重) 组合 10 → ≤6;CSS 里裸 `border-radius:` 数字 0 处。
### 批次 B:给卡片分主次(约 1 天)
三档面,替代现在唯一的一种:
| 类 | 用途 | 样子 |
|---|---|---|
| `.card--hero` | 首屏 KPI 区、地图 | 更大内边距 + `--sh-3`、无边框、左侧 3px 主色条 |
| `.card`(默认) | 图表、列表、表单 | 保持现状(已够干净) |
| `.card--quiet` | 说明块、次级列表 | `--bg-2` 浅底、无边框无阴影 |
配套把首屏 KPI 从「4×2 等重白盒」改成 **1 个 hero 大数 + 3 个指标**:首屏必须有一个一眼能落下去的点。手机端同理(hero 占整行,后 3 个两列)。
> 判据:把首屏截图给人看 3 秒,对方能指出「最重要的那个数」。
### 批次 C:让图表说人话(约 1–2 天,仍不用库)
- **月度分布**:加基线、峰值柱顶部标数值 + 一处「最高 197 次」文案、每根柱 `<title>` 与 hover 值
- **一天分布**:加 0/6/12/18 轴标 + 早/午/晚三段极浅底色,让「作息」这个概念不用读标题也懂
- **类型分布**:柱色改用类型色(见批次 D),数值列 `tabular-nums`(数字列宽不再跳)
- **空态统一**:现在有的写「暂无数据」、有的空着;统一一个 `emptystate`(模板里已有这个定义,直接用)
> 判据:不看图例、不看标题,也能说出这张图在讲什么。
### 批次 D:色彩语义化 + 窄屏导航(约 1 天)
1. **类型色升级为语义令牌**:`--cat-home` / `--cat-work` / `--cat-food` … 10 个,并用 `color-mix()` 派生各自浅底(沿用现有 `--accent-soft` 的做法,不手写 10 套浅色)
- 用在:筛选 chips、地点表「主要类型」列、KPI 强调、图表
- 硬约束:正文用色对比 ≥4.5:1、大字/图形 ≥3:1(延续现有口径),**暗色模式各派生一套**
2. **窄屏导航**:现状溢出 23–133px。
- 第一步(低风险):主链接收到 4 个(主页/地图/地点/统计),「时间线/报告」进「更多」;把 `.overflow-right` 的渐隐加重到看得见
- 第二步(可选,需评估):底部 tab bar。⚠️ 地图页的浮层 z-index 与 `--nav-h` 已经踩过坑(审计 F11/F12),上底部栏要先确认与 Leaflet 控件、安全区的冲突
> 判据:320/390 下**没有任何链接被切半个字**;导航高度不增(现 51px);暗色模式下新色阶对比度同样达标。
## 四、建议顺序
先 **A + B**(约 1.5 天):纯 CSS + 少量模板 class,风险最低、观感提升最直接,改完能立刻判断方向对不对。
C 独立(图表可以慢慢磨),D 涉及配色与导航,放最后一次性做,避免中途改两次色。
## 五、验收方式(沿用项目既有纪律)
- 视觉:`rond-web-verify` 技能在 **320/390/430/1440** 四个宽度现场截图 + 量数(别用 `shots/` 里的旧图)
- 不改坏:`./scripts/verify.sh` 必须仍然 **0 skip 全绿**(模板改动会影响 `tb_stack_test`、`TestVisitorPagesUseThirdPersonCopy` 等)
- 暗色:`--color-scheme dark` 跑同一组断言
- 无障碍:`audit/a11y.js` 的对比度与字号判据不得比改动前更差(改动前基线要先跑一遍存档)
- 本次只出方案,**未改任何代码**。
---
# 落地记录:A + B 批次(2026-10-09)
方案通过后先做了 A(设计令牌收敛)与 B(卡片分主次)。下表每个数字都是**同一份探针在改动前后各跑一遍**得到的。
## A:设计令牌收敛
**字阶**:文本 6 档 `--fs-xs/sm/md/lg/xl/2xl` = 11 / 13 / 15 / 21 / 26 / 34px。
- app.css 原来 19 种字号、96 处声明;模板里另有 54 处内联字号 —— 全部并档。
- **两个字外档,都不是漏掉的**:`--fs-control: 16px`(窄屏表单控件必须 ≥16px,否则 iOS 聚焦放大整页,属功能约束,不能进字阶);`--glyph-*` 6 档(✕ / 箭头 / 空态图标,按控件尺寸定 —— 把 22px 的 ✕ 塞进字阶只会把按钮撑坏)。
- 字重收敛到 400 / 500 / 600(删掉 650 与 700 两个半档)。
**圆角** 4 档 `--r-xs/sm/md/lg` = 3 / 6 / 8 / 12px(原来 2.5/4/5/6/7/8/9/10/12/20px 混用;卡片 10 → 12)。
**阴影** 3 档 `--sh-1/2/3`(原来只有 1 档变量 + 6 处写死的阴影);暗色模式整套覆盖。
**间距** 5 档 `--sp-1..5`。⚠️ **本批只用在卡片内边距这类主节奏上,没有做全量迁移** —— 一次性铺开的改动面太大、收益最低,留到需要时再逐批替换。
**`--card-pad` 单列**:折叠卡片的标题行要用负 margin 顶到边,两处必须永远相等。
## B:卡片分主次
- `.card--hero`(首屏地图:无边框 + `--sh-3` + 更大内边距)、`.card`(默认)、`.card--quiet`(设置页「地图底图 / 图片存储」两块只读说明:页面底色 + 细边 + 无阴影)。
⚠️ quiet 第一版用 `--bg-2`(#fafbfb),实测与页面底 #f6f7f8 只差 **4/255** —— 根本看不出边界,卡片像「破了个洞」。改成「页面底色 + 保留细边」才对。
- 首屏 KPI 从「4×2 等重白盒」改成 `.kpi-lead`(主数 + 城市/省份上下文)+ 5 个常规指标。**8 个数字一个没少**,只是分了主次。
## 改动前后(同一探针)
| 页面 / 视口 | (字号,字重) 组合 | 卡片样式档 | 最小字号 |
|---|---|---|---|
| `/` 390 | 13 → **10** | 2 → **3** | 10 → 11 |
| `/` 1440 | 15 → **10** | 2 → **3** | 10 → 11 |
| `/places` 390 | 10 → **5** | 2 → 2 | 12 → 12 |
| `/timeline` 390 | 10 → **6** | 2 → 2 | 11.5 → 11 |
| `/stats` 1440 | 15 → **9** | 2 → 2 | 10 → 11 |
| `/admin/settings` 1440 | 10 → **7** | 1 → **2** | 12 → 13 |
首页 390 的字阶从
`10/400 11.5/400 12.5/400 13/400 13/500 13.5/400 14/400 14/500 15/600 17/400 18/600 21/600 21.8/650`
收敛到
`11/400 13/400 13/500 15/500 15/600 18/400 18/600 21/600 21.8/600 28.1/600`
(字重只剩 400/500/600;多出来的 28.1px 是新加的首屏主数)
## 回归
- `./scripts/verify.sh`:**PASS=347 FAIL=0 SKIP=0**(含本轮新增的两条守卫)
- 移动端 390 审计(溢出 / 小触控 / 小字 / iOS 自放大):**逐值一致**
- 桌面 1440 溢出审计:唯一差异是 `/map` 露外元素 9 → 8
- 暗色模式:三档阴影与主色强调条都按暗色变量生效(实测 computed 值)
## 途中发现并修掉的四处
1. **Leaflet 的字号跟着根字号漂**。vendor 里 `.leaflet-container { font-size: 0.75rem }`,根字号 14 → 13px 之后,地图右下角的**署名与比例尺缩到 9.8px**(掉到本站 11px 下限之下),弹窗正文也跟着缩。已按自己的字阶钉死:容器 = `--fs-sm`,署名/比例尺 = `--fs-xs`;`/map` 上小于 11px 的元素从 2 个降到 0 个。
2. **静态守卫抓到并档**。`TestNavLinksNarrowRuleHasNoDeadPadding` 里写死了 `font-size: 13px`,改成令牌后它红了 —— 守卫本身是对的(它就该在写法漂移时叫),已把判据从「钉具体值」改成「钉窄屏那条规则还在、且设了字号」。另补 `TestNoBareFontSizePx`:静态扫 app.css + 全部模板,禁止再出现裸 px 字号(做过变异验证:注入一条 `font-size: 14px` 会红)。
3. **`h1..h4` 从来就没设过字号**,一直靠浏览器默认的 `2em / 1.5em / 1.17em` —— 也就是跟着根字号漂。根从 14 → 13px 后,`/stats` 里没被 `.card > h2` 命中的两个 h2(「我的结论」「访客点赞」)从 21px 悄悄变成 19.5px,比同页其它卡片标题(15px)大一圈。已显式给出字号:h1 = `--fs-lg`、h2 = `--fs-md`、h3/h4 = `--fs-sm`;实测 `/stats` 的 12 个 h2 现在统一 `15px/600`。
4. **`<b> / <strong>` 走浏览器默认 700**,比全站最高层级(标题 600)还重。已钉成 600。
> 3 与 4 都是「量 DOM 的 (字号,字重) 组合」才浮出来的:肉眼看页面完全正常,只是觉得「有点不齐」。
> 这也是为什么这一批要**先存基线、再用同一份探针逐值 diff**,而不是改完看一眼截图。
## 附:一处历史遗留(本批未动)
7 个模板(`admin/index.html`、`admin/login.html`、`admin/twofa-login.html`、`admin/twofa.html`、`map.html`、`places.html`、`timeline.html`)在**工作区里是 CRLF**,而 `.gitattributes` 要求 `eol=lf`。
本批按字节替换、原样保住了各自行尾,所以 diff 里没有行尾噪声。要不要统一成 LF,另开一次清理。
---
# 落地记录:C + D 批次(2026-10-09)
## C:让图表说人话
- **月度分布**:柱底压一道基线(原来柱子直接悬在空白里,高低只能靠互相比);峰值柱不透明拉满、并在柱顶标出次数(`.pv`),副标题里就不再重复写一遍「最高 N 次」。
- **一天中的分布**:原来只能靠 0/6/12/18 四个数字猜。现在换成一行 **6 小时一段的标尺**(深夜 0-6 / 上午 6-12 / 下午 12-18 / 晚上 18-24),用的是**同一套 24 列网格**(不是 `flex: 6`),所以段边界与柱子严格对齐;峰值小时也标了出来。
- 首页那份「月度活跃度」同样加了峰值锚点 —— 为此给 `HomeData` 补了 `MaxMonthly`,与统计页同一口径(`maxOf`)。
- **空态这条划掉了**:查下来已经统一 —— 8 个页面共用 `emptystate`,局部空态一律 `<div class="empty">`,没有需要收拾的。
## D1:类型色语义化(结论和原计划差得比较多)
先把 13 个色在浅/暗两个底上**算了对比度**:
| 底 | 结果 |
|---|---|
| 白底(浅色面板) | **6 个色低于图形对象 3:1**:yellow 1.87、mint 2.12、orange 2.20、green 2.22、cyan 2.54、teal 2.57 |
| 暗底 `#1c2024` | 只有 indigo 2.90 不够,其余都在 3.9 以上 |
所以**没有**按原计划把类型色用到 chips / 表格类型列 / KPI 强调 —— 那几处要的是**文字**,这套色连图形 3:1 都到不了,
做成彩色标签只会更难读。这也正好解释了全站为什么从头到尾只用 8px 小圆点做身份标记。
浅色那 6 个色**一个没动**:它们是手机端与地图上的同一套身份色,改暗会破坏对应关系;而且它们只当标记、不承载唯一信息。
实际做了三件事:
1. **色值收口到 CSS**:`--cat-*` 13 个(原样 Apple 色名值)+ `--cat-*-soft` 13 个浅底(半透明,两个主题自适应)。
Go 侧 `appleColors` 退成「哪些色名合法」的名单;`colorOf` 已成死代码,删掉。
2. **模板函数换成 `csscolor` / `csssoft`**(17 + 2 处)。⚠️ 返回值必须是 `template.CSS`:
`html/template` 的 CSS 上下文过滤器**不认识 `var()`**,普通 string 会被替换成 `ZgotmplZ` ——
页面上所有圆点当场变透明,而且不报错、模板测试照过(本批实测踩到)。
挂了 `template.CSS` 等于关掉这道过滤,所以数据侧色值必须先过严格校验(只有 `#` + 6 位十六进制放行)。
守卫 `TestCSSColorSurvivesTemplateSanitizer` 把两个方向都钉住:不出现 `ZgotmplZ`、恶意色值不得逃出 `style` 属性(做过变异验证)。
3. **暗色只提亮一个**:`--cat-indigo` 掺 10% 白(2.90 → 3.55:1),其余不动。
地图那份调色板(`rond-map.js` 的 `APPLE`)**保持独立**:地图瓦片在暗色下仍是浅色,标记就该用浅色底那套值 —— 硬统一反而会错。
## D2:窄屏导航
改前 ≤620px 靠横向滚动兜底:**390 溢 63px、320 溢 133px**,最深处的「统计」被切成半个字紧贴后面的按钮,
而 `.overflow-right` 的渐隐提示实际看不出来。现在:
- 链接本体抽成 `{{define "navitems"}}` 渲染两遍:桌面在 `.nav-links` 平铺,窄屏收进 `.nav-menu`(原生 `<details>`,不靠 JS 也能开合)。
- JS 只补原生不给的三件事:点外面 / Esc / 点链接后收起。
- ⚠️ **顶栏 z-index 抬到 `--z-nav: 1200`**:菜单属于 `.nav` 这个层叠上下文、自己抬不动,不抬就会被
地图页的 `--z-map-overlay`(1100) 盖住。已在 `/map` 用 `elementFromPoint` 实测(`menuOnTop=true`)。
- 顺手删掉窄屏那几条已经够不着的滚动/遮罩规则;宽屏保留横向兜底(以后又加入口时宁可能滑,也别切字)。
## C + D 的回归
- `./scripts/verify.sh`:**PASS=348 FAIL=0 SKIP=0**
- 移动端 390 / 320:**溢出全 0**(改前 `/admin` 在 320 还溢 34px,现在也归零)
- 桌面 1440:溢出与改前一致(−15 是滚动条宽,按既有口径属正常)
- **7 个页面都没有低于 11px 的文字**(审计的「小字」判据是 <12px,会把我们 11px 的脚注档一并算进去,属设计内)
- 暗色:菜单、类型色、三档阴影都按暗色变量生效(实测 computed 值)
+52
View File
@@ -0,0 +1,52 @@
package web
import (
"bytes"
"html/template"
"testing"
)
// TestCSSColorSurvivesTemplateSanitizer 钉住两个方向:
//
// 1. `var(--cat-*)` 必须原样渲染进 style。html/template 的 CSS 上下文过滤器**不认识 var()**,
// 函数返回普通 string 会被替换成 `ZgotmplZ` —— 页面上所有类型色圆点会当场变成透明,
// 而且不报错、模板测试也照过(实测踩过)。
// 2. 一旦返回 template.CSS 就等于关掉这道过滤,所以**数据侧来的色值必须先严格校验**:
// 只有「# + 6 位十六进制」才放行,否则 `#"><img …` 能直接逃出 style 属性。
func TestCSSColorSurvivesTemplateSanitizer(t *testing.T) {
tpl := template.Must(template.New("t").Funcs(templateFuncs()).
Parse(`<i style="background:{{csscolor .C}}"></i><b style="background:{{csssoft .C}}"></b>`))
render := func(v string) string {
t.Helper()
var buf bytes.Buffer
if err := tpl.Execute(&buf, map[string]string{"C": v}); err != nil {
t.Fatalf("渲染失败(色值 %q): %v", v, err)
}
return buf.String()
}
cases := []struct {
name string
in string
want string
}{
{"调色板色名走 CSS 变量", "teal",
`<i style="background:var(--cat-teal)"></i><b style="background:var(--cat-teal-soft)"></b>`},
{"自定义色值原样用(浅底拼 22 当 alpha)", "#a2845e",
`<i style="background:#a2845e"></i><b style="background:#a2845e22"></b>`},
{"空值退兜底", "",
`<i style="background:var(--cat-none)"></i><b style="background:var(--cat-none-soft)"></b>`},
{"想逃出属性的串退兜底", `#"><img src=x>`,
`<i style="background:var(--cat-none)"></i><b style="background:var(--cat-none-soft)"></b>`},
{"位数不对的 hex 退兜底", "#12345",
`<i style="background:var(--cat-none)"></i><b style="background:var(--cat-none-soft)"></b>`},
{"套了别的 CSS 声明也退兜底", "red;background:url(javascript:1)",
`<i style="background:var(--cat-none)"></i><b style="background:var(--cat-none-soft)"></b>`},
}
for _, c := range cases {
if got := render(c.in); got != c.want {
t.Errorf("%s:csscolor/csssoft(%q)\n 得到 %s\n 期望 %s", c.name, c.in, got, c.want)
}
}
}
+67 -9
View File
@@ -1,7 +1,9 @@
package web
import (
"io/fs"
"os"
"regexp"
"strings"
"testing"
)
@@ -152,19 +154,75 @@ func TestPlaceImageControlsAreLabelled(t *testing.T) {
}
}
// ---------- F14:窄屏导航链接的内边距别再写死一处(会被后面的触控块盖掉) ----------
func TestNavLinksNarrowRuleHasNoDeadPadding(t *testing.T) {
// ---------- 窄屏导航:链接行收进菜单(原来是横向滚动,最右边那个字会被切掉) ----------
//
// 620px 以下链接行一定放不下(实测:390 溢 63px、320 溢 133px,「统计」被切成半个字紧贴
// 后面的按钮,而渐隐提示实际上看不出来)。现在整行收进 .nav-menu 这个原生 <details>。
//
// 这条守卫钉的是**成对关系**:CSS 的两半(桌面藏菜单 / 窄屏藏链接行)与模板里的两个类
// 缺任何一个,窄屏就会退回老样子、或者宽屏凭空多出一个菜单按钮。
func TestNarrowNavCollapsesToMenu(t *testing.T) {
css, err := os.ReadFile(pkgFile(t, "static/css/app.css"))
if err != nil {
t.Fatalf("读取 app.css 失败: %v", err)
}
src := string(css)
for _, want := range []string{
".nav-menu { display: none; }", // 桌面:菜单按钮不出现
".nav-links { display: none; }", // 窄屏:链接行不出现
".nav-menu { display: block; flex: 1; }", // 窄屏:菜单按钮取而代之
} {
if !strings.Contains(src, want) {
t.Errorf("app.css 缺少 %q —— 「桌面藏菜单 / 窄屏藏链接行」这两半必须成对,"+
"否则窄屏会退回「横向滚动 + 最右边的字被切掉」", want)
}
}
b, err := tmplFS.ReadFile("templates/layout.html")
if err != nil {
t.Fatalf("读取 layout.html 失败: %v", err)
}
nav := string(b)
if !strings.Contains(nav, `class="nav-links"`) || !strings.Contains(nav, `class="nav-menu"`) {
t.Error("layout.html 里 .nav-links 与 .nav-menu 必须都在:CSS 的两半分别作用在这两个元素上")
}
if n := strings.Count(nav, `{{template "navitems" .}}`); n != 2 {
t.Errorf("导航链接应当渲染两遍(宽屏平铺 + 窄屏菜单),实际 %d 处", n)
}
}
// ---------- 字阶纪律:app.css 与模板内联样式都只走令牌 ----------
//
// 改前 app.css 里散着 10 / 10.5 / 11 / 11.5 / 12 / 12.5 / 12.8 / 13 / 13.5 / 14 / 14.5 / 15 /
// 17 / 18 / 21px 十几档字号,光 12~15px 之间就挤了 4 档 —— 层级挤不出来,只能靠加粗硬顶;
// 模板里另有 54 处内联字号。现在:文本走 --fs-*,表单控件走 --fs-control(窄屏必须 ≥16px,
// 否则 iOS 聚焦会放大整页),字形走 --glyph-*(22px 的 ✕ 塞进字阶只会把按钮撑坏)。
func TestNoBareFontSizePx(t *testing.T) {
re := regexp.MustCompile(`font-size:\s*[^;\n]*[0-9]+(\.[0-9]+)?px`)
check := func(name string, b []byte) {
for _, m := range re.FindAllString(string(b), -1) {
t.Errorf("%s 里还有裸字号 %q —— 一律走 --fs-* / --fs-control / --glyph-* 令牌", name, m)
}
}
b, err := os.ReadFile(pkgFile(t, "static/css/app.css"))
if err != nil {
t.Fatalf("读取 app.css 失败: %v", err)
}
src := string(b)
if !strings.Contains(src, ".nav-links a { font-size: 13px; }") {
t.Fatal("找不到窄屏的 `.nav-links a { font-size: 13px; }`(写法变了,这条守卫失效)")
}
if strings.Contains(src, ".nav-links a { padding:") {
t.Error("窄屏 .nav-links a 又写了 padding:同特异性的内边距由后面「窄屏触控」那块给," +
"写在这里会被盖掉(死代码)")
check("app.css", b)
for _, dir := range []string{"templates/*.html", "templates/admin/*.html"} {
names, err := fs.Glob(tmplFS, dir)
if err != nil {
t.Fatalf("列举 %s 失败: %v", dir, err)
}
for _, name := range names {
body, err := tmplFS.ReadFile(name)
if err != nil {
t.Fatalf("读取 %s 失败: %v", name, err)
}
check(name, body)
}
}
}
+79 -29
View File
@@ -10,6 +10,7 @@ import (
"log"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -467,6 +468,7 @@ func (s *Server) settingsPage(w http.ResponseWriter, r *http.Request) {
d := SettingsData{Page: p, Modules: setting.AllModules, Pages: setting.AllPages,
MapInfo: s.mapInfo(), MapLayers: layers, S3Info: s.s3Info()}
d.Flash = r.URL.Query().Get("saved")
d.Warn = trackWarnMessage(r.URL.Query().Get("warn"))
s.render(w, "admin/settings", d)
}
@@ -581,57 +583,105 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request) {
if r.PostFormValue("regions_mode") == setting.RegionsBlacklist {
next.RegionsMode = setting.RegionsBlacklist
}
next.TrackScript = cleanTrackScript(r.PostFormValue("track_script"))
// 开关只在「勾选 + 脚本非空」时才生效:勾了但没填内容等于没开,
// 否则 layout 里会挂一个空 <script>,看着像开了其实什么也没统计。
next.TrackEnabled = r.PostFormValue("track_enabled") == "on" && next.TrackScript != ""
script, trackWarn := cleanTrackScript(r.PostFormValue("track_script"))
if trackWarnRejected(trackWarn) {
// 判为危险:这次改动整段不采纳,库里原有的脚本与开关都保持不动
// (next := cur 已经带上了它们)。把站长手上本来好用的脚本一并清掉,
// 比拒绝这次改动更糟。
} else {
next.TrackScript = script
// 开关只在「勾选 + 脚本非空」时才生效:勾了但没填内容等于没开,
// 否则 layout 里会挂一个空 <script>,看着像开了其实什么也没统计。
next.TrackEnabled = r.PostFormValue("track_enabled") == "on" && next.TrackScript != ""
}
if err := s.sets.Save(ctx, next); err != nil {
s.serverError(w, r, err)
return
}
http.Redirect(w, r, "/admin/settings?saved=1", http.StatusSeeOther)
dest := "/admin/settings?saved=1"
if trackWarn != "" {
dest += "&warn=" + url.QueryEscape(trackWarn)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// cleanTrackScript 收拾管理员粘进来的统计脚本。
// 统计脚本没被原样采纳时的三类说明码。它们是固定常量、不是用户输入,
// 所以可以安全地放进跳转查询串,再由 trackWarnMessage 翻成人话。
const (
warnTrackTruncated = "truncated"
warnTrackUnbalanced = "unbalanced"
warnTrackBadSrc = "badsrc"
)
// trackWarnMessage 把说明码翻成人话;未识别的码不提示(不回显查询串原文)。
func trackWarnMessage(code string) string {
switch code {
case warnTrackTruncated:
return "统计脚本超过 4096 字符,已截断保存"
case warnTrackUnbalanced:
return "统计脚本里 <script> 与 </script> 的数量不配对,本次改动已拒绝,库里原有脚本保持不变"
case warnTrackBadSrc:
return "统计脚本的 src 只接受 http://、https:// 或站内相对路径,本次改动已拒绝,库里原有脚本保持不变"
}
return ""
}
// trackWarnRejected 报告这个说明码是否代表「整段没被采纳」。
func trackWarnRejected(code string) bool {
return code == warnTrackUnbalanced || code == warnTrackBadSrc
}
// cleanTrackScript 收拾管理员粘进来的统计脚本,返回采纳后的内容与一个说明码(空 = 原样通过)。
//
// 这段内容会被原样注入**每一个公开页面**,所以要挡住几类会伤到访客的输入。
// 但**不能**简单地把「出现 </script 就拒」当规则:百度统计、Umami 这类服务
// 贴进来的就是完整 HTML,一行里必然带 </script>,一拒就把正常用法挡死了。
//
// 真正的注入面只有一种:**未闭合的 <script> 里出现 </script> 会提前闭合**,
// 后面的内容当正文渲染,页面布局散架。判据因此是「奇偶」:
// 开标签数与闭合标签数不配平 ⇒ 有提前闭合 ⇒ 拒。配平则整段原样放行。
// 两条判据:
// - 开闭 <script> 标签必须**严格配平**:少一个 </script> 会提前闭合、后面的内容
// 当正文渲染(页面布局散架),多一个 <script> 则会把页面剩下的收尾整段吞进脚本。
// - 开标签上的 src 只放行 http(s):// 与站内相对路径,挡掉 javascript: / data: 伪协议。
//
// 另外挡住:javascript: / data: 这类伪协议的 src(点击即执行),
// 以及超长内容(多半是误粘了整份 HTML 文档,截断)。
func cleanTrackScript(v string) string {
// src 只在**标签内部**找。以前是对整段做 strings.Index(low, "src="),于是脚本体里
// 一句 `img.src=u` 就会被命中、整段被判危险而清空——页面却照样提示「设置已保存」,
// 站长根本看不出脚本没挂上(实测复现过)。
func cleanTrackScript(v string) (script, warn string) {
v = strings.TrimSpace(v)
if v == "" {
return ""
return "", ""
}
const maxLen = 4096
if len(v) > maxLen {
v = v[:maxLen]
warn = warnTrackTruncated
}
low := strings.ToLower(v)
// 开闭标签数必须配平,否则有人能靠 </script> 提前闭合、把后面的内容
// 从「脚本」变成「正文」注入到页面里。
open := strings.Count(low, "<script")
// 去掉属性里的 <script 字样不算开标签(如 data-src="<script"),这里只做保守计数:
// 宁可多拒一次,也别放过真的注入。
shut := strings.Count(low, "</script")
if shut > open {
return ""
}
// src 只放行 http/https 与站内相对路径,挡掉 javascript: / data: 伪协议
if idx := strings.Index(low, "src="); idx >= 0 {
rest := strings.TrimLeft(v[idx+4:], "\"'")
if rest != "" && !strings.HasPrefix(rest, "http://") &&
!strings.HasPrefix(rest, "https://") && !strings.HasPrefix(rest, "/") {
return ""
open := 0
for i := 0; ; {
j := strings.Index(low[i:], "<script")
if j < 0 {
break
}
start := i + j
open++
end := strings.IndexByte(low[start:], '>')
if end < 0 {
break // 这个开标签没闭合,交给下面的配平判定处理
}
tag := low[start : start+end]
if k := strings.Index(tag, "src="); k >= 0 {
rest := strings.TrimLeft(tag[k+4:], "'\" \t")
if rest != "" && !strings.HasPrefix(rest, "http://") &&
!strings.HasPrefix(rest, "https://") && !strings.HasPrefix(rest, "/") {
return "", warnTrackBadSrc
}
}
i = start + end + 1
}
return v
if shut := strings.Count(low, "</script"); open != shut {
return "", warnTrackUnbalanced
}
return v, warn
}
// ---------- 修改口令 ----------
+1
View File
@@ -369,6 +369,7 @@ func (s *Server) home(w http.ResponseWriter, r *http.Request) {
d.MaxCity = maxOf(d.CityStats, func(c stats.CityStat) int { return c.Count })
d.MaxTransport = maxOf(d.Transports, func(t stats.TransportStat) int { return t.Count })
d.MaxTag = maxOf(d.Tags, func(t stats.TagStat) int { return t.Count })
d.MaxMonthly = maxOf(d.Monthly, func(m MonthBar) int { return m.Count })
s.render(w, "home", d)
}
+59
View File
@@ -0,0 +1,59 @@
package web
import (
"bytes"
"html/template"
"strings"
"testing"
"rond-with-you/internal/auth"
"rond-with-you/internal/setting"
)
// TestNavShowsAllPagesToAdmin 钉住导航的显隐口径:页面开关**只管访客**。
//
// 站长的公开页一直是能访问的(guardPage 只拦访客),导航却没跟着放行——
// 关掉页面开关后站长自己也只剩「主页」一个入口,明明能访问却点不到。
// 这条口径必须和 guardPage 保持一致,所以两边都写死在这里。
func TestNavShowsAllPagesToAdmin(t *testing.T) {
base, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(tmplFS, "templates/layout.html")
if err != nil {
t.Fatalf("解析 layout.html: %v", err)
}
renderNav := func(p Page) string {
t.Helper()
var buf bytes.Buffer
if err := base.ExecuteTemplate(&buf, "nav", p); err != nil {
t.Fatalf("渲染 nav: %v", err)
}
return buf.String()
}
links := []string{`href="/map"`, `href="/places"`, `href="/timeline"`, `href="/stats"`, `href="/report"`}
// 页面开关全关:访客点不到,站长必须照样有入口
closed := setting.Default()
closed.Pages = nil
adminNav := renderNav(Page{Nav: "home", Settings: closed, User: &auth.User{}})
for _, l := range links {
if !strings.Contains(adminNav, l) {
t.Errorf("站点关闭了页面开关,但站长导航少了 %s——开关只管访客", l)
}
}
visitorNav := renderNav(Page{Nav: "home", Settings: closed})
for _, l := range links {
if strings.Contains(visitorNav, l) {
t.Errorf("页面已对访客关闭,访客导航里不该出现 %s", l)
}
}
// 反向:全开放时访客也要看得到(防止把判据改成「只有站长」)
openNav := renderNav(Page{Nav: "home", Settings: setting.Default()})
for _, l := range links {
if !strings.Contains(openNav, l) {
t.Errorf("页面已对访客开放,访客导航里应出现 %s", l)
}
}
}
+5
View File
@@ -131,6 +131,11 @@ func New(cfg *config.Config, db *sql.DB) (*Server, error) {
media: media.New(cfg),
}
s.routes()
// 启动自检:把每个页面各渲染一遍,任一页失败就拒绝启动。
// 「模板能解析」不代表「渲染得完」——字段不存在、nil 解引用都只在执行期暴露。
if err := s.smokeRender(); err != nil {
return nil, err
}
return s, nil
}
+332 -165
View File
@@ -19,11 +19,70 @@
--row-hover: #fbfcfc;
--bg-2: #fafbfb; /* 比卡片略深一层的面(行内编辑表单、空态框) */
--on-accent: #ffffff; /* 主色底上的文字 */
/* ---------- 类型色(活动 / 交通方式)----------
色名是 rond 里的 Apple 日历色名(与 Go 侧 appleColors 的键一一对应,模板经
csscolor / csscolorsoft 取用)。地图那份调色板在 rond-map.js 里、**故意独立**:
地图瓦片在暗色下保持浅色,标记得用浅色底上那套值,不能跟着页面的暗色变量走。
⚠️ 这套色的**明度差异很大**:在白底上 yellow 1.87、mint 2.12、orange 2.20、green 2.22、
cyan 2.54、teal 2.57 —— 都远低于图形对象 3:1。所以它们只能当「身份标记」(圆点、柱条),
**别拿去当文字色或承载唯一信息的图形**,这也正是全站只用小圆点而不是彩色标签的原因。 */
--cat-red: #ff3b30; --cat-orange: #ff9500; --cat-yellow: #e8b700;
--cat-green: #34c759; --cat-mint: #00c7be; --cat-teal: #30b0c7;
--cat-cyan: #32ade6; --cat-blue: #007aff; --cat-indigo: #5856d6;
--cat-purple: #af52de; --cat-pink: #ff2d55; --cat-brown: #a2845e;
--cat-gray: #8e8e93;
/* 色名不认识时的兜底,与 Go 里 cssColor 的 var(--cat-none) 对齐 */
--cat-none: #5b6169;
/* 浅底(emoji 方块那种):半透明叠在卡片上,浅色暗色自适应。
原来是模板里给 hex 拼 "22" 当 alpha——换成 3 位色值或 var() 就散了,这里收口。 */
--cat-red-soft: rgba(255, 59, 48, .14); --cat-orange-soft: rgba(255, 149, 0, .14);
--cat-yellow-soft: rgba(232, 183, 0, .14); --cat-green-soft: rgba(52, 199, 89, .14);
--cat-mint-soft: rgba(0, 199, 190, .14); --cat-teal-soft: rgba(48, 176, 199, .14);
--cat-cyan-soft: rgba(50, 173, 230, .14); --cat-blue-soft: rgba(0, 122, 255, .14);
--cat-indigo-soft: rgba(88, 86, 214, .14); --cat-purple-soft: rgba(175, 82, 222, .14);
--cat-pink-soft: rgba(255, 45, 85, .14); --cat-brown-soft: rgba(162, 132, 94, .16);
--cat-gray-soft: rgba(142, 142, 147, .16);
--cat-none-soft: rgba(91, 97, 105, .14);
--ok-bg: #eef8f5; --ok-fg: #17604f; --ok-line: #cfe6e0;
--err-bg: #fdf3f2; --err-fg: #b42318; --err-line: #f3dcda;
--info-bg: #f2f6fd; --info-fg: #2c5282; --info-line: #dbe5f5;
--radius: 10px;
--shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 1px 3px rgba(16, 24, 40, .06);
/* 圆角 4 档。原来是 7 / 8 / 9 / 10 一堆近似值混着用,肉眼分不出、改一处要改一片。 */
--r-xs: 3px; /* 小方块(热力格、气温柱顶) */
--r-sm: 6px; /* 控件(按钮、输入框、导航链接、标签) */
--r-md: 8px; /* 中号面(缩略图、提示条) */
--r-lg: 12px; /* 卡片与浮层 */
/* 阴影 3 档,暗色模式整套覆盖(见文件末尾的 prefers-color-scheme) */
--sh-1: 0 1px 2px rgba(16, 24, 40, .04), 0 1px 3px rgba(16, 24, 40, .06);
--sh-2: 0 4px 14px rgba(16, 24, 40, .10), 0 1px 3px rgba(16, 24, 40, .06);
--sh-3: 0 10px 30px rgba(16, 24, 40, .12), 0 2px 6px rgba(16, 24, 40, .06);
/* 间距 5 档。本批只用在卡片内边距这类「主节奏」上,其余逐批替换。 */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px;
/* 卡片内边距单列一个变量:折叠卡片的标题行要用负 margin 顶到边(见 .filters-card),
两处必须永远相等,所以绑成同一个值。 */
--card-pad: 18px;
/* ---------- 字阶 ----------
文本只有 6 档。原来是 10/10.5/11/11.5/12/12.5/12.8/13/13.5/14/14.5/15/17/18/21px
十几档挤在一起,12~15px 之间就塞了 4 档 —— 层级挤不出来,只能靠加粗硬顶。 */
--fs-xs: 11px; /* 脚注 / 单位 / 轴标 / 图例 */
--fs-sm: 13px; /* 正文(默认) */
--fs-md: 15px; /* 卡片标题 / 强调文本 */
--fs-lg: 21px; /* 页面标题 */
--fs-xl: 26px; /* KPI 数值 */
--fs-2xl: 34px; /* 首屏主数 */
/* 表单控件字号单独一档:窄屏必须 ≥16px,否则 iOS 一聚焦就把整页放大。
它不属于文本层级,所以不并进上面的字阶。 */
--fs-control: 16px;
/* 字形尺寸(✕ / 箭头 / 空态图标 / 表情):按控件尺寸定,不是文字层级。
故意不进字阶 —— 把 22px 的 ✕ 塞进 26px 的字阶只会把按钮撑坏。 */
--glyph-xs: 12px;
--glyph-sm: 15px;
--glyph-md: 18px;
--glyph-lg: 22px;
--glyph-xl: 30px;
--glyph-2xl: 40px;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 顶栏高度:窄屏更矮。地图页的定位跟它对齐,避免出现几像素的缝。 */
--nav-h: 54px;
@@ -33,6 +92,10 @@
分两档是为了保住原有次序:加载状态条要压在所有浮层之上。 */
--z-map-overlay: 1100;
--z-map-status: 1150;
/* 顶栏。必须压过地图页的浮层(--z-map-overlay = 1100):窄屏的导航菜单是从顶栏
展开的,而它属于 .nav 这个层叠上下文、自己抬不动 —— 只有把整条顶栏放到浮层之上,
展开后才不会被地图的筛选面板盖住(实测过 elementFromPoint)。 */
--z-nav: 1200;
/* 刘海屏 / 底部 Home 指示条的安全区。不支持 env() 的浏览器回落到 0。 */
--safe-t: env(safe-area-inset-top, 0px);
--safe-r: env(safe-area-inset-right, 0px);
@@ -47,17 +110,28 @@ html, body {
padding: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
font-size: var(--fs-sm);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
/* 显式给字号。这条规则以前只管字重和 margin,字号靠浏览器默认的 2em / 1.5em / 1.17em
—— 也就是跟着**根字号**漂:根从 14px 降到 13px 后,没被 `.card > h2` 命中的那些 h2
从 21px 悄悄变成 19.5px(/stats 的「我的结论」「访客点赞」两块),比同页卡片标题大一圈。 */
h1 { font-size: var(--fs-lg); }
h2 { font-size: var(--fs-md); }
h3, h4 { font-size: var(--fs-sm); }
/* <b>/<strong> 的浏览器默认是 bold(700),而全站层级只到 600 —— 不钉住的话
行内加粗会比标题还重。 */
b, strong { font-weight: 600; }
/* ---------- 顶栏 ---------- */
.nav {
position: sticky; top: 0; z-index: 500;
position: sticky; top: 0; z-index: var(--z-nav);
background: var(--glass);
backdrop-filter: saturate(1.6) blur(10px);
border-bottom: 1px solid var(--line);
@@ -66,41 +140,108 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
}
.nav-inner {
max-width: 1120px; margin: 0 auto; padding: 0 20px;
/* 菜单是相对 .nav-inner 绝对定位的(要铺满整条),所以这里得有定位上下文 */
position: relative;
height: var(--nav-h); display: flex; align-items: center; gap: 22px;
}
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text); font-size: 15px; }
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text); font-size: var(--fs-md); }
.brand:hover { text-decoration: none; }
.brand .avatar { font-size: 18px; }
.nav-links { display: flex; gap: 4px; flex: 1; }
.brand .avatar { font-size: var(--glyph-md); }
/* 链接行自带横向兜底:620px 以下整行会收进 .nav-menu,这条就只为「以后又加了入口、
宽屏也放不下」兜底 —— 宁可让它能滑,也别把最右边那个字切掉一半。 */
.nav-links { display: flex; gap: 4px; flex: 1; overflow-x: auto; scrollbar-width: none; }
.nav-links::-webkit-scrollbar { display: none; }
/* 还能往右滑时右缘渐隐(由 nav 的脚本按 scrollLeft 加/去这个类)。用 mask 而不是盖一层
渐变伪元素:伪元素会把链接文字一起遮住,mask 是让内容本身渐隐。 */
.nav-links.overflow-right {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}
.nav-links a {
padding: 5px 11px; border-radius: 7px; color: var(--text-2); font-size: 13.5px; white-space: nowrap;
padding: 5px 11px; border-radius: var(--r-sm); color: var(--text-2); font-size: var(--fs-sm); white-space: nowrap;
}
.nav-links a:hover { background: var(--line-soft); color: var(--text); text-decoration: none; }
.nav-links a.on { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
.nav-right { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--text-3); }
.nav-right { display: flex; align-items: center; gap: 10px; font-size: var(--fs-sm); color: var(--text-3); }
/* 窄屏导航菜单。620px 以下链接行一定放不下(390 溢 63px、320 溢 133px),整行收进这里。
用原生 <details>,不靠 JS 也能开合;JS 只补三件原生不给的事:点外面 / Esc / 点链接后收起。 */
.nav-menu { display: none; }
.nav-menu > summary {
list-style: none; cursor: pointer; user-select: none; white-space: nowrap;
display: inline-flex; align-items: center; gap: 4px;
min-height: 44px; padding: 0 10px; border-radius: var(--r-sm);
color: var(--text-2); font-size: var(--fs-sm);
}
.nav-menu > summary::-webkit-details-marker { display: none; }
.nav-menu > summary::after { content: "▾"; font-size: var(--glyph-xs); color: var(--text-3); }
.nav-menu[open] > summary { background: var(--accent-soft); color: var(--accent); }
.nav-menu[open] > summary::after { content: "▴"; }
.nav-menu-body {
position: absolute; left: 0; right: 0; top: 100%; z-index: 1;
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px; padding: 6px;
background: var(--panel); border-bottom: 1px solid var(--line); box-shadow: var(--sh-2);
}
.nav-menu-body a {
display: flex; align-items: center; min-height: 44px; padding: 0 12px;
border-radius: var(--r-sm); color: var(--text-2); font-size: var(--fs-sm);
}
.nav-menu-body a:hover { background: var(--line-soft); color: var(--text); text-decoration: none; }
.nav-menu-body a.on { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
/* ---------- 布局 ---------- */
.wrap { max-width: 1120px; margin: 0 auto;
padding: 22px calc(20px + var(--safe-r)) calc(60px + var(--safe-b)) calc(20px + var(--safe-l)); }
.page-head { margin-bottom: 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.page-head h1 { font-size: 21px; }
.page-head .sub { color: var(--text-3); font-size: 13px; margin-top: 3px; }
.page-head h1 { font-size: var(--fs-lg); }
.page-head .sub { color: var(--text-3); font-size: var(--fs-sm); margin-top: 3px; }
.card {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 18px; margin-bottom: 16px;
background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg);
box-shadow: var(--sh-1); padding: var(--card-pad); margin-bottom: var(--sp-4);
}
.card > h2 { font-size: 15px; margin-bottom: 2px; display: flex; align-items: center; gap: 8px; }
.card > h2 { font-size: var(--fs-md); margin-bottom: 2px; display: flex; align-items: center; gap: var(--sp-2); }
/* .card-body 是数据编辑页折叠用的容器,卡片内容被它包住后不再是 .card 的直接子元素,
所以下面两条用「后代」而不是「子」选择器——否则折叠脚本一加上,间距就全没了。 */
.card .card-sub { color: var(--text-3); font-size: 12.5px; margin-bottom: 14px; }
.card .card-sub { color: var(--text-3); font-size: var(--fs-sm); margin-bottom: var(--sp-3); }
.card.collapsed .card-body { display: none; }
/* 卡片三档。原来全站只有一种白盒(量下来 17 个盒子只有 2 种样式),KPI / 地图 / 图表 /
表格 / 筛选器全等重,扫视时没有落点。分档不是装饰,是为了「一眼知道先看哪」:
hero 只给首屏主数区与地图,quiet 只给纯说明性的内容。 */
.card--hero { border-color: transparent; box-shadow: var(--sh-3); padding: calc(var(--card-pad) + var(--sp-2)); }
/* quiet 用页面底色 + 细边 + 无阴影 = 贴在页面上的扁平面板。
注意别拿 --bg-2 (#fafbfb) 当底色:它和页面底 #f6f7f8 只差 4/255,
实测根本看不出边界,卡片会像「破了个洞」而不是「轻了一档」。 */
.card--quiet { background: var(--bg); border-color: var(--line); box-shadow: none; }
.card--quiet > h2 { font-size: var(--fs-sm); color: var(--text-2); }
/* 首屏主数:把最重要的那个数从 KPI 格里拎出来单独一行。
原来 8 个数字一样大一样重,扫过去不知道看哪个 —— 层级靠「只让一个特别大」,
而不是把所有的都调大。 */
.kpi-lead {
position: relative; overflow: hidden;
background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg);
box-shadow: var(--sh-3);
padding: calc(var(--card-pad) + var(--sp-1)) var(--card-pad) var(--card-pad);
margin-bottom: var(--sp-3);
}
.kpi-lead::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent);
}
.kpi-lead .kl-v {
font-size: clamp(var(--fs-xl), 7.2vw, var(--fs-2xl)); font-weight: 600;
letter-spacing: -.02em; line-height: 1.1; font-variant-numeric: tabular-nums;
}
.kpi-lead .kl-v small { font-size: var(--fs-md); font-weight: 500; color: var(--text-3); margin-left: var(--sp-1); }
.kpi-lead .kl-k { color: var(--text-2); font-size: var(--fs-sm); margin-top: var(--sp-1); }
.kpi-lead .kl-x { color: var(--text-3); font-size: var(--fs-sm); margin-top: var(--sp-2); }
/* 只有带 data-card 的卡片(数据编辑页)能折叠:标题整行可点,右侧一个箭头 */
.card[data-card] > h2 { cursor: pointer; }
.card[data-card] > h2 .card-toggle {
margin-left: auto; flex: 0 0 auto; cursor: pointer;
background: none; border: 0; padding: 0 2px; color: var(--text-3);
font: inherit; font-size: 12px; line-height: 1;
font: inherit; font-size: var(--glyph-xs); line-height: 1;
}
.card[data-card] > h2 .card-toggle:hover { color: var(--text); }
.grid { display: grid; gap: 16px; }
@@ -118,21 +259,13 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
.g2, .g3, .g4 { grid-template-columns: 1fr; }
.wrap { padding: 16px calc(14px + var(--safe-r)) calc(48px + var(--safe-b)) calc(14px + var(--safe-l)); }
.nav-inner { padding: 0 10px; gap: 6px; }
/* 窄屏只留头像,导航可横向滚动,避免换行把布局撑坏 */
/* 窄屏只留头像 */
.brand .t { display: none; }
.nav-links { overflow-x: auto; scrollbar-width: none; gap: 0; }
.nav-links::-webkit-scrollbar { display: none; }
/* 还能往右滑的时候右缘渐隐一下(由 nav 后面那几行脚本按 scrollLeft 加/去这个类)。
用 mask 而不是盖一层渐变伪元素:伪元素会把链接文字一起遮住,mask 是让内容本身渐隐。
不支持 mask 的浏览器只是少个提示,滑动照旧。 */
.nav-links.overflow-right {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}
/* 内边距由下面「窄屏触控」那块给(同特异性、写在后面才生效);这里只留字号。 */
.nav-links a { font-size: 13px; }
/* 链接行整行收进 .nav-menu(样式见上面那段),这里不再横向滚动 */
.nav-links { display: none; }
.nav-menu { display: block; flex: 1; }
.nav-right .who { display: none; }
.nav-right .btn.sm { padding: 4px 9px; font-size: 12.5px; }
.nav-right .btn.sm { padding: 4px 9px; font-size: var(--fs-sm); }
}
/* ---------- 窄屏触控与表单(移动端适配) ---------- */
@@ -164,26 +297,26 @@ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
/* 用 flex + grow 而不是固定列数的网格:KPI 个数常常是 6、7 这种除不尽列数的,
网格会在最后一行留下空格子——而间隙是用底色画的,空格子就露成灰块,很难看。
flex 会让最后一行自己撑满,任何个数都不会有空格。 */
.kpis { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); }
.kpi { flex: 1 1 158px; min-width: 0; background: var(--panel); padding: 15px 16px; }
.kpis { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line); border-radius: var(--r-lg); overflow: hidden; border: 1px solid var(--line); }
.kpi { flex: 1 1 158px; min-width: 0; background: var(--panel); padding: var(--sp-4); }
/* 字号跟着视口收:窄屏上「112天19小时」这种值放不下就会折成两行,把整行撑高 */
.kpi .v { font-size: clamp(17px, 5.6vw, 23px); font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.kpi .v small { font-size: 13px; font-weight: 500; color: var(--text-3); margin-left: 3px; }
.kpi .k { color: var(--text-3); font-size: 12.5px; margin-top: 1px; }
.kpi .v { font-size: clamp(var(--fs-md), 5.6vw, var(--fs-xl)); font-weight: 600; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.kpi .v small { font-size: var(--fs-sm); font-weight: 500; color: var(--text-3); margin-left: 3px; }
.kpi .k { color: var(--text-3); font-size: var(--fs-sm); margin-top: 1px; }
/* ---------- 图表(纯 CSS/SVG) ---------- */
.bars { display: flex; flex-direction: column; gap: 9px; }
.bar-row { display: grid; grid-template-columns: 128px 1fr 104px; align-items: center; gap: 10px; font-size: 13px; }
.bar-row { display: grid; grid-template-columns: 128px 1fr 104px; align-items: center; gap: 10px; font-size: var(--fs-sm); }
/* 整行可点(下钻为筛选):给一点悬停反馈,并让点击区域覆盖整行;
柱条本身只有 9px 高,靠「行」来接收点击才够手指点 */
a.bar-row { margin: 0 -6px; padding: 4px 6px; border-radius: 6px; color: inherit; text-decoration: none; }
a.bar-row { margin: 0 -6px; padding: 4px 6px; border-radius: var(--r-sm); color: inherit; text-decoration: none; }
a.bar-row:hover { background: var(--line-soft); text-decoration: none; }
a.col { display: block; text-decoration: none; }
a.col:hover { opacity: 1; filter: brightness(1.12); }
.bar-row .nm { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-track { background: var(--line-soft); border-radius: 5px; height: 9px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 5px; background: var(--accent); min-width: 2px; }
.bar-row .vv { text-align: right; color: var(--text-2); font-variant-numeric: tabular-nums; font-size: 12.5px; white-space: nowrap; }
.bar-track { background: var(--line-soft); border-radius: var(--r-sm); height: 9px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: var(--r-sm); background: var(--accent); min-width: 2px; }
.bar-row .vv { text-align: right; color: var(--text-2); font-variant-numeric: tabular-nums; font-size: var(--fs-sm); white-space: nowrap; }
@media (max-width: 620px) {
.bar-row { grid-template-columns: 84px 1fr 92px; }
/* 图表行在手机上要能点中:整行 ~37px。再往上加(44px)会把「类型分布」这种
@@ -191,35 +324,54 @@ a.col:hover { opacity: 1; filter: brightness(1.12); }
a.bar-row { padding: 8px 6px; }
}
.cols { display: flex; align-items: flex-end; gap: 3px; height: 120px; }
.cols .col { flex: 1; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; opacity: .85; }
/* 基线:柱子底下压一道浅线,让「高度从哪儿量起」有个参照(原来柱子直接悬在空白里,
高低只能靠互相比)。
padding-top 是给峰值柱顶上那个数字留的位置:height 走 border-box,内容区仍是 120px。 */
.cols { position: relative; display: flex; align-items: flex-end; gap: 3px; height: 136px; padding-top: 16px; border-bottom: 1px solid var(--line); }
.cols .col { position: relative; flex: 1; background: var(--accent); border-radius: var(--r-xs) var(--r-xs) 0 0; min-height: 2px; opacity: .8; }
.cols .col:hover { opacity: 1; }
.cols-x { display: flex; gap: 3px; font-size: 10px; color: var(--text-3); margin-top: 5px; }
/* 峰值柱:不透明拉满,并在顶上标出次数。数值本来写在副标题里,但它不告诉你「哪一根最高」,
标在柱子上才是能一眼找到的锚点。 */
.cols .col.peak { opacity: 1; }
.cols .col .pv {
position: absolute; left: 50%; bottom: calc(100% + 2px); transform: translateX(-50%);
font-size: var(--fs-xs); line-height: 1.2; color: var(--text-2);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.cols-x { display: flex; gap: 3px; font-size: var(--fs-xs); color: var(--text-3); margin-top: 5px; }
.cols-x span { flex: 1; text-align: center; overflow: hidden; white-space: nowrap; }
/* 一天 24 根柱,光看柱子分不出「上午 / 下午」。这条标尺用同一套 24 列网格(不是 flex:6),
所以每段边界和柱子严格对齐;每段直接写上时段和小时区间,就不用再单独留一行坐标轴。 */
.cols-band { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); gap: 3px; margin-top: var(--sp-2); }
.cols-band span {
grid-column: span 6; text-align: center; white-space: nowrap;
font-size: var(--fs-xs); color: var(--text-3);
background: var(--line-soft); border-radius: var(--r-xs); padding: 2px 0;
}
/* ---------- 逐月气温 ---------- */
.temp-chart { margin-top: 16px; }
.temp-head { font-size: 12.5px; color: var(--text-3); margin-bottom: 8px; }
.temp-head { font-size: var(--fs-sm); color: var(--text-3); margin-bottom: 8px; }
/* 横向可滚动:柱子固定宽,十几二十个月挤在窄屏上也不会把标签压成一团 */
.temp-strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 4px; }
.temp-col { flex: 0 0 36px; display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: default; }
.temp-col .tc-v { font-size: 10.5px; color: var(--text-2); font-variant-numeric: tabular-nums; }
.temp-col .tc-v { font-size: var(--fs-xs); color: var(--text-2); font-variant-numeric: tabular-nums; }
.temp-col .tc-track { width: 100%; height: 84px; display: flex; align-items: flex-end; }
.temp-col .tc-bar { width: 100%; border-radius: 4px 4px 0 0; min-height: 3px; }
.temp-col .tc-m { font-size: 10.5px; color: var(--text-3); white-space: nowrap; }
.temp-col .tc-bar { width: 100%; border-radius: var(--r-xs) var(--r-xs) 0 0; min-height: 3px; }
.temp-col .tc-m { font-size: var(--fs-xs); color: var(--text-3); white-space: nowrap; }
.temp-col:hover .tc-bar { filter: brightness(1.08); }
.temp-col:hover .tc-v { color: var(--text); }
/* ---------- 日历热力 ---------- */
.heat { display: flex; gap: 3px; overflow-x: auto; padding-bottom: 4px; }
.heat .wk { display: flex; flex-direction: column; gap: 3px; }
.heat .d { width: 11px; height: 11px; border-radius: 2.5px; background: var(--line-soft); }
.heat .d { width: 11px; height: 11px; border-radius: var(--r-xs); background: var(--line-soft); }
.heat .d.l1 { background: #cfe6e0; }
.heat .d.l2 { background: #9dcfc3; }
.heat .d.l3 { background: #5fae9c; }
.heat .d.l4 { background: var(--accent); }
.heat-legend { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--text-3); margin-top: 8px; }
.heat-legend .d { width: 11px; height: 11px; border-radius: 2.5px; }
.heat-legend { display: flex; align-items: center; gap: 5px; font-size: var(--fs-xs); color: var(--text-3); margin-top: 8px; }
.heat-legend .d { width: 11px; height: 11px; border-radius: var(--r-xs); }
/* ---------- 列表 ---------- */
.list { display: flex; flex-direction: column; }
@@ -229,33 +381,33 @@ a.col:hover { opacity: 1; filter: brightness(1.12); }
}
.item:last-child { border-bottom: 0; }
.item .ic {
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center; justify-content: center;
background: var(--line-soft); font-size: 17px;
width: 34px; height: 34px; border-radius: var(--r-md); display: flex; align-items: center; justify-content: center;
background: var(--line-soft); font-size: var(--glyph-md);
}
.item .t { font-weight: 500; }
.item .t a { color: var(--text); }
.item .m { color: var(--text-3); font-size: 12.5px; margin-top: 1px; }
.item .r { text-align: right; color: var(--text-2); font-size: 12.5px; white-space: nowrap; }
.item .m { color: var(--text-3); font-size: var(--fs-sm); margin-top: 1px; }
.item .r { text-align: right; color: var(--text-2); font-size: var(--fs-sm); white-space: nowrap; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 20px;
background: var(--line-soft); color: var(--text-2); font-size: 12.5px; border: 1px solid transparent;
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px;
background: var(--line-soft); color: var(--text-2); font-size: var(--fs-sm); border: 1px solid transparent;
}
.chip.on { background: var(--accent-soft); color: var(--accent); border-color: #cfe6e0; font-weight: 500; }
.chip .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 5px; background: var(--accent-soft); color: var(--accent); font-size: 11.5px; }
.tag { display: inline-block; padding: 1px 7px; border-radius: var(--r-sm); background: var(--accent-soft); color: var(--accent); font-size: var(--fs-xs); }
/* ---------- 表单 ---------- */
label.f { display: block; margin-bottom: 13px; }
label.f > span { display: block; font-size: 12.5px; color: var(--text-2); margin-bottom: 5px; }
label.f > span { display: block; font-size: var(--fs-sm); color: var(--text-2); margin-bottom: 5px; }
/* 必填标记:字段名后的红色星号,以及表单顶部的统一说明 */
.req { color: #b42318; font-weight: 700; margin-left: 2px; }
.req-tip { font-size: 12.5px; color: var(--text-2); margin: 0 0 10px; }
.req { color: #b42318; font-weight: 600; margin-left: 2px; }
.req-tip { font-size: var(--fs-sm); color: var(--text-2); margin: 0 0 10px; }
.req-tip .req { margin-right: 3px; }
/* flatpickr 日期时间选择器:贴合站点风格 */
.flatpickr-calendar { border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.14); font-family: inherit; }
.flatpickr-calendar { border-radius: var(--r-lg); box-shadow: var(--sh-2); font-family: inherit; }
.flatpickr-day.selected, .flatpickr-day.selected:hover { background: var(--accent); border-color: var(--accent); }
.flatpickr-day.today { border-color: var(--accent); }
.flatpickr-day.today:hover { background: var(--accent-soft); color: var(--text); }
@@ -268,30 +420,30 @@ label.f > span { display: block; font-size: 12.5px; color: var(--text-2); margin
没有圆角、没有内边距、13.33px 的字,跟旁边的输入框明显不是一套。 */
input:not([type=checkbox]):not([type=radio]):not([type=color]):not([type=range]):not([type=file]):not([type=submit]):not([type=button]):not([type=reset]):not([type=image]):not([type=hidden]),
select, textarea {
width: 100%; padding: 7px 10px; border: 1px solid var(--line); border-radius: 7px; font: inherit;
width: 100%; padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--r-sm); font: inherit;
font-size: var(--field-fs, inherit);
background: var(--surface); color: var(--text); outline: none;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.check { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-2); margin-bottom: 8px; word-break: keep-all; overflow-wrap: break-word; }
.check { display: flex; align-items: center; gap: 7px; font-size: var(--fs-sm); color: var(--text-2); margin-bottom: 8px; word-break: keep-all; overflow-wrap: break-word; }
.check input { width: 15px; height: 15px; accent-color: var(--accent); }
.nobr { white-space: nowrap; }
.btn {
display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 7px;
border: 1px solid var(--line); background: var(--surface); color: var(--text); font: inherit; font-size: 13.5px; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: var(--r-sm);
border: 1px solid var(--line); background: var(--surface); color: var(--text); font: inherit; font-size: var(--fs-sm); cursor: pointer;
}
.btn:hover { background: var(--line-soft); text-decoration: none; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.btn.primary:hover { filter: brightness(1.06); }
.btn.sm { padding: 4px 10px; font-size: 12.5px; }
.btn.sm { padding: 4px 10px; font-size: var(--fs-sm); }
.btn.danger { color: #b42318; border-color: #f0cecb; }
.btn.danger:hover { background: var(--err-bg); }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
/* ---------- 表格 ---------- */
table.tb { width: 100%; border-collapse: collapse; font-size: 13px; }
table.tb { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); }
table.tb th {
text-align: left; font-weight: 500; color: var(--text-3); font-size: 12px;
text-align: left; font-weight: 500; color: var(--text-3); font-size: var(--fs-sm);
padding: 7px 10px; border-bottom: 1px solid var(--line); white-space: nowrap;
}
table.tb td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); }
@@ -323,7 +475,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
.map-panel {
position: absolute; top: 12px; left: 12px; z-index: var(--z-map-overlay); width: 286px; max-height: calc(100% - 24px);
overflow-y: auto; background: var(--glass-strong); backdrop-filter: blur(8px);
border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--sh-2);
}
/* 内边距挂在表单上,不挂面板:窄屏的折叠条与操作行要 sticky 到面板的**内边缘**,
面板自己带 padding 的话它们只能粘在内容区边界上,边上会留一条缝让滚动内容穿过去
@@ -334,7 +486,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
/* 「含家/工作」单独一行(不属于时间段预设)。窄屏预设行是单行横滑的,
它留在里面会被滑到屏幕外;单独一行上下各留 10px,读起来也像另一个开关。 */
.map-panel .hw-row { margin-bottom: 10px; }
.map-panel h3 { font-size: 13px; margin-bottom: 9px; color: var(--text-2); }
.map-panel h3 { font-size: var(--fs-sm); margin-bottom: 9px; color: var(--text-2); }
/* 筛选面板的折叠开关:桌面上面板只占 286px、不碍事,按钮直接不显示,只在窄屏出现 */
.panel-fold { display: none; }
/* 筛选面板里可折叠的两块(地点类型 / 更多筛选):这两块在窄屏各占两百多像素,
@@ -342,7 +494,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
收起时标题里显示已选/已生效的那几项,否则收起后就不知道当前选了什么。 */
.type-fold > summary, .adv-fold > summary {
list-style: none; display: flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-2); cursor: pointer; padding: 2px 0 8px;
font-size: var(--fs-sm); color: var(--text-2); cursor: pointer; padding: 2px 0 8px;
}
.type-fold > summary::-webkit-details-marker, .adv-fold > summary::-webkit-details-marker { display: none; }
.type-fold > summary:hover, .adv-fold > summary:hover { color: var(--accent); }
@@ -350,17 +502,17 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
flex: 0 1 auto; min-width: 0; color: var(--text-3);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.type-fold > summary .tf-arrow, .adv-fold > summary .tf-arrow { flex: 0 0 auto; margin-left: auto; font-size: 11px; color: var(--text-3); }
.type-fold > summary .tf-arrow, .adv-fold > summary .tf-arrow { flex: 0 0 auto; margin-left: auto; font-size: var(--fs-xs); color: var(--text-3); }
.type-fold[open] > summary .tf-arrow, .adv-fold[open] > summary .tf-arrow { transform: rotate(90deg); }
/* 展开后标签行/控件自己就说明了当前值,标题里那份就不必再占一行 */
.type-fold[open] > summary .tf-sel, .adv-fold[open] > summary .tf-sel { display: none; }
/* 「更多筛选」里的 h3 是二级标题,别跟折叠标题撞成一样大 */
.adv-fold > h3 { font-size: 12.5px; color: var(--text-3); margin-bottom: 8px; }
.adv-fold > h3 { font-size: var(--fs-sm); color: var(--text-3); margin-bottom: 8px; }
.map-legend {
/* bottom 让开右下角那行底图版权(约 15px 高),否则两者会叠在一起 */
position: absolute; bottom: 26px; right: 12px; z-index: var(--z-map-overlay); background: var(--glass-strong);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px; font-size: 12px;
max-height: 40%; overflow-y: auto; box-shadow: var(--shadow);
border: 1px solid var(--line); border-radius: var(--r-md); padding: 8px 11px; font-size: var(--fs-sm);
max-height: 40%; overflow-y: auto; box-shadow: var(--sh-2);
}
.map-legend .lg { display: flex; align-items: center; gap: 6px; margin-top: 4px; color: var(--text-2); }
.map-legend .lg i { width: 9px; height: 9px; border-radius: 50%; }
@@ -369,11 +521,11 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
.fog-opacity { background: var(--surface); padding: 6px 8px; line-height: 1.2; }
.fog-opacity .fog-label {
display: block; text-align: left; border: 0; background: none; padding: 0;
font: inherit; font-size: 11px; color: var(--text-2); cursor: default;
font: inherit; font-size: var(--fs-xs); color: var(--text-2); cursor: default;
}
.fog-opacity .fog-slider { display: block; width: 96px; margin: 4px 0 0; accent-color: var(--accent); }
@media (max-width: 620px) {
.fog-opacity .fog-label { cursor: pointer; font-size: 12px; }
.fog-opacity .fog-label { cursor: pointer; font-size: var(--fs-sm); }
.fog-opacity .fog-slider { display: none; }
.fog-opacity.open .fog-slider { display: block; }
}
@@ -382,8 +534,8 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
.to-top {
position: fixed; right: calc(16px + var(--safe-r)); bottom: calc(20px + var(--safe-b)); z-index: var(--z-map-status);
width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line);
background: var(--panel); color: var(--text-2); font-size: 20px; line-height: 1;
cursor: pointer; box-shadow: var(--shadow);
background: var(--panel); color: var(--text-2); font-size: var(--glyph-lg); line-height: 1;
cursor: pointer; box-shadow: var(--sh-2);
}
.to-top[hidden] { display: none; }
.to-top:hover { color: var(--text); border-color: var(--text-3); }
@@ -430,23 +582,23 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
bottom: 12px; z-index: var(--z-map-overlay);
display: flex; align-items: center; gap: 10px;
max-width: min(560px, calc(100% - 24px));
padding: 7px 12px; border-radius: 10px;
background: var(--glass-strong); border: 1px solid var(--line); box-shadow: var(--shadow);
padding: 7px 12px; border-radius: var(--r-lg);
background: var(--glass-strong); border: 1px solid var(--line); box-shadow: var(--sh-2);
backdrop-filter: blur(8px);
}
.timeline-bar[hidden] { display: none; }
.timeline-bar input[type=range] { flex: 1 1 auto; min-width: 90px; accent-color: var(--accent); margin: 0; }
.timeline-bar .tl-label { flex: 0 0 auto; font-size: 12px; color: var(--text-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
.timeline-bar .tl-label { flex: 0 0 auto; font-size: var(--fs-sm); color: var(--text-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
.timeline-bar button {
flex: 0 0 auto; border: 1px solid var(--line); background: var(--surface); color: var(--text);
border-radius: 7px; cursor: pointer; font: inherit; font-size: 12px; line-height: 1;
border-radius: var(--r-sm); cursor: pointer; font: inherit; font-size: var(--fs-sm); line-height: 1;
}
.timeline-bar .tl-play { width: 30px; height: 30px; font-size: 13px; padding: 0; }
.timeline-bar .tl-play { width: 30px; height: 30px; font-size: var(--fs-sm); padding: 0; }
.timeline-bar .tl-reset { padding: 7px 9px; }
.timeline-bar button:hover { background: var(--line-soft); }
@media (max-width: 620px) {
.timeline-bar { bottom: 26px; gap: 8px; padding: 6px 9px; }
.timeline-bar .tl-label { font-size: 11.5px; }
.timeline-bar .tl-label { font-size: var(--fs-xs); }
}
/* 地图上的状态条(加载失败 + 重试)。放底部中间:左上角是筛选面板、右下是图例与版权。 */
@@ -455,7 +607,7 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
bottom: 62px;
z-index: 600; display: flex; align-items: center; gap: 10px;
max-width: calc(100% - 24px); background: rgba(30,40,50,.92); color: #fff;
border-radius: 8px; padding: 8px 12px; font-size: 13px; box-shadow: var(--shadow);
border-radius: var(--r-md); padding: 8px 12px; font-size: var(--fs-sm); box-shadow: var(--sh-2);
}
.map-status[hidden] { display: none; }
.map-status .btn { border-color: rgba(255,255,255,.35); background: rgba(255,255,255,.12); color: #fff; flex: 0 0 auto; }
@@ -476,12 +628,12 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
于是它只盖住左边一小条,右边的内容会从粘性条旁边穿过去。 */
display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px;
padding: 0 13px; border: 0; background: var(--glass-strong); color: var(--text-2);
font: inherit; font-size: 13.5px; font-weight: 500; text-align: left; cursor: pointer;
font: inherit; font-size: var(--fs-sm); font-weight: 500; text-align: left; cursor: pointer;
position: sticky; top: 0; z-index: 2;
}
.map-panel:not(.collapsed) .panel-fold { border-bottom: 1px solid var(--line); }
.panel-fold .pf-n { color: var(--text-3); font-size: 12px; font-weight: 400; }
.panel-fold .pf-c { margin-left: auto; padding-left: 4px; font-size: 12px; color: var(--text-3); }
.panel-fold .pf-n { color: var(--text-3); font-size: var(--fs-sm); font-weight: 400; }
.panel-fold .pf-c { margin-left: auto; padding-left: var(--sp-1); font-size: var(--glyph-xs); color: var(--text-3); }
.panel-fold:hover { color: var(--text); }
.panel-fold:hover .pf-c { color: var(--text); }
/* 「查询 / 重置」粘在面板底部:展开后表单 600+px、可视窗不到 400px,
@@ -500,21 +652,30 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
.map-panel .presets > .chip { flex: 0 0 auto; }
.map-panel .map-count { display: none; }
}
.map-preview { height: 320px; border-radius: 8px; overflow: hidden; border: 1px solid var(--line); }
/* Leaflet 把 .leaflet-container 的字号写成 0.75rem(见 vendor/leaflet/leaflet.css 的
「general typography」),于是它跟着**根字号**漂:根从 14px 降到 13px 后,
右下角的署名与比例尺缩到了 9.8px(掉到本站 11px 的下限之下),弹窗正文也跟着缩。
这里按自己的字阶钉死,分两档:容器 = 正文(弹窗正文是 1.08333em,跟着它走),
署名与比例尺 = 脚注。 */
.leaflet-container { font-size: var(--fs-sm); }
.leaflet-container .leaflet-control-attribution,
.leaflet-container .leaflet-control-scale-line { font-size: var(--fs-xs); }
.map-preview { height: 320px; border-radius: var(--r-md); overflow: hidden; border: 1px solid var(--line); }
/* ---------- 地点图片 ---------- */
/* 管理面板里的缩略图墙 */
.imgs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.imgs-cell { position: relative; margin: 0; border-radius: 8px; overflow: hidden; border: 1px solid var(--line); background: var(--line-soft); aspect-ratio: 4 / 3; }
.imgs-cell { position: relative; margin: 0; border-radius: var(--r-md); overflow: hidden; border: 1px solid var(--line); background: var(--line-soft); aspect-ratio: 4 / 3; }
.imgs-cell img { width: 100%; height: 100%; object-fit: cover; display: block; cursor: zoom-in; }
.imgs-del {
position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border-radius: 50%;
border: 0; background: rgba(0,0,0,.55); color: #fff; font-size: 15px; line-height: 1;
border: 0; background: rgba(0,0,0,.55); color: #fff; font-size: var(--glyph-sm); line-height: 1;
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.imgs-del:hover { background: #b42318; }
.imgs-busy { opacity: .45; pointer-events: none; }
.imgs-hint { font-size: 12.5px; color: var(--text-3); margin-top: 8px; min-height: 18px; }
.imgs-hint { font-size: var(--fs-sm); color: var(--text-3); margin-top: 8px; min-height: 18px; }
.imgs-hint.ok { color: #17604f; }
.imgs-hint.err { color: #b42318; }
/* 存储没配好时把「选择图片」变成不可点的样子,而不是留个能点但必失败的按钮 */
@@ -530,26 +691,26 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
/* 横滑留给我们做切换,纵向留给浏览器;pinch-zoom 让双指放大仍可用系统手势 */
touch-action: pan-y pinch-zoom;
}
.imgs-stage img { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; border-radius: 8px; }
.imgs-stage img { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; border-radius: var(--r-md); }
/* 双击放大:图放大到两倍、容器可拖动看局部(用尺寸而不是 transform,放大后不糊) */
.imgs-stage.zoomed { touch-action: pan-x pan-y; align-items: flex-start; justify-content: flex-start; }
.imgs-stage.zoomed img { max-width: none; max-height: none; width: 200%; border-radius: 0; }
.imgs-close {
position: fixed; top: calc(12px + var(--safe-t)); right: calc(14px + var(--safe-r)); z-index: 2001;
width: 44px; height: 44px; border-radius: 50%;
border: 1px solid rgba(255,255,255,.3); background: rgba(0,0,0,.4); color: #fff; font-size: 22px;
border: 1px solid rgba(255,255,255,.3); background: rgba(0,0,0,.4); color: #fff; font-size: var(--glyph-lg);
line-height: 1; cursor: pointer;
}
.imgs-close:hover { background: rgba(255,255,255,.18); }
.imgs-count {
position: fixed; top: calc(20px + var(--safe-t)); left: calc(18px + var(--safe-l)); z-index: 2001;
color: rgba(255,255,255,.78); font-size: 13px;
color: rgba(255,255,255,.78); font-size: var(--fs-sm);
}
/* 上一张 / 下一张:做成整条竖带,拇指够得着,不用瞄小按钮 */
.imgs-nav {
position: fixed; top: 50%; transform: translateY(-50%); z-index: 2001;
width: 44px; height: 76px; border: 0; border-radius: 10px; cursor: pointer;
background: rgba(255,255,255,.12); color: #fff; font-size: 30px; line-height: 1;
width: 44px; height: 76px; border: 0; border-radius: var(--r-lg); cursor: pointer;
background: rgba(255,255,255,.12); color: #fff; font-size: var(--glyph-xl); line-height: 1;
}
.imgs-nav:hover { background: rgba(255,255,255,.24); }
.imgs-prev { left: calc(6px + var(--safe-l)); }
@@ -558,26 +719,26 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
/* ---------- 「停一下」提示页(内容对访客不开放之类) ---------- */
.notice-body {
min-height: 100vh; margin: 0; display: flex; align-items: center; justify-content: center;
padding: 24px 18px; background: var(--bg);
padding: var(--sp-5) 18px; background: var(--bg);
}
.notice-card {
max-width: 420px; text-align: center; background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); padding: 30px 26px; box-shadow: var(--shadow);
border-radius: var(--r-lg); padding: 30px 26px; box-shadow: var(--sh-2);
}
.notice-card .notice-ico { font-size: 40px; line-height: 1; margin-bottom: 12px; }
.notice-card h1 { font-size: 18px; margin: 0 0 8px; }
.notice-card p { color: var(--text-2); font-size: 13.5px; margin: 0 0 18px; }
.notice-card .notice-tip { color: var(--text-3); font-size: 12px; margin-top: 12px; }
.notice-card .notice-ico { font-size: var(--glyph-2xl); line-height: 1; margin-bottom: var(--sp-3); }
.notice-card h1 { font-size: var(--fs-lg); margin: 0 0 var(--sp-2); }
.notice-card p { color: var(--text-2); font-size: var(--fs-sm); margin: 0 0 18px; }
.notice-card .notice-tip { color: var(--text-3); font-size: var(--fs-sm); margin-top: 12px; }
/* 后台设置页的「标签 / 值」信息行(地图底图、图片存储状态)。
这个 class 一直没写样式,之前是两段光秃秃的 inline 文本;长一点的 URL
(比如对象存储的 endpoint)会把整页撑宽 —— 所以要 minmax(0,1fr) + 允许断词。 */
.kv { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 4px 12px; font-size: 13px; }
.kv { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 4px 12px; font-size: var(--fs-sm); }
.kv .k { color: var(--text-3); }
.kv .v { color: var(--text); overflow-wrap: anywhere; }
@media (max-width: 620px) {
.kv { grid-template-columns: 1fr; gap: 2px; }
.kv .k { font-size: 12px; }
.kv .k { font-size: var(--fs-sm); }
}
/* URL 这类超长 token 不该把容器顶宽 */
.mono { overflow-wrap: anywhere; }
@@ -608,13 +769,19 @@ table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-
--bg-2: #191d21;
/* 浅绿底上用白字只有 2.4:1,反过来用深字才够 */
--on-accent: #0f1b18;
/* 类型色只有 indigo 在深底上不够:#5856d6 对 #1c2024 只有 2.90:1,其余都在 3.9 以上。
掺 10% 白提亮到 3.55:1。别的色一概不动 —— 它们本来就是浅亮色,深底上只会更清楚,
改了反而与手机端 / 地图上的颜色对不上。 */
--cat-indigo: #6967da;
--ok-bg: #16302a; --ok-fg: #7fd3bb; --ok-line: #245046;
--err-bg: #33201e; --err-fg: #f0a49b; --err-line: #5a3330;
--info-bg: #1b2534; --info-fg: #9dbdea; --info-line: #2f4055;
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .35);
--sh-1: 0 1px 2px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .35);
--sh-2: 0 6px 18px rgba(0, 0, 0, .50);
--sh-3: 0 12px 34px rgba(0, 0, 0, .55);
}
/* 兜底:还有两处没走变量的白底 */
.flatpickr-calendar { background: var(--panel); border-color: var(--line); box-shadow: var(--shadow); }
.flatpickr-calendar { background: var(--panel); border-color: var(--line); box-shadow: var(--sh-2); }
.flatpickr-day { color: var(--text); }
.flatpickr-day.prevMonthDay, .flatpickr-day.nextMonthDay { color: var(--text-3); }
.flatpickr-day:hover { background: var(--line-soft); color: var(--text); }
@@ -667,40 +834,40 @@ input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus
/* ---------- 「已选条件」行 ---------- */
.active-filters { margin-bottom: 12px; align-items: center; }
.active-filters .af-label { font-size: 12.5px; color: var(--text-3); }
.active-filters .af-label { font-size: var(--fs-sm); color: var(--text-3); }
.active-filters .chip { user-select: none; }
.active-filters .af-x { margin-left: 4px; opacity: .6; font-size: 13px; }
.active-filters .af-x { margin-left: 4px; opacity: .6; font-size: var(--fs-sm); }
.active-filters .chip:hover .af-x { opacity: 1; }
.active-filters .af-clear { background: transparent; color: var(--text-3); border-color: var(--line); }
/* ---------- 其他 ---------- */
.alert { padding: 9px 13px; border-radius: 8px; font-size: 13px; margin-bottom: 14px; }
.alert { padding: 9px 13px; border-radius: var(--r-md); font-size: var(--fs-sm); margin-bottom: 14px; }
.alert.err { background: var(--err-bg); color: var(--err-fg); border: 1px solid var(--err-line); }
.alert.ok { background: var(--ok-bg); color: var(--ok-fg); border: 1px solid var(--ok-line); }
.alert.info { background: var(--info-bg); color: var(--info-fg); border: 1px solid var(--info-line); }
.muted { color: var(--text-3); }
.mono { font-family: var(--mono); font-size: 12px; }
.mono { font-family: var(--mono); font-size: var(--fs-sm); }
.empty { text-align: center; padding: 40px 16px; color: var(--text-3); }
.empty .big { font-size: 30px; margin-bottom: 8px; }
.pager { display: flex; gap: 6px; align-items: center; justify-content: center; margin-top: 18px; font-size: 13px; }
.pager a, .pager span { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); color: var(--text-2); }
.empty .big { font-size: var(--glyph-xl); margin-bottom: var(--sp-2); }
.pager { display: flex; gap: 6px; align-items: center; justify-content: center; margin-top: 18px; font-size: var(--fs-sm); }
.pager a, .pager span { padding: 4px 10px; border-radius: var(--r-sm); border: 1px solid var(--line); color: var(--text-2); }
.pager a:hover { background: var(--line-soft); text-decoration: none; }
.pager .cur { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-3); font-size: 12.5px; text-align: center; }
footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-3); font-size: var(--fs-sm); text-align: center; }
.timeline-day { margin-bottom: 20px; }
.timeline-day .dh {
position: sticky; top: var(--nav-h); background: var(--bg); z-index: 5; padding: 6px 0 7px;
font-size: 13px; color: var(--text-2); font-weight: 500; display: flex; gap: 8px; align-items: baseline;
font-size: var(--fs-sm); color: var(--text-2); font-weight: 500; display: flex; gap: 8px; align-items: baseline;
}
.timeline-day .dh .muted { font-size: 12px; }
.timeline-day .dh .muted { font-size: var(--fs-sm); }
.tl-item { display: grid; grid-template-columns: 62px 20px 1fr; gap: 10px; align-items: start; padding: 7px 0; }
.tl-item .tm { color: var(--text-3); font-size: 12.5px; font-variant-numeric: tabular-nums; padding-top: 1px; }
.tl-item .tm { color: var(--text-3); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; padding-top: 1px; }
.tl-item .ln { display: flex; justify-content: center; position: relative; }
.tl-item .ln::before { content: ""; position: absolute; top: 12px; bottom: -12px; width: 1px; background: var(--line); }
.tl-item:last-child .ln::before { display: none; }
.tl-item .ln i { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); margin-top: 5px; position: relative; z-index: 1; }
.tl-item .bd { font-size: 13.5px; }
.tl-item .bd .meta { color: var(--text-3); font-size: 12.5px; }
.tl-item .bd { font-size: var(--fs-sm); }
.tl-item .bd .meta { color: var(--text-3); font-size: var(--fs-sm); }
/* 聚合气泡改用主题色,默认的黄蓝配色和站点风格不一致 */
.marker-cluster-small,
@@ -716,7 +883,7 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
/* 更多筛选类型 */
.more-types { margin: 2px 0 10px; }
.more-types summary { font-size: 12.5px; color: var(--text-3); cursor: pointer; padding: 3px 0; }
.more-types summary { font-size: var(--fs-sm); color: var(--text-3); cursor: pointer; padding: 3px 0; }
.more-types summary:hover { color: var(--accent); }
.more-types .chips { margin-top: 8px; }
@@ -726,14 +893,14 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
/* 推荐 / 踩雷 表态按钮 */
.vote-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.vote-label { color: var(--text-3); font-size: 12.5px; min-width: 62px; }
.vote-label { color: var(--text-3); font-size: var(--fs-sm); min-width: 62px; }
/* 「推荐 / 踩雷」必须并排:把它们包成不可换行的组,窄屏时只让后面那句提示换行 */
.vote-btns { display: inline-flex; gap: 8px; flex-wrap: nowrap; }
.vote-btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 14px; border-radius: 999px; white-space: nowrap; flex: 0 0 auto;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
font-size: 13.5px; font-weight: 500; cursor: pointer; line-height: 1;
font-size: var(--fs-sm); font-weight: 500; cursor: pointer; line-height: 1;
transition: background .12s, border-color .12s, color .12s;
}
.vote-btn:hover { border-color: var(--accent); color: var(--accent); }
@@ -749,7 +916,7 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
.btn.danger { color: var(--err-fg); border-color: var(--err-line); }
}
.vote-btn:disabled { opacity: .55; cursor: default; }
.vote-tip { font-size: 12.5px; color: var(--text-3); }
.vote-tip { font-size: var(--fs-sm); color: var(--text-3); }
.vote-tip.up { color: #1f8f43; }
.vote-tip.down { color: #c62a20; }
/* 站长页面上访客票数只读展示:沿用「我的结论」同一套行结构,尺寸/行距完全一致,只弱化配色 */
@@ -767,22 +934,22 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
/* 地图气泡:地点信息 + 只读备注 + 就地表决 */
.mp { min-width: 240px; max-width: 300px; }
.mp-n { font-weight: 600; margin-bottom: 2px; }
.mp-m { color: var(--text-3); font-size: 12px; line-height: 1.6; }
.mp-m { color: var(--text-3); font-size: var(--fs-sm); line-height: 1.6; }
.mp-note {
margin-top: 6px; padding: 6px 8px; border-radius: 6px;
margin-top: 6px; padding: 6px 8px; border-radius: var(--r-sm);
background: var(--bg, #f5f6f8); color: var(--text);
font-size: 12.5px; line-height: 1.55; white-space: pre-line;
font-size: var(--fs-sm); line-height: 1.55; white-space: pre-line;
max-height: 150px; overflow-y: auto;
}
.mp-foot { margin-top: 7px; font-size: 12.5px; }
.mp-foot { margin-top: 7px; font-size: var(--fs-sm); }
/* nowrap:气泡本来就窄,「👎 踩雷」被挤到第二行会很难看 */
.mp-vote { display: flex; align-items: center; gap: 6px; margin-top: 8px; flex-wrap: nowrap; }
.mp-vl { flex: 0 0 auto; color: var(--text-3); font-size: 12px; white-space: nowrap; }
.mp-vl { flex: 0 0 auto; color: var(--text-3); font-size: var(--fs-sm); white-space: nowrap; }
.mp-vb {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 999px;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
font-size: 12.5px; font-weight: 500; line-height: 1; cursor: pointer;
font-size: var(--fs-sm); font-weight: 500; line-height: 1; cursor: pointer;
flex: 0 0 auto; white-space: nowrap;
transition: background .12s, border-color .12s, color .12s;
}
@@ -794,9 +961,9 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
/* 备注块:rond 回填的备注是按次一行的,必须保留换行 */
.note-block {
margin-top: 4px; padding: 7px 9px; border-radius: 8px;
margin-top: 4px; padding: 7px 9px; border-radius: var(--r-md);
background: var(--bg, #f5f6f8); color: var(--text-2);
font-size: 12.8px; line-height: 1.65; white-space: pre-line;
font-size: var(--fs-sm); line-height: 1.65; white-space: pre-line;
}
/* 报告导出为 PDF / 长图:隐藏导航、页脚与操作按钮,卡片避免跨页断裂 */
@@ -820,7 +987,7 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
.inline-edit[open] { display: block; }
/* 行内编辑表单的宽度:桌面靠 --efw 撑到够填(各表不同,见模板里的 inline 变量),
放不下宽表时下面会放开这个下限——不然 320px 的表单会把 294px 的卡片顶破。 */
.inline-edit form { background: var(--bg-2, #fafbfb); border: 1px solid var(--line); border-radius: 10px; padding: 12px; min-width: var(--efw, 280px); }
.inline-edit form { background: var(--bg-2, #fafbfb); border: 1px solid var(--line); border-radius: var(--r-lg); padding: 12px; min-width: var(--efw, 280px); }
.inline-edit form .f { margin-bottom: 8px; }
/* 「按名称搜坐标」输入框 + 按钮:必须始终同一行(含移动端),底边对齐。
@@ -828,7 +995,7 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
* 且 .row 会换行。这里显式 nowrap + 归零外边距,并让输入框可收缩。 */
.search-row { display: flex; flex-wrap: nowrap; align-items: flex-end; gap: 8px; margin-bottom: 12px; }
.search-row > label.f { flex: 1 1 auto; min-width: 0; margin-bottom: 0; }
.search-row .btn { flex: 0 0 auto; font-size: 14px; }
.search-row .btn { flex: 0 0 auto; font-size: var(--fs-sm); }
.search-row > .act { display: flex; flex: 0 0 auto; gap: 8px; }
/* 按名称搜坐标的候选列表:整行可点,名称与地址分两行 */
@@ -850,31 +1017,31 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
.filter-row > .act { display: flex; flex: 0 0 auto; gap: 8px; }
.geo-item {
display: block; width: 100%; text-align: left; cursor: pointer;
padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px;
padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--r-md);
background: var(--panel); color: var(--text); font: inherit;
}
.geo-item:hover { background: var(--line-soft); }
.geo-name { display: block; font-size: 13.5px; }
.geo-addr { display: block; color: var(--text-3); font-size: 12px; margin-top: 2px; }
.geo-name { display: block; font-size: var(--fs-sm); }
.geo-addr { display: block; color: var(--text-3); font-size: var(--fs-sm); margin-top: 2px; }
/* ---------- 专题 ---------- */
/* 必须带上 #map:站点里 #map{height:100%} 是 ID 选择器,
单靠 .topic-map 这个类压不过它,容器会被算成 0 高(地图看不见)。 */
#map.topic-map { height: 520px; width: 100%; border-radius: 10px; overflow: hidden; }
#map.topic-map { height: 520px; width: 100%; border-radius: var(--r-lg); overflow: hidden; }
@media (max-width: 620px) { #map.topic-map { height: 360px; } }
.topic-card { display: block; text-decoration: none; color: inherit; transition: box-shadow .15s, transform .15s; }
.topic-card:hover { box-shadow: 0 6px 22px rgba(0,0,0,.08); transform: translateY(-1px); }
.topic-card:hover { box-shadow: var(--sh-2); transform: translateY(-1px); }
.topic-head { display: flex; align-items: center; gap: 8px; }
.topic-head h2 { margin: 0; }
/* 地图兜底提示:脚本没就绪时留在容器里,解释为什么没有地图 */
.map-fallback {
display: flex; align-items: center; justify-content: center; height: 100%;
padding: 20px; text-align: center; color: var(--text-3); font-size: 13px;
padding: 20px; text-align: center; color: var(--text-3); font-size: var(--fs-sm);
}
/* 专题:时间范围独立成行(精确到分钟,塞进 KPI 网格会被挤成多行) */
.topic-range { margin-top: 5px; color: var(--text-2); font-size: 13.5px; font-variant-numeric: tabular-nums; }
.topic-range { margin-top: 5px; color: var(--text-2); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.topic-card .topic-range { margin: 7px 0 0; }
.topic-desc { color: var(--text-2); white-space: pre-wrap; line-height: 1.65; }
.kpis.kpis-3 .kpi { flex: 1 1 30%; }
@@ -882,13 +1049,13 @@ footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-
/* 三个数字都很短,窄屏保持三列比 2+1 更好看 */
.topic-card .kpis.kpis-3 .kpi { flex: 1 1 30%; }
.topic-card .kpi { padding: 11px 12px; }
.topic-range { font-size: 12.5px; }
.topic-range { font-size: var(--fs-sm); }
}
/* ---------- 表单里的「文字 + 内联按钮」组合 ---------- */
/* label 的文字行右侧放次要操作(活动改名/删除),不额外占高度,也就不会把整格撑高 */
label.f > span.span-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.mini-actions { display: flex; gap: 8px; font-size: 12px; }
.mini-actions { display: flex; gap: 8px; font-size: var(--fs-sm); }
.mini-actions a { color: var(--text-3); }
.mini-actions a:hover { color: var(--accent); }
.mini-actions a.danger:hover { color: #b42318; }
@@ -900,7 +1067,7 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
/* 到访列表里的收藏星标:点一下就切换,不用展开行内编辑 */
.star {
background: none; border: 0; padding: 0 3px; cursor: pointer; line-height: 1;
font-size: 15px; color: var(--text-3); vertical-align: -1px;
font-size: var(--glyph-sm); color: var(--text-3); vertical-align: -1px;
}
.star:hover { color: #e8b700; }
.star.on { color: #e8b700; }
@@ -912,11 +1079,11 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
background: rgba(0, 0, 0, .45);
}
.point-modal-box {
width: 100%; max-width: 660px; background: var(--panel); border-radius: 12px;
padding: 14px; box-shadow: 0 12px 40px rgba(0, 0, 0, .25);
width: 100%; max-width: 660px; background: var(--panel); border-radius: var(--r-lg);
padding: 14px; box-shadow: var(--sh-3);
}
.point-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.point-modal-map { height: 360px; border-radius: 10px; overflow: hidden; }
.point-modal-map { height: 360px; border-radius: var(--r-lg); overflow: hidden; }
@media (max-width: 620px) {
#point-modal { padding: 16px 10px; }
.point-modal-map { height: 260px; }
@@ -931,7 +1098,7 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
.photo-table { table-layout: fixed; width: 100%; min-width: 880px; }
.photo-table th, .photo-table td { padding: 7px 8px; vertical-align: top; }
.photo-table input, .photo-table select { width: 100%; min-width: 0; box-sizing: border-box; }
.photo-table input[type=datetime-local] { font-size: 12.5px; padding: 6px 6px; }
.photo-table input[type=datetime-local] { font-size: var(--fs-sm); padding: 6px 6px; }
.photo-table input[type=checkbox] { width: auto; }
.photo-table .chip { white-space: nowrap; }
@@ -954,14 +1121,14 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
border-bottom: 1px solid var(--line-soft);
}
.photo-map {
height: 200px; width: 100%; border-radius: 10px; overflow: hidden;
height: 200px; width: 100%; border-radius: var(--r-lg); overflow: hidden;
border: 1px solid var(--line); transition: height .18s ease;
}
/* 粘住以后收矮:200px 的浮层会挡住从下面滚过去的半个屏幕,收到 132px 还能看清
高亮出来的那个点,占的高度也只剩一条。说明文字这时候就没必要留着了。 */
#map-card.stuck .photo-map { height: 132px; }
#map-card.stuck .card-sub { display: none; }
#map-card.stuck { box-shadow: 0 6px 14px rgba(16, 24, 40, .07); }
#map-card.stuck { box-shadow: var(--sh-1); }
@media (max-width: 700px) {
.photo-map { height: 170px; }
#map-card.stuck .photo-map { height: 110px; }
@@ -993,9 +1160,9 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
table.tb select, .photo-table select { min-height: 0; }
/* file / color 这两种被基础规则排除在外(不套文本框外观),也就没吃到
--field-fs,窄屏仍是 13.3px ⇒ iOS 一聚焦就放大。单独提上来。 */
input[type=file], input[type=color] { font-size: 16px; }
input[type=file], input[type=color] { font-size: var(--fs-control); }
/* 11.5px 的标签在手机上偏小(/stats 一页 29 处、/ 15 处) */
.tag { font-size: 12px; }
.tag { font-size: var(--fs-sm); }
/* 导航左上角那个 logo 是回首页的入口,原来只有 25×29:给它撑到 44 高,
横向也补到 44(.brand .t 在窄屏被隐藏,加内边距就够了)。 */
.brand { min-width: 44px; min-height: 44px; justify-content: center; }
@@ -1007,10 +1174,10 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
top 取 --nav-h:导航是 sticky 的,写 0 会被压在它下面。
这些规则整条在媒体查询里 ⇒ 桌面端 .collapsed 不生效,等于没影响。 */
.filters-card > .panel-fold {
margin: -18px -18px 14px; border-radius: var(--radius) var(--radius) 0 0; top: var(--nav-h);
margin: calc(var(--card-pad) * -1) calc(var(--card-pad) * -1) 14px; border-radius: var(--r-lg) var(--r-lg) 0 0; top: var(--nav-h);
}
.filters-card.collapsed { padding: 0; }
.filters-card.collapsed > .panel-fold { margin: 0; border-radius: var(--radius); }
.filters-card.collapsed > .panel-fold { margin: 0; border-radius: var(--r-lg); }
.filters-card.collapsed > :not(.panel-fold) { display: none; }
}
@@ -1038,21 +1205,21 @@ label.f > span.span-actions { display: flex; align-items: center; justify-conten
*/
dialog.prompt-box {
border: 1px solid var(--line);
border-radius: var(--radius);
border-radius: var(--r-lg);
padding: 0;
min-width: 0; width: min(420px, calc(100vw - 32px));
background: var(--surface);
color: var(--text);
box-shadow: 0 8px 28px rgba(16, 24, 40, .18);
box-shadow: var(--sh-2);
}
dialog.prompt-box::backdrop { background: rgba(16, 24, 40, .38); }
.prompt-box .pb-body { padding: 14px 16px 12px; }
.prompt-box .pb-title { font-size: 14.5px; font-weight: 500; margin-bottom: 8px; }
.prompt-box .pb-title { font-size: var(--fs-md); font-weight: 500; margin-bottom: 8px; }
/* 输入框字号必须 ≥16px:iOS Safari 聚焦时会自动放大页面,16px 以下会把布局顶歪 */
.prompt-box .pb-input {
width: 100%; font-size: 16px; font-family: inherit; color: var(--text);
width: 100%; font-size: var(--fs-control); font-family: inherit; color: var(--text);
background: var(--surface); border: 1px solid var(--line);
border-radius: 7px; padding: 7px 10px; min-height: 40px;
border-radius: var(--r-sm); padding: 7px 10px; min-height: 40px;
}
.prompt-box .pb-input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
.prompt-box .pb-acts { display: flex; gap: 8px; justify-content: flex-end; padding: 0 16px 14px; }
@@ -1067,12 +1234,12 @@ dialog.prompt-box::backdrop { background: rgba(16, 24, 40, .38); }
.prompt-fallback {
position: fixed; inset: 8px 8px auto 8px; z-index: 2000;
display: flex; gap: 8px; align-items: center;
padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); box-shadow: 0 8px 28px rgba(16, 24, 40, .18);
padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r-lg);
background: var(--surface); box-shadow: var(--sh-2);
}
.prompt-fallback input {
flex: 1; min-width: 0; font-size: 16px; font-family: inherit;
border: 1px solid var(--line); border-radius: 7px; padding: 7px 10px; min-height: 40px;
flex: 1; min-width: 0; font-size: var(--fs-control); font-family: inherit;
border: 1px solid var(--line); border-radius: var(--r-sm); padding: 7px 10px; min-height: 40px;
}
@media (max-width: 800px) {
@@ -1092,7 +1259,7 @@ dialog.prompt-box::backdrop { background: rgba(16, 24, 40, .38); }
}
.tw > table.tb.tb-stack > tbody > tr > td::before {
content: attr(data-label); position: absolute; left: 0; top: 2px;
width: 5em; color: var(--text-3); font-size: 12px;
width: 5em; color: var(--text-3); font-size: var(--fs-sm);
}
/* 首格是这一行的主角(地点名 / 天气 / 方式…):不缩进、字号抬一档当小标题;
操作格也是这一类(表头为空、整行铺开)。它们里面的内容是长名称或编辑表单,
@@ -1100,7 +1267,7 @@ dialog.prompt-box::backdrop { background: rgba(16, 24, 40, .38); }
于是「已选:#403 2026-09-13 19:53 · 某某地」这一行在窄处不换行、把卡片顶出 44px。
堆叠态每格本来就是一整行,nowrap 没有意义,放开。 */
.tw > table.tb.tb-stack > tbody > tr > td:not([data-label]) {
padding-left: 0; margin-bottom: 2px; font-size: 14px; white-space: normal;
padding-left: 0; margin-bottom: 2px; font-size: var(--fs-sm); white-space: normal;
}
/* 操作格(有的带「操作」标签、有的表头本来就是空的)在堆叠态整行铺开,
桌面那条 nowrap 会让格里的编辑表单不换行、把卡片顶出几十像素 */
+99
View File
@@ -0,0 +1,99 @@
package web
import (
"bytes"
"fmt"
"strings"
"rond-with-you/internal/setting"
)
// 启动时的模板自检:把每个页面各用一份「最小可用数据」渲染一遍,任一页失败就拒绝启动。
//
// 防的是一类**只在执行期暴露**的错误:模板引用了 Go 结构体里不存在的字段
// (典型是改了结构体却没重新编译,磁盘上的新模板被老二进制拿去渲染),
// 或者模板里对着 nil 解引用。这类错误既不会让模板解析失败,render 又只把错误写进日志,
// 于是线上表现成「页面渲染到一半就断了、HTTP 却仍是 200」——用户对着半张没有收尾的页面
// 猜半天,服务端日志里只有孤零零一行。
//
// 2026-10-09 就是这么踩的:提交里同时改了 setting.Settings 和 settings.html,
// 但跑的是改动前编译的二进制,debug 模式下模板走磁盘热加载 ⇒ 新模板 + 旧结构体,
// /admin/settings 在「统计脚本」那张卡片处被截断。启动自检能把这种组合直接挡在门外。
//
// 探针值只求「类型对、不炸」:这些页面本来就得能渲染空数据(还没导入数据时的首页就是空的),
// 所以空值探针是合理的下限,而不是凑数。
var pageProbe = map[string]func() any{
"home": func() any { return HomeData{Page: probePage("home")} },
"map": func() any { return MapData{Page: probePage("map")} },
"places": func() any { return PlacesData{Page: probePage("places")} },
"place": func() any { return PlaceData{Page: probePage("places")} },
"timeline": func() any { return TimelineData{Page: probePage("timeline")} },
"stats": func() any { return StatsData{Page: probePage("stats")} },
"report": func() any { return ReportData{Page: probePage("report")} },
"topics": func() any { return TopicListData{Page: probePage("topics")} },
"topic": func() any { return TopicData{Page: probePage("topics")} },
"admin/index": func() any { return AdminData{Page: probePage("admin"), MaxSize: maxUploadBytes} },
"admin/password": func() any { return PasswordData{Page: probePage("admin"), Username: "admin"} },
"admin/twofa": func() any { return TwoFAData{Page: probePage("admin")} },
"admin/fog-mask": func() any { return FogMaskData{Page: probePage("admin")} },
"admin/fog-preview": func() any { return FogPreviewData{Page: probePage("admin")} },
"admin/backup-preview": func() any { return BackupDiffData{Page: probePage("admin")} },
"admin/baseline": func() any { return BaselineData{Page: probePage("admin"), MaxSize: maxUploadBytes} },
"admin/fillup": func() any { return FillupData{Page: probePage("admin")} },
"admin/notes": func() any { return NotesData{Page: probePage("admin")} },
"admin/edit": func() any { return EditData{Page: probePage("admin")} },
"admin/topics": func() any { return TopicAdminData{Page: probePage("admin")} },
"admin/photos": func() any { return PhotoData{Page: probePage("admin")} },
"admin/login": func() any { return LoginData{Settings: setting.Default()} },
"admin/twofa-login": func() any { return TwoFALoginData{Settings: setting.Default()} },
"admin/settings": func() any {
return SettingsData{Page: probePage("admin"), Modules: setting.AllModules,
Pages: setting.AllPages, MapLayers: []MapLayerOption{{Key: "k", Label: "l"}}}
},
}
// probePage 是所有探针共用的页面上下文:给足站点设置,免得模板在取值阶段就断。
func probePage(nav string) Page {
return Page{Nav: nav, Title: "模板自检", Settings: setting.Default()}
}
// smokeRender 把所有页面各渲染一遍,专为触发执行期错误。
// 任一页失败都返回错误,让 New 拒绝启动——宁可起不来,也不要起来后一半页面是残的。
func (s *Server) smokeRender() error {
var failed []string
for key := range pageFiles {
probe, ok := pageProbe[key]
if !ok {
// 没有探针的页面等于没被自检覆盖。这是漏,不是省事,所以按失败处理。
failed = append(failed, key+": pageProbe 里没有配自检数据")
continue
}
if err := s.smokeOne(key, probe()); err != nil {
failed = append(failed, err.Error())
}
}
if len(failed) > 0 {
return fmt.Errorf("模板自检未通过,多半是 Go 结构体与模板字段不同步(改完 Go 要重新编译):\n %s",
strings.Join(failed, "\n "))
}
return nil
}
func (s *Server) smokeOne(key string, data any) (err error) {
// 模板里调到的方法可能对着空数据 panic,别让它把整个启动带走——
// 这里要的是一个可读的「哪一页坏了」,不是一段崩栈。
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%s: 渲染时 panic: %v", key, r)
}
}()
set, err := s.pageSet(key)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
var buf bytes.Buffer
if err := set.t.ExecuteTemplate(&buf, set.entry, data); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package web
import (
"html/template"
"path/filepath"
"strings"
"testing"
"rond-with-you/internal/config"
)
// TestSmokeRenderAllPages 把启动自检搬进 go test:每个页面都要能用「最小可用数据」渲染到底。
//
// 比 TestPageTemplatesParse 更强的地方在于**执行**:模板能解析不代表渲染得完,
// 「字段不存在 / 对着 nil 解引用」只在执行期暴露,而线上表现是「HTTP 200 + 半张页面」。
// 2026-10-09 的 /admin/settings 截断就是这么来的(新模板引用了旧结构体里没有的字段)。
func TestSmokeRenderAllPages(t *testing.T) {
srv := &Server{cfg: &config.Config{}}
pages := make(map[string]pageSet, len(pageFiles))
for key, def := range pageFiles {
tpl, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
ParseFS(tmplFS, def.files...)
if err != nil {
t.Fatalf("解析模板 %s 失败: %v", key, err)
}
pages[key] = pageSet{t: tpl, entry: def.entry}
}
srv.pages = pages
if err := srv.smokeRender(); err != nil {
t.Fatal(err)
}
// 定义性不变量:pageFiles 里每加一个页面,就必须在 pageProbe 里配一份自检数据,
// 否则那一页会悄悄逃过自检(smokeRender 会把缺项报成失败,这里再独立钉一次)。
for key := range pageFiles {
if _, ok := pageProbe[key]; !ok {
t.Errorf("页面 %s 没有自检数据(pageProbe 缺项)——新加页面时要一起补", key)
}
}
for key := range pageProbe {
if _, ok := pageFiles[key]; !ok {
t.Errorf("pageProbe 里的 %s 不是已注册页面(改名后忘了同步?)", key)
}
}
}
// TestSmokeRenderCatchesFieldDrift 变异验证:把页面数据换成**字段更少**的类型时必须报错。
//
// 光断言「当前代码自检通过」证明不了自检有效——它可能只是什么都没检查。
// 这里用 Page 顶替 SettingsData(模板引用的 .Modules / .S3Info 都不在 Page 上),
// 自检必须红,且错误信息里要能看到是哪一页。
func TestSmokeRenderCatchesFieldDrift(t *testing.T) {
srv := &Server{cfg: &config.Config{}}
pages := make(map[string]pageSet, len(pageFiles))
for key, def := range pageFiles {
tpl, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
ParseFS(tmplFS, def.files...)
if err != nil {
t.Fatalf("解析模板 %s 失败: %v", key, err)
}
pages[key] = pageSet{t: tpl, entry: def.entry}
}
srv.pages = pages
orig := pageProbe["admin/settings"]
pageProbe["admin/settings"] = func() any { return probePage("admin") }
t.Cleanup(func() { pageProbe["admin/settings"] = orig })
err := srv.smokeRender()
if err == nil {
t.Fatal("页面数据缺字段时自检必须报错,否则它什么也挡不住")
}
if !strings.Contains(err.Error(), "admin/settings") {
t.Errorf("自检报错要指名是哪个页面,实际为:%v", err)
}
}
@@ -131,7 +131,7 @@
<td data-label="活动">{{if .Activity}}{{.Activity}}{{else}}<span class="muted">—</span>{{end}}</td>
<td data-label="差异">
{{range .Fields}}
<div style="font-size:12.5px;line-height:1.7">
<div style="font-size:var(--fs-sm);line-height:1.7">
<span class="muted">{{.Label}}</span>
<span style="text-decoration:line-through;opacity:.65">{{if .From}}{{.From}}{{else}}(空){{end}}</span>
<span style="opacity:.5">→</span>
@@ -190,7 +190,7 @@
<td class="num mono" data-label="坐标">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}</td>
<td data-label="合并时删除">
{{if gt .VisitCount 0}}
<span class="muted" style="font-size:12.5px">还有到访,不能删</span>
<span class="muted" style="font-size:var(--fs-sm)">还有到访,不能删</span>
{{else}}
<input type="checkbox" form="merge-form" name="dp_{{.SrcPK}}" value="1">
{{end}}
@@ -213,7 +213,7 @@
<td>{{if .DB.Name}}{{.DB.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
<td data-label="差异">
{{range .Fields}}
<div style="font-size:12.5px;line-height:1.7">
<div style="font-size:var(--fs-sm);line-height:1.7">
<span class="muted">{{.Label}}</span>
<span style="text-decoration:line-through;opacity:.65">{{if .From}}{{.From}}{{else}}(空){{end}}</span>
<span style="opacity:.5">→</span>
@@ -241,17 +241,17 @@
</div>
<div class="row" style="gap:24px;align-items:flex-start;flex-wrap:wrap">
<div style="flex:1 1 220px">
<div class="muted" style="font-size:12.5px;margin-bottom:4px">备份新增</div>
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:4px">备份新增</div>
{{range .ActAdded}}<div>{{.Name}}</div>{{else}}<div class="muted">没有</div>{{end}}
</div>
<div style="flex:1 1 220px">
<div class="muted" style="font-size:12.5px;margin-bottom:4px">仅站内有(勾上=合并时删除)</div>
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:4px">仅站内有(勾上=合并时删除)</div>
{{range .ActGone}}
<label style="display:block"><input type="checkbox" form="merge-form" name="da_{{.SrcPK}}" value="1"> {{.Name}}</label>
{{else}}<div class="muted">没有</div>{{end}}
</div>
<div style="flex:1 1 260px">
<div class="muted" style="font-size:12.5px;margin-bottom:4px">名称不同</div>
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:4px">名称不同</div>
{{range .ActClash}}
<div style="margin-bottom:6px">
{{range .Fields}}<div><span style="text-decoration:line-through;opacity:.65">{{.From}}</span> → <strong>{{.To}}</strong></div>{{end}}
+1 -1
View File
@@ -121,7 +121,7 @@
<input type="file" name="backup" accept=".rondbackup,.zip" required>
<button class="btn primary" type="submit">上传并换底座</button>
</div>
<label class="muted" style="display:block;margin-top:10px;font-size:13px">
<label class="muted" style="display:block;margin-top:10px;font-size:var(--fs-sm)">
<input type="checkbox" name="force" value="1">
即使与当前数据集对不上也强制替换(重合率偏低时会再让你确认一次)
</label>
+1 -1
View File
@@ -518,7 +518,7 @@
<td class="muted mono">{{if .Span}}{{.Span}}{{else}}(无时间){{end}}
{{if .DurationMin}}<br><span class="muted">{{dur .DurationMin}}</span>{{end}}</td>
<td class="mv-route" data-label="路线" title="{{.FromLabel}}{{if .FromCity}} · {{.FromCity}}{{end}} → {{.ToLabel}}{{if .ToCity}} · {{.ToCity}}{{end}}">{{.FromLabel}}<span class="mv-city">{{if .FromCity}} · {{.FromCity}}{{end}}</span><br><span class="muted">→ {{.ToLabel}}<span class="mv-city">{{if .ToCity}} · {{.ToCity}}{{end}}</span></span></td>
<td class="muted" data-label="方式">{{if .TransportName}}<span class="dot" style="display:inline-block;background:{{colorof .TransportColor}}"></span> {{.TransportName}} <span class="muted mono">#{{.TransportSrc}}</span>{{else}}(无方式){{end}}</td>
<td class="muted" data-label="方式">{{if .TransportName}}<span class="dot" style="display:inline-block;background:{{csscolor .TransportColor}}"></span> {{.TransportName}} <span class="muted mono">#{{.TransportSrc}}</span>{{else}}(无方式){{end}}</td>
<td class="num" data-label="里程">{{if .DistanceKm}}{{km .DistanceKm}}{{else}}—{{end}}</td>
<td class="ops">
<details class="inline-edit">
+2 -2
View File
@@ -16,7 +16,7 @@
{{if .Rows}}
<div class="card">
<h2>待补全({{num .Total}}){{if gt .PageCount 1}}<span class="muted" style="font-size:13px;font-weight:400"> 第 {{.PageNo}} / {{.PageCount}} 页</span>{{end}}</h2>
<h2>待补全({{num .Total}}){{if gt .PageCount 1}}<span class="muted" style="font-size:var(--fs-sm);font-weight:400"> 第 {{.PageNo}} / {{.PageCount}} 页</span>{{end}}</h2>
<div class="card-sub">
<strong>默认都是「不动」</strong>,你改过的才会写进去。
活动可以直接打字(会提示已有的类型),也可以点「采纳本页建议」一键填上;
@@ -79,7 +79,7 @@
<td data-label="建议来源">{{if $row.Source}}{{$row.Source}}{{else}}<span class="muted">—</span>{{end}}</td>
<td data-label="高德参考">
{{if $row.AmapType}}
<span style="font-size:12.5px">{{if $row.AmapPoi}}{{$row.AmapPoi}}<br>{{end}}<span class="muted">{{$row.AmapType}}</span></span>
<span style="font-size:var(--fs-sm)">{{if $row.AmapPoi}}{{$row.AmapPoi}}<br>{{end}}<span class="muted">{{$row.AmapType}}</span></span>
{{else}}<span class="muted">—</span>{{end}}
</td>
</tr>
+1 -1
View File
@@ -33,7 +33,7 @@
<button class="btn primary" type="submit" id="mask-save">保存</button>
<button class="btn" type="button" id="mask-reset">清除选择</button>
</div>
<div class="muted" id="mask-hint" style="font-size:12.5px;margin-top:6px">在下方地图上点选圆心</div>
<div class="muted" id="mask-hint" style="font-size:var(--fs-sm);margin-top:6px">在下方地图上点选圆心</div>
</form>
</div>
@@ -55,7 +55,7 @@
<div class="card">
<h2>差异地图</h2>
<div class="card-sub">在真实底图上核对差异落在哪些区域,可以缩放平移。</div>
<div class="row" style="gap:16px;margin:2px 0 10px;font-size:12.5px;flex-wrap:wrap">
<div class="row" style="gap:16px;margin:2px 0 10px;font-size:var(--fs-sm);flex-wrap:wrap">
<span style="display:flex;align-items:center;gap:6px">
<i style="width:13px;height:13px;border-radius:3px;background:#24a45a;display:inline-block"></i>叠加会新增
</span>
@@ -110,7 +110,7 @@
⚠️ 快照里没有 <span class="mono">Model/~</span> 与 <span class="mono">Model/#</span> 两层数据,替换会把现有的这两层一并清掉。
</div>
{{else}}
<div class="muted" style="font-size:12.5px;margin-bottom:8px">
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:8px">
另外,站点不解析但会留存的 <span class="mono">Model/~</span>({{.TildeLayers}} 项)与
<span class="mono">Model/#</span>({{.HashLayers}} 项)也会一并换成这份快照里的{{if .OtherLayers}},另有 {{.OtherLayers}} 项其它条目{{end}}。
</div>
+1 -1
View File
@@ -66,7 +66,7 @@
</label>
<button class="btn primary" type="submit">分析差异</button>
</form>
<div class="muted" style="font-size:12.5px;margin-top:8px">
<div class="muted" style="font-size:var(--fs-sm);margin-top:8px">
上传后<strong>先不写入</strong>:会先给出这份快照能补多少新区域、以及「整体替换」会丢掉多少已有区域,
确认之后才真正导入。
</div>
+4 -4
View File
@@ -13,9 +13,9 @@
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px">
<div style="width:100%;max-width:368px">
<div style="text-align:center;margin-bottom:22px">
<div style="font-size:30px">{{.Settings.OwnerAvatar}}</div>
<h1 style="font-size:19px;margin-top:6px">{{.Settings.SiteTitle}}</h1>
<div class="muted" style="font-size:13px">后台管理</div>
<div style="font-size:var(--glyph-xl)">{{.Settings.OwnerAvatar}}</div>
<h1 style="font-size:var(--fs-lg);margin-top:6px">{{.Settings.SiteTitle}}</h1>
<div class="muted" style="font-size:var(--fs-sm)">后台管理</div>
</div>
<div class="card" style="margin:0">
@@ -46,7 +46,7 @@
</script>
</div>
<div style="text-align:center;margin-top:16px"><a class="muted" href="/" style="font-size:13px">← 返回主页</a></div>
<div style="text-align:center;margin-top:16px"><a class="muted" href="/" style="font-size:var(--fs-sm)">← 返回主页</a></div>
</div>
</div>
</body>
+3 -3
View File
@@ -61,7 +61,7 @@
<form id="bulk-form" method="post" action="/admin/notes/bulk"></form>
<div class="card" style="position:sticky;top:8px;z-index:3;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label class="check" style="margin:0"><input type="checkbox" id="chk-all"> 全选本页</label>
<span class="muted" style="font-size:12.5px">批量:</span>
<span class="muted" style="font-size:var(--fs-sm)">批量:</span>
<select name="verdict" form="bulk-form" style="padding:5px 8px;border:1px solid var(--line);border-radius:8px">
<option value="1">👍 推荐</option>
<option value="-1">👎 踩雷</option>
@@ -100,7 +100,7 @@
data-verdict-btn data-src="{{.SrcPK}}" data-v="-1" title="标为踩雷">👎</button>
</div>
</form>
<div class="muted" style="font-size:12px;margin-top:4px">
<div class="muted" style="font-size:var(--fs-sm);margin-top:4px">
地点 ID {{.SrcPK}}{{if .VisitCount}} · 到访 {{num .VisitCount}} 次{{end}}
<span class="vote-tip" data-verdict-status="{{.SrcPK}}">{{if eq .Verdict 1}} · 已标推荐{{else if eq .Verdict -1}} · 已标踩雷{{end}}</span>
{{if or .Up .Down}} · 访客 👍{{.Up}} 👎{{.Down}}{{end}}
@@ -171,7 +171,7 @@
<div class="item" style="grid-template-columns:1fr auto;gap:10px;align-items:center">
<div>
<div>{{if .Alias}}{{.Alias}}{{else}}<span class="muted">(无别名)</span>{{end}}{{if .Content}} · <span class="muted">{{.Content}}</span>{{end}}{{if .RondNote}} · <span class="muted">来自 rond:{{.RondNote}}</span>{{end}}</div>
<div class="muted" style="font-size:12px">地点 ID {{.SrcPK}} · 更新于 {{dt .UpdatedAt}}</div>
<div class="muted" style="font-size:var(--fs-sm)">地点 ID {{.SrcPK}} · 更新于 {{dt .UpdatedAt}}</div>
</div>
<form method="post" action="/admin/place/note">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
+2 -2
View File
@@ -8,7 +8,7 @@
</div>
<div class="card">
<h2 style="font-size:14px;margin:0 0 10px">用户名</h2>
<h2 style="font-size:var(--fs-sm);margin:0 0 10px">用户名</h2>
{{if eq .Flash "name"}}<div class="alert ok">用户名已更新</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<form method="post" action="/admin/username">
@@ -19,7 +19,7 @@
</div>
<div class="card">
<h2 style="font-size:14px;margin:0 0 10px">密码</h2>
<h2 style="font-size:var(--fs-sm);margin:0 0 10px">密码</h2>
{{if and .Flash (ne .Flash "name")}}<div class="alert ok">密码已更新</div>{{end}}
<form method="post" action="/admin/password">
<label class="f"><span>当前密码</span><input type="password" name="current" autocomplete="current-password" required></label>
+2 -2
View File
@@ -36,7 +36,7 @@
一次几十张、几百 MB 都没有上传耗时或存储压力。
</div>
<div class="row" style="align-items:center;margin-bottom:10px">
<span class="muted" style="font-size:12.5px">照片里的坐标是</span>
<span class="muted" style="font-size:var(--fs-sm)">照片里的坐标是</span>
<label class="check" style="margin:0"><input type="radio" name="coord-sel" value="wgs84" checked> WGS-84(绝大多数手机)</label>
<label class="check" style="margin:0"><input type="radio" name="coord-sel" value="gcj02"> 已经是 GCJ-02(部分国产手机会这样写)</label>
</div>
@@ -46,7 +46,7 @@
<div class="row" style="align-items:center;gap:10px">
<label class="btn primary" for="photo-input" style="cursor:pointer">+ 选择照片</label>
<input type="file" id="photo-input" accept="image/*" multiple hidden>
<span class="muted" id="pick-count" style="font-size:12.5px">可多选,JPEG / HEIC 都行</span>
<span class="muted" id="pick-count" style="font-size:var(--fs-sm)">可多选,JPEG / HEIC 都行</span>
</div>
<div id="parse-status" class="alert info"></div>
</div>
+15 -13
View File
@@ -9,6 +9,7 @@
</div>
{{if .Flash}}<div class="alert ok">设置已保存</div>{{end}}
{{if .Warn}}<div class="alert err">{{.Warn}}</div>{{end}}
<form method="post" action="/admin/settings">
@@ -53,7 +54,7 @@
</label>
{{end}}
</div>
<div class="muted" style="font-size:12.5px;margin-top:8px">
<div class="muted" style="font-size:var(--fs-sm);margin-top:8px">
关闭的页面在访客导航中隐藏,直接输入网址也会得到 404。主页不受此开关影响。
</div>
<label class="f" style="max-width:320px;margin-top:14px">
@@ -63,7 +64,7 @@
{{range .Pages}}<option value="{{.Key}}" {{if eq $.Settings.VisitorHome .Key}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<div class="muted" style="font-size:12.5px;margin-top:6px">
<div class="muted" style="font-size:var(--fs-sm);margin-top:6px">
访客访问站点根路径时直接落到这里;你登录后访问根路径仍然看主页。
选择了一个没对访客开放的页面时不会跳转(会留在主页)。
</div>
@@ -91,7 +92,7 @@
<label class="check"><input type="checkbox" name="hide_work" {{if .Settings.HideWork}}checked{{end}}> 隐藏类型为「工作」的记录</label>
<label class="check"><input type="checkbox" name="notes_public" {{if .Settings.NotesPublic}}checked{{end}}> 地点备注对访客公开(不勾选则只有你自己能看到)</label>
<label class="check"><input type="checkbox" name="marks_public" {{if .Settings.MarksPublic}}checked{{end}}> 「推荐 / 踩雷」标记与票数对访客公开</label>
<div class="muted" style="font-size:12.5px">
<div class="muted" style="font-size:var(--fs-sm)">
隐藏后匿名访客看不到这两类记录。你登录后可在任意公开页面用筛选器上的
<span class="mono">含家/工作</span> 开关临时显示给自己看。
</div>
@@ -104,10 +105,10 @@
<option value="10" {{if eq .Settings.MinDwellMin 10}}selected{{end}}>少于 10 分钟</option>
</select>
</label>
<div class="muted" style="font-size:12.5px">该设置作用于全站统计与地图,可有效过滤路过点。</div>
<div class="muted" style="font-size:var(--fs-sm)">该设置作用于全站统计与地图,可有效过滤路过点。</div>
</div>
<div class="card">
<div class="card card--quiet">
<h2>地图底图</h2>
<div class="card-sub">在 <span class="mono">conf/app.ini</span> 中配置,改完重启生效</div>
<div class="kv"><span class="k">当前生效</span><span class="v">{{.MapInfo}}</span></div>
@@ -121,20 +122,20 @@
<div class="card-sub" style="margin-top:0">放大后底图自带的 POI 与路名标注太密、看不清自己的点时,换成<b>纯路网</b>。
访客在地图上手动切换过的话,浏览器会记住他自己的选择。</div>
{{end}}
<div class="muted" style="font-size:12.5px;margin-top:8px">
<div class="muted" style="font-size:var(--fs-sm);margin-top:8px">
<div><span class="mono">map_provider</span>:<span class="mono">amap</span>(默认,免密钥)/ <span class="mono">tianditu</span>(需 <span class="mono">map_key</span>)/ <span class="mono">custom</span>(自备 <span class="mono">map_tile_url</span>)</div>
<div><span class="mono">map_key</span>:填写后瓦片改由服务端代理,密钥不会出现在页面源码里。</div>
<div><span class="mono">map_crs</span>:仅 <span class="mono">custom</span> 使用,填 <span class="mono">gcj02</span> 或 <span class="mono">wgs84</span>;站点数据本身是 GCJ-02,换成 WGS-84 底图(天地图 / OSM)时会自动纠偏。</div>
</div>
</div>
<div class="card">
<div class="card card--quiet">
<h2>图片存储</h2>
<div class="card-sub">地点图片存到哪儿。图片在<b>浏览器里</b>转成 WebP 后才上传,服务器只负责收下与转存</div>
<label class="check"><input type="radio" name="media_backend" value="local" {{if ne .Settings.MediaBackend "s3"}}checked{{end}}> 服务器本地磁盘</label>
<label class="check"><input type="radio" name="media_backend" value="s3" {{if eq .Settings.MediaBackend "s3"}}checked{{end}}> 对象存储(S3 兼容,如 Cloudflare R2)</label>
<div class="kv" style="margin-top:8px"><span class="k">对象存储</span><span class="v">{{.S3Info}}</span></div>
<div class="muted" style="font-size:12.5px;margin-top:8px">
<div class="muted" style="font-size:var(--fs-sm);margin-top:8px">
<div>凭据放在 <span class="mono">conf/app.ini</span>,不进数据库、也不会下发到前端:</div>
<div><span class="mono">s3_endpoint</span>(R2 形如 <span class="mono">https://&lt;账号&gt;.r2.cloudflarestorage.com</span>)、<span class="mono">s3_bucket</span>、<span class="mono">s3_access_key</span>、<span class="mono">s3_secret_key</span>、<span class="mono">s3_region</span>(R2 填 <span class="mono">auto</span>)。</div>
<div><span class="mono">s3_public_base</span>:桶的公开域名(自定义域或 r2.dev),拼上对象名就是 <span class="mono">&lt;img src&gt;</span>;不填的话图片存得进去但浏览器取不到。</div>
@@ -151,14 +152,15 @@
<textarea name="track_script" rows="4"
placeholder="https://example.com/analytics.js">{{.Settings.TrackScript}}</textarea>
</label>
<div class="muted" style="font-size:12.5px">
<div class="muted" style="font-size:var(--fs-sm)">
<div>填一个 <span class="mono">https://…</span> 地址会包成 <span class="mono">&lt;script src=…&gt;</span>;
需要初始化代码的服务(Umami、百度统计等)可以把整段 <span class="mono">&lt;script&gt;</span> 贴进来。</div>
<div>整段 <span class="mono">&lt;script&gt;</span> 需要闭合标签配对(多一个 <span class="mono">&lt;/script&gt;</span> 会被判为粘贴错误);
<div>整段脚本的 <span class="mono">&lt;script&gt;</span> 与 <span class="mono">&lt;/script&gt;</span> 必须数量相同:
多一个 <span class="mono">&lt;/script&gt;</span> 会提前闭合、少一个会把页面收尾吞进脚本,两种情况都拒收;
<span class="mono">src</span> 只接受 <span class="mono">https://</span>、<span class="mono">http://</span> 或以 <span class="mono">/</span> 开头的地址,
超过 4096 字符会被截断。填了但没勾开关不会生效。</div>
<div><span class="mono">conf/app.ini</span> 的 <span class="mono">track_script</span> 会作为这里的默认值——
它也需要开关打开才注入。</div>
超过 4096 字符会被截断(并给出提示)。填了但没勾开关不会生效。</div>
<div>这个框留空时回落到 <span class="mono">conf/app.ini</span> 的 <span class="mono">track_script</span>
——它不会回填到上面这个框(改 ini 要重启)。</div>
</div>
</div>
@@ -13,9 +13,9 @@
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px">
<div style="width:100%;max-width:368px">
<div style="text-align:center;margin-bottom:22px">
<div style="font-size:30px">🔐</div>
<h1 style="font-size:19px;margin-top:6px">两步验证</h1>
<div class="muted" style="font-size:13px">输入验证器 App 中的 6 位验证码</div>
<div style="font-size:var(--glyph-xl)">🔐</div>
<h1 style="font-size:var(--fs-lg);margin-top:6px">两步验证</h1>
<div class="muted" style="font-size:var(--fs-sm)">输入验证器 App 中的 6 位验证码</div>
</div>
<div class="card" style="margin:0">
@@ -26,13 +26,13 @@
<label class="f"><span>验证码</span>
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6"
autocomplete="one-time-code" autofocus required
style="text-align:center;font-size:20px;letter-spacing:8px">
style="text-align:center;font-size:var(--glyph-lg);letter-spacing:8px">
</label>
<button class="btn primary" type="submit" style="width:100%;justify-content:center">验证并登录</button>
</form>
</div>
<div style="text-align:center;margin-top:16px"><a class="muted" href="/admin/login" style="font-size:13px">← 重新登录</a></div>
<div style="text-align:center;margin-top:16px"><a class="muted" href="/admin/login" style="font-size:var(--fs-sm)">← 重新登录</a></div>
</div>
</div>
</body>
+4 -4
View File
@@ -18,7 +18,7 @@
<form method="post" action="/admin/twofa/disable" style="max-width:360px">
<label class="f"><span>输入当前验证码以关闭</span>
<input type="text" name="code" inputmode="numeric" maxlength="6" required
style="text-align:center;letter-spacing:6px;font-size:16px">
style="text-align:center;letter-spacing:6px;font-size:var(--fs-control)">
</label>
<button class="btn danger" type="submit">关闭两步验证</button>
</form>
@@ -35,13 +35,13 @@
<input type="text" readonly value="{{.Secret}}" onclick="this.select()"
style="font-family:monospace;letter-spacing:1px">
</label>
<div class="muted" style="font-size:12.5px">账户名:rond-with-you · 算法 SHA1 · 6 位 · 30 秒</div>
<div class="muted" style="font-size:var(--fs-sm)">账户名:rond-with-you · 算法 SHA1 · 6 位 · 30 秒</div>
</div>
</div>
<form method="post" action="/admin/twofa/confirm" style="max-width:360px;margin-top:6px">
<label class="f"><span>输入验证码确认开启</span>
<input type="text" name="code" inputmode="numeric" maxlength="6" required
style="text-align:center;letter-spacing:6px;font-size:16px">
style="text-align:center;letter-spacing:6px;font-size:var(--fs-control)">
</label>
<button class="btn primary" type="submit">确认开启</button>
</form>
@@ -53,7 +53,7 @@
<form method="post" action="/admin/twofa/setup">
<button class="btn primary" type="submit">开始绑定验证器</button>
</form>
<div class="muted" style="font-size:12.5px;margin-top:10px">
<div class="muted" style="font-size:var(--fs-sm);margin-top:10px">
需要一个 TOTP 验证器 App(Google Authenticator、Microsoft Authenticator、1Password、Aegis 等均可)。
</div>
</div>
+14 -10
View File
@@ -16,10 +16,14 @@
</div>
{{if .Settings.Has "overview"}}
{{/* 首屏主数:8 个数字全都在,但只让「去过的地方」占主位,城市/省份降为它下面的一行
上下文。原来 8 个一样大一样重,扫过去不知道先看哪个。 */}}
<div class="kpi-lead">
<div class="kl-v">{{num .Overview.Places}}<small>个</small></div>
<div class="kl-k">去过的地点</div>
<div class="kl-x">走过 {{num .Overview.Cities}} 座城市、{{num .Overview.Provinces}} 个省级行政区</div>
</div>
<div class="kpis" style="margin-bottom:16px">
<div class="kpi"><div class="v">{{num .Overview.Places}}<small>个</small></div><div class="k">去过的地点</div></div>
<div class="kpi"><div class="v">{{num .Overview.Cities}}<small>座</small></div><div class="k">到过的城市</div></div>
<div class="kpi"><div class="v">{{num .Overview.Provinces}}<small>个</small></div><div class="k">覆盖省份</div></div>
<div class="kpi"><div class="v">{{num .Overview.Days}}<small>天</small></div><div class="k">有记录的日子</div></div>
<div class="kpi"><div class="v">{{num .Overview.Visits}}<small>次</small></div><div class="k">累计到访</div></div>
<div class="kpi"><div class="v">{{dur .Overview.DwellMin}}</div><div class="k">累计停留</div></div>
@@ -29,11 +33,11 @@
{{end}}
{{if and (.Settings.Has "map") .Points}}
<div class="card">
<div class="card card--hero">
<h2>足迹地图</h2>
<div class="card-sub">共 {{num (len .Points)}} 个地点{{if .Settings.HideHome}} · 已隐藏「家」{{end}}{{if .Settings.HideWork}} · 已隐藏「工作」{{end}}</div>
<div id="home-map" class="map-preview"></div>
<div style="margin-top:10px;font-size:12.5px" class="muted">滚轮被禁用,点击地图进入全屏模式查看细节与筛选。</div>
<div style="margin-top:10px;font-size:var(--fs-sm)" class="muted">滚轮被禁用,点击地图进入全屏模式查看细节与筛选。</div>
</div>
{{end}}
@@ -46,7 +50,7 @@
{{range .ActivityStats}}
<div class="bar-row">
<div class="nm" title="{{.Name}}">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{colorof .Color}}"></div></div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{csscolor .Color}}"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
@@ -77,7 +81,7 @@
<h2>月度活跃度</h2>
<div class="card-sub">每月到访次数</div>
<div class="cols">
{{range .Monthly}}<div class="col" style="height:{{.Pct}}%" title="{{.Key}}:{{.Count}} 次"></div>{{end}}
{{range .Monthly}}<div class="col{{if eq .Count $.MaxMonthly}} peak{{end}}" style="height:{{.Pct}}%" title="{{.Key}}:{{.Count}} 次">{{if eq .Count $.MaxMonthly}}<span class="pv">{{num .Count}}</span>{{end}}</div>{{end}}
</div>
<div class="cols-x">{{range .Monthly}}<span>{{.Label}}</span>{{end}}</div>
</div>
@@ -103,7 +107,7 @@
{{range .Transports}}
<div class="bar-row">
<div class="nm">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxTransport}}%;background:{{colorof .Color}}"></div></div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxTransport}}%;background:{{csscolor .Color}}"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
@@ -118,7 +122,7 @@
<div class="chips">
{{range .Tags}}
<a class="chip" href="{{$.TagLink .ID}}" title="{{num .Count}} 次">
<span class="dot" style="background:{{colorof .Color}}"></span>{{.Name}}
<span class="dot" style="background:{{csscolor .Color}}"></span>{{.Name}}
<span class="muted">{{num .Count}}</span>
</a>
{{end}}
@@ -156,7 +160,7 @@
<div class="list">
{{range .Recent}}
<div class="item">
<div class="ic" style="background:{{colorof .Color}}22">{{iconor .Icon "📍"}}</div>
<div class="ic" style="background:{{csssoft .Color}}">{{iconor .Icon "📍"}}</div>
<div>
<div class="t">{{if .PlaceID}}<a href="{{$.PlaceLink .PlaceID}}">{{.PlaceName}}</a>{{else}}未命名地点{{end}}</div>
<div class="m">{{day .Arrival}} · {{hm .Arrival}}{{if .City}} · {{.City}}{{end}}{{if .ActivityName}} · {{.ActivityName}}{{end}}{{if .Tags}} · {{range $i, $t := .Tags}}{{if $i}}、{{end}}<span class="tag">{{$t}}</span>{{end}}{{end}}</div>
+42 -21
View File
@@ -1,16 +1,28 @@
{{/* navitems 是导航链接本体,抽出来是因为它要渲染两遍:桌面上在 .nav-links 里平铺,
窄屏整行一定放不下(实测 390 溢 63px、320 溢 133px,最深处的「统计」会被切成半个字),
所以另收进 .nav-menu 那个折叠菜单。两份靠 display 二选一,DOM 里不会同时可见。
页面开关只管访客:站长登录后始终能访问全部页面(guardPage 也是这么放的),
导航必须跟着一起放行,否则关掉页面开关后站长自己也只剩「主页」一个入口。 */}}
{{define "navitems"}}
<a href="/" class="{{if eq .Nav "home"}}on{{end}}">主页</a>
{{if or .IsAdmin (.Settings.HasPage "map")}}<a href="/map" class="{{if eq .Nav "map"}}on{{end}}">地图</a>{{end}}
{{if or .IsAdmin (.Settings.HasPage "places")}}<a href="/places" class="{{if eq .Nav "places"}}on{{end}}">地点</a>{{end}}
{{if or .IsAdmin (.Settings.HasPage "timeline")}}<a href="/timeline" class="{{if eq .Nav "timeline"}}on{{end}}">时间线</a>{{end}}
{{if or .IsAdmin (.Settings.HasPage "stats")}}<a href="/stats" class="{{if eq .Nav "stats"}}on{{end}}">统计</a>{{end}}
{{if .HasTopics}}<a href="/topics" class="{{if eq .Nav "topics"}}on{{end}}">专题</a>{{end}}
{{if or .IsAdmin (.Settings.HasPage "report")}}<a href="/report" class="{{if eq .Nav "report"}}on{{end}}">报告</a>{{end}}
{{end}}
{{define "nav"}}
<nav class="nav">
<div class="nav-inner">
<a class="brand" href="/" title="{{.Settings.SiteTitle}}"><span class="avatar">{{.Settings.OwnerAvatar}}</span><span class="t">{{.Settings.SiteTitle}}</span></a>
<div class="nav-links">
<a href="/" class="{{if eq .Nav "home"}}on{{end}}">主页</a>
{{if .Settings.HasPage "map"}}<a href="/map" class="{{if eq .Nav "map"}}on{{end}}">地图</a>{{end}}
{{if .Settings.HasPage "places"}}<a href="/places" class="{{if eq .Nav "places"}}on{{end}}">地点</a>{{end}}
{{if .Settings.HasPage "timeline"}}<a href="/timeline" class="{{if eq .Nav "timeline"}}on{{end}}">时间线</a>{{end}}
{{if .Settings.HasPage "stats"}}<a href="/stats" class="{{if eq .Nav "stats"}}on{{end}}">统计</a>{{end}}
{{if .HasTopics}}<a href="/topics" class="{{if eq .Nav "topics"}}on{{end}}">专题</a>{{end}}
{{if .Settings.HasPage "report"}}<a href="/report" class="{{if eq .Nav "report"}}on{{end}}">报告</a>{{end}}
</div>
<div class="nav-links">{{template "navitems" .}}</div>
<details class="nav-menu">
<summary aria-label="导航菜单">菜单</summary>
<nav class="nav-menu-body">{{template "navitems" .}}</nav>
</details>
<div class="nav-right">
{{if .User}}
<span class="who">{{.User.DisplayName}}</span>
@@ -23,17 +35,26 @@
</div>
</nav>
<script>
/* 手机上导航装不下这几项,可以横向滑动(滚动条已隐藏)——右侧加一层渐隐,提示「还能往右滑」;
滑到末端就收掉,免得让人以为后面还有。桌面端没有横向溢出,这段等于不做事。 */
/* 1) 620px 以上:链接行万一还是放不下(以后加了入口),横向可滑 + 右缘渐隐提示。
2) 620px 及以下:整行已经收进 .nav-menu,这里只管菜单开合——原生 <details> 不会
点外面自动收、也不响应 Esc,补上;点链接后也收掉,免得盖着刚打开的页面。 */
(function () {
var el = document.querySelector('.nav-links');
if (!el) return;
var update = function () {
el.classList.toggle('overflow-right', el.scrollWidth - el.clientWidth - el.scrollLeft > 2);
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
if (el) {
var update = function () {
el.classList.toggle('overflow-right', el.scrollWidth - el.clientWidth - el.scrollLeft > 2);
};
update();
el.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
}
var menu = document.querySelector('.nav-menu');
if (!menu) return;
document.addEventListener('click', function (e) { if (!menu.contains(e.target)) menu.open = false; });
menu.addEventListener('click', function (e) {
if (e.target.closest && e.target.closest('a')) menu.open = false;
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') menu.open = false; });
})();
</script>
{{end}}
@@ -70,7 +91,7 @@
<div class="chips" style="margin-bottom:{{if gt (len .Activities) 14}}8{{else}}12{{end}}px">
{{range $i, $a := .Activities}}{{if or (lt $i 14) ($.IsAct $a.ID)}}
<a class="chip {{if $.IsAct $a.ID}}on{{end}}" href="{{$.ActLink $a.ID}}" title="{{num $a.Count}} 次到访">
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
<span class="dot" style="background:{{csscolor $a.Color}}"></span>{{$a.Name}}
<span class="muted">{{num $a.Count}}</span>
</a>
{{end}}{{end}}
@@ -81,7 +102,7 @@
<div class="chips">
{{range $i, $a := .Activities}}{{if and (ge $i 14) (not ($.IsAct $a.ID))}}
<a class="chip" href="{{$.ActLink $a.ID}}">
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
<span class="dot" style="background:{{csscolor $a.Color}}"></span>{{$a.Name}}
<span class="muted">{{num $a.Count}}</span>
</a>
{{end}}{{end}}
@@ -163,7 +184,7 @@
<div class="card">
<div class="empty">
<div class="big">🗺️</div>
<div style="font-size:15px;color:var(--text);margin-bottom:6px">还没有导入任何数据</div>
<div style="font-size:var(--fs-md);color:var(--text);margin-bottom:6px">还没有导入任何数据</div>
<div>登录后台,上传 rond 导出的 <span class="mono">.rondbackup</span> 备份文件即可生成足迹地图。</div>
<div style="margin-top:14px"><a class="btn primary" href="/admin">前往后台导入</a></div>
</div>
+3 -3
View File
@@ -60,7 +60,7 @@
<div class="chips" style="margin-bottom:10px">
{{range $i, $a := .Activities}}{{if or (lt $i 12) ($.IsAct $a.ID)}}
<a class="chip {{if $.IsAct $a.ID}}on{{end}}" href="{{$.ActLink $a.ID}}">
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
<span class="dot" style="background:{{csscolor $a.Color}}"></span>{{$a.Name}}
</a>
{{end}}{{end}}
</div>
@@ -69,7 +69,7 @@
<summary>展开其余 {{len .Activities}} 个类型</summary>
<div class="chips">
{{range $i, $a := .Activities}}{{if and (ge $i 12) (not ($.IsAct $a.ID))}}
<a class="chip" href="{{$.ActLink $a.ID}}"><span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}</a>
<a class="chip" href="{{$.ActLink $a.ID}}"><span class="dot" style="background:{{csscolor $a.Color}}"></span>{{$a.Name}}</a>
{{end}}{{end}}
</div>
</details>
@@ -136,7 +136,7 @@
<div class="map-legend">
<div style="font-weight:500;color:var(--text-2)">类型分布</div>
{{range .Legend}}
<div class="lg"><i style="background:{{colorof .Color}}"></i>{{.Name}} <span class="muted">{{num .Count}}</span></div>
<div class="lg"><i style="background:{{csscolor .Color}}"></i>{{.Name}} <span class="muted">{{num .Count}}</span></div>
{{end}}
</div>
{{end}}
+2 -2
View File
@@ -82,7 +82,7 @@
<div class="row" style="margin-top:12px;gap:10px">
<label class="btn primary" for="imgs-input" id="imgs-pick" style="cursor:pointer">+ 选择图片</label>
<input type="file" id="imgs-input" accept="image/*" multiple hidden>
<span class="muted" id="imgs-count" style="font-size:12.5px"></span>
<span class="muted" id="imgs-count" style="font-size:var(--fs-sm)"></span>
</div>
<div class="imgs-hint" id="imgs-hint"></div>
</div>
@@ -200,7 +200,7 @@
<div class="list" style="max-height:280px;overflow-y:auto">
{{range .Visits}}
<div class="item" style="grid-template-columns:34px 1fr auto">
<div class="ic" style="background:{{colorof .Color}}22;width:30px;height:30px;font-size:15px">{{iconor .Icon "📍"}}</div>
<div class="ic" style="background:{{csssoft .Color}};width:30px;height:30px;font-size:var(--fs-md)">{{iconor .Icon "📍"}}</div>
<div>
<div class="t" style="font-weight:400">{{day .Arrival}}</div>
<div class="m">{{hm .Arrival}} – {{if .Departure}}{{hm .Departure}}{{else}}进行中{{end}}{{if .Remark}} · {{.Remark}}{{end}}</div>
+4 -4
View File
@@ -16,7 +16,7 @@
<div class="card">
<div class="row" style="margin-bottom:12px">
<span class="muted" style="font-size:12.5px">排序</span>
<span class="muted" style="font-size:var(--fs-sm)">排序</span>
<div class="chips">
<a class="chip {{if eq .Sort ""}}on{{end}}" href="{{.Link .FilterBase "sort" ""}}">到访次数</a>
<a class="chip {{if eq .Sort "dwell"}}on{{end}}" href="{{.Link .FilterBase "sort" "dwell"}}">停留时长</a>
@@ -28,7 +28,7 @@
{{if or .IsAdmin .Settings.MarksPublic}}
<div class="row" style="margin-bottom:12px">
<span class="muted" style="font-size:12.5px">标记</span>
<span class="muted" style="font-size:var(--fs-sm)">标记</span>
<div class="chips">
<a class="chip {{if eq .Verdict 0}}on{{end}}" href="{{.VerdictLink 0}}">全部</a>
<a class="chip {{if eq .Verdict 1}}on{{end}}" href="{{.VerdictLink 1}}">👍 推荐</a>
@@ -55,11 +55,11 @@
{{if .IsHome}}<span class="tag">家</span>{{end}}
{{if .IsWork}}<span class="tag">工作</span>{{end}}
{{if eq .Verdict 1}}<span class="tag vote-up">👍 推荐</span>{{else if eq .Verdict -1}}<span class="tag vote-down">👎 踩雷</span>{{end}}
{{if or .Up .Down}}<span class="muted" style="font-size:12px">👍{{.Up}} · 👎{{.Down}}</span>{{end}}
{{if or .Up .Down}}<span class="muted" style="font-size:var(--fs-sm)">👍{{.Up}} · 👎{{.Down}}</span>{{end}}
</td>
<td class="muted" data-label="城市">{{if .City}}{{.City}}{{else}}—{{end}}</td>
<td data-label="主要类型">
{{if .TopActivity}}<span class="chip" style="padding:1px 8px"><span class="dot" style="background:{{colorof .TopColor}}"></span>{{.TopActivity}}</span>{{else}}<span class="muted">—</span>{{end}}
{{if .TopActivity}}<span class="chip" style="padding:1px 8px"><span class="dot" style="background:{{csscolor .TopColor}}"></span>{{.TopActivity}}</span>{{else}}<span class="muted">—</span>{{end}}
</td>
<td data-label="图片">{{if .HasImage}}<span title="有图片">📷</span>{{else}}<span class="muted" title="还没有上传图片">—</span>{{end}}</td>
<td class="num" data-label="到访">{{num .VisitCount}}</td>
+1 -1
View File
@@ -86,7 +86,7 @@
<tbody>
{{range .Transports}}
<tr>
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
<td><span class="dot" style="display:inline-block;background:{{csscolor .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
<td class="num" data-label="次数">{{num .Count}}</td>
<td class="num" data-label="里程">{{km .Distance}} km</td>
</tr>
+18 -15
View File
@@ -27,14 +27,14 @@
<div class="card" style="margin-bottom:16px">
<div class="row" style="justify-content:space-between;align-items:center;flex-wrap:wrap">
<h2 style="margin:0">推荐 &amp; 踩雷({{if .IsAdmin}}我的结论{{else}}站长结论{{end}})</h2>
<span class="muted" style="font-size:12.5px">推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}}</span>
<span class="muted" style="font-size:var(--fs-sm)">推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}}</span>
</div>
<div class="card-sub">
只统计<strong>{{if .IsAdmin}}你自己{{else}}站长{{end}}</strong>在地点页或地图气泡里标的 👍/👎——按坐标键存,重新上传备份也不会丢。
</div>
<div class="grid g2">
<div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 {{if .IsAdmin}}我{{else}}站长{{end}}推荐的地点</div>
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:6px">👍 {{if .IsAdmin}}我{{else}}站长{{end}}推荐的地点</div>
{{if .Recommended}}
<div class="chips">
{{range .Recommended}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
@@ -42,7 +42,7 @@
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
</div>
<div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 {{if .IsAdmin}}我{{else}}站长{{end}}踩雷的地点</div>
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:6px">👎 {{if .IsAdmin}}我{{else}}站长{{end}}踩雷的地点</div>
{{if .Avoided}}
<div class="chips">
{{range .Avoided}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
@@ -56,14 +56,14 @@
<div class="card" style="margin-bottom:16px">
<div class="row" style="justify-content:space-between;align-items:center;flex-wrap:wrap">
<h2 style="margin:0">访客点赞</h2>
<span class="muted" style="font-size:12.5px">👍 {{num .VoteUp}} 票 · 👎 {{num .VoteDown}} 票</span>
<span class="muted" style="font-size:var(--fs-sm)">👍 {{num .VoteUp}} 票 · 👎 {{num .VoteDown}} 票</span>
</div>
<div class="card-sub">
访客在地点页点的 👍/👎,一人一票、可改可撤。与上面的站长结论<strong>互不影响</strong>:各存各的表,谁也不会覆盖谁。
</div>
<div class="grid g2">
<div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 被点赞最多的地点</div>
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:6px">👍 被点赞最多的地点</div>
{{if .VisitorUp}}
<div class="chips">
{{range .VisitorUp}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}<span class="muted"> · 👍 {{.Up}}{{if .Down}} · 👎 {{.Down}}{{end}}</span></a>{{end}}
@@ -71,7 +71,7 @@
{{else}}<div class="empty" style="padding:10px 0">还没有访客点赞</div>{{end}}
</div>
<div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 被点踩最多的地点</div>
<div class="muted" style="font-size:var(--fs-sm);margin-bottom:6px">👎 被点踩最多的地点</div>
{{if .VisitorDown}}
<div class="chips">
{{range .VisitorDown}}<a class="chip" href="{{$.PlaceLink .ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}<span class="muted"> · 👎 {{.Down}}{{if .Up}} · 👍 {{.Up}}{{end}}</span></a>{{end}}
@@ -84,10 +84,11 @@
<div class="card">
<h2>月度分布</h2>
<div class="card-sub">每月到访次数,最高 {{num .MaxMonthly}} 次</div>
<div class="card-sub">每月到访次数 · 点柱条只看那个月</div>
<div class="cols">
{{/* 点柱条直接筛出那个月;title 是悬停读数 */}}
{{range .Monthly}}<a class="col" href="{{$.MonthLink .Key}}" title="{{.Key}}:{{.Count}} 次到访。点一下只看这个月" style="height:{{.Pct}}%"></a>{{end}}
{{/* 点柱条直接筛出那个月;title 是悬停读数。最高的那根在柱顶标出次数(.pv),
副标题里就不再重复写一遍「最高 N 次」。 */}}
{{range .Monthly}}<a class="col{{if eq .Count $.MaxMonthly}} peak{{end}}" href="{{$.MonthLink .Key}}" title="{{.Key}}:{{.Count}} 次到访。点一下只看这个月" style="height:{{.Pct}}%">{{if eq .Count $.MaxMonthly}}<span class="pv">{{num .Count}}</span>{{end}}</a>{{end}}
</div>
<div class="cols-x">{{range .Monthly}}<span>{{.Label}}</span>{{end}}</div>
</div>
@@ -100,7 +101,7 @@
{{range .ActivityStats}}
<a class="bar-row" href="{{$.ActLink .ID}}" title="{{.Name}}:{{num .Count}} 次,累计 {{dur .Dwell}}。点一下按这个类型筛选">
<div class="nm">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{colorof .Color}}"></div></div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{csscolor .Color}}"></div></div>
<div class="vv">{{num .Count}} 次 · {{dur .Dwell}}</div>
</a>
{{end}}
@@ -111,10 +112,12 @@
<h2>一天中的分布</h2>
<div class="card-sub">按到访开始时间统计</div>
<div class="cols" style="height:130px">
{{range $h, $c := .Hourly}}<div class="col" style="height:{{pct $c $.MaxHourly}}%" title="{{$h}} 时:{{$c}} 次"></div>{{end}}
{{range $h, $c := .Hourly}}<div class="col{{if eq $c $.MaxHourly}} peak{{end}}" style="height:{{pct $c $.MaxHourly}}%" title="{{$h}} 时:{{$c}} 次"></div>{{end}}
</div>
<div class="cols-x">
{{range $h, $c := .Hourly}}<span>{{if eq $h 0}}0{{else if eq $h 6}}6{{else if eq $h 12}}12{{else if eq $h 18}}18{{else}}&nbsp;{{end}}</span>{{end}}
{{/* 24 根柱光看高度分不出上午/下午:换成一行 6 小时一段的标尺,段边界与柱子严格
对齐(同一套 24 列网格),每段直接把时段与小时区间写出来,就不必再留坐标轴。 */}}
<div class="cols-band">
<span>深夜 0-6</span><span>上午 6-12</span><span>下午 12-18</span><span>晚上 18-24</span>
</div>
</div>
</div>
@@ -181,7 +184,7 @@
<tbody>
{{range .Transports}}
<tr>
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
<td><span class="dot" style="display:inline-block;background:{{csscolor .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
<td class="num" data-label="次数">{{num .Count}}</td>
<td class="num" data-label="里程">{{km .Distance}} km</td>
<td class="num" data-label="在途时间">{{dur .DwellMin}}</td>
@@ -202,7 +205,7 @@
<div class="bars">
{{range .Tags}}
<a class="bar-row" href="{{$.TagLink .ID}}" title="#{{.Name}}:{{num .Count}} 次到访。点一下按这个标签筛选">
<div class="nm"><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{.Name}}</div>
<div class="nm"><span class="dot" style="display:inline-block;background:{{csscolor .Color}}"></span> {{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxTag}}%"></div></div>
<div class="vv">{{num .Count}} 次</div>
</a>
+2 -2
View File
@@ -29,11 +29,11 @@
{{range .Items}}
<div class="tl-item">
<div class="tm">{{hm .Arrival}}</div>
<div class="ln"><i style="background:{{colorof .Color}}"></i></div>
<div class="ln"><i style="background:{{csscolor .Color}}"></i></div>
<div class="bd">
<div>
{{if .PlaceID}}<a href="{{$.PlaceLink .PlaceID}}">{{if .PlaceName}}{{.PlaceName}}{{else}}未命名地点{{end}}</a>{{else}}未命名地点{{end}}
{{if .ActivityName}}<span class="chip" style="padding:1px 8px;font-size:11.5px"><span class="dot" style="background:{{colorof .Color}}"></span>{{.ActivityName}}</span>{{end}}
{{if .ActivityName}}<span class="chip" style="padding:1px 8px;font-size:var(--fs-xs)"><span class="dot" style="background:{{csscolor .Color}}"></span>{{.ActivityName}}</span>{{end}}
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
</div>
<div class="meta">
+1 -1
View File
@@ -49,7 +49,7 @@
<tr>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
<td class="muted" data-label="城市">{{.City}}</td>
<td class="muted" data-label="类型">{{if .Act}}<span class="dot" style="background:{{colorof .Color}}"></span>{{.Act}}{{end}}</td>
<td class="muted" data-label="类型">{{if .Act}}<span class="dot" style="background:{{csscolor .Color}}"></span>{{.Act}}{{end}}</td>
<td class="num" data-label="到访">{{num .Count}}</td>
<td class="num muted" data-label="停留">{{dur .Dwell}}</td>
<td class="muted mono" data-label="最近一次">{{.LastAt}}</td>
+73 -29
View File
@@ -37,47 +37,91 @@ func TestTrackTag(t *testing.T) {
//
// 这段内容会被原样注入**每一个公开页面**,因此必须挡住三类输入:
// 提前闭合 </script>(页面布局散架)、javascript: 伪协议、超长误粘。
// 判据写的是「危险输入一律清空」,所以这里逐条钉住放行与拒绝的边界。
// 判据写的是「危险输入一律不收」,所以这里逐条钉住放行与拒绝的边界。
func TestCleanTrackScript(t *testing.T) {
cases := []struct {
name string
in string
want string
name string
in string
want string
wantWarn string
}{
{"空串", "", ""},
{"纯空白", " \n\t ", ""},
{"普通 https URL 原样保留", "https://umami.example.com/script.js", "https://umami.example.com/script.js"},
{"首尾空白被裁掉", " https://a/b.js ", "https://a/b.js"},
{"http 也放行", "http://a/b.js", "http://a/b.js"},
{"空串", "", "", ""},
{"纯空白", " \n\t ", "", ""},
{"普通 https URL 原样保留", "https://umami.example.com/script.js", "https://umami.example.com/script.js", ""},
{"首尾空白被裁掉", " https://a/b.js ", "https://a/b.js", ""},
{"http 也放行", "http://a/b.js", "http://a/b.js", ""},
{"整段 script 原样保留(百度统计这类)",
`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js"></script>`,
`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js"></script>`},
`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js"></script>`, ""},
{"src 指向相对路径(放行站内相对)", `<script src="/static/a.js"></script>`, `<script src="/static/a.js"></script>`, ""},
// 脚本体里的 src= **不是标签属性**:以前对整段做 strings.Index,这里会被误伤、
// 整段静默清空(页面还照样提示「设置已保存」)。实测复现过,钉住。
{"脚本体里的 src= 赋值必须放行", `<script>var i=new Image();i.src=u;</script>`,
`<script>var i=new Image();i.src=u;</script>`, ""},
{"拼出来的 src 也要放行", `<script>var s=document.createElement('script');s.src=base+"/a.js";</script>`,
`<script>var s=document.createElement('script');s.src=base+"/a.js";</script>`, ""},
// ↓ 必须拒的
{"提前闭合 script", `abc</script><img src=x onerror=alert(1)>`, ""},
{"大写闭合标签也挡", `abc</SCRIPT>`, ""},
{"大写 SRC 伪协议", `<script src="javascript:alert(1)"></script>`, ""},
{"data: 伪协议", `<script src="data:text/html,<script>alert(1)</script>">`, ""},
{"src 指向相对路径(放行站内相对)", `<script src="/static/a.js"></script>`, `<script src="/static/a.js"></script>`},
{"超长内容被截断到 4096", strings.Repeat("a", 5000), strings.Repeat("a", 4096)},
{"提前闭合 script", `abc</script><img src=x onerror=alert(1)>`, "", warnTrackUnbalanced},
{"大写闭合标签也挡", `abc</SCRIPT>`, "", warnTrackUnbalanced},
{"末尾未闭合的开标签也挡", `<script>x</script><script>y`, "", warnTrackUnbalanced},
{"光一个开标签也挡", `<script>y`, "", warnTrackUnbalanced},
{"大写 SRC 伪协议", `<script src="javascript:alert(1)"></script>`, "", warnTrackBadSrc},
{"data: 伪协议", `<script src="data:text/html,<script>alert(1)</script>">`, "", warnTrackBadSrc},
{"超长内容被截断到 4096", strings.Repeat("a", 5000), strings.Repeat("a", 4096), warnTrackTruncated},
}
for _, c := range cases {
if got := cleanTrackScript(c.in); got != c.want {
got, warn := cleanTrackScript(c.in)
if got != c.want {
t.Errorf("%s:cleanTrackScript(%q) = %q,期望 %q", c.name, c.in, got, c.want)
}
}
// 定义性不变量:任何输入都不许产出「含 </script 的非空结果」。
// 相对断言(比对某个具体长度)会被基准值一起膨胀而假绿,这里钉死语义。
dangerous := []string{
`abc</script>`, `ABC</ScRiPt>`, `<script src=javascript:alert(1)>`,
`<script src=data:text/html,x>`, strings.Repeat("x", 9000) + `</script>`,
}
for _, in := range dangerous {
if got := cleanTrackScript(in); strings.Contains(strings.ToLower(got), "</script") && got != "" {
t.Errorf("cleanTrackScript 放过了危险内容 %q -> %q", in, got)
if warn != c.wantWarn {
t.Errorf("%s:说明码 = %q,期望 %q", c.name, warn, c.wantWarn)
}
}
if len(cleanTrackScript(strings.Repeat("a", 9000))) > 4096 {
t.Error("超长内容没有被截断到 4096")
// 定义性不变量:只要采纳了非空结果,里面的开闭 <script> 标签数必定**相等**。
// 注意不是「不得含 </script」——百度统计那种整段贴进来的本来就带闭合标签,
// 真正的保证是「闭合数既不会多、也不会少」(多了提前闭合、少了吞掉收尾)。
inputs := []string{
`abc</script>`, `ABC</ScRiPt>`, `<script>x</script><script>y`, `<script>y`,
`<script src=javascript:alert(1)>`, `<script src=data:text/html,x>`,
strings.Repeat("x", 9000) + `</script>`,
`<script>a</script><script>b</script>`, `<script>var i=new Image();i.src=u;</script>`,
}
for _, in := range inputs {
got, _ := cleanTrackScript(in)
if got == "" {
continue
}
low := strings.ToLower(got)
if open, shut := strings.Count(low, "<script"), strings.Count(low, "</script"); open != shut {
t.Errorf("采纳了标签不配平的内容:%q -> open=%d shut=%d", in, open, shut)
}
}
if got, _ := cleanTrackScript(strings.Repeat("a", 9000)); len(got) > 4096 {
t.Errorf("超长内容没有被截断到 4096:len=%d", len(got))
}
}
// TestTrackWarnMessage 说明码必须是**固定文案**:它最终进跳转查询串,
// 未识别的码一律不提示(避免把查询串原文回显到页面上)。
func TestTrackWarnMessage(t *testing.T) {
for _, code := range []string{warnTrackTruncated, warnTrackUnbalanced, warnTrackBadSrc} {
if trackWarnMessage(code) == "" {
t.Errorf("说明码 %q 应有对应提示文案", code)
}
}
for _, code := range []string{"", "unknown", "<script>alert(1)</script>"} {
if msg := trackWarnMessage(code); msg != "" {
t.Errorf("未识别的说明码 %q 不该产出提示,得到 %q", code, msg)
}
}
if !trackWarnRejected(warnTrackUnbalanced) || !trackWarnRejected(warnTrackBadSrc) {
t.Error("被拒的两类说明码必须被 trackWarnRejected 认出来")
}
if trackWarnRejected(warnTrackTruncated) || trackWarnRejected("") {
t.Error("「截断」是采纳(只是改了内容),不该算作整段被拒")
}
}
+38 -7
View File
@@ -7,6 +7,7 @@ import (
"html/template"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
@@ -521,6 +522,8 @@ type HomeData struct {
MaxCity int
MaxTransport int
MaxTag int
// MaxMonthly 是月度柱图里的最高值,用来在图上标出峰值那一根(首页与统计页口径一致)。
MaxMonthly int
}
type MapData struct {
@@ -1102,6 +1105,8 @@ type SettingsData struct {
MapLayers []MapLayerOption
// S3Info 是对象存储配置状态的只读说明(配没配齐、桶名、公开域名)。
S3Info string
// Warn 是统计脚本这次没被原样采纳的提示(「已截断」/「已拒绝」)。
Warn string
}
// MapLayerOption 是后台「默认底图」下拉的一项。
@@ -1335,16 +1340,41 @@ func transportColorNames() []string {
return out
}
func colorOf(name string) string {
if c, ok := appleColors[name]; ok {
return c
// cssColor 给模板的内联 style 用,返回 CSS 变量名——只有走变量,配色才能跟着暗色模式走。
// 变量定义在 app.css 的 :root 里,名字就是 appleColors 的键,两边必须同步增删。
//
// 那还需要「真实色值」的地方怎么办:导出/下发的 JSON(地图点)带的是原始色名,
// 由 rond-map.js 自己解析——**那份调色板是故意独立的**:地图瓦片在暗色下保持浅色,
// 所以标记必须用浅色底上那套值,不能跟着页面的暗色变量走。
//
// ⚠️ 返回值必须是 template.CSS:html/template 的 CSS 上下文过滤器**不认识 var()**,
// 普通 string 会被判成不安全、替换成 ZgotmplZ(实测踩过,页面上的圆点全变成透明)。
// 而一旦挂了 template.CSS 就等于关掉这道过滤,所以色值必须先过 hexColorRe,
// 只有「# + 6 位十六进制」才原样放行 —— 否则 `#"><img…` 这种能直接逃出 style 属性。
func cssColor(name string) template.CSS {
if _, ok := appleColors[name]; ok {
return template.CSS("var(--cat-" + name + ")")
}
if len(name) == 7 && name[0] == '#' {
return name
if hexColorRe.MatchString(name) {
return template.CSS(name)
}
return "#5b6169"
return "var(--cat-none)"
}
// cssColorSoft 是同一色的浅底(emoji 方块那种)。自定义色值没有对应变量,
// 退回「hex + 22」这个 8 位带 alpha 的写法——它是合法 CSS,同样先过严格校验。
func cssColorSoft(name string) template.CSS {
if _, ok := appleColors[name]; ok {
return template.CSS("var(--cat-" + name + "-soft)")
}
if hexColorRe.MatchString(name) {
return template.CSS(name + "22")
}
return "var(--cat-none-soft)"
}
var hexColorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// iconOf 只保留 emoji 图标。rond 里的图标多为 SF Symbols 名称(如 house.fill),
// 在网页上无法渲染,直接返回空串让模板退回默认样式。
func iconOf(s string) string {
@@ -1422,7 +1452,8 @@ func templateFuncs() template.FuncMap {
}
return b
},
"colorof": colorOf,
"csscolor": cssColor,
"csssoft": cssColorSoft,
"icon": iconOf,
"iconor": iconOr,
"condition": stats.ConditionName,