Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4d4a6fa79 | ||
|
|
1b4f7cd392 | ||
|
|
ddc18f5370 | ||
|
|
6752b149d8 | ||
|
|
a38688cdd3 | ||
|
|
c6c7fbe2d4 |
@@ -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: 9dfe6566032003ed16f7c693d5d257500b7c5025
|
||||
synchronized_at: 2026-10-05T07:05:36Z
|
||||
<!-- 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:Admin SYB 商品页仅从「店铺管理」选择店铺,支持最多 50 家多选,停用但未删除的店铺仍可选。所选店铺之间为 OR,与订单号、解析状态、处理阶段、采购类型及来源创建日期等条件为 AND,筛选后分页。订单号按完整值精确查询,最多 100 个去重非空值,每个最多 64 字符,不检索虾皮商品 ID 或标题。店铺名称与同步规则使用相同的首尾去空白、全角 ASCII 折半角及大小写归一,不合并名称中间空格。旧 API 保留 shopName 包含查询;新 Admin 不允许自由输入或选择已删除店铺。店铺改名后,保留旧名称的历史商品可能无法通过新名称选项查到,此限制已获用户接受。
|
||||
- SYB 商品数据本身不保存 PDD 订单号、快递单号、支付或有效订单状态;Admin 的采购任务创建入口位于 SYB 商品列表,任务事实仍写入独立采购域。
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: cc305694c6d7a6dfaa0e2eb150183fd36bb864a6
|
||||
synchronized_at: 2026-10-05T06:51:22Z
|
||||
wiki_revision: 40e77a656d1c21d72454ad722a4f71a0a4491241
|
||||
synchronized_at: 2026-10-05T07:18:16Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -106,7 +106,7 @@ adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchas
|
||||
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
|
||||
## SYB 商品列表查询等待(#355)
|
||||
|
||||
实现绑定 `a24c206`,仅 Web 客户端;已通过本地合成数据测试与构建,尚未合并 main 或发布线上。
|
||||
实现绑定 `a24c206`,仅 Web 客户端;2026-10-05 与 #353/#354 合并至 main `1b4f7cd` 并配套发布 Server/Web 至现有 167 服务器。已通过本地合成测试及线上只读页面验证:列表请求实际等待预算为 60000ms,原筛选组合超过 10 秒后正常返回。发布记录和回退目标见 #355 工单。
|
||||
|
||||
- `web/src/api/goauto/syb-products.js` 的 `listSybProducts` 为 `GET /api/admin/v1/syb-products` 单独设置默认 `timeout: 60000`,覆盖 SYB 商品页搜索、翻页和修改每页数量。全局请求默认仍为 10000ms,其他页面及详情、AI 匹配、采购、采集、退货操作的原超时不变。
|
||||
- 保留现有加载、错误提示、取消和旧请求隔离逻辑,不增加自动重试。慢请求在 10~60 秒间完成时不再被原 10 秒客户端预算提前中止;超过 60 秒仍会超时,上游更短的超时也可能先终止请求。
|
||||
|
||||
@@ -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: 60bac77693f31b33162a65dc902456f324774f7b
|
||||
synchronized_at: 2026-10-05T07:06:06Z
|
||||
<!-- 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 商品列表店铺筛选:Admin 使用重复的 shopIds 查询参数,表示 1~50 个店铺管理 ID,集合内按 OR 匹配。shopIds 不可与旧 shopName 同传;非法、不存在或软删除的 ID 返回 422。服务端读取管理店铺(包含停用店铺),用与店铺同步一致的首尾去空白、全角 ASCII 折半角及大小写归一规则,与已存 SYB 商品的原始店铺名比较。其他筛选条件按 AND 组合,筛选后分页;无匹配时返回空列表,不放宽为全部店铺。旧 shopName 包含查询只供旧客户端兼容,新 Admin 仅提交管理店铺 ID。管理店铺改名后,历史商品保留的旧名称可能无法按新店铺选项查询,此限制已获 #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`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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, timeout: 60000, ...options })
|
||||
return request({ url: '/api/admin/v1/syb-products', method: 'get', params, timeout: 60000, 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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -32,6 +32,14 @@ test('existing cancellation and error options are preserved', () => {
|
||||
assert.equal(config.signal.aborted, true)
|
||||
})
|
||||
|
||||
test('multi-shop serialization and explicit timeout override survive integration', () => {
|
||||
const api = load(config => config)
|
||||
const config = api.listSybProducts({ shopIds: [1, 2], page: 1 })
|
||||
assert.equal(config.timeout, 60000)
|
||||
assert.equal(config.paramsSerializer.serialize(config.params), 'shopIds=1&shopIds=2&page=1')
|
||||
assert.equal(api.listSybProducts({}, { timeout: 15000 }).timeout, 15000)
|
||||
})
|
||||
|
||||
test('other SYB API functions do not acquire the list timeout; global timeout stays 10 seconds', () => {
|
||||
const api = load(config => config)
|
||||
for (const name of names.filter(name => name !== 'listSybProducts')) {
|
||||
|
||||
Reference in New Issue
Block a user