diff --git a/internal/web/handlers_edit.go b/internal/web/handlers_edit.go index 1b6d7b5..8f7964d 100644 --- a/internal/web/handlers_edit.go +++ b/internal/web/handlers_edit.go @@ -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() diff --git a/internal/web/server.go b/internal/web/server.go index 46b0384..9d6884d 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -256,6 +256,9 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /admin/edit/activity", s.requireAdmin(s.editActivityCreate)) s.mux.HandleFunc("POST /admin/edit/activity/update", s.requireAdmin(s.editActivityUpdate)) s.mux.HandleFunc("POST /admin/edit/activity/delete", s.requireAdmin(s.editActivityDelete)) + s.mux.HandleFunc("POST /admin/edit/activity/merge", s.requireAdmin(s.editActivityMerge)) + s.mux.HandleFunc("POST /admin/edit/category/rename", s.requireAdmin(s.editCategoryRename)) + s.mux.HandleFunc("POST /admin/edit/category/clear", s.requireAdmin(s.editCategoryClear)) // 照片导入:EXIF 由浏览器读(不上传照片),确认后只把元数据提交上来落库 s.mux.HandleFunc("GET /admin/photos", s.requireAdmin(s.adminPhotosPage)) s.mux.HandleFunc("POST /admin/photos/import", s.requireAdmin(s.adminPhotosImport)) diff --git a/internal/web/taxonomy_test.go b/internal/web/taxonomy_test.go new file mode 100644 index 0000000..cde4d52 --- /dev/null +++ b/internal/web/taxonomy_test.go @@ -0,0 +1,175 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// taxonomyPost 按页面 fetch 的方式调「活动与类别」那几个端点,返回解析后的 JSON。 +func taxonomyPost(t *testing.T, f *movementFixture, path string, form url.Values) map[string]any { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + ctx := req.WithContext(f.ctx) + switch path { + case "/admin/edit/category/rename": + f.srv.editCategoryRename(rec, ctx) + case "/admin/edit/category/clear": + f.srv.editCategoryClear(rec, ctx) + case "/admin/edit/activity/merge": + f.srv.editActivityMerge(rec, ctx) + default: + t.Fatalf("未接线的端点 %s", path) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("%s 返回的不是 JSON: %s", path, rec.Body.String()) + } + return out +} + +// 类别没有表,「改名」只能是按值批量改地点;填成另一个已有值就是合并。 +func TestCategoryRenameMergesIntoExisting(t *testing.T) { + f := newMovementFixture(t) + mk := func(pk int, cat string) { + f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category) + VALUES ($1,$2,'类别测试点',30,120,$3)`, f.dsID, pk, cat) + } + mk(690001, "测试类别甲") + mk(690002, "测试类别甲") + mk(690003, "测试类别乙") + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 690001 AND 690003`, f.dsID) + }) + + got := taxonomyPost(t, f, "/admin/edit/category/rename", url.Values{ + "from": {"测试类别甲"}, "to": {"测试类别乙"}, + }) + if got["places"] != float64(2) { + t.Fatalf("应报告 2 个地点被改,实际 %v", got["places"]) + } + if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别乙' + AND src_pk BETWEEN 690001 AND 690003`, f.dsID); n != 3 { + t.Fatalf("甲应整体并进乙(共 3 个),实际 %d 个", n) + } + if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别甲'`, f.dsID); n != 0 { + t.Fatalf("甲应一个不剩,还剩 %d 个", n) + } +} + +// 新类别填空不能当成「改名成空」把类别悄悄丢掉——那是「清空」的活,要另走一步。 +func TestCategoryRenameRefusesEmptyTarget(t *testing.T) { + f := newMovementFixture(t) + f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category) + VALUES ($1,690011,'类别测试点',30,120,'测试类别丙')`, f.dsID) + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=690011`, f.dsID) + }) + + got := taxonomyPost(t, f, "/admin/edit/category/rename", url.Values{"from": {"测试类别丙"}, "to": {" "}}) + if got["error"] == nil { + t.Fatal("新类别为空时应报错") + } + if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别丙'`, f.dsID); n != 1 { + t.Fatalf("报错就不该动数据,实际只剩 %d 个", n) + } +} + +// 「清空」= 摘掉类别:那些地点退回未指定(NULL),去重列表里也就不再出现。 +func TestCategoryClearDetachesPlaces(t *testing.T) { + f := newMovementFixture(t) + for _, pk := range []int{690021, 690022} { + f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category) + VALUES ($1,$2,'类别测试点',30,120,'测试类别丁')`, f.dsID, pk) + } + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 690021 AND 690022`, f.dsID) + }) + + got := taxonomyPost(t, f, "/admin/edit/category/clear", url.Values{"from": {"测试类别丁"}}) + if got["places"] != float64(2) { + t.Fatalf("应报告 2 个地点被摘掉,实际 %v", got["places"]) + } + if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 690021 AND 690022 + AND poi_category IS NULL`, f.dsID); n != 2 { + t.Fatalf("应写 NULL 而不是空串,实际 %d 个", n) + } + if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别丁'`, f.dsID); n != 0 { + t.Fatalf("类别应彻底消失,还剩 %d 个", n) + } +} + +// 合并:源的到访整体转到目标,源活动被删,is_home/is_work 跟着目标走 +// (那两个是从活动抄到到访上的冗余标记,不跟着改就会留下一个矛盾的到访)。 +func TestActivityMergeMovesVisitsThenDeletesSource(t *testing.T) { + f := newMovementFixture(t) + placeID, _ := fillupPlace(t, f) + + mkAct := func(pk int, name string, home bool) int64 { + var id int64 + if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO activities + (dataset_id, src_pk, name, color, is_home) VALUES ($1,$2,$3,'',$4) RETURNING id`, + f.dsID, pk, name, home).Scan(&id); err != nil { + t.Fatalf("建活动失败: %v", err) + } + t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM activities WHERE id=$1`, id) }) + return id + } + from := mkAct(695001, "合并测试甲", false) + to := mkAct(695002, "合并测试乙", true) + + f.mustExec(t, `UPDATE visits SET activity_id=$2, is_home=FALSE WHERE dataset_id=$1 AND place_id=$3`, + f.dsID, from, placeID) + if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND activity_id=$2`, f.dsID, from); n == 0 { + t.Fatal("合并前应有到访挂在源活动上") + } + + got := taxonomyPost(t, f, "/admin/edit/activity/merge", url.Values{ + "from": {itoa(from)}, "to": {itoa(to)}, + }) + if got["error"] != nil { + t.Fatalf("合并不该失败: %v", got["error"]) + } + if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND activity_id=$2`, f.dsID, to); n == 0 { + t.Fatal("到访应转到目标活动名下") + } + if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND is_home`, f.dsID); n == 0 { + t.Fatal("目标活动的 is_home 应抄到转过来的到访上") + } + if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1 AND id=$2`, f.dsID, from); n != 0 { + t.Fatal("源活动应被删掉") + } + if n := f.scalar(t, `SELECT count(*) FROM entity_raw WHERE dataset_id=$1 AND entity='ZACTIVITY' AND src_pk=695001`, + f.dsID); n != 0 { + t.Fatal("源活动的原始行留档应一起清掉,否则导出还会带它") + } +} + +// 合并到自己或选空都必须拒绝,别把活动连同它的到访一起清掉。 +func TestActivityMergeRefusesDegenerateInput(t *testing.T) { + f := newMovementFixture(t) + var id int64 + if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO activities (dataset_id, src_pk, name, color) + VALUES ($1,695011,'合并测试丙','') RETURNING id`, f.dsID).Scan(&id); err != nil { + t.Fatalf("建活动失败: %v", err) + } + t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM activities WHERE id=$1`, id) }) + + for _, form := range []url.Values{ + {"from": {itoa(id)}, "to": {itoa(id)}}, + {"from": {itoa(id)}, "to": {"0"}}, + {"from": {"0"}, "to": {itoa(id)}}, + } { + if got := taxonomyPost(t, f, "/admin/edit/activity/merge", form); got["error"] == nil { + t.Fatalf("%v 应被拒绝", form) + } + } + if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1 AND id=$2`, f.dsID, id); n != 1 { + t.Fatal("被拒绝的合并不该动数据") + } +} diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index d37f5de..2ffcce7 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -22,6 +22,74 @@ {{else}} {{if not .CanExport}}
| 名称 | 引用 | 原生属性 | |
|---|---|---|---|
| + | {{num .Visits}} | +{{if .IsHome}}家{{end}}{{if and .IsHome .IsWork}} · {{end}}{{if .IsWork}}工作{{end}} | ++ + + | +
| 类别 | 地点 | |
|---|---|---|
|
+
+ {{.Label}}
+ |
+ {{num .Places}} | ++ + + | +