@@ -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=" 720 px ">
<div v-loading=" detailLoading ">
@@ -271,10 +338,10 @@ onMounted(load)
</el-drawer>
<el-dialog v-model=" permissionVisible " title=" 视频任务数据范围授权 " width=" 620 px ">
<p class=" dim ">勾选允许查看/操作视频任务的用户(仅数据范围,非按钮权限 )。当前已授权 {{ g rantedCount }} 人。</p>
<p class=" dim ">勾选允许查看/操作视频任务的用户(仅数据范围)。当前已授权 {{ permissionG rantedCount }} 人。</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 : 10 px ; }
. actions { display : flex ; align - items : center ; gap : 12 px ; }
. filter - grid { display : flex ; flex - wrap : wrap ; gap : 12 px 18 px ; }
. f - item { display : flex ; flex - direction : column ; gap : 6 px ; width : 170 px ; }
. f - item { display : flex ; flex - direction : column ; gap : 6 px ; width : 220 px ; }
. f - item label { color : var ( -- el - text - color - secondary ) ; font - size : 12 px ; }
. f - item . wide { width : 340 px ; }
. f - item . btn - row { flex - direction : row ; align - items : flex - end ; gap : 8 px ; width : auto ; }
. table - footer { display : flex ; justify - content : space - between ; align - items : center ; padding - top : 14 px ; }
. table - footer span { color : var ( -- el - text - color - secondary ) ; font - size : 12.5 px ; }
. 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 : 13 px ; }
. task - card - grid { display : flex ; flex - direction : column ; gap : 14 px ; }
. task - card { border : 1 px solid var ( -- el - border - color ) ; border - radius : 12 px ; background : # fff ; box - shadow : 0 1 px 2 px rgba ( 39 , 67 , 94 , 0.04 ) , 0 14 px 30 px - 24 px rgba ( 39 , 67 , 94 , 0.28 ) ; overflow : hidden ; }
. task - card - head { display : flex ; align - items : center ; gap : 12 px ; padding : 12 px 16 px ; border - bottom : 1 px solid var ( -- el - border - color - lighter ) ; }
. task - card - title { display : flex ; align - items : center ; gap : 10 px ; 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 : 12 px ; }
. task - time { margin - left : auto ; color : var ( -- el - text - color - secondary ) ; font - size : 12.5 px ; }
. video - list { padding : 6 px 16 px ; }
. video - row { display : flex ; align - items : center ; gap : 12 px ; padding : 10 px 0 ; border - bottom : 1 px dashed var ( -- el - border - color - lighter ) ; }
. video - row : last - child { border - bottom : 0 ; }
. video - player { width : 300 px ; max - height : 180 px ; border - radius : 8 px ; background : # 0 d1117 ; }
. video - no - preview { color : var ( -- el - text - color - secondary ) ; font - size : 13 px ; }
. video - index { color : var ( -- el - text - color - secondary ) ; font - size : 12.5 px ; }
. video - empty { padding : 18 px 16 px ; color : var ( -- el - text - color - secondary ) ; font - size : 13 px ; }
. dim { color : var ( -- el - text - color - secondary ) ; font - size : 12 px ; }
. json - block { background : var ( -- el - fill - color - lighter ) ; border - radius : 6 px ; padding : 10 px ; max - height : 320 px ; overflow : auto ; font - size : 12 px ; }
< / style >