mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -1,17 +1,21 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// 视频任务状态
|
||||
export type VideoTaskStatus = 'pending' | 'submitted' | 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled'
|
||||
// 异步任务状态
|
||||
export type AsyncTaskStatus = 'pending' | 'submitted' | 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled'
|
||||
|
||||
// 视频任务列表项
|
||||
export interface VideoTaskItem {
|
||||
// 异步任务类型
|
||||
export type AsyncTaskType = 'video'
|
||||
|
||||
// 异步任务列表项
|
||||
export interface AsyncTaskItem {
|
||||
id: string
|
||||
external_task_id: string
|
||||
user_id: string
|
||||
username: string
|
||||
task_type: AsyncTaskType
|
||||
model: string
|
||||
prompt: string
|
||||
status: VideoTaskStatus
|
||||
status: AsyncTaskStatus
|
||||
progress_percent: number
|
||||
progress_message: string | null
|
||||
provider_id: string
|
||||
@@ -44,7 +48,7 @@ export interface CandidateKeyInfo {
|
||||
}
|
||||
|
||||
// 请求元数据
|
||||
export interface VideoTaskRequestMetadata {
|
||||
export interface AsyncTaskRequestMetadata {
|
||||
candidate_keys: CandidateKeyInfo[]
|
||||
selected_key_id: string
|
||||
selected_endpoint_id: string
|
||||
@@ -52,10 +56,12 @@ export interface VideoTaskRequestMetadata {
|
||||
user_agent: string
|
||||
request_id: string
|
||||
request_headers?: Record<string, string>
|
||||
poll_raw_response?: any // 轮询完成时的原始响应
|
||||
billing_snapshot?: any // 计费快照
|
||||
}
|
||||
|
||||
// 视频任务详情
|
||||
export interface VideoTaskDetail extends VideoTaskItem {
|
||||
// 异步任务详情
|
||||
export interface AsyncTaskDetail extends AsyncTaskItem {
|
||||
api_key_id: string
|
||||
endpoint_id: string
|
||||
key_id: string
|
||||
@@ -81,73 +87,76 @@ export interface VideoTaskDetail extends VideoTaskItem {
|
||||
base_url: string
|
||||
api_format: string
|
||||
} | null
|
||||
request_metadata: VideoTaskRequestMetadata | null
|
||||
request_metadata: AsyncTaskRequestMetadata | null
|
||||
}
|
||||
|
||||
// 视频任务列表响应
|
||||
export interface VideoTaskListResponse {
|
||||
items: VideoTaskItem[]
|
||||
// 异步任务列表响应
|
||||
export interface AsyncTaskListResponse {
|
||||
items: AsyncTaskItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
// 视频任务统计响应
|
||||
export interface VideoTaskStatsResponse {
|
||||
// 异步任务统计响应
|
||||
export interface AsyncTaskStatsResponse {
|
||||
total: number
|
||||
by_status: Record<VideoTaskStatus, number>
|
||||
by_status: Record<AsyncTaskStatus, number>
|
||||
by_model: Record<string, number>
|
||||
today_count: number
|
||||
active_users?: number // 仅管理员
|
||||
processing_count?: number // 仅管理员
|
||||
}
|
||||
|
||||
// 视频任务查询参数
|
||||
export interface VideoTaskQueryParams {
|
||||
status?: VideoTaskStatus
|
||||
// 异步任务查询参数
|
||||
export interface AsyncTaskQueryParams {
|
||||
status?: AsyncTaskStatus
|
||||
task_type?: AsyncTaskType
|
||||
user_id?: string
|
||||
model?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export const videoTasksApi = {
|
||||
export const asyncTasksApi = {
|
||||
/**
|
||||
* 获取视频任务列表
|
||||
* 获取异步任务列表
|
||||
*/
|
||||
async list(params: VideoTaskQueryParams = {}): Promise<VideoTaskListResponse> {
|
||||
async list(params: AsyncTaskQueryParams = {}): Promise<AsyncTaskListResponse> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.status) searchParams.append('status', params.status)
|
||||
if (params.task_type) searchParams.append('task_type', params.task_type)
|
||||
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()
|
||||
// 后端 API 路径保持不变,前端抽象为异步任务
|
||||
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> {
|
||||
async getStats(): Promise<AsyncTaskStatsResponse> {
|
||||
const response = await apiClient.get('/api/admin/video-tasks/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取视频任务详情
|
||||
* 获取异步任务详情
|
||||
*/
|
||||
async getDetail(taskId: string): Promise<VideoTaskDetail> {
|
||||
async getDetail(taskId: string): Promise<AsyncTaskDetail> {
|
||||
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`)
|
||||
@@ -155,4 +164,4 @@ export const videoTasksApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export default videoTasksApi
|
||||
export default asyncTasksApi
|
||||
@@ -340,6 +340,7 @@ export interface ProviderWithEndpointsSummary {
|
||||
website?: string
|
||||
provider_priority: number
|
||||
keep_priority_on_conversion: boolean // 格式转换时是否保持优先级
|
||||
enable_format_conversion: boolean // 是否允许格式转换(提供商级别开关)
|
||||
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
|
||||
monthly_quota_usd?: number
|
||||
monthly_used_usd?: number
|
||||
|
||||
124
frontend/src/api/gemini-files.ts
Normal file
124
frontend/src/api/gemini-files.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Gemini Files 管理 API
|
||||
*/
|
||||
|
||||
import apiClient from './client'
|
||||
|
||||
export interface FileMappingResponse {
|
||||
id: string
|
||||
file_name: string
|
||||
key_id: string
|
||||
key_name: string | null
|
||||
user_id: string | null
|
||||
username: string | null
|
||||
display_name: string | null
|
||||
mime_type: string | null
|
||||
created_at: string
|
||||
expires_at: string
|
||||
is_expired: boolean
|
||||
}
|
||||
|
||||
export interface FileMappingListResponse {
|
||||
items: FileMappingResponse[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface FileMappingStatsResponse {
|
||||
total_mappings: number
|
||||
active_mappings: number
|
||||
expired_mappings: number
|
||||
by_mime_type: Record<string, number>
|
||||
capable_keys_count: number
|
||||
}
|
||||
|
||||
export interface ListMappingsParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
include_expired?: boolean
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface CapableKeyResponse {
|
||||
id: string
|
||||
name: string
|
||||
provider_name: string | null
|
||||
}
|
||||
|
||||
export interface UploadResultItem {
|
||||
key_id: string
|
||||
key_name: string | null
|
||||
success: boolean
|
||||
file_name: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
display_name: string
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
results: UploadResultItem[]
|
||||
success_count: number
|
||||
fail_count: number
|
||||
}
|
||||
|
||||
export const geminiFilesApi = {
|
||||
/**
|
||||
* 获取文件映射统计
|
||||
*/
|
||||
async getStats(): Promise<FileMappingStatsResponse> {
|
||||
const response = await apiClient.get('/api/admin/gemini-files/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 列出文件映射
|
||||
*/
|
||||
async listMappings(params?: ListMappingsParams): Promise<FileMappingListResponse> {
|
||||
const response = await apiClient.get('/api/admin/gemini-files/mappings', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除指定映射
|
||||
*/
|
||||
async deleteMapping(mappingId: string): Promise<{ message: string; file_name: string }> {
|
||||
const response = await apiClient.delete(`/api/admin/gemini-files/mappings/${mappingId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 清理过期映射
|
||||
*/
|
||||
async cleanupExpired(): Promise<{ message: string; deleted_count: number }> {
|
||||
const response = await apiClient.delete('/api/admin/gemini-files/mappings')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取可用的 Key 列表
|
||||
*/
|
||||
async getCapableKeys(): Promise<CapableKeyResponse[]> {
|
||||
const response = await apiClient.get('/api/admin/gemini-files/capable-keys')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传文件到指定的 Keys
|
||||
*/
|
||||
async uploadFile(file: File, keyIds: string[]): Promise<UploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response = await apiClient.post(
|
||||
`/api/admin/gemini-files/upload?key_ids=${keyIds.join(',')}`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -242,25 +242,41 @@
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('update:open', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="isSaving || !canSave"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ isSaving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="isVerifying || !canVerify"
|
||||
@click="handleVerify"
|
||||
>
|
||||
{{ isVerifying ? '验证中...' : '验证' }}
|
||||
</Button>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<!-- 左侧:清除按钮(仅在已有配置时显示) -->
|
||||
<div>
|
||||
<Button
|
||||
v-if="hasExistingConfig"
|
||||
variant="destructive"
|
||||
:disabled="isClearing"
|
||||
@click="handleClear"
|
||||
>
|
||||
{{ isClearing ? '清除中...' : '清除' }}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- 右侧:验证、保存、取消按钮 -->
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="isVerifying || !canVerify"
|
||||
@click="handleVerify"
|
||||
>
|
||||
{{ isVerifying ? '验证中...' : '验证' }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="isSaving || !canSave"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ isSaving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('update:open', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -281,8 +297,9 @@ import {
|
||||
SelectValue,
|
||||
Switch,
|
||||
} from '@/components/ui'
|
||||
import { saveProviderOpsConfig, verifyProviderAuth, getProviderOpsConfig } from '@/api/providerOps'
|
||||
import { saveProviderOpsConfig, verifyProviderAuth, getProviderOpsConfig, deleteProviderOpsConfig } from '@/api/providerOps'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import {
|
||||
authTemplateRegistry,
|
||||
type AuthTemplate,
|
||||
@@ -305,11 +322,13 @@ const emit = defineEmits<{
|
||||
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cookie', 'token_cookie', 'auth_cookie', 'cookie_string', 'cookie', 'proxy_password'] as const
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
|
||||
// State
|
||||
const isSaving = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const isLoadingConfig = ref(false)
|
||||
const isClearing = ref(false)
|
||||
const verifyStatus = ref<'success' | 'error' | null>(null)
|
||||
const formChanged = ref(false)
|
||||
|
||||
@@ -543,6 +562,40 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!props.providerId) return
|
||||
|
||||
const confirmed = await confirmDanger(
|
||||
'确定要清除该提供商的认证配置吗?清除后将无法进行余额查询、签到等操作。',
|
||||
'清除认证',
|
||||
'清除'
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
isClearing.value = true
|
||||
try {
|
||||
const result = await deleteProviderOpsConfig(props.providerId)
|
||||
if (result.success) {
|
||||
showSuccess(result.message || '认证信息已清除', '清除成功')
|
||||
// 重置状态
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
verifyStatus.value = null
|
||||
formChanged.value = false
|
||||
selectedTemplateId.value = 'new_api'
|
||||
resetFormData()
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
} else {
|
||||
showError(result.message || '清除失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
showError(error.response?.data?.detail || error.message, '清除失败')
|
||||
} finally {
|
||||
isClearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromConfig(config: any) {
|
||||
if (!config?.connector) return
|
||||
|
||||
|
||||
@@ -55,6 +55,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:title="provider.enable_format_conversion ? '已启用格式转换(点击关闭)' : '启用格式转换'"
|
||||
:class="provider.enable_format_conversion ? 'text-primary' : ''"
|
||||
@click="toggleFormatConversion"
|
||||
>
|
||||
<Shuffle class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -502,7 +511,8 @@ import {
|
||||
Power,
|
||||
GripVertical,
|
||||
Copy,
|
||||
Shield
|
||||
Shield,
|
||||
Shuffle
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -511,7 +521,7 @@ import Card from '@/components/ui/card.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
|
||||
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
||||
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints'
|
||||
import {
|
||||
KeyFormDialog,
|
||||
KeyAllowedModelsEditDialog,
|
||||
@@ -702,6 +712,20 @@ function handleClose() {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换格式转换开关
|
||||
async function toggleFormatConversion() {
|
||||
if (!provider.value) return
|
||||
const newValue = !provider.value.enable_format_conversion
|
||||
try {
|
||||
await updateProvider(provider.value.id, { enable_format_conversion: newValue })
|
||||
provider.value.enable_format_conversion = newValue
|
||||
showSuccess(newValue ? '已启用格式转换' : '已禁用格式转换')
|
||||
emit('refresh')
|
||||
} catch {
|
||||
showError('切换格式转换失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示端点管理对话框
|
||||
function showAddEndpointDialog() {
|
||||
endpointDialogOpen.value = true
|
||||
|
||||
@@ -366,6 +366,8 @@ import {
|
||||
Mail,
|
||||
Puzzle,
|
||||
Video,
|
||||
Zap,
|
||||
FileUp,
|
||||
type LucideIcon,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
@@ -511,6 +513,27 @@ const navigation = computed(() => {
|
||||
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
||||
]
|
||||
|
||||
// 动态添加已激活模块的菜单项
|
||||
// 图标映射
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
'Key': Key,
|
||||
'FileUp': FileUp,
|
||||
'Shield': Shield,
|
||||
'Puzzle': Puzzle,
|
||||
}
|
||||
|
||||
// 添加模块菜单项(按 admin_menu_order 排序,只显示已激活的)
|
||||
const moduleMenuItems = Object.values(moduleStore.modules)
|
||||
.filter(m => m.active && m.admin_route && m.admin_menu_group === 'system')
|
||||
.sort((a, b) => a.admin_menu_order - b.admin_menu_order)
|
||||
.map(m => ({
|
||||
name: m.display_name,
|
||||
href: m.admin_route!,
|
||||
icon: iconMap[m.admin_menu_icon || ''] || Puzzle
|
||||
}))
|
||||
|
||||
systemItems.push(...moduleMenuItems)
|
||||
|
||||
// 模块管理和系统设置放在最后
|
||||
systemItems.push({ name: '模块管理', href: '/admin/modules', icon: Puzzle })
|
||||
systemItems.push({ name: '系统设置', href: '/admin/system', icon: Cog })
|
||||
@@ -531,7 +554,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/async-tasks', icon: Zap },
|
||||
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
|
||||
]
|
||||
},
|
||||
@@ -582,6 +605,18 @@ const breadcrumbs = computed((): BreadcrumbItem[] => {
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: module pages not in navigation (module not active)
|
||||
// Check if current path matches a module's admin_route
|
||||
const currentModule = Object.values(moduleStore.modules).find(
|
||||
m => m.admin_route && route.path === m.admin_route
|
||||
)
|
||||
if (currentModule) {
|
||||
return [
|
||||
{ label: '模块管理', href: '/admin/modules' },
|
||||
{ label: currentModule.display_name }
|
||||
]
|
||||
}
|
||||
|
||||
return [{ label: '仪表盘' }]
|
||||
})
|
||||
|
||||
|
||||
@@ -208,10 +208,20 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'AnnouncementManagement',
|
||||
component: () => importWithRetry(() => import('@/views/user/Announcements.vue'))
|
||||
},
|
||||
{
|
||||
path: 'async-tasks',
|
||||
name: 'AsyncTasks',
|
||||
component: () => importWithRetry(() => import('@/views/admin/AsyncTasks.vue'))
|
||||
},
|
||||
{
|
||||
path: 'gemini-files',
|
||||
name: 'GeminiFilesManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/GeminiFilesManagement.vue'))
|
||||
},
|
||||
// 保留旧路由兼容性
|
||||
{
|
||||
path: 'video-tasks',
|
||||
name: 'VideoTasks',
|
||||
component: () => importWithRetry(() => import('@/views/admin/VideoTasks.vue'))
|
||||
redirect: '/admin/async-tasks'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
570
frontend/src/views/admin/GeminiFilesManagement.vue
Normal file
570
frontend/src/views/admin/GeminiFilesManagement.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user