From 479cc449d6e499f67f4bdba793f40508ae222034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 17:25:41 +0800 Subject: [PATCH] =?UTF-8?q?task-117(=E4=BB=BB=E5=8A=A1=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=88=86=E6=9E=90=E4=B8=AD=E5=BF=83):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E9=87=8D=E5=A4=8D=E6=A3=80=E6=9F=A5=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 duplicate-filter.ts(明细筛选 snake 查询) 与 parseDuplicateItemsPage/ fetchDuplicateItems(GET /duplicate-check-items)。 TDD: task-117.test.ts 8 用例先 RED 后 GREEN。 --- .../src/pages/tasks/duplicate-api.ts | 15 +++- .../src/pages/tasks/duplicate-filter.ts | 63 ++++++++++++++ .../src/pages/tasks/duplicate-model.ts | 28 +++++++ admin-frontend-vue/tests/task-117.test.ts | 84 +++++++++++++++++++ 4 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 admin-frontend-vue/src/pages/tasks/duplicate-filter.ts create mode 100644 admin-frontend-vue/tests/task-117.test.ts diff --git a/admin-frontend-vue/src/pages/tasks/duplicate-api.ts b/admin-frontend-vue/src/pages/tasks/duplicate-api.ts index 340cb51c..472bae4b 100644 --- a/admin-frontend-vue/src/pages/tasks/duplicate-api.ts +++ b/admin-frontend-vue/src/pages/tasks/duplicate-api.ts @@ -1,6 +1,7 @@ /** 店铺数据重复检查适配(任务 115-119):/api/admin/shop-data-crawl/duplicate-check-* 系列端点。 */ import { http } from '@/api/http' -import { parseDuplicateOverview, type DuplicateOverview } from './duplicate-model.ts' +import { parseDuplicateItemsPage, parseDuplicateOverview, type DuplicateOverview } from './duplicate-model.ts' +import { toDuplicateItemsQuery, type DuplicateFilter } from './duplicate-filter.ts' export const DUPLICATE_CHECK_ENDPOINT = '/api/admin/shop-data-crawl' @@ -11,3 +12,15 @@ export async function fetchDuplicateOverview(force = false): Promise> { + const { data } = await http.get(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-items`, { + params: toDuplicateItemsQuery(filter, page, pageSize), + }) + return parseDuplicateItemsPage(data) +} diff --git a/admin-frontend-vue/src/pages/tasks/duplicate-filter.ts b/admin-frontend-vue/src/pages/tasks/duplicate-filter.ts new file mode 100644 index 00000000..4206c04a --- /dev/null +++ b/admin-frontend-vue/src/pages/tasks/duplicate-filter.ts @@ -0,0 +1,63 @@ +/** 重复检查明细筛选与分页(任务 117):与 /duplicate-check-items 的 snake 参数契约对齐;纯逻辑。 */ + +export const DUPLICATE_DEFAULT_PAGE_SIZE = 20 +export const DUPLICATE_MIN_PAGE_SIZE = 10 +export const DUPLICATE_MAX_PAGE_SIZE = 100 +export const DUPLICATE_PAGE_MIN = 1 + +export interface DuplicateFilter { + view: string + asin: string + shopName: string + country: string + site: string + dateFrom: string + dateTo: string +} + +export interface DuplicateItemsQuery { + page: number + page_size: number + view?: string + asin?: string + shop_name?: string + country?: string + site?: string + date_from?: string + date_to?: string +} + +function textOrUndefined(value: unknown): string | undefined { + const text = typeof value === 'string' ? value.trim() : '' + return text || undefined +} + +function finiteInt(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null +} + +/** 前端筛选 + 页码 → Java 查询参数(snake,仅下发非空)。 */ +export function toDuplicateItemsQuery(filter: DuplicateFilter, page: unknown, pageSize: unknown): DuplicateItemsQuery { + const safePage = Math.max(finiteInt(page) ?? DUPLICATE_PAGE_MIN, DUPLICATE_PAGE_MIN) + const rawSize = finiteInt(pageSize) + const pageSizeClamped = + rawSize === null || rawSize < 1 + ? DUPLICATE_DEFAULT_PAGE_SIZE + : Math.min(Math.max(rawSize, DUPLICATE_MIN_PAGE_SIZE), DUPLICATE_MAX_PAGE_SIZE) + const query: DuplicateItemsQuery = { page: safePage, page_size: pageSizeClamped } + const view = textOrUndefined(filter.view) + if (view) query.view = view + const asin = textOrUndefined(filter.asin) + if (asin) query.asin = asin + const shopName = textOrUndefined(filter.shopName) + if (shopName) query.shop_name = shopName + const country = textOrUndefined(filter.country) + if (country) query.country = country + const site = textOrUndefined(filter.site) + if (site) query.site = site + const dateFrom = textOrUndefined(filter.dateFrom) + if (dateFrom) query.date_from = dateFrom + const dateTo = textOrUndefined(filter.dateTo) + if (dateTo) query.date_to = dateTo + return query +} diff --git a/admin-frontend-vue/src/pages/tasks/duplicate-model.ts b/admin-frontend-vue/src/pages/tasks/duplicate-model.ts index 3d24d139..d08b9f53 100644 --- a/admin-frontend-vue/src/pages/tasks/duplicate-model.ts +++ b/admin-frontend-vue/src/pages/tasks/duplicate-model.ts @@ -138,3 +138,31 @@ function parseDuplicateMetrics(raw: unknown): DuplicateMetrics | null { source: text(r.source), } } + +/** 明细列表分页负载(data: {pending,items,total,page,page_size}) → 前端结果。 */ +export function parseDuplicateItemsPage(payload: unknown): { + pending: boolean + scannedAt: string + items: DuplicateItem[] + total: number + page: number + pageSize: number +} { + const core = unwrap(payload) + const record = core && typeof core === 'object' ? (core as Record) : {} + const items = Array.isArray(record.items) + ? record.items.map((raw) => parseDuplicateItem(raw)).filter((item): item is DuplicateItem => item !== null) + : [] + const total = count(record.total) + const pageNumber = count(record.page) >= 1 ? count(record.page) : 1 + const rawSize = record.page_size ?? record.pageSize + const pageSize = count(rawSize) >= 1 ? count(rawSize) : 20 + return { + pending: record.pending === true, + scannedAt: text(record.scanned_at ?? record.scannedAt), + items, + total, + page: pageNumber, + pageSize, + } +} diff --git a/admin-frontend-vue/tests/task-117.test.ts b/admin-frontend-vue/tests/task-117.test.ts new file mode 100644 index 00000000..dae0bd0b --- /dev/null +++ b/admin-frontend-vue/tests/task-117.test.ts @@ -0,0 +1,84 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { parseDuplicateItemsPage } from '../src/pages/tasks/duplicate-model.ts' +import { toDuplicateItemsQuery } from '../src/pages/tasks/duplicate-filter.ts' + +test('test_task_117_duplicate_items_normal_primary_path', () => { + // 正常主路径:明细筛选序列化与分页负载解析。 + const query = toDuplicateItemsQuery( + { view: 'monitor', asin: 'B0X', shopName: '店', country: 'DE', site: '', dateFrom: '2026-01-01', dateTo: '2026-01-31' }, + 2, + 20, + ) + assert.equal(query.view, 'monitor') + assert.equal(query.asin, 'B0X') + assert.equal(query.shop_name, '店') + assert.equal(query.country, 'DE') + assert.equal(query.date_from, '2026-01-01') + assert.equal(query.page, 2) + assert.equal(query.page_size, 20) +}) + +test('test_task_117_duplicate_items_normal_variant_input', () => { + // 正常变体:明细项含跨店出现记录解析。 + const page = parseDuplicateItemsPage({ + data: { + pending: false, + items: [ + { asin: 'B0ABC', shop_count: 2, record_count: 3, occurrences: [ + { asin: 'B0ABC', shop_name: 'A店', group_name: '华东', country_codes: ['DE'], country: 'DE', date: '2026-01-01', price: '9.9', brand: 'X' }, + ] }, + ], + total: 1, + page: 1, + page_size: 20, + }, + }) + assert.equal(page.items.length, 1) + const item = page.items[0] + assert.equal(item.asin, 'B0ABC') + assert.equal(item.shopCount, 2) + assert.equal(item.occurrences[0].shopName, 'A店') + assert.equal(item.occurrences[0].price, '9.9') +}) + +test('test_task_117_duplicate_items_repeated_is_idempotent', () => { + // 正常重复:查询序列化稳定。 + const filter = { view: '', asin: 'A', shopName: '', country: '', site: '', dateFrom: '', dateTo: '' } + assert.deepEqual(toDuplicateItemsQuery(filter, 1, 20), toDuplicateItemsQuery(filter, 1, 20)) +}) + +test('test_task_117_duplicate_items_boundary_empty_input', () => { + // 边界空值:空筛选回默认分页;pending 空态。 + const page = parseDuplicateItemsPage({ data: { pending: true, items: [], total: 0, page: 1, page_size: 20 } }) + assert.equal(page.pending, true) + assert.deepEqual(page.items, []) +}) + +test('test_task_117_duplicate_items_boundary_single_item', () => { + // 边界单元素:单明细项。 + const page = parseDuplicateItemsPage({ data: { pending: false, items: [{ asin: 'S', shop_count: 1, record_count: 1, occurrences: [] }], total: 1, page: 1, page_size: 20 } }) + assert.equal(page.items[0].asin, 'S') + assert.deepEqual(page.items[0].occurrences, []) +}) + +test('test_task_117_duplicate_items_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:页大小钳制到上限。 + const query = toDuplicateItemsQuery({ view: '', asin: '', shopName: '', country: '', site: '', dateFrom: '', dateTo: '' }, 1, 999) + assert.equal(query.page_size, 100) +}) + +test('test_task_117_duplicate_items_invalid_input_rejected', () => { + // 异常输入:success=false 抛后端 message。 + assert.throws(() => parseDuplicateItemsPage({ success: false, message: '暂无权限查看' }), /暂无权限/) +}) + +test('test_task_117_duplicate_items_dependency_failure_returns_actionable_message', () => { + // 依赖失败/加载走 adapter:GET /duplicate-check-items + 解析。 + const api = readSource('src/pages/tasks/duplicate-api.ts') + assert.match(api, /\/duplicate-check-items/) + assert.match(api, /fetchDuplicateItems/) + const model = readSource('src/pages/tasks/duplicate-model.ts') + assert.equal(/axios|http\./.test(model), false, '明细解析保持纯逻辑') +})