diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 3ed116b..a05b58c 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -159,6 +159,15 @@ CREATE TABLE IF NOT EXISTS settings ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- 类别值 → 中文显示名 的站点自定义覆盖。**只影响显示,不碰数据**: +-- 导出到 rond 用的是 places.poi_category 本身,这里改名不会写回手机。 +-- 刻意不挂 dataset_id:类别值是它自己的稳定键,挂数据集会像备注/投票那样换个数据集就失联。 +CREATE TABLE IF NOT EXISTS category_labels ( + value TEXT PRIMARY KEY, + label TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + -- 世界迷雾(Fog of World)已探索位图,按块粒度存储。 -- 迷雾与数据集无关(来自另一个应用),挂在用户维度下; -- gbx/gby 为全球块坐标(0..65535),一个位对应 2^22 网格里约 9.55m 的一格。 diff --git a/internal/web/fillup.go b/internal/web/fillup.go index e4844e0..531231f 100644 --- a/internal/web/fillup.go +++ b/internal/web/fillup.go @@ -60,6 +60,7 @@ type FillupRow struct { ActHint string // 建议的活动名(空 = 没建议) ActID int64 // 建议命中现有活动时的 id;0 表示得新建 CatHint string // 建议的类别 + CatLabel string // 上面那个类别建议的显示名(站点自定义的优先) Source string // 建议从哪来 AmapType string // 高德给出的类型原文,供人工判断(可能没映射出建议) AmapPoi string // 高德找到的最近 POI 名字 @@ -112,7 +113,7 @@ type activityOption struct { // - 缺类别 = poi_category 为空。 // // 零到访的地点只要缺类别也会进来——它们没有到访可挂活动,补类别仍然有意义。 -func (s *Server) fillupRows(ctx context.Context, datasetID int64) ([]FillupRow, error) { +func (s *Server) fillupRows(ctx context.Context, datasetID int64, custom map[string]string) ([]FillupRow, error) { rows, err := s.db.QueryContext(ctx, ` SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.city,''), COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.poi_category,''), @@ -169,6 +170,7 @@ func (s *Server) fillupRows(ctx context.Context, datasetID int64) ([]FillupRow, } if out[i].NoCat && h.Cat != "" { out[i].CatHint = h.Cat + out[i].CatLabel = categoryLabel(h.Cat, custom) } out[i].AmapType, out[i].AmapPoi = h.Type, h.Poi } @@ -196,7 +198,8 @@ func (s *Server) adminFillupPage(w http.ResponseWriter, r *http.Request) { return } p.SetFlashFromQuery(r) - rows, err := s.fillupRows(ctx, ds.ID) + catNames := s.categoryLabels(ctx) + rows, err := s.fillupRows(ctx, ds.ID, catNames) if err != nil { s.serverError(w, r, err) return @@ -206,7 +209,7 @@ func (s *Server) adminFillupPage(w http.ResponseWriter, r *http.Request) { s.serverError(w, r, err) return } - usedCats, err := s.usedCategories(ctx, ds.ID) + usedCats, err := s.usedCategories(ctx, ds.ID, catNames) if err != nil { s.serverError(w, r, err) return @@ -228,7 +231,7 @@ func (s *Server) adminFillupPage(w http.ResponseWriter, r *http.Request) { rows = rows[from:to] s.render(w, "admin/fillup", FillupData{ - Page: p, Rows: rows, Activities: acts, Categories: fillupCategories(usedCats), + Page: p, Rows: rows, Activities: acts, Categories: fillupCategories(usedCats, catNames), Total: total, WithHint: hinted, PageNo: pageNo, PageCount: pageCount, }) } @@ -263,7 +266,7 @@ type fillupCategory struct { // usedCategories 列出这个数据集里真在用的类别(按地点数从多到少),排在静态表前面。 // 候选得先跟已有数据对得上——否则用户会在这一页挑一个名字相近的静态枚举, // 跟库里已有的值并存成两种,同一个意思分两处统计。 -func (s *Server) usedCategories(ctx context.Context, datasetID int64) ([]fillupCategory, error) { +func (s *Server) usedCategories(ctx context.Context, datasetID int64, custom map[string]string) ([]fillupCategory, error) { rows, err := s.db.QueryContext(ctx, `SELECT poi_category, count(*) FROM places WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' GROUP BY 1 ORDER BY 2 DESC, 1`, datasetID) if err != nil { @@ -278,14 +281,14 @@ func (s *Server) usedCategories(ctx context.Context, datasetID int64) ([]fillupC return nil, err } // 值可能是 MKPOICategory 枚举,也可能是真机自己写的中文——poiCategoryLabel 两种都认 - out = append(out, fillupCategory{v, fmt.Sprintf("%s(在用 %d)", poiCategoryLabel(v), n)}) + out = append(out, fillupCategory{v, fmt.Sprintf("%s(在用 %d)", categoryLabel(v, custom), n)}) } return out, rows.Err() } // fillupCategories 是类别下拉的选项:数据集里真在用的排最前面,再跟真机用过的 12 种、 // 其余常见 MapKit 类别。同一个值只出现一次——在用的那份信息更准,就顶掉静态表里的说明。 -func fillupCategories(inUse []fillupCategory) []fillupCategory { +func fillupCategories(inUse []fillupCategory, custom map[string]string) []fillupCategory { known := []string{ "MKPOICategoryRestaurant", "MKPOICategoryStore", "MKPOICategoryPublicTransport", "MKPOICategoryHotel", "MKPOICategoryLandmark", "MKPOICategoryFoodMarket", @@ -313,10 +316,10 @@ func fillupCategories(inUse []fillupCategory) []fillupCategory { push(c) } for _, c := range known { - push(fillupCategory{c, poiName(c) + "(真机用过)"}) + push(fillupCategory{c, categoryLabel(c, custom) + "(真机用过)"}) } for _, c := range extra { - push(fillupCategory{c, poiName(c)}) + push(fillupCategory{c, categoryLabel(c, custom)}) } return out } @@ -489,7 +492,8 @@ func (s *Server) adminFillupAmap(w http.ResponseWriter, r *http.Request) { s.fillupFlash(w, r, "err", "conf/app.ini 里没配 geocode_key,用不了高德") return } - rows, err := s.fillupRows(ctx, ds.ID) + catNames := s.categoryLabels(ctx) + rows, err := s.fillupRows(ctx, ds.ID, catNames) if err != nil { s.serverError(w, r, err) return diff --git a/internal/web/handlers_edit.go b/internal/web/handlers_edit.go index 8f7964d..ecf1626 100644 --- a/internal/web/handlers_edit.go +++ b/internal/web/handlers_edit.go @@ -133,6 +133,7 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) { } // 已有类别去重:新建表单、每行编辑的下拉、顶部类别台账都用它(提交值仍是原始标识符); // 顺手带上地点数,台账要按用量排序、也要知道改一下会影响多少行 + catNames := s.categoryLabels(ctx) 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 { @@ -147,8 +148,11 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) { 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}) + d.Categories = append(d.Categories, EditOption{Label: c, Name: categoryLabel(c, catNames)}) + d.CategoryRows = append(d.CategoryRows, EditCategory{ + Value: c, Label: categoryLabel(c, catNames), Places: n, + Custom: catNames[c] != "", Missing: categoryLabelMissing(c), + }) } crows.Close() @@ -203,7 +207,7 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) { if e.OrigLat.Valid && e.OrigLon.Valid { e.MovedM = int(geo.Haversine(e.OrigLat.Float64, e.OrigLon.Float64, e.Lat, e.Lon)*1000 + 0.5) } - e.CategoryOptions = categoryOptions(d.Categories, e.Category) + e.CategoryOptions = categoryOptions(d.Categories, e.Category, catNames) d.Places = append(d.Places, e) } lrows.Close() @@ -437,7 +441,7 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) { // categoryOptions 组出某一行的类别下拉:库里的已有类别,当前值不在其中时补进列表, // 否则真机里那些非常见类别会被静默改掉或显示为空。 -func categoryOptions(all []EditOption, cur string) []EditOption { +func categoryOptions(all []EditOption, cur string, custom map[string]string) []EditOption { cur = strings.TrimSpace(cur) out := make([]EditOption, 0, len(all)+1) found := false @@ -448,7 +452,7 @@ func categoryOptions(all []EditOption, cur string) []EditOption { out = append(out, c) } if cur != "" && !found { - out = append(out, EditOption{Label: cur, Name: poiCategoryLabel(cur)}) + out = append(out, EditOption{Label: cur, Name: categoryLabel(cur, custom)}) } return out } @@ -471,42 +475,59 @@ const activityNewValue = "__new__" // poiCategoryText 把 rond/MapKit 的 POI 分类标识符换成中文。 // 库里存的原值是 MKPOICategoryXxx(rond 靠它显示图标),表单里给人看中文。 +// poiCategoryText 是类别值 → 中文名 的**内置默认**对照,站点可以用 category_labels 覆盖它。 +// MapKit 只认 MKPOICategory<名字> 这种枚举串,中文名纯粹是站内显示用的。 +// +// 这里原来是两张表(view.go 的 poiNames 给补全页静态项和 GPX,这张给编辑页), +// 两边还对不上:FoodMarket 一处「菜市场」一处「食品市场」、Cafe 一处「咖啡馆」一处「咖啡厅」、 +// FitnessCenter 一处「健身房」一处「健身中心」,ATM / Atm 大小写也各写各的。 +// 2026-09-20 合成这一张(冲突处取编辑页那套),并删掉永远匹配不上的 MKPOICategoryMarina2。 var poiCategoryText = map[string]string{ - "MKPOICategoryRestaurant": "餐厅", - "MKPOICategoryStore": "商店", - "MKPOICategoryPublicTransport": "公共交通", + "MKPOICategoryATM": "取款机", + "MKPOICategoryAirport": "机场", + "MKPOICategoryAmusementPark": "游乐园", + "MKPOICategoryAquarium": "水族馆", + "MKPOICategoryAtm": "取款机", + "MKPOICategoryBakery": "面包店", + "MKPOICategoryBank": "银行", + "MKPOICategoryBar": "酒吧", + "MKPOICategoryBeach": "海滩", + "MKPOICategoryCafe": "咖啡馆", + "MKPOICategoryCampground": "露营地", + "MKPOICategoryCarRental": "租车", + "MKPOICategoryCinema": "电影院", + "MKPOICategoryFireStation": "消防站", + "MKPOICategoryFitnessCenter": "健身房", + "MKPOICategoryFoodMarket": "菜市场", + "MKPOICategoryGasStation": "加油站", + "MKPOICategoryHospital": "医院", "MKPOICategoryHotel": "酒店", "MKPOICategoryLandmark": "地标", - "MKPOICategoryFoodMarket": "菜市场", - "MKPOICategoryBeach": "海滩", - "MKPOICategoryCampground": "露营地", - "MKPOICategoryZoo": "动物园", - "MKPOICategoryMuseum": "博物馆", - "MKPOICategoryStadium": "体育场", - "MKPOICategoryPark": "公园", - "MKPOICategoryCafe": "咖啡馆", - "MKPOICategoryBar": "酒吧", - "MKPOICategoryBakery": "面包店", - "MKPOICategorySchool": "学校", - "MKPOICategoryHospital": "医院", - "MKPOICategoryPharmacy": "药店", - "MKPOICategoryBank": "银行", - "MKPOICategoryATM": "取款机", - "MKPOICategoryGasStation": "加油站", - "MKPOICategoryParking": "停车场", - "MKPOICategoryAirport": "机场", - "MKPOICategoryTrainStation": "火车站", - "MKPOICategoryMarina": "码头", - "MKPOICategoryNationalPark": "国家公园", - "MKPOICategoryTheater": "剧院", - "MKPOICategoryCinema": "电影院", "MKPOICategoryLibrary": "图书馆", - "MKPOICategoryFitnessCenter": "健身房", - "MKPOICategoryAmusementPark": "游乐园", + "MKPOICategoryMarina": "码头", + "MKPOICategoryMovieTheater": "电影院", + "MKPOICategoryMuseum": "博物馆", + "MKPOICategoryNationalPark": "国家公园", "MKPOICategoryNightlife": "夜生活", + "MKPOICategoryPark": "公园", + "MKPOICategoryParking": "停车场", + "MKPOICategoryPharmacy": "药店", + "MKPOICategoryPolice": "派出所", + "MKPOICategoryPostOffice": "邮局", + "MKPOICategoryPublicTransport": "公共交通", + "MKPOICategoryRestaurant": "餐厅", "MKPOICategoryRestroom": "洗手间", + "MKPOICategorySchool": "学校", + "MKPOICategorySpa": "水疗", + "MKPOICategoryStadium": "体育场", + "MKPOICategoryStore": "商店", + "MKPOICategoryTheater": "剧院", + "MKPOICategoryTrainStation": "火车站", + "MKPOICategoryUniversity": "大学", + "MKPOICategoryZoo": "动物园", } +// poiCategoryLabel 是内置默认名(不含站点覆盖)。 func poiCategoryLabel(v string) string { if l, ok := poiCategoryText[v]; ok { return l @@ -514,6 +535,45 @@ func poiCategoryLabel(v string) string { return strings.TrimPrefix(v, "MKPOICategory") } +// categoryLabel 取类别的显示名:站点自定义的优先,其次内置默认,都没有就退回去掉前缀的原值。 +// 只影响显示——导出到 rond 用的是 places.poi_category 本身,显示名不会被写回去。 +func categoryLabel(v string, custom map[string]string) string { + if l, ok := custom[v]; ok && l != "" { + return l + } + return poiCategoryLabel(v) +} + +// categoryLabelMissing 判断这个值是不是「还没有中文名」:是 MapKit 枚举但内置表里没有, +// 显示出来只会是 Foo 这种半截英文,该由站长补一个。中文值(站内自己写的)自带名字,不算缺。 +func categoryLabelMissing(v string) bool { + if !strings.HasPrefix(v, "MKPOICategory") { + return false + } + _, ok := poiCategoryText[v] + return !ok +} + +// categoryLabels 读站点自定义的类别显示名。读不到就是空 map,各处自动退回内置名。 +func (s *Server) categoryLabels(ctx context.Context) map[string]string { + out := map[string]string{} + rows, err := s.db.QueryContext(ctx, `SELECT value, label FROM category_labels`) + if err != nil { + log.Printf("读类别显示名失败: %v", err) + return out + } + defer rows.Close() + for rows.Next() { + var v, l string + if err := rows.Scan(&v, &l); err != nil { + log.Printf("读类别显示名失败: %v", err) + return out + } + out[v] = l + } + return out +} + // placeDesc 把省市区拼成一段(相邻重复段只留一个,直辖市省名=市名时不会出现 // 「北京市北京市」这种),街道有值再接上。 func placeDesc(city, province, area, thoroughfare string) string { @@ -670,7 +730,7 @@ func (s *Server) editCategoryRename(w http.ResponseWriter, r *http.Request) { return } n, _ := res.RowsAffected() - s.json(w, map[string]any{"ok": true, "places": n, "to": to, "label": poiCategoryLabel(to)}) + s.json(w, map[string]any{"ok": true, "places": n, "to": to, "label": categoryLabel(to, s.categoryLabels(ctx))}) } // editCategoryClear 把某个类别从所有地点上摘掉(那些地点退回「未指定」)。 @@ -750,6 +810,37 @@ func (s *Server) editActivityMerge(w http.ResponseWriter, r *http.Request) { s.json(w, map[string]any{"ok": true, "visits": moved}) } +// editCategoryLabel 改类别在站内的**显示名**。跟改名/清空是两回事: +// 这里只动 category_labels,绝不碰 places.poi_category——显示名不会被导出写回手机。 +// 传空表示恢复内置默认名。 +func (s *Server) editCategoryLabel(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if err := r.ParseForm(); err != nil { + s.serverError(w, r, err) + return + } + value := strings.TrimSpace(r.PostFormValue("value")) + label := strings.TrimSpace(r.PostFormValue("label")) + if value == "" { + s.json(w, map[string]any{"error": "请指定要改名的类别值"}) + return + } + if label == "" { + if _, err := s.db.ExecContext(ctx, `DELETE FROM category_labels WHERE value=$1`, value); err != nil { + s.serverError(w, r, err) + return + } + s.json(w, map[string]any{"ok": true, "value": value, "label": poiCategoryLabel(value), "custom": false}) + return + } + if _, err := s.db.ExecContext(ctx, `INSERT INTO category_labels (value, label) VALUES ($1,$2) + ON CONFLICT (value) DO UPDATE SET label=EXCLUDED.label, updated_at=now()`, value, label); err != nil { + s.serverError(w, r, err) + return + } + s.json(w, map[string]any{"ok": true, "value": value, "label": label, "custom": true}) +} + // editPlaceCreate 是最前面那个融合表单(新建地点 / 给已有地点补到访)的处理函数。 func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/web/handlers_export.go b/internal/web/handlers_export.go index cdb74bb..6dda385 100644 --- a/internal/web/handlers_export.go +++ b/internal/web/handlers_export.go @@ -92,6 +92,7 @@ func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) { return } var b strings.Builder + catNames := s.categoryLabels(r.Context()) b.WriteString(`` + "\n") b.WriteString(`` + "\n") for _, p := range places { @@ -100,7 +101,7 @@ func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) { parts = append(parts, p.Province, p.City) } if p.POICategory != "" { - parts = append(parts, poiName(p.POICategory)) + parts = append(parts, categoryLabel(p.POICategory, catNames)) } desc := strings.Join(strings.Fields(strings.Join(parts, " · ")), " ") var last string diff --git a/internal/web/handlers_photos.go b/internal/web/handlers_photos.go index 8b70a57..a0a09d1 100644 --- a/internal/web/handlers_photos.go +++ b/internal/web/handlers_photos.go @@ -51,6 +51,7 @@ func (s *Server) adminPhotosPage(w http.ResponseWriter, r *http.Request) { p.Flatpickr = true p.HasMap = true d := PhotoData{Page: p, GeocodeReady: s.cfg.GeocodeKey != ""} + catNames := s.categoryLabels(r.Context()) ds, err := s.datasetFor(r.Context()) if err != nil { s.serverError(w, r, err) @@ -81,7 +82,7 @@ func (s *Server) adminPhotosPage(w http.ResponseWriter, r *http.Request) { s.serverError(w, r, err) return } - d.Categories = append(d.Categories, EditOption{Label: c, Name: poiCategoryLabel(c)}) + d.Categories = append(d.Categories, EditOption{Label: c, Name: categoryLabel(c, catNames)}) } crows.Close() } diff --git a/internal/web/handlers_public.go b/internal/web/handlers_public.go index 2e98e91..cbf33f7 100644 --- a/internal/web/handlers_public.go +++ b/internal/web/handlers_public.go @@ -527,6 +527,7 @@ func (s *Server) placePage(w http.ResponseWriter, r *http.Request) { return } d := PlaceData{Page: p, Place: place, Visits: visits, Points: []MapPoint{toPoint(place)}} + d.CategoryLabel = categoryLabel(place.POICategory, s.categoryLabels(r.Context())) d.Title = place.Name // 备注保存后跳回本页并带上 ok,只有站长能看到这句提示 if p.IsAdmin() { diff --git a/internal/web/server.go b/internal/web/server.go index 9d6884d..bd17084 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -259,6 +259,7 @@ func (s *Server) routes() { 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)) + s.mux.HandleFunc("POST /admin/edit/category/label", s.requireAdmin(s.editCategoryLabel)) // 照片导入: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 index cde4d52..d3e0e85 100644 --- a/internal/web/taxonomy_test.go +++ b/internal/web/taxonomy_test.go @@ -23,6 +23,8 @@ func taxonomyPost(t *testing.T, f *movementFixture, path string, form url.Values f.srv.editCategoryClear(rec, ctx) case "/admin/edit/activity/merge": f.srv.editActivityMerge(rec, ctx) + case "/admin/edit/category/label": + f.srv.editCategoryLabel(rec, ctx) default: t.Fatalf("未接线的端点 %s", path) } @@ -173,3 +175,105 @@ func TestActivityMergeRefusesDegenerateInput(t *testing.T) { t.Fatal("被拒绝的合并不该动数据") } } + +// 显示名的兜底顺序:站点自定义 > 内置表 > 去掉前缀的原值。 +// 「还没有中文名」只该命中「是 MapKit 枚举、但内置表里没有」这一种—— +// 中文值(站内自己写的)自带名字,不该被提示去补。 +func TestCategoryLabelFallbacks(t *testing.T) { + if got := categoryLabel("MKPOICategoryBeach", nil); got != "海滩" { + t.Errorf("内置名丢了: %q", got) + } + if got := categoryLabel("MKPOICategoryBeach", map[string]string{"MKPOICategoryBeach": "海边"}); got != "海边" { + t.Errorf("自定义应盖过内置名,实际 %q", got) + } + if got := categoryLabel("MKPOICategoryNope", nil); got != "Nope" { + t.Errorf("内置表没有的枚举应去掉前缀,实际 %q", got) + } + if got := categoryLabel("一般道路", nil); got != "一般道路" { + t.Errorf("中文值应原样返回,实际 %q", got) + } + for v, want := range map[string]bool{ + "MKPOICategoryNope": true, "MKPOICategoryBeach": false, "一般道路": false, + } { + if got := categoryLabelMissing(v); got != want { + t.Errorf("categoryLabelMissing(%q)=%v,期望 %v", v, got, want) + } + } +} + +// 关键不变量:改「显示名」只影响显示,places.poi_category 一个字都不能动—— +// 那才是导出回手机的类别值,中文名跟着写进去就把数据污染了。 +func TestCategoryLabelNeverTouchesValue(t *testing.T) { + f := newMovementFixture(t) + f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category) + VALUES ($1,692001,'显示名测试点',30,120,'MKPOICategoryBeach')`, f.dsID) + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=692001`, f.dsID) + f.pg.ExecContext(f.ctx, `DELETE FROM category_labels WHERE value='MKPOICategoryBeach'`) + }) + + if got := taxonomyPost(t, f, "/admin/edit/category/label", url.Values{ + "value": {"MKPOICategoryBeach"}, "label": {"海边"}, + }); got["error"] != nil { + t.Fatalf("改显示名失败: %v", got["error"]) + } + if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk=692001 + AND poi_category='MKPOICategoryBeach'`, f.dsID); n != 1 { + t.Fatal("改显示名不能碰 poi_category——那是要导出回手机的类别值") + } + if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='海边'`, f.dsID); n != 0 { + t.Fatal("中文名不该被写成类别值") + } +} + +// 覆盖要贯穿到各处的中文名(补全页下拉、编辑页台账),清空则恢复内置默认名。 +func TestCategoryLabelOverridesEverywhereThenResets(t *testing.T) { + f := newMovementFixture(t) + // 造一个地点让这个类别「在用」——台账里只会列出真在用的值 + f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category) + VALUES ($1,692011,'显示名测试点',30,120,'MKPOICategoryBeach')`, f.dsID) + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=692011`, f.dsID) + f.pg.ExecContext(f.ctx, `DELETE FROM category_labels WHERE value='MKPOICategoryBeach'`) + }) + render := func(h http.HandlerFunc, path string) string { + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest("GET", path, nil).WithContext(f.ctx)) + if rec.Code != http.StatusOK { + t.Fatalf("%s 渲染失败: %d", path, rec.Code) + } + return rec.Body.String() + } + + // 没覆盖时用内置名,并把它当成「留空时的默认值」露出来 + if body := render(f.srv.editPage, "/admin/edit"); !strings.Contains(body, "留空=默认「海滩」") { + t.Fatal("未自定义时应把内置默认名显示出来") + } + + if got := taxonomyPost(t, f, "/admin/edit/category/label", url.Values{ + "value": {"MKPOICategoryBeach"}, "label": {"海边"}, + }); got["custom"] != true { + t.Fatalf("应报告已自定义,实际 %v", got) + } + edit := render(f.srv.editPage, "/admin/edit") + if !strings.Contains(edit, `value="海边"`) || !strings.Contains(edit, "已自定义") { + t.Error("编辑页台账应显示自定义的显示名") + } + fill := render(f.srv.adminFillupPage, "/admin/fillup") + if !strings.Contains(fill, "海边(在用 ") { + t.Error("补全页下拉的中文名也该跟着走(两套表已并成一套)") + } + + // 传空 = 恢复内置默认名,覆盖行要删掉 + if got := taxonomyPost(t, f, "/admin/edit/category/label", url.Values{ + "value": {"MKPOICategoryBeach"}, "label": {" "}, + }); got["custom"] != false { + t.Fatalf("清空应报告已恢复默认,实际 %v", got) + } + if n := f.scalar(t, `SELECT count(*) FROM category_labels WHERE value='MKPOICategoryBeach'`); n != 0 { + t.Fatal("恢复默认时覆盖行应被删掉,而不是留一条空的") + } + if body := render(f.srv.adminFillupPage, "/admin/fillup"); strings.Contains(body, "海边") { + t.Error("恢复默认后不该再出现自定义名") + } +} diff --git a/internal/web/templates/admin/edit.html b/internal/web/templates/admin/edit.html index 2ffcce7..d118737 100644 --- a/internal/web/templates/admin/edit.html +++ b/internal/web/templates/admin/edit.html @@ -69,8 +69,17 @@ {{range .CategoryRows}} - -
{{.Label}}
+ + + + {{if .Missing}}还没有中文名 + {{else if .Custom}}已自定义{{end}} {{num .Places}} @@ -1341,6 +1350,13 @@ reload('已把 ' + count(tr, '地点') + ' 个地点的类别改成「' + (d.label || v) + '」'); }); }); + var labelInput = tr.querySelector('input[name="cat-label"]'); + tr.querySelector('[data-act="label"]').addEventListener('click', function () { + var v = (labelInput.value || '').trim(); + post('/admin/edit/category/label', { value: from, label: v }, function (d) { + reload(d.custom ? '显示名已改成「' + d.label + '」' : '已恢复默认显示名「' + d.label + '」'); + }); + }); tr.querySelector('[data-act="clear"]').addEventListener('click', function () { var n = count(tr, '地点'); if (!window.confirm('清空 ' + n + ' 个地点的「' + from + '」类别?\n' + diff --git a/internal/web/templates/admin/fillup.html b/internal/web/templates/admin/fillup.html index 2b9bb65..806b902 100644 --- a/internal/web/templates/admin/fillup.html +++ b/internal/web/templates/admin/fillup.html @@ -68,7 +68,7 @@ {{$hint := $row.CatHint}} diff --git a/internal/web/templates/place.html b/internal/web/templates/place.html index 5c8ad55..53bbe84 100644 --- a/internal/web/templates/place.html +++ b/internal/web/templates/place.html @@ -7,7 +7,7 @@ {{if .Place.City}}{{.Place.City}}{{end}} {{if .Place.Province}} · {{.Place.Province}}{{end}} {{if .Place.District}} · {{.Place.District}}{{end}} - {{if .Place.POICategory}} · {{poiname .Place.POICategory}}{{end}} + {{if .CategoryLabel}} · {{.CategoryLabel}}{{end}} · {{f1 .Place.Lat}}, {{f1 .Place.Lon}} diff --git a/internal/web/view.go b/internal/web/view.go index 8e48e5f..44bdd45 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -537,6 +537,9 @@ type PlaceData struct { Place stats.Place Visits []stats.VisitRow Points []MapPoint + // CategoryLabel 是类别的显示名(站点自定义的优先)。类别的中文名在模板里算不出来—— + // 它要读站点覆盖,所以在这里先渲染好。 + CategoryLabel string // Verdict 是站长官方结论(1=推荐 / -1=踩雷 / 0=未标);Votes 是访客票数; // MyVote 是当前访客自己对这一地点的投票。 Verdict int @@ -908,10 +911,13 @@ type EditActivity struct { // EditCategory 是类别台账的一行。类别没有表,值就是 places.poi_category, // 所以「改名 / 清空」都是按值批量作用到地点上——改成另一个已有值就是合并。 +// Label 是显示名(站点自定义的优先),Custom 表示它被改过,Missing 表示还没有中文名。 type EditCategory struct { - Value string - Label string - Places int + Value string + Label string + Places int + Custom bool + Missing bool } type EditPlace struct { @@ -1279,33 +1285,6 @@ func colorOf(name string) string { return "#5b6169" } -var poiNames = map[string]string{ - "MKPOICategoryStore": "商店", "MKPOICategoryRestaurant": "餐厅", "MKPOICategoryCafe": "咖啡厅", - "MKPOICategoryPublicTransport": "公共交通", "MKPOICategoryHotel": "酒店", "MKPOICategoryLandmark": "地标", - "MKPOICategoryFoodMarket": "食品市场", "MKPOICategoryMuseum": "博物馆", "MKPOICategoryZoo": "动物园", - "MKPOICategoryBeach": "海滩", "MKPOICategoryCampground": "露营地", "MKPOICategoryStadium": "体育场", - "MKPOICategoryPark": "公园", "MKPOICategoryNationalPark": "国家公园", "MKPOICategorySchool": "学校", - "MKPOICategoryPharmacy": "药店", "MKPOICategoryHospital": "医院", "MKPOICategorySpa": "水疗", - "MKPOICategoryAirport": "机场", "MKPOICategoryMarina": "码头", "MKPOICategoryPostOffice": "邮局", - "MKPOICategoryMarina2": "码头", - - "MKPOICategoryBakery": "面包店", "MKPOICategoryLibrary": "图书馆", - "MKPOICategoryUniversity": "大学", "MKPOICategoryAquarium": "水族馆", - "MKPOICategoryFitnessCenter": "健身中心", "MKPOICategoryMovieTheater": "电影院", - "MKPOICategoryTheater": "剧院", "MKPOICategoryParking": "停车场", - "MKPOICategoryBank": "银行", "MKPOICategoryAtm": "取款机", - "MKPOICategoryPolice": "派出所", "MKPOICategoryCarRental": "租车", - "MKPOICategoryGasStation": "加油站", "MKPOICategoryAmusementPark": "游乐园", - "MKPOICategoryRestroom": "洗手间", "MKPOICategoryFireStation": "消防站", -} - -func poiName(s string) string { - if v, ok := poiNames[s]; ok { - return v - } - return s -} - // iconOf 只保留 emoji 图标。rond 里的图标多为 SF Symbols 名称(如 house.fill), // 在网页上无法渲染,直接返回空串让模板退回默认样式。 func iconOf(s string) string { @@ -1347,8 +1326,6 @@ func templateFuncs() template.FuncMap { "dt": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }, // actKey 给活动冲突生成表单字段名,与处理函数共用同一套键 "actKey": rond.ActivityKey, - // catName 把 MKPOICategoryXxx 翻成中文,补全页的类别下拉要用 - "catName": poiName, // dtt 接受 *time.Time,nil 返回空串,便于可选时间字段直接渲染 "dtt": func(t *time.Time) string { if t == nil { @@ -1387,7 +1364,6 @@ func templateFuncs() template.FuncMap { }, "safe": func(s string) template.HTML { return template.HTML(s) }, "colorof": colorOf, - "poiname": poiName, "icon": iconOf, "iconor": iconOr, "condition": stats.ConditionName,