mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 18:37:46 +08:00
1435 lines
48 KiB
Vue
1435 lines
48 KiB
Vue
<template>
|
||
<div class="space-y-6 pb-8">
|
||
<!-- 统计卡片 -->
|
||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<Card
|
||
variant="default"
|
||
class="p-4"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||
<Zap class="w-5 h-5 text-primary" />
|
||
</div>
|
||
<div>
|
||
<p class="text-2xl font-bold">
|
||
{{ stats?.total ?? '-' }}
|
||
</p>
|
||
<p class="text-xs text-muted-foreground">
|
||
总任务数
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card
|
||
variant="default"
|
||
class="p-4"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||
<Loader2
|
||
class="w-5 h-5 text-blue-500"
|
||
:class="{ 'animate-spin': runningCount > 0 }"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<p class="text-2xl font-bold">
|
||
{{ runningCount || '-' }}
|
||
</p>
|
||
<p class="text-xs text-muted-foreground">
|
||
处理中
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card
|
||
variant="default"
|
||
class="p-4"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||
<CheckCircle class="w-5 h-5 text-green-500" />
|
||
</div>
|
||
<div>
|
||
<p class="text-2xl font-bold">
|
||
{{ stats?.by_status?.succeeded ?? '-' }}
|
||
</p>
|
||
<p class="text-xs text-muted-foreground">
|
||
已完成
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card
|
||
variant="default"
|
||
class="p-4"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
|
||
<Calendar class="w-5 h-5 text-amber-500" />
|
||
</div>
|
||
<div>
|
||
<p class="text-2xl font-bold">
|
||
{{ stats?.registered_tasks ?? '-' }}
|
||
</p>
|
||
<p class="text-xs text-muted-foreground">
|
||
已注册
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<!-- 任务表格 -->
|
||
<Card
|
||
variant="default"
|
||
class="overflow-hidden"
|
||
>
|
||
<!-- 标题和筛选器 -->
|
||
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
|
||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<h3 class="text-base font-semibold">
|
||
异步任务
|
||
</h3>
|
||
<div class="flex items-center gap-2">
|
||
<!-- 状态筛选 -->
|
||
<Select
|
||
v-model="filterStatus"
|
||
>
|
||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||
<SelectValue placeholder="状态" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">
|
||
全部状态
|
||
</SelectItem>
|
||
<SelectItem value="queued">
|
||
排队中
|
||
</SelectItem>
|
||
<SelectItem value="running">
|
||
运行中
|
||
</SelectItem>
|
||
<SelectItem value="retrying">
|
||
重试中
|
||
</SelectItem>
|
||
<SelectItem value="succeeded">
|
||
成功
|
||
</SelectItem>
|
||
<SelectItem value="failed">
|
||
失败
|
||
</SelectItem>
|
||
<SelectItem value="cancelled">
|
||
已取消
|
||
</SelectItem>
|
||
<SelectItem value="skipped">
|
||
已跳过
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<!-- 模型筛选 -->
|
||
<Input
|
||
v-model="filterModel"
|
||
type="text"
|
||
placeholder="任务 Key..."
|
||
class="w-32 h-8 text-xs"
|
||
/>
|
||
<!-- 刷新按钮 -->
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-8 w-8"
|
||
:disabled="loading"
|
||
@click="fetchTasks"
|
||
>
|
||
<RefreshCw
|
||
class="w-3.5 h-3.5"
|
||
:class="{ 'animate-spin': loading }"
|
||
/>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 加载状态 -->
|
||
<div
|
||
v-if="loading && !tasks.length"
|
||
class="p-8 text-center"
|
||
>
|
||
<Loader2 class="w-8 h-8 animate-spin mx-auto text-muted-foreground" />
|
||
<p class="mt-2 text-sm text-muted-foreground">
|
||
加载中...
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div
|
||
v-else-if="!tasks.length"
|
||
class="p-8 text-center"
|
||
>
|
||
<Zap class="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||
<p class="mt-2 text-sm text-muted-foreground">
|
||
暂无异步任务
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 桌面端表格 -->
|
||
<Table
|
||
v-else
|
||
class="hidden md:table"
|
||
>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead class="w-[25%]">
|
||
任务
|
||
</TableHead>
|
||
<TableHead class="w-[15%]">
|
||
{{ isAdmin ? '用户/Provider' : 'Provider' }}
|
||
</TableHead>
|
||
<TableHead class="w-[12%]">
|
||
状态
|
||
</TableHead>
|
||
<TableHead class="w-[10%]">
|
||
参数
|
||
</TableHead>
|
||
<TableHead class="w-[15%]">
|
||
时间
|
||
</TableHead>
|
||
<TableHead class="w-[8%] text-center">
|
||
操作
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
<TableRow
|
||
v-for="task in tasks"
|
||
:key="task.id"
|
||
class="cursor-pointer hover:bg-muted/50"
|
||
@click="openTaskDetail(task)"
|
||
>
|
||
<!-- 任务信息 -->
|
||
<TableCell>
|
||
<div class="space-y-1">
|
||
<div class="flex items-center gap-2">
|
||
<Video
|
||
v-if="isVideoTask(task)"
|
||
class="w-4 h-4 text-muted-foreground shrink-0"
|
||
/>
|
||
<span class="font-medium text-sm truncate">{{ displayTaskName(task) }}</span>
|
||
</div>
|
||
<p
|
||
class="text-xs text-muted-foreground truncate max-w-[280px]"
|
||
:title="displayTaskDescription(task)"
|
||
>
|
||
{{ displayTaskDescription(task) }}
|
||
</p>
|
||
</div>
|
||
</TableCell>
|
||
<!-- 用户/Provider -->
|
||
<TableCell>
|
||
<div class="space-y-0.5 text-sm">
|
||
<div
|
||
v-if="isAdmin"
|
||
class="flex items-center gap-1.5"
|
||
>
|
||
<User class="w-3 h-3 text-muted-foreground" />
|
||
<span class="truncate max-w-[100px]">{{ task.username }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||
<Server class="w-3 h-3" />
|
||
<span class="truncate max-w-[100px]">{{ displayTaskSource(task) }}</span>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<!-- 状态 -->
|
||
<TableCell>
|
||
<div class="flex flex-col items-start gap-1">
|
||
<Badge
|
||
:variant="getStatusVariant(task.status)"
|
||
class="text-xs"
|
||
>
|
||
{{ getStatusLabel(task.status) }}
|
||
</Badge>
|
||
<div
|
||
v-if="task.progress_percent > 0 && isRunningStatus(task.status)"
|
||
class="w-full"
|
||
>
|
||
<div class="flex items-center gap-2">
|
||
<div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||
<div
|
||
class="h-full bg-primary transition-all"
|
||
:style="{ width: `${task.progress_percent}%` }"
|
||
/>
|
||
</div>
|
||
<span class="text-xs text-muted-foreground">{{ task.progress_percent }}%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<!-- 参数 -->
|
||
<TableCell>
|
||
<div class="text-xs space-y-0.5 text-muted-foreground">
|
||
<div
|
||
v-if="task.duration_seconds || task.attempt"
|
||
class="flex items-center gap-1"
|
||
>
|
||
<Timer class="w-3 h-3" />
|
||
<span>{{ task.duration_seconds ? `${task.duration_seconds}s` : `${task.attempt}/${task.max_attempts ?? 1}` }}</span>
|
||
</div>
|
||
<div v-if="task.resolution">
|
||
{{ task.resolution }}
|
||
</div>
|
||
<div v-if="task.aspect_ratio">
|
||
{{ task.aspect_ratio }}
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<!-- 时间 -->
|
||
<TableCell>
|
||
<div class="text-xs space-y-0.5">
|
||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||
<Clock class="w-3 h-3" />
|
||
<span>{{ formatDate(task.created_at) }}</span>
|
||
</div>
|
||
<div
|
||
v-if="finishTime(task)"
|
||
class="flex items-center gap-1.5 text-green-600 dark:text-green-400"
|
||
>
|
||
<CheckCircle class="w-3 h-3" />
|
||
<span>{{ formatDate(finishTime(task)) }}</span>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<!-- 操作 -->
|
||
<TableCell class="text-center">
|
||
<div class="flex items-center justify-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-7 w-7"
|
||
title="任务详情"
|
||
@click.stop="openTaskDetail(task)"
|
||
>
|
||
<Eye class="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
v-if="isVideoTask(task)"
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-7 w-7"
|
||
title="使用记录"
|
||
@click.stop="openUsageRecord(task)"
|
||
>
|
||
<ExternalLink class="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
</TableBody>
|
||
</Table>
|
||
|
||
<!-- 移动端卡片列表 -->
|
||
<div
|
||
v-if="tasks.length"
|
||
class="md:hidden divide-y divide-border/60"
|
||
>
|
||
<div
|
||
v-for="task in tasks"
|
||
:key="`m-${task.id}`"
|
||
class="p-4 space-y-3 hover:bg-muted/30 cursor-pointer active:bg-muted/50 transition-colors"
|
||
@click="openTaskDetail(task)"
|
||
>
|
||
<!-- 顶部:模型和状态 -->
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||
<Video
|
||
v-if="isVideoTask(task)"
|
||
class="w-4 h-4 text-muted-foreground shrink-0"
|
||
/>
|
||
<span class="font-medium text-sm truncate">{{ displayTaskName(task) }}</span>
|
||
</div>
|
||
<Badge
|
||
:variant="getStatusVariant(task.status)"
|
||
class="text-xs shrink-0"
|
||
>
|
||
{{ getStatusLabel(task.status) }}
|
||
</Badge>
|
||
</div>
|
||
|
||
<!-- 进度条(如果有) -->
|
||
<div
|
||
v-if="task.progress_percent > 0 && isRunningStatus(task.status)"
|
||
class="space-y-1"
|
||
>
|
||
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
|
||
<div
|
||
class="h-full bg-primary transition-all"
|
||
:style="{ width: `${task.progress_percent}%` }"
|
||
/>
|
||
</div>
|
||
<p class="text-xs text-muted-foreground text-right">
|
||
{{ task.progress_percent }}%
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Prompt -->
|
||
<p class="text-sm text-muted-foreground line-clamp-2">
|
||
{{ displayTaskDescription(task) }}
|
||
</p>
|
||
|
||
<!-- 信息网格 -->
|
||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||
<div
|
||
v-if="isAdmin"
|
||
class="flex items-center gap-1.5 text-muted-foreground"
|
||
>
|
||
<User class="w-3 h-3" />
|
||
<span class="truncate">{{ task.username }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||
<Server class="w-3 h-3" />
|
||
<span class="truncate">{{ displayTaskSource(task) }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||
<Clock class="w-3 h-3" />
|
||
<span>{{ formatDate(task.created_at) }}</span>
|
||
</div>
|
||
<div
|
||
v-if="task.duration_seconds || task.attempt"
|
||
class="flex items-center gap-1.5 text-muted-foreground"
|
||
>
|
||
<Timer class="w-3 h-3" />
|
||
<span>{{ task.duration_seconds ? `${task.duration_seconds}s` : `${task.attempt}/${task.max_attempts ?? 1}` }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="flex justify-end gap-2">
|
||
<Button
|
||
v-if="isVideoTask(task)"
|
||
variant="outline"
|
||
size="sm"
|
||
class="h-7 text-xs"
|
||
@click.stop="openUsageRecord(task)"
|
||
>
|
||
<ExternalLink class="w-3.5 h-3.5 mr-1" />
|
||
使用记录
|
||
</Button>
|
||
<Button
|
||
v-if="authStore.canOperateAdmin && canCancel(task.status)"
|
||
variant="outline"
|
||
size="sm"
|
||
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50"
|
||
@click.stop="cancelTask(task)"
|
||
>
|
||
<XCircle class="w-3.5 h-3.5 mr-1" />
|
||
取消
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<Pagination
|
||
v-if="total > 0"
|
||
:current="currentPage"
|
||
:total="total"
|
||
:page-size="pageSize"
|
||
cache-key="async-tasks-page-size"
|
||
@update:current="goToPage"
|
||
@update:page-size="handlePageSizeChange"
|
||
/>
|
||
</Card>
|
||
|
||
<!-- 任务详情抽屉 -->
|
||
<Teleport to="body">
|
||
<Transition name="drawer">
|
||
<div
|
||
v-if="showDetail && selectedTask"
|
||
class="fixed inset-0 z-50 flex justify-end"
|
||
@click.self="closeDetail"
|
||
>
|
||
<!-- 背景遮罩 -->
|
||
<div
|
||
class="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
||
@click="closeDetail"
|
||
/>
|
||
<!-- 抽屉内容 -->
|
||
<Card class="relative h-full w-full sm:w-[800px] sm:max-w-[90vw] rounded-none shadow-2xl flex flex-col">
|
||
<!-- 固定头部 -->
|
||
<div class="sticky top-0 z-10 bg-background border-b px-3 sm:px-6 py-3 sm:py-4 flex-shrink-0">
|
||
<!-- 第一行:标题、模型、状态、操作按钮 -->
|
||
<div class="flex items-center justify-between gap-4 mb-3">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<h3 class="text-lg font-semibold">
|
||
任务详情
|
||
</h3>
|
||
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||
<Video
|
||
v-if="isVideoTask(selectedTask)"
|
||
class="w-3.5 h-3.5 mr-1"
|
||
/>
|
||
<span>{{ displayTaskName(selectedTask) }}</span>
|
||
</div>
|
||
<Badge :variant="getStatusVariant(selectedTask.status)">
|
||
{{ getStatusLabel(selectedTask.status) }}
|
||
</Badge>
|
||
</div>
|
||
<div class="flex items-center gap-1 shrink-0">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-8 w-8"
|
||
:class="{ 'text-primary': detailAutoRefresh }"
|
||
:title="detailAutoRefresh ? '停止自动刷新' : '开启自动刷新(每5秒)'"
|
||
@click="toggleDetailAutoRefresh"
|
||
>
|
||
<RefreshCw
|
||
class="w-4 h-4"
|
||
:class="{ 'animate-spin': detailAutoRefresh }"
|
||
/>
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-8 w-8"
|
||
title="关闭"
|
||
@click="closeDetail"
|
||
>
|
||
<X class="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<!-- 第二行:关键元信息 -->
|
||
<div class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||
<span class="flex items-center gap-1">
|
||
<span class="font-medium text-foreground">ID:</span>
|
||
<span class="font-mono">{{ selectedTask.id.slice(0, 8) }}...</span>
|
||
</span>
|
||
<span class="opacity-40">|</span>
|
||
<span>{{ formatDateFull(selectedTask.created_at) }}</span>
|
||
<template v-if="isAdmin">
|
||
<span class="opacity-40">|</span>
|
||
<span>用户: {{ selectedTask.username }}</span>
|
||
</template>
|
||
<span class="opacity-40">|</span>
|
||
<span>{{ displayTaskSource(selectedTask) }}</span>
|
||
</div>
|
||
<!-- 进度条 -->
|
||
<div
|
||
v-if="selectedTask.progress_percent > 0 && isRunningStatus(selectedTask.status)"
|
||
class="mt-3"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<div class="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||
<div
|
||
class="h-full bg-primary transition-all"
|
||
:style="{ width: `${selectedTask.progress_percent}%` }"
|
||
/>
|
||
</div>
|
||
<span class="text-xs text-muted-foreground font-medium">{{ selectedTask.progress_percent }}%</span>
|
||
</div>
|
||
<p
|
||
v-if="selectedTask.progress_message"
|
||
class="text-xs text-muted-foreground mt-1"
|
||
>
|
||
{{ selectedTask.progress_message }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 可滚动内容区域 -->
|
||
<div class="flex-1 min-h-0 overflow-y-auto px-3 sm:px-6 py-3 sm:py-4 space-y-5">
|
||
<!-- 错误信息 -->
|
||
<div
|
||
v-if="selectedTask.error_message"
|
||
class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800"
|
||
>
|
||
<div class="flex items-start gap-2">
|
||
<AlertCircle class="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p
|
||
v-if="selectedTask.error_code"
|
||
class="text-xs font-medium text-red-600 dark:text-red-400 mb-1"
|
||
>
|
||
错误码: {{ selectedTask.error_code }}
|
||
</p>
|
||
<p class="text-sm text-red-600 dark:text-red-400">
|
||
{{ selectedTask.error_message }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 视频结果(放在最前面) -->
|
||
<div
|
||
v-if="selectedTask.video_url || selectedTask.video_urls?.length"
|
||
class="space-y-3"
|
||
>
|
||
<!-- 主视频 -->
|
||
<div v-if="selectedTask.video_url">
|
||
<div class="rounded-lg overflow-hidden border border-border/60 bg-black">
|
||
<video
|
||
:src="getVideoUrl(selectedTask.id, selectedTask.video_url)"
|
||
controls
|
||
preload="none"
|
||
class="w-full max-h-[300px] object-contain"
|
||
/>
|
||
</div>
|
||
<!-- 视频信息 -->
|
||
<div class="mt-2 space-y-2">
|
||
<!-- 链接 -->
|
||
<div class="flex items-center gap-1 p-1.5 bg-muted/50 rounded border border-border/40">
|
||
<code
|
||
class="flex-1 text-xs text-foreground truncate px-1"
|
||
:title="selectedTask.video_url"
|
||
>{{ selectedTask.video_url }}</code>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="h-6 px-2 text-xs shrink-0"
|
||
@click="copyToClipboard(selectedTask.video_url)"
|
||
>
|
||
<Copy class="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
<!-- 元信息 -->
|
||
<div
|
||
v-if="selectedTask.video_size_bytes || selectedTask.video_expires_at"
|
||
class="flex items-center gap-3 text-xs text-muted-foreground"
|
||
>
|
||
<span v-if="selectedTask.video_size_bytes">
|
||
大小: {{ formatFileSize(selectedTask.video_size_bytes) }}
|
||
</span>
|
||
<span
|
||
v-if="selectedTask.video_expires_at"
|
||
class="text-amber-600 dark:text-amber-400"
|
||
>
|
||
过期: {{ formatDate(selectedTask.video_expires_at) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 多个视频 -->
|
||
<div
|
||
v-else-if="selectedTask.video_urls?.length"
|
||
class="space-y-4"
|
||
>
|
||
<div
|
||
v-for="(url, index) in selectedTask.video_urls"
|
||
:key="index"
|
||
>
|
||
<p class="text-xs text-muted-foreground font-medium mb-1.5">
|
||
视频 {{ index + 1 }}
|
||
</p>
|
||
<div class="rounded-lg overflow-hidden border border-border/60 bg-black">
|
||
<video
|
||
:src="getVideoUrl(selectedTask.id, url)"
|
||
controls
|
||
preload="none"
|
||
class="w-full max-h-[250px] object-contain"
|
||
/>
|
||
</div>
|
||
<div class="mt-1.5 flex items-center gap-1 p-1.5 bg-muted/50 rounded border border-border/40">
|
||
<code
|
||
class="flex-1 text-xs text-foreground truncate px-1"
|
||
:title="url"
|
||
>{{ url }}</code>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="h-6 px-2 text-xs shrink-0"
|
||
@click="copyToClipboard(url)"
|
||
>
|
||
<Copy class="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 任务完成但无视频 -->
|
||
<div
|
||
v-else-if="isSucceededStatus(selectedTask.status) && isVideoTask(selectedTask)"
|
||
class="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800 text-center"
|
||
>
|
||
<Video class="w-8 h-8 mx-auto mb-2 text-amber-500" />
|
||
<p class="text-sm text-amber-600 dark:text-amber-400">
|
||
视频链接不可用或已过期
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Prompt -->
|
||
<div class="space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||
Prompt
|
||
</h4>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
class="h-6 px-2 text-xs"
|
||
@click="copyToClipboard(displayTaskDescription(selectedTask))"
|
||
>
|
||
<Copy class="w-3 h-3 mr-1" />
|
||
复制
|
||
</Button>
|
||
</div>
|
||
<div class="p-3 bg-muted/50 rounded-lg border border-border/60 text-sm whitespace-pre-wrap break-words max-h-32 overflow-y-auto leading-relaxed">
|
||
{{ displayTaskDescription(selectedTask) }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 视频信息(网格布局) -->
|
||
<div
|
||
v-if="selectedTask.video_duration_seconds || selectedTask.resolution || selectedTask.aspect_ratio || selectedTask.size"
|
||
class="space-y-2"
|
||
>
|
||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||
视频信息
|
||
</h4>
|
||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
<div
|
||
v-if="selectedTask.video_duration_seconds"
|
||
class="p-3 bg-muted/30 rounded-lg"
|
||
>
|
||
<p class="text-xs text-muted-foreground mb-0.5">
|
||
视频时长
|
||
</p>
|
||
<p class="text-sm font-medium">
|
||
{{ selectedTask.video_duration_seconds.toFixed(1) }}s
|
||
</p>
|
||
</div>
|
||
<div
|
||
v-if="selectedTask.resolution"
|
||
class="p-3 bg-muted/30 rounded-lg"
|
||
>
|
||
<p class="text-xs text-muted-foreground mb-0.5">
|
||
分辨率
|
||
</p>
|
||
<p class="text-sm font-medium">
|
||
{{ selectedTask.resolution }}
|
||
</p>
|
||
</div>
|
||
<div
|
||
v-if="selectedTask.aspect_ratio"
|
||
class="p-3 bg-muted/30 rounded-lg"
|
||
>
|
||
<p class="text-xs text-muted-foreground mb-0.5">
|
||
宽高比
|
||
</p>
|
||
<p class="text-sm font-medium">
|
||
{{ selectedTask.aspect_ratio }}
|
||
</p>
|
||
</div>
|
||
<div
|
||
v-if="selectedTask.size"
|
||
class="p-3 bg-muted/30 rounded-lg"
|
||
>
|
||
<p class="text-xs text-muted-foreground mb-0.5">
|
||
尺寸
|
||
</p>
|
||
<p class="text-sm font-medium">
|
||
{{ selectedTask.size }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 执行状态(网格布局) -->
|
||
<div class="space-y-2">
|
||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||
执行状态
|
||
</h4>
|
||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
<div class="p-3 bg-muted/30 rounded-lg">
|
||
<p class="text-xs text-muted-foreground mb-0.5">
|
||
轮询
|
||
</p>
|
||
<p class="text-sm font-medium">
|
||
{{ selectedTask.poll_count ?? selectedTask.attempt ?? 0 }} / {{ selectedTask.max_poll_count ?? selectedTask.max_attempts ?? 1 }}
|
||
</p>
|
||
</div>
|
||
<div class="p-3 bg-muted/30 rounded-lg">
|
||
<p class="text-xs text-muted-foreground mb-0.5">
|
||
重试
|
||
</p>
|
||
<p class="text-sm font-medium">
|
||
{{ selectedTask.retry_count ?? 0 }} / {{ selectedTask.max_retries ?? selectedTask.max_attempts ?? 1 }}
|
||
</p>
|
||
</div>
|
||
<div class="p-3 bg-muted/30 rounded-lg">
|
||
<p class="text-xs text-muted-foreground mb-0.5">
|
||
轮询间隔
|
||
</p>
|
||
<p class="text-sm font-medium">
|
||
{{ selectedTask.poll_interval_seconds ? `${selectedTask.poll_interval_seconds}s` : selectedTask.trigger ?? '-' }}
|
||
</p>
|
||
</div>
|
||
<div
|
||
v-if="selectedTask.next_poll_at"
|
||
class="p-3 bg-muted/30 rounded-lg"
|
||
>
|
||
<p class="text-xs text-muted-foreground mb-0.5">
|
||
下次轮询
|
||
</p>
|
||
<p class="text-sm font-medium">
|
||
{{ formatDate(selectedTask.next_poll_at) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 时间范围 -->
|
||
<div class="space-y-2">
|
||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||
时间范围
|
||
</h4>
|
||
<div class="flex items-center gap-1 text-sm font-medium">
|
||
<span>{{ formatTimeWithMs(selectedTask.created_at) }}</span>
|
||
<span class="time-arrow-container">
|
||
<span
|
||
v-if="finishTime(selectedTask)"
|
||
class="time-duration"
|
||
>+{{ calcDuration(selectedTask.created_at, finishTime(selectedTask) || selectedTask.created_at) }}</span>
|
||
<span class="time-arrow">→</span>
|
||
</span>
|
||
<template v-if="finishTime(selectedTask)">
|
||
<span>{{ formatTimeWithMs(finishTime(selectedTask)) }}</span>
|
||
</template>
|
||
<span
|
||
v-else
|
||
class="text-muted-foreground"
|
||
>处理中...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 响应数据 -->
|
||
<div
|
||
v-if="selectedTask.request_metadata?.poll_raw_response"
|
||
class="space-y-2"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||
<FileJson class="w-3.5 h-3.5" />
|
||
响应数据
|
||
</h4>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="h-5 px-1.5 text-xs"
|
||
@click="copyToClipboard(JSON.stringify(selectedTask.request_metadata.poll_raw_response, null, 2))"
|
||
>
|
||
<Copy class="w-3 h-3 mr-1" />
|
||
复制
|
||
</Button>
|
||
</div>
|
||
<div class="p-3 bg-muted/50 rounded-lg border border-border/40 overflow-x-auto max-h-48 overflow-y-auto">
|
||
<pre class="text-xs font-mono whitespace-pre-wrap break-all text-foreground/80">{{ formatJson(selectedTask.request_metadata.poll_raw_response) }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div
|
||
v-if="authStore.canOperateAdmin && canCancel(selectedTask.status)"
|
||
class="pt-4 border-t border-border/60"
|
||
>
|
||
<Button
|
||
variant="destructive"
|
||
class="w-full"
|
||
@click="cancelTask(selectedTask)"
|
||
>
|
||
<XCircle class="w-4 h-4 mr-2" />
|
||
取消任务
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</Transition>
|
||
</Teleport>
|
||
|
||
<!-- 使用记录详情抽屉 -->
|
||
<RequestDetailDrawer
|
||
:is-open="usageDetailOpen"
|
||
:request-id="usageRequestId"
|
||
@close="usageDetailOpen = false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||
import { asyncTasksApi, type AsyncTaskItem, type AsyncTaskDetail, type AsyncTaskStatsResponse, type AsyncTaskStatus } from '@/api/async-tasks'
|
||
import { useToast } from '@/composables/useToast'
|
||
import { useClipboard } from '@/composables/useClipboard'
|
||
import { getI18nLocale, useI18n } from '@/i18n'
|
||
import Card from '@/components/ui/card.vue'
|
||
import Button from '@/components/ui/button.vue'
|
||
import Input from '@/components/ui/input.vue'
|
||
import Badge from '@/components/ui/badge.vue'
|
||
import Select from '@/components/ui/select.vue'
|
||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||
import SelectValue from '@/components/ui/select-value.vue'
|
||
import SelectContent from '@/components/ui/select-content.vue'
|
||
import SelectItem from '@/components/ui/select-item.vue'
|
||
import Table from '@/components/ui/table.vue'
|
||
import TableHeader from '@/components/ui/table-header.vue'
|
||
import TableBody from '@/components/ui/table-body.vue'
|
||
import TableRow from '@/components/ui/table-row.vue'
|
||
import TableHead from '@/components/ui/table-head.vue'
|
||
import TableCell from '@/components/ui/table-cell.vue'
|
||
import Pagination from '@/components/ui/pagination.vue'
|
||
import { RequestDetailDrawer } from '@/features/usage/components'
|
||
import {
|
||
Zap,
|
||
Video,
|
||
Loader2,
|
||
FileJson,
|
||
CheckCircle,
|
||
Calendar,
|
||
RefreshCw,
|
||
User,
|
||
Server,
|
||
Clock,
|
||
Timer,
|
||
XCircle,
|
||
X,
|
||
AlertCircle,
|
||
Eye,
|
||
ExternalLink,
|
||
Copy,
|
||
} from 'lucide-vue-next'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { log } from '@/utils/logger'
|
||
|
||
const authStore = useAuthStore()
|
||
const isAdmin = computed(() => authStore.canAccessAdmin)
|
||
const { toast } = useToast()
|
||
const { legacyT } = useI18n()
|
||
const { copyToClipboard } = useClipboard()
|
||
|
||
// 状态
|
||
const loading = ref(false)
|
||
const tasks = ref<AsyncTaskItem[]>([])
|
||
const stats = ref<AsyncTaskStatsResponse | null>(null)
|
||
const total = ref(0)
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(20)
|
||
const filterStatus = ref('all')
|
||
const filterModel = ref('')
|
||
const showDetail = ref(false)
|
||
const selectedTask = ref<AsyncTaskDetail | null>(null)
|
||
const detailAutoRefresh = ref(false)
|
||
let detailRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||
const authenticatedVideoUrls = ref<Record<string, string>>({})
|
||
let authenticatedVideoLoadGeneration = 0
|
||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||
let overviewRefreshInFlight = false
|
||
|
||
// 使用记录详情抽屉状态
|
||
const usageDetailOpen = ref(false)
|
||
const usageRequestId = ref<string | null>(null)
|
||
const runningCount = computed(() => {
|
||
return stats.value?.running_count
|
||
?? stats.value?.processing_count
|
||
?? stats.value?.by_status?.running
|
||
?? stats.value?.by_status?.processing
|
||
?? 0
|
||
})
|
||
|
||
// 判断是否为视频任务
|
||
function isVideoTask(task: AsyncTaskItem): boolean {
|
||
return task.task_type === 'video' || !!task.video_url || !!task.duration_seconds || task.task_key === 'video.task.poller'
|
||
}
|
||
|
||
function displayTaskName(task: AsyncTaskItem | AsyncTaskDetail): string {
|
||
return task.model || task.task_key || task.id
|
||
}
|
||
|
||
function displayTaskDescription(task: AsyncTaskItem | AsyncTaskDetail): string {
|
||
if (task.prompt) return task.prompt
|
||
if (task.progress_message) return task.progress_message
|
||
if (task.error_message) return task.error_message
|
||
if (task.payload) return formatJson(task.payload)
|
||
return task.trigger || task.kind || '-'
|
||
}
|
||
|
||
function displayTaskSource(task: AsyncTaskItem | AsyncTaskDetail): string {
|
||
if (task.provider_name) return `Provider: ${task.provider_name}`
|
||
if (task.kind || task.trigger) return `${task.kind ?? 'task'} / ${task.trigger ?? '-'}`
|
||
return task.owner_instance || '-'
|
||
}
|
||
|
||
function finishTime(task: AsyncTaskItem | AsyncTaskDetail): string | null {
|
||
return task.finished_at || task.completed_at || null
|
||
}
|
||
|
||
function isRunningStatus(status: string): boolean {
|
||
return ['running', 'retrying', 'processing', 'submitted', 'pending', 'queued'].includes(status)
|
||
}
|
||
|
||
function isSucceededStatus(status: string): boolean {
|
||
return status === 'succeeded' || status === 'completed'
|
||
}
|
||
|
||
// 获取任务列表
|
||
async function fetchTasks() {
|
||
loading.value = true
|
||
try {
|
||
const response = await asyncTasksApi.list({
|
||
status: filterStatus.value !== 'all' ? filterStatus.value as AsyncTaskStatus : undefined,
|
||
task_key: filterModel.value || undefined,
|
||
page: currentPage.value,
|
||
page_size: pageSize.value,
|
||
})
|
||
tasks.value = response.items
|
||
total.value = response.total
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: '获取任务列表失败',
|
||
description: error instanceof Error ? error.message : String(error),
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 获取统计数据
|
||
async function fetchStats() {
|
||
try {
|
||
stats.value = await asyncTasksApi.getStats()
|
||
} catch (error) {
|
||
log.error('Failed to fetch stats', error)
|
||
}
|
||
}
|
||
|
||
async function refreshOverview() {
|
||
if (overviewRefreshInFlight) return
|
||
overviewRefreshInFlight = true
|
||
try {
|
||
await Promise.all([fetchTasks(), fetchStats()])
|
||
} finally {
|
||
overviewRefreshInFlight = false
|
||
}
|
||
}
|
||
|
||
// 打开任务详情
|
||
async function openTaskDetail(task: AsyncTaskItem) {
|
||
try {
|
||
const detail = await asyncTasksApi.getDetail(task.id)
|
||
selectedTask.value = detail
|
||
showDetail.value = true
|
||
await loadAuthenticatedVideo(detail)
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: '获取任务详情失败',
|
||
description: error instanceof Error ? error.message : String(error),
|
||
variant: 'destructive',
|
||
})
|
||
}
|
||
}
|
||
|
||
// 刷新任务详情
|
||
async function refreshTaskDetail() {
|
||
if (!selectedTask.value) return
|
||
try {
|
||
const detail = await asyncTasksApi.getDetail(selectedTask.value.id)
|
||
selectedTask.value = detail
|
||
await loadAuthenticatedVideo(detail)
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: '刷新失败',
|
||
description: error instanceof Error ? error.message : String(error),
|
||
variant: 'destructive',
|
||
})
|
||
}
|
||
}
|
||
|
||
// 切换详情自动刷新
|
||
function toggleDetailAutoRefresh() {
|
||
detailAutoRefresh.value = !detailAutoRefresh.value
|
||
if (detailAutoRefresh.value) {
|
||
startDetailAutoRefresh()
|
||
} else {
|
||
stopDetailAutoRefresh()
|
||
}
|
||
}
|
||
|
||
// 开始详情自动刷新
|
||
function startDetailAutoRefresh() {
|
||
if (!isPageVisible.value) return
|
||
if (detailRefreshInterval) return
|
||
// 立即刷新一次
|
||
refreshTaskDetail()
|
||
detailRefreshInterval = setInterval(() => {
|
||
if (isPageVisible.value && selectedTask.value && showDetail.value) {
|
||
refreshTaskDetail()
|
||
}
|
||
}, 5000)
|
||
}
|
||
|
||
function pauseDetailAutoRefresh() {
|
||
if (detailRefreshInterval) {
|
||
clearInterval(detailRefreshInterval)
|
||
detailRefreshInterval = null
|
||
}
|
||
}
|
||
|
||
// 停止详情自动刷新
|
||
function stopDetailAutoRefresh() {
|
||
pauseDetailAutoRefresh()
|
||
detailAutoRefresh.value = false
|
||
}
|
||
|
||
// 关闭详情抽屉
|
||
function closeDetail() {
|
||
stopDetailAutoRefresh()
|
||
clearAuthenticatedVideoUrls()
|
||
showDetail.value = false
|
||
selectedTask.value = null
|
||
}
|
||
|
||
// 打开使用记录详情抽屉
|
||
async function openUsageRecord(task: AsyncTaskItem) {
|
||
try {
|
||
// 获取任务详情以获得 request_id
|
||
const detail = await asyncTasksApi.getDetail(task.id)
|
||
const requestId = detail.request_metadata?.request_id
|
||
if (requestId) {
|
||
usageRequestId.value = requestId
|
||
usageDetailOpen.value = true
|
||
} else {
|
||
toast({
|
||
title: '无法打开使用记录',
|
||
description: '该任务没有关联的请求ID',
|
||
variant: 'destructive',
|
||
})
|
||
}
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: '获取任务信息失败',
|
||
description: error instanceof Error ? error.message : String(error),
|
||
variant: 'destructive',
|
||
})
|
||
}
|
||
}
|
||
|
||
// 取消任务
|
||
async function cancelTask(task: AsyncTaskItem | AsyncTaskDetail) {
|
||
if (!confirm(legacyT('确定要取消这个任务吗?'))) return
|
||
try {
|
||
await asyncTasksApi.cancel(task.id)
|
||
toast({
|
||
title: '任务已取消',
|
||
})
|
||
await refreshOverview()
|
||
if (showDetail.value) {
|
||
closeDetail()
|
||
}
|
||
} catch (error: unknown) {
|
||
toast({
|
||
title: '取消任务失败',
|
||
description: error instanceof Error ? error.message : String(error),
|
||
variant: 'destructive',
|
||
})
|
||
}
|
||
}
|
||
|
||
// 状态相关
|
||
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||
switch (status) {
|
||
case 'succeeded':
|
||
case 'completed':
|
||
return 'default'
|
||
case 'failed':
|
||
return 'destructive'
|
||
case 'cancelled':
|
||
return 'outline'
|
||
default:
|
||
return 'secondary'
|
||
}
|
||
}
|
||
|
||
function getStatusLabel(status: string): string {
|
||
const labels: Record<string, string> = {
|
||
pending: '待处理',
|
||
submitted: '已提交',
|
||
queued: '排队中',
|
||
running: '运行中',
|
||
retrying: '重试中',
|
||
processing: '处理中',
|
||
succeeded: '成功',
|
||
completed: '已完成',
|
||
failed: '失败',
|
||
cancelled: '已取消',
|
||
skipped: '已跳过',
|
||
}
|
||
return labels[status] || status
|
||
}
|
||
|
||
function canCancel(status: string): boolean {
|
||
return ['pending', 'submitted', 'queued', 'processing', 'running', 'retrying'].includes(status)
|
||
}
|
||
|
||
// 格式化日期(简短格式,用于表格列表)
|
||
function formatDate(dateStr: string | null): string {
|
||
if (!dateStr) return '-'
|
||
const date = new Date(dateStr)
|
||
return date.toLocaleString(getI18nLocale(), {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
// 格式化日期(完整格式,用于详情)
|
||
function formatDateFull(dateStr: string | null): string {
|
||
if (!dateStr) return '-'
|
||
const date = new Date(dateStr)
|
||
return date.toLocaleString(getI18nLocale(), {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
})
|
||
}
|
||
|
||
// 格式化时间(带毫秒,用于时间范围)
|
||
function formatTimeWithMs(dateStr: string | null): string {
|
||
if (!dateStr) return '-'
|
||
const date = new Date(dateStr)
|
||
const hours = date.getHours().toString().padStart(2, '0')
|
||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||
const seconds = date.getSeconds().toString().padStart(2, '0')
|
||
const ms = date.getMilliseconds().toString().padStart(3, '0')
|
||
return `${hours}:${minutes}:${seconds}.${ms}`
|
||
}
|
||
|
||
// 格式化文件大小
|
||
function formatFileSize(bytes: number | null): string {
|
||
if (!bytes) return '-'
|
||
const units = ['B', 'KB', 'MB', 'GB']
|
||
let size = bytes
|
||
let unitIndex = 0
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024
|
||
unitIndex++
|
||
}
|
||
return `${size.toFixed(unitIndex > 0 ? 2 : 0)} ${units[unitIndex]}`
|
||
}
|
||
|
||
function authenticatedVideoKey(taskId: string, originalUrl: string): string {
|
||
return `${taskId}\n${originalUrl}`
|
||
}
|
||
|
||
function requiresAuthenticatedVideoProxy(originalUrl: string): boolean {
|
||
try {
|
||
return new URL(originalUrl).hostname === 'generativelanguage.googleapis.com'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function clearAuthenticatedVideoUrls() {
|
||
authenticatedVideoLoadGeneration += 1
|
||
for (const objectUrl of Object.values(authenticatedVideoUrls.value)) {
|
||
URL.revokeObjectURL(objectUrl)
|
||
}
|
||
authenticatedVideoUrls.value = {}
|
||
}
|
||
|
||
async function loadAuthenticatedVideo(task: AsyncTaskDetail) {
|
||
const candidates = [task.video_url, ...(task.video_urls || [])]
|
||
.filter((value): value is string => Boolean(value && requiresAuthenticatedVideoProxy(value)))
|
||
if (candidates.length === 0) return
|
||
|
||
const generation = ++authenticatedVideoLoadGeneration
|
||
try {
|
||
const blob = await asyncTasksApi.getVideoBlob(task.id)
|
||
if (generation !== authenticatedVideoLoadGeneration || selectedTask.value?.id !== task.id) {
|
||
return
|
||
}
|
||
const objectUrl = URL.createObjectURL(blob)
|
||
const next = { ...authenticatedVideoUrls.value }
|
||
for (const originalUrl of candidates) {
|
||
next[authenticatedVideoKey(task.id, originalUrl)] = objectUrl
|
||
}
|
||
authenticatedVideoUrls.value = next
|
||
} catch (error) {
|
||
log.error('Failed to load authenticated video preview', error)
|
||
}
|
||
}
|
||
|
||
// 获取视频 URL(需要认证的 Google URL 使用 Bearer 请求后的临时 Blob URL)
|
||
function getVideoUrl(taskId: string, originalUrl: string): string {
|
||
if (requiresAuthenticatedVideoProxy(originalUrl)) {
|
||
return authenticatedVideoUrls.value[authenticatedVideoKey(taskId, originalUrl)] || ''
|
||
}
|
||
return originalUrl
|
||
}
|
||
|
||
// 计算时间差
|
||
function calcDuration(startStr: string, endStr: string): string {
|
||
const start = new Date(startStr).getTime()
|
||
const end = new Date(endStr).getTime()
|
||
const diffMs = end - start
|
||
if (diffMs < 1000) return `${diffMs}ms`
|
||
const diffS = diffMs / 1000
|
||
if (diffS < 60) return `${diffS.toFixed(1)}s`
|
||
const mins = Math.floor(diffS / 60)
|
||
const secs = Math.floor(diffS % 60)
|
||
return `${mins}m${secs}s`
|
||
}
|
||
|
||
|
||
// 格式化 JSON
|
||
function formatJson(obj: unknown): string {
|
||
try {
|
||
return JSON.stringify(obj, null, 2)
|
||
} catch {
|
||
return String(obj)
|
||
}
|
||
}
|
||
|
||
// 分页
|
||
function goToPage(page: number) {
|
||
currentPage.value = page
|
||
fetchTasks()
|
||
}
|
||
|
||
function handlePageSizeChange(size: number) {
|
||
pageSize.value = size
|
||
currentPage.value = 1
|
||
fetchTasks()
|
||
}
|
||
|
||
// 监听筛选条件变化
|
||
let filterTimeout: number
|
||
watch(filterStatus, () => {
|
||
currentPage.value = 1
|
||
fetchTasks()
|
||
})
|
||
watch(filterModel, () => {
|
||
clearTimeout(filterTimeout)
|
||
filterTimeout = window.setTimeout(() => {
|
||
currentPage.value = 1
|
||
fetchTasks()
|
||
}, 400)
|
||
})
|
||
|
||
// 检查是否有进行中的任务
|
||
const hasProcessingTasks = computed(() => {
|
||
return tasks.value.some(t =>
|
||
['pending', 'submitted', 'queued', 'processing', 'running', 'retrying'].includes(t.status)
|
||
)
|
||
})
|
||
|
||
// 自动刷新逻辑
|
||
let autoRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||
const AUTO_REFRESH_INTERVAL = 5000 // 5秒
|
||
|
||
function startAutoRefresh() {
|
||
if (!isPageVisible.value) return
|
||
if (autoRefreshInterval) return
|
||
autoRefreshInterval = setInterval(() => {
|
||
if (isPageVisible.value && hasProcessingTasks.value && !loading.value) {
|
||
refreshOverview()
|
||
}
|
||
}, AUTO_REFRESH_INTERVAL)
|
||
}
|
||
|
||
function stopAutoRefresh() {
|
||
if (autoRefreshInterval) {
|
||
clearInterval(autoRefreshInterval)
|
||
autoRefreshInterval = null
|
||
}
|
||
}
|
||
|
||
// 监听是否有进行中的任务,动态启停自动刷新
|
||
watch(hasProcessingTasks, (has) => {
|
||
if (has && isPageVisible.value) {
|
||
startAutoRefresh()
|
||
} else {
|
||
stopAutoRefresh()
|
||
}
|
||
}, { immediate: true })
|
||
|
||
function handleVisibilityChange() {
|
||
isPageVisible.value = !document.hidden
|
||
if (!isPageVisible.value) {
|
||
stopAutoRefresh()
|
||
pauseDetailAutoRefresh()
|
||
return
|
||
}
|
||
if (hasProcessingTasks.value) {
|
||
startAutoRefresh()
|
||
}
|
||
if (detailAutoRefresh.value && selectedTask.value && showDetail.value) {
|
||
startDetailAutoRefresh()
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||
refreshOverview()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||
stopAutoRefresh()
|
||
stopDetailAutoRefresh()
|
||
clearAuthenticatedVideoUrls()
|
||
clearTimeout(filterTimeout)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.drawer-enter-active,
|
||
.drawer-leave-active {
|
||
transition: all 0.3s ease;
|
||
}
|
||
.drawer-enter-active > div:first-child,
|
||
.drawer-leave-active > div:first-child {
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
.drawer-enter-active > div:last-child,
|
||
.drawer-leave-active > div:last-child {
|
||
transition: transform 0.3s ease;
|
||
}
|
||
.drawer-enter-from,
|
||
.drawer-leave-to {
|
||
opacity: 0;
|
||
}
|
||
.drawer-enter-from > div:last-child,
|
||
.drawer-leave-to > div:last-child {
|
||
transform: translateX(100%);
|
||
}
|
||
|
||
/* 时间范围箭头容器 */
|
||
.time-arrow-container {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 0.25rem;
|
||
}
|
||
|
||
.time-arrow {
|
||
color: hsl(var(--muted-foreground));
|
||
}
|
||
|
||
.time-duration {
|
||
position: absolute;
|
||
top: -1rem;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
font-size: 0.65rem;
|
||
color: hsl(var(--muted-foreground));
|
||
white-space: nowrap;
|
||
}
|
||
</style>
|