diff --git a/admin/handler/web/pdd.go b/admin/handler/web/pdd.go new file mode 100644 index 0000000..bd1b55b --- /dev/null +++ b/admin/handler/web/pdd.go @@ -0,0 +1,171 @@ +package web + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/gin-gonic/gin" + + "cmautobuy/admin/service" +) + +// PDD 商品页的处理器。 +// +// 本文件只做三件事:取参数 → 调 service → 渲染或跳转。 +// 不写业务判断,不拼 SQL——那些在 service/pdd.go 和 repository/pdd.go 里。 + +// PddList 渲染 PDD 商品列表页。 +func (h *Handler) PddList(c *gin.Context) { + keyword := c.Query("q") + status := service.ParseCollectStatus(c.Query("status")) + + result, err := service.ListPddProducts(h.db, keyword, status) + if err != nil { + fail(c, http.StatusInternalServerError, + "读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") + return + } + + // 底部状态条平时显示统计,刚做完操作时先显示操作结果。 + // 操作结果是通过跳转带过来的 msg 参数传的(见 pddRedirect)。 + statusLine := result.StatusLine() + if msg := c.Query("msg"); msg != "" { + statusLine = msg + " · " + statusLine + } + + c.HTML(http.StatusOK, "pdd/list", page(c, "pdd", "PDD 商品", gin.H{ + "Rows": result.Rows, + "Keyword": keyword, + "Status": statusLine, + "StatusFilter": string(status), + "StatusOptions": service.CollectStatusOptions(), + "HasAnyProducts": result.Total > 0, + "IsFiltered": result.IsFiltered, + })) +} + +// PddDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。 +// +// 弹窗内容由服务端渲染,前端只负责把它放进弹窗壳子里显示出来。 +// 不要改成"前端拿 JSON 自己拼表格"——规格表的维度顺序、 +// 价格格式、null 的处理都是业务规则,散到前端就没人维护得住了。 +func (h *Handler) PddDetail(c *gin.Context) { + id, err := strconv.ParseInt(c.Query("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "商品编号不对,请刷新页面后重试。") + return + } + + detail, err := service.GetPddProductDetail(h.db, id) + if err != nil { + fail(c, http.StatusInternalServerError, "读取商品详情失败,数据没有被改动。") + return + } + if detail == nil { + fail(c, http.StatusNotFound, "这个商品不存在或已被删除,请刷新页面。") + return + } + + c.HTML(http.StatusOK, "pdd/edit_modal", gin.H{ + "D": detail, + "CSRFToken": csrfToken(c), + }) +} + +// PddCreate 按链接创建一条 PDD 商品。 +// +// 只填链接,其余字段全靠采集回填。 +// 链接解析不出 goods_id 时**报错并且不写库**,见 service.ParsePddGoodsID。 +func (h *Handler) PddCreate(c *gin.Context) { + goodsID, err := service.CreatePddProduct(h.db, c.PostForm("url")) + if err != nil { + fail(c, http.StatusBadRequest, err.Error()+"。没有创建任何记录。") + return + } + h.pddRedirect(c, fmt.Sprintf("已保存商品 %s,现在可以勾选它创建采集任务", goodsID)) +} + +// PddSave 保存弹窗里改过的链接。 +func (h *Handler) PddSave(c *gin.Context) { + id, err := strconv.ParseInt(c.PostForm("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "商品编号不对,请刷新页面后重试。没有改动任何数据。") + return + } + if err := service.UpdatePddProductURL(h.db, id, c.PostForm("url")); err != nil { + fail(c, http.StatusBadRequest, err.Error()+"。没有改动任何数据。") + return + } + h.pddRedirect(c, "链接已保存") +} + +// PddCollect 为勾选的商品创建采集任务。 +// +// 按钮文案是「创建采集任务」而不是「采集」:它做的是建一个任务, +// 不是立刻去采。真正的采集要等客户端来领、去手机上跑, +// 可能几秒也可能几分钟。 +func (h *Handler) PddCollect(c *gin.Context) { + ids := c.PostFormArray("ids") + if len(ids) == 0 { + h.pddRedirect(c, "没有勾选任何商品,没有创建任务") + return + } + + created, skipped, err := service.CreatePddCollectTasks(h.db, ids) + if err != nil { + fail(c, http.StatusInternalServerError, + "创建采集任务失败:"+err.Error()+"。这一批任务整体没有创建,可以直接重试。") + return + } + + msg := fmt.Sprintf("已创建 %d 个采集任务,等待客户端领取", created) + if skipped > 0 { + // 跳过了几个必须说出来,否则操作员会以为都建上了,等半天没动静 + msg += fmt.Sprintf("(跳过 %d 个采集中或已删除的)", skipped) + } + h.pddRedirect(c, msg) +} + +// PddDelete 批量软删除。 +func (h *Handler) PddDelete(c *gin.Context) { + ids := c.PostFormArray("ids") + if len(ids) == 0 { + h.pddRedirect(c, "没有勾选任何商品,没有删除") + return + } + + n, err := service.DeletePddProducts(h.db, ids) + if err != nil { + fail(c, http.StatusInternalServerError, + "删除失败:"+err.Error()+"。没有删除任何记录。") + return + } + h.pddRedirect(c, fmt.Sprintf("已删除 %d 条", n)) +} + +// pddRedirect 处理完写操作后跳回列表页。 +// +// 用 303 跳转而不是直接渲染,是为了让浏览器地址栏变成 GET /pdd—— +// 否则用户按 F5 会重复提交刚才那个 POST(重复建任务、重复删除)。 +// +// 筛选条件一起带回去,免得操作员每做一次操作就要重新筛一遍。 +func (h *Handler) pddRedirect(c *gin.Context, msg string) { + params := url.Values{} + if s := c.PostForm("status"); s != "" { + params.Set("status", s) + } + if q := c.PostForm("q"); q != "" { + params.Set("q", q) + } + if msg != "" { + params.Set("msg", msg) + } + + target := "/pdd" + if len(params) > 0 { + target += "?" + params.Encode() + } + c.Redirect(http.StatusSeeOther, target) +} diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index 8ea8f90..a973970 100644 --- a/admin/handler/web/shopee.go +++ b/admin/handler/web/shopee.go @@ -57,10 +57,17 @@ func (h *Handler) ShopeeImport(c *gin.Context) { // ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。 // -// 这是 PDD 链接唯一的录入口,见 docs/admin/05-ui-specification.md §4.3。 +// 这是**从蝦皮商品出发**录入 PDD 链接的入口, +// 见 docs/admin/05-ui-specification.md §4.3。PDD 商品页(§5)也能录入, +// 两处并存;本页这个目前还是骨架,合并与否由「蝦皮↔PDD 关联入口」工单决定。 func (h *Handler) ShopeeSave(c *gin.Context) { - // TODO(骨架): 保存并根据 PDD 链接是否为空更新 collect_status - // (空 -> no_link,非空且未采集 -> pending) + // TODO(骨架): 保存 PDD 链接时调 repository.EnsurePddProduct, + // 由它去建 / 复用 / 复活 pdd_products 那一行。 + // + // **采集状态不在蝦皮这边**:它属于 PDD 商品(pdd_products.collect_status)。 + // "还没填链接"由 shopee_products.pdd_goods_id IS NULL 表达—— + // 没填链接时 pdd_products 里根本不会有那一行。 + // 不要写 no_link,那个值在 #16 已从 CHECK 约束里删掉了,写了会直接撞约束。 fail(c, http.StatusNotImplemented, "保存功能尚未实现。") } diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index f738f37..b7fee5c 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -26,9 +26,9 @@ type Handler struct { onlineThreshold time.Duration } -// Register 把四个模块的页面路由挂上去。 +// Register 把五个模块的页面路由挂上去。 // -// 四个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条), +// 五个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条), // 这是有意的,见 docs/admin/05-ui-specification.md §2。 func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { h := &Handler{db: db, onlineThreshold: onlineThreshold} @@ -49,18 +49,26 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { pages.POST("/shopee/delete", h.ShopeeDelete) pages.POST("/shopee/collect", h.ShopeeCollect) - // 2. 顺运宝数据 + // 2. PDD 商品 + pages.GET("/pdd", h.PddList) + pages.GET("/pdd/detail", h.PddDetail) // 双击行时前端来取弹窗内容 + pages.POST("/pdd/create", h.PddCreate) + pages.POST("/pdd/save", h.PddSave) + pages.POST("/pdd/collect", h.PddCollect) + pages.POST("/pdd/delete", h.PddDelete) + + // 3. 顺运宝数据 pages.GET("/syb", h.SybList) pages.POST("/syb/sync", h.SybSync) pages.POST("/syb/match", h.SybMatch) pages.POST("/syb/create-task", h.SybCreateTask) pages.POST("/syb/delete", h.SybDelete) - // 3. 采购任务 + // 4. 采购任务 pages.GET("/tasks", h.TaskList) pages.POST("/tasks/delete", h.TaskDelete) - // 4. 客户端列表 + // 5. 客户端列表 pages.GET("/clients", h.ClientList) pages.POST("/clients/delete", h.ClientDelete) } diff --git a/admin/repository/pdd.go b/admin/repository/pdd.go index d405bfc..e11647a 100644 --- a/admin/repository/pdd.go +++ b/admin/repository/pdd.go @@ -3,10 +3,48 @@ package repository import ( "database/sql" "fmt" + "strings" "cmautobuy/admin/model" ) +// pddColumns 是所有查询共用的列清单。 +// 写成常量是为了让下面几个查询的列顺序和 scanPddProduct 永远对得上—— +// 改列的时候只改这一处,改漏了会 Scan 到错的字段上,而且不报错。 +const pddColumns = `id, goods_id, url, title, skus_json, + collect_status, collect_msg, artifact_ref, collected_at, + deleted_at, created_at, updated_at` + +// rowScanner 让 *sql.Row 和 *sql.Rows 都能喂给 scanPddProduct。 +type rowScanner interface { + Scan(dest ...any) error +} + +// scanPddProduct 按 pddColumns 的顺序读一行。 +func scanPddProduct(s rowScanner, extra ...any) (*model.PddProduct, error) { + var p model.PddProduct + var title, skus, msg, artifact, collectedAt, deletedAt sql.NullString + + dest := []any{ + &p.ID, &p.GoodsID, &p.URL, &title, &skus, + &p.CollectStatus, &msg, &artifact, &collectedAt, + &deletedAt, &p.CreatedAt, &p.UpdatedAt, + } + dest = append(dest, extra...) + + if err := s.Scan(dest...); err != nil { + return nil, err + } + + p.Title = title.String + p.SkusJSON = skus.String + p.CollectMsg = msg.String + p.ArtifactRef = artifact.String + p.CollectedAt = collectedAt.String + p.DeletedAt = deletedAt.String + return &p, nil +} + // EnsurePddProduct 保证 goods_id 对应的 PDD 商品存在,返回它。 // // 操作员在货运单或蝦皮商品上填 PDD 链接、点保存时调它。三种情况: @@ -51,11 +89,15 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error) if existing.IsDeleted() { // 复活:清掉删除标记,状态回到待采集。 // 采集结果一并清空——记录被删过一次,旧数据不能再当成有效的用。 + // + // title 也要清:它是**采集回来的**(SetCollectResult 写的), + // 属于采集结果的一部分。留着的话状态显示"未采集"、标题却有值, + // 操作员会以为已经采过了。 _, err := q.Exec(` UPDATE pdd_products SET deleted_at = NULL, url = ?, collect_status = 'pending', - skus_json = NULL, collect_msg = NULL, artifact_ref = NULL, - collected_at = NULL, updated_at = ? + title = NULL, skus_json = NULL, collect_msg = NULL, + artifact_ref = NULL, collected_at = NULL, updated_at = ? WHERE goods_id = ?`, url, now, goodsID) if err != nil { @@ -82,31 +124,179 @@ func EnsurePddProduct(q Execer, goodsID, url string) (*model.PddProduct, error) // 之所以连删除的也查出来,是因为 EnsurePddProduct 要靠它判断该不该复活。 // 给界面用的查询请用 ListPddProducts,那个会过滤掉已删除的。 func GetPddProductByGoodsID(q Execer, goodsID string) (*model.PddProduct, error) { - var p model.PddProduct - var title, skus, msg, artifact, collectedAt, deletedAt sql.NullString - - err := q.QueryRow(` - SELECT id, goods_id, url, title, skus_json, - collect_status, collect_msg, artifact_ref, collected_at, - deleted_at, created_at, updated_at - FROM pdd_products WHERE goods_id = ?`, goodsID).Scan( - &p.ID, &p.GoodsID, &p.URL, &title, &skus, - &p.CollectStatus, &msg, &artifact, &collectedAt, - &deletedAt, &p.CreatedAt, &p.UpdatedAt) + p, err := scanPddProduct(q.QueryRow( + `SELECT `+pddColumns+` FROM pdd_products WHERE goods_id = ?`, goodsID)) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("查询 PDD 商品 %s 失败: %w", goodsID, err) } + return p, nil +} - p.Title = title.String - p.SkusJSON = skus.String - p.CollectMsg = msg.String - p.ArtifactRef = artifact.String - p.CollectedAt = collectedAt.String - p.DeletedAt = deletedAt.String - return &p, nil +// GetPddProductByID 按主键查一条,**已软删除的查不到**。 +// +// 弹窗用的是这个,不是 GetPddProductByGoodsID —— 后者连删掉的也返回, +// 那是给 EnsurePddProduct 判断复活用的,不能拿来渲染界面。 +func GetPddProductByID(q Execer, id int64) (*model.PddProduct, error) { + p, err := scanPddProduct(q.QueryRow( + `SELECT `+pddColumns+` + FROM pdd_products WHERE id = ? AND deleted_at IS NULL`, id)) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询 PDD 商品 #%d 失败: %w", id, err) + } + return p, nil +} + +// PddProductRow 是列表页要的一行:商品本身,外加数据库算好的规格数。 +type PddProductRow struct { + model.PddProduct + // SkuCount 是 skus_json 里的规格条数。 + // **-1 表示"还没有采集结果"**,和"采到 0 个规格"是两回事: + // 前者显示 —,后者是采集出了问题,要显示 0 让操作员看见。 + SkuCount int +} + +// 规格数交给 SQLite 算,不要把整个 skus_json 读进 Go 再数—— +// 列表页几十上百行,每行都反序列化一遍纯属浪费。 +// +// json_valid 那层判断不能省:skus_json 万一存进了坏数据, +// json_array_length 会让**整条查询报错**,页面直接打不开。 +const pddSkuCountExpr = ` + CASE WHEN skus_json IS NULL OR skus_json = '' OR NOT json_valid(skus_json) + THEN -1 + ELSE COALESCE(json_array_length(skus_json, '$.skus'), -1) + END` + +// ListPddProducts 查列表。keyword 匹配 goods_id 或链接,status 为空表示不筛选。 +// +// 只返回未删除的。软删除的记录还在库里(sku_mappings 指着它), +// 但界面上不该看见。 +func ListPddProducts(q Execer, keyword string, status model.CollectStatus) ([]PddProductRow, error) { + sqlText := `SELECT ` + pddColumns + `,` + pddSkuCountExpr + ` + FROM pdd_products + WHERE deleted_at IS NULL` + var args []any + + if keyword = strings.TrimSpace(keyword); keyword != "" { + // ESCAPE 不能省:操作员搜 "_" 时,LIKE 会把它当"任意一个字符", + // 结果是全部行都命中,看着像搜索坏了。 + pattern := "%" + escapeLike(keyword) + "%" + sqlText += ` AND (goods_id LIKE ? ESCAPE '\' OR url LIKE ? ESCAPE '\')` + args = append(args, pattern, pattern) + } + if status != "" { + sqlText += ` AND collect_status = ?` + args = append(args, string(status)) + } + // 刚动过的排前面,操作员点完"创建采集任务"一眼能看到结果 + sqlText += ` ORDER BY updated_at DESC, id DESC` + + rows, err := q.Query(sqlText, args...) + if err != nil { + return nil, fmt.Errorf("查询 PDD 商品列表失败: %w", err) + } + defer rows.Close() + + list := make([]PddProductRow, 0, 16) + for rows.Next() { + var r PddProductRow + p, err := scanPddProduct(rows, &r.SkuCount) + if err != nil { + return nil, fmt.Errorf("读取 PDD 商品列表失败: %w", err) + } + r.PddProduct = *p + list = append(list, r) + } + return list, rows.Err() +} + +// escapeLike 把 LIKE 的三个特殊字符转义掉,配合 ESCAPE '\' 使用。 +// 反斜杠必须第一个换,否则会把后面刚加的反斜杠又转义一遍。 +func escapeLike(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `%`, `\%`) + s = strings.ReplaceAll(s, `_`, `\_`) + return s +} + +// CountPddProductsByStatus 统计各采集状态各有多少条,供底部状态条显示。 +// +// 统计的是**全部未删除记录**,不受当前筛选影响—— +// 状态条是全局概览,"总共还有几个没采"这个数不该跟着筛选变。 +func CountPddProductsByStatus(q Execer) (map[model.CollectStatus]int, error) { + rows, err := q.Query(` + SELECT collect_status, COUNT(*) + FROM pdd_products + WHERE deleted_at IS NULL + GROUP BY collect_status`) + if err != nil { + return nil, fmt.Errorf("统计 PDD 商品状态失败: %w", err) + } + defer rows.Close() + + counts := map[model.CollectStatus]int{} + for rows.Next() { + var status string + var n int + if err := rows.Scan(&status, &n); err != nil { + return nil, err + } + counts[model.CollectStatus(status)] = n + } + return counts, rows.Err() +} + +// UpdatePddProductURL 只改链接原文,不动采集结果和状态。 +// +// 调用方必须先确认新链接解析出来的 goods_id 和这条记录的一致, +// 见 service.UpdatePddProductURL 的说明。 +func UpdatePddProductURL(q Execer, id int64, url string) error { + if url == "" { + return fmt.Errorf("url 不能为空") + } + res, err := q.Exec(` + UPDATE pdd_products SET url = ?, updated_at = ? + WHERE id = ? AND deleted_at IS NULL`, + url, model.NowISO(), id) + if err != nil { + return fmt.Errorf("更新 PDD 商品 #%d 链接失败: %w", id, err) + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("PDD 商品 #%d 不存在或已被删除", id) + } + return nil +} + +// SoftDeletePddProducts 批量软删除,返回实际删掉的条数。 +// +// 已经删过的不会重复计数(WHERE 里带了 deleted_at IS NULL), +// 所以返回值就是"这次真正消失的行数",可以直接报给操作员。 +func SoftDeletePddProducts(q Execer, goodsIDs []string) (int64, error) { + if len(goodsIDs) == 0 { + return 0, nil + } + // 占位符按数量生成,值仍然是参数化传入,不存在注入 + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(goodsIDs)), ",") + now := model.NowISO() + + args := make([]any, 0, len(goodsIDs)+2) + args = append(args, now, now) + for _, id := range goodsIDs { + args = append(args, id) + } + + res, err := q.Exec(` + UPDATE pdd_products SET deleted_at = ?, updated_at = ? + WHERE goods_id IN (`+placeholders+`) AND deleted_at IS NULL`, args...) + if err != nil { + return 0, fmt.Errorf("批量删除 PDD 商品失败: %w", err) + } + return res.RowsAffected() } // SetCollectResult 保存采集回来的 PDD 商品数据。 diff --git a/admin/repository/task.go b/admin/repository/task.go index 6cb7b87..0be6732 100644 --- a/admin/repository/task.go +++ b/admin/repository/task.go @@ -262,3 +262,27 @@ func MarkTaskFailure(q Execer, taskID string, newStatus model.TaskStatus, errCod } return nil } + +// InsertCollectTask 建一条采集任务。 +// +// `[必须]` 采集任务**不指定客户端**:assigned_client 为 NULL、status 为 pending, +// 谁领到就在领取时标记谁(见 #17 和 ClaimNextTask)。采集是纯读取操作, +// 哪台机器跑都一样,指定了反而会在那台机器关着的时候干等。 +// +// goodsURL 必填 —— Client 契约里 pdd_goods_url 是 NOT NULL, +// 没有它客户端拿到任务也不知道去哪采。 +func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error { + if goodsID == "" || goodsURL == "" { + return fmt.Errorf("采集任务的商品 ID 和链接都不能为空") + } + now := model.NowISO() + _, err := q.Exec(` + INSERT INTO tasks (task_id, task_type, status, assigned_client, + pdd_goods_url, pdd_goods_id, created_at, updated_at) + VALUES (?, 'collect', 'pending', NULL, ?, ?, ?, ?)`, + taskID, goodsURL, goodsID, now, now) + if err != nil { + return fmt.Errorf("创建商品 %s 的采集任务失败: %w", goodsID, err) + } + return nil +} diff --git a/admin/service/pdd.go b/admin/service/pdd.go new file mode 100644 index 0000000..9ca4620 --- /dev/null +++ b/admin/service/pdd.go @@ -0,0 +1,539 @@ +// PDD 商品模块的业务逻辑。 +// +// 这一层负责三件事,界面层和数据层都不做: +// - 从链接里解析 goods_id(解析不出就不许写库); +// - 把数据库里的原始字段翻成界面直接能显示的文字(状态、价格、时间); +// - 创建采集任务时的去重和跳过规则。 +// +// 改动前必读 admin/AGENTS.md 的分层约定:本层**不碰 HTTP,也不拼 SQL**。 +package service + +import ( + "database/sql" + "errors" + "fmt" + "net/url" + "sort" + "strings" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +// ErrBadPddURL 链接里解析不出 goods_id。 +// +// 这个错误必须让操作员看见,**不能容错兜底**:goods_id 上有 UNIQUE 约束, +// 防重全靠它。拿不到就没法查重,同一个商品会存成好几行, +// 采好几遍,规格映射还说不清指向哪一行。 +var ErrBadPddURL = errors.New("PDD 链接无效") + +// ErrPddGoodsIDChanged 编辑链接时换成了另一个商品。 +var ErrPddGoodsIDChanged = errors.New("新链接指向的是另一个商品") + +// ---------- 链接解析 ---------- + +// pddHosts 是认识的拼多多域名(含子域名)。 +// +// 卡域名是为了拦住"粘错了链接"这种常见失误——粘了淘宝链接却存进 PDD 表, +// 要等客户端跑到手机上才发现。 +var pddHosts = []string{"yangkeduo.com", "pinduoduo.com"} + +// goodsIDParams 是可能装着 goods_id 的查询参数名,按优先级排。 +var goodsIDParams = []string{"goods_id", "_x_goods_id"} + +// ParsePddGoodsID 从 PDD 商品链接里取出 goods_id。 +// +// 认这几种写法: +// +// https://mobile.yangkeduo.com/goods.html?goods_id=737116531267 +// https://mobile.yangkeduo.com/goods2.html?goods_id=737116531267&_x_xx=1 +// https://yangkeduo.com/duo_coupon_landing.html?_x_goods_id=737116531267 +// +// **短链(如 p.pinduoduo.com/xxxx)一律拒绝**,因为里面没有 goods_id。 +// 让操作员回 App 里复制完整链接,比在这里猜要安全得多。 +func ParsePddGoodsID(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("%w: 链接不能为空", ErrBadPddURL) + } + + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("%w: 这不是一个链接(%s)", ErrBadPddURL, raw) + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("%w: 链接要以 http:// 或 https:// 开头", ErrBadPddURL) + } + if !isPddHost(u.Hostname()) { + return "", fmt.Errorf( + "%w: %s 不是拼多多的网址,认得的是 yangkeduo.com 和 pinduoduo.com", + ErrBadPddURL, u.Hostname()) + } + + query := u.Query() + for _, name := range goodsIDParams { + if id := strings.TrimSpace(query.Get(name)); isGoodsID(id) { + return id, nil + } + } + return "", fmt.Errorf( + "%w: 链接里没有 goods_id。请在拼多多 App 的商品页点「分享」→「复制链接」,"+ + "拿到带 goods_id= 的完整链接,短链接不行", ErrBadPddURL) +} + +func isPddHost(host string) bool { + host = strings.ToLower(host) + for _, h := range pddHosts { + if host == h || strings.HasSuffix(host, "."+h) { + return true + } + } + return false +} + +// isGoodsID 判断是不是一串合理长度的纯数字。 +// 卡长度是为了拦住 goods_id=0 或者被截断的半截 ID。 +func isGoodsID(s string) bool { + if len(s) < 6 || len(s) > 24 { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// ---------- 界面文字 ---------- + +// collectStatusTexts 是采集状态的中文说法。 +// +// `[必须]` 界面上必须有文字,不能只用颜色区分—— +// 见 docs/admin/05-ui-specification.md §2。 +var collectStatusTexts = map[model.CollectStatus]string{ + model.CollectPending: "未采集", + model.CollectCollecting: "采集中", + model.CollectCollected: "已采集", + model.CollectFailed: "采集失败", +} + +// CollectStatusOption 是筛选下拉框的一项。 +type CollectStatusOption struct { + Value string // 空串表示"全部" + Text string +} + +// CollectStatusOptions 返回筛选下拉框的全部选项。 +func CollectStatusOptions() []CollectStatusOption { + return []CollectStatusOption{ + {"", "全部"}, + {string(model.CollectPending), collectStatusTexts[model.CollectPending]}, + {string(model.CollectCollecting), collectStatusTexts[model.CollectCollecting]}, + {string(model.CollectCollected), collectStatusTexts[model.CollectCollected]}, + {string(model.CollectFailed), collectStatusTexts[model.CollectFailed]}, + } +} + +// ParseCollectStatus 校验筛选参数。认不出的一律当"全部", +// 不报错——地址栏里的参数是用户可以随便改的,不值得为它弹错误页。 +func ParseCollectStatus(s string) model.CollectStatus { + status := model.CollectStatus(strings.TrimSpace(s)) + if _, ok := collectStatusTexts[status]; ok { + return status + } + return "" +} + +func collectStatusText(s model.CollectStatus) string { + if t, ok := collectStatusTexts[s]; ok { + return t + } + return string(s) +} + +// formatLocalTime 把库里的 ISO 8601 转成本地时区的可读写法。 +// 空值或坏值都显示占位符,不显示 0001-01-01。 +func formatLocalTime(iso string) string { + if strings.TrimSpace(iso) == "" { + return placeholder + } + t, ok := model.ParseISO(iso) + if !ok { + return iso // 原样显示,好让人看出数据有问题 + } + return t.Local().Format("2006-01-02 15:04") +} + +// placeholder 是"这里没有值"的统一写法。空单元格看着像页面坏了。 +const placeholder = "—" + +// formatPriceCent 把整数分显示成 ¥12.56。 +// +// `[必须]` price_cent 为 null 时显示"未采到",**不能显示 ¥0.00**。 +// 0 元和采不到价格是两回事,显示成 0 元会让操作员以为捡到便宜, +// 而这个数是要参与价格保护比对的(会花钱)。 +func formatPriceCent(cent *int64) string { + if cent == nil { + return "未采到" + } + v := *cent + sign := "" + if v < 0 { + sign, v = "-", -v + } + return fmt.Sprintf("%s¥%d.%02d", sign, v/100, v%100) +} + +// ---------- 列表 ---------- + +// PddProductView 是列表页一行要显示的全部内容,全部已经是字符串。 +// 模板里不做判断和格式化,该在这里算完。 +type PddProductView struct { + ID int64 + GoodsID string + URL string + Title string // 未采集时是占位符 + StatusText string + SkuCountText string // 未采集时是占位符 + CollectedAt string + UpdatedAt string + CollectMsg string + IsFailed bool // 失败的行标黄,方便一眼找出来重采 +} + +// PddListResult 是列表页要的全部数据。 +type PddListResult struct { + Rows []PddProductView + Total int // 全部未删除记录数,不受筛选影响 + Counts map[model.CollectStatus]int + IsFiltered bool +} + +// ListPddProducts 查列表并把每一行翻成界面文字。 +func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus) (*PddListResult, error) { + rows, err := repository.ListPddProducts(db, keyword, status) + if err != nil { + return nil, err + } + counts, err := repository.CountPddProductsByStatus(db) + if err != nil { + return nil, err + } + + result := &PddListResult{ + Rows: make([]PddProductView, 0, len(rows)), + Counts: counts, + IsFiltered: strings.TrimSpace(keyword) != "" || status != "", + } + for _, n := range counts { + result.Total += n + } + + for _, r := range rows { + v := PddProductView{ + ID: r.ID, + GoodsID: r.GoodsID, + URL: r.URL, + Title: r.Title, + StatusText: collectStatusText(r.CollectStatus), + CollectedAt: formatLocalTime(r.CollectedAt), + UpdatedAt: formatLocalTime(r.UpdatedAt), + CollectMsg: r.CollectMsg, + IsFailed: r.CollectStatus == model.CollectFailed, + } + if v.Title == "" { + v.Title = "(未采集,采集后自动回填)" + } + // -1 是"还没采过"。注意 0 要如实显示 0: + // 采到 0 个规格说明采集出了问题,显示成 — 就看不出来了。 + if r.SkuCount < 0 { + v.SkuCountText = placeholder + } else { + v.SkuCountText = fmt.Sprintf("%d", r.SkuCount) + } + result.Rows = append(result.Rows, v) + } + return result, nil +} + +// StatusLine 拼底部状态条,形如: +// +// 共 24 条 · 已采集 18 · 未采集 4 · 采集中 1 · 采集失败 1 +// +// 有筛选时前面加一句"筛选出 N 条",免得操作员把筛选后的行数 +// 当成全部行数,以为记录被删了。 +func (r *PddListResult) StatusLine() string { + order := []model.CollectStatus{ + model.CollectCollected, model.CollectPending, + model.CollectCollecting, model.CollectFailed, + } + parts := []string{fmt.Sprintf("共 %d 条", r.Total)} + for _, s := range order { + parts = append(parts, fmt.Sprintf("%s %d", collectStatusText(s), r.Counts[s])) + } + line := strings.Join(parts, " · ") + if r.IsFiltered { + line = fmt.Sprintf("筛选出 %d 条 / %s", len(r.Rows), line) + } + return line +} + +// ---------- 弹窗 ---------- + +// PddSkuView 是弹窗规格表的一行。 +type PddSkuView struct { + Options []string // 按 DimensionNames 的顺序排好,模板直接 range + PriceText string + Available string +} + +// PddProductDetail 是双击弹窗要显示的全部内容。 +type PddProductDetail struct { + ID int64 + GoodsID string + URL string + Title string + StatusText string + CollectMsg string + CollectedAt string + ArtifactRef string + + // Collected 为 false 时模板显示"尚未采集",而不是一张空表。 + Collected bool + DimensionNames []string + SKUs []PddSkuView + + // SkusError 非空表示 skus_json 存的东西解析不了。 + // 这种情况要如实说出来,不能装作"没有规格"—— + // 前者是数据坏了要重采,后者是商品本身没规格。 + SkusError string +} + +// GetPddProductDetail 读一条 PDD 商品,并把 skus_json 拆成规格表。 +// 商品不存在或已删除返回 (nil, nil)。 +func GetPddProductDetail(db *sql.DB, id int64) (*PddProductDetail, error) { + p, err := repository.GetPddProductByID(db, id) + if err != nil || p == nil { + return nil, err + } + + d := &PddProductDetail{ + ID: p.ID, + GoodsID: p.GoodsID, + URL: p.URL, + Title: p.Title, + StatusText: collectStatusText(p.CollectStatus), + CollectMsg: p.CollectMsg, + CollectedAt: formatLocalTime(p.CollectedAt), + ArtifactRef: p.ArtifactRef, + } + if d.Title == "" { + d.Title = placeholder + } + if d.CollectMsg == "" { + d.CollectMsg = placeholder + } + + if strings.TrimSpace(p.SkusJSON) == "" { + return d, nil + } + + collected, err := parseCollected(p.SkusJSON) + if err != nil { + d.SkusError = "采集结果解析不了,需要重新采集:" + err.Error() + return d, nil + } + + d.Collected = true + keys, names := dimensionOrder(collected) + d.DimensionNames = names + + for _, sku := range collected.SKUs { + row := PddSkuView{ + Options: make([]string, 0, len(keys)), + PriceText: formatPriceCent(sku.PriceCent), + Available: "否", + } + if sku.Available { + row.Available = "是" + } + for _, k := range keys { + value := sku.Options[k] + if value == "" { + value = placeholder + } + row.Options = append(row.Options, value) + } + d.SKUs = append(d.SKUs, row) + } + return d, nil +} + +// dimensionOrder 定下规格各维度的显示顺序,返回 (取值用的 key, 表头文字)。 +// +// 优先用采集结果里的 dimensions;它缺失时退回"把所有 SKU 出现过的 key 排序"。 +// 退回方案必须排序:Go 的 map 遍历顺序是随机的,不排的话 +// 同一个商品每次刷新页面列的顺序都不一样,看着像数据在跳。 +func dimensionOrder(c *collectedData) (keys, names []string) { + if len(c.Dimensions) > 0 { + for _, d := range c.Dimensions { + name := d.Name + if name == "" { + name = d.Key + } + keys = append(keys, d.Key) + names = append(names, name) + } + return keys, names + } + + seen := map[string]bool{} + for _, sku := range c.SKUs { + for k := range sku.Options { + seen[k] = true + } + } + for k := range seen { + keys = append(keys, k) + } + sort.Strings(keys) + return keys, keys +} + +// ---------- 创建与编辑 ---------- + +// CreatePddProduct 按链接建一条 PDD 商品,返回它的 goods_id。 +// +// 只需要链接,其余字段全靠采集回填。 +// 重复创建同一个商品不会产生第二行,软删除过的会被复活 +// (三个分支都在 repository.EnsurePddProduct 里,见 #16)。 +func CreatePddProduct(db *sql.DB, rawURL string) (string, error) { + goodsID, err := ParsePddGoodsID(rawURL) + if err != nil { + return "", err + } + p, err := repository.EnsurePddProduct(db, goodsID, strings.TrimSpace(rawURL)) + if err != nil { + return "", err + } + return p.GoodsID, nil +} + +// UpdatePddProductURL 保存弹窗里改过的链接。 +// +// `[必须]` 新链接必须还是同一个商品。换成别的商品要新建一条,不能就地改: +// 这一行上挂着采集结果和 sku_mappings,goods_id 一换, +// 那些数据就全指到错的商品上了,之后按它下单就是买错东西。 +func UpdatePddProductURL(db *sql.DB, id int64, rawURL string) error { + goodsID, err := ParsePddGoodsID(rawURL) + if err != nil { + return err + } + p, err := repository.GetPddProductByID(db, id) + if err != nil { + return err + } + if p == nil { + return fmt.Errorf("PDD 商品 #%d 不存在或已被删除", id) + } + if p.GoodsID != goodsID { + return fmt.Errorf( + "%w:这条记录是商品 %s,新链接是 %s。"+ + "要维护另一个商品请用「创建」新建一条", + ErrPddGoodsIDChanged, p.GoodsID, goodsID) + } + return repository.UpdatePddProductURL(db, id, strings.TrimSpace(rawURL)) +} + +// DeletePddProducts 批量软删除,返回实际删掉的条数。 +func DeletePddProducts(db *sql.DB, goodsIDs []string) (int64, error) { + return repository.SoftDeletePddProducts(db, dedupe(goodsIDs)) +} + +// ---------- 创建采集任务 ---------- + +// CreatePddCollectTasks 为勾选的 PDD 商品创建采集任务。 +// +// `[必须]` **不指定客户端**(assigned_client 为 NULL,status 为 pending), +// 谁领到就在领取时标记谁,见 #17。采集是纯读取操作,哪台机器跑都一样, +// 指定了反而会在那台机器关着的时候干等。 +// +// 规则: +// - 按 goods_id 去重,勾了重复的只建一个; +// - collect_status 已经是 collecting 的**跳过**——有任务在跑了, +// 再建一个就是让两台机器采同一个商品,白费一趟; +// - 建成功后把状态置为 collecting。 +// +// 返回的 skipped 必须显示给操作员。静默跳过的话, +// 操作员会以为任务建好了,等半天没动静也不知道为什么。 +func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (created, skipped int, err error) { + goodsIDs = dedupe(goodsIDs) + if len(goodsIDs) == 0 { + return 0, 0, nil + } + + tx, err := db.Begin() + if err != nil { + return 0, 0, fmt.Errorf("开始事务失败: %w", err) + } + defer tx.Rollback() // 已提交的事务再 Rollback 是空操作,安全 + + for _, goodsID := range goodsIDs { + p, err := repository.GetPddProductByGoodsID(tx, goodsID) + if err != nil { + return 0, 0, err + } + if p == nil || p.IsDeleted() { + skipped++ + continue + } + + // 先占状态再建任务:MarkCollecting 只在 pending/failed 时成功, + // 它同时起到"这个商品有没有人已经在采"的判断作用。 + ok, err := repository.MarkCollecting(tx, goodsID) + if err != nil { + return 0, 0, err + } + if !ok { + skipped++ + continue + } + + if err := repository.InsertCollectTask( + tx, newCollectTaskID(), p.GoodsID, p.URL); err != nil { + return 0, 0, err + } + created++ + } + + if err := tx.Commit(); err != nil { + return 0, 0, fmt.Errorf("提交事务失败: %w", err) + } + return created, skipped, nil +} + +// newCollectTaskID 生成采集任务编号。 +// 带 COL- 前缀是为了在日志和数据库里一眼认出这是采集任务而不是采购任务。 +func newCollectTaskID() string { + id := newID() + if len(id) > 16 { + id = id[:16] + } + return "COL-" + id +} + +// dedupe 去掉重复项并保持原有顺序,顺带丢掉空串。 +func dedupe(values []string) []string { + seen := make(map[string]bool, len(values)) + out := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" || seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + return out +} diff --git a/admin/service/pdd_page_test.go b/admin/service/pdd_page_test.go new file mode 100644 index 0000000..9abd13d --- /dev/null +++ b/admin/service/pdd_page_test.go @@ -0,0 +1,661 @@ +package service + +import ( + "database/sql" + "errors" + "strings" + "testing" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +// ── 链接解析 ─────────────────────────────────────────── + +func TestParsePddGoodsID_认得的写法(t *testing.T) { + cases := []struct { + name string + url string + want string + }{ + {"标准商品页", "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267", "737116531267"}, + {"goods2 带一堆参数", "https://mobile.yangkeduo.com/goods2.html?_x_org=2&goods_id=737116531267&refer_page=1", "737116531267"}, + {"优惠券落地页用的是 _x_goods_id", "https://yangkeduo.com/duo_coupon_landing.html?_x_goods_id=737116531267", "737116531267"}, + {"pinduoduo.com 域名", "https://mobile.pinduoduo.com/goods.html?goods_id=737116531267", "737116531267"}, + {"http 也认", "http://mobile.yangkeduo.com/goods.html?goods_id=737116531267", "737116531267"}, + {"前后有空格", " https://mobile.yangkeduo.com/goods.html?goods_id=737116531267 ", "737116531267"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParsePddGoodsID(tc.url) + if err != nil { + t.Fatalf("应该解析成功: %v", err) + } + if got != tc.want { + t.Errorf("goods_id = %q,期望 %q", got, tc.want) + } + }) + } +} + +// 解析不出来必须报错。容错兜底会让同一个商品存成好几行, +// 采好几遍,规格映射还说不清指向哪一行。 +func TestParsePddGoodsID_认不出的一律报错(t *testing.T) { + cases := []struct{ name, url string }{ + {"空串", ""}, + {"只有空格", " "}, + {"短链没有 goods_id", "https://p.pinduoduo.com/AbCdEfGh"}, + {"不是拼多多", "https://item.taobao.com/item.htm?id=737116531267"}, + {"不带协议", "mobile.yangkeduo.com/goods.html?goods_id=737116531267"}, + {"goods_id 不是数字", "https://mobile.yangkeduo.com/goods.html?goods_id=abc123456"}, + {"goods_id 太短像是被截断", "https://mobile.yangkeduo.com/goods.html?goods_id=123"}, + {"goods_id 为空", "https://mobile.yangkeduo.com/goods.html?goods_id="}, + {"域名只是长得像", "https://yangkeduo.com.evil.example/goods.html?goods_id=737116531267"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParsePddGoodsID(tc.url) + if !errors.Is(err, ErrBadPddURL) { + t.Fatalf("期望 ErrBadPddURL,实际 err=%v got=%q", err, got) + } + // 报错要说清下一步该怎么办,光说"无效"操作员不知道改什么 + if len(err.Error()) < len("PDD 链接无效")+4 { + t.Errorf("错误信息太笼统: %q", err.Error()) + } + }) + } +} + +// ── 价格显示 ─────────────────────────────────────────── + +// price_cent 为 null 显示"未采到",**不能显示 ¥0.00**。 +// 0 元和采不到价格是两回事,而这个数是要参与价格保护比对的。 +func TestFormatPriceCent(t *testing.T) { + cent := func(v int64) *int64 { return &v } + cases := []struct { + in *int64 + want string + }{ + {nil, "未采到"}, + {cent(1256), "¥12.56"}, + {cent(0), "¥0.00"}, + {cent(5), "¥0.05"}, + {cent(100), "¥1.00"}, + {cent(123456), "¥1234.56"}, + } + for _, tc := range cases { + if got := formatPriceCent(tc.in); got != tc.want { + t.Errorf("formatPriceCent(%v) = %q,期望 %q", tc.in, got, tc.want) + } + } +} + +// ── 创建 ─────────────────────────────────────────────── + +func TestCreatePddProduct_只填链接即可(t *testing.T) { + db := newTestDB(t) + + goodsID, err := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267") + if err != nil { + t.Fatalf("创建失败: %v", err) + } + if goodsID != "737116531267" { + t.Errorf("goods_id = %q", goodsID) + } + + p, _ := repository.GetPddProductByGoodsID(db, goodsID) + if p == nil { + t.Fatal("应该建出一行") + } + if p.CollectStatus != model.CollectPending { + t.Errorf("新建的状态应为 pending,实际 %s", p.CollectStatus) + } + if p.Title != "" || p.SkusJSON != "" { + t.Error("标题和规格应该留空,等采集回填") + } +} + +func TestCreatePddProduct_链接解析不出就不写库(t *testing.T) { + db := newTestDB(t) + + if _, err := CreatePddProduct(db, "https://p.pinduoduo.com/AbCdEfGh"); !errors.Is(err, ErrBadPddURL) { + t.Fatalf("期望 ErrBadPddURL,实际 %v", err) + } + + var n int + db.QueryRow(`SELECT COUNT(*) FROM pdd_products`).Scan(&n) + if n != 0 { + t.Errorf("解析失败时不该写库,实际有 %d 行", n) + } +} + +func TestCreatePddProduct_重复创建不产生第二行(t *testing.T) { + db := newTestDB(t) + url := "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267" + + for i := 0; i < 3; i++ { + if _, err := CreatePddProduct(db, url); err != nil { + t.Fatalf("第 %d 次创建失败: %v", i+1, err) + } + } + + var n int + db.QueryRow(`SELECT COUNT(*) FROM pdd_products`).Scan(&n) + if n != 1 { + t.Errorf("同一个商品应该只有 1 行,实际 %d 行", n) + } +} + +// 软删除后重新创建同一链接要复活原行,并且**旧采集结果被清空**。 +// 记录被删过一次,旧数据不该再当有效的用。 +func TestCreatePddProduct_删除后重新创建复活且清空采集结果(t *testing.T) { + db := newTestDB(t) + url := "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267" + + goodsID, _ := CreatePddProduct(db, url) + if err := repository.SetCollectResult(db, goodsID, "旧标题", sampleSkusJSON); err != nil { + t.Fatalf("写采集结果失败: %v", err) + } + before, _ := repository.GetPddProductByGoodsID(db, goodsID) + + if _, err := DeletePddProducts(db, []string{goodsID}); err != nil { + t.Fatalf("删除失败: %v", err) + } + if _, err := CreatePddProduct(db, url); err != nil { + t.Fatalf("重新创建失败: %v", err) + } + + after, _ := repository.GetPddProductByGoodsID(db, goodsID) + if after.ID != before.ID { + t.Errorf("应该复活原行(id %d),实际是新行 id %d", before.ID, after.ID) + } + if after.IsDeleted() { + t.Error("复活后 deleted_at 应该清掉") + } + if after.SkusJSON != "" || after.Title != "" { + t.Errorf("复活后采集结果应清空,实际 title=%q skus=%q", after.Title, after.SkusJSON) + } + if after.CollectStatus != model.CollectPending { + t.Errorf("复活后状态应回到 pending,实际 %s", after.CollectStatus) + } +} + +// ── 编辑链接 ─────────────────────────────────────────── + +func TestUpdatePddProductURL_同一个商品可以改写法(t *testing.T) { + db := newTestDB(t) + goodsID, _ := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267") + p, _ := repository.GetPddProductByGoodsID(db, goodsID) + + newURL := "https://mobile.yangkeduo.com/goods2.html?goods_id=737116531267&refer_page=1" + if err := UpdatePddProductURL(db, p.ID, newURL); err != nil { + t.Fatalf("保存失败: %v", err) + } + + after, _ := repository.GetPddProductByID(db, p.ID) + if after.URL != newURL { + t.Errorf("链接没保存上: %q", after.URL) + } +} + +// 换成另一个商品必须拒绝:这一行上挂着采集结果和 sku_mappings, +// goods_id 一换那些数据就全指到错的商品上,之后按它下单就是买错东西。 +func TestUpdatePddProductURL_换成别的商品要拒绝(t *testing.T) { + db := newTestDB(t) + goodsID, _ := CreatePddProduct(db, "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267") + p, _ := repository.GetPddProductByGoodsID(db, goodsID) + + err := UpdatePddProductURL(db, p.ID, + "https://mobile.yangkeduo.com/goods.html?goods_id=999888777666") + if !errors.Is(err, ErrPddGoodsIDChanged) { + t.Fatalf("期望 ErrPddGoodsIDChanged,实际 %v", err) + } + + after, _ := repository.GetPddProductByID(db, p.ID) + if after.GoodsID != goodsID { + t.Error("拒绝之后 goods_id 不该变") + } + if !strings.Contains(err.Error(), "创建") { + t.Errorf("错误信息要告诉操作员改用「创建」,实际 %q", err.Error()) + } +} + +// ── 列表 ─────────────────────────────────────────────── + +// sampleSkusJSON 是一份采集结果样本,两个维度三个规格,其中一个采不到价格。 +const sampleSkusJSON = `{ + "schema_version": 1, + "goods_id": "737116531267", + "title": "西装外套三件套", + "dimensions": [ + {"key": "color", "name": "颜色分类"}, + {"key": "size", "name": "尺码"} + ], + "skus": [ + {"options": {"color": "黑色", "size": "M"}, "price_cent": 1256, "available": true, "raw_price": "¥12.56"}, + {"options": {"color": "白色", "size": "M"}, "price_cent": 1256, "available": false, "raw_price": "¥12.56"}, + {"options": {"color": "红色", "size": "L"}, "price_cent": null, "available": true, "raw_price": ""} + ] +}` + +func createProduct(t *testing.T, db *sql.DB, goodsID string) { + t.Helper() + if _, err := CreatePddProduct(db, + "https://mobile.yangkeduo.com/goods.html?goods_id="+goodsID); err != nil { + t.Fatalf("创建商品 %s 失败: %v", goodsID, err) + } +} + +// 规格数:未采集显示 —,采到几个就显示几个。 +// **采到 0 个要如实显示 0**,那说明采集出了问题,显示成 — 就看不出来了。 +func TestListPddProducts_规格数(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "100000000001") // 没采过 + createProduct(t, db, "100000000002") // 采到 3 个 + createProduct(t, db, "100000000003") // 采到 0 个 + + repository.SetCollectResult(db, "100000000002", "有规格的", sampleSkusJSON) + repository.SetCollectResult(db, "100000000003", "没规格的", `{"skus": []}`) + + result, err := ListPddProducts(db, "", "") + if err != nil { + t.Fatalf("查列表失败: %v", err) + } + + want := map[string]string{ + "100000000001": placeholder, + "100000000002": "3", + "100000000003": "0", + } + for _, row := range result.Rows { + if got := row.SkuCountText; got != want[row.GoodsID] { + t.Errorf("商品 %s 规格数 = %q,期望 %q", row.GoodsID, got, want[row.GoodsID]) + } + } +} + +// skus_json 存进了坏数据时,列表页必须还能打开。 +// json_array_length 碰到非法 JSON 会让整条查询报错,那样页面直接白屏。 +func TestListPddProducts_坏掉的采集结果不影响列表打开(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "100000000001") + if _, err := db.Exec( + `UPDATE pdd_products SET skus_json = '这不是JSON' WHERE goods_id = ?`, + "100000000001"); err != nil { + t.Fatalf("造坏数据失败: %v", err) + } + + result, err := ListPddProducts(db, "", "") + if err != nil { + t.Fatalf("列表页应该照样打得开: %v", err) + } + if len(result.Rows) != 1 || result.Rows[0].SkuCountText != placeholder { + t.Errorf("坏数据的规格数应显示占位符,实际 %+v", result.Rows) + } +} + +func TestListPddProducts_按采集状态筛选(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "100000000001") + createProduct(t, db, "100000000002") + repository.SetCollectResult(db, "100000000002", "已采的", sampleSkusJSON) + + collected, err := ListPddProducts(db, "", model.CollectCollected) + if err != nil { + t.Fatalf("筛选失败: %v", err) + } + if len(collected.Rows) != 1 || collected.Rows[0].GoodsID != "100000000002" { + t.Errorf("按「已采集」筛选应只剩 1 条,实际 %d 条", len(collected.Rows)) + } + if !collected.IsFiltered { + t.Error("IsFiltered 应为 true,空状态文案要靠它分情况") + } + + // 统计不受筛选影响:状态条是全局概览 + if collected.Total != 2 { + t.Errorf("Total 应是全部 2 条,不该跟着筛选变,实际 %d", collected.Total) + } +} + +func TestListPddProducts_按商品ID和链接搜索(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + createProduct(t, db, "999888777666") + + byID, _ := ListPddProducts(db, "7371165", "") + if len(byID.Rows) != 1 || byID.Rows[0].GoodsID != "737116531267" { + t.Errorf("按 ID 片段搜索失败,命中 %d 条", len(byID.Rows)) + } + + byURL, _ := ListPddProducts(db, "yangkeduo", "") + if len(byURL.Rows) != 2 { + t.Errorf("按链接搜索应命中 2 条,实际 %d 条", len(byURL.Rows)) + } +} + +// LIKE 的通配符要转义,否则搜 "7_7" 会把 "737…" 也捞出来,看着像搜索坏了。 +// +// 注意别拿单个 "_" 当测试词:链接里的 goods_id= 本来就带下划线, +// 那种情况命中是对的,测不出问题。 +func TestListPddProducts_搜索词里的通配符不当通配符用(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + + for _, kw := range []string{"7_7", "%"} { + result, err := ListPddProducts(db, kw, "") + if err != nil { + t.Fatalf("搜 %q 出错: %v", kw, err) + } + if len(result.Rows) != 0 { + t.Errorf("搜 %q 应该一条都搜不到,实际 %d 条", kw, len(result.Rows)) + } + } +} + +func TestListPddProducts_删除的查不到(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + DeletePddProducts(db, []string{"737116531267"}) + + result, _ := ListPddProducts(db, "", "") + if len(result.Rows) != 0 { + t.Errorf("软删除的不该出现在列表里,实际 %d 条", len(result.Rows)) + } + if result.Total != 0 { + t.Errorf("统计也不该算上删掉的,实际 %d", result.Total) + } +} + +func TestStatusLine_四个状态都列出来(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "100000000001") + createProduct(t, db, "100000000002") + repository.SetCollectResult(db, "100000000002", "已采的", sampleSkusJSON) + + result, _ := ListPddProducts(db, "", "") + line := result.StatusLine() + for _, want := range []string{"共 2 条", "已采集 1", "未采集 1", "采集中 0", "采集失败 0"} { + if !strings.Contains(line, want) { + t.Errorf("状态条缺少 %q:%s", want, line) + } + } +} + +// ── 弹窗详情 ─────────────────────────────────────────── + +func TestGetPddProductDetail_规格表按dimensions排列(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + repository.SetCollectResult(db, "737116531267", "西装外套三件套", sampleSkusJSON) + p, _ := repository.GetPddProductByGoodsID(db, "737116531267") + + d, err := GetPddProductDetail(db, p.ID) + if err != nil { + t.Fatalf("读详情失败: %v", err) + } + if !d.Collected { + t.Fatal("已采集的应标为 Collected") + } + + // 表头按 dimensions 里的顺序,用的是 name 不是 key + if len(d.DimensionNames) != 2 || + d.DimensionNames[0] != "颜色分类" || d.DimensionNames[1] != "尺码" { + t.Errorf("维度顺序不对: %v", d.DimensionNames) + } + if len(d.SKUs) != 3 { + t.Fatalf("应有 3 个规格,实际 %d", len(d.SKUs)) + } + + first := d.SKUs[0] + if len(first.Options) != 2 || first.Options[0] != "黑色" || first.Options[1] != "M" { + t.Errorf("第一行规格值顺序不对: %v", first.Options) + } + if first.PriceText != "¥12.56" || first.Available != "是" { + t.Errorf("第一行价格/有货不对: %q %q", first.PriceText, first.Available) + } + if d.SKUs[1].Available != "否" { + t.Errorf("缺货的应显示「否」,实际 %q", d.SKUs[1].Available) + } + // price_cent 为 null 的那一行 + if d.SKUs[2].PriceText != "未采到" { + t.Errorf("采不到价格应显示「未采到」而不是 ¥0.00,实际 %q", d.SKUs[2].PriceText) + } +} + +// dimensions 缺失时退回按 key 排序。不排的话 Go 的 map 是随机顺序, +// 同一个商品每次刷新页面列的顺序都不一样。 +func TestGetPddProductDetail_没有dimensions时按key排序且稳定(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + repository.SetCollectResult(db, "737116531267", "无维度信息", `{ + "skus": [{"options": {"size": "M", "color": "黑色", "style": "A"}, + "price_cent": 100, "available": true}] + }`) + p, _ := repository.GetPddProductByGoodsID(db, "737116531267") + + for i := 0; i < 5; i++ { + d, err := GetPddProductDetail(db, p.ID) + if err != nil { + t.Fatalf("读详情失败: %v", err) + } + want := []string{"color", "size", "style"} + for j, name := range want { + if d.DimensionNames[j] != name { + t.Fatalf("第 %d 次读,维度顺序应稳定为 %v,实际 %v", i+1, want, d.DimensionNames) + } + } + if d.SKUs[0].Options[0] != "黑色" { + t.Fatalf("规格值应跟着表头一起排,实际 %v", d.SKUs[0].Options) + } + } +} + +func TestGetPddProductDetail_未采集时不算已采集(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + p, _ := repository.GetPddProductByGoodsID(db, "737116531267") + + d, err := GetPddProductDetail(db, p.ID) + if err != nil { + t.Fatalf("读详情失败: %v", err) + } + if d.Collected { + t.Error("没采过的不该标为已采集——界面要显示「尚未采集」而不是空表") + } + if d.StatusText != "未采集" { + t.Errorf("状态文字 = %q", d.StatusText) + } + if d.SkusError != "" { + t.Errorf("没采过不是解析出错,SkusError 应为空,实际 %q", d.SkusError) + } +} + +// 采集结果坏掉要如实说出来,不能装作"没有规格"—— +// 前者是数据坏了要重采,后者是商品本身没规格,处理方式不一样。 +func TestGetPddProductDetail_采集结果坏掉时说清楚(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + db.Exec(`UPDATE pdd_products SET skus_json = '坏数据' WHERE goods_id = ?`, "737116531267") + p, _ := repository.GetPddProductByGoodsID(db, "737116531267") + + d, err := GetPddProductDetail(db, p.ID) + if err != nil { + t.Fatalf("不该整个失败,要能打开弹窗: %v", err) + } + if d.SkusError == "" { + t.Error("应该给出解析失败的说明") + } + if d.Collected { + t.Error("解析不了就不能当成已采集") + } +} + +func TestGetPddProductDetail_删除的读不到(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + p, _ := repository.GetPddProductByGoodsID(db, "737116531267") + DeletePddProducts(db, []string{"737116531267"}) + + d, err := GetPddProductDetail(db, p.ID) + if err != nil { + t.Fatalf("不该报错: %v", err) + } + if d != nil { + t.Error("已删除的应返回 nil,让界面提示「已被删除,请刷新」") + } +} + +// ── 创建采集任务 ─────────────────────────────────────── + +// collectTaskOf 读出某个商品对应的采集任务。 +func collectTaskOf(t *testing.T, db *sql.DB, goodsID string) (status, assigned, url string, ok bool) { + t.Helper() + var a sql.NullString + err := db.QueryRow(` + SELECT status, assigned_client, pdd_goods_url + FROM tasks WHERE task_type = 'collect' AND pdd_goods_id = ?`, goodsID, + ).Scan(&status, &a, &url) + if err == sql.ErrNoRows { + return "", "", "", false + } + if err != nil { + t.Fatalf("查采集任务失败: %v", err) + } + return status, a.String, url, true +} + +// `[必须]` 采集任务不指定客户端。采集是纯读取,哪台机器跑都一样, +// 指定了反而会在那台机器关着的时候干等。 +func TestCreatePddCollectTasks_不指定客户端(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + + created, skipped, err := CreatePddCollectTasks(db, []string{"737116531267"}) + if err != nil { + t.Fatalf("建任务失败: %v", err) + } + if created != 1 || skipped != 0 { + t.Fatalf("created=%d skipped=%d,期望 1/0", created, skipped) + } + + status, assigned, url, ok := collectTaskOf(t, db, "737116531267") + if !ok { + t.Fatal("应该建出一条采集任务") + } + if assigned != "" { + t.Errorf("assigned_client 必须为空,实际 %q", assigned) + } + if status != "pending" { + t.Errorf("状态应为 pending(无主待领),实际 %q", status) + } + // Client 契约里 pdd_goods_url 必填,没有它客户端拿到任务也不知道去哪采 + if url == "" { + t.Error("pdd_goods_url 必须有值") + } + + // 建完任务商品状态要变成采集中 + p, _ := repository.GetPddProductByGoodsID(db, "737116531267") + if p.CollectStatus != model.CollectCollecting { + t.Errorf("商品状态应为 collecting,实际 %s", p.CollectStatus) + } +} + +// 建出来的任务必须真的能被领走,否则页面上永远卡在"采集中"。 +func TestCreatePddCollectTasks_建出的任务能被任意客户端领走(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + CreatePddCollectTasks(db, []string{"737116531267"}) + + task, err := ClaimNextTask(db, "client-随便哪台", []string{"collect"}) + if err != nil { + t.Fatalf("领取失败: %v", err) + } + if task == nil { + t.Fatal("采集任务应该能被任意客户端领到") + } + if task.PddGoodsID != "737116531267" { + t.Errorf("领到的任务商品不对: %q", task.PddGoodsID) + } +} + +func TestCreatePddCollectTasks_按商品去重(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + + created, _, err := CreatePddCollectTasks(db, + []string{"737116531267", "737116531267", " 737116531267 ", ""}) + if err != nil { + t.Fatalf("建任务失败: %v", err) + } + if created != 1 { + t.Errorf("重复勾选只该建 1 个任务,实际 %d", created) + } + + var n int + db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type = 'collect'`).Scan(&n) + if n != 1 { + t.Errorf("库里应只有 1 条采集任务,实际 %d", n) + } +} + +// 已经在采的跳过:再建一个就是让两台机器采同一个商品,白费一趟。 +// 而且跳过了几个必须报出来,静默跳过会让操作员等半天不知道为什么没动静。 +func TestCreatePddCollectTasks_采集中的跳过并报数(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "100000000001") + createProduct(t, db, "100000000002") + + // 第一个先建一次,让它进入 collecting + if _, _, err := CreatePddCollectTasks(db, []string{"100000000001"}); err != nil { + t.Fatalf("第一次建任务失败: %v", err) + } + + created, skipped, err := CreatePddCollectTasks(db, + []string{"100000000001", "100000000002"}) + if err != nil { + t.Fatalf("第二次建任务失败: %v", err) + } + if created != 1 { + t.Errorf("只该给没在采的那个建任务,实际建了 %d 个", created) + } + if skipped != 1 { + t.Errorf("采集中的那个应计入 skipped,实际 %d", skipped) + } +} + +// 采集失败的可以重新采集,不能一直卡在失败状态。 +func TestCreatePddCollectTasks_失败的可以重新采集(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + repository.SetCollectFailed(db, "737116531267", "页面打不开", "") + + created, skipped, err := CreatePddCollectTasks(db, []string{"737116531267"}) + if err != nil { + t.Fatalf("建任务失败: %v", err) + } + if created != 1 || skipped != 0 { + t.Errorf("失败的应该能重采,created=%d skipped=%d", created, skipped) + } +} + +func TestCreatePddCollectTasks_已删除的跳过(t *testing.T) { + db := newTestDB(t) + createProduct(t, db, "737116531267") + DeletePddProducts(db, []string{"737116531267"}) + + created, skipped, err := CreatePddCollectTasks(db, []string{"737116531267"}) + if err != nil { + t.Fatalf("不该报错: %v", err) + } + if created != 0 || skipped != 1 { + t.Errorf("已删除的应被跳过,created=%d skipped=%d", created, skipped) + } +} + +func TestCreatePddCollectTasks_什么都没勾时不建任务(t *testing.T) { + db := newTestDB(t) + created, skipped, err := CreatePddCollectTasks(db, nil) + if err != nil || created != 0 || skipped != 0 { + t.Errorf("空输入应安静返回,得到 %d/%d/%v", created, skipped, err) + } +} diff --git a/admin/service/submit.go b/admin/service/submit.go index 3f6ff34..d7d2763 100644 --- a/admin/service/submit.go +++ b/admin/service/submit.go @@ -31,7 +31,14 @@ var ( type collectedData struct { GoodsID string `json:"goods_id"` Title string `json:"title"` - SKUs []struct { + // Dimensions 决定规格各维度的显示顺序。 + // 少了它就只能按 Go 的 map 遍历,而 map 是无序的—— + // 同一个商品每次刷新页面「颜色/尺码」的先后都可能变。 + Dimensions []struct { + Key string `json:"key"` + Name string `json:"name"` + } `json:"dimensions"` + SKUs []struct { Options map[string]string `json:"options"` PriceCent *int64 `json:"price_cent"` // 指针:采不到价格时是 null,不是 0 Available bool `json:"available"` diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 22217ee..100452c 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -135,6 +135,99 @@ tr.empty small { color: #aaa; } font-size: 13px; } +/* 链接和标题这类长内容截断显示,鼠标悬停看全文(title 属性)。 + 不截断的话一列能把表格撑到屏幕外面去。 */ +.truncate { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── 弹窗 ─────────────────────────────── */ +/* 用 hidden 属性控制显示隐藏,JS 只改这一个属性,不拼样式 */ +.modal-backdrop[hidden] { display: none; } +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(20, 24, 30, .45); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 40px 16px; + overflow-y: auto; + z-index: 10; +} +.modal { + background: #fff; + border-radius: 6px; + box-shadow: 0 8px 32px rgba(0, 0, 0, .25); + width: 100%; + max-width: 720px; +} +.modal-head { + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #e1e4e8; +} +.modal-head h2 { margin: 0; font-size: 16px; flex: 1; } +.modal-x { + border: none; + background: none; + font-size: 20px; + line-height: 1; + padding: 0 6px; + color: #666; +} +.modal-body { padding: 16px; } +.modal-body h3 { font-size: 14px; margin: 20px 0 8px; } +.modal-foot { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + padding: 12px 16px; + border-top: 1px solid #e1e4e8; + background: #fafbfc; +} +/* 把左边的按钮和右边的按钮推开 */ +.modal-foot .grow { flex: 1; } +button.primary { + background: #1f6feb; + border-color: #1f6feb; + color: #fff; +} +button.primary:hover { background: #1a5fd0; } + +/* 弹窗里的字段表:左边标签,右边值 */ +.detail { + display: grid; + grid-template-columns: 88px 1fr; + gap: 8px 12px; + margin: 0; + align-items: baseline; +} +.detail dt { color: #555; } +.detail dd { margin: 0; word-break: break-all; } +.detail dd small { color: #999; margin-left: 6px; } + +/* 创建弹窗里的单个字段,标签在输入框上面 */ +.field { display: flex; flex-direction: column; gap: 6px; } +.field label { color: #555; } +input[type="text"] { + padding: 5px 8px; + border: 1px solid #ccd1d6; + border-radius: 3px; +} +input.wide { width: 100%; } + +select { + padding: 5px 8px; + border: 1px solid #ccd1d6; + border-radius: 3px; + background: #fff; +} + /* ── 错误页 ───────────────────────────── */ .error-box { background: #fff; diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 17418ef..031d5df 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -56,32 +56,125 @@ } /* ── 删除前二次确认 ──────────────────────── - 标了 data-confirm-delete 的表单,提交前必须确认, - 并明确告诉用户会删掉几条、不可恢复。 */ + 标了 data-confirm-delete 的元素,提交前必须确认, + 并明确告诉用户会删掉几条、能不能恢复。 + + 标在表单上就拦 submit,标在按钮上就拦 click—— + 一个表单配多个提交按钮时(PDD 页就是),只有删除那个要确认。 + + 属性值可以写自定义文案,里面的 {n} 会替换成勾选条数; + 不写就用默认那句。 */ + var DEFAULT_CONFIRM = "将删除 {n} 条记录,不可恢复。确定继续?"; + function setupConfirmDelete() { - document.querySelectorAll("[data-confirm-delete]").forEach(function (form) { - form.addEventListener("submit", function (e) { + document.querySelectorAll("[data-confirm-delete]").forEach(function (el) { + var isForm = el.tagName === "FORM"; + el.addEventListener(isForm ? "submit" : "click", function (e) { var n = checkedCount(); if (n === 0) { e.preventDefault(); return; } - if (!window.confirm("将删除 " + n + " 条记录,不可恢复。确定继续?")) { + var text = el.getAttribute("data-confirm-delete") || DEFAULT_CONFIRM; + if (!window.confirm(text.replace("{n}", n) + "\n\n确定继续?")) { e.preventDefault(); } }); }); } + /* ── 弹窗 ────────────────────────────────── + 弹窗**内容由服务端渲染**,这里只负责显示、隐藏和把内容取回来。 + 不要在这里拼业务数据——价格格式、规格顺序都是业务规则, + 散到前端就没人维护得住了。 */ + + function openModal(modal) { + modal.hidden = false; + var focusable = modal.querySelector("input, button"); + if (focusable) focusable.focus(); + } + + function closeModal(modal) { + modal.hidden = true; + } + + function setupModals() { + /* 打开:按钮上写 data-modal-open="弹窗的 id" */ + document.querySelectorAll("[data-modal-open]").forEach(function (btn) { + btn.addEventListener("click", function () { + var modal = document.getElementById(btn.getAttribute("data-modal-open")); + if (modal) openModal(modal); + }); + }); + + document.querySelectorAll(".modal-backdrop").forEach(function (modal) { + /* 关闭:叉、取消按钮,以及点弹窗外面的灰底 */ + modal.addEventListener("click", function (e) { + if (e.target === modal || e.target.closest("[data-modal-close]")) { + closeModal(modal); + } + }); + }); + + /* Esc 关掉最上面那个打开着的弹窗 */ + document.addEventListener("keydown", function (e) { + if (e.key !== "Escape") return; + var open = document.querySelectorAll(".modal-backdrop:not([hidden])"); + if (open.length > 0) closeModal(open[open.length - 1]); + }); + } + + /* ── 双击行打开详情弹窗 ──────────────────── + 行上写 data-detail-id,双击时去 /<模块>/detail?id=… 取回 + 已经渲染好的 HTML 片段,塞进壳子里显示。 */ + function setupRowDetail() { + var modal = document.getElementById("detail-modal"); + if (!modal) return; + + var slot = modal.querySelector("[data-detail-slot]"); + var base = modal.getAttribute("data-detail-url") || ""; + + document.querySelectorAll("tr[data-detail-id]").forEach(function (row) { + row.addEventListener("dblclick", function (e) { + /* 双击到勾选框或链接上时不要弹窗,那是另一个动作 */ + if (e.target.closest("input, a, button")) return; + + slot.innerHTML = ""; + openModal(modal); + + fetch(base + "?id=" + encodeURIComponent(row.getAttribute("data-detail-id")), { + headers: { "X-Requested-With": "fetch" } + }) + .then(function (resp) { + if (!resp.ok) throw new Error("HTTP " + resp.status); + return resp.text(); + }) + .then(function (html) { + slot.innerHTML = html; + }) + .catch(function (err) { + /* 出错也要说清楚,不能让弹窗一直停在"正在加载…" */ + slot.innerHTML = + "" + + ""; + }); + }); + }); + } + document.addEventListener("DOMContentLoaded", function () { document.querySelectorAll("table").forEach(setupCheckAll); setupConfirmDelete(); + setupModals(); + setupRowDetail(); syncButtons(); }); - /* TODO(骨架): 双击行打开弹窗。 - 蝦皮数据页 -> 编辑弹窗(填 PDD 链接 + 采集按钮) + /* TODO(骨架): 另外两个页面的弹窗,做法照 PDD 商品页抄: + 蝦皮数据页 -> 编辑弹窗(填 PDD 链接) 顺运宝页 -> 规格匹配弹窗 - 实现时注意:弹窗内容由服务端渲染,这里只负责显示/隐藏, - 不要在前端拼业务数据。 */ + 页面里放一个 id="detail-modal" 的壳子、行上写 data-detail-id, + 服务端出一个返回片段的 /<模块>/detail,这里就不用再加代码了。 */ })(); diff --git a/admin/templates/partials/header.html b/admin/templates/partials/header.html index a8b515d..50d7cb6 100644 --- a/admin/templates/partials/header.html +++ b/admin/templates/partials/header.html @@ -11,6 +11,7 @@