Files
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

252 lines
12 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>地点补全</h1>
<div class="sub">
共 <strong>{{num .Total}}</strong> 个地点缺活动或类别,其中 <strong>{{num .WithHint}}</strong> 个能给出建议。
<strong>只填空</strong>——已经有活动或类别的一律不动。
</div>
</div>
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
{{if .Rows}}
<div class="card">
<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>,你改过的才会写进去。
活动可以直接打字(会提示已有的类型),也可以点「采纳本页建议」一键填上;
类别从下拉里挑,都不合适就选「其它」自己写一个(真机也存中文类别,写中文同样认得)。<br>
活动按<strong>地点</strong>补:一次把它名下所有没设活动的到访一起填上,不会只改一条。
右侧「高德参考」是高德在这个坐标上找到的 POI 与类型,拿不准时作参考。
</div>
{{if gt .PageCount 1}}
<div class="pager">
{{if gt .PageNo 1}}<a href="{{.PageLink (sub .PageNo 1)}}">上一页</a>{{end}}
<span class="cur">第 {{.PageNo}} / {{.PageCount}} 页</span>
{{if lt .PageNo .PageCount}}<a href="{{.PageLink (add .PageNo 1)}}">下一页</a>{{end}}
</div>
{{end}}
<form method="post" action="/admin/fillup/apply" id="fillup-form">
<input type="hidden" name="page" value="{{.PageNo}}">
<div class="tw">
<table class="tb tb-stack">
<thead>
<tr>
<th>地点</th><th>城市</th><th class="num">待设到访</th><th>缺什么</th>
<th>活动</th><th>类别</th><th>建议来源</th><th>高德参考</th>
</tr>
</thead>
<tbody>
{{range $row := .Rows}}
<tr>
<td>{{if $row.Name}}{{$row.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
<td data-label="城市">{{if $row.City}}{{$row.City}}{{else}}<span class="muted">—</span>{{end}}</td>
<td class="num" data-label="待设到访">{{if $row.NoAct}}{{$row.NoAct}}{{else}}<span class="muted">—</span>{{end}}</td>
<td data-label="缺什么">{{$row.Missing}}</td>
<td data-label="活动">
{{if $row.NoAct}}
{{$ahint := $row.ActHint}}
<select name="act_{{$row.PlaceID}}" class="fill-sel" data-hint="{{$ahint}}" style="min-width:10em">
<option value="">(不动)</option>
{{if $ahint}}<option value="{{$ahint}}">建议:{{$ahint}}</option>{{end}}
{{range $.Activities}}{{if ne .Name $ahint}}<option value="{{.Name}}">{{.Name}}</option>{{end}}{{end}}
<option value="__custom__">其它(自己写)…</option>
</select>
<input type="text" name="act_{{$row.PlaceID}}__custom" placeholder="例如 爬山"
style="display:none;margin-top:4px;min-width:10em">
{{else}}<span class="muted">—</span>{{end}}
</td>
<td data-label="类别">
{{if $row.NoCat}}
{{$hint := $row.CatHint}}
<select name="cat_{{$row.PlaceID}}" class="fill-sel" data-hint="{{$hint}}" style="min-width:10em">
<option value="">(不动)</option>
{{if $hint}}<option value="{{$hint}}">建议:{{$row.CatLabel}}</option>{{end}}
{{range $.Categories}}{{if ne .Value $hint}}<option value="{{.Value}}">{{.Label}}</option>{{end}}{{end}}
<option value="__custom__">其它(自己写)…</option>
</select>
<input type="text" name="cat_{{$row.PlaceID}}__custom" placeholder="例如 美食街"
style="display:none;margin-top:4px;min-width:10em">
{{else}}<span class="muted">—</span>{{end}}
</td>
<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: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>
{{end}}
</tbody>
</table>
</div>
<div class="row" style="margin-top:12px">
<button class="btn primary" type="submit">执行补全</button>
<button class="btn" type="button" id="pick-hints">采纳本页建议</button>
<button class="btn" type="submit" formaction="/admin/fillup/amap" id="amap-btn"
title="用高德按坐标搜一遍,给还没建议的地点补上参考(每批 40 个)">用高德分析剩下的</button>
</div>
</form>
{{if gt .PageCount 1}}
<div class="pager" style="margin-top:12px">
{{if gt .PageNo 1}}<a href="{{.PageLink (sub .PageNo 1)}}">上一页</a>{{end}}
<span class="cur">第 {{.PageNo}} / {{.PageCount}} 页</span>
{{if lt .PageNo .PageCount}}<a href="{{.PageLink (add .PageNo 1)}}">下一页</a>{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="card">
<div class="alert ok">所有地点的活动和类别都齐了,没什么要补的。</div>
</div>
{{end}}
</div>
<script>
(function () {
var KEY = 'rond-fillup-draft';
var NEW = 'rond-fillup-newopts';
// 活动和类别是同一套下拉:选了「其它(自己写)」就露出旁边的文本框
document.querySelectorAll('select.fill-sel').forEach(function (sel) {
var box = document.querySelector('input[name="' + sel.name + '__custom"]');
if (!box) return;
var sync = function () {
var custom = sel.value === '__custom__';
box.style.display = custom ? '' : 'none';
if (custom) box.focus();
};
sel.addEventListener('change', sync);
sync();
});
// 下拉的候选是渲染时定好的,手工新填的名字进不了候选,后面遇到同类地点还得重打一遍。
// 所以把填过的值记下来补进同类下拉:本页实时生效,翻页/点「用高德分析」也留得住,
// 提交成功后清掉(那之后它们已经在真正的候选里了)。
var fromOther = {act: [], cat: []}; // 从别处(别的页/上次)带来的
var fromPage = {act: [], cat: []}; // 本页文本框里当前填着的
try {
var saved = JSON.parse(sessionStorage.getItem(NEW) || '{}');
fromOther.act = saved.act || [];
fromOther.cat = saved.cat || [];
} catch (e) { /* 坏数据就当没有 */ }
var paint = function (prefix) {
var vals = fromOther[prefix].concat(fromPage[prefix].filter(function (v) {
return fromOther[prefix].indexOf(v) < 0;
}));
document.querySelectorAll('select[name^="' + prefix + '_"]').forEach(function (sel) {
// 重画不能动用户已经选好的东西:这一格选的要是「新填」里的值,先留住它
var keep = sel.value;
if (keep && vals.indexOf(keep) < 0 && [].some.call(sel.options, function (o) {
return o.value === keep && o.getAttribute('data-local');
})) vals.push(keep);
[].forEach.call(sel.querySelectorAll('option[data-local]'), function (o) { o.remove(); });
// 插在「(不动)」/建议之后、真正的候选之前,免得沉到列表最底下去找
var anchor = null;
for (var i = 0; i < sel.options.length; i++) {
var o = sel.options[i];
if (o.getAttribute('data-local') || !o.value || o.value === sel.dataset.hint) continue;
anchor = o;
break;
}
vals.forEach(function (v) {
if ([].some.call(sel.options, function (o) { return o.value === v; })) return;
var o = document.createElement('option');
o.value = v;
o.textContent = '新填:' + v;
o.setAttribute('data-local', '1');
sel.insertBefore(o, anchor);
});
// 选项重插之后浏览器不会自己选回来,得手工还回去
if (keep && sel.value !== keep) sel.value = keep;
});
};
var collect = function (prefix) {
fromPage[prefix] = [];
document.querySelectorAll('input[name^="' + prefix + '_"][name$="__custom"]').forEach(function (box) {
var v = box.value.trim();
if (v && fromPage[prefix].indexOf(v) < 0) fromPage[prefix].push(v);
});
paint(prefix);
try {
sessionStorage.setItem(NEW, JSON.stringify({
act: fromOther.act.concat(fromPage.act),
cat: fromOther.cat.concat(fromPage.cat)
}));
} catch (e) { /* 隐私模式就算了 */ }
};
// 文本框里打字就同步,不用等失焦或提交。直接挂在框上,不靠事件冒泡——
// 省得以后有人在这行 DOM 上动手脚(或程序化派发)时静默失效。
document.querySelectorAll('input[name$="__custom"]').forEach(function (box) {
box.addEventListener('input', function () { collect(box.name.split('_')[0]); });
});
// 采纳本页建议:把建议项选上(活动和类别都是下拉)
var pick = document.getElementById('pick-hints');
if (pick) {
pick.addEventListener('click', function () {
var n = 0;
document.querySelectorAll('select.fill-sel').forEach(function (sel) {
if (sel.value || !sel.dataset.hint) return;
sel.value = sel.dataset.hint;
n++;
});
pick.textContent = n ? '已填入 ' + n + ' 条建议' : '本页没有可填的建议';
});
}
// 离开本页(翻页 / 「用高德分析」)之前把填过的存起来、回来接着填——
// 否则挑了半天,点一下就全没了。按 name 合并,所以翻页也留得住。
var save = function () {
var draft = {};
try { draft = JSON.parse(sessionStorage.getItem(KEY) || '{}'); } catch (e) { draft = {}; }
document.querySelectorAll('select.fill-sel, input[name$="__custom"]').forEach(function (el) {
if (el.value) draft[el.name] = el.value;
else delete draft[el.name]; // 本页明确清空的就是不要了,别让旧值顶上来
});
try { sessionStorage.setItem(KEY, JSON.stringify(draft)); } catch (e) { /* 隐私模式就算了 */ }
};
var amap = document.getElementById('amap-btn');
if (amap) amap.addEventListener('click', save);
document.querySelectorAll('.pager a').forEach(function (a) { a.addEventListener('click', save); });
// 先把「新填」的候选补上再恢复草稿:草稿里选中的可能就是新填的值,
// 选项不在跟前的话 select.value 是设不进去的。
paint('act');
paint('cat');
try {
var draft = JSON.parse(sessionStorage.getItem(KEY) || '{}');
Object.keys(draft).forEach(function (name) {
var el = document.querySelector('[name="' + name + '"]');
if (!el) return;
el.value = draft[name];
if (el.tagName === 'SELECT') el.dispatchEvent(new Event('change'));
});
} catch (e) { /* 坏数据就当没有 */ }
// 恢复出来的文本框(`set value` 不触发 input)也要算进「新填」
collect('act');
collect('cat');
// 提交成功后草稿就没用了,清掉,免得下次进来还带着旧选择
var form = document.getElementById('fillup-form');
if (form) form.addEventListener('submit', function () {
try {
sessionStorage.removeItem(KEY);
sessionStorage.removeItem(NEW);
} catch (e) {}
});
})();
</script>
{{end}}