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"),
|
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 {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError,
|
fail(c, http.StatusInternalServerError,
|
||||||
"读取任务列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
"读取任务列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||||
@@ -577,6 +577,16 @@ func (h *Handler) TaskList(c *gin.Context) {
|
|||||||
statusLine = msg + " · " + statusLine
|
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{
|
c.HTML(http.StatusOK, "task/list", page(c, "tasks", "采集采购", gin.H{
|
||||||
"Rows": result.Rows,
|
"Rows": result.Rows,
|
||||||
"Keyword": filter.Keyword,
|
"Keyword": filter.Keyword,
|
||||||
@@ -586,6 +596,8 @@ func (h *Handler) TaskList(c *gin.Context) {
|
|||||||
"TypeOptions": service.TaskTypeOptions(),
|
"TypeOptions": service.TaskTypeOptions(),
|
||||||
"StatusOptions": service.TaskStatusOptions(),
|
"StatusOptions": service.TaskStatusOptions(),
|
||||||
"IsFiltered": result.IsFiltered,
|
"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 != "" {
|
if q := c.PostForm("q"); q != "" {
|
||||||
params.Set("q", q)
|
params.Set("q", q)
|
||||||
}
|
}
|
||||||
|
if p := c.PostForm("page"); p != "" {
|
||||||
|
params.Set("page", p)
|
||||||
|
}
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
params.Set("msg", msg)
|
params.Set("msg", msg)
|
||||||
}
|
}
|
||||||
@@ -672,28 +687,16 @@ func (h *Handler) ClientList(c *gin.Context) {
|
|||||||
keyword := c.Query("name")
|
keyword := c.Query("name")
|
||||||
actor := currentUser(c)
|
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 {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError,
|
fail(c, http.StatusInternalServerError,
|
||||||
"读取客户端列表失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。")
|
"读取客户端列表失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
online := 0
|
status := fmt.Sprintf("共 %d 台客户端 · 在线 %d · 离线 %d · 第 %d/%d 页",
|
||||||
for _, v := range views {
|
result.Total, result.Online, result.Offline, result.Page, result.TotalPages)
|
||||||
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 = "当前没有绑定给你的客户端,请联系管理员。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
purchasers := []model.User{}
|
purchasers := []model.User{}
|
||||||
if actor != nil && actor.IsAdmin() {
|
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{
|
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"),
|
"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) {
|
func redirectClients(c *gin.Context, message, errorMessage string) {
|
||||||
query := url.Values{}
|
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 != "" {
|
if message != "" {
|
||||||
query.Set("msg", message)
|
query.Set("msg", message)
|
||||||
}
|
}
|
||||||
@@ -785,5 +801,5 @@ func (h *Handler) ClientDelete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("clients_deleted count=%d", n)
|
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) {
|
func (h *Handler) PddList(c *gin.Context) {
|
||||||
keyword := c.Query("q")
|
keyword := c.Query("q")
|
||||||
status := service.ParseCollectStatus(c.Query("status"))
|
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 {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError,
|
fail(c, http.StatusInternalServerError,
|
||||||
"读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
"读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||||
@@ -35,6 +36,18 @@ func (h *Handler) PddList(c *gin.Context) {
|
|||||||
statusLine = msg + " · " + statusLine
|
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{
|
c.HTML(http.StatusOK, "pdd/list", page(c, "pdd", "PDD 商品", gin.H{
|
||||||
"Rows": result.Rows,
|
"Rows": result.Rows,
|
||||||
"Keyword": keyword,
|
"Keyword": keyword,
|
||||||
@@ -43,6 +56,9 @@ func (h *Handler) PddList(c *gin.Context) {
|
|||||||
"StatusOptions": service.CollectStatusOptions(),
|
"StatusOptions": service.CollectStatusOptions(),
|
||||||
"HasAnyProducts": result.Total > 0,
|
"HasAnyProducts": result.Total > 0,
|
||||||
"IsFiltered": result.IsFiltered,
|
"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{
|
c.HTML(http.StatusOK, "pdd/edit_modal", gin.H{
|
||||||
"D": detail,
|
"D": detail,
|
||||||
"CSRFToken": csrfToken(c),
|
"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 != "" {
|
if q := c.PostForm("q"); q != "" {
|
||||||
params.Set("q", q)
|
params.Set("q", q)
|
||||||
}
|
}
|
||||||
|
if p := c.PostForm("page"); p != "" {
|
||||||
|
params.Set("page", p)
|
||||||
|
}
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
params.Set("msg", 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) {
|
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,
|
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 {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError, "读取用户列表失败,账号数据没有被改动。刷新后重试。")
|
fail(c, http.StatusInternalServerError, "读取用户列表失败,账号数据没有被改动。刷新后重试。")
|
||||||
return
|
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()),
|
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
||||||
"StatusFilter": result.Status, "Message": message, "Error": errorMessage,
|
"StatusFilter": result.Status, "Message": message, "Error": errorMessage,
|
||||||
"NeedCreate": needCreate, "CreateUsername": createUsername,
|
"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、不回显。
|
// UserCreate 只创建固定 purchaser 角色,密码不写入 URL、不回显。
|
||||||
func (h *Handler) UserCreate(c *gin.Context) {
|
func (h *Handler) UserCreate(c *gin.Context) {
|
||||||
err := service.CreatePurchaser(h.db, currentUser(c), c.PostForm("username"),
|
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) {
|
func redirectUsers(c *gin.Context, message, errorMessage string) {
|
||||||
query := url.Values{}
|
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 != "" {
|
if message != "" {
|
||||||
query.Set("msg", message)
|
query.Set("msg", message)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-2
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,7 +12,23 @@ import (
|
|||||||
"cmautobuy/admin/service"
|
"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) {
|
func TestMainPagesReturnOK(t *testing.T) {
|
||||||
db, err := repository.Open(t.TempDir())
|
db, err := repository.Open(t.TempDir())
|
||||||
@@ -49,7 +66,7 @@ func TestMainPagesReturnOK(t *testing.T) {
|
|||||||
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: token})
|
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) {
|
t.Run(path, func(t *testing.T) {
|
||||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
addAuth(request)
|
addAuth(request)
|
||||||
@@ -59,6 +76,11 @@ func TestMainPagesReturnOK(t *testing.T) {
|
|||||||
t.Fatalf("GET %s = %d,期望 200,响应:%s",
|
t.Fatalf("GET %s = %d,期望 200,响应:%s",
|
||||||
path, response.Code, response.Body.String())
|
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()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListClientsForUser 返回带当前负责人的客户端。
|
// clientListFilter 统一生成客户端分页、总数和在线统计的可见范围。
|
||||||
// visibleUserID 为空表示管理员全量视图,否则只返回当前绑定给该用户的客户端。
|
func clientListFilter(keyword, visibleUserID string) (string, []any) {
|
||||||
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`
|
|
||||||
where := make([]string, 0, 2)
|
where := make([]string, 0, 2)
|
||||||
args := make([]any, 0, 3)
|
args := make([]any, 0, 3)
|
||||||
if visibleUserID != "" {
|
if visibleUserID != "" {
|
||||||
@@ -147,10 +139,24 @@ func ListClientsForUser(db *sql.DB, keyword, visibleUserID string) ([]ClientWith
|
|||||||
like := "%" + kw + "%"
|
like := "%" + kw + "%"
|
||||||
args = append(args, like, like)
|
args = append(args, like, like)
|
||||||
}
|
}
|
||||||
if len(where) > 0 {
|
if len(where) == 0 {
|
||||||
query += ` WHERE ` + strings.Join(where, ` AND `)
|
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...)
|
rows, err := db.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -173,6 +179,40 @@ func ListClientsForUser(db *sql.DB, keyword, visibleUserID string) ([]ClientWith
|
|||||||
return out, rows.Err()
|
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 返回可成为新负责人的启用采购员。
|
// ListActivePurchasers 返回可成为新负责人的启用采购员。
|
||||||
func ListActivePurchasers(db *sql.DB) ([]model.User, error) {
|
func ListActivePurchasers(db *sql.DB) ([]model.User, error) {
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
|
|||||||
+33
-20
@@ -174,33 +174,43 @@ const pddSkuCountExpr = `
|
|||||||
ELSE COALESCE(json_array_length(skus_json, '$.skus'), -1)
|
ELSE COALESCE(json_array_length(skus_json, '$.skus'), -1)
|
||||||
END`
|
END`
|
||||||
|
|
||||||
// ListPddProducts 查列表。keyword 匹配 goods_id 或链接,status 为空表示不筛选。
|
// pddListFilter 统一生成 PDD 列表和总数查询的筛选条件。
|
||||||
//
|
// 两个查询必须共用这一处,避免新增筛选时只改列表、漏改页数。
|
||||||
// 只返回未删除的。软删除的记录还在库里(sku_mappings 指着它),
|
func pddListFilter(keyword string, status model.CollectStatus) (string, []any) {
|
||||||
// 但界面上不该看见。
|
where := ` WHERE deleted_at IS NULL`
|
||||||
func ListPddProducts(q Execer, keyword string, status model.CollectStatus) ([]PddProductRow, error) {
|
args := make([]any, 0, 3)
|
||||||
sqlText := `SELECT ` + pddColumns + `,` + pddSkuCountExpr + `
|
|
||||||
FROM pdd_products
|
|
||||||
WHERE deleted_at IS NULL`
|
|
||||||
var args []any
|
|
||||||
|
|
||||||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||||
// ESCAPE 不能省:操作员搜 "_" 时,LIKE 会把它当"任意一个字符",
|
|
||||||
// 结果是全部行都命中,看着像搜索坏了。
|
|
||||||
pattern := "%" + escapeLike(keyword) + "%"
|
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)
|
args = append(args, pattern, pattern)
|
||||||
}
|
}
|
||||||
if status != "" {
|
if status != "" {
|
||||||
sqlText += ` AND collect_status = ?`
|
where += ` AND collect_status = ?`
|
||||||
args = append(args, string(status))
|
args = append(args, string(status))
|
||||||
}
|
}
|
||||||
// 刚动过的排前面,操作员点完"创建采集任务"一眼能看到结果
|
return where, args
|
||||||
sqlText += ` ORDER BY updated_at DESC, id DESC`
|
}
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("查询 PDD 商品列表失败: %w", err)
|
return nil, 0, fmt.Errorf("查询 PDD 商品列表失败: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -209,12 +219,15 @@ func ListPddProducts(q Execer, keyword string, status model.CollectStatus) ([]Pd
|
|||||||
var r PddProductRow
|
var r PddProductRow
|
||||||
p, err := scanPddProduct(rows, &r.SkuCount)
|
p, err := scanPddProduct(rows, &r.SkuCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("读取 PDD 商品列表失败: %w", err)
|
return nil, 0, fmt.Errorf("读取 PDD 商品列表失败: %w", err)
|
||||||
}
|
}
|
||||||
r.PddProduct = *p
|
r.PddProduct = *p
|
||||||
list = append(list, r)
|
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 '\' 使用。
|
// escapeLike 把 LIKE 的三个特殊字符转义掉,配合 ESCAPE '\' 使用。
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ func taskFilterClause(filter TaskFilter) (string, []any) {
|
|||||||
// 「目标」列更好读;join 条件里的 `deleted_at IS NULL` 让"join 不到"和
|
// 「目标」列更好读;join 条件里的 `deleted_at IS NULL` 让"join 不到"和
|
||||||
// "商品已软删除"这两种情况都自然落到 PddTitle 为空,service 层退回显示
|
// "商品已软删除"这两种情况都自然落到 PddTitle 为空,service 层退回显示
|
||||||
// PddGoodsID 即可,不用在 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)
|
where, args := taskFilterClause(filter)
|
||||||
sqlText := `
|
sqlText := `
|
||||||
SELECT t.task_id, t.task_type, t.status, t.assigned_client,
|
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
|
FROM tasks t
|
||||||
LEFT JOIN pdd_products p
|
LEFT JOIN pdd_products p
|
||||||
ON p.goods_id = t.pdd_goods_id AND p.deleted_at IS NULL` +
|
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...)
|
rows, err := q.Query(sqlText, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -12,6 +13,42 @@ import (
|
|||||||
"cmautobuy/admin/repository"
|
"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 建一个临时库,测试结束自动删。
|
// newTestDB 建一个临时库,测试结束自动删。
|
||||||
// 用真实的 migrations,这样表结构变了测试会跟着失败。
|
// 用真实的 migrations,这样表结构变了测试会跟着失败。
|
||||||
func newTestDB(t *testing.T) *sql.DB {
|
func newTestDB(t *testing.T) *sql.DB {
|
||||||
|
|||||||
+26
-10
@@ -238,26 +238,42 @@ type PddProductView struct {
|
|||||||
|
|
||||||
// PddListResult 是列表页要的全部数据。
|
// PddListResult 是列表页要的全部数据。
|
||||||
type PddListResult struct {
|
type PddListResult struct {
|
||||||
Rows []PddProductView
|
Rows []PddProductView
|
||||||
Total int // 全部未删除记录数,不受筛选影响
|
Total int // 全部未删除记录数,不受筛选影响
|
||||||
Counts map[model.CollectStatus]int
|
FilteredTotal int // 当前筛选下的总数,用于页数和筛选状态
|
||||||
IsFiltered bool
|
Page int
|
||||||
|
TotalPages int
|
||||||
|
Counts map[model.CollectStatus]int
|
||||||
|
IsFiltered bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPddProducts 查列表并把每一行翻成界面文字。
|
// ListPddProducts 查列表并把每一行翻成界面文字。
|
||||||
func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus) (*PddListResult, error) {
|
func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus, requestedPage int) (*PddListResult, error) {
|
||||||
rows, err := repository.ListPddProducts(db, keyword, status)
|
page := requestedPage
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
rows, filteredTotal, err := repository.ListPddProducts(db, keyword, status, PageSize, (page-1)*PageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
counts, err := repository.CountPddProductsByStatus(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &PddListResult{
|
result := &PddListResult{
|
||||||
Rows: make([]PddProductView, 0, len(rows)),
|
Rows: make([]PddProductView, 0, len(rows)), Counts: counts,
|
||||||
Counts: counts,
|
FilteredTotal: filteredTotal, Page: page, TotalPages: totalPages,
|
||||||
IsFiltered: strings.TrimSpace(keyword) != "" || status != "",
|
IsFiltered: strings.TrimSpace(keyword) != "" || status != "",
|
||||||
}
|
}
|
||||||
for _, n := range counts {
|
for _, n := range counts {
|
||||||
@@ -312,9 +328,9 @@ func (r *PddListResult) StatusLine() string {
|
|||||||
}
|
}
|
||||||
line := strings.Join(parts, " · ")
|
line := strings.Join(parts, " · ")
|
||||||
if r.IsFiltered {
|
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 (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -12,6 +13,33 @@ import (
|
|||||||
"cmautobuy/admin/repository"
|
"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) {
|
func TestParsePddGoodsID_认得的写法(t *testing.T) {
|
||||||
@@ -259,7 +287,7 @@ func TestListPddProducts_规格数(t *testing.T) {
|
|||||||
repository.SetCollectResult(db, "100000000002", "有规格的", "", sampleSkusJSON)
|
repository.SetCollectResult(db, "100000000002", "有规格的", "", sampleSkusJSON)
|
||||||
repository.SetCollectResult(db, "100000000003", "没规格的", "", `{"skus": []}`)
|
repository.SetCollectResult(db, "100000000003", "没规格的", "", `{"skus": []}`)
|
||||||
|
|
||||||
result, err := ListPddProducts(db, "", "")
|
result, err := ListPddProducts(db, "", "", 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查列表失败: %v", err)
|
t.Fatalf("查列表失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -287,7 +315,7 @@ func TestListPddProducts_坏掉的采集结果不影响列表打开(t *testing.T
|
|||||||
t.Fatalf("造坏数据失败: %v", err)
|
t.Fatalf("造坏数据失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := ListPddProducts(db, "", "")
|
result, err := ListPddProducts(db, "", "", 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("列表页应该照样打得开: %v", err)
|
t.Fatalf("列表页应该照样打得开: %v", err)
|
||||||
}
|
}
|
||||||
@@ -302,7 +330,7 @@ func TestListPddProducts_按采集状态筛选(t *testing.T) {
|
|||||||
createProduct(t, db, "100000000002")
|
createProduct(t, db, "100000000002")
|
||||||
repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON)
|
repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON)
|
||||||
|
|
||||||
collected, err := ListPddProducts(db, "", model.CollectCollected)
|
collected, err := ListPddProducts(db, "", model.CollectCollected, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("筛选失败: %v", err)
|
t.Fatalf("筛选失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -324,12 +352,12 @@ func TestListPddProducts_按商品ID和链接搜索(t *testing.T) {
|
|||||||
createProduct(t, db, "737116531267")
|
createProduct(t, db, "737116531267")
|
||||||
createProduct(t, db, "999888777666")
|
createProduct(t, db, "999888777666")
|
||||||
|
|
||||||
byID, _ := ListPddProducts(db, "7371165", "")
|
byID, _ := ListPddProducts(db, "7371165", "", 1)
|
||||||
if len(byID.Rows) != 1 || byID.Rows[0].GoodsID != "737116531267" {
|
if len(byID.Rows) != 1 || byID.Rows[0].GoodsID != "737116531267" {
|
||||||
t.Errorf("按 ID 片段搜索失败,命中 %d 条", len(byID.Rows))
|
t.Errorf("按 ID 片段搜索失败,命中 %d 条", len(byID.Rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
byURL, _ := ListPddProducts(db, "yangkeduo", "")
|
byURL, _ := ListPddProducts(db, "yangkeduo", "", 1)
|
||||||
if len(byURL.Rows) != 2 {
|
if len(byURL.Rows) != 2 {
|
||||||
t.Errorf("按链接搜索应命中 2 条,实际 %d 条", len(byURL.Rows))
|
t.Errorf("按链接搜索应命中 2 条,实际 %d 条", len(byURL.Rows))
|
||||||
}
|
}
|
||||||
@@ -344,7 +372,7 @@ func TestListPddProducts_搜索词里的通配符不当通配符用(t *testing.T
|
|||||||
createProduct(t, db, "737116531267")
|
createProduct(t, db, "737116531267")
|
||||||
|
|
||||||
for _, kw := range []string{"7_7", "%"} {
|
for _, kw := range []string{"7_7", "%"} {
|
||||||
result, err := ListPddProducts(db, kw, "")
|
result, err := ListPddProducts(db, kw, "", 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("搜 %q 出错: %v", kw, err)
|
t.Fatalf("搜 %q 出错: %v", kw, err)
|
||||||
}
|
}
|
||||||
@@ -359,7 +387,7 @@ func TestListPddProducts_删除的查不到(t *testing.T) {
|
|||||||
createProduct(t, db, "737116531267")
|
createProduct(t, db, "737116531267")
|
||||||
DeletePddProducts(db, []string{"737116531267"})
|
DeletePddProducts(db, []string{"737116531267"})
|
||||||
|
|
||||||
result, _ := ListPddProducts(db, "", "")
|
result, _ := ListPddProducts(db, "", "", 1)
|
||||||
if len(result.Rows) != 0 {
|
if len(result.Rows) != 0 {
|
||||||
t.Errorf("软删除的不该出现在列表里,实际 %d 条", len(result.Rows))
|
t.Errorf("软删除的不该出现在列表里,实际 %d 条", len(result.Rows))
|
||||||
}
|
}
|
||||||
@@ -374,7 +402,7 @@ func TestStatusLine_四个状态都列出来(t *testing.T) {
|
|||||||
createProduct(t, db, "100000000002")
|
createProduct(t, db, "100000000002")
|
||||||
repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON)
|
repository.SetCollectResult(db, "100000000002", "已采的", "", sampleSkusJSON)
|
||||||
|
|
||||||
result, _ := ListPddProducts(db, "", "")
|
result, _ := ListPddProducts(db, "", "", 1)
|
||||||
line := result.StatusLine()
|
line := result.StatusLine()
|
||||||
for _, want := range []string{"共 2 条", "已采集 1", "未采集 1", "采集中 0", "采集失败 0"} {
|
for _, want := range []string{"共 2 条", "已采集 1", "未采集 1", "采集中 0", "采集失败 0"} {
|
||||||
if !strings.Contains(line, want) {
|
if !strings.Contains(line, want) {
|
||||||
@@ -444,7 +472,7 @@ func TestPdd页面_显示店铺与按颜色采样标记(t *testing.T) {
|
|||||||
repository.SetCollectResult(
|
repository.SetCollectResult(
|
||||||
db, "737116531267", "测试商品", "测试旗舰店", resultJSON)
|
db, "737116531267", "测试商品", "测试旗舰店", resultJSON)
|
||||||
|
|
||||||
list, err := ListPddProducts(db, "", "")
|
list, err := ListPddProducts(db, "", "", 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("读取列表失败: %v", err)
|
t.Fatalf("读取列表失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,17 +131,25 @@ func ListClientViews(db *sql.DB, keyword string, threshold time.Duration) ([]Cli
|
|||||||
|
|
||||||
// ListClientViewsForUser 按网页登录身份限制可见范围:管理员全量,采购员只看自己。
|
// ListClientViewsForUser 按网页登录身份限制可见范围:管理员全量,采购员只看自己。
|
||||||
func ListClientViewsForUser(db *sql.DB, actor *model.User, keyword string, threshold time.Duration) ([]ClientView, error) {
|
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 {
|
if actor == nil {
|
||||||
return nil, ErrUnauthenticated
|
return "", ErrUnauthenticated
|
||||||
}
|
}
|
||||||
visibleUserID := ""
|
visibleUserID := ""
|
||||||
if !actor.IsAdmin() {
|
if !actor.IsAdmin() {
|
||||||
if actor.Role != model.RolePurchaser {
|
if actor.Role != model.RolePurchaser {
|
||||||
return nil, ErrAdminRequired
|
return "", ErrAdminRequired
|
||||||
}
|
}
|
||||||
visibleUserID = actor.UserID
|
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) {
|
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
|
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 返回当前用户在采购任务页面可选择的客户端。
|
// ListAssignableClients 返回当前用户在采购任务页面可选择的客户端。
|
||||||
// 当前创建页面尚未实现,本函数固定未来入口也必须沿用相同权限边界。
|
// 当前创建页面尚未实现,本函数固定未来入口也必须沿用相同权限边界。
|
||||||
func ListAssignableClients(db *sql.DB, actor *model.User, threshold time.Duration) ([]ClientView, error) {
|
func ListAssignableClients(db *sql.DB, actor *model.User, threshold time.Duration) ([]ClientView, error) {
|
||||||
|
|||||||
+16
-11
@@ -233,9 +233,11 @@ type TaskView struct {
|
|||||||
|
|
||||||
// TaskListResult 是列表页要的全部数据。
|
// TaskListResult 是列表页要的全部数据。
|
||||||
type TaskListResult struct {
|
type TaskListResult struct {
|
||||||
Rows []TaskView
|
Rows []TaskView
|
||||||
Counts map[model.TaskStatus]int
|
Counts map[model.TaskStatus]int
|
||||||
Total int // 当前筛选下的总数(= Counts 求和),不是全库总数
|
Total int // 当前筛选下的总数(= Counts 求和),不是全库总数
|
||||||
|
Page int
|
||||||
|
TotalPages int
|
||||||
|
|
||||||
// IsFiltered 为 false 时如果 Rows 也是空的,说明库里从来没建过任务
|
// IsFiltered 为 false 时如果 Rows 也是空的,说明库里从来没建过任务
|
||||||
// (不筛选就是查全表,全表空自然等价于"从没有过");为 true 时
|
// (不筛选就是查全表,全表空自然等价于"从没有过");为 true 时
|
||||||
@@ -247,12 +249,18 @@ type TaskListResult struct {
|
|||||||
//
|
//
|
||||||
// `[必须]` 底部统计要跟随当前筛选,所以 Counts 和 Rows 用的是**同一个**
|
// `[必须]` 底部统计要跟随当前筛选,所以 Counts 和 Rows 用的是**同一个**
|
||||||
// filter,见 #19。
|
// filter,见 #19。
|
||||||
func ListTasksView(db *sql.DB, filter repository.TaskFilter) (*TaskListResult, error) {
|
func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int) (*TaskListResult, error) {
|
||||||
rows, err := repository.ListTasks(db, filter)
|
counts, err := repository.CountTasksByStatus(db, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -260,13 +268,10 @@ func ListTasksView(db *sql.DB, filter repository.TaskFilter) (*TaskListResult, e
|
|||||||
result := &TaskListResult{
|
result := &TaskListResult{
|
||||||
Rows: make([]TaskView, 0, len(rows)),
|
Rows: make([]TaskView, 0, len(rows)),
|
||||||
Counts: counts,
|
Counts: counts,
|
||||||
|
Total: total, Page: page, TotalPages: totalPages,
|
||||||
IsFiltered: filter.Type != "" || filter.Status != "" ||
|
IsFiltered: filter.Type != "" || filter.Status != "" ||
|
||||||
strings.TrimSpace(filter.Keyword) != "",
|
strings.TrimSpace(filter.Keyword) != "",
|
||||||
}
|
}
|
||||||
for _, n := range counts {
|
|
||||||
result.Total += n
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
result.Rows = append(result.Rows, TaskView{
|
result.Rows = append(result.Rows, TaskView{
|
||||||
TaskID: r.TaskID,
|
TaskID: r.TaskID,
|
||||||
@@ -292,7 +297,7 @@ func (r *TaskListResult) StatusLine() string {
|
|||||||
for _, s := range taskStatusOrder {
|
for _, s := range taskStatusOrder {
|
||||||
parts = append(parts, fmt.Sprintf("%s %d", taskStatusText(s), r.Counts[s]))
|
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 批量删除任务,返回实际删掉的条数。
|
// DeleteTasks 批量删除任务,返回实际删掉的条数。
|
||||||
|
|||||||
+42
-16
@@ -2,12 +2,38 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
"cmautobuy/admin/repository"
|
"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 插一条任务,字段按需覆盖默认值,供本文件的用例使用。
|
// insertTestTask 插一条任务,字段按需覆盖默认值,供本文件的用例使用。
|
||||||
// 跟 client_test.go 里的 insertTask 不一样:那个专为"领取"场景写死了固定值,
|
// 跟 client_test.go 里的 insertTask 不一样:那个专为"领取"场景写死了固定值,
|
||||||
// 这里需要覆盖类型、状态、options 等更多维度。
|
// 这里需要覆盖类型、状态、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: "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"})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
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: "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"})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
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-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
|
||||||
insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "222"})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
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"})
|
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" {
|
if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-ABC" {
|
||||||
t.Fatalf("按任务编号搜索失败: err=%v rows=%+v", err, r)
|
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" {
|
if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "PUR-1" {
|
||||||
t.Fatalf("按订单号搜索失败: err=%v rows=%+v", err, r)
|
t.Fatalf("按订单号搜索失败: err=%v rows=%+v", err, r)
|
||||||
}
|
}
|
||||||
// 按 PDD 商品 ID 命中
|
// 按 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" {
|
if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-ABC" {
|
||||||
t.Fatalf("按 PDD 商品 ID 搜索失败: err=%v rows=%+v", err, r)
|
t.Fatalf("按 PDD 商品 ID 搜索失败: err=%v rows=%+v", err, r)
|
||||||
}
|
}
|
||||||
@@ -165,7 +191,7 @@ func TestListTasksView_目标列_采集任务显示PDD商品ID(t *testing.T) {
|
|||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
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"})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
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"})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -224,7 +250,7 @@ func TestListTasksView_目标列_采购任务显示订单号规格数量价格
|
|||||||
quantity: 2, maxPriceCent: 4200,
|
quantity: 2, maxPriceCent: 4200,
|
||||||
})
|
})
|
||||||
|
|
||||||
r, err := ListTasksView(db, repository.TaskFilter{})
|
r, err := ListTasksView(db, repository.TaskFilter{}, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -240,7 +266,7 @@ func TestListTasksView_无主任务客户端列显示占位符(t *testing.T) {
|
|||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
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-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"})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -266,7 +292,7 @@ func TestListTasksView_统计跟随当前筛选(t *testing.T) {
|
|||||||
t.Fatalf("不筛选时总数应该是 4,实际 %d", all.Total)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -285,7 +311,7 @@ func TestListTasksView_统计跟随当前筛选(t *testing.T) {
|
|||||||
|
|
||||||
func TestListTasksView_从未建过任务时不算筛选无结果(t *testing.T) {
|
func TestListTasksView_从未建过任务时不算筛选无结果(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
r, err := ListTasksView(db, repository.TaskFilter{})
|
r, err := ListTasksView(db, repository.TaskFilter{}, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -301,7 +327,7 @@ func TestListTasksView_筛选无结果时IsFiltered为true(t *testing.T) {
|
|||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -416,7 +442,7 @@ func TestDeleteTasks_批量删除(t *testing.T) {
|
|||||||
t.Fatalf("应该删除 1 条,实际 %d", n)
|
t.Fatalf("应该删除 1 条,实际 %d", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := ListTasksView(db, repository.TaskFilter{})
|
r, err := ListTasksView(db, repository.TaskFilter{}, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/* Admin 样式表。手写 CSS,不引入框架、不引入构建流程。
|
/* Admin 样式表。手写 CSS,不引入框架、不引入构建流程。
|
||||||
见 admin/AGENTS.md 前端约束。
|
见 admin/AGENTS.md 前端约束。
|
||||||
|
|
||||||
五个模块共用同一套三段式布局的样式:
|
六个主列表共用同一套三段式布局的样式:
|
||||||
.toolbar(顶部工具条)/ .table-wrap(中间表格)/ .statusbar(底部状态条)。
|
.toolbar(顶部工具条)/ .table-wrap(中间表格)/ .statusbar(底部状态条)。
|
||||||
改这里会同时影响五个页面,这是有意的——不要为某个页面复制一份。 */
|
改这里会同时影响六个主列表,这是有意的——不要为某个页面复制一份。 */
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
@@ -185,6 +185,11 @@ tr.empty small { color: #aaa; }
|
|||||||
color: #c8cdd4;
|
color: #c8cdd4;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.statusbar > span:first-child {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 链接和标题这类长内容截断显示,鼠标悬停看全文(title 属性)。
|
/* 链接和标题这类长内容截断显示,鼠标悬停看全文(title 属性)。
|
||||||
@@ -381,13 +386,14 @@ select {
|
|||||||
|
|
||||||
/* ── 分页控件 ─────────────────────────── */
|
/* ── 分页控件 ─────────────────────────── */
|
||||||
/* 见 docs/admin/05-ui-specification.md §3.2。全项目第一个分页页面
|
/* 见 docs/admin/05-ui-specification.md §3.2。全项目第一个分页页面
|
||||||
(蝦皮数据页,工单 #43)定的样式,其余四个页面照抄。
|
(蝦皮数据页,工单 #43)定的样式,其余主列表复用。
|
||||||
控件本身是 <a href>,不是按钮/JS,样式借用 button 的观感即可。 */
|
控件本身是 <a href>,不是按钮/JS,样式借用 button 的观感即可。 */
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-left: 12px;
|
margin-left: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.pagination a,
|
.pagination a,
|
||||||
.pagination span.disabled {
|
.pagination span.disabled {
|
||||||
@@ -397,6 +403,15 @@ select {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 13px;
|
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 { color: #1f6feb; background: #fff; }
|
||||||
.pagination a:hover { background: #f0f2f4; }
|
.pagination a:hover { background: #f0f2f4; }
|
||||||
/* 首页/末页时对应按钮禁用:用 <span> 而不是不可点的 <a>(语义上不再是链接),
|
/* 首页/末页时对应按钮禁用:用 <span> 而不是不可点的 <a>(语义上不再是链接),
|
||||||
|
|||||||
@@ -211,7 +211,8 @@
|
|||||||
slot.innerHTML = "<div class='modal-body'>正在加载…</div>";
|
slot.innerHTML = "<div class='modal-body'>正在加载…</div>";
|
||||||
openModal(modal);
|
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" }
|
headers: { "X-Requested-With": "fetch" }
|
||||||
})
|
})
|
||||||
.then(function (resp) {
|
.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>
|
{{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="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>
|
<button type="submit" class="danger" data-need-checked>删除</button>
|
||||||
</form>{{end}}
|
</form>{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +57,8 @@
|
|||||||
data-confirm-submit="确定解绑客户端“{{.Name}}”吗?采购员“{{.AssignedUsername}}”将立即看不到它;归属历史和既有任务会保留。">
|
data-confirm-submit="确定解绑客户端“{{.Name}}”吗?采购员“{{.AssignedUsername}}”将立即看不到它;归属历史和既有任务会保留。">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="client_id" value="{{.ClientID}}">
|
<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>
|
<button type="submit" class="danger">解绑</button>
|
||||||
</form>{{end}}
|
</form>{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +67,9 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<tr class="empty">
|
<tr class="empty">
|
||||||
<td colspan="{{if $.CurrentUser.IsAdmin}}8{{else}}6{{end}}">
|
<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>
|
<small>客户端第一次调用领取接口时会自动登记到这里,不需要手工添加。</small>
|
||||||
{{else}}当前没有绑定给你的客户端。<br><small>如需使用客户端,请联系管理员绑定。</small>{{end}}
|
{{else}}当前没有绑定给你的客户端。<br><small>如需使用客户端,请联系管理员绑定。</small>{{end}}
|
||||||
</td>
|
</td>
|
||||||
@@ -99,6 +105,8 @@
|
|||||||
<p class="hint" data-assignment-note>绑定只改变负责人和网页可见范围,不影响 Client 接口或既有任务。</p>
|
<p class="hint" data-assignment-note>绑定只改变负责人和网页可见范围,不影响 Client 接口或既有任务。</p>
|
||||||
<input type="hidden" name="client_id" data-assign-client-id-input>
|
<input type="hidden" name="client_id" data-assign-client-id-input>
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<input type="hidden" name="name" value="{{.Keyword}}">
|
||||||
|
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<button type="button" data-modal-close>取消</button>
|
<button type="button" data-modal-close>取消</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "footer"}}
|
{{define "footer"}}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{{/* 底部状态条:三段式布局的第三段,五个页面都有。
|
{{/* 底部状态条:三段式布局的第三段,六个主列表都有。
|
||||||
Pagination 是可选的(只有做了分页的页面才传),
|
Pagination 是可选的(只有做了分页的页面才传),
|
||||||
没做分页的页面不受影响,见 docs/admin/05-ui-specification.md §3.2。
|
没做分页的页面不受影响,见 docs/admin/05-ui-specification.md §3.2。
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,17 @@
|
|||||||
<form id="pdd-recollect-form" method="post" action="/pdd/collect" hidden>
|
<form id="pdd-recollect-form" method="post" action="/pdd/collect" hidden>
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="ids" value="{{.GoodsID}}">
|
<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>
|
||||||
|
|
||||||
<form method="post" action="/pdd/save">
|
<form method="post" action="/pdd/save">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<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">
|
<div class="modal-body">
|
||||||
<dl class="detail">
|
<dl class="detail">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
{{/* 把当前筛选一起带过去,操作完还停在原来的筛选上 */}}
|
{{/* 把当前筛选一起带过去,操作完还停在原来的筛选上 */}}
|
||||||
<input type="hidden" name="status" value="{{.StatusFilter}}" form="pdd-form">
|
<input type="hidden" name="status" value="{{.StatusFilter}}" form="pdd-form">
|
||||||
<input type="hidden" name="q" value="{{.Keyword}}" 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="csrf_token" value="{{.CSRFToken}}">
|
||||||
<input type="hidden" name="status" value="{{.StatusFilter}}">
|
<input type="hidden" name="status" value="{{.StatusFilter}}">
|
||||||
<input type="hidden" name="q" value="{{.Keyword}}">
|
<input type="hidden" name="q" value="{{.Keyword}}">
|
||||||
|
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="create-url">PDD 链接</label>
|
<label for="create-url">PDD 链接</label>
|
||||||
@@ -139,7 +141,7 @@
|
|||||||
|
|
||||||
{{/* ── 编辑弹窗的壳子 ───────────────────────────
|
{{/* ── 编辑弹窗的壳子 ───────────────────────────
|
||||||
里面的内容双击行时由 /pdd/detail 返回,前端只负责放进来和显示。 */}}
|
里面的内容双击行时由 /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" role="dialog" aria-modal="true" data-detail-slot>
|
||||||
<div class="modal-body">正在加载…</div>
|
<div class="modal-body">正在加载…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
<input type="hidden" name="type" value="{{.TypeFilter}}" form="task-form">
|
<input type="hidden" name="type" value="{{.TypeFilter}}" form="task-form">
|
||||||
<input type="hidden" name="status" value="{{.StatusFilter}}" 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="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"
|
<button type="submit" form="task-form" formaction="/tasks/delete" class="danger"
|
||||||
data-need-checked
|
data-need-checked
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<input type="hidden" name="user_id" value="{{.UserID}}">
|
<input type="hidden" name="user_id" value="{{.UserID}}">
|
||||||
<input type="hidden" name="status" value="{{if .IsActive}}disabled{{else}}active{{end}}">
|
<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>
|
<button type="submit" {{if .IsActive}}class="danger"{{end}}>{{if .IsActive}}禁用{{else}}启用{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" data-modal-open="reset-password-modal"
|
<button type="button" data-modal-open="reset-password-modal"
|
||||||
@@ -96,6 +99,9 @@
|
|||||||
minlength="6" maxlength="72" required autocomplete="new-password">
|
minlength="6" maxlength="72" required autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<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>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<button type="button" data-modal-close>取消</button>
|
<button type="button" data-modal-close>取消</button>
|
||||||
@@ -128,6 +134,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="user_id" data-reset-user-id-input>
|
<input type="hidden" name="user_id" data-reset-user-id-input>
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<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>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<button type="button" data-modal-close>取消</button>
|
<button type="button" data-modal-close>取消</button>
|
||||||
|
|||||||
@@ -78,12 +78,13 @@
|
|||||||
|
|
||||||
### 3.2 分页
|
### 3.2 分页
|
||||||
|
|
||||||
`[必须]` 五个模块**统一每页 20 条**(原来这里写的是"建议一页 50 条",
|
`[必须]` 六个主列表(蝦皮、PDD、顺运宝、采集采购、客户端、用户)
|
||||||
|
**统一每页 20 条**(原来这里写的是"建议一页 50 条",
|
||||||
已按工单 #43 改为 20 并升级为 `[必须]`)——20 行在 1366×768 上正好一屏,
|
已按工单 #43 改为 20 并升级为 `[必须]`)——20 行在 1366×768 上正好一屏,
|
||||||
不用滚动就能看完;50 条要滚动,操作员容易漏看最下面几行。
|
不用滚动就能看完;50 条要滚动,操作员容易漏看最下面几行。
|
||||||
|
|
||||||
蝦皮数据页(§4)是全项目第一个做分页的页面,本节是它定下的通用规则,
|
蝦皮数据页(§4)是全项目第一个做分页的页面,本节是它定下的通用规则,
|
||||||
**其余四个页面照抄**,不要各写一套:
|
其余主列表复用公共实现,不要各写一套:
|
||||||
|
|
||||||
- `[必须]` 分页用 SQL 的 `LIMIT ? OFFSET ?`,**不得**把全量查出来在 Go 里
|
- `[必须]` 分页用 SQL 的 `LIMIT ? OFFSET ?`,**不得**把全量查出来在 Go 里
|
||||||
切片——这正是蝦皮数据页改之前一次吐 3.4MB HTML 的成因(实测 5195 行)。
|
切片——这正是蝦皮数据页改之前一次吐 3.4MB HTML 的成因(实测 5195 行)。
|
||||||
@@ -105,14 +106,17 @@
|
|||||||
浏览器的前进后退和书签都该正常工作。
|
浏览器的前进后退和书签都该正常工作。
|
||||||
- `[必须]` 第一页时"首页 / 上一页"禁用,最后一页时"下一页 / 末页"禁用;
|
- `[必须]` 第一页时"首页 / 上一页"禁用,最后一页时"下一页 / 末页"禁用;
|
||||||
禁用要有**视觉 + 语义**区分(不可点的元素不要还是 `<a>`),不能只靠颜色。
|
禁用要有**视觉 + 语义**区分(不可点的元素不要还是 `<a>`),不能只靠颜色。
|
||||||
|
- `[必须]` 固定底部状态条左侧显示状态和统计,分页组件稳定靠在右下角;
|
||||||
|
窄窗口可以换成上下两行,但不得遮住状态文字、分页按钮或表格最后一行。
|
||||||
|
- `[必须]` 表格勾选只代表当前页。翻页后不保留勾选,也不提供跨页批量操作,
|
||||||
|
避免操作员看不到将被处理的记录。
|
||||||
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
|
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
|
||||||
操作员应该靠筛选定位,不是靠翻页数页码。
|
操作员应该靠筛选定位,不是靠翻页数页码。
|
||||||
|
|
||||||
公共实现放在 `admin/service/pagination.go`(`PageSize` 常量、
|
公共实现放在 `admin/service/pagination.go`(`PageSize` 常量、
|
||||||
`ParsePage` / `ClampPage` / `TotalPages` / `NewPaginationView`)和
|
`ParsePage` / `ClampPage` / `TotalPages` / `NewPaginationView`)和
|
||||||
`admin/templates/partials/footer.html`(分页控件,`Pagination` 字段
|
`admin/templates/partials/footer.html`(分页控件,`Pagination` 字段
|
||||||
为空时不渲染,不影响还没做分页的页面),后面四个页面直接复用,
|
为空时不渲染)。六个主列表全部直接复用,不要各写一份。
|
||||||
不要各写一份。
|
|
||||||
|
|
||||||
## 4. 蝦皮数据页
|
## 4. 蝦皮数据页
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user