package web import ( "context" "database/sql" "encoding/json" "fmt" "html/template" "log" "math" "net/http" "strings" "time" "rond-with-you/internal/geo" "rond-with-you/internal/stats" ) // 专题 = 某段时间 + 某个区域内的足迹。地点来自「时间范围内的到访」, // 迷雾不落库、由地图瓦片按视口自然呈现——所以专题只要把地图视野限定在 // 区域(或地点包围盒)上,「区域内的迷雾」就成立了。 const ( // 表单现在精确到分钟;纯日期形式仍然接受(老表单缓存 / 手工拼接参数) topicTimeLayout = "2006-01-02" topicDateTimeLayout = "2006-01-02T15:04" ) // parseTopicTime 解析表单时间,两种格式都认。 func parseTopicTime(raw string) (time.Time, bool) { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, false } if t, err := time.ParseInLocation(topicDateTimeLayout, raw, time.Local); err == nil { return t, true } if t, err := time.ParseInLocation(topicTimeLayout, raw, time.Local); err == nil { return t, true } return time.Time{}, false } // ---------- 显示区(一组圆) ---------- const ( // maxTopicAreas 限制显示区个数:每个圆在 SQL 里占 3 个占位符、在 clip-path 里是一段子路径, // 都不是能无限堆的东西;后台地图编辑器用同一个上限。 maxTopicAreas = 20 // 半径 50 米 ~ 2000 公里:再小在地图上拖不出来,再大没有意义。 topicAreaMinRadiusKm = 0.05 topicAreaMaxRadiusKm = 2000.0 ) // parseTopicAreas 解析 areas 列。坏数据要上抛而不是吞掉—— // 显示区决定了专题「有哪些地点」,静默退化成「全都要」比报错危险得多。 func parseTopicAreas(raw []byte) ([]TopicArea, error) { if len(raw) == 0 { return nil, nil } var areas []TopicArea if err := json.Unmarshal(raw, &areas); err != nil { return nil, err } return areas, nil } // normalizeTopicAreas 校验并收敛显示区。crs 是**点选时所处底图**的坐标系: // 天地图那类 WGS-84 底图上点的圆心必须先换算成 GCJ-02 再存 —— // 地点过滤是拿它跟 places 的 p.lat/p.lon(GCJ-02)直接比球面距离的,不换算会整体偏几百米。 func normalizeTopicAreas(areas []TopicArea, crs string) ([]TopicArea, error) { if len(areas) > maxTopicAreas { return nil, fmt.Errorf("显示区最多 %d 个", maxTopicAreas) } out := make([]TopicArea, 0, len(areas)) for _, a := range areas { if math.IsNaN(a.Lat) || math.IsNaN(a.Lon) || math.IsNaN(a.RadiusKm) || math.IsInf(a.Lat, 0) || math.IsInf(a.Lon, 0) || math.IsInf(a.RadiusKm, 0) { return nil, fmt.Errorf("显示区里有非法数值") } if math.Abs(a.Lat) > 90 || math.Abs(a.Lon) > 180 { return nil, fmt.Errorf("显示区的圆心超出经纬度范围") } if a.RadiusKm < topicAreaMinRadiusKm || a.RadiusKm > topicAreaMaxRadiusKm { return nil, fmt.Errorf("显示区的半径要在 %g ~ %g 公里之间", topicAreaMinRadiusKm, topicAreaMaxRadiusKm) } if crs == "wgs84" { a.Lat, a.Lon = geo.WGS84ToGCJ02(a.Lat, a.Lon) } out = append(out, a) } return out, nil } // topicAreasColumn 是写进 areas 列的值。**空必须是 `[]` 而不是 `null`**: // 列上有 NOT NULL,而 json.Marshal(nil slice) 给出来的正好是 "null"。 func topicAreasColumn(areas []TopicArea) []byte { if len(areas) == 0 { return []byte("[]") } b, err := json.Marshal(areas) if err != nil { return []byte("[]") } return b } // topicAreasFromForm 从表单取显示区:优先读地图编辑器提交的 areas(JSON); // 没给才退回老的 center_lat / center_lon / radius_km 三字段(合成一个圆)。 // 两条路走同一套校验与换算,免得「新入口有校验、老入口没有」。 func topicAreasFromForm(r *http.Request) ([]TopicArea, error) { crs := strings.TrimSpace(r.PostFormValue("crs")) raw := strings.TrimSpace(r.PostFormValue("areas")) if raw == "" { lat, ok1 := parseFloat(r.PostFormValue("center_lat")) lon, ok2 := parseFloat(r.PostFormValue("center_lon")) rkm, ok3 := parseFloat(r.PostFormValue("radius_km")) // 老表单里「不限区域」就是留空或 0,不是错误 if !ok1 || !ok2 || !ok3 || rkm <= 0 { return nil, nil } if lat == 0 && lon == 0 { return nil, fmt.Errorf("限定区域时要在图上点选圆心") } return normalizeTopicAreas([]TopicArea{{Lat: lat, Lon: lon, RadiusKm: rkm}}, crs) } var areas []TopicArea if err := json.Unmarshal([]byte(raw), &areas); err != nil { return nil, fmt.Errorf("显示区数据无法解析") } return normalizeTopicAreas(areas, crs) } // topicAreasBounds 求所有圆的并集外接矩形(GCJ-02 度)。 // 只有一个圆时它就是这个圆的外接正方形,与早先那条 fitCircle(toBounds(2r km))等价, // 所以单圆专题的视野不变;多圆只有外接矩形才有意义。 func topicAreasBounds(areas []TopicArea) (minLat, maxLat, minLon, maxLon float64, ok bool) { if len(areas) == 0 { return 0, 0, 0, 0, false } minLat, maxLat = math.MaxFloat64, -math.MaxFloat64 minLon, maxLon = math.MaxFloat64, -math.MaxFloat64 for _, a := range areas { dLat := a.RadiusKm / 111.32 // 经度方向 1 度比纬度短,按纬度余弦放大;高纬处夹一下,避免除到极大 cos := math.Cos(a.Lat * math.Pi / 180) if cos < 0.01 { cos = 0.01 } dLon := a.RadiusKm / (111.32 * cos) minLat, maxLat = math.Min(minLat, a.Lat-dLat), math.Max(maxLat, a.Lat+dLat) minLon, maxLon = math.Min(minLon, a.Lon-dLon), math.Max(maxLon, a.Lon+dLon) } if minLat < -90 { minLat = -90 } if maxLat > 90 { maxLat = 90 } if minLon < -180 { minLon = -180 } if maxLon > 180 { maxLon = 180 } return minLat, maxLat, minLon, maxLon, true } // ---------- 查询 ---------- // loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。 // // 按**属主**取而不是按数据集:专题是「一段时间 + 一块区域」的定义,本不该随数据集生灭。 // 早期按 dataset_id 过滤 ⇒ 删掉旧数据集时专题被级联删掉(实测丢过一批,且没有恢复来源)。 // 专题的**内容**仍然照当前生效数据集算(见 topicContent),变的是「这份定义归谁」。 func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool) ([]Topic, error) { q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon, radius_km, show_fog, enabled, sort_order, areas FROM topics WHERE user_id=$1` if onlyEnabled { q += ` AND enabled` } q += ` ORDER BY sort_order, id` rows, err := s.db.QueryContext(ctx, q, ownerID) if err != nil { return nil, err } defer rows.Close() var out []Topic for rows.Next() { var t Topic var rawAreas []byte if err := rows.Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt, &t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder, &rawAreas); err != nil { return nil, err } if t.Areas, err = parseTopicAreas(rawAreas); err != nil { return nil, fmt.Errorf("专题 #%d 的显示区解析失败: %w", t.ID, err) } t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout) t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout) t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt) out = append(out, t) } return out, rows.Err() } // topicRangeLabel 给完整时间范围。整天(00:00 ~ 23:59)时只显示日期—— // 这种情况多是「按天圈的范围」,写成「00:00 ~ 23:59」只是噪音; // 只要有一端带了具体时刻,就完整显示到分钟。 func topicRangeLabel(start, end time.Time) string { a, b := start.Local(), end.Local() wholeDays := a.Hour() == 0 && a.Minute() == 0 && b.Hour() == 23 && b.Minute() >= 59 if wholeDays { if a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() { return a.Format("2006-01-02") } return fmt.Sprintf("%s ~ %s", a.Format("2006-01-02"), b.Format("2006-01-02")) } if a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() { return fmt.Sprintf("%s %s ~ %s", a.Format("2006-01-02"), a.Format("15:04"), b.Format("15:04")) } return fmt.Sprintf("%s ~ %s", a.Format("2006-01-02 15:04"), b.Format("2006-01-02 15:04")) } // topicHaversineSQL 是写进 SQL 的球面距离(公里)表达式,参数依次是中心纬度、中心经度。 // 放在 SQL 里是为了让「区域过滤」和聚合统计一次算完,不必先查一遍再回 Go 过滤。 const topicHaversineSQL = `6371 * 2 * asin(sqrt( power(sin(radians((%s - p.lat) / 2)), 2) + cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))` // topicAreaCond 追加显示区的参数并返回区域条件:落在**任一**圆内就算在区域内(并集)。 // 批量统计要重复拼这段,抽出来保证两条路径的占位符编号逐字一致 —— // 这里少算一个 $n,表现就是「专题内容全变 0」。每个圆占 3 个参数(纬度、经度、半径)。 func topicAreaCond(t *Topic, args *[]any) string { areas := t.AreaList() if len(areas) == 0 { return "" } conds := make([]string, 0, len(areas)) for _, a := range areas { *args = append(*args, a.Lat, a.Lon, a.RadiusKm) n := len(*args) // 球面距离表达式的三个 %s 依次是 中心纬度 / 中心纬度 / 中心经度,最后再比半径 conds = append(conds, fmt.Sprintf("%s <= $%d", fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)) } return " AND (" + strings.Join(conds, " OR ") + ")" } // topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。 func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64, regions []string, exclude bool) ([]MapPoint, int, int, int, error) { args := []any{datasetID, t.StartAt, t.EndAt} area := topicAreaCond(t, &args) // 到 area 为止,这条查询自己的占位符就用到这么多;下面的 regionSQL 会在此基础上 // 继续往后编号并追加参数。记下来给下面那条「天数」查询用 —— 它没有区域条件, // 不能把这份(已被追加过的)args 整个传进去。 baseArgs := len(args) q := `SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.city,''), COALESCE((SELECT a.name FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL AND ` + stats.SoloVisitSQL("v2") + ` GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''), COALESCE((SELECT a.color FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL AND ` + stats.SoloVisitSQL("v2") + ` GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''), count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0), max(v.arrival) FROM places p JOIN visits v ON v.place_id = p.id WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3 AND ` + stats.SoloVisitSQL("v") + area + regionSQL("p", regions, exclude, &args) + ` GROUP BY p.id ORDER BY count(*) DESC, p.id` rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, 0, 0, 0, err } defer rows.Close() var points []MapPoint cities := map[string]bool{} totalVisits := 0 var totalDwell int64 for rows.Next() { var m MapPoint var last time.Time if err := rows.Scan(&m.ID, &m.SrcPK, &m.Name, &m.Lat, &m.Lon, &m.City, &m.Act, &m.Color, &m.Count, &m.Dwell, &last); err != nil { return nil, 0, 0, 0, err } m.LastAt = last.Local().Format("2006-01-02") points = append(points, m) totalVisits += m.Count totalDwell += m.Dwell if m.City != "" { cities[m.City] = true } } if err := rows.Err(); err != nil { return nil, 0, 0, 0, err } // 天数:按本地日期去重(专题可能只覆盖几天,直接在 SQL 里算更准)。 // // 参数必须按**这条 SQL 真正用到的占位符**来凑:$1..$3 固定,area 用 $4..$6。 // 以前这里直接把上面那条 SQL 的 args 整个传进来,而它已经被 regionSQL 追加过区域 // 参数 —— 这条 SQL 当时又没有区域条件,参数就比占位符多,pgx 直接报 // 「expected 3 arguments, got 4」。而且专题列表的统计会把这个错误吞掉, // 表现成「专题内容全变 0、点进去才 500」,很难查。 days := 0 dargs := append([]any{}, args[:baseArgs]...) daysRegion := regionSQL("p", regions, exclude, &dargs) if err := s.db.QueryRowContext(ctx, `SELECT count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date) FROM visits v JOIN places p ON p.id = v.place_id WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3 AND `+stats.SoloVisitSQL("v")+area+daysRegion, dargs...).Scan(&days); err != nil { return nil, 0, 0, 0, err } return points, totalVisits, days, len(cities), nil } // fillTopicStatsBatch 一次算完所有专题的内容统计。 // // 列表页原本每个专题跑 2 条查询(点位 + 天数),专题一多就是 2N 次往返;而列表 // 只需要汇总数字、不需要点位。这里用 UNION ALL 把 N 个专题拼成一条查询:每个子查询 // 自带一套占位符(从 base 往后编),args 顺序必须与拼接顺序一致。口径与详情页 // (topicPage → topicContent)完全一致 —— 同一专题在列表页和详情页必须数字相同。 func (s *Server) fillTopicStatsBatch(ctx context.Context, list []Topic, datasetID int64, regions []string, exclude bool) error { if len(list) == 0 { return nil } var b strings.Builder var args []any for i := range list { t := &list[i] if i > 0 { b.WriteString(" UNION ALL ") } base := len(args) args = append(args, datasetID, t.StartAt, t.EndAt) area := topicAreaCond(t, &args) region := regionSQL("p", regions, exclude, &args) fmt.Fprintf(&b, `SELECT %d AS tid, count(DISTINCT p.id), count(*), count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date), count(DISTINCT NULLIF(p.city,'')), COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0) FROM places p JOIN visits v ON v.place_id = p.id WHERE p.dataset_id=$%d AND v.arrival >= $%d AND v.arrival <= $%d AND `+stats.SoloVisitSQL("v")+`%s%s`, i, base+1, base+2, base+3, area, region) } rows, err := s.db.QueryContext(ctx, b.String(), args...) if err != nil { return err } defer rows.Close() for rows.Next() { var i, places, visits, days, cities int var dwell int64 if err := rows.Scan(&i, &places, &visits, &days, &cities, &dwell); err != nil { return err } if i < 0 || i >= len(list) { return fmt.Errorf("专题统计返回了越界序号 %d", i) } list[i].PlaceCount, list[i].VisitCount, list[i].DayCount, list[i].CityCount = places, visits, days, cities list[i].DwellMin = dwell } return rows.Err() } // parseTopicForm 解析后台表单。日期按本地时区补成当天 00:00 / 23:59:59, // 这样「9月1日到9月3日」能把 3 号整天的记录都收进来。 func parseTopicForm(r *http.Request) (Topic, error) { var t Topic t.Title = strings.TrimSpace(r.PostFormValue("title")) t.Subtitle = strings.TrimSpace(r.PostFormValue("subtitle")) t.Description = strings.TrimSpace(r.PostFormValue("description")) if t.Title == "" { return t, fmt.Errorf("请填写专题标题") } startRaw := strings.TrimSpace(r.PostFormValue("start_at")) start, ok := parseTopicTime(startRaw) if !ok { return t, fmt.Errorf("请填写有效的开始时间") } endRaw := strings.TrimSpace(r.PostFormValue("end_at")) end, ok := parseTopicTime(endRaw) if !ok { return t, fmt.Errorf("请填写有效的结束时间") } // 只给了日期(没给时刻)时把结束补到当天最后一刻,不然那天整天的记录会被漏掉 if len(endRaw) <= len(topicTimeLayout) { end = end.Add(24*time.Hour - time.Second) } if end.Before(start) { return t, fmt.Errorf("结束时间不能早于开始时间") } t.StartAt, t.EndAt = start, end areas, err := topicAreasFromForm(r) if err != nil { return t, err } t.Areas = areas // 镜像第一个圆进那三列废弃列:万一回滚旧二进制,退化成「第一个圆」总比「没有区域」强。 t.CenterLat, t.CenterLon, t.RadiusKm = 0, 0, 0 if len(areas) > 0 { t.CenterLat, t.CenterLon, t.RadiusKm = areas[0].Lat, areas[0].Lon, areas[0].RadiusKm } t.ShowFog = r.PostFormValue("show_fog") == "on" t.Enabled = r.PostFormValue("enabled") == "on" t.SortOrder = atoiOr(r.PostFormValue("sort_order"), 0) t.StartDate, t.EndDate = start.Format(topicDateTimeLayout), t.EndAt.Format(topicDateTimeLayout) return t, nil } // ---------- 后台:专题管理 ---------- func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() p, err := s.pageBase(r, "admin", "专题管理") if err != nil { s.serverError(w, r, err) return } d := TopicAdminData{Page: p} d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err") u := userFrom(ctx) // 专题归**人**不归数据集,所以没有生效数据集时也能看、也能改(只是统计不出来)。 // 早期版本这里要 ds != nil 才加载,删数据集期间专题就整个不见了。 list, err := s.loadTopics(ctx, u.ID, false) if err != nil { s.serverError(w, r, err) return } ds, err := s.datasetFor(ctx) if err != nil { s.serverError(w, r, err) return } if ds != nil { // 后台管理页:站长看全部;统计照当前生效数据集算 if err := s.fillTopicStatsBatch(ctx, list, ds.ID, nil, false); err != nil { log.Printf("专题统计失败: %v", err) } } d.Topics = list // 编辑模式:?edit= if id := atoiOr(r.URL.Query().Get("edit"), 0); id > 0 { for _, t := range list { if t.ID == int64(id) { d.Form = t } } } if d.Form.Title == "" { // 新建时给个合理默认:最近 30 天 now := time.Now() d.Form.StartDate = now.AddDate(0, 0, -30).Format(topicDateTimeLayout) d.Form.EndDate = now.Format(topicDateTimeLayout) d.Form.ShowFog, d.Form.Enabled = true, true d.IsNew = true } s.render(w, "admin/topics", d) } func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) { ctx := r.Context() u := userFrom(ctx) ds, err := s.datasetFor(ctx) if err != nil || ds == nil { redirectTopics(w, r, "err", "还没有可编辑的数据集") return } if err := r.ParseForm(); err != nil { s.serverError(w, r, err) return } t, err := parseTopicForm(r) if err != nil { redirectTopics(w, r, "err", err.Error()) return } id := atoiOr(r.PostFormValue("id"), 0) if id > 0 { if _, err := s.db.ExecContext(ctx, `UPDATE topics SET title=$3, subtitle=$4, description=$5, start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10, show_fog=$11, enabled=$12, sort_order=$13, areas=$14::jsonb WHERE id=$1 AND user_id=$2`, id, u.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt, t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder, topicAreasColumn(t.Areas)); err != nil { s.serverError(w, r, err) return } redirectTopics(w, r, "ok", "专题已更新") return } var newID int64 // dataset_id 照写不动,但只当**出处**记:这一份专题是在哪份数据上建的。 // 认它归谁、对外怎么展示都与它无关(外键已摘,见 schema.sql)。 if err := s.db.QueryRowContext(ctx, `INSERT INTO topics (user_id, dataset_id, title, subtitle, description, start_at, end_at, center_lat, center_lon, radius_km, show_fog, enabled, sort_order, areas) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb) RETURNING id`, u.ID, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt, t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder, topicAreasColumn(t.Areas)).Scan(&newID); err != nil { s.serverError(w, r, err) return } redirectTopics(w, r, "ok", "专题已创建") } func (s *Server) adminTopicDelete(w http.ResponseWriter, r *http.Request) { s.topicMutate(w, r, func(ctx context.Context, userID int64, id int) error { _, err := s.db.ExecContext(ctx, `DELETE FROM topics WHERE id=$1 AND user_id=$2`, id, userID) return err }, "专题已删除") } func (s *Server) adminTopicToggle(w http.ResponseWriter, r *http.Request) { s.topicMutate(w, r, func(ctx context.Context, userID int64, id int) error { _, err := s.db.ExecContext(ctx, `UPDATE topics SET enabled = NOT enabled WHERE id=$1 AND user_id=$2`, id, userID) return err }, "展示状态已切换") } // topicMutate 收敛删除 / 开关两个动作里重复的取参与错误处理。 // 按属主收口(专题是用户级的),**不要求有生效数据集**——没有数据时照样该能整理专题。 func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64, int) error, okMsg string) { ctx := r.Context() if err := r.ParseForm(); err != nil { s.serverError(w, r, err) return } id := atoiOr(r.PostFormValue("id"), 0) if id <= 0 { redirectTopics(w, r, "err", "专题标识无效") return } if err := fn(ctx, userFrom(ctx).ID, id); err != nil { s.serverError(w, r, err) return } redirectTopics(w, r, "ok", okMsg) } // ---------- 前台:专题列表与详情 ---------- func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() p, err := s.pageBase(r, "topics", "专题") if err != nil { s.serverError(w, r, err) return } d := TopicListData{Page: p} if p.Dataset != nil { list, err := s.loadTopics(ctx, p.Dataset.UserID, !p.IsAdmin()) if err != nil { s.serverError(w, r, err) return } regions, exclude := visitorRegions(p) if err := s.fillTopicStatsBatch(ctx, list, p.Dataset.ID, regions, exclude); err != nil { log.Printf("专题统计失败: %v", err) } d.Topics = list } s.render(w, "topics", d) } func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() p, err := s.pageBase(r, "topics", "专题") if err != nil { s.serverError(w, r, err) return } p.HasMap = true id := atoiOr(r.PathValue("id"), 0) if id <= 0 || p.Dataset == nil { s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。") return } var t Topic var rawAreas []byte err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon, radius_km, show_fog, enabled, sort_order, areas FROM topics WHERE id=$1 AND user_id=$2`, id, p.Dataset.UserID). Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt, &t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder, &rawAreas) if err == sql.ErrNoRows { s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。") return } if err != nil { s.serverError(w, r, err) return } if t.Areas, err = parseTopicAreas(rawAreas); err != nil { s.serverError(w, r, fmt.Errorf("专题 #%d 的显示区解析失败: %w", t.ID, err)) return } // 未对外展示的专题只有站长能看。给访客的话术与「不存在」保持一致—— // 这里刻意不区分,否则等于告诉访客「这个 ID 存在但你没资格看」。 if !t.Enabled && !p.IsAdmin() { s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。") return } t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout) t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout) t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt) regions, exclude := visitorRegions(p) points, visits, days, cities, err := s.topicContent(ctx, &t, p.Dataset.ID, regions, exclude) if err != nil { s.serverError(w, r, err) return } t.PlaceCount, t.VisitCount, t.DayCount, t.CityCount = len(points), visits, days, cities for _, mp := range points { t.DwellMin += mp.Dwell } p.Title = t.Title // 站点设置里的「备注对外可见」同样适用于专题页 if p.IsAdmin() || p.Settings.NotesPublic { s.applyNotesToPoints(ctx, p.Dataset.ID, points) } js, err := json.Marshal(points) if err != nil { s.serverError(w, r, err) return } fitBBox := fitParams(t, points) d := TopicData{ Page: p, Topic: t, Points: points, IsAdmin: p.IsAdmin(), PointsJSON: template.JS(js), FitBBoxJSON: fitBBox, } s.render(w, "topic", d) } // applyNotesToPoints 把别名/备注贴到地图点上(专题页的迷你地点列表也用)。 func (s *Server) applyNotesToPoints(ctx context.Context, datasetID int64, points []MapPoint) { ns, err := s.notes.All(ctx, datasetID) if err != nil || len(ns) == 0 { return } for i := range points { if n, ok := ns[points[i].SrcPK]; ok { if n.Alias != "" { points[i].Name = n.Alias } points[i].Note = n.Content } } } // fitParams 生成把地图视野框到专题范围的脚本参数:限定显示区时用**所有圆的并集外接矩形**, // 否则用地点包围盒——两者都能保证「区域内的迷雾」正好落在视野里。 // // 早先这里还给过一个 fitCircle(中心 + 半径),多圆之后放弃:它只表达得了一个圆, // 留着就是两套并行的贴合口径。单圆时外接矩形正是那个圆的外接正方形,视野与原路径等价。 func fitParams(t Topic, points []MapPoint) template.JS { if minLat, maxLat, minLon, maxLon, ok := topicAreasBounds(t.AreaList()); ok { b, _ := json.Marshal(map[string]any{ "minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon, }) return template.JS(b) } if len(points) == 0 { return template.JS("null") } minLat, maxLat := points[0].Lat, points[0].Lat minLon, maxLon := points[0].Lon, points[0].Lon for _, p := range points { minLat, maxLat = math.Min(minLat, p.Lat), math.Max(maxLat, p.Lat) minLon, maxLon = math.Min(minLon, p.Lon), math.Max(maxLon, p.Lon) } b, _ := json.Marshal(map[string]any{ "minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon, }) return template.JS(b) } // redirectTopics 把结果带回专题管理页(保留可能的 ?edit= 上下文由页面自己处理)。 func redirectTopics(w http.ResponseWriter, r *http.Request, kind, msg string) { back := r.Referer() if !strings.Contains(back, "/admin/topics") { back = "/admin/topics" } sep := "?" if strings.Contains(back, "?") { sep = "&" } http.Redirect(w, r, back+sep+kind+"="+urlQueryEscape(msg), http.StatusSeeOther) }