diff --git a/internal/web/fillup.go b/internal/web/fillup.go index 3fb93af..be9c367 100644 --- a/internal/web/fillup.go +++ b/internal/web/fillup.go @@ -3,7 +3,9 @@ package web import ( "context" "fmt" + "html/template" "net/http" + "net/url" "sort" "strconv" "strings" @@ -82,6 +84,21 @@ type FillupData struct { Categories []fillupCategory // 类别下拉选项 Total int WithHint int + + // 分页。218 行在手机上堆成一屏一行有五万多像素长,一页 30 行刚好。 + PageNo int + PageCount int +} + +// fillupPerPage 是补全页每页的行数。 +const fillupPerPage = 30 + +// PageLink 生成带页码的链接,供模板里的上一页/下一页用。 +func (d FillupData) PageLink(no int) template.URL { + if no < 1 { + no = 1 + } + return template.URL("/admin/fillup?page=" + strconv.Itoa(no)) } // activityOption 只是给下拉用的(活动 id + 名字)。 @@ -189,13 +206,26 @@ func (s *Server) adminFillupPage(w http.ResponseWriter, r *http.Request) { s.serverError(w, r, err) return } - d := FillupData{Page: p, Rows: rows, Activities: acts, Categories: fillupCategories(), Total: len(rows)} + total := len(rows) + hinted := 0 for _, row := range rows { if row.ActHint != "" || row.CatHint != "" { - d.WithHint++ + hinted++ } } - s.render(w, "admin/fillup", d) + pageCount := (total + fillupPerPage - 1) / fillupPerPage + pageNo := clampPage(r.URL.Query().Get("page"), pageCount) + from := (pageNo - 1) * fillupPerPage + to := from + fillupPerPage + if to > total { + to = total + } + rows = rows[from:to] + + s.render(w, "admin/fillup", FillupData{ + Page: p, Rows: rows, Activities: acts, Categories: fillupCategories(), + Total: total, WithHint: hinted, PageNo: pageNo, PageCount: pageCount, + }) } // activityOptions 列出该数据集的活动,供下拉选择。 @@ -451,7 +481,25 @@ func (s *Server) adminFillupAmap(w http.ResponseWriter, r *http.Request) { s.fillupFlash(w, r, "ok", msg) } -// fillupFlash 把提示带回补全页本身(adminFlash 是跳后台首页的)。 +// fillupFlash 把提示带回补全页本身(adminFlash 是跳后台首页的),并保留当前页码—— +// 处理完的那几行会从列表里消失,停在同一页才看得见剩下的。 func (s *Server) fillupFlash(w http.ResponseWriter, r *http.Request, kind, msg string) { - http.Redirect(w, r, "/admin/fillup?"+kind+"="+urlQueryEscape(msg), http.StatusSeeOther) + q := url.Values{} + q.Set(kind, msg) + if pg := strings.TrimSpace(r.PostFormValue("page")); pg != "" { + q.Set("page", pg) + } + http.Redirect(w, r, "/admin/fillup?"+q.Encode(), http.StatusSeeOther) +} + +// clampPage 解析页码并夹到有效范围;解析不出来就回第一页。 +func clampPage(raw string, pageCount int) int { + no, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || no < 1 { + no = 1 + } + if pageCount > 0 && no > pageCount { + no = pageCount + } + return no } diff --git a/internal/web/fillup_test.go b/internal/web/fillup_test.go index cb5d4c2..30097ef 100644 --- a/internal/web/fillup_test.go +++ b/internal/web/fillup_test.go @@ -168,3 +168,68 @@ func intToString(v int64) string { } return string(b) } + +func TestClampPage(t *testing.T) { + cases := []struct { + raw string + count int + want int + }{ + {"", 8, 1}, + {"1", 8, 1}, + {"8", 8, 8}, + {"999", 8, 8}, // 超了就停在最后一页,别给空白页 + {"0", 8, 1}, + {"-3", 8, 1}, + {"abc", 8, 1}, + {"3", 0, 3}, // 没有数据时不夹(总会在最后回到 1) + } + for _, c := range cases { + if got := clampPage(c.raw, c.count); got != c.want { + t.Errorf("clampPage(%q, %d) = %d,期望 %d", c.raw, c.count, got, c.want) + } + } +} + +// 每页只渲染 30 行;页码越界时夹回最后一页。 +func TestFillupPagination(t *testing.T) { + f := newMovementFixture(t) + // 造 35 个「缺类别」的地点,凑出两页 + for i := 0; i < 35; i++ { + pk := 680000 + i + if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon) + VALUES ($1,$2,$3,$4,$5)`, f.dsID, pk, "分页测试点", 30.0+float64(i)*0.001, 120.0); err != nil { + t.Fatalf("造地点失败: %v", err) + } + } + t.Cleanup(func() { + f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 680000 AND 680099`, f.dsID) + }) + + countRows := func(url string) (rows, pageCount int) { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", url, nil).WithContext(f.ctx) + f.srv.adminFillupPage(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s 渲染失败: %d", url, rec.Code) + } + body := rec.Body.String() + return strings.Count(body, `name="cat_`), strings.Count(body, `class="pager"`) + } + + rows, pagers := countRows("/admin/fillup") + if rows != fillupPerPage { + t.Fatalf("第 1 页应有 %d 行,实际 %d", fillupPerPage, rows) + } + if pagers == 0 { + t.Error("多于 1 页时应出现分页控件") + } + if rows, _ := countRows("/admin/fillup?page=2"); rows == 0 || rows > fillupPerPage { + t.Fatalf("第 2 页行数异常: %d", rows) + } + // 越界页码要夹回最后一页,而不是给一页空白 + if rows, _ := countRows("/admin/fillup?page=9999"); rows == 0 { + t.Error("越界页码应夹到最后一页,不该是空白页") + } +} diff --git a/internal/web/templates/admin/fillup.html b/internal/web/templates/admin/fillup.html index 00c5709..b8d7f10 100644 --- a/internal/web/templates/admin/fillup.html +++ b/internal/web/templates/admin/fillup.html @@ -16,7 +16,7 @@ {{if .Rows}}
-

待补全({{num .Total}})

+

待补全({{num .Total}}){{if gt .PageCount 1}} 第 {{.PageNo}} / {{.PageCount}} 页{{end}}

建议已经在下拉里选好了,不满意的改成别的、或选「(不动)」。 活动按地点补:一次把该地点名下所有没设活动的到访一起填上,不会只改一条。 @@ -25,7 +25,16 @@ 右侧「高德参考」写的是高德在这个坐标上找到的 POI 与类型,猜不出活动时拿它作判断。
「用高德分析剩下的」每次处理 40 个、约 4 秒,结果会填进上面还没建议的行里,点多几次即可。
+ {{if gt .PageCount 1}} +
+ {{if gt .PageNo 1}}上一页{{end}} + 第 {{.PageNo}} / {{.PageCount}} 页 + {{if lt .PageNo .PageCount}}下一页{{end}} +
+
只提交本页的选择,翻页前先把这页提交掉。
+ {{end}}
+
@@ -80,6 +89,13 @@ title="用高德按坐标搜一遍,给还没建议的地点补上参考">用高德分析剩下的 + {{if gt .PageCount 1}} +
+ {{if gt .PageNo 1}}上一页{{end}} + 第 {{.PageNo}} / {{.PageCount}} 页 + {{if lt .PageNo .PageCount}}下一页{{end}} +
+ {{end}} {{else}}
@@ -87,4 +103,21 @@
{{end}} + + {{end}}