feat: 统一 Admin 主列表分页 (#64)

This commit is contained in:
chengma
2026-08-09 21:51:20 +08:00
parent 5c9ebd968c
commit 155dd3b4a2
23 changed files with 549 additions and 120 deletions
+35 -19
View File
@@ -563,7 +563,7 @@ func (h *Handler) TaskList(c *gin.Context) {
Keyword: c.Query("q"),
}
result, err := service.ListTasksView(h.db, filter)
result, err := service.ListTasksView(h.db, filter, service.ParsePage(c.Query("page")))
if err != nil {
fail(c, http.StatusInternalServerError,
"读取任务列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
@@ -577,6 +577,16 @@ func (h *Handler) TaskList(c *gin.Context) {
statusLine = msg + " · " + statusLine
}
values := url.Values{}
if filter.Type != "" {
values.Set("type", string(filter.Type))
}
if filter.Status != "" {
values.Set("status", string(filter.Status))
}
if strings.TrimSpace(filter.Keyword) != "" {
values.Set("q", filter.Keyword)
}
c.HTML(http.StatusOK, "task/list", page(c, "tasks", "采集采购", gin.H{
"Rows": result.Rows,
"Keyword": filter.Keyword,
@@ -586,6 +596,8 @@ func (h *Handler) TaskList(c *gin.Context) {
"TypeOptions": service.TaskTypeOptions(),
"StatusOptions": service.TaskStatusOptions(),
"IsFiltered": result.IsFiltered,
"CurrentPage": result.Page,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
}))
}
@@ -648,6 +660,9 @@ func (h *Handler) taskRedirect(c *gin.Context, msg string) {
if q := c.PostForm("q"); q != "" {
params.Set("q", q)
}
if p := c.PostForm("page"); p != "" {
params.Set("page", p)
}
if msg != "" {
params.Set("msg", msg)
}
@@ -672,28 +687,16 @@ func (h *Handler) ClientList(c *gin.Context) {
keyword := c.Query("name")
actor := currentUser(c)
views, err := service.ListClientViewsForUser(h.db, actor, keyword, h.onlineThreshold)
result, err := service.ListClientPageForUser(h.db, actor, keyword, h.onlineThreshold,
service.ParsePage(c.Query("page")))
if err != nil {
fail(c, http.StatusInternalServerError,
"读取客户端列表失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。")
return
}
online := 0
for _, v := range views {
if v.Status == "在线" {
online++
}
}
status := fmt.Sprintf("共 %d 台客户端 · 在线 %d · 离线 %d",
len(views), online, len(views)-online)
if len(views) == 0 {
if actor != nil && actor.IsAdmin() {
status = "还没有客户端。客户端第一次调用领取接口时会自动登记。"
} else {
status = "当前没有绑定给你的客户端,请联系管理员。"
}
}
status := fmt.Sprintf("共 %d 台客户端 · 在线 %d · 离线 %d · 第 %d/%d 页",
result.Total, result.Online, result.Offline, result.Page, result.TotalPages)
purchasers := []model.User{}
if actor != nil && actor.IsAdmin() {
@@ -705,9 +708,16 @@ func (h *Handler) ClientList(c *gin.Context) {
}
}
values := url.Values{}
if strings.TrimSpace(keyword) != "" {
values.Set("name", keyword)
}
c.HTML(http.StatusOK, "client/list", page(c, "clients", "客户端列表", gin.H{
"Keyword": keyword, "Rows": views, "Status": status,
"Keyword": keyword, "Rows": result.Rows, "Status": status,
"Purchasers": purchasers, "Message": c.Query("msg"), "Error": c.Query("error"),
"CurrentPage": result.Page,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"IsFiltered": strings.TrimSpace(keyword) != "",
}))
}
@@ -754,6 +764,12 @@ func (h *Handler) clientActionFailure(c *gin.Context, err error, fallback string
func redirectClients(c *gin.Context, message, errorMessage string) {
query := url.Values{}
if name := c.PostForm("name"); name != "" {
query.Set("name", name)
}
if page := c.PostForm("page"); page != "" {
query.Set("page", page)
}
if message != "" {
query.Set("msg", message)
}
@@ -785,5 +801,5 @@ func (h *Handler) ClientDelete(c *gin.Context) {
return
}
log.Printf("clients_deleted count=%d", n)
c.Redirect(http.StatusSeeOther, "/clients")
redirectClients(c, fmt.Sprintf("已删除 %d 台客户端", n), "")
}
+77
View File
@@ -0,0 +1,77 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func listPostContext(t *testing.T, path string, values url.Values) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
context.Request = request
return context, recorder
}
func assertRedirectQuery(t *testing.T, recorder *httptest.ResponseRecorder, wantPath string, want map[string]string) {
t.Helper()
location, err := url.Parse(recorder.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
if location.Path != wantPath {
t.Fatalf("跳转路径=%s,期望 %s", location.Path, wantPath)
}
for key, value := range want {
if got := location.Query().Get(key); got != value {
t.Fatalf("%s=%q,期望 %q;Location=%s", key, got, value, location.String())
}
}
}
func Test列表写操作跳转保留筛选和页码(t *testing.T) {
t.Run("PDD", func(t *testing.T) {
context, recorder := listPostContext(t, "/pdd/delete", url.Values{
"q": {"737"}, "status": {"pending"}, "page": {"3"},
})
(&Handler{}).pddRedirect(context, "完成")
assertRedirectQuery(t, recorder, "/pdd", map[string]string{
"q": "737", "status": "pending", "page": "3", "msg": "完成",
})
})
t.Run("任务", func(t *testing.T) {
context, recorder := listPostContext(t, "/tasks/delete", url.Values{
"q": {"TASK"}, "type": {"collect"}, "status": {"pending"}, "page": {"2"},
})
(&Handler{}).taskRedirect(context, "完成")
assertRedirectQuery(t, recorder, "/tasks", map[string]string{
"q": "TASK", "type": "collect", "status": "pending", "page": "2", "msg": "完成",
})
})
t.Run("客户端", func(t *testing.T) {
context, recorder := listPostContext(t, "/clients/assign", url.Values{
"name": {"仓库"}, "page": {"4"},
})
redirectClients(context, "完成", "")
assertRedirectQuery(t, recorder, "/clients", map[string]string{
"name": "仓库", "page": "4", "msg": "完成",
})
})
t.Run("用户", func(t *testing.T) {
context, recorder := listPostContext(t, "/users/status", url.Values{
"q": {"buyer"}, "list_status": {"active"}, "page": {"5"},
})
redirectUsers(context, "完成", "")
assertRedirectQuery(t, recorder, "/users", map[string]string{
"q": "buyer", "status": "active", "page": "5", "msg": "完成",
})
})
}
+22 -1
View File
@@ -20,8 +20,9 @@ import (
func (h *Handler) PddList(c *gin.Context) {
keyword := c.Query("q")
status := service.ParseCollectStatus(c.Query("status"))
pageNum := service.ParsePage(c.Query("page"))
result, err := service.ListPddProducts(h.db, keyword, status)
result, err := service.ListPddProducts(h.db, keyword, status, pageNum)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
@@ -35,6 +36,18 @@ func (h *Handler) PddList(c *gin.Context) {
statusLine = msg + " · " + statusLine
}
values := url.Values{}
if keyword != "" {
values.Set("q", keyword)
}
if status != "" {
values.Set("status", string(status))
}
detailValues := url.Values{}
for key, entries := range values {
detailValues[key] = append([]string(nil), entries...)
}
detailValues.Set("page", strconv.Itoa(result.Page))
c.HTML(http.StatusOK, "pdd/list", page(c, "pdd", "PDD 商品", gin.H{
"Rows": result.Rows,
"Keyword": keyword,
@@ -43,6 +56,9 @@ func (h *Handler) PddList(c *gin.Context) {
"StatusOptions": service.CollectStatusOptions(),
"HasAnyProducts": result.Total > 0,
"IsFiltered": result.IsFiltered,
"CurrentPage": result.Page,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"DetailURL": "/pdd/detail?" + detailValues.Encode(),
}))
}
@@ -71,6 +87,8 @@ func (h *Handler) PddDetail(c *gin.Context) {
c.HTML(http.StatusOK, "pdd/edit_modal", gin.H{
"D": detail,
"CSRFToken": csrfToken(c),
"Keyword": c.Query("q"), "StatusFilter": c.Query("status"),
"CurrentPage": service.ParsePage(c.Query("page")),
})
}
@@ -156,6 +174,9 @@ func (h *Handler) pddRedirect(c *gin.Context, msg string) {
if q := c.PostForm("q"); q != "" {
params.Set("q", q)
}
if p := c.PostForm("page"); p != "" {
params.Set("page", p)
}
if msg != "" {
params.Set("msg", msg)
}
+23 -2
View File
@@ -34,9 +34,13 @@ func (h *Handler) UserList(c *gin.Context) {
}
func (h *Handler) renderUserList(c *gin.Context, httpStatus int, message, errorMessage string, needCreate bool, createUsername string) {
keyword := strings.TrimSpace(c.Query("q"))
keyword := strings.TrimSpace(userListValue(c, "q"))
listStatus := userListValue(c, "status")
if c.Request.Method == http.MethodPost {
listStatus = c.PostForm("list_status")
}
result, err := service.ListUsers(h.db, currentUser(c), keyword,
model.UserStatus(c.Query("status")), service.ParsePage(c.Query("page")))
model.UserStatus(listStatus), service.ParsePage(userListValue(c, "page")))
if err != nil {
fail(c, http.StatusInternalServerError, "读取用户列表失败,账号数据没有被改动。刷新后重试。")
return
@@ -54,9 +58,17 @@ func (h *Handler) renderUserList(c *gin.Context, httpStatus int, message, errorM
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"StatusFilter": result.Status, "Message": message, "Error": errorMessage,
"NeedCreate": needCreate, "CreateUsername": createUsername,
"CurrentPage": result.Page,
}))
}
func userListValue(c *gin.Context, key string) string {
if c.Request.Method == http.MethodPost {
return c.PostForm(key)
}
return c.Query(key)
}
// UserCreate 只创建固定 purchaser 角色,密码不写入 URL、不回显。
func (h *Handler) UserCreate(c *gin.Context) {
err := service.CreatePurchaser(h.db, currentUser(c), c.PostForm("username"),
@@ -114,6 +126,15 @@ func isExpectedUserError(err error) bool {
func redirectUsers(c *gin.Context, message, errorMessage string) {
query := url.Values{}
if keyword := c.PostForm("q"); keyword != "" {
query.Set("q", keyword)
}
if status := c.PostForm("list_status"); status != "" {
query.Set("status", status)
}
if page := c.PostForm("page"); page != "" {
query.Set("page", page)
}
if message != "" {
query.Set("msg", message)
}
+24 -2
View File
@@ -3,6 +3,7 @@ package main
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
@@ -11,7 +12,23 @@ import (
"cmautobuy/admin/service"
)
// 五个主页面都走一次真实路由和模板渲染。
func TestPaginationCSS_桌面右对齐且窄窗口不遮挡内容(t *testing.T) {
content, err := os.ReadFile("static/css/app.css")
if err != nil {
t.Fatal(err)
}
css := string(content)
for _, want := range []string{
"margin-left: auto", "flex: 0 0 auto", "@media (max-width: 760px)",
"position: static", "justify-content: flex-end",
} {
if !strings.Contains(css, want) {
t.Fatalf("分页布局 CSS 缺少 %q", want)
}
}
}
// 六个主页面都走一次真实路由和模板渲染。
// 这样模板字段写错或新增列漏接时,测试阶段就会失败,不必等人工点页面。
func TestMainPagesReturnOK(t *testing.T) {
db, err := repository.Open(t.TempDir())
@@ -49,7 +66,7 @@ func TestMainPagesReturnOK(t *testing.T) {
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: token})
}
for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients"} {
for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients", "/users"} {
t.Run(path, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, path, nil)
addAuth(request)
@@ -59,6 +76,11 @@ func TestMainPagesReturnOK(t *testing.T) {
t.Fatalf("GET %s = %d,期望 200,响应:%s",
path, response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, `<nav class="pagination" aria-label="分页">`) ||
!strings.Contains(body, "首页") || !strings.Contains(body, "末页") {
t.Fatalf("GET %s 没有渲染公共分页组件:%s", path, body)
}
})
}
+53 -13
View File
@@ -126,16 +126,8 @@ func ListClients(db *sql.DB, keyword string) ([]model.Client, error) {
return out, rows.Err()
}
// ListClientsForUser 返回带当前负责人的客户端。
// visibleUserID 为空表示管理员全量视图,否则只返回当前绑定给该用户的客户端。
func ListClientsForUser(db *sql.DB, keyword, visibleUserID string) ([]ClientWithAssignee, error) {
query := `SELECT c.client_id, c.name, c.device_address, c.platform, c.pdd_package,
c.capabilities, c.last_seen_at, c.created_at, c.updated_at,
a.user_id, u.username
FROM clients c
LEFT JOIN client_user_assignments a
ON a.client_id = c.client_id AND a.ended_at IS NULL
LEFT JOIN users u ON u.user_id = a.user_id`
// clientListFilter 统一生成客户端分页、总数和在线统计的可见范围。
func clientListFilter(keyword, visibleUserID string) (string, []any) {
where := make([]string, 0, 2)
args := make([]any, 0, 3)
if visibleUserID != "" {
@@ -147,10 +139,24 @@ func ListClientsForUser(db *sql.DB, keyword, visibleUserID string) ([]ClientWith
like := "%" + kw + "%"
args = append(args, like, like)
}
if len(where) > 0 {
query += ` WHERE ` + strings.Join(where, ` AND `)
if len(where) == 0 {
return "", args
}
query += ` ORDER BY c.last_seen_at DESC, c.client_id`
return ` WHERE ` + strings.Join(where, ` AND `), args
}
const clientListFrom = ` FROM clients c
LEFT JOIN client_user_assignments a
ON a.client_id = c.client_id AND a.ended_at IS NULL
LEFT JOIN users u ON u.user_id = a.user_id`
func listClientsForUserPage(db *sql.DB, keyword, visibleUserID string, limit, offset int) ([]ClientWithAssignee, error) {
where, args := clientListFilter(keyword, visibleUserID)
query := `SELECT c.client_id, c.name, c.device_address, c.platform, c.pdd_package,
c.capabilities, c.last_seen_at, c.created_at, c.updated_at,
a.user_id, u.username` + clientListFrom + where +
` ORDER BY c.last_seen_at DESC, c.client_id LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
@@ -173,6 +179,40 @@ func ListClientsForUser(db *sql.DB, keyword, visibleUserID string) ([]ClientWith
return out, rows.Err()
}
// ListClientsForUser 返回带当前负责人的全部客户端,供需要完整候选集的业务使用。
func ListClientsForUser(db *sql.DB, keyword, visibleUserID string) ([]ClientWithAssignee, error) {
return listClientsForUserPage(db, keyword, visibleUserID, -1, 0)
}
// ListClientsForUserPage 返回一页客户端和同一可见范围内的总数。
func ListClientsForUserPage(db *sql.DB, keyword, visibleUserID string, limit, offset int) ([]ClientWithAssignee, int, error) {
where, args := clientListFilter(keyword, visibleUserID)
var total int
if err := db.QueryRow(`SELECT COUNT(*)`+clientListFrom+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("统计客户端列表失败: %w", err)
}
rows, err := listClientsForUserPage(db, keyword, visibleUserID, limit, offset)
return rows, total, err
}
// CountOnlineClientsForUser 统计同一筛选和权限范围内的在线客户端。
// julianday 解析失败会得到 NULL,自然按离线处理,与 model.Client.IsOnline 一致。
func CountOnlineClientsForUser(db *sql.DB, keyword, visibleUserID, cutoffISO string) (int, error) {
where, args := clientListFilter(keyword, visibleUserID)
args = append(args, cutoffISO)
var online int
query := `SELECT COUNT(*)` + clientListFrom + where
if where == "" {
query += ` WHERE julianday(c.last_seen_at) > julianday(?)`
} else {
query += ` AND julianday(c.last_seen_at) > julianday(?)`
}
if err := db.QueryRow(query, args...).Scan(&online); err != nil {
return 0, fmt.Errorf("统计在线客户端失败: %w", err)
}
return online, nil
}
// ListActivePurchasers 返回可成为新负责人的启用采购员。
func ListActivePurchasers(db *sql.DB) ([]model.User, error) {
rows, err := db.Query(`
+33 -20
View File
@@ -174,33 +174,43 @@ const pddSkuCountExpr = `
ELSE COALESCE(json_array_length(skus_json, '$.skus'), -1)
END`
// ListPddProducts 查列表。keyword 匹配 goods_id 或链接,status 为空表示不筛选。
//
// 只返回未删除的。软删除的记录还在库里(sku_mappings 指着它),
// 但界面上不该看见。
func ListPddProducts(q Execer, keyword string, status model.CollectStatus) ([]PddProductRow, error) {
sqlText := `SELECT ` + pddColumns + `,` + pddSkuCountExpr + `
FROM pdd_products
WHERE deleted_at IS NULL`
var args []any
// pddListFilter 统一生成 PDD 列表和总数查询的筛选条件。
// 两个查询必须共用这一处,避免新增筛选时只改列表、漏改页数。
func pddListFilter(keyword string, status model.CollectStatus) (string, []any) {
where := ` WHERE deleted_at IS NULL`
args := make([]any, 0, 3)
if keyword = strings.TrimSpace(keyword); keyword != "" {
// ESCAPE 不能省:操作员搜 "_" 时,LIKE 会把它当"任意一个字符",
// 结果是全部行都命中,看着像搜索坏了。
pattern := "%" + escapeLike(keyword) + "%"
sqlText += ` AND (goods_id LIKE ? ESCAPE '\' OR url LIKE ? ESCAPE '\')`
where += ` AND (goods_id LIKE ? ESCAPE '\' OR url LIKE ? ESCAPE '\')`
args = append(args, pattern, pattern)
}
if status != "" {
sqlText += ` AND collect_status = ?`
where += ` AND collect_status = ?`
args = append(args, string(status))
}
// 刚动过的排前面,操作员点完"创建采集任务"一眼能看到结果
sqlText += ` ORDER BY updated_at DESC, id DESC`
return where, args
}
rows, err := q.Query(sqlText, args...)
// ListPddProducts 查一页列表和当前筛选下的总数。
// keyword 匹配 goods_id 或链接,status 为空表示不筛选。
//
// 只返回未删除的。软删除的记录还在库里(sku_mappings 指着它),
// 但界面上不该看见。
func ListPddProducts(q Execer, keyword string, status model.CollectStatus, limit, offset int) ([]PddProductRow, int, error) {
where, args := pddListFilter(keyword, status)
var total int
if err := q.QueryRow(`SELECT COUNT(*) FROM pdd_products`+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("统计 PDD 商品列表失败: %w", err)
}
sqlText := `SELECT ` + pddColumns + `,` + pddSkuCountExpr + `
FROM pdd_products` + where
// 刚动过的排前面,操作员点完"创建采集任务"一眼能看到结果
sqlText += ` ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`
listArgs := append(append([]any{}, args...), limit, offset)
rows, err := q.Query(sqlText, listArgs...)
if err != nil {
return nil, fmt.Errorf("查询 PDD 商品列表失败: %w", err)
return nil, 0, fmt.Errorf("查询 PDD 商品列表失败: %w", err)
}
defer rows.Close()
@@ -209,12 +219,15 @@ func ListPddProducts(q Execer, keyword string, status model.CollectStatus) ([]Pd
var r PddProductRow
p, err := scanPddProduct(rows, &r.SkuCount)
if err != nil {
return nil, fmt.Errorf("读取 PDD 商品列表失败: %w", err)
return nil, 0, fmt.Errorf("读取 PDD 商品列表失败: %w", err)
}
r.PddProduct = *p
list = append(list, r)
}
return list, rows.Err()
if err := rows.Err(); err != nil {
return nil, 0, err
}
return list, total, nil
}
// escapeLike 把 LIKE 的三个特殊字符转义掉,配合 ESCAPE '\' 使用。
+3 -2
View File
@@ -329,7 +329,7 @@ func taskFilterClause(filter TaskFilter) (string, []any) {
// 「目标」列更好读;join 条件里的 `deleted_at IS NULL` 让"join 不到"和
// "商品已软删除"这两种情况都自然落到 PddTitle 为空,service 层退回显示
// PddGoodsID 即可,不用在 service 里再判断一次已删除。
func ListTasks(q Execer, filter TaskFilter) ([]TaskListRow, error) {
func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, error) {
where, args := taskFilterClause(filter)
sqlText := `
SELECT t.task_id, t.task_type, t.status, t.assigned_client,
@@ -338,7 +338,8 @@ func ListTasks(q Execer, filter TaskFilter) ([]TaskListRow, error) {
FROM tasks t
LEFT JOIN pdd_products p
ON p.goods_id = t.pdd_goods_id AND p.deleted_at IS NULL` +
where + ` ORDER BY t.updated_at DESC, t.task_id DESC`
where + ` ORDER BY t.updated_at DESC, t.task_id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := q.Query(sqlText, args...)
if err != nil {
+37
View File
@@ -3,6 +3,7 @@ package service
import (
"database/sql"
"errors"
"fmt"
"path/filepath"
"sync"
"testing"
@@ -12,6 +13,42 @@ import (
"cmautobuy/admin/repository"
)
func TestListClientPageForUser_分页统计不泄露其他采购员客户端(t *testing.T) {
db := newTestDB(t)
admin, buyerA, _ := prepareClientAssignmentUsers(t, db)
now := time.Now().UTC()
for i := 0; i < 22; i++ {
clientID := fmt.Sprintf("page-client-%02d", i)
if err := RegisterClient(db, model.Client{ClientID: clientID, Name: clientID}, true); err != nil {
t.Fatal(err)
}
if i < 21 {
if _, _, err := AssignClient(db, admin, clientID, buyerA.UserID, now); err != nil {
t.Fatalf("绑定 %s 失败: %v", clientID, err)
}
}
}
buyerPage, err := ListClientPageForUser(db, buyerA, "", time.Hour, 2)
if err != nil {
t.Fatal(err)
}
if len(buyerPage.Rows) != 1 || buyerPage.Total != 21 || buyerPage.Page != 2 || buyerPage.TotalPages != 2 {
t.Fatalf("采购员分页不对: rows=%d total=%d page=%d/%d",
len(buyerPage.Rows), buyerPage.Total, buyerPage.Page, buyerPage.TotalPages)
}
if buyerPage.Online+buyerPage.Offline != 21 {
t.Fatalf("采购员统计泄露或漏算: online=%d offline=%d", buyerPage.Online, buyerPage.Offline)
}
adminPage, err := ListClientPageForUser(db, admin, "", time.Hour, 999)
if err != nil {
t.Fatal(err)
}
if adminPage.Total != 22 || adminPage.Page != 2 || len(adminPage.Rows) != 2 {
t.Fatalf("管理员分页不对: rows=%d total=%d page=%d", len(adminPage.Rows), adminPage.Total, adminPage.Page)
}
}
// newTestDB 建一个临时库,测试结束自动删。
// 用真实的 migrations,这样表结构变了测试会跟着失败。
func newTestDB(t *testing.T) *sql.DB {
+26 -10
View File
@@ -238,26 +238,42 @@ type PddProductView struct {
// PddListResult 是列表页要的全部数据。
type PddListResult struct {
Rows []PddProductView
Total int // 全部未删除记录数,不受筛选影响
Counts map[model.CollectStatus]int
IsFiltered bool
Rows []PddProductView
Total int // 全部未删除记录数,不受筛选影响
FilteredTotal int // 当前筛选下的总数,用于页数和筛选状态
Page int
TotalPages int
Counts map[model.CollectStatus]int
IsFiltered bool
}
// ListPddProducts 查列表并把每一行翻成界面文字。
func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus) (*PddListResult, error) {
rows, err := repository.ListPddProducts(db, keyword, status)
func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus, requestedPage int) (*PddListResult, error) {
page := requestedPage
if page < 1 {
page = 1
}
rows, filteredTotal, err := repository.ListPddProducts(db, keyword, status, PageSize, (page-1)*PageSize)
if err != nil {
return nil, err
}
totalPages := TotalPages(filteredTotal)
clampedPage := ClampPage(page, totalPages)
if clampedPage != page {
page = clampedPage
rows, _, err = repository.ListPddProducts(db, keyword, status, PageSize, (page-1)*PageSize)
if err != nil {
return nil, err
}
}
counts, err := repository.CountPddProductsByStatus(db)
if err != nil {
return nil, err
}
result := &PddListResult{
Rows: make([]PddProductView, 0, len(rows)),
Counts: counts,
Rows: make([]PddProductView, 0, len(rows)), Counts: counts,
FilteredTotal: filteredTotal, Page: page, TotalPages: totalPages,
IsFiltered: strings.TrimSpace(keyword) != "" || status != "",
}
for _, n := range counts {
@@ -312,9 +328,9 @@ func (r *PddListResult) StatusLine() string {
}
line := strings.Join(parts, " · ")
if r.IsFiltered {
line = fmt.Sprintf("筛选出 %d 条 / %s", len(r.Rows), line)
line = fmt.Sprintf("筛选出 %d 条 / %s", r.FilteredTotal, line)
}
return line
return fmt.Sprintf("%s · 第 %d/%d 页", line, r.Page, r.TotalPages)
}
// ---------- 弹窗 ----------
+37 -9
View File
@@ -3,6 +3,7 @@ package service
import (
"database/sql"
"errors"
"fmt"
"strings"
"sync"
"testing"
@@ -12,6 +13,33 @@ import (
"cmautobuy/admin/repository"
)
func TestListPddProducts_统一每页20条并收敛越界页(t *testing.T) {
db := newTestDB(t)
for i := 0; i < 25; i++ {
goodsID := fmt.Sprintf("73711653%04d", i)
if _, err := CreatePddProduct(db,
"https://mobile.yangkeduo.com/goods.html?goods_id="+goodsID); err != nil {
t.Fatalf("创建第 %d 个商品失败: %v", i, err)
}
}
second, err := ListPddProducts(db, "", "", 2)
if err != nil {
t.Fatal(err)
}
if len(second.Rows) != 5 || second.FilteredTotal != 25 || second.Page != 2 || second.TotalPages != 2 {
t.Fatalf("第二页结果不对: rows=%d total=%d page=%d/%d",
len(second.Rows), second.FilteredTotal, second.Page, second.TotalPages)
}
overflow, err := ListPddProducts(db, "", "", 999)
if err != nil {
t.Fatal(err)
}
if overflow.Page != 2 || len(overflow.Rows) != 5 {
t.Fatalf("越界页应收敛到末页: page=%d rows=%d", overflow.Page, len(overflow.Rows))
}
}
// ── 链接解析 ───────────────────────────────────────────
func TestParsePddGoodsID_认得的写法(t *testing.T) {
@@ -259,7 +287,7 @@ func TestListPddProducts_规格数(t *testing.T) {
repository.SetCollectResult(db, "100000000002", "有规格的", "", sampleSkusJSON)
repository.SetCollectResult(db, "100000000003", "没规格的", "", `{"skus": []}`)
result, err := ListPddProducts(db, "", "")
result, err := ListPddProducts(db, "", "", 1)
if err != nil {
t.Fatalf("查列表失败: %v", err)
}
@@ -287,7 +315,7 @@ func TestListPddProducts_坏掉的采集结果不影响列表打开(t *testing.T
t.Fatalf("造坏数据失败: %v", err)
}
result, err := ListPddProducts(db, "", "")
result, err := ListPddProducts(db, "", "", 1)
if err != nil {
t.Fatalf("列表页应该照样打得开: %v", err)
}
@@ -302,7 +330,7 @@ func TestListPddProducts_按采集状态筛选(t *testing.T) {
createProduct(t, db, "100000000002")
repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON)
collected, err := ListPddProducts(db, "", model.CollectCollected)
collected, err := ListPddProducts(db, "", model.CollectCollected, 1)
if err != nil {
t.Fatalf("筛选失败: %v", err)
}
@@ -324,12 +352,12 @@ func TestListPddProducts_按商品ID和链接搜索(t *testing.T) {
createProduct(t, db, "737116531267")
createProduct(t, db, "999888777666")
byID, _ := ListPddProducts(db, "7371165", "")
byID, _ := ListPddProducts(db, "7371165", "", 1)
if len(byID.Rows) != 1 || byID.Rows[0].GoodsID != "737116531267" {
t.Errorf("按 ID 片段搜索失败,命中 %d 条", len(byID.Rows))
}
byURL, _ := ListPddProducts(db, "yangkeduo", "")
byURL, _ := ListPddProducts(db, "yangkeduo", "", 1)
if len(byURL.Rows) != 2 {
t.Errorf("按链接搜索应命中 2 条,实际 %d 条", len(byURL.Rows))
}
@@ -344,7 +372,7 @@ func TestListPddProducts_搜索词里的通配符不当通配符用(t *testing.T
createProduct(t, db, "737116531267")
for _, kw := range []string{"7_7", "%"} {
result, err := ListPddProducts(db, kw, "")
result, err := ListPddProducts(db, kw, "", 1)
if err != nil {
t.Fatalf("搜 %q 出错: %v", kw, err)
}
@@ -359,7 +387,7 @@ func TestListPddProducts_删除的查不到(t *testing.T) {
createProduct(t, db, "737116531267")
DeletePddProducts(db, []string{"737116531267"})
result, _ := ListPddProducts(db, "", "")
result, _ := ListPddProducts(db, "", "", 1)
if len(result.Rows) != 0 {
t.Errorf("软删除的不该出现在列表里,实际 %d 条", len(result.Rows))
}
@@ -374,7 +402,7 @@ func TestStatusLine_四个状态都列出来(t *testing.T) {
createProduct(t, db, "100000000002")
repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON)
result, _ := ListPddProducts(db, "", "")
result, _ := ListPddProducts(db, "", "", 1)
line := result.StatusLine()
for _, want := range []string{"共 2 条", "已采集 1", "未采集 1", "采集中 0", "采集失败 0"} {
if !strings.Contains(line, want) {
@@ -444,7 +472,7 @@ func TestPdd页面_显示店铺与按颜色采样标记(t *testing.T) {
repository.SetCollectResult(
db, "737116531267", "测试商品", "测试旗舰店", resultJSON)
list, err := ListPddProducts(db, "", "")
list, err := ListPddProducts(db, "", "", 1)
if err != nil {
t.Fatalf("读取列表失败: %v", err)
}
+63 -3
View File
@@ -131,17 +131,25 @@ func ListClientViews(db *sql.DB, keyword string, threshold time.Duration) ([]Cli
// ListClientViewsForUser 按网页登录身份限制可见范围:管理员全量,采购员只看自己。
func ListClientViewsForUser(db *sql.DB, actor *model.User, keyword string, threshold time.Duration) ([]ClientView, error) {
visibleUserID, err := visibleClientUserID(actor)
if err != nil {
return nil, err
}
return listClientViews(db, keyword, visibleUserID, threshold)
}
func visibleClientUserID(actor *model.User) (string, error) {
if actor == nil {
return nil, ErrUnauthenticated
return "", ErrUnauthenticated
}
visibleUserID := ""
if !actor.IsAdmin() {
if actor.Role != model.RolePurchaser {
return nil, ErrAdminRequired
return "", ErrAdminRequired
}
visibleUserID = actor.UserID
}
return listClientViews(db, keyword, visibleUserID, threshold)
return visibleUserID, nil
}
func listClientViews(db *sql.DB, keyword, visibleUserID string, threshold time.Duration) ([]ClientView, error) {
@@ -163,6 +171,58 @@ func listClientViews(db *sql.DB, keyword, visibleUserID string, threshold time.D
return views, nil
}
// ClientListResult 是客户端页面的一页数据和同一权限范围内的完整统计。
type ClientListResult struct {
Rows []ClientView
Page int
Total int
TotalPages int
Online int
Offline int
}
// ListClientPageForUser 按网页登录身份、搜索词和统一页大小返回客户端列表。
func ListClientPageForUser(db *sql.DB, actor *model.User, keyword string, threshold time.Duration, requestedPage int) (*ClientListResult, error) {
visibleUserID, err := visibleClientUserID(actor)
if err != nil {
return nil, err
}
now := time.Now().UTC()
page := requestedPage
if page < 1 {
page = 1
}
rows, total, err := repository.ListClientsForUserPage(db, keyword, visibleUserID, PageSize, (page-1)*PageSize)
if err != nil {
return nil, err
}
totalPages := TotalPages(total)
clampedPage := ClampPage(page, totalPages)
if clampedPage != page {
page = clampedPage
rows, _, err = repository.ListClientsForUserPage(db, keyword, visibleUserID, PageSize, (page-1)*PageSize)
if err != nil {
return nil, err
}
}
online, err := repository.CountOnlineClientsForUser(
db, keyword, visibleUserID, now.Add(-threshold).Format(model.TimeLayout))
if err != nil {
return nil, err
}
views := make([]ClientView, 0, len(rows))
for _, client := range rows {
views = append(views, ClientView{
Client: client.Client, Status: client.StatusText(now, threshold),
AssignedUserID: client.AssignedUserID, AssignedUsername: client.AssignedUsername,
})
}
return &ClientListResult{
Rows: views, Page: page, Total: total, TotalPages: totalPages,
Online: online, Offline: total - online,
}, nil
}
// ListAssignableClients 返回当前用户在采购任务页面可选择的客户端。
// 当前创建页面尚未实现,本函数固定未来入口也必须沿用相同权限边界。
func ListAssignableClients(db *sql.DB, actor *model.User, threshold time.Duration) ([]ClientView, error) {
+16 -11
View File
@@ -233,9 +233,11 @@ type TaskView struct {
// TaskListResult 是列表页要的全部数据。
type TaskListResult struct {
Rows []TaskView
Counts map[model.TaskStatus]int
Total int // 当前筛选下的总数(= Counts 求和),不是全库总数
Rows []TaskView
Counts map[model.TaskStatus]int
Total int // 当前筛选下的总数(= Counts 求和),不是全库总数
Page int
TotalPages int
// IsFiltered 为 false 时如果 Rows 也是空的,说明库里从来没建过任务
// (不筛选就是查全表,全表空自然等价于"从没有过");为 true 时
@@ -247,12 +249,18 @@ type TaskListResult struct {
//
// `[必须]` 底部统计要跟随当前筛选,所以 Counts 和 Rows 用的是**同一个**
// filter,见 #19。
func ListTasksView(db *sql.DB, filter repository.TaskFilter) (*TaskListResult, error) {
rows, err := repository.ListTasks(db, filter)
func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int) (*TaskListResult, error) {
counts, err := repository.CountTasksByStatus(db, filter)
if err != nil {
return nil, err
}
counts, err := repository.CountTasksByStatus(db, filter)
total := 0
for _, n := range counts {
total += n
}
totalPages := TotalPages(total)
page := ClampPage(requestedPage, totalPages)
rows, err := repository.ListTasks(db, filter, PageSize, (page-1)*PageSize)
if err != nil {
return nil, err
}
@@ -260,13 +268,10 @@ func ListTasksView(db *sql.DB, filter repository.TaskFilter) (*TaskListResult, e
result := &TaskListResult{
Rows: make([]TaskView, 0, len(rows)),
Counts: counts,
Total: total, Page: page, TotalPages: totalPages,
IsFiltered: filter.Type != "" || filter.Status != "" ||
strings.TrimSpace(filter.Keyword) != "",
}
for _, n := range counts {
result.Total += n
}
for _, r := range rows {
result.Rows = append(result.Rows, TaskView{
TaskID: r.TaskID,
@@ -292,7 +297,7 @@ func (r *TaskListResult) StatusLine() string {
for _, s := range taskStatusOrder {
parts = append(parts, fmt.Sprintf("%s %d", taskStatusText(s), r.Counts[s]))
}
return strings.Join(parts, " · ")
return fmt.Sprintf("%s · 第 %d/%d 页", strings.Join(parts, " · "), r.Page, r.TotalPages)
}
// DeleteTasks 批量删除任务,返回实际删掉的条数。
+42 -16
View File
@@ -2,12 +2,38 @@ package service
import (
"database/sql"
"fmt"
"testing"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
func TestListTasksView_统一每页20条并收敛越界页(t *testing.T) {
db := newTestDB(t)
for i := 0; i < 25; i++ {
insertTestTask(t, db, testTaskParams{
taskID: fmt.Sprintf("PAGE-%02d", i), taskType: model.TaskCollect,
status: model.TaskPending, pddGoodsID: fmt.Sprintf("%d", i+1),
})
}
second, err := ListTasksView(db, repository.TaskFilter{}, 2)
if err != nil {
t.Fatal(err)
}
if len(second.Rows) != 5 || second.Total != 25 || second.Page != 2 || second.TotalPages != 2 {
t.Fatalf("第二页结果不对: rows=%d total=%d page=%d/%d",
len(second.Rows), second.Total, second.Page, second.TotalPages)
}
overflow, err := ListTasksView(db, repository.TaskFilter{}, 999)
if err != nil {
t.Fatal(err)
}
if overflow.Page != 2 || len(overflow.Rows) != 5 {
t.Fatalf("越界页应收敛到末页: page=%d rows=%d", overflow.Page, len(overflow.Rows))
}
}
// insertTestTask 插一条任务,字段按需覆盖默认值,供本文件的用例使用。
// 跟 client_test.go 里的 insertTask 不一样:那个专为"领取"场景写死了固定值,
// 这里需要覆盖类型、状态、options 等更多维度。
@@ -94,7 +120,7 @@ func TestListTasksView_同时显示采集和采购(t *testing.T) {
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"})
result, err := ListTasksView(db, repository.TaskFilter{})
result, err := ListTasksView(db, repository.TaskFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -110,7 +136,7 @@ func TestListTasksView_类型筛选生效(t *testing.T) {
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"})
result, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskCollect})
result, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskCollect}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -127,7 +153,7 @@ func TestListTasksView_状态筛选生效(t *testing.T) {
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "222"})
result, err := ListTasksView(db, repository.TaskFilter{Status: model.TaskSucceeded})
result, err := ListTasksView(db, repository.TaskFilter{Status: model.TaskSucceeded}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -143,17 +169,17 @@ func TestListTasksView_关键词同时匹配任务编号订单号PDD商品ID(t *
insertTestTask(t, db, testTaskParams{taskID: "PUR-2", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-OTHER"})
// 按任务编号命中
r, err := ListTasksView(db, repository.TaskFilter{Keyword: "ABC"})
r, err := ListTasksView(db, repository.TaskFilter{Keyword: "ABC"}, 1)
if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-ABC" {
t.Fatalf("按任务编号搜索失败: err=%v rows=%+v", err, r)
}
// 按订单号命中
r, err = ListTasksView(db, repository.TaskFilter{Keyword: "XYZ"})
r, err = ListTasksView(db, repository.TaskFilter{Keyword: "XYZ"}, 1)
if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "PUR-1" {
t.Fatalf("按订单号搜索失败: err=%v rows=%+v", err, r)
}
// 按 PDD 商品 ID 命中
r, err = ListTasksView(db, repository.TaskFilter{Keyword: "737116531267"})
r, err = ListTasksView(db, repository.TaskFilter{Keyword: "737116531267"}, 1)
if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-ABC" {
t.Fatalf("按 PDD 商品 ID 搜索失败: err=%v rows=%+v", err, r)
}
@@ -165,7 +191,7 @@ func TestListTasksView_目标列_采集任务显示PDD商品ID(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"})
r, err := ListTasksView(db, repository.TaskFilter{})
r, err := ListTasksView(db, repository.TaskFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -185,7 +211,7 @@ func TestListTasksView_目标列_采集任务能join到标题时追加显示(t *
}
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "737116531267"})
r, err := ListTasksView(db, repository.TaskFilter{})
r, err := ListTasksView(db, repository.TaskFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -206,7 +232,7 @@ func TestListTasksView_目标列_商品已软删除时退回显示商品ID(t *te
}
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"})
r, err := ListTasksView(db, repository.TaskFilter{})
r, err := ListTasksView(db, repository.TaskFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -224,7 +250,7 @@ func TestListTasksView_目标列_采购任务显示订单号规格数量价格
quantity: 2, maxPriceCent: 4200,
})
r, err := ListTasksView(db, repository.TaskFilter{})
r, err := ListTasksView(db, repository.TaskFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -240,7 +266,7 @@ func TestListTasksView_无主任务客户端列显示占位符(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
r, err := ListTasksView(db, repository.TaskFilter{})
r, err := ListTasksView(db, repository.TaskFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -258,7 +284,7 @@ func TestListTasksView_统计跟随当前筛选(t *testing.T) {
insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"})
insertTestTask(t, db, testTaskParams{taskID: "PUR-2", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-2"})
all, err := ListTasksView(db, repository.TaskFilter{})
all, err := ListTasksView(db, repository.TaskFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -266,7 +292,7 @@ func TestListTasksView_统计跟随当前筛选(t *testing.T) {
t.Fatalf("不筛选时总数应该是 4,实际 %d", all.Total)
}
collectOnly, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskCollect})
collectOnly, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskCollect}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -285,7 +311,7 @@ func TestListTasksView_统计跟随当前筛选(t *testing.T) {
func TestListTasksView_从未建过任务时不算筛选无结果(t *testing.T) {
db := newTestDB(t)
r, err := ListTasksView(db, repository.TaskFilter{})
r, err := ListTasksView(db, repository.TaskFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -301,7 +327,7 @@ func TestListTasksView_筛选无结果时IsFiltered为true(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
r, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskPurchase})
r, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskPurchase}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -416,7 +442,7 @@ func TestDeleteTasks_批量删除(t *testing.T) {
t.Fatalf("应该删除 1 条,实际 %d", n)
}
r, err := ListTasksView(db, repository.TaskFilter{})
r, err := ListTasksView(db, repository.TaskFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
+19 -4
View File
@@ -1,9 +1,9 @@
/* Admin 样式表。手写 CSS,不引入框架、不引入构建流程。
见 admin/AGENTS.md 前端约束。
五个模块共用同一套三段式布局的样式:
六个主列表共用同一套三段式布局的样式:
.toolbar(顶部工具条)/ .table-wrap(中间表格)/ .statusbar(底部状态条)。
改这里会同时影响五个页面,这是有意的——不要为某个页面复制一份。 */
改这里会同时影响六个主列表,这是有意的——不要为某个页面复制一份。 */
* { box-sizing: border-box; }
@@ -185,6 +185,11 @@ tr.empty small { color: #aaa; }
color: #c8cdd4;
padding: 10px 16px;
font-size: 13px;
gap: 12px;
}
.statusbar > span:first-child {
flex: 1 1 auto;
min-width: 0;
}
/* 链接和标题这类长内容截断显示,鼠标悬停看全文(title 属性)。
@@ -381,13 +386,14 @@ select {
/* ── 分页控件 ─────────────────────────── */
/* 见 docs/admin/05-ui-specification.md §3.2。全项目第一个分页页面
(蝦皮数据页,工单 #43)定的样式,其余四个页面照抄。
(蝦皮数据页,工单 #43)定的样式,其余主列表复用。
控件本身是 <a href>,不是按钮/JS,样式借用 button 的观感即可。 */
.pagination {
display: flex;
align-items: center;
gap: 6px;
margin-left: 12px;
margin-left: auto;
flex: 0 0 auto;
}
.pagination a,
.pagination span.disabled {
@@ -397,6 +403,15 @@ select {
text-decoration: none;
font-size: 13px;
}
/* 窄窗口把状态和分页分成两行,并让底栏回到文档流。
这样无论状态文字换几行,都不会遮住表格最后一行。 */
@media (max-width: 760px) {
body { padding-bottom: 0; }
.statusbar { position: static; flex-wrap: wrap; gap: 6px 12px; }
.statusbar > span:first-child { flex-basis: 100%; }
.pagination { width: 100%; justify-content: flex-end; margin-left: 0; }
}
.pagination a { color: #1f6feb; background: #fff; }
.pagination a:hover { background: #f0f2f4; }
/* 首页/末页时对应按钮禁用:用 <span> 而不是不可点的 <a>(语义上不再是链接),
+2 -1
View File
@@ -211,7 +211,8 @@
slot.innerHTML = "<div class='modal-body'>正在加载…</div>";
openModal(modal);
fetch(base + "?id=" + encodeURIComponent(row.getAttribute("data-detail-id")), {
var separator = base.indexOf("?") === -1 ? "?" : "&";
fetch(base + separator + "id=" + encodeURIComponent(row.getAttribute("data-detail-id")), {
headers: { "X-Requested-With": "fetch" }
})
.then(function (resp) {
+9 -1
View File
@@ -10,6 +10,8 @@
{{if .CurrentUser.IsAdmin}}<form class="inline" id="client-delete-form" method="post" action="/clients/delete" data-confirm-delete>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="name" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
<button type="submit" class="danger" data-need-checked>删除</button>
</form>{{end}}
</div>
@@ -55,6 +57,8 @@
data-confirm-submit="确定解绑客户端“{{.Name}}”吗?采购员“{{.AssignedUsername}}”将立即看不到它;归属历史和既有任务会保留。">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="client_id" value="{{.ClientID}}">
<input type="hidden" name="name" value="{{$.Keyword}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}">
<button type="submit" class="danger">解绑</button>
</form>{{end}}
</div>
@@ -63,7 +67,9 @@
{{else}}
<tr class="empty">
<td colspan="{{if $.CurrentUser.IsAdmin}}8{{else}}6{{end}}">
{{if $.CurrentUser.IsAdmin}}还没有客户端。<br>
{{if $.IsFiltered}}当前筛选条件下没有客户端。<br>
<small>换个名称或清空搜索词再试。<a href="/clients">查看全部</a></small>
{{else if $.CurrentUser.IsAdmin}}还没有客户端。<br>
<small>客户端第一次调用领取接口时会自动登记到这里,不需要手工添加。</small>
{{else}}当前没有绑定给你的客户端。<br><small>如需使用客户端,请联系管理员绑定。</small>{{end}}
</td>
@@ -99,6 +105,8 @@
<p class="hint" data-assignment-note>绑定只改变负责人和网页可见范围,不影响 Client 接口或既有任务。</p>
<input type="hidden" name="client_id" data-assign-client-id-input>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="name" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
+1 -1
View File
@@ -1,7 +1,7 @@
{{define "footer"}}
</main>
{{/* 底部状态条:三段式布局的第三段,五个页面都有。
{{/* 底部状态条:三段式布局的第三段,六个主列表都有。
Pagination 是可选的(只有做了分页的页面才传),
没做分页的页面不受影响,见 docs/admin/05-ui-specification.md §3.2。
+6
View File
@@ -16,11 +16,17 @@
<form id="pdd-recollect-form" method="post" action="/pdd/collect" hidden>
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="ids" value="{{.GoodsID}}">
<input type="hidden" name="status" value="{{$.StatusFilter}}">
<input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}">
</form>
<form method="post" action="/pdd/save">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="id" value="{{.ID}}">
<input type="hidden" name="status" value="{{$.StatusFilter}}">
<input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}">
<div class="modal-body">
<dl class="detail">
+3 -1
View File
@@ -16,6 +16,7 @@
{{/* 把当前筛选一起带过去,操作完还停在原来的筛选上 */}}
<input type="hidden" name="status" value="{{.StatusFilter}}" form="pdd-form">
<input type="hidden" name="q" value="{{.Keyword}}" form="pdd-form">
<input type="hidden" name="page" value="{{.CurrentPage}}" form="pdd-form">
{{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队,
真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}}
@@ -117,6 +118,7 @@
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="status" value="{{.StatusFilter}}">
<input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
<div class="modal-body">
<div class="field">
<label for="create-url">PDD 链接</label>
@@ -139,7 +141,7 @@
{{/* ── 编辑弹窗的壳子 ───────────────────────────
里面的内容双击行时由 /pdd/detail 返回,前端只负责放进来和显示。 */}}
<div class="modal-backdrop" id="detail-modal" data-detail-url="/pdd/detail" hidden>
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
<div class="modal" role="dialog" aria-modal="true" data-detail-slot>
<div class="modal-body">正在加载…</div>
</div>
+1
View File
@@ -37,6 +37,7 @@
<input type="hidden" name="type" value="{{.TypeFilter}}" form="task-form">
<input type="hidden" name="status" value="{{.StatusFilter}}" form="task-form">
<input type="hidden" name="q" value="{{.Keyword}}" form="task-form">
<input type="hidden" name="page" value="{{.CurrentPage}}" form="task-form">
<button type="submit" form="task-form" formaction="/tasks/delete" class="danger"
data-need-checked
+9
View File
@@ -50,6 +50,9 @@
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="user_id" value="{{.UserID}}">
<input type="hidden" name="status" value="{{if .IsActive}}disabled{{else}}active{{end}}">
<input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="list_status" value="{{$.StatusFilter}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}">
<button type="submit" {{if .IsActive}}class="danger"{{end}}>{{if .IsActive}}禁用{{else}}启用{{end}}</button>
</form>
<button type="button" data-modal-open="reset-password-modal"
@@ -96,6 +99,9 @@
minlength="6" maxlength="72" required autocomplete="new-password">
</div>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="list_status" value="{{.StatusFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
@@ -128,6 +134,9 @@
</div>
<input type="hidden" name="user_id" data-reset-user-id-input>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="list_status" value="{{.StatusFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}">
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
+8 -4
View File
@@ -78,12 +78,13 @@
### 3.2 分页
`[必须]` 五个模块**统一每页 20 条**(原来这里写的是"建议一页 50 条",
`[必须]` 六个主列表(蝦皮、PDD、顺运宝、采集采购、客户端、用户)
**统一每页 20 条**(原来这里写的是"建议一页 50 条",
已按工单 #43 改为 20 并升级为 `[必须]`)——20 行在 1366×768 上正好一屏,
不用滚动就能看完;50 条要滚动,操作员容易漏看最下面几行。
蝦皮数据页(§4)是全项目第一个做分页的页面,本节是它定下的通用规则,
**其余四个页面照抄**,不要各写一套:
其余主列表复用公共实现,不要各写一套:
- `[必须]` 分页用 SQL 的 `LIMIT ? OFFSET ?`,**不得**把全量查出来在 Go 里
切片——这正是蝦皮数据页改之前一次吐 3.4MB HTML 的成因(实测 5195 行)。
@@ -105,14 +106,17 @@
浏览器的前进后退和书签都该正常工作。
- `[必须]` 第一页时"首页 / 上一页"禁用,最后一页时"下一页 / 末页"禁用;
禁用要有**视觉 + 语义**区分(不可点的元素不要还是 `<a>`),不能只靠颜色。
- `[必须]` 固定底部状态条左侧显示状态和统计,分页组件稳定靠在右下角;
窄窗口可以换成上下两行,但不得遮住状态文字、分页按钮或表格最后一行。
- `[必须]` 表格勾选只代表当前页。翻页后不保留勾选,也不提供跨页批量操作,
避免操作员看不到将被处理的记录。
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
操作员应该靠筛选定位,不是靠翻页数页码。
公共实现放在 `admin/service/pagination.go`(`PageSize` 常量、
`ParsePage` / `ClampPage` / `TotalPages` / `NewPaginationView`)和
`admin/templates/partials/footer.html`(分页控件,`Pagination` 字段
为空时不渲染,不影响还没做分页的页面),后面四个页面直接复用,
不要各写一份。
为空时不渲染)。六个主列表全部直接复用,不要各写一份。
## 4. 蝦皮数据页