Files
rond-with-you/internal/web/templates/admin/photos.html
T

866 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:12.5px">照片里的坐标是</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:12.5px">可多选,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) {
if (el._flatpickr) return;
flatpickr(el, { enableTime: true, time_24hr: true, allowInput: true, minuteIncrement: 5, dateFormat: 'Y-m-dTH:i' });
});
} 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) { 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');
var modalInited = false;
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 (!modalInited) {
if (!window.rondMap) { document.getElementById('point-modal-meta').textContent += '(地图脚本未就绪)'; return; }
window.rondMap.preview({ id: 'point-modal-map', points: pts });
modalInited = true;
// 单个点 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);
} else {
var st = window.rondMap._state['point-modal-map'];
if (st && st.setPoints) st.setPoints(pts);
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
}
}
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}}