diff --git a/admin-frontend-vue/src/pages/tasks/duplicate-api.ts b/admin-frontend-vue/src/pages/tasks/duplicate-api.ts index 472bae4b..bb77b637 100644 --- a/admin-frontend-vue/src/pages/tasks/duplicate-api.ts +++ b/admin-frontend-vue/src/pages/tasks/duplicate-api.ts @@ -1,6 +1,11 @@ /** 店铺数据重复检查适配(任务 115-119):/api/admin/shop-data-crawl/duplicate-check-* 系列端点。 */ import { http } from '@/api/http' -import { parseDuplicateItemsPage, parseDuplicateOverview, type DuplicateOverview } from './duplicate-model.ts' +import { + parseDuplicateDetailPage, + 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' @@ -24,3 +29,15 @@ export async function fetchDuplicateItems( }) return parseDuplicateItemsPage(data) } + +/** 加载撞款详情卡片分页:GET /duplicate-check-detail。 */ +export async function fetchDuplicateDetail( + filter: DuplicateFilter, + page: number, + pageSize: number, +): Promise> { + const { data } = await http.get(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-detail`, { + params: toDuplicateItemsQuery(filter, page, pageSize), + }) + return parseDuplicateDetailPage(data) +} diff --git a/admin-frontend-vue/src/pages/tasks/duplicate-model.ts b/admin-frontend-vue/src/pages/tasks/duplicate-model.ts index d08b9f53..2a1cd2e8 100644 --- a/admin-frontend-vue/src/pages/tasks/duplicate-model.ts +++ b/admin-frontend-vue/src/pages/tasks/duplicate-model.ts @@ -139,6 +139,60 @@ function parseDuplicateMetrics(raw: unknown): DuplicateMetrics | null { } } +/** 撞款详情卡(抽屉):跨店聚合一张卡片。 */ +export interface DuplicateDetail { + asin: string + shopCount: number + recordCount: number + shopNames: string[] + brand: string + firstDate: string + occurrences: DuplicateOccurrence[] +} + +/** 解析单张撞款详情卡;缺 asin 视为无效。 */ +export function parseDuplicateDetail(raw: unknown): DuplicateDetail | null { + if (!raw || typeof raw !== 'object') return null + const r = raw as Record + const asin = text(r.asin) + if (!asin) return null + const shopNamesRaw = r.shop_names ?? r.shopNames + const shopNames = Array.isArray(shopNamesRaw) + ? (shopNamesRaw as unknown[]).map((name) => text(name)).filter(Boolean) + : [] + return { + asin, + shopCount: count(r.shop_count ?? r.shopCount), + recordCount: count(r.record_count ?? r.recordCount), + shopNames, + brand: text(r.brand), + firstDate: text(r.first_date ?? r.firstDate), + occurrences: Array.isArray(r.occurrences) + ? r.occurrences.map((occ) => parseDuplicateOccurrence(occ)).filter((occ): occ is DuplicateOccurrence => occ !== null) + : [], + } +} + +/** 详情分页负载(data: {pending,items,total,page,page_size}) → 前端结果。 */ +export function parseDuplicateDetailPage(payload: unknown): { + pending: boolean + items: DuplicateDetail[] + 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) => parseDuplicateDetail(raw)).filter((detail): detail is DuplicateDetail => detail !== 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, items, total, page: pageNumber, pageSize } +} + /** 明细列表分页负载(data: {pending,items,total,page,page_size}) → 前端结果。 */ export function parseDuplicateItemsPage(payload: unknown): { pending: boolean diff --git a/admin-frontend-vue/tests/task-118.test.ts b/admin-frontend-vue/tests/task-118.test.ts new file mode 100644 index 00000000..0e883216 --- /dev/null +++ b/admin-frontend-vue/tests/task-118.test.ts @@ -0,0 +1,81 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { parseDuplicateDetail, parseDuplicateDetailPage } from '../src/pages/tasks/duplicate-model.ts' + +test('test_task_118_duplicate_detail_normal_primary_path', () => { + // 正常主路径:详情卡(snake)解析为类型化详情。 + const detail = parseDuplicateDetail({ + asin: 'B0ABC', + shop_count: 3, + record_count: 4, + shop_names: ['A店', 'B店', 'C店'], + brand: 'X', + first_date: '2026-01-01', + occurrences: [ + { asin: 'B0ABC', shop_name: 'A店', country: 'DE', date: '2026-01-01', price: '9.9', brand: 'X' }, + { asin: 'B0ABC', shop_name: 'B店', country: 'DE', date: '2026-01-02', price: '10.1', brand: 'X' }, + ], + }) + assert.equal(detail.asin, 'B0ABC') + assert.equal(detail.shopCount, 3) + assert.deepEqual(detail.shopNames, ['A店', 'B店', 'C店']) + assert.equal(detail.brand, 'X') + assert.equal(detail.firstDate, '2026-01-01') + assert.equal(detail.occurrences.length, 2) +}) + +test('test_task_118_duplicate_detail_normal_variant_input', () => { + // 正常变体:详情分页负载解析。 + const page = parseDuplicateDetailPage({ + data: { pending: false, items: [{ asin: 'B0X', shop_count: 2, record_count: 2, shop_names: ['A'], occurrences: [] }], total: 1, page: 1, page_size: 6 }, + }) + assert.equal(page.items.length, 1) + assert.equal(page.items[0].asin, 'B0X') + assert.equal(page.pageSize, 6) +}) + +test('test_task_118_duplicate_detail_repeated_is_idempotent', () => { + // 正常重复:解析稳定、不改输入。 + const payload = { data: { items: [{ asin: 'A', shop_count: 1, record_count: 1, shop_names: [], occurrences: [] }], total: 1, page: 1, page_size: 6 } } + assert.deepEqual(parseDuplicateDetailPage(payload), parseDuplicateDetailPage(payload)) +}) + +test('test_task_118_duplicate_detail_boundary_empty_input', () => { + // 边界空值:pending 空详情列表。 + const page = parseDuplicateDetailPage({ data: { pending: true, items: [], total: 0, page: 1, page_size: 6 } }) + assert.equal(page.pending, true) + assert.deepEqual(page.items, []) +}) + +test('test_task_118_duplicate_detail_boundary_single_item', () => { + // 边界单元素:单详情卡无店名列表。 + const detail = parseDuplicateDetail({ asin: 'S', shop_count: 1, record_count: 1, occurrences: [] }) + assert.ok(detail) + assert.deepEqual(detail.shopNames, []) + assert.equal(detail.brand, '') +}) + +test('test_task_118_duplicate_detail_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:缺字段回默认。 + const detail = parseDuplicateDetail({ asin: 'Z' }) + assert.ok(detail) + assert.equal(detail.shopCount, 0) + assert.equal(detail.firstDate, '') +}) + +test('test_task_118_duplicate_detail_invalid_input_rejected', () => { + // 异常输入:缺 asin 详情视为无效;success=false 抛后端 message。 + assert.equal(parseDuplicateDetail('garbage'), null) + assert.equal(parseDuplicateDetail({ shop_count: 2 }), null) + assert.throws(() => parseDuplicateDetailPage({ success: false, message: '无权查看详情' }), /无权查看/) +}) + +test('test_task_118_duplicate_detail_dependency_failure_returns_actionable_message', () => { + // 依赖失败/加载走 adapter:GET /duplicate-check-detail。 + const api = readSource('src/pages/tasks/duplicate-api.ts') + assert.match(api, /\/duplicate-check-detail/) + assert.match(api, /fetchDuplicateDetail/) + const model = readSource('src/pages/tasks/duplicate-model.ts') + assert.equal(/axios|http\./.test(model), false, '详情解析保持纯逻辑') +})