Files
tamakyi ab7ccd22ce 前端改造:设计令牌 + 卡片层级 + 图表可读性 + 类型色收口 + 窄屏导航
先出方案(frontend-plan-2026-10-09.md)再动手,四批按顺序落地。方法上先存基线、再用同一份
探针在改动前后逐值 diff —— 这批里有 6 处问题只有「量」才看得见,看截图全是正常的。

A 设计令牌(app.css 为主)
- 字阶收敛到 6 档 --fs-xs/sm/md/lg/xl/2xl = 11/13/15/21/26/34px。原来 app.css 有 19 种字号、
  模板里另有 54 处内联字号,光 12~15px 之间就挤了 4 档 —— 层级挤不出来,只能靠加粗硬顶。
- 两个「字外档」不是漏的:--fs-control: 16px(窄屏表单控件必须 ≥16px,否则 iOS 聚焦放大整页,
  属功能约束);--glyph-*(✕/箭头/空态图标按控件尺寸定,硬塞进字阶只会把按钮撑坏)。
- 字重收敛到 400/500/600(删掉 650 与 700)。
- 圆角 4 档 --r-*、阴影 3 档 --sh-1/2/3(暗色整套覆盖)、间距 --sp-1..5、
  卡片内边距单列 --card-pad(与折叠卡片标题行的负 margin 绑在一起,改一个即改两处)。
- 门禁 TestNoBareFontSizePx:静态扫 app.css 与全部模板,出现 font-size: <数字>px 即红。

B 卡片分主次
- .card--hero / .card(默认)/ .card--quiet 三档。quiet 走「页面底色 + 细边 + 无阴影」——
  第一版拿 --bg-2 当底色,实测它与页面底 #f6f7f8 只差 4/255,看不出边界、像破了个洞。
- 首屏 KPI 从「4×2 等重白盒」改成 .kpi-lead(主数 + 一行上下文)+ 5 个常规指标,8 个数字一个没少。

C 图表说人话
- 月度分布:柱底加基线;峰值柱不透明拉满、柱顶标次数(.pv),副标题里不再重复写「最高 N 次」。
- 一天中的分布:把「0/6/12/18 四个数字」换成 6 小时一段的标尺(深夜/上午/下午/晚上),
  用同一套 24 列网格,段边界与柱子严格对齐。
- 首页月度图同样加峰值锚点(给 HomeData 补 MaxMonthly,与统计页同口径 maxOf)。
- 空态没动:查下来已经统一(8 页共用 emptystate、局部走 .empty)。

D1 类型色语义化(结论与计划不同,按实测改了)
- 先算对比度:白底上 yellow 1.87 / mint 2.12 / orange 2.20 / green 2.22 / cyan 2.54 / teal 2.57,
  6 个色连图形对象 3:1 都到不了;暗底只有 indigo 2.90 不够。
- 所以**没有**把类型色用到 chips / 表格类型列 / KPI 强调 —— 那几处要的是文字色,只会更难读。
  这也解释了全站为什么一直只用 8px 小圆点做身份标记。浅色那 6 个色一个没动:
  它们是手机端与地图上的同一套身份色,改暗会破坏对应关系。
- 实际做的:色值收口成 --cat-* / --cat-*-soft(26 个变量);模板 colorof → csscolor / csssoft(19 处);
  暗色只提亮 indigo(2.90 → 3.55:1);colorOf 成了死代码,删掉。
- ⚠️ 返回值必须是 template.CSS:html/template 的 CSS 过滤器不认识 var(),普通 string 会被静默
  换成 ZgotmplZ —— 页面上所有圆点当场变透明,而且不报错、模板测试照过。挂了它等于关掉那道过滤,
  所以数据侧色值先过严格校验(只有 # + 6 位 hex 放行),否则 #"><img… 能逃出 style 属性。
  守卫 TestCSSColorSurvivesTemplateSanitizer 把两个方向都钉住(做过变异验证)。

D2 窄屏导航
- 改前 ≤620px 靠横向滚动兜底:390 溢 63px、320 溢 133px,「统计」被切成半个字紧贴后面的按钮,
  渐隐提示实际看不出来。现在链接本体抽成 {{define "navitems"}} 渲染两遍:桌面 .nav-links 平铺、
  窄屏 .nav-menu(原生 <details>,不靠 JS 也能开合);JS 只补点外面 / Esc / 点链接后收起。
- ⚠️ 顶栏 --z-nav 抬到 1200:菜单是 .nav 的子元素、自己抬不动层叠上下文,不抬就会被地图页的
  --z-map-overlay(1100) 盖住。已在 /map 上用 elementFromPoint 实测。
- 守卫 TestNarrowNavCollapsesToMenu 钉「CSS 两半 + 模板两个类 + 渲染两遍」的成对关系。

途中发现并一并修掉的(都只有量 DOM 才看得见)
- Leaflet 的 .leaflet-container 字号是 0.75rem、跟着根字号漂:根 14 → 13px 后地图署名/比例尺
  掉到 9.8px(低于本站 11px 下限)、弹窗正文也跟着缩。已按自己的字阶钉死。
- h1..h4 原来只设了字重与 margin、没设字号,靠浏览器默认的 2em/1.5em/1.17em —— 同样跟着根字号漂:
  /stats 那两个 h2 从 21px 变成 19.5px,比同页卡片标题大一圈。已显式给出字号。
- <b>/<strong> 浏览器默认 700,比全站最高层级(标题 600)还重,已钉成 600。

回归
- ./scripts/verify.sh:PASS=348 FAIL=0 SKIP=0
- internal/fog 加 -race:PASS=22 FAIL=0 SKIP=0
- JS 单测 34/34
- 移动端 320 / 390 溢出全 0(改前 /admin 在 320 还溢 34px);桌面 1440 与改前一致(−15 是滚动条宽)
- 全站 7 个页面都没有低于 11px 的文字

说明:A/B 与 C/D 本想拆两笔提交,但 app.css / layout.html / view.go 里两批改动交织,按文件拆不开、
按 hunk 拆又容易做出「中间那笔编译不过」的历史,所以合成一笔、在消息里分段。
2026-10-09 17:01:08 +08:00

1201 lines
37 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"sort"
"strconv"
"strings"
"time"
"rond-with-you/internal/geo"
"rond-with-you/internal/setting"
"rond-with-you/internal/stats"
"rond-with-you/internal/votes"
)
const (
placesPerPage = 30
visitsPerPage = 180
mapPointLimit = 4000
)
// ---------- 公共上下文 ----------
func (s *Server) pageBase(r *http.Request, nav, title string) (Page, error) {
ctx := r.Context()
p := Page{Nav: nav, Title: title, User: userFrom(ctx), PageNo: 1, AssetV: assetVersion}
sets, err := s.sets.Load(ctx)
if err != nil {
return p, err
}
p.Settings = sets
// 统计脚本:后台「站点设置」里勾了开关才注入(默认关闭)。
// conf/app.ini 的 track_script 只在这一个开关打开时充当默认内容,
// 这样既保留了原来「改 ini 就能挂上」的用法,也让开关成为唯一的总闸。
track := sets.TrackScript
if track == "" {
track = s.cfg.TrackScript
}
if sets.TrackEnabled {
p.TrackScript = trackTag(track)
}
ds, err := s.datasetFor(ctx)
if err != nil {
return p, err
}
p.Dataset, p.HasData = ds, ds != nil
if ds == nil {
return p, nil
}
// 有专题才在导航里露出入口(含未对外展示的:站长要能从导航进管理页)。
// 专题归**人**(换了数据集也还是同一批),所以按属主判,不是按数据集。
if err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM topics WHERE user_id=$1)`, ds.UserID).Scan(&p.HasTopics); err != nil {
return p, err
}
q := r.URL.Query()
// 「家 / 工作」默认隐藏,只有已登录的站长带 hw=1 才放行;
// 匿名访客手工拼参数也会被忽略。
p.ShowHomeWork = p.IsAdmin() && q.Get("hw") == "1"
p.From, p.To = q.Get("from"), q.Get("to")
p.ActivityIDs = parseInt64s(q["act"])
p.City = q.Get("city")
p.TagID = int64(atoiOr(q.Get("tag"), 0))
p.Keyword = strings.TrimSpace(q.Get("q"))
p.MinDwell = atoiOr(q.Get("min"), 0)
p.Sort = q.Get("sort")
if v := atoiOr(q.Get("verdict"), 0); v == 1 || v == -1 {
p.Verdict = v
}
p.PageNo = atoiOr(q.Get("page"), 1)
if p.PageNo < 1 {
p.PageNo = 1
}
if p.Activities, err = s.q.Activities(ctx, ds.ID); err != nil {
return p, err
}
// 类型标签上的次数要跟着当前筛选走(时间 / 城市 / 标签 / 区域…),否则选了城市之后
// 数字仍是全库的。算失败就保留上面那份全量数字:一个角标不值得把整页搞成 500。
if counts, cerr := s.q.ActivityCounts(ctx, s.filterOf(p)); cerr != nil {
log.Printf("类型分面计数失败: %v", cerr)
} else {
for i := range p.Activities {
p.Activities[i].Count = counts[p.Activities[i].ID]
}
}
if p.TagOptions, err = s.q.Tags(ctx, ds.ID); err != nil {
return p, err
}
// 城市筛选下拉框对访客同样按区域限制:黑名单城市直接不出现在列表里,
// 而不是点了之后才显示空结果。站长始终看全量。
regions, exclude := p.Settings.Regions, p.Settings.RegionBlacklist()
if p.IsAdmin() {
regions = nil
}
if p.CityList, err = s.q.Cities(ctx, ds.ID, regions, exclude); err != nil {
return p, err
}
p.RangeLabel = rangeLabel(p.From, p.To)
p.FilterBase = r.URL.Path
p.Presets = buildPresets(p.From, p.To)
// 「回到哪」的两个参数:Back 是别人挂给我们的(详情页/编辑页的返回按钮用),
// BackQS 是把自己挂给别人的(列表页生成详情链接用),SelfPath 是它的未编码原文。
p.Back = backFromRequest(r)
p.SelfPath = selfPath(r)
p.BackQS = backQS(r)
return p, nil
}
// buildPresets 生成筛选器上的快捷时间范围。
func buildPresets(from, to string) []RangePreset {
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
f := func(d time.Time) string { return d.Format("2006-01-02") }
defs := []struct{ label, from, to string }{
{"全部", "", ""},
{"近 7 天", f(today.AddDate(0, 0, -6)), f(today)},
{"近 30 天", f(today.AddDate(0, 0, -29)), f(today)},
{"近 90 天", f(today.AddDate(0, 0, -89)), f(today)},
{"今年", f(time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local)), f(today)},
{"去年", f(time.Date(now.Year()-1, 1, 1, 0, 0, 0, 0, time.Local)), f(time.Date(now.Year()-1, 12, 31, 0, 0, 0, 0, time.Local))},
}
out := make([]RangePreset, 0, len(defs))
for _, d := range defs {
out = append(out, RangePreset{Label: d.label, From: d.from, To: d.to, Active: from == d.from && to == d.to})
}
return out
}
func rangeLabel(from, to string) string {
switch {
case from == "" && to == "":
return "全部时间"
case from != "" && to == "":
return from + " 起"
case from == "" && to != "":
return "至 " + to
default:
return from + " ~ " + to
}
}
// applyNotes 把别名与备注贴到地点上。别名对外替代原名;备注是否给访客看
// 取决于「备注公开」开关,站长自己永远可见。
// 展示用的备注是「rond 备份里的备注 + 站长手写备注」拼起来的。
func (s *Server) applyNotes(ctx context.Context, datasetID int64, places []stats.Place, showNote bool) {
if len(places) == 0 {
return
}
ns, err := s.notes.All(ctx, datasetID)
if err != nil {
return
}
for i := range places {
n, ok := ns[places[i].SrcPK]
if !ok {
continue
}
if n.Alias != "" {
places[i].Alias = n.Alias
places[i].Name = n.Alias
}
if showNote {
places[i].Note = joinNote(n.RondNote, n.Content)
}
}
}
// joinNote 拼接展示用备注:rond 备份里的备注在前,手写备注在后。
func joinNote(rond, hand string) string {
rond, hand = strings.TrimSpace(rond), strings.TrimSpace(hand)
switch {
case rond == "":
return hand
case hand == "":
return rond
}
return rond + "\n\n" + hand
}
// attachVotes 把「推荐 / 踩雷」的站长结论与访客票数贴到地点上(按 src_pk 关联)。
func (s *Server) attachVotes(ctx context.Context, datasetID int64, places []stats.Place) {
if len(places) == 0 {
return
}
verdicts, err := s.votes.Verdicts(ctx, datasetID)
if err != nil {
return
}
tallies, err := s.votes.Tallies(ctx, datasetID)
if err != nil {
return
}
for i := range places {
pk := places[i].SrcPK
places[i].Verdict = verdicts[pk]
if t, ok := tallies[pk]; ok {
places[i].Up, places[i].Down = t.Up, t.Down
}
}
}
// filterOf 把页面状态转成查询条件,并叠加站点级隐私设置。
func (s *Server) filterOf(p Page) stats.Filter {
f := stats.Filter{
DatasetID: p.Dataset.ID,
ActivityIDs: p.ActivityIDs,
City: p.City,
TagID: p.TagID,
Keyword: p.Keyword,
MinDwell: p.MinDwell,
ExcludeHome: p.Settings.HideHome && !p.ShowHomeWork,
ExcludeWork: p.Settings.HideWork && !p.ShowHomeWork,
Verdict: p.Verdict,
}
if p.Settings.MinDwellMin > f.MinDwell {
f.MinDwell = p.Settings.MinDwellMin
}
if t, ok := parseDate(p.From); ok {
f.From = &t
}
if t, ok := parseDate(p.To); ok {
// 结束日期包含当天,按次日 0 点开区间处理
end := t.AddDate(0, 0, 1)
f.To = &end
}
// 区域限制只对访客生效:站长自己要看全部数据,否则没法核对
if regions, exclude := visitorRegions(p); len(regions) > 0 {
f.Regions, f.RegionsExclude = regions, exclude
}
return f
}
// regionHidden 报告该地点是否因区域设置对当前访客不可见。
// 页面级查询走 filterOf 的 SQL 条件,但详情页是按 ID 直取的,必须单独判一次,
// 否则访客拿到链接就能看到被隐藏区域的地点。
func regionHidden(sets setting.Settings, isAdmin bool, place stats.Place) bool {
if isAdmin || len(sets.Regions) == 0 {
return false
}
hit := false
for _, r := range sets.Regions {
if r == place.Province || r == place.City || r == place.District {
hit = true
break
}
}
if sets.RegionBlacklist() {
return hit
}
return !hit
}
func parseDate(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{"2006-01-02", "2006-1-2", "2006/01/02"} {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t, true
}
}
return time.Time{}, false
}
// ---------- 主页 ----------
func (s *Server) home(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "home", "足迹主页")
if err != nil {
s.serverError(w, r, err)
return
}
p.HasMap = true
if p.Dataset == nil {
s.render(w, "home", HomeData{Page: p})
return
}
// 访客默认页面:把访客直接送到站长指定的页面。
// 三种情况刻意不跳:站长自己访问、没配、或目标页面没对访客开放(那时 VisitorHomePath 为空);
// 另外数据还没导入时不跳——让访客留在主页看到引导,比落到一张空地图有用。
if !p.IsAdmin() {
if dest := p.Settings.VisitorHomePath(); dest != "" {
http.Redirect(w, r, dest, http.StatusFound)
return
}
}
ctx := r.Context()
f := s.filterOf(p)
d := HomeData{Page: p}
if p.Settings.Has("overview") {
if d.Overview, err = s.q.Overview(ctx, f); err != nil {
s.serverError(w, r, err)
return
}
}
if p.Settings.Has("types") {
if d.ActivityStats, err = s.q.ActivityStats(ctx, f); err != nil {
s.serverError(w, r, err)
return
}
}
if p.Settings.Has("cities") {
if d.CityStats, err = s.q.CityStats(ctx, f, 10); err != nil {
s.serverError(w, r, err)
return
}
if d.Monthly, err = s.monthBars(ctx, f); err != nil {
s.serverError(w, r, err)
return
}
}
if p.Settings.Has("heatmap") {
cal, err := s.q.Calendar(ctx, f)
if err != nil {
s.serverError(w, r, err)
return
}
if d.Overview.FirstAt != nil && d.Overview.LastAt != nil {
d.Calendar = BuildCalendar(*d.Overview.FirstAt, *d.Overview.LastAt, cal)
} else if f.From != nil && f.To != nil {
d.Calendar = BuildCalendar(*f.From, f.To.AddDate(0, 0, -1), cal)
}
}
if p.Settings.Has("transport") {
if d.Transports, err = s.q.TransportStats(ctx, f); err != nil {
s.serverError(w, r, err)
return
}
}
if p.Settings.Has("tags") {
if d.Tags, err = s.q.TagStats(ctx, f); err != nil {
s.serverError(w, r, err)
return
}
}
if p.Settings.Has("weather") {
if d.Weather, err = s.q.WeatherStats(ctx, f); err != nil {
s.serverError(w, r, err)
return
}
}
if p.Settings.Has("timeline") {
if d.Recent, err = s.q.RecentVisits(ctx, f, 12); err != nil {
s.serverError(w, r, err)
return
}
}
if p.Settings.Has("map") {
places, err := s.q.MapPlaces(ctx, f, 300)
if err != nil {
s.serverError(w, r, err)
return
}
d.Places = places
for _, pl := range places {
d.Points = append(d.Points, toPoint(pl))
}
}
d.MaxActivity = maxOf(d.ActivityStats, func(a stats.ActivityStat) int { return a.Count })
d.MaxCity = maxOf(d.CityStats, func(c stats.CityStat) int { return c.Count })
d.MaxTransport = maxOf(d.Transports, func(t stats.TransportStat) int { return t.Count })
d.MaxTag = maxOf(d.Tags, func(t stats.TagStat) int { return t.Count })
d.MaxMonthly = maxOf(d.Monthly, func(m MonthBar) int { return m.Count })
s.render(w, "home", d)
}
func (s *Server) monthBars(ctx context.Context, f stats.Filter) ([]MonthBar, error) {
b, err := s.q.Monthly(ctx, f)
if err != nil {
return nil, err
}
return BuildMonthly(b), nil
}
func toPoint(p stats.Place) MapPoint {
mp := MapPoint{
ID: p.ID, Lat: p.Lat, Lon: p.Lon, Name: p.Name, City: p.City,
Act: p.TopActivity, Color: p.TopColor, Icon: p.TopIcon,
Count: p.VisitCount, Dwell: p.DwellMinutes, Home: p.IsHome, Work: p.IsWork,
SrcPK: p.SrcPK, Verdict: p.Verdict, Up: p.Up, Down: p.Down, Note: p.Note,
}
if p.LastAt != nil {
mp.LastAt = p.LastAt.Local().Format("2006-01-02")
}
if p.FirstAt != nil {
mp.First = p.FirstAt.Unix()
}
return mp
}
// ---------- 地图 ----------
func (s *Server) mapPage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "map", "足迹地图")
if err != nil {
s.serverError(w, r, err)
return
}
p.HasMap = true
d := MapData{Page: p}
if p.Dataset == nil {
s.render(w, "map", d)
return
}
places, err := s.q.MapPlaces(r.Context(), s.filterOf(p), mapPointLimit)
if err != nil {
s.serverError(w, r, err)
return
}
for _, pl := range places {
d.Points = append(d.Points, toPoint(pl))
}
d.Legend = buildLegend(d.Points)
s.render(w, "map", d)
}
// buildLegend 按当前结果里的类型聚合出图例。
func buildLegend(points []MapPoint) []LegendItem {
idx := map[string]int{}
var out []LegendItem
for _, p := range points {
name := p.Act
if name == "" {
name = "未分类"
}
if i, ok := idx[name]; ok {
out[i].Count++
continue
}
idx[name] = len(out)
out = append(out, LegendItem{Name: name, Color: p.Color, Icon: p.Icon, Count: 1})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Count > out[j].Count })
if len(out) > 12 {
out = out[:12]
}
return out
}
func (s *Server) apiPoints(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "map", "")
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
type resp struct {
Points []MapPoint `json:"points"`
Total int `json:"total"`
}
out := resp{Points: []MapPoint{}}
if p.Dataset == nil {
s.json(w, out)
return
}
places, err := s.q.MapPlaces(r.Context(), s.filterOf(p), mapPointLimit)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.applyNotes(r.Context(), p.Dataset.ID, places, p.IsAdmin() || p.Settings.NotesPublic)
if p.IsAdmin() || p.Settings.MarksPublic {
s.attachVotes(r.Context(), p.Dataset.ID, places)
}
for _, pl := range places {
out.Points = append(out.Points, toPoint(pl))
}
out.Total = len(out.Points)
s.json(w, out)
}
// ---------- 地点库 ----------
func (s *Server) placesPage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "places", "地点库")
if err != nil {
s.serverError(w, r, err)
return
}
d := PlacesData{Page: p}
if p.Dataset == nil {
s.render(w, "places", d)
return
}
places, total, err := s.q.Places(r.Context(), s.filterOf(p), p.Sort, placesPerPage, (p.PageNo-1)*placesPerPage)
if err != nil {
s.serverError(w, r, err)
return
}
s.applyNotes(r.Context(), p.Dataset.ID, places, p.IsAdmin() || p.Settings.NotesPublic)
if p.IsAdmin() || p.Settings.MarksPublic {
s.attachVotes(r.Context(), p.Dataset.ID, places)
}
d.Places = places
d.Total = total
d.PageCount = (total + placesPerPage - 1) / placesPerPage
s.render(w, "places", d)
}
func (s *Server) placePage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "places", "地点详情")
if err != nil {
s.serverError(w, r, err)
return
}
p.HasMap = true
if p.Dataset == nil {
s.notFoundPage(w, "这个地点不存在", "站点还没有导入任何数据,这一页自然也没有内容。")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
s.notFoundPage(w, "这个地址不对", "地点的编号应该是数字,链接可能被截断了。")
return
}
place, err := s.q.Place(r.Context(), p.Dataset.ID, id)
if err != nil {
s.notFoundPage(w, "这个地点不存在", "")
return
}
if regionHidden(p.Settings, p.IsAdmin(), place) {
s.placeHiddenPage(w)
return
}
ps := []stats.Place{place}
s.applyNotes(r.Context(), p.Dataset.ID, ps, p.IsAdmin() || p.Settings.NotesPublic)
place = ps[0]
visits, err := s.q.VisitsByPlace(r.Context(), p.Dataset.ID, id, 200)
if err != nil {
s.serverError(w, r, err)
return
}
d := PlaceData{Page: p, Place: place, Visits: visits, Points: []MapPoint{toPoint(place)}}
d.CategoryLabel = categoryLabel(place.POICategory, s.categoryLabels(r.Context()))
d.Title = place.Name
// 备注保存后跳回本页并带上 ok,只有站长能看到这句提示
if p.IsAdmin() {
d.Flash = r.URL.Query().Get("ok")
}
// 编辑框里编辑的是手写备注,rond 带出来的那一块只读展示
if ns, err := s.notes.All(r.Context(), p.Dataset.ID); err == nil {
if n, ok := ns[place.SrcPK]; ok {
d.MyNote, d.RondNote = n.Content, n.RondNote
}
}
d.Verdict, _ = s.votes.Verdict(r.Context(), p.Dataset.ID, place.SrcPK)
d.Votes, _ = s.votes.Tally(r.Context(), p.Dataset.ID, place.SrcPK)
d.MyVote = s.votes.Voter(r.Context(), p.Dataset.ID, place.SrcPK, s.voterKeyFromRequest(r))
// 图片:访客也要看得到,所以不分管理员
if imgs, err := s.placeImagesOf(r.Context(), p.Dataset.ID, place.SrcPK); err == nil {
d.Images = imgs
// 地址列表单独给一份:访客页没有缩略图墙可读,画廊靠它初始化
d.ImageURLs = make([]string, 0, len(imgs))
for _, im := range imgs {
d.ImageURLs = append(d.ImageURLs, im.URL)
}
}
if p.IsAdmin() {
if backend, err := s.imageBackend(r.Context()); err == nil {
d.ImageBackend = backend
d.ImageReady = true
} else {
d.ImageBackend = "配置有误"
d.ImageReady = false
}
}
d.ImageMaxPer = imageMaxPer
s.render(w, "place", d)
}
// ---------- 时间线 ----------
func (s *Server) timelinePage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "timeline", "时间线")
if err != nil {
s.serverError(w, r, err)
return
}
d := TimelineData{Page: p}
if p.Dataset == nil {
s.render(w, "timeline", d)
return
}
// 排序跟着 ?sort= 走:空 = 最新在前,asc = 最早在前(按天分组时天序也会跟着反过来)
order := "desc"
if p.Sort == "asc" {
order = "asc"
}
visits, total, err := s.q.Visits(r.Context(), s.filterOf(p), visitsPerPage, (p.PageNo-1)*visitsPerPage, order)
if err != nil {
s.serverError(w, r, err)
return
}
d.Total = total
d.PageCount = (total + visitsPerPage - 1) / visitsPerPage
d.Days = groupByDay(visits)
s.render(w, "timeline", d)
}
type TimelineDay struct {
Date string
Label string
Items []stats.VisitRow
Dwell int64
}
func groupByDay(visits []stats.VisitRow) []TimelineDay {
var days []TimelineDay
idx := map[string]int{}
for _, v := range visits {
key := v.Arrival.Local().Format("2006-01-02")
i, ok := idx[key]
if !ok {
days = append(days, TimelineDay{Date: key, Label: dayLabel(v.Arrival)})
i = len(days) - 1
idx[key] = i
}
days[i].Items = append(days[i].Items, v)
days[i].Dwell += int64(v.DurationMin)
}
return days
}
// ---------- 统计 ----------
func (s *Server) statsPage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "stats", "数据统计")
if err != nil {
s.serverError(w, r, err)
return
}
d := StatsData{Page: p}
if p.Dataset == nil {
s.render(w, "stats", d)
return
}
ctx := r.Context()
f := s.filterOf(p)
// 这一屏有十几个互不依赖的查询,串行跑就是它们的耗时之和;并发之后约等于最慢的那个。
// 每个任务只写自己的字段(d 的不同成员地址不同),互不干扰。
var hourly []stats.Bucket
if err := s.runJobs(ctx,
pageJob{"overview", func(ctx context.Context) (err error) {
d.Overview, err = s.q.Overview(ctx, f)
return
}},
pageJob{"activities", func(ctx context.Context) (err error) {
d.ActivityStats, err = s.q.ActivityStats(ctx, f)
return
}},
pageJob{"cities", func(ctx context.Context) (err error) {
d.CityStats, err = s.q.CityStats(ctx, f, 20)
return
}},
pageJob{"provinces", func(ctx context.Context) (err error) {
d.ProvinceStats, err = s.q.ProvinceStats(ctx, f)
return
}},
pageJob{"monthly", func(ctx context.Context) (err error) {
d.Monthly, err = s.monthBars(ctx, f)
return
}},
pageJob{"transports", func(ctx context.Context) (err error) {
d.Transports, err = s.q.TransportStats(ctx, f)
return
}},
pageJob{"tags", func(ctx context.Context) (err error) {
d.Tags, err = s.q.TagStats(ctx, f)
return
}},
pageJob{"weather", func(ctx context.Context) (err error) {
d.Weather, err = s.q.WeatherStats(ctx, f)
return
}},
pageJob{"temps", func(ctx context.Context) (err error) {
var bs []stats.TempBucket
if bs, err = s.q.HourlyTemp(ctx, f); err != nil {
return
}
d.Temps = TempChart(bs)
return
}},
pageJob{"hourly", func(ctx context.Context) (err error) {
hourly, err = s.q.Hourly(ctx, f)
return
}},
pageJob{"weekday", func(ctx context.Context) (err error) {
d.Weekday, err = s.q.Weekday(ctx, f)
return
}},
pageJob{"fog", func(ctx context.Context) error {
d.Fog = s.fogCoverage(ctx, p.Dataset.ID, f.Regions, f.RegionsExclude, p.IsAdmin())
return nil
}},
pageJob{"verdicts", func(ctx context.Context) error {
if !p.IsAdmin() && !p.Settings.MarksPublic {
return nil
}
var err error
if d.VoteRec, d.VoteAvoid, d.VoteUp, d.VoteDown, err = s.voteTotals(ctx, p.Dataset.ID); err != nil {
return err
}
if d.Recommended, err = s.verdictPlaces(ctx, p.Dataset.ID, votes.VerdictRecommend, 8); err != nil {
return err
}
d.Avoided, err = s.verdictPlaces(ctx, p.Dataset.ID, votes.VerdictAvoid, 8)
return err
}},
// 访客点赞榜另起一个任务:与站长结论互不依赖,串在同一个任务里只会白等
pageJob{"visitorvotes", func(ctx context.Context) error {
if !p.IsAdmin() && !p.Settings.MarksPublic {
return nil
}
var err error
if d.VisitorUp, err = s.votes.TopVisitorVoted(ctx, p.Dataset.ID, 1, 8); err != nil {
return err
}
d.VisitorDown, err = s.votes.TopVisitorVoted(ctx, p.Dataset.ID, -1, 8)
return err
}},
); err != nil {
s.serverError(w, r, err)
return
}
d.Hourly = make([]int, 24)
for _, b := range hourly {
if h, err := strconv.Atoi(b.Key); err == nil && h >= 0 && h < 24 {
d.Hourly[h] = b.Count
if b.Count > d.MaxHourly {
d.MaxHourly = b.Count
}
}
}
for _, m := range d.Monthly {
if m.Count > d.MaxMonthly {
d.MaxMonthly = m.Count
}
}
d.MaxActivity = maxOf(d.ActivityStats, func(a stats.ActivityStat) int { return a.Count })
d.MaxCity = maxOf(d.CityStats, func(c stats.CityStat) int { return c.Count })
d.MaxWeekday = maxOf(d.Weekday, func(b stats.Bucket) int { return b.Count })
d.MaxTag = maxOf(d.Tags, func(t stats.TagStat) int { return t.Count })
s.render(w, "stats", d)
}
// ---------- 年度报告 ----------
// reportYears 返回有到访记录的年份,新的在前。
func (s *Server) reportYears(ctx context.Context, datasetID int64) ([]int, error) {
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT extract(year from arrival AT TIME ZONE '`+
stats.TZ+`')::int FROM visits WHERE dataset_id=$1 ORDER BY 1 DESC`, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []int
for rows.Next() {
var y int
if err := rows.Scan(&y); err != nil {
return nil, err
}
out = append(out, y)
}
return out, rows.Err()
}
func (s *Server) reportPage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "report", "年度报告")
if err != nil {
s.serverError(w, r, err)
return
}
d := ReportData{Page: p}
if p.Dataset == nil {
s.render(w, "report", d)
return
}
ctx := r.Context()
years, err := s.reportYears(ctx, p.Dataset.ID)
if err != nil {
s.serverError(w, r, err)
return
}
d.Years = years
year := atoiOr(r.URL.Query().Get("y"), 0)
known := false
for _, y := range years {
if y == year {
known = true
break
}
}
if !known && len(years) > 0 {
year = years[0]
}
d.Year = year
if year <= 0 {
s.render(w, "report", d)
return
}
f := s.filterOf(p)
from := time.Date(year, 1, 1, 0, 0, 0, 0, time.Local)
to := from.AddDate(1, 0, 0)
f.From, f.To = &from, &to
if d.Overview, err = s.q.Overview(ctx, f); err != nil {
s.serverError(w, r, err)
return
}
if d.TopCities, err = s.q.CityStats(ctx, f, 8); err != nil {
s.serverError(w, r, err)
return
}
if d.Transports, err = s.q.TransportStats(ctx, f); err != nil {
s.serverError(w, r, err)
return
}
cargs := []any{p.Dataset.ID, from, to}
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM places p
WHERE p.dataset_id=$1 AND p.first_visit_at >= $2 AND p.first_visit_at < $3`+
regionSQL("p", f.Regions, f.RegionsExclude, &cargs),
cargs...).Scan(&d.NewPlaces); err != nil {
s.serverError(w, r, err)
return
}
if d.North, err = s.extremePlace(ctx, p.Dataset.ID, from, to, "DESC", f.Regions, f.RegionsExclude); err != nil {
s.serverError(w, r, err)
return
}
if d.South, err = s.extremePlace(ctx, p.Dataset.ID, from, to, "ASC", f.Regions, f.RegionsExclude); err != nil {
s.serverError(w, r, err)
return
}
d.Fog = s.fogCoverage(ctx, p.Dataset.ID, f.Regions, f.RegionsExclude, p.IsAdmin())
if d.Cities, err = s.firstArrivalCities(ctx, p.Dataset.ID, from, to, f.Regions, f.RegionsExclude); err != nil {
s.serverError(w, r, err)
return
}
s.render(w, "report", d)
}
// regionSQL 生成访客的区域限制条件(作用在 places 别名 p 上)。
// 省 / 市 / 区任意层级命中即算命中,空列表返回空串。
// exclude 为 true 时是黑名单(命中即排除),false 时是白名单(命中才保留)。args 就地追加占位符。
// visitorRegions 返回对访客生效的区域限制(站长恒为空)。
// 公开查询一律走它——「漏套区域限制」这类 bug 已经在轨迹、专题、迷雾统计上各踩过一次了,
// 收敛到一个入口,新增查询时照抄一行就行。
func visitorRegions(p Page) ([]string, bool) {
if p.IsAdmin() {
return nil, false
}
return p.Settings.Regions, p.Settings.RegionBlacklist()
}
// regionSQL 生成区域限制条件(省 / 市 / 区任一命中即算命中)。alias 是 places 表的别名——
// 多数调用点是 p,行程轨迹要同时限制两端(pf / pt),所以不能写死。
func regionSQL(alias string, regions []string, exclude bool, args *[]any) string {
if len(regions) == 0 {
return ""
}
var conds []string
for _, r := range regions {
*args = append(*args, r)
ph := fmt.Sprintf("$%d", len(*args))
// 必须 COALESCE:district 常为 NULL,NULL = 'x' 得到 NULL,
// 「不匹配」会变成未知值,黑名单里的 NOT (...) 也跟着变 NULL,整行被误删。
conds = append(conds, "COALESCE("+alias+".province,'') = "+ph,
"COALESCE("+alias+".city,'') = "+ph, "COALESCE("+alias+".district,'') = "+ph)
}
cond := "(" + strings.Join(conds, " OR ") + ")"
if exclude {
cond = "NOT " + cond
}
return " AND " + cond
}
// firstArrivalCities 返回「首次到达城市」里程碑:有史以来第一次到访落在
// [from,to) 区间内的城市,按首次到达日期升序。区域限制对访客同样生效。
func (s *Server) firstArrivalCities(ctx context.Context, datasetID int64, from, to time.Time, regions []string, exclude bool) ([]CityMilestone, error) {
args := []any{datasetID, from, to}
regSQL := regionSQL("p", regions, exclude, &args)
q := `SELECT COALESCE(p.city,''), COALESCE(p.province,''), min(v.arrival)::date
FROM visits v JOIN places p ON p.id = v.place_id
WHERE v.dataset_id = $1 AND p.city IS NOT NULL AND p.city <> '' AND ` + stats.SoloVisitSQL("v") + regSQL + `
GROUP BY p.city, p.province
HAVING min(v.arrival) >= $2 AND min(v.arrival) < $3
ORDER BY min(v.arrival) ASC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CityMilestone
for rows.Next() {
var cm CityMilestone
var first sql.NullTime
if err := rows.Scan(&cm.City, &cm.Province, &first); err != nil {
return nil, err
}
if first.Valid {
cm.First = first.Time.Local().Format("2006-01-02")
}
out = append(out, cm)
}
return out, rows.Err()
}
// extremePlace 取最北/最南的地点(年度内有到访记录的地点里按纬度取极值)。要带 region 限制:它会让地点名直接露给访客,
// 漏了的话黑名单区域的地点名照样看得到。
func (s *Server) extremePlace(ctx context.Context, datasetID int64, from, to time.Time, dir string,
regions []string, exclude bool) (*stats.Place, error) {
var p stats.Place
args := []any{datasetID, from, to}
q := `SELECT ` + stats.PlaceCols + ` FROM places p
WHERE p.dataset_id=$1 AND p.lat IS NOT NULL
AND EXISTS (SELECT 1 FROM visits v WHERE v.place_id=p.id AND v.arrival >= $2 AND v.arrival < $3
AND ` + stats.SoloVisitSQL("v") + `)` +
regionSQL("p", regions, exclude, &args) + `
ORDER BY p.lat ` + dir + ` LIMIT 1`
if err := stats.ScanPlace(s.db.QueryRowContext(ctx, q, args...), &p); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &p, nil
}
// ---------- 行程轨迹 API ----------
// CityAgg 是城市级聚合:按城市汇总到访与时长,并给出「首次到达」日期(里程碑用)。
type CityAgg struct {
City string `json:"city"`
Province string `json:"province"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Visits int `json:"visits"`
Places int `json:"places"`
DwellMin int64 `json:"dwell"`
FirstVisit string `json:"first,omitempty"`
LastVisit string `json:"last,omitempty"`
}
// apiCityAgg 返回城市级聚合,地图用来画按到访量加权的城市圈。
func (s *Server) apiCityAgg(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "map", "")
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
out := struct {
Cities []CityAgg `json:"cities"`
}{Cities: []CityAgg{}}
if p.Dataset == nil {
s.json(w, out)
return
}
// 城市聚合直接查 places,绕过了 filterOf,所以区域限制要自己挂一次,
// 否则被隐藏区域的城市名和坐标照样会出现在地图上
args := []any{p.Dataset.ID}
regSQL := ""
if !p.IsAdmin() {
regSQL = regionSQL("p", p.Settings.Regions, p.Settings.RegionBlacklist(), &args)
}
// 先按**地点**聚合、再按城市汇总。直接在 places ⋈ visits 上 sum(places.visit_count) 是错的:
// 一个地点有 N 条到访,它的 visit_count 就会被累加 N 次(实测「到访 72661 次」,真值 568),
// dwell 同理;count(*) 数出来的是到访条数,却被当成「地点数」显示。
rows, err := s.db.QueryContext(r.Context(), `SELECT COALESCE(pa.city,''), COALESCE(pa.province,''),
AVG(pa.lat)::float8, AVG(pa.lon)::float8,
COALESCE(sum(pa.visits),0), count(*), COALESCE(sum(pa.dwell),0),
min(pa.first_at), max(pa.last_at)
FROM (
SELECT p.id, p.city, p.province, p.lat, p.lon,
-- 首末到访按**可见到访**的到达时间算,不取 places 列值(那两列是全量,
-- 会被已并入 / 子段的行推早推晚),与 visit_count 之类的口径保持一致
min(v.arrival) AS first_at, max(v.arrival) AS last_at,
count(*) AS visits,
COALESCE(sum(LEAST(COALESCE(v.duration_min,0),10080)),0) AS dwell
FROM places p
JOIN visits v ON v.place_id = p.id AND v.dataset_id = p.dataset_id
WHERE p.dataset_id = $1 AND p.city IS NOT NULL AND p.city <> '' AND p.lat IS NOT NULL`+regSQL+`
AND `+stats.SoloVisitSQL("v")+`
AND NOT EXISTS (SELECT 1 FROM visits vh WHERE vh.place_id = p.id
AND (vh.is_home OR vh.is_work))
GROUP BY p.id
) pa
GROUP BY pa.city, pa.province
ORDER BY 5 DESC`, args...)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
defer rows.Close()
for rows.Next() {
var c CityAgg
var first, last sql.NullTime
if err := rows.Scan(&c.City, &c.Province, &c.Lat, &c.Lon, &c.Visits, &c.Places,
&c.DwellMin, &first, &last); err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
if first.Valid {
c.FirstVisit = first.Time.Local().Format("2006-01-02")
}
if last.Valid {
c.LastVisit = last.Time.Local().Format("2006-01-02")
}
out.Cities = append(out.Cities, c)
}
if err := rows.Err(); err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, out)
}
// apiTrack 返回当前筛选范围内的位移段(起终点坐标 + 交通方式),供地图画轨迹线。
// 坐标与地点一样是 GCJ-02,前端按底图坐标系统一投影。
func (s *Server) apiTrack(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "map", "")
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
type TrackSeg struct {
A [2]float64 `json:"a"`
B [2]float64 `json:"b"`
Mode string `json:"m,omitempty"`
Color string `json:"k,omitempty"`
Km float64 `json:"km,omitempty"`
}
out := struct {
Segs []TrackSeg `json:"segs"`
Modes []ModeCount `json:"modes"`
}{Segs: []TrackSeg{}, Modes: []ModeCount{}}
if p.Dataset == nil {
s.json(w, out)
return
}
f := s.filterOf(p)
conds := []string{"m.dataset_id = $1"}
args := []any{p.Dataset.ID}
if f.From != nil {
args = append(args, *f.From)
conds = append(conds, fmt.Sprintf("m.started_at >= $%d", len(args)))
}
if f.To != nil {
args = append(args, *f.To)
conds = append(conds, fmt.Sprintf("m.started_at < $%d", len(args)))
}
var flags []string
if f.ExcludeHome {
flags = append(flags, "vf.is_home")
}
if f.ExcludeWork {
flags = append(flags, "vf.is_work")
}
if len(flags) > 0 {
conds = append(conds, `NOT EXISTS (
SELECT 1 FROM visits vf WHERE vf.dataset_id = m.dataset_id
AND (vf.src_pk = m.from_visit_src OR vf.src_pk = m.to_visit_src)
AND (`+strings.Join(flags, " OR ")+`))`)
}
// 区域限制要套在**两端**上:只要有一端落在被隐藏的区域,整段轨迹都会暴露那个坐标,
// 所以这种段直接不返回(只按起点过滤的话,终点在隐藏区域的线段照样画得出来)。
regSQL := ""
if len(f.Regions) > 0 {
regSQL = regionSQL("pf", f.Regions, f.RegionsExclude, &args) +
regionSQL("pt", f.Regions, f.RegionsExclude, &args)
}
q := `SELECT pf.lat, pf.lon, pt.lat, pt.lon,
COALESCE(m.transport_name,''), COALESCE(m.transport_color,''), COALESCE(m.distance_km,0)
FROM movements m
JOIN places pf ON pf.id = m.from_place_id AND pf.lat IS NOT NULL
JOIN places pt ON pt.id = m.to_place_id AND pt.lat IS NOT NULL
WHERE ` + strings.Join(conds, " AND ") + regSQL + `
ORDER BY m.started_at LIMIT 5000`
modeTally := map[string]int{}
modeColor := map[string]string{}
rows, err := s.db.QueryContext(r.Context(), q, args...)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
defer rows.Close()
for rows.Next() {
var seg TrackSeg
var color string
if err := rows.Scan(&seg.A[0], &seg.A[1], &seg.B[0], &seg.B[1], &seg.Mode, &color, &seg.Km); err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
// 没记录交通方式的段按「步行 / 其他」归到灰色,避免和默认青色混淆
if seg.Mode == "" {
seg.Mode, color = modeFallback, "gray"
}
if c, ok := appleColors[color]; ok {
seg.Color = c
} else if strings.HasPrefix(color, "#") {
seg.Color = color
}
modeTally[seg.Mode]++
modeColor[seg.Mode] = seg.Color
out.Segs = append(out.Segs, seg)
}
if err := rows.Err(); err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
for name, n := range modeTally {
out.Modes = append(out.Modes, ModeCount{Name: name, Count: n, Color: modeColor[name]})
}
sort.Slice(out.Modes, func(i, j int) bool { return out.Modes[i].Count > out.Modes[j].Count })
s.json(w, out)
}
// GPXTrack 是给前端画真实路径用的一条轨迹。
type GPXTrack struct {
ID int64 `json:"id"`
Name string `json:"name"`
Points [][2]float64 `json:"points"`
LengthKm float64 `json:"km"`
Started string `json:"started,omitempty"`
}
// apiGpx 返回已导入的 GPX 轨迹。库里存的是 WGS-84(GPX 标准),
// 底图是 GCJ-02 时按点转成火星坐标,保证和足迹点对齐。
//
// 轨迹按**当前生效数据集的属主**圈定,不是把整张表端出去:gpx_tracks 带 user_id,
// 这是公开接口、不带登录态,不按属主过滤就等于把别人的轨迹一起露给访客。
// 没有生效数据集时返回空集——和页面其余部分「没数据就没有」的口径一致。
func (s *Server) apiGpx(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
empty := func() {
s.json(w, struct {
Tracks []GPXTrack `json:"tracks"`
}{Tracks: []GPXTrack{}})
}
var owner int64
err := s.db.QueryRowContext(ctx, `SELECT user_id FROM datasets
WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
empty()
return
}
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
crs := r.URL.Query().Get("crs")
rows, err := s.db.QueryContext(ctx, `SELECT id, name, points, length_km, started_at
FROM gpx_tracks WHERE user_id=$1 ORDER BY started_at DESC NULLS LAST, id DESC`, owner)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
defer rows.Close()
out := struct {
Tracks []GPXTrack `json:"tracks"`
}{Tracks: []GPXTrack{}}
for rows.Next() {
var t GPXTrack
var raw []byte
var started sql.NullTime
if err := rows.Scan(&t.ID, &t.Name, &raw, &t.LengthKm, &started); err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
var pts [][2]float64
if err := json.Unmarshal(raw, &pts); err != nil {
continue
}
if crs == "gcj02" {
for i := range pts {
lat, lon := geo.WGS84ToGCJ02(pts[i][0], pts[i][1])
pts[i] = [2]float64{lat, lon}
}
}
t.Points = pts
if started.Valid {
t.Started = started.Time.Local().Format("2006-01-02 15:04")
}
out.Tracks = append(out.Tracks, t)
}
if err := rows.Err(); err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, out)
}