活动的管理能力其实后端早就有(create/update/delete 三个端点),但入口只藏在 「到访记录」卡片里——要先在下拉里选中某个活动,再点按钮弹 prompt,看不见 「哪些活动一条到访都没挂」(实测有 0 条的活动);类别则完全没有集中入口。 现在页首多了一张卡片(唯一默认展开的),两个表格都带用量: - 活动:名称行内改、删除(有引用时二次确认并报条数)、新增, 另有一条「把[A]并到[B]」——改名解决不了「两个名字指同一件事」, 合并会把到访转过去、把 is_home/is_work 抄成目标的,再删源活动、清它的原始行留档。 - 类别:类别没有独立的表,值就长在 places.poi_category 上,所以「改名 / 清空」 都是按值批量作用到地点;改成另一个已有值就是合并,不另做按钮。 新增端点:/admin/edit/category/rename、/admin/edit/category/clear、 /admin/edit/activity/merge。编辑页那条 DISTINCT poi_category 查询顺手改成 GROUP BY 带计数,一份数据两用。 顺带修一处会静默失效的写法:新表原来只写了 tb-stack,漏了配对的 tb, 而 CSS 全是 `.tw > table.tb.tb-stack …` ⇒ 一条堆叠规则都没命中,窄屏下表仍是 640px 底宽。横滑是在 .tw 里滚的、页面不溢出,肉眼很难发现,守卫测试也只查 「有没有 tb-stack」——已补上「有 tb-stack 必须有 tb」并做了变异验证。
1363 lines
76 KiB
HTML
1363 lines
76 KiB
HTML
{{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="类别">
|
||
<input list="cat-values" type="text" name="cat-value" value="{{.Value}}" style="min-width:12em">
|
||
<div class="card-sub" style="margin:2px 0 0">{{.Label}}</div>
|
||
</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}}
|
||
<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>
|
||
|
||
<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}}
|
||
<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}}</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}}
|
||
<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);
|
||
});
|
||
});
|
||
|
||
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; }
|
||
post('/admin/edit/category/rename', { from: from, to: v }, function (d) {
|
||
reload('已把 ' + count(tr, '地点') + ' 个地点的类别改成「' + (d.label || v) + '」');
|
||
});
|
||
});
|
||
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}}
|