Files
rond-with-you/internal/web/templates/admin/edit.html
T
tamakyi 8dec973db1 修「补录行被合并回来」的重复:对账时认它的派生 UUID
成因链:站点补录的到访没有留档行(entity_raw 里没有它),也就没有 UUID;导出时会被补上
一个确定性 UUID v5("ZVISIT/<src_pk>") 写进包里,手机于是把它当成一条真记录;这份包再
合并回站点时,站点这侧没有 UUID 可对齐,DiffVisits 直接判成「备份新增」又插一条 ——
时间线上出现两条一模一样的到访(实测数据集 9 有 43 对,生产时间线一屏就有 53 对)。

- rond:把派生 UUID 抽成 DerivedUUID / DerivedVisitUUID / VisitMatchUUID(导出侧 coreDataUUID
  复用同一个函数),差异与合并都拿它当对账键(diff.go 的 byUUID、backup_merge 的 storedByUUID)。
- 「时间 + 地点」兜底配对补 used 检查:同一条站点行现在两个索引里都有,不查会被 classify 两次。
- 数据编辑页的「重复到访」卡片认得这类(成因标「你补录的那条被合并回来的一份」),
  清理时把副本名下的位移 / 天气**改挂**到保留项——补录行自己名下没有位移,跟着删就白丢里程;
  派生 UUID 判定排在「站点侧副本」之前(证据更强)。

用例:rond 两条(正例 + 派生 UUID 不相配的反例)、web 一条端到端(造补录行与副本 → 检出 →
清理 → 断言位移与天气改挂、留档被清)。变异验证:去掉改挂语义、关掉该判定,两条都立刻红。
2026-09-30 15:02:45 +08:00

1481 lines
83 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"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>数据编辑</h1>
<div class="sub">手动补录没带手机时的记录点(新增地点 / 到访 / 行程 / 天气),改完在下方一键导出为 <span class="mono">.rondbackup</span></div>
</div>
<div class="row">
{{/* 从筛选过的列表点「编辑」进来的:给一个回得去的出口(筛选条件还在那个地址里) */}}
{{if .Back}}<a class="btn" href="{{.Back}}">← {{.BackLabel}}</a>{{end}}
<button class="btn" type="button" id="fold-all">全部折叠</button>
<a class="btn" href="/admin">返回后台</a>
<a class="btn primary" href="/admin/export/rondbackup">导出 rondbackup</a>
</div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
{{if not .HasData}}
{{template "emptystate" .}}
{{else}}
{{if not .CanExport}}<div class="alert err">导出所需要的基线库暂不可用(当初上传的原始 <span class="mono">.rondbackup</span> 可能已被清理)。请到<a href="/admin">后台</a>重新上传一次该备份文件,即可恢复导出。</div>{{end}}
{{/* 活动与类别是「分类字典」,不随下面的分页列表变,所以放最上面。
类别没有独立的表,值就长在 places.poi_category 上——所以「改名 / 清空」都是按值批量作用到地点。 */}}
<div class="card" data-card="taxonomy" id="taxonomy">
<h2>活动与类别</h2>
<div class="card-sub">
活动是单独的一张表(共 {{num (len .ActivityRows)}} 个),类别只是地点上的一个字段({{num (len .CategoryRows)}} 种在用)。
<b>改名时把值填成另一个已有的,就是合并</b>——整批并过去,不用先删再建。
</div>
<h3>活动</h3>
<div class="tw">
<table class="tb tb-stack">
<thead><tr><th>名称</th><th>引用</th><th>原生属性</th><th></th></tr></thead>
<tbody id="act-rows">
{{range .ActivityRows}}
<tr data-id="{{.ID}}" data-name="{{.Name}}">
<td data-label="名称"><input type="text" name="act-name" value="{{.Name}}" style="min-width:9em"></td>
<td data-label="引用">{{num .Visits}}</td>
<td data-label="原生属性">{{if .IsHome}}家{{end}}{{if and .IsHome .IsWork}} · {{end}}{{if .IsWork}}工作{{end}}</td>
<td class="ops">
<button class="btn sm" type="button" data-act="rename">改名</button>
<button class="btn sm" type="button" data-act="delete">删除</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="row" style="margin-top:10px;gap:8px;flex-wrap:wrap;align-items:center">
<span class="muted">合并:把</span>
<select id="merge-from" aria-label="合并来源">{{range .ActivityRows}}<option value="{{.ID}}">{{.Name}}({{num .Visits}})</option>{{end}}</select>
<span class="muted">并到</span>
<select id="merge-to" aria-label="合并目标">{{range .ActivityRows}}<option value="{{.ID}}">{{.Name}}({{num .Visits}})</option>{{end}}</select>
<button class="btn sm" type="button" id="merge-go">合并</button>
<span class="muted">|</span>
<input type="text" id="act-new" placeholder="新活动名称" style="min-width:8em">
<button class="btn sm" type="button" id="act-new-go">新增活动</button>
</div>
<h3>类别</h3>
<div class="tw">
<table class="tb tb-stack">
<thead><tr><th>类别</th><th>地点</th><th></th></tr></thead>
<tbody id="cat-rows">
{{range .CategoryRows}}
<tr data-value="{{.Value}}">
<td data-label="类别">
<label class="f" style="margin-bottom:6px"><span>导出值</span>
<input list="cat-values" type="text" name="cat-value" value="{{.Value}}" style="min-width:12em">
</label>
<label class="f" style="margin-bottom:6px"><span>显示名</span>
<input type="text" name="cat-label" value="{{if .Custom}}{{.Label}}{{end}}"
placeholder="{{if .Missing}}填一个中文名{{else}}留空=默认「{{.Label}}」{{end}}"
style="min-width:12em">
</label>
<button class="btn sm" type="button" data-act="label">保存显示名</button>
{{if .Missing}}<span class="muted">还没有中文名</span>
{{else if .Custom}}<span class="muted">已自定义</span>{{end}}
</td>
<td data-label="地点">{{num .Places}}</td>
<td class="ops">
<button class="btn sm" type="button" data-act="rename">改名</button>
<button class="btn sm" type="button" data-act="clear">清空</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<datalist id="cat-values">{{range .CategoryRows}}<option value="{{.Value}}"></option>{{end}}</datalist>
<div class="card-sub" style="margin-top:8px">
把值改成另一个已有类别就是合并;「清空」= 那些地点退回「未指定」。
要加一个全新的类别,在地点行内编辑里选「自定义…」填就行——类别的候选本来就来自地点数据本身,
凭空预置一个没人用的值不会出现在任何下拉里。
</div>
</div>
<div class="card" data-card="place-new">
<h2>新增地点</h2>
<div class="card-sub">按名称搜索可直接填入坐标;也可在地图上点击选点,坐标为 GCJ-02(与 rond 记录同系)。</div>
{{if .GeocodeReady}}
<div class="search-row">
<label class="f"><span>按名称搜索坐标</span>
<input type="text" id="geo-q" placeholder="如:杭州西湖 / 上海外滩 / 某某小区" autocomplete="off">
</label>
<button class="btn" type="button" id="geo-go">搜索</button>
</div>
<div id="geo-msg" class="card-sub" style="margin:0 0 6px"></div>
<div id="geo-list" class="geo-list"></div>
{{else}}
<div class="card-sub" style="margin:0 0 10px">按名称搜坐标未启用:在 <span class="mono">conf/app.ini</span> 填 <span class="mono">geocode_key</span>(高德 Web 服务 Key,免费)后重启即可。</div>
{{end}}
<form method="post" action="/admin/edit/place">
<div class="row" style="align-items:center;margin-bottom:10px">
<label class="check" style="margin:0"><input type="radio" name="mode" value="new" checked> 新建地点</label>
<label class="check" style="margin:0"><input type="radio" name="mode" value="existing"> 给已有地点补到访</label>
</div>
<div id="mode-new">
<div class="req-tip"><b class="req">*</b>名称、坐标必填;坐标可搜索或地图点选自动填入</div>
<div class="row">
<label class="f" style="flex:2 1 200px"><span>名称<b class="req">*</b></span><input type="text" name="name" placeholder="如:某某公园"></label>
<label class="f" style="flex:1 1 130px"><span>类别</span>
<select name="category" class="cat-select">
<option value="">未指定</option>
{{range .Categories}}<option value="{{.Label}}">{{.Name}}</option>{{end}}
<option value="__custom__">自定义…</option>
</select>
</label>
<label class="f cat-custom" style="flex:1 1 130px;display:none"><span>自定义类别</span><input type="text" name="category_custom" placeholder="如 公园"></label>
<label class="f" style="flex:1 1 130px"><span>纬度<b class="req">*</b></span><input type="text" name="lat" placeholder="30.123456"></label>
<label class="f" style="flex:1 1 130px"><span>经度<b class="req">*</b></span><input type="text" name="lon" placeholder="120.123456"></label>
</div>
<div class="row">
<label class="f" style="flex:1 1 120px"><span>国家代码</span><input type="text" name="country" placeholder="CN"></label>
<label class="f" style="flex:1 1 120px"><span>省份</span><input type="text" name="province"></label>
<label class="f" style="flex:1 1 120px"><span>城市</span><input type="text" name="city"></label>
<label class="f" style="flex:1 1 120px"><span>区县</span><input type="text" name="district"></label>
<label class="f" style="flex:1 1 140px"><span>街道</span><input type="text" name="thoroughfare"></label>
<label class="f" style="flex:1 1 120px"><span>时区</span><input type="text" name="timezone" placeholder="Asia/Shanghai"></label>
</div>
<div id="picker-map" style="height:260px;border-radius:10px;border:1px solid var(--line);margin:4px 0 12px"></div>
</div>
<div id="mode-existing" style="display:none">
<div class="req-tip"><b class="req">*</b>搜索并选中地点后,请在下方到访时段里填写到达时间</div>
<label class="f" style="max-width:520px"><span>地点<b class="req">*</b></span>
<div class="picker" data-source="place-options">
<input type="text" class="picker-input" placeholder="输入名称 / 城市 / #编号 过滤" autocomplete="off">
<input type="hidden" name="existing_place">
<div class="picker-picked" style="display:none;margin:4px 0 0">
已选:<b class="picker-picked-label"></b>
<a href="javascript:void(0)" class="picker-clear" style="margin-left:6px">重选</a>
</div>
<div class="picker-results geo-list" style="display:none;max-height:240px"></div>
</div>
</label>
<select id="place-options" hidden>
{{range .PlaceOptions}}<option value="{{.ID}}">{{.Label}}</option>{{end}}
</select>
<select id="visit-options" hidden>
{{range .VisitOptions}}<option value="{{.SrcPK}}">{{.Label}}</option>{{end}}
</select>
</div>
<div class="card-sub" style="margin:0 0 8px">到访时段<b>选填</b>:留空则只记录地点;填了就追加一条独立的到访(如某次忘带手机的那段),不影响该地点已有记录。离开时间留空表示只记了到达;时间凭记忆填个大概即可(5 分钟步进)。</div>
<div class="row">
<label class="f" style="flex:1 1 170px"><span>到达时间</span><input type="datetime-local" name="arrival"></label>
<label class="f" style="flex:1 1 170px"><span>离开时间</span><input type="datetime-local" name="departure"></label>
<label class="f" style="flex:1 1 170px">
<span class="span-actions">活动
<span class="mini-actions">
<a href="javascript:void(0)" id="activity-rename">改名</a>
<a href="javascript:void(0)" id="activity-delete" class="danger">删除</a>
</span>
</span>
{{/* 与照片导入页、到访行内编辑同一套:下拉里带「+ 新增活动…」,选中后就地展开新建框 */}}
<select name="activity_id" id="activity-select" class="activity-id-inline">
<option value="">未指定</option>
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
<option value="__new__">+ 新增活动…</option>
</select>
</label>
<label class="f visit-act-new" style="flex:1 1 170px;display:none"><span>新建活动</span>
<div class="input-btn">
<input type="text" class="visit-act-name" placeholder="名称">
<button type="button" class="btn visit-act-add">添加</button>
</div>
</label>
<label class="f" style="flex:1 1 140px"><span>备注</span><input type="text" name="remark" placeholder="可选"></label>
</div>
<label class="check"><input type="checkbox" name="bookmarked"> 标记为收藏</label>
{{if .Tags}}
<div class="chips" style="margin:6px 0 10px">
{{range .Tags}}<label class="chip" style="cursor:pointer"><input type="checkbox" name="tags" value="{{.ID}}" style="margin-right:5px">{{.Name}}</label>{{end}}
</div>
{{end}}
<button class="btn primary" type="submit">提交</button>
</form>
</div>
<div class="card" data-card="places">
<h2>地点列表</h2>
<form method="get" action="/admin/edit" class="filter-row" style="margin-top:6px">
{{/* back 要跟着走:不然一筛选,「返回地点库」就没了 */}}
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
{{/* 三个表单各自只带自己那几个条件,其余条件必须原样带上:
否则搜一下就把别的列表的筛选悄悄清掉了。 */}}
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
{{if .MovementFilter.Transport}}<input type="hidden" name="mt" value="{{.MovementFilter.Transport}}">{{end}}
{{if .MovementFilter.PlaceID}}<input type="hidden" name="mp" value="{{.MovementFilter.PlaceID}}">{{end}}
{{if .MovementFilter.From}}<input type="hidden" name="mfrom" value="{{.MovementFilter.From}}">{{end}}
{{if .MovementFilter.To}}<input type="hidden" name="mto" value="{{.MovementFilter.To}}">{{end}}
<label class="f" style="flex:1 1 200px"><span>搜索</span><input type="text" name="q" value="{{.Query}}" placeholder="名称 / 城市 / 区县"></label>
<label class="f" style="flex:0 1 170px"><span>坐标来源</span>
<select name="src">
<option value="">全部</option>
{{range .PlaceSources}}<option value="{{.Value}}"{{if eq $.SourceFilter .Value}} selected{{end}}>{{.Label}}{{if $.SourceCount .Value}}({{$.SourceCount .Value}}){{end}}</option>{{end}}
<option value="none"{{if eq .SourceFilter "none"}} selected{{end}}>未留档{{if .SourceCount "none"}}({{.SourceCount "none"}}){{end}}</option>
</select>
</label>
<label class="f" style="flex:0 1 170px"><span>坐标系</span>
<select name="sus">
<option value="">全部</option>
<option value="1"{{if .SuspectOnly}} selected{{end}}>只看疑似 WGS-84{{if .SuspectCount}}({{.SuspectCount}}){{end}}</option>
</select>
</label>
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
<div class="act"><button class="btn" type="submit">筛选</button><a class="btn" href="{{.EditLink "q" "src" "sus"}}">重置</a></div>
</form>
<div class="card-sub">共 {{num .Total}} 个地点,按到访数排序{{if .SuspectCount}};其中 <b>{{num .SuspectCount}}</b> 个的显示坐标疑似就是 WGS-84(当初没做偏移,底图上会偏 500 米左右),行内已标注{{end}}。「坐标来源」是建点时留下的记录:照片导入会把当时选的坐标系一起记下来,<b>未留档</b>的是 2026-09 之前的行,只能靠原图或行内标注判断。</div>
{{if .Places}}
<div class="tw">
<table class="tb tb-stack">
<thead><tr><th>名称</th><th>城市</th><th>坐标</th><th class="num">到访</th><th></th></tr></thead>
<tbody>
{{range .Places}}
<tr id="place-row-{{.SrcPK}}"{{if and $.OpenSrcPK (eq .SrcPK $.OpenSrcPK)}} class="located"{{end}}>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}} <span class="muted mono">#{{.SrcPK}}</span></td>
<td class="muted" data-label="城市">{{.City}}{{if .District}} · {{.District}}{{end}}{{if .Thoroughfare}} · {{.Thoroughfare}}{{end}}</td>
<td class="muted mono" data-label="坐标">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}
<br><span class="tag{{if not .CoordSource}} warn{{end}}" title="{{if .CoordSource}}建点时留下的坐标来源记录{{else}}这行建于 2026-09 之前,没有坐标来源留档;坐标是否做过偏移只能靠原图 EXIF 或行内标注判断{{end}}">{{.SourceTag}}</span>
{{if .SuspectWGS}}<span class="tag warn" title="导入留档里的原始 GPS 与显示坐标完全相同,说明当初没做 GCJ-02 偏移。在 GCJ-02 底图上会整体偏 500 米左右——用行内「编辑」→ 坐标系换算修。">疑似 WGS-84</span>{{end}}
{{if .MovedM}}<br><span class="muted">比导入时移动了 {{num .MovedM}} 米</span>{{end}}</td>
<td class="num" data-label="到访">{{num .VisitCount}}</td>
<td class="ops">
<details class="inline-edit"{{if and $.OpenSrcPK (eq .SrcPK $.OpenSrcPK)}} open{{end}}>
<summary class="btn sm">编辑</summary>
<form method="post" action="/admin/edit/place/update" data-visit-count="{{.VisitCount}}" style="margin-top:8px;--efw:280px">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<label class="f"><span>名称<b class="req">*</b></span><input type="text" name="name" value="{{.Name}}" required></label>
<label class="f"><span>类别</span>
<select name="category" class="cat-select">
<option value="">未指定</option>
{{$cur := .Category}}
{{range .CategoryOptions}}<option value="{{.Label}}"{{if eq .Label $cur}} selected{{end}}>{{.Name}}</option>{{end}}
<option value="__custom__">自定义…</option>
</select>
</label>
<label class="f cat-custom" style="display:none"><span>自定义类别</span><input type="text" name="category_custom" placeholder="如 公园"></label>
{{/* 改的是该地点名下**全部到访**的类型,所以默认「(不修改)」、
不预选当前值:否则每次改个坐标保存,都会把历史到访重写一遍 */}}
<label class="f"><span>活动<span class="muted">(改该地点全部到访)</span></span>
<select name="activity_id" class="activity-id-inline">
<option value="">(不修改)</option>
{{range $.Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
<option value="__new__">+ 新增活动…</option>
</select>
</label>
<label class="f visit-act-new" style="display:none"><span>新建活动</span>
<div class="input-btn">
<input type="text" class="visit-act-name" placeholder="名称">
<button type="button" class="btn visit-act-add">添加</button>
</div>
</label>
{{if .ActivityText}}<div class="card-sub" style="margin:0 0 8px">该地点到访当前类型:<span class="mono">{{.ActivityText}}</span></div>{{end}}
<div class="row">
<label class="f" style="flex:1 1 120px"><span>纬度<b class="req">*</b></span><input type="text" name="lat" value="{{printf "%.6f" .Lat}}" required></label>
<label class="f" style="flex:1 1 120px"><span>经度<b class="req">*</b></span><input type="text" name="lon" value="{{printf "%.6f" .Lon}}" required></label>
</div>
<div class="row">
<label class="f" style="flex:1 1 210px"><span>坐标系换算</span>
<select class="coord-sel">
<option value="">保持原样</option>
<option value="wgs2gcj">这条其实是 WGS-84 → 转成 GCJ-02</option>
<option value="gcj2wgs">这条其实是 GCJ-02 → 转成 WGS-84</option>
</select>
</label>
<div class="coord-diff card-sub" style="flex:2 1 260px;margin:0 0 8px"></div>
</div>
{{if .MovedM}}<div class="card-sub" style="margin:0 0 8px">导入时的坐标:<span class="mono">{{printf "%.6f" .OrigLat.Float64}}, {{printf "%.6f" .OrigLon.Float64}}</span>(现在比它偏了 {{num .MovedM}} 米)</div>{{end}}
<div class="row">
<div class="act" style="margin-bottom:8px">
<button type="button" class="btn sm regeo-btn"
title="用上面填的经纬度重新反查省市区与街道,填进下面的框里。坐标改对之后地址要跟着刷,否则两者会差好几百米。">按坐标刷新地址</button>
</div>
<div class="regeo-hint card-sub" style="flex:2 1 260px;margin:0 0 8px"></div>
</div>
<div class="row">
<label class="f" style="flex:1 1 110px"><span>省份</span><input type="text" name="province" value="{{.Province}}"></label>
<label class="f" style="flex:1 1 110px"><span>城市</span><input type="text" name="city" value="{{.City}}"></label>
<label class="f" style="flex:1 1 110px"><span>区县</span><input type="text" name="district" value="{{.District}}"></label>
<label class="f" style="flex:1 1 130px"><span>街道</span><input type="text" name="thoroughfare" value="{{.Thoroughfare}}"></label>
</div>
<div class="row">
<label class="f" style="flex:1 1 120px"><span>坐标来源</span>
<select name="coord_source" title="建这个点时坐标是怎么来的。留档只影响判断,不参与任何换算。">
{{$p := .}}
<option value="none"{{if not .CoordSource}} selected{{end}}>未记录</option>
{{range $.PlaceSources}}<option value="{{.Value}}"{{if eq .Value $p.CoordSource}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</label>
<label class="f" style="flex:1 1 120px"><span>坐标本身是</span>
<select name="coord_sys" title="填在左边「纬度/经度」里的这个坐标属于哪个坐标系。照片导入会自己记,这里只用于事后订正。">
<option value="none"{{if not .CoordSys}} selected{{end}}>未记录</option>
<option value="wgs84"{{if eq .CoordSys "wgs84"}} selected{{end}}>WGS-84</option>
<option value="gcj02"{{if eq .CoordSys "gcj02"}} selected{{end}}>GCJ-02</option>
</select>
</label>
<input type="hidden" name="src_lat" value="{{if .HasSrcCoord}}{{.SrcLat.Float64}}{{end}}">
<input type="hidden" name="src_lon" value="{{if .HasSrcCoord}}{{.SrcLon.Float64}}{{end}}">
</div>
{{if .HasSrcCoord}}<div class="card-sub" style="margin:0 0 8px">换算前的原始坐标:<span class="mono">{{printf "%.6f" .SrcLat.Float64}}, {{printf "%.6f" .SrcLon.Float64}}</span></div>{{end}}
<button class="btn primary sm" type="submit">保存</button>
</form>
</details>
<form method="post" action="/admin/edit/place/delete" style="display:inline"
onsubmit="return confirm('删除该地点会同时删除它名下的全部到访记录,且不可恢复。确认删除?')">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<button class="btn sm danger" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .PageCount 1}}
<div class="pager">
{{if gt .PageNo 1}}<a href="{{$.PageLink "page" (sub .PageNo 1)}}">上一页</a>{{end}}
<span class="cur">第 {{.PageNo}} / {{.PageCount}} 页</span>
{{if lt .PageNo .PageCount}}<a href="{{$.PageLink "page" (add .PageNo 1)}}">下一页</a>{{end}}
</div>
{{end}}
{{else}}<div class="empty">没有匹配的地点</div>{{end}}
</div>
{{/* 重复到访:站点侧跟手机包「同一到达时间 + 同一地点」的历史副本。判定见
edit_duplicates.go(与 tools/dedup_site_visits.py 同一套语义)。
只在检出 >0 对时出现;判定不了时只给一行说明、不给按钮。 */}}
{{if .DupUnavailable}}
<div class="card" data-card="dup-visits">
<h2>重复到访</h2>
<div class="card-sub">{{.DupUnavailable}}</div>
</div>
{{else if .DupVisits}}
<div class="card" data-card="dup-visits">
<h2>重复到访 {{num (len .DupVisits)}} 对</h2>
<div class="card-sub">
同一刻同一地点存了两条。成因有两种:<b>站点侧副本</b>(手机包里也有这条,但 UUID 不同——合并 / 差异按 UUID 认人,
把它当成了「备份新增」)与<b>补录行被合并回来</b>(补录的行没有 UUID,导出时会被补一个派生 UUID,
这份包再合并回站点时对不上,于是又插了一条)。默认全选:删掉勾选的副本会连它的位移 / 天气 / 留档一起清
(「补录行被合并回来」那类会把位移 / 天气<b>改挂</b>到保留项,不白丢里程),
并把指向它的合并标记改指到保留项。导出到 rond 后会变成成对的两条,清完就没了。
</div>
<form method="post" action="/admin/edit/dup/clean">
<div class="tw">
<table class="tb tb-stack">
<thead><tr><th>到达时间</th><th>地点</th><th>保留</th><th>删除(多出来的那条)</th></tr></thead>
<tbody>
{{range .DupVisits}}
<tr>
<td class="muted mono" data-label="到达时间">{{.ArrivalStr}}</td>
<td data-label="地点">{{if .PlaceName}}{{.PlaceName}}{{else}}<span class="muted">(未命名地点)</span>{{end}}
{{if .Reason}}<span class="tag" title="这一对是怎么重复的">{{.Reason}}</span>{{end}}</td>
<td class="mono" data-label="保留">#{{.Keep}}</td>
<td data-label="删除">
<label class="check"><input type="checkbox" name="victim" value="{{.Victim}}" checked>
<span class="mono">#{{.Victim}}</span></label>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="row" style="margin-top:10px;gap:8px;align-items:center">
<button class="btn primary" type="submit"
onclick="return confirm('删除勾选的副本?连它们的位移 / 天气 / 留档一起清掉,不可撤销。')">清理选中的副本</button>
<a class="btn" href="{{.EditLink}}">全部保留</a>
<span class="muted">「全部保留」什么都不改,只是取消勾选。</span>
</div>
</form>
</div>
{{end}}
<div class="card" data-card="visits">
<h2>到访记录</h2>
<form method="get" action="/admin/edit" class="search-row" style="margin-top:6px">
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
{{/* 只搜到访不能把别的列表的筛选清掉(原来只带 vq+back,一搜 q/src/sus
和行程的 mt/mp/mfrom/mto 全丢了)。 */}}
{{if .Query}}<input type="hidden" name="q" value="{{.Query}}">{{end}}
{{if .SourceFilter}}<input type="hidden" name="src" value="{{.SourceFilter}}">{{end}}
{{if .SuspectOnly}}<input type="hidden" name="sus" value="1">{{end}}
{{if .MovementFilter.Transport}}<input type="hidden" name="mt" value="{{.MovementFilter.Transport}}">{{end}}
{{if .MovementFilter.PlaceID}}<input type="hidden" name="mp" value="{{.MovementFilter.PlaceID}}">{{end}}
{{if .MovementFilter.From}}<input type="hidden" name="mfrom" value="{{.MovementFilter.From}}">{{end}}
{{if .MovementFilter.To}}<input type="hidden" name="mto" value="{{.MovementFilter.To}}">{{end}}
<label class="f"><span>搜索</span><input type="text" name="vq" value="{{.VisitQuery}}" placeholder="地点 / 城市 / 备注 / 活动 / 日期 / #编号"></label>
<div class="act"><button class="btn" type="submit">搜索</button><a class="btn" href="{{.EditLink "vq"}}">重置</a></div>
</form>
<div class="card-sub">共 {{num .VisitTotal}} 条{{if .VisitQuery}}(匹配「{{.VisitQuery}}」){{end}},按时间倒序。可修改时间 / 类型 / 备注,或删除。</div>
{{if .Visits}}
<div class="tw">
<table class="tb tb-stack">
<thead><tr><th>时间</th><th>地点</th><th>类型</th><th>备注</th><th></th></tr></thead>
<tbody>
{{range .Visits}}
<tr>
<td class="muted mono">{{.ArrivalStr}}{{if .DepartureStr}}<br>→ {{.DepartureStr}}{{else}}<br><span class="muted">(未离开)</span>{{end}}</td>
<td data-label="地点"><span class="muted mono">#{{.SrcPK}}</span> {{if .PlaceName}}{{.PlaceName}}{{else}}<span class="muted">—</span>{{end}}{{if .City}} <span class="muted">· {{.City}}</span>{{end}}{{if .Hidden}} <span class="tag" title="真机里这次到访不独立成立(已并入别处 / 是某个父段的一部分),时间线与统计不显示它">{{.Hidden}}</span>{{end}}</td>
<td class="muted" data-label="类型">{{.ActivityName}}{{if .UserAdded}} <span class="tag">补录</span>{{end}}</td>
<td class="muted" data-label="备注">{{.Emoji}}{{if .Remark}}{{.Remark}}{{end}}
<form method="post" action="/admin/edit/visit/bookmark" style="display:inline">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<button class="star{{if .Bookmarked}} on{{end}}" type="submit"
title="{{if .Bookmarked}}取消收藏{{else}}标记为收藏{{end}}">{{if .Bookmarked}}★{{else}}☆{{end}}</button>
</form>
</td>
<td class="ops">
<details class="inline-edit">
<summary class="btn sm">编辑</summary>
<form method="post" action="/admin/edit/visit/update" style="margin-top:8px;--efw:300px">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<label class="f"><span>类型</span>
<select name="activity_id" class="activity-id-inline">
<option value="">(无)</option>
{{$cur := .ActivityID}}
{{range $.Activities}}<option value="{{.ID}}" {{if eq .ID $cur}}selected{{end}}>{{.Name}}</option>{{end}}
<option value="__new__">+ 新增活动…</option>
</select>
</label>
{{/* 选到「+ 新增活动…」就地展开。输入框不写 name:它不该随表单提交,
选中的活动由 activity_id 带过去。建完由脚本挂到页面所有类型下拉上。 */}}
<label class="f visit-act-new" style="display:none"><span>新建活动</span>
<div class="input-btn">
<input type="text" class="visit-act-name" placeholder="名称">
<button type="button" class="btn visit-act-add">添加</button>
</div>
</label>
<div class="row">
<label class="f" style="flex:1 1 190px"><span>到达时间<b class="req">*</b></span><input type="datetime-local" name="arrival" value="{{.ArrivalStr}}" required></label>
<label class="f" style="flex:1 1 190px"><span>离开时间</span><input type="datetime-local" name="departure" value="{{.DepartureStr}}"></label>
</div>
<div class="row">
<label class="f" style="flex:2 1 180px"><span>备注</span><input type="text" name="remark" value="{{.Remark}}"></label>
<label class="f" style="flex:1 1 90px"><span>表情</span><input type="text" name="emoji" value="{{.Emoji}}"></label>
</div>
<label class="check"><input type="checkbox" name="bookmarked" {{if .Bookmarked}}checked{{end}}> 收藏</label>
<button class="btn primary sm" type="submit">保存</button>
</form>
</details>
<form method="post" action="/admin/edit/visit/delete" style="display:inline"
onsubmit="return confirm('删除这条到访记录?')">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<button class="btn sm danger" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .VisitPageCount 1}}
<div class="pager">
{{if gt .VisitPageNo 1}}<a href="{{$.PageLink "vpage" (sub .VisitPageNo 1)}}">上一页</a>{{end}}
<span class="cur">第 {{.VisitPageNo}} / {{.VisitPageCount}} 页</span>
{{if lt .VisitPageNo .VisitPageCount}}<a href="{{$.PageLink "vpage" (add .VisitPageNo 1)}}">下一页</a>{{end}}
</div>
{{end}}
{{else}}<div class="empty">还没有到访记录</div>{{end}}
</div>
<div class="card" data-card="movements">
<h2>行程记录</h2>
<form method="get" action="/admin/edit" class="filter-row" style="margin-top:6px">
{{if .Back}}<input type="hidden" name="back" value="{{.BackRaw}}">{{end}}
{{if .Query}}<input type="hidden" name="q" value="{{.Query}}">{{end}}
{{if .VisitQuery}}<input type="hidden" name="vq" value="{{.VisitQuery}}">{{end}}
{{if .SourceFilter}}<input type="hidden" name="src" value="{{.SourceFilter}}">{{end}}
{{if .SuspectOnly}}<input type="hidden" name="sus" value="1">{{end}}
<label class="f" style="flex:0 1 150px"><span>交通方式</span>
<select name="mt">
<option value="">全部</option>
<option value="none"{{if eq .MovementFilter.Transport "none"}} selected{{end}}>(无方式)</option>
{{$cur := .MovementFilter.Transport}}{{range .Transports}}<option value="{{.SrcPK}}"{{if eq (printf "%d" .SrcPK) $cur}} selected{{end}}>{{.Label}}({{.Count}} 段)</option>{{end}}
</select>
</label>
<label class="f" style="flex:1 1 200px"><span>地点(起或止)</span>
<select name="mp">
<option value="">全部</option>
{{$p := .MovementFilter.PlaceID}}{{range .PlaceOptions}}<option value="{{.ID}}"{{if eq .ID $p}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</label>
<label class="f" style="flex:0 1 150px"><span>开始时间从</span><input type="date" name="mfrom" value="{{.MovementFilter.From}}"></label>
<label class="f" style="flex:0 1 150px"><span>到</span><input type="date" name="mto" value="{{.MovementFilter.To}}"></label>
<div class="act">
<button class="btn" type="submit">筛选</button>
<a class="btn" href="{{.EditLink "mt" "mp" "mfrom" "mto"}}">重置</a>
</div>
</form>
<div class="card-sub">共 {{num .MovementTotal}} 段行程{{if or .MovementFilter.Transport .MovementFilter.PlaceID .MovementFilter.From .MovementFilter.To}}(已筛选){{end}},按开始时间倒序。可改起止、方式与时间区间,或删除。</div>
{{if .Movements}}
<div class="tw">
<table class="tb tb-stack">
<thead><tr><th>时间</th><th>路线</th><th>方式</th><th class="num">里程</th><th></th></tr></thead>
<tbody>
{{range .Movements}}
<tr>
<td class="muted mono">{{if .Span}}{{.Span}}{{else}}(无时间){{end}}
{{if .DurationMin}}<br><span class="muted">{{dur .DurationMin}}</span>{{end}}</td>
<td class="mv-route" data-label="路线" title="{{.FromLabel}}{{if .FromCity}} · {{.FromCity}}{{end}} → {{.ToLabel}}{{if .ToCity}} · {{.ToCity}}{{end}}">{{.FromLabel}}<span class="mv-city">{{if .FromCity}} · {{.FromCity}}{{end}}</span><br><span class="muted">→ {{.ToLabel}}<span class="mv-city">{{if .ToCity}} · {{.ToCity}}{{end}}</span></span></td>
<td class="muted" data-label="方式">{{if .TransportName}}<span class="dot" style="display:inline-block;background:{{colorof .TransportColor}}"></span> {{.TransportName}} <span class="muted mono">#{{.TransportSrc}}</span>{{else}}(无方式){{end}}</td>
<td class="num" data-label="里程">{{if .DistanceKm}}{{km .DistanceKm}}{{else}}—{{end}}</td>
<td class="ops">
<details class="inline-edit">
<summary class="btn sm">编辑</summary>
<form method="post" action="/admin/edit/movement/update" style="margin-top:8px;--efw:320px">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<div class="row">
<label class="f" style="flex:1 1 210px"><span>起始到访<b class="req">*</b></span>
<div class="picker" data-source="visit-options">
<input type="text" class="picker-input" placeholder="{{.FromLabel}}" autocomplete="off">
<input type="hidden" name="from_visit" value="{{.FromSrc}}">
<div class="picker-picked" style="display:none;margin:4px 0 0">
已选:<b class="picker-picked-label"></b>
<a href="javascript:void(0)" class="picker-clear" style="margin-left:6px">重选</a>
</div>
<div class="picker-results geo-list" style="display:none;max-height:200px"></div>
</div>
</label>
<label class="f" style="flex:1 1 210px"><span>结束到访<b class="req">*</b></span>
<div class="picker" data-source="visit-options">
<input type="text" class="picker-input" placeholder="{{.ToLabel}}" autocomplete="off">
<input type="hidden" name="to_visit" value="{{.ToSrc}}">
<div class="picker-picked" style="display:none;margin:4px 0 0">
已选:<b class="picker-picked-label"></b>
<a href="javascript:void(0)" class="picker-clear" style="margin-left:6px">重选</a>
</div>
<div class="picker-results geo-list" style="display:none;max-height:200px"></div>
</div>
</label>
</div>
<div class="row">
<label class="f" style="flex:1 1 190px"><span>开始时间</span><input type="datetime-local" name="start" value="{{.StartStr}}"></label>
<label class="f" style="flex:1 1 190px"><span>结束时间</span><input type="datetime-local" name="end" value="{{.EndStr}}"></label>
</div>
<div class="row">
<label class="f" style="flex:1 1 130px"><span>交通方式</span>
<select name="transport_src" class="tsel">
<option value="">(无)</option>
{{$cur := .TransportSrc}}{{range $.Transports}}<option value="{{.SrcPK}}"{{if eq .SrcPK $cur}} selected{{end}} data-name="{{.Name}}" data-color="{{.Color}}" data-icon="{{.Icon}}">{{.Label}}</option>{{end}}
<option value="__new__">新建方式…</option>
</select>
</label>
<label class="f" style="flex:1 1 120px"><span>方式名称</span><input type="text" name="transport_name" value="{{.TransportName}}"></label>
<label class="f" style="flex:1 1 100px"><span>颜色</span>
<select name="transport_color">{{template "coloropts" dict "colors" $.TransportColors "cur" .TransportColor}}</select>
</label>
</div>
<input type="hidden" name="transport_icon" value="">
<button class="btn primary sm" type="submit">保存</button>
</form>
</details>
<form method="post" action="/admin/edit/movement/delete" style="display:inline"
onsubmit="return confirm('删除这段行程?\n只删位移记录,两端的到访保留。')">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<button class="btn sm danger" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if gt .MovementPageCount 1}}
<div class="pager">
{{if gt .MovementPageNo 1}}<a href="{{$.PageLink "mpage" (sub .MovementPageNo 1)}}">上一页</a>{{end}}
<span class="cur">第 {{.MovementPageNo}} / {{.MovementPageCount}} 页</span>
{{if lt .MovementPageNo .MovementPageCount}}<a href="{{$.PageLink "mpage" (add .MovementPageNo 1)}}">下一页</a>{{end}}
</div>
{{end}}
{{else}}<div class="empty">还没有行程记录</div>{{end}}
</div>
<div class="card" data-card="movement-new">
<h2>新增行程</h2>
<div class="card-sub">补录两点之间的位移。起止选对应的到访记录(按名称 / 日期 / #编号 搜索);时间留空则按两端到访自动推断:开始=起点离开时间、结束=终点到达时间。</div>
<form method="post" action="/admin/edit/movement">
<div class="req-tip"><b class="req">*</b>起始到访、结束到访必填</div>
<div class="row">
<label class="f" style="flex:1 1 260px"><span>起始到访<b class="req">*</b></span>
<div class="picker" data-source="visit-options">
<input type="text" class="picker-input" placeholder="搜索到访(名称 / 日期 / #编号)" autocomplete="off">
<input type="hidden" name="from_visit">
<div class="picker-picked" style="display:none;margin:4px 0 0">
已选:<b class="picker-picked-label"></b>
<a href="javascript:void(0)" class="picker-clear" style="margin-left:6px">重选</a>
</div>
<div class="picker-results geo-list" style="display:none;max-height:200px"></div>
</div>
</label>
<label class="f" style="flex:1 1 260px"><span>结束到访<b class="req">*</b></span>
<div class="picker" data-source="visit-options">
<input type="text" class="picker-input" placeholder="搜索到访(名称 / 日期 / #编号)" autocomplete="off">
<input type="hidden" name="to_visit">
<div class="picker-picked" style="display:none;margin:4px 0 0">
已选:<b class="picker-picked-label"></b>
<a href="javascript:void(0)" class="picker-clear" style="margin-left:6px">重选</a>
</div>
<div class="picker-results geo-list" style="display:none;max-height:200px"></div>
</div>
</label>
</div>
<div class="row">
<label class="f" style="flex:1 1 190px"><span>开始时间</span><input type="datetime-local" name="start"></label>
<label class="f" style="flex:1 1 190px"><span>结束时间</span><input type="datetime-local" name="end"></label>
<label class="f" style="flex:1 1 150px"><span>交通方式</span>
<select name="transport_src" class="tsel">
<option value="">(无)</option>
{{range .Transports}}<option value="{{.SrcPK}}" data-name="{{.Name}}" data-color="{{.Color}}" data-icon="{{.Icon}}">{{.Label}}({{.Count}} 段)</option>{{end}}
<option value="__new__">新建方式…</option>
</select>
</label>
</div>
<div class="row">
<label class="f" style="flex:1 1 150px"><span>方式名称</span><input type="text" name="transport_name" placeholder="留空=沿用"></label>
<label class="f" style="flex:1 1 120px"><span>颜色</span>
<select name="transport_color">{{template "coloropts" dict "colors" .TransportColors "cur" ""}}</select>
</label>
<label class="f" style="flex:1 1 150px"><span>图标</span><input type="text" name="transport_icon" placeholder="如 moped.fill / 🚌"></label>
</div>
<div class="card-sub" style="margin:2px 0 8px">方式名称 / 颜色 / 图标改动会作用到<b>所有</b>使用该方式的行程——rond 里交通方式是一份全局列表,改名即整体改名。</div>
<button class="btn primary" type="submit">新增行程</button>
</form>
</div>
<div class="card" data-card="weather-new">
<h2>新增天气</h2>
<div class="card-sub">补录逐小时天气。关联到访可留空。</div>
<form method="post" action="/admin/edit/weather">
<div class="req-tip"><b class="req">*</b>时间必填</div>
<div class="row">
<label class="f" style="flex:1 1 200px"><span>时间<b class="req">*</b></span><input type="datetime-local" name="at" required></label>
<label class="f" style="flex:1 1 260px"><span>关联到访</span>
<div class="picker" data-source="visit-options">
<input type="text" class="picker-input" placeholder="可留空;搜索到访(名称 / 日期 / #编号)" autocomplete="off">
<input type="hidden" name="visit_src">
<div class="picker-picked" style="display:none;margin:4px 0 0">
已选:<b class="picker-picked-label"></b>
<a href="javascript:void(0)" class="picker-clear" style="margin-left:6px">重选</a>
</div>
<div class="picker-results geo-list" style="display:none;max-height:200px"></div>
</div>
</label>
<label class="f" style="flex:1 1 120px"><span>气温 ℃</span><input type="text" name="temperature_c"></label>
</div>
<div class="row">
<label class="f" style="flex:2 1 200px"><span>天气现象</span><input type="text" name="condition" placeholder="如 晴"></label>
<label class="f" style="flex:1 1 150px"><span>图标符号</span><input type="text" name="symbol" placeholder="可选"></label>
</div>
<button class="btn primary" type="submit">新增天气</button>
</form>
</div>
{{end}}
</div>
<script>
// 卡片折叠:这页六张卡片全展开太长了,每张给个折叠按钮,状态记在本地。
// 放在这里(内容之后、下面的初始化之前)同步执行,避免折叠状态晚一步才生效。
(function () {
var KEY = 'rond-edit-fold';
var state = {};
try { state = JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } catch (e) { state = {}; }
function save() {
try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) { /* 隐私模式等,忽略 */ }
}
function apply(card, collapsed) {
card.classList.toggle('collapsed', collapsed);
var head = card.querySelector('h2');
var btn = card.querySelector('.card-toggle');
if (btn) {
btn.textContent = collapsed ? '▸' : '▾';
btn.setAttribute('aria-expanded', String(!collapsed));
btn.title = collapsed ? '展开' : '折叠';
}
if (head) head.title = collapsed ? '点击展开' : '点击折叠';
// 选点地图在 display:none 的容器里初始化会拿到 0 尺寸(一片空白):
// 折叠状态下还没建过就先建,建过就补一次尺寸重算
var m = collapsed ? null : card.querySelector('#picker-map');
if (m) {
if (!m.classList.contains('leaflet-container') && window.__pickerMapInit) window.__pickerMapInit();
setTimeout(function () { window.dispatchEvent(new Event('resize')); }, 0);
}
}
var cards = [];
Array.prototype.forEach.call(document.querySelectorAll('.card[data-card]'), function (card) {
var head = card.querySelector('h2');
if (!head) return;
cards.push(card);
if (card.querySelector('.card-body')) return; // 装过了(脚本被重复执行),只补状态
// h2 之后的内容整体搬进 .card-body:折叠只藏它,标题与按钮始终在
var body = document.createElement('div');
body.className = 'card-body';
var node = head.nextSibling;
while (node) {
var next = node.nextSibling;
body.appendChild(node);
node = next;
}
card.appendChild(body);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'card-toggle';
btn.addEventListener('click', function (e) {
e.stopPropagation();
show(card, !card.classList.contains('collapsed'));
});
head.addEventListener('click', function (e) {
if (e.target === btn) return;
show(card, !card.classList.contains('collapsed'));
});
head.appendChild(btn);
});
// 状态统一在这里落地:与上面的「装按钮/包 body」分开,重复执行也能对齐。
// 默认折叠——这页卡片全展开太长,只有明确记过「展开」的才展开;
// 「活动与类别」是新加的、也最小,默认展开,省得有人找不到。
cards.forEach(function (card) {
var collapsed = state[card.dataset.card];
if (collapsed === undefined) collapsed = card.dataset.card !== 'taxonomy';
apply(card, collapsed);
});
function show(card, collapsed) {
apply(card, collapsed);
state[card.dataset.card] = collapsed;
save();
syncAll();
}
var all = document.getElementById('fold-all');
function syncAll() {
if (!all) return;
var open = cards.filter(function (c) { return !c.classList.contains('collapsed'); }).length;
all.textContent = open === 0 ? '全部展开' : '全部折叠';
}
if (all) {
all.addEventListener('click', function () {
var collapse = cards.some(function (c) { return !c.classList.contains('collapsed'); });
cards.forEach(function (c) { apply(c, collapse); state[c.dataset.card] = collapse; });
save();
syncAll();
});
syncAll();
}
})();
</script>
{{if .OpenSrcPK}}
<script>
// 从地点详情页跳进来(?open=<src_pk>):把目标行滚到眼前。
// 卡片默认是折叠的,这一行根本不可见,所以先借页面的折叠逻辑展开它(状态也会记住)。
(function () {
var row = document.getElementById('place-row-{{.OpenSrcPK}}');
if (!row) return;
var card = row.closest('.card');
if (card && card.classList.contains('collapsed')) {
var head = card.querySelector('h2');
if (head) head.click();
}
requestAnimationFrame(function () { row.scrollIntoView({ block: 'center' }); });
})();
</script>
{{end}}
<script>
(function () {
// Leaflet 由 layout 在 content 之后引入,脚本执行时 window.L 还没有,
// 必须等 load 事件(所有阻塞脚本就绪)后再初始化,否则地图整个不出现
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
window.addEventListener('load', cb);
}
whenReady(function () {
// 原生 datetime-local 在桌面端交互差(上下键调分钟、不能直接键入),
// 统一换 flatpickr:中文、24 小时制、可直接手输。提交格式必须仍是
// datetime-local 的 "YYYY-MM-DDTHH:MM",否则服务端解析不到。
// 注意 l10n 对象叫 flatpickr.l10ns(复数);且必须 try 住——这里抛错会把
// 同一回调里后面的地图和地点搜索初始化一起炸掉。
try {
if (window.flatpickr) {
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
document.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
flatpickr(el, {
enableTime: true,
time_24hr: true,
allowInput: true,
// 补录靠回忆,没人记得 12:59:47——5 分钟步进贴合「大概那个点」的精度
minuteIncrement: 5,
dateFormat: 'Y-m-dTH:i'
});
});
}
} catch (e) { console.error('flatpickr 初始化失败', e); }
// 融合表单的模式切换。required 不能写在 name/lat 等字段上——切到
// 「补已有地点」时它们虽被隐藏,HTML5 仍会校验并拦住提交,所以校验交给后端。
var modeNew = document.querySelector('input[name=mode][value=new]');
var modeExisting = document.querySelector('input[name=mode][value=existing]');
function syncMode() {
if (!modeNew) return;
var isNew = modeNew.checked;
document.getElementById('mode-new').style.display = isNew ? '' : 'none';
document.getElementById('mode-existing').style.display = isNew ? 'none' : '';
// Leaflet 在 display:none 容器里初始化会拿到 0 尺寸,切回来时补一次重算
if (isNew) window.dispatchEvent(new Event('resize'));
}
if (modeNew && modeExisting) {
modeNew.addEventListener('change', syncMode);
modeExisting.addEventListener('change', syncMode);
}
// ---------- 可搜索选择器(地点 / 到访端点都给原生下拉没法用) ----------
// 数据从隐藏 select 读:内联 JSON 会被模板的 JS 上下文转义搞坏
function initPickers() {
document.querySelectorAll('.picker').forEach(function (p) {
var src = document.getElementById(p.dataset.source);
if (!src) return;
var items = [];
Array.prototype.forEach.call(src.options, function (o) {
items.push({ id: o.value, label: o.textContent });
});
var input = p.querySelector('.picker-input');
var hidden = p.querySelector('input[type=hidden]');
var pickedBox = p.querySelector('.picker-picked');
var pickedLabel = p.querySelector('.picker-picked-label');
var results = p.querySelector('.picker-results');
var clearBtn = p.querySelector('.picker-clear');
function close() {
results.style.display = 'none';
results.innerHTML = '';
}
function pick(it) {
hidden.value = it.id;
pickedLabel.textContent = it.label;
pickedBox.style.display = '';
input.style.display = 'none';
close();
}
function render(kw) {
kw = kw.trim().toLowerCase();
var hits = items.filter(function (it) {
return !kw || it.label.toLowerCase().indexOf(kw) >= 0;
}).slice(0, 30);
results.innerHTML = '';
hits.forEach(function (it) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'geo-item';
btn.textContent = it.label;
btn.addEventListener('click', function () { pick(it); });
results.appendChild(btn);
});
results.style.display = hits.length ? '' : 'none';
}
input.addEventListener('focus', function () { render(input.value); });
input.addEventListener('input', function () { render(input.value); });
input.addEventListener('blur', function () {
// 延后一点再收:否则点选项时的 blur 会先把列表拆了,点击落空
setTimeout(close, 150);
});
clearBtn.addEventListener('click', function () {
hidden.value = '';
pickedBox.style.display = 'none';
input.style.display = '';
input.value = '';
input.focus();
});
// 行内编辑的表单里隐藏域已经带了当前值:直接显示成「已选:… + 重选」。
// 不这么做的话用户看不到当前选的是哪条,也没地方清空重选。
if (hidden.value) {
for (var i = 0; i < items.length; i++) {
if (items[i].id === hidden.value) { pick(items[i]); break; }
}
}
});
}
initPickers();
// 点空白处 / 按 Esc 收回下拉(页面里几十个 picker,监听挂一次就够)
function closeAllPickers(except) {
document.querySelectorAll('.picker').forEach(function (p) {
if (except && p.contains(except)) return;
var r = p.querySelector('.picker-results');
if (r) { r.style.display = 'none'; r.innerHTML = ''; }
});
}
document.addEventListener('click', function (e) { closeAllPickers(e.target); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAllPickers(null);
});
// ---------- 交通方式:方式名称 / 颜色 / 图标跟着下拉走 ----------
// 这三个字段是给「改名 / 换色 / 换图标」用的:选中已有方式时预填它的当前值,
// 提交时留空表示沿用(服务端这么解释),所以清空不会把方式变成无名模式。
function syncTransportFields(sel) {
var form = sel.form || sel.closest('form');
if (!form) return;
var name = form.querySelector('[name=transport_name]');
var color = form.querySelector('[name=transport_color]');
var icon = form.querySelector('[name=transport_icon]');
if (!name) return;
var opt = sel.options[sel.selectedIndex];
var isNew = sel.value === '__new__';
var none = sel.value === '';
[name, color, icon].forEach(function (el) { if (el) el.disabled = none; });
if (none) {
name.value = '';
if (color) color.value = '';
if (icon) icon.value = '';
return;
}
name.value = isNew ? '' : ((opt && opt.dataset.name) || '');
if (color) color.value = isNew ? '' : ((opt && opt.dataset.color) || '');
if (icon) icon.value = isNew ? '' : ((opt && opt.dataset.icon) || '');
name.placeholder = isNew ? '必填,如 共享单车' : '留空=沿用';
}
document.querySelectorAll('select.tsel').forEach(function (sel) {
syncTransportFields(sel);
sel.addEventListener('change', function () { syncTransportFields(sel); });
});
// ---------- 坐标换算:换完直接写进经纬度框,并把前后变化摆出来 ----------
// 换算在服务端做(geo 包是唯一实现),这里只管显示与回填。
// 换算同时要修正「坐标来源留档」:换算后的经纬度框里是 GCJ-02,而原始坐标是换算前那一份,
// 所以顺带把 coord_sys 设成来源坐标系、src_lat/src_lon 填成换算前的值——
// 留档与显示坐标于是永远对得上(显示坐标 == 按 coord_sys 换算原始坐标)。
document.querySelectorAll('.coord-sel').forEach(function (sel) {
var form = sel.form || sel.closest('form');
var latI = form.querySelector('[name=lat]'), lonI = form.querySelector('[name=lon]');
var diff = form.querySelector('.coord-diff');
var sysI = form.querySelector('[name=coord_sys]');
var srcLatI = form.querySelector('[name=src_lat]'), srcLonI = form.querySelector('[name=src_lon]');
// base 是「还没换算之前」的坐标:换向要基于它,来回切不会越换越偏
var base = { lat: latI.value, lon: lonI.value };
var keep = { sys: sysI ? sysI.value : '',
srcLat: srcLatI ? srcLatI.value : '', srcLon: srcLonI ? srcLonI.value : '' };
var lastMode = '';
sel.addEventListener('change', function () {
var mode = sel.value;
if (!mode) {
latI.value = base.lat;
lonI.value = base.lon;
if (sysI) sysI.value = keep.sys;
if (srcLatI) srcLatI.value = keep.srcLat;
if (srcLonI) srcLonI.value = keep.srcLon;
diff.textContent = '';
lastMode = '';
return;
}
if (lastMode === '') base = { lat: latI.value, lon: lonI.value };
lastMode = mode;
diff.textContent = '换算中…';
fetch('/api/coords?mode=' + mode + '&lat=' + encodeURIComponent(base.lat) + '&lon=' + encodeURIComponent(base.lon),
{ headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.error) { diff.textContent = d.error; return; }
latI.value = d.lat.toFixed(6);
lonI.value = d.lon.toFixed(6);
// wgs2gcj 说明原来的值是 WGS-84,留档记 wgs84;反过来记 gcj02
if (sysI) sysI.value = mode === 'wgs2gcj' ? 'wgs84' : 'gcj02';
if (srcLatI) srcLatI.value = base.lat;
if (srcLonI) srcLonI.value = base.lon;
var from = mode === 'wgs2gcj' ? 'WGS-84' : 'GCJ-02';
var to = mode === 'wgs2gcj' ? 'GCJ-02' : 'WGS-84';
diff.innerHTML = from + ' <span class="mono">' + base.lat + ', ' + base.lon + '</span>'
+ ' → ' + to + ' <b class="mono">' + latI.value + ', ' + lonI.value + '</b>'
+ ' <span class="muted">(移动 ' + d.dist_m + ' 米 · 往' + d.bearing + ')</span>'
+ '<br><span class="muted">原始坐标会记进留档(' + from + ')</span>';
})
.catch(function () { diff.textContent = '换算失败,请重试'; });
});
});
// ---------- 按坐标刷新地址:坐标改对了,省市区/街道还是按旧坐标反查来的 ----------
// 反查交给服务端(/api/regeo),这里是 GCJ-02,与库内坐标同系,不用再换算。
document.querySelectorAll('.regeo-btn').forEach(function (btn) {
var form = btn.closest('form');
var hint = form.querySelector('.regeo-hint');
btn.addEventListener('click', function () {
var lat = form.querySelector('[name=lat]').value;
var lon = form.querySelector('[name=lon]').value;
if (!lat || !lon) { hint.textContent = '先填经纬度'; return; }
hint.textContent = '反查中…';
btn.disabled = true;
fetch('/api/regeo', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ coord: 'gcj02', points: [{ lat: parseFloat(lat), lon: parseFloat(lon), key: 'r' }] })
})
.then(function (r) { return r.json(); })
.then(function (d) {
var it = d.items && d.items[0];
if (!it) { hint.textContent = d.error || '反查没有结果'; return; }
// 只填反查给出的字段,空的保留原值——反查失败不该把已有地址清空。
// 接口给的是 street/township(高德有时只给乡镇),表单里的框叫 thoroughfare。
var fill = { province: it.province, city: it.city, district: it.district,
thoroughfare: it.street || it.township };
Object.keys(fill).forEach(function (k) {
var el = form.querySelector('[name=' + k + ']');
if (el && fill[k]) el.value = fill[k];
});
hint.textContent = (it.address || '(无地址)')
+ (it.poi ? ' 最近:' + it.poi + (it.poiDist ? '(' + Math.round(it.poiDist) + ' 米)' : '') : '')
+ ' — 已填入下面的框,点「保存」生效';
})
.catch(function () { hint.textContent = '反查失败,请重试'; })
.finally(function () { btn.disabled = false; });
});
});
// ---------- 时间区间 → 实时算时长 ----------
// 时长不是独立字段(导出回 rond 只有 ZSTART_/ZEND_),这里只是让填的时候看得见
function fmtMin(m) {
m = Math.round(m);
if (m < 60) return m + ' 分钟';
var h = Math.floor(m / 60), r = m % 60;
return r ? h + ' 小时 ' + r + ' 分钟' : h + ' 小时';
}
document.querySelectorAll('form[action="/admin/edit/movement"], form[action="/admin/edit/movement/update"]')
.forEach(function (f) {
var s = f.querySelector('[name=start]'), e = f.querySelector('[name=end]');
if (!s || !e) return;
var hint = document.createElement('div');
hint.className = 'card-sub';
hint.style.margin = '2px 0 0';
e.parentNode.appendChild(hint);
function upd() {
if (!s.value || !e.value) { hint.textContent = '时间留空则按两端到访自动推断'; return; }
var d = (new Date(e.value) - new Date(s.value)) / 60000;
if (!isFinite(d)) { hint.textContent = ''; return; }
hint.textContent = d > 0 ? '时长 ' + fmtMin(d) : '结束时间必须晚于开始时间';
}
['change', 'input', 'blur', 'focusout'].forEach(function (ev) {
s.addEventListener(ev, upd);
e.addEventListener(ev, upd);
});
upd();
});
// ---------- 活动内联新建:创建后即时挂进**所有**类型下拉并选中 ----------
// 类型下拉在页面上有多处(顶部地点表单 + 每条到访的行内编辑),选项又是服务端渲染的,
// 所以建完必须一起挂上——否则在顶部建好、下面那行还得刷新页面才看得到。
// 另外 /admin/edit/activity 不做去重,同名会真的建两行,所以先按名字找现成的。
function findActivityByName(name) {
var found = '';
[].forEach.call(document.querySelectorAll('select[name="activity_id"]'), function (s) {
if (found) return;
[].forEach.call(s.options, function (o) {
if (o.value && o.value !== '__new__' && o.textContent === name) found = o.value;
});
});
return found;
}
function addActivityOption(id, name) {
[].forEach.call(document.querySelectorAll('select[name="activity_id"]'), 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(name, onDone) {
name = (name || '').trim();
if (!name) return;
var exist = findActivityByName(name);
if (exist) { onDone(exist); 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);
onDone(String(d.id));
}).catch(function (e) { alert('创建活动失败:' + e); });
}
// 顶部「新建活动」不再单独接事件:它和到访/地点行内编辑共用下面那套
// `visit-act-*` 委派处理器(同一个表单里只有一个新建框,重复监听会建出两条)。
// ---- 下拉里的「+ 新增活动…」:就地展开新建框,建完选中并收起 ----
// 顶部新建地点卡、到访行内编辑、地点行内编辑三处共用(靠 form 里的类名找同伴元素)
function inlineActParts(el) {
var form = el.closest ? el.closest('form') : null;
if (!form) return null;
var wrap = form.querySelector('.visit-act-new');
return { wrap: wrap, input: wrap ? wrap.querySelector('input') : null,
sel: form.querySelector('select.activity-id-inline') };
}
document.addEventListener('change', function (e) {
var t = e.target;
if (!t || !t.classList || !t.classList.contains('activity-id-inline')) return;
var p = inlineActParts(t);
if (!p || !p.wrap) return;
var on = t.value === '__new__';
p.wrap.style.display = on ? '' : 'none';
if (on && p.input) p.input.focus();
});
function inlineActCreate(el) {
var p = inlineActParts(el);
if (!p) return;
createActivity(p.input ? p.input.value : '', function (id) {
if (p.sel) p.sel.value = id;
if (p.input) p.input.value = '';
if (p.wrap) p.wrap.style.display = 'none';
});
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.classList || !t.classList.contains('visit-act-add')) return;
inlineActCreate(t);
});
document.addEventListener('keydown', function (e) {
var t = e.target;
if (e.key !== 'Enter' || !t || !t.classList || !t.classList.contains('visit-act-name')) return;
e.preventDefault();
inlineActCreate(t);
});
// 提交守卫:还停在「+ 新增活动…」上就拦下来。否则 activity_id 会带着 "__new__" 提交,
// resolveActivity 解析不出整数就当成「无」,那条记录的类型会被静默清掉。
document.addEventListener('submit', function (e) {
var f = e.target;
if (!f || !f.querySelector) return;
var sel = f.querySelector('select.activity-id-inline');
if (sel && sel.value === '__new__') {
e.preventDefault();
alert('活动选了「+ 新增活动…」但还没点「添加」:填个名字再点添加,或把这一项改回默认值。');
sel.focus();
return;
}
// 地点行内编辑的「活动」是批量改该地点**全部**到访,动的是历史记录,落库前再问一次
if (!f.getAttribute || f.getAttribute('action') !== '/admin/edit/place/update') return;
if (!sel || !sel.value) return;
var n = f.getAttribute('data-visit-count') || '';
if (!window.confirm('把该地点' + (n ? '全部 ' + n + ' 条' : '全部') + '到访的活动都改成「' +
sel.options[sel.selectedIndex].textContent + '」?原有记录会被一起改掉。')) e.preventDefault();
}, true);
// ---------- 活动改名 / 删除:作用于下拉里当前选中的那一个 ----------
function activityPost(path, body, onOK) {
fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) { alert(d.error); return; }
onOK(d);
}).catch(function (e) { alert('请求失败:' + e); });
}
var actSel = document.getElementById('activity-select');
var actRename = document.getElementById('activity-rename');
if (actSel && actRename) {
actRename.addEventListener('click', function () {
var opt = actSel.options[actSel.selectedIndex];
if (!actSel.value || !opt) { alert('请先在下拉里选择要改名的活动'); return; }
var name = window.prompt('新的活动名称', opt.textContent);
if (!name || !name.trim()) return;
activityPost('/admin/edit/activity/update',
'id=' + encodeURIComponent(actSel.value) + '&name=' + encodeURIComponent(name.trim()),
function (d) { opt.textContent = d.name; });
});
document.getElementById('activity-delete').addEventListener('click', function () {
var opt = actSel.options[actSel.selectedIndex];
if (!actSel.value || !opt) { alert('请先在下拉里选择要删除的活动'); return; }
if (!window.confirm('删除活动「' + opt.textContent + '」?\n引用它的到访会变成「未分类」,导出后 rond 里这些到访就没有活动了。')) return;
activityPost('/admin/edit/activity/delete', 'id=' + encodeURIComponent(actSel.value),
function () { actSel.removeChild(opt); actSel.value = ''; });
});
}
// ---------- 类别下拉:选到「自定义…」时就地展开输入框(含每行行内编辑) ----------
document.addEventListener('change', function (e) {
var t = e.target;
if (!t || !t.classList || !t.classList.contains('cat-select')) return;
var form = t.closest('form');
var wrap = form && form.querySelector('.cat-custom');
if (!wrap) return;
var custom = t.value === '__custom__';
wrap.style.display = custom ? '' : 'none';
var input = wrap.querySelector('input');
if (custom && input) input.focus();
});
var form = document.querySelector('form[action="/admin/edit/place"]');
var el = document.getElementById('picker-map');
var latInput = form && form.querySelector('[name=lat]');
var lonInput = form && form.querySelector('[name=lon]');
var mapGoto = function () {}; // 地图未就绪时的空操作,坐标照填不误
function fillCoord(lat, lon) {
if (latInput) latInput.value = lat.toFixed(6);
if (lonInput) lonInput.value = lon.toFixed(6);
}
// ---------- 地图选点 ----------
if (el && window.L && latInput && lonInput) {
// 底图走站点配置:带密钥的图层由服务端转发,免密钥的是浏览器直连的 {s} 模板,
// 这里不能硬写 /tiles/xxx 路由(免密钥时该路由不存在,会 404 导致地图空白)
var fallback = {
def: { url: 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', maxZoom: 18, attr: '© 高德地图' },
subs: ['1', '2', '3', '4']
};
var init = function (cfg) {
var def = cfg.def, map = L.map(el).setView([34.5, 108.9], 4), marker = null;
L.tileLayer(def.url, {
subdomains: cfg.subs,
maxZoom: def.maxZoom || 18,
minZoom: 3,
attribution: def.attr || ''
}).addTo(map);
mapGoto = function (lat, lon, zoom) {
if (marker) { marker.setLatLng([lat, lon]); } else { marker = L.marker([lat, lon]).addTo(map); }
if (zoom) map.setView([lat, lon], zoom);
};
map.on('click', function (e) { fillCoord(e.latlng.lat, e.latlng.lng); mapGoto(e.latlng.lat, e.latlng.lng, 0); });
var lat0 = parseFloat(latInput.value), lon0 = parseFloat(lonInput.value);
if (lat0 && lon0) mapGoto(lat0, lon0, 14);
};
var startMap = function () {
fetch('/api/map-config', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (c) {
var def = (c && c.base && c.base.length) ? c.base[0] : fallback.def;
var subs = (c && c.subs && c.subs.length) ? c.subs : fallback.subs;
init({ def: def, subs: subs });
})
.catch(function () { init(fallback); });
};
// 这张卡片被折叠着就先别建:Leaflet 在 display:none 的容器里初始化会拿到 0 尺寸,
// 展开时上面的折叠脚本会回来调它
window.__pickerMapInit = startMap;
if (el.offsetParent !== null) startMap();
}
// ---------- 按名称搜索坐标(高德地理编码,服务端代理) ----------
var q = document.getElementById('geo-q'), go = document.getElementById('geo-go');
var list = document.getElementById('geo-list'), msg = document.getElementById('geo-msg');
if (!form || !q || !go || !list || !msg) return;
function setVal(name, v) {
if (!v) return;
var i = form.querySelector('[name=' + name + ']');
if (i && !i.value.trim()) i.value = v;
}
function search() {
var kw = q.value.trim();
if (!kw) { msg.textContent = '请输入要搜索的名称'; return; }
msg.textContent = '搜索中…';
list.textContent = '';
fetch('/api/geocode?q=' + encodeURIComponent(kw), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.error) { msg.textContent = d.error; return; }
var items = (d && d.items) || [];
if (!items.length) { msg.textContent = '没有找到匹配的地点'; return; }
msg.textContent = '找到 ' + items.length + ' 个候选,点一个填入:';
items.forEach(function (it) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'geo-item';
var nm = document.createElement('span');
nm.className = 'geo-name';
nm.textContent = it.name || it.address || '(无名)';
var ad = document.createElement('span');
ad.className = 'geo-addr';
var parts = [];
if (it.address && it.address !== it.name) parts.push(it.address);
parts.push(it.lat.toFixed(5) + ', ' + it.lon.toFixed(5));
ad.textContent = parts.join(' · ');
b.appendChild(nm); b.appendChild(ad);
b.addEventListener('click', function () {
var ni = form.querySelector('[name=name]');
if (ni && !ni.value.trim()) ni.value = it.name || '';
setVal('province', it.province); setVal('city', it.city); setVal('district', it.district);
fillCoord(it.lat, it.lon);
mapGoto(it.lat, it.lon, 15);
msg.textContent = '已填入:' + (it.name || it.address || '');
list.textContent = '';
});
list.appendChild(b);
});
})
.catch(function () { msg.textContent = '搜索失败,请稍后再试'; });
}
go.addEventListener('click', search);
q.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); search(); }
});
});
})();
// ---------- 活动与类别:改名 / 删除 / 合并 / 清空 ----------
// 这些都是整批作用到数据上的操作,用量、原生属性、去重列表会一起变,
// 就地改 DOM 很容易拼出一个「看起来对」的状态,所以成功后直接整页重来拿新数字。
(function () {
var box = null;
function toast(text, bad) {
if (!box) {
var card = document.querySelector('[data-card="taxonomy"]');
var host = card.querySelector('.card-body') || card;
box = document.createElement('div');
host.insertBefore(box, host.firstChild);
}
box.className = 'alert ' + (bad ? 'err' : 'ok');
box.style.margin = '0 0 10px';
box.textContent = text;
if (!bad) box.scrollIntoView({ block: 'nearest' });
}
function post(path, body, after) {
fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(body).toString()
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) { toast(d.error, true); return; }
after(d);
}).catch(function (e) { toast('请求失败:' + e, true); });
}
function reload(text) {
location.href = '/admin/edit?ok=' + encodeURIComponent(text) + '#taxonomy';
}
function count(tr, label) {
var cell = tr.querySelector('[data-label="' + label + '"]');
return cell ? cell.textContent.trim() : '';
}
document.querySelectorAll('#act-rows tr').forEach(function (tr) {
var input = tr.querySelector('input[name="act-name"]');
var id = tr.dataset.id, name = tr.dataset.name;
tr.querySelector('[data-act="rename"]').addEventListener('click', function () {
var v = (input.value || '').trim();
if (!v) { toast('活动名称不能为空', true); return; }
if (v === name) { toast('名称没变', true); return; }
post('/admin/edit/activity/update', { id: id, name: v }, function () {
reload('活动已改名:' + name + ' → ' + v);
});
});
tr.querySelector('[data-act="delete"]').addEventListener('click', function () {
var n = count(tr, '引用');
var what = (!n || n === '0') ? '目前没有到访用它。'
: '引用它的 ' + n + ' 条到访会变成「未分类」。';
if (!window.confirm('删除活动「' + name + '」?\n' + what)) return;
post('/admin/edit/activity/delete', { id: id }, function () {
reload('已删除活动:' + name);
});
});
});
var mf = document.getElementById('merge-from');
var mt = document.getElementById('merge-to');
var go = document.getElementById('merge-go');
if (mf && mt && go) go.addEventListener('click', function () {
if (!mf.value || mf.value === mt.value) { toast('请选两个不同的活动', true); return; }
var a = mf.options[mf.selectedIndex].textContent;
var b = mt.options[mt.selectedIndex].textContent;
if (!window.confirm('把「' + a + '」的到访全部并到「' + b + '」,然后删掉「' + a + '」?')) return;
post('/admin/edit/activity/merge', { from: mf.value, to: mt.value }, function (d) {
reload('已把「' + a + '」并到「' + b + '」,转移 ' + d.visits + ' 条到访');
});
});
var an = document.getElementById('act-new');
var ang = document.getElementById('act-new-go');
if (an && ang) ang.addEventListener('click', function () {
var v = (an.value || '').trim();
if (!v) { toast('请填活动名称', true); return; }
post('/admin/edit/activity', { name: v }, function () {
reload('已新增活动:' + v);
});
});
// 内置表认得的类别枚举。改「导出值」时拿它比对:真机只认它自己那套枚举名,
// 拼错的串(比如 CarRent 少了 al)站内看不出任何问题,导回手机后那个地点就显示不出类别。
var KNOWN = [{{range .KnownCategories}}"{{.}}",{{end}}];
function enumTypo(v) {
if (KNOWN.indexOf(v) >= 0) return null;
for (var i = 0; i < KNOWN.length; i++) {
var k = KNOWN[i];
if (k.indexOf(v) === 0 || v.indexOf(k) === 0) return k; // 「你是不是想写这个」
}
return '';
}
// 要往 places.poi_category 写一个没记录过的枚举时拦一下。返回 true 表示可以继续。
function okEnum(v) {
if (!/^MKPOICategory/.test(v)) return true;
var near = enumTypo(v);
if (near === null) return true;
var tip = near ? '\n\n你是不是想写「' + near + '」?' : '';
return window.confirm('「' + v + '」不在已知的类别枚举里。' + tip +
'\n\n真机只认它自己那套枚举名:写错的串在站内看着一切正常,' +
'但导出回手机后这个地点就显示不出类别了。');
}
// 地点行内编辑里的「自定义类别」写的是同一列、同样的坑,用同一套提醒。
// 用捕获阶段监听:地点那句「批量改到访活动」的确认也走捕获,两边互不影响。
document.addEventListener('submit', function (e) {
var f = e.target;
if (!f || !f.querySelector) return;
var sel = f.querySelector('select.cat-select');
if (!sel || sel.value !== '__custom__') return;
var box = f.querySelector('.cat-custom input');
if (!okEnum(box ? (box.value || '').trim() : '')) e.preventDefault();
}, true);
document.querySelectorAll('#cat-rows tr').forEach(function (tr) {
var input = tr.querySelector('input[name="cat-value"]');
var from = tr.dataset.value;
tr.querySelector('[data-act="rename"]').addEventListener('click', function () {
var v = (input.value || '').trim();
if (!v) { toast('类别不能为空;要摘掉请用「清空」', true); return; }
if (v === from) { toast('类别没变', true); return; }
if (!okEnum(v)) return;
post('/admin/edit/category/rename', { from: from, to: v }, function (d) {
reload('已把 ' + count(tr, '地点') + ' 个地点的类别改成「' + (d.label || v) + '」');
});
});
var labelInput = tr.querySelector('input[name="cat-label"]');
tr.querySelector('[data-act="label"]').addEventListener('click', function () {
var v = (labelInput.value || '').trim();
post('/admin/edit/category/label', { value: from, label: v }, function (d) {
reload(d.custom ? '显示名已改成「' + d.label + '」' : '已恢复默认显示名「' + d.label + '」');
});
});
tr.querySelector('[data-act="clear"]').addEventListener('click', function () {
var n = count(tr, '地点');
if (!window.confirm('清空 ' + n + ' 个地点的「' + from + '」类别?\n' +
'它们会退回「未指定」,统计里的这个类别会消失。')) return;
post('/admin/edit/category/clear', { from: from }, function (d) {
reload('已清空 ' + d.places + ' 个地点的类别');
});
});
});
})();
</script>
{{end}}
{{/* 交通方式的颜色下拉:rond 认的是系统色名,不是一个颜色选择器。
每行行内编辑都要一份,抽出来免得 40 行 × 13 个选项堆在模板里。 */}}
{{define "coloropts"}}
<option value="">(不指定)</option>
{{$cur := .cur}}{{range $c := .colors}}<option value="{{$c}}"{{if eq $c $cur}} selected{{end}}>{{$c}}</option>{{end}}
{{end}}