diff --git a/admin-frontend-vue/src/pages/tasks/shop-data-api.ts b/admin-frontend-vue/src/pages/tasks/shop-data-api.ts index 566f5368..78bacd5f 100644 --- a/admin-frontend-vue/src/pages/tasks/shop-data-api.ts +++ b/admin-frontend-vue/src/pages/tasks/shop-data-api.ts @@ -1,7 +1,9 @@ -/** 店铺数据任务列表加载适配(任务 110):GET /api/admin/shop-data-crawl-tasks + 筛选归一与解析。 */ +/** 店铺数据任务列表/批量下载适配(任务 110/113)。 */ import { http } from '@/api/http' import { parseShopDataTaskPage, type ShopDataTaskPageResult } from './shop-data-model.ts' import { toShopDataQuery, type ShopDataFilter } from './shop-data-filter.ts' +import { toShopDataZipRequest } from './shop-data-download.ts' +import { downloadZipHeaderCounts } from './image-video-download.ts' export const SHOP_DATA_CRAWL_TASKS_ENDPOINT = '/api/admin/shop-data-crawl-tasks' @@ -15,3 +17,18 @@ export async function fetchShopDataTaskList( }) return parseShopDataTaskPage(data) } + +export interface ShopDataZipDownloadResult { + blob: Blob + fileCount?: number + errorCount?: number +} + +/** 批量下载选中结果文件为 zip:POST /download-zip(blob);从响应头读成功/失败计数。 */ +export async function requestShopDataZipDownload(resultIds: readonly (number | string)[]): Promise { + const { data, headers } = await http.post(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/download-zip`, toShopDataZipRequest(resultIds), { + responseType: 'blob', + }) + const counts = downloadZipHeaderCounts(headers as Record) + return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount } +} diff --git a/admin-frontend-vue/src/pages/tasks/shop-data-download.ts b/admin-frontend-vue/src/pages/tasks/shop-data-download.ts new file mode 100644 index 00000000..8469b538 --- /dev/null +++ b/admin-frontend-vue/src/pages/tasks/shop-data-download.ts @@ -0,0 +1,48 @@ +/** 店铺数据结果批量下载任务(任务 113):打包请求体与下载 job 状态;纯逻辑。 */ + +export type ShopDataDownloadStatus = 'idle' | 'running' | 'done' | 'failed' + +export interface ShopDataDownloadJob { + status: ShopDataDownloadStatus + message?: string + fileCount?: number + errorCount?: number +} + +function finitePositive(value: unknown): number | null { + const number = typeof value === 'number' && Number.isFinite(value) + ? value + : typeof value === 'string' && /^\d+$/.test(value.trim()) + ? Number(value.trim()) + : null + return number === null || number < 1 ? null : Math.floor(number) +} + +/** result_id → POST /download-zip 请求体(过滤非法 id,按序去重)。 */ +export function toShopDataZipRequest(resultIds: readonly (number | string)[]): { result_ids: number[] } { + const ids: number[] = [] + const seen = new Set() + for (const id of resultIds) { + const parsed = finitePositive(id) + if (parsed === null || seen.has(parsed)) continue + seen.add(parsed) + ids.push(parsed) + } + return { result_ids: ids } +} + +export function createShopDataDownloadJob(): ShopDataDownloadJob { + return { status: 'idle' } +} + +export function startShopDataDownload(): ShopDataDownloadJob { + return { status: 'running' } +} + +export function succeedShopDataDownload(_job: ShopDataDownloadJob, fileCount: number, errorCount: number): ShopDataDownloadJob { + return { status: 'done', fileCount, errorCount } +} + +export function failShopDataDownload(_job: ShopDataDownloadJob, message: string): ShopDataDownloadJob { + return { status: 'failed', message } +} diff --git a/admin-frontend-vue/tests/task-113.test.ts b/admin-frontend-vue/tests/task-113.test.ts new file mode 100644 index 00000000..dc464727 --- /dev/null +++ b/admin-frontend-vue/tests/task-113.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + createShopDataDownloadJob, + failShopDataDownload, + succeedShopDataDownload, + toShopDataZipRequest, +} from '../src/pages/tasks/shop-data-download.ts' + +test('test_task_113_shop_data_download_normal_primary_path', () => { + // 正常主路径:选中 result_id 组装打包请求体。 + assert.deepEqual(toShopDataZipRequest([501, 502]), { result_ids: [501, 502] }) +}) + +test('test_task_113_shop_data_download_normal_variant_input', () => { + // 正常变体:字符串 id 解析与非法 id 过滤。 + assert.deepEqual(toShopDataZipRequest(['3', 'x', 0, '-1']), { result_ids: [3] }) +}) + +test('test_task_113_shop_data_download_repeated_is_idempotent', () => { + // 正常重复:请求体构建稳定。 + assert.deepEqual(toShopDataZipRequest(['1', '2', '1']), { result_ids: [1, 2] }) +}) + +test('test_task_113_shop_data_download_boundary_empty_input', () => { + // 边界空值:无选择回空请求体。 + assert.deepEqual(toShopDataZipRequest([]), { result_ids: [] }) +}) + +test('test_task_113_shop_data_download_boundary_single_item', () => { + // 边界单元素:单结果下载 job 成功状态。 + assert.deepEqual(toShopDataZipRequest([9]), { result_ids: [9] }) + const job = succeedShopDataDownload(createShopDataDownloadJob(), 1, 0) + assert.equal(job.status, 'done') + assert.equal(job.fileCount, 1) +}) + +test('test_task_113_shop_data_download_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:非数字输入被忽略。 + assert.deepEqual(toShopDataZipRequest([' ']), { result_ids: [] }) +}) + +test('test_task_113_shop_data_download_invalid_input_rejected', () => { + // 异常输入:失败 job 携带可操作消息。 + const job = failShopDataDownload(createShopDataDownloadJob(), '请至少选择一个结果文件') + assert.equal(job.status, 'failed') + assert.equal(job.message, '请至少选择一个结果文件') +}) + +test('test_task_113_shop_data_download_dependency_failure_returns_actionable_message', () => { + // 依赖失败/下载走 adapter:POST /api/admin/shop-data-crawl-tasks/download-zip + blob。 + const model = readSource('src/pages/tasks/shop-data-download.ts') + assert.equal(/axios|http\.|vue/.test(model), false, '下载模型保持纯逻辑') + const api = readSource('src/pages/tasks/shop-data-api.ts') + assert.match(api, /\/download-zip/) + assert.match(api, /requestShopDataZipDownload/) + assert.match(api, /responseType:\s*['\"]blob['\"]/) +})