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

718 lines
22 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'
/** 视频任务记录页 · 像素复刻旧版 admin.html panel-image-video-tasks(自绘:form-box 筛选[用户名/提交起止+查询/重置] + panel-box toolbar[全选/批量下载/权限配置/汇总] + 任务卡片网格 + 旧式分页)。
* script 逻辑沿用现有 Vue 实现(批量打包/明细抽屉/权限授权)。 */
import { computed, onMounted, reactive, ref } from 'vue'
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 { imageVideoGeneratedAt, 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'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
const loading = ref(false)
const tasks = ref<ImageVideoRow[]>([])
const totalTasks = ref(0)
const page = ref(1)
const pageSize = ref(20)
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize.value)))
const filter = reactive<ImageVideoFilter & { dateRange: string[] }>({ ...createImageVideoFilter(), dateRange: [] })
const selection = ref<Set<string>>(new Set())
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)
const downloadingKey = ref('')
/** 旧版状态 label 映射(admin.js 前缀兜底:RUN/WAIT/PROCESS/POLL → 处理中)。 */
function statusLabel(status: string): string {
const map: Record<string, string> = {
PENDING: '待执行',
WAITING: '排队中',
RUNNING: '执行中',
POLLING: '处理中',
SUCCESS: '已完成',
COMPLETED: '已完成',
DONE: '已完成',
FAILED: '失败',
ERROR: '失败',
CANCELLED: '已取消',
CANCELED: '已取消',
DELETED: '已删除',
STOPPED: '已停止',
UNKNOWN: '未知',
}
const key = (status || '').toUpperCase()
if (map[key]) return map[key]
if (/^(RUN|WAIT|PROCESS|POLL)/.test(key)) return '处理中'
return key || '未知'
}
function statusType(status: string): 'pending' | 'running' | 'success' | 'failed' | 'cancelled' {
const key = (status || '').toUpperCase()
if (key === 'SUCCESS' || key === 'COMPLETED' || key === 'DONE') return 'success'
if (key === 'FAILED' || key === 'ERROR') return 'failed'
if (key === 'CANCELLED' || key === 'CANCELED') return 'cancelled'
if (key === 'PENDING' || key === 'WAITING') return 'pending'
return 'running'
}
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 {
username: filter.username.trim(),
userId: filter.userId,
status: filter.status,
executeId: filter.executeId.trim(),
submittedFrom: filter.dateRange?.[0] || '',
submittedTo: filter.dateRange?.[1] || '',
}
}
async function load() {
loading.value = true
try {
const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize.value)
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
void load()
}
function reset() {
Object.assign(filter, createImageVideoFilter())
filter.dateRange = []
page.value = 1
void load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
selection.value = new Set()
void load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
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(task.taskId)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '详情加载失败')
detailVisible.value = false
} finally {
detailLoading.value = false
}
}
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 downloadBatch() {
const keys = Array.from(selection.value)
if (!keys.length) {
ElMessage.warning('请先勾选要下载的任务/视频')
return
}
downloading.value = true
try {
const { blob, fileCount, errorCount } = await requestVideoZipDownload(keys)
saveBlob(blob, `视频批量下载_${keys.length}个.zip`)
const note = errorCount ? `${errorCount} 个失败` : ''
ElMessage.success(`正在打包 ${fileCount ?? keys.length} 个视频${note}`)
} catch (error) {
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')
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
}
}
function videoErrorText(task: ImageVideoRow): string {
const key = (task.status || '').toUpperCase()
if (key === 'FAILED' || key === 'ERROR' || key === 'CANCELLED') return '任务失败,未生成视频'
return '视频生成中或暂无结果'
}
function hasVideos(task: ImageVideoRow): boolean {
return task.videos.length > 0
}
onMounted(load)
</script>
<template>
<div class="iv-view">
<section class="form-box">
<h3>视频任务记录筛选</h3>
<div class="form-row">
<div class="form-group" style="min-width: 160px">
<label>用户名</label>
<input v-model="filter.username" type="text" placeholder="模糊搜索" @keyup.enter="apply" />
</div>
<div class="form-group" style="min-width: 176px">
<label>提交开始</label>
<input v-model="filter.dateRange[0]" type="datetime-local" />
</div>
<div class="form-group" style="min-width: 176px">
<label>提交结束</label>
<input v-model="filter.dateRange[1]" type="datetime-local" />
</div>
<button class="btn" type="button" @click="apply">查询</button>
<button class="btn btn-secondary" type="button" @click="reset">重置</button>
</div>
</section>
<section class="panel-box iv-panel">
<div class="iv-toolbar">
<div class="iv-toolbar-main">
<button v-if="session.isSuperAdmin" class="btn btn-secondary" type="button" @click="openPermission">权限配置</button>
<button class="btn iv-batch-btn" type="button" :disabled="selectionCount === 0 || downloading" @click="downloadBatch">
批量下载{{ selectionCount ? ` (${selectionCount})` : '' }}
</button>
<label class="iv-select-all">
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll" />
全选当前页
</label>
</div>
<span class="iv-summary"> {{ totalTasks }} 个任务 · 本页 {{ currentVideoCount }} 个视频</span>
</div>
<div v-loading="loading" class="task-card-grid">
<template v-if="tasks.length">
<section v-for="task in tasks" :key="String(task.taskId)" class="iv-card">
<header class="iv-card-head">
<input type="checkbox" :checked="cardSelected(task)" :disabled="!hasVideos(task)" @change="toggleCard(task)" />
<span class="iv-card-title">任务 {{ task.taskId }} · 视频 {{ task.videos.length }}</span>
<span class="st-pill" :class="`is-${statusType(task.status)}`">{{ statusLabel(task.status) }}</span>
</header>
<div class="iv-card-info">
<div class="iv-info-row"><label>用户名</label><span>{{ task.username || '-' }}</span></div>
<div class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
<div class="iv-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</span></div>
<div class="iv-info-row"><label>生成时间</label><span>{{ formatDateTime(imageVideoGeneratedAt(task)) }}</span></div>
<div class="iv-info-row">
<label>调试链接</label>
<button v-if="task.debugUrl" class="btn btn-sm" type="button" @click="copyLink(task.debugUrl)">复制链接</button>
<span v-else>-</span>
</div>
</div>
<div v-if="hasVideos(task)" class="iv-video-list">
<div v-for="(video, index) in task.videos" :key="`${task.taskId}-${index}`" class="iv-video-row">
<input type="checkbox" :checked="selection.has(`${task.taskId}:${index}`)" @change="toggleVideoKey(`${task.taskId}:${index}`)" />
<video v-if="video.displayUrl" class="iv-player" :src="video.displayUrl" controls preload="metadata"></video>
<span v-else class="iv-no-preview">视频加载失败可尝试下载</span>
<div class="iv-video-ops">
<button
class="btn btn-sm btn-secondary"
type="button"
:disabled="!video.displayUrl"
:aria-busy="downloadingKey === `${task.taskId}:${index}`"
@click="downloadVideo(task, index, video)"
>
下载
</button>
<button v-if="video.displayUrl" class="btn btn-sm" type="button" @click="copyLink(video.displayUrl)">复制链接</button>
</div>
</div>
</div>
<div v-else class="iv-empty-note">{{ videoErrorText(task) }}</div>
</section>
</template>
<div v-else-if="!loading" class="empty-tip">暂无符合条件的视频任务</div>
</div>
<OldPagination :total="totalTasks" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<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="任务模式">{{ detail.mode || '—' }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatDateTime(detail.submittedAt) }}</el-descriptions-item>
<el-descriptions-item label="生成时间">{{ formatDateTime(imageVideoGeneratedAt(detail)) }}</el-descriptions-item>
<el-descriptions-item label="执行ID">{{ detail.cozeExecuteId || '—' }}</el-descriptions-item>
<el-descriptions-item label="错误">{{ detail.errorMessage || '—' }}</el-descriptions-item>
</el-descriptions>
</template>
</div>
</el-drawer>
<el-dialog v-model="permissionVisible" title="视频任务记录权限配置" width="620px">
<p class="dim">勾选允许查看视频任务记录的用户仅数据范围)。当前已授权 {{ permissionGrantedCount }} </p>
<el-table :data="permissionItems" border size="small" max-height="420">
<el-table-column prop="id" label="ID" min-width="80" />
<el-table-column prop="username" label="用户名" min-width="180" />
<el-table-column label="授权" min-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>
/* 像素复刻旧版 admin.html panel-image-video-tasks(蓝白末层)。 */
.iv-view {
font-family: inherit;
color: #24384d;
display: flex;
flex-direction: column;
gap: 18px;
}
.form-box,
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0 0 16px;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.iv-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.iv-toolbar-main {
display: inline-flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.iv-select-all {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: #5b6f83;
cursor: pointer;
}
.iv-summary {
color: #5b6f83;
font-size: 13px;
white-space: nowrap;
}
.task-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.iv-card {
border: 1px solid #d8e3ee;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
overflow: hidden;
}
.iv-card-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #e6edf4;
}
.iv-card-title {
font-weight: 600;
color: #24384d;
font-size: 13px;
}
.iv-card-info {
padding: 8px 16px 0;
}
.iv-info-row {
display: flex;
gap: 10px;
padding: 4px 0;
font-size: 13px;
}
.iv-info-row label {
flex: none;
width: 64px;
color: #8293a5;
}
.iv-info-row span {
color: #40586e;
min-width: 0;
}
.iv-video-list {
padding: 8px 16px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.iv-video-row {
display: flex;
align-items: center;
gap: 10px;
}
.iv-player {
flex: 1;
min-width: 0;
max-height: 240px;
border-radius: 8px;
background: #0f1722;
}
.iv-no-preview {
flex: 1;
padding: 18px 12px;
text-align: center;
color: #8293a5;
font-size: 12.5px;
border: 1px dashed #d8e3ee;
border-radius: 8px;
}
.iv-video-ops {
display: flex;
flex-direction: column;
gap: 6px;
}
.iv-empty-note {
padding: 16px;
color: #8293a5;
font-size: 12.5px;
}
.st-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
line-height: 1.7;
border: 1px solid transparent;
white-space: nowrap;
}
.st-pill.is-success {
color: #3d7158;
background: #e8f2eb;
border-color: #bdd7c5;
}
.st-pill.is-failed {
color: #91474f;
background: #f8ebeb;
border-color: #e4c2c5;
}
.st-pill.is-cancelled {
color: #5b6f83;
background: #f1f6fa;
border-color: #d6e2ec;
}
.st-pill.is-pending {
color: #8a6d3b;
background: #fbf4ea;
border-color: #e7d3ae;
}
.st-pill.is-running {
color: #8a6d3b;
background: #fbf4ea;
border-color: #e7d3ae;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.dim {
color: #8293a5;
font-size: 12px;
}
@media (max-width: 1100px) {
.task-card-grid {
grid-template-columns: 1fr;
}
}
</style>