feat: 蝦皮数据页改为商品级列表与规格弹窗 (#41)
原来是 SKU 级一行,问题不在行数(6092→5195 只少 15%),在语义错了: pdd_goods_url / pdd_goods_id 在 shopee_products 上,是商品级字段。 商品 24420648774 有 62 个 SKU,列表里 62 行显示同一个 PDD 链接, 将来双击任意一行编辑的也是同一个字段,操作员会以为改的是这一行。 加了两列,都是防止聚合后丢信息: 「SKU 数」—— 只显示颜色数和尺码数会严重误导。实测 602 个商品(11.6%) 的颜色数×尺码数大于实际 SKU 数,最悬殊的 26886533818 是 15×5=75 但 实际只有 28 个。蝦皮报表只含有销售成绩的 SKU,数据本来就不全。 「待补」—— 原来解析失败的行整行标黄,聚合成数量后这个信号会丢。 23 条失败分布在 6 个商品里,其中 4 个是「部分失败」:数字看着正常, 坏数据被吃掉,永远没人去补。现在待补>0 整行标黄。 颜色数/尺码数只统计 parse_ok=1,否则空值会被算进 DISTINCT 多出 一个「空颜色」。 弹窗只读,待补的行显示 spec_raw 原文——不显示的话人工不知道该填什么, 保留原文这个设计就白做了。复用 #18 的弹窗机制,app.js 未改动。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -15,12 +16,39 @@ import (
|
||||
|
||||
// ShopeeList 渲染蝦皮数据列表页。
|
||||
//
|
||||
// 表格按 SKU 展开显示,数据来自 shopee_products 和 shopee_skus 联查。
|
||||
// 列定义见 docs/admin/05-ui-specification.md §4.2。
|
||||
// **商品级一行**(不是 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)
|
||||
}
|
||||
|
||||
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
|
||||
//
|
||||
// 复用 #18 的弹窗机制:行上写 data-detail-id="{{.GoodsID}}",
|
||||
// 前端拿 goods_id 拼到 data-detail-url 后面,以 "id" 作为查询参数名
|
||||
// (固定写法,见 static/js/app.js「双击行打开详情弹窗」)。
|
||||
//
|
||||
// `[必须]` 这个弹窗只读,不含保存表单——ShopeeSave 仍是 501,见 #41。
|
||||
func (h *Handler) ShopeeDetail(c *gin.Context) {
|
||||
goodsID := strings.TrimSpace(c.Query("id"))
|
||||
if goodsID == "" {
|
||||
fail(c, http.StatusBadRequest, "商品编号不对,请刷新页面后重试。")
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := service.GetShopeeProductDetail(h.db, goodsID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "读取商品详情失败,数据没有被改动。")
|
||||
return
|
||||
}
|
||||
if detail == nil {
|
||||
fail(c, http.StatusNotFound, "这个商品不存在,请刷新页面。")
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{"D": detail})
|
||||
}
|
||||
|
||||
// ShopeeImport 处理 Excel 上传导入。
|
||||
//
|
||||
// 关键规则(写错会丢数据,见 docs/admin/03-data-model.md §3.3):
|
||||
@@ -109,7 +137,7 @@ func (h *Handler) ShopeeImport(c *gin.Context) {
|
||||
|
||||
// renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。
|
||||
func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures []service.ImportFailure) {
|
||||
result, err := service.ListShopeeSKUs(h.db, keyword)
|
||||
result, err := service.ListShopeeProducts(h.db, keyword)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError,
|
||||
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||
|
||||
@@ -44,6 +44,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
||||
|
||||
// 1. 蝦皮数据
|
||||
pages.GET("/shopee", h.ShopeeList)
|
||||
pages.GET("/shopee/detail", h.ShopeeDetail) // 双击行时前端来取弹窗内容
|
||||
pages.POST("/shopee/import", h.ShopeeImport)
|
||||
pages.POST("/shopee/save", h.ShopeeSave)
|
||||
pages.POST("/shopee/delete", h.ShopeeDelete)
|
||||
|
||||
+128
-32
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -77,65 +78,78 @@ func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShopeeSKURow 是列表页要的一行:SKU 本身,联查出商品标题和采集状态。
|
||||
// ShopeeProductRow 是列表页要的一行:**商品级**聚合数据,联查出采集状态。
|
||||
//
|
||||
// `pdd_goods_url` / `pdd_goods_id` 挂在 shopee_products 上,是商品级字段——
|
||||
// 一行对应一个商品,不是一个 SKU,见工单 #41。
|
||||
//
|
||||
// 颜色数 / 尺码数只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41):
|
||||
// 把 parse_ok = 0 的空颜色/空尺码算进 DISTINCT 会多出一个"空值"选项。
|
||||
// SKUCount 是这个商品报表里出现过的全部 SKU 数(不管解析成功与否)——
|
||||
// 蝦皮报表只含有销售成绩的 SKU,这个数天然会小于"颜色数 × 尺码数",
|
||||
// 加这一列就是为了防止操作员把两者乘出来的数当成实际规格数(见 #41)。
|
||||
// PendingCount 是 parse_ok = 0 的 SKU 数,即"待补",> 0 时列表整行标黄。
|
||||
//
|
||||
// 采集状态挂在 pdd_products 上,不在蝦皮这边,见
|
||||
// docs/admin/01-requirements.md §6.1。CollectStatus / CollectMsg 为
|
||||
// NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。
|
||||
type ShopeeSKURow struct {
|
||||
model.ShopeeSKU
|
||||
ProductTitle string
|
||||
PddGoodsURL string
|
||||
PddGoodsID string
|
||||
type ShopeeProductRow struct {
|
||||
model.ShopeeProduct
|
||||
ColorCount int
|
||||
SizeCount int
|
||||
SKUCount int
|
||||
PendingCount int
|
||||
CollectStatus sql.NullString
|
||||
CollectMsg sql.NullString
|
||||
}
|
||||
|
||||
// ListShopeeSKUs 按蝦皮商品 ID 关键字查 SKU 列表(联查商品标题和采集状态)。
|
||||
// ListShopeeProducts 按关键字查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
|
||||
//
|
||||
// keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**——
|
||||
// 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。
|
||||
// keyword 为空表示不筛选。
|
||||
func ListShopeeSKUs(q Execer, keyword string) ([]ShopeeSKURow, error) {
|
||||
func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) {
|
||||
sqlText := `
|
||||
SELECT sk.sku_id, sk.goods_id, sk.spec_raw, sk.color, sk.size, sk.advice,
|
||||
sk.parse_ok, sk.sku_code, sk.is_manual, sk.created_at, sk.updated_at,
|
||||
sp.title, sp.pdd_goods_url, sp.pdd_goods_id,
|
||||
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,
|
||||
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.color END) AS color_count,
|
||||
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.size END) AS size_count,
|
||||
COUNT(sk.sku_id) AS sku_count,
|
||||
COUNT(CASE WHEN sk.parse_ok = 0 THEN 1 END) AS pending_count,
|
||||
pp.collect_status, pp.collect_msg
|
||||
FROM shopee_skus sk
|
||||
JOIN shopee_products sp ON sp.goods_id = sk.goods_id
|
||||
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 != "" {
|
||||
sqlText += ` WHERE sk.goods_id LIKE ? ESCAPE '\'`
|
||||
args = append(args, "%"+escapeLike(keyword)+"%")
|
||||
like := "%" + escapeLike(keyword) + "%"
|
||||
sqlText += ` WHERE sp.goods_id LIKE ? ESCAPE '\' OR sp.title LIKE ? ESCAPE '\'`
|
||||
args = append(args, like, like)
|
||||
}
|
||||
sqlText += ` ORDER BY sk.goods_id, sk.sku_id`
|
||||
sqlText += ` GROUP BY sp.goods_id ORDER BY sp.goods_id`
|
||||
|
||||
rows, err := q.Query(sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询蝦皮 SKU 列表失败: %w", err)
|
||||
return nil, fmt.Errorf("查询蝦皮商品列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []ShopeeSKURow
|
||||
var list []ShopeeProductRow
|
||||
for rows.Next() {
|
||||
var r ShopeeSKURow
|
||||
var color, size, advice, skuCode sql.NullString
|
||||
var parseOK, isManual int
|
||||
var pddGoodsURL, pddGoodsID sql.NullString
|
||||
var r ShopeeProductRow
|
||||
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
|
||||
if err := rows.Scan(
|
||||
&r.SKUID, &r.GoodsID, &r.SpecRaw, &color, &size, &advice,
|
||||
&parseOK, &skuCode, &isManual, &r.CreatedAt, &r.UpdatedAt,
|
||||
&r.ProductTitle, &pddGoodsURL, &pddGoodsID,
|
||||
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode,
|
||||
&pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt,
|
||||
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
|
||||
&r.CollectStatus, &r.CollectMsg,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("读取蝦皮 SKU 列表失败: %w", err)
|
||||
return nil, fmt.Errorf("读取蝦皮商品列表失败: %w", err)
|
||||
}
|
||||
r.Color = color.String
|
||||
r.Size = size.String
|
||||
r.Advice = advice.String
|
||||
r.SKUCode = skuCode.String
|
||||
r.ParseOK = parseOK != 0
|
||||
r.IsManual = isManual != 0
|
||||
r.Title = title.String
|
||||
r.ShopeeStatus = shopeeStatus.String
|
||||
r.MainSKUCode = mainSKUCode.String
|
||||
r.PddGoodsURL = pddGoodsURL.String
|
||||
r.PddGoodsID = pddGoodsID.String
|
||||
list = append(list, r)
|
||||
@@ -143,6 +157,88 @@ func ListShopeeSKUs(q Execer, keyword string) ([]ShopeeSKURow, error) {
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// GetShopeeProductByGoodsID 按 goods_id 查一条蝦皮商品,不带聚合。
|
||||
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
|
||||
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
|
||||
var p model.ShopeeProduct
|
||||
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
|
||||
err := q.QueryRow(`
|
||||
SELECT goods_id, title, shopee_status, main_sku_code,
|
||||
pdd_goods_url, pdd_goods_id, created_at, updated_at
|
||||
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
|
||||
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode,
|
||||
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询蝦皮商品 %s 失败: %w", goodsID, err)
|
||||
}
|
||||
p.Title = title.String
|
||||
p.ShopeeStatus = shopeeStatus.String
|
||||
p.MainSKUCode = mainSKUCode.String
|
||||
p.PddGoodsURL = pddGoodsURL.String
|
||||
p.PddGoodsID = pddGoodsID.String
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetShopeeCollectStatus 查一个 PDD 商品的采集状态和失败原因。
|
||||
// pddGoodsID 为空(蝦皮商品还没填链接)或查不到对应的 pdd_products 行时,
|
||||
// 返回的两个 sql.NullString 都是 Valid=false,调用方按"未填链接"/"PDD 商品缺失"处理。
|
||||
func GetShopeeCollectStatus(q Execer, pddGoodsID string) (status, msg sql.NullString, err error) {
|
||||
if pddGoodsID == "" {
|
||||
return status, msg, nil
|
||||
}
|
||||
err = q.QueryRow(`
|
||||
SELECT collect_status, collect_msg FROM pdd_products
|
||||
WHERE goods_id = ? AND deleted_at IS NULL`, pddGoodsID).Scan(&status, &msg)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return sql.NullString{}, sql.NullString{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return status, msg, fmt.Errorf("查询 PDD 商品 %s 采集状态失败: %w", pddGoodsID, err)
|
||||
}
|
||||
return status, msg, nil
|
||||
}
|
||||
|
||||
// ListShopeeSKUsByGoodsID 查一个商品的完整规格表,**含解析失败的行**。
|
||||
//
|
||||
// 弹窗要显示全部规格(不管待补与否),所以这里不按 parse_ok 过滤,
|
||||
// 见 #41「弹窗里待补的行必须显示 spec_raw 原文」。
|
||||
func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error) {
|
||||
rows, err := q.Query(`
|
||||
SELECT sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
|
||||
is_manual, created_at, updated_at
|
||||
FROM shopee_skus
|
||||
WHERE goods_id = ?
|
||||
ORDER BY sku_id`, goodsID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询蝦皮商品 %s 的规格失败: %w", goodsID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []model.ShopeeSKU
|
||||
for rows.Next() {
|
||||
var sk model.ShopeeSKU
|
||||
var color, size, advice, skuCode sql.NullString
|
||||
var parseOK, isManual int
|
||||
if err := rows.Scan(
|
||||
&sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice,
|
||||
&parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("读取蝦皮商品 %s 的规格失败: %w", goodsID, err)
|
||||
}
|
||||
sk.Color = color.String
|
||||
sk.Size = size.String
|
||||
sk.Advice = advice.String
|
||||
sk.SKUCode = skuCode.String
|
||||
sk.ParseOK = parseOK != 0
|
||||
sk.IsManual = isManual != 0
|
||||
list = append(list, sk)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
|
||||
func CountShopeeProducts(q Execer) (int, error) {
|
||||
var n int
|
||||
|
||||
+131
-30
@@ -10,19 +10,26 @@ import (
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
// ShopeeSKUView 是列表页一行要显示的全部内容,全部已经是字符串,
|
||||
// ShopeeProductView 是列表页一行要显示的全部内容,全部已经是字符串,
|
||||
// 模板里不做判断和格式化。
|
||||
type ShopeeSKUView struct {
|
||||
//
|
||||
// 一行对应一个**商品**,不是一个 SKU——见工单 #41。
|
||||
type ShopeeProductView struct {
|
||||
GoodsID string
|
||||
Title string
|
||||
SKUID string
|
||||
|
||||
Color string
|
||||
Size string
|
||||
Advice string
|
||||
// ParseFailed 为 true 时整行标黄,提示需要人工补颜色/尺码/建议,
|
||||
// 见 docs/admin/05-ui-specification.md §4.2。
|
||||
ParseFailed bool
|
||||
// ColorCount / SizeCount 只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41)。
|
||||
ColorCount int
|
||||
SizeCount int
|
||||
// SKUCount 是这个商品报表里实际出现过的 SKU 数(不管解析成功与否)。
|
||||
// `[必须]` 必须单独展示:蝦皮报表只含有销售成绩的 SKU,
|
||||
// 颜色数 × 尺码数经常大于 SKUCount,只显示前两者会严重误导操作员,
|
||||
// 让人以为要匹配的规格比实际多得多(实测最悬殊相差 2.7 倍,见 #41)。
|
||||
SKUCount int
|
||||
// PendingCount 是 parse_ok = 0 的 SKU 数,即"待补"。
|
||||
// `[必须]` > 0 时整行标黄(沿用 .row-warn)——聚合成商品级之后,
|
||||
// "部分失败"的商品数字看起来完全正常,坏数据会被吃掉,见 #41。
|
||||
PendingCount int
|
||||
|
||||
// PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红——
|
||||
// 这是最需要操作员注意的状态,见 §4.2。
|
||||
@@ -30,26 +37,25 @@ type ShopeeSKUView struct {
|
||||
PddMissing bool
|
||||
|
||||
StatusText string
|
||||
IsManual bool
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// ShopeeListResult 是列表页要的全部数据。
|
||||
type ShopeeListResult struct {
|
||||
Rows []ShopeeSKUView
|
||||
Rows []ShopeeProductView
|
||||
Total int // shopee_products 的总商品数(不受筛选影响),用于判断"是否已导入过"
|
||||
IsFiltered bool
|
||||
}
|
||||
|
||||
// ListShopeeSKUs 查蝦皮 SKU 列表并把每一行翻成界面文字。
|
||||
// ListShopeeProducts 查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
|
||||
//
|
||||
// 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段,
|
||||
// 见 docs/admin/01-requirements.md §6.1:
|
||||
//
|
||||
// shopee_products.pdd_goods_id 为空 -> "未填链接"
|
||||
// pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败
|
||||
func ListShopeeSKUs(db *sql.DB, keyword string) (*ShopeeListResult, error) {
|
||||
rows, err := repository.ListShopeeSKUs(db, keyword)
|
||||
func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
|
||||
rows, err := repository.ListShopeeProducts(db, keyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -59,26 +65,19 @@ func ListShopeeSKUs(db *sql.DB, keyword string) (*ShopeeListResult, error) {
|
||||
}
|
||||
|
||||
result := &ShopeeListResult{
|
||||
Rows: make([]ShopeeSKUView, 0, len(rows)),
|
||||
Rows: make([]ShopeeProductView, 0, len(rows)),
|
||||
Total: total,
|
||||
IsFiltered: keyword != "",
|
||||
}
|
||||
for _, r := range rows {
|
||||
v := ShopeeSKUView{
|
||||
GoodsID: r.GoodsID,
|
||||
Title: r.ProductTitle,
|
||||
SKUID: r.SKUID,
|
||||
IsManual: r.IsManual,
|
||||
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||||
}
|
||||
if r.ParseOK {
|
||||
v.Color, v.Size, v.Advice = r.Color, r.Size, r.Advice
|
||||
if v.Advice == "" {
|
||||
v.Advice = placeholder
|
||||
}
|
||||
} else {
|
||||
v.Color, v.Size, v.Advice = placeholder, placeholder, placeholder
|
||||
v.ParseFailed = true
|
||||
v := ShopeeProductView{
|
||||
GoodsID: r.GoodsID,
|
||||
Title: r.Title,
|
||||
ColorCount: r.ColorCount,
|
||||
SizeCount: r.SizeCount,
|
||||
SKUCount: r.SKUCount,
|
||||
PendingCount: r.PendingCount,
|
||||
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||||
}
|
||||
|
||||
if r.PddGoodsID == "" {
|
||||
@@ -100,3 +99,105 @@ func ListShopeeSKUs(db *sql.DB, keyword string) (*ShopeeListResult, error) {
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---------- 弹窗 ----------
|
||||
|
||||
// ShopeeSpecView 是弹窗规格表的一行。
|
||||
type ShopeeSpecView struct {
|
||||
SKUID string
|
||||
Color string
|
||||
Size string
|
||||
Advice string
|
||||
// StatusText 是"✓"或"待补",`[必须]` 要有文字,不能只靠颜色区分(见 #41)。
|
||||
StatusText string
|
||||
// Pending 为 true 时模板整行标黄,并在下面加一行显示 SpecRaw 原文。
|
||||
Pending bool
|
||||
// SpecRaw 只在 Pending 为 true 时才有意义。
|
||||
// `[必须]` 待补的行必须显示规格原文——不显示的话人工不知道该填什么,
|
||||
// 这正是保留 spec_raw 这个设计的全部意义,见 #41。
|
||||
SpecRaw string
|
||||
}
|
||||
|
||||
// ShopeeProductDetail 是双击弹窗要显示的全部内容。
|
||||
//
|
||||
// `[必须]` 这个弹窗只读,不含可提交的表单——本工单不实现保存,
|
||||
// ShopeeSave 仍是 501,见 #41。
|
||||
type ShopeeProductDetail struct {
|
||||
GoodsID string
|
||||
Title string
|
||||
ShopeeStatus string
|
||||
MainSKUCode string
|
||||
|
||||
PddURL string
|
||||
PddMissing bool
|
||||
StatusText string
|
||||
|
||||
SKUs []ShopeeSpecView
|
||||
PendingCount int
|
||||
}
|
||||
|
||||
// GetShopeeProductDetail 读一个蝦皮商品的详情(商品信息 + 完整规格表)。
|
||||
// 商品不存在返回 (nil, nil)。
|
||||
func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, error) {
|
||||
p, err := repository.GetShopeeProductByGoodsID(db, goodsID)
|
||||
if err != nil || p == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := &ShopeeProductDetail{
|
||||
GoodsID: p.GoodsID,
|
||||
Title: p.Title,
|
||||
ShopeeStatus: p.ShopeeStatus,
|
||||
MainSKUCode: p.MainSKUCode,
|
||||
}
|
||||
if d.Title == "" {
|
||||
d.Title = placeholder
|
||||
}
|
||||
if d.ShopeeStatus == "" {
|
||||
d.ShopeeStatus = placeholder
|
||||
}
|
||||
if d.MainSKUCode == "" {
|
||||
d.MainSKUCode = placeholder
|
||||
}
|
||||
|
||||
if p.PddGoodsID == "" {
|
||||
d.PddMissing = true
|
||||
d.PddURL = "未填写"
|
||||
d.StatusText = "未填链接"
|
||||
} else {
|
||||
d.PddURL = p.PddGoodsURL
|
||||
status, _, err := repository.GetShopeeCollectStatus(db, p.PddGoodsID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status.Valid {
|
||||
d.StatusText = collectStatusText(model.CollectStatus(status.String))
|
||||
} else {
|
||||
d.StatusText = "PDD 商品缺失"
|
||||
}
|
||||
}
|
||||
|
||||
skus, err := repository.ListShopeeSKUsByGoodsID(db, goodsID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.SKUs = make([]ShopeeSpecView, 0, len(skus))
|
||||
for _, sk := range skus {
|
||||
row := ShopeeSpecView{SKUID: sk.SKUID}
|
||||
if sk.ParseOK {
|
||||
row.Color, row.Size, row.Advice = sk.Color, sk.Size, sk.Advice
|
||||
if row.Advice == "" {
|
||||
row.Advice = placeholder
|
||||
}
|
||||
row.StatusText = "✓"
|
||||
} else {
|
||||
row.Color, row.Size, row.Advice = placeholder, placeholder, placeholder
|
||||
row.StatusText = "待补"
|
||||
row.Pending = true
|
||||
row.SpecRaw = sk.SpecRaw
|
||||
d.PendingCount++
|
||||
}
|
||||
d.SKUs = append(d.SKUs, row)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
// seedShopeeProduct 建一个蝦皮商品,goods_id / title 之外不填别的。
|
||||
func seedShopeeProduct(t *testing.T, db *sql.DB, goodsID, title string) {
|
||||
t.Helper()
|
||||
if err := repository.UpsertShopeeProduct(db, goodsID, title, "正常", "货号"+goodsID); err != nil {
|
||||
t.Fatalf("建蝦皮商品 %s 失败: %v", goodsID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedShopeeSKU 建一条蝦皮 SKU。specRaw 只在 parseOK=false 时用于校验回显。
|
||||
func seedShopeeSKU(t *testing.T, db *sql.DB, skuID, goodsID, specRaw, color, size, advice string, parseOK bool) {
|
||||
t.Helper()
|
||||
if err := repository.UpsertShopeeSKU(db, skuID, goodsID, specRaw, color, size, advice, parseOK, "sku-"+skuID); err != nil {
|
||||
t.Fatalf("建蝦皮 SKU %s 失败: %v", skuID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// setShopeePddLink 直接写 shopee_products.pdd_goods_url / pdd_goods_id,
|
||||
// 模拟"已经填过 PDD 链接"。ShopeeSave 本工单不实现,测试直接写库代替。
|
||||
func setShopeePddLink(t *testing.T, db *sql.DB, goodsID, pddGoodsID, pddURL string) {
|
||||
t.Helper()
|
||||
_, err := db.Exec(
|
||||
`UPDATE shopee_products SET pdd_goods_id = ?, pdd_goods_url = ? WHERE goods_id = ?`,
|
||||
pddGoodsID, pddURL, goodsID)
|
||||
if err != nil {
|
||||
t.Fatalf("写蝦皮商品 %s 的 PDD 链接失败: %v", goodsID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 列表:商品级聚合 ──────────────────────────────────
|
||||
|
||||
func TestListShopeeProducts_商品级一行(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
// 商品 24420648774 在真实样本里有 62 个 SKU,这里用 3 个模拟同样的
|
||||
// "一个商品多个 SKU 但只应该出现一行"的场景,见工单 #41。
|
||||
seedShopeeProduct(t, db, "24420648774", "吊帶背心")
|
||||
seedShopeeSKU(t, db, "sku-1", "24420648774", "", "黑色", "M", "40-50公斤", true)
|
||||
seedShopeeSKU(t, db, "sku-2", "24420648774", "", "黑色", "L", "50-60公斤", true)
|
||||
seedShopeeSKU(t, db, "sku-3", "24420648774", "", "白色", "M", "40-50公斤", true)
|
||||
|
||||
result, err := ListShopeeProducts(db, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if len(result.Rows) != 1 {
|
||||
t.Fatalf("行数 = %d,想要 1(商品级一行,不是 SKU 级)", len(result.Rows))
|
||||
}
|
||||
row := result.Rows[0]
|
||||
if row.SKUCount != 3 {
|
||||
t.Errorf("SKUCount = %d,想要 3", row.SKUCount)
|
||||
}
|
||||
if row.ColorCount != 2 {
|
||||
t.Errorf("ColorCount = %d,想要 2(黑/白)", row.ColorCount)
|
||||
}
|
||||
if row.SizeCount != 2 {
|
||||
t.Errorf("SizeCount = %d,想要 2(M/L)", row.SizeCount)
|
||||
}
|
||||
if row.PendingCount != 0 {
|
||||
t.Errorf("PendingCount = %d,想要 0", row.PendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListShopeeProducts_颜色尺码只统计parse_ok(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
// 模拟工单里 26886533818 的情形:颜色数 × 尺码数会大于实际 SKU 数,
|
||||
// 而且有一部分 SKU 解析失败——它们不能被算进颜色/尺码的 DISTINCT。
|
||||
seedShopeeProduct(t, db, "26886533818", "測試商品")
|
||||
seedShopeeSKU(t, db, "s1", "26886533818", "", "咖啡色", "2XL", "", true)
|
||||
seedShopeeSKU(t, db, "s2", "26886533818", "", "咖啡色", "3XL", "", true)
|
||||
seedShopeeSKU(t, db, "s3", "26886533818", "", "紅色", "M", "", true)
|
||||
// 解析失败的两条:spec_raw 保留,color/size 为空,不该被 DISTINCT 算进去。
|
||||
seedShopeeSKU(t, db, "s4", "26886533818", "紅色,3XL建議80-90公斤】", "", "", "", false)
|
||||
seedShopeeSKU(t, db, "s5", "26886533818", "粉色,3XL【寬鬆版 82.5-92.5kg", "", "", "", false)
|
||||
|
||||
result, err := ListShopeeProducts(db, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if len(result.Rows) != 1 {
|
||||
t.Fatalf("行数 = %d,想要 1", len(result.Rows))
|
||||
}
|
||||
row := result.Rows[0]
|
||||
if row.SKUCount != 5 {
|
||||
t.Errorf("SKUCount = %d,想要 5(含 2 条解析失败的)", row.SKUCount)
|
||||
}
|
||||
if row.ColorCount != 2 {
|
||||
t.Errorf("ColorCount = %d,想要 2(咖啡色/紅色,不含解析失败的空颜色)", row.ColorCount)
|
||||
}
|
||||
if row.SizeCount != 3 {
|
||||
t.Errorf("SizeCount = %d,想要 3(2XL/3XL/M,不含解析失败的空尺码)", row.SizeCount)
|
||||
}
|
||||
if row.PendingCount != 2 {
|
||||
t.Errorf("PendingCount = %d,想要 2", row.PendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListShopeeProducts_待补大于0整行标黄(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
seedShopeeProduct(t, db, "1001", "商品A")
|
||||
seedShopeeSKU(t, db, "s1", "1001", "坏数据", "", "", "", false)
|
||||
|
||||
result, err := ListShopeeProducts(db, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if result.Rows[0].PendingCount != 1 {
|
||||
t.Fatalf("PendingCount = %d,想要 1", result.Rows[0].PendingCount)
|
||||
}
|
||||
// 整行标黄由模板按 PendingCount > 0 判断,这里只验证数据源正确。
|
||||
}
|
||||
|
||||
func TestListShopeeProducts_PDD链接未填写(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
seedShopeeProduct(t, db, "1001", "商品A")
|
||||
|
||||
result, err := ListShopeeProducts(db, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
row := result.Rows[0]
|
||||
if !row.PddMissing || row.PddURL != "未填写" {
|
||||
t.Errorf("PddMissing/PddURL = %v/%q,想要 true/\"未填写\"", row.PddMissing, row.PddURL)
|
||||
}
|
||||
if row.StatusText != "未填链接" {
|
||||
t.Errorf("StatusText = %q,想要 \"未填链接\"", row.StatusText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListShopeeProducts_采集状态联查(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
seedShopeeProduct(t, db, "1001", "商品A")
|
||||
|
||||
if _, err := repository.EnsurePddProduct(db, "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001"); err != nil {
|
||||
t.Fatalf("建 PDD 商品失败: %v", err)
|
||||
}
|
||||
if err := repository.SetCollectResult(db, "9001", "PDD标题", "店铺", "{}"); err != nil {
|
||||
t.Fatalf("设置采集结果失败: %v", err)
|
||||
}
|
||||
setShopeePddLink(t, db, "1001", "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001")
|
||||
|
||||
result, err := ListShopeeProducts(db, "")
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
row := result.Rows[0]
|
||||
if row.PddMissing {
|
||||
t.Error("已填链接,PddMissing 应为 false")
|
||||
}
|
||||
if row.StatusText != "已采集" {
|
||||
t.Errorf("StatusText = %q,想要 \"已采集\"", row.StatusText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺码(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
seedShopeeProduct(t, db, "1001", "吊帶背心")
|
||||
seedShopeeProduct(t, db, "1002", "短袖T恤")
|
||||
seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "", true)
|
||||
|
||||
// 按商品 ID 匹配
|
||||
result, err := ListShopeeProducts(db, "1001")
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if len(result.Rows) != 1 || result.Rows[0].GoodsID != "1001" {
|
||||
t.Fatalf("按商品 ID 搜索结果不对: %+v", result.Rows)
|
||||
}
|
||||
|
||||
// 按商品名称匹配
|
||||
result, err = ListShopeeProducts(db, "背心")
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if len(result.Rows) != 1 || result.Rows[0].GoodsID != "1001" {
|
||||
t.Fatalf("按商品名称搜索结果不对: %+v", result.Rows)
|
||||
}
|
||||
|
||||
// 颜色/尺码不参与匹配:搜"黑色"应该搜不到任何商品。
|
||||
result, err = ListShopeeProducts(db, "黑色")
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if len(result.Rows) != 0 {
|
||||
t.Fatalf("关键字不应匹配颜色,得到 %+v", result.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 弹窗详情 ──────────────────────────────────
|
||||
|
||||
func TestGetShopeeProductDetail_待补行显示原文(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
seedShopeeProduct(t, db, "1001", "商品A")
|
||||
seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "40-50公斤", true)
|
||||
seedShopeeSKU(t, db, "s2", "1001", "紅色,3XL建議80-90公斤】", "", "", "", false)
|
||||
|
||||
detail, err := GetShopeeProductDetail(db, "1001")
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if detail == nil {
|
||||
t.Fatal("详情为 nil")
|
||||
}
|
||||
if len(detail.SKUs) != 2 {
|
||||
t.Fatalf("SKUs 数量 = %d,想要 2(含解析失败的)", len(detail.SKUs))
|
||||
}
|
||||
if detail.PendingCount != 1 {
|
||||
t.Errorf("PendingCount = %d,想要 1", detail.PendingCount)
|
||||
}
|
||||
|
||||
var pending *ShopeeSpecView
|
||||
for i := range detail.SKUs {
|
||||
if detail.SKUs[i].Pending {
|
||||
pending = &detail.SKUs[i]
|
||||
}
|
||||
}
|
||||
if pending == nil {
|
||||
t.Fatal("没有找到待补的那一行")
|
||||
}
|
||||
if pending.SpecRaw != "紅色,3XL建議80-90公斤】" {
|
||||
t.Errorf("SpecRaw = %q,想要显示原文", pending.SpecRaw)
|
||||
}
|
||||
if pending.StatusText != "待补" {
|
||||
t.Errorf("StatusText = %q,想要 \"待补\"", pending.StatusText)
|
||||
}
|
||||
|
||||
for _, s := range detail.SKUs {
|
||||
if !s.Pending && s.StatusText != "✓" {
|
||||
t.Errorf("解析成功的行 StatusText = %q,想要 \"✓\"", s.StatusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShopeeProductDetail_商品不存在返回nil(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
detail, err := GetShopeeProductDetail(db, "not-exist")
|
||||
if err != nil {
|
||||
t.Fatalf("不应该报错: %v", err)
|
||||
}
|
||||
if detail != nil {
|
||||
t.Fatalf("不存在的商品应该返回 nil,得到 %+v", detail)
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,14 @@ tr.empty small { color: #aaa; }
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 蝦皮数据页商品名称列收窄到 30%,配合 .truncate 一起用(见工单 #41)。
|
||||
用独立类设 max-width,不用 flex——.search-narrow 那套是给工具条用的,
|
||||
表格单元格不是 flex 布局,flex 在这里不生效。放在 .truncate 之后,
|
||||
同优先级下按 CSS 层叠顺序覆盖 .truncate 的 280px。 */
|
||||
.col-title {
|
||||
max-width: 30%;
|
||||
}
|
||||
|
||||
/* ── 弹窗 ─────────────────────────────── */
|
||||
/* 用 hidden 属性控制显示隐藏,JS 只改这一个属性,不拼样式 */
|
||||
.modal-backdrop[hidden] { display: none; }
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{{define "shopee/detail_modal"}}
|
||||
{{/* 双击一行时弹出的内容。
|
||||
这是一个**片段**,不是整页——外面的弹窗壳子在 shopee/list.html 里。
|
||||
|
||||
`[必须]` 这个弹窗只读,没有保存表单——ShopeeSave 仍是 501,
|
||||
不要放一个点了没反应的保存按钮,见工单 #41。 */}}
|
||||
{{with .D}}
|
||||
<div class="modal-head">
|
||||
<h2>商品 {{.GoodsID}}</h2>
|
||||
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<dl class="detail">
|
||||
<dt>商品名称</dt><dd>{{.Title}}</dd>
|
||||
<dt>蝦皮状态</dt><dd>{{.ShopeeStatus}}</dd>
|
||||
<dt>主商品货号</dt><dd>{{.MainSKUCode}}</dd>
|
||||
{{/* PDD 链接本工单只读展示,不做保存,见 #41「不做」清单 */}}
|
||||
<dt>PDD 链接</dt>
|
||||
<dd{{if .PddMissing}} class="missing"{{end}}>{{.PddURL}}</dd>
|
||||
<dt>采集状态</dt><dd>{{.StatusText}}</dd>
|
||||
</dl>
|
||||
|
||||
<h3>
|
||||
规格({{len .SKUs}} 个{{if gt .PendingCount 0}},{{.PendingCount}} 个待人工补{{end}})
|
||||
</h3>
|
||||
|
||||
{{if not .SKUs}}
|
||||
<p class="missing">这个商品在报表里没有任何 SKU。</p>
|
||||
{{else}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>规格ID</th>
|
||||
<th>颜色</th>
|
||||
<th>尺码</th>
|
||||
<th>建議</th>
|
||||
{{/* 状态列要有文字(✓ / 待补),不能只靠颜色区分,见 #41 */}}
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .SKUs}}
|
||||
<tr{{if .Pending}} class="row-warn"{{end}}>
|
||||
<td>{{.SKUID}}</td>
|
||||
<td>{{.Color}}</td>
|
||||
<td>{{.Size}}</td>
|
||||
<td>{{.Advice}}</td>
|
||||
<td>{{.StatusText}}</td>
|
||||
</tr>
|
||||
{{if .Pending}}
|
||||
{{/* `[必须]` 待补的行要显示 spec_raw 原文——不显示的话人工不知道
|
||||
该填什么,这正是保留原文这个设计的全部意义,见 #41。 */}}
|
||||
<tr class="row-warn">
|
||||
<td colspan="5"><small>原文:{{.SpecRaw}}</small></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="modal-foot">
|
||||
<button type="button" data-modal-close>关闭</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -28,6 +28,8 @@
|
||||
</div>
|
||||
|
||||
{{/* ── 第二段:带勾选的表格 ──────────────────────────── */}}
|
||||
{{/* 一个商品一行(不是一个 SKU 一行),见工单 #41。
|
||||
双击行打开详情弹窗,见 static/js/app.js。 */}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -37,7 +39,14 @@
|
||||
<th>商品名称</th>
|
||||
<th>颜色</th>
|
||||
<th>尺码</th>
|
||||
<th>建议</th>
|
||||
{{/* SKU 数是这个商品报表里实际出现过的规格条数。
|
||||
`[必须]` 必须单独显示——颜色数 × 尺码数经常大于它(蝦皮报表只含
|
||||
有销售成绩的 SKU),只看前两列会让人误以为要匹配的规格更多,见 #41。 */}}
|
||||
<th>SKU</th>
|
||||
{{/* `[必须]` 待补 > 0 说明该商品有 SKU 解析失败,整行标黄(.row-warn)。
|
||||
聚合成商品级之后,"部分失败"的行光看数字是正常的,
|
||||
不加这一列坏数据就会被悄悄吃掉,见 #41。 */}}
|
||||
<th>待补</th>
|
||||
<th>PDD 链接</th>
|
||||
<th>采集状态</th>
|
||||
<th>更新时间</th>
|
||||
@@ -45,22 +54,25 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr{{if .ParseFailed}} class="row-warn"{{end}} title="SKU {{.SKUID}}">
|
||||
<tr data-detail-id="{{.GoodsID}}"{{if gt .PendingCount 0}} class="row-warn"{{end}}>
|
||||
<td class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}"></td>
|
||||
<td>{{.GoodsID}}</td>
|
||||
<td title="{{.Title}}">{{.Title}}{{if .IsManual}} <small>(人工新增)</small>{{end}}</td>
|
||||
<td>{{.Color}}</td>
|
||||
<td>{{.Size}}</td>
|
||||
<td>{{.Advice}}</td>
|
||||
{{/* 商品名称列收窄到 30%,用独立的 .col-title 类设 max-width,
|
||||
不用 flex——表格单元格和工具条的 .search-narrow 那套不一样,见 #41。 */}}
|
||||
<td class="truncate col-title" title="{{.Title}}">{{.Title}}</td>
|
||||
<td>{{.ColorCount}}</td>
|
||||
<td>{{.SizeCount}}</td>
|
||||
<td>{{.SKUCount}}</td>
|
||||
<td>{{.PendingCount}}</td>
|
||||
<td{{if .PddMissing}} class="missing"{{end}}>{{.PddURL}}</td>
|
||||
<td>{{.StatusText}}</td>
|
||||
<td>{{.UpdatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr class="empty">
|
||||
<td colspan="9">
|
||||
<td colspan="10">
|
||||
{{if .IsFiltered}}
|
||||
没有匹配的数据,换个商品 ID 试试。
|
||||
没有匹配的数据,换个商品 ID 或商品名称试试。
|
||||
{{else}}
|
||||
还没有数据,点左上角「导入 Excel」开始。<br>
|
||||
<small>样本文件不在仓库里,需向项目负责人索取,放到 raw_data/ 下。</small>
|
||||
@@ -72,6 +84,10 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
双击任意一行可以查看这个商品的完整规格表。
|
||||
</p>
|
||||
|
||||
{{/* ── 导入失败行:全部列出来,不折叠、不只显示条数 ──── */}}
|
||||
{{if .Failures}}
|
||||
<div class="table-wrap">
|
||||
@@ -97,15 +113,20 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* TODO(骨架): 编辑弹窗 templates/shopee/edit_modal.html
|
||||
这是从蝦皮商品出发录入 PDD 链接、发起采集的入口。
|
||||
采集按钮**在本页只出现在这个弹窗里**——不要放到表格每一行,
|
||||
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,
|
||||
还会建出 N 个重复任务。
|
||||
{{/* ── 详情弹窗的壳子 ───────────────────────────
|
||||
里面的内容双击行时由 /shopee/detail 返回,前端只负责放进来和显示,
|
||||
复用 #18 的机制(data-detail-url / data-detail-slot / data-detail-id),
|
||||
app.js 不需要改,见工单 #41。
|
||||
|
||||
PDD 商品页(templates/pdd/)也能录入链接和发起采集,两处并存;
|
||||
本页这个还是骨架(点了返回 501)。合并与否由
|
||||
「蝦皮↔PDD 关联入口」工单决定,不要在本页单独改。 */}}
|
||||
这个弹窗**只读**:商品信息 + 完整规格表,没有保存按钮。
|
||||
从蝦皮商品出发录入 PDD 链接、发起采集的入口(含 ShopeeSave/
|
||||
ShopeeCollect 的实现)由后续的「蝦皮↔PDD 关联入口」工单负责,
|
||||
本工单不做,ShopeeSave/ShopeeDelete/ShopeeCollect 仍是 501。 */}}
|
||||
<div class="modal-backdrop" id="detail-modal" data-detail-url="/shopee/detail" hidden>
|
||||
<div class="modal" role="dialog" aria-modal="true" data-detail-slot>
|
||||
<div class="modal-body">正在加载…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user