Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693ee82ab8 | ||
|
|
82e63412c0 |
@@ -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 的一格。
|
||||
|
||||
+14
-10
@@ -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
|
||||
|
||||
+140
-34
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -133,6 +134,8 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// 已有类别去重:新建表单、每行编辑的下拉、顶部类别台账都用它(提交值仍是原始标识符);
|
||||
// 顺手带上地点数,台账要按用量排序、也要知道改一下会影响多少行
|
||||
catNames := s.categoryLabels(ctx)
|
||||
d.KnownCategories = knownCategoryNames()
|
||||
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 +150,13 @@ 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)})
|
||||
// Missing 是「还得你补一个中文名」:自己是枚举、内置表没有**且**你也没填过。
|
||||
// 只看内置表会把已经填好的又标成「还没有中文名」(2026-09-20 用户就这么撞上了)。
|
||||
d.CategoryRows = append(d.CategoryRows, EditCategory{
|
||||
Value: c, Label: categoryLabel(c, catNames), Places: n,
|
||||
Custom: catNames[c] != "", Missing: categoryLabelMissing(c) && catNames[c] == "",
|
||||
})
|
||||
}
|
||||
crows.Close()
|
||||
|
||||
@@ -203,7 +211,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 +445,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 +456,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 +479,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 +539,56 @@ 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
|
||||
}
|
||||
|
||||
// knownCategoryNames 列出内置表认得的枚举名(排序后)。页面改「导出值」时拿它提醒:
|
||||
// 真机只认它自己那套枚举,拼错的串站内看不出问题,导回手机后那个地点就显示不出类别了。
|
||||
func knownCategoryNames() []string {
|
||||
out := make([]string, 0, len(poiCategoryText))
|
||||
for k := range poiCategoryText {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// 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 +745,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 +825,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()
|
||||
|
||||
@@ -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(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
|
||||
b.WriteString(`<gpx version="1.1" creator="rond-with-you" xmlns="http://www.topografix.com/GPX/1/1">` + "\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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestInlineScriptHasNoMultilineStrings 守住「模板里的内联 JS 不许出现跨行字符串」。
|
||||
//
|
||||
// JS 的单引号字符串不能含裸换行;真出现一个,那一整块 <script> 会语法错误、
|
||||
// **整段交互静默失效**——页面没有任何提示,控制台之外看不出来,
|
||||
// 而 TestPageTemplatesParse 照样通过(模板本身是合法的)。
|
||||
//
|
||||
// 2026-09-20 就是这么栽的:用脚本改模板时 `\n` 被多转义了一层,写进去成了真换行,
|
||||
// 当时如果这条测试在,一眼就能看到。
|
||||
func TestInlineScriptHasNoMultilineStrings(t *testing.T) {
|
||||
checked := 0
|
||||
err := fs.WalkDir(tmplFS, "templates", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || filepath.Ext(path) != ".html" {
|
||||
return err
|
||||
}
|
||||
raw, err := fs.ReadFile(tmplFS, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, blk := range scriptBlocks(string(raw)) {
|
||||
checked++
|
||||
if bad, ok := unterminatedString(blk.text); ok {
|
||||
t.Errorf("%s:%d 内联 JS 里的字符串跨行了(第 %d 行起的这段):字符串不能含裸换行,"+
|
||||
"否则整块 <script> 语法错误、页面上所有交互静默失效。\n%s",
|
||||
path, blk.line+bad.line-1, blk.line+bad.line-1, strings.TrimSpace(bad.text))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("遍历模板失败: %v", err)
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("一个内联 <script> 块都没扫到:模板目录或写法变了?")
|
||||
}
|
||||
t.Logf("扫了 %d 个内联脚本块", checked)
|
||||
}
|
||||
|
||||
type scriptBlock struct {
|
||||
line int // 块内容在整个文件里的起始行号
|
||||
text string // <script> 与 </script> 之间的内容
|
||||
}
|
||||
|
||||
// scriptBlocks 抠出每个 <script> 块的内容与它在文件里的起始行号。
|
||||
func scriptBlocks(s string) []scriptBlock {
|
||||
var out []scriptBlock
|
||||
line := 1
|
||||
for {
|
||||
i := strings.Index(s, "<script")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
line += strings.Count(s[:i], "\n")
|
||||
j := strings.Index(s[i:], ">")
|
||||
if j < 0 {
|
||||
break
|
||||
}
|
||||
line += strings.Count(s[i:i+j+1], "\n")
|
||||
rest := s[i+j+1:]
|
||||
end := strings.Index(rest, "</script>")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
start := line
|
||||
out = append(out, scriptBlock{line: start, text: rest[:end]})
|
||||
line += strings.Count(rest[:end], "\n") + strings.Count("</script>", "\n") + 1
|
||||
s = rest[end:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unterminatedString 找「单/双引号字符串里出现裸换行」的第一处。
|
||||
// 跳过 // 行注释、/* */ 块注释、反引号模板串,以及 Go 模板动作 {{…}}
|
||||
// (动作里可能带引号,不该被当成 JS 字符串的边界)。
|
||||
func unterminatedString(js string) (scriptBlock, bool) {
|
||||
var (
|
||||
quote byte // 当前所在字符串的引号,0 表示不在字符串里
|
||||
tick bool // 反引号模板串
|
||||
blockC bool // /* */ 里
|
||||
action int // 处于 {{…}} 里的深度
|
||||
line = 1
|
||||
)
|
||||
for i := 0; i < len(js); i++ {
|
||||
c := js[i]
|
||||
if c == '\n' {
|
||||
line++
|
||||
if quote != 0 {
|
||||
from := strings.LastIndex(js[:i], "\n") + 1
|
||||
return scriptBlock{line: line, text: js[from:i]}, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if blockC {
|
||||
if c == '*' && i+1 < len(js) && js[i+1] == '/' {
|
||||
blockC = false
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if action > 0 {
|
||||
if c == '}' && i+1 < len(js) && js[i+1] == '}' {
|
||||
action = 0
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if tick {
|
||||
if c == '`' {
|
||||
tick = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if quote != 0 {
|
||||
if c == '\\' {
|
||||
i++
|
||||
} else if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case c == '{' && i+1 < len(js) && js[i+1] == '{':
|
||||
action++
|
||||
i++
|
||||
case c == '/' && i+1 < len(js) && js[i+1] == '/':
|
||||
nl := strings.IndexByte(js[i:], '\n')
|
||||
if nl < 0 {
|
||||
return scriptBlock{}, false
|
||||
}
|
||||
line += strings.Count(js[i:i+nl], "\n")
|
||||
i += nl
|
||||
case c == '/' && i+1 < len(js) && js[i+1] == '*':
|
||||
blockC = true
|
||||
i++
|
||||
case c == '`':
|
||||
tick = true
|
||||
case c == '\'' || c == '"':
|
||||
quote = c
|
||||
}
|
||||
}
|
||||
return scriptBlock{}, false
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,146 @@ 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("恢复默认后不该再出现自定义名")
|
||||
}
|
||||
}
|
||||
|
||||
// 填过显示名之后就不该再标「还没有中文名」——判断得看覆盖,不能只看内置表。
|
||||
// (2026-09-20 用户把「租车点」改成拼错的 MKPOICategoryCarRent、又手填了显示名,
|
||||
// 页面却还挂着「还没有中文名」,就是这么来的。)
|
||||
func TestCategoryLabelMissingRespectsOverride(t *testing.T) {
|
||||
const val = "MKPOICategoryCarRent" // 真机没有这个枚举,内置表自然也没有
|
||||
f := newMovementFixture(t)
|
||||
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
|
||||
VALUES ($1,692021,'显示名测试点',30,120,$2)`, f.dsID, val)
|
||||
t.Cleanup(func() {
|
||||
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=692021`, f.dsID)
|
||||
f.pg.ExecContext(f.ctx, `DELETE FROM category_labels WHERE value=$1`, val)
|
||||
})
|
||||
body := func() string {
|
||||
rec := httptest.NewRecorder()
|
||||
f.srv.editPage(rec, httptest.NewRequest("GET", "/admin/edit", nil).WithContext(f.ctx))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("渲染失败: %d", rec.Code)
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
if b := body(); !strings.Contains(b, "还没有中文名") || !strings.Contains(b, "填一个中文名") {
|
||||
t.Fatal("既不在内置表、也没填过,应当提示去补一个中文名")
|
||||
}
|
||||
if got := taxonomyPost(t, f, "/admin/edit/category/label", url.Values{
|
||||
"value": {val}, "label": {"租车"},
|
||||
}); got["custom"] != true {
|
||||
t.Fatalf("应报告已自定义,实际 %v", got)
|
||||
}
|
||||
b := body()
|
||||
if strings.Contains(b, "还没有中文名") {
|
||||
t.Error("已经填过显示名了,不该再说「还没有中文名」")
|
||||
}
|
||||
if !strings.Contains(b, "已自定义") {
|
||||
t.Error("填过显示名之后应当标成「已自定义」")
|
||||
}
|
||||
if !strings.Contains(b, `value="租车"`) {
|
||||
t.Error("显示名框应回填已保存的名字")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,17 @@
|
||||
{{range .CategoryRows}}
|
||||
<tr data-value="{{.Value}}">
|
||||
<td data-label="类别">
|
||||
<label class="f" style="margin-bottom:6px"><span>导出值</span>
|
||||
<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>
|
||||
</label>
|
||||
<label class="f" style="margin-bottom:6px"><span>显示名</span>
|
||||
<input type="text" name="cat-label" value="{{if .Custom}}{{.Label}}{{end}}"
|
||||
placeholder="{{if .Missing}}填一个中文名{{else}}留空=默认「{{.Label}}」{{end}}"
|
||||
style="min-width:12em">
|
||||
</label>
|
||||
<button class="btn sm" type="button" data-act="label">保存显示名</button>
|
||||
{{if .Missing}}<span class="muted">还没有中文名</span>
|
||||
{{else if .Custom}}<span class="muted">已自定义</span>{{end}}
|
||||
</td>
|
||||
<td data-label="地点">{{num .Places}}</td>
|
||||
<td class="ops">
|
||||
@@ -1330,6 +1339,41 @@
|
||||
});
|
||||
});
|
||||
|
||||
// 内置表认得的类别枚举。改「导出值」时拿它比对:真机只认它自己那套枚举名,
|
||||
// 拼错的串(比如 CarRent 少了 al)站内看不出任何问题,导回手机后那个地点就显示不出类别。
|
||||
var KNOWN = [{{range .KnownCategories}}"{{.}}",{{end}}];
|
||||
|
||||
function enumTypo(v) {
|
||||
if (KNOWN.indexOf(v) >= 0) return null;
|
||||
for (var i = 0; i < KNOWN.length; i++) {
|
||||
var k = KNOWN[i];
|
||||
if (k.indexOf(v) === 0 || v.indexOf(k) === 0) return k; // 「你是不是想写这个」
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// 要往 places.poi_category 写一个没记录过的枚举时拦一下。返回 true 表示可以继续。
|
||||
function okEnum(v) {
|
||||
if (!/^MKPOICategory/.test(v)) return true;
|
||||
var near = enumTypo(v);
|
||||
if (near === null) return true;
|
||||
var tip = near ? '\n\n你是不是想写「' + near + '」?' : '';
|
||||
return window.confirm('「' + v + '」不在已知的类别枚举里。' + tip +
|
||||
'\n\n真机只认它自己那套枚举名:写错的串在站内看着一切正常,' +
|
||||
'但导出回手机后这个地点就显示不出类别了。');
|
||||
}
|
||||
|
||||
// 地点行内编辑里的「自定义类别」写的是同一列、同样的坑,用同一套提醒。
|
||||
// 用捕获阶段监听:地点那句「批量改到访活动」的确认也走捕获,两边互不影响。
|
||||
document.addEventListener('submit', function (e) {
|
||||
var f = e.target;
|
||||
if (!f || !f.querySelector) return;
|
||||
var sel = f.querySelector('select.cat-select');
|
||||
if (!sel || sel.value !== '__custom__') return;
|
||||
var box = f.querySelector('.cat-custom input');
|
||||
if (!okEnum(box ? (box.value || '').trim() : '')) e.preventDefault();
|
||||
}, true);
|
||||
|
||||
document.querySelectorAll('#cat-rows tr').forEach(function (tr) {
|
||||
var input = tr.querySelector('input[name="cat-value"]');
|
||||
var from = tr.dataset.value;
|
||||
@@ -1337,10 +1381,18 @@
|
||||
var v = (input.value || '').trim();
|
||||
if (!v) { toast('类别不能为空;要摘掉请用「清空」', true); return; }
|
||||
if (v === from) { toast('类别没变', true); return; }
|
||||
if (!okEnum(v)) return;
|
||||
post('/admin/edit/category/rename', { from: from, to: v }, function (d) {
|
||||
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' +
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
{{$hint := $row.CatHint}}
|
||||
<select name="cat_{{$row.PlaceID}}" class="fill-sel" data-hint="{{$hint}}" style="min-width:10em">
|
||||
<option value="">(不动)</option>
|
||||
{{if $hint}}<option value="{{$hint}}">建议:{{catName $hint}}</option>{{end}}
|
||||
{{if $hint}}<option value="{{$hint}}">建议:{{$row.CatLabel}}</option>{{end}}
|
||||
{{range $.Categories}}{{if ne .Value $hint}}<option value="{{.Value}}">{{.Label}}</option>{{end}}{{end}}
|
||||
<option value="__custom__">其它(自己写)…</option>
|
||||
</select>
|
||||
|
||||
@@ -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}}
|
||||
· <span class="mono">{{f1 .Place.Lat}}, {{f1 .Place.Lon}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+8
-30
@@ -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
|
||||
@@ -750,6 +753,8 @@ type EditData struct {
|
||||
PlaceOptions []EditOption
|
||||
VisitOptions []EditOption // 最近到访(值=src_pk),供行程/天气表单选关联
|
||||
Categories []EditOption // 已有地点的类别(去重),供新建时下拉直选
|
||||
// KnownCategories 是内置表认得的类别枚举名,页面改「导出值」时拿它做提醒
|
||||
KnownCategories []string
|
||||
// ActivityRows / CategoryRows 是顶部「活动与类别」卡片用的台账(带用量)
|
||||
ActivityRows []EditActivity
|
||||
CategoryRows []EditCategory
|
||||
@@ -908,10 +913,13 @@ type EditActivity struct {
|
||||
|
||||
// EditCategory 是类别台账的一行。类别没有表,值就是 places.poi_category,
|
||||
// 所以「改名 / 清空」都是按值批量作用到地点上——改成另一个已有值就是合并。
|
||||
// Label 是显示名(站点自定义的优先),Custom 表示它被改过,Missing 表示还没有中文名。
|
||||
type EditCategory struct {
|
||||
Value string
|
||||
Label string
|
||||
Places int
|
||||
Custom bool
|
||||
Missing bool
|
||||
}
|
||||
|
||||
type EditPlace struct {
|
||||
@@ -1279,33 +1287,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 +1328,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 +1366,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,
|
||||
|
||||
Reference in New Issue
Block a user