feat(web): prefill recent PDD products in picker (#168)

This commit is contained in:
QiuSW
2026-08-31 15:15:12 +08:00
parent d89a0270b7
commit 78f5895a09
5 changed files with 128 additions and 9 deletions
+14
View File
@@ -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 = "商品已停用"
+53
View File
@@ -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)
}
}
+14 -8
View File
@@ -41,13 +41,16 @@
</el-dialog>
<!-- PDD 选择器 -->
<el-dialog v-model="pddPicker.open" title="搜索并选择 PDD 商品" width="720px" :close-on-click-modal="false">
<el-input v-model="pddPicker.keyword" placeholder="goods_id、标题或店铺" clearable @keyup.enter="searchPddPicker" />
<el-table v-loading="pddPicker.loading" :data="pddPicker.items" border size="small" style="margin-top:12px" max-height="360" empty-text="请输入关键词查询">
<el-dialog v-model="pddPicker.open" title="搜索并选择 PDD 商品" width="860px" :close-on-click-modal="false">
<el-input v-model="pddPicker.keyword" placeholder="goods_id、标题或店铺" clearable @clear="searchPddPicker" @keyup.enter="searchPddPicker"><template #append><el-button @click="searchPddPicker">搜索</el-button></template></el-input>
<p class="muted picker-help">最近更新的 PDD 商品;可输入 goods_id、标题或店铺搜索。</p>
<el-table v-loading="pddPicker.loading" :data="pddPicker.items" border size="small" style="margin-top:12px" max-height="360" empty-text="最近更新的 PDD 商品,可输入 goods_id、标题或店铺搜索">
<el-table-column label="颜色图" width="86" align="center"><template #default="{ row }"><el-image v-if="row.colorImages?.[0]?.imagePath" class="picker-thumb" :src="row.colorImages[0].imagePath" :alt="`${row.colorImages[0].color || ''}颜色图`" fit="cover" lazy><template #error><span class="picker-image-placeholder">暂无图片</span></template></el-image><span v-else class="picker-image-placeholder">暂无图片</span></template></el-table-column>
<el-table-column label="goods_id" prop="goodsId" width="150" />
<el-table-column label="标题 / 店铺" min-width="200"><template #default="{ row }"><div>{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName }}</div></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'disabled' ? 'info' : 'success'">{{ row.status === 'disabled' ? '已停用' : '可用' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button type="primary" link :disabled="row.status === 'disabled'" @click="pickPdd(row)">选择</el-button></template></el-table-column>
<el-table-column label="标题 / 店铺" min-width="200"><template #default="{ row }"><div>{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '未填写店铺' }}</div></template></el-table-column>
<el-table-column label="最近更新" width="150"><template #default="{ row }">{{ parseTime(row.updatedAt) }}</template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'disabled' ? 'info' : 'success'">{{ row.status === 'disabled' ? '已停用' : '可用' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="80"><template #default="{ row }"><el-button type="primary" link :disabled="row.status === 'disabled'" @click="pickPdd(row)">选择</el-button></template></el-table-column>
</el-table>
<template #footer><el-button @click="pddPicker.open = false">取消</el-button></template>
</el-dialog>
@@ -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}
+42
View File
@@ -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')
})
+5 -1
View File
@@ -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: {