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