fix(#84): restore collection selection in locate mode

This commit is contained in:
QiuSW
2026-08-25 11:05:56 +08:00
parent 0991fd2b57
commit a622d718e0
4 changed files with 116 additions and 25 deletions
+42 -21
View File
@@ -100,6 +100,10 @@ type SaveResponse struct {
Replayed bool `json:"replayed,omitempty"`
}
type DetailResponse struct {
Product ListProductView `json:"product"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
@@ -197,19 +201,27 @@ func (service *Service) Update(ctx context.Context, productID uint64, request Up
if result.RowsAffected == 0 {
return SaveResponse{}, &ServiceError{Code: CodeProductNotFound, Message: "PDD 商品不存在"}
}
return service.Detail(ctx, productID)
var updated models.PDDProduct
if err := db.First(&updated, productID).Error; err != nil {
return SaveResponse{}, internalError(err)
}
view, err := makeView(updated)
return SaveResponse{Product: view}, err
}
func (service *Service) Detail(ctx context.Context, productID uint64) (SaveResponse, error) {
func (service *Service) Detail(ctx context.Context, productID uint64) (DetailResponse, error) {
var record models.PDDProduct
if err := service.DB.WithContext(ctx).First(&record, productID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return SaveResponse{}, &ServiceError{Code: CodeProductNotFound, Message: "PDD 商品不存在"}
return DetailResponse{}, &ServiceError{Code: CodeProductNotFound, Message: "PDD 商品不存在"}
}
return SaveResponse{}, internalError(err)
return DetailResponse{}, internalError(err)
}
view, err := makeView(record)
return SaveResponse{Product: view}, err
items, err := service.makeCollectionViews(ctx, []models.PDDProduct{record})
if err != nil {
return DetailResponse{}, err
}
return DetailResponse{Product: items[0]}, nil
}
func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) {
@@ -244,6 +256,18 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
if err := query.Order("updated_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&records).Error; err != nil {
return ListResponse{}, internalError(err)
}
items, err := service.makeCollectionViews(ctx, records)
if err != nil {
return ListResponse{}, err
}
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
func (service *Service) makeCollectionViews(ctx context.Context, records []models.PDDProduct) ([]ListProductView, error) {
items := make([]ListProductView, 0, len(records))
if len(records) == 0 {
return items, nil
}
productIDs := make([]uint64, 0, len(records))
for _, record := range records {
productIDs = append(productIDs, record.ID)
@@ -253,27 +277,24 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
TaskID uint64
}
activeTasks := map[uint64]uint64{}
if len(productIDs) > 0 {
var rows []activeTaskRow
if err := service.DB.WithContext(ctx).Model(&models.CollectionTask{}).
Select("pdd_product_id, MIN(id) AS task_id").
Where("pdd_product_id IN ? AND status IN ?", productIDs, []string{models.TaskStatusPending, models.TaskStatusRunning}).
Group("pdd_product_id").Scan(&rows).Error; err != nil {
return ListResponse{}, internalError(err)
}
for _, row := range rows {
activeTasks[row.PDDProductID] = row.TaskID
}
var rows []activeTaskRow
if err := service.DB.WithContext(ctx).Model(&models.CollectionTask{}).
Select("pdd_product_id, MIN(id) AS task_id").
Where("pdd_product_id IN ? AND status IN ?", productIDs, []string{models.TaskStatusPending, models.TaskStatusRunning}).
Group("pdd_product_id").Scan(&rows).Error; err != nil {
return nil, internalError(err)
}
for _, row := range rows {
activeTasks[row.PDDProductID] = row.TaskID
}
var ruleCount int64
if err := service.DB.WithContext(ctx).Model(&models.CollectionRule{}).Count(&ruleCount).Error; err != nil {
return ListResponse{}, internalError(err)
return nil, internalError(err)
}
items := make([]ListProductView, 0, len(records))
for _, record := range records {
view, err := makeView(record)
if err != nil {
return ListResponse{}, err
return nil, err
}
item := ListProductView{ProductView: view, CollectionSelectable: true}
if record.Status == "disabled" {
@@ -289,7 +310,7 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
}
items = append(items, item)
}
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
return items, nil
}
func validateCreateRequest(request SaveRequest) error {
+46
View File
@@ -190,3 +190,49 @@ func TestListDisablesCollectionWhenNoRuleExists(t *testing.T) {
t.Fatalf("missing-rule product remained selectable: %+v", listed.Items[0])
}
}
func TestDetailUsesSameCollectionEligibilityAsList(t *testing.T) {
db := openProductDatabase(t)
service := NewService(db)
rule := models.CollectionRule{Name: "rule", ContentJSON: `{}`}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
products := []models.PDDProduct{
{GoodsID: "555555", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=555555", Status: "active"},
{GoodsID: "666666", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=666666", Status: "disabled"},
{GoodsID: "777777", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=777777", Status: "active"},
}
if err := db.Create(&products).Error; err != nil {
t.Fatal(err)
}
active := models.CollectionTask{
PDDProductID: products[2].ID, RuleID: rule.ID, Status: models.TaskStatusPending,
URLSnapshot: products[2].URL, GoodsIDSnapshot: products[2].GoodsID, RuleSnapshot: rule.ContentJSON,
}
if err := db.Create(&active).Error; err != nil {
t.Fatal(err)
}
listed, err := service.List(context.Background(), ListRequest{Page: 1, PageSize: 20})
if err != nil {
t.Fatal(err)
}
byID := map[uint64]ListProductView{}
for _, item := range listed.Items {
byID[item.ID] = item
}
for _, product := range products {
detail, err := service.Detail(context.Background(), product.ID)
if err != nil {
t.Fatalf("detail %d: %v", product.ID, err)
}
want := byID[product.ID]
got := detail.Product
if got.CollectionSelectable != want.CollectionSelectable || got.CollectionDisabledReason != want.CollectionDisabledReason {
t.Fatalf("detail/list eligibility mismatch for %d: detail=%+v list=%+v", product.ID, got, want)
}
if (got.ActiveCollectionTaskID == nil) != (want.ActiveCollectionTaskID == nil) {
t.Fatalf("detail/list active task mismatch for %d: detail=%+v list=%+v", product.ID, got, want)
}
}
}
+2 -2
View File
@@ -12,10 +12,10 @@
<el-alert v-if="location.active && location.goodsId" :title="`当前定位:PDD-${location.productId} / goods_id ${location.goodsId}`" type="info" :closable="false" show-icon class="location-notice" />
<p class="muted">已选择 {{ selectedProducts.length }} 条;仅选择当前页,每个商品创建一个独立采集任务,不合并、不拆单。</p>
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe :empty-text="location.error || '暂无 PDD 商品,请先添加商品链接'" @selection-change="handleSelectionChange">
<el-table-column v-if="!location.active" type="selection" width="48" :selectable="isProductSelectable" />
<el-table-column type="selection" width="48" :selectable="isProductSelectable" />
<el-table-column label="goods_id" prop="goodsId" min-width="150" />
<el-table-column label="商品 / 店铺" min-width="240"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column>
<el-table-column label="状态 / 采集任务" min-width="190"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag><div v-if="location.active" class="collection-ready">当前定位商品</div><div v-else :class="row.collectionSelectable ? 'collection-ready' : 'muted'">{{ collectionStateText(row) }}</div></template></el-table-column>
<el-table-column label="状态 / 采集任务" min-width="190"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag><div :class="row.collectionSelectable ? 'collection-ready' : 'muted'">{{ collectionStateText(row) }}</div></template></el-table-column>
<el-table-column label="销量 / 评价" width="140"><template #default="{ row }"><div>销量 {{ countText(row.salesCount) }}</div><div class="muted">评价 {{ countText(row.reviewCount) }}</div></template></el-table-column>
<el-table-column label="规格" min-width="180"><template #default="{ row }">{{ specsSummary(row.specs) }}</template></el-table-column>
<el-table-column label="颜色价格" min-width="130"><template #default="{ row }">{{ priceSummary(row.specs) }}</template></el-table-column>
+26 -2
View File
@@ -47,7 +47,7 @@ test('PDD 商品列表、详情和编辑流程符合已确认原型', async ({ p
});
await page.goto('http://localhost:9527/#/pdd-products/index');
await expect(page.getByRole('heading', { name: 'PDD 商品' })).toBeVisible();
await expect(page.getByRole('button', { name: '添加', exact: true })).toBeVisible();
await expect(page.getByText('夏季防晒冰丝袖套')).toBeVisible();
await expect(page.getByText('¥25.90')).toBeVisible();
@@ -96,6 +96,11 @@ test('带 productId 的 PDD 商品路由进入单商品定位并可通过查询
await page.getByLabel('PDD 商品详情').getByLabel('关闭此对话框').click();
await expect(page.getByLabel('PDD 商品详情')).toBeHidden();
const locatedRow = page.locator('.el-table__body-wrapper tbody tr');
await expect(locatedRow.getByRole('checkbox')).toBeEnabled();
await locatedRow.locator('.el-checkbox').click();
await expect(page.getByText('已选择 1 条')).toBeVisible();
await expect(page.getByRole('button', { name: '创建采集 (1)', exact: true })).toBeEnabled();
await page.getByRole('button', { name: '查询', exact: true }).click();
await expect.poll(() => listCalls).toBe(1);
await expect(page).toHaveURL('http://localhost:9527/#/pdd-products/index');
@@ -143,6 +148,25 @@ test('单商品定位的重置和无效编号不会误显示其他商品', async
await expect(page).toHaveURL('http://localhost:9527/#/pdd-products/index');
});
test('单商品定位对不可采集商品禁用勾选并显示原因', async ({ page, context }) => {
const disabled = { ...product, status: 'disabled', collectionSelectable: false, collectionDisabledReason: '商品已停用' };
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]);
await page.route('**/api/**', async route => {
const url = new URL(route.request().url());
if (url.pathname.startsWith('/src/api/')) return route.continue();
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] } } });
if (url.pathname.endsWith('/api/admin/v1/pdd-products/1')) return route.fulfill({ json: { code: 200, data: { product: disabled } } });
return route.fulfill({ json: { code: 200, data: [] } });
});
await page.goto('http://localhost:9527/#/pdd-products/index?productId=1');
await page.getByLabel('PDD 商品详情').getByLabel('关闭此对话框').click();
await expect(page.getByLabel('PDD 商品详情')).toBeHidden();
await expect(page.locator('.el-table__body-wrapper tbody tr').getByRole('checkbox')).toBeDisabled();
await expect(page.getByText('商品已停用', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '创建采集', exact: true })).toBeDisabled();
});
test('PDD 商品当前页多选并批量创建独立采集任务', async ({ page, context }) => {
const second = { ...product, id: 2, goodsId: '719834019024', title: '第二件商品' };
const disabled = { ...product, id: 3, goodsId: '731370706977', title: '已停用商品', status: 'disabled', collectionSelectable: false, collectionDisabledReason: '商品已停用' };
@@ -169,7 +193,7 @@ test('PDD 商品当前页多选并批量创建独立采集任务', async ({ page
await expect(rows.nth(2).getByRole('checkbox')).toBeDisabled();
await expect(page.getByText('已选择 2 条')).toBeVisible();
await page.getByRole('button', { name: '批量创建采集任务' }).click();
await page.getByRole('button', { name: '创建采集 (2)', exact: true }).click();
await page.getByLabel('采集规则').click();
await page.getByRole('option', { name: 'PDD 商品详情采集' }).click();
await page.getByRole('button', { name: '创建 2 个采集任务' }).click();