mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-05 09:12:27 +08:00
Initial commit
This commit is contained in:
378
frontend/src/views/admin/AliasManagement.vue
Normal file
378
frontend/src/views/admin/AliasManagement.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 搜索和过滤区域 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-base font-semibold">别名管理</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="alias-search"
|
||||
v-model="aliasesSearch"
|
||||
placeholder="搜索别名或关联模型"
|
||||
class="w-44 pl-8 pr-3 h-8 text-sm border-border/60 focus-visible:ring-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 提供商过滤器 -->
|
||||
<Select v-model:open="aliasProviderSelectOpen" :model-value="aliasProviderFilter" @update:model-value="aliasProviderFilter = $event">
|
||||
<SelectTrigger class="w-40 h-8 text-xs border-border/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部别名</SelectItem>
|
||||
<SelectItem value="global">仅全局别名</SelectItem>
|
||||
<SelectItem v-for="provider in providers" :key="provider.id" :value="provider.id">
|
||||
{{ provider.display_name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="openCreateAliasDialog"
|
||||
title="新建别名"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton :loading="loadingAliases" @click="loadAliases" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadingAliases" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="w-10 h-10 animate-spin text-primary" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<Table class="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[200px]">别名</TableHead>
|
||||
<TableHead class="w-[280px]">关联模型</TableHead>
|
||||
<TableHead class="w-[70px] text-center">类型</TableHead>
|
||||
<TableHead class="w-[100px] text-center">作用域</TableHead>
|
||||
<TableHead class="w-[70px] text-center">状态</TableHead>
|
||||
<TableHead class="w-[100px] text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="filteredAliases.length === 0">
|
||||
<TableCell colspan="6" class="text-center py-8 text-muted-foreground">
|
||||
{{ aliasProviderFilter === 'global' ? '暂无全局别名' : '暂无别名' }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="alias in paginatedAliases" :key="alias.id">
|
||||
<TableCell>
|
||||
<span class="font-mono font-medium">{{ alias.alias }}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-medium">{{ alias.global_model_display_name || alias.global_model_name }}</span>
|
||||
<span class="text-xs text-muted-foreground font-mono">{{ alias.global_model_name }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge
|
||||
v-if="alias.provider_id"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ alias.provider_name || 'Provider 特定' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
variant="default"
|
||||
class="text-xs"
|
||||
>
|
||||
全局
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge :variant="alias.is_active ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ alias.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="openEditAliasDialog(alias)"
|
||||
title="编辑别名"
|
||||
>
|
||||
<Edit class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="toggleAliasStatus(alias)"
|
||||
:title="alias.is_active ? '停用别名' : '启用别名'"
|
||||
>
|
||||
<Power class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="confirmDeleteAlias(alias)"
|
||||
title="删除别名"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="!loadingAliases && filteredAliases.length > 0"
|
||||
:current="aliasesCurrentPage"
|
||||
:total="filteredAliases.length"
|
||||
:page-size="aliasesPageSize"
|
||||
@update:current="aliasesCurrentPage = $event"
|
||||
@update:page-size="aliasesPageSize = $event"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 创建/编辑别名对话框 -->
|
||||
<AliasDialog
|
||||
:open="createAliasDialogOpen"
|
||||
:editing-alias="editingAlias"
|
||||
:global-models="globalModels"
|
||||
:providers="providers"
|
||||
@update:open="handleAliasDialogUpdate"
|
||||
@submit="handleAliasSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Edit,
|
||||
Loader2,
|
||||
Plus,
|
||||
Power,
|
||||
Search,
|
||||
Trash2
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Badge,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
RefreshButton,
|
||||
Pagination
|
||||
} from '@/components/ui'
|
||||
import AliasDialog from '@/features/models/components/AliasDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import {
|
||||
getAliases,
|
||||
createAlias,
|
||||
updateAlias,
|
||||
deleteAlias,
|
||||
type ModelAlias,
|
||||
type CreateModelAliasRequest,
|
||||
type UpdateModelAliasRequest
|
||||
} from '@/api/endpoints/aliases'
|
||||
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
|
||||
// 状态
|
||||
const loadingAliases = ref(false)
|
||||
const submitting = ref(false)
|
||||
const aliasesSearch = ref('')
|
||||
const aliasProviderFilter = ref<string>('all')
|
||||
const aliasProviderSelectOpen = ref(false)
|
||||
const createAliasDialogOpen = ref(false)
|
||||
const editingAliasId = ref<string | null>(null)
|
||||
|
||||
// 数据
|
||||
const allAliases = ref<ModelAlias[]>([])
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const providers = ref<any[]>([])
|
||||
|
||||
// 分页
|
||||
const aliasesCurrentPage = ref(1)
|
||||
const aliasesPageSize = ref(20)
|
||||
|
||||
// 编辑中的别名对象
|
||||
const editingAlias = computed(() => {
|
||||
if (!editingAliasId.value) return null
|
||||
return allAliases.value.find(a => a.id === editingAliasId.value) || null
|
||||
})
|
||||
|
||||
// 筛选后的别名列表
|
||||
const filteredAliases = computed(() => {
|
||||
let result = allAliases.value
|
||||
|
||||
// 按 Provider 筛选
|
||||
if (aliasProviderFilter.value === 'global') {
|
||||
result = result.filter(alias => !alias.provider_id)
|
||||
} else if (aliasProviderFilter.value !== 'all') {
|
||||
result = result.filter(alias => alias.provider_id === aliasProviderFilter.value)
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选
|
||||
const keyword = aliasesSearch.value.trim().toLowerCase()
|
||||
if (keyword) {
|
||||
result = result.filter(alias =>
|
||||
alias.alias.toLowerCase().includes(keyword) ||
|
||||
alias.global_model_name?.toLowerCase().includes(keyword) ||
|
||||
alias.global_model_display_name?.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 分页计算
|
||||
const paginatedAliases = computed(() => {
|
||||
const start = (aliasesCurrentPage.value - 1) * aliasesPageSize.value
|
||||
const end = start + aliasesPageSize.value
|
||||
return filteredAliases.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 搜索或筛选变化时重置到第一页
|
||||
watch([aliasesSearch, aliasProviderFilter], () => {
|
||||
aliasesCurrentPage.value = 1
|
||||
})
|
||||
|
||||
async function loadAliases() {
|
||||
loadingAliases.value = true
|
||||
try {
|
||||
allAliases.value = await getAliases({ limit: 1000 })
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '加载别名失败')
|
||||
} finally {
|
||||
loadingAliases.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGlobalModelsList() {
|
||||
try {
|
||||
const response = await listGlobalModels()
|
||||
globalModels.value = response.models || []
|
||||
} catch (err: any) {
|
||||
console.error('加载模型失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
providers.value = await getProvidersSummary()
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '加载 Provider 列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateAliasDialog() {
|
||||
editingAliasId.value = null
|
||||
createAliasDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEditAliasDialog(alias: ModelAlias) {
|
||||
editingAliasId.value = alias.id
|
||||
createAliasDialogOpen.value = true
|
||||
}
|
||||
|
||||
function handleAliasDialogUpdate(value: boolean) {
|
||||
createAliasDialogOpen.value = value
|
||||
if (!value) {
|
||||
editingAliasId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAliasSubmit(data: CreateModelAliasRequest | UpdateModelAliasRequest, isEdit: boolean) {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit && editingAliasId.value) {
|
||||
await updateAlias(editingAliasId.value, data as UpdateModelAliasRequest)
|
||||
success(data.mapping_type === 'mapping' ? '映射已更新' : '别名已更新')
|
||||
} else {
|
||||
await createAlias(data as CreateModelAliasRequest)
|
||||
success(data.mapping_type === 'mapping' ? '映射已创建' : '别名已创建')
|
||||
}
|
||||
createAliasDialogOpen.value = false
|
||||
editingAliasId.value = null
|
||||
await loadAliases()
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail || err.message
|
||||
let errorMessage = detail
|
||||
if (detail === '映射已存在') {
|
||||
errorMessage = '目标作用域已存在同名别名,请先删除冲突的映射或选择其他作用域'
|
||||
}
|
||||
showError(errorMessage, isEdit ? '更新失败' : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteAlias(alias: ModelAlias) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除别名 "${alias.alias}" 吗?`,
|
||||
'删除别名'
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await deleteAlias(alias.id)
|
||||
success('别名已删除')
|
||||
await loadAliases()
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAliasStatus(alias: ModelAlias) {
|
||||
try {
|
||||
await updateAlias(alias.id, { is_active: !alias.is_active })
|
||||
alias.is_active = !alias.is_active
|
||||
success(alias.is_active ? '别名已启用' : '别名已停用')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || err.message, '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadAliases(),
|
||||
loadGlobalModelsList(),
|
||||
loadProviders()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
924
frontend/src/views/admin/ApiKeys.vue
Normal file
924
frontend/src/views/admin/ApiKeys.vue
Normal file
@@ -0,0 +1,924 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="py-16 text-center space-y-4">
|
||||
<Skeleton class="mx-auto h-10 w-10 rounded-full" />
|
||||
<Skeleton class="mx-auto h-4 w-32" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">独立余额 API Keys</h3>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
活跃 {{ activeKeyCount }} · 禁用 {{ inactiveKeyCount }} · 无限 Key {{ unlimitedKeyCount }}
|
||||
<span v-if="expiringSoonCount > 0" class="text-amber-600"> · 即将到期 {{ expiringSoonCount }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索..."
|
||||
class="h-8 w-40 pl-8 pr-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<Select v-model="filterStatus" v-model:open="filterStatusOpen">
|
||||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="status in statusFilters" :key="status.value" :value="status.value">
|
||||
{{ status.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 余额类型筛选 -->
|
||||
<Select v-model="filterBalance" v-model:open="filterBalanceOpen">
|
||||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="balance in balanceFilters" :key="balance.value" :value="balance.value">
|
||||
{{ balance.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 创建独立 Key 按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="openCreateDialog"
|
||||
title="创建独立 Key"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton :loading="loading" @click="loadApiKeys" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden xl:block overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[200px] h-12 font-semibold">密钥信息</TableHead>
|
||||
<TableHead class="w-[160px] h-12 font-semibold">余额 (已用/总额)</TableHead>
|
||||
<TableHead class="w-[130px] h-12 font-semibold">使用统计</TableHead>
|
||||
<TableHead class="w-[110px] h-12 font-semibold">有效期</TableHead>
|
||||
<TableHead class="w-[140px] h-12 font-semibold">最近使用</TableHead>
|
||||
<TableHead class="w-[70px] h-12 font-semibold text-center">状态</TableHead>
|
||||
<TableHead class="w-[130px] h-12 font-semibold text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="filteredApiKeys.length === 0">
|
||||
<TableCell colspan="7" class="h-64 text-center">
|
||||
<div class="flex flex-col items-center justify-center space-y-4">
|
||||
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Key class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div v-if="hasActiveFilters">
|
||||
<h3 class="text-lg font-semibold">未找到匹配的 Key</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
尝试调整筛选条件
|
||||
</p>
|
||||
<Button variant="outline" size="sm" class="mt-3" @click="clearFilters">
|
||||
清除筛选
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h3 class="text-lg font-semibold">暂无独立余额 Key</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
点击右上角按钮创建独立余额 Key
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="apiKey in filteredApiKeys" :key="apiKey.id" class="border-b border-border/40 hover:bg-muted/30 transition-colors">
|
||||
<TableCell class="py-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-foreground truncate" :title="apiKey.name || '未命名 Key'">
|
||||
{{ apiKey.name || '未命名 Key' }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<code class="text-xs font-mono text-muted-foreground">
|
||||
{{ apiKey.key_display || 'sk-****' }}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
@click="copyKeyPrefix(apiKey)"
|
||||
title="复制完整密钥"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="text-xs">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-mono font-medium">${{ (apiKey.balance_used_usd || 0).toFixed(2) }}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span :class="isBalanceLimited(apiKey) ? 'font-mono font-medium text-primary' : 'font-mono text-muted-foreground'">
|
||||
{{ isBalanceLimited(apiKey) ? `$${(apiKey.current_balance_usd || 0).toFixed(2)}` : '无限' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="text-muted-foreground">
|
||||
请求: <span class="font-medium text-foreground">{{ (apiKey.total_requests || 0).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
速率: <span class="font-medium text-foreground">{{ apiKey.rate_limit ? `${apiKey.rate_limit}/min` : '未设置' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="text-xs">
|
||||
<div v-if="apiKey.expires_at" class="space-y-1">
|
||||
<div class="text-foreground">{{ formatDate(apiKey.expires_at) }}</div>
|
||||
<div class="text-muted-foreground">{{ getRelativeTime(apiKey.expires_at) }}</div>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground">永不过期</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="text-xs">
|
||||
<span v-if="apiKey.last_used_at" class="text-foreground">{{ formatDate(apiKey.last_used_at) }}</span>
|
||||
<span v-else class="text-muted-foreground">暂无记录</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge :variant="apiKey.is_active ? 'success' : 'destructive'" class="font-medium">
|
||||
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="editApiKey(apiKey)"
|
||||
title="编辑"
|
||||
>
|
||||
<SquarePen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="openAddBalanceDialog(apiKey)"
|
||||
title="调整余额"
|
||||
>
|
||||
<DollarSign class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="toggleApiKey(apiKey)"
|
||||
:title="apiKey.is_active ? '禁用' : '启用'"
|
||||
>
|
||||
<Power class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="deleteApiKey(apiKey)"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="xl:hidden divide-y divide-border/40">
|
||||
<div v-if="apiKeys.length === 0" class="p-8 text-center">
|
||||
<Key class="h-12 w-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p class="text-muted-foreground">暂无独立余额 Key</p>
|
||||
</div>
|
||||
<div v-for="apiKey in apiKeys" :key="apiKey.id" class="p-4 sm:p-5 hover:bg-muted/30 transition-colors">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="inline-flex rounded-lg bg-muted px-3 py-1.5 text-xs font-mono font-semibold">
|
||||
{{ apiKey.key_display || 'sk-****' }}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 hover:bg-muted flex-shrink-0"
|
||||
@click="copyKeyPrefix(apiKey)"
|
||||
title="复制完整密钥"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-foreground" :class="{ 'text-muted-foreground': !apiKey.name }">
|
||||
{{ apiKey.name || '未命名 Key' }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge :variant="apiKey.is_active ? 'success' : 'destructive'" class="text-xs flex-shrink-0">
|
||||
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<span class="inline-flex items-center gap-1 rounded-full border border-border/60 px-2.5 py-0.5">
|
||||
{{ isBalanceLimited(apiKey) ? '限额 Key' : '无限额度' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="apiKey.auto_delete_on_expiry"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5"
|
||||
>
|
||||
过期自动删除
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 p-3 bg-muted/50 rounded-lg text-xs">
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<span>已用</span>
|
||||
<span class="font-semibold">${{ (apiKey.balance_used_usd || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<span>剩余</span>
|
||||
<span :class="getBalanceRemaining(apiKey) > 0 ? 'font-semibold text-emerald-600' : 'font-semibold text-rose-600'">
|
||||
{{ isBalanceLimited(apiKey) ? `$${getBalanceRemaining(apiKey).toFixed(2)}` : '无限制' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-amber-600">
|
||||
<span>总费用</span>
|
||||
<span>${{ (apiKey.total_cost_usd || 0).toFixed(4) }}</span>
|
||||
</div>
|
||||
<div v-if="isBalanceLimited(apiKey)" class="h-1.5 rounded-full bg-background/40 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-emerald-500"
|
||||
:style="{ width: `${getBalanceProgress(apiKey)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="p-2 bg-muted/40 rounded-lg">
|
||||
<div class="text-muted-foreground mb-1">速率限制</div>
|
||||
<div class="font-semibold">{{ apiKey.rate_limit ? `${apiKey.rate_limit}/min` : '未设置' }}</div>
|
||||
</div>
|
||||
<div class="p-2 bg-muted/40 rounded-lg">
|
||||
<div class="text-muted-foreground mb-1">请求次数</div>
|
||||
<div class="font-semibold">{{ (apiKey.total_requests || 0).toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="p-2 bg-muted/40 rounded-lg col-span-2">
|
||||
<div class="text-muted-foreground mb-1">有效期</div>
|
||||
<div class="font-semibold">
|
||||
{{ apiKey.expires_at ? formatDate(apiKey.expires_at) : '永不过期' }}
|
||||
</div>
|
||||
<div v-if="apiKey.expires_at" class="text-[11px] text-muted-foreground">
|
||||
{{ getRelativeTime(apiKey.expires_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<p>创建: {{ formatDate(apiKey.created_at) }}</p>
|
||||
<p>
|
||||
最近使用:
|
||||
<span v-if="apiKey.last_used_at" class="font-medium text-foreground">{{ formatDate(apiKey.last_used_at) }}</span>
|
||||
<span v-else>暂无记录</span>
|
||||
</p>
|
||||
<p v-if="apiKey.expires_at">过期后: {{ apiKey.auto_delete_on_expiry ? '自动删除' : '仅禁用' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="editApiKey(apiKey)"
|
||||
>
|
||||
<SquarePen class="h-3.5 w-3.5 mr-1.5" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-blue-600"
|
||||
@click="openAddBalanceDialog(apiKey)"
|
||||
>
|
||||
<DollarSign class="h-3.5 w-3.5 mr-1.5" />
|
||||
调整
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="toggleApiKey(apiKey)"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5 mr-1.5" />
|
||||
{{ apiKey.is_active ? '禁用' : '启用' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-rose-600"
|
||||
@click="deleteApiKey(apiKey)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5 mr-1.5" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="!loading && apiKeys.length > 0"
|
||||
:current="currentPage"
|
||||
:total="total"
|
||||
:page-size="limit"
|
||||
:show-page-size-selector="false"
|
||||
@update:current="handlePageChange"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 创建/编辑独立Key对话框 -->
|
||||
<StandaloneKeyFormDialog
|
||||
:open="showKeyFormDialog"
|
||||
:api-key="editingKeyData"
|
||||
@close="closeKeyFormDialog"
|
||||
@submit="handleKeyFormSubmit"
|
||||
ref="keyFormDialogRef"
|
||||
/>
|
||||
|
||||
<!-- 新 Key 显示对话框 -->
|
||||
<Dialog v-model="showNewKeyDialog" size="lg">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
|
||||
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">创建成功</h3>
|
||||
<p class="text-xs text-muted-foreground">请妥善保管, 切勿泄露给他人.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">API Key</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
:value="newKeyValue"
|
||||
readonly
|
||||
class="flex-1 font-mono text-sm bg-muted/50 h-11"
|
||||
@click="selectKey"
|
||||
ref="keyInput"
|
||||
/>
|
||||
<Button @click="copyKey" class="h-11">
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="closeNewKeyDialog" class="h-10 px-5">确定</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 余额调整对话框 -->
|
||||
<Dialog v-model="showAddBalanceDialog" size="md">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30 flex-shrink-0">
|
||||
<DollarSign class="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">余额调整</h3>
|
||||
<p class="text-xs text-muted-foreground">增加或扣除 API Key 余额</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="p-3 bg-muted/50 rounded-lg text-sm">
|
||||
<div class="font-medium mb-2">当前余额信息</div>
|
||||
<div class="space-y-1 text-xs text-muted-foreground">
|
||||
<div>已用: <span class="font-semibold text-foreground">${{ (addBalanceKey.balance_used_usd || 0).toFixed(2) }}</span></div>
|
||||
<div>当前余额: <span class="font-semibold text-foreground">${{ (addBalanceKey.current_balance_usd || 0).toFixed(2) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="addBalanceAmount" class="text-sm font-medium">调整金额 (USD)</Label>
|
||||
<Input
|
||||
id="addBalanceAmount"
|
||||
:model-value="addBalanceAmount ?? ''"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="正数为增加,负数为扣除"
|
||||
class="h-11"
|
||||
@update:model-value="(v) => addBalanceAmount = parseNumberInput(v, { allowFloat: true })"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span v-if="addBalanceAmount && addBalanceAmount > 0" class="text-emerald-600">
|
||||
增加 ${{ addBalanceAmount.toFixed(2) }},调整后余额: ${{ ((addBalanceKey.current_balance_usd || 0) + addBalanceAmount).toFixed(2) }}
|
||||
</span>
|
||||
<span v-else-if="addBalanceAmount && addBalanceAmount < 0" class="text-rose-600">
|
||||
扣除 ${{ Math.abs(addBalanceAmount).toFixed(2) }},调整后余额: ${{ Math.max(0, (addBalanceKey.current_balance_usd || 0) + addBalanceAmount).toFixed(2) }}
|
||||
</span>
|
||||
<span v-else class="text-muted-foreground">
|
||||
输入正数增加余额,负数扣除余额
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="outline" @click="showAddBalanceDialog = false" class="h-10 px-5">
|
||||
取消
|
||||
</Button>
|
||||
<Button @click="handleAddBalance" :disabled="addingBalance || !addBalanceAmount || addBalanceAmount === 0" class="h-10 px-5">
|
||||
{{ addingBalance ? '调整中...' : '确认调整' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
Input,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Label
|
||||
} from '@/components/ui'
|
||||
|
||||
import {
|
||||
Plus,
|
||||
Key,
|
||||
Trash2,
|
||||
Power,
|
||||
DollarSign,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
SquarePen,
|
||||
Search
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
|
||||
const apiKeys = ref<AdminApiKey[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const limit = ref(100)
|
||||
const showNewKeyDialog = ref(false)
|
||||
const newKeyValue = ref('')
|
||||
const keyInput = ref<HTMLInputElement>()
|
||||
|
||||
// 统一的表单对话框状态
|
||||
const showKeyFormDialog = ref(false)
|
||||
const editingKeyData = ref<StandaloneKeyFormData | null>(null)
|
||||
const keyFormDialogRef = ref<InstanceType<typeof StandaloneKeyFormDialog>>()
|
||||
|
||||
const EXPIRY_SOON_DAYS = 7
|
||||
|
||||
// 筛选相关
|
||||
const searchQuery = ref('')
|
||||
const filterStatus = ref<'all' | 'active' | 'inactive'>('all')
|
||||
const filterStatusOpen = ref(false)
|
||||
const filterBalance = ref<'all' | 'limited' | 'unlimited'>('all')
|
||||
const filterBalanceOpen = ref(false)
|
||||
|
||||
const statusFilters = [
|
||||
{ value: 'all' as const, label: '全部状态' },
|
||||
{ value: 'active' as const, label: '活跃' },
|
||||
{ value: 'inactive' as const, label: '禁用' }
|
||||
]
|
||||
|
||||
const balanceFilters = [
|
||||
{ value: 'all' as const, label: '全部类型' },
|
||||
{ value: 'limited' as const, label: '限额' },
|
||||
{ value: 'unlimited' as const, label: '无限' }
|
||||
]
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return searchQuery.value !== '' || filterStatus.value !== 'all' || filterBalance.value !== 'all'
|
||||
})
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery.value = ''
|
||||
filterStatus.value = 'all'
|
||||
filterBalance.value = 'all'
|
||||
}
|
||||
|
||||
const skip = computed(() => (currentPage.value - 1) * limit.value)
|
||||
|
||||
const activeKeyCount = computed(() => apiKeys.value.filter(key => key.is_active).length)
|
||||
const inactiveKeyCount = computed(() => Math.max(0, apiKeys.value.length - activeKeyCount.value))
|
||||
const limitedKeyCount = computed(() => apiKeys.value.filter(isBalanceLimited).length)
|
||||
const unlimitedKeyCount = computed(() => Math.max(0, apiKeys.value.length - limitedKeyCount.value))
|
||||
const expiringSoonCount = computed(() => apiKeys.value.filter(key => isExpiringSoon(key)).length)
|
||||
|
||||
// 筛选后的 API Keys
|
||||
const filteredApiKeys = computed(() => {
|
||||
let result = apiKeys.value
|
||||
|
||||
// 搜索筛选
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(key =>
|
||||
(key.name && key.name.toLowerCase().includes(query)) ||
|
||||
(key.key_display && key.key_display.toLowerCase().includes(query)) ||
|
||||
(key.username && key.username.toLowerCase().includes(query)) ||
|
||||
(key.user_email && key.user_email.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
// 状态筛选
|
||||
if (filterStatus.value === 'active') {
|
||||
result = result.filter(key => key.is_active)
|
||||
} else if (filterStatus.value === 'inactive') {
|
||||
result = result.filter(key => !key.is_active)
|
||||
}
|
||||
|
||||
// 余额类型筛选
|
||||
if (filterBalance.value === 'limited') {
|
||||
result = result.filter(isBalanceLimited)
|
||||
} else if (filterBalance.value === 'unlimited') {
|
||||
result = result.filter(key => !isBalanceLimited(key))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 充值相关状态
|
||||
const showAddBalanceDialog = ref(false)
|
||||
const addBalanceKey = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
balance_used_usd: 0,
|
||||
current_balance_usd: 0
|
||||
})
|
||||
const addBalanceAmount = ref<number | undefined>(undefined)
|
||||
const addingBalance = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadApiKeys()
|
||||
})
|
||||
|
||||
async function loadApiKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminApi.getAllApiKeys({
|
||||
skip: skip.value,
|
||||
limit: limit.value
|
||||
})
|
||||
apiKeys.value = response.api_keys
|
||||
total.value = response.total
|
||||
} catch (err: any) {
|
||||
console.error('加载独立Keys失败:', err)
|
||||
error(err.response?.data?.detail || '加载独立 Keys 失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
loadApiKeys()
|
||||
}
|
||||
|
||||
async function toggleApiKey(apiKey: AdminApiKey) {
|
||||
try {
|
||||
const response = await adminApi.toggleApiKey(apiKey.id)
|
||||
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
|
||||
if (index !== -1) {
|
||||
apiKeys.value[index].is_active = response.is_active
|
||||
}
|
||||
success(response.message)
|
||||
} catch (err: any) {
|
||||
console.error('切换密钥状态失败:', err)
|
||||
error(err.response?.data?.detail || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApiKey(apiKey: AdminApiKey) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除这个独立余额 Key 吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`,
|
||||
'删除独立 Key'
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const response = await adminApi.deleteApiKey(apiKey.id)
|
||||
apiKeys.value = apiKeys.value.filter(k => k.id !== apiKey.id)
|
||||
total.value = total.value - 1
|
||||
success(response.message)
|
||||
} catch (err: any) {
|
||||
console.error('删除密钥失败:', err)
|
||||
error(err.response?.data?.detail || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function editApiKey(apiKey: AdminApiKey) {
|
||||
// 计算过期天数
|
||||
let expireDays: number | undefined = undefined
|
||||
let neverExpire = true
|
||||
|
||||
if (apiKey.expires_at) {
|
||||
const expiresDate = new Date(apiKey.expires_at)
|
||||
const now = new Date()
|
||||
const diffMs = expiresDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays > 0) {
|
||||
expireDays = diffDays
|
||||
neverExpire = false
|
||||
}
|
||||
}
|
||||
|
||||
editingKeyData.value = {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name || '',
|
||||
expire_days: expireDays,
|
||||
never_expire: neverExpire,
|
||||
rate_limit: apiKey.rate_limit || 100,
|
||||
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
|
||||
allowed_providers: apiKey.allowed_providers || [],
|
||||
allowed_api_formats: apiKey.allowed_api_formats || [],
|
||||
allowed_models: apiKey.allowed_models || []
|
||||
}
|
||||
|
||||
showKeyFormDialog.value = true
|
||||
}
|
||||
|
||||
function openAddBalanceDialog(apiKey: AdminApiKey) {
|
||||
addBalanceKey.value = {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name || apiKey.key_display || 'sk-****',
|
||||
balance_used_usd: apiKey.balance_used_usd || 0,
|
||||
current_balance_usd: apiKey.current_balance_usd || 0
|
||||
}
|
||||
|
||||
addBalanceAmount.value = undefined
|
||||
showAddBalanceDialog.value = true
|
||||
}
|
||||
|
||||
async function handleAddBalance() {
|
||||
if (!addBalanceAmount.value || addBalanceAmount.value === 0) {
|
||||
error('调整金额不能为 0')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证扣除金额不能超过当前余额
|
||||
if (addBalanceAmount.value < 0 && Math.abs(addBalanceAmount.value) > (addBalanceKey.value.current_balance_usd || 0)) {
|
||||
error('扣除金额不能超过当前余额')
|
||||
return
|
||||
}
|
||||
|
||||
addingBalance.value = true
|
||||
try {
|
||||
const response = await adminApi.addApiKeyBalance(addBalanceKey.value.id, addBalanceAmount.value)
|
||||
|
||||
// 重新加载列表
|
||||
await loadApiKeys()
|
||||
|
||||
showAddBalanceDialog.value = false
|
||||
const action = addBalanceAmount.value > 0 ? '增加' : '扣除'
|
||||
const amount = Math.abs(addBalanceAmount.value).toFixed(2)
|
||||
success(response.message || `余额${action}成功,${action} $${amount}`)
|
||||
} catch (err: any) {
|
||||
console.error('余额调整失败:', err)
|
||||
error(err.response?.data?.detail || '调整失败')
|
||||
} finally {
|
||||
addingBalance.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectKey() {
|
||||
keyInput.value?.select()
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newKeyValue.value)
|
||||
success('API Key 已复制到剪贴板')
|
||||
} catch (err) {
|
||||
error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKeyPrefix(apiKey: AdminApiKey) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||
await navigator.clipboard.writeText(response.key)
|
||||
success('完整密钥已复制到剪贴板')
|
||||
} catch (err) {
|
||||
console.error('复制密钥失败:', err)
|
||||
error('复制失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
function closeNewKeyDialog() {
|
||||
showNewKeyDialog.value = false
|
||||
newKeyValue.value = ''
|
||||
}
|
||||
|
||||
function isBalanceLimited(apiKey: AdminApiKey): boolean {
|
||||
return apiKey.current_balance_usd !== null && apiKey.current_balance_usd !== undefined
|
||||
}
|
||||
|
||||
function getBalanceProgress(apiKey: AdminApiKey): number {
|
||||
if (!isBalanceLimited(apiKey)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 总额 = 当前余额 + 已使用
|
||||
const used = apiKey.balance_used_usd || 0
|
||||
const remaining = apiKey.current_balance_usd || 0
|
||||
const total = used + remaining
|
||||
|
||||
if (total <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 进度条显示剩余比例(绿色部分)
|
||||
const ratio = (remaining / total) * 100
|
||||
const normalized = Number.isFinite(ratio) ? ratio : 0
|
||||
return Math.max(0, Math.min(100, normalized))
|
||||
}
|
||||
|
||||
function isExpiringSoon(apiKey: AdminApiKey): boolean {
|
||||
if (!apiKey.expires_at) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expiresAt = new Date(apiKey.expires_at).getTime()
|
||||
const now = Date.now()
|
||||
const diffDays = (expiresAt - now) / (1000 * 60 * 60 * 24)
|
||||
return diffDays > 0 && diffDays <= EXPIRY_SOON_DAYS
|
||||
}
|
||||
|
||||
function getBalanceRemaining(apiKey: AdminApiKey): number {
|
||||
// 计算剩余余额 = 当前余额 - 已使用余额
|
||||
if (apiKey.current_balance_usd === null || apiKey.current_balance_usd === undefined) {
|
||||
return 0
|
||||
}
|
||||
const remaining = apiKey.current_balance_usd - (apiKey.balance_used_usd || 0)
|
||||
return Math.max(0, remaining) // 不能为负数
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function getRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = date.getTime() - now.getTime()
|
||||
|
||||
if (diff < 0) return '已过期'
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
|
||||
if (days > 0) return `${days}天后过期`
|
||||
if (hours > 0) return `${hours}小时后过期`
|
||||
return '即将过期'
|
||||
}
|
||||
|
||||
// ========== 统一表单对话框方法 ==========
|
||||
|
||||
// 打开创建对话框
|
||||
function openCreateDialog() {
|
||||
editingKeyData.value = null
|
||||
showKeyFormDialog.value = true
|
||||
}
|
||||
|
||||
// 关闭表单对话框
|
||||
function closeKeyFormDialog() {
|
||||
showKeyFormDialog.value = false
|
||||
editingKeyData.value = null
|
||||
}
|
||||
|
||||
// 统一处理表单提交
|
||||
async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
|
||||
keyFormDialogRef.value?.setSaving(true)
|
||||
try {
|
||||
if (data.id) {
|
||||
// 更新
|
||||
const updateData: Partial<CreateStandaloneApiKeyRequest> = {
|
||||
name: data.name || undefined,
|
||||
rate_limit: data.rate_limit,
|
||||
expire_days: data.never_expire ? null : (data.expire_days || null),
|
||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
|
||||
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
|
||||
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
|
||||
}
|
||||
await adminApi.updateApiKey(data.id, updateData)
|
||||
success('API Key 更新成功')
|
||||
} else {
|
||||
// 创建
|
||||
if (!data.initial_balance_usd || data.initial_balance_usd <= 0) {
|
||||
error('初始余额必须大于 0')
|
||||
return
|
||||
}
|
||||
const createData: CreateStandaloneApiKeyRequest = {
|
||||
name: data.name || undefined,
|
||||
initial_balance_usd: data.initial_balance_usd,
|
||||
rate_limit: data.rate_limit,
|
||||
expire_days: data.never_expire ? null : (data.expire_days || null),
|
||||
auto_delete_on_expiry: data.auto_delete_on_expiry,
|
||||
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
|
||||
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
|
||||
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
|
||||
}
|
||||
const response = await adminApi.createStandaloneApiKey(createData)
|
||||
newKeyValue.value = response.key
|
||||
showNewKeyDialog.value = true
|
||||
success('独立 Key 创建成功')
|
||||
}
|
||||
closeKeyFormDialog()
|
||||
await loadApiKeys()
|
||||
} catch (err: any) {
|
||||
console.error('保存独立Key失败:', err)
|
||||
error(err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
keyFormDialogRef.value?.setSaving(false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
564
frontend/src/views/admin/AuditLogs.vue
Normal file
564
frontend/src/views/admin/AuditLogs.vue
Normal file
@@ -0,0 +1,564 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 审计日志列表 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">审计日志</h3>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">查看系统所有操作记录</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="audit-logs-search"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索用户ID..."
|
||||
class="w-64 h-8 text-sm pl-8"
|
||||
@input="handleSearchChange"
|
||||
/>
|
||||
</div>
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
<!-- 事件类型筛选 -->
|
||||
<Select
|
||||
v-model="filters.eventType"
|
||||
v-model:open="eventTypeSelectOpen"
|
||||
@update:model-value="handleEventTypeChange"
|
||||
>
|
||||
<SelectTrigger class="w-40 h-8 border-border/60">
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">全部类型</SelectItem>
|
||||
<SelectItem value="login_success">登录成功</SelectItem>
|
||||
<SelectItem value="login_failed">登录失败</SelectItem>
|
||||
<SelectItem value="logout">退出登录</SelectItem>
|
||||
<SelectItem value="api_key_created">API密钥创建</SelectItem>
|
||||
<SelectItem value="api_key_deleted">API密钥删除</SelectItem>
|
||||
<SelectItem value="request_success">请求成功</SelectItem>
|
||||
<SelectItem value="request_failed">请求失败</SelectItem>
|
||||
<SelectItem value="user_created">用户创建</SelectItem>
|
||||
<SelectItem value="user_updated">用户更新</SelectItem>
|
||||
<SelectItem value="user_deleted">用户删除</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<!-- 时间范围筛选 -->
|
||||
<Select
|
||||
v-model="filtersDaysString"
|
||||
v-model:open="daysSelectOpen"
|
||||
@update:model-value="handleDaysChange"
|
||||
>
|
||||
<SelectTrigger class="w-28 h-8 border-border/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1天</SelectItem>
|
||||
<SelectItem value="7">7天</SelectItem>
|
||||
<SelectItem value="30">30天</SelectItem>
|
||||
<SelectItem value="90">90天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<!-- 重置筛选 -->
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="handleResetFilters"
|
||||
title="重置筛选"
|
||||
>
|
||||
<FilterX class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<div class="h-4 w-px bg-border" />
|
||||
<!-- 导出按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="exportLogs"
|
||||
title="导出"
|
||||
>
|
||||
<Download class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton :loading="loading" @click="refreshLogs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="logs.length === 0" class="text-center py-12 text-muted-foreground">
|
||||
暂无审计记录
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="h-12 font-semibold">时间</TableHead>
|
||||
<TableHead class="h-12 font-semibold">用户</TableHead>
|
||||
<TableHead class="h-12 font-semibold">事件类型</TableHead>
|
||||
<TableHead class="h-12 font-semibold">描述</TableHead>
|
||||
<TableHead class="h-12 font-semibold">IP地址</TableHead>
|
||||
<TableHead class="h-12 font-semibold">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="log in logs" :key="log.id" @mousedown="handleMouseDown" @click="handleRowClick($event, log)" class="cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors">
|
||||
<TableCell class="text-xs py-4">
|
||||
{{ formatDateTime(log.created_at) }}
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="py-4">
|
||||
<div v-if="log.user_id" class="flex flex-col">
|
||||
<span class="text-sm font-medium">
|
||||
{{ log.user_email || `用户 ${log.user_id}` }}
|
||||
</span>
|
||||
<span v-if="log.user_username" class="text-xs text-muted-foreground">
|
||||
{{ log.user_username }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground italic">系统</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="py-4">
|
||||
<Badge :variant="getEventTypeBadgeVariant(log.event_type)">
|
||||
<component :is="getEventTypeIcon(log.event_type)" class="h-3 w-3 mr-1" />
|
||||
{{ getEventTypeLabel(log.event_type) }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="max-w-xs truncate py-4" :title="log.description">
|
||||
{{ log.description || '无描述' }}
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="py-4">
|
||||
<span v-if="log.ip_address" class="flex items-center text-sm">
|
||||
<Globe class="h-3 w-3 mr-1 text-muted-foreground" />
|
||||
{{ log.ip_address }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="py-4">
|
||||
<Badge v-if="log.status_code" :variant="getStatusCodeVariant(log.status_code)">
|
||||
{{ log.status_code }}
|
||||
</Badge>
|
||||
<span v-else>-</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<Pagination
|
||||
:current="currentPage"
|
||||
:total="totalRecords"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50, 100]"
|
||||
@update:current="handlePageChange"
|
||||
@update:page-size="pageSize = $event; currentPage = 1; loadLogs()"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 详情对话框 (使用shadcn Dialog组件) -->
|
||||
<div v-if="selectedLog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click="closeLogDetail">
|
||||
<Card class="max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto" @click.stop>
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium">审计日志详情</h3>
|
||||
<Button variant="ghost" size="sm" @click="closeLogDetail">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label>事件类型</Label>
|
||||
<p class="mt-1 text-sm">{{ getEventTypeLabel(selectedLog.event_type) }}</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<p class="mt-1 text-sm">{{ selectedLog.description || '无描述' }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>时间</Label>
|
||||
<p class="mt-1 text-sm">{{ formatDateTime(selectedLog.created_at) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.user_id">
|
||||
<Label>用户信息</Label>
|
||||
<div class="mt-1 text-sm">
|
||||
<p class="font-medium">{{ selectedLog.user_email || `用户 ${selectedLog.user_id}` }}</p>
|
||||
<p v-if="selectedLog.user_username" class="text-muted-foreground">{{ selectedLog.user_username }}</p>
|
||||
<p class="text-xs text-muted-foreground">ID: {{ selectedLog.user_id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.ip_address">
|
||||
<Label>IP地址</Label>
|
||||
<p class="mt-1 text-sm">{{ selectedLog.ip_address }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.status_code">
|
||||
<Label>状态码</Label>
|
||||
<p class="mt-1 text-sm">{{ selectedLog.status_code }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.error_message">
|
||||
<Label>错误消息</Label>
|
||||
<p class="mt-1 text-sm text-destructive">{{ selectedLog.error_message }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLog.metadata">
|
||||
<Label>元数据</Label>
|
||||
<pre class="mt-1 text-sm bg-muted p-3 rounded-md overflow-x-auto">{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
Separator,
|
||||
Label,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Input,
|
||||
Pagination,
|
||||
RefreshButton
|
||||
} from '@/components/ui'
|
||||
import { auditApi } from '@/api/audit'
|
||||
import {
|
||||
Download,
|
||||
Shield,
|
||||
Key,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Globe,
|
||||
X,
|
||||
User,
|
||||
Settings,
|
||||
Search,
|
||||
FilterX
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
interface AuditLog {
|
||||
id: string
|
||||
event_type: string
|
||||
user_id?: number
|
||||
user_email?: string
|
||||
user_username?: string
|
||||
description: string
|
||||
ip_address?: string
|
||||
status_code?: number
|
||||
error_message?: string
|
||||
metadata?: any
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const selectedLog = ref<AuditLog | null>(null)
|
||||
|
||||
// 搜索查询
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Select open state
|
||||
const eventTypeSelectOpen = ref(false)
|
||||
const daysSelectOpen = ref(false)
|
||||
|
||||
const filters = ref({
|
||||
userId: '',
|
||||
eventType: '__all__',
|
||||
days: 7,
|
||||
limit: 50
|
||||
})
|
||||
|
||||
const filtersDaysString = ref('7')
|
||||
const filtersLimitString = ref('50')
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalRecords = ref(0)
|
||||
|
||||
let loadTimeout: number
|
||||
const debouncedLoadLogs = () => {
|
||||
clearTimeout(loadTimeout)
|
||||
loadTimeout = window.setTimeout(resetAndLoad, 500)
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return searchQuery.value !== '' ||
|
||||
filters.value.eventType !== '__all__' ||
|
||||
filters.value.days !== 7
|
||||
})
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const offset = (currentPage.value - 1) * pageSize.value
|
||||
|
||||
const filterParams = {
|
||||
user_id: filters.value.userId || undefined,
|
||||
event_type: (filters.value.eventType !== '__all__' ? filters.value.eventType : undefined),
|
||||
days: filters.value.days,
|
||||
limit: pageSize.value,
|
||||
offset: offset
|
||||
}
|
||||
|
||||
const data = await auditApi.getAuditLogs(filterParams)
|
||||
logs.value = data.items || []
|
||||
totalRecords.value = data.meta?.total ?? logs.value.length
|
||||
} catch (error) {
|
||||
console.error('获取审计日志失败:', error)
|
||||
logs.value = []
|
||||
totalRecords.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refreshLogs() {
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters.value = {
|
||||
userId: '',
|
||||
eventType: '__all__',
|
||||
days: 7,
|
||||
limit: 50
|
||||
}
|
||||
filtersDaysString.value = '7'
|
||||
filtersLimitString.value = '50'
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 搜索变化处理
|
||||
function handleSearchChange() {
|
||||
filters.value.userId = searchQuery.value
|
||||
debouncedLoadLogs()
|
||||
}
|
||||
|
||||
// 重置筛选条件
|
||||
function handleResetFilters() {
|
||||
searchQuery.value = ''
|
||||
filters.value.userId = ''
|
||||
filters.value.eventType = '__all__'
|
||||
filters.value.days = 7
|
||||
filtersDaysString.value = '7'
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
// 页码变化处理
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function handleEventTypeChange(value: string) {
|
||||
filters.value.eventType = value
|
||||
resetAndLoad()
|
||||
}
|
||||
|
||||
function handleDaysChange(value: string) {
|
||||
filtersDaysString.value = value
|
||||
filters.value.days = parseInt(value)
|
||||
resetAndLoad()
|
||||
}
|
||||
|
||||
function handleLimitChange(value: string) {
|
||||
filtersLimitString.value = value
|
||||
filters.value.limit = parseInt(value)
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function resetAndLoad() {
|
||||
currentPage.value = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
async function exportLogs() {
|
||||
try {
|
||||
let allLogs: AuditLog[] = []
|
||||
let offset = 0
|
||||
const batchSize = 500
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const data = await auditApi.getAuditLogs({
|
||||
user_id: filters.value.userId || undefined,
|
||||
event_type: filters.value.eventType !== '__all__' ? filters.value.eventType : undefined,
|
||||
days: filters.value.days,
|
||||
limit: batchSize,
|
||||
offset
|
||||
})
|
||||
|
||||
const batch = data.items || []
|
||||
allLogs = allLogs.concat(batch)
|
||||
|
||||
if (batch.length < batchSize) {
|
||||
hasMore = false
|
||||
} else {
|
||||
offset += batch.length
|
||||
hasMore = offset < (data.meta?.total ?? offset)
|
||||
}
|
||||
}
|
||||
|
||||
const csvContent = [
|
||||
['时间', '用户邮箱', '用户名', '用户ID', '事件类型', '描述', 'IP地址', '状态码', '错误消息'].join(','),
|
||||
...allLogs.map((log: AuditLog) => [
|
||||
log.created_at,
|
||||
`"${log.user_email || ''}"`,
|
||||
`"${log.user_username || ''}"`,
|
||||
log.user_id || '',
|
||||
log.event_type,
|
||||
`"${log.description || ''}"`,
|
||||
log.ip_address || '',
|
||||
log.status_code || '',
|
||||
`"${log.error_message || ''}"`
|
||||
].join(','))
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用复用的行点击逻辑
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
||||
|
||||
function handleRowClick(event: MouseEvent, log: AuditLog) {
|
||||
if (!shouldTriggerRowClick(event)) return
|
||||
showLogDetail(log)
|
||||
}
|
||||
|
||||
function showLogDetail(log: AuditLog) {
|
||||
selectedLog.value = log
|
||||
}
|
||||
|
||||
function closeLogDetail() {
|
||||
selectedLog.value = null
|
||||
}
|
||||
|
||||
function getEventTypeLabel(eventType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'login_success': '登录成功',
|
||||
'login_failed': '登录失败',
|
||||
'logout': '退出登录',
|
||||
'api_key_created': 'API密钥创建',
|
||||
'api_key_deleted': 'API密钥删除',
|
||||
'api_key_used': 'API密钥使用',
|
||||
'request_success': '请求成功',
|
||||
'request_failed': '请求失败',
|
||||
'request_rate_limited': '请求限流',
|
||||
'request_quota_exceeded': '配额超出',
|
||||
'user_created': '用户创建',
|
||||
'user_updated': '用户更新',
|
||||
'user_deleted': '用户删除',
|
||||
'provider_added': '提供商添加',
|
||||
'provider_updated': '提供商更新',
|
||||
'provider_removed': '提供商删除',
|
||||
'suspicious_activity': '可疑活动',
|
||||
'unauthorized_access': '未授权访问',
|
||||
'data_export': '数据导出',
|
||||
'config_changed': '配置变更'
|
||||
}
|
||||
return labels[eventType] || eventType
|
||||
}
|
||||
|
||||
function getEventTypeIcon(eventType: string) {
|
||||
const icons: Record<string, any> = {
|
||||
'login_success': CheckCircle,
|
||||
'login_failed': XCircle,
|
||||
'logout': User,
|
||||
'api_key_created': Key,
|
||||
'api_key_deleted': Key,
|
||||
'api_key_used': Key,
|
||||
'request_success': CheckCircle,
|
||||
'request_failed': XCircle,
|
||||
'request_rate_limited': AlertTriangle,
|
||||
'request_quota_exceeded': AlertTriangle,
|
||||
'user_created': User,
|
||||
'user_updated': User,
|
||||
'user_deleted': User,
|
||||
'provider_added': Settings,
|
||||
'provider_updated': Settings,
|
||||
'provider_removed': Settings,
|
||||
'suspicious_activity': Shield,
|
||||
'unauthorized_access': Shield,
|
||||
'data_export': Activity,
|
||||
'config_changed': Settings
|
||||
}
|
||||
return icons[eventType] || Activity
|
||||
}
|
||||
|
||||
function getEventTypeBadgeVariant(eventType: string): 'default' | 'success' | 'destructive' | 'warning' | 'secondary' {
|
||||
if (eventType.includes('success') || eventType.includes('created')) return 'success'
|
||||
if (eventType.includes('failed') || eventType.includes('deleted') || eventType.includes('unauthorized')) return 'destructive'
|
||||
if (eventType.includes('limited') || eventType.includes('exceeded') || eventType.includes('suspicious')) return 'warning'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function getStatusCodeVariant(statusCode: number): 'default' | 'success' | 'destructive' | 'warning' {
|
||||
if (statusCode < 300) return 'success'
|
||||
if (statusCode < 400) return 'default'
|
||||
if (statusCode < 500) return 'warning'
|
||||
return 'destructive'
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLogs()
|
||||
})
|
||||
</script>
|
||||
443
frontend/src/views/admin/CacheMonitoring.vue
Normal file
443
frontend/src/views/admin/CacheMonitoring.vue
Normal file
@@ -0,0 +1,443 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, onBeforeUnmount } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Pagination from '@/components/ui/pagination.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import { Trash2, Eraser, Search, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { cacheApi, type CacheStats, type CacheConfig, type UserAffinity } from '@/api/cache'
|
||||
|
||||
const stats = ref<CacheStats | null>(null)
|
||||
const config = ref<CacheConfig | null>(null)
|
||||
const loading = ref(false)
|
||||
const affinityList = ref<UserAffinity[]>([])
|
||||
const listLoading = ref(false)
|
||||
const tableKeyword = ref('')
|
||||
const matchedUserId = ref<string | null>(null)
|
||||
const clearingRowAffinityKey = ref<string | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const { success: showSuccess, error: showError, info: showInfo } = useToast()
|
||||
const { confirm: showConfirm } = useConfirm()
|
||||
const currentTime = ref(Math.floor(Date.now() / 1000))
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let skipNextKeywordWatch = false
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 计算分页后的数据
|
||||
const paginatedAffinityList = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return affinityList.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 页码变化处理
|
||||
function handlePageChange() {
|
||||
// 分页变化时滚动到顶部
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// 获取缓存统计
|
||||
async function fetchCacheStats() {
|
||||
loading.value = true
|
||||
try {
|
||||
stats.value = await cacheApi.getStats()
|
||||
} catch (error) {
|
||||
showError('获取缓存统计失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取缓存配置
|
||||
async function fetchCacheConfig() {
|
||||
try {
|
||||
config.value = await cacheApi.getConfig()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取缓存亲和性列表
|
||||
async function fetchAffinityList(keyword?: string) {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const response = await cacheApi.listAffinities(keyword)
|
||||
affinityList.value = response.items
|
||||
matchedUserId.value = response.matched_user_id ?? null
|
||||
|
||||
if (keyword && response.total === 0) {
|
||||
showInfo('未找到匹配的缓存记录')
|
||||
}
|
||||
} catch (error) {
|
||||
showError('获取缓存列表失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAffinitySearch() {
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
|
||||
if (!tableKeyword.value) {
|
||||
currentPage.value = 1
|
||||
await fetchAffinityList()
|
||||
return
|
||||
}
|
||||
|
||||
skipNextKeywordWatch = true
|
||||
tableKeyword.value = ''
|
||||
currentPage.value = 1
|
||||
await fetchAffinityList()
|
||||
}
|
||||
|
||||
// 清除缓存(按 affinity_key 或用户标识符)
|
||||
async function clearUserCache(identifier: string, displayName?: string) {
|
||||
const target = identifier?.trim()
|
||||
|
||||
if (!target) {
|
||||
showError('无法识别标识符')
|
||||
return
|
||||
}
|
||||
|
||||
const label = displayName || target
|
||||
|
||||
const confirmed = await showConfirm({
|
||||
title: '确认清除',
|
||||
message: `确定要清除 ${label} 的缓存吗?`,
|
||||
confirmText: '确认清除',
|
||||
variant: 'destructive'
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
clearingRowAffinityKey.value = target
|
||||
|
||||
try {
|
||||
await cacheApi.clearUserCache(target)
|
||||
showSuccess('清除成功')
|
||||
await fetchCacheStats()
|
||||
await fetchAffinityList(tableKeyword.value.trim() || undefined)
|
||||
} catch (error) {
|
||||
showError('清除失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
clearingRowAffinityKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有缓存
|
||||
async function clearAllCache() {
|
||||
const firstConfirm = await showConfirm({
|
||||
title: '危险操作',
|
||||
message: '警告:此操作会清除所有用户的缓存亲和性,确定继续吗?',
|
||||
confirmText: '继续',
|
||||
variant: 'destructive'
|
||||
})
|
||||
|
||||
if (!firstConfirm) {
|
||||
return
|
||||
}
|
||||
|
||||
const secondConfirm = await showConfirm({
|
||||
title: '再次确认',
|
||||
message: '这将影响所有用户,请再次确认!',
|
||||
confirmText: '确认清除',
|
||||
variant: 'destructive'
|
||||
})
|
||||
|
||||
if (!secondConfirm) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await cacheApi.clearAllCache()
|
||||
showSuccess('已清除所有缓存')
|
||||
await fetchCacheStats()
|
||||
await fetchAffinityList(tableKeyword.value.trim() || undefined)
|
||||
} catch (error) {
|
||||
showError('清除失败')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算剩余时间(使用实时更新的 currentTime)
|
||||
function getRemainingTime(expireAt?: number) {
|
||||
if (!expireAt) return '未知'
|
||||
const remaining = expireAt - currentTime.value
|
||||
if (remaining <= 0) return '已过期'
|
||||
|
||||
const minutes = Math.floor(remaining / 60)
|
||||
const seconds = Math.floor(remaining % 60)
|
||||
return `${minutes}分${seconds}秒`
|
||||
}
|
||||
|
||||
// 启动倒计时定时器
|
||||
function startCountdown() {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
currentTime.value = Math.floor(Date.now() / 1000)
|
||||
|
||||
// 过滤掉已过期的项目
|
||||
const beforeCount = affinityList.value.length
|
||||
affinityList.value = affinityList.value.filter(item => {
|
||||
return item.expire_at && item.expire_at > currentTime.value
|
||||
})
|
||||
|
||||
// 如果有项目被移除,显示提示
|
||||
if (beforeCount > affinityList.value.length) {
|
||||
const removedCount = beforeCount - affinityList.value.length
|
||||
showInfo(`${removedCount} 个缓存已自动过期移除`)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 停止倒计时定时器
|
||||
function stopCountdown() {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(tableKeyword, (value) => {
|
||||
if (skipNextKeywordWatch) {
|
||||
skipNextKeywordWatch = false
|
||||
return
|
||||
}
|
||||
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
}
|
||||
|
||||
const keyword = value.trim()
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
fetchAffinityList(keyword || undefined)
|
||||
searchDebounceTimer = null
|
||||
}, 600)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchCacheStats()
|
||||
fetchCacheConfig()
|
||||
fetchAffinityList()
|
||||
startCountdown()
|
||||
})
|
||||
|
||||
// 刷新所有数据
|
||||
async function refreshData() {
|
||||
await Promise.all([
|
||||
fetchCacheStats(),
|
||||
fetchCacheConfig(),
|
||||
fetchAffinityList()
|
||||
])
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
}
|
||||
stopCountdown()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 标题 -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">缓存监控</h2>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
管理缓存亲和性,提高 Prompt Caching 命中率
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 核心指标 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<!-- 缓存命中率 -->
|
||||
<Card class="p-4">
|
||||
<div class="text-xs text-muted-foreground">命中率</div>
|
||||
<div class="text-2xl font-bold text-success mt-1">
|
||||
{{ stats ? (stats.affinity_stats.cache_hit_rate * 100).toFixed(1) : '0.0' }}%
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ stats?.affinity_stats?.cache_hits || 0 }} / {{ (stats?.affinity_stats?.cache_hits || 0) + (stats?.affinity_stats?.cache_misses || 0) }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 活跃缓存数 -->
|
||||
<Card class="p-4">
|
||||
<div class="text-xs text-muted-foreground">活跃缓存</div>
|
||||
<div class="text-2xl font-bold mt-1">
|
||||
{{ stats?.affinity_stats?.total_affinities || 0 }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
TTL {{ config?.cache_ttl_seconds || 300 }}s
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Provider切换 -->
|
||||
<Card class="p-4">
|
||||
<div class="text-xs text-muted-foreground">Provider 切换</div>
|
||||
<div class="text-2xl font-bold mt-1" :class="(stats?.affinity_stats?.provider_switches || 0) > 0 ? 'text-destructive' : ''">
|
||||
{{ stats?.affinity_stats?.provider_switches || 0 }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
Key 切换 {{ stats?.affinity_stats?.key_switches || 0 }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 预留比例 -->
|
||||
<Card class="p-4">
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-1">
|
||||
预留比例
|
||||
<Badge v-if="config?.dynamic_reservation?.enabled" variant="outline" class="text-[10px] px-1">动态</Badge>
|
||||
</div>
|
||||
<div class="text-2xl font-bold mt-1">
|
||||
<template v-if="config?.dynamic_reservation?.enabled">
|
||||
{{ (config.dynamic_reservation.config.stable_min_reservation * 100).toFixed(0) }}-{{ (config.dynamic_reservation.config.stable_max_reservation * 100).toFixed(0) }}%
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ config ? (config.cache_reservation_ratio * 100).toFixed(0) : '30' }}%
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
失效 {{ stats?.affinity_stats?.cache_invalidations || 0 }}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 缓存亲和性列表 -->
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-6 py-3 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-semibold">亲和性列表</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="cache-affinity-search"
|
||||
v-model="tableKeyword"
|
||||
placeholder="搜索用户或 Key"
|
||||
class="w-48 h-8 text-sm pl-8 pr-8"
|
||||
/>
|
||||
<button
|
||||
v-if="tableKeyword"
|
||||
type="button"
|
||||
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10"
|
||||
@click="resetAffinitySearch"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-border" />
|
||||
<Button @click="clearAllCache" variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground/70 hover:text-destructive" title="清除全部缓存">
|
||||
<Eraser class="h-4 w-4" />
|
||||
</Button>
|
||||
<RefreshButton :loading="loading || listLoading" @click="refreshData" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-28">用户</TableHead>
|
||||
<TableHead class="w-36">Key</TableHead>
|
||||
<TableHead class="w-28">Provider</TableHead>
|
||||
<TableHead class="w-40">模型</TableHead>
|
||||
<TableHead class="w-36">API 格式 / Key</TableHead>
|
||||
<TableHead class="w-20 text-center">剩余</TableHead>
|
||||
<TableHead class="w-14 text-center">次数</TableHead>
|
||||
<TableHead class="w-12 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody v-if="!listLoading && affinityList.length">
|
||||
<TableRow v-for="item in paginatedAffinityList" :key="`${item.affinity_key}-${item.endpoint_id}-${item.key_id}`">
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Badge v-if="item.is_standalone" variant="outline" class="text-warning border-warning/30 text-[10px] px-1">独立</Badge>
|
||||
<span class="text-sm font-medium truncate max-w-[90px]" :title="item.username ?? undefined">{{ item.username || '未知' }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm truncate max-w-[100px]" :title="item.user_api_key_name || undefined">{{ item.user_api_key_name || '未命名' }}</span>
|
||||
<Badge v-if="item.rate_multiplier !== 1.0" variant="outline" class="text-warning border-warning/30 text-[10px] px-2">{{ item.rate_multiplier }}x</Badge>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground font-mono">{{ item.user_api_key_prefix || '---' }}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="text-sm truncate max-w-[100px]" :title="item.provider_name || undefined">{{ item.provider_name || '未知' }}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="text-sm truncate max-w-[150px]" :title="item.model_display_name || undefined">{{ item.model_display_name || '---' }}</div>
|
||||
<div class="text-xs text-muted-foreground" :title="item.model_name || undefined">{{ item.model_name || '---' }}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="text-sm">{{ item.endpoint_api_format || '---' }}</div>
|
||||
<div class="text-xs text-muted-foreground font-mono">{{ item.key_prefix || '---' }}</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<span class="text-xs">{{ getRemainingTime(item.expire_at) }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<span class="text-sm">{{ item.request_count }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
|
||||
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
|
||||
:disabled="clearingRowAffinityKey === item.affinity_key"
|
||||
title="清除缓存"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
<TableBody v-else>
|
||||
<TableRow>
|
||||
<TableCell colspan="8" class="text-center py-6 text-sm text-muted-foreground">
|
||||
{{ listLoading ? '加载中...' : '暂无缓存记录' }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Pagination
|
||||
v-if="affinityList.length > 0"
|
||||
:current="currentPage"
|
||||
:total="affinityList.length"
|
||||
:page-size="pageSize"
|
||||
@update:current="currentPage = $event; handlePageChange()"
|
||||
@update:page-size="pageSize = $event"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
371
frontend/src/views/admin/IPSecurity.vue
Normal file
371
frontend/src/views/admin/IPSecurity.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">黑名单 IP 数量</p>
|
||||
<h3 class="text-2xl font-bold mt-2">{{ blacklistStats.total || 0 }}</h3>
|
||||
</div>
|
||||
<div class="h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<ShieldX class="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">白名单 IP 数量</p>
|
||||
<h3 class="text-2xl font-bold mt-2">{{ whitelistData.total || 0 }}</h3>
|
||||
</div>
|
||||
<div class="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<ShieldCheck class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- IP 黑名单管理 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">IP 黑名单</h3>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">管理被禁止访问的 IP 地址</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="showAddBlacklistDialog = true"
|
||||
title="添加黑名单"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton :loading="loadingBlacklist" @click="loadBlacklistStats" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingBlacklist" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-6">
|
||||
<div v-if="!blacklistStats.available" class="text-center py-8 text-muted-foreground">
|
||||
<AlertCircle class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Redis 不可用,无法管理黑名单</p>
|
||||
<p class="text-xs mt-1">{{ blacklistStats.error }}</p>
|
||||
</div>
|
||||
<div v-else-if="blacklistStats.total === 0" class="text-center py-8 text-muted-foreground">
|
||||
<ShieldX class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无黑名单 IP</p>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">
|
||||
当前共有 <span class="font-semibold text-foreground">{{ blacklistStats.total }}</span> 个 IP 在黑名单中
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- IP 白名单管理 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">IP 白名单</h3>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">管理可信任的 IP 地址(支持 CIDR 格式)</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="showAddWhitelistDialog = true"
|
||||
title="添加白名单"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton :loading="loadingWhitelist" @click="loadWhitelist" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingWhitelist" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="whitelistData.whitelist.length === 0" class="text-center py-12 text-muted-foreground">
|
||||
<ShieldCheck class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无白名单 IP</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>IP 地址 / CIDR</TableHead>
|
||||
<TableHead class="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="ip in whitelistData.whitelist" :key="ip">
|
||||
<TableCell class="font-mono text-sm">{{ ip }}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="handleRemoveFromWhitelist(ip)"
|
||||
class="h-8 px-3"
|
||||
>
|
||||
<Trash2 class="w-4 h-4 mr-1.5" />
|
||||
移除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 添加黑名单对话框 -->
|
||||
<Dialog v-model:open="showAddBlacklistDialog">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 IP 到黑名单</DialogTitle>
|
||||
<DialogDescription>
|
||||
被加入黑名单的 IP 将无法访问任何接口
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">IP 地址</label>
|
||||
<Input
|
||||
v-model="blacklistForm.ip_address"
|
||||
placeholder="例如: 192.168.1.100"
|
||||
class="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">原因</label>
|
||||
<Input
|
||||
v-model="blacklistForm.reason"
|
||||
placeholder="加入黑名单的原因"
|
||||
maxlength="200"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">过期时间(可选)</label>
|
||||
<Input
|
||||
v-model.number="blacklistForm.ttl"
|
||||
type="number"
|
||||
placeholder="留空表示永久,单位:秒"
|
||||
min="1"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
留空表示永久封禁,或输入秒数(如 3600 表示 1 小时)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" @click="showAddBlacklistDialog = false">取消</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="handleAddToBlacklist"
|
||||
:disabled="!blacklistForm.ip_address || !blacklistForm.reason"
|
||||
>
|
||||
添加到黑名单
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 添加白名单对话框 -->
|
||||
<Dialog v-model:open="showAddWhitelistDialog">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加 IP 到白名单</DialogTitle>
|
||||
<DialogDescription>
|
||||
白名单中的 IP 不受速率限制
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">IP 地址或 CIDR</label>
|
||||
<Input
|
||||
v-model="whitelistForm.ip_address"
|
||||
placeholder="例如: 192.168.1.0/24 或 192.168.1.100"
|
||||
class="font-mono"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
支持单个 IP 或 CIDR 网段格式
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" @click="showAddWhitelistDialog = false">取消</Button>
|
||||
<Button
|
||||
@click="handleAddToWhitelist"
|
||||
:disabled="!whitelistForm.ip_address"
|
||||
>
|
||||
添加到白名单
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Plus, Trash2, ShieldX, ShieldCheck, AlertCircle } from 'lucide-vue-next'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
RefreshButton
|
||||
} from '@/components/ui'
|
||||
import { blacklistApi, whitelistApi, type BlacklistStats, type WhitelistResponse } from '@/api/security'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
|
||||
// 黑名单状态
|
||||
const loadingBlacklist = ref(false)
|
||||
const blacklistStats = ref<BlacklistStats>({
|
||||
available: false,
|
||||
total: 0
|
||||
})
|
||||
const showAddBlacklistDialog = ref(false)
|
||||
const blacklistForm = ref({
|
||||
ip_address: '',
|
||||
reason: '',
|
||||
ttl: undefined as number | undefined
|
||||
})
|
||||
|
||||
// 白名单状态
|
||||
const loadingWhitelist = ref(false)
|
||||
const whitelistData = ref<WhitelistResponse>({
|
||||
whitelist: [],
|
||||
total: 0
|
||||
})
|
||||
const showAddWhitelistDialog = ref(false)
|
||||
const whitelistForm = ref({
|
||||
ip_address: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 加载黑名单统计
|
||||
*/
|
||||
async function loadBlacklistStats() {
|
||||
loadingBlacklist.value = true
|
||||
try {
|
||||
blacklistStats.value = await blacklistApi.getStats()
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '无法获取黑名单统计')
|
||||
} finally {
|
||||
loadingBlacklist.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载白名单列表
|
||||
*/
|
||||
async function loadWhitelist() {
|
||||
loadingWhitelist.value = true
|
||||
try {
|
||||
whitelistData.value = await whitelistApi.getList()
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '无法获取白名单列表')
|
||||
} finally {
|
||||
loadingWhitelist.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 IP 到黑名单
|
||||
*/
|
||||
async function handleAddToBlacklist() {
|
||||
try {
|
||||
await blacklistApi.add({
|
||||
ip_address: blacklistForm.value.ip_address,
|
||||
reason: blacklistForm.value.reason,
|
||||
ttl: blacklistForm.value.ttl
|
||||
})
|
||||
|
||||
success(`IP ${blacklistForm.value.ip_address} 已加入黑名单`)
|
||||
|
||||
showAddBlacklistDialog.value = false
|
||||
blacklistForm.value = { ip_address: '', reason: '', ttl: undefined }
|
||||
await loadBlacklistStats()
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '无法添加 IP 到黑名单')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 IP 到白名单
|
||||
*/
|
||||
async function handleAddToWhitelist() {
|
||||
try {
|
||||
await whitelistApi.add({
|
||||
ip_address: whitelistForm.value.ip_address
|
||||
})
|
||||
|
||||
success(`IP ${whitelistForm.value.ip_address} 已加入白名单`)
|
||||
|
||||
showAddWhitelistDialog.value = false
|
||||
whitelistForm.value = { ip_address: '' }
|
||||
await loadWhitelist()
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '无法添加 IP 到白名单')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从白名单移除 IP
|
||||
*/
|
||||
async function handleRemoveFromWhitelist(ip: string) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要从白名单移除 ${ip} 吗?\n\n此操作无法撤销。`,
|
||||
'移除白名单'
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await whitelistApi.remove(ip)
|
||||
|
||||
success(`IP ${ip} 已从白名单移除`)
|
||||
|
||||
await loadWhitelist()
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '无法从白名单移除 IP')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBlacklistStats()
|
||||
loadWhitelist()
|
||||
})
|
||||
</script>
|
||||
1344
frontend/src/views/admin/ModelManagement.vue
Normal file
1344
frontend/src/views/admin/ModelManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
561
frontend/src/views/admin/ProviderManagement.vue
Normal file
561
frontend/src/views/admin/ProviderManagement.vue
Normal file
@@ -0,0 +1,561 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 提供商表格 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/50">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<!-- 左侧:标题 -->
|
||||
<h3 class="text-base font-semibold text-foreground">提供商管理</h3>
|
||||
|
||||
<!-- 右侧:操作区 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="provider-search"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索提供商..."
|
||||
class="w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 调度策略 -->
|
||||
<button
|
||||
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
|
||||
@click="openPriorityDialog"
|
||||
title="点击调整调度策略"
|
||||
>
|
||||
<span class="text-muted-foreground/80">调度:</span>
|
||||
<span class="font-medium text-foreground/90">{{ priorityModeConfig.label }}</span>
|
||||
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="openAddProviderDialog"
|
||||
title="新增提供商"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton :loading="loading" @click="loadProviders" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="filteredProviders.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div class="text-muted-foreground mb-2">
|
||||
<template v-if="searchQuery">
|
||||
未找到匹配 "{{ searchQuery }}" 的提供商
|
||||
</template>
|
||||
<template v-else>
|
||||
暂无提供商,点击右上角添加
|
||||
</template>
|
||||
</div>
|
||||
<Button v-if="searchQuery" variant="outline" size="sm" @click="searchQuery = ''">
|
||||
清除搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<div v-else class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/40 hover:bg-transparent">
|
||||
<TableHead class="w-[150px] h-11 font-medium text-foreground/80">提供商信息</TableHead>
|
||||
<TableHead class="w-[100px] h-11 font-medium text-foreground/80">计费类型</TableHead>
|
||||
<TableHead class="w-[120px] h-11 font-medium text-foreground/80">官网</TableHead>
|
||||
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">资源统计</TableHead>
|
||||
<TableHead class="w-[240px] h-11 font-medium text-foreground/80">端点健康</TableHead>
|
||||
<TableHead class="w-[140px] h-11 font-medium text-foreground/80">配额/限流</TableHead>
|
||||
<TableHead class="w-[80px] h-11 font-medium text-foreground/80 text-center">状态</TableHead>
|
||||
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="provider in paginatedProviders"
|
||||
:key="provider.id"
|
||||
class="border-b border-border/30 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
@mousedown="handleMouseDown"
|
||||
@click="handleRowClick($event, provider.id)"
|
||||
>
|
||||
<TableCell class="py-3.5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-sm font-medium text-foreground">{{ provider.display_name }}</span>
|
||||
<span class="text-xs text-muted-foreground/70 font-mono">{{ provider.name }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5">
|
||||
<Badge variant="outline" class="text-xs font-normal border-border/50">
|
||||
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5">
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary/80 hover:text-primary hover:underline truncate block max-w-[100px]"
|
||||
@click.stop
|
||||
:title="provider.website"
|
||||
>
|
||||
{{ formatWebsiteDisplay(provider.website) }}
|
||||
</a>
|
||||
<span v-else class="text-xs text-muted-foreground/50">-</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 text-center">
|
||||
<div class="space-y-0.5 text-xs">
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-muted-foreground/70">端点:</span>
|
||||
<span class="font-medium text-foreground/90">{{ provider.active_endpoints }}</span>
|
||||
<span class="text-muted-foreground/50">/{{ provider.total_endpoints }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-muted-foreground/70">密钥:</span>
|
||||
<span class="font-medium text-foreground/90">{{ provider.active_keys }}</span>
|
||||
<span class="text-muted-foreground/50">/{{ provider.total_keys }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-muted-foreground/70">模型:</span>
|
||||
<span class="font-medium text-foreground/90">{{ provider.active_models }}</span>
|
||||
<span class="text-muted-foreground/50">/{{ provider.total_models }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 align-middle">
|
||||
<div
|
||||
v-if="provider.endpoint_health_details && provider.endpoint_health_details.length > 0"
|
||||
class="flex flex-wrap gap-1.5 max-w-[280px]"
|
||||
>
|
||||
<span
|
||||
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
|
||||
:key="endpoint.api_format"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md border text-[10px] font-medium tracking-wide uppercase leading-none"
|
||||
:class="getEndpointTagClass(endpoint, provider)"
|
||||
:title="getEndpointTooltip(endpoint, provider)"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="getEndpointDotColor(endpoint, provider)"
|
||||
></span>
|
||||
{{ endpoint.api_format }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-xs text-muted-foreground/50">暂无端点</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5">
|
||||
<div class="space-y-0.5 text-xs">
|
||||
<div v-if="provider.billing_type === 'monthly_quota'" class="text-muted-foreground/70">
|
||||
配额: <span class="font-semibold" :class="getQuotaUsedColorClass(provider)">${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="rpmUsage(provider)" class="flex items-center gap-1">
|
||||
<span class="text-muted-foreground/70">RPM:</span>
|
||||
<span class="font-medium text-foreground/80">{{ rpmUsage(provider) }}</span>
|
||||
</div>
|
||||
<div v-if="provider.billing_type !== 'monthly_quota' && !rpmUsage(provider)" class="text-muted-foreground/50">
|
||||
无限制
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 text-center">
|
||||
<Badge :variant="provider.is_active ? 'success' : 'secondary'" class="text-xs">
|
||||
{{ provider.is_active ? '活跃' : '已停用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5" @click.stop>
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
@click="openProviderDrawer(provider.id)"
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
@click="openEditProviderDialog(provider)"
|
||||
title="编辑提供商"
|
||||
>
|
||||
<Edit class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
@click="toggleProviderStatus(provider)"
|
||||
:title="provider.is_active ? '停用提供商' : '启用提供商'"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
|
||||
@click="handleDeleteProvider(provider)"
|
||||
title="删除提供商"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="!loading && filteredProviders.length > 0"
|
||||
:current="currentPage"
|
||||
:total="filteredProviders.length"
|
||||
:page-size="pageSize"
|
||||
@update:current="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 对话框 -->
|
||||
<ProviderFormDialog
|
||||
v-model="providerDialogOpen"
|
||||
:provider="providerToEdit"
|
||||
@provider-created="handleProviderAdded"
|
||||
@provider-updated="handleProviderUpdated"
|
||||
/>
|
||||
|
||||
<PriorityManagementDialog
|
||||
v-model="priorityDialogOpen"
|
||||
:providers="providers"
|
||||
@saved="handlePrioritySaved"
|
||||
/>
|
||||
|
||||
<ProviderDetailDrawer
|
||||
:open="providerDrawerOpen"
|
||||
:provider-id="selectedProviderId"
|
||||
@update:open="providerDrawerOpen = $event"
|
||||
@edit="openEditProviderDialog"
|
||||
@toggle-status="toggleProviderStatus"
|
||||
@refresh="loadProviders"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Eye,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
Power
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
import Pagination from '@/components/ui/pagination.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import { ProviderFormDialog, PriorityManagementDialog } from '@/features/providers/components'
|
||||
import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import {
|
||||
getProvidersSummary,
|
||||
deleteProvider,
|
||||
updateProvider,
|
||||
type ProviderWithEndpointsSummary
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const providerDialogOpen = ref(false)
|
||||
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
|
||||
const priorityDialogOpen = ref(false)
|
||||
const priorityMode = ref<'provider' | 'global_key'>('provider')
|
||||
const providerDrawerOpen = ref(false)
|
||||
const selectedProviderId = ref<string | null>(null)
|
||||
|
||||
// 搜索
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 优先级模式配置
|
||||
const priorityModeConfig = computed(() => {
|
||||
return {
|
||||
label: priorityMode.value === 'global_key' ? '全局 Key 优先' : '提供商优先'
|
||||
}
|
||||
})
|
||||
|
||||
// 筛选后的提供商列表
|
||||
const filteredProviders = computed(() => {
|
||||
let result = [...providers.value]
|
||||
|
||||
// 搜索筛选
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(p =>
|
||||
p.display_name.toLowerCase().includes(query) ||
|
||||
p.name.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// 排序
|
||||
return result.sort((a, b) => {
|
||||
// 1. 优先显示活跃的提供商
|
||||
if (a.is_active !== b.is_active) {
|
||||
return a.is_active ? -1 : 1
|
||||
}
|
||||
// 2. 按优先级排序
|
||||
if (a.provider_priority !== b.provider_priority) {
|
||||
return a.provider_priority - b.provider_priority
|
||||
}
|
||||
// 3. 按名称排序
|
||||
return a.display_name.localeCompare(b.display_name)
|
||||
})
|
||||
})
|
||||
|
||||
// 分页
|
||||
const paginatedProviders = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return filteredProviders.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 搜索时重置分页
|
||||
watch(searchQuery, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
// 加载优先级模式
|
||||
async function loadPriorityMode() {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig('provider_priority_mode')
|
||||
if (response.value) {
|
||||
priorityMode.value = response.value as 'provider' | 'global_key'
|
||||
}
|
||||
} catch {
|
||||
priorityMode.value = 'provider'
|
||||
}
|
||||
}
|
||||
|
||||
// 加载提供商列表
|
||||
async function loadProviders() {
|
||||
loading.value = true
|
||||
try {
|
||||
providers.value = await getProvidersSummary()
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '加载提供商列表失败', '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 格式化官网显示
|
||||
function formatWebsiteDisplay(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
return urlObj.hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// 端点排序
|
||||
function sortEndpoints(endpoints: any[]) {
|
||||
return [...endpoints].sort((a, b) => {
|
||||
const order = ['CLAUDE', 'OPENAI', 'CLAUDE_COMPATIBLE', 'OPENAI_COMPATIBLE', 'GEMINI', 'GEMINI_COMPATIBLE']
|
||||
return order.indexOf(a.api_format) - order.indexOf(b.api_format)
|
||||
})
|
||||
}
|
||||
|
||||
// 判断端点是否可用(有 key)
|
||||
function isEndpointAvailable(endpoint: any, _provider: ProviderWithEndpointsSummary): boolean {
|
||||
// 检查该端点是否有活跃的密钥
|
||||
return (endpoint.active_keys ?? 0) > 0
|
||||
}
|
||||
|
||||
// 端点标签样式
|
||||
function getEndpointTagClass(endpoint: any, provider: ProviderWithEndpointsSummary): string {
|
||||
if (!isEndpointAvailable(endpoint, provider)) {
|
||||
return 'border-red-300/50 bg-red-50/50 text-red-600/80 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400/80'
|
||||
}
|
||||
return 'border-border/40 bg-muted/20 text-foreground/70'
|
||||
}
|
||||
|
||||
// 端点圆点颜色
|
||||
function getEndpointDotColor(endpoint: any, provider: ProviderWithEndpointsSummary): string {
|
||||
if (!isEndpointAvailable(endpoint, provider)) {
|
||||
return 'bg-red-400'
|
||||
}
|
||||
const score = endpoint.health_score
|
||||
if (score === undefined || score === null) {
|
||||
return 'bg-muted-foreground/40'
|
||||
}
|
||||
if (score >= 0.8) {
|
||||
return 'bg-green-500'
|
||||
}
|
||||
if (score >= 0.5) {
|
||||
return 'bg-amber-500'
|
||||
}
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
// 端点提示文本
|
||||
function getEndpointTooltip(endpoint: any, provider: ProviderWithEndpointsSummary): string {
|
||||
if (provider.active_keys === 0) {
|
||||
return `${endpoint.api_format}: 无可用密钥`
|
||||
}
|
||||
const score = endpoint.health_score
|
||||
if (score === undefined || score === null) {
|
||||
return `${endpoint.api_format}: 暂无健康数据`
|
||||
}
|
||||
return `${endpoint.api_format}: 健康度 ${(score * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
// 配额已用颜色(根据使用比例)
|
||||
function getQuotaUsedColorClass(provider: ProviderWithEndpointsSummary): string {
|
||||
const used = provider.monthly_used_usd ?? 0
|
||||
const quota = provider.monthly_quota_usd ?? 0
|
||||
if (quota <= 0) return 'text-foreground'
|
||||
const ratio = used / quota
|
||||
if (ratio >= 0.9) return 'text-red-600 dark:text-red-400'
|
||||
if (ratio >= 0.7) return 'text-amber-600 dark:text-amber-400'
|
||||
return 'text-foreground'
|
||||
}
|
||||
|
||||
function rpmUsage(provider: ProviderWithEndpointsSummary): string | null {
|
||||
const rpmLimit = provider.rpm_limit
|
||||
const rpmUsed = provider.rpm_used ?? 0
|
||||
|
||||
if (rpmLimit === null || rpmLimit === undefined) {
|
||||
return rpmUsed > 0 ? `${rpmUsed}` : null
|
||||
}
|
||||
|
||||
if (rpmLimit === 0) {
|
||||
return '已完全禁止'
|
||||
}
|
||||
|
||||
return `${rpmUsed} / ${rpmLimit}`
|
||||
}
|
||||
|
||||
// 使用复用的行点击逻辑
|
||||
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
||||
|
||||
// 处理行点击 - 只在非选中文本时打开抽屉
|
||||
function handleRowClick(event: MouseEvent, providerId: string) {
|
||||
if (!shouldTriggerRowClick(event)) return
|
||||
openProviderDrawer(providerId)
|
||||
}
|
||||
|
||||
// 打开添加提供商对话框
|
||||
function openAddProviderDialog() {
|
||||
providerToEdit.value = null
|
||||
providerDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 打开优先级管理对话框
|
||||
function openPriorityDialog() {
|
||||
priorityDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 打开提供商详情抽屉
|
||||
function openProviderDrawer(providerId: string) {
|
||||
selectedProviderId.value = providerId
|
||||
providerDrawerOpen.value = true
|
||||
}
|
||||
|
||||
// 打开编辑提供商对话框
|
||||
function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
|
||||
providerToEdit.value = provider
|
||||
providerDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 处理提供商编辑完成
|
||||
function handleProviderUpdated() {
|
||||
loadProviders()
|
||||
}
|
||||
|
||||
// 优先级保存成功回调
|
||||
async function handlePrioritySaved() {
|
||||
await loadProviders()
|
||||
await loadPriorityMode()
|
||||
}
|
||||
|
||||
// 处理提供商添加
|
||||
function handleProviderAdded() {
|
||||
loadProviders()
|
||||
}
|
||||
|
||||
// 删除提供商
|
||||
async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
|
||||
const confirmed = await confirmDanger(
|
||||
'删除提供商',
|
||||
`确定要删除提供商 "${provider.display_name}" 吗?\n\n这将同时删除其所有端点、密钥和配置。此操作不可恢复!`
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await deleteProvider(provider.id)
|
||||
showSuccess('提供商已删除')
|
||||
loadProviders()
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '删除提供商失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换提供商状态
|
||||
async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
|
||||
try {
|
||||
await updateProvider(provider.id, { is_active: !provider.is_active })
|
||||
provider.is_active = !provider.is_active
|
||||
showSuccess(provider.is_active ? '提供商已启用' : '提供商已停用')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
loadPriorityMode()
|
||||
})
|
||||
</script>
|
||||
543
frontend/src/views/admin/SystemSettings.vue
Normal file
543
frontend/src/views/admin/SystemSettings.vue
Normal file
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader title="系统设置" description="管理系统级别的配置和参数">
|
||||
<template #actions>
|
||||
<Button @click="saveSystemConfig" :disabled="loading">
|
||||
{{ loading ? '保存中...' : '保存所有配置' }}
|
||||
</Button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- 基础配置 -->
|
||||
<CardSection title="基础配置" description="配置系统默认参数">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label for="default-quota" class="block text-sm font-medium">
|
||||
默认用户配额(美元)
|
||||
</Label>
|
||||
<Input
|
||||
id="default-quota"
|
||||
v-model.number="systemConfig.default_user_quota_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="10.00"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
新用户注册时的默认配额
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="rate-limit" class="block text-sm font-medium">
|
||||
每分钟请求限制
|
||||
</Label>
|
||||
<Input
|
||||
id="rate-limit"
|
||||
v-model.number="systemConfig.rate_limit_per_minute"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
0 表示不限制
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 用户注册配置 -->
|
||||
<CardSection title="用户注册" description="控制用户注册和验证">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-registration"
|
||||
v-model:checked="systemConfig.enable_registration"
|
||||
/>
|
||||
<Label for="enable-registration" class="cursor-pointer">
|
||||
开放用户注册
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="require-email-verification"
|
||||
v-model:checked="systemConfig.require_email_verification"
|
||||
/>
|
||||
<Label for="require-email-verification" class="cursor-pointer">
|
||||
需要邮箱验证
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- API Key 管理配置 -->
|
||||
<CardSection title="API Key 管理" description="API Key 相关配置">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label for="api-key-expire" class="block text-sm font-medium">
|
||||
API密钥过期天数
|
||||
</Label>
|
||||
<Input
|
||||
id="api-key-expire"
|
||||
v-model.number="systemConfig.api_key_expire_days"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
0 表示永不过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full pt-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="auto-delete-expired-keys"
|
||||
v-model:checked="systemConfig.auto_delete_expired_keys"
|
||||
/>
|
||||
<div>
|
||||
<Label for="auto-delete-expired-keys" class="cursor-pointer">
|
||||
自动删除过期 Key
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
关闭时仅禁用过期 Key
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 日志记录配置 -->
|
||||
<CardSection title="日志记录" description="控制请求日志的记录方式和内容">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label for="request-log-level" class="block text-sm font-medium mb-2">
|
||||
记录详细程度
|
||||
</Label>
|
||||
<Select v-model="systemConfig.request_log_level" v-model:open="logLevelSelectOpen">
|
||||
<SelectTrigger id="request-log-level" class="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">BASIC - 基本信息 (~1KB/条)</SelectItem>
|
||||
<SelectItem value="headers">HEADERS - 含请求头 (~2-3KB/条)</SelectItem>
|
||||
<SelectItem value="full">FULL - 完整请求响应 (~50KB/条)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
敏感信息会自动脱敏
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="max-request-body-size" class="block text-sm font-medium">
|
||||
最大请求体大小 (KB)
|
||||
</Label>
|
||||
<Input
|
||||
id="max-request-body-size"
|
||||
v-model.number="maxRequestBodySizeKB"
|
||||
type="number"
|
||||
placeholder="512"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过此大小的请求体将被截断记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="max-response-body-size" class="block text-sm font-medium">
|
||||
最大响应体大小 (KB)
|
||||
</Label>
|
||||
<Input
|
||||
id="max-response-body-size"
|
||||
v-model.number="maxResponseBodySizeKB"
|
||||
type="number"
|
||||
placeholder="512"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过此大小的响应体将被截断记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="sensitive-headers" class="block text-sm font-medium">
|
||||
敏感请求头
|
||||
</Label>
|
||||
<Input
|
||||
id="sensitive-headers"
|
||||
v-model="sensitiveHeadersStr"
|
||||
placeholder="authorization, x-api-key, cookie"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
逗号分隔,这些请求头会被脱敏处理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 日志清理策略 -->
|
||||
<CardSection title="日志清理策略" description="配置日志的分级保留和自动清理">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="md:col-span-2">
|
||||
<div class="flex items-center space-x-2 mb-4">
|
||||
<Checkbox
|
||||
id="enable-auto-cleanup"
|
||||
v-model:checked="systemConfig.enable_auto_cleanup"
|
||||
/>
|
||||
<Label for="enable-auto-cleanup" class="cursor-pointer">
|
||||
启用自动清理任务
|
||||
</Label>
|
||||
<span class="text-xs text-muted-foreground ml-2">
|
||||
(每天凌晨执行)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="detail-log-retention-days" class="block text-sm font-medium">
|
||||
详细日志保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="detail-log-retention-days"
|
||||
v-model.number="systemConfig.detail_log_retention_days"
|
||||
type="number"
|
||||
placeholder="7"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后压缩 body 字段
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="compressed-log-retention-days" class="block text-sm font-medium">
|
||||
压缩日志保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="compressed-log-retention-days"
|
||||
v-model.number="systemConfig.compressed_log_retention_days"
|
||||
type="number"
|
||||
placeholder="90"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后删除 body 字段
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="header-retention-days" class="block text-sm font-medium">
|
||||
请求头保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="header-retention-days"
|
||||
v-model.number="systemConfig.header_retention_days"
|
||||
type="number"
|
||||
placeholder="90"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后清空 headers 字段
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="log-retention-days" class="block text-sm font-medium">
|
||||
完整日志保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="log-retention-days"
|
||||
v-model.number="systemConfig.log_retention_days"
|
||||
type="number"
|
||||
placeholder="365"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后删除整条记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="cleanup-batch-size" class="block text-sm font-medium">
|
||||
每批次清理记录数
|
||||
</Label>
|
||||
<Input
|
||||
id="cleanup-batch-size"
|
||||
v-model.number="systemConfig.cleanup_batch_size"
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
避免单次操作过大影响性能
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清理策略说明 -->
|
||||
<div class="mt-4 p-4 bg-muted/50 rounded-lg">
|
||||
<h4 class="text-sm font-medium mb-2">清理策略说明</h4>
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<p>1. <strong>详细日志阶段</strong>: 保留完整的 request_body 和 response_body</p>
|
||||
<p>2. <strong>压缩日志阶段</strong>: body 字段被压缩存储,节省空间</p>
|
||||
<p>3. <strong>统计阶段</strong>: 仅保留 tokens、成本等统计信息</p>
|
||||
<p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.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 { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
const { success, error } = useToast()
|
||||
|
||||
interface SystemConfig {
|
||||
// 基础配置
|
||||
default_user_quota_usd: number
|
||||
rate_limit_per_minute: number
|
||||
// 用户注册
|
||||
enable_registration: boolean
|
||||
require_email_verification: boolean
|
||||
// API Key 管理
|
||||
api_key_expire_days: number
|
||||
auto_delete_expired_keys: boolean
|
||||
// 日志记录
|
||||
request_log_level: string
|
||||
max_request_body_size: number
|
||||
max_response_body_size: number
|
||||
sensitive_headers: string[]
|
||||
// 日志清理
|
||||
enable_auto_cleanup: boolean
|
||||
detail_log_retention_days: number
|
||||
compressed_log_retention_days: number
|
||||
header_retention_days: number
|
||||
log_retention_days: number
|
||||
cleanup_batch_size: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const logLevelSelectOpen = ref(false)
|
||||
|
||||
const systemConfig = ref<SystemConfig>({
|
||||
// 基础配置
|
||||
default_user_quota_usd: 10.0,
|
||||
rate_limit_per_minute: 0,
|
||||
// 用户注册
|
||||
enable_registration: false,
|
||||
require_email_verification: false,
|
||||
// API Key 管理
|
||||
api_key_expire_days: 0,
|
||||
auto_delete_expired_keys: false,
|
||||
// 日志记录
|
||||
request_log_level: 'basic',
|
||||
max_request_body_size: 1048576,
|
||||
max_response_body_size: 1048576,
|
||||
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
|
||||
// 日志清理
|
||||
enable_auto_cleanup: true,
|
||||
detail_log_retention_days: 7,
|
||||
compressed_log_retention_days: 90,
|
||||
header_retention_days: 90,
|
||||
log_retention_days: 365,
|
||||
cleanup_batch_size: 1000,
|
||||
})
|
||||
|
||||
// 计算属性:KB 和 字节 之间的转换
|
||||
const maxRequestBodySizeKB = computed({
|
||||
get: () => Math.round(systemConfig.value.max_request_body_size / 1024),
|
||||
set: (val: number) => {
|
||||
systemConfig.value.max_request_body_size = val * 1024
|
||||
}
|
||||
})
|
||||
|
||||
const maxResponseBodySizeKB = computed({
|
||||
get: () => Math.round(systemConfig.value.max_response_body_size / 1024),
|
||||
set: (val: number) => {
|
||||
systemConfig.value.max_response_body_size = val * 1024
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:敏感请求头数组和字符串之间的转换
|
||||
const sensitiveHeadersStr = computed({
|
||||
get: () => systemConfig.value.sensitive_headers.join(', '),
|
||||
set: (val: string) => {
|
||||
systemConfig.value.sensitive_headers = val
|
||||
.split(',')
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(s => s.length > 0)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSystemConfig()
|
||||
})
|
||||
|
||||
async function loadSystemConfig() {
|
||||
try {
|
||||
const configs = [
|
||||
// 基础配置
|
||||
'default_user_quota_usd',
|
||||
'rate_limit_per_minute',
|
||||
// 用户注册
|
||||
'enable_registration',
|
||||
'require_email_verification',
|
||||
// API Key 管理
|
||||
'api_key_expire_days',
|
||||
'auto_delete_expired_keys',
|
||||
// 日志记录
|
||||
'request_log_level',
|
||||
'max_request_body_size',
|
||||
'max_response_body_size',
|
||||
'sensitive_headers',
|
||||
// 日志清理
|
||||
'enable_auto_cleanup',
|
||||
'detail_log_retention_days',
|
||||
'compressed_log_retention_days',
|
||||
'header_retention_days',
|
||||
'log_retention_days',
|
||||
'cleanup_batch_size',
|
||||
]
|
||||
|
||||
for (const key of configs) {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig(key)
|
||||
if (response.value !== null && response.value !== undefined) {
|
||||
(systemConfig.value as any)[key] = response.value
|
||||
}
|
||||
} catch (err) {
|
||||
// 配置不存在时使用默认值,无需处理
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error('加载系统配置失败')
|
||||
console.error('加载系统配置失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSystemConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const configItems = [
|
||||
// 基础配置
|
||||
{
|
||||
key: 'default_user_quota_usd',
|
||||
value: systemConfig.value.default_user_quota_usd,
|
||||
description: '默认用户配额(美元)'
|
||||
},
|
||||
{
|
||||
key: 'rate_limit_per_minute',
|
||||
value: systemConfig.value.rate_limit_per_minute,
|
||||
description: '每分钟请求限制'
|
||||
},
|
||||
// 用户注册
|
||||
{
|
||||
key: 'enable_registration',
|
||||
value: systemConfig.value.enable_registration,
|
||||
description: '是否开放用户注册'
|
||||
},
|
||||
{
|
||||
key: 'require_email_verification',
|
||||
value: systemConfig.value.require_email_verification,
|
||||
description: '是否需要邮箱验证'
|
||||
},
|
||||
// API Key 管理
|
||||
{
|
||||
key: 'api_key_expire_days',
|
||||
value: systemConfig.value.api_key_expire_days,
|
||||
description: 'API密钥过期天数'
|
||||
},
|
||||
{
|
||||
key: 'auto_delete_expired_keys',
|
||||
value: systemConfig.value.auto_delete_expired_keys,
|
||||
description: '是否自动删除过期的API Key'
|
||||
},
|
||||
// 日志记录
|
||||
{
|
||||
key: 'request_log_level',
|
||||
value: systemConfig.value.request_log_level,
|
||||
description: '请求记录级别'
|
||||
},
|
||||
{
|
||||
key: 'max_request_body_size',
|
||||
value: systemConfig.value.max_request_body_size,
|
||||
description: '最大请求体记录大小(字节)'
|
||||
},
|
||||
{
|
||||
key: 'max_response_body_size',
|
||||
value: systemConfig.value.max_response_body_size,
|
||||
description: '最大响应体记录大小(字节)'
|
||||
},
|
||||
{
|
||||
key: 'sensitive_headers',
|
||||
value: systemConfig.value.sensitive_headers,
|
||||
description: '敏感请求头列表'
|
||||
},
|
||||
// 日志清理
|
||||
{
|
||||
key: 'enable_auto_cleanup',
|
||||
value: systemConfig.value.enable_auto_cleanup,
|
||||
description: '是否启用自动清理任务'
|
||||
},
|
||||
{
|
||||
key: 'detail_log_retention_days',
|
||||
value: systemConfig.value.detail_log_retention_days,
|
||||
description: '详细日志保留天数'
|
||||
},
|
||||
{
|
||||
key: 'compressed_log_retention_days',
|
||||
value: systemConfig.value.compressed_log_retention_days,
|
||||
description: '压缩日志保留天数'
|
||||
},
|
||||
{
|
||||
key: 'header_retention_days',
|
||||
value: systemConfig.value.header_retention_days,
|
||||
description: '请求头保留天数'
|
||||
},
|
||||
{
|
||||
key: 'log_retention_days',
|
||||
value: systemConfig.value.log_retention_days,
|
||||
description: '完整日志保留天数'
|
||||
},
|
||||
{
|
||||
key: 'cleanup_batch_size',
|
||||
value: systemConfig.value.cleanup_batch_size,
|
||||
description: '每批次清理的记录数'
|
||||
},
|
||||
]
|
||||
|
||||
const promises = configItems.map(item =>
|
||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
success('系统配置已保存')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
console.error('保存配置失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
834
frontend/src/views/admin/Users.vue
Normal file
834
frontend/src/views/admin/Users.vue
Normal file
@@ -0,0 +1,834 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 用户表格 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<!-- 标题和筛选器 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-base font-semibold">用户管理</h3>
|
||||
|
||||
<!-- 筛选器和操作按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="users-search"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索用户名或邮箱..."
|
||||
class="w-48 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60 focus:border-primary/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 角色筛选 -->
|
||||
<Select v-model="filterRole" v-model:open="filterRoleOpen">
|
||||
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部角色" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部角色</SelectItem>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<Select v-model="filterStatus" v-model:open="filterStatusOpen">
|
||||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="active">活跃</SelectItem>
|
||||
<SelectItem value="inactive">禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 新增用户按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="openCreateDialog"
|
||||
title="新增用户"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton :loading="usersStore.loading || loadingStats" @click="refreshUsers" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<div class="hidden xl:block overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[200px] h-12 font-semibold">用户信息</TableHead>
|
||||
<TableHead class="w-[180px] h-12 font-semibold">邮箱</TableHead>
|
||||
<TableHead class="w-[180px] h-12 font-semibold">使用统计</TableHead>
|
||||
<TableHead class="w-[180px] h-12 font-semibold">配额(美元)</TableHead>
|
||||
<TableHead class="w-[110px] h-12 font-semibold">创建时间</TableHead>
|
||||
<TableHead class="w-[90px] h-12 font-semibold text-center">状态</TableHead>
|
||||
<TableHead class="w-[220px] h-12 font-semibold text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="user in paginatedUsers" :key="user.id" class="border-b border-border/40 hover:bg-muted/30 transition-colors">
|
||||
<TableCell class="py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md">
|
||||
<AvatarFallback class="bg-primary text-sm font-bold text-white">
|
||||
{{ user.username.charAt(0).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="truncate text-sm font-semibold mb-1" :title="user.username">{{ user.username }}</div>
|
||||
<Badge :variant="user.role === 'admin' ? 'default' : 'secondary'" class="text-xs px-2 py-0.5">
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<span class="block truncate text-sm text-muted-foreground" :title="user.email || '-'">
|
||||
{{ user.email || '-' }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div v-if="userStats[user.id]" class="space-y-1 text-xs">
|
||||
<div class="flex items-center text-muted-foreground">
|
||||
<span class="w-14">请求:</span>
|
||||
<span class="font-medium text-foreground">{{ formatNumber(userStats[user.id]?.request_count) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-muted-foreground">
|
||||
<span class="w-14">Tokens:</span>
|
||||
<span class="font-medium text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-muted-foreground">
|
||||
<span class="w-14">费用:</span>
|
||||
<span class="font-medium text-foreground">${{ formatCurrency(userStats[user.id]?.total_cost) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-muted-foreground">
|
||||
<span v-if="loadingStats">加载中...</span>
|
||||
<span v-else>无数据</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="space-y-1.5 text-xs">
|
||||
<div v-if="user.quota_usd != null" class="text-muted-foreground">
|
||||
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium">${{ user.quota_usd.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground">
|
||||
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium text-amber-600">无限制</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground">累计: <span class="font-medium text-foreground">${{ (user.total_usd || 0).toFixed(2) }}</span></div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-xs text-muted-foreground">
|
||||
{{ formatDate(user.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge :variant="user.is_active ? 'success' : 'destructive'" class="font-medium px-3 py-1">
|
||||
{{ user.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="editUser(user)" title="编辑用户">
|
||||
<SquarePen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="manageApiKeys(user)" title="查看API Keys">
|
||||
<Key class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="toggleUserStatus(user)"
|
||||
:title="user.is_active ? '禁用用户' : '启用用户'"
|
||||
>
|
||||
<PauseCircle v-if="user.is_active" class="h-4 w-4" />
|
||||
<PlayCircle v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="resetQuota(user)" title="重置配额">
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="deleteUser(user)" title="删除用户">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="xl:hidden divide-y divide-border/40">
|
||||
<div v-for="user in paginatedUsers" :key="user.id" class="p-4 sm:p-5 hover:bg-muted/30 transition-colors">
|
||||
<!-- 用户头部 -->
|
||||
<div class="flex items-start justify-between mb-3 sm:mb-4">
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<Avatar class="h-10 w-10 sm:h-12 sm:w-12 ring-2 ring-background shadow-md flex-shrink-0">
|
||||
<AvatarFallback class="bg-primary text-sm sm:text-base font-bold text-white">
|
||||
{{ user.username.charAt(0).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-sm sm:text-base mb-1 truncate">{{ user.username }}</div>
|
||||
<Badge :variant="user.role === 'admin' ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge :variant="user.is_active ? 'success' : 'destructive'" class="font-medium text-xs flex-shrink-0">
|
||||
{{ user.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="space-y-2 sm:space-y-3 mb-3 sm:mb-4">
|
||||
<div class="text-xs sm:text-sm">
|
||||
<span class="text-muted-foreground">邮箱:</span>
|
||||
<span class="ml-2 text-foreground truncate block sm:inline">{{ user.email || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="userStats[user.id]" class="grid grid-cols-2 gap-2 p-2 sm:p-3 bg-muted/50 rounded-lg text-xs">
|
||||
<div>
|
||||
<div class="text-muted-foreground mb-1">请求次数</div>
|
||||
<div class="font-semibold text-sm text-foreground">{{ formatNumber(userStats[user.id]?.request_count) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground mb-1">Tokens</div>
|
||||
<div class="font-semibold text-sm text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<div class="text-muted-foreground mb-1">消费金额</div>
|
||||
<div class="font-semibold text-sm text-foreground">${{ formatCurrency(userStats[user.id]?.total_cost) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2 sm:p-3 bg-muted/50 rounded-lg text-xs space-y-1">
|
||||
<div v-if="user.quota_usd != null">
|
||||
<span class="text-muted-foreground">当前配额:</span>
|
||||
<span class="ml-2 font-semibold text-sm">${{ (user.used_usd || 0).toFixed(2) }}</span> / ${{ user.quota_usd.toFixed(2) }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<span class="text-muted-foreground">当前配额:</span>
|
||||
<span class="ml-2 font-semibold text-sm">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="text-amber-600">无限制</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">累计消费:</span>
|
||||
<span class="ml-2 font-semibold text-sm">${{ (user.total_usd || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">创建时间:</span>
|
||||
<span class="ml-2 text-sm">{{ formatDate(user.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 - 响应式布局 -->
|
||||
<div class="grid grid-cols-2 sm:flex sm:flex-wrap gap-1.5 sm:gap-2">
|
||||
<Button variant="outline" size="sm" class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[90px]" @click="editUser(user)">
|
||||
<SquarePen class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
|
||||
<span class="hidden sm:inline">编辑</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[100px]" @click="manageApiKeys(user)">
|
||||
<Key class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
|
||||
<span class="hidden sm:inline">API Keys</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[90px]"
|
||||
:class="user.is_active ? 'text-amber-600' : 'text-emerald-600'"
|
||||
@click="toggleUserStatus(user)"
|
||||
>
|
||||
<PauseCircle v-if="user.is_active" class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
|
||||
<PlayCircle v-else class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
|
||||
<span class="hidden sm:inline">{{ user.is_active ? '禁用' : '启用' }}</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="text-xs sm:text-sm h-8 sm:h-9" @click="resetQuota(user)">
|
||||
<RotateCcw class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
|
||||
<span class="hidden sm:inline">重置</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="col-span-2 text-xs sm:text-sm h-8 sm:h-9 text-rose-600 sm:col-span-1" @click="deleteUser(user)">
|
||||
<Trash2 class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
|
||||
<span class="hidden sm:inline">删除</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<Pagination
|
||||
:current="currentPage"
|
||||
:total="filteredUsers.length"
|
||||
:page-size="pageSize"
|
||||
@update:current="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 用户表单对话框(创建/编辑共用) -->
|
||||
<UserFormDialog
|
||||
:open="showUserFormDialog"
|
||||
:user="editingUser"
|
||||
@close="closeUserFormDialog"
|
||||
@submit="handleUserFormSubmit"
|
||||
ref="userFormDialogRef"
|
||||
/>
|
||||
|
||||
<!-- API Keys 管理对话框 -->
|
||||
<Dialog v-model="showApiKeysDialog" size="xl">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-kraft/10 flex-shrink-0">
|
||||
<Key class="h-5 w-5 text-kraft" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">管理 API Keys</h3>
|
||||
<p class="text-xs text-muted-foreground">查看和管理用户的 API 密钥</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto space-y-3">
|
||||
<template v-if="userApiKeys.length > 0">
|
||||
<div
|
||||
v-for="apiKey in userApiKeys"
|
||||
:key="apiKey.id"
|
||||
class="rounded-lg border border-border bg-card p-4 hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<!-- 左侧信息 -->
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-semibold text-foreground">
|
||||
{{ apiKey.name || '未命名 API Key' }}
|
||||
</span>
|
||||
<Badge
|
||||
:variant="apiKey.is_active ? 'success' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ apiKey.is_active ? '活跃' : '已禁用' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="apiKey.is_standalone"
|
||||
variant="default"
|
||||
class="text-xs bg-purple-500"
|
||||
>
|
||||
独立余额
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<code class="text-xs font-mono text-muted-foreground">
|
||||
{{ apiKey.key_display || 'sk-****' }}
|
||||
</code>
|
||||
<button
|
||||
@click="copyFullKey(apiKey)"
|
||||
class="p-0.5 hover:bg-muted rounded transition-colors"
|
||||
title="复制完整密钥"
|
||||
>
|
||||
<Copy class="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧统计和操作 -->
|
||||
<div class="flex items-center gap-4 flex-shrink-0">
|
||||
<div class="text-right text-sm">
|
||||
<div class="text-muted-foreground">
|
||||
{{ (apiKey.total_requests || 0).toLocaleString() }} 次
|
||||
</div>
|
||||
<div class="font-semibold text-rose-600">
|
||||
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="deleteApiKey(apiKey)"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/20 px-4 py-12 text-center"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||
<Key class="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-base font-semibold text-foreground">暂无 API Keys</p>
|
||||
<p class="text-sm text-muted-foreground">点击下方按钮创建</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="showApiKeysDialog = false" class="h-10 px-5">取消</Button>
|
||||
<Button @click="createApiKey" class="h-10 px-5" :disabled="creatingApiKey">
|
||||
{{ creatingApiKey ? '创建中...' : '创建' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 新 API Key 显示对话框 -->
|
||||
<Dialog v-model="showNewApiKeyDialog" size="lg">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
|
||||
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">创建成功</h3>
|
||||
<p class="text-xs text-muted-foreground">请妥善保管, 切勿泄露给他人.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">API Key</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
:value="newApiKey"
|
||||
readonly
|
||||
class="flex-1 font-mono text-sm bg-muted/50 h-11"
|
||||
@click="selectApiKey"
|
||||
ref="apiKeyInput"
|
||||
/>
|
||||
<Button @click="copyApiKey" class="h-11">
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="closeNewApiKeyDialog" class="h-10 px-5">确定</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { usageApi, type UsageByUser } from '@/api/usage'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
// UI 组件
|
||||
import {
|
||||
Dialog,
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
Pagination,
|
||||
RefreshButton
|
||||
} from '@/components/ui'
|
||||
|
||||
import {
|
||||
Plus,
|
||||
SquarePen,
|
||||
Key,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Copy,
|
||||
Search,
|
||||
CheckCircle
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
// 功能组件
|
||||
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger, confirmWarning } = useConfirm()
|
||||
const usersStore = useUsersStore()
|
||||
|
||||
// 用户表单对话框状态
|
||||
const showUserFormDialog = ref(false)
|
||||
const editingUser = ref<UserFormData | null>(null)
|
||||
const userFormDialogRef = ref<InstanceType<typeof UserFormDialog>>()
|
||||
|
||||
// API Keys 对话框状态
|
||||
const showApiKeysDialog = ref(false)
|
||||
const showNewApiKeyDialog = ref(false)
|
||||
const selectedUser = ref<any>(null)
|
||||
const userApiKeys = ref<any[]>([])
|
||||
const newApiKey = ref('')
|
||||
const creatingApiKey = ref(false)
|
||||
const apiKeyInput = ref<HTMLInputElement>()
|
||||
|
||||
// 用户统计
|
||||
const userStats = ref<Record<string, UsageByUser>>({})
|
||||
const loadingStats = ref(false)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterRole = ref('all')
|
||||
const filterStatus = ref('all')
|
||||
const filterRoleOpen = ref(false)
|
||||
const filterStatusOpen = ref(false)
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
let filtered = [...usersStore.users]
|
||||
|
||||
// 先排序:管理员优先,然后按创建时间倒序
|
||||
filtered.sort((a, b) => {
|
||||
// 管理员优先
|
||||
if (a.role === 'admin' && b.role !== 'admin') return -1
|
||||
if (a.role !== 'admin' && b.role === 'admin') return 1
|
||||
// 同角色按创建时间倒序(新用户在前)
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
u => u.username.toLowerCase().includes(query) || u.email?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
if (filterRole.value !== 'all') {
|
||||
filtered = filtered.filter(u => u.role === filterRole.value)
|
||||
}
|
||||
|
||||
if (filterStatus.value !== 'all') {
|
||||
filtered = filtered.filter(u =>
|
||||
filterStatus.value === 'active' ? u.is_active : !u.is_active
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const paginatedUsers = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredUsers.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
// Watch filter changes and reset to first page
|
||||
watch([searchQuery, filterRole, filterStatus], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await usersStore.fetchUsers()
|
||||
await loadUserStats()
|
||||
})
|
||||
|
||||
async function refreshUsers() {
|
||||
await usersStore.fetchUsers()
|
||||
await loadUserStats()
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
async function loadUserStats() {
|
||||
loadingStats.value = true
|
||||
try {
|
||||
const data = await usageApi.getUsageByUser()
|
||||
userStats.value = data.reduce((acc: any, stat: any) => {
|
||||
acc[stat.user_id] = stat
|
||||
return acc
|
||||
}, {})
|
||||
} catch (err) {
|
||||
console.error('加载用户统计失败:', err)
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(1)}M`
|
||||
} else if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`
|
||||
}
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
function formatNumber(value?: number | null): string {
|
||||
const numericValue = typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
return numericValue.toLocaleString()
|
||||
}
|
||||
|
||||
function formatCurrency(value?: number | null, fractionDigits = 4): string {
|
||||
const numericValue = typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
return numericValue.toFixed(fractionDigits)
|
||||
}
|
||||
|
||||
async function toggleUserStatus(user: any) {
|
||||
const action = user.is_active ? '禁用' : '启用'
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要${action}用户 ${user.username} 吗?`,
|
||||
`${action}用户`
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await usersStore.updateUser(user.id, { is_active: !user.is_active })
|
||||
success(`用户已${action}`)
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', `${action}用户失败`)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 用户表单对话框方法 ==========
|
||||
|
||||
function openCreateDialog() {
|
||||
editingUser.value = null
|
||||
showUserFormDialog.value = true
|
||||
}
|
||||
|
||||
function editUser(user: any) {
|
||||
editingUser.value = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
quota_usd: user.quota_usd,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
allowed_providers: user.allowed_providers || [],
|
||||
allowed_endpoints: user.allowed_endpoints || [],
|
||||
allowed_models: user.allowed_models || []
|
||||
}
|
||||
showUserFormDialog.value = true
|
||||
}
|
||||
|
||||
function closeUserFormDialog() {
|
||||
showUserFormDialog.value = false
|
||||
editingUser.value = null
|
||||
}
|
||||
|
||||
async function handleUserFormSubmit(data: UserFormData & { password?: string }) {
|
||||
userFormDialogRef.value?.setSaving(true)
|
||||
try {
|
||||
if (data.id) {
|
||||
// 更新用户
|
||||
const updateData: any = {
|
||||
username: data.username,
|
||||
email: data.email || undefined,
|
||||
quota_usd: data.quota_usd,
|
||||
role: data.role,
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_endpoints: data.allowed_endpoints,
|
||||
allowed_models: data.allowed_models
|
||||
}
|
||||
if (data.password) {
|
||||
updateData.password = data.password
|
||||
}
|
||||
await usersStore.updateUser(data.id, updateData)
|
||||
success('用户信息已更新')
|
||||
} else {
|
||||
// 创建用户
|
||||
const newUser = await usersStore.createUser({
|
||||
username: data.username,
|
||||
password: data.password!,
|
||||
email: data.email || undefined,
|
||||
quota_usd: data.quota_usd,
|
||||
role: data.role,
|
||||
allowed_providers: data.allowed_providers,
|
||||
allowed_endpoints: data.allowed_endpoints,
|
||||
allowed_models: data.allowed_models
|
||||
})
|
||||
// 如果创建时指定为禁用,则更新状态
|
||||
if (data.is_active === false && newUser) {
|
||||
await usersStore.updateUser(newUser.id, { is_active: false })
|
||||
}
|
||||
success('用户创建成功')
|
||||
}
|
||||
closeUserFormDialog()
|
||||
} catch (err: any) {
|
||||
const title = data.id ? '更新用户失败' : '创建用户失败'
|
||||
error(err.response?.data?.detail || '未知错误', title)
|
||||
} finally {
|
||||
userFormDialogRef.value?.setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function manageApiKeys(user: any) {
|
||||
selectedUser.value = user
|
||||
showApiKeysDialog.value = true
|
||||
await loadUserApiKeys(user.id)
|
||||
}
|
||||
|
||||
async function loadUserApiKeys(userId: string) {
|
||||
try {
|
||||
userApiKeys.value = await usersStore.getUserApiKeys(userId)
|
||||
} catch (err) {
|
||||
console.error('加载API Keys失败:', err)
|
||||
userApiKeys.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
if (!selectedUser.value) return
|
||||
|
||||
creatingApiKey.value = true
|
||||
try {
|
||||
const response = await usersStore.createApiKey(
|
||||
selectedUser.value.id,
|
||||
`Key-${new Date().toISOString().split('T')[0]}`
|
||||
)
|
||||
newApiKey.value = response.key || ''
|
||||
showNewApiKeyDialog.value = true
|
||||
await loadUserApiKeys(selectedUser.value.id)
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '创建 API Key 失败')
|
||||
} finally {
|
||||
creatingApiKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectApiKey() {
|
||||
apiKeyInput.value?.select()
|
||||
}
|
||||
|
||||
async function copyApiKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newApiKey.value)
|
||||
success('API Key已复制到剪贴板')
|
||||
} catch (err) {
|
||||
error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function closeNewApiKeyDialog() {
|
||||
showNewApiKeyDialog.value = false
|
||||
newApiKey.value = ''
|
||||
}
|
||||
|
||||
async function deleteApiKey(apiKey: any) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除这个API Key吗?\n\n${apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`,
|
||||
'删除 API Key'
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await usersStore.deleteApiKey(selectedUser.value.id, apiKey.id)
|
||||
await loadUserApiKeys(selectedUser.value.id)
|
||||
success('API Key已删除')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '删除 API Key 失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFullKey(apiKey: any) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||
await navigator.clipboard.writeText(response.key)
|
||||
success('完整密钥已复制到剪贴板')
|
||||
} catch (err: any) {
|
||||
console.error('复制密钥失败:', err)
|
||||
error(err.response?.data?.detail || '未知错误', '复制密钥失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function resetQuota(user: any) {
|
||||
const confirmed = await confirmWarning(
|
||||
`确定要重置用户 ${user.username} 的配额使用量吗?\n\n这将把已使用金额重置为0。`,
|
||||
'重置配额'
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await usersStore.resetUserQuota(user.id)
|
||||
success('配额已重置')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '重置配额失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(user: any) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除用户 ${user.username} 吗?\n\n此操作将删除:\n• 用户账户\n• 所有API密钥\n• 所有使用记录\n\n此操作无法撤销!`,
|
||||
'删除用户'
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await usersStore.deleteUser(user.id)
|
||||
success('用户已删除')
|
||||
} catch (err: any) {
|
||||
error(err.response?.data?.detail || '未知错误', '删除用户失败')
|
||||
}
|
||||
}
|
||||
|
||||
function getRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
const months = Math.floor(days / 30)
|
||||
const years = Math.floor(days / 365)
|
||||
|
||||
if (years > 0) return `${years}年前`
|
||||
if (months > 0) return `${months}月前`
|
||||
if (days > 0) return `${days}天前`
|
||||
if (hours > 0) return `${hours}小时前`
|
||||
if (minutes > 0) return `${minutes}分钟前`
|
||||
return '刚刚'
|
||||
}
|
||||
</script>
|
||||
167
frontend/src/views/public/CliSection.vue
Normal file
167
frontend/src/views/public/CliSection.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="min-h-screen snap-start flex items-center px-16 lg:px-20 py-20"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto grid md:grid-cols-2 gap-12 items-center">
|
||||
<!-- Content column -->
|
||||
<div :class="contentOrder">
|
||||
<!-- Badge -->
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium mb-4 transition-all duration-500"
|
||||
:class="badgeClass"
|
||||
:style="badgeStyle"
|
||||
>
|
||||
<component :is="badgeIcon" class="h-3 w-3" />
|
||||
{{ badgeText }}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2
|
||||
class="text-4xl md:text-5xl font-bold text-[#191919] dark:text-white mb-6 transition-all duration-700"
|
||||
:style="titleStyle"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
class="text-lg text-[#666663] dark:text-gray-300 mb-4 transition-all duration-700"
|
||||
:style="descStyle"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Install command -->
|
||||
<div
|
||||
class="mb-4 transition-all duration-700 relative z-10"
|
||||
:style="cardStyleFn(0)"
|
||||
>
|
||||
<div :class="[panelClasses.commandPanel, 'flex flex-wrap items-center gap-3 px-4 py-3']">
|
||||
<PlatformSelect
|
||||
:model-value="platformValue"
|
||||
@update:model-value="$emit('update:platformValue', $event)"
|
||||
:options="platformOptions"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div class="flex-1 min-w-[180px]">
|
||||
<CodeHighlight :code="installCommand" language="bash" dense />
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('copy', installCommand)"
|
||||
:class="panelClasses.iconButtonSmall"
|
||||
title="复制配置"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config files -->
|
||||
<div
|
||||
v-for="(config, idx) in configFiles"
|
||||
:key="config.path"
|
||||
class="transition-all duration-700"
|
||||
:class="idx < configFiles.length - 1 ? 'mb-3' : ''"
|
||||
:style="cardStyleFn(idx + 1)"
|
||||
>
|
||||
<div :class="[panelClasses.configPanel, 'overflow-hidden']">
|
||||
<div :class="panelClasses.panelHeader">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-[#666663] dark:text-muted-foreground">
|
||||
{{ config.path }}
|
||||
</span>
|
||||
<button
|
||||
@click="$emit('copy', config.content)"
|
||||
:class="panelClasses.iconButtonSmall"
|
||||
title="复制配置"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="panelClasses.codeBody">
|
||||
<div class="config-code-wrapper">
|
||||
<CodeHighlight :code="config.content" :language="config.language" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo placeholder column -->
|
||||
<div :class="logoOrder" class="flex items-center justify-center h-full min-h-[300px] relative">
|
||||
<slot name="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, type CSSProperties, type Component } from 'vue'
|
||||
import { Copy } from 'lucide-vue-next'
|
||||
import PlatformSelect from '@/components/PlatformSelect.vue'
|
||||
import CodeHighlight from '@/components/CodeHighlight.vue'
|
||||
import { panelClasses, type PlatformOption } from './home-config'
|
||||
|
||||
// Expose section element for parent scroll tracking
|
||||
const sectionRef = ref<HTMLElement | null>(null)
|
||||
defineExpose({ sectionEl: sectionRef })
|
||||
|
||||
interface ConfigFile {
|
||||
path: string
|
||||
content: string
|
||||
language: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
description: string
|
||||
badgeIcon: Component
|
||||
badgeText: string
|
||||
badgeClass: string
|
||||
platformValue: string
|
||||
platformOptions: PlatformOption[]
|
||||
installCommand: string
|
||||
configFiles: ConfigFile[]
|
||||
// Style props
|
||||
badgeStyle: CSSProperties
|
||||
titleStyle: CSSProperties
|
||||
descStyle: CSSProperties
|
||||
cardStyleFn: (cardIndex: number) => CSSProperties
|
||||
// Layout: 'left' means content on left, 'right' means content on right
|
||||
contentPosition?: 'left' | 'right'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentPosition: 'left'
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
copy: [text: string]
|
||||
'update:platformValue': [value: string]
|
||||
}>()
|
||||
|
||||
const contentOrder = computed(() =>
|
||||
props.contentPosition === 'right' ? 'md:order-2' : ''
|
||||
)
|
||||
|
||||
const logoOrder = computed(() =>
|
||||
props.contentPosition === 'right' ? 'md:order-1' : ''
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-code-wrapper :deep(.code-highlight pre) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
background-color: transparent !important;
|
||||
padding: 1rem 1.2rem !important;
|
||||
}
|
||||
|
||||
/* Header separator line */
|
||||
.panel-header {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
752
frontend/src/views/public/Home.vue
Normal file
752
frontend/src/views/public/Home.vue
Normal file
@@ -0,0 +1,752 @@
|
||||
<template>
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="relative h-screen overflow-y-auto snap-y snap-mandatory scroll-smooth literary-grid literary-paper"
|
||||
>
|
||||
<!-- Fixed scroll indicator -->
|
||||
<nav class="scroll-indicator">
|
||||
<button
|
||||
v-for="(section, index) in sections"
|
||||
:key="index"
|
||||
@click="scrollToSection(index)"
|
||||
class="scroll-indicator-btn group"
|
||||
>
|
||||
<span class="scroll-indicator-label">{{ section.name }}</span>
|
||||
<div
|
||||
class="scroll-indicator-dot"
|
||||
:class="{ active: currentSection === index }"
|
||||
/>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="fixed top-0 left-0 right-0 z-50 border-b border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-xl transition-all">
|
||||
<div class="mx-auto max-w-7xl px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Logo & Brand -->
|
||||
<div
|
||||
class="flex items-center gap-3 group/logo cursor-pointer"
|
||||
@click="scrollToSection(0)"
|
||||
>
|
||||
<HeaderLogo size="h-9 w-9" className="text-[#191919] dark:text-white" />
|
||||
<div class="flex flex-col justify-center">
|
||||
<h1 class="text-lg font-bold text-[#191919] dark:text-white leading-none">Aether</h1>
|
||||
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1.5 font-medium tracking-wide">API Gateway</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center Navigation -->
|
||||
<nav class="hidden md:flex items-center gap-2">
|
||||
<button
|
||||
v-for="(section, index) in sections"
|
||||
:key="index"
|
||||
@click="scrollToSection(index)"
|
||||
class="group relative px-3 py-2 text-sm font-medium transition"
|
||||
:class="currentSection === index
|
||||
? 'text-[#cc785c] dark:text-[#d4a27f]'
|
||||
: 'text-[#666663] dark:text-muted-foreground hover:text-[#191919] dark:hover:text-white'"
|
||||
>
|
||||
{{ section.name }}
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 rounded-full transition-all duration-300"
|
||||
:class="currentSection === index ? 'bg-[#cc785c] dark:bg-[#d4a27f] scale-x-100' : 'bg-transparent scale-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Right Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="toggleDarkMode"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
|
||||
>
|
||||
<SunMoon v-if="themeMode === 'system'" class="h-4 w-4" />
|
||||
<Sun v-else-if="themeMode === 'light'" class="h-4 w-4" />
|
||||
<Moon v-else class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<RouterLink
|
||||
v-if="authStore.isAuthenticated"
|
||||
:to="dashboardPath"
|
||||
class="rounded-xl bg-[#191919] dark:bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-[#262625] dark:hover:bg-[#b86d52]"
|
||||
>
|
||||
控制台
|
||||
</RouterLink>
|
||||
<button
|
||||
v-else
|
||||
@click="showLoginDialog = true"
|
||||
class="rounded-xl bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-lg shadow-[#cc785c]/30 transition hover:bg-[#d4a27f]"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="relative z-10">
|
||||
<!-- Fixed Logo Container -->
|
||||
<div class="fixed inset-0 z-20 pointer-events-none flex items-center justify-center overflow-hidden">
|
||||
<div
|
||||
class="transform-gpu logo-container"
|
||||
:class="[currentSection === SECTIONS.HOME ? 'home-section' : '', `logo-transition-${scrollDirection}`]"
|
||||
:style="fixedLogoStyle"
|
||||
>
|
||||
<Transition :name="logoTransitionName">
|
||||
<AetherLineByLineLogo
|
||||
v-if="currentSection === SECTIONS.HOME"
|
||||
ref="aetherLogoRef"
|
||||
key="aether-logo"
|
||||
:size="400"
|
||||
:line-delay="50"
|
||||
:stroke-duration="1200"
|
||||
:fill-duration="1500"
|
||||
:auto-start="false"
|
||||
:loop="true"
|
||||
:loop-pause="800"
|
||||
:stroke-width="3.5"
|
||||
:cycle-colors="true"
|
||||
:is-dark="isDark"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:key="`ripple-wrapper-${currentLogoType}`"
|
||||
:class="{ 'heartbeat-wrapper': currentSection === SECTIONS.GEMINI && geminiFillComplete }"
|
||||
>
|
||||
<RippleLogo
|
||||
ref="rippleLogoRef"
|
||||
:type="currentLogoType"
|
||||
:size="320"
|
||||
:use-adaptive="false"
|
||||
:disable-ripple="currentSection === SECTIONS.GEMINI || currentSection === SECTIONS.FEATURES"
|
||||
:anim-delay="logoTransitionDelay"
|
||||
:static="currentSection === SECTIONS.FEATURES"
|
||||
:class="[currentLogoClass, 'logo-active']"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 0: Introduction -->
|
||||
<section ref="section0" class="min-h-screen snap-start flex items-center justify-center px-16 lg:px-20 py-20">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<div class="h-80 w-full mb-16" />
|
||||
<h1
|
||||
class="mb-6 text-5xl md:text-7xl font-bold text-[#191919] dark:text-white leading-tight transition-all duration-700"
|
||||
:style="getTitleStyle(SECTIONS.HOME)"
|
||||
>
|
||||
欢迎使用 <span class="text-primary">Aether</span>
|
||||
</h1>
|
||||
<p
|
||||
class="mb-8 text-xl text-[#666663] dark:text-gray-300 max-w-2xl mx-auto transition-all duration-700"
|
||||
:style="getDescStyle(SECTIONS.HOME)"
|
||||
>
|
||||
AI 开发工具统一接入平台<br />
|
||||
整合 Claude Code、Codex CLI、Gemini CLI 等多个 AI 编程助手
|
||||
</p>
|
||||
<button
|
||||
@click="scrollToSection(SECTIONS.CLAUDE)"
|
||||
class="mt-16 transition-all duration-700 cursor-pointer hover:scale-110"
|
||||
:style="getScrollIndicatorStyle(SECTIONS.HOME)"
|
||||
>
|
||||
<ChevronDown class="h-8 w-8 mx-auto text-[#91918d] dark:text-muted-foreground/80 animate-bounce" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 1: Claude Code -->
|
||||
<CliSection
|
||||
ref="section1"
|
||||
title="Claude Code"
|
||||
description="直接在您的终端中释放Claude的原始力量。瞬间搜索百万行代码库。将数小时的流程转化为单一命令。您的工具。您的流程。您的代码库,以思维速度进化。"
|
||||
:badge-icon="Code2"
|
||||
badge-text="IDE 集成"
|
||||
badge-class="bg-[#cc785c]/10 dark:bg-amber-900/30 border border-[#cc785c]/20 dark:border-amber-800 text-[#cc785c] dark:text-amber-400"
|
||||
v-model:platform-value="claudePlatform"
|
||||
:platform-options="platformPresets.claude.options"
|
||||
:install-command="claudeInstallCommand"
|
||||
:config-files="[{ path: '~/.claude/settings.json', content: claudeConfig, language: 'json' }]"
|
||||
:badge-style="getBadgeStyle(SECTIONS.CLAUDE)"
|
||||
:title-style="getTitleStyle(SECTIONS.CLAUDE)"
|
||||
:desc-style="getDescStyle(SECTIONS.CLAUDE)"
|
||||
:card-style-fn="(idx) => getCardStyle(SECTIONS.CLAUDE, idx)"
|
||||
content-position="right"
|
||||
@copy="copyToClipboard"
|
||||
/>
|
||||
|
||||
<!-- Section 2: Codex CLI -->
|
||||
<CliSection
|
||||
ref="section2"
|
||||
title="Codex CLI"
|
||||
description="Codex CLI 是一款可在本地终端运行的编程助手工具,它能够读取、修改并执行用户指定目录中的代码。"
|
||||
:badge-icon="Terminal"
|
||||
badge-text="命令行工具"
|
||||
badge-class="bg-[#cc785c]/10 dark:bg-emerald-900/30 border border-[#cc785c]/20 dark:border-emerald-800 text-[#cc785c] dark:text-emerald-400"
|
||||
v-model:platform-value="codexPlatform"
|
||||
:platform-options="platformPresets.codex.options"
|
||||
:install-command="codexInstallCommand"
|
||||
:config-files="[
|
||||
{ path: '~/.codex/config.toml', content: codexConfig, language: 'toml' },
|
||||
{ path: '~/.codex/auth.json', content: codexAuthConfig, language: 'json' }
|
||||
]"
|
||||
:badge-style="getBadgeStyle(SECTIONS.CODEX)"
|
||||
:title-style="getTitleStyle(SECTIONS.CODEX)"
|
||||
:desc-style="getDescStyle(SECTIONS.CODEX)"
|
||||
:card-style-fn="(idx) => getCardStyle(SECTIONS.CODEX, idx)"
|
||||
content-position="left"
|
||||
@copy="copyToClipboard"
|
||||
/>
|
||||
|
||||
<!-- Section 3: Gemini CLI -->
|
||||
<CliSection
|
||||
ref="section3"
|
||||
title="Gemini CLI"
|
||||
description="Gemini CLI 是一款开源人工智能代理,可将 Gemini 的强大功能直接带入你的终端。它提供了对 Gemini 的轻量级访问,为你提供了从提示符到我们模型的最直接路径。"
|
||||
:badge-icon="Sparkles"
|
||||
badge-text="多模态 AI"
|
||||
badge-class="bg-[#cc785c]/10 dark:bg-primary/20 border border-[#cc785c]/20 dark:border-primary/30 text-[#cc785c] dark:text-primary"
|
||||
v-model:platform-value="geminiPlatform"
|
||||
:platform-options="platformPresets.gemini.options"
|
||||
:install-command="geminiInstallCommand"
|
||||
:config-files="[
|
||||
{ path: '~/.gemini/.env', content: geminiEnvConfig, language: 'dotenv' },
|
||||
{ path: '~/.gemini/settings.json', content: geminiSettingsConfig, language: 'json' }
|
||||
]"
|
||||
:badge-style="getBadgeStyle(SECTIONS.GEMINI)"
|
||||
:title-style="getTitleStyle(SECTIONS.GEMINI)"
|
||||
:desc-style="getDescStyle(SECTIONS.GEMINI)"
|
||||
:card-style-fn="(idx) => getCardStyle(SECTIONS.GEMINI, idx)"
|
||||
content-position="right"
|
||||
@copy="copyToClipboard"
|
||||
>
|
||||
<template #logo>
|
||||
<GeminiStarCluster :is-visible="currentSection === SECTIONS.GEMINI && sectionVisibility[SECTIONS.GEMINI] > 0.05" />
|
||||
</template>
|
||||
</CliSection>
|
||||
|
||||
<!-- Section 4: Features -->
|
||||
<section ref="section4" class="min-h-screen snap-start flex items-center justify-center px-16 lg:px-20 py-20 relative overflow-hidden">
|
||||
<div class="max-w-4xl mx-auto text-center relative z-10">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full bg-[#cc785c]/10 dark:bg-purple-500/20 border border-[#cc785c]/20 dark:border-purple-500/40 px-4 py-2 text-sm font-medium text-[#cc785c] dark:text-purple-300 mb-6 backdrop-blur-sm transition-all duration-500"
|
||||
:style="getBadgeStyle(SECTIONS.FEATURES)"
|
||||
>
|
||||
<Sparkles class="h-4 w-4" />
|
||||
项目进度
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="text-4xl md:text-5xl font-bold text-[#191919] dark:text-white mb-6 transition-all duration-700"
|
||||
:style="getTitleStyle(SECTIONS.FEATURES)"
|
||||
>
|
||||
功能开发进度
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="text-lg text-[#666663] dark:text-gray-300 mb-12 max-w-2xl mx-auto transition-all duration-700"
|
||||
:style="getDescStyle(SECTIONS.FEATURES)"
|
||||
>
|
||||
核心 API 代理功能已完成,正在载入更多功能
|
||||
</p>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="(feature, idx) in featureCards"
|
||||
:key="idx"
|
||||
class="bg-white/70 dark:bg-[#262624]/80 backdrop-blur-sm rounded-2xl p-6 border border-[#e5e4df] dark:border-[rgba(227,224,211,0.16)] hover:border-[#cc785c]/30 dark:hover:border-[#d4a27f]/40 transition-all duration-700"
|
||||
:style="getFeatureCardStyle(SECTIONS.FEATURES, idx)"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-xl mb-4 mx-auto"
|
||||
:class="feature.status === 'completed'
|
||||
? 'bg-emerald-500/10 dark:bg-emerald-500/15'
|
||||
: 'bg-[#cc785c]/10 dark:bg-[#cc785c]/15'"
|
||||
>
|
||||
<component
|
||||
:is="feature.icon"
|
||||
class="h-6 w-6"
|
||||
:class="feature.status === 'completed'
|
||||
? 'text-emerald-500 dark:text-emerald-400'
|
||||
: 'text-[#cc785c] dark:text-[#d4a27f] animate-spin'"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-[#191919] dark:text-white mb-2">{{ feature.title }}</h3>
|
||||
<p class="text-sm text-[#666663] dark:text-[#c9c3b4]">{{ feature.desc }}</p>
|
||||
<div
|
||||
class="mt-3 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
:class="feature.status === 'completed'
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400'"
|
||||
>
|
||||
{{ feature.status === 'completed' ? '已完成' : '进行中' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 transition-all duration-700" :style="getButtonsStyle(SECTIONS.FEATURES)">
|
||||
<RouterLink
|
||||
v-if="authStore.isAuthenticated"
|
||||
:to="dashboardPath"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-primary hover:bg-primary/90 px-6 py-3 text-base font-semibold text-white shadow-lg shadow-primary/30 transition hover:shadow-primary/50 hover:scale-105"
|
||||
>
|
||||
<Rocket class="h-5 w-5" />
|
||||
立即开始使用
|
||||
</RouterLink>
|
||||
<button
|
||||
v-else
|
||||
@click="showLoginDialog = true"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-primary hover:bg-primary/90 px-6 py-3 text-base font-semibold text-white shadow-lg shadow-primary/30 transition hover:shadow-primary/50 hover:scale-105"
|
||||
>
|
||||
<Rocket class="h-5 w-5" />
|
||||
立即开始使用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="relative z-10 border-t border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-md py-8">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<p class="text-sm text-[#91918d] dark:text-muted-foreground">© 2025 Aether. 团队内部使用</p>
|
||||
<div class="flex items-center gap-6 text-sm text-[#91918d] dark:text-muted-foreground">
|
||||
<a href="#" class="transition hover:text-[#191919] dark:hover:text-white">使用条款</a>
|
||||
<a href="#" class="transition hover:text-[#191919] dark:hover:text-white">隐私政策</a>
|
||||
<a href="#" class="transition hover:text-[#191919] dark:hover:text-white">技术支持</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<LoginDialog v-model="showLoginDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import {
|
||||
ChevronDown,
|
||||
Code2,
|
||||
Moon,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
Sun,
|
||||
SunMoon,
|
||||
Terminal
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import LoginDialog from '@/features/auth/components/LoginDialog.vue'
|
||||
import RippleLogo from '@/components/RippleLogo.vue'
|
||||
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||
import AetherLineByLineLogo from '@/components/AetherLineByLineLogo.vue'
|
||||
import GeminiStarCluster from '@/components/GeminiStarCluster.vue'
|
||||
import CliSection from './CliSection.vue'
|
||||
import {
|
||||
SECTIONS,
|
||||
sections,
|
||||
featureCards,
|
||||
useCliConfigs,
|
||||
platformPresets,
|
||||
getInstallCommand,
|
||||
getLogoType,
|
||||
getLogoClass
|
||||
} from './home-config'
|
||||
import {
|
||||
useSectionAnimations,
|
||||
useLogoPosition,
|
||||
useLogoTransition
|
||||
} from './useSectionAnimations'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { isDark, themeMode, toggleDarkMode } = useDarkMode()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const dashboardPath = computed(() =>
|
||||
authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard'
|
||||
)
|
||||
const baseUrl = computed(() => window.location.origin)
|
||||
|
||||
// Scroll state
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const currentSection = ref(0)
|
||||
const previousSection = ref(0)
|
||||
const scrollDirection = ref<'up' | 'down'>('down')
|
||||
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1024)
|
||||
const sectionVisibility = ref<number[]>([0, 0, 0, 0, 0])
|
||||
let lastScrollY = 0
|
||||
|
||||
// Section refs - section0 and section4 are direct HTML elements, section1-3 are CliSection components
|
||||
const section0 = ref<HTMLElement | null>(null)
|
||||
const section1 = ref<InstanceType<typeof CliSection> | null>(null)
|
||||
const section2 = ref<InstanceType<typeof CliSection> | null>(null)
|
||||
const section3 = ref<InstanceType<typeof CliSection> | null>(null)
|
||||
const section4 = ref<HTMLElement | null>(null)
|
||||
|
||||
// Helper to get DOM element from ref (handles both direct elements and component instances)
|
||||
const getSectionElement = (index: number): HTMLElement | null => {
|
||||
switch (index) {
|
||||
case 0: return section0.value
|
||||
case 1: return (section1.value?.sectionEl as HTMLElement | null | undefined) ?? null
|
||||
case 2: return (section2.value?.sectionEl as HTMLElement | null | undefined) ?? null
|
||||
case 3: return (section3.value?.sectionEl as HTMLElement | null | undefined) ?? null
|
||||
case 4: return section4.value
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
// Logo refs
|
||||
const aetherLogoRef = ref<InstanceType<typeof AetherLineByLineLogo> | null>(null)
|
||||
const rippleLogoRef = ref<InstanceType<typeof RippleLogo> | null>(null)
|
||||
const hasLogoAnimationStarted = ref(false)
|
||||
const geminiFillComplete = ref(false)
|
||||
|
||||
// Animation composables
|
||||
const {
|
||||
getBadgeStyle,
|
||||
getTitleStyle,
|
||||
getDescStyle,
|
||||
getButtonsStyle,
|
||||
getScrollIndicatorStyle,
|
||||
getCardStyle,
|
||||
getFeatureCardStyle
|
||||
} = useSectionAnimations(sectionVisibility)
|
||||
|
||||
const { fixedLogoStyle } = useLogoPosition(currentSection, windowWidth)
|
||||
const { logoTransitionName } = useLogoTransition(currentSection, previousSection)
|
||||
|
||||
// Logo computed
|
||||
const currentLogoType = computed(() => getLogoType(currentSection.value))
|
||||
const currentLogoClass = computed(() => getLogoClass(currentSection.value))
|
||||
const logoTransitionDelay = computed(() => {
|
||||
if (currentSection.value === SECTIONS.FEATURES) return 0
|
||||
if (previousSection.value === SECTIONS.FEATURES) return 200
|
||||
return 500
|
||||
})
|
||||
|
||||
// Platform states
|
||||
const claudePlatform = ref(platformPresets.claude.defaultValue)
|
||||
const codexPlatform = ref(platformPresets.codex.defaultValue)
|
||||
const geminiPlatform = ref(platformPresets.gemini.defaultValue)
|
||||
|
||||
// Install commands
|
||||
const claudeInstallCommand = computed(() => getInstallCommand('claude', claudePlatform.value))
|
||||
const codexInstallCommand = computed(() => getInstallCommand('codex', codexPlatform.value))
|
||||
const geminiInstallCommand = computed(() => getInstallCommand('gemini', geminiPlatform.value))
|
||||
|
||||
// CLI configs
|
||||
const { claudeConfig, codexConfig, codexAuthConfig, geminiEnvConfig, geminiSettingsConfig } =
|
||||
useCliConfigs(baseUrl)
|
||||
|
||||
// Dialog state
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
// Scroll handling
|
||||
let scrollEndTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const calculateVisibility = (element: HTMLElement | null): number => {
|
||||
if (!element) return 0
|
||||
const rect = element.getBoundingClientRect()
|
||||
const containerHeight = window.innerHeight
|
||||
if (rect.bottom < 0 || rect.top > containerHeight) return 0
|
||||
const elementCenter = rect.top + rect.height / 2
|
||||
const viewportCenter = containerHeight / 2
|
||||
const distanceFromCenter = Math.abs(elementCenter - viewportCenter)
|
||||
const maxDistance = containerHeight / 2 + rect.height / 2
|
||||
return Math.max(0, 1 - distanceFromCenter / maxDistance)
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollContainer.value) return
|
||||
|
||||
const containerHeight = window.innerHeight
|
||||
const newScrollY = scrollContainer.value.scrollTop
|
||||
|
||||
// Track scroll direction
|
||||
scrollDirection.value = newScrollY > lastScrollY ? 'down' : 'up'
|
||||
lastScrollY = newScrollY
|
||||
|
||||
// Update visibility
|
||||
for (let i = 0; i < 5; i++) {
|
||||
sectionVisibility.value[i] = calculateVisibility(getSectionElement(i))
|
||||
}
|
||||
|
||||
// Update current section
|
||||
const scrollMiddle = newScrollY + containerHeight / 2
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
const section = getSectionElement(i)
|
||||
if (section && section.offsetTop <= scrollMiddle) {
|
||||
if (currentSection.value !== i) {
|
||||
previousSection.value = currentSection.value
|
||||
currentSection.value = i
|
||||
hasLogoAnimationStarted.value = false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Detect snap complete
|
||||
if (scrollEndTimer) clearTimeout(scrollEndTimer)
|
||||
scrollEndTimer = setTimeout(() => {
|
||||
if (currentSection.value === SECTIONS.HOME && !hasLogoAnimationStarted.value) {
|
||||
hasLogoAnimationStarted.value = true
|
||||
setTimeout(() => aetherLogoRef.value?.startAnimation(), 100)
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const scrollToSection = (index: number) => {
|
||||
const target = getSectionElement(index)
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// Watch Gemini fill complete
|
||||
watch(
|
||||
() => rippleLogoRef.value?.fillComplete,
|
||||
(val) => {
|
||||
if (currentSection.value === SECTIONS.GEMINI && val) geminiFillComplete.value = true
|
||||
}
|
||||
)
|
||||
|
||||
watch(currentSection, (_, old) => {
|
||||
if (old === SECTIONS.GEMINI) geminiFillComplete.value = false
|
||||
})
|
||||
|
||||
const handleResize = () => {
|
||||
windowWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollContainer.value?.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
handleScroll()
|
||||
|
||||
// Initial animation
|
||||
setTimeout(() => {
|
||||
if (currentSection.value === SECTIONS.HOME && !hasLogoAnimationStarted.value) {
|
||||
hasLogoAnimationStarted.value = true
|
||||
setTimeout(() => aetherLogoRef.value?.startAnimation(), 100)
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
scrollContainer.value?.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (scrollEndTimer) clearTimeout(scrollEndTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Typography */
|
||||
h1, h2, h3 {
|
||||
font-family: var(--serif);
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: var(--serif);
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
button, nav, a, .inline-flex {
|
||||
font-family: var(--sans-serif);
|
||||
}
|
||||
|
||||
/* Panel styles */
|
||||
.command-panel-surface {
|
||||
border-color: var(--color-border);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.dark .command-panel-surface {
|
||||
background: rgba(38, 38, 36, 0.3);
|
||||
}
|
||||
|
||||
/* Performance */
|
||||
h1, h2, p {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Scroll indicator */
|
||||
.scroll-indicator {
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.scroll-indicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-indicator-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.scroll-indicator-label {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #666663;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dark .scroll-indicator-label {
|
||||
color: #a0a0a0;
|
||||
background: rgba(25, 23, 20, 0.9);
|
||||
}
|
||||
|
||||
.scroll-indicator-btn:hover .scroll-indicator-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.scroll-indicator-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #d4d4d4;
|
||||
background: transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .scroll-indicator-dot {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.scroll-indicator-dot.active {
|
||||
background: #cc785c;
|
||||
border-color: #cc785c;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
/* Logo transitions */
|
||||
.logo-scale-enter-active {
|
||||
transition: opacity 0.5s ease-out, transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.logo-scale-leave-active {
|
||||
transition: opacity 0.3s ease-in, transform 0.3s ease-in;
|
||||
}
|
||||
|
||||
.logo-scale-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.6) rotate(-8deg);
|
||||
}
|
||||
|
||||
.logo-scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.2) rotate(8deg);
|
||||
}
|
||||
|
||||
.logo-slide-left-enter-active,
|
||||
.logo-slide-right-enter-active {
|
||||
transition: opacity 0.4s ease-out, transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.logo-slide-left-leave-active,
|
||||
.logo-slide-right-leave-active {
|
||||
transition: opacity 0.25s ease-in, transform 0.3s ease-in;
|
||||
}
|
||||
|
||||
.logo-slide-left-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(60px) scale(0.9);
|
||||
}
|
||||
|
||||
.logo-slide-left-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-60px) scale(0.9);
|
||||
}
|
||||
|
||||
.logo-slide-right-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-60px) scale(0.9);
|
||||
}
|
||||
|
||||
.logo-slide-right-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(60px) scale(0.9);
|
||||
}
|
||||
|
||||
/* Logo container */
|
||||
.logo-container {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-container.home-section {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.logo-container > * {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.logo-container {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
}
|
||||
.logo-container.home-section {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Heartbeat animation */
|
||||
.heartbeat-wrapper {
|
||||
animation: heartbeat 1.5s ease-in-out infinite;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@keyframes heartbeat {
|
||||
0%, 70%, 100% { transform: scale(1); }
|
||||
14% { transform: scale(1.06); }
|
||||
28% { transform: scale(1); }
|
||||
42% { transform: scale(1.1); }
|
||||
}
|
||||
</style>
|
||||
253
frontend/src/views/public/LogoColorDemo.vue
Normal file
253
frontend/src/views/public/LogoColorDemo.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#fafaf7] dark:bg-[#191714] p-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h1 class="text-3xl font-bold text-center mb-2 text-[#191919] dark:text-white">Logo 颜色方案对比</h1>
|
||||
<p class="text-center text-[#666663] dark:text-gray-400 mb-8">点击任意方案可以放大预览</p>
|
||||
|
||||
<!-- Color schemes grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="(scheme, index) in colorSchemes"
|
||||
:key="index"
|
||||
class="relative bg-white dark:bg-[#262624] rounded-2xl p-6 border border-[#e5e4df] dark:border-[rgba(227,224,211,0.16)] cursor-pointer transition-all hover:shadow-lg hover:scale-[1.02]"
|
||||
:class="{ 'ring-2 ring-primary': selectedScheme === index }"
|
||||
@click="selectScheme(index)"
|
||||
>
|
||||
<!-- Scheme name badge -->
|
||||
<div class="absolute top-3 left-3 px-2 py-1 rounded-full text-xs font-medium"
|
||||
:style="{ backgroundColor: scheme.primary + '20', color: scheme.primary }">
|
||||
{{ scheme.name }}
|
||||
</div>
|
||||
|
||||
<!-- Logo preview -->
|
||||
<div class="flex items-center justify-center h-48 mb-4">
|
||||
<svg
|
||||
viewBox="0 0 800 800"
|
||||
class="w-40 h-40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient :id="`gradient-${index}`" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" :stop-color="scheme.primary" />
|
||||
<stop offset="50%" :stop-color="scheme.secondary" />
|
||||
<stop offset="100%" :stop-color="scheme.primary" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Fill -->
|
||||
<path
|
||||
:d="fullPath"
|
||||
:fill="`url(#gradient-${index})`"
|
||||
fill-rule="evenodd"
|
||||
opacity="0.7"
|
||||
/>
|
||||
|
||||
<!-- Lines -->
|
||||
<path
|
||||
v-for="(path, pathIndex) in linePaths"
|
||||
:key="pathIndex"
|
||||
:d="path"
|
||||
fill="none"
|
||||
:stroke="scheme.primary"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Color swatches -->
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full border-2 border-white shadow"
|
||||
:style="{ backgroundColor: scheme.primary }"
|
||||
></div>
|
||||
<span class="text-xs text-[#666663] dark:text-gray-400 mt-1">{{ scheme.primary }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full border-2 border-white shadow"
|
||||
:style="{ backgroundColor: scheme.secondary }"
|
||||
></div>
|
||||
<span class="text-xs text-[#666663] dark:text-gray-400 mt-1">{{ scheme.secondary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-center text-sm text-[#666663] dark:text-gray-400 mt-3">{{ scheme.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Large preview modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showPreview"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click="showPreview = false"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark:bg-[#262624] rounded-3xl p-8 max-w-lg w-full mx-4 shadow-2xl"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-[#191919] dark:text-white">
|
||||
{{ colorSchemes[selectedScheme].name }}
|
||||
</h2>
|
||||
<button
|
||||
@click="showPreview = false"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Animated logo -->
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<AetherLineByLineLogo
|
||||
:key="selectedScheme"
|
||||
:size="300"
|
||||
:line-delay="120"
|
||||
:stroke-duration="2000"
|
||||
:color-duration="1200"
|
||||
:auto-start="true"
|
||||
:loop="true"
|
||||
:loop-pause="300"
|
||||
:stroke-width="3.5"
|
||||
:outline-color="colorSchemes[selectedScheme].primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Color info -->
|
||||
<div class="flex items-center justify-center gap-6 mt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-6 h-6 rounded-full border-2 border-white shadow"
|
||||
:style="{ backgroundColor: colorSchemes[selectedScheme].primary }"
|
||||
></div>
|
||||
<span class="text-sm font-mono text-[#666663] dark:text-gray-400">
|
||||
{{ colorSchemes[selectedScheme].primary }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-6 h-6 rounded-full border-2 border-white shadow"
|
||||
:style="{ backgroundColor: colorSchemes[selectedScheme].secondary }"
|
||||
></div>
|
||||
<span class="text-sm font-mono text-[#666663] dark:text-gray-400">
|
||||
{{ colorSchemes[selectedScheme].secondary }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply button -->
|
||||
<div class="mt-6 text-center">
|
||||
<button
|
||||
@click="applyScheme"
|
||||
class="px-6 py-2 bg-primary text-white rounded-xl font-medium hover:bg-primary/90 transition"
|
||||
>
|
||||
应用此方案
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Back button -->
|
||||
<div class="mt-8 text-center">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-[#666663] dark:text-gray-400 hover:text-[#191919] dark:hover:text-white transition"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回首页
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AetherLineByLineLogo from '@/components/AetherLineByLineLogo.vue'
|
||||
import { AETHER_LINE_PATHS, AETHER_FULL_PATH } from '@/constants/logoPaths'
|
||||
|
||||
const linePaths = AETHER_LINE_PATHS
|
||||
const fullPath = AETHER_FULL_PATH
|
||||
|
||||
const colorSchemes = [
|
||||
{
|
||||
name: '当前配色 - 暖橙',
|
||||
primary: '#cc785c',
|
||||
secondary: '#e8a882',
|
||||
description: '温暖的赤陶色,亲和力强'
|
||||
},
|
||||
{
|
||||
name: '深金色调',
|
||||
primary: '#b08d57',
|
||||
secondary: '#d4b896',
|
||||
description: '古铜金色,更加沉稳大气'
|
||||
},
|
||||
{
|
||||
name: '玫瑰红调',
|
||||
primary: '#a4636c',
|
||||
secondary: '#d4a5aa',
|
||||
description: '优雅的玫瑰红,细腻温婉'
|
||||
},
|
||||
{
|
||||
name: '青铜绿调',
|
||||
primary: '#7a8c70',
|
||||
secondary: '#a8b8a0',
|
||||
description: '自然青铜色,清新科技感'
|
||||
},
|
||||
{
|
||||
name: '深紫藤调',
|
||||
primary: '#7d6b8a',
|
||||
secondary: '#b5a8c2',
|
||||
description: '紫藤色调,神秘优雅'
|
||||
},
|
||||
{
|
||||
name: '黑金商务',
|
||||
primary: '#3d3833',
|
||||
secondary: '#8b7355',
|
||||
description: '炭黑配金色,高端商务风'
|
||||
},
|
||||
{
|
||||
name: '海蓝科技',
|
||||
primary: '#4a7c8c',
|
||||
secondary: '#8ab8c8',
|
||||
description: '深海蓝色,科技专业感'
|
||||
},
|
||||
{
|
||||
name: '焦糖棕调',
|
||||
primary: '#8b6b4a',
|
||||
secondary: '#c4a882',
|
||||
description: '焦糖棕色,复古温暖'
|
||||
},
|
||||
{
|
||||
name: '石墨灰调',
|
||||
primary: '#5a5a5a',
|
||||
secondary: '#9a9a9a',
|
||||
description: '中性石墨色,简约现代'
|
||||
}
|
||||
]
|
||||
|
||||
const selectedScheme = ref(0)
|
||||
const showPreview = ref(false)
|
||||
|
||||
const selectScheme = (index: number) => {
|
||||
selectedScheme.value = index
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
const applyScheme = () => {
|
||||
const scheme = colorSchemes[selectedScheme.value]
|
||||
alert(`应用方案: ${scheme.name}\n\n请将以下颜色值更新到代码中:\n主色: ${scheme.primary}\n过渡色: ${scheme.secondary}\n\n需要修改的文件:\n1. AetherLineByLineLogo.vue - gradient 颜色\n2. Home.vue - outlineColor 属性`)
|
||||
showPreview.value = false
|
||||
}
|
||||
</script>
|
||||
171
frontend/src/views/public/home-config.ts
Normal file
171
frontend/src/views/public/home-config.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import { Apple, Box, Check, Loader2, Monitor, Terminal } from 'lucide-vue-next'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
// Section index constants
|
||||
export const SECTIONS = {
|
||||
HOME: 0,
|
||||
CLAUDE: 1,
|
||||
CODEX: 2,
|
||||
GEMINI: 3,
|
||||
FEATURES: 4
|
||||
} as const
|
||||
|
||||
export type SectionIndex = (typeof SECTIONS)[keyof typeof SECTIONS]
|
||||
|
||||
// Section navigation configuration
|
||||
export const sections = [
|
||||
{ name: '首页' },
|
||||
{ name: 'Claude' },
|
||||
{ name: 'OpenAI' },
|
||||
{ name: 'Gemini' },
|
||||
{ name: '更多' }
|
||||
] as const
|
||||
|
||||
// Feature cards data
|
||||
export const featureCards = [
|
||||
{
|
||||
icon: Check,
|
||||
title: 'Claude / OpenAI / Gemini',
|
||||
desc: '已完整接入三大主流 AI 编程助手的标准 API',
|
||||
status: 'completed' as const
|
||||
},
|
||||
{
|
||||
icon: Check,
|
||||
title: '灵活的 API 扩展',
|
||||
desc: '插件化架构,可快速接入其他 LLM Provider',
|
||||
status: 'completed' as const
|
||||
},
|
||||
{
|
||||
icon: Loader2,
|
||||
title: '项目追踪',
|
||||
desc: '开发日志、代码 Review、文档生成等功能即将到来',
|
||||
status: 'in-progress' as const
|
||||
}
|
||||
]
|
||||
|
||||
// CLI configuration generators
|
||||
export function useCliConfigs(baseUrl: Ref<string>) {
|
||||
const claudeConfig = computed(() => `{
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key",
|
||||
"ANTHROPIC_BASE_URL": "${baseUrl.value}"
|
||||
}
|
||||
}`)
|
||||
|
||||
const codexConfig = computed(() => `model_provider = "aether"
|
||||
model = "latest-model-name"
|
||||
model_reasoning_effort = "high"
|
||||
network_access = "enabled"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.aether]
|
||||
name = "aether"
|
||||
base_url = "${baseUrl.value}/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true`)
|
||||
|
||||
const codexAuthConfig = computed(() => `{
|
||||
"OPENAI_API_KEY": "your-api-key"
|
||||
}`)
|
||||
|
||||
const geminiEnvConfig = computed(() => `GOOGLE_GEMINI_BASE_URL=${baseUrl.value}
|
||||
GEMINI_API_KEY=your-api-key
|
||||
GEMINI_MODEL=latest-model-name`)
|
||||
|
||||
const geminiSettingsConfig = computed(() => `{
|
||||
"ide": {
|
||||
"enabled": true
|
||||
},
|
||||
"security": {
|
||||
"auth": {
|
||||
"selectedType": "gemini-api-key"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
return {
|
||||
claudeConfig,
|
||||
codexConfig,
|
||||
codexAuthConfig,
|
||||
geminiEnvConfig,
|
||||
geminiSettingsConfig
|
||||
}
|
||||
}
|
||||
|
||||
// CSS class constants
|
||||
export const panelClasses = {
|
||||
commandPanel: 'rounded-xl border command-panel-surface',
|
||||
configPanel: 'rounded-xl border config-panel',
|
||||
panelHeader: 'px-4 py-2 panel-header',
|
||||
codeBody: 'code-panel-body',
|
||||
iconButtonSmall: [
|
||||
'flex items-center justify-center rounded-lg border h-7 w-7',
|
||||
'border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)]',
|
||||
'bg-transparent',
|
||||
'text-[#666663] dark:text-[#f1ead8]',
|
||||
'transition hover:bg-[#f0f0eb] dark:hover:bg-[#3a3731]'
|
||||
].join(' ')
|
||||
} as const
|
||||
|
||||
// Platform option type
|
||||
export interface PlatformOption {
|
||||
value: string
|
||||
label: string
|
||||
hint: string
|
||||
icon: Component
|
||||
command: string
|
||||
}
|
||||
|
||||
// Platform presets configuration
|
||||
export const platformPresets = {
|
||||
claude: {
|
||||
options: [
|
||||
{ value: 'mac', label: 'Mac / Linux', hint: 'Terminal', icon: Terminal, command: 'curl -fsSL https://claude.ai/install.sh | bash' },
|
||||
{ value: 'windows', label: 'Windows', hint: 'PowerShell', icon: Monitor, command: 'irm https://claude.ai/install.ps1 | iex' },
|
||||
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @anthropic-ai/claude-code' },
|
||||
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install --cask claude-code' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'mac'
|
||||
},
|
||||
codex: {
|
||||
options: [
|
||||
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @openai/codex' },
|
||||
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install --cask codex' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'nodejs'
|
||||
},
|
||||
gemini: {
|
||||
options: [
|
||||
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @google/gemini-cli' },
|
||||
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install gemini-cli' }
|
||||
] as PlatformOption[],
|
||||
defaultValue: 'nodejs'
|
||||
}
|
||||
} as const
|
||||
|
||||
// Helper to get command by platform value
|
||||
export function getInstallCommand(preset: keyof typeof platformPresets, value: string): string {
|
||||
const config = platformPresets[preset]
|
||||
return config.options.find((opt) => opt.value === value)?.command ?? ''
|
||||
}
|
||||
|
||||
// Logo type mapping
|
||||
export function getLogoType(section: number): 'claude' | 'openai' | 'gemini' | 'aether' {
|
||||
switch (section) {
|
||||
case SECTIONS.CLAUDE: return 'claude'
|
||||
case SECTIONS.CODEX: return 'openai'
|
||||
case SECTIONS.GEMINI: return 'gemini'
|
||||
default: return 'aether'
|
||||
}
|
||||
}
|
||||
|
||||
// Logo color class mapping
|
||||
export function getLogoClass(section: number): string {
|
||||
switch (section) {
|
||||
case SECTIONS.CLAUDE: return 'text-[#D97757]'
|
||||
case SECTIONS.CODEX: return 'text-[#191919] dark:text-white'
|
||||
case SECTIONS.GEMINI: return '' // Gemini uses gradient
|
||||
default: return 'text-[#191919] dark:text-white'
|
||||
}
|
||||
}
|
||||
199
frontend/src/views/public/useSectionAnimations.ts
Normal file
199
frontend/src/views/public/useSectionAnimations.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { computed, type Ref, type CSSProperties } from 'vue'
|
||||
import { SECTIONS } from './home-config'
|
||||
|
||||
// Animation configuration constants
|
||||
const ANIMATION_CONFIG = {
|
||||
delays: {
|
||||
badge: 0,
|
||||
title: 0.1,
|
||||
desc: 0.2,
|
||||
buttons: 0.3,
|
||||
scrollIndicator: 0.4,
|
||||
cardBase: 0.25,
|
||||
cardIncrement: 0.1,
|
||||
featureCardBase: 0.2,
|
||||
featureCardIncrement: 0.15
|
||||
},
|
||||
translateY: {
|
||||
home: 30,
|
||||
cli: 10,
|
||||
badge: 8,
|
||||
featureCard: 30
|
||||
},
|
||||
translateX: {
|
||||
badge: 24,
|
||||
title: 32,
|
||||
desc: 28,
|
||||
buttons: 24,
|
||||
card: 20
|
||||
}
|
||||
} as const
|
||||
|
||||
// Get horizontal direction based on section layout
|
||||
// Claude(1) and Gemini(3) content on right, slide from right
|
||||
// Codex(2) content on left, slides from left
|
||||
function getDirectionMultiplier(index: number): number {
|
||||
if (index === SECTIONS.CLAUDE || index === SECTIONS.GEMINI) return 1
|
||||
if (index === SECTIONS.CODEX) return -1
|
||||
return 0
|
||||
}
|
||||
|
||||
function getHorizontalOffset(index: number, distance: number, progress: number): number {
|
||||
const direction = getDirectionMultiplier(index)
|
||||
if (direction === 0) return 0
|
||||
return (1 - progress) * distance * direction
|
||||
}
|
||||
|
||||
export function useSectionAnimations(sectionVisibility: Ref<number[]>) {
|
||||
const { delays, translateY, translateX } = ANIMATION_CONFIG
|
||||
|
||||
// Style generators for different elements
|
||||
const getBadgeStyle = (index: number): CSSProperties => {
|
||||
const visibility = sectionVisibility.value[index]
|
||||
const opacity = Math.min(1, visibility * 3)
|
||||
const progress = Math.min(1, visibility * 2)
|
||||
const direction = getDirectionMultiplier(index)
|
||||
const offsetX = getHorizontalOffset(index, translateX.badge, progress)
|
||||
const offsetY = direction === 0 ? (1 - progress) * translateY.badge : 0
|
||||
return {
|
||||
opacity,
|
||||
transform: `translate(${offsetX}px, ${offsetY}px)`
|
||||
}
|
||||
}
|
||||
|
||||
const getTitleStyle = (index: number): CSSProperties => {
|
||||
const visibility = sectionVisibility.value[index]
|
||||
const adjustedVisibility = Math.max(0, visibility - delays.title) / (1 - delays.title)
|
||||
const progress = Math.min(1, adjustedVisibility * 2)
|
||||
const yBase = getDirectionMultiplier(index) === 0 ? translateY.home : translateY.cli
|
||||
const offsetY = (1 - progress) * yBase
|
||||
const offsetX = getHorizontalOffset(index, translateX.title, progress)
|
||||
return {
|
||||
opacity: progress,
|
||||
transform: `translate(${offsetX}px, ${offsetY}px)`
|
||||
}
|
||||
}
|
||||
|
||||
const getDescStyle = (index: number): CSSProperties => {
|
||||
const visibility = sectionVisibility.value[index]
|
||||
const adjustedVisibility = Math.max(0, visibility - delays.desc) / (1 - delays.desc)
|
||||
const progress = Math.min(1, adjustedVisibility * 2)
|
||||
const yBase = getDirectionMultiplier(index) === 0 ? translateY.home : translateY.badge
|
||||
const offsetY = (1 - progress) * yBase
|
||||
const offsetX = getHorizontalOffset(index, translateX.desc, progress)
|
||||
return {
|
||||
opacity: progress,
|
||||
transform: `translate(${offsetX}px, ${offsetY}px)`
|
||||
}
|
||||
}
|
||||
|
||||
const getButtonsStyle = (index: number): CSSProperties => {
|
||||
const visibility = sectionVisibility.value[index]
|
||||
const adjustedVisibility = Math.max(0, visibility - delays.buttons) / (1 - delays.buttons)
|
||||
const progress = Math.min(1, adjustedVisibility * 2)
|
||||
const yBase = getDirectionMultiplier(index) === 0 ? 20 : translateY.badge
|
||||
const offsetY = (1 - progress) * yBase
|
||||
const offsetX = getHorizontalOffset(index, translateX.buttons, progress)
|
||||
return {
|
||||
opacity: progress,
|
||||
transform: `translate(${offsetX}px, ${offsetY}px)`
|
||||
}
|
||||
}
|
||||
|
||||
const getScrollIndicatorStyle = (index: number): CSSProperties => {
|
||||
const visibility = sectionVisibility.value[index]
|
||||
const adjustedVisibility = Math.max(0, visibility - delays.scrollIndicator) / (1 - delays.scrollIndicator)
|
||||
const opacity = Math.min(1, adjustedVisibility * 2)
|
||||
return { opacity }
|
||||
}
|
||||
|
||||
const getCardStyle = (sectionIndex: number, cardIndex: number): CSSProperties => {
|
||||
const visibility = sectionVisibility.value[sectionIndex]
|
||||
const totalDelay = delays.cardBase + cardIndex * delays.cardIncrement
|
||||
const adjustedVisibility = Math.max(0, visibility - totalDelay) / (1 - totalDelay)
|
||||
const progress = Math.min(1, adjustedVisibility * 2)
|
||||
const yBase = getDirectionMultiplier(sectionIndex) === 0 ? 20 : translateY.cli
|
||||
const offsetY = (1 - progress) * yBase
|
||||
const offsetX = getHorizontalOffset(sectionIndex, translateX.card, progress)
|
||||
return {
|
||||
opacity: progress,
|
||||
transform: `translate(${offsetX}px, ${offsetY}px)`
|
||||
}
|
||||
}
|
||||
|
||||
const getFeatureCardStyle = (sectionIndex: number, cardIndex: number): CSSProperties => {
|
||||
const visibility = sectionVisibility.value[sectionIndex]
|
||||
const totalDelay = delays.featureCardBase + cardIndex * delays.featureCardIncrement
|
||||
const adjustedVisibility = Math.max(0, visibility - totalDelay) / (1 - totalDelay)
|
||||
const opacity = Math.min(1, adjustedVisibility * 2)
|
||||
const offsetY = (1 - Math.min(1, adjustedVisibility * 2)) * translateY.featureCard
|
||||
const scale = 0.9 + Math.min(1, adjustedVisibility * 2) * 0.1
|
||||
return {
|
||||
opacity,
|
||||
transform: `translateY(${offsetY}px) scale(${scale})`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getBadgeStyle,
|
||||
getTitleStyle,
|
||||
getDescStyle,
|
||||
getButtonsStyle,
|
||||
getScrollIndicatorStyle,
|
||||
getCardStyle,
|
||||
getFeatureCardStyle
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed logo position style based on current section
|
||||
export function useLogoPosition(
|
||||
currentSection: Ref<number>,
|
||||
windowWidth: Ref<number>
|
||||
) {
|
||||
const fixedLogoStyle = computed(() => {
|
||||
const section = currentSection.value
|
||||
const isDesktop = windowWidth.value >= 768
|
||||
let transform = ''
|
||||
let opacity = 1
|
||||
|
||||
if (section === SECTIONS.HOME) {
|
||||
transform = 'scale(1.1) translateY(-18vh)'
|
||||
opacity = 0.25
|
||||
} else if (section === SECTIONS.CLAUDE) {
|
||||
transform = isDesktop ? 'translateX(-25vw) scale(1)' : 'translateY(-20vh) scale(0.8)'
|
||||
} else if (section === SECTIONS.CODEX) {
|
||||
transform = isDesktop ? 'translateX(25vw) scale(1)' : 'translateY(-20vh) scale(0.8)'
|
||||
} else if (section === SECTIONS.GEMINI) {
|
||||
transform = isDesktop ? 'translateX(-25vw) scale(1)' : 'translateY(-20vh) scale(0.8)'
|
||||
} else {
|
||||
transform = isDesktop ? 'translateX(0) scale(1)' : 'translateY(-20vh) scale(0.8)'
|
||||
opacity = 0.15
|
||||
}
|
||||
|
||||
return {
|
||||
transform,
|
||||
opacity,
|
||||
transition: 'transform 0.8s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.6s ease-out'
|
||||
}
|
||||
})
|
||||
|
||||
return { fixedLogoStyle }
|
||||
}
|
||||
|
||||
// Logo transition name based on scroll direction
|
||||
export function useLogoTransition(
|
||||
currentSection: Ref<number>,
|
||||
previousSection: Ref<number>
|
||||
) {
|
||||
const logoTransitionName = computed(() => {
|
||||
if (currentSection.value === SECTIONS.HOME || previousSection.value === SECTIONS.HOME) {
|
||||
return 'logo-scale'
|
||||
}
|
||||
if (currentSection.value > previousSection.value) {
|
||||
return 'logo-slide-left'
|
||||
}
|
||||
return 'logo-slide-right'
|
||||
})
|
||||
|
||||
return { logoTransitionName }
|
||||
}
|
||||
978
frontend/src/views/shared/Dashboard.vue
Normal file
978
frontend/src/views/shared/Dashboard.vue
Normal file
@@ -0,0 +1,978 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 页面头部:统计卡片 + 公告 -->
|
||||
<div class="flex gap-6 items-start">
|
||||
<!-- 左侧统计区域 -->
|
||||
<div ref="statsPanelRef" class="flex-1 min-w-0 flex flex-col">
|
||||
<Badge :variant="authStore.user?.role === 'admin' ? 'default' : 'secondary'" class="uppercase tracking-[0.45em] mb-4 self-start">
|
||||
{{ authStore.user?.role === 'admin' ? 'ADMIN MODE' : 'PERSONAL MODE' }}
|
||||
</Badge>
|
||||
|
||||
<!-- 主要统计卡片 -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<template v-if="loading && stats.length === 0">
|
||||
<Card v-for="i in 4" :key="'skeleton-' + i" class="p-5">
|
||||
<Skeleton class="h-4 w-20 mb-4" />
|
||||
<Skeleton class="h-8 w-32 mb-2" />
|
||||
<Skeleton class="h-4 w-16" />
|
||||
</Card>
|
||||
</template>
|
||||
<Card
|
||||
v-for="(stat, index) in stats"
|
||||
v-else
|
||||
:key="stat.name"
|
||||
class="relative overflow-hidden p-5"
|
||||
:class="statCardBorders[index % statCardBorders.length]"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute -right-4 -top-6 h-28 w-28 rounded-full blur-3xl opacity-40"
|
||||
:class="statCardGlows[index % statCardGlows.length]"
|
||||
/>
|
||||
<div class="flex items-start justify-between relative">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.4em] text-muted-foreground">{{ stat.name }}</p>
|
||||
<p class="mt-4 text-3xl font-semibold text-foreground">{{ stat.value }}</p>
|
||||
<p v-if="stat.subValue" class="mt-1 text-sm text-muted-foreground">{{ stat.subValue }}</p>
|
||||
<div v-if="stat.change || stat.extraBadge" class="mt-2 flex items-center gap-1.5">
|
||||
<Badge
|
||||
v-if="stat.change"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ stat.change }}
|
||||
</Badge>
|
||||
<Badge v-if="stat.extraBadge" variant="secondary">
|
||||
{{ stat.extraBadge }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-border bg-card/50 p-3 shadow-inner backdrop-blur-sm" :class="getStatIconColor(index)">
|
||||
<component :is="stat.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 管理员:系统健康摘要 -->
|
||||
<div v-if="isAdmin && systemHealth" class="mt-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-foreground">本月系统健康</h3>
|
||||
<Badge variant="outline" class="uppercase tracking-[0.3em] text-[10px]">Monthly</Badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card class="p-4 border-book-cloth/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">平均响应</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ systemHealth.avg_response_time }}s</p>
|
||||
</div>
|
||||
<Clock class="h-4 w-4 text-book-cloth" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 border-kraft/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">错误率</p>
|
||||
<p class="mt-2 text-xl font-semibold" :class="systemHealth.error_rate > 5 ? 'text-destructive' : 'text-foreground'">{{ systemHealth.error_rate }}%</p>
|
||||
</div>
|
||||
<AlertTriangle class="h-4 w-4 text-kraft" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 border-book-cloth/25">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">转移次数</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ systemHealth.fallback_count }}</p>
|
||||
</div>
|
||||
<Shuffle class="h-4 w-4 text-kraft" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card v-if="costStats" class="p-4 border-manilla/40">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">实际成本</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatCurrency(costStats.total_actual_cost) }}</p>
|
||||
<Badge v-if="costStats.cost_savings > 0" variant="success" class="mt-1 text-[10px]">
|
||||
节省 {{ formatCurrency(costStats.cost_savings) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<DollarSign class="h-4 w-4 text-book-cloth" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 普通用户:缓存统计 -->
|
||||
<div v-else-if="!isAdmin && cacheStats && cacheStats.total_cache_tokens > 0" class="mt-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-foreground">本月缓存使用</h3>
|
||||
<Badge variant="outline" class="uppercase tracking-[0.3em] text-[10px]">Monthly</Badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card class="p-4 border-book-cloth/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">缓存命中率</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ cacheStats.cache_hit_rate || 0 }}%</p>
|
||||
</div>
|
||||
<Database class="h-4 w-4 text-book-cloth" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 border-kraft/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">缓存读取</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatTokens(cacheStats.cache_read_tokens) }}</p>
|
||||
</div>
|
||||
<Hash class="h-4 w-4 text-kraft" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 border-book-cloth/25">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">缓存创建</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatTokens(cacheStats.cache_creation_tokens) }}</p>
|
||||
</div>
|
||||
<Database class="h-4 w-4 text-kraft" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card v-if="tokenBreakdown" class="p-4 border-manilla/40">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-muted-foreground">总Token</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}</p>
|
||||
<p class="mt-1 text-[10px] text-muted-foreground">输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }}</p>
|
||||
</div>
|
||||
<Hash class="h-4 w-4 text-book-cloth" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧系统公告 -->
|
||||
<div
|
||||
id="announcements-section"
|
||||
class="w-[340px] flex-shrink-0 flex flex-col min-h-0"
|
||||
:style="announcementsContainerStyle"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between flex-shrink-0">
|
||||
<h3 class="text-sm font-medium text-foreground">系统公告</h3>
|
||||
<Badge variant="outline" class="uppercase tracking-[0.3em] text-[10px]">Live</Badge>
|
||||
</div>
|
||||
|
||||
<Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full">
|
||||
<div v-if="loadingAnnouncements" class="py-8 text-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin mx-auto text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="announcements.length === 0" class="py-8 text-center">
|
||||
<Bell class="h-8 w-8 mx-auto text-muted-foreground/40" />
|
||||
<p class="mt-2 text-xs text-muted-foreground">暂无公告</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="-mx-4 px-4 flex-1 overflow-y-auto scrollbar-thin min-h-0 pb-2">
|
||||
<div ref="announcementsTimelineRef" class="relative pl-5">
|
||||
<div
|
||||
v-if="announcements.length > 1"
|
||||
class="absolute left-[7px] w-[2px] bg-slate-200 dark:bg-muted"
|
||||
:style="timelineLineStyle"
|
||||
></div>
|
||||
|
||||
<button
|
||||
v-for="announcement in announcements"
|
||||
:key="announcement.id"
|
||||
data-announcement-item
|
||||
type="button"
|
||||
class="relative w-full text-left mb-3 last:mb-0"
|
||||
@click="viewAnnouncementDetail(announcement)"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<div class="absolute left-[-18px] top-1 z-10">
|
||||
<span
|
||||
data-announcement-marker
|
||||
class="flex h-3 w-3 items-center justify-center rounded-full border-2 border-white dark:border-slate-900"
|
||||
:class="[
|
||||
announcement.is_pinned
|
||||
? 'bg-amber-500 dark:bg-amber-400'
|
||||
: announcement.is_read
|
||||
? 'bg-slate-300 dark:bg-slate-600'
|
||||
: getAnnouncementDotColor(announcement.type)
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="!announcement.is_read && !announcement.is_pinned"
|
||||
class="h-1.5 w-1.5 rounded-full bg-white"
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 rounded-lg p-2 transition"
|
||||
:class="[
|
||||
announcement.is_pinned
|
||||
? 'hover:bg-amber-50/50 dark:hover:bg-amber-900/10'
|
||||
: 'hover:bg-slate-50/50 dark:hover:bg-slate-800/30'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h4 class="text-xs font-medium text-foreground line-clamp-1 flex-1">
|
||||
{{ announcement.title }}
|
||||
</h4>
|
||||
<span
|
||||
v-if="announcement.is_pinned"
|
||||
class="flex-shrink-0 rounded-full bg-amber-100 dark:bg-amber-900/30 px-1.5 py-0.5 text-[9px] font-medium text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
置顶
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-muted-foreground leading-relaxed line-clamp-2 mb-1">
|
||||
{{ getPlainText(announcement.content) }}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground/70">
|
||||
{{ formatAnnouncementDate(announcement.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 趋势图表区域 -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- 请求次数和费用趋势 -->
|
||||
<Card class="p-5">
|
||||
<h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider">请求次数 / 费用趋势</h4>
|
||||
<div v-if="loadingDaily" class="flex items-center justify-center h-[280px]">
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
<div v-else style="height: 280px;">
|
||||
<LineChart v-if="chartData.requests" :data="chartData.requests" :options="chartOptions.requests" />
|
||||
<div v-else class="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 每日模型成本(堆叠柱状图) -->
|
||||
<Card class="p-5">
|
||||
<h4 class="mb-3 text-xs font-semibold text-foreground uppercase tracking-wider">每日模型成本</h4>
|
||||
<div v-if="loadingDaily" class="flex items-center justify-center h-[280px]">
|
||||
<Skeleton class="h-full w-full" />
|
||||
</div>
|
||||
<div v-else style="height: 280px;">
|
||||
<BarChart
|
||||
v-if="dailyModelCostChartData.labels && dailyModelCostChartData.labels.length > 0"
|
||||
:data="dailyModelCostChartData"
|
||||
:options="dailyModelCostChartOptions"
|
||||
/>
|
||||
<div v-else class="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 每日统计表格 -->
|
||||
<Card class="overflow-hidden mt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="text-left">日期</TableHead>
|
||||
<TableHead class="text-center">请求次数</TableHead>
|
||||
<TableHead class="text-center">Tokens</TableHead>
|
||||
<TableHead class="text-center">费用</TableHead>
|
||||
<TableHead class="text-center">平均响应</TableHead>
|
||||
<TableHead class="text-center">使用模型</TableHead>
|
||||
<TableHead class="text-center">使用提供商</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="loadingDaily">
|
||||
<TableCell colspan="7" class="text-center py-8">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Skeleton class="h-5 w-5 rounded-full" />
|
||||
<span class="text-muted-foreground text-xs">加载中...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-else-if="dailyStats.length === 0">
|
||||
<TableCell colspan="7" class="text-center py-8 text-muted-foreground text-xs">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<template v-else>
|
||||
<TableRow v-for="stat in dailyStats.slice().reverse()" :key="stat.date">
|
||||
<TableCell class="font-medium text-xs">{{ formatDate(stat.date) }}</TableCell>
|
||||
<TableCell class="text-center text-xs">{{ stat.requests.toLocaleString() }}</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="secondary" class="text-[10px]">{{ formatTokens(stat.tokens) }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="success" class="text-[10px]">${{ stat.cost.toFixed(4) }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Badge variant="outline" class="text-[10px]">{{ formatResponseTime(stat.avg_response_time) }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-xs">{{ stat.unique_models }}</TableCell>
|
||||
<TableCell class="text-center text-xs">{{ stat.unique_providers }}</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- 汇总信息 -->
|
||||
<div v-if="dailyStats.length > 0" class="border-t border-border bg-muted/30 backdrop-blur-sm px-4 py-3 text-xs">
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">总请求</div>
|
||||
<div class="font-semibold text-foreground">{{ totalStats.requests.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">总Tokens</div>
|
||||
<div class="font-semibold text-book-cloth dark:text-kraft">{{ formatTokens(totalStats.tokens) }}</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">总费用</div>
|
||||
<div class="font-semibold text-amber-600 dark:text-amber-400">${{ totalStats.cost.toFixed(4) }}</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground text-[10px]">平均响应</div>
|
||||
<div class="font-semibold text-book-cloth dark:text-kraft">{{ formatResponseTime(totalStats.avgResponseTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 公告详情对话框 -->
|
||||
<Dialog v-model="detailDialogOpen" size="lg">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<component
|
||||
v-if="selectedAnnouncement"
|
||||
:is="getAnnouncementIcon(selectedAnnouncement.type)"
|
||||
class="h-5 w-5 flex-shrink-0"
|
||||
:class="getAnnouncementIconColor(selectedAnnouncement.type)"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight truncate">{{ selectedAnnouncement?.title || '公告详情' }}</h3>
|
||||
<p class="text-xs text-muted-foreground">系统公告</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="selectedAnnouncement" class="space-y-4">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ formatFullDate(selectedAnnouncement.created_at) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-html="renderMarkdown(selectedAnnouncement.content)"
|
||||
class="prose prose-sm dark:prose-invert max-w-none"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="detailDialogOpen = false" class="h-10 px-5">关闭</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { dashboardApi, type DashboardStat, type DailyStat } from '@/api/dashboard'
|
||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||
import {
|
||||
Card,
|
||||
Badge,
|
||||
Button,
|
||||
Skeleton,
|
||||
Dialog,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from '@/components/ui'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
import BarChart from '@/components/charts/BarChart.vue'
|
||||
import {
|
||||
Users,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
Key,
|
||||
Hash,
|
||||
Bell,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Wrench,
|
||||
Loader2,
|
||||
Clock,
|
||||
Database,
|
||||
Shuffle
|
||||
} from 'lucide-vue-next'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import type { ChartData, ChartOptions, ChartDataset, TooltipItem } from 'chart.js'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const statsPanelRef = ref<HTMLElement | null>(null)
|
||||
const announcementsHeight = ref<number | null>(null)
|
||||
const announcementsTimelineRef = ref<HTMLElement | null>(null)
|
||||
const timelineLineStyle = ref<{ top: string; bottom: string }>({ top: '0px', bottom: '0px' })
|
||||
|
||||
const announcementsContainerStyle = computed(() => {
|
||||
if (!announcementsHeight.value) return {}
|
||||
// 设置固定高度,与左侧统计面板保持一致
|
||||
return { height: `${announcementsHeight.value}px` }
|
||||
})
|
||||
|
||||
let statsPanelObserver: ResizeObserver | null = null
|
||||
let announcementsTimelineObserver: ResizeObserver | null = null
|
||||
|
||||
function updateAnnouncementsHeight() {
|
||||
if (typeof window === 'undefined') return
|
||||
const panel = statsPanelRef.value
|
||||
if (!panel) return
|
||||
const { height } = panel.getBoundingClientRect()
|
||||
if (height <= 0) return
|
||||
announcementsHeight.value = Math.round(height)
|
||||
nextTick(() => updateTimelineLine())
|
||||
}
|
||||
|
||||
function updateTimelineLine() {
|
||||
if (typeof window === 'undefined') return
|
||||
const container = announcementsTimelineRef.value
|
||||
if (!container) return
|
||||
const items = container.querySelectorAll<HTMLElement>('[data-announcement-item]')
|
||||
if (items.length < 2) {
|
||||
timelineLineStyle.value = { top: '0px', bottom: '0px' }
|
||||
return
|
||||
}
|
||||
const firstMarker = items[0].querySelector<HTMLElement>('[data-announcement-marker]')
|
||||
const lastMarker = items[items.length - 1].querySelector<HTMLElement>('[data-announcement-marker]')
|
||||
if (!firstMarker || !lastMarker) return
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const firstRect = firstMarker.getBoundingClientRect()
|
||||
const lastRect = lastMarker.getBoundingClientRect()
|
||||
const topOffset = Math.max(0, firstRect.top + firstRect.height / 2 - containerRect.top)
|
||||
const bottomOffset = Math.max(0, containerRect.bottom - (lastRect.top + lastRect.height / 2))
|
||||
timelineLineStyle.value = { top: `${topOffset}px`, bottom: `${bottomOffset}px` }
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
updateAnnouncementsHeight()
|
||||
updateTimelineLine()
|
||||
}
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (typeof window === 'undefined') return
|
||||
const panel = statsPanelRef.value
|
||||
if (!panel || !('ResizeObserver' in window)) return
|
||||
statsPanelObserver = new ResizeObserver(() => updateAnnouncementsHeight())
|
||||
statsPanelObserver.observe(panel)
|
||||
updateAnnouncementsHeight()
|
||||
}
|
||||
|
||||
function setupTimelineResizeObserver() {
|
||||
if (typeof window === 'undefined' || !('ResizeObserver' in window)) return
|
||||
const container = announcementsTimelineRef.value
|
||||
announcementsTimelineObserver?.disconnect()
|
||||
announcementsTimelineObserver = null
|
||||
if (!container) return
|
||||
announcementsTimelineObserver = new ResizeObserver(() => updateTimelineLine())
|
||||
announcementsTimelineObserver.observe(container)
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
|
||||
const statCardBorders = [
|
||||
'border-book-cloth/30 dark:border-book-cloth/25',
|
||||
'border-kraft/30 dark:border-kraft/25',
|
||||
'border-manilla/40 dark:border-manilla/30',
|
||||
'border-book-cloth/25 dark:border-kraft/25'
|
||||
]
|
||||
|
||||
const statCardGlows = [
|
||||
'bg-book-cloth/30',
|
||||
'bg-kraft/30',
|
||||
'bg-manilla/35',
|
||||
'bg-kraft/30'
|
||||
]
|
||||
|
||||
const getStatIconColor = (index: number): string => {
|
||||
const colors = ['text-book-cloth', 'text-kraft', 'text-book-cloth', 'text-kraft']
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
const stats = ref<DashboardStat[]>([])
|
||||
const todayStats = ref<{
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
actual_cost?: number
|
||||
cache_creation_tokens?: number
|
||||
cache_read_tokens?: number
|
||||
}>({ requests: 0, tokens: 0, cost: 0 })
|
||||
|
||||
const systemHealth = ref<{
|
||||
avg_response_time: number
|
||||
error_rate: number
|
||||
error_requests: number
|
||||
fallback_count: number
|
||||
total_requests: number
|
||||
} | null>(null)
|
||||
|
||||
const costStats = ref<{
|
||||
total_cost: number
|
||||
total_actual_cost: number
|
||||
cost_savings: number
|
||||
} | null>(null)
|
||||
|
||||
const cacheStats = ref<{
|
||||
cache_creation_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_creation_cost?: number
|
||||
cache_read_cost?: number
|
||||
cache_hit_rate?: number
|
||||
total_cache_tokens: number
|
||||
} | null>(null)
|
||||
|
||||
const tokenBreakdown = ref<{
|
||||
input: number
|
||||
output: number
|
||||
cache_creation: number
|
||||
cache_read: number
|
||||
} | null>(null)
|
||||
|
||||
const activeUsers = ref(0)
|
||||
const dailyStats = ref<DailyStat[]>([])
|
||||
const selectedDays = ref(7)
|
||||
const loadingDaily = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
|
||||
// 公告
|
||||
const announcements = ref<Announcement[]>([])
|
||||
const loadingAnnouncements = ref(false)
|
||||
const selectedAnnouncement = ref<Announcement | null>(null)
|
||||
const detailDialogOpen = ref(false)
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
Users, Activity, TrendingUp, DollarSign, Key, Hash, Database
|
||||
}
|
||||
|
||||
const totalStats = computed(() => {
|
||||
if (dailyStats.value.length === 0) {
|
||||
return { requests: 0, tokens: 0, cost: 0, avgResponseTime: 0 }
|
||||
}
|
||||
const totals = dailyStats.value.reduce((acc, stat) => {
|
||||
acc.requests += stat.requests
|
||||
acc.tokens += stat.tokens
|
||||
acc.cost += stat.cost
|
||||
acc.totalResponseTime += stat.avg_response_time * stat.requests
|
||||
return acc
|
||||
}, { requests: 0, tokens: 0, cost: 0, totalResponseTime: 0 })
|
||||
return {
|
||||
requests: totals.requests,
|
||||
tokens: totals.tokens,
|
||||
cost: totals.cost,
|
||||
avgResponseTime: totals.requests > 0 ? totals.totalResponseTime / totals.requests : 0
|
||||
}
|
||||
})
|
||||
|
||||
// 图表数据
|
||||
const chartData = computed(() => {
|
||||
if (dailyStats.value.length === 0) {
|
||||
return { requests: null }
|
||||
}
|
||||
|
||||
const labels = dailyStats.value.map(stat => formatDateForChart(stat.date))
|
||||
const requests = dailyStats.value.map(stat => stat.requests)
|
||||
const costs = dailyStats.value.map(stat => stat.cost)
|
||||
|
||||
return {
|
||||
requests: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '请求次数',
|
||||
data: requests,
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.4,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: '费用 ($)',
|
||||
data: costs,
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
} as ChartData<'line'>
|
||||
}
|
||||
})
|
||||
|
||||
// 每日模型成本(堆叠柱状图)
|
||||
const MODEL_COLORS = [
|
||||
'rgba(59, 130, 246, 0.8)', // blue
|
||||
'rgba(239, 68, 68, 0.8)', // red
|
||||
'rgba(16, 185, 129, 0.8)', // green
|
||||
'rgba(245, 158, 11, 0.8)', // amber
|
||||
'rgba(139, 92, 246, 0.8)', // purple
|
||||
'rgba(6, 182, 212, 0.8)', // cyan
|
||||
'rgba(132, 204, 22, 0.8)', // lime
|
||||
'rgba(249, 115, 22, 0.8)' // orange
|
||||
]
|
||||
|
||||
const dailyModelCostChartData = computed<ChartData<'bar'>>(() => {
|
||||
if (dailyStats.value.length === 0) {
|
||||
return { labels: [], datasets: [] }
|
||||
}
|
||||
|
||||
// 收集所有出现过的模型
|
||||
const allModels = new Set<string>()
|
||||
dailyStats.value.forEach(day => {
|
||||
day.model_breakdown?.forEach(mb => allModels.add(mb.model))
|
||||
})
|
||||
const modelList = Array.from(allModels)
|
||||
|
||||
// 按总费用降序排列模型
|
||||
const modelTotalCost = new Map<string, number>()
|
||||
dailyStats.value.forEach(day => {
|
||||
day.model_breakdown?.forEach(mb => {
|
||||
modelTotalCost.set(mb.model, (modelTotalCost.get(mb.model) || 0) + mb.cost)
|
||||
})
|
||||
})
|
||||
modelList.sort((a, b) => (modelTotalCost.get(b) || 0) - (modelTotalCost.get(a) || 0))
|
||||
|
||||
// 为每个模型创建一个 dataset
|
||||
const datasets: ChartDataset<'bar', number[]>[] = modelList.map((model, index) => ({
|
||||
label: model.replace('claude-', '').replace('gpt-', ''),
|
||||
data: dailyStats.value.map(day => {
|
||||
const found = day.model_breakdown?.find(mb => mb.model === model)
|
||||
return found ? found.cost : 0
|
||||
}),
|
||||
backgroundColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
||||
borderRadius: 2,
|
||||
stack: 'stack0',
|
||||
barPercentage: 0.6,
|
||||
categoryPercentage: 0.7
|
||||
}))
|
||||
|
||||
return {
|
||||
labels: dailyStats.value.map(stat => formatDateForChart(stat.date)),
|
||||
datasets
|
||||
}
|
||||
})
|
||||
|
||||
const dailyModelCostChartOptions = computed<ChartOptions<'bar'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: { font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
title: { display: true, text: '费用 ($)', color: 'rgb(107, 114, 128)', font: { size: 10 } },
|
||||
ticks: { font: { size: 10 } }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: { font: { size: 10 }, boxWidth: 12, padding: 8 }
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: TooltipItem<'bar'>) => {
|
||||
const value = typeof context.raw === 'number' ? context.raw : 0
|
||||
if (value === 0) return ''
|
||||
return `${context.dataset.label}: $${value.toFixed(4)}`
|
||||
},
|
||||
footer: (items: TooltipItem<'bar'>[]) => {
|
||||
const total = items.reduce((sum, item) => {
|
||||
const val = typeof item.raw === 'number' ? item.raw : 0
|
||||
return sum + val
|
||||
}, 0)
|
||||
return `Total: $${total.toFixed(4)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
requests: {
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: { display: true, text: '请求次数', color: 'rgb(107, 114, 128)', font: { size: 10 } }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: { display: true, text: '费用 ($)', color: 'rgb(107, 114, 128)', font: { size: 10 } },
|
||||
grid: { drawOnChartArea: false }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { labels: { font: { size: 11 } } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || ''
|
||||
const value = context.parsed.y
|
||||
if (label.includes('费用')) return `${label}: $${value.toFixed(4)}`
|
||||
return `${label}: ${value.toLocaleString()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} as ChartOptions<'line'>
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
setupResizeObserver()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
}
|
||||
await Promise.all([
|
||||
loadDashboardData(),
|
||||
loadDailyStats(),
|
||||
loadAnnouncements()
|
||||
])
|
||||
await nextTick()
|
||||
setupTimelineResizeObserver()
|
||||
updateAnnouncementsHeight()
|
||||
updateTimelineLine()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
}
|
||||
if (statsPanelObserver && statsPanelRef.value) {
|
||||
statsPanelObserver.unobserve(statsPanelRef.value)
|
||||
}
|
||||
statsPanelObserver?.disconnect()
|
||||
statsPanelObserver = null
|
||||
announcementsTimelineObserver?.disconnect()
|
||||
announcementsTimelineObserver = null
|
||||
})
|
||||
|
||||
async function loadDashboardData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const statsData = await dashboardApi.getStats()
|
||||
stats.value = statsData.stats.map(stat => ({
|
||||
...stat,
|
||||
icon: iconMap[stat.icon] || Activity
|
||||
}))
|
||||
if (statsData.today) todayStats.value = statsData.today
|
||||
if (isAdmin.value) {
|
||||
if (statsData.system_health) systemHealth.value = statsData.system_health
|
||||
if (statsData.cost_stats) costStats.value = statsData.cost_stats
|
||||
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
||||
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
||||
if (statsData.users) activeUsers.value = statsData.users.active
|
||||
} else {
|
||||
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
||||
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDailyStats() {
|
||||
loadingDaily.value = true
|
||||
try {
|
||||
const response = await dashboardApi.getDailyStats(selectedDays.value)
|
||||
dailyStats.value = response.daily_stats
|
||||
} catch {
|
||||
dailyStats.value = []
|
||||
} finally {
|
||||
loadingDaily.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
if (date.toDateString() === today.toDateString()) return '今天'
|
||||
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', weekday: 'short' })
|
||||
}
|
||||
|
||||
function formatDateForChart(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
if (date.toDateString() === today.toDateString()) return '今天'
|
||||
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
return date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatResponseTime(seconds: number): string {
|
||||
if (seconds === 0) return '-'
|
||||
if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`
|
||||
return `${seconds.toFixed(2)}s`
|
||||
}
|
||||
|
||||
// 公告相关
|
||||
async function loadAnnouncements() {
|
||||
loadingAnnouncements.value = true
|
||||
try {
|
||||
const response = await announcementApi.getAnnouncements({ active_only: true, limit: 100 })
|
||||
announcements.value = response.items
|
||||
} catch {
|
||||
announcements.value = []
|
||||
} finally {
|
||||
loadingAnnouncements.value = false
|
||||
await nextTick()
|
||||
setupTimelineResizeObserver()
|
||||
updateTimelineLine()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => announcements.value.length, async () => {
|
||||
await nextTick()
|
||||
setupTimelineResizeObserver()
|
||||
updateTimelineLine()
|
||||
})
|
||||
|
||||
async function viewAnnouncementDetail(announcement: Announcement) {
|
||||
if (!announcement.is_read && !isAdmin.value) {
|
||||
try {
|
||||
await announcementApi.markAsRead(announcement.id)
|
||||
announcement.is_read = true
|
||||
} catch {}
|
||||
}
|
||||
selectedAnnouncement.value = announcement
|
||||
detailDialogOpen.value = true
|
||||
}
|
||||
|
||||
function getPlainText(content: string): string {
|
||||
const cleaned = content
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`[^`]*`/g, ' ')
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
||||
.replace(/\[[^\]]*]\(([^)]*)\)/g, '$1')
|
||||
.replace(/[#>*_~]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (cleaned.length <= 100) return cleaned
|
||||
return `${cleaned.slice(0, 100).trim()}...`
|
||||
}
|
||||
|
||||
function getAnnouncementIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'important': return AlertCircle
|
||||
case 'warning': return AlertTriangle
|
||||
case 'maintenance': return Wrench
|
||||
default: return Info
|
||||
}
|
||||
}
|
||||
|
||||
function getAnnouncementIconColor(type: string) {
|
||||
switch (type) {
|
||||
case 'important': return 'text-rose-600 dark:text-rose-400'
|
||||
case 'warning': return 'text-amber-600 dark:text-amber-400'
|
||||
case 'maintenance': return 'text-orange-600 dark:text-orange-400'
|
||||
default: return 'text-primary dark:text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnnouncementDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function getAnnouncementDotColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'important': return 'bg-rose-500 dark:bg-rose-400'
|
||||
case 'warning': return 'bg-amber-500 dark:bg-amber-400'
|
||||
case 'maintenance': return 'bg-orange-500 dark:bg-orange-400'
|
||||
default: return 'bg-emerald-500 dark:bg-emerald-400'
|
||||
}
|
||||
}
|
||||
|
||||
function formatFullDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function renderMarkdown(content: string): string {
|
||||
const rawHtml = marked(content) as string
|
||||
return sanitizeMarkdown(rawHtml)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-1,
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line-clamp-1 { -webkit-line-clamp: 1; }
|
||||
.line-clamp-2 { -webkit-line-clamp: 2; }
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar { width: 5px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb { background: rgb(203 213 225); border-radius: 2px; }
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb { background: rgb(71 85 105); }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover { background: rgb(148 163 184); }
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover { background: rgb(100 116 139); }
|
||||
|
||||
:deep(.prose) { color: var(--color-text); }
|
||||
:deep(.prose p) { margin-top: 0.75em; margin-bottom: 0.75em; line-height: 1.65; }
|
||||
:deep(.prose ul), :deep(.prose ol) { margin-top: 0.75em; margin-bottom: 0.75em; padding-left: 1.5em; }
|
||||
:deep(.prose li) { margin-top: 0.25em; margin-bottom: 0.25em; }
|
||||
:deep(.prose h1), :deep(.prose h2), :deep(.prose h3), :deep(.prose h4) { margin-top: 1.5em; margin-bottom: 0.75em; font-weight: 600; color: var(--color-text); }
|
||||
:deep(.prose code) { background: var(--color-code-background); color: var(--color-code-text); padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; font-weight: 500; }
|
||||
:deep(.prose pre) { background: var(--color-code-background); padding: 1em; border-radius: 8px; overflow-x: auto; }
|
||||
:deep(.prose a) { color: var(--book-cloth); text-decoration: underline; }
|
||||
:deep(.prose blockquote) { border-left: 3px solid var(--book-cloth); padding-left: 1em; margin-left: 0; font-style: italic; color: var(--cloud-dark); }
|
||||
:deep(.prose strong) { font-weight: 600; }
|
||||
</style>
|
||||
18
frontend/src/views/shared/HealthMonitor.vue
Normal file
18
frontend/src/views/shared/HealthMonitor.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<HealthMonitorCard
|
||||
title="健康监控"
|
||||
:is-admin="isAdminPage"
|
||||
:show-provider-info="isAdminPage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import HealthMonitorCard from '@/features/providers/components/HealthMonitorCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
||||
</script>
|
||||
411
frontend/src/views/shared/Usage.vue
Normal file
411
frontend/src/views/shared/Usage.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 活跃度热图 -->
|
||||
<ActivityHeatmapCard
|
||||
:data="activityHeatmapData"
|
||||
:title="isAdminPage ? '总体活跃天数' : '我的活跃天数'"
|
||||
/>
|
||||
|
||||
<!-- 分析统计 -->
|
||||
<!-- 管理员:模型 + 提供商 + API格式(3列) -->
|
||||
<div v-if="isAdminPage" class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<UsageModelTable
|
||||
:data="enhancedModelStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
/>
|
||||
<UsageProviderTable
|
||||
:data="providerStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
/>
|
||||
<UsageApiFormatTable
|
||||
:data="apiFormatStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
/>
|
||||
</div>
|
||||
<!-- 用户:模型 + API格式(2列) -->
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<UsageModelTable
|
||||
:data="enhancedModelStats"
|
||||
:is-admin="authStore.isAdmin"
|
||||
/>
|
||||
<UsageApiFormatTable
|
||||
:data="apiFormatStats"
|
||||
:is-admin="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 请求详情 -->
|
||||
<UsageRecordsTable
|
||||
:records="displayRecords"
|
||||
:is-admin="isAdminPage"
|
||||
:show-actual-cost="authStore.isAdmin"
|
||||
:loading="isLoadingRecords"
|
||||
:selected-period="selectedPeriod"
|
||||
:filter-user="filterUser"
|
||||
:filter-model="filterModel"
|
||||
:filter-provider="filterProvider"
|
||||
:filter-status="filterStatus"
|
||||
:available-users="availableUsers"
|
||||
:available-models="availableModels"
|
||||
:available-providers="availableProviders"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total-records="totalRecords"
|
||||
:page-size-options="pageSizeOptions"
|
||||
@update:selected-period="handlePeriodChange"
|
||||
@update:filter-user="handleFilterUserChange"
|
||||
@update:filter-model="handleFilterModelChange"
|
||||
@update:filter-provider="handleFilterProviderChange"
|
||||
@update:filter-status="handleFilterStatusChange"
|
||||
@update:current-page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@refresh="refreshData"
|
||||
@export="exportData"
|
||||
@show-detail="showRequestDetail"
|
||||
/>
|
||||
|
||||
<!-- 请求详情抽屉 - 仅管理员可见 -->
|
||||
<RequestDetailDrawer
|
||||
v-if="isAdminPage"
|
||||
:isOpen="detailModalOpen"
|
||||
:requestId="selectedRequestId"
|
||||
@close="detailModalOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usageApi } from '@/api/usage'
|
||||
import { usersApi } from '@/api/users'
|
||||
import { meApi } from '@/api/me'
|
||||
import {
|
||||
UsageModelTable,
|
||||
UsageProviderTable,
|
||||
UsageApiFormatTable,
|
||||
UsageRecordsTable,
|
||||
ActivityHeatmapCard,
|
||||
RequestDetailDrawer
|
||||
} from '@/features/usage/components'
|
||||
import {
|
||||
useUsageData,
|
||||
getDateRangeFromPeriod
|
||||
} from '@/features/usage/composables'
|
||||
import type { PeriodValue, FilterStatusValue } from '@/features/usage/types'
|
||||
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 判断是否是管理员页面
|
||||
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
||||
|
||||
// 时间段选择
|
||||
const selectedPeriod = ref<PeriodValue>('today')
|
||||
|
||||
// 分页状态
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const pageSizeOptions = [10, 20, 50, 100]
|
||||
|
||||
// 筛选状态
|
||||
const filterUser = ref('__all__')
|
||||
const filterModel = ref('__all__')
|
||||
const filterProvider = ref('__all__')
|
||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||
|
||||
// 用户列表(仅管理员页面使用)
|
||||
const availableUsers = ref<UserOption[]>([])
|
||||
|
||||
// 使用 composables
|
||||
const {
|
||||
isLoadingRecords,
|
||||
providerStats,
|
||||
apiFormatStats,
|
||||
currentRecords,
|
||||
totalRecords,
|
||||
enhancedModelStats,
|
||||
activityHeatmapData,
|
||||
availableModels,
|
||||
availableProviders,
|
||||
loadStats,
|
||||
loadRecords
|
||||
} = useUsageData({ isAdminPage })
|
||||
|
||||
// 用户页面需要前端筛选
|
||||
const filteredRecords = computed(() => {
|
||||
if (!isAdminPage.value) {
|
||||
let records = [...currentRecords.value]
|
||||
|
||||
if (filterModel.value !== '__all__') {
|
||||
records = records.filter(record => record.model === filterModel.value)
|
||||
}
|
||||
|
||||
if (filterProvider.value !== '__all__') {
|
||||
records = records.filter(record => record.provider === filterProvider.value)
|
||||
}
|
||||
|
||||
if (filterStatus.value !== '__all__') {
|
||||
if (filterStatus.value === 'stream') {
|
||||
records = records.filter(record =>
|
||||
record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'standard') {
|
||||
records = records.filter(record =>
|
||||
!record.is_stream && !record.error_message && (!record.status_code || record.status_code === 200)
|
||||
)
|
||||
} else if (filterStatus.value === 'error') {
|
||||
records = records.filter(record =>
|
||||
record.error_message || (record.status_code && record.status_code >= 400)
|
||||
)
|
||||
} else if (filterStatus.value === 'active') {
|
||||
records = records.filter(record =>
|
||||
record.status === 'pending' || record.status === 'streaming'
|
||||
)
|
||||
} else if (filterStatus.value === 'pending') {
|
||||
records = records.filter(record => record.status === 'pending')
|
||||
} else if (filterStatus.value === 'streaming') {
|
||||
records = records.filter(record => record.status === 'streaming')
|
||||
} else if (filterStatus.value === 'completed') {
|
||||
records = records.filter(record => record.status === 'completed')
|
||||
} else if (filterStatus.value === 'failed') {
|
||||
records = records.filter(record => record.status === 'failed')
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
return currentRecords.value
|
||||
})
|
||||
|
||||
// 获取活跃请求的 ID 列表
|
||||
const activeRequestIds = computed(() => {
|
||||
return currentRecords.value
|
||||
.filter(record => record.status === 'pending' || record.status === 'streaming')
|
||||
.map(record => record.id)
|
||||
})
|
||||
|
||||
// 检查是否有活跃请求
|
||||
const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
||||
|
||||
// 自动刷新定时器
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次
|
||||
|
||||
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
||||
async function pollActiveRequests() {
|
||||
if (!hasActiveRequests.value) return
|
||||
|
||||
try {
|
||||
// 根据页面类型选择不同的 API
|
||||
const idsParam = activeRequestIds.value.join(',')
|
||||
const { requests } = isAdminPage.value
|
||||
? await usageApi.getActiveRequests(activeRequestIds.value)
|
||||
: await meApi.getActiveRequests(idsParam)
|
||||
|
||||
// 检查是否有状态变化
|
||||
let hasChanges = false
|
||||
for (const update of requests) {
|
||||
const record = currentRecords.value.find(r => r.id === update.id)
|
||||
if (record && record.status !== update.status) {
|
||||
hasChanges = true
|
||||
// 如果状态变为 completed 或 failed,需要刷新获取完整数据
|
||||
if (update.status === 'completed' || update.status === 'failed') {
|
||||
break
|
||||
}
|
||||
// 否则只更新状态和 token 信息
|
||||
record.status = update.status
|
||||
record.input_tokens = update.input_tokens
|
||||
record.output_tokens = update.output_tokens
|
||||
record.cost = update.cost
|
||||
record.response_time_ms = update.response_time_ms ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有请求完成或失败,刷新整个列表获取完整数据
|
||||
if (hasChanges && requests.some(r => r.status === 'completed' || r.status === 'failed')) {
|
||||
await refreshData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询活跃请求状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动自动刷新
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshTimer) return
|
||||
autoRefreshTimer = setInterval(pollActiveRequests, AUTO_REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
// 停止自动刷新
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 监听活跃请求状态,自动启动/停止刷新
|
||||
watch(hasActiveRequests, (hasActive) => {
|
||||
if (hasActive) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
|
||||
// 用户页面的前端分页
|
||||
const paginatedRecords = computed(() => {
|
||||
if (!isAdminPage.value) {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return filteredRecords.value.slice(start, end)
|
||||
}
|
||||
return currentRecords.value
|
||||
})
|
||||
|
||||
// 显示的记录
|
||||
const displayRecords = computed(() => paginatedRecords.value)
|
||||
|
||||
|
||||
// 详情弹窗状态
|
||||
const detailModalOpen = ref(false)
|
||||
const selectedRequestId = ref<string | null>(null)
|
||||
|
||||
// 初始化加载
|
||||
onMounted(async () => {
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
// 管理员页面加载用户列表和第一页记录
|
||||
if (isAdminPage.value) {
|
||||
// 并行加载用户列表和记录
|
||||
const [users] = await Promise.all([
|
||||
usersApi.getAllUsers(),
|
||||
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
])
|
||||
availableUsers.value = users.map(u => ({ id: u.id, username: u.username, email: u.email }))
|
||||
}
|
||||
})
|
||||
|
||||
// 处理时间段变化
|
||||
async function handlePeriodChange(value: string) {
|
||||
selectedPeriod.value = value as PeriodValue
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 处理分页变化
|
||||
async function handlePageChange(page: number) {
|
||||
currentPage.value = page
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 处理每页大小变化
|
||||
async function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: size }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前筛选参数
|
||||
function getCurrentFilters() {
|
||||
return {
|
||||
user_id: filterUser.value !== '__all__' ? filterUser.value : undefined,
|
||||
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 处理筛选变化
|
||||
async function handleFilterUserChange(value: string) {
|
||||
filterUser.value = value
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilterModelChange(value: string) {
|
||||
filterModel.value = value
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilterProviderChange(value: string) {
|
||||
filterProvider.value = value
|
||||
currentPage.value = 1
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilterStatusChange(value: string) {
|
||||
filterStatus.value = value as FilterStatusValue
|
||||
currentPage.value = 1
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
async function refreshData() {
|
||||
const dateRange = getDateRangeFromPeriod(selectedPeriod.value)
|
||||
await loadStats(dateRange)
|
||||
|
||||
if (isAdminPage.value) {
|
||||
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
|
||||
}
|
||||
}
|
||||
|
||||
// 显示请求详情
|
||||
function showRequestDetail(id: string) {
|
||||
if (!isAdminPage.value) return
|
||||
selectedRequestId.value = id
|
||||
detailModalOpen.value = true
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
async function exportData(format: 'csv' | 'json') {
|
||||
try {
|
||||
const blob = await usageApi.exportUsage(format)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `usage-stats.${format}`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
677
frontend/src/views/user/Announcements.vue
Normal file
677
frontend/src/views/user/Announcements.vue
Normal file
@@ -0,0 +1,677 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 公告列表卡片 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">公告管理</h3>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ isAdmin ? '管理系统公告和通知' : '查看系统公告和通知' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge v-if="unreadCount > 0" variant="default" class="px-3 py-1">
|
||||
{{ unreadCount }} 条未读
|
||||
</Badge>
|
||||
<div class="h-4 w-px bg-border" />
|
||||
<Button
|
||||
v-if="isAdmin"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="openCreateDialog"
|
||||
title="新建公告"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton :loading="loading" @click="loadAnnouncements(currentPage)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="announcements.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Bell class="h-12 w-12 text-muted-foreground mb-3" />
|
||||
<h3 class="text-sm font-medium text-foreground">暂无公告</h3>
|
||||
<p class="text-xs text-muted-foreground mt-1">系统暂时没有发布任何公告</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[80px] h-12 font-semibold text-center">类型</TableHead>
|
||||
<TableHead class="h-12 font-semibold">概要</TableHead>
|
||||
<TableHead class="w-[120px] h-12 font-semibold">发布者</TableHead>
|
||||
<TableHead class="w-[140px] h-12 font-semibold">发布时间</TableHead>
|
||||
<TableHead class="w-[80px] h-12 font-semibold text-center">状态</TableHead>
|
||||
<TableHead v-if="isAdmin" class="w-[80px] h-12 font-semibold text-center">置顶</TableHead>
|
||||
<TableHead v-if="isAdmin" class="w-[80px] h-12 font-semibold text-center">启用</TableHead>
|
||||
<TableHead v-if="isAdmin" class="w-[100px] h-12 font-semibold text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="announcement in announcements"
|
||||
:key="announcement.id"
|
||||
:class="'border-b border-border/40 transition-colors cursor-pointer ' + (announcement.is_read ? 'hover:bg-muted/30' : 'bg-primary/5 hover:bg-primary/10')"
|
||||
@click="viewAnnouncementDetail(announcement)"
|
||||
>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<component :is="getAnnouncementIcon(announcement.type)" class="w-5 h-5" :class="getIconColor(announcement.type)" />
|
||||
<span :class="['text-xs font-medium', getTypeTextColor(announcement.type)]">
|
||||
{{ getTypeLabel(announcement.type) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-foreground">{{ announcement.title }}</span>
|
||||
<Pin v-if="announcement.is_pinned" class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground line-clamp-1">
|
||||
{{ getPlainText(announcement.content) }}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-sm text-muted-foreground">
|
||||
{{ announcement.author.username }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-xs text-muted-foreground">
|
||||
{{ formatDate(announcement.created_at) }}
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge v-if="announcement.is_read" variant="secondary" class="text-xs px-2.5 py-0.5">
|
||||
已读
|
||||
</Badge>
|
||||
<Badge v-else variant="default" class="text-xs px-2.5 py-0.5">
|
||||
未读
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="py-4" @click.stop>
|
||||
<div class="flex items-center justify-center">
|
||||
<Switch
|
||||
:model-value="announcement.is_pinned"
|
||||
@update:model-value="toggleAnnouncementPin(announcement, $event)"
|
||||
class="data-[state=checked]:bg-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="py-4" @click.stop>
|
||||
<div class="flex items-center justify-center">
|
||||
<Switch
|
||||
:model-value="announcement.is_active"
|
||||
@update:model-value="toggleAnnouncementActive(announcement, $event)"
|
||||
class="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell v-if="isAdmin" class="py-4" @click.stop>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<Button @click="openEditDialog(announcement)" variant="ghost" size="icon" class="h-8 w-8">
|
||||
<SquarePen class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button @click="confirmDelete(announcement)" variant="ghost" size="icon" class="h-9 w-9 hover:bg-rose-500/10 hover:text-rose-600">
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="!loading && total > 0"
|
||||
:current="currentPage"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
@update:current="loadAnnouncements($event)"
|
||||
@update:page-size="pageSize = $event; loadAnnouncements(1)"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 创建/编辑公告对话框 -->
|
||||
<Dialog v-model="dialogOpen" size="xl">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Bell class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">{{ editingAnnouncement ? '编辑公告' : '新建公告' }}</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ editingAnnouncement ? '修改公告内容和设置' : '发布新的系统公告' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="saveAnnouncement" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="title" class="text-sm font-medium">标题 *</Label>
|
||||
<Input id="title" v-model="formData.title" placeholder="输入公告标题" class="h-11" required />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="content" class="text-sm font-medium">内容 * (支持 Markdown)</Label>
|
||||
<Textarea id="content" v-model="formData.content" placeholder="输入公告内容,支持 Markdown 格式" rows="10" required />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="type" class="text-sm font-medium">类型</Label>
|
||||
<Select v-model="formData.type" v-model:open="typeSelectOpen">
|
||||
<SelectTrigger id="type" class="h-11">
|
||||
<SelectValue placeholder="选择类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="info">信息</SelectItem>
|
||||
<SelectItem value="warning">警告</SelectItem>
|
||||
<SelectItem value="maintenance">维护</SelectItem>
|
||||
<SelectItem value="important">重要</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="priority" class="text-sm font-medium">优先级</Label>
|
||||
<Input id="priority" v-model.number="formData.priority" type="number" placeholder="0" class="h-11" min="0" max="10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 p-3 border rounded-lg bg-muted/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="pinned"
|
||||
v-model="formData.is_pinned"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
/>
|
||||
<Label for="pinned" class="cursor-pointer text-sm">置顶公告</Label>
|
||||
</div>
|
||||
<div v-if="editingAnnouncement" class="flex items-center gap-2">
|
||||
<input
|
||||
id="active"
|
||||
v-model="formData.is_active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
||||
/>
|
||||
<Label for="active" class="cursor-pointer text-sm">启用</Label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="saveAnnouncement" :disabled="saving" class="h-10 px-5">
|
||||
<Loader2 v-if="saving" class="animate-spin h-4 w-4 mr-2" />
|
||||
{{ editingAnnouncement ? '保存' : '创建' }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="dialogOpen = false" type="button" class="h-10 px-5">取消</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<AlertDialog
|
||||
v-model="deleteDialogOpen"
|
||||
type="danger"
|
||||
title="确认删除"
|
||||
:description="`确定要删除公告「${deletingAnnouncement?.title}」吗?此操作无法撤销。`"
|
||||
confirm-text="删除"
|
||||
:loading="deleting"
|
||||
@confirm="deleteAnnouncement"
|
||||
@cancel="deleteDialogOpen = false"
|
||||
/>
|
||||
|
||||
<!-- 公告详情对话框 -->
|
||||
<Dialog v-model="detailDialogOpen" size="lg">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg flex-shrink-0" :class="getDialogIconClass(viewingAnnouncement?.type)">
|
||||
<component
|
||||
v-if="viewingAnnouncement"
|
||||
:is="getAnnouncementIcon(viewingAnnouncement.type)"
|
||||
class="h-5 w-5"
|
||||
:class="getIconColor(viewingAnnouncement.type)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight truncate">{{ viewingAnnouncement?.title || '公告详情' }}</h3>
|
||||
<p class="text-xs text-muted-foreground">系统公告</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="viewingAnnouncement" class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-xs text-gray-500 dark:text-muted-foreground">
|
||||
<span>{{ viewingAnnouncement.author.username }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatFullDate(viewingAnnouncement.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-html="renderMarkdown(viewingAnnouncement.content)"
|
||||
class="prose prose-sm dark:prose-invert max-w-none"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="detailDialogOpen = false" type="button" class="h-10 px-5">关闭</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
Dialog,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Switch
|
||||
} from '@/components/ui'
|
||||
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 { AlertDialog } from '@/components/common'
|
||||
import { Bell, AlertCircle, AlertTriangle, Info, Pin, Wrench, Loader2, Plus, SquarePen, Trash2 } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
|
||||
const announcements = ref<Announcement[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const unreadCount = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 对话框状态
|
||||
const dialogOpen = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const detailDialogOpen = ref(false)
|
||||
const typeSelectOpen = ref(false)
|
||||
const editingAnnouncement = ref<Announcement | null>(null)
|
||||
const deletingAnnouncement = ref<Announcement | null>(null)
|
||||
const viewingAnnouncement = ref<Announcement | null>(null)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'info' as 'info' | 'warning' | 'maintenance' | 'important',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_active: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadAnnouncements()
|
||||
})
|
||||
|
||||
async function loadAnnouncements(page = 1) {
|
||||
loading.value = true
|
||||
currentPage.value = page
|
||||
try {
|
||||
const response = await announcementApi.getAnnouncements({
|
||||
active_only: !isAdmin.value, // 管理员可以看到所有公告
|
||||
limit: pageSize.value,
|
||||
offset: (page - 1) * pageSize.value
|
||||
})
|
||||
announcements.value = response.items
|
||||
total.value = response.total
|
||||
unreadCount.value = response.unread_count || 0
|
||||
} catch (error) {
|
||||
console.error('加载公告失败:', error)
|
||||
showError('加载公告失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(announcement: Announcement) {
|
||||
try {
|
||||
await announcementApi.markAsRead(announcement.id)
|
||||
announcement.is_read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
success('已标记为已读')
|
||||
} catch (error) {
|
||||
console.error('标记失败:', error)
|
||||
showError('标记失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function viewAnnouncementDetail(announcement: Announcement) {
|
||||
// 标记为已读
|
||||
if (!announcement.is_read && !isAdmin.value) {
|
||||
try {
|
||||
await announcementApi.markAsRead(announcement.id)
|
||||
announcement.is_read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
} catch (error) {
|
||||
console.error('标记已读失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示详情对话框
|
||||
viewingAnnouncement.value = announcement
|
||||
detailDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editingAnnouncement.value = null
|
||||
formData.value = {
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'info',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_active: true
|
||||
}
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(announcement: Announcement) {
|
||||
editingAnnouncement.value = announcement
|
||||
formData.value = {
|
||||
title: announcement.title,
|
||||
content: announcement.content,
|
||||
type: announcement.type,
|
||||
priority: announcement.priority,
|
||||
is_pinned: announcement.is_pinned,
|
||||
is_active: announcement.is_active
|
||||
}
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function toggleAnnouncementPin(announcement: Announcement, newStatus: boolean) {
|
||||
try {
|
||||
await announcementApi.updateAnnouncement(announcement.id, {
|
||||
is_pinned: newStatus
|
||||
})
|
||||
announcement.is_pinned = newStatus
|
||||
success(newStatus ? '已置顶' : '已取消置顶')
|
||||
} catch (error) {
|
||||
console.error('更新置顶状态失败:', error)
|
||||
showError('更新置顶状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAnnouncementActive(announcement: Announcement, newStatus: boolean) {
|
||||
try {
|
||||
await announcementApi.updateAnnouncement(announcement.id, {
|
||||
is_active: newStatus
|
||||
})
|
||||
announcement.is_active = newStatus
|
||||
success(newStatus ? '已启用' : '已禁用')
|
||||
} catch (error) {
|
||||
console.error('更新启用状态失败:', error)
|
||||
showError('更新启用状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAnnouncement() {
|
||||
if (!formData.value.title || !formData.value.content) {
|
||||
showError('请填写标题和内容')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingAnnouncement.value) {
|
||||
// 更新
|
||||
await announcementApi.updateAnnouncement(editingAnnouncement.value.id, formData.value)
|
||||
success('公告更新成功')
|
||||
} else {
|
||||
// 创建
|
||||
await announcementApi.createAnnouncement(formData.value)
|
||||
success('公告创建成功')
|
||||
}
|
||||
dialogOpen.value = false
|
||||
loadAnnouncements(currentPage.value)
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
showError('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(announcement: Announcement) {
|
||||
deletingAnnouncement.value = announcement
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function deleteAnnouncement() {
|
||||
if (!deletingAnnouncement.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
await announcementApi.deleteAnnouncement(deletingAnnouncement.value.id)
|
||||
success('公告已删除')
|
||||
deleteDialogOpen.value = false
|
||||
loadAnnouncements(currentPage.value)
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
showError('删除失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getAnnouncementIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return AlertCircle
|
||||
case 'warning':
|
||||
return AlertTriangle
|
||||
case 'maintenance':
|
||||
return Wrench
|
||||
default:
|
||||
return Info
|
||||
}
|
||||
}
|
||||
|
||||
function getIconColor(type: string) {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return 'text-red-500'
|
||||
case 'warning':
|
||||
return 'text-yellow-500'
|
||||
case 'maintenance':
|
||||
return 'text-orange-500'
|
||||
default:
|
||||
return 'text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function getIconBgClass(type: string) {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return 'bg-red-50 dark:bg-red-900/20'
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 dark:bg-yellow-900/20'
|
||||
case 'maintenance':
|
||||
return 'bg-orange-50 dark:bg-orange-900/20'
|
||||
default:
|
||||
return 'bg-primary/10'
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeTextColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
case 'warning':
|
||||
return 'text-yellow-600 dark:text-yellow-400'
|
||||
case 'maintenance':
|
||||
return 'text-orange-600 dark:text-orange-400'
|
||||
default:
|
||||
return 'text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeBadgeVariant(type: string): 'default' | 'success' | 'destructive' | 'warning' | 'secondary' {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return 'destructive'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'maintenance':
|
||||
return 'secondary'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return '重要'
|
||||
case 'warning':
|
||||
return '警告'
|
||||
case 'maintenance':
|
||||
return '维护'
|
||||
default:
|
||||
return '信息'
|
||||
}
|
||||
}
|
||||
|
||||
function getDialogIconClass(type?: string) {
|
||||
switch (type) {
|
||||
case 'important':
|
||||
return 'bg-rose-100 dark:bg-rose-900/30'
|
||||
case 'warning':
|
||||
return 'bg-amber-100 dark:bg-amber-900/30'
|
||||
case 'maintenance':
|
||||
return 'bg-orange-100 dark:bg-orange-900/30'
|
||||
default:
|
||||
return 'bg-primary/10 dark:bg-primary/20'
|
||||
}
|
||||
}
|
||||
|
||||
function formatFullDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function renderMarkdown(content: string): string {
|
||||
const rawHtml = marked(content) as string
|
||||
return sanitizeMarkdown(rawHtml)
|
||||
}
|
||||
|
||||
function getPlainText(content: string): string {
|
||||
// 简单地移除 Markdown 标记,用于预览
|
||||
return content
|
||||
.replace(/[#*_`~\[\]()]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
.substring(0, 200)
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes} 分钟前`
|
||||
} else if (hours < 24) {
|
||||
return `${hours} 小时前`
|
||||
} else if (days < 7) {
|
||||
return `${days} 天前`
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Markdown 内容样式 */
|
||||
:deep(.prose) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
:deep(.prose p) {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
:deep(.prose ul) {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
:deep(.prose li) {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
:deep(.prose h1),
|
||||
:deep(.prose h2),
|
||||
:deep(.prose h3) {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
:deep(.prose code) {
|
||||
@apply bg-gray-100 dark:bg-muted px-1 py-0.5 rounded text-sm;
|
||||
}
|
||||
|
||||
:deep(.prose pre) {
|
||||
@apply bg-gray-100 dark:bg-card p-3 rounded-lg overflow-x-auto;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
444
frontend/src/views/user/ModelCatalog.vue
Normal file
444
frontend/src/views/user/ModelCatalog.vue
Normal file
@@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 模型列表 -->
|
||||
<Card class="overflow-hidden">
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<!-- 左侧:标题 -->
|
||||
<h3 class="text-base font-semibold">可用模型</h3>
|
||||
|
||||
<!-- 右侧:操作区 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
id="model-search"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索模型名称..."
|
||||
class="w-44 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60 focus:border-primary/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 能力筛选 -->
|
||||
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.vision ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
@click="capabilityFilters.vision = !capabilityFilters.vision"
|
||||
title="Vision"
|
||||
>
|
||||
<Eye class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.toolUse ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
@click="capabilityFilters.toolUse = !capabilityFilters.toolUse"
|
||||
title="Tool Use"
|
||||
>
|
||||
<Wrench class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div class="w-px h-4 bg-border/60" />
|
||||
<button
|
||||
class="px-2.5 h-full text-xs transition-colors"
|
||||
:class="capabilityFilters.extendedThinking ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
|
||||
@click="capabilityFilters.extendedThinking = !capabilityFilters.extendedThinking"
|
||||
title="Extended Thinking"
|
||||
>
|
||||
<Brain class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="h-4 w-px bg-border" />
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton :loading="loading" @click="refreshData" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<Table class="table-fixed w-full">
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="w-[140px] h-12 font-semibold">模型名称</TableHead>
|
||||
<TableHead class="w-[120px] h-12 font-semibold">模型偏好</TableHead>
|
||||
<TableHead class="w-[100px] h-12 font-semibold">能力</TableHead>
|
||||
<TableHead class="w-[140px] h-12 font-semibold text-center">价格 ($/M)</TableHead>
|
||||
<TableHead class="w-[70px] h-12 font-semibold text-center">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="loading">
|
||||
<TableCell colspan="5" class="text-center py-12">
|
||||
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-else-if="filteredModels.length === 0">
|
||||
<TableCell colspan="5" class="text-center py-12 text-muted-foreground">
|
||||
没有找到匹配的模型
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<template v-else>
|
||||
<TableRow
|
||||
v-for="model in paginatedModels"
|
||||
:key="model.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
@mousedown="handleMouseDown"
|
||||
@click="openModelDetail(model, $event)"
|
||||
>
|
||||
<TableCell class="py-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium hover:text-primary transition-colors">{{ model.display_name || model.name }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||
<span>{{ model.name }}</span>
|
||||
<button
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
title="复制模型 ID"
|
||||
@click.stop="copyToClipboard(model.name)"
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex gap-1.5 flex-wrap items-center">
|
||||
<template v-if="getModelSupportedCapabilities(model).length > 0">
|
||||
<button
|
||||
v-for="cap in getModelSupportedCapabilitiesDetails(model)"
|
||||
:key="cap.name"
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all',
|
||||
isCapabilityEnabled(model.name, cap.name)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground'
|
||||
]"
|
||||
:title="cap.description"
|
||||
@click.stop="toggleCapability(model.name, cap.name)"
|
||||
>
|
||||
<Check v-if="isCapabilityEnabled(model.name, cap.name)" class="w-3 h-3" />
|
||||
<Plus v-else class="w-3 h-3" />
|
||||
{{ cap.short_name || cap.display_name }}
|
||||
</button>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground text-xs">-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4">
|
||||
<div class="flex gap-1.5">
|
||||
<Eye v-if="model.default_supports_vision" class="w-4 h-4 text-muted-foreground" title="Vision" />
|
||||
<Wrench v-if="model.default_supports_function_calling" class="w-4 h-4 text-muted-foreground" title="Tool Use" />
|
||||
<Brain v-if="model.default_supports_extended_thinking" class="w-4 h-4 text-muted-foreground" title="Extended Thinking" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="text-xs space-y-0.5">
|
||||
<!-- 按 Token 计费 -->
|
||||
<div v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')">
|
||||
<span class="text-muted-foreground">In:</span>
|
||||
<span class="font-mono ml-1">{{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}</span>
|
||||
<span class="text-muted-foreground mx-1">/</span>
|
||||
<span class="text-muted-foreground">Out:</span>
|
||||
<span class="font-mono ml-1">{{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}</span>
|
||||
<span v-if="hasTieredPricing(model)" class="ml-1 text-muted-foreground" title="阶梯计费">[阶梯]</span>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
<div v-if="model.default_price_per_request && model.default_price_per_request > 0">
|
||||
<span class="text-muted-foreground">按次:</span>
|
||||
<span class="font-mono ml-1">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
||||
</div>
|
||||
<!-- 无计费配置 -->
|
||||
<div v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request" class="text-muted-foreground">-</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge :variant="model.is_active ? 'success' : 'secondary'">
|
||||
{{ model.is_active ? '可用' : '停用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="!loading && filteredModels.length > 0"
|
||||
:current="currentPage"
|
||||
:total="filteredModels.length"
|
||||
:page-size="pageSize"
|
||||
@update:current="currentPage = $event"
|
||||
@update:pageSize="pageSize = $event"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 模型详情抽屉 -->
|
||||
<UserModelDetailDrawer
|
||||
v-model:open="drawerOpen"
|
||||
:model="selectedModel"
|
||||
:capabilities="allCapabilities"
|
||||
:user-configurable-capabilities="userConfigurableCapabilities"
|
||||
:model-capability-settings="modelCapabilitySettings"
|
||||
@toggle-capability="toggleCapability"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Loader2,
|
||||
Eye,
|
||||
Wrench,
|
||||
Brain,
|
||||
Search,
|
||||
Copy,
|
||||
Check,
|
||||
Plus,
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
Badge,
|
||||
Input,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
getPublicGlobalModels,
|
||||
type PublicGlobalModel,
|
||||
} from '@/api/public-models'
|
||||
import { meApi } from '@/api/me'
|
||||
import {
|
||||
getUserConfigurableCapabilities,
|
||||
getAllCapabilities,
|
||||
type CapabilityDefinition
|
||||
} from '@/api/endpoints'
|
||||
import UserModelDetailDrawer from './components/UserModelDetailDrawer.vue'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const models = ref<PublicGlobalModel[]>([])
|
||||
|
||||
// 抽屉状态
|
||||
const drawerOpen = ref(false)
|
||||
const selectedModel = ref<PublicGlobalModel | null>(null)
|
||||
|
||||
// 使用复用的行点击逻辑
|
||||
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
|
||||
|
||||
function openModelDetail(model: PublicGlobalModel, event: MouseEvent) {
|
||||
if (!shouldTriggerRowClick(event)) return
|
||||
selectedModel.value = model
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
// 分页
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 能力筛选
|
||||
const capabilityFilters = ref({
|
||||
vision: false,
|
||||
toolUse: false,
|
||||
extendedThinking: false,
|
||||
})
|
||||
|
||||
// 能力配置相关
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
const allCapabilities = ref<CapabilityDefinition[]>([])
|
||||
const userConfigurableCapabilities = computed(() =>
|
||||
availableCapabilities.value.filter(cap => cap.config_mode === 'user_configurable')
|
||||
)
|
||||
const modelCapabilitySettings = ref<Record<string, Record<string, boolean>>>({})
|
||||
const savingCapability = ref<string | null>(null) // 正在保存的能力标识 "modelName:capName"
|
||||
|
||||
// 获取模型支持的可配置能力名称列表(从 supported_capabilities 字段读取)
|
||||
function getModelSupportedCapabilities(model: PublicGlobalModel): string[] {
|
||||
if (!model.supported_capabilities) return []
|
||||
// 只返回用户可配置的能力
|
||||
return model.supported_capabilities.filter(capName =>
|
||||
userConfigurableCapabilities.value.some(cap => cap.name === capName)
|
||||
)
|
||||
}
|
||||
|
||||
// 获取模型支持的可配置能力详情列表
|
||||
function getModelSupportedCapabilitiesDetails(model: PublicGlobalModel): CapabilityDefinition[] {
|
||||
const supportedNames = getModelSupportedCapabilities(model)
|
||||
return userConfigurableCapabilities.value.filter(cap => supportedNames.includes(cap.name))
|
||||
}
|
||||
|
||||
// 检查某个能力是否已启用
|
||||
function isCapabilityEnabled(modelName: string, capName: string): boolean {
|
||||
return modelCapabilitySettings.value[modelName]?.[capName] || false
|
||||
}
|
||||
|
||||
// 切换能力配置
|
||||
async function toggleCapability(modelName: string, capName: string) {
|
||||
const capKey = `${modelName}:${capName}`
|
||||
if (savingCapability.value === capKey) return // 防止重复点击
|
||||
|
||||
savingCapability.value = capKey
|
||||
try {
|
||||
const currentEnabled = isCapabilityEnabled(modelName, capName)
|
||||
const newEnabled = !currentEnabled
|
||||
|
||||
// 更新本地状态
|
||||
const newSettings = { ...modelCapabilitySettings.value }
|
||||
if (!newSettings[modelName]) {
|
||||
newSettings[modelName] = {}
|
||||
}
|
||||
|
||||
if (newEnabled) {
|
||||
newSettings[modelName][capName] = true
|
||||
} else {
|
||||
delete newSettings[modelName][capName]
|
||||
// 如果该模型没有任何能力配置了,删除整个模型条目
|
||||
if (Object.keys(newSettings[modelName]).length === 0) {
|
||||
delete newSettings[modelName]
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 API 保存
|
||||
await meApi.updateModelCapabilitySettings({
|
||||
model_capability_settings: Object.keys(newSettings).length > 0 ? newSettings : null
|
||||
})
|
||||
|
||||
// 更新本地状态
|
||||
modelCapabilitySettings.value = newSettings
|
||||
} catch (err) {
|
||||
console.error('保存能力配置失败:', err)
|
||||
showError('保存失败,请重试')
|
||||
} finally {
|
||||
savingCapability.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选后的模型列表
|
||||
const filteredModels = computed(() => {
|
||||
let result = models.value
|
||||
|
||||
// 搜索
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(m =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.display_name?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// 能力筛选
|
||||
if (capabilityFilters.value.vision) {
|
||||
result = result.filter(m => m.default_supports_vision)
|
||||
}
|
||||
if (capabilityFilters.value.toolUse) {
|
||||
result = result.filter(m => m.default_supports_function_calling)
|
||||
}
|
||||
if (capabilityFilters.value.extendedThinking) {
|
||||
result = result.filter(m => m.default_supports_extended_thinking)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 分页计算
|
||||
const paginatedModels = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return filteredModels.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 搜索或筛选变化时重置到第一页
|
||||
watch([searchQuery, capabilityFilters], () => {
|
||||
currentPage.value = 1
|
||||
}, { deep: true })
|
||||
|
||||
async function loadModels() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getPublicGlobalModels({ limit: 1000 })
|
||||
models.value = response.models || []
|
||||
} catch (err: any) {
|
||||
console.error('加载模型失败:', err)
|
||||
showError(err.response?.data?.detail || err.message, '加载模型失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
const [userCaps, allCaps] = await Promise.all([
|
||||
getUserConfigurableCapabilities(),
|
||||
getAllCapabilities()
|
||||
])
|
||||
availableCapabilities.value = userCaps
|
||||
allCapabilities.value = allCaps
|
||||
} catch (err) {
|
||||
console.error('Failed to load capabilities:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelCapabilitySettings() {
|
||||
try {
|
||||
const response = await meApi.getModelCapabilitySettings()
|
||||
modelCapabilitySettings.value = response.model_capability_settings || {}
|
||||
} catch (err) {
|
||||
console.error('Failed to load model capability settings:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
await Promise.all([loadModels(), loadCapabilities(), loadModelCapabilitySettings()])
|
||||
}
|
||||
|
||||
// 从 PublicGlobalModel 的 default_tiered_pricing 获取第一阶梯价格
|
||||
function getFirstTierPrice(model: PublicGlobalModel, type: 'input' | 'output'): number | null {
|
||||
const tiered = model.default_tiered_pricing
|
||||
if (!tiered?.tiers?.length) return null
|
||||
const firstTier = tiered.tiers[0]
|
||||
if (type === 'input') {
|
||||
return firstTier.input_price_per_1m || null
|
||||
}
|
||||
return firstTier.output_price_per_1m || null
|
||||
}
|
||||
|
||||
// 检测是否有阶梯计费(多于一个阶梯)
|
||||
function hasTieredPricing(model: PublicGlobalModel): boolean {
|
||||
const tiered = model.default_tiered_pricing
|
||||
return (tiered?.tiers?.length || 0) > 1
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制')
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
638
frontend/src/views/user/MyApiKeys.vue
Normal file
638
frontend/src/views/user/MyApiKeys.vue
Normal file
@@ -0,0 +1,638 @@
|
||||
<template>
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- API Keys 表格 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<!-- 标题和操作栏 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-base font-semibold">我的 API Keys</h3>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 新增 API Key 按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="showCreateDialog = true"
|
||||
title="创建新 API Key"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton :loading="loading" @click="loadApiKeys" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingState message="加载中..." />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="apiKeys.length === 0" class="flex items-center justify-center py-12">
|
||||
<EmptyState
|
||||
title="暂无 API 密钥"
|
||||
description="创建你的第一个 API 密钥开始使用"
|
||||
:icon="Key"
|
||||
>
|
||||
<template #actions>
|
||||
<Button @click="showCreateDialog = true" size="lg" class="shadow-lg shadow-primary/20">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
创建新 API Key
|
||||
</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<div v-else class="hidden md:block overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||
<TableHead class="min-w-[200px] h-12 font-semibold">密钥名称</TableHead>
|
||||
<TableHead class="min-w-[80px] h-12 font-semibold">能力</TableHead>
|
||||
<TableHead class="min-w-[160px] h-12 font-semibold">密钥</TableHead>
|
||||
<TableHead class="min-w-[100px] h-12 font-semibold">费用(USD)</TableHead>
|
||||
<TableHead class="min-w-[100px] h-12 font-semibold">请求次数</TableHead>
|
||||
<TableHead class="min-w-[70px] h-12 font-semibold text-center">状态</TableHead>
|
||||
<TableHead class="min-w-[100px] h-12 font-semibold">最后使用</TableHead>
|
||||
<TableHead class="min-w-[80px] h-12 font-semibold text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="apiKey in paginatedApiKeys"
|
||||
:key="apiKey.id"
|
||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<!-- 密钥名称 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold truncate" :title="apiKey.name">
|
||||
{{ apiKey.name }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-0.5">
|
||||
创建于 {{ formatDate(apiKey.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 能力 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex gap-1.5 flex-wrap items-center">
|
||||
<template v-if="userConfigurableCapabilities.length > 0">
|
||||
<button
|
||||
v-for="cap in userConfigurableCapabilities"
|
||||
:key="cap.name"
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-all',
|
||||
isCapabilityEnabled(apiKey, cap.name)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-transparent text-muted-foreground border border-dashed border-muted-foreground/50 hover:border-primary/50 hover:text-foreground'
|
||||
]"
|
||||
:title="getCapabilityTooltip(cap, isCapabilityEnabled(apiKey, cap.name))"
|
||||
@click.stop="toggleCapability(apiKey, cap.name)"
|
||||
>
|
||||
<Check v-if="isCapabilityEnabled(apiKey, cap.name)" class="w-3 h-3" />
|
||||
<Plus v-else class="w-3 h-3" />
|
||||
{{ cap.short_name || cap.display_name }}
|
||||
</button>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground text-xs">-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 密钥显示 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<code class="text-xs font-mono text-muted-foreground bg-muted/30 px-2 py-1 rounded">
|
||||
{{ apiKey.key_display || 'sk-••••••••' }}
|
||||
</code>
|
||||
<Button
|
||||
@click="copyApiKey(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6"
|
||||
title="复制完整密钥"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 费用 -->
|
||||
<TableCell class="py-4">
|
||||
<span class="text-sm font-semibold text-amber-600 dark:text-amber-500">
|
||||
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<!-- 请求次数 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Activity class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{{ formatNumber(apiKey.total_requests || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- 状态 -->
|
||||
<TableCell class="py-4 text-center">
|
||||
<Badge
|
||||
:variant="apiKey.is_active ? 'success' : 'secondary'"
|
||||
class="font-medium px-3 py-1"
|
||||
>
|
||||
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<!-- 最后使用时间 -->
|
||||
<TableCell class="py-4 text-sm text-muted-foreground">
|
||||
{{ apiKey.last_used_at ? formatRelativeTime(apiKey.last_used_at) : '从未使用' }}
|
||||
</TableCell>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<TableCell class="py-4">
|
||||
<div class="flex justify-center gap-1">
|
||||
<Button
|
||||
@click="toggleApiKey(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="apiKey.is_active ? '禁用' : '启用'"
|
||||
>
|
||||
<Power class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@click="confirmDelete(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div v-if="!loading && apiKeys.length > 0" class="md:hidden space-y-3 p-4">
|
||||
<Card
|
||||
v-for="apiKey in paginatedApiKeys"
|
||||
:key="apiKey.id"
|
||||
variant="default"
|
||||
class="group hover:shadow-md hover:border-primary/30 transition-all duration-200"
|
||||
>
|
||||
<div class="p-4">
|
||||
<!-- 第一行:名称、状态、操作 -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h3 class="text-sm font-semibold text-foreground truncate">{{ apiKey.name }}</h3>
|
||||
<Badge
|
||||
:variant="apiKey.is_active ? 'success' : 'secondary'"
|
||||
class="text-xs px-1.5 py-0"
|
||||
>
|
||||
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Button
|
||||
@click="copyApiKey(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="复制"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
@click="toggleApiKey(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:title="apiKey.is_active ? '禁用' : '启用'"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
@click="confirmDelete(apiKey)"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:密钥、时间、统计 -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<code class="font-mono text-muted-foreground">{{ apiKey.key_display || 'sk-••••••••' }}</code>
|
||||
<span class="text-muted-foreground">•</span>
|
||||
<span class="text-muted-foreground">
|
||||
{{ apiKey.last_used_at ? formatRelativeTime(apiKey.last_used_at) : '从未使用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="text-amber-600 dark:text-amber-500 font-semibold">
|
||||
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">•</span>
|
||||
<span class="text-foreground font-medium">
|
||||
{{ formatNumber(apiKey.total_requests || 0) }} 次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="apiKeys.length > 0"
|
||||
:current="currentPage"
|
||||
:total="apiKeys.length"
|
||||
:page-size="pageSize"
|
||||
@update:current="currentPage = $event"
|
||||
@update:pageSize="pageSize = $event"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 创建 API 密钥对话框 -->
|
||||
<Dialog v-model="showCreateDialog">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Key class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">创建 API 密钥</h3>
|
||||
<p class="text-xs text-muted-foreground">创建一个新的密钥用于访问 API 服务</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="key-name" class="text-sm font-semibold">密钥名称</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
v-model="newKeyName"
|
||||
placeholder="例如:生产环境密钥"
|
||||
class="h-11 border-border/60"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">给密钥起一个有意义的名称方便识别</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" class="h-11 px-6" @click="showCreateDialog = false">取消</Button>
|
||||
<Button class="h-11 px-6 shadow-lg shadow-primary/20" @click="createApiKey" :disabled="creating">
|
||||
<Loader2 v-if="creating" class="animate-spin h-4 w-4 mr-2" />
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 新密钥创建成功对话框 -->
|
||||
<Dialog v-model="showKeyDialog" size="lg">
|
||||
<template #header>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
|
||||
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground leading-tight">创建成功</h3>
|
||||
<p class="text-xs text-muted-foreground">请妥善保管, 切勿泄露给他人</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">API 密钥</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
:value="newKeyValue"
|
||||
readonly
|
||||
class="flex-1 font-mono text-sm bg-muted/50 h-11"
|
||||
@click="($event.target as HTMLInputElement)?.select()"
|
||||
/>
|
||||
<Button @click="copyTextToClipboard(newKeyValue)" class="h-11">
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="showKeyDialog = false" class="h-10 px-5">确定</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<AlertDialog
|
||||
v-model="showDeleteDialog"
|
||||
type="danger"
|
||||
title="确认删除"
|
||||
:description="`确定要删除密钥 "${keyToDelete?.name}" 吗?此操作不可恢复。`"
|
||||
confirm-text="删除"
|
||||
:loading="deleting"
|
||||
@confirm="deleteApiKey"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { meApi, type ApiKey } from '@/api/me'
|
||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { Dialog, Pagination } from '@/components/ui'
|
||||
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, Check } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const apiKeys = ref<ApiKey[]>([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// 分页相关
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const paginatedApiKeys = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return apiKeys.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const showKeyDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
|
||||
const newKeyName = ref('')
|
||||
const newKeyValue = ref('')
|
||||
const keyToDelete = ref<ApiKey | null>(null)
|
||||
|
||||
// 能力配置相关
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
const userConfigurableCapabilities = computed(() =>
|
||||
availableCapabilities.value.filter(cap => cap.config_mode === 'user_configurable')
|
||||
)
|
||||
const savingCapability = ref<string | null>(null) // 正在保存的能力标识 "keyId:capName"
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
loadCapabilities()
|
||||
})
|
||||
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
availableCapabilities.value = await getAllCapabilities()
|
||||
} catch (error) {
|
||||
console.error('Failed to load capabilities:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
apiKeys.value = await meApi.getApiKeys()
|
||||
} catch (error: any) {
|
||||
console.error('加载 API 密钥失败:', error)
|
||||
if (!error.response) {
|
||||
showError('无法连接到服务器,请检查后端服务是否运行')
|
||||
} else if (error.response.status === 401) {
|
||||
showError('认证失败,请重新登录')
|
||||
} else {
|
||||
showError('加载 API 密钥失败:' + (error.response?.data?.detail || error.message))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
if (!newKeyName.value.trim()) {
|
||||
showError('请输入密钥名称')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const newKey = await meApi.createApiKey(newKeyName.value)
|
||||
newKeyValue.value = newKey.key || ''
|
||||
showCreateDialog.value = false
|
||||
showKeyDialog.value = true
|
||||
newKeyName.value = ''
|
||||
await loadApiKeys()
|
||||
success('API 密钥创建成功')
|
||||
} catch (error) {
|
||||
console.error('创建 API 密钥失败:', error)
|
||||
showError('创建 API 密钥失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(apiKey: ApiKey) {
|
||||
keyToDelete.value = apiKey
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteApiKey() {
|
||||
if (!keyToDelete.value) return
|
||||
|
||||
deleting.value = true
|
||||
try {
|
||||
await meApi.deleteApiKey(keyToDelete.value.id)
|
||||
apiKeys.value = apiKeys.value.filter(k => k.id !== keyToDelete.value!.id)
|
||||
showDeleteDialog.value = false
|
||||
success('API 密钥已删除')
|
||||
} catch (error) {
|
||||
console.error('删除 API 密钥失败:', error)
|
||||
showError('删除 API 密钥失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
keyToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleApiKey(apiKey: ApiKey) {
|
||||
try {
|
||||
const updated = await meApi.toggleApiKey(apiKey.id)
|
||||
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
|
||||
if (index !== -1) {
|
||||
apiKeys.value[index].is_active = updated.is_active
|
||||
}
|
||||
success(updated.is_active ? '密钥已启用' : '密钥已禁用')
|
||||
} catch (error) {
|
||||
console.error('切换密钥状态失败:', error)
|
||||
showError('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 检查某个能力是否已启用
|
||||
function isCapabilityEnabled(apiKey: ApiKey, capName: string): boolean {
|
||||
return apiKey.force_capabilities?.[capName] || false
|
||||
}
|
||||
|
||||
// 切换能力配置
|
||||
async function toggleCapability(apiKey: ApiKey, capName: string) {
|
||||
const capKey = `${apiKey.id}:${capName}`
|
||||
if (savingCapability.value === capKey) return // 防止重复点击
|
||||
|
||||
savingCapability.value = capKey
|
||||
try {
|
||||
const currentEnabled = isCapabilityEnabled(apiKey, capName)
|
||||
const newEnabled = !currentEnabled
|
||||
|
||||
// 构建新的能力配置
|
||||
const newCapabilities: Record<string, boolean> = { ...(apiKey.force_capabilities || {}) }
|
||||
|
||||
if (newEnabled) {
|
||||
newCapabilities[capName] = true
|
||||
} else {
|
||||
delete newCapabilities[capName]
|
||||
}
|
||||
|
||||
const capabilitiesData = Object.keys(newCapabilities).length > 0 ? newCapabilities : null
|
||||
|
||||
// 调用 API 保存
|
||||
await meApi.updateApiKeyCapabilities(apiKey.id, {
|
||||
force_capabilities: capabilitiesData
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
|
||||
if (index !== -1) {
|
||||
apiKeys.value[index].force_capabilities = capabilitiesData
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('保存能力配置失败:', err)
|
||||
showError('保存失败,请重试')
|
||||
} finally {
|
||||
savingCapability.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function copyApiKey(apiKey: ApiKey) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await meApi.getFullApiKey(apiKey.id)
|
||||
await copyTextToClipboard(response.key, false) // 不显示内部提示
|
||||
success('完整密钥已复制到剪贴板')
|
||||
} catch (error) {
|
||||
console.error('复制密钥失败:', error)
|
||||
showError('复制失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string, showToast: boolean = true) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
if (showToast) success('已复制到剪贴板')
|
||||
} else {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
textArea.style.top = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful && showToast) {
|
||||
success('已复制到剪贴板')
|
||||
} else if (!successful) {
|
||||
showError('复制失败,请手动复制')
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
showError('复制失败,请手动选择文本进行复制')
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(num: number | undefined | null): string {
|
||||
if (num === undefined || num === null) {
|
||||
return '0'
|
||||
}
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMins < 1) return '刚刚'
|
||||
if (diffMins < 60) return `${diffMins}分钟前`
|
||||
if (diffHours < 24) return `${diffHours}小时前`
|
||||
if (diffDays < 7) return `${diffDays}天前`
|
||||
|
||||
return formatDate(dateString)
|
||||
}
|
||||
|
||||
// 获取能力按钮的提示文字
|
||||
function getCapabilityTooltip(cap: CapabilityDefinition, isEnabled: boolean): string {
|
||||
if (isEnabled) {
|
||||
return `[已启用] 此密钥只能访问支持${cap.display_name}的模型`
|
||||
}
|
||||
return `${cap.description}`
|
||||
}
|
||||
|
||||
</script>
|
||||
457
frontend/src/views/user/Settings.vue
Normal file
457
frontend/src/views/user/Settings.vue
Normal file
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h2 class="text-2xl font-bold text-foreground mb-6">个人设置</h2>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- 左侧:个人信息和密码 -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- 基本信息 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">基本信息</h3>
|
||||
<form @submit.prevent="updateProfile" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label for="username">用户名</Label>
|
||||
<Input id="username" v-model="profileForm.username" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="email">邮箱</Label>
|
||||
<Input id="email" v-model="profileForm.email" type="email" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="bio">个人简介</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
v-model="preferencesForm.bio"
|
||||
rows="3"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="avatar">头像 URL</Label>
|
||||
<Input id="avatar" v-model="preferencesForm.avatar_url" type="url" class="mt-1" />
|
||||
<p class="mt-1 text-sm text-muted-foreground">输入头像图片的 URL 地址</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="savingProfile">
|
||||
{{ savingProfile ? '保存中...' : '保存修改' }}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">修改密码</h3>
|
||||
<form @submit.prevent="changePassword" class="space-y-4">
|
||||
<div>
|
||||
<Label for="old-password">当前密码</Label>
|
||||
<Input id="old-password" v-model="passwordForm.old_password" type="password" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="new-password">新密码</Label>
|
||||
<Input id="new-password" v-model="passwordForm.new_password" type="password" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="confirm-password">确认新密码</Label>
|
||||
<Input id="confirm-password" v-model="passwordForm.confirm_password" type="password" class="mt-1" />
|
||||
</div>
|
||||
<Button type="submit" :disabled="changingPassword">
|
||||
{{ changingPassword ? '修改中...' : '修改密码' }}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<!-- 偏好设置 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">偏好设置</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label for="theme">主题</Label>
|
||||
<Select
|
||||
v-model="preferencesForm.theme"
|
||||
v-model:open="themeSelectOpen"
|
||||
@update:model-value="handleThemeChange"
|
||||
>
|
||||
<SelectTrigger id="theme" class="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">浅色</SelectItem>
|
||||
<SelectItem value="dark">深色</SelectItem>
|
||||
<SelectItem value="system">跟随系统</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="language">语言</Label>
|
||||
<Select
|
||||
v-model="preferencesForm.language"
|
||||
v-model:open="languageSelectOpen"
|
||||
@update:model-value="handleLanguageChange"
|
||||
>
|
||||
<SelectTrigger id="language" class="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zh-CN">简体中文</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="timezone">时区</Label>
|
||||
<Input id="timezone" v-model="preferencesForm.timezone" placeholder="Asia/Shanghai" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-foreground">通知设置</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
|
||||
<div class="flex-1">
|
||||
<Label for="email-notifications" class="text-sm font-medium cursor-pointer">
|
||||
邮件通知
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">接收系统重要通知</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="email-notifications"
|
||||
v-model="preferencesForm.notifications.email"
|
||||
@update:modelValue="updatePreferences"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
|
||||
<div class="flex-1">
|
||||
<Label for="usage-alerts" class="text-sm font-medium cursor-pointer">
|
||||
使用提醒
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">当接近配额限制时提醒</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="usage-alerts"
|
||||
v-model="preferencesForm.notifications.usage_alerts"
|
||||
@update:modelValue="updatePreferences"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<div class="flex-1">
|
||||
<Label for="announcement-notifications" class="text-sm font-medium cursor-pointer">
|
||||
公告通知
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground mt-1">接收系统公告</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="announcement-notifications"
|
||||
v-model="preferencesForm.notifications.announcements"
|
||||
@update:modelValue="updatePreferences"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:账户信息和使用量 -->
|
||||
<div class="space-y-6">
|
||||
<!-- 账户信息 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">账户信息</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">角色</span>
|
||||
<Badge :variant="profile?.role === 'admin' ? 'default' : 'secondary'">
|
||||
{{ profile?.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">账户状态</span>
|
||||
<span :class="profile?.is_active ? 'text-success' : 'text-destructive'">
|
||||
{{ profile?.is_active ? '活跃' : '已停用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">注册时间</span>
|
||||
<span class="text-foreground">
|
||||
{{ formatDate(profile?.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">最后登录</span>
|
||||
<span class="text-foreground">
|
||||
{{ profile?.last_login_at ? formatDate(profile.last_login_at) : '未记录' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 使用配额 -->
|
||||
<Card class="p-6">
|
||||
<h3 class="text-lg font-medium text-foreground mb-4">使用配额</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-muted-foreground">配额使用(美元)</span>
|
||||
<span class="text-foreground">
|
||||
<template v-if="isUnlimitedQuota()">
|
||||
{{ formatCurrency(profile?.used_usd || 0) }} /
|
||||
<span class="text-warning">无限制</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatCurrency(profile?.used_usd || 0) }} /
|
||||
{{ formatCurrency(profile?.quota_usd || 0) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-muted rounded-full h-2.5">
|
||||
<div
|
||||
class="bg-success h-2.5 rounded-full"
|
||||
:style="`width: ${getUsagePercentage()}%`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { meApi, type Profile } from '@/api/me'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Textarea from '@/components/ui/textarea.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 Switch from '@/components/ui/switch.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatCurrency } from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const profile = ref<Profile | null>(null)
|
||||
|
||||
const profileForm = ref({
|
||||
email: '',
|
||||
username: ''
|
||||
})
|
||||
|
||||
const passwordForm = ref({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
const preferencesForm = ref({
|
||||
avatar_url: '',
|
||||
bio: '',
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
timezone: 'Asia/Shanghai',
|
||||
notifications: {
|
||||
email: true,
|
||||
usage_alerts: true,
|
||||
announcements: true
|
||||
}
|
||||
})
|
||||
|
||||
const savingProfile = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
const themeSelectOpen = ref(false)
|
||||
const languageSelectOpen = ref(false)
|
||||
|
||||
function handleThemeChange(value: string) {
|
||||
preferencesForm.value.theme = value
|
||||
themeSelectOpen.value = false
|
||||
updatePreferences()
|
||||
|
||||
// 应用主题
|
||||
if (value === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (value === 'light') {
|
||||
document.documentElement.classList.remove('dark')
|
||||
} else {
|
||||
// system: 跟随系统
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleLanguageChange(value: string) {
|
||||
preferencesForm.value.language = value
|
||||
languageSelectOpen.value = false
|
||||
updatePreferences()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProfile()
|
||||
await loadPreferences()
|
||||
})
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
profile.value = await meApi.getProfile()
|
||||
profileForm.value = {
|
||||
email: profile.value.email,
|
||||
username: profile.value.username
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('加载个人信息失败:', error)
|
||||
showError('加载个人信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreferences() {
|
||||
try {
|
||||
const prefs = await meApi.getPreferences()
|
||||
preferencesForm.value = {
|
||||
avatar_url: prefs.avatar_url || '',
|
||||
bio: prefs.bio || '',
|
||||
theme: prefs.theme || 'light',
|
||||
language: prefs.language || 'zh-CN',
|
||||
timezone: prefs.timezone || 'Asia/Shanghai',
|
||||
notifications: {
|
||||
email: prefs.notifications?.email ?? true,
|
||||
usage_alerts: prefs.notifications?.usage_alerts ?? true,
|
||||
announcements: prefs.notifications?.announcements ?? true
|
||||
}
|
||||
}
|
||||
|
||||
// 应用主题
|
||||
if (preferencesForm.value.theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else if (preferencesForm.value.theme === 'light') {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('加载偏好设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile() {
|
||||
savingProfile.value = true
|
||||
try {
|
||||
await meApi.updateProfile(profileForm.value)
|
||||
|
||||
// 同时更新偏好设置中的 avatar_url 和 bio
|
||||
await meApi.updatePreferences({
|
||||
avatar_url: preferencesForm.value.avatar_url || undefined,
|
||||
bio: preferencesForm.value.bio || undefined,
|
||||
theme: preferencesForm.value.theme,
|
||||
language: preferencesForm.value.language,
|
||||
timezone: preferencesForm.value.timezone || undefined,
|
||||
notifications: {
|
||||
email: preferencesForm.value.notifications.email,
|
||||
usage_alerts: preferencesForm.value.notifications.usage_alerts,
|
||||
announcements: preferencesForm.value.notifications.announcements
|
||||
}
|
||||
})
|
||||
|
||||
success('个人信息已更新')
|
||||
await loadProfile()
|
||||
authStore.fetchCurrentUser()
|
||||
} catch (error) {
|
||||
log.error('更新个人信息失败:', error)
|
||||
showError('更新个人信息失败')
|
||||
} finally {
|
||||
savingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
|
||||
showError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.value.new_password.length < 8) {
|
||||
showError('密码长度至少8位')
|
||||
return
|
||||
}
|
||||
|
||||
changingPassword.value = true
|
||||
try {
|
||||
await meApi.changePassword({
|
||||
old_password: passwordForm.value.old_password,
|
||||
new_password: passwordForm.value.new_password
|
||||
})
|
||||
success('密码修改成功')
|
||||
passwordForm.value = {
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('修改密码失败:', error)
|
||||
showError('修改密码失败,请检查当前密码是否正确')
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePreferences() {
|
||||
try {
|
||||
await meApi.updatePreferences({
|
||||
avatar_url: preferencesForm.value.avatar_url || undefined,
|
||||
bio: preferencesForm.value.bio || undefined,
|
||||
theme: preferencesForm.value.theme,
|
||||
language: preferencesForm.value.language,
|
||||
timezone: preferencesForm.value.timezone || undefined,
|
||||
notifications: {
|
||||
email: preferencesForm.value.notifications.email,
|
||||
usage_alerts: preferencesForm.value.notifications.usage_alerts,
|
||||
announcements: preferencesForm.value.notifications.announcements
|
||||
}
|
||||
})
|
||||
success('设置已保存')
|
||||
} catch (error) {
|
||||
log.error('更新偏好设置失败:', error)
|
||||
showError('保存设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
function getUsagePercentage(): number {
|
||||
if (!profile.value) return 0
|
||||
|
||||
const quota = profile.value.quota_usd
|
||||
const used = profile.value.used_usd
|
||||
if (quota == null || quota === 0) return 0
|
||||
return Math.min(100, (used / quota) * 100)
|
||||
}
|
||||
|
||||
function isUnlimitedQuota(): boolean {
|
||||
return profile.value?.quota_usd == null
|
||||
}
|
||||
|
||||
function formatDate(dateString?: string): string {
|
||||
if (!dateString) return '未知'
|
||||
return new Date(dateString).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
395
frontend/src/views/user/components/UserModelDetailDrawer.vue
Normal file
395
frontend/src/views/user/components/UserModelDetailDrawer.vue
Normal file
@@ -0,0 +1,395 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="open && model"
|
||||
class="fixed inset-0 z-50 flex justify-end"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- 背景遮罩 -->
|
||||
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="handleClose"></div>
|
||||
|
||||
<!-- 抽屉内容 -->
|
||||
<Card class="relative h-full w-[700px] rounded-none shadow-2xl overflow-y-auto">
|
||||
<!-- 标题栏 -->
|
||||
<div class="sticky top-0 z-10 bg-background border-b p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1 flex-1 min-w-0">
|
||||
<h3 class="text-xl font-bold truncate">{{ model.display_name || model.name }}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :variant="model.is_active ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.is_active ? '可用' : '停用' }}
|
||||
</Badge>
|
||||
<span class="text-sm text-muted-foreground font-mono">{{ model.name }}</span>
|
||||
<button
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors"
|
||||
title="复制模型 ID"
|
||||
@click="copyToClipboard(model.name)"
|
||||
>
|
||||
<Copy class="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="model.description" class="text-xs text-muted-foreground">
|
||||
{{ model.description }}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" @click="handleClose" title="关闭">
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- 模型能力 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">模型能力</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<Zap class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Streaming</p>
|
||||
<p class="text-xs text-muted-foreground">流式输出</p>
|
||||
</div>
|
||||
<Badge :variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_streaming ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<ImageIcon class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Image Generation</p>
|
||||
<p class="text-xs text-muted-foreground">图像生成</p>
|
||||
</div>
|
||||
<Badge :variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<Eye class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Vision</p>
|
||||
<p class="text-xs text-muted-foreground">视觉理解</p>
|
||||
</div>
|
||||
<Badge :variant="model.default_supports_vision ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<Wrench class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Tool Use</p>
|
||||
<p class="text-xs text-muted-foreground">工具调用</p>
|
||||
</div>
|
||||
<Badge :variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg border">
|
||||
<Brain class="w-5 h-5 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Extended Thinking</p>
|
||||
<p class="text-xs text-muted-foreground">深度思考</p>
|
||||
</div>
|
||||
<Badge :variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'" class="text-xs">
|
||||
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型偏好 -->
|
||||
<div v-if="getModelUserConfigurableCapabilities().length > 0" class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">模型偏好</h4>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="cap in getModelUserConfigurableCapabilities()"
|
||||
:key="cap.name"
|
||||
class="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium">{{ cap.display_name }}</p>
|
||||
<p v-if="cap.description" class="text-xs text-muted-foreground truncate">{{ cap.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isCapabilityEnabled(cap.name) ? 'bg-primary' : 'bg-muted'
|
||||
]"
|
||||
role="switch"
|
||||
:aria-checked="isCapabilityEnabled(cap.name)"
|
||||
@click="handleToggleCapability(cap.name)"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-background shadow-lg ring-0 transition duration-200 ease-in-out',
|
||||
isCapabilityEnabled(cap.name) ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 定价信息 -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-sm">定价信息</h4>
|
||||
|
||||
<!-- 单阶梯(固定价格)展示 -->
|
||||
<div v-if="getTierCount(model.default_tiered_pricing) <= 1" class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">输入价格 ($/M)</Label>
|
||||
<p class="text-lg font-semibold mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'input_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">输出价格 ($/M)</Label>
|
||||
<p class="text-lg font-semibold mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'output_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">缓存创建 ($/M)</Label>
|
||||
<p class="text-sm font-mono mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_creation_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg border">
|
||||
<Label class="text-xs text-muted-foreground">缓存读取 ($/M)</Label>
|
||||
<p class="text-sm font-mono mt-1">
|
||||
{{ getFirstTierPrice(model.default_tiered_pricing, 'cache_read_price_per_1m') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 1h 缓存 -->
|
||||
<div v-if="getFirst1hCachePrice(model.default_tiered_pricing) !== '-'" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20">
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存创建</Label>
|
||||
<span class="text-sm font-mono">{{ getFirst1hCachePrice(model.default_tiered_pricing) }}</span>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
<div v-if="model.default_price_per_request && model.default_price_per_request > 0" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20">
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
|
||||
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 多阶梯计费展示 -->
|
||||
<div v-else class="space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Layers class="w-4 h-4" />
|
||||
<span>阶梯计费 ({{ getTierCount(model.default_tiered_pricing) }} 档)</span>
|
||||
</div>
|
||||
|
||||
<!-- 阶梯价格表格 -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-muted/30">
|
||||
<TableHead class="text-xs h-9">阶梯</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">输入 ($/M)</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">输出 ($/M)</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">缓存创建</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">缓存读取</TableHead>
|
||||
<TableHead class="text-xs h-9 text-right">1h 缓存</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="(tier, index) in model.default_tiered_pricing?.tiers || []"
|
||||
:key="index"
|
||||
class="text-xs"
|
||||
>
|
||||
<TableCell class="py-2">
|
||||
<span v-if="tier.up_to === null" class="text-muted-foreground">
|
||||
{{ index === 0 ? '所有' : `> ${formatTierLimit((model.default_tiered_pricing?.tiers || [])[index - 1]?.up_to)}` }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ index === 0 ? '0' : formatTierLimit((model.default_tiered_pricing?.tiers || [])[index - 1]?.up_to) }} - {{ formatTierLimit(tier.up_to) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono">
|
||||
${{ tier.input_price_per_1m?.toFixed(2) || '0.00' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono">
|
||||
${{ tier.output_price_per_1m?.toFixed(2) || '0.00' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono text-muted-foreground">
|
||||
{{ tier.cache_creation_price_per_1m != null ? `$${tier.cache_creation_price_per_1m.toFixed(2)}` : '-' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono text-muted-foreground">
|
||||
{{ tier.cache_read_price_per_1m != null ? `$${tier.cache_read_price_per_1m.toFixed(2)}` : '-' }}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 text-right font-mono text-muted-foreground">
|
||||
{{ get1hCachePrice(tier) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 按次计费(多阶梯时也显示) -->
|
||||
<div v-if="model.default_price_per_request && model.default_price_per_request > 0" class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20">
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">按次计费</Label>
|
||||
<span class="text-sm font-mono">${{ model.default_price_per_request.toFixed(3) }}/次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
X,
|
||||
Eye,
|
||||
Wrench,
|
||||
Brain,
|
||||
Zap,
|
||||
Copy,
|
||||
Layers,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-vue-next'
|
||||
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 Label from '@/components/ui/label.vue'
|
||||
import Table from '@/components/ui/table.vue'
|
||||
import TableHeader from '@/components/ui/table-header.vue'
|
||||
import TableBody from '@/components/ui/table-body.vue'
|
||||
import TableRow from '@/components/ui/table-row.vue'
|
||||
import TableHead from '@/components/ui/table-head.vue'
|
||||
import TableCell from '@/components/ui/table-cell.vue'
|
||||
|
||||
import type { PublicGlobalModel } from '@/api/public-models'
|
||||
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
|
||||
import type { CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
|
||||
interface Props {
|
||||
model: PublicGlobalModel | null
|
||||
open: boolean
|
||||
capabilities?: CapabilityDefinition[]
|
||||
userConfigurableCapabilities?: CapabilityDefinition[]
|
||||
modelCapabilitySettings?: Record<string, Record<string, boolean>>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'toggle-capability': [modelName: string, capName: string]
|
||||
}>()
|
||||
|
||||
// 根据能力名称获取显示名称
|
||||
function getCapabilityDisplayName(capName: string): string {
|
||||
const cap = props.capabilities?.find(c => c.name === capName)
|
||||
return cap?.display_name || capName
|
||||
}
|
||||
|
||||
// 获取模型支持的用户可配置能力
|
||||
function getModelUserConfigurableCapabilities(): CapabilityDefinition[] {
|
||||
if (!props.model?.supported_capabilities || !props.userConfigurableCapabilities) return []
|
||||
return props.userConfigurableCapabilities.filter(cap =>
|
||||
props.model!.supported_capabilities!.includes(cap.name)
|
||||
)
|
||||
}
|
||||
|
||||
// 检查能力是否已启用
|
||||
function isCapabilityEnabled(capName: string): boolean {
|
||||
if (!props.model) return false
|
||||
return props.modelCapabilitySettings?.[props.model.name]?.[capName] || false
|
||||
}
|
||||
|
||||
// 切换能力
|
||||
function handleToggleCapability(capName: string) {
|
||||
if (!props.model) return
|
||||
emit('toggle-capability', props.model.name, capName)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstTierPrice(
|
||||
tieredPricing: TieredPricingConfig | undefined | null,
|
||||
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
|
||||
): string {
|
||||
if (!tieredPricing?.tiers?.length) return '-'
|
||||
const firstTier = tieredPricing.tiers[0]
|
||||
const value = firstTier[priceKey]
|
||||
if (value == null || value === 0) return '-'
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function getTierCount(tieredPricing: TieredPricingConfig | undefined | null): number {
|
||||
return tieredPricing?.tiers?.length || 0
|
||||
}
|
||||
|
||||
function formatTierLimit(limit: number | null | undefined): string {
|
||||
if (limit == null) return ''
|
||||
if (limit >= 1000000) {
|
||||
return `${(limit / 1000000).toFixed(1)}M`
|
||||
} else if (limit >= 1000) {
|
||||
return `${(limit / 1000).toFixed(0)}K`
|
||||
}
|
||||
return limit.toString()
|
||||
}
|
||||
|
||||
function get1hCachePrice(tier: PricingTier): string {
|
||||
const ttl1h = tier.cache_ttl_pricing?.find(t => t.ttl_minutes === 60)
|
||||
if (ttl1h) {
|
||||
return `$${ttl1h.cache_creation_price_per_1m.toFixed(2)}`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
function getFirst1hCachePrice(tieredPricing: TieredPricingConfig | undefined | null): string {
|
||||
if (!tieredPricing?.tiers?.length) return '-'
|
||||
return get1hCachePrice(tieredPricing.tiers[0])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-active .relative,
|
||||
.drawer-leave-active .relative {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-from .relative {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.drawer-leave-to .relative {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.drawer-enter-to .relative,
|
||||
.drawer-leave-from .relative {
|
||||
transform: translateX(0);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user