first release
@@ -0,0 +1,208 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
iterations = 210_000
|
||||
keyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
var ErrInvalidCredentials = errors.New("用户名或密码错误")
|
||||
|
||||
// HashPassword 使用 PBKDF2-SHA256 生成口令摘要。
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dk, err := pbkdf2.Key(sha256.New, password, salt, iterations, keyLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("pbkdf2$sha256$%d$%s$%s", iterations, b64(salt), b64(dk)), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 5 || parts[0] != "pbkdf2" {
|
||||
return false
|
||||
}
|
||||
iter, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
salt, err := unb64(parts[3])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := unb64(parts[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.RawStdEncoding.EncodeToString(b) }
|
||||
func unb64(s string) ([]byte, error) { return base64.RawStdEncoding.DecodeString(s) }
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
DisplayName string
|
||||
Hash string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
Secret []byte
|
||||
Days int
|
||||
}
|
||||
|
||||
// Authenticate 校验用户口令。
|
||||
func (s *Store) Authenticate(ctx context.Context, username, password string) (*User, error) {
|
||||
var u User
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT id, username, COALESCE(display_name,''), password_hash
|
||||
FROM users WHERE username=$1`, username).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Hash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !VerifyPassword(u.Hash, password) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if _, err := s.DB.ExecContext(ctx, `UPDATE users SET last_login_at=now() WHERE id=$1`, u.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// EnsureDefaultUser 在没有任何用户时创建初始管理员。
|
||||
func (s *Store) EnsureDefaultUser(ctx context.Context, username, password string) (bool, error) {
|
||||
var n int
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if n > 0 {
|
||||
return false, nil
|
||||
}
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := s.DB.ExecContext(ctx, `INSERT INTO users (username, password_hash, display_name)
|
||||
VALUES ($1,$2,$3)`, username, hash, "站长"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Store) ChangePassword(ctx context.Context, userID int64, current, next string) error {
|
||||
var hash string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id=$1`, userID).Scan(&hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if !VerifyPassword(hash, current) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
nh, err := HashPassword(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.DB.ExecContext(ctx, `UPDATE users SET password_hash=$1 WHERE id=$2`, nh, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrUsernameTaken 表示新用户名已被占用。
|
||||
var ErrUsernameTaken = errors.New("用户名已被占用")
|
||||
|
||||
// ChangeUsername 校验口令后改名。
|
||||
// 会话 cookie 认的是 id 与口令摘要(都不含用户名),所以改名不会把当前登录踢掉。
|
||||
func (s *Store) ChangeUsername(ctx context.Context, userID int64, current, name string) error {
|
||||
var hash string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id=$1`, userID).Scan(&hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if !VerifyPassword(hash, current) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
var taken bool
|
||||
if err := s.DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM users WHERE username=$1 AND id<>$2)`, name, userID).Scan(&taken); err != nil {
|
||||
return err
|
||||
}
|
||||
if taken {
|
||||
return ErrUsernameTaken
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE users SET username=$1 WHERE id=$2`, name, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserByID 读取用户;用于每次请求还原登录态。
|
||||
func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) {
|
||||
var u User
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT id, username, COALESCE(display_name,''), password_hash
|
||||
FROM users WHERE id=$1`, id).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// 会话令牌 = expiry|uid|hmac(uid|expiry|口令摘要前缀),口令变更即自动失效。
|
||||
func (s *Store) SignToken(userID int64, hash string) string {
|
||||
exp := time.Now().AddDate(0, 0, s.Days).Unix()
|
||||
payload := fmt.Sprintf("%d|%d", userID, exp)
|
||||
return payload + "|" + s.mac(payload, hash)
|
||||
}
|
||||
|
||||
func (s *Store) ParseToken(token, hash string) (int64, bool) {
|
||||
parts := strings.Split(token, "|")
|
||||
if len(parts) != 3 {
|
||||
return 0, false
|
||||
}
|
||||
uid, err1 := strconv.ParseInt(parts[0], 10, 64)
|
||||
exp, err2 := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err1 != nil || err2 != nil || time.Now().Unix() > exp {
|
||||
return 0, false
|
||||
}
|
||||
if !hmac.Equal([]byte(parts[2]), []byte(s.mac(parts[0]+"|"+parts[1], hash))) {
|
||||
return 0, false
|
||||
}
|
||||
return uid, true
|
||||
}
|
||||
|
||||
func (s *Store) mac(payload, hash string) string {
|
||||
m := hmac.New(sha256.New, s.Secret)
|
||||
m.Write([]byte(payload))
|
||||
// 混入口令摘要,改密后旧 cookie 立即作废
|
||||
m.Write([]byte("|" + hash[:min(len(hash), 16)]))
|
||||
return b64(m.Sum(nil))
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// TestChangeUsername 覆盖改名的正向路径、口令校验与占用冲突。
|
||||
// 会临时建一个用户并在结束时删掉,所以需要一个可连的库:用 ROND_DSN 指过来,
|
||||
// 未设置时跳过(与项目里其它按环境变量门控的测试一致)。
|
||||
func TestChangeUsername(t *testing.T) {
|
||||
dsn := os.Getenv("ROND_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("设置 ROND_DSN 后运行改名检查")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("连接数据库: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
name := fmt.Sprintf("tmp_rename_%d", time.Now().UnixNano())
|
||||
hash, err := HashPassword("tmp-pass-123456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var id int64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`INSERT INTO users (username, password_hash) VALUES ($1,$2) RETURNING id`, name, hash).Scan(&id); err != nil {
|
||||
t.Fatalf("建临时用户: %v", err)
|
||||
}
|
||||
defer db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, id)
|
||||
|
||||
st := &Store{DB: db, Secret: []byte("test-secret"), Days: 7}
|
||||
// 改名不该影响已登录会话:cookie 认的是 id 与口令摘要,都不含用户名
|
||||
tok := st.SignToken(id, hash)
|
||||
|
||||
next := name + "_new"
|
||||
if err := st.ChangeUsername(ctx, id, "wrong-password", next); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Errorf("口令错误应返回 ErrInvalidCredentials,实际 %v", err)
|
||||
}
|
||||
|
||||
// 换成一个已存在的用户名(库里除自己之外随便取一个)
|
||||
var other string
|
||||
if err := db.QueryRowContext(ctx, `SELECT username FROM users WHERE id<>$1 LIMIT 1`, id).Scan(&other); err == nil {
|
||||
if err := st.ChangeUsername(ctx, id, "tmp-pass-123456", other); !errors.Is(err, ErrUsernameTaken) {
|
||||
t.Errorf("占用他人用户名应返回 ErrUsernameTaken,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := st.ChangeUsername(ctx, id, "tmp-pass-123456", next); err != nil {
|
||||
t.Fatalf("正常改名失败: %v", err)
|
||||
}
|
||||
u, err := st.UserByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("回读用户: %v", err)
|
||||
}
|
||||
if u.Username != next {
|
||||
t.Errorf("用户名应为 %q,实际 %q", next, u.Username)
|
||||
}
|
||||
if got, ok := st.ParseToken(tok, hash); !ok || got != id {
|
||||
t.Errorf("改名后旧会话应仍有效,实际 ok=%v id=%d", ok, got)
|
||||
}
|
||||
// 密码没被动过:改完还能用原口令登录
|
||||
if _, err := st.Authenticate(ctx, next, "tmp-pass-123456"); err != nil {
|
||||
t.Errorf("改名后原口令应仍可登录: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TOTP 实现(RFC 6238,30 秒步长、6 位数字、HMAC-SHA1),
|
||||
// 与 Google Authenticator / 1Password / Microsoft Authenticator 兼容。
|
||||
|
||||
const (
|
||||
totpStep = 30 * time.Second
|
||||
totpStepS = 30 // 步长(秒),计数器 = unix秒 / 30
|
||||
totpWindow = 1 // 允许前后偏差的步数
|
||||
)
|
||||
|
||||
// counterOf 把时刻换算成 TOTP 计数器。
|
||||
func counterOf(unixSec int64) uint64 {
|
||||
if unixSec < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(unixSec / totpStepS)
|
||||
}
|
||||
|
||||
var ErrTOTPNotEnabled = errors.New("未开启两步验证")
|
||||
|
||||
// GenerateTOTPSecret 生成 20 字节随机秘钥的 base32 编码(无填充)。
|
||||
func GenerateTOTPSecret() (string, error) {
|
||||
raw := make([]byte, 20)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func totpCode(secret string, counter uint64) (string, error) {
|
||||
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(
|
||||
strings.ToUpper(strings.ReplaceAll(secret, " ", "")))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("秘钥格式无效: %w", err)
|
||||
}
|
||||
var msg [8]byte
|
||||
binary.BigEndian.PutUint64(msg[:], counter)
|
||||
m := hmac.New(sha1.New, key)
|
||||
m.Write(msg[:])
|
||||
sum := m.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0x0f
|
||||
code := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1_000_000
|
||||
return fmt.Sprintf("%06d", code), nil
|
||||
}
|
||||
|
||||
// TOTPCode 返回指定时刻的验证码(用于展示与测试)。
|
||||
func TOTPCode(secret string, t time.Time) (string, error) {
|
||||
return totpCode(secret, counterOf(t.Unix()))
|
||||
}
|
||||
|
||||
// VerifyTOTP 校验验证码,允许前后各一个步长的时钟偏差。
|
||||
func VerifyTOTP(secret, code string) bool {
|
||||
now := counterOf(time.Now().Unix())
|
||||
for _, c := range []uint64{now - totpWindow, now, now + totpWindow} {
|
||||
want, err := totpCode(secret, c)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(strings.TrimSpace(code))) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// OTPAuthURL 生成验证器 App 扫码用的 URI。
|
||||
func OTPAuthURL(username, secret string) string {
|
||||
label := urlEscape("rond-with-you:" + username)
|
||||
return "otpauth://totp/" + label + "?secret=" + secret + "&issuer=" + urlEscape("rond-with-you") +
|
||||
"&algorithm=SHA1&digits=6&period=30"
|
||||
}
|
||||
|
||||
func urlEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '~', r == ':':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
fmt.Fprintf(&b, "%%%02X", r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ---------- 用户秘钥存取 ----------
|
||||
|
||||
// TOTPState 返回用户的两步验证状态。
|
||||
func (s *Store) TOTPState(ctx context.Context, userID int64) (secret string, enabled bool, err error) {
|
||||
err = s.DB.QueryRowContext(ctx, `SELECT COALESCE(totp_secret,''), totp_enabled
|
||||
FROM users WHERE id=$1`, userID).Scan(&secret, &enabled)
|
||||
return
|
||||
}
|
||||
|
||||
// SetTOTPSecret 保存(或覆盖)秘钥;enabled 保持不变,供「先展示二维码再确认」流程。
|
||||
func (s *Store) SetTOTPSecret(ctx context.Context, userID int64, secret string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE users SET totp_secret=$1 WHERE id=$2`, secret, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnableTOTP 确认启用两步验证。
|
||||
func (s *Store) EnableTOTP(ctx context.Context, userID int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE users SET totp_enabled=TRUE WHERE id=$1 AND totp_secret<>''`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DisableTOTP 关闭两步验证并清除秘钥。
|
||||
func (s *Store) DisableTOTP(ctx context.Context, userID int64) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE users SET totp_enabled=FALSE, totp_secret='' WHERE id=$1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- 两步登录的中间凭据 ----------
|
||||
|
||||
// SignPending2FA 在口令校验通过但未过两步验证时签发短时中间令牌,
|
||||
// 绑定口令摘要(改密即失效),有效期 5 分钟。
|
||||
func (s *Store) SignPending2FA(userID int64, hash string) string {
|
||||
exp := time.Now().Add(5 * time.Minute).Unix()
|
||||
payload := fmt.Sprintf("2fa|%d|%d", userID, exp)
|
||||
return payload + "|" + s.mac("p"+payload, hash)
|
||||
}
|
||||
|
||||
// ParsePending2FA 还原中间令牌。
|
||||
func (s *Store) ParsePending2FA(token, hash string) (int64, bool) {
|
||||
parts := strings.Split(token, "|")
|
||||
if len(parts) != 4 || parts[0] != "2fa" {
|
||||
return 0, false
|
||||
}
|
||||
var uid, exp int64
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &uid); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[2], "%d", &exp); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if time.Now().Unix() > exp {
|
||||
return 0, false
|
||||
}
|
||||
payload := "2fa|" + parts[1] + "|" + parts[2]
|
||||
if !hmac.Equal([]byte(parts[3]), []byte(s.mac("p"+payload, hash))) {
|
||||
return 0, false
|
||||
}
|
||||
return uid, true
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Package captcha 生成登录页用的图形验证码。
|
||||
//
|
||||
// 刻意不引第三方库、也不依赖字体文件:字形是内置的 5×7 点阵,放大后逐个加抖动,
|
||||
// 再叠干扰线与噪点。服务端只存答案(不存图片),答案一次性使用并带过期时间。
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"math/big"
|
||||
mrand "math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// alphabet 是取码字符集:去掉 0/O、1/I/L 这些互相像的,省得用户反复认错。
|
||||
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
// CodeLen 是验证码长度。
|
||||
CodeLen = 4
|
||||
|
||||
glyphW, glyphH = 5, 7
|
||||
scale = 4
|
||||
padX, padY = 10, 6
|
||||
// stepX 要给抖动留余量:字符宽 20,抖动 ±2,步长 26 时最小间距只剩 2px,
|
||||
// 相邻字符的笔画会串在一起(人眼也一样难看)。30 保证最小间距仍有 6px。
|
||||
stepX = 30
|
||||
// Width/Height 是出图尺寸。
|
||||
Width = padX*2 + (CodeLen-1)*stepX + glyphW*scale // 130
|
||||
Height = padY*2 + glyphH*scale // 40
|
||||
|
||||
defaultTTL = 10 * time.Minute
|
||||
maxItems = 4096
|
||||
)
|
||||
|
||||
// Store 保存待验证的答案。站点是单进程部署,放内存即可;
|
||||
// 重启会让已发出的验证码失效,用户重新获取一次就行。
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
items map[string]item
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
type item struct {
|
||||
code string
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
// NewStore 建一个验证码池;ttl <= 0 时用 10 分钟。
|
||||
func NewStore(ttl time.Duration) *Store {
|
||||
if ttl <= 0 {
|
||||
ttl = defaultTTL
|
||||
}
|
||||
return &Store{items: map[string]item{}, ttl: ttl}
|
||||
}
|
||||
|
||||
// Issue 生成一张新验证码,返回用于对答的 id 与 PNG 字节。
|
||||
func (s *Store) Issue() (string, []byte, error) {
|
||||
code, err := randomCode()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
id, err := randomID()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.pruneLocked()
|
||||
s.items[id] = item{code: code, exp: time.Now().Add(s.ttl)}
|
||||
s.mu.Unlock()
|
||||
|
||||
png, err := render(code)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return id, png, nil
|
||||
}
|
||||
|
||||
// Verify 校验答案。无论对错都作废该 id——一次一用,避免拿同一张图反复试。
|
||||
func (s *Store) Verify(id, answer string) bool {
|
||||
s.mu.Lock()
|
||||
it, ok := s.items[id]
|
||||
delete(s.items, id)
|
||||
s.mu.Unlock()
|
||||
if !ok || time.Now().After(it.exp) {
|
||||
return false
|
||||
}
|
||||
got := strings.ToUpper(strings.TrimSpace(answer))
|
||||
return got == it.code
|
||||
}
|
||||
|
||||
// Answer 返回某个 id 当前对应的答案,不作废它。
|
||||
// 只给测试与本地排查用:答案本身不会经任何接口下发到客户端。
|
||||
func (s *Store) Answer(id string) (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
it, ok := s.items[id]
|
||||
return it.code, ok
|
||||
}
|
||||
|
||||
// pruneLocked 清掉过期项;仍然超过上限时随便丢一些,防止有人只取图不对答把内存撑大。
|
||||
// 调用方需持有 s.mu。
|
||||
func (s *Store) pruneLocked() {
|
||||
now := time.Now()
|
||||
for k, v := range s.items {
|
||||
if now.After(v.exp) {
|
||||
delete(s.items, k)
|
||||
}
|
||||
}
|
||||
for k := range s.items {
|
||||
if len(s.items) < maxItems {
|
||||
break
|
||||
}
|
||||
delete(s.items, k)
|
||||
}
|
||||
}
|
||||
|
||||
func randomCode() (string, error) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < CodeLen; i++ {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(alphabet[n.Int64()])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func randomID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
// render 把验证码画成 PNG:底 → 干扰线 → 字符(各自抖动)→ 噪点。
|
||||
func render(code string) ([]byte, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, Width, Height))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{248, 250, 251, 255}}, image.Point{}, draw.Src)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
drawLine(img, mrand.Intn(Width), mrand.Intn(Height), mrand.Intn(Width), mrand.Intn(Height), rgb(160, 215))
|
||||
}
|
||||
for i, ch := range code {
|
||||
g, ok := glyphs[ch]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 每个字符独立抖动,位置与颜色都不同——不抖的话点阵字太规整,等于白送
|
||||
ox := padX + i*stepX + mrand.Intn(5) - 2
|
||||
oy := padY + mrand.Intn(5) - 2
|
||||
col := rgb(0, 110)
|
||||
for row := 0; row < glyphH; row++ {
|
||||
for c := 0; c < glyphW; c++ {
|
||||
if g[row]&(1<<uint(glyphW-1-c)) == 0 {
|
||||
continue
|
||||
}
|
||||
for dy := 0; dy < scale; dy++ {
|
||||
for dx := 0; dx < scale; dx++ {
|
||||
img.Set(ox+c*scale+dx, oy+row*scale+dy, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 压在最上面的噪点少一些,别把字盖住
|
||||
for i := 0; i < 60; i++ {
|
||||
img.Set(mrand.Intn(Width), mrand.Intn(Height), rgb(120, 200))
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// rgb 返回每个通道取 [lo, hi) 的随机颜色。
|
||||
func rgb(lo, hi int) color.RGBA {
|
||||
f := func() uint8 { return uint8(lo + mrand.Intn(hi-lo)) }
|
||||
return color.RGBA{f(), f(), f(), 255}
|
||||
}
|
||||
|
||||
// drawLine 是 Bresenham 画线(只用标准库,不引画图包)。
|
||||
func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
|
||||
dx, dy := abs(x1-x0), -abs(y1-y0)
|
||||
sx, sy := -1, -1
|
||||
if x0 < x1 {
|
||||
sx = 1
|
||||
}
|
||||
if y0 < y1 {
|
||||
sy = 1
|
||||
}
|
||||
err := dx + dy
|
||||
for {
|
||||
img.Set(x0, y0, c)
|
||||
if x0 == x1 && y0 == y1 {
|
||||
return
|
||||
}
|
||||
e2 := 2 * err
|
||||
if e2 >= dy {
|
||||
err += dy
|
||||
x0 += sx
|
||||
}
|
||||
if e2 <= dx {
|
||||
err += dx
|
||||
y0 += sy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// glyphs 是 5×7 点阵字形:每行低 5 位有效,最高位在最左边。
|
||||
var glyphs = map[rune][glyphH]byte{
|
||||
'0': {0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110},
|
||||
'1': {0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110},
|
||||
'2': {0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111},
|
||||
'3': {0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110},
|
||||
'4': {0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010},
|
||||
'5': {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110},
|
||||
'6': {0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110},
|
||||
'7': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000},
|
||||
'8': {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110},
|
||||
'9': {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100},
|
||||
'A': {0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
|
||||
'B': {0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110},
|
||||
'C': {0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110},
|
||||
'D': {0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110},
|
||||
'E': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111},
|
||||
'F': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000},
|
||||
'G': {0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111},
|
||||
'H': {0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
|
||||
'I': {0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110},
|
||||
'J': {0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100},
|
||||
'K': {0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001},
|
||||
'L': {0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111},
|
||||
'M': {0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001},
|
||||
'N': {0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001},
|
||||
'O': {0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
|
||||
'P': {0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000},
|
||||
'Q': {0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101},
|
||||
'R': {0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001},
|
||||
'S': {0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110},
|
||||
'T': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100},
|
||||
'U': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
|
||||
'V': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100},
|
||||
'W': {0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001},
|
||||
'X': {0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001},
|
||||
'Y': {0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100},
|
||||
'Z': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111},
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIssueAndVerify(t *testing.T) {
|
||||
s := NewStore(time.Minute)
|
||||
id, pngBytes, err := s.Issue()
|
||||
if err != nil {
|
||||
t.Fatalf("出图失败: %v", err)
|
||||
}
|
||||
if id == "" || len(pngBytes) == 0 {
|
||||
t.Fatal("id 或图片为空")
|
||||
}
|
||||
code, ok := s.Answer(id)
|
||||
if !ok || len([]rune(code)) != CodeLen {
|
||||
t.Fatalf("答案不对: %q ok=%v", code, ok)
|
||||
}
|
||||
for _, r := range code {
|
||||
if !strings.ContainsRune(alphabet, r) {
|
||||
t.Errorf("答案含取码字符集之外的字符 %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 大小写与首尾空格都该容忍(用户手打容易带上)
|
||||
if !s.Verify(id, " "+strings.ToLower(code)+" ") {
|
||||
t.Error("正确验证码应通过(应容忍大小写与空格)")
|
||||
}
|
||||
// 一次性:同一张图不能再用
|
||||
if s.Verify(id, code) {
|
||||
t.Error("同一张验证码不应能被验证两次")
|
||||
}
|
||||
|
||||
// 错误答案
|
||||
id2, _, err := s.Issue()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Verify(id2, "XXXX") {
|
||||
t.Error("错误答案不应通过")
|
||||
}
|
||||
if s.Verify(id2, "XXXX") {
|
||||
t.Error("失败后该验证码也应作废")
|
||||
}
|
||||
|
||||
// 未发出的 id
|
||||
if s.Verify("nope", "ABCD") {
|
||||
t.Error("未知 id 不应通过")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiry(t *testing.T) {
|
||||
s := NewStore(time.Millisecond)
|
||||
id, _, err := s.Issue()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, _ := s.Answer(id)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
if s.Verify(id, code) {
|
||||
t.Error("过期后不应通过")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrune(t *testing.T) {
|
||||
s := NewStore(time.Millisecond)
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, _, err := s.Issue(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
// 下一次 Issue 会顺手清掉过期的,池子不该越攒越大
|
||||
if _, _, err := s.Issue(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(s.items); n > 1 {
|
||||
t.Errorf("过期项应被清掉,实际还剩 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageShowsAnswer 确认图里画的确实是答案:按已知点阵模板逐位回读。
|
||||
// 若哪天改了绘制偏移、抖动范围或字形表却忘了同步,用户会连自己也登不进后台,
|
||||
// 这条测试会立刻失败——它同时就是「图还认得出」的最低保证。
|
||||
func TestImageShowsAnswer(t *testing.T) {
|
||||
s := NewStore(time.Minute)
|
||||
for n := 0; n < 8; n++ {
|
||||
id, pngBytes, err := s.Issue()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, _ := s.Answer(id)
|
||||
img, err := png.Decode(bytes.NewReader(pngBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("图片无法解码: %v", err)
|
||||
}
|
||||
if b := img.Bounds(); b.Dx() != Width || b.Dy() != Height {
|
||||
t.Fatalf("图片尺寸应为 %dx%d,实际 %dx%d", Width, Height, b.Dx(), b.Dy())
|
||||
}
|
||||
if got := readCode(t, img); got != want {
|
||||
t.Fatalf("图里读出 %q,答案却是 %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// darkGrid 把图片预先取成「哪些像素属于字符笔画」的网格:
|
||||
// 字形用 0~110 的深色,干扰线与噪点都在 120 以上。逐像素调 img.At 太慢,先摊平。
|
||||
func darkGrid(img image.Image) [][]bool {
|
||||
grid := make([][]bool, Height)
|
||||
for y := 0; y < Height; y++ {
|
||||
row := make([]bool, Width)
|
||||
for x := 0; x < Width; x++ {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
row[x] = r>>8 < 120 && g>>8 < 120 && b>>8 < 120
|
||||
}
|
||||
grid[y] = row
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
// readCode 逐位回读验证码:每个字符的位置只在基准点 ±2 内抖动,
|
||||
// 于是在这个小邻域里让每个字形去套,取重合度最高的那个。
|
||||
func readCode(t *testing.T, img image.Image) string {
|
||||
t.Helper()
|
||||
grid := darkGrid(img)
|
||||
var out strings.Builder
|
||||
// 基准位置与 render 保持一致
|
||||
for i := 0; i < CodeLen; i++ {
|
||||
baseX := padX + i*stepX
|
||||
best, bestGlyph := -1, '?'
|
||||
for ch, g := range glyphs {
|
||||
if !strings.ContainsRune(alphabet, ch) {
|
||||
continue
|
||||
}
|
||||
for dx := -3; dx <= 3; dx++ {
|
||||
for dy := -3; dy <= 3; dy++ {
|
||||
score := 0
|
||||
for row := 0; row < glyphH; row++ {
|
||||
for c := 0; c < glyphW; c++ {
|
||||
on := g[row]&(1<<uint(glyphW-1-c)) != 0
|
||||
if !on {
|
||||
continue
|
||||
}
|
||||
// 只看笔画中心,避开边缘被抖动与噪点糊掉的部分
|
||||
x := baseX + dx + c*scale + scale/2
|
||||
y := padY + dy + row*scale + scale/2
|
||||
if x < 0 || y < 0 || x >= Width || y >= Height {
|
||||
continue
|
||||
}
|
||||
// 命中加分、误命中扣分:只看命中数的话,笔画多的字形
|
||||
// (比如 B 比 C 多一竖)光靠瞎撞也能赢
|
||||
if grid[y][x] {
|
||||
score++
|
||||
} else {
|
||||
score--
|
||||
}
|
||||
}
|
||||
}
|
||||
if score > best {
|
||||
best, bestGlyph = score, ch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 一个小字形最多 (5*7*scale*scale) 个像素,笔画中心数远小于它;
|
||||
// 命中太少说明这个位置没有字符,读数就不可信了
|
||||
if best < 8 {
|
||||
t.Fatalf("第 %d 个字符位置没读到笔画(最高分 %d)", i, best)
|
||||
}
|
||||
out.WriteRune(bestGlyph)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Listen string
|
||||
DSN string
|
||||
SecretKey string
|
||||
UploadDir string
|
||||
DataDir string
|
||||
SessionDays int
|
||||
Debug bool
|
||||
|
||||
// 底图。amap 走免密钥瓦片;填了 map_key 或选 tianditu 时改由服务端代理,
|
||||
// 密钥不出现在页面源码里。
|
||||
MapProvider string // amap | tianditu | custom
|
||||
MapKey string
|
||||
MapTileURL string // custom 模式下的瓦片模板,支持 {s} {x} {y} {z} {key}
|
||||
MapCRS string // custom 模式下底图的坐标系:gcj02 | wgs84
|
||||
|
||||
// 地理编码(后台「按名称搜坐标」)。高德 Web 服务类型密钥,与瓦片用的
|
||||
// JS 密钥不是同一个;只经服务端代理调用,不进入前端源码。留空则功能关闭。
|
||||
GeocodeKey string
|
||||
|
||||
// TrackScript 是第三方统计 / 追踪脚本。填 URL 会包成 <script src>;
|
||||
// 需要附加属性或初始化代码的服务(百度统计、Umami 等)可以把整段 <script> 贴进来。
|
||||
TrackScript string
|
||||
|
||||
// 地点图片的对象存储(S3 兼容,Cloudflare R2 等)。存哪边由后台「图片存储」
|
||||
// 设置决定,凭据只留在服务端、不进数据库也不下发前端。全部留空 = 只能用本地磁盘。
|
||||
S3Endpoint string // R2 形如 https://<account>.r2.cloudflarestorage.com
|
||||
S3Region string // R2 填 auto
|
||||
S3Bucket string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3PublicBase string // 公开访问前缀(自定义域名或 r2.dev),拼对象 key 即为 <img src>
|
||||
}
|
||||
|
||||
// S3Ready 判断对象存储是否配置齐全(缺一不可,否则只能在本地存)。
|
||||
func (c *Config) S3Ready() bool {
|
||||
return c.S3Endpoint != "" && c.S3Bucket != "" && c.S3AccessKey != "" && c.S3SecretKey != ""
|
||||
}
|
||||
|
||||
// MediaDir 是本地图片的落盘目录。
|
||||
func (c *Config) MediaDir() string { return filepath.Join(c.DataDir, "media") }
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
kv := map[string]string{}
|
||||
f, err := os.Open(path)
|
||||
if err == nil {
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
kv[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("读取配置 %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Listen: get(kv, "listen", ":8080"),
|
||||
DSN: get(kv, "dsn", ""),
|
||||
SecretKey: get(kv, "secret_key", ""),
|
||||
UploadDir: get(kv, "upload_dir", "storage/uploads"),
|
||||
DataDir: get(kv, "data_dir", "storage/data"),
|
||||
SessionDays: getInt(kv, "session_days", 14),
|
||||
Debug: get(kv, "debug", "false") == "true",
|
||||
|
||||
MapProvider: strings.ToLower(get(kv, "map_provider", "amap")),
|
||||
MapKey: get(kv, "map_key", ""),
|
||||
MapTileURL: get(kv, "map_tile_url", ""),
|
||||
MapCRS: strings.ToLower(get(kv, "map_crs", "gcj02")),
|
||||
|
||||
GeocodeKey: get(kv, "geocode_key", ""),
|
||||
|
||||
TrackScript: strings.TrimSpace(get(kv, "track_script", "")),
|
||||
|
||||
S3Endpoint: strings.TrimRight(get(kv, "s3_endpoint", ""), "/"),
|
||||
S3Region: get(kv, "s3_region", "auto"),
|
||||
S3Bucket: get(kv, "s3_bucket", ""),
|
||||
S3AccessKey: get(kv, "s3_access_key", ""),
|
||||
S3SecretKey: get(kv, "s3_secret_key", ""),
|
||||
S3PublicBase: normalizeBaseURL(get(kv, "s3_public_base", "")),
|
||||
}
|
||||
if cfg.DSN == "" {
|
||||
return nil, fmt.Errorf("配置缺少 dsn(PostgreSQL 连接串)")
|
||||
}
|
||||
if cfg.MapProvider != "amap" && cfg.MapProvider != "tianditu" && cfg.MapProvider != "custom" {
|
||||
return nil, fmt.Errorf("map_provider 只支持 amap / tianditu / custom,当前为 %q", cfg.MapProvider)
|
||||
}
|
||||
if cfg.MapProvider == "tianditu" && cfg.MapKey == "" {
|
||||
return nil, fmt.Errorf("map_provider = tianditu 时必须填写 map_key(天地图密钥)")
|
||||
}
|
||||
if cfg.MapProvider == "custom" && cfg.MapTileURL == "" {
|
||||
return nil, fmt.Errorf("map_provider = custom 时必须填写 map_tile_url")
|
||||
}
|
||||
if cfg.MapCRS != "gcj02" && cfg.MapCRS != "wgs84" {
|
||||
return nil, fmt.Errorf("map_crs 只支持 gcj02 / wgs84,当前为 %q", cfg.MapCRS)
|
||||
}
|
||||
if cfg.SecretKey == "" {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.SecretKey = hex.EncodeToString(b)
|
||||
}
|
||||
for _, d := range []string{cfg.UploadDir, cfg.DataDir, cfg.MediaDir()} {
|
||||
if err := os.MkdirAll(filepath.Clean(d), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建目录 %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// normalizeBaseURL 把「域名」补成完整 URL。
|
||||
// s3_public_base 是直接拼进 <img src> 的,写成裸域名(r2.example.com)会得到相对地址,
|
||||
// 浏览器把当前页面的路径拼上去,图全变破图——这种写法太容易发生了,这里统一补 https。
|
||||
func normalizeBaseURL(v string) string {
|
||||
v = strings.TrimRight(strings.TrimSpace(v), "/")
|
||||
if v == "" || strings.Contains(v, "://") {
|
||||
return v
|
||||
}
|
||||
return "https://" + v
|
||||
}
|
||||
|
||||
func get(kv map[string]string, key, def string) string {
|
||||
if v, ok := kv[key]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getInt(kv map[string]string, key string, def int) int {
|
||||
if v, ok := kv[key]; ok && v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExampleConfigDocumentsEveryKey 守住「样例配置与代码不脱节」。
|
||||
//
|
||||
// app.example.ini 是用户唯一能看到的配置说明:里面没有的键,等于不存在。
|
||||
// 反过来说,样例里写了代码不读的键(曾经有个 base_url)同样是坑——填了没反应,
|
||||
// 用户只会怀疑自己写错了。两个方向都检查。
|
||||
func TestExampleConfigDocumentsEveryKey(t *testing.T) {
|
||||
src, err := os.ReadFile("config.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读 config.go: %v", err)
|
||||
}
|
||||
// 以真正的读取点为准:get(kv, "...") / getInt(kv, "...")
|
||||
// 注意键名要允许数字(s3_endpoint 这类),写成 [a-z_]+ 会把它们整批漏掉,
|
||||
// 于是「样例里写了但代码不读」这种坑就查不出来了。
|
||||
re := regexp.MustCompile(`get(?:Int)?\(kv,\s*"([a-z0-9_]+)"`)
|
||||
used := map[string]bool{}
|
||||
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
|
||||
used[m[1]] = true
|
||||
}
|
||||
if len(used) < 10 {
|
||||
t.Fatalf("只从 config.go 扫到 %d 个配置键,抽取规则可能已失效", len(used))
|
||||
}
|
||||
|
||||
ex, err := os.ReadFile("../../conf/app.example.ini")
|
||||
if err != nil {
|
||||
t.Fatalf("读 app.example.ini: %v", err)
|
||||
}
|
||||
documented := map[string]bool{}
|
||||
for _, line := range strings.Split(string(ex), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
k, _, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
documented[strings.TrimSpace(k)] = true
|
||||
}
|
||||
|
||||
for k := range used {
|
||||
if !documented[k] {
|
||||
t.Errorf("app.example.ini 缺少配置项 %s(代码会读它)", k)
|
||||
}
|
||||
}
|
||||
for k := range documented {
|
||||
if !used[k] {
|
||||
t.Errorf("app.example.ini 里的 %s 代码并不读取,要么补上实现要么删掉", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExampleConfigLoads 用示例配置真跑一遍解析,确认它开箱可用。
|
||||
// dsn 是样例里的占位串,解析阶段不会去连库,所以这里能过。
|
||||
func TestExampleConfigLoads(t *testing.T) {
|
||||
cfg, err := Load("../../conf/app.example.ini")
|
||||
if err != nil {
|
||||
t.Fatalf("示例配置解析失败: %v", err)
|
||||
}
|
||||
// 挑几个「文档里承诺过」的默认值:改了代码或改了文档,这里都会响
|
||||
if cfg.Listen != ":8080" {
|
||||
t.Errorf("listen 默认值变了: %q", cfg.Listen)
|
||||
}
|
||||
if cfg.SessionDays != 14 {
|
||||
t.Errorf("session_days 默认值变了: %d", cfg.SessionDays)
|
||||
}
|
||||
if cfg.MapProvider != "amap" || cfg.MapCRS != "gcj02" {
|
||||
t.Errorf("底图默认值变了: provider=%q crs=%q", cfg.MapProvider, cfg.MapCRS)
|
||||
}
|
||||
if cfg.S3Region != "auto" || cfg.S3Ready() {
|
||||
t.Errorf("对象存储默认应当是「未配置」且 region=auto,实际 ready=%v region=%q",
|
||||
cfg.S3Ready(), cfg.S3Region)
|
||||
}
|
||||
if cfg.Debug {
|
||||
t.Error("示例配置里 debug 应当是关的")
|
||||
}
|
||||
if cfg.SecretKey == "" {
|
||||
t.Error("secret_key 留空时应自动生成,不该是空串")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
//go:embed schema.sql
|
||||
var schemaSQL string
|
||||
|
||||
// WarmUp 在后台把连接先建好放进池里。
|
||||
//
|
||||
// 建连接是这套系统里最贵的一步(见上面 SetMaxIdleConns 的注释),冷启动时
|
||||
// 第一个页面会把这笔开销全吃掉;并发建 n 条(不是串行)只需约一次建连的时间。
|
||||
// 失败就放弃——真实的报错由后续请求自己暴露,这里不必打扰启动流程。
|
||||
func WarmUp(d *sql.DB, n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
conn, err := d.Conn(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close() // 归还到池里,之后就是热连接
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func Open(dsn string) (*sql.DB, error) {
|
||||
d, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空闲连接数必须与上限相同:PostgreSQL 建连接的开销远大于查询本身
|
||||
// (本机实测建连 ~300ms、查询 ~2ms,psql 也一样)。池里留不住连接时,
|
||||
// 每个并发请求都要重新建连,页面耗时反而比串行还高。
|
||||
d.SetMaxOpenConns(8)
|
||||
d.SetMaxIdleConns(8)
|
||||
d.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err = d.PingContext(ctx)
|
||||
if err == nil {
|
||||
return d, nil
|
||||
}
|
||||
// 配置文件里写了一个还不存在的库:先用维护库把它建出来再连一次。
|
||||
// 只在「库不存在」时兜底——口令错、端口不通这些要原样报出来,
|
||||
// 否则连错服务器也会被当成「建个新的」。
|
||||
if !isDatabaseMissing(err) {
|
||||
d.Close()
|
||||
return nil, fmt.Errorf("连接 PostgreSQL 失败: %w", err)
|
||||
}
|
||||
if cerr := createDatabase(ctx, dsn); cerr != nil {
|
||||
d.Close()
|
||||
return nil, fmt.Errorf("数据库不存在,自动创建失败: %w", cerr)
|
||||
}
|
||||
if err := d.PingContext(ctx); err != nil {
|
||||
d.Close()
|
||||
return nil, fmt.Errorf("连接 PostgreSQL 失败(库已创建): %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// isDatabaseMissing 判断错误是不是「目标库不存在」(SQLSTATE 3D000)。
|
||||
// 驱动有时只给文本,所以再按消息兜一层。
|
||||
func isDatabaseMissing(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "3D000" {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "database") && strings.Contains(msg, "does not exist")
|
||||
}
|
||||
|
||||
// createDatabase 连到维护库把 dsn 里指定的库建出来。
|
||||
// 库名走 pgx.Identifier 转义,配置里写什么名字都不会拼坏语句。
|
||||
func createDatabase(ctx context.Context, dsn string) error {
|
||||
cc, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 dsn 失败: %w", err)
|
||||
}
|
||||
name := cc.Database
|
||||
if name == "" {
|
||||
return errors.New("dsn 里没有指定库名")
|
||||
}
|
||||
// 维护库优先 postgres,个别部署会删掉它,那就退回 template1
|
||||
var lastErr error
|
||||
for _, maint := range []string{"postgres", "template1"} {
|
||||
mc, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 dsn 失败: %w", err)
|
||||
}
|
||||
mc.Database = maint
|
||||
conn, err := pgx.ConnectConfig(ctx, mc)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
_, err = conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{name}.Sanitize())
|
||||
conn.Close(ctx)
|
||||
if err != nil {
|
||||
// 并发启动时可能已经被另一个实例建好了,那就算成功
|
||||
if strings.Contains(strings.ToLower(err.Error()), "already exists") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w(当前账号可能没有 CREATEDB 权限)", err)
|
||||
}
|
||||
log.Printf("数据库 %s 不存在,已自动创建", name)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("连不上维护库(postgres / template1): %w", lastErr)
|
||||
}
|
||||
|
||||
func Migrate(ctx context.Context, d *sql.DB) error {
|
||||
if _, err := d.ExecContext(ctx, schemaSQL); err != nil {
|
||||
return fmt.Errorf("初始化表结构失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return contains(s, "SQLSTATE 23505") || contains(s, "duplicate key value")
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (func() bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// TestOpenCreatesMissingDatabase 验证「配置里写了不存在的库,启动时自动建出来」。
|
||||
// 需要一个可连的库:用 ROND_DSN 指过来(会临时建一个库并在结束时删掉),未设置时跳过。
|
||||
func TestOpenCreatesMissingDatabase(t *testing.T) {
|
||||
dsn := os.Getenv("ROND_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("设置 ROND_DSN 后运行自动建库检查")
|
||||
}
|
||||
name := fmt.Sprintf("rond_test_autocreate_%d", time.Now().UnixNano())
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 ROND_DSN: %v", err)
|
||||
}
|
||||
u.Path = "/" + name
|
||||
target := u.String()
|
||||
|
||||
d, err := Open(target)
|
||||
if err != nil {
|
||||
t.Fatalf("库不存在时应自动建出来再连,实际报错: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
d.Close()
|
||||
// DROP DATABASE 要求没有连接挂着,所以先关掉上面的句柄
|
||||
dropDatabase(t, dsn, name)
|
||||
}()
|
||||
|
||||
// 库真的建出来了
|
||||
var n int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM pg_database WHERE datname=$1`, name).Scan(&n); err != nil {
|
||||
t.Fatalf("查 pg_database: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("库 %s 未被创建(匹配到 %d 条)", name, n)
|
||||
}
|
||||
// 建表也能正常跑(否则「建了个空库」没有意义)
|
||||
if err := Migrate(context.Background(), d); err != nil {
|
||||
t.Fatalf("初始化表结构: %v", err)
|
||||
}
|
||||
var tables int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM information_schema.tables WHERE table_schema='public'`).Scan(&tables); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tables < 10 {
|
||||
t.Errorf("表结构不完整,只有 %d 张表", tables)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenExistingDatabase 守住常规路径:库已存在时不该多做事(也不该报错)。
|
||||
func TestOpenExistingDatabase(t *testing.T) {
|
||||
dsn := os.Getenv("ROND_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("设置 ROND_DSN 后运行连接检查")
|
||||
}
|
||||
d, err := Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("连接既有库失败: %v", err)
|
||||
}
|
||||
d.Close()
|
||||
}
|
||||
|
||||
// TestDatabaseMissingDetection 确认只有「库不存在」会被当成需要建库的信号,
|
||||
// 否则连错服务器、口令错也会被当成「那就建个新的」。
|
||||
func TestDatabaseMissingDetection(t *testing.T) {
|
||||
if isDatabaseMissing(nil) {
|
||||
t.Error("nil 不该判为库不存在")
|
||||
}
|
||||
if !isDatabaseMissing(fmt.Errorf(`failed to connect: database "rond_x" does not exist (SQLSTATE 3D000)`)) {
|
||||
t.Error("库不存在的错误应被识别")
|
||||
}
|
||||
for _, msg := range []string{
|
||||
"failed SASL auth: 用户 \"postgres\" Password 认证失败 (SQLSTATE 28P01)",
|
||||
"dial tcp 127.0.0.1:5499: connectex: No connection could be made",
|
||||
} {
|
||||
if isDatabaseMissing(fmt.Errorf("%s", msg)) {
|
||||
t.Errorf("不该判为库不存在: %s", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dropDatabase(t *testing.T, dsn, name string) {
|
||||
t.Helper()
|
||||
cc, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Logf("清理时解析 dsn 失败: %v", err)
|
||||
return
|
||||
}
|
||||
cc.Database = "postgres"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
conn, err := pgx.ConnectConfig(ctx, cc)
|
||||
if err != nil {
|
||||
t.Logf("清理时连维护库失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
if _, err := conn.Exec(ctx, "DROP DATABASE IF EXISTS "+pgx.Identifier{name}.Sanitize()); err != nil {
|
||||
t.Logf("删除测试库失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
-- rond-with-you 数据结构
|
||||
-- 一次上传 = 一个 dataset,所有事实表挂在其下,支持多份备份共存与切换
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS datasets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
original_name TEXT NOT NULL,
|
||||
sha256 CHAR(64) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
source_created_at TIMESTAMPTZ,
|
||||
device_name TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status TEXT NOT NULL DEFAULT 'done',
|
||||
error TEXT,
|
||||
visit_count INT NOT NULL DEFAULT 0,
|
||||
place_count INT NOT NULL DEFAULT 0,
|
||||
raw_visit_count INT NOT NULL DEFAULT 0,
|
||||
first_visit_at TIMESTAMPTZ,
|
||||
last_visit_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_datasets_user ON datasets(user_id, uploaded_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_datasets_sha ON datasets(user_id, sha256);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||
src_pk INT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
is_home BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_work BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_excluded BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
UNIQUE (dataset_id, src_pk)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||
src_pk INT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
group_name TEXT,
|
||||
UNIQUE (dataset_id, src_pk)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS places (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||
src_pk INT NOT NULL,
|
||||
name TEXT,
|
||||
poi_category TEXT,
|
||||
lat DOUBLE PRECISION,
|
||||
lon DOUBLE PRECISION,
|
||||
country_code TEXT,
|
||||
province TEXT,
|
||||
city TEXT,
|
||||
district TEXT,
|
||||
sublocality TEXT,
|
||||
thoroughfare TEXT,
|
||||
timezone TEXT,
|
||||
visit_count INT NOT NULL DEFAULT 0,
|
||||
dwell_minutes BIGINT NOT NULL DEFAULT 0,
|
||||
first_visit_at TIMESTAMPTZ,
|
||||
last_visit_at TIMESTAMPTZ,
|
||||
UNIQUE (dataset_id, src_pk)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_places_dataset ON places(dataset_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_places_city ON places(dataset_id, city);
|
||||
CREATE INDEX IF NOT EXISTS idx_places_geo ON places(dataset_id, lat, lon);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS visits (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||
src_pk INT NOT NULL,
|
||||
place_id BIGINT REFERENCES places(id) ON DELETE CASCADE,
|
||||
activity_id BIGINT REFERENCES activities(id) ON DELETE SET NULL,
|
||||
arrival TIMESTAMPTZ NOT NULL,
|
||||
departure TIMESTAMPTZ,
|
||||
duration_min INT,
|
||||
is_home BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_work BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
bookmarked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_user_added BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
remark TEXT,
|
||||
emoji TEXT,
|
||||
weather_symbol TEXT,
|
||||
UNIQUE (dataset_id, src_pk)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_visits_arrival ON visits(dataset_id, arrival DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_visits_place ON visits(dataset_id, place_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_visits_activity ON visits(dataset_id, activity_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS visit_tags (
|
||||
visit_id BIGINT NOT NULL REFERENCES visits(id) ON DELETE CASCADE,
|
||||
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (visit_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS movements (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||
user_id BIGINT,
|
||||
src_pk INT NOT NULL,
|
||||
transport_src INT,
|
||||
transport_name TEXT,
|
||||
transport_color TEXT,
|
||||
transport_icon TEXT,
|
||||
from_visit_src INT,
|
||||
to_visit_src INT,
|
||||
from_place_id BIGINT REFERENCES places(id) ON DELETE SET NULL,
|
||||
to_place_id BIGINT REFERENCES places(id) ON DELETE SET NULL,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
duration_min INT,
|
||||
distance_km DOUBLE PRECISION,
|
||||
UNIQUE (dataset_id, src_pk)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_movements_start ON movements(dataset_id, started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||
user_id BIGINT,
|
||||
src_pk INT NOT NULL,
|
||||
visit_src INT,
|
||||
at TIMESTAMPTZ,
|
||||
is_daylight BOOLEAN,
|
||||
temperature_c DOUBLE PRECISION,
|
||||
apparent_c DOUBLE PRECISION,
|
||||
humidity DOUBLE PRECISION,
|
||||
precipitation_amount DOUBLE PRECISION,
|
||||
precipitation_chance DOUBLE PRECISION,
|
||||
wind_speed DOUBLE PRECISION,
|
||||
visibility DOUBLE PRECISION,
|
||||
uv_index INT,
|
||||
condition TEXT,
|
||||
symbol TEXT,
|
||||
uv_category TEXT,
|
||||
wind_direction TEXT,
|
||||
UNIQUE (dataset_id, src_pk)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_weather_at ON weather(dataset_id, at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 世界迷雾(Fog of World)已探索位图,按块粒度存储。
|
||||
-- 迷雾与数据集无关(来自另一个应用),挂在用户维度下;
|
||||
-- gbx/gby 为全球块坐标(0..65535),一个位对应 2^22 网格里约 9.55m 的一格。
|
||||
CREATE TABLE IF NOT EXISTS fog_blocks (
|
||||
user_id BIGINT NOT NULL,
|
||||
gbx INT NOT NULL,
|
||||
gby INT NOT NULL,
|
||||
bitmap BYTEA NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, gbx, gby)
|
||||
);
|
||||
|
||||
-- 地点备注与别名。按 src_pk(rond 内部地点 ID,跨快照稳定)而不是 places.id 存,
|
||||
-- 这样重新上传备份、数据集换了新 id 之后备注依然挂着。
|
||||
-- dataset_id 是后加的:src_pk 只在单个数据集内唯一,缺了它切数据集时同编号的
|
||||
-- 地点会把别人的备注/别名认领过去。
|
||||
CREATE TABLE IF NOT EXISTS place_notes (
|
||||
user_id BIGINT NOT NULL,
|
||||
dataset_id BIGINT NOT NULL DEFAULT 0,
|
||||
src_pk INT NOT NULL,
|
||||
alias TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, dataset_id, src_pk)
|
||||
);
|
||||
|
||||
-- 站长对地点的「官方结论」:推荐(1) / 踩雷(-1),与自由文本备注分开存,同样按 src_pk。
|
||||
CREATE TABLE IF NOT EXISTS place_votes (
|
||||
user_id BIGINT NOT NULL,
|
||||
dataset_id BIGINT NOT NULL DEFAULT 0,
|
||||
src_pk INT NOT NULL,
|
||||
verdict SMALLINT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, dataset_id, src_pk)
|
||||
);
|
||||
|
||||
-- 访客对地点的投票:一人一票、可改可撤。voter_key 由匿名 cookie + IP 派生,
|
||||
-- 与数据集无关(换快照不丢),故不挂 dataset_id。
|
||||
CREATE TABLE IF NOT EXISTS place_visitor_votes (
|
||||
src_pk INT NOT NULL,
|
||||
voter_key TEXT NOT NULL,
|
||||
vote SMALLINT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (src_pk, voter_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pvv_src ON place_visitor_votes(src_pk);
|
||||
|
||||
-- GPX 轨迹:rond 只记录「地点到地点」的直线位移,导入 GPX 可以把真实路径补上。
|
||||
-- 坐标统一按 WGS-84 存(GPX 标准),展示时再按底图坐标系转换。
|
||||
CREATE TABLE IF NOT EXISTS gpx_tracks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
point_count INT NOT NULL DEFAULT 0,
|
||||
length_km DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
started_at TIMESTAMPTZ,
|
||||
points JSONB NOT NULL,
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpx_user ON gpx_tracks(user_id, uploaded_at DESC);
|
||||
|
||||
-- ---------- 增量迁移 ----------
|
||||
-- 数据集是「快照」语义:一次上传 = 一份完整数据集,页面始终只展示其中一份。
|
||||
-- 因此判重不做行级去重,而是「文件摘要 + 内容指纹」两道,见 datasets.content_hash。
|
||||
-- 这里只给事实表补上 user_id(分区键,也为将来多用户留位)。
|
||||
ALTER TABLE visits ADD COLUMN IF NOT EXISTS user_id BIGINT;
|
||||
ALTER TABLE movements ADD COLUMN IF NOT EXISTS user_id BIGINT;
|
||||
ALTER TABLE weather ADD COLUMN IF NOT EXISTS user_id BIGINT;
|
||||
|
||||
-- 回填失败绝不能拖垮启动:老库若已存在重复行,唯一索引会让回填直接报错。
|
||||
-- 这里逐条兜住,最坏情况只是这些历史行保持 NULL,站点照常启动。
|
||||
DO $mig$
|
||||
BEGIN
|
||||
BEGIN
|
||||
UPDATE visits SET user_id = d.user_id FROM datasets d
|
||||
WHERE d.id = visits.dataset_id AND visits.user_id IS NULL;
|
||||
EXCEPTION WHEN unique_violation THEN
|
||||
RAISE NOTICE 'visits 回填 user_id 遇到重复行,已跳过';
|
||||
END;
|
||||
BEGIN
|
||||
UPDATE movements SET user_id = d.user_id FROM datasets d
|
||||
WHERE d.id = movements.dataset_id AND movements.user_id IS NULL;
|
||||
EXCEPTION WHEN unique_violation THEN
|
||||
RAISE NOTICE 'movements 回填 user_id 遇到重复行,已跳过';
|
||||
END;
|
||||
BEGIN
|
||||
UPDATE weather SET user_id = d.user_id FROM datasets d
|
||||
WHERE d.id = weather.dataset_id AND weather.user_id IS NULL;
|
||||
EXCEPTION WHEN unique_violation THEN
|
||||
RAISE NOTICE 'weather 回填 user_id 遇到重复行,已跳过';
|
||||
END;
|
||||
END
|
||||
$mig$;
|
||||
|
||||
-- 早期版本把 (user_id, src_pk) 做成了跨数据集全局唯一,想实现「重复导入不堆积」。
|
||||
-- 但那会让更新备份里的新记录只落进未激活的数据集(页面上看不到),已废弃,这里清理掉。
|
||||
-- 行级唯一性仍由各表自带的 UNIQUE (dataset_id, src_pk) 保证。
|
||||
DROP INDEX IF EXISTS idx_visits_dedupe;
|
||||
DROP INDEX IF EXISTS idx_movements_dedupe;
|
||||
DROP INDEX IF EXISTS idx_weather_dedupe;
|
||||
|
||||
-- 内容指纹:识别「内容相同、但文件字节不同」的重复上传(rond 每次重新导出字节都会变)
|
||||
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS content_hash TEXT;
|
||||
|
||||
-- 管理员 TOTP 两步验证:secret 为 base32(无填充);enabled=false 时 secret 仅作为
|
||||
-- 「已生成待确认」状态存在,确认验证码通过后才允许登录启用
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_datasets_content ON datasets(user_id, content_hash);
|
||||
|
||||
-- rond 备份里按次到访记录的备注(ZVISIT.ZREMARK_)汇总后的缓存。
|
||||
-- 单独一列存:回填只重写这一列,站长手写的 note 永远不受影响;
|
||||
-- 展示时把 rond_note 拼在 note 之前(见 web.applyNotes 的 joinNote)。
|
||||
ALTER TABLE place_notes ADD COLUMN IF NOT EXISTS rond_note TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 导出 rondbackup 所需的 Core Data 元信息(实体编号、ZPRIMARYKEY、建表 DDL、访问↔标签关联表),
|
||||
-- 导入时抓取并缓存,导出时据此从 PostgreSQL 重建 LifeEasy.sqlite。NULL 表示旧数据集未抓到,
|
||||
-- 需要把原始 .rondbackup 重新上传一次(即便内容相同也会补抓)。
|
||||
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS coredata_meta JSONB;
|
||||
|
||||
-- rond 原始行留档:每条实体在 Core Data 里的**完整一行**(含站点未映射的字段)
|
||||
-- 按 JSONB 存下来。有了它,导出不再依赖基线库去补那些字段——
|
||||
-- 基线即便丢了也能精确还原,站点侧也具备完整数据,不会因为映射范围窄而漏字段。
|
||||
-- BLOB 编码为 {"$blob": "<base64>"};时间戳保持 Core Data 的浮点秒,避免精度损失。
|
||||
CREATE TABLE IF NOT EXISTS entity_raw (
|
||||
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||
entity TEXT NOT NULL,
|
||||
src_pk INT NOT NULL,
|
||||
raw JSONB NOT NULL,
|
||||
-- skipped=真 表示这行因缺少必填字段没能进 PostgreSQL(例如没有到达时间的孤独到访)。
|
||||
-- 导出时这些行要按留档原样写回,否则往返一趟就少数据;而用户在站点上删掉的
|
||||
-- 行不在此列,不会被「复活」。
|
||||
skipped BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (dataset_id, entity, src_pk)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_raw_lookup ON entity_raw (dataset_id, entity);
|
||||
-- 旧库升级:补上后加的 skipped 列
|
||||
ALTER TABLE entity_raw ADD COLUMN IF NOT EXISTS skipped BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- 旧库升级:place_notes / place_votes 补 dataset_id 并把主键扩成三列。
|
||||
-- 回填归到当前激活数据集——旧数据只能这么认领,之后按数据集各管各的。
|
||||
ALTER TABLE place_notes ADD COLUMN IF NOT EXISTS dataset_id BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE place_votes ADD COLUMN IF NOT EXISTS dataset_id BIGINT NOT NULL DEFAULT 0;
|
||||
UPDATE place_notes SET dataset_id = COALESCE((SELECT id FROM datasets WHERE is_active ORDER BY id DESC LIMIT 1), 0)
|
||||
WHERE dataset_id = 0;
|
||||
UPDATE place_votes SET dataset_id = COALESCE((SELECT id FROM datasets WHERE is_active ORDER BY id DESC LIMIT 1), 0)
|
||||
WHERE dataset_id = 0;
|
||||
|
||||
DO $$
|
||||
DECLARE cols text;
|
||||
BEGIN
|
||||
SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum))
|
||||
INTO cols
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
|
||||
WHERE c.conname = 'place_notes_pkey';
|
||||
IF cols IS DISTINCT FROM 'user_id,dataset_id,src_pk' THEN
|
||||
ALTER TABLE place_notes DROP CONSTRAINT IF EXISTS place_notes_pkey;
|
||||
ALTER TABLE place_notes ADD CONSTRAINT place_notes_pkey PRIMARY KEY (user_id, dataset_id, src_pk);
|
||||
END IF;
|
||||
|
||||
SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum))
|
||||
INTO cols
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
|
||||
WHERE c.conname = 'place_votes_pkey';
|
||||
IF cols IS DISTINCT FROM 'user_id,dataset_id,src_pk' THEN
|
||||
ALTER TABLE place_votes DROP CONSTRAINT IF EXISTS place_votes_pkey;
|
||||
ALTER TABLE place_votes ADD CONSTRAINT place_votes_pkey PRIMARY KEY (user_id, dataset_id, src_pk);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 专题:把某段时间内、某个区域里的足迹(地点 + 迷雾)打包成一个可对外展示的页面。
|
||||
-- 区域用「中心点 + 半径」,半径为 0 表示不限区域(时间范围内的地点画到哪算哪)。
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
start_at TIMESTAMPTZ NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
center_lat DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
center_lon DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
radius_km DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
show_fog BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
-- enabled=假 时前台不可见(列表不出现、详情 404),站长仍可在后台预览
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_user ON topics(user_id, sort_order, id);
|
||||
|
||||
-- fog of world 快照里「非已探索层」的原始瓦片文件(Model/~ 与 Model/#)。
|
||||
-- 站点只用已探索层渲染,但导出必须原样写回这两层,否则重导出的快照会丢掉约 55% 的内容。
|
||||
-- data 存的是原始(zlib 压缩后的)字节,一个字节都不动。
|
||||
CREATE TABLE IF NOT EXISTS fog_raw_files (
|
||||
user_id BIGINT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
data BYTEA NOT NULL,
|
||||
PRIMARY KEY (user_id, path)
|
||||
);
|
||||
|
||||
-- 迷雾遮罩区:这些圆形范围内的迷雾对访客隐藏(站长自己照常看得到)。
|
||||
-- 存的中心是 **GCJ-02**(与 places 同系),渲染成瓦片前会换算到迷雾网格的 WGS-84。
|
||||
CREATE TABLE IF NOT EXISTS fog_masks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lon DOUBLE PRECISION NOT NULL,
|
||||
radius_m DOUBLE PRECISION NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 地点坐标的来源留档。以前没有任何记录,只能靠「留档里 ZRAWLATITUDE 与显示坐标是否相同」
|
||||
-- 反推——照片导入的点没有 ZLOCATION 留档行,根本判不出来。2026-09 那次照片导入选错坐标系
|
||||
-- (把 WGS-84 的 EXIF 当成已经是 GCJ-02)导致 63 个点整体偏 500~670m,事后只能逐张对原图
|
||||
-- 才查出来,就是因为缺这几列。现在建点的那一刻就写下来:
|
||||
-- coord_source 哪个入口建的:rond(导入备份)/ photo(照片导入)/ manual(后台新增地点)
|
||||
-- coord_sys 提交上来的坐标本身属于哪个坐标系:wgs84 / gcj02
|
||||
-- src_lat/lon 提交上来、**还没做任何换算**的原始坐标
|
||||
-- 三者齐全时,显示坐标 = 按 coord_sys 换算 src_lat/lon 的结果,随时可复算、可核对。
|
||||
-- 留空的含义是「历史行、没有留档」,不要在迁移里用启发式去猜(猜错比留空更糟)。
|
||||
ALTER TABLE places ADD COLUMN IF NOT EXISTS coord_source TEXT;
|
||||
ALTER TABLE places ADD COLUMN IF NOT EXISTS coord_sys TEXT;
|
||||
ALTER TABLE places ADD COLUMN IF NOT EXISTS src_lat DOUBLE PRECISION;
|
||||
ALTER TABLE places ADD COLUMN IF NOT EXISTS src_lon DOUBLE PRECISION;
|
||||
|
||||
-- 存量回填不在这里做:判据需要 geo 包的 GCJ-02 往返换算(见 internal/ingest/coord.go),
|
||||
-- SQL 里写不出来。启动时由 ingest.BackfillAllCoordSource 统一处理,那里同时能识别出
|
||||
-- 「站点导出的备份给新建地点合成的 ZLOCATION 行」,不会把它误当成真机的原始 GPS。
|
||||
|
||||
-- 地点图片:站长给地点配的图。字节由**浏览器**转成 WebP 后再上传(服务端只负责收下与转存),
|
||||
-- 1 核机器不用扛解码。storage 记这一张实际放在哪、object_key 是本地相对路径或对象存储 key:
|
||||
-- 换了存储后端之后旧图仍按各自那一列去取,不会因为改了设置就全变破图。
|
||||
CREATE TABLE IF NOT EXISTS place_images (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
dataset_id BIGINT NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
|
||||
src_pk INT NOT NULL,
|
||||
storage TEXT NOT NULL DEFAULT 'local',
|
||||
object_key TEXT NOT NULL,
|
||||
width INT NOT NULL DEFAULT 0,
|
||||
height INT NOT NULL DEFAULT 0,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (dataset_id, src_pk, object_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_place_images_place ON place_images(dataset_id, src_pk, position);
|
||||
@@ -0,0 +1,254 @@
|
||||
// Package exif 从 JPEG 里读出拍摄时间与 GPS 坐标(照片导入用)。
|
||||
//
|
||||
// 只做两件事:EXIF 的 DateTimeOriginal 和 GPS IFD,其余一概不碰。
|
||||
// 之所以自己解析而不引第三方库:站点是内网/离线构建,少一个依赖少一份麻烦;
|
||||
// JPEG 的 EXIF 就在紧跟在 SOI 之后的 APP1 段里,格式固定,值得手写。
|
||||
//
|
||||
// 注意 EXIF 里的 GPS 是 WGS-84,而 rond / 站点内部是 GCJ-02,
|
||||
// 调用方拿到坐标后要自己转(见 internal/geo.WGS84ToGCJ02)。
|
||||
package exif
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Info 是从一张照片里读到的有用信息。
|
||||
type Info struct {
|
||||
Lat, Lon float64
|
||||
HasGPS bool
|
||||
ShotAt time.Time
|
||||
HasTime bool
|
||||
}
|
||||
|
||||
var (
|
||||
errNotImage = errors.New("不认识的图片格式(只支持 JPEG 与 HEIC/HEIF)")
|
||||
errNoEXIF = errors.New("这张图片没有 EXIF 信息")
|
||||
errNeedField = errors.New("EXIF 里没有拍摄时间或 GPS")
|
||||
// exif 时间没有时区,按本地理解(手机拍的就是当地时间)
|
||||
timeLayout = "2006:01:02 15:04:05"
|
||||
)
|
||||
|
||||
// Parse 读出照片的拍摄时间与 GPS,自动识别 JPEG 与 HEIC/HEIF。
|
||||
// 缺 GPS 或时间都能容忍,用 Has* 判断。
|
||||
func Parse(r io.Reader) (Info, error) {
|
||||
// HEIC 的 Exif item 位置由 iloc 指到绝对偏移,可能在文件靠后,所以要读全;
|
||||
// 上限 32MB 兜底(调用方另有更严的大小限制)。
|
||||
buf, err := io.ReadAll(io.LimitReader(r, 32<<20))
|
||||
if err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
if len(buf) >= 2 && buf[0] == 0xFF && buf[1] == 0xD8 {
|
||||
return parseJPEG(buf)
|
||||
}
|
||||
// ISO BMFF:第 5~8 字节是 ftyp
|
||||
if len(buf) >= 12 && string(buf[4:8]) == "ftyp" {
|
||||
return parseHEIF(buf)
|
||||
}
|
||||
return Info{}, errNotImage
|
||||
}
|
||||
|
||||
// parseJPEG 扫 JPEG 的段,找到 APP1 里的 EXIF。
|
||||
func parseJPEG(buf []byte) (Info, error) {
|
||||
i := 2
|
||||
for i+4 <= len(buf) {
|
||||
if buf[i] != 0xFF { // 段间的填充字节
|
||||
i++
|
||||
continue
|
||||
}
|
||||
marker := buf[i+1]
|
||||
switch {
|
||||
case marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7):
|
||||
i += 2 // 这些标记后面没有长度字段
|
||||
continue
|
||||
case marker == 0xDA || marker == 0xD9: // 图像数据开始 / 文件结束
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
size := int(buf[i+2])<<8 | int(buf[i+3])
|
||||
if size < 2 || i+2+size > len(buf) {
|
||||
break
|
||||
}
|
||||
seg := buf[i+4 : i+2+size]
|
||||
if marker == 0xE1 && len(seg) > 6 && string(seg[:6]) == "Exif\x00\x00" {
|
||||
return parseTIFF(seg[6:])
|
||||
}
|
||||
i += 2 + size
|
||||
}
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
|
||||
// ParseAll 是 Parse 的便利版:把「有照片但没 GPS」也算成软失败,
|
||||
// 返回尽量多的信息,同时给出缺什么。
|
||||
func ParseAll(r io.Reader) (Info, error) {
|
||||
info, err := Parse(r)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
if !info.HasGPS && !info.HasTime {
|
||||
return info, errNeedField
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
typ uint16
|
||||
count uint32
|
||||
valOff [4]byte
|
||||
}
|
||||
|
||||
// readIFD 读出某个 IFD 的所有条目(tag -> entry)。
|
||||
func readIFD(t []byte, off int, bo binary.ByteOrder) map[uint16]entry {
|
||||
out := map[uint16]entry{}
|
||||
if off < 0 || off+2 > len(t) {
|
||||
return out
|
||||
}
|
||||
n := int(bo.Uint16(t[off : off+2]))
|
||||
base := off + 2
|
||||
if base+n*12 > len(t) {
|
||||
n = (len(t) - base) / 12
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
p := base + i*12
|
||||
e := entry{
|
||||
typ: bo.Uint16(t[p+2 : p+4]),
|
||||
count: bo.Uint32(t[p+4 : p+8]),
|
||||
}
|
||||
copy(e.valOff[:], t[p+8:p+12])
|
||||
out[bo.Uint16(t[p:p+2])] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// typeSize 是 EXIF 里每种类型的字节数。
|
||||
func typeSize(typ uint16) int {
|
||||
switch typ {
|
||||
case 1, 2, 6, 7: // BYTE / ASCII / SBYTE / UNDEFINED
|
||||
return 1
|
||||
case 3, 8: // SHORT / SSHORT
|
||||
return 2
|
||||
case 4, 9, 11: // LONG / SLONG / FLOAT
|
||||
return 4
|
||||
case 5, 10, 12: // RATIONAL / SRATIONAL / DOUBLE
|
||||
return 8
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// valueOffset 返回值所在位置的偏移。总长 > 4 字节时 valOff 存的是偏移,
|
||||
// 否则值直接内联在 valOff 里(左对齐)。
|
||||
func (e entry) valueOffset(t []byte, bo binary.ByteOrder) int {
|
||||
if int(e.count)*typeSize(e.typ) > 4 {
|
||||
return int(bo.Uint32(e.valOff[:]))
|
||||
}
|
||||
// 内联值:算出它在整个 t 里的位置
|
||||
return -1
|
||||
}
|
||||
|
||||
// inline 返回内联值(4 字节以内)的字节。
|
||||
func (e entry) inline() []byte { return e.valOff[:] }
|
||||
|
||||
// ascii 读 ASCII 类型的值(EXIF 里以 NUL 结尾)。
|
||||
func (e entry) ascii(t []byte, bo binary.ByteOrder) string {
|
||||
total := int(e.count)
|
||||
if total <= 0 {
|
||||
return ""
|
||||
}
|
||||
var raw []byte
|
||||
if total <= 4 {
|
||||
raw = e.inline()[:total]
|
||||
} else {
|
||||
off := int(bo.Uint32(e.valOff[:]))
|
||||
if off < 0 || off+total > len(t) {
|
||||
return ""
|
||||
}
|
||||
raw = t[off : off+total]
|
||||
}
|
||||
return strings.TrimRight(string(raw), "\x00")
|
||||
}
|
||||
|
||||
// rationals 读一组 RATIONAL(用 uint32 对表示),n 是想要的个数。
|
||||
func (e entry) rationals(t []byte, bo binary.ByteOrder, n int) ([]float64, bool) {
|
||||
off := int(bo.Uint32(e.valOff[:]))
|
||||
if off < 0 || off+n*8 > len(t) {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]float64, n)
|
||||
for i := 0; i < n; i++ {
|
||||
num := bo.Uint32(t[off+i*8 : off+i*8+4])
|
||||
den := bo.Uint32(t[off+i*8+4 : off+i*8+8])
|
||||
if den == 0 {
|
||||
return nil, false
|
||||
}
|
||||
out[i] = float64(num) / float64(den)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func parseTIFF(t []byte) (Info, error) {
|
||||
var info Info
|
||||
if len(t) < 8 {
|
||||
return info, errNoEXIF
|
||||
}
|
||||
var bo binary.ByteOrder
|
||||
switch string(t[:2]) {
|
||||
case "II":
|
||||
bo = binary.LittleEndian
|
||||
case "MM":
|
||||
bo = binary.BigEndian
|
||||
default:
|
||||
return info, errNoEXIF
|
||||
}
|
||||
if bo.Uint16(t[2:4]) != 0x002A {
|
||||
return info, errNoEXIF
|
||||
}
|
||||
ifd0 := readIFD(t, int(bo.Uint32(t[4:8])), bo)
|
||||
|
||||
// 拍摄时间在 Exif 子 IFD(IFD0 里的 0x8769 指向它)
|
||||
if e, ok := ifd0[0x8769]; ok {
|
||||
sub := readIFD(t, int(bo.Uint32(e.valOff[:])), bo)
|
||||
if d, ok := sub[0x9003]; ok {
|
||||
if s := strings.TrimSpace(d.ascii(t, bo)); s != "" {
|
||||
if tm, err := time.ParseInLocation(timeLayout, s, time.Local); err == nil {
|
||||
info.ShotAt, info.HasTime = tm, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GPS 在另一个子 IFD(IFD0 里的 0x8825)
|
||||
if e, ok := ifd0[0x8825]; ok {
|
||||
gps := readIFD(t, int(bo.Uint32(e.valOff[:])), bo)
|
||||
lat, okLat := gpsCoord(gps, t, bo, 1, 2)
|
||||
lon, okLon := gpsCoord(gps, t, bo, 3, 4)
|
||||
if okLat && okLon {
|
||||
info.Lat, info.Lon, info.HasGPS = lat, lon, true
|
||||
}
|
||||
}
|
||||
if !info.HasGPS && !info.HasTime {
|
||||
return info, fmt.Errorf("%w", errNeedField)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// gpsCoord 把「度分秒 + 方位」拼成十进制度。refTag 是 1/3(N-S、E-W),valTag 是 2/4。
|
||||
func gpsCoord(gps map[uint16]entry, t []byte, bo binary.ByteOrder, refTag, valTag uint16) (float64, bool) {
|
||||
refE, ok1 := gps[refTag]
|
||||
valE, ok2 := gps[valTag]
|
||||
if !ok1 || !ok2 {
|
||||
return 0, false
|
||||
}
|
||||
parts, ok := valE.rationals(t, bo, 3)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
v := parts[0] + parts[1]/60 + parts[2]/3600
|
||||
switch strings.ToUpper(strings.TrimSpace(refE.ascii(t, bo))) {
|
||||
case "S", "W":
|
||||
v = -v
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package exif
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// buildTIFF 拼出小端 TIFF(IFD0 + ExifIFD + GPS IFD),JPEG 与 HEIC 共用。
|
||||
func buildTIFF(lat, lon float64, shot string) []byte {
|
||||
tiff := &bytes.Buffer{}
|
||||
tiff.WriteString("II")
|
||||
binary.Write(tiff, binary.LittleEndian, uint16(0x002A))
|
||||
binary.Write(tiff, binary.LittleEndian, uint32(8)) // IFD0 偏移
|
||||
|
||||
const (
|
||||
ifd0Off = 8
|
||||
exifOff = ifd0Off + 30 // IFD0: 2 条目 = 2 + 24 + 4
|
||||
shotOff = exifOff + 18 // ExifIFD: 1 条目 = 2 + 12 + 4
|
||||
shotLen = len("2006:01:02 15:04:05") + 1
|
||||
gpsOff = shotOff + shotLen
|
||||
ratOff = gpsOff + 54 // GPS IFD: 4 条目 = 2 + 48 + 4
|
||||
)
|
||||
// IFD0
|
||||
binary.Write(tiff, binary.LittleEndian, uint16(2))
|
||||
tiff.Write(entryBytes(0x8769, 4, 1, uint32(exifOff)))
|
||||
tiff.Write(entryBytes(0x8825, 4, 1, uint32(gpsOff)))
|
||||
binary.Write(tiff, binary.LittleEndian, uint32(0))
|
||||
// ExifIFD:DateTimeOriginal
|
||||
binary.Write(tiff, binary.LittleEndian, uint16(1))
|
||||
tiff.Write(entryBytes(0x9003, 2, uint32(shotLen), uint32(shotOff)))
|
||||
binary.Write(tiff, binary.LittleEndian, uint32(0))
|
||||
tiff.WriteString(shot + "\x00")
|
||||
// GPS IFD
|
||||
binary.Write(tiff, binary.LittleEndian, uint16(4))
|
||||
latRef, lonRef := byte('N'), byte('E')
|
||||
alat, alon := math.Abs(lat), math.Abs(lon)
|
||||
if lat < 0 {
|
||||
latRef = 'S'
|
||||
}
|
||||
if lon < 0 {
|
||||
lonRef = 'W'
|
||||
}
|
||||
tiff.Write(entryBytes(1, 2, 2, uint32(latRef)))
|
||||
tiff.Write(entryBytes(2, 5, 3, uint32(ratOff)))
|
||||
tiff.Write(entryBytes(3, 2, 2, uint32(lonRef)))
|
||||
tiff.Write(entryBytes(4, 5, 3, uint32(ratOff+24)))
|
||||
binary.Write(tiff, binary.LittleEndian, uint32(0))
|
||||
writeDMS(tiff, alat)
|
||||
writeDMS(tiff, alon)
|
||||
return tiff.Bytes()
|
||||
}
|
||||
|
||||
// buildJPEG 拼一张最小 JPEG:SOI + APP1(EXIF) + EOI。
|
||||
// 只有 EXIF 段是真的,后面不需要有效图像数据——解析器读到 APP1 就返回。
|
||||
func buildJPEG(t *testing.T, lat, lon float64, shot string) []byte {
|
||||
t.Helper()
|
||||
body := &bytes.Buffer{}
|
||||
body.Write([]byte("Exif\x00\x00"))
|
||||
body.Write(buildTIFF(lat, lon, shot))
|
||||
out := &bytes.Buffer{}
|
||||
out.Write([]byte{0xFF, 0xD8, 0xFF, 0xE1})
|
||||
binary.Write(out, binary.BigEndian, uint16(body.Len()+2))
|
||||
out.Write(body.Bytes())
|
||||
out.Write([]byte{0xFF, 0xD9})
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// mkBox 拼一个 ISO BMFF 盒子(32 位长度)。
|
||||
func mkBox(typ string, payload []byte) []byte {
|
||||
b := make([]byte, 8+len(payload))
|
||||
binary.BigEndian.PutUint32(b[0:4], uint32(8+len(payload)))
|
||||
copy(b[4:8], typ)
|
||||
copy(b[8:], payload)
|
||||
return b
|
||||
}
|
||||
|
||||
// buildHEIC 拼一个最小 HEIC:ftyp + meta(iinf + iloc) + Exif item。
|
||||
// Exif item 放在文件末尾,iloc 里填它的绝对偏移——真实文件也是这样定位的。
|
||||
func buildHEIC(t *testing.T, lat, lon float64, shot string) []byte {
|
||||
t.Helper()
|
||||
tiff := buildTIFF(lat, lon, shot)
|
||||
// HEIF 的 Exif item = 4 字节 TIFF 偏移 + TIFF 本体
|
||||
item := append([]byte{0, 0, 0, 4}, tiff...)
|
||||
ftyp := mkBox("ftyp", append([]byte("heic"), []byte{0, 0, 0, 0, 'h', 'e', 'i', 'c'}...))
|
||||
|
||||
// infe v2:version+flags + item_ID + protection + item_type + 空名字
|
||||
infe := mkBox("infe", append([]byte{2, 0, 0, 0, 0, 1, 0, 0}, append([]byte("Exif"), 0)...))
|
||||
iinf := mkBox("iinf", append([]byte{0, 0, 0, 0, 0, 1}, infe...))
|
||||
|
||||
// iloc v0:offsetSize=4 lengthSize=4 baseSize=0,一条数据段
|
||||
ilocPayload := []byte{0, 0, 0, 0, 0x44, 0x00, 0, 1, 0, 1, 0, 0, 0, 1}
|
||||
offPos := len(ilocPayload)
|
||||
ilocPayload = append(ilocPayload, 0, 0, 0, 0) // extent_offset 待回填
|
||||
ilocPayload = append(ilocPayload,
|
||||
byte(len(item)>>24), byte(len(item)>>16), byte(len(item)>>8), byte(len(item)))
|
||||
iloc := mkBox("iloc", ilocPayload)
|
||||
|
||||
meta := mkBox("meta", append([]byte{0, 0, 0, 0}, append(iinf, iloc...)...))
|
||||
// extent_offset 的绝对位置:meta 头(8) + fullbox(4) + iinf + iloc 头(8) + 段内偏移
|
||||
pos := 8 + 4 + len(iinf) + 8 + offPos
|
||||
binary.BigEndian.PutUint32(meta[pos:pos+4], uint32(len(ftyp)+len(meta)))
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
out.Write(ftyp)
|
||||
out.Write(meta)
|
||||
out.Write(item)
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func entryBytes(tag, typ uint16, count uint32, val uint32) []byte {
|
||||
b := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint16(b[0:2], tag)
|
||||
binary.LittleEndian.PutUint16(b[2:4], typ)
|
||||
binary.LittleEndian.PutUint32(b[4:8], count)
|
||||
binary.LittleEndian.PutUint32(b[8:12], val)
|
||||
return b
|
||||
}
|
||||
|
||||
// writeDMS 把十进制度拆成 度/分/秒 三个 rational。
|
||||
func writeDMS(w *bytes.Buffer, v float64) {
|
||||
d := math.Floor(v)
|
||||
m := math.Floor((v - d) * 60)
|
||||
s := ((v-d)*60 - m) * 60
|
||||
for _, p := range [][2]uint32{{uint32(d), 1}, {uint32(m), 1}, {uint32(math.Round(s * 10000)), 10000}} {
|
||||
binary.Write(w, binary.LittleEndian, p[0])
|
||||
binary.Write(w, binary.LittleEndian, p[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGPSAndTime(t *testing.T) {
|
||||
// 大理古城附近,EXIF 存的是 WGS-84
|
||||
jpg := buildJPEG(t, 25.6890, 100.1560, "2026:08:14 12:40:33")
|
||||
info, err := Parse(bytes.NewReader(jpg))
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if !info.HasGPS {
|
||||
t.Fatal("没读到 GPS")
|
||||
}
|
||||
if math.Abs(info.Lat-25.6890) > 1e-4 || math.Abs(info.Lon-100.1560) > 1e-4 {
|
||||
t.Fatalf("坐标不对: %v, %v", info.Lat, info.Lon)
|
||||
}
|
||||
if !info.HasTime {
|
||||
t.Fatal("没读到拍摄时间")
|
||||
}
|
||||
want := time.Date(2026, 8, 14, 12, 40, 33, 0, time.Local)
|
||||
if !info.ShotAt.Equal(want) {
|
||||
t.Fatalf("时间不对: %v 期望 %v", info.ShotAt, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSouthWest(t *testing.T) {
|
||||
jpg := buildJPEG(t, -33.8688, -70.6693, "2025:01:02 03:04:05")
|
||||
info, err := Parse(bytes.NewReader(jpg))
|
||||
if err != nil {
|
||||
t.Fatalf("解析失败: %v", err)
|
||||
}
|
||||
if info.Lat > 0 || info.Lon > 0 {
|
||||
t.Fatalf("南纬/西经应该是负数: %v, %v", info.Lat, info.Lon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsNonImage(t *testing.T) {
|
||||
if _, err := Parse(bytes.NewReader([]byte("PNG\x89 not a jpeg"))); err == nil {
|
||||
t.Fatal("非图片应该报错")
|
||||
}
|
||||
bare := []byte{0xFF, 0xD8, 0xFF, 0xD9} // 合法 JPEG 但没有 EXIF 段
|
||||
if _, err := Parse(bytes.NewReader(bare)); err == nil {
|
||||
t.Fatal("没有 EXIF 应该报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHEIC(t *testing.T) {
|
||||
// 喜洲古镇附近,EXIF 存 WGS-84
|
||||
heic := buildHEIC(t, 25.8560, 100.1310, "2026:08:15 15:43:00")
|
||||
info, err := Parse(bytes.NewReader(heic))
|
||||
if err != nil {
|
||||
t.Fatalf("HEIC 解析失败: %v", err)
|
||||
}
|
||||
if !info.HasGPS || math.Abs(info.Lat-25.8560) > 1e-4 || math.Abs(info.Lon-100.1310) > 1e-4 {
|
||||
t.Fatalf("坐标不对: %v, %v", info.Lat, info.Lon)
|
||||
}
|
||||
if !info.HasTime || info.ShotAt.Format("2006-01-02 15:04:05") != "2026-08-15 15:43:00" {
|
||||
t.Fatalf("时间不对: %v", info.ShotAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHEICNoExifItem(t *testing.T) {
|
||||
// 有 meta/iinf/iloc 但没有 Exif item:应报「没有 EXIF」而不是崩
|
||||
infe := mkBox("infe", append([]byte{2, 0, 0, 0, 0, 1, 0, 0}, append([]byte("hvc1"), 0)...))
|
||||
iinf := mkBox("iinf", append([]byte{0, 0, 0, 0, 0, 1}, infe...))
|
||||
meta := mkBox("meta", append([]byte{0, 0, 0, 0}, iinf...))
|
||||
ftyp := mkBox("ftyp", append([]byte("heic"), []byte{0, 0, 0, 0, 'h', 'e', 'i', 'c'}...))
|
||||
buf := append(ftyp, meta...)
|
||||
if _, err := Parse(bytes.NewReader(buf)); err == nil {
|
||||
t.Fatal("没有 Exif item 应该报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTruncatedDoesNotPanic(t *testing.T) {
|
||||
// 截断到各种长度都不该 panic(上传中断、文件损坏在真实世界里都会出现)
|
||||
full := buildHEIC(t, 25.8560, 100.1310, "2026:08:15 15:43:00")
|
||||
for _, n := range []int{1, 5, 12, 20, len(full) / 2, len(full) - 1} {
|
||||
if n <= len(full) {
|
||||
_, _ = Parse(bytes.NewReader(full[:n]))
|
||||
}
|
||||
}
|
||||
jpg := buildJPEG(t, 25.6890, 100.1560, "2026:08:14 12:40:33")
|
||||
for _, n := range []int{1, 4, 10, len(jpg) / 2, len(jpg) - 1} {
|
||||
_, _ = Parse(bytes.NewReader(jpg[:n]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// HEIF/HEIC 容器里的 EXIF 提取。
|
||||
//
|
||||
// HEIC 是 ISO BMFF(和 MP4 同一套盒子结构):文件头是 ftyp,后面跟一个 meta box,
|
||||
// 里面 iinf 描述每个 item(其中有一个 item_type == "Exif"),iloc 给出它的位置。
|
||||
// 取出来的 Exif item 是「4 字节 TIFF 偏移 + 标准 TIFF」,后半段与 JPEG 的 APP1 完全一样,
|
||||
// 所以直接交给 parseTIFF 复用。
|
||||
//
|
||||
// 纯 Go 实现,不引 libheif:内网/离线构建环境下 CGO 依赖太麻烦,而我们只要 EXIF 这一小块。
|
||||
package exif
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var errNotHEIF = errors.New("不是 HEIF/HEIC 容器")
|
||||
|
||||
// boxHeader 读一个 box 的头,返回类型、负载起点与下一个 box 的起点。
|
||||
func boxHeader(b []byte, off int) (typ string, payload, next int, ok bool) {
|
||||
if off < 0 || off+8 > len(b) {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
size := int(binary.BigEndian.Uint32(b[off : off+4]))
|
||||
hdr := 8
|
||||
switch size {
|
||||
case 1: // largesize
|
||||
if off+16 > len(b) {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
size = int(binary.BigEndian.Uint64(b[off+8 : off+16]))
|
||||
hdr = 16
|
||||
case 0: // 一直到文件末尾
|
||||
size = len(b) - off
|
||||
}
|
||||
if size < hdr || off+size > len(b) {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
return string(b[off+4 : off+8]), off + hdr, off + size, true
|
||||
}
|
||||
|
||||
// parseHEIF 从 HEIC/HEIF 里取 EXIF。
|
||||
func parseHEIF(b []byte) (Info, error) {
|
||||
for off := 0; off+8 <= len(b); {
|
||||
typ, payload, next, ok := boxHeader(b, off)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if typ == "meta" {
|
||||
// meta 是 FullBox:负载前面还有 4 字节 version+flags
|
||||
if payload+4 > next {
|
||||
return Info{}, errNotHEIF
|
||||
}
|
||||
return parseMeta(b, payload+4, next)
|
||||
}
|
||||
if next <= off {
|
||||
break
|
||||
}
|
||||
off = next
|
||||
}
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
|
||||
func parseMeta(b []byte, start, end int) (Info, error) {
|
||||
iinfStart, iinfEnd := -1, -1
|
||||
ilocStart, ilocEnd := -1, -1
|
||||
for off := start; off+8 <= end; {
|
||||
typ, payload, next, ok := boxHeader(b, off)
|
||||
if !ok || next > end {
|
||||
break
|
||||
}
|
||||
switch typ {
|
||||
case "iinf":
|
||||
iinfStart, iinfEnd = payload, next
|
||||
case "iloc":
|
||||
ilocStart, ilocEnd = payload, next
|
||||
}
|
||||
off = next
|
||||
}
|
||||
if iinfStart < 0 || ilocStart < 0 {
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
itemID, ok := findExifItem(b, iinfStart, iinfEnd)
|
||||
if !ok {
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
off, length, ok := findItemExtent(b, ilocStart, ilocEnd, itemID)
|
||||
if !ok || off < 0 || length <= 0 || off+length > len(b) {
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
item := b[off : off+length]
|
||||
if len(item) < 4 {
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
// Exif item 开头 4 字节是 TIFF 头相对本 item 起点的偏移
|
||||
tiff := int(binary.BigEndian.Uint32(item[:4]))
|
||||
if tiff < 0 || tiff+8 > len(item) {
|
||||
tiff = 4 // 少数写入方这里填得不准,退化成「紧跟这 4 字节」
|
||||
}
|
||||
return parseTIFF(item[tiff:])
|
||||
}
|
||||
|
||||
// findExifItem 在 iinf 里找 item_type 为 "Exif" 的 item。
|
||||
func findExifItem(b []byte, start, end int) (uint32, bool) {
|
||||
if start+4 > end {
|
||||
return 0, false
|
||||
}
|
||||
ver := b[start]
|
||||
p := start + 4
|
||||
var count int
|
||||
if ver == 0 {
|
||||
if p+2 > end {
|
||||
return 0, false
|
||||
}
|
||||
count = int(binary.BigEndian.Uint16(b[p : p+2]))
|
||||
p += 2
|
||||
} else {
|
||||
if p+4 > end {
|
||||
return 0, false
|
||||
}
|
||||
count = int(binary.BigEndian.Uint32(b[p : p+4]))
|
||||
p += 4
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
typ, payload, next, ok := boxHeader(b, p)
|
||||
if !ok || next > end {
|
||||
break
|
||||
}
|
||||
// infe v2 起:version+flags(4) + item_ID(2) + protection(2) + item_type(4)
|
||||
if typ == "infe" && payload < next && b[payload] >= 2 && payload+12 <= next {
|
||||
if string(b[payload+8:payload+12]) == "Exif" {
|
||||
return uint32(binary.BigEndian.Uint16(b[payload+4 : payload+6])), true
|
||||
}
|
||||
}
|
||||
p = next
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// findItemExtent 在 iloc 里找指定 item 的第一段数据(偏移 + 长度)。
|
||||
func findItemExtent(b []byte, start, end int, wantID uint32) (int, int, bool) {
|
||||
if start+8 > end {
|
||||
return 0, 0, false
|
||||
}
|
||||
ver := b[start]
|
||||
p := start + 4
|
||||
offsetSize := int(b[p] >> 4)
|
||||
lengthSize := int(b[p] & 0x0F)
|
||||
p++
|
||||
baseSize := int(b[p] >> 4)
|
||||
indexSize := 0
|
||||
if ver >= 1 {
|
||||
indexSize = int(b[p] & 0x0F)
|
||||
}
|
||||
p++
|
||||
if offsetSize == 0 || lengthSize == 0 || p+2 > end {
|
||||
return 0, 0, false
|
||||
}
|
||||
count := int(binary.BigEndian.Uint16(b[p : p+2]))
|
||||
p += 2
|
||||
|
||||
// 越过越界就读不动了,返回 false 让上层当作没找到
|
||||
readN := func(n int) (uint64, bool) {
|
||||
if n < 0 || p+n > end {
|
||||
return 0, false
|
||||
}
|
||||
var v uint64
|
||||
for i := 0; i < n; i++ {
|
||||
v = v<<8 | uint64(b[p+i])
|
||||
}
|
||||
p += n
|
||||
return v, true
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
var id uint32
|
||||
if ver == 0 {
|
||||
v, ok := readN(2)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
id = uint32(v)
|
||||
} else {
|
||||
v, ok := readN(4)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
id = uint32(v)
|
||||
}
|
||||
if ver >= 1 {
|
||||
if _, ok := readN(2); !ok { // construction_method + reserved
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
if _, ok := readN(2); !ok { // data_reference_index
|
||||
return 0, 0, false
|
||||
}
|
||||
base, ok := readN(baseSize)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
extentCountV, ok := readN(2)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
for e := 0; e < int(extentCountV); e++ {
|
||||
if ver >= 1 && indexSize > 0 {
|
||||
if _, ok := readN(indexSize); !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
off, ok1 := readN(offsetSize)
|
||||
length, ok2 := readN(lengthSize)
|
||||
if !ok1 || !ok2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if id == wantID {
|
||||
return int(base + off), int(length), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Package fog 解析与生成世界迷雾(Fog of World)的快照数据。
|
||||
//
|
||||
// 快照是一个 zip,其中 Model/*/ 下的每个文件是一块「瓦片」:全球 512x512 块瓦片,
|
||||
// 每块瓦片 128x128 个块,每块是 64x64 的位图,一个位对应全球 2^22 x 2^22 网格里
|
||||
// 约 9.55m*cos(纬度) 宽的一格。网格走网页墨卡托,经纬度即 WGS-84(已实测确认)。
|
||||
// 文件内容 zlib 压缩:头 32768 字节是 16384 个 uint16(LE),值 = 该块在块体里的
|
||||
// 序号(1 基,0 表示无数据);块体每块 512 字节位图 + 3 字节附加(地区码与置位校验)。
|
||||
package fog
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// MapTiles 全球瓦片数(每边)。
|
||||
MapTiles = 512
|
||||
// TileBlocks 每块瓦片的块数(每边)。
|
||||
TileBlocks = 128
|
||||
// BlockCells 每块的格子数(每边)。
|
||||
BlockCells = 64
|
||||
// WorldCells 全球格子数(每边),2^22。
|
||||
WorldCells = MapTiles * TileBlocks * BlockCells
|
||||
|
||||
bitmapSize = BlockCells * BlockCells / 8 // 512
|
||||
extraSize = 3
|
||||
blockSize = bitmapSize + extraSize // 515
|
||||
headerLen = TileBlocks * TileBlocks
|
||||
headerSize = headerLen * 2 // 32768
|
||||
|
||||
mask1 = "olhwjsktri"
|
||||
mask2 = "eizxdwknmo"
|
||||
)
|
||||
|
||||
// Block 是一片 64x64 的已探索位图。GX/GY 为全局块坐标(0..65535),
|
||||
// Bitmap 固定 512 字节,行优先、每行 8 字节、字节内高位在前。
|
||||
type Block struct {
|
||||
GX, GY int
|
||||
Bitmap []byte
|
||||
}
|
||||
|
||||
// CellCount 返回位图里的置位数(已探索格子数)。
|
||||
func (b *Block) CellCount() int {
|
||||
n := 0
|
||||
for _, v := range b.Bitmap {
|
||||
n += int(popcount[v])
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var popcount = func() [256]byte {
|
||||
var t [256]byte
|
||||
for i := range t {
|
||||
t[i] = byte(strings.Count(strconv.FormatUint(uint64(i), 2), "1"))
|
||||
}
|
||||
return t
|
||||
}()
|
||||
|
||||
// tileIDFromName 从文件名还原瓦片编号,并校验 md5 前缀与 M2 尾缀。
|
||||
func tileIDFromName(name string) (int, bool) {
|
||||
if len(name) < 7 || len(name[4:]) < 3 {
|
||||
return 0, false
|
||||
}
|
||||
digits := make([]byte, 0, len(name))
|
||||
for _, c := range name[4 : len(name)-2] {
|
||||
v := strings.IndexRune(mask1, c)
|
||||
if v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
digits = append(digits, byte('0'+v))
|
||||
}
|
||||
id, err := strconv.Atoi(string(digits))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
s := strconv.Itoa(id)
|
||||
if fmt.Sprintf("%x", md5.Sum([]byte(s)))[:4] != name[:4] {
|
||||
return 0, false
|
||||
}
|
||||
tail := name[len(name)-2:]
|
||||
for i := 0; i < 2; i++ {
|
||||
if int(s[len(s)-2+i]-'0') != strings.IndexByte(mask2, tail[i]) {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// tileName 把瓦片编号编码回文件名。
|
||||
func tileName(id int) string {
|
||||
s := strconv.Itoa(id)
|
||||
sum := fmt.Sprintf("%x", md5.Sum([]byte(s)))
|
||||
var b strings.Builder
|
||||
b.WriteString(sum[:4])
|
||||
for i := 0; i < len(s); i++ {
|
||||
b.WriteByte(mask1[s[i]-'0'])
|
||||
}
|
||||
for i := len(s) - 2; i < len(s); i++ {
|
||||
b.WriteByte(mask2[s[i]-'0'])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Decode 读取 .fwss(或任何包含迷雾瓦片文件的 zip),返回去重后的块与格子总数。
|
||||
// 兼容 Model/*/、Sync/、根目录等布局:凡文件名能通过瓦片校验的都算。
|
||||
func Decode(data []byte) ([]Block, int, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("打开迷雾快照: %w", err)
|
||||
}
|
||||
merged := map[[2]int][]byte{}
|
||||
cells := 0
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
id, ok := tileIDFromName(path.Base(f.Name))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
|
||||
}
|
||||
raw, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
|
||||
}
|
||||
zdata, err := zlibDecompress(raw)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("解压 %s: %w", f.Name, err)
|
||||
}
|
||||
if len(zdata) < headerSize {
|
||||
continue
|
||||
}
|
||||
tx, ty := id%MapTiles, id/MapTiles
|
||||
for i := 0; i < headerLen; i++ {
|
||||
idx := int(binary.LittleEndian.Uint16(zdata[2*i:]))
|
||||
if idx == 0 {
|
||||
continue
|
||||
}
|
||||
off := headerSize + (idx-1)*blockSize
|
||||
if off+blockSize > len(zdata) {
|
||||
return nil, 0, fmt.Errorf("%s: 块体越界", f.Name)
|
||||
}
|
||||
bm := make([]byte, bitmapSize)
|
||||
copy(bm, zdata[off:off+bitmapSize])
|
||||
key := [2]int{tx*TileBlocks + i%TileBlocks, ty*TileBlocks + i/TileBlocks}
|
||||
if old, dup := merged[key]; dup {
|
||||
for j := range bm {
|
||||
old[j] |= bm[j]
|
||||
}
|
||||
continue
|
||||
}
|
||||
merged[key] = bm
|
||||
cells += popcountSum(bm)
|
||||
}
|
||||
}
|
||||
blocks := make([]Block, 0, len(merged))
|
||||
for k, bm := range merged {
|
||||
blocks = append(blocks, Block{GX: k[0], GY: k[1], Bitmap: bm})
|
||||
}
|
||||
sortBlocks(blocks)
|
||||
return blocks, cells, nil
|
||||
}
|
||||
|
||||
func popcountSum(bm []byte) int {
|
||||
n := 0
|
||||
for _, v := range bm {
|
||||
n += int(popcount[v])
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func sortBlocks(bs []Block) {
|
||||
for i := 1; i < len(bs); i++ {
|
||||
for j := i; j > 0 && (bs[j-1].GY > bs[j].GY || (bs[j-1].GY == bs[j].GY && bs[j-1].GX > bs[j].GX)); j-- {
|
||||
bs[j-1], bs[j] = bs[j], bs[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func zlibDecompress(raw []byte) ([]byte, error) {
|
||||
zr, err := zlib.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
return io.ReadAll(zr)
|
||||
}
|
||||
|
||||
// Encode 把块集还原成 .fwss 结构(Model/*/ 由块重建),可用于导出与再导入。
|
||||
// layers 是留存的非已探索层原始文件(Model/~ 与 Model/#),原样写回——
|
||||
// 它们不参与渲染,但少了它们 fog of world 那边看到的就是残缺快照。
|
||||
func Encode(blocks []Block, layers map[string][]byte) ([]byte, error) {
|
||||
byTile := map[[2]int][]Block{}
|
||||
for _, b := range blocks {
|
||||
tx, ty := b.GX/TileBlocks, b.GY/TileBlocks
|
||||
byTile[[2]int{tx, ty}] = append(byTile[[2]int{tx, ty}], b)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for t, list := range byTile {
|
||||
sortByLinear(list)
|
||||
header := make([]byte, headerSize)
|
||||
body := make([]byte, 0, len(list)*blockSize)
|
||||
for k, b := range list {
|
||||
i := (b.GY%TileBlocks)*TileBlocks + b.GX%TileBlocks
|
||||
binary.LittleEndian.PutUint16(header[2*i:], uint16(k+1))
|
||||
body = append(body, b.Bitmap...)
|
||||
body = append(body, extraBytes(b)...)
|
||||
}
|
||||
var raw bytes.Buffer
|
||||
zc := zlib.NewWriter(&raw)
|
||||
if _, err := zc.Write(header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := zc.Write(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zc.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := t[0] + t[1]*MapTiles
|
||||
name := "Model/*/" + tileName(id)
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write(raw.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 其它层直接写原始字节,不做二次压缩,免得多一层格式风险
|
||||
for p, data := range layers {
|
||||
w, err := zw.Create(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func sortByLinear(list []Block) {
|
||||
lin := func(b Block) int { return (b.GY%TileBlocks)*TileBlocks + b.GX%TileBlocks }
|
||||
for i := 1; i < len(list); i++ {
|
||||
for j := i; j > 0 && lin(list[j-1]) > lin(list[j]); j-- {
|
||||
list[j-1], list[j] = list[j], list[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extraBytes 生成块尾那 3 字节。真机存的是一个**大端 24 位数**,值为 置位数*2+1:
|
||||
// 实测真机包 8701/8701 个块精确满足(如 118 格 -> 0x0000ED = 237 = 118*2+1)。
|
||||
// 一个 64x64 块最多 4096 格,该值 <= 0x2001,所以最高字节在数学上恒为 0。
|
||||
// (旧实现写的是 0x10 / 0x80|…,纯属臆造,会让导出包里每个块的元信息都与真机对不上。)
|
||||
func extraBytes(b Block) []byte {
|
||||
sum := b.CellCount()*2 + 1
|
||||
return []byte{0x00, byte(sum >> 8), byte(sum & 0xFF)}
|
||||
}
|
||||
|
||||
// CellSideM 是格子在赤道的边长(米)。宽随纬度 cos 收缩,高恒定(网页墨卡托)。
|
||||
const CellSideM = 9.55462861
|
||||
|
||||
// CellOf 把 WGS-84 经纬度投到全球格坐标(网页墨卡托,2^22 网格)。
|
||||
func CellOf(lat, lon float64) (cx, cy int) {
|
||||
cx = int((lon + 180.0) / 360.0 * WorldCells)
|
||||
rad := lat * math.Pi / 180
|
||||
cy = int((math.Pi - math.Asinh(math.Tan(rad))) / (2 * math.Pi) * WorldCells)
|
||||
return
|
||||
}
|
||||
|
||||
// AreaKm2 估算块集覆盖的实地面积:每格面积按块中心纬度做 cos 修正。
|
||||
func AreaKm2(blocks []Block) float64 {
|
||||
var m2 float64
|
||||
for _, b := range blocks {
|
||||
m2 += float64(b.CellCount()) * cellAreaM2(b.GX, b.GY)
|
||||
}
|
||||
return m2 / 1e6
|
||||
}
|
||||
|
||||
// cellAreaM2 是某块内单格的面积(m²):一格宽 CellSideM、随纬度乘 cos 收缩,高恒定。
|
||||
func cellAreaM2(gx, gy int) float64 {
|
||||
_, lat := cellLonLat(gx*BlockCells+BlockCells/2, gy*BlockCells+BlockCells/2)
|
||||
return CellSideM * math.Cos(lat*math.Pi/180) * CellSideM
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNativeSnapshotRoundtrip 守住「真机快照解码再编码后内容不变」。
|
||||
// 需要环境变量 FOG_SRC 指向真机导出的 .fwss;未设置时跳过。
|
||||
//
|
||||
// 重点守三件事:
|
||||
// 1. Model/* 的每块逐字节一致(512 字节位图 + 尾 3 字节元信息)。尾 3 字节曾按臆造的
|
||||
// 0x10/0x80 生成,实测 8701/8701 个块与真机全不符——它其实是一个大端 24 位数,
|
||||
// 值为置位数*2+1;
|
||||
// 2. Model/* 的条目集合与文件名不变(名字是 md5 前缀 + 掩码数字,重建时必须能原样还原);
|
||||
// 3. Model/~ 与 Model/# 两层原样带出,条目名与内容一个不差——少了它们快照就残缺 55%。
|
||||
//
|
||||
// 比对按「格」而不是按块体槽位:槽位序号只是头里的指针,顺序允许不同,指向同一张位图即可。
|
||||
func TestNativeSnapshotRoundtrip(t *testing.T) {
|
||||
src := os.Getenv("FOG_SRC")
|
||||
if src == "" {
|
||||
t.Skip("设置 FOG_SRC 后运行迷雾快照往返检查")
|
||||
}
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s: %v", src, err)
|
||||
}
|
||||
|
||||
blocks, cells, err := Decode(data)
|
||||
if err != nil {
|
||||
t.Fatalf("解码: %v", err)
|
||||
}
|
||||
layers, err := RawLayers(data)
|
||||
if err != nil {
|
||||
t.Fatalf("读取其它层: %v", err)
|
||||
}
|
||||
out, err := Encode(blocks, layers)
|
||||
if err != nil {
|
||||
t.Fatalf("编码: %v", err)
|
||||
}
|
||||
|
||||
orig, err := unzipAll(data)
|
||||
if err != nil {
|
||||
t.Fatalf("解开原始包: %v", err)
|
||||
}
|
||||
got, err := unzipAll(out)
|
||||
if err != nil {
|
||||
t.Fatalf("解开导出包: %v", err)
|
||||
}
|
||||
|
||||
if len(orig) != len(got) {
|
||||
t.Errorf("条目数不一致: 原始 %d vs 导出 %d", len(orig), len(got))
|
||||
}
|
||||
for name := range orig {
|
||||
if _, ok := got[name]; !ok {
|
||||
t.Errorf("导出包缺少条目 %s", name)
|
||||
}
|
||||
}
|
||||
for name := range got {
|
||||
if _, ok := orig[name]; !ok {
|
||||
t.Errorf("导出包多出条目 %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
checked := 0
|
||||
for name, a := range orig {
|
||||
if !isModelStar(name) {
|
||||
continue
|
||||
}
|
||||
b, ok := got[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pa, err := zlibDecompress(a)
|
||||
if err != nil {
|
||||
t.Fatalf("%s 原始载荷解压失败: %v", name, err)
|
||||
}
|
||||
pb, err := zlibDecompress(b)
|
||||
if err != nil {
|
||||
t.Fatalf("%s 导出载荷解压失败: %v", name, err)
|
||||
}
|
||||
if len(pa) != len(pb) {
|
||||
t.Errorf("%s: 载荷长度 %d vs %d", name, len(pa), len(pb))
|
||||
continue
|
||||
}
|
||||
for cell := 0; cell < headerLen; cell++ {
|
||||
ba, bb := slotAt(pa, cell), slotAt(pb, cell)
|
||||
if (ba == nil) != (bb == nil) {
|
||||
t.Errorf("%s: 格 %d 一边有块一边没有", name, cell)
|
||||
continue
|
||||
}
|
||||
if ba == nil {
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(ba[:bitmapSize], bb[:bitmapSize]) {
|
||||
t.Errorf("%s: 格 %d 的块位图不一致", name, cell)
|
||||
}
|
||||
if !bytes.Equal(ba[bitmapSize:], bb[bitmapSize:]) {
|
||||
t.Errorf("%s: 格 %d 的块元信息不一致: 原始 %x vs 导出 %x(置位数 %d)",
|
||||
name, cell, ba[bitmapSize:], bb[bitmapSize:], popcountSum(ba[:bitmapSize]))
|
||||
}
|
||||
checked++
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Error("没有比对到任何块,样本可能不对")
|
||||
}
|
||||
|
||||
// 其它层:条目内容(zlib 流本身)逐字节一致
|
||||
for name, v := range layers {
|
||||
if !bytes.Equal(got[name], v) {
|
||||
t.Errorf("其它层 %s 未原样带出", name)
|
||||
}
|
||||
}
|
||||
t.Logf("已核对 %d 个块、%d 个其它层条目(%d 格)", checked, len(layers), cells)
|
||||
}
|
||||
|
||||
// TestExtraBytesEncoding 钉住块尾 3 字节的编码:大端 24 位数,值 = 置位数*2+1,最高字节恒为 0。
|
||||
func TestExtraBytesEncoding(t *testing.T) {
|
||||
bm := make([]byte, bitmapSize)
|
||||
for _, c := range []struct{ bit, cells int }{{0, 1}, {7, 2}, {8, 3}, {511, 4}} {
|
||||
bm[c.bit/8] |= 0x80 >> (c.bit % 8)
|
||||
if got := extraBytes(Block{Bitmap: bm}); got[0] != 0 || int(got[1])<<8|int(got[2]) != c.cells*2+1 {
|
||||
t.Errorf("置位数 %d 时得到 %x", c.cells, got)
|
||||
}
|
||||
}
|
||||
// 满块 4096 格:值 8193 = 0x2001,最高字节仍应为 0
|
||||
full := make([]byte, bitmapSize)
|
||||
for i := range full {
|
||||
full[i] = 0xFF
|
||||
}
|
||||
if got := extraBytes(Block{Bitmap: full}); !bytes.Equal(got, []byte{0x00, 0x20, 0x01}) {
|
||||
t.Errorf("满块应为 002001,实际 %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func isModelStar(name string) bool {
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
rest := strings.TrimPrefix(name, "Model/")
|
||||
return rest != name && strings.HasPrefix(rest, "*/")
|
||||
}
|
||||
|
||||
// unzipAll 读出包内全部条目的内容——那是 zlib 流本身,要 zlibDecompress 才是载荷。
|
||||
func unzipAll(data []byte) (map[string][]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]byte{}
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[path.Clean(strings.ReplaceAll(f.Name, "\\", "/"))] = b
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// slotAt 取出某个格子所指向的块(头里存的是 1 基槽位序号,0 表示该格没有数据)。
|
||||
func slotAt(payload []byte, cell int) []byte {
|
||||
idx := int(binary.LittleEndian.Uint16(payload[2*cell:]))
|
||||
if idx == 0 {
|
||||
return nil
|
||||
}
|
||||
off := headerSize + (idx-1)*blockSize
|
||||
if off+blockSize > len(payload) {
|
||||
return nil
|
||||
}
|
||||
return payload[off : off+blockSize]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// layerMaxBytes 是单个留存条目的解压上限(实测最大约 250KB)。
|
||||
const layerMaxBytes = 8 << 20
|
||||
|
||||
// RawLayers 取出快照里「不属于已探索层」的条目(内容原样,条目名也原样)。
|
||||
//
|
||||
// fog of world 的快照里有三类瓦片:
|
||||
//
|
||||
// Model/* 已探索层:128×128 块,每块 512 字节位图 + 3 字节元信息(本包解析的就是它)
|
||||
// Model/~ 另一层位图:每块 512 字节,没有那 3 字节
|
||||
// Model/# 只有元信息:每块 3 字节
|
||||
//
|
||||
// 后两类我们不解析(站点只用已探索层来渲染),但**必须原样留住**——
|
||||
// 否则重新导出的快照会丢掉这 55% 的内容,fog of world 那边看到的就是残缺数据。
|
||||
//
|
||||
// 留存范围是「快照里的全部条目,除去已探索层与被 Decode 解析掉的那些瓦片」:
|
||||
// Decode 认的是「文件名能通过瓦片校验」,不认目录,所以判断也要按文件名来,
|
||||
// 否则同一个文件会被解析一遍、又原样写一遍,包里出现两份。
|
||||
func RawLayers(data []byte) (map[string][]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]byte{}
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
p := path.Clean(strings.ReplaceAll(f.Name, "\\", "/"))
|
||||
// Model/* 之下就是已探索层,由 fog_blocks 重建
|
||||
if rest := strings.TrimPrefix(p, "Model/"); rest != p {
|
||||
if i := strings.Index(rest, "/"); i > 0 && rest[:i] == "*" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 文件名能通过瓦片校验的会被 Decode 解析成块,写回时也走 Model/*,这里不重复留存
|
||||
if _, ok := tileIDFromName(path.Base(p)); ok {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 留存的条目不解析、也不该被截断:超限就报错,别把半份数据悄悄带进导出包
|
||||
raw, err := io.ReadAll(io.LimitReader(rc, layerMaxBytes+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > layerMaxBytes {
|
||||
return nil, fmt.Errorf("条目 %s 超过 %d 字节,疑似异常快照", p, layerMaxBytes)
|
||||
}
|
||||
out[p] = raw
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
)
|
||||
|
||||
// Mask 是一块「对访客隐藏」的圆形区域:中心(**GCJ-02**,与 places 同系)+ 半径(米)。
|
||||
//
|
||||
// 迷雾网格是 WGS-84、底图可能是 GCJ-02,所以判定前必须把中心换算到网格坐标,
|
||||
// 与渲染瓦片时对 GCJ 底图做的纠偏是同一套变换——不然遮罩会整体偏出几百米。
|
||||
type Mask struct {
|
||||
ID int64
|
||||
Name string
|
||||
Lat float64
|
||||
Lon float64
|
||||
RadiusM float64
|
||||
}
|
||||
|
||||
// maskGrid 是遮罩在基网格里的预计算形式。
|
||||
//
|
||||
// 网格的纵向边长恒为 CellSideM、横向边长随纬度乘 cos(φ) 收缩,
|
||||
// 所以「实地上的圆」在网格里是**椭圆**(横向更宽)。半径换算错这一步,
|
||||
// 高纬度地区会明显偏窄。
|
||||
type maskGrid struct {
|
||||
m Mask
|
||||
gx, gy float64 // 圆心(格坐标,浮点)
|
||||
rx, ry float64 // 横向 / 纵向半径(格)
|
||||
x0, x1 int // 包围盒(格),用于快速排除
|
||||
y0, y1 int
|
||||
}
|
||||
|
||||
func newMaskGrid(m Mask) maskGrid {
|
||||
wlat, wlon := geo.GCJ02ToWGS84(m.Lat, m.Lon)
|
||||
gx, gy := mercX(wlon), mercY(wlat)
|
||||
cos := math.Cos(wlat * math.Pi / 180)
|
||||
if cos < 1e-6 {
|
||||
cos = 1e-6
|
||||
}
|
||||
ry := m.RadiusM / CellSideM
|
||||
rx := ry / cos
|
||||
g := maskGrid{m: m, gx: gx, gy: gy, rx: rx, ry: ry}
|
||||
g.x0 = int(math.Floor(gx-rx)) - 1
|
||||
g.x1 = int(math.Ceil(gx+rx)) + 1
|
||||
g.y0 = int(math.Floor(gy-ry)) - 1
|
||||
g.y1 = int(math.Ceil(gy+ry)) + 1
|
||||
return g
|
||||
}
|
||||
|
||||
func (g maskGrid) hitCell(cx, cy int) bool {
|
||||
if cx < g.x0 || cx > g.x1 || cy < g.y0 || cy > g.y1 {
|
||||
return false
|
||||
}
|
||||
dx := (float64(cx) + 0.5 - g.gx) / g.rx
|
||||
dy := (float64(cy) + 0.5 - g.gy) / g.ry
|
||||
return dx*dx+dy*dy <= 1
|
||||
}
|
||||
|
||||
// MaskSet 是一组遮罩的判定集合。零值/nil 表示不遮任何东西。
|
||||
type MaskSet struct {
|
||||
grids []maskGrid
|
||||
}
|
||||
|
||||
// NewMaskSet 预计算一组遮罩;传空切片会返回 nil。
|
||||
func NewMaskSet(masks []Mask) *MaskSet {
|
||||
if len(masks) == 0 {
|
||||
return nil
|
||||
}
|
||||
s := &MaskSet{grids: make([]maskGrid, 0, len(masks))}
|
||||
for _, m := range masks {
|
||||
if m.RadiusM <= 0 {
|
||||
continue
|
||||
}
|
||||
s.grids = append(s.grids, newMaskGrid(m))
|
||||
}
|
||||
if len(s.grids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Empty 报告集合里有没有遮罩(nil 安全)。
|
||||
func (s *MaskSet) Empty() bool { return s == nil || len(s.grids) == 0 }
|
||||
|
||||
// Hit 报告某个全球格是否被遮罩盖住。nil 安全,未命中时只花一次包围盒比较。
|
||||
func (s *MaskSet) Hit(cx, cy int) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
for _, g := range s.grids {
|
||||
if g.hitCell(cx, cy) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HitLatLon 按经纬度判定(同样传 GCJ-02),用于地点级的过滤。
|
||||
func (s *MaskSet) HitLatLon(lat, lon float64) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
wlat, wlon := geo.GCJ02ToWGS84(lat, lon)
|
||||
return s.Hit(CellOf(wlat, wlon))
|
||||
}
|
||||
|
||||
// maskedStats 统计被遮罩盖住的置位格数与面积(m²),用于从「已探索」的总量里扣掉。
|
||||
//
|
||||
// 三个要点:
|
||||
// - 逐格判一次 masks.Hit,而不是按遮罩逐个累加:遮罩可以互相重叠,按遮罩累加会把
|
||||
// 重叠区域的格子算好几遍(格数扣多,和面积也会对不上);
|
||||
// - 只遍历遮罩包围盒覆盖到的块(块坐标去重),不扫全库;
|
||||
// - 面积用 cellAreaM2,与 AreaKm2 同一套算法。
|
||||
func maskedStats(blocks map[[2]int][]byte, masks *MaskSet) (cells int, areaM2 float64) {
|
||||
if masks.Empty() {
|
||||
return 0, 0
|
||||
}
|
||||
seen := make(map[[2]int]bool)
|
||||
for _, g := range masks.grids {
|
||||
for bx := g.x0 / BlockCells; bx <= g.x1/BlockCells; bx++ {
|
||||
for by := g.y0 / BlockCells; by <= g.y1/BlockCells; by++ {
|
||||
key := [2]int{bx, by}
|
||||
if seen[key] {
|
||||
continue // 两块遮罩可能盖到同一块,不能算两遍
|
||||
}
|
||||
seen[key] = true
|
||||
bm, ok := blocks[key]
|
||||
if !ok || len(bm) != bitmapSize {
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
for row := 0; row < BlockCells; row++ {
|
||||
line := bm[row*8 : row*8+8]
|
||||
for colB, v := range line {
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
for bit := 0; bit < 8; bit++ {
|
||||
if v&(0x80>>bit) == 0 {
|
||||
continue
|
||||
}
|
||||
if masks.Hit(bx*BlockCells+colB*8+bit, by*BlockCells+row) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
cells += n
|
||||
areaM2 += float64(n) * cellAreaM2(bx, by)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells, areaM2
|
||||
}
|
||||
|
||||
// MaskedStats 在读锁内统计被遮罩盖住的部分。调用方本来拿不到块表(它不导出),
|
||||
// 而走 All() 会为一次统计复制并排序整张块表(上万块),所以这里直接在索引上算。
|
||||
func (ix *Index) MaskedStats(masks *MaskSet) (cells int, areaM2 float64) {
|
||||
if masks.Empty() {
|
||||
return 0, 0
|
||||
}
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
return maskedStats(ix.blocks, masks)
|
||||
}
|
||||
|
||||
// MaskFingerprint 返回一组遮罩的短指纹,供前端把它拼进迷雾瓦片的 URL。
|
||||
//
|
||||
// 遮罩不在 fogIndex 里(每次请求都实时读库),所以服务端改完立刻生效;
|
||||
// 但访客的瓦片是允许公共缓存的,URL 不变的话浏览器 / CDN 会继续拿缓存里那份
|
||||
// (最长 1 小时)——设完遮罩看不到变化,很容易被当成「遮罩不生效」。
|
||||
// 指纹变了 URL 就变,缓存自然失效。
|
||||
func MaskFingerprint(masks []Mask) string {
|
||||
if len(masks) == 0 {
|
||||
return "0"
|
||||
}
|
||||
h := fnv.New64a()
|
||||
for _, m := range masks {
|
||||
fmt.Fprintf(h, "%d|%.6f|%.6f|%.1f|", m.ID, m.Lat, m.Lon, m.RadiusM)
|
||||
}
|
||||
return strconv.FormatUint(h.Sum64(), 16)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// allOnesBlock 造一块位图全置位的块(每格都已探索)。
|
||||
func allOnesBlock(gx, gy int) Block {
|
||||
bm := make([]byte, BlockCells*BlockCells/8)
|
||||
for i := range bm {
|
||||
bm[i] = 0xFF
|
||||
}
|
||||
return Block{GX: gx, GY: gy, Bitmap: bm}
|
||||
}
|
||||
|
||||
// blocksAround 造出覆盖这些遮罩包围盒的全部块。
|
||||
func blocksAround(masks ...Mask) []Block {
|
||||
var out []Block
|
||||
seen := map[[2]int]bool{}
|
||||
for _, m := range masks {
|
||||
g := newMaskGrid(m)
|
||||
for bx := g.x0 / BlockCells; bx <= g.x1/BlockCells; bx++ {
|
||||
for by := g.y0 / BlockCells; by <= g.y1/BlockCells; by++ {
|
||||
if seen[[2]int{bx, by}] {
|
||||
continue
|
||||
}
|
||||
seen[[2]int{bx, by}] = true
|
||||
out = append(out, allOnesBlock(bx, by))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMaskedCellsUnion 遮罩重叠时,被遮住的格子只能算一次。
|
||||
// 曾经的实现是「按遮罩逐个累加命中格数」,重叠区域会被统计两三遍,
|
||||
// 于是覆盖率里扣掉的格数偏大、与面积(另一套逐格算法)对不上。
|
||||
func TestMaskedCellsUnion(t *testing.T) {
|
||||
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
|
||||
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 2000}
|
||||
|
||||
onlyA, onlyB := NewMaskSet([]Mask{a}), NewMaskSet([]Mask{b})
|
||||
both := NewMaskSet([]Mask{a, b})
|
||||
if onlyA == nil || onlyB == nil || both == nil {
|
||||
t.Fatal("遮罩集合不应为空")
|
||||
}
|
||||
|
||||
blocks := blocksAround(a, b)
|
||||
var want int
|
||||
for _, blk := range blocks {
|
||||
for row := 0; row < BlockCells; row++ {
|
||||
for col := 0; col < BlockCells; col++ {
|
||||
if both.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
|
||||
want++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用例前提:两块遮罩真的有重叠,否则测不出重复计数
|
||||
if sum := countMasked(blocks, onlyA) + countMasked(blocks, onlyB); sum <= want {
|
||||
t.Fatalf("两块遮罩没有重叠(单独 %d + %d,并集 %d),用例无效", sum, want, want)
|
||||
}
|
||||
|
||||
if got := countMasked(blocks, both); got != want {
|
||||
t.Errorf("重叠遮罩下 MaskedCells = %d,应为并集 %d(偏大即重复计数)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaskedAreaMatchesCells 面积与格数必须出自同一个集合:
|
||||
// 扣掉的面积换算成格数后要与统计出的格数相等。
|
||||
func TestMaskedAreaMatchesCells(t *testing.T) {
|
||||
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
|
||||
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 2000}
|
||||
set := NewMaskSet([]Mask{a, b})
|
||||
blocks := blocksAround(a, b)
|
||||
|
||||
// 逐块按块中心纬度算格子面积(与 AreaKm2Excluding 内同一套算法)
|
||||
var lostM2 float64
|
||||
for _, blk := range blocks {
|
||||
_, lat := cellLonLat(blk.GX*BlockCells+BlockCells/2, blk.GY*BlockCells+BlockCells/2)
|
||||
cell := CellSideM * math.Cos(lat*math.Pi/180) * CellSideM
|
||||
n := 0
|
||||
for row := 0; row < BlockCells; row++ {
|
||||
for col := 0; col < BlockCells; col++ {
|
||||
if set.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
lostM2 += float64(n) * cell
|
||||
}
|
||||
|
||||
byKey := map[[2]int][]byte{}
|
||||
for _, blk := range blocks {
|
||||
byKey[[2]int{blk.GX, blk.GY}] = blk.Bitmap
|
||||
}
|
||||
gotCells, gotM2 := maskedStats(byKey, set)
|
||||
if gotCells != countMasked(blocks, set) {
|
||||
t.Errorf("遮罩格数 %d,暴力统计 %d", gotCells, countMasked(blocks, set))
|
||||
}
|
||||
if diff := math.Abs(gotM2 - lostM2); diff > 1e-9 {
|
||||
t.Errorf("遮罩面积 %.6f m²,按格数算应为 %.6f m²(差 %.3f)", gotM2, lostM2, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// countMasked 暴力数一遍被遮住的格子(测试基准)。
|
||||
func countMasked(blocks []Block, set *MaskSet) int {
|
||||
n := 0
|
||||
for _, blk := range blocks {
|
||||
for row := 0; row < BlockCells; row++ {
|
||||
for col := 0; col < BlockCells; col++ {
|
||||
if set.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestMaskFingerprint 指纹要能反映遮罩的任何改动,否则访客的瓦片缓存不会失效。
|
||||
func TestMaskFingerprint(t *testing.T) {
|
||||
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
|
||||
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 1500}
|
||||
|
||||
if MaskFingerprint(nil) != "0" {
|
||||
t.Error("空遮罩的指纹应为 0")
|
||||
}
|
||||
base := MaskFingerprint([]Mask{a})
|
||||
cases := map[string][]Mask{
|
||||
"多了一块": {a, b},
|
||||
"半径改了": {{ID: 1, Lat: a.Lat, Lon: a.Lon, RadiusM: 3000}},
|
||||
"位置挪了": {{ID: 1, Lat: a.Lat + 0.001, Lon: a.Lon, RadiusM: a.RadiusM}},
|
||||
"换成另一块": {b},
|
||||
}
|
||||
for name, list := range cases {
|
||||
if MaskFingerprint(list) == base {
|
||||
t.Errorf("%s 之后指纹没变,访客缓存不会失效", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
)
|
||||
|
||||
// 迷雾覆盖层配色:填充用偏亮的蓝,边界再描一圈更深的轮廓,
|
||||
// 这样在浅色底图上也能一眼看出「已揭开」的范围。
|
||||
var (
|
||||
FogColor = [4]byte{48, 122, 224, 132}
|
||||
FogEdge = [4]byte{16, 52, 132, 232}
|
||||
)
|
||||
|
||||
// RenderTile 渲染一张 256x256 的迷雾透明瓦片。
|
||||
//
|
||||
// z/x/y 为 slippy 瓦片坐标。crs 指明底图坐标系:
|
||||
// 迷雾网格是 WGS-84,而高德这类底图把 GCJ-02 坐标画在自己的名义位置上,
|
||||
// 所以 gcj02 底图下每个块要先做 WGS84→GCJ02 正变换再投影;
|
||||
// 块只有 600m 见方,偏移场在块内变化不足 1px,按块中心算一次即可。
|
||||
// wgs84 底图(天地图/自备)则不偏移。
|
||||
// masks 是被遮罩盖住、对访客不显示的区域(nil = 全部显示)。被遮住的格子既不画填充,
|
||||
// 也不算作「未探索」的邻居——否则遮罩边缘会被描出一圈轮廓,等于把隐藏范围本身暴露了。
|
||||
func RenderTile(blocks map[[2]int][]byte, z, x, y int, crs string, masks *MaskSet) *image.NRGBA {
|
||||
if z < 0 || z > 22 {
|
||||
return nil
|
||||
}
|
||||
step := WorldCells >> z // 该瓦片每边覆盖的格子数
|
||||
scale := 256.0 / float64(step)
|
||||
ox, oy := float64(x*step), float64(y*step)
|
||||
// 第一遍只画掩码,第二遍再按邻域决定填充色还是轮廓色:
|
||||
// 只有紧邻「未探索」的像素才描边,已探索区域内部保持平涂。
|
||||
var mask [256 * 256]bool
|
||||
// hole 记录「因为遮罩而不画」的像素,第二遍描边时把它当作正常像素跳过
|
||||
var hole []bool
|
||||
if !masks.Empty() {
|
||||
hole = make([]bool, 256*256)
|
||||
}
|
||||
mark := func(px, py int) {
|
||||
if px < 0 || px > 255 || py < 0 || py > 255 {
|
||||
return
|
||||
}
|
||||
mask[py*256+px] = true
|
||||
}
|
||||
fill := func(px, py, w, h int) {
|
||||
for r := 0; r < h; r++ {
|
||||
for c := 0; c < w; c++ {
|
||||
mark(px+c, py+r)
|
||||
}
|
||||
}
|
||||
}
|
||||
// markHole 把一段像素标成「被遮罩隐去」
|
||||
markHole := func(px, py, w, h int) {
|
||||
if hole == nil {
|
||||
return
|
||||
}
|
||||
for r := 0; r < h; r++ {
|
||||
for c := 0; c < w; c++ {
|
||||
x, y := px+c, py+r
|
||||
if x >= 0 && x <= 255 && y >= 0 && y <= 255 {
|
||||
hole[y*256+x] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接遍历块表而不是全球块范围:低缩放级下范围循环会膨胀到数十亿次空查询。
|
||||
// GCJ 偏移最多约 700m(74 格),粗裁剪时两侧各放宽两块保证不漏。
|
||||
const margin = 2 * BlockCells
|
||||
lo, hi := float64(x*step-margin), float64((x+1)*step+margin)
|
||||
rlo, rhi := float64(y*step-margin), float64((y+1)*step+margin)
|
||||
shift := crs == "gcj02"
|
||||
|
||||
for key, bm := range blocks {
|
||||
gbx, gby := key[0], key[1]
|
||||
bx, by := float64(gbx*BlockCells), float64(gby*BlockCells)
|
||||
if bx+BlockCells <= lo || bx >= hi || by+BlockCells <= rlo || by >= rhi {
|
||||
continue
|
||||
}
|
||||
ddx, ddy := 0.0, 0.0
|
||||
if shift {
|
||||
ddx, ddy = blockShift(gbx, gby)
|
||||
}
|
||||
u0 := (bx + ddx - ox) * scale
|
||||
v0 := (by + ddy - oy) * scale
|
||||
|
||||
if scale >= 1 {
|
||||
w := int(math.Ceil(scale))
|
||||
for row := 0; row < BlockCells; row++ {
|
||||
line := bm[row*8 : row*8+8]
|
||||
if line[0]|line[1]|line[2]|line[3]|line[4]|line[5]|line[6]|line[7] == 0 {
|
||||
continue
|
||||
}
|
||||
py := int(math.Floor(v0 + float64(row)*scale))
|
||||
for colB, byteVal := range line {
|
||||
if byteVal == 0 {
|
||||
continue
|
||||
}
|
||||
for bit := 0; bit < 8; bit++ {
|
||||
if byteVal&(0x80>>bit) == 0 {
|
||||
continue
|
||||
}
|
||||
px := int(math.Floor(u0 + float64(colB*8+bit)*scale))
|
||||
if masks.Hit(gbx*BlockCells+colB*8+bit, gby*BlockCells+row) {
|
||||
markHole(px, py, w, w)
|
||||
continue
|
||||
}
|
||||
fill(px, py, w, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 格子比像素还小(低缩放级):整块只要有探索就整块涂上。
|
||||
// 这时按块中心判遮罩——缩到这种级别,块本身还不到一个像素。
|
||||
if popcountSum(bm) == 0 {
|
||||
continue
|
||||
}
|
||||
if masks.Hit(gbx*BlockCells+BlockCells/2, gby*BlockCells+BlockCells/2) {
|
||||
continue
|
||||
}
|
||||
u1 := (bx + BlockCells + ddx - ox) * scale
|
||||
v1 := (by + BlockCells + ddy - oy) * scale
|
||||
px0, py0 := int(math.Floor(u0)), int(math.Floor(v0))
|
||||
px1, py1 := int(math.Ceil(u1)), int(math.Ceil(v1))
|
||||
if px1-px0 < 1 {
|
||||
px1 = px0 + 1
|
||||
}
|
||||
if py1-py0 < 1 {
|
||||
py1 = py0 + 1
|
||||
}
|
||||
fill(px0, py0, px1-px0, py1-py0)
|
||||
}
|
||||
|
||||
// 第二遍着色。瓦片外的邻格按「已探索」处理,避免在瓦片接缝处
|
||||
// 凭空描出一条网格线(真实边界由邻瓦片自己画)。
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 256, 256))
|
||||
for py := 0; py < 256; py++ {
|
||||
for px := 0; px < 256; px++ {
|
||||
if !mask[py*256+px] {
|
||||
continue
|
||||
}
|
||||
// 已探索的邻居:被遮罩隐去的也算「不算未探索」,免得把隐藏范围描出轮廓
|
||||
lit := func(i int) bool { return mask[i] || (hole != nil && hole[i]) }
|
||||
edge := false
|
||||
if px > 0 && !lit(py*256+px-1) {
|
||||
edge = true
|
||||
} else if px < 255 && !lit(py*256+px+1) {
|
||||
edge = true
|
||||
} else if py > 0 && !lit((py-1)*256+px) {
|
||||
edge = true
|
||||
} else if py < 255 && !lit((py+1)*256+px) {
|
||||
edge = true
|
||||
}
|
||||
c := FogColor
|
||||
if edge {
|
||||
c = FogEdge
|
||||
}
|
||||
i := (py*256 + px) * 4
|
||||
img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = c[0], c[1], c[2], c[3]
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// blockShift 返回该块中心在 GCJ-02 底图下的像素偏移(单位:基网格格子)。
|
||||
func blockShift(gbx, gby int) (float64, float64) {
|
||||
lon, lat := cellLonLat(gbx*BlockCells+BlockCells/2, gby*BlockCells+BlockCells/2)
|
||||
glat, glon := geo.WGS84ToGCJ02(lat, lon)
|
||||
return mercX(glon) - mercX(lon), mercY(glat) - mercY(lat)
|
||||
}
|
||||
|
||||
func mercX(lon float64) float64 { return (lon + 180.0) / 360.0 * WorldCells }
|
||||
|
||||
func mercY(lat float64) float64 {
|
||||
rad := lat * math.Pi / 180
|
||||
return (math.Pi - math.Asinh(math.Tan(rad))) / (2 * math.Pi) * WorldCells
|
||||
}
|
||||
|
||||
func cellLonLat(cx, cy int) (float64, float64) {
|
||||
lon := (float64(cx)+0.5)/WorldCells*360.0 - 180.0
|
||||
n := 2 * math.Pi * (float64(cy) + 0.5) / WorldCells
|
||||
lat := math.Atan(math.Sinh(math.Pi-n)) * 180.0 / math.Pi
|
||||
return lon, lat
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Index 是某个用户迷雾位图的内存索引,按块粒度持有,
|
||||
// 供瓦片渲染做范围查询;写路径先落库再刷新内存。
|
||||
type Index struct {
|
||||
mu sync.RWMutex
|
||||
blocks map[[2]int][]byte
|
||||
cells int
|
||||
// areaM2 是全部置位格的实地面积(m²),写入索引时一并算好。
|
||||
// 覆盖率统计每次都要这个数,现算得把上万块的位图全扫一遍。
|
||||
areaM2 float64
|
||||
ver int64 // 最近一次更新的 unix 秒,用作瓦片缓存版本号
|
||||
}
|
||||
|
||||
// NewIndex 建一个空索引。
|
||||
func NewIndex() *Index {
|
||||
return &Index{blocks: map[[2]int][]byte{}}
|
||||
}
|
||||
|
||||
// Load 从库里加载某用户的全部迷雾块。
|
||||
func (ix *Index) Load(db *sql.DB, userID int64) error {
|
||||
rows, err := db.Query(`SELECT gbx, gby, bitmap FROM fog_blocks WHERE user_id=$1`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
blocks := map[[2]int][]byte{}
|
||||
cells, areaM2 := 0, 0.0
|
||||
for rows.Next() {
|
||||
var gx, gy int
|
||||
var bm []byte
|
||||
if err := rows.Scan(&gx, &gy, &bm); err != nil {
|
||||
return err
|
||||
}
|
||||
blocks[[2]int{gx, gy}] = bm
|
||||
n := popcountSum(bm)
|
||||
cells += n
|
||||
areaM2 += float64(n) * cellAreaM2(gx, gy)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
ix.mu.Lock()
|
||||
ix.blocks = blocks
|
||||
ix.cells = cells
|
||||
ix.areaM2 = areaM2
|
||||
ix.ver = time.Now().Unix()
|
||||
ix.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAll 从库里加载全部迷雾块(站点为单用户展示,公开读路径不区分归属)。
|
||||
func (ix *Index) LoadAll(db *sql.DB) error {
|
||||
rows, err := db.Query(`SELECT gbx, gby, bitmap FROM fog_blocks`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
blocks := map[[2]int][]byte{}
|
||||
cells, areaM2 := 0, 0.0
|
||||
for rows.Next() {
|
||||
var gx, gy int
|
||||
var bm []byte
|
||||
if err := rows.Scan(&gx, &gy, &bm); err != nil {
|
||||
return err
|
||||
}
|
||||
blocks[[2]int{gx, gy}] = bm
|
||||
n := popcountSum(bm)
|
||||
cells += n
|
||||
areaM2 += float64(n) * cellAreaM2(gx, gy)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
ix.mu.Lock()
|
||||
ix.blocks = blocks
|
||||
ix.cells = cells
|
||||
ix.areaM2 = areaM2
|
||||
ix.ver = time.Now().Unix()
|
||||
ix.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats 返回块数与格子数。
|
||||
func (ix *Index) Stats() (blocks, cells int) {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
return len(ix.blocks), ix.cells
|
||||
}
|
||||
|
||||
// AreaKm2 返回已探索面积(km²,按块中心纬度做 cos 修正)。写入索引时算好,这里只读。
|
||||
func (ix *Index) AreaKm2() float64 {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
return ix.areaM2 / 1e6
|
||||
}
|
||||
|
||||
// Version 返回数据版本号,随每次写入变化。
|
||||
func (ix *Index) Version() int64 {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
return ix.ver
|
||||
}
|
||||
|
||||
// View 在读锁内执行渲染查询,避免渲染中途被写换代。
|
||||
func (ix *Index) View(fn func(blocks map[[2]int][]byte)) {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
fn(ix.blocks)
|
||||
}
|
||||
|
||||
// Import 把解析出的块写入库:merge 模式按位或合并进已有数据,
|
||||
// replace 模式先清空该用户全部迷雾再写入。完成后刷新内存索引。
|
||||
func (ix *Index) Import(db *sql.DB, userID int64, blocks []Block, merge bool) (applied int, err error) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if !merge {
|
||||
if _, err := tx.Exec(`DELETE FROM fog_blocks WHERE user_id=$1`, userID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
var upsert *sql.Stmt
|
||||
if upsert, err = tx.Prepare(`INSERT INTO fog_blocks (user_id, gbx, gby, bitmap, updated_at)
|
||||
VALUES ($1,$2,$3,$4,now())
|
||||
ON CONFLICT (user_id, gbx, gby) DO UPDATE SET bitmap=$4, updated_at=now()`); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer upsert.Close()
|
||||
|
||||
for _, b := range blocks {
|
||||
if merge {
|
||||
var old []byte
|
||||
err := tx.QueryRow(`SELECT bitmap FROM fog_blocks WHERE user_id=$1 AND gbx=$2 AND gby=$3`,
|
||||
userID, b.GX, b.GY).Scan(&old)
|
||||
switch {
|
||||
case err == nil && len(old) == len(b.Bitmap):
|
||||
for i := range b.Bitmap {
|
||||
b.Bitmap[i] |= old[i]
|
||||
}
|
||||
case err != nil && !errors.Is(err, sql.ErrNoRows):
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if _, err := upsert.Exec(userID, b.GX, b.GY, b.Bitmap); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applied++
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := ix.Load(db, userID); err != nil {
|
||||
return applied, err
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// CellLit 报告某个全局格是否已探索。
|
||||
func (ix *Index) CellLit(cx, cy int) bool {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
bm, ok := ix.blocks[[2]int{cx / BlockCells, cy / BlockCells}]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
col, row := cx%BlockCells, cy%BlockCells
|
||||
return bm[row*8+col/8]&(0x80>>(col%8)) != 0
|
||||
}
|
||||
|
||||
// All 返回全部块(导出用)。
|
||||
func (ix *Index) All() []Block {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
out := make([]Block, 0, len(ix.blocks))
|
||||
for k, bm := range ix.blocks {
|
||||
out = append(out, Block{GX: k[0], GY: k[1], Bitmap: bm})
|
||||
}
|
||||
sortBlocks(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// LoadOrNewIndex 返回某用户的迷雾索引;首次访问时从库里加载。
|
||||
func LoadOrNewIndex(db *sql.DB, userID int64, existing *Index) (*Index, error) {
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
ix := NewIndex()
|
||||
if err := ix.Load(db, userID); err != nil {
|
||||
return nil, fmt.Errorf("加载迷雾数据: %w", err)
|
||||
}
|
||||
return ix, nil
|
||||
}
|
||||
|
||||
// SaveRawLayers 保存快照里其它图层的原始条目(path -> zlib 流本身,一个字节都不动)。
|
||||
// replace 为真时先清空(「整体替换」与「清空迷雾」都该把旧的一并带走),
|
||||
// 否则同名覆盖——后传的快照更完整。
|
||||
func (ix *Index) SaveRawLayers(db *sql.DB, userID int64, layers map[string][]byte, replace bool) error {
|
||||
if !replace && len(layers) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if replace {
|
||||
if _, err := tx.Exec(`DELETE FROM fog_raw_files WHERE user_id=$1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for p, data := range layers {
|
||||
if _, err := tx.Exec(`INSERT INTO fog_raw_files (user_id, path, data) VALUES ($1,$2,$3)
|
||||
ON CONFLICT (user_id, path) DO UPDATE SET data=EXCLUDED.data`, userID, p, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DropRawLayers 丢弃留存的其它图层。清空迷雾时调用(与 fog_blocks 一样是站点级,
|
||||
// 不按用户区分),否则导出的快照里会留着已经没有对应 Model/* 瓦片的图层条目。
|
||||
func DropRawLayers(db *sql.DB) error {
|
||||
_, err := db.Exec(`DELETE FROM fog_raw_files`)
|
||||
return err
|
||||
}
|
||||
|
||||
// RawLayers 读出留存的非已探索层文件(path -> 原始字节)。
|
||||
func LoadRawLayers(db *sql.DB, userID int64) (map[string][]byte, error) {
|
||||
rows, err := db.Query(`SELECT path, data FROM fog_raw_files WHERE user_id=$1`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string][]byte{}
|
||||
for rows.Next() {
|
||||
var p string
|
||||
var data []byte
|
||||
if err := rows.Scan(&p, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[p] = data
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 迷雾遮罩区(对访客隐藏的范围) ----------
|
||||
|
||||
// SaveMask 新增或更新一块遮罩(ID 为 0 时新增),返回其 ID。
|
||||
func SaveMask(db *sql.DB, userID int64, m Mask) (int64, error) {
|
||||
if m.ID > 0 {
|
||||
_, err := db.Exec(`UPDATE fog_masks SET name=$1, lat=$2, lon=$3, radius_m=$4 WHERE id=$5 AND user_id=$6`,
|
||||
m.Name, m.Lat, m.Lon, m.RadiusM, m.ID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return m.ID, nil
|
||||
}
|
||||
var id int64
|
||||
err := db.QueryRow(`INSERT INTO fog_masks (user_id, name, lat, lon, radius_m) VALUES ($1,$2,$3,$4,$5)
|
||||
RETURNING id`, userID, m.Name, m.Lat, m.Lon, m.RadiusM).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// DeleteMask 删除一块遮罩。
|
||||
func DeleteMask(db *sql.DB, userID, id int64) error {
|
||||
_, err := db.Exec(`DELETE FROM fog_masks WHERE id=$1 AND user_id=$2`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadMasks 读出全部遮罩。遮罩与迷雾一样是站点级数据(单站长的站点),
|
||||
// 展示与渲染时不分归属,与 fog_blocks 的 LoadAll 保持一致。
|
||||
func LoadMasks(db *sql.DB) ([]Mask, error) {
|
||||
rows, err := db.Query(`SELECT id, COALESCE(name,''), lat, lon, radius_m FROM fog_masks
|
||||
WHERE radius_m > 0 ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Mask
|
||||
for rows.Next() {
|
||||
var m Mask
|
||||
if err := rows.Scan(&m.ID, &m.Name, &m.Lat, &m.Lon, &m.RadiusM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package geo
|
||||
|
||||
import "math"
|
||||
|
||||
const earthRadiusKm = 6371.0088
|
||||
|
||||
// Haversine 返回两点间大圆距离(公里)。输入为 WGS-84 经纬度。
|
||||
func Haversine(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
r1, r2 := rad(lat1), rad(lat2)
|
||||
dlat := rad(lat2 - lat1)
|
||||
dlon := rad(lon2 - lon1)
|
||||
a := math.Sin(dlat/2)*math.Sin(dlat/2) +
|
||||
math.Cos(r1)*math.Cos(r2)*math.Sin(dlon/2)*math.Sin(dlon/2)
|
||||
return 2 * earthRadiusKm * math.Asin(math.Min(1, math.Sqrt(a)))
|
||||
}
|
||||
|
||||
func rad(d float64) float64 { return d * math.Pi / 180 }
|
||||
|
||||
// Bearing 返回从点 1 指向点 2 的方位角(度,正北为 0,顺时针;范围 -180~180)。
|
||||
// 用来把「坐标移动了多远」说成「往东南 520 米」这种能对上的话。
|
||||
func Bearing(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
dLon := rad(lon2 - lon1)
|
||||
y := math.Sin(dLon) * math.Cos(rad(lat2))
|
||||
x := math.Cos(rad(lat1))*math.Sin(rad(lat2)) -
|
||||
math.Sin(rad(lat1))*math.Cos(rad(lat2))*math.Cos(dLon)
|
||||
return math.Atan2(y, x) * 180 / math.Pi
|
||||
}
|
||||
|
||||
// 中国境内判定所用的粗略外接矩形
|
||||
func inChina(lat, lon float64) bool {
|
||||
return lon >= 73.66 && lon <= 135.05 && lat >= 3.86 && lat <= 53.55
|
||||
}
|
||||
|
||||
const (
|
||||
axxisPi = math.Pi * 3000.0 / 180.0
|
||||
ee = 0.00669342162296594323
|
||||
semiMajor = 6378245.0
|
||||
)
|
||||
|
||||
// WGS84ToGCJ02 将 GPS 原始坐标转为火星坐标(GCJ-02)。
|
||||
// 用于把原始定位、世界迷雾等 WGS-84 数据叠加到国内底图上。
|
||||
func WGS84ToGCJ02(lat, lon float64) (float64, float64) {
|
||||
if !inChina(lat, lon) {
|
||||
return lat, lon
|
||||
}
|
||||
dLat := transformLat(lon-105.0, lat-35.0)
|
||||
dLon := transformLon(lon-105.0, lat-35.0)
|
||||
radLat := lat / 180.0 * math.Pi
|
||||
magic := math.Sin(radLat)
|
||||
magic = 1 - ee*magic*magic
|
||||
sqrtMagic := math.Sqrt(magic)
|
||||
dLat = (dLat * 180.0) / ((semiMajor * (1 - ee)) / (magic * sqrtMagic) * math.Pi)
|
||||
dLon = (dLon * 180.0) / (semiMajor / sqrtMagic * math.Cos(radLat) * math.Pi)
|
||||
return lat + dLat, lon + dLon
|
||||
}
|
||||
|
||||
func transformLat(x, y float64) float64 {
|
||||
ret := -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*math.Sqrt(math.Abs(x))
|
||||
ret += (20.0*math.Sin(6.0*x*math.Pi) + 20.0*math.Sin(2.0*x*math.Pi)) * 2.0 / 3.0
|
||||
ret += (20.0*math.Sin(y*math.Pi) + 40.0*math.Sin(y/3.0*math.Pi)) * 2.0 / 3.0
|
||||
ret += (160.0*math.Sin(y/12.0*math.Pi) + 320*math.Sin(y*math.Pi/30.0)) * 2.0 / 3.0
|
||||
return ret
|
||||
}
|
||||
|
||||
func transformLon(x, y float64) float64 {
|
||||
ret := 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*math.Sqrt(math.Abs(x))
|
||||
ret += (20.0*math.Sin(6.0*x*math.Pi) + 20.0*math.Sin(2.0*x*math.Pi)) * 2.0 / 3.0
|
||||
ret += (20.0*math.Sin(x*math.Pi) + 40.0*math.Sin(x/3.0*math.Pi)) * 2.0 / 3.0
|
||||
ret += (150.0*math.Sin(x/12.0*math.Pi) + 300.0*math.Sin(x/30.0*math.Pi)) * 2.0 / 3.0
|
||||
return ret
|
||||
}
|
||||
|
||||
// GCJ02ToWGS84 是 WGS84ToGCJ02 的逆运算。
|
||||
// 正变换没有解析反函数,用不动点迭代逼近;偏移量随经纬度缓变,5 次迭代已收敛到厘米级。
|
||||
func GCJ02ToWGS84(lat, lon float64) (float64, float64) {
|
||||
if !inChina(lat, lon) {
|
||||
return lat, lon
|
||||
}
|
||||
wLat, wLon := lat, lon
|
||||
for i := 0; i < 5; i++ {
|
||||
gLat, gLon := WGS84ToGCJ02(wLat, wLon)
|
||||
wLat += lat - gLat
|
||||
wLon += lon - gLon
|
||||
}
|
||||
return wLat, wLon
|
||||
}
|
||||
|
||||
// GCJ02ToWGS84InPlace 与 GCJ02ToWGS84 相同,供批量换算时复用调用点。
|
||||
func GCJ02ToWGS84InPlace(points [][2]float64) {
|
||||
for i := range points {
|
||||
points[i][0], points[i][1] = GCJ02ToWGS84(points[i][0], points[i][1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Package gpx 解析 GPX 轨迹文件(标准 WGS-84 坐标),
|
||||
// 用来给 rond 的「地点到地点」直线位移补上真实路径。
|
||||
package gpx
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Track 是一条轨迹(GPX 的 trk / rte),坐标是 WGS-84。
|
||||
type Track struct {
|
||||
Name string
|
||||
Points []Point
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
Lat float64
|
||||
Lon float64
|
||||
Ele float64
|
||||
T time.Time
|
||||
}
|
||||
|
||||
type gpxDoc struct {
|
||||
XMLName xml.Name `xml:"gpx"`
|
||||
Name string `xml:"metadata>name"`
|
||||
Trks []trk `xml:"trk"`
|
||||
Rtes []rte `xml:"rte"`
|
||||
}
|
||||
|
||||
type trk struct {
|
||||
Name string `xml:"name"`
|
||||
Trkseg []trkseg `xml:"trkseg"`
|
||||
}
|
||||
|
||||
type trkseg struct {
|
||||
Pts []wpt `xml:"trkpt"`
|
||||
}
|
||||
|
||||
type rte struct {
|
||||
Name string `xml:"name"`
|
||||
Pts []wpt `xml:"rtept"`
|
||||
}
|
||||
|
||||
type wpt struct {
|
||||
Lat float64 `xml:"lat,attr"`
|
||||
Lon float64 `xml:"lon,attr"`
|
||||
Ele float64 `xml:"ele"`
|
||||
T string `xml:"time"`
|
||||
}
|
||||
|
||||
// Parse 解析 GPX 文本,返回其中的全部轨迹。trk 优先,没有则退回 rte。
|
||||
func Parse(data []byte) ([]Track, error) {
|
||||
var doc gpxDoc
|
||||
if err := xml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("GPX 解析失败: %w", err)
|
||||
}
|
||||
var out []Track
|
||||
base := strings.TrimSpace(doc.Name)
|
||||
for _, t := range doc.Trks {
|
||||
tr := Track{Name: firstNonEmpty(strings.TrimSpace(t.Name), base)}
|
||||
for _, seg := range t.Trkseg {
|
||||
for _, p := range seg.Pts {
|
||||
if p.Lat == 0 && p.Lon == 0 {
|
||||
continue
|
||||
}
|
||||
pt := Point{Lat: p.Lat, Lon: p.Lon, Ele: p.Ele}
|
||||
if p.T != "" {
|
||||
pt.T, _ = time.Parse(time.RFC3339, strings.TrimSpace(p.T))
|
||||
}
|
||||
tr.Points = append(tr.Points, pt)
|
||||
}
|
||||
}
|
||||
if len(tr.Points) > 1 {
|
||||
if tr.Time.IsZero() {
|
||||
tr.Time = tr.Points[0].T
|
||||
}
|
||||
out = append(out, tr)
|
||||
}
|
||||
}
|
||||
for _, r := range doc.Rtes {
|
||||
tr := Track{Name: firstNonEmpty(strings.TrimSpace(r.Name), base)}
|
||||
for _, p := range r.Pts {
|
||||
if p.Lat == 0 && p.Lon == 0 {
|
||||
continue
|
||||
}
|
||||
pt := Point{Lat: p.Lat, Lon: p.Lon}
|
||||
if p.T != "" {
|
||||
pt.T, _ = time.Parse(time.RFC3339, strings.TrimSpace(p.T))
|
||||
}
|
||||
tr.Points = append(tr.Points, pt)
|
||||
}
|
||||
if len(tr.Points) > 1 {
|
||||
if tr.Time.IsZero() {
|
||||
tr.Time = tr.Points[0].T
|
||||
}
|
||||
out = append(out, tr)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("文件里没有找到轨迹点(需要 trk/trkpt 或 rte/rtept)")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LengthKm 计算轨迹里程(Haversine)。
|
||||
func (t Track) LengthKm() float64 {
|
||||
sum := 0.0
|
||||
for i := 1; i < len(t.Points); i++ {
|
||||
sum += haversine(t.Points[i-1], t.Points[i])
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// BBox 返回 [minLon, minLat, maxLon, maxLat]。
|
||||
func (t Track) BBox() [4]float64 {
|
||||
b := [4]float64{180, 90, -180, -90}
|
||||
for _, p := range t.Points {
|
||||
if p.Lon < b[0] {
|
||||
b[0] = p.Lon
|
||||
}
|
||||
if p.Lat < b[1] {
|
||||
b[1] = p.Lat
|
||||
}
|
||||
if p.Lon > b[2] {
|
||||
b[2] = p.Lon
|
||||
}
|
||||
if p.Lat > b[3] {
|
||||
b[3] = p.Lat
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func haversine(a, b Point) float64 {
|
||||
const r = 6371.0
|
||||
dLat := (b.Lat - a.Lat) * math.Pi / 180
|
||||
dLon := (b.Lon - a.Lon) * math.Pi / 180
|
||||
la1 := a.Lat * math.Pi / 180
|
||||
la2 := b.Lat * math.Pi / 180
|
||||
h := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(la1)*math.Cos(la2)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return 2 * r * math.Asin(math.Sqrt(h))
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
)
|
||||
|
||||
// querier 让回填既能跑在导入事务里,也能跑在启动时的连接池上。
|
||||
type querier interface {
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// BackfillCoordSource 给「有 ZLOCATION 留档」的地点补坐标来源,并自愈两类历史错误。
|
||||
//
|
||||
// 判据不能是「有留档行」——站点导出的备份会给新建地点合成 ZLOCATION 行,把
|
||||
// ZRAWLATITUDE 写成 GCJ02ToWGS84(导出那一刻的显示坐标)。这份包被回导之后,合成行
|
||||
// 会被误当成真机的原始 GPS,于是照片导入的点被标成「rond 导入」。区分办法就是这条
|
||||
// 往返关系本身:
|
||||
//
|
||||
// ZRAWLATITUDE == GCJ02ToWGS84(ZLATITUDE) (位级相等,因为值就是我们自己算的)
|
||||
//
|
||||
// 真机行的 ZRAW 是独立采样值,从不满足(实测基线 321 行零命中)。判据必须拿留档行
|
||||
// **自己的** ZLATITUDE 去比,而不是 places.lat——后者会被后来的坐标编辑改掉。
|
||||
//
|
||||
// 顺带修一处历史笔误:早期回填把 src_lon 写成了显示经度 ZLONGITUDE,应为原始经度
|
||||
// ZRAWLONGITUDE。按「src_lon 恰好等于显示经度」精确识别后改写,不碰用户手工订正过的值。
|
||||
func BackfillCoordSource(ctx context.Context, q querier, datasetID int64) (int, error) {
|
||||
rows, err := q.QueryContext(ctx, `SELECT p.src_pk, COALESCE(p.coord_source,''), p.src_lat, p.src_lon,
|
||||
(r.raw->>'ZLATITUDE')::float8, (r.raw->>'ZLONGITUDE')::float8,
|
||||
(r.raw->>'ZRAWLATITUDE')::float8, (r.raw->>'ZRAWLONGITUDE')::float8
|
||||
FROM places p JOIN entity_raw r
|
||||
ON r.dataset_id=p.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=p.src_pk
|
||||
WHERE p.dataset_id=$1
|
||||
AND (r.raw->>'ZLATITUDE') ~ '^-?[0-9.]+$'
|
||||
AND (r.raw->>'ZLONGITUDE') ~ '^-?[0-9.]+$'
|
||||
AND (r.raw->>'ZRAWLATITUDE') ~ '^-?[0-9.]+$'
|
||||
AND (r.raw->>'ZRAWLONGITUDE') ~ '^-?[0-9.]+$'`, datasetID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读留档行: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var fillPK []int64
|
||||
var fillLat, fillLon []float64
|
||||
var clearPK, fixPK []int64
|
||||
var fixLat, fixLon []float64
|
||||
|
||||
for rows.Next() {
|
||||
var pk int64
|
||||
var source string
|
||||
var srcLat, srcLon sql.NullFloat64
|
||||
var zlat, zlon, rawLat, rawLon float64
|
||||
if err := rows.Scan(&pk, &source, &srcLat, &srcLon, &zlat, &zlon, &rawLat, &rawLon); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
gLat, gLon := geo.GCJ02ToWGS84(zlat, zlon)
|
||||
if gLat == zlat && gLon == zlon {
|
||||
// 境外点不做 GCJ 偏移,ZRAW 与显示坐标本来就该相等。可合成行写的是
|
||||
// GCJ02ToWGS84(显示坐标),在境外同样是恒等——两边长得一模一样,判不出来,
|
||||
// 干脆不碰:宁可留空,也别把真机的境外点清掉。
|
||||
continue
|
||||
}
|
||||
if gLat == rawLat && gLon == rawLon {
|
||||
// 合成行:早期回填按「有留档」误判成了 rond,撤回它写下的那份
|
||||
if source == "rond" && srcLat.Valid && srcLat.Float64 == rawLat {
|
||||
clearPK = append(clearPK, pk)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case source == "":
|
||||
fillPK = append(fillPK, pk)
|
||||
fillLat = append(fillLat, rawLat)
|
||||
fillLon = append(fillLon, rawLon)
|
||||
case source == "rond" && srcLon.Valid && srcLon.Float64 == zlon && srcLon.Float64 != rawLon:
|
||||
fixPK = append(fixPK, pk)
|
||||
fixLat = append(fixLat, rawLat)
|
||||
fixLon = append(fixLon, rawLon)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
changed := 0
|
||||
if len(fillPK) > 0 {
|
||||
res, err := q.ExecContext(ctx, `UPDATE places p SET coord_source='rond', coord_sys='wgs84',
|
||||
src_lat=v.rlat, src_lon=v.rlon
|
||||
FROM (SELECT unnest($2::bigint[]) AS pk, unnest($3::float8[]) AS rlat,
|
||||
unnest($4::float8[]) AS rlon) v
|
||||
WHERE p.dataset_id=$1 AND p.src_pk=v.pk AND COALESCE(p.coord_source,'')=''`,
|
||||
datasetID, fillPK, fillLat, fillLon)
|
||||
if err != nil {
|
||||
return changed, fmt.Errorf("补坐标来源: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
changed += int(n)
|
||||
}
|
||||
}
|
||||
if len(fixPK) > 0 {
|
||||
res, err := q.ExecContext(ctx, `UPDATE places p SET src_lat=v.rlat, src_lon=v.rlon
|
||||
FROM (SELECT unnest($2::bigint[]) AS pk, unnest($3::float8[]) AS rlat,
|
||||
unnest($4::float8[]) AS rlon) v
|
||||
WHERE p.dataset_id=$1 AND p.src_pk=v.pk`,
|
||||
datasetID, fixPK, fixLat, fixLon)
|
||||
if err != nil {
|
||||
return changed, fmt.Errorf("修正原始坐标: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
changed += int(n)
|
||||
}
|
||||
}
|
||||
if len(clearPK) > 0 {
|
||||
res, err := q.ExecContext(ctx, `UPDATE places SET coord_source=NULL, coord_sys=NULL,
|
||||
src_lat=NULL, src_lon=NULL
|
||||
WHERE dataset_id=$1 AND src_pk=ANY($2::bigint[]) AND coord_source='rond'`,
|
||||
datasetID, clearPK)
|
||||
if err != nil {
|
||||
return changed, fmt.Errorf("撤回误判的来源: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
changed += int(n)
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// BackfillAllCoordSource 对所有数据集跑一遍回填,供启动时自愈历史数据用。
|
||||
// 单个数据集失败不阻断启动——留档是增强,不该挡住服务。
|
||||
func BackfillAllCoordSource(ctx context.Context, d *sql.DB) error {
|
||||
rows, err := d.QueryContext(ctx, `SELECT id FROM datasets ORDER BY id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := BackfillCoordSource(ctx, d, id); err != nil {
|
||||
return fmt.Errorf("数据集 %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
"rond-with-you/internal/rond"
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
Visits int
|
||||
Places int
|
||||
RawVisits int
|
||||
Skipped int
|
||||
Identical bool // 内容与已有数据集完全一致,本次没有新建快照
|
||||
FirstVisit *time.Time
|
||||
LastVisit *time.Time
|
||||
}
|
||||
|
||||
// File 计算文件摘要,用于去重。
|
||||
func File(path string) (string, int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||||
}
|
||||
|
||||
// insertEntityRaw 把「实体 -> src_pk -> 完整原始行」写进 entity_raw。
|
||||
// 单条失败不阻断导入(留档只影响导出保真度),但会记日志提示。
|
||||
func insertEntityRaw(ctx context.Context, tx *sql.Tx, datasetID int64, rows map[string]map[int]map[string]any, skipped map[string]map[int]bool) error {
|
||||
for entity, byPK := range rows {
|
||||
for pk, raw := range byPK {
|
||||
blob, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
log.Printf("留档 %s#%d 序列化失败: %v", entity, pk, err)
|
||||
continue
|
||||
}
|
||||
// 传 string 而不是 []byte:pgx 会把 []byte 当 bytea,jsonb 列要文本
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw, skipped)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
ON CONFLICT (dataset_id, entity, src_pk) DO UPDATE SET raw=EXCLUDED.raw, skipped=EXCLUDED.skipped`,
|
||||
datasetID, entity, pk, string(blob), skipped[entity][pk]); err != nil {
|
||||
return fmt.Errorf("留档 %s 原始行: %w", entity, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentHash 给备份内容算指纹,用来识别「内容没变、只是重新导出了一次」的上传。
|
||||
// rond 每次导出连 WAL 一起打包,字节必然不同,文件摘要挡不住这种重复;
|
||||
// 但同一批到访的 Z_PK 是稳定的,所以按到访自然键排序后取摘要即可。
|
||||
func contentHash(b *rond.Backup) string {
|
||||
keys := make([]string, 0, len(b.Visits))
|
||||
for _, v := range b.Visits {
|
||||
if v.Arrival == nil {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, fmt.Sprintf("%d|%d", v.SrcPK, v.Arrival.Unix()))
|
||||
}
|
||||
sort.Strings(keys)
|
||||
h := sha256.New()
|
||||
for _, k := range keys {
|
||||
h.Write([]byte(k))
|
||||
h.Write([]byte{'\n'})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Import 把解析后的备份写入 PostgreSQL,整个过程在一个事务内完成。
|
||||
func Import(ctx context.Context, db *sql.DB, userID int64, originalName, sha string, size int64, b *rond.Backup) (int64, *Stats, error) {
|
||||
hash := contentHash(b)
|
||||
|
||||
// 留档 Core Data 结构快照(实体编号 / DDL / 关联表)。失败不致命,留 NULL。
|
||||
metaJSON, _ := json.Marshal(b.Meta)
|
||||
hasMeta := len(metaJSON) > 0 && string(metaJSON) != "null"
|
||||
|
||||
// 内容与已有数据集完全一致时直接复用,不新建快照,也不改动当前展示的那一份。
|
||||
var prevID int64
|
||||
var prev Stats
|
||||
err := db.QueryRowContext(ctx, `SELECT id, visit_count, place_count, raw_visit_count FROM datasets
|
||||
WHERE user_id=$1 AND content_hash=$2 AND status='done'
|
||||
ORDER BY uploaded_at DESC LIMIT 1`, userID, hash).
|
||||
Scan(&prevID, &prev.Visits, &prev.Places, &prev.RawVisits)
|
||||
switch {
|
||||
case err == nil:
|
||||
if hasMeta {
|
||||
if _, e := db.ExecContext(ctx, `UPDATE datasets SET coredata_meta=$1 WHERE id=$2`, metaJSON, prevID); e != nil {
|
||||
log.Printf("补抓 Core Data 元信息失败: %v", e)
|
||||
}
|
||||
}
|
||||
prev.Identical = true
|
||||
return prevID, &prev, nil
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var datasetID int64
|
||||
err = tx.QueryRowContext(ctx, `INSERT INTO datasets (user_id, original_name, sha256, size_bytes, status)
|
||||
VALUES ($1,$2,$3,$4,'importing') RETURNING id`, userID, originalName, sha, size).Scan(&datasetID)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("创建数据集: %w", err)
|
||||
}
|
||||
|
||||
// 缓存 Core Data 元信息,导出 rondbackup 时据此重建库结构。
|
||||
if hasMeta {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE datasets SET coredata_meta=$1 WHERE id=$2`, metaJSON, datasetID); err != nil {
|
||||
return 0, nil, fmt.Errorf("写入 Core Data 元信息: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 站点模型装不下的行(缺必填字段)记在这里,导出时按留档原样写回。
|
||||
rawSkipped := map[string]map[int]bool{}
|
||||
skip := func(entity string, pk int) {
|
||||
if rawSkipped[entity] == nil {
|
||||
rawSkipped[entity] = map[int]bool{}
|
||||
}
|
||||
rawSkipped[entity][pk] = true
|
||||
}
|
||||
|
||||
// 活动类型
|
||||
actID := map[int]int64{}
|
||||
homeAct, workAct := map[int]bool{}, map[int]bool{}
|
||||
for _, a := range b.Activities {
|
||||
var id int64
|
||||
if err := tx.QueryRowContext(ctx, `INSERT INTO activities
|
||||
(dataset_id, src_pk, name, color, icon, is_home, is_work, is_excluded, is_archived)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||
datasetID, a.SrcPK, a.Name, a.Color, a.Icon, a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived).Scan(&id); err != nil {
|
||||
return 0, nil, fmt.Errorf("写入活动类型: %w", err)
|
||||
}
|
||||
actID[a.SrcPK] = id
|
||||
homeAct[a.SrcPK] = a.IsHome
|
||||
workAct[a.SrcPK] = a.IsWork
|
||||
}
|
||||
|
||||
// 标签
|
||||
tagID := map[int]int64{}
|
||||
for _, t := range b.Tags {
|
||||
var id int64
|
||||
if err := tx.QueryRowContext(ctx, `INSERT INTO tags (dataset_id, src_pk, name, color, group_name)
|
||||
VALUES ($1,$2,$3,$4,NULLIF($5,'')) RETURNING id`,
|
||||
datasetID, t.SrcPK, t.Name, t.Color, t.GroupName).Scan(&id); err != nil {
|
||||
return 0, nil, fmt.Errorf("写入标签: %w", err)
|
||||
}
|
||||
tagID[t.SrcPK] = id
|
||||
}
|
||||
|
||||
// 交通方式
|
||||
transName, transColor, transIcon := map[int]string{}, map[int]string{}, map[int]string{}
|
||||
for _, t := range b.Transports {
|
||||
transName[t.SrcPK], transColor[t.SrcPK], transIcon[t.SrcPK] = t.Name, t.Color, t.Icon
|
||||
}
|
||||
|
||||
// 地点
|
||||
placeID := map[int]int64{}
|
||||
type locAgg struct {
|
||||
count int
|
||||
dwell int64
|
||||
first *time.Time
|
||||
last *time.Time
|
||||
}
|
||||
agg := map[int]*locAgg{}
|
||||
locCoord := map[int][2]float64{}
|
||||
for _, l := range b.Locations {
|
||||
var id int64
|
||||
if err := tx.QueryRowContext(ctx, `INSERT INTO places
|
||||
(dataset_id, src_pk, name, poi_category, lat, lon, country_code, province, city,
|
||||
district, sublocality, thoroughfare, timezone)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||||
datasetID, l.SrcPK, l.Name, nullStr(l.POICategory), l.Lat, l.Lon,
|
||||
nullStr(l.CountryCode), nullStr(l.Province), nullStr(l.City), nullStr(l.District),
|
||||
nullStr(l.Sublocality), nullStr(l.Thoroughfare), nullStr(l.Timezone)).Scan(&id); err != nil {
|
||||
return 0, nil, fmt.Errorf("写入地点: %w", err)
|
||||
}
|
||||
placeID[l.SrcPK] = id
|
||||
agg[l.SrcPK] = &locAgg{}
|
||||
locCoord[l.SrcPK] = [2]float64{l.Lat, l.Lon}
|
||||
}
|
||||
|
||||
// 访问记录
|
||||
visitID := map[int]int64{}
|
||||
var first, last *time.Time
|
||||
var vcount, skipped int
|
||||
visitPlace := map[int]int{}
|
||||
for _, v := range b.Visits {
|
||||
if v.Arrival == nil {
|
||||
skip("ZVISIT", v.SrcPK)
|
||||
continue
|
||||
}
|
||||
var pid any
|
||||
if v.LocationSrcPK != nil {
|
||||
if id, ok := placeID[*v.LocationSrcPK]; ok {
|
||||
pid = id
|
||||
}
|
||||
}
|
||||
var aid any
|
||||
isHome, isWork := false, false
|
||||
if v.ActivitySrcPK != nil {
|
||||
if id, ok := actID[*v.ActivitySrcPK]; ok {
|
||||
aid = id
|
||||
}
|
||||
isHome, isWork = homeAct[*v.ActivitySrcPK], workAct[*v.ActivitySrcPK]
|
||||
}
|
||||
var dur any
|
||||
if v.Departure != nil {
|
||||
m := int(v.Departure.Sub(*v.Arrival).Minutes())
|
||||
if m < 0 {
|
||||
m = 0
|
||||
}
|
||||
dur = m
|
||||
}
|
||||
var id int64
|
||||
err := tx.QueryRowContext(ctx, `INSERT INTO visits
|
||||
(dataset_id, user_id, src_pk, place_id, activity_id, arrival, departure, duration_min,
|
||||
is_home, is_work, bookmarked, is_user_added, remark, emoji, weather_symbol)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NULLIF($13,''),NULLIF($14,''),NULLIF($15,''))
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id`,
|
||||
datasetID, userID, v.SrcPK, pid, aid, v.Arrival, v.Departure, dur,
|
||||
isHome, isWork, v.Bookmarked, v.UserAdded, v.Remark, v.Emoji, v.WeatherSymbol).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// 该到访已随更早的备份导入过,跳过即可,连同它的标签一起跳过
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("写入访问记录: %w", err)
|
||||
}
|
||||
visitID[v.SrcPK] = id
|
||||
vcount++
|
||||
|
||||
if v.LocationSrcPK != nil {
|
||||
if a, ok := agg[*v.LocationSrcPK]; ok {
|
||||
a.count++
|
||||
if dur != nil {
|
||||
a.dwell += int64(dur.(int))
|
||||
}
|
||||
if a.first == nil || v.Arrival.Before(*a.first) {
|
||||
a.first = v.Arrival
|
||||
}
|
||||
if a.last == nil || v.Arrival.After(*a.last) {
|
||||
a.last = v.Arrival
|
||||
}
|
||||
}
|
||||
visitPlace[v.SrcPK] = *v.LocationSrcPK
|
||||
}
|
||||
if first == nil || v.Arrival.Before(*first) {
|
||||
first = v.Arrival
|
||||
}
|
||||
if last == nil || v.Arrival.After(*last) {
|
||||
last = v.Arrival
|
||||
}
|
||||
|
||||
for _, tpk := range v.TagSrcPKs {
|
||||
if tid, ok := tagID[tpk]; ok {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO visit_tags (visit_id, tag_id) VALUES ($1,$2)
|
||||
ON CONFLICT DO NOTHING`, id, tid); err != nil {
|
||||
return 0, nil, fmt.Errorf("写入访问标签: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 位移
|
||||
for _, m := range b.Movements {
|
||||
var dist any
|
||||
var fromPID, toPID any
|
||||
if m.FromVisitSrc != nil && m.ToVisitSrc != nil {
|
||||
fp, okf := visitPlace[*m.FromVisitSrc]
|
||||
tp, okt := visitPlace[*m.ToVisitSrc]
|
||||
if okf && okt {
|
||||
if id, ok := placeID[fp]; ok {
|
||||
fromPID = id
|
||||
}
|
||||
if id, ok := placeID[tp]; ok {
|
||||
toPID = id
|
||||
}
|
||||
c1, c2 := locCoord[fp], locCoord[tp]
|
||||
if c1[0] != 0 && c2[0] != 0 {
|
||||
d := geo.Haversine(c1[0], c1[1], c2[0], c2[1])
|
||||
dist = d
|
||||
}
|
||||
}
|
||||
}
|
||||
var tname, tcolor, ticon any
|
||||
if m.TransportSrc != nil {
|
||||
tname, tcolor, ticon = nullStr(transName[*m.TransportSrc]), nullStr(transColor[*m.TransportSrc]), nullStr(transIcon[*m.TransportSrc])
|
||||
}
|
||||
var dur any
|
||||
if m.Start != nil && m.End != nil {
|
||||
if d := int(m.End.Sub(*m.Start).Minutes()); d >= 0 {
|
||||
dur = d
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO movements
|
||||
(dataset_id, user_id, src_pk, transport_src, transport_name, transport_color, transport_icon,
|
||||
from_visit_src, to_visit_src, from_place_id, to_place_id, started_at, ended_at, duration_min, distance_km)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
datasetID, userID, m.SrcPK, m.TransportSrc, tname, tcolor, ticon,
|
||||
m.FromVisitSrc, m.ToVisitSrc, fromPID, toPID, m.Start, m.End, dur, dist); err != nil {
|
||||
return 0, nil, fmt.Errorf("写入位移记录: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 小时级天气(去重,仅保留整点)
|
||||
for _, w := range b.Weather {
|
||||
if w.At == nil {
|
||||
skip("ZHOURLYWEATHER", w.SrcPK)
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO weather
|
||||
(dataset_id, user_id, src_pk, visit_src, at, is_daylight, temperature_c, apparent_c, humidity,
|
||||
precipitation_amount, precipitation_chance, wind_speed, visibility, uv_index,
|
||||
condition, symbol, uv_category, wind_direction)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NULLIF($15,''),NULLIF($16,''),NULLIF($17,''),NULLIF($18,''))
|
||||
ON CONFLICT DO NOTHING`,
|
||||
datasetID, userID, w.SrcPK, w.VisitSrc, w.At, w.IsDaylight, w.TempC, w.ApparentC, w.Humidity,
|
||||
w.PrecipAmt, w.PrecipPct, w.WindSpeed, w.Visibility, w.UVIndex,
|
||||
w.Condition, w.Symbol, w.UVCategory, w.WindDir); err != nil {
|
||||
return 0, nil, fmt.Errorf("写入天气记录: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 回填地点聚合
|
||||
for src, a := range agg {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE places SET visit_count=$1, dwell_minutes=$2,
|
||||
first_visit_at=$3, last_visit_at=$4 WHERE id=$5`, a.count, a.dwell, a.first, a.last, placeID[src]); err != nil {
|
||||
return 0, nil, fmt.Errorf("回填地点统计: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总
|
||||
st := &Stats{Visits: vcount, Skipped: skipped, Places: len(b.Locations), RawVisits: b.RawVisitNum, FirstVisit: first, LastVisit: last}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE datasets SET status='done', visit_count=$1, place_count=$2,
|
||||
raw_visit_count=$3, first_visit_at=$4, last_visit_at=$5,
|
||||
source_created_at=$6, content_hash=$7 WHERE id=$8`,
|
||||
st.Visits, st.Places, st.RawVisits, first, last, time.Now(), hash, datasetID); err != nil {
|
||||
return 0, nil, fmt.Errorf("更新数据集汇总: %w", err)
|
||||
}
|
||||
|
||||
// 快照式语义:最新导入的那一份接管展示,其余自动退居后台(后台仍可手动切回)
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE datasets SET is_active = (id=$1) WHERE user_id=$2`,
|
||||
datasetID, userID); err != nil {
|
||||
return 0, nil, fmt.Errorf("切换生效数据集: %w", err)
|
||||
}
|
||||
|
||||
// 原始行留档:站点只映射了一部分字段,把 Core Data 里的整行存下来,
|
||||
// 导出时逐列还原——这样既不受映射范围限制,也不依赖基线库去补剩下的列。
|
||||
if err := insertEntityRaw(ctx, tx, datasetID, b.RawRows, rawSkipped); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
// 坐标来源留档要等 entity_raw 写完才能做(原始 GPS 只在留档行里)。
|
||||
if _, err := BackfillCoordSource(ctx, tx, datasetID); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return datasetID, st, nil
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Cached 记录已有上传的摘要,供重复上传时直接复用。
|
||||
type Cached struct {
|
||||
DatasetID int64
|
||||
Stats Stats
|
||||
}
|
||||
|
||||
func SortLocations(list []rond.Location) {
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].SrcPK < list[j].SrcPK })
|
||||
}
|
||||
|
||||
func TempDir(base, name string) (string, error) {
|
||||
d := filepath.Join(base, fmt.Sprintf("import-%d", time.Now().UnixNano()))
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// Package media 负责地点图片的落盘 / 对象存储与取址。
|
||||
//
|
||||
// 存哪边由后台设置决定(local | s3),S3 凭据只在 conf/app.ini 里、不进数据库。
|
||||
// 上传的字节由浏览器转成 WebP 之后才发过来,服务端只做「接收 → 原样写出去」,
|
||||
// 不碰解码与转码,1 核机器也扛得住。
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rond-with-you/internal/config"
|
||||
)
|
||||
|
||||
// BackendLocal / BackendS3 是两种存储后端的标识(与后台设置里的取值一致)。
|
||||
const (
|
||||
BackendLocal = "local"
|
||||
BackendS3 = "s3"
|
||||
)
|
||||
|
||||
// Store 把「写到哪」收敛到一处:本地磁盘或 S3 兼容对象存储。
|
||||
type Store struct {
|
||||
cfg *config.Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Store {
|
||||
return &Store{cfg: cfg, client: &http.Client{Timeout: 60 * time.Second}}
|
||||
}
|
||||
|
||||
// LocalDir 返回本地图片目录,供静态路由挂载。
|
||||
func (s *Store) LocalDir() string { return s.cfg.MediaDir() }
|
||||
|
||||
// S3Ready 表示对象存储是否配置齐全(后台选了 s3 但没配齐时要能给出明确提示)。
|
||||
func (s *Store) S3Ready() bool { return s.cfg.S3Ready() }
|
||||
|
||||
// LocalPath 把对象 key 映射成本地磁盘路径,并保证不越出图片目录。
|
||||
func (s *Store) LocalPath(key string) (string, error) {
|
||||
clean := path.Clean("/" + key)
|
||||
if clean == "/" || strings.Contains(clean, "..") {
|
||||
return "", fmt.Errorf("非法对象名 %q", key)
|
||||
}
|
||||
return filepath.Join(s.cfg.MediaDir(), filepath.FromSlash(strings.TrimPrefix(clean, "/"))), nil
|
||||
}
|
||||
|
||||
// PublicURL 返回浏览器可直接放进 <img src> 的地址。
|
||||
// 本地存储走站内 /media/;对象存储走 s3_public_base(没配就返回空串,
|
||||
// 调用方要当成「配错了」处理——R2 桶默认私有,不给公开域名浏览器取不到)。
|
||||
func (s *Store) PublicURL(backend, key string) string {
|
||||
if backend == BackendS3 {
|
||||
return s.S3URL(key)
|
||||
}
|
||||
return "/media/" + strings.TrimPrefix(key, "/")
|
||||
}
|
||||
|
||||
// S3URL 返回对象存储上的公开地址;未配 s3_public_base 时返回空串。
|
||||
func (s *Store) S3URL(key string) string {
|
||||
if s.cfg.S3PublicBase == "" {
|
||||
return ""
|
||||
}
|
||||
return s.cfg.S3PublicBase + "/" + strings.TrimPrefix(key, "/")
|
||||
}
|
||||
|
||||
// Save 写入一个对象。backend 传 BackendLocal 或 BackendS3。
|
||||
func (s *Store) Save(ctx context.Context, backend, key string, data []byte, contentType string) error {
|
||||
switch backend {
|
||||
case BackendLocal:
|
||||
full, err := s.LocalPath(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// 先写临时文件再改名:中途失败不会留下半张图
|
||||
tmp := full + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, full)
|
||||
case BackendS3:
|
||||
if !s.cfg.S3Ready() {
|
||||
return errors.New("对象存储未配置齐全(需要 s3_endpoint / s3_bucket / s3_access_key / s3_secret_key)")
|
||||
}
|
||||
return s.putS3(ctx, key, data, contentType)
|
||||
default:
|
||||
return fmt.Errorf("未知的存储后端 %q", backend)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete 删掉一个对象;对象本来就不在不算错误(本地文件不存在 / S3 返回 404)。
|
||||
func (s *Store) Delete(ctx context.Context, backend, key string) error {
|
||||
switch backend {
|
||||
case BackendLocal:
|
||||
full, err := s.LocalPath(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
s.pruneEmptyDirs(filepath.Dir(full))
|
||||
return nil
|
||||
case BackendS3:
|
||||
if !s.cfg.S3Ready() {
|
||||
return nil // 配置被撤掉了,本地已经无从删除,交给调用方记日志
|
||||
}
|
||||
return s.deleteS3(ctx, key)
|
||||
default:
|
||||
return fmt.Errorf("未知的存储后端 %q", backend)
|
||||
}
|
||||
}
|
||||
|
||||
// pruneEmptyDirs 从里往外收掉空目录。按月份分目录后,删掉某个月最后一张图就会留下空夹;
|
||||
// 只删"空"的(os.Remove 对非空目录会报错,就此打住),且绝不越出图片根目录。
|
||||
func (s *Store) pruneEmptyDirs(dir string) {
|
||||
root := filepath.Clean(s.cfg.MediaDir())
|
||||
for filepath.Clean(dir) != root && strings.HasPrefix(filepath.Clean(dir), root+string(filepath.Separator)) {
|
||||
if err := os.Remove(dir); err != nil {
|
||||
return
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- S3 兼容对象存储:只实现 PUT / DELETE + SigV4 签名 ---------- */
|
||||
func (s *Store) putS3(ctx context.Context, key string, data []byte, contentType string) error {
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
req, err := s.s3Request(ctx, http.MethodPut, key, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
// 图片内容寻址、key 里带哈希,可以长期缓存
|
||||
req.Header.Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
s.sign(req, data)
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("上传对象存储失败:HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) deleteS3(ctx context.Context, key string) error {
|
||||
req, err := s.s3Request(ctx, http.MethodDelete, key, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.sign(req, nil)
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("删除对象失败:HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) s3Request(ctx context.Context, method, key string, body []byte) (*http.Request, error) {
|
||||
// 路径式访问:R2 / MinIO / AWS 都支持,省掉虚拟主机名的判断
|
||||
u := s.cfg.S3Endpoint + "/" + s.cfg.S3Bucket + "/" + escapePath(key)
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, rdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.ContentLength = int64(len(body))
|
||||
}
|
||||
req.Header.Set("x-amz-content-sha256", sha256Hex(body))
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// sign 给请求补上 SigV4 签名(用当前时间)。
|
||||
func (s *Store) sign(req *http.Request, body []byte) {
|
||||
s.signAt(req, body, time.Now().UTC())
|
||||
}
|
||||
|
||||
// unsignedHeaders 是不参与签名的头:Authorization 自己就是签名结果,
|
||||
// 其余几个由 transport 在发送时补/改,签了反而对不上。
|
||||
var unsignedHeaders = map[string]struct{}{
|
||||
"authorization": {}, "content-length": {}, "user-agent": {},
|
||||
"accept-encoding": {}, "connection": {},
|
||||
}
|
||||
|
||||
// signAt 与 sign 相同,但时间可注入——SigV4 的签名依赖时刻,测试要能锁死它。
|
||||
// 签进去的是「请求上现有的全部头(去掉上面那几个)+ host」,签全量比挑几个更稳:
|
||||
// 不会因为漏签某个自定义头而被服务端拒绝。
|
||||
func (s *Store) signAt(req *http.Request, body []byte, now time.Time) {
|
||||
const (
|
||||
algo = "AWS4-HMAC-SHA256"
|
||||
service = "s3"
|
||||
)
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
payloadHash := sha256Hex(body)
|
||||
req.Header.Set("x-amz-content-sha256", payloadHash)
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
|
||||
headers := map[string]string{"host": req.URL.Host}
|
||||
for k, v := range req.Header {
|
||||
name := strings.ToLower(k)
|
||||
if _, skip := unsignedHeaders[name]; skip {
|
||||
continue
|
||||
}
|
||||
headers[name] = strings.Join(v, ",")
|
||||
}
|
||||
// 参与签名的头必须按名字排序,且值要去掉首尾空白、内部连续空白压成一个空格
|
||||
names := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var canonHeaders strings.Builder
|
||||
for _, n := range names {
|
||||
canonHeaders.WriteString(n)
|
||||
canonHeaders.WriteByte(':')
|
||||
canonHeaders.WriteString(strings.Join(strings.Fields(headers[n]), " "))
|
||||
canonHeaders.WriteByte('\n')
|
||||
}
|
||||
signedHeaders := strings.Join(names, ";")
|
||||
|
||||
canonicalRequest := strings.Join([]string{
|
||||
req.Method,
|
||||
canonicalURI(req.URL.Path),
|
||||
canonicalQuery(req.URL.RawQuery),
|
||||
canonHeaders.String(),
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
}, "\n")
|
||||
|
||||
scope := strings.Join([]string{dateStamp, s.cfg.S3Region, service, "aws4_request"}, "/")
|
||||
stringToSign := strings.Join([]string{
|
||||
algo,
|
||||
amzDate,
|
||||
scope,
|
||||
sha256Hex([]byte(canonicalRequest)),
|
||||
}, "\n")
|
||||
|
||||
kDate := hmacSHA256([]byte("AWS4"+s.cfg.S3SecretKey), dateStamp)
|
||||
kRegion := hmacSHA256(kDate, s.cfg.S3Region)
|
||||
kService := hmacSHA256(kRegion, service)
|
||||
kSigning := hmacSHA256(kService, "aws4_request")
|
||||
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
algo, s.cfg.S3AccessKey, scope, signedHeaders, signature))
|
||||
}
|
||||
|
||||
// canonicalURI 逐段编码路径;"/" 保留作分隔符,其余按 RFC 3986 转义。
|
||||
func canonicalURI(p string) string {
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
segs := strings.Split(p, "/")
|
||||
for i, s := range segs {
|
||||
segs[i] = escape(s)
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
// canonicalQuery 按 key 排序并转义查询串(当前用不到,留着保证签名公式完整)。
|
||||
func canonicalQuery(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(raw, "&")
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func escapePath(p string) string {
|
||||
segs := strings.Split(strings.TrimPrefix(p, "/"), "/")
|
||||
for i, s := range segs {
|
||||
segs[i] = escape(s)
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
// escape 实现 RFC 3986 的 unreserved 之外全部百分号转义(空格要转成 %20,不能是 +)。
|
||||
func escape(s string) string {
|
||||
const unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if strings.IndexByte(unreserved, c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func hmacSHA256(key []byte, data string) []byte {
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(data))
|
||||
return m.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rond-with-you/internal/config"
|
||||
)
|
||||
|
||||
// TestSignV4MatchesAWSExample 用 AWS 官方文档里的示例向量锁死 SigV4。
|
||||
// 手写签名最容易在「规范化请求 / 签名串 / 派生密钥」这三步之一算错,
|
||||
// 而那类错误只有真去 PUT 才会暴露;有了官方向量,离线就能验。
|
||||
//
|
||||
// 向量来自 AWS《Examples of the complete Signature Version 4 signing process》,
|
||||
// 场景是 GET 一个带 Range 的对象(服务 s3、区域 us-east-1)。
|
||||
func TestSignV4MatchesAWSExample(t *testing.T) {
|
||||
s := New(&config.Config{
|
||||
S3Endpoint: "https://examplebucket.s3.amazonaws.com",
|
||||
S3Region: "us-east-1",
|
||||
S3Bucket: "examplebucket",
|
||||
S3AccessKey: "AKIAIOSFODNN7EXAMPLE",
|
||||
S3SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://examplebucket.s3.amazonaws.com/test.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-9")
|
||||
|
||||
s.signAt(req, nil, time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
const want = "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
|
||||
auth := req.Header.Get("Authorization")
|
||||
if !strings.Contains(auth, "Signature="+want) {
|
||||
t.Errorf("SigV4 与官方向量不符\n得到: %s\n期望签名: %s", auth, want)
|
||||
}
|
||||
if !strings.Contains(auth, "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date") {
|
||||
t.Errorf("参与签名的头不对: %s", auth)
|
||||
}
|
||||
if !strings.Contains(auth, "Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request") {
|
||||
t.Errorf("Credential 作用域不对: %s", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscapeRFC3986 复核百分号转义:对象名里出现空格、加号、中文时不能被签错。
|
||||
func TestEscapeRFC3986(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"abc-_.~": "abc-_.~",
|
||||
"a b": "a%20b",
|
||||
"a+b": "a%2Bb",
|
||||
"中文": "%E4%B8%AD%E6%96%87",
|
||||
"a/b": "a%2Fb",
|
||||
"2026/01/1": "2026%2F01%2F1",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := escape(in); got != want {
|
||||
t.Errorf("escape(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
// 路径按段编码:分隔符要保留
|
||||
if got := canonicalURI("/a b/c+d/x.webp"); got != "/a%20b/c%2Bd/x.webp" {
|
||||
t.Errorf("canonicalURI 分段编码不对: %q", got)
|
||||
}
|
||||
if got := escapePath("2026/01/a b.webp"); got != "2026/01/a%20b.webp" {
|
||||
t.Errorf("escapePath 不对: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalKeyRoundTrip 本地后端:key → 磁盘路径 → 读回,并挡住目录穿越。
|
||||
func TestLocalKeyRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := New(&config.Config{DataDir: dir})
|
||||
if err := s.Save(t.Context(), BackendLocal, "9/12/abc.webp", []byte("RIFFwebp"), "image/webp"); err != nil {
|
||||
t.Fatalf("写入失败: %v", err)
|
||||
}
|
||||
got, err := s.LocalPath("9/12/abc.webp")
|
||||
if err != nil {
|
||||
t.Fatalf("取路径失败: %v", err)
|
||||
}
|
||||
if b, err := os.ReadFile(got); err != nil || string(b) != "RIFFwebp" {
|
||||
t.Fatalf("读回失败: %q %v", b, err)
|
||||
}
|
||||
if err := s.Delete(t.Context(), BackendLocal, "9/12/abc.webp"); err != nil {
|
||||
t.Fatalf("删除失败: %v", err)
|
||||
}
|
||||
if err := s.Delete(t.Context(), BackendLocal, "9/12/abc.webp"); err != nil {
|
||||
t.Fatalf("重复删除不该报错: %v", err)
|
||||
}
|
||||
// key 一律当成「相对图片目录」的路径:带 .. 的会被规整,规整后必须仍落在目录内
|
||||
for _, tricky := range []string{"../conf/app.ini", "a/../../etc/passwd", "/abs/x.webp"} {
|
||||
p, err := s.LocalPath(tricky)
|
||||
if err != nil {
|
||||
continue // 直接拒绝也可以
|
||||
}
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
t.Errorf("路径逃出了图片目录: %q -> %q", tricky, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicURL 复核取址:本地走站内,对象存储走公开域名;没配公开域名要返回空串。
|
||||
func TestPublicURL(t *testing.T) {
|
||||
local := New(&config.Config{DataDir: t.TempDir()})
|
||||
if got := local.PublicURL(BackendLocal, "9/a.webp"); got != "/media/9/a.webp" {
|
||||
t.Errorf("本地取址不对: %q", got)
|
||||
}
|
||||
noBase := New(&config.Config{DataDir: t.TempDir(), S3Endpoint: "https://x.r2.cloudflarestorage.com",
|
||||
S3Bucket: "b", S3AccessKey: "k", S3SecretKey: "s"})
|
||||
if got := noBase.PublicURL(BackendS3, "9/a.webp"); got != "" {
|
||||
t.Errorf("没配公开域名时应当返回空串(提示配错),实际 %q", got)
|
||||
}
|
||||
withBase := New(&config.Config{DataDir: t.TempDir(), S3PublicBase: "https://img.example.com"})
|
||||
if got := withBase.PublicURL(BackendS3, "9/a.webp"); got != "https://img.example.com/9/a.webp" {
|
||||
t.Errorf("对象存储取址不对: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestS3RequestUsesPathStyle 复核请求形态:路径式、带上内容哈希。
|
||||
func TestS3RequestUsesPathStyle(t *testing.T) {
|
||||
s := New(&config.Config{S3Endpoint: "https://acct.r2.cloudflarestorage.com",
|
||||
S3Region: "auto", S3Bucket: "pics", S3AccessKey: "k", S3SecretKey: "s"})
|
||||
req, err := s.s3Request(t.Context(), http.MethodPut, "9/12/a b.webp", []byte("x"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := req.URL.Path, "/pics/9/12/a b.webp"; got != want {
|
||||
t.Errorf("路径不对: %q(期望 %q)", got, want)
|
||||
}
|
||||
if got := req.Header.Get("x-amz-content-sha256"); got != sha256Hex([]byte("x")) {
|
||||
t.Errorf("内容哈希不对: %q", got)
|
||||
}
|
||||
if req.URL.Host != "acct.r2.cloudflarestorage.com" {
|
||||
t.Errorf("主机不对: %q", req.URL.Host)
|
||||
}
|
||||
// 签名后不应报错,且路径仍是「已解码」的原样路径(签名时再按段转义)
|
||||
s.sign(req, []byte("x"))
|
||||
if req.Header.Get("Authorization") == "" {
|
||||
t.Error("签名后没有 Authorization 头")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package notes 存放站长为地点加的别名与备注。
|
||||
// 备注按 rond 的地点 ID(src_pk)而非数据库自增 id 存储,
|
||||
// 因此重新上传备份、地点换了新 id 之后内容依然挂在原地。
|
||||
package notes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
SrcPK int
|
||||
Alias string
|
||||
Content string
|
||||
RondNote string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Store struct{ DB *sql.DB }
|
||||
|
||||
// All 返回某个数据集的全站备注(站点只有一个站长账号,读的时候不按 user_id 过滤,
|
||||
// 这样匿名访客也能看到别名;写的时候才需要登录用户的 id)。
|
||||
// 后写覆盖先写,保证同一地点只保留一条。必须按 dataset_id 过滤:
|
||||
// src_pk 只在单个数据集内唯一,跨数据集同编号是完全不同的地点。
|
||||
func (s *Store) All(ctx context.Context, datasetID int64) (map[int]Note, error) {
|
||||
out := map[int]Note{}
|
||||
rows, err := s.DB.QueryContext(ctx,
|
||||
`SELECT src_pk, alias, note, rond_note, updated_at FROM place_notes
|
||||
WHERE dataset_id=$1 ORDER BY updated_at DESC`, datasetID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var n Note
|
||||
if err := rows.Scan(&n.SrcPK, &n.Alias, &n.Content, &n.RondNote, &n.UpdatedAt); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out[n.SrcPK] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetNote 只改备注、保留别名(批量「清空备注」用);两者都为空则删除记录。
|
||||
func (s *Store) SetNote(ctx context.Context, userID, datasetID int64, srcPK int, content string) error {
|
||||
var alias string
|
||||
err := s.DB.QueryRowContext(ctx,
|
||||
`SELECT alias FROM place_notes WHERE user_id=$1 AND dataset_id=$2 AND src_pk=$3`,
|
||||
userID, datasetID, srcPK).Scan(&alias)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
return s.Save(ctx, userID, datasetID, srcPK, alias, content)
|
||||
}
|
||||
|
||||
// Delete 删除某地点的整条备注与别名。
|
||||
func (s *Store) Delete(ctx context.Context, userID, datasetID int64, srcPK int) error {
|
||||
_, err := s.DB.ExecContext(ctx,
|
||||
`DELETE FROM place_notes WHERE user_id=$1 AND dataset_id=$2 AND src_pk=$3`, userID, datasetID, srcPK)
|
||||
return err
|
||||
}
|
||||
|
||||
// Save 写入别名与备注;两者都为空时清空手写内容(rond 回填的那一块要留着)。
|
||||
func (s *Store) Save(ctx context.Context, userID, datasetID int64, srcPK int, alias, content string) error {
|
||||
alias = strings.TrimSpace(alias)
|
||||
content = strings.TrimSpace(content)
|
||||
if alias == "" && content == "" {
|
||||
// 没有 rond 备注时直接删行;有则只清空手写部分,别把回填结果一起删掉
|
||||
_, err := s.DB.ExecContext(ctx,
|
||||
`DELETE FROM place_notes WHERE user_id=$1 AND dataset_id=$2 AND src_pk=$3 AND rond_note=''`,
|
||||
userID, datasetID, srcPK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.DB.ExecContext(ctx,
|
||||
`UPDATE place_notes SET alias='', note='', updated_at=now()
|
||||
WHERE user_id=$1 AND dataset_id=$2 AND src_pk=$3`, userID, datasetID, srcPK)
|
||||
return err
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO place_notes (user_id, dataset_id, src_pk, alias, note, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,now())
|
||||
ON CONFLICT (user_id, dataset_id, src_pk)
|
||||
DO UPDATE SET alias=EXCLUDED.alias, note=EXCLUDED.note, updated_at=now()`,
|
||||
userID, datasetID, srcPK, alias, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// BackfillRond 把 rond 备份里按次到访记录的备注(ZVISIT.ZREMARK_)汇总进 rond_note:
|
||||
// 同一地点多条备注全部保留,去重后按时间升序、每条一行并带上日期。
|
||||
// 只重写 rond_note 一列,站长手写的 note 一概不动。返回写了多少行。
|
||||
func (s *Store) BackfillRond(ctx context.Context, userID, datasetID int64) (int, error) {
|
||||
// DISTINCT 在「日期 + 内容」这一层做,同一天重复写的同一句只留一条
|
||||
const agg = `
|
||||
WITH raw AS (
|
||||
SELECT p.src_pk, to_char(v.arrival, 'YYYY-MM-DD') || ' ' || btrim(v.remark) AS line
|
||||
FROM visits v JOIN places p ON p.id = v.place_id
|
||||
WHERE v.dataset_id = $1 AND btrim(COALESCE(v.remark, '')) <> ''
|
||||
), uniq AS (
|
||||
SELECT DISTINCT src_pk, line, left(line, 10) AS day FROM raw
|
||||
), agg AS (
|
||||
SELECT src_pk, string_agg(line, E'\n' ORDER BY day, line) AS txt FROM uniq GROUP BY src_pk
|
||||
)`
|
||||
res, err := s.DB.ExecContext(ctx, agg+`
|
||||
INSERT INTO place_notes (user_id, dataset_id, src_pk, alias, note, rond_note, updated_at)
|
||||
SELECT $2, $1, src_pk, '', '', txt, now() FROM agg
|
||||
ON CONFLICT (user_id, dataset_id, src_pk) DO UPDATE SET rond_note = EXCLUDED.rond_note`,
|
||||
datasetID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
|
||||
// 备注被删掉的地点要清掉旧值,否则会一直留着过期的 rond 内容。
|
||||
// 限定在本数据集的地点里:datasetID 不合法时子查询为空,一行都不会动,
|
||||
// 不会把别的数据集的回填结果误清掉。
|
||||
if _, err := s.DB.ExecContext(ctx, `
|
||||
UPDATE place_notes n SET rond_note = ''
|
||||
WHERE n.user_id = $2 AND n.dataset_id = $1 AND n.rond_note <> ''
|
||||
AND n.src_pk IN (SELECT src_pk FROM places WHERE dataset_id = $1)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM visits v JOIN places p ON p.id = v.place_id
|
||||
WHERE v.dataset_id = $1 AND p.src_pk = n.src_pk AND btrim(COALESCE(v.remark, '')) <> ''
|
||||
)`, datasetID, userID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(affected), nil
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package rond
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestBaselineKeepsCoreData 守住导出的地基:基线库必须完整保留 Core Data 的
|
||||
// 元数据表与索引、以及我们不落库的实体(ZRAWVISIT 等)。一旦有人重新改成
|
||||
// 「纯建表重建」,这里会立刻失败——那正是当初导出包 85KB、rond 提示恢复失败的原因。
|
||||
// 需要环境变量 ROND_SRC(原始 .rondbackup);未设置时跳过。
|
||||
func TestBaselineKeepsCoreData(t *testing.T) {
|
||||
src := os.Getenv("ROND_SRC")
|
||||
if src == "" {
|
||||
t.Skip("设置 ROND_SRC 后运行基线保真度检查")
|
||||
}
|
||||
base := filepath.Join(t.TempDir(), "baseline.sqlite")
|
||||
if err := WriteBaseline(src, base); err != nil {
|
||||
t.Fatalf("生成基线库失败: %v", err)
|
||||
}
|
||||
before := shapeOf(t, src)
|
||||
after := shapeOfFile(t, base)
|
||||
|
||||
if after.meta == 0 || after.metaPlist == 0 {
|
||||
t.Errorf("基线缺少 Z_METADATA / Z_PLIST,Core Data 会拒绝打开")
|
||||
}
|
||||
if after.modelCache == 0 {
|
||||
t.Errorf("基线缺少 Z_MODELCACHE")
|
||||
}
|
||||
if !reflect.DeepEqual(before.tables, after.tables) {
|
||||
t.Errorf("表集合不一致\n原: %v\n基: %v", before.tables, after.tables)
|
||||
}
|
||||
if !reflect.DeepEqual(before.indexes, after.indexes) {
|
||||
t.Errorf("索引集合不一致:缺 %v", diffList(before.indexes, after.indexes))
|
||||
}
|
||||
for _, kv := range [][3]any{
|
||||
{"ZRAWVISIT", before.rawVisit, after.rawVisit},
|
||||
{"ZKEYWORD", before.keyword, after.keyword},
|
||||
{"ZVISIT", before.visit, after.visit},
|
||||
{"ZLOCATION", before.location, after.location},
|
||||
} {
|
||||
if kv[1] != kv[2] {
|
||||
t.Errorf("%s 行数不一致: 原 %v vs 基 %v", kv[0], kv[1], kv[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sqliteShape 是「表/索引集合 + 关键表行数」,用于对比两个库的结构。
|
||||
type sqliteShape struct {
|
||||
tables []string
|
||||
indexes []string
|
||||
meta int
|
||||
metaPlist int
|
||||
modelCache int
|
||||
rawVisit int
|
||||
keyword int
|
||||
visit int
|
||||
location int
|
||||
}
|
||||
|
||||
func shapeOf(t *testing.T, archive string) sqliteShape {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := Open(archive, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("打开 %s 失败: %v", archive, err)
|
||||
}
|
||||
defer db.Close()
|
||||
return readShape(t, db)
|
||||
}
|
||||
|
||||
// shapeOfFile 与 shapeOf 相同,但直接读一个已解包的 .sqlite 文件(基线库形态)。
|
||||
func shapeOfFile(t *testing.T, path string) sqliteShape {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path))
|
||||
if err != nil {
|
||||
t.Fatalf("打开 %s 失败: %v", path, err)
|
||||
}
|
||||
defer db.Close()
|
||||
return readShape(t, db)
|
||||
}
|
||||
|
||||
func readShape(t *testing.T, db *sql.DB) sqliteShape {
|
||||
t.Helper()
|
||||
return sqliteShape{
|
||||
tables: queryNames(t, db, `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`),
|
||||
indexes: queryNames(t, db, `SELECT name FROM sqlite_master WHERE type='index' ORDER BY name`),
|
||||
meta: countRows(t, db, `SELECT count(*) FROM Z_METADATA`),
|
||||
metaPlist: countRows(t, db, `SELECT count(*) FROM Z_METADATA WHERE length(Z_PLIST)>0`),
|
||||
modelCache: countRows(t, db, `SELECT count(*) FROM Z_MODELCACHE`),
|
||||
rawVisit: countRows(t, db, `SELECT count(*) FROM ZRAWVISIT`),
|
||||
keyword: countRows(t, db, `SELECT count(*) FROM ZKEYWORD`),
|
||||
visit: countRows(t, db, `SELECT count(*) FROM ZVISIT`),
|
||||
location: countRows(t, db, `SELECT count(*) FROM ZLOCATION`),
|
||||
}
|
||||
}
|
||||
|
||||
func queryNames(t *testing.T, db *sql.DB, q string) []string {
|
||||
t.Helper()
|
||||
rows, err := db.Query(q)
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, db *sql.DB, q string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.QueryRow(q).Scan(&n); err != nil {
|
||||
t.Fatalf("查询失败 %s: %v", q, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func diffList(have, want []string) []string {
|
||||
set := map[string]bool{}
|
||||
for _, v := range want {
|
||||
set[v] = true
|
||||
}
|
||||
var out []string
|
||||
for _, v := range have {
|
||||
if !set[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestExportRoundtrip 用 rond 自己的 Open+Parse 反向解析「导出包」,与原始备份逐项比对,
|
||||
// 验证从 PostgreSQL 重建的 .rondbackup 与 rond 原始格式一致。
|
||||
// 需要环境变量 ROND_SRC(原始 .rondbackup)与 ROND_EXPORT(导出包);未设置时跳过。
|
||||
func TestExportRoundtrip(t *testing.T) {
|
||||
src := os.Getenv("ROND_SRC")
|
||||
exp := os.Getenv("ROND_EXPORT")
|
||||
if src == "" || exp == "" {
|
||||
t.Skip("设置 ROND_SRC 与 ROND_EXPORT 后运行往返对比")
|
||||
}
|
||||
a := parseForTest(t, src)
|
||||
c := parseForTest(t, exp)
|
||||
|
||||
na := countArrival(a)
|
||||
nc := countArrival(c)
|
||||
t.Logf("原始: visits(有到达)=%d places=%d tags=%d acts=%d moves=%d weather=%d rawvisits=%d",
|
||||
na, len(a.Locations), len(a.Tags), len(a.Activities), len(a.Movements), len(a.Weather), a.RawVisitNum)
|
||||
t.Logf("导出: visits(有到达)=%d places=%d tags=%d acts=%d moves=%d weather=%d rawvisits=%d",
|
||||
nc, len(c.Locations), len(c.Tags), len(c.Activities), len(c.Movements), len(c.Weather), c.RawVisitNum)
|
||||
|
||||
if na != nc {
|
||||
t.Errorf("到访数不一致: 原 %d vs 导 %d", na, nc)
|
||||
}
|
||||
if len(a.Locations) != len(c.Locations) {
|
||||
t.Errorf("地点数不一致: 原 %d vs 导 %d", len(a.Locations), len(c.Locations))
|
||||
}
|
||||
if len(a.Activities) != len(c.Activities) {
|
||||
t.Errorf("活动类型数不一致: 原 %d vs 导 %d", len(a.Activities), len(c.Activities))
|
||||
}
|
||||
if len(a.Tags) != len(c.Tags) {
|
||||
t.Errorf("标签数不一致: 原 %d vs 导 %d", len(a.Tags), len(c.Tags))
|
||||
}
|
||||
if len(a.Movements) != len(c.Movements) {
|
||||
t.Errorf("位移数不一致: 原 %d vs 导 %d", len(a.Movements), len(c.Movements))
|
||||
}
|
||||
if len(a.Weather) != len(c.Weather) {
|
||||
t.Errorf("天气数不一致: 原 %d vs 导 %d", len(a.Weather), len(c.Weather))
|
||||
}
|
||||
|
||||
// 逐条抽查时间与坐标是否一致(按 SrcPK 对齐)
|
||||
loc := map[int]Location{}
|
||||
for _, l := range a.Locations {
|
||||
loc[l.SrcPK] = l
|
||||
}
|
||||
maxDiff := 0.0
|
||||
for _, l := range c.Locations {
|
||||
o, ok := loc[l.SrcPK]
|
||||
if !ok {
|
||||
t.Errorf("导出多出地点 %d", l.SrcPK)
|
||||
continue
|
||||
}
|
||||
if d := absf(o.Lat - l.Lat); d > maxDiff {
|
||||
maxDiff = d
|
||||
}
|
||||
if d := absf(o.Lon - l.Lon); d > maxDiff {
|
||||
maxDiff = d
|
||||
}
|
||||
}
|
||||
t.Logf("坐标最大偏差: %.9f", maxDiff)
|
||||
if maxDiff > 1e-9 {
|
||||
t.Errorf("坐标出现偏差: %v", maxDiff)
|
||||
}
|
||||
|
||||
vm := map[int]Visit{}
|
||||
for _, v := range a.Visits {
|
||||
vm[v.SrcPK] = v
|
||||
}
|
||||
var maxTimeDelta time.Duration
|
||||
for _, v := range c.Visits {
|
||||
o, ok := vm[v.SrcPK]
|
||||
if !ok {
|
||||
t.Errorf("导出多出到访 %d", v.SrcPK)
|
||||
continue
|
||||
}
|
||||
if (o.Arrival == nil) != (v.Arrival == nil) {
|
||||
t.Errorf("到访 %d 到达时间有无不一致", v.SrcPK)
|
||||
continue
|
||||
}
|
||||
if o.Arrival == nil {
|
||||
continue
|
||||
}
|
||||
// Core Data 用 float64 存秒数,往返存在亚微秒级舍入,毫秒容差内视为一致
|
||||
d := o.Arrival.Sub(*v.Arrival)
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d > maxTimeDelta {
|
||||
maxTimeDelta = d
|
||||
}
|
||||
if d > time.Millisecond {
|
||||
t.Errorf("到访 %d 到达时间偏差过大: 原 %v vs 导 %v", v.SrcPK, o.Arrival, *v.Arrival)
|
||||
}
|
||||
}
|
||||
t.Logf("时间最大偏差: %v", maxTimeDelta)
|
||||
}
|
||||
|
||||
func parseForTest(t *testing.T, path string) *Backup {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := Open(path, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("打开 %s 失败: %v", path, err)
|
||||
}
|
||||
defer db.Close()
|
||||
b, err := Parse(db)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 %s 失败: %v", path, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func countArrival(b *Backup) int {
|
||||
n := 0
|
||||
for _, v := range b.Visits {
|
||||
if v.Arrival != nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestLastArrivalDateMatchesVisits 守住地点详情的「最近活跃时间」。
|
||||
// rond 直接显示 ZLOCATION.ZLASTARRIVALDATE_,它是该地点最晚一次到访到达时间的缓存:
|
||||
// 有到访却留空,rond 里就永远显示「无数据」;时间对不上,显示的就是过期值。
|
||||
// 需要环境变量 ROND_EXPORT(导出后的 .rondbackup);未设置时跳过。
|
||||
func TestLastArrivalDateMatchesVisits(t *testing.T) {
|
||||
exp := os.Getenv("ROND_EXPORT")
|
||||
if exp == "" {
|
||||
t.Skip("设置 ROND_EXPORT 后运行「最近活跃时间」一致性检查")
|
||||
}
|
||||
db, err := Open(exp, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("打开 %s 失败: %v", exp, err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(`SELECT l.Z_PK, COALESCE(l.ZNAME_,''), l.ZLASTARRIVALDATE_, v.mx
|
||||
FROM ZLOCATION l
|
||||
LEFT JOIN (SELECT ZLOCATION AS loc, max(ZARRIVALDATE_) mx FROM ZVISIT GROUP BY ZLOCATION) v
|
||||
ON v.loc = l.Z_PK`)
|
||||
if err != nil {
|
||||
t.Fatalf("查询地点: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
checked, unvisited := 0, 0
|
||||
for rows.Next() {
|
||||
var pk int
|
||||
var name string
|
||||
var last, mx sql.NullFloat64
|
||||
if err := rows.Scan(&pk, &name, &last, &mx); err != nil {
|
||||
t.Fatalf("读取地点: %v", err)
|
||||
}
|
||||
switch {
|
||||
case !mx.Valid:
|
||||
// 从未到访:rond 自己写的是 distantPast 哨兵或干脆不写,两种都行
|
||||
unvisited++
|
||||
case !last.Valid:
|
||||
t.Errorf("地点 %d(%s) 有到访记录,但 ZLASTARRIVALDATE_ 为空——rond 会显示「无数据」", pk, name)
|
||||
default:
|
||||
checked++
|
||||
// Core Data 用 float64 存秒数,毫秒容差内视为一致
|
||||
if d := absf(last.Float64 - mx.Float64); d > 1e-3 {
|
||||
t.Errorf("地点 %d(%s) 最近活跃时间与最新到访不符: 差 %.3f 秒", pk, name, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历地点: %v", err)
|
||||
}
|
||||
t.Logf("已核对 %d 个有到访的地点(另有 %d 个从未到访)", checked, unvisited)
|
||||
if checked == 0 {
|
||||
t.Error("没有核对到任何有到访的地点,样本可能不对")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUUIDv5 守住补 UUID 的确定性:同一条记录每次导出必须拿到同一个 UUID。
|
||||
// 若退化成随机,每次导出的包都会让 rond 把同一批记录当成新的。
|
||||
func TestUUIDv5(t *testing.T) {
|
||||
a, b := uuidV5("ZVISIT/42"), uuidV5("ZVISIT/42")
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Errorf("同名两次生成不一致: %x vs %x", a, b)
|
||||
}
|
||||
if len(a) != 16 {
|
||||
t.Fatalf("长度应为 16 字节,实际 %d", len(a))
|
||||
}
|
||||
if a[6]>>4 != 5 {
|
||||
t.Errorf("版本位应为 5,实际 %d", a[6]>>4)
|
||||
}
|
||||
if a[8]>>6 != 2 {
|
||||
t.Errorf("变体位应为 RFC 4122(10xx),实际 %d", a[8]>>6)
|
||||
}
|
||||
if bytes.Equal(a, uuidV5("ZVISIT/43")) {
|
||||
t.Error("不同记录不应拿到同一个 UUID")
|
||||
}
|
||||
if bytes.Equal(uuidV5("ZVISIT/42"), uuidV5("ZACTIVITY/42")) {
|
||||
t.Error("不同表不应拿到同一个 UUID")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgramRowsFillNativeColumns 守住「站点新建的行也要带上真机必有的列」。
|
||||
// ZLOCATION 的原始 GPS 与 ZVISIT/ZACTIVITY 的 UUID 在真机包里几乎 100% 有值
|
||||
// (实测 323/323、822/824、30/30),而站点这边没有对应字段,导出必须替新行补上,
|
||||
// 否则 rond 里就会缺东西——「最近活跃时间」显示「无数据」就是同一个坑。
|
||||
// 需要环境变量 ROND_SRC(原始包)与 ROND_EXPORT(导出包);未设置时跳过。
|
||||
func TestProgramRowsFillNativeColumns(t *testing.T) {
|
||||
src, exp := os.Getenv("ROND_SRC"), os.Getenv("ROND_EXPORT")
|
||||
if src == "" || exp == "" {
|
||||
t.Skip("设置 ROND_SRC 与 ROND_EXPORT 后运行原生列补全检查")
|
||||
}
|
||||
old, cur := openBackup(t, src), openBackup(t, exp)
|
||||
defer old.Close()
|
||||
defer cur.Close()
|
||||
|
||||
for _, c := range []struct{ table, col string }{
|
||||
{"ZLOCATION", "ZRAWLATITUDE"},
|
||||
{"ZLOCATION", "ZRAWLONGITUDE"},
|
||||
{"ZVISIT", "ZIDENTIFIER_"},
|
||||
{"ZACTIVITY", "ZUID_"},
|
||||
{"ZMOVEMENT", "ZTYPE_"},
|
||||
} {
|
||||
var base int
|
||||
if err := old.QueryRow(`SELECT COALESCE(max(Z_PK),0) FROM ` + c.table).Scan(&base); err != nil {
|
||||
t.Fatalf("读取 %s 基线最大主键: %v", c.table, err)
|
||||
}
|
||||
var total, missing int
|
||||
if err := cur.QueryRow(`SELECT count(*), COALESCE(sum(CASE WHEN `+c.col+` IS NULL THEN 1 ELSE 0 END),0)
|
||||
FROM `+c.table+` WHERE Z_PK > ?`, base).Scan(&total, &missing); err != nil {
|
||||
t.Fatalf("统计 %s.%s: %v", c.table, c.col, err)
|
||||
}
|
||||
if total == 0 {
|
||||
continue // 这一版没有新增行
|
||||
}
|
||||
if missing > 0 {
|
||||
t.Errorf("%s.%s:%d 个站点新建的行里有 %d 个为空(真机几乎全部有值)", c.table, c.col, total, missing)
|
||||
}
|
||||
t.Logf("%s.%s:新建 %d 行,全部有值", c.table, c.col, total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMovementTypeNeverNull 守住 ZMOVEMENT.ZTYPE_ 全表非空。
|
||||
// 真机 791/791 都有值(2=无交通方式/步行、5=有交通方式,另有少量 0/4),
|
||||
// 说明这个属性在 Core Data 里是非可选的——留 NULL 轻则显示异常,重则整包恢复失败。
|
||||
// 需要环境变量 ROND_EXPORT(导出包);未设置时跳过。
|
||||
func TestMovementTypeNeverNull(t *testing.T) {
|
||||
exp := os.Getenv("ROND_EXPORT")
|
||||
if exp == "" {
|
||||
t.Skip("设置 ROND_EXPORT 后运行位移类型非空检查")
|
||||
}
|
||||
db := openBackup(t, exp)
|
||||
defer db.Close()
|
||||
|
||||
var total, missing int
|
||||
if err := db.QueryRow(`SELECT count(*),
|
||||
COALESCE(sum(CASE WHEN ZTYPE_ IS NULL THEN 1 ELSE 0 END),0) FROM ZMOVEMENT`).
|
||||
Scan(&total, &missing); err != nil {
|
||||
t.Fatalf("统计 ZMOVEMENT.ZTYPE_: %v", err)
|
||||
}
|
||||
if total == 0 {
|
||||
t.Skip("导出包里没有位移记录")
|
||||
}
|
||||
if missing > 0 {
|
||||
t.Errorf("ZMOVEMENT 共 %d 条,其中 %d 条 ZTYPE_ 为空", total, missing)
|
||||
}
|
||||
t.Logf("ZMOVEMENT 共 %d 条,ZTYPE_ 全部有值", total)
|
||||
}
|
||||
|
||||
func openBackup(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := Open(path, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("打开 %s 失败: %v", path, err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func absf(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
// Package rond 负责解析 rond(iOS 应用 LifeEasy)导出的 .rondbackup 备份包。
|
||||
//
|
||||
// 备份包本质是 zip,内含 Core Data 的 SQLite 库 LifeEasy.sqlite 及同名 -wal / -shm。
|
||||
// 数据主要在 WAL 里,因此必须三个文件一起解出来再打开,否则会丢失绝大部分记录。
|
||||
package rond
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Core Data 时间戳基准:2001-01-01 UTC
|
||||
var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func appleTime(v sql.NullFloat64) *time.Time {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
// 秒的整数部分和小数部分分开算:8 亿量级的秒乘 1e6/1e9 已超出 float64 的
|
||||
// 精确整数范围(2^53),一次性相乘再取整会系统性吃掉最后 1 微秒,
|
||||
// 往返导出后时间戳就对不上了。
|
||||
sec := math.Floor(v.Float64)
|
||||
us := int64(sec)*1_000_000 + int64(math.Round((v.Float64-sec)*1e6))
|
||||
t := appleEpoch.Add(time.Duration(us) * time.Microsecond).Local()
|
||||
return &t
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
SrcPK int
|
||||
Name string
|
||||
Color string
|
||||
Icon string
|
||||
IsHome bool
|
||||
IsWork bool
|
||||
IsExcluded bool
|
||||
IsArchived bool
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
SrcPK int
|
||||
Name string
|
||||
Color string
|
||||
GroupName string
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
SrcPK int
|
||||
Name string
|
||||
POICategory string
|
||||
Lat float64
|
||||
Lon float64
|
||||
CountryCode string
|
||||
Province string
|
||||
City string
|
||||
District string
|
||||
Sublocality string
|
||||
Thoroughfare string
|
||||
SubThoroughfare string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type Visit struct {
|
||||
SrcPK int
|
||||
LocationSrcPK *int
|
||||
ActivitySrcPK *int
|
||||
Arrival *time.Time
|
||||
Departure *time.Time
|
||||
Bookmarked bool
|
||||
UserAdded bool
|
||||
Remark string
|
||||
Emoji string
|
||||
WeatherSymbol string
|
||||
TagSrcPKs []int
|
||||
}
|
||||
|
||||
type Movement struct {
|
||||
SrcPK int
|
||||
TransportSrc *int
|
||||
FromVisitSrc *int
|
||||
ToVisitSrc *int
|
||||
Start *time.Time
|
||||
End *time.Time
|
||||
}
|
||||
|
||||
type Weather struct {
|
||||
SrcPK int
|
||||
VisitSrc *int
|
||||
At *time.Time
|
||||
IsDaylight bool
|
||||
TempC *float64
|
||||
ApparentC *float64
|
||||
Humidity *float64
|
||||
PrecipAmt *float64
|
||||
PrecipPct *float64
|
||||
WindSpeed *float64
|
||||
Visibility *float64
|
||||
UVIndex *int
|
||||
Condition string
|
||||
Symbol string
|
||||
UVCategory string
|
||||
WindDir string
|
||||
}
|
||||
|
||||
type Transport struct {
|
||||
SrcPK int
|
||||
Name string
|
||||
Color string
|
||||
Icon string
|
||||
}
|
||||
|
||||
// CoreDataMeta 记录备份包里 Core Data 库的结构信息(实体编号、建表 DDL、关联表),
|
||||
// 作为数据集的结构快照留档;导出 rondbackup 已改为以「基线库」为底覆盖写入,不再依赖它。
|
||||
type CoreDataMeta struct {
|
||||
// 实体名(ZPRIMARYKEY.Z_NAME) -> 实体编号(Z_ENT) 与当前最大主键(Z_MAX)
|
||||
Entities map[string]EntityInfo `json:"entities"`
|
||||
// 表名 -> CREATE TABLE 语句(含全部列与默认值,导出只 INSERT 其中部分列)
|
||||
DDL map[string]string `json:"ddl"`
|
||||
// 访问↔标签多对多关联表(Core Data 自动生成的 Z_<tagEnt>VISITS_)
|
||||
JoinTable JoinTableInfo `json:"join_visit_tag"`
|
||||
}
|
||||
|
||||
type EntityInfo struct {
|
||||
Ent int `json:"ent"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
|
||||
type JoinTableInfo struct {
|
||||
Name string `json:"name"`
|
||||
VisitCol string `json:"visit_col"`
|
||||
TagCol string `json:"tag_col"`
|
||||
}
|
||||
|
||||
type Backup struct {
|
||||
Activities []Activity
|
||||
Tags []Tag
|
||||
Transports []Transport
|
||||
Locations []Location
|
||||
Visits []Visit
|
||||
Movements []Movement
|
||||
Weather []Weather
|
||||
RawVisitNum int
|
||||
TagGroups map[int]string
|
||||
Meta *CoreDataMeta
|
||||
// RawRows 是各实体在 Core Data 里的完整原始行:「表名 -> Z_PK -> 列名 -> 值」。
|
||||
// 站点只映射了其中一部分字段,留档让导出能逐列还原,不必依赖基线库补剩下的列。
|
||||
RawRows map[string]map[int]map[string]any
|
||||
}
|
||||
|
||||
// Open 解压备份包到 dir,返回可用的 SQLite 连接。
|
||||
// 调用方负责 Close 与删除 dir。
|
||||
func Open(archivePath, dir string) (*sql.DB, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zr, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("备份包不是有效的 zip: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
var dbPath string
|
||||
for _, f := range zr.File {
|
||||
base := filepath.Base(f.Name)
|
||||
if f.FileInfo().IsDir() || base == "" {
|
||||
continue
|
||||
}
|
||||
// 只取 Core Data 主库及其 WAL / SHM
|
||||
low := strings.ToLower(base)
|
||||
if !strings.HasSuffix(low, ".sqlite") && !strings.HasSuffix(low, ".sqlite-wal") && !strings.HasSuffix(low, ".sqlite-shm") {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(dir, base)
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return nil, err
|
||||
}
|
||||
_, cerr := io.Copy(out, rc)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
if strings.HasSuffix(low, ".sqlite") {
|
||||
dbPath = dst
|
||||
}
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("备份包内未找到 .sqlite 数据库文件")
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
// 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态
|
||||
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("合并 WAL 失败: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Parse 读取 Core Data 库,转换为领域模型。
|
||||
func Parse(db *sql.DB) (*Backup, error) {
|
||||
b := &Backup{TagGroups: map[int]string{}}
|
||||
var err error
|
||||
|
||||
// 标签分组
|
||||
rows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,'') FROM ZTAGGROUP`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取标签分组: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var pk int
|
||||
var name string
|
||||
if err = rows.Scan(&pk, &name); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
b.TagGroups[pk] = name
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// 标签(含所属分组关系 Z_11TAGS_ 的逆向:ZTAG.ZGROUP -> ZTAGGROUP)
|
||||
tagGroup := map[int]int{}
|
||||
grows, err := db.Query(`SELECT Z_PK, COALESCE(ZGROUP,0) FROM ZTAG`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取标签: %w", err)
|
||||
}
|
||||
for grows.Next() {
|
||||
var pk, g int
|
||||
if err = grows.Scan(&pk, &g); err != nil {
|
||||
grows.Close()
|
||||
return nil, err
|
||||
}
|
||||
tagGroup[pk] = g
|
||||
}
|
||||
grows.Close()
|
||||
|
||||
trows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTAG`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取标签: %w", err)
|
||||
}
|
||||
for trows.Next() {
|
||||
var t Tag
|
||||
if err = trows.Scan(&t.SrcPK, &t.Name, &t.Color); err != nil {
|
||||
trows.Close()
|
||||
return nil, err
|
||||
}
|
||||
t.GroupName = b.TagGroups[tagGroup[t.SrcPK]]
|
||||
b.Tags = append(b.Tags, t)
|
||||
}
|
||||
trows.Close()
|
||||
|
||||
// 类型 / 活动
|
||||
arows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,''),
|
||||
COALESCE(ZISHOME,0), COALESCE(ZISWORK,0), COALESCE(ZISEXCLUDED,0), COALESCE(ZISARCHIVED,0) FROM ZACTIVITY`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取活动类型: %w", err)
|
||||
}
|
||||
for arows.Next() {
|
||||
var a Activity
|
||||
var h, w, e, ar int
|
||||
if err = arows.Scan(&a.SrcPK, &a.Name, &a.Color, &a.Icon, &h, &w, &e, &ar); err != nil {
|
||||
arows.Close()
|
||||
return nil, err
|
||||
}
|
||||
a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived = h == 1, w == 1, e == 1, ar == 1
|
||||
b.Activities = append(b.Activities, a)
|
||||
}
|
||||
arows.Close()
|
||||
|
||||
// 交通方式
|
||||
trrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,'') FROM ZTRANSPORT`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取交通方式: %w", err)
|
||||
}
|
||||
for trrows.Next() {
|
||||
var t Transport
|
||||
if err = trrows.Scan(&t.SrcPK, &t.Name, &t.Color, &t.Icon); err != nil {
|
||||
trrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
b.Transports = append(b.Transports, t)
|
||||
}
|
||||
trrows.Close()
|
||||
|
||||
// 地点
|
||||
lrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCATEGORY_,''),
|
||||
COALESCE(ZLATITUDE,0), COALESCE(ZLONGITUDE,0),
|
||||
COALESCE(ZISOCOUNTRYCODE,''), COALESCE(ZADMINISTRATIVEAREA,''), COALESCE(ZLOCALITY,''),
|
||||
COALESCE(ZSUBLOCALITY,''), COALESCE(ZTHOROUGHFARE,''), COALESCE(ZSUBTHOROUGHFARE,''),
|
||||
COALESCE(ZTIMEZONE,'') FROM ZLOCATION`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取地点: %w", err)
|
||||
}
|
||||
for lrows.Next() {
|
||||
var l Location
|
||||
if err = lrows.Scan(&l.SrcPK, &l.Name, &l.POICategory, &l.Lat, &l.Lon,
|
||||
&l.CountryCode, &l.Province, &l.City, &l.Sublocality, &l.Thoroughfare,
|
||||
&l.SubThoroughfare, &l.Timezone); err != nil {
|
||||
lrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
b.Locations = append(b.Locations, l)
|
||||
}
|
||||
lrows.Close()
|
||||
|
||||
// 访问记录
|
||||
vrows, err := db.Query(`SELECT Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_,
|
||||
COALESCE(ZBOOKMARKED,0), COALESCE(ZUSERADDED,0), COALESCE(ZREMARK_,''),
|
||||
COALESCE(ZEMOJINAME_,''), COALESCE(ZWEATHERSYMBOL_,'') FROM ZVISIT`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取访问记录: %w", err)
|
||||
}
|
||||
visitIdx := map[int]int{}
|
||||
for vrows.Next() {
|
||||
var v Visit
|
||||
var loc, act sql.NullInt64
|
||||
var arr, dep sql.NullFloat64
|
||||
var bm, ua int
|
||||
if err = vrows.Scan(&v.SrcPK, &loc, &act, &arr, &dep, &bm, &ua,
|
||||
&v.Remark, &v.Emoji, &v.WeatherSymbol); err != nil {
|
||||
vrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if loc.Valid {
|
||||
pk := int(loc.Int64)
|
||||
v.LocationSrcPK = &pk
|
||||
}
|
||||
if act.Valid {
|
||||
pk := int(act.Int64)
|
||||
v.ActivitySrcPK = &pk
|
||||
}
|
||||
v.Arrival = appleTime(arr)
|
||||
v.Departure = appleTime(dep)
|
||||
v.Bookmarked, v.UserAdded = bm == 1, ua == 1
|
||||
visitIdx[v.SrcPK] = len(b.Visits)
|
||||
b.Visits = append(b.Visits, v)
|
||||
}
|
||||
vrows.Close()
|
||||
|
||||
// 访问 → 标签
|
||||
vtrows, err := db.Query(`SELECT Z_18VISITS_, Z_11TAGS_5 FROM Z_11VISITS_`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取访问标签: %w", err)
|
||||
}
|
||||
for vtrows.Next() {
|
||||
var vid, tid int
|
||||
if err = vtrows.Scan(&vid, &tid); err != nil {
|
||||
vtrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if i, ok := visitIdx[vid]; ok {
|
||||
b.Visits[i].TagSrcPKs = append(b.Visits[i].TagSrcPKs, tid)
|
||||
}
|
||||
}
|
||||
vtrows.Close()
|
||||
|
||||
// 位移
|
||||
mrows, err := db.Query(`SELECT Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_ FROM ZMOVEMENT`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取位移记录: %w", err)
|
||||
}
|
||||
for mrows.Next() {
|
||||
var m Movement
|
||||
var tr, from, to sql.NullInt64
|
||||
var st, en sql.NullFloat64
|
||||
if err = mrows.Scan(&m.SrcPK, &tr, &from, &to, &st, &en); err != nil {
|
||||
mrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if tr.Valid {
|
||||
pk := int(tr.Int64)
|
||||
m.TransportSrc = &pk
|
||||
}
|
||||
if from.Valid {
|
||||
pk := int(from.Int64)
|
||||
m.FromVisitSrc = &pk
|
||||
}
|
||||
if to.Valid {
|
||||
pk := int(to.Int64)
|
||||
m.ToVisitSrc = &pk
|
||||
}
|
||||
m.Start = appleTime(st)
|
||||
m.End = appleTime(en)
|
||||
b.Movements = append(b.Movements, m)
|
||||
}
|
||||
mrows.Close()
|
||||
|
||||
// 小时级天气
|
||||
wrows, err := db.Query(`SELECT Z_PK, ZVISIT, ZDATE_, COALESCE(ZISDAYLIGHT,0),
|
||||
ZTEMPERATURE_, ZAPPARENTTEMPERATURE_, ZHUMIDITY_, ZPRECIPITATIONAMOUNT_,
|
||||
ZPRECIPITATIONCHANCE_, ZWINDSPEED_, ZVISIBILITY_, ZUVINDEXVALUE_,
|
||||
COALESCE(ZCONDITION_,''), COALESCE(ZSYMBOLNAME_,''), COALESCE(ZUVINDEXCATEGORY_,''),
|
||||
COALESCE(ZWINDCOMPASSDIRECTION_,'') FROM ZHOURLYWEATHER`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取天气记录: %w", err)
|
||||
}
|
||||
for wrows.Next() {
|
||||
var w Weather
|
||||
var vis sql.NullInt64
|
||||
var at sql.NullFloat64
|
||||
var dl int
|
||||
var temp, app, hum, pa, pc, ws, vv sql.NullFloat64
|
||||
var uv sql.NullInt64
|
||||
if err = wrows.Scan(&w.SrcPK, &vis, &at, &dl, &temp, &app, &hum, &pa, &pc, &ws, &vv, &uv,
|
||||
&w.Condition, &w.Symbol, &w.UVCategory, &w.WindDir); err != nil {
|
||||
wrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if vis.Valid {
|
||||
pk := int(vis.Int64)
|
||||
w.VisitSrc = &pk
|
||||
}
|
||||
w.At = appleTime(at)
|
||||
w.IsDaylight = dl == 1
|
||||
w.TempC = fptr(temp)
|
||||
w.ApparentC = fptr(app)
|
||||
w.Humidity = fptr(hum)
|
||||
w.PrecipAmt = fptr(pa)
|
||||
w.PrecipPct = fptr(pc)
|
||||
w.WindSpeed = fptr(ws)
|
||||
w.Visibility = fptr(vv)
|
||||
if uv.Valid {
|
||||
n := int(uv.Int64)
|
||||
w.UVIndex = &n
|
||||
}
|
||||
b.Weather = append(b.Weather, w)
|
||||
}
|
||||
wrows.Close()
|
||||
|
||||
if err := db.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&b.RawVisitNum); err != nil {
|
||||
b.RawVisitNum = 0
|
||||
}
|
||||
|
||||
// 抓取 Core Data 结构元信息(实体编号 / 建表 DDL / 关联表),作为结构快照留档。
|
||||
// 失败不致命:旧备份可能没有这些表。
|
||||
if meta, merr := captureMeta(db); merr == nil {
|
||||
b.Meta = meta
|
||||
} else {
|
||||
log.Printf("抓取 Core Data 元信息失败: %v", merr)
|
||||
}
|
||||
// 整行留档:导出时逐列还原,不丢任何 rond 自己写的字段。
|
||||
if raw, rerr := captureRawRows(db); rerr == nil {
|
||||
b.RawRows = raw
|
||||
} else {
|
||||
log.Printf("抓取原始行留档失败(导出将退回基线补列): %v", rerr)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// rawTables 是需要整行留档的 Core Data 表(站点管理的实体 + 标签分组)。
|
||||
var rawTables = []string{"ZLOCATION", "ZVISIT", "ZMOVEMENT", "ZHOURLYWEATHER", "ZACTIVITY", "ZTAG", "ZTAGGROUP", "ZTRANSPORT"}
|
||||
|
||||
// captureRawRows 读出上表每一行的全部列,供导出逐列还原。
|
||||
// BLOB 编码成 {"$blob": base64}(JSON 没有字节串类型),时间戳维持 Core Data 浮点秒原值。
|
||||
func captureRawRows(db *sql.DB) (map[string]map[int]map[string]any, error) {
|
||||
out := map[string]map[int]map[string]any{}
|
||||
for _, t := range rawTables {
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, t).Scan(&n); err != nil || n == 0 {
|
||||
continue // 旧备份可能没这张表
|
||||
}
|
||||
cols, err := queryStrings(context.Background(), db, fmt.Sprintf("SELECT name FROM pragma_table_info('%s')", t))
|
||||
if err != nil || len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
quoted := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
quoted[i] = `"` + strings.ReplaceAll(c, `"`, `""`) + `"`
|
||||
}
|
||||
rows, err := db.Query(fmt.Sprintf("SELECT %s FROM %s", strings.Join(quoted, ","), t))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 %s 原始行: %w", t, err)
|
||||
}
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
m := map[int]map[string]any{}
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
var pk int
|
||||
row := make(map[string]any, len(cols))
|
||||
for i, c := range cols {
|
||||
if c == "Z_PK" {
|
||||
if v, ok := vals[i].(int64); ok {
|
||||
pk = int(v)
|
||||
}
|
||||
}
|
||||
row[c] = jsonSafe(vals[i])
|
||||
}
|
||||
if pk > 0 {
|
||||
m[pk] = row
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[t] = m
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsonSafe 把驱动值换成能被 JSON 无损表达的形式。
|
||||
func jsonSafe(v any) any {
|
||||
if b, ok := v.([]byte); ok {
|
||||
return map[string]any{"$blob": base64.StdEncoding.EncodeToString(b)}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// WriteBaseline 把备份包内的 Core Data 库另存为一份「单文件基线」,供导出时作为底稿。
|
||||
//
|
||||
// 为什么要有基线:rond 是 Core Data 应用,库里有 Z_METADATA(模型版本哈希)、
|
||||
// Z_MODELCACHE、持久化历史表(ACHANGE/ATRANSACTION*)、大量索引,以及我们并不落库的实体
|
||||
// (ZRAWVISIT 原始 GPS 采样点、ZKEYWORD、ZTRIP* 等)。仅凭抓到的建表语句重建只能得到
|
||||
// 一个「空壳」,Core Data 会判定与模型不兼容而拒绝打开。以基线为底、只覆盖我们管理的
|
||||
// 那几张实体表,上述内容就都能原样保留。
|
||||
//
|
||||
// 备份包里数据主要在 WAL 中,所以这里先按 Open 的方式解包并合并 WAL,再用
|
||||
// VACUUM INTO 导出一份自洽的单文件副本(不依赖 -wal/-shm)。
|
||||
func WriteBaseline(archivePath, dst string) error {
|
||||
dir, err := os.MkdirTemp("", "rond-base-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
db, err := Open(archivePath, dir) // Open 内部已做 wal_checkpoint(TRUNCATE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(dst)
|
||||
// VACUUM INTO 需要字面量路径(不用参数绑定),单引号按 SQL 规则转义
|
||||
lit := strings.ReplaceAll(filepath.ToSlash(dst), "'", "''")
|
||||
if _, err := db.Exec("VACUUM INTO '" + lit + "'"); err != nil {
|
||||
// 退路:主库在 Open 里已合并过 WAL,直接复制也自洽
|
||||
src := filepath.Join(dir, "LifeEasy.sqlite")
|
||||
if cerr := copyFile(src, dst); cerr != nil {
|
||||
return fmt.Errorf("生成基线库失败: %v(VACUUM INTO 亦失败: %w)", cerr, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst 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()
|
||||
}
|
||||
|
||||
// captureMeta 读取 ZPRIMARYKEY(实体编号与最大主键)与所需的 Z* 建表 DDL,
|
||||
// 并定位「访问↔标签」关联表及其两列。作为数据集上的 Core Data 结构快照留档。
|
||||
func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
|
||||
m := &CoreDataMeta{Entities: map[string]EntityInfo{}, DDL: map[string]string{}}
|
||||
rows, err := db.Query(`SELECT COALESCE(Z_ENT,0), COALESCE(Z_NAME,''), COALESCE(Z_MAX,0) FROM Z_PRIMARYKEY`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 Z_PRIMARYKEY: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var ent, max int
|
||||
var name string
|
||||
if err := rows.Scan(&ent, &name, &max); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if name != "" {
|
||||
m.Entities[name] = EntityInfo{Ent: ent, Max: max}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
drows, err := db.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE 'Z%' AND sql IS NOT NULL`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取建表 DDL: %w", err)
|
||||
}
|
||||
for drows.Next() {
|
||||
var name, ddl string
|
||||
if err := drows.Scan(&name, &ddl); err != nil {
|
||||
drows.Close()
|
||||
return nil, err
|
||||
}
|
||||
m.DDL[name] = ddl
|
||||
}
|
||||
drows.Close()
|
||||
|
||||
// 访问↔标签关联表:表名形如 Z_<tagEnt>VISITS_,两列分别指向 Visit / Tag 实体。
|
||||
visitEnt, okV := m.Entities["Visit"]
|
||||
tagEnt, okT := m.Entities["Tag"]
|
||||
if okV && okT {
|
||||
for tname, ddl := range m.DDL {
|
||||
if !strings.HasPrefix(tname, "Z_") || !strings.HasSuffix(tname, "VISITS_") {
|
||||
continue
|
||||
}
|
||||
_ = ddl
|
||||
crows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tname))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var vcol, tcol string
|
||||
for crows.Next() {
|
||||
var cid int
|
||||
var cname, ctype string
|
||||
var notnull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := crows.Scan(&cid, &cname, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
crows.Close()
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cname, fmt.Sprintf("Z_%dVISITS", visitEnt.Ent)) {
|
||||
vcol = cname
|
||||
}
|
||||
if strings.Contains(cname, fmt.Sprintf("Z_%dTAGS", tagEnt.Ent)) {
|
||||
tcol = cname
|
||||
}
|
||||
}
|
||||
crows.Close()
|
||||
if vcol != "" && tcol != "" {
|
||||
m.JoinTable = JoinTableInfo{Name: tname, VisitCol: vcol, TagCol: tcol}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func fptr(v sql.NullFloat64) *float64 {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
f := v.Float64
|
||||
return &f
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Settings 站点级配置,管理员可在后台调整。
|
||||
type Settings struct {
|
||||
SiteTitle string `json:"site_title"`
|
||||
SiteSubtitle string `json:"site_subtitle"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
OwnerAvatar string `json:"owner_avatar"`
|
||||
OwnerBio string `json:"owner_bio"`
|
||||
Modules []string `json:"modules"`
|
||||
// Pages 是对访客开放的页面;管理员始终可以访问全部页面
|
||||
Pages []string `json:"pages"`
|
||||
// Regions 是访客的区域限制列表(省 / 市 / 区任意层级);
|
||||
// 空表示不限制,管理员始终看到全部数据。方向由 RegionsMode 决定。
|
||||
Regions []string `json:"regions"`
|
||||
// RegionsMode 决定 Regions 是白名单还是黑名单:
|
||||
// whitelist = 访客只看得到命中这些区域的记录;blacklist = 命中这些区域的记录对访客隐藏。
|
||||
RegionsMode string `json:"regions_mode"`
|
||||
HideHome bool `json:"hide_home"`
|
||||
HideWork bool `json:"hide_work"`
|
||||
// NotesPublic 决定地点备注是否对访客展示;默认只站长自己可见
|
||||
NotesPublic bool `json:"notes_public"`
|
||||
// MarksPublic 决定「推荐 / 踩雷」标记与票数是否对访客展示;默认公开
|
||||
MarksPublic bool `json:"marks_public"`
|
||||
MinDwellMin int `json:"min_dwell_min"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
// VisitorHome 是访客打开站点根路径时的落地页;VisitorHomeMain 表示就停在主页。
|
||||
// 站长自己访问根路径始终看主页,不受这里影响。
|
||||
VisitorHome string `json:"visitor_home"`
|
||||
// MapDefaultBase 是地图默认铺哪张底图,取值是底图图层的 Key(如 amap-roadnet)。
|
||||
// 空 = 用第一张。点位密集时换成「纯路网」可以去掉底图自带的 POI 与路名标注,
|
||||
// 自己的点看得更清楚;访客在图上手动切换过的话,浏览器会记住他自己的选择。
|
||||
MapDefaultBase string `json:"map_default_base"`
|
||||
// MediaBackend 是地点图片存哪:local(服务器磁盘)或 s3(S3 兼容对象存储,如 R2)。
|
||||
// 对象存储的凭据在 conf/app.ini 里(s3_*),不进数据库。
|
||||
MediaBackend string `json:"media_backend"`
|
||||
}
|
||||
|
||||
// 地点图片的两种存储后端。
|
||||
const (
|
||||
MediaBackendLocal = "local"
|
||||
MediaBackendS3 = "s3"
|
||||
)
|
||||
|
||||
// VisitorHomeMain 表示访客落地到主页本身。
|
||||
const VisitorHomeMain = "home"
|
||||
|
||||
// Regions 的两种方向。
|
||||
const (
|
||||
RegionsWhitelist = "whitelist"
|
||||
RegionsBlacklist = "blacklist"
|
||||
)
|
||||
|
||||
// AllModules 主页可开关的区块。
|
||||
var AllModules = []Module{
|
||||
{Key: "overview", Name: "数据总览", Desc: "去过的地方、城市、天数、总里程等核心数字"},
|
||||
{Key: "map", Name: "足迹地图", Desc: "所有去过的地点在地图上的分布"},
|
||||
{Key: "types", Name: "类型分布", Desc: "按「餐厅 / 商场 / 景点」等类型统计"},
|
||||
{Key: "cities", Name: "城市排行", Desc: "按城市汇总到访次数与停留时长"},
|
||||
{Key: "timeline", Name: "最近足迹", Desc: "最新若干条到访记录"},
|
||||
{Key: "heatmap", Name: "活跃日历", Desc: "记录热度按天分布"},
|
||||
{Key: "transport", Name: "出行方式", Desc: "各类交通方式的次数与里程"},
|
||||
{Key: "hourly", Name: "作息分布", Desc: "到访时间在一天中的分布"},
|
||||
{Key: "tags", Name: "我的标签", Desc: "自定义标签的统计"},
|
||||
{Key: "weather", Name: "天气足迹", Desc: "记录期间的气温与天气"},
|
||||
}
|
||||
|
||||
// AllPages 可控制访客可见性的页面。
|
||||
var AllPages = []Module{
|
||||
{Key: "map", Name: "足迹地图", Desc: "交互式地图与行程轨迹"},
|
||||
{Key: "places", Name: "地点库", Desc: "全部地点的列表与详情"},
|
||||
{Key: "timeline", Name: "时间线", Desc: "按天浏览到访记录"},
|
||||
{Key: "stats", Name: "数据统计", Desc: "各维度统计图表与迷雾覆盖"},
|
||||
{Key: "report", Name: "年度报告", Desc: "按年份汇总的足迹报告"},
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
Key string
|
||||
Name string
|
||||
Desc string
|
||||
}
|
||||
|
||||
func Default() Settings {
|
||||
mods := make([]string, 0, len(AllModules))
|
||||
for _, m := range AllModules {
|
||||
mods = append(mods, m.Key)
|
||||
}
|
||||
pages := make([]string, 0, len(AllPages))
|
||||
for _, p := range AllPages {
|
||||
pages = append(pages, p.Key)
|
||||
}
|
||||
return Settings{
|
||||
SiteTitle: "我的足迹地图",
|
||||
SiteSubtitle: "记录我去过的每一个地方",
|
||||
OwnerName: "站长",
|
||||
OwnerAvatar: "🐾",
|
||||
OwnerBio: "",
|
||||
Modules: mods,
|
||||
Pages: pages,
|
||||
// 「家」和「工作」暴露的是常住地,默认不对访客展示
|
||||
HideHome: true,
|
||||
HideWork: true,
|
||||
// 「推荐 / 踩雷」属于评价类信息,默认对访客公开
|
||||
MarksPublic: true,
|
||||
// 区域限制默认按黑名单:列出要藏起来的区域,其余照常对访客开放
|
||||
RegionsMode: RegionsBlacklist,
|
||||
MinDwellMin: 0,
|
||||
AccentColor: "#2f7d6e",
|
||||
// 默认所有人都从主页进;想让人一进来就看到地图时再改
|
||||
VisitorHome: VisitorHomeMain,
|
||||
// 图片默认存服务器本地磁盘;配好 R2 之后再在后台切过去
|
||||
MediaBackend: MediaBackendLocal,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Settings) Has(module string) bool {
|
||||
for _, m := range s.Modules {
|
||||
if m == module {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPage 报告某页面是否对访客开放。
|
||||
func (s Settings) HasPage(page string) bool {
|
||||
for _, p := range s.Pages {
|
||||
if p == page {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VisitorHomePath 返回访客访问根路径时应跳转到的路径,空串表示不跳。
|
||||
//
|
||||
// 只在目标页面已对访客开放时才返回路径:否则相当于把访客送到一个他自己看不到的
|
||||
// 页面(那里会 404),比留在主页更糟。
|
||||
func (s Settings) VisitorHomePath() string {
|
||||
if s.VisitorHome == "" || s.VisitorHome == VisitorHomeMain {
|
||||
return ""
|
||||
}
|
||||
for _, p := range AllPages {
|
||||
if p.Key == s.VisitorHome && s.HasPage(p.Key) {
|
||||
return "/" + p.Key
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// NormalizeVisitorHome 把表单值收敛到合法取值(主页或 AllPages 里的页)。
|
||||
// 表单被改坏时回落到主页——这是个「跳错地方」比不跳更糟的开关。
|
||||
func NormalizeVisitorHome(v string) string {
|
||||
for _, p := range AllPages {
|
||||
if p.Key == v {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return VisitorHomeMain
|
||||
}
|
||||
|
||||
// RegionBlacklist 报告 Regions 是黑名单(命中即对访客隐藏)还是白名单(命中才放行)。
|
||||
func (s Settings) RegionBlacklist() bool { return s.RegionsMode == RegionsBlacklist }
|
||||
|
||||
type Store struct{ DB *sql.DB }
|
||||
|
||||
func (st *Store) Load(ctx context.Context) (Settings, error) {
|
||||
cur := Default()
|
||||
var raw []byte
|
||||
err := st.DB.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='site'`).Scan(&raw)
|
||||
if err == sql.ErrNoRows {
|
||||
return cur, nil
|
||||
}
|
||||
if err != nil {
|
||||
return cur, err
|
||||
}
|
||||
// 先看原始 JSON 里出现过哪些键:布尔项的默认值是 true,
|
||||
// 直接用零值覆盖会让「历史配置里没这一项」被误判成「显式关掉了」。
|
||||
var present map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &present); err != nil {
|
||||
return cur, fmt.Errorf("站点设置解析失败: %w", err)
|
||||
}
|
||||
var saved Settings
|
||||
if err := json.Unmarshal(raw, &saved); err != nil {
|
||||
return cur, fmt.Errorf("站点设置解析失败: %w", err)
|
||||
}
|
||||
// 逐项覆盖,保证新增字段拿到默认值
|
||||
if saved.SiteTitle != "" {
|
||||
cur.SiteTitle = saved.SiteTitle
|
||||
}
|
||||
if saved.SiteSubtitle != "" {
|
||||
cur.SiteSubtitle = saved.SiteSubtitle
|
||||
}
|
||||
if saved.OwnerName != "" {
|
||||
cur.OwnerName = saved.OwnerName
|
||||
}
|
||||
if saved.OwnerAvatar != "" {
|
||||
cur.OwnerAvatar = saved.OwnerAvatar
|
||||
}
|
||||
cur.OwnerBio = saved.OwnerBio
|
||||
if saved.Modules != nil {
|
||||
cur.Modules = saved.Modules
|
||||
}
|
||||
// pages 键出现即视为显式配置:空数组表示站长明确关闭了所有页面
|
||||
if _, ok := present["pages"]; ok {
|
||||
cur.Pages = saved.Pages
|
||||
}
|
||||
if _, ok := present["regions"]; ok {
|
||||
cur.Regions = saved.Regions
|
||||
}
|
||||
// regions_mode 是后加的键,而它出现之前 regions 一直是白名单。
|
||||
// 缺键时若直接套用新默认值(黑名单),历史配置里填过的白名单会静默反转成黑名单
|
||||
// —— 本该放行的区域全被藏起来,所以缺键一律按白名单解释。
|
||||
if _, ok := present["regions_mode"]; ok && saved.RegionsMode == RegionsBlacklist {
|
||||
cur.RegionsMode = RegionsBlacklist
|
||||
} else {
|
||||
cur.RegionsMode = RegionsWhitelist
|
||||
}
|
||||
if _, ok := present["hide_home"]; ok {
|
||||
cur.HideHome = saved.HideHome
|
||||
}
|
||||
if _, ok := present["hide_work"]; ok {
|
||||
cur.HideWork = saved.HideWork
|
||||
}
|
||||
if _, ok := present["notes_public"]; ok {
|
||||
cur.NotesPublic = saved.NotesPublic
|
||||
}
|
||||
if _, ok := present["marks_public"]; ok {
|
||||
cur.MarksPublic = saved.MarksPublic
|
||||
}
|
||||
if saved.VisitorHome != "" {
|
||||
cur.VisitorHome = NormalizeVisitorHome(saved.VisitorHome)
|
||||
}
|
||||
// 空串是合法值(= 跟随第一张),所以直接赋值,不做非空判断
|
||||
cur.MapDefaultBase = saved.MapDefaultBase
|
||||
if saved.MediaBackend == MediaBackendS3 {
|
||||
cur.MediaBackend = MediaBackendS3
|
||||
} else {
|
||||
cur.MediaBackend = MediaBackendLocal
|
||||
}
|
||||
cur.MinDwellMin = saved.MinDwellMin
|
||||
if saved.AccentColor != "" {
|
||||
cur.AccentColor = saved.AccentColor
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
func (st *Store) Save(ctx context.Context, s Settings) error {
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = st.DB.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES ('site', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value=$1, updated_at=now()`, raw)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
func TestVisitorHomePath(t *testing.T) {
|
||||
full := Default() // Pages 默认全开
|
||||
|
||||
noMap := full
|
||||
noMap.Pages = []string{"places", "stats"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
set Settings
|
||||
want string
|
||||
}{
|
||||
{"默认停在主页", full, ""},
|
||||
{"空值(历史配置)停在主页", func() Settings { s := full; s.VisitorHome = ""; return s }(), ""},
|
||||
{"显式主页", func() Settings { s := full; s.VisitorHome = "home"; return s }(), ""},
|
||||
{"跳到地图", func() Settings { s := full; s.VisitorHome = "map"; return s }(), "/map"},
|
||||
{"跳到统计", func() Settings { s := full; s.VisitorHome = "stats"; return s }(), "/stats"},
|
||||
{"目标页没对访客开放则不跳", noMap, ""},
|
||||
{"非法值不跳", func() Settings { s := full; s.VisitorHome = "javascript:alert(1)"; return s }(), ""},
|
||||
{"不给访客任何页面时不跳", func() Settings { s := full; s.Pages = nil; s.VisitorHome = "map"; return s }(), ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.set.VisitorHomePath(); got != c.want {
|
||||
t.Errorf("%s: VisitorHomePath = %q,期望 %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVisitorHome(t *testing.T) {
|
||||
for _, v := range []string{"", "home", "nope", "/map", "../admin", "MAP"} {
|
||||
if got := NormalizeVisitorHome(v); got != VisitorHomeMain {
|
||||
t.Errorf("NormalizeVisitorHome(%q) = %q,期望 %q", v, got, VisitorHomeMain)
|
||||
}
|
||||
}
|
||||
for _, p := range AllPages {
|
||||
if got := NormalizeVisitorHome(p.Key); got != p.Key {
|
||||
t.Errorf("NormalizeVisitorHome(%q) = %q,期望原样保留", p.Key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisitorHomeStore 覆盖存取:旧配置(没有该键)必须回落到主页,
|
||||
// 而不是把零值当成「访客落地到空路径」。
|
||||
// 只在库名带 test 的库上跑——它会改写 settings 里的 site 键,不能碰正式库。
|
||||
func TestVisitorHomeStore(t *testing.T) {
|
||||
dsn := os.Getenv("ROND_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 ROND_DSN")
|
||||
}
|
||||
if !strings.Contains(dsn, "test") {
|
||||
t.Skipf("库名不含 test,跳过以免动到正式站的设置:%s", dsn)
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
st := Store{DB: db}
|
||||
|
||||
var orig []byte
|
||||
had := true
|
||||
if err := db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='site'`).Scan(&orig); err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
t.Fatal(err)
|
||||
}
|
||||
had = false
|
||||
}
|
||||
defer func() { // 还原原配置,别把测试库的设置留下改动
|
||||
if had {
|
||||
db.ExecContext(ctx, `UPDATE settings SET value=$1 WHERE key='site'`, orig)
|
||||
} else {
|
||||
db.ExecContext(ctx, `DELETE FROM settings WHERE key='site'`)
|
||||
}
|
||||
}()
|
||||
|
||||
set := func(raw string) {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES ('site', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value=$1`, []byte(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧配置:整个键都不存在 → 默认停在主页
|
||||
set(`{"pages":["map","stats"]}`)
|
||||
if s, err := st.Load(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if s.VisitorHome != VisitorHomeMain {
|
||||
t.Errorf("缺键时 VisitorHome = %q,期望 %q", s.VisitorHome, VisitorHomeMain)
|
||||
}
|
||||
|
||||
// 正常往返
|
||||
s, err := st.Load(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.VisitorHome = "map"
|
||||
if err := st.Save(ctx, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s2, err := st.Load(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if s2.VisitorHome != "map" {
|
||||
t.Errorf("存回来成了 %q,期望 map", s2.VisitorHome)
|
||||
}
|
||||
|
||||
// 库里被塞了非法值 → 读出来必须收敛到主页
|
||||
set(`{"pages":["map"],"visitor_home":"/etc/passwd"}`)
|
||||
if s3, err := st.Load(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if s3.VisitorHome != VisitorHomeMain {
|
||||
t.Errorf("非法值读出 %q,期望收敛为 %q", s3.VisitorHome, VisitorHomeMain)
|
||||
}
|
||||
|
||||
// 目标页没开放时不跳(即便设置里写着 map)
|
||||
set(`{"pages":["stats"],"visitor_home":"map"}`)
|
||||
if s4, err := st.Load(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if got := s4.VisitorHomePath(); got != "" {
|
||||
t.Errorf("目标页未开放却仍要跳 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
// Package stats 汇总所有面向页面的查询。
|
||||
// 所有查询都以 dataset_id 为根作用域,并共享同一套筛选条件,保证各页面口径一致。
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TZ 显示用时区。备份数据全部来自国内,按北京时间切分自然日 / 小时。
|
||||
const TZ = "Asia/Shanghai"
|
||||
|
||||
// maxDwellMin 是统计停留时长时的单次上限(7 天)。
|
||||
// rond 会把「一直待在同一个地方」记成一条超长到访(实测有 35 天的),
|
||||
// 不封顶的话一条就能把总时长和平均时长整个带偏;这类记录本身是真的,
|
||||
// 所以是裁剪而不是剔除。与 handlers_edit.go 的 refreshPlaceAgg 保持一致。
|
||||
const maxDwellMin = 10080
|
||||
|
||||
type Filter struct {
|
||||
DatasetID int64
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
ActivityIDs []int64
|
||||
City string
|
||||
Province string
|
||||
TagID int64
|
||||
MinDwell int
|
||||
ExcludeHome bool
|
||||
ExcludeWork bool
|
||||
Keyword string
|
||||
// Regions 是访客的区域限制列表:省 / 市 / 区任意层级,空表示不限制。
|
||||
// RegionsExclude 为 true 时是黑名单(命中即排除),false 时是白名单(命中才保留)。
|
||||
Regions []string
|
||||
RegionsExclude bool
|
||||
// Verdict 非 0 时只保留站长标记为该结论的地点(1 推荐 / -1 踩雷)。
|
||||
Verdict int
|
||||
}
|
||||
|
||||
type where struct {
|
||||
conds []string
|
||||
args []any
|
||||
// off 是占位符起始偏移,用于把同一套条件嵌进外层查询的子查询里。
|
||||
off int
|
||||
}
|
||||
|
||||
func (w *where) ph(v any) string {
|
||||
w.args = append(w.args, v)
|
||||
return fmt.Sprintf("$%d", len(w.args)+w.off)
|
||||
}
|
||||
|
||||
func (w *where) cond(expr string, vals ...any) {
|
||||
parts := strings.Split(expr, "?")
|
||||
var b strings.Builder
|
||||
b.WriteString(parts[0])
|
||||
for i, v := range vals {
|
||||
b.WriteString(w.ph(v))
|
||||
if i+1 < len(parts) {
|
||||
b.WriteString(parts[i+1])
|
||||
}
|
||||
}
|
||||
w.conds = append(w.conds, b.String())
|
||||
}
|
||||
|
||||
func (w *where) inList(col string, ids []int64) {
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
ph := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
ph = append(ph, w.ph(id))
|
||||
}
|
||||
w.conds = append(w.conds, col+" IN ("+strings.Join(ph, ",")+")")
|
||||
}
|
||||
|
||||
// expr 返回不带 WHERE 前缀的条件表达式,供子查询拼接。
|
||||
func (w *where) expr() string {
|
||||
return strings.Join(w.conds, " AND ")
|
||||
}
|
||||
|
||||
func (w *where) SQL() string {
|
||||
if len(w.conds) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " WHERE " + w.expr()
|
||||
}
|
||||
|
||||
func (w *where) andSQL() string {
|
||||
if len(w.conds) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " AND " + strings.Join(w.conds, " AND ")
|
||||
}
|
||||
|
||||
// visitsWhere 构造作用于 visits 表别名 v 的过滤条件。
|
||||
// off 用于嵌套:子查询里的占位符从 off+1 开始编号,避免与外层冲突。
|
||||
func (f Filter) visitsWhere(off int) *where {
|
||||
w := &where{off: off}
|
||||
w.cond("v.dataset_id = ?", f.DatasetID)
|
||||
if f.From != nil {
|
||||
w.cond("v.arrival >= ?", *f.From)
|
||||
}
|
||||
if f.To != nil {
|
||||
w.cond("v.arrival < ?", *f.To)
|
||||
}
|
||||
w.inList("v.activity_id", f.ActivityIDs)
|
||||
if f.MinDwell > 0 {
|
||||
w.cond("COALESCE(v.duration_min,0) >= ?", f.MinDwell)
|
||||
}
|
||||
if f.ExcludeHome {
|
||||
w.cond("NOT v.is_home")
|
||||
}
|
||||
if f.ExcludeWork {
|
||||
w.cond("NOT v.is_work")
|
||||
}
|
||||
if f.City != "" {
|
||||
w.cond("EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city = ?)", f.City)
|
||||
}
|
||||
if f.Province != "" {
|
||||
w.cond("EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.province = ?)", f.Province)
|
||||
}
|
||||
if f.TagID > 0 {
|
||||
w.cond("EXISTS (SELECT 1 FROM visit_tags vtx WHERE vtx.visit_id=v.id AND vtx.tag_id = ?)", f.TagID)
|
||||
}
|
||||
if len(f.Regions) > 0 {
|
||||
// 省 / 市 / 区三级各来一份占位符:填「广东省」命中全省,填「贵阳市」只命中该市。
|
||||
// 这里不用 COALESCE:条件包在 EXISTS 里,NULL 只是让子查询查不到行,
|
||||
// 「NOT EXISTS」照样能正确保留不匹配的记录。
|
||||
var prov, city, dist []string
|
||||
for _, r := range f.Regions {
|
||||
prov = append(prov, w.ph(r))
|
||||
city = append(city, w.ph(r))
|
||||
dist = append(dist, w.ph(r))
|
||||
}
|
||||
expr := `EXISTS (SELECT 1 FROM places pxr WHERE pxr.id=v.place_id
|
||||
AND (pxr.province IN (` + strings.Join(prov, ",") + `) OR pxr.city IN (` +
|
||||
strings.Join(city, ",") + `) OR pxr.district IN (` + strings.Join(dist, ",") + `)))`
|
||||
if f.RegionsExclude {
|
||||
expr = "NOT " + expr
|
||||
}
|
||||
w.conds = append(w.conds, expr)
|
||||
}
|
||||
if f.Keyword != "" {
|
||||
kw := "%" + f.Keyword + "%"
|
||||
w.cond(`EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id
|
||||
AND (px.name ILIKE ? OR px.city ILIKE ? OR px.poi_category ILIKE ?))`, kw, kw, kw)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// hasVisitConds 判断除时间范围外,是否还有「必须落到某次到访上」才能判定的条件。
|
||||
//
|
||||
// 用于天气:删掉到访时 weather.visit_src 会被置空而不是删行(见 editVisitDelete),
|
||||
// 这些失去归属的天气行仍然属于「某段时间里经历的天气」。所以只按时间筛选时不该
|
||||
// 额外要求它们能关联到到访,否则一删到访、那几天的天气就凭空少一截。
|
||||
func (f Filter) hasVisitConds() bool {
|
||||
return len(f.ActivityIDs) > 0 || f.MinDwell > 0 || f.ExcludeHome || f.ExcludeWork ||
|
||||
f.City != "" || f.Province != "" || f.TagID > 0 || len(f.Regions) > 0 || f.Keyword != ""
|
||||
}
|
||||
|
||||
// weatherWhere 构造作用于 weather 表别名 w 的过滤条件。
|
||||
//
|
||||
// 时间用**观测时刻 w.at**(不是所属到访的 arrival):筛一段时间,要的是「这段时间里
|
||||
// 经历的天气」,横跨边界的那次到访也只算落在区间内的那些小时。
|
||||
// 城市 / 类型 / 标签 / 区域 / 关键词这些条件则挂到它所属的那次到访上判定
|
||||
// (实测 557/557 行都带 visit_src 且能落到 places)。
|
||||
func (f Filter) weatherWhere() *where {
|
||||
w := &where{}
|
||||
w.cond("w.dataset_id = ?", f.DatasetID)
|
||||
if f.From != nil {
|
||||
w.cond("w.at >= ?", *f.From)
|
||||
}
|
||||
if f.To != nil {
|
||||
w.cond("w.at < ?", *f.To)
|
||||
}
|
||||
if f.hasVisitConds() {
|
||||
rest := f
|
||||
rest.From, rest.To = nil, nil // 时间已经按 w.at 判过了
|
||||
vw := rest.visitsWhere(len(w.args))
|
||||
w.conds = append(w.conds,
|
||||
"EXISTS (SELECT 1 FROM visits v WHERE v.src_pk = w.visit_src AND "+vw.expr()+")")
|
||||
w.args = append(w.args, vw.args...)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// movementsWhere 构造作用于 movements 表的过滤条件(off 的语义同 visitsWhere)。
|
||||
//
|
||||
// 与 visitsWhere 一样必须带区域限制:movements 本身没有城市字段,要按两端的 places
|
||||
// 判定——任一端落在被隐藏的区域,这段里程 / 这次出行就不该出现在访客看到的数据里
|
||||
// (地图上的轨迹图层用的是同一套判定)。
|
||||
func (f Filter) movementsWhere(off int) *where {
|
||||
w := &where{off: off}
|
||||
w.cond("movements.dataset_id = ?", f.DatasetID)
|
||||
if f.From != nil {
|
||||
w.cond("movements.started_at >= ?", *f.From)
|
||||
}
|
||||
if f.To != nil {
|
||||
w.cond("movements.started_at < ?", *f.To)
|
||||
}
|
||||
if f.ExcludeHome || f.ExcludeWork {
|
||||
var flags []string
|
||||
if f.ExcludeHome {
|
||||
flags = append(flags, "vf.is_home")
|
||||
}
|
||||
if f.ExcludeWork {
|
||||
flags = append(flags, "vf.is_work")
|
||||
}
|
||||
w.cond(`NOT EXISTS (
|
||||
SELECT 1 FROM visits vf WHERE vf.dataset_id = movements.dataset_id
|
||||
AND (vf.src_pk = movements.from_visit_src OR vf.src_pk = movements.to_visit_src)
|
||||
AND (` + strings.Join(flags, " OR ") + `))`)
|
||||
}
|
||||
if len(f.Regions) > 0 {
|
||||
var prov, city, dist []string
|
||||
for _, r := range f.Regions {
|
||||
prov = append(prov, w.ph(r))
|
||||
city = append(city, w.ph(r))
|
||||
dist = append(dist, w.ph(r))
|
||||
}
|
||||
expr := `EXISTS (SELECT 1 FROM places pmv
|
||||
WHERE pmv.id IN (movements.from_place_id, movements.to_place_id)
|
||||
AND (pmv.province IN (` + strings.Join(prov, ",") + `) OR pmv.city IN (` +
|
||||
strings.Join(city, ",") + `) OR pmv.district IN (` + strings.Join(dist, ",") + `)))`
|
||||
if f.RegionsExclude {
|
||||
expr = "NOT " + expr
|
||||
}
|
||||
w.conds = append(w.conds, expr)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
type DB struct{ *sql.DB }
|
||||
|
||||
// ---------- 总览 ----------
|
||||
|
||||
type Overview struct {
|
||||
Places int
|
||||
Cities int
|
||||
Provinces int
|
||||
Days int
|
||||
Visits int
|
||||
DwellMin int64
|
||||
DistanceKm float64
|
||||
Movements int
|
||||
FirstAt *time.Time
|
||||
LastAt *time.Time
|
||||
}
|
||||
|
||||
func (d DB) Overview(ctx context.Context, f Filter) (Overview, error) {
|
||||
var o Overview
|
||||
w := f.visitsWhere(0)
|
||||
q := `SELECT count(DISTINCT v.place_id),
|
||||
count(DISTINCT p.city), count(DISTINCT p.province),
|
||||
count(DISTINCT (v.arrival AT TIME ZONE '` + TZ + `')::date),
|
||||
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0), min(v.arrival), max(v.arrival)
|
||||
FROM visits v LEFT JOIN places p ON p.id = v.place_id` + w.SQL()
|
||||
err := d.QueryRowContext(ctx, q, w.args...).Scan(&o.Places, &o.Cities, &o.Provinces,
|
||||
&o.Days, &o.Visits, &o.DwellMin, &o.FirstAt, &o.LastAt)
|
||||
if err != nil {
|
||||
return o, err
|
||||
}
|
||||
|
||||
mw := f.movementsWhere(0)
|
||||
if err := d.QueryRowContext(ctx, `SELECT COALESCE(sum(distance_km),0), count(*) FROM movements`+mw.SQL(),
|
||||
mw.args...).Scan(&o.DistanceKm, &o.Movements); err != nil {
|
||||
return o, err
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// ---------- 类型分布 ----------
|
||||
|
||||
type ActivityStat struct {
|
||||
ID int64
|
||||
Name string
|
||||
Color string
|
||||
Icon string
|
||||
Count int
|
||||
Dwell int64
|
||||
Places int
|
||||
}
|
||||
|
||||
func (d DB) ActivityStats(ctx context.Context, f Filter) ([]ActivityStat, error) {
|
||||
w := f.visitsWhere(0)
|
||||
q := `SELECT COALESCE(a.id,0), COALESCE(a.name,'未分类'), COALESCE(a.color,''),
|
||||
COALESCE(a.icon,''), count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0),
|
||||
count(DISTINCT v.place_id)
|
||||
FROM visits v LEFT JOIN activities a ON a.id = v.activity_id` + w.SQL() + `
|
||||
GROUP BY 1,2,3,4 ORDER BY 5 DESC`
|
||||
rows, err := d.QueryContext(ctx, q, w.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ActivityStat
|
||||
for rows.Next() {
|
||||
var a ActivityStat
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Color, &a.Icon, &a.Count, &a.Dwell, &a.Places); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Activities 列出全部活动类型,供筛选器使用。
|
||||
func (d DB) Activities(ctx context.Context, datasetID int64) ([]ActivityStat, error) {
|
||||
rows, err := d.QueryContext(ctx, `SELECT a.id, a.name, COALESCE(a.color,''), COALESCE(a.icon,''),
|
||||
(SELECT count(*) FROM visits v WHERE v.activity_id=a.id) n,
|
||||
COALESCE((SELECT sum(LEAST(COALESCE(v.duration_min,0), 10080)) FROM visits v WHERE v.activity_id=a.id),0)
|
||||
FROM activities a WHERE a.dataset_id=$1 ORDER BY n DESC, a.name`, datasetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ActivityStat
|
||||
for rows.Next() {
|
||||
var a ActivityStat
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Color, &a.Icon, &a.Count, &a.Dwell); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 城市 / 省份 ----------
|
||||
|
||||
type CityStat struct {
|
||||
City string
|
||||
Count int
|
||||
Places int
|
||||
Dwell int64
|
||||
}
|
||||
|
||||
func (d DB) CityStats(ctx context.Context, f Filter, limit int) ([]CityStat, error) {
|
||||
w := f.visitsWhere(0)
|
||||
w.cond("p.city IS NOT NULL AND p.city <> ''")
|
||||
q := `SELECT p.city, count(*), count(DISTINCT v.place_id), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0)
|
||||
FROM visits v JOIN places p ON p.id = v.place_id` + w.SQL() + `
|
||||
GROUP BY p.city ORDER BY 2 DESC LIMIT ` + fmt.Sprint(limit)
|
||||
rows, err := d.QueryContext(ctx, q, w.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CityStat
|
||||
for rows.Next() {
|
||||
var c CityStat
|
||||
if err := rows.Scan(&c.City, &c.Count, &c.Places, &c.Dwell); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type ProvinceStat struct {
|
||||
Province string
|
||||
Cities int
|
||||
Count int
|
||||
Places int
|
||||
}
|
||||
|
||||
func (d DB) ProvinceStats(ctx context.Context, f Filter) ([]ProvinceStat, error) {
|
||||
w := f.visitsWhere(0)
|
||||
w.cond("p.province IS NOT NULL AND p.province <> ''")
|
||||
q := `SELECT p.province, count(DISTINCT p.city), count(*), count(DISTINCT v.place_id)
|
||||
FROM visits v JOIN places p ON p.id = v.place_id` + w.SQL() + `
|
||||
GROUP BY p.province ORDER BY 3 DESC`
|
||||
rows, err := d.QueryContext(ctx, q, w.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProvinceStat
|
||||
for rows.Next() {
|
||||
var p ProvinceStat
|
||||
if err := rows.Scan(&p.Province, &p.Cities, &p.Count, &p.Places); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Cities 返回去重后的城市名列表(按到访地点数降序),供筛选下拉框使用。
|
||||
// regions 非空时对访客按区域过滤:exclude=true 为黑名单(命中即排除),
|
||||
// false 为白名单(命中才保留)。省/市/区三级都参与匹配,且必须 COALESCE——
|
||||
// district 常为 NULL,NULL = 'x' 得到 NULL,黑名单的 NOT (...) 会变 NULL,
|
||||
// 整行被静默丢弃。传空 regions 表示不限制。
|
||||
func (d DB) Cities(ctx context.Context, datasetID int64, regions []string, exclude bool) ([]string, error) {
|
||||
args := []any{datasetID}
|
||||
regSQL := ""
|
||||
if len(regions) > 0 {
|
||||
var conds []string
|
||||
for _, r := range regions {
|
||||
args = append(args, r)
|
||||
ph := fmt.Sprintf("$%d", len(args))
|
||||
conds = append(conds, "COALESCE(p.province,'') = "+ph,
|
||||
"COALESCE(p.city,'') = "+ph, "COALESCE(p.district,'') = "+ph)
|
||||
}
|
||||
cond := "(" + strings.Join(conds, " OR ") + ")"
|
||||
if exclude {
|
||||
cond = "NOT " + cond
|
||||
}
|
||||
regSQL = " AND " + cond
|
||||
}
|
||||
rows, err := d.QueryContext(ctx, `SELECT p.city FROM places p WHERE p.dataset_id=$1 AND p.city IS NOT NULL AND p.city<>''`+regSQL+`
|
||||
GROUP BY p.city ORDER BY count(*) DESC`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 时间分布 ----------
|
||||
|
||||
type Bucket struct {
|
||||
Key string
|
||||
Label string
|
||||
Count int
|
||||
Dwell int64
|
||||
}
|
||||
|
||||
func (d DB) Monthly(ctx context.Context, f Filter) ([]Bucket, error) {
|
||||
w := f.visitsWhere(0)
|
||||
q := `SELECT to_char(date_trunc('month', v.arrival AT TIME ZONE '` + TZ + `'), 'YYYY-MM'),
|
||||
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0)
|
||||
FROM visits v` + w.SQL() + ` GROUP BY 1 ORDER BY 1`
|
||||
return d.buckets(ctx, q, w.args, false)
|
||||
}
|
||||
|
||||
// Calendar 返回每天的记录数,用于活跃日历。
|
||||
func (d DB) Calendar(ctx context.Context, f Filter) (map[string]int, error) {
|
||||
w := f.visitsWhere(0)
|
||||
q := `SELECT to_char((v.arrival AT TIME ZONE '` + TZ + `')::date, 'YYYY-MM-DD'), count(*)
|
||||
FROM visits v` + w.SQL() + ` GROUP BY 1`
|
||||
rows, err := d.QueryContext(ctx, q, w.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var k string
|
||||
var n int
|
||||
if err := rows.Scan(&k, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d DB) Hourly(ctx context.Context, f Filter) ([]Bucket, error) {
|
||||
w := f.visitsWhere(0)
|
||||
q := `SELECT to_char(extract(hour from v.arrival AT TIME ZONE '` + TZ + `')::int, 'FM00'),
|
||||
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0)
|
||||
FROM visits v` + w.SQL() + ` GROUP BY 1 ORDER BY 1`
|
||||
return d.buckets(ctx, q, w.args, false)
|
||||
}
|
||||
|
||||
func (d DB) Weekday(ctx context.Context, f Filter) ([]Bucket, error) {
|
||||
w := f.visitsWhere(0)
|
||||
q := `SELECT extract(isodow from v.arrival AT TIME ZONE '` + TZ + `')::int::text,
|
||||
count(*), COALESCE(sum(LEAST(COALESCE(v.duration_min,0), 10080)),0)
|
||||
FROM visits v` + w.SQL() + ` GROUP BY 1 ORDER BY 1`
|
||||
b, err := d.buckets(ctx, q, w.args, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := map[string]string{"1": "周一", "2": "周二", "3": "周三", "4": "周四", "5": "周五", "6": "周六", "7": "周日"}
|
||||
for i := range b {
|
||||
b[i].Label = names[b[i].Key]
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (d DB) buckets(ctx context.Context, q string, args []any, withLabel bool) ([]Bucket, error) {
|
||||
rows, err := d.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Bucket
|
||||
for rows.Next() {
|
||||
var b Bucket
|
||||
if err := rows.Scan(&b.Key, &b.Count, &b.Dwell); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Label = b.Key
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 出行方式 ----------
|
||||
|
||||
type TransportStat struct {
|
||||
Name string
|
||||
Color string
|
||||
Icon string
|
||||
Count int
|
||||
Distance float64
|
||||
DwellMin int64
|
||||
}
|
||||
|
||||
func (d DB) TransportStats(ctx context.Context, f Filter) ([]TransportStat, error) {
|
||||
w := f.movementsWhere(0)
|
||||
q := `SELECT COALESCE(transport_name,'未知'), COALESCE(transport_color,''), COALESCE(transport_icon,''),
|
||||
count(*), COALESCE(sum(distance_km),0), COALESCE(sum(duration_min),0)
|
||||
FROM movements` + w.SQL() + ` GROUP BY 1,2,3 ORDER BY 4 DESC`
|
||||
rows, err := d.QueryContext(ctx, q, w.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TransportStat
|
||||
for rows.Next() {
|
||||
var t TransportStat
|
||||
if err := rows.Scan(&t.Name, &t.Color, &t.Icon, &t.Count, &t.Distance, &t.DwellMin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 标签 ----------
|
||||
|
||||
type TagStat struct {
|
||||
ID int64
|
||||
Name string
|
||||
Color string
|
||||
Group string
|
||||
Count int
|
||||
}
|
||||
|
||||
func (d DB) TagStats(ctx context.Context, f Filter) ([]TagStat, error) {
|
||||
w := f.visitsWhere(0)
|
||||
q := `SELECT t.id, t.name, COALESCE(t.color,''), COALESCE(t.group_name,''), count(*)
|
||||
FROM visit_tags vt JOIN tags t ON t.id = vt.tag_id JOIN visits v ON v.id = vt.visit_id` + w.SQL() + `
|
||||
GROUP BY 1,2,3,4 ORDER BY 5 DESC`
|
||||
rows, err := d.QueryContext(ctx, q, w.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TagStat
|
||||
for rows.Next() {
|
||||
var t TagStat
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.Group, &t.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d DB) Tags(ctx context.Context, datasetID int64) ([]TagStat, error) {
|
||||
rows, err := d.QueryContext(ctx, `SELECT t.id, t.name, COALESCE(t.color,''), COALESCE(t.group_name,''),
|
||||
(SELECT count(*) FROM visit_tags vt WHERE vt.tag_id=t.id)
|
||||
FROM tags t WHERE t.dataset_id=$1 ORDER BY 5 DESC, 2`, datasetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TagStat
|
||||
for rows.Next() {
|
||||
var t TagStat
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Color, &t.Group, &t.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 地点 ----------
|
||||
|
||||
type Place struct {
|
||||
ID int64
|
||||
Name string
|
||||
POICategory string
|
||||
Lat float64
|
||||
Lon float64
|
||||
City string
|
||||
Province string
|
||||
District string
|
||||
Timezone string
|
||||
VisitCount int
|
||||
DwellMinutes int64
|
||||
FirstAt *time.Time
|
||||
LastAt *time.Time
|
||||
TopActivity string
|
||||
TopColor string
|
||||
TopIcon string
|
||||
IsHome bool
|
||||
IsWork bool
|
||||
// SrcPK 是 rond 内部的地点 ID,跨数据集快照稳定,用于挂备注与别名
|
||||
SrcPK int
|
||||
// Alias / Note 来自 place_notes:别名对外替代原名显示,备注仅站长可见
|
||||
Alias string
|
||||
Note string
|
||||
// Verdict / Up / Down 是「推荐 / 踩雷」的站长结论与访客票数,
|
||||
// 由查询后单独填充(它们按 src_pk 存在另外两张表里)。
|
||||
Verdict int
|
||||
Up int
|
||||
Down int
|
||||
}
|
||||
|
||||
const placeCols = `p.id, COALESCE(p.name,''), COALESCE(p.poi_category,''), COALESCE(p.lat,0), COALESCE(p.lon,0),
|
||||
COALESCE(p.city,''), COALESCE(p.province,''), COALESCE(p.district,''), COALESCE(p.timezone,''),
|
||||
p.visit_count, p.dwell_minutes, p.first_visit_at, p.last_visit_at,
|
||||
COALESCE((SELECT a.name FROM visits v2 JOIN activities a ON a.id=v2.activity_id
|
||||
WHERE v2.place_id=p.id GROUP BY a.name ORDER BY count(*) DESC LIMIT 1),''),
|
||||
COALESCE((SELECT a.color FROM visits v2 JOIN activities a ON a.id=v2.activity_id
|
||||
WHERE v2.place_id=p.id GROUP BY a.color ORDER BY count(*) DESC LIMIT 1),''),
|
||||
COALESCE((SELECT a.icon FROM visits v2 JOIN activities a ON a.id=v2.activity_id
|
||||
WHERE v2.place_id=p.id GROUP BY a.icon ORDER BY count(*) DESC LIMIT 1),''),
|
||||
EXISTS (SELECT 1 FROM visits v3 WHERE v3.place_id=p.id AND v3.is_home),
|
||||
EXISTS (SELECT 1 FROM visits v4 WHERE v4.place_id=p.id AND v4.is_work),
|
||||
p.src_pk`
|
||||
|
||||
// PlaceCols 与 ScanPlace 配对,供包外构造只取单个地点的查询。
|
||||
const PlaceCols = placeCols
|
||||
|
||||
func scanPlace(s interface{ Scan(...any) error }) (Place, error) {
|
||||
var p Place
|
||||
err := s.Scan(&p.ID, &p.Name, &p.POICategory, &p.Lat, &p.Lon, &p.City, &p.Province,
|
||||
&p.District, &p.Timezone, &p.VisitCount, &p.DwellMinutes, &p.FirstAt, &p.LastAt,
|
||||
&p.TopActivity, &p.TopColor, &p.TopIcon, &p.IsHome, &p.IsWork, &p.SrcPK)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// ScanPlace 把一行按 placeCols 列序扫描进 Place。
|
||||
func ScanPlace(s interface{ Scan(...any) error }, p *Place) error {
|
||||
got, err := scanPlace(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*p = got
|
||||
return nil
|
||||
}
|
||||
|
||||
// Places 分页返回地点库;筛选条件最终落在 visits 上,确保与地图口径一致。
|
||||
func (d DB) Places(ctx context.Context, f Filter, sortBy string, limit, offset int) ([]Place, int, error) {
|
||||
inner := f.visitsWhere(1)
|
||||
inner.cond("v.place_id = p.id")
|
||||
order := "p.visit_count DESC, p.dwell_minutes DESC, p.name"
|
||||
switch sortBy {
|
||||
case "dwell":
|
||||
order = "p.dwell_minutes DESC, p.visit_count DESC"
|
||||
case "recent":
|
||||
order = "p.last_visit_at DESC NULLS LAST"
|
||||
case "name":
|
||||
order = "p.name"
|
||||
case "first":
|
||||
order = "p.first_visit_at ASC NULLS LAST"
|
||||
}
|
||||
|
||||
args := append([]any{f.DatasetID}, inner.args...)
|
||||
base := `FROM places p WHERE p.dataset_id = $1 AND EXISTS (SELECT 1 FROM visits v WHERE ` + inner.expr() + `)`
|
||||
if f.Verdict != 0 {
|
||||
// 结论存在 place_votes,按 src_pk 关联;占位符接在内层条件之后。
|
||||
// dataset_id 也要对上:src_pk 只在单个数据集内唯一。
|
||||
args = append(args, f.Verdict)
|
||||
base += fmt.Sprintf(` 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 = $%d)`, len(args))
|
||||
}
|
||||
q := `SELECT ` + placeCols + ` ` + base + ` ORDER BY ` + order + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset)
|
||||
|
||||
rows, err := d.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Place
|
||||
for rows.Next() {
|
||||
p, err := scanPlace(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := d.QueryRowContext(ctx, `SELECT count(*) `+base, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// MapPlaces 返回地图所需的轻量地点集合。
|
||||
func (d DB) MapPlaces(ctx context.Context, f Filter, limit int) ([]Place, error) {
|
||||
inner := f.visitsWhere(1)
|
||||
inner.cond("v.place_id = p.id")
|
||||
q := `SELECT ` + placeCols + ` FROM places p WHERE p.dataset_id = $1
|
||||
AND EXISTS (SELECT 1 FROM visits v WHERE ` + inner.expr() + `)
|
||||
ORDER BY p.visit_count DESC LIMIT ` + fmt.Sprint(limit)
|
||||
args := append([]any{f.DatasetID}, inner.args...)
|
||||
rows, err := d.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Place
|
||||
for rows.Next() {
|
||||
p, err := scanPlace(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d DB) Place(ctx context.Context, datasetID, id int64) (Place, error) {
|
||||
q := `SELECT ` + placeCols + ` FROM places p WHERE p.dataset_id=$1 AND p.id=$2`
|
||||
return scanPlace(d.QueryRowContext(ctx, q, datasetID, id))
|
||||
}
|
||||
|
||||
// ---------- 访问记录 ----------
|
||||
|
||||
type VisitRow struct {
|
||||
ID int64
|
||||
SrcPK int
|
||||
Arrival time.Time
|
||||
Departure *time.Time
|
||||
DurationMin int
|
||||
PlaceID *int64
|
||||
PlaceName string
|
||||
City string
|
||||
POICategory string
|
||||
ActivityName string
|
||||
Color string
|
||||
Icon string
|
||||
IsHome bool
|
||||
IsWork bool
|
||||
Remark string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
const visitCols = `v.id, v.src_pk, v.arrival, v.departure, COALESCE(v.duration_min,0),
|
||||
v.place_id, COALESCE(p.name,''), COALESCE(p.city,''), COALESCE(p.poi_category,''),
|
||||
COALESCE(a.name,''), COALESCE(a.color,''), COALESCE(a.icon,''),
|
||||
v.is_home, v.is_work, COALESCE(v.remark,''),
|
||||
COALESCE((SELECT string_agg(t.name, '、' ORDER BY t.name) FROM visit_tags vt JOIN tags t ON t.id=vt.tag_id
|
||||
WHERE vt.visit_id = v.id), '')`
|
||||
|
||||
func scanVisit(s interface{ Scan(...any) error }) (VisitRow, error) {
|
||||
var v VisitRow
|
||||
var tags string
|
||||
err := s.Scan(&v.ID, &v.SrcPK, &v.Arrival, &v.Departure, &v.DurationMin,
|
||||
&v.PlaceID, &v.PlaceName, &v.City, &v.POICategory,
|
||||
&v.ActivityName, &v.Color, &v.Icon, &v.IsHome, &v.IsWork, &v.Remark, &tags)
|
||||
if tags != "" {
|
||||
v.Tags = strings.Split(tags, "、")
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (d DB) Visits(ctx context.Context, f Filter, limit, offset int, order string) ([]VisitRow, int, error) {
|
||||
w := f.visitsWhere(0)
|
||||
dir := "DESC"
|
||||
if order == "asc" {
|
||||
dir = "ASC"
|
||||
}
|
||||
q := `SELECT ` + visitCols + ` FROM visits v
|
||||
LEFT JOIN places p ON p.id = v.place_id
|
||||
LEFT JOIN activities a ON a.id = v.activity_id` + w.SQL() +
|
||||
` ORDER BY v.arrival ` + dir + fmt.Sprintf(` LIMIT %d OFFSET %d`, limit, offset)
|
||||
rows, err := d.QueryContext(ctx, q, w.args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []VisitRow
|
||||
for rows.Next() {
|
||||
v, err := scanVisit(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var total int
|
||||
cq := `SELECT count(*) FROM visits v` + w.SQL()
|
||||
if err := d.QueryRowContext(ctx, cq, w.args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// RecentVisits 最近足迹,主页使用。
|
||||
func (d DB) RecentVisits(ctx context.Context, f Filter, limit int) ([]VisitRow, error) {
|
||||
w := f.visitsWhere(0)
|
||||
q := `SELECT ` + visitCols + ` FROM visits v
|
||||
LEFT JOIN places p ON p.id = v.place_id
|
||||
LEFT JOIN activities a ON a.id = v.activity_id` + w.SQL() +
|
||||
` ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit)
|
||||
rows, err := d.QueryContext(ctx, q, w.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []VisitRow
|
||||
for rows.Next() {
|
||||
v, err := scanVisit(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d DB) VisitsByPlace(ctx context.Context, datasetID, placeID int64, limit int) ([]VisitRow, error) {
|
||||
q := `SELECT ` + visitCols + ` FROM visits v
|
||||
LEFT JOIN places p ON p.id = v.place_id
|
||||
LEFT JOIN activities a ON a.id = v.activity_id
|
||||
WHERE v.dataset_id=$1 AND v.place_id=$2 ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit)
|
||||
rows, err := d.QueryContext(ctx, q, datasetID, placeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []VisitRow
|
||||
for rows.Next() {
|
||||
v, err := scanVisit(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d DB) Bookmarked(ctx context.Context, datasetID int64, limit int) ([]VisitRow, error) {
|
||||
q := `SELECT ` + visitCols + ` FROM visits v
|
||||
LEFT JOIN places p ON p.id = v.place_id
|
||||
LEFT JOIN activities a ON a.id = v.activity_id
|
||||
WHERE v.dataset_id=$1 AND v.bookmarked ORDER BY v.arrival DESC LIMIT ` + fmt.Sprint(limit)
|
||||
rows, err := d.QueryContext(ctx, q, datasetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []VisitRow
|
||||
for rows.Next() {
|
||||
v, err := scanVisit(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 天气 ----------
|
||||
|
||||
type WeatherStat struct {
|
||||
Condition string
|
||||
Symbol string
|
||||
Count int
|
||||
AvgTemp float64
|
||||
MinTemp float64
|
||||
MaxTemp float64
|
||||
}
|
||||
|
||||
func (d DB) WeatherStats(ctx context.Context, f Filter) ([]WeatherStat, error) {
|
||||
// 这里以前只用了 f.DatasetID,筛选(时间范围 / 城市 / 类型 …)对它完全不起作用——
|
||||
// 统计页筛完之后「气温」还是全量汇总,就是这么来的。
|
||||
w := f.weatherWhere()
|
||||
q := `SELECT COALESCE(w.condition,'未知'), COALESCE(w.symbol,''), count(*),
|
||||
COALESCE(avg(w.temperature_c),0)-273.15, COALESCE(min(w.temperature_c),0)-273.15, COALESCE(max(w.temperature_c),0)-273.15
|
||||
FROM weather w` + w.SQL() + ` GROUP BY 1,2 ORDER BY 3 DESC LIMIT 12`
|
||||
rows, err := d.QueryContext(ctx, q, w.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []WeatherStat
|
||||
for rows.Next() {
|
||||
var w WeatherStat
|
||||
if err := rows.Scan(&w.Condition, &w.Symbol, &w.Count, &w.AvgTemp, &w.MinTemp, &w.MaxTemp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TempBucket 是逐月平均气温。
|
||||
//
|
||||
// 原先这里借用 Bucket,把「平均气温」塞进了 Dwell 字段(因为 buckets() 的第三列一律读成
|
||||
// Dwell),读代码的人根本猜不到,所以单开一个结构。
|
||||
type TempBucket struct {
|
||||
Key string // 2026-09
|
||||
Label string // 标签用短写(26-09),十几根柱子横排时 7 个字符会挤在一起
|
||||
Hours int // 这个月有多少小时有记录
|
||||
AvgC float64 // 平均气温(摄氏度)
|
||||
}
|
||||
|
||||
// HourlyTemp 逐月平均气温(同样跟着筛选走)。
|
||||
func (d DB) HourlyTemp(ctx context.Context, f Filter) ([]TempBucket, error) {
|
||||
w := f.weatherWhere()
|
||||
w.conds = append(w.conds, "w.temperature_c IS NOT NULL")
|
||||
q := `SELECT to_char(date_trunc('month', w.at AT TIME ZONE '` + TZ + `'), 'YYYY-MM'),
|
||||
count(*), COALESCE(avg(w.temperature_c)-273.15, 0)
|
||||
FROM weather w` + w.SQL() + ` GROUP BY 1 ORDER BY 1`
|
||||
rows, err := d.QueryContext(ctx, q, w.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TempBucket
|
||||
for rows.Next() {
|
||||
var b TempBucket
|
||||
if err := rows.Scan(&b.Key, &b.Hours, &b.AvgC); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.AvgC = math.Round(b.AvgC*10) / 10
|
||||
if len(b.Key) >= 7 { // 2026-09 → 26-09
|
||||
b.Label = b.Key[2:]
|
||||
} else {
|
||||
b.Label = b.Key
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ConditionName 把 Apple 的天气枚举翻成中文。
|
||||
func ConditionName(c string) string {
|
||||
m := map[string]string{
|
||||
"clear": "晴", "mostlyClear": "晴间多云", "partlyCloudy": "多云", "mostlyCloudy": "阴天多云",
|
||||
"cloudy": "阴", "foggy": "有雾", "haze": "霾", "mostlyCloudyNight": "夜间多云",
|
||||
"drizzle": "毛毛雨", "rain": "雨", "heavyRain": "大雨", "showers": "阵雨",
|
||||
"thunderstorms": "雷雨", "snow": "雪", "heavySnow": "大雪", "sleet": "雨夹雪",
|
||||
"windy": "大风", "blizzard": "暴风雪", "freezingRain": "冻雨", "hot": "高温", "cold": "寒冷",
|
||||
}
|
||||
if v, ok := m[c]; ok {
|
||||
return v
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package stats_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"rond-with-you/internal/stats"
|
||||
)
|
||||
|
||||
// 这些是只读的对照测试:把统计结果和「手写 SQL 数一遍」的结果比对,
|
||||
// 专门钉住「区域限制漏在某个查询里」这类问题(轨迹、里程、出行方式都犯过)。
|
||||
func setup(t *testing.T) (*sql.DB, int64) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("ROND_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("未设置 ROND_DSN")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
var id int64
|
||||
if err := db.QueryRow(`SELECT id FROM datasets WHERE is_active LIMIT 1`).Scan(&id); err != nil {
|
||||
t.Skipf("没有生效数据集:%v", err)
|
||||
}
|
||||
return db, id
|
||||
}
|
||||
|
||||
func TestOverviewMatchesManualSQL(t *testing.T) {
|
||||
db, dsID := setup(t)
|
||||
d := stats.DB{DB: db}
|
||||
f := stats.Filter{DatasetID: dsID}
|
||||
|
||||
got, err := d.Overview(context.Background(), f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var places, cities, days, visits, movements int
|
||||
var dist float64
|
||||
if err := db.QueryRow(`SELECT count(DISTINCT v.place_id), count(DISTINCT p.city),
|
||||
count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date), count(*)
|
||||
FROM visits v LEFT JOIN places p ON p.id = v.place_id WHERE v.dataset_id=$1`, dsID).
|
||||
Scan(&places, &cities, &days, &visits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COALESCE(sum(distance_km),0), count(*) FROM movements WHERE dataset_id=$1`, dsID).
|
||||
Scan(&dist, &movements); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Places != places || got.Cities != cities || got.Days != days || got.Visits != visits {
|
||||
t.Errorf("总览与手工 SQL 不一致:%+v vs places=%d cities=%d days=%d visits=%d",
|
||||
got, places, cities, days, visits)
|
||||
}
|
||||
if diff := got.DistanceKm - dist; diff > 0.01 || diff < -0.01 {
|
||||
t.Errorf("里程 %.4f,手工 SQL %.4f", got.DistanceKm, dist)
|
||||
}
|
||||
if got.Movements != movements {
|
||||
t.Errorf("行程数 %d,手工 SQL %d", got.Movements, movements)
|
||||
}
|
||||
}
|
||||
|
||||
// 区域限制必须同时作用于 visits 与 movements:只改其中一边,
|
||||
// 访客会看到「到访次数藏了、里程还留着」这种自相矛盾的数字。
|
||||
func TestRegionFilterAppliesToVisitsAndMovements(t *testing.T) {
|
||||
db, dsID := setup(t)
|
||||
d := stats.DB{DB: db}
|
||||
ctx := context.Background()
|
||||
|
||||
// 挑一个有记录的省份做白名单,避免用空区域(那样等于没限制)
|
||||
var region string
|
||||
if err := db.QueryRow(`SELECT p.province FROM places p JOIN visits v ON v.place_id=p.id
|
||||
WHERE v.dataset_id=$1 AND p.province IS NOT NULL AND p.province <> ''
|
||||
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(®ion); err != nil {
|
||||
t.Skipf("找不到可用省份:%v", err)
|
||||
}
|
||||
|
||||
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
only, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if only.Visits >= all.Visits {
|
||||
t.Errorf("白名单 %s 后到访数没减少:%d → %d", region, all.Visits, only.Visits)
|
||||
}
|
||||
if only.DistanceKm >= all.DistanceKm {
|
||||
t.Errorf("白名单 %s 后里程没减少:%.1f → %.1f(movements 的区域过滤可能漏了)",
|
||||
region, all.DistanceKm, only.DistanceKm)
|
||||
}
|
||||
|
||||
// 黑名单:反过来,排除这个省后剩下的应等于「全部 - 只有它」
|
||||
var other string
|
||||
if err := db.QueryRow(`SELECT p.province FROM places p JOIN visits v ON v.place_id=p.id
|
||||
WHERE v.dataset_id=$1 AND p.province IS NOT NULL AND p.province <> '' AND p.province <> $2
|
||||
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID, region).Scan(&other); err == nil {
|
||||
notRegion, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}, RegionsExclude: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notRegion.Visits == all.Visits {
|
||||
t.Errorf("黑名单 %s 后到访数没减少:仍为 %d", region, all.Visits)
|
||||
}
|
||||
}
|
||||
|
||||
// 出行方式统计同样要跟着缩水
|
||||
tAll, err := d.TransportStats(ctx, stats.Filter{DatasetID: dsID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tOnly, err := d.TransportStats(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := func(ts []stats.TransportStat) float64 {
|
||||
var v float64
|
||||
for _, x := range ts {
|
||||
v += x.Distance
|
||||
}
|
||||
return v
|
||||
}
|
||||
if sum(tOnly) >= sum(tAll) {
|
||||
t.Errorf("白名单 %s 后出行方式里程没减少:%.1f → %.1f", region, sum(tAll), sum(tOnly))
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤条件不该把时间范围弄丢。
|
||||
func TestOverviewTimeRangeStillApplies(t *testing.T) {
|
||||
db, dsID := setup(t)
|
||||
d := stats.DB{DB: db}
|
||||
ctx := context.Background()
|
||||
|
||||
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if all.FirstAt == nil || all.LastAt == nil {
|
||||
t.Skip("没有时间范围可测")
|
||||
}
|
||||
mid := all.FirstAt.Add(all.LastAt.Sub(*all.FirstAt) / 2)
|
||||
half, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, From: &mid})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if half.Visits >= all.Visits {
|
||||
t.Errorf("限定后半段后到访数没减少:%d → %d", all.Visits, half.Visits)
|
||||
}
|
||||
if half.LastAt == nil || all.LastAt == nil || half.LastAt.Before(mid) {
|
||||
t.Errorf("后半段的最后时间 %v 早于起点 %v", half.LastAt, mid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWeatherStatsRespectsFilter 天气统计必须跟着筛选走。
|
||||
//
|
||||
// 以前 WeatherStats 声明了 Filter 却只用了 DatasetID,HourlyTemp 干脆只收 datasetID:
|
||||
// 统计页筛完之后「气温」还是全量汇总,跟同一页的到访数对不上。
|
||||
func TestWeatherStatsRespectsFilter(t *testing.T) {
|
||||
db, dsID := setup(t)
|
||||
d := stats.DB{DB: db}
|
||||
ctx := context.Background()
|
||||
|
||||
var minAt, maxAt time.Time
|
||||
if err := db.QueryRow(`SELECT min(at), max(at) FROM weather WHERE dataset_id=$1`, dsID).
|
||||
Scan(&minAt, &maxAt); err != nil {
|
||||
t.Skipf("没有天气数据可测:%v", err)
|
||||
}
|
||||
// 取「最后一个月」当区间:它必然只覆盖一部分天气
|
||||
from := time.Date(maxAt.Year(), maxAt.Month(), 1, 0, 0, 0, 0, maxAt.Location())
|
||||
if !from.After(minAt) {
|
||||
t.Skip("天气数据只落在一个月内,构造不出有效区间")
|
||||
}
|
||||
|
||||
sum := func(ws []stats.WeatherStat) int {
|
||||
n := 0
|
||||
for _, w := range ws {
|
||||
n += w.Count
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
all, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID, From: &from})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 与手写 SQL 对账
|
||||
var wantTotal, wantWindow int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM weather WHERE dataset_id=$1`, dsID).Scan(&wantTotal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT count(*) FROM weather w WHERE w.dataset_id=$1 AND w.at >= $2`,
|
||||
dsID, from).Scan(&wantWindow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 分组数是 12 上限,只有没截断时求和才等于总数
|
||||
if len(got) < 12 && sum(got) != wantWindow {
|
||||
t.Errorf("按时间筛选后天气行数 %d,手写 SQL %d", sum(got), wantWindow)
|
||||
}
|
||||
if wantWindow < wantTotal && sum(got) >= sum(all) {
|
||||
t.Errorf("筛了时间但天气行数没减少(%d → %d),筛选没生效", sum(all), sum(got))
|
||||
}
|
||||
|
||||
// 逐月气温同样要跟着区间走
|
||||
temps, err := d.HourlyTemp(ctx, stats.Filter{DatasetID: dsID, From: &from})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(temps) != 1 {
|
||||
t.Errorf("区间只覆盖 %s 一个月,逐月气温应当只剩 1 组,实际 %d 组:%+v",
|
||||
from.Format("2006-01"), len(temps), temps)
|
||||
}
|
||||
|
||||
// 城市筛选:只有归属于该城市到访的天气算数
|
||||
var city string
|
||||
if err := db.QueryRow(`SELECT p.city FROM weather w
|
||||
JOIN visits v ON v.dataset_id=w.dataset_id AND v.src_pk=w.visit_src
|
||||
JOIN places p ON p.id=v.place_id
|
||||
WHERE w.dataset_id=$1 AND p.city IS NOT NULL
|
||||
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&city); err != nil {
|
||||
t.Skipf("没有带城市的天气:%v", err)
|
||||
}
|
||||
byCity, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID, City: city})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wantCity int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM weather w
|
||||
WHERE w.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
|
||||
WHERE v.dataset_id=w.dataset_id AND v.src_pk=w.visit_src
|
||||
AND EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city=$2))`,
|
||||
dsID, city).Scan(&wantCity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(byCity) < 12 && sum(byCity) != wantCity {
|
||||
t.Errorf("按城市「%s」筛选后天气行数 %d,手写 SQL %d", city, sum(byCity), wantCity)
|
||||
}
|
||||
if sum(byCity) >= wantTotal {
|
||||
t.Errorf("按城市筛选后行数(%d)不该等于全量(%d)", sum(byCity), wantTotal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWeatherWhereSemantics 钉住天气筛选的两条语义,它们都不是显而易见的:
|
||||
//
|
||||
// 1. 时间用**观测时刻 w.at**,不是所属到访的 arrival —— 筛一段时间要的是
|
||||
// 「这段时间里经历的天气」,横跨边界的那次到访只算落在区间内的小时。
|
||||
// 2. 只按时间筛时**不能**要求天气能关联到访。删到访时 weather.visit_src 是被置空
|
||||
// 而不是删行(editVisitDelete),那些失去归属的天气行仍属于「某段时间的天气」;
|
||||
// 一旦加上 EXISTS,删掉一次到访就会让它那几天的天气从统计里凭空消失。
|
||||
// 而城市 / 类型 / 标签这类条件必须挂到到访上,那时才该有 EXISTS。
|
||||
//
|
||||
// 纯白盒:直接看生成的 SQL 形状,不需要真实数据。
|
||||
func TestWeatherWhereSemantics(t *testing.T) {
|
||||
from := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("时间按观测时刻", func(t *testing.T) {
|
||||
w := Filter{DatasetID: 9, From: &from, To: &to}.weatherWhere()
|
||||
got := w.SQL()
|
||||
if !strings.Contains(got, "w.at >= ") || !strings.Contains(got, "w.at < ") {
|
||||
t.Errorf("时间条件应落在 w.at 上,实际:%s", got)
|
||||
}
|
||||
if strings.Contains(got, "v.arrival") {
|
||||
t.Errorf("不该用所属到访的 arrival 判时间,实际:%s", got)
|
||||
}
|
||||
if strings.Contains(got, "EXISTS") {
|
||||
t.Errorf("只按时间筛选时不该要求能关联到到访(否则删过到访的天气会消失),实际:%s", got)
|
||||
}
|
||||
if len(w.args) != 3 {
|
||||
t.Errorf("参数个数应为 3(数据集 + 起 + 止),实际 %d:%v", len(w.args), w.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("城市条件挂到到访上", func(t *testing.T) {
|
||||
w := Filter{DatasetID: 9, City: "贵阳市"}.weatherWhere()
|
||||
got := w.SQL()
|
||||
if !strings.Contains(got, "EXISTS (SELECT 1 FROM visits v WHERE v.src_pk = w.visit_src") {
|
||||
t.Errorf("城市条件应通过所属到访判定,实际:%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "v.dataset_id = ") {
|
||||
t.Errorf("EXISTS 里应当带上数据集条件,实际:%s", got)
|
||||
}
|
||||
if strings.Contains(got, "v.arrival") {
|
||||
t.Errorf("时间参数已置空,不该出现 arrival 条件,实际:%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("占位符编号连续", func(t *testing.T) {
|
||||
// 外层先写了 dataset + 起止时间($1~$3),EXISTS 里的条件必须从 $4 接着编,
|
||||
// 否则参数会错位(这类错误只在有数据时炸,很难查)。
|
||||
// 城市条件本身会带一条 v.dataset_id,所以 EXISTS 里有两个占位符。
|
||||
w := Filter{DatasetID: 9, From: &from, To: &to, City: "贵阳市"}.weatherWhere()
|
||||
got := w.SQL()
|
||||
if !strings.Contains(got, "v.dataset_id = $4") {
|
||||
t.Errorf("EXISTS 的第一个占位符应当是 $4,实际:%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "px.city = $5") {
|
||||
t.Errorf("城市条件应当是 $5,实际:%s", got)
|
||||
}
|
||||
if len(w.args) != 5 {
|
||||
t.Errorf("参数个数应为 5(数据集 + 起 + 止 + v.dataset_id + 城市),实际 %d:%v",
|
||||
len(w.args), w.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("区域限制也算到访条件", func(t *testing.T) {
|
||||
w := Filter{DatasetID: 9, Regions: []string{"贵阳市"}, RegionsExclude: true}.weatherWhere()
|
||||
if !strings.Contains(w.SQL(), "EXISTS") {
|
||||
t.Errorf("区域限制应当触发 EXISTS,实际:%s", w.SQL())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Package votes 存放地点的「推荐 / 踩雷」两类表态:
|
||||
// - place_votes 站长对地点的官方结论(单值)
|
||||
// - place_visitor_votes 访客点赞 / 点踩(一人一票,可改可撤)
|
||||
//
|
||||
// 两者都按 rond 的地点 ID(src_pk)存储,重新上传备份、地点换了新 id 也不丢。
|
||||
package votes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// VerdictRecommend / VerdictAvoid 是官方结论的取值。
|
||||
const (
|
||||
VerdictAvoid = -1
|
||||
VerdictRecommend = 1
|
||||
)
|
||||
|
||||
type Store struct{ DB *sql.DB }
|
||||
|
||||
// Tally 是一个地点的访客票数汇总。
|
||||
type Tally struct {
|
||||
Up int `json:"up"`
|
||||
Down int `json:"down"`
|
||||
}
|
||||
|
||||
// Verdicts 返回某个数据集的站长结论:src_pk -> verdict(1/-1)。
|
||||
// 必须按 dataset_id 过滤:src_pk 只在单个数据集内唯一。
|
||||
func (s *Store) Verdicts(ctx context.Context, datasetID int64) (map[int]int, error) {
|
||||
out := map[int]int{}
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT src_pk, verdict FROM place_votes WHERE dataset_id=$1`, datasetID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pk, v int
|
||||
if err := rows.Scan(&pk, &v); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out[pk] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Verdict 取单个地点的站长结论;未标注返回 0。
|
||||
func (s *Store) Verdict(ctx context.Context, datasetID int64, srcPK int) (int, error) {
|
||||
var v int
|
||||
err := s.DB.QueryRowContext(ctx,
|
||||
`SELECT verdict FROM place_votes WHERE dataset_id=$1 AND src_pk=$2`, datasetID, srcPK).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SaveVerdict 设置站长结论;verdict 为 0 表示取消结论。
|
||||
func (s *Store) SaveVerdict(ctx context.Context, userID, datasetID int64, srcPK, verdict int) error {
|
||||
if verdict == 0 {
|
||||
_, err := s.DB.ExecContext(ctx,
|
||||
`DELETE FROM place_votes WHERE user_id=$1 AND dataset_id=$2 AND src_pk=$3`, userID, datasetID, srcPK)
|
||||
return err
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO place_votes (user_id, dataset_id, src_pk, verdict, updated_at)
|
||||
VALUES ($1,$2,$3,$4,now())
|
||||
ON CONFLICT (user_id, dataset_id, src_pk) DO UPDATE SET verdict=EXCLUDED.verdict, updated_at=now()`,
|
||||
userID, datasetID, srcPK, verdict)
|
||||
return err
|
||||
}
|
||||
|
||||
// Tallies 返回全部地点的访客票数。票表规模很小(个人站点),一次读全更省事,
|
||||
// 也避免给 pgx 传数组参数带来的类型歧义。
|
||||
func (s *Store) Tallies(ctx context.Context) (map[int]Tally, error) {
|
||||
out := map[int]Tally{}
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT src_pk,
|
||||
count(*) FILTER (WHERE vote=1), count(*) FILTER (WHERE vote=-1)
|
||||
FROM place_visitor_votes GROUP BY src_pk`)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pk int
|
||||
var t Tally
|
||||
if err := rows.Scan(&pk, &t.Up, &t.Down); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out[pk] = t
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Tally 取单个地点的访客票数。
|
||||
func (s *Store) Tally(ctx context.Context, srcPK int) (Tally, error) {
|
||||
var t Tally
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT count(*) FILTER (WHERE vote=1), count(*) FILTER (WHERE vote=-1)
|
||||
FROM place_visitor_votes WHERE src_pk=$1`, srcPK).Scan(&t.Up, &t.Down)
|
||||
return t, err
|
||||
}
|
||||
|
||||
// VoterAll 返回该访客投过的全部票:src_pk -> vote,供地图一次性标注「我投的」。
|
||||
func (s *Store) VoterAll(ctx context.Context, voterKey string) (map[int]int, error) {
|
||||
out := map[int]int{}
|
||||
if voterKey == "" {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.DB.QueryContext(ctx,
|
||||
`SELECT src_pk, vote FROM place_visitor_votes WHERE voter_key=$1`, voterKey)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pk, v int
|
||||
if err := rows.Scan(&pk, &v); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out[pk] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Voter 返回该访客对某地点的既有投票(1/-1/0)。voterKey 为空表示拿不到身份。
|
||||
func (s *Store) Voter(ctx context.Context, srcPK int, voterKey string) int {
|
||||
if voterKey == "" {
|
||||
return 0
|
||||
}
|
||||
var v int
|
||||
if err := s.DB.QueryRowContext(ctx,
|
||||
`SELECT vote FROM place_visitor_votes WHERE src_pk=$1 AND voter_key=$2`, srcPK, voterKey).Scan(&v); err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Cast 记录访客投票:未投过则新增;投同一票则撤销(再点一次取消);投另一票则改为该票。
|
||||
// 返回更新后的票数与该访客最终的选择。
|
||||
func (s *Store) Cast(ctx context.Context, srcPK int, voterKey string, vote int) (Tally, int, error) {
|
||||
var cur int
|
||||
err := s.DB.QueryRowContext(ctx,
|
||||
`SELECT vote FROM place_visitor_votes WHERE src_pk=$1 AND voter_key=$2`, srcPK, voterKey).Scan(&cur)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
_, err = s.DB.ExecContext(ctx,
|
||||
`INSERT INTO place_visitor_votes (src_pk, voter_key, vote) VALUES ($1,$2,$3)`, srcPK, voterKey, vote)
|
||||
cur = vote
|
||||
case err != nil:
|
||||
return Tally{}, 0, err
|
||||
case cur == vote:
|
||||
_, err = s.DB.ExecContext(ctx,
|
||||
`DELETE FROM place_visitor_votes WHERE src_pk=$1 AND voter_key=$2`, srcPK, voterKey)
|
||||
cur = 0
|
||||
default:
|
||||
_, err = s.DB.ExecContext(ctx,
|
||||
`UPDATE place_visitor_votes SET vote=$3, updated_at=now() WHERE src_pk=$1 AND voter_key=$2`,
|
||||
srcPK, voterKey, vote)
|
||||
cur = vote
|
||||
}
|
||||
if err != nil {
|
||||
return Tally{}, 0, err
|
||||
}
|
||||
t, err := s.Tally(ctx, srcPK)
|
||||
return t, cur, err
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)+" 个地点的地址 / 名称")
|
||||
}
|
||||
}
|
||||
@@ -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("未被选中的标签「乙」从下拉里消失了")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||
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())
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("离开早于到达应不合法")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: ¬es.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
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: {}
|
||||
};
|
||||
})();
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
})));
|
||||
@@ -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)};
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 618 B |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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="广东省 贵阳市 南明区">{{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://<账号>.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"><img src></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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||