Compare commits

...
11 changed files with 324 additions and 50 deletions
+3 -3
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: 76037bfdf5e986c478055e7338c21ee1ba561bad
synchronized_at: 2026-09-30T01:50:31Z
wiki_revision: 93335c279bcd660ceb5179ea4809ae8a2d90210c
synchronized_at: 2026-10-05T03:50:11Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -70,7 +70,7 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`(
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
- 重新解析只读取已保存的原始 JSON,不请求 SYB 接口,永不改写 `RawJSON`;标记 `manuallyConfirmed` 的明细默认跳过重新解析,除非显式指定强制覆盖。
- 人工修正的颜色/尺码视为与解析成功同等可信,同样会合并进虾皮商品档案;标记为已人工确认。
- Admin 商品列表的订单号筛选每行一个、去空去重后精确匹配,最多 100 个且单项最长 64 字符;店铺名称使用包含匹配。店铺、订单号与解析状态之间使用 AND,不再用该筛选框搜索蝦皮商品 ID 或商品标题。
- #353: The Admin SYB product page offers multi-select shops exclusively from managed SYB shops (including disabled shops), up to 50. Selected shops are OR-combined, then AND-combined with order code, parse status, process stage, purchase type, source creation-date and pagination filters. Order-code search accepts up to 100 distinct nonblank exact codes of at most 64 characters each; it does not search Shopee item IDs or titles. Matching uses the same edge-trim/full-width ASCII/case folding as SYB shop sync, without collapsing interior spaces. Legacy API clients can still use shopName substring search, but Admin cannot enter free-form or deleted shops. If a managed shop is renamed, historical products retaining its old name may no longer be found under the new selection; accepted limitation.
- SYB 商品数据本身不保存 PDD 订单号、快递单号、支付或有效订单状态;Admin 的采购任务创建入口位于 SYB 商品列表,任务事实仍写入独立采购域。
+4 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: f92513e7184fe704d2b9d1c6606b16a86d8d3fb8
synchronized_at: 2026-09-30T01:58:59Z
wiki_revision: efd822778c102c9d3c4fd4ea48bda6b59368d698
synchronized_at: 2026-10-05T03:46:26Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -83,6 +83,8 @@ POST /api/admin/v1/syb-products/reparse-batch
PATCH /api/admin/v1/syb-products/{productId}/correction
```
#353 SYB product list shop filter: Admin sends repeated shopIds query parameters (1-50 managed-shop IDs, OR within the set). shopIds cannot be combined with legacy shopName; malformed, missing, or soft-deleted IDs return 422. Selected IDs resolve against the managed SYB shop list, including disabled shops, and are compared with persisted SYB product shop names using the same trim/full-width ASCII/case normalization used by shop sync. Other filters remain AND conditions, with pagination after filtering. Zero matches return an empty list. The legacy shopName substring filter remains available only to older API clients; the Admin UI uses managed IDs only. Renaming a managed shop may leave historical products under its old name unfindable by that shop selection; this limitation is accepted for #353.
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
+17 -1
View File
@@ -32,6 +32,22 @@ func (handler Handler) logInternalFailure(c *gin.Context, stage string, err erro
}
func (handler Handler) List(c *gin.Context) {
var shopIDs []uint64
if values, present := c.Request.URL.Query()["shopIds"]; present {
if len(values) == 0 || len(values) > 50 {
writeError(c, invalidRequest("shopIds 数量必须为 1 到 50 个"))
return
}
shopIDs = make([]uint64, 0, len(values))
for _, value := range values {
id, err := strconv.ParseUint(value, 10, 64)
if err != nil || id == 0 {
writeError(c, invalidRequest("shopIds 必须是有效的店铺 ID"))
return
}
shopIDs = append(shopIDs, id)
}
}
page, err := queryInt(c.Query("page"), 1)
if err != nil {
writeError(c, invalidRequest("page 必须是正整数"))
@@ -47,7 +63,7 @@ func (handler Handler) List(c *gin.Context) {
return
}
response, err := service.List(c.Request.Context(), ListRequest{
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")),
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), ShopIDs: shopIDs, OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")),
})
if err != nil {
writeError(c, err)
+43 -1
View File
@@ -9,6 +9,7 @@ import (
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
"go-admin/app/goauto/sybshop"
"gorm.io/gorm"
)
@@ -42,6 +43,7 @@ func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
type ListRequest struct {
Page, PageSize int
ShopName string
ShopIDs []uint64
OrderCodes []string
ParseStatus string
ProcessStage string
@@ -95,7 +97,47 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
if createdTo != nil {
query = query.Where("syb_stock_created_at < ?", *createdTo)
}
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" && len(request.ShopIDs) > 0 {
return ListResponse{}, invalidRequest("shopName 和 shopIds 不能同时使用")
}
if len(request.ShopIDs) > 0 {
if len(request.ShopIDs) > 50 {
return ListResponse{}, invalidRequest("一次最多选择 50 个店铺")
}
selected := make(map[uint64]struct{}, len(request.ShopIDs))
for _, id := range request.ShopIDs {
if id == 0 {
return ListResponse{}, invalidRequest("shopIds 必须是有效的店铺 ID")
}
selected[id] = struct{}{}
}
var shops []models.SYBShop
if err := service.DB.WithContext(ctx).Where("id IN ?", request.ShopIDs).Find(&shops).Error; err != nil {
return ListResponse{}, internalError(err)
}
if len(shops) != len(selected) {
return ListResponse{}, invalidRequest("所选店铺不存在或已删除")
}
shopNames := make(map[string]struct{}, len(shops))
for _, shop := range shops {
shopNames[shop.NormalizedName] = struct{}{}
}
var sourceNames []string
if err := service.DB.WithContext(ctx).Model(&models.SYBProduct{}).Distinct("shop_name").Pluck("shop_name", &sourceNames).Error; err != nil {
return ListResponse{}, internalError(err)
}
matchedNames := make([]string, 0)
for _, name := range sourceNames {
if _, ok := shopNames[sybshop.Normalize(name)]; ok {
matchedNames = append(matchedNames, name)
}
}
if len(matchedNames) == 0 {
query = query.Where("1 = 0")
} else {
query = query.Where("shop_name IN ?", matchedNames)
}
} else if request.ShopName != "" {
if len([]rune(request.ShopName)) > 255 {
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
}
@@ -74,6 +74,57 @@ func TestServiceListFiltersByOrderCodesShopAndParseStatus(t *testing.T) {
}
}
func TestServiceListFiltersByManagedShopIDs(t *testing.T) {
db := openTestDB(t)
orders := []struct{ name, code string }{
{" ABC店 ", "ORDER-A"}, {"第二店", "ORDER-B"}, {"其他店", "ORDER-C"},
}
for i, fixture := range orders {
order := realOrder()
order.Code, order.ShopName, order.StockID = fixture.code, fixture.name, order.StockID+uint64(i)
detail := realDetailA()
detail.ID += uint64(i)
if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
t.Fatal(err)
}
}
shops := []models.SYBShop{
{DisplayName: "ABC店", NormalizedName: "abc店", Enabled: true},
{DisplayName: "第二店", NormalizedName: "第二店", Enabled: false},
{DisplayName: "未出现店", NormalizedName: "未出现店", Enabled: true},
}
if err := db.Create(&shops).Error; err != nil {
t.Fatal(err)
}
service := sybimport.NewService(db)
result, err := service.List(context.Background(), sybimport.ListRequest{ShopIDs: []uint64{shops[0].ID, shops[1].ID}, OrderCodes: []string{"ORDER-A", "ORDER-B", "ORDER-C"}})
if err != nil || result.Total != 2 {
t.Fatalf("multi-shop OR and order-code AND: result=%+v err=%v", result, err)
}
result, err = service.List(context.Background(), sybimport.ListRequest{ShopIDs: []uint64{shops[2].ID}})
if err != nil || result.Total != 0 {
t.Fatalf("unmatched managed shop must return empty: result=%+v err=%v", result, err)
}
for _, request := range []sybimport.ListRequest{
{ShopIDs: []uint64{shops[0].ID}, ShopName: "ABC"},
{ShopIDs: []uint64{shops[0].ID, 999999}},
{ShopIDs: []uint64{0}},
{ShopIDs: make([]uint64, 51)},
} {
_, err := service.List(context.Background(), request)
if code := serviceErrCode(t, err); code != sybimport.CodeInvalidRequest {
t.Fatalf("request %+v: %s", request, code)
}
}
if err := db.Delete(&shops[0]).Error; err != nil {
t.Fatal(err)
}
_, err = service.List(context.Background(), sybimport.ListRequest{ShopIDs: []uint64{shops[0].ID}})
if code := serviceErrCode(t, err); code != sybimport.CodeInvalidRequest {
t.Fatalf("deleted shop: %s", code)
}
}
func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
service := sybimport.NewService(openTestDB(t))
tooMany := make([]string, 101)
@@ -0,0 +1,36 @@
package sybimport_test
import (
"net/http"
"net/http/httptest"
"testing"
"go-admin/app/goauto/sybimport"
"github.com/gin-gonic/gin"
)
func TestListHandlerShopIDsValidation(t *testing.T) {
db := openTestDB(t)
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/syb-products", (sybimport.Handler{DB: db}).List)
for _, target := range []string{
"/syb-products?shopIds=",
"/syb-products?shopIds=abc",
"/syb-products?shopIds=0",
"/syb-products?shopIds=1&shopName=test",
"/syb-products?shopIds=999999",
} {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, target, nil))
if response.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s: expected 422, got %d %s", target, response.Code, response.Body.String())
}
}
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/syb-products?shopName=legacy", nil))
if response.Code != http.StatusOK {
t.Fatalf("legacy shopName: %d %s", response.Code, response.Body.String())
}
}
+8 -1
View File
@@ -1,7 +1,14 @@
import request from '@/utils/request'
export function listSybProducts(params, options = {}) {
return request({ url: '/api/admin/v1/syb-products', method: 'get', params, ...options })
return request({ url: '/api/admin/v1/syb-products', method: 'get', params, paramsSerializer: { serialize(values) {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(values)) {
if (Array.isArray(value)) value.forEach(item => query.append(key, String(item)))
else if (value !== undefined && value !== null && value !== '') query.append(key, String(value))
}
return query.toString()
} }, ...options })
}
export function getSybProduct(productId) {
+61 -23
View File
@@ -4,11 +4,23 @@
<el-card class="page-card" shadow="never">
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<div class="search-row search-row-primary">
<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-autocomplete v-model="query.shopName" class="shop-input" placeholder="选择或输入店铺" clearable value-key="displayName" :fetch-suggestions="suggestSybShops">
<template #default="{ item }"><div class="shop-option" :title="item.displayName"><span class="shop-option-name">{{ item.displayName }}</span><el-tag v-if="!item.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></template>
</el-autocomplete>
<el-select
v-model="query.shopIds"
class="shop-input"
multiple
filterable
collapse-tags
collapse-tags-tooltip
clearable
:multiple-limit="50"
:loading="shopOptionsLoading"
placeholder="选择店铺"
@visible-change="onShopVisible"
>
<el-option v-for="shop in shopOptions" :key="shop.id" :value="shop.id" :label="shop.displayName"><div class="shop-option" :title="shop.displayName"><span class="shop-option-name">{{ shop.displayName }}</span><el-tag v-if="!shop.enabled" size="small" type="info" effect="plain">已停用</el-tag></div></el-option>
<template #empty><div class="shop-empty"><template v-if="shopOptionsLoading">正在加载店铺…</template><template v-else-if="shopOptionsError">店铺加载失败 <el-button link type="primary" @click.stop="ensureShopOptions">重试</el-button></template><template v-else>暂无店铺</template></div></template>
</el-select>
</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="failed" /></el-select></el-form-item>
@@ -20,7 +32,10 @@
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
</div>
</el-form>
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
<div class="top-pagination-row">
<span v-if="canPurchase" class="selection-count" aria-live="polite">本页选择 <strong>{{ selectedProducts.length }}</strong> 条</span>
<pagination v-show="total > 0" v-model:page="query.page" v-model:limit="query.pageSize" class="top-pagination" :auto-scroll="false" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="onPagination" />
</div>
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
<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>
@@ -82,7 +97,7 @@
</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 && isPurchaseCandidate(row)" 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" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="load" />
<pagination v-show="total > 0" v-model:page="query.page" v-model:limit="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="onPagination" />
</el-card>
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
@@ -400,9 +415,10 @@ export default {
return {
loading: false, products: [], selectedProducts: [], total: 0,
purchaseReadiness: {}, purchaseReadinessLoading: false, specMatchLoading: false, loadGeneration: 0,
paginationTimer: null, loadedPage: 1, loadedPageSize: 200,
purchaseDevices: [],
purchasePreviewGeneration: 0,
shopOptions: [], shopOptionsLoaded: false, shopOptionsPromise: null,
shopOptions: [], shopOptionsLoaded: false, shopOptionsLoading: false, shopOptionsError: false, shopOptionsPromise: null,
purchaseDialog: { open: false, loading: false, saving: false, ids: [], selectedCount: 0, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0, previewError: '' },
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
specMatchResult: { open: false, items: [], selectedIDs: [], autoConfirmedCount: 0, pendingCount: 0, failedCount: 0, skippedCount: 0 },
@@ -410,7 +426,7 @@ export default {
imageSearchBatch: this.emptyImageSearchBatch(),
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
query: { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
@@ -473,10 +489,13 @@ export default {
}
},
created() { this.load() },
beforeUnmount() { if (this.paginationTimer !== null) clearTimeout(this.paginationTimer) },
methods: {
async load(allowNetworkRetry = true, waitForReadiness = false) {
const orderCodes = this.normalizedOrderCodes()
if (orderCodes === null) return
this.loadedPage = this.query.page
this.loadedPageSize = this.query.pageSize
const generation = ++this.loadGeneration
this.loading = true
this.selectedProducts = []
@@ -484,7 +503,7 @@ export default {
const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {}
try {
const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || []
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }, requestOptions)
const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopIds: this.query.shopIds, orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, purchaseType: this.query.purchaseType, createdFrom, createdTo }, requestOptions)
if (generation !== this.loadGeneration) return
this.products = r.data.items
this.total = r.data.total
@@ -507,7 +526,17 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
reset() { this.query = { page: 1, pageSize: 200, shopIds: [], orderCodesText: '', parseStatus: '', processStage: '', purchaseType: 'pdd', createdAtRange: [] }; this.load() },
onPagination() {
// Both controls may emit after a size change clamps the old page. Coalesce
// those events and keep the user on page one with exactly one list load.
if (this.paginationTimer !== null) clearTimeout(this.paginationTimer)
this.paginationTimer = setTimeout(() => {
this.paginationTimer = null
if (this.query.pageSize !== this.loadedPageSize) this.query.page = 1
if (this.query.page !== this.loadedPage || this.query.pageSize !== this.loadedPageSize) this.load()
}, 0)
},
// #340: 选择「退货待确认/已用退货/无需采购」这些不属于「需 PDD 采购」默认范围
// 的阶段时,自动把采购类型切到「全部」,否则处理阶段过滤会和采购类型过滤
// 的 AND 组合出空列表;用户仍可再手动切回。
@@ -516,22 +545,27 @@ export default {
this.query.purchaseType = 'all'
}
},
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
if (this.shopOptionsPromise) return this.shopOptionsPromise
this.shopOptionsPromise = listSybShops({ page: 1, pageSize: 200 }, { suppressNetworkError: true, suppressErrorMessage: true })
.then((response) => { this.shopOptions = response.data.items || [] })
.catch(() => { this.shopOptions = []; ElMessage.warning('店铺列表加载失败,仍可直接输入店铺名称查询') })
.finally(() => { this.shopOptionsLoaded = true; this.shopOptionsPromise = null })
this.shopOptionsLoading = true
this.shopOptionsError = false
this.shopOptionsPromise = (async() => {
const options = []
for (let page = 1; ; page += 1) {
const response = await listSybShops({ page, pageSize: 200 }, { suppressNetworkError: true, suppressErrorMessage: true })
options.push(...(response.data.items || []))
if (options.length >= response.data.total || !response.data.items?.length) break
}
this.shopOptions = options
this.shopOptionsLoaded = true
})().catch(() => {
this.shopOptionsError = true
ElMessage.warning('店铺列表加载失败,请重试')
}).finally(() => { this.shopOptionsLoading = false; this.shopOptionsPromise = null })
return this.shopOptionsPromise
},
async suggestSybShops(value, callback) {
await this.ensureShopOptions()
const keyword = this.normalizeShopName(value)
const matches = keyword ? this.shopOptions.filter(item => this.normalizeShopName(item.displayName).includes(keyword)) : this.shopOptions
callback(matches.slice(0, 50))
},
onShopVisible(visible) { if (visible) this.ensureShopOptions() },
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 }
@@ -942,9 +976,10 @@ export default {
.search-row-secondary{justify-content:flex-start}
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
.shop-input{width:210px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
.shop-option{display:flex;align-items:center;justify-content:space-between;gap:12px}
.shop-option-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.shop-empty{padding:8px 12px;color:var(--el-text-color-secondary)}
.toolbar-actions :deep(.el-form-item__content){gap:8px}
.toolbar-actions :deep(.el-button + .el-button){margin-left:0}
.action-button-wrap{display:inline-flex}
@@ -952,6 +987,10 @@ export default {
.action-count-primary{background:rgba(255,255,255,.2);color:inherit}
.selection-count{display:inline-flex;align-items:center;min-height:32px;white-space:nowrap;color:#606266}
.selection-count strong{margin:0 4px;font-variant-numeric:tabular-nums;color:#1e40af}
.top-pagination-row{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:8px 16px;margin-bottom:12px}
.top-pagination-row .selection-count{margin-right:auto}
.top-pagination{max-width:100%;min-width:0;overflow-x:auto}
.top-pagination.pagination-container{padding:4px 0}
.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}
.missing{color:#b91c1c}
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
@@ -961,7 +1000,6 @@ export default {
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.notice{margin-bottom:16px}
.return-missing-alert{margin:6px 0}
.compact-notice{margin-bottom:12px}
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}
.spec-match-summary{gap:8px 24px}
.purchase-note{margin:0 0 16px}
+97 -15
View File
@@ -22,7 +22,7 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect(page.getByRole('button', { name: '导入', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '同步记录', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '从 SYB 导入', exact: true })).toHaveCount(0)
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
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('本页不展示采购任务、PDD 订单号、快递单号和支付状态。', { exact: true })).toHaveCount(0)
@@ -34,42 +34,46 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect(shopeeTitle).toHaveAttribute('title', '用于验证加宽后的虾皮商品标题区域')
await expect(shopeeTitle).toHaveCSS('text-overflow', 'ellipsis')
const selectionCount = await page.getByText('已选择 0 条', { exact: true }).boundingBox()
const selectionCount = await page.getByText('本页选择 0 条', { exact: true }).boundingBox()
const queryButton = await page.getByRole('button', { name: '查询', exact: true }).boundingBox()
const reset = await page.getByRole('button', { name: '重置' }).boundingBox()
const purchase = await page.getByRole('button', { name: /创建采购\s*0/ }).boundingBox()
expect(selectionCount).not.toBeNull(); expect(queryButton).not.toBeNull(); expect(reset).not.toBeNull(); expect(purchase).not.toBeNull()
expect(Math.abs((selectionCount?.y || 0) - (queryButton?.y || 0))).toBeLessThan(2)
expect((selectionCount?.y || 0)).toBeGreaterThan((queryButton?.y || 0))
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: /创建采购\s*0/ })).toBeDisabled()
const shopControl = page.getByPlaceholder('选择或输入店铺')
const shopControl = page.getByRole('combobox', { name: '店铺' })
const shopInput = await shopControl.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 shopControl.click()
await page.locator('.shop-input').click()
await expect(page.getByText('大碼臻選', { exact: true }).last()).toBeVisible()
await expect(page.getByText('歷史店鋪', { exact: true }).last()).toBeVisible()
await expect(page.getByText('已停用', { exact: true })).toBeVisible()
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.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
await expect(page.getByRole('button', { name: /创建采集\s*1/ })).toBeEnabled()
const widths = await page.locator('.el-table__header-wrapper colgroup col').evaluateAll(cols => cols.map(col => Number(col.getAttribute('width'))))
expect(widths[3]).toBe(260)
expect(widths[9]).toBeGreaterThanOrEqual(210)
expect(widths[10]).toBe(132)
expect(widths[11]).toBe(132)
await shopControl.fill('大碼')
await page.locator('.shop-input').click()
await page.getByRole('option', { name: '大碼臻選' }).click()
await page.locator('.shop-input').click()
await page.getByRole('option', { name: /歷史店鋪/ }).click()
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.getAll('shopIds')).toEqual(['1', '2'])
expect(latest?.searchParams.has('shopName')).toBe(false)
expect(latest?.searchParams.get('orderCodes')).toBe('SYB001,SYB002')
await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select').click()
@@ -78,6 +82,53 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect.poll(() => listURLs.at(-1)?.searchParams.get('processStage')).toBe('pdd_pending')
})
test('SYB 商品上下分页同步且每次操作只请求一次列表', 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/menurole')) return route.fulfill({ json: { code: 200, data: sybProductRoute }})
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')) {
listURLs.push(url)
const currentPage = Number(url.searchParams.get('page') || 1)
return route.fulfill({ json: { code: 200, data: { items: [{ id: currentPage, orderCode: `SYB-${currentPage}`, shopeeItemId: '1001', shopeeProductId: 1, productTitle: '分页验证', shopName: '测试店铺', quantity: 1, unitPriceCent: 1000, parseStatus: 'success' }], total: 980, page: currentPage, pageSize: Number(url.searchParams.get('pageSize') || 200) }}})
}
if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) {
return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: Number(listURLs.at(-1)?.searchParams.get('page') || 1), pddProductId: 1, eligible: true, processStage: 'purchase_ready', processStageLabel: '可创建采购' }], eligibleCount: 1, skippedCount: 0 }}})
}
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('/#/syb-products')
await expect.poll(() => listURLs.length).toBe(1)
await expect(page.locator('.top-pagination .el-pagination')).toBeVisible()
await expect(page.locator('.pagination-container')).toHaveCount(2)
await expect(page.getByText('先完成并保存规格匹配')).toHaveCount(0)
await page.locator('.el-table__body-wrapper tbody tr').first().locator('.el-checkbox').click()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await page.locator('.top-pagination .el-pager .number').filter({ hasText: '5' }).click()
await expect.poll(() => listURLs.length).toBe(2)
expect(listURLs.at(-1)?.searchParams.get('page')).toBe('5')
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
await expect(page.locator('.pagination-container .el-pager .number.is-active')).toHaveCount(2)
await page.locator('.top-pagination .el-pagination__sizes').click()
await page.getByRole('option', { name: /500/ }).click()
await expect.poll(() => listURLs.length).toBe(3)
expect(listURLs.at(-1)?.searchParams.get('page')).toBe('1')
expect(listURLs.at(-1)?.searchParams.get('pageSize')).toBe('500')
await page.waitForTimeout(100)
expect(listURLs).toHaveLength(3)
await expect(page.locator('.pagination-container .el-pager .number.is-active')).toHaveCount(2)
await page.locator('.pagination-container:not(.top-pagination) .el-pager .number').filter({ hasText: '2' }).click()
await expect.poll(() => listURLs.length).toBe(4)
expect(listURLs.at(-1)?.searchParams.get('page')).toBe('2')
expect(listURLs.at(-1)?.searchParams.get('pageSize')).toBe('500')
})
test('批量 AI 匹配展示混合结果并在关闭后刷新采购候选', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let matched = false
@@ -127,7 +178,7 @@ test('批量 AI 匹配展示混合结果并在关闭后刷新采购候选', asyn
await expect(page.getByRole('button', { name: /AI 匹配\s*1/ })).toBeEnabled()
})
test('店铺选项加载失败时仍可输入历史店铺查询', async({ page, context }) => {
test('店铺选项加载失败时提示重试且不能输入任意店铺', 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 => {
@@ -141,13 +192,44 @@ test('店铺选项加载失败时仍可输入历史店铺查询', async({ page,
})
await page.goto('/#/syb-products')
const shopControl = page.getByPlaceholder('选择或输入店铺')
await shopControl.click()
await expect(page.getByText('店铺列表加载失败,仍可直接输入店铺名称查询', { exact: true })).toBeVisible()
const shopControl = page.getByRole('combobox', { name: '店铺' })
await page.locator('.shop-input').click()
await expect(page.getByRole('tooltip', { name: /店铺加载失败/ })).toBeVisible()
await expect(page.getByRole('button', { name: '重试' })).toBeVisible()
await shopControl.fill('已删除店铺')
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => listURLs.length).toBeGreaterThan(1)
expect(listURLs.at(-1)?.searchParams.get('shopName')).toBe('已删除店铺')
expect(listURLs.at(-1)?.searchParams.has('shopIds')).toBe(false)
expect(listURLs.at(-1)?.searchParams.has('shopName')).toBe(false)
})
test('店铺管理超过 200 家时仍可搜索并选择后续页店铺', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
const shopPages: number[] = []
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/menurole')) return route.fulfill({ json: { code: 200, data: sybProductRoute }})
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-shops')) {
const currentPage = Number(url.searchParams.get('page'))
shopPages.push(currentPage)
const start = (currentPage - 1) * 200 + 1
const count = currentPage === 1 ? 200 : 1
const items = Array.from({ length: count }, (_, index) => ({ id: start + index, displayName: `店铺${start + index}`, enabled: true }))
return route.fulfill({ json: { code: 200, data: { items, total: 201, page: currentPage, pageSize: 200 }}})
}
if (url.pathname.endsWith('/api/admin/v1/syb-products')) { listURLs.push(url); return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 200 }}}) }
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('/#/syb-products')
await page.locator('.shop-input').click()
await expect.poll(() => shopPages).toEqual([1, 2])
await page.getByRole('combobox', { name: '店铺' }).fill('店铺201')
await page.getByRole('option', { name: '店铺201' }).click()
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => listURLs.at(-1)?.searchParams.getAll('shopIds')).toEqual(['201'])
})
test('PDD 待采集的下一步在 SYB 列表上方打开对应 PDD 商品抽屉', async({ page, context }) => {
@@ -257,7 +339,7 @@ test('混选后采购与采集分别提交候选子集且采集按 PDD 去重',
const rows = page.locator('.el-table__body-wrapper tbody tr')
await expect(rows).toHaveCount(3)
for (let index = 0; index < 3; index += 1) await rows.nth(index).locator('.el-checkbox').click()
await expect(page.getByText('已选择 3 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 3 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
await expect(page.getByRole('button', { name: /创建采集\s*1/ })).toBeEnabled()
+2 -2
View File
@@ -61,7 +61,7 @@ test('one confirmation, mixed selection, progress, success and filter retention'
expect(writes).toEqual([11, 12])
expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200')
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
})
for (const mode of ['partial', 'conflict', 'auth']) {
@@ -75,7 +75,7 @@ for (const mode of ['partial', 'conflict', 'auth']) {
await expect(page.getByText(mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : '跳过 2 条', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '返回列表', exact: true }).click()
if (mode === 'partial') {
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /取消匹配\s*1/ }).first()).toBeEnabled()
}
})
+2 -2
View File
@@ -65,7 +65,7 @@ test('v2 button order, one confirmation, no write on return, progress and mutual
await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
})
for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
@@ -81,7 +81,7 @@ for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
if (mode === 'partial') {
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /使用退货\s*1/ })).toBeEnabled()
}
})