first release

This commit is contained in:
tamakyi
2026-09-18 13:05:49 +08:00
commit b327bc1230
123 changed files with 30003 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
package web
import (
"crypto/sha256"
"encoding/hex"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
)
// assetVersion 是静态资源的版本号,页面链接带 ?v=它,一致才允许长期缓存。
// 重新编译或改动静态文件后版本号随之变化,旧缓存自动失效。
var assetVersion = "0"
// initAssetVersion 在启动时计算版本号:调试模式读磁盘(改样式即时生效),
// 正式模式读 embed 的文件内容。
func initAssetVersion(debug bool) {
h := sha256.New()
if debug {
var paths []string
filepath.Walk("internal/web/static", func(p string, info os.FileInfo, err error) error {
if err == nil && info != nil && !info.IsDir() {
paths = append(paths, p)
}
return nil
})
sort.Strings(paths)
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return
}
io.Copy(h, f)
f.Close()
io.WriteString(h, p)
}
assetVersion = hex.EncodeToString(h.Sum(nil))[:12]
return
}
var names []string
fs.WalkDir(staticFS, ".", func(p string, d fs.DirEntry, err error) error {
if err == nil && d != nil && !d.IsDir() {
names = append(names, p)
}
return nil
})
sort.Strings(names)
for _, p := range names {
f, err := staticFS.Open(p)
if err != nil {
return
}
io.Copy(h, f)
f.Close()
io.WriteString(h, p)
}
assetVersion = hex.EncodeToString(h.Sum(nil))[:12]
}
+151
View File
@@ -0,0 +1,151 @@
package web
import (
"context"
"log"
"net/http"
"strconv"
"strings"
)
// backfillNeed 是被判定为「信息不全」的地点:地址全空(区域黑名单匹配不上、统计里也没城市),
// 或者名字为空(地图上显示成「未命名」)。
type backfillNeed struct {
ID int64
Lat, Lon float64
Name string
Province, City, Distr string
}
// backfillPlaceAddresses 给缺地址 / 没名字的地点补上反查结果。
//
// 只填空着的列,不覆盖已有值——宁可少改。名字为空时用最近的 POI 命名(和 rond 自己的做法一致),
// 周边 200m 没有收录就退回街道 / 乡镇;再取不到就只补地址,名字保持空。
// limit 用来兜住高德配额;返回值 filled 是补成功的条数,remain 是还剩下的候选数。
func (s *Server) backfillPlaceAddresses(ctx context.Context, datasetID int64, limit int) (filled, remain int, err error) {
if s.cfg.GeocodeKey == "" {
return 0, 0, nil // 没配 key,这个功能整体关闭
}
rows, err := s.db.QueryContext(ctx, `SELECT id, lat, lon, COALESCE(name,''),
COALESCE(province,''), COALESCE(city,''), COALESCE(district,'')
FROM places
WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL
AND (COALESCE(city,'')='' OR COALESCE(name,'')='')
ORDER BY id`, datasetID)
if err != nil {
return 0, 0, err
}
var todo []backfillNeed
for rows.Next() {
var t backfillNeed
if err := rows.Scan(&t.ID, &t.Lat, &t.Lon, &t.Name, &t.Province, &t.City, &t.Distr); err != nil {
rows.Close()
return 0, 0, err
}
todo = append(todo, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, 0, err
}
if len(todo) == 0 {
return 0, 0, nil
}
if limit > 0 && len(todo) > limit {
remain = len(todo) - limit
todo = todo[:limit]
}
for _, t := range todo {
if ctx.Err() != nil {
break
}
_, prov, city, dist, street, town, ok := s.amapRegeo(ctx, t.Lat, t.Lon)
if !ok {
remain++
continue
}
// 名字为空:优先用最近的 POI,退而用街道 / 乡镇
name := t.Name
if strings.TrimSpace(name) == "" {
if poi, _, hit := s.amapNearestPOI(ctx, t.Lat, t.Lon); hit {
name = poi
} else if street != "" {
name = street
} else if town != "" {
name = town
}
}
// 只填空着的列
newProv, newCity, newDist := t.Province, t.City, t.Distr
if newProv == "" {
newProv = prov
}
if newCity == "" {
newCity = city
}
if newDist == "" {
newDist = dist
}
if name == t.Name && newProv == t.Province && newCity == t.City && newDist == t.Distr {
continue // 反查也没给出新信息
}
if _, err := s.db.ExecContext(ctx, `UPDATE places SET
name = COALESCE(NULLIF($2,''), name),
province = COALESCE(NULLIF($3,''), province),
city = COALESCE(NULLIF($4,''), city),
district = COALESCE(NULLIF($5,''), district)
WHERE id=$1`, t.ID, name, newProv, newCity, newDist); err != nil {
return filled, remain, err
}
filled++
}
return filled, remain, nil
}
// backfillAfterImport 是导入后的自动补全:只处理少量,别让上传等太久。
func (s *Server) backfillAfterImport(ctx context.Context, datasetID int64) string {
filled, remain, err := s.backfillPlaceAddresses(ctx, datasetID, 20)
if err != nil {
log.Printf("导入后补全地址失败: %v", err)
return ""
}
if filled == 0 && remain == 0 {
return ""
}
msg := ""
if filled > 0 {
msg = ";已自动反查补全 " + strconv.Itoa(filled) + " 个缺地址的地点"
}
if remain > 0 {
msg += "(还有 " + strconv.Itoa(remain) + " 个可在后台点「补全缺失地址」继续)"
}
return msg
}
// adminBackfillPlaces 手动把当前数据集里缺地址 / 没名字的地点补全一遍。
func (s *Server) adminBackfillPlaces(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有导入任何备份")
return
}
if s.cfg.GeocodeKey == "" {
s.adminFlash(w, r, "err", "没有配置 geocode_key(高德 Web 服务密钥),无法反查地址")
return
}
filled, remain, err := s.backfillPlaceAddresses(ctx, ds.ID, 100)
if err != nil {
s.serverError(w, r, err)
return
}
switch {
case filled == 0 && remain == 0:
s.adminFlash(w, r, "ok", "没有需要补全的地点:地址与名称都齐全")
case remain > 0:
s.adminFlash(w, r, "ok", "已补全 "+strconv.Itoa(filled)+" 个地点,还剩 "+strconv.Itoa(remain)+" 个,再点一次继续")
default:
s.adminFlash(w, r, "ok", "已补全 "+strconv.Itoa(filled)+" 个地点的地址 / 名称")
}
}
+49
View File
@@ -0,0 +1,49 @@
package web
import (
"bytes"
"strings"
"testing"
"rond-with-you/internal/stats"
)
// TestTagDropdownKeepsAllOptionsUnderFilter 盯住一个真实踩过的坑。
//
// 统计页的「标签排行」把数据放在自己的 Tags 字段上,而它内嵌了 Page —— 模板里的
// `.Tags` 会优先取外层那个,于是公共筛选里的标签下拉被换成了「筛选后剩下的标签」:
// 一旦选中某个标签,其它标签就从下拉里消失,用户再也切不回去。
// 现在下拉专用字段叫 Page.TagOptions,与排行用的 Tags 分开。
func TestTagDropdownKeepsAllOptionsUnderFilter(t *testing.T) {
f := newMovementFixture(t)
tmpl := f.srv.pages["stats"].t // layout.html + stats.html 一起解析过,「filters」在里面
data := StatsData{Page: Page{
FilterBase: "/stats",
TagOptions: []stats.TagStat{{ID: 1, Name: "甲"}, {ID: 2, Name: "乙"}},
TagID: 1, // 当前正按「甲」筛选
}}
// 排行只筛出被选中的那一个
data.Tags = []stats.TagStat{{ID: 1, Name: "甲"}}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "filters", data); err != nil {
t.Fatal(err)
}
html := buf.String()
// 只看标签那一个 select:整个筛选表单里还有城市、最短停留等下拉
start := strings.Index(html, `<select name="tag">`)
if start < 0 {
t.Fatalf("没找到标签下拉:\n%s", html)
}
seg := html[start:]
if end := strings.Index(seg, "</select>"); end >= 0 {
seg = seg[:end]
}
if got := strings.Count(seg, "<option"); got != 3 { // 全部标签 + 甲 + 乙
t.Errorf("标签下拉应当始终列出全部标签(3 项),实际 %d 项\n%s", got, seg)
}
if !strings.Contains(seg, ">乙</option>") {
t.Error("未被选中的标签「乙」从下拉里消失了")
}
}
+356
View File
@@ -0,0 +1,356 @@
package web
import (
"bytes"
"context"
"fmt"
"image"
"image/png"
"io"
"log"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"rond-with-you/internal/fog"
"rond-with-you/internal/geo"
)
const maxFogUploadBytes = 64 << 20 // .fwss 快照一般几 MB,放宽到 64MB
// 迷雾是站点级数据(与公开页读数据不区分归属的约定一致),
// 内存里只持一份索引,首次访问时从库里加载。
var (
fogOnce sync.Once
fogIndexV *fog.Index
)
func (s *Server) fogIndex() (*fog.Index, error) {
fogOnce.Do(func() {
fogIndexV = fog.NewIndex()
if err := fogIndexV.LoadAll(s.db); err != nil {
fogIndexV = nil
}
})
if fogIndexV == nil {
return nil, fmt.Errorf("迷雾数据加载失败")
}
return fogIndexV, nil
}
// handleFogTile 渲染迷雾透明瓦片。迷雾网格是 WGS-84;当前激活底图若是
// GCJ-02(高德),前端会带 ?crs=gcj02,服务端把每个块做 WGS→GCJ 正偏移后再画。
func (s *Server) handleFogTile(w http.ResponseWriter, r *http.Request) {
z, err1 := strconv.Atoi(r.PathValue("z"))
x, err2 := strconv.Atoi(r.PathValue("x"))
y, err3 := strconv.Atoi(r.PathValue("y"))
if err1 != nil || err2 != nil || err3 != nil || z < 0 || z > 22 ||
x < 0 || y < 0 || x >= 1<<uint(z) || y >= 1<<uint(z) {
http.NotFound(w, r)
return
}
ix, err := s.fogIndex()
if err != nil {
s.serverError(w, r, err)
return
}
crs := r.URL.Query().Get("crs")
if crs != "gcj02" && crs != "wgs84" {
crs = s.cfg.MapCRS
}
// 遮罩只对访客生效:站长自己要能看到完整迷雾,否则没法核对遮哪里
isAdmin := false
if u, _ := s.sessionUser(r); u != nil {
isAdmin = true
}
// 渲染结果缓存:一张瓦片要逐格画位图再做 PNG 编码,而内容只随
// 「迷雾数据版本 + 遮罩 + 底图坐标系」变化。站长的瓦片不含遮罩,指纹用 "-",
// 这样改遮罩时不必连带丢掉站长的缓存。
fp := "-"
if !isAdmin {
fp = s.maskFP()
}
cacheKey := strconv.Itoa(z) + "/" + strconv.Itoa(x) + "/" + strconv.Itoa(y) + "/" + crs + "/" + fp
if t, ok := s.fogTiles.get(cacheKey); ok {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", fogTileCacheControl(isAdmin))
w.Write(t.body)
return
}
masks := s.maskSetFor(isAdmin)
var img *image.NRGBA
ix.View(func(blocks map[[2]int][]byte) {
img = fog.RenderTile(blocks, z, x, y, crs, masks)
})
if img == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", fogTileCacheControl(isAdmin))
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
s.serverError(w, r, err)
return
}
s.fogTiles.put(cacheKey, cachedTile{body: buf.Bytes(), ct: "image/png", at: time.Now()})
w.Write(buf.Bytes())
}
// fogTileCacheControl 决定迷雾瓦片的缓存策略。站长与访客的瓦片内容不同(一个含遮罩、
// 一个不含)而 URL 相同,所以站长的响应绝不能进共享缓存——否则 CDN / 反向代理会把
// 未遮罩的瓦片原样发给访客。访客那份内容对所有人一致,允许公开缓存。
func fogTileCacheControl(isAdmin bool) string {
if isAdmin {
return "private, no-store"
}
return "public, max-age=3600"
}
// adminFogUpload 导入 .fwss:mode=overlay 按位或合并进现有迷雾,
// mode=cover 清空后整体替换。
func (s *Server) adminFogUpload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
r.Body = http.MaxBytesReader(w, r.Body, maxFogUploadBytes+1<<20)
if err := r.ParseMultipartForm(32 << 20); err != nil {
s.adminFlash(w, r, "err", "读取上传内容失败:"+err.Error())
return
}
file, hdr, err := r.FormFile("fwss")
if err != nil {
s.adminFlash(w, r, "err", "请选择要上传的 .fwss 迷雾快照")
return
}
defer file.Close()
name := strings.ToLower(filepath.Base(hdr.Filename))
if !strings.HasSuffix(name, ".fwss") && !strings.HasSuffix(name, ".zip") {
s.adminFlash(w, r, "err", "文件类型不正确,请上传 Fog of World 导出的 .fwss 快照")
return
}
mode := r.PostFormValue("mode")
if mode != "overlay" && mode != "cover" {
mode = "overlay"
}
data, err := io.ReadAll(io.LimitReader(file, maxFogUploadBytes+1))
if err != nil {
s.adminFlash(w, r, "err", "读取文件失败:"+err.Error())
return
}
if int64(len(data)) > maxFogUploadBytes {
s.adminFlash(w, r, "err", "迷雾快照超过 64MB,疑似文件有误")
return
}
blocks, cells, err := fog.Decode(data)
if err != nil {
s.adminFlash(w, r, "err", "解析迷雾快照失败:"+err.Error())
return
}
// 快照里还有 Model/~ 与 Model/# 两层(约占一半体积):站点不用它们渲染,
// 但导出时要原样写回,否则重导出的快照在 fog of world 里是残缺的
layers, err := fog.RawLayers(data)
if err != nil {
s.adminFlash(w, r, "err", "解析快照的其它图层失败:"+err.Error())
return
}
if len(blocks) == 0 {
s.adminFlash(w, r, "err", "快照里没有找到迷雾瓦片数据")
return
}
ix, err := s.fogIndex()
if err != nil {
s.serverError(w, r, err)
return
}
applied, err := ix.Import(s.db, u.ID, blocks, mode == "overlay")
if err != nil {
s.serverError(w, r, err)
return
}
// 「整体替换」时图层也整体换掉,避免留下上一份快照里已经不存在的图层条目
if err := ix.SaveRawLayers(s.db, u.ID, layers, mode == "cover"); err != nil {
log.Printf("留存迷雾其它图层失败: %v", err)
}
b, c := ix.Stats()
verb := "叠加合并"
if mode == "cover" {
verb = "整体替换"
}
s.adminFlash(w, r, "ok", fmt.Sprintf(
"迷雾导入成功(%s):本次写入 %d 块 / %d 格,当前共 %d 块 / %d 格", verb, applied, cells, b, c))
}
// ---------- 迷雾覆盖率 ----------
// FogCity 是单个城市(或省份)的迷雾点亮统计:地点里有多少个落在已探索格内。
type FogCity struct {
Name string `json:"name"`
Lit int `json:"lit"`
Total int `json:"total"`
}
// FogCoverage 是统计页展示的迷雾覆盖率数据。
type FogCoverage struct {
AreaKm2 float64 `json:"area_km2"`
Blocks int `json:"blocks"`
Cells int `json:"cells"`
LitPlaces int `json:"lit_places"`
Places int `json:"places"`
Cities []FogCity `json:"cities"`
Provinces []FogCity `json:"provinces"`
}
type fogCounter struct{ lit, total map[string]int }
func newFogCounter() *fogCounter {
return &fogCounter{lit: map[string]int{}, total: map[string]int{}}
}
func (fc *fogCounter) add(name string, lit bool) {
fc.total[name]++
if lit {
fc.lit[name]++
}
}
func (fc *fogCounter) top() []FogCity {
out := make([]FogCity, 0, len(fc.total))
for name, total := range fc.total {
out = append(out, FogCity{Name: name, Lit: fc.lit[name], Total: total})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Lit != out[j].Lit {
return out[i].Lit > out[j].Lit
}
return out[i].Total > out[j].Total
})
if len(out) > 12 {
out = out[:12]
}
return out
}
// fogCoverage 计算当前数据集地点与迷雾位图的叠加:迷雾网格是 WGS-84,
// 库里的地点是 GCJ-02,先反算到 WGS 再查格位图。
// 没导入迷雾或没数据时返回 nil,模板据此隐藏整个区块。
// fogCoverage 统计迷雾覆盖率。regions/exclude 来自 filterOf(站长为空),
// 与其它公开统计保持一致——否则黑名单里的城市名照样会出现在「涉及城市」里。
func (s *Server) fogCoverage(ctx context.Context, datasetID int64, regions []string, exclude bool, admin bool) *FogCoverage {
ix, err := s.fogIndex()
if err != nil {
return nil
}
blocks, cells := ix.Stats()
if blocks == 0 {
return nil
}
// 遮罩内的迷雾对访客不算数:地点要跳过,面积也要扣掉这部分格子
masks := s.maskSetFor(admin)
args := []any{datasetID}
q := `SELECT COALESCE(p.city,''), COALESCE(p.province,''),
COALESCE(p.lat,0), COALESCE(p.lon,0), p.lat IS NULL
FROM places p WHERE p.dataset_id=$1` + regionSQL("p", regions, exclude, &args)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil
}
defer rows.Close()
cov := &FogCoverage{Blocks: blocks, Cells: cells}
cities, provs := newFogCounter(), newFogCounter()
for rows.Next() {
var city, province string
var lat, lon float64
var nullCoord bool
if err := rows.Scan(&city, &province, &lat, &lon, &nullCoord); err != nil {
return nil
}
cov.Places++
if nullCoord {
continue
}
if masks.HitLatLon(lat, lon) {
continue
}
wlat, wlon := geo.GCJ02ToWGS84(lat, lon)
cx, cy := fog.CellOf(wlat, wlon)
lit := ix.CellLit(cx, cy)
if lit {
cov.LitPlaces++
}
if city != "" {
cities.add(city, lit)
}
if province != "" {
provs.add(province, lit)
}
}
if err := rows.Err(); err != nil {
return nil
}
cov.Cities = cities.top()
cov.Provinces = provs.top()
// 面积与格数都用索引里预计算好的总量,再减掉遮罩盖住的部分
// (以前是每次现扫一遍全部位图、还顺手复制排序了三次整张块表)。
cov.AreaKm2 = ix.AreaKm2()
if mc, ma := ix.MaskedStats(masks); mc > 0 {
cov.Cells -= mc
cov.AreaKm2 -= ma / 1e6
}
return cov
}
// fogResetIndex 丢弃内存里的迷雾索引(清空迷雾后调用),
// 下次访问时按空库重建。
func fogResetIndex() {
fogIndexV = fog.NewIndex()
}
// adminFogClear 清空全部迷雾数据。迷雾是站点级数据,一次清空影响所有展示。
func (s *Server) adminFogClear(w http.ResponseWriter, r *http.Request) {
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM fog_blocks`); err != nil {
s.serverError(w, r, err)
return
}
// 其它图层一并丢弃:留着的话导出的快照会有图层条目却没有对应的 Model/* 瓦片
if err := fog.DropRawLayers(s.db); err != nil {
log.Printf("清空迷雾其它图层失败: %v", err)
}
fogResetIndex()
s.adminFlash(w, r, "ok", "迷雾已清空")
}
// adminFogExport 把当前迷雾还原成 .fwss 供迁移。
func (s *Server) adminFogExport(w http.ResponseWriter, r *http.Request) {
ix, err := s.fogIndex()
if err != nil {
s.serverError(w, r, err)
return
}
blocks := ix.All()
if len(blocks) == 0 {
s.adminFlash(w, r, "err", "还没有导入任何迷雾数据")
return
}
layers, err := fog.LoadRawLayers(s.db, userFrom(r.Context()).ID)
if err != nil {
log.Printf("读取迷雾其它图层失败: %v", err)
}
out, err := fog.Encode(blocks, layers)
if err != nil {
s.serverError(w, r, err)
return
}
_, cells := ix.Stats()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="fog-export-%s.fwss"`, time.Now().Format("20060102-150405")))
w.Header().Set("X-Fog-Cells", strconv.Itoa(cells))
w.Write(out)
}
+103
View File
@@ -0,0 +1,103 @@
package web
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
"rond-with-you/internal/fog"
"rond-with-you/internal/geo"
)
// fogMaskPage 管理「对访客隐藏」的迷雾范围。
func (s *Server) fogMaskPage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "admin", "迷雾遮罩区")
if err != nil {
s.serverError(w, r, err)
return
}
p.SetFlashFromQuery(r)
p.HasMap = true // 布局按这个标记引入 Leaflet,漏了地图整块不出现
masks := s.masksFor()
if err != nil {
s.serverError(w, r, err)
return
}
d := FogMaskData{Page: p, Masks: masks, MapInfo: s.mapInfo()}
s.render(w, "admin/fog-mask", d)
}
// fogMaskSave 新增或更新一块遮罩区。
// 表单里的坐标来自底图(可能是 WGS-84 的天地图),要换算成站点统一的 GCJ-02 再存。
func (s *Server) fogMaskSave(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
id, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("id")), 10, 64)
lat, ok1 := parseFloat(r.PostFormValue("lat"))
lon, ok2 := parseFloat(r.PostFormValue("lon"))
radius, ok3 := parseFloat(r.PostFormValue("radius_m"))
name := strings.TrimSpace(r.PostFormValue("name"))
if !ok1 || !ok2 || math.Abs(lat) > 90 || math.Abs(lon) > 180 {
redirectFogMask(w, r, "err", "请先在地图上点选圆心")
return
}
if !ok3 || radius < 50 || radius > 50000 {
redirectFogMask(w, r, "err", "半径请填 50 ~ 50000 米")
return
}
if r.PostFormValue("crs") == "wgs84" {
lat, lon = geo.WGS84ToGCJ02(lat, lon)
}
// 保存成功后失效两处缓存:站长点完保存就该看到访客视角的变化
defer func() { s.masks.invalidate(); s.fogTiles.clear() }()
if _, err := fog.SaveMask(s.db, u.ID, fog.Mask{ID: id, Name: name, Lat: lat, Lon: lon, RadiusM: radius}); err != nil {
s.serverError(w, r, err)
return
}
verb := "已新增"
if id > 0 {
verb = "已更新"
}
label := name
if label == "" {
label = fmt.Sprintf("%.4f, %.4f", lat, lon)
}
redirectFogMask(w, r, "ok", fmt.Sprintf("%s遮罩区「%s」(半径 %s 米)", verb, label, trimNum(radius)))
}
// fogMaskDelete 删除一块遮罩区。
func (s *Server) fogMaskDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
id, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("id")), 10, 64)
if id <= 0 {
redirectFogMask(w, r, "err", "遮罩区不存在")
return
}
// 删除后同样立刻失效:站长的操作要立即反映到访客视角
defer func() { s.masks.invalidate(); s.fogTiles.clear() }()
if err := fog.DeleteMask(s.db, u.ID, id); err != nil {
s.serverError(w, r, err)
return
}
redirectFogMask(w, r, "ok", "已删除该遮罩区")
}
func redirectFogMask(w http.ResponseWriter, r *http.Request, kind, msg string) {
http.Redirect(w, r, "/admin/fog/mask?"+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
}
// trimNum 把 500.0 这种整数半径显示成 500。
func trimNum(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
+411
View File
@@ -0,0 +1,411 @@
package web
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"rond-with-you/internal/geo"
)
// 按名称搜坐标:调用高德 Web 服务接口,密钥只在服务端拼接。
// 高德返回的就是 GCJ-02,与库内坐标同系,前端拿到即可直接入库,无需任何坐标转换。
const (
amapPlaceTextURL = "https://restapi.amap.com/v3/place/text" // 关键字/POI 搜索,适合地名
amapGeocodeURL = "https://restapi.amap.com/v3/geocode/geo" // 结构化地址解析,POI 无结果时兜底
)
var geocodeHTTPClient = &http.Client{Timeout: 8 * time.Second}
// geocodeItem 是返回给前端的候选点。
type geocodeItem struct {
Name string `json:"name"`
Address string `json:"address"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
}
// apiGeocode 供后台「新增地点」的搜索框使用,仅管理员可调用。
func (s *Server) apiGeocode(w http.ResponseWriter, r *http.Request) {
if s.cfg.GeocodeKey == "" {
s.json(w, map[string]any{"error": "服务端未配置 geocode_key,暂时无法按名称搜索坐标"})
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
s.json(w, map[string]any{"error": "请输入要搜索的名称"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
items, err := s.amapSearchPOI(ctx, q)
if err != nil {
s.json(w, map[string]any{"error": err.Error()})
return
}
if len(items) == 0 {
// 地块/门牌这类不是 POI 的地址,再走一次结构化地理编码
items, err = s.amapGeocode(ctx, q)
if err != nil {
s.json(w, map[string]any{"error": err.Error()})
return
}
}
if len(items) == 0 {
s.json(w, map[string]any{"error": "没有找到匹配的地点,换个更完整的名称试试"})
return
}
s.json(w, map[string]any{"items": items})
}
func (s *Server) amapSearchPOI(ctx context.Context, q string) ([]geocodeItem, error) {
v := url.Values{}
v.Set("key", s.cfg.GeocodeKey)
v.Set("keywords", q)
v.Set("offset", "8")
v.Set("page", "1")
v.Set("extensions", "base")
body, err := s.amapGet(ctx, amapPlaceTextURL+"?"+v.Encode())
if err != nil {
return nil, err
}
var raw struct {
Status string `json:"status"`
Info string `json:"info"`
Pois []struct {
Name string `json:"name"`
Location string `json:"location"`
Address json.RawMessage `json:"address"`
Pname json.RawMessage `json:"pname"`
Cityname json.RawMessage `json:"cityname"`
Adname json.RawMessage `json:"adname"`
} `json:"pois"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
if raw.Status != "1" {
return nil, &geoErr{"高德返回:" + raw.Info}
}
out := make([]geocodeItem, 0, len(raw.Pois))
for _, p := range raw.Pois {
lon, lat, ok := splitLocation(p.Location)
if !ok {
continue
}
out = append(out, geocodeItem{
Name: p.Name, Address: amapStr(p.Address), Lat: lat, Lon: lon,
Province: amapStr(p.Pname), City: amapStr(p.Cityname), District: amapStr(p.Adname),
})
}
return out, nil
}
func (s *Server) amapGeocode(ctx context.Context, q string) ([]geocodeItem, error) {
v := url.Values{}
v.Set("key", s.cfg.GeocodeKey)
v.Set("address", q)
body, err := s.amapGet(ctx, amapGeocodeURL+"?"+v.Encode())
if err != nil {
return nil, err
}
var raw struct {
Status string `json:"status"`
Info string `json:"info"`
Geocodes []struct {
FormattedAddress string `json:"formatted_address"`
Location string `json:"location"`
Province json.RawMessage `json:"province"`
City json.RawMessage `json:"city"`
District json.RawMessage `json:"district"`
} `json:"geocodes"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
if raw.Status != "1" {
return nil, &geoErr{"高德返回:" + raw.Info}
}
out := make([]geocodeItem, 0, len(raw.Geocodes))
for _, g := range raw.Geocodes {
lon, lat, ok := splitLocation(g.Location)
if !ok {
continue
}
out = append(out, geocodeItem{
Name: g.FormattedAddress, Address: g.FormattedAddress, Lat: lat, Lon: lon,
Province: amapStr(g.Province), City: amapStr(g.City), District: amapStr(g.District),
})
}
return out, nil
}
func (s *Server) amapGet(ctx context.Context, u string) ([]byte, error) {
// 网络抖动很常见,失败退避重试几次;限速令牌每次请求都要重新领
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
select {
case <-time.After(time.Duration(attempt) * 400 * time.Millisecond):
case <-ctx.Done():
return nil, ctx.Err()
}
}
select {
case <-amapLimiter.C:
case <-ctx.Done():
return nil, ctx.Err()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "rond-with-you/1.0")
resp, err := geocodeHTTPClient.Do(req)
if err != nil {
lastErr = &geoErr{"请求高德失败:" + err.Error()}
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = &geoErr{"高德返回 " + resp.Status}
continue
}
if readErr != nil {
lastErr = &geoErr{"读取高德响应失败:" + readErr.Error()}
continue
}
return body, nil
}
return nil, lastErr
}
// amapStr 处理高德字段既可能是字符串、也可能是空数组的情况(直辖市常为空数组)。
func amapStr(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
var arr []string
if json.Unmarshal(raw, &arr) == nil && len(arr) > 0 {
return arr[0]
}
return ""
}
// splitLocation 解析高德的 "经度,纬度"。
func splitLocation(v string) (lon, lat float64, ok bool) {
a, b, found := strings.Cut(strings.TrimSpace(v), ",")
if !found {
return 0, 0, false
}
lon, err1 := strconv.ParseFloat(strings.TrimSpace(a), 64)
lat, err2 := strconv.ParseFloat(strings.TrimSpace(b), 64)
if err1 != nil || err2 != nil {
return 0, 0, false
}
return lon, lat, true
}
type geoErr struct{ msg string }
func (e *geoErr) Error() string { return e.msg }
// ---------- 逆地理编码(坐标 -> 地址),照片导入用 ----------
// regeoItem 是一个坐标反查到的地址信息。坐标是**转换后**的 GCJ-02
// (EXIF 是 WGS-84,前端把原始值发上来,这里统一转,避免两份实现)。
type regeoItem struct {
// Key 由调用方给(照片导入里是行号):同一坐标可能被多张照片共用,
// 服务端做了去重,没有这个 key 就配不回原来的行。
Key string `json:"key"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Address string `json:"address"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
Street string `json:"street"`
Township string `json:"township"`
// Poi / PoiDist 是最近的兴趣点(店名)与距离(米)——
// 照片导入里用它判断「这个点到底在哪儿」,比只给街道好认得多
Poi string `json:"poi"`
PoiDist float64 `json:"poiDist"`
}
const regeoURL = "https://restapi.amap.com/v3/geocode/regeo"
const aroundURL = "https://restapi.amap.com/v3/place/around"
// 高德对免费 key 有 QPS 限制,一次性甩几十个请求容易被限流(返回
// CUQPS_HAS_EXCEEDED_THE_LIMIT)。所有高德请求统一在这里排队,按 10 次/秒放行。
const amapQPS = 10
// amapLimiter 是全局限速器:令牌在每次真正发请求前领取,
// 所以一个点位内部发几次请求(regeo + 周边搜索)也照样受限。
var amapLimiter = time.NewTicker(time.Second / amapQPS)
// apiRegeo 批量把坐标换成地址:照片导入时用来让用户判断「这个点对不对」。
// 只传坐标、不传照片;相同坐标会去重,避免同一地点拍多张时重复调用。
func (s *Server) apiRegeo(w http.ResponseWriter, r *http.Request) {
if s.cfg.GeocodeKey == "" {
s.json(w, map[string]any{"error": "服务端未配置 geocode_key,无法反查地址"})
return
}
var req struct {
Coord string `json:"coord"`
Points []struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Key string `json:"key"`
} `json:"points"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
s.json(w, map[string]any{"error": "请求格式不对"})
return
}
if len(req.Points) > 200 {
s.json(w, map[string]any{"error": "一次最多反查 200 个坐标"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
// 先把坐标规整成 GCJ-02(EXIF 是 WGS-84;部分国产手机可能已经是 GCJ-02,由前端选择)
type job struct {
lat, lon float64
dedup string // 去重用
keys []string
}
jobs := make([]job, 0, len(req.Points))
at := map[string]int{}
for _, p := range req.Points {
lat, lon := p.Lat, p.Lon
if req.Coord != "gcj02" {
lat, lon = geo.WGS84ToGCJ02(lat, lon)
}
// 5 位小数约 1 米:同一地点拍的多张只查一次,省配额也省时间
k := fmt.Sprintf("%.5f,%.5f", lat, lon)
if i, ok := at[k]; ok {
jobs[i].keys = append(jobs[i].keys, p.Key)
continue
}
at[k] = len(jobs)
jobs = append(jobs, job{lat: lat, lon: lon, dedup: k, keys: []string{p.Key}})
}
// 一个去重后的点位可能对应多行,结果按行返回(key 原样带回)
perJob := make([]regeoItem, len(jobs))
sem := make(chan struct{}, 4) // 限并发;速率由 amapGet 里的全局限速器控制
var wg sync.WaitGroup
for i, j := range jobs {
if ctx.Err() != nil {
break
}
wg.Add(1)
go func(i int, j job) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
item := regeoItem{Lat: j.lat, Lon: j.lon}
if addr, prov, city, dist, street, town, ok := s.amapRegeo(ctx, j.lat, j.lon); ok {
item.Address, item.Province, item.City = addr, prov, city
item.District, item.Street, item.Township = dist, street, town
}
// 最近的地点(店名):regeo 返回的 pois 多是行政单位,
// 用周边搜索按距离排序取第一条才是店名
if name, dist, ok := s.amapNearestPOI(ctx, j.lat, j.lon); ok {
item.Poi, item.PoiDist = name, dist
}
perJob[i] = item
}(i, j)
}
wg.Wait()
items := make([]regeoItem, 0, len(req.Points))
for i, j := range jobs {
for _, k := range j.keys {
it := perJob[i]
it.Key = k
items = append(items, it)
}
}
s.json(w, map[string]any{"items": items})
}
// amapNearestPOI 找坐标附近最近的一个兴趣点(店名)。
// 半径给 200 米:再大容易匹配到马路边上不相干的店;取不到就返回 ok=false,
// 界面上只显示地址。
func (s *Server) amapNearestPOI(ctx context.Context, lat, lon float64) (name string, dist float64, ok bool) {
v := url.Values{}
v.Set("key", s.cfg.GeocodeKey)
v.Set("location", fmt.Sprintf("%.6f,%.6f", lon, lat))
v.Set("radius", "200")
v.Set("offset", "1")
v.Set("sortrule", "distance") // 按距离排序,第一条就是最近的
body, err := s.amapGet(ctx, aroundURL+"?"+v.Encode())
if err != nil {
return "", 0, false
}
var raw struct {
Status string `json:"status"`
Pois []struct {
Name string `json:"name"`
Distance string `json:"distance"`
} `json:"pois"`
}
if err := json.Unmarshal(body, &raw); err != nil || raw.Status != "1" || len(raw.Pois) == 0 {
return "", 0, false
}
d, _ := strconv.ParseFloat(raw.Pois[0].Distance, 64)
return raw.Pois[0].Name, d, true
}
// amapRegeo 调一次高德的逆地理编码。失败返回 ok=false,调用方留空即可。
func (s *Server) amapRegeo(ctx context.Context, lat, lon float64) (addr, prov, city, dist, street, town string, ok bool) {
v := url.Values{}
v.Set("key", s.cfg.GeocodeKey)
// 高德要的是「经度,纬度」
v.Set("location", fmt.Sprintf("%.6f,%.6f", lon, lat))
v.Set("extensions", "base")
body, err := s.amapGet(ctx, regeoURL+"?"+v.Encode())
if err != nil {
return
}
var raw struct {
Status string `json:"status"`
Regeocode struct {
Formatted string `json:"formatted_address"`
Comp struct {
Province json.RawMessage `json:"province"`
City json.RawMessage `json:"city"`
District json.RawMessage `json:"district"`
Township json.RawMessage `json:"township"`
StreetN struct {
Street json.RawMessage `json:"street"`
} `json:"streetNumber"`
} `json:"addressComponent"`
} `json:"regeocode"`
}
if err := json.Unmarshal(body, &raw); err != nil || raw.Status != "1" {
return "", "", "", "", "", "", false
}
return raw.Regeocode.Formatted,
amapStr(raw.Regeocode.Comp.Province), amapStr(raw.Regeocode.Comp.City),
amapStr(raw.Regeocode.Comp.District), amapStr(raw.Regeocode.Comp.StreetN.Street),
amapStr(raw.Regeocode.Comp.Township), true
}
+115
View File
@@ -0,0 +1,115 @@
package web
import (
"compress/gzip"
"net/http"
"strings"
"sync"
)
// 可压缩的响应类型。图片(迷雾 PNG、图标)本身已是压缩格式,再压纯属浪费 CPU。
func compressible(ct string) bool {
ct = strings.ToLower(ct)
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
ct = strings.TrimSpace(ct)
switch ct {
case "text/html", "text/css", "text/plain", "text/javascript",
"application/javascript", "application/json", "application/x-javascript",
"image/svg+xml", "application/manifest+json", "application/xml":
return true
}
return strings.HasPrefix(ct, "text/") && ct != "text/cache-manifest"
}
type gzipWriter struct {
http.ResponseWriter
gz *gzip.Writer
comp bool
seen bool // 首部是否已定下来(WriteHeader 已调用,或首字节已写)
}
var gzipPool = sync.Pool{New: func() any {
return gzip.NewWriter(nil)
}}
// WriteHeader 必须在首部提交前决定压不压。静态文件由 http.FileServer 提供,
// 它会先设好 Content-Length 再显式 WriteHeader(200/206);若拖到 Write 里再改首部,
// 首部早已提交,会出现「声明长度 ≠ 实际压缩后长度」,浏览器报
// ERR_CONTENT_LENGTH_MISMATCH 并把 CSS/JS 截断。只有 200 才压缩,
// 206(Range)/304 等自带长度语义,一律原样透传。
func (g *gzipWriter) WriteHeader(code int) {
if g.seen {
return
}
g.seen = true
if code == http.StatusOK && compressible(g.Header().Get("Content-Type")) {
g.comp = true
g.Header().Set("Content-Encoding", "gzip")
g.Header().Set("Vary", "Accept-Encoding")
g.Header().Del("Content-Length")
}
g.ResponseWriter.WriteHeader(code)
}
func (g *gzipWriter) Write(b []byte) (int, error) {
if !g.seen {
g.seen = true
// 上游没有显式 WriteHeader 时,首字节写入才能确定内容类型,此时决定压不压
ct := g.Header().Get("Content-Type")
if ct == "" {
ct = http.DetectContentType(b)
g.Header().Set("Content-Type", ct)
}
if compressible(ct) {
g.comp = true
g.Header().Set("Content-Encoding", "gzip")
g.Header().Set("Vary", "Accept-Encoding")
g.Header().Del("Content-Length")
}
}
if !g.comp {
return g.ResponseWriter.Write(b)
}
if g.gz == nil {
g.gz = gzipPool.Get().(*gzip.Writer)
g.gz.Reset(g.ResponseWriter)
}
return g.gz.Write(b)
}
func (g *gzipWriter) Flush() {
if g.gz != nil {
g.gz.Flush()
}
if f, ok := g.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (g *gzipWriter) close() {
if g.gz != nil {
g.gz.Close()
gzipPool.Put(g.gz)
g.gz = nil
}
}
// gzipHandler 对声明了 gzip 且内容类型可压缩的响应做在线压缩。
func gzipHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 带 Range 的请求(浏览器校验 / 续传 immutable 缓存的静态资源)会返回 206 并自带
// Content-Length,gzip 无法在首部已提交后再改写长度,会导致浏览器把 CSS/JS 截断
// (ERR_CONTENT_LENGTH_MISMATCH)。这类请求直接透传、不压缩。
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") ||
r.Header.Get("Upgrade") != "" ||
r.Header.Get("Range") != "" {
next.ServeHTTP(w, r)
return
}
gw := &gzipWriter{ResponseWriter: w}
defer gw.close()
next.ServeHTTP(gw, r)
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
package web
import (
"bytes"
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
"rond-with-you/internal/stats"
)
// 这两个导出面向站长自己的数据迁移,因此不做「家 / 工作」过滤——
// 能进后台的人本来就拥有全部数据的查看权。
func (s *Server) exportPlaces(w http.ResponseWriter, r *http.Request) []stats.Place {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可导出的数据集")
return nil
}
// 直接按 dataset 全量取,绕过筛选与隐私开关
rows, err := s.db.QueryContext(ctx, `SELECT `+stats.PlaceCols+`
FROM places p WHERE p.dataset_id=$1 ORDER BY p.id`, ds.ID)
if err != nil {
s.serverError(w, r, err)
return nil
}
defer rows.Close()
var out []stats.Place
for rows.Next() {
var p stats.Place
if err := stats.ScanPlace(rows, &p); err != nil {
s.serverError(w, r, err)
return nil
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
s.serverError(w, r, err)
return nil
}
return out
}
func xmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
return r.Replace(s)
}
func (s *Server) exportGeoJSON(w http.ResponseWriter, r *http.Request) {
places := s.exportPlaces(w, r)
if places == nil {
return
}
var b strings.Builder
b.WriteString(`{"type":"FeatureCollection","features":[`)
for i, p := range places {
if i > 0 {
b.WriteByte(',')
}
var first, last string
if p.FirstAt != nil {
first = p.FirstAt.UTC().Format("2006-01-02")
}
if p.LastAt != nil {
last = p.LastAt.UTC().Format("2006-01-02")
}
// GeoJSON 坐标是 [lon, lat];库里的坐标本身是 GCJ-02,原样导出并在说明里注明
fmt.Fprintf(&b, `{"type":"Feature","geometry":{"type":"Point","coordinates":[%v,%v]},`+
`"properties":{"name":%q,"city":%q,"province":%q,"category":%q,`+
`"visits":%d,"dwell_min":%d,"first":%q,"last":%q,"crs":"gcj02"}}`,
p.Lon, p.Lat, p.Name, p.City, p.Province, p.POICategory,
p.VisitCount, p.DwellMinutes, first, last)
}
b.WriteString(`]}`)
w.Header().Set("Content-Type", "application/geo+json; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="places.geojson"`)
w.Write([]byte(b.String()))
}
func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) {
places := s.exportPlaces(w, r)
if places == nil {
return
}
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
b.WriteString(`<gpx version="1.1" creator="rond-with-you" xmlns="http://www.topografix.com/GPX/1/1">` + "\n")
for _, p := range places {
var parts []string
if p.City != "" {
parts = append(parts, p.Province, p.City)
}
if p.POICategory != "" {
parts = append(parts, poiName(p.POICategory))
}
desc := strings.Join(strings.Fields(strings.Join(parts, " · ")), " ")
var last string
if p.LastAt != nil {
last = p.LastAt.UTC().Format("2006-01-02T15:04:05Z")
}
fmt.Fprintf(&b, "<wpt lat=\"%v\" lon=\"%v\"><name>%s</name>", p.Lat, p.Lon, xmlEscape(p.Name))
if desc != "" {
fmt.Fprintf(&b, "<desc>%s</desc>", xmlEscape(desc))
}
if last != "" {
fmt.Fprintf(&b, "<time>%s</time>", last)
}
b.WriteString("</wpt>\n")
}
b.WriteString("</gpx>\n")
w.Header().Set("Content-Type", "application/gpx+xml; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="places-%s.gpx"`, time.Now().Format("20060102")))
w.Write([]byte(b.String()))
}
// baselinePath 返回某数据集的基线库路径。基线是导入时从原始备份包留下的单文件副本,
// 导出时以它为底做覆盖,从而保住 Core Data 的元数据表、索引、持久化历史,
// 以及我们并不落库的实体(原始 GPS 采样点 ZRAWVISIT、ZKEYWORD、ZTRIP* 等)。
func (s *Server) baselinePath(datasetID int64) string {
return filepath.Join(s.cfg.DataDir, "baselines", fmt.Sprintf("%d.sqlite", datasetID))
}
// ensureBaseline 确保基线库存在;缺失时从当初上传的原始备份包重建。
func (s *Server) ensureBaseline(ctx context.Context, datasetID int64) (string, error) {
p := s.baselinePath(datasetID)
if _, err := os.Stat(p); err == nil {
return p, nil
}
archive, err := s.findSourceArchive(ctx, datasetID)
if err != nil {
return "", err
}
if err := rond.WriteBaseline(archive, p); err != nil {
return "", fmt.Errorf("重建基线库失败:%w", err)
}
return p, nil
}
// findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。
func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) {
var sha string
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,'') FROM datasets WHERE id=$1`, datasetID).Scan(&sha); err != nil {
return "", err
}
files, _ := filepath.Glob(filepath.Join(s.cfg.UploadDir, "*"))
// 新的在前,命中即止
sort.Slice(files, func(i, j int) bool {
fi, ei := os.Stat(files[i])
fj, ej := os.Stat(files[j])
if ei != nil || ej != nil {
return false
}
return fi.ModTime().After(fj.ModTime())
})
for _, f := range files {
if fi, err := os.Stat(f); err != nil || fi.IsDir() {
continue
}
got, _, err := ingest.File(f)
if err != nil {
continue
}
if sha == "" || got == sha {
return f, nil
}
}
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
}
// exportRondbackup 以基线库为底,把当前数据集写回为 rond 原始格式的 .rondbackup 并下载。
// 坐标是 GCJ-02,与 rond 同系,原样写回;新增/编辑的记录随数据集一并导出。
func (s *Server) exportRondbackup(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可导出的数据集")
return
}
base, err := s.ensureBaseline(ctx, ds.ID)
if err != nil {
s.adminFlash(w, r, "err", "导出 rondbackup 失败:"+err.Error())
return
}
// 先构建到缓冲,成功后再写响应头,避免半截输出破坏下载文件
var buf bytes.Buffer
if err := rond.ExportRondbackup(ctx, s.db, ds.ID, base, &buf); err != nil {
s.adminFlash(w, r, "err", "导出 rondbackup 失败:"+err.Error())
return
}
stem := strings.TrimSuffix(filepath.Base(ds.Name), filepath.Ext(ds.Name))
if stem == "" {
stem = "LifeEasy"
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s-export-%s.rondbackup"`, stem, time.Now().Format("20060102")))
w.Write(buf.Bytes())
}
+267
View File
@@ -0,0 +1,267 @@
package web
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"rond-with-you/internal/setting"
)
// 地点图片的几条硬约束。字节由浏览器转好 WebP 才发过来,服务端只做形状校验,
// 不重新编码(1 核机器扛不住解码大图)。
const (
imageMaxBytes = 6 << 20 // 单张上限 6MB:长边 1440px、按 220KB 目标降质的 WebP 通常不到 300KB
imageMaxPer = 12 // 单个地点最多留几张,防止误拖一堆进来
imageUploadMem = 8 << 20 // multipart 解析用的内存上限
)
// PlaceImage 是模板里用的地点图片。
type PlaceImage struct {
ID int64
URL string
Width int
Height int
Bytes int64
}
// savePlaceImage 把一段已经转好的 WebP 字节存到当前后端,落一行记录,并返回可用的地址。
// 上传接口与照片导入共用这一条路径,省得两处各写一遍 key 规则。
// url 为空表示「存是存进去了,但没配 s3_public_base,浏览器取不到」——调用方要据此提示。
func (s *Server) savePlaceImage(ctx context.Context, datasetID int64, srcPK int, data []byte, w, h int) (int64, string, error) {
if !isWebP(data) {
return 0, "", fmt.Errorf("不是 WebP 数据")
}
backend, err := s.imageBackend(ctx)
if err != nil {
return 0, "", err
}
sum := sha256.Sum256(data)
// 对象名 = <数据集>/<上传月份>/<地点号>-<内容哈希>.webp
// 按月份分目录:一次出行传几十张,按月归堆最好找、也最好整月备份/迁移;
// 若按地点分目录,几百个地点会摊出几百个大多只有零张图的空夹。
// 文件名带地点号:在对象存储控制台里一眼看出这张是谁的,不用回库查。
// 带内容哈希:同一张图重复上传落到同一个 key,天然去重。
key := fmt.Sprintf("%d/%s/%d-%s.webp", datasetID, time.Now().Format("200601"),
srcPK, hex.EncodeToString(sum[:])[:16])
if err := s.media.Save(ctx, backend, key, data, "image/webp"); err != nil {
return 0, "", err
}
var id int64
if err := s.db.QueryRowContext(ctx, `INSERT INTO place_images
(dataset_id, src_pk, storage, object_key, width, height, bytes, position)
VALUES ($1,$2,$3,$4,$5,$6,$7,
COALESCE((SELECT max(position)+1 FROM place_images WHERE dataset_id=$1 AND src_pk=$2), 0))
ON CONFLICT (dataset_id, src_pk, object_key) DO UPDATE SET bytes=EXCLUDED.bytes
RETURNING id`,
datasetID, srcPK, backend, key, w, h, int64(len(data))).Scan(&id); err != nil {
return 0, "", err
}
return id, s.media.PublicURL(backend, key), nil
}
// adminPlaceImageUpload 收下浏览器转好的 WebP 并落库。
// 存哪边由后台设置决定;对象存储的凭据只在服务端,前端拿到的永远是最终 URL。
func (s *Server) adminPlaceImageUpload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return
}
if err := r.ParseMultipartForm(imageUploadMem); err != nil {
s.json(w, map[string]any{"error": "上传内容读不出来(可能太大)"})
return
}
src, err := strconv.Atoi(strings.TrimSpace(r.FormValue("src_pk")))
if err != nil {
s.json(w, map[string]any{"error": "地点标识无效"})
return
}
var exists bool
if err := s.db.QueryRowContext(ctx, `SELECT true FROM places
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&exists); err != nil {
s.json(w, map[string]any{"error": "地点不存在"})
return
}
var count int
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM place_images
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&count); err != nil {
s.serverError(w, r, err)
return
}
if count >= imageMaxPer {
s.json(w, map[string]any{"error": fmt.Sprintf("一个地点最多 %d 张,先删几张再传", imageMaxPer)})
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
s.json(w, map[string]any{"error": "没有收到图片"})
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, imageMaxBytes+1))
if err != nil {
s.json(w, map[string]any{"error": "读取上传内容失败"})
return
}
if len(data) > imageMaxBytes {
s.json(w, map[string]any{"error": fmt.Sprintf("图片超过 %dMB", imageMaxBytes>>20)})
return
}
if !isWebP(data) {
// 前端没转成功(老浏览器不支持 canvas.toBlob('image/webp'))时给出明确提示
s.json(w, map[string]any{"error": "只接受 WebP:请用较新的浏览器,或在转换完成后再保存"})
return
}
id, url, err := s.savePlaceImage(ctx, ds.ID, src, data,
atoiOr(r.FormValue("w"), 0), atoiOr(r.FormValue("h"), 0))
if err != nil {
log.Printf("地点图片写入失败(src_pk=%d): %v", src, err)
s.json(w, map[string]any{"error": "保存图片失败:" + err.Error()})
return
}
backend, _ := s.imageBackend(ctx)
out := map[string]any{"ok": true, "id": id, "url": url, "bytes": len(data),
"name": hdr.Filename, "storage": backend}
if url == "" {
out["error"] = "图片已存进对象存储,但没配 s3_public_base,浏览器取不到;" +
"请在 conf/app.ini 里补上公开域名后重新上传"
}
s.json(w, out)
}
// adminPlaceImageDelete 删掉一张图:先删对象再删行(对象删失败也不留孤儿行)。
func (s *Server) adminPlaceImageDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
s.json(w, map[string]any{"error": "还没有可编辑的数据集"})
return
}
// 前端是用 FormData 提交的(multipart),这里**不要**先 r.ParseForm():
// 它会把 r.Form 先填成「不含 multipart 字段」的那一份,之后再怎么读都是空的
// (FormValue 只在 r.Form == nil 时才去解析)。直接用 FormValue,它自己会挑对解析方式。
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
if err != nil {
s.json(w, map[string]any{"error": "图片标识无效"})
return
}
var backend, key string
if err := s.db.QueryRowContext(ctx, `SELECT storage, object_key FROM place_images
WHERE id=$1 AND dataset_id=$2`, id, ds.ID).Scan(&backend, &key); err != nil {
s.json(w, map[string]any{"error": "图片不存在"})
return
}
if err := s.media.Delete(ctx, backend, key); err != nil {
// 对象删不掉就留着行,否则文件永远无人回收
log.Printf("删除图片对象失败(%s %s): %v", backend, key, err)
s.json(w, map[string]any{"error": "删除文件失败:" + err.Error()})
return
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM place_images WHERE id=$1 AND dataset_id=$2`,
id, ds.ID); err != nil {
s.serverError(w, r, err)
return
}
s.json(w, map[string]any{"ok": true})
}
// imageBackend 取当前设置的存储后端;选了对象存储但凭据不全时明确报出来,
// 不要静默退回本地(那样用户以为存到 R2 了,其实落在服务器磁盘上)。
func (s *Server) imageBackend(ctx context.Context) (string, error) {
st, err := s.sets.Load(ctx)
if err != nil {
return "", err
}
if st.MediaBackend == setting.MediaBackendS3 {
if !s.media.S3Ready() {
return "", fmt.Errorf("后台选了对象存储,但 conf/app.ini 里的 s3_* 没配齐")
}
return setting.MediaBackendS3, nil
}
return setting.MediaBackendLocal, nil
}
// placeImagesOf 读某个地点的图片,按 position 排序。
func (s *Server) placeImagesOf(ctx context.Context, datasetID int64, srcPK int) ([]PlaceImage, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, storage, object_key, width, height, bytes
FROM place_images WHERE dataset_id=$1 AND src_pk=$2 ORDER BY position, id`, datasetID, srcPK)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PlaceImage
for rows.Next() {
var img PlaceImage
var backend, key string
if err := rows.Scan(&img.ID, &backend, &key, &img.Width, &img.Height, &img.Bytes); err != nil {
return nil, err
}
url := s.media.PublicURL(backend, key)
if url == "" {
continue // 存储配置被撤掉后取不到址,跳过而不是给出破图
}
img.URL = url
out = append(out, img)
}
return out, rows.Err()
}
// serveMedia 提供本地存储的图片。key 是内容寻址的(带 sha1 前缀),
// 内容永不变化,所以可以长期强缓存。
func (s *Server) serveMedia(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, "/media/")
full, err := s.media.LocalPath(rel)
if err != nil {
http.NotFound(w, r)
return
}
f, err := os.Open(full)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", mimeOfExt(filepath.Ext(full)))
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeContent(w, r, path.Base(full), fi.ModTime(), f)
}
func mimeOfExt(ext string) string {
switch strings.ToLower(ext) {
case ".webp":
return "image/webp"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
default:
return "application/octet-stream"
}
}
// isWebP 校验 RIFF....WEBP 头。前端已经转过一道,这里是防御性的最后一道。
func isWebP(b []byte) bool {
return len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP"
}
+281
View File
@@ -0,0 +1,281 @@
package web
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"rond-with-you/internal/geo"
)
// 照片批量导入:浏览器读 EXIF(static/js/photo-exif.js),只把坐标与时间提交上来。
//
// 照片本身默认不上传:手机相册一次几十张、几百 MB,为了几十字节的 EXIF 全传一遍
// 既慢又占服务器磁盘(早期版本就是这么做的,51 张 334MB 直接把请求撑爆)。
// 例外是行内勾了「存图」的那几张——那几张会在浏览器里先转成 WebP(长边 2000px、
// 通常几百 KB)再随表单发过来,作为地点的配图。
const (
photoMaxRows = 300
// photoMaxBodyBytes 是整个导入请求的上限。不勾「存图」时表单只有几十 KB;
// 勾了之后每张图约 100~250KB(长边 1440px 的 WebP),60MB 够一次带两三百张。
photoMaxBodyBytes = 60 << 20
// 默认离开时间 = 到达后 10 分钟(用户可改)
photoDefaultStay = 10 * time.Minute
)
// PhotoData 是照片导入页的数据。页面只放一个文件选择框,
// 预览表由前端脚本填,所以这里没有表格数据。
type PhotoData struct {
Page
Err string
Flash string
// GeocodeReady 决定前端能否反查地址(没配高德 key 就只能看经纬度)
GeocodeReady bool
// 与「新增地点」同一套选项:类别 / 活动 / 标签
Categories []EditOption
Activities []EditOption
Tags []EditOption
}
func (s *Server) adminPhotosPage(w http.ResponseWriter, r *http.Request) {
p, err := s.pageBase(r, "admin", "照片导入")
if err != nil {
s.serverError(w, r, err)
return
}
p.Flatpickr = true
p.HasMap = true
d := PhotoData{Page: p, GeocodeReady: s.cfg.GeocodeKey != ""}
ds, err := s.datasetFor(r.Context())
if err != nil {
s.serverError(w, r, err)
return
}
if ds != nil {
// 与数据编辑页共用同一批选项,保证两边能选的东西一致
if d.Activities, err = s.editOptions(r.Context(), `SELECT a.id, COALESCE(a.name,'') FROM activities a
WHERE a.dataset_id=$1 ORDER BY a.name`, ds.ID); err != nil {
s.serverError(w, r, err)
return
}
if d.Tags, err = s.editOptions(r.Context(), `SELECT t.id, COALESCE(t.name,'') FROM tags t
WHERE t.dataset_id=$1 ORDER BY t.name`, ds.ID); err != nil {
s.serverError(w, r, err)
return
}
crows, err := s.db.QueryContext(r.Context(), `SELECT DISTINCT poi_category FROM places
WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' ORDER BY poi_category`, ds.ID)
if err != nil {
s.serverError(w, r, err)
return
}
for crows.Next() {
var c string
if err := crows.Scan(&c); err != nil {
crows.Close()
s.serverError(w, r, err)
return
}
d.Categories = append(d.Categories, EditOption{Label: c, Name: poiCategoryLabel(c)})
}
crows.Close()
}
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
s.render(w, "admin/photos", d)
}
// adminPhotosImport 把前端解析好、用户确认过的行写入 places + visits。
// 坐标默认按 WGS-84 处理(EXIF 就是 WGS-84),转成站点用的 GCJ-02 再落库。
func (s *Server) adminPhotosImport(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
redirectPhotos(w, r, "err", "还没有可编辑的数据集")
return
}
// 整个请求体封顶:勾了「存图」时表单是 multipart,最多可能带 300 张小图。
// 不设上限的话,一个畸形请求就能把这台 1 核机器吃满。
r.Body = http.MaxBytesReader(w, r.Body, photoMaxBodyBytes)
// 必须用 ParseMultipartForm 而不是 ParseForm:ParseForm 只认 urlencoded,
// 会把 PostForm 先填成空的那一份,后面 PostFormValue/FormFile 全是空的。
// urlencoded 的调用方(脚本、测试)会拿到 ErrNotMultipart —— 那种情况它已经
// 把普通表单解析好了,放过即可。
if err := r.ParseMultipartForm(imageUploadMem); err != nil && !errors.Is(err, http.ErrNotMultipart) {
redirectPhotos(w, r, "err", "表单太大或读不出来(图片总量请控制在 60MB 以内)")
return
}
n := atoiOr(r.PostFormValue("n"), 0)
if n <= 0 {
redirectPhotos(w, r, "err", "没有可导入的照片")
return
}
if n > photoMaxRows {
redirectPhotos(w, r, "err", fmt.Sprintf("一次最多导入 %d 张,当前 %d 张", photoMaxRows, n))
return
}
// EXIF 标准是 WGS-84,但部分国产手机直接写 GCJ-02——由用户在页面上选择,
// 只有明确说「已是 GCJ-02」时才不转换
fromWGS84 := r.PostFormValue("coord") != "gcj02"
// 这次导入用的坐标系原样留档:以后要复算、要核对,都从这一个字段读,
// 不用再去猜「当初到底选了什么」(2026-09 那批 63 个点就是栽在没有它)
coordSys := "wgs84"
if !fromWGS84 {
coordSys = "gcj02"
}
// 地点的编号逐条分配(见下面 nextSrcPK);到访的主键由 insertVisit 自己取
var imported, skipped, withImage int
var firstErr, imageErr string
for i := 0; i < n; i++ {
if r.PostFormValue(fmt.Sprintf("keep_%d", i)) != "on" {
skipped++
continue
}
lat, ok1 := parseFloat(r.PostFormValue(fmt.Sprintf("lat_%d", i)))
lon, ok2 := parseFloat(r.PostFormValue(fmt.Sprintf("lon_%d", i)))
if !ok1 || !ok2 || (lat == 0 && lon == 0) {
firstErr = fmt.Sprintf("第 %d 行坐标无效,已跳过", i+1)
skipped++
continue
}
// 换算前的原值先存下来:显示坐标换算错了还能反推回去,也能和原图 EXIF 直接对账
srcLat, srcLon := lat, lon
if fromWGS84 {
lat, lon = geo.WGS84ToGCJ02(lat, lon)
}
arrival, ok := parseLocalDT(r.PostFormValue(fmt.Sprintf("arrival_%d", i)))
if !ok {
firstErr = fmt.Sprintf("第 %d 行到达时间无效,已跳过", i+1)
skipped++
continue
}
var departure *time.Time
if v := strings.TrimSpace(r.PostFormValue(fmt.Sprintf("departure_%d", i))); v != "" {
if t, ok := parseLocalDT(v); ok && !t.Before(arrival) {
departure = &t
}
}
if departure == nil {
d := arrival.Add(photoDefaultStay)
departure = &d
}
name := strings.TrimSpace(r.PostFormValue(fmt.Sprintf("name_%d", i)))
if name == "" {
name = fmt.Sprintf("照片地点 %d", i+1)
}
var placeID int64
// 字段与「新增地点」一致:地址相关字段来自反查(也可能被用户改过),
// 空串交给 NULLIF 存成 NULL。类别支持下拉里的「自定义…」。
category := r.PostFormValue(fmt.Sprintf("category_%d", i))
if category == categoryCustomValue {
category = r.PostFormValue(fmt.Sprintf("category_custom_%d", i))
}
// 地点与到访一个事务:以前分两步走,写到访失败时地点会留下成为孤儿
//(visit_count=0),导进 rond 就是个永远「无数据」的点。
// 到访走与「新增到访」同一条路径:活动 / 备注 / 收藏 / 标签都能带上
actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue(fmt.Sprintf("activity_%d", i)))
var placeSrc int
if err := s.withTx(ctx, func(ex execer) error {
// 编号按「库里 + Core Data + 留档」三处最大值取:留档里存着导入时缺必填
// 字段的 skipped 行,那些号没有对应实体行却一直占着,只看 MAX(places) 会复用,
// 导出时新行被当旧行、继承对方的未管理列。
pk, err := s.nextSrcPK(ctx, ex, ds.ID, placeSeq)
if err != nil {
return err
}
if err := clearStaleRaw(ctx, ex, ds.ID, "ZLOCATION", pk); err != nil {
return err
}
placeSrc = pk
if err := ex.QueryRowContext(ctx, `INSERT INTO places
(dataset_id, src_pk, name, poi_category, lat, lon, country_code,
province, city, district, thoroughfare, timezone,
coord_source, coord_sys, src_lat, src_lon)
VALUES ($1,$2,$3,NULLIF($4,''),$5,$6,NULLIF($7,''),NULLIF($8,''),NULLIF($9,''),NULLIF($10,''),NULLIF($11,''),NULLIF($12,''),$13,$14,$15,$16)
RETURNING id`,
ds.ID, pk, name, category,
lat, lon, r.PostFormValue(fmt.Sprintf("country_%d", i)),
r.PostFormValue(fmt.Sprintf("province_%d", i)),
r.PostFormValue(fmt.Sprintf("city_%d", i)), r.PostFormValue(fmt.Sprintf("district_%d", i)),
r.PostFormValue(fmt.Sprintf("street_%d", i)), r.PostFormValue(fmt.Sprintf("timezone_%d", i)),
"photo", coordSys, srcLat, srcLon).Scan(&placeID); err != nil {
return fmt.Errorf("写地点: %w", err)
}
if _, err := s.insertVisit(ctx, ex, ds.ID, u.ID, placeID, arrival, departure, actID, isHome, isWork,
r.PostFormValue(fmt.Sprintf("bookmarked_%d", i)) == "on",
strings.TrimSpace(r.PostFormValue(fmt.Sprintf("remark_%d", i))), "",
r.PostForm[fmt.Sprintf("tags_%d", i)]); err != nil {
return fmt.Errorf("写到访: %w", err)
}
return nil
}); err != nil {
log.Printf("照片导入:第 %d 行落库失败(已整体回滚)%v", i+1, err)
firstErr = "部分照片落库失败,已跳过"
skipped++
continue
}
// 勾了「存图」的行,浏览器已经把这张照片转成 WebP 一起发过来了。
// 图片存失败只记日志、不回滚地点:地点已经写好,丢一张图不该连点一起丢。
if n, err := s.attachImportedImage(ctx, r, ds.ID, placeSrc, i); err != nil {
log.Printf("照片导入:第 %d 行配图失败(地点已保留): %v", i+1, err)
imageErr = "部分照片的配图没存上(地点已导入)"
} else if n > 0 {
withImage += n
}
imported++
}
s.refreshDatasetAgg(ctx, ds.ID)
msg := fmt.Sprintf("已导入 %d 个地点(照片)", imported)
if withImage > 0 {
msg += fmt.Sprintf(",配了 %d 张图", withImage)
}
if skipped > 0 {
msg += fmt.Sprintf(",跳过 %d 张", skipped)
}
if firstErr != "" {
msg += "。" + firstErr
}
if imageErr != "" {
msg += "。" + imageErr
}
redirectPhotos(w, r, "ok", msg)
}
// attachImportedImage 收下行内附带的 WebP 并挂到刚建好的地点上。
// 浏览器已经把原图转码过了,这里只做形状校验与转存;没有这一项就返回 0。
func (s *Server) attachImportedImage(ctx context.Context, r *http.Request, datasetID int64, srcPK, row int) (int, error) {
f, _, err := r.FormFile(fmt.Sprintf("image_%d", row))
if err != nil {
return 0, nil // 这一行没勾「存图」
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, imageMaxBytes+1))
if err != nil {
return 0, fmt.Errorf("读图失败: %w", err)
}
if len(data) == 0 {
return 0, nil
}
if len(data) > imageMaxBytes {
return 0, fmt.Errorf("图片超过 %dMB", imageMaxBytes>>20)
}
if _, _, err := s.savePlaceImage(ctx, datasetID, srcPK, data,
atoiOr(r.PostFormValue(fmt.Sprintf("image_w_%d", row)), 0),
atoiOr(r.PostFormValue(fmt.Sprintf("image_h_%d", row)), 0)); err != nil {
return 0, err
}
return 1, nil
}
// redirectPhotos 把结果带回照片导入页。
func redirectPhotos(w http.ResponseWriter, r *http.Request, kind, msg string) {
http.Redirect(w, r, "/admin/photos?"+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
}
File diff suppressed because it is too large Load Diff
+500
View File
@@ -0,0 +1,500 @@
package web
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"html/template"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// 专题 = 某段时间 + 某个区域内的足迹。地点来自「时间范围内的到访」,
// 迷雾不落库、由地图瓦片按视口自然呈现——所以专题只要把地图视野限定在
// 区域(或地点包围盒)上,「区域内的迷雾」就成立了。
const (
// 表单现在精确到分钟;纯日期形式仍然接受(老表单缓存 / 手工拼接参数)
topicTimeLayout = "2006-01-02"
topicDateTimeLayout = "2006-01-02T15:04"
)
// parseTopicTime 解析表单时间,两种格式都认。
func parseTopicTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
if t, err := time.ParseInLocation(topicDateTimeLayout, raw, time.Local); err == nil {
return t, true
}
if t, err := time.ParseInLocation(topicTimeLayout, raw, time.Local); err == nil {
return t, true
}
return time.Time{}, false
}
// ---------- 查询 ----------
// loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。
func (s *Server) loadTopics(ctx context.Context, datasetID int64, onlyEnabled bool) ([]Topic, error) {
q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon,
radius_km, show_fog, enabled, sort_order
FROM topics WHERE dataset_id=$1`
if onlyEnabled {
q += ` AND enabled`
}
q += ` ORDER BY sort_order, id`
rows, err := s.db.QueryContext(ctx, q, datasetID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Topic
for rows.Next() {
var t Topic
if err := rows.Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder); err != nil {
return nil, err
}
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt)
out = append(out, t)
}
return out, rows.Err()
}
// topicRangeLabel 给完整时间范围。整天(00:00 ~ 23:59)时只显示日期——
// 这种情况多是「按天圈的范围」,写成「00:00 ~ 23:59」只是噪音;
// 只要有一端带了具体时刻,就完整显示到分钟。
func topicRangeLabel(start, end time.Time) string {
a, b := start.Local(), end.Local()
wholeDays := a.Hour() == 0 && a.Minute() == 0 && b.Hour() == 23 && b.Minute() >= 59
if wholeDays {
if a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() {
return a.Format("2006-01-02")
}
return fmt.Sprintf("%s ~ %s", a.Format("2006-01-02"), b.Format("2006-01-02"))
}
if a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() {
return fmt.Sprintf("%s %s ~ %s", a.Format("2006-01-02"), a.Format("15:04"), b.Format("15:04"))
}
return fmt.Sprintf("%s ~ %s", a.Format("2006-01-02 15:04"), b.Format("2006-01-02 15:04"))
}
// topicHaversineSQL 是写进 SQL 的球面距离(公里)表达式,参数依次是中心纬度、中心经度。
// 放在 SQL 里是为了让「区域过滤」和聚合统计一次算完,不必先查一遍再回 Go 过滤。
const topicHaversineSQL = `6371 * 2 * asin(sqrt(
power(sin(radians((%s - p.lat) / 2)), 2) +
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))`
// topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
func (s *Server) topicContent(ctx context.Context, t *Topic, datasetID int64,
regions []string, exclude bool) ([]MapPoint, int, int, int, error) {
args := []any{datasetID, t.StartAt, t.EndAt}
area := ""
if t.HasArea() {
args = append(args, t.CenterLat, t.CenterLon, t.RadiusKm)
n := len(args)
area = fmt.Sprintf(" AND %s <= $%d",
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
}
// 到 area 为止,这条查询自己的占位符就用到这么多;下面的 regionSQL 会在此基础上
// 继续往后编号并追加参数。记下来给下面那条「天数」查询用 —— 它没有区域条件,
// 不能把这份(已被追加过的)args 整个传进去。
baseArgs := len(args)
q := `SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
COALESCE(p.city,''),
COALESCE((SELECT a.name FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL
GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
COALESCE((SELECT a.color FROM visits v2 LEFT JOIN activities a ON a.id=v2.activity_id
WHERE v2.place_id=p.id AND v2.activity_id IS NOT NULL
GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0), max(v.arrival)
FROM places p JOIN visits v ON v.place_id = p.id
WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3` + area +
regionSQL("p", regions, exclude, &args) + `
GROUP BY p.id ORDER BY count(*) DESC, p.id`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, 0, 0, err
}
defer rows.Close()
var points []MapPoint
cities := map[string]bool{}
totalVisits := 0
var totalDwell int64
for rows.Next() {
var m MapPoint
var last time.Time
if err := rows.Scan(&m.ID, &m.SrcPK, &m.Name, &m.Lat, &m.Lon, &m.City,
&m.Act, &m.Color, &m.Count, &m.Dwell, &last); err != nil {
return nil, 0, 0, 0, err
}
m.LastAt = last.Local().Format("2006-01-02")
points = append(points, m)
totalVisits += m.Count
totalDwell += m.Dwell
if m.City != "" {
cities[m.City] = true
}
}
if err := rows.Err(); err != nil {
return nil, 0, 0, 0, err
}
// 天数:按本地日期去重(专题可能只覆盖几天,直接在 SQL 里算更准)。
//
// 参数必须按**这条 SQL 真正用到的占位符**来凑:$1..$3 固定,area 用 $4..$6。
// 以前这里直接把上面那条 SQL 的 args 整个传进来,而它已经被 regionSQL 追加过区域
// 参数 —— 这条 SQL 当时又没有区域条件,参数就比占位符多,pgx 直接报
// 「expected 3 arguments, got 4」。而且专题列表的统计会把这个错误吞掉,
// 表现成「专题内容全变 0、点进去才 500」,很难查。
days := 0
dargs := append([]any{}, args[:baseArgs]...)
daysRegion := regionSQL("p", regions, exclude, &dargs)
if err := s.db.QueryRowContext(ctx, `SELECT count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date)
FROM visits v JOIN places p ON p.id = v.place_id
WHERE p.dataset_id=$1 AND v.arrival >= $2 AND v.arrival <= $3`+area+daysRegion, dargs...).Scan(&days); err != nil {
return nil, 0, 0, 0, err
}
return points, totalVisits, days, len(cities), nil
}
// fillTopicStats 给专题填上内容统计(列表页展示用)。
func (s *Server) fillTopicStats(ctx context.Context, t *Topic, datasetID int64, regions []string, exclude bool) {
points, visits, days, cities, err := s.topicContent(ctx, t, datasetID, regions, exclude)
if err != nil {
// 以前这里直接 return:专题列表会静默显示成「0 个地点」,看不出是出错了。
log.Printf("专题 #%d「%s」统计失败: %v", t.ID, t.Title, err)
return
}
t.PlaceCount, t.VisitCount, t.DayCount, t.CityCount = len(points), visits, days, cities
for _, p := range points {
t.DwellMin += p.Dwell
}
}
// parseTopicForm 解析后台表单。日期按本地时区补成当天 00:00 / 23:59:59,
// 这样「9月1日到9月3日」能把 3 号整天的记录都收进来。
func parseTopicForm(r *http.Request) (Topic, error) {
var t Topic
t.Title = strings.TrimSpace(r.PostFormValue("title"))
t.Subtitle = strings.TrimSpace(r.PostFormValue("subtitle"))
t.Description = strings.TrimSpace(r.PostFormValue("description"))
if t.Title == "" {
return t, fmt.Errorf("请填写专题标题")
}
startRaw := strings.TrimSpace(r.PostFormValue("start_at"))
start, ok := parseTopicTime(startRaw)
if !ok {
return t, fmt.Errorf("请填写有效的开始时间")
}
endRaw := strings.TrimSpace(r.PostFormValue("end_at"))
end, ok := parseTopicTime(endRaw)
if !ok {
return t, fmt.Errorf("请填写有效的结束时间")
}
// 只给了日期(没给时刻)时把结束补到当天最后一刻,不然那天整天的记录会被漏掉
if len(endRaw) <= len(topicTimeLayout) {
end = end.Add(24*time.Hour - time.Second)
}
if end.Before(start) {
return t, fmt.Errorf("结束时间不能早于开始时间")
}
t.StartAt, t.EndAt = start, end
t.CenterLat, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lat")), 64)
t.CenterLon, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lon")), 64)
t.RadiusKm, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("radius_km")), 64)
if t.RadiusKm < 0 {
t.RadiusKm = 0
}
if t.RadiusKm > 0 && t.CenterLat == 0 && t.CenterLon == 0 {
return t, fmt.Errorf("限定区域时要填中心点坐标")
}
t.ShowFog = r.PostFormValue("show_fog") == "on"
t.Enabled = r.PostFormValue("enabled") == "on"
t.SortOrder = atoiOr(r.PostFormValue("sort_order"), 0)
t.StartDate, t.EndDate = start.Format(topicDateTimeLayout), t.EndAt.Format(topicDateTimeLayout)
return t, nil
}
// ---------- 后台:专题管理 ----------
func (s *Server) topicsAdminPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p, err := s.pageBase(r, "admin", "专题管理")
if err != nil {
s.serverError(w, r, err)
return
}
p.Flatpickr = true
d := TopicAdminData{Page: p}
d.Flash, d.Err = r.URL.Query().Get("ok"), r.URL.Query().Get("err")
ds, err := s.datasetFor(ctx)
if err != nil {
s.serverError(w, r, err)
return
}
if ds != nil {
list, err := s.loadTopics(ctx, ds.ID, false)
if err != nil {
s.serverError(w, r, err)
return
}
for i := range list {
s.fillTopicStats(ctx, &list[i], ds.ID, nil, false) // 后台管理页:站长看全部
}
d.Topics = list
// 编辑模式:?edit=<id>
if id := atoiOr(r.URL.Query().Get("edit"), 0); id > 0 {
for _, t := range list {
if t.ID == int64(id) {
d.Form = t
}
}
}
}
if d.Form.Title == "" {
// 新建时给个合理默认:最近 30 天
now := time.Now()
d.Form.StartDate = now.AddDate(0, 0, -30).Format(topicDateTimeLayout)
d.Form.EndDate = now.Format(topicDateTimeLayout)
d.Form.ShowFog, d.Form.Enabled = true, true
d.IsNew = true
}
s.render(w, "admin/topics", d)
}
func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := userFrom(ctx)
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
redirectTopics(w, r, "err", "还没有可编辑的数据集")
return
}
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
t, err := parseTopicForm(r)
if err != nil {
redirectTopics(w, r, "err", err.Error())
return
}
id := atoiOr(r.PostFormValue("id"), 0)
if id > 0 {
if _, err := s.db.ExecContext(ctx, `UPDATE topics SET title=$3, subtitle=$4, description=$5,
start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10,
show_fog=$11, enabled=$12, sort_order=$13
WHERE id=$1 AND dataset_id=$2`,
id, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder); err != nil {
s.serverError(w, r, err)
return
}
redirectTopics(w, r, "ok", "专题已更新")
return
}
var newID int64
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
center_lat, center_lon, radius_km, show_fog, enabled, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
u.ID, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder).Scan(&newID); err != nil {
s.serverError(w, r, err)
return
}
redirectTopics(w, r, "ok", "专题已创建")
}
func (s *Server) adminTopicDelete(w http.ResponseWriter, r *http.Request) {
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM topics WHERE id=$1 AND dataset_id=$2`, id, dsID)
return err
}, "专题已删除")
}
func (s *Server) adminTopicToggle(w http.ResponseWriter, r *http.Request) {
s.topicMutate(w, r, func(ctx context.Context, dsID int64, id int) error {
_, err := s.db.ExecContext(ctx, `UPDATE topics SET enabled = NOT enabled WHERE id=$1 AND dataset_id=$2`, id, dsID)
return err
}, "展示状态已切换")
}
// topicMutate 收敛删除 / 开关两个动作里重复的取参与错误处理。
func (s *Server) topicMutate(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64, int) error, okMsg string) {
ctx := r.Context()
ds, err := s.datasetFor(ctx)
if err != nil || ds == nil {
redirectTopics(w, r, "err", "还没有可编辑的数据集")
return
}
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
id := atoiOr(r.PostFormValue("id"), 0)
if id <= 0 {
redirectTopics(w, r, "err", "专题标识无效")
return
}
if err := fn(ctx, ds.ID, id); err != nil {
s.serverError(w, r, err)
return
}
redirectTopics(w, r, "ok", okMsg)
}
// ---------- 前台:专题列表与详情 ----------
func (s *Server) topicsListPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p, err := s.pageBase(r, "topics", "专题")
if err != nil {
s.serverError(w, r, err)
return
}
d := TopicListData{Page: p}
if p.Dataset != nil {
list, err := s.loadTopics(ctx, p.Dataset.ID, !p.IsAdmin())
if err != nil {
s.serverError(w, r, err)
return
}
regions, exclude := visitorRegions(p)
for i := range list {
s.fillTopicStats(ctx, &list[i], p.Dataset.ID, regions, exclude)
}
d.Topics = list
}
s.render(w, "topics", d)
}
func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p, err := s.pageBase(r, "topics", "专题")
if err != nil {
s.serverError(w, r, err)
return
}
p.HasMap = true
id := atoiOr(r.PathValue("id"), 0)
if id <= 0 || p.Dataset == nil {
http.NotFound(w, r)
return
}
var t Topic
err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at,
center_lat, center_lon, radius_km, show_fog, enabled, sort_order
FROM topics WHERE id=$1 AND dataset_id=$2`, id, p.Dataset.ID).
Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder)
if err == sql.ErrNoRows {
http.NotFound(w, r)
return
}
if err != nil {
s.serverError(w, r, err)
return
}
// 未对外展示的专题只有站长能看
if !t.Enabled && !p.IsAdmin() {
http.NotFound(w, r)
return
}
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt)
regions, exclude := visitorRegions(p)
points, visits, days, cities, err := s.topicContent(ctx, &t, p.Dataset.ID, regions, exclude)
if err != nil {
s.serverError(w, r, err)
return
}
t.PlaceCount, t.VisitCount, t.DayCount, t.CityCount = len(points), visits, days, cities
for _, mp := range points {
t.DwellMin += mp.Dwell
}
p.Title = t.Title
// 站点设置里的「备注对外可见」同样适用于专题页
if p.IsAdmin() || p.Settings.NotesPublic {
s.applyNotesToPoints(ctx, p.Dataset.ID, points)
}
js, err := json.Marshal(points)
if err != nil {
s.serverError(w, r, err)
return
}
fitCircle, fitBBox := fitParams(t, points)
d := TopicData{
Page: p, Topic: t, Points: points, IsAdmin: p.IsAdmin(),
PointsJSON: template.JS(js), FitCircleJSON: fitCircle, FitBBoxJSON: fitBBox,
}
s.render(w, "topic", d)
}
// applyNotesToPoints 把别名/备注贴到地图点上(专题页的迷你地点列表也用)。
func (s *Server) applyNotesToPoints(ctx context.Context, datasetID int64, points []MapPoint) {
ns, err := s.notes.All(ctx, datasetID)
if err != nil || len(ns) == 0 {
return
}
for i := range points {
if n, ok := ns[points[i].SrcPK]; ok {
if n.Alias != "" {
points[i].Name = n.Alias
}
points[i].Note = n.Content
}
}
}
// fitParams 生成把地图视野框到专题范围的脚本参数:限定区域时用圆心 + 半径,
// 否则用地点包围盒——两者都能保证「区域内的迷雾」正好落在视野里。
func fitParams(t Topic, points []MapPoint) (circle, bbox template.JS) {
if t.HasArea() {
b, _ := json.Marshal(map[string]any{
"lat": t.CenterLat, "lon": t.CenterLon, "radiusKm": t.RadiusKm,
})
return template.JS(b), template.JS("null")
}
if len(points) == 0 {
return template.JS("null"), template.JS("null")
}
minLat, maxLat := points[0].Lat, points[0].Lat
minLon, maxLon := points[0].Lon, points[0].Lon
for _, p := range points {
minLat, maxLat = math.Min(minLat, p.Lat), math.Max(maxLat, p.Lat)
minLon, maxLon = math.Min(minLon, p.Lon), math.Max(maxLon, p.Lon)
}
b, _ := json.Marshal(map[string]any{
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
})
return template.JS("null"), template.JS(b)
}
// redirectTopics 把结果带回专题管理页(保留可能的 ?edit= 上下文由页面自己处理)。
func redirectTopics(w http.ResponseWriter, r *http.Request, kind, msg string) {
back := r.Referer()
if !strings.Contains(back, "/admin/topics") {
back = "/admin/topics"
}
sep := "?"
if strings.Contains(back, "?") {
sep = "&"
}
http.Redirect(w, r, back+sep+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
}
+234
View File
@@ -0,0 +1,234 @@
package web
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net"
"net/http"
"strconv"
"strings"
"rond-with-you/internal/stats"
"rond-with-you/internal/votes"
)
// voterCookie 给匿名访客分配稳定身份,用于「一人一票」去重。
const voterCookie = "rond_voter"
// clientIP 取访客 IP,优先用反代透传的 X-Forwarded-For 第一段。
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// voterKeyFromRequest 从既有 cookie 推导匿名身份;没有 cookie 返回空串。
// GET 页面据此显示「你自己的票」,但不主动种 cookie。
func (s *Server) voterKeyFromRequest(r *http.Request) string {
c, err := r.Cookie(voterCookie)
if err != nil || len(c.Value) < 16 {
return ""
}
return hashVoter(c.Value, clientIP(r))
}
// ensureVoterKey 在投票时调用:没有 cookie 就种一个,再返回匿名身份。
// 叠加 IP 后即便访客清 cookie 重来,也会被识别为同一来源,起轻度防刷作用。
func (s *Server) ensureVoterKey(w http.ResponseWriter, r *http.Request) string {
c, err := r.Cookie(voterCookie)
var uid string
if err == nil && len(c.Value) >= 16 {
uid = c.Value
} else {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return ""
}
uid = hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: voterCookie, Value: uid, Path: "/",
MaxAge: 3600 * 24 * 365, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
return hashVoter(uid, clientIP(r))
}
func hashVoter(uid, ip string) string {
sum := sha256.Sum256([]byte(uid + "|" + ip))
return hex.EncodeToString(sum[:16])
}
// votePayload 是投票数据的对外结构(含访客票数、站长结论与「我投的」)。
// Admin / Public 让前端自己决定气泡里该显示「我的结论」还是「大家怎么说」。
type votePayload struct {
Verdicts map[int]int `json:"verdicts"`
Tallies map[int]votes.Tally `json:"tallies"`
Mine map[int]int `json:"mine"`
Admin bool `json:"admin"`
Public bool `json:"public"`
}
// apiVotes 返回全部地点的结论、票数与当前访客自己的票,供地点库 / 地图一次性取回。
// 标记不对访客公开时,访客拿到空集。
func (s *Server) apiVotes(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
isAdmin := userFrom(ctx) != nil
public := true
if sets, err := s.sets.Load(ctx); err == nil {
public = sets.MarksPublic
}
if !isAdmin && !public {
s.json(w, votePayload{
Verdicts: map[int]int{}, Tallies: map[int]votes.Tally{}, Mine: map[int]int{},
Public: false,
})
return
}
var votesDatasetID int64
if ds, err := s.datasetFor(ctx); err == nil && ds != nil {
votesDatasetID = ds.ID
}
verdicts, err := s.votes.Verdicts(ctx, votesDatasetID)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
tallies, err := s.votes.Tallies(ctx)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
mine, err := s.votes.VoterAll(ctx, s.voterKeyFromRequest(r))
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, votePayload{Verdicts: verdicts, Tallies: tallies, Mine: mine, Admin: isAdmin, Public: public})
}
// apiVerdict 供地图气泡以 AJAX 设置站长结论(不刷新页面)。
func (s *Server) apiVerdict(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if err := r.ParseForm(); err != nil {
s.json(w, map[string]string{"error": "请求解析失败"})
return
}
ds, err := s.datasetFor(r.Context())
if err != nil || ds == nil {
s.json(w, map[string]string{"error": "还没有可编辑的数据集"})
return
}
srcPK, err := strconv.Atoi(r.PostFormValue("src_pk"))
verdict, _ := strconv.Atoi(r.PostFormValue("verdict"))
if err != nil {
s.json(w, map[string]string{"error": "地点标识无效"})
return
}
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
verdict = 0
}
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, srcPK, verdict); err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, map[string]any{"verdict": verdict})
}
// apiVote 记录访客对某地点的点赞 / 点踩,返回最新票数与该访客的选择。
func (s *Server) apiVote(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.json(w, map[string]string{"error": "请求解析失败"})
return
}
srcPK, err := strconv.Atoi(r.PostFormValue("src_pk"))
vote, _ := strconv.Atoi(r.PostFormValue("vote"))
if err != nil || (vote != 1 && vote != -1) {
s.json(w, map[string]string{"error": "参数无效"})
return
}
key := s.ensureVoterKey(w, r)
if key == "" {
s.json(w, map[string]string{"error": "无法识别访客"})
return
}
t, mine, err := s.votes.Cast(r.Context(), srcPK, key, vote)
if err != nil {
s.json(w, map[string]string{"error": err.Error()})
return
}
s.json(w, map[string]any{"up": t.Up, "down": t.Down, "mine": mine})
}
// voteTotals 汇总站长结论数量与访客票数,供统计页展示。
func (s *Server) voteTotals(ctx context.Context, datasetID int64) (rec, avoid, up, down int, err error) {
err = s.db.QueryRowContext(ctx, `SELECT
(SELECT count(*) FROM place_votes WHERE dataset_id=$1 AND verdict=1),
(SELECT count(*) FROM place_votes WHERE dataset_id=$1 AND verdict=-1),
(SELECT count(*) FROM place_visitor_votes WHERE vote=1),
(SELECT count(*) FROM place_visitor_votes WHERE vote=-1)`, datasetID).
Scan(&rec, &avoid, &up, &down)
return
}
// verdictPlaces 返回站长标为某结论的地点,按到访次数排序。
func (s *Server) verdictPlaces(ctx context.Context, datasetID int64, verdict, limit int) ([]stats.Place, error) {
q := `SELECT ` + stats.PlaceCols + ` FROM places p
WHERE p.dataset_id=$1 AND EXISTS (SELECT 1 FROM place_votes pv
WHERE pv.src_pk=p.src_pk AND pv.dataset_id=p.dataset_id AND pv.verdict=$2)
ORDER BY p.visit_count DESC LIMIT ` + strconv.Itoa(limit)
rows, err := s.db.QueryContext(ctx, q, datasetID, verdict)
if err != nil {
return nil, err
}
defer rows.Close()
var out []stats.Place
for rows.Next() {
var pl stats.Place
if err := stats.ScanPlace(rows, &pl); err != nil {
return nil, err
}
out = append(out, pl)
}
return out, rows.Err()
}
// adminPlaceVote 设置站长对地点的官方结论(1 推荐 / -1 踩雷 / 0 取消)。
func (s *Server) adminPlaceVote(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if err := r.ParseForm(); err != nil {
s.serverError(w, r, err)
return
}
ds, err := s.datasetFor(r.Context())
if err != nil || ds == nil {
s.adminFlash(w, r, "err", "还没有可编辑的数据集")
return
}
srcPK, err := strconv.Atoi(r.PostFormValue("src_pk"))
verdict, _ := strconv.Atoi(r.PostFormValue("verdict"))
if err != nil {
s.adminFlash(w, r, "err", "地点标识无效")
return
}
if verdict != votes.VerdictRecommend && verdict != votes.VerdictAvoid {
verdict = 0
}
if err := s.votes.SaveVerdict(r.Context(), u.ID, ds.ID, srcPK, verdict); err != nil {
s.serverError(w, r, err)
return
}
next := r.PostFormValue("next")
if !strings.HasPrefix(next, "/") {
next = "/admin"
}
http.Redirect(w, r, next, http.StatusSeeOther)
}
+84
View File
@@ -0,0 +1,84 @@
package web
import (
"sync"
"time"
"rond-with-you/internal/fog"
)
// maskTTL 是遮罩列表的缓存时长。保存/删除遮罩时会主动失效,这里的 TTL 只是兜底
// (比如多实例部署时另一个进程改了库)。所以可以取长一点。
const maskTTL = 30 * time.Second
// maskCache 缓存迷雾遮罩列表。
//
// 为什么需要:遮罩不在 fogIndex 的内存索引里,每个迷雾瓦片请求都会查一次库,
// 而地图一屏就是几十张瓦片——同一份数据被反复查几十遍。指纹也顺带缓存,
// 它被瓦片 URL 与缓存键用到。
type maskCache struct {
mu sync.Mutex
at time.Time
list []fog.Mask
fp string
valid bool
}
// get 返回缓存的遮罩列表与指纹;ok 为 false 表示需要查库。
// 「站点一块遮罩都没有」也是有效缓存(list 为 nil),所以不能拿 list 判空。
func (c *maskCache) get() (list []fog.Mask, fp string, ok bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.valid && time.Since(c.at) < maskTTL {
return c.list, c.fp, true
}
return nil, "", false
}
// put 写入缓存(由调用方查库后调用)。
func (c *maskCache) put(list []fog.Mask) ([]fog.Mask, string) {
fp := fog.MaskFingerprint(list)
c.mu.Lock()
c.list, c.fp, c.at, c.valid = list, fp, time.Now(), true
c.mu.Unlock()
return list, fp
}
// invalidate 在遮罩被保存 / 删除后立刻失效,保证改完立即对所有访客生效。
func (c *maskCache) invalidate() {
c.mu.Lock()
c.valid = false
c.mu.Unlock()
}
// masksFor 取遮罩列表(带缓存)。查库失败时返回 nil(等于不遮),与原先的行为一致。
func (s *Server) masksFor() []fog.Mask {
if list, _, ok := s.masks.get(); ok {
return list
}
list, err := fog.LoadMasks(s.db)
if err != nil {
return nil
}
out, _ := s.masks.put(list)
return out
}
// maskSetFor 取遮罩判定集合(带缓存);没有遮罩时返回 nil。
//
// admin 为 true 时不返回遮罩——站长永远看完整迷雾,否则没法核对遮哪里。
func (s *Server) maskSetFor(admin bool) *fog.MaskSet {
if admin {
return nil
}
return fog.NewMaskSet(s.masksFor())
}
// maskFP 取遮罩指纹(带缓存),供瓦片 URL 与缓存键使用。
func (s *Server) maskFP() string {
if _, fp, ok := s.masks.get(); ok {
return fp
}
list := s.masksFor()
return fog.MaskFingerprint(list)
}
+683
View File
@@ -0,0 +1,683 @@
package web
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/config"
"rond-with-you/internal/db"
"rond-with-you/internal/geo"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
)
// movementFixture 是一份「刚导入、还没被改过」的数据集,外加可直接调用的后台服务。
//
// 需要本地才有的测试库配置与原始备份包,用环境变量开启(缺一个就跳过):
//
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run Export -v
//
// 它会在测试库里建一个专用账号,跑完连同它的数据集一起删掉,不碰别的数据。
type movementFixture struct {
cfg *config.Config
pg *sql.DB
srv *Server
dsID int64
src string // 原始 .rondbackup 的路径
ctx context.Context // 已注入登录态,可直接喂给后台处理函数
}
func newMovementFixture(t *testing.T) *movementFixture {
t.Helper()
confPath, src := os.Getenv("ROND_TEST_CONF"), os.Getenv("ROND_SRC")
if confPath == "" || src == "" {
t.Skip("设置 ROND_TEST_CONF 与 ROND_SRC 后运行导入导出校验")
}
// go test 的工作目录是包目录(internal/web),而配置里的相对路径、以及调试模式下
// 模板的磁盘路径(internal/web/templates/...)都是相对仓库根的,先切过去
chdirModuleRoot(t)
cfg, err := config.Load(confPath)
if err != nil {
t.Fatalf("加载配置 %s 失败: %v", confPath, err)
}
ctx := context.Background()
pg, err := db.Open(cfg.DSN)
if err != nil {
t.Fatalf("连接数据库失败: %v", err)
}
// 注册得比后面几项早,t.Cleanup 是后进先出,所以连接会最后才关
t.Cleanup(func() { pg.Close() })
if err := db.Migrate(ctx, pg); err != nil {
t.Fatalf("初始化数据库失败: %v", err)
}
// 用一次性账号隔离:导入去重是按 (user_id, sha256) 判的,共用账号会命中
// 上一次跑剩的数据集,测的就不是「刚导入的这份」了。
// 顺带清掉上次跑到一半留下的账号,免得越攒越多。
purgeE2EUsers(t, ctx, pg, cfg)
user := fmt.Sprintf("e2e-movement-%d", time.Now().UnixNano())
hash, err := auth.HashPassword("e2e-password")
if err != nil {
t.Fatalf("生成口令哈希失败: %v", err)
}
var uid int64
if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,$2,'e2e') RETURNING id`, user, hash).Scan(&uid); err != nil {
t.Fatalf("创建测试账号失败: %v", err)
}
var prevActive int64
pg.QueryRowContext(ctx, `SELECT COALESCE(id,0) FROM datasets WHERE is_active LIMIT 1`).Scan(&prevActive)
t.Cleanup(func() {
dropE2EUser(t, ctx, pg, cfg, uid)
if prevActive > 0 {
pg.ExecContext(ctx, `UPDATE datasets SET is_active=(id=$1) WHERE id=$1`, prevActive)
}
})
// 导入原始备份包
rdb, err := rond.Open(src, t.TempDir())
if err != nil {
t.Fatalf("打开 %s 失败: %v", src, err)
}
defer rdb.Close()
backup, err := rond.Parse(rdb)
if err != nil {
t.Fatalf("解析 %s 失败: %v", src, err)
}
sha, size, err := ingest.File(src)
if err != nil {
t.Fatalf("计算摘要失败: %v", err)
}
dsID, _, err := ingest.Import(ctx, pg, uid, filepath.Base(src), sha, size, backup)
if err != nil {
t.Fatalf("导入失败: %v", err)
}
// 导出取基线库时要按 sha 在 uploads 目录里找回原包,这里补一份
if _, err := os.Stat(cfg.UploadDir); errors.Is(err, os.ErrNotExist) {
os.MkdirAll(cfg.UploadDir, 0o755)
}
archived := filepath.Join(cfg.UploadDir, fmt.Sprintf("e2e-%d-%s", time.Now().Unix(), filepath.Base(src)))
if err := copyTo(archived, src); err != nil {
t.Fatalf("复制原始包到上传目录失败: %v", err)
}
t.Cleanup(func() { os.Remove(archived) })
srv, err := New(cfg, pg)
if err != nil {
t.Fatalf("初始化服务失败: %v", err)
}
return &movementFixture{cfg: cfg, pg: pg, srv: srv, dsID: dsID, src: src,
ctx: ctxWithUser(&auth.User{ID: uid, Username: user})}
}
// openSource 再开一次原始包,用来跟导出结果做对照。
func (f *movementFixture) openSource(t *testing.T) *sql.DB {
t.Helper()
db, err := rond.Open(f.src, t.TempDir())
if err != nil {
t.Fatalf("打开原始包失败: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
// export 走后台真实的导出处理函数,返回 .rondbackup 的字节。
func (f *movementFixture) export(t *testing.T) []byte {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/export/rondbackup", nil).WithContext(f.ctx)
f.srv.exportRondbackup(rec, req)
if rec.Code != 200 {
t.Fatalf("导出失败: code=%d body=%s", rec.Code, rec.Body.String())
}
return rec.Body.Bytes()
}
// TestMovementCRUDExportE2E 端到端验证「后台增改删行程 → 导出 rondbackup」这条链路。
//
// 真的导入、真的调用后台处理函数、真的导出,然后直接读导出的 SQLite 逐项核对:
// 新建/修改的位移有没有带上 rond 必需的列(ZTYPE_ 等)、派生字段是否落库、
// 删掉的是否真没了、以及没被碰过的数据是不是一字未动。
func TestMovementCRUDExportE2E(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
srv, base := f.srv, f.ctx
// 选一对合法的起止到访:起点有离开时间,终点到访在其之后
var fromVisit, toVisit int
if err := pg.QueryRowContext(ctx, `SELECT vf.src_pk, vt.src_pk
FROM visits vf JOIN visits vt ON vt.dataset_id=vf.dataset_id AND vt.arrival > vf.departure + interval '30 min'
WHERE vf.dataset_id=$1 AND vf.departure IS NOT NULL
ORDER BY vf.arrival DESC LIMIT 1`, dsID).Scan(&fromVisit, &toVisit); err != nil {
t.Fatalf("挑不出合法的起止到访: %v", err)
}
var wantStart, wantEnd time.Time
if err := pg.QueryRowContext(ctx, `SELECT vf.departure, vt.arrival FROM visits vf JOIN visits vt
ON vt.dataset_id=vf.dataset_id AND vt.src_pk=$3
WHERE vf.dataset_id=$1 AND vf.src_pk=$2`, dsID, fromVisit, toVisit).Scan(&wantStart, &wantEnd); err != nil {
t.Fatalf("读取两端到访时间失败: %v", err)
}
t.Logf("起止到访 #%d → #%d,合法区间 %v ~ %v", fromVisit, toVisit, wantStart.Local(), wantEnd.Local())
// 3) 编号复用防护:先删掉基线里编号最大的那条位移,再新建。
// 若按「库内 MAX(src_pk)+1」分配,这里会原样拿回 791——那个号在基线里已存在,
// 导出时新行会被当成旧行去 UPDATE,ZTYPE_ 之类就继承成别人的值。
var baselineMax int
if err := pg.QueryRowContext(ctx, `SELECT COALESCE(MAX(src_pk),0) FROM movements WHERE dataset_id=$1`,
dsID).Scan(&baselineMax); err != nil {
t.Fatal(err)
}
post(t, srv.editMovementDelete, base, "/admin/edit/movement/delete", url.Values{
"src_pk": {fmt.Sprint(baselineMax)},
})
post(t, srv.editMovementCreate, base, "/admin/edit/movement", url.Values{
"from_visit": {fmt.Sprint(fromVisit)},
"to_visit": {fmt.Sprint(toVisit)},
})
plainMove, _ := lastMovement(t, ctx, pg, dsID)
if plainMove <= baselineMax {
t.Errorf("删掉 #%d 后新建的行程拿到了 #%d:复用了基线里的编号", baselineMax, plainMove)
}
t.Logf("删掉基线里最大的位移 #%d 后新建得到 #%d", baselineMax, plainMove)
// 4) 新建:时间留空(应自动推断)、顺带新建一个交通方式
post(t, srv.editMovementCreate, base, "/admin/edit/movement", url.Values{
"from_visit": {fmt.Sprint(fromVisit)},
"to_visit": {fmt.Sprint(toVisit)},
"transport_src": {transportNewValue},
"transport_name": {"共享单车"},
"transport_color": {"green"},
"transport_icon": {"bicycle"},
})
newMove, newTrans := lastMovement(t, ctx, pg, dsID)
if newTrans <= 0 {
t.Fatal("新建交通方式没有分配编号")
}
t.Logf("新建行程 src_pk=%d,交通方式 #%d", newMove, newTrans)
// 5) 改:把新建那条的时间改成区间内的一小段,并把方式改名。
// 表单是分钟精度,断言也要按分钟对齐,否则差的就是被截掉的秒数
mid := wantStart.Add(wantEnd.Sub(wantStart) / 3).Truncate(time.Minute)
post(t, srv.editMovementUpdate, base, "/admin/edit/movement/update", url.Values{
"src_pk": {fmt.Sprint(newMove)},
"from_visit": {fmt.Sprint(fromVisit)},
"to_visit": {fmt.Sprint(toVisit)},
"start": {mid.Format("2006-01-02T15:04")},
"end": {mid.Add(10 * time.Minute).Format("2006-01-02T15:04")},
"transport_src": {fmt.Sprint(newTrans)},
"transport_name": {"单车"},
"transport_color": {"green"},
"transport_icon": {"bicycle"},
})
// 5b) 只改交通方式:时间必须原样不动。表单是分钟精度,回填后原样提交若被当成
// 「改了时间」写回,真机的小数秒就被抹掉了,而且截断后还早于起点到访的离开时间,
// 会被校验拒掉(线上就是「改方式提示开始时间不能早于离开时间」)。
var pickPK, pickTrans, otherTrans int
var pickFrom, pickTo int
var pickSt, pickEn time.Time
// 优先挑「把开始时间截到分钟就会早于起点到访的离开时间」的那类行——线上报的就是它
const pickCols = `SELECT m.src_pk, m.transport_src, m.started_at, m.ended_at,
m.from_visit_src, m.to_visit_src,
(SELECT min(transport_src) FROM movements WHERE dataset_id=m.dataset_id
AND transport_src IS NOT NULL AND transport_src <> m.transport_src)`
const pickWhere = ` FROM movements m
JOIN visits vf ON vf.dataset_id=m.dataset_id AND vf.src_pk=m.from_visit_src
WHERE m.dataset_id=$1 AND m.to_visit_src IS NOT NULL
AND m.transport_src IS NOT NULL AND m.started_at IS NOT NULL AND m.ended_at IS NOT NULL`
const pickTail = ` ORDER BY m.src_pk LIMIT 1`
err := pg.QueryRowContext(ctx, pickCols+pickWhere+` AND date_trunc('minute', m.started_at) < vf.departure`+pickTail, dsID).
Scan(&pickPK, &pickTrans, &pickSt, &pickEn, &pickFrom, &pickTo, &otherTrans)
if errors.Is(err, sql.ErrNoRows) {
err = pg.QueryRowContext(ctx, pickCols+pickWhere+pickTail, dsID).
Scan(&pickPK, &pickTrans, &pickSt, &pickEn, &pickFrom, &pickTo, &otherTrans)
}
if err != nil {
t.Fatalf("挑不出可改方式的已有行程: %v", err)
}
var otherName, otherColor string
pg.QueryRowContext(ctx, `SELECT COALESCE(max(transport_name),''), COALESCE(max(transport_color),'')
FROM movements WHERE dataset_id=$1 AND transport_src=$2`, dsID, otherTrans).Scan(&otherName, &otherColor)
post(t, srv.editMovementUpdate, base, "/admin/edit/movement/update", url.Values{
"src_pk": {fmt.Sprint(pickPK)},
"from_visit": {fmt.Sprint(pickFrom)},
"to_visit": {fmt.Sprint(pickTo)},
"start": {pickSt.Local().Format("2006-01-02T15:04")},
"end": {pickEn.Local().Format("2006-01-02T15:04")},
"transport_src": {fmt.Sprint(otherTrans)},
"transport_name": {otherName},
"transport_color": {otherColor},
})
var gotSt, gotEn time.Time
var gotTrans int
if err := pg.QueryRowContext(ctx, `SELECT started_at, ended_at, transport_src FROM movements
WHERE dataset_id=$1 AND src_pk=$2`, dsID, pickPK).Scan(&gotSt, &gotEn, &gotTrans); err != nil {
t.Fatal(err)
}
if !gotSt.Equal(pickSt) || !gotEn.Equal(pickEn) {
t.Errorf("只改方式却动了时间: %v~%v → %v~%v", pickSt, pickEn, gotSt, gotEn)
}
if gotTrans != otherTrans {
t.Errorf("交通方式没改成: 期望 %d,实际 %d", otherTrans, gotTrans)
}
t.Logf("只改方式 #%d:%d→%d,时间保持 %v ~ %v", pickPK, pickTrans, gotTrans, gotSt, gotEn)
// 5c) 坐标换算:真机里有一批点的显示坐标其实没做过 GCJ-02 偏移(留档里的原始
// GPS 与显示坐标一字不差)。把它们转成 GCJ-02 后,导出包里
// 「gcj(原始GPS) == 显示坐标」这条不变量必须重新成立,否则 rond 上还是偏的。
var susPK int
var susLat, susLon float64
var susName, susCat, susProv, susCity, susDist string
err = pg.QueryRowContext(ctx, `SELECT p.src_pk, p.lat, p.lon, COALESCE(p.name,''),
COALESCE(p.poi_category,''), COALESCE(p.province,''), COALESCE(p.city,''), COALESCE(p.district,'')
FROM places p`+placeRawJoin+` WHERE p.dataset_id=$1 AND `+suspectWGS+` ORDER BY p.src_pk LIMIT 1`,
dsID).Scan(&susPK, &susLat, &susLon, &susName, &susCat, &susProv, &susCity, &susDist)
if errors.Is(err, sql.ErrNoRows) {
t.Error("这份数据集里应当有「显示坐标其实是 WGS-84」的点,一个都没判出来")
} else if err != nil {
t.Fatalf("挑选待换算的地点失败: %v", err)
} else {
nlat, nlon := geo.WGS84ToGCJ02(susLat, susLon)
loc := post(t, srv.editPlaceUpdate, base, "/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(susPK)},
"name": {susName},
"category": {susCat},
"lat": {fmt.Sprintf("%.6f", nlat)},
"lon": {fmt.Sprintf("%.6f", nlon)},
"province": {susProv},
"city": {susCity},
"district": {susDist},
})
if !strings.Contains(loc, "err=") && !strings.Contains(loc, url.QueryEscape("坐标移动")) {
t.Errorf("保存后应提示坐标移动了多少,实际跳转: %s", loc)
}
var gotLat, gotLon float64
pg.QueryRowContext(ctx, `SELECT lat, lon FROM places WHERE dataset_id=$1 AND src_pk=$2`,
dsID, susPK).Scan(&gotLat, &gotLon)
if absFloat(gotLat-nlat) > 1e-6 || absFloat(gotLon-nlon) > 1e-6 {
t.Errorf("坐标没写进去: %.6f,%.6f(期望 %.6f,%.6f)", gotLat, gotLon, nlat, nlon)
}
var still int
pg.QueryRowContext(ctx, `SELECT count(*) FROM places p`+placeRawJoin+
` WHERE p.dataset_id=$1 AND p.src_pk=$2 AND `+suspectWGS, dsID, susPK).Scan(&still)
if still != 0 {
t.Error("换算过的点仍被判为「疑似 WGS-84」")
}
t.Logf("坐标换算 #%d %s:%.6f,%.6f → %.6f,%.6f", susPK, susName, susLat, susLon, nlat, nlon)
}
// 5d) 站点新建的地点(照片导入就是这一类,没有 rond 留档):换算后导出时要把
// 「换算前用户填的那个值」当成原始 GPS 写进 ZRAWLATITUDE/ZRAWLONGITUDE。
// 新行的 ZRAW 是导出时按显示坐标反算的,所以换算一次正好把它还原成真值——
// rond 里位置对了,原始 GPS 也留着。
const siteLat, siteLon = 22.853216, 113.253067
post(t, srv.editPlaceCreate, base, "/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"e2e-坐标换算"}, "lat": {fmt.Sprint(siteLat)}, "lon": {fmt.Sprint(siteLon)},
})
var sitePK int
if err := pg.QueryRowContext(ctx, `SELECT src_pk FROM places WHERE dataset_id=$1 ORDER BY id DESC LIMIT 1`,
dsID).Scan(&sitePK); err != nil {
t.Fatalf("读取新建地点失败: %v", err)
}
siteConvLat, siteConvLon := geo.WGS84ToGCJ02(siteLat, siteLon)
post(t, srv.editPlaceUpdate, base, "/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(sitePK)},
"name": {"e2e-坐标换算"},
"lat": {fmt.Sprintf("%.6f", siteConvLat)},
"lon": {fmt.Sprintf("%.6f", siteConvLon)},
})
t.Logf("站点新建地点 #%d 换算:%.6f,%.6f → %.6f,%.6f", sitePK, siteLat, siteLon, siteConvLat, siteConvLon)
// 6) 派生字段必须落库:网页地图靠 from/to_place_id 画线,访客侧的区域过滤也按它们判定,
// 留空的话这段行程会绕开黑名单区域直接露出来
var dist sql.NullFloat64
var fromID, toID, dur sql.NullInt64
if err := pg.QueryRowContext(ctx, `SELECT from_place_id, to_place_id, distance_km,
COALESCE(duration_min,0) FROM movements WHERE dataset_id=$1 AND src_pk=$2`,
dsID, newMove).Scan(&fromID, &toID, &dist, &dur); err != nil {
t.Fatalf("读取新建行程失败: %v", err)
}
if !dur.Valid || dur.Int64 != 10 {
t.Errorf("改后的时长应为 10 分钟,实际 %v", dur)
}
if !fromID.Valid || !toID.Valid {
t.Error("新建行程的 from_place_id / to_place_id 为空:地图轨迹与区域过滤都会失灵")
}
if !dist.Valid || dist.Float64 <= 0 {
t.Errorf("新建行程的里程没有算出来: %v", dist)
}
// 7) 编辑页要能渲染出来:行程列表的 SQL、交通方式聚合、模板执行都在这条路径上,
// 光测处理函数看不见它们
for _, q := range []string{"/admin/edit",
fmt.Sprintf("/admin/edit?mt=%d", newTrans),
fmt.Sprintf("/admin/edit?mp=%d", fromID.Int64),
"/admin/edit?mfrom=2026-09-01&mto=2026-09-30",
"/admin/edit?mt=none",
} {
rec := httptest.NewRecorder()
srv.editPage(rec, httptest.NewRequest("GET", q, nil).WithContext(base))
if rec.Code != 200 {
t.Fatalf("渲染 %s 失败: code=%d", q, rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"行程记录", "新增行程", "新建方式…"} {
if !strings.Contains(body, want) {
t.Errorf("%s 的页面里没有 %q", q, want)
}
}
}
// 8) 导出
blob := f.export(t)
out := filepath.Join(t.TempDir(), "export.rondbackup")
if err := os.WriteFile(out, blob, 0o644); err != nil {
t.Fatal(err)
}
if p := os.Getenv("ROND_EXPORT_OUT"); p != "" {
os.WriteFile(p, blob, 0o644)
t.Logf("导出包已保存到 %s", p)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
defer edb.Close()
// 9) 核对导出包
var mvType sql.NullInt64
var mvTrans sql.NullInt64
var mvStart, mvEnd sql.NullFloat64
var mvFrom, mvTo sql.NullInt64
if err := edb.QueryRow(`SELECT ZTYPE_, ZTRANSPORT_, ZSTART_, ZEND_, ZVISITFROM_, ZVISITTO_
FROM ZMOVEMENT WHERE Z_PK=?`, newMove).Scan(&mvType, &mvTrans, &mvStart, &mvEnd, &mvFrom, &mvTo); err != nil {
t.Fatalf("导出包里找不到新建的行程 #%d: %v", newMove, err)
}
if !mvType.Valid {
t.Error("新建行程的 ZTYPE_ 为空:真机 791/791 都有值,Core Data 里它是非可选属性")
} else if mvType.Int64 != 5 {
t.Errorf("有交通方式的新建行程 ZTYPE_ 应为 5,实际 %d", mvType.Int64)
}
if !mvTrans.Valid || int(mvTrans.Int64) != newTrans {
t.Errorf("新建行程的 ZTRANSPORT_ 应为 %d,实际 %v", newTrans, mvTrans)
}
if int(mvFrom.Int64) != fromVisit || int(mvTo.Int64) != toVisit {
t.Errorf("两端到访写回不对: %d/%d,应为 %d/%d", mvFrom.Int64, mvTo.Int64, fromVisit, toVisit)
}
if d := absFloat(mvStart.Float64 - appleSec(mid)); d > 1 {
t.Errorf("开始时间偏差 %.3f 秒(应为改后的 %v)", d, mid.Local())
}
if d := absFloat(mvEnd.Float64 - appleSec(mid.Add(10*time.Minute))); d > 1 {
t.Errorf("结束时间偏差 %.3f 秒", d)
}
var n int
edb.QueryRow(`SELECT count(*) FROM ZMOVEMENT WHERE Z_PK=?`, baselineMax).Scan(&n)
if n != 0 {
t.Errorf("删掉的位移 #%d 仍在导出包里", baselineMax)
}
// 无交通方式的新建行程 → ZTYPE_ 应为 2;这条同时验证了编号没有复用
// (复用了就会继承基线里那条记录的取值)
var plainType sql.NullInt64
if err := edb.QueryRow(`SELECT ZTYPE_ FROM ZMOVEMENT WHERE Z_PK=?`, plainMove).Scan(&plainType); err != nil {
t.Fatalf("导出包里找不到 #%d: %v", plainMove, err)
}
if !plainType.Valid || plainType.Int64 != 2 {
t.Errorf("无交通方式的新建行程 ZTYPE_ 应为 2,实际 %v", plainType)
}
// 交通方式:改名要作用到该方式的全部行程,导出后 ZTRANSPORT 只有一行这个名字
var tName, tColor string
if err := edb.QueryRow(`SELECT COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTRANSPORT WHERE Z_PK=?`,
newTrans).Scan(&tName, &tColor); err != nil {
t.Fatalf("导出包里找不到新建的交通方式 #%d: %v", newTrans, err)
}
if tName != "单车" || tColor != "green" {
t.Errorf("交通方式应为「单车/green」,实际「%s/%s」", tName, tColor)
}
// 原有交通方式一个都不能少(删掉某方式的最后一条行程 ≠ 删除这个方式)
var transCount, unnamed int
edb.QueryRow(`SELECT count(*), COALESCE(sum(CASE WHEN COALESCE(ZNAME_,'')='' THEN 1 ELSE 0 END),0)
FROM ZTRANSPORT`).Scan(&transCount, &unnamed)
if transCount != 6 {
t.Errorf("ZTRANSPORT 应有 6 个(原 5 个 + 新建 1 个),实际 %d", transCount)
}
if unnamed != 0 {
t.Errorf("有 %d 个交通方式没有名字", unnamed)
}
var dup int
edb.QueryRow(`SELECT count(*) FROM (SELECT Z_PK FROM ZTRANSPORT GROUP BY Z_PK HAVING count(*)>1) x`).Scan(&dup)
if dup != 0 {
t.Error("ZTRANSPORT 出现重复主键")
}
// 全表 ZTYPE_ 非空
var total, missing int
edb.QueryRow(`SELECT count(*), COALESCE(sum(CASE WHEN ZTYPE_ IS NULL THEN 1 ELSE 0 END),0) FROM ZMOVEMENT`).
Scan(&total, &missing)
if missing > 0 {
t.Errorf("ZMOVEMENT 共 %d 条,其中 %d 条 ZTYPE_ 为空", total, missing)
}
// 只改方式的那条,导出后的 ZSTART_/ZEND_ 必须与原始包逐位一致(没被分钟精度截断)
srcDB := f.openSource(t)
var origSt, origEn float64
if err := srcDB.QueryRow(`SELECT ZSTART_, ZEND_ FROM ZMOVEMENT WHERE Z_PK=?`, pickPK).Scan(&origSt, &origEn); err != nil {
t.Fatalf("原始包里找不到 #%d: %v", pickPK, err)
}
var expSt, expEn sql.NullFloat64
if err := edb.QueryRow(`SELECT ZSTART_, ZEND_ FROM ZMOVEMENT WHERE Z_PK=?`, pickPK).Scan(&expSt, &expEn); err != nil {
t.Fatalf("导出包里找不到 #%d: %v", pickPK, err)
}
if d := absFloat(expSt.Float64 - origSt); d > 1e-6 {
t.Errorf("只改方式却把 ZSTART_ 改了 %.6f 秒(%.6f → %.6f)", d, origSt, expSt.Float64)
}
if d := absFloat(expEn.Float64 - origEn); d > 1e-6 {
t.Errorf("只改方式却把 ZEND_ 改了 %.6f 秒", d)
}
// 换算过的那个点:导出包里 gcj(原始GPS) 必须等于显示坐标(rond 的坐标模型),
// 且原始 GPS 本身不能被动过——它才是这条记录真正的 WGS-84。
if susPK > 0 {
var rawLat, rawLon, disLat, disLon float64
if err := edb.QueryRow(`SELECT ZRAWLATITUDE, ZRAWLONGITUDE, ZLATITUDE, ZLONGITUDE
FROM ZLOCATION WHERE Z_PK=?`, susPK).Scan(&rawLat, &rawLon, &disLat, &disLon); err != nil {
t.Fatalf("导出包里找不到换算过的地点 #%d: %v", susPK, err)
}
expLat, expLon := geo.WGS84ToGCJ02(rawLat, rawLon)
if absFloat(expLat-disLat) > 1e-6 || absFloat(expLon-disLon) > 1e-6 {
t.Errorf("导出包里 gcj(原始GPS) 与显示坐标对不上:gcj(%.6f,%.6f)=%.6f,%.6f vs %.6f,%.6f",
rawLat, rawLon, expLat, expLon, disLat, disLon)
}
if absFloat(rawLat-susLat) > 1e-9 {
t.Errorf("原始 GPS 被改动了: %.6f → %.6f", susLat, rawLat)
}
t.Logf("导出核对:%s 的 gcj(原始GPS)=%.6f,%.6f == 显示坐标 ✓", susName, expLat, expLon)
}
// 站点新建的那个地点:显示坐标是换算后的 GCJ-02,原始 GPS 应写回换算前用户填的值
{
var zrawLat, zrawLon, zdisLat, zdisLon float64
if err := edb.QueryRow(`SELECT ZRAWLATITUDE, ZRAWLONGITUDE, ZLATITUDE, ZLONGITUDE FROM ZLOCATION
WHERE Z_PK=?`, sitePK).Scan(&zrawLat, &zrawLon, &zdisLat, &zdisLon); err != nil {
t.Fatalf("导出包里找不到新建地点 #%d: %v", sitePK, err)
}
if absFloat(zdisLat-siteConvLat) > 1e-6 || absFloat(zdisLon-siteConvLon) > 1e-6 {
t.Errorf("新建地点的显示坐标应是换算后的值: %.6f,%.6f", zdisLat, zdisLon)
}
if absFloat(zrawLat-siteLat) > 1e-6 || absFloat(zrawLon-siteLon) > 1e-6 {
t.Errorf("新建地点的原始 GPS 应写回换算前输入的值 %.6f,%.6f,实际 %.6f,%.6f",
siteLat, siteLon, zrawLat, zrawLon)
}
if gLat, gLon := geo.WGS84ToGCJ02(zrawLat, zrawLon); absFloat(gLat-zdisLat) > 1e-6 || absFloat(gLon-zdisLon) > 1e-6 {
t.Errorf("gcj(原始GPS) 与显示坐标对不上: %.6f,%.6f", gLat, gLon)
}
t.Logf("新建地点导出核对:原始 GPS %.6f,%.6f / 显示坐标 %.6f,%.6f ✓", zrawLat, zrawLon, zdisLat, zdisLon)
}
// 未落库的实体必须原样带回来
var raw, rawAfter int
srcDB.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&raw)
edb.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&rawAfter)
if raw != rawAfter {
t.Errorf("ZRAWVISIT 行数变了: %d → %d", raw, rawAfter)
}
t.Logf("导出核对通过:位移 %d 条、交通方式 %d 个、ZRAWVISIT %d 条", total, transCount, rawAfter)
}
// TestMovementCleanExport 只做「导入后原样导出」,把包写到 ROND_CLEAN_OUT。
// 它是上面那个用例的对照组:一个没被改过的数据集导出来必须是纯净往返,
// 交给 tools/verify_rondbackup.py 逐字段比对,用来证明这轮改动没有波及别处。
func TestMovementCleanExport(t *testing.T) {
out := os.Getenv("ROND_CLEAN_OUT")
if out == "" {
t.Skip("设置 ROND_CLEAN_OUT 后导出一份纯净往返包")
}
f := newMovementFixture(t)
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
t.Fatal(err)
}
t.Logf("纯净往返包已保存到 %s", out)
}
// dropE2EUser 删掉测试账号:数据集表上的外键会级联清掉各事实表与整行留档,
// 留在磁盘上的基线库文件要自己删。
func dropE2EUser(t *testing.T, ctx context.Context, pg *sql.DB, cfg *config.Config, uid int64) {
t.Helper()
var ids []int64
rows, err := pg.QueryContext(ctx, `SELECT id FROM datasets WHERE user_id=$1`, uid)
if err != nil {
t.Logf("清理测试账号失败: %v", err)
return
}
for rows.Next() {
var id int64
rows.Scan(&id)
ids = append(ids, id)
}
rows.Close()
if _, err := pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid); err != nil {
t.Logf("清理测试账号失败: %v", err)
}
for _, id := range ids {
os.Remove(filepath.Join(cfg.DataDir, "baselines", fmt.Sprintf("%d.sqlite", id)))
}
}
// purgeE2EUsers 清掉上次跑到一半留下的测试账号(它的 username 前缀是本测试专用的)。
func purgeE2EUsers(t *testing.T, ctx context.Context, pg *sql.DB, cfg *config.Config) {
t.Helper()
var ids []int64
rows, err := pg.QueryContext(ctx, `SELECT id FROM users WHERE username LIKE 'e2e-movement-%'`)
if err != nil {
return
}
for rows.Next() {
var id int64
rows.Scan(&id)
ids = append(ids, id)
}
rows.Close()
for _, id := range ids {
t.Logf("清掉上次遗留的测试账号 #%d", id)
dropE2EUser(t, ctx, pg, cfg, id)
}
}
// chdirModuleRoot 把工作目录切到仓库根(含 go.mod 的那一层)。
// 调试模式下模板是从磁盘按「internal/web/templates/...」读的,配置里的
// storage 路径也是相对仓库根,只有站在仓库根上才和真跑起来时一致。
func chdirModuleRoot(t *testing.T) {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
return
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("找不到 go.mod,无法定位仓库根目录")
}
dir = parent
}
}
// post 直接调用后台处理函数,返回重定向地址(带 ok=/err= 提示)。
// 绕过 requireAdmin 是刻意的:这里要测的是处理逻辑本身,登录态由 ctxWithUser 直接注入。
func post(t *testing.T, h func(http.ResponseWriter, *http.Request), ctx context.Context, path string, form url.Values) string {
t.Helper()
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(ctx)
w := httptest.NewRecorder()
h(w, r)
// 处理函数出错时会 500;正常路径是 303 重定向 + ?err= 提示
if w.Code >= 500 {
t.Fatalf("%s 处理失败: code=%d", path, w.Code)
}
loc := w.Header().Get("Location")
if strings.Contains(loc, "err=") {
t.Fatalf("%s 被拒绝: %s", path, loc)
}
return loc
}
func ctxWithUser(u *auth.User) context.Context {
return context.WithValue(context.Background(), userKey{}, u)
}
func lastMovement(t *testing.T, ctx context.Context, pg *sql.DB, datasetID int64) (int, int) {
t.Helper()
var pk int
var tr sql.NullInt64
if err := pg.QueryRowContext(ctx, `SELECT src_pk, transport_src FROM movements
WHERE dataset_id=$1 ORDER BY id DESC LIMIT 1`, datasetID).Scan(&pk, &tr); err != nil {
t.Fatalf("读取刚写入的行程失败: %v", err)
}
return pk, int(tr.Int64)
}
func copyTo(dst, src string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
func absFloat(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func appleSec(t time.Time) float64 {
return float64(t.Unix()-978307200) + float64(t.Nanosecond())/1e9
}
+122
View File
@@ -0,0 +1,122 @@
package web
import (
"strings"
"testing"
"time"
)
func mt(s string) time.Time {
t, err := time.ParseInLocation("2006-01-02 15:04", s, time.Local)
if err != nil {
panic(err)
}
return t
}
// TestMovementWindow 守住行程时间区间的推导与校验。
// 规则来自真机包实测(760 条起点 / 756 条终点全部满足):位移不早于起点到访的
// 离开时间,也不晚于终点到访的到达时间;ZSTART_/ZEND_ 在真机里 791/791 都有值,
// 所以留空必须能推断出值,不能写 NULL。
func TestMovementWindow(t *testing.T) {
dep := mt("2026-09-16 10:00")
from := movementEnds{SrcPK: 1, Arrive: mt("2026-09-16 09:00"), Depart: &dep}
to := movementEnds{SrcPK: 2, Arrive: mt("2026-09-16 11:00")}
in := func(s, e string) movementWindowInput {
return movementWindowInput{From: from, To: to, StartRaw: s, EndRaw: e}
}
// 留空:按两端补默认值,即「起点离开 → 终点到达」
start, end, msg := movementWindow(in("", ""))
if msg != "" {
t.Fatalf("留空应能推断,得到错误: %s", msg)
}
if !start.Equal(dep) || !end.Equal(to.Arrive) {
t.Fatalf("推断结果不对: %v ~ %v", start, end)
}
// 起点没有离开时间时退回到达时间
fromNoDep := movementEnds{SrcPK: 1, Arrive: mt("2026-09-16 09:00")}
if start, _, msg = movementWindow(movementWindowInput{From: fromNoDep, To: to}); msg != "" || !start.Equal(fromNoDep.Arrive) {
t.Fatalf("缺离开时间应以到达时间为起点: %v %s", start, msg)
}
// 合法的手填区间
if _, _, msg = movementWindow(in("2026-09-16T10:10", "2026-09-16T10:50")); msg != "" {
t.Fatalf("区间合法却被拒: %s", msg)
}
for _, c := range []struct {
name string
start, end string
wantMsgSubstring string
}{
{"结束早于开始", "2026-09-16T10:50", "2026-09-16T10:10", "必须晚于开始时间"},
{"开始早于起点离开", "2026-09-16T09:30", "2026-09-16T10:50", "不能早于起点到访的离开时间"},
{"结束晚于终点到达", "2026-09-16T10:10", "2026-09-16T11:30", "不能晚于终点到访的到达时间"},
{"只填开始且越界", "2026-09-16T09:00", "", "不能早于起点到访的离开时间"},
{"时间格式错", "2026/09/16 10:00", "", "格式不正确"},
} {
_, _, msg := movementWindow(in(c.start, c.end))
if msg == "" {
t.Errorf("%s:应被拒绝但通过了", c.name)
continue
}
if !strings.Contains(msg, c.wantMsgSubstring) {
t.Errorf("%s:错误信息里应包含 %q,实际为 %q", c.name, c.wantMsgSubstring, msg)
}
}
}
// TestKeepStoredTime 守住分钟精度的表单不该磨掉库里的小数秒(到访与行程共用)。
func TestKeepStoredTime(t *testing.T) {
stored := mt("2026-09-13 20:03").Add(21*time.Second + 323*time.Millisecond)
typed := mt("2026-09-13 20:03") // 表单回填/手填都只到分钟
if got := keepStoredTime(typed, &stored); !got.Equal(stored) {
t.Errorf("同一分钟应沿用库里的值: %v", got)
}
// 真改了分钟就按提交值走
other := mt("2026-09-13 20:10")
if got := keepStoredTime(other, &stored); !got.Equal(other) {
t.Errorf("改了分钟应按提交值生效: %v", got)
}
// 没有原值(新建)时原样返回
if got := keepStoredTime(typed, nil); !got.Equal(typed) {
t.Errorf("无原值时应原样返回: %v", got)
}
}
// TestMovementWindowKeepsStoredSeconds 守住「只改方式不该动时间」。
// 表单是分钟精度,回填后原样提交会被解析成不带秒的整分钟;与库里同分钟就必须
// 沿用库里原值,否则每次编辑都把真机的小数秒截掉,而且截断后早于起点到访的
// 离开时间,会被校验拒掉——「改方式提示开始时间不能早于离开时间」就是这么来的。
func TestMovementWindowKeepsStoredSeconds(t *testing.T) {
dep := mt("2026-09-13 20:03").Add(21*time.Second + 323*time.Millisecond)
from := movementEnds{SrcPK: 1, Arrive: mt("2026-09-13 19:00"), Depart: &dep}
to := movementEnds{SrcPK: 2, Arrive: mt("2026-09-13 20:31").Add(9 * time.Second)}
curStart := mt("2026-09-13 20:03").Add(21*time.Second + 323*time.Millisecond)
curEnd := mt("2026-09-13 20:31").Add(9 * time.Second)
in := movementWindowInput{From: from, To: to, CurStart: &curStart, CurEnd: &curEnd}
// 表单回填的就是截到分钟的值
in.StartRaw, in.EndRaw = "2026-09-13T20:03", "2026-09-13T20:31"
start, end, msg := movementWindow(in)
if msg != "" {
t.Fatalf("只改方式的时间提交应被接受,实际: %s", msg)
}
if !start.Equal(curStart) || !end.Equal(curEnd) {
t.Fatalf("时间被截断了: %v ~ %v,应为 %v ~ %v", start, end, curStart, curEnd)
}
// 真的改了分钟才按提交值走
in.StartRaw, in.EndRaw = "2026-09-13T20:10", "2026-09-13T20:31"
if start, _, msg = movementWindow(in); msg != "" || start.Equal(curStart) {
t.Fatalf("改了分钟应按提交值生效: %v %s", start, msg)
}
// 手填到同一分钟之外的越界值仍要拒绝
in.StartRaw, in.EndRaw = "2026-09-13T19:30", "2026-09-13T20:31"
if _, _, msg = movementWindow(in); !strings.Contains(msg, "不能早于起点到访的离开时间") {
t.Fatalf("越界的手填值应被拒,实际: %s", msg)
}
}
+49
View File
@@ -0,0 +1,49 @@
package web
import (
"fmt"
"html"
"io"
"net/http"
)
// pausePage 渲染一个「停一下」的极简提示页:给一句人话、一个回首页的按钮,
// 需要时再自动跳回首页。用于「内容对访客不开放」这类情况——
// 直接甩 404 会让人以为站点坏了,说清楚再引导走开才不突兀。
//
// autoSeconds > 0 时页面会自己跳回首页(同时把按钮留给人手动点)。
func (s *Server) pausePage(w http.ResponseWriter, status int, icon, title, msg string, autoSeconds int) {
var auto string
if autoSeconds > 0 {
auto = fmt.Sprintf(`<meta http-equiv="refresh" content="%d;url=/">`, autoSeconds)
}
if icon == "" {
icon = "🔒"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
fmt.Fprintf(w, `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">`+
`<meta name="viewport" content="width=device-width,initial-scale=1">`+
`<title>%s</title>%s`+
`<link rel="stylesheet" href="/static/css/app.css?v=%s"></head>`+
`<body class="notice-body"><div class="notice-card">`+
`<div class="notice-ico">%s</div><h1>%s</h1><p>%s</p>`+
`<a class="btn primary" href="/">返回首页</a>`,
html.EscapeString(title), auto, assetVersion, icon,
html.EscapeString(title), html.EscapeString(msg))
if autoSeconds > 0 {
fmt.Fprintf(w, `<div class="notice-tip">%d 秒后自动回到首页</div>`, autoSeconds)
}
io.WriteString(w, `</div></body></html>`)
}
// placeHiddenPage 是「这个地点没对访客开放」时的回应。
//
// 注意取舍:以前这里直接 404,好处是访客分不清「被藏起来了」和「根本不存在」。
// 现在改成明确告知,更好懂,代价是能反推出某个 ID 确实存在。个人站点这个取舍划算,
// 想换回原样只需要把这一处改回 http.NotFound。
func (s *Server) placeHiddenPage(w http.ResponseWriter) {
s.pausePage(w, http.StatusForbidden, "🔒", "这个地点没有对访客开放",
"站长按区域设置隐藏了这个地点的记录。带你去别处看看。", 4)
}
+66
View File
@@ -0,0 +1,66 @@
package web
import (
"context"
"log"
"sync"
"time"
)
// parallelLimit 是页面内并行查询的并发上限。
// 站点常跑在 1C1G 的小机器上,并发太高会把 PostgreSQL 和 CPU 一起挤住,
// 反而比串行更慢(同一份磁盘在排队)。
const parallelLimit = 6
// slowJobLog 是打慢查询日志的阈值(debug 模式下)。
const slowJobLog = 30 * time.Millisecond
// pageJob 是一次页面内的独立查询。name 只用于日志,方便定位是哪个查询慢。
type pageJob struct {
name string
run func(context.Context) error
}
// runJobs 并发执行互不依赖的查询,返回第一个(按传入顺序)非 nil 错误。
//
// 统计页这类页面有十几个互不依赖的查询,串行执行时页面耗时≈它们之和(实测 ~400ms),
// 并发之后≈最慢的那一个。**任务之间不能写同一份数据**:这里只保证并发安全,
// 不管业务上的写入冲突。
func (s *Server) runJobs(ctx context.Context, jobs ...pageJob) error {
if len(jobs) <= 1 {
for _, j := range jobs {
if err := j.run(ctx); err != nil {
return err
}
}
return nil
}
sem := make(chan struct{}, parallelLimit)
errs := make([]error, len(jobs))
var wg sync.WaitGroup
for i := range jobs {
wg.Add(1)
go func(i int) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
errs[i] = ctx.Err()
return
}
defer func() { <-sem }()
start := time.Now()
errs[i] = jobs[i].run(ctx)
if d := time.Since(start); d > slowJobLog && s.cfg.Debug {
log.Printf("慢查询 %s: %v", jobs[i].name, d.Round(time.Millisecond))
}
}(i)
}
wg.Wait()
for _, err := range errs {
if err != nil {
return err
}
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package web
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"rond-with-you/internal/config"
)
func testServer() *Server { return &Server{cfg: &config.Config{}} }
func TestRunJobsRunsConcurrently(t *testing.T) {
s := testServer()
const n = 8
const each = 60 * time.Millisecond
var done int32
jobs := make([]pageJob, 0, n)
for i := 0; i < n; i++ {
jobs = append(jobs, pageJob{"job", func(context.Context) error {
time.Sleep(each)
atomic.AddInt32(&done, 1)
return nil
}})
}
start := time.Now()
if err := s.runJobs(context.Background(), jobs...); err != nil {
t.Fatal(err)
}
elapsed := time.Since(start)
if int(done) != n {
t.Fatalf("只完成了 %d 个任务", done)
}
// 并发执行:总耗时应接近单个任务,而不是 n 倍。给足余量避免机器慢时抖动。
if elapsed > each*n/2 {
t.Errorf("8 个 %v 的任务耗时 %v,看起来没并发(串行约 %v)", each, elapsed, each*n)
}
}
func TestRunJobsReturnsFirstErrorInOrder(t *testing.T) {
s := testServer()
errA, errB := errors.New("A 错"), errors.New("B 错")
// 故意让靠后的任务先失败,返回值仍应是传入顺序里的第一个错误
err := s.runJobs(context.Background(),
pageJob{"slow-fail", func(context.Context) error {
time.Sleep(50 * time.Millisecond)
return errA
}},
pageJob{"fast-fail", func(context.Context) error { return errB }},
pageJob{"ok", func(context.Context) error { return nil }},
)
if !errors.Is(err, errA) {
t.Errorf("返回 %v,期望传入顺序里的第一个错误 %v", err, errA)
}
}
func TestRunJobsSingleJobRunsInline(t *testing.T) {
s := testServer()
ran := false
if err := s.runJobs(context.Background(), pageJob{"only", func(context.Context) error {
ran = true
return nil
}}); err != nil {
t.Fatal(err)
}
if !ran {
t.Error("单个任务也应执行")
}
}
func TestRunJobsStopsOnCancelledContext(t *testing.T) {
s := testServer()
ctx, cancel := context.WithCancel(context.Background())
cancel()
var ran int32
jobs := make([]pageJob, 0, 4)
for i := 0; i < 4; i++ {
jobs = append(jobs, pageJob{"job", func(ctx context.Context) error {
// 真实查询在 ctx 取消后就是这样返回的
select {
case <-ctx.Done():
return ctx.Err()
default:
}
atomic.AddInt32(&ran, 1)
return nil
}})
}
if err := s.runJobs(ctx, jobs...); !errors.Is(err, context.Canceled) {
t.Errorf("ctx 已取消时应返回 context.Canceled,实际 %v", err)
}
if n := atomic.LoadInt32(&ran); n != 0 {
t.Errorf("ctx 一开始就已取消,不该有任务真正执行(实际 %d 个)", n)
}
}
+46
View File
@@ -0,0 +1,46 @@
package web
import (
"net/http"
"net/url"
"strings"
"testing"
)
func visitReq(form string) *http.Request {
vals := url.Values{}
for _, kv := range strings.Split(form, "&") {
p := strings.SplitN(kv, "=", 2)
vals.Set(p[0], p[1])
}
r, _ := http.NewRequest("POST", "/admin/edit/visit", strings.NewReader(vals.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
func TestParseVisitTimes(t *testing.T) {
// 只记到达:离开留空应该合法
arr, dep, ok, msg := parseVisitTimes(visitReq("arrival=2026-09-16T10:00&departure="))
if !ok {
t.Fatalf("只记到达应合法,得到: %s", msg)
}
if dep != nil {
t.Fatalf("离开应为 nil,得到 %v", dep)
}
if arr.IsZero() {
t.Fatal("到达时间不应为零值")
}
// 两个时间都有
_, dep, ok, _ = parseVisitTimes(visitReq("arrival=2026-09-16T10:00&departure=2026-09-16T11:30"))
if !ok || dep == nil {
t.Fatalf("双时间应合法且 departure 非空, ok=%v dep=%v", ok, dep)
}
// 缺到达
if _, _, ok, _ = parseVisitTimes(visitReq("arrival=&departure=2026-09-16T11:30")); ok {
t.Fatal("缺到达应不合法")
}
// 离开早于到达
if _, _, ok, _ = parseVisitTimes(visitReq("arrival=2026-09-16T10:00&departure=2026-09-16T09:00")); ok {
t.Fatal("离开早于到达应不合法")
}
}
+440
View File
@@ -0,0 +1,440 @@
package web
import (
"database/sql"
"fmt"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"rond-with-you/internal/geo"
"rond-with-you/internal/ingest"
"rond-with-you/internal/rond"
)
// TestPlaceCoordSource 盯住「坐标来源留档」这条链路:建点时就记下来、编辑时不被误改、
// 换算时跟着坐标一起修正。
//
// 为什么要这么细:2026-09 那次照片导入把 WGS-84 的 EXIF 当成 GCJ-02 存了进去,
// 63 个点整体偏 500~670m,而库里没有任何记录能说明「当初选的是什么坐标系」,
// 最后只能逐张翻原图 EXIF 才裁出来。留档就是为了让这种事下一次能一眼看出。
//
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run CoordSource -v
func TestPlaceCoordSource(t *testing.T) {
f := newMovementFixture(t)
// ---- 1. rond 导入:留档必须自动补上,且原始坐标就是留档里的 ZRAWLATITUDE ----
var n, bad int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*),
count(*) FILTER (WHERE coord_source<>'rond' OR coord_sys<>'wgs84'
OR src_lat IS DISTINCT FROM (SELECT (r.raw->>'ZRAWLATITUDE')::float8 FROM entity_raw r
WHERE r.dataset_id=places.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=places.src_pk))
FROM places WHERE dataset_id=$1`, f.dsID).Scan(&n, &bad); err != nil {
t.Fatalf("统计留档失败: %v", err)
}
if n == 0 {
t.Fatal("数据集里没有地点,导入没成功")
}
if bad != 0 {
t.Fatalf("%d/%d 个地点没补上 rond 留档(或原始坐标与留档对不上)", bad, n)
}
// 挑一个「留档里的原始坐标 != 显示坐标」的点,也就是 rond 正常做过偏移的那种
var pk int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND src_lat IS NOT NULL AND abs(src_lat-lat)>1e-9
ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&pk); err != nil {
t.Fatalf("找不到做过偏移的点: %v", err)
}
type snapshot struct {
lat, lon float64
coordSource, coordSys string
srcLat, srcLon float64
hasSrc bool
name, province, street string
}
load := func() snapshot {
t.Helper()
var s snapshot
var sl, so *float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT lat, lon, COALESCE(coord_source,''), COALESCE(coord_sys,''),
src_lat, src_lon, COALESCE(name,''), COALESCE(province,''), COALESCE(thoroughfare,'')
FROM places WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).
Scan(&s.lat, &s.lon, &s.coordSource, &s.coordSys, &sl, &so, &s.name, &s.province, &s.street); err != nil {
t.Fatalf("读地点失败: %v", err)
}
if sl != nil && so != nil {
s.srcLat, s.srcLon, s.hasSrc = *sl, *so, true
}
return s
}
before := load()
if !before.hasSrc {
t.Fatal("rond 导入的点应当留下原始坐标")
}
// ---- 2. 只改名称:留档一个字都不能动,街道也不能被没提交的字段清掉 ----
post := func(path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
switch path {
case "/admin/edit/place/update":
f.srv.editPlaceUpdate(rec, req)
case "/admin/edit/place":
f.srv.editPlaceCreate(rec, req)
case "/admin/photos/import":
f.srv.adminPhotosImport(rec, req)
default:
t.Fatalf("未接入的处理函数: %s", path)
}
return rec
}
// 先给这一行塞一个街道值,验「表单没提交就不该被清空」
f.mustExec(t, `UPDATE places SET thoroughfare='测试街道' WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk)
// 批量换算工具就是这么提交的:只有坐标与地址,没有留档字段
post("/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {before.name}, "category": {""},
"lat": {fmt.Sprintf("%.6f", before.lat)}, "lon": {fmt.Sprintf("%.6f", before.lon)},
"province": {before.province}, "city": {""}, "district": {""},
})
after := load()
if after.coordSource != before.coordSource || after.coordSys != before.coordSys ||
after.srcLat != before.srcLat || after.srcLon != before.srcLon {
t.Fatalf("只改名称却动了留档:%+v -> %+v", before, after)
}
if after.street != "测试街道" {
t.Fatalf("表单没提交街道,却被清成了 %q", after.street)
}
// ---- 3. 走了「坐标系换算」:留档要记成来源坐标系 + 换算前的坐标 ----
// 复刻页面的真实行为:JavaScript 先调 /api/coords 把坐标换算好再提交,
// 同时把「换算前的值 + 它属于哪个坐标系」一起带上(见 edit.html 的 coord-sel)。
// 这里反过来做:把这条 rond 点的显示坐标(GCJ)当 WGS 提交,看留档跟不跟着走。
// 隐藏字段是原值全精度带出来的(不是 %.6f),所以这里也用全精度提交,
// 免得把留档里那份原始坐标截成 6 位。
rawLat, rawLon := before.srcLat, before.srcLon
subLat, subLon := geo.WGS84ToGCJ02(rawLat, rawLon)
post("/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {before.name},
"lat": {fmt.Sprintf("%.6f", subLat)}, "lon": {fmt.Sprintf("%.6f", subLon)},
"province": {""}, "city": {""}, "district": {""},
"coord_source": {"manual"}, "coord_sys": {"wgs84"},
"src_lat": {fmt.Sprintf("%v", rawLat)}, "src_lon": {fmt.Sprintf("%v", rawLon)},
})
after = load()
if after.coordSys != "wgs84" || after.coordSource != "manual" {
t.Fatalf("留档没跟着换算走:%+v", after)
}
if after.srcLat != rawLat || after.srcLon != rawLon {
t.Fatalf("原始坐标没按表单给的记:想要 %v,%v 实际 %v,%v", rawLat, rawLon, after.srcLat, after.srcLon)
}
// 显示坐标 == 按留档换算原始坐标(表单按 6 位小数提交,所以留 1e-6 的余量)
if wlat, wlon := geo.WGS84ToGCJ02(after.srcLat, after.srcLon); abs(wlat-after.lat) > 1e-6 || abs(wlon-after.lon) > 1e-6 {
t.Fatalf("「显示坐标 == 按留档换算原始坐标」不成立:%v,%v vs %v,%v", wlat, wlon, after.lat, after.lon)
}
// ---- 4. 明确选「未记录」:整组留档一起清掉 ----
post("/admin/edit/place/update", url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {before.name},
"lat": {fmt.Sprintf("%.6f", after.lat)}, "lon": {fmt.Sprintf("%.6f", after.lon)},
"province": {""}, "city": {""}, "district": {""},
"coord_source": {"none"}, "coord_sys": {"none"},
})
after = load()
if after.coordSource != "" || after.coordSys != "" || after.hasSrc {
t.Fatalf("选了「未记录」却没清干净:%+v", after)
}
// ---- 5. 后台新增地点:来源 manual / 坐标系 gcj02(地图点选与高德搜索都是 GCJ) ----
post("/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"留档测试点"}, "lat": {"30.123456"}, "lon": {"120.123456"},
})
var newPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places WHERE dataset_id=$1 AND name='留档测试点'`,
f.dsID).Scan(&newPK); err != nil {
t.Fatalf("新增地点没落库: %v", err)
}
var cs, cy string
var sl, so *float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(coord_source,''), COALESCE(coord_sys,''),
src_lat, src_lon FROM places WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, newPK).
Scan(&cs, &cy, &sl, &so); err != nil {
t.Fatalf("读新增点失败: %v", err)
}
if cs != "manual" || cy != "gcj02" || sl == nil || *sl != 30.123456 || so == nil || *so != 120.123456 {
t.Fatalf("新增地点的留档不对:source=%q sys=%q src=%v,%v", cs, cy, sl, so)
}
// ---- 6. 照片导入:来源 photo,原始坐标是换算前那一份 ----
// 选「已经是 GCJ-02」正是当初出事的选项,这里把它固化下来:一定要记下来。
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"gcj02"}, "keep_0": {"on"},
"lat_0": {"22.853216"}, "lon_0": {"113.253067"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"照片留档测试点"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
var pcs, pcy string
var psl, pso float64
var plat, plon float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT coord_source, coord_sys, src_lat, src_lon, lat, lon
FROM places WHERE dataset_id=$1 AND name='照片留档测试点'`, f.dsID).
Scan(&pcs, &pcy, &psl, &pso, &plat, &plon); err != nil {
t.Fatalf("照片导入没落库: %v", err)
}
if pcs != "photo" || pcy != "gcj02" {
t.Fatalf("照片导入的留档不对:source=%q sys=%q", pcs, pcy)
}
if psl != 22.853216 || pso != 113.253067 {
t.Fatalf("照片导入没记下换算前的原始坐标:%v,%v", psl, pso)
}
if plat != 22.853216 || plon != 113.253067 {
t.Fatalf("选了「已经是 GCJ-02」就不该换算,实际存成了 %v,%v", plat, plon)
}
// 再导一张按 WGS-84 处理的:显示坐标必须是换算后的值,原始坐标保持 EXIF 原样
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"wgs84"}, "keep_0": {"on"},
"lat_0": {"22.853216"}, "lon_0": {"113.253067"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"照片留档测试点2"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
if err := f.pg.QueryRowContext(f.ctx, `SELECT coord_source, coord_sys, src_lat, src_lon, lat, lon
FROM places WHERE dataset_id=$1 AND name='照片留档测试点2'`, f.dsID).
Scan(&pcs, &pcy, &psl, &pso, &plat, &plon); err != nil {
t.Fatalf("照片导入(WGS)没落库: %v", err)
}
if pcs != "photo" || pcy != "wgs84" || psl != 22.853216 || pso != 113.253067 {
t.Fatalf("WGS-84 照片导入的留档不对:source=%q sys=%q src=%v,%v", pcs, pcy, psl, pso)
}
if wlat, wlon := geo.WGS84ToGCJ02(psl, pso); abs(wlat-plat) > 1e-9 || abs(wlon-plon) > 1e-9 {
t.Fatalf("显示坐标应当是换算后的值:%v,%v", plat, plon)
}
}
// TestCoordSourceSkipsExportSynthetic 盯住回填判据里最容易踩的那个坑:站点导出的备份会
// 给新建地点**合成** ZLOCATION 行,把 ZRAWLATITUDE 写成 GCJ02ToWGS84(导出那一刻的显示坐标)。
// 这种包被回导之后,「有 ZLOCATION 留档」就不再等于「rond 导入」,早期回填据此把 62 个
// 照片导入的点全标成了 rond。判据必须落在「ZRAW 是否恰好等于 gcj2wgs(该行自己的 ZLAT)」上。
//
// 这里手工造两行留档:一行合成(ZRAW = gcj2wgs(ZLAT)),一行真机(ZRAW 是独立采样值),
// 然后跑回填,断言只有真机那行被认领、合成那行不碰。
func TestCoordSourceSkipsExportSynthetic(t *testing.T) {
f := newMovementFixture(t)
// 造两个站点新建的点:编号取在基线之外,模拟「导出时还不存在」的新建地点
var maxPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(max(src_pk),0) FROM places
WHERE dataset_id=$1`, f.dsID).Scan(&maxPK); err != nil {
t.Fatalf("读编号上限失败: %v", err)
}
synthPK, devicePK := maxPK+1, maxPK+2
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon) VALUES
($1,$2,'合成行测试点',30.5,120.5), ($1,$3,'真机行测试点',30.6,120.6)`, f.dsID, synthPK, devicePK)
// 合成行:ZRAW 严格等于 gcj2wgs(ZLAT)——正是 export.go 给新建地点的写法
synLat, synLon := geo.GCJ02ToWGS84(30.5, 120.5)
// 真机行:ZRAW 是独立采样值,显示坐标是 app 自己按 GCJ 偏移算的,两者不会位级吻合
// (真机基线 321 行零命中就是这个原因)。这里按同样的关系构造,但不让往返精确相等。
devRawLat, devRawLon := 30.7, 120.7
devLat, devLon := geo.WGS84ToGCJ02(devRawLat, devRawLon)
devLat, devLon = devLat+1e-9, devLon+1e-9
raw := func(lat, lon, rlat, rlon float64) string {
return fmt.Sprintf(`{"ZLATITUDE":%v,"ZLONGITUDE":%v,"ZRAWLATITUDE":%v,"ZRAWLONGITUDE":%v}`,
lat, lon, rlat, rlon)
}
f.mustExec(t, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw, skipped) VALUES
($1,'ZLOCATION',$2,$3::jsonb,false), ($1,'ZLOCATION',$4,$5::jsonb,false)`,
f.dsID, synthPK, raw(30.5, 120.5, synLat, synLon), devicePK, raw(devLat, devLon, devRawLat, devRawLon))
// 先故意把合成行写成早期回填的错误结果,验证回填会把它撤回
f.mustExec(t, `UPDATE places SET coord_source='rond', coord_sys='wgs84', src_lat=$3, src_lon=$4
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, synthPK, synLat, synLon)
if _, err := ingest.BackfillCoordSource(f.ctx, f.pg, f.dsID); err != nil {
t.Fatalf("回填失败: %v", err)
}
var source, sys string
var srcLat, srcLon *float64
load := func(pk int) {
t.Helper()
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(coord_source,''), COALESCE(coord_sys,''),
src_lat, src_lon FROM places WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).
Scan(&source, &sys, &srcLat, &srcLon); err != nil {
t.Fatalf("读留档失败: %v", err)
}
}
// 合成行必须被撤回:它不是真机留档,来源应当是「未记录」
load(synthPK)
if source != "" || sys != "" || srcLat != nil || srcLon != nil {
t.Fatalf("导出合成行没被撤回:source=%q sys=%q src=%v,%v", source, sys, srcLat, srcLon)
}
// 真机行必须被认领,且原始坐标就是留档里的 ZRAW
load(devicePK)
if source != "rond" || sys != "wgs84" || srcLat == nil || *srcLat != devRawLat || srcLon == nil || *srcLon != devRawLon {
t.Fatalf("真机留档行没被正确认领:source=%q sys=%q src=%v,%v(想要 %v,%v)",
source, sys, srcLat, srcLon, devRawLat, devRawLon)
}
// 再跑一次必须幂等(启动时每次都会跑)
if _, err := ingest.BackfillCoordSource(f.ctx, f.pg, f.dsID); err != nil {
t.Fatalf("二次回填失败: %v", err)
}
load(devicePK)
if source != "rond" || srcLat == nil || *srcLat != devRawLat {
t.Fatalf("回填不幂等:%q %v", source, srcLat)
}
}
// TestExportPhotoImportFlag 盯住导出侧补的 ZISPHOTOIMPORT:rond 靠这一位区分
// 「照片导入」与「手工新增」,站点没有对应字段,只能从坐标来源留档推。
// 真机行(coord_source=rond)必须原样保留,不能被我们改写。
func TestExportPhotoImportFlag(t *testing.T) {
f := newMovementFixture(t)
// 照片导入一个点 → photo;手工新增一个点 → manual
post := func(path string, form url.Values) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
switch path {
case "/admin/photos/import":
f.srv.adminPhotosImport(rec, req)
case "/admin/edit/place":
f.srv.editPlaceCreate(rec, req)
default:
t.Fatalf("未接入的处理函数: %s", path)
}
}
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"wgs84"}, "keep_0": {"on"},
"lat_0": {"22.853216"}, "lon_0": {"113.253067"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"导出照片标志位测试点"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
post("/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"导出手工点测试点"}, "lat": {"30.123456"}, "lon": {"120.123456"},
})
var photoPK, manualPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND name='导出照片标志位测试点'`, f.dsID).Scan(&photoPK); err != nil {
t.Fatalf("照片导入点没落库: %v", err)
}
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND name='导出手工点测试点'`, f.dsID).Scan(&manualPK); err != nil {
t.Fatalf("手工点没落库: %v", err)
}
// 基线里的真机行:留档里记着它自己那份 ZISPHOTOIMPORT,导出后必须一字不改
var devPK int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND coord_source='rond' ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&devPK); err != nil {
t.Fatalf("找不到真机点: %v", err)
}
srcDB := f.openSource(t)
var devWant int
if err := srcDB.QueryRow(`SELECT ZISPHOTOIMPORT FROM ZLOCATION WHERE Z_PK=?`, devPK).Scan(&devWant); err != nil {
t.Fatalf("读原包 ZISPHOTOIMPORT 失败: %v", err)
}
out := filepath.Join(t.TempDir(), "export.rondbackup")
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
t.Fatal(err)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
defer edb.Close()
flagOf := func(pk int) int {
t.Helper()
var v sql.NullInt64
if err := edb.QueryRow(`SELECT ZISPHOTOIMPORT FROM ZLOCATION WHERE Z_PK=?`, pk).Scan(&v); err != nil {
t.Fatalf("读导出包 Z_PK=%d 失败: %v", pk, err)
}
if !v.Valid {
return -1
}
return int(v.Int64)
}
if got := flagOf(photoPK); got != 1 {
t.Errorf("照片导入的点 ZISPHOTOIMPORT 应为 1,实际 %d", got)
}
if got := flagOf(manualPK); got != 0 {
t.Errorf("手工新增的点 ZISPHOTOIMPORT 应为 0,实际 %d", got)
}
if got := flagOf(devPK); got != devWant {
t.Errorf("真机点 #%d 的 ZISPHOTOIMPORT 被改写:原有 %d,导出成 %d", devPK, devWant, got)
}
}
// TestInlineSaveKeepsFullPrecisionCoord 盯住「行内保存会把坐标截到 6 位小数」。
// 表单的经纬度框是 %.6f,而真机基线的点带长小数(26.46800422211455 这种)。
// 只改备注/名称时原样回贴,旧实现会把坐标写短——导出与原始包逐位对照就对不上。
// 与时间字段同一套规矩:落在同一个 6 位小数上就沿用库里那份。
func TestInlineSaveKeepsFullPrecisionCoord(t *testing.T) {
f := newMovementFixture(t)
var pk int
var lat0, lon0, srcLat0 float64
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk, lat, lon, src_lat FROM places
WHERE dataset_id=$1 AND coord_source='rond' AND src_lat IS NOT NULL
AND lat <> round(lat::numeric, 6)::float8 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(
&pk, &lat0, &lon0, &srcLat0); err != nil {
t.Skipf("本地基线的坐标都已经是 6 位,测不出截断: %v", err)
}
// 只改名称:经纬度按表单的 6 位小数原样回贴
req := httptest.NewRequest("POST", "/admin/edit/place/update", strings.NewReader(url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {"改了名字"},
"lat": {fmt.Sprintf("%.6f", lat0)}, "lon": {fmt.Sprintf("%.6f", lon0)},
"province": {""}, "city": {""}, "district": {""},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
f.srv.editPlaceUpdate(httptest.NewRecorder(), req)
var lat1, lon1, srcLat1 float64
var name string
if err := f.pg.QueryRowContext(f.ctx, `SELECT lat, lon, src_lat, name FROM places
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).Scan(&lat1, &lon1, &srcLat1, &name); err != nil {
t.Fatalf("读回失败: %v", err)
}
if name != "改了名字" {
t.Fatalf("名称没改成功: %q", name)
}
if lat1 != lat0 || lon1 != lon0 {
t.Errorf("只改名称却把坐标截短了:%v,%v → %v,%v", lat0, lon0, lat1, lon1)
}
if srcLat1 != srcLat0 {
t.Errorf("原始坐标被动过:%v → %v", srcLat0, srcLat1)
}
}
func (f *movementFixture) mustExec(t *testing.T, q string, args ...any) {
t.Helper()
if _, err := f.pg.ExecContext(f.ctx, q, args...); err != nil {
t.Fatalf("执行 %s 失败: %v", q, err)
}
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
+304
View File
@@ -0,0 +1,304 @@
package web
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"testing"
"time"
)
// webpBytes 造一段带正确 RIFF/WEBP 魔数的假数据。服务端只校验文件头
// (转码在浏览器做),所以这里不需要真的 WebP 编码器。
func webpBytes(n int) []byte {
b := make([]byte, 12+n)
copy(b[0:4], "RIFF")
copy(b[8:12], "WEBP")
for i := 12; i < len(b); i++ {
b[i] = byte(i)
}
return b
}
// postMultipart 按浏览器 FormData 的形态发一个 multipart 请求。
func postMultipart(t *testing.T, f *movementFixture, handler http.HandlerFunc, fields map[string]string, files map[string][]byte) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
w := multipart.NewWriter(&body)
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
t.Fatal(err)
}
}
for field, data := range files {
part, err := w.CreateFormFile(field, "image.webp")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/x", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
handler(rec, req)
return rec
}
// setMediaBackend 把「图片存哪边」临时改成 backend,测试结束再恢复。
// 多个测试共用一个库,不还原就会互相干扰(前一个设了 s3,后一个的本地上传就失败了)。
func setMediaBackend(t *testing.T, f *movementFixture, backend string) {
t.Helper()
var prev string
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(value->>'media_backend','local')
FROM settings WHERE key='site'`).Scan(&prev); err != nil {
t.Fatalf("读图片存储设置失败: %v", err)
}
write := func(v string) {
t.Helper()
if _, err := f.pg.ExecContext(f.ctx, `UPDATE settings
SET value = jsonb_set(value, '{media_backend}', to_jsonb($1::text)) WHERE key='site'`, v); err != nil {
t.Fatalf("写图片存储设置失败: %v", err)
}
}
write(backend)
t.Cleanup(func() { write(prev) })
}
// TestPlaceImageUploadDelete 端到端跑一遍浏览器那条路:
// multipart 上传 → 落库 + 落盘 → /media 取回同样的字节 → multipart 删除 → 两处都没了。
//
// 这里刻意用 multipart 而不是 urlencoded:前端是用 FormData 发的,
// 而「先 ParseForm 再读 multipart」会把字段读成空——这个坑真踩过(删除按钮点了没反应)。
func TestPlaceImageUploadDelete(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir() // 图片写进临时目录,别脏了 storage/
setMediaBackend(t, f, "local")
// 借一个现成的地点
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到地点: %v", err)
}
raw := webpBytes(64)
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src), "w": "1200", "h": "900"},
map[string][]byte{"file": raw})
var up struct {
OK bool `json:"ok"`
ID int64 `json:"id"`
URL string `json:"url"`
Bytes int `json:"bytes"`
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
t.Fatalf("上传响应不是 JSON: %s", rec.Body.String())
}
if !up.OK || up.Error != "" {
t.Fatalf("上传失败: %s", rec.Body.String())
}
if !strings.HasPrefix(up.URL, "/media/") {
t.Fatalf("本地存储的地址应以 /media/ 开头,实际 %q", up.URL)
}
// 落盘 + 落库
full, err := f.srv.media.LocalPath(strings.TrimPrefix(up.URL, "/media/"))
if err != nil {
t.Fatalf("取路径失败: %v", err)
}
if got, err := os.ReadFile(full); err != nil || !bytes.Equal(got, raw) {
t.Fatalf("落盘的字节与上传的不一致: %v", err)
}
// /media 直出同样的字节
rec = httptest.NewRecorder()
req := httptest.NewRequest("GET", up.URL, nil).WithContext(f.ctx)
f.srv.serveMedia(rec, req)
if rec.Code != 200 {
t.Fatalf("取图返回 %d", rec.Code)
}
if !bytes.Equal(rec.Body.Bytes(), raw) {
t.Fatal("取回的字节与上传的不一致")
}
if ct := rec.Header().Get("Content-Type"); ct != "image/webp" {
t.Errorf("Content-Type 不对: %q", ct)
}
// 非 WebP 必须被挡住(前端转码失败时会发来 PNG)
rec = postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src)},
map[string][]byte{"file": append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 32)...)})
if !strings.Contains(rec.Body.String(), "只接受 WebP") {
t.Errorf("非 WebP 没被拒绝: %s", rec.Body.String())
}
// 删除:同样走 multipart
rec = postMultipart(t, f, f.srv.adminPlaceImageDelete,
map[string]string{"id": fmt.Sprint(up.ID)}, nil)
if !strings.Contains(rec.Body.String(), `"ok":true`) {
t.Fatalf("删除失败: %s", rec.Body.String())
}
if _, err := os.Stat(full); !os.IsNotExist(err) {
t.Errorf("文件没删掉: %v", err)
}
var n int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM place_images WHERE id=$1`, up.ID).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("库里的记录没删掉")
}
}
// TestPhotoImportWithImage 照片导入「存图」:地点与该行图片要在一次导入里都建好,
// 并且图片挂在正确的 src_pk 上。
func TestPhotoImportWithImage(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir()
setMediaBackend(t, f, "local")
raw := webpBytes(48)
rec := postMultipart(t, f, f.srv.adminPhotosImport, map[string]string{
"n": "1", "coord": "wgs84", "keep_0": "on",
"lat_0": "30.123456", "lon_0": "120.123456",
"arrival_0": "2026-04-18T18:14", "name_0": "带图的导入点",
"country_0": "CN", "timezone_0": "Asia/Shanghai",
"image_w_0": "1200", "image_h_0": "900",
}, map[string][]byte{"image_0": raw})
if rec.Code != http.StatusSeeOther {
t.Fatalf("导入没返回重定向: %d %s", rec.Code, rec.Body.String())
}
loc, err := url.QueryUnescape(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(loc, "err=") {
t.Fatalf("导入报错: %s", loc)
}
if !strings.Contains(loc, "配了 1 张图") {
t.Errorf("结果消息里应提到配了图: %s", loc)
}
var src int
var w, h int
if err := f.pg.QueryRowContext(f.ctx, `SELECT p.src_pk, pi.width, pi.height FROM places p
JOIN place_images pi ON pi.dataset_id=p.dataset_id AND pi.src_pk=p.src_pk
WHERE p.dataset_id=$1 AND p.name='带图的导入点'`, f.dsID).Scan(&src, &w, &h); err != nil {
t.Fatalf("配图没挂到地点上: %v", err)
}
if w != 1200 || h != 900 {
t.Errorf("宽高没记对: %dx%d", w, h)
}
// 字节确实落盘了:目录是 <数据集>/<上传月份>/,文件名是 <地点号>-<哈希>.webp
dir := filepath.Join(f.cfg.MediaDir(), fmt.Sprint(f.dsID), time.Now().Format("200601"))
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("月份目录不存在: %v", err)
}
if len(entries) != 1 {
t.Fatalf("期望落盘 1 个文件,实际 %d", len(entries))
}
if name := entries[0].Name(); !strings.HasPrefix(name, fmt.Sprint(src)+"-") ||
!strings.HasSuffix(name, ".webp") {
t.Errorf("文件名应是 <地点号>-<哈希>.webp,实际 %q", name)
}
}
// TestPlaceImageS3Backend 走一遍对象存储那条路:用 httptest 假装 S3 端点,
// 断言请求打到「路径式 + 桶名 + 对象名」上,并带上了 SigV4 的 Authorization。
// 真去校验签名的是 media 包里的官方向量测试,这里盯的是接线对不对。
func TestPlaceImageS3Backend(t *testing.T) {
f := newMovementFixture(t)
f.cfg.DataDir = t.TempDir()
var gotPath, gotAuth, gotCT string
var gotBody []byte
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotCT = r.Header.Get("Content-Type")
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer fake.Close()
f.cfg.S3Endpoint = fake.URL
f.cfg.S3Region = "auto"
f.cfg.S3Bucket = "pics"
f.cfg.S3AccessKey = "AKIATEST"
f.cfg.S3SecretKey = "secret"
f.cfg.S3PublicBase = "https://img.example.com"
setMediaBackend(t, f, "s3")
var src int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1`, f.dsID).Scan(&src); err != nil {
t.Fatalf("找不到地点: %v", err)
}
raw := webpBytes(32)
rec := postMultipart(t, f, f.srv.adminPlaceImageUpload,
map[string]string{"src_pk": fmt.Sprint(src)}, map[string][]byte{"file": raw})
var up struct {
OK bool `json:"ok"`
URL string `json:"url"`
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
t.Fatalf("上传响应不是 JSON: %s", rec.Body.String())
}
if !up.OK || up.Error != "" {
t.Fatalf("对象存储上传失败: %s", rec.Body.String())
}
if !strings.HasPrefix(up.URL, "https://img.example.com/") {
t.Errorf("对象存储的地址应当用 s3_public_base 拼,实际 %q", up.URL)
}
// 对象名 = <数据集>/<上传月份>/<地点号>-<内容哈希>.webp
parts := strings.Split(strings.TrimPrefix(gotPath, "/pics/"), "/")
if len(parts) != 3 {
t.Fatalf("对象名层级不对(应为 数据集/月份/文件名): %q", gotPath)
}
if parts[0] != fmt.Sprint(f.dsID) {
t.Errorf("第一层应是数据集号,实际 %q", parts[0])
}
if len(parts[1]) != 6 || !strings.HasPrefix(parts[1], time.Now().Format("2006")) {
t.Errorf("第二层应是上传月份(YYYYMM),实际 %q", parts[1])
}
if !strings.HasPrefix(parts[2], fmt.Sprint(src)+"-") || !strings.HasSuffix(parts[2], ".webp") {
t.Errorf("文件名应是 <地点号>-<哈希>.webp,实际 %q", parts[2])
}
if !strings.Contains(gotAuth, "AWS4-HMAC-SHA256 Credential=AKIATEST/") ||
!strings.Contains(gotAuth, "/auto/s3/aws4_request") {
t.Errorf("没带上 SigV4 签名: %q", gotAuth)
}
if gotCT != "image/webp" {
t.Errorf("Content-Type 不对: %q", gotCT)
}
if !bytes.Equal(gotBody, raw) {
t.Error("发出去的字节与上传的不一致")
}
// 存储被切到对象存储后,本地不应再落盘
entries, _ := os.ReadDir(path.Join(f.cfg.MediaDir(), fmt.Sprint(f.dsID)))
if len(entries) != 0 {
t.Errorf("切到对象存储后不该再写本地磁盘,却发现 %d 个条目", len(entries))
}
}
+94
View File
@@ -0,0 +1,94 @@
package web
import (
"fmt"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestNewRowNumberingSkipsArchivedPKs 盯住「站点新建行的编号」。
//
// 编号不能只看 `MAX(places.src_pk)`:`entity_raw` 里还躺着导入时缺必填字段的 skipped 行
// (导出要把它们原样补回去,所以一直留着),那些号没有对应实体行却占着号位。
// 只看 places 的话,新行会拿到一个已经被留档占用的号 —— 导出时被当成「已存在的行」走 UPDATE,
// 于是继承对方 rond 自己写的那些列(ZRADIUS / ZTYPE_ / ZNOTE_…)。
//
// 这里造一条「只有留档、没有实体行」的 ZLOCATION,再分别走两条新建路径,断言编号绕开它。
func TestNewRowNumberingSkipsArchivedPKs(t *testing.T) {
f := newMovementFixture(t)
// 与 nextSrcPK 同一套口径,先算出「当前有效上限」
var cur int
if err := f.pg.QueryRowContext(f.ctx, `SELECT GREATEST(
(SELECT COALESCE(MAX(src_pk),0) FROM places WHERE dataset_id=$1),
COALESCE((coredata_meta->'entities'->'Location'->>'max')::int,0),
(SELECT COALESCE(MAX((raw->>'Z_PK')::numeric),0)::int FROM entity_raw
WHERE dataset_id=$1 AND entity='ZLOCATION' AND (raw->>'Z_PK') ~ '^[0-9]+$')
) FROM datasets WHERE id=$1`, f.dsID).Scan(&cur); err != nil {
t.Fatalf("算编号上限失败: %v", err)
}
stalePK := cur + 3
// 留档存的是整行 Core Data 记录,Z_PK 就在里面——nextSrcPK 正是靠它算上限
f.mustExec(t, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw, skipped)
VALUES ($1,'ZLOCATION',$2,$3::jsonb,true)`, f.dsID, stalePK,
fmt.Sprintf(`{"Z_PK":%d,"ZLATITUDE":30.0,"ZLONGITUDE":120.0}`, stalePK))
post := func(path string, form url.Values) {
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(f.ctx)
rec := httptest.NewRecorder()
switch path {
case "/admin/edit/place":
f.srv.editPlaceCreate(rec, req)
case "/admin/photos/import":
f.srv.adminPhotosImport(rec, req)
default:
t.Fatalf("未接入的处理函数: %s", path)
}
if rec.Code >= 400 {
t.Fatalf("%s 返回 %d: %s", path, rec.Code, rec.Body.String())
}
}
srcPKOf := func(name string) int {
t.Helper()
var pk int
if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places
WHERE dataset_id=$1 AND name=$2`, f.dsID, name).Scan(&pk); err != nil {
t.Fatalf("地点 %q 没落库: %v", name, err)
}
return pk
}
// 路径一:后台「新增地点」
post("/admin/edit/place", url.Values{
"mode": {"new"}, "name": {"编号测试点-手工"}, "lat": {"30.11"}, "lon": {"120.11"},
})
if got := srcPKOf("编号测试点-手工"); got <= stalePK {
t.Errorf("手工新增的编号 %d 没绕开留档占用的 %d(旧实现会拿到 %d)", got, stalePK, cur+1)
}
// 路径二:照片导入(同一套规则)
post("/admin/photos/import", url.Values{
"n": {"1"}, "coord": {"wgs84"}, "keep_0": {"on"},
"lat_0": {"30.22"}, "lon_0": {"120.22"},
"arrival_0": {"2026-04-18T18:14"}, "name_0": {"编号测试点-照片"},
"country_0": {"CN"}, "timezone_0": {"Asia/Shanghai"},
})
if got := srcPKOf("编号测试点-照片"); got <= stalePK {
t.Errorf("照片导入的编号 %d 没绕开留档占用的 %d", got, stalePK)
}
// 留档那条本身还在(newSrcPK 绕开了它,就不该被清掉)
var n int
if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM entity_raw
WHERE dataset_id=$1 AND entity='ZLOCATION' AND src_pk=$2`, f.dsID, stalePK).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("留档行被误清:期望 1 行,实际 %d", n)
}
}
+472
View File
@@ -0,0 +1,472 @@
package web
import (
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"net/http"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"rond-with-you/internal/auth"
"rond-with-you/internal/captcha"
"rond-with-you/internal/config"
"rond-with-you/internal/media"
"rond-with-you/internal/notes"
"rond-with-you/internal/setting"
"rond-with-you/internal/stats"
"rond-with-you/internal/votes"
)
//go:embed templates/*.html templates/admin/*.html
var tmplFS embed.FS
//go:embed static
var staticFS embed.FS
// pageFiles 每个页面单独成组解析:页面文件都定义 "content",
// 与 layout.html 一起构成一个独立模板集,避免定义名冲突。
var pageFiles = map[string]struct {
files []string
entry string
}{
"home": {[]string{"templates/layout.html", "templates/home.html"}, "layout"},
"map": {[]string{"templates/layout.html", "templates/map.html"}, "layout"},
"places": {[]string{"templates/layout.html", "templates/places.html"}, "layout"},
"place": {[]string{"templates/layout.html", "templates/place.html"}, "layout"},
"timeline": {[]string{"templates/layout.html", "templates/timeline.html"}, "layout"},
"stats": {[]string{"templates/layout.html", "templates/stats.html"}, "layout"},
"report": {[]string{"templates/layout.html", "templates/report.html"}, "layout"},
"admin/index": {[]string{"templates/layout.html", "templates/admin/index.html"}, "layout"},
"admin/settings": {[]string{"templates/layout.html", "templates/admin/settings.html"}, "layout"},
"admin/password": {[]string{"templates/layout.html", "templates/admin/password.html"}, "layout"},
"admin/login": {[]string{"templates/admin/login.html"}, "login"},
"admin/twofa-login": {[]string{"templates/admin/twofa-login.html"}, "login2"},
"admin/twofa": {[]string{"templates/layout.html", "templates/admin/twofa.html"}, "layout"},
"admin/fog-mask": {[]string{"templates/layout.html", "templates/admin/fog-mask.html"}, "layout"},
"admin/notes": {[]string{"templates/layout.html", "templates/admin/notes.html"}, "layout"},
"admin/edit": {[]string{"templates/layout.html", "templates/admin/edit.html"}, "layout"},
"admin/topics": {[]string{"templates/layout.html", "templates/admin/topics.html"}, "layout"},
"admin/photos": {[]string{"templates/layout.html", "templates/admin/photos.html"}, "layout"},
"topics": {[]string{"templates/layout.html", "templates/topics.html"}, "layout"},
"topic": {[]string{"templates/layout.html", "templates/topic.html"}, "layout"},
}
type pageSet struct {
t *template.Template
entry string
}
type Server struct {
cfg *config.Config
db *sql.DB
q stats.DB
auth *auth.Store
sets *setting.Store
notes *notes.Store
votes *votes.Store
pages map[string]pageSet
mux *http.ServeMux
cookie string
tiles tileSet
tileCache *tileCache
// fogTiles 缓存渲染好的迷雾瓦片:每张都要现画逐格位图再 PNG 编码,
// 而内容只随「迷雾版本 + 遮罩 + 底图坐标系」变化。
fogTiles *tileCache
// masks 缓存遮罩列表:它不在 fogIndex 里,不然每张瓦片都要查一次库。
masks maskCache
// captcha 存登录验证码的答案(内存、一次性、带过期)
captcha *captcha.Store
// media 管地点图片写哪(本地磁盘 / 对象存储)与怎么取址
media *media.Store
}
func New(cfg *config.Config, db *sql.DB) (*Server, error) {
initAssetVersion(cfg.Debug)
pages := make(map[string]pageSet, len(pageFiles))
for key, def := range pageFiles {
t, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
ParseFS(tmplFS, def.files...)
if err != nil {
return nil, fmt.Errorf("解析模板 %s 失败: %w", key, err)
}
pages[key] = pageSet{t: t, entry: def.entry}
}
s := &Server{
cfg: cfg,
db: db,
q: stats.DB{DB: db},
auth: &auth.Store{DB: db, Secret: []byte(cfg.SecretKey), Days: cfg.SessionDays},
sets: &setting.Store{DB: db},
notes: &notes.Store{DB: db},
votes: &votes.Store{DB: db},
pages: pages,
mux: http.NewServeMux(),
cookie: "rond_session",
tiles: buildTileSet(cfg),
tileCache: newTileCache(1024),
fogTiles: newTileCache(512),
captcha: captcha.NewStore(10 * time.Minute),
media: media.New(cfg),
}
s.routes()
return s, nil
}
// guardPage 拦截「站长已关闭」的公开页面:管理员照常访问,访客拿到 404。
// withUser 在 mux 之前运行,这里能直接拿到登录态。
func (s *Server) guardPage(nav string, next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if userFrom(r.Context()) == nil {
if sets, err := s.sets.Load(r.Context()); err == nil && !sets.HasPage(nav) {
s.pausePage(w, http.StatusNotFound, "🔒", "该页面未对访客开放",
"站长还没有把这一页开放给访客。", 0)
return
}
}
next.ServeHTTP(w, r)
})
}
func (s *Server) Handler() http.Handler {
return s.recover(gzipHandler(s.logRequests(s.withUser(s.mux))))
}
// cacheStatic 给带了当前版本号的静态资源加长期强缓存。
// 版本号随编译产物变化(静态文件内容哈希),旧链接即使被缓存也不会命中新版本。
func (s *Server) cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if v := r.URL.Query().Get("v"); v != "" && v == assetVersion {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
next.ServeHTTP(w, r)
})
}
// withUser 为所有请求解析登录态。公开页面也要知道访问者是访客还是站长,
// 才能决定是否渲染「含家/工作」这类只给自己看的开关。
// 未携带会话 cookie 时不会查库,匿名访问没有额外开销。
func (s *Server) withUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, err := s.sessionUser(r); err == nil && u != nil {
r = r.WithContext(context.WithValue(r.Context(), userKey{}, u))
}
next.ServeHTTP(w, r)
})
}
func (s *Server) routes() {
// 静态资源:调试模式下直接读磁盘,改样式/脚本不用重新编译。
// 页面链接都带 ?v=<版本>,版本一致才给一年强缓存,重新编译后版本变了自然失效。
if s.cfg.Debug {
s.mux.Handle("GET /static/", s.cacheStatic(
http.StripPrefix("/static/", http.FileServer(http.Dir("internal/web/static")))))
} else {
sub, _ := fs.Sub(staticFS, "static")
s.mux.Handle("GET /static/", s.cacheStatic(
http.StripPrefix("/static/", http.FileServer(http.FS(sub)))))
}
// 公开页面:受「公开页面」开关控制,关闭后仅管理员可访问
s.mux.HandleFunc("GET /{$}", s.home)
s.mux.Handle("GET /map", s.guardPage("map", s.mapPage))
s.mux.Handle("GET /places", s.guardPage("places", s.placesPage))
s.mux.HandleFunc("GET /places/{id}", s.placePage)
s.mux.Handle("GET /timeline", s.guardPage("timeline", s.timelinePage))
s.mux.Handle("GET /stats", s.guardPage("stats", s.statsPage))
s.mux.HandleFunc("GET /api/points", s.apiPoints)
s.mux.HandleFunc("GET /api/track", s.apiTrack)
s.mux.HandleFunc("GET /api/cities", s.apiCityAgg)
s.mux.HandleFunc("GET /api/gpx", s.apiGpx)
s.mux.HandleFunc("GET /api/votes", s.apiVotes)
s.mux.HandleFunc("POST /api/vote", s.apiVote)
s.mux.HandleFunc("POST /api/verdict", s.requireAdmin(s.apiVerdict))
s.mux.HandleFunc("GET /api/map-config", s.mapConfig)
// 地理编码走服务端代理,高德密钥不出现在前端源码里;仅管理员可用,避免配额被外部消耗
s.mux.HandleFunc("GET /api/geocode", s.requireAdmin(s.apiGeocode))
s.mux.HandleFunc("GET /api/coords", s.requireAdmin(s.apiCoords))
s.mux.HandleFunc("POST /api/regeo", s.requireAdmin(s.apiRegeo))
s.mux.HandleFunc("GET /tiles/{layer}/{z}/{x}/{y}", s.handleTile)
s.mux.HandleFunc("GET /tiles/fog/{z}/{x}/{y}", s.handleFogTile)
// 专题:把某段时间 / 某个区域的足迹打包成可对外展示的页面。
// 不做页面级开关——访客能不能看由「每个专题的展示开关」决定(详情页对未展示的专题 404)。
s.mux.HandleFunc("GET /admin/topics", s.requireAdmin(s.topicsAdminPage))
s.mux.HandleFunc("POST /admin/topics/save", s.requireAdmin(s.adminTopicSave))
s.mux.HandleFunc("POST /admin/topics/delete", s.requireAdmin(s.adminTopicDelete))
s.mux.HandleFunc("POST /admin/topics/toggle", s.requireAdmin(s.adminTopicToggle))
s.mux.HandleFunc("GET /topics", s.topicsListPage)
s.mux.HandleFunc("GET /topics/{id}", s.topicPage)
s.mux.Handle("GET /report", s.guardPage("report", s.reportPage))
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := s.db.PingContext(r.Context()); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
io.WriteString(w, "ok")
})
// 后台
s.mux.HandleFunc("GET /captcha", s.captchaImage)
s.mux.HandleFunc("GET /admin/login", s.loginPage)
s.mux.HandleFunc("POST /admin/login", s.loginSubmit)
s.mux.HandleFunc("POST /admin/login/twofa", s.login2FA)
s.mux.HandleFunc("POST /admin/logout", s.logout)
s.mux.HandleFunc("GET /admin/twofa", s.requireAdmin(s.twofaPage))
s.mux.HandleFunc("POST /admin/twofa/setup", s.requireAdmin(s.twofaSetup))
s.mux.HandleFunc("POST /admin/twofa/confirm", s.requireAdmin(s.twofaConfirm))
s.mux.HandleFunc("POST /admin/twofa/disable", s.requireAdmin(s.twofaDisable))
s.mux.HandleFunc("GET /admin", s.requireAdmin(s.adminHome))
s.mux.HandleFunc("POST /admin/upload", s.requireAdmin(s.adminUpload))
s.mux.HandleFunc("POST /admin/datasets/{id}/activate", s.requireAdmin(s.adminActivate))
s.mux.HandleFunc("POST /admin/datasets/{id}/delete", s.requireAdmin(s.adminDelete))
s.mux.HandleFunc("POST /admin/place/note", s.requireAdmin(s.adminPlaceNote))
s.mux.HandleFunc("POST /admin/place/vote", s.requireAdmin(s.adminPlaceVote))
// 地点图片:字节由浏览器转成 WebP 后发过来,这里只负责收下、落库、取址
s.mux.HandleFunc("POST /admin/place/images", s.requireAdmin(s.adminPlaceImageUpload))
s.mux.HandleFunc("POST /admin/place/images/delete", s.requireAdmin(s.adminPlaceImageDelete))
// 本地存储的图片直出;对象存储的图走公开域名,不经过这里
s.mux.HandleFunc("GET /media/", s.serveMedia)
s.mux.HandleFunc("POST /admin/notes/bulk", s.requireAdmin(s.adminNotesBulk))
s.mux.HandleFunc("POST /admin/notes/rond", s.requireAdmin(s.adminNotesRond))
s.mux.HandleFunc("GET /admin/notes", s.requireAdmin(s.notesPage))
// 数据编辑:手动补录记录点,改完可导出回 rondbackup
s.mux.HandleFunc("GET /admin/edit", s.requireAdmin(s.editPage))
s.mux.HandleFunc("POST /admin/edit/place", s.requireAdmin(s.editPlaceCreate))
s.mux.HandleFunc("POST /admin/edit/place/update", s.requireAdmin(s.editPlaceUpdate))
s.mux.HandleFunc("POST /admin/edit/place/delete", s.requireAdmin(s.editPlaceDelete))
s.mux.HandleFunc("POST /admin/edit/visit/update", s.requireAdmin(s.editVisitUpdate))
s.mux.HandleFunc("POST /admin/edit/activity", s.requireAdmin(s.editActivityCreate))
s.mux.HandleFunc("POST /admin/edit/activity/update", s.requireAdmin(s.editActivityUpdate))
s.mux.HandleFunc("POST /admin/edit/activity/delete", s.requireAdmin(s.editActivityDelete))
// 照片导入:EXIF 由浏览器读(不上传照片),确认后只把元数据提交上来落库
s.mux.HandleFunc("GET /admin/photos", s.requireAdmin(s.adminPhotosPage))
s.mux.HandleFunc("POST /admin/photos/import", s.requireAdmin(s.adminPhotosImport))
s.mux.HandleFunc("POST /admin/edit/visit/delete", s.requireAdmin(s.editVisitDelete))
s.mux.HandleFunc("POST /admin/edit/visit/bookmark", s.requireAdmin(s.adminVisitBookmark))
s.mux.HandleFunc("POST /admin/edit/movement", s.requireAdmin(s.editMovementCreate))
s.mux.HandleFunc("POST /admin/edit/movement/update", s.requireAdmin(s.editMovementUpdate))
s.mux.HandleFunc("POST /admin/edit/movement/delete", s.requireAdmin(s.editMovementDelete))
s.mux.HandleFunc("POST /admin/edit/weather", s.requireAdmin(s.editWeatherCreate))
s.mux.HandleFunc("POST /admin/gpx", s.requireAdmin(s.adminGpxUpload))
s.mux.HandleFunc("POST /admin/gpx/delete", s.requireAdmin(s.adminGpxDelete))
s.mux.HandleFunc("POST /admin/fog", s.requireAdmin(s.adminFogUpload))
s.mux.HandleFunc("POST /admin/fog/clear", s.requireAdmin(s.adminFogClear))
s.mux.HandleFunc("GET /admin/fog/export", s.requireAdmin(s.adminFogExport))
s.mux.HandleFunc("POST /admin/places/backfill", s.requireAdmin(s.adminBackfillPlaces))
s.mux.HandleFunc("GET /admin/fog/mask", s.requireAdmin(s.fogMaskPage))
s.mux.HandleFunc("POST /admin/fog/mask/save", s.requireAdmin(s.fogMaskSave))
s.mux.HandleFunc("POST /admin/fog/mask/delete", s.requireAdmin(s.fogMaskDelete))
s.mux.HandleFunc("GET /admin/export/places.geojson", s.requireAdmin(s.exportGeoJSON))
s.mux.HandleFunc("GET /admin/export/places.gpx", s.requireAdmin(s.exportGPX))
s.mux.HandleFunc("GET /admin/export/rondbackup", s.requireAdmin(s.exportRondbackup))
s.mux.HandleFunc("GET /admin/settings", s.requireAdmin(s.settingsPage))
s.mux.HandleFunc("POST /admin/settings", s.requireAdmin(s.settingsSubmit))
s.mux.HandleFunc("GET /admin/password", s.requireAdmin(s.passwordPage))
s.mux.HandleFunc("POST /admin/password", s.requireAdmin(s.passwordSubmit))
s.mux.HandleFunc("POST /admin/username", s.requireAdmin(s.usernameSubmit))
}
// ---------- 中间件 ----------
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
if s.cfg.Debug || strings.HasPrefix(r.URL.Path, "/admin") {
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
}
})
}
func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic %s %s: %v", r.Method, r.URL.Path, rec)
http.Error(w, "服务器内部错误", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// sessionUser 从签名 cookie 还原登录态;口令变更后旧会话自动失效。
func (s *Server) sessionUser(r *http.Request) (*auth.User, error) {
c, err := r.Cookie(s.cookie)
if err != nil || c.Value == "" {
return nil, nil
}
uidStr, _, ok := strings.Cut(c.Value, "|")
if !ok {
return nil, nil
}
uid, err := strconv.ParseInt(uidStr, 10, 64)
if err != nil {
return nil, nil
}
u, err := s.auth.UserByID(r.Context(), uid)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if _, ok := s.auth.ParseToken(c.Value, u.Hash); !ok {
return nil, nil
}
return u, nil
}
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u := userFrom(r.Context())
if u == nil {
http.Redirect(w, r, "/admin/login?next="+path.Clean(r.URL.Path), http.StatusSeeOther)
return
}
next(w, r)
}
}
type userKey struct{}
func userFrom(ctx context.Context) *auth.User {
u, _ := ctx.Value(userKey{}).(*auth.User)
return u
}
func (s *Server) login(w http.ResponseWriter, userID int64, hash string) {
http.SetCookie(w, &http.Cookie{
Name: s.cookie,
Value: s.auth.SignToken(userID, hash),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: s.cfg.SessionDays * 86400,
})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: s.cookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// ---------- 渲染 ----------
// render 渲染指定页面(key 见 pageFiles)。
func (s *Server) render(w http.ResponseWriter, key string, data any) {
set, err := s.pageSet(key)
if err != nil {
log.Printf("加载页面模板 %s 失败: %v", key, err)
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := set.t.ExecuteTemplate(w, set.entry, data); err != nil {
log.Printf("渲染 %s 失败: %v", key, err)
}
}
func (s *Server) renderStatus(w http.ResponseWriter, code int, key string, data any) {
set, err := s.pageSet(key)
if err != nil {
http.Error(w, "页面不存在", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
if err := set.t.ExecuteTemplate(w, set.entry, data); err != nil {
log.Printf("渲染 %s 失败: %v", key, err)
}
}
// pageSet 返回模板集;调试模式下每次从磁盘重新解析,改完模板刷新即可见效。
func (s *Server) pageSet(key string) (pageSet, error) {
def, ok := pageFiles[key]
if !ok {
return pageSet{}, fmt.Errorf("未知页面模板 %s", key)
}
if !s.cfg.Debug {
return s.pages[key], nil
}
files := make([]string, len(def.files))
for i, f := range def.files {
// 嵌入路径相对包目录,调试时按工作目录拼接
files[i] = filepath.Join("internal", "web", f)
}
t, err := template.New(filepath.Base(files[0])).Funcs(templateFuncs()).ParseFiles(files...)
if err != nil {
return pageSet{}, err
}
return pageSet{t: t, entry: def.entry}, nil
}
func (s *Server) json(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("JSON 输出失败: %v", err)
}
}
func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("处理 %s 出错: %v", r.URL.Path, err)
http.Error(w, "服务器内部错误: "+err.Error(), http.StatusInternalServerError)
}
func atoiOr(s string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return n
}
func parseInt64s(vals []string) []int64 {
var out []int64
for _, v := range vals {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
out = append(out, n)
}
}
return out
}
// datasetFor 取当前生效的数据集;无数据时返回 nil。
func (s *Server) datasetFor(ctx context.Context) (*Dataset, error) {
var d Dataset
err := s.db.QueryRowContext(ctx, `SELECT id, original_name, uploaded_at, visit_count, place_count,
raw_visit_count, first_visit_at, last_visit_at, size_bytes
FROM datasets WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).
Scan(&d.ID, &d.Name, &d.UploadedAt, &d.Visits, &d.Places, &d.RawVisits, &d.First, &d.Last, &d.Size)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &d, nil
}
type Dataset struct {
ID int64
Name string
UploadedAt time.Time
Visits int
Places int
RawVisits int
First *time.Time
Last *time.Time
Size int64
}
+900
View File
@@ -0,0 +1,900 @@
:root {
--accent: #2f7d6e;
--accent-soft: #e8f3f0;
--bg: #f6f7f8;
--panel: #ffffff;
--line: #e5e7ea;
--line-soft: #f0f1f3;
--text: #1f2328;
--text-2: #5b6169;
/* 次要文字。原来是 #8b9199,对白底只有 3.18:1、对浅灰底 2.81:1,
低于 WCAG AA 的 4.5:1(页面副标题、KPI 标签、筛选计数全用这个色阶)。
改成 #666d75 之后:白底 5.24 / 浅灰底 4.63 / 绿浅底 4.62,
既达标又仍比 --text-2(5.53)浅,层次没丢。 */
--text-3: #666d75;
/* 会随明暗切换的「面」色,抽出来暗色只需换这一组 */
--surface: #ffffff; /* 输入框 / 按钮 / 浮层的底 */
--glass: rgba(255, 255, 255, .92); /* 顶栏毛玻璃 */
--glass-strong: rgba(255, 255, 255, .97); /* 地图面板 / 图例 */
--row-hover: #fbfcfc;
--bg-2: #fafbfb; /* 比卡片略深一层的面(行内编辑表单、空态框) */
--on-accent: #ffffff; /* 主色底上的文字 */
--ok-bg: #eef8f5; --ok-fg: #17604f; --ok-line: #cfe6e0;
--err-bg: #fdf3f2; --err-fg: #b42318; --err-line: #f3dcda;
--info-bg: #f2f6fd; --info-fg: #2c5282; --info-line: #dbe5f5;
--radius: 10px;
--shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 1px 3px rgba(16, 24, 40, .06);
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 顶栏高度:窄屏更矮。地图页的定位跟它对齐,避免出现几像素的缝。 */
--nav-h: 54px;
/* 刘海屏 / 底部 Home 指示条的安全区。不支持 env() 的浏览器回落到 0。 */
--safe-t: env(safe-area-inset-top, 0px);
--safe-r: env(safe-area-inset-right, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
--safe-l: env(safe-area-inset-left, 0px);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
/* ---------- 顶栏 ---------- */
.nav {
position: sticky; top: 0; z-index: 500;
background: var(--glass);
backdrop-filter: saturate(1.6) blur(10px);
border-bottom: 1px solid var(--line);
/* 横屏时刘海在左右,竖屏时状态栏在顶部 */
padding: var(--safe-t) var(--safe-r) 0 var(--safe-l);
}
.nav-inner {
max-width: 1120px; margin: 0 auto; padding: 0 20px;
height: var(--nav-h); display: flex; align-items: center; gap: 22px;
}
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text); font-size: 15px; }
.brand:hover { text-decoration: none; }
.brand .avatar { font-size: 18px; }
.nav-links { display: flex; gap: 4px; flex: 1; }
.nav-links a {
padding: 5px 11px; border-radius: 7px; color: var(--text-2); font-size: 13.5px; white-space: nowrap;
}
.nav-links a:hover { background: var(--line-soft); color: var(--text); text-decoration: none; }
.nav-links a.on { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
.nav-right { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--text-3); }
/* ---------- 布局 ---------- */
.wrap { max-width: 1120px; margin: 0 auto;
padding: 22px calc(20px + var(--safe-r)) calc(60px + var(--safe-b)) calc(20px + var(--safe-l)); }
.wrap-wide { max-width: 100%; padding: 0; }
.page-head { margin-bottom: 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.page-head h1 { font-size: 21px; }
.page-head .sub { color: var(--text-3); font-size: 13px; margin-top: 3px; }
.card {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 18px; margin-bottom: 16px;
}
.card > h2 { font-size: 15px; margin-bottom: 2px; display: flex; align-items: center; gap: 8px; }
/* .card-body 是数据编辑页折叠用的容器,卡片内容被它包住后不再是 .card 的直接子元素,
所以下面两条用「后代」而不是「子」选择器——否则折叠脚本一加上,间距就全没了。 */
.card .card-sub { color: var(--text-3); font-size: 12.5px; margin-bottom: 14px; }
.card.collapsed .card-body { display: none; }
/* 只有带 data-card 的卡片(数据编辑页)能折叠:标题整行可点,右侧一个箭头 */
.card[data-card] > h2 { cursor: pointer; }
.card[data-card] > h2 .card-toggle {
margin-left: auto; flex: 0 0 auto; cursor: pointer;
background: none; border: 0; padding: 0 2px; color: var(--text-3);
font: inherit; font-size: 12px; line-height: 1;
}
.card[data-card] > h2 .card-toggle:hover { color: var(--text); }
.grid { display: grid; gap: 16px; }
/* grid 子项默认 min-width:auto(= 不小于自身 min-content),而卡片里常常放着
带 min-width 的表格——不加这条,卡片会拒绝收缩、把**整页**顶宽,出现横向滚动条
(/stats、/report 在手机上整页 692px 就是这么来的)。压到 0 之后由卡片内部的
.tw 自己横向滚动。 */
.grid > * { min-width: 0; }
.g2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.g3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.g4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 900px) { .g3, .g4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) {
:root { --nav-h: 50px; }
.g2, .g3, .g4 { grid-template-columns: 1fr; }
.wrap { padding: 16px calc(14px + var(--safe-r)) calc(48px + var(--safe-b)) calc(14px + var(--safe-l)); }
.nav-inner { padding: 0 10px; gap: 6px; }
/* 窄屏只留头像,导航可横向滚动,避免换行把布局撑坏 */
.brand .t { display: none; }
.nav-links { overflow-x: auto; scrollbar-width: none; gap: 0; }
.nav-links::-webkit-scrollbar { display: none; }
.nav-links a { padding: 5px 7px; font-size: 13px; }
.nav-right .who { display: none; }
.nav-right .btn.sm { padding: 4px 9px; font-size: 12.5px; }
}
/* ---------- 窄屏触控与表单(移动端适配) ---------- */
/* 手指的落点比鼠标粗得多:导航链接原本只有 40×31、筛选标签 59×28、小按钮 45×30,
都低于 44×44 的通行下限,最容易点的就是它们。这里只放宽尺寸与内边距,不动结构。 */
@media (max-width: 620px) {
.nav-links a, .btn, .btn.sm, .chip { min-height: 44px; }
.nav-links a { display: inline-flex; align-items: center; padding: 8px 9px; }
.chip { padding: 9px 12px; }
.btn, .btn.sm { padding: 10px 14px; }
.check input { width: 20px; height: 20px; }
/* iOS 聚焦字号小于 16px 的控件时会自动放大整页——用户点完输入框页面突然变大,
还要手动缩回去。窄屏统一提到 16px 规避(桌面端不受影响,保持紧凑)。 */
:root { --field-fs: 16px; }
/* 表格里的控件仍然紧凑:后台那张表列很多,16px 会把列撑得很难用。
表格里横向可滚,iOS 缩放的影响也小。 */
.photo-table, table.tb { --field-fs: 14px; }
}
.only-sm { display: none; }
@media (max-width: 620px) {
.hide-sm { display: none; }
.only-sm { display: inline; }
/* 窄屏用地势与手势缩放,把控件让给底部 */
.map-panel { top: 8px; }
}
/* ---------- 统计数字 ---------- */
/* grid-auto-rows: 1fr 让所有格子等高:否则某一格的值换行时,那一整行都比别行高 */
/* 用 flex + grow 而不是固定列数的网格:KPI 个数常常是 6、7 这种除不尽列数的,
网格会在最后一行留下空格子——而间隙是用底色画的,空格子就露成灰块,很难看。
flex 会让最后一行自己撑满,任何个数都不会有空格。 */
.kpis { display: flex; flex-wrap: wrap; gap: 1px; background: var(--line); border-radius: var(--radius); overflow: hidden; border: 1px solid var(--line); }
@media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.kpi { flex: 1 1 158px; min-width: 0; background: var(--panel); padding: 15px 16px; }
/* 字号跟着视口收:窄屏上「112天19小时」这种值放不下就会折成两行,把整行撑高 */
.kpi .v { font-size: clamp(17px, 5.6vw, 23px); font-weight: 650; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.kpi .v small { font-size: 13px; font-weight: 500; color: var(--text-3); margin-left: 3px; }
.kpi .k { color: var(--text-3); font-size: 12.5px; margin-top: 1px; }
/* ---------- 图表(纯 CSS/SVG) ---------- */
.bars { display: flex; flex-direction: column; gap: 9px; }
.bar-row { display: grid; grid-template-columns: 128px 1fr 104px; align-items: center; gap: 10px; font-size: 13px; }
/* 整行可点(下钻为筛选):给一点悬停反馈,并让点击区域覆盖整行;
柱条本身只有 9px 高,靠「行」来接收点击才够手指点 */
a.bar-row { margin: 0 -6px; padding: 4px 6px; border-radius: 6px; color: inherit; text-decoration: none; }
a.bar-row:hover { background: var(--line-soft); text-decoration: none; }
a.col { display: block; text-decoration: none; }
a.col:hover { opacity: 1; filter: brightness(1.12); }
.bar-row .nm { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-track { background: var(--line-soft); border-radius: 5px; height: 9px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 5px; background: var(--accent); min-width: 2px; }
.bar-row .vv { text-align: right; color: var(--text-2); font-variant-numeric: tabular-nums; font-size: 12.5px; white-space: nowrap; }
@media (max-width: 620px) {
.bar-row { grid-template-columns: 84px 1fr 92px; }
/* 图表行在手机上要能点中:整行 ~37px。再往上加(44px)会把「类型分布」这种
60 多行的图撑到两倍高,翻起来更难受,这里取个折中。 */
a.bar-row { padding: 8px 6px; }
}
.cols { display: flex; align-items: flex-end; gap: 3px; height: 120px; }
.cols .col { flex: 1; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; opacity: .85; }
.cols .col:hover { opacity: 1; }
.cols-x { display: flex; gap: 3px; font-size: 10px; color: var(--text-3); margin-top: 5px; }
.cols-x span { flex: 1; text-align: center; overflow: hidden; white-space: nowrap; }
/* ---------- 逐月气温 ---------- */
.temp-chart { margin-top: 16px; }
.temp-head { font-size: 12.5px; color: var(--text-3); margin-bottom: 8px; }
/* 横向可滚动:柱子固定宽,十几二十个月挤在窄屏上也不会把标签压成一团 */
.temp-strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 4px; }
.temp-col { flex: 0 0 36px; display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: default; }
.temp-col .tc-v { font-size: 10.5px; color: var(--text-2); font-variant-numeric: tabular-nums; }
.temp-col .tc-track { width: 100%; height: 84px; display: flex; align-items: flex-end; }
.temp-col .tc-bar { width: 100%; border-radius: 4px 4px 0 0; min-height: 3px; }
.temp-col .tc-m { font-size: 10.5px; color: var(--text-3); white-space: nowrap; }
.temp-col:hover .tc-bar { filter: brightness(1.08); }
.temp-col:hover .tc-v { color: var(--text); }
/* ---------- 日历热力 ---------- */
.heat { display: flex; gap: 3px; overflow-x: auto; padding-bottom: 4px; }
.heat .wk { display: flex; flex-direction: column; gap: 3px; }
.heat .d { width: 11px; height: 11px; border-radius: 2.5px; background: var(--line-soft); }
.heat .d.l1 { background: #cfe6e0; }
.heat .d.l2 { background: #9dcfc3; }
.heat .d.l3 { background: #5fae9c; }
.heat .d.l4 { background: var(--accent); }
.heat-legend { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--text-3); margin-top: 8px; }
.heat-legend .d { width: 11px; height: 11px; border-radius: 2.5px; }
/* ---------- 列表 ---------- */
.list { display: flex; flex-direction: column; }
.item {
display: grid; grid-template-columns: 40px 1fr auto; gap: 12px; align-items: center;
padding: 11px 2px; border-bottom: 1px solid var(--line-soft);
}
.item:last-child { border-bottom: 0; }
.item .ic {
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center; justify-content: center;
background: var(--line-soft); font-size: 17px;
}
.item .t { font-weight: 500; }
.item .t a { color: var(--text); }
.item .m { color: var(--text-3); font-size: 12.5px; margin-top: 1px; }
.item .r { text-align: right; color: var(--text-2); font-size: 12.5px; white-space: nowrap; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 20px;
background: var(--line-soft); color: var(--text-2); font-size: 12.5px; border: 1px solid transparent;
}
.chip.on { background: var(--accent-soft); color: var(--accent); border-color: #cfe6e0; font-weight: 500; }
.chip .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 5px; background: var(--accent-soft); color: var(--accent); font-size: 11.5px; }
/* ---------- 表单 ---------- */
label.f { display: block; margin-bottom: 13px; }
label.f > span { display: block; font-size: 12.5px; color: var(--text-2); margin-bottom: 5px; }
/* 必填标记:字段名后的红色星号,以及表单顶部的统一说明 */
.req { color: #b42318; font-weight: 700; margin-left: 2px; }
.req-tip { font-size: 12.5px; color: var(--text-2); margin: 0 0 10px; }
.req-tip .req { margin-right: 3px; }
/* flatpickr 日期时间选择器:贴合站点风格 */
.flatpickr-calendar { border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.14); font-family: inherit; }
.flatpickr-day.selected, .flatpickr-day.selected:hover { background: var(--accent); border-color: var(--accent); }
.flatpickr-day.today { border-color: var(--accent); }
.flatpickr-day.today:hover { background: var(--accent-soft); color: var(--text); }
.flatpickr-day:hover { background: var(--accent-soft); }
/* 控件字号走变量:窄屏要整体提到 16px(iOS 聚焦小于 16px 会自动放大整页),
而基础选择器 input[type=…], select, textarea 的特异性高于媒体查询里的单选择器,
直接在里面写 font-size 是压不过的——用可继承的自定义属性就没有这个问题。 */
/* 按「排除掉不该套文本框样式的类型」来写,而不是逐个枚举 text/number/date——
枚举漏了 datetime-local(后台编辑页有 160 多个),那些框就一直是浏览器默认外观:
没有圆角、没有内边距、13.33px 的字,跟旁边的输入框明显不是一套。 */
input:not([type=checkbox]):not([type=radio]):not([type=color]):not([type=range]):not([type=file]):not([type=submit]):not([type=button]):not([type=reset]):not([type=image]):not([type=hidden]),
select, textarea {
width: 100%; padding: 7px 10px; border: 1px solid var(--line); border-radius: 7px; font: inherit;
font-size: var(--field-fs, inherit);
background: var(--surface); color: var(--text); outline: none;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.check { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--text-2); margin-bottom: 8px; word-break: keep-all; overflow-wrap: break-word; }
.check input { width: 15px; height: 15px; accent-color: var(--accent); }
.nobr { white-space: nowrap; }
.btn {
display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 7px;
border: 1px solid var(--line); background: var(--surface); color: var(--text); font: inherit; font-size: 13.5px; cursor: pointer;
}
.btn:hover { background: var(--line-soft); text-decoration: none; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.btn.primary:hover { filter: brightness(1.06); }
.btn.sm { padding: 4px 10px; font-size: 12.5px; }
.btn.danger { color: #b42318; border-color: #f0cecb; }
.btn.danger:hover { background: var(--err-bg); }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; }
/* ---------- 表格 ---------- */
table.tb { width: 100%; border-collapse: collapse; font-size: 13px; }
table.tb th {
text-align: left; font-weight: 500; color: var(--text-3); font-size: 12px;
padding: 7px 10px; border-bottom: 1px solid var(--line); white-space: nowrap;
}
table.tb td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); }
table.tb tr:hover td { background: var(--row-hover); }
table.tb td.num, table.tb th.num { text-align: right; font-variant-numeric: tabular-nums; }
.tw { overflow-x: auto; }
/* 表格要给个内容下限:table 是 width:100%,而 CJK 允许逐字断行,没有下限时窄屏会把
每列压到一两个字宽(「一般公共场所」会排成 6 行、一行一个字)。有了下限,放不下就
交给上面的 .tw 横向滚动,列宽不再被榨干。 */
.tw > table.tb { min-width: 640px; }
/* 从地点详情页跳进来定位到的那一行(?open=):淡色底,长列表里一眼能认出目标。
用底色而不是动画——动画会被 prefers-reduced-motion 跳过,那时就完全看不出来了。 */
table.tb tr.located td, table.tb tr.located:hover td { background: var(--accent-soft); }
/* ---------- 地图 ---------- */
#map { height: 100%; width: 100%; background: #eef1f2; }
.map-page {
position: fixed;
inset: calc(var(--nav-h) + var(--safe-t)) var(--safe-r) var(--safe-b) var(--safe-l);
}
.map-panel {
position: absolute; top: 12px; left: 12px; z-index: 400; width: 286px; max-height: calc(100% - 24px);
overflow-y: auto; background: var(--glass-strong); backdrop-filter: blur(8px);
border: 1px solid var(--line); border-radius: var(--radius); padding: 13px; box-shadow: var(--shadow);
}
.map-panel h3 { font-size: 13px; margin-bottom: 9px; color: var(--text-2); }
.map-legend {
/* bottom 让开右下角那行底图版权(约 15px 高),否则两者会叠在一起 */
position: absolute; bottom: 26px; right: 12px; z-index: 400; background: var(--glass-strong);
border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px; font-size: 12px;
max-height: 40%; overflow-y: auto; box-shadow: var(--shadow);
}
.map-legend .lg { display: flex; align-items: center; gap: 6px; margin-top: 4px; color: var(--text-2); }
.map-legend .lg i { width: 9px; height: 9px; border-radius: 50%; }
.maplink { display: block; padding: 8px 0; border-bottom: 1px solid var(--line-soft); }
.maplink b { font-weight: 500; }
.maplink span { color: var(--text-3); font-size: 12px; display: block; }
.dot-marker { border-radius: 50%; border: 1.5px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.16); }
/* 迷雾浓度的样式从内联挪出来:窄屏要把它收成一个可展开的小标签——
不然「图层 / 迷雾浓度 / 缩放」三个控件竖着堆在右下角,占掉近 180px。 */
.fog-opacity { background: var(--surface); padding: 6px 8px; line-height: 1.2; }
.fog-opacity .fog-label {
display: block; text-align: left; border: 0; background: none; padding: 0;
font: inherit; font-size: 11px; color: var(--text-2); cursor: default;
}
.fog-opacity .fog-slider { display: block; width: 96px; margin: 4px 0 0; accent-color: var(--accent); }
@media (max-width: 620px) {
.fog-opacity .fog-label { cursor: pointer; font-size: 12px; }
.fog-opacity .fog-slider { display: none; }
.fog-opacity.open .fog-slider { display: block; }
}
/* 长列表划到底后的「回到顶部」。地图页不滚动,所以它不会出现。 */
.to-top {
position: fixed; right: calc(16px + var(--safe-r)); bottom: calc(20px + var(--safe-b)); z-index: 900;
width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--line);
background: var(--panel); color: var(--text-2); font-size: 20px; line-height: 1;
cursor: pointer; box-shadow: var(--shadow);
}
.to-top[hidden] { display: none; }
.to-top:hover { color: var(--text); border-color: var(--text-3); }
/* 时间轴回放时新出现的点:冒出来 + 淡入(配合 JS 里的逐个延时,像"扫过来")。
点在 Leaflet 里是 overlay pane 里的 <path>,用 transform-box: fill-box 让缩放以自身
中心为原点,才不会跑到左上角去。 */
@media (prefers-reduced-motion: no-preference) {
@keyframes pt-in {
from { opacity: .1; transform: scale(.2); }
55% { opacity: 1; }
to { opacity: .9; transform: scale(1); }
}
.leaflet-overlay-pane path.pt-new {
animation: pt-in .55s cubic-bezier(.22, .9, .28, 1) both;
transform-box: fill-box;
transform-origin: center;
}
/* 聚合圆圈是 DOM 元素、而且 Leaflet 用 transform 给它定位,
所以只能动透明度 —— 再加 scale 会把它从原位挪走。 */
@keyframes pt-in-dom {
from { opacity: .1; }
60% { opacity: 1; }
to { opacity: .92; }
}
.leaflet-marker-pane .marker-cluster.pt-new {
animation: pt-in-dom .5s ease-out both;
}
/* 数字变了轻弹一下:弹内层 div(只放数字),外层带 Leaflet 的定位 transform 不能动 */
@keyframes pt-pulse {
0% { transform: scale(1); }
45% { transform: scale(1.3); }
100% { transform: scale(1); }
}
.leaflet-marker-pane .marker-cluster > div.pt-pulse {
animation: pt-pulse .32s ease-out;
}
}
/* 时间轴回放:地图底部中间的一条。窄屏上抬高一档,给右下角的底图版权让位。
和左侧的日期区间不是一回事——区间是「只看这段时间」,这里是「走到这一刻为止」。 */
.timeline-bar {
position: absolute; left: 50%; transform: translateX(-50%);
bottom: 12px; z-index: 650;
display: flex; align-items: center; gap: 10px;
max-width: min(560px, calc(100% - 24px));
padding: 7px 12px; border-radius: 10px;
background: var(--glass-strong); border: 1px solid var(--line); box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.timeline-bar[hidden] { display: none; }
.timeline-bar input[type=range] { flex: 1 1 auto; min-width: 90px; accent-color: var(--accent); margin: 0; }
.timeline-bar .tl-label { flex: 0 0 auto; font-size: 12px; color: var(--text-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
.timeline-bar button {
flex: 0 0 auto; border: 1px solid var(--line); background: var(--surface); color: var(--text);
border-radius: 7px; cursor: pointer; font: inherit; font-size: 12px; line-height: 1;
}
.timeline-bar .tl-play { width: 30px; height: 30px; font-size: 13px; padding: 0; }
.timeline-bar .tl-reset { padding: 7px 9px; }
.timeline-bar button:hover { background: var(--line-soft); }
@media (max-width: 620px) {
.timeline-bar { bottom: 26px; gap: 8px; padding: 6px 9px; }
.timeline-bar .tl-label { font-size: 11.5px; }
}
/* 地图上的状态条(加载失败 + 重试)。放底部中间:左上角是筛选面板、右下是图例与版权。 */
.map-status {
position: absolute; left: 50%; transform: translateX(-50%);
bottom: 62px;
z-index: 600; display: flex; align-items: center; gap: 10px;
max-width: calc(100% - 24px); background: rgba(30,40,50,.92); color: #fff;
border-radius: 8px; padding: 8px 12px; font-size: 13px; box-shadow: var(--shadow);
}
.map-status[hidden] { display: none; }
.map-status .btn { border-color: rgba(255,255,255,.35); background: rgba(255,255,255,.12); color: #fff; flex: 0 0 auto; }
.map-status .btn:hover { background: rgba(255,255,255,.22); }
@media (max-width: 620px) {
.map-panel { left: 8px; right: 8px; width: auto; max-height: 44%; top: 8px; }
.map-legend { display: none; }
}
.map-preview { height: 320px; border-radius: 8px; overflow: hidden; border: 1px solid var(--line); }
/* ---------- 地点图片 ---------- */
/* 管理面板里的缩略图墙 */
.imgs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.imgs-cell { position: relative; margin: 0; border-radius: 8px; overflow: hidden; border: 1px solid var(--line); background: var(--line-soft); aspect-ratio: 4 / 3; }
.imgs-cell img { width: 100%; height: 100%; object-fit: cover; display: block; cursor: zoom-in; }
.imgs-del {
position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; border-radius: 50%;
border: 0; background: rgba(0,0,0,.55); color: #fff; font-size: 15px; line-height: 1;
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.imgs-del:hover { background: #b42318; }
.imgs-busy { opacity: .45; pointer-events: none; }
.imgs-hint { font-size: 12.5px; color: var(--text-3); margin-top: 8px; min-height: 18px; }
.imgs-hint.ok { color: #17604f; }
.imgs-hint.err { color: #b42318; }
/* 存储没配好时把「选择图片」变成不可点的样子,而不是留个能点但必失败的按钮 */
.btn.is-disabled { opacity: .5; cursor: not-allowed; }
/* 展示用:全屏画廊。深色底是为了不干扰照片本身,白底会把照片衬灰。
一次只看一张 —— 以前把图竖着堆起来,手机上既不能左右滑也不能放大。 */
.imgs-modal { position: fixed; inset: 0; z-index: 2000; background: rgba(12,14,16,.94); overflow: hidden; }
.imgs-modal[hidden] { display: none; }
.imgs-stage {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
overflow: auto; padding: calc(56px + var(--safe-t)) calc(14px + var(--safe-r)) calc(24px + var(--safe-b)) calc(14px + var(--safe-l));
/* 横滑留给我们做切换,纵向留给浏览器;pinch-zoom 让双指放大仍可用系统手势 */
touch-action: pan-y pinch-zoom;
}
.imgs-stage img { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; border-radius: 8px; }
/* 双击放大:图放大到两倍、容器可拖动看局部(用尺寸而不是 transform,放大后不糊) */
.imgs-stage.zoomed { touch-action: pan-x pan-y; align-items: flex-start; justify-content: flex-start; }
.imgs-stage.zoomed img { max-width: none; max-height: none; width: 200%; border-radius: 0; }
.imgs-close {
position: fixed; top: calc(12px + var(--safe-t)); right: calc(14px + var(--safe-r)); z-index: 2001;
width: 44px; height: 44px; border-radius: 50%;
border: 1px solid rgba(255,255,255,.3); background: rgba(0,0,0,.4); color: #fff; font-size: 22px;
line-height: 1; cursor: pointer;
}
.imgs-close:hover { background: rgba(255,255,255,.18); }
.imgs-count {
position: fixed; top: calc(20px + var(--safe-t)); left: calc(18px + var(--safe-l)); z-index: 2001;
color: rgba(255,255,255,.78); font-size: 13px;
}
/* 上一张 / 下一张:做成整条竖带,拇指够得着,不用瞄小按钮 */
.imgs-nav {
position: fixed; top: 50%; transform: translateY(-50%); z-index: 2001;
width: 44px; height: 76px; border: 0; border-radius: 10px; cursor: pointer;
background: rgba(255,255,255,.12); color: #fff; font-size: 30px; line-height: 1;
}
.imgs-nav:hover { background: rgba(255,255,255,.24); }
.imgs-prev { left: calc(6px + var(--safe-l)); }
.imgs-next { right: calc(6px + var(--safe-r)); }
/* ---------- 「停一下」提示页(内容对访客不开放之类) ---------- */
.notice-body {
min-height: 100vh; margin: 0; display: flex; align-items: center; justify-content: center;
padding: 24px 18px; background: var(--bg);
}
.notice-card {
max-width: 420px; text-align: center; background: var(--panel); border: 1px solid var(--line);
border-radius: var(--radius); padding: 30px 26px; box-shadow: var(--shadow);
}
.notice-card .notice-ico { font-size: 40px; line-height: 1; margin-bottom: 12px; }
.notice-card h1 { font-size: 18px; margin: 0 0 8px; }
.notice-card p { color: var(--text-2); font-size: 13.5px; margin: 0 0 18px; }
.notice-card .notice-tip { color: var(--text-3); font-size: 12px; margin-top: 12px; }
/* 后台设置页的「标签 / 值」信息行(地图底图、图片存储状态)。
这个 class 一直没写样式,之前是两段光秃秃的 inline 文本;长一点的 URL
(比如对象存储的 endpoint)会把整页撑宽 —— 所以要 minmax(0,1fr) + 允许断词。 */
.kv { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 4px 12px; font-size: 13px; }
.kv .k { color: var(--text-3); }
.kv .v { color: var(--text); overflow-wrap: anywhere; }
@media (max-width: 620px) {
.kv { grid-template-columns: 1fr; gap: 2px; }
.kv .k { font-size: 12px; }
}
/* URL 这类超长 token 不该把容器顶宽 */
.mono { overflow-wrap: anywhere; }
/* ---------- 暗色模式 ----------
跟随系统(prefers-color-scheme)。配色本来就是变量驱动的,所以这里主要换一套变量,
再覆盖几处硬编码的白色面。地图瓦片保持浅色——上游只有这一套免密钥瓦片,
所以地图页只暗掉面板与控件,瓦片不动。
色值是算过的:主文字 13.6:1、次要 8.0:1、弱化 5.4:1(都在 AA 之上)。 */
@media (prefers-color-scheme: dark) {
/* 用 html:root 而不是 :root:模板会在 CSS 之后注入一条 :root{--accent:…}(站长自定义主色),
同特异性下后者胜出,所以这里必须高一级才盖得住。 */
html:root {
/* 站长选的主色原值在深底上往往太暗(深绿只有 3.3:1),按比例掺白提亮 */
--accent: color-mix(in srgb, var(--accent-base, #2f7d6e) 58%, #ffffff);
--accent-soft: color-mix(in srgb, var(--accent-base, #2f7d6e) 24%, #1c2024);
--bg: #14171a;
--panel: #1c2024;
--line: #2b3137;
--line-soft: #23272c;
--text: #e8eaed;
--text-2: #b0b6bd;
--text-3: #99a0a8;
--surface: #22262b;
--glass: rgba(28, 32, 36, .92);
--glass-strong: rgba(28, 32, 36, .96);
--row-hover: #212529;
--bg-2: #191d21;
/* 浅绿底上用白字只有 2.4:1,反过来用深字才够 */
--on-accent: #0f1b18;
--ok-bg: #16302a; --ok-fg: #7fd3bb; --ok-line: #245046;
--err-bg: #33201e; --err-fg: #f0a49b; --err-line: #5a3330;
--info-bg: #1b2534; --info-fg: #9dbdea; --info-line: #2f4055;
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .35);
}
/* 兜底:还有两处没走变量的白底 */
.flatpickr-calendar { background: var(--panel); border-color: var(--line); box-shadow: var(--shadow); }
.flatpickr-day { color: var(--text); }
.flatpickr-day.prevMonthDay, .flatpickr-day.nextMonthDay { color: var(--text-3); }
.flatpickr-day:hover { background: var(--line-soft); color: var(--text); }
.flatpickr-months .flatpickr-month, .flatpickr-weekdays, .flatpickr-weekday { color: var(--text-2); fill: var(--text-2); background: transparent; }
.flatpickr-current-month .flatpickr-monthDropdown-months { background: var(--panel); color: var(--text); }
.flatpickr-time input, .flatpickr-time .flatpickr-am-pm { color: var(--text); }
/* Leaflet 自带的控件是白底,暗色下要跟着换,否则浅字压浅底看不清。
注意 leaflet.css 里角标的背景写的是 `.leaflet-container .leaflet-control-attribution`
(两级),所以这里必须带上 .leaflet-container 前缀,否则盖不住。 */
.leaflet-container .leaflet-bar a,
.leaflet-container .leaflet-bar a:hover,
.leaflet-container .leaflet-control-layers,
.leaflet-container .leaflet-control-layers-expanded,
.leaflet-container .leaflet-control-attribution,
.fog-opacity {
background: var(--surface); color: var(--text);
}
.leaflet-container .leaflet-bar a { border-bottom-color: var(--line); }
.leaflet-container .leaflet-bar a:hover { background: var(--line-soft); }
.leaflet-container .leaflet-control-attribution,
.leaflet-container .leaflet-control-attribution a { color: var(--text-2); }
.leaflet-control-scale-line {
background: rgba(28, 32, 36, .7); border-color: var(--text-3); color: var(--text-2);
}
.leaflet-container { background: #101315; }
/* 地图上的浅色瓦片在暗色 UI 里反差过大,整体压一点点亮度 */
#map .leaflet-tile-pane { filter: brightness(.86) saturate(.9); }
}
/* ---------- 键盘焦点 ----------
全站原来没有任何 :focus-visible 规则:用 Tab 走查时看不出焦点落在哪,
键盘用户(以及用读屏的)基本没法用。只在键盘操作时出现,鼠标点击不会看到这个圈。 */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn:focus-visible, .chip:focus-visible, .vote-btn:focus-visible,
.nav-links a:focus-visible, .pager a:focus-visible, .imgs-nav:focus-visible, .to-top:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
/* 输入框本来就有 focus 描边,这里只补键盘态的对比度 */
input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible {
outline: 2px solid var(--accent); outline-offset: 1px;
}
/* ---------- 顶部加载进度条 ---------- */
.load-bar {
position: fixed; top: 0; left: 0; height: 2px; width: 0; z-index: 3000;
background: var(--accent); opacity: 0;
transition: width .18s ease-out, opacity .26s ease;
}
.load-bar[hidden] { display: none; }
/* ---------- 「已选条件」行 ---------- */
.active-filters { margin-bottom: 12px; align-items: center; }
.active-filters .af-label { font-size: 12.5px; color: var(--text-3); }
.active-filters .chip { user-select: none; }
.active-filters .af-x { margin-left: 4px; opacity: .6; font-size: 13px; }
.active-filters .chip:hover .af-x { opacity: 1; }
.active-filters .af-clear { background: transparent; color: var(--text-3); border-color: var(--line); }
/* ---------- 其他 ---------- */
.alert { padding: 9px 13px; border-radius: 8px; font-size: 13px; margin-bottom: 14px; }
.alert.err { background: var(--err-bg); color: var(--err-fg); border: 1px solid var(--err-line); }
.alert.ok { background: var(--ok-bg); color: var(--ok-fg); border: 1px solid var(--ok-line); }
.alert.info { background: var(--info-bg); color: var(--info-fg); border: 1px solid var(--info-line); }
.muted { color: var(--text-3); }
.mono { font-family: var(--mono); font-size: 12px; }
.empty { text-align: center; padding: 40px 16px; color: var(--text-3); }
.empty .big { font-size: 30px; margin-bottom: 8px; }
.pager { display: flex; gap: 6px; align-items: center; justify-content: center; margin-top: 18px; font-size: 13px; }
.pager a, .pager span { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); color: var(--text-2); }
.pager a:hover { background: var(--line-soft); text-decoration: none; }
.pager .cur { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
footer.ft { max-width: 1120px; margin: 0 auto; padding: 20px; color: var(--text-3); font-size: 12.5px; text-align: center; }
.timeline-day { margin-bottom: 20px; }
.timeline-day .dh {
position: sticky; top: 54px; background: var(--bg); z-index: 5; padding: 6px 0 7px;
font-size: 13px; color: var(--text-2); font-weight: 500; display: flex; gap: 8px; align-items: baseline;
}
.timeline-day .dh .muted { font-size: 12px; }
.tl-item { display: grid; grid-template-columns: 62px 20px 1fr; gap: 10px; align-items: start; padding: 7px 0; }
.tl-item .tm { color: var(--text-3); font-size: 12.5px; font-variant-numeric: tabular-nums; padding-top: 1px; }
.tl-item .ln { display: flex; justify-content: center; position: relative; }
.tl-item .ln::before { content: ""; position: absolute; top: 12px; bottom: -12px; width: 1px; background: var(--line); }
.tl-item:last-child .ln::before { display: none; }
.tl-item .ln i { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); margin-top: 5px; position: relative; z-index: 1; }
.tl-item .bd { font-size: 13.5px; }
.tl-item .bd .meta { color: var(--text-3); font-size: 12.5px; }
/* 聚合气泡改用主题色,默认的黄蓝配色和站点风格不一致 */
.marker-cluster-small,
.marker-cluster-medium,
.marker-cluster-large { background-color: color-mix(in srgb, var(--accent) 22%, transparent); }
.marker-cluster-small div,
.marker-cluster-medium div,
.marker-cluster-large div { background-color: var(--accent); color: var(--on-accent); font-weight: 500; }
/* 筛选面板占据左上角,把缩放控件挪到右上与图层切换并列 */
.map-panel { left: 12px; }
@media (max-width: 620px) { .map-panel { left: 8px; right: 8px; } }
/* 更多筛选类型 */
.more-types { margin: 2px 0 10px; }
.more-types summary { font-size: 12.5px; color: var(--text-3); cursor: pointer; padding: 3px 0; }
.more-types summary:hover { color: var(--accent); }
.more-types .chips { margin-top: 8px; }
@media (max-width: 620px) {
.leaflet-bottom.leaflet-right .leaflet-control-zoom { margin-bottom: 34px; }
}
/* 推荐 / 踩雷 表态按钮 */
.vote-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.vote-label { color: var(--text-3); font-size: 12.5px; min-width: 62px; }
/* 「推荐 / 踩雷」必须并排:把它们包成不可换行的组,窄屏时只让后面那句提示换行 */
.vote-btns { display: inline-flex; gap: 8px; flex-wrap: nowrap; }
.vote-btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 14px; border-radius: 999px; white-space: nowrap; flex: 0 0 auto;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
font-size: 13.5px; font-weight: 500; cursor: pointer; line-height: 1;
transition: background .12s, border-color .12s, color .12s;
}
.vote-btn:hover { border-color: var(--accent); color: var(--accent); }
.vote-btn.up.on { background: #e7f6ec; border-color: #34c759; color: #1f8f43; }
.vote-btn.down.on { background: #fdeceb; border-color: #ff3b30; color: #c62a20; }
@media (prefers-color-scheme: dark) {
/* 浅底深字在深色页里太扎眼,换成深底亮字(对比度都在 AA 之上) */
.vote-btn.up.on { background: #16302a; border-color: #34c759; color: #7fd3bb; }
.vote-btn.down.on { background: #33201e; border-color: #ff3b30; color: #f0a49b; }
.vote-tip.up { color: #7fd3bb; }
.vote-tip.down { color: #f0a49b; }
/* 深红字在深底上只有 2.3:1,换成亮红 */
.btn.danger { color: var(--err-fg); border-color: var(--err-line); }
}
.vote-btn:disabled { opacity: .55; cursor: default; }
.vote-tip { font-size: 12.5px; color: var(--text-3); }
.vote-tip.up { color: #1f8f43; }
.vote-tip.down { color: #c62a20; }
/* 站长页面上访客票数只读展示:沿用「我的结论」同一套行结构,尺寸/行距完全一致,只弱化配色 */
.vote-btn.ro {
cursor: default; color: var(--text-2);
border-style: dashed; border-color: var(--line); background: transparent;
}
.vote-btn.ro:hover { border-color: var(--line); color: var(--text-2); }
/* 列表 / 气泡里的推荐踩雷小标签 */
/* 坐标疑似没做过 GCJ-02 偏移的提醒标 */
.tag.warn { background: #fdf3e3; color: #a15c00; }
.tag.vote-up { background: #e7f6ec; color: #1f8f43; }
.tag.vote-down { background: #fdeceb; color: #c62a20; }
/* 地图气泡:地点信息 + 只读备注 + 就地表决 */
.mp { min-width: 240px; max-width: 300px; }
.mp-n { font-weight: 600; margin-bottom: 2px; }
.mp-m { color: var(--text-3); font-size: 12px; line-height: 1.6; }
.mp-note {
margin-top: 6px; padding: 6px 8px; border-radius: 6px;
background: var(--bg, #f5f6f8); color: var(--text);
font-size: 12.5px; line-height: 1.55; white-space: pre-line;
max-height: 150px; overflow-y: auto;
}
.mp-foot { margin-top: 7px; font-size: 12.5px; }
/* nowrap:气泡本来就窄,「👎 踩雷」被挤到第二行会很难看 */
.mp-vote { display: flex; align-items: center; gap: 6px; margin-top: 8px; flex-wrap: nowrap; }
.mp-vl { flex: 0 0 auto; color: var(--text-3); font-size: 12px; white-space: nowrap; }
.mp-vb {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 999px;
border: 1px solid var(--line); background: var(--surface); color: var(--text);
font-size: 12.5px; font-weight: 500; line-height: 1; cursor: pointer;
flex: 0 0 auto; white-space: nowrap;
transition: background .12s, border-color .12s, color .12s;
}
.mp-vb b { font-weight: 600; }
.mp-vb:hover { border-color: var(--accent); color: var(--accent); }
.mp-vb.up.on { background: #e7f6ec; border-color: #34c759; color: #1f8f43; }
.mp-vb.down.on { background: #fdeceb; border-color: #ff3b30; color: #c62a20; }
.mp-vb:disabled { opacity: .55; cursor: default; }
/* 备注块:rond 回填的备注是按次一行的,必须保留换行 */
.note-block {
margin-top: 4px; padding: 7px 9px; border-radius: 8px;
background: var(--bg, #f5f6f8); color: var(--text-2);
font-size: 12.8px; line-height: 1.65; white-space: pre-line;
}
/* 报告导出为 PDF / 长图:隐藏导航、页脚与操作按钮,卡片避免跨页断裂 */
@media print {
.nav, .ft, .no-print { display: none !important; }
body { background: #fff; }
.wrap { max-width: 820px; margin: 0 auto; padding: 0 12px; }
.page-head h1 { margin-top: 8px; }
.card, .kpi, .kpis { break-inside: avoid; }
.grid.g2 { grid-template-columns: 1fr 1fr; }
a { color: inherit; text-decoration: none; }
.bar-fill { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.chip.on { background: var(--accent-soft); color: var(--accent); }
}
/* 数据编辑页:行内编辑用 details 折叠,summary 复用 .btn 外观 */
.inline-edit { display: inline-block; position: relative; }
.inline-edit summary { list-style: none; display: inline-block; }
.inline-edit summary::-webkit-details-marker { display: none; }
.inline-edit[open] { display: block; }
.inline-edit form { background: var(--bg-2, #fafbfb); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
.inline-edit form .f { margin-bottom: 8px; }
/* 「按名称搜坐标」输入框 + 按钮:必须始终同一行(含移动端),底边对齐。
* 不能直接复用 .row —— label.f 自带 margin-bottom,flex-end 按外边距盒对齐会把它顶高;
* 且 .row 会换行。这里显式 nowrap + 归零外边距,并让输入框可收缩。 */
.search-row { display: flex; flex-wrap: nowrap; align-items: flex-end; gap: 8px; margin-bottom: 12px; }
.search-row > label.f { flex: 1 1 auto; min-width: 0; margin-bottom: 0; }
.search-row .btn { flex: 0 0 auto; font-size: 14px; }
.search-row > .act { display: flex; flex: 0 0 auto; gap: 8px; }
/* 按名称搜坐标的候选列表:整行可点,名称与地址分两行 */
.geo-list { display: flex; flex-direction: column; gap: 4px; margin: 0 0 12px; }
/* 到访选择器的候选列表有 max-height(200~240px),但 flex 子项不会因为上限而收缩、
overflow 又是 visible —— 30 条候选会实打实溢出上千像素,被后面的表单元素盖住。
必须给个 overflow 让它在自己的框里滚。 */
.picker-results { overflow-y: auto; }
.geo-list:empty { margin: 0; }
/* 行程列表的路线列:窄屏下每列只剩几十像素,城市名会把行高撑到到访列表的近两倍,
藏掉它(编号 + 地点名够认了,城市在紧邻的到访列表里就有),title 里仍留着全称。 */
@media (max-width: 620px) { .mv-city { display: none; } }
/* 筛选行:和 .row 一样窄屏会换行,但控件按底边对齐。
不能直接用 .row + align-items:flex-end——label.f 自带 margin-bottom,
flex-end 按外边距盒对齐会把按钮顶低几像素(就是那种「按钮有点歪」)。 */
.filter-row { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; }
.filter-row > label.f { margin-bottom: 0; }
.filter-row > .act { display: flex; flex: 0 0 auto; gap: 8px; }
.geo-item {
display: block; width: 100%; text-align: left; cursor: pointer;
padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--panel); color: var(--text); font: inherit;
}
.geo-item:hover { background: var(--line-soft); }
.geo-name { display: block; font-size: 13.5px; }
.geo-addr { display: block; color: var(--text-3); font-size: 12px; margin-top: 2px; }
/* 「同时创建到访」折叠区 */
.opt-visit { margin: 0 0 12px; }
.opt-visit > summary { cursor: pointer; color: var(--text-3); font-size: 12.5px; margin-bottom: 8px; }
.opt-visit .row { margin-bottom: 8px; }
/* ---------- 专题 ---------- */
/* 必须带上 #map:站点里 #map{height:100%} 是 ID 选择器,
单靠 .topic-map 这个类压不过它,容器会被算成 0 高(地图看不见)。 */
#map.topic-map { height: 520px; width: 100%; border-radius: 10px; overflow: hidden; }
@media (max-width: 620px) { #map.topic-map { height: 360px; } }
.topic-card { display: block; text-decoration: none; color: inherit; transition: box-shadow .15s, transform .15s; }
.topic-card:hover { box-shadow: 0 6px 22px rgba(0,0,0,.08); transform: translateY(-1px); }
.topic-head { display: flex; align-items: center; gap: 8px; }
.topic-head h2 { margin: 0; }
.topic-card .kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 620px) { .topic-card .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* 地图兜底提示:脚本没就绪时留在容器里,解释为什么没有地图 */
.map-fallback {
display: flex; align-items: center; justify-content: center; height: 100%;
padding: 20px; text-align: center; color: var(--text-3); font-size: 13px;
}
/* 专题:时间范围独立成行(精确到分钟,塞进 KPI 网格会被挤成多行) */
.topic-range { margin-top: 5px; color: var(--text-2); font-size: 13.5px; font-variant-numeric: tabular-nums; }
.topic-card .topic-range { margin: 7px 0 0; }
.topic-desc { color: var(--text-2); white-space: pre-wrap; line-height: 1.65; }
.kpis.kpis-3 .kpi { flex: 1 1 30%; }
@media (max-width: 620px) {
/* 三个数字都很短,窄屏保持三列比 2+1 更好看 */
.topic-card .kpis.kpis-3 .kpi { flex: 1 1 30%; }
.topic-card .kpi { padding: 11px 12px; }
.topic-range { font-size: 12.5px; }
}
/* ---------- 表单里的「文字 + 内联按钮」组合 ---------- */
/* label 的文字行右侧放次要操作(活动改名/删除),不额外占高度,也就不会把整格撑高 */
label.f > span.span-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.mini-actions { display: flex; gap: 8px; font-size: 12px; }
.mini-actions a { color: var(--text-3); }
.mini-actions a:hover { color: var(--accent); }
.mini-actions a.danger:hover { color: #b42318; }
/* 输入框 + 按钮同一行:input 自适应、按钮不压缩(不设 min-width:0 的话 input 会被按钮顶到下一行) */
.input-btn { display: flex; gap: 6px; }
.input-btn > input { flex: 1 1 auto; min-width: 0; }
.input-btn > .btn { flex: 0 0 auto; padding-left: 12px; padding-right: 12px; }
/* 到访列表里的收藏星标:点一下就切换,不用展开行内编辑 */
.star {
background: none; border: 0; padding: 0 3px; cursor: pointer; line-height: 1;
font-size: 15px; color: var(--text-3); vertical-align: -1px;
}
.star:hover { color: #e8b700; }
.star.on { color: #e8b700; }
/* 单个点位的地图弹窗 */
#point-modal {
position: fixed; inset: 0; z-index: 1200; display: flex; align-items: flex-start;
justify-content: center; padding: 40px 16px; overflow: auto;
background: rgba(0, 0, 0, .45);
}
.point-modal-box {
width: 100%; max-width: 660px; background: var(--panel); border-radius: 12px;
padding: 14px; box-shadow: 0 12px 40px rgba(0, 0, 0, .25);
}
.point-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.point-modal-map { height: 360px; border-radius: 10px; overflow: hidden; }
@media (max-width: 620px) {
#point-modal { padding: 16px 10px; }
.point-modal-map { height: 260px; }
}
/* 表格里的行内操作链接不许折行:中文默认能在任意字间断开,
「地图」「编辑」这类两字链接在窄列里会被拆成两行 */
.tb a { white-space: nowrap; }
/* 照片导入表:固定列宽比例,输入框跟着列宽走。
否则每个 input 自带 min-width,8 列加起来必然超出视口,最右边的「说明」列要横向拖动才看得到 */
.photo-table { table-layout: fixed; width: 100%; min-width: 880px; }
.photo-table th, .photo-table td { padding: 7px 8px; vertical-align: top; }
.photo-table input, .photo-table select { width: 100%; min-width: 0; box-sizing: border-box; }
.photo-table input[type=datetime-local] { font-size: 12.5px; padding: 6px 6px; }
.photo-table input[type=checkbox] { width: auto; }
.photo-table .chip { white-space: nowrap; }
/* 窄屏:9 列在手机上放不下,把信息量最低的「文件」列去掉(地点名已经能认出是哪张),
同时把最小宽度调小,需要横向滑动时也能更快看到最右边的操作 */
@media (max-width: 700px) {
.photo-table { min-width: 640px; }
.photo-table col:nth-child(3),
.photo-table thead th:nth-child(3),
.photo-table tbody tr td:nth-child(3) { display: none; }
}
/* 照片导入:确认表上方的总览图。
做成「粘」的是为了让「鼠标划到某一行 → 图上那个点跳出来」始终看得见——
表格几十行,图一滚出屏幕这个联动就白搭了。粘住时必须自带不透明底,
否则下面滚过去的行会透上来。 */
#map-card {
position: sticky; top: calc(var(--nav-h) + 8px); z-index: 5;
background: var(--panel); padding: 2px 0 10px; margin-bottom: 2px;
border-bottom: 1px solid var(--line-soft);
}
.photo-map {
height: 200px; width: 100%; border-radius: 10px; overflow: hidden;
border: 1px solid var(--line); transition: height .18s ease;
}
/* 粘住以后收矮:200px 的浮层会挡住从下面滚过去的半个屏幕,收到 132px 还能看清
高亮出来的那个点,占的高度也只剩一条。说明文字这时候就没必要留着了。 */
#map-card.stuck .photo-map { height: 132px; }
#map-card.stuck .card-sub { display: none; }
#map-card.stuck { box-shadow: 0 6px 14px rgba(16, 24, 40, .07); }
@media (max-width: 700px) {
.photo-map { height: 170px; }
#map-card.stuck .photo-map { height: 110px; }
}
@media (prefers-reduced-motion: reduce) {
.photo-map { transition: none; }
}
/* 照片导入的进度/结果提示:脚本直接往元素里写文本,空的时候自己收起 */
#parse-status { margin: 12px 0 0; }
#parse-status:empty { display: none; }
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#2f7d6e"/>
<path d="M16 6c-4.4 0-8 3.6-8 8 0 5.6 8 12 8 12s8-6.4 8-12c0-4.4-3.6-8-8-8z" fill="#fff"/>
<circle cx="16" cy="14" r="3.2" fill="#2f7d6e"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+278
View File
@@ -0,0 +1,278 @@
// 浏览器端读取照片的 EXIF(拍摄时间 + GPS),只读文件头附近的一小段,
// 不把整张照片传到服务器——几十张手机照片动辄几百 MB,上传既慢又占磁盘。
//
// 支持的容器:JPEG(APP1 段)与 HEIC/HEIF(ISO BMFF 的 meta box)。
// 算法与 internal/exif 的 Go 实现一致,但这里必须分片读取:
// JPEG 的 APP1 在最前面,通常 < 64KB;
// HEIC 的 Exif item 位置由 iloc 指定(绝对偏移),可能落在文件靠后,
// 所以先读 64KB 解析出偏移,必要时再按需读那一小段。
(function () {
'use strict';
var HEAD_BYTES = 64 * 1024;
function readRange(file, start, end) {
var blob = file.slice(start, Math.min(end, file.size));
return blob.arrayBuffer().then(function (buf) {
return new Uint8Array(buf);
});
}
function str(u8, off, len) {
var s = '';
for (var i = 0; i < len && off + i < u8.length; i++) s += String.fromCharCode(u8[off + i]);
return s;
}
// ---------- TIFF(JPEG 与 HEIC 共用,HEIC 里是 Exif item 的负载) ----------
function readIFD(dv, u8, base, off, le) {
var out = {};
if (off < 0 || base + off + 2 > u8.length) return out;
var n = dv.getUint16(base + off, le);
var p0 = base + off + 2;
if (p0 + n * 12 > u8.length) n = Math.max(0, Math.floor((u8.length - p0) / 12));
for (var i = 0; i < n; i++) {
var p = p0 + i * 12;
out[dv.getUint16(p, le)] = {
type: dv.getUint16(p + 2, le),
count: dv.getUint32(p + 4, le),
valOff: p + 8
};
}
return out;
}
function typeSize(t) {
switch (t) {
case 1: case 2: case 6: case 7: return 1;
case 3: case 8: return 2;
case 4: case 9: case 11: return 4;
case 5: case 10: case 12: return 8;
}
return 1;
}
// ascii 读 ASCII 值:总长 ≤ 4 字节时内联在条目里,否则 valOff 是偏移
function ascii(dv, u8, base, e, le) {
var total = e.count;
if (total <= 0) return '';
var start;
if (total <= 4) {
start = e.valOff;
} else {
start = base + dv.getUint32(e.valOff, le);
}
var out = '';
for (var i = 0; i < total && start + i < u8.length; i++) {
var c = u8[start + i];
if (c === 0) break;
out += String.fromCharCode(c);
}
return out;
}
function rationals(dv, u8, base, e, le, n) {
var off = base + dv.getUint32(e.valOff, le);
if (off < 0 || off + n * 8 > u8.length) return null;
var out = [];
for (var i = 0; i < n; i++) {
var num = dv.getUint32(off + i * 8, le);
var den = dv.getUint32(off + i * 8 + 4, le);
if (den === 0) return null;
out.push(num / den);
}
return out;
}
// gpsCoord 把「度分秒 + 半球」拼成十进制度
function gpsCoord(dv, u8, base, le, gps, refTag, valTag) {
var refE = gps[refTag], valE = gps[valTag];
if (!refE || !valE) return null;
var parts = rationals(dv, u8, base, valE, le, 3);
if (!parts) return null;
var v = parts[0] + parts[1] / 60 + parts[2] / 3600;
var ref = ascii(dv, u8, base, refE, le).toUpperCase();
if (ref === 'S' || ref === 'W') v = -v;
return v;
}
function parseTIFF(u8, base) {
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
if (base + 8 > u8.length) throw new Error('EXIF 数据不完整');
var mark = str(u8, base, 2);
if (mark !== 'II' && mark !== 'MM') throw new Error('EXIF 标识不对');
var le = mark === 'II';
if (dv.getUint16(base + 2, le) !== 0x002A) throw new Error('EXIF 标识不对');
var res = { hasGPS: false, hasTime: false, lat: 0, lon: 0, shot: null };
var ifd0 = readIFD(dv, u8, base, dv.getUint32(base + 4, le), le);
if (ifd0[0x8769]) {
var sub = readIFD(dv, u8, base, dv.getUint32(ifd0[0x8769].valOff, le), le);
if (sub[0x9003]) {
var m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(ascii(dv, u8, base, sub[0x9003], le).trim());
if (m) {
res.shot = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
res.hasTime = true;
}
}
}
if (ifd0[0x8825]) {
var gps = readIFD(dv, u8, base, dv.getUint32(ifd0[0x8825].valOff, le), le);
var la = gpsCoord(dv, u8, base, le, gps, 1, 2);
var lo = gpsCoord(dv, u8, base, le, gps, 3, 4);
if (la !== null && lo !== null && (la !== 0 || lo !== 0)) {
res.lat = la; res.lon = lo; res.hasGPS = true;
}
}
return res;
}
// ---------- JPEG ----------
function parseJPEG(u8) {
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
var i = 2;
while (i + 4 <= u8.length) {
if (u8[i] !== 0xFF) { i++; continue; }
var marker = u8[i + 1];
if (marker === 0xD8 || marker === 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { i += 2; continue; }
if (marker === 0xDA || marker === 0xD9) break; // 图像数据开始
var size = (u8[i + 2] << 8) | u8[i + 3];
if (size < 2 || i + 2 + size > u8.length) break;
if (marker === 0xE1 && size > 8 && str(u8, i + 4, 6) === 'Exif\x00\x00') {
return parseTIFF(u8, i + 10);
}
i += 2 + size;
}
throw new Error('这张 JPEG 里没有 EXIF 信息');
}
// ---------- HEIC / HEIF ----------
function boxHeader(dv, u8, off) {
if (off + 8 > u8.length) return null;
var size = dv.getUint32(off);
var hdr = 8;
if (size === 1) {
if (off + 16 > u8.length) return null;
size = Number(dv.getBigUint64(off + 8));
hdr = 16;
} else if (size === 0) {
size = u8.length - off;
}
if (size < hdr) return null;
// next 允许超出已读范围:大文件的 meta 往往只读到头部,后面的数据按需再取
return { typ: str(u8, off + 4, 4), payload: off + hdr, next: off + size };
}
function findExifItemID(dv, u8, start, end) {
if (start + 4 > u8.length) return 0;
var ver = u8[start];
var p = start + 4;
var count = ver === 0 ? dv.getUint16(p) : dv.getUint32(p);
p += ver === 0 ? 2 : 4;
for (var i = 0; i < count; i++) {
var b = boxHeader(dv, u8, p);
if (!b) break;
if (b.typ === 'infe' && b.payload < u8.length && u8[b.payload] >= 2 && b.payload + 12 <= u8.length) {
if (str(u8, b.payload + 8, 4) === 'Exif') return dv.getUint16(b.payload + 4);
}
p = b.next;
}
return 0;
}
function findItemExtent(dv, u8, start, wantID) {
if (start + 8 > u8.length) return null;
var ver = u8[start];
var p = start + 4;
var offsetSize = u8[p] >> 4, lengthSize = u8[p] & 0x0F;
p++;
var baseSize = u8[p] >> 4, indexSize = ver >= 1 ? (u8[p] & 0x0F) : 0;
p++;
if (!offsetSize || !lengthSize) return null;
var count = dv.getUint16(p);
p += 2;
function readN(n) {
if (n < 0 || p + n > u8.length) return null;
var v = 0;
for (var i = 0; i < n; i++) v = v * 256 + u8[p + i];
p += n;
return v;
}
for (var i = 0; i < count; i++) {
var id = ver === 0 ? readN(2) : readN(4);
if (id === null) return null;
if (ver >= 1 && readN(2) === null) return null;
if (readN(2) === null) return null; // data_reference_index
var base = readN(baseSize);
if (base === null) return null;
var extents = readN(2);
if (extents === null) return null;
for (var e = 0; e < extents; e++) {
if (ver >= 1 && indexSize > 0 && readN(indexSize) === null) return null;
var off = readN(offsetSize), len = readN(lengthSize);
if (off === null || len === null) return null;
if (id === wantID) return { offset: base + off, length: len };
}
}
return null;
}
function parseHEIF(file, u8) {
var dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
var meta = null;
for (var off = 0; off + 8 <= u8.length;) {
var b = boxHeader(dv, u8, off);
if (!b) break;
if (b.typ === 'meta') { meta = b; break; }
off = b.next;
}
if (!meta) throw new Error('HEIC 文件里没有找到 meta');
var start = meta.payload + 4; // meta 是 FullBox
var iinf = null, iloc = null;
for (var o = start; o + 8 <= u8.length && o < meta.next;) {
var sb = boxHeader(dv, u8, o);
if (!sb) break;
if (sb.typ === 'iinf') iinf = sb;
if (sb.typ === 'iloc') iloc = sb;
o = sb.next;
}
if (!iinf || !iloc) throw new Error('HEIC 里缺少 iinf/iloc');
var itemID = findExifItemID(dv, u8, iinf.payload, iinf.next);
if (!itemID) throw new Error('这张 HEIC 里没有 EXIF');
var ext = findItemExtent(dv, u8, iloc.payload, itemID);
if (!ext || ext.length <= 8) throw new Error('HEIC 的 EXIF 位置无效');
function finish(bytes) {
var tiff = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);
if (tiff + 8 > bytes.length) tiff = 4;
return parseTIFF(bytes, tiff);
}
// Exif item 已经读进来了就直接解析,否则按偏移再取那一小段
if (ext.offset >= 0 && ext.offset + ext.length <= u8.length) {
return finish(u8.subarray(ext.offset, ext.offset + ext.length));
}
return readRange(file, ext.offset, ext.offset + ext.length).then(finish);
}
// ---------- 对外入口 ----------
function read(file) {
return readRange(file, 0, HEAD_BYTES).then(function (head) {
if (head.length >= 2 && head[0] === 0xFF && head[1] === 0xD8) {
return Promise.resolve(parseJPEG(head));
}
if (head.length >= 12 && str(head, 4, 4) === 'ftyp') {
return parseHEIF(file, head);
}
return Promise.reject(new Error('不认识的图片格式(支持 JPEG 与 HEIC/HEIF)'));
});
}
window.rondExif = { read: read };
})();
+119
View File
@@ -0,0 +1,119 @@
/* 地点图片:在浏览器里转成 WebP 再上传。
*
* 为什么放在前端转:1 核的服务器解一张 4000px 的原图要好几秒,几张就够卡住整站;
* 而用户自己的手机/电脑转一张是几十毫秒的事。服务端只负责把收到的 WebP 字节原样写出去。
*
* 尺寸与质量策略(在真机照片上量过才定的):
* 长边 1440px,质量 0.72 → 超 220KB 再试 0.58 → 还超就把整张缩到 1080px 重编。
* 关键在于「体积大头是像素数,不是质量」:实测一张 13MB 的城市照片,
* 1600px 从 q0.74 一路降到 q0.5 只从 483KB 掉到 391KB(掉 19%,画面却明显糊了),
* 而同样 q0.72 把长边从 1600 缩到 1024 直接掉到 217KB(掉 55%)。
* 所以阶梯走到底还超标时,宁可缩尺寸也不再压质量。
* 这几张图只用于详情页画廊与灯箱(最大显示到一千出头的宽度),1440px 留了余量。
*/
(function () {
var MAX_EDGE = 1440;
var QUALITY_LADDER = [0.72, 0.58];
var TARGET_BYTES = 220 * 1024;
// 兜底档:纹理特别密的照片在 1440px 下怎么降质都超标时才用
var FALLBACK_EDGE = 1080;
var FALLBACK_QUALITY = 0.7;
function loadImage(file) {
return new Promise(function (resolve, reject) {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('这张图片读不出来')); };
img.src = url;
});
}
function encode(cv, quality) {
return new Promise(function (resolve, reject) {
cv.toBlob(function (blob) {
// 不支持 WebP 编码的浏览器会静默给回 PNG,这里必须显式挡住——
// 否则上传的是 PNG,服务端会以「只接受 WebP」拒掉,用户却看不出原因
if (!blob || blob.type !== 'image/webp') {
reject(new Error('这个浏览器不支持 WebP 转码,请换新版 Chrome / Edge / Safari'));
return;
}
resolve(blob);
}, 'image/webp', quality);
});
}
// render 把原图按长边上限放到一张画布上,返回画布与落地的宽高
function render(img, limit) {
var w = img.naturalWidth || img.width || 1;
var h = img.naturalHeight || img.height || 1;
var scale = Math.min(1, limit / Math.max(w, h));
var tw = Math.max(1, Math.round(w * scale));
var th = Math.max(1, Math.round(h * scale));
var cv = document.createElement('canvas');
cv.width = tw;
cv.height = th;
var g = cv.getContext('2d');
if (g.imageSmoothingQuality !== undefined) g.imageSmoothingQuality = 'high';
// 白底:源图带透明通道时(截图、PNG)不会在 WebP 里变成黑块
g.fillStyle = '#fff';
g.fillRect(0, 0, tw, th);
g.drawImage(img, 0, 0, tw, th);
return { cv: cv, width: tw, height: th };
}
// toWebP(file) -> Promise<{blob, width, height, quality, bytes}>
function toWebP(file, opts) {
opts = opts || {};
var maxEdge = opts.maxEdge || MAX_EDGE;
var target = opts.targetBytes || TARGET_BYTES;
var ladder = opts.ladder || QUALITY_LADDER;
var fallbackEdge = opts.fallbackEdge === undefined ? FALLBACK_EDGE : opts.fallbackEdge;
var fallbackQuality = opts.fallbackQuality || FALLBACK_QUALITY;
return loadImage(file).then(function (img) {
var box = render(img, maxEdge);
function attempt(i) {
var q = ladder[i];
return encode(box.cv, q).then(function (blob) {
var out = { blob: blob, width: box.width, height: box.height, quality: q, bytes: blob.size };
if (out.bytes <= target || i === ladder.length - 1) return out;
return attempt(i + 1);
});
}
return attempt(0).then(function (out) {
// 阶梯走到底还超标 ⇒ 这张图纹理特别密,再降质量只会又糊又大,改成缩尺寸
var longest = Math.max(box.width, box.height);
if (out.bytes <= target || !fallbackEdge || longest <= fallbackEdge) return out;
var small = render(img, fallbackEdge);
return encode(small.cv, fallbackQuality).then(function (blob) {
// 兜底档同样是有损重编,纹理极端的图上未必比阶梯档小,所以比一次体积、取小的
if (blob.size >= out.bytes) return out;
return { blob: blob, width: small.width, height: small.height, quality: fallbackQuality, bytes: blob.size };
});
});
});
}
function post(url, form) {
return fetch(url, { method: 'POST', body: form, credentials: 'same-origin' })
.then(function (r) { return r.json(); });
}
// upload(srcPK, blob, w, h) -> Promise<{ok, id, url} | {error}>
function upload(srcPK, blob, w, h) {
var fd = new FormData();
fd.append('src_pk', srcPK);
fd.append('w', w);
fd.append('h', h);
fd.append('file', blob, 'image.webp');
return post('/admin/place/images', fd);
}
function remove(id) {
var fd = new FormData();
fd.append('id', id);
return post('/admin/place/images/delete', fd);
}
window.rondImage = { toWebP: toWebP, upload: upload, remove: remove };
})();
+930
View File
@@ -0,0 +1,930 @@
/* rond 足迹地图:底图坐标系感知 + Leaflet 渲染
*
* 坐标系约定:数据库里的地点坐标是 GCJ-02(火星坐标,见 internal/geo/geo.go 的说明),
* 因此画在 GCJ-02 底图(高德/腾讯)上时**不做任何转换**;
* 只有换成 WGS-84 底图(天地图 / OSM / 世界迷雾)时才反算一次,否则会偏 500 米以上。
*/
(function () {
'use strict';
var PI = 3.1415926535897932384626, A = 6378245.0, EE = 0.00669342162296594323;
var DATA_CRS = 'gcj02';
// 交通方式图例:轨迹开启时告诉用户颜色含义
function modeLegendControl(modes) {
var Ctl = L.Control.extend({
options: { position: 'bottomleft' },
onAdd: function () {
var box = L.DomUtil.create('div', 'mode-legend');
box.style.cssText = 'background:rgba(255,255,255,.92);padding:6px 9px;border-radius:8px;' +
'box-shadow:0 1px 4px rgba(0,0,0,.18);font-size:11.5px;line-height:1.7';
modes.forEach(function (m) {
var row = L.DomUtil.create('div');
row.innerHTML = '<span style="display:inline-block;width:14px;height:3px;vertical-align:middle;' +
'background:' + (m.color || '#30b0c7') + ';margin-right:6px"></span>' +
m.name + ' <span style="color:#8a9199">' + m.count + '</span>';
box.appendChild(row);
});
L.DomEvent.disableClickPropagation(box);
return box;
}
});
return new Ctl();
}
// GPX 轨迹按需加载:勾选时才请求,切底图后按新坐标系重取
var gpxCache = {};
function ensureGpx(crs, done) {
if (!gpxCache[crs]) {
gpxCache[crs] = fetch('/api/gpx?crs=' + crs)
.then(function (r) { return r.json(); })
.then(function (d) { return d.tracks || []; })
.catch(function () { delete gpxCache[crs]; return []; });
}
gpxCache[crs].then(done);
}
// 按需加载 leaflet-heat:同一个 promise 复用,避免连点重复插入脚本
var heatLoading = null;
function ensureHeat(done) {
if (L.heatLayer) { done(); return; }
if (!heatLoading) {
heatLoading = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = '/static/vendor/heat/leaflet-heat.js' + (window.__assetV ? '?v=' + window.__assetV : '');
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
heatLoading.then(done, function () { heatLoading = null; });
}
// 迷雾浓度滑杆:不同底图明暗差异大,让用户自己调节更直观。
// 返回控件本身,便于迷雾被关掉时把滑杆一并收起来——图层关着、滑杆还在,
// 会让人以为迷雾已经打开了。
function addFogOpacityControl(map, layer, corner) {
var Ctl = L.Control.extend({
options: { position: corner },
onAdd: function () {
var box = L.DomUtil.create('div', 'leaflet-bar fog-opacity');
// 标签做成按钮:窄屏用它把滑杆收起来(右下角控件太多时太挤)
box.innerHTML = '<button type="button" class="fog-label">迷雾浓度</button>';
var label = box.querySelector('.fog-label');
var slider = L.DomUtil.create('input', 'fog-slider');
slider.type = 'range';
slider.min = '20';
slider.max = '100';
slider.step = '5';
slider.value = String(Math.round(layer.options.opacity * 100));
box.appendChild(slider);
L.DomEvent.disableClickPropagation(box);
L.DomEvent.disableScrollPropagation(box);
L.DomEvent.on(label, 'click', function () { box.classList.toggle('open'); });
L.DomEvent.on(slider, 'input', function () {
var v = parseInt(slider.value, 10) / 100;
layer.setOpacity(v);
localStorage.setItem('rond.fogOpacity', String(v));
});
return box;
}
});
return new Ctl().addTo(map);
}
function inChina(lat, lon) {
return lon >= 73.66 && lon <= 135.05 && lat >= 3.86 && lat <= 53.55;
}
function transformLat(x, y) {
var r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
r += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
r += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3;
r += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3;
return r;
}
function transformLon(x, y) {
var r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
r += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
r += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3;
r += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3;
return r;
}
// WGS-84 -> GCJ-02
function wgsToGcj(lat, lon) {
if (!inChina(lat, lon)) return [lat, lon];
var dLat = transformLat(lon - 105, lat - 35);
var dLon = transformLon(lon - 105, lat - 35);
var radLat = lat / 180 * PI;
var magic = Math.sin(radLat);
magic = 1 - EE * magic * magic;
var sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
dLon = (dLon * 180) / (A / sqrtMagic * Math.cos(radLat) * PI);
return [lat + dLat, lon + dLon];
}
// GCJ-02 -> WGS-84,正变换没有解析反函数,不动点迭代即可
function gcjToWgs(lat, lon) {
if (!inChina(lat, lon)) return [lat, lon];
var wLat = lat, wLon = lon;
for (var i = 0; i < 5; i++) {
var g = wgsToGcj(wLat, wLon);
wLat += lat - g[0];
wLon += lon - g[1];
}
return [wLat, wLon];
}
function project(lat, lon, crs) {
if (crs === 'wgs84') return gcjToWgs(lat, lon);
return [lat, lon];
}
var APPLE = {
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'
};
var maps = {};
var cfgCache = null;
function register(id, map) {
maps[id] = map;
window.__rondMaps = maps;
return map;
}
function colorOf(name) {
if (!name) return '#5b6169';
if (APPLE[name]) return APPLE[name];
return /^#/.test(name) ? name : '#5b6169';
}
// 底图配置由服务端下发:需要密钥的图层走 /tiles/... 代理,密钥不进页面
function loadConfig() {
if (cfgCache) return Promise.resolve(cfgCache);
return fetch('/api/map-config', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (c) {
if (!c || !c.base || !c.base.length) throw new Error('底图配置为空');
cfgCache = c;
return c;
})
.catch(function () {
// 兜底:直接用免密钥高德瓦片,保证地图不会空白
cfgCache = {
base: [{
key: 'amap-street', label: '街道', crs: 'gcj02', maxZoom: 18,
url: 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}',
attr: '© 高德地图'
}],
overlays: []
};
return cfgCache;
});
}
function tileLayer(def) {
return L.tileLayer(def.url, {
subdomains: ['1', '2', '3', '4'],
maxZoom: def.maxZoom || 18,
minZoom: 3,
attribution: def.attr || ''
});
}
/* ---------- 默认底图 ----------
* 三处来源,优先级从高到低:
* 1) 访客自己在图上切过的那张(localStorage,换设备就没了,但不用每次重切)
* 2) 后台「设置 → 地图底图 → 默认用哪张底图」设的那张
* 3) 配置里的第一张
* Key 对不上(换过 map_provider、旧配置残留)就往下落,绝不让地图空着。
*/
var BASE_PICK = 'rond.base';
function pickBase(cfg) {
var byKey = {};
cfg.base.forEach(function (b) { byKey[b.key] = b; });
try {
var saved = window.localStorage.getItem(BASE_PICK);
if (saved && byKey[saved]) return byKey[saved];
} catch (e) { /* 隐私模式禁用了 localStorage,忽略即可 */ }
if (cfg.default && byKey[cfg.default]) return byKey[cfg.default];
return cfg.base[0];
}
function rememberBase(key) {
if (!key) return;
try { window.localStorage.setItem(BASE_PICK, key); } catch (e) { /* 同上 */ }
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function dur(min) {
min = Math.round(min || 0);
if (min <= 0) return '—';
var d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
if (d > 0) return d + '天' + (h ? h + '小时' : '');
if (h > 0) return h + '小时' + (m ? m + '分' : '');
return m + '分';
}
/* ---------- 推荐 / 踩雷:地图气泡里直接表决 ----------
* 票数一次性从 /api/votes 取回(个人站点规模很小),气泡渲染时同步查表,
* 不为每个点单独发请求;投票就地 AJAX,不刷新页面。
*/
var voteData = null; // {verdicts:{}, tallies:{}, mine:{}}
var voteMeta = { admin: false, public: false };
var voteLoad = null;
function loadVotes() {
if (voteLoad) return voteLoad;
voteLoad = fetch('/api/votes', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
voteMeta.admin = !!d.admin;
voteMeta.public = !!d.public;
voteData = { verdicts: d.verdicts || {}, tallies: d.tallies || {}, mine: d.mine || {} };
repaintVotes();
return voteData;
})
.catch(function () { voteLoad = null; return null; });
return voteLoad;
}
function verdictOf(pk, fallback) {
if (voteData && voteData.verdicts[pk] != null) return voteData.verdicts[pk];
return fallback || 0;
}
function tallyOf(pk, up, down) {
var t = voteData && voteData.tallies[pk];
return t || { up: up || 0, down: down || 0 };
}
function paintVote(box, p) {
var pk = p.sp;
// 票数与身份(站长 / 访客)还没拿到时先不画,免得先闪出「大家怎么说」再变成「我的结论」
if (!voteData) { box.style.display = 'none'; return; }
if (!(voteMeta.admin || voteMeta.public)) { box.style.display = 'none'; return; }
box.style.display = '';
// 管理员看自己的结论(点已选中的按钮可取消);访客看大家怎么说
if (voteMeta.admin) {
var v = verdictOf(pk, p.vd);
box.innerHTML = '<span class="mp-vl">我的结论</span>' +
'<button type="button" class="mp-vb up' + (v === 1 ? ' on' : '') + '" data-set="' + (v === 1 ? 0 : 1) + '">👍 推荐</button>' +
'<button type="button" class="mp-vb down' + (v === -1 ? ' on' : '') + '" data-set="' + (v === -1 ? 0 : -1) + '">👎 踩雷</button>';
} else {
var t = tallyOf(pk, p.u, p.dn), m = mineArea(pk);
box.innerHTML = '<span class="mp-vl">大家怎么说</span>' +
'<button type="button" class="mp-vb up' + (m === 1 ? ' on' : '') + '" data-set="1">👍 <b>' + t.up + '</b></button>' +
'<button type="button" class="mp-vb down' + (m === -1 ? ' on' : '') + '" data-set="-1">👎 <b>' + t.down + '</b></button>';
}
Array.prototype.forEach.call(box.querySelectorAll('.mp-vb'), function (b) {
b.addEventListener('click', function () {
if (b.disabled) return;
b.disabled = true;
var body = voteMeta.admin
? 'src_pk=' + pk + '&verdict=' + b.getAttribute('data-set')
: 'src_pk=' + pk + '&vote=' + b.getAttribute('data-set');
fetch(voteMeta.admin ? '/api/verdict' : '/api/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
body: body
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.error) { b.disabled = false; return; }
if (!voteData) voteData = { verdicts: {}, tallies: {}, mine: {} };
if (voteMeta.admin) {
if (d.verdict) voteData.verdicts[pk] = d.verdict; else delete voteData.verdicts[pk];
} else {
voteData.tallies[pk] = { up: d.up, down: d.down };
if (d.mine) voteData.mine[pk] = d.mine; else delete voteData.mine[pk];
}
paintVote(box, p);
})
.catch(function () { b.disabled = false; });
});
});
}
function mineArea(pk) {
return voteData && voteData.mine[pk] ? voteData.mine[pk] : 0;
}
// 票数异步到货后,把已经打开的气泡补上真实票数
function repaintVotes() {
Array.prototype.forEach.call(document.querySelectorAll('.mp-vote'), function (box) {
if (box.__p) paintVote(box, box.__p);
});
}
function popup(p, vote) {
var wrap = document.createElement('div');
wrap.className = 'mp';
var parts = ['<div class="mp-n">' + esc(p.n || '未命名地点') + '</div>'];
var meta = [];
if (p.a) meta.push(esc(p.a));
if (p.c) meta.push(esc(p.c));
if (meta.length) parts.push('<div class="mp-m">' + meta.join(' · ') + '</div>');
parts.push('<div class="mp-m">到访 <b>' + p.v + '</b> 次 · 停留 <b>' + dur(p.d) + '</b></div>');
// 备注只读直出,省得多点一次详情页
if (p.note) parts.push('<div class="mp-note">' + esc(p.note) + '</div>');
if (p.t) parts.push('<div class="mp-m">最近一次 ' + esc(p.t) + '</div>');
parts.push('<div class="mp-foot"><a href="/places/' + p.id + '">查看详情 →</a></div>');
wrap.innerHTML = parts.join('');
if (vote && p.sp) {
// 无论如何都挂上,交给 paintVote 决定显不显示;
// 不挂的话票数到货后的 repaintVotes() 就找不到它了
var box = L.DomUtil.create('div', 'mp-vote');
box.__p = p;
paintVote(box, p);
wrap.appendChild(box);
}
return wrap;
}
// radiusOf 让点的大小反映到访次数(按当前这批里最多的那个归一化)
function radiusOf(p, max) {
return 4 + Math.min(9, Math.sqrt(p.v / max) * 9);
}
function maxVisit(points) {
var max = 1;
points.forEach(function (p) { if (p.v > max) max = p.v; });
return max;
}
// makeMarker 造一个点。抽出来是为了让 setPoints 能只补差集:
// 整体重建会让所有点同时闪一下,聚合的展开状态也会丢。
function makeMarker(p, crs, max, vote) {
var c = project(p.lat, p.lon, crs);
var m = L.circleMarker([c[0], c[1]], baseStyle(p, max));
// 传函数而不是字符串:每次打开都重算票数与「我投的」,避免陈旧的闭包数据
// maxWidth 要比默认的 300 大,否则「我的结论 👍 推荐 👎 踩雷」会被挤成两行
m.bindPopup(function () { return popup(p, vote); },
{ closeButton: true, maxWidth: 360, minWidth: 240 });
m.bindTooltip(p.n || '未命名', { direction: 'top', offset: [0, -4] });
m.__p = p; // 记住这份数据是谁的,用来判断下批是不是「重新拉的」
return m;
}
function makeGroup(useCluster) {
return useCluster && L.markerClusterGroup
? L.markerClusterGroup({ maxClusterRadius: 46, disableClusteringAtZoom: 15, showCoverageOnHover: false })
: L.layerGroup();
}
// index 可选:传了就顺手填好「点 id → 图层」,供 setPoints 做增量更新,
// 也供 highlight() 按 key 找到某个点。没有 id 的点用调用方给的 p.key 当键
// (照片导入页传的是行号,它本来就没有库内 id)。
function markers(points, crs, useCluster, vote, index) {
var group = makeGroup(useCluster);
var max = maxVisit(points);
points.forEach(function (p) {
var m = makeMarker(p, crs, max, vote);
if (index) index[p.id !== undefined ? p.id : p.key] = m;
group.addLayer(m);
});
return group;
}
// 点的「常态样式」:highlight 复原时要用同一套值,所以抽出来
function baseStyle(p, max) {
return {
radius: radiusOf(p, max), color: '#fff', weight: 1.5,
fillColor: colorOf(p.k), fillOpacity: 0.9
};
}
function reducedMotion() {
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
// popIn 给一个元素打上入场动画。动画结束后把类和延时清掉,
// 免得这个元素之后被复用(缩放时 Leaflet 会换元素)时莫名其妙又跑一遍。
function popIn(el, delay) {
if (!el || !el.classList) return;
el.style.animationDelay = delay;
el.classList.add('pt-new');
el.addEventListener('animationend', function () {
el.classList.remove('pt-new');
el.style.animationDelay = '';
}, { once: true });
}
// 新出现的点做一次「冒出」动画,一批按顺序错开一点,看起来是"扫过来"的。
//
// 必须等图层真正上屏再打类:markerClusterGroup 是**异步**把子标记渲染成 DOM 的,
// addLayer 之后立刻 getElement() 拿到的是 null(这也是第一版动画完全没生效的原因)。
// 所以挂 add 事件,并且顺手判一次「是不是已经上屏了」。
function animateIn(markers) {
if (reducedMotion()) return;
markers.forEach(function (m, i) {
var delay = Math.min(i, 24) * 10 + 'ms';
var done = false;
var fire = function () {
if (done) return;
done = true;
popIn(m.getElement && m.getElement(), delay);
};
if (m.once) m.once('add', fire);
fire();
});
}
// 聚合图标不能用 DOM 元素本身当身份:markerCluster 每次数据变动都会重建它们
// (实测一轮更新里往 marker pane 加了 791 个元素),拿元素当 key 每次都被当成
// 「第一次见到」,结果就是什么都不会触发。用它的**位置**当身份才稳。
function clusterKey(el) {
var t = el.style.transform || '';
var m = t.match(/-?\d+(?:\.\d+)?/g);
if (m && m.length >= 2) {
// 位置量化到 40px 一格:点集一变,聚合中心会跟着微微挪动,
// 不量化的话同一个圈每次都被当成「新出现的」,回放时一片乱闪。
return Math.round(Number(m[0]) / 40) + ',' + Math.round(Number(m[1]) / 40);
}
return el.style.left + ',' + el.style.top;
}
// clusterState 把当前所有聚合圈的位置 → 数字记下来,并在数字变化 /
// 出现新位置时给相应元素打动画类。返回给下一次调用当基线。
function clusterState(map, prev) {
var now = {};
var changed = [];
var appeared = [];
[].slice.call(map.getContainer().querySelectorAll('.marker-cluster')).forEach(function (el) {
var inner = el.firstElementChild;
if (!inner) return;
var key = clusterKey(el);
var n = (inner.textContent || '').trim();
now[key] = n;
if (!prev) return; // 第一次只记基线:别一上来满屏都在动
if (prev[key] === undefined) appeared.push(el);
else if (prev[key] !== n) changed.push(inner);
});
if (!prev || reducedMotion()) return now;
appeared.forEach(function (el, i) { popIn(el, Math.min(i, 24) * 10 + 'ms'); });
// 数字变了轻弹一下。回放时在聚合视图下主要看到的就是数字在跳,
// 光靠新圆圈淡入几乎看不出来。弹内层 div —— 外层带着 Leaflet 的定位 transform。
changed.forEach(function (inner, i) {
setTimeout(function () {
inner.classList.remove('pt-pulse');
void inner.offsetWidth; // 强制回流:连着两次同样的动画不加这句不会重放
inner.classList.add('pt-pulse');
inner.addEventListener('animationend', function () {
inner.classList.remove('pt-pulse');
}, { once: true });
}, Math.min(i, 24) * 10);
});
return now;
}
function fit(map, points, crs, pad) {
var lls = points.map(function (p) {
var c = project(p.lat, p.lon, crs);
return [c[0], c[1]];
}).filter(function (c) { return c[0] || c[1]; });
if (!lls.length) {
map.setView([26.6, 106.7], 11);
return;
}
map.fitBounds(L.latLngBounds(lls).pad(pad == null ? 0.15 : pad));
}
// 底图坐标系可能随图层切换而改变(例如高德 -> 天地图),此时必须重画标记
function state4(map, points, group, crs, vote) {
return {
map: map, points: points, group: group, crs: crs, vote: vote,
apply: function (nextCrs) {
if (nextCrs === this.crs) return;
this.map.removeLayer(this.group);
this.crs = nextCrs;
this.group = markers(this.points, this.crs, this.clustered, this.vote);
this.group.addTo(this.map);
fit(this.map, this.points, this.crs, this.pad);
}
};
}
window.rondMap = {
gcj: wgsToGcj,
gcjToWgs: gcjToWgs,
colorOf: colorOf,
esc: esc,
dur: dur,
// 简易预览:主页 / 地点详情用,无聚类、无控件。
// 默认不带就地表决(详情页下方本来就有表决卡片,重复一套按钮反而混乱);
// 需要时传 vote: true。
preview: function (opts) {
var points = opts.points || [];
var vote = opts.vote === true;
// 同一个容器画第二次(照片导入页反查完画一次、点「重新反查」又画一次):
// Leaflet 不允许对已经有地图的容器再 L.map(),会抛
// "Map container is already initialized."——那个错会被调用方的 catch 接住,
// 于是「重新反查」明明成功了却报一句失败。这里直接复用那份实例换一批点。
// 判据用「容器有没有地图」(register 记在 maps[] 里),不是「状态建全没有」:
// 只要容器已被 Leaflet 认领过,再 L.map() 就一定会抛,哪怕上次的状态没建完。
// 容器可能刚从 display:none 变回可见,先量一次尺寸(换点会顺手重贴视野)。
var live = maps[opts.id];
if (live) {
live.invalidateSize();
var prev = window.rondMap._state[opts.id];
if (prev && prev.setPoints) prev.setPoints(points);
else if (prev) prev.previewPending = points; // 配置还没回来,等 setPoints 建好再补画
return;
}
var map = L.map(opts.id, { zoomControl: false, attributionControl: false, scrollWheelZoom: false });
var st = null;
register(opts.id, map);
if (vote) loadVotes();
loadConfig().then(function (cfg) {
var def = pickBase(cfg);
tileLayer(def).addTo(map);
var byId = {};
var group = markers(points, def.crs, false, vote, byId);
group.addTo(map);
var applyFit = function () { fit(map, points, def.crs, 0.06); };
applyFit();
// 点击缩略图进全屏地图:只有主页那种「预览」需要(opts.clickToFull)。
// 地点详情页不能要——它上面本来就有「在地图中查看」按钮,点地图就走很突兀;
// 后台照片页更不能要——点一下地图就被甩出后台。
if (opts.clickToFull) {
map.on('click', function () { window.location.href = '/map'; });
}
// 容器尺寸变化后重新贴合视野(卡片布局在字体加载后可能变高)
window.addEventListener('resize', function () { map.invalidateSize(); applyFit(); });
st = state4(map, points, group, def.crs, vote);
st.clustered = false;
st.pad = 0.06;
st.byId = byId;
st.max = maxVisit(points);
st.setPoints = function (pts) {
points = st.points = pts;
if (st.hl) st.hl = null; // 旧图层马上要被丢掉,别留着悬空引用
map.removeLayer(st.group);
st.byId = {};
st.max = maxVisit(pts);
st.group = markers(pts, st.crs, false, vote, st.byId);
st.group.addTo(map);
applyFit();
};
window.rondMap._state[opts.id] = st;
// 配置就绪前有别的调用挂上来的点,现在补画一次
if (st.previewPending) {
var queued = st.previewPending;
st.previewPending = null;
st.setPoints(queued);
}
});
},
// highlight(id, key):把某个点临时挑出来(放大、换醒目色、浮出名字、压到最上层)。
// key 传 null / undefined 复原。key 取点的 id(full() 那套),没有 id 时取 p.key
// ——照片导入页传的是行号,拿它做「鼠标划到哪一行,图上哪个点跳出来」。
// 视野里没有这个点时才把它平移进来:划过一行就动一次地图会很晃。
highlight: function (id, key) {
var st = window.rondMap._state[id];
if (!st || !st.byId || !st.map) return;
if (st.hl) {
st.hl.setStyle(baseStyle(st.hl.__p || {}, st.max || 1));
st.hl.closeTooltip();
st.hl = null;
}
if (key === null || key === undefined) return;
var m = st.byId[key];
if (!m) return;
m.setStyle({
radius: radiusOf(m.__p || {}, st.max || 1) + 8,
color: '#fff', weight: 3, fillColor: '#e8590c', fillOpacity: 1
});
m.bringToFront();
m.openTooltip();
var ll = m.getLatLng();
if (!st.map.getBounds().pad(-0.12).contains(ll)) st.map.panTo(ll);
st.hl = m;
},
// 全屏地图:聚类 + 图层切换 + 坐标纠偏
full: function (opts) {
var map = L.map(opts.id, { zoomControl: false, worldCopyJump: false, attributionControl: false });
// 默认那个角标会显示「Leaflet | © 高德地图」,其中「Leaflet」是渲染库自己的链接,
// 对访客没有任何意义,去掉(prefix: false 只去掉这一半)。
// 底图版权(© 高德地图 / © OpenStreetMap)必须留着——那是上游的使用条款要求。
L.control.attribution({ prefix: false, position: 'bottomright' }).addTo(map);
var points = opts.points || [];
// 窄屏筛选面板占满宽度,控件改挂右下角(该尺寸下图例已隐藏)
var corner = window.matchMedia('(max-width: 620px)').matches ? 'bottomright' : 'topright';
L.control.zoom({ position: corner }).addTo(map);
L.control.scale({ imperial: false, position: 'bottomleft' }).addTo(map);
register(opts.id, map);
window.rondMap._state[opts.id] = { pending: true, points: points };
loadVotes();
loadConfig().then(function (cfg) {
var bases = {}, def = pickBase(cfg);
cfg.base.forEach(function (b) {
var l = tileLayer(b);
l.__crs = b.crs;
l.__key = b.key;
bases[b.label] = l;
});
var active = bases[def.label];
active.addTo(map);
// 瓦片加载兜底:当前底图短时间内连续加载失败就自动切到别的底图。
// Leaflet 只是本地渲染库,失败的是上游瓦片源(高德等),这里把选择权自动接过来。
var errCount = 0, errTimer = null;
function noteFallback(msg) {
var el = document.createElement('div');
el.textContent = msg;
el.style.cssText = 'position:absolute;left:50%;transform:translateX(-50%);top:10px;z-index:1000;' +
'background:rgba(30,40,50,.85);color:#fff;padding:7px 14px;border-radius:8px;font-size:12.5px';
map.getContainer().appendChild(el);
setTimeout(function () { el.remove(); }, 6000);
}
Object.keys(bases).forEach(function (label) {
bases[label].on('tileerror', function () {
if (map.hasLayer(bases[label]) && bases[label] === active) {
errCount++;
clearTimeout(errTimer);
errTimer = setTimeout(function () { errCount = 0; }, 10000);
if (errCount >= 8) {
errCount = 0;
var others = Object.keys(bases).filter(function (l) { return l !== label; });
if (!others.length) return;
map.removeLayer(active);
active = bases[others[0]];
active.addTo(map);
st.apply(active.__crs);
noteFallback('「' + label + '」瓦片加载失败,已自动切换到「' + others[0] + '」');
}
}
});
});
// 天地图的注记是独立图层,必须叠在底图之上
(cfg.overlays || []).forEach(function (o) {
var ol = tileLayer(o);
ol.__crs = o.crs;
ol.addTo(map);
});
// 世界迷雾图层:瓦片由服务端按当前底图坐标系纠偏后渲染,
// 切换底图坐标系时必须换 URL 重新拉取。
// 专题可以在管理页关掉迷雾(opts.showFog=false),此时图层、浓度滑杆、图层开关都不出现。
var wantFog = opts.showFog !== false;
var fogLayer = null;
if (wantFog && cfg.fog && cfg.fog.blocks > 0) {
fogLayer = L.tileLayer('/tiles/fog/{z}/{x}/{y}?crs=' + def.crs + '&v=' + cfg.fog.ver, {
minZoom: 3, maxZoom: 18, opacity: 0.9
});
}
var overlayDefs = {};
if (fogLayer) {
overlayDefs['世界迷雾'] = fogLayer;
var savedFog = parseFloat(localStorage.getItem('rond.fogOpacity'));
if (!isNaN(savedFog) && savedFog > 0 && savedFog <= 1) fogLayer.setOpacity(savedFog);
var fogCtl = addFogOpacityControl(map, fogLayer, corner);
// 默认打开。浓度滑杆一直摆在图上,迷雾要是默认关着,用户会以为这功能已经开了;
// 只有用户自己关过(rond.fogOff=1)才不显示,关掉时把滑杆一并收起来。
var syncFog = function () {
var on = map.hasLayer(fogLayer);
var box = fogCtl.getContainer();
if (box) box.style.display = on ? '' : 'none';
localStorage.setItem('rond.fogOff', on ? '0' : '1');
};
if (localStorage.getItem('rond.fogOff') === '1') syncFog();
else fogLayer.addTo(map);
map.on('overlayadd overlayremove', function (e) {
if (e.name === '世界迷雾') syncFog();
});
}
if (Object.keys(bases).length > 1 || fogLayer) {
L.control.layers(bases, overlayDefs, { position: corner }).addTo(map);
// 记住访客挑的底图:下次进来直接铺他喜欢的那张(含「纯路网」这种少标注的)
map.on('baselayerchange', function (e) {
if (e.layer) rememberBase(e.layer.__key);
});
}
var group = markers(points, def.crs, true, true);
group.addTo(map);
// 专题页要的是「区域视野」而不是「点集视野」:只框住点的话,
// 只去过一两个地方时视野会缩得过小,区域内的迷雾也看不全。
// 传 fitCircle(中心 + 半径)或 fitBBox 就能覆盖默认的点集贴合。
var applyFit = function () {
if (opts.fitCircle && opts.fitCircle.radiusKm > 0) {
var c = project(opts.fitCircle.lat, opts.fitCircle.lon, def.crs);
map.fitBounds(L.latLng(c[0], c[1]).toBounds(opts.fitCircle.radiusKm * 2000));
return;
}
if (opts.fitBBox) {
var b = opts.fitBBox;
var p1 = project(b.minLat, b.minLon, def.crs), p2 = project(b.maxLat, b.maxLon, def.crs);
map.fitBounds(L.latLngBounds([[p1[0], p1[1]], [p2[0], p2[1]]]).pad(0.12));
return;
}
fit(map, points, def.crs);
};
applyFit();
var st = state4(map, points, group, def.crs, true);
st.clustered = true;
st.pad = 0.15;
st.apply = function (next) {
if (next === this.crs) return;
this.crs = next;
this.map.removeLayer(this.group);
this.group = markers(this.points, this.crs, true, this.vote);
this.group.addTo(this.map);
applyFit();
};
window.rondMap._state[opts.id] = st;
// 行程轨迹与到访热力:都基于 GCJ-02 数据,随底图坐标系重投影
var trackLayer = null, trackSegs = null, heatLayer = null;
var cityLayer = null, gpxLayer = null, modeLegend = null;
function buildTrack(segs, crs) {
var g = L.layerGroup();
segs.forEach(function (s) {
var a = project(s.a[0], s.a[1], crs), b = project(s.b[0], s.b[1], crs);
L.polyline([a, b], { color: s.k || '#30b0c7', weight: 2, opacity: 0.55 })
.bindTooltip((s.m || '行程') + (s.km ? ' · ' + s.km.toFixed(1) + ' km' : ''), { sticky: true })
.addTo(g);
});
return g;
}
function buildHeat(crs) {
// 必须用 st.points:本页首屏是用 points: [] 构造的(地点随后经 /api/points 拉回),
// setPoints 只更新了 st.points,闭包里那个 points 一直是空数组 —— 用它热力图恒为空。
var pts = (st.points || []).map(function (p) {
var c = project(p.lat, p.lon, crs);
return [c[0], c[1], Math.min(p.v || 1, 25)];
}).filter(function (c) { return c[0] || c[1]; });
return L.heatLayer(pts, { radius: 22, blur: 18, maxZoom: 17, minOpacity: 0.35 });
}
window.rondMap.setTrack = function (segs, modes) {
if (trackLayer) { map.removeLayer(trackLayer); trackLayer = null; }
if (segs && segs.length) {
trackSegs = segs;
trackLayer = buildTrack(segs, st.crs).addTo(map);
} else {
trackSegs = null;
}
// 交通方式图例:告诉用户哪种颜色是哪类出行
if (modeLegend) { map.removeControl(modeLegend); modeLegend = null; }
if (modes && modes.length && segs && segs.length) {
modeLegend = modeLegendControl(modes);
modeLegend.addTo(map);
}
};
// 城市级聚合:圆圈大小随到访次数增长,气泡里带「首次到达」里程碑
window.rondMap.setCityAgg = function (on) {
if (cityLayer) { map.removeLayer(cityLayer); cityLayer = null; }
if (!on) return;
fetch('/api/cities').then(function (r) { return r.json(); }).then(function (d) {
var cities = d.cities || [];
if (!cities.length) return;
var max = Math.max.apply(null, cities.map(function (c) { return c.visits || 1; }));
cityLayer = L.layerGroup();
cities.forEach(function (c) {
var pos = project(c.lat, c.lon, st.crs);
var r = 8 + 22 * Math.sqrt((c.visits || 1) / max);
L.circleMarker(pos, {
radius: r, color: '#2f7d6e', weight: 1.5,
fillColor: '#2f7d6e', fillOpacity: 0.18
}).bindPopup('<b>' + c.city + '</b>' +
(c.province ? ' · ' + c.province : '') +
'<br>到访 ' + c.visits + ' 次 · ' + c.places + ' 个地点' +
(c.first ? '<br>首次到达 ' + c.first : '')).addTo(cityLayer);
});
cityLayer.addTo(map);
});
};
// GPX 轨迹:库里是 WGS-84,服务端已按当前底图坐标系转换好
window.rondMap.setGpx = function (on) {
if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; }
if (!on) return;
ensureGpx(st.crs, function (tracks) {
gpxLayer = L.layerGroup();
tracks.forEach(function (t) {
if (!t.points || t.points.length < 2) return;
L.polyline(t.points, { color: '#e8590c', weight: 3, opacity: 0.8 })
.bindTooltip(t.name + (t.km ? ' · ' + t.km.toFixed(1) + ' km' : ''), { sticky: true })
.addTo(gpxLayer);
});
gpxLayer.addTo(map);
});
};
window.rondMap.setHeat = function (on) {
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
if (!on) return;
// heat 库只在第一次开热力图时才下载,平时不进首屏
ensureHeat(function () {
if (L.heatLayer) heatLayer = buildHeat(st.crs).addTo(map);
});
};
map.on('baselayerchange', function (e) {
if (e.layer && e.layer.__crs) {
active = e.layer;
errCount = 0;
st.apply(e.layer.__crs);
if (fogLayer && map.hasLayer(fogLayer)) {
fogLayer.setUrl('/tiles/fog/{z}/{x}/{y}?crs=' + e.layer.__crs + '&v=' + cfg.fog.ver);
}
if (trackLayer && trackSegs) {
map.removeLayer(trackLayer);
trackLayer = buildTrack(trackSegs, e.layer.__crs).addTo(map);
}
if (heatLayer) {
map.removeLayer(heatLayer);
heatLayer = buildHeat(e.layer.__crs).addTo(map);
}
if (cityLayer) { map.removeLayer(cityLayer); cityLayer = null; window.rondMap.setCityAgg(true); }
if (gpxLayer) { map.removeLayer(gpxLayer); gpxLayer = null; window.rondMap.setGpx(true); }
}
});
// byId 记住「点 → 图层」,setPoints 才能只补差集;
// max 是当前这批里最多的到访次数,用于点的半径归一化
st.byId = {};
st.max = 1;
window.rondMap.setPoints = function (pts, opts) {
opts = opts || {};
var prev = st.byId || {};
var max = 1;
pts.forEach(function (p) { if (p.v > max) max = p.v; });
// 数据是重新拉的(对象不是同一个)就整体重建:挂着的 popup 里存的是旧对象,
// 复用会导致打开气泡看到的还是上一次筛选的数字。
// 时间轴只是在这批点上做子集筛选,对象是同一批,因此能安全走增量。
var refetch = pts.some(function (p) { return prev[p.id] && prev[p.id].__p !== p; });
if (refetch || Object.keys(prev).length === 0) {
points = st.points = pts;
map.removeLayer(st.group);
st.byId = {};
st.group = markers(pts, st.crs, true, true, st.byId);
st.group.addTo(map);
st.max = max;
fit(map, pts, st.crs);
return;
}
var next = {};
var added = [];
pts.forEach(function (p) {
var m = prev[p.id];
if (m) {
next[p.id] = m;
delete prev[p.id];
return;
}
m = makeMarker(p, st.crs, max, true);
next[p.id] = m;
added.push(m);
st.group.addLayer(m);
});
// 剩下的就是这一批里没有的点,逐个移除(不重建,其余点纹丝不动)
Object.keys(prev).forEach(function (id) { st.group.removeLayer(prev[id]); });
// 到访次数上限变了就顺手调一下半径,免得新旧点大小不一致
if (max !== st.max) {
pts.forEach(function (p) {
var m = next[p.id];
if (m && m.setRadius) m.setRadius(radiusOf(p, max));
});
st.max = max;
}
st.byId = next;
points = st.points = pts;
animateIn(added);
st.clusterIcons = clusterState(map, st.clusterIcons);
// 时间轴拖动的每一帧都重新 fit 会让地图不停缩放,很晕;只有换筛选时才 fit
if (!opts.keepView) fit(map, pts, st.crs);
};
window.addEventListener('resize', function () { map.invalidateSize(); });
});
return map;
},
_state: {}
};
})();
+15
View File
@@ -0,0 +1,15 @@
{
"name": "我的足迹地图",
"short_name": "足迹",
"description": "rond 备份驱动的个人足迹地图",
"lang": "zh-CN",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f7f8f8",
"theme_color": "#2f7d6e",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+68
View File
@@ -0,0 +1,68 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.zh = {}));
}(this, (function (exports) { 'use strict';
var fp = typeof window !== "undefined" && window.flatpickr !== undefined
? window.flatpickr
: {
l10ns: {},
};
var Mandarin = {
weekdays: {
shorthand: ["周日", "周一", "周二", "周三", "周四", "周五", "周六"],
longhand: [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
],
},
months: {
shorthand: [
"一月",
"二月",
"三月",
"四月",
"五月",
"六月",
"七月",
"八月",
"九月",
"十月",
"十一月",
"十二月",
],
longhand: [
"一月",
"二月",
"三月",
"四月",
"五月",
"六月",
"七月",
"八月",
"九月",
"十月",
"十一月",
"十二月",
],
},
rangeSeparator: " 至 ",
weekAbbreviation: "周",
scrollTitle: "滚动切换",
toggleTitle: "点击切换 12/24 小时时制",
};
fp.l10ns.zh = Mandarin;
var zh = fp.l10ns;
exports.Mandarin = Mandarin;
exports.default = zh;
Object.defineProperty(exports, '__esModule', { value: true });
})));
+11
View File
@@ -0,0 +1,11 @@
/*
(c) 2014, Vladimir Agafonkin
simpleheat, a tiny JavaScript library for drawing heatmaps with Canvas
https://github.com/mourner/simpleheat
*/
!function(){"use strict";function t(i){return this instanceof t?(this._canvas=i="string"==typeof i?document.getElementById(i):i,this._ctx=i.getContext("2d"),this._width=i.width,this._height=i.height,this._max=1,void this.clear()):new t(i)}t.prototype={defaultRadius:25,defaultGradient:{.4:"blue",.6:"cyan",.7:"lime",.8:"yellow",1:"red"},data:function(t,i){return this._data=t,this},max:function(t){return this._max=t,this},add:function(t){return this._data.push(t),this},clear:function(){return this._data=[],this},radius:function(t,i){i=i||15;var a=this._circle=document.createElement("canvas"),s=a.getContext("2d"),e=this._r=t+i;return a.width=a.height=2*e,s.shadowOffsetX=s.shadowOffsetY=200,s.shadowBlur=i,s.shadowColor="black",s.beginPath(),s.arc(e-200,e-200,t,0,2*Math.PI,!0),s.closePath(),s.fill(),this},gradient:function(t){var i=document.createElement("canvas"),a=i.getContext("2d"),s=a.createLinearGradient(0,0,0,256);i.width=1,i.height=256;for(var e in t)s.addColorStop(e,t[e]);return a.fillStyle=s,a.fillRect(0,0,1,256),this._grad=a.getImageData(0,0,1,256).data,this},draw:function(t){this._circle||this.radius(this.defaultRadius),this._grad||this.gradient(this.defaultGradient);var i=this._ctx;i.clearRect(0,0,this._width,this._height);for(var a,s=0,e=this._data.length;e>s;s++)a=this._data[s],i.globalAlpha=Math.max(a[2]/this._max,t||.05),i.drawImage(this._circle,a[0]-this._r,a[1]-this._r);var n=i.getImageData(0,0,this._width,this._height);return this._colorize(n.data,this._grad),i.putImageData(n,0,0),this},_colorize:function(t,i){for(var a,s=3,e=t.length;e>s;s+=4)a=4*t[s],a&&(t[s-3]=i[a],t[s-2]=i[a+1],t[s-1]=i[a+2])}},window.simpleheat=t}(),/*
(c) 2014, Vladimir Agafonkin
Leaflet.heat, a tiny and fast heatmap plugin for Leaflet.
https://github.com/Leaflet/Leaflet.heat
*/
L.HeatLayer=(L.Layer?L.Layer:L.Class).extend({initialize:function(t,i){this._latlngs=t,L.setOptions(this,i)},setLatLngs:function(t){return this._latlngs=t,this.redraw()},addLatLng:function(t){return this._latlngs.push(t),this.redraw()},setOptions:function(t){return L.setOptions(this,t),this._heat&&this._updateOptions(),this.redraw()},redraw:function(){return!this._heat||this._frame||this._map._animating||(this._frame=L.Util.requestAnimFrame(this._redraw,this)),this},onAdd:function(t){this._map=t,this._canvas||this._initCanvas(),t._panes.overlayPane.appendChild(this._canvas),t.on("moveend",this._reset,this),t.options.zoomAnimation&&L.Browser.any3d&&t.on("zoomanim",this._animateZoom,this),this._reset()},onRemove:function(t){t.getPanes().overlayPane.removeChild(this._canvas),t.off("moveend",this._reset,this),t.options.zoomAnimation&&t.off("zoomanim",this._animateZoom,this)},addTo:function(t){return t.addLayer(this),this},_initCanvas:function(){var t=this._canvas=L.DomUtil.create("canvas","leaflet-heatmap-layer leaflet-layer"),i=L.DomUtil.testProp(["transformOrigin","WebkitTransformOrigin","msTransformOrigin"]);t.style[i]="50% 50%";var a=this._map.getSize();t.width=a.x,t.height=a.y;var s=this._map.options.zoomAnimation&&L.Browser.any3d;L.DomUtil.addClass(t,"leaflet-zoom-"+(s?"animated":"hide")),this._heat=simpleheat(t),this._updateOptions()},_updateOptions:function(){this._heat.radius(this.options.radius||this._heat.defaultRadius,this.options.blur),this.options.gradient&&this._heat.gradient(this.options.gradient),this.options.max&&this._heat.max(this.options.max)},_reset:function(){var t=this._map.containerPointToLayerPoint([0,0]);L.DomUtil.setPosition(this._canvas,t);var i=this._map.getSize();this._heat._width!==i.x&&(this._canvas.width=this._heat._width=i.x),this._heat._height!==i.y&&(this._canvas.height=this._heat._height=i.y),this._redraw()},_redraw:function(){var t,i,a,s,e,n,h,o,r,d=[],_=this._heat._r,l=this._map.getSize(),m=new L.Bounds(L.point([-_,-_]),l.add([_,_])),c=void 0===this.options.max?1:this.options.max,u=void 0===this.options.maxZoom?this._map.getMaxZoom():this.options.maxZoom,f=1/Math.pow(2,Math.max(0,Math.min(u-this._map.getZoom(),12))),g=_/2,p=[],v=this._map._getMapPanePos(),w=v.x%g,y=v.y%g;for(t=0,i=this._latlngs.length;i>t;t++)if(a=this._map.latLngToContainerPoint(this._latlngs[t]),m.contains(a)){e=Math.floor((a.x-w)/g)+2,n=Math.floor((a.y-y)/g)+2;var x=void 0!==this._latlngs[t].alt?this._latlngs[t].alt:void 0!==this._latlngs[t][2]?+this._latlngs[t][2]:1;r=x*f,p[n]=p[n]||[],s=p[n][e],s?(s[0]=(s[0]*s[2]+a.x*r)/(s[2]+r),s[1]=(s[1]*s[2]+a.y*r)/(s[2]+r),s[2]+=r):p[n][e]=[a.x,a.y,r]}for(t=0,i=p.length;i>t;t++)if(p[t])for(h=0,o=p[t].length;o>h;h++)s=p[t][h],s&&d.push([Math.round(s[0]),Math.round(s[1]),Math.min(s[2],c)]);this._heat.data(d).draw(this.options.minOpacity),this._frame=null},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),a=this._map._getCenterOffset(t.center)._multiplyBy(-i).subtract(this._map._getMapPanePos());L.DomUtil.setTransform?L.DomUtil.setTransform(this._canvas,a,i):this._canvas.style[L.DomUtil.TRANSFORM]=L.DomUtil.getTranslateString(a)+" scale("+i+")"}}),L.heatLayer=function(t,i){return new L.HeatLayer(t,i)};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
.marker-cluster-small {
background-color: rgba(181, 226, 140, 0.6);
}
.marker-cluster-small div {
background-color: rgba(110, 204, 57, 0.6);
}
.marker-cluster-medium {
background-color: rgba(241, 211, 87, 0.6);
}
.marker-cluster-medium div {
background-color: rgba(240, 194, 12, 0.6);
}
.marker-cluster-large {
background-color: rgba(253, 156, 115, 0.6);
}
.marker-cluster-large div {
background-color: rgba(241, 128, 23, 0.6);
}
/* IE 6-8 fallback colors */
.leaflet-oldie .marker-cluster-small {
background-color: rgb(181, 226, 140);
}
.leaflet-oldie .marker-cluster-small div {
background-color: rgb(110, 204, 57);
}
.leaflet-oldie .marker-cluster-medium {
background-color: rgb(241, 211, 87);
}
.leaflet-oldie .marker-cluster-medium div {
background-color: rgb(240, 194, 12);
}
.leaflet-oldie .marker-cluster-large {
background-color: rgb(253, 156, 115);
}
.leaflet-oldie .marker-cluster-large div {
background-color: rgb(241, 128, 23);
}
.marker-cluster {
background-clip: padding-box;
border-radius: 20px;
}
.marker-cluster div {
width: 30px;
height: 30px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
border-radius: 15px;
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
}
.marker-cluster span {
line-height: 30px;
}
@@ -0,0 +1,14 @@
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
}
.leaflet-cluster-spider-leg {
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>迷雾遮罩区</h1>
<div class="sub">这些范围内的迷雾<strong>对访客隐藏</strong>,你自己仍然看得到。在地图上点一下就是圆心,半径可调。</div>
</div>
<div class="row">
<a class="btn" href="/admin">← 返回后台</a>
</div>
</div>
<div class="card">
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div id="mask-map" style="height:380px;border-radius:10px;background:var(--line-soft)"></div>
<form method="post" action="/admin/fog/mask/save" id="mask-form" style="margin-top:12px">
<input type="hidden" name="id">
<input type="hidden" name="lat">
<input type="hidden" name="lon">
<input type="hidden" name="crs">
<div class="row">
<label class="f" style="flex:1 1 200px;margin-bottom:0">
<span>名称(可选)</span>
<input type="text" name="name" maxlength="40" placeholder="例如:家附近、公司周边">
</label>
<label class="f" style="flex:0 1 150px;margin-bottom:0">
<span>半径(米)</span>
<input type="number" name="radius_m" min="50" max="50000" step="50" value="500" required>
</label>
<button class="btn primary" type="submit" id="mask-save">保存</button>
<button class="btn" type="button" id="mask-reset">清除选择</button>
</div>
<div class="muted" id="mask-hint" style="font-size:12.5px;margin-top:6px">在下方地图上点选圆心</div>
</form>
</div>
<div class="card">
<h2>已有遮罩区({{len .Masks}})</h2>
{{if .Masks}}
<table class="tb">
<thead><tr><th>名称</th><th>圆心(纬度, 经度)</th><th>半径</th><th></th></tr></thead>
<tbody>
{{range .Masks}}
<tr>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
<td class="mono">{{printf "%.5f" .Lat}}, {{printf "%.5f" .Lon}}</td>
<td>{{printf "%.0f" .RadiusM}} 米</td>
<td>
<button class="btn sm" type="button" data-edit
data-id="{{.ID}}" data-name="{{.Name}}"
data-lat="{{.Lat}}" data-lon="{{.Lon}}" data-radius="{{.RadiusM}}">编辑</button>
<form method="post" action="/admin/fog/mask/delete" style="display:inline"
onsubmit="return confirm('删除后这块范围的迷雾会重新对访客显示。确认删除?')">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn sm" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="muted">还没有遮罩区。在地图上点选圆心、设置半径后保存即可。</div>
{{end}}
<div class="card-sub" style="margin-top:10px">遮罩只影响访客:站长登录状态下看到的始终是完整迷雾。</div>
</div>
</div>
<script>
(function () {
// Leaflet 由 layout 在 content 之后引入,脚本执行时 window.L 还没有,
// 必须等 load 事件之后再初始化,否则地图整个不出现(与后台「新增地点」同一条约定)
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
window.addEventListener('load', cb);
}
var el = document.getElementById('mask-map');
var form = document.getElementById('mask-form');
if (!el || !form) return;
var latIn = form.querySelector('[name=lat]'), lonIn = form.querySelector('[name=lon]');
var idIn = form.querySelector('[name=id]'), nameIn = form.querySelector('[name=name]');
var radiusIn = form.querySelector('[name=radius_m]'), crsIn = form.querySelector('[name=crs]');
var hint = document.getElementById('mask-hint');
var saveBtn = document.getElementById('mask-save');
// 底图走站点配置(与后台「新增地点」同一套取法):带密钥的图层由服务端转发,
// 免密钥的是浏览器直连的 {s} 模板,不能硬写 /tiles/ 路由
var fallback = {
def: { url: 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', maxZoom: 18, attr: '© 高德地图', crs: 'gcj02' },
subs: ['1', '2', '3', '4']
};
function init(cfg) {
var def = cfg.def;
var map = L.map(el).setView([34.5, 108.9], 4);
L.tileLayer(def.url, { subdomains: cfg.subs, maxZoom: def.maxZoom || 18, minZoom: 3, attribution: def.attr || '' }).addTo(map);
crsIn.value = def.crs || 'gcj02';
// 已有遮罩区画出来(只读),供对照
var existing = L.layerGroup().addTo(map);
[].forEach.call(document.querySelectorAll('[data-edit]'), function (b) {
var lat = parseFloat(b.dataset.lat), lon = parseFloat(b.dataset.lon), r = parseFloat(b.dataset.radius);
L.marker([lat, lon], { opacity: 0.65 }).addTo(existing);
L.circle([lat, lon], { radius: r, color: '#d9534f', weight: 1.5, fillOpacity: 0.12 }).addTo(existing);
});
var marker = null, circle = null;
function place(lat, lon) {
if (!marker) {
marker = L.marker([lat, lon], { draggable: true }).addTo(map);
marker.on('drag', function (e) { circle.setLatLng(e.target.getLatLng()); });
marker.on('dragend', function (e) { fillCoord(e.target.getLatLng()); });
} else {
marker.setLatLng([lat, lon]);
}
if (!circle) {
circle = L.circle([lat, lon], { radius: parseFloat(radiusIn.value) || 500, color: '#d9534f', fillOpacity: 0.18 }).addTo(map);
} else {
circle.setLatLng([lat, lon]);
}
}
function fillCoord(ll) { latIn.value = ll.lat.toFixed(6); lonIn.value = ll.lng.toFixed(6); }
radiusIn.addEventListener('input', function () {
if (circle) circle.setRadius(Math.max(50, parseFloat(radiusIn.value) || 500));
});
map.on('click', function (e) { place(e.latlng.lat, e.latlng.lng); fillCoord(e.latlng); });
marker = null;
// 列表里的「编辑」:把该行灌进表单,地图上跟过去
[].forEach.call(document.querySelectorAll('[data-edit]'), function (b) {
b.addEventListener('click', function () {
idIn.value = b.dataset.id;
nameIn.value = b.dataset.name || '';
radiusIn.value = Math.round(parseFloat(b.dataset.radius) || 500);
var lat = parseFloat(b.dataset.lat), lon = parseFloat(b.dataset.lon);
place(lat, lon);
fillCoord({ lat: lat, lng: lon });
map.setView([lat, lon], 13);
saveBtn.textContent = '保存修改';
hint.textContent = '正在编辑「' + (b.dataset.name || b.dataset.id) + '」,也可以在地图上点选新圆心';
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
});
});
document.getElementById('mask-reset').addEventListener('click', function () {
form.reset();
idIn.value = '';
radiusIn.value = 500;
if (marker) { map.removeLayer(marker); marker = null; }
if (circle) { map.removeLayer(circle); circle = null; }
saveBtn.textContent = '保存';
hint.textContent = '在下方地图上点选圆心';
});
}
whenReady(function () {
if (!window.L) return;
fetch('/api/map-config', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (c) {
var def = (c && c.base && c.base.length) ? c.base[0] : fallback.def;
init({ def: def, subs: (c && c.subs && c.subs.length) ? c.subs : fallback.subs });
})
.catch(function () { init(fallback); });
});
})();
</script>
{{end}}
+169
View File
@@ -0,0 +1,169 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>后台管理</h1>
<div class="sub">上传 rond 备份、切换展示的数据、调整站点设置</div>
</div>
<div class="row">
<a class="btn" href="/admin/edit">数据编辑</a>
<a class="btn" href="/admin/topics">专题管理</a>
<a class="btn" href="/admin/photos">照片导入</a>
<a class="btn" href="/admin/settings">站点设置</a>
<a class="btn" href="/admin/twofa">两步验证</a>
<a class="btn" href="/admin/notes">地点备注</a>
<a class="btn" href="/admin/password">账号设置</a>
<a class="btn" href="/" target="_blank">查看站点</a>
</div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
{{if .DefaultPW}}<div class="alert err">⚠️ 您仍在使用初始口令 <span class="mono">rond-admin</span>,站点对公网开放时任何人都可以用这组口令登录后台。请尽快<a href="/admin/password">修改密码</a>,并在<a href="/admin/twofa">两步验证</a>中绑定动态口令。</div>{{end}}
<div class="card">
<h2>上传备份</h2>
<div class="card-sub">
在 rond App 中导出备份,得到 <span class="mono">.rondbackup</span> 文件后上传。
单次最大 {{size .MaxSize}}。
<b>上传是以备份为准重建数据</b>:站点里删掉的地点、改过的记录,只要下次传一份
<b>内容不同</b>的备份,就会被备份里的原样覆盖回来(重复传同一份文件不会,会提示「完全一致」)。
在站点上新加的地点属于当时那个数据集,传新备份后要看它们,可在下面的数据集列表里切回。
</div>
<form method="post" action="/admin/upload" enctype="multipart/form-data">
<label class="f" style="max-width:520px">
<span>选择文件</span>
<input type="file" name="backup" accept=".rondbackup,.zip" required
style="padding:8px;border:1px dashed var(--line);border-radius:8px;background:var(--bg-2)">
</label>
<button class="btn primary" type="submit">上传并导入</button>
</form>
</div>
<div class="card">
<h2>世界迷雾</h2>
<div class="card-sub">
导入 Fog of World 导出的 <span class="mono">.fwss</span> 快照,在足迹地图上叠加已探索区域。
迷雾网格为 WGS-84,站点会自动对高德等 GCJ-02 底图做纠偏。
迷雾是站点级数据,不属于任何数据集,删除数据集不影响迷雾。
{{if gt .FogBlocks 0}}
当前已入库 <span class="mono">{{num .FogBlocks}}</span> 块 / <span class="mono">{{num .FogCells}}</span> 格,
估算已探索 <span class="mono">{{printf "%.1f" .FogArea}}</span> km²{{if .FogUpdatedAt}},最近更新 {{dtt .FogUpdatedAt}}{{end}}。
{{else}}
<b>尚未导入迷雾</b>——把 Fog of World 的 <span class="mono">.fwss</span>(在 App 内导出,通常在 zip 里)上传即可点亮地图。
{{end}}
</div>
<form method="post" action="/admin/fog" enctype="multipart/form-data">
<label class="f" style="max-width:520px">
<span>选择快照</span>
<input type="file" name="fwss" accept=".fwss,.zip" required
style="padding:8px;border:1px dashed var(--line);border-radius:8px;background:var(--bg-2)">
</label>
<label class="f" style="max-width:520px">
<span>导入方式</span>
<select name="mode">
<option value="overlay" selected>叠加 — 保留现有迷雾,把新路线合并进来</option>
<option value="cover">覆盖 — 清空现有迷雾,整体替换</option>
</select>
</label>
<button class="btn primary" type="submit">导入迷雾</button>
</form>
{{if gt .FogBlocks 0}}
<div style="margin:10px 0 0;display:flex;gap:8px;flex-wrap:wrap">
<a class="btn" href="/admin/fog/export">导出当前迷雾(.fwss)</a>
<a class="btn" href="/admin/fog/mask">迷雾遮罩区{{if .FogMasks}}({{.FogMasks}}){{end}}</a>
<form method="post" action="/admin/fog/clear"
onsubmit="return confirm('将删除全部迷雾数据(数据库与地图图层同步清空),且不可恢复。确认清空?')">
<button class="btn danger" type="submit">清空迷雾</button>
</form>
</div>
{{end}}
</div>
<div class="card">
<h2>数据导出</h2>
<div class="card-sub">
把当前展示的数据集导出为通用格式,方便备份或迁移到其他平台。
坐标保持入库时的 GCJ-02,GeoJSON 里已注明坐标系。
</div>
<div class="row">
<a class="btn" href="/admin/export/places.geojson">导出地点(GeoJSON)</a>
<a class="btn" href="/admin/export/places.gpx">导出地点(GPX)</a>
</div>
<div class="card-sub" style="margin-top:14px">
也可导出为 rond 原始格式的 <span class="mono">.rondbackup</span>(含你在<a href="/admin/edit">数据编辑</a>里补录的记录),可直接导回 rond App。
坐标为 GCJ-02,原样写回、无需纠偏。
</div>
<a class="btn primary" href="/admin/export/rondbackup">导出 rondbackup(可回导 rond)</a>
</div>
<div class="card">
<div class="row" style="justify-content:space-between;align-items:center">
<h2 style="margin:0">已导入的数据集</h2>
<form method="post" action="/admin/places/backfill"
onsubmit="return confirm('对缺地址 / 没名字的地点逐个反查高德补全?(每次处理 100 个)')">
<button class="btn sm" type="submit">补全缺失地址</button>
</form>
</div>
<div class="card-sub">同一时刻只有一份数据处于「展示中」状态,主页与地图读取的就是它</div>
{{if .Datasets}}
<div class="tw">
<table class="tb">
<thead>
<tr><th>文件</th><th>导入时间</th><th class="num">到访</th><th class="num">地点</th><th>数据跨度</th><th>状态</th><th></th></tr>
</thead>
<tbody>
{{range .Datasets}}
<tr>
<td><span class="mono">{{trunc .Name 34}}</span></td>
<td class="muted">{{dt .UploadedAt}}</td>
<td class="num">{{num .Visits}}</td>
<td class="num">{{num .Places}}</td>
<td class="muted">{{if .First}}{{d .First}} ~ {{d .Last}}{{else}}—{{end}}</td>
<td>
{{if and $.Dataset (eq $.Dataset.ID .ID)}}
<span class="tag">展示中</span>
{{else}}
<span class="muted">未启用</span>
{{end}}
</td>
<td style="white-space:nowrap">
{{if not (and $.Dataset (eq $.Dataset.ID .ID))}}
<form method="post" action="/admin/datasets/{{.ID}}/activate" style="display:inline">
<button class="btn sm" type="submit">启用</button>
</form>
{{end}}
<form method="post" action="/admin/datasets/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('删除后该数据集的所有记录都会从数据库移除,且不可恢复。确认删除?')">
<button class="btn sm danger" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty"><div class="big">📦</div>还没有导入任何数据集</div>
{{end}}
</div>
<div class="card">
<h2>数据说明</h2>
<div class="card-sub">从备份中读取并转换的字段</div>
<div class="tw">
<table class="tb">
<thead><tr><th>来源表</th><th>内容</th><th>用途</th></tr></thead>
<tbody>
<tr><td class="mono">ZVISIT</td><td>到访记录(到达 / 离开时间、类型、地点、备注、标签)</td><td>时间线、统计、地图</td></tr>
<tr><td class="mono">ZLOCATION</td><td>地点(名称、经纬度、城市、省份、POI 分类)</td><td>地图打点、地点库</td></tr>
<tr><td class="mono">ZACTIVITY</td><td>自定义类型(颜色、图标、是否家 / 工作)</td><td>类型分布</td></tr>
<tr><td class="mono">ZMOVEMENT</td><td>位移段(交通方式、起止时间)</td><td>出行方式统计</td></tr>
<tr><td class="mono">ZTAG</td><td>自定义标签及分组</td><td>标签筛选</td></tr>
<tr><td class="mono">ZHOURLYWEATHER</td><td>逐小时天气(气温、天气现象、湿度)</td><td>天气足迹</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{{end}}
+53
View File
@@ -0,0 +1,53 @@
{{define "login"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
{{/* viewport-fit=cover 才会把刘海屏 / 底部 Home 指示条的安全区通过 env() 暴露出来,否则 CSS 里的 safe-area-inset 全是 0 */}}
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>登录后台 · {{.Settings.SiteTitle}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/app.css">
<style>:root{--accent:{{.Settings.AccentColor}};--accent-soft:{{.Settings.AccentColor}}1a}</style>
</head>
<body>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px">
<div style="width:100%;max-width:368px">
<div style="text-align:center;margin-bottom:22px">
<div style="font-size:30px">{{.Settings.OwnerAvatar}}</div>
<h1 style="font-size:19px;margin-top:6px">{{.Settings.SiteTitle}}</h1>
<div class="muted" style="font-size:13px">后台管理</div>
</div>
<div class="card" style="margin:0">
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
{{if .Flash}}<div class="alert info">{{.Flash}}</div>{{end}}
<form method="post" action="/admin/login">
<input type="hidden" name="next" value="{{.Next}}">
<label class="f"><span>用户名</span><input type="text" name="username" autocomplete="username" autofocus required></label>
<label class="f"><span>密码</span><input type="password" name="password" autocomplete="current-password" required></label>
<label class="f"><span>验证码</span>
{{/* 用 div 而不是 span:label.f > span 会把 display 压成 block,.row 的 flex 就失效了。
输入框要显式 width:auto —— 全局的 input[type=text]{width:100%} 会把图挤到下一行。 */}}
<div class="row" style="flex-wrap:nowrap;gap:8px">
<input type="text" name="captcha" inputmode="latin" autocomplete="off"
spellcheck="false" maxlength="6" required style="flex:1 1 0;width:auto;min-width:0">
<img id="captcha-img" src="/captcha" alt="验证码" width="130" height="40"
title="看不清?点击换一张"
style="border:1px solid var(--line);border-radius:6px;cursor:pointer;flex:0 0 auto">
</div>
</label>
<button class="btn primary" type="submit" style="width:100%;justify-content:center">登录</button>
</form>
<script>
// 换一张:带上时间戳绕开缓存;服务端每次出图都会下发新的 id cookie
document.getElementById('captcha-img').addEventListener('click', function () {
this.src = '/captcha?t=' + Date.now();
});
</script>
</div>
<div style="text-align:center;margin-top:16px"><a class="muted" href="/" style="font-size:13px">← 返回主页</a></div>
</div>
</div>
</body>
</html>{{end}}
+189
View File
@@ -0,0 +1,189 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>地点备注</h1>
<div class="sub">列出当前数据集的全部地点,可给任意地点起别名、写备注、标「推荐 / 踩雷」;别名会替代原名显示</div>
</div>
<div class="row">
<a class="btn" href="/admin/settings">可见性设置</a>
<a class="btn" href="/admin">← 返回后台</a>
</div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div class="card">
<div class="card-sub" style="margin:0 0 10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<span>
备注对外{{if .NotesPublic}}<b>公开</b>(访客也能看到){{else}}<b>不公开</b>(只有你能看到){{end}}
· 共 {{num .Total}} 个地点
</span>
<form method="post" action="/admin/notes/rond" style="margin:0">
<button class="btn sm" type="submit"
title="把 rond 备份里按次到访写的备注汇总成地点备注;只重写「来自 rond」那一块,你手写的内容不动">
从 rond 备份回填备注
</button>
</form>
</div>
<form method="get" action="/admin/notes" class="row" style="gap:8px;flex-wrap:wrap;align-items:flex-end;margin:0">
<label class="f" style="margin:0;flex:1 1 200px"><span>关键词</span>
<input type="text" name="q" value="{{.Query}}" placeholder="地点名 / 别名 / 备注 / 城市">
</label>
<label class="f" style="margin:0;flex:0 0 145px"><span>状态</span>
<select name="status">
<option value="all" {{if eq .Status "all"}}selected{{end}}>全部地点</option>
<option value="has" {{if eq .Status "has"}}selected{{end}}>仅有备注</option>
<option value="none" {{if eq .Status "none"}}selected{{end}}>仅无备注</option>
</select>
</label>
<label class="f" style="margin:0;flex:0 0 150px"><span>城市</span>
<select name="city">
<option value="">全部城市</option>
{{range .CityOptions}}<option value="{{.}}" {{if eq . $.City}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
<label class="f" style="margin:0;flex:0 0 140px"><span>排序</span>
<select name="sort">
<option value="visit" {{if eq .Sort "visit"}}selected{{end}}>到访次数</option>
<option value="note" {{if eq .Sort "note"}}selected{{end}}>最近编辑</option>
<option value="talk" {{if eq .Sort "talk"}}selected{{end}}>票数</option>
<option value="name" {{if eq .Sort "name"}}selected{{end}}>名称</option>
</select>
</label>
<button class="btn primary" type="submit">筛选</button>
<a class="btn" href="/admin/notes">重置</a>
</form>
</div>
{{if .Rows}}
<form id="bulk-form" method="post" action="/admin/notes/bulk"></form>
<div class="card" style="position:sticky;top:8px;z-index:3;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label class="check" style="margin:0"><input type="checkbox" id="chk-all"> 全选本页</label>
<span class="muted" style="font-size:12.5px">批量:</span>
<select name="verdict" form="bulk-form" style="padding:5px 8px;border:1px solid var(--line);border-radius:8px">
<option value="1">👍 推荐</option>
<option value="-1">👎 踩雷</option>
<option value="0">取消结论</option>
</select>
<button class="btn sm" type="submit" form="bulk-form" name="action" value="verdict">设为该结论</button>
<button class="btn sm" type="submit" form="bulk-form" name="action" value="clear"
onclick="return confirm('清空所选地点的手写备注?(别名与「来自 rond」的备注都会保留)')">清空备注</button>
<button class="btn sm danger" type="submit" form="bulk-form" name="action" value="delete"
onclick="return confirm('删除所选地点的手写备注与别名?(rond 备份里的备注下次回填还会再出现)')">删除备注</button>
</div>
<div class="card" style="padding:0">
<div class="list">
{{range .Rows}}
<div class="item" style="grid-template-columns:28px 1fr;gap:10px;align-items:stretch">
<div style="display:flex;align-items:flex-start;padding-top:10px">
<input type="checkbox" name="src_pk" value="{{.SrcPK}}" form="bulk-form" class="row-chk">
</div>
<div>
<form method="post" action="/admin/place/note" style="display:grid;grid-template-columns:1fr 1.4fr auto;gap:10px;align-items:end">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<input type="hidden" name="next" value="{{$.BackURL}}">
<label class="f" style="margin:0">
<span>别名{{if .PlaceName}}(原名:{{.PlaceName}}{{if .PlaceCity}} · {{.PlaceCity}}{{end}}){{end}}</span>
<input type="text" name="alias" value="{{.Alias}}" maxlength="60" placeholder="{{.PlaceName}}">
</label>
<label class="f" style="margin:0">
<span>备注{{if .PlaceID}} <a href="/places/{{.PlaceID}}" target="_blank">查看 ↗</a>{{end}}</span>
<input type="text" name="note" value="{{.Content}}" maxlength="2000">
</label> <div class="row" style="gap:6px;flex-wrap:nowrap">
<button class="btn primary" type="submit">保存</button>
<button class="vote-btn up {{if eq .Verdict 1}}on{{end}}" type="button"
data-verdict-btn data-src="{{.SrcPK}}" data-v="1" title="标为推荐">👍</button>
<button class="vote-btn down {{if eq .Verdict -1}}on{{end}}" type="button"
data-verdict-btn data-src="{{.SrcPK}}" data-v="-1" title="标为踩雷">👎</button>
</div>
</form>
<div class="muted" style="font-size:12px;margin-top:4px">
地点 ID {{.SrcPK}}{{if .VisitCount}} · 到访 {{num .VisitCount}} 次{{end}}
<span class="vote-tip" data-verdict-status="{{.SrcPK}}">{{if eq .Verdict 1}} · 已标推荐{{else if eq .Verdict -1}} · 已标踩雷{{end}}</span>
{{if or .Up .Down}} · 访客 👍{{.Up}} 👎{{.Down}}{{end}}
{{if .Content}} · 更新于 {{dt .UpdatedAt}}{{end}}
</div>
{{if .RondNote}}<div class="note-block">来自 rond:{{.RondNote}}</div>{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{if gt .PageCount 1}}
<div class="pager">
{{if gt .PageNo 1}}<a href="{{.PageLink (sub .PageNo 1)}}">上一页</a>{{end}}
<span class="cur">第 {{.PageNo}} / {{.PageCount}} 页</span>
{{if lt .PageNo .PageCount}}<a href="{{.PageLink (add .PageNo 1)}}">下一页</a>{{end}}
</div>
{{end}}
<script>
(function () {
var all = document.getElementById('chk-all');
var boxes = document.querySelectorAll('.row-chk');
if (all) {
all.addEventListener('change', function () {
boxes.forEach(function (b) { b.checked = all.checked; });
});
}
// 行内 👍/👎 用 AJAX(原来是 formaction 提交 → 整页刷新,会把同一行没保存的备注编辑一起丢掉)
document.querySelectorAll('.vote-btn[data-verdict-btn]').forEach(function (b) {
b.addEventListener('click', function () {
var src = b.getAttribute('data-src');
var v = Number(b.getAttribute('data-v'));
fetch('/api/verdict', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
body: 'src_pk=' + src + '&verdict=' + (b.classList.contains('on') ? 0 : v)
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) { alert(d.error); return; }
var cur = Number(d.verdict) || 0;
b.parentElement.querySelectorAll('.vote-btn').forEach(function (x) {
x.classList.toggle('on', Number(x.getAttribute('data-v')) === cur);
});
var st = document.querySelector('[data-verdict-status="' + src + '"]');
if (st) {
st.className = 'vote-tip' + (cur === 1 ? ' up' : cur === -1 ? ' down' : '');
st.textContent = cur === 1 ? ' · 已标推荐' : cur === -1 ? ' · 已标踩雷' : '';
}
}).catch(function () {});
});
});
})();
</script>
{{else}}
<div class="empty"><div class="big">📝</div>{{if .Query}}没有匹配的地点{{else}}当前数据集里还没有地点{{end}}</div>
{{end}}
{{if .Orphans}}
<div class="card" style="padding:0;margin-top:16px">
<div style="padding:12px 14px 4px"><h2 style="margin:0">已不在当前数据集的备注</h2>
<div class="card-sub">这些备注挂在当前备份里已不存在的地点上(换了快照的旧地点),可逐条清理。</div>
</div>
<div class="list">
{{range .Orphans}}
<div class="item" style="grid-template-columns:1fr auto;gap:10px;align-items:center">
<div>
<div>{{if .Alias}}{{.Alias}}{{else}}<span class="muted">(无别名)</span>{{end}}{{if .Content}} · <span class="muted">{{.Content}}</span>{{end}}{{if .RondNote}} · <span class="muted">来自 rond:{{.RondNote}}</span>{{end}}</div>
<div class="muted" style="font-size:12px">地点 ID {{.SrcPK}} · 更新于 {{dt .UpdatedAt}}</div>
</div>
<form method="post" action="/admin/place/note">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<input type="hidden" name="alias" value="">
<input type="hidden" name="note" value="">
<input type="hidden" name="next" value="{{$.BackURL}}">
<button class="btn danger" type="submit" onclick="return confirm('删除这条备注?')">删除</button>
</form>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
@@ -0,0 +1,35 @@
{{define "content"}}
<div class="wrap" style="max-width:520px">
<div class="page-head">
<div>
<h1>账号设置</h1>
<div class="sub">修改密码后当前会话会失效,需要用新密码重新登录</div>
</div>
</div>
<div class="card">
<h2 style="font-size:14px;margin:0 0 10px">用户名</h2>
{{if eq .Flash "name"}}<div class="alert ok">用户名已更新</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<form method="post" action="/admin/username">
<label class="f"><span>用户名(2~32 个字符,不含空格)</span><input type="text" name="username" value="{{.Username}}" maxlength="32" autocomplete="username" required></label>
<label class="f"><span>当前密码</span><input type="password" name="current" autocomplete="current-password" required></label>
<div class="row"><button class="btn primary" type="submit">修改用户名</button></div>
</form>
</div>
<div class="card">
<h2 style="font-size:14px;margin:0 0 10px">密码</h2>
{{if and .Flash (ne .Flash "name")}}<div class="alert ok">密码已更新</div>{{end}}
<form method="post" action="/admin/password">
<label class="f"><span>当前密码</span><input type="password" name="current" autocomplete="current-password" required></label>
<label class="f"><span>新密码(至少 8 位)</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
<label class="f"><span>确认新密码</span><input type="password" name="confirm" autocomplete="new-password" minlength="8" required></label>
<div class="row">
<button class="btn primary" type="submit">修改密码</button>
<a class="btn" href="/admin">返回后台</a>
</div>
</form>
</div>
</div>
{{end}}
+746
View File
@@ -0,0 +1,746 @@
{{define "content"}}
{{/* 解析器放最前:它必须在用户选完文件之前就绪,放页面底部会晚一步 */}}
<script src="/static/js/photo-exif.js?v={{.AssetV}}"></script>
<script src="/static/js/place-image.js?v={{.AssetV}}"></script>
{{/* 选项数据放隐藏 select 里给脚本读:内联 JSON 会被模板的 JS 上下文转义搞坏 */}}
<select id="category-options" hidden>
<option value="">未指定</option>
{{range .Categories}}<option value="{{.Label}}">{{.Name}}</option>{{end}}
<option value="__custom__">自定义…</option>
</select>
<select id="activity-options" hidden>
<option value="">未指定</option>
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<select id="tag-options" hidden>
{{range .Tags}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<div class="wrap">
<div class="page-head">
<div>
<h1>照片导入</h1>
<div class="sub">从照片的 EXIF 读拍摄时间与坐标,反查地址后逐条确认,再写入数据库</div>
</div>
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div class="card">
<h2>1. 选照片与坐标系</h2>
<div class="card-sub">
支持 JPEG 与 HEIC/HEIF。<b>EXIF 在浏览器里读取,照片不会上传到服务器</b>——
一次几十张、几百 MB 都没有上传耗时或存储压力。
</div>
<div class="row" style="align-items:center;margin-bottom:10px">
<span class="muted" style="font-size:12.5px">照片里的坐标是</span>
<label class="check" style="margin:0"><input type="radio" name="coord-sel" value="wgs84" checked> WGS-84(绝大多数手机)</label>
<label class="check" style="margin:0"><input type="radio" name="coord-sel" value="gcj02"> 已经是 GCJ-02(部分国产手机会这样写)</label>
</div>
{{if not .GeocodeReady}}
<div class="alert err" style="margin-bottom:10px">未配置高德 <span class="mono">geocode_key</span>,无法反查地址,只能靠坐标判断。</div>
{{end}}
<div class="row" style="align-items:center;gap:10px">
<label class="btn primary" for="photo-input" style="cursor:pointer">+ 选择照片</label>
<input type="file" id="photo-input" accept="image/*" multiple hidden>
<span class="muted" id="pick-count" style="font-size:12.5px">可多选,JPEG / HEIC 都行</span>
</div>
<div id="parse-status" class="alert info"></div>
</div>
<form method="post" action="/admin/photos/import" id="import-form" enctype="multipart/form-data">
<input type="hidden" name="n" id="row-count" value="0">
<input type="hidden" name="coord" id="coord-field" value="wgs84">
<div class="card" id="preview-card" style="display:none">
<h2>2. 确认与修改</h2>
<div class="card-sub">对照「地址」列确认点是否正确;点该行的「更多」可以改类别、省市区街道、活动、标签等——字段与<a href="/admin/edit">新增地点</a>完全一致。</div>
<div id="map-card" style="display:none">
<div id="photo-map" class="photo-map"></div>
<div class="card-sub" style="margin:6px 2px 0">地图上是所有已读取的点(坐标已按上面的选择换算);点跑到国外或海里说明坐标系选错了。<b>鼠标移到下面任意一行,地图上对应的点会跳出来</b>,点「地图」可以看单点大图。</div>
</div>
<div class="kpis" style="margin:12px 0 14px">
<div class="kpi"><div class="v" id="k-total">0</div><div class="k">读取到的照片</div></div>
<div class="kpi"><div class="v" id="k-gps">0</div><div class="k">含坐标</div></div>
<div class="kpi"><div class="v" id="k-addr">0</div><div class="k">地址反查成功</div></div>
<div class="kpi"><div class="v" id="k-todo">0</div><div class="k">待补坐标 / 时间</div></div>
</div>
{{/* 固定列宽比例:输入框跟着列宽走,表格就不会被撑宽到需要横向拖动 */}}
<div class="tw">
<table class="tb photo-table">
<colgroup>
<col style="width:4%"><col style="width:7%"><col style="width:10%"><col style="width:11%">
<col style="width:11%"><col style="width:19%"><col style="width:12%"><col style="width:12%">
<col style="width:14%">
</colgroup>
<thead>
<tr>
<th>导入</th><th>存图</th><th>文件</th><th>地点名</th><th>坐标</th><th>地址(反查)</th>
<th>到达时间</th><th>离开时间</th><th>说明</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="row" style="margin-top:12px;gap:10px">
<button class="btn primary" type="submit">写入数据库</button>
<button class="btn" type="button" id="retry-btn">重新反查未成功的</button>
<button class="btn" type="button" id="reset-btn">重新选照片</button>
</div>
<div class="req-tip" style="margin-top:8px">导入后每张照片会生成一个地点 + 一条到访(标记为「补录」),可在<a href="/admin/edit">数据编辑</a>里继续调整或删除。</div>
</div>
</form>
</div>
{{/* 单个点位的地图:总图留着看分布,这一个用来看「这个点到底在哪」 */}}
<div id="point-modal" style="display:none">
<div class="point-modal-box">
<div class="point-modal-head">
<b id="point-modal-title">点位</b>
<button class="btn sm" type="button" id="point-modal-close">关闭</button>
</div>
<div id="point-modal-map" class="point-modal-map"></div>
<div id="point-modal-meta" class="card-sub" style="margin:8px 2px 0"></div>
</div>
</div>
<script>
(function () {
var DEFAULT_STAY_MIN = 10;
var input = document.getElementById('photo-input');
var rowsBox = document.getElementById('rows');
var previewCard = document.getElementById('preview-card');
var mapCard = document.getElementById('map-card');
var status = document.getElementById('parse-status');
var rowCount = document.getElementById('row-count');
var coordField = document.getElementById('coord-field');
if (!input || !window.rondExif) return;
function coordSel() {
var el = document.querySelector('input[name=coord-sel]:checked');
return el ? el.value : 'wgs84';
}
function pad(n) { return (n < 10 ? '0' : '') + n; }
// datetime-local 需要的本地时间串(EXIF 没有时区,按拍摄地当地时间理解)
function dtLocal(d) {
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function tInput(name, value, placeholder, width) {
var el = document.createElement('input');
el.type = 'text';
el.name = name;
el.value = value || '';
el.placeholder = placeholder || '';
// 宽度交给列宽(photo-table 是 fixed 布局,input 撑满格子即可)
el.style.width = '100%';
el.style.minWidth = '0';
return el;
}
function dtInput(name, value) {
var el = document.createElement('input');
el.type = 'datetime-local';
el.name = name;
el.value = value || '';
el.style.width = '100%';
el.style.minWidth = '0';
return el;
}
function fieldLabel(text, control, flex) {
var lab = document.createElement('label');
lab.className = 'f';
lab.style.cssText = flex + ';margin-bottom:0';
var sp = document.createElement('span');
sp.textContent = text;
lab.appendChild(sp);
lab.appendChild(control);
return lab;
}
// 更多里的分组小标题:一堆输入框挤在一起像锅粥,每组给一句说明
function groupCap(text) {
var d = document.createElement('div');
d.className = 'card-sub';
d.style.cssText = 'margin:0 0 4px';
d.textContent = text;
return d;
}
// 鼠标划到某一行时把图上那个点挑出来(index 为 null 表示复原)。
// 记一下当前高亮的是谁:鼠标在「主行 ↔ 更多块」之间移动会连发几次事件,
// 行号没变就不必让地图重设一遍样式(会闪)。
var hoveredRow = null;
function highlightRow(i) {
if (hoveredRow === i) return;
hoveredRow = i;
if (!window.rondMap) return;
window.rondMap.highlight('photo-map', i === null ? null : 'row-' + i);
}
// 主行和它的「更多」块是两条独立的 <tr>,各自绑 mouseleave 的话,
// 鼠标一进「更多」块就会被主行的 leave 把高亮清掉——而改字段往往就在那块里。
// 所以两行打上同一个行号,统一按「光标底下那一行是谁」来算。
rowsBox.addEventListener('mouseover', function (e) {
var tr = e.target && e.target.closest ? e.target.closest('tr') : null;
highlightRow(tr && tr.dataset.rowIndex !== undefined ? Number(tr.dataset.rowIndex) : null);
});
rowsBox.addEventListener('mouseleave', function () { highlightRow(null); });
// 按隐藏 select 的选项克隆一个独立的下拉(每行一份,互不影响)
function makeSelect(name, srcId) {
var src = document.getElementById(srcId);
var sel = document.createElement('select');
sel.name = name;
if (src) {
[].forEach.call(src.options, function (o) {
var opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.textContent;
sel.appendChild(opt);
});
}
return sel;
}
function setVal(name, v) {
var el = rowsBox.querySelector('input[name="' + name + '"], select[name="' + name + '"]');
if (el && v) el.value = v;
}
function valOf(idx, field) {
var el = rowsBox.querySelector('[name="' + field + '_' + idx + '"]');
return el ? el.value : '';
}
// 一行 = 主行(常用字段)+ 默认收起的「更多」行(字段与新增地点一致)
function addRow(index, file, info, errMsg) {
var tr = document.createElement('tr');
var td = document.createElement('td');
var keep = document.createElement('input');
keep.type = 'checkbox';
keep.name = 'keep_' + index;
keep.checked = !!(info && info.hasGPS); // 没读到坐标的默认不导入
td.appendChild(keep);
tr.appendChild(td);
// 存图:勾上就把这张照片在浏览器里转成 WebP 一起提交,作为该地点的配图。
// 默认不勾——一次导入几十张全存会占不少空间,也可能把相册里的截图一起带进去。
td = document.createElement('td');
var keepImg = document.createElement('input');
keepImg.type = 'checkbox';
keepImg.name = 'keepimg_' + index;
keepImg.title = '把这张照片在浏览器里转成 WebP(长边 1440px、约 100~250KB)存为该地点的图片,地点页可查看、可删';
td.appendChild(keepImg);
tr.appendChild(td);
td = document.createElement('td');
td.className = 'muted';
td.style.cssText = 'max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
td.textContent = file.name;
td.title = file.name;
tr.appendChild(td);
td = document.createElement('td');
var initialName = file.name.replace(/\.[^.]+$/, '');
var nameInput = tInput('name_' + index, initialName, '地点名', '120px');
nameInput.dataset.initial = initialName;
td.appendChild(nameInput);
tr.appendChild(td);
td = document.createElement('td');
var latEl = tInput('lat_' + index, info && info.hasGPS ? info.lat.toFixed(6) : '', '纬度', '98px');
var lonEl = tInput('lon_' + index, info && info.hasGPS ? info.lon.toFixed(6) : '', '经度', '98px');
latEl.style.marginBottom = '4px';
td.appendChild(latEl);
td.appendChild(lonEl);
tr.appendChild(td);
td = document.createElement('td');
td.className = 'muted';
td.style.cssText = 'max-width:260px';
var addrEl = document.createElement('div');
addrEl.textContent = '—';
td.appendChild(addrEl);
tr.appendChild(td);
var arrival = '', departure = '';
if (info && info.hasTime && info.shot) {
arrival = dtLocal(info.shot);
departure = dtLocal(new Date(info.shot.getTime() + DEFAULT_STAY_MIN * 60000));
}
td = document.createElement('td');
td.appendChild(dtInput('arrival_' + index, arrival));
tr.appendChild(td);
td = document.createElement('td');
td.appendChild(dtInput('departure_' + index, departure));
tr.appendChild(td);
var notes = [];
if (errMsg) notes.push(errMsg);
if (info && info.hasGPS && !info.hasTime) notes.push('没有拍摄时间,请手工填写');
if (info && !info.hasGPS && info.hasTime) notes.push('没有 GPS 坐标,请手工填写');
td = document.createElement('td');
td.className = 'muted';
td.style.maxWidth = '220px';
var noteEl = document.createElement('div');
noteEl.textContent = notes.join(';');
td.appendChild(noteEl);
// 操作链接单独一行且整体不折行——中文默认能在任意字间断开,
// 「地图」会被拆成「地」「图」两行
var acts = document.createElement('div');
acts.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;margin-top:6px';
var mapLink = document.createElement('a');
mapLink.href = 'javascript:void(0)';
mapLink.textContent = '地图';
mapLink.className = 'btn sm';
mapLink.addEventListener('click', function () {
var lat = parseFloat(valOf(index, 'lat')), lon = parseFloat(valOf(index, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) { window.alert('这一行还没有坐标'); return; }
openPointMap(lat, lon, valOf(index, 'name'), addrEl.textContent);
});
acts.appendChild(mapLink);
var moreLink = document.createElement('a');
moreLink.href = 'javascript:void(0)';
moreLink.textContent = '更多';
moreLink.className = 'btn sm';
acts.appendChild(moreLink);
td.appendChild(acts);
tr.appendChild(td);
rowsBox.appendChild(tr);
// ---- 更多:地点属性(与新增地点一致)----
var tr2 = document.createElement('tr');
tr2.style.display = 'none';
var td2 = document.createElement('td');
// 主行是 9 列(colSpan 少一列的话,这块灰底就比上面窄一格,看着很突兀)
td2.colSpan = 9;
td2.style.cssText = 'background:var(--bg-2);padding:12px 14px'
var row1 = document.createElement('div');
row1.className = 'row';
var catSel = makeSelect('category_' + index, 'category-options');
var catCustom = tInput('category_custom_' + index, '', '如 公园', '140px');
var catCustomWrap = fieldLabel('自定义类别', catCustom, 'flex:0 1 150px');
catCustomWrap.style.display = 'none';
catSel.addEventListener('change', function () {
catCustomWrap.style.display = catSel.value === '__custom__' ? '' : 'none';
});
td2.appendChild(groupCap('地点属性'));
row1.appendChild(fieldLabel('类别', catSel, 'flex:0 1 160px'));
row1.appendChild(catCustomWrap);
[
['country_' + index, '国家代码', 'CN', '90px'],
['province_' + index, '省份', '', '100px'],
['city_' + index, '城市', '', '100px'],
['district_' + index, '区县', '', '100px'],
['street_' + index, '街道', '', '140px'],
['timezone_' + index, '时区', 'Asia/Shanghai', '130px']
].forEach(function (spec) {
row1.appendChild(fieldLabel(spec[1], tInput(spec[0], spec[2], '', spec[3]), 'flex:0 1 ' + spec[3]));
});
td2.appendChild(row1);
// ---- 更多:到访属性(与新增到访一致)----
var row2 = document.createElement('div');
row2.className = 'row';
var cap2 = groupCap('到访属性');
cap2.style.marginTop = '14px';
td2.appendChild(cap2);
row2.appendChild(fieldLabel('活动', makeSelect('activity_' + index, 'activity-options'), 'flex:0 1 170px'));
// 备注不设 flex-grow:让它自己那么宽就行,否则会被拉伸到最右,把「标记为收藏」挤出视野
row2.appendChild(fieldLabel('备注', tInput('remark_' + index, '', '可选', '200px'), 'flex:0 1 260px'));
var favWrap = document.createElement('label');
favWrap.className = 'check';
favWrap.style.cssText = 'margin:0;flex:0 0 auto;align-self:flex-end;padding-bottom:9px';
var fav = document.createElement('input');
fav.type = 'checkbox';
fav.name = 'bookmarked_' + index;
favWrap.appendChild(fav);
favWrap.appendChild(document.createTextNode(' 标记为收藏'));
row2.appendChild(favWrap);
td2.appendChild(row2);
// ---- 更多:标签(多选 chips)----
var tagSrc = document.getElementById('tag-options');
if (tagSrc && tagSrc.options.length) {
var cap3 = groupCap('标签');
cap3.style.marginTop = '14px';
td2.appendChild(cap3);
var row3 = document.createElement('div');
row3.className = 'chips';
[].forEach.call(tagSrc.options, function (o) {
var chip = document.createElement('label');
chip.className = 'chip';
chip.style.cursor = 'pointer';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.name = 'tags_' + index;
cb.value = o.value;
cb.style.marginRight = '5px';
chip.appendChild(cb);
chip.appendChild(document.createTextNode(o.textContent));
row3.appendChild(chip);
});
td2.appendChild(row3);
}
tr2.appendChild(td2);
rowsBox.appendChild(tr2);
moreLink.addEventListener('click', function () {
var show = tr2.style.display === 'none';
tr2.style.display = show ? '' : 'none';
moreLink.textContent = show ? '收起' : '更多';
});
// 行号打在两行 <tr> 上(主行 + 「更多」行),rowsBox 的 mouseover 靠它找回来
tr.dataset.rowIndex = index;
tr2.dataset.rowIndex = index;
return { addrEl: addrEl, file: file, keepImg: keepImg };
}
function initPickers() {
try {
if (!window.flatpickr) return;
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
rowsBox.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
if (el._flatpickr) return;
flatpickr(el, { enableTime: true, time_24hr: true, allowInput: true, minuteIncrement: 5, dateFormat: 'Y-m-dTH:i' });
});
} catch (e) { console.error('flatpickr 初始化失败', e); }
}
// 反查地址:坐标交给服务端(坐标系换算也在那边做),拿回地址与换算后的坐标
function lookup(coords) {
return fetch('/api/regeo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coord: coordSel(), points: coords })
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) throw new Error(d.error);
return d.items || [];
});
}
// 世代号:连续选两次文件时,旧那一轮的解析结果一律丢弃,避免表格出现重复行
var gen = 0;
// rowRefs 提到外层:applyOne 与「重新反查」按钮都要按行号找回那一行的地址元素
var rowRefs = {};
input.addEventListener('change', function () {
var files = [].slice.call(input.files || []);
if (!files.length) return;
var my = ++gen;
rowsBox.innerHTML = '';
rowCount.value = '0';
previewCard.style.display = '';
mapCard.style.display = 'none';
status.textContent = '正在读取 ' + files.length + ' 张照片的 EXIF…';
var pickCount = document.getElementById('pick-count');
if (pickCount) pickCount.textContent = '已选 ' + files.length + ' 张';
rowRefs = {};
var done = 0, withGPS = 0;
var chain = Promise.resolve();
files.forEach(function (file, i) {
chain = chain.then(function () {
if (my !== gen) return;
return window.rondExif.read(file).then(function (info) {
if (my !== gen) return;
rowRefs[i] = addRow(i, file, info, null);
if (info.hasGPS) withGPS++;
}).catch(function (err) {
if (my !== gen) return;
rowRefs[i] = addRow(i, file, null, (err && err.message) || '读取失败');
}).then(function () {
if (my !== gen) return;
done++;
status.textContent = '正在读取 ' + done + '/' + files.length + '…';
initPickers();
});
});
});
chain.then(function () {
if (my !== gen) return;
rowCount.value = String(files.length);
// EXIF 一读完就把数字亮出来(此时还没反查,地址数为 0);后面每条路径都会再刷
updateKpis();
if (!withGPS) {
status.textContent = '已读取 ' + files.length + ' 张,但都没有可用的 GPS 坐标。';
return;
}
status.textContent = '已读取 ' + files.length + ' 张(' + withGPS + ' 张含坐标),正在反查地址…';
// 收集坐标:key 用行号,服务端去重后仍能配回每一行
var coords = [], owner = [];
files.forEach(function (file, i) {
var lat = parseFloat(valOf(i, 'lat')), lon = parseFloat(valOf(i, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) return;
coords.push({ lat: lat, lon: lon, key: String(i) });
owner.push(i);
});
if (!coords.length) {
status.textContent = '已读取 ' + files.length + ' 张,没有可反查的坐标。';
return;
}
lookup(coords).then(function (items) {
if (my !== gen) return;
var byKey = {};
items.forEach(function (it) { byKey[it.key] = it; });
var hit = 0;
owner.forEach(function (i) {
var it = byKey[String(i)];
if (!it || !it.address) return;
hit++;
applyOne(i, it);
});
finishLookup(files.length, withGPS, hit, owner.length);
}).catch(function (err) {
if (my !== gen) return;
status.textContent = '已读取 ' + files.length + ' 张(' + withGPS + ' 张含坐标),但地址反查失败:' +
(err.message || err) + '。可以点「重新反查未成功的」再试,或只看坐标判断。';
drawMap();
});
});
});
// applyOne 把一条反查结果写回它的行:地址、店名、坐标、省市区街道、地点名
function applyOne(i, it) {
if (rowRefs[i]) {
// 有店名时显示成「店名 · 完整地址」,比干巴巴的街道好认
var text = it.address || '';
if (it.poi) {
text = it.poi + (it.poiDist ? '(' + Math.round(it.poiDist) + 'm)' : '') + ' · ' + text;
}
rowRefs[i].addrEl.textContent = text;
rowRefs[i].addrEl.title = text;
}
// 服务端返回的是换算后的 GCJ-02 坐标,写回表格保证导入的就是它
if (it.lat && it.lon) {
var latEl = rowsBox.querySelector('input[name=lat_' + i + ']');
var lonEl = rowsBox.querySelector('input[name=lon_' + i + ']');
if (latEl) latEl.value = it.lat.toFixed(6);
if (lonEl) lonEl.value = it.lon.toFixed(6);
}
setVal('province_' + i, it.province);
setVal('city_' + i, it.city);
setVal('district_' + i, it.district);
setVal('street_' + i, it.street);
if (it.country) setVal('country_' + i, it.country);
var nameEl = rowsBox.querySelector('input[name=name_' + i + ']');
if (nameEl && nameEl.value === nameEl.dataset.initial) {
// 名称还停在文件名(用户没改过)时优先用店名,其次「区县 + 街道/乡镇」
var better = it.poi || [it.district, it.street || it.township].filter(Boolean).join('');
if (better) nameEl.value = better;
}
}
// 四个数字比一行长句子好扫。待补=没有坐标或没有到达时间的行(这两项不补就导不进去)
function kpi(id, v) { var el = document.getElementById(id); if (el) el.textContent = String(v); }
// 全部从 DOM 现算,不靠调用方传参:谁改了表格谁就调一次 updateKpis() 即可。
// 之前是「哪些路径记得传参才更新」,漏了「全都没 GPS」「没有可反查的坐标」「重新反查」
// 这几条,数字会停在上一批的值上——比显示 0 更容易误导。
function updateKpis() {
var total = 0, gps = 0, addr = 0, todo = 0;
Object.keys(rowRefs).forEach(function (k) {
var i = Number(k);
total++;
var lat = parseFloat(valOf(i, 'lat')), lon = parseFloat(valOf(i, 'lon'));
var hasPoint = !isNaN(lat) && !isNaN(lon) && !!(lat || lon);
if (hasPoint) gps++;
// 地址列还停在占位符 = 这一行没反查成功(与「重新反查」挑行的判据同源)
var ref = rowRefs[i];
if (ref && ref.addrEl && ref.addrEl.textContent !== '—') addr++;
if (!hasPoint || !valOf(i, 'arrival')) todo++;
});
kpi('k-total', total); kpi('k-gps', gps); kpi('k-addr', addr); kpi('k-todo', todo);
}
// 手工改过坐标/时间之后数字要跟着变,不然一直挂在旧值上
rowsBox.addEventListener('change', updateKpis);
function finishLookup(total, withGPS, hit, asked) {
status.textContent = '已读取 ' + total + ' 张(' + withGPS + ' 张含坐标),' +
hit + '/' + asked + ' 个点位反查成功' +
(hit < asked ? ',可点「重新反查未成功的」再试' : '') + '。确认无误后点下面的按钮写入数据库。';
drawMap();
}
// ---- 单个点位的地图弹窗:只建一个 Leaflet 实例,之后靠 setPoints/setView 复用 ----
var modal = document.getElementById('point-modal');
var modalInited = false;
function openPointMap(lat, lon, name, addr) {
document.getElementById('point-modal-title').textContent = name || '点位';
document.getElementById('point-modal-meta').textContent =
(addr && addr !== '—' ? addr + ' · ' : '') + lat.toFixed(6) + ', ' + lon.toFixed(6);
modal.style.display = 'flex';
var pts = [{ lat: lat, lon: lon, n: name || '', v: 1 }];
if (!modalInited) {
if (!window.rondMap) { document.getElementById('point-modal-meta').textContent += '(地图脚本未就绪)'; return; }
window.rondMap.preview({ id: 'point-modal-map', points: pts });
modalInited = true;
// 单个点 fitBounds 会顶到最大缩放,等底图就绪后固定到街区级
var pin = function () {
var st = window.rondMap._state['point-modal-map'];
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
};
setTimeout(pin, 400);
setTimeout(pin, 1200);
} else {
var st = window.rondMap._state['point-modal-map'];
if (st && st.setPoints) st.setPoints(pts);
if (st && st.map) { st.map.invalidateSize(); st.map.setView([lat, lon], 16); }
}
}
function closePointMap() { modal.style.display = 'none'; }
document.getElementById('point-modal-close').addEventListener('click', closePointMap);
modal.addEventListener('click', function (e) { if (e.target === modal) closePointMap(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePointMap(); });
function drawMap() {
// 放最前面:这是「表格改完了」的统一出口,KPI 在这里收口就不会再漏路径
updateKpis();
if (!window.rondMap) return;
var points = [];
[].forEach.call(rowsBox.querySelectorAll('input[name^=lat_]'), function (el) {
var i = el.name.replace('lat_', '');
var nameEl = rowsBox.querySelector('input[name=name_' + i + ']');
var lat = parseFloat(el.value), lon = parseFloat(valOf(i, 'lon'));
if (!lat || !lon || isNaN(lat) || isNaN(lon)) return;
points.push({ lat: lat, lon: lon, n: nameEl ? nameEl.value : '', v: 1, key: 'row-' + i });
});
if (!points.length) { mapCard.style.display = 'none'; return; }
mapCard.style.display = '';
window.rondMap.preview({ id: 'photo-map', points: points });
syncStuck();
}
// 图粘住时收矮(.stuck):不收的话 200px 的浮层会挡住从下面滚过去的半个表格。
// 容器高度变了必须告诉 Leaflet 重新量一次,否则底图会缺一块。
// 导航高度只在窗口尺寸变化时才会变,所以在滚动路径上不能每帧去解析 --nav-h
// (读自定义属性会触发一次样式计算);滚动里只留必须实时的 getBoundingClientRect。
var stuckOffset = 0;
function measureNav() {
stuckOffset = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h') || '54') + 8;
}
function syncStuck() {
var stuck = mapCard.getBoundingClientRect().top <= stuckOffset + 2;
if (stuck === mapCard.classList.contains('stuck')) return;
mapCard.classList.toggle('stuck', stuck);
var st = window.rondMap && window.rondMap._state['photo-map'];
if (st && st.map) setTimeout(function () { st.map.invalidateSize(); }, 260);
}
measureNav();
window.addEventListener('scroll', syncStuck, { passive: true });
window.addEventListener('resize', function () { measureNav(); syncStuck(); });
document.getElementById('retry-btn').addEventListener('click', function () {
var todo = [];
Object.keys(rowRefs).forEach(function (k) {
var ref = rowRefs[k];
// 地址列还是占位符说明这行没查到(或查失败)
if (!ref || !ref.addrEl || ref.addrEl.textContent !== '—') return;
var lat = parseFloat(valOf(k, 'lat')), lon = parseFloat(valOf(k, 'lon'));
if (isNaN(lat) || isNaN(lon) || (!lat && !lon)) return;
todo.push({ lat: lat, lon: lon, key: String(k) });
});
if (!todo.length) { status.textContent = '没有需要重新反查的行。'; return; }
status.textContent = '正在重新反查 ' + todo.length + ' 个点位…';
lookup(todo).then(function (items) {
var byKey = {};
items.forEach(function (it) { byKey[it.key] = it; });
var hit = 0;
todo.forEach(function (c) {
var it = byKey[c.key];
if (!it || !it.address) return;
hit++;
applyOne(Number(c.key), it);
});
status.textContent = '重新反查完成:' + hit + '/' + todo.length + ' 个成功' +
(hit < todo.length ? '(其余的周边可能确实没有收录,或坐标不在境内)' : '') + '。';
drawMap();
}).catch(function (err) {
status.textContent = '重新反查失败:' + ((err && err.message) || err);
});
});
document.getElementById('reset-btn').addEventListener('click', function () {
gen++;
input.value = '';
rowsBox.innerHTML = '';
rowCount.value = '0';
previewCard.style.display = 'none';
mapCard.style.display = 'none';
status.textContent = '';
var pickCount = document.getElementById('pick-count');
if (pickCount) pickCount.textContent = '可多选,JPEG / HEIC 都行';
window.scrollTo(0, 0);
});
[].forEach.call(document.querySelectorAll('input[name=coord-sel]'), function (el) {
el.addEventListener('change', function () { coordField.value = coordSel(); });
});
/* ---------- 提交前把勾了「存图」的照片转成 WebP ----------
* 转码是异步的,所以先拦下这次提交,转完再 form.submit() 放行;
* form.submit() 不触发 submit 事件,不会自己套自己。
* 转好的字节塞进隐藏的 <input type=file>,随表单一起以 multipart 发出去。
*/
var form = document.getElementById('import-form');
var submitBtn = form.querySelector('button[type=submit]');
form.addEventListener('submit', function (e) {
if (form.dataset.ready === '1') return; // 放行那一趟
var picked = [];
rowsBox.querySelectorAll('input[name^=keepimg_]').forEach(function (cb) {
if (!cb.checked) return;
var i = Number(cb.name.slice('keepimg_'.length));
var ref = rowRefs[i];
if (ref && ref.file) picked.push({ i: i, file: ref.file, cb: cb });
});
if (!picked.length) { form.dataset.ready = '1'; return; }
// 服务端对整包有 60MB 上限:按转码后的体积(约 100~250KB)估,这里保守按 200 张提醒一次
if (picked.length > 200 && !window.confirm('这次勾了 ' + picked.length + ' 张要存为图片,' +
'总量可能超过服务端 60MB 的上限导致整包提交失败。建议分两次导入,是否继续?')) {
if (submitBtn) submitBtn.disabled = false;
return;
}
e.preventDefault();
if (submitBtn) submitBtn.disabled = true;
var at = 0, failed = 0;
function next() {
if (at >= picked.length) {
if (failed) {
// 有转码失败的:提示后仍然继续导入地点,只是那几张不带图
status.textContent = '已跳过 ' + failed + ' 张转码失败的图片,正在写入数据库…';
} else {
status.textContent = '图片已就绪,正在写入数据库…';
}
form.dataset.ready = '1';
form.submit();
return;
}
var it = picked[at++];
status.textContent = '正在转码图片 ' + at + '/' + picked.length + '…';
window.rondImage.toWebP(it.file).then(function (out) {
var holder = document.createElement('input');
holder.type = 'file';
holder.name = 'image_' + it.i;
holder.style.display = 'none';
var dt = new DataTransfer();
dt.items.add(new File([out.blob], 'image.webp', { type: 'image/webp' }));
holder.files = dt.files;
form.appendChild(holder);
// 宽高随文件一起发:服务端不解码图片,只记账
[['image_w_', out.width], ['image_h_', out.height]].forEach(function (pair) {
var h = document.createElement('input');
h.type = 'hidden';
h.name = pair[0] + it.i;
h.value = pair[1];
form.appendChild(h);
});
}).catch(function (err) {
failed++;
it.cb.checked = false;
status.textContent = '第 ' + (it.i + 1) + ' 行的图片转码失败(已跳过):' +
((err && err.message) || err);
}).then(next);
}
next();
});
})();
</script>
{{end}}
+151
View File
@@ -0,0 +1,151 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>站点设置</h1>
<div class="sub">控制主页展示的模块、隐私处理与地图底图</div>
</div>
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
</div>
{{if .Flash}}<div class="alert ok">设置已保存</div>{{end}}
<form method="post" action="/admin/settings">
<div class="card">
<h2>基本信息</h2>
<div class="card-sub">展示在主页顶部与浏览器标题</div>
<div class="grid g2">
<label class="f"><span>站点标题</span><input type="text" name="site_title" value="{{.Settings.SiteTitle}}" maxlength="40"></label>
<label class="f"><span>副标题</span><input type="text" name="site_subtitle" value="{{.Settings.SiteSubtitle}}" maxlength="80" placeholder="记录我去过的每一个地方"></label>
<label class="f"><span>署名</span><input type="text" name="owner_name" value="{{.Settings.OwnerName}}" maxlength="30"></label>
<label class="f"><span>头像(单个 emoji)</span><input type="text" name="owner_avatar" value="{{.Settings.OwnerAvatar}}" maxlength="8"></label>
</div>
<label class="f"><span>主色调</span>
<div class="row">
<input type="color" name="accent_color" value="{{.Settings.AccentColor}}" style="width:56px;height:34px;padding:2px">
<span class="muted">用于按钮、图表与选中状态</span>
</div>
</label>
</div>
<div class="card">
<h2>主页展示模块</h2>
<div class="card-sub">取消勾选的模块不会出现在主页上;一个都不选时按全部展示处理</div>
<div class="grid g2">
{{range .Modules}}
<label class="check">
<input type="checkbox" name="modules" value="{{.Key}}" {{if $.Settings.Has .Key}}checked{{end}}>
<span><b style="font-weight:500">{{.Name}}</b> <span class="muted">— {{.Desc}}</span></span>
</label>
{{end}}
</div>
</div>
<div class="card">
<h2>公开页面</h2>
<div class="card-sub">控制访客能在导航里看到并访问哪些页面;你登录后始终可以访问全部页面</div>
<div class="grid g2">
{{range .Pages}}
<label class="check">
<input type="checkbox" name="pages" value="{{.Key}}" {{if $.Settings.HasPage .Key}}checked{{end}}>
<span><b style="font-weight:500">{{.Name}}</b> <span class="muted">— {{.Desc}}</span></span>
</label>
{{end}}
</div>
<div class="muted" style="font-size:12.5px;margin-top:8px">
关闭的页面在访客导航中隐藏,直接输入网址也会得到 404。主页不受此开关影响。
</div>
<label class="f" style="max-width:320px;margin-top:14px">
<span>访客默认页面</span>
<select name="visitor_home">
<option value="home" {{if eq .Settings.VisitorHome "home"}}selected{{end}}>主页(默认)</option>
{{range .Pages}}<option value="{{.Key}}" {{if eq $.Settings.VisitorHome .Key}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<div class="muted" style="font-size:12.5px;margin-top:6px">
访客访问站点根路径时直接落到这里;你登录后访问根路径仍然看主页。
选择了一个没对访客开放的页面时不会跳转(会留在主页)。
</div>
</div>
<div class="card">
<h2>访客可见区域</h2>
<div class="card-sub">
一行一个,省 / 市 / 区都认(例如「广东省」「贵阳市」「南明区」)。
留空表示不限制,访客能看到全部数据;你自己登录后始终是全量。
</div>
<label class="check"><input type="radio" name="regions_mode" value="whitelist" {{if eq .Settings.RegionsMode "whitelist"}}checked{{end}}> 白名单:访客<span class="nobr">只能看到这些区域</span>的记录</label>
<label class="check"><input type="radio" name="regions_mode" value="blacklist" {{if eq .Settings.RegionsMode "blacklist"}}checked{{end}}> 黑名单:这些区域的记录<span class="nobr">对访客隐藏</span>,其余照常开放</label>
<label class="f" style="max-width:520px;margin-top:10px">
<span>区域列表</span>
<textarea name="regions" rows="5" placeholder="广东省&#10;贵阳市&#10;南明区">{{range .Settings.Regions}}{{.}}
{{end}}</textarea>
</label>
</div>
<div class="card">
<h2>隐私处理</h2>
<div class="card-sub">「家」和「工作」通常是敏感位置,默认已隐藏</div>
<label class="check"><input type="checkbox" name="hide_home" {{if .Settings.HideHome}}checked{{end}}> 隐藏类型为「家」的记录</label>
<label class="check"><input type="checkbox" name="hide_work" {{if .Settings.HideWork}}checked{{end}}> 隐藏类型为「工作」的记录</label>
<label class="check"><input type="checkbox" name="notes_public" {{if .Settings.NotesPublic}}checked{{end}}> 地点备注对访客公开(不勾选则只有你自己能看到)</label>
<label class="check"><input type="checkbox" name="marks_public" {{if .Settings.MarksPublic}}checked{{end}}> 「推荐 / 踩雷」标记与票数对访客公开</label>
<div class="muted" style="font-size:12.5px">
隐藏后匿名访客看不到这两类记录。你登录后可在任意公开页面用筛选器上的
<span class="mono">含家/工作</span> 开关临时显示给自己看。
</div>
<label class="f" style="max-width:280px;margin-top:12px">
<span>忽略停留时间过短的记录</span>
<select name="min_dwell_min">
<option value="0" {{if eq .Settings.MinDwellMin 0}}selected{{end}}>不过滤</option>
<option value="2" {{if eq .Settings.MinDwellMin 2}}selected{{end}}>少于 2 分钟</option>
<option value="5" {{if eq .Settings.MinDwellMin 5}}selected{{end}}>少于 5 分钟</option>
<option value="10" {{if eq .Settings.MinDwellMin 10}}selected{{end}}>少于 10 分钟</option>
</select>
</label>
<div class="muted" style="font-size:12.5px">该设置作用于全站统计与地图,可有效过滤路过点。</div>
</div>
<div class="card">
<h2>地图底图</h2>
<div class="card-sub">在 <span class="mono">conf/app.ini</span> 中配置,改完重启生效</div>
<div class="kv"><span class="k">当前生效</span><span class="v">{{.MapInfo}}</span></div>
{{if .MapLayers}}
<label class="f" style="max-width:340px;margin-top:10px"><span>默认用哪张底图</span>
<select name="map_default_base">
<option value="" {{if not .Settings.MapDefaultBase}}selected{{end}}>跟随第一张({{(index .MapLayers 0).Label}})</option>
{{range .MapLayers}}<option value="{{.Key}}" {{if eq .Key $.Settings.MapDefaultBase}}selected{{end}}>{{.Label}}</option>{{end}}
</select>
</label>
<div class="card-sub" style="margin-top:0">放大后底图自带的 POI 与路名标注太密、看不清自己的点时,换成<b>纯路网</b>。
访客在地图上手动切换过的话,浏览器会记住他自己的选择。</div>
{{end}}
<div class="muted" style="font-size:12.5px;margin-top:8px">
<div><span class="mono">map_provider</span>:<span class="mono">amap</span>(默认,免密钥)/ <span class="mono">tianditu</span>(需 <span class="mono">map_key</span>)/ <span class="mono">custom</span>(自备 <span class="mono">map_tile_url</span>)</div>
<div><span class="mono">map_key</span>:填写后瓦片改由服务端代理,密钥不会出现在页面源码里。</div>
<div><span class="mono">map_crs</span>:仅 <span class="mono">custom</span> 使用,填 <span class="mono">gcj02</span> 或 <span class="mono">wgs84</span>;站点数据本身是 GCJ-02,换成 WGS-84 底图(天地图 / OSM)时会自动纠偏。</div>
</div>
</div>
<div class="card">
<h2>图片存储</h2>
<div class="card-sub">地点图片存到哪儿。图片在<b>浏览器里</b>转成 WebP 后才上传,服务器只负责收下与转存</div>
<label class="check"><input type="radio" name="media_backend" value="local" {{if ne .Settings.MediaBackend "s3"}}checked{{end}}> 服务器本地磁盘</label>
<label class="check"><input type="radio" name="media_backend" value="s3" {{if eq .Settings.MediaBackend "s3"}}checked{{end}}> 对象存储(S3 兼容,如 Cloudflare R2)</label>
<div class="kv" style="margin-top:8px"><span class="k">对象存储</span><span class="v">{{.S3Info}}</span></div>
<div class="muted" style="font-size:12.5px;margin-top:8px">
<div>凭据放在 <span class="mono">conf/app.ini</span>,不进数据库、也不会下发到前端:</div>
<div><span class="mono">s3_endpoint</span>(R2 形如 <span class="mono">https://&lt;账号&gt;.r2.cloudflarestorage.com</span>)、<span class="mono">s3_bucket</span>、<span class="mono">s3_access_key</span>、<span class="mono">s3_secret_key</span>、<span class="mono">s3_region</span>(R2 填 <span class="mono">auto</span>)。</div>
<div><span class="mono">s3_public_base</span>:桶的公开域名(自定义域或 r2.dev),拼上对象名就是 <span class="mono">&lt;img src&gt;</span>;不填的话图片存得进去但浏览器取不到。</div>
<div>改了 <span class="mono">conf/app.ini</span> 需要重启;这里的「存哪边」保存后立即生效,已经存过的老图仍按各自原来的位置取,不受影响。</div>
</div>
</div>
<div class="row">
<button class="btn primary" type="submit">保存设置</button>
<a class="btn" href="/admin">取消</a>
</div>
</form>
</div>
{{end}}
+105
View File
@@ -0,0 +1,105 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>专题管理</h1>
<div class="sub">把某段时间、某个区域里的足迹打包成一个可对外展示的页面(地点 + 世界迷雾)</div>
</div>
<div class="row">
<a class="btn" href="/admin">← 返回后台</a>
<a class="btn" href="/topics" target="_blank">查看前台</a>
</div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<div class="card">
<h2>{{if .IsNew}}新建专题{{else}}编辑专题 #{{.Form.ID}}{{end}}</h2>
<div class="card-sub">时间范围可精确到分钟;半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选。</div>
<form method="post" action="/admin/topics/save">
{{if not .IsNew}}<input type="hidden" name="id" value="{{.Form.ID}}">{{end}}
<div class="row">
<label class="f" style="flex:2 1 240px"><span>标题<b class="req">*</b></span><input type="text" name="title" value="{{.Form.Title}}" placeholder="如:2026 大理之行"></label>
<label class="f" style="flex:2 1 240px"><span>副标题</span><input type="text" name="subtitle" value="{{.Form.Subtitle}}" placeholder="如:环洱海骑行四天"></label>
</div>
<label class="f"><span>说明</span><textarea name="description" rows="3" placeholder="可选:这段行程的来龙去脉">{{.Form.Description}}</textarea></label>
<div class="row">
<label class="f" style="flex:1 1 200px"><span>开始时间<b class="req">*</b></span><input type="datetime-local" name="start_at" value="{{.Form.StartDate}}"></label>
<label class="f" style="flex:1 1 200px"><span>结束时间<b class="req">*</b></span><input type="datetime-local" name="end_at" value="{{.Form.EndDate}}"></label>
<label class="f" style="flex:1 1 120px"><span>排序</span><input type="number" name="sort_order" value="{{.Form.SortOrder}}" placeholder="小的在前"></label>
</div>
<div class="card-sub" style="margin:0 0 8px">区域(可选):填了中心点与半径就只收这个圆内的足迹;留空则不限区域</div>
<div class="row">
<label class="f" style="flex:1 1 150px"><span>中心纬度</span><input type="text" name="center_lat" value="{{if .Form.CenterLat}}{{printf "%.6f" .Form.CenterLat}}{{end}}" placeholder="25.606486"></label>
<label class="f" style="flex:1 1 150px"><span>中心经度</span><input type="text" name="center_lon" value="{{if .Form.CenterLon}}{{printf "%.6f" .Form.CenterLon}}{{end}}" placeholder="100.267638"></label>
<label class="f" style="flex:1 1 120px"><span>半径(公里)</span><input type="text" name="radius_km" value="{{if .Form.RadiusKm}}{{printf "%.1f" .Form.RadiusKm}}{{end}}" placeholder="0 或留空=不限"></label>
</div>
<div class="row" style="align-items:center;margin-bottom:10px">
<label class="check" style="margin:0"><input type="checkbox" name="show_fog" {{if .Form.ShowFog}}checked{{end}}> 展示世界迷雾</label>
<label class="check" style="margin:0"><input type="checkbox" name="enabled" {{if .Form.Enabled}}checked{{end}}> 对外展示(取消勾选则访客看不到)</label>
</div>
<button class="btn primary" type="submit">{{if .IsNew}}创建专题{{else}}保存修改{{end}}</button>
{{if not .IsNew}}<a class="btn" href="/admin/topics">取消编辑</a>{{end}}
</form>
</div>
<div class="card">
<h2>专题列表</h2>
<div class="card-sub">共 {{len .Topics}} 个;「对访客」一列是展示开关,关掉的专题只有你自己能预览</div>
{{if .Topics}}
<div class="tw">
<table class="tb">
<thead><tr><th>标题</th><th>时间范围</th><th class="num">地点</th><th class="num">到访</th><th>区域</th><th>对访客</th><th></th></tr></thead>
<tbody>
{{range .Topics}}
<tr>
<td><a href="/topics/{{.ID}}" target="_blank">{{.Title}}</a>{{if .Subtitle}} <span class="muted">· {{.Subtitle}}</span>{{end}}</td>
<td class="muted mono">{{.RangeLabel}}</td>
<td class="num">{{num .PlaceCount}}</td>
<td class="num">{{num .VisitCount}}</td>
<td class="muted">{{if .HasArea}}{{printf "%.1f" .RadiusKm}} km 内{{else}}不限{{end}}</td>
<td>
<form method="post" action="/admin/topics/toggle" style="display:inline">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn sm {{if .Enabled}}primary{{end}}" type="submit">{{if .Enabled}}展示中{{else}}已隐藏{{end}}</button>
</form>
</td>
<td style="white-space:nowrap">
<a class="btn sm" href="/admin/topics?edit={{.ID}}">编辑</a>
<form method="post" action="/admin/topics/delete" style="display:inline"
onsubmit="return confirm('删除专题「{{.Title}}」?地点数据不受影响。')">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn sm danger" type="submit">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty">还没有专题,用上面的表单建一个吧</div>
{{end}}
</div>
</div>
<script>
(function () {
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
window.addEventListener('load', cb);
}
whenReady(function () {
try {
if (!window.flatpickr) return;
if (flatpickr.l10ns && flatpickr.l10ns.zh) flatpickr.localize(flatpickr.l10ns.zh);
document.querySelectorAll('input[type=datetime-local]').forEach(function (el) {
flatpickr(el, { enableTime: true, time_24hr: true, allowInput: true, minuteIncrement: 5, dateFormat: 'Y-m-dTH:i' });
});
} catch (e) { console.error('flatpickr 初始化失败', e); }
});
})();
</script>
{{end}}
@@ -0,0 +1,39 @@
{{define "login2"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
{{/* viewport-fit=cover 才会把刘海屏 / 底部 Home 指示条的安全区通过 env() 暴露出来,否则 CSS 里的 safe-area-inset 全是 0 */}}
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>两步验证 · {{.Settings.SiteTitle}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/app.css">
<style>:root{--accent:{{.Settings.AccentColor}};--accent-soft:{{.Settings.AccentColor}}1a}</style>
</head>
<body>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px">
<div style="width:100%;max-width:368px">
<div style="text-align:center;margin-bottom:22px">
<div style="font-size:30px">🔐</div>
<h1 style="font-size:19px;margin-top:6px">两步验证</h1>
<div class="muted" style="font-size:13px">输入验证器 App 中的 6 位验证码</div>
</div>
<div class="card" style="margin:0">
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
<form method="post" action="/admin/login/twofa">
<input type="hidden" name="pending" value="{{.Pending}}">
<input type="hidden" name="next" value="{{.Next}}">
<label class="f"><span>验证码</span>
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6"
autocomplete="one-time-code" autofocus required
style="text-align:center;font-size:20px;letter-spacing:8px">
</label>
<button class="btn primary" type="submit" style="width:100%;justify-content:center">验证并登录</button>
</form>
</div>
<div style="text-align:center;margin-top:16px"><a class="muted" href="/admin/login" style="font-size:13px">← 重新登录</a></div>
</div>
</div>
</body>
</html>{{end}}
+62
View File
@@ -0,0 +1,62 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>两步验证</h1>
<div class="sub">为后台登录追加一层动态验证码(TOTP,兼容 Google Authenticator 等 App)</div>
</div>
<div class="row"><a class="btn" href="/admin">← 返回后台</a></div>
</div>
{{if .Flash}}<div class="alert ok">{{.Flash}}</div>{{end}}
{{if .Err}}<div class="alert err">{{.Err}}</div>{{end}}
{{if .Enabled}}
<div class="card">
<h2>状态:已开启</h2>
<div class="card-sub">每次登录后台,除口令外还需输入验证器 App 中的 6 位验证码</div>
<form method="post" action="/admin/twofa/disable" style="max-width:360px">
<label class="f"><span>输入当前验证码以关闭</span>
<input type="text" name="code" inputmode="numeric" maxlength="6" required
style="text-align:center;letter-spacing:6px;font-size:16px">
</label>
<button class="btn danger" type="submit">关闭两步验证</button>
</form>
</div>
{{else if .Pending}}
<div class="card">
<h2>绑定验证器</h2>
<div class="card-sub">用验证器 App 扫描二维码,或手动输入秘钥,然后输入 App 显示的 6 位验证码确认</div>
<div class="row" style="align-items:flex-start;gap:20px;flex-wrap:wrap">
{{if .QR}}<img src="data:image/png;base64,{{.QR}}" alt="TOTP 二维码"
style="width:220px;height:220px;border:1px solid var(--line);border-radius:10px;background:var(--surface)">{{end}}
<div style="min-width:240px">
<label class="f"><span>秘钥(手动输入用)</span>
<input type="text" readonly value="{{.Secret}}" onclick="this.select()"
style="font-family:monospace;letter-spacing:1px">
</label>
<div class="muted" style="font-size:12.5px">账户名:rond-with-you · 算法 SHA1 · 6 位 · 30 秒</div>
</div>
</div>
<form method="post" action="/admin/twofa/confirm" style="max-width:360px;margin-top:6px">
<label class="f"><span>输入验证码确认开启</span>
<input type="text" name="code" inputmode="numeric" maxlength="6" required
style="text-align:center;letter-spacing:6px;font-size:16px">
</label>
<button class="btn primary" type="submit">确认开启</button>
</form>
</div>
{{else}}
<div class="card">
<h2>状态:未开启</h2>
<div class="card-sub">开启后,登录 = 口令 + 动态验证码,即使口令泄露账户也不会被直接攻破</div>
<form method="post" action="/admin/twofa/setup">
<button class="btn primary" type="submit">开始绑定验证器</button>
</form>
<div class="muted" style="font-size:12.5px;margin-top:10px">
需要一个 TOTP 验证器 App(Google Authenticator、Microsoft Authenticator、1Password、Aegis 等均可)。
</div>
</div>
{{end}}
</div>
{{end}}
+182
View File
@@ -0,0 +1,182 @@
{{define "content"}}
{{if not .HasData}}
<div class="wrap">{{template "emptystate" .}}</div>
{{else}}
<div class="wrap">
<div class="page-head">
<div>
<h1>{{.Settings.OwnerAvatar}} {{.Settings.OwnerName}}</h1>
<div class="sub">{{if .Settings.SiteSubtitle}}{{.Settings.SiteSubtitle}} · {{end}}{{.RangeLabel}}</div>
</div>
<div class="row">
<a class="btn" href="/map">全屏地图</a>
<a class="btn" href="/stats">数据统计</a>
</div>
</div>
{{if .Settings.Has "overview"}}
<div class="kpis" style="margin-bottom:16px">
<div class="kpi"><div class="v">{{num .Overview.Places}}<small>个</small></div><div class="k">去过的地点</div></div>
<div class="kpi"><div class="v">{{num .Overview.Cities}}<small>座</small></div><div class="k">到过的城市</div></div>
<div class="kpi"><div class="v">{{num .Overview.Provinces}}<small>个</small></div><div class="k">覆盖省份</div></div>
<div class="kpi"><div class="v">{{num .Overview.Days}}<small>天</small></div><div class="k">有记录的日子</div></div>
<div class="kpi"><div class="v">{{num .Overview.Visits}}<small>次</small></div><div class="k">累计到访</div></div>
<div class="kpi"><div class="v">{{dur .Overview.DwellMin}}</div><div class="k">累计停留</div></div>
<div class="kpi"><div class="v">{{km .Overview.DistanceKm}}<small>km</small></div><div class="k">出行里程</div></div>
<div class="kpi"><div class="v">{{if .Overview.FirstAt}}{{since .Overview.FirstAt}}{{else}}—{{end}}</div><div class="k">开始记录于</div></div>
</div>
{{end}}
{{if and (.Settings.Has "map") .Points}}
<div class="card">
<h2>足迹地图</h2>
<div class="card-sub">共 {{num (len .Points)}} 个地点{{if .Settings.HideHome}} · 已隐藏「家」{{end}}{{if .Settings.HideWork}} · 已隐藏「工作」{{end}}</div>
<div id="home-map" class="map-preview"></div>
<div style="margin-top:10px;font-size:12.5px" class="muted">滚轮被禁用,点击地图进入全屏模式查看细节与筛选。</div>
</div>
{{end}}
<div class="grid g2">
{{if and (.Settings.Has "types") .ActivityStats}}
<div class="card">
<h2>去过的类型</h2>
<div class="card-sub">按到访次数排序</div>
<div class="bars">
{{range .ActivityStats}}
<div class="bar-row">
<div class="nm" title="{{.Name}}">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{colorof .Color}}"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{if and (.Settings.Has "cities") .CityStats}}
<div class="card">
<h2>城市足迹</h2>
<div class="card-sub">按到访次数排序</div>
<div class="bars">
{{range .CityStats}}
<div class="bar-row">
<div class="nm"><a href="{{$.CityLink .City}}">{{.City}}</a></div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxCity}}%"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
</div>
<div style="margin-top:12px" class="muted">共覆盖 {{num .Overview.Cities}} 座城市、{{num .Overview.Provinces}} 个省级行政区。</div>
</div>
{{end}}
</div>
{{if .Monthly}}
<div class="card">
<h2>月度活跃度</h2>
<div class="card-sub">每月到访次数</div>
<div class="cols">
{{range .Monthly}}<div class="col" style="height:{{.Pct}}%" title="{{.Key}}:{{.Count}} 次"></div>{{end}}
</div>
<div class="cols-x">{{range .Monthly}}<span>{{.Label}}</span>{{end}}</div>
</div>
{{end}}
{{if and (.Settings.Has "heatmap") .Calendar}}
<div class="card">
<h2>活跃日历</h2>
<div class="card-sub">每一列是一周,颜色越深记录越多</div>
<div class="heat">
{{range .Calendar}}<div class="wk">{{range .Days}}<div class="d {{if gt .Level 0}}l{{.Level}}{{end}}" title="{{.Title}}"></div>{{end}}</div>{{end}}
</div>
<div class="heat-legend">少 <span class="d"></span><span class="d l1"></span><span class="d l2"></span><span class="d l3"></span><span class="d l4"></span> 多</div>
</div>
{{end}}
<div class="grid g2">
{{if and (.Settings.Has "transport") .Transports}}
<div class="card">
<h2>出行方式</h2>
<div class="card-sub">按次数排序,里程为两点间直线距离估算</div>
<div class="bars">
{{range .Transports}}
<div class="bar-row">
<div class="nm">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxTransport}}%;background:{{colorof .Color}}"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{if and (.Settings.Has "tags") .Tags}}
<div class="card">
<h2>我的标签</h2>
<div class="card-sub">记录时打上的自定义标签</div>
<div class="chips">
{{range .Tags}}
<a class="chip" href="{{$.TagLink .ID}}" title="{{num .Count}} 次">
<span class="dot" style="background:{{colorof .Color}}"></span>{{.Name}}
<span class="muted">{{num .Count}}</span>
</a>
{{end}}
</div>
</div>
{{end}}
</div>
{{if and (.Settings.Has "weather") .Weather}}
<div class="card">
<h2>天气足迹</h2>
<div class="card-sub">记录期间经历过的天气</div>
<div class="tw">
<table class="tb">
<thead><tr><th>天气</th><th class="num">记录小时数</th><th class="num">平均气温</th><th class="num">范围</th></tr></thead>
<tbody>
{{range .Weather}}
<tr>
<td>{{condition .Condition}}</td>
<td class="num">{{num .Count}}</td>
<td class="num">{{f1 .AvgTemp}} ℃</td>
<td class="num">{{f1 .MinTemp}} ~ {{f1 .MaxTemp}} ℃</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{if and (.Settings.Has "timeline") .Recent}}
<div class="card">
<h2>最近足迹</h2>
<div class="card-sub">最新 {{num (len .Recent)}} 条到访记录</div>
<div class="list">
{{range .Recent}}
<div class="item">
<div class="ic" style="background:{{colorof .Color}}22">{{iconor .Icon "📍"}}</div>
<div>
<div class="t">{{if .PlaceID}}<a href="/places/{{.PlaceID}}">{{.PlaceName}}</a>{{else}}未命名地点{{end}}</div>
<div class="m">{{day .Arrival}} · {{hm .Arrival}}{{if .City}} · {{.City}}{{end}}{{if .ActivityName}} · {{.ActivityName}}{{end}}{{if .Tags}} · {{range $i, $t := .Tags}}{{if $i}}、{{end}}<span class="tag">{{$t}}</span>{{end}}{{end}}</div>
</div>
<div class="r">{{dur .DurationMin}}<br><span class="muted">{{since .Arrival}}</span></div>
</div>
{{end}}
</div>
<div style="margin-top:12px"><a class="btn sm" href="/timeline">查看完整时间线 →</a></div>
</div>
{{end}}
</div>
{{if and (.Settings.Has "map") .Points}}
<script>
window.addEventListener('load', function () {
window.rondMap.preview({ id: 'home-map', points: {{json .Points}}, clickToFull: true });
});
</script>
{{end}}
{{end}}
{{end}}
+295
View File
@@ -0,0 +1,295 @@
{{define "nav"}}
<nav class="nav">
<div class="nav-inner">
<a class="brand" href="/" title="{{.Settings.SiteTitle}}"><span class="avatar">{{.Settings.OwnerAvatar}}</span><span class="t">{{.Settings.SiteTitle}}</span></a>
<div class="nav-links">
<a href="/" class="{{if eq .Nav "home"}}on{{end}}">主页</a>
{{if .Settings.HasPage "map"}}<a href="/map" class="{{if eq .Nav "map"}}on{{end}}">地图</a>{{end}}
{{if .Settings.HasPage "places"}}<a href="/places" class="{{if eq .Nav "places"}}on{{end}}">地点</a>{{end}}
{{if .Settings.HasPage "timeline"}}<a href="/timeline" class="{{if eq .Nav "timeline"}}on{{end}}">时间线</a>{{end}}
{{if .Settings.HasPage "stats"}}<a href="/stats" class="{{if eq .Nav "stats"}}on{{end}}">统计</a>{{end}}
{{if .HasTopics}}<a href="/topics" class="{{if eq .Nav "topics"}}on{{end}}">专题</a>{{end}}
{{if .Settings.HasPage "report"}}<a href="/report" class="{{if eq .Nav "report"}}on{{end}}">报告</a>{{end}}
</div>
<div class="nav-right">
{{if .User}}
<span class="who">{{.User.DisplayName}}</span>
<a class="btn sm" href="/admin"><span class="hide-sm">后台</span><span class="only-sm">后台</span></a>
<form method="post" action="/admin/logout" style="display:inline"><button class="btn sm" type="submit">退出</button></form>
{{else}}
<a class="btn sm" href="/admin"><span class="hide-sm">登录后台</span><span class="only-sm">登录</span></a>
{{end}}
</div>
</div>
</nav>
{{end}}
{{define "filters"}}
<form class="card" method="get" action="{{.FilterBase}}" id="filters-form">
<div class="chips" style="margin-bottom:12px">
{{range .Presets}}<a class="chip {{if .Active}}on{{end}}" href="{{$.RangeLink .From .To}}">{{.Label}}</a>{{end}}
<span class="chip" style="background:transparent;color:var(--text-3)">{{.RangeLabel}}</span>
{{if .IsAdmin}}
<a class="chip {{if .ShowHomeWork}}on{{end}}" href="{{.HWLink}}" title="仅登录后的你自己可见,访客看不到这两类记录">含家/工作</a>
{{end}}
</div>
{{/* 已选条件:以前只能靠「重置」全清,想撤掉其中一个得手动改回「全部」再点应用 */}}
{{$chips := .ActiveChips}}
{{if $chips}}
<div class="chips active-filters">
<span class="af-label">已选</span>
{{range $chips}}<a class="chip on" href="{{.RemoveURL}}" title="去掉这个条件">{{.Label}}<span class="af-x">×</span></a>{{end}}
<a class="chip af-clear" href="{{.FilterBase}}">全部清空</a>
</div>
{{end}}
{{if .Activities}}
<div class="chips" style="margin-bottom:{{if gt (len .Activities) 14}}8{{else}}12{{end}}px">
{{range $i, $a := .Activities}}{{if or (lt $i 14) ($.IsAct $a.ID)}}
<a class="chip {{if $.IsAct $a.ID}}on{{end}}" href="{{$.ActLink $a.ID}}" title="{{num $a.Count}} 次到访">
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
<span class="muted">{{num $a.Count}}</span>
</a>
{{end}}{{end}}
</div>
{{if gt (len .Activities) 14}}
<details class="more-types">
<summary>展开其余 {{len .Activities}} 个类型</summary>
<div class="chips">
{{range $i, $a := .Activities}}{{if and (ge $i 14) (not ($.IsAct $a.ID))}}
<a class="chip" href="{{$.ActLink $a.ID}}">
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
<span class="muted">{{num $a.Count}}</span>
</a>
{{end}}{{end}}
</div>
</details>
{{end}}
{{end}}
<div class="row" style="align-items:flex-end">
<label class="f" style="flex:1 1 140px;margin-bottom:0"><span>起始日期</span><input type="date" name="from" value="{{.From}}"></label>
<label class="f" style="flex:1 1 140px;margin-bottom:0"><span>结束日期</span><input type="date" name="to" value="{{.To}}"></label>
<label class="f" style="flex:1 1 130px;margin-bottom:0"><span>城市</span>
<select name="city">
<option value="">全部城市</option>
{{range .CityList}}<option value="{{.}}" {{if eq . $.City}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
<label class="f" style="flex:1 1 130px;margin-bottom:0"><span>标签</span>
<select name="tag">
<option value="0">全部标签</option>
{{range .TagOptions}}<option value="{{.ID}}" {{if eq .ID $.TagID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</label>
<label class="f" style="flex:1 1 120px;margin-bottom:0"><span>最短停留</span>
<select name="min">
<option value="0" {{if eq .MinDwell 0}}selected{{end}}>不限</option>
<option value="5" {{if eq .MinDwell 5}}selected{{end}}>≥ 5 分钟</option>
<option value="15" {{if eq .MinDwell 15}}selected{{end}}>≥ 15 分钟</option>
<option value="30" {{if eq .MinDwell 30}}selected{{end}}>≥ 30 分钟</option>
<option value="60" {{if eq .MinDwell 60}}selected{{end}}>≥ 1 小时</option>
</select>
</label>
<label class="f" style="flex:2 1 180px;margin-bottom:0"><span>关键词</span><input type="text" name="q" value="{{.Keyword}}" placeholder="地点 / 城市 / 类型,回车即可"></label>
<div style="display:flex;gap:8px">
{{/* 下拉与日期改动即生效(见下面的脚本);这个按钮是给「关键词」和
关了 JS 的浏览器兜底的,所以不再是主按钮样式 */}}
<button class="btn" type="submit">查询</button>
<a class="btn" href="{{.FilterBase}}">重置</a>
</div>
</div>
{{range .ActivityIDs}}<input type="hidden" name="act" value="{{.}}">{{end}}
{{if .ShowHomeWork}}<input type="hidden" name="hw" value="1">{{end}}
{{if .Sort}}<input type="hidden" name="sort" value="{{.Sort}}">{{end}}
</form>
{{end}}
{{define "emptystate"}}
<div class="card">
<div class="empty">
<div class="big">🗺️</div>
<div style="font-size:15px;color:var(--text);margin-bottom:6px">还没有导入任何数据</div>
<div>登录后台,上传 rond 导出的 <span class="mono">.rondbackup</span> 备份文件即可生成足迹地图。</div>
<div style="margin-top:14px"><a class="btn primary" href="/admin">前往后台导入</a></div>
</div>
</div>
{{end}}
{{define "pager"}}
{{if gt .PageCount 1}}
<div class="pager">
{{if gt .PageNo 1}}<a href="{{.Link .FilterBase "page" (printf "%d" (sub .PageNo 1))}}">上一页</a>{{end}}
<span class="cur">第 {{.PageNo}} / {{.PageCount}} 页</span>
{{if lt .PageNo .PageCount}}<a href="{{.Link .FilterBase "page" (printf "%d" (add .PageNo 1))}}">下一页</a>{{end}}
</div>
{{end}}
{{end}}
{{define "layout"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
{{/* viewport-fit=cover 才会把刘海屏 / 底部 Home 指示条的安全区通过 env() 暴露出来,否则 CSS 里的 safe-area-inset 全是 0 */}}
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{.Title}} · {{.Settings.SiteTitle}}</title>
<script>window.__assetV="{{.AssetV}}"</script>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="manifest" href="/static/manifest.webmanifest">
<meta name="theme-color" content="{{.Settings.AccentColor}}">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
{{if .HasMap}}
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v={{.AssetV}}">
{{if or (eq .Nav "map") (eq .Nav "topics")}}
<link rel="stylesheet" href="/static/vendor/markercluster/MarkerCluster.css?v={{.AssetV}}">
<link rel="stylesheet" href="/static/vendor/markercluster/MarkerCluster.Default.css?v={{.AssetV}}">
{{end}}
{{end}}
{{if .Flatpickr}}
<link rel="stylesheet" href="/static/vendor/flatpickr/flatpickr.min.css?v={{.AssetV}}">
{{end}}
<link rel="stylesheet" href="/static/css/app.css?v={{.AssetV}}">
{{/* accent-base 是站长选的主色原值:暗色模式会用 color-mix 把它提亮,否则深色底上的链接会看不清(原来的深绿在深底上只有 3.3:1)。 */}}
<style>:root{--accent:{{.Settings.AccentColor}};--accent-soft:{{.Settings.AccentColor}}1a;--accent-base:{{.Settings.AccentColor}}}</style>
</head>
<body>
{{template "nav" .}}
{{template "content" .}}
{{if ne .Nav "map"}}
<footer class="ft">
{{.Settings.SiteTitle}}{{if .Settings.SiteSubtitle}} · {{.Settings.SiteSubtitle}}{{end}}
{{if .Dataset}} · 数据截至 {{d .Dataset.Last}}{{end}}
</footer>
{{end}}
{{if .HasMap}}
<script src="/static/vendor/leaflet/leaflet.js?v={{.AssetV}}"></script>
{{if or (eq .Nav "map") (eq .Nav "topics")}}<script src="/static/vendor/markercluster/leaflet.markercluster.js?v={{.AssetV}}"></script>{{end}}
<script src="/static/js/rond-map.js?v={{.AssetV}}"></script>
{{end}}
{{if .Flatpickr}}
<script src="/static/vendor/flatpickr/flatpickr.min.js?v={{.AssetV}}"></script>
<script src="/static/vendor/flatpickr/l10n/zh.js?v={{.AssetV}}"></script>
{{end}}
{{/* 第三方统计脚本:只在公开页面挂,后台/登录页不挂(免得把自己的操作也统计进去) */}}
{{if and .TrackScript (ne .Nav "admin")}}
{{.TrackScript}}
{{end}}
<script>
// 后台的表单提交 / 翻页 / 搜索都是整页刷新,浏览器会回到顶部,滚动位置就丢了。
// 这里在离开前把位置记进 sessionStorage,加载完再滚回去。
// 只在 /admin 下生效:前台页面(主页进详情之类)本来就该从头看。
(function () {
if (location.pathname.indexOf("/admin") !== 0) return;
var KEY = "rond:scroll:" + location.pathname;
function record() {
try {
sessionStorage.setItem(KEY, Math.round(window.scrollY || window.pageYOffset || 0) + ":" + Date.now());
} catch (e) {}
}
function restore() {
try {
var raw = sessionStorage.getItem(KEY);
if (!raw) return;
sessionStorage.removeItem(KEY);
var parts = raw.split(":"), y = parseInt(parts[0], 10), ts = parseInt(parts[1], 10);
// 10 秒内才认:确认框被取消时留下的记录不该在下次打开时把视图拽走
if (isFinite(y) && y > 0 && Date.now() - ts < 10000) window.scrollTo(0, y);
} catch (e) {}
}
// 等 load:解析阶段就滚的话文档可能还不够高,scrollTo 会被截断到 0
if (document.readyState === "complete") restore();
else window.addEventListener("load", restore);
document.addEventListener("submit", record, true);
// 翻页 / 重置这类是同路径的链接跳转,也要记(新开标签、锚点、跨页不记)
document.addEventListener("click", function (ev) {
var a = ev.target && ev.target.closest ? ev.target.closest("a") : null;
if (!a) return;
var href = a.getAttribute("href") || "";
if (!href || href.charAt(0) === "#" || a.target === "_blank") return;
var u;
try { u = new URL(a.href, location.href); } catch (e) { return; }
if (u.pathname === location.pathname) record();
}, true);
})();
</script>
{{/* 长列表(地点库 300+ 行、时间线 800+ 条)划到底后能一键回来。
地图页整页不滚动,所以这个按钮在那里不会出现。 */}}
{{/* 顶部细进度条:筛选是整页 GET,慢网下点了没反应会让人以为卡住 */}}
<div class="load-bar" id="load-bar" hidden></div>
<button class="to-top" id="to-top" type="button" aria-label="回到顶部" hidden>↑</button>
<script>
(function () {
/* ---------- 顶部加载进度条 ---------- */
var bar = document.getElementById('load-bar'), timer = null, safety = null, w = 0;
function start() {
bar.hidden = false;
w = 8;
bar.style.width = w + '%';
bar.style.opacity = '1';
clearInterval(timer);
timer = setInterval(function () {
w = Math.min(90, w + Math.max(0.4, (90 - w) / 14));
bar.style.width = w + '%';
}, 180);
// 兜底:万一这次「跳转」被阻止了(比如地图页是 AJAX 换数据),别让进度条一直挂着
clearTimeout(safety);
safety = setTimeout(finish, 8000);
}
function finish() {
clearInterval(timer);
clearTimeout(safety);
if (bar.hidden) return;
bar.style.width = '100%';
setTimeout(function () {
bar.style.opacity = '0';
setTimeout(function () { bar.hidden = true; bar.style.width = '0'; }, 260);
}, 140);
}
window.rondProgress = { start: start, finish: finish };
document.addEventListener('submit', start, true);
document.addEventListener('click', function (e) {
var a = e.target && e.target.closest ? e.target.closest('a') : null;
if (!a || a.target === '_blank' || a.hasAttribute('download')) return;
var u;
try { u = new URL(a.href, location.href); } catch (err) { return; }
// 只对「真的会跳走」的站内链接亮进度条
if (u.origin !== location.origin) return;
if (u.pathname === location.pathname && u.search === location.search) return;
start();
}, true);
window.addEventListener('pageshow', finish); // 从 bfcache 回来时也要收掉
/* ---------- 筛选:改动即生效 ---------- */
var form = document.getElementById('filters-form');
if (form) {
// 下拉与日期改了就提交,不用再点「查询」。
// 关键词不自动提交(每敲一个字都跳转会很难受)——回车即可。
function apply() {
// 用 requestSubmit 而不是 form.submit():前者会**触发 submit 事件**,
// 于是地图页自己那个「拦下提交、改走 AJAX」的处理器仍然生效;
// 用 submit() 会绕过它,把地图页的筛选变成整页刷新。
if (form.requestSubmit) form.requestSubmit();
else form.submit();
}
form.addEventListener('change', function (e) {
var el = e.target;
if (!el || !el.name) return;
if (el.tagName === 'SELECT' || el.type === 'date') apply();
});
}
var b = document.getElementById('to-top');
function sync() { b.hidden = window.scrollY < 600; }
window.addEventListener('scroll', sync, { passive: true });
sync();
b.addEventListener('click', function () {
// 尊重「减少动态效果」偏好:开了就别做平滑滚动
var smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
});
})();
</script>
</body>
</html>{{end}}
+333
View File
@@ -0,0 +1,333 @@
{{define "content"}}
{{if not .HasData}}
<div class="wrap">{{template "emptystate" .}}</div>
{{else}}
<div class="map-page">
<div id="map"></div>
{{/* 时间轴回放:拖动即可按「首次到访时间」累加显示点位。
注意它和左侧的日期区间是两回事——区间是「只看这段时间」,
时间轴是「一路走到这个时刻为止」,所以放在地图底部而不是筛选面板里。 */}}
<div class="timeline-bar" id="timeline-bar" hidden>
<button class="tl-play" type="button" id="tl-play" aria-label="播放时间轴">▶</button>
<input type="range" id="tl-range" min="0" max="100" value="100" aria-label="时间轴">
<span class="tl-label" id="tl-label"></span>
<button class="tl-reset" type="button" id="tl-reset">全部</button>
</div>
{{/* 加载失败时的提示条:绝不重新加载页面,只在地图上说清楚并给一个重试按钮 */}}
<div class="map-status" id="map-status" hidden>
<span id="map-status-text"></span>
<button class="btn sm" type="button" id="map-retry" hidden>重试</button>
</div>
<div class="map-panel">
<form id="filter-form" method="get" action="/map">
<h3>时间范围</h3>
<div class="chips" style="margin-bottom:10px">
{{range .Presets}}<a class="chip {{if .Active}}on{{end}}" href="{{$.RangeLink .From .To}}">{{.Label}}</a>{{end}}
{{if .IsAdmin}}
<a class="chip {{if .ShowHomeWork}}on{{end}}" href="{{.HWLink}}" title="仅登录后的你自己可见,访客看不到这两类记录">含家/工作</a>
{{end}}
</div>
<div class="row" style="gap:8px;margin-bottom:10px">
<input type="date" name="from" value="{{.From}}" style="flex:1">
<input type="date" name="to" value="{{.To}}" style="flex:1">
</div>
{{if .Activities}}
<h3>地点类型</h3>
<div class="chips" style="margin-bottom:10px">
{{range $i, $a := .Activities}}{{if or (lt $i 12) ($.IsAct $a.ID)}}
<a class="chip {{if $.IsAct $a.ID}}on{{end}}" href="{{$.ActLink $a.ID}}">
<span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}
</a>
{{end}}{{end}}
</div>
{{if gt (len .Activities) 12}}
<details class="more-types">
<summary>展开其余 {{len .Activities}} 个类型</summary>
<div class="chips">
{{range $i, $a := .Activities}}{{if and (ge $i 12) (not ($.IsAct $a.ID))}}
<a class="chip" href="{{$.ActLink $a.ID}}"><span class="dot" style="background:{{colorof $a.Color}}"></span>{{$a.Name}}</a>
{{end}}{{end}}
</div>
</details>
{{end}}
{{end}}
<h3>其它条件</h3>
<div class="row" style="gap:8px;margin-bottom:10px">
<select name="city" style="flex:1">
<option value="">全部城市</option>
{{range .CityList}}<option value="{{.}}" {{if eq . $.City}}selected{{end}}>{{.}}</option>{{end}}
</select>
<select name="tag" style="flex:1">
<option value="0">全部标签</option>
{{range .TagOptions}}<option value="{{.ID}}" {{if eq .ID $.TagID}}selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="row" style="gap:8px;margin-bottom:12px">
<select name="min" style="flex:1">
<option value="0" {{if eq .MinDwell 0}}selected{{end}}>不限停留</option>
<option value="5" {{if eq .MinDwell 5}}selected{{end}}>≥ 5 分钟</option>
<option value="15" {{if eq .MinDwell 15}}selected{{end}}>≥ 15 分钟</option>
<option value="30" {{if eq .MinDwell 30}}selected{{end}}>≥ 30 分钟</option>
<option value="60" {{if eq .MinDwell 60}}selected{{end}}>≥ 1 小时</option>
</select>
<input type="text" name="q" value="{{.Keyword}}" placeholder="关键词" style="flex:1">
</div>
{{range .ActivityIDs}}<input type="hidden" name="act" value="{{.}}">{{end}}
{{if .ShowHomeWork}}<input type="hidden" name="hw" value="1">{{end}}
<div class="row">
<button class="btn" type="submit" style="flex:1">查询</button>
<a class="btn" href="/map">重置</a>
</div>
<div class="chips" style="margin-top:10px">
<label class="chip" style="user-select:none"><input type="checkbox" id="track-toggle" style="accent-color:var(--accent)"> 行程轨迹</label>
<label class="chip" style="user-select:none"><input type="checkbox" id="heat-toggle" style="accent-color:var(--accent)"> 到访热力</label>
<label class="chip" style="user-select:none"><input type="checkbox" id="city-toggle" style="accent-color:var(--accent)"> 城市聚合</label>
<label class="chip" style="user-select:none"><input type="checkbox" id="gpx-toggle" style="accent-color:var(--accent)"> GPX 轨迹</label>
</div>
<div class="muted" style="margin-top:9px;font-size:12px">当前显示 <b id="pt-count">{{num (len .Points)}}</b> 个地点</div>
</form>
</div>
{{if .Legend}}
<div class="map-legend">
<div style="font-weight:500;color:var(--text-2)">类型分布</div>
{{range .Legend}}
<div class="lg"><i style="background:{{colorof .Color}}"></i>{{.Name}} <span class="muted">{{num .Count}}</span></div>
{{end}}
</div>
{{end}}
</div>
<script>
window.addEventListener('load', function () {
// 首屏不带地点数据(全量内联会让 HTML 膨胀到 ~58KB),
// 页面先出壳,地点走 /api/points 并行拉取后再填充。
window.__map = window.rondMap.full({ id: 'map', points: [] });
var form = document.getElementById('filter-form');
var ptCount = document.getElementById('pt-count');
var statusBox = document.getElementById('map-status');
var statusText = document.getElementById('map-status-text');
var retryBtn = document.getElementById('map-retry');
function qs() {
return new URLSearchParams(new FormData(form)).toString();
}
function showStatus(msg, withRetry) {
statusText.textContent = msg;
retryBtn.hidden = !withRetry;
statusBox.hidden = false;
}
function hideStatus() { statusBox.hidden = true; }
// HTTP 500 时 fetch 本身是成功的,紧接着 r.json() 才抛错——统一在这里判掉,
// 调用方就只有一个失败分支要处理。
function loadJSON(url) {
return fetch(url, { headers: { 'Accept': 'application/json' } }).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
/* 以前这里是 `.catch(function () { form.submit(); })`:接口一失败就整页重提交,
而重提交后的新页面又走同一条路 —— 实测 8 秒内自刷 17 次、接口被拒 16 次、
访客看不到任何解释,服务器被反复打(1C1G 上尤其难受)。
现在改成:有限次自动重试 + 明确提示 + 手动重试按钮,**任何情况下都不重新加载页面**。 */
var RETRY_DELAYS = [1000, 3000];
// 返回的 promise 一定 resolve(true/false),不会 reject——避免未处理的 rejection,
// 也让调用方只需判断「这次到底加载成功了没」。
function loadPoints(q, attempt) {
attempt = attempt || 0;
return loadJSON('/api/points' + (q ? '?' + q : '')).then(function (d) {
var pts = d.points || [];
// 先交给时间轴:它自己会在「有时间的点」上做过滤再决定画什么
setupTimeline(pts);
window.rondMap.setPoints(pts);
ptCount.textContent = pts.length;
hideStatus();
return true;
}).catch(function (err) {
var why = (err && err.message) ? err.message : '网络异常';
if (attempt < RETRY_DELAYS.length) {
showStatus('地点加载失败(' + why + '),正在重试…', false);
return new Promise(function (res) { setTimeout(res, RETRY_DELAYS[attempt]); })
.then(function () { return loadPoints(q, attempt + 1); });
}
showStatus('地点加载失败(' + why + ')。地图上暂时没有点,可以点右侧重试。', true);
return false;
});
}
function reloadPoints(q) {
// 地图页的筛选是 AJAX 换数据、不跳页,所以顶部进度条要自己收
if (window.rondProgress) window.rondProgress.start();
return loadPoints(q).then(function (ok) {
if (ok && document.getElementById('track-toggle').checked) refreshTrack();
if (window.rondProgress) window.rondProgress.finish();
return ok;
});
}
// 与列表页保持一致:下拉 / 日期改动即生效,不用再点「应用筛选」。
// 关键词仍然回车才查(每敲一个字都发请求很难受)。
// 这里走的是本页自己的 submit 处理器(AJAX 换数据),所以用 requestSubmit。
form.addEventListener('change', function (e) {
var el = e.target;
if (!el || !el.name) return;
if (el.tagName === 'SELECT' || el.type === 'date') {
if (form.requestSubmit) form.requestSubmit();
else form.submit();
}
});
retryBtn.addEventListener('click', function () {
retryBtn.hidden = true;
hideStatus();
reloadPoints(qs());
});
// 从后台切回来时顺手重试一次:多半是刚才断网/休眠导致的失败
document.addEventListener('visibilitychange', function () {
if (!document.hidden && !statusBox.hidden) retryBtn.click();
});
/* ---------- 时间轴回放 ----------
* 每个点带着「首次到访时间」(ft,Unix 秒),拖动滑块就把 ft ≤ 截止时刻的点留下。
* 这样得到的是「一路走到这个时刻,足迹长什么样」的累加效果。
* 实现上不去逐点改样式,而是复用 setPoints 整体重画——点位规模是几百,
* 且只在「日期真的变了」时才重画(拖动一次可能跨很多像素但只跨几天)。
*/
var tlBar = document.getElementById('timeline-bar');
var tlRange = document.getElementById('tl-range');
var tlLabel = document.getElementById('tl-label');
var tlPlay = document.getElementById('tl-play');
var allPoints = []; // 最近一次拉取到的全部点(时间轴始终在它上面过滤)
var tlMin = 0, tlMax = 0, tlDay = 86400, tlLastDay = null, tlTimer = null;
function fmtDay(sec) {
var d = new Date(sec * 1000);
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2);
}
function applyTimeline() {
if (!allPoints.length) return;
var cut = Number(tlRange.value);
// 只跟着「天」变化重画:拖动时像素变化远多于天数变化,不然一帧要重画好几次
var day = Math.floor(cut / tlDay);
if (day === tlLastDay) return;
tlLastDay = day;
var sub = [], shown = 0;
for (var i = 0; i < allPoints.length; i++) {
var p2 = allPoints[i];
// 「截至某日」要把那一天也算进去,否则拖到最左会是一个空地图
if (!p2.ft || p2.ft < cut + tlDay) { sub.push(p2); shown++; }
}
// keepView:拖动/播放时不要重新 fit 视野,不然地图会跟着不停缩放,很晕
window.rondMap.setPoints(sub, { keepView: true });
tlLabel.textContent = fmtDay(cut) + ' · ' + shown + '/' + allPoints.length + ' 个点';
}
function setupTimeline(pts) {
allPoints = pts;
var withTime = pts.filter(function (p2) { return p2.ft; });
if (withTime.length < 2) { tlBar.hidden = true; return; }
tlMin = withTime[0].ft;
tlMax = withTime[0].ft;
for (var i = 1; i < withTime.length; i++) {
if (withTime[i].ft < tlMin) tlMin = withTime[i].ft;
if (withTime[i].ft > tlMax) tlMax = withTime[i].ft;
}
// 两端都对齐到「天」:input[type=range] 的可取值为 min + k*step,
// max 不是 step 的整数倍时**根本取不到 max**,全量时就会少算几个点。
tlMin = Math.floor(tlMin / tlDay) * tlDay;
tlMax = Math.ceil(tlMax / tlDay) * tlDay;
tlRange.min = tlMin;
tlRange.max = tlMax;
tlRange.step = tlDay;
tlRange.value = tlMax;
tlLastDay = null;
tlBar.hidden = false;
applyTimeline();
}
tlRange.addEventListener('input', function () {
if (tlTimer) return; // 一帧只重画一次,拖起来才不卡
tlTimer = requestAnimationFrame(function () { tlTimer = null; applyTimeline(); });
});
tlRange.addEventListener('change', function () { tlLastDay = null; applyTimeline(); });
document.getElementById('tl-reset').addEventListener('click', function () {
stopPlay();
tlRange.value = tlMax;
tlLastDay = null;
applyTimeline();
});
// 播放:约 12 秒走完整个跨度(按天推进,和拖动走同一条路径)
function stopPlay() {
if (tlTimer) { cancelAnimationFrame(tlTimer); tlTimer = null; }
if (tlPlay._t) { clearInterval(tlPlay._t); tlPlay._t = null; }
tlPlay.textContent = '▶';
tlPlay.setAttribute('aria-label', '播放时间轴');
}
tlPlay.addEventListener('click', function () {
if (tlPlay._t) { stopPlay(); return; }
if (Number(tlRange.value) >= tlMax) { tlRange.value = tlMin; tlLastDay = null; }
tlPlay.textContent = '⏸';
tlPlay.setAttribute('aria-label', '暂停');
var steps = Math.max(1, Math.round((tlMax - Number(tlRange.value)) / tlDay));
var perTick = Math.max(1, Math.ceil(steps / 240)); // 最多约 240 帧,约 12 秒
tlPlay._t = setInterval(function () {
var next = Number(tlRange.value) + perTick * tlDay;
if (next >= tlMax) { next = tlMax; stopPlay(); }
tlRange.value = next;
applyTimeline();
}, 50);
});
reloadPoints(qs());
form.addEventListener('submit', function (e) {
e.preventDefault();
var q = qs();
history.replaceState(null, '', '/map' + (q ? '?' + q : ''));
reloadPoints(q);
});
var trackOn = false;
function refreshTrack() {
if (!trackOn) { window.rondMap.setTrack(null, null); return; }
var q = qs();
loadJSON('/api/track' + (q ? '?' + q : ''))
.then(function (d) { window.rondMap.setTrack(d.segs || [], d.modes || []); })
// 轨迹拉不到只影响这一层,不阻塞地图本身;安静失败 + 取消勾选,别让开关停在"已选但没线"的状态
.catch(function (err) {
trackOn = false;
document.getElementById('track-toggle').checked = false;
window.rondMap.setTrack(null, null);
showStatus('行程轨迹加载失败(' + ((err && err.message) || '网络异常') + '),已取消勾选。', false);
setTimeout(hideStatus, 4000);
});
}
document.getElementById('track-toggle').addEventListener('change', function () {
trackOn = this.checked;
refreshTrack();
});
document.getElementById('heat-toggle').addEventListener('change', function () {
window.rondMap.setHeat(this.checked);
});
document.getElementById('city-toggle').addEventListener('change', function () {
window.rondMap.setCityAgg(this.checked);
});
document.getElementById('gpx-toggle').addEventListener('change', function () {
window.rondMap.setGpx(this.checked);
});
});
</script>
{{end}}
{{end}}
+454
View File
@@ -0,0 +1,454 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>{{if .Place.Name}}{{.Place.Name}}{{else}}未命名地点{{end}}</h1>
<div class="sub">
{{if .Place.City}}{{.Place.City}}{{end}}
{{if .Place.Province}} · {{.Place.Province}}{{end}}
{{if .Place.District}} · {{.Place.District}}{{end}}
{{if .Place.POICategory}} · {{poiname .Place.POICategory}}{{end}}
· <span class="mono">{{f1 .Place.Lat}}, {{f1 .Place.Lon}}</span>
</div>
</div>
<div class="row">
<a class="btn" href="/places">← 返回地点库</a>
<a class="btn" href="/map?q={{.Place.Name}}">在地图中查看</a>
{{if .IsAdmin}}<a class="btn" href="/admin/edit?open={{.Place.SrcPK}}" title="在数据编辑页里直接打开这个地点的编辑框">编辑</a>{{end}}
{{if .Images}}<button class="btn" type="button" id="imgs-open">图片 <span class="muted">{{len .Images}}</span></button>{{end}}
</div>
</div>
<div class="kpis" style="margin-bottom:16px">
<div class="kpi"><div class="v">{{num .Place.VisitCount}}<small>次</small></div><div class="k">到访次数</div></div>
<div class="kpi"><div class="v">{{dur .Place.DwellMinutes}}</div><div class="k">累计停留</div></div>
<div class="kpi"><div class="v">{{if .Place.FirstAt}}{{d .Place.FirstAt}}{{else}}—{{end}}</div><div class="k">首次到访</div></div>
<div class="kpi"><div class="v">{{if .Place.LastAt}}{{since .Place.LastAt}}{{else}}—{{end}}</div><div class="k">最近到访</div></div>
</div>
{{if .IsAdmin}}
<div class="card" style="margin-bottom:16px">
<h2>备注与别名</h2>
<div class="card-sub">
别名会替代原名显示在所有页面上;备注{{if .Settings.NotesPublic}}对访客公开{{else}}只有你自己能看到{{end}}。
「推荐 / 踩雷」是单独的按钮,不用写进备注。按 rond 的地点 ID 保存,重新上传备份也不会丢。
</div>
{{if .Flash}}<div class="alert ok" id="note-flash">{{.Flash}}</div>{{end}}
{{if .RondNote}}
<div class="card-sub" style="margin-bottom:10px">
<div style="margin-bottom:4px">
rond 备份里给这个地点写过的备注(按次到访记录,只读;展示时会拼在你的备注之前):
</div>
<div class="note-block">{{.RondNote}}</div>
</div>
{{end}}
<form method="post" action="/admin/place/note" id="note-form">
<input type="hidden" name="src_pk" value="{{.Place.SrcPK}}">
<input type="hidden" name="next" value="/places/{{.Place.ID}}">
<label class="f" style="max-width:420px">
<span>别名</span>
<input type="text" name="alias" value="{{.Place.Alias}}" maxlength="60" placeholder="{{.Place.Name}}">
</label>
<label class="f" style="max-width:640px">
<span>备注</span>
<textarea name="note" rows="3" maxlength="2000" placeholder="例如:和谁来的、什么印象">{{.MyNote}}</textarea>
</label>
<button class="btn primary" type="submit">保存</button>
</form>
</div>
{{else if .Place.Note}}
<div class="card" style="margin-bottom:16px"><h2>备注</h2><div class="note-block">{{.Place.Note}}</div></div>
{{end}}
{{if .IsAdmin}}
<div class="card" style="margin-bottom:16px">
<h2>地点图片</h2>
<div class="card-sub">
选好图就<b>自动上传并立即生效</b>,不用再点保存。
上传前会<b>在浏览器里转成 WebP</b>(长边 2000px),服务器只负责收下与转存,不做转码。
当前存储:<b>{{if eq .ImageBackend "s3"}}对象存储(S3 / R2){{else if eq .ImageBackend "local"}}服务器本地磁盘{{else}}{{.ImageBackend}}{{end}}</b>
,最多 {{.ImageMaxPer}} 张。传完访客在地点页点「图片」就能看到。
</div>
<div id="imgs-admin" data-src="{{.Place.SrcPK}}">
<div class="imgs-grid" id="imgs-grid">
{{range .Images}}
<figure class="imgs-cell" data-id="{{.ID}}">
<img src="{{.URL}}" alt="" loading="lazy">
<button type="button" class="imgs-del" data-id="{{.ID}}" title="删除这张">×</button>
</figure>
{{end}}
</div>
<div class="row" style="margin-top:12px;gap:10px">
<label class="btn primary" for="imgs-input" id="imgs-pick" style="cursor:pointer">+ 选择图片</label>
<input type="file" id="imgs-input" accept="image/*" multiple hidden>
<span class="muted" id="imgs-count" style="font-size:12.5px"></span>
</div>
<div class="imgs-hint" id="imgs-hint"></div>
</div>
</div>
{{end}}
{{if or .IsAdmin .Settings.MarksPublic}}
<div class="card" style="margin-bottom:16px">
<h2>推荐 / 踩雷</h2>
{{if .IsAdmin}}
{{/* 站长这里只放自己的结论;访客票数只读展示,避免同一块地方出现两套投票按钮 */}}
<div class="card-sub">值得再去就👍,踩雷避坑就👎。改主意了点一下已选中的按钮即可取消。</div>
<div class="vote-row" data-verdict-row data-src="{{.Place.SrcPK}}">
<span class="vote-label">我的结论</span>
<span class="vote-btns">
<button class="vote-btn up {{if eq .Verdict 1}}on{{end}}" type="button" data-verdict="1">👍 推荐</button>
<button class="vote-btn down {{if eq .Verdict -1}}on{{end}}" type="button" data-verdict="-1">👎 踩雷</button>
</span>
<span class="vote-tip" data-cur="{{.Verdict}}"></span>
</div>
<div class="vote-row">
<span class="vote-label">访客票数</span>
<span class="vote-btns">
<span class="vote-btn up ro">👍 <span class="up-n">{{num .Votes.Up}}</span></span>
<span class="vote-btn down ro">👎 <span class="down-n">{{num .Votes.Down}}</span></span>
</span>
<span class="vote-tip">只读</span>
</div>
{{else}}
<div class="card-sub">值得再去就👍,踩雷避坑就👎。改主意了点一下已选中的按钮即可取消。</div>
<div class="vote-row" data-visitor-row data-src="{{.Place.SrcPK}}">
<span class="vote-label">大家怎么说</span>
<span class="vote-btns">
<button class="vote-btn up" type="button" data-vote="1">👍 <span class="up-n">{{.Votes.Up}}</span></button>
<button class="vote-btn down" type="button" data-vote="-1">👎 <span class="down-n">{{.Votes.Down}}</span></button>
</span>
<span class="vote-tip" data-mine="{{.MyVote}}"></span>
</div>
{{end}}
</div>
<script>
(function () {
var POST = { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, credentials: 'same-origin' };
// 站长结论:AJAX 提交,避免整页刷新把还没保存的备注冲掉
var vrow = document.querySelector('.vote-row[data-verdict-row]');
if (vrow) {
var cur = Number(vrow.querySelector('.vote-tip').getAttribute('data-cur')) || 0;
var vtip = vrow.querySelector('.vote-tip');
var paintVerdict = function () {
vrow.querySelectorAll('.vote-btn').forEach(function (b) {
b.classList.toggle('on', Number(b.getAttribute('data-verdict')) === cur);
});
vtip.textContent = cur === 1 ? '已标记 · 推荐' : cur === -1 ? '已标记 · 踩雷' : '未标记';
};
paintVerdict();
vrow.querySelectorAll('.vote-btn').forEach(function (b) {
b.addEventListener('click', function () {
var v = Number(b.getAttribute('data-verdict'));
fetch('/api/verdict', Object.assign({
body: 'src_pk=' + vrow.getAttribute('data-src') + '&verdict=' + (cur === v ? 0 : v)
}, POST)).then(function (r) { return r.json(); }).then(function (d) {
if (d.error) { alert(d.error); return; }
cur = Number(d.verdict) || 0;
paintVerdict();
}).catch(function () {});
});
});
}
// 访客票:一人一票,再点一次撤回
var row = document.querySelector('.vote-row[data-visitor-row]');
if (!row) return;
var src = row.getAttribute('data-src');
var mine = Number(row.querySelector('.vote-tip').getAttribute('data-mine')) || 0;
function paint(m) {
mine = m;
row.querySelectorAll('.vote-btn').forEach(function (b) {
b.classList.toggle('on', Number(b.getAttribute('data-vote')) === mine);
});
var tip = row.querySelector('.vote-tip');
if (tip) tip.textContent = mine === 1 ? '你投了推荐' : mine === -1 ? '你投了踩雷' : '';
}
paint(mine);
row.querySelectorAll('.vote-btn').forEach(function (b) {
b.addEventListener('click', function () {
var v = Number(b.getAttribute('data-vote'));
b.disabled = true;
fetch('/api/vote', Object.assign({
body: 'src_pk=' + encodeURIComponent(src) + '&vote=' + v
}, POST)).then(function (r) { return r.json(); }).then(function (d) {
b.disabled = false;
if (d.error) { alert(d.error); return; }
row.querySelector('.up-n').textContent = d.up;
row.querySelector('.down-n').textContent = d.down;
paint(Number(d.mine) || 0);
}).catch(function () { b.disabled = false; });
});
});
})();
</script>
{{end}}
<div class="grid g2">
<div class="card">
<h2>位置</h2>
<div class="card-sub">底图:GCJ-02(与记录坐标同系,无需纠偏)</div>
<div id="place-map" class="map-preview" style="height:280px"></div>
</div>
<div class="card">
<h2>到访记录</h2>
<div class="card-sub">最近 {{num (len .Visits)}} 次</div>
<div class="list" style="max-height:280px;overflow-y:auto">
{{range .Visits}}
<div class="item" style="grid-template-columns:34px 1fr auto">
<div class="ic" style="background:{{colorof .Color}}22;width:30px;height:30px;font-size:15px">{{iconor .Icon "📍"}}</div>
<div>
<div class="t" style="font-weight:400">{{day .Arrival}}</div>
<div class="m">{{hm .Arrival}} – {{if .Departure}}{{hm .Departure}}{{else}}进行中{{end}}{{if .Remark}} · {{.Remark}}{{end}}</div>
</div>
<div class="r">{{dur .DurationMin}}</div>
</div>
{{end}}
</div>
</div>
</div>
</div>
<script>
window.addEventListener('load', function () {
window.rondMap.preview({ id: 'place-map', points: {{json .Points}} });
});
</script>
{{/* 全屏画廊:单张查看 + 左右切换 + 双击放大。
以前是把所有图竖着堆在一个滚动页里,手机上既不能左右滑、也不能放大。
容器常驻(内容由 JS 从缩略图墙同步),所以这里不用 if .Images 包着。 */}}
<div class="imgs-modal" id="imgs-modal" hidden>
<div class="imgs-count" id="imgs-counter"></div>
<button class="imgs-close" type="button" id="imgs-close" aria-label="关闭">×</button>
<button class="imgs-nav imgs-prev" type="button" id="imgs-prev" aria-label="上一张" hidden>‹</button>
<button class="imgs-nav imgs-next" type="button" id="imgs-next" aria-label="下一张" hidden>›</button>
<div class="imgs-stage" id="imgs-stage">
<img id="imgs-img" alt="">
</div>
</div>
<script src="/static/js/place-image.js?v={{.AssetV}}"></script>
<script>
window.addEventListener('load', function () {
var grid = document.getElementById('imgs-grid');
var modal = document.getElementById('imgs-modal');
var maxPer = {{.ImageMaxPer}};
var admin = document.getElementById('imgs-admin');
/* ---------- 全屏画廊 ----------
* 内容是「缩略图墙的镜像」:传图/删图之后不用刷新页面也能看到最新的,
* 页头那个「图片 N」按钮也一样(服务端渲染时还没有图的话,这里现补一个)。
* 所以下面统一由 syncGallery() 从 grid 反推,而不是各写一套。
*/
var stage = document.getElementById('imgs-stage');
var viewImg = document.getElementById('imgs-img');
var counter = document.getElementById('imgs-counter');
var prevBtn = document.getElementById('imgs-prev');
var nextBtn = document.getElementById('imgs-next');
var urls = [];
var idx = 0;
function cellURLs() {
return [].map.call(document.querySelectorAll('#imgs-grid .imgs-cell img'), function (i) {
return i.getAttribute('src');
});
}
function paint() {
if (!urls.length) return;
viewImg.src = urls[idx];
counter.textContent = (idx + 1) + ' / ' + urls.length;
var many = urls.length > 1;
prevBtn.hidden = nextBtn.hidden = !many;
// 换图后回到「适应屏幕」状态,否则下一张会继承上一张的放大
stage.classList.remove('zoomed');
stage.scrollTop = stage.scrollLeft = 0;
}
function goto(i) {
if (!urls.length) return;
idx = (i % urls.length + urls.length) % urls.length;
paint();
}
function openGallery(on) {
modal.hidden = !on;
// 打开时锁住背景滚动,否则滑到尽头会把后面的页面一起带着滚
document.body.style.overflow = on ? 'hidden' : '';
if (on) {
urls = cellURLs();
idx = Math.min(idx, Math.max(0, urls.length - 1));
paint();
} else {
stage.classList.remove('zoomed');
}
}
function syncGallery() {
urls = cellURLs();
var btn = document.getElementById('imgs-open');
if (!urls.length) {
if (btn) btn.remove();
if (modal) modal.hidden = true;
return;
}
if (!btn) {
btn = document.createElement('button');
btn.className = 'btn';
btn.type = 'button';
btn.id = 'imgs-open';
var head = document.querySelector('.page-head .row');
if (head) head.appendChild(btn);
}
btn.innerHTML = '图片 <span class="muted">' + urls.length + '</span>';
if (idx >= urls.length) idx = 0;
counter.textContent = urls.length + ' 张';
}
modal.addEventListener('click', function (e) {
var cls = (e.target.className || '').indexOf('imgs-close') >= 0;
// 点空白或关闭按钮都关掉;点图片本身不关(可能是想放大)
if (e.target === modal || e.target === stage || cls) openGallery(false);
});
prevBtn.addEventListener('click', function (e) { e.stopPropagation(); goto(idx - 1); });
nextBtn.addEventListener('click', function (e) { e.stopPropagation(); goto(idx + 1); });
document.addEventListener('click', function (e) {
if (e.target && e.target.id === 'imgs-open') { idx = 0; openGallery(true); }
});
document.addEventListener('keydown', function (e) {
if (modal.hidden) return;
if (e.key === 'Escape') openGallery(false);
else if (e.key === 'ArrowLeft') goto(idx - 1);
else if (e.key === 'ArrowRight') goto(idx + 1);
});
// 手势:横向滑动=切换,轻点两下=放大/还原。两者必须在**同一个** touchend 里分流——
// 分成两个监听器时,滑动结束后那个监听器会把它当成一次「点按」,连滑两次就误触发双击放大。
var tx = 0, ty = 0, tracking = false, panning = false, lastTap = 0;
function toggleZoom() {
stage.classList.toggle('zoomed');
if (!stage.classList.contains('zoomed')) stage.scrollTop = stage.scrollLeft = 0;
}
stage.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1) { tracking = false; return; }
tracking = true;
// 放大状态下拖动是「看局部」,不要再当成切换
panning = stage.classList.contains('zoomed');
tx = e.touches[0].clientX;
ty = e.touches[0].clientY;
}, { passive: true });
stage.addEventListener('touchend', function (e) {
if (!tracking) return;
tracking = false;
var t = e.changedTouches[0];
var dx = t.clientX - tx, dy = t.clientY - ty;
if (!panning && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
goto(idx + (dx < 0 ? 1 : -1));
return;
}
// 手指基本没动才算「点按」,避免拖动松手被误判成双击
if (Math.abs(dx) > 12 || Math.abs(dy) > 12) return;
var now = Date.now();
if (now - lastTap < 300) { lastTap = 0; toggleZoom(); } else { lastTap = now; }
}, { passive: true });
stage.addEventListener('dblclick', function (e) { e.preventDefault(); toggleZoom(); });
{{if .IsAdmin}}
/* ---------- 站长:传图 / 删图 ---------- */
if (!admin || !window.rondImage) { syncGallery(); return; }
var srcPK = admin.getAttribute('data-src');
var input = document.getElementById('imgs-input');
var pick = document.getElementById('imgs-pick');
var hint = document.getElementById('imgs-hint');
var countEl = document.getElementById('imgs-count');
var ready = {{if .ImageReady}}true{{else}}false{{end}};
function setHint(msg, kind) {
hint.textContent = msg || '';
hint.className = 'imgs-hint' + (kind ? ' ' + kind : '');
}
function refreshCount() {
var n = grid.querySelectorAll('.imgs-cell').length;
countEl.textContent = n ? ('已有 ' + n + ' / ' + maxPer + ' 张') : ('还没有图片,最多 ' + maxPer + ' 张');
}
function cell(id, url) {
var fig = document.createElement('figure');
fig.className = 'imgs-cell';
fig.setAttribute('data-id', id);
var img = document.createElement('img');
img.src = url; img.loading = 'lazy'; img.alt = '';
var del = document.createElement('button');
del.type = 'button'; del.className = 'imgs-del'; del.textContent = '×'; del.title = '删除这张';
del.setAttribute('data-id', id);
fig.appendChild(img); fig.appendChild(del);
return fig;
}
refreshCount();
if (!ready) {
setHint('当前图片存储配置有问题,先修好再上传(见 conf/app.ini 的 s3_* 与后台「图片存储」)。', 'err');
pick.classList.add('is-disabled');
pick.removeAttribute('for');
pick.title = '图片存储未就绪';
}
// 选中即上传:没有「保存」这一步,所以进度和结果必须在页面上说清楚
input.addEventListener('change', function () {
var files = [].slice.call(input.files || []);
input.value = ''; // 允许连着选同一张
if (!files.length) return;
admin.classList.add('imgs-busy');
var done = 0, failed = 0;
var finish = function () {
if (done + failed < files.length) return;
admin.classList.remove('imgs-busy');
refreshCount();
syncGallery();
if (failed) {
setHint('完成:成功 ' + done + ' 张,失败 ' + failed + ' 张。' +
(done ? '成功的那几张已经生效,刷新页面也在。' : ''), 'err');
} else {
setHint('已上传 ' + done + ' 张,已即时生效(访客刷新就能看到,不用再点保存)。', 'ok');
}
};
files.forEach(function (f, idx) {
setHint('正在处理 ' + (idx + 1) + '/' + files.length + ':' + f.name + '(浏览器转码 → 上传)…');
window.rondImage.toWebP(f).then(function (out) {
return window.rondImage.upload(srcPK, out.blob, out.width, out.height)
.then(function (d) {
if (d.error) throw new Error(d.error);
grid.appendChild(cell(d.id, d.url));
done++;
refreshCount();
});
}).catch(function (err) {
failed++;
setHint('「' + f.name + '」失败:' + (err && err.message ? err.message : err), 'err');
}).then(finish);
});
});
grid.addEventListener('click', function (e) {
var del = e.target.closest('.imgs-del');
if (!del) return;
var id = del.getAttribute('data-id');
del.disabled = true;
window.rondImage.remove(id).then(function (d) {
if (d.error) { setHint('删除失败:' + d.error, 'err'); del.disabled = false; return; }
var fig = grid.querySelector('.imgs-cell[data-id="' + id + '"]');
if (fig) fig.remove();
refreshCount();
syncGallery();
setHint('已删除该张,立即生效。', 'ok');
}).catch(function () { setHint('删除失败', 'err'); del.disabled = false; });
});
{{else}}
syncGallery();
{{end}}
});
</script>
{{end}}
+84
View File
@@ -0,0 +1,84 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>地点库</h1>
<div class="sub">
共 {{num .Total}} 个地点 · {{.RangeLabel}}{{if .City}} · 城市:{{.City}}{{end}}{{if .Keyword}} · 关键词:{{.Keyword}}{{end}}
</div>
</div>
</div>
{{if not .HasData}}
{{template "emptystate" .}}
{{else}}
{{template "filters" .}}
<div class="card">
<div class="row" style="margin-bottom:12px">
<span class="muted" style="font-size:12.5px">排序</span>
<div class="chips">
<a class="chip {{if eq .Sort ""}}on{{end}}" href="{{.Link .FilterBase "sort" ""}}">到访次数</a>
<a class="chip {{if eq .Sort "dwell"}}on{{end}}" href="{{.Link .FilterBase "sort" "dwell"}}">停留时长</a>
<a class="chip {{if eq .Sort "recent"}}on{{end}}" href="{{.Link .FilterBase "sort" "recent"}}">最近到访</a>
<a class="chip {{if eq .Sort "first"}}on{{end}}" href="{{.Link .FilterBase "sort" "first"}}">首次到访</a>
<a class="chip {{if eq .Sort "name"}}on{{end}}" href="{{.Link .FilterBase "sort" "name"}}">名称</a>
</div>
</div>
{{if or .IsAdmin .Settings.MarksPublic}}
<div class="row" style="margin-bottom:12px">
<span class="muted" style="font-size:12.5px">标记</span>
<div class="chips">
<a class="chip {{if eq .Verdict 0}}on{{end}}" href="{{.VerdictLink 0}}">全部</a>
<a class="chip {{if eq .Verdict 1}}on{{end}}" href="{{.VerdictLink 1}}">👍 推荐</a>
<a class="chip {{if eq .Verdict -1}}on{{end}}" href="{{.VerdictLink -1}}">👎 踩雷</a>
</div>
</div>
{{end}}
{{if .Places}}
<div class="tw">
<table class="tb">
<thead>
<tr>
<th>地点</th><th>城市</th><th>主要类型</th>
<th class="num">到访</th><th class="num">累计停留</th>
<th>首次</th><th>最近</th>
</tr>
</thead>
<tbody>
{{range .Places}}
<tr>
<td>
<a href="/places/{{.ID}}">{{if .Name}}{{.Name}}{{else}}未命名地点{{end}}</a>
{{if .IsHome}}<span class="tag">家</span>{{end}}
{{if .IsWork}}<span class="tag">工作</span>{{end}}
{{if eq .Verdict 1}}<span class="tag vote-up">👍 推荐</span>{{else if eq .Verdict -1}}<span class="tag vote-down">👎 踩雷</span>{{end}}
{{if or .Up .Down}}<span class="muted" style="font-size:12px">👍{{.Up}} · 👎{{.Down}}</span>{{end}}
</td>
<td class="muted">{{if .City}}{{.City}}{{else}}—{{end}}</td>
<td>
{{if .TopActivity}}<span class="chip" style="padding:1px 8px"><span class="dot" style="background:{{colorof .TopColor}}"></span>{{.TopActivity}}</span>{{else}}<span class="muted">—</span>{{end}}
</td>
<td class="num">{{num .VisitCount}}</td>
<td class="num">{{dur .DwellMinutes}}</td>
<td class="muted">{{if .FirstAt}}{{d .FirstAt}}{{else}}—{{end}}</td>
<td class="muted">{{if .LastAt}}{{d .LastAt}}{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{template "pager" .}}
{{else}}
<div class="empty">
<div class="big">🔍</div>
<div>没有符合条件的地点。</div>
<div style="margin-top:12px"><a class="btn" href="{{.FilterBase}}">清空筛选条件</a></div>
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
+144
View File
@@ -0,0 +1,144 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>{{.Year}} 年度报告</h1>
<div class="sub">这一年的足迹,一目了然</div>
</div>
<div class="row no-print">
{{if .Years}}
<select onchange="location='/report?y='+this.value" style="min-width:110px">
{{range .Years}}<option value="{{.}}" {{if eq . $.Year}}selected{{end}}>{{.}} 年</option>{{end}}
</select>
{{end}}
<button class="btn" id="btn-share" type="button">复制链接</button>
<button class="btn primary" id="btn-pdf" type="button">保存为 PDF</button>
</div>
</div>
{{if not .HasData}}
{{template "emptystate" .}}
{{else if gt .Year 0}}
<div class="kpis" style="margin-bottom:16px">
<div class="kpi"><div class="v">{{num .Overview.Visits}}<small>次</small></div><div class="k">到访记录</div></div>
<div class="kpi"><div class="v">{{num .NewPlaces}}<small>个</small></div><div class="k">新地点</div></div>
<div class="kpi"><div class="v">{{num .Overview.Cities}}<small>个</small></div><div class="k">城市</div></div>
<div class="kpi"><div class="v">{{num .Overview.Provinces}}<small>个</small></div><div class="k">省级行政区</div></div>
<div class="kpi"><div class="v">{{num .Overview.Days}}<small>天</small></div><div class="k">活跃天数</div></div>
<div class="kpi"><div class="v">{{km .Overview.DistanceKm}}<small>km</small></div><div class="k">出行里程</div></div>
</div>
{{if .Cities}}
<div class="card" style="margin-bottom:16px">
<h2>🏅 首次到达的城市</h2>
<div class="card-sub">这一年里,你第一次踏足的这些城市(按到达日期排序)</div>
<div class="chips">
{{range .Cities}}
<span class="chip on" title="{{.Province}} · 首次到达 {{.First}}">
📍 {{.City}}<span class="muted"> · {{.First}}</span>
</span>
{{end}}
</div>
</div>
{{end}}
<div class="grid g2">
<div class="card">
<h2>去得最多的城市</h2>
<div class="card-sub">按到访次数</div>
{{if .TopCities}}
<div class="bars">
{{range $i, $c := .TopCities}}
<div class="bar-row">
<div class="nm"><span class="muted">{{add $i 1}}.</span> {{.City}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count (index $.TopCities 0).Count}}%;background:var(--accent)"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
</div>
{{else}}<div class="empty">这一年还没有到访记录</div>{{end}}
</div>
<div class="card">
<h2>南北两端</h2>
<div class="card-sub">这一年到过纬度最高与最低的地点</div>
{{if .North}}
<div class="tw">
<table class="tb">
<thead><tr><th>方位</th><th>地点</th><th class="num">纬度</th></tr></thead>
<tbody>
<tr><td>最北</td><td>{{.North.Name}}<span class="muted"> · {{.North.City}}</span></td><td class="num">{{f1 .North.Lat}}°N</td></tr>
{{if .South}}<tr><td>最南</td><td>{{.South.Name}}<span class="muted"> · {{.South.City}}</span></td><td class="num">{{f1 .South.Lat}}°N</td>{{end}}
</tbody>
</table>
</div>
{{else}}<div class="empty">这一年还没有到访记录</div>{{end}}
</div>
</div>
{{if .Transports}}
<div class="card">
<h2>在路上</h2>
<div class="card-sub">这一年 {{num .Overview.Movements}} 段行程,共 {{km .Overview.DistanceKm}} km</div>
<div class="tw">
<table class="tb">
<thead><tr><th>方式</th><th class="num">次数</th><th class="num">里程</th></tr></thead>
<tbody>
{{range .Transports}}
<tr>
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
<td class="num">{{num .Count}}</td>
<td class="num">{{km .Distance}} km</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{if .Fog}}
{{/* 这张卡原来只有一句说明、没有任何内容(本来想放地图但没接上),看着像坏了。
改成与统计页一致的四个数字;完整的迷雾视图在 /stats 与地图页。 */}}
<div class="card">
<h2>世界迷雾</h2>
<div class="card-sub">迷雾是累计数据,不按年份切分。</div>
<div class="kpis">
<div class="kpi"><div class="v">{{f1 .Fog.AreaKm2}}<small>km²</small></div><div class="k">已探索面积</div></div>
<div class="kpi"><div class="v">{{pct .Fog.LitPlaces .Fog.Places}}<small>%</small></div><div class="k">地点点亮率</div></div>
<div class="kpi"><div class="v">{{num .Fog.LitPlaces}}<small>/{{num .Fog.Places}}</small></div><div class="k">点亮地点</div></div>
<div class="kpi"><div class="v">{{num .Fog.Blocks}}<small>块</small></div><div class="k">迷雾快照</div></div>
</div>
</div>
{{end}}
<div class="row" style="justify-content:center;margin:8px 0 24px">
<a class="btn" href="/stats?from={{printf "%d-01-01" .Year}}&to={{printf "%d-12-31" .Year}}">查看这一年的完整统计 →</a>
</div>
{{else}}
<div class="card"><div class="empty">还没有数据</div></div>
{{end}}
</div>
<script>
(function () {
var share = document.getElementById('btn-share');
var pdf = document.getElementById('btn-pdf');
if (share) share.addEventListener('click', function () {
var url = location.href;
if (navigator.share) {
navigator.share({ title: document.title, url: url }).catch(function () {});
return;
}
if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(function () {
share.textContent = '已复制链接';
setTimeout(function () { share.textContent = '复制链接'; }, 1600);
}).catch(function () { window.prompt('复制下面的链接:', url); });
} else {
window.prompt('复制下面的链接:', url);
}
});
if (pdf) pdf.addEventListener('click', function () { window.print(); });
})();
</script>
{{end}}
+245
View File
@@ -0,0 +1,245 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>数据统计</h1>
{{/* 备份文件名只对站长有意义,访客看到的是噪音(也是没必要暴露的信息) */}}
<div class="sub">{{.RangeLabel}}{{if .IsAdmin}}{{if .Dataset}} · 数据来自 {{.Dataset.Name}}{{end}}{{end}}</div>
</div>
</div>
{{if not .HasData}}
{{template "emptystate" .}}
{{else}}
{{template "filters" .}}
<div class="kpis" style="margin-bottom:16px">
<div class="kpi"><div class="v">{{num .Overview.Visits}}<small>次</small></div><div class="k">到访记录</div></div>
<div class="kpi"><div class="v">{{num .Overview.Places}}<small>个</small></div><div class="k">不同地点</div></div>
<div class="kpi"><div class="v">{{dur .Overview.DwellMin}}</div><div class="k">累计停留</div></div>
<div class="kpi"><div class="v">{{km .Overview.DistanceKm}}<small>km</small></div><div class="k">出行里程</div></div>
</div>
{{if or .IsAdmin .Settings.MarksPublic}}
<div class="card" style="margin-bottom:16px">
<h2>推荐 &amp; 踩雷</h2>
<div class="card-sub">
站长结论 推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}} | 访客票数 👍 {{num .VoteUp}} · 👎 {{num .VoteDown}}
</div>
<div class="grid g2">
<div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👍 推荐的地点</div>
{{if .Recommended}}
<div class="chips">
{{range .Recommended}}<a class="chip" href="/places/{{.ID}}"><span class="dot" style="background:#34c759"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
</div>
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
</div>
<div>
<div class="muted" style="font-size:12.5px;margin-bottom:6px">👎 踩雷的地点</div>
{{if .Avoided}}
<div class="chips">
{{range .Avoided}}<a class="chip" href="/places/{{.ID}}"><span class="dot" style="background:#ff3b30"></span>{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}}<span class="muted"> · {{.City}}</span>{{end}}</a>{{end}}
</div>
{{else}}<div class="empty" style="padding:10px 0">还没有标记</div>{{end}}
</div>
</div>
</div>
{{end}}
<div class="card">
<h2>月度分布</h2>
<div class="card-sub">每月到访次数,最高 {{num .MaxMonthly}} 次</div>
<div class="cols">
{{/* 点柱条直接筛出那个月;title 是悬停读数 */}}
{{range .Monthly}}<a class="col" href="{{$.MonthLink .Key}}" title="{{.Key}}:{{.Count}} 次到访。点一下只看这个月" style="height:{{.Pct}}%"></a>{{end}}
</div>
<div class="cols-x">{{range .Monthly}}<span>{{.Label}}</span>{{end}}</div>
</div>
<div class="grid g2">
<div class="card">
<h2>类型分布</h2>
<div class="card-sub">按到访次数排序</div>
<div class="bars">
{{range .ActivityStats}}
<a class="bar-row" href="{{$.ActLink .ID}}" title="{{.Name}}:{{num .Count}} 次,累计 {{dur .Dwell}}。点一下按这个类型筛选">
<div class="nm">{{with icon .Icon}}{{.}} {{end}}{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxActivity}}%;background:{{colorof .Color}}"></div></div>
<div class="vv">{{num .Count}} 次 · {{dur .Dwell}}</div>
</a>
{{end}}
</div>
</div>
<div class="card">
<h2>一天中的分布</h2>
<div class="card-sub">按到访开始时间统计</div>
<div class="cols" style="height:130px">
{{range $h, $c := .Hourly}}<div class="col" style="height:{{pct $c $.MaxHourly}}%" title="{{$h}} 时:{{$c}} 次"></div>{{end}}
</div>
<div class="cols-x">
{{range $h, $c := .Hourly}}<span>{{if eq $h 0}}0{{else if eq $h 6}}6{{else if eq $h 12}}12{{else if eq $h 18}}18{{else}}&nbsp;{{end}}</span>{{end}}
</div>
</div>
</div>
<div class="grid g2">
<div class="card">
<h2>城市排行</h2>
<div class="card-sub">到访次数</div>
<div class="bars">
{{range .CityStats}}
<a class="bar-row" href="{{$.CityLink .City}}" title="{{.City}}:{{num .Count}} 次到访、{{num .Places}} 个地点。点一下按这个城市筛选">
<div class="nm">{{.City}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxCity}}%"></div></div>
<div class="vv">{{num .Count}} 次 · {{num .Places}} 地点</div>
</a>
{{end}}
</div>
</div>
<div class="card">
<h2>省级行政区</h2>
<div class="card-sub">覆盖情况</div>
<div class="tw">
<table class="tb">
<thead><tr><th>省份</th><th class="num">城市</th><th class="num">地点</th><th class="num">到访</th></tr></thead>
<tbody>
{{range .ProvinceStats}}
<tr>
<td>{{.Province}}</td>
<td class="num">{{num .Cities}}</td>
<td class="num">{{num .Places}}</td>
<td class="num">{{num .Count}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
<div class="grid g2">
<div class="card">
<h2>星期分布</h2>
<div class="card-sub">一周中的到访规律</div>
<div class="bars">
{{range .Weekday}}
{{/* 星期没有对应的筛选维度(筛选只有日期区间),所以只补悬停读数 */}}
<div class="bar-row" title="{{.Label}}:{{num .Count}} 次到访">
<div class="nm">{{.Label}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxWeekday}}%"></div></div>
<div class="vv">{{num .Count}} 次</div>
</div>
{{end}}
</div>
</div>
<div class="card">
<h2>出行方式</h2>
<div class="card-sub">次数与估算里程</div>
{{if .Transports}}
<div class="tw">
<table class="tb">
<thead><tr><th>方式</th><th class="num">次数</th><th class="num">里程</th><th class="num">在途时间</th></tr></thead>
<tbody>
{{range .Transports}}
<tr>
<td><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{with icon .Icon}}{{.}} {{end}}{{.Name}}</td>
<td class="num">{{num .Count}}</td>
<td class="num">{{km .Distance}} km</td>
<td class="num">{{dur .DwellMin}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}<div class="empty">暂无位移记录</div>{{end}}
</div>
</div>
<div class="grid g2">
{{if .Tags}}
<div class="card">
<h2>标签统计</h2>
<div class="card-sub">自定义标签的使用情况</div>
<div class="bars">
{{range .Tags}}
<a class="bar-row" href="{{$.TagLink .ID}}" title="#{{.Name}}:{{num .Count}} 次到访。点一下按这个标签筛选">
<div class="nm"><span class="dot" style="display:inline-block;background:{{colorof .Color}}"></span> {{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Count $.MaxTag}}%"></div></div>
<div class="vv">{{num .Count}} 次</div>
</a>
{{end}}
</div>
</div>
{{end}}
{{if .Weather}}
<div class="card">
<h2>天气与气温</h2>
<div class="card-sub">记录期间经历的小时级天气</div>
<div class="tw">
<table class="tb">
<thead><tr><th>天气</th><th class="num">小时数</th><th class="num">平均气温</th></tr></thead>
<tbody>
{{range .Weather}}
<tr>
<td>{{condition .Condition}}</td>
<td class="num">{{num .Count}}</td>
<td class="num">{{f1 .AvgTemp}} ℃</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{/* 逐月平均气温:这份数据以前一直算着却没渲染(白跑一条查询),这里把它用上。
横向可滚动,十几根柱子挤在窄屏上也不会把月份标签压成一团。 */}}
{{if .Temps}}
<div class="temp-chart">
<div class="temp-head">逐月平均气温</div>
<div class="temp-strip">
{{range .Temps}}
<div class="temp-col" title="{{.Key}}:平均 {{f1 .AvgC}} ℃,{{num .Hours}} 小时有记录">
<span class="tc-v">{{f1 .AvgC}}°</span>
<div class="tc-track"><div class="tc-bar" style="height:{{.Pct}}%;background:{{.Color}}"></div></div>
<span class="tc-m">{{.Label}}</span>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
</div>
{{if .Fog}}
<div class="card">
<h2>世界迷雾覆盖</h2>
<div class="card-sub">
迷雾快照 {{num .Fog.Blocks}} 块 / {{num .Fog.Cells}} 格,估算已探索
<b>{{f1 .Fog.AreaKm2}}</b> km²;地点落在已探索格内的比例即「点亮率」
</div>
<div class="kpis" style="margin-bottom:12px">
<div class="kpi"><div class="v">{{f1 .Fog.AreaKm2}}<small>km²</small></div><div class="k">已探索面积</div></div>
<div class="kpi"><div class="v">{{pct .Fog.LitPlaces .Fog.Places}}<small>%</small></div><div class="k">地点点亮率</div></div>
<div class="kpi"><div class="v">{{num .Fog.LitPlaces}}<small>/{{num .Fog.Places}}</small></div><div class="k">点亮地点</div></div>
</div>
{{if .Fog.Cities}}
<div class="bars">
{{range .Fog.Cities}}
<a class="bar-row" href="{{$.CityLink .Name}}" title="{{.Name}}:{{.Lit}}/{{.Total}} 个地点落在已探索区。点一下按这个城市筛选">
<div class="nm">{{.Name}}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{pct .Lit .Total}}%;background:var(--accent)"></div></div>
<div class="vv">{{.Lit}}/{{.Total}}</div>
</a>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{end}}
</div>
{{end}}
+60
View File
@@ -0,0 +1,60 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>时间线</h1>
<div class="sub">共 {{num .Total}} 条到访记录 · {{.RangeLabel}}</div>
</div>
</div>
{{if not .HasData}}
{{template "emptystate" .}}
{{else}}
{{template "filters" .}}
{{if .Days}}
<div class="card">
<div class="row" style="margin-bottom:6px">
<a class="btn sm {{if eq .Sort ""}}primary{{end}}" href="{{.Link .FilterBase "sort" ""}}">最新在前</a>
<a class="btn sm {{if eq .Sort "asc"}}primary{{end}}" href="{{.Link .FilterBase "sort" "asc"}}">最早在前</a>
</div>
</div>
{{range .Days}}
<div class="timeline-day">
<div class="dh">
<span>{{.Label}}</span>
<span class="muted">{{num (len .Items)}} 条 · 停留 {{dur .Dwell}}</span>
</div>
{{range .Items}}
<div class="tl-item">
<div class="tm">{{hm .Arrival}}</div>
<div class="ln"><i style="background:{{colorof .Color}}"></i></div>
<div class="bd">
<div>
{{if .PlaceID}}<a href="/places/{{.PlaceID}}">{{if .PlaceName}}{{.PlaceName}}{{else}}未命名地点{{end}}</a>{{else}}未命名地点{{end}}
{{if .ActivityName}}<span class="chip" style="padding:1px 8px;font-size:11.5px"><span class="dot" style="background:{{colorof .Color}}"></span>{{.ActivityName}}</span>{{end}}
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
</div>
<div class="meta">
{{if .Departure}}{{hm .Arrival}} – {{hm .Departure}} · {{end}}停留 {{dur .DurationMin}}
{{if .City}} · {{.City}}{{end}}
{{if .Remark}} · {{.Remark}}{{end}}
</div>
</div>
</div>
{{end}}
</div>
{{end}}
{{template "pager" .}}
{{else}}
<div class="card"><div class="empty">
<div class="big">📭</div>
<div>没有符合条件的到访记录。</div>
<div style="margin-top:12px"><a class="btn" href="{{.FilterBase}}">清空筛选条件</a></div>
</div></div>
{{end}}
{{end}}
</div>
{{end}}
+97
View File
@@ -0,0 +1,97 @@
{{define "content"}}
{{if not .HasData}}
<div class="wrap">{{template "emptystate" .}}</div>
{{else}}
<div class="wrap">
<div class="page-head">
<div>
<h1>{{.Topic.Title}}</h1>
{{if .Topic.Subtitle}}<div class="sub">{{.Topic.Subtitle}}</div>{{end}}
{{/* 时间范围独立成行:塞进 KPI 网格里会被挤成好几行 */}}
<div class="topic-range">{{.Topic.RangeLabel}}{{if .Topic.DayCount}} · 共 {{num .Topic.DayCount}} 天{{end}}</div>
</div>
<div class="row">
<a class="btn" href="/topics">← 全部专题</a>
{{if .IsAdmin}}<a class="btn" href="/admin/topics?edit={{.Topic.ID}}">编辑</a>{{end}}
</div>
</div>
{{if not .Topic.Enabled}}<div class="alert err">这个专题尚未对外展示,只有你自己能看到。</div>{{end}}
{{if .Topic.Description}}<div class="card topic-desc">{{.Topic.Description}}</div>{{end}}
<div class="card">
<div class="kpis">
<div class="kpi"><div class="k">{{if .Topic.HasArea}}区域内地点{{else}}地点{{end}}</div><div class="v">{{num .Topic.PlaceCount}}</div></div>
<div class="kpi"><div class="k">到访次数</div><div class="v">{{num .Topic.VisitCount}}</div></div>
<div class="kpi"><div class="k">涉及城市</div><div class="v">{{num .Topic.CityCount}}</div></div>
<div class="kpi"><div class="k">累计停留</div><div class="v">{{dur .Topic.DwellMin}}</div></div>
</div>
</div>
<div class="card" style="padding:10px">
{{/* 高度写 inline:站点里 #map{height:100%} 是 ID 选择器,只靠外部 CSS 类
容易在别处规则变动/缓存不一致时把容器算成 0 高,地图就整块消失 */}}
<div id="map" class="topic-map" style="height:520px">
<div id="map-fallback" class="map-fallback">地图加载中……若一直不出现,请强制刷新(Ctrl+F5)或检查浏览器是否拦截了地图脚本</div>
</div>
</div>
<div class="card">
<h2>去过的地方</h2>
<div class="card-sub">共 {{num .Topic.PlaceCount}} 个,按到访次数排序</div>
{{if .Points}}
<div class="tw">
<table class="tb">
<thead><tr><th>地点</th><th>城市</th><th>类型</th><th class="num">到访</th><th class="num">停留</th><th>最近一次</th></tr></thead>
<tbody>
{{range .Points}}
<tr>
<td>{{if .Name}}{{.Name}}{{else}}<span class="muted">(未命名)</span>{{end}}</td>
<td class="muted">{{.City}}</td>
<td class="muted">{{if .Act}}<span class="dot" style="background:{{colorof .Color}}"></span>{{.Act}}{{end}}</td>
<td class="num">{{num .Count}}</td>
<td class="num muted">{{dur .Dwell}}</td>
<td class="muted mono">{{.LastAt}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="empty">这个时间 / 区域范围内没有记录</div>
{{end}}
</div>
</div>
<script>
(function () {
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
window.addEventListener('load', cb);
}
whenReady(function () {
var el = document.getElementById('map');
// 移动端矮一些(inline 高度优先于外部 CSS,只能在这里按视口改)
if (el && window.matchMedia('(max-width: 620px)').matches) el.style.height = '360px';
if (window.rondMap) {
window.rondMap.full({
id: 'map',
points: {{.PointsJSON}},
fitCircle: {{.FitCircleJSON}},
fitBBox: {{.FitBBoxJSON}},
showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}}
});
}
// 地图脚本没就绪(被拦 / 加载失败)时,把兜底提示留在原位,用户至少知道发生了什么
setTimeout(function () {
var fb = document.getElementById('map-fallback');
if (fb && el && el.classList.contains('leaflet-container')) fb.style.display = 'none';
}, 1200);
});
})();
</script>
{{end}}
{{end}}
+33
View File
@@ -0,0 +1,33 @@
{{define "content"}}
<div class="wrap">
<div class="page-head">
<div>
<h1>专题</h1>
<div class="sub">把某段时间、某个区域的足迹整理成一个个小专题</div>
</div>
{{if .IsAdmin}}<div class="row"><a class="btn" href="/admin/topics">专题管理</a></div>{{end}}
</div>
{{if .Topics}}
<div class="grid g2">
{{range .Topics}}
<a class="card topic-card" href="/topics/{{.ID}}">
<div class="topic-head">
<h2>{{.Title}}</h2>
{{if not .Enabled}}<span class="tag">未展示</span>{{end}}
</div>
{{if .Subtitle}}<div class="card-sub">{{.Subtitle}}</div>{{end}}
<div class="topic-range">{{.RangeLabel}}</div>
<div class="kpis kpis-3" style="margin-top:10px">
<div class="kpi"><div class="k">地点</div><div class="v">{{num .PlaceCount}}</div></div>
<div class="kpi"><div class="k">到访</div><div class="v">{{num .VisitCount}}</div></div>
<div class="kpi"><div class="k">天数</div><div class="v">{{num .DayCount}}</div></div>
</div>
</a>
{{end}}
</div>
{{else}}
<div class="card"><div class="empty">还没有对外展示的专题</div></div>
{{end}}
</div>
{{end}}
+19
View File
@@ -0,0 +1,19 @@
package web
import (
"html/template"
"path/filepath"
"testing"
)
// TestPageTemplatesParse 把每个页面模板都解析一遍。
// 模板是启动时编译的(见 New),语法错会让整个服务起不来;
// 这个测试把它提前到 go test 阶段,不用真起一次服务才发现。
func TestPageTemplatesParse(t *testing.T) {
for key, def := range pageFiles {
if _, err := template.New(filepath.Base(def.files[0])).Funcs(templateFuncs()).
ParseFS(tmplFS, def.files...); err != nil {
t.Errorf("解析模板 %s 失败: %v", key, err)
}
}
}
+261
View File
@@ -0,0 +1,261 @@
package web
import (
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"rond-with-you/internal/config"
)
// mapLayer 描述一个可供前端使用的底图/叠加图层。
// crs 表示该底图自身的坐标系:国内底图是 gcj02,天地图与 OSM 是 wgs84。
type mapLayer struct {
Key string `json:"key"`
Label string `json:"label"`
CRS string `json:"crs"`
URL string `json:"url"`
MaxZoom int `json:"maxZoom"`
Attribution string `json:"attr"`
}
// tileSet 是当前部署的底图集合。
// upstream 记录哪些图层必须经服务端转发:命中即说明该图层带密钥,
// 密钥只在上游模板里拼接,不会出现在页面源码中。
type tileSet struct {
base []mapLayer
overlays []mapLayer
upstream map[string]string
subStart int // 瓦片子域起始序号:高德从 1 起,天地图从 0 起
subCount int // 瓦片子域个数:高德 4 个,天地图 8 个
}
// 高德免密钥瓦片(三种样式实测:同一块的「近黑像素占比」≈ 文字量)
//
// style=8 街道图,带 POI 与路名标注(长沙一块 dark≈1.5%,wprd 主机更密≈3.5%)
// style=7 纯路网,只画道路、几乎不标注(同一块 dark≈0.2%)—— 点密集时用它才看得清自己的点
// style=6 卫星影像(走 wprd 主机)
const (
amapStyleStreet = "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}"
amapStyleRoadnet = "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}"
amapStyleSatellite = "https://wprd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=6&x={x}&y={y}&z={z}"
)
const tdtBase = "https://t{s}.tianditu.gov.cn/%s/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0" +
"&LAYER=%s&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk={key}"
func buildTileSet(cfg *config.Config) tileSet {
ts := tileSet{upstream: map[string]string{}, overlays: []mapLayer{}, subStart: 1, subCount: 4}
switch cfg.MapProvider {
case "tianditu":
ts.subStart, ts.subCount = 0, 8
ts.upstream["tdt-vec"] = fmt.Sprintf(tdtBase, "vec_w", "vec")
ts.upstream["tdt-img"] = fmt.Sprintf(tdtBase, "img_w", "img")
ts.upstream["tdt-cva"] = fmt.Sprintf(tdtBase, "cva_w", "cva")
ts.upstream["tdt-cia"] = fmt.Sprintf(tdtBase, "cia_w", "cia")
ts.base = []mapLayer{
{Key: "tdt-vec", Label: "矢量", CRS: "wgs84", URL: "/tiles/tdt-vec/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 天地图"},
{Key: "tdt-img", Label: "影像", CRS: "wgs84", URL: "/tiles/tdt-img/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 天地图"},
}
// 天地图的注记是独立图层,缺了会看不清地名
ts.overlays = []mapLayer{
{Key: "tdt-cva", Label: "矢量注记", CRS: "wgs84", URL: "/tiles/tdt-cva/{z}/{x}/{y}", MaxZoom: 18},
{Key: "tdt-cia", Label: "影像注记", CRS: "wgs84", URL: "/tiles/tdt-cia/{z}/{x}/{y}", MaxZoom: 18},
}
case "custom":
key := cfg.MapTileURL
if cfg.MapKey != "" {
ts.upstream["custom"] = key
ts.base = []mapLayer{{Key: "custom", Label: "底图", CRS: cfg.MapCRS, URL: "/tiles/custom/{z}/{x}/{y}", MaxZoom: 19}}
} else {
ts.base = []mapLayer{{Key: "custom", Label: "底图", CRS: cfg.MapCRS, URL: key, MaxZoom: 19}}
}
default: // amap
// OSM 免密钥全球瓦片作为兜底:高德瓦片加载持续失败时前端会自动切过去
osm := mapLayer{Key: "osm", Label: "OSM", CRS: "wgs84",
URL: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", MaxZoom: 19, Attribution: "© OpenStreetMap"}
if cfg.MapKey == "" {
// 免密钥瓦片,浏览器直连,服务端零负担
ts.base = []mapLayer{
{Key: "amap-street", Label: "街道", CRS: "gcj02", URL: amapStyleStreet, MaxZoom: 18, Attribution: "© 高德地图"},
{Key: "amap-roadnet", Label: "纯路网", CRS: "gcj02", URL: amapStyleRoadnet, MaxZoom: 18, Attribution: "© 高德地图"},
{Key: "amap-satellite", Label: "卫星影像", CRS: "gcj02", URL: amapStyleSatellite, MaxZoom: 18, Attribution: "© 高德地图"},
osm,
}
} else {
ts.upstream["amap-street"] = amapStyleStreet
ts.upstream["amap-roadnet"] = amapStyleRoadnet
ts.upstream["amap-satellite"] = amapStyleSatellite
ts.base = []mapLayer{
{Key: "amap-street", Label: "街道", CRS: "gcj02", URL: "/tiles/amap-street/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 高德地图"},
{Key: "amap-roadnet", Label: "纯路网", CRS: "gcj02", URL: "/tiles/amap-roadnet/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 高德地图"},
{Key: "amap-satellite", Label: "卫星影像", CRS: "gcj02", URL: "/tiles/amap-satellite/{z}/{x}/{y}", MaxZoom: 18, Attribution: "© 高德地图"},
osm,
}
}
}
return ts
}
// ---------- 瓦片代理 ----------
var tilePathRe = regexp.MustCompile(`^/tiles/([a-z0-9-]+)/(\d{1,2})/(\d{1,8})/(\d{1,8})$`)
type cachedTile struct {
body []byte
ct string
at time.Time
}
type tileCache struct {
mu sync.Mutex
items map[string]cachedTile
limit int
}
func newTileCache(limit int) *tileCache {
return &tileCache{items: map[string]cachedTile{}, limit: limit}
}
func (c *tileCache) get(k string) (cachedTile, bool) {
c.mu.Lock()
defer c.mu.Unlock()
t, ok := c.items[k]
return t, ok
}
// clear 清空缓存(数据变更后调用)。
func (c *tileCache) clear() {
c.mu.Lock()
c.items = map[string]cachedTile{}
c.mu.Unlock()
}
func (c *tileCache) put(k string, t cachedTile) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.items) >= c.limit {
// 简单的整表淘汰:够用且不会让内存无上限增长
c.items = map[string]cachedTile{}
}
c.items[k] = t
}
var tileHTTPClient = &http.Client{Timeout: 15 * time.Second}
// handleTile 代理瓦片请求。上游地址与密钥都在服务端拼接,前端只拿到本站路径。
func (s *Server) handleTile(w http.ResponseWriter, r *http.Request) {
m := tilePathRe.FindStringSubmatch(r.URL.Path)
if m == nil {
http.NotFound(w, r)
return
}
layer := m[1]
tpl, ok := s.tiles.upstream[layer]
if !ok {
http.NotFound(w, r)
return
}
z, _ := strconv.Atoi(m[2])
x, _ := strconv.Atoi(m[3])
y, _ := strconv.Atoi(m[4])
if z < 0 || z > 22 {
http.NotFound(w, r)
return
}
if x < 0 || y < 0 || x >= 1<<uint(z) || y >= 1<<uint(z) {
http.NotFound(w, r)
return
}
cacheKey := fmt.Sprintf("%s/%d/%d/%d", layer, z, x, y)
if t, ok := s.tileCache.get(cacheKey); ok {
writeTile(w, t.body, t.ct, true)
return
}
up := tpl
if s.tiles.subCount > 0 {
// 子域按瓦片坐标轮转。写死 0 会打到不存在的域名(高德只有 01~04)
up = strings.ReplaceAll(up, "{s}", strconv.Itoa(s.tiles.subStart+(x+y)%s.tiles.subCount))
}
up = strings.ReplaceAll(up, "{z}", strconv.Itoa(z))
up = strings.ReplaceAll(up, "{x}", strconv.Itoa(x))
up = strings.ReplaceAll(up, "{y}", strconv.Itoa(y))
up = strings.ReplaceAll(up, "{key}", s.cfg.MapKey)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, up, nil)
if err != nil {
http.Error(w, "上游地址无效", http.StatusBadGateway)
return
}
req.Header.Set("User-Agent", "rond-with-you/1.0")
resp, err := tileHTTPClient.Do(req)
if err != nil {
http.Error(w, "拉取瓦片失败", http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, "上游返回 "+resp.Status, http.StatusBadGateway)
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
http.Error(w, "读取瓦片失败", http.StatusBadGateway)
return
}
ct := resp.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "image/") {
ct = "image/png"
}
s.tileCache.put(cacheKey, cachedTile{body: body, ct: ct, at: time.Now()})
writeTile(w, body, ct, false)
}
func writeTile(w http.ResponseWriter, body []byte, ct string, cached bool) {
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "public, max-age=86400")
if cached {
w.Header().Set("X-Tile-Cache", "hit")
}
w.Write(body)
}
// mapConfig 返回前端构建底图所需的全部信息。
// subs 用于浏览器直连的免密钥底图:这类 URL 里带 {s} 占位符,
// 必须由前端交给 Leaflet 展开,服务端不参与,因此子域范围得下发。
// default 是后台设的「默认铺哪张底图」(空 = 第一张);
// 访客在图上手动切换过的话,前端会优先用他自己记在 localStorage 里的选择。
func (s *Server) mapConfig(w http.ResponseWriter, r *http.Request) {
subs := make([]string, 0, s.tiles.subCount)
for i := 0; i < s.tiles.subCount; i++ {
subs = append(subs, strconv.Itoa(s.tiles.subStart+i))
}
def := ""
if st, err := s.sets.Load(r.Context()); err == nil {
def = st.MapDefaultBase
}
resp := map[string]any{
"base": s.tiles.base,
"overlays": s.tiles.overlays,
"subs": subs,
"default": def,
}
if ix, err := s.fogIndex(); err == nil {
if blocks, cells := ix.Stats(); blocks > 0 {
// ver 里带上遮罩指纹:访客的瓦片允许公共缓存,遮罩改了 URL 必须跟着变,
// 否则访客会一直看到缓存里那份(最长 1 小时),像「遮罩没生效」。
ver := strconv.FormatInt(ix.Version(), 10) + "-" + s.maskFP()
resp["fog"] = map[string]any{
"blocks": blocks, "cells": cells, "ver": ver,
}
}
}
s.json(w, resp)
}
+34
View File
@@ -0,0 +1,34 @@
package web
import (
"strings"
"testing"
)
func TestTrackTag(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", ""},
{" ", ""},
{"https://umami.example.com/script.js",
`<script defer src="https://umami.example.com/script.js"></script>`},
{"http://a/b.js?x=1&y=2", `<script defer src="http://a/b.js?x=1&y=2"></script>`},
// 整段贴进来的原样输出(百度统计这类要带初始化代码)
{`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js?x"></script>`,
`<script>var _hmt=_hmt||[];</script><script src="https://hm.baidu.com/hm.js?x"></script>`},
// 形如 URL 却夹了引号/尖括号:宁可不挂,也不拼出可注出属性的标签
{`https://x/y.js" onload="alert(1)`, ""},
{`https://x/<y>.js`, ""},
{" https://a/b.js ", `<script defer src="https://a/b.js"></script>`},
}
for _, c := range cases {
if got := string(trackTag(c.in)); got != c.want {
t.Errorf("trackTag(%q) = %q,期望 %q", c.in, got, c.want)
}
}
if !strings.Contains(string(trackTag("//cdn.example.com/a.js")), "//cdn.example.com/a.js") {
t.Error("不以 http 开头但含尖括号以外的内容应原样输出")
}
}
+1389
View File
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
// withVisitorRegions 临时给「访客可见区域」配上一项,跑完还原。
//
// 这类 bug 只在访客视角 + 配了区域时才犯(`visitorRegions` 对站长返回空),
// 站长自己点一遍是好的,所以必须专门造这个组合。
func withVisitorRegions(t *testing.T, f *movementFixture) {
t.Helper()
var prev []byte
if err := f.pg.QueryRowContext(f.ctx, `SELECT value FROM settings WHERE key='site'`).Scan(&prev); err != nil {
t.Fatalf("读站点设置失败: %v", err)
}
write := func(v []byte) {
t.Helper()
if _, err := f.pg.ExecContext(f.ctx,
`UPDATE settings SET value=$1::jsonb WHERE key='site'`, string(v)); err != nil {
t.Fatalf("写站点设置失败: %v", err)
}
}
write([]byte(`{"regions":["河池市"],"regions_mode":"blacklist"}`))
t.Cleanup(func() { write(prev) })
}
// TestVisitorPagesWithRegionFilter 用**访客身份**把公开页面走一遍,只要求不 500。
//
// 专为「某个查询漏了区域条件、或者参数与占位符对不上」准备:这类错误犯过两次——
// 一次是 movements 的里程没跟着区域走,一次是专题的「天数」查询把上一条 SQL 的参数
// 整个传进来(多了区域那几个),pgx 报 expected N arguments, got M;
// 而专题列表会把错误吞掉,表现成「专题内容全变 0、点进去才 500」。
// 两次都只在访客视角复现,站长自己试是好的——所以这个组合必须专门测。
func TestVisitorPagesWithRegionFilter(t *testing.T) {
f := newMovementFixture(t)
withVisitorRegions(t, f)
var placeID, topicID int64
if err := f.pg.QueryRowContext(f.ctx, `SELECT id FROM places WHERE dataset_id=$1 ORDER BY id LIMIT 1`,
f.dsID).Scan(&placeID); err != nil {
t.Fatalf("取地点失败: %v", err)
}
// 专题页正是这次踩坑的地方,没有现成的就建一个覆盖几天到访的
if err := f.pg.QueryRowContext(f.ctx, `SELECT id FROM topics WHERE dataset_id=$1 ORDER BY id LIMIT 1`,
f.dsID).Scan(&topicID); err != nil {
if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO topics
(dataset_id, user_id, title, start_at, end_at, enabled)
VALUES ($1,$2,'访客冒烟','2020-01-01T00:00:00+08:00','2030-01-01T00:00:00+08:00',true)
RETURNING id`, f.dsID, userFrom(f.ctx).ID).Scan(&topicID); err != nil {
t.Fatalf("建测试专题失败: %v", err)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM topics WHERE id=$1`, topicID)
})
}
pages := []struct {
name string
path string
handle http.HandlerFunc
param string // PathValue("id") 用真实 id 填充
}{
{"主页", "/", f.srv.home, ""},
{"地点库", "/places", f.srv.placesPage, ""},
{"地点详情", "/places/x", f.srv.placePage, strconv.FormatInt(placeID, 10)},
{"统计", "/stats", f.srv.statsPage, ""},
{"时间线", "/timeline", f.srv.timelinePage, ""},
{"年度报告", "/report", f.srv.reportPage, ""},
{"地图", "/map", f.srv.mapPage, ""},
{"专题列表", "/topics", f.srv.topicsListPage, ""},
{"专题详情", "/topics/x", f.srv.topicPage, strconv.FormatInt(topicID, 10)},
{"点位接口", "/api/points", f.srv.apiPoints, ""},
{"轨迹接口", "/api/track", f.srv.apiTrack, ""},
}
for _, p := range pages {
t.Run(p.name, func(t *testing.T) {
req := httptest.NewRequest("GET", p.path, nil)
// 访客 = 上下文里没有登录态(withUser 中间件没跑)
req = req.WithContext(context.Background())
if p.param != "" {
req.SetPathValue("id", p.param)
}
rec := httptest.NewRecorder()
p.handle(rec, req)
if rec.Code >= 500 {
t.Errorf("%s 访客访问返回 %d:%s", p.path, rec.Code, firstLine(rec.Body.String()))
}
})
}
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > 200 {
s = s[:200]
}
return s
}