新建地点的活动下拉可即时新增,地点行内编辑支持批量改该地点全部到访的活动

站长反馈「已有地点编辑和新建里只有类别没有活动能选」。实测澄清:新建地点卡一直有活动下拉,
只是形态与类别/照片页不一致(旁边常显一个新建框、下拉里没有「+ 新增活动…」);真正完全没有
活动字段的是地点列表行的「编辑」。

- 新建地点卡:活动下拉补「+ 新增活动…」哨兵,旁边的新建框改成选中哨兵才展开;
  删掉原来那对 #activity-add/#new-activity-name 监听——三处活动下拉现在共用同一套
  `visit-act-*` 委派处理器,重复监听会建出两条活动。
- 地点行内编辑:加「活动(改该地点全部到访)」下拉,默认「(不修改)」不预选当前值
  (否则每次改坐标保存都会重写历史到访的类型),并标出当前类型构成;选了真实活动时
  提交前 confirm 一次。
- 服务端(places 没有活动列,活动挂在到访上):editPlaceUpdate 解析 activity_id,空值=不修改,
  与地点行同一事务内 UPDATE visits 的 activity_id/is_home/is_work,flash 报改了几条;
  哨兵值/不存在的 id/别的数据集的活动一律拒。导出无需改动(ZVISIT.ZACTIVITY_ 本就是管理列)。
- 回归 internal/web/place_activity_test.go:批量改(含别的地点不受影响)、空值不修改、越权被拒、
  模板契约、以及导出包里那批到访的 ZACTIVITY_ 必须换成新活动的 rond 编号。
- 顺手:TestMovementCleanExport 建出 ROND_CLEAN_OUT 的父目录,别再把「目录不存在」报成导出失败。
This commit is contained in:
tamakyi
2026-09-18 21:23:13 +08:00
parent 96e76318ab
commit 86e420bd4e
5 changed files with 339 additions and 33 deletions
+72 -10
View File
@@ -154,7 +154,11 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
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(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)...)
@@ -166,7 +170,8 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
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); err != nil {
&e.OrigLat, &e.OrigLon, &e.CoordSource, &e.CoordSys, &e.SrcLat, &e.SrcLon,
&e.ActivityText); err != nil {
lrows.Close()
s.serverError(w, r, err)
return
@@ -437,6 +442,10 @@ func pickCategory(r *http.Request) string {
// categoryCustomValue 是类别下拉里「自定义…」这一项的提交值。
const categoryCustomValue = "__custom__"
// activityNewValue 是活动下拉里「+ 新增活动…」这一项的提交值。前端有提交守卫,
// 服务端再拦一道:哨兵当 id 解析不出整数,会静默变成「无活动」。
const activityNewValue = "__new__"
// poiCategoryText 把 rond/MapKit 的 POI 分类标识符换成中文。
// 库里存的原值是 MKPOICategoryXxx(rond 靠它显示图标),表单里给人看中文。
var poiCategoryText = map[string]string{
@@ -648,6 +657,10 @@ func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
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"]
@@ -778,16 +791,65 @@ func (s *Server) editPlaceUpdate(w http.ResponseWriter, r *http.Request) {
city: strings.TrimSpace(r.PostFormValue("city")),
district: strings.TrimSpace(r.PostFormValue("district")),
}
if _, err := s.updatePlaceRow(ctx, ds.ID, src, form, placeExtraFromForm(r), oldLat, oldLon); err != nil {
// 「活动」是把该地点名下**全部到访**一起换类型(地点自己没有活动列,活动挂在到访上)。
// 这一项不会被清空——空值表示「(不修改)」,行内表单每次都会提交它。
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 moved := geo.Haversine(oldLat, oldLon, lat, lon) * 1000; moved >= 5 {
redirectEdit(w, r, "ok", fmt.Sprintf("地点已更新:坐标移动到 %.6f, %.6f(%.0f 米,%s方向)",
lat, lon, moved, bearingLabel(geo.Bearing(oldLat, oldLon, lat, lon))))
return
// 活动的分面计数、「最近活跃」都跟着到访走,改完刷新一次
if actID != nil {
s.refreshDatasetAgg(ctx, ds.ID)
}
redirectEdit(w, r, "ok", "地点已更新")
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 是地点行编辑里必填的那几个字段。
@@ -858,7 +920,7 @@ func placeExtraFromForm(r *http.Request) placeExtra {
// 没带、但坐标确实动过,就认为提交上来的是显示坐标本身(等价于 gcj02)。
// 坐标没动则一个字都不碰,否则每次改个名字都会把「导入时的原始坐标」冲掉,
// 行内那个「比导入时移动了 N 米」的对照就没了。
func (s *Server) updatePlaceRow(ctx context.Context, datasetID int64, src int, f placeForm,
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,'')",
@@ -902,7 +964,7 @@ func (s *Server) updatePlaceRow(ctx context.Context, datasetID int64, src int, f
add("src_lat=$%d", *e.srcLat)
add("src_lon=$%d", *e.srcLon)
}
res, err := s.db.ExecContext(ctx, `UPDATE places SET `+strings.Join(set, ", ")+
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