diff --git a/admin-frontend-vue/e2e/dup-console.spec.ts b/admin-frontend-vue/e2e/dup-console.spec.ts new file mode 100644 index 00000000..3a35eb35 --- /dev/null +++ b/admin-frontend-vue/e2e/dup-console.spec.ts @@ -0,0 +1,95 @@ +import { existsSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { expect, test } from '@playwright/test' + +// 撞款控制台(DuplicateCheckPage)像素级复刻 reference asin-store-console 的真浏览器验收: +// mock 后端跑真实数据渲染,无 pageerror;并截图本页与 reference 离线页供人工比对。 + +const REFERENCE = 'D:\\todesk\\副业\\数富AI\\crawler-plugin\\reference\\asin-store-console\\asin-store-console\\index.html' + +test('test_dup_console_render_dup_tab_normal_primary_path', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + + await page.goto('/admin-vue/shop-center/duplicate-check') + await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控') + // KPI + 矩阵行 + 撞款卡片都渲染出真实 mock 数据 + await expect(page.locator('.kpi')).toHaveCount(7) + await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 }) + await expect(page.locator('.dup-card').first()).toBeVisible() + // 分组撞款:切到「分组撞款」出现分组 chips + await page.getByRole('button', { name: /分组撞款/ }).click() + await expect(page.locator('.g-chip').first()).toBeVisible() + expect(errors).toEqual([]) + await page.screenshot({ path: 'test-results/dup-console-admin-dup.jpg' }) +}) + +test('test_dup_console_render_ledger_tab_normal_variant_input', async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + + await page.goto('/admin-vue/shop-center/duplicate-check') + 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() + 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、弱红底、单元底色等。 + await page.goto('/admin-vue/shop-center/duplicate-check') + await expect(page.locator('.kpi')).toHaveCount(7) + const got = await page.evaluate(() => { + const cs = (sel, prop) => { + const el = document.querySelector(sel) + return el ? getComputedStyle(el)[prop] : null + } + return { + btnPrimary: cs('.btn-primary', 'backgroundColor'), + btnPrimaryColor: cs('.btn-primary', 'color'), + kpiDanger: cs('.kpi.danger .k-value', 'color'), + kpiPrimary: cs('.kpi.primary .k-value', 'color'), + mCell: cs('.m-cell', 'backgroundColor'), + mCellText: cs('.m-cell', 'color'), + badgeDup: cs('.badge.dup', 'backgroundColor'), + badgeSingle: cs('.badge.single', 'backgroundColor'), + kSub: cs('.kpi .k-sub', 'color'), + selectBorder: cs('.f-group select', 'borderColor'), + kDotDanger: cs('.kpi.danger .k-dot', 'backgroundColor'), + } + }) + expect(got.btnPrimary).toBe('rgb(46, 91, 230)') + expect(got.btnPrimaryColor).toBe('rgb(255, 255, 255)') + expect(got.kpiDanger).toBe('rgb(214, 58, 74)') + expect(got.kpiPrimary).toBe('rgb(46, 91, 230)') + expect(got.mCell).toBe('rgb(234, 240, 255)') + expect(got.mCellText).toBe('rgb(46, 91, 230)') + expect(got.badgeDup).toBe('rgb(253, 235, 237)') + expect(got.badgeSingle).toBe('rgb(231, 247, 239)') + expect(got.kSub).toBe('rgb(152, 161, 177)') + expect(got.selectBorder).toBe('rgb(213, 219, 230)') + expect(got.kDotDanger).toBe('rgb(214, 58, 74)') +}) + +test('test_dup_console_structure_matches_reference', async ({ page }) => { + // 结构对齐:矩阵列=可见店铺数、台账列=reference 10 列、分组说明抽屉内 4 列。 + await page.goto('/admin-vue/shop-center/duplicate-check') + await expect(page.locator('.kpi')).toHaveCount(7) + await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 }) + const matrixHeaders = await page.locator('table.matrix thead th').count() + expect(matrixHeaders).toBe(8) // ASIN + 品牌 + 6 家店 + await page.getByRole('button', { name: /全部ASIN台账/ }).click() + await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 }) + const ledgerHeaders = await page.locator('table.ledger thead th').count() + expect(ledgerHeaders).toBe(10) +}) + +test('test_dup_console_reference_screenshot', async ({ page }) => { + // reference 离线原型截图:仅供人工像素比对(不接入应用路由)。 + test.skip(!existsSync(REFERENCE), 'reference 原型不在本机,跳过比对') + await page.goto(pathToFileURL(REFERENCE).href) + await expect(page.locator('.kpi').first()).toBeVisible({ timeout: 15000 }) + await page.screenshot({ path: 'test-results/dup-console-reference.jpg' }) +}) diff --git a/admin-frontend-vue/scripts/mock-admin-server.mjs b/admin-frontend-vue/scripts/mock-admin-server.mjs index f90536ea..68e5fb5d 100644 --- a/admin-frontend-vue/scripts/mock-admin-server.mjs +++ b/admin-frontend-vue/scripts/mock-admin-server.mjs @@ -1,4 +1,4 @@ -// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API。 +// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API + 撞款控制台夹具。 import { createServer } from 'node:http' const PORT = Number(process.env.MOCK_PORT || 18100) @@ -15,6 +15,123 @@ const MENU_TREE = [ }, ] +/* ===================== 撞款控制台夹具(对齐前端 parse 的 snake 契约) ===================== */ +const STORE_GROUP = { 店A1: '陈新辉', 店A2: '陈新辉', 店B1: '魏振峰', 店B2: '魏振峰', 店B3: '魏振峰', 店C1: '郭亚芳' } +const GROUP_DEFS = [ + { name: '陈新辉', shops: ['店A1', '店A2'] }, + { name: '魏振峰', shops: ['店B1', '店B2', '店B3'] }, + { name: '郭亚芳', shops: ['店C1'] }, +] + +function occ(asin, brand, shop, country, day) { + return { asin, date: `2026-08-${String(day).padStart(2, '0')} 09:00:00`, price: '9.90', brand, shop_name: shop, group_name: STORE_GROUP[shop], country_codes: [country], country } +} + +// 撞款种子(≥2 店在售)与单店种子 +const DUP_SEEDS = [ + { asin: 'E0000001', brand: '品牌甲', occ: [occ('E0000001', '品牌甲', '店A1', 'UK', 20), occ('E0000001', '品牌甲', '店A1', 'UK', 24), occ('E0000001', '品牌甲', '店A2', 'DE', 22), occ('E0000001', '品牌甲', '店B1', 'FR', 21)] }, + { asin: 'E0000002', brand: '品牌乙', occ: [occ('E0000002', '品牌乙', '店B1', 'UK', 10), occ('E0000002', '品牌乙', '店B2', 'DE', 12)] }, + { asin: 'E0000003', brand: '品牌丙', occ: [occ('E0000003', '品牌丙', '店A2', 'DE', 15), occ('E0000003', '品牌丙', '店B3', 'IT', 16)] }, +] +const SINGLE_SEEDS = [ + { asin: 'E0000004', brand: '品牌甲', occ: [occ('E0000004', '品牌甲', '店A1', 'UK', 1)] }, + { asin: 'E0000005', brand: '品牌丁', occ: [occ('E0000005', '品牌丁', '店B2', 'UK', 2), occ('E0000005', '品牌丁', '店B2', 'UK', 3)] }, + { asin: 'E0000006', brand: '品牌戊', occ: [occ('E0000006', '品牌戊', '店C1', 'FR', 5)] }, + { asin: 'E0000007', brand: '品牌己', occ: [occ('E0000007', '品牌己', '店B3', 'DE', 6)] }, + { asin: 'E0000008', brand: '品牌庚', occ: [occ('E0000008', '品牌庚', '店A1', 'ES', 7)] }, +] + +function finalizeRow(seed) { + const stores = [] + const storeSet = new Set() + const countries = new Set() + let earliest = '' + let latest = '' + for (const o of seed.occ) { + if (!storeSet.has(o.shop_name)) { + storeSet.add(o.shop_name) + stores.push(o.shop_name) + } + countries.add(o.country) + if (!earliest || o.date < earliest) earliest = o.date + if (o.date > latest) latest = o.date + } + const groups = [...new Set(stores.map((s) => STORE_GROUP[s] || ''))].filter(Boolean) + return { + asin: seed.asin, + brand: seed.brand, + store_count: stores.length, + stores, + groups, + countries: [...countries], + record_count: seed.occ.length, + earliest, + latest, + occurrences: seed.occ, + } +} + +const ALL_ROWS = [...DUP_SEEDS, ...SINGLE_SEEDS].map(finalizeRow) +const DUP_ROWS = ALL_ROWS.filter((row) => row.store_count >= 2) + +const SHOPS = Object.keys(STORE_GROUP).map((name) => { + const codes = new Set() + let recordCount = 0 + let asinCount = 0 + for (const row of ALL_ROWS) { + if (row.stores.includes(name)) { + asinCount += 1 + for (const o of row.occurrences) { + if (o.shop_name === name) { + recordCount += 1 + codes.add(o.country) + } + } + } + } + return { shop_name: name, group_name: STORE_GROUP[name], country_codes: [...codes], asin_count: asinCount, record_count: recordCount } +}) + +function computeGroups() { + return GROUP_DEFS.map((group) => { + const members = group.shops + const hit = DUP_ROWS.filter((row) => members.filter((shop) => row.stores.includes(shop)).length >= 2) + const asinUnique = ALL_ROWS.filter((row) => row.stores.some((shop) => members.includes(shop))).length + const recordCount = ALL_ROWS.reduce((sum, row) => { + if (row.stores.some((shop) => members.includes(shop))) sum += row.recordCount + return sum + }, 0) + return { name: group.name, shop_count: members.length, asin_unique: asinUnique, record_count: recordCount, dup_count: hit.length } + }) +} + +const GROUPS = computeGroups() +const RECORD_TOTAL = ALL_ROWS.reduce((sum, row) => sum + row.recordCount, 0) + +function consolePayload() { + return { + success: true, + data: { + pending: false, + scanned_at: '2026-09-05 08:30:00', + summary: { + shop_count: SHOPS.length, + asin_total: ALL_ROWS.length, + record_total: RECORD_TOTAL, + duplicate_asin_total: DUP_ROWS.length, + duplicate_shop_count: DUP_ROWS.length ? 6 : 0, + site_count: 5, + asin_per_shop: 4.8, + source: 'job', + }, + shops: SHOPS, + dup: DUP_ROWS, + groups: GROUPS, + total_dup: DUP_ROWS.length, + }, + } +} + function json(res, payload, status = 200) { const body = JSON.stringify(payload) res.writeHead(status, { @@ -54,6 +171,15 @@ const server = createServer((req, res) => { if (url === '/api/admin/shop-manage-groups') { return json(res, { success: true, data: { items: [] } }) } + if (url === '/api/admin/shop-data-crawl/duplicate-check-console') { + return json(res, consolePayload()) + } + if (url === '/api/admin/shop-data-crawl/duplicate-check-ledger') { + return json(res, { + success: true, + data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 }, + }) + } if (url.startsWith('/api/')) { return json(res, { success: true, data: { items: [], total: 0 } }) } diff --git a/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue b/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue index 25c5cefb..11883aec 100644 --- a/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue +++ b/admin-frontend-vue/src/pages/tasks/DuplicateCheckPage.vue @@ -1,14 +1,14 @@