From 78f5895a0982e947ac8771703b5182c86cababd6 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 31 Aug 2026 15:15:12 +0800 Subject: [PATCH] feat(web): prefill recent PDD products in picker (#168) --- server/app/goauto/product/service.go | 14 +++++ server/app/goauto/product/service_test.go | 53 +++++++++++++++++++ .../views/goauto/shopee-products/index.vue | 22 +++++--- web/tests/e2e/shopee-pdd-picker.spec.ts | 42 +++++++++++++++ web/vite.config.mjs | 6 ++- 5 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 web/tests/e2e/shopee-pdd-picker.spec.ts diff --git a/server/app/goauto/product/service.go b/server/app/goauto/product/service.go index 92cc35b..5d6402c 100644 --- a/server/app/goauto/product/service.go +++ b/server/app/goauto/product/service.go @@ -309,12 +309,26 @@ func (service *Service) makeCollectionViews(ctx context.Context, records []model if err := service.DB.WithContext(ctx).Model(&models.CollectionRule{}).Count(&ruleCount).Error; err != nil { return nil, internalError(err) } + firstImages := make(map[uint64]ColorImageView, len(productIDs)) + var imageRows []models.PDDProductColorImage + if err := service.DB.WithContext(ctx).Where("pdd_product_id IN ?", productIDs).Order("pdd_product_id ASC, color ASC").Find(&imageRows).Error; err != nil { + return nil, internalError(err) + } + for _, image := range imageRows { + if _, exists := firstImages[image.PDDProductID]; exists { + continue + } + firstImages[image.PDDProductID] = ColorImageView{Color: image.Color, ImagePath: image.ImagePath, Width: image.Width, Height: image.Height, UpdatedAt: image.UpdatedAt} + } for _, record := range records { view, err := makeView(record) if err != nil { return nil, err } item := ListProductView{ProductView: view, CollectionSelectable: true} + if image, ok := firstImages[record.ID]; ok { + item.ColorImages = []ColorImageView{image} + } if record.Status == "disabled" { item.CollectionSelectable = false item.CollectionDisabledReason = "商品已停用" diff --git a/server/app/goauto/product/service_test.go b/server/app/goauto/product/service_test.go index 726e74c..8602bfe 100644 --- a/server/app/goauto/product/service_test.go +++ b/server/app/goauto/product/service_test.go @@ -265,3 +265,56 @@ func TestDetailUsesSameCollectionEligibilityAsList(t *testing.T) { } } } + +func TestListBulkLoadsOnlyFirstColorImagePerProduct(t *testing.T) { + db := openProductDatabase(t) + products := []models.PDDProduct{{GoodsID: "888881", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=888881", Status: "active"}, {GoodsID: "888882", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=888882", Status: "active"}} + if err := db.Create(&products).Error; err != nil { + t.Fatal(err) + } + device := models.AgentDevice{InstallID: "list-image-device", Name: "device", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOnline, TokenDigest: "digest", TokenIssuedAt: time.Now()} + if err := db.Create(&device).Error; err != nil { + t.Fatal(err) + } + rule := models.CollectionRule{Name: "image-rule", ContentJSON: `{}`} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + task := models.CollectionTask{PDDProductID: &products[0].ID, RuleID: rule.ID, DeviceID: &device.ID, Source: models.CollectionTaskSourceAdmin, Status: models.TaskStatusCompleted, URLSnapshot: products[0].URL, GoodsIDSnapshot: products[0].GoodsID, RuleSnapshot: `{}`} + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + images := []models.PDDProductColorImage{{PDDProductID: products[0].ID, Color: "白色", ImagePath: "/static/white.jpg", ContentType: "image/jpeg", ByteSize: 100, Width: 10, Height: 10, SourceTaskID: task.ID, SourceDeviceID: device.ID}, {PDDProductID: products[0].ID, Color: "黑色", ImagePath: "/static/black.jpg", ContentType: "image/jpeg", ByteSize: 100, Width: 10, Height: 10, SourceTaskID: task.ID, SourceDeviceID: device.ID}} + if err := db.Create(&images).Error; err != nil { + t.Fatal(err) + } + queries := 0 + callback := "test:count-list-image-queries" + if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) { + if strings.Contains(tx.Statement.Table, "pdd_product_color_image") { + queries++ + } + }); err != nil { + t.Fatal(err) + } + listed, err := NewService(db).List(context.Background(), ListRequest{Page: 1, PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if err := db.Callback().Query().Remove(callback); err != nil { + t.Fatal(err) + } + if queries != 1 { + t.Fatalf("color image queries=%d, want 1", queries) + } + byID := map[uint64]ListProductView{} + for _, item := range listed.Items { + byID[item.ID] = item + } + if got := byID[products[0].ID].ColorImages; len(got) != 1 || got[0].Color != "白色" { + t.Fatalf("unexpected first image: %+v", got) + } + if got := byID[products[1].ID].ColorImages; len(got) != 0 { + t.Fatalf("image-less product returned images: %+v", got) + } +} diff --git a/web/src/views/goauto/shopee-products/index.vue b/web/src/views/goauto/shopee-products/index.vue index da4728f..2c5a15e 100644 --- a/web/src/views/goauto/shopee-products/index.vue +++ b/web/src/views/goauto/shopee-products/index.vue @@ -41,13 +41,16 @@ - - - + + +

最近更新的 PDD 商品;可输入 goods_id、标题或店铺搜索。

+ + - - - + + + +
@@ -250,11 +253,13 @@ export default { }, // ---------------- PDD 选择 ---------------- - openPddPicker(target) { this.pddPicker = { open: true, loading: false, keyword: '', items: [], target } }, + openPddPicker(target) { this.pddPicker = { open: true, loading: false, keyword: '', items: [], target }; this.searchPddPicker() }, async searchPddPicker() { this.pddPicker.loading = true try { - const r = await listPddProducts({ page: 1, pageSize: 20, keyword: this.pddPicker.keyword }) + const keyword = this.pddPicker.keyword.trim() + const params = keyword ? { page: 1, pageSize: 20, keyword } : { page: 1, pageSize: 5, status: 'active' } + const r = await listPddProducts(params) this.pddPicker.items = r.data.items } finally { this.pddPicker.loading = false @@ -467,6 +472,7 @@ export default { .search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc} .primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399} .thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px} +.picker-help{margin:8px 0 0}.picker-thumb{display:block;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{display:flex;align-items:center;justify-content:center;width:56px;height:56px;margin:auto;border-radius:4px;background:#f1f5f9;color:#909399;font-size:11px} .link{color:#1677ff;cursor:pointer} .scope-note{margin-top:12px;font-size:12px;color:#b91c1c} .notice{margin-bottom:16px} diff --git a/web/tests/e2e/shopee-pdd-picker.spec.ts b/web/tests/e2e/shopee-pdd-picker.spec.ts new file mode 100644 index 0000000..2f860e3 --- /dev/null +++ b/web/tests/e2e/shopee-pdd-picker.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test' + +const routes = [{ path: '/goauto-order-workbench', component: 'Layout', menuName: 'GoAutoOrderWorkbench', title: '订单工作台', visible: '0', children: [{ path: '/shopee-products', component: '/goauto/shopee-products/index', menuName: 'GoAutoShopeeProducts', title: '虾皮商品', visible: '0' }] }] + +test('PDD 选择器默认最近五条并支持搜索与清空回退', async({ page, context }) => { + await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) + const listURLs: URL[] = [] + 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: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] }}}) + if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }}) + if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 9, shopeeItemId: 'S9', title: '待关联商品', shopName: '测试店', specs: [] }], total: 1, page: 1, pageSize: 20 }}}) + if (url.pathname.endsWith('/api/admin/v1/pdd-products')) { + listURLs.push(url) + const keyword = url.searchParams.get('keyword') + const item = keyword ? { id: 2, goodsId: '222222', title: '搜索商品', shopName: '搜索店', status: 'disabled', updatedAt: '2026-08-30T10:00:00Z' } : { id: 1, goodsId: '111111', title: '最近商品', shopName: '最近店', status: 'active', updatedAt: '2026-08-31T10:00:00Z', colorImages: [{ color: '黑色', imagePath: '/static/uploadfile/goauto-color/test.jpg' }] } + return route.fulfill({ json: { code: 200, data: { items: [item, ...(keyword ? [] : [{ id: 3, goodsId: '333333', title: '无图商品', shopName: '', status: 'active', updatedAt: '2026-08-29T10:00:00Z' }])], total: keyword ? 1 : 2, page: 1, pageSize: Number(url.searchParams.get('pageSize')) }}}) + } + return route.fulfill({ json: { code: 200, data: [] }}) + }) + await page.goto('/#/shopee-products') + await page.getByRole('button', { name: '添加', exact: true }).click() + await page.getByRole('button', { name: '搜索并选择', exact: true }).click() + await expect(page.getByText('最近商品', { exact: true })).toBeVisible() + await expect(page.getByText('暂无图片', { exact: true }).first()).toBeVisible() + expect(listURLs[0].searchParams.get('pageSize')).toBe('5') + expect(listURLs[0].searchParams.get('status')).toBe('active') + expect(listURLs[0].searchParams.has('keyword')).toBe(false) + await page.getByPlaceholder('goods_id、标题或店铺').fill('222222') + await page.getByRole('button', { name: '搜索', exact: true }).click() + await expect(page.getByText('搜索商品', { exact: true })).toBeVisible() + expect(listURLs.at(-1)?.searchParams.get('pageSize')).toBe('20') + expect(listURLs.at(-1)?.searchParams.get('keyword')).toBe('222222') + expect(listURLs.at(-1)?.searchParams.has('status')).toBe(false) + await expect(page.getByRole('button', { name: '选择', exact: true })).toBeDisabled() + await page.getByPlaceholder('goods_id、标题或店铺').fill('') + await page.getByPlaceholder('goods_id、标题或店铺').press('Enter') + await expect(page.getByText('最近商品', { exact: true })).toBeVisible() + expect(listURLs.at(-1)?.searchParams.get('pageSize')).toBe('5') + expect(listURLs.at(-1)?.searchParams.get('status')).toBe('active') +}) diff --git a/web/vite.config.mjs b/web/vite.config.mjs index c9cc32f..6b764ac 100644 --- a/web/vite.config.mjs +++ b/web/vite.config.mjs @@ -9,6 +9,9 @@ const resolve = dir => path.resolve(import.meta.dirname, dir) export default defineConfig(({ mode }) => { // 同时加载 VITE_ 与 VUE_APP_ 前缀,保持与 Vue CLI 时期的 .env 文件兼容 const env = loadEnv(mode, process.cwd(), ['VITE_', 'VUE_APP_', 'NODE_']) + // 颜色图片等上传文件的地址是相对路径 /static/...,浏览器会按页面来源解析。 + // 开发服务器与后端不同源,必须代理到后端,否则图片 404 显示“加载失败”。 + const backendOrigin = process.env.VUE_APP_BASE_API || env.VUE_APP_BASE_API || '' return { base: '/', @@ -49,7 +52,8 @@ export default defineConfig(({ mode }) => { server: { port: 9527, - open: false + open: false, + proxy: backendOrigin ? { '/static': { target: backendOrigin, changeOrigin: true }} : undefined }, css: {