数据编辑页加「活动与类别」卡片:改名 / 删除 / 新增 / 合并
活动的管理能力其实后端早就有(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」并做了变异验证。
This commit is contained in:
@@ -22,6 +22,74 @@
|
||||
{{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>
|
||||
@@ -595,8 +663,13 @@
|
||||
head.appendChild(btn);
|
||||
});
|
||||
// 状态统一在这里落地:与上面的「装按钮/包 body」分开,重复执行也能对齐。
|
||||
// 默认折叠——这页六张卡片全展开太长,只有明确记过「展开」的才展开。
|
||||
cards.forEach(function (card) { apply(card, state[card.dataset.card] !== false); });
|
||||
// 默认折叠——这页卡片全展开太长,只有明确记过「展开」的才展开;
|
||||
// 「活动与类别」是新加的、也最小,默认展开,省得有人找不到。
|
||||
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);
|
||||
@@ -1176,6 +1249,108 @@
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// ---------- 活动与类别:改名 / 删除 / 合并 / 清空 ----------
|
||||
// 这些都是整批作用到数据上的操作,用量、原生属性、去重列表会一起变,
|
||||
// 就地改 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}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user