From 155dd3b4a2ce280313a4ce48540c9af1e497f158 Mon Sep 17 00:00:00 2001 From: chengma Date: Sun, 9 Aug 2026 21:51:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=20Admin=20=E4=B8=BB?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=88=86=E9=A1=B5=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/others.go | 54 ++++++++++++------- admin/handler/web/pagination_test.go | 77 ++++++++++++++++++++++++++++ admin/handler/web/pdd.go | 23 ++++++++- admin/handler/web/user.go | 25 ++++++++- admin/main_test.go | 26 +++++++++- admin/repository/client.go | 66 +++++++++++++++++++----- admin/repository/pdd.go | 53 +++++++++++-------- admin/repository/task.go | 5 +- admin/service/client_test.go | 37 +++++++++++++ admin/service/pdd.go | 36 +++++++++---- admin/service/pdd_page_test.go | 46 +++++++++++++---- admin/service/service.go | 66 ++++++++++++++++++++++-- admin/service/task.go | 27 ++++++---- admin/service/task_test.go | 58 +++++++++++++++------ admin/static/css/app.css | 23 +++++++-- admin/static/js/app.js | 3 +- admin/templates/client/list.html | 10 +++- admin/templates/partials/footer.html | 2 +- admin/templates/pdd/edit_modal.html | 6 +++ admin/templates/pdd/list.html | 4 +- admin/templates/task/list.html | 1 + admin/templates/user/list.html | 9 ++++ docs/admin/05-ui-specification.md | 12 +++-- 23 files changed, 549 insertions(+), 120 deletions(-) create mode 100644 admin/handler/web/pagination_test.go diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 89ebe4f..0604b24 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -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), "") } diff --git a/admin/handler/web/pagination_test.go b/admin/handler/web/pagination_test.go new file mode 100644 index 0000000..4c8f8b2 --- /dev/null +++ b/admin/handler/web/pagination_test.go @@ -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": "完成", + }) + }) +} diff --git a/admin/handler/web/pdd.go b/admin/handler/web/pdd.go index 25a2838..26fa334 100644 --- a/admin/handler/web/pdd.go +++ b/admin/handler/web/pdd.go @@ -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) } diff --git a/admin/handler/web/user.go b/admin/handler/web/user.go index 2a3478e..1177658 100644 --- a/admin/handler/web/user.go +++ b/admin/handler/web/user.go @@ -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) } diff --git a/admin/main_test.go b/admin/main_test.go index f1c34b8..c5f8f90 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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, `