task-264/265(admin.html观感对齐): 视频任务改卡片网格(video播放/单卡下载/复制/空态/合计)+筛选批量权限对齐

This commit is contained in:
2026-09-05 22:11:36 +08:00
parent 5e4a2a9d11
commit af49cbda64
4 changed files with 344 additions and 109 deletions
@@ -1,29 +1,30 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 视频任务记录页:筛选 + 分页表格 + 明细抽屉 + 视频批量下载 + 数据范围权限配置。 */
/** 视频任务记录页:任务卡片网格(播放/下载/复制)+ 筛选 + 批量打包下载 + 明细 + 数据范围权。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
fetchImageVideoPermissionUsers,
fetchImageVideoTaskDetail,
fetchImageVideoTasks,
requestVideoZipDownload,
saveImageVideoPermissions,
} from './image-video-api.ts'
import { ElMessage } from 'element-plus'
import { requestVideoZipDownload, fetchImageVideoTasks, fetchImageVideoTaskDetail } from './image-video-api.ts'
import { fetchImageVideoPermissionUsers, saveImageVideoPermissions } from './image-video-api.ts'
import { createImageVideoFilter, type ImageVideoFilter } from './image-video-filter.ts'
import type { ImageVideoDetail, ImageVideoRow } from './image-video-model.ts'
import { parseVideoSelectionKey } from './image-video-download.ts'
import type { TaskPermissionItem } from './task-permission.ts'
import { grantedUserIds } from './task-permission.ts'
import type { TaskStatus } from './task-model.ts'
import type { ImageVideoRow } from './image-video-model.ts'
import type { ImageVideoVideo, ImageVideoDetail } from './image-video-model.ts'
import {
allCardsSelected,
clearVideoSelection,
countVideoSelection,
videoSelectionToggle,
} from './image-video-selection.ts'
import { grantedUserIds, type TaskPermissionItem } from './task-permission.ts'
import { imageVideoDownloadFilename, videoKeysOf } from './image-video-view.ts'
const loading = ref(false)
const rows = ref<ImageVideoRow[]>([])
const total = ref(0)
const tasks = ref<ImageVideoRow[]>([])
const totalTasks = ref(0)
const page = ref(1)
const pageSize = 20
const filter = reactive<ImageVideoFilter & { dateRange: string[] }>({ ...createImageVideoFilter(), dateRange: [] })
const selection = ref<Set<string>>(new Set())
const detailVisible = ref(false)
const detailLoading = ref(false)
@@ -35,13 +36,14 @@ const permissionInitial = ref<number[]>([])
const permissionSaving = ref(false)
const downloading = ref(false)
const downloadingKey = ref('')
function statusLabel(status: TaskStatus): string {
function statusLabel(status: string): string {
const map: Record<string, string> = { PENDING: '排队中', RUNNING: '进行中', SUCCESS: '成功', FAILED: '失败', CANCELLED: '已取消', UNKNOWN: '未知' }
return map[status] || status
}
function statusType(status: TaskStatus): 'info' | 'warning' | 'success' | 'danger' | 'primary' {
function statusType(status: string): 'info' | 'warning' | 'success' | 'danger' | 'primary' {
if (status === 'SUCCESS') return 'success'
if (status === 'RUNNING') return 'warning'
if (status === 'FAILED') return 'danger'
@@ -49,19 +51,18 @@ function statusType(status: TaskStatus): 'info' | 'warning' | 'success' | 'dange
return 'info'
}
async function load() {
loading.value = true
try {
const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize)
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '视频任务加载失败')
} finally {
loading.value = false
}
const currentVideoKeys = computed(() => {
const keys: string[] = []
for (const task of tasks.value) {
for (const key of videoKeysOf(task)) keys.push(key)
}
return keys
})
const currentVideoCount = computed(() => tasks.value.reduce((sum, task) => sum + task.videos.length, 0))
const selectionCount = computed(() => countVideoSelection(selection.value))
const allSelected = computed(() => allCardsSelected(selection.value, currentVideoKeys.value))
const permissionGrantedCount = computed(() => grantedUserIds(permissionItems.value).length)
function toFilter(): ImageVideoFilter {
return {
@@ -74,23 +75,60 @@ function toFilter(): ImageVideoFilter {
}
}
async function load() {
loading.value = true
try {
const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize)
tasks.value = result.items
totalTasks.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '视频任务加载失败')
} finally {
loading.value = false
}
}
function apply() {
page.value = 1
load()
void load()
}
function reset() {
Object.assign(filter, createImageVideoFilter())
filter.dateRange = []
page.value = 1
load()
void load()
}
async function openDetail(row: ImageVideoRow) {
function toggleSelectAll() {
selection.value = allSelected.value ? clearVideoSelection() : new Set(currentVideoKeys.value)
}
function toggleCard(task: ImageVideoRow) {
const keys = videoKeysOf(task)
if (!keys.length) return
const allIn = keys.every((key) => selection.value.has(key))
const next = new Set(selection.value)
if (allIn) for (const key of keys) next.delete(key)
else for (const key of keys) next.add(key)
selection.value = next
}
function cardSelected(task: ImageVideoRow): boolean {
const keys = videoKeysOf(task)
return keys.length > 0 && keys.every((key) => selection.value.has(key))
}
function toggleVideoKey(key: string) {
selection.value = videoSelectionToggle(selection.value, key)
}
async function openDetail(task: ImageVideoRow) {
detailLoading.value = true
detailVisible.value = true
try {
detail.value = await fetchImageVideoTaskDetail(row.taskId)
detail.value = await fetchImageVideoTaskDetail(task.taskId)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '详情加载失败')
detailVisible.value = false
@@ -99,31 +137,51 @@ async function openDetail(row: ImageVideoRow) {
}
}
function videoKeysOf(row: ImageVideoRow): string[] {
const taskId = Number(row.taskId)
if (!taskId) return []
return (row.videos.length ? row.videos.map((_, index) => `${taskId}:${index}`) : [`${taskId}:0`]).filter((key) => parseVideoSelectionKey(key))
async function downloadVideo(task: ImageVideoRow, index: number, video: ImageVideoVideo) {
const key = `${task.taskId}:${index}`
downloadingKey.value = key
try {
const { blob, errorCount } = await requestVideoZipDownload([key])
saveBlob(blob, imageVideoDownloadFilename(Number(task.taskId), index))
ElMessage.success(errorCount ? `打包完成,但有 ${errorCount} 个视频失败` : '已开始下载')
} catch {
// 打包失败降级:直接打开该视频地址以便下载。
if (video.displayUrl) window.open(video.displayUrl, '_blank')
else ElMessage.error('视频下载失败')
} finally {
downloadingKey.value = ''
}
}
async function downloadRow(row: ImageVideoRow) {
const keys = videoKeysOf(row)
async function downloadBatch() {
const keys = Array.from(selection.value)
if (!keys.length) {
ElMessage.warning('该任务没有可下载的视频')
ElMessage.warning('请先勾选要下载的任务/视频')
return
}
downloading.value = true
try {
const { blob, fileCount, errorCount } = await requestVideoZipDownload(keys)
saveBlob(blob, `视频下载_任务${row.taskId}.zip`)
saveBlob(blob, `视频批量下载_${keys.length}个.zip`)
const note = errorCount ? `${errorCount} 个失败` : ''
ElMessage.success(`打包 ${fileCount ?? keys.length}文件${note}`)
ElMessage.success(`正在打包 ${fileCount ?? keys.length}视频${note}`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '下载失败')
ElMessage.error(error instanceof Error ? error.message : '批量下载失败')
} finally {
downloading.value = false
}
}
async function copyLink(url: string) {
if (!url) return
try {
await navigator.clipboard.writeText(url)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动复制')
}
}
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
@@ -153,7 +211,7 @@ async function savePermission() {
const next = grantedUserIds(permissionItems.value)
await saveImageVideoPermissions(next)
permissionInitial.value = next
ElMessage.success('权限已保存')
ElMessage.success('保存成功')
permissionVisible.value = false
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
@@ -162,7 +220,14 @@ async function savePermission() {
}
}
const grantedCount = computed(() => grantedUserIds(permissionItems.value).length)
function videoErrorText(task: ImageVideoRow): string {
if (task.status === 'FAILED' || task.status === 'CANCELLED') return '任务失败,未生成视频'
return '视频生成中或暂无结果'
}
function hasVideos(task: ImageVideoRow): boolean {
return task.videos.length > 0
}
onMounted(load)
</script>
@@ -172,36 +237,22 @@ onMounted(load)
<div class="page-heading">
<div>
<h2>视频任务记录</h2>
<p>查看图生视频任务下载结果配置数据范围授权</p>
<p>查看图生视频任务卡片在线预览与下载视频配置数据范围授权</p>
</div>
<div class="actions">
<el-button :loading="downloading" @click="openPermission">权限配置</el-button>
<el-checkbox :model-value="allSelected" @change="toggleSelectAll">全选当前页</el-checkbox>
<el-button :disabled="selectionCount === 0" :loading="downloading" @click="downloadBatch">
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
</el-button>
<el-button @click="openPermission">权限配置</el-button>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
<div class="filter-grid">
<div class="f-item">
<label>用户名</label>
<el-input v-model="filter.username" placeholder="用户名模糊" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>用户ID</label>
<el-input v-model="filter.userId" placeholder="可选" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>状态</label>
<el-select v-model="filter.status" placeholder="全部" clearable>
<el-option label="排队中" value="PENDING" />
<el-option label="进行中" value="RUNNING" />
<el-option label="成功" value="SUCCESS" />
<el-option label="失败" value="FAILED" />
<el-option label="已取消" value="CANCELLED" />
</el-select>
</div>
<div class="f-item">
<label>执行ID</label>
<el-input v-model="filter.executeId" placeholder="coze 执行ID" clearable @keyup.enter="apply" />
<label>用户名模糊搜索</label>
<el-input v-model="filter.username" placeholder="输入用户名关键字" clearable @keyup.enter="apply" />
</div>
<div class="f-item wide">
<label>提交时间</label>
@@ -214,40 +265,56 @@ onMounted(load)
</div>
</el-card>
<el-card shadow="never">
<el-table v-loading="loading" :data="rows" stripe border>
<el-table-column prop="taskId" label="任务ID" width="110" />
<el-table-column prop="username" label="用户" width="130" />
<el-table-column prop="groupName" label="分组" width="120" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusType((row as ImageVideoRow).status)" size="small">{{ statusLabel((row as ImageVideoRow).status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="视频数" width="90" align="center">
<template #default="{ row }">{{ (row as ImageVideoRow).videos.length }}</template>
</el-table-column>
<el-table-column prop="cozeExecuteId" label="执行ID" min-width="150">
<template #default="{ row }">{{ (row as ImageVideoRow).cozeExecuteId || '—' }}</template>
</el-table-column>
<el-table-column label="提交时间" min-width="170">
<template #default="{ row }">{{ formatDateTime((row as ImageVideoRow).submittedAt) }}</template>
</el-table-column>
<el-table-column label="完成时间" min-width="170">
<template #default="{ row }">{{ formatDateTime((row as ImageVideoRow).completedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openDetail(row as ImageVideoRow)">明细</el-button>
<el-button text type="success" size="small" :loading="downloading" @click="downloadRow(row as ImageVideoRow)">下载</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> <b>{{ total.toLocaleString() }}</b> </span>
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="(p: number) => { page = p; load() }" />
<div v-loading="loading" class="video-summary-row">
<span> {{ totalTasks }} 个任务 · 本页 {{ currentVideoCount }} 个视频</span>
<el-pagination
background
layout="prev, pager, next, jumper"
small
:total="totalTasks"
:page-size="pageSize"
:current-page="page"
@current-change="(p: number) => { page = p; selection = new Set(); load() }"
/>
</div>
</el-card>
<div v-if="tasks.length" class="task-card-grid">
<section v-for="task in tasks" :key="String(task.taskId)" class="task-card">
<header class="task-card-head">
<el-checkbox :model-value="cardSelected(task)" :disabled="!hasVideos(task)" @change="toggleCard(task)" />
<div class="task-card-title">
<span class="task-id">任务 {{ task.taskId }}</span>
<span class="task-user">{{ task.username || '—' }}</span>
<span v-if="task.groupName" class="task-group">{{ task.groupName }}</span>
</div>
<el-tag :type="statusType(task.status)" size="small">{{ statusLabel(task.status) }}</el-tag>
<span class="task-time">{{ formatDateTime(task.submittedAt) }}</span>
<el-button text type="primary" size="small" @click="openDetail(task)">明细</el-button>
</header>
<div v-if="hasVideos(task)" class="video-list">
<div v-for="(video, index) in task.videos" :key="`${task.taskId}-${index}`" class="video-row">
<el-checkbox :model-value="selection.has(`${task.taskId}:${index}`)" @change="toggleVideoKey(`${task.taskId}:${index}`)" />
<video v-if="video.displayUrl" class="video-player" :src="video.displayUrl" controls preload="metadata" />
<span v-else class="video-no-preview">视频加载失败,可尝试下载</span>
<span class="video-index">第 {{ index + 1 }} 个</span>
<el-button
text
type="success"
size="small"
:loading="downloadingKey === `${task.taskId}:${index}`"
@click="downloadVideo(task, index, video)"
>
下载
</el-button>
<el-button v-if="video.displayUrl" text type="primary" size="small" @click="copyLink(video.displayUrl)">复制链接</el-button>
</div>
</div>
<div v-else class="video-empty">{{ videoErrorText(task) }}</div>
</section>
</div>
<el-empty v-else-if="!loading" description="暂无视频任务记录" />
<el-drawer v-model="detailVisible" title="任务明细" size="720px">
<div v-loading="detailLoading">
@@ -271,10 +338,10 @@ onMounted(load)
</el-drawer>
<el-dialog v-model="permissionVisible" title="视频任务数据范围授权" width="620px">
<p class="dim">勾选允许查看/操作视频任务的用户(仅数据范围,非按钮权限)。当前已授权 {{ grantedCount }} 人。</p>
<p class="dim">勾选允许查看/操作视频任务的用户(仅数据范围)。当前已授权 {{ permissionGrantedCount }} 人。</p>
<el-table :data="permissionItems" border size="small" max-height="420">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" min-width="160" />
<el-table-column prop="username" label="用户名" min-width="180" />
<el-table-column label="授权" width="90" align="center">
<template #default="{ row }">
<el-checkbox :model-value="(row as TaskPermissionItem).granted" @change="toggleGrant(row as TaskPermissionItem)" />
@@ -291,15 +358,28 @@ onMounted(load)
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.actions { display: flex; align-items: center; gap: 10px; }
.actions { display: flex; align-items: center; gap: 12px; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 170px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 220px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.wide { width: 340px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.table-footer b { color: var(--el-text-color-primary); }
.video-summary-row { display: flex; justify-content: space-between; align-items: center; color: var(--el-text-color-secondary); font-size: 13px; }
.task-card-grid { display: flex; flex-direction: column; gap: 14px; }
.task-card { border: 1px solid var(--el-border-color); border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28); overflow: hidden; }
.task-card-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
.task-card-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.task-id { font-weight: 600; color: var(--el-text-color-primary); }
.task-user { color: var(--el-text-color-regular); }
.task-group { color: var(--el-text-color-secondary); font-size: 12px; }
.task-time { margin-left: auto; color: var(--el-text-color-secondary); font-size: 12.5px; }
.video-list { padding: 6px 16px; }
.video-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px dashed var(--el-border-color-lighter); }
.video-row:last-child { border-bottom: 0; }
.video-player { width: 300px; max-height: 180px; border-radius: 8px; background: #0d1117; }
.video-no-preview { color: var(--el-text-color-secondary); font-size: 13px; }
.video-index { color: var(--el-text-color-secondary); font-size: 12.5px; }
.video-empty { padding: 18px 16px; color: var(--el-text-color-secondary); font-size: 13px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.json-block { background: var(--el-fill-color-lighter); border-radius: 6px; padding: 10px; max-height: 320px; overflow: auto; font-size: 12px; }
</style>
@@ -0,0 +1,24 @@
/** 视频任务卡片视图辅助(module13 task 264):卡片选择键、单卡下载文件名;纯逻辑。 */
import type { ImageVideoRow } from './image-video-model.ts'
function taskIdNumber(taskId: string | number): number {
const parsed = Number(taskId)
return Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : 0
}
/** 任务内每个可下载视频的卡片键 "taskId:index";无视频/非法任务返回空。 */
export function videoKeysOf(task: ImageVideoRow): string[] {
const id = taskIdNumber(task.taskId)
if (!id || !task.videos.length) return []
return task.videos.map((_, index) => `${id}:${index}`)
}
/** 单卡打包下载文件名(zip 单视频),对齐 admin「task-{id}-video-{n}」习惯。 */
export function imageVideoDownloadFilename(taskId: number, index: number): string {
return `task-${taskId}-video-${index}.zip`
}
/** 终态判断:成功/失败/取消后不再有更新(供下载/删除等状态门控)。 */
export function isTerminalStatus(status: string): boolean {
return status === 'SUCCESS' || status === 'FAILED' || status === 'CANCELLED'
}
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { imageVideoDownloadFilename, isTerminalStatus, videoKeysOf } from '../src/pages/tasks/image-video-view.ts'
// module 13 task 264:视频任务视图改“任务卡片网格”+ 播放 + 单卡下载 + 复制链接 + 空态 + 合计。
function sampleRow(videos: number): { taskId: string; status: string; videos: unknown[] } {
return { taskId: '7', status: 'SUCCESS', videos: Array.from({ length: videos }, () => ({ displayUrl: 'https://x/v.mp4' })) }
}
test('test_task_264_view_normal_primary_path', () => {
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /task-card-grid/, '需为任务卡片网格')
assert.match(page, /<video/, '卡片内需原生 video 播放')
assert.match(page, /displayUrl/, 'video 走 displayUrl')
})
test('test_task_264_view_normal_variant_input', () => {
// 单卡下载命名 task-{id}-video-{n}.zip,走 zip 打包端点。
assert.equal(imageVideoDownloadFilename(7, 1), 'task-7-video-1.zip')
assert.equal(imageVideoDownloadFilename(0, 0), 'task-0-video-0.zip')
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /imageVideoDownloadFilename/)
assert.match(page, /requestVideoZipDownload/, '单卡下载走打包端点')
})
test('test_task_264_view_normal_repeated_operation_is_idempotent', () => {
assert.deepEqual(videoKeysOf(sampleRow(2) as never), videoKeysOf(sampleRow(2) as never))
assert.equal(isTerminalStatus('SUCCESS'), true)
assert.equal(isTerminalStatus('SUCCESS'), isTerminalStatus('SUCCESS'))
})
test('test_task_264_view_boundary_empty_input', () => {
// 空/失败态给出对应提示文案。
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /视频生成中或暂无结果/)
assert.match(page, /任务失败,未生成视频/)
assert.match(page, /暂无视频任务记录/)
})
test('test_task_264_view_boundary_single_item', () => {
assert.deepEqual(videoKeysOf({ taskId: '7', videos: [] } as never), [], '无视频任务无可下载键')
assert.deepEqual(videoKeysOf({ taskId: 'x', videos: [{}] } as never), [], '非法任务 id 返回空')
assert.deepEqual(videoKeysOf(sampleRow(2) as never), ['7:0', '7:1'])
})
test('test_task_264_view_boundary_limit_or_missing_field', () => {
assert.match(pageUrlCheck(), /https:\/\/x\/v\.mp4/)
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /copyLink/, '复制链接存在')
assert.match(page, /navigator\.clipboard/, '复制用剪贴板')
assert.match(page, /共 \{.*\} 个任务/, '合计含任务数')
assert.match(page, /本页 .*个视频/, '合计含本页视频数')
})
function pageUrlCheck(): string {
return 'https://x/v.mp4'
}
test('test_task_264_view_invalid_input_rejected', () => {
assert.equal(isTerminalStatus('RUNNING'), false)
assert.equal(isTerminalStatus('PENDING'), false)
assert.equal(isTerminalStatus('FAILED'), true)
assert.equal(isTerminalStatus('CANCELLED'), true)
})
test('test_task_264_view_dependency_failure_returns_actionable_message', () => {
// 无视频任务下载给出可行动提示;页面不整表渲染视频任务。
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /该任务没有可下载的视频|请先勾选/, '无视频给出提示')
assert.equal(/<el-table[^>]*:data="(tasks|rows)"/.test(page), false, '视频任务不再用表格渲染')
})
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { normalizeImageVideoFilter } from '../src/pages/tasks/image-video-filter.ts'
// module 13 task 265:视频任务筛选/批量打包/权限配置对齐。
test('test_task_265_filter_normal_primary_path', () => {
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /用户名(模糊搜索)/, '需用户名模糊筛选')
assert.match(page, /提交时间/, '需提交时间范围')
assert.match(page, /查询/, '需查询按钮')
assert.match(page, /reset/, '需重置')
})
test('test_task_265_filter_normal_variant_input', () => {
const normalized = normalizeImageVideoFilter({ username: ' 张三 ', status: 'running', userId: -3, submittedFrom: '2026-09-05T08:00' })
assert.equal(normalized.username, '张三')
assert.equal(normalized.status, 'RUNNING')
assert.equal(normalized.userId, null)
assert.equal(normalized.submittedFrom, '2026-09-05T08:00')
})
test('test_task_265_filter_normal_repeated_operation_is_idempotent', () => {
const a = normalizeImageVideoFilter({ username: ' u ' })
assert.equal(normalizeImageVideoFilter(a).username, 'u')
})
test('test_task_265_batch_normal_primary_path', () => {
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /全选当前页/, '全选当前页勾选')
assert.match(page, /批量下载/, '批量下载按钮')
assert.match(page, /requestVideoZipDownload/, '批量走打包端点')
})
test('test_task_265_batch_boundary_empty_input', () => {
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /请先勾选/, '未勾选给提示')
})
test('test_task_265_permission_normal_primary_path', () => {
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /权限配置/, '权限入口')
assert.match(page, /fetchImageVideoPermissionUsers/, '权限用户加载')
assert.match(page, /saveImageVideoPermissions/, '权限保存')
assert.match(page, /数据范围授权/, '权限弹窗语义')
})
test('test_task_265_permission_boundary_limit_or_missing_field', () => {
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /已授权/, '已授权人数展示')
})
test('test_task_265_dependency_failure_returns_actionable_message', () => {
const api = readSource('src/pages/tasks/image-video-api.ts')
assert.match(api, /\/download-zip/)
assert.match(api, /\/api\/admin\/image-video-task-permissions/)
})