refactor: 重构异步任务系统和计费服务架构

- 重构任务系统:新增 lifecycle (TaskStatus/BillingStatus)、context、application 模块
- 将 video tasks 泛化为 async tasks,支持更通用的异步任务管理
- 新增 Gemini Files 管理模块和管理界面
- 重构 billing 服务:拆分 schema.py 和 service.py
- 新增 candidate 服务模块用于请求候选管理
- 数据库迁移:添加 billing_status、request_id、gemini_file_mappings 表和索引
- 移除废弃的 video_telemetry、task orchestrator 等模块
This commit is contained in:
fawney19
2026-02-02 03:16:52 +08:00
parent feb7484fda
commit 9e31efe26c
75 changed files with 7511 additions and 2068 deletions

View File

@@ -5,7 +5,7 @@
<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" />
<Zap class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.total ?? '-' }}</p>
@@ -53,7 +53,7 @@
<!-- 标题和筛选器 -->
<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>
<h3 class="text-base font-semibold">异步任务</h3>
<div class="flex items-center gap-2">
<!-- 状态筛选 -->
<Select v-model="filterStatus">
@@ -98,8 +98,8 @@
<!-- 空状态 -->
<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>
<Zap class="w-12 h-12 mx-auto text-muted-foreground/50" />
<p class="mt-2 text-sm text-muted-foreground">暂无异步任务</p>
</div>
<!-- 任务列表 -->
@@ -114,6 +114,7 @@
<div class="flex-1 min-w-0">
<!-- 模型和状态 -->
<div class="flex items-center gap-2 mb-1">
<Video v-if="isVideoTask(task)" class="w-4 h-4 text-muted-foreground" />
<span class="font-medium text-sm">{{ task.model }}</span>
<Badge :variant="getStatusVariant(task.status)">
{{ getStatusLabel(task.status) }}
@@ -389,18 +390,86 @@
</div>
<!-- 视频结果 -->
<div v-if="selectedTask.video_url" class="space-y-3">
<h4 class="text-sm font-medium">视频结果</h4>
<div class="space-y-2">
<div v-if="selectedTask.status === 'completed' || selectedTask.video_url || selectedTask.video_urls?.length" class="space-y-3">
<h4 class="text-sm font-medium flex items-center gap-2">
<Video class="w-4 h-4" />
视频结果
</h4>
<!-- 主视频 -->
<div v-if="selectedTask.video_url" class="space-y-2">
<video
:src="selectedTask.video_url"
controls
class="w-full rounded-lg"
/>
<!-- 视频链接 -->
<div class="p-2 bg-muted/50 rounded text-xs">
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground truncate flex-1" :title="selectedTask.video_url">
{{ selectedTask.video_url }}
</span>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="copyToClipboard(selectedTask.video_url)"
>
复制链接
</Button>
</div>
</div>
<p v-if="selectedTask.video_expires_at" class="text-xs text-muted-foreground">
过期时间: {{ formatDate(selectedTask.video_expires_at) }}
</p>
</div>
<!-- 多个视频如果有 -->
<div v-else-if="selectedTask.video_urls?.length" class="space-y-3">
<div v-for="(url, index) in selectedTask.video_urls" :key="index" class="space-y-2">
<p class="text-xs text-muted-foreground">视频 {{ index + 1 }}</p>
<video :src="url" controls class="w-full rounded-lg" />
<div class="p-2 bg-muted/50 rounded text-xs">
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground truncate flex-1" :title="url">{{ url }}</span>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="copyToClipboard(url)"
>
复制链接
</Button>
</div>
</div>
</div>
</div>
<!-- 任务完成但无视频 -->
<div v-else-if="selectedTask.status === 'completed'" class="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-center">
<p class="text-sm text-amber-600 dark:text-amber-400">任务已完成但视频链接不可用或已过期</p>
</div>
</div>
<!-- 任务完成响应体 -->
<div v-if="selectedTask.request_metadata?.poll_raw_response" class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium flex items-center gap-2">
<FileJson class="w-4 h-4" />
任务响应
</h4>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="copyToClipboard(JSON.stringify(selectedTask.request_metadata.poll_raw_response, null, 2))"
>
复制
</Button>
</div>
<div class="p-3 bg-muted/50 rounded-lg overflow-x-auto">
<pre class="text-xs font-mono whitespace-pre-wrap break-all">{{ formatJson(selectedTask.request_metadata.poll_raw_response) }}</pre>
</div>
</div>
<!-- 操作按钮 -->
@@ -447,7 +516,7 @@
<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 { asyncTasksApi, type AsyncTaskItem, type AsyncTaskDetail, type AsyncTaskStatsResponse, type AsyncTaskStatus } from '@/api/async-tasks'
import { useToast } from '@/composables/useToast'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
@@ -459,8 +528,10 @@ 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 {
Zap,
Video,
Loader2,
FileJson,
CheckCircle,
Calendar,
RefreshCw,
@@ -478,24 +549,29 @@ const { toast } = useToast()
//
const loading = ref(false)
const tasks = ref<VideoTaskItem[]>([])
const stats = ref<VideoTaskStatsResponse | null>(null)
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<VideoTaskDetail | null>(null)
const selectedTask = ref<AsyncTaskDetail | null>(null)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
//
function isVideoTask(task: AsyncTaskItem): boolean {
return task.task_type === 'video' || !!task.video_url || !!task.duration_seconds
}
//
async function fetchTasks() {
loading.value = true
try {
const response = await videoTasksApi.list({
status: filterStatus.value !== 'all' ? filterStatus.value as VideoTaskStatus : undefined,
const response = await asyncTasksApi.list({
status: filterStatus.value !== 'all' ? filterStatus.value as AsyncTaskStatus : undefined,
model: filterModel.value || undefined,
page: currentPage.value,
page_size: pageSize.value,
@@ -516,16 +592,16 @@ async function fetchTasks() {
//
async function fetchStats() {
try {
stats.value = await videoTasksApi.getStats()
stats.value = await asyncTasksApi.getStats()
} catch (error) {
console.error('Failed to fetch stats:', error)
}
}
//
async function openTaskDetail(task: VideoTaskItem) {
async function openTaskDetail(task: AsyncTaskItem) {
try {
selectedTask.value = await videoTasksApi.getDetail(task.id)
selectedTask.value = await asyncTasksApi.getDetail(task.id)
showDetail.value = true
} catch (error: any) {
toast({
@@ -537,10 +613,10 @@ async function openTaskDetail(task: VideoTaskItem) {
}
//
async function cancelTask(task: VideoTaskItem | VideoTaskDetail) {
async function cancelTask(task: AsyncTaskItem | AsyncTaskDetail) {
if (!confirm('确定要取消这个任务吗?')) return
try {
await videoTasksApi.cancel(task.id)
await asyncTasksApi.cancel(task.id)
toast({
title: '任务已取消',
})
@@ -602,6 +678,30 @@ function formatDate(dateStr: string | null): string {
})
}
//
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
toast({
title: '已复制到剪贴板',
})
} catch (error) {
toast({
title: '复制失败',
variant: 'destructive',
})
}
}
// JSON
function formatJson(obj: any): string {
try {
return JSON.stringify(obj, null, 2)
} catch {
return String(obj)
}
}
//
function goToPage(page: number) {
currentPage.value = page

View File

@@ -0,0 +1,570 @@
<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">
<FileUp class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.total_mappings ?? '-' }}</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?.active_mappings ?? '-' }}</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">
<Clock class="w-5 h-5 text-amber-500" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.expired_mappings ?? '-' }}</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">
<Key class="w-5 h-5 text-blue-500" />
</div>
<div>
<p class="text-2xl font-bold">{{ stats?.capable_keys_count ?? '-' }}</p>
<p class="text-xs text-muted-foreground">支持的 Key</p>
</div>
</div>
</Card>
</div>
<!-- 上传区域 -->
<Card variant="default" class="p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium">上传文件</h3>
<Button
v-if="capableKeys.length > 0"
variant="ghost"
size="sm"
class="h-7 text-xs"
@click="toggleSelectAll"
>
{{ selectedKeyIds.length === capableKeys.length ? '取消全选' : '全选' }}
</Button>
</div>
<!-- Key 选择器 -->
<div v-if="capableKeys.length > 0" class="mb-4">
<p class="text-xs text-muted-foreground mb-2">选择要上传到的 Key可多选</p>
<div class="flex flex-wrap gap-2">
<button
v-for="key in capableKeys"
:key="key.id"
class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
:class="selectedKeyIds.includes(key.id)
? 'border-primary bg-primary/10 text-primary'
: 'border-border hover:border-primary/50'"
@click="toggleKeySelection(key.id)"
>
<span class="font-medium">{{ key.name }}</span>
<span v-if="key.provider_name" class="text-muted-foreground ml-1">({{ key.provider_name }})</span>
</button>
</div>
</div>
<div v-else class="mb-4 text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/30 rounded-lg p-3">
暂无可用的 Key请先配置具有Gemini 文件 API能力的 Key
</div>
<!-- 拖拽上传区 -->
<div
class="border-2 border-dashed border-border/60 rounded-lg p-6 text-center transition-colors"
:class="{
'border-primary bg-primary/5': isDragging,
'hover:border-primary/50': !isDragging && !uploading && selectedKeyIds.length > 0,
'opacity-50 cursor-not-allowed': selectedKeyIds.length === 0
}"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<input
ref="fileInputRef"
type="file"
class="hidden"
@change="handleFileSelect"
/>
<div v-if="uploading" class="flex flex-col items-center gap-2">
<Loader2 class="w-8 h-8 animate-spin text-primary" />
<p class="text-sm text-muted-foreground">正在上传到 {{ selectedKeyIds.length }} Key...</p>
</div>
<div v-else class="flex flex-col items-center gap-2">
<Upload class="w-8 h-8 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
<template v-if="selectedKeyIds.length > 0">
拖拽文件到此处
<button
class="text-primary hover:underline"
@click="fileInputRef?.click()"
>
点击选择
</button>
</template>
<template v-else>
请先选择至少一个 Key
</template>
</p>
<p class="text-xs text-muted-foreground">
支持视频图片音频文档等最大 2GB有效期 48 小时
</p>
</div>
</div>
</Card>
<!-- MIME 类型分布 -->
<Card v-if="stats?.by_mime_type && Object.keys(stats.by_mime_type).length > 0" variant="default" class="p-4">
<h3 class="text-sm font-medium mb-3">文件类型分布</h3>
<div class="flex flex-wrap gap-2">
<Badge
v-for="(count, mimeType) in stats.by_mime_type"
:key="mimeType"
variant="secondary"
class="text-xs"
>
{{ mimeType }}: {{ count }}
</Badge>
</div>
</Card>
<!-- 文件映射表格 -->
<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">
<!-- 搜索 -->
<Input
v-model="searchQuery"
type="text"
placeholder="搜索文件名..."
class="w-40 h-8 text-xs"
/>
<!-- 包含过期 -->
<label class="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
v-model="includeExpired"
type="checkbox"
class="rounded border-border"
/>
包含过期
</label>
<!-- 清理过期按钮 -->
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
:disabled="loading || (stats?.expired_mappings ?? 0) === 0"
@click="cleanupExpired"
>
<Trash2 class="w-3 h-3 mr-1" />
清理过期
</Button>
<!-- 刷新按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="loading"
@click="fetchData"
>
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" />
</Button>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading && !mappings.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="!mappings.length" class="p-8 text-center">
<FileUp class="w-12 h-12 mx-auto text-muted-foreground/50" />
<p class="mt-2 text-sm text-muted-foreground">暂无文件映射</p>
<p class="mt-1 text-xs text-muted-foreground">
用户通过 Gemini Files API 上传文件后会在此显示
</p>
</div>
<!-- 文件列表 -->
<div v-else class="divide-y divide-border/60">
<div
v-for="mapping in mappings"
:key="mapping.id"
class="px-4 sm:px-6 py-4 hover:bg-muted/30 transition-colors"
:class="{ 'opacity-50': mapping.is_expired }"
>
<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">
<component :is="getFileIcon(mapping.mime_type)" class="w-4 h-4 text-muted-foreground" />
<span class="font-mono text-sm font-medium">{{ mapping.file_name }}</span>
<Badge v-if="mapping.is_expired" variant="secondary" class="text-xs">
已过期
</Badge>
<Badge v-else variant="outline" class="text-xs text-green-600">
有效
</Badge>
</div>
<!-- 显示名 -->
<p v-if="mapping.display_name" class="text-sm text-muted-foreground truncate">
{{ mapping.display_name }}
</p>
<!-- 元信息 -->
<div class="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span v-if="mapping.mime_type" class="flex items-center gap-1">
<File class="w-3 h-3" />
{{ mapping.mime_type }}
</span>
<span v-if="mapping.username" class="flex items-center gap-1">
<User class="w-3 h-3" />
{{ mapping.username }}
</span>
<span v-if="mapping.key_name" class="flex items-center gap-1">
<Key class="w-3 h-3" />
{{ mapping.key_name }}
</span>
<span class="flex items-center gap-1">
<Clock class="w-3 h-3" />
{{ formatDate(mapping.created_at) }}
</span>
<span class="flex items-center gap-1" :class="{ 'text-red-500': mapping.is_expired }">
<Timer class="w-3 h-3" />
过期: {{ formatDate(mapping.expires_at) }}
</span>
</div>
</div>
<!-- 操作 -->
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-red-500"
title="删除映射"
@click.stop="deleteMapping(mapping)"
>
<Trash2 class="w-4 h-4" />
</Button>
</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 }} 条记录
</p>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
:disabled="currentPage <= 1"
@click="currentPage--"
>
上一页
</Button>
<span class="text-sm text-muted-foreground">
{{ currentPage }} / {{ totalPages }}
</span>
<Button
variant="outline"
size="sm"
:disabled="currentPage >= totalPages"
@click="currentPage++"
>
下一页
</Button>
</div>
</div>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import {
FileUp,
CheckCircle,
Clock,
Key,
RefreshCw,
Loader2,
Trash2,
File,
User,
Timer,
Video,
Image,
FileText,
Music,
Upload
} from 'lucide-vue-next'
import { geminiFilesApi } from '@/api/gemini-files'
const { toast } = useToast()
// 状态
const loading = ref(false)
const stats = ref<any>(null)
const mappings = ref<any[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = 20
const searchQuery = ref('')
const includeExpired = ref(false)
// 上传状态
const uploading = ref(false)
const isDragging = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const capableKeys = ref<any[]>([])
const selectedKeyIds = ref<string[]>([])
// 计算属性
const totalPages = computed(() => Math.ceil(total.value / pageSize))
// 监听筛选条件变化
watch([searchQuery, includeExpired], () => {
currentPage.value = 1
fetchMappings()
})
watch(currentPage, () => {
fetchMappings()
})
// 获取数据
async function fetchData() {
await Promise.all([fetchStats(), fetchMappings(), fetchCapableKeys()])
}
async function fetchCapableKeys() {
try {
const keys = await geminiFilesApi.getCapableKeys()
capableKeys.value = keys
// 默认全选
if (selectedKeyIds.value.length === 0 && keys.length > 0) {
selectedKeyIds.value = keys.map(k => k.id)
}
} catch (error: any) {
console.error('Failed to fetch capable keys:', error)
}
}
function toggleKeySelection(keyId: string) {
const index = selectedKeyIds.value.indexOf(keyId)
if (index === -1) {
selectedKeyIds.value.push(keyId)
} else {
selectedKeyIds.value.splice(index, 1)
}
}
function toggleSelectAll() {
if (selectedKeyIds.value.length === capableKeys.value.length) {
selectedKeyIds.value = []
} else {
selectedKeyIds.value = capableKeys.value.map(k => k.id)
}
}
async function fetchStats() {
try {
const data = await geminiFilesApi.getStats()
stats.value = data
} catch (error: any) {
toast({
title: '获取统计失败',
description: error.message,
variant: 'destructive'
})
}
}
async function fetchMappings() {
loading.value = true
try {
const data = await geminiFilesApi.listMappings({
page: currentPage.value,
page_size: pageSize,
include_expired: includeExpired.value,
search: searchQuery.value || undefined
})
mappings.value = data.items
total.value = data.total
} catch (error: any) {
toast({
title: '获取文件列表失败',
description: error.message,
variant: 'destructive'
})
} finally {
loading.value = false
}
}
async function deleteMapping(mapping: any) {
if (!confirm(`确定要删除映射 "${mapping.file_name}" 吗?\n\n注意这只会删除映射记录不会删除 Google 上的实际文件。`)) {
return
}
try {
await geminiFilesApi.deleteMapping(mapping.id)
toast({
title: '删除成功',
description: `已删除映射 ${mapping.file_name}`
})
await fetchData()
} catch (error: any) {
toast({
title: '删除失败',
description: error.message,
variant: 'destructive'
})
}
}
async function cleanupExpired() {
if (!confirm('确定要清理所有过期的文件映射吗?')) {
return
}
try {
const result = await geminiFilesApi.cleanupExpired()
toast({
title: '清理完成',
description: `已清理 ${result.deleted_count} 条过期映射`
})
await fetchData()
} catch (error: any) {
toast({
title: '清理失败',
description: error.message,
variant: 'destructive'
})
}
}
// 上传相关
async function uploadFile(file: globalThis.File) {
if (selectedKeyIds.value.length === 0) {
toast({
title: '请选择 Key',
description: '请至少选择一个 Key 来上传文件',
variant: 'destructive'
})
return
}
uploading.value = true
let hasSuccess = false
try {
const result = await geminiFilesApi.uploadFile(file, selectedKeyIds.value)
if (result.fail_count === 0) {
toast({
title: '上传成功',
description: `文件 ${result.display_name} 已上传到 ${result.success_count} 个 Key`
})
hasSuccess = true
} else if (result.success_count > 0) {
toast({
title: '部分成功',
description: `成功 ${result.success_count} 个,失败 ${result.fail_count}`
})
hasSuccess = true
} else {
const errors = result.results.map(r => r.error).filter(Boolean).join('; ')
toast({
title: '上传失败',
description: errors || '所有 Key 上传都失败了',
variant: 'destructive'
})
}
} catch (error: any) {
toast({
title: '上传失败',
description: error.response?.data?.detail || error.message,
variant: 'destructive'
})
} finally {
uploading.value = false
isDragging.value = false
// 有成功上传时刷新列表,并重置到第一页
if (hasSuccess) {
currentPage.value = 1
await fetchData()
}
}
}
function handleDrop(e: DragEvent) {
isDragging.value = false
const files = e.dataTransfer?.files
if (files && files.length > 0) {
uploadFile(files[0])
}
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files && input.files.length > 0) {
uploadFile(input.files[0])
input.value = '' // 清空以便重复选择同一文件
}
}
// 工具函数
function formatDate(dateStr: 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 getFileIcon(mimeType: string | null) {
if (!mimeType) return File
if (mimeType.startsWith('video/')) return Video
if (mimeType.startsWith('image/')) return Image
if (mimeType.startsWith('audio/')) return Music
if (mimeType.startsWith('text/') || mimeType.includes('pdf')) return FileText
return File
}
// 初始化
onMounted(() => {
fetchData()
})
</script>

View File

@@ -187,6 +187,26 @@
</div>
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="enable-format-conversion"
v-model:checked="systemConfig.enable_format_conversion"
/>
<div>
<Label
for="enable-format-conversion"
class="cursor-pointer"
>
全局格式转换
</Label>
<p class="text-xs text-muted-foreground">
开启后强制允许所有提供商接受跨格式请求
</p>
</div>
</div>
</div>
</div>
</CardSection>
@@ -887,6 +907,8 @@ interface SystemConfig {
enable_registration: boolean
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 格式转换
enable_format_conversion: boolean
// 日志记录
request_log_level: string
max_request_body_size: number
@@ -941,6 +963,8 @@ const systemConfig = ref<SystemConfig>({
enable_registration: false,
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 格式转换
enable_format_conversion: false,
// 日志记录
request_log_level: 'basic',
max_request_body_size: 1048576,
@@ -968,7 +992,8 @@ const hasBasicConfigChanges = computed(() => {
systemConfig.value.default_user_quota_usd !== originalConfig.value.default_user_quota_usd ||
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.enable_format_conversion !== originalConfig.value.enable_format_conversion
)
})
@@ -1045,6 +1070,8 @@ async function loadSystemConfig() {
'enable_registration',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 格式转换
'enable_format_conversion',
// 日志记录
'request_log_level',
'max_request_body_size',
@@ -1104,6 +1131,11 @@ async function saveBasicConfig() {
value: systemConfig.value.auto_delete_expired_keys,
description: '是否自动删除过期的API Key'
},
{
key: 'enable_format_conversion',
value: systemConfig.value.enable_format_conversion,
description: '全局格式转换开关:开启时强制允许所有提供商的格式转换'
},
]
await Promise.all(
@@ -1117,6 +1149,7 @@ async function saveBasicConfig() {
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
originalConfig.value.enable_registration = systemConfig.value.enable_registration
originalConfig.value.auto_delete_expired_keys = systemConfig.value.auto_delete_expired_keys
originalConfig.value.enable_format_conversion = systemConfig.value.enable_format_conversion
}
success('基础配置已保存')
} catch (err) {