先出方案(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 拆又容易做出「中间那笔编译不过」的历史,所以合成一笔、在消息里分段。
252 lines
12 KiB
HTML
252 lines
12 KiB
HTML
{{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}}
|