feat: 统一 Admin 主列表分页 (#64)
This commit is contained in:
+35
-19
@@ -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), "")
|
||||
}
|
||||
|
||||
@@ -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": "完成",
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 '\' 使用。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
// ---------- 弹窗 ----------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>(语义上不再是链接),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{{define "footer"}}
|
||||
</main>
|
||||
|
||||
{{/* 底部状态条:三段式布局的第三段,五个页面都有。
|
||||
{{/* 底部状态条:三段式布局的第三段,六个主列表都有。
|
||||
Pagination 是可选的(只有做了分页的页面才传),
|
||||
没做分页的页面不受影响,见 docs/admin/05-ui-specification.md §3.2。
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. 蝦皮数据页
|
||||
|
||||
|
||||
Reference in New Issue
Block a user