feat(web): prefill recent PDD products in picker (#168)
This commit is contained in:
@@ -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 = "商品已停用"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user