feat(#49): complete SYB shop management
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Project-Profile
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
|
||||
wiki_revision: 1d9156c3b118f6458aa040ae6031ac400826385f
|
||||
synchronized_at: 2026-08-19T01:19:44Z
|
||||
wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
|
||||
synchronized_at: 2026-08-20T03:52:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
@@ -17,8 +17,8 @@ synchronized_at: 2026-08-19T01:19:44Z
|
||||
| 主要使用者 | 管理员、采购人员、开发维护者 |
|
||||
| Gitea 仓库 | `OPC/goauto` |
|
||||
| 默认分支 | `main` |
|
||||
| 当前已实施范围 | PDD 正式商品档案、采集规则、采集任务、Android 执行、任务详情结果 |
|
||||
| 后续设计范围 | Shopee/SYB 商品关系、采购演练、创建待付款订单和物流回填 |
|
||||
| 当前已实施范围 | PDD 商品与采集闭环、Shopee/SYB 商品档案、SYB 接口导入与店铺准入管理 |
|
||||
| 后续设计范围 | 采购演练、创建待付款订单和物流回填 |
|
||||
| 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 |
|
||||
|
||||
## 建设基线
|
||||
@@ -67,3 +67,5 @@ synchronized_at: 2026-08-19T01:19:44Z
|
||||
当前采集 MVP 的 T01~T22 和 T24 已完成实现并由用户验收;T08 只读实时屏幕已延期。T23、T25~T28 的一加真机增强、假售罄恢复、浏览器重开恢复和端口配置已经实现,其中部分工单仍等待用户验收。T24 的规格遍历已在一加和三星真机复核;三星未识别评价数量的问题由用户确认暂不处理。
|
||||
|
||||
#31 PDD 商品档案原型、数据库、API、Admin 页面和采集结果写回已于 2026-08-17 通过用户验收;#45 PDD 商品列表批量采集入口已于 2026-08-18 通过用户验收。#32 采购闭环原型及后续采购工单仍按各自门禁推进。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。
|
||||
|
||||
#49 已完成 SYB 店铺表、管理 API、Admin 只读/管理界面、真实列表发现及导入双重过滤,正在等待用户验收。真实 MySQL 迁移和既有错误导入数据清理尚未执行;清理必须先确认精确 SQL 与影响行数。
|
||||
|
||||
@@ -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: a81b23771f859e89d16a8626735f1f3425d063e2
|
||||
synchronized_at: 2026-08-20T01:27:29Z
|
||||
wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
|
||||
synchronized_at: 2026-08-20T03:52:51Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -110,9 +110,11 @@ Android Portal/Agent
|
||||
| SYB 商品明细导入、解析与虾皮档案合并 | `server/app/goauto/sybimport/`(解析、幂等落库、同步编排、导入端点、管理端 API 和 Admin 页面均已实现) |
|
||||
| 顺云宝(SYB)ERP HTTP 客户端与登录会话 | `server/app/goauto/sybclient/`(登录、OCR 验证码、会话缓存、列表与明细读取;见 [SYB-ERP-Interface-Contract](SYB-ERP-Interface-Contract)) |
|
||||
| SYB 商品明细增量迁移 | `server/cmd/migrate/migration/version-local/1786700700000_syb_product_import.go` |
|
||||
| SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` |
|
||||
| 任务领取、结果、重置与删除 | `server/app/goauto/task/` |
|
||||
| 管理端基线 | `web/`(go-admin-ui v3.0.0) |
|
||||
| 管理端闭环页面 | `web/src/views/goauto/` |
|
||||
| SYB 店铺管理页面与接口封装 | `web/src/views/goauto/syb-shops/`、`web/src/api/goauto/syb-shops.js`;确认原型快照 `prototypes/49/v1/index.html` |
|
||||
| Android Agent 基线 | `android/app/src/main/java/cn/ilapage/goauto/agent/` |
|
||||
| Android 无障碍规则执行 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/` |
|
||||
| 三端统一验证 | `scripts/verify.ps1` |
|
||||
|
||||
@@ -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: 3871bcc837677c0ef75f6b75d9b9e0bde147cf78
|
||||
synchronized_at: 2026-08-19T08:15:34Z
|
||||
wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
|
||||
synchronized_at: 2026-08-20T03:52:54Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -45,6 +45,15 @@ synchronized_at: 2026-08-19T08:15:34Z
|
||||
- 人工修正的颜色/尺码视为与解析成功同等可信,同样会合并进虾皮商品档案;标记为已人工确认。
|
||||
- 不创建采购任务,不保存 PDD 订单号、快递单号、支付或有效订单状态;采购发起入口不在本域实现。
|
||||
|
||||
## SYB 店铺过滤
|
||||
|
||||
- `syb_shop` 保存允许导入的 SYB 店铺;店铺名比较前统一去除首尾空白、转换全角/半角字符并忽略大小写。规范化后同名的店铺只能保留一条有效记录。
|
||||
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
|
||||
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
|
||||
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
|
||||
- 只有列表与明细响应的店铺名都非空且命中启用快照时才允许写入。明细店铺名为空、变化为未启用店铺或无法匹配时跳过,并计入跳过数量。
|
||||
- 停用或软删除店铺只影响后续导入,不删除历史 SYB 商品、虾皮商品或任务数据。已有错误导入数据的清理必须先给出精确 SQL 和影响行数,再由用户单独确认。
|
||||
|
||||
## 采集规则
|
||||
|
||||
- 规则创建后立即可用,不存在草稿、发布、版本或停用流程。
|
||||
|
||||
@@ -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: fca6dfac9f1e5dcba51278664fc24f58e3ce3cce
|
||||
synchronized_at: 2026-08-19T07:09:33Z
|
||||
wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
|
||||
synchronized_at: 2026-08-20T03:53:07Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -62,12 +62,13 @@ POST /api/admin/v1/shopee-products/batch-delete
|
||||
```http
|
||||
GET /api/admin/v1/syb-products
|
||||
GET /api/admin/v1/syb-products/{productId}
|
||||
POST /api/admin/v1/syb-products/import
|
||||
POST /api/admin/v1/syb-products/{productId}/reparse
|
||||
POST /api/admin/v1/syb-products/reparse-batch
|
||||
PATCH /api/admin/v1/syb-products/{productId}/correction
|
||||
```
|
||||
|
||||
**没有导入/创建端点**:服务端尚未接入顺云宝 ERP 的真实接口凭据与拉取客户端,明细目前只能通过服务端内部调用 `sybimport.ApplyDetail` 写入(当前仅测试代码调用),管理端无法触发导入。这是已知且明确记录的缺口,不是遗漏。
|
||||
`import` 提交日期范围后同步调用 SYB 客户端完成列表完整性校验、启用店铺过滤、明细读取和幂等落库。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`;#50 将另行把长耗时导入改为后台任务,本接口当前仍为同步执行。
|
||||
|
||||
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配货运单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
|
||||
|
||||
@@ -75,6 +76,21 @@ PATCH /api/admin/v1/syb-products/{productId}/correction
|
||||
|
||||
`correction` 提交 `targetColor`、`targetSize`,覆盖解析结果并标记 `manuallyConfirmed=true`;非空的人工修正与解析成功同等可信,会合并进已关联的虾皮商品档案。
|
||||
|
||||
## 管理端:SYB 店铺
|
||||
|
||||
```http
|
||||
GET /api/admin/v1/syb-shops
|
||||
POST /api/admin/v1/syb-shops
|
||||
PATCH /api/admin/v1/syb-shops/{shopId}/name
|
||||
PATCH /api/admin/v1/syb-shops/{shopId}/enabled
|
||||
DELETE /api/admin/v1/syb-shops/{shopId}
|
||||
POST /api/admin/v1/syb-shops/discover
|
||||
```
|
||||
|
||||
列表支持 `keyword`、`enabledOnly`、`page` 和 `pageSize`,返回店铺原名、规范化名称、启用状态及分页汇总。写操作由 go-admin/Casbin 仅授权管理员;其他角色只读。删除为软删除,不清理已经导入的数据。
|
||||
|
||||
`discover` 提交日期范围,从 SYB 真实货运单列表发现店铺,返回 `shopName` 和 `status`(`new` / `exists`);发现只读,不自动新增。店铺比较固定执行首尾去空白、全角/半角统一和忽略大小写。同步先完成原始列表完整性校验,再按一次性启用店铺快照过滤;明细响应中的店铺名为空或不在快照内时不得写入。
|
||||
|
||||
## 管理端:采集规则
|
||||
|
||||
```http
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Delivery-Issues
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
|
||||
wiki_revision: e3cb3e3043c09471273cd1176590dafed40e1834
|
||||
synchronized_at: 2026-08-19T07:09:36Z
|
||||
wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
|
||||
synchronized_at: 2026-08-20T03:53:09Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 当前 MVP 交付工单索引
|
||||
@@ -64,6 +64,9 @@ synchronized_at: 2026-08-19T07:09:36Z
|
||||
| T39 | [#41](https://git.ilapage.cn/OPC/goauto/issues/41) | SYB 货运单商品导入与虾皮信息提取 | #40;源数据域独立于采购任务;Stage B 解析/落库/管理端 API/Admin 页面已完成,SYB 接口拉取客户端待实施 |
|
||||
| T40 | [#42](https://git.ilapage.cn/OPC/goauto/issues/42) | Android 采购演练规则与持久执行基线 | #33、#34;只演练,不改地址、不创建订单 |
|
||||
| T43 | [#45](https://git.ilapage.cn/OPC/goauto/issues/45) | Admin PDD 商品列表多选与批量采集任务创建(2026-08-18 已验收) | 已完成 |
|
||||
| T46 | [#48](https://git.ilapage.cn/OPC/goauto/issues/48) | SYB ERP 客户端移植与真实导入 | #41;已实施,等待验收 |
|
||||
| T47 | [#49](https://git.ilapage.cn/OPC/goauto/issues/49) | 店铺管理与 SYB 同步店铺过滤 | #48;代码与原型已完成,等待验收;真实迁移和历史数据清理需另行确认 |
|
||||
| T48 | [#50](https://git.ilapage.cn/OPC/goauto/issues/50) | SYB 导入后台任务与进度展示 | #48、#49 |
|
||||
|
||||
推荐依赖顺序:#31、#40、#41 完成商品域 → #33、#34 建立采购契约和服务端状态机 → #42 完成不下单演练 → #35 管理端人工处理 → #36 高风险真实订单动作 → #37、#38 物流闭环 → #39 真机总验收。
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: ee43fb44302307a67261d5f6195b4dd19c28d092
|
||||
synchronized_at: 2026-08-19T08:22:06Z
|
||||
wiki_revision: f92d8e766d3307ac80abfd85f664fb0ab87ec4c2
|
||||
synchronized_at: 2026-08-20T03:53:19Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -464,8 +464,7 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
|
||||
这几条不是抓包结论,是本项目的决定,写在这里避免每次重新讨论:
|
||||
|
||||
> 下面两条「店铺准入」是上游 cmautobuy 的业务规则,**GoAuto 尚未采纳**。
|
||||
> 保留原文是因为其中「先做完整性校验、再做过滤」的顺序结论对任何过滤策略都成立。
|
||||
> 以下「店铺准入」已由 GoAuto #49 采纳并实现;店铺规范化还包括全角/半角统一和忽略大小写。
|
||||
|
||||
`[必须]` **同步先校验原始全量,再做店铺准入。** 顺序固定为:按日期查询原始总数
|
||||
并执行单次容量熔断 → 拉完当天原始列表并核对分页前后总数、页长和唯一 ID → 按
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -20,3 +20,9 @@
|
||||
四份文件由 QuantUX MCP 导出(#31、#32 于 2026-08-17,#40、#41 于 2026-08-18),是包含图片和交互逻辑的单文件离线 HTML,可直接用浏览器打开。MCP 导出验证结果均为 `PASS`,没有运行时错误或不支持的控件类型。
|
||||
|
||||
原型审核状态:#31 PDD 商品档案原型 2026-08-17 已确认;#32 采购闭环原型 2026-08-18 已确认,可作为采购实施依据;#40 虾皮商品档案原型 2026-08-19 按 #46 修订后重新确认;#41 SYB 商品导入原型 2026-08-19 已确认。四份原型均可作为实现依据。其余原型以对应 Gitea 工单中记录的用户审核状态为准;草稿不能作为正式实现依据。重新导出时必须使用相同 App ID,并审查差异,不能静默覆盖。
|
||||
|
||||
## 分工单版本化审核快照
|
||||
|
||||
- `49/v1/index.html`:#49「GoAuto - 店铺管理原型」,线上 App ID `6a866e98191a826306a7ef78`,1440×900,7 个页面、347 个控件、19 条流程线。覆盖管理员店铺列表、启停、新增、改名、软删除、从 SYB 发现店铺、无启用店铺时的导入拦截、采购员只读状态和模块边界。2026-08-20 用户确认线上原型后导出,MCP 校验 `PASS`,导出文件不含访问凭据。
|
||||
|
||||
从 #47 之后新增的原型使用 `prototypes/<工单号>/<版本>/index.html`。已确认版本不得原位覆盖;后续结构或流程变化必须导出到新版本目录并重新确认。
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"go-admin/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -51,6 +52,25 @@ func (handler Handler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// `[必须]` Refuse before Connect: Connect may log in and send a captcha to
|
||||
// the configured OCR service. With no enabled shop there is no valid sync
|
||||
// to run, so consuming either remote service would be wasteful and would
|
||||
// violate #49's pre-flight boundary. Sync checks again to cover a shop being
|
||||
// disabled between this pre-flight and the actual run.
|
||||
enabled, err := sybshop.EnabledNames(c.Request.Context(), service.DB)
|
||||
if err != nil {
|
||||
writeError(c, internalError(err))
|
||||
return
|
||||
}
|
||||
if len(enabled) == 0 {
|
||||
writeError(c, invalidRequest(ErrNoEnabledShop.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
settings := config.ExtConfig.SYB.Resolved()
|
||||
if !settings.HasCredentials() {
|
||||
// Name the file that was actually consulted. The previous wording only
|
||||
@@ -60,11 +80,6 @@ func (handler Handler) Import(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
importGate.Lock()
|
||||
if importGate.running {
|
||||
importGate.Unlock()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 没有启用店铺时必须在读取凭据、登录、OCR 和任何 SYB 请求之前拒绝。
|
||||
// 这里不配置任何 SYB 客户端;若前置顺序回退,响应会变成凭据错误或尝试联网。
|
||||
func TestImportRejectsNoEnabledShopBeforeConnect(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:import-no-shop?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SYBShop{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.POST("/api/admin/v1/syb-products/import", Handler{DB: db}.Import)
|
||||
body := bytes.NewBufferString(`{"dateFrom":"2026-08-19","dateTo":"2026-08-19"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/admin/v1/syb-products/import", body)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !strings.Contains(response.Message, "没有启用任何店铺") {
|
||||
t.Fatalf("unexpected response: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -189,12 +189,16 @@ func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncCo
|
||||
// this order belongs to an enabled shop; if the detail disagrees,
|
||||
// the two views are inconsistent and importing it would write a
|
||||
// row for a shop nobody enabled.
|
||||
if name := stringField(detail.Raw, "shopName"); !sybshop.IsBlank(name) {
|
||||
if _, ok := allowed[sybshop.Normalize(name)]; !ok {
|
||||
report.AcceptedCount--
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
name := stringField(detail.Raw, "shopName")
|
||||
if sybshop.IsBlank(name) {
|
||||
report.AcceptedCount--
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[sybshop.Normalize(name)]; !ok {
|
||||
report.AcceptedCount--
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if err := applyStockDetail(ctx, db, byID[detail.ID], detail, &report); err != nil {
|
||||
return report, err
|
||||
|
||||
@@ -63,7 +63,8 @@ type fakeSYB struct {
|
||||
shopNames []string
|
||||
// detailShopName 非空时,明细响应里的 shopName 用它覆盖,
|
||||
// 用于构造「列表说是 A 店、明细说是 B 店」的不一致。
|
||||
detailShopName string
|
||||
detailShopName string
|
||||
blankDetailShopName bool
|
||||
}
|
||||
|
||||
func (f *fakeSYB) shopFor(i int) string {
|
||||
@@ -118,10 +119,14 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
if id == f.detailDropID {
|
||||
continue
|
||||
}
|
||||
detailShopName := f.detailShopName
|
||||
if detailShopName == "" && !f.blankDetailShopName {
|
||||
detailShopName = f.shopFor(int(id - 1000))
|
||||
}
|
||||
list = append(list, map[string]any{
|
||||
"id": id,
|
||||
"code": fmt.Sprintf("ORD-%d", id),
|
||||
"shopName": f.detailShopName,
|
||||
"shopName": detailShopName,
|
||||
"details": []any{map[string]any{
|
||||
"id": float64(id*10 + 1), "productId": float64(9001),
|
||||
"productTitle": "测试商品", "productSpec": "白色,L",
|
||||
@@ -397,3 +402,19 @@ func TestSyncRechecksShopOnDetailResponse(t *testing.T) {
|
||||
t.Fatalf("不应写入任何行,实际 %d", written)
|
||||
}
|
||||
}
|
||||
|
||||
// 列表命中启用店铺,但明细店铺名为空时同样必须拦住;空值不能沿用列表值猜测。
|
||||
func TestSyncRejectsBlankShopOnDetailResponse(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, blankDetailShopName: true}
|
||||
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f),
|
||||
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
|
||||
if err != nil {
|
||||
t.Fatalf("同步失败: %v", err)
|
||||
}
|
||||
if report.DetailCount != 0 || report.AcceptedCount != 0 || report.ShopSkipped != 2 {
|
||||
t.Fatalf("空店铺名应全部拦住: details=%d accepted=%d skipped=%d",
|
||||
report.DetailCount, report.AcceptedCount, report.ShopSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listSybShops(params) {
|
||||
return request({ url: '/api/admin/v1/syb-shops', method: 'get', params })
|
||||
}
|
||||
|
||||
export function createSybShop(data) {
|
||||
return request({ url: '/api/admin/v1/syb-shops', method: 'post', data })
|
||||
}
|
||||
|
||||
export function renameSybShop(shopId, data) {
|
||||
return request({ url: `/api/admin/v1/syb-shops/${shopId}/name`, method: 'patch', data })
|
||||
}
|
||||
|
||||
export function setSybShopEnabled(shopId, data) {
|
||||
return request({ url: `/api/admin/v1/syb-shops/${shopId}/enabled`, method: 'patch', data })
|
||||
}
|
||||
|
||||
export function deleteSybShop(shopId) {
|
||||
return request({ url: `/api/admin/v1/syb-shops/${shopId}`, method: 'delete' })
|
||||
}
|
||||
|
||||
export function discoverSybShops(data) {
|
||||
return request({ url: '/api/admin/v1/syb-shops/discover', method: 'post', data, timeout: 30 * 60 * 1000 })
|
||||
}
|
||||
@@ -132,6 +132,19 @@ export const constantRoutes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/syb-shops',
|
||||
component: Layout,
|
||||
redirect: '/syb-shops/index',
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
component: () => import('@/views/goauto/syb-shops/index'),
|
||||
name: 'GoAutoSybShops',
|
||||
meta: { title: 'SYB 店铺', icon: 'shopping' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/collection-rules',
|
||||
component: Layout,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item><el-button type="primary" :icon="Download" @click="openImport">从 SYB 导入</el-button></el-form-item>
|
||||
<el-form-item><el-button @click="$router.push('/syb-shops/index')">店铺管理</el-button></el-form-item>
|
||||
<el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="货运单号、虾皮商品ID或商品标题" clearable @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 140px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
@@ -133,9 +134,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, RefreshLeft, Download } from '@element-plus/icons-vue'
|
||||
import { listSybProducts, getSybProduct, reparseSybProduct, reparseSybProductsBatch, correctSybProduct, importSybProducts } from '@/api/goauto/syb-products'
|
||||
import { listSybShops } from '@/api/goauto/syb-shops'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProducts',
|
||||
@@ -195,7 +197,14 @@ export default {
|
||||
|
||||
// ---------------- 从 SYB 导入 ----------------
|
||||
disableFutureDate(date) { return date.getTime() > Date.now() },
|
||||
openImport() {
|
||||
async openImport() {
|
||||
const shops = await listSybShops({ page: 1, pageSize: 1, enabledOnly: true })
|
||||
if (!shops.data.enabledCount) {
|
||||
await ElMessageBox.confirm('还没有启用任何店铺,导入不会登录 SYB,也不会消耗验证码。请先到店铺管理启用至少一家店铺。', '无法导入', { confirmButtonText: '去店铺管理', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(() => this.$router.push('/syb-shops/index'))
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
const today = new Date()
|
||||
const start = new Date(today.getTime() - 2 * 86400000)
|
||||
const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item>
|
||||
<el-tooltip :disabled="isAdmin" content="只有管理员可以配置店铺" placement="bottom">
|
||||
<span><el-button type="primary" :icon="Search" :disabled="!isAdmin" @click="openDiscover">从 SYB 发现店铺</el-button></span>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-tooltip :disabled="isAdmin" content="只有管理员可以配置店铺" placement="bottom">
|
||||
<span><el-button :icon="Plus" :disabled="!isAdmin" @click="openCreate">新增店铺</el-button></span>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="搜索"><el-input v-model="query.keyword" clearable placeholder="店铺名称" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="只看启用"><el-switch v-model="query.enabledOnly" @change="search" /></el-form-item>
|
||||
<el-form-item><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="!isAdmin"
|
||||
title="当前账号只有查看权限。启用、停用、新增、改名和删除店铺需要管理员操作。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<el-alert
|
||||
v-else-if="loaded && enabledCount === 0"
|
||||
title="还没有启用任何店铺,SYB 导入会在登录前被拒绝。请先从 SYB 发现店铺并启用。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
|
||||
<div class="summary" aria-live="polite">
|
||||
<span>店铺共 <strong>{{ total }}</strong> 个</span>
|
||||
<span>已启用 <strong class="enabled-count">{{ enabledCount }}</strong> 个</span>
|
||||
<span class="muted">过滤不会减少货运单列表请求,只减少明细请求和入库量。</span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="shops" row-key="id" border stripe empty-text="暂无店铺,请先从 SYB 发现">
|
||||
<el-table-column label="店铺名称" min-width="240"><template #default="{ row }"><div class="primary">{{ row.displayName }}</div><div class="muted">匹配时忽略首尾空白、全半角和大小写</div></template></el-table-column>
|
||||
<el-table-column label="同步状态" width="150"><template #default="{ row }"><el-switch v-if="isAdmin" v-model="row.enabled" :loading="row.updating" active-text="启用" inactive-text="停用" inline-prompt :aria-label="`${row.displayName}同步状态`" @change="toggleEnabled(row)" /><el-tag v-else :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="最近出现" min-width="180"><template #default="{ row }"><div>{{ formatDateTime(row.lastSeenAt) }}</div><div class="muted">{{ row.lastSeenOrderCount == null ? '等待同步记录' : `当次 ${row.lastSeenOrderCount} 张货运单` }}</div></template></el-table-column>
|
||||
<el-table-column label="更新时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><template v-if="isAdmin"><el-button type="primary" link @click="openRename(row)">改名</el-button><el-button type="danger" link @click="remove(row)">删除</el-button></template><span v-else class="muted">只读</span></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">停用或删除只影响后续导入,已经导入的商品明细不会被删除;同步历史仍保留当时的店铺名。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editor.open" :title="editor.mode === 'create' ? '新增店铺' : '修改店铺名称'" width="520px" :close-on-click-modal="false" @closed="resetEditor">
|
||||
<el-alert title="优先使用“从 SYB 发现店铺”。名称匹配失败不会报错,只会导致数据没有进入系统。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-form ref="editorForm" :model="editor.data" :rules="editorRules" label-position="top">
|
||||
<el-form-item label="店铺名称" prop="displayName"><el-input v-model="editor.data.displayName" maxlength="100" show-word-limit placeholder="请输入 SYB 中显示的完整店铺名" /></el-form-item>
|
||||
<el-form-item v-if="editor.mode === 'create'" label="创建后状态"><el-switch v-model="editor.data.enabled" active-text="启用" inactive-text="停用" /></el-form-item>
|
||||
<p class="helper">系统会去掉首尾空白、统一全半角并忽略大小写;例如“ ABC店 ”、“ABC店”和“abc店”视为同一家店。</p>
|
||||
</el-form>
|
||||
<template #footer><el-button :disabled="editor.saving" @click="editor.open = false">取消</el-button><el-button type="primary" :loading="editor.saving" @click="saveEditor">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="discover.open" title="从 SYB 发现店铺" width="760px" :close-on-click-modal="false" @closed="resetDiscover">
|
||||
<el-alert title="只读取货运单列表中的店铺名,不拉取商品明细,也不会自动添加或启用店铺。通常选择最近 2~3 天即可。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="发现日期范围"><el-date-picker v-model="discover.range" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="起始日期" end-placeholder="结束日期" :disabled-date="disableFutureDate" style="width:100%" /></el-form-item>
|
||||
</el-form>
|
||||
<div class="discover-actions"><el-button type="primary" :loading="discover.loading" :disabled="!validDiscoverRange" @click="runDiscover">开始发现</el-button><span class="muted">货运单很多时可能需要等待,请勿重复提交。</span></div>
|
||||
<el-table v-if="discover.searched" ref="discoverTable" v-loading="discover.loading" :data="discover.items" row-key="displayName" border size="small" max-height="360" empty-text="所选日期没有发现店铺" @selection-change="rows => discover.selected = rows">
|
||||
<el-table-column type="selection" width="48" :selectable="row => row.status === 'new'" />
|
||||
<el-table-column label="店铺名称" prop="displayName" min-width="260" />
|
||||
<el-table-column label="货运单" prop="orderCount" width="110" />
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="discoverMeta(row.status).type">{{ discoverMeta(row.status).label }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button :disabled="discover.saving" @click="discover.open = false">关闭</el-button><el-button type="primary" :loading="discover.saving" :disabled="discover.selected.length === 0" @click="addDiscovered">添加选中的 {{ discover.selected.length }} 家店铺</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { createSybShop, deleteSybShop, discoverSybShops, listSybShops, renameSybShop, setSybShopEnabled } from '@/api/goauto/syb-shops'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybShops',
|
||||
setup() { return { Plus, RefreshLeft, Search } },
|
||||
data() {
|
||||
return {
|
||||
loading: false, loaded: false, shops: [], total: 0, enabledCount: 0,
|
||||
query: { page: 1, pageSize: 20, keyword: '', enabledOnly: false },
|
||||
editor: this.emptyEditor(),
|
||||
editorRules: { displayName: [{ required: true, message: '请输入店铺名称', trigger: 'blur' }, { max: 100, message: '店铺名称不能超过 100 个字符', trigger: 'blur' }] },
|
||||
discover: this.emptyDiscover()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
validDiscoverRange() { return Array.isArray(this.discover.range) && this.discover.range.length === 2 }
|
||||
},
|
||||
created() { this.load() },
|
||||
methods: {
|
||||
emptyEditor() { return { open: false, saving: false, mode: 'create', shopId: null, data: { displayName: '', enabled: true }} },
|
||||
emptyDiscover() { return { open: false, loading: false, saving: false, searched: false, range: null, items: [], selected: [] } },
|
||||
async load() { this.loading = true; try { const r = await listSybShops(this.query); this.shops = r.data.items.map(item => ({ ...item, updating: false })); this.total = r.data.total; this.enabledCount = r.data.enabledCount; this.loaded = true } finally { this.loading = false } },
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 20, keyword: '', enabledOnly: false }; this.load() },
|
||||
formatDateTime(value) { return value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) : '尚未记录' },
|
||||
disableFutureDate(date) { return date.getTime() > Date.now() },
|
||||
defaultRange() { const today = new Date(); const start = new Date(today.getTime() - 2 * 86400000); const fmt = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; return [fmt(start), fmt(today)] },
|
||||
openCreate() { if (!this.isAdmin) return; this.editor = { ...this.emptyEditor(), open: true }; this.$nextTick(() => this.$refs.editorForm?.clearValidate()) },
|
||||
openRename(row) { if (!this.isAdmin) return; this.editor = { ...this.emptyEditor(), open: true, mode: 'rename', shopId: row.id, data: { displayName: row.displayName, enabled: row.enabled }}; this.$nextTick(() => this.$refs.editorForm?.clearValidate()) },
|
||||
resetEditor() { this.editor = this.emptyEditor() },
|
||||
async saveEditor() { if (!await this.$refs.editorForm.validate().catch(() => false)) return; this.editor.saving = true; try { const name = this.editor.data.displayName.trim(); if (this.editor.mode === 'create') await createSybShop({ displayName: name, enabled: this.editor.data.enabled }); else await renameSybShop(this.editor.shopId, { displayName: name }); ElMessage.success(this.editor.mode === 'create' ? '店铺已添加' : '店铺名称已更新'); this.editor.open = false; await this.load() } finally { this.editor.saving = false } },
|
||||
async toggleEnabled(row) { const next = row.enabled; row.updating = true; try { await setSybShopEnabled(row.id, { enabled: next }); ElMessage.success(next ? '店铺已启用' : '店铺已停用'); await this.load() } catch (error) { row.enabled = !next; throw error } finally { row.updating = false } },
|
||||
async remove(row) { if (!this.isAdmin) return; await ElMessageBox.confirm(`删除店铺“${row.displayName}”?已导入的数据不受影响,后续同步将不再导入该店铺。`, '删除店铺', { confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'el-button--danger' }); await deleteSybShop(row.id); ElMessage.success('店铺已删除'); await this.load() },
|
||||
openDiscover() { if (!this.isAdmin) return; this.discover = { ...this.emptyDiscover(), open: true, range: this.defaultRange() } },
|
||||
resetDiscover() { this.discover = this.emptyDiscover() },
|
||||
discoverMeta(status) { return { new: { label: '新发现,可添加', type: 'warning' }, enabled: { label: '已启用', type: 'success' }, disabled: { label: '已停用', type: 'info' }}[status] || { label: status || '未知', type: 'info' } },
|
||||
async runDiscover() { if (!this.validDiscoverRange) return; this.discover.loading = true; this.discover.selected = []; try { const [dateFrom, dateTo] = this.discover.range; const r = await discoverSybShops({ dateFrom, dateTo }); this.discover.items = r.data.items; this.discover.searched = true } finally { this.discover.loading = false } },
|
||||
async addDiscovered() { this.discover.saving = true; let added = 0; const failed = []; try { for (const row of this.discover.selected) { try { await createSybShop({ displayName: row.displayName, enabled: true }); added++ } catch (error) { failed.push(error?.response?.data?.message || `${row.displayName} 添加失败`) } } if (added) ElMessage.success(`已添加并启用 ${added} 家店铺`); if (failed.length) ElMessage.warning(`${failed.length} 家未添加:${failed[0]}`); await this.load(); await this.runDiscover() } finally { this.discover.saving = false } }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.notice{margin-bottom:16px}.summary{display:flex;align-items:center;gap:20px;min-height:44px;padding:8px 12px;margin-bottom:12px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.summary strong{font-variant-numeric:tabular-nums}.enabled-count{color:#15803d}.primary{font-weight:600;color:#1f2937}.muted,.helper{font-size:12px;color:#606266}.scope-note{margin-top:12px;font-size:12px;color:#606266}.discover-actions{display:flex;align-items:center;gap:12px;margin-bottom:12px}@media(max-width:768px){.summary,.discover-actions{align-items:flex-start;flex-direction:column}}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const shops = [
|
||||
{ id: 1, displayName: '晨曦优选店', normalizedName: '晨曦优选店', enabled: true, lastSeenAt: '2026-08-20T02:00:00Z', lastSeenOrderCount: 126, updatedAt: '2026-08-20T02:00:00Z' },
|
||||
{ id: 2, displayName: 'ABC服饰', normalizedName: 'abc服饰', enabled: false, lastSeenAt: null, lastSeenOrderCount: null, updatedAt: '2026-08-19T02:00:00Z' },
|
||||
];
|
||||
|
||||
async function mockPage(page: any, context: any, roles: string[]) {
|
||||
const calls: { creates: any[]; enabled: any[] } = { creates: [], enabled: [] };
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]);
|
||||
await page.route('**/api/**', async (route: any) => {
|
||||
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, name: roles.includes('admin') ? '管理员' : '采购员', avatar: '', introduction: '', permissions: [] } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-shops/discover')) return route.fulfill({ json: { code: 200, data: { items: [{ displayName: '新发现店铺', orderCount: 38, status: 'new' }, { displayName: '晨曦优选店', orderCount: 126, status: 'enabled' }] } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-shops') && route.request().method() === 'POST') {
|
||||
calls.creates.push(route.request().postDataJSON());
|
||||
return route.fulfill({ json: { code: 200, data: { id: 3, ...route.request().postDataJSON() } } });
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-shops/2/enabled')) {
|
||||
calls.enabled.push(route.request().postDataJSON());
|
||||
return route.fulfill({ json: { code: 200, data: { ...shops[1], ...route.request().postDataJSON() } } });
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-shops')) return route.fulfill({ json: { code: 200, data: { items: shops, total: 2, enabledCount: 1, page: 1, pageSize: 20 } } });
|
||||
return route.fulfill({ json: { code: 200, data: [] } });
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
test('管理员可以发现、添加并启用 SYB 店铺', async ({ page, context }) => {
|
||||
const calls = await mockPage(page, context, ['admin']);
|
||||
await page.goto('http://localhost:9527/#/syb-shops/index');
|
||||
await expect(page.getByText('晨曦优选店').first()).toBeVisible();
|
||||
await expect(page.getByText('已启用 1 个')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '从 SYB 发现店铺', exact: true }).click();
|
||||
await page.getByRole('button', { name: '开始发现' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: '从 SYB 发现店铺' });
|
||||
const rows = dialog.locator('.el-table__body-wrapper tbody tr');
|
||||
await rows.nth(0).locator('.el-checkbox').click();
|
||||
await expect(rows.nth(1).getByRole('checkbox')).toBeDisabled();
|
||||
await dialog.getByRole('button', { name: '添加选中的 1 家店铺' }).click();
|
||||
await expect.poll(() => calls.creates).toEqual([{ displayName: '新发现店铺', enabled: true }]);
|
||||
await dialog.getByRole('button', { name: '关闭此对话框' }).click();
|
||||
|
||||
await page.locator('.el-table__body-wrapper tbody tr').filter({ hasText: 'ABC服饰' }).locator('.el-switch').click();
|
||||
await expect.poll(() => calls.enabled).toEqual([{ enabled: true }]);
|
||||
});
|
||||
|
||||
test('采购员查看店铺时配置控件保持只读', async ({ page, context }) => {
|
||||
await mockPage(page, context, ['purchaser']);
|
||||
await page.goto('http://localhost:9527/#/syb-shops/index');
|
||||
await expect(page.getByText('当前账号只有查看权限')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '从 SYB 发现店铺', exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: '新增店铺' })).toBeDisabled();
|
||||
await expect(page.locator('.el-table').getByRole('switch')).toHaveCount(0);
|
||||
await expect(page.getByText('已启用', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText('只读').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('没有启用店铺时从 SYB 导入在前端预检阶段停止', async ({ page, context }) => {
|
||||
let importCalled = false;
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]);
|
||||
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-shops')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0, enabledCount: 0, page: 1, pageSize: 1 } } });
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products/import')) {
|
||||
importCalled = true;
|
||||
return route.fulfill({ json: { code: 500, message: '不应调用' } });
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 } } });
|
||||
return route.fulfill({ json: { code: 200, data: [] } });
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:9527/#/syb-products/index');
|
||||
await page.getByRole('button', { name: '从 SYB 导入' }).click();
|
||||
await expect(page.getByRole('dialog', { name: '无法导入' })).toContainText('还没有启用任何店铺');
|
||||
expect(importCalled).toBe(false);
|
||||
});
|
||||
Reference in New Issue
Block a user