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:
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"cmautobuy/admin/config"
|
"cmautobuy/admin/config"
|
||||||
|
"cmautobuy/admin/repository"
|
||||||
"cmautobuy/admin/service"
|
"cmautobuy/admin/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ import (
|
|||||||
// **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus
|
// **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus
|
||||||
// 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。
|
// 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。
|
||||||
func (h *Handler) ShopeeList(c *gin.Context) {
|
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 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
|
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
|
||||||
@@ -132,12 +134,25 @@ func (h *Handler) ShopeeImport(c *gin.Context) {
|
|||||||
if len(result.Failures) > 0 {
|
if len(result.Failures) > 0 {
|
||||||
msg += fmt.Sprintf(",%d 行解析失败(见下方列表)", len(result.Failures))
|
msg += fmt.Sprintf(",%d 行解析失败(见下方列表)", len(result.Failures))
|
||||||
}
|
}
|
||||||
h.renderShopeeList(c, "", msg, result.Failures)
|
// 导入完成后回到第 1 页、清空筛选——刚导入的数据从第一页就能看到,
|
||||||
|
// 沿用筛选反而可能让人以为导入没生效(筛出来的还是老的几条)。
|
||||||
|
h.renderShopeeList(c, "", "", "", msg, result.Failures)
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。
|
// 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 {
|
if err != nil {
|
||||||
fail(c, http.StatusInternalServerError,
|
fail(c, http.StatusInternalServerError,
|
||||||
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||||
@@ -146,22 +161,43 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures
|
|||||||
|
|
||||||
status := msg
|
status := msg
|
||||||
if status == "" {
|
if status == "" {
|
||||||
status = fmt.Sprintf("共 %d 个商品", result.Total)
|
status = shopeeStatusLine(filter.Status, result)
|
||||||
if result.IsFiltered {
|
|
||||||
status = fmt.Sprintf("筛选出 %d 条 / %s", len(result.Rows), status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
|
||||||
"Keyword": keyword,
|
"Keyword": keyword,
|
||||||
|
"StatusFilter": filter.Status,
|
||||||
|
"StatusOptions": service.ShopeeStatusOptions(),
|
||||||
"Rows": result.Rows,
|
"Rows": result.Rows,
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"HasAnyProducts": result.Total > 0,
|
"HasAnyProducts": result.HasAnyProducts,
|
||||||
"IsFiltered": result.IsFiltered,
|
"IsFiltered": result.IsFiltered,
|
||||||
"Failures": failures,
|
"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 链接、颜色、尺码、建议)。
|
// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。
|
||||||
//
|
//
|
||||||
// 这是**从蝦皮商品出发**录入 PDD 链接的入口,
|
// 这是**从蝦皮商品出发**录入 PDD 链接的入口,
|
||||||
|
|||||||
+73
-11
@@ -103,12 +103,64 @@ type ShopeeProductRow struct {
|
|||||||
CollectMsg sql.NullString
|
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 或商品名称,**不匹配颜色/尺码**——
|
// keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**——
|
||||||
// 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。
|
// 那是 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 := `
|
sqlText := `
|
||||||
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code,
|
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,
|
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
|
FROM shopee_products sp
|
||||||
LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
|
LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
|
||||||
LEFT JOIN pdd_products pp
|
LEFT JOIN pdd_products pp
|
||||||
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL`
|
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` +
|
||||||
var args []any
|
where + ` GROUP BY sp.goods_id ORDER BY sp.goods_id LIMIT ? OFFSET ?`
|
||||||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
args = append(args, limit, offset)
|
||||||
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`
|
|
||||||
|
|
||||||
rows, err := q.Query(sqlText, args...)
|
rows, err := q.Query(sqlText, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,6 +204,21 @@ func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
|
|||||||
return list, rows.Err()
|
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 查一条蝦皮商品,不带聚合。
|
// GetShopeeProductByGoodsID 按 goods_id 查一条蝦皮商品,不带聚合。
|
||||||
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
|
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
|
||||||
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
|
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"cmautobuy/admin/model"
|
"cmautobuy/admin/model"
|
||||||
"cmautobuy/admin/repository"
|
"cmautobuy/admin/repository"
|
||||||
@@ -43,23 +44,49 @@ type ShopeeProductView struct {
|
|||||||
// ShopeeListResult 是列表页要的全部数据。
|
// ShopeeListResult 是列表页要的全部数据。
|
||||||
type ShopeeListResult struct {
|
type ShopeeListResult struct {
|
||||||
Rows []ShopeeProductView
|
Rows []ShopeeProductView
|
||||||
Total int // shopee_products 的总商品数(不受筛选影响),用于判断"是否已导入过"
|
|
||||||
|
// Total 是**当前筛选条件下**的商品总数,不是本页行数。
|
||||||
|
// `[必须]` 底部状态条必须显示这个,不能显示本页行数(20),
|
||||||
|
// 否则操作员会以为总共就这么多,见工单 #43。
|
||||||
|
Total int
|
||||||
|
|
||||||
|
// HasAnyProducts 是 shopee_products 的全库总数是否大于 0,不受筛选影响,
|
||||||
|
// 只用于区分空状态文案:"从没导入过" vs "筛选没结果"。
|
||||||
|
HasAnyProducts bool
|
||||||
|
|
||||||
IsFiltered bool
|
IsFiltered bool
|
||||||
|
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
TotalPages int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListShopeeProducts 查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
|
// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
|
||||||
//
|
//
|
||||||
// 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段,
|
// 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段,
|
||||||
// 见 docs/admin/01-requirements.md §6.1:
|
// 见 docs/admin/01-requirements.md §6.1:
|
||||||
//
|
//
|
||||||
// shopee_products.pdd_goods_id 为空 -> "未填链接"
|
// shopee_products.pdd_goods_id 为空 -> "未填链接"
|
||||||
// pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -67,7 +94,11 @@ func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
|
|||||||
result := &ShopeeListResult{
|
result := &ShopeeListResult{
|
||||||
Rows: make([]ShopeeProductView, 0, len(rows)),
|
Rows: make([]ShopeeProductView, 0, len(rows)),
|
||||||
Total: total,
|
Total: total,
|
||||||
IsFiltered: keyword != "",
|
HasAnyProducts: hasAny > 0,
|
||||||
|
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "",
|
||||||
|
Page: page,
|
||||||
|
PageSize: PageSize,
|
||||||
|
TotalPages: totalPages,
|
||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
v := ShopeeProductView{
|
v := ShopeeProductView{
|
||||||
@@ -100,6 +131,48 @@ func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
|
|||||||
return result, nil
|
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 是弹窗规格表的一行。
|
// ShopeeSpecView 是弹窗规格表的一行。
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cmautobuy/admin/repository"
|
"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-2", "24420648774", "", "黑色", "L", "50-60公斤", true)
|
||||||
seedShopeeSKU(t, db, "sku-3", "24420648774", "", "白色", "M", "40-50公斤", 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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
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, "s4", "26886533818", "紅色,3XL建議80-90公斤】", "", "", "", false)
|
||||||
seedShopeeSKU(t, db, "s5", "26886533818", "粉色,3XL【寬鬆版 82.5-92.5kg", "", "", "", 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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -109,7 +110,7 @@ func TestListShopeeProducts_待补大于0整行标黄(t *testing.T) {
|
|||||||
seedShopeeProduct(t, db, "1001", "商品A")
|
seedShopeeProduct(t, db, "1001", "商品A")
|
||||||
seedShopeeSKU(t, db, "s1", "1001", "坏数据", "", "", "", false)
|
seedShopeeSKU(t, db, "s1", "1001", "坏数据", "", "", "", false)
|
||||||
|
|
||||||
result, err := ListShopeeProducts(db, "")
|
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -123,7 +124,7 @@ func TestListShopeeProducts_PDD链接未填写(t *testing.T) {
|
|||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
seedShopeeProduct(t, db, "1001", "商品A")
|
seedShopeeProduct(t, db, "1001", "商品A")
|
||||||
|
|
||||||
result, err := ListShopeeProducts(db, "")
|
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
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")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -168,7 +169,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺
|
|||||||
seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "", true)
|
seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "", true)
|
||||||
|
|
||||||
// 按商品 ID 匹配
|
// 按商品 ID 匹配
|
||||||
result, err := ListShopeeProducts(db, "1001")
|
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "1001"}, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("查询失败: %v", err)
|
t.Fatalf("查询失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -250,3 +251,162 @@ func TestGetShopeeProductDetail_商品不存在返回nil(t *testing.T) {
|
|||||||
t.Fatalf("不存在的商品应该返回 nil,得到 %+v", detail)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ tr.empty small { color: #aaa; }
|
|||||||
.statusbar {
|
.statusbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0; right: 0; bottom: 0;
|
left: 0; right: 0; bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
background: #2b3440;
|
background: #2b3440;
|
||||||
color: #c8cdd4;
|
color: #c8cdd4;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
@@ -262,6 +264,34 @@ select {
|
|||||||
background: #fff;
|
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 {
|
.error-box {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
{{define "footer"}}
|
{{define "footer"}}
|
||||||
</main>
|
</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>
|
<script src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -15,6 +15,15 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form class="inline grow" method="get" action="/shopee">
|
<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>
|
<label for="q">商品 ID</label>
|
||||||
<input id="q" type="text" name="goods_id" value="{{.Keyword}}" placeholder="商品 ID">
|
<input id="q" type="text" name="goods_id" value="{{.Keyword}}" placeholder="商品 ID">
|
||||||
<button type="submit">搜索</button>
|
<button type="submit">搜索</button>
|
||||||
|
|||||||
@@ -85,7 +85,14 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
|
|
||||||
### 4.1 蝦皮数据模块
|
### 4.1 蝦皮数据模块
|
||||||
|
|
||||||
**顶部工具条:** 导入按钮、商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。
|
**顶部工具条:** 导入按钮、状态筛选(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、
|
||||||
|
商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。
|
||||||
|
|
||||||
|
`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护
|
||||||
|
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话,
|
||||||
|
操作员得先知道 ID 才能搜——而"哪些商品需要处理"恰恰是不知道 ID 的时候才要问的。
|
||||||
|
实测样本 5195 个商品里只有 6 个待补规格,只做分页要翻 260 页才能找全,等于找不到。
|
||||||
|
界面细节和筛选条件的 SQL 见 [05 界面规范](05-ui-specification.md) §4.1。
|
||||||
|
|
||||||
**中间表格**(按 SKU 展开显示,数据来自商品表和 SKU 表联查):
|
**中间表格**(按 SKU 展开显示,数据来自商品表和 SKU 表联查):
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。
|
见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。
|
||||||
- `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。
|
- `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。
|
||||||
- `[建议]` 默认按 `更新时间 DESC` 排序。
|
- `[建议]` 默认按 `更新时间 DESC` 排序。
|
||||||
- `[建议]` 一页 50 条,超过分页。搜索走数据库,不要一次查出来在内存里过滤。
|
- `[必须]` 每页 **20** 条,超过分页,见 §3.2。搜索走数据库,不要一次查出来在内存里过滤。
|
||||||
- `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。
|
- `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。
|
||||||
- `[必须]` 空状态要分情况,文案不能都是"暂无数据":
|
- `[必须]` 空状态要分情况,文案不能都是"暂无数据":
|
||||||
- 从没导入过 → "还没有数据,点左上角『导入』开始"
|
- 从没导入过 → "还没有数据,点左上角『导入』开始"
|
||||||
@@ -72,12 +72,50 @@
|
|||||||
`[建议]` placeholder 要短。收到 30% 之后长文案显示不全——
|
`[建议]` 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. 蝦皮数据页
|
||||||
|
|
||||||
### 4.1 工具条
|
### 4.1 工具条
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[导入 Excel] [批量采集] 商品ID [________] [搜索] [删除]
|
[导入 Excel] [批量采集] 状态[全部▾] 商品ID [________] [搜索] [删除]
|
||||||
```
|
```
|
||||||
|
|
||||||
- **导入 Excel**:选文件 → POST 上传 → 显示结果统计
|
- **导入 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 表格列
|
### 4.2 表格列
|
||||||
|
|
||||||
@@ -145,6 +198,23 @@
|
|||||||
|
|
||||||
新增的行 `is_manual = 1`,`[必须]` 后续导入**不得删除**它们。
|
新增的行 `is_manual = 1`,`[必须]` 后续导入**不得删除**它们。
|
||||||
|
|
||||||
|
### 4.5 底部状态条
|
||||||
|
|
||||||
|
```text
|
||||||
|
共 5195 个商品 · 第 1/260 页 [首页] [上一页] [下一页] [末页]
|
||||||
|
```
|
||||||
|
|
||||||
|
筛选后(例如状态选了"待补规格"):
|
||||||
|
|
||||||
|
```text
|
||||||
|
待补规格:6 个商品 · 第 1/1 页 [首页] [上一页] [下一页] [末页]
|
||||||
|
```
|
||||||
|
|
||||||
|
`[必须]` 分页与状态条的通用规则见 §3.2,不在这里重复。这里只强调
|
||||||
|
蝦皮页特有的一点:导入完成后状态条**显示导入结果**("导入完成:N 个商品 /
|
||||||
|
M 个 SKU,K 行解析失败"),覆盖掉正常的统计文案,并且回到第 1 页、清空筛选——
|
||||||
|
刚导入的数据从第一页就能看到,沿用旧筛选反而可能让人以为导入没生效。
|
||||||
|
|
||||||
## 5. PDD 商品页
|
## 5. PDD 商品页
|
||||||
|
|
||||||
页面 `objectName` 为 `pddProductPage`,路由 `/pdd`。
|
页面 `objectName` 为 `pddProductPage`,路由 `/pdd`。
|
||||||
|
|||||||
Reference in New Issue
Block a user