导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走, 手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。 新动作 /admin/baseline(后台首页加入口)就是给同一份数据的底座换代用的。 三道闸,顺序不能挪: 1. 重合判据:新包里的到访 UUID 与站点侧带 UUID 的到访比。一条都对不上直接拒 (连 force 也不给过)——干跑只验结构,一份不相干的包照样导得出,但换下去站点 每行都在新底座里认不出身份,全按新行插到最大编号之后,真机上就是「这些到访 全变成新的」。重合率不到一半要显式确认;判据没过不当场报错了事:包已经收进 uploads,页面给一个「我确认,强制替换」按钮,不必重传几十 MB。 2. 干跑导出:底座缺表/缺实体编号在这步挡住,不会出现「换完了才发现导不出、 原来那份已经不知去哪」。 3. 旧底座留档 + 记档,换错了可一键回滚。 两个坑: - 留档名不能只用秒级时间戳。「替换后紧接着回滚」落在同一秒时,回滚那次 rename 会把 刚生成的留档覆盖掉,于是回滚回了个寂寞(TestRollbackBaselineRestoresPrevious 当场红)。 改用 baselineBackupName():20060102-150405 + 冲突自增。 - ensureBaseline 必须先看记档。只认 datasets.sha256(= 最初那份包)的话,底座一丢就静默 重建回旧包,上一次换基线白做且毫无提示。抽出 ensureBaselineTo(ctx, dsID, dst) 共用; 「重建」入口写临时文件再 rename,不做破坏性删除。 回归用例 7 条(internal/web/baseline_swap_test.go):成功路径断言「换底座前后导出包里 ZVISIT 的 UUID 集合逐条一致」——编号会换、身份不该换,谁在底座里没对上立刻亮; 记档优先重建带反证(清掉记档后同一条路必须走不通);部分重合需确认、完全对不上 force 也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、页面渲染到底。 验收:./scripts/verify.sh 全量 PASS=318 FAIL=0 SKIP=0。
1676 lines
48 KiB
Go
1676 lines
48 KiB
Go
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 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
|
||
}
|
||
|
||
// 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」的参数,筛选条件会在翻页时静默失效。
|
||
//
|
||
// 这里**不能**再手工把 & 写成 &:template.URL 在 href 里仍会过一次 urlNormalizer,
|
||
// 它会替我们做 HTML 属性转义。手工写一遍的结果是 `&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"
|
||
}
|
||
}
|