feat: 添加多维度计费系统和视频任务管理功能

计费系统:
- 新增 BillingRule 和 DimensionCollector 数据模型
- 实现 FormulaEngine 安全表达式求值引擎 (AST 白名单)
- 支持 dimension/matrix/tiered/constant 多种维度映射
- BillingRuleService 支持 Provider Model -> GlobalModel 规则回退
- CLI task_type 在计费域自动映射为 chat

视频任务增强:
- 添加 request_metadata 字段记录候选 key 和计费规则快照
- 后台轮询支持并发控制 (Semaphore + 独立 session)
- 任务终态自动写入 Usage 记录并计算成本
- 新增视频任务管理 API 和前端界面

其他改进:
- UsageService 新增 record_usage_with_custom_cost 方法
- StandardizedUsage 支持 dimensions 字段 (兼容 extra)
- 配置新增 BILLING_REQUIRE_RULE 和 BILLING_STRICT_MODE
This commit is contained in:
fawney19
2026-01-31 19:11:25 +08:00
parent dc4bb25cc2
commit 97b15afe7c
30 changed files with 4356 additions and 237 deletions

View File

@@ -0,0 +1,158 @@
import apiClient from './client'
// 视频任务状态
export type VideoTaskStatus = 'pending' | 'submitted' | 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled'
// 视频任务列表项
export interface VideoTaskItem {
id: string
external_task_id: string
user_id: string
username: string
model: string
prompt: string
status: VideoTaskStatus
progress_percent: number
progress_message: string | null
provider_id: string
provider_name: string
duration_seconds: number
resolution: string
aspect_ratio: string
video_url: string | null
error_code: string | null
error_message: string | null
poll_count: number
max_poll_count: number
created_at: string
completed_at: string | null
submitted_at: string | null
}
// 候选 Key 信息
export interface CandidateKeyInfo {
index: number
provider_id: string
provider_name: string
endpoint_id: string
key_id: string
key_name: string | null
auth_type: string
has_billing_rule: boolean
priority: number
selected?: boolean
}
// 请求元数据
export interface VideoTaskRequestMetadata {
candidate_keys: CandidateKeyInfo[]
selected_key_id: string
selected_endpoint_id: string
client_ip: string
user_agent: string
request_id: string
request_headers?: Record<string, string>
}
// 视频任务详情
export interface VideoTaskDetail extends VideoTaskItem {
api_key_id: string
endpoint_id: string
key_id: string
client_api_format: string
provider_api_format: string
format_converted: boolean
original_request_body: any
converted_request_body: any
size: string | null
video_urls: string[] | null
thumbnail_url: string | null
video_size_bytes: number | null
video_expires_at: string | null
stored_video_path: string | null
storage_provider: string | null
retry_count: number
max_retries: number
poll_interval_seconds: number
next_poll_at: string | null
updated_at: string | null
endpoint: {
id: string
base_url: string
api_format: string
} | null
request_metadata: VideoTaskRequestMetadata | null
}
// 视频任务列表响应
export interface VideoTaskListResponse {
items: VideoTaskItem[]
total: number
page: number
page_size: number
pages: number
}
// 视频任务统计响应
export interface VideoTaskStatsResponse {
total: number
by_status: Record<VideoTaskStatus, number>
by_model: Record<string, number>
today_count: number
active_users?: number // 仅管理员
processing_count?: number // 仅管理员
}
// 视频任务查询参数
export interface VideoTaskQueryParams {
status?: VideoTaskStatus
user_id?: string
model?: string
page?: number
page_size?: number
}
export const videoTasksApi = {
/**
* 获取视频任务列表
*/
async list(params: VideoTaskQueryParams = {}): Promise<VideoTaskListResponse> {
const searchParams = new URLSearchParams()
if (params.status) searchParams.append('status', params.status)
if (params.user_id) searchParams.append('user_id', params.user_id)
if (params.model) searchParams.append('model', params.model)
if (params.page) searchParams.append('page', params.page.toString())
if (params.page_size) searchParams.append('page_size', params.page_size.toString())
const query = searchParams.toString()
const url = query ? `/api/admin/video-tasks?${query}` : '/api/admin/video-tasks'
const response = await apiClient.get(url)
return response.data
},
/**
* 获取视频任务统计
*/
async getStats(): Promise<VideoTaskStatsResponse> {
const response = await apiClient.get('/api/admin/video-tasks/stats')
return response.data
},
/**
* 获取视频任务详情
*/
async getDetail(taskId: string): Promise<VideoTaskDetail> {
const response = await apiClient.get(`/api/admin/video-tasks/${taskId}`)
return response.data
},
/**
* 取消视频任务
*/
async cancel(taskId: string): Promise<{ id: string; status: string; message: string }> {
const response = await apiClient.post(`/api/admin/video-tasks/${taskId}/cancel`)
return response.data
},
}
export default videoTasksApi

View File

@@ -365,6 +365,7 @@ import {
X,
Mail,
Puzzle,
Video,
type LucideIcon,
} from 'lucide-vue-next'
@@ -530,6 +531,7 @@ const navigation = computed(() => {
{ name: '模型管理', href: '/admin/models', icon: Layers },
{ name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '访问令牌', href: '/admin/management-tokens', icon: KeyRound },
{ name: '视频任务', href: '/admin/video-tasks', icon: Video },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
]
},

View File

@@ -207,6 +207,11 @@ const routes: RouteRecordRaw[] = [
path: 'announcements',
name: 'AnnouncementManagement',
component: () => importWithRetry(() => import('@/views/user/Announcements.vue'))
},
{
path: 'video-tasks',
name: 'VideoTasks',
component: () => importWithRetry(() => import('@/views/admin/VideoTasks.vue'))
}
]
}

View File

@@ -0,0 +1,629 @@
<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">
<Video 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': (stats?.processing_count ?? 0) > 0 }" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.processing_count ?? stats?.by_status?.processing ?? '-' }}</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?.completed ?? '-' }}</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?.today_count ?? '-' }}</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="submitted">已提交</SelectItem>
<SelectItem value="processing">处理中</SelectItem>
<SelectItem value="completed">已完成</SelectItem>
<SelectItem value="failed">失败</SelectItem>
<SelectItem value="cancelled">已取消</SelectItem>
</SelectContent>
</Select>
<!-- 模型筛选 -->
<Input
v-model="filterModel"
type="text"
placeholder="模型..."
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">
<Video class="w-12 h-12 mx-auto text-muted-foreground/50" />
<p class="mt-2 text-sm text-muted-foreground">暂无视频任务</p>
</div>
<!-- 任务列表 -->
<div v-else class="divide-y divide-border/60">
<div
v-for="task in tasks"
:key="task.id"
class="px-4 sm:px-6 py-4 hover:bg-muted/30 cursor-pointer transition-colors"
@click="openTaskDetail(task)"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<!-- 模型和状态 -->
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm">{{ task.model }}</span>
<Badge :variant="getStatusVariant(task.status)">
{{ getStatusLabel(task.status) }}
</Badge>
<span v-if="task.progress_percent > 0 && task.status === 'processing'" class="text-xs text-muted-foreground">
{{ task.progress_percent }}%
</span>
</div>
<!-- Prompt 摘要 -->
<p class="text-sm text-muted-foreground truncate">{{ task.prompt }}</p>
<!-- 元信息 -->
<div class="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span class="flex items-center gap-1">
<User class="w-3 h-3" />
{{ task.username }}
</span>
<span class="flex items-center gap-1">
<Server class="w-3 h-3" />
{{ task.provider_name }}
</span>
<span class="flex items-center gap-1">
<Clock class="w-3 h-3" />
{{ formatDate(task.created_at) }}
</span>
<span v-if="task.duration_seconds" class="flex items-center gap-1">
<Timer class="w-3 h-3" />
{{ task.duration_seconds }}s
</span>
</div>
</div>
<!-- 操作 -->
<div class="flex items-center gap-2">
<Button
v-if="canCancel(task.status)"
variant="ghost"
size="sm"
class="text-red-500 hover:text-red-600 hover:bg-red-50"
@click.stop="cancelTask(task)"
>
<XCircle class="w-4 h-4" />
</Button>
<ChevronRight class="w-4 h-4 text-muted-foreground" />
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="px-4 sm:px-6 py-3 border-t border-border/60 flex items-center justify-between">
<p class="text-xs text-muted-foreground">
{{ total }} {{ currentPage }}/{{ totalPages }}
</p>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
>
上一页
</Button>
<Button
variant="outline"
size="sm"
:disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)"
>
下一页
</Button>
</div>
</div>
</Card>
<!-- 任务详情抽屉 -->
<Teleport to="body">
<Transition name="drawer">
<div
v-if="showDetail && selectedTask"
class="fixed inset-0 z-50 flex justify-end"
@click.self="showDetail = false"
>
<!-- 背景遮罩 -->
<div
class="absolute inset-0 bg-black/30 backdrop-blur-sm"
@click="showDetail = false"
/>
<!-- 抽屉内容 -->
<Card class="relative h-full w-full sm:w-[600px] sm:max-w-[90vw] rounded-none shadow-2xl overflow-y-auto">
<!-- 标题栏 -->
<div class="sticky top-0 z-10 bg-background border-b p-4 sm:p-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">任务详情</h3>
<Button variant="ghost" size="icon" @click="showDetail = false">
<X class="w-4 h-4" />
</Button>
</div>
</div>
<!-- 内容 -->
<div class="p-4 sm:p-6 space-y-6">
<!-- 状态和进度 -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">状态</span>
<Badge :variant="getStatusVariant(selectedTask.status)">
{{ getStatusLabel(selectedTask.status) }}
</Badge>
</div>
<div v-if="selectedTask.progress_percent > 0" class="space-y-1">
<div class="flex justify-between text-xs text-muted-foreground">
<span>进度</span>
<span>{{ selectedTask.progress_percent }}%</span>
</div>
<div class="h-2 bg-muted rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all"
:style="{ width: `${selectedTask.progress_percent}%` }"
/>
</div>
</div>
<p v-if="selectedTask.progress_message" class="text-xs text-muted-foreground">
{{ selectedTask.progress_message }}
</p>
</div>
<!-- 错误信息 -->
<div v-if="selectedTask.error_message" class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p class="text-sm text-red-600 dark:text-red-400">
<span v-if="selectedTask.error_code" class="font-medium">[{{ selectedTask.error_code }}]</span>
{{ selectedTask.error_message }}
</p>
</div>
<!-- 基本信息 -->
<div class="space-y-3">
<h4 class="text-sm font-medium">基本信息</h4>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-muted-foreground">模型</span>
<p class="font-medium">{{ selectedTask.model }}</p>
</div>
<div>
<span class="text-muted-foreground">时长</span>
<p class="font-medium">{{ selectedTask.duration_seconds }}s</p>
</div>
<div>
<span class="text-muted-foreground">分辨率</span>
<p class="font-medium">{{ selectedTask.resolution }}</p>
</div>
<div>
<span class="text-muted-foreground">宽高比</span>
<p class="font-medium">{{ selectedTask.aspect_ratio }}</p>
</div>
</div>
</div>
<!-- Prompt -->
<div class="space-y-2">
<h4 class="text-sm font-medium">Prompt</h4>
<div class="p-3 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap">
{{ selectedTask.prompt }}
</div>
</div>
<!-- Provider 信息 -->
<div class="space-y-3">
<h4 class="text-sm font-medium">Provider 信息</h4>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-muted-foreground">用户</span>
<p class="font-medium">{{ selectedTask.username }}</p>
</div>
<div>
<span class="text-muted-foreground">Provider</span>
<p class="font-medium">{{ selectedTask.provider_name }}</p>
</div>
<div>
<span class="text-muted-foreground">客户端格式</span>
<p class="font-medium">{{ selectedTask.client_api_format }}</p>
</div>
<div>
<span class="text-muted-foreground">Provider 格式</span>
<p class="font-medium">{{ selectedTask.provider_api_format }}</p>
</div>
</div>
</div>
<!-- 候选 Key 追踪 -->
<div v-if="selectedTask.request_metadata?.candidate_keys?.length" class="space-y-3">
<h4 class="text-sm font-medium flex items-center gap-2">
<Key class="w-4 h-4" />
候选 Key 列表
<span class="text-xs text-muted-foreground">({{ selectedTask.request_metadata.candidate_keys.length }} )</span>
</h4>
<div class="space-y-2">
<div
v-for="candidateKey in selectedTask.request_metadata.candidate_keys"
:key="candidateKey.key_id"
class="p-2 rounded-lg text-xs border"
:class="candidateKey.selected ? 'bg-primary/10 border-primary/30' : 'bg-muted/30 border-border/60'"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-medium">{{ candidateKey.provider_name }}</span>
<span v-if="candidateKey.key_name" class="text-muted-foreground">/ {{ candidateKey.key_name }}</span>
<Badge v-if="candidateKey.selected" variant="default" class="text-[10px] px-1.5 py-0">已选中</Badge>
<Badge v-if="candidateKey.has_billing_rule === false" variant="outline" class="text-[10px] px-1.5 py-0 text-amber-500 border-amber-500/50">无计费规则</Badge>
</div>
<span class="text-muted-foreground">优先级: {{ candidateKey.priority }}</span>
</div>
<div class="flex items-center gap-3 mt-1 text-muted-foreground">
<span>Auth: {{ candidateKey.auth_type }}</span>
<span class="font-mono truncate max-w-[120px]" :title="candidateKey.key_id">Key: {{ candidateKey.key_id.slice(0, 8) }}...</span>
</div>
</div>
</div>
</div>
<!-- 请求追踪 -->
<div v-if="selectedTask.request_metadata" class="space-y-3">
<h4 class="text-sm font-medium">请求追踪</h4>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-muted-foreground">Request ID</span>
<p class="font-mono text-xs truncate" :title="selectedTask.request_metadata.request_id">{{ selectedTask.request_metadata.request_id }}</p>
</div>
<div>
<span class="text-muted-foreground">Client IP</span>
<p class="font-medium">{{ selectedTask.request_metadata.client_ip }}</p>
</div>
<div class="col-span-2">
<span class="text-muted-foreground">User Agent</span>
<p class="text-xs truncate" :title="selectedTask.request_metadata.user_agent">{{ selectedTask.request_metadata.user_agent }}</p>
</div>
</div>
</div>
<!-- 轮询信息 -->
<div class="space-y-3">
<h4 class="text-sm font-medium">轮询信息</h4>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-muted-foreground">轮询次数</span>
<p class="font-medium">{{ selectedTask.poll_count }} / {{ selectedTask.max_poll_count }}</p>
</div>
<div>
<span class="text-muted-foreground">轮询间隔</span>
<p class="font-medium">{{ selectedTask.poll_interval_seconds }}s</p>
</div>
<div v-if="selectedTask.next_poll_at">
<span class="text-muted-foreground">下次轮询</span>
<p class="font-medium">{{ formatDate(selectedTask.next_poll_at) }}</p>
</div>
</div>
</div>
<!-- 时间信息 -->
<div class="space-y-3">
<h4 class="text-sm font-medium">时间信息</h4>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-muted-foreground">创建时间</span>
<p class="font-medium">{{ formatDate(selectedTask.created_at) }}</p>
</div>
<div v-if="selectedTask.submitted_at">
<span class="text-muted-foreground">提交时间</span>
<p class="font-medium">{{ formatDate(selectedTask.submitted_at) }}</p>
</div>
<div v-if="selectedTask.completed_at">
<span class="text-muted-foreground">完成时间</span>
<p class="font-medium">{{ formatDate(selectedTask.completed_at) }}</p>
</div>
</div>
</div>
<!-- 视频结果 -->
<div v-if="selectedTask.video_url" class="space-y-3">
<h4 class="text-sm font-medium">视频结果</h4>
<div class="space-y-2">
<video
:src="selectedTask.video_url"
controls
class="w-full rounded-lg"
/>
<p v-if="selectedTask.video_expires_at" class="text-xs text-muted-foreground">
过期时间: {{ formatDate(selectedTask.video_expires_at) }}
</p>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="canCancel(selectedTask.status)" class="pt-4 border-t">
<Button
variant="destructive"
class="w-full"
@click="cancelTask(selectedTask)"
>
<XCircle class="w-4 h-4 mr-2" />
取消任务
</Button>
</div>
</div>
</Card>
</div>
</Transition>
</Teleport>
</div>
</template>
<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%);
}
</style>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { videoTasksApi, type VideoTaskItem, type VideoTaskDetail, type VideoTaskStatsResponse, type VideoTaskStatus } from '@/api/video-tasks'
import { useToast } from '@/composables/useToast'
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 {
Video,
Loader2,
CheckCircle,
Calendar,
RefreshCw,
User,
Server,
Clock,
Timer,
XCircle,
ChevronRight,
X,
Key,
} from 'lucide-vue-next'
const { toast } = useToast()
// 状态
const loading = ref(false)
const tasks = ref<VideoTaskItem[]>([])
const stats = ref<VideoTaskStatsResponse | 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<VideoTaskDetail | null>(null)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
// 获取任务列表
async function fetchTasks() {
loading.value = true
try {
const response = await videoTasksApi.list({
status: filterStatus.value !== 'all' ? filterStatus.value as VideoTaskStatus : undefined,
model: filterModel.value || undefined,
page: currentPage.value,
page_size: pageSize.value,
})
tasks.value = response.items
total.value = response.total
} catch (error: any) {
toast({
title: '获取任务列表失败',
description: error.message,
variant: 'destructive',
})
} finally {
loading.value = false
}
}
// 获取统计数据
async function fetchStats() {
try {
stats.value = await videoTasksApi.getStats()
} catch (error) {
console.error('Failed to fetch stats:', error)
}
}
// 打开任务详情
async function openTaskDetail(task: VideoTaskItem) {
try {
selectedTask.value = await videoTasksApi.getDetail(task.id)
showDetail.value = true
} catch (error: any) {
toast({
title: '获取任务详情失败',
description: error.message,
variant: 'destructive',
})
}
}
// 取消任务
async function cancelTask(task: VideoTaskItem | VideoTaskDetail) {
if (!confirm('确定要取消这个任务吗?')) return
try {
await videoTasksApi.cancel(task.id)
toast({
title: '任务已取消',
})
fetchTasks()
fetchStats()
if (showDetail.value) {
showDetail.value = false
selectedTask.value = null
}
} catch (error: any) {
toast({
title: '取消任务失败',
description: error.message,
variant: 'destructive',
})
}
}
// 状态相关
function getStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
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: '排队中',
processing: '处理中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
}
return labels[status] || status
}
function canCancel(status: string): boolean {
return ['pending', 'submitted', 'queued', 'processing'].includes(status)
}
// 格式化日期
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',
})
}
// 分页
function goToPage(page: number) {
currentPage.value = page
fetchTasks()
}
// 监听筛选条件变化
let filterTimeout: number
watch(filterStatus, () => {
currentPage.value = 1
fetchTasks()
})
watch(filterModel, () => {
clearTimeout(filterTimeout)
filterTimeout = window.setTimeout(() => {
currentPage.value = 1
fetchTasks()
}, 400)
})
onMounted(() => {
fetchTasks()
fetchStats()
})
</script>