feat(#356): support pasted purchase order filters

This commit is contained in:
QiuSW
2026-10-05 16:33:38 +08:00
parent e4d4a6fa79
commit bb3990e432
8 changed files with 425 additions and 17 deletions
@@ -0,0 +1,153 @@
package purchase
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
)
func TestNormalizeAdminOrderNos(t *testing.T) {
hundred := make([]string, 100)
for i := range hundred {
hundred[i] = fmt.Sprintf("DEMO-%03d", i)
}
for _, tc := range []struct {
name, single string
raw, want []string
invalid bool
}{
{name: "absent"},
{name: "legacy single", single: "PARTIAL"},
{name: "mixed and duplicate", raw: []string{" 001\r\nDEMO-A,DEMO-B\t001", "DEMO-A,DEMO-C\u0085\uFEFF"}, want: []string{"001", "DEMO-A", "DEMO-B", "DEMO-C"}},
{name: "100", raw: hundred, want: hundred},
{name: "duplicates do not count", raw: append(append([]string{}, hundred...), hundred...), want: hundred},
{name: "101", raw: append(append([]string{}, hundred...), "EXTRA"), invalid: true},
{name: "64 runes", raw: []string{strings.Repeat("中", 64)}, want: []string{strings.Repeat("中", 64)}},
{name: "65 runes", raw: []string{"GOOD", strings.Repeat("中", 65)}, invalid: true},
{name: "single too long", single: strings.Repeat("A", 65), invalid: true},
{name: "empty collection", raw: []string{}, invalid: true},
{name: "blank collection", raw: []string{" ,,\r\n\t"}, invalid: true},
{name: "conflicting parameters", single: "PART", raw: []string{"DEMO-A"}, invalid: true},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := normalizeAdminOrderNos(tc.single, tc.raw)
if tc.invalid {
if code(err) != CodeInvalidRequest {
t.Fatalf("expected invalid request, got %v", err)
}
return
}
if err != nil || !reflect.DeepEqual(got, tc.want) {
t.Fatalf("got %v, %v; want %v", got, err, tc.want)
}
})
}
}
func TestAdminOrderNosExactFilterAndPagination(t *testing.T) {
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
s := testService(db)
base := createAdminQueryTask(t, s, f)
// Isolated fixture records only; no device execution or external order creation.
for i, orderNo := range []string{"001", "DEMO-A", "DEMO-A", "XDEMO-A", "DEMO-A2", "DEMO-B", "' OR 1=1 --"} {
row := base
row.ID = 0
row.CreateRequestID = uuid.NewString()
row.ShopeeOrderNoSnapshot = orderNo
row.ShopeeTitleSnapshot = "DEMO-A"
row.Status = models.PurchaseTaskStatusFailed
if i == 5 {
row.Status = models.PurchaseTaskStatusCancelled
}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
req := AdminListRequest{Page: 1, PageSize: 2, Status: models.PurchaseTaskStatusFailed, ShopeeOrderNos: []string{"001", "DEMO-A", "DEMO-B"}}
first, err := s.AdminList(context.Background(), req)
if err != nil || first.Total != 3 || len(first.Items) != 2 {
t.Fatalf("first: %+v %v", first, err)
}
req.Page = 2
second, err := s.AdminList(context.Background(), req)
if err != nil || second.Total != 3 || len(second.Items) != 1 {
t.Fatalf("second: %+v %v", second, err)
}
seen := map[uint64]bool{}
for _, row := range append(first.Items, second.Items...) {
if seen[row.ID] || (row.ShopeeOrderNoSnapshot != "001" && row.ShopeeOrderNoSnapshot != "DEMO-A") {
t.Fatalf("unexpected row %+v", row)
}
seen[row.ID] = true
}
for _, query := range [][]string{{"MISSING", "DEMO"}, {"%", "_"}, {"' OR 1=1 --", "MISSING"}} {
out, err := s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNos: query})
if err != nil || out.Total != 0 {
t.Fatalf("unexpected broadened query %v: %+v %v", query, out, err)
}
}
// A repeated-key collection remains exact even with one item; old scalar stays LIKE.
out, err := s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNos: []string{"DEMO-A"}})
if err != nil || out.Total != 2 {
t.Fatalf("one-item collection: %+v %v", out, err)
}
out, err = s.AdminList(context.Background(), AdminListRequest{ShopeeOrderNo: "DEMO-A"})
if err != nil || out.Total != 4 {
t.Fatalf("legacy LIKE: %+v %v", out, err)
}
}
func TestAdminOrderNosHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db := testDB(t)
f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
s := testService(db)
row := createAdminQueryTask(t, s, f)
if err := db.Model(&row).Update("shopee_order_no_snapshot", "DEMO-A").Error; err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
query string
invalid bool
}{
{"shopeeOrderNos=DEMO-A&shopeeOrderNos=DEMO-B", false},
{"shopeeOrderNos=DEMO-A%2CDEMO-B", false},
{"shopeeOrderNo=DEMO", false},
{"shopeeOrderNos=", true},
{"shopeeOrderNos=DEMO-A&shopeeOrderNo=DEMO", true},
{"shopeeOrderNos=" + url.QueryEscape(strings.Repeat("A", 65)), true},
} {
for _, role := range []string{"admin", "purchaser", "after_sales"} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
c.Request = httptest.NewRequest(http.MethodGet, "/api/admin/v1/purchase-tasks?"+tc.query, nil)
(Handler{DB: db}).AdminList(c)
if tc.invalid {
if !strings.Contains(w.Body.String(), CodeInvalidRequest) {
t.Fatalf("invalid query accepted: %s", w.Body.String())
}
continue
}
var result struct {
Data AdminListResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil || w.Code != http.StatusOK || result.Data.Total != 1 {
t.Fatalf("query %s: %d %s", tc.query, w.Code, w.Body.String())
}
}
}
}
+56 -9
View File
@@ -6,6 +6,8 @@ import (
"errors"
"strings"
"time"
"unicode"
"unicode/utf8"
"go-admin/app/goauto/models"
@@ -13,15 +15,16 @@ import (
)
type AdminListRequest struct {
Page int
PageSize int
TaskID uint64
Status string
ExecutionMode string
TaskType string
SYBProductID uint64
ShopeeOrderNo string
PDDOrderNo string
Page int
PageSize int
TaskID uint64
Status string
ExecutionMode string
TaskType string
SYBProductID uint64
ShopeeOrderNo string
ShopeeOrderNos []string
PDDOrderNo string
}
type AdminTaskItem struct {
@@ -112,7 +115,48 @@ type AdminDetailResponse struct {
// Query-only value: never a PurchaseTask status or a logistics writeback state.
const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded"
// A nil list means the optional filter was absent; an explicitly empty list
// must not silently turn a restricted lookup into an unfiltered query.
func normalizeAdminOrderNos(single string, raw []string) ([]string, error) {
single = strings.TrimSpace(single)
if utf8.RuneCountInString(single) > 64 {
return nil, fail(CodeInvalidRequest, "每个蝦皮订单号最多 64 个字符")
}
if raw == nil {
return nil, nil
}
if single != "" {
return nil, fail(CodeInvalidRequest, "蝦皮订单号单值与批量参数不能同时使用")
}
values := make([]string, 0, len(raw))
seen := make(map[string]bool)
for _, entry := range raw {
for _, value := range strings.FieldsFunc(entry, func(r rune) bool {
return r == ',' || r == ',' || unicode.IsSpace(r) || r == '\uFEFF'
}) {
if utf8.RuneCountInString(value) > 64 {
return nil, fail(CodeInvalidRequest, "每个蝦皮订单号最多 64 个字符")
}
if !seen[value] {
seen[value] = true
values = append(values, value)
if len(values) > 100 {
return nil, fail(CodeInvalidRequest, "最多查询 100 个蝦皮订单号,请分批查询")
}
}
}
}
if len(values) == 0 {
return nil, fail(CodeInvalidRequest, "批量蝦皮订单号不能为空")
}
return values, nil
}
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
orderNos, err := normalizeAdminOrderNos(req.ShopeeOrderNo, req.ShopeeOrderNos)
if err != nil {
return AdminListResponse{}, err
}
if req.Page < 1 {
req.Page = 1
}
@@ -152,6 +196,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if orderNo := strings.TrimSpace(req.ShopeeOrderNo); orderNo != "" {
query = query.Where("shopee_order_no_snapshot LIKE ?", "%"+orderNo+"%")
}
if len(orderNos) > 0 {
query = query.Where("shopee_order_no_snapshot IN ?", orderNos)
}
if orderNo := strings.TrimSpace(req.PDDOrderNo); orderNo != "" {
query = query.Where("pdd_order_no LIKE ?", "%"+orderNo+"%")
}
+4 -3
View File
@@ -52,9 +52,10 @@ func (h Handler) AdminList(c *gin.Context) {
response, err := service.AdminList(c.Request.Context(), AdminListRequest{
Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID,
Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")),
TaskType: strings.TrimSpace(c.Query("taskType")),
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
TaskType: strings.TrimSpace(c.Query("taskType")),
ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")),
ShopeeOrderNos: c.Request.URL.Query()["shopeeOrderNos"],
PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")),
})
if err != nil {
writeError(c, err)
+19 -1
View File
@@ -1,6 +1,24 @@
import request from '@/utils/request'
export function listPurchaseTasks(params) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'get', params }) }
export function listPurchaseTasks(params) {
return request({
url: '/api/admin/v1/purchase-tasks', method: 'get', params,
paramsSerializer: {
serialize(values) {
const query = new URLSearchParams()
Object.entries(values).forEach(([key, value]) => {
if (value == null) return
if (key === 'shopeeOrderNos' && Array.isArray(value)) {
// Preserve an explicitly empty list so the server rejects it.
if (!value.length) query.append(key, '')
value.forEach(item => query.append(key, item))
} else query.append(key, value)
})
return query.toString()
}
}
})
}
export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) }
export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) }
export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) }
+10
View File
@@ -0,0 +1,10 @@
// Match the Server's whitespace/comma splitting; keep identifiers as strings.
export function parsePurchaseOrderFilter(raw = '') {
const values = [...new Set(raw.split(/[,,\s\u0085]+/u).filter(Boolean))]
const error = values.length > 100
? '最多查询 100 个蝦皮订单号,请分批查询'
: values.some(value => [...value].length > 64)
? '每个蝦皮订单号最多 64 个字符'
: ''
return { values, error }
}
+23 -4
View File
@@ -6,11 +6,17 @@
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item label="任务"><el-input v-model="query.taskId" class="task-input" clearable placeholder="编号" @keyup.enter="search" /></el-form-item>
<el-form-item label="类型"><el-select v-model="query.taskType" clearable placeholder="全部类型" style="width:130px"><el-option label="订单采购" value="syb_order" /><el-option label="备货采购" value="stock" /></el-select></el-form-item>
<el-form-item label="蝦皮订单号"><el-input v-model="query.shopeeOrderNo" class="shopee-order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
<el-form-item label="蝦皮订单号" for="purchase-order-filter">
<div class="shopee-order-filter">
<el-input id="purchase-order-filter" v-model="query.shopeeOrderNo" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" resize="none" placeholder="可粘贴多个订单号,换行、逗号或空格分隔" aria-describedby="purchase-order-hint purchase-order-error" :aria-invalid="Boolean(orderFilter.error)" />
<div id="purchase-order-hint" class="order-filter-hint"><span v-if="orderFilter.values.length > 1" aria-live="polite">已输入 {{ orderFilter.values.length }} 个订单号。<br></span>单个支持包含查询,多个按完整订单号匹配。</div>
<div v-if="orderFilter.error" id="purchase-order-error" class="order-filter-error" role="alert">{{ orderFilter.error }}</div>
</div>
</el-form-item>
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></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>
<el-form-item><el-button type="primary" :icon="Search" :disabled="Boolean(orderFilter.error)" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
</el-form>
<div class="batch-actions"><span class="selected-copy">已选择 <strong>{{ retrySelection.length }}</strong> 条</span><el-button type="warning" :disabled="retryCandidates.length === 0" :loading="retryDialog.saving" @click="openRetryDialog">重试({{ retryCandidates.length }})</el-button><el-button type="primary" plain :disabled="orderWritebackCandidates.length === 0" :loading="orderWriteback.saving" @click="submitOrderWriteback(orderWritebackCandidates)">批量回填 SYB({{ orderWritebackCandidates.length }})</el-button></div>
@@ -142,6 +148,7 @@
</template>
<script>
import { parsePurchaseOrderFilter } from '@/utils/purchase-order-filter'
import { ElMessage, ElMessageBox } from 'element-plus'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks'
@@ -171,6 +178,7 @@ export default {
}
},
computed: {
orderFilter() { return parsePurchaseOrderFilter(this.query.shopeeOrderNo) },
retryCandidates() { return this.retrySelection.filter(this.isRetrySelectable) },
orderWritebackCandidates() { return this.retrySelection.filter(this.canOrderWriteback) },
detailAlert() {
@@ -200,10 +208,19 @@ export default {
},
methods: {
async load() {
this.retrySelection = []
this.$refs.taskTable?.clearSelection()
if (this.orderFilter.error) { this.tasks = []; this.total = 0; return }
this.loading = true; this.loadError = ''
try { const r = await listPurchaseTasks(this.cleanQuery()); this.tasks = r.data.items || []; this.total = r.data.total || 0; this.retrySelection = []; this.$nextTick(() => this.$refs.taskTable?.clearSelection()) } catch (error) { this.loadError = error?.message || '采购任务加载失败,请稍后重试' } finally { this.loading = false }
try { const r = await listPurchaseTasks(this.cleanQuery()); this.tasks = r.data.items || []; this.total = r.data.total || 0; this.retrySelection = []; this.$nextTick(() => this.$refs.taskTable?.clearSelection()) } catch (error) { this.tasks = []; this.total = 0; this.loadError = error?.message || '采购任务加载失败,请稍后重试' } finally { this.loading = false }
},
cleanQuery() {
const params = Object.fromEntries(Object.entries(this.query).filter(([key, value]) => key !== 'shopeeOrderNo' && value !== ''))
const values = this.orderFilter.values
if (values.length === 1) params.shopeeOrderNo = values[0]
else if (values.length > 1) params.shopeeOrderNos = values
return params
},
cleanQuery() { return Object.fromEntries(Object.entries(this.query).filter(([, value]) => value !== '')) },
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() },
async openDetail(row) { this.detail = { open: true, loading: true, task: null, attempts: [], matching: null }; try { await this.refreshDetail(row.id) } finally { this.detail.loading = false } },
@@ -294,6 +311,8 @@ export default {
</script>
<style lang="scss" scoped>
.shopee-order-filter{width:240px;max-width:100%;white-space:normal}.order-filter-hint,.order-filter-error{font-size:12px;line-height:1.5;margin-top:4px}.order-filter-hint{color:var(--el-text-color-regular)}.order-filter-error{color:var(--el-color-danger)}
.page-card .search-form{flex-wrap:wrap;align-items:flex-start}.search-form :deep(.el-form-item){max-width:100%}.search-form :deep(.el-form-item__content){min-width:0}
.batch-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:16px}.batch-actions .el-button+.el-button{margin-left:0}
.page-card{min-height:calc(100vh - 124px)}.search-form{display:flex;align-items:flex-end;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc;white-space:nowrap}.retry-action{margin-left:auto;margin-right:0}.selected-copy{margin-right:10px;color:#606266}.selected-copy strong{color:#337ecc;font-variant-numeric:tabular-nums}.task-input{width:72px}.shopee-order-input{width:140px}.syb-input{width:100px}.order-input{width:120px}.order-number{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.notice{margin-bottom:18px}.primary{color:#303133;font-weight:600}.muted{color:#909399;font-size:12px;line-height:1.5}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}.retry-disabled{display:block;margin-top:4px;color:#b88230;font-size:11px;line-height:1.2;cursor:help}.dialog-help{margin:14px 0 0;color:#606266;line-height:1.6}.drawer-body{min-height:240px;padding:0 4px 28px}.detail-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.detail-heading>div{display:flex;align-items:center;gap:8px}.task-number{font-size:22px;font-weight:700;color:#1f2937}.drawer-body section{margin-top:24px}.drawer-body h3{margin:0 0 12px;color:#303133;font-size:17px}.state-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.state-card{display:flex;min-height:104px;flex-direction:column;gap:7px;padding:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.state-card span,.state-card small{color:#606266}.state-card strong{color:#1f2937;font-size:16px;overflow-wrap:anywhere}.drawer-actions{position:sticky;bottom:0;z-index:2;display:flex;justify-content:flex-end;gap:8px;margin:24px -4px -28px;padding:16px 4px;background:#fff;border-top:1px solid #e5e7eb}:deep(.el-table .retry-disabled-row){color:#909399;background:#fafafa}:deep(.el-table .retry-disabled-row .el-checkbox){cursor:not-allowed}@media(max-width:1200px){.search-form{overflow-x:auto}.retry-action{margin-left:24px}}@media(max-width:768px){.search-form{white-space:normal}.state-grid{grid-template-columns:1fr}.detail-heading{align-items:flex-start;flex-direction:column}.drawer-actions{flex-wrap:wrap}}
</style>
+118
View File
@@ -0,0 +1,118 @@
import { expect, test } from '@playwright/test'
async function mockList(page: any) {
const state = { queries: [] as URLSearchParams[], fail: false, empty: false, writes: 0 }
await page.context().addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }])
await page.route('**/api/**', async route => {
const req = route.request()
const url = new URL(req.url())
if (url.pathname.startsWith('/src/api/')) return route.continue()
if (req.method() !== 'GET') { state.writes++; return route.abort() }
if (url.pathname.endsWith('/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '测试采购员', avatar: '', introduction: '', permissions: [] } } })
if (url.pathname.endsWith('/menurole')) return route.fulfill({ json: { code: 200, data: [{ path: '/collection-purchase', component: 'Layout', menuName: 'Purchase', title: '采集采购', visible: '0', children: [{ path: '/purchase-tasks/index', component: '/goauto/purchase-tasks/index', menuName: 'PurchaseTasks', title: '采购管理', visible: '0' }] }] } })
if (url.pathname.endsWith('/purchase-tasks')) {
state.queries.push(url.searchParams)
if (state.fail) return route.fulfill({ status: 500, json: { code: 500, message: '模拟查询失败' } })
return route.fulfill({ json: { code: 200, data: { items: state.empty ? [] : [{ id: 101, taskType: 'syb_order', status: 'failed', executionMode: 'live', retryable: true, shopeeOrderNoSnapshot: 'DEMO-A', quantity: 1, createdAt: '2026-10-01T00:00:00Z' }], total: state.empty ? 0 : 21 } } })
}
return route.fulfill({ json: { code: 200, data: [] } })
})
await page.goto('/#/purchase-tasks/index')
await expect(page.getByText('CG-101', { exact: true })).toBeVisible()
return state
}
test('批量粘贴去重、精确集合与状态组合,翻页保留条件并复用重试确认', async ({ page }) => {
const state = await mockList(page)
const field = page.getByLabel('蝦皮订单号', { exact: true })
await field.fill('001\r\nDEMO-A,DEMO-B\t001 DEMO-A')
await expect(page.getByText('已输入 3 个订单号。')).toBeVisible()
const before = state.queries.length
await field.press('End')
await field.press('Enter')
await expect(field).toHaveValue(/\n$/)
expect(state.queries.length).toBe(before)
await page.locator('.el-form-item').filter({ has: page.locator('label').filter({ hasText: /^状态$/ }) }).locator('.el-select').click()
await page.getByRole('option', { name: '采购失败', exact: true }).click()
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => state.queries.at(-1)?.getAll('shopeeOrderNos')).toEqual(['001', 'DEMO-A', 'DEMO-B'])
expect(state.queries.at(-1)?.get('shopeeOrderNo')).toBeNull()
expect(state.queries.at(-1)?.get('status')).toBe('failed')
await page.locator('.btn-next').click()
await expect.poll(() => state.queries.at(-1)?.get('page')).toBe('2')
expect(state.queries.at(-1)?.getAll('shopeeOrderNos')).toEqual(['001', 'DEMO-A', 'DEMO-B'])
await page.locator('.el-table__body .el-checkbox').first().click()
await page.getByRole('button', { name: '重试(1)', exact: true }).click()
await expect(page.getByRole('button', { name: '确认重试', exact: true })).toBeVisible()
expect(state.writes).toBe(0)
})
test('单号兼容、清空重置及空列表', async ({ page }) => {
const state = await mockList(page)
const field = page.getByLabel('蝦皮订单号', { exact: true })
await field.fill(' DEMO-A\nDEMO-A ')
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => state.queries.at(-1)?.get('shopeeOrderNo')).toBe('DEMO-A')
expect(state.queries.at(-1)?.has('shopeeOrderNos')).toBe(false)
await field.fill('')
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => state.queries.at(-1)?.has('shopeeOrderNo')).toBe(false)
await field.fill('MISSING-A,MISSING-B')
state.empty = true
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect(page.getByText('暂无采购任务')).toBeVisible()
await expect(field).toHaveValue('MISSING-A,MISSING-B')
await page.getByRole('button', { name: '重置', exact: true }).click()
await expect(field).toHaveValue('')
await expect.poll(() => state.queries.at(-1)?.has('shopeeOrderNos')).toBe(false)
expect(state.writes).toBe(0)
})
test('100个可查、101个或过长就地报错且不请求', async ({ page }) => {
const state = await mockList(page)
const field = page.getByLabel('蝦皮订单号', { exact: true })
const hundred = Array.from({ length: 100 }, (_, i) => `DEMO-${i}`)
await field.fill(hundred.join('\n'))
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect.poll(() => state.queries.at(-1)?.getAll('shopeeOrderNos').length).toBe(100)
const before = state.queries.length
await field.fill([...hundred, 'EXTRA'].join('\n'))
await expect(page.getByRole('alert').filter({ hasText: '最多查询 100' })).toBeVisible()
await expect(page.getByRole('button', { name: '查询', exact: true })).toBeDisabled()
await field.fill('A'.repeat(65))
await expect(page.getByRole('alert').filter({ hasText: '64' })).toBeVisible()
expect(state.queries.length).toBe(before)
expect(state.writes).toBe(0)
})
test('查询失败保留输入、清除旧勾选且不自动重试', async ({ page }) => {
const state = await mockList(page)
await page.locator('.el-table__body .el-checkbox').first().click()
await expect(page.getByRole('button', { name: '重试(1)', exact: true })).toBeEnabled()
const field = page.getByLabel('蝦皮订单号', { exact: true })
await field.fill('DEMO-A DEMO-B')
state.fail = true
const before = state.queries.length
await page.getByRole('button', { name: '查询', exact: true }).click()
await expect(page.getByRole('button', { name: '重新加载', exact: true })).toBeVisible()
await expect(field).toHaveValue('DEMO-A DEMO-B')
await expect(page.getByRole('button', { name: '重试(0)', exact: true })).toBeDisabled()
expect(state.queries.length).toBe(before + 1)
expect(state.writes).toBe(0)
})
test('轻量布局在桌面和窄屏可读,输入与查询可键盘访问', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await mockList(page)
for (const viewport of [{ width: 1440, height: 900 }, { width: 375, height: 812 }, { width: 812, height: 375 }]) {
await page.setViewportSize(viewport)
const field = page.getByLabel('蝦皮订单号', { exact: true })
await field.focus()
await expect(field).toBeFocused()
await expect(field).toBeInViewport()
const box = await field.boundingBox()
expect(box!.width).toBeLessThanOrEqual(viewport.width)
expect(box!.height).toBeGreaterThan(40)
await expect(page.getByRole('button', { name: '查询', exact: true })).toBeVisible()
}
})
@@ -0,0 +1,42 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const source = fs.readFileSync(path.join(__dirname, '../../src/utils/purchase-order-filter.js'), 'utf8')
const parse = new Function(source.replace('export function', 'function') + '\nreturn parsePurchaseOrderFilter')()
const apiSource = fs.readFileSync(path.join(__dirname, '../../src/api/goauto/purchase-tasks.js'), 'utf8')
const api = new Function('request', apiSource.replace(/^import .+\n/gm, '').replace(/export function /g, 'function ') + '\nreturn { listPurchaseTasks, retryPurchaseTasksBatch }')(config => config)
test('split mixed delimiters, remove empty/duplicates, preserve identifiers', () => {
assert.deepEqual(parse(' 001\r\nDEMO-A,DEMO-B\t001,DEMO-C\u0085\uFEFF'), { values: ['001', 'DEMO-A', 'DEMO-B', 'DEMO-C'], error: '' })
assert.deepEqual(parse(' ,,\t\r\n'), { values: [], error: '' })
assert.deepEqual(parse('DEMO-A\nDEMO-A').values, ['DEMO-A'])
})
test('100 unique orders and 64 Unicode code points allowed, overflow rejected without truncation', () => {
const hundred = Array.from({ length: 100 }, (_, i) => `DEMO-${i}`)
assert.equal(parse(hundred.join('\n')).error, '')
assert.equal(parse([...hundred, ...hundred].join(',')).error, '')
assert.equal(parse([...hundred, 'EXTRA'].join(',')).values.length, 101)
assert.match(parse([...hundred, 'EXTRA'].join(',')).error, /100/)
assert.equal(parse('中'.repeat(64)).error, '')
assert.equal(parse('😀'.repeat(64)).error, '')
assert.match(parse('中'.repeat(65)).error, /64/)
})
test('API serializes repeated keys and special characters without changing other filters', () => {
const params = { shopeeOrderNos: ['001', 'A&B', 'C+D'], page: 2, status: 'failed', pddOrderNo: 'PDD-1' }
const config = api.listPurchaseTasks(params)
const query = new URLSearchParams(config.paramsSerializer.serialize(params))
assert.deepEqual(query.getAll('shopeeOrderNos'), params.shopeeOrderNos)
assert.equal(query.get('shopeeOrderNos[]'), null)
assert.equal(query.get('page'), '2')
assert.equal(query.get('status'), 'failed')
assert.equal(query.get('pddOrderNo'), 'PDD-1')
assert.equal(config.timeout, undefined)
assert.equal(config.paramsSerializer.serialize({ shopeeOrderNos: [] }), 'shopeeOrderNos=')
assert.equal(config.paramsSerializer.serialize({ shopeeOrderNo: 'PART' }), 'shopeeOrderNo=PART')
assert.equal(api.retryPurchaseTasksBatch({ taskIds: [1] }).paramsSerializer, undefined)
assert.equal(api.retryPurchaseTasksBatch({ taskIds: [1] }).timeout, 60000)
})