Files
rond-with-you/internal/web/templates/admin/photos.html
T
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

864 lines
39 KiB
HTML
Raw 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"}}
{{/* 解析器放最前:它必须在用户选完文件之前就绪,放页面底部会晚一步 */}}
<script src="/static/js/photo-exif.js?v={{.AssetV}}"></script>
<script src="/static/js/place-image.js?v={{.AssetV}}"></script>
{{/* 选项数据放隐藏 select 里给脚本读:内联 JSON 会被模板的 JS 上下文转义搞坏 */}}
<select id="category-options" hidden>
<option value="">未指定</option>
{{range .Categories}}<option value="{{.Label}}">{{.Name}}</option>{{end}}
<option value="__custom__">自定义…</option>
</select>
<select id="activity-options" hidden>
<option value="">未指定</option>
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
<option value="__new__">+ 新增活动…</option>
</select>
<select id="tag-options" hidden>
{{range .Tags}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<div class="wrap">
<div class="page-head">
<div>
<h1>照片导入</h1>
<div class="sub">从照片的 EXIF 读拍摄时间与坐标,反查地址后逐条确认,再写入数据库</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}}
<div class="card">
<h2>1. 选照片与坐标系</h2>
<div class="card-sub">
支持 JPEG 与 HEIC/HEIF。<b>EXIF 在浏览器里读取,照片不会上传到服务器</b>——
一次几十张、几百 MB 都没有上传耗时或存储压力。
</div>
<div class="row" style="align-items:center;margin-bottom:10px">
<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>
{{if not .GeocodeReady}}
<div class="alert err" style="margin-bottom:10px">未配置高德 <span class="mono">geocode_key</span>,无法反查地址,只能靠坐标判断。</div>
{{end}}
<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:var(--fs-sm)">可多选,JPEG / HEIC 都行</span>
</div>
<div id="parse-status" class="alert info"></div>
</div>
<form method="post" action="/admin/photos/import" id="import-form" enctype="multipart/form-data">
<input type="hidden" name="n" id="row-count" value="0">
<input type="hidden" name="coord" id="coord-field" value="wgs84">
<div class="card" id="preview-card" style="display:none">
<h2>2. 确认与修改</h2>
<div class="card-sub">对照「地址」列确认点是否正确;点该行的「更多」可以改类别、省市区街道、活动、标签等——字段与<a href="/admin/edit">新增地点</a>完全一致。</div>
<div id="map-card" style="display:none">
<div id="photo-map" class="photo-map"></div>
<div class="card-sub" style="margin:6px 2px 0">地图上是所有已读取的点(坐标已按上面的选择换算);点跑到国外或海里说明坐标系选错了。<b>鼠标移到下面任意一行,地图上对应的点会跳出来</b>,点「地图」可以看单点大图。</div>
</div>
<div class="kpis" style="margin:12px 0 14px">
<div class="kpi"><div class="v" id="k-total">0</div><div class="k">读取到的照片</div></div>
<div class="kpi"><div class="v" id="k-gps">0</div><div class="k">含坐标</div></div>
<div class="kpi"><div class="v" id="k-addr">0</div><div class="k">地址反查成功</div></div>
<div class="kpi"><div class="v" id="k-todo">0</div><div class="k">待补坐标 / 时间</div></div>
</div>
{{/* 固定列宽比例:输入框跟着列宽走,表格就不会被撑宽到需要横向拖动 */}}
<div class="tw">
<table class="tb photo-table">
<colgroup>
<col style="width:4%"><col style="width:7%"><col style="width:10%"><col style="width:11%">
<col style="width:11%"><col style="width:19%"><col style="width:12%"><col style="width:12%">
<col style="width:14%">
</colgroup>
<thead>
<tr>
<th>导入</th><th>存图</th><th>文件</th><th>地点名</th><th>坐标</th><th>地址(反查)</th>
<th>到达时间</th><th>离开时间</th><th>说明</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="row" style="margin-top:12px;gap:10px">
<button class="btn primary" type="submit">写入数据库</button>
<button class="btn" type="button" id="retry-btn">重新反查未成功的</button>
<button class="btn" type="button" id="reset-btn">重新选照片</button>
</div>
<div class="req-tip" style="margin-top:8px">导入后每张照片会生成一个地点 + 一条到访(标记为「补录」),可在<a href="/admin/edit">数据编辑</a>里继续调整或删除。</div>
</div>
</form>
</div>
{{/* 单个点位的地图:总图留着看分布,这一个用来看「这个点到底在哪」 */}}
<div id="point-modal" style="display:none">
<div class="point-modal-box">
<div class="point-modal-head">
<b id="point-modal-title">点位</b>
<button class="btn sm" type="button" id="point-modal-close">关闭</button>
</div>
<div id="point-modal-map" class="point-modal-map"></div>
<div id="point-modal-meta" class="card-sub" style="margin:8px 2px 0"></div>
</div>
</div>
<script>
(function () {
var DEFAULT_STAY_MIN = 10;
var input = document.getElementById('photo-input');
var rowsBox = document.getElementById('rows');
var previewCard = document.getElementById('preview-card');
var mapCard = document.getElementById('map-card');
var status = document.getElementById('parse-status');
var rowCount = document.getElementById('row-count');
var coordField = document.getElementById('coord-field');
if (!input || !window.rondExif) return;
function coordSel() {
var el = document.querySelector('input[name=coord-sel]:checked');
return el ? el.value : 'wgs84';
}
function pad(n) { return (n < 10 ? '0' : '') + n; }
// datetime-local 需要的本地时间串(EXIF 没有时区,按拍摄地当地时间理解)
function dtLocal(d) {
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function tInput(name, value, placeholder, width) {
var el = document.createElement('input');
el.type = 'text';
el.name = name;
el.value = value || '';
el.placeholder = placeholder || '';
// 宽度交给列宽(photo-table 是 fixed 布局,input 撑满格子即可)
el.style.width = '100%';
el.style.minWidth = '0';
return el;
}
function dtInput(name, value) {
var el = document.createElement('input');
el.type = 'datetime-local';
el.name = name;
el.value = value || '';
el.style.width = '100%';
el.style.minWidth = '0';
return el;
}
function fieldLabel(text, control, flex) {
var lab = document.createElement('label');
lab.className = 'f';
lab.style.cssText = flex + ';margin-bottom:0';
var sp = document.createElement('span');
sp.textContent = text;
lab.appendChild(sp);
lab.appendChild(control);
return lab;
}
// 更多里的分组小标题:一堆输入框挤在一起像锅粥,每组给一句说明
function groupCap(text) {
var d = document.createElement('div');
d.className = 'card-sub';
d.style.cssText = 'margin:0 0 4px';
d.textContent = text;
return d;
}
// 鼠标划到某一行时把图上那个点挑出来(index 为 null 表示复原)。
// 记一下当前高亮的是谁:鼠标在「主行 ↔ 更多块」之间移动会连发几次事件,
// 行号没变就不必让地图重设一遍样式(会闪)。
var hoveredRow = null;
function highlightRow(i) {
if (hoveredRow === i) return;
hoveredRow = i;
if (!window.rondMap) return;
window.rondMap.highlight('photo-map', i === null ? null : 'row-' + i);
}
// 主行和它的「更多」块是两条独立的 <tr>,各自绑 mouseleave 的话,
// 鼠标一进「更多」块就会被主行的 leave 把高亮清掉——而改字段往往就在那块里。
// 所以两行打上同一个行号,统一按「光标底下那一行是谁」来算。
rowsBox.addEventListener('mouseover', function (e) {
var tr = e.target && e.target.closest ? e.target.closest('tr') : null;
highlightRow(tr && tr.dataset.rowIndex !== undefined ? Number(tr.dataset.rowIndex) : null);
});
rowsBox.addEventListener('mouseleave', function () { highlightRow(null); });
// 按隐藏 select 的选项克隆一个独立的下拉(每行一份,互不影响)
function makeSelect(name, srcId) {
var src = document.getElementById(srcId);
var sel = document.createElement('select');
sel.name = name;
if (src) {
[].forEach.call(src.options, function (o) {
var opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.textContent;
sel.appendChild(opt);
});
}
return sel;
}
// 活动下拉里的「+ 新增活动…」:选中后就地展开一个新建框,建完把新选项挂到**所有**
// 活动下拉上(含隐藏的选项源,之后才解析出来的行也才有它),并在当前这行选中。
// 名字已经存在就直接选中,不再新建——/admin/edit/activity 不做去重,同名会真的建两行。
function addActivityOption(id, name) {
var src = document.getElementById('activity-options');
var sels = [].slice.call(document.querySelectorAll('select[name^="activity_"]'));
if (src) sels.unshift(src);
sels.forEach(function (s) {
var opt = document.createElement('option');
opt.value = id;
opt.textContent = name;
// 插在「+ 新增活动…」前面:不能让新建项落到那个特殊项之后,否则下次它又跑到末尾
var tail = s.querySelector('option[value="__new__"]');
if (tail) s.insertBefore(opt, tail); else s.appendChild(opt);
});
}
function createActivity(sel, input, wrap) {
var name = input.value.trim();
if (!name) { input.focus(); return; }
var src = document.getElementById('activity-options');
var exist = '';
if (src) {
[].forEach.call(src.options, function (o) {
if (o.value !== '__new__' && o.textContent === name) exist = o.value;
});
}
if (exist) {
sel.value = exist;
input.value = '';
wrap.style.display = 'none';
return;
}
fetch('/admin/edit/activity', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=' + encodeURIComponent(name)
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) { alert(d.error); return; }
addActivityOption(d.id, d.name);
sel.value = String(d.id);
input.value = '';
wrap.style.display = 'none';
}).catch(function (e) { alert('创建活动失败:' + e); });
}
// 自定义类别填好之后,把它挂到**所有**类别下拉上(含隐藏的选项源),当前这行改成直接选中它。
// 一次导入几十张同类型的照片时,第 2 张起就能从下拉里直接选到,不必再输入一遍同样的话 ——
// 类别是自由文本(存的就是 poi_category 原串),所以选项的 value 就是那串文字本身。
function categoryExists(sel, label) {
return [].some.call(sel.options, function (o) { return o.value === label; });
}
function addCategoryOption(label) {
var src = document.getElementById('category-options');
var sels = [].slice.call(document.querySelectorAll('select[name^="category_"]'));
if (src) sels.unshift(src);
sels.forEach(function (s) {
if (categoryExists(s, label)) return;
var opt = document.createElement('option');
opt.value = label;
opt.textContent = label;
// 插在「自定义…」前面,别让新类别落到那个特殊项之后
var tail = s.querySelector('option[value="__custom__"]');
if (tail) s.insertBefore(opt, tail); else s.appendChild(opt);
});
}
function promoteCategory(sel, input, wrap) {
if (sel.value !== '__custom__') return;
var label = (input.value || '').trim();
if (!label) return;
addCategoryOption(label);
sel.value = label;
wrap.style.display = 'none';
}
function setVal(name, v) {
var el = rowsBox.querySelector('input[name="' + name + '"], select[name="' + name + '"]');
if (el && v) el.value = v;
}
function valOf(idx, field) {
var el = rowsBox.querySelector('[name="' + field + '_' + idx + '"]');
return el ? el.value : '';
}
// 一行 = 主行(常用字段)+ 默认收起的「更多」行(字段与新增地点一致)
function addRow(index, file, info, errMsg) {
var tr = document.createElement('tr');
var td = document.createElement('td');
var keep = document.createElement('input');
keep.type = 'checkbox';
keep.name = 'keep_' + index;
keep.checked = !!(info && info.hasGPS); // 没读到坐标的默认不导入
td.appendChild(keep);
tr.appendChild(td);
// 存图:勾上就把这张照片在浏览器里转成 WebP 一起提交,作为该地点的配图。
// 默认不勾——一次导入几十张全存会占不少空间,也可能把相册里的截图一起带进去。
td = document.createElement('td');
var keepImg = document.createElement('input');
keepImg.type = 'checkbox';
keepImg.name = 'keepimg_' + index;
keepImg.title = '把这张照片在浏览器里转成 WebP(长边 1440px、约 100~250KB)存为该地点的图片,地点页可查看、可删';
td.appendChild(keepImg);
tr.appendChild(td);
td = document.createElement('td');
td.className = 'muted';
td.style.cssText = 'max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
td.textContent = file.name;
td.title = file.name;
tr.appendChild(td);
td = document.createElement('td');
var initialName = file.name.replace(/\.[^.]+$/, '');
var nameInput = tInput('name_' + index, initialName, '地点名', '120px');
nameInput.dataset.initial = initialName;
td.appendChild(nameInput);
tr.appendChild(td);
td = document.createElement('td');
var latEl = tInput('lat_' + index, info && info.hasGPS ? info.lat.toFixed(6) : '', '纬度', '98px');
var lonEl = tInput('lon_' + index, info && info.hasGPS ? info.lon.toFixed(6) : '', '经度', '98px');
latEl.style.marginBottom = '4px';
td.appendChild(latEl);
td.appendChild(lonEl);
tr.appendChild(td);
td = document.createElement('td');
td.className = 'muted';
td.style.cssText = 'max-width:260px';
var addrEl = document.createElement('div');
addrEl.textContent = '—';
td.appendChild(addrEl);
tr.appendChild(td);
var arrival = '', departure = '';
if (info && info.hasTime && info.shot) {
arrival = dtLocal(info.shot);
departure = dtLocal(new Date(info.shot.getTime() + DEFAULT_STAY_MIN * 60000));
}
td = document.createElement('td');
td.appendChild(dtInput('arrival_' + index, arrival));
tr.appendChild(td);
td = document.createElement('td');
td.appendChild(dtInput('departure_' + index, departure));
tr.appendChild(td);
var notes = [];
if (errMsg) notes.push(errMsg);
if (info && info.hasGPS && !info.hasTime) notes.push('没有拍摄时间,请手工填写');
if (info && !info.hasGPS && info.hasTime) notes.push('没有 GPS 坐标,请手工填写');
td = document.createElement('td');
td.className = 'muted';
td.style.maxWidth = '220px';
var noteEl = document.createElement('div');
noteEl.textContent = notes.join(';');
td.appendChild(noteEl);
// 操作链接单独一行且整体不折行——中文默认能在任意字间断开,
// 「地图」会被拆成「地」「图」两行
var acts = document.createElement('div');
acts.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;margin-top:6px';
var mapLink = document.createElement('a');
mapLink.href = 'javascript:void(0)';
mapLink.textContent = '地图';
mapLink.className = 'btn sm';
mapLink.addEventListener('click', function () {
var lat = parseFloat(valOf(index, 'lat')), lon = parseFloat(valOf(index, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) { window.alert('这一行还没有坐标'); return; }
openPointMap(lat, lon, valOf(index, 'name'), addrEl.textContent);
});
acts.appendChild(mapLink);
var moreLink = document.createElement('a');
moreLink.href = 'javascript:void(0)';
moreLink.textContent = '更多';
moreLink.className = 'btn sm';
acts.appendChild(moreLink);
td.appendChild(acts);
tr.appendChild(td);
rowsBox.appendChild(tr);
// ---- 更多:地点属性(与新增地点一致)----
var tr2 = document.createElement('tr');
tr2.style.display = 'none';
var td2 = document.createElement('td');
// 主行是 9 列(colSpan 少一列的话,这块灰底就比上面窄一格,看着很突兀)
td2.colSpan = 9;
td2.style.cssText = 'background:var(--bg-2);padding:12px 14px'
var row1 = document.createElement('div');
row1.className = 'row';
var catSel = makeSelect('category_' + index, 'category-options');
var catCustom = tInput('category_custom_' + index, '', '如 公园', '140px');
var catCustomWrap = fieldLabel('自定义类别', catCustom, 'flex:0 1 150px');
catCustomWrap.style.display = 'none';
catSel.addEventListener('change', function () {
catCustomWrap.style.display = catSel.value === '__custom__' ? '' : 'none';
});
// 填完自定义类别(失焦或回车)就把它变成所有行都能直接选的一项
catCustom.addEventListener('change', function () {
promoteCategory(catSel, catCustom, catCustomWrap);
});
catCustom.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
e.preventDefault();
promoteCategory(catSel, catCustom, catCustomWrap);
});
td2.appendChild(groupCap('地点属性'));
row1.appendChild(fieldLabel('类别', catSel, 'flex:0 1 160px'));
row1.appendChild(catCustomWrap);
[
['country_' + index, '国家代码', 'CN', '90px'],
['province_' + index, '省份', '', '100px'],
['city_' + index, '城市', '', '100px'],
['district_' + index, '区县', '', '100px'],
['street_' + index, '街道', '', '140px'],
['timezone_' + index, '时区', 'Asia/Shanghai', '130px']
].forEach(function (spec) {
row1.appendChild(fieldLabel(spec[1], tInput(spec[0], spec[2], '', spec[3]), 'flex:0 1 ' + spec[3]));
});
td2.appendChild(row1);
// ---- 更多:到访属性(与新增到访一致)----
var row2 = document.createElement('div');
row2.className = 'row';
var cap2 = groupCap('到访属性');
cap2.style.marginTop = '14px';
td2.appendChild(cap2);
var actSel = makeSelect('activity_' + index, 'activity-options');
// 新建活动的输入框不给 name:它不是要随表单提交的字段,选中的活动由 activity_<i> 带过去
var actNewInput = tInput('', '', '新活动名称', '');
var actNewBtn = document.createElement('button');
actNewBtn.type = 'button';
actNewBtn.className = 'btn';
actNewBtn.textContent = '添加';
var actNewBox = document.createElement('div');
actNewBox.className = 'input-btn';
actNewBox.appendChild(actNewInput);
actNewBox.appendChild(actNewBtn);
var actNewWrap = fieldLabel('新建活动', actNewBox, 'flex:0 1 190px');
actNewWrap.classList.add('act-new');
actNewWrap.style.display = 'none';
actSel.addEventListener('change', function () {
var on = actSel.value === '__new__';
actNewWrap.style.display = on ? '' : 'none';
if (on) actNewInput.focus();
});
actNewBtn.addEventListener('click', function () { createActivity(actSel, actNewInput, actNewWrap); });
actNewInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); createActivity(actSel, actNewInput, actNewWrap); }
});
row2.appendChild(fieldLabel('活动', actSel, 'flex:0 1 170px'));
row2.appendChild(actNewWrap);
// 备注不设 flex-grow:让它自己那么宽就行,否则会被拉伸到最右,把「标记为收藏」挤出视野
row2.appendChild(fieldLabel('备注', tInput('remark_' + index, '', '可选', '200px'), 'flex:0 1 260px'));
var favWrap = document.createElement('label');
favWrap.className = 'check';
favWrap.style.cssText = 'margin:0;flex:0 0 auto;align-self:flex-end;padding-bottom:9px';
var fav = document.createElement('input');
fav.type = 'checkbox';
fav.name = 'bookmarked_' + index;
favWrap.appendChild(fav);
favWrap.appendChild(document.createTextNode(' 标记为收藏'));
row2.appendChild(favWrap);
td2.appendChild(row2);
// ---- 更多:标签(多选 chips)----
var tagSrc = document.getElementById('tag-options');
if (tagSrc && tagSrc.options.length) {
var cap3 = groupCap('标签');
cap3.style.marginTop = '14px';
td2.appendChild(cap3);
var row3 = document.createElement('div');
row3.className = 'chips';
[].forEach.call(tagSrc.options, function (o) {
var chip = document.createElement('label');
chip.className = 'chip';
chip.style.cursor = 'pointer';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.name = 'tags_' + index;
cb.value = o.value;
cb.style.marginRight = '5px';
chip.appendChild(cb);
chip.appendChild(document.createTextNode(o.textContent));
row3.appendChild(chip);
});
td2.appendChild(row3);
}
tr2.appendChild(td2);
rowsBox.appendChild(tr2);
moreLink.addEventListener('click', function () {
var show = tr2.style.display === 'none';
tr2.style.display = show ? '' : 'none';
moreLink.textContent = show ? '收起' : '更多';
});
// 行号打在两行 <tr> 上(主行 + 「更多」行),rowsBox 的 mouseover 靠它找回来
tr.dataset.rowIndex = index;
tr2.dataset.rowIndex = index;
return { addrEl: addrEl, file: file, keepImg: keepImg };
}
function initPickers() {
try {
if (!window.flatpickr) return;
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
rowsBox.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
window.rondDatetime.attachPicker(el);
});
} catch (e) { console.error('flatpickr 初始化失败', e); }
}
// 反查地址:坐标交给服务端(坐标系换算也在那边做),拿回地址与换算后的坐标
function lookup(coords) {
return fetch('/api/regeo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coord: coordSel(), points: coords })
}).then(function (r) {
// 500 时回的是 HTML,直接 r.json() 会抛 SyntaxError(英文),先判状态码
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function (d) {
if (d.error) throw new Error(d.error);
return d.items || [];
});
}
// 世代号:连续选两次文件时,旧那一轮的解析结果一律丢弃,避免表格出现重复行
var gen = 0;
// rowRefs 提到外层:applyOne 与「重新反查」按钮都要按行号找回那一行的地址元素
var rowRefs = {};
input.addEventListener('change', function () {
var files = [].slice.call(input.files || []);
if (!files.length) return;
var my = ++gen;
rowsBox.innerHTML = '';
rowCount.value = '0';
previewCard.style.display = '';
mapCard.style.display = 'none';
status.textContent = '正在读取 ' + files.length + ' 张照片的 EXIF…';
var pickCount = document.getElementById('pick-count');
if (pickCount) pickCount.textContent = '已选 ' + files.length + ' 张';
rowRefs = {};
var done = 0, withGPS = 0;
var chain = Promise.resolve();
files.forEach(function (file, i) {
chain = chain.then(function () {
if (my !== gen) return;
return window.rondExif.read(file).then(function (info) {
if (my !== gen) return;
rowRefs[i] = addRow(i, file, info, null);
if (info.hasGPS) withGPS++;
}).catch(function (err) {
if (my !== gen) return;
rowRefs[i] = addRow(i, file, null, (err && err.message) || '读取失败');
}).then(function () {
if (my !== gen) return;
done++;
status.textContent = '正在读取 ' + done + '/' + files.length + '…';
initPickers();
});
});
});
chain.then(function () {
if (my !== gen) return;
rowCount.value = String(files.length);
// EXIF 一读完就把数字亮出来(此时还没反查,地址数为 0);后面每条路径都会再刷
updateKpis();
if (!withGPS) {
status.textContent = '已读取 ' + files.length + ' 张,但都没有可用的 GPS 坐标。';
return;
}
status.textContent = '已读取 ' + files.length + ' 张(' + withGPS + ' 张含坐标),正在反查地址…';
// 收集坐标:key 用行号,服务端去重后仍能配回每一行
var coords = [], owner = [];
files.forEach(function (file, i) {
var lat = parseFloat(valOf(i, 'lat')), lon = parseFloat(valOf(i, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) return;
coords.push({ lat: lat, lon: lon, key: String(i) });
owner.push(i);
});
if (!coords.length) {
status.textContent = '已读取 ' + files.length + ' 张,没有可反查的坐标。';
return;
}
lookup(coords).then(function (items) {
if (my !== gen) return;
var byKey = {};
items.forEach(function (it) { byKey[it.key] = it; });
var hit = 0;
owner.forEach(function (i) {
var it = byKey[String(i)];
if (!it || !it.address) return;
hit++;
applyOne(i, it);
});
finishLookup(files.length, withGPS, hit, owner.length);
}).catch(function (err) {
if (my !== gen) return;
status.textContent = '已读取 ' + files.length + ' 张(' + withGPS + ' 张含坐标),但地址反查失败:' +
(err.message || err) + '。可以点「重新反查未成功的」再试,或只看坐标判断。';
drawMap();
});
});
});
// applyOne 把一条反查结果写回它的行:地址、店名、坐标、省市区街道、地点名
function applyOne(i, it) {
if (rowRefs[i]) {
// 有店名时显示成「店名 · 完整地址」,比干巴巴的街道好认
var text = it.address || '';
if (it.poi) {
text = it.poi + (it.poiDist ? '(' + Math.round(it.poiDist) + 'm)' : '') + ' · ' + text;
}
rowRefs[i].addrEl.textContent = text;
rowRefs[i].addrEl.title = text;
}
// 服务端返回的是换算后的 GCJ-02 坐标,写回表格保证导入的就是它
if (it.lat && it.lon) {
var latEl = rowsBox.querySelector('input[name=lat_' + i + ']');
var lonEl = rowsBox.querySelector('input[name=lon_' + i + ']');
if (latEl) latEl.value = it.lat.toFixed(6);
if (lonEl) lonEl.value = it.lon.toFixed(6);
}
setVal('province_' + i, it.province);
setVal('city_' + i, it.city);
setVal('district_' + i, it.district);
setVal('street_' + i, it.street);
if (it.country) setVal('country_' + i, it.country);
var nameEl = rowsBox.querySelector('input[name=name_' + i + ']');
if (nameEl && nameEl.value === nameEl.dataset.initial) {
// 名称还停在文件名(用户没改过)时优先用店名,其次「区县 + 街道/乡镇」
var better = it.poi || [it.district, it.street || it.township].filter(Boolean).join('');
if (better) nameEl.value = better;
}
}
// 四个数字比一行长句子好扫。待补=没有坐标或没有到达时间的行(这两项不补就导不进去)
function kpi(id, v) { var el = document.getElementById(id); if (el) el.textContent = String(v); }
// 全部从 DOM 现算,不靠调用方传参:谁改了表格谁就调一次 updateKpis() 即可。
// 之前是「哪些路径记得传参才更新」,漏了「全都没 GPS」「没有可反查的坐标」「重新反查」
// 这几条,数字会停在上一批的值上——比显示 0 更容易误导。
function updateKpis() {
var total = 0, gps = 0, addr = 0, todo = 0;
Object.keys(rowRefs).forEach(function (k) {
var i = Number(k);
total++;
var lat = parseFloat(valOf(i, 'lat')), lon = parseFloat(valOf(i, 'lon'));
var hasPoint = !isNaN(lat) && !isNaN(lon) && !!(lat || lon);
if (hasPoint) gps++;
// 地址列还停在占位符 = 这一行没反查成功(与「重新反查」挑行的判据同源)
var ref = rowRefs[i];
if (ref && ref.addrEl && ref.addrEl.textContent !== '—') addr++;
if (!hasPoint || !valOf(i, 'arrival')) todo++;
});
kpi('k-total', total); kpi('k-gps', gps); kpi('k-addr', addr); kpi('k-todo', todo);
}
// 手工改过坐标/时间之后数字要跟着变,不然一直挂在旧值上
rowsBox.addEventListener('change', updateKpis);
function finishLookup(total, withGPS, hit, asked) {
status.textContent = '已读取 ' + total + ' 张(' + withGPS + ' 张含坐标),' +
hit + '/' + asked + ' 个点位反查成功' +
(hit < asked ? ',可点「重新反查未成功的」再试' : '') + '。确认无误后点下面的按钮写入数据库。';
drawMap();
}
// ---- 单个点位的地图弹窗:只建一个 Leaflet 实例,之后靠 setPoints/setView 复用 ----
var modal = document.getElementById('point-modal');
function openPointMap(lat, lon, name, addr) {
document.getElementById('point-modal-title').textContent = name || '点位';
document.getElementById('point-modal-meta').textContent =
(addr && addr !== '—' ? addr + ' · ' : '') + lat.toFixed(6) + ', ' + lon.toFixed(6);
modal.style.display = 'flex';
var pts = [{ lat: lat, lon: lon, n: name || '', v: 1 }];
if (!window.rondMap) { document.getElementById('point-modal-meta').textContent += '(地图脚本未就绪)'; return; }
// 统一交给 preview:它自己区分「首次建图」与「复用换点」,配置还没回来时也会把点排队补画。
// 原来这里同步置 modalInited=true,第二次打开时地图实例其实还没建好(_state 里没有条目),
// 于是走 else 分支拿不到 st,该画的点被静默丢掉。
window.rondMap.preview({ id: 'point-modal-map', points: pts });
// 单个点 fitBounds 会顶到最大缩放,等底图就绪后固定到街区级
var pin = function () {
var st = window.rondMap._state['point-modal-map'];
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
};
setTimeout(pin, 400);
setTimeout(pin, 1200);
}
function closePointMap() { modal.style.display = 'none'; }
document.getElementById('point-modal-close').addEventListener('click', closePointMap);
modal.addEventListener('click', function (e) { if (e.target === modal) closePointMap(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePointMap(); });
function drawMap() {
// 放最前面:这是「表格改完了」的统一出口,KPI 在这里收口就不会再漏路径
updateKpis();
if (!window.rondMap) return;
var points = [];
[].forEach.call(rowsBox.querySelectorAll('input[name^=lat_]'), function (el) {
var i = el.name.replace('lat_', '');
var nameEl = rowsBox.querySelector('input[name=name_' + i + ']');
var lat = parseFloat(el.value), lon = parseFloat(valOf(i, 'lon'));
if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
points.push({ lat: lat, lon: lon, n: nameEl ? nameEl.value : '', v: 1, key: 'row-' + i });
});
if (!points.length) { mapCard.style.display = 'none'; return; }
mapCard.style.display = '';
window.rondMap.preview({ id: 'photo-map', points: points });
syncStuck();
}
// 图粘住时收矮(.stuck):不收的话 200px 的浮层会挡住从下面滚过去的半个表格。
// 容器高度变了必须告诉 Leaflet 重新量一次,否则底图会缺一块。
// 导航高度只在窗口尺寸变化时才会变,所以在滚动路径上不能每帧去解析 --nav-h
// (读自定义属性会触发一次样式计算);滚动里只留必须实时的 getBoundingClientRect。
var stuckOffset = 0;
function measureNav() {
stuckOffset = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h') || '54') + 8;
}
function syncStuck() {
var stuck = mapCard.getBoundingClientRect().top <= stuckOffset + 2;
if (stuck === mapCard.classList.contains('stuck')) return;
mapCard.classList.toggle('stuck', stuck);
var st = window.rondMap && window.rondMap._state['photo-map'];
if (st && st.map) setTimeout(function () { st.map.invalidateSize(); }, 260);
}
measureNav();
window.addEventListener('scroll', syncStuck, { passive: true });
window.addEventListener('resize', function () { measureNav(); syncStuck(); });
document.getElementById('retry-btn').addEventListener('click', function () {
var todo = [];
Object.keys(rowRefs).forEach(function (k) {
var ref = rowRefs[k];
// 地址列还是占位符说明这行没查到(或查失败)
if (!ref || !ref.addrEl || ref.addrEl.textContent !== '—') return;
var lat = parseFloat(valOf(k, 'lat')), lon = parseFloat(valOf(k, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) return;
todo.push({ lat: lat, lon: lon, key: String(k) });
});
if (!todo.length) { status.textContent = '没有需要重新反查的行。'; return; }
status.textContent = '正在重新反查 ' + todo.length + ' 个点位…';
lookup(todo).then(function (items) {
var byKey = {};
items.forEach(function (it) { byKey[it.key] = it; });
var hit = 0;
todo.forEach(function (c) {
var it = byKey[c.key];
if (!it || !it.address) return;
hit++;
applyOne(Number(c.key), it);
});
status.textContent = '重新反查完成:' + hit + '/' + todo.length + ' 个成功' +
(hit < todo.length ? '(其余的周边可能确实没有收录,或坐标不在境内)' : '') + '。';
drawMap();
}).catch(function (err) {
status.textContent = '重新反查失败:' + ((err && err.message) || err);
});
});
document.getElementById('reset-btn').addEventListener('click', function () {
gen++;
input.value = '';
rowsBox.innerHTML = '';
rowCount.value = '0';
previewCard.style.display = 'none';
mapCard.style.display = 'none';
status.textContent = '';
var pickCount = document.getElementById('pick-count');
if (pickCount) pickCount.textContent = '可多选,JPEG / HEIC 都行';
window.scrollTo(0, 0);
});
[].forEach.call(document.querySelectorAll('input[name=coord-sel]'), function (el) {
el.addEventListener('change', function () { coordField.value = coordSel(); });
});
/* ---------- 提交前把勾了「存图」的照片转成 WebP ----------
* 转码是异步的,所以先拦下这次提交,转完再 form.submit() 放行;
* form.submit() 不触发 submit 事件,不会自己套自己。
* 转好的字节塞进隐藏的 <input type=file>,随表单一起以 multipart 发出去。
*/
var form = document.getElementById('import-form');
var submitBtn = form.querySelector('button[type=submit]');
form.addEventListener('submit', function (e) {
if (form.dataset.ready === '1') return; // 放行那一趟
// 选了「+ 新增活动…」却没点「添加」的:拦下来。否则 activity_<i> 会带着 "__new__"
// 提交,服务端解析不出整数就当成「未指定」,这行活动会静默丢掉。
var unresolved = [].slice.call(rowsBox.querySelectorAll('select[name^="activity_"]'))
.filter(function (s) { return s.value === '__new__'; });
if (unresolved.length) {
e.preventDefault();
alert('有 ' + unresolved.length + ' 行的活动选了「+ 新增活动…」但还没点「添加」:'
+ '填个名字再点添加,或者把它改回「未指定」。');
unresolved[0].focus();
return;
}
var picked = [];
rowsBox.querySelectorAll('input[name^=keepimg_]').forEach(function (cb) {
if (!cb.checked) return;
var i = Number(cb.name.slice('keepimg_'.length));
var ref = rowRefs[i];
if (ref && ref.file) picked.push({ i: i, file: ref.file, cb: cb });
});
if (!picked.length) { form.dataset.ready = '1'; return; }
// 服务端对整包有 60MB 上限:按转码后的体积(约 100~250KB)估,这里保守按 200 张提醒一次
if (picked.length > 200 && !window.confirm('这次勾了 ' + picked.length + ' 张要存为图片,' +
'总量可能超过服务端 60MB 的上限导致整包提交失败。建议分两次导入,是否继续?')) {
if (submitBtn) submitBtn.disabled = false;
return;
}
e.preventDefault();
if (submitBtn) submitBtn.disabled = true;
var at = 0, failed = 0;
function next() {
if (at >= picked.length) {
if (failed) {
// 有转码失败的:提示后仍然继续导入地点,只是那几张不带图
status.textContent = '已跳过 ' + failed + ' 张转码失败的图片,正在写入数据库…';
} else {
status.textContent = '图片已就绪,正在写入数据库…';
}
form.dataset.ready = '1';
form.submit();
return;
}
var it = picked[at++];
status.textContent = '正在转码图片 ' + at + '/' + picked.length + '…';
window.rondImage.toWebP(it.file).then(function (out) {
var holder = document.createElement('input');
holder.type = 'file';
holder.name = 'image_' + it.i;
holder.style.display = 'none';
var dt = new DataTransfer();
dt.items.add(new File([out.blob], 'image.webp', { type: 'image/webp' }));
holder.files = dt.files;
form.appendChild(holder);
// 宽高随文件一起发:服务端不解码图片,只记账
[['image_w_', out.width], ['image_h_', out.height]].forEach(function (pair) {
var h = document.createElement('input');
h.type = 'hidden';
h.name = pair[0] + it.i;
h.value = pair[1];
form.appendChild(h);
});
}).catch(function (err) {
failed++;
it.cb.checked = false;
status.textContent = '第 ' + (it.i + 1) + ' 行的图片转码失败(已跳过):' +
((err && err.message) || err);
}).then(next);
}
next();
});
})();
</script>
{{end}}