Files
rond-with-you/internal/web/handlers_topics.go
T
tamakyi 770e752a7a 图片恢复支持对象存储(R2);专题也改成归人不归数据集
R2 上的图怎么救:库里的行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。
给 internal/media 加了 ListObjects(ListObjectsV2 + SigV4 + 自动翻页 + XML 解析):
- 路径式访问、请求只到桶;查询串必须**先百分号编码再签名**(canonicalQuery 按原样取
  RawQuery,两边得是同一份字节,prefix 里的 / 要写成 %2F)。
- ⚠️ R2 的 API Token 默认只给对象读写,没有 s3:ListBucket 就 403。列桶失败**不让整个
  恢复失败**:本地那半照救,原因进 Warnings、页面用红色提示条显示(否则站长会以为恢复完了)。
- 候选对象 = 本地目录 ∪ 桶里 .webp(同键以本地为准);行的 storage 按「对象实际在哪」写,
  bytes 用桶里报的 Size。另加一条 warning:挂回了却没配 s3_public_base ⇒ 行有了页面空白,
  这比「没恢复」更难懂。

专题(用户拍板「一并做」):topics.dataset_id 同样带 CASCADE,删数据集带走专题,而专题
只存在这张表里、没有恢复来源。外键摘掉,所有查询 dataset_id → user_id(loadTopics、
topicPage、保存/删除/开关、pageBase.HasTopics);**内容仍照当前生效数据集算**
(topicContent 不动)——变的是「这份定义归谁」。Dataset 补 UserID 供前台按属主定位
用户级数据;管理专题不再要求有生效数据集。

导出链路未改动:place_images / topics / Dataset.UserID 都不参与 .rondbackup 导出,
全量验收(含导出保真、ZRAW 夹取、编号对齐那批)PASS=331 FAIL=0 SKIP=0。
2026-09-30 23:31:17 +08:00

552 lines
20 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"
"strconv"
"strings"
"time"
"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
}
// ---------- 查询 ----------
// 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
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
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); err != nil {
return nil, 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」。
func topicAreaCond(t *Topic, args *[]any) string {
if !t.HasArea() {
return ""
}
*args = append(*args, t.CenterLat, t.CenterLon, t.RadiusKm)
n := len(*args)
return fmt.Sprintf(" AND %s <= $%d",
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
}
// 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
t.CenterLat, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lat")), 64)
t.CenterLon, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lon")), 64)
t.RadiusKm, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("radius_km")), 64)
if t.RadiusKm < 0 {
t.RadiusKm = 0
}
if t.RadiusKm > 0 && t.CenterLat == 0 && t.CenterLon == 0 {
return t, fmt.Errorf("限定区域时要填中心点坐标")
}
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
}
p.Flatpickr = true
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
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); 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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) 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).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
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
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)
if err == sql.ErrNoRows {
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
return
}
if err != nil {
s.serverError(w, r, 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
}
fitCircle, fitBBox := fitParams(t, points)
d := TopicData{
Page: p, Topic: t, Points: points, IsAdmin: p.IsAdmin(),
PointsJSON: template.JS(js), FitCircleJSON: fitCircle, 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 生成把地图视野框到专题范围的脚本参数:限定区域时用圆心 + 半径,
// 否则用地点包围盒——两者都能保证「区域内的迷雾」正好落在视野里。
func fitParams(t Topic, points []MapPoint) (circle, bbox template.JS) {
if t.HasArea() {
b, _ := json.Marshal(map[string]any{
"lat": t.CenterLat, "lon": t.CenterLon, "radiusKm": t.RadiusKm,
})
return template.JS(b), template.JS("null")
}
if len(points) == 0 {
return template.JS("null"), 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("null"), 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)
}