feat: 蝦皮数据页分页与状态筛选 (#43)

导入真实样本后 /shopee 一次吐 3.4MB / 5195 行。但只加分页会把问题从
「5195 行糊在一起」变成「260 页里藏着 6 个」——那 6 个待补规格的商品
仍然找不到。所以分页和状态筛选一起做。

HTML 3.4MB → 16.7KB。

状态条显示全量而不是本页:「共 5195 个商品 · 第 1/260 页」。
显示「共 20 个商品」会让操作员以为总共就 20 个。筛选后显示筛选结果
总数:「待补规格:6 个商品」。

列表查询和 COUNT 共用同一套筛选条件拼装。分开写两份 WHERE,迟早
有天忘了给 COUNT 也加条件,页码算错而且没人发现(#19 踩过一次)。

page 越界兜到最后一页而不是显示空表格——空表格会让操作员以为数据没了。
总数为 0 时显示「第 1/1 页」,不出现「第 1/0 页」。

「待补规格」用 EXISTS 不用 JOIN+DISTINCT:一个商品有多个失败 SKU 时
JOIN 会出重复行,DISTINCT 又让 LIMIT/OFFSET 的行为难推理。

分页控件是 <a href> 纯 GET,浏览器前进后退和书签都正常。首末页用
<span class="disabled"> 禁用,语义上不再是链接,不只靠颜色区分。

这是全项目第一个分页页面,通用逻辑单独放 service/pagination.go 供
后面四页复用,规则写进 05 §3.2 而不是蝦皮页那一节(#34 踩过这个错)。
05 §3 的每页条数从「建议 50」改为「统一 20」并写明理由。

实现踩到 html/template 的 URL 上下文转义:夹在字面量 & 中间的动态内容
会被整体当成一个参数值转义,?/= 变成 %3F/%3D 让链接失效。改为在 Go 里
把整段 URL 拼好,模板作为单个 pipeline 输出,并加了回归测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-08 11:40:07 +08:00
co-authored by Claude Opus 5
parent 033bef0614
commit edfe39cc35
11 changed files with 734 additions and 42 deletions
+44 -8
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -11,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"cmautobuy/admin/config"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
)
@@ -19,7 +21,7 @@ import (
// **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus
// 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。
func (h *Handler) ShopeeList(c *gin.Context) {
h.renderShopeeList(c, c.Query("goods_id"), c.Query("msg"), nil)
h.renderShopeeList(c, c.Query("goods_id"), c.Query("status"), c.Query("page"), c.Query("msg"), nil)
}
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
@@ -132,12 +134,25 @@ func (h *Handler) ShopeeImport(c *gin.Context) {
if len(result.Failures) > 0 {
msg += fmt.Sprintf(",%d 行解析失败(见下方列表)", len(result.Failures))
}
h.renderShopeeList(c, "", msg, result.Failures)
// 导入完成后回到第 1 页、清空筛选——刚导入的数据从第一页就能看到,
// 沿用筛选反而可能让人以为导入没生效(筛出来的还是老的几条)。
h.renderShopeeList(c, "", "", "", msg, result.Failures)
}
// renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。
func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures []service.ImportFailure) {
result, err := service.ListShopeeProducts(h.db, keyword)
//
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留 keyword/statusRaw,
// 所以这里把它们原样透传回模板去拼下一页的链接,不在这里丢掉(工单 #43)。
func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw, msg string, failures []service.ImportFailure) {
filter := repository.ShopeeFilter{
Keyword: keyword,
Status: service.ParseShopeeStatus(statusRaw),
}
// 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数,
// 局部变量会把它遮住导致编译失败。
pageNum := service.ParsePage(pageRaw)
result, err := service.ListShopeeProducts(h.db, filter, pageNum)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
@@ -146,22 +161,43 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures
status := msg
if status == "" {
status = fmt.Sprintf("共 %d 个商品", result.Total)
if result.IsFiltered {
status = fmt.Sprintf("筛选出 %d 条 / %s", len(result.Rows), status)
status = shopeeStatusLine(filter.Status, result)
}
values := url.Values{}
if keyword != "" {
values.Set("goods_id", keyword)
}
if filter.Status != "" {
values.Set("status", filter.Status)
}
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
"Keyword": keyword,
"StatusFilter": filter.Status,
"StatusOptions": service.ShopeeStatusOptions(),
"Rows": result.Rows,
"Status": status,
"HasAnyProducts": result.Total > 0,
"HasAnyProducts": result.HasAnyProducts,
"IsFiltered": result.IsFiltered,
"Failures": failures,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
}))
}
// shopeeStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。
//
// `[必须]` 显示的是筛选后的**全量**总数(result.Total),不是本页行数,
// 筛选时前缀用状态文字("待补规格:6 个商品"),不筛选时用"共",
// 见工单 #43。
func shopeeStatusLine(statusFilter string, result *service.ShopeeListResult) string {
prefix := fmt.Sprintf("共 %d 个商品", result.Total)
if text := service.ShopeeStatusLabel(statusFilter); text != "" {
prefix = fmt.Sprintf("%s:%d 个商品", text, result.Total)
}
return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages)
}
// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。
//
// 这是**从蝦皮商品出发**录入 PDD 链接的入口,
+73 -11
View File
@@ -103,12 +103,64 @@ type ShopeeProductRow struct {
CollectMsg sql.NullString
}
// ListShopeeProducts 按关键字查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
// ShopeeFilter 是蝦皮商品列表页支持的筛选条件,两项都可以为空。
//
// Status 取值见 §「状态筛选的四个取值」(工单 #43):
//
// "" 不筛选
// "pending_spec" 有 parse_ok = 0 的 SKU(待补规格)
// "no_link" pdd_goods_url 为空(未填 PDD 链接)
// "has_link" pdd_goods_url 非空(已填链接)
//
// 认不出的取值一律当作 ""(不筛选),由 service 层的 ParseShopeeStatus 兜底,
// 这里不做校验——repository 只管拼 SQL。
type ShopeeFilter struct {
Keyword string
Status string
}
// shopeeFilterClause 把关键字和状态筛选拼成 WHERE 子句,供 ListShopeeProducts
// 和 CountShopeeProductsFiltered 共用——两处筛选逻辑必须完全一致,
// 否则底部统计会跟表格对不上(#19 踩过一次,见工单 #43)。
//
// 「待补规格」用 EXISTS 子查询,不用 JOIN + DISTINCT:一个商品有多个失败
// SKU 时 JOIN 会出重复行,DISTINCT 又会让外层 LIMIT/OFFSET 的行为难推理
// (见工单 #43)。
func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
var clauses []string
var args []any
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
like := "%" + escapeLike(kw) + "%"
clauses = append(clauses, `(sp.goods_id LIKE ? ESCAPE '\' OR sp.title LIKE ? ESCAPE '\')`)
args = append(args, like, like)
}
switch filter.Status {
case "pending_spec":
clauses = append(clauses,
`EXISTS (SELECT 1 FROM shopee_skus s WHERE s.goods_id = sp.goods_id AND s.parse_ok = 0)`)
case "no_link":
clauses = append(clauses, `(sp.pdd_goods_url IS NULL OR sp.pdd_goods_url = '')`)
case "has_link":
clauses = append(clauses, `(sp.pdd_goods_url IS NOT NULL AND sp.pdd_goods_url <> '')`)
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
//
// keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**——
// 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。
// keyword 为空表示不筛选。
func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
//
// `[必须]` 分页用 LIMIT/OFFSET 在数据库里做,不把全量查出来在 Go 里切片
// (工单 #43:这正是改之前 HTML 一次 3.4MB 的成因)。
func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) {
where, args := shopeeFilterClause(filter)
sqlText := `
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code,
sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at,
@@ -120,14 +172,9 @@ func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
FROM shopee_products sp
LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
LEFT JOIN pdd_products pp
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL`
var args []any
if keyword = strings.TrimSpace(keyword); keyword != "" {
like := "%" + escapeLike(keyword) + "%"
sqlText += ` WHERE sp.goods_id LIKE ? ESCAPE '\' OR sp.title LIKE ? ESCAPE '\'`
args = append(args, like, like)
}
sqlText += ` GROUP BY sp.goods_id ORDER BY sp.goods_id`
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` +
where + ` GROUP BY sp.goods_id ORDER BY sp.goods_id LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := q.Query(sqlText, args...)
if err != nil {
@@ -157,6 +204,21 @@ func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
return list, rows.Err()
}
// CountShopeeProductsFiltered 统计当前筛选条件下的商品总数。
//
// `[必须]` 用和 ListShopeeProducts **完全相同**的筛选条件(shopeeFilterClause)——
// 底部状态条和分页页数都靠它,写成两份 WHERE 迟早有一天会忘了同步改,
// 页码就会算错而且没人发现(#19 已经踩过一次,见工单 #43)。
func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) {
where, args := shopeeFilterClause(filter)
sqlText := `SELECT COUNT(*) FROM shopee_products sp` + where
var n int
if err := q.QueryRow(sqlText, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err)
}
return n, nil
}
// GetShopeeProductByGoodsID 按 goods_id 查一条蝦皮商品,不带聚合。
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
+106
View File
@@ -0,0 +1,106 @@
// 分页的通用规则。
//
// `[必须]` 这是全项目第一个做分页的页面(蝦皮数据页,工单 #43),
// 这里定的规则和常量供后面四个页面照抄,不要各写一份——分开写的话,
// 每页条数、越界兜底这些规则五个页面迟早会不一致。
package service
import (
"strconv"
"strings"
)
// PageSize 是全项目统一的每页条数。
//
// `[必须]` 固定 20:见 docs/admin/05-ui-specification.md §3.2——
// 20 行在 1366×768 上正好一屏不用滚动。
const PageSize = 20
// ParsePage 解析 URL 上的 page 查询参数。
//
// `[必须]` 认不出来的一律当作第 1 页,不报错——用户手改地址栏或者用旧书签
// 不该把页面搞崩(工单 #43)。真正的越界(超过总页数)由 ClampPage 兜底,
// 这里只处理"不是正整数"的情况。
func ParsePage(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 1 {
return 1
}
return n
}
// TotalPages 按 PageSize 把总数换算成总页数。
//
// `[必须]` 总数为 0 时返回 1,不是 0——页面要显示"第 1/1 页",
// 不能出现"第 1/0 页"(工单 #43)。
func TotalPages(total int) int {
if total <= 0 {
return 1
}
return (total + PageSize - 1) / PageSize
}
// ClampPage 把请求的页码限制在 [1, totalPages] 范围内。
//
// `[必须]` page 超过总页数时兜到最后一页(显示有数据的那一页),
// 不是显示空表格——操作员看到空表格会以为数据没了(工单 #43)。
func ClampPage(page, totalPages int) int {
if page < 1 {
return 1
}
if page > totalPages {
return totalPages
}
return page
}
// PaginationView 是分页控件要显示的全部内容,模板不做判断和算术。
//
// `[必须]` FirstURL/PrevURL/NextURL/LastURL 是**整段拼好的**相对链接
// (形如 "?status=no_link&page=3"),模板里必须整体作为单个 pipeline 输出
// (`<a href="{{.FirstURL}}">`),不要在模板里把筛选参数和 page 分开拼接。
// html/template 的上下文转义规则对"字面量 & 中间插一段动态内容"的情况,
// 会把动态内容当成单个参数值整体转义,问号和等号会被转成 %3F/%3D,
// 链接直接失效——这是本工单实测踩到的一个坑,见 pagination_test.go。
type PaginationView struct {
Page int
TotalPages int
HasPrev bool
HasNext bool
FirstURL string
PrevURL string
NextURL string
LastURL string
}
// NewPaginationView 根据当前页、总页数和筛选查询串组装分页控件视图。
//
// baseQuery 是不含 page 的查询串(例如 url.Values{"status": {"no_link"}}.Encode()
// 的结果),由调用方(handler)负责把当前筛选和关键词编码进去——
// 这样翻页时筛选和关键词才不会丢,见工单 #43。
func NewPaginationView(page, totalPages int, baseQuery string) PaginationView {
v := PaginationView{
Page: page,
TotalPages: totalPages,
HasPrev: page > 1,
HasNext: page < totalPages,
FirstURL: PaginationURL(baseQuery, 1),
LastURL: PaginationURL(baseQuery, totalPages),
}
if v.HasPrev {
v.PrevURL = PaginationURL(baseQuery, page-1)
}
if v.HasNext {
v.NextURL = PaginationURL(baseQuery, page+1)
}
return v
}
// PaginationURL 把筛选查询串和目标页码拼成一个完整的相对链接("?...")。
func PaginationURL(baseQuery string, page int) string {
q := "page=" + strconv.Itoa(page)
if baseQuery != "" {
q = baseQuery + "&" + q
}
return "?" + q
}
+111
View File
@@ -0,0 +1,111 @@
package service
import "testing"
func TestParsePage_非法值当作第1页(t *testing.T) {
cases := []string{"", "abc", "0", "-1", "-100", " "}
for _, c := range cases {
if got := ParsePage(c); got != 1 {
t.Errorf("ParsePage(%q) = %d,想要 1", c, got)
}
}
}
func TestParsePage_合法值原样返回(t *testing.T) {
cases := map[string]int{"1": 1, "2": 2, "9999": 9999, " 3 ": 3}
for in, want := range cases {
if got := ParsePage(in); got != want {
t.Errorf("ParsePage(%q) = %d,想要 %d", in, got, want)
}
}
}
func TestTotalPages_总数为0返回1不是0(t *testing.T) {
if got := TotalPages(0); got != 1 {
t.Errorf("TotalPages(0) = %d,想要 1(不能出现『第 1/0 页』)", got)
}
}
func TestTotalPages_按PageSize向上取整(t *testing.T) {
cases := map[int]int{
1: 1,
PageSize: 1,
PageSize + 1: 2,
PageSize * 2: 2,
5195: 260, // 工单 #43 实测样本量:5195 条 / 20 条一页 = 260 页
}
for total, want := range cases {
if got := TotalPages(total); got != want {
t.Errorf("TotalPages(%d) = %d,想要 %d", total, got, want)
}
}
}
func TestClampPage_越界兜到最后一页(t *testing.T) {
if got := ClampPage(9999, 260); got != 260 {
t.Errorf("ClampPage(9999, 260) = %d,想要 260(兜到最后一页,不是空表格)", got)
}
}
func TestClampPage_小于1当作第1页(t *testing.T) {
for _, p := range []int{0, -1, -100} {
if got := ClampPage(p, 260); got != 1 {
t.Errorf("ClampPage(%d, 260) = %d,想要 1", p, got)
}
}
}
func TestClampPage_合法范围原样返回(t *testing.T) {
if got := ClampPage(3, 260); got != 3 {
t.Errorf("ClampPage(3, 260) = %d,想要 3", got)
}
}
func TestNewPaginationView_首页禁用上一页和首页(t *testing.T) {
v := NewPaginationView(1, 260, "")
if v.HasPrev {
t.Error("第 1 页时 HasPrev 应为 false(首页/上一页要禁用)")
}
if !v.HasNext {
t.Error("第 1 页且总页数 > 1 时 HasNext 应为 true")
}
}
func TestNewPaginationView_末页禁用下一页和末页(t *testing.T) {
v := NewPaginationView(260, 260, "")
if v.HasPrev != true {
t.Error("最后一页时 HasPrev 应为 true(前面还有页)")
}
if v.HasNext {
t.Error("最后一页时 HasNext 应为 false(下一页/末页要禁用)")
}
}
func TestNewPaginationView_总数为0时首尾都禁用(t *testing.T) {
v := NewPaginationView(1, 1, "")
if v.HasPrev || v.HasNext {
t.Errorf("只有 1 页时前后都应禁用,HasPrev=%v HasNext=%v", v.HasPrev, v.HasNext)
}
}
func TestNewPaginationView_URL带上筛选条件(t *testing.T) {
v := NewPaginationView(2, 260, "status=no_link")
if v.PrevURL != "?status=no_link&page=1" {
t.Errorf("PrevURL = %q,想要 ?status=no_link&page=1", v.PrevURL)
}
if v.NextURL != "?status=no_link&page=3" {
t.Errorf("NextURL = %q,想要 ?status=no_link&page=3", v.NextURL)
}
if v.FirstURL != "?status=no_link&page=1" {
t.Errorf("FirstURL = %q,想要 ?status=no_link&page=1", v.FirstURL)
}
if v.LastURL != "?status=no_link&page=260" {
t.Errorf("LastURL = %q,想要 ?status=no_link&page=260", v.LastURL)
}
}
func TestPaginationURL_无筛选条件时只有page(t *testing.T) {
if got := PaginationURL("", 3); got != "?page=3" {
t.Errorf("PaginationURL(\"\", 3) = %q,想要 ?page=3", got)
}
}
+79 -6
View File
@@ -5,6 +5,7 @@ package service
import (
"database/sql"
"strings"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
@@ -43,23 +44,49 @@ type ShopeeProductView struct {
// ShopeeListResult 是列表页要的全部数据。
type ShopeeListResult struct {
Rows []ShopeeProductView
Total int // shopee_products 的总商品数(不受筛选影响),用于判断"是否已导入过"
// Total 是**当前筛选条件下**的商品总数,不是本页行数。
// `[必须]` 底部状态条必须显示这个,不能显示本页行数(20),
// 否则操作员会以为总共就这么多,见工单 #43。
Total int
// HasAnyProducts 是 shopee_products 的全库总数是否大于 0,不受筛选影响,
// 只用于区分空状态文案:"从没导入过" vs "筛选没结果"。
HasAnyProducts bool
IsFiltered bool
Page int
PageSize int
TotalPages int
}
// ListShopeeProducts 查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
//
// 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段,
// 见 docs/admin/01-requirements.md §6.1:
//
// shopee_products.pdd_goods_id 为空 -> "未填链接"
// pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败
func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
rows, err := repository.ListShopeeProducts(db, keyword)
//
// `[必须]` Total 用和 ListShopeeProducts 同一套筛选条件的 COUNT
// (repository.CountShopeeProductsFiltered),分页页数据此算出,
// page 越界时兜到最后一页,见 service/pagination.go(工单 #43)。
func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*ShopeeListResult, error) {
total, err := repository.CountShopeeProductsFiltered(db, filter)
if err != nil {
return nil, err
}
total, err := repository.CountShopeeProducts(db)
totalPages := TotalPages(total)
page = ClampPage(page, totalPages)
offset := (page - 1) * PageSize
rows, err := repository.ListShopeeProducts(db, filter, PageSize, offset)
if err != nil {
return nil, err
}
hasAny, err := repository.CountShopeeProducts(db)
if err != nil {
return nil, err
}
@@ -67,7 +94,11 @@ func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
result := &ShopeeListResult{
Rows: make([]ShopeeProductView, 0, len(rows)),
Total: total,
IsFiltered: keyword != "",
HasAnyProducts: hasAny > 0,
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "",
Page: page,
PageSize: PageSize,
TotalPages: totalPages,
}
for _, r := range rows {
v := ShopeeProductView{
@@ -100,6 +131,48 @@ func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
return result, nil
}
// ---------- 状态筛选 ----------
// shopeeStatusTexts 是状态筛选下拉框每个取值对应的中文文字,
// 也用于底部状态条筛选后的前缀("待补规格:6 个商品"),见工单 #43。
var shopeeStatusTexts = map[string]string{
"pending_spec": "待补规格",
"no_link": "未填 PDD 链接",
"has_link": "已填链接",
}
// ShopeeStatusOption 是筛选下拉框的一项。
type ShopeeStatusOption struct {
Value string // 空串表示"全部"
Text string
}
// ShopeeStatusOptions 返回状态筛选下拉框的全部选项:全部 + 三个状态。
func ShopeeStatusOptions() []ShopeeStatusOption {
return []ShopeeStatusOption{
{"", "全部"},
{"pending_spec", shopeeStatusTexts["pending_spec"]},
{"no_link", shopeeStatusTexts["no_link"]},
{"has_link", shopeeStatusTexts["has_link"]},
}
}
// ParseShopeeStatus 校验筛选参数。认不出的一律当"全部",不报错——
// 地址栏参数是用户可以随便改的,不值得为它弹错误页(工单 #43)。
func ParseShopeeStatus(s string) string {
status := strings.TrimSpace(s)
if _, ok := shopeeStatusTexts[status]; ok {
return status
}
return ""
}
// ShopeeStatusLabel 返回状态筛选取值对应的中文文字,取值为空或认不出时
// 返回空串——调用方(handler 层组装状态条文案)据此判断要不要用作前缀。
func ShopeeStatusLabel(status string) string {
return shopeeStatusTexts[status]
}
// ---------- 弹窗 ----------
// ShopeeSpecView 是弹窗规格表的一行。
+168 -8
View File
@@ -2,6 +2,7 @@ package service
import (
"database/sql"
"fmt"
"testing"
"cmautobuy/admin/repository"
@@ -47,7 +48,7 @@ func TestListShopeeProducts_商品级一行(t *testing.T) {
seedShopeeSKU(t, db, "sku-2", "24420648774", "", "黑色", "L", "50-60公斤", true)
seedShopeeSKU(t, db, "sku-3", "24420648774", "", "白色", "M", "40-50公斤", true)
result, err := ListShopeeProducts(db, "")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -82,7 +83,7 @@ func TestListShopeeProducts_颜色尺码只统计parse_ok(t *testing.T) {
seedShopeeSKU(t, db, "s4", "26886533818", "紅色,3XL建議80-90公斤】", "", "", "", false)
seedShopeeSKU(t, db, "s5", "26886533818", "粉色,3XL【寬鬆版 82.5-92.5kg", "", "", "", false)
result, err := ListShopeeProducts(db, "")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -109,7 +110,7 @@ func TestListShopeeProducts_待补大于0整行标黄(t *testing.T) {
seedShopeeProduct(t, db, "1001", "商品A")
seedShopeeSKU(t, db, "s1", "1001", "坏数据", "", "", "", false)
result, err := ListShopeeProducts(db, "")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -123,7 +124,7 @@ func TestListShopeeProducts_PDD链接未填写(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品A")
result, err := ListShopeeProducts(db, "")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -148,7 +149,7 @@ func TestListShopeeProducts_采集状态联查(t *testing.T) {
}
setShopeePddLink(t, db, "1001", "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001")
result, err := ListShopeeProducts(db, "")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -168,7 +169,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺
seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "", true)
// 按商品 ID 匹配
result, err := ListShopeeProducts(db, "1001")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "1001"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -177,7 +178,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺
}
// 按商品名称匹配
result, err = ListShopeeProducts(db, "背心")
result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "背心"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -186,7 +187,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺
}
// 颜色/尺码不参与匹配:搜"黑色"应该搜不到任何商品。
result, err = ListShopeeProducts(db, "黑色")
result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "黑色"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
@@ -250,3 +251,162 @@ func TestGetShopeeProductDetail_商品不存在返回nil(t *testing.T) {
t.Fatalf("不存在的商品应该返回 nil,得到 %+v", detail)
}
}
// ── 分页(工单 #43) ──────────────────────────────────
func seedShopeeProducts(t *testing.T, db *sql.DB, n int) {
t.Helper()
for i := 0; i < n; i++ {
seedShopeeProduct(t, db, fmt.Sprintf("g%04d", i), fmt.Sprintf("商品%d", i))
}
}
func TestListShopeeProducts_每页固定20条(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 25)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 20 {
t.Errorf("第 1 页行数 = %d,想要 20", len(result.Rows))
}
if result.Total != 25 {
t.Errorf("Total = %d,想要 25(全量,不是本页数)", result.Total)
}
if result.TotalPages != 2 {
t.Errorf("TotalPages = %d,想要 2", result.TotalPages)
}
result2, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 2)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result2.Rows) != 5 {
t.Errorf("第 2 页行数 = %d,想要 5(25 条剩下的)", len(result2.Rows))
}
}
func TestListShopeeProducts_page越界兜到最后一页(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 25)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 9999)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Page != 2 {
t.Errorf("Page = %d,想要兜到最后一页 2", result.Page)
}
if len(result.Rows) != 5 {
t.Errorf("越界后应显示最后一页的数据,行数 = %d,想要 5,不是空表格", len(result.Rows))
}
}
func TestListShopeeProducts_page小于1或非数字当作第1页(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 3)
for _, p := range []int{0, -1, -100} {
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, p)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Page != 1 {
t.Errorf("page=%d 时 Page = %d,想要 1", p, result.Page)
}
}
}
func TestListShopeeProducts_总数为0时第1页共1页(t *testing.T) {
db := newTestDB(t)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.TotalPages != 1 {
t.Errorf("总数为 0 时 TotalPages = %d,想要 1(不能出现『第 1/0 页』)", result.TotalPages)
}
if result.Page != 1 {
t.Errorf("Page = %d,想要 1", result.Page)
}
}
// ── 状态筛选(工单 #43) ──────────────────────────────
func TestListShopeeProducts_待补规格筛选_一个商品多个失败SKU只出现一行(t *testing.T) {
db := newTestDB(t)
// 同一商品两条解析失败的 SKU:用 JOIN + DISTINCT 会因为多行匹配 EXISTS
// 子查询而出重复,这里验证过滤用 EXISTS 时该商品只出现一次。
seedShopeeProduct(t, db, "1001", "待补商品")
seedShopeeSKU(t, db, "s1", "1001", "坏数据1", "", "", "", false)
seedShopeeSKU(t, db, "s2", "1001", "坏数据2", "", "", "", false)
seedShopeeProduct(t, db, "1002", "正常商品")
seedShopeeSKU(t, db, "s3", "1002", "", "黑色", "M", "", true)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "pending_spec"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Total != 1 {
t.Fatalf("Total = %d,想要 1(不因为两条失败 SKU 出重复行)", result.Total)
}
if len(result.Rows) != 1 || result.Rows[0].GoodsID != "1001" {
t.Fatalf("筛选结果不对: %+v", result.Rows)
}
}
func TestListShopeeProducts_未填链接和已填链接筛选(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "未填链接商品")
seedShopeeProduct(t, db, "1002", "已填链接商品")
setShopeePddLink(t, db, "1002", "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001")
noLink, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "no_link"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if noLink.Total != 1 || noLink.Rows[0].GoodsID != "1001" {
t.Fatalf("未填链接筛选结果不对: %+v", noLink.Rows)
}
hasLink, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "has_link"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if hasLink.Total != 1 || hasLink.Rows[0].GoodsID != "1002" {
t.Fatalf("已填链接筛选结果不对: %+v", hasLink.Rows)
}
}
func TestListShopeeProducts_筛选参数认不出来当全部(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 3)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "not_a_real_status"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
// repository 层不做校验,直接透传的话这个取值走不进 switch 的任何分支,
// 等价于不筛选——校验(认不出来当全部)在 service.ParseShopeeStatus,
// 这里验证 repository 端不会因为陌生取值报错或漏数据。
if result.Total != 3 {
t.Errorf("Total = %d,想要 3(陌生取值不应影响结果)", result.Total)
}
}
func TestParseShopeeStatus_认不出来当全部(t *testing.T) {
cases := []string{"", "not_a_real_status", " ", "PENDING_SPEC"}
for _, c := range cases {
if got := ParseShopeeStatus(c); got != "" {
t.Errorf("ParseShopeeStatus(%q) = %q,想要空串(当全部)", c, got)
}
}
for _, c := range []string{"pending_spec", "no_link", "has_link"} {
if got := ParseShopeeStatus(c); got != c {
t.Errorf("ParseShopeeStatus(%q) = %q,想要原样返回", c, got)
}
}
}
+30
View File
@@ -137,6 +137,8 @@ tr.empty small { color: #aaa; }
.statusbar {
position: fixed;
left: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
background: #2b3440;
color: #c8cdd4;
padding: 10px 16px;
@@ -262,6 +264,34 @@ select {
background: #fff;
}
/* ── 分页控件 ─────────────────────────── */
/* 见 docs/admin/05-ui-specification.md §3.2。全项目第一个分页页面
(蝦皮数据页,工单 #43)定的样式,其余四个页面照抄。
控件本身是 <a href>,不是按钮/JS,样式借用 button 的观感即可。 */
.pagination {
display: flex;
align-items: center;
gap: 6px;
margin-left: 12px;
}
.pagination a,
.pagination span.disabled {
padding: 4px 10px;
border: 1px solid #ccd1d6;
border-radius: 3px;
text-decoration: none;
font-size: 13px;
}
.pagination a { color: #1f6feb; background: #fff; }
.pagination a:hover { background: #f0f2f4; }
/* 首页/末页时对应按钮禁用:用 <span> 而不是不可点的 <a>(语义上不再是链接),
再配色区分,不能只靠颜色(见 05 §3.2)。 */
.pagination span.disabled {
color: #aaa;
background: #f5f6f8;
cursor: not-allowed;
}
/* ── 错误页 ───────────────────────────── */
.error-box {
background: #fff;
+30 -2
View File
@@ -1,8 +1,36 @@
{{define "footer"}}
</main>
{{/* 底部状态条:三段式布局的第三段,五个页面都有 */}}
<footer class="statusbar">{{.Status}}</footer>
{{/* 底部状态条:三段式布局的第三段,五个页面都有。
Pagination 是可选的(只有做了分页的页面才传),
没做分页的页面不受影响,见 docs/admin/05-ui-specification.md §3.2。
`[必须]` 每个链接的 href 必须整体来自 .Pagination.XxxURL 这一个 pipeline,
不要在这里把筛选参数和 page 分开拼接(比如 "?{{.BaseQuery}}&page=...")——
html/template 会把夹在字面量 & 中间的动态内容当成单个参数值整体转义,
? 和 = 变成 %3F/%3D,链接直接失效。已经在 service.NewPaginationView
里把完整链接拼好,这里只管原样输出。 */}}
<footer class="statusbar">
<span>{{.Status}}</span>
{{if .Pagination}}
<nav class="pagination" aria-label="分页">
{{if .Pagination.HasPrev}}
<a href="{{.Pagination.FirstURL}}">首页</a>
<a href="{{.Pagination.PrevURL}}">上一页</a>
{{else}}
<span class="disabled">首页</span>
<span class="disabled">上一页</span>
{{end}}
{{if .Pagination.HasNext}}
<a href="{{.Pagination.NextURL}}">下一页</a>
<a href="{{.Pagination.LastURL}}">末页</a>
{{else}}
<span class="disabled">下一页</span>
<span class="disabled">末页</span>
{{end}}
</nav>
{{end}}
</footer>
<script src="/static/js/app.js"></script>
</body>
+9
View File
@@ -15,6 +15,15 @@
</form>
<form class="inline grow" method="get" action="/shopee">
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花,
见 docs/admin/05-ui-specification.md §4.1、工单 #43。
放在关键词前面:操作员大多是"看某一类"而不是"查某个 ID"。 */}}
<label for="status">状态</label>
<select id="status" name="status">
{{range .StatusOptions}}
<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>
{{end}}
</select>
<label for="q">商品 ID</label>
<input id="q" type="text" name="goods_id" value="{{.Keyword}}" placeholder="商品 ID">
<button type="submit">搜索</button>
+8 -1
View File
@@ -85,7 +85,14 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
### 4.1 蝦皮数据模块
**顶部工具条:** 导入按钮、商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。
**顶部工具条:** 导入按钮、状态筛选(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、
商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。
`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话,
操作员得先知道 ID 才能搜——而"哪些商品需要处理"恰恰是不知道 ID 的时候才要问的。
实测样本 5195 个商品里只有 6 个待补规格,只做分页要翻 260 页才能找全,等于找不到。
界面细节和筛选条件的 SQL 见 [05 界面规范](05-ui-specification.md) §4.1。
**中间表格**(按 SKU 展开显示,数据来自商品表和 SKU 表联查):
+72 -2
View File
@@ -47,7 +47,7 @@
见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。
- `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。
- `[建议]` 默认按 `更新时间 DESC` 排序。
- `[建议]` 一页 50 条,超过分页。搜索走数据库,不要一次查出来在内存里过滤。
- `[必须]` 每页 **20** 条,超过分页,见 §3.2。搜索走数据库,不要一次查出来在内存里过滤。
- `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。
- `[必须]` 空状态要分情况,文案不能都是"暂无数据":
- 从没导入过 → "还没有数据,点左上角『导入』开始"
@@ -72,12 +72,50 @@
`[建议]` placeholder 要短。收到 30% 之后长文案显示不全——
真装不下就缩短文案,**不要把宽度调回去**。
### 3.2 分页
`[必须]` 五个模块**统一每页 20 条**(原来这里写的是"建议一页 50 条",
已按工单 #43 改为 20 并升级为 `[必须]`)——20 行在 1366×768 上正好一屏,
不用滚动就能看完;50 条要滚动,操作员容易漏看最下面几行。
蝦皮数据页(§4)是全项目第一个做分页的页面,本节是它定下的通用规则,
**其余四个页面照抄**,不要各写一套:
- `[必须]` 分页用 SQL 的 `LIMIT ? OFFSET ?`,**不得**把全量查出来在 Go 里
切片——这正是蝦皮数据页改之前一次吐 3.4MB HTML 的成因(实测 5195 行)。
- `[必须]` 总数用**单独的 `COUNT(*)`** 查询,并且和列表查询**共用同一套
筛选条件拼装函数**。分开写两份 `WHERE` 迟早会漏改一处,页码跟着算错,
而且不会报错、不容易发现(#19 已经踩过一次)。
- `[必须]` 页码参数是 `?page=N`,从 **1** 开始,不是 0——给操作员看的东西
不要 0-based。
- `[必须]` 越界要兜住:
- `page` 非数字、`0`、负数 → 当作第 1 页;
- `page` 超过总页数 → 显示**最后一页**(有数据的那页),不是空表格;
- 总数为 0 → 显示"第 1/1 页",不出现"第 1/0 页"。
- `[必须]` 底部状态条显示**全量总数**(当前筛选条件下的总数),不是本页
行数——"共 20 个商品"会让操作员以为总共就 20 个。筛选后显示筛选结果的
总数,例如"待补规格:6 个商品 · 第 1/1 页",不是本页凑出来的数字。
- `[必须]` 翻页时**保留当前筛选和关键词**——用查询参数原样带过去,
丢了的话操作员翻到第二页筛选就没了,会以为数据变了。
- `[必须]` 分页控件用 `<a href>`,**不要用 JS**。这是纯 GET 导航,
浏览器的前进后退和书签都该正常工作。
- `[必须]` 第一页时"首页 / 上一页"禁用,最后一页时"下一页 / 末页"禁用;
禁用要有**视觉 + 语义**区分(不可点的元素不要还是 `<a>`),不能只靠颜色。
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
操作员应该靠筛选定位,不是靠翻页数页码。
公共实现放在 `admin/service/pagination.go`(`PageSize` 常量、
`ParsePage` / `ClampPage` / `TotalPages` / `NewPaginationView`)和
`admin/templates/partials/footer.html`(分页控件,`Pagination` 字段
为空时不渲染,不影响还没做分页的页面),后面四个页面直接复用,
不要各写一份。
## 4. 蝦皮数据页
### 4.1 工具条
```text
[导入 Excel] [批量采集] 商品ID [________] [搜索] [删除]
[导入 Excel] [批量采集] 状态[全部▾] 商品ID [________] [搜索] [删除]
```
- **导入 Excel**:选文件 → POST 上传 → 显示结果统计
@@ -85,6 +123,21 @@
`[必须]` 失败行要列出行号和原因,不能静默跳过。
- **批量采集**:勾选若干行 → 服务端**按商品去重**后建采集任务。
`[必须]` 已是"采集中"的商品跳过,并在结果里说明跳过了几个。
- **状态筛选**(`[必须]`,工单 #43):全部 / 待补规格 / 未填 PDD 链接 / 已填链接。
这个筛选是**刚需**,不是锦上添花——本页主要用途是维护
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID
搜索的话,操作员得先知道 ID 才能搜,而"哪些商品需要处理"恰恰是
不知道 ID 的时候才要问的。实测样本 5195 个商品里只有 6 个待补规格,
只做分页(260 页)翻不出来。
- 「待补规格」:`EXISTS (SELECT 1 FROM shopee_skus WHERE goods_id = 该商品 AND parse_ok = 0)`。
`[必须]` 用 `EXISTS`,不用 `JOIN` + `DISTINCT`——一个商品有多个失败
SKU 时 `JOIN` 会出重复行,`DISTINCT` 又会让分页的 `LIMIT/OFFSET`
行为难推理。
- 「未填 PDD 链接」:`pdd_goods_url IS NULL OR pdd_goods_url = ''`。
- 「已填链接」:`pdd_goods_url IS NOT NULL AND pdd_goods_url <> ''`。
- `[必须]` 筛选参数认不出来的一律当"全部",不报错——地址栏是用户可以
随便改的。
- `[必须]` 筛选与关键词搜索可以叠加,翻页时都要保留,见 §3.2。
### 4.2 表格列
@@ -145,6 +198,23 @@
新增的行 `is_manual = 1`,`[必须]` 后续导入**不得删除**它们。
### 4.5 底部状态条
```text
共 5195 个商品 · 第 1/260 页 [首页] [上一页] [下一页] [末页]
```
筛选后(例如状态选了"待补规格"):
```text
待补规格:6 个商品 · 第 1/1 页 [首页] [上一页] [下一页] [末页]
```
`[必须]` 分页与状态条的通用规则见 §3.2,不在这里重复。这里只强调
蝦皮页特有的一点:导入完成后状态条**显示导入结果**("导入完成:N 个商品 /
M 个 SKU,K 行解析失败"),覆盖掉正常的统计文案,并且回到第 1 页、清空筛选——
刚导入的数据从第一页就能看到,沿用旧筛选反而可能让人以为导入没生效。
## 5. PDD 商品页
页面 `objectName` 为 `pddProductPage`,路由 `/pdd`。