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

463 lines
21 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>{{if .Place.Name}}{{.Place.Name}}{{else}}未命名地点{{end}}</h1>
<div class="sub">
{{if .Place.City}}{{.Place.City}}{{end}}
{{if .Place.Province}} · {{.Place.Province}}{{end}}
{{if .Place.District}} · {{.Place.District}}{{end}}
{{if .CategoryLabel}} · {{.CategoryLabel}}{{end}}
· <span class="mono">{{f1 .Place.Lat}}, {{f1 .Place.Lon}}</span>
</div>
</div>
<div class="row">
{{/* 有 back 就按它回去:从筛选过的地点库/时间线/地图点进来时,这里要回到**那份筛选结果** */}}
<a class="btn" href="{{if .Back}}{{.Back}}{{else}}/places{{end}}">← {{if .BackLabel}}{{.BackLabel}}{{else}}返回地点库{{end}}</a>
<a class="btn" href="{{.MapLink .Place.Name}}">在地图中查看</a>
{{if .IsAdmin}}<a class="btn" href="{{.EditPlaceLink .Place.SrcPK}}" title="在数据编辑页里直接打开这个地点的编辑框">编辑</a>{{end}}
{{if .Images}}<button class="btn" type="button" id="imgs-open">图片 <span class="muted">{{len .Images}}</span></button>{{end}}
</div>
</div>
<div class="kpis" style="margin-bottom:16px">
<div class="kpi"><div class="v">{{num .Place.VisitCount}}<small>次</small></div><div class="k">到访次数</div></div>
<div class="kpi"><div class="v">{{dur .Place.DwellMinutes}}</div><div class="k">累计停留</div></div>
<div class="kpi"><div class="v">{{if .Place.FirstAt}}{{d .Place.FirstAt}}{{else}}—{{end}}</div><div class="k">首次到访</div></div>
<div class="kpi"><div class="v">{{if .Place.LastAt}}{{since .Place.LastAt}}{{else}}—{{end}}</div><div class="k">最近到访</div></div>
</div>
{{if .IsAdmin}}
<div class="card" style="margin-bottom:16px">
<h2>备注与别名</h2>
<div class="card-sub">
别名会替代原名显示在所有页面上;备注{{if .Settings.NotesPublic}}对访客公开{{else}}只有你自己能看到{{end}}。
「推荐 / 踩雷」是单独的按钮,不用写进备注。按 rond 的地点 ID 保存,重新上传备份也不会丢。
</div>
{{if .Flash}}<div class="alert ok" id="note-flash">{{.Flash}}</div>{{end}}
{{if .RondNote}}
<div class="card-sub" style="margin-bottom:10px">
<div style="margin-bottom:4px">
rond 备份里给这个地点写过的备注(按次到访记录,只读;展示时会拼在你的备注之前):
</div>
<div class="note-block">{{.RondNote}}</div>
</div>
{{end}}
<form method="post" action="/admin/place/note" id="note-form">
<input type="hidden" name="src_pk" value="{{.Place.SrcPK}}">
<input type="hidden" name="next" value="/places/{{.Place.ID}}">
<label class="f" style="max-width:420px">
<span>别名</span>
<input type="text" name="alias" value="{{.Place.Alias}}" maxlength="60" placeholder="{{.Place.Name}}">
</label>
<label class="f" style="max-width:640px">
<span>备注</span>
<textarea name="note" rows="3" maxlength="2000" placeholder="例如:和谁来的、什么印象">{{.MyNote}}</textarea>
</label>
<button class="btn primary" type="submit">保存</button>
</form>
</div>
{{else if .Place.Note}}
<div class="card" style="margin-bottom:16px"><h2>备注</h2><div class="note-block">{{.Place.Note}}</div></div>
{{end}}
{{if .IsAdmin}}
<div class="card" style="margin-bottom:16px">
<h2>地点图片</h2>
<div class="card-sub">
选好图就<b>自动上传并立即生效</b>,不用再点保存。
上传前会<b>在浏览器里转成 WebP</b>(长边 2000px),服务器只负责收下与转存,不做转码。
当前存储:<b>{{if eq .ImageBackend "s3"}}对象存储(S3 / R2){{else if eq .ImageBackend "local"}}服务器本地磁盘{{else}}{{.ImageBackend}}{{end}}</b>
,最多 {{.ImageMaxPer}} 张。传完访客在地点页点「图片」就能看到。
</div>
<div id="imgs-admin" data-src="{{.Place.SrcPK}}" data-name="{{.Place.Name}}">
<div class="imgs-grid" id="imgs-grid">
{{range .Images}}
<figure class="imgs-cell" data-id="{{.ID}}">
<img src="{{.URL}}" alt="{{if $.Place.Name}}{{$.Place.Name}} 的图片{{else}}地点图片{{end}}" loading="lazy">
<button type="button" class="imgs-del" data-id="{{.ID}}" title="删除这张" aria-label="删除这张图片">×</button>
</figure>
{{end}}
</div>
<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:var(--fs-sm)"></span>
</div>
<div class="imgs-hint" id="imgs-hint"></div>
</div>
</div>
{{end}}
{{if or .IsAdmin .Settings.MarksPublic}}
<div class="card" style="margin-bottom:16px">
<h2>推荐 / 踩雷</h2>
{{if .IsAdmin}}
{{/* 站长这里只放自己的结论;访客票数只读展示,避免同一块地方出现两套投票按钮 */}}
<div class="card-sub">值得再去就👍,踩雷避坑就👎。改主意了点一下已选中的按钮即可取消。</div>
<div class="vote-row" data-verdict-row data-src="{{.Place.SrcPK}}">
<span class="vote-label">我的结论</span>
<span class="vote-btns">
<button class="vote-btn up {{if eq .Verdict 1}}on{{end}}" type="button" data-verdict="1">👍 推荐</button>
<button class="vote-btn down {{if eq .Verdict -1}}on{{end}}" type="button" data-verdict="-1">👎 踩雷</button>
</span>
<span class="vote-tip" data-cur="{{.Verdict}}"></span>
</div>
<div class="vote-row">
<span class="vote-label">访客票数</span>
<span class="vote-btns">
<span class="vote-btn up ro">👍 <span class="up-n">{{num .Votes.Up}}</span></span>
<span class="vote-btn down ro">👎 <span class="down-n">{{num .Votes.Down}}</span></span>
</span>
<span class="vote-tip">只读</span>
</div>
{{else}}
<div class="card-sub">值得再去就👍,踩雷避坑就👎。改主意了点一下已选中的按钮即可取消。</div>
<div class="vote-row" data-visitor-row data-src="{{.Place.SrcPK}}">
<span class="vote-label">大家怎么说</span>
<span class="vote-btns">
<button class="vote-btn up" type="button" data-vote="1">👍 <span class="up-n">{{.Votes.Up}}</span></button>
<button class="vote-btn down" type="button" data-vote="-1">👎 <span class="down-n">{{.Votes.Down}}</span></button>
</span>
<span class="vote-tip" data-mine="{{.MyVote}}"></span>
</div>
{{end}}
</div>
<script>
(function () {
var POST = { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, credentials: 'same-origin' };
// 站长结论:AJAX 提交,避免整页刷新把还没保存的备注冲掉
var vrow = document.querySelector('.vote-row[data-verdict-row]');
if (vrow) {
var cur = Number(vrow.querySelector('.vote-tip').getAttribute('data-cur')) || 0;
var vtip = vrow.querySelector('.vote-tip');
var paintVerdict = function () {
vrow.querySelectorAll('.vote-btn').forEach(function (b) {
b.classList.toggle('on', Number(b.getAttribute('data-verdict')) === cur);
});
vtip.textContent = cur === 1 ? '已标记 · 推荐' : cur === -1 ? '已标记 · 踩雷' : '未标记';
};
paintVerdict();
vrow.querySelectorAll('.vote-btn').forEach(function (b) {
b.addEventListener('click', function () {
var v = Number(b.getAttribute('data-verdict'));
fetch('/api/verdict', Object.assign({
body: 'src_pk=' + vrow.getAttribute('data-src') + '&verdict=' + (cur === v ? 0 : v)
}, POST)).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) { alert(d.error); return; }
cur = Number(d.verdict) || 0;
paintVerdict();
}).catch(function () {});
});
});
}
// 访客票:一人一票,再点一次撤回
var row = document.querySelector('.vote-row[data-visitor-row]');
if (!row) return;
var src = row.getAttribute('data-src');
var mine = Number(row.querySelector('.vote-tip').getAttribute('data-mine')) || 0;
function paint(m) {
mine = m;
row.querySelectorAll('.vote-btn').forEach(function (b) {
b.classList.toggle('on', Number(b.getAttribute('data-vote')) === mine);
});
var tip = row.querySelector('.vote-tip');
if (tip) tip.textContent = mine === 1 ? '你投了推荐' : mine === -1 ? '你投了踩雷' : '';
}
paint(mine);
row.querySelectorAll('.vote-btn').forEach(function (b) {
b.addEventListener('click', function () {
var v = Number(b.getAttribute('data-vote'));
b.disabled = true;
fetch('/api/vote', Object.assign({
body: 'src_pk=' + encodeURIComponent(src) + '&vote=' + v
}, POST)).then(function (r) { return r.json(); }).then(function (d) {
b.disabled = false;
if (d.error) { alert(d.error); return; }
row.querySelector('.up-n').textContent = d.up;
row.querySelector('.down-n').textContent = d.down;
paint(Number(d.mine) || 0);
}).catch(function () { b.disabled = false; });
});
});
})();
</script>
{{end}}
<div class="grid g2">
<div class="card">
<h2>位置</h2>
<div class="card-sub">底图:GCJ-02(与记录坐标同系,无需纠偏)</div>
<div id="place-map" class="map-preview" style="height:280px"></div>
</div>
<div class="card">
<h2>到访记录</h2>
<div class="card-sub">最近 {{num (len .Visits)}} 次</div>
<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:{{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>
</div>
<div class="r">{{dur .DurationMin}}</div>
</div>
{{end}}
</div>
</div>
</div>
</div>
<script>
window.addEventListener('load', function () {
window.rondMap.preview({ id: 'place-map', points: {{json .Points}} });
});
</script>
{{/* 全屏画廊:单张查看 + 左右切换 + 双击放大。
以前是把所有图竖着堆在一个滚动页里,手机上既不能左右滑、也不能放大。
容器常驻(内容由 JS 从缩略图墙同步),所以这里不用 if .Images 包着。 */}}
<div class="imgs-modal" id="imgs-modal" hidden>
<div class="imgs-count" id="imgs-counter"></div>
<button class="imgs-close" type="button" id="imgs-close" aria-label="关闭">×</button>
<button class="imgs-nav imgs-prev" type="button" id="imgs-prev" aria-label="上一张" hidden>‹</button>
<button class="imgs-nav imgs-next" type="button" id="imgs-next" aria-label="下一张" hidden>›</button>
<div class="imgs-stage" id="imgs-stage">
<img id="imgs-img" alt="{{if .Place.Name}}{{.Place.Name}} 的图片{{else}}地点图片{{end}}">
</div>
</div>
<script src="/static/js/place-image.js?v={{.AssetV}}"></script>
<script>
window.addEventListener('load', function () {
var grid = document.getElementById('imgs-grid');
var modal = document.getElementById('imgs-modal');
var maxPer = {{.ImageMaxPer}};
var admin = document.getElementById('imgs-admin');
/* ---------- 全屏画廊 ----------
* 地址有两个来源:站长页有缩略图墙,传图/删图之后就地在墙上读(它才是实时真相);
* 访客页没有那一整块,只能读服务端随页面发下来的这份。页头那个「图片 N」按钮
* 与画廊共用同一套数据,所以服务端渲染时还没有图的话,这里现补一个。
*/
var stage = document.getElementById('imgs-stage');
var viewImg = document.getElementById('imgs-img');
var counter = document.getElementById('imgs-counter');
var prevBtn = document.getElementById('imgs-prev');
var nextBtn = document.getElementById('imgs-next');
var serverURLs = {{if .ImageURLs}}{{json .ImageURLs}}{{else}}[]{{end}};
var urls = [];
var idx = 0;
// 判据必须是「缩略图墙在不在」,而不是「墙里有几张」:站长把图删光时墙还在、只是空了,
// 这时若按「墙是空的」回退到服务端那份,删掉的图就会被数回来。
function cellURLs() {
var g = document.getElementById('imgs-grid');
if (!g) return serverURLs;
return [].map.call(g.querySelectorAll('.imgs-cell img'), function (i) {
return i.getAttribute('src');
});
}
function paint() {
if (!urls.length) return;
viewImg.src = urls[idx];
counter.textContent = (idx + 1) + ' / ' + urls.length;
var many = urls.length > 1;
prevBtn.hidden = nextBtn.hidden = !many;
// 换图后回到「适应屏幕」状态,否则下一张会继承上一张的放大
stage.classList.remove('zoomed');
stage.scrollTop = stage.scrollLeft = 0;
}
function goto(i) {
if (!urls.length) return;
idx = (i % urls.length + urls.length) % urls.length;
paint();
}
function openGallery(on) {
modal.hidden = !on;
// 打开时锁住背景滚动,否则滑到尽头会把后面的页面一起带着滚
document.body.style.overflow = on ? 'hidden' : '';
if (on) {
urls = cellURLs();
idx = Math.min(idx, Math.max(0, urls.length - 1));
paint();
} else {
stage.classList.remove('zoomed');
}
}
function syncGallery() {
urls = cellURLs();
var btn = document.getElementById('imgs-open');
if (!urls.length) {
if (btn) btn.remove();
if (modal) modal.hidden = true;
return;
}
if (!btn) {
btn = document.createElement('button');
btn.className = 'btn';
btn.type = 'button';
btn.id = 'imgs-open';
var head = document.querySelector('.page-head .row');
if (head) head.appendChild(btn);
}
btn.innerHTML = '图片 <span class="muted">' + urls.length + '</span>';
if (idx >= urls.length) idx = 0;
counter.textContent = urls.length + ' 张';
}
modal.addEventListener('click', function (e) {
var cls = (e.target.className || '').indexOf('imgs-close') >= 0;
// 点空白或关闭按钮都关掉;点图片本身不关(可能是想放大)
if (e.target === modal || e.target === stage || cls) openGallery(false);
});
prevBtn.addEventListener('click', function (e) { e.stopPropagation(); goto(idx - 1); });
nextBtn.addEventListener('click', function (e) { e.stopPropagation(); goto(idx + 1); });
document.addEventListener('click', function (e) {
if (e.target && e.target.id === 'imgs-open') { idx = 0; openGallery(true); }
});
document.addEventListener('keydown', function (e) {
if (modal.hidden) return;
if (e.key === 'Escape') openGallery(false);
else if (e.key === 'ArrowLeft') goto(idx - 1);
else if (e.key === 'ArrowRight') goto(idx + 1);
});
// 手势:横向滑动=切换,轻点两下=放大/还原。两者必须在**同一个** touchend 里分流——
// 分成两个监听器时,滑动结束后那个监听器会把它当成一次「点按」,连滑两次就误触发双击放大。
var tx = 0, ty = 0, tracking = false, panning = false, lastTap = 0;
function toggleZoom() {
stage.classList.toggle('zoomed');
if (!stage.classList.contains('zoomed')) stage.scrollTop = stage.scrollLeft = 0;
}
stage.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { tracking = false; return; }
tracking = true;
// 放大状态下拖动是「看局部」,不要再当成切换
panning = stage.classList.contains('zoomed');
tx = e.touches[0].clientX;
ty = e.touches[0].clientY;
}, { passive: true });
stage.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
var t = e.changedTouches[0];
var dx = t.clientX - tx, dy = t.clientY - ty;
if (!panning && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
goto(idx + (dx < 0 ? 1 : -1));
return;
}
// 手指基本没动才算「点按」,避免拖动松手被误判成双击
if (Math.abs(dx) > 12 || Math.abs(dy) > 12) return;
var now = Date.now();
if (now - lastTap < 300) { lastTap = 0; toggleZoom(); } else { lastTap = now; }
}, { passive: true });
stage.addEventListener('dblclick', function (e) { e.preventDefault(); toggleZoom(); });
{{if .IsAdmin}}
/* ---------- 站长:传图 / 删图 ---------- */
if (!admin || !window.rondImage) { syncGallery(); return; }
var srcPK = admin.getAttribute('data-src');
var input = document.getElementById('imgs-input');
var pick = document.getElementById('imgs-pick');
var hint = document.getElementById('imgs-hint');
var countEl = document.getElementById('imgs-count');
var ready = {{if .ImageReady}}true{{else}}false{{end}};
function setHint(msg, kind) {
hint.textContent = msg || '';
hint.className = 'imgs-hint' + (kind ? ' ' + kind : '');
}
function refreshCount() {
var n = grid.querySelectorAll('.imgs-cell').length;
countEl.textContent = n ? ('已有 ' + n + ' / ' + maxPer + ' 张') : ('还没有图片,最多 ' + maxPer + ' 张');
}
function cell(id, url) {
var fig = document.createElement('figure');
fig.className = 'imgs-cell';
fig.setAttribute('data-id', id);
var img = document.createElement('img');
img.src = url; img.loading = 'lazy';
img.alt = (admin && admin.dataset.name ? admin.dataset.name + ' 的图片' : '地点图片');
var del = document.createElement('button');
del.type = 'button'; del.className = 'imgs-del'; del.textContent = '×'; del.title = '删除这张';
del.setAttribute('aria-label', '删除这张图片');
del.setAttribute('data-id', id);
fig.appendChild(img); fig.appendChild(del);
return fig;
}
refreshCount();
if (!ready) {
setHint('当前图片存储配置有问题,先修好再上传(见 conf/app.ini 的 s3_* 与后台「图片存储」)。', 'err');
pick.classList.add('is-disabled');
pick.removeAttribute('for');
pick.title = '图片存储未就绪';
}
// 选中即上传:没有「保存」这一步,所以进度和结果必须在页面上说清楚
input.addEventListener('change', function () {
var files = [].slice.call(input.files || []);
input.value = ''; // 允许连着选同一张
if (!files.length) return;
admin.classList.add('imgs-busy');
var done = 0, failed = 0;
var finish = function () {
if (done + failed < files.length) return;
admin.classList.remove('imgs-busy');
refreshCount();
syncGallery();
if (failed) {
setHint('完成:成功 ' + done + ' 张,失败 ' + failed + ' 张。' +
(done ? '成功的那几张已经生效,刷新页面也在。' : ''), 'err');
} else {
setHint('已上传 ' + done + ' 张,已即时生效(访客刷新就能看到,不用再点保存)。', 'ok');
}
};
files.forEach(function (f, idx) {
setHint('正在处理 ' + (idx + 1) + '/' + files.length + ':' + f.name + '(浏览器转码 → 上传)…');
window.rondImage.toWebP(f).then(function (out) {
return window.rondImage.upload(srcPK, out.blob, out.width, out.height)
.then(function (d) {
if (d.error) throw new Error(d.error);
grid.appendChild(cell(d.id, d.url));
done++;
refreshCount();
});
}).catch(function (err) {
failed++;
setHint('「' + f.name + '」失败:' + (err && err.message ? err.message : err), 'err');
}).then(finish);
});
});
grid.addEventListener('click', function (e) {
var del = e.target.closest('.imgs-del');
if (!del) return;
var id = del.getAttribute('data-id');
del.disabled = true;
window.rondImage.remove(id).then(function (d) {
if (d.error) { setHint('删除失败:' + d.error, 'err'); del.disabled = false; return; }
var fig = grid.querySelector('.imgs-cell[data-id="' + id + '"]');
if (fig) fig.remove();
refreshCount();
syncGallery();
setHint('已删除该张,立即生效。', 'ok');
}).catch(function () { setHint('删除失败', 'err'); del.disabled = false; });
});
{{else}}
syncGallery();
{{end}}
});
</script>
{{end}}