2026-01-31 19:11:25 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="space-y-6 pb-8">
|
|
|
|
|
|
<!-- 统计卡片 -->
|
|
|
|
|
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="p-4"
|
|
|
|
|
|
>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
2026-02-02 03:16:52 +08:00
|
|
|
|
<Zap class="w-5 h-5 text-primary" />
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-2xl font-bold">
|
|
|
|
|
|
{{ stats?.total ?? '-' }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
总任务数
|
|
|
|
|
|
</p>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="p-4"
|
|
|
|
|
|
>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Loader2
|
|
|
|
|
|
class="w-5 h-5 text-blue-500"
|
2026-05-09 21:19:29 +08:00
|
|
|
|
:class="{ 'animate-spin': runningCount > 0 }"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
/>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-2xl font-bold">
|
2026-05-09 21:19:29 +08:00
|
|
|
|
{{ runningCount || '-' }}
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
处理中
|
|
|
|
|
|
</p>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="p-4"
|
|
|
|
|
|
>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
<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>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-2xl font-bold">
|
2026-05-09 21:19:29 +08:00
|
|
|
|
{{ stats?.by_status?.succeeded ?? '-' }}
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
已完成
|
|
|
|
|
|
</p>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="p-4"
|
|
|
|
|
|
>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
<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>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-2xl font-bold">
|
2026-05-09 21:19:29 +08:00
|
|
|
|
{{ stats?.registered_tasks ?? '-' }}
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
2026-05-09 21:19:29 +08:00
|
|
|
|
已注册
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</p>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 任务表格 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="overflow-hidden"
|
|
|
|
|
|
>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
<!-- 标题和筛选器 -->
|
|
|
|
|
|
<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">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<h3 class="text-base font-semibold">
|
|
|
|
|
|
异步任务
|
|
|
|
|
|
</h3>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<!-- 状态筛选 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
v-model="filterStatus"
|
|
|
|
|
|
>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
|
|
|
|
|
<SelectValue placeholder="状态" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<SelectItem value="all">
|
|
|
|
|
|
全部状态
|
|
|
|
|
|
</SelectItem>
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<SelectItem value="queued">
|
|
|
|
|
|
排队中
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</SelectItem>
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<SelectItem value="running">
|
|
|
|
|
|
运行中
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</SelectItem>
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<SelectItem value="retrying">
|
|
|
|
|
|
重试中
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="succeeded">
|
|
|
|
|
|
成功
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="failed">
|
|
|
|
|
|
失败
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="cancelled">
|
|
|
|
|
|
已取消
|
|
|
|
|
|
</SelectItem>
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<SelectItem value="skipped">
|
|
|
|
|
|
已跳过
|
|
|
|
|
|
</SelectItem>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<!-- 模型筛选 -->
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="filterModel"
|
|
|
|
|
|
type="text"
|
2026-05-09 21:19:29 +08:00
|
|
|
|
placeholder="任务 Key..."
|
2026-01-31 19:11:25 +08:00
|
|
|
|
class="w-32 h-8 text-xs"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<!-- 刷新按钮 -->
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
:disabled="loading"
|
|
|
|
|
|
@click="fetchTasks"
|
|
|
|
|
|
>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="w-3.5 h-3.5"
|
|
|
|
|
|
:class="{ 'animate-spin': loading }"
|
|
|
|
|
|
/>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 加载状态 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="loading && !tasks.length"
|
|
|
|
|
|
class="p-8 text-center"
|
|
|
|
|
|
>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
<Loader2 class="w-8 h-8 animate-spin mx-auto text-muted-foreground" />
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
|
加载中...
|
|
|
|
|
|
</p>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 空状态 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="!tasks.length"
|
|
|
|
|
|
class="p-8 text-center"
|
|
|
|
|
|
>
|
2026-02-02 03:16:52 +08:00
|
|
|
|
<Zap class="w-12 h-12 mx-auto text-muted-foreground/50" />
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
|
暂无异步任务
|
|
|
|
|
|
</p>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- 桌面端表格 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Table
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="hidden md:table"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<TableHead class="w-[25%]">
|
|
|
|
|
|
任务
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[15%]">
|
2026-02-28 11:44:08 +08:00
|
|
|
|
{{ isAdmin ? '用户/Provider' : 'Provider' }}
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[12%]">
|
|
|
|
|
|
状态
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[10%]">
|
|
|
|
|
|
参数
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[15%]">
|
|
|
|
|
|
时间
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[8%] text-center">
|
|
|
|
|
|
操作
|
|
|
|
|
|
</TableHead>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</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">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Video
|
|
|
|
|
|
v-if="isVideoTask(task)"
|
|
|
|
|
|
class="w-4 h-4 text-muted-foreground shrink-0"
|
|
|
|
|
|
/>
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<span class="font-medium text-sm truncate">{{ displayTaskName(task) }}</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p
|
|
|
|
|
|
class="text-xs text-muted-foreground truncate max-w-[280px]"
|
2026-05-09 21:19:29 +08:00
|
|
|
|
:title="displayTaskDescription(task)"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
>
|
2026-05-09 21:19:29 +08:00
|
|
|
|
{{ displayTaskDescription(task) }}
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<!-- 用户/Provider -->
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<div class="space-y-0.5 text-sm">
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="isAdmin"
|
|
|
|
|
|
class="flex items-center gap-1.5"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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" />
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<span class="truncate max-w-[100px]">{{ displayTaskSource(task) }}</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<!-- 状态 -->
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<div class="flex flex-col items-start gap-1">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
:variant="getStatusVariant(task.status)"
|
|
|
|
|
|
class="text-xs"
|
|
|
|
|
|
>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
{{ getStatusLabel(task.status) }}
|
|
|
|
|
|
</Badge>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
2026-05-09 21:19:29 +08:00
|
|
|
|
v-if="task.progress_percent > 0 && isRunningStatus(task.status)"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
class="w-full"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
<!-- 参数 -->
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<div class="text-xs space-y-0.5 text-muted-foreground">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
2026-05-09 21:19:29 +08:00
|
|
|
|
v-if="task.duration_seconds || task.attempt"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
class="flex items-center gap-1"
|
|
|
|
|
|
>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
<Timer class="w-3 h-3" />
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<span>{{ task.duration_seconds ? `${task.duration_seconds}s` : `${task.attempt}/${task.max_attempts ?? 1}` }}</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div v-if="task.resolution">
|
|
|
|
|
|
{{ task.resolution }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="task.aspect_ratio">
|
|
|
|
|
|
{{ task.aspect_ratio }}
|
|
|
|
|
|
</div>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</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>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
2026-05-09 21:19:29 +08:00
|
|
|
|
v-if="finishTime(task)"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
class="flex items-center gap-1.5 text-green-600 dark:text-green-400"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<CheckCircle class="w-3 h-3" />
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<span>{{ formatDate(finishTime(task)) }}</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
<!-- 操作 -->
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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
|
2026-05-09 21:19:29 +08:00
|
|
|
|
v-if="isVideoTask(task)"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 移动端卡片列表 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="tasks.length"
|
|
|
|
|
|
class="md:hidden divide-y divide-border/60"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Video
|
|
|
|
|
|
v-if="isVideoTask(task)"
|
|
|
|
|
|
class="w-4 h-4 text-muted-foreground shrink-0"
|
|
|
|
|
|
/>
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<span class="font-medium text-sm truncate">{{ displayTaskName(task) }}</span>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
:variant="getStatusVariant(task.status)"
|
|
|
|
|
|
class="text-xs shrink-0"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
{{ getStatusLabel(task.status) }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 进度条(如果有) -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
2026-05-09 21:19:29 +08:00
|
|
|
|
v-if="task.progress_percent > 0 && isRunningStatus(task.status)"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
class="space-y-1"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-xs text-muted-foreground text-right">
|
|
|
|
|
|
{{ task.progress_percent }}%
|
|
|
|
|
|
</p>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Prompt -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-sm text-muted-foreground line-clamp-2">
|
2026-05-09 21:19:29 +08:00
|
|
|
|
{{ displayTaskDescription(task) }}
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</p>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 信息网格 -->
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="isAdmin"
|
|
|
|
|
|
class="flex items-center gap-1.5 text-muted-foreground"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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" />
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<span class="truncate">{{ displayTaskSource(task) }}</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</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>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
2026-05-09 21:19:29 +08:00
|
|
|
|
v-if="task.duration_seconds || task.attempt"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
class="flex items-center gap-1.5 text-muted-foreground"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<Timer class="w-3 h-3" />
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<span>{{ task.duration_seconds ? `${task.duration_seconds}s` : `${task.attempt}/${task.max_attempts ?? 1}` }}</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 操作按钮 -->
|
|
|
|
|
|
<div class="flex justify-end gap-2">
|
|
|
|
|
|
<Button
|
2026-05-09 21:19:29 +08:00
|
|
|
|
v-if="isVideoTask(task)"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
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
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin && canCancel(task.status)"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
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>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 分页 -->
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 任务详情抽屉 -->
|
|
|
|
|
|
<Teleport to="body">
|
|
|
|
|
|
<Transition name="drawer">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="showDetail && selectedTask"
|
|
|
|
|
|
class="fixed inset-0 z-50 flex justify-end"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
@click.self="closeDetail"
|
2026-01-31 19:11:25 +08:00
|
|
|
|
>
|
|
|
|
|
|
<!-- 背景遮罩 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
@click="closeDetail"
|
2026-01-31 19:11:25 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<!-- 抽屉内容 -->
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<h3 class="text-lg font-semibold">
|
|
|
|
|
|
任务详情
|
|
|
|
|
|
</h3>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Video
|
|
|
|
|
|
v-if="isVideoTask(selectedTask)"
|
|
|
|
|
|
class="w-3.5 h-3.5 mr-1"
|
|
|
|
|
|
/>
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<span>{{ displayTaskName(selectedTask) }}</span>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<Badge :variant="getStatusVariant(selectedTask.status)">
|
|
|
|
|
|
{{ getStatusLabel(selectedTask.status) }}
|
|
|
|
|
|
</Badge>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<div class="flex items-center gap-1 shrink-0">
|
2026-02-02 03:16:52 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
:class="{ 'text-primary': detailAutoRefresh }"
|
|
|
|
|
|
:title="detailAutoRefresh ? '停止自动刷新' : '开启自动刷新(每5秒)'"
|
|
|
|
|
|
@click="toggleDetailAutoRefresh"
|
2026-02-02 03:16:52 +08:00
|
|
|
|
>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<RefreshCw
|
|
|
|
|
|
class="w-4 h-4"
|
|
|
|
|
|
:class="{ 'animate-spin': detailAutoRefresh }"
|
|
|
|
|
|
/>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</Button>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="关闭"
|
|
|
|
|
|
@click="closeDetail"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<X class="w-4 h-4" />
|
2026-02-02 03:16:52 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- 第二行:关键元信息 -->
|
|
|
|
|
|
<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>
|
2026-02-28 11:44:08 +08:00
|
|
|
|
<template v-if="isAdmin">
|
|
|
|
|
|
<span class="opacity-40">|</span>
|
|
|
|
|
|
<span>用户: {{ selectedTask.username }}</span>
|
|
|
|
|
|
</template>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<span class="opacity-40">|</span>
|
2026-05-18 12:41:57 +08:00
|
|
|
|
<span>{{ displayTaskSource(selectedTask) }}</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 进度条 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
2026-05-09 21:19:29 +08:00
|
|
|
|
v-if="selectedTask.progress_percent > 0 && isRunningStatus(selectedTask.status)"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
class="mt-3"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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}%` }"
|
|
|
|
|
|
/>
|
2026-02-02 03:16:52 +08:00
|
|
|
|
</div>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<span class="text-xs text-muted-foreground font-medium">{{ selectedTask.progress_percent }}%</span>
|
2026-02-02 03:16:52 +08:00
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p
|
|
|
|
|
|
v-if="selectedTask.progress_message"
|
|
|
|
|
|
class="text-xs text-muted-foreground mt-1"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
{{ selectedTask.progress_message }}
|
|
|
|
|
|
</p>
|
2026-02-02 03:16:52 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- 可滚动内容区域 -->
|
|
|
|
|
|
<div class="flex-1 min-h-0 overflow-y-auto px-3 sm:px-6 py-3 sm:py-4 space-y-5">
|
|
|
|
|
|
<!-- 错误信息 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<div class="flex items-start gap-2">
|
|
|
|
|
|
<AlertCircle class="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
|
|
|
|
|
<div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p
|
|
|
|
|
|
v-if="selectedTask.error_code"
|
|
|
|
|
|
class="text-xs font-medium text-red-600 dark:text-red-400 mb-1"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
错误码: {{ selectedTask.error_code }}
|
|
|
|
|
|
</p>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-sm text-red-600 dark:text-red-400">
|
|
|
|
|
|
{{ selectedTask.error_message }}
|
|
|
|
|
|
</p>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- 视频结果(放在最前面) -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedTask.video_url || selectedTask.video_urls?.length"
|
|
|
|
|
|
class="space-y-3"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- 主视频 -->
|
|
|
|
|
|
<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">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<code
|
|
|
|
|
|
class="flex-1 text-xs text-foreground truncate px-1"
|
|
|
|
|
|
:title="selectedTask.video_url"
|
|
|
|
|
|
>{{ selectedTask.video_url }}</code>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
<!-- 元信息 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedTask.video_size_bytes || selectedTask.video_expires_at"
|
|
|
|
|
|
class="flex items-center gap-3 text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<span v-if="selectedTask.video_size_bytes">
|
|
|
|
|
|
大小: {{ formatFileSize(selectedTask.video_size_bytes) }}
|
|
|
|
|
|
</span>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="selectedTask.video_expires_at"
|
|
|
|
|
|
class="text-amber-600 dark:text-amber-400"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
过期: {{ formatDate(selectedTask.video_expires_at) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<!-- 多个视频 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<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>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<div class="rounded-lg overflow-hidden border border-border/60 bg-black">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<video
|
|
|
|
|
|
:src="getVideoUrl(selectedTask.id, url)"
|
|
|
|
|
|
controls
|
|
|
|
|
|
preload="none"
|
|
|
|
|
|
class="w-full max-h-[250px] object-contain"
|
|
|
|
|
|
/>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mt-1.5 flex items-center gap-1 p-1.5 bg-muted/50 rounded border border-border/40">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<code
|
|
|
|
|
|
class="flex-1 text-xs text-foreground truncate px-1"
|
|
|
|
|
|
:title="url"
|
|
|
|
|
|
>{{ url }}</code>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 任务完成但无视频 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
2026-05-09 21:19:29 +08:00
|
|
|
|
v-else-if="isSucceededStatus(selectedTask.status) && isVideoTask(selectedTask)"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
class="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800 text-center"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<Video class="w-8 h-8 mx-auto mb-2 text-amber-500" />
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-sm text-amber-600 dark:text-amber-400">
|
|
|
|
|
|
视频链接不可用或已过期
|
|
|
|
|
|
</p>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Prompt -->
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
|
<div class="flex items-center justify-between">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
|
|
|
|
Prompt
|
|
|
|
|
|
</h4>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-6 px-2 text-xs"
|
2026-05-09 21:19:29 +08:00
|
|
|
|
@click="copyToClipboard(displayTaskDescription(selectedTask))"
|
2026-02-03 18:48:39 +08:00
|
|
|
|
>
|
|
|
|
|
|
<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">
|
2026-05-09 21:19:29 +08:00
|
|
|
|
{{ displayTaskDescription(selectedTask) }}
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 视频信息(网格布局) -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<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>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<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>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<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>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<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>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<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>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 执行状态(网格布局) -->
|
|
|
|
|
|
<div class="space-y-2">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
|
|
|
|
执行状态
|
|
|
|
|
|
</h4>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
|
|
|
|
<div class="p-3 bg-muted/30 rounded-lg">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-xs text-muted-foreground mb-0.5">
|
|
|
|
|
|
轮询
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-sm font-medium">
|
2026-05-09 21:19:29 +08:00
|
|
|
|
{{ selectedTask.poll_count ?? selectedTask.attempt ?? 0 }} / {{ selectedTask.max_poll_count ?? selectedTask.max_attempts ?? 1 }}
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</p>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="p-3 bg-muted/30 rounded-lg">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-xs text-muted-foreground mb-0.5">
|
|
|
|
|
|
重试
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-sm font-medium">
|
2026-05-09 21:19:29 +08:00
|
|
|
|
{{ selectedTask.retry_count ?? 0 }} / {{ selectedTask.max_retries ?? selectedTask.max_attempts ?? 1 }}
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</p>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="p-3 bg-muted/30 rounded-lg">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<p class="text-xs text-muted-foreground mb-0.5">
|
|
|
|
|
|
轮询间隔
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="text-sm font-medium">
|
2026-05-09 21:19:29 +08:00
|
|
|
|
{{ selectedTask.poll_interval_seconds ? `${selectedTask.poll_interval_seconds}s` : selectedTask.trigger ?? '-' }}
|
2026-02-04 23:59:45 +08:00
|
|
|
|
</p>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<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>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 时间范围 -->
|
|
|
|
|
|
<div class="space-y-2">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
|
|
|
|
时间范围
|
|
|
|
|
|
</h4>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<div class="flex items-center gap-1 text-sm font-medium">
|
|
|
|
|
|
<span>{{ formatTimeWithMs(selectedTask.created_at) }}</span>
|
|
|
|
|
|
<span class="time-arrow-container">
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<span
|
2026-05-09 21:19:29 +08:00
|
|
|
|
v-if="finishTime(selectedTask)"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
class="time-duration"
|
2026-05-09 21:19:29 +08:00
|
|
|
|
>+{{ calcDuration(selectedTask.created_at, finishTime(selectedTask) || selectedTask.created_at) }}</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<span class="time-arrow">→</span>
|
|
|
|
|
|
</span>
|
2026-05-09 21:19:29 +08:00
|
|
|
|
<template v-if="finishTime(selectedTask)">
|
|
|
|
|
|
<span>{{ formatTimeWithMs(finishTime(selectedTask)) }}</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</template>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="text-muted-foreground"
|
|
|
|
|
|
>处理中...</span>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 响应数据 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedTask.request_metadata?.poll_raw_response"
|
|
|
|
|
|
class="space-y-2"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 操作按钮 -->
|
2026-02-04 23:59:45 +08:00
|
|
|
|
<div
|
2026-05-13 18:12:46 +08:00
|
|
|
|
v-if="authStore.canOperateAdmin && canCancel(selectedTask.status)"
|
2026-02-04 23:59:45 +08:00
|
|
|
|
class="pt-4 border-t border-border/60"
|
|
|
|
|
|
>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
class="w-full"
|
|
|
|
|
|
@click="cancelTask(selectedTask)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<XCircle class="w-4 h-4 mr-2" />
|
|
|
|
|
|
取消任务
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Transition>
|
|
|
|
|
|
</Teleport>
|
2026-02-03 18:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 使用记录详情抽屉 -->
|
|
|
|
|
|
<RequestDetailDrawer
|
|
|
|
|
|
:is-open="usageDetailOpen"
|
|
|
|
|
|
:request-id="usageRequestId"
|
|
|
|
|
|
@close="usageDetailOpen = false"
|
|
|
|
|
|
/>
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-02-03 18:48:39 +08:00
|
|
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
2026-02-02 03:16:52 +08:00
|
|
|
|
import { asyncTasksApi, type AsyncTaskItem, type AsyncTaskDetail, type AsyncTaskStatsResponse, type AsyncTaskStatus } from '@/api/async-tasks'
|
2026-01-31 19:11:25 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2026-02-03 18:48:39 +08:00
|
|
|
|
import { useClipboard } from '@/composables/useClipboard'
|
2026-06-30 14:14:12 +08:00
|
|
|
|
import { useI18n } from '@/i18n'
|
2026-01-31 19:11:25 +08:00
|
|
|
|
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'
|
2026-02-03 18:48:39 +08:00
|
|
|
|
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'
|
2026-01-31 19:11:25 +08:00
|
|
|
|
import {
|
2026-02-02 03:16:52 +08:00
|
|
|
|
Zap,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
Video,
|
|
|
|
|
|
Loader2,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
FileJson,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
CheckCircle,
|
|
|
|
|
|
Calendar,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
User,
|
|
|
|
|
|
Server,
|
|
|
|
|
|
Clock,
|
|
|
|
|
|
Timer,
|
|
|
|
|
|
XCircle,
|
|
|
|
|
|
X,
|
2026-02-03 18:48:39 +08:00
|
|
|
|
AlertCircle,
|
|
|
|
|
|
Eye,
|
|
|
|
|
|
ExternalLink,
|
|
|
|
|
|
Copy,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
} from 'lucide-vue-next'
|
2026-02-28 11:44:08 +08:00
|
|
|
|
import { useAuthStore } from '@/stores/auth'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { log } from '@/utils/logger'
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-28 11:44:08 +08:00
|
|
|
|
const authStore = useAuthStore()
|
2026-05-13 18:12:46 +08:00
|
|
|
|
const isAdmin = computed(() => authStore.canAccessAdmin)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
const { toast } = useToast()
|
2026-06-30 14:14:12 +08:00
|
|
|
|
const { legacyT } = useI18n()
|
2026-02-03 18:48:39 +08:00
|
|
|
|
const { copyToClipboard } = useClipboard()
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
// 状态
|
|
|
|
|
|
const loading = ref(false)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
const tasks = ref<AsyncTaskItem[]>([])
|
|
|
|
|
|
const stats = ref<AsyncTaskStatsResponse | null>(null)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
const total = ref(0)
|
|
|
|
|
|
const currentPage = ref(1)
|
|
|
|
|
|
const pageSize = ref(20)
|
|
|
|
|
|
const filterStatus = ref('all')
|
|
|
|
|
|
const filterModel = ref('')
|
|
|
|
|
|
const showDetail = ref(false)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
const selectedTask = ref<AsyncTaskDetail | null>(null)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
const detailAutoRefresh = ref(false)
|
|
|
|
|
|
let detailRefreshInterval: ReturnType<typeof setInterval> | null = null
|
2026-03-03 22:04:40 +08:00
|
|
|
|
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
|
|
|
|
|
let overviewRefreshInFlight = false
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 使用记录详情抽屉状态
|
|
|
|
|
|
const usageDetailOpen = ref(false)
|
|
|
|
|
|
const usageRequestId = ref<string | null>(null)
|
2026-05-09 21:19:29 +08:00
|
|
|
|
const runningCount = computed(() => {
|
|
|
|
|
|
return stats.value?.running_count
|
|
|
|
|
|
?? stats.value?.processing_count
|
|
|
|
|
|
?? stats.value?.by_status?.running
|
|
|
|
|
|
?? stats.value?.by_status?.processing
|
|
|
|
|
|
?? 0
|
|
|
|
|
|
})
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
// 判断是否为视频任务
|
|
|
|
|
|
function isVideoTask(task: AsyncTaskItem): boolean {
|
2026-05-09 21:19:29 +08:00
|
|
|
|
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'
|
2026-02-02 03:16:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
// 获取任务列表
|
|
|
|
|
|
async function fetchTasks() {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
2026-02-02 03:16:52 +08:00
|
|
|
|
const response = await asyncTasksApi.list({
|
|
|
|
|
|
status: filterStatus.value !== 'all' ? filterStatus.value as AsyncTaskStatus : undefined,
|
2026-05-09 21:19:29 +08:00
|
|
|
|
task_key: filterModel.value || undefined,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
page: currentPage.value,
|
|
|
|
|
|
page_size: pageSize.value,
|
|
|
|
|
|
})
|
|
|
|
|
|
tasks.value = response.items
|
|
|
|
|
|
total.value = response.total
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (error: unknown) {
|
2026-01-31 19:11:25 +08:00
|
|
|
|
toast({
|
|
|
|
|
|
title: '获取任务列表失败',
|
2026-02-22 00:43:41 +08:00
|
|
|
|
description: error instanceof Error ? error.message : String(error),
|
2026-01-31 19:11:25 +08:00
|
|
|
|
variant: 'destructive',
|
|
|
|
|
|
})
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取统计数据
|
|
|
|
|
|
async function fetchStats() {
|
|
|
|
|
|
try {
|
2026-02-02 03:16:52 +08:00
|
|
|
|
stats.value = await asyncTasksApi.getStats()
|
2026-01-31 19:11:25 +08:00
|
|
|
|
} catch (error) {
|
2026-02-22 00:43:41 +08:00
|
|
|
|
log.error('Failed to fetch stats', error)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
async function refreshOverview() {
|
|
|
|
|
|
if (overviewRefreshInFlight) return
|
|
|
|
|
|
overviewRefreshInFlight = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await Promise.all([fetchTasks(), fetchStats()])
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
overviewRefreshInFlight = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
// 打开任务详情
|
2026-02-02 03:16:52 +08:00
|
|
|
|
async function openTaskDetail(task: AsyncTaskItem) {
|
2026-01-31 19:11:25 +08:00
|
|
|
|
try {
|
2026-02-02 03:16:52 +08:00
|
|
|
|
selectedTask.value = await asyncTasksApi.getDetail(task.id)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
showDetail.value = true
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (error: unknown) {
|
2026-01-31 19:11:25 +08:00
|
|
|
|
toast({
|
|
|
|
|
|
title: '获取任务详情失败',
|
2026-02-22 00:43:41 +08:00
|
|
|
|
description: error instanceof Error ? error.message : String(error),
|
2026-01-31 19:11:25 +08:00
|
|
|
|
variant: 'destructive',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 刷新任务详情
|
|
|
|
|
|
async function refreshTaskDetail() {
|
|
|
|
|
|
if (!selectedTask.value) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
selectedTask.value = await asyncTasksApi.getDetail(selectedTask.value.id)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (error: unknown) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
toast({
|
|
|
|
|
|
title: '刷新失败',
|
2026-02-22 00:43:41 +08:00
|
|
|
|
description: error instanceof Error ? error.message : String(error),
|
2026-02-03 18:48:39 +08:00
|
|
|
|
variant: 'destructive',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换详情自动刷新
|
|
|
|
|
|
function toggleDetailAutoRefresh() {
|
|
|
|
|
|
detailAutoRefresh.value = !detailAutoRefresh.value
|
|
|
|
|
|
if (detailAutoRefresh.value) {
|
|
|
|
|
|
startDetailAutoRefresh()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stopDetailAutoRefresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 开始详情自动刷新
|
|
|
|
|
|
function startDetailAutoRefresh() {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (!isPageVisible.value) return
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (detailRefreshInterval) return
|
|
|
|
|
|
// 立即刷新一次
|
|
|
|
|
|
refreshTaskDetail()
|
|
|
|
|
|
detailRefreshInterval = setInterval(() => {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (isPageVisible.value && selectedTask.value && showDetail.value) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
refreshTaskDetail()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 5000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
function pauseDetailAutoRefresh() {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (detailRefreshInterval) {
|
|
|
|
|
|
clearInterval(detailRefreshInterval)
|
|
|
|
|
|
detailRefreshInterval = null
|
|
|
|
|
|
}
|
2026-03-03 22:04:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 停止详情自动刷新
|
|
|
|
|
|
function stopDetailAutoRefresh() {
|
|
|
|
|
|
pauseDetailAutoRefresh()
|
2026-02-03 18:48:39 +08:00
|
|
|
|
detailAutoRefresh.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 关闭详情抽屉
|
|
|
|
|
|
function closeDetail() {
|
|
|
|
|
|
stopDetailAutoRefresh()
|
|
|
|
|
|
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',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (error: unknown) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
toast({
|
|
|
|
|
|
title: '获取任务信息失败',
|
2026-02-22 00:43:41 +08:00
|
|
|
|
description: error instanceof Error ? error.message : String(error),
|
2026-02-03 18:48:39 +08:00
|
|
|
|
variant: 'destructive',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
// 取消任务
|
2026-02-02 03:16:52 +08:00
|
|
|
|
async function cancelTask(task: AsyncTaskItem | AsyncTaskDetail) {
|
2026-06-30 14:14:12 +08:00
|
|
|
|
if (!confirm(legacyT('确定要取消这个任务吗?'))) return
|
2026-01-31 19:11:25 +08:00
|
|
|
|
try {
|
2026-02-02 03:16:52 +08:00
|
|
|
|
await asyncTasksApi.cancel(task.id)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
toast({
|
|
|
|
|
|
title: '任务已取消',
|
|
|
|
|
|
})
|
2026-03-03 22:04:40 +08:00
|
|
|
|
await refreshOverview()
|
2026-01-31 19:11:25 +08:00
|
|
|
|
if (showDetail.value) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
closeDetail()
|
2026-01-31 19:11:25 +08:00
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (error: unknown) {
|
2026-01-31 19:11:25 +08:00
|
|
|
|
toast({
|
|
|
|
|
|
title: '取消任务失败',
|
2026-02-22 00:43:41 +08:00
|
|
|
|
description: error instanceof Error ? error.message : String(error),
|
2026-01-31 19:11:25 +08:00
|
|
|
|
variant: 'destructive',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 状态相关
|
|
|
|
|
|
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
|
|
|
|
switch (status) {
|
2026-05-09 21:19:29 +08:00
|
|
|
|
case 'succeeded':
|
2026-01-31 19:11:25 +08:00
|
|
|
|
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: '排队中',
|
2026-05-09 21:19:29 +08:00
|
|
|
|
running: '运行中',
|
|
|
|
|
|
retrying: '重试中',
|
2026-01-31 19:11:25 +08:00
|
|
|
|
processing: '处理中',
|
2026-05-09 21:19:29 +08:00
|
|
|
|
succeeded: '成功',
|
2026-01-31 19:11:25 +08:00
|
|
|
|
completed: '已完成',
|
|
|
|
|
|
failed: '失败',
|
|
|
|
|
|
cancelled: '已取消',
|
2026-05-09 21:19:29 +08:00
|
|
|
|
skipped: '已跳过',
|
2026-01-31 19:11:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
return labels[status] || status
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function canCancel(status: string): boolean {
|
2026-05-09 21:19:29 +08:00
|
|
|
|
return ['pending', 'submitted', 'queued', 'processing', 'running', 'retrying'].includes(status)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 格式化日期(简短格式,用于表格列表)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
function formatDate(dateStr: string | null): string {
|
|
|
|
|
|
if (!dateStr) return '-'
|
|
|
|
|
|
const date = new Date(dateStr)
|
|
|
|
|
|
return date.toLocaleString('zh-CN', {
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 格式化日期(完整格式,用于详情)
|
|
|
|
|
|
function formatDateFull(dateStr: string | null): string {
|
|
|
|
|
|
if (!dateStr) return '-'
|
|
|
|
|
|
const date = new Date(dateStr)
|
|
|
|
|
|
return date.toLocaleString('zh-CN', {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
|
second: '2-digit',
|
|
|
|
|
|
})
|
2026-02-02 03:16:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 格式化时间(带毫秒,用于时间范围)
|
|
|
|
|
|
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]}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取视频 URL(需要认证的 Google URL 使用代理)
|
|
|
|
|
|
function getVideoUrl(taskId: string, originalUrl: string): string {
|
|
|
|
|
|
// Google API 链接需要代理
|
|
|
|
|
|
if (originalUrl.includes('generativelanguage.googleapis.com')) {
|
|
|
|
|
|
// 从 localStorage 获取 token 作为 query param
|
|
|
|
|
|
const token = localStorage.getItem('access_token')
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
return `/api/admin/video-tasks/${taskId}/video?token=${encodeURIComponent(token)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return `/api/admin/video-tasks/${taskId}/video`
|
|
|
|
|
|
}
|
|
|
|
|
|
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`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
// 格式化 JSON
|
2026-02-22 00:43:41 +08:00
|
|
|
|
function formatJson(obj: unknown): string {
|
2026-02-02 03:16:52 +08:00
|
|
|
|
try {
|
|
|
|
|
|
return JSON.stringify(obj, null, 2)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return String(obj)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
// 分页
|
|
|
|
|
|
function goToPage(page: number) {
|
|
|
|
|
|
currentPage.value = page
|
|
|
|
|
|
fetchTasks()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
function handlePageSizeChange(size: number) {
|
|
|
|
|
|
pageSize.value = size
|
|
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
fetchTasks()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
// 监听筛选条件变化
|
|
|
|
|
|
let filterTimeout: number
|
|
|
|
|
|
watch(filterStatus, () => {
|
|
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
fetchTasks()
|
|
|
|
|
|
})
|
|
|
|
|
|
watch(filterModel, () => {
|
|
|
|
|
|
clearTimeout(filterTimeout)
|
|
|
|
|
|
filterTimeout = window.setTimeout(() => {
|
|
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
fetchTasks()
|
|
|
|
|
|
}, 400)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
// 检查是否有进行中的任务
|
|
|
|
|
|
const hasProcessingTasks = computed(() => {
|
|
|
|
|
|
return tasks.value.some(t =>
|
2026-05-09 21:19:29 +08:00
|
|
|
|
['pending', 'submitted', 'queued', 'processing', 'running', 'retrying'].includes(t.status)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 自动刷新逻辑
|
|
|
|
|
|
let autoRefreshInterval: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
|
const AUTO_REFRESH_INTERVAL = 5000 // 5秒
|
|
|
|
|
|
|
|
|
|
|
|
function startAutoRefresh() {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (!isPageVisible.value) return
|
2026-02-03 18:48:39 +08:00
|
|
|
|
if (autoRefreshInterval) return
|
|
|
|
|
|
autoRefreshInterval = setInterval(() => {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (isPageVisible.value && hasProcessingTasks.value && !loading.value) {
|
|
|
|
|
|
refreshOverview()
|
2026-02-03 18:48:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}, AUTO_REFRESH_INTERVAL)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopAutoRefresh() {
|
|
|
|
|
|
if (autoRefreshInterval) {
|
|
|
|
|
|
clearInterval(autoRefreshInterval)
|
|
|
|
|
|
autoRefreshInterval = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 监听是否有进行中的任务,动态启停自动刷新
|
|
|
|
|
|
watch(hasProcessingTasks, (has) => {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
if (has && isPageVisible.value) {
|
2026-02-03 18:48:39 +08:00
|
|
|
|
startAutoRefresh()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stopAutoRefresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { immediate: true })
|
|
|
|
|
|
|
2026-03-03 22:04:40 +08:00
|
|
|
|
function handleVisibilityChange() {
|
|
|
|
|
|
isPageVisible.value = !document.hidden
|
|
|
|
|
|
if (!isPageVisible.value) {
|
|
|
|
|
|
stopAutoRefresh()
|
|
|
|
|
|
pauseDetailAutoRefresh()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasProcessingTasks.value) {
|
|
|
|
|
|
startAutoRefresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
if (detailAutoRefresh.value && selectedTask.value && showDetail.value) {
|
|
|
|
|
|
startDetailAutoRefresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 19:11:25 +08:00
|
|
|
|
onMounted(() => {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
|
|
|
|
refreshOverview()
|
2026-01-31 19:11:25 +08:00
|
|
|
|
})
|
2026-02-03 18:48:39 +08:00
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
2026-03-03 22:04:40 +08:00
|
|
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
stopAutoRefresh()
|
|
|
|
|
|
stopDetailAutoRefresh()
|
|
|
|
|
|
clearTimeout(filterTimeout)
|
|
|
|
|
|
})
|
2026-01-31 19:11:25 +08:00
|
|
|
|
</script>
|
2026-02-04 23:59:45 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|