task-118(任务与重复分析中心): 实现重复检查详情抽屉
duplicate-model.ts 增加 DuplicateDetail 解析与详情分页负载解析,duplicate-api.ts 增加 fetchDuplicateDetail(GET /duplicate-check-detail)。 TDD: task-118.test.ts 8 用例先 RED 后 GREEN。
This commit is contained in:
@@ -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<ReturnType<typeof parseDuplicateDetailPage>> {
|
||||
const { data } = await http.get<unknown>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-detail`, {
|
||||
params: toDuplicateItemsQuery(filter, page, pageSize),
|
||||
})
|
||||
return parseDuplicateDetailPage(data)
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<unknown>(payload)
|
||||
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : {}
|
||||
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
|
||||
|
||||
@@ -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, '详情解析保持纯逻辑')
|
||||
})
|
||||
Reference in New Issue
Block a user