feat: 支持蝦皮批量创建 PDD 采集任务 (#185)

This commit is contained in:
chengma
2026-08-12 16:12:35 +08:00
parent 250998b7b9
commit 07ca1b0afb
10 changed files with 280 additions and 26 deletions
+44 -15
View File
@@ -75,6 +75,12 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取可选客户端失败,数据没有被改动。刷新页面重试。")
return
}
status := msg
if status == "" {
@@ -99,21 +105,22 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
}
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
"Keyword": keyword,
"ShopNameKeyword": filter.ShopName,
"StatusFilter": filter.Status,
"StatusOptions": service.ShopeeStatusOptions(),
"ShopFilter": filter.Shop,
"ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"),
"ImageFilter": filter.Image,
"ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"),
"Rows": result.Rows,
"Status": status,
"HasAnyProducts": result.HasAnyProducts,
"IsFiltered": result.IsFiltered,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"CurrentPage": result.Page,
"DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page),
"Keyword": keyword,
"ShopNameKeyword": filter.ShopName,
"StatusFilter": filter.Status,
"StatusOptions": service.ShopeeStatusOptions(),
"ShopFilter": filter.Shop,
"ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"),
"ImageFilter": filter.Image,
"ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"),
"Rows": result.Rows,
"Status": status,
"HasAnyProducts": result.HasAnyProducts,
"IsFiltered": result.IsFiltered,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"CurrentPage": result.Page,
"DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page),
"AssignableClients": assignableClients,
}))
}
@@ -180,6 +187,28 @@ func (h *Handler) ShopeeCollect(c *gin.Context) {
h.shopeeRedirect(c, service.FormatCollectTaskMessage(result))
}
// ShopeeCollectBatch 为勾选的蝦皮商品按当前 PDD 关联批量创建采集任务。
func (h *Handler) ShopeeCollectBatch(c *gin.Context) {
ids := c.PostFormArray("ids")
if len(ids) == 0 {
h.shopeeRedirect(c, "没有勾选任何蝦皮商品,没有创建任务")
return
}
result, err := service.CreateShopeePddCollectTasksForUser(
h.db, currentUser(c), ids, c.PostForm("client_id"))
if err != nil {
status := http.StatusInternalServerError
message := "创建采集任务失败,系统没有改动这一批数据。请稍后重试。"
if service.IsValidationError(err) {
status = http.StatusBadRequest
message = err.Error() + "。这一批任务整体没有创建,请重新选择。"
}
fail(c, status, message)
return
}
h.shopeeRedirect(c, service.FormatCollectTaskMessage(result))
}
func (h *Handler) shopeeRedirect(c *gin.Context, msg string) {
params := url.Values{}
if value := strings.TrimSpace(c.PostForm("list_goods_id")); value != "" {
+1
View File
@@ -59,6 +59,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
pages.POST("/shopee/save", h.ShopeeSave)
pages.POST("/shopee/delete", h.ShopeeDelete)
pages.POST("/shopee/collect", h.ShopeeCollect)
pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch)
// 2. PDD 商品
pages.GET("/pdd", h.PddList)
+18
View File
@@ -234,6 +234,24 @@ func TestShopeePage_移除Excel入口且保留目录记录入口(t *testing.T) {
}
}
func TestShopeePage_批量创建Pdd采集任务入口(t *testing.T) {
content, err := os.ReadFile("templates/shopee/list.html")
if err != nil {
t.Fatal(err)
}
page := string(content)
for _, want := range []string{
`action="/shopee/collect-batch"`, `data-modal-open="shopee-collect-modal"`,
`data-collect-form="shopee-collect-form"`, `form="shopee-collect-form"`,
`<label for="shopee-collect-client">执行客户端</label>`,
`不指定(任意客户端可领取)`, `.AssignableClients`, `data-collect-submit`,
} {
if !strings.Contains(page, want) {
t.Errorf("蝦皮批量采集入口缺少 %q", want)
}
}
}
func TestShopeeDetail_区分顺运宝观测与正式SKU(t *testing.T) {
content, err := os.ReadFile("templates/shopee/detail_modal.html")
if err != nil {
+31
View File
@@ -301,6 +301,37 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct,
return &p, nil
}
// ListShopeePddLinksByGoodsIDs 批量读取蝦皮商品当前关联的 PDD 商品 ID。
// 返回结果只包含仍存在的蝦皮商品;值为空表示商品存在但尚未关联 PDD。
func ListShopeePddLinksByGoodsIDs(q Execer, goodsIDs []string) (map[string]string, error) {
links := make(map[string]string, len(goodsIDs))
if len(goodsIDs) == 0 {
return links, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(goodsIDs)), ",")
args := make([]any, len(goodsIDs))
for i, goodsID := range goodsIDs {
args[i] = goodsID
}
rows, err := q.Query(`SELECT goods_id, COALESCE(pdd_goods_id, '')
FROM shopee_products WHERE goods_id IN (`+placeholders+`)`, args...)
if err != nil {
return nil, fmt.Errorf("批量查询蝦皮商品 PDD 关联失败: %w", err)
}
defer rows.Close()
for rows.Next() {
var shopeeGoodsID, pddGoodsID string
if err := rows.Scan(&shopeeGoodsID, &pddGoodsID); err != nil {
return nil, fmt.Errorf("读取蝦皮商品 PDD 关联失败: %w", err)
}
links[shopeeGoodsID] = pddGoodsID
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历蝦皮商品 PDD 关联失败: %w", err)
}
return links, nil
}
// UpdateShopeePddLink 更新蝦皮商品当前关联的 PDD 商品。
// 调用方必须先在同一事务里保证 PDD 商品存在,避免留下悬空关联。
func UpdateShopeePddLink(q Execer, shopeeGoodsID, pddGoodsID, pddURL string) error {
+12 -4
View File
@@ -543,9 +543,11 @@ func DeletePddProducts(db *sql.DB, goodsIDs []string) (int64, error) {
type CollectTaskResult struct {
Created int
SkippedCollecting int // 正在采集且**没超时**,得等客户端来领/提交
SkippedCollected int // 已经采集完成,本来就不需要重新采集
SkippedDeleted int // 商品不存在或已被软删除
SkippedShopeeMissing int // 浏览器提交的蝦皮商品已不存在
SkippedUnlinked int // 蝦皮商品尚未关联 PDD
SkippedCollecting int // 正在采集且**没超时**,得等客户端来领/提交
SkippedCollected int // 已经采集完成,本来就不需要重新采集
SkippedDeleted int // 商品不存在或已被软删除
// RetryWaitText 是 SkippedCollecting 里最快能重试的还需等待时长,
// 形如 "12 分钟";SkippedCollecting 为 0 时是空串。
@@ -555,7 +557,7 @@ type CollectTaskResult struct {
// Skipped 是跳过总数,供测试和粗粒度统计用;
// 界面提示要按原因分开说,见 FormatCollectTaskMessage,不要直接显示这个数。
func (r CollectTaskResult) Skipped() int {
return r.SkippedCollecting + r.SkippedCollected + r.SkippedDeleted
return r.SkippedShopeeMissing + r.SkippedUnlinked + r.SkippedCollecting + r.SkippedCollected + r.SkippedDeleted
}
// CreatePddCollectTasks 为勾选的 PDD 商品创建不指定客户端的采集任务。
@@ -784,6 +786,12 @@ func formatRetryWait(remain time.Duration) string {
// (比如还要等多久),见 #24。
func FormatCollectTaskMessage(r CollectTaskResult) string {
var reasons []string
if r.SkippedShopeeMissing > 0 {
reasons = append(reasons, fmt.Sprintf("%d 个蝦皮商品已不存在", r.SkippedShopeeMissing))
}
if r.SkippedUnlinked > 0 {
reasons = append(reasons, fmt.Sprintf("%d 个未关联 PDD", r.SkippedUnlinked))
}
if r.SkippedCollecting > 0 {
reason := fmt.Sprintf("%d 个正在采集中", r.SkippedCollecting)
if r.Created > 0 {
+37
View File
@@ -112,6 +112,43 @@ func CreateShopeePddCollectTaskForUser(db *sql.DB, actor *model.User, shopeeGood
return CreatePddCollectTasksForUser(db, actor, []string{product.PddGoodsID}, "")
}
// CreateShopeePddCollectTasksForUser 把勾选的蝦皮商品转换成当前 PDD 商品后批量建采集任务。
// 蝦皮与 PDD 可以多对一,因此交给统一 PDD 服务前先按 PDD goods_id 去重;
// 商品不存在和未关联按原因跳过,其余状态、防重和客户端权限仍由统一服务处理。
func CreateShopeePddCollectTasksForUser(db *sql.DB, actor *model.User, shopeeGoodsIDs []string, clientID string) (CollectTaskResult, error) {
shopeeGoodsIDs = dedupe(shopeeGoodsIDs)
if len(shopeeGoodsIDs) == 0 {
return CollectTaskResult{}, invalidInput("没有选择蝦皮商品")
}
links, err := repository.ListShopeePddLinksByGoodsIDs(db, shopeeGoodsIDs)
if err != nil {
return CollectTaskResult{}, err
}
var skipped CollectTaskResult
pddGoodsIDs := make([]string, 0, len(shopeeGoodsIDs))
for _, shopeeGoodsID := range shopeeGoodsIDs {
pddGoodsID, exists := links[shopeeGoodsID]
if !exists {
skipped.SkippedShopeeMissing++
continue
}
if strings.TrimSpace(pddGoodsID) == "" {
skipped.SkippedUnlinked++
continue
}
pddGoodsIDs = append(pddGoodsIDs, pddGoodsID)
}
result, err := CreatePddCollectTasksForUser(db, actor, dedupe(pddGoodsIDs), clientID)
if err != nil {
return CollectTaskResult{}, err
}
result.SkippedShopeeMissing = skipped.SkippedShopeeMissing
result.SkippedUnlinked = skipped.SkippedUnlinked
return result, nil
}
// AssociateSybPdd 从顺运宝明细解析出可信的蝦皮商品,再复用统一关联逻辑。
func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (string, error) {
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
+87
View File
@@ -3,6 +3,7 @@ package service
import (
"database/sql"
"errors"
"strings"
"testing"
"time"
@@ -120,6 +121,92 @@ func TestCreateShopeePddCollectTaskForUser_记录当前用户(t *testing.T) {
}
}
func TestCreateShopeePddCollectTasksForUser_按Pdd去重并分类跳过(t *testing.T) {
db := newTestDB(t)
admin, _, _ := insertTaskUsers(t, db)
for _, goodsID := range []string{"S-A", "S-B", "S-UNLINKED", "S-DELETED"} {
seedShopeeProduct(t, db, goodsID, goodsID)
}
for _, goodsID := range []string{"S-A", "S-B"} {
if _, err := AssociateShopeePdd(db, goodsID, pddURLA, false); err != nil {
t.Fatal(err)
}
}
if _, err := AssociateShopeePdd(db, "S-DELETED", pddURLB, false); err != nil {
t.Fatal(err)
}
if err := repository.SoftDeletePddProduct(db, "937122477375"); err != nil {
t.Fatal(err)
}
result, err := CreateShopeePddCollectTasksForUser(db, admin,
[]string{"S-A", "S-B", "S-A", "S-UNLINKED", "S-DELETED", "S-MISSING"}, "")
if err != nil {
t.Fatal(err)
}
if result.Created != 1 || result.SkippedUnlinked != 1 || result.SkippedShopeeMissing != 1 || result.SkippedDeleted != 1 {
t.Fatalf("批量分类或 PDD 去重错误: %+v", result)
}
var taskCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount); err != nil || taskCount != 1 {
t.Fatalf("相同 PDD 商品只应创建一条任务,count=%d err=%v", taskCount, err)
}
message := FormatCollectTaskMessage(result)
for _, want := range []string{"已创建 1 个", "1 个蝦皮商品已不存在", "1 个未关联 PDD", "1 个已删除的"} {
if !strings.Contains(message, want) {
t.Errorf("批量结果提示 %q 缺少 %q", message, want)
}
}
}
func TestCreateShopeePddCollectTasksForUser_已采集和正常采集中分别跳过(t *testing.T) {
db := newTestDB(t)
admin, _, _ := insertTaskUsers(t, db)
seedShopeeProduct(t, db, "S-COLLECTED", "已采集")
seedShopeeProduct(t, db, "S-COLLECTING", "采集中")
AssociateShopeePdd(db, "S-COLLECTED", pddURLA, false)
AssociateShopeePdd(db, "S-COLLECTING", pddURLB, false)
if err := repository.SetCollectResult(db, "737116531267", "商品", "店铺", collectedThreeDimensions); err != nil {
t.Fatal(err)
}
if result, err := CreatePddCollectTasksForUser(db, admin, []string{"937122477375"}, ""); err != nil || result.Created != 1 {
t.Fatalf("准备采集中任务失败: result=%+v err=%v", result, err)
}
result, err := CreateShopeePddCollectTasksForUser(db, admin,
[]string{"S-COLLECTED", "S-COLLECTING"}, "")
if err != nil || result.Created != 0 || result.SkippedCollected != 1 || result.SkippedCollecting != 1 {
t.Fatalf("状态分类错误: result=%+v err=%v", result, err)
}
}
func TestCreateShopeePddCollectTasksForUser_拒绝不可见客户端且整批不创建(t *testing.T) {
db := newTestDB(t)
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
if err := RegisterClient(db, model.Client{ClientID: "client-batch-b", Name: "B 的客户端"}, true); err != nil {
t.Fatal(err)
}
if _, _, err := AssignClient(db, admin, "client-batch-b", buyerB.UserID, time.Now()); err != nil {
t.Fatal(err)
}
seedShopeeProduct(t, db, "S-PERM", "权限商品")
AssociateShopeePdd(db, "S-PERM", pddURLA, false)
_, err := CreateShopeePddCollectTasksForUser(db, buyerA, []string{"S-PERM"}, "client-batch-b")
if !IsValidationError(err) {
t.Fatalf("伪造他人客户端应返回校验错误,实际 %v", err)
}
var taskCount int
db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount)
if taskCount != 0 {
t.Fatalf("权限失败后不应创建任务,实际 %d", taskCount)
}
product, _ := repository.GetPddProductByGoodsID(db, "737116531267")
if product.CollectStatus != model.CollectPending {
t.Fatalf("权限失败后商品状态应保持 pending,实际 %s", product.CollectStatus)
}
}
func TestCreateSybPddCollectTasksForUser_同一PDD商品去重(t *testing.T) {
db := newTestDB(t)
admin, _, _ := insertTaskUsers(t, db)
+37 -1
View File
@@ -4,6 +4,16 @@
{{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
<div class="toolbar">
{{if .CurrentUser.IsAdmin}}<a class="button" href="/integrations/catalog">导入记录</a>{{end}}
<form id="shopee-collect-form" method="post" action="/shopee/collect-batch" hidden></form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="shopee-collect-form">
<input type="hidden" name="list_goods_id" value="{{.Keyword}}" form="shopee-collect-form">
<input type="hidden" name="list_shop_name" value="{{.ShopNameKeyword}}" form="shopee-collect-form">
<input type="hidden" name="status" value="{{.StatusFilter}}" form="shopee-collect-form">
<input type="hidden" name="shop" value="{{.ShopFilter}}" form="shopee-collect-form">
<input type="hidden" name="image" value="{{.ImageFilter}}" form="shopee-collect-form">
<input type="hidden" name="page" value="{{.CurrentPage}}" form="shopee-collect-form">
<button type="button" data-modal-open="shopee-collect-modal" data-collect-open
data-collect-form="shopee-collect-form" data-need-checked>创建 PDD 采集任务</button>
<form class="inline grow" method="get" action="/shopee">
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花,
见 docs/admin/05-ui-specification.md §4.1、工单 #43。
@@ -77,7 +87,7 @@
<tbody>
{{range .Rows}}
<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 class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}" form="shopee-collect-form"></td>
<td>{{.GoodsID}}</td>
<td>{{if .ImageURL}}<button type="button" class="thumb-action" data-image-preview-url="{{.ImageURL}}" data-image-preview-title="{{.Title}}" title="查看蝦皮主图"><img src="{{.ImageURL}}" alt="{{.Title}} 缩略图" class="thumb" width="32" height="32" loading="lazy" referrerpolicy="no-referrer" data-image-thumb><span class="thumb-fallback" hidden data-image-thumb-fallback>加载失败</span></button>{{else}}<span class="thumb-placeholder">无图</span>{{end}}</td>
{{/* 商品名称列收窄,用独立的 .col-title 类设 max-width(具体数值见 app.css),
@@ -111,6 +121,32 @@
</table>
</div>
<div class="modal-backdrop" id="shopee-collect-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="shopee-collect-modal-title">
<div class="modal-head">
<h2 id="shopee-collect-modal-title">创建 PDD 采集任务</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<div class="modal-body">
<p>将按当前 PDD 关联处理已选择的 <strong data-collect-count>0</strong> 个蝦皮商品。</p>
<div class="field">
<label for="shopee-collect-client">执行客户端</label>
<select id="shopee-collect-client" name="client_id" form="shopee-collect-form" autofocus>
<option value="">不指定(任意客户端可领取)</option>
{{range .AssignableClients}}
<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>
{{end}}
</select>
</div>
<p class="hint">相同 PDD 商品只创建一个任务;未关联、已删除、已采集和正在采集的商品会按原因统计并跳过。</p>
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" form="shopee-collect-form" class="primary" data-collect-submit>确认创建</button>
</div>
</div>
</div>
<div class="modal-backdrop" id="image-preview-modal" hidden><div class="modal image-preview-modal" role="dialog" aria-modal="true" aria-labelledby="image-preview-title"><div class="modal-head"><h2 id="image-preview-title" data-image-preview-title>蝦皮商品主图</h2><button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button></div><div class="modal-body image-preview-body"><p class="hint image-preview-status" data-image-preview-status role="status">正在加载原图…</p><img data-image-preview-image alt="" hidden></div><div class="modal-foot"><a class="button-link" data-image-preview-link href="#" target="_blank" rel="noopener noreferrer">新窗口打开原图</a><button type="button" data-modal-close>关闭</button></div></div></div>
<p class="hint">
+9 -5
View File
@@ -87,8 +87,8 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
### 4.1 蝦皮数据模块
**顶部工具条:** 导入按钮、状态筛选(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、
商品 ID 搜索框、搜索按钮、删除按钮。采集从商品详情中发起,避免脱离商品上下文。
**顶部工具条:** 导入按钮、批量创建 PDD 采集任务按钮、状态筛选
(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、商品 ID 搜索框、搜索按钮、删除按钮。
`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话,
@@ -100,7 +100,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
| 列 | 说明 |
|---|---|
| 勾选 | 支持批量删除 |
| 勾选 | 支持批量删除、批量创建 PDD 采集任务 |
| 商品 ID | 蝦皮商品编号 |
| 商品名称 | |
| 颜色 / 尺码 / 建议 | 从规格原文解析,解析不出来留空并标记 |
@@ -114,7 +114,11 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
- 保存链接时从完整链接解析 `goods_id`,在同一事务内复用或复活 PDD 商品档案并更新当前关联;
- 更换成不同 `goods_id` 时必须明确勾选确认,旧规格映射保留但不会被当前关联查询误用;
- 弹窗内提供【创建采集任务】按钮,复用 PDD 商品模块的幂等与超时重试规则;
- `[必须]` 采集按钮**在本页只出现在弹窗里**,不要放在每个表格行——
- 蝦皮列表支持勾选商品后批量创建 PDD 采集任务。系统按蝦皮商品当前关联取得
PDD 商品并按 PDD 商品 ID 去重,可选当前账号可见客户端;未关联、PDD 已删除、
已采集和正常采集中的商品按原因统计并跳过。采集失败、采集超时或采集中但已无
有效任务的商品可以恢复创建。该入口不修改 PDD 关联,不替代商品详情里的单商品入口;
- `[必须]` 采集操作**只出现在顶部批量入口和商品详情弹窗里**,不要放在每个表格行——
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
`[注意]` PDD 商品模块(§4.2)继续作为商品档案、采集状态和故障诊断页;
@@ -527,7 +531,7 @@ MVP 之后:
- PDD 链接**人工填写**,不自动抓取。
- PDD 商品是**独立模块**,创建商品和创建采集任务都在那里;
蝦皮数据模块的编辑弹窗负责的是「这个蝦皮商品对应哪个 PDD 商品」。
- **采集任务不指定客户端**,谁领到算谁的;采购任务仍可分配,也允许留空。
- 采集任务和采购任务都允许指定当前账号可见的客户端,也允许留空;留空时由符合条件的客户端领取。
- 任务**分配给指定客户端**,Client 只领分给自己的。
- **不加心跳接口**;设置页显式登记,领取接口保留兼容登记,在线状态由最近活动时间派生。
- 规格映射**独立成表且可复用**,按 `(蝦皮商品 ID, spec_key, PDD 商品 ID)` 隔离;换 PDD 商品不会误用旧映射,换回时可继续复用。
+4 -1
View File
@@ -136,7 +136,7 @@
### 4.1 工具条
```text
[导入记录] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除]
[导入记录] [创建 PDD 采集任务] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除]
```
- **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。
@@ -166,6 +166,9 @@
- `[必须]` 店铺、图片使用独立三态下拉框,不使用含义不清的复选框;两个条件可互相
组合,也可与状态和商品 ID/标题搜索叠加。未知参数按“全部”处理,翻页、详情弹窗
和写操作返回时保留全部条件。工具条在窄窗口沿用公共 `flex-wrap` 自然换行。
- **创建 PDD 采集任务**(工单 #185):勾选蝦皮商品后打开批量确认弹窗,显示选择
数量及带可见标签的“执行客户端”,默认不指定。服务端按当前 PDD 关联和 PDD 商品 ID
去重;未关联、已删除、已采集、正常采集中分别统计并跳过。商品详情的单商品入口保留。
### 4.2 表格列