From 6c061a054897369e17f7e74cc10ed9e5dbe9fe96 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sat, 19 Sep 2026 16:31:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=B0=E7=82=B9=E8=A1=A5=E5=85=A8=EF=BC=9A?= =?UTF-8?q?=E6=8C=89=E5=9C=B0=E5=90=8D=E4=B8=8E=E5=9D=90=E6=A0=87=E7=8C=9C?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E4=B8=8E=E7=B1=BB=E5=88=AB=EF=BC=8C=E5=8F=AA?= =?UTF-8?q?=E5=A1=AB=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台新增「地点补全」页,把「名下到访没设活动」和「类别为空」的地点列出来, 给出建议、由站长确认后再写。判断分三层: - 本地关键词表:零延迟、离线,命中即高置信(「星巴克咖啡」→ 奶茶和甜甜水) - 高德周边搜索:按坐标问「这里是什么 POI」,比按名字搜准; 拿到的类型再映射成活动与 MapKit 类别 - 人工:都猜不出的留空,站长自己填;填了列表里没有的名字会自动建成新活动类型 两条底线:**只填空**(已有活动或类别的一律不动);**活动按地点批量设**, 一次把它名下所有没设活动的到访一起填上,否则同一个地点会在统计里分裂成两种活动。 类别写的是 Apple MapKit 的枚举名(真机确认用过 12 种),不是高德分类。 规则上踩过两个坑,都写进注释了:匹配前要先掐掉括号里的地址(否则 「尚优居客栈(大理古城步行街店)」会命中「步行街」),并且按词长优先 (否则「马鞍山公园」会命中「山」);单字关键词一律不要,误命中率太高。 --- internal/web/fillup.go | 436 +++++++++++++++++++++++ internal/web/fillup_rules.go | 203 +++++++++++ internal/web/fillup_rules_test.go | 108 ++++++ internal/web/fillup_test.go | 170 +++++++++ internal/web/geocode.go | 14 +- internal/web/server.go | 4 + internal/web/templates/admin/fillup.html | 79 ++++ internal/web/templates/admin/index.html | 1 + 8 files changed, 1012 insertions(+), 3 deletions(-) create mode 100644 internal/web/fillup.go create mode 100644 internal/web/fillup_rules.go create mode 100644 internal/web/fillup_rules_test.go create mode 100644 internal/web/fillup_test.go create mode 100644 internal/web/templates/admin/fillup.html diff --git a/internal/web/fillup.go b/internal/web/fillup.go new file mode 100644 index 0000000..d83d641 --- /dev/null +++ b/internal/web/fillup.go @@ -0,0 +1,436 @@ +package web + +import ( + "context" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "sync" +) + +// 地点补全:把「活动为空 / 类别为空」的地点捞出来,给一条建议,由站长确认后再写。 +// +// 两条底线贯穿这个文件: +// - **只填空**:SQL 里都带 `AND activity_id IS NULL` / `AND poi_category=''`, +// 已经有值的一律不动,改错了也只影响原本空着的地方; +// - **按地点批量**:填活动是把该地点名下**所有**没活动的到访一起设, +// 而不是只挑一条——否则同一个地点会在统计里分裂成两种活动。 + +// fillupHint 是一次高德查询给出的建议(按地点 id 缓存在内存里,页面重载仍能看到)。 +type fillupHint struct { + Act string + Cat string +} + +var ( + fillupMu sync.Mutex + fillupCache = map[int64]fillupHint{} +) + +func fillupCachePut(placeID int64, h fillupHint) { + fillupMu.Lock() + defer fillupMu.Unlock() + fillupCache[placeID] = h +} + +func fillupHintOf(placeID int64) (fillupHint, bool) { + fillupMu.Lock() + defer fillupMu.Unlock() + h, ok := fillupCache[placeID] + return h, ok +} + +// FillupRow 是补全页上的一行。 +type FillupRow struct { + PlaceID int64 + SrcPK int + Name string + City string + Visits int + NoAct int // 还没设活动的到访条数 + NoCat bool // 类别为空 + HasCoord bool + Lat, Lon float64 + ActHint string // 建议的活动名(空 = 没建议) + ActID int64 // 建议命中现有活动时的 id;0 表示得新建 + CatHint string // 建议的类别 + Source string // 建议从哪来 +} + +func (r FillupRow) Missing() string { + switch { + case r.NoAct > 0 && r.NoCat: + return "活动 + 类别" + case r.NoAct > 0: + return "活动" + default: + return "类别" + } +} + +// FillupData 是补全页的数据。 +type FillupData struct { + Page + Rows []FillupRow + Activities []activityOption // 下拉选项 + Categories []string // 类别下拉选项 + Total int + WithHint int +} + +// activityOption 只是给下拉用的(活动 id + 名字)。 +type activityOption struct { + ID int64 + Name string +} + +// fillupRows 捞出需要补的地点。判定口径: +// - 缺活动 = 该地点名下还有 activity_id 为空的到访(含「一条都没设」和「只设了一部分」); +// - 缺类别 = poi_category 为空。 +// +// 零到访的地点只要缺类别也会进来——它们没有到访可挂活动,补类别仍然有意义。 +func (s *Server) fillupRows(ctx context.Context, datasetID int64) ([]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,''), + (SELECT count(*) FROM visits v WHERE v.place_id = p.id AND v.activity_id IS NULL) + FROM places p + WHERE p.dataset_id = $1 + AND (COALESCE(p.poi_category,'') = '' + OR EXISTS (SELECT 1 FROM visits v WHERE v.place_id = p.id AND v.activity_id IS NULL)) + ORDER BY p.visit_count DESC, p.id`, datasetID) + if err != nil { + return nil, err + } + defer rows.Close() + // 活动名 -> id:用来判断建议是「选中现有的」还是「需要新建一个」 + actByName := map[string]int64{} + arows, err := s.db.QueryContext(ctx, `SELECT id, name FROM activities WHERE dataset_id=$1`, datasetID) + if err == nil { + for arows.Next() { + var id int64 + var name string + if arows.Scan(&id, &name) == nil { + actByName[name] = id + } + } + arows.Close() + } + var out []FillupRow + for rows.Next() { + var r FillupRow + var lat, lon float64 + var cat string + if err := rows.Scan(&r.PlaceID, &r.SrcPK, &r.Name, &r.City, &lat, &lon, &cat, &r.NoAct); err != nil { + return nil, err + } + r.NoCat = cat == "" + r.Lat, r.Lon = lat, lon + r.HasCoord = lat != 0 || lon != 0 + // 建议先走本地词表:不花钱、不联网、结果稳定 + if r.NoAct > 0 { + if a := guessActivityByName(r.Name); a != "" { + r.ActHint, r.Source, r.ActID = a, "词表", actByName[a] + } + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + // 补上高德那一轮的结果(如果有缓存),并把没建议的排后面 + for i := range out { + if h, ok := fillupHintOf(out[i].PlaceID); ok { + if out[i].ActHint == "" && h.Act != "" { + out[i].ActHint, out[i].Source, out[i].ActID = h.Act, "高德", actByName[h.Act] + } + if out[i].NoCat && h.Cat != "" { + out[i].CatHint = h.Cat + } + } + } + sort.SliceStable(out, func(i, j int) bool { + return (out[i].ActHint != "" || out[i].CatHint != "") && (out[j].ActHint == "" && out[j].CatHint == "") + }) + return out, nil +} + +func (s *Server) adminFillupPage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ds, err := s.datasetFor(ctx) + if err != nil { + s.serverError(w, r, err) + return + } + if ds == nil { + s.fillupFlash(w, r, "err", "还没有可补全的数据集") + return + } + p, err := s.pageBase(r, "admin", "地点补全") + if err != nil { + s.serverError(w, r, err) + return + } + p.SetFlashFromQuery(r) + rows, err := s.fillupRows(ctx, ds.ID) + if err != nil { + s.serverError(w, r, err) + return + } + acts, err := s.activityOptions(ctx, ds.ID) + if err != nil { + s.serverError(w, r, err) + return + } + d := FillupData{Page: p, Rows: rows, Activities: acts, Categories: fillupCategories(), Total: len(rows)} + for _, row := range rows { + if row.ActHint != "" || row.CatHint != "" { + d.WithHint++ + } + } + s.render(w, "admin/fillup", d) +} + +// activityOptions 列出该数据集的活动,供下拉选择。 +func (s *Server) activityOptions(ctx context.Context, datasetID int64) ([]activityOption, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, name FROM activities + WHERE dataset_id=$1 ORDER BY name`, datasetID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []activityOption + for rows.Next() { + var a activityOption + if err := rows.Scan(&a.ID, &a.Name); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// fillupCategories 是类别下拉的选项:真机用过的 12 种在前,其余常见 MapKit 类别在后。 +func fillupCategories() []string { + known := []string{ + "MKPOICategoryRestaurant", "MKPOICategoryStore", "MKPOICategoryPublicTransport", + "MKPOICategoryHotel", "MKPOICategoryLandmark", "MKPOICategoryFoodMarket", + "MKPOICategoryBeach", "MKPOICategoryMuseum", "MKPOICategoryCampground", + "MKPOICategoryZoo", "MKPOICategoryStadium", "MKPOICategoryPark", + } + extra := []string{ + "MKPOICategoryCafe", "MKPOICategoryBakery", "MKPOICategoryAirport", + "MKPOICategoryHospital", "MKPOICategoryPharmacy", "MKPOICategorySchool", + "MKPOICategoryUniversity", "MKPOICategoryLibrary", "MKPOICategoryParking", + "MKPOICategoryBank", "MKPOICategoryMovieTheater", "MKPOICategoryFitnessCenter", + "MKPOICategoryNationalPark", "MKPOICategoryAquarium", "MKPOICategoryAmusementPark", + "MKPOICategoryGasStation", "MKPOICategoryCarRental", "MKPOICategoryMarina", + } + return append(known, extra...) +} + +// adminFillupApply 执行补全。表单里每行两个字段: +// +// act_ = "" 不动 +// = 活动 id 设为该活动 +// = "__new__:名字" 新建这个活动再设 +// cat_ = "" 不动 +// = MKPOICategoryX 写进地点类别 +// +// 全部改动在一个事务里,失败整体回滚。 +func (s *Server) adminFillupApply(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ds, err := s.datasetFor(ctx) + if err != nil || ds == nil { + s.fillupFlash(w, r, "err", "还没有可补全的数据集") + return + } + if err := r.ParseForm(); err != nil { + s.serverError(w, r, err) + return + } + var acts, cats, newActs int + err = s.withTx(ctx, func(ex execer) error { + ids, err := s.fillupTargets(ctx, ex, ds.ID) + if err != nil { + return err + } + // 活动:先把要新建的建出来,再统一分配,避免同一批里重复建 + actByName := map[string]int64{} + actByID := map[int64]string{} + for _, a := range mustActivityOptions(ctx, ex, ds.ID) { + actByName[a.Name], actByID[a.ID] = a.ID, a.Name + } + + for _, pid := range ids { + // 框里填的是**活动名**:对得上就用现有的,对不上就新建一个 + if name := strings.TrimSpace(r.PostFormValue("act_" + strconv.FormatInt(pid, 10))); name != "" { + actID, ok := actByName[name] + if !ok { + pk, err := s.nextSrcPK(ctx, ex, ds.ID, activitySeq) + if err != nil { + return err + } + if err := ex.QueryRowContext(ctx, `INSERT INTO activities + (dataset_id, src_pk, name, color, icon, is_home, is_work, is_excluded, is_archived) + VALUES ($1,$2,$3,'','',false,false,false,false) RETURNING id`, + ds.ID, pk, name).Scan(&actID); err != nil { + return err + } + actByName[name] = actID + newActs++ + } + isHome, isWork := s.activityFlags(ctx, ex, actID) + res, err := ex.ExecContext(ctx, `UPDATE visits v SET activity_id=$1, is_home=$2, is_work=$3 + WHERE v.dataset_id=$4 AND v.activity_id IS NULL + AND v.place_id = (SELECT p.id FROM places p WHERE p.dataset_id=$4 AND p.id=$5)`, + actID, isHome, isWork, ds.ID, pid) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n > 0 { + acts += int(n) + } + } + if cat := strings.TrimSpace(r.PostFormValue("cat_" + strconv.FormatInt(pid, 10))); cat != "" { + res, err := ex.ExecContext(ctx, `UPDATE places SET poi_category=$1 + WHERE dataset_id=$2 AND id=$3 AND COALESCE(poi_category,'')=''`, cat, ds.ID, pid) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n > 0 { + cats += int(n) + } + } + } + return nil + }) + if err != nil { + s.fillupFlash(w, r, "err", "补全失败,已整体回滚:"+err.Error()) + return + } + if acts+cats == 0 { + s.fillupFlash(w, r, "ok", "没有选中任何要补的内容,数据未变") + return + } + msg := "补全完成" + if acts > 0 { + msg += ":为 " + strconv.Itoa(acts) + " 条到访设了活动" + } + if cats > 0 { + msg += ":为 " + strconv.Itoa(cats) + " 个地点补了类别" + } + if newActs > 0 { + msg += "(新建 " + strconv.Itoa(newActs) + " 个活动类型)" + } + s.fillupFlash(w, r, "ok", msg) +} + +// fillupTargets 返回本次提交涉及的地点 id(只认本数据集的,防止越权改别处)。 +func (s *Server) fillupTargets(ctx context.Context, ex execer, datasetID int64) ([]int64, error) { + rows, err := ex.QueryContext(ctx, `SELECT id FROM places WHERE dataset_id=$1`, datasetID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +func (s *Server) activityFlags(ctx context.Context, ex execer, actID int64) (isHome, isWork bool) { + ex.QueryRowContext(ctx, `SELECT is_home, is_work FROM activities WHERE id=$1`, actID).Scan(&isHome, &isWork) + return +} + +func mustActivityOptions(ctx context.Context, ex execer, datasetID int64) []activityOption { + rows, err := ex.QueryContext(ctx, `SELECT id, name FROM activities WHERE dataset_id=$1`, datasetID) + if err != nil { + return nil + } + defer rows.Close() + var out []activityOption + for rows.Next() { + var a activityOption + if err := rows.Scan(&a.ID, &a.Name); err == nil { + out = append(out, a) + } + } + return out +} + +// fillupAmapBatch 是一次「用高德分析」最多处理的条数。 +// 高德限流 10 QPS、每条一次请求,一批 40 条约 4 秒——再长就该担心请求超时了。 +const fillupAmapBatch = 40 + +// adminFillupAmap 对「还没有建议、有坐标、还没查过」的地点跑一次高德周边搜索, +// 把猜出来的活动与类别放进内存缓存,页面重载就能看到。 +// +// 用周边的坐标搜而不是按名字搜:地点名可能是后来反查补全出来的, +// 而坐标是它真实所在的位置,「这个坐标上是什么 POI」比「这个名字叫什么」准得多。 +func (s *Server) adminFillupAmap(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ds, err := s.datasetFor(ctx) + if err != nil || ds == nil { + s.fillupFlash(w, r, "err", "还没有可补全的数据集") + return + } + if s.cfg.GeocodeKey == "" { + s.fillupFlash(w, r, "err", "conf/app.ini 里没配 geocode_key,用不了高德") + return + } + rows, err := s.fillupRows(ctx, ds.ID) + if err != nil { + s.serverError(w, r, err) + return + } + var todo []FillupRow + for _, row := range rows { + if !row.HasCoord || row.ActHint != "" { + continue // 已经有建议、或者没坐标搜不了 + } + if _, done := fillupHintOf(row.PlaceID); done { + continue // 查过了(哪怕是没查着),别反复重试 + } + todo = append(todo, row) + } + if len(todo) == 0 { + s.fillupFlash(w, r, "ok", "没有需要再查的地点") + return + } + n, hits := 0, 0 + for _, row := range todo { + if n >= fillupAmapBatch { + break + } + _, typ, _, ok := s.amapAround(ctx, row.Lat, row.Lon) + var h fillupHint + if ok { + h.Act = guessActivityFromAmapType(typ) + h.Cat = guessCategoryFromAmapType(typ) + if h.Act != "" || h.Cat != "" { + hits++ + } + } + fillupCachePut(row.PlaceID, h) + n++ + } + msg := fmt.Sprintf("已用高德分析 %d 个,其中 %d 个猜出了结果", n, hits) + if left := len(todo) - n; left > 0 { + msg += fmt.Sprintf(";还剩 %d 个,再点一次继续", left) + } + s.fillupFlash(w, r, "ok", msg) +} + +// fillupFlash 把提示带回补全页本身(adminFlash 是跳后台首页的)。 +func (s *Server) fillupFlash(w http.ResponseWriter, r *http.Request, kind, msg string) { + http.Redirect(w, r, "/admin/fillup?"+kind+"="+urlQueryEscape(msg), http.StatusSeeOther) +} diff --git a/internal/web/fillup_rules.go b/internal/web/fillup_rules.go new file mode 100644 index 0000000..7c45091 --- /dev/null +++ b/internal/web/fillup_rules.go @@ -0,0 +1,203 @@ +package web + +import "strings" + +// 地点补全的判定规则:地名怎么猜活动、高德 POI 类型怎么猜活动和类别。 +// +// 两条原则: +// - **只填空**:调用方只在原值为空时才用这里的建议; +// - **拿不准就别猜**:宁可标成「待确认」让人看一眼,也不要瞎填一个看起来像样的值, +// 错的活动会直接污染统计与筛选,比空着更难发现。 +// +// 词表按**定义顺序**匹配,先命中的赢——所以具体的词要排在笼统的词前面。 + +// actHint 是一条「关键词 → 活动类型」的映射,关键词出现在地名里就算命中。 +type actHint struct{ kw, val string } + +// nameActHints 从地名猜活动。活动名必须与站点里已有的 31 个完全一致, +// 否则调用方会把它当成「需要新建的类型」。 +var nameActHints = []actHint{ + {"咖啡", "奶茶和甜甜水"}, {"奶茶", "奶茶和甜甜水"}, {"茶艺", "奶茶和甜甜水"}, + {"书店", "学习"}, {"图书馆", "学习"}, {"学校", "学习"}, {"大学", "学习"}, + {"博物馆", "纪念馆"}, {"纪念馆", "纪念馆"}, {"陈列馆", "纪念馆"}, + {"动物园", "动物园"}, {"海洋馆", "动物园"}, + {"沙滩", "沙滩"}, {"海滨", "沙滩"}, {"海滩", "沙滩"}, + {"度假区", "旅游景点"}, {"古镇", "旅游景点"}, {"景区", "旅游景点"}, + {"游客中心", "旅游景点"}, {"索道", "旅游景点"}, + {"公园", "散步"}, {"绿道", "散步"}, {"步道", "散步"}, {"广场", "散步"}, {"栈道", "散步"}, + {"步行街", "步行街"}, {"老街", "步行街"}, + {"小吃", "美食街"}, {"美食", "美食街"}, {"夜市", "美食街"}, {"大排档", "美食街"}, + {"餐厅", "餐厅"}, {"饭店", "餐厅"}, {"酒楼", "餐厅"}, {"食府", "餐厅"}, {"火锅", "餐厅"}, + {"菜市场", "市场"}, {"农贸", "市场"}, {"市场", "市场"}, + {"超市", "小商店"}, {"便利店", "小商店"}, {"小卖部", "小商店"}, {"商店", "小商店"}, + {"商场", "商场"}, {"购物", "商场"}, {"百货", "商场"}, + {"公交", "公共交通"}, {"地铁", "公共交通"}, {"车站", "公共交通"}, {"机场", "公共交通"}, + {"高铁", "公共交通"}, {"码头", "公共交通"}, {"客运", "公共交通"}, + {"民宿", "酒店"}, {"客栈", "酒店"}, {"宾馆", "酒店"}, {"酒店", "酒店"}, {"度假", "酒店"}, + {"医院", "一般公共场所"}, {"诊所", "一般公共场所"}, {"政务", "一般公共场所"}, {"服务中心", "一般公共场所"}, + {"体育", "户外活动"}, {"运动", "户外活动"}, {"健身", "户外活动"}, {"露营", "户外活动"}, + {"骑行", "骑行目的地"}, {"自行车", "骑行目的地"}, + {"租车", "租车"}, {"汽车租赁", "租车"}, + {"彩票", "彩票购买点"}, + {"4S", "车辆维修"}, {"维修", "车辆维修"}, {"汽修", "车辆维修"}, + {"停车", "暂且休息"}, {"服务区", "暂且休息"}, {"休息区", "暂且休息"}, + {"公司", "工作"}, {"办公", "工作"}, {"产业园", "工作"}, {"工厂", "工作"}, + {"塔", "地标"}, {"大桥", "地标"}, {"地标", "地标"}, {"牌坊", "地标"}, + {"乡镇", "乡镇"}, {"村委会", "乡镇"}, {"街道办", "乡镇"}, + {"山峰", "山"}, {"峡谷", "山"}, {"雪山", "山"}, +} + +// amapActHints 从高德 POI 类型猜活动(高德给的是「大类;小类」形式,按子串匹配)。 +var amapActHints = []actHint{ + {"咖啡厅", "奶茶和甜甜水"}, {"茶艺馆", "奶茶和甜甜水"}, {"冷饮店", "奶茶和甜甜水"}, + {"图书馆", "学习"}, {"学校", "学习"}, {"高等院校", "学习"}, {"中学", "学习"}, {"小学", "学习"}, + {"博物馆", "纪念馆"}, {"展览馆", "纪念馆"}, {"科教文化", "纪念馆"}, + {"动物园", "动物园"}, {"水族馆", "动物园"}, + {"海滩", "沙滩"}, {"海滨浴场", "沙滩"}, + {"风景名胜", "旅游景点"}, {"公园广场", "旅游景点"}, {"寺庙道观", "旅游景点"}, + {"公园", "散步"}, {"城市广场", "散步"}, + {"特色街区", "步行街"}, + {"小吃快餐", "美食街"}, {"美食街", "美食街"}, {"餐饮相关", "美食街"}, + {"中餐厅", "餐厅"}, {"外国餐厅", "餐厅"}, {"餐厅", "餐厅"}, {"快餐厅", "餐厅"}, + {"综合市场", "市场"}, {"农副产品市场", "市场"}, {"菜市场", "市场"}, + {"便利店", "小商店"}, {"超市", "小商店"}, {"专卖店", "小商店"}, {"小商品市场", "小商店"}, + {"商场", "商场"}, {"购物中心", "商场"}, {"百货商场", "商场"}, + {"地铁站", "公共交通"}, {"公交车站", "公共交通"}, {"火车站", "公共交通"}, + {"机场", "公共交通"}, {"港口码头", "公共交通"}, {"长途汽车站", "公共交通"}, + {"宾馆酒店", "酒店"}, {"旅馆招待所", "酒店"}, {"度假村", "酒店"}, + {"综合医院", "一般公共场所"}, {"专科医院", "一般公共场所"}, {"诊所", "一般公共场所"}, + {"政府机关", "一般公共场所"}, {"社会团体", "一般公共场所"}, + {"运动场馆", "户外活动"}, {"健身中心", "户外活动"}, {"露营地", "户外活动"}, + {"汽车租赁", "租车"}, {"汽车维修", "车辆维修"}, {"汽车服务", "车辆维修"}, + {"彩票", "彩票购买点"}, + {"停车场", "暂且休息"}, {"服务区", "暂且休息"}, + {"公司", "工作"}, {"写字楼", "工作"}, {"产业园区", "工作"}, + {"旅游景点", "旅游景点"}, + {"商务住宅", "家"}, {"住宅区", "家"}, + + // 一级大类兜底 + {"餐饮服务", "餐厅"}, + {"购物服务", "商场"}, + {"住宿服务", "酒店"}, + {"交通设施服务", "公共交通"}, +} + +// mkHints 从高德 POI 类型猜 MapKit 类别。 +// +// 值必须是真机能认的枚举名(`MKPOICategory` + 驼峰)。已确认真机用过的是这 12 种: +// Restaurant / Store / PublicTransport / Hotel / Landmark / FoodMarket / Beach / +// Museum / Campground / Zoo / Stadium / Park。其余取 MapKit 的公开枚举名, +// 万一写错,rond 那边只会显示成原始串,不会损坏数据。 +var mkHints = []actHint{ + {"咖啡厅", "MKPOICategoryCafe"}, {"茶艺馆", "MKPOICategoryCafe"}, {"冷饮店", "MKPOICategoryCafe"}, + {"糕饼店", "MKPOICategoryBakery"}, {"面包", "MKPOICategoryBakery"}, + {"图书馆", "MKPOICategoryLibrary"}, + {"学校", "MKPOICategorySchool"}, {"中学", "MKPOICategorySchool"}, {"小学", "MKPOICategorySchool"}, + {"高等院校", "MKPOICategoryUniversity"}, + {"博物馆", "MKPOICategoryMuseum"}, {"展览馆", "MKPOICategoryMuseum"}, + {"动物园", "MKPOICategoryZoo"}, {"水族馆", "MKPOICategoryAquarium"}, + {"海滩", "MKPOICategoryBeach"}, {"海滨浴场", "MKPOICategoryBeach"}, + {"风景名胜", "MKPOICategoryLandmark"}, {"寺庙道观", "MKPOICategoryLandmark"}, + {"公园", "MKPOICategoryPark"}, {"城市广场", "MKPOICategoryLandmark"}, + {"露营地", "MKPOICategoryCampground"}, + {"综合市场", "MKPOICategoryFoodMarket"}, {"农副产品市场", "MKPOICategoryFoodMarket"}, + {"便利店", "MKPOICategoryStore"}, {"超市", "MKPOICategoryStore"}, + {"专卖店", "MKPOICategoryStore"}, {"商场", "MKPOICategoryStore"}, {"购物中心", "MKPOICategoryStore"}, + {"地铁站", "MKPOICategoryPublicTransport"}, {"公交车站", "MKPOICategoryPublicTransport"}, + {"火车站", "MKPOICategoryPublicTransport"}, {"港口码头", "MKPOICategoryPublicTransport"}, + {"长途汽车站", "MKPOICategoryPublicTransport"}, + {"机场", "MKPOICategoryAirport"}, + {"宾馆酒店", "MKPOICategoryHotel"}, {"旅馆招待所", "MKPOICategoryHotel"}, {"度假村", "MKPOICategoryHotel"}, + {"综合医院", "MKPOICategoryHospital"}, {"专科医院", "MKPOICategoryHospital"}, + {"诊所", "MKPOICategoryHospital"}, {"药房", "MKPOICategoryPharmacy"}, + {"运动场馆", "MKPOICategoryStadium"}, {"健身中心", "MKPOICategoryFitnessCenter"}, + {"电影院", "MKPOICategoryMovieTheater"}, {"剧场", "MKPOICategoryTheater"}, + {"汽车租赁", "MKPOICategoryCarRental"}, {"加油站", "MKPOICategoryGasStation"}, + {"停车场", "MKPOICategoryParking"}, + {"银行", "MKPOICategoryBank"}, {"自动取款机", "MKPOICategoryAtm"}, + {"邮局", "MKPOICategoryPostOffice"}, {"派出所", "MKPOICategoryPolice"}, + {"中餐厅", "MKPOICategoryRestaurant"}, {"外国餐厅", "MKPOICategoryRestaurant"}, + {"快餐厅", "MKPOICategoryRestaurant"}, {"餐厅", "MKPOICategoryRestaurant"}, + + // 高德的一级大类兜底:小类不认识时至少落到大类上 + {"餐饮服务", "MKPOICategoryRestaurant"}, + {"购物服务", "MKPOICategoryStore"}, + {"住宿服务", "MKPOICategoryHotel"}, + {"体育休闲服务", "MKPOICategoryStadium"}, + {"医疗保健服务", "MKPOICategoryHospital"}, + {"风景名胜", "MKPOICategoryLandmark"}, + {"交通设施服务", "MKPOICategoryPublicTransport"}, + {"金融保险服务", "MKPOICategoryBank"}, +} + +// trimParens 只看括号外的主体:「尚优居客栈(大理古城步行街店)」是个客栈, +// 括号里那串只是地址,不该拿它来判断。 +func trimParens(s string) string { + if i := strings.IndexAny(s, "(([【"); i > 0 { + return s[:i] + } + return s +} + +// matchHint 取**最长的**命中词——越长的词越具体。 +// +// 不用「定义顺序」也不用「出现位置」:前者要求把词表排成一件容易出错的事, +// 后者会让「马鞍山公园」命中「山」而不是「公园」。长度相同则取先定义的。 +func matchHint(hints []actHint, s string) string { + if s == "" { + return "" + } + best, bestLen := "", 0 + for _, h := range hints { + if len(h.kw) > bestLen && strings.Contains(s, h.kw) { + best, bestLen = h.val, len(h.kw) + } + } + return best +} + +// matchNameHint 是地名专用的入口:先掐掉括号里的地址再匹配。 +func matchNameHint(hints []actHint, s string) string { + return matchHint(hints, trimParens(s)) +} + +// guessActivityByName 从地名猜活动,命中表示置信度高。 +func guessActivityByName(name string) string { return matchNameHint(nameActHints, name) } + +// matchAmapType 在高德类型串里找映射。高德给的是「大类;小类」, +// **小类更具体**(「风景名胜;公园」该按公园算,不是风景名胜),所以先试小类再退回大类。 +func matchAmapType(hints []actHint, t string) string { + if t == "" { + return "" + } + if i := strings.LastIndexByte(t, ';'); i >= 0 { + if v := matchHint(hints, t[i+1:]); v != "" { + return v + } + return matchHint(hints, t[:i]) + } + return matchHint(hints, t) +} + +func guessActivityFromAmapType(t string) string { return matchAmapType(amapActHints, t) } + +func guessCategoryFromAmapType(t string) string { return matchAmapType(mkHints, t) } + +// knownMKCategories 是真机确认用过的类别,界面上标一下,方便一眼看出哪些是「新猜的」。 +var knownMKCategories = map[string]bool{ + "MKPOICategoryRestaurant": true, + "MKPOICategoryStore": true, + "MKPOICategoryPublicTransport": true, + "MKPOICategoryHotel": true, + "MKPOICategoryLandmark": true, + "MKPOICategoryFoodMarket": true, + "MKPOICategoryBeach": true, + "MKPOICategoryMuseum": true, + "MKPOICategoryCampground": true, + "MKPOICategoryZoo": true, + "MKPOICategoryStadium": true, + "MKPOICategoryPark": true, +} + +// mkCategoryKnown 报告这个类别真机是否用过。 +func mkCategoryKnown(c string) bool { return knownMKCategories[c] } diff --git a/internal/web/fillup_rules_test.go b/internal/web/fillup_rules_test.go new file mode 100644 index 0000000..635b409 --- /dev/null +++ b/internal/web/fillup_rules_test.go @@ -0,0 +1,108 @@ +package web + +import "testing" + +// 用例直接用站点里真实存在的地名,规则改坏了这里立刻会响。 +func TestGuessActivityByName(t *testing.T) { + cases := []struct{ name, want string }{ + {"星巴克咖啡", "奶茶和甜甜水"}, + {"后谷咖啡", "奶茶和甜甜水"}, + {"花溪十里河滩绿道", "散步"}, + {"马鞍山公园", "散步"}, + {"河池市体育馆", "户外活动"}, + {"喜洲古镇洱海环境监测站", "旅游景点"}, + {"十里河滩旅游度假区-孔子圣像", "旅游景点"}, + {"尚优居客栈(大理古城步行街店)", "酒店"}, // 括号里是地址,主体才是客栈 + {"鑫圣餐厅", "餐厅"}, + {"彭大花·白族小花园餐厅(广武路店)", "餐厅"}, + {"大昌盛超市", "小商店"}, + {"中国福利彩票", "彩票购买点"}, + + // 猜不出来的必须留空,交给高德或者人工——宁可空着也别瞎填 + {"蜜雪冰城", ""}, + {"昆明站", ""}, // 单字「站」误命中太多,交给高德 + {"独山路28-17号", ""}, // 路名里有「山」但不是山 + {"银滩镇", ""}, + {"傣思蜜清真手抓饭", ""}, + {"何孃孃小锅米线", ""}, + {"", ""}, + } + for _, c := range cases { + if got := guessActivityByName(c.name); got != c.want { + t.Errorf("guessActivityByName(%q) = %q,期望 %q", c.name, got, c.want) + } + } +} + +func TestGuessCategoryFromAmapType(t *testing.T) { + cases := []struct{ typ, want string }{ + {"餐饮服务;咖啡厅", "MKPOICategoryCafe"}, + {"餐饮服务;中餐厅", "MKPOICategoryRestaurant"}, + {"餐饮服务;快餐厅", "MKPOICategoryRestaurant"}, + {"购物服务;购物中心", "MKPOICategoryStore"}, + {"购物服务;便利店", "MKPOICategoryStore"}, + // 小类优先:「风景名胜」是大类,这地方其实是个公园 + {"风景名胜;公园", "MKPOICategoryPark"}, + {"住宿服务;宾馆酒店", "MKPOICategoryHotel"}, + {"交通设施服务;地铁站", "MKPOICategoryPublicTransport"}, + {"交通设施服务;机场", "MKPOICategoryAirport"}, + {"体育休闲服务;运动场馆", "MKPOICategoryStadium"}, + {"医疗保健服务;综合医院", "MKPOICategoryHospital"}, + + // 大类命中、小类不认识时退回大类 + {"购物服务;从未见过的子类", "MKPOICategoryStore"}, + {"", ""}, + {"未知;未知", ""}, + } + for _, c := range cases { + if got := guessCategoryFromAmapType(c.typ); got != c.want { + t.Errorf("guessCategoryFromAmapType(%q) = %q,期望 %q", c.typ, got, c.want) + } + } +} + +func TestGuessActivityFromAmapType(t *testing.T) { + cases := []struct{ typ, want string }{ + {"餐饮服务;咖啡厅", "奶茶和甜甜水"}, + {"餐饮服务;小吃快餐", "美食街"}, + {"购物服务;便利店", "小商店"}, + {"住宿服务;宾馆酒店", "酒店"}, + {"风景名胜;公园", "散步"}, + {"体育休闲服务;运动场馆", "户外活动"}, + {"未知;未知", ""}, + } + for _, c := range cases { + if got := guessActivityFromAmapType(c.typ); got != c.want { + t.Errorf("guessActivityFromAmapType(%q) = %q,期望 %q", c.typ, got, c.want) + } + } +} + +// 类别值必须是真机能认的枚举名,别写出别的形状。 +func TestKnownMKCategoriesShape(t *testing.T) { + for c := range knownMKCategories { + if len(c) <= len("MKPOICategory") || c[:13] != "MKPOICategory" { + t.Errorf("类别 %q 不符合 MKPOICategory<名字> 的形状", c) + } + } + if !mkCategoryKnown("MKPOICategoryRestaurant") { + t.Error("真机用过的类别应被认得") + } + if mkCategoryKnown("MKPOICategoryCafe") { + t.Error("这一次新猜的类别不该被算作「真机用过」") + } +} + +func TestTrimParens(t *testing.T) { + cases := []struct{ in, want string }{ + {"尚优居客栈(大理古城步行街店)", "尚优居客栈"}, + {"三哥·思茅菜(北区店)", "三哥·思茅菜"}, + {"没有括号的名字", "没有括号的名字"}, + {"开头就是括号的", "开头就是括号的"}, // 整串都是括号开头时不截断,避免截成空串 + } + for _, c := range cases { + if got := trimParens(c.in); got != c.want { + t.Errorf("trimParens(%q) = %q,期望 %q", c.in, got, c.want) + } + } +} diff --git a/internal/web/fillup_test.go b/internal/web/fillup_test.go new file mode 100644 index 0000000..cb5d4c2 --- /dev/null +++ b/internal/web/fillup_test.go @@ -0,0 +1,170 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" +) + +// fillupPlace 在测试数据集里造一个「没类别、名下到访全都没活动」的地点。 +func fillupPlace(t *testing.T, f *movementFixture) (placeID int64, placePK int) { + t.Helper() + const pk = 670001 + if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon) + VALUES ($1,$2,'测试补全点',31.1,121.1) RETURNING id`, f.dsID, pk).Scan(&placeID); err != nil { + t.Fatalf("建地点失败: %v", err) + } + for _, vpk := range []int{670002, 670003} { + if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits + (dataset_id, src_pk, place_id, arrival, departure) + VALUES ($1,$2,$3,'2026-01-01 10:00:00+08','2026-01-01 11:00:00+08')`, + f.dsID, vpk, placeID); err != nil { + t.Fatalf("建到访失败: %v", err) + } + } + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM visits WHERE dataset_id=$1 AND place_id=$2`, f.dsID, placeID) + f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE id=$1`, placeID) + }) + return placeID, pk +} + +func applyFillup(t *testing.T, f *movementFixture, form url.Values) string { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/admin/fillup/apply", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + f.srv.adminFillupApply(rec, req.WithContext(f.ctx)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("补全应跳回页面: code=%d body=%s", rec.Code, rec.Body.String()) + } + return rec.Header().Get("Location") +} + +// 补活动是按**地点**批量做的:一次把它名下所有没活动的到访一起设,不是只改一条。 +func TestFillupSetsActivityForWholePlace(t *testing.T) { + f := newMovementFixture(t) + placeID, _ := fillupPlace(t, f) + + acts, err := f.srv.activityOptions(f.ctx, f.dsID) + if err != nil || len(acts) == 0 { + t.Fatalf("取活动失败: %v", err) + } + target := acts[0].Name + + loc := applyFillup(t, f, url.Values{ + "act_" + strconv.FormatInt(placeID, 10): {target}, + }) + if strings.Contains(loc, "err=") { + t.Fatalf("补全失败: %s", loc) + } + if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND place_id=$2 + AND activity_id IS NULL`, f.dsID, placeID); n != 0 { + t.Fatalf("该地点两条到访都该设上活动,还剩 %d 条没设", n) + } + var got string + if err := f.pg.QueryRowContext(f.ctx, `SELECT a.name FROM visits v JOIN activities a ON a.id=v.activity_id + WHERE v.dataset_id=$1 AND v.place_id=$2 LIMIT 1`, f.dsID, placeID).Scan(&got); err != nil { + t.Fatalf("读活动失败: %v", err) + } + if got != target { + t.Fatalf("活动应为 %q,实际 %q", target, got) + } +} + +// 「只填空」:已经有值的一律不动,哪怕表单里填了别的。 +func TestFillupNeverOverwrites(t *testing.T) { + f := newMovementFixture(t) + placeID, _ := fillupPlace(t, f) + + // 先给一条到访设好活动,给地点设好类别 + acts, _ := f.srv.activityOptions(f.ctx, f.dsID) + first := acts[0].Name + second := "补全测试活动" + if len(acts) > 1 { + second = acts[1].Name + } + if _, err := f.pg.ExecContext(f.ctx, `UPDATE visits SET activity_id=$1 + WHERE dataset_id=$2 AND place_id=$3 AND src_pk=670002`, + acts[0].ID, f.dsID, placeID); err != nil { + t.Fatalf("预置活动失败: %v", err) + } + if _, err := f.pg.ExecContext(f.ctx, `UPDATE places SET poi_category='MKPOICategoryPark' + WHERE id=$1`, placeID); err != nil { + t.Fatalf("预置类别失败: %v", err) + } + + applyFillup(t, f, url.Values{ + "act_" + strconv.FormatInt(placeID, 10): {second}, + "cat_" + strconv.FormatInt(placeID, 10): {"MKPOICategoryZoo"}, + }) + + // 原来那条不能被改掉 + var got string + if err := f.pg.QueryRowContext(f.ctx, `SELECT a.name FROM visits v JOIN activities a ON a.id=v.activity_id + WHERE v.dataset_id=$1 AND v.place_id=$2 AND v.src_pk=670002`, f.dsID, placeID).Scan(&got); err != nil { + t.Fatalf("读活动失败: %v", err) + } + if got != first { + t.Fatalf("已有活动的到访不该被改:期望 %q,实际 %q", first, got) + } + // 已经填过的类别也不能被覆盖 + var cat string + if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(poi_category,'') FROM places WHERE id=$1`, placeID).Scan(&cat); err != nil { + t.Fatalf("读类别失败: %v", err) + } + if cat != "MKPOICategoryPark" { + t.Fatalf("已有类别不该被覆盖,实际 %q", cat) + } + // 但另一条原本没活动的到访应当补上:这个地点不该再有无活动的到访 + if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND place_id=$2 + AND activity_id IS NULL`, f.dsID, placeID); n != 0 { + t.Fatalf("原本没活动的那条应当补上,还剩 %d 条没设", n) + } +} + +// 填一个现有列表里没有的名字,应当自动建成新的活动类型(用户要的「匹配不上就新增」)。 +func TestFillupCreatesUnknownActivity(t *testing.T) { + f := newMovementFixture(t) + placeID, _ := fillupPlace(t, f) + before := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1`, f.dsID) + + applyFillup(t, f, url.Values{"act_" + strconv.FormatInt(placeID, 10): {"洗碗机测试活动"}}) + + if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1 AND name=$2`, + f.dsID, "洗碗机测试活动"); n != 1 { + t.Fatalf("该新建一个活动类型,实际 %d 个", n) + } + if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1`, f.dsID); n != before+1 { + t.Fatalf("活动总数应 +1,实际 %d(原 %d)", n, before) + } + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM activities WHERE dataset_id=$1 AND name=$2`, f.dsID, "洗碗机测试活动") + }) +} + +func itoa(v int64) string { + return strings.TrimSpace(strings.ReplaceAll(strings.Trim(strings.Join([]string{}, ""), " "), " ", "")) + intToString(v) +} + +func intToString(v int64) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var b []byte + for v > 0 { + b = append([]byte{byte('0' + v%10)}, b...) + v /= 10 + } + if neg { + return "-" + string(b) + } + return string(b) +} diff --git a/internal/web/geocode.go b/internal/web/geocode.go index 990b6b8..45e31cc 100644 --- a/internal/web/geocode.go +++ b/internal/web/geocode.go @@ -351,6 +351,13 @@ func (s *Server) apiRegeo(w http.ResponseWriter, r *http.Request) { // 半径给 200 米:再大容易匹配到马路边上不相干的店;取不到就返回 ok=false, // 界面上只显示地址。 func (s *Server) amapNearestPOI(ctx context.Context, lat, lon float64) (name string, dist float64, ok bool) { + name, _, dist, ok = s.amapAround(ctx, lat, lon) + return name, dist, ok +} + +// amapAround 周边搜索:拿最近那个 POI 的名字、类型与距离。 +// typ 是高德的分类串(形如「餐饮服务;咖啡厅」),地点补全靠它猜活动与类别。 +func (s *Server) amapAround(ctx context.Context, lat, lon float64) (name, typ string, dist float64, ok bool) { v := url.Values{} v.Set("key", s.cfg.GeocodeKey) v.Set("location", fmt.Sprintf("%.6f,%.6f", lon, lat)) @@ -359,20 +366,21 @@ func (s *Server) amapNearestPOI(ctx context.Context, lat, lon float64) (name str v.Set("sortrule", "distance") // 按距离排序,第一条就是最近的 body, err := s.amapGet(ctx, aroundURL+"?"+v.Encode()) if err != nil { - return "", 0, false + return "", "", 0, false } var raw struct { Status string `json:"status"` Pois []struct { Name string `json:"name"` + Type string `json:"type"` Distance string `json:"distance"` } `json:"pois"` } if err := json.Unmarshal(body, &raw); err != nil || raw.Status != "1" || len(raw.Pois) == 0 { - return "", 0, false + return "", "", 0, false } d, _ := strconv.ParseFloat(raw.Pois[0].Distance, 64) - return raw.Pois[0].Name, d, true + return raw.Pois[0].Name, raw.Pois[0].Type, d, true } // amapRegeo 调一次高德的逆地理编码。失败返回 ok=false,调用方留空即可。 diff --git a/internal/web/server.go b/internal/web/server.go index 5ba9a92..46b0384 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -56,6 +56,7 @@ var pageFiles = map[string]struct { "admin/fog-mask": {[]string{"templates/layout.html", "templates/admin/fog-mask.html"}, "layout"}, "admin/fog-preview": {[]string{"templates/layout.html", "templates/admin/fog-preview.html"}, "layout"}, "admin/backup-preview": {[]string{"templates/layout.html", "templates/admin/backup-preview.html"}, "layout"}, + "admin/fillup": {[]string{"templates/layout.html", "templates/admin/fillup.html"}, "layout"}, "admin/notes": {[]string{"templates/layout.html", "templates/admin/notes.html"}, "layout"}, "admin/edit": {[]string{"templates/layout.html", "templates/admin/edit.html"}, "layout"}, "admin/topics": {[]string{"templates/layout.html", "templates/admin/topics.html"}, "layout"}, @@ -279,6 +280,9 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /admin/export/places.geojson", s.requireAdmin(s.exportGeoJSON)) s.mux.HandleFunc("GET /admin/export/places.gpx", s.requireAdmin(s.exportGPX)) s.mux.HandleFunc("GET /admin/export/rondbackup", s.requireAdmin(s.exportRondbackup)) + s.mux.HandleFunc("GET /admin/fillup", s.requireAdmin(s.adminFillupPage)) + s.mux.HandleFunc("POST /admin/fillup/apply", s.requireAdmin(s.adminFillupApply)) + s.mux.HandleFunc("POST /admin/fillup/amap", s.requireAdmin(s.adminFillupAmap)) s.mux.HandleFunc("GET /admin/settings", s.requireAdmin(s.settingsPage)) s.mux.HandleFunc("POST /admin/settings", s.requireAdmin(s.settingsSubmit)) s.mux.HandleFunc("GET /admin/password", s.requireAdmin(s.passwordPage)) diff --git a/internal/web/templates/admin/fillup.html b/internal/web/templates/admin/fillup.html new file mode 100644 index 0000000..1c2b78d --- /dev/null +++ b/internal/web/templates/admin/fillup.html @@ -0,0 +1,79 @@ +{{define "content"}} +
+
+
+

地点补全

+
+ 共 {{num .Total}} 个地点缺活动或类别,其中 {{num .WithHint}} 个能给出建议。 + 只填空——已经有活动或类别的一律不动。 +
+
+ +
+ + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Err}}
{{.Err}}
{{end}} + + {{if .Rows}} +
+

待补全({{num .Total}})

+
+ 建议已经预填在框里,不满意的直接改或清空即可,留空就是不动。 + 活动按地点补:一次把该地点名下所有没设活动的到访一起填上,不会只改一条。 + 填了现有列表之外的名字,会自动建成新的活动类型。
+ 「用高德分析剩下的」每次处理 40 个、约 4 秒,结果会填进上面还没建议的行里,点多几次即可。 +
+
+
+ + + + + + + + + {{range $row := .Rows}} + + + + + + + + + + {{end}} + +
地点城市待设到访缺什么活动类别建议来源
{{if $row.Name}}{{$row.Name}}{{else}}(未命名){{end}}{{if $row.City}}{{$row.City}}{{else}}—{{end}}{{if $row.NoAct}}{{$row.NoAct}}{{else}}—{{end}}{{$row.Missing}} + {{if $row.NoAct}} + + {{else}}—{{end}} + + {{if $row.NoCat}} + + {{else}}—{{end}} + {{if $row.Source}}{{$row.Source}}{{else}}—{{end}}
+
+ + {{range .Activities}}{{end}} + + + {{range .Categories}}{{end}} + +
+ + +
+
+
+ {{else}} +
+
所有地点的活动和类别都齐了,没什么要补的。
+
+ {{end}} +
+{{end}} diff --git a/internal/web/templates/admin/index.html b/internal/web/templates/admin/index.html index 018485c..e25c1b0 100644 --- a/internal/web/templates/admin/index.html +++ b/internal/web/templates/admin/index.html @@ -12,6 +12,7 @@ 站点设置 两步验证 地点备注 + 地点补全 账号设置 查看站点