Files
rond-with-you/internal/web/view.go
T
tamakyi ff46a9cbb8 统计页把「我的结论」与「访客点赞」拆成两块
用户问「数据统计处的推荐地点和踩雷地点只针对站长吗?应该分开,用户的点赞点踩单独做一块」。
他看到的没错:那张卡的**列表全是站长结论**(verdictPlaces = place_votes),访客票只在副标题里
露了个总数(👍 N · 👎 M)——既看不出哪几个地点被顶上去,也容易被当成两者合并的榜。

- 新增 votes.TopVisitorVoted:按访客票数列地点榜。HAVING 只算**至少有一票的那个方向**,
  否则 0 票的地点会把榜塞满;同票数按 visit_count DESC, p.id 排,刷新之间顺序不跳。
  票按坐标键挂,所以 join 回当前数据集的地点才定位得到(与 Tallies/verdictPlaces 同一口径)。
- 「推荐 & 踩雷(我的结论)」只讲站长,副标题只留「推荐 N · 踩雷 M」,标题与列表都改成「我」的口吻。
- 新增「访客点赞」卡:副标题总票数,两列列 👍/👎 票最多的地点并带票数,各有空态。
- 加载拆成两个 pageJob(verdicts / visitorvotes)并行,两块互不依赖就不该串着等。
- 两块共用同一个可见性开关(IsAdmin || Settings.MarksPublic),与地点页投票区一致。

用例(stats_votes_split_test.go)按卡片标题切出两段 HTML,断言「我标的地点只出现在站长块、
访客顶的地点只出现在访客块」,并核对两边各自的计数——比单看总数更能证明没串台。

⚠️ 又一次同一个坑:两张票表都按坐标键读、与数据集无关,place_visitor_votes 还没有 user_id、
不随删用户清理 ⇒ 上一轮跑剩的票会被算进来、票数越堆越多。新增 isolateVoteState 按 place_key
清两张表(前置 + cleanup 各一次)。

顺带订正 README:那里还写着站长结论「按 src_pk 存」,其实早就是坐标键了。
全量验收 PASS=338 FAIL=0 SKIP=0。
2026-10-01 01:47:48 +08:00

1684 lines
48 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 (
"database/sql"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/fog"
"rond-with-you/internal/rond"
"rond-with-you/internal/setting"
"rond-with-you/internal/stats"
"rond-with-you/internal/votes"
)
// SetFlashFromQuery 取重定向带回来的一次性提示(?ok= / ?err=)。
//
// 凡是「处理完跳回本页」的页面都必须调一次:`adminFlash` 之类的帮助函数把消息挂在
// URL 上,页面不读就静默丢了——后台首页就漏过这一句,上传备份、导入迷雾、开两步验证
// 全都没有任何提示。
func (p *Page) SetFlashFromQuery(r *http.Request) {
q := r.URL.Query()
p.Flash, p.Err = q.Get("ok"), q.Get("err")
}
// Page 是所有页面共享的上下文(站点设置、登录态、筛选器状态)。
type Page struct {
Settings setting.Settings
User *auth.User
Nav string
Title string
Flash string
Err string
HasData bool
Dataset *Dataset
// AssetV 是静态资源版本号,链接带 ?v=AssetV 时才允许长期缓存
AssetV string
// TrackScript 是站点配置里的第三方统计脚本(已包成可直接输出的 HTML)
TrackScript template.HTML
Activities []stats.ActivityStat
TagOptions []stats.TagStat
CityList []string
From string
To string
ActivityIDs []int64
City string
TagID int64
Keyword string
MinDwell int
Sort string
// Verdict 是地点库的「推荐 / 踩雷」筛选(1 推荐 / -1 踩雷 / 0 不限)
Verdict int
PageNo int
PageCount int
Total int
RangeLabel string
FilterBase string
Presets []RangePreset
// ShowHomeWork 表示本次请求允许包含「家 / 工作」。
// 只有登录后的站长主动打开开关才为 true,匿名访客拿不到。
ShowHomeWork bool
// HasMap 决定是否加载 Leaflet,避免没有地图的页面白下载一份地图库。
HasMap bool
// Flatpickr 决定是否加载日期时间选择器(目前只有数据编辑页在用)。
Flatpickr bool
// HasTopics 表示该数据集有专题,导航里才露出「专题」入口。
HasTopics bool
// Back 是「回到哪里」(来自 ?back=):列表页把自己的地址挂给详情页,详情页的「返回」
// 和「编辑」再把它带下去 —— 这样从筛选结果点进去、编辑完还能回到**同一份筛选**,
// 而不是回到裸列表。只接受站内路径,空表示没有来源。
Back template.URL
// BackQS 是当前页面地址(含查询串、去掉 back 自己)编码后的 ?back= 参数值,
// 列表页生成指向详情页的链接时用它。必须整串编码:里面有 & 时不编码会把后面的参数截断。
BackQS template.URL
// SelfPath 是 BackQS 的**未编码**原文。需要在这一层再编码一次的场景(编辑页的
// ?back=)必须用它——拿已编码的 BackQS 再编码会变成 %252F…(见 EditPlaceLink)。
SelfPath string
}
// IsAdmin 报告当前访问者是否已登录后台。
func (p Page) IsAdmin() bool { return p.User != nil }
// RangePreset 是筛选器上的快捷时间范围。
type RangePreset struct {
Label string
From string
To string
Active bool
}
func (p Page) IsAct(id int64) bool {
for _, v := range p.ActivityIDs {
if v == id {
return true
}
}
return false
}
func (p Page) filterPairs() []string {
var kv []string
if p.ShowHomeWork {
kv = append(kv, "hw", "1")
}
if p.From != "" {
kv = append(kv, "from", p.From)
}
if p.To != "" {
kv = append(kv, "to", p.To)
}
for _, id := range p.ActivityIDs {
kv = append(kv, "act", strconv.FormatInt(id, 10))
}
if p.City != "" {
kv = append(kv, "city", p.City)
}
if p.TagID > 0 {
kv = append(kv, "tag", strconv.FormatInt(p.TagID, 10))
}
if p.MinDwell > 0 {
kv = append(kv, "min", strconv.Itoa(p.MinDwell))
}
if p.Keyword != "" {
kv = append(kv, "q", p.Keyword)
}
if p.Sort != "" {
kv = append(kv, "sort", p.Sort)
}
if p.Verdict != 0 {
kv = append(kv, "verdict", strconv.Itoa(p.Verdict))
}
return kv
}
// selfPath 是当前请求的地址(路径 + 规范化后的查询串),**未编码**。
//
// 去掉 back 自身是必须的:详情页的地址里本来就带着 back(来自列表页),
// 原样再编码一层会变成套娃,回退一次只退到上一层的自己。
// 用 url.Values.Encode() 顺带把参数规范化(排序、重新编码),服务端解析结果不变。
func selfPath(r *http.Request) string {
q := r.URL.Query()
q.Del("back")
// ok / err 是重定向带回来的一次性提示,挂进 back 会让它在下一页再弹一次
q.Del("ok")
q.Del("err")
u := r.URL.Path
if e := q.Encode(); e != "" {
u += "?" + e
}
return u
}
// backQS 是 selfPath 的编码形式,直接当 ?back= 的值用。
// 需要在这一层再编码一次的场景要用 selfPath,别拿它再转一遍(会变成 %252F…)。
func backQS(r *http.Request) template.URL {
return template.URL(url.QueryEscape(selfPath(r)))
}
// safeInternalPath 只放行**站内路径**,不合法时返回空串。
//
// 返回值会当 template.URL 用(绕过 html/template 的 URL 转义),所以必须挡死
// 「//evil.com」这种协议相对地址,以及引号/尖括号这类能跳出属性的字符 ——
// 否则只要有人把链接拼成 /places/1?back=https://evil.com,这里就成了开放重定向。
// 控制字符(含 %00、%0b)也一律不收:浏览器会把 NUL 之类的字符悄悄吃掉,
// 留着就是给「同一串地址在不同环节解析成不同东西」留口子。
//
// ?back=、后台表单的 next、投票后的跳转三处共用这一份判据,别再各写一遍。
func safeInternalPath(p string) string {
p = strings.TrimSpace(p)
if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
return ""
}
for i := 0; i < len(p); i++ {
if c := p[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' {
return ""
}
}
return p
}
func backFromRequest(r *http.Request) template.URL {
return template.URL(safeInternalPath(r.URL.Query().Get("back")))
}
// BackRaw 是 Back 的纯字符串形式,给**非 href** 的场景用(表单的 hidden 字段):
// 那种位置由模板按普通属性做 HTML 转义就够了,用 template.URL 反而会被当成 URL 再处理一遍。
func (p Page) BackRaw() string { return string(p.Back) }
// BackLabel 给「返回」按钮起个名字:从哪儿来的就写哪儿。
// 先剥掉查询串与 fragment,再取「第一个路径段」——
// 否则 `/places?city=X` 这种「带筛选、没有下级路径」的地址整串都是第一段,
// 匹配不上任何前缀,按钮就退化成光秃秃的「返回」(这个坑踩过一次)。
func (p Page) BackLabel() string {
b := string(p.Back)
if b == "" {
return ""
}
if i := strings.IndexAny(b, "?#"); i >= 0 {
b = b[:i]
}
seg := strings.SplitN(strings.TrimPrefix(b, "/"), "/", 2)[0]
switch seg {
case "":
return "返回首页"
case "places":
return "返回地点库"
case "timeline":
return "返回时间线"
case "map":
return "返回地图"
case "stats":
return "返回数据统计"
case "topics":
return "返回专题"
case "admin":
if strings.HasPrefix(b, "/admin/edit") {
return "返回数据编辑"
}
return "返回后台"
}
return "返回"
}
// PlaceLink 指向某个地点的详情页,并把「返回到当前这份筛选结果」一起带上。
func (p Page) PlaceLink(id int64) template.URL {
u := "/places/" + strconv.FormatInt(id, 10)
if p.BackQS != "" {
u += "?back=" + string(p.BackQS)
}
return template.URL(u)
}
// EditPlaceLink 是「到数据编辑页打开这个地点」,同样带上返回地址。
// 优先用列表页给的 Back(站长要的是「改完回地点库」),没有就退回当前这一页。
//
// 这里必须用**未编码**的 SelfPath:BackQS 已经 QueryEscape 过一次,
// 再转一次就成了 %252F…,服务端解出来不以 / 开头会被当成站外地址丢掉,
// 于是「直接打开 /places/1 再点编辑」的编辑页上返回按钮凭空消失。
func (p Page) EditPlaceLink(srcPK int) template.URL {
back := string(p.Back)
if back == "" {
back = p.SelfPath
}
u := "/admin/edit?open=" + strconv.Itoa(srcPK)
if back != "" {
u += "&back=" + url.QueryEscape(back)
}
return template.URL(u)
}
// MapLink 是「在地图中查看这个地点」:按名字筛。
//
// 名字必须 QueryEscape —— 以前是直接内插进 href 的,名字里带 & 会把参数拆断、
// 带 # 会把后面整段吃掉(浏览器当 fragment)。真机数据里现在没有这种名字,
// 但用户随时能自己起一个。
func (p Page) MapLink(name string) template.URL {
return template.URL("/map?q=" + url.QueryEscape(strings.TrimSpace(name)))
}
// Link 在保留当前筛选条件的前提下生成链接。extra 里的键会**覆盖**同名项,而不是追加——
// 否则会拼出 ?sort=dwell&sort=name 这种重复参数,而取参数只认第一个,
// 表现为「点了另一个排序没反应」。extra 里值为空的键会被丢掉,
// 这样「回到默认排序」只要传 sort=""(buildURL 会跳过空值)。
func (p Page) Link(base string, extra ...string) string {
kv := p.filterPairs()
drop := map[string]bool{}
for i := 0; i+1 < len(extra); i += 2 {
drop[extra[i]] = true
}
out := make([]string, 0, len(kv)+len(extra))
for i := 0; i+1 < len(kv); i += 2 {
if drop[kv[i]] {
continue
}
out = append(out, kv[i], kv[i+1])
}
return buildURL(base, append(out, extra...))
}
// ActLink 点击类型标签时切换该类型的选中状态。
func (p Page) ActLink(id int64) string {
var kv []string
has := false
for _, v := range p.ActivityIDs {
if v == id {
has = true
continue
}
kv = append(kv, "act", strconv.FormatInt(v, 10))
}
if !has {
kv = append(kv, "act", strconv.FormatInt(id, 10))
}
rest := p.filterPairs()
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] != "act" {
kv = append(kv, rest[i], rest[i+1])
}
}
return buildURL(p.FilterBase, kv)
}
// TagLink 切换标签筛选。
func (p Page) TagLink(id int64) string {
kv := p.filterPairs()
out := kv[:0]
for i := 0; i+1 < len(kv); i += 2 {
if kv[i] != "tag" {
out = append(out, kv[i], kv[i+1])
}
}
if p.TagID != id {
out = append(out, "tag", strconv.FormatInt(id, 10))
}
return buildURL(p.FilterBase, out)
}
// CityLink 切换城市筛选。
func (p Page) CityLink(city string) string {
kv := p.filterPairs()
out := kv[:0]
for i := 0; i+1 < len(kv); i += 2 {
if kv[i] != "city" {
out = append(out, kv[i], kv[i+1])
}
}
if p.City != city {
out = append(out, "city", city)
}
return buildURL(p.FilterBase, out)
}
// VerdictLink 切换「推荐 / 踩雷」筛选(v=1 推荐 / -1 踩雷)。
func (p Page) VerdictLink(v int) string {
kv := p.filterPairs()
out := kv[:0]
for i := 0; i+1 < len(kv); i += 2 {
if kv[i] != "verdict" {
out = append(out, kv[i], kv[i+1])
}
}
if p.Verdict != v {
out = append(out, "verdict", strconv.Itoa(v))
}
return buildURL(p.FilterBase, out)
}
// RangeLink 切换日期区间。
func (p Page) RangeLink(from, to string) string {
kv := p.filterPairs()
out := kv[:0]
for i := 0; i+1 < len(kv); i += 2 {
if kv[i] != "from" && kv[i] != "to" {
out = append(out, kv[i], kv[i+1])
}
}
if from != "" {
out = append(out, "from", from)
}
if to != "" {
out = append(out, "to", to)
}
return buildURL(p.FilterBase, out)
}
// MonthLink 把「2026-09」这样的月份键变成该月的日期区间链接,
// 用于点月度柱条直接把筛选切到那个月。
func (p Page) MonthLink(key string) string {
t, err := time.Parse("2006-01", key)
if err != nil {
return p.FilterBase
}
return p.RangeLink(t.Format("2006-01-02"), t.AddDate(0, 1, 0).Format("2006-01-02"))
}
// HWLink 切换「包含家 / 工作」。站点默认隐藏这两个常住地,
// 站长登录后在页面上打开开关即可临时显示。
func (p Page) HWLink() string {
kv := p.filterPairs()
out := kv[:0]
for i := 0; i+1 < len(kv); i += 2 {
if kv[i] != "hw" {
out = append(out, kv[i], kv[i+1])
}
}
if !p.ShowHomeWork {
out = append(out, "hw", "1")
}
return buildURL(p.FilterBase, out)
}
// RemoveFilter 生成「摘掉某个筛选条件」的链接。
// key 传 "date" 时同时摘掉 from 与 to(日期是一组);
// value 非空时只摘掉多值参数(act)里的这一项,其余保留。
func (p Page) RemoveFilter(key, value string) string {
kv := p.filterPairs()
out := kv[:0]
for i := 0; i+1 < len(kv); i += 2 {
k, v := kv[i], kv[i+1]
match := k == key || (key == "date" && (k == "from" || k == "to"))
if !match {
out = append(out, k, v)
continue
}
if value != "" && v != value {
out = append(out, k, v) // 多值参数里只摘掉指定的那个
}
}
return buildURL(p.FilterBase, out)
}
// ActiveChip 是「已选条件」行里的一项。
type ActiveChip struct {
Label string
RemoveURL string
}
// ActiveChips 列出当前生效的筛选,让站长逐个摘掉。
// 以前只能靠「重置」全清——想撤掉其中一个,得手动改回「全部」再点应用。
func (p Page) ActiveChips() []ActiveChip {
var out []ActiveChip
if p.From != "" || p.To != "" {
label := "截至 " + p.To
switch {
case p.From != "" && p.To != "":
label = p.From + " ~ " + p.To
case p.From != "":
label = p.From + " 起"
}
out = append(out, ActiveChip{Label: label, RemoveURL: p.RemoveFilter("date", "")})
}
for _, id := range p.ActivityIDs {
for _, a := range p.Activities {
if a.ID == id {
out = append(out, ActiveChip{Label: a.Name,
RemoveURL: p.RemoveFilter("act", strconv.FormatInt(id, 10))})
break
}
}
}
if p.City != "" {
out = append(out, ActiveChip{Label: p.City, RemoveURL: p.RemoveFilter("city", "")})
}
if p.TagID > 0 {
for _, t := range p.TagOptions {
if t.ID == p.TagID {
out = append(out, ActiveChip{Label: t.Name, RemoveURL: p.RemoveFilter("tag", "")})
break
}
}
}
if p.MinDwell > 0 {
out = append(out, ActiveChip{Label: "≥ " + strconv.Itoa(p.MinDwell) + " 分钟",
RemoveURL: p.RemoveFilter("min", "")})
}
if p.Keyword != "" {
out = append(out, ActiveChip{Label: "“" + p.Keyword + "”", RemoveURL: p.RemoveFilter("q", "")})
}
if p.Verdict != 0 {
label := "👍 推荐"
if p.Verdict < 0 {
label = "👎 踩雷"
}
out = append(out, ActiveChip{Label: label, RemoveURL: p.RemoveFilter("verdict", "")})
}
if p.ShowHomeWork {
out = append(out, ActiveChip{Label: "含家/工作", RemoveURL: p.RemoveFilter("hw", "")})
}
return out
}
func buildURL(base string, kv []string) string {
if len(kv) == 0 {
return base
}
v := url.Values{}
for i := 0; i+1 < len(kv); i += 2 {
if kv[i+1] == "" {
continue
}
v.Add(kv[i], kv[i+1])
}
if len(v) == 0 {
return base
}
return base + "?" + v.Encode()
}
// ---------- 页面数据 ----------
type HomeData struct {
Page
Overview stats.Overview
ActivityStats []stats.ActivityStat
CityStats []stats.CityStat
Monthly []MonthBar
Calendar []CalWeek
Transports []stats.TransportStat
Tags []stats.TagStat
Weather []stats.WeatherStat
Recent []stats.VisitRow
Places []stats.Place
Points []MapPoint
MaxActivity int
MaxCity int
MaxTransport int
MaxTag int
}
type MapData struct {
Page
Points []MapPoint
Legend []LegendItem
}
// ModeCount 是轨迹图例的一项:交通方式、段数与颜色。
type ModeCount struct {
Name string `json:"name"`
Count int `json:"count"`
Color string `json:"color"`
}
// modeFallback 是没记录交通方式的位移段在轨迹上归到的类别(灰)。
const modeFallback = "步行 / 其他"
// LegendItem 是地图右下角图例的一项,按当前筛选结果统计。
type LegendItem struct {
Name string
Color string
Icon string
Count int
}
type PlacesData struct {
Page
Places []stats.Place
}
type PlaceData struct {
Page
Place stats.Place
Visits []stats.VisitRow
Points []MapPoint
// CategoryLabel 是类别的显示名(站点自定义的优先)。类别的中文名在模板里算不出来——
// 它要读站点覆盖,所以在这里先渲染好。
CategoryLabel string
// Verdict 是站长官方结论(1=推荐 / -1=踩雷 / 0=未标);Votes 是访客票数;
// MyVote 是当前访客自己对这一地点的投票。
Verdict int
Votes votes.Tally
MyVote int
// MyNote 是站长手写的备注(编辑框里编辑的就是它);
// RondNote 是 rond 备份里带出来的备注,只读,展示时拼在 MyNote 之前。
MyNote string
RondNote string
// Images 是站长给这个地点配的图片(按 position 排序)。
Images []PlaceImage
// ImageURLs 是 Images 的地址列表,随页面一起发给浏览器。
// 访客页里没有缩略图墙(那一整块是站长专属的),画廊只能拿这份当数据源。
ImageURLs []string
// ImageBackend 是当前生效的图片存储(local / s3),后台那块面板展示用;
// ImageReady 为假表示选了对象存储但凭据没配齐,此时不允许上传。
ImageBackend string
ImageReady bool
// ImageMaxPer 是单个地点的图片张数上限,模板里用来提示
ImageMaxPer int
}
type TimelineData struct {
Page
Days []TimelineDay
}
type StatsData struct {
Page
Overview stats.Overview
ActivityStats []stats.ActivityStat
CityStats []stats.CityStat
ProvinceStats []stats.ProvinceStat
Monthly []MonthBar
Hourly []int
Weekday []stats.Bucket
Transports []stats.TransportStat
Tags []stats.TagStat
Weather []stats.WeatherStat
Temps []TempPoint
Fog *FogCoverage
// 站长结论(推荐 / 踩雷)与访客票数 —— 两块分开:
// VoteRec/VoteAvoid + Recommended/Avoided 是**站长的结论**(只由站长标注);
// VoteUp/VoteDown + VisitorUp/VisitorDown 是**访客的点赞点踩**(匿名一人一票)。
// 早先两块挤在一张卡上、访客票只在副标题里露个总数,看不出「哪几个地点被顶上去」。
VoteRec int
VoteAvoid int
VoteUp int
VoteDown int
Recommended []stats.Place
Avoided []stats.Place
VisitorUp []votes.VotedPlace
VisitorDown []votes.VotedPlace
MaxMonthly int
MaxHourly int
MaxActivity int
MaxCity int
MaxWeekday int
MaxTag int
}
// ReportData 是年度报告页的数据。
type ReportData struct {
Page
Year int
Years []int
Overview stats.Overview
TopCities []stats.CityStat
Transports []stats.TransportStat
North *stats.Place
South *stats.Place
NewPlaces int
// Cities 是本年度「首次到达」的城市里程碑(按首次到达日期升序)。
Cities []CityMilestone
Fog *FogCoverage
}
// CityMilestone 是年度报告的「首次到达某城」里程碑:该城市有史以来第一次到访落在这一年。
type CityMilestone struct {
City string
Province string
First string // 首次到达日期 YYYY-MM-DD
}
type LoginData struct {
Settings setting.Settings
Err string
Next string
Flash string
}
type AdminData struct {
Page
Datasets []Dataset
MaxSize int64
// 迷雾当前状态(未导入时 Blocks 为 0,模板据此显示空态提示)
FogBlocks int
FogCells int
FogArea float64
FogUpdatedAt *time.Time
// FogMasks 是遮罩区数量,模板据此在按钮上显示计数
FogMasks int
// DefaultPW 表示管理员仍在使用初始口令,模板顶部给出警示条
DefaultPW bool
// 图片挂载体检:库里的行数、盘上的对象数、以及「对象有而库里没行」的落单数。
// 落单 > 0 就是「有一批图其实还在、却看不见」,模板据此给警示条。
ImgRows, ImgObjects, ImgOrphans int
}
// VisitRow 是一条到访摊平后的展示行。地点与活动写名称而不是编号——
// 两边的编号空间完全不同,编号对人没有任何意义。
type VisitRow struct {
// Key 是这条冲突的对齐键(到访 UUID),合并表单拿它当字段名
Key string
// SrcPK 只有「仅站内有」的行有意义:要删哪条就勾哪条
SrcPK int
Arrival *time.Time
Departure *time.Time
Place string
Activity string
Remark string
Emoji string
UserAdded bool // 站点补录的行
Fields []rond.FieldChange
}
// BackupDiffData 是「备份差异预览」页的数据。
//
// 上限说明:列表按 diffListLimit 截断,页面上只提示「还有更多」,不做分页——
// 预演是给人下判断用的,看前 200 条足够判断要不要继续;真正要逐条取舍的是
// 后面那个合并流程(那里才有逐条操作)。
type BackupDiffData struct {
Page
Token string
DatasetName string
VisitCounts rond.Counts
PlaceCounts rond.Counts
ActCounts rond.Counts
VisitAdded, VisitGone, VisitClash []VisitRow
PlaceAdded []rond.Location
PlaceGone []rond.StoredPlace
PlaceClash []rond.PlaceConflict
ActAdded []rond.Activity
ActGone []rond.StoredActivity
ActClash []rond.ActivityConflict
// 两侧总量,用于「有没有少数据」的直觉判断
BackupVisits, StoredVisits int
BackupPlaces, StoredPlaces int
BackupActs, StoredActs int
BackupMovements, StoredMovements int
BackupWeather, StoredWeather int
BackupRaw int
Truncated bool
Identical bool
}
// BaselineData 是「换基线」页的数据:当前底座是谁、里面有什么、能不能回滚。
type BaselineData struct {
Page
// Dataset 为空表示还没有数据集,页面只给一句提示
Dataset *Dataset
Status *baselineStatus
// Counts 是底座里各表的行数(读不出来时 CountsErr 有值)
Counts []baselineCount
CountsErr string
// OrigName / OrigSHA 是数据集最初上传的那份包;FromOriginal 表示当前底座就是它,
// 也就是说这个数据集还没换过底座。
OrigName string
OrigSHA string
FromOriginal bool
// 刚上传但判据没过、等站长确认的那份包(列表里给个「强制替换」按钮,不用重传)
PendingSHA string
PendingName string
PendingMatched int
PendingWith int
MaxSize int64
}
// FogPreviewData 是「迷雾导入预览」页的数据:先把差异摆出来,再决定叠加还是整体替换。
type FogPreviewData struct {
Page
Token string
Diff fog.Diff
// 快照里不参与渲染、但整体替换会一并换掉的图层条目数
TildeLayers int
HashLayers int
OtherLayers int
// 差异地图的初始视野:两边块集覆盖范围的包围盒(已按底图坐标系换算)。
// 没有任何块时 HasBounds 为假,模板不画地图。
HasBounds bool
MinLat, MinLon float64
MaxLat, MaxLon float64
}
type NotesData struct {
Page
Rows []NoteRow
Orphans []NoteRow // 备注挂在已不在当前数据集的地点上的行
CityOptions []string
Query string
Status string // all / has / none
City string
Sort string
NotesPublic bool
// BackURL 是带完整筛选条件的本页地址,用于行内表单保存后跳回原处
BackURL string
}
// PageLink 生成备注管理页的分页链接,保留当前筛选条件。
func (d NotesData) PageLink(page int) string {
v := url.Values{}
if d.Query != "" {
v.Set("q", d.Query)
}
if d.Status != "" && d.Status != "all" {
v.Set("status", d.Status)
}
if d.City != "" {
v.Set("city", d.City)
}
if d.Sort != "" {
v.Set("sort", d.Sort)
}
v.Set("page", strconv.Itoa(page))
return "/admin/notes?" + v.Encode()
}
// EditData 是数据编辑页的数据:地点 / 到访 / 行程三个可搜索分页列表 + 各类新增表单。
type EditData struct {
Page
Activities []EditOption
Tags []EditOption
Transports []EditTransport
// TransportColors 是 rond 认的那套系统色名,供交通方式选色(顺序固定,便于复用)
TransportColors []string
PlaceOptions []EditOption
VisitOptions []EditOption // 最近到访(值=src_pk),供行程/天气表单选关联
Categories []EditOption // 已有地点的类别(去重),供新建时下拉直选
// KnownCategories 是内置表认得的类别枚举名,页面改「导出值」时拿它做提醒
KnownCategories []string
// ActivityRows / CategoryRows 是顶部「活动与类别」卡片用的台账(带用量)
ActivityRows []EditActivity
CategoryRows []EditCategory
Places []EditPlace
Visits []EditVisit
Movements []EditMovement
Query string
// VisitQuery 是到访记录列表的搜索词(与地点列表的 Query 独立)
VisitQuery string
// MovementFilter 是行程列表的筛选条件,都是选择框而不是让人手输关键词
MovementFilter MovementFilter
PageNo int
PageCount int
Total int
VisitPageNo int
VisitPageCount int
VisitTotal int
MovementPageNo int
MovementPageCount int
MovementTotal int
// SuspectCount 是数据集里「显示坐标疑似就是 WGS-84」的地点总数
SuspectCount int
// SuspectOnly 是地点列表「只看疑似 WGS-84」的开关
SuspectOnly bool
// SourceFilter 是地点列表按「坐标来源」筛选的当前值(空=全部,none=没有留档)
SourceFilter string
// SourceCounts 是各来源的地点数,给筛选下拉显示数量
SourceCounts map[string]int
CanExport bool
GeocodeReady bool
// OpenSrcPK 是「从地点详情页跳进来要定位的地点」(?open=<src_pk>):非 0 时自动翻到
// 该行所在页、展开它的编辑框并高亮,省得在几百行地点里手动搜
OpenSrcPK int
// DupVisits 是「重复到访」卡片检出的站点副本(与保留项的配对),空的就没有卡片
DupVisits []EditDupVisit
// DupUnavailable 非空表示判定不了(基线不可用 / 跟本站到访对不上),
// 此时只显示一行说明、不给按钮——拿一份对不上的参照去删到访比不判更糟
DupUnavailable string
}
// PlaceSources 供模板渲染筛选下拉,顺序固定(与 placeSourceLabels 一致)。
func (d EditData) PlaceSources() []struct{ Value, Label string } { return placeSourceLabels }
// SourceCount 取某个来源的点数,没有就是 0(模板里做「(N)」提示用)。
func (d EditData) SourceCount(v string) int { return d.SourceCounts[v] }
// CoordSysLabel 把坐标系标识写成显示用的短名。
func CoordSysLabel(v string) string { return coordSysLabel(v) }
// SourceLabel 把 coord_source 写成显示名;空值表示历史行没有留档。
func SourceLabel(v string) string {
for _, s := range placeSourceLabels {
if s.Value == v {
return s.Label
}
}
return "未留档"
}
// MovementFilter 是行程列表的筛选条件:交通方式 / 一端的地点 / 时间范围。
// 三个都是可选项,空值表示不限。
type MovementFilter struct {
// Transport 为空=全部、"none"=没有交通方式的那些、数字=transport_src
Transport string
// PlaceID 按起或止任一端匹配(movements 上的 from/to_place_id 是导入与新建时都写好的)
PlaceID int64
From string // YYYY-MM-DD
To string // YYYY-MM-DD,含当天
}
func (f MovementFilter) set(v url.Values) {
if f.Transport != "" {
v.Set("mt", f.Transport)
}
if f.PlaceID > 0 {
v.Set("mp", strconv.FormatInt(f.PlaceID, 10))
}
if f.From != "" {
v.Set("mfrom", f.From)
}
if f.To != "" {
v.Set("mto", f.To)
}
}
// PageLink 是某个列表翻到第 n 页的地址,带上当前的全部筛选条件(三个列表共用,
// 翻页不会把别的列表的条件丢掉)。
//
// 返回 template.URL 是必须的:href 里再经 html/template 的 URL 转义会把 "=" 编成
// %3d,`mt%3d4` 在服务端解出来是「键为 mt=4」的参数,筛选条件会在翻页时静默失效。
//
// 这里**不能**再手工把 & 写成 &amp;:template.URL 在 href 里仍会过一次 urlNormalizer,
// 它会替我们做 HTML 属性转义。手工写一遍的结果是 `&amp;amp;`,浏览器解出来是「字面的
// &amp;」,参数名变成 `amp;vpage` —— 表现就是「带筛选时翻页没反应、筛选还丢了」。
func (d EditData) PageLink(param string, n int) template.URL {
v := d.editFilterValues()
v.Set(param, strconv.Itoa(n))
// back 也要跟着翻页走,否则在数据编辑页翻一页,「返回地点库」就没了
d.putBack(v)
return template.URL("/admin/edit?" + v.Encode())
}
// EditLink 是数据编辑页自己的一个链接:保留三个列表当前的全部筛选与 back,
// clear 里的键会被去掉 —— 用于「重置某个列表的筛选」。
//
// 以前这些「重置」是在模板里手拼 `?vq={{.VisitQuery}}` 的:一来把 back 丢了,
// 二来搜索词里带 & 或空格就会把地址拆坏。统一在这里生成。
func (d EditData) EditLink(clear ...string) template.URL {
v := d.editFilterValues()
for _, k := range clear {
v.Del(k)
}
d.putBack(v)
if e := v.Encode(); e != "" {
return template.URL("/admin/edit?" + e)
}
return template.URL("/admin/edit")
}
// editFilterValues 是数据编辑页三个列表的当前筛选条件(不含页码、不含 back)。
func (d EditData) editFilterValues() url.Values {
v := url.Values{}
if d.Query != "" {
v.Set("q", d.Query)
}
if d.SuspectOnly {
v.Set("sus", "1")
}
if d.SourceFilter != "" {
v.Set("src", d.SourceFilter)
}
if d.VisitQuery != "" {
v.Set("vq", d.VisitQuery)
}
d.MovementFilter.set(v)
return v
}
func (d EditData) putBack(v url.Values) {
if d.Back != "" {
v.Set("back", string(d.Back))
}
}
// EditOption 是表单里的一项选项(活动 / 标签 / 交通 / 地点)。
type EditOption struct {
ID int64
SrcPK int
Name string
Label string
}
// EditActivity 是「活动与类别」卡片里的一行活动:带用量,好一眼看出哪些没人用。
type EditActivity struct {
ID int64
Name string
Visits int
IsHome bool
IsWork bool
}
// EditCategory 是类别台账的一行。类别没有表,值就是 places.poi_category,
// 所以「改名 / 清空」都是按值批量作用到地点上——改成另一个已有值就是合并。
// Label 是显示名(站点自定义的优先),Custom 表示它被改过,Missing 表示还没有中文名。
type EditCategory struct {
Value string
Label string
Places int
Custom bool
Missing bool
}
type EditPlace struct {
ID int64
SrcPK int
Name string
Category string
Province string
City string
District string
Thoroughfare string
Lat float64
Lon float64
VisitCount int
// SuspectWGS 为真表示导入留档里的原始 GPS 与显示坐标完全相同——真机里正常会写
// 「显示坐标 = 原始 GPS 做过 GCJ-02 偏移」,一字不差的就是当初没偏移的那批,
// 在 GCJ-02 底图上会整体偏 500 米左右。
SuspectWGS bool
// OrigLat/OrigLon 是导入留档里的显示坐标(改过之后用来做前后对照)
OrigLat, OrigLon sql.NullFloat64
// MovedM 是当前坐标相对导入时的位移(米),0 表示没动过
MovedM int
// CoordSource/CoordSys 是建点时留下的来源与坐标系;SrcLat/SrcLon 是换算前的原始坐标。
// 三者都为空表示历史行没有留档(2026-09 之前的点都是这样)。
CoordSource string
CoordSys string
SrcLat, SrcLon sql.NullFloat64
// CategoryOptions 是这一行类别下拉的选项:库里的已有类别 + 当前值(不在其中时补上)
CategoryOptions []EditOption
// ActivityText 是该地点名下到访当前的活动构成(「商场 8 · 餐饮 3」这种)。
// 行内编辑里的「活动」是把该地点全部到访一起改,所以得先让人看清现在是什么。
ActivityText string
}
// SourceTag 是列表里那一枚来源标记的文案,没有留档时给「未留档」。
func (p EditPlace) SourceTag() string {
if p.CoordSource == "" {
return "未留档"
}
l := SourceLabel(p.CoordSource)
if s := coordSysLabel(p.CoordSys); s != "" {
l += " · " + s
}
return l
}
// HasSrcCoord 表示这行是否留下了「换算前的原始坐标」,模板据此决定要不要显示。
func (p EditPlace) HasSrcCoord() bool { return p.SrcLat.Valid && p.SrcLon.Valid }
// EditTransport 是交通方式。站点没有单独维护它——rond 的 ZTRANSPORT 是全局列表,
// 导出时由行程里用到的 transport_src 聚合而来,所以这里也从行程里读。
type EditTransport struct {
SrcPK int
Name string
Color string
Icon string
Count int
}
// Label 供下拉/列表显示:没有名称时退回编号,免得出现一个空选项。
func (t EditTransport) Label() string {
if strings.TrimSpace(t.Name) == "" {
return fmt.Sprintf("方式 #%d", t.SrcPK)
}
return t.Name
}
// EditMovement 是行程列表的一行。
type EditMovement struct {
SrcPK int
TransportSrc int
TransportName string
TransportColor string
FromSrc int
ToSrc int
FromLabel string
ToLabel string
// 城市单独放一份:窄屏下这一列只有几十像素,城市名会把行高撑到别处的近两倍,
// 由 CSS 在窄屏隐藏(编号 + 地点名已经够认了)
FromCity string
ToCity string
// StartStr / EndStr 是表单用的 datetime-local 值;Span 是列表里显示用的短区间
StartStr string
EndStr string
Span string
DurationMin int64
DistanceKm float64
}
// movementSpan 把行程区间压成一行:同一天只写一次日期。
// 列表在窄屏下每列只有几十像素,三行时间会把行高撑到别处的两倍。
func movementSpan(start, end time.Time) string {
if start.Format("2006-01-02") == end.Format("2006-01-02") {
return start.Format("01-02 15:04") + " → " + end.Format("15:04")
}
return start.Format("01-02 15:04") + " → " + end.Format("01-02 15:04")
}
type EditVisit struct {
SrcPK int
PlaceName string
City string
ActivityName string
ActivityID int64
Arrival time.Time
ArrivalStr string
DepartureStr string
Remark string
Emoji string
Bookmarked bool
UserAdded bool
// Hidden 非空表示这一行在真机里不独立成立(「已并入 #562」/「子段 #374」):
// 访客侧(时间线、统计、地点计数)不显示它,编辑页要看得到全部原始行,所以只打标不过滤
Hidden string
}
type SettingsData struct {
Page
Modules []setting.Module
Pages []setting.Module
Saved bool
// MapInfo 是当前部署实际生效的底图说明,只读展示。
MapInfo string
// MapLayers 是当前可选的底图图层,供「默认底图」下拉渲染。
MapLayers []MapLayerOption
// S3Info 是对象存储配置状态的只读说明(配没配齐、桶名、公开域名)。
S3Info string
}
// MapLayerOption 是后台「默认底图」下拉的一项。
type MapLayerOption struct {
Key string
Label string
}
type PasswordData struct {
Page
// Username 是当前用户名,改名表单里回显用
Username string
}
// FogMaskData 是迷雾遮罩区管理页的数据。
type FogMaskData struct {
Page
Masks []fog.Mask
MapInfo string
}
// TwoFAData 是两步验证管理页的数据。
type TwoFAData struct {
Page
// Enabled 表示两步验证已开启;Pending 表示已生成秘钥等待扫码确认
Enabled bool
Pending bool
Secret string
// QR 是 otpauth 链接的 PNG 二维码 data URI
QR string
Err string
Flash string
}
// TwoFALoginData 是登录第二步(输入验证码)页面的数据。
type TwoFALoginData struct {
Settings setting.Settings
Pending string
Next string
Err string
}
// ---------- 视图模型 ----------
type MapPoint struct {
ID int64 `json:"id"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Name string `json:"n"`
City string `json:"c,omitempty"`
Act string `json:"a,omitempty"`
Color string `json:"k,omitempty"`
Icon string `json:"i,omitempty"`
Count int `json:"v"`
Dwell int64 `json:"d"`
Home bool `json:"h,omitempty"`
Work bool `json:"w,omitempty"`
LastAt string `json:"t,omitempty"`
// First 是首次到访时间(Unix 秒),供地图的「时间轴回放」按时间累加显示点位;
// 没有到访记录的点为 0,时间轴一律把它们算作「一开始就在」。
First int64 `json:"ft,omitempty"`
// SrcPK / Verdict / Up / Down 供地图气泡展示「推荐 / 踩雷」标记与票数
SrcPK int `json:"sp,omitempty"`
Verdict int `json:"vd,omitempty"`
Up int `json:"u,omitempty"`
Down int `json:"dn,omitempty"`
// Note 是地点备注(访客是否可见由站点设置决定),气泡里直接展示
Note string `json:"note,omitempty"`
}
type MonthBar struct {
Key string
Label string
Count int
Pct int
}
type CalWeek struct {
Days []CalDay
Month string
}
type CalDay struct {
Date string
Day int
Count int
Level int
Empty bool
Title string
}
// BuildCalendar 生成活跃日历(按周分列,周一起始)。
func BuildCalendar(from, to time.Time, counts map[string]int) []CalWeek {
if from.IsZero() || to.IsZero() || to.Before(from) {
return nil
}
start := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
// 回退到周一
off := (int(start.Weekday()) + 6) % 7
start = start.AddDate(0, 0, -off)
end := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, to.Location())
max := 0
for _, c := range counts {
if c > max {
max = c
}
}
var weeks []CalWeek
for cur := start; !cur.After(end); cur = cur.AddDate(0, 0, 7) {
w := CalWeek{}
for i := 0; i < 7; i++ {
d := cur.AddDate(0, 0, i)
key := d.Format("2006-01-02")
if d.Before(from) || d.After(end) {
w.Days = append(w.Days, CalDay{Empty: true})
continue
}
n := counts[key]
w.Days = append(w.Days, CalDay{
Date: key,
Day: d.Day(),
Count: n,
Level: levelOf(n, max),
Title: fmt.Sprintf("%s:%d 条记录", key, n),
})
}
// 每列顶部标注当月,便于阅读
mid := cur.AddDate(0, 0, 3)
w.Month = mid.Format("1月")
weeks = append(weeks, w)
}
// 若跨度超过 60 周,按月聚合展示
if len(weeks) > 60 {
return weeks[:60]
}
return weeks
}
func levelOf(n, max int) int {
if n <= 0 || max <= 0 {
return 0
}
switch {
case n*4 <= max:
return 1
case n*2 <= max:
return 2
case n*4 <= max*3:
return 3
default:
return 4
}
}
// BuildMonthly 把月度统计补齐成连续序列(无记录的月份补 0),
// 否则有空档的时间段会被压缩,看不出真实的节奏。
func BuildMonthly(buckets []stats.Bucket) []MonthBar {
if len(buckets) == 0 {
return nil
}
byKey := make(map[string]int, len(buckets))
for _, b := range buckets {
byKey[b.Key] = b.Count
}
start, err1 := time.Parse("2006-01", buckets[0].Key)
end, err2 := time.Parse("2006-01", buckets[len(buckets)-1].Key)
if err1 != nil || err2 != nil {
return nil
}
// 跨度较大时按年标注,避免「8月」重复出现分不清年份
multiYear := start.Year() != end.Year()
max := 0
for _, b := range buckets {
if b.Count > max {
max = b.Count
}
}
var out []MonthBar
for cur := start; !cur.After(end); cur = cur.AddDate(0, 1, 0) {
key := cur.Format("2006-01")
n := byKey[key]
pct := 2
if max > 0 {
pct = int(float64(n)/float64(max)*100 + 0.5)
}
if pct < 2 {
pct = 2
}
label := fmt.Sprintf("%d月", int(cur.Month()))
if multiYear {
label = fmt.Sprintf("%d/%d", cur.Year()%100, int(cur.Month()))
}
out = append(out, MonthBar{Key: key, Label: label, Count: n, Pct: pct})
}
return out
}
// ---------- 模板函数 ----------
// maxOf 求切片中的最大计数值,用于条形图归一化。
func maxOf[T any](in []T, get func(T) int) int {
m := 0
for _, v := range in {
if n := get(v); n > m {
m = n
}
}
return m
}
// appleColors 是 rond 使用的系统色名到十六进制的映射。
var appleColors = map[string]string{
"red": "#ff3b30", "orange": "#ff9500", "yellow": "#e8b700", "green": "#34c759",
"mint": "#00c7be", "teal": "#30b0c7", "cyan": "#32ade6", "blue": "#007aff",
"indigo": "#5856d6", "purple": "#af52de", "pink": "#ff2d55", "brown": "#a2845e",
"gray": "#8e8e93",
}
// transportColorNames 返回 rond 的系统色名(固定顺序,不随机 map 遍历顺序)。
func transportColorNames() []string {
out := make([]string, 0, len(appleColors))
for n := range appleColors {
out = append(out, n)
}
sort.Strings(out)
return out
}
func colorOf(name string) string {
if c, ok := appleColors[name]; ok {
return c
}
if len(name) == 7 && name[0] == '#' {
return name
}
return "#5b6169"
}
// iconOf 只保留 emoji 图标。rond 里的图标多为 SF Symbols 名称(如 house.fill),
// 在网页上无法渲染,直接返回空串让模板退回默认样式。
func iconOf(s string) string {
for _, r := range s {
if r > 0x2000 {
return s
}
}
return ""
}
func iconOr(s, fallback string) string {
if v := iconOf(s); v != "" {
return v
}
return fallback
}
func templateFuncs() template.FuncMap {
return template.FuncMap{
"num": fmtNum,
"size": fmtSize,
// dur 接受任意整数类型,模板里 int / int64 都会传入
"dur": func(v any) string {
switch n := v.(type) {
case int64:
return fmtDur(n)
case int:
return fmtDur(int64(n))
case int32:
return fmtDur(int64(n))
case float64:
return fmtDur(int64(n))
}
return "—"
},
"km": func(f float64) string { return fmt.Sprintf("%.1f", f) },
"f1": func(f float64) string { return fmt.Sprintf("%.1f", f) },
"dt": func(t time.Time) string { return t.Local().Format("2006-01-02 15:04") },
// actKey 给活动冲突生成表单字段名,与处理函数共用同一套键
"actKey": rond.ActivityKey,
// dtt 接受 *time.Time,nil 返回空串,便于可选时间字段直接渲染
"dtt": func(t *time.Time) string {
if t == nil {
return ""
}
return t.Local().Format("2006-01-02 15:04")
},
"d": func(t time.Time) string { return t.Local().Format("2006-01-02") },
"hm": func(t time.Time) string { return t.Local().Format("15:04") },
"md": func(t time.Time) string { return t.Local().Format("01-02") },
"day": dayLabel,
"since": sinceLabel,
"trunc": func(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
},
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b float64) float64 { return a * b },
"seqn": seqn,
"pct": func(a, b int) int {
if b == 0 {
return 0
}
return int(float64(a)/float64(b)*100 + .5)
},
"dict": dict,
"cond": func(ok bool, a, b string) string {
if ok {
return a
}
return b
},
"colorof": colorOf,
"icon": iconOf,
"iconor": iconOr,
"condition": stats.ConditionName,
"json": func(v any) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("[]")
}
return template.JS(b)
},
}
}
func fmtSize(n int64) string {
switch {
case n >= 1<<30:
return fmt.Sprintf("%.1f GB", float64(n)/(1<<30))
case n >= 1<<20:
return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
case n >= 1<<10:
return fmt.Sprintf("%.1f KB", float64(n)/(1<<10))
default:
return fmt.Sprintf("%d B", n)
}
}
func fmtNum(n int) string {
s := strconv.Itoa(n)
neg := strings.HasPrefix(s, "-")
if neg {
s = s[1:]
}
var out []string
for len(s) > 3 {
out = append([]string{s[len(s)-3:]}, out...)
s = s[:len(s)-3]
}
out = append([]string{s}, out...)
r := strings.Join(out, ",")
if neg {
return "-" + r
}
return r
}
func fmtDur(minutes int64) string {
if minutes <= 0 {
return "—"
}
d := minutes / (60 * 24)
h := (minutes % (60 * 24)) / 60
m := minutes % 60
switch {
case d > 0 && h > 0:
return fmt.Sprintf("%d天%d小时", d, h)
case d > 0:
return fmt.Sprintf("%d天", d)
case h > 0 && m > 0:
return fmt.Sprintf("%d小时%d分", h, m)
case h > 0:
return fmt.Sprintf("%d小时", h)
default:
return fmt.Sprintf("%d分", m)
}
}
var weekdayCN = [...]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
func dayLabel(t time.Time) string {
lt := t.Local()
now := time.Now()
prefix := ""
switch lt.Format("2006-01-02") {
case now.Format("2006-01-02"):
prefix = "今天 · "
case now.AddDate(0, 0, -1).Format("2006-01-02"):
prefix = "昨天 · "
}
return fmt.Sprintf("%s%d年%d月%d日 %s", prefix, lt.Year(), lt.Month(), lt.Day(), weekdayCN[int(lt.Weekday())])
}
func sinceLabel(t time.Time) string {
d := time.Since(t)
if d < 0 {
d = 0
}
days := int(d.Hours() / 24)
switch {
case days == 0:
return "今天"
case days == 1:
return "昨天"
case days < 30:
return fmt.Sprintf("%d 天前", days)
case days < 365:
return fmt.Sprintf("%d 个月前", days/30)
default:
return fmt.Sprintf("%d 年前", days/365)
}
}
func seqn(n int) []int {
out := make([]int, 0, n)
for i := 0; i < n; i++ {
out = append(out, i)
}
return out
}
func dict(kv ...any) map[string]any {
m := map[string]any{}
for i := 0; i+1 < len(kv); i += 2 {
if k, ok := kv[i].(string); ok {
m[k] = kv[i+1]
}
}
return m
}
// ---------- 专题 ----------
// Topic 是一个行程专题:某段时间、某个区域内的足迹打包展示。
type Topic struct {
ID int64
Title string
Subtitle string
Description string
StartAt time.Time
EndAt time.Time
// StartDate / EndDate 是表单用的 YYYY-MM-DD 形式
StartDate string
EndDate string
// RangeLabel 是展示用的时间范围文案
RangeLabel string
CenterLat float64
CenterLon float64
RadiusKm float64
ShowFog bool
Enabled bool
SortOrder int
// PlaceCount / VisitCount / DayCount / CityCount / DwellMin 由内容统计得出
PlaceCount int
VisitCount int
DayCount int
CityCount int
DwellMin int64
}
// HasArea 报告专题是否限定了区域(中心点 + 半径)。
func (t Topic) HasArea() bool { return t.RadiusKm > 0 && (t.CenterLat != 0 || t.CenterLon != 0) }
// TopicAdminData 是后台专题管理页的数据。
type TopicAdminData struct {
Page
Topics []Topic
Form Topic // 表单回填用
Flash string
Err string
IsNew bool
}
// TopicListData 是前台专题列表页的数据。
type TopicListData struct {
Page
Topics []Topic
}
// TopicData 是专题详情页的数据。
type TopicData struct {
Page
Topic Topic
Points []MapPoint
IsAdmin bool
// PointsJSON / FitCircleJSON / FitBBoxJSON 内联给地图脚本
// (template.JS 跳过转义,避免被二次转义搞坏)。两个 fit 只有一个非 null。
PointsJSON template.JS
FitCircleJSON template.JS
FitBBoxJSON template.JS
}
// trackTag 把配置里的追踪脚本转成可输出的 HTML。
//
// 填 URL(http/https 开头,且不含尖括号)时包一层 <script defer src>;
// 其余情况原样输出——百度统计、Umami 这类要带属性或初始化代码,只能贴整段。
// 值来自服务端配置文件(站长自己写),所以这里不做转义,与 map_tile_url 同一信任级别。
func trackTag(v string) template.HTML {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") {
if strings.ContainsAny(v, "<>\"'") {
return "" // 形如 URL 却夹了引号/标签,宁可不挂
}
return template.HTML(`<script defer src="` + v + `"></script>`)
}
return template.HTML(v)
}
// TempPoint 是逐月气温图上的一根柱子(含算好的百分比高度与配色),
// 具体数值与配色都在服务端算好,模板里只管摆。
type TempPoint struct {
Key string
Label string
AvgC float64
Hours int
Pct int
Color string
}
// TempChart 把逐月平均气温整理成可直接渲染的柱子。
// 高度不按 0℃ 起算——那样 -5℃ 到 35℃ 之间只有 12% 的差别,看不出季节变化;
// 这里按「本区间的最低温 ~ 最高温」铺满高度。
func TempChart(bs []stats.TempBucket) []TempPoint {
if len(bs) == 0 {
return nil
}
lo, hi := bs[0].AvgC, bs[0].AvgC
for _, b := range bs {
if b.AvgC < lo {
lo = b.AvgC
}
if b.AvgC > hi {
hi = b.AvgC
}
}
span := hi - lo
out := make([]TempPoint, 0, len(bs))
for _, b := range bs {
pct := 60
if span > 0.05 {
pct = 12 + int((b.AvgC-lo)/span*88)
}
out = append(out, TempPoint{
Key: b.Key, Label: b.Label, AvgC: b.AvgC, Hours: b.Hours,
Pct: pct, Color: tempColor(b.AvgC),
})
}
return out
}
// tempColor 把摄氏度映射到冷蓝(≤-5)→ 暖红(≥32)的色带。
func tempColor(c float64) string {
switch {
case c <= -5:
return "#4b83c4"
case c <= 5:
return "#6fa8dc"
case c <= 15:
return "#69b8a8"
case c <= 22:
return "#8fbf5a"
case c <= 28:
return "#e2a33c"
default:
return "#d9614a"
}
}