align(重复检测): DuplicateCheckPage 像素级复刻 reference asin-store-console
撞款控制台整页复刻 reference 观感:引入页面独立 reference 令牌(#2E5BE6 主蓝/#D63A4A 红/#F2F4F8 底)落到 KPI 色点/矩阵单元格/撞款卡/台账 chips;撞款覆盖矩阵与全部ASIN台账改原生表格(台账20条/页+共N条/上一页/下一页/跳至N页分页条,列头服务端排序▲/▼);筛选改原生 input/select/date;明细抽屉+底部 toast 复刻 reference(原生 mask/drawer/d-table 汇总徽章);分组说明只读(归属来自店铺管理)。 数据源保持自家 /api/admin/shop-data-crawl/duplicate-check-console|-ledger|-export 不变。 验收:mock-admin-server 补 console/ledger 夹具;新增 e2e dup-console.spec(渲染/台账切换/令牌色值/矩阵台账结构断言+截图);align-dup-console/align-column-width 断言随原生表格调整;前端 1552 测试全绿 + vue-tsc/build 通过。
This commit is contained in:
@@ -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' })
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API。
|
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API + 撞款控制台夹具。
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
|
|
||||||
const PORT = Number(process.env.MOCK_PORT || 18100)
|
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) {
|
function json(res, payload, status = 200) {
|
||||||
const body = JSON.stringify(payload)
|
const body = JSON.stringify(payload)
|
||||||
res.writeHead(status, {
|
res.writeHead(status, {
|
||||||
@@ -54,6 +171,15 @@ const server = createServer((req, res) => {
|
|||||||
if (url === '/api/admin/shop-manage-groups') {
|
if (url === '/api/admin/shop-manage-groups') {
|
||||||
return json(res, { success: true, data: { items: [] } })
|
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/')) {
|
if (url.startsWith('/api/')) {
|
||||||
return json(res, { success: true, data: { items: [], total: 0 } })
|
return json(res, { success: true, data: { items: [], total: 0 } })
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -55,8 +55,8 @@ export async function fetchDuplicateLedger(
|
|||||||
return parseLedgerPage(data)
|
return parseLedgerPage(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出 CSV(撞款 view=monitor / 台账 view=all):GET /duplicate-check-export → Blob。 */
|
/** 导出 Excel(撞款 view=monitor / 台账 view=all):GET /duplicate-check-export → xlsx Blob。 */
|
||||||
export async function requestDuplicateCsvBlob(filter: DuplicateFilter, view: string): Promise<Blob> {
|
export async function requestDuplicateExportBlob(filter: DuplicateFilter, view: string): Promise<Blob> {
|
||||||
const { data } = await http.get<Blob>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, {
|
const { data } = await http.get<Blob>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, {
|
||||||
params: toDuplicateItemsQuery({ ...filter, view }, 1, 100),
|
params: toDuplicateItemsQuery({ ...filter, view }, 1, 100),
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@@ -47,9 +47,10 @@ test('align_column_width_normal_variant_input', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('align_column_width_boundary_empty_input', () => {
|
test('align_column_width_boundary_empty_input', () => {
|
||||||
// 例外页 DuplicateCheckPage 保持自身实现不动。
|
// 例外页 DuplicateCheckPage 已整页复刻 reference 撞款控制台(原生 ledger 表格),不参与 el-table 列宽约定。
|
||||||
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||||
assert.match(dup, /el-table-column/, 'DuplicateCheckPage 表格仍在且未被修改')
|
assert.equal(/el-table-column/.test(dup), false, '撞款台账已用 reference 原生表格,不再有 el-table-column')
|
||||||
|
assert.match(dup, /class="ledger"/, '撞款台账仍为表格实现')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('align_column_width_dependency_failure_returns_actionable_message', () => {
|
test('align_column_width_dependency_failure_returns_actionable_message', () => {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ test('align_dup_console_drawer_template', () => {
|
|||||||
assert.match(page, /drawerAggRows/, '抽屉渲染聚合行')
|
assert.match(page, /drawerAggRows/, '抽屉渲染聚合行')
|
||||||
assert.match(page, /drawerSummary/, '抽屉渲染汇总徽章')
|
assert.match(page, /drawerSummary/, '抽屉渲染汇总徽章')
|
||||||
assert.match(page, /次上架/, '汇总含 N 次上架')
|
assert.match(page, /次上架/, '汇总含 N 次上架')
|
||||||
assert.match(page, /label="次数"/, '聚合表含次数列')
|
assert.match(page, /<th[^>]*>次数<\/th>/, '聚合表含次数列')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('align_dup_console_ledger_total_preload', () => {
|
test('align_dup_console_ledger_total_preload', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user