diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index cac047a..0c3531a 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -4,7 +4,6 @@ - 已选择 {{ selectedProducts.length }} 条 {{ item.displayName }}已停用 @@ -20,7 +19,10 @@ - + + 本页选择 {{ selectedProducts.length }} 条 + + 无图 @@ -82,7 +84,7 @@ 详情采购 - + @@ -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} diff --git a/web/tests/e2e/syb-product-layout.spec.ts b/web/tests/e2e/syb-product-layout.spec.ts index 095fb02..de38887 100644 --- a/web/tests/e2e/syb-product-layout.spec.ts +++ b/web/tests/e2e/syb-product-layout.spec.ts @@ -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() diff --git a/web/tests/e2e/syb-return-cancel.spec.ts b/web/tests/e2e/syb-return-cancel.spec.ts index aa841ed..cae8189 100644 --- a/web/tests/e2e/syb-return-cancel.spec.ts +++ b/web/tests/e2e/syb-return-cancel.spec.ts @@ -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() } }) diff --git a/web/tests/e2e/syb-return-confirm.spec.ts b/web/tests/e2e/syb-return-confirm.spec.ts index 1d0d9ea..4a201ff 100644 --- a/web/tests/e2e/syb-return-confirm.spec.ts +++ b/web/tests/e2e/syb-return-confirm.spec.ts @@ -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() } })