Files
rond-with-you/internal/web/handlers_topics.go
T
tamakyi 44763dfc3f 专题的多圆显示区:前台按并集裁剪迷雾
上一批把显示区改成了「一组圆」,这一批让前台真正认多个圆。

- clip-path 从 circle() 换成 path():circle() 只表达得了一个圆。每个圆一段
  圆弧子路径、**同绕向**(sweep=0),nonzero 填充规则下多段自然取并集,
  重叠处不会互相挖洞。
- 拼路径的活单独放 fog-clip.js(rond-map.js 是个大 IIFE,函数取不出来),
  这样能直接跑 node 单测;layout 里必须排在 rond-map.js 之前加载。
- 顺带清掉已经死掉的 fitCircle:多圆之后「圆心 + 半径」表达不了,留着就是
  两套并行的贴合口径。fitParams 现在只给 fitBBox(单圆时外接矩形正是那个
  圆的外接正方形,视野等价)。
- 守卫改名 TestTopicFogIsClippedToAreas,钉住两端(模板发 fogAreas ↔ 脚本认
  fogAreas)与两处顺序(裁剪注册在 applyFit 之后、fog-clip.js 在 rond-map.js
  之前);fogAreas 必须**无条件**下发(空数组 = 不限区域),不要包在条件里。
- verify.sh 挂进前端单测:以前只靠人记得手动跑 = 等于没有。找不到 node 直接
  失败而不是跳过(跳过会让验收看着全绿、实际一行前端脚本都没验)。

验证(临时两圆专题,SQL 造的,已删):
- fog-clip.test.js 15 条:把生成的 path 解析回圆弧再验几何,含绕向一致性与
  半径 0/负数的夹取。
- 端到端像素验证:自校准出「CSS 容器坐标 → 图像像素」(scale≈2、offset≈19.2,
  正是 site_cdp 的 --clip 外扩 10px×scale,别再手算);三张掩码图证明并集语义
  —— A 独有点只有 A 亮、B 独有点只有 B 亮、**重叠中心两圆都亮且并集也亮
  (无洞)**,整体集合差异 0.013%。
- 迷雾掩码:86,776 个像素,按两圆并集的解析式判 **圆外 0 个**;两个圆内分别
  有 52,609 / 46,757 个迷雾像素 ⇒ 第二个圆真的被认了,不是只看第一个。
2026-10-12 16:44:55 +08:00

700 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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=<id>
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)
}