Compare commits
3
Commits
bf23fc62d7
...
cbb45b6d6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbb45b6d6b | ||
|
|
da6fe54ab4 | ||
|
|
ffee082fad |
+46
-6
@@ -206,6 +206,11 @@ func (s *Server) adminFillupPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
usedCats, err := s.usedCategories(ctx, ds.ID)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
total := len(rows)
|
||||
hinted := 0
|
||||
for _, row := range rows {
|
||||
@@ -223,7 +228,7 @@ func (s *Server) adminFillupPage(w http.ResponseWriter, r *http.Request) {
|
||||
rows = rows[from:to]
|
||||
|
||||
s.render(w, "admin/fillup", FillupData{
|
||||
Page: p, Rows: rows, Activities: acts, Categories: fillupCategories(),
|
||||
Page: p, Rows: rows, Activities: acts, Categories: fillupCategories(usedCats),
|
||||
Total: total, WithHint: hinted, PageNo: pageNo, PageCount: pageCount,
|
||||
})
|
||||
}
|
||||
@@ -255,8 +260,32 @@ type fillupCategory struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
// fillupCategories 是类别下拉的选项:真机用过的 12 种在前,其余常见 MapKit 类别在后。
|
||||
func fillupCategories() []fillupCategory {
|
||||
// usedCategories 列出这个数据集里真在用的类别(按地点数从多到少),排在静态表前面。
|
||||
// 候选得先跟已有数据对得上——否则用户会在这一页挑一个名字相近的静态枚举,
|
||||
// 跟库里已有的值并存成两种,同一个意思分两处统计。
|
||||
func (s *Server) usedCategories(ctx context.Context, datasetID int64) ([]fillupCategory, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT poi_category, count(*) FROM places
|
||||
WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' GROUP BY 1 ORDER BY 2 DESC, 1`, datasetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []fillupCategory
|
||||
for rows.Next() {
|
||||
var v string
|
||||
var n int
|
||||
if err := rows.Scan(&v, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 值可能是 MKPOICategory 枚举,也可能是真机自己写的中文——poiCategoryLabel 两种都认
|
||||
out = append(out, fillupCategory{v, fmt.Sprintf("%s(在用 %d)", poiCategoryLabel(v), n)})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// fillupCategories 是类别下拉的选项:数据集里真在用的排最前面,再跟真机用过的 12 种、
|
||||
// 其余常见 MapKit 类别。同一个值只出现一次——在用的那份信息更准,就顶掉静态表里的说明。
|
||||
func fillupCategories(inUse []fillupCategory) []fillupCategory {
|
||||
known := []string{
|
||||
"MKPOICategoryRestaurant", "MKPOICategoryStore", "MKPOICategoryPublicTransport",
|
||||
"MKPOICategoryHotel", "MKPOICategoryLandmark", "MKPOICategoryFoodMarket",
|
||||
@@ -271,12 +300,23 @@ func fillupCategories() []fillupCategory {
|
||||
"MKPOICategoryNationalPark", "MKPOICategoryAquarium", "MKPOICategoryAmusementPark",
|
||||
"MKPOICategoryGasStation", "MKPOICategoryCarRental", "MKPOICategoryMarina",
|
||||
}
|
||||
out := make([]fillupCategory, 0, len(known)+len(extra))
|
||||
seen := make(map[string]bool, len(inUse)+len(known)+len(extra))
|
||||
out := make([]fillupCategory, 0, len(inUse)+len(known)+len(extra))
|
||||
push := func(c fillupCategory) {
|
||||
if c.Value == "" || seen[c.Value] {
|
||||
return
|
||||
}
|
||||
seen[c.Value] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
for _, c := range inUse {
|
||||
push(c)
|
||||
}
|
||||
for _, c := range known {
|
||||
out = append(out, fillupCategory{c, poiName(c) + "(真机用过)"})
|
||||
push(fillupCategory{c, poiName(c) + "(真机用过)"})
|
||||
}
|
||||
for _, c := range extra {
|
||||
out = append(out, fillupCategory{c, poiName(c)})
|
||||
push(fillupCategory{c, poiName(c)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -301,3 +301,78 @@ func TestFillupCustomActivityEmptyIsSkipped(t *testing.T) {
|
||||
t.Fatalf("没填名字不该建类型,活动数 %d → %d", before, n)
|
||||
}
|
||||
}
|
||||
|
||||
// 「手工填过的值自动进候选」是纯前端行为,Go 测不到运行时,只能钉住它还在模板里。
|
||||
// 这东西少一半都不会报错,只会悄悄退回「后面每行都得重新打一遍」——
|
||||
// 所以把三个关键标记都钉住:存下来的键、注入选项的标记、显示用的前缀。
|
||||
func TestFillupKeepsHandwrittenCandidates(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
fillupPlace(t, f)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/admin/fillup", nil).WithContext(f.ctx)
|
||||
f.srv.adminFillupPage(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("渲染失败: %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"rond-fillup-newopts", "data-local", "新填:"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("模板里找不到 %q —— 「手工填过的值自动进候选」失效了", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 补全页的类别候选要「数据里在用的 ∪ 静态表」:库里真在用的值(尤其是真机自己写的中文)
|
||||
// 原来在这一页根本选不到,只能手打,跟静态枚举并存成两种,同一个意思分两处统计。
|
||||
func TestFillupCategoriesIncludeUsedOnes(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
fillupPlace(t, f) // 补全页得有行才会渲染出下拉
|
||||
for _, p := range []struct {
|
||||
pk int
|
||||
cat string
|
||||
}{
|
||||
{691001, "测试类别戊"}, {691002, "测试类别戊"}, {691003, "MKPOICategoryRestaurant"},
|
||||
} {
|
||||
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
|
||||
VALUES ($1,$2,'类别候选测试点',30,120,$3)`, f.dsID, p.pk, p.cat)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 691001 AND 691003`, f.dsID)
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/admin/fillup", nil).WithContext(f.ctx)
|
||||
f.srv.adminFillupPage(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("渲染失败: %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
|
||||
// 下拉每行渲染一份,要数重复就得只看其中一个
|
||||
seg := body[strings.Index(body, `<select name="cat_`):]
|
||||
if end := strings.Index(seg, "</select>"); end > 0 {
|
||||
seg = seg[:end]
|
||||
} else {
|
||||
t.Fatal("没找到类别下拉")
|
||||
}
|
||||
if !strings.Contains(seg, `value="测试类别戊"`) {
|
||||
t.Fatal("库里在用的中文类别应出现在下拉里")
|
||||
}
|
||||
if !strings.Contains(seg, "测试类别戊(在用 2)") {
|
||||
t.Error("在用类别应标出它被几个地点用着")
|
||||
}
|
||||
// 既在用又躺在静态表里的,只能出现一次(在用那份信息更准)
|
||||
if n := strings.Count(seg, `value="MKPOICategoryRestaurant"`); n != 1 {
|
||||
t.Fatalf("既在用又在静态表的值应只出现一次,实际 %d 次", n)
|
||||
}
|
||||
// 静态表里的值一旦真在用,就该显示成「在用」而不是「真机用过」。
|
||||
// 不钉具体数字:夹具导的是真实备份,那个类别本来就有几十个地点在用。
|
||||
if !strings.Contains(seg, `value="MKPOICategoryRestaurant">餐厅(在用 `) {
|
||||
t.Error("静态表里的值一旦真在用,就该显示成「在用」而不是「真机用过」")
|
||||
}
|
||||
// 在用的排最前面:排在静态表里那些还没用过的值之前
|
||||
if a, b := strings.Index(body, `value="测试类别戊"`), strings.Index(body, `value="MKPOICategoryCafe"`); a < 0 || b < 0 || a > b {
|
||||
t.Errorf("在用类别应排在静态表前面(在用=%d 静态=%d)", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,24 +131,47 @@ func (s *Server) editPage(w http.ResponseWriter, r *http.Request) {
|
||||
if d.PageNo > d.PageCount {
|
||||
d.PageNo = d.PageCount
|
||||
}
|
||||
// 已有类别去重:新建表单和每行编辑的下拉都用它(提交值仍是原始标识符)
|
||||
crows, err := s.db.QueryContext(ctx, `SELECT DISTINCT poi_category FROM places
|
||||
WHERE dataset_id=$1 AND COALESCE(poi_category,'')<>'' ORDER BY poi_category`, ds.ID)
|
||||
// 已有类别去重:新建表单、每行编辑的下拉、顶部类别台账都用它(提交值仍是原始标识符);
|
||||
// 顺手带上地点数,台账要按用量排序、也要知道改一下会影响多少行
|
||||
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
|
||||
if err := crows.Scan(&c); err != nil {
|
||||
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: poiCategoryLabel(c)})
|
||||
d.CategoryRows = append(d.CategoryRows, EditCategory{Value: c, Label: poiCategoryLabel(c), Places: n})
|
||||
}
|
||||
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),
|
||||
@@ -617,6 +640,116 @@ func (s *Server) editActivityDelete(w http.ResponseWriter, r *http.Request) {
|
||||
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": poiCategoryLabel(to)})
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
// editPlaceCreate 是最前面那个融合表单(新建地点 / 给已有地点补到访)的处理函数。
|
||||
func (s *Server) editPlaceCreate(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
@@ -256,6 +256,9 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("POST /admin/edit/activity", s.requireAdmin(s.editActivityCreate))
|
||||
s.mux.HandleFunc("POST /admin/edit/activity/update", s.requireAdmin(s.editActivityUpdate))
|
||||
s.mux.HandleFunc("POST /admin/edit/activity/delete", s.requireAdmin(s.editActivityDelete))
|
||||
s.mux.HandleFunc("POST /admin/edit/activity/merge", s.requireAdmin(s.editActivityMerge))
|
||||
s.mux.HandleFunc("POST /admin/edit/category/rename", s.requireAdmin(s.editCategoryRename))
|
||||
s.mux.HandleFunc("POST /admin/edit/category/clear", s.requireAdmin(s.editCategoryClear))
|
||||
// 照片导入:EXIF 由浏览器读(不上传照片),确认后只把元数据提交上来落库
|
||||
s.mux.HandleFunc("GET /admin/photos", s.requireAdmin(s.adminPhotosPage))
|
||||
s.mux.HandleFunc("POST /admin/photos/import", s.requireAdmin(s.adminPhotosImport))
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// taxonomyPost 按页面 fetch 的方式调「活动与类别」那几个端点,返回解析后的 JSON。
|
||||
func taxonomyPost(t *testing.T, f *movementFixture, path string, form url.Values) map[string]any {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
ctx := req.WithContext(f.ctx)
|
||||
switch path {
|
||||
case "/admin/edit/category/rename":
|
||||
f.srv.editCategoryRename(rec, ctx)
|
||||
case "/admin/edit/category/clear":
|
||||
f.srv.editCategoryClear(rec, ctx)
|
||||
case "/admin/edit/activity/merge":
|
||||
f.srv.editActivityMerge(rec, ctx)
|
||||
default:
|
||||
t.Fatalf("未接线的端点 %s", path)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("%s 返回的不是 JSON: %s", path, rec.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 类别没有表,「改名」只能是按值批量改地点;填成另一个已有值就是合并。
|
||||
func TestCategoryRenameMergesIntoExisting(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
mk := func(pk int, cat string) {
|
||||
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
|
||||
VALUES ($1,$2,'类别测试点',30,120,$3)`, f.dsID, pk, cat)
|
||||
}
|
||||
mk(690001, "测试类别甲")
|
||||
mk(690002, "测试类别甲")
|
||||
mk(690003, "测试类别乙")
|
||||
t.Cleanup(func() {
|
||||
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 690001 AND 690003`, f.dsID)
|
||||
})
|
||||
|
||||
got := taxonomyPost(t, f, "/admin/edit/category/rename", url.Values{
|
||||
"from": {"测试类别甲"}, "to": {"测试类别乙"},
|
||||
})
|
||||
if got["places"] != float64(2) {
|
||||
t.Fatalf("应报告 2 个地点被改,实际 %v", got["places"])
|
||||
}
|
||||
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别乙'
|
||||
AND src_pk BETWEEN 690001 AND 690003`, f.dsID); n != 3 {
|
||||
t.Fatalf("甲应整体并进乙(共 3 个),实际 %d 个", n)
|
||||
}
|
||||
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别甲'`, f.dsID); n != 0 {
|
||||
t.Fatalf("甲应一个不剩,还剩 %d 个", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 新类别填空不能当成「改名成空」把类别悄悄丢掉——那是「清空」的活,要另走一步。
|
||||
func TestCategoryRenameRefusesEmptyTarget(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
|
||||
VALUES ($1,690011,'类别测试点',30,120,'测试类别丙')`, f.dsID)
|
||||
t.Cleanup(func() {
|
||||
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=690011`, f.dsID)
|
||||
})
|
||||
|
||||
got := taxonomyPost(t, f, "/admin/edit/category/rename", url.Values{"from": {"测试类别丙"}, "to": {" "}})
|
||||
if got["error"] == nil {
|
||||
t.Fatal("新类别为空时应报错")
|
||||
}
|
||||
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别丙'`, f.dsID); n != 1 {
|
||||
t.Fatalf("报错就不该动数据,实际只剩 %d 个", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 「清空」= 摘掉类别:那些地点退回未指定(NULL),去重列表里也就不再出现。
|
||||
func TestCategoryClearDetachesPlaces(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
for _, pk := range []int{690021, 690022} {
|
||||
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
|
||||
VALUES ($1,$2,'类别测试点',30,120,'测试类别丁')`, f.dsID, pk)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 690021 AND 690022`, f.dsID)
|
||||
})
|
||||
|
||||
got := taxonomyPost(t, f, "/admin/edit/category/clear", url.Values{"from": {"测试类别丁"}})
|
||||
if got["places"] != float64(2) {
|
||||
t.Fatalf("应报告 2 个地点被摘掉,实际 %v", got["places"])
|
||||
}
|
||||
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 690021 AND 690022
|
||||
AND poi_category IS NULL`, f.dsID); n != 2 {
|
||||
t.Fatalf("应写 NULL 而不是空串,实际 %d 个", n)
|
||||
}
|
||||
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别丁'`, f.dsID); n != 0 {
|
||||
t.Fatalf("类别应彻底消失,还剩 %d 个", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 合并:源的到访整体转到目标,源活动被删,is_home/is_work 跟着目标走
|
||||
// (那两个是从活动抄到到访上的冗余标记,不跟着改就会留下一个矛盾的到访)。
|
||||
func TestActivityMergeMovesVisitsThenDeletesSource(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
placeID, _ := fillupPlace(t, f)
|
||||
|
||||
mkAct := func(pk int, name string, home bool) int64 {
|
||||
var id int64
|
||||
if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO activities
|
||||
(dataset_id, src_pk, name, color, is_home) VALUES ($1,$2,$3,'',$4) RETURNING id`,
|
||||
f.dsID, pk, name, home).Scan(&id); err != nil {
|
||||
t.Fatalf("建活动失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM activities WHERE id=$1`, id) })
|
||||
return id
|
||||
}
|
||||
from := mkAct(695001, "合并测试甲", false)
|
||||
to := mkAct(695002, "合并测试乙", true)
|
||||
|
||||
f.mustExec(t, `UPDATE visits SET activity_id=$2, is_home=FALSE WHERE dataset_id=$1 AND place_id=$3`,
|
||||
f.dsID, from, placeID)
|
||||
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND activity_id=$2`, f.dsID, from); n == 0 {
|
||||
t.Fatal("合并前应有到访挂在源活动上")
|
||||
}
|
||||
|
||||
got := taxonomyPost(t, f, "/admin/edit/activity/merge", url.Values{
|
||||
"from": {itoa(from)}, "to": {itoa(to)},
|
||||
})
|
||||
if got["error"] != nil {
|
||||
t.Fatalf("合并不该失败: %v", got["error"])
|
||||
}
|
||||
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND activity_id=$2`, f.dsID, to); n == 0 {
|
||||
t.Fatal("到访应转到目标活动名下")
|
||||
}
|
||||
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND is_home`, f.dsID); n == 0 {
|
||||
t.Fatal("目标活动的 is_home 应抄到转过来的到访上")
|
||||
}
|
||||
if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1 AND id=$2`, f.dsID, from); n != 0 {
|
||||
t.Fatal("源活动应被删掉")
|
||||
}
|
||||
if n := f.scalar(t, `SELECT count(*) FROM entity_raw WHERE dataset_id=$1 AND entity='ZACTIVITY' AND src_pk=695001`,
|
||||
f.dsID); n != 0 {
|
||||
t.Fatal("源活动的原始行留档应一起清掉,否则导出还会带它")
|
||||
}
|
||||
}
|
||||
|
||||
// 合并到自己或选空都必须拒绝,别把活动连同它的到访一起清掉。
|
||||
func TestActivityMergeRefusesDegenerateInput(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
var id int64
|
||||
if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO activities (dataset_id, src_pk, name, color)
|
||||
VALUES ($1,695011,'合并测试丙','') RETURNING id`, f.dsID).Scan(&id); err != nil {
|
||||
t.Fatalf("建活动失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM activities WHERE id=$1`, id) })
|
||||
|
||||
for _, form := range []url.Values{
|
||||
{"from": {itoa(id)}, "to": {itoa(id)}},
|
||||
{"from": {itoa(id)}, "to": {"0"}},
|
||||
{"from": {"0"}, "to": {itoa(id)}},
|
||||
} {
|
||||
if got := taxonomyPost(t, f, "/admin/edit/activity/merge", form); got["error"] == nil {
|
||||
t.Fatalf("%v 应被拒绝", form)
|
||||
}
|
||||
}
|
||||
if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1 AND id=$2`, f.dsID, id); n != 1 {
|
||||
t.Fatal("被拒绝的合并不该动数据")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,74 @@
|
||||
{{else}}
|
||||
{{if not .CanExport}}<div class="alert err">导出所需要的基线库暂不可用(当初上传的原始 <span class="mono">.rondbackup</span> 可能已被清理)。请到<a href="/admin">后台</a>重新上传一次该备份文件,即可恢复导出。</div>{{end}}
|
||||
|
||||
{{/* 活动与类别是「分类字典」,不随下面的分页列表变,所以放最上面。
|
||||
类别没有独立的表,值就长在 places.poi_category 上——所以「改名 / 清空」都是按值批量作用到地点。 */}}
|
||||
<div class="card" data-card="taxonomy" id="taxonomy">
|
||||
<h2>活动与类别</h2>
|
||||
<div class="card-sub">
|
||||
活动是单独的一张表(共 {{num (len .ActivityRows)}} 个),类别只是地点上的一个字段({{num (len .CategoryRows)}} 种在用)。
|
||||
<b>改名时把值填成另一个已有的,就是合并</b>——整批并过去,不用先删再建。
|
||||
</div>
|
||||
|
||||
<h3>活动</h3>
|
||||
<div class="tw">
|
||||
<table class="tb tb-stack">
|
||||
<thead><tr><th>名称</th><th>引用</th><th>原生属性</th><th></th></tr></thead>
|
||||
<tbody id="act-rows">
|
||||
{{range .ActivityRows}}
|
||||
<tr data-id="{{.ID}}" data-name="{{.Name}}">
|
||||
<td data-label="名称"><input type="text" name="act-name" value="{{.Name}}" style="min-width:9em"></td>
|
||||
<td data-label="引用">{{num .Visits}}</td>
|
||||
<td data-label="原生属性">{{if .IsHome}}家{{end}}{{if and .IsHome .IsWork}} · {{end}}{{if .IsWork}}工作{{end}}</td>
|
||||
<td class="ops">
|
||||
<button class="btn sm" type="button" data-act="rename">改名</button>
|
||||
<button class="btn sm" type="button" data-act="delete">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="row" style="margin-top:10px;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<span class="muted">合并:把</span>
|
||||
<select id="merge-from" aria-label="合并来源">{{range .ActivityRows}}<option value="{{.ID}}">{{.Name}}({{num .Visits}})</option>{{end}}</select>
|
||||
<span class="muted">并到</span>
|
||||
<select id="merge-to" aria-label="合并目标">{{range .ActivityRows}}<option value="{{.ID}}">{{.Name}}({{num .Visits}})</option>{{end}}</select>
|
||||
<button class="btn sm" type="button" id="merge-go">合并</button>
|
||||
<span class="muted">|</span>
|
||||
<input type="text" id="act-new" placeholder="新活动名称" style="min-width:8em">
|
||||
<button class="btn sm" type="button" id="act-new-go">新增活动</button>
|
||||
</div>
|
||||
|
||||
<h3>类别</h3>
|
||||
<div class="tw">
|
||||
<table class="tb tb-stack">
|
||||
<thead><tr><th>类别</th><th>地点</th><th></th></tr></thead>
|
||||
<tbody id="cat-rows">
|
||||
{{range .CategoryRows}}
|
||||
<tr data-value="{{.Value}}">
|
||||
<td data-label="类别">
|
||||
<input list="cat-values" type="text" name="cat-value" value="{{.Value}}" style="min-width:12em">
|
||||
<div class="card-sub" style="margin:2px 0 0">{{.Label}}</div>
|
||||
</td>
|
||||
<td data-label="地点">{{num .Places}}</td>
|
||||
<td class="ops">
|
||||
<button class="btn sm" type="button" data-act="rename">改名</button>
|
||||
<button class="btn sm" type="button" data-act="clear">清空</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<datalist id="cat-values">{{range .CategoryRows}}<option value="{{.Value}}"></option>{{end}}</datalist>
|
||||
<div class="card-sub" style="margin-top:8px">
|
||||
把值改成另一个已有类别就是合并;「清空」= 那些地点退回「未指定」。
|
||||
要加一个全新的类别,在地点行内编辑里选「自定义…」填就行——类别的候选本来就来自地点数据本身,
|
||||
凭空预置一个没人用的值不会出现在任何下拉里。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-card="place-new">
|
||||
<h2>新增地点</h2>
|
||||
<div class="card-sub">按名称搜索可直接填入坐标;也可在地图上点击选点,坐标为 GCJ-02(与 rond 记录同系)。</div>
|
||||
@@ -595,8 +663,13 @@
|
||||
head.appendChild(btn);
|
||||
});
|
||||
// 状态统一在这里落地:与上面的「装按钮/包 body」分开,重复执行也能对齐。
|
||||
// 默认折叠——这页六张卡片全展开太长,只有明确记过「展开」的才展开。
|
||||
cards.forEach(function (card) { apply(card, state[card.dataset.card] !== false); });
|
||||
// 默认折叠——这页卡片全展开太长,只有明确记过「展开」的才展开;
|
||||
// 「活动与类别」是新加的、也最小,默认展开,省得有人找不到。
|
||||
cards.forEach(function (card) {
|
||||
var collapsed = state[card.dataset.card];
|
||||
if (collapsed === undefined) collapsed = card.dataset.card !== 'taxonomy';
|
||||
apply(card, collapsed);
|
||||
});
|
||||
|
||||
function show(card, collapsed) {
|
||||
apply(card, collapsed);
|
||||
@@ -1176,6 +1249,108 @@
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// ---------- 活动与类别:改名 / 删除 / 合并 / 清空 ----------
|
||||
// 这些都是整批作用到数据上的操作,用量、原生属性、去重列表会一起变,
|
||||
// 就地改 DOM 很容易拼出一个「看起来对」的状态,所以成功后直接整页重来拿新数字。
|
||||
(function () {
|
||||
var box = null;
|
||||
function toast(text, bad) {
|
||||
if (!box) {
|
||||
var card = document.querySelector('[data-card="taxonomy"]');
|
||||
var host = card.querySelector('.card-body') || card;
|
||||
box = document.createElement('div');
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
box.className = 'alert ' + (bad ? 'err' : 'ok');
|
||||
box.style.margin = '0 0 10px';
|
||||
box.textContent = text;
|
||||
if (!bad) box.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
function post(path, body, after) {
|
||||
fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(body).toString()
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (d.error) { toast(d.error, true); return; }
|
||||
after(d);
|
||||
}).catch(function (e) { toast('请求失败:' + e, true); });
|
||||
}
|
||||
function reload(text) {
|
||||
location.href = '/admin/edit?ok=' + encodeURIComponent(text) + '#taxonomy';
|
||||
}
|
||||
function count(tr, label) {
|
||||
var cell = tr.querySelector('[data-label="' + label + '"]');
|
||||
return cell ? cell.textContent.trim() : '';
|
||||
}
|
||||
|
||||
document.querySelectorAll('#act-rows tr').forEach(function (tr) {
|
||||
var input = tr.querySelector('input[name="act-name"]');
|
||||
var id = tr.dataset.id, name = tr.dataset.name;
|
||||
tr.querySelector('[data-act="rename"]').addEventListener('click', function () {
|
||||
var v = (input.value || '').trim();
|
||||
if (!v) { toast('活动名称不能为空', true); return; }
|
||||
if (v === name) { toast('名称没变', true); return; }
|
||||
post('/admin/edit/activity/update', { id: id, name: v }, function () {
|
||||
reload('活动已改名:' + name + ' → ' + v);
|
||||
});
|
||||
});
|
||||
tr.querySelector('[data-act="delete"]').addEventListener('click', function () {
|
||||
var n = count(tr, '引用');
|
||||
var what = (!n || n === '0') ? '目前没有到访用它。'
|
||||
: '引用它的 ' + n + ' 条到访会变成「未分类」。';
|
||||
if (!window.confirm('删除活动「' + name + '」?\n' + what)) return;
|
||||
post('/admin/edit/activity/delete', { id: id }, function () {
|
||||
reload('已删除活动:' + name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var mf = document.getElementById('merge-from');
|
||||
var mt = document.getElementById('merge-to');
|
||||
var go = document.getElementById('merge-go');
|
||||
if (mf && mt && go) go.addEventListener('click', function () {
|
||||
if (!mf.value || mf.value === mt.value) { toast('请选两个不同的活动', true); return; }
|
||||
var a = mf.options[mf.selectedIndex].textContent;
|
||||
var b = mt.options[mt.selectedIndex].textContent;
|
||||
if (!window.confirm('把「' + a + '」的到访全部并到「' + b + '」,然后删掉「' + a + '」?')) return;
|
||||
post('/admin/edit/activity/merge', { from: mf.value, to: mt.value }, function (d) {
|
||||
reload('已把「' + a + '」并到「' + b + '」,转移 ' + d.visits + ' 条到访');
|
||||
});
|
||||
});
|
||||
|
||||
var an = document.getElementById('act-new');
|
||||
var ang = document.getElementById('act-new-go');
|
||||
if (an && ang) ang.addEventListener('click', function () {
|
||||
var v = (an.value || '').trim();
|
||||
if (!v) { toast('请填活动名称', true); return; }
|
||||
post('/admin/edit/activity', { name: v }, function () {
|
||||
reload('已新增活动:' + v);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('#cat-rows tr').forEach(function (tr) {
|
||||
var input = tr.querySelector('input[name="cat-value"]');
|
||||
var from = tr.dataset.value;
|
||||
tr.querySelector('[data-act="rename"]').addEventListener('click', function () {
|
||||
var v = (input.value || '').trim();
|
||||
if (!v) { toast('类别不能为空;要摘掉请用「清空」', true); return; }
|
||||
if (v === from) { toast('类别没变', true); return; }
|
||||
post('/admin/edit/category/rename', { from: from, to: v }, function (d) {
|
||||
reload('已把 ' + count(tr, '地点') + ' 个地点的类别改成「' + (d.label || v) + '」');
|
||||
});
|
||||
});
|
||||
tr.querySelector('[data-act="clear"]').addEventListener('click', function () {
|
||||
var n = count(tr, '地点');
|
||||
if (!window.confirm('清空 ' + n + ' 个地点的「' + from + '」类别?\n' +
|
||||
'它们会退回「未指定」,统计里的这个类别会消失。')) return;
|
||||
post('/admin/edit/category/clear', { from: from }, function (d) {
|
||||
reload('已清空 ' + d.places + ' 个地点的类别');
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
<script>
|
||||
(function () {
|
||||
var KEY = 'rond-fillup-draft';
|
||||
var NEW = 'rond-fillup-newopts';
|
||||
|
||||
// 活动和类别是同一套下拉:选了「其它(自己写)」就露出旁边的文本框
|
||||
document.querySelectorAll('select.fill-sel').forEach(function (sel) {
|
||||
@@ -127,6 +128,70 @@
|
||||
sync();
|
||||
});
|
||||
|
||||
// 下拉的候选是渲染时定好的,手工新填的名字进不了候选,后面遇到同类地点还得重打一遍。
|
||||
// 所以把填过的值记下来补进同类下拉:本页实时生效,翻页/点「用高德分析」也留得住,
|
||||
// 提交成功后清掉(那之后它们已经在真正的候选里了)。
|
||||
var fromOther = {act: [], cat: []}; // 从别处(别的页/上次)带来的
|
||||
var fromPage = {act: [], cat: []}; // 本页文本框里当前填着的
|
||||
try {
|
||||
var saved = JSON.parse(sessionStorage.getItem(NEW) || '{}');
|
||||
fromOther.act = saved.act || [];
|
||||
fromOther.cat = saved.cat || [];
|
||||
} catch (e) { /* 坏数据就当没有 */ }
|
||||
|
||||
var paint = function (prefix) {
|
||||
var vals = fromOther[prefix].concat(fromPage[prefix].filter(function (v) {
|
||||
return fromOther[prefix].indexOf(v) < 0;
|
||||
}));
|
||||
document.querySelectorAll('select[name^="' + prefix + '_"]').forEach(function (sel) {
|
||||
// 重画不能动用户已经选好的东西:这一格选的要是「新填」里的值,先留住它
|
||||
var keep = sel.value;
|
||||
if (keep && vals.indexOf(keep) < 0 && [].some.call(sel.options, function (o) {
|
||||
return o.value === keep && o.getAttribute('data-local');
|
||||
})) vals.push(keep);
|
||||
[].forEach.call(sel.querySelectorAll('option[data-local]'), function (o) { o.remove(); });
|
||||
// 插在「(不动)」/建议之后、真正的候选之前,免得沉到列表最底下去找
|
||||
var anchor = null;
|
||||
for (var i = 0; i < sel.options.length; i++) {
|
||||
var o = sel.options[i];
|
||||
if (o.getAttribute('data-local') || !o.value || o.value === sel.dataset.hint) continue;
|
||||
anchor = o;
|
||||
break;
|
||||
}
|
||||
vals.forEach(function (v) {
|
||||
if ([].some.call(sel.options, function (o) { return o.value === v; })) return;
|
||||
var o = document.createElement('option');
|
||||
o.value = v;
|
||||
o.textContent = '新填:' + v;
|
||||
o.setAttribute('data-local', '1');
|
||||
sel.insertBefore(o, anchor);
|
||||
});
|
||||
// 选项重插之后浏览器不会自己选回来,得手工还回去
|
||||
if (keep && sel.value !== keep) sel.value = keep;
|
||||
});
|
||||
};
|
||||
|
||||
var collect = function (prefix) {
|
||||
fromPage[prefix] = [];
|
||||
document.querySelectorAll('input[name^="' + prefix + '_"][name$="__custom"]').forEach(function (box) {
|
||||
var v = box.value.trim();
|
||||
if (v && fromPage[prefix].indexOf(v) < 0) fromPage[prefix].push(v);
|
||||
});
|
||||
paint(prefix);
|
||||
try {
|
||||
sessionStorage.setItem(NEW, JSON.stringify({
|
||||
act: fromOther.act.concat(fromPage.act),
|
||||
cat: fromOther.cat.concat(fromPage.cat)
|
||||
}));
|
||||
} catch (e) { /* 隐私模式就算了 */ }
|
||||
};
|
||||
|
||||
// 文本框里打字就同步,不用等失焦或提交。直接挂在框上,不靠事件冒泡——
|
||||
// 省得以后有人在这行 DOM 上动手脚(或程序化派发)时静默失效。
|
||||
document.querySelectorAll('input[name$="__custom"]').forEach(function (box) {
|
||||
box.addEventListener('input', function () { collect(box.name.split('_')[0]); });
|
||||
});
|
||||
|
||||
// 采纳本页建议:把建议项选上(活动和类别都是下拉)
|
||||
var pick = document.getElementById('pick-hints');
|
||||
if (pick) {
|
||||
@@ -141,18 +206,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 「用高德分析」会整页重载,先把这一页填过的存起来、回来再恢复——
|
||||
// 否则挑了半天,点一下就全没了。
|
||||
// 离开本页(翻页 / 「用高德分析」)之前把填过的存起来、回来接着填——
|
||||
// 否则挑了半天,点一下就全没了。按 name 合并,所以翻页也留得住。
|
||||
var save = function () {
|
||||
var draft = {};
|
||||
try { draft = JSON.parse(sessionStorage.getItem(KEY) || '{}'); } catch (e) { draft = {}; }
|
||||
document.querySelectorAll('select.fill-sel, input[name$="__custom"]').forEach(function (el) {
|
||||
if (el.value) draft[el.name] = el.value;
|
||||
else delete draft[el.name]; // 本页明确清空的就是不要了,别让旧值顶上来
|
||||
});
|
||||
try { sessionStorage.setItem(KEY, JSON.stringify(draft)); } catch (e) { /* 隐私模式就算了 */ }
|
||||
};
|
||||
var amap = document.getElementById('amap-btn');
|
||||
if (amap) amap.addEventListener('click', save);
|
||||
document.querySelectorAll('.pager a').forEach(function (a) { a.addEventListener('click', save); });
|
||||
|
||||
// 先把「新填」的候选补上再恢复草稿:草稿里选中的可能就是新填的值,
|
||||
// 选项不在跟前的话 select.value 是设不进去的。
|
||||
paint('act');
|
||||
paint('cat');
|
||||
try {
|
||||
var draft = JSON.parse(sessionStorage.getItem(KEY) || '{}');
|
||||
Object.keys(draft).forEach(function (name) {
|
||||
@@ -162,11 +234,17 @@
|
||||
if (el.tagName === 'SELECT') el.dispatchEvent(new Event('change'));
|
||||
});
|
||||
} catch (e) { /* 坏数据就当没有 */ }
|
||||
// 恢复出来的文本框(`set value` 不触发 input)也要算进「新填」
|
||||
collect('act');
|
||||
collect('cat');
|
||||
|
||||
// 提交成功后草稿就没用了,清掉,免得下次进来还带着旧选择
|
||||
var form = document.getElementById('fillup-form');
|
||||
if (form) form.addEventListener('submit', function () {
|
||||
try { sessionStorage.removeItem(KEY); } catch (e) {}
|
||||
try {
|
||||
sessionStorage.removeItem(KEY);
|
||||
sessionStorage.removeItem(NEW);
|
||||
} catch (e) {}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
+24
-4
@@ -750,10 +750,13 @@ type EditData struct {
|
||||
PlaceOptions []EditOption
|
||||
VisitOptions []EditOption // 最近到访(值=src_pk),供行程/天气表单选关联
|
||||
Categories []EditOption // 已有地点的类别(去重),供新建时下拉直选
|
||||
Places []EditPlace
|
||||
Visits []EditVisit
|
||||
Movements []EditMovement
|
||||
Query string
|
||||
// ActivityRows / CategoryRows 是顶部「活动与类别」卡片用的台账(带用量)
|
||||
ActivityRows []EditActivity
|
||||
CategoryRows []EditCategory
|
||||
Places []EditPlace
|
||||
Visits []EditVisit
|
||||
Movements []EditMovement
|
||||
Query string
|
||||
// VisitQuery 是到访记录列表的搜索词(与地点列表的 Query 独立)
|
||||
VisitQuery string
|
||||
// MovementFilter 是行程列表的筛选条件,都是选择框而不是让人手输关键词
|
||||
@@ -894,6 +897,23 @@ type EditOption struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
// EditActivity 是「活动与类别」卡片里的一行活动:带用量,好一眼看出哪些没人用。
|
||||
type EditActivity struct {
|
||||
ID int64
|
||||
Name string
|
||||
Visits int
|
||||
IsHome bool
|
||||
IsWork bool
|
||||
}
|
||||
|
||||
// EditCategory 是类别台账的一行。类别没有表,值就是 places.poi_category,
|
||||
// 所以「改名 / 清空」都是按值批量作用到地点上——改成另一个已有值就是合并。
|
||||
type EditCategory struct {
|
||||
Value string
|
||||
Label string
|
||||
Places int
|
||||
}
|
||||
|
||||
type EditPlace struct {
|
||||
ID int64
|
||||
SrcPK int
|
||||
|
||||
@@ -208,6 +208,13 @@ func checkWideTablesOutsideGrid(t *testing.T, path, body string) int {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
// 两个类是成对的:app.css 里每一条堆叠规则都写成 `.tw > table.tb.tb-stack …`。
|
||||
// 只写 tb-stack 一条都命中不了,表格退回 `.tw > table.tb` 的 640px 底宽,
|
||||
// 窄屏照旧横滑——而且横滑是在 .tw 里滚的,页面本身不溢出,肉眼很难发现
|
||||
// (2026-09-20 加「活动与类别」卡片时就这么漏过一次)。
|
||||
if m := classRe.FindStringSubmatch(tag); m == nil || !strings.Contains(" "+m[1]+" ", " tb ") {
|
||||
t.Errorf("%s:tb-stack 表少了 tb 类(class=%q)——堆叠规则匹配不到,窄屏只能横滑", path, tag)
|
||||
}
|
||||
// 只有标了 tb-stack 的才算「宽表」;普通的窄表放进 g2 没问题,不该报。
|
||||
if inG2 {
|
||||
t.Errorf("%s:一张宽表被放在 g2 网格里(卡片只剩半宽,桌面端要横滑)", path)
|
||||
|
||||
Reference in New Issue
Block a user