用户诉求的后半:不该让他手填经纬度和半径,应该在地图上圈。三个手填输入框
换成地图 + 圆列表:点地图加圆、拖圆心移动、拖圆边改半径、列表里可改半径/
定位/删除、可一次清空;改动立刻写进隐藏字段 areas(JSON) 与 crs,随专题表单
一起提交。
- 复用 /admin/fog-mask 那套约定:/api/map-config 取底图、底图坐标系随表单带上、
后端换算成 GCJ-02。不另起炉灶。
- areas 的初值**渲染进隐藏字段**(不是只塞给 JS):脚本没加载上时提交的仍是原值,
否则一次保存就把已有显示区静默清空。
- 地图初始视野取生效数据集的地点包围盒(placesBounds),一上来就落在自己的地界上,
而不是全国视野。
- 上限(20 个 / 0.05~2000 km)前端也校验一份,并**禁用提交**;两边数字用守卫比对,
免得出现「前端放行、后端拒绝,用户不知道改哪里」。
实测抓到并修掉的 bug:/admin/topics 没设 p.HasMap ⇒ 布局不引入 Leaflet,
表现是脚本加载了、rondTopicAreas 也在,就是 window.L 永远 undefined,init 静默
返回、控制台不报错、页面像「什么都没发生」。已加守卫钉住这一点。
其余:
- topic-areas.js 只暴露纯函数(校验/序列化/增删改的不可变性/几何换算)供 node 单测,
另加一个最小测试钩子 api._map 供无头验证(同 window.rondMap._state 惯例)。
- CSS 用定宽容器装半径输入框:站点全局 input 规则是 width:100% 且带 10 个 :not()
(特异性 (0,11,1)),直接给 input 写宽度压不过。
- gofmt:文档注释里的缩进列表按 Go 1.19 的规则重排;顺带把工作区的 CRLF 归一到 LF
(.gitattributes 早就声明 eol=lf,blob 本来也是 LF,所以 git diff 无额外噪声)。
验证:
- topic-areas.test.js 44 条(含「增删改返回新数组不原地改」、半径换算自洽性)。
- 三个新守卫:两边上限一致、模板契约(旧输入框必须没了 + 挂点齐全 + 初值来源)、
topicsAdminPage 必须设 HasMap。
- 无头端到端:两击成两圆 → 改半径 7.5 → 删第 1 个 → 越界半径被拦且禁用提交 →
提交 200 且落库 areas=[{lat:24.72,lon:108.1,radiusKm:7.5}](废弃列镜像 7.5);全程无 JS 报错。
732 lines
28 KiB
Go
732 lines
28 KiB
Go
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
|
||
}
|
||
// 显示区编辑器要用 Leaflet:布局按这个标记引入它,漏了就是「地图整块不出现」
|
||
// (实测踩过:脚本加载了、rondTopicAreas 也在,但 window.L 一直是 undefined)。
|
||
p.HasMap = 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)
|
||
}
|
||
// 显示区编辑器的初始视野落在站长自己的地界上(拿不到就退回编辑器自带的兜底视野)
|
||
if b, err := s.placesBounds(ctx, ds.ID); err != nil {
|
||
log.Printf("取地点包围盒失败: %v", err)
|
||
} else {
|
||
d.MapHomeJSON = b
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
|
||
// placesBounds 是生效数据集里可用地点的包围盒,给显示区编辑器当初始视野。
|
||
// 「可用」=有坐标且不是 (0,0)(脏数据会被填成 0,0,落进去会把视野拉到几内亚湾)。
|
||
// 没有地点时返回 null,编辑器自己有兜底视野。
|
||
func (s *Server) placesBounds(ctx context.Context, datasetID int64) (template.JS, error) {
|
||
var minLat, maxLat, minLon, maxLon sql.NullFloat64
|
||
if err := s.db.QueryRowContext(ctx, `SELECT min(lat), max(lat), min(lon), max(lon) FROM places
|
||
WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL AND (lat <> 0 OR lon <> 0)`,
|
||
datasetID).Scan(&minLat, &maxLat, &minLon, &maxLon); err != nil {
|
||
return "", err
|
||
}
|
||
if !minLat.Valid {
|
||
return "null", nil
|
||
}
|
||
b, err := json.Marshal(map[string]any{
|
||
"minLat": minLat.Float64, "maxLat": maxLat.Float64,
|
||
"minLon": minLon.Float64, "maxLon": maxLon.Float64,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return template.JS(b), nil
|
||
}
|
||
|
||
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)
|
||
}
|