feat(#74): 优化 SYB 商品筛选和列表布局

This commit is contained in:
QiuSW
2026-08-24 15:10:47 +08:00
parent 634bd46729
commit 0fccbc99c1
8 changed files with 134 additions and 29 deletions
+3 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 9bb0da18b68d368b37c060af9867983ace6b49f6
synchronized_at: 2026-08-24T03:42:12Z
wiki_revision: 5f88de723d9ca9124383cd4abcfa5c27ddcd6d48
synchronized_at: 2026-08-24T07:03:25Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -130,7 +130,7 @@ Android Portal/Agent
| Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key),采购员只可查看启用状态 |
| Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent |
| SYB 店铺管理页面与接口封装 | `web/src/views/goauto/syb-shops/`、`web/src/api/goauto/syb-shops.js`;确认原型快照 `prototypes/49/v1/index.html` |
| SYB 异步导入、当前页采购选择/确认/逐条结果与同步记录页面 | `web/src/views/goauto/syb-products/`、`web/src/views/goauto/syb-sync-runs/`、`web/src/api/goauto/syb-products.js`、`web/src/api/goauto/purchase-tasks.js`;确认原型见 #44 设计证据,导入原型快照为 `prototypes/50/v2/index.html` |
| SYB 异步导入、商品筛选、当前页采购选择/确认/逐条结果与同步记录页面 | `web/src/views/goauto/syb-products/`、`web/src/views/goauto/syb-sync-runs/`、`web/src/api/goauto/syb-products.js`、`web/src/api/goauto/purchase-tasks.js`;商品列表按店铺名称包含匹配,并支持最多 100 个多行订单号精确筛选;确认原型见 #44 设计证据,导入原型快照为 `prototypes/50/v2/index.html` |
| Android Agent 基线 | `android/app/src/main/java/cn/ilapage/goauto/agent/` |
| Android 无障碍规则执行 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/` |
| PDD 安全规格入口与评论页误触恢复 | `automation/PddProductDetailCollector.kt`:明确选择语义、底部购买文字、评论上下文排除、一次返回重试和结构化错误;不保存控件树或截图 |
+3 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 14e4596838288a4685ffac6b55b17fcecba40326
synchronized_at: 2026-08-24T03:42:20Z
wiki_revision: 5adb7b0f05c4562e23fe92fe7ff89625d697c2d4
synchronized_at: 2026-08-24T07:03:44Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -46,6 +46,7 @@ synchronized_at: 2026-08-24T03:42:20Z
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
- 重新解析只读取已保存的原始 JSON,不请求 SYB 接口,永不改写 `RawJSON`;标记 `manuallyConfirmed` 的明细默认跳过重新解析,除非显式指定强制覆盖。
- 人工修正的颜色/尺码视为与解析成功同等可信,同样会合并进虾皮商品档案;标记为已人工确认。
- Admin 商品列表的订单号筛选每行一个、去空去重后精确匹配,最多 100 个且单项最长 64 字符;店铺名称使用包含匹配。店铺、订单号与解析状态之间使用 AND,不再用该筛选框搜索蝦皮商品 ID 或商品标题。
- SYB 商品数据本身不保存 PDD 订单号、快递单号、支付或有效订单状态;Admin 的采购任务创建入口位于 SYB 商品列表,任务事实仍写入独立采购域。
## SYB 店铺过滤
+1 -1
View File
@@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) {
return
}
response, err := service.List(c.Request.Context(), ListRequest{
Page: page, PageSize: pageSize, Keyword: c.Query("keyword"), ParseStatus: strings.TrimSpace(c.Query("parseStatus")),
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")),
})
if err != nil {
writeError(c, err)
+36 -4
View File
@@ -39,7 +39,8 @@ func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
type ListRequest struct {
Page, PageSize int
Keyword string
ShopName string
OrderCodes []string
ParseStatus string
}
@@ -65,9 +66,18 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
request.PageSize = 100
}
query := service.DB.WithContext(ctx).Model(&models.SYBProduct{})
if request.Keyword = strings.TrimSpace(request.Keyword); request.Keyword != "" {
like := "%" + request.Keyword + "%"
query = query.Where("order_code LIKE ? OR shopee_item_id LIKE ? OR product_title LIKE ?", like, like, like)
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
if len([]rune(request.ShopName)) > 255 {
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
}
query = query.Where("shop_name LIKE ?", "%"+request.ShopName+"%")
}
orderCodes, err := normalizeOrderCodes(request.OrderCodes)
if err != nil {
return ListResponse{}, err
}
if len(orderCodes) > 0 {
query = query.Where("order_code IN ?", orderCodes)
}
if request.ParseStatus != "" {
if request.ParseStatus != models.SYBParseStatusSuccess && request.ParseStatus != models.SYBParseStatusUncertain && request.ParseStatus != models.SYBParseStatusFailed {
@@ -86,6 +96,28 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
func normalizeOrderCodes(raw []string) ([]string, error) {
seen := make(map[string]bool, len(raw))
result := make([]string, 0, len(raw))
for _, chunk := range raw {
for _, value := range strings.FieldsFunc(chunk, func(r rune) bool { return r == ',' || r == '\r' || r == '\n' }) {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
if len([]rune(value)) > 64 {
return nil, invalidRequest("订单号不能超过 64 个字符")
}
seen[value] = true
result = append(result, value)
if len(result) > 100 {
return nil, invalidRequest("一次最多查询 100 个订单号")
}
}
}
return result, nil
}
// DetailView exposes RawJSON, which the model deliberately keeps json:"-" so
// List (and any other place a bare models.SYBProduct gets serialized) never
// leaks the full raw payload by accident. This was caught before it shipped:
+51 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
@@ -20,7 +21,7 @@ func serviceErrCode(t *testing.T, err error) string {
return target.Code
}
func TestServiceListFiltersByKeywordAndParseStatus(t *testing.T) {
func TestServiceListFiltersByOrderCodesShopAndParseStatus(t *testing.T) {
db := openTestDB(t)
if _, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()); err != nil {
t.Fatalf("apply a: %v", err)
@@ -28,14 +29,39 @@ func TestServiceListFiltersByKeywordAndParseStatus(t *testing.T) {
if _, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB()); err != nil {
t.Fatalf("apply b: %v", err)
}
otherOrder := realOrder()
otherOrder.Code = "260729OTHER"
otherOrder.StockID++
otherOrder.ShopName = "其他店铺"
otherDetail := realDetailA()
otherDetail.ID++
if _, err := sybimport.ApplyDetail(context.Background(), db, otherOrder, otherDetail); err != nil {
t.Fatalf("apply other order: %v", err)
}
service := sybimport.NewService(db)
byKeyword, err := service.List(context.Background(), sybimport.ListRequest{Keyword: "260728TB95MJTQ"})
byOrders, err := service.List(context.Background(), sybimport.ListRequest{OrderCodes: []string{" 260728TB95MJTQ\n", "260729OTHER", "260728TB95MJTQ"}})
if err != nil {
t.Fatalf("list by keyword: %v", err)
t.Fatalf("list by order codes: %v", err)
}
if byKeyword.Total != 2 {
t.Fatalf("expected 2 rows for the order code, got %d", byKeyword.Total)
if byOrders.Total != 3 {
t.Fatalf("expected all 3 rows for two exact order codes, got %d", byOrders.Total)
}
combined, err := service.List(context.Background(), sybimport.ListRequest{ShopName: " 韓舍 ", OrderCodes: []string{"260728TB95MJTQ", "260729OTHER"}, ParseStatus: models.SYBParseStatusSuccess})
if err != nil {
t.Fatalf("list by combined filters: %v", err)
}
if combined.Total != 1 || combined.Items[0].OrderCode != "260728TB95MJTQ" {
t.Fatalf("shop, order and status filters must use AND: %+v", combined)
}
partialOrder, err := service.List(context.Background(), sybimport.ListRequest{OrderCodes: []string{"260728"}})
if err != nil {
t.Fatalf("list by partial order: %v", err)
}
if partialOrder.Total != 0 {
t.Fatalf("order codes must use exact matching, got %d rows", partialOrder.Total)
}
byStatus, err := service.List(context.Background(), sybimport.ListRequest{ParseStatus: models.SYBParseStatusUncertain})
@@ -47,6 +73,26 @@ func TestServiceListFiltersByKeywordAndParseStatus(t *testing.T) {
}
}
func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
service := sybimport.NewService(openTestDB(t))
tooMany := make([]string, 101)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("ORDER-%03d", i)
}
if code := serviceErrCode(t, func() error {
_, err := service.List(context.Background(), sybimport.ListRequest{OrderCodes: tooMany})
return err
}()); code != sybimport.CodeInvalidRequest {
t.Fatalf("expected too-many rejection, got %s", code)
}
if code := serviceErrCode(t, func() error {
_, err := service.List(context.Background(), sybimport.ListRequest{OrderCodes: []string{strings.Repeat("A", 65)}})
return err
}()); code != sybimport.CodeInvalidRequest {
t.Fatalf("expected too-long rejection, got %s", code)
}
}
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
db := openTestDB(t)
service := sybimport.NewService(db)
+17 -9
View File
@@ -6,7 +6,8 @@
<el-form-item><el-tooltip :disabled="isAdmin" content="只有管理员可以开始导入" placement="bottom"><span><el-button type="primary" :icon="Download" :disabled="!isAdmin" @click="openImport">导入</el-button></span></el-tooltip></el-form-item>
<el-form-item><el-button @click="$router.push('/syb-sync-runs/index')">同步记录</el-button></el-form-item>
<el-form-item v-if="canPurchase"><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
<el-form-item label="搜索"><el-input v-model="query.keyword" class="keyword-input" placeholder="订单号、虾皮商品ID或商品标题" clearable @keyup.enter="search" /></el-form-item>
<el-form-item label="店铺"><el-input v-model="query.shopName" class="shop-input" placeholder="输入店铺名称" clearable /></el-form-item>
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button v-if="canPurchase" type="primary" :disabled="selectedProducts.length === 0" @click="openPurchaseBatch">创建采购</el-button></el-form-item>
</el-form>
@@ -24,15 +25,14 @@
<el-table-column v-if="canPurchase" type="selection" width="48" :selectable="rowSelectable" />
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
<el-table-column label="订单号" prop="orderCode" min-width="120" />
<el-table-column label="虾皮商品" min-width="421"><template #default="{ row }"><a class="link" href="javascript:void(0)" @click="openShopeeDetail(row.shopeeProductId)">{{ row.shopeeItemId }} ↗</a><div class="muted">{{ row.productTitle || '未取到标题' }}</div></template></el-table-column>
<el-table-column label="虾皮商品" min-width="421"><template #default="{ row }"><template v-if="row.shopeeProductId"><a class="link" href="javascript:void(0)" @click="openShopeeDetail(row.shopeeProductId)">{{ row.shopeeItemId }} ↗</a></template><template v-else><span>{{ row.shopeeItemId || '—' }}</span><el-tag size="small" type="warning" effect="plain" class="unlinked-tag">未关联</el-tag></template><div class="muted">{{ row.productTitle || '未取到标题' }}</div></template></el-table-column>
<el-table-column label="店铺" prop="shopName" min-width="100" />
<el-table-column label="目标颜色 / 尺码" min-width="128"><template #default="{ row }"><div :class="{ 'missing': !row.targetColor }">颜色:{{ row.targetColor || '—' }}</div><div :class="{ 'missing': !row.targetSize }">尺码:{{ row.targetSize || '—' }}</div></template></el-table-column>
<el-table-column label="数量" prop="quantity" width="56" />
<el-table-column label="售价" width="110"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column>
<el-table-column label="虾皮关联" width="80"><template #default="{ row }"><el-tag :type="row.shopeeProductId ? 'success' : 'info'">{{ row.shopeeProductId ? '已关联' : '未关联' }}</el-tag></template></el-table-column>
<el-table-column label="解析状态" width="80"><template #default="{ row }"><el-tag :type="parseMeta(row.parseStatus).type">{{ parseMeta(row.parseStatus).label }}</el-tag><el-tag v-if="row.manuallyConfirmed" size="small" type="success" effect="plain" style="margin-left:4px">人工已确认</el-tag></template></el-table-column>
<el-table-column v-if="canPurchase" label="采购准备" min-width="170"><template #default="{ row }"><span v-if="purchaseReadinessLoading" class="muted">正在检查</span><template v-else-if="purchaseReady(row).eligible"><el-tag type="success">可以创建</el-tag><div class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div></template><template v-else><el-tag type="warning">暂不可创建</el-tag><div class="purchase-reason">{{ purchaseReady(row).reason || '请刷新后重试' }}</div><el-button v-if="purchaseActionLabel(purchaseReady(row))" type="primary" link @click="runPurchaseNextAction(row, purchaseReady(row))">{{ purchaseActionLabel(purchaseReady(row)) }}</el-button></template></template></el-table-column>
<el-table-column label="操作" width="92" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && purchaseReady(row).eligible" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
<el-table-column v-if="canPurchase" label="采购准备" min-width="210"><template #default="{ row }"><span v-if="purchaseReadinessLoading" class="muted">正在检查</span><template v-else-if="purchaseReady(row).eligible"><el-tag type="success">可以创建</el-tag><div class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div></template><template v-else><el-tag type="warning">暂不可创建</el-tag><div class="purchase-reason">{{ purchaseReady(row).reason || '请刷新后重试' }}</div><el-button v-if="purchaseActionLabel(purchaseReady(row))" type="primary" link @click="runPurchaseNextAction(row, purchaseReady(row))">{{ purchaseActionLabel(purchaseReady(row)) }}</el-button></template></template></el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && purchaseReady(row).eligible" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
<p class="scope-note">本页不展示采购任务、PDD 订单号、快递单号和支付状态。</p>
@@ -157,7 +157,7 @@ export default {
purchaseDevices: [],
purchaseDialog: { open: false, loading: false, saving: false, ids: [], deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 },
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
query: { page: 1, pageSize: 20, keyword: '', parseStatus: '' },
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '' },
importDialog: { open: false, saving: false, range: null, spanDays: 0 },
activeRun: null, syncPollTimer: null,
detail: { open: false, loading: false, item: null },
@@ -185,13 +185,15 @@ export default {
beforeUnmount() { this.stopSyncPolling() },
methods: {
async load(allowNetworkRetry = true) {
const orderCodes = this.normalizedOrderCodes()
if (orderCodes === null) return
const generation = ++this.loadGeneration
this.loading = true
this.selectedProducts = []
this.$refs.productTable?.clearSelection()
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
try {
const r = await listSybProducts(this.query, requestOptions)
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus }, requestOptions)
if (generation !== this.loadGeneration) return
this.products = r.data.items
this.total = r.data.total
@@ -210,7 +212,13 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, keyword: '', parseStatus: '' }; this.load() },
reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '' }; this.load() },
normalizedOrderCodes() {
const values = [...new Set(this.query.orderCodesText.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]
if (values.length > 100) { ElMessage.warning('一次最多查询 100 个订单号'); return null }
if (values.some(value => [...value].length > 64)) { ElMessage.warning('订单号不能超过 64 个字符'); return null }
return values
},
handleSelectionChange(rows) { this.selectedProducts = rows },
rowSelectable(row) { return this.canPurchase && this.purchaseReady(row).eligible },
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
@@ -379,7 +387,7 @@ export default {
.search-form{display:flex;flex-wrap:wrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
.keyword-input{width:170px}
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
.toolbar-actions :deep(.el-form-item__content){gap:8px}
.toolbar-actions :deep(.el-button + .el-button){margin-left:0}
.selection-count{display:inline-flex;align-items:center;min-height:32px;white-space:nowrap;color:#606266}
+2 -2
View File
@@ -85,7 +85,7 @@ test('新的查询结果不会被旧采购预检覆盖', async({ page, context }
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/syb-products')) {
const searched = url.searchParams.get('keyword') === '新商品'
const searched = url.searchParams.get('orderCodes') === 'NEW-76'
const item = searched
? { id: 76, orderCode: 'NEW-76', shopeeItemId: '10076', productTitle: '新商品', quantity: 1, parseStatus: 'success' }
: { id: 75, orderCode: 'OLD-75', shopeeItemId: '10075', productTitle: '旧商品', quantity: 1, parseStatus: 'success' }
@@ -104,7 +104,7 @@ test('新的查询结果不会被旧采购预检覆盖', async({ page, context }
await page.goto('http://localhost:9527/#/syb-products/index')
await expect(page.getByText('旧商品')).toBeVisible()
await page.getByPlaceholder('订单号、虾皮商品ID或商品标题').fill('新商品')
await page.getByPlaceholder('每行一个订单号,最多 100 个').fill('NEW-76')
await page.getByRole('button', { name: '查询' }).click()
await expect(page.getByText('新商品', { exact: true })).toBeVisible()
await expect(page.getByText('新商品暂不可创建')).toBeVisible()
+21 -3
View File
@@ -1,13 +1,15 @@
import { expect, test } from '@playwright/test'
test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context }) => {
await page.setViewportSize({ width: 1920, height: 1080 })
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: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
if (url.pathname.endsWith('/api/admin/v1/syb-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 1, orderCode: 'SYB001', shopeeItemId: '26154802794', shopeeProductId: 9, productTitle: '用于验证加宽后的虾皮商品标题区域', shopName: '大碼臻選', targetColor: '黑色', targetSize: 'XL', quantity: 2, unitPriceCent: 1000, parseStatus: 'success', manuallyConfirmed: false }], total: 1, page: 1, pageSize: 20 }}})
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 1, eligible: true, minUnitPriceCent: 200, maxUnitPriceCent: 1500 }], eligibleCount: 1, skippedCount: 0 }}})
if (url.pathname.endsWith('/api/admin/v1/syb-products')) { listURLs.push(url); return route.fulfill({ json: { code: 200, data: { items: [{ id: 1, orderCode: 'SYB001', shopeeItemId: '26154802794', shopeeProductId: 9, productTitle: '用于验证加宽后的虾皮商品标题区域', shopName: '大碼臻選', targetColor: '黑色', targetSize: 'XL', quantity: 2, unitPriceCent: 1000, parseStatus: 'success', manuallyConfirmed: false }, { id: 2, orderCode: 'SYB002', shopeeItemId: '26154802795', productTitle: '尚未关联的商品', shopName: '大碼臻選', targetColor: '白色', targetSize: 'L', quantity: 1, unitPriceCent: 900, parseStatus: 'success', manuallyConfirmed: false }], total: 2, page: 1, pageSize: 20 }}}) }
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 1, eligible: true, minUnitPriceCent: 200, maxUnitPriceCent: 1500 }, { sybProductId: 2, eligible: false, reason: '尚未关联蝦皮商品' }], eligibleCount: 1, skippedCount: 1 }}})
return route.fulfill({ json: { code: 200, data: [] }})
})
@@ -17,6 +19,9 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect(page.getByRole('button', { name: '从 SYB 导入', exact: true })).toHaveCount(0)
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '重新解析', exact: true })).toHaveCount(0)
await expect(page.getByRole('columnheader', { name: '蝦皮关联' })).toHaveCount(0)
await expect(page.getByText('未关联', { exact: true }).last()).toBeVisible()
await expect(page.getByText('26154802795 ↗', { exact: true })).toHaveCount(0)
const importButton = await page.getByRole('button', { name: '导入', exact: true }).boundingBox()
const queryButton = await page.getByRole('button', { name: '查询', exact: true }).boundingBox()
@@ -27,11 +32,24 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
expect(Math.abs((reset?.y || 0) - (purchase?.y || 0))).toBeLessThan(2)
expect(purchase?.x || 0).toBeGreaterThan((reset?.x || 0) + (reset?.width || 0))
await expect(page.getByRole('button', { name: '创建采购', exact: true })).toBeDisabled()
const shopInput = await page.getByPlaceholder('输入店铺名称').boundingBox()
const orderInput = await page.getByPlaceholder('每行一个订单号,最多 100 个').boundingBox()
expect(shopInput).not.toBeNull(); expect(orderInput).not.toBeNull()
expect(shopInput?.x || 0).toBeLessThan(orderInput?.x || 0)
await page.locator('.el-table__body-wrapper tbody tr').first().locator('.el-checkbox').click()
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '创建采购', exact: true })).toBeEnabled()
const widths = await page.locator('.el-table__header-wrapper colgroup col').evaluateAll(cols => cols.map(col => Number(col.getAttribute('width'))))
expect(widths.slice(0, 10)).toEqual([48, 76, 120, 421, 100, 128, 56, 110, 80, 80])
expect(widths[9]).toBeGreaterThanOrEqual(210)
expect(widths[10]).toBe(132)
await page.getByPlaceholder('输入店铺名称').fill('大碼')
await page.getByPlaceholder('每行一个订单号,最多 100 个').fill(' SYB001\n\nSYB002\nSYB001 ')
await page.getByPlaceholder('每行一个订单号,最多 100 个').press('Control+Enter')
await expect.poll(() => listURLs.length).toBeGreaterThan(1)
const latest = listURLs.at(-1)
expect(latest?.searchParams.get('shopName')).toBe('大碼')
expect(latest?.searchParams.get('orderCodes')).toBe('SYB001,SYB002')
})