数据编辑页加「活动与类别」卡片:改名 / 删除 / 新增 / 合并

活动的管理能力其实后端早就有(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:
tamakyi
2026-09-20 09:51:43 +08:00
parent ffee082fad
commit da6fe54ab4
6 changed files with 523 additions and 10 deletions
+137 -4
View File
@@ -131,24 +131,47 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
if d.PageNo > d.PageCount {
d.PageNo = d.PageCount
}
// 已有类别去重:新建表单和每行编辑的下拉都用它(提交值仍是原始标识符)
crows, err := s.db.QueryContext(ctx, `SELECT DISTINCT poi_category FROM places
WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' ORDER BY poi_category`, ds.ID)
// 已有类别去重:新建表单、每行编辑的下拉、顶部类别台账都用它(提交值仍是原始标识符);
// 顺手带上地点数,台账要按用量排序、也要知道改一下会影响多少行
crows, err := s.db.QueryContext(ctx, `SELECT poi_category, count(*) FROM places
WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' GROUP BY 1 ORDER BY 1`, ds.ID)
if err != nil {
s.serverError(w, r, err)
return
}
for crows.Next() {
var c string
if err := crows.Scan(&c); err != nil {
var n int
if err := crows.Scan(&c, &n); err != nil {
crows.Close()
s.serverError(w, r, err)
return
}
d.Categories = append(d.Categories, EditOption{Label: c, Name: poiCategoryLabel(c)})
d.CategoryRows = append(d.CategoryRows, EditCategory{Value: c, Label: poiCategoryLabel(c), Places: n})
}
crows.Close()
// 活动台账:带上引用到访数,好一眼看出哪些压根没人用
arows, err := s.db.QueryContext(ctx, `SELECT a.id, COALESCE(a.name,''), a.is_home, a.is_work, count(v.id)
FROM activities a LEFT JOIN visits v ON v.activity_id=a.id
WHERE a.dataset_id=$1 GROUP BY a.id, a.name, a.is_home, a.is_work
ORDER BY 5 DESC, a.name`, ds.ID)
if err != nil {
s.serverError(w, r, err)
return
}
for arows.Next() {
var a EditActivity
if err := arows.Scan(&a.ID, &a.Name, &a.IsHome, &a.IsWork, &a.Visits); err != nil {
arows.Close()
s.serverError(w, r, err)
return
}
d.ActivityRows = append(d.ActivityRows, a)
}
arows.Close()
lrows, err := s.db.QueryContext(ctx, fmt.Sprintf(`SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.poi_category,''),
COALESCE(p.province,''), COALESCE(p.city,''), COALESCE(p.district,''), COALESCE(p.thoroughfare,''),
COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.visit_count,0),
@@ -617,6 +640,116 @@ func (s *Server) editActivityDelete(w http.ResponseWriter, r *http.Request) {
s.json(w, map[string]any{"ok": true})
}
// editCategoryRename 把某个类别整体改名。改成另一个**已经有地点在用**的类别就是合并——
// 两者本来就是同一条 UPDATE,所以不另做「合并」按钮。
func (s *Server) editCategoryRename(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return
}
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
from := strings.TrimSpace(r.PostFormValue("from"))
to := strings.TrimSpace(r.PostFormValue("to"))
if from == "" || to == "" {
s.json(w, map[string]any{"error": "新旧类别都要填;要摘掉类别请用「清空」"})
return
}
if from == to {
s.json(w, map[string]any{"error": "新旧类别一样,没得改"})
return
}
res, err := s.db.ExecContext(ctx, `UPDATE places SET poi_category=$3
WHERE dataset_id=$1 AND poi_category=$2`, ds.ID, from, to)
if err != nil {
s.serverError(w, r, err)
return
}
n, _ := res.RowsAffected()
s.json(w, map[string]any{"ok": true, "places": n, "to": to, "label": poiCategoryLabel(to)})
}
// editCategoryClear 把某个类别从所有地点上摘掉(那些地点退回「未指定」)。
// 类别的值就长在 places.poi_category 上,没有独立的表,所以「删除类别」只能落成这个。
func (s *Server) editCategoryClear(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return
}
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
from := strings.TrimSpace(r.PostFormValue("from"))
if from == "" {
s.json(w, map[string]any{"error": "请选择要清空的类别"})
return
}
res, err := s.db.ExecContext(ctx, `UPDATE places SET poi_category=NULL
WHERE dataset_id=$1 AND poi_category=$2`, ds.ID, from)
if err != nil {
s.serverError(w, r, err)
return
}
n, _ := res.RowsAffected()
s.json(w, map[string]any{"ok": true, "places": n})
}
// editActivityMerge 把 from 的到访全部转到 to 名下,然后删掉 from。
// 改名解决不了「两个名字指同一件事」(「活动」和「散步」这种),只有合并能收敛。
func (s *Server) editActivityMerge(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return
}
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
from, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("from")), 10, 64)
to, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("to")), 10, 64)
if from <= 0 || to <= 0 || from == to {
s.json(w, map[string]any{"error": "请选两个不同的活动"})
return
}
var moved int64
if err := s.withTx(ctx, func(ex execer) error {
// 两端都必须是本数据集的:否则能把别处的到访「拐」过来
var n int
if err := ex.QueryRowContext(ctx, `SELECT count(*) FROM activities
WHERE dataset_id=$1 AND id IN ($2,$3)`, ds.ID, from, to).Scan(&n); err != nil {
return err
}
if n != 2 {
return errors.New("活动不存在")
}
// is_home / is_work 是从活动抄到到访上的冗余标记,跟着目标活动走
isHome, isWork := s.activityFlags(ctx, ex, to)
res, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, is_home=$4, is_work=$5
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, from, to, isHome, isWork)
if err != nil {
return err
}
moved, _ = res.RowsAffected()
_, err = ex.ExecContext(ctx, `DELETE FROM activities WHERE id=$1 AND dataset_id=$2`, from, ds.ID)
return err
}); err != nil {
s.json(w, map[string]any{"error": err.Error()})
return
}
s.pruneEntityRaw(ctx, ds.ID)
s.refreshDatasetAgg(ctx, ds.ID)
s.json(w, map[string]any{"ok": true, "visits": moved})
}
// editPlaceCreate 是最前面那个融合表单(新建地点 / 给已有地点补到访)的处理函数。
func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()