补全页加分页,每页 30 行

218 行在手机上堆成一屏一行有五万多像素长,分页后第 1 页只有八千多。

页码会夹紧:?page=999 停在最后一页而不是给空白页,解析不出来回第一页。
提交后留在同一页——处理完的行会从列表里消失,停在这儿才看得见剩下的。
翻页前若这一页有改过还没提交的下拉,先确认一次(预选的建议不算「改过」)。
This commit is contained in:
tamakyi
2026-09-19 18:25:16 +08:00
parent b311ba202b
commit 824c1e3b3d
3 changed files with 152 additions and 6 deletions
+53 -5
View File
@@ -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
}
+65
View File
@@ -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("越界页码应夹到最后一页,不该是空白页")
}
}
+34 -1
View File
@@ -16,7 +16,7 @@
{{if .Rows}}
<div class="card">
<h2>待补全({{num .Total}})</h2>
<h2>待补全({{num .Total}}){{if gt .PageCount 1}}<span class="muted" style="font-size:13px;font-weight:400"> 第 {{.PageNo}} / {{.PageCount}} 页</span>{{end}}</h2>
<div class="card-sub">
建议已经在下拉里<strong>选好</strong>了,不满意的改成别的、或选「(不动)」。
活动按<strong>地点</strong>补:一次把该地点名下所有没设活动的到访一起填上,不会只改一条。
@@ -25,7 +25,16 @@
右侧「高德参考」写的是高德在这个坐标上找到的 POI 与类型,猜不出活动时拿它作判断。<br>
「用高德分析剩下的」每次处理 40 个、约 4 秒,结果会填进上面还没建议的行里,点多几次即可。
</div>
{{if gt .PageCount 1}}
<div class="pager">
{{if gt .PageNo 1}}<a href="{{.PageLink (sub .PageNo 1)}}">上一页</a>{{end}}
<span class="cur">第 {{.PageNo}} / {{.PageCount}} 页</span>
{{if lt .PageNo .PageCount}}<a href="{{.PageLink (add .PageNo 1)}}">下一页</a>{{end}}
</div>
<div class="card-sub" style="margin-bottom:10px">只提交<strong>本页</strong>的选择,翻页前先把这页提交掉。</div>
{{end}}
<form method="post" action="/admin/fillup/apply">
<input type="hidden" name="page" value="{{.PageNo}}">
<div class="tw">
<table class="tb tb-stack">
<thead>
@@ -80,6 +89,13 @@
title="用高德按坐标搜一遍,给还没建议的地点补上参考">用高德分析剩下的</button>
</div>
</form>
{{if gt .PageCount 1}}
<div class="pager" style="margin-top:12px">
{{if gt .PageNo 1}}<a href="{{.PageLink (sub .PageNo 1)}}">上一页</a>{{end}}
<span class="cur">第 {{.PageNo}} / {{.PageCount}} 页</span>
{{if lt .PageNo .PageCount}}<a href="{{.PageLink (add .PageNo 1)}}">下一页</a>{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="card">
@@ -87,4 +103,21 @@
</div>
{{end}}
</div>
<script>
(function () {
// 选了东西还没提交就翻页,等于白填——拦一下(预选的那些建议不算「改过」)
var initial = {};
document.querySelectorAll('select.fill-sel').forEach(function (s) { initial[s.name] = s.value; });
document.querySelectorAll('.pager a').forEach(function (a) {
a.addEventListener('click', function (e) {
var dirty = false;
document.querySelectorAll('select.fill-sel').forEach(function (s) {
if (s.value !== initial[s.name]) dirty = true;
});
if (dirty && !confirm('这一页还有没提交的选择,翻页会丢掉。继续翻页?')) e.preventDefault();
});
});
})();
</script>
{{end}}