用户报:把某行的导出值改成 MKPOICategoryCarRent、又手填了显示名「租车」, 保存成功、框里也回填了,页面却仍挂着「还没有中文名」。 两件事叠在一起: 1. 判断错了。Missing 只看内置表,没看有没有自定义覆盖 —— 填过了还照样提示。改成 categoryLabelMissing(v) && 没有覆盖。 补 TestCategoryLabelMissingRespectsOverride,并做变异验证(去掉那半个条件即报错)。 2. 值本身是拼错的。MKPOICategoryCarRent 不是真机的枚举,正确是 MKPOICategoryCarRental(少了个 al)——站内看不出任何异常,但导回手机后 那个地点显示不出类别。这正是加「显示名」时提醒过的风险,所以补一道防线: 编辑页把内置枚举名渲染给脚本,往 poi_category 写值的两个入口 (类别台账的「改名」、地点行内编辑的「自定义类别」)都过 okEnum(), 不在已知枚举里就 confirm,还能猜「你是不是想写 MKPOICategoryCarRental」。 中文值和已知枚举不打扰。 另外:这次用脚本往模板写 JS 时,\n 被多转义一层成了真换行,JS 单引号字符串 含裸换行 ⇒ 整块 <script> 语法错误、页面交互全哑,而模板解析测试照样通过。 新增 inline_script_test.go 扫所有内联脚本块(现在 31 个),字符串跨行就报错 并指出文件行号,已做变异验证。
2380 lines
90 KiB
Go
2380 lines
90 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"math"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"rond-with-you/internal/geo"
|
||
)
|
||
|
||
// ---------- 数据编辑:手动补录没带手机时的记录点,改完可导出回 rondbackup ----------
|
||
|
||
const editPerPage = 40
|
||
|
||
// editPage 渲染数据编辑页:地点、到访两个可搜索分页列表 + 新增表单。
|
||
func (s *Server) editPage(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.HasMap = true
|
||
p.Flatpickr = true
|
||
q := r.URL.Query()
|
||
d := EditData{Page: p, Query: strings.TrimSpace(q.Get("q")), SourceCounts: map[string]int{}}
|
||
d.VisitQuery = strings.TrimSpace(q.Get("vq"))
|
||
d.MovementFilter = MovementFilter{
|
||
Transport: strings.TrimSpace(q.Get("mt")),
|
||
PlaceID: int64(atoiOr(q.Get("mp"), 0)),
|
||
From: normalizeDate(q.Get("mfrom")),
|
||
To: normalizeDate(q.Get("mto")),
|
||
}
|
||
d.SuspectOnly = q.Get("sus") == "1"
|
||
d.SourceFilter = strings.TrimSpace(q.Get("src"))
|
||
if !validPlaceSource(d.SourceFilter) {
|
||
d.SourceFilter = ""
|
||
}
|
||
d.PageNo = atoiOr(q.Get("page"), 1)
|
||
d.OpenSrcPK = atoiOr(q.Get("open"), 0)
|
||
d.VisitPageNo = atoiOr(q.Get("vpage"), 1)
|
||
d.MovementPageNo = atoiOr(q.Get("mpage"), 1)
|
||
d.Flash, d.Err = q.Get("ok"), q.Get("err")
|
||
d.GeocodeReady = s.cfg.GeocodeKey != ""
|
||
d.TransportColors = transportColorNames()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if ds == nil {
|
||
s.render(w, "admin/edit", d)
|
||
return
|
||
}
|
||
// 导出 rondbackup 靠的是「基线库」(导入时留存的 Core Data 库底稿)。
|
||
// 老数据集可能还没留,顺手补建一次——失败才算不可导出,页面给出提示。
|
||
if _, err := s.ensureBaseline(ctx, ds.ID); err != nil {
|
||
log.Printf("基线库不可用(导出 rondbackup 将失败): %v", err)
|
||
} else {
|
||
d.CanExport = true
|
||
}
|
||
|
||
// 活动类型与标签
|
||
if d.Activities, err = s.editOptions(ctx, `SELECT a.id, COALESCE(a.name,'')
|
||
FROM activities a WHERE a.dataset_id=$1 ORDER BY a.id`, ds.ID); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if d.Tags, err = s.editOptions(ctx, `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
|
||
}
|
||
// 交通方式:rond 的 ZTRANSPORT 是全局列表,站点不单独维护,从行程里聚合
|
||
if d.Transports, err = s.editTransports(ctx, ds.ID); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
|
||
// 地点列表(搜索 + 分页)。「只看疑似 WGS-84」的条件用到留档别名 r,
|
||
// 所以下面的 count 与列表都要带上 placeRawJoin。
|
||
where, args := "p.dataset_id=$1", []any{ds.ID}
|
||
if d.SuspectOnly {
|
||
where += " AND " + suspectWGS
|
||
}
|
||
// 坐标来源筛选:none 是「没有留档」的历史行(含 2026-09 那批照片导入的点)
|
||
if d.SourceFilter == placeSourceNone {
|
||
where += " AND p.coord_source IS NULL"
|
||
} else if d.SourceFilter != "" {
|
||
args = append(args, d.SourceFilter)
|
||
where += fmt.Sprintf(" AND p.coord_source = $%d", len(args))
|
||
}
|
||
if d.Query != "" {
|
||
args = append(args, "%"+escapeLike(d.Query)+"%")
|
||
n := len(args)
|
||
where += fmt.Sprintf(" AND (COALESCE(p.name,'') ILIKE $%d OR COALESCE(p.city,'') ILIKE $%d OR COALESCE(p.district,'') ILIKE $%d)", n, n, n)
|
||
}
|
||
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM places p`+placeRawJoin+` WHERE `+where, args...).Scan(&d.Total); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
d.PageCount = max1((d.Total + editPerPage - 1) / editPerPage)
|
||
// 行号按「当前筛选后的顺序」算:筛选一变顺序就变,所以不能拿全量排名跳页。
|
||
// 筛选条件下找不到这一行(比如带着搜索词跳进来)就停在第 1 页,不猜。
|
||
if d.OpenSrcPK > 0 {
|
||
args = append(args, d.OpenSrcPK)
|
||
var rowNo int
|
||
// 比的是 src_pk(rond 编号,也是页面链接里那个 #号),不是库内主键 id
|
||
err := s.db.QueryRowContext(ctx, `SELECT rn FROM (
|
||
SELECT p.src_pk, row_number() OVER (ORDER BY p.visit_count DESC, p.id) AS rn
|
||
FROM places p`+placeRawJoin+` WHERE `+where+`) t WHERE t.src_pk=$`+strconv.Itoa(len(args)),
|
||
args...).Scan(&rowNo)
|
||
args = args[:len(args)-1]
|
||
if err == nil {
|
||
d.PageNo = (rowNo-1)/editPerPage + 1
|
||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
}
|
||
if d.PageNo < 1 {
|
||
d.PageNo = 1
|
||
}
|
||
if d.PageNo > d.PageCount {
|
||
d.PageNo = d.PageCount
|
||
}
|
||
// 已有类别去重:新建表单、每行编辑的下拉、顶部类别台账都用它(提交值仍是原始标识符);
|
||
// 顺手带上地点数,台账要按用量排序、也要知道改一下会影响多少行
|
||
catNames := s.categoryLabels(ctx)
|
||
d.KnownCategories = knownCategoryNames()
|
||
crows, err := s.db.QueryContext(ctx, `SELECT poi_category, count(*) FROM places
|
||
WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' GROUP BY 1 ORDER BY 1`, ds.ID)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
for crows.Next() {
|
||
var c string
|
||
var n int
|
||
if err := crows.Scan(&c, &n); err != nil {
|
||
crows.Close()
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
d.Categories = append(d.Categories, EditOption{Label: c, Name: categoryLabel(c, catNames)})
|
||
// Missing 是「还得你补一个中文名」:自己是枚举、内置表没有**且**你也没填过。
|
||
// 只看内置表会把已经填好的又标成「还没有中文名」(2026-09-20 用户就这么撞上了)。
|
||
d.CategoryRows = append(d.CategoryRows, EditCategory{
|
||
Value: c, Label: categoryLabel(c, catNames), Places: n,
|
||
Custom: catNames[c] != "", Missing: categoryLabelMissing(c) && catNames[c] == "",
|
||
})
|
||
}
|
||
crows.Close()
|
||
|
||
// 活动台账:带上引用到访数,好一眼看出哪些压根没人用
|
||
arows, err := s.db.QueryContext(ctx, `SELECT a.id, COALESCE(a.name,''), a.is_home, a.is_work, count(v.id)
|
||
FROM activities a LEFT JOIN visits v ON v.activity_id=a.id
|
||
WHERE a.dataset_id=$1 GROUP BY a.id, a.name, a.is_home, a.is_work
|
||
ORDER BY 5 DESC, a.name`, ds.ID)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
for arows.Next() {
|
||
var a EditActivity
|
||
if err := arows.Scan(&a.ID, &a.Name, &a.IsHome, &a.IsWork, &a.Visits); err != nil {
|
||
arows.Close()
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
d.ActivityRows = append(d.ActivityRows, a)
|
||
}
|
||
arows.Close()
|
||
|
||
lrows, err := s.db.QueryContext(ctx, fmt.Sprintf(`SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.poi_category,''),
|
||
COALESCE(p.province,''), COALESCE(p.city,''), COALESCE(p.district,''), COALESCE(p.thoroughfare,''),
|
||
COALESCE(p.lat,0), COALESCE(p.lon,0), COALESCE(p.visit_count,0),
|
||
CASE WHEN `+suspectWGS+` THEN TRUE ELSE FALSE END,
|
||
((r.raw->>'ZLATITUDE'))::float8, ((r.raw->>'ZLONGITUDE'))::float8,
|
||
COALESCE(p.coord_source,''), COALESCE(p.coord_sys,''), p.src_lat, p.src_lon,
|
||
COALESCE((SELECT string_agg(x.n || ' ' || x.c, ' · ' ORDER BY x.c DESC, x.n) FROM (
|
||
SELECT COALESCE(a.name,'(无)') AS n, count(*) AS c FROM visits v
|
||
LEFT JOIN activities a ON a.id=v.activity_id
|
||
WHERE v.place_id=p.id GROUP BY 1) x), '')
|
||
FROM places p`+placeRawJoin+` WHERE %s ORDER BY p.visit_count DESC, p.id LIMIT $%d OFFSET $%d`,
|
||
where, len(args)+1, len(args)+2),
|
||
append(args, editPerPage, (d.PageNo-1)*editPerPage)...)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
for lrows.Next() {
|
||
var e EditPlace
|
||
if err := lrows.Scan(&e.ID, &e.SrcPK, &e.Name, &e.Category, &e.Province, &e.City,
|
||
&e.District, &e.Thoroughfare, &e.Lat, &e.Lon, &e.VisitCount, &e.SuspectWGS,
|
||
&e.OrigLat, &e.OrigLon, &e.CoordSource, &e.CoordSys, &e.SrcLat, &e.SrcLon,
|
||
&e.ActivityText); err != nil {
|
||
lrows.Close()
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
// 站点上的坐标改过之后,留档里的那份就是「导入时的原值」,用它做前后对照
|
||
if e.OrigLat.Valid && e.OrigLon.Valid {
|
||
e.MovedM = int(geo.Haversine(e.OrigLat.Float64, e.OrigLon.Float64, e.Lat, e.Lon)*1000 + 0.5)
|
||
}
|
||
e.CategoryOptions = categoryOptions(d.Categories, e.Category, catNames)
|
||
d.Places = append(d.Places, e)
|
||
}
|
||
lrows.Close()
|
||
|
||
// 整个数据集里疑似「没做过偏移」的点数(列表分页,所以单独统计一次)
|
||
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM places p`+placeRawJoin+
|
||
` WHERE p.dataset_id=$1 AND `+suspectWGS, ds.ID).Scan(&d.SuspectCount); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
|
||
// 各坐标来源的数量,给筛选下拉当提示用(三种来源各一次查询太浪费,一次 group by 拿到)
|
||
srows, err := s.db.QueryContext(ctx, `SELECT COALESCE(coord_source,''), count(*) FROM places
|
||
WHERE dataset_id=$1 GROUP BY 1`, ds.ID)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
for srows.Next() {
|
||
var k string
|
||
var n int
|
||
if err := srows.Scan(&k, &n); err != nil {
|
||
srows.Close()
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if k == "" {
|
||
k = placeSourceNone
|
||
}
|
||
d.SourceCounts[k] = n
|
||
}
|
||
srows.Close()
|
||
|
||
// 最近到访(搜索 + 分页)。搜索词与地点列表独立(vq),
|
||
// 匹配地点名 / 城市 / 备注 / 活动名,纯数字再按 src_pk 精确命中一次。
|
||
vwhere := "v.dataset_id=$1"
|
||
vargs := []any{ds.ID}
|
||
if d.VisitQuery != "" {
|
||
vargs = append(vargs, "%"+escapeLike(d.VisitQuery)+"%", d.VisitQuery)
|
||
like, exact := len(vargs)-1, len(vargs)
|
||
// 日期也参与模糊匹配:补录时常按「哪天」找,搜 2026-08-14 或 08-14 都该有结果
|
||
vwhere += fmt.Sprintf(` AND (COALESCE(p.name,'') ILIKE $%d OR COALESCE(p.city,'') ILIKE $%d
|
||
OR COALESCE(v.remark,'') ILIKE $%d OR COALESCE(a.name,'') ILIKE $%d
|
||
OR to_char(v.arrival, 'YYYY-MM-DD') ILIKE $%d OR v.src_pk::text = $%d)`,
|
||
like, like, like, like, like, exact)
|
||
}
|
||
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM visits v
|
||
LEFT JOIN places p ON p.id=v.place_id LEFT JOIN activities a ON a.id=v.activity_id
|
||
WHERE `+vwhere, vargs...).Scan(&d.VisitTotal); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
d.VisitPageCount = max1((d.VisitTotal + editPerPage - 1) / editPerPage)
|
||
if d.VisitPageNo < 1 {
|
||
d.VisitPageNo = 1
|
||
}
|
||
if d.VisitPageNo > d.VisitPageCount {
|
||
d.VisitPageNo = d.VisitPageCount
|
||
}
|
||
vargs = append(vargs, editPerPage, (d.VisitPageNo-1)*editPerPage)
|
||
vrows, err := s.db.QueryContext(ctx, `SELECT v.src_pk, COALESCE(p.name,''), COALESCE(p.city,''),
|
||
COALESCE(a.name,''), COALESCE(a.id,0), v.arrival, v.departure, COALESCE(v.remark,''), COALESCE(v.emoji,''),
|
||
v.bookmarked, v.is_user_added
|
||
FROM visits v LEFT JOIN places p ON p.id=v.place_id LEFT JOIN activities a ON a.id=v.activity_id
|
||
WHERE `+vwhere+fmt.Sprintf(` ORDER BY v.arrival DESC LIMIT $%d OFFSET $%d`, len(vargs)-1, len(vargs)),
|
||
vargs...)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
for vrows.Next() {
|
||
var e EditVisit
|
||
var dep sql.NullTime
|
||
if err := vrows.Scan(&e.SrcPK, &e.PlaceName, &e.City, &e.ActivityName, &e.ActivityID,
|
||
&e.Arrival, &dep, &e.Remark, &e.Emoji, &e.Bookmarked, &e.UserAdded); err != nil {
|
||
vrows.Close()
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
e.ArrivalStr = e.Arrival.Local().Format("2006-01-02T15:04")
|
||
if dep.Valid {
|
||
e.DepartureStr = dep.Time.Local().Format("2006-01-02T15:04")
|
||
}
|
||
d.Visits = append(d.Visits, e)
|
||
}
|
||
vrows.Close()
|
||
|
||
// 行程记录(搜索 + 分页)。两端到访可能为空——真机里就有 35 条这样的位移——
|
||
// 所以 join 一律 LEFT,搜索条件也要能容忍 NULL。
|
||
const movementFrom = ` FROM movements m
|
||
LEFT JOIN visits vf ON vf.dataset_id=m.dataset_id AND vf.src_pk=m.from_visit_src
|
||
LEFT JOIN places pf ON pf.id=vf.place_id
|
||
LEFT JOIN visits vt ON vt.dataset_id=m.dataset_id AND vt.src_pk=m.to_visit_src
|
||
LEFT JOIN places pt ON pt.id=vt.place_id`
|
||
mwhere, margs := "m.dataset_id=$1", []any{ds.ID}
|
||
mf := d.MovementFilter
|
||
switch {
|
||
case mf.Transport == movementFilterNone:
|
||
mwhere += " AND m.transport_src IS NULL"
|
||
case mf.Transport != "":
|
||
if n, err := strconv.Atoi(mf.Transport); err == nil && n > 0 {
|
||
margs = append(margs, n)
|
||
mwhere += fmt.Sprintf(" AND m.transport_src = $%d", len(margs))
|
||
}
|
||
}
|
||
if mf.PlaceID > 0 {
|
||
margs = append(margs, mf.PlaceID)
|
||
n := len(margs)
|
||
mwhere += fmt.Sprintf(" AND (m.from_place_id = $%d OR m.to_place_id = $%d)", n, n)
|
||
}
|
||
// 时间范围按 started_at 比:上界取「到」的次日零点,含当天
|
||
if t, ok := parseDate(mf.From); ok {
|
||
margs = append(margs, t)
|
||
mwhere += fmt.Sprintf(" AND m.started_at >= $%d", len(margs))
|
||
}
|
||
if t, ok := parseDate(mf.To); ok {
|
||
margs = append(margs, t.AddDate(0, 0, 1))
|
||
mwhere += fmt.Sprintf(" AND m.started_at < $%d", len(margs))
|
||
}
|
||
if err := s.db.QueryRowContext(ctx, `SELECT count(*)`+movementFrom+` WHERE `+mwhere, margs...).
|
||
Scan(&d.MovementTotal); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
d.MovementPageCount = max1((d.MovementTotal + editPerPage - 1) / editPerPage)
|
||
if d.MovementPageNo < 1 {
|
||
d.MovementPageNo = 1
|
||
}
|
||
if d.MovementPageNo > d.MovementPageCount {
|
||
d.MovementPageNo = d.MovementPageCount
|
||
}
|
||
mrows, err := s.db.QueryContext(ctx, `SELECT m.src_pk, COALESCE(m.transport_src,0),
|
||
COALESCE(m.transport_name,''), COALESCE(m.transport_color,''),
|
||
COALESCE(m.from_visit_src,0), COALESCE(m.to_visit_src,0),
|
||
COALESCE(pf.name,''), COALESCE(pf.city,''), COALESCE(pt.name,''), COALESCE(pt.city,''),
|
||
m.started_at, m.ended_at, m.duration_min, COALESCE(m.distance_km,0)`+movementFrom+
|
||
fmt.Sprintf(` WHERE %s ORDER BY m.started_at DESC NULLS LAST, m.src_pk DESC LIMIT $%d OFFSET $%d`,
|
||
mwhere, len(margs)+1, len(margs)+2),
|
||
append(margs, editPerPage, (d.MovementPageNo-1)*editPerPage)...)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
for mrows.Next() {
|
||
var e EditMovement
|
||
var start, end sql.NullTime
|
||
var dur sql.NullInt64
|
||
var fromName, toName string
|
||
if err := mrows.Scan(&e.SrcPK, &e.TransportSrc, &e.TransportName, &e.TransportColor,
|
||
&e.FromSrc, &e.ToSrc, &fromName, &e.FromCity, &toName, &e.ToCity,
|
||
&start, &end, &dur, &e.DistanceKm); err != nil {
|
||
mrows.Close()
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
e.FromLabel = visitEndpointLabel(e.FromSrc, fromName)
|
||
e.ToLabel = visitEndpointLabel(e.ToSrc, toName)
|
||
if start.Valid {
|
||
e.StartStr = start.Time.Local().Format("2006-01-02T15:04")
|
||
}
|
||
if end.Valid {
|
||
e.EndStr = end.Time.Local().Format("2006-01-02T15:04")
|
||
}
|
||
// 时长以 duration_min 为准;历史行没有这一列(NULL/0)时按起止时间现算,
|
||
// 免得列表里一堆行程都显示 0 分钟
|
||
if dur.Valid && dur.Int64 > 0 {
|
||
e.DurationMin = dur.Int64
|
||
} else if start.Valid && end.Valid {
|
||
e.DurationMin = int64(end.Time.Sub(start.Time).Minutes())
|
||
}
|
||
if start.Valid && end.Valid {
|
||
e.Span = movementSpan(start.Time.Local(), end.Time.Local())
|
||
}
|
||
d.Movements = append(d.Movements, e)
|
||
}
|
||
mrows.Close()
|
||
|
||
// 新增到访用的地点下拉(按到访数取前 300)。只给「城市 · 名称」时同名地点
|
||
// 完全无法区分,把省市区、街道、到访数和 src_pk 一起带出来——src_pk 还能
|
||
// 直接对着行程表单里要填的值抄。
|
||
prows, err := s.db.QueryContext(ctx, `SELECT id, src_pk, COALESCE(name,''), COALESCE(city,''),
|
||
COALESCE(province,''), COALESCE(NULLIF(sublocality,''), NULLIF(district,''), ''),
|
||
COALESCE(thoroughfare,''), visit_count FROM places
|
||
WHERE dataset_id=$1 ORDER BY visit_count DESC, id LIMIT 300`, ds.ID)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
for prows.Next() {
|
||
var o EditOption
|
||
var city, province, area, thoroughfare string
|
||
var srcPK, visitCount int
|
||
if err := prows.Scan(&o.ID, &srcPK, &o.Name, &city, &province, &area, &thoroughfare, &visitCount); err != nil {
|
||
prows.Close()
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
o.Label = fmt.Sprintf("#%d %s — %s(%d次)", srcPK, o.Name,
|
||
placeDesc(city, province, area, thoroughfare), visitCount)
|
||
d.PlaceOptions = append(d.PlaceOptions, o)
|
||
}
|
||
prows.Close()
|
||
|
||
// 最近到访:行程的起止端点、天气的关联到访都从这里选(手填 src_pk 太难用)。
|
||
// 取最近 500 条——行程补录基本发生在近期,真要更早的可以先用筛选缩小列表。
|
||
oprows, err := s.db.QueryContext(ctx, `SELECT v.src_pk, v.arrival, COALESCE(p.name,'') FROM visits v
|
||
LEFT JOIN places p ON p.id=v.place_id WHERE v.dataset_id=$1 ORDER BY v.arrival DESC LIMIT 500`, ds.ID)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
for oprows.Next() {
|
||
var pk int
|
||
var arrival time.Time
|
||
var pname string
|
||
if err := oprows.Scan(&pk, &arrival, &pname); err != nil {
|
||
oprows.Close()
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if pname == "" {
|
||
pname = "(未命名地点)"
|
||
}
|
||
label := fmt.Sprintf("#%d %s %s", pk, arrival.Local().Format("2006-01-02 15:04"), pname)
|
||
d.VisitOptions = append(d.VisitOptions, EditOption{SrcPK: pk, Label: label, Name: label})
|
||
}
|
||
oprows.Close()
|
||
|
||
s.render(w, "admin/edit", d)
|
||
}
|
||
|
||
// categoryOptions 组出某一行的类别下拉:库里的已有类别,当前值不在其中时补进列表,
|
||
// 否则真机里那些非常见类别会被静默改掉或显示为空。
|
||
func categoryOptions(all []EditOption, cur string, custom map[string]string) []EditOption {
|
||
cur = strings.TrimSpace(cur)
|
||
out := make([]EditOption, 0, len(all)+1)
|
||
found := false
|
||
for _, c := range all {
|
||
if c.Label == cur {
|
||
found = true
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
if cur != "" && !found {
|
||
out = append(out, EditOption{Label: cur, Name: categoryLabel(cur, custom)})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// pickCategory 处理类别下拉:选到「自定义…」时用旁边输入框的值。
|
||
func pickCategory(r *http.Request) string {
|
||
v := strings.TrimSpace(r.PostFormValue("category"))
|
||
if v == categoryCustomValue {
|
||
return strings.TrimSpace(r.PostFormValue("category_custom"))
|
||
}
|
||
return v
|
||
}
|
||
|
||
// categoryCustomValue 是类别下拉里「自定义…」这一项的提交值。
|
||
const categoryCustomValue = "__custom__"
|
||
|
||
// activityNewValue 是活动下拉里「+ 新增活动…」这一项的提交值。前端有提交守卫,
|
||
// 服务端再拦一道:哨兵当 id 解析不出整数,会静默变成「无活动」。
|
||
const activityNewValue = "__new__"
|
||
|
||
// poiCategoryText 把 rond/MapKit 的 POI 分类标识符换成中文。
|
||
// 库里存的原值是 MKPOICategoryXxx(rond 靠它显示图标),表单里给人看中文。
|
||
// poiCategoryText 是类别值 → 中文名 的**内置默认**对照,站点可以用 category_labels 覆盖它。
|
||
// MapKit 只认 MKPOICategory<名字> 这种枚举串,中文名纯粹是站内显示用的。
|
||
//
|
||
// 这里原来是两张表(view.go 的 poiNames 给补全页静态项和 GPX,这张给编辑页),
|
||
// 两边还对不上:FoodMarket 一处「菜市场」一处「食品市场」、Cafe 一处「咖啡馆」一处「咖啡厅」、
|
||
// FitnessCenter 一处「健身房」一处「健身中心」,ATM / Atm 大小写也各写各的。
|
||
// 2026-09-20 合成这一张(冲突处取编辑页那套),并删掉永远匹配不上的 MKPOICategoryMarina2。
|
||
var poiCategoryText = map[string]string{
|
||
"MKPOICategoryATM": "取款机",
|
||
"MKPOICategoryAirport": "机场",
|
||
"MKPOICategoryAmusementPark": "游乐园",
|
||
"MKPOICategoryAquarium": "水族馆",
|
||
"MKPOICategoryAtm": "取款机",
|
||
"MKPOICategoryBakery": "面包店",
|
||
"MKPOICategoryBank": "银行",
|
||
"MKPOICategoryBar": "酒吧",
|
||
"MKPOICategoryBeach": "海滩",
|
||
"MKPOICategoryCafe": "咖啡馆",
|
||
"MKPOICategoryCampground": "露营地",
|
||
"MKPOICategoryCarRental": "租车",
|
||
"MKPOICategoryCinema": "电影院",
|
||
"MKPOICategoryFireStation": "消防站",
|
||
"MKPOICategoryFitnessCenter": "健身房",
|
||
"MKPOICategoryFoodMarket": "菜市场",
|
||
"MKPOICategoryGasStation": "加油站",
|
||
"MKPOICategoryHospital": "医院",
|
||
"MKPOICategoryHotel": "酒店",
|
||
"MKPOICategoryLandmark": "地标",
|
||
"MKPOICategoryLibrary": "图书馆",
|
||
"MKPOICategoryMarina": "码头",
|
||
"MKPOICategoryMovieTheater": "电影院",
|
||
"MKPOICategoryMuseum": "博物馆",
|
||
"MKPOICategoryNationalPark": "国家公园",
|
||
"MKPOICategoryNightlife": "夜生活",
|
||
"MKPOICategoryPark": "公园",
|
||
"MKPOICategoryParking": "停车场",
|
||
"MKPOICategoryPharmacy": "药店",
|
||
"MKPOICategoryPolice": "派出所",
|
||
"MKPOICategoryPostOffice": "邮局",
|
||
"MKPOICategoryPublicTransport": "公共交通",
|
||
"MKPOICategoryRestaurant": "餐厅",
|
||
"MKPOICategoryRestroom": "洗手间",
|
||
"MKPOICategorySchool": "学校",
|
||
"MKPOICategorySpa": "水疗",
|
||
"MKPOICategoryStadium": "体育场",
|
||
"MKPOICategoryStore": "商店",
|
||
"MKPOICategoryTheater": "剧院",
|
||
"MKPOICategoryTrainStation": "火车站",
|
||
"MKPOICategoryUniversity": "大学",
|
||
"MKPOICategoryZoo": "动物园",
|
||
}
|
||
|
||
// poiCategoryLabel 是内置默认名(不含站点覆盖)。
|
||
func poiCategoryLabel(v string) string {
|
||
if l, ok := poiCategoryText[v]; ok {
|
||
return l
|
||
}
|
||
return strings.TrimPrefix(v, "MKPOICategory")
|
||
}
|
||
|
||
// categoryLabel 取类别的显示名:站点自定义的优先,其次内置默认,都没有就退回去掉前缀的原值。
|
||
// 只影响显示——导出到 rond 用的是 places.poi_category 本身,显示名不会被写回去。
|
||
func categoryLabel(v string, custom map[string]string) string {
|
||
if l, ok := custom[v]; ok && l != "" {
|
||
return l
|
||
}
|
||
return poiCategoryLabel(v)
|
||
}
|
||
|
||
// categoryLabelMissing 判断这个值是不是「还没有中文名」:是 MapKit 枚举但内置表里没有,
|
||
// 显示出来只会是 Foo 这种半截英文,该由站长补一个。中文值(站内自己写的)自带名字,不算缺。
|
||
func categoryLabelMissing(v string) bool {
|
||
if !strings.HasPrefix(v, "MKPOICategory") {
|
||
return false
|
||
}
|
||
_, ok := poiCategoryText[v]
|
||
return !ok
|
||
}
|
||
|
||
// knownCategoryNames 列出内置表认得的枚举名(排序后)。页面改「导出值」时拿它提醒:
|
||
// 真机只认它自己那套枚举,拼错的串站内看不出问题,导回手机后那个地点就显示不出类别了。
|
||
func knownCategoryNames() []string {
|
||
out := make([]string, 0, len(poiCategoryText))
|
||
for k := range poiCategoryText {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// categoryLabels 读站点自定义的类别显示名。读不到就是空 map,各处自动退回内置名。
|
||
func (s *Server) categoryLabels(ctx context.Context) map[string]string {
|
||
out := map[string]string{}
|
||
rows, err := s.db.QueryContext(ctx, `SELECT value, label FROM category_labels`)
|
||
if err != nil {
|
||
log.Printf("读类别显示名失败: %v", err)
|
||
return out
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var v, l string
|
||
if err := rows.Scan(&v, &l); err != nil {
|
||
log.Printf("读类别显示名失败: %v", err)
|
||
return out
|
||
}
|
||
out[v] = l
|
||
}
|
||
return out
|
||
}
|
||
|
||
// placeDesc 把省市区拼成一段(相邻重复段只留一个,直辖市省名=市名时不会出现
|
||
// 「北京市北京市」这种),街道有值再接上。
|
||
func placeDesc(city, province, area, thoroughfare string) string {
|
||
segs := make([]string, 0, 3)
|
||
for _, s := range []string{province, city, area} {
|
||
if s == "" {
|
||
continue
|
||
}
|
||
if len(segs) > 0 && segs[len(segs)-1] == s {
|
||
continue
|
||
}
|
||
segs = append(segs, s)
|
||
}
|
||
if thoroughfare != "" {
|
||
segs = append(segs, thoroughfare)
|
||
}
|
||
return strings.Join(segs, " ")
|
||
}
|
||
|
||
// editOptions 读取 (id, name) 形式的选项列表,供表单下拉/勾选使用。
|
||
func (s *Server) editOptions(ctx context.Context, query string, args ...any) ([]EditOption, error) {
|
||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []EditOption
|
||
for rows.Next() {
|
||
var o EditOption
|
||
if err := rows.Scan(&o.ID, &o.Name); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, o)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// pruneEntityRaw 清掉留档里已经不在库中的行。这些行导出时本来就不会写回,
|
||
// 留着反而危险:新建取的是 MAX(src_pk)+1,一旦删掉编号最大的那条,下一次新建
|
||
// 就会复用同一个 src_pk,于是新行继承旧留档的 rond 私有字段(ZRADIUS/ZRAW 等)。
|
||
// skipped=TRUE 的行必须保留——它们本来就是「因缺必填字段没进库、导出时要补回」的行。
|
||
// 清理失败只记日志:留档是保真度增强,不该阻断用户的删除操作。
|
||
func (s *Server) pruneEntityRaw(ctx context.Context, datasetID int64) {
|
||
if _, err := s.db.ExecContext(ctx, `DELETE FROM entity_raw r
|
||
WHERE r.dataset_id=$1 AND NOT r.skipped AND (
|
||
(r.entity='ZLOCATION' AND NOT EXISTS (SELECT 1 FROM places p WHERE p.dataset_id=r.dataset_id AND p.src_pk=r.src_pk))
|
||
OR (r.entity='ZVISIT' AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=r.dataset_id AND v.src_pk=r.src_pk))
|
||
OR (r.entity='ZMOVEMENT' AND NOT EXISTS (SELECT 1 FROM movements m WHERE m.dataset_id=r.dataset_id AND m.src_pk=r.src_pk))
|
||
OR (r.entity='ZHOURLYWEATHER' AND NOT EXISTS (SELECT 1 FROM weather w WHERE w.dataset_id=r.dataset_id AND w.src_pk=r.src_pk))
|
||
OR (r.entity='ZACTIVITY' AND NOT EXISTS (SELECT 1 FROM activities a WHERE a.dataset_id=r.dataset_id AND a.src_pk=r.src_pk))
|
||
OR (r.entity='ZTAG' AND NOT EXISTS (SELECT 1 FROM tags t WHERE t.dataset_id=r.dataset_id AND t.src_pk=r.src_pk))
|
||
)`, datasetID); err != nil {
|
||
log.Printf("清理原始行留档失败: %v", err)
|
||
}
|
||
}
|
||
|
||
// ---------- 地点 ----------
|
||
|
||
// editActivityUpdate 重命名活动类型。
|
||
func (s *Server) editActivityUpdate(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.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
id, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("id")), 10, 64)
|
||
name := strings.TrimSpace(r.PostFormValue("name"))
|
||
if id <= 0 || name == "" {
|
||
s.json(w, map[string]any{"error": "请选择活动并填写新名称"})
|
||
return
|
||
}
|
||
res, err := s.db.ExecContext(ctx, `UPDATE activities SET name=$3 WHERE id=$1 AND dataset_id=$2`, id, ds.ID, name)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
s.json(w, map[string]any{"error": "活动不存在"})
|
||
return
|
||
}
|
||
s.json(w, map[string]any{"id": id, "name": name})
|
||
}
|
||
|
||
// editActivityDelete 删除活动类型。引用它的到访变成「未分类」——
|
||
// rond 里 ZVISIT.ZACTIVITY_ 本就允许为空,导出时该列写 NULL。
|
||
func (s *Server) editActivityDelete(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.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
id, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("id")), 10, 64)
|
||
if id <= 0 {
|
||
s.json(w, map[string]any{"error": "请选择要删除的活动"})
|
||
return
|
||
}
|
||
// is_home / is_work 是从活动上抄到到访的冗余标记,活动没了就该一起清掉
|
||
if _, err := s.db.ExecContext(ctx, `UPDATE visits SET activity_id=NULL, is_home=FALSE, is_work=FALSE
|
||
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, id); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
res, err := s.db.ExecContext(ctx, `DELETE FROM activities WHERE id=$1 AND dataset_id=$2`, id, ds.ID)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
s.json(w, map[string]any{"error": "活动不存在"})
|
||
return
|
||
}
|
||
s.pruneEntityRaw(ctx, ds.ID)
|
||
s.refreshDatasetAgg(ctx, ds.ID)
|
||
s.json(w, map[string]any{"ok": true})
|
||
}
|
||
|
||
// editCategoryRename 把某个类别整体改名。改成另一个**已经有地点在用**的类别就是合并——
|
||
// 两者本来就是同一条 UPDATE,所以不另做「合并」按钮。
|
||
func (s *Server) editCategoryRename(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.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
from := strings.TrimSpace(r.PostFormValue("from"))
|
||
to := strings.TrimSpace(r.PostFormValue("to"))
|
||
if from == "" || to == "" {
|
||
s.json(w, map[string]any{"error": "新旧类别都要填;要摘掉类别请用「清空」"})
|
||
return
|
||
}
|
||
if from == to {
|
||
s.json(w, map[string]any{"error": "新旧类别一样,没得改"})
|
||
return
|
||
}
|
||
res, err := s.db.ExecContext(ctx, `UPDATE places SET poi_category=$3
|
||
WHERE dataset_id=$1 AND poi_category=$2`, ds.ID, from, to)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
s.json(w, map[string]any{"ok": true, "places": n, "to": to, "label": categoryLabel(to, s.categoryLabels(ctx))})
|
||
}
|
||
|
||
// editCategoryClear 把某个类别从所有地点上摘掉(那些地点退回「未指定」)。
|
||
// 类别的值就长在 places.poi_category 上,没有独立的表,所以「删除类别」只能落成这个。
|
||
func (s *Server) editCategoryClear(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.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
from := strings.TrimSpace(r.PostFormValue("from"))
|
||
if from == "" {
|
||
s.json(w, map[string]any{"error": "请选择要清空的类别"})
|
||
return
|
||
}
|
||
res, err := s.db.ExecContext(ctx, `UPDATE places SET poi_category=NULL
|
||
WHERE dataset_id=$1 AND poi_category=$2`, ds.ID, from)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
s.json(w, map[string]any{"ok": true, "places": n})
|
||
}
|
||
|
||
// editActivityMerge 把 from 的到访全部转到 to 名下,然后删掉 from。
|
||
// 改名解决不了「两个名字指同一件事」(「活动」和「散步」这种),只有合并能收敛。
|
||
func (s *Server) editActivityMerge(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.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
from, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("from")), 10, 64)
|
||
to, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("to")), 10, 64)
|
||
if from <= 0 || to <= 0 || from == to {
|
||
s.json(w, map[string]any{"error": "请选两个不同的活动"})
|
||
return
|
||
}
|
||
var moved int64
|
||
if err := s.withTx(ctx, func(ex execer) error {
|
||
// 两端都必须是本数据集的:否则能把别处的到访「拐」过来
|
||
var n int
|
||
if err := ex.QueryRowContext(ctx, `SELECT count(*) FROM activities
|
||
WHERE dataset_id=$1 AND id IN ($2,$3)`, ds.ID, from, to).Scan(&n); err != nil {
|
||
return err
|
||
}
|
||
if n != 2 {
|
||
return errors.New("活动不存在")
|
||
}
|
||
// is_home / is_work 是从活动抄到到访上的冗余标记,跟着目标活动走
|
||
isHome, isWork := s.activityFlags(ctx, ex, to)
|
||
res, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, is_home=$4, is_work=$5
|
||
WHERE dataset_id=$1 AND activity_id=$2`, ds.ID, from, to, isHome, isWork)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
moved, _ = res.RowsAffected()
|
||
_, err = ex.ExecContext(ctx, `DELETE FROM activities WHERE id=$1 AND dataset_id=$2`, from, ds.ID)
|
||
return err
|
||
}); err != nil {
|
||
s.json(w, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
s.pruneEntityRaw(ctx, ds.ID)
|
||
s.refreshDatasetAgg(ctx, ds.ID)
|
||
s.json(w, map[string]any{"ok": true, "visits": moved})
|
||
}
|
||
|
||
// editCategoryLabel 改类别在站内的**显示名**。跟改名/清空是两回事:
|
||
// 这里只动 category_labels,绝不碰 places.poi_category——显示名不会被导出写回手机。
|
||
// 传空表示恢复内置默认名。
|
||
func (s *Server) editCategoryLabel(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
value := strings.TrimSpace(r.PostFormValue("value"))
|
||
label := strings.TrimSpace(r.PostFormValue("label"))
|
||
if value == "" {
|
||
s.json(w, map[string]any{"error": "请指定要改名的类别值"})
|
||
return
|
||
}
|
||
if label == "" {
|
||
if _, err := s.db.ExecContext(ctx, `DELETE FROM category_labels WHERE value=$1`, value); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
s.json(w, map[string]any{"ok": true, "value": value, "label": poiCategoryLabel(value), "custom": false})
|
||
return
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, `INSERT INTO category_labels (value, label) VALUES ($1,$2)
|
||
ON CONFLICT (value) DO UPDATE SET label=EXCLUDED.label, updated_at=now()`, value, label); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
s.json(w, map[string]any{"ok": true, "value": value, "label": label, "custom": true})
|
||
}
|
||
|
||
// editPlaceCreate 是最前面那个融合表单(新建地点 / 给已有地点补到访)的处理函数。
|
||
func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
u := userFrom(ctx)
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectEdit(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
|
||
// 融合表单:mode=existing 只给已有地点补一条到访;mode=new 建地点,
|
||
// 到访时段选填(留空则只记录地点)。
|
||
//
|
||
// 先把所有输入校验完再落库:建地点与建到访是一个事务,校验没过就什么都不写。
|
||
// 以前是先建地点、再校验时段,时段不合法就留下一个没有到访的地点——它导进 rond
|
||
// 后「最近活跃时间」永远是「无数据」,用户却以为自己填了时间。
|
||
var placeID int64
|
||
var nextSrc int
|
||
isNewPlace := r.PostFormValue("mode") != "existing"
|
||
|
||
// 到访时段选填:留空则只记录地点(补已有地点时留空没有意义,给出提示)
|
||
var arrival time.Time
|
||
var departure *time.Time
|
||
hasVisit := strings.TrimSpace(r.PostFormValue("arrival")) != ""
|
||
if hasVisit {
|
||
var ok bool
|
||
var msg string
|
||
if arrival, departure, ok, msg = parseVisitTimes(r); !ok {
|
||
redirectEdit(w, r, "err", msg)
|
||
return
|
||
}
|
||
} else if !isNewPlace {
|
||
redirectEdit(w, r, "err", "给已有地点补到访时,请填写到达时间")
|
||
return
|
||
}
|
||
// 活动可选:历史数据里本就存在无活动的到访
|
||
if strings.TrimSpace(r.PostFormValue("activity_id")) == activityNewValue {
|
||
redirectEdit(w, r, "err", "活动选了「+ 新增活动…」但还没点「添加」")
|
||
return
|
||
}
|
||
actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue("activity_id"))
|
||
tags := r.PostForm["tags"]
|
||
|
||
if !isNewPlace {
|
||
placeID, err = strconv.ParseInt(strings.TrimSpace(r.PostFormValue("existing_place")), 10, 64)
|
||
if err != nil || placeID <= 0 {
|
||
redirectEdit(w, r, "err", "请选择要补到访的地点")
|
||
return
|
||
}
|
||
if err := s.db.QueryRowContext(ctx, `SELECT src_pk FROM places WHERE id=$1 AND dataset_id=$2`, placeID, ds.ID).Scan(new(int)); err != nil {
|
||
redirectEdit(w, r, "err", "所选地点不存在")
|
||
return
|
||
}
|
||
if _, err := s.insertVisit(ctx, s.db, ds.ID, u.ID, placeID, arrival, departure, actID, isHome, isWork,
|
||
r.PostFormValue("bookmarked") == "on",
|
||
strings.TrimSpace(r.PostFormValue("remark")), strings.TrimSpace(r.PostFormValue("emoji")), tags); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
s.refreshDatasetAgg(ctx, ds.ID)
|
||
redirectEdit(w, r, "ok", "已为所选地点新增到访")
|
||
return
|
||
}
|
||
|
||
name := strings.TrimSpace(r.PostFormValue("name"))
|
||
if name == "" {
|
||
redirectEdit(w, r, "err", "请填写地点名称")
|
||
return
|
||
}
|
||
lat, ok1 := parseFloat(r.PostFormValue("lat"))
|
||
lon, ok2 := parseFloat(r.PostFormValue("lon"))
|
||
if !ok1 || !ok2 || (lat == 0 && lon == 0) {
|
||
redirectEdit(w, r, "err", "请在地图上选点、搜索地点或填写有效的经纬度")
|
||
return
|
||
}
|
||
var visitSrc int
|
||
if err := s.withTx(ctx, func(ex execer) error {
|
||
// 编号不能只看 places 的 MAX:留档里还躺着 skipped 行(导入时缺必填字段、
|
||
// 没有对应实体行却占着号),entity_raw 的 Z_PK 可能比 places 还大。
|
||
// 只看 MAX(places) 会复用那些号,导出时新行被当旧行、继承对方的未管理列。
|
||
pk, err := s.nextSrcPK(ctx, ex, ds.ID, placeSeq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// 同号若还留着旧留档(nextSrcPK 的留档查询是兜底,查不动就跳过),先清掉
|
||
if err := clearStaleRaw(ctx, ex, ds.ID, "ZLOCATION", pk); err != nil {
|
||
return err
|
||
}
|
||
nextSrc = pk
|
||
// 这一页的坐标来自地图点选或高德搜索,本来就是 GCJ-02,原样记为来源。
|
||
// 之后用户若用行内「坐标系换算」改掉了,那边的 UPDATE 会把留档一并修正。
|
||
if err := ex.QueryRowContext(ctx, `INSERT INTO places
|
||
(dataset_id, src_pk, name, poi_category, lat, lon, country_code, province, city, district, sublocality, thoroughfare, timezone,
|
||
coord_source, coord_sys, src_lat, src_lon)
|
||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,$6,NULLIF($7,''),NULLIF($8,''),NULLIF($9,''),NULLIF($10,''),NULLIF($11,''),NULLIF($12,''),NULLIF($13,''),
|
||
'manual','gcj02',$5,$6)
|
||
RETURNING id`,
|
||
ds.ID, nextSrc, name, pickCategory(r),
|
||
lat, lon, strings.TrimSpace(r.PostFormValue("country")), strings.TrimSpace(r.PostFormValue("province")),
|
||
strings.TrimSpace(r.PostFormValue("city")), strings.TrimSpace(r.PostFormValue("district")),
|
||
strings.TrimSpace(r.PostFormValue("sublocality")), strings.TrimSpace(r.PostFormValue("thoroughfare")),
|
||
strings.TrimSpace(r.PostFormValue("timezone"))).Scan(&placeID); err != nil {
|
||
return err
|
||
}
|
||
if !hasVisit {
|
||
return nil // 只记地点,不建到访
|
||
}
|
||
visitSrc, err = s.insertVisit(ctx, ex, ds.ID, u.ID, placeID, arrival, departure, actID, isHome, isWork,
|
||
r.PostFormValue("bookmarked") == "on",
|
||
strings.TrimSpace(r.PostFormValue("remark")), strings.TrimSpace(r.PostFormValue("emoji")), tags)
|
||
return err
|
||
}); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
s.refreshDatasetAgg(ctx, ds.ID)
|
||
if hasVisit {
|
||
redirectEdit(w, r, "ok", fmt.Sprintf("已新增地点(src_pk=%d)及到访(src_pk=%d)", nextSrc, visitSrc))
|
||
} else {
|
||
redirectEdit(w, r, "ok", fmt.Sprintf("已新增地点(src_pk=%d)", nextSrc))
|
||
}
|
||
}
|
||
|
||
func (s *Server) editPlaceUpdate(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectEdit(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
src, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("src_pk")))
|
||
if err != nil {
|
||
redirectEdit(w, r, "err", "地点标识无效")
|
||
return
|
||
}
|
||
lat, ok1 := parseFloat(r.PostFormValue("lat"))
|
||
lon, ok2 := parseFloat(r.PostFormValue("lon"))
|
||
if !ok1 || !ok2 {
|
||
redirectEdit(w, r, "err", "经纬度无效")
|
||
return
|
||
}
|
||
// 原坐标:保存后把「移动了多远、往哪个方向」说清楚,坐标换算是这页的常用操作,
|
||
// 改完得能立刻看出效果(改成多少、挪了多少)
|
||
var oldLat, oldLon float64
|
||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(lat,0), COALESCE(lon,0) FROM places
|
||
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&oldLat, &oldLon); err != nil {
|
||
redirectEdit(w, r, "err", "地点不存在")
|
||
return
|
||
}
|
||
// 表单的经纬度框只到 6 位小数,而真机基线的点带长小数(如 26.46800422211455)。
|
||
// 原样回贴再写回去就等于把坐标截短了——只改个备注、改个名称也会悄悄挪点,
|
||
// 导出跟原包逐位对照就对不上。与 keepStoredTime 同一套办法:落在同一个
|
||
// 6 位小数上就沿用库里那份。
|
||
lat = keepStoredCoord(lat, oldLat)
|
||
lon = keepStoredCoord(lon, oldLon)
|
||
// 名称留空时保留原值:历史数据里存在无名称的地点,不该因为编辑就逼着补名字
|
||
//
|
||
// 其余列按「表单提交了才写」处理:行内编辑会把当前值原样带上,所以等价于全覆盖;
|
||
// 而批量换算这类只提交坐标的调用(tools/photo_coord_audit.py)不会把地址细分、
|
||
// 坐标来源留档一起抹掉。坐标来源留档的取值规则见下面 updatePlaceRow 的注释。
|
||
form := placeForm{
|
||
name: strings.TrimSpace(r.PostFormValue("name")), category: pickCategory(r), lat: lat, lon: lon,
|
||
province: strings.TrimSpace(r.PostFormValue("province")),
|
||
city: strings.TrimSpace(r.PostFormValue("city")),
|
||
district: strings.TrimSpace(r.PostFormValue("district")),
|
||
}
|
||
|
||
// 「活动」是把该地点名下**全部到访**一起换类型(地点自己没有活动列,活动挂在到访上)。
|
||
// 这一项不会被清空——空值表示「(不修改)」,行内表单每次都会提交它。
|
||
var actID any
|
||
var actName string
|
||
var actHome, actWork bool
|
||
switch raw := strings.TrimSpace(r.PostFormValue("activity_id")); raw {
|
||
case "":
|
||
case activityNewValue:
|
||
redirectEdit(w, r, "err", "活动选了「+ 新增活动…」但还没点「添加」")
|
||
return
|
||
default:
|
||
id, home, work := s.resolveActivity(ctx, ds.ID, raw)
|
||
if id == nil {
|
||
redirectEdit(w, r, "err", "所选活动不存在")
|
||
return
|
||
}
|
||
actID, actHome, actWork = id, home, work
|
||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(name,'') FROM activities WHERE id=$1`,
|
||
id).Scan(&actName); err != nil {
|
||
actName = ""
|
||
}
|
||
}
|
||
|
||
// 地点行与它名下到访的活动一起落库:只改完地点就失败,会留下「提示说改了、其实没改」的状态
|
||
var actVisits int64
|
||
if err := s.withTx(ctx, func(ex execer) error {
|
||
if _, err := s.updatePlaceRow(ctx, ex, ds.ID, src, form, placeExtraFromForm(r), oldLat, oldLon); err != nil {
|
||
return err
|
||
}
|
||
if actID == nil {
|
||
return nil
|
||
}
|
||
res, err := ex.ExecContext(ctx, `UPDATE visits SET activity_id=$3, is_home=$4, is_work=$5
|
||
WHERE dataset_id=$1 AND place_id=(SELECT id FROM places WHERE dataset_id=$1 AND src_pk=$2)`,
|
||
ds.ID, src, actID, actHome, actWork)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actVisits, _ = res.RowsAffected()
|
||
return nil
|
||
}); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
// 活动的分面计数、「最近活跃」都跟着到访走,改完刷新一次
|
||
if actID != nil {
|
||
s.refreshDatasetAgg(ctx, ds.ID)
|
||
}
|
||
|
||
msg := "地点已更新"
|
||
if moved := geo.Haversine(oldLat, oldLon, lat, lon) * 1000; moved >= 5 {
|
||
msg = fmt.Sprintf("地点已更新:坐标移动到 %.6f, %.6f(%.0f 米,%s方向)",
|
||
lat, lon, moved, bearingLabel(geo.Bearing(oldLat, oldLon, lat, lon)))
|
||
}
|
||
if actID != nil {
|
||
msg += fmt.Sprintf(";该地点 %d 条到访的活动已改为「%s」", actVisits, actName)
|
||
}
|
||
redirectEdit(w, r, "ok", msg)
|
||
}
|
||
|
||
// placeForm 是地点行编辑里必填的那几个字段。
|
||
type placeForm struct {
|
||
name string
|
||
category string
|
||
lat, lon float64
|
||
province, city, district string
|
||
}
|
||
|
||
// placeExtra 是可选的细分字段与坐标来源留档;nil 表示「表单没提交,保持原值」。
|
||
type placeExtra struct {
|
||
thoroughfare, sublocality, country, timezone *string
|
||
coordSource, coordSys *string
|
||
// srcLat/srcLon 只在「表单明确给了原始坐标」时有值(行内换算会把换算前的值带上),
|
||
// 让留档里的原始坐标保持是原始坐标,而不是被换算后的显示坐标顶掉。
|
||
srcLat, srcLon *float64
|
||
}
|
||
|
||
// placeNone 是表单里「未记录」这个选项的提交值:要显式清空一列时用。
|
||
const placeNone = "none"
|
||
|
||
func optForm(v string) *string {
|
||
if v == placeNone {
|
||
s := ""
|
||
return &s
|
||
}
|
||
return &v
|
||
}
|
||
|
||
func placeExtraFromForm(r *http.Request) placeExtra {
|
||
has := func(k string) bool { _, ok := r.PostForm[k]; return ok }
|
||
var e placeExtra
|
||
if has("thoroughfare") {
|
||
e.thoroughfare = optForm(strings.TrimSpace(r.PostFormValue("thoroughfare")))
|
||
}
|
||
if has("sublocality") {
|
||
e.sublocality = optForm(strings.TrimSpace(r.PostFormValue("sublocality")))
|
||
}
|
||
if has("country") {
|
||
e.country = optForm(strings.TrimSpace(r.PostFormValue("country")))
|
||
}
|
||
if has("timezone") {
|
||
e.timezone = optForm(strings.TrimSpace(r.PostFormValue("timezone")))
|
||
}
|
||
if has("coord_source") {
|
||
e.coordSource = optForm(strings.TrimSpace(r.PostFormValue("coord_source")))
|
||
}
|
||
if has("coord_sys") {
|
||
e.coordSys = optForm(strings.TrimSpace(r.PostFormValue("coord_sys")))
|
||
}
|
||
if has("src_lat") && has("src_lon") {
|
||
if a, ok := parseFloat(r.PostFormValue("src_lat")); ok {
|
||
if b, ok2 := parseFloat(r.PostFormValue("src_lon")); ok2 {
|
||
e.srcLat, e.srcLon = &a, &b
|
||
}
|
||
}
|
||
}
|
||
return e
|
||
}
|
||
|
||
// updatePlaceRow 把一行地点的改动落到库里。
|
||
//
|
||
// 坐标来源留档(coord_source / coord_sys / src_lat / src_lon)的规则:
|
||
// - 表单没提交的列一律不动——批量工具只改坐标,不该顺手把留档清掉;
|
||
// - 表单提交了 coord_sys:非空就记下来,空(placeNone)就清空这一组留档;
|
||
// - 原始坐标 src_lat/src_lon:换算时由表单把换算前的值带上来,优先用它;
|
||
// 没带、但坐标确实动过,就认为提交上来的是显示坐标本身(等价于 gcj02)。
|
||
// 坐标没动则一个字都不碰,否则每次改个名字都会把「导入时的原始坐标」冲掉,
|
||
// 行内那个「比导入时移动了 N 米」的对照就没了。
|
||
func (s *Server) updatePlaceRow(ctx context.Context, ex execer, datasetID int64, src int, f placeForm,
|
||
e placeExtra, oldLat, oldLon float64) (int64, error) {
|
||
set := []string{
|
||
"name=COALESCE(NULLIF($3,''), name)", "poi_category=NULLIF($4,'')",
|
||
"lat=$5", "lon=$6", "province=NULLIF($7,'')", "city=NULLIF($8,'')", "district=NULLIF($9,'')",
|
||
}
|
||
args := []any{datasetID, src, f.name, f.category, f.lat, f.lon, f.province, f.city, f.district}
|
||
add := func(expr string, v any) {
|
||
args = append(args, v)
|
||
set = append(set, fmt.Sprintf(expr, len(args)))
|
||
}
|
||
setNull := func(expr string) { set = append(set, expr) }
|
||
if e.thoroughfare != nil {
|
||
add("thoroughfare=NULLIF($%d,'')", *e.thoroughfare)
|
||
}
|
||
if e.sublocality != nil {
|
||
add("sublocality=NULLIF($%d,'')", *e.sublocality)
|
||
}
|
||
if e.country != nil {
|
||
add("country_code=NULLIF($%d,'')", *e.country)
|
||
}
|
||
if e.timezone != nil {
|
||
add("timezone=NULLIF($%d,'')", *e.timezone)
|
||
}
|
||
if e.coordSource != nil {
|
||
add("coord_source=NULLIF($%d,'')", *e.coordSource)
|
||
}
|
||
if e.coordSys != nil {
|
||
add("coord_sys=NULLIF($%d,'')", *e.coordSys)
|
||
if *e.coordSys == "" {
|
||
// 明确选了「未记录」:整组留档一起清掉,别留下对不上的半截数据
|
||
setNull("src_lat=NULL")
|
||
setNull("src_lon=NULL")
|
||
} else if e.srcLat != nil {
|
||
add("src_lat=$%d", *e.srcLat)
|
||
add("src_lon=$%d", *e.srcLon)
|
||
} else if f.lat != oldLat || f.lon != oldLon {
|
||
add("src_lat=$%d", f.lat)
|
||
add("src_lon=$%d", f.lon)
|
||
}
|
||
} else if (f.lat != oldLat || f.lon != oldLon) && e.srcLat != nil {
|
||
add("src_lat=$%d", *e.srcLat)
|
||
add("src_lon=$%d", *e.srcLon)
|
||
}
|
||
res, err := ex.ExecContext(ctx, `UPDATE places SET `+strings.Join(set, ", ")+
|
||
` WHERE dataset_id=$1 AND src_pk=$2`, args...)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
n, _ := res.RowsAffected()
|
||
return n, nil
|
||
}
|
||
|
||
func (s *Server) editPlaceDelete(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectEdit(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
src, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("src_pk")))
|
||
if err != nil {
|
||
redirectEdit(w, r, "err", "地点标识无效")
|
||
return
|
||
}
|
||
// 该地点名下到访的 src_pk 集合(删地点会级联删掉这些到访)
|
||
const visitSrcs = `SELECT src_pk FROM visits WHERE dataset_id=$1
|
||
AND place_id=(SELECT id FROM places WHERE dataset_id=$1 AND src_pk=$2)`
|
||
// 行程以两端到访为锚点:端点没了还留着就是悬空引用,rond 里会指向不存在的到访
|
||
if _, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
|
||
AND (from_visit_src IN (`+visitSrcs+`) OR to_visit_src IN (`+visitSrcs+`))`, ds.ID, src); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
// 天气本身有独立价值,只解除关联,不连坐删除
|
||
if _, err := s.db.ExecContext(ctx, `UPDATE weather SET visit_src=NULL
|
||
WHERE dataset_id=$1 AND visit_src IN (`+visitSrcs+`)`, ds.ID, src); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
s.pruneEntityRaw(ctx, ds.ID)
|
||
s.refreshDatasetAgg(ctx, ds.ID)
|
||
redirectEdit(w, r, "ok", "地点及其到访、关联行程已删除")
|
||
}
|
||
|
||
// ---------- 到访 ----------
|
||
// (新增到访已并入 editPlaceCreate 的融合表单,这里只剩更新 / 删除)
|
||
|
||
// editActivityCreate 内联新建一个活动类型,JSON 返回给融合表单即时加进下拉。
|
||
func (s *Server) editActivityCreate(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.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
name := strings.TrimSpace(r.PostFormValue("name"))
|
||
if name == "" {
|
||
s.json(w, map[string]any{"error": "请填写活动名称"})
|
||
return
|
||
}
|
||
var id int64
|
||
if err := s.withTx(ctx, func(ex execer) error {
|
||
// 编号同其他实体:留档里的 skipped 行可能占着比 activities 更大的号。
|
||
// 活动还会带 ZACTIVITY.ZUID_(导出侧给新建行补的确定性 UUID),编号撞了就会串行。
|
||
pk, err := s.nextSrcPK(ctx, ex, ds.ID, activitySeq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := clearStaleRaw(ctx, ex, ds.ID, "ZACTIVITY", pk); err != nil {
|
||
return err
|
||
}
|
||
return ex.QueryRowContext(ctx, `INSERT INTO activities (dataset_id, src_pk, name, color)
|
||
VALUES ($1, $2, $3, 'blue') RETURNING id`, ds.ID, pk, name).Scan(&id)
|
||
}); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
s.refreshDatasetAgg(ctx, ds.ID)
|
||
s.json(w, map[string]any{"id": id, "name": name})
|
||
}
|
||
|
||
func (s *Server) editVisitUpdate(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectEdit(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
src, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("src_pk")))
|
||
if err != nil {
|
||
redirectEdit(w, r, "err", "到访标识无效")
|
||
return
|
||
}
|
||
arrival, departure, ok, msg := parseVisitTimes(r)
|
||
if !ok {
|
||
redirectEdit(w, r, "err", msg)
|
||
return
|
||
}
|
||
actID, isHome, isWork := s.resolveActivity(ctx, ds.ID, r.PostFormValue("activity_id"))
|
||
var oldPlace int64
|
||
var curArrival time.Time
|
||
var curDeparture sql.NullTime
|
||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0), arrival, departure FROM visits
|
||
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&oldPlace, &curArrival, &curDeparture); err != nil {
|
||
redirectEdit(w, r, "err", "到访记录不存在")
|
||
return
|
||
}
|
||
// 时间字段是分钟精度:只改类型/备注时不能把真机的小数秒(20:03:21.323)截成整分钟
|
||
arrival = keepStoredTime(arrival, &curArrival)
|
||
if departure != nil && curDeparture.Valid {
|
||
kept := keepStoredTime(*departure, &curDeparture.Time)
|
||
departure = &kept
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, `UPDATE visits SET activity_id=$3, arrival=$4, departure=$5, duration_min=$6,
|
||
is_home=$7, is_work=$8, bookmarked=$9, remark=NULLIF($10,''), emoji=NULLIF($11,'')
|
||
WHERE dataset_id=$1 AND src_pk=$2`,
|
||
ds.ID, src, actID, arrival, departure, durationMin(arrival, departure), isHome, isWork,
|
||
r.PostFormValue("bookmarked") == "on",
|
||
strings.TrimSpace(r.PostFormValue("remark")), strings.TrimSpace(r.PostFormValue("emoji"))); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
// 时间改动可能让引用这条到访的行程越界,就地夹回去(见 clampVisitMovements)
|
||
if err := s.clampVisitMovements(ctx, s.db, ds.ID, src); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
s.saveVisitTags(ctx, s.db, ds.ID, src, r.PostForm["tags"])
|
||
if oldPlace > 0 {
|
||
s.refreshPlaceAgg(ctx, s.db, oldPlace)
|
||
}
|
||
s.refreshDatasetAgg(ctx, ds.ID)
|
||
redirectEdit(w, r, "ok", "到访已更新")
|
||
}
|
||
|
||
// adminVisitBookmark 切换一条到访的收藏标记。列表里直接点星标就能取消,
|
||
// 不用展开行内编辑——之前勾上收藏后找不到取消的地方。
|
||
func (s *Server) adminVisitBookmark(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectEdit(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
src, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("src_pk")))
|
||
if err != nil {
|
||
redirectEdit(w, r, "err", "到访标识无效")
|
||
return
|
||
}
|
||
res, err := s.db.ExecContext(ctx, `UPDATE visits SET bookmarked = NOT bookmarked
|
||
WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
redirectEdit(w, r, "err", "到访记录不存在")
|
||
return
|
||
}
|
||
var on bool
|
||
if err := s.db.QueryRowContext(ctx, `SELECT bookmarked FROM visits WHERE dataset_id=$1 AND src_pk=$2`,
|
||
ds.ID, src).Scan(&on); err == nil && on {
|
||
redirectEdit(w, r, "ok", "已标记为收藏")
|
||
return
|
||
}
|
||
redirectEdit(w, r, "ok", "已取消收藏")
|
||
}
|
||
func (s *Server) editVisitDelete(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectEdit(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
src, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("src_pk")))
|
||
if err != nil {
|
||
redirectEdit(w, r, "err", "到访标识无效")
|
||
return
|
||
}
|
||
var placeID int64
|
||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src).Scan(&placeID); err != nil {
|
||
redirectEdit(w, r, "err", "到访记录不存在")
|
||
return
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1
|
||
AND (from_visit_src=$2 OR to_visit_src=$2)`, ds.ID, src); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, `UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src=$2`, ds.ID, src); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if _, err := s.db.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, src); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
s.pruneEntityRaw(ctx, ds.ID)
|
||
if placeID > 0 {
|
||
s.refreshPlaceAgg(ctx, s.db, placeID)
|
||
}
|
||
s.refreshDatasetAgg(ctx, ds.ID)
|
||
redirectEdit(w, r, "ok", "到访已删除")
|
||
}
|
||
|
||
// ---------- 行程 ----------
|
||
|
||
// editTransports 读出该数据集里用到的交通方式(含被引用的行程数),
|
||
// 供下拉选择与「管理方式」用。ZTRANSPORT 在 rond 里是全局表,站点这边不落库,
|
||
// 名称/颜色/图标是冗余存在每条行程上的,因此同一方式的不同行程可能不一致——
|
||
// 取值规则必须与导出侧 queryTransports 一致(取最近编辑过的那条的非空值)。
|
||
func (s *Server) editTransports(ctx context.Context, datasetID int64) ([]EditTransport, error) {
|
||
rows, err := s.db.QueryContext(ctx, `SELECT transport_src, count(*),
|
||
(array_agg(transport_name ORDER BY id DESC) FILTER (WHERE transport_name IS NOT NULL))[1],
|
||
(array_agg(transport_color ORDER BY id DESC) FILTER (WHERE transport_color IS NOT NULL))[1],
|
||
(array_agg(transport_icon ORDER BY id DESC) FILTER (WHERE transport_icon IS NOT NULL))[1]
|
||
FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL
|
||
GROUP BY transport_src ORDER BY transport_src`, datasetID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []EditTransport
|
||
for rows.Next() {
|
||
var t EditTransport
|
||
var name, color, icon sql.NullString
|
||
if err := rows.Scan(&t.SrcPK, &t.Count, &name, &color, &icon); err != nil {
|
||
return nil, err
|
||
}
|
||
t.Name, t.Color, t.Icon = name.String, color.String, icon.String
|
||
out = append(out, t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// visitEndpointLabel 把行程一端的到访渲染成「#编号 地点名」。
|
||
// 没有关联到访(真机里就有)时给一句人话,别留空白格。
|
||
func visitEndpointLabel(src int, name string) string {
|
||
if src <= 0 {
|
||
return "(无关联到访)"
|
||
}
|
||
if name == "" {
|
||
name = "(未命名地点)"
|
||
}
|
||
return fmt.Sprintf("#%d %s", src, name)
|
||
}
|
||
|
||
// movementEnds 是行程一端的到访在库里的状态,用来校验/推导行程区间。
|
||
type movementEnds struct {
|
||
SrcPK int
|
||
PlaceID int64
|
||
Arrive time.Time
|
||
Depart *time.Time
|
||
}
|
||
|
||
// visitEnds 读出一条到访的到达/离开时间与地点。
|
||
func (s *Server) visitEnds(ctx context.Context, ex execer, datasetID int64, src int) (movementEnds, bool) {
|
||
var e movementEnds
|
||
var dep sql.NullTime
|
||
e.SrcPK = src
|
||
if err := ex.QueryRowContext(ctx, `SELECT COALESCE(place_id,0), arrival, departure FROM visits
|
||
WHERE dataset_id=$1 AND src_pk=$2`, datasetID, src).Scan(&e.PlaceID, &e.Arrive, &dep); err != nil {
|
||
return e, false
|
||
}
|
||
if dep.Valid {
|
||
e.Depart = &dep.Time
|
||
}
|
||
return e, true
|
||
}
|
||
|
||
// movementTimes 是库里一条位移的起止时间。
|
||
type movementTimes struct{ Start, End *time.Time }
|
||
|
||
// movementWindowInput 是行程时间区间的输入。StartRaw / EndRaw 是表单提交的
|
||
// datetime-local 值(分钟精度,留空表示按两端到访推断);CurStart / CurEnd 是库里
|
||
// 已有的值(改已有行程时才有)。
|
||
type movementWindowInput struct {
|
||
From, To movementEnds
|
||
StartRaw, EndRaw string
|
||
CurStart, CurEnd *time.Time
|
||
}
|
||
|
||
// movementWindow 校验并补齐行程的起止时间。
|
||
//
|
||
// 真机规律(实测 760 条起点、756 条终点全部满足):位移不早于起点到访的离开时间,
|
||
// 也不晚于终点到访的到达时间。留空就按这两端补默认值——ZSTART_/ZEND_ 在真机里
|
||
// 791/791 都有值,是必填语义,导出不能写 NULL。
|
||
func movementWindow(in movementWindowInput) (time.Time, time.Time, string) {
|
||
lo := in.From.Arrive
|
||
if in.From.Depart != nil {
|
||
lo = *in.From.Depart
|
||
}
|
||
hi := in.To.Arrive
|
||
start, startSet, msg := resolveMovementEnd(in.StartRaw, in.CurStart, lo, "开始时间")
|
||
if msg != "" {
|
||
return time.Time{}, time.Time{}, msg
|
||
}
|
||
end, endSet, msg := resolveMovementEnd(in.EndRaw, in.CurEnd, hi, "结束时间")
|
||
if msg != "" {
|
||
return time.Time{}, time.Time{}, msg
|
||
}
|
||
if !end.After(start) {
|
||
return time.Time{}, time.Time{}, fmt.Sprintf(
|
||
"结束时间(%s)必须晚于开始时间(%s)", end.Format("2006-01-02 15:04"), start.Format("2006-01-02 15:04"))
|
||
}
|
||
// 表单只填到分钟:手填的 20:03 落在真机的 20:03:21 上会被判成「早于离开时间」,
|
||
// 这是取整误差不是时序错误,给一分钟余量;真机数据的违例是 0 条,余量不会放过真问题
|
||
if startSet && start.Before(lo.Add(-time.Minute)) {
|
||
return time.Time{}, time.Time{}, fmt.Sprintf(
|
||
"开始时间不能早于起点到访的离开时间(%s)", lo.Format("2006-01-02 15:04"))
|
||
}
|
||
if endSet && end.After(hi.Add(time.Minute)) {
|
||
return time.Time{}, time.Time{}, fmt.Sprintf(
|
||
"结束时间不能晚于终点到访的到达时间(%s)", hi.Format("2006-01-02 15:04"))
|
||
}
|
||
return start, end, ""
|
||
}
|
||
|
||
// resolveMovementEnd 解析一端的时间。留空就用默认值;**与库里已有的值落在同一分钟
|
||
// 视为「没改」,直接沿用原值**——表单只有分钟精度,照提交值写回会把真机的小数秒
|
||
// (实测 20:03:21.323)截成 20:03:00,于是「只改交通方式」的保存会同时改掉时间,
|
||
// 截断后还早于起点到访的离开时间,被上面的校验直接拒掉。
|
||
func resolveMovementEnd(raw string, cur *time.Time, def time.Time, label string) (time.Time, bool, string) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
if cur != nil {
|
||
return *cur, false, ""
|
||
}
|
||
return def, false, ""
|
||
}
|
||
t, ok := parseLocalDT(raw)
|
||
if !ok {
|
||
return time.Time{}, false, label + "格式不正确"
|
||
}
|
||
kept := keepStoredTime(t, cur)
|
||
return kept, kept.Equal(t), ""
|
||
}
|
||
|
||
// clampVisitMovements 把「引用这条到访」的行程夹回合法窗口。
|
||
//
|
||
// 真机不变量(判据同 movementWindow):位移不早于起点到访的离开时间、不晚于终点到访的到达时间。
|
||
// 但表单对手填时间有 1 分钟容差,而**改到访时间时不会回头校验引用它的行程** —— 把某次到访的
|
||
// 到达时间改早之后,原先合法的行程就变成越界(实测有 2 条超出 26~47 秒,导出包里带着这个违例)。
|
||
// 所以每次改到访时间后按两端到访的当前时间把行程夹回来。
|
||
//
|
||
// 夹不动的情况(窗口被压得比 1 分钟还窄)把结束时间推到开始之后 1 分钟:真机 791/791 条
|
||
// ZSTART_/ZEND_ 都有值,宁可略微越界也不能写 NULL。
|
||
func (s *Server) clampVisitMovements(ctx context.Context, ex execer, datasetID int64, visitSrcPK int) error {
|
||
rows, err := ex.QueryContext(ctx, `SELECT m.src_pk, m.started_at, m.ended_at,
|
||
fl.departure, fl.arrival, tl.arrival
|
||
FROM movements m
|
||
LEFT JOIN visits fl ON fl.dataset_id = m.dataset_id AND fl.src_pk = m.from_visit_src
|
||
LEFT JOIN visits tl ON tl.dataset_id = m.dataset_id AND tl.src_pk = m.to_visit_src
|
||
WHERE m.dataset_id = $1 AND (m.from_visit_src = $2 OR m.to_visit_src = $2)`,
|
||
datasetID, visitSrcPK)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
type fix struct {
|
||
pk int
|
||
start, end time.Time
|
||
}
|
||
var fixes []fix
|
||
for rows.Next() {
|
||
var pk int
|
||
var start, end time.Time
|
||
var fDep, fArr, tArr sql.NullTime
|
||
if err := rows.Scan(&pk, &start, &end, &fDep, &fArr, &tArr); err != nil {
|
||
rows.Close()
|
||
return err
|
||
}
|
||
// 起点侧的下界:有离开时间用离开,没有就用到达
|
||
lo := sql.NullTime{}
|
||
if fDep.Valid {
|
||
lo = fDep
|
||
} else {
|
||
lo = fArr
|
||
}
|
||
ns, ne := clampMovementWindow(start, end, lo, tArr)
|
||
if !ns.Equal(start) || !ne.Equal(end) {
|
||
fixes = append(fixes, fix{pk: pk, start: ns, end: ne})
|
||
}
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return err
|
||
}
|
||
for _, f := range fixes {
|
||
if _, err := ex.ExecContext(ctx, `UPDATE movements SET started_at=$3, ended_at=$4, duration_min=$5
|
||
WHERE dataset_id=$1 AND src_pk=$2`,
|
||
datasetID, f.pk, f.start, f.end, int(f.end.Sub(f.start).Minutes())); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// clampMovementWindow 把一条行程的起止夹进两端到访给出的窗口:
|
||
// lo = 起点到访的离开时间(没有就用到达),hi = 终点到访的到达时间。
|
||
// 任一端的到访缺失(NullTime 无效)就那一侧不夹。
|
||
//
|
||
// 夹完必须 end > start —— 窗口被压得比 1 分钟还窄时把结束推到开始之后 1 分钟:
|
||
// 真机 791/791 条 ZSTART_/ZEND_ 都有值,宁可略微越界也不能写 NULL。
|
||
func clampMovementWindow(start, end time.Time, lo, hi sql.NullTime) (time.Time, time.Time) {
|
||
if lo.Valid && start.Before(lo.Time) {
|
||
start = lo.Time
|
||
}
|
||
if hi.Valid && end.After(hi.Time) {
|
||
end = hi.Time
|
||
}
|
||
if !end.After(start) {
|
||
end = start.Add(time.Minute)
|
||
}
|
||
return start, end
|
||
}
|
||
|
||
// sameMinute 比的是本地时区下的「哪一分钟」:表单值是按本地时区解析出来的,
|
||
// 而库里取回的 timestamptz 常在 UTC,先统一到本地再比。
|
||
func sameMinute(a, b time.Time) bool {
|
||
const m = "2006-01-02T15:04"
|
||
return a.In(time.Local).Format(m) == b.In(time.Local).Format(m)
|
||
}
|
||
|
||
// keepStoredTime 表单提交的时间与库里已有的值落在同一分钟时,沿用库里那份。
|
||
// 页面里的时间输入只到分钟,照提交值写回会把真机的小数秒截掉——改个备注、换个类型
|
||
// 都会顺带把 ZARRIVALDATE_/ZSTART_ 磨掉一点,导出的包就跟原包对不上了。
|
||
func keepStoredTime(t time.Time, cur *time.Time) time.Time {
|
||
if cur != nil && sameMinute(t, *cur) {
|
||
return *cur
|
||
}
|
||
return t
|
||
}
|
||
|
||
// keepStoredCoord 与 keepStoredTime 同一套办法,只是精度从「分钟」换成「6 位小数」:
|
||
// 表单控件只带 6 位,而真机留档的点是长小数。提交值与库里那份落在同一个 6 位小数上,
|
||
// 就说明用户没有真的改坐标,沿用库里那份,避免把小数位截掉。
|
||
func keepStoredCoord(sub, cur float64) float64 {
|
||
if math.Round(sub*1e6) == math.Round(cur*1e6) {
|
||
return cur
|
||
}
|
||
return sub
|
||
}
|
||
|
||
// movementTransport 是表单里选定的交通方式最终要写进库的样子。
|
||
// Src==0 表示这条行程没有交通方式(真机里有 390/791 条如此)。
|
||
type movementTransport struct {
|
||
Src int
|
||
Name string
|
||
Color string
|
||
Icon string
|
||
}
|
||
|
||
// transportNewValue 是交通方式下拉里「新建方式…」这一项的提交值。
|
||
const transportNewValue = "__new__"
|
||
|
||
// resolveMovementTransport 解析表单里的交通方式。取值为「(无) / 已有方式 /
|
||
// 新建方式…」三种;新建时分配新编号。名称/颜色/图标留空表示沿用该方式的现有值——
|
||
// 方式必须有个名字,否则 rond 里会出现一个没有名字的模式。
|
||
func (s *Server) resolveMovementTransport(ctx context.Context, ex execer, datasetID int64, srcRaw, name, color, icon string) (movementTransport, string) {
|
||
srcRaw = strings.TrimSpace(srcRaw)
|
||
if srcRaw == "" {
|
||
return movementTransport{}, ""
|
||
}
|
||
if srcRaw == transportNewValue {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return movementTransport{}, "新建交通方式时请填写方式名称"
|
||
}
|
||
next, err := s.nextSrcPK(ctx, ex, datasetID, transportSeq)
|
||
if err != nil {
|
||
return movementTransport{}, "无法分配交通方式编号:" + err.Error()
|
||
}
|
||
return movementTransport{Src: next, Name: name, Color: strings.TrimSpace(color), Icon: strings.TrimSpace(icon)}, ""
|
||
}
|
||
src, err := strconv.Atoi(srcRaw)
|
||
if err != nil || src <= 0 {
|
||
return movementTransport{}, "交通方式无效"
|
||
}
|
||
var cur EditTransport
|
||
var cname, ccolor, cicon sql.NullString
|
||
if err := ex.QueryRowContext(ctx, `SELECT
|
||
(array_agg(transport_name ORDER BY id DESC) FILTER (WHERE transport_name IS NOT NULL))[1],
|
||
(array_agg(transport_color ORDER BY id DESC) FILTER (WHERE transport_color IS NOT NULL))[1],
|
||
(array_agg(transport_icon ORDER BY id DESC) FILTER (WHERE transport_icon IS NOT NULL))[1]
|
||
FROM movements WHERE dataset_id=$1 AND transport_src=$2`,
|
||
datasetID, src).Scan(&cname, &ccolor, &cicon); err != nil {
|
||
return movementTransport{}, "交通方式不存在"
|
||
}
|
||
cur.Name, cur.Color, cur.Icon = cname.String, ccolor.String, cicon.String
|
||
t := movementTransport{Src: src, Name: cur.Name, Color: cur.Color, Icon: cur.Icon}
|
||
// 留空=保持原值:表单里这三个字段是给「改名/换色」用的,清空不该把方式变成无名模式
|
||
if v := strings.TrimSpace(name); v != "" {
|
||
t.Name = v
|
||
}
|
||
if v := strings.TrimSpace(color); v != "" {
|
||
t.Color = v
|
||
}
|
||
if v := strings.TrimSpace(icon); v != "" {
|
||
t.Icon = v
|
||
}
|
||
return t, ""
|
||
}
|
||
|
||
// syncTransport 把方式的名称/颜色/图标统一写回该方式名下的所有行程。
|
||
// 这些列是冗余存在每条行程上的,不统一的话导出时的聚合结果取决于哪条行程先被读到:
|
||
// rond 里会出现两个同名方式,或者同一方式一会儿叫这个名字一会儿叫那个。
|
||
func syncTransport(ctx context.Context, ex execer, datasetID int64, t movementTransport) error {
|
||
if t.Src <= 0 {
|
||
return nil
|
||
}
|
||
_, err := ex.ExecContext(ctx, `UPDATE movements SET transport_name=NULLIF($3,''),
|
||
transport_color=NULLIF($4,''), transport_icon=NULLIF($5,'')
|
||
WHERE dataset_id=$1 AND transport_src=$2`,
|
||
datasetID, t.Src, t.Name, t.Color, t.Icon)
|
||
return err
|
||
}
|
||
|
||
// editMovementCreate 新增一条行程。
|
||
func (s *Server) editMovementCreate(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
u := userFrom(ctx)
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectEdit(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
src, ok, msg := s.validateMovementForm(ctx, s.db, ds.ID, r, 0)
|
||
if !ok {
|
||
redirectEdit(w, r, "err", msg)
|
||
return
|
||
}
|
||
tr, msg := s.resolveMovementTransport(ctx, s.db, ds.ID,
|
||
r.PostFormValue("transport_src"), r.PostFormValue("transport_name"),
|
||
r.PostFormValue("transport_color"), r.PostFormValue("transport_icon"))
|
||
if msg != "" {
|
||
redirectEdit(w, r, "err", msg)
|
||
return
|
||
}
|
||
if err := s.withTx(ctx, func(ex execer) error {
|
||
pk, err := s.nextSrcPK(ctx, ex, ds.ID, movementSeq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := clearStaleRaw(ctx, ex, ds.ID, "ZMOVEMENT", pk); err != nil {
|
||
return err
|
||
}
|
||
var trSrc, trName, trColor, trIcon any
|
||
if tr.Src > 0 {
|
||
trSrc, trName, trColor, trIcon = tr.Src, nullStr(tr.Name), nullStr(tr.Color), nullStr(tr.Icon)
|
||
}
|
||
if _, err := ex.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)`,
|
||
ds.ID, u.ID, pk, trSrc, trName, trColor, trIcon,
|
||
src.FromVisit, src.ToVisit, nullInt64(src.FromPlace), nullInt64(src.ToPlace),
|
||
src.Start, src.End, src.DurationMin, src.DistanceKm); err != nil {
|
||
return err
|
||
}
|
||
return syncTransport(ctx, ex, ds.ID, tr)
|
||
}); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
redirectEdit(w, r, "ok", "已新增行程")
|
||
}
|
||
|
||
// editMovementUpdate 修改一条行程的方式 / 区间,派生字段(两端地点、里程、时长)一并重算。
|
||
// 位移类型 ZTYPE_ 不在可改范围内:真机里手改过行程的记录也保留原来的类型值。
|
||
func (s *Server) editMovementUpdate(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectEdit(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
pk, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("src_pk")))
|
||
if err != nil || pk <= 0 {
|
||
redirectEdit(w, r, "err", "行程标识无效")
|
||
return
|
||
}
|
||
src, ok, msg := s.validateMovementForm(ctx, s.db, ds.ID, r, pk)
|
||
if !ok {
|
||
redirectEdit(w, r, "err", msg)
|
||
return
|
||
}
|
||
tr, msg := s.resolveMovementTransport(ctx, s.db, ds.ID,
|
||
r.PostFormValue("transport_src"), r.PostFormValue("transport_name"),
|
||
r.PostFormValue("transport_color"), r.PostFormValue("transport_icon"))
|
||
if msg != "" {
|
||
redirectEdit(w, r, "err", msg)
|
||
return
|
||
}
|
||
var trSrc, trName, trColor, trIcon any
|
||
if tr.Src > 0 {
|
||
trSrc, trName, trColor, trIcon = tr.Src, nullStr(tr.Name), nullStr(tr.Color), nullStr(tr.Icon)
|
||
}
|
||
res, err := s.db.ExecContext(ctx, `UPDATE movements SET
|
||
transport_src=$3, transport_name=$4, transport_color=$5, transport_icon=$6,
|
||
from_visit_src=$7, to_visit_src=$8, from_place_id=$9, to_place_id=$10,
|
||
started_at=$11, ended_at=$12, duration_min=$13, distance_km=$14
|
||
WHERE dataset_id=$1 AND src_pk=$2`,
|
||
ds.ID, pk, trSrc, trName, trColor, trIcon,
|
||
src.FromVisit, src.ToVisit, nullInt64(src.FromPlace), nullInt64(src.ToPlace),
|
||
src.Start, src.End, src.DurationMin, src.DistanceKm)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
redirectEdit(w, r, "err", "行程不存在")
|
||
return
|
||
}
|
||
if err := syncTransport(ctx, s.db, ds.ID, tr); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
redirectEdit(w, r, "ok", "行程已更新")
|
||
}
|
||
|
||
// editMovementDelete 删除一条行程。留档要一起清掉:下一个新建可能复用它的 src_pk,
|
||
// 留着旧留档会让新行继承这条的 rond 私有字段(ZTYPE_/ZNOTE_ 之类)。
|
||
func (s *Server) editMovementDelete(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectEdit(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
pk, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("src_pk")))
|
||
if err != nil || pk <= 0 {
|
||
redirectEdit(w, r, "err", "行程标识无效")
|
||
return
|
||
}
|
||
res, err := s.db.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND src_pk=$2`, ds.ID, pk)
|
||
if err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
redirectEdit(w, r, "err", "行程不存在")
|
||
return
|
||
}
|
||
s.pruneEntityRaw(ctx, ds.ID)
|
||
redirectEdit(w, r, "ok", "行程已删除")
|
||
}
|
||
|
||
// movementForm 是校验通过的行程写入参数。
|
||
type movementForm struct {
|
||
FromVisit, ToVisit int
|
||
FromPlace, ToPlace int64
|
||
Start, End time.Time
|
||
DurationMin int
|
||
DistanceKm float64
|
||
}
|
||
|
||
// validateMovementForm 校验表单并算出所有派生字段。pk>0 表示改已有行程(先确认它存在)。
|
||
//
|
||
// 这里的每个校验都对着真机数据:起止到访都存在且不同、位移不早于起点离开、
|
||
// 不晚于终点到达、时长非负。两端的 place_id 与里程也必须一起算出来——
|
||
// 漏了的话这条行程在网页地图上画不出来,而且区域黑名单是按两端地点判定的,
|
||
// 地点为空会让它绕过过滤直接露给访客。
|
||
func (s *Server) validateMovementForm(ctx context.Context, ex execer, datasetID int64, r *http.Request, pk int) (movementForm, bool, string) {
|
||
var f movementForm
|
||
var cur movementTimes
|
||
if pk > 0 {
|
||
var st, en sql.NullTime
|
||
if err := ex.QueryRowContext(ctx, `SELECT started_at, ended_at FROM movements
|
||
WHERE dataset_id=$1 AND src_pk=$2`, datasetID, pk).Scan(&st, &en); err != nil {
|
||
return f, false, "行程不存在"
|
||
}
|
||
if st.Valid {
|
||
cur.Start = &st.Time
|
||
}
|
||
if en.Valid {
|
||
cur.End = &en.Time
|
||
}
|
||
}
|
||
from, _ := strconv.Atoi(strings.TrimSpace(r.PostFormValue("from_visit")))
|
||
to, _ := strconv.Atoi(strings.TrimSpace(r.PostFormValue("to_visit")))
|
||
if from <= 0 || to <= 0 {
|
||
return f, false, "请选择起始到访与结束到访"
|
||
}
|
||
if from == to {
|
||
return f, false, "起始到访与结束到访不能是同一条记录"
|
||
}
|
||
fromEnd, okFrom := s.visitEnds(ctx, ex, datasetID, from)
|
||
if !okFrom {
|
||
return f, false, "起始到访不存在"
|
||
}
|
||
toEnd, okTo := s.visitEnds(ctx, ex, datasetID, to)
|
||
if !okTo {
|
||
return f, false, "结束到访不存在"
|
||
}
|
||
start, end, msg := movementWindow(movementWindowInput{
|
||
From: fromEnd, To: toEnd,
|
||
StartRaw: r.PostFormValue("start"), EndRaw: r.PostFormValue("end"),
|
||
CurStart: cur.Start, CurEnd: cur.End,
|
||
})
|
||
if msg != "" {
|
||
return f, false, msg
|
||
}
|
||
f = movementForm{
|
||
FromVisit: from, ToVisit: to, FromPlace: fromEnd.PlaceID, ToPlace: toEnd.PlaceID,
|
||
Start: start, End: end, DurationMin: int(end.Sub(start).Minutes()),
|
||
}
|
||
// 里程:与导入路径一致,用两端地点的 GCJ-02 坐标算直线距离
|
||
fromCoord, okFromCoord := s.placeCoord(ctx, ex, datasetID, fromEnd.PlaceID)
|
||
toCoord, okToCoord := s.placeCoord(ctx, ex, datasetID, toEnd.PlaceID)
|
||
if okFromCoord && okToCoord && fromCoord != toCoord {
|
||
f.DistanceKm = geo.Haversine(fromCoord[0], fromCoord[1], toCoord[0], toCoord[1])
|
||
}
|
||
return f, true, ""
|
||
}
|
||
|
||
// placeCoord 取地点的显示坐标(GCJ-02)。
|
||
func (s *Server) placeCoord(ctx context.Context, ex execer, datasetID, placeID int64) ([2]float64, bool) {
|
||
if placeID <= 0 {
|
||
return [2]float64{}, false
|
||
}
|
||
var lat, lon float64
|
||
if err := ex.QueryRowContext(ctx, `SELECT COALESCE(lat,0), COALESCE(lon,0) FROM places
|
||
WHERE dataset_id=$1 AND id=$2`, datasetID, placeID).Scan(&lat, &lon); err != nil {
|
||
return [2]float64{}, false
|
||
}
|
||
if lat == 0 && lon == 0 {
|
||
return [2]float64{}, false
|
||
}
|
||
return [2]float64{lat, lon}, true
|
||
}
|
||
|
||
// srcPKSeq 描述一个「站点会新建行」的编号空间。
|
||
type srcPKSeq struct {
|
||
table string // PostgreSQL 表名
|
||
col string // 取最大值的列
|
||
entity string // coredata_meta 里的实体名
|
||
raw string // entity_raw 里的表名(Core Data 表名)
|
||
}
|
||
|
||
var (
|
||
placeSeq = srcPKSeq{"places", "src_pk", "Location", "ZLOCATION"}
|
||
visitSeq = srcPKSeq{"visits", "src_pk", "Visit", "ZVISIT"}
|
||
activitySeq = srcPKSeq{"activities", "src_pk", "Activity", "ZACTIVITY"}
|
||
weatherSeq = srcPKSeq{"weather", "src_pk", "HourlyWeather", "ZHOURLYWEATHER"}
|
||
movementSeq = srcPKSeq{"movements", "src_pk", "Movement", "ZMOVEMENT"}
|
||
tagSeq = srcPKSeq{"tags", "src_pk", "Tag", "ZTAG"}
|
||
transportSeq = srcPKSeq{"movements", "transport_src", "Transport", "ZTRANSPORT"}
|
||
)
|
||
|
||
// nextSrcPK 给站点新建的行分配编号:库里该列的最大值、导入时 Core Data 的最大主键、
|
||
// 整行留档里的最大主键,三者取大再加一。
|
||
//
|
||
// 只按库里现有数据取最大值时,「删掉编号最大的那条」会让下一次新建复用同一个号——
|
||
// 导出时那个号在基线里已经存在,新行会被当成旧行去 UPDATE,继承对方的 rond 私有字段
|
||
// (位移的 ZTYPE_ 就是这么来的);对交通方式更糟:会静默改掉一个已有模式的名字。
|
||
// 所以这里必须同时看导入时的编号上限,而它只在导入那一刻能读到。
|
||
func (s *Server) nextSrcPK(ctx context.Context, ex execer, datasetID int64, q srcPKSeq) (int, error) {
|
||
var max int
|
||
if err := ex.QueryRowContext(ctx, fmt.Sprintf(
|
||
`SELECT COALESCE(MAX(%s),0) FROM %s WHERE dataset_id=$1`, q.col, q.table), datasetID).Scan(&max); err != nil {
|
||
return 0, err
|
||
}
|
||
// 导入时抓到的 Z_PRIMARYKEY.Z_MAX:老数据集没抓到(NULL)时退回 0
|
||
var metaMax int
|
||
if err := ex.QueryRowContext(ctx, `SELECT COALESCE((coredata_meta->'entities'->$2->>'max')::int,0)
|
||
FROM datasets WHERE id=$1`, datasetID, q.entity).Scan(&metaMax); err == nil && metaMax > max {
|
||
max = metaMax
|
||
}
|
||
// 留档是逐行的原始记录,可作为兜底(表不存在或查不动就忽略)
|
||
var rawMax int
|
||
if err := ex.QueryRowContext(ctx, `SELECT COALESCE(MAX((raw->>'Z_PK')::numeric),0)::int FROM entity_raw
|
||
WHERE dataset_id=$1 AND entity=$2 AND (raw->>'Z_PK') ~ '^[0-9]+$'`, datasetID, q.raw).
|
||
Scan(&rawMax); err == nil && rawMax > max {
|
||
max = rawMax
|
||
}
|
||
return max + 1, nil
|
||
}
|
||
|
||
// clearStaleRaw 删掉指定实体/编号的旧留档。新建行的编号若与已删记录重合,
|
||
// 留着旧留档会让新行在导出时被当成那条旧记录写回。
|
||
func clearStaleRaw(ctx context.Context, ex execer, datasetID int64, entity string, pk int) error {
|
||
_, err := ex.ExecContext(ctx, `DELETE FROM entity_raw WHERE dataset_id=$1 AND entity=$2 AND src_pk=$3`,
|
||
datasetID, entity, pk)
|
||
return err
|
||
}
|
||
|
||
func nullInt64(v int64) any {
|
||
if v <= 0 {
|
||
return nil
|
||
}
|
||
return v
|
||
}
|
||
|
||
// ---------- 天气 ----------
|
||
|
||
func (s *Server) editWeatherCreate(w http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
u := userFrom(ctx)
|
||
ds, err := s.datasetFor(ctx)
|
||
if err != nil || ds == nil {
|
||
redirectEdit(w, r, "err", "还没有可编辑的数据集")
|
||
return
|
||
}
|
||
if err := r.ParseForm(); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
at, ok := parseLocalDT(r.PostFormValue("at"))
|
||
if !ok {
|
||
redirectEdit(w, r, "err", "请填写有效的天气时间")
|
||
return
|
||
}
|
||
var visitSrc any
|
||
if v := strings.TrimSpace(r.PostFormValue("visit_src")); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||
visitSrc = n
|
||
}
|
||
}
|
||
temp, _ := parseFloat(r.PostFormValue("temperature_c"))
|
||
var nextSrc int
|
||
if err := s.withTx(ctx, func(ex execer) error {
|
||
// 与地点同一套:留档里可能躺着比 weather 表更大的号(skipped 行),只看 MAX 会复用
|
||
pk, err := s.nextSrcPK(ctx, ex, ds.ID, weatherSeq)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := clearStaleRaw(ctx, ex, ds.ID, "ZHOURLYWEATHER", pk); err != nil {
|
||
return err
|
||
}
|
||
nextSrc = pk
|
||
_, err = ex.ExecContext(ctx, `INSERT INTO weather
|
||
(dataset_id, user_id, src_pk, visit_src, at, temperature_c, condition, symbol)
|
||
VALUES ($1,$2,$3,$4,$5,$6,NULLIF($7,''),NULLIF($8,''))`,
|
||
ds.ID, u.ID, nextSrc, visitSrc, at, temp,
|
||
strings.TrimSpace(r.PostFormValue("condition")), strings.TrimSpace(r.PostFormValue("symbol")))
|
||
return err
|
||
}); err != nil {
|
||
s.serverError(w, r, err)
|
||
return
|
||
}
|
||
redirectEdit(w, r, "ok", fmt.Sprintf("已新增天气(src_pk=%d)", nextSrc))
|
||
}
|
||
|
||
// ---------- 辅助 ----------
|
||
|
||
// resolveActivity 把表单里的活动 id 解析为 (activity_id, isHome, isWork)。未选择时返回 nil/false。
|
||
func (s *Server) resolveActivity(ctx context.Context, datasetID int64, raw string) (any, bool, bool) {
|
||
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||
if err != nil || id <= 0 {
|
||
return nil, false, false
|
||
}
|
||
var home, work bool
|
||
if err := s.db.QueryRowContext(ctx, `SELECT is_home, is_work FROM activities WHERE id=$1 AND dataset_id=$2`,
|
||
id, datasetID).Scan(&home, &work); err != nil {
|
||
return nil, false, false
|
||
}
|
||
return id, home, work
|
||
}
|
||
|
||
// execer 是 *sql.DB 与 *sql.Tx 的公共子集。
|
||
// 「建地点 + 建到访」必须落在同一个事务里,这些写方法因此不能写死用连接池。
|
||
type execer interface {
|
||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||
}
|
||
|
||
// withTx 在一个事务里执行 fn:fn 返回错误就整体回滚,避免只落一半(比如地点建了、到访没建,
|
||
// 导出去在 rond 里就是一个永远没有活跃时间的点)。
|
||
func (s *Server) withTx(ctx context.Context, fn func(ex execer) error) error {
|
||
tx, err := s.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := fn(tx); err != nil {
|
||
tx.Rollback()
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// insertVisit 插入一条手动补录的到访(带标签),返回其 src_pk。
|
||
// 「新增到访」与「新增地点时顺带建到访」共用,保证两条路径的落库行为一致。
|
||
func (s *Server) insertVisit(ctx context.Context, ex execer, datasetID, userID, placeID int64,
|
||
arrival time.Time, departure *time.Time, actID any, isHome, isWork, bookmarked bool,
|
||
remark, emoji string, tags []string) (int, error) {
|
||
// 与地点同一套编号规则:留档里的 skipped 行可能占着比 visits 更大的号
|
||
nextSrc, err := s.nextSrcPK(ctx, ex, datasetID, visitSeq)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if err := clearStaleRaw(ctx, ex, datasetID, "ZVISIT", nextSrc); err != nil {
|
||
return 0, err
|
||
}
|
||
if _, err := ex.ExecContext(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)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,TRUE,NULLIF($12,''),NULLIF($13,''))`,
|
||
datasetID, userID, nextSrc, placeID, actID, arrival, departure, durationMin(arrival, departure), isHome, isWork,
|
||
bookmarked, remark, emoji); err != nil {
|
||
return 0, err
|
||
}
|
||
s.saveVisitTags(ctx, ex, datasetID, nextSrc, tags)
|
||
s.refreshPlaceAgg(ctx, ex, placeID)
|
||
return nextSrc, nil
|
||
}
|
||
|
||
// saveVisitTags 重写到访的标签关联。
|
||
func (s *Server) saveVisitTags(ctx context.Context, ex execer, datasetID int64, visitSrc int, tagIDs []string) {
|
||
var visitID int64
|
||
if err := ex.QueryRowContext(ctx, `SELECT id FROM visits WHERE dataset_id=$1 AND src_pk=$2`, datasetID, visitSrc).Scan(&visitID); err != nil {
|
||
return
|
||
}
|
||
ex.ExecContext(ctx, `DELETE FROM visit_tags WHERE visit_id=$1`, visitID)
|
||
for _, t := range tagIDs {
|
||
id, err := strconv.ParseInt(t, 10, 64)
|
||
if err != nil || id <= 0 {
|
||
continue
|
||
}
|
||
ex.ExecContext(ctx, `INSERT INTO visit_tags (visit_id, tag_id)
|
||
SELECT $1, t.id FROM tags t WHERE t.id=$2 AND t.dataset_id=$3 ON CONFLICT DO NOTHING`, visitID, id, datasetID)
|
||
}
|
||
}
|
||
|
||
// refreshPlaceAgg 重算某地点的到访聚合(次数 / 停留 / 首末时间)。
|
||
func (s *Server) refreshPlaceAgg(ctx context.Context, ex execer, placeID int64) {
|
||
ex.ExecContext(ctx, `UPDATE places p SET
|
||
visit_count = COALESCE(v.c,0), dwell_minutes = COALESCE(v.d,0),
|
||
first_visit_at = v.f, last_visit_at = v.l
|
||
FROM (SELECT $1::bigint AS pid) x
|
||
LEFT JOIN (SELECT place_id, count(*) c, COALESCE(sum(LEAST(COALESCE(duration_min,0), 10080)),0) d, min(arrival) f, max(arrival) l
|
||
FROM visits WHERE place_id=$1 GROUP BY place_id) v ON v.place_id = x.pid
|
||
WHERE p.id = x.pid`, placeID)
|
||
}
|
||
|
||
// refreshDatasetAgg 重算数据集的到访数 / 地点数 / 时间跨度(页脚「数据截至」与后台 flash 用)。
|
||
// 编辑页增删记录只动业务表,不刷新的话 datasets 上的缓存计数会越漂越远。
|
||
func (s *Server) refreshDatasetAgg(ctx context.Context, datasetID int64) {
|
||
s.db.ExecContext(ctx, `UPDATE datasets d SET
|
||
visit_count = COALESCE(v.c,0), first_visit_at = v.f, last_visit_at = v.l,
|
||
place_count = COALESCE(p.c,0)
|
||
FROM (SELECT $1::bigint AS did) x
|
||
LEFT JOIN (SELECT dataset_id, count(*) c, min(arrival) f, max(arrival) l
|
||
FROM visits WHERE dataset_id=$1 GROUP BY dataset_id) v ON v.dataset_id = x.did
|
||
LEFT JOIN (SELECT dataset_id, count(*) c
|
||
FROM places WHERE dataset_id=$1 GROUP BY dataset_id) p ON p.dataset_id = x.did
|
||
WHERE d.id = x.did`, datasetID)
|
||
}
|
||
|
||
func parseFloat(v string) (float64, bool) {
|
||
f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
return f, true
|
||
}
|
||
|
||
// parseLocalDT 解析 datetime-local 的值(本地时区)。
|
||
func parseLocalDT(v string) (time.Time, bool) {
|
||
v = strings.TrimSpace(v)
|
||
if v == "" {
|
||
return time.Time{}, false
|
||
}
|
||
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02"} {
|
||
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
||
return t, true
|
||
}
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
// parseVisitTimes 解析到访的到达/离开时间,并校验先后顺序。
|
||
func parseVisitTimes(r *http.Request) (time.Time, *time.Time, bool, string) {
|
||
arrival, ok := parseLocalDT(r.PostFormValue("arrival"))
|
||
if !ok {
|
||
return time.Time{}, nil, false, "请填写有效的到达时间"
|
||
}
|
||
// 离开时间可选:补录时常常只知道「什么时候到的」,还没离开或记不清就留空
|
||
var departure *time.Time
|
||
if v := strings.TrimSpace(r.PostFormValue("departure")); v != "" {
|
||
t, ok := parseLocalDT(v)
|
||
if !ok {
|
||
return time.Time{}, nil, false, "离开时间格式不正确"
|
||
}
|
||
if t.Before(arrival) {
|
||
return time.Time{}, nil, false, "离开时间不能早于到达时间"
|
||
}
|
||
departure = &t
|
||
}
|
||
return arrival, departure, true, ""
|
||
}
|
||
|
||
func durationMin(arrival time.Time, departure *time.Time) any {
|
||
if departure == nil {
|
||
return nil
|
||
}
|
||
if d := int(departure.Sub(arrival).Minutes()); d >= 0 {
|
||
return d
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// nullStr 把空串当「没填」存成 NULL,与导入路径的写法保持一致
|
||
// (导出时再按留档决定写 NULL 还是空串,见 rond.keepEmptyString)。
|
||
func nullStr(s string) any {
|
||
if strings.TrimSpace(s) == "" {
|
||
return nil
|
||
}
|
||
return s
|
||
}
|
||
|
||
func max1(n int) int {
|
||
if n < 1 {
|
||
return 1
|
||
}
|
||
return n
|
||
}
|
||
|
||
// redirectEdit 回到数据编辑页并带上提示(保留来源页的筛选与分页)。
|
||
func redirectEdit(w http.ResponseWriter, r *http.Request, kind, msg string) {
|
||
back := r.Referer()
|
||
if !strings.Contains(back, "/admin/edit") {
|
||
back = "/admin/edit"
|
||
}
|
||
sep := "?"
|
||
if strings.Contains(back, "?") {
|
||
sep = "&"
|
||
}
|
||
http.Redirect(w, r, back+sep+kind+"="+urlQueryEscape(msg), http.StatusSeeOther)
|
||
}
|
||
|
||
// ---------- 坐标换算 ----------
|
||
|
||
// bearingNames 是方位角到中文方位的映射(每 45° 一个,居中对齐)。
|
||
var bearingNames = [8]string{"北", "东北", "东", "东南", "南", "西南", "西", "西北"}
|
||
|
||
func bearingLabel(deg float64) string {
|
||
if deg < 0 {
|
||
deg += 360
|
||
}
|
||
return bearingNames[int((deg+22.5)/45)%8]
|
||
}
|
||
|
||
// apiCoords 换算一个坐标。站点上的坐标一律是 GCJ-02,但真机里有些点其实存的是
|
||
// 原始 GPS(实测 323 个地点里有 18 个 ZRAWLATITUDE 与 ZLATITUDE 完全相同,
|
||
// 即当初没做偏移,位置整体偏了 500 多米)。换算放在服务端:geo 包是唯一实现,
|
||
// 前端再抄一份迟早会跟导出/迷雾那边不一致。
|
||
func (s *Server) apiCoords(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
lat, ok1 := parseFloat(q.Get("lat"))
|
||
lon, ok2 := parseFloat(q.Get("lon"))
|
||
if !ok1 || !ok2 || (lat == 0 && lon == 0) {
|
||
s.json(w, map[string]any{"error": "经纬度无效"})
|
||
return
|
||
}
|
||
var nlat, nlon float64
|
||
switch q.Get("mode") {
|
||
case "wgs2gcj":
|
||
nlat, nlon = geo.WGS84ToGCJ02(lat, lon)
|
||
case "gcj2wgs":
|
||
nlat, nlon = geo.GCJ02ToWGS84(lat, lon)
|
||
default:
|
||
s.json(w, map[string]any{"error": "未知的换算方向"})
|
||
return
|
||
}
|
||
dist := geo.Haversine(lat, lon, nlat, nlon) * 1000
|
||
s.json(w, map[string]any{
|
||
"lat": nlat, "lon": nlon,
|
||
"dist_m": int(dist + 0.5),
|
||
"bearing": bearingLabel(geo.Bearing(lat, lon, nlat, nlon)),
|
||
})
|
||
}
|
||
|
||
// suspectWGS 是判断「这个地点的显示坐标其实是 WGS-84」的 SQL 条件:
|
||
// 导入留档里的原始 GPS 与显示坐标完全相同。真机正常会写 gcj(raw)==显示坐标
|
||
// (305/323),剩下 18 个 raw 与显示坐标一字不差,就是没做偏移的那批。
|
||
// 中国境外的点本来就不该偏移,用外接矩形把它们排除掉,免得误判。
|
||
const suspectWGS = `(r.raw->>'ZRAWLATITUDE') ~ '^-?[0-9.]+$'
|
||
AND (r.raw->>'ZRAWLONGITUDE') ~ '^-?[0-9.]+$'
|
||
AND abs((r.raw->>'ZRAWLATITUDE')::float8 - COALESCE(p.lat,0)) < 1e-9
|
||
AND abs((r.raw->>'ZRAWLONGITUDE')::float8 - COALESCE(p.lon,0)) < 1e-9
|
||
AND p.lat BETWEEN 3.86 AND 53.55 AND p.lon BETWEEN 73.66 AND 135.05`
|
||
|
||
// 地点列表与 entity_raw 的关联:留档里既有原始 GPS(判断坐标系是否没偏移),
|
||
// 也有导入时的显示坐标(做「坐标前后变动」对照)。
|
||
const placeRawJoin = ` LEFT JOIN entity_raw r
|
||
ON r.dataset_id=p.dataset_id AND r.entity='ZLOCATION' AND r.src_pk=p.src_pk`
|
||
|
||
// 坐标来源的取值。placeSourceNone 不是库里的值,而是筛选/展示时代表
|
||
// 「coord_source 为空」这一档——2026-09 之前建的点都没有留档。
|
||
const (
|
||
placeSourceNone = "none"
|
||
)
|
||
|
||
// placeSourceLabels 是来源的展示名,顺序即筛选下拉的顺序。
|
||
var placeSourceLabels = []struct{ Value, Label string }{
|
||
{"photo", "照片导入"},
|
||
{"rond", "rond 导入"},
|
||
{"manual", "后台新增"},
|
||
}
|
||
|
||
func validPlaceSource(v string) bool {
|
||
if v == "" || v == placeSourceNone {
|
||
return true
|
||
}
|
||
for _, s := range placeSourceLabels {
|
||
if s.Value == v {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// coordSysLabel 把坐标系写成一行短标签,给列表用。
|
||
func coordSysLabel(v string) string {
|
||
switch v {
|
||
case "wgs84":
|
||
return "WGS-84"
|
||
case "gcj02":
|
||
return "GCJ-02"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// movementFilterNone 是「只看没有交通方式的行程」这个筛选项的提交值。
|
||
const movementFilterNone = "none"
|
||
|
||
// normalizeDate 把日期收敛成 date 输入框认的 YYYY-MM-DD,认不出来就当没填
|
||
// (筛选条件不该让整页 500)。
|
||
func normalizeDate(v string) string {
|
||
t, ok := parseDate(strings.TrimSpace(v))
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return t.Format("2006-01-02")
|
||
}
|
||
|
||
// escapeLike 转义 ILIKE 的通配符,否则用户搜 "%" 会命中全部、搜 "_" 会变成任意单字符。
|
||
func escapeLike(s string) string {
|
||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||
}
|