Files
rond-with-you/internal/web/view.go
T
tamakyi 5aeded2d51 补全页恢复手工填写,把「不动」变成真正的默认
上一版改成 select 之后有两个问题:活动和类别都只能从列表里挑,填不进去的写不了;
而且建议被强制预选,用户选「不动」再点「用高德分析」(会整页重载)就白选了,
看起来像被锁死。

现在:活动是可输入的输入框,带已有类型提示,填新名字会新建类型;
类别下拉末尾加「其它(自己写)…」,选中后露出文本框,写什么存什么——
真机自己也存中文类别,写中文一样认得,不必强记 MKPOICategory 那串枚举名。

「不动」改成真正的默认:什么都不预选,想批量采纳就点「采纳本页建议」。
点「用高德分析」前会把本页填过的存进 sessionStorage,回来接着填,不会白改。
2026-09-19 20:13:06 +08:00

1629 lines
46 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
}
// 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
}
// backQS 把当前请求的地址编码成 ?back= 的值。
//
// 去掉 back 自身是必须的:详情页的地址里本来就带着 back(来自列表页),
// 原样再编码一层会变成套娃,回退一次只退到上一层的自己。
// 用 url.Values.Encode() 顺带把参数规范化(排序、重新编码),服务端解析结果不变。
func backQS(r *http.Request) template.URL {
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 template.URL(url.QueryEscape(u))
}
// backFromRequest 取出 ?back= 并只放行**站内路径**。
//
// 返回值会当 template.URL 用(绕过 html/template 的 URL 转义),所以这里必须挡死
// 「//evil.com」这种协议相对地址,以及引号/尖括号这类能跳出属性的字符 ——
// 否则只要有人把链接拼成 /places/1?back=https://evil.com,这里就成了开放重定向。
func backFromRequest(r *http.Request) template.URL {
b := strings.TrimSpace(r.URL.Query().Get("back"))
if b == "" || !strings.HasPrefix(b, "/") || strings.HasPrefix(b, "//") {
return ""
}
for i := 0; i < len(b); i++ {
// 控制字符(含 %00、%0b 这类)与能跳出属性的符号一律不收:
// 浏览器会把 NUL 之类的字符悄悄吃掉,留着就是给「同一串地址在不同环节解析成不同东西」留口子
if c := b[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' {
return ""
}
}
return template.URL(b)
}
// 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(站长要的是「改完回地点库」),没有就退回当前这一页。
func (p Page) EditPlaceLink(srcPK int) template.URL {
back := p.Back
if back == "" {
back = p.BackQS
}
u := "/admin/edit?open=" + strconv.Itoa(srcPK)
if back != "" {
u += "&back=" + url.QueryEscape(string(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
// 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 int
VoteAvoid int
VoteUp int
VoteDown int
Recommended []stats.Place
Avoided []stats.Place
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
}
// 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
}
// 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 // 已有地点的类别(去重),供新建时下拉直选
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
}
// 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
}
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
}
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"
}
var poiNames = map[string]string{
"MKPOICategoryStore": "商店", "MKPOICategoryRestaurant": "餐厅", "MKPOICategoryCafe": "咖啡厅",
"MKPOICategoryPublicTransport": "公共交通", "MKPOICategoryHotel": "酒店", "MKPOICategoryLandmark": "地标",
"MKPOICategoryFoodMarket": "食品市场", "MKPOICategoryMuseum": "博物馆", "MKPOICategoryZoo": "动物园",
"MKPOICategoryBeach": "海滩", "MKPOICategoryCampground": "露营地", "MKPOICategoryStadium": "体育场",
"MKPOICategoryPark": "公园", "MKPOICategoryNationalPark": "国家公园", "MKPOICategorySchool": "学校",
"MKPOICategoryPharmacy": "药店", "MKPOICategoryHospital": "医院", "MKPOICategorySpa": "水疗",
"MKPOICategoryAirport": "机场", "MKPOICategoryMarina": "码头", "MKPOICategoryPostOffice": "邮局",
"MKPOICategoryMarina2": "码头",
"MKPOICategoryBakery": "面包店", "MKPOICategoryLibrary": "图书馆",
"MKPOICategoryUniversity": "大学", "MKPOICategoryAquarium": "水族馆",
"MKPOICategoryFitnessCenter": "健身中心", "MKPOICategoryMovieTheater": "电影院",
"MKPOICategoryTheater": "剧院", "MKPOICategoryParking": "停车场",
"MKPOICategoryBank": "银行", "MKPOICategoryAtm": "取款机",
"MKPOICategoryPolice": "派出所", "MKPOICategoryCarRental": "租车",
"MKPOICategoryGasStation": "加油站", "MKPOICategoryAmusementPark": "游乐园",
"MKPOICategoryRestroom": "洗手间", "MKPOICategoryFireStation": "消防站",
}
func poiName(s string) string {
if v, ok := poiNames[s]; ok {
return v
}
return s
}
// 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,
// catName 把 MKPOICategoryXxx 翻成中文,补全页的类别下拉要用
"catName": poiName,
// 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
},
"safe": func(s string) template.HTML { return template.HTML(s) },
"colorof": colorOf,
"poiname": poiName,
"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"
}
}