Compare commits

..
Author SHA1 Message Date
QiuSW c6c7fbe2d4 feat(#354): add top pagination to SYB products 2026-10-05 11:12:48 +08:00
4 changed files with 80 additions and 14 deletions
+23 -4
View File
@@ -4,7 +4,6 @@
<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>
@@ -20,7 +19,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 +84,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,6 +402,7 @@ 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,
@@ -473,10 +476,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 = []
@@ -508,6 +514,16 @@ 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() },
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 组合出空列表;用户仍可再手动切回。
@@ -952,6 +968,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 +981,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}
+53 -6
View File
@@ -22,7 +22,7 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect(page.getByRole('button', { name: '导入', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '同步记录', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: '从 SYB 导入', exact: true })).toHaveCount(0)
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '重新解析', exact: true })).toHaveCount(0)
await expect(page.getByRole('columnheader', { name: '蝦皮关联' })).toHaveCount(0)
await expect(page.getByText('本页不展示采购任务、PDD 订单号、快递单号和支付状态。', { exact: true })).toHaveCount(0)
@@ -34,12 +34,12 @@ 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()
@@ -55,14 +55,14 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
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.getByPlaceholder('每行一个订单号,最多 100 个').fill(' SYB001\n\nSYB002\nSYB001 ')
@@ -78,6 +78,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
@@ -257,7 +304,7 @@ test('混选后采购与采集分别提交候选子集且采集按 PDD 去重',
const rows = page.locator('.el-table__body-wrapper tbody tr')
await expect(rows).toHaveCount(3)
for (let index = 0; index < 3; index += 1) await rows.nth(index).locator('.el-checkbox').click()
await expect(page.getByText('已选择 3 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 3 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
await expect(page.getByRole('button', { name: /创建采集\s*1/ })).toBeEnabled()
+2 -2
View File
@@ -61,7 +61,7 @@ test('one confirmation, mixed selection, progress, success and filter retention'
expect(writes).toEqual([11, 12])
expect(lists.at(-1)?.searchParams.get('pageSize')).toBe('200')
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
})
for (const mode of ['partial', 'conflict', 'auth']) {
@@ -75,7 +75,7 @@ for (const mode of ['partial', 'conflict', 'auth']) {
await expect(page.getByText(mode === 'auth' ? '失败 2 条' : mode === 'partial' ? '失败 1 条' : '跳过 2 条', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '返回列表', exact: true }).click()
if (mode === 'partial') {
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /取消匹配\s*1/ }).first()).toBeEnabled()
}
})
+2 -2
View File
@@ -65,7 +65,7 @@ test('v2 button order, one confirmation, no write on return, progress and mutual
await expect(page.getByText('跳过 1 条', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
await expect(page.getByText('已选择 0 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 0 条', { exact: true })).toBeVisible()
})
for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
@@ -81,7 +81,7 @@ for (const mode of ['partial', 'conflict', 'auth', 'readback-confirmed']) {
await page.getByRole('button', { name: '返回列表', exact: true }).click()
await expect(page.getByRole('dialog', { name: '批量使用退货结果', exact: true })).toBeHidden()
if (mode === 'partial') {
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByText('本页选择 1 条', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /使用退货\s*1/ })).toBeEnabled()
}
})