Files
crawler-plugin/admin-frontend-vue/src/pages/tasks/ImageVideoTasksPage.vue
T

306 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { 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'
const loading = ref(false)
const rows = ref<ImageVideoRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const filter = reactive<ImageVideoFilter & { dateRange: string[] }>({ ...createImageVideoFilter(), dateRange: [] })
const detailVisible = ref(false)
const detailLoading = ref(false)
const detail = ref<ImageVideoDetail | null>(null)
const permissionVisible = ref(false)
const permissionItems = ref<TaskPermissionItem[]>([])
const permissionInitial = ref<number[]>([])
const permissionSaving = ref(false)
const downloading = ref(false)
function statusLabel(status: TaskStatus): 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' {
if (status === 'SUCCESS') return 'success'
if (status === 'RUNNING') return 'warning'
if (status === 'FAILED') return 'danger'
if (status === 'CANCELLED') return 'info'
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
}
}
function toFilter(): ImageVideoFilter {
return {
username: filter.username.trim(),
userId: filter.userId,
status: filter.status,
executeId: filter.executeId.trim(),
submittedFrom: filter.dateRange?.[0] || '',
submittedTo: filter.dateRange?.[1] || '',
}
}
function apply() {
page.value = 1
load()
}
function reset() {
Object.assign(filter, createImageVideoFilter())
filter.dateRange = []
page.value = 1
load()
}
async function openDetail(row: ImageVideoRow) {
detailLoading.value = true
detailVisible.value = true
try {
detail.value = await fetchImageVideoTaskDetail(row.taskId)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '详情加载失败')
detailVisible.value = false
} finally {
detailLoading.value = false
}
}
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 downloadRow(row: ImageVideoRow) {
const keys = videoKeysOf(row)
if (!keys.length) {
ElMessage.warning('该任务没有可下载的视频')
return
}
downloading.value = true
try {
const { blob, fileCount, errorCount } = await requestVideoZipDownload(keys)
saveBlob(blob, `视频下载_任务${row.taskId}.zip`)
const note = errorCount ? `${errorCount} 个失败` : ''
ElMessage.success(`已打包 ${fileCount ?? keys.length} 个文件${note}`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '下载失败')
} finally {
downloading.value = false
}
}
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
async function openPermission() {
try {
permissionItems.value = await fetchImageVideoPermissionUsers()
permissionInitial.value = grantedUserIds(permissionItems.value)
permissionVisible.value = true
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
}
}
function toggleGrant(item: TaskPermissionItem) {
item.granted = !item.granted
}
async function savePermission() {
permissionSaving.value = true
try {
const next = grantedUserIds(permissionItems.value)
await saveImageVideoPermissions(next)
permissionInitial.value = next
ElMessage.success('权限已保存')
permissionVisible.value = false
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
permissionSaving.value = false
}
}
const grantedCount = computed(() => grantedUserIds(permissionItems.value).length)
onMounted(load)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>视频任务记录</h2>
<p>查看图生视频任务下载结果配置数据范围授权</p>
</div>
<div class="actions">
<el-button :loading="downloading" @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" />
</div>
<div class="f-item wide">
<label>提交时间</label>
<el-date-picker v-model="filter.dateRange" type="datetimerange" range-separator="至" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DDTHH:mm" unlink-panels />
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</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>
</el-card>
<el-drawer v-model="detailVisible" title="任务明细" size="720px">
<div v-loading="detailLoading">
<template v-if="detail">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="任务ID">{{ detail.taskId }}</el-descriptions-item>
<el-descriptions-item label="用户">{{ detail.username }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(detail.status) }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatDateTime(detail.submittedAt) }}</el-descriptions-item>
<el-descriptions-item label="执行ID">{{ detail.cozeExecuteId || '—' }}</el-descriptions-item>
<el-descriptions-item label="错误">{{ detail.errorMessage || '—' }}</el-descriptions-item>
</el-descriptions>
<h4>请求</h4>
<pre class="json-block">{{ JSON.stringify(detail.request, null, 2) }}</pre>
<h4>提交响应</h4>
<pre class="json-block">{{ JSON.stringify(detail.submitResponse, null, 2) }}</pre>
<h4>结果</h4>
<pre class="json-block">{{ JSON.stringify(detail.result, null, 2) }}</pre>
</template>
</div>
</el-drawer>
<el-dialog v-model="permissionVisible" title="视频任务数据范围授权" width="620px">
<p class="dim">勾选允许查看/操作视频任务的用户(仅数据范围,非按钮权限)。当前已授权 {{ grantedCount }} 人。</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 label="授权" width="90" align="center">
<template #default="{ row }">
<el-checkbox :model-value="(row as TaskPermissionItem).granted" @change="toggleGrant(row as TaskPermissionItem)" />
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="permissionVisible = false">取消</el-button>
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.actions { display: flex; align-items: center; gap: 10px; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 170px; }
.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); }
.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>