新建地点的活动下拉可即时新增,地点行内编辑支持批量改该地点全部到访的活动
站长反馈「已有地点编辑和新建里只有类别没有活动能选」。实测澄清:新建地点卡一直有活动下拉, 只是形态与类别/照片页不一致(旁边常显一个新建框、下拉里没有「+ 新增活动…」);真正完全没有 活动字段的是地点列表行的「编辑」。 - 新建地点卡:活动下拉补「+ 新增活动…」哨兵,旁边的新建框改成选中哨兵才展开; 删掉原来那对 #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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
// ---------- 活动改名 / 删除:作用于下拉里当前选中的那一个 ----------
|
||||
|
||||
@@ -852,6 +852,9 @@ type EditPlace struct {
|
||||
SrcLat, SrcLon sql.NullFloat64
|
||||
// CategoryOptions 是这一行类别下拉的选项:库里的已有类别 + 当前值(不在其中时补上)
|
||||
CategoryOptions []EditOption
|
||||
// ActivityText 是该地点名下到访当前的活动构成(「商场 8 · 餐饮 3」这种)。
|
||||
// 行内编辑里的「活动」是把该地点全部到访一起改,所以得先让人看清现在是什么。
|
||||
ActivityText string
|
||||
}
|
||||
|
||||
// SourceTag 是列表里那一枚来源标记的文案,没有留档时给「未留档」。
|
||||
|
||||
Reference in New Issue
Block a user