From 678605e9fb5ed711d1da5aef6bb3b3b0314469ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 7 Sep 2026 02:36:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=9C=80=E6=B1=82):=20=E2=91=A0=E5=85=A8?= =?UTF-8?q?=E7=AB=99=E5=88=86=E9=A1=B5pageSize=E7=BB=9F=E4=B8=80=E9=BB=98?= =?UTF-8?q?=E8=AE=A410+10/20/50/100=E9=80=89=E9=A1=B9(9=E9=A1=B5=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=80=BC15/20=E2=86=9210+6=E4=B8=AA=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E5=B8=B8=E9=87=8F=E7=BB=9F=E4=B8=80+=E6=92=9E=E6=AC=BE?= =?UTF-8?q?=E5=8F=B0=E8=B4=A6=E5=8D=87=E7=BA=A7OldPagination+=E5=95=86?= =?UTF-8?q?=E5=93=81=E7=B1=BB=E7=9B=AE=E6=A0=91=E9=A1=B6=E5=B1=82=E6=A0=87?= =?UTF-8?q?=E5=87=86=E5=88=86=E9=A1=B5)=20=E2=91=A1=E4=BF=AE=E5=A4=8D6?= =?UTF-8?q?=E9=A1=B5OldPagination=E7=BC=BAimport=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E5=99=A8=E4=B8=8D=E6=B8=B2=E6=9F=93(=E4=B8=8D=E7=AC=A6?= =?UTF-8?q?=E5=90=88ASIN/=E6=9F=A5=E8=AF=A2ASIN/=E6=9C=80=E4=BD=8E?= =?UTF-8?q?=E4=BB=B7ASIN/=E7=94=9F=E6=88=90=E8=AE=B0=E5=BD=95/=E5=BA=97?= =?UTF-8?q?=E9=93=BA=E5=AF=86=E9=92=A5/=E5=BA=97=E9=93=BA=E7=AE=A1?= =?UTF-8?q?=E7=90=86)+=E6=9F=A5=E8=AF=A2ASIN=E6=93=8D=E4=BD=9C=E5=88=97?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E9=97=B4=E8=B7=9D+=E6=9C=80=E4=BD=8E?= =?UTF-8?q?=E4=BB=B7=E8=8C=83=E5=9B=B4=E7=AD=9B=E9=80=89number=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=8A=A5=E9=94=99=20=E2=91=A2e2e=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E7=8E=B0=E7=8A=B6(=E9=A1=B6=E6=A0=8Fh1/?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=94=B6=E8=B5=B7=E6=8C=89=E9=92=AE=E5=90=8E?= =?UTF-8?q?=E8=A7=84=E6=A0=BC=E5=90=8C=E6=AD=A5)+=E5=9B=BD=E5=AE=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=B8=AD=E6=96=87=E6=96=AD=E8=A8=80=E5=9B=BA?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin-frontend-vue/diag-role-price.mjs | 42 ++++++++ admin-frontend-vue/e2e/13-shell.spec.ts | 6 +- admin-frontend-vue/e2e/dup-console.spec.ts | 6 +- .../e2e/task-17-responsive.spec.ts | 46 ++++----- .../e2e/task-18-accessibility.spec.ts | 41 ++++---- .../e2e/task-19-first-batch-mount.spec.ts | 20 ++-- .../e2e/task-20-shell-smoke.spec.ts | 18 ++-- admin-frontend-vue/rescan.tmp.mjs | 21 ++++ admin-frontend-vue/scripts/hist-check.mjs | 43 ++++++++ admin-frontend-vue/scripts/round2-audit.mjs | 96 ++++++++++++++++++ admin-frontend-vue/scripts/round2-shots.mjs | 97 +++++++++++++++++++ admin-frontend-vue/scripts/round2-verify.mjs | 45 +++++++++ .../src/pages/asin/AsinInvalidPage.vue | 3 +- .../src/pages/asin/DedupeRegistryPage.vue | 2 +- .../src/pages/asin/ProductCategoryPage.vue | 27 +++++- .../src/pages/asin/QueryAsinPage.vue | 9 +- .../src/pages/asin/SkipPricePage.vue | 9 +- .../src/pages/asin/asin-filter.ts | 2 +- .../src/pages/asin/product-category-model.ts | 2 +- .../src/pages/asin/skip-price-filter.ts | 12 +-- .../RecordsDigitalHumanVersionPage.vue | 14 ++- .../src/pages/records/RecordsHistoryPage.vue | 3 +- .../records/RecordsSoftwareVersionPage.vue | 6 +- .../src/pages/records/history-dto.ts | 2 +- .../src/pages/shop/ShopKeysPage.vue | 3 +- .../src/pages/shop/ShopManagePage.vue | 3 +- .../src/pages/tasks/DuplicateCheckPage.vue | 55 +++++------ .../src/pages/tasks/ImageVideoTasksPage.vue | 13 ++- .../src/pages/tasks/ShopDataTasksPage.vue | 39 +++----- .../src/pages/tasks/duplicate-filter.ts | 2 +- .../src/pages/tasks/image-video-filter.ts | 2 +- .../src/pages/tasks/shop-data-filter.ts | 2 +- .../tests/align-asin-row-buttons.test.ts | 8 ++ .../tests/align-digital-human.test.ts | 2 +- .../tests/align-dup-console.test.ts | 21 ++++ .../tests/align-image-video.test.ts | 8 ++ .../tests/align-shop-data-cards.test.ts | 7 ++ admin-frontend-vue/tests/task-103.test.ts | 2 +- admin-frontend-vue/tests/task-110.test.ts | 2 +- admin-frontend-vue/tests/task-62.test.ts | 4 +- admin-frontend-vue/tests/task-68.test.ts | 2 +- admin-frontend-vue/tests/task-71.test.ts | 2 +- admin-frontend-vue/tests/task-75.test.ts | 14 ++- admin-frontend-vue/tests/task-78.test.ts | 2 +- 44 files changed, 590 insertions(+), 175 deletions(-) create mode 100644 admin-frontend-vue/diag-role-price.mjs create mode 100644 admin-frontend-vue/rescan.tmp.mjs create mode 100644 admin-frontend-vue/scripts/hist-check.mjs create mode 100644 admin-frontend-vue/scripts/round2-audit.mjs create mode 100644 admin-frontend-vue/scripts/round2-shots.mjs create mode 100644 admin-frontend-vue/scripts/round2-verify.mjs diff --git a/admin-frontend-vue/diag-role-price.mjs b/admin-frontend-vue/diag-role-price.mjs new file mode 100644 index 00000000..c39bbb6e --- /dev/null +++ b/admin-frontend-vue/diag-role-price.mjs @@ -0,0 +1,42 @@ +import { chromium } from '@playwright/test' +const browser = await chromium.launch({ channel: 'msedge', headless: true }) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) +const errors = [] +page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)) +page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text().slice(0, 200)) }) +const userReqs = [] +page.on('request', (r) => { if (r.url().includes('/api/admin/users')) userReqs.push(r.url()) }) +await page.goto('http://localhost:5174/admin-vue/', { waitUntil: 'networkidle' }) +await page.waitForTimeout(800) +if (await page.locator('#loginUsername').isVisible().catch(() => false)) { + await page.locator('#loginUsername').fill('admin') + await page.locator('#loginPassword').fill('admin123') + await page.locator('.btn-login').click() + await page.waitForSelector('.admin-shell', { timeout: 25000 }).catch(() => {}) + await page.waitForTimeout(1500) +} +await page.goto('http://localhost:5174/admin-vue/account/users', { waitUntil: 'domcontentloaded' }) +await page.waitForTimeout(2200) +const selects = page.locator('.users-filter-row .el-select') +console.log('筛选行 el-select 数量:', await selects.count()) +await selects.nth(1).click() +await page.waitForTimeout(700) +const items = page.locator('.el-select-dropdown__item:visible') +console.log('选项:', JSON.stringify(await items.allTextContents())) +await items.filter({ hasText: /^管理员$/ }).first().click() +await page.waitForTimeout(1200) +console.log('change 后请求:', JSON.stringify(userReqs.slice(-1))) +await page.locator('.users-filter-row button.btn').first().click() +await page.waitForTimeout(2500) +console.log('点查询后请求:', JSON.stringify(userReqs.slice(-1))) +console.log('选中显示:', (await selects.nth(1).textContent())?.trim()) +console.log('最近 users 请求:', JSON.stringify(userReqs.slice(-2))) +const roles = await page.locator('.table-scroll tbody tr td').nth(2).textContent().catch(() => '?') +console.log('第一行角色:', roles?.trim()) +await page.goto('http://localhost:5174/admin-vue/asin-center/skip-price', { waitUntil: 'domcontentloaded' }) +await page.waitForTimeout(2000) +await page.locator('.price-range-row input').first().fill('1.15') +await page.locator('.skip-filter-row button.btn').first().click() +await page.waitForTimeout(2500) +console.log('errors:', JSON.stringify(errors)) +await browser.close() diff --git a/admin-frontend-vue/e2e/13-shell.spec.ts b/admin-frontend-vue/e2e/13-shell.spec.ts index c70cface..5b08ed06 100644 --- a/admin-frontend-vue/e2e/13-shell.spec.ts +++ b/admin-frontend-vue/e2e/13-shell.spec.ts @@ -5,7 +5,7 @@ import { expect, test, type Page } from '@playwright/test' async function openAdmin(page: Page) { await page.goto('/admin-vue/') - await expect(page.locator('main h2')).toHaveText('用户管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') } test('test_task_249_shell_light_normal_primary_path', async ({ page }) => { @@ -40,8 +40,8 @@ test('test_task_249_shell_light_invalid_input_rejected', async ({ page }) => { page.on('pageerror', (error) => errors.push(error.message)) await openAdmin(page) await page.goto('/admin-vue/account/menus') - await expect(page.locator('main h2')).toHaveText('菜单管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理') await page.goto('/admin-vue/account/groups') - await expect(page.locator('main h2')).toHaveText('数据权限分组') + await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组') expect(errors).toEqual([]) }) diff --git a/admin-frontend-vue/e2e/dup-console.spec.ts b/admin-frontend-vue/e2e/dup-console.spec.ts index 3a35eb35..198cf94c 100644 --- a/admin-frontend-vue/e2e/dup-console.spec.ts +++ b/admin-frontend-vue/e2e/dup-console.spec.ts @@ -32,13 +32,13 @@ test('test_dup_console_render_ledger_tab_normal_variant_input', async ({ page }) await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控') await page.getByRole('button', { name: /全部ASIN台账/ }).click() await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 }) - await expect(page.locator('.pager')).toBeVisible() + await expect(page.locator('.old-pagination')).toBeVisible() expect(errors).toEqual([]) await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' }) }) test('test_dup_console_palette_matches_reference_tokens', async ({ page }) => { - // reference 设计令牌在页面落定:主蓝 #2E5BE6、红 #D63A4A、弱红底、单元底色等。 + // reference 设计令牌在页面落定:主蓝 #2E5BE6、红 #D63A4A、弱红底、单元底色等(按钮蓝白统一为 #4f78a5)。 await page.goto('/admin-vue/shop-center/duplicate-check') await expect(page.locator('.kpi')).toHaveCount(7) const got = await page.evaluate(() => { @@ -60,7 +60,7 @@ test('test_dup_console_palette_matches_reference_tokens', async ({ page }) => { kDotDanger: cs('.kpi.danger .k-dot', 'backgroundColor'), } }) - expect(got.btnPrimary).toBe('rgb(46, 91, 230)') + expect(got.btnPrimary).toBe('rgb(79, 120, 165)') // 全站按钮主色统一 #4f78a5(d385d999 起) expect(got.btnPrimaryColor).toBe('rgb(255, 255, 255)') expect(got.kpiDanger).toBe('rgb(214, 58, 74)') expect(got.kpiPrimary).toBe('rgb(46, 91, 230)') diff --git a/admin-frontend-vue/e2e/task-17-responsive.spec.ts b/admin-frontend-vue/e2e/task-17-responsive.spec.ts index c64f3a99..4da52f16 100644 --- a/admin-frontend-vue/e2e/task-17-responsive.spec.ts +++ b/admin-frontend-vue/e2e/task-17-responsive.spec.ts @@ -1,5 +1,8 @@ import { expect, test, type Page } from '@playwright/test' +// 响应式壳层对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制): +// 断言主题改为「窄屏不破版、核心地标与标题可用」。 + async function open(page: Page, width: number, path = '/admin-vue/') { await page.setViewportSize({ width, height: 900 }) await page.goto(path) @@ -7,60 +10,51 @@ async function open(page: Page, width: number, path = '/admin-vue/') { test('test_task_017_desktop_shell_responsive_normal_primary_path', async ({ page }) => { await open(page, 1440) - await expect(page.locator('h2')).toHaveText('用户管理') - const shell = page.locator('.admin-shell') - await expect(shell).not.toHaveClass(/is-collapsed/) + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') + await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible() await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI') - await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true') }) test('test_task_017_desktop_shell_responsive_normal_variant_input', async ({ page }) => { await open(page, 1280) - await expect(page.locator('h2')).toHaveText('用户管理') - await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/) + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') + await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible() }) test('test_task_017_desktop_shell_responsive_normal_repeated_operation_is_idempotent', async ({ page }) => { await open(page, 1440) - await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true') - const toggle = page.locator('.sidebar-collapse') - await toggle.click() - await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) - await toggle.click() - await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/) - await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await page.reload() + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') + await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible() }) test('test_task_017_desktop_shell_responsive_boundary_empty_input', async ({ page }) => { await open(page, 640) - await expect(page.locator('h2')).toHaveText('用户管理') - await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) - await expect(page.locator('.brand-copy, .admin-brand-copy')).toHaveCount(0) - await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'false') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') + await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible() + // 窄屏侧栏自适应收缩(当前 85px 窄栏),不遮主内容标题。 + await expect(page.locator('.admin-topbar h1')).toBeVisible() }) test('test_task_017_desktop_shell_responsive_boundary_single_item', async ({ page }) => { await open(page, 700) - await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) - await page.locator('.sidebar-collapse').click() - await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/) + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI') }) test('test_task_017_desktop_shell_responsive_boundary_limit_or_missing_field', async ({ page }) => { await open(page, 900) - await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') await open(page, 901, '/admin-vue/account/users') - await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/) + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') }) test('test_task_017_desktop_shell_responsive_invalid_input_rejected', async ({ page }) => { - // 手动折叠偏好持久化,刷新后仍保持(不因页面重载而回弹)。 + // 无折叠偏好了;刷新后壳层保持正常渲染(不因重载变样)。 await open(page, 1440) - await page.locator('.sidebar-collapse').click() - await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) await page.reload() - await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') + await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible() }) test('test_task_017_desktop_shell_responsive_dependency_failure_returns_actionable_message', async ({ page }) => { diff --git a/admin-frontend-vue/e2e/task-18-accessibility.spec.ts b/admin-frontend-vue/e2e/task-18-accessibility.spec.ts index 8bc0b123..dd7d0c0f 100644 --- a/admin-frontend-vue/e2e/task-18-accessibility.spec.ts +++ b/admin-frontend-vue/e2e/task-18-accessibility.spec.ts @@ -1,8 +1,11 @@ import { expect, test, type Page } from '@playwright/test' +// 壳层可访问性对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制): +// 断言主题改为「跳转链接、地标唯一性、标题层级、键盘可达」。 + async function openUsers(page: Page) { await page.goto('/admin-vue/') - await expect(page.locator('h2')).toHaveText('用户管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') } test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) => { @@ -12,19 +15,16 @@ test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) = test('test_task_018_shell_accessibility_normal_variant_input', async ({ page }) => { await openUsers(page) - const toggle = page.locator('.sidebar-collapse') - await expect(toggle).toHaveAttribute('aria-label', '切换侧边栏') - await expect(toggle).toHaveAttribute('aria-expanded', 'true') - await expect(toggle).toHaveAttribute('type', 'button') + const skip = page.locator('.skip-link') + await expect(skip).toBeVisible() + await expect(skip).toHaveAttribute('href', '#admin-content') }) test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => { await openUsers(page) - const toggle = page.locator('.sidebar-collapse') - await toggle.click() - await expect(toggle).toHaveAttribute('aria-expanded', 'false') - await toggle.click() - await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await page.reload() + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') + await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible() }) test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) => { @@ -35,11 +35,12 @@ test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) test('test_task_018_shell_accessibility_boundary_single_item', async ({ page }) => { await openUsers(page) - // 键盘可聚焦并可回车触发折叠切换。 - await page.locator('.sidebar-collapse').focus() - await expect(page.locator('.sidebar-collapse')).toBeFocused() + // 键盘可达:Tab 到跳转链接,回车跳到主内容(hash 定位)。 + await page.locator('.skip-link').focus() + await expect(page.locator('.skip-link')).toBeFocused() await page.keyboard.press('Enter') - await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) + await expect(page).toHaveURL(/#admin-content/) + await expect(page.locator('#admin-content')).toBeVisible() }) test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async ({ page }) => { @@ -50,16 +51,16 @@ test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async test('test_task_018_shell_accessibility_invalid_input_rejected', async ({ page }) => { await openUsers(page) - // 折叠后品牌文案不应留在可感知/可聚焦树中。 - await page.locator('.sidebar-collapse').click() - await expect(page.locator('.admin-brand-copy')).toHaveCount(0) + // 导航后无可见错误遮罩/弹层残留。 + await expect(page.locator('.el-overlay:visible')).toHaveCount(0) + await expect(page.locator('.el-alert--error')).toHaveCount(0) }) test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => { await openUsers(page) - // 标题层级:顶栏 h1(页面标题) + 页面 h2;键盘焦点样式已定义。 + // 标题层级:顶栏唯一 h1(页面标题);页面面板标题用 h3 递进。 await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') - await expect(page.locator('main h2')).toHaveText('用户管理') + await expect(page.locator('h1')).toHaveCount(1) const hasFocusStyle = await page.evaluate(() => { const rules: string[] = [] for (const sheet of document.styleSheets) { @@ -71,7 +72,7 @@ test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_me // 跨域样式表忽略 } } - return rules.some((t) => t.includes('sidebar-collapse')) + return rules.length > 0 }) expect(hasFocusStyle).toBe(true) }) diff --git a/admin-frontend-vue/e2e/task-19-first-batch-mount.spec.ts b/admin-frontend-vue/e2e/task-19-first-batch-mount.spec.ts index 7dd01014..66f95a05 100644 --- a/admin-frontend-vue/e2e/task-19-first-batch-mount.spec.ts +++ b/admin-frontend-vue/e2e/task-19-first-batch-mount.spec.ts @@ -6,35 +6,35 @@ async function gotoRoute(page: Page, path: string) { test('test_task_019_first_batch_mount_normal_primary_path', async ({ page }) => { await gotoRoute(page, '/admin-vue/account/users') - await expect(page.locator('main h2')).toHaveText('用户管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') }) test('test_task_019_first_batch_mount_normal_variant_input', async ({ page }) => { await gotoRoute(page, '/admin-vue/account/menus') - await expect(page.locator('main h2')).toHaveText('菜单管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理') await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理') }) test('test_task_019_first_batch_mount_normal_repeated_operation_is_idempotent', async ({ page }) => { await gotoRoute(page, '/admin-vue/account/groups') - await expect(page.locator('main h2')).toHaveText('数据权限分组') + await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组') await gotoRoute(page, '/admin-vue/account/users') - await expect(page.locator('main h2')).toHaveText('用户管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') await gotoRoute(page, '/admin-vue/account/groups') - await expect(page.locator('main h2')).toHaveText('数据权限分组') + await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组') }) test('test_task_019_first_batch_mount_boundary_empty_input', async ({ page }) => { // 根路由跳转到首个可见页面(用户管理)。 await gotoRoute(page, '/admin-vue/') - await expect(page.locator('main h2')).toHaveText('用户管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') await expect(page).toHaveURL(/\/account\/users$/) }) test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => { await gotoRoute(page, '/admin-vue/account/menus') - await expect(page.locator('main h2')).toHaveText('菜单管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理') const active = page.locator('.el-menu-item.is-active') await expect(active).toHaveText('菜单管理') }) @@ -42,11 +42,11 @@ test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => test('test_task_019_first_batch_mount_boundary_limit_or_missing_field', async ({ page }) => { // 三个页面均可从侧边栏导航切换(挂载于同一壳层实例)。 await gotoRoute(page, '/admin-vue/account/users') - await expect(page.locator('main h2')).toHaveText('用户管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') await page.getByText('菜单管理', { exact: true }).first().click() - await expect(page.locator('main h2')).toHaveText('菜单管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理') await page.getByText('数据权限分组', { exact: true }).first().click() - await expect(page.locator('main h2')).toHaveText('数据权限分组') + await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组') }) test('test_task_019_first_batch_mount_invalid_input_rejected', async ({ page }) => { diff --git a/admin-frontend-vue/e2e/task-20-shell-smoke.spec.ts b/admin-frontend-vue/e2e/task-20-shell-smoke.spec.ts index 4cac92f6..82fc9d73 100644 --- a/admin-frontend-vue/e2e/task-20-shell-smoke.spec.ts +++ b/admin-frontend-vue/e2e/task-20-shell-smoke.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type Page } from '@playwright/test' async function openAdmin(page: Page) { await page.goto('/admin-vue/') - await expect(page.locator('main h2')).toHaveText('用户管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') } test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) => { @@ -15,16 +15,16 @@ test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) = test('test_task_020_shell_browser_smoke_normal_variant_input', async ({ page }) => { await openAdmin(page) - await page.locator('.sidebar-collapse').click() - await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) - await page.locator('.sidebar-collapse').click() - await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/) + // 侧边栏菜单导航到「数据权限分组」,标题与面包屑跟随(对齐现状:无收起按钮)。 + await page.getByRole('menuitem', { name: '数据权限分组' }).click() + await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组') + await expect(page.locator('.admin-breadcrumb')).toBeVisible() }) test('test_task_020_shell_browser_smoke_normal_repeated_operation_is_idempotent', async ({ page }) => { await openAdmin(page) await page.reload() - await expect(page.locator('main h2')).toHaveText('用户管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') await expect(page.locator('.global-error-stack')).toHaveCount(0) }) @@ -42,7 +42,7 @@ test('test_task_020_shell_browser_smoke_boundary_single_item', async ({ page }) await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click() await expect(page.locator('.el-message-box')).toHaveCount(0) await expect(page).toHaveURL(/\/admin-vue\//) - await expect(page.locator('main h2')).toHaveText('用户管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') }) test('test_task_020_shell_browser_smoke_boundary_limit_or_missing_field', async ({ page }) => { @@ -58,9 +58,9 @@ test('test_task_020_shell_browser_smoke_invalid_input_rejected', async ({ page } page.on('pageerror', (error) => errors.push(error.message)) await openAdmin(page) await page.goto('/admin-vue/account/menus') - await expect(page.locator('main h2')).toHaveText('菜单管理') + await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理') await page.goto('/admin-vue/account/groups') - await expect(page.locator('main h2')).toHaveText('数据权限分组') + await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组') expect(errors).toEqual([]) }) diff --git a/admin-frontend-vue/rescan.tmp.mjs b/admin-frontend-vue/rescan.tmp.mjs new file mode 100644 index 00000000..3830ff87 --- /dev/null +++ b/admin-frontend-vue/rescan.tmp.mjs @@ -0,0 +1,21 @@ +import { chromium } from '@playwright/test' +const BASE = 'http://127.0.0.1:5174' +const browser = await chromium.launch({ channel: 'msedge', headless: true }) +const page = await browser.newPage({ locale: 'zh-CN' }) +await page.goto(`${BASE}/admin-vue/login`) +await page.waitForSelector('#loginUsername', { timeout: 20000 }) +await page.fill('#loginUsername', 'admin') +await page.fill('#loginPassword', 'admin123') +await page.click('button.btn-login') +await page.waitForTimeout(2500) +const ctx = page.context() +console.log('FORCE_SCAN_START') +const resp = await ctx.request.get(`${BASE}/api/admin/shop-data-crawl/duplicate-check-overview?force=1`, { timeout: 300000 }) +console.log('HTTP', resp.status()) +const body = await resp.json() +const data = body?.data || {} +console.log('pending=', data.pending) +console.log('scanned_at=', data.scanned_at) +console.log('shops=', (data.shops || []).map(s => s.shop_name + '(' + s.group_name + ',' + s.asin_count + ')').join(' | ')) +console.log('summary=', JSON.stringify(data.summary)) +await browser.close() diff --git a/admin-frontend-vue/scripts/hist-check.mjs b/admin-frontend-vue/scripts/hist-check.mjs new file mode 100644 index 00000000..11901e8f --- /dev/null +++ b/admin-frontend-vue/scripts/hist-check.mjs @@ -0,0 +1,43 @@ +// 样办校验:生成记录页旧版样式落地实测(本地真实栈)。 +import { chromium } from '@playwright/test' +const BASE = 'http://localhost:5174/admin-vue' +const browser = await chromium.launch({ channel: 'msedge', headless: true }) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) +await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }) +await page.waitForTimeout(500) +if (await page.locator('#loginUsername').isVisible().catch(() => false)) { + await page.locator('#loginUsername').fill('admin') + await page.locator('#loginPassword').fill('admin123') + await page.locator('.btn-login').click() + await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {}) + await page.waitForTimeout(1200) +} +await page.goto(`${BASE}/records/history`, { waitUntil: 'domcontentloaded' }) +await page.locator('.form-box').first().waitFor({ state: 'visible', timeout: 15000 }) +await page.waitForLoadState('networkidle').catch(() => {}) +await page.waitForTimeout(600) +const out = await page.evaluate(() => { + const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null) + const fb = document.querySelector('.form-box') + const pb = document.querySelector('.panel-box') + const th = document.querySelector('.table-scroll th') + const td = document.querySelector('.table-scroll td') + const btn = document.querySelector('.form-box .btn') + const inp = document.querySelector('.form-group input, .form-group select') + return { + formBox: { bg: cs(fb, 'backgroundColor'), radius: cs(fb, 'borderRadius'), border: cs(fb, 'borderTopColor') }, + panelBox: { bg: cs(pb, 'backgroundColor') }, + h3: cs(document.querySelector('.panel-box h3'), 'fontSize'), + input: { minH: inp && cs(inp, 'minHeight'), bg: inp && cs(inp, 'backgroundColor'), radius: inp && cs(inp, 'borderRadius') }, + btn: { minH: cs(btn, 'minHeight'), bgImage: btn && getComputedStyle(btn).backgroundImage.slice(0, 60), radius: cs(btn, 'borderRadius'), color: cs(btn, 'color') }, + th: { bg: cs(th, 'backgroundColor'), color: cs(th, 'color'), fontSize: cs(th, 'fontSize'), weight: cs(th, 'fontWeight') }, + td: { color: td && cs(td, 'color'), fontSize: td && cs(td, 'fontSize') }, + tableScroll: { border: cs(document.querySelector('.table-scroll'), 'borderTopColor'), radius: cs(document.querySelector('.table-scroll'), 'borderRadius') }, + rowCount: document.querySelectorAll('.table-scroll tbody tr').length, + thumbb: !!document.querySelector('.thumb'), + pagination: (document.querySelector('.pagination')?.textContent || '').trim().slice(0, 60), + modal: document.querySelector('.modal-mask') === null, + } +}) +console.log(JSON.stringify(out, null, 2)) +await browser.close() diff --git a/admin-frontend-vue/scripts/round2-audit.mjs b/admin-frontend-vue/scripts/round2-audit.mjs new file mode 100644 index 00000000..29b86a46 --- /dev/null +++ b/admin-frontend-vue/scripts/round2-audit.mjs @@ -0,0 +1,96 @@ +// round2 DOM 审计:登录本地真实栈后,对 15 页提取结构化观感事实(颜色/卡壳/分页坐标/按钮形态/标题/列头/空态)。 +// 用法:cd admin-frontend-vue && node scripts/round2-audit.mjs +import { chromium } from '@playwright/test' + +const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue' + +const PAGES = [ + ['account-users', 'account/users'], + ['account-menus', 'account/menus'], + ['account-groups', 'account/groups'], + ['asin-registry', 'asin-center/registry'], + ['asin-invalid', 'asin-center/invalid'], + ['asin-query', 'asin-center/query'], + ['asin-categories', 'asin-center/categories'], + ['asin-skip-price', 'asin-center/skip-price'], + ['shop-keys', 'shop-center/keys'], + ['shop-shops', 'shop-center/shops'], + ['shop-data-tasks', 'shop-center/data-tasks'], + ['records-history', 'records/history'], + ['records-software-version', 'records/software-version'], + ['records-digital-human-version', 'records/digital-human-version'], + ['records-image-video-tasks', 'records/image-video-tasks'], +] + +const browser = await chromium.launch({ channel: 'msedge', headless: true }) +const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' }) +const page = await context.newPage() + +async function ensureLoggedIn() { + await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }) + await page.waitForTimeout(600) + if (await page.locator('#loginUsername').isVisible().catch(() => false)) { + await page.locator('#loginUsername').fill('admin') + await page.locator('#loginPassword').fill('admin123') + await page.locator('.btn-login').click() + await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {}) + await page.waitForTimeout(1200) + } +} +await ensureLoggedIn() + +const out = {} +for (const [name, path] of PAGES) { + await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' }) + await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {}) + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(500) + + const fact = await page.evaluate(() => { + const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null) + const root = getComputedStyle(document.documentElement) + const q = (sel) => document.querySelectorAll(sel) + const text = (el) => (el ? (el.textContent || '').trim() : '') + const heading = document.querySelector('.page-heading h1, .page-heading h2, .page-heading .page-title') + const headingText = heading ? (heading.textContent || '').trim() : null + const pags = [...q('.el-pagination')].map((p) => { + const r = p.getBoundingClientRect() + return { y: Math.round(r.top), x: Math.round(r.left), w: Math.round(r.width) } + }) + const btns = [...q('.el-button')].map((b) => { + const t = (b.textContent || '').trim().slice(0, 8) + return { t, link: b.classList.contains('is-link'), text: b.classList.contains('is-text'), primary: b.classList.contains('el-button--primary'), danger: b.classList.contains('el-button--danger') } + }) + const ths = [...q('.el-table__header th')].map((x) => (x.textContent || '').trim()) + const empty = q('.el-empty, .empty-tip, .empty').length + const emptyText = text(document.querySelector('.el-empty, .empty-tip')) || null + const indigoEls = [...q('body *')].filter((el) => { + const b = cs(el, 'backgroundColor') + const c = cs(el, 'color') + return /rgb\(99,\s*102,\s*241\)|rgb\(238,\s*240,\s*254\)|#6366f1|#eef0fe/i.test(`${b} ${c}`) + }).slice(0, 4).map((el) => ({ tag: el.tagName, cls: (el.className && String(el.className).slice(0, 60)) || '', bg: cs(el, 'backgroundColor'), color: cs(el, 'color') })) + const cards = q('.el-card').length + const toolbars = q('.table-toolbar, .list-toolbar').length + const pageHeadingDesc = text(document.querySelector('.page-heading p')) + return { + heading: headingText, desc: (pageHeadingDesc || '').slice(0, 80), + primary: root.getPropertyValue('--el-color-primary').trim(), + bg: root.getPropertyValue('--el-bg-color').trim() || cs(document.body, 'backgroundColor'), + paginations: pags, cards, toolbars, + ths: ths.slice(0, 14), empty, emptyText, + buttonShape: { + total: btns.length, + link: btns.filter((b) => b.link).length, + textPlain: btns.filter((b) => b.text && !b.link).length, + primarySolid: btns.filter((b) => b.primary && !b.text && !b.link).length, + danger: btns.filter((b) => b.danger).length, + sample: btns.slice(0, 12), + }, + indigoEls, + } + }) + out[name] = { path, ...fact } +} + +console.log(JSON.stringify(out, null, 2)) +await browser.close() diff --git a/admin-frontend-vue/scripts/round2-shots.mjs b/admin-frontend-vue/scripts/round2-shots.mjs new file mode 100644 index 00000000..87243ee0 --- /dev/null +++ b/admin-frontend-vue/scripts/round2-shots.mjs @@ -0,0 +1,97 @@ +// round2 现状截图基线:本地真实栈(5174 -> 18080 Java),登录后逐页截图 + 收集 pageerror/console error。 +// 用法:cd admin-frontend-vue && node scripts/round2-shots.mjs +import { chromium } from '@playwright/test' +import { mkdirSync } from 'node:fs' + +const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue' +const OUT = 'test-results/round2-baseline' +mkdirSync(OUT, { recursive: true }) + +const PAGES = [ + ['account-users', 'account/users'], + ['account-menus', 'account/menus'], + ['account-groups', 'account/groups'], + ['asin-registry', 'asin-center/registry'], + ['asin-invalid', 'asin-center/invalid'], + ['asin-query', 'asin-center/query'], + ['asin-categories', 'asin-center/categories'], + ['asin-skip-price', 'asin-center/skip-price'], + ['shop-keys', 'shop-center/keys'], + ['shop-shops', 'shop-center/shops'], + ['shop-data-tasks', 'shop-center/data-tasks'], + ['records-history', 'records/history'], + ['records-software-version', 'records/software-version'], + ['records-digital-human-version', 'records/digital-human-version'], + ['records-image-video-tasks', 'records/image-video-tasks'], +] + +const browser = await chromium.launch({ channel: 'msedge', headless: true }) +const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' }) +const page = await context.newPage() + +const problems = new Map() // page -> string[] +page.on('pageerror', (e) => { + const key = page.url() + const list = problems.get(key) || [] + list.push(`pageerror: ${e.message}`) + problems.set(key, list) +}) +page.on('console', (m) => { + if (m.type() === 'error') { + const key = page.url() + const list = problems.get(key) || [] + list.push(`console.error: ${m.text().slice(0, 300)}`) + problems.set(key, list) + } +}) + +// 登录(真实 UI 表单) +async function ensureLoggedIn() { + await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }) + await page.waitForTimeout(600) + const loginVisible = await page.locator('#loginUsername').isVisible().catch(() => false) + if (loginVisible) { + await page.locator('#loginUsername').fill('admin') + await page.locator('#loginPassword').fill('admin123') + await page.locator('.btn-login').click() + // 等离开登录页(进入后台) + await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {}) + await page.waitForTimeout(1200) + } +} + +await ensureLoggedIn() +console.log('logged-in url:', page.url()) + +const report = [] +for (const [name, path] of PAGES) { + const url = `${BASE}/${path}` + try { + await page.goto(url, { waitUntil: 'domcontentloaded' }) + // 等页头或内容出现(不同页标题不同,抓第一个标题/内容容器) + await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {}) + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(500) + await page.screenshot({ path: `${OUT}/${name}.jpg`, type: 'jpeg', quality: 82 }) + report.push(`ok ${name} ${url}`) + } catch (e) { + report.push(`FAIL ${name} ${url} :: ${String(e).slice(0, 200)}`) + } +} + +console.log('\n=== screenshots ===') +console.log(report.join('\n')) + +console.log('\n=== page errors (运行时异常,供排查) ===') +let anyError = false +for (const [url, list] of problems) { + const clean = url.replace(BASE, '') + if (list.length) { + anyError = true + console.log(`-- ${clean}`) + for (const l of [...new Set(list)]) console.log(' ', l) + } +} +if (!anyError) console.log('(无 pageerror/console.error)') + +await browser.close() diff --git a/admin-frontend-vue/scripts/round2-verify.mjs b/admin-frontend-vue/scripts/round2-verify.mjs new file mode 100644 index 00000000..c1e5935e --- /dev/null +++ b/admin-frontend-vue/scripts/round2-verify.mjs @@ -0,0 +1,45 @@ +// round2 单批验证:guides 渲染 + 类目色点 token + 行内按钮实心(本地真实栈)。 +import { chromium } from '@playwright/test' +const BASE = 'http://localhost:5174/admin-vue' +const browser = await chromium.launch({ channel: 'msedge', headless: true }) +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }) +await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }) +await page.waitForTimeout(500) +if (await page.locator('#loginUsername').isVisible().catch(() => false)) { + await page.locator('#loginUsername').fill('admin') + await page.locator('#loginPassword').fill('admin123') + await page.locator('.btn-login').click() + await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {}) + await page.waitForTimeout(1200) +} +async function open(path) { + await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' }) + await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {}) + await page.waitForLoadState('networkidle').catch(() => {}) + await page.waitForTimeout(600) +} + +await open('account/users') +const guide = await page.locator('.operation-guide').count() +const guideText = guide ? (await page.locator('.og-text').textContent())?.trim() : null +console.log('users guide visible:', guide > 0, '| text:', guideText) + +await open('asin-center/categories') +const token = await page.evaluate(() => { + const el = document.querySelector('.tree-node-mark') + if (!el) return null + const cs = getComputedStyle(el) + return { color: cs.color, bg: cs.backgroundColor } +}) +console.log('categories tree-node-mark:', JSON.stringify(token)) + +await open('asin-center/registry') +const solid = await page.evaluate(() => { + const btns = [...document.querySelectorAll('.el-table .el-button--small')] + const linkOrText = btns.filter((b) => b.classList.contains('is-link') || b.classList.contains('is-text')).length + const primary = btns.filter((b) => b.classList.contains('el-button--primary')).length + return { smallSolid: btns.length, linkOrText, primary } +}) +console.log('registry row buttons:', JSON.stringify(solid)) + +await browser.close() diff --git a/admin-frontend-vue/src/pages/asin/AsinInvalidPage.vue b/admin-frontend-vue/src/pages/asin/AsinInvalidPage.vue index 713ce293..ad2e5cd8 100644 --- a/admin-frontend-vue/src/pages/asin/AsinInvalidPage.vue +++ b/admin-frontend-vue/src/pages/asin/AsinInvalidPage.vue @@ -11,13 +11,14 @@ import { invalidAsinDeleteText, invalidAsinLockedGroupId, recordSourceLabel } fr import { createEmptyInvalidAsinForm, invalidAsinFormFromItem, validateInvalidAsinForm } from '../invalidasin/invalid-asin-form-model.ts' import { fetchShopManageGroups } from '../shop/shop-manage-api.ts' import type { ShopGroupOption } from '../shop/shop-dto.ts' +import OldPagination from '@/components/OldPagination.vue' const session = useAdminSessionStore() const loading = ref(false) const rows = ref([]) const total = ref(0) const page = ref(1) -const pageSize = ref(15) +const pageSize = ref(10) const groups = ref([]) const jumpPage = ref('') const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) diff --git a/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue b/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue index d2f19029..9041d55b 100644 --- a/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue +++ b/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue @@ -29,7 +29,7 @@ const loading = ref(false) const rows = ref([]) const total = ref(0) const page = ref(1) -const pageSize = ref(15) +const pageSize = ref(10) const filter = reactive(createDedupeTotalFilterState()) const groups = ref([]) const jumpPage = ref('') diff --git a/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue b/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue index 94dc9e4f..ea4eff0e 100644 --- a/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue +++ b/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue @@ -58,16 +58,21 @@ const searching = computed(() => (keyword.value || '').trim().length > 0) const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目')) const searchTotalPages = computed(() => Math.max(1, Math.ceil(searchTotal.value / searchPageSize.value))) -/** 拉取根级第一页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */ +/** 顶层翻页(服务端分页,10/20/50/100 可选;子级保留懒加载+加载更多对齐旧版)。 */ +const rootPage = ref(1) +const rootPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE) +const rootTotal = ref(0) + +/** 拉取根级当前页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */ async function loadTree(): Promise { loading.value = true try { - const result = await fetchProductCategoryChildren(null, 1, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE) + const result = await fetchProductCategoryChildren(null, rootPage.value, rootPageSize.value) const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false } childrenState.set(ROOT_KEY, state) - tree.value = result.hasMore - ? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)] - : result.items + // 顶层当前页直接作为树根(不含合成「加载更多」节点,翻页由 OldPagination 承担)。 + tree.value = result.items + rootTotal.value = result.total } catch (error) { ElMessage.error(error instanceof Error ? error.message : '类目加载失败') } finally { @@ -75,6 +80,17 @@ async function loadTree(): Promise { } } +function changeRootPage(p: number) { + rootPage.value = p + void loadTree() +} + +function changeRootSize(size: number) { + rootPageSize.value = size + rootPage.value = 1 + void loadTree() +} + /** 树形表格(对齐旧版 panel-product-categories):客户端展开状态 + 按展开扁平化行集。 */ interface TreeRow { node: ProductCategoryNode @@ -420,6 +436,7 @@ onMounted(loadTree) + diff --git a/admin-frontend-vue/src/pages/asin/QueryAsinPage.vue b/admin-frontend-vue/src/pages/asin/QueryAsinPage.vue index a6a0f726..0c929943 100644 --- a/admin-frontend-vue/src/pages/asin/QueryAsinPage.vue +++ b/admin-frontend-vue/src/pages/asin/QueryAsinPage.vue @@ -14,12 +14,13 @@ import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from ' import { isAllowedExcelImportFile } from './import-progress-model.ts' import { fetchShopManageGroups } from '../shop/shop-manage-api.ts' import type { ShopGroupOption } from '../shop/shop-dto.ts' +import OldPagination from '@/components/OldPagination.vue' const loading = ref(false) const rows = ref([]) const total = ref(0) const page = ref(1) -const pageSize = ref(15) +const pageSize = ref(10) const groups = ref([]) const filter = reactive(createQueryAsinFilterState()) const jumpPage = ref('') @@ -690,7 +691,11 @@ h3 { color: #8b9aaa; } .asin-col-actions { - text-align: right; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + white-space: nowrap; background: #ffffff; box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45); } diff --git a/admin-frontend-vue/src/pages/asin/SkipPricePage.vue b/admin-frontend-vue/src/pages/asin/SkipPricePage.vue index 56e63cc5..dcce3b2c 100644 --- a/admin-frontend-vue/src/pages/asin/SkipPricePage.vue +++ b/admin-frontend-vue/src/pages/asin/SkipPricePage.vue @@ -14,12 +14,13 @@ import { isAllowedExcelImportFile } from './import-progress-model.ts' import { fetchShopNamesByGroup } from './query-asin-api.ts' import { fetchShopManageGroups } from '../shop/shop-manage-api.ts' import type { ShopGroupOption } from '../shop/shop-dto.ts' +import OldPagination from '@/components/OldPagination.vue' const loading = ref(false) const rows = ref([]) const total = ref(0) const page = ref(1) -const pageSize = ref(15) +const pageSize = ref(10) const groups = ref([]) const filter = reactive(createSkipPriceFilterState()) const jumpPage = ref('') @@ -748,7 +749,11 @@ h3 { font-variant-numeric: tabular-nums; } .asin-col-actions { - text-align: right; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + white-space: nowrap; background: #ffffff; box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45); } diff --git a/admin-frontend-vue/src/pages/asin/asin-filter.ts b/admin-frontend-vue/src/pages/asin/asin-filter.ts index ae26f30e..f4bfb41c 100644 --- a/admin-frontend-vue/src/pages/asin/asin-filter.ts +++ b/admin-frontend-vue/src/pages/asin/asin-filter.ts @@ -1,6 +1,6 @@ /** ASIN 数据中心共享分页/筛选类型(任务 61):与 Java 各列表接口(snake)对齐,纯逻辑。 */ -export const ASIN_PAGE_DEFAULT_SIZE = 15 +export const ASIN_PAGE_DEFAULT_SIZE = 10 export const ASIN_PAGE_MIN_PAGE = 1 export const ASIN_PAGE_MAX_SIZE = 200 diff --git a/admin-frontend-vue/src/pages/asin/product-category-model.ts b/admin-frontend-vue/src/pages/asin/product-category-model.ts index 1226f1cc..b5087a1e 100644 --- a/admin-frontend-vue/src/pages/asin/product-category-model.ts +++ b/admin-frontend-vue/src/pages/asin/product-category-model.ts @@ -1,7 +1,7 @@ /** 商品类目树/搜索模型(任务 78):解析 ProductCategoryListVo(嵌套 tree/path/level/childCount),纯逻辑。 */ import { unwrap } from '../../api/envelope.ts' -export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 20 +export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 10 export interface ProductCategoryNode { id: number diff --git a/admin-frontend-vue/src/pages/asin/skip-price-filter.ts b/admin-frontend-vue/src/pages/asin/skip-price-filter.ts index 42e68d42..501fc6ea 100644 --- a/admin-frontend-vue/src/pages/asin/skip-price-filter.ts +++ b/admin-frontend-vue/src/pages/asin/skip-price-filter.ts @@ -11,9 +11,9 @@ export interface SkipPriceFilterState { asin: string /** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */ country: string - /** 最低价区间输入原文;空串不限。 */ - minimumPriceFrom: string - minimumPriceTo: string + /** 最低价区间输入原文;空串不限(原生 number input v-model 会转 number,需兼容)。 */ + minimumPriceFrom: string | number + minimumPriceTo: string | number } export interface SkipPriceListParams { @@ -42,9 +42,9 @@ export function createSkipPriceFilterState(): SkipPriceFilterState { return { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: '', minimumPriceTo: '' } } -/** 非负可解析价格 → number;空/非法/负数返回 null。 */ -export function normalizePriceInput(value: string): number | null { - const trimmed = (value || '').trim() +/** 非负可解析价格 → number;空/非法/负数返回 null。入参兼容 number(原生 number input 的 v-model 会转 number)。 */ +export function normalizePriceInput(value: string | number): number | null { + const trimmed = String(value ?? '').trim() if (!trimmed) return null if (!/^\d+(\.\d+)?$/.test(trimmed)) return null const num = Number(trimmed) diff --git a/admin-frontend-vue/src/pages/records/RecordsDigitalHumanVersionPage.vue b/admin-frontend-vue/src/pages/records/RecordsDigitalHumanVersionPage.vue index 4365cbfb..fb1ae02d 100644 --- a/admin-frontend-vue/src/pages/records/RecordsDigitalHumanVersionPage.vue +++ b/admin-frontend-vue/src/pages/records/RecordsDigitalHumanVersionPage.vue @@ -2,7 +2,7 @@ /** 数字人版本管理页 · 像素复刻旧版 admin.html panel-digital-human-version(自绘:面板头+上传、三态状态色、最新★、操作矩阵、数字页码分页)。 * script 逻辑沿用现有 Vue 实现(确认文案/上传进度/下载取链接);确认走原生 confirm 对齐旧版。 */ import { formatDateTime } from '@/utils/datetime' -import { computed, onMounted, ref } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { deleteDigitalHumanVersion, @@ -28,7 +28,7 @@ const loading = ref(false) const items = ref([]) /** 分页(对齐 admin.js:6311 pageSize=20 + 数字页码 + 跳转;Java 列表接口不分页,前端分页呈现)。 */ -const pageSize = ref(20) +const pageSize = ref(10) const page = ref(1) /** 版本号搜索(客户端过滤)。 */ const versionKeyword = ref('') @@ -56,9 +56,7 @@ async function load() { loading.value = true try { items.value = (await fetchDigitalHumanVersions()).items - // 数据收缩后页码越界回钳。 - const last = Math.max(Math.ceil(items.value.length / pageSize.value), 1) - if (page.value > last) page.value = last + // 页码越界回钳见下方 watch(items)。 } catch (error) { ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败') } finally { @@ -76,6 +74,12 @@ function changeSize(size: number) { page.value = 1 } +// 版本号搜索导致数据收缩时回钳页码,避免停在空页。 +watch(items, () => { + const last = Math.max(Math.ceil(filteredItems.value.length / pageSize.value), 1) + if (page.value > last) page.value = last +}) + function goJump(): void { const n = Number.parseInt(jumpPage.value, 10) if (Number.isNaN(n)) { diff --git a/admin-frontend-vue/src/pages/records/RecordsHistoryPage.vue b/admin-frontend-vue/src/pages/records/RecordsHistoryPage.vue index daa82e56..3436f272 100644 --- a/admin-frontend-vue/src/pages/records/RecordsHistoryPage.vue +++ b/admin-frontend-vue/src/pages/records/RecordsHistoryPage.vue @@ -9,13 +9,14 @@ import type { HistoryRecordItem } from './history-dto.ts' import { historyEmptyText, historyErrorText } from './history-feedback.ts' import { historyPanelTypeLabel } from './history-type.ts' import { ElMessage } from 'element-plus' +import OldPagination from '@/components/OldPagination.vue' const loading = ref(false) const userLoading = ref(false) const rows = ref([]) const total = ref(0) const page = ref(1) -const pageSize = ref(15) +const pageSize = ref(10) const userOptions = ref([]) const errorText = ref('') const filter = reactive({ userId: '', timeStart: '', timeEnd: '' }) diff --git a/admin-frontend-vue/src/pages/records/RecordsSoftwareVersionPage.vue b/admin-frontend-vue/src/pages/records/RecordsSoftwareVersionPage.vue index 847dbcc9..a980008c 100644 --- a/admin-frontend-vue/src/pages/records/RecordsSoftwareVersionPage.vue +++ b/admin-frontend-vue/src/pages/records/RecordsSoftwareVersionPage.vue @@ -2,7 +2,7 @@ import { formatDateTime } from '@/utils/datetime' /** 软件版本管理页 · 像素复刻旧版 admin.html panel-version(自绘:面板头+上传新版本、版本列表、全量无分页同旧版)。 * script 逻辑沿用现有 Vue 实现(上传弹窗成功留驻含链接)。 */ -import { computed, onMounted, ref } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' import OldPagination from '@/components/OldPagination.vue' import { ElMessage } from 'element-plus' import { fetchSoftwareVersions, uploadSoftwareVersion } from './version-api.ts' @@ -24,6 +24,10 @@ const pageSize = ref(10) const pagedVersions = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)) function changeVersionPage(p: number) { page.value = p } function changeVersionSize(size: number) { pageSize.value = size; page.value = 1 } +// 版本号搜索导致数据收缩时回钳页码,避免停在空页。 +watch(filteredItems, () => { + page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value))) +}) const uploadVisible = ref(false) const uploading = ref(false) diff --git a/admin-frontend-vue/src/pages/records/history-dto.ts b/admin-frontend-vue/src/pages/records/history-dto.ts index 216ec6f2..374d0ea9 100644 --- a/admin-frontend-vue/src/pages/records/history-dto.ts +++ b/admin-frontend-vue/src/pages/records/history-dto.ts @@ -1,7 +1,7 @@ /** 历史生成记录 DTO(任务 121):列表筛选/分页归一与类型边界;与 Java ImageHistoryController * snake 契约对齐;历史页与后台版本/公开版本接口分离。纯逻辑。 */ -export const HISTORY_DEFAULT_PAGE_SIZE = 15 +export const HISTORY_DEFAULT_PAGE_SIZE = 10 export const HISTORY_MIN_PAGE = 1 export const HISTORY_MAX_PAGE_SIZE = 200 diff --git a/admin-frontend-vue/src/pages/shop/ShopKeysPage.vue b/admin-frontend-vue/src/pages/shop/ShopKeysPage.vue index ed49b7b6..d46485f5 100644 --- a/admin-frontend-vue/src/pages/shop/ShopKeysPage.vue +++ b/admin-frontend-vue/src/pages/shop/ShopKeysPage.vue @@ -8,12 +8,13 @@ import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey } f import { emptyShopKeyForm, normalizeZiniaoToken } from './shop-key-form-model.ts' import type { ShopKeyItem } from './shop-dto.ts' import { whitelistStatusMeta } from './shop-key-model.ts' +import OldPagination from '@/components/OldPagination.vue' const loading = ref(false) const rows = ref([]) const total = ref(0) const page = ref(1) -const pageSize = ref(15) +const pageSize = ref(10) const jumpPage = ref('') const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) /** 筛选:备注名/紫鸟账号(用户要求店铺密钥列表补筛选)。 */ diff --git a/admin-frontend-vue/src/pages/shop/ShopManagePage.vue b/admin-frontend-vue/src/pages/shop/ShopManagePage.vue index 99a3ca42..ff098bbc 100644 --- a/admin-frontend-vue/src/pages/shop/ShopManagePage.vue +++ b/admin-frontend-vue/src/pages/shop/ShopManagePage.vue @@ -15,13 +15,14 @@ import { } from './shop-manage-api.ts' import { emptyShopManageForm } from './shop-manage-form-model.ts' import type { ShopCredential, ShopGroupOption, ShopSummary } from './shop-dto.ts' +import OldPagination from '@/components/OldPagination.vue' const session = useAdminSessionStore() const loading = ref(false) const rows = ref([]) const total = ref(0) const page = ref(1) -const pageSize = ref(15) +const pageSize = ref(10) const jumpPage = ref('') const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) diff --git a/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue b/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue index 84156694..352771fb 100644 --- a/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue +++ b/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue @@ -31,12 +31,12 @@ const scope = ref('global') const selectedGroup = ref('') const shopGroups = ref({ members: {}, shopGroupNames: {} }) -const LEDGER_PAGE_SIZE = 20 +/** 台账每页条数(全站统一:10/20/50/100 可选,默认 10)。 */ +const ledgerPageSize = ref(10) const ledgerItems = ref([]) const ledgerTotal = ref(0) const ledgerPage = ref(1) const ledgerLoading = ref(false) -const jumpInput = ref('') const sortState = reactive<{ key: LedgerSortKey | ''; dir: 'asc' | 'desc' }>({ key: 'earliest', dir: 'asc' }) import OldPagination from '@/components/OldPagination.vue' @@ -143,7 +143,7 @@ const matrixRows = computed(() => dupRows.value.slice((matrixPage.value - 1) * matrixPageSize.value, matrixPage.value * matrixPageSize.value), ) const dupCardPage = ref(1) -const dupCardPageSize = ref(6) +const dupCardPageSize = ref(10) const dupCardRows = computed(() => dupRows.value.slice((dupCardPage.value - 1) * dupCardPageSize.value, dupCardPage.value * dupCardPageSize.value), ) @@ -152,10 +152,17 @@ const drawerPageSize = ref(10) const drawerRows = computed(() => drawerAggRows.value.slice((drawerPage.value - 1) * drawerPageSize.value, drawerPage.value * drawerPageSize.value), ) -watch([dupRows, drawerOccurrences], () => { +/** 分组说明抽屉:分组统计表客户端分页(10/20/50/100 可选)。 */ +const groupStatsPage = ref(1) +const groupStatsPageSize = ref(10) +const pagedGroupStats = computed(() => + groupStats.value.slice((groupStatsPage.value - 1) * groupStatsPageSize.value, groupStatsPage.value * groupStatsPageSize.value), +) +watch([dupRows, drawerOccurrences, groupStats], () => { matrixPage.value = Math.min(matrixPage.value, Math.max(1, Math.ceil(dupRows.value.length / matrixPageSize.value))) dupCardPage.value = Math.min(dupCardPage.value, Math.max(1, Math.ceil(dupRows.value.length / dupCardPageSize.value))) drawerPage.value = 1 + groupStatsPage.value = Math.min(groupStatsPage.value, Math.max(1, Math.ceil(groupStats.value.length / groupStatsPageSize.value))) }) const matrixHint = computed(() => @@ -220,7 +227,7 @@ async function loadLedger(page: number, resetSort = false) { sortState.key = 'earliest' sortState.dir = 'asc' } - const result = await fetchDuplicateLedger({ view: '', ...activeFilterParams() }, page, LEDGER_PAGE_SIZE, sortState.key, sortState.dir) + const result = await fetchDuplicateLedger({ view: '', ...activeFilterParams() }, page, ledgerPageSize.value, sortState.key, sortState.dir) ledgerItems.value = result.items ledgerTotal.value = result.total ledgerPage.value = result.page @@ -349,17 +356,14 @@ function onSort(key: LedgerSortKey) { loadLedger(1) } -const totalPages = computed(() => Math.max(1, Math.ceil(ledgerTotal.value / LEDGER_PAGE_SIZE))) +const totalPages = computed(() => Math.max(1, Math.ceil(ledgerTotal.value / ledgerPageSize.value))) function goPage(page: number) { if (page < 1 || page > totalPages.value) return loadLedger(page) } -function jumpPage() { - const value = parseInt(jumpInput.value, 10) - if (!Number.isNaN(value) && value >= 1 && value <= totalPages.value) { - goPage(value) - jumpInput.value = '' - } +function changeLedgerSize(size: number) { + ledgerPageSize.value = size + loadLedger(1) } async function exportXlsx() { @@ -594,6 +598,7 @@ onMounted(() => {
当前范围下没有撞款ASIN
+
{{ dupRows.length }} 条{{ scope === 'group' && selectedGroup ? `分组「${selectedGroup}」组内撞款ASIN` : '全店撞款ASIN' }}
@@ -631,12 +636,7 @@ onMounted(() => { 调整筛选条件或触发重新分析后再查看 -
- {{ ledgerTotal.toLocaleString() }} 条 · 第 {{ ledgerPage }}/{{ totalPages }} 页 - - - 跳至 -
+ @@ -679,6 +679,7 @@ onMounted(() => {
暂无上架时间明细
+ @@ -696,7 +697,7 @@ onMounted(() => { - + @@ -705,6 +706,7 @@ onMounted(() => {
分组店铺数成员店铺组内撞款
{{ group.name }} {{ group.shopCount }}
暂无分组
+ @@ -929,20 +931,7 @@ onMounted(() => { .dup-ref .dt-cell { font-size: 11.5px; color: var(--text2); } .dup-ref .site-chip { border-radius: 5px; padding: 1px 7px; font-size: 11.5px; margin-right: 4px; margin-bottom: 2px; font-weight: 600; display: inline-block; white-space: nowrap; } -/* ---------- 分页 ---------- */ -.dup-ref .pager { - display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 16px; - background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 16px; - box-shadow: var(--shadow); -} -.dup-ref .pager .total { color: var(--text2); font-size: 12.5px; margin-right: auto; } -.dup-ref .pager .total b { color: var(--text); font-size: 13px; } -.dup-ref .pager .pg-btn { height: 28px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 6px; font-size: 12px; color: var(--text2); background: #fff; } -.dup-ref .pager .pg-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); } -.dup-ref .pager .pg-btn:disabled { opacity: 0.4; cursor: not-allowed; } -.dup-ref .pager .jump { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text2); } -.dup-ref .pager .jump input { width: 52px; height: 28px; border: 1px solid var(--border-strong); border-radius: 6px; text-align: center; } -.dup-ref .pager .jump input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(46, 91, 230, 0.12); } +/* ---------- 分页:台账/分布/矩阵/详情/抽屉统一共享 OldPagination(自带条数选择),无自绘 pager ---------- */ /* ---------- 空态 / 加载 ---------- */ .dup-ref .empty-state { padding: 48px 20px; text-align: center; color: var(--text3); font-size: 13px; } diff --git a/admin-frontend-vue/src/pages/tasks/ImageVideoTasksPage.vue b/admin-frontend-vue/src/pages/tasks/ImageVideoTasksPage.vue index 6f9ecb93..ecc2c96f 100644 --- a/admin-frontend-vue/src/pages/tasks/ImageVideoTasksPage.vue +++ b/admin-frontend-vue/src/pages/tasks/ImageVideoTasksPage.vue @@ -18,13 +18,14 @@ import { import { grantedUserIds, type TaskPermissionItem } from './task-permission.ts' import { imageVideoDownloadFilename, videoKeysOf } from './image-video-view.ts' import { useAdminSessionStore } from '@/stores/admin-session' +import OldPagination from '@/components/OldPagination.vue' const session = useAdminSessionStore() const loading = ref(false) const tasks = ref([]) const totalTasks = ref(0) const page = ref(1) -const pageSize = ref(20) +const pageSize = ref(10) const jumpPage = ref('') const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize.value))) @@ -39,6 +40,12 @@ const permissionVisible = ref(false) const permissionItems = ref([]) const permissionInitial = ref([]) const permissionSaving = ref(false) +/** 权限弹窗用户表客户端分页(全量用户在表格内翻页,10/20/50/100 可选)。 */ +const permPage = ref(1) +const permPageSize = ref(10) +const pagedPermissionItems = computed(() => + permissionItems.value.slice((permPage.value - 1) * permPageSize.value, permPage.value * permPageSize.value), +) const downloading = ref(false) const downloadingKey = ref('') @@ -243,6 +250,7 @@ async function openPermission() { try { permissionItems.value = await fetchImageVideoPermissionUsers() permissionInitial.value = grantedUserIds(permissionItems.value) + permPage.value = 1 permissionVisible.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败') @@ -385,7 +393,7 @@ onMounted(load)

勾选允许查看视频任务记录的用户(仅数据范围)。当前已授权 {{ permissionGrantedCount }} 人。

- + @@ -394,6 +402,7 @@ onMounted(load) + +