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

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

- 新建地点卡:活动下拉补「+ 新增活动…」哨兵,旁边的新建框改成选中哨兵才展开;
  删掉原来那对 #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
+4
View File
@@ -545,6 +545,10 @@ func TestMovementCleanExport(t *testing.T) {
t.Skip("设置 ROND_CLEAN_OUT 后导出一份纯净往返包")
}
f := newMovementFixture(t)
// 目录不存在时直接 WriteFile 只会报「找不到路径」,看着像导出坏了——顺手建出来
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
t.Fatal(err)
}
+224
View File
@@ -0,0 +1,224 @@
package web
import (
"database/sql"
"fmt"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"rond-with-you/internal/rond"
)
// TestPlaceUpdateBatchActivity 盯住「地点行内编辑里的活动」这条路:地点自己没有活动列
// (活动挂在到访上),所以那个下拉是「把该地点名下全部到访一起换类型」。
// 写错的两种后果都很安静:空值被当成「改成无活动」→ 历史到访的类型被清掉;
// 不校验归属 → 能把别的数据集的活动塞进来。
//
// ROND_TEST_CONF=conf/app-test.ini ROND_SRC=data/xxx.rondbackup go test ./internal/web -run BatchActivity -v
func TestPlaceUpdateBatchActivity(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
// 挑到访最多的那个地点:批量的效果才看得出来
var pk int
var placeID int64
var n int
if err := pg.QueryRowContext(ctx, `SELECT p.id, p.src_pk, count(v.id) AS c FROM places p
JOIN visits v ON v.place_id = p.id
WHERE p.dataset_id=$1 GROUP BY p.id, p.src_pk ORDER BY c DESC, p.src_pk LIMIT 1`,
dsID).Scan(&placeID, &pk, &n); err != nil {
t.Fatalf("找不到带有访次的地点: %v", err)
}
if n < 2 {
t.Fatalf("这个数据集里没有多于一条到访的地点(最多 %d 条),验不出「批量」", n)
}
var name string
var lat, lon float64
if err := pg.QueryRowContext(ctx, `SELECT COALESCE(name,''), COALESCE(lat,0), COALESCE(lon,0)
FROM places WHERE dataset_id=$1 AND src_pk=$2`, dsID, pk).Scan(&name, &lat, &lon); err != nil {
t.Fatalf("读地点失败: %v", err)
}
// 挑一个该地点还没用过的活动,否则「改了」和「没改」分不出来
var actID int64
var actName string
var actHome, actWork bool
if err := pg.QueryRowContext(ctx, `SELECT a.id, COALESCE(a.name,''), a.is_home, a.is_work FROM activities a
WHERE a.dataset_id=$1 AND NOT EXISTS (SELECT 1 FROM visits v
WHERE v.place_id=$2 AND v.activity_id=a.id)
ORDER BY a.id LIMIT 1`, dsID, placeID).Scan(&actID, &actName, &actHome, &actWork); err != nil {
t.Fatalf("找不到没被这个地点用过的活动: %v", err)
}
postRaw := func(activity string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{
"src_pk": {fmt.Sprint(pk)}, "name": {name},
"lat": {fmt.Sprintf("%.6f", lat)}, "lon": {fmt.Sprintf("%.6f", lon)},
"province": {""}, "city": {""}, "district": {""},
}
if activity != "" {
form.Set("activity_id", activity)
}
req := httptest.NewRequest("POST", "/admin/edit/place/update", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
f.srv.editPlaceUpdate(rec, req)
if rec.Code >= 500 {
t.Fatalf("处理失败: code=%d", rec.Code)
}
return rec
}
// is_home / is_work 是从活动上抄到到访的冗余标记,换活动时要跟着一起走
matched := func() int {
t.Helper()
var c int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
WHERE dataset_id=$1 AND place_id=$2 AND activity_id=$3 AND is_home=$4 AND is_work=$5`,
dsID, placeID, actID, actHome, actWork).Scan(&c); err != nil {
t.Fatalf("统计失败: %v", err)
}
return c
}
var othersBefore int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
WHERE dataset_id=$1 AND place_id<>$2 AND activity_id=$3`, dsID, placeID, actID).Scan(&othersBefore); err != nil {
t.Fatal(err)
}
// 1) 选中一个活动:该地点名下全部到访一起换过去
rec := postRaw(fmt.Sprint(actID))
loc := rec.Header().Get("Location")
if msg, _ := url.QueryUnescape(loc); !strings.Contains(msg, "ok=") || !strings.Contains(msg, "条到访") {
t.Fatalf("提示里要说清改了几条到访,实际 %q", loc)
}
if got := matched(); got != n {
t.Fatalf("该地点有 %d 条到访,只有 %d 条换成了「%s」(含 is_home/is_work)", n, got, actName)
}
var othersAfter int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits
WHERE dataset_id=$1 AND place_id<>$2 AND activity_id=$3`, dsID, placeID, actID).Scan(&othersAfter); err != nil {
t.Fatal(err)
}
if othersAfter != othersBefore {
t.Fatalf("改一个地点的活动,别的地点的到访也变了:%d → %d", othersBefore, othersAfter)
}
// 2) 「(不修改)」:行内表单每次都会提交这一项,空值必须一个到访都不动
postRaw("")
if got := matched(); got != n {
t.Fatalf("没选活动却动了到访:只剩 %d/%d 条还是「%s」", got, n, actName)
}
// 3) 哨兵值「+ 新增活动…」:前端有守卫,服务端也要拦(当 id 会解析成「无活动」)
for _, raw := range []string{"__new__", "999999"} {
rec = postRaw(raw)
if l := rec.Header().Get("Location"); !strings.Contains(l, "err=") {
t.Fatalf("提交 activity_id=%q 应当被拒绝,实际 %q", raw, l)
}
if got := matched(); got != n {
t.Fatalf("提交 activity_id=%q 之后到访被改了:只剩 %d/%d 条还是「%s」", raw, got, n, actName)
}
}
// 3b) 别的数据集的活动:越权也得拒(resolveActivity 带 dataset_id 条件就是为了这个)
var foreign int64
if err := pg.QueryRowContext(ctx, `SELECT id FROM activities WHERE dataset_id<>$1 ORDER BY id LIMIT 1`,
dsID).Scan(&foreign); err == nil {
rec = postRaw(fmt.Sprint(foreign))
if l := rec.Header().Get("Location"); !strings.Contains(l, "err=") {
t.Fatalf("提交别的数据集的活动 #%d 应当被拒绝,实际 %q", foreign, l)
}
if got := matched(); got != n {
t.Fatalf("越权提交之后到访被改了:只剩 %d/%d 条还是「%s」", got, n, actName)
}
} else if err != sql.ErrNoRows {
t.Fatalf("查别的数据集的活动失败: %v", err)
}
// 4) 模板:行内表单必须带上活动下拉 + 新建框,且下拉里三样齐全
// (JS 靠 .activity-id-inline / .visit-act-new / .visit-act-add 三个类名找同伴元素)
req := httptest.NewRequest("GET", "/admin/edit", nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
f.srv.editPage(w, req)
if w.Code != 200 {
t.Fatalf("编辑页渲染失败: code=%d", w.Code)
}
body := w.Body.String()
for _, want := range []string{
`action="/admin/edit/place/update"`, `select name="activity_id" class="activity-id-inline"`,
`class="f visit-act-new"`, `class="btn visit-act-add"`, `(不修改)`, `+ 新增活动…`,
} {
if !strings.Contains(body, want) {
t.Fatalf("编辑页里找不到 %q:模板与 JS 的契约断了", want)
}
}
// 这一行的提示要落在它自己身上:地点按到访数倒序排,246 条的那个在第 1 页
i := strings.Index(body, fmt.Sprintf(`id="place-row-%d"`, pk))
if i < 0 {
t.Fatalf("编辑页第 1 页找不到 #%d 这一行", pk)
}
row := body[i:]
if j := strings.Index(row, `<tr id="place-row-`); j > 0 {
row = row[:j]
}
if !strings.Contains(row, "该地点到访当前类型:") ||
!strings.Contains(row, actName+" "+fmt.Sprint(n)) {
t.Fatalf("这一行的「当前类型」提示不对(期望 %s %d):%s", actName, n, row[:min(len(row), 320)])
}
// 5) 导出链路:站点改的是 visits.activity_id,导出的包里对应到访必须换成新活动的 **src_pk**
// —— 站点内部 id 与 rond 编号是两套号,写错就会「网页上改了、rond 里还是旧活动」。
var actSrc int
if err := pg.QueryRowContext(ctx, `SELECT src_pk FROM activities WHERE id=$1`, actID).Scan(&actSrc); err != nil {
t.Fatalf("读活动编号失败: %v", err)
}
visitRows, err := pg.QueryContext(ctx, `SELECT src_pk FROM visits WHERE dataset_id=$1 AND place_id=$2`, dsID, placeID)
if err != nil {
t.Fatalf("读到访编号失败: %v", err)
}
var pks []int
for visitRows.Next() {
var v int
if err := visitRows.Scan(&v); err != nil {
visitRows.Close()
t.Fatalf("扫到访编号失败: %v", err)
}
pks = append(pks, v)
}
visitRows.Close()
blob := f.export(t)
out := filepath.Join(t.TempDir(), "activity-export.rondbackup")
if err := os.WriteFile(out, blob, 0o644); err != nil {
t.Fatal(err)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
defer edb.Close()
ph := make([]string, len(pks))
args := make([]any, 0, len(pks)+1)
for i, v := range pks {
ph[i] = "?"
args = append(args, v)
}
args = append(args, actSrc)
var inPackage, converted int
q := `SELECT count(*), count(*) FILTER (WHERE ZACTIVITY_=?) FROM ZVISIT WHERE Z_PK IN (` +
strings.Join(ph, ",") + `)`
if err := edb.QueryRow(q, args...).Scan(&inPackage, &converted); err != nil {
t.Fatalf("读导出包失败: %v", err)
}
if inPackage != len(pks) {
t.Fatalf("导出包里只有 %d/%d 条到访", inPackage, len(pks))
}
if converted != len(pks) {
t.Fatalf("导出包里只有 %d/%d 条到访的活动换成了 #%d(站点内部 id %d)", converted, len(pks), actSrc, actID)
}
}
+36 -23
View File
@@ -98,15 +98,17 @@
<a href="javascript:void(0)" id="activity-delete" class="danger">删除</a>
</span>
</span>
<select name="activity_id" id="activity-select">
{{/* 与照片导入页、到访行内编辑同一套:下拉里带「+ 新增活动…」,选中后就地展开新建框 */}}
<select name="activity_id" id="activity-select" class="activity-id-inline">
<option value="">未指定</option>
{{range .Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
<option value="__new__">+ 新增活动…</option>
</select>
</label>
<label class="f" style="flex:1 1 170px"><span>新建活动</span>
<label class="f visit-act-new" style="flex:1 1 170px;display:none"><span>新建活动</span>
<div class="input-btn">
<input type="text" id="new-activity-name" placeholder="名称">
<button type="button" class="btn" id="activity-add">添加</button>
<input type="text" class="visit-act-name" placeholder="名称">
<button type="button" class="btn visit-act-add">添加</button>
</div>
</label>
<label class="f" style="flex:1 1 140px"><span>备注</span><input type="text" name="remark" placeholder="可选"></label>
@@ -161,7 +163,7 @@
<td style="white-space:nowrap">
<details class="inline-edit"{{if and $.OpenSrcPK (eq .SrcPK $.OpenSrcPK)}} open{{end}}>
<summary class="btn sm">编辑</summary>
<form method="post" action="/admin/edit/place/update" style="margin-top:8px;min-width:280px">
<form method="post" action="/admin/edit/place/update" data-visit-count="{{.VisitCount}}" style="margin-top:8px;min-width:280px">
<input type="hidden" name="src_pk" value="{{.SrcPK}}">
<label class="f"><span>名称<b class="req">*</b></span><input type="text" name="name" value="{{.Name}}" required></label>
<label class="f"><span>类别</span>
@@ -173,6 +175,22 @@
</select>
</label>
<label class="f cat-custom" style="display:none"><span>自定义类别</span><input type="text" name="category_custom" placeholder="如 公园"></label>
{{/* 改的是该地点名下**全部到访**的类型,所以默认「(不修改)」、
不预选当前值:否则每次改个坐标保存,都会把历史到访重写一遍 */}}
<label class="f"><span>活动<span class="muted">(改该地点全部到访)</span></span>
<select name="activity_id" class="activity-id-inline">
<option value="">(不修改)</option>
{{range $.Activities}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
<option value="__new__">+ 新增活动…</option>
</select>
</label>
<label class="f visit-act-new" style="display:none"><span>新建活动</span>
<div class="input-btn">
<input type="text" class="visit-act-name" placeholder="名称">
<button type="button" class="btn visit-act-add">添加</button>
</div>
</label>
{{if .ActivityText}}<div class="card-sub" style="margin:0 0 8px">该地点到访当前类型:<span class="mono">{{.ActivityText}}</span></div>{{end}}
<div class="row">
<label class="f" style="flex:1 1 120px"><span>纬度<b class="req">*</b></span><input type="text" name="lat" value="{{printf "%.6f" .Lat}}" required></label>
<label class="f" style="flex:1 1 120px"><span>经度<b class="req">*</b></span><input type="text" name="lon" value="{{printf "%.6f" .Lon}}" required></label>
@@ -939,23 +957,11 @@
}).catch(function (e) { alert('创建活动失败:' + e); });
}
var actAdd = document.getElementById('activity-add');
if (actAdd) {
var newActInput = document.getElementById('new-activity-name');
var addActivityTop = function () {
var sel = document.getElementById('activity-select');
createActivity(newActInput.value, function (id) {
if (sel) sel.value = id;
newActInput.value = '';
});
};
actAdd.addEventListener('click', addActivityTop);
newActInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); addActivityTop(); }
});
}
// 顶部「新建活动」不再单独接事件:它和到访/地点行内编辑共用下面那套
// `visit-act-*` 委派处理器(同一个表单里只有一个新建框,重复监听会建出两条)。
// ---- 到访行内编辑里的「+ 新增活动…」:就地展开新建框,建完选中并收起 ----
// ---- 下拉里的「+ 新增活动…」:就地展开新建框,建完选中并收起 ----
// 顶部新建地点卡、到访行内编辑、地点行内编辑三处共用(靠 form 里的类名找同伴元素)
function inlineActParts(el) {
var form = el.closest ? el.closest('form') : null;
if (!form) return null;
@@ -993,16 +999,23 @@
inlineActCreate(t);
});
// 提交守卫:还停在「+ 新增活动…」上就拦下来。否则 activity_id 会带着 "__new__" 提交,
// resolveActivity 解析不出整数就当成「无」,这条到访的类型会被静默清掉。
// resolveActivity 解析不出整数就当成「无」,那条记录的类型会被静默清掉。
document.addEventListener('submit', function (e) {
var f = e.target;
if (!f || !f.querySelector) return;
var sel = f.querySelector('select.activity-id-inline');
if (sel && sel.value === '__new__') {
e.preventDefault();
alert('类型选了「+ 新增活动…」但还没点「添加」:填个名字再点添加,或改回「(无)」。');
alert('活动选了「+ 新增活动…」但还没点「添加」:填个名字再点添加,或把这一项改回默认值。');
sel.focus();
return;
}
// 地点行内编辑的「活动」是批量改该地点**全部**到访,动的是历史记录,落库前再问一次
if (!f.getAttribute || f.getAttribute('action') !== '/admin/edit/place/update') return;
if (!sel || !sel.value) return;
var n = f.getAttribute('data-visit-count') || '';
if (!window.confirm('把该地点' + (n ? '全部 ' + n + ' 条' : '全部') + '到访的活动都改成「' +
sel.options[sel.selectedIndex].textContent + '」?原有记录会被一起改掉。')) e.preventDefault();
}, true);
// ---------- 活动改名 / 删除:作用于下拉里当前选中的那一个 ----------
+3
View File
@@ -852,6 +852,9 @@ type EditPlace struct {
SrcLat, SrcLon sql.NullFloat64
// CategoryOptions 是这一行类别下拉的选项:库里的已有类别 + 当前值(不在其中时补上)
CategoryOptions []EditOption
// ActivityText 是该地点名下到访当前的活动构成(「商场 8 · 餐饮 3」这种)。
// 行内编辑里的「活动」是把该地点全部到访一起改,所以得先让人看清现在是什么。
ActivityText string
}
// SourceTag 是列表里那一枚来源标记的文案,没有留档时给「未留档」。