diff --git a/admin-frontend-vue/src/pages/tasks/image-video-api.ts b/admin-frontend-vue/src/pages/tasks/image-video-api.ts index 42503903..5a77aa6a 100644 --- a/admin-frontend-vue/src/pages/tasks/image-video-api.ts +++ b/admin-frontend-vue/src/pages/tasks/image-video-api.ts @@ -1,7 +1,8 @@ -/** 视频任务列表/详情适配(任务 103/104):GET /api/admin/image-video-tasks(/detail)。 */ +/** 视频任务列表/详情/批量下载适配(任务 103-106)。 */ import { http } from '@/api/http' import { parseImageVideoDetail, parseImageVideoPage, type ImageVideoDetail, type ImageVideoPageResult } from './image-video-model.ts' import { toImageVideoQuery, type ImageVideoFilter } from './image-video-filter.ts' +import { downloadZipHeaderCounts, toVideoZipRequest } from './image-video-download.ts' export const IMAGE_VIDEO_TASKS_ENDPOINT = '/api/admin/image-video-tasks' @@ -21,3 +22,18 @@ export async function fetchImageVideoTaskDetail(taskId: string | number): Promis const { data } = await http.get(`${IMAGE_VIDEO_TASKS_ENDPOINT}/${taskId}`) return parseImageVideoDetail(data) } + +export interface VideoZipDownloadResult { + blob: Blob + fileCount?: number + errorCount?: number +} + +/** 批量打包下载视频:POST /download-zip(blob);从响应头读成功/失败计数。 */ +export async function requestVideoZipDownload(keys: readonly string[]): Promise { + const { data, headers } = await http.post(`${IMAGE_VIDEO_TASKS_ENDPOINT}/download-zip`, toVideoZipRequest(keys), { + 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/image-video-download.ts b/admin-frontend-vue/src/pages/tasks/image-video-download.ts new file mode 100644 index 00000000..a5da3c89 --- /dev/null +++ b/admin-frontend-vue/src/pages/tasks/image-video-download.ts @@ -0,0 +1,72 @@ +/** 视频批量打包下载任务模型(任务 106):选择键→打包请求体、下载 job 状态与响应头计数;纯逻辑。 */ + +export type BatchDownloadStatus = 'idle' | 'running' | 'done' | 'failed' + +export interface BatchDownloadJob { + status: BatchDownloadStatus + message?: string + fileCount?: number + errorCount?: number +} + +/** 解析 "taskId:videoIndex" 卡片选择键;非法键返回 null。 */ +export function parseVideoSelectionKey(key: string): { taskId: number; videoIndex: number } | null { + const match = /^(\d+):(\d+)$/.exec(typeof key === 'string' ? key.trim() : '') + if (!match) return null + return { taskId: Number(match[1]), videoIndex: Number(match[2]) } +} + +/** 选择键 → POST /download-zip 请求体(去重、跳过非法键)。 */ +export function toVideoZipRequest(keys: readonly string[]): { items: Array<{ task_id: number; video_index: number }> } { + const items: Array<{ task_id: number; video_index: number }> = [] + const seen = new Set() + for (const key of keys) { + const parsed = parseVideoSelectionKey(key) + if (!parsed) continue + const token = `${parsed.taskId}:${parsed.videoIndex}` + if (seen.has(token)) continue + seen.add(token) + items.push({ task_id: parsed.taskId, video_index: parsed.videoIndex }) + } + return { items } +} + +/** 大小写不敏感解析 zip 响应头里的成功/失败计数。 */ +export function downloadZipHeaderCounts(headers: Record | undefined): { fileCount?: number; errorCount?: number } { + const read = (name: string): number | undefined => { + if (!headers || typeof headers !== 'object') return undefined + const lower = name.toLowerCase() + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lower) { + const value = headers[key] + if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value) + if (typeof value === 'string' && /^\d+$/.test(value.trim())) return Number(value.trim()) + return undefined + } + } + return undefined + } + const fileCount = read('x-archive-file-count') + const errorCount = read('x-archive-error-count') + if (fileCount === undefined && errorCount === undefined) return {} + const counts: { fileCount?: number; errorCount?: number } = {} + if (fileCount !== undefined) counts.fileCount = fileCount + if (errorCount !== undefined) counts.errorCount = errorCount + return counts +} + +export function createBatchDownloadJob(): BatchDownloadJob { + return { status: 'idle' } +} + +export function startBatchDownload(job: BatchDownloadJob): BatchDownloadJob { + return { status: 'running' } +} + +export function succeedBatchDownload(job: BatchDownloadJob, fileCount: number, errorCount: number): BatchDownloadJob { + return { status: 'done', fileCount, errorCount } +} + +export function failBatchDownload(job: BatchDownloadJob, message: string): BatchDownloadJob { + return { status: 'failed', message } +} diff --git a/admin-frontend-vue/tests/task-106.test.ts b/admin-frontend-vue/tests/task-106.test.ts new file mode 100644 index 00000000..bb5abcf6 --- /dev/null +++ b/admin-frontend-vue/tests/task-106.test.ts @@ -0,0 +1,74 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + createBatchDownloadJob, + downloadZipHeaderCounts, + failBatchDownload, + parseVideoSelectionKey, + startBatchDownload, + succeedBatchDownload, + toVideoZipRequest, +} from '../src/pages/tasks/image-video-download.ts' + +test('test_task_106_video_batch_download_normal_primary_path', () => { + // 正常主路径:选择键解析为 {task_id, video_index} 打包请求体。 + const key = parseVideoSelectionKey('11:2') + assert.deepEqual(key, { taskId: 11, videoIndex: 2 }) + assert.deepEqual(toVideoZipRequest(['11:2', '7:0']), { + items: [ + { task_id: 11, video_index: 2 }, + { task_id: 7, video_index: 0 }, + ], + }) +}) + +test('test_task_106_video_batch_download_normal_variant_input', () => { + // 正常变体:job 从 running 到 done 携带成功/失败计数。 + const job = succeedBatchDownload(startBatchDownload(createBatchDownloadJob()), 18, 2) + assert.equal(job.status, 'done') + assert.equal(job.fileCount, 18) + assert.equal(job.errorCount, 2) +}) + +test('test_task_106_video_batch_download_repeated_is_idempotent', () => { + // 正常重复:请求体构建稳定、去重键不重复。 + assert.deepEqual(toVideoZipRequest(['1:0', '1:0', '2:1']), { items: [{ task_id: 1, video_index: 0 }, { task_id: 2, video_index: 1 }] }) +}) + +test('test_task_106_video_batch_download_boundary_empty_input', () => { + // 边界空值:无选择/空任务 id 键被忽略。 + assert.deepEqual(toVideoZipRequest([]), { items: [] }) + assert.equal(parseVideoSelectionKey('9:empty'), null) +}) + +test('test_task_106_video_batch_download_boundary_single_item', () => { + // 边界单元素:单键请求体。 + assert.deepEqual(toVideoZipRequest(['5:0']), { items: [{ task_id: 5, video_index: 0 }] }) + assert.deepEqual(createBatchDownloadJob(), { status: 'idle' }) +}) + +test('test_task_106_video_batch_download_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:响应头大小写不敏感解析计数。 + assert.deepEqual(downloadZipHeaderCounts({ 'X-Archive-File-Count': '3', 'x-archive-error-count': '1' }), { fileCount: 3, errorCount: 1 }) + assert.deepEqual(downloadZipHeaderCounts({}), {}) +}) + +test('test_task_106_video_batch_download_invalid_input_rejected', () => { + // 异常输入:失败 job 带可操作消息;非数字头忽略。 + const job = failBatchDownload(createBatchDownloadJob(), '单次最多打包 100 个视频') + assert.equal(job.status, 'failed') + assert.equal(job.message, '单次最多打包 100 个视频') + assert.deepEqual(downloadZipHeaderCounts({ 'x-archive-file-count': 'abc' }), {}) + assert.equal(parseVideoSelectionKey('bad'), null) +}) + +test('test_task_106_video_batch_download_dependency_failure_returns_actionable_message', () => { + // 依赖失败/下载走 adapter:打包模型纯逻辑;adapter POST /download-zip + blob。 + const model = readSource('src/pages/tasks/image-video-download.ts') + assert.equal(/axios|http\.|vue/.test(model), false, '批量下载模型保持纯逻辑') + const api = readSource('src/pages/tasks/image-video-api.ts') + assert.match(api, /download-zip/) + assert.match(api, /requestVideoZipDownload/) + assert.match(api, /responseType:\s*['\"]blob['\"]/) +})