refactor(frontend): 优化布局和视图页面

- 更新 MainLayout 布局组件
- 优化 admin 视图: 用户、模型、Provider、API Keys 等管理页面
- 改进 shared 视图: Dashboard、Usage 页面
- 调整 user 视图: ModelCatalog、MyApiKeys、Settings、Announcements 页面
- 更新 public 视图: Home、CliSection、LogoColorDemo 页面
This commit is contained in:
fawney19
2025-12-12 16:15:54 +08:00
parent 06c0a47b21
commit 39ea9e8e86
20 changed files with 3062 additions and 1154 deletions

View File

@@ -6,9 +6,10 @@
:content-class="contentClasses"
>
<!-- GLOBAL TEXTURE (Paper Noise) -->
<div class="absolute inset-0 pointer-events-none z-0 opacity-[0.03] mix-blend-multiply fixed"
:style="{ backgroundImage: `url(\&quot;data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E\&quot;)` }">
</div>
<div
class="absolute inset-0 pointer-events-none z-0 opacity-[0.03] mix-blend-multiply fixed"
:style="{ backgroundImage: `url(\&quot;data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E\&quot;)` }"
/>
<template #notice>
<div class="flex w-full max-w-3xl items-center justify-between rounded-3xl bg-orange-500 px-6 py-3 text-white shadow-2xl ring-1 ring-white/30">
@@ -17,10 +18,10 @@
<span>认证已过期请重新登录</span>
</div>
<Button
@click="handleRelogin"
variant="outline"
size="sm"
class="border-white/60 text-white hover:bg-white/10"
@click="handleRelogin"
>
重新登录
</Button>
@@ -30,10 +31,18 @@
<template #sidebar>
<!-- HEADER (Brand) -->
<div class="shrink-0 flex items-center px-6 h-20">
<RouterLink to="/" class="flex items-center gap-3 group transition-opacity hover:opacity-80">
<HeaderLogo size="h-9 w-9" className="text-[#191919] dark:text-white" />
<RouterLink
to="/"
class="flex items-center gap-3 group transition-opacity hover:opacity-80"
>
<HeaderLogo
size="h-9 w-9"
class-name="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>
<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">Multi Private Gateway</span>
</div>
</RouterLink>
@@ -68,7 +77,11 @@
>
<Settings class="w-4 h-4" />
</RouterLink>
<button @click="handleLogout" class="p-1.5 rounded-md text-muted-foreground hover:text-red-500 transition-colors" title="退出登录">
<button
class="p-1.5 rounded-md text-muted-foreground hover:text-red-500 transition-colors"
title="退出登录"
@click="handleLogout"
>
<LogOut class="w-4 h-4" />
</button>
</div>
@@ -79,8 +92,14 @@
<template #header>
<!-- Mobile Header -->
<div class="lg:hidden p-4 flex items-center justify-between border-b border-border bg-background/80 backdrop-blur-md">
<RouterLink to="/" class="flex items-center gap-2">
<HeaderLogo size="h-8 w-8" className="text-[#191919] dark:text-white" />
<RouterLink
to="/"
class="flex items-center gap-2"
>
<HeaderLogo
size="h-8 w-8"
class-name="text-[#191919] dark:text-white"
/>
<span class="font-bold text-lg">Aether</span>
</RouterLink>
<MobileNav
@@ -102,7 +121,10 @@
</div>
<!-- Demo Mode Badge (center) -->
<div v-if="isDemo" class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 text-xs font-medium">
<div
v-if="isDemo"
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 text-xs font-medium"
>
<AlertTriangle class="w-3.5 h-3.5" />
<span>演示模式</span>
</div>
@@ -110,13 +132,22 @@
<div class="flex items-center gap-2">
<!-- Theme Toggle -->
<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' ? '深色模式' : '浅色模式'"
@click="toggleDarkMode"
>
<SunMoon v-if="themeMode === 'system'" class="h-4 w-4" />
<SunMedium v-else-if="themeMode === 'light'" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
<SunMoon
v-if="themeMode === 'system'"
class="h-4 w-4"
/>
<SunMedium
v-else-if="themeMode === 'light'"
class="h-4 w-4"
/>
<Moon
v-else
class="h-4 w-4"
/>
</button>
</div>
</header>

View File

@@ -4,7 +4,9 @@
<!-- 搜索和过滤区域 -->
<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>
<h3 class="text-base font-semibold">
别名管理
</h3>
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
@@ -20,14 +22,26 @@
<div class="h-4 w-px bg-border" />
<!-- 提供商过滤器 -->
<Select v-model:open="aliasProviderSelectOpen" :model-value="aliasProviderFilter" @update:model-value="aliasProviderFilter = $event">
<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">
<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>
@@ -40,37 +54,61 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openCreateAliasDialog"
title="新建别名"
@click="openCreateAliasDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loadingAliases" @click="loadAliases" />
<RefreshButton
:loading="loadingAliases"
@click="loadAliases"
/>
</div>
</div>
</div>
<div v-if="loadingAliases" class="flex items-center justify-center py-12">
<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>
<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">
<TableCell
colspan="6"
class="text-center py-8 text-muted-foreground"
>
{{ aliasProviderFilter === 'global' ? '暂无全局别名' : '暂无别名' }}
</TableCell>
</TableRow>
<TableRow v-for="alias in paginatedAliases" :key="alias.id">
<TableRow
v-for="alias in paginatedAliases"
:key="alias.id"
>
<TableCell>
<span class="font-mono font-medium">{{ alias.alias }}</span>
</TableCell>
@@ -81,7 +119,10 @@
</div>
</TableCell>
<TableCell class="text-center">
<Badge variant="secondary" class="text-xs">
<Badge
variant="secondary"
class="text-xs"
>
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
</Badge>
</TableCell>
@@ -102,7 +143,10 @@
</Badge>
</TableCell>
<TableCell class="text-center">
<Badge :variant="alias.is_active ? 'default' : 'secondary'" class="text-xs">
<Badge
:variant="alias.is_active ? 'default' : 'secondary'"
class="text-xs"
>
{{ alias.is_active ? '活跃' : '停用' }}
</Badge>
</TableCell>
@@ -112,8 +156,8 @@
variant="ghost"
size="icon"
class="h-7 w-7"
@click="openEditAliasDialog(alias)"
title="编辑别名"
@click="openEditAliasDialog(alias)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
@@ -121,8 +165,8 @@
variant="ghost"
size="icon"
class="h-7 w-7"
@click="toggleAliasStatus(alias)"
:title="alias.is_active ? '停用别名' : '启用别名'"
@click="toggleAliasStatus(alias)"
>
<Power class="w-3.5 h-3.5" />
</Button>
@@ -130,8 +174,8 @@
variant="ghost"
size="icon"
class="h-7 w-7"
@click="confirmDeleteAlias(alias)"
title="删除别名"
@click="confirmDeleteAlias(alias)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>

View File

@@ -1,8 +1,14 @@
<template>
<div class="space-y-6 pb-8">
<Card variant="default" class="overflow-hidden">
<Card
variant="default"
class="overflow-hidden"
>
<!-- 加载状态 -->
<div v-if="loading" class="py-16 text-center space-y-4">
<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>
@@ -11,10 +17,15 @@
<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>
<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>
<span
v-if="expiringSoonCount > 0"
class="text-amber-600"
> · 即将到期 {{ expiringSoonCount }}</span>
</p>
</div>
<div class="flex items-center gap-2">
@@ -33,24 +44,38 @@
<div class="h-4 w-px bg-border" />
<!-- 状态筛选 -->
<Select v-model="filterStatus" v-model:open="filterStatusOpen">
<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">
<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">
<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">
<SelectItem
v-for="balance in balanceFilters"
:key="balance.value"
:value="balance.value"
>
{{ balance.label }}
</SelectItem>
</SelectContent>
@@ -64,14 +89,17 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openCreateDialog"
title="创建独立 Key"
@click="openCreateDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton :loading="loading" @click="loadApiKeys" />
<RefreshButton
:loading="loading"
@click="loadApiKeys"
/>
</div>
</div>
</div>
@@ -80,33 +108,59 @@
<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>
<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">
<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>
<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
variant="outline"
size="sm"
class="mt-3"
@click="clearFilters"
>
清除筛选
</Button>
</div>
<div v-else>
<h3 class="text-lg font-semibold">暂无独立余额 Key</h3>
<h3 class="text-lg font-semibold">
暂无独立余额 Key
</h3>
<p class="mt-2 text-sm text-muted-foreground">
点击右上角按钮创建独立余额 Key
</p>
@@ -114,10 +168,17 @@
</div>
</TableCell>
</TableRow>
<TableRow v-for="apiKey in filteredApiKeys" :key="apiKey.id" class="border-b border-border/40 hover:bg-muted/30 transition-colors">
<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'">
<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">
@@ -128,8 +189,8 @@
variant="ghost"
size="icon"
class="h-6 w-6"
@click="copyKeyPrefix(apiKey)"
title="复制完整密钥"
@click="copyKeyPrefix(apiKey)"
>
<Copy class="h-3 w-3" />
</Button>
@@ -159,21 +220,42 @@
</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
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 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>
<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">
<Badge
:variant="apiKey.is_active ? 'success' : 'destructive'"
class="font-medium"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
</TableCell>
@@ -183,8 +265,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="editApiKey(apiKey)"
title="编辑"
@click="editApiKey(apiKey)"
>
<SquarePen class="h-4 w-4" />
</Button>
@@ -192,8 +274,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openAddBalanceDialog(apiKey)"
title="调整余额"
@click="openAddBalanceDialog(apiKey)"
>
<DollarSign class="h-4 w-4" />
</Button>
@@ -201,8 +283,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="toggleApiKey(apiKey)"
:title="apiKey.is_active ? '禁用' : '启用'"
@click="toggleApiKey(apiKey)"
>
<Power class="h-4 w-4" />
</Button>
@@ -210,8 +292,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="deleteApiKey(apiKey)"
title="删除"
@click="deleteApiKey(apiKey)"
>
<Trash2 class="h-4 w-4" />
</Button>
@@ -223,11 +305,20 @@
</div>
<div class="xl:hidden divide-y divide-border/40">
<div v-if="apiKeys.length === 0" class="p-8 text-center">
<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>
<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
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">
@@ -239,17 +330,23 @@
variant="ghost"
size="icon"
class="h-7 w-7 hover:bg-muted flex-shrink-0"
@click="copyKeyPrefix(apiKey)"
title="复制完整密钥"
@click="copyKeyPrefix(apiKey)"
>
<Copy class="h-3.5 w-3.5" />
</Button>
</div>
<div class="text-sm font-semibold text-foreground" :class="{ 'text-muted-foreground': !apiKey.name }">
<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">
<Badge
:variant="apiKey.is_active ? 'success' : 'destructive'"
class="text-xs flex-shrink-0"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
</div>
@@ -281,7 +378,10 @@
<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
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)}%` }"
@@ -291,19 +391,32 @@
<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 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 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="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">
<div
v-if="apiKey.expires_at"
class="text-[11px] text-muted-foreground"
>
{{ getRelativeTime(apiKey.expires_at) }}
</div>
</div>
@@ -313,10 +426,15 @@
<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-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>
<p v-if="apiKey.expires_at">
过期后: {{ apiKey.auto_delete_on_expiry ? '自动删除' : '仅禁用' }}
</p>
</div>
<div class="grid grid-cols-2 gap-2">
@@ -373,15 +491,18 @@
<!-- 创建/编辑独立Key对话框 -->
<StandaloneKeyFormDialog
ref="keyFormDialogRef"
:open="showKeyFormDialog"
:api-key="editingKeyData"
@close="closeKeyFormDialog"
@submit="handleKeyFormSubmit"
ref="keyFormDialogRef"
/>
<!-- Key 显示对话框 -->
<Dialog v-model="showNewKeyDialog" size="lg">
<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">
@@ -389,8 +510,12 @@
<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>
<h3 class="text-lg font-semibold text-foreground leading-tight">
创建成功
</h3>
<p class="text-xs text-muted-foreground">
请妥善保管, 切勿泄露给他人.
</p>
</div>
</div>
</div>
@@ -401,14 +526,17 @@
<Label class="text-sm font-medium">API Key</Label>
<div class="flex items-center gap-2">
<Input
ref="keyInput"
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
class="h-11"
@click="copyKey"
>
复制
</Button>
</div>
@@ -416,12 +544,20 @@
</div>
<template #footer>
<Button @click="closeNewKeyDialog" class="h-10 px-5">确定</Button>
<Button
class="h-10 px-5"
@click="closeNewKeyDialog"
>
确定
</Button>
</template>
</Dialog>
<!-- 余额调整对话框 -->
<Dialog v-model="showAddBalanceDialog" size="md">
<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">
@@ -429,8 +565,12 @@
<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>
<h3 class="text-lg font-semibold text-foreground leading-tight">
余额调整
</h3>
<p class="text-xs text-muted-foreground">
增加或扣除 API Key 余额
</p>
</div>
</div>
</div>
@@ -438,7 +578,9 @@
<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="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>
@@ -446,7 +588,10 @@
</div>
<div class="space-y-2">
<Label for="addBalanceAmount" class="text-sm font-medium">调整金额 (USD)</Label>
<Label
for="addBalanceAmount"
class="text-sm font-medium"
>调整金额 (USD)</Label>
<Input
id="addBalanceAmount"
:model-value="addBalanceAmount ?? ''"
@@ -457,13 +602,22 @@
@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">
<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">
<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
v-else
class="text-muted-foreground"
>
输入正数增加余额,负数扣除余额
</span>
</p>
@@ -472,10 +626,18 @@
<template #footer>
<div class="flex gap-3 justify-end">
<Button variant="outline" @click="showAddBalanceDialog = false" class="h-10 px-5">
<Button
variant="outline"
class="h-10 px-5"
@click="showAddBalanceDialog = false"
>
取消
</Button>
<Button @click="handleAddBalance" :disabled="addingBalance || !addBalanceAmount || addBalanceAmount === 0" class="h-10 px-5">
<Button
:disabled="addingBalance || !addBalanceAmount || addBalanceAmount === 0"
class="h-10 px-5"
@click="handleAddBalance"
>
{{ addingBalance ? '调整中...' : '确认调整' }}
</Button>
</div>

View File

@@ -1,13 +1,20 @@
<template>
<div class="space-y-6 pb-8">
<!-- 审计日志列表 -->
<Card variant="default" class="overflow-hidden">
<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>
<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">
<!-- 搜索框 -->
@@ -33,17 +40,39 @@
<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>
<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>
<!-- 时间范围筛选 -->
@@ -56,10 +85,18 @@
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="7">7</SelectItem>
<SelectItem value="30">30</SelectItem>
<SelectItem value="90">90</SelectItem>
<SelectItem value="1">
1
</SelectItem>
<SelectItem value="7">
7
</SelectItem>
<SelectItem value="30">
30
</SelectItem>
<SelectItem value="90">
90
</SelectItem>
</SelectContent>
</Select>
<!-- 重置筛选 -->
@@ -68,8 +105,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="handleResetFilters"
title="重置筛选"
@click="handleResetFilters"
>
<FilterX class="w-3.5 h-3.5" />
</Button>
@@ -79,22 +116,31 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="exportLogs"
title="导出"
@click="exportLogs"
>
<Download class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton :loading="loading" @click="refreshLogs" />
<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
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 v-else-if="logs.length === 0" class="text-center py-12 text-muted-foreground">
<div
v-else-if="logs.length === 0"
class="text-center py-12 text-muted-foreground"
>
暂无审计记录
</div>
@@ -102,45 +148,81 @@
<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>
<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">
<TableRow
v-for="log in logs"
:key="log.id"
class="cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors"
@mousedown="handleMouseDown"
@click="handleRowClick($event, log)"
>
<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">
<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">
<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>
<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" />
<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">
<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">
<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>
@@ -148,7 +230,10 @@
</TableCell>
<TableCell class="py-4">
<Badge v-if="log.status_code" :variant="getStatusCodeVariant(log.status_code)">
<Badge
v-if="log.status_code"
:variant="getStatusCodeVariant(log.status_code)"
>
{{ log.status_code }}
</Badge>
<span v-else>-</span>
@@ -170,12 +255,25 @@
</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
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">
<h3 class="text-lg font-medium">
审计日志详情
</h3>
<Button
variant="ghost"
size="sm"
@click="closeLogDetail"
>
<X class="h-4 w-4" />
</Button>
</div>
@@ -183,43 +281,64 @@
<div class="space-y-4">
<div>
<Label>事件类型</Label>
<p class="mt-1 text-sm">{{ getEventTypeLabel(selectedLog.event_type) }}</p>
<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>
<p class="mt-1 text-sm">
{{ selectedLog.description || '无描述' }}
</p>
</div>
<div>
<Label>时间</Label>
<p class="mt-1 text-sm">{{ formatDateTime(selectedLog.created_at) }}</p>
<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>
<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>
<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>
<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>
<p class="mt-1 text-sm text-destructive">
{{ selectedLog.error_message }}
</p>
</div>
<div v-if="selectedLog.metadata">
@@ -334,7 +453,7 @@ async function loadLogs() {
event_type: (filters.value.eventType !== '__all__' ? filters.value.eventType : undefined),
days: filters.value.days,
limit: pageSize.value,
offset: offset
offset
}
const data = await auditApi.getAuditLogs(filterParams)

View File

@@ -285,7 +285,9 @@ onBeforeUnmount(() => {
<div class="space-y-6">
<!-- 标题 -->
<div>
<h2 class="text-2xl font-bold">缓存监控</h2>
<h2 class="text-2xl font-bold">
缓存监控
</h2>
<p class="text-sm text-muted-foreground mt-1">
管理缓存亲和性提高 Prompt Caching 命中率
</p>
@@ -294,7 +296,9 @@ onBeforeUnmount(() => {
<!-- 亲和性系统状态 -->
<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-xs text-muted-foreground">
活跃亲和性
</div>
<div class="text-2xl font-bold mt-1">
{{ stats?.affinity_stats?.active_affinities || 0 }}
</div>
@@ -304,8 +308,13 @@ onBeforeUnmount(() => {
</Card>
<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' : ''">
<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">
@@ -314,8 +323,13 @@ onBeforeUnmount(() => {
</Card>
<Card class="p-4">
<div class="text-xs text-muted-foreground">缓存失效</div>
<div class="text-2xl font-bold mt-1" :class="(stats?.affinity_stats?.cache_invalidations || 0) > 0 ? 'text-warning' : ''">
<div class="text-xs text-muted-foreground">
缓存失效
</div>
<div
class="text-2xl font-bold mt-1"
:class="(stats?.affinity_stats?.cache_invalidations || 0) > 0 ? 'text-warning' : ''"
>
{{ stats?.affinity_stats?.cache_invalidations || 0 }}
</div>
<div class="text-xs text-muted-foreground mt-1">
@@ -326,7 +340,13 @@ onBeforeUnmount(() => {
<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>
<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">
@@ -347,7 +367,9 @@ onBeforeUnmount(() => {
<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>
<h3 class="text-base font-semibold">
亲和性列表
</h3>
</div>
<div class="flex items-center gap-2">
<div class="relative">
@@ -368,10 +390,19 @@ onBeforeUnmount(() => {
</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="清除全部缓存">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground/70 hover:text-destructive"
title="清除全部缓存"
@click="clearAllCache"
>
<Eraser class="h-4 w-4" />
</Button>
<RefreshButton :loading="loading || listLoading" @click="refreshData" />
<RefreshButton
:loading="loading || listLoading"
@click="refreshData"
/>
</div>
</div>
</div>
@@ -379,41 +410,99 @@ onBeforeUnmount(() => {
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-36">用户</TableHead>
<TableHead class="w-28">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>
<TableHead class="w-36">
用户
</TableHead>
<TableHead class="w-28">
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}`">
<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-[120px]" :title="item.username ?? undefined">{{ item.username || '未知' }}</span>
<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-[120px]"
: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-[80px]" :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>
<span
class="text-sm truncate max-w-[80px]"
: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>
<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>
<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>
<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>
<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>
@@ -426,9 +515,9 @@ onBeforeUnmount(() => {
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="清除缓存"
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
@@ -437,7 +526,10 @@ onBeforeUnmount(() => {
</TableBody>
<TableBody v-else>
<TableRow>
<TableCell colspan="8" class="text-center py-6 text-sm text-muted-foreground">
<TableCell
colspan="8"
class="text-center py-6 text-sm text-muted-foreground"
>
{{ listLoading ? '加载中...' : '暂无缓存记录' }}
</TableCell>
</TableRow>
@@ -460,7 +552,9 @@ onBeforeUnmount(() => {
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<BarChart3 class="h-5 w-5 text-muted-foreground" />
<h3 class="text-base font-semibold">TTL 分析</h3>
<h3 class="text-base font-semibold">
TTL 分析
</h3>
<span class="text-xs text-muted-foreground">分析用户请求间隔推荐合适的缓存 TTL</span>
</div>
<div class="flex items-center gap-2">
@@ -483,31 +577,62 @@ onBeforeUnmount(() => {
</div>
<!-- 缓存命中概览 -->
<div v-if="hitAnalysis" class="px-6 py-4 border-b border-border/40 bg-muted/30">
<div
v-if="hitAnalysis"
class="px-6 py-4 border-b border-border/40 bg-muted/30"
>
<div class="grid grid-cols-2 md:grid-cols-5 gap-6">
<div>
<div class="text-xs text-muted-foreground">请求命中率</div>
<div class="text-2xl font-bold text-success">{{ hitAnalysis.request_cache_hit_rate }}%</div>
<div class="text-xs text-muted-foreground">{{ formatNumber(hitAnalysis.requests_with_cache_hit) }} / {{ formatNumber(hitAnalysis.total_requests) }} 请求</div>
<div class="text-xs text-muted-foreground">
请求命中率
</div>
<div class="text-2xl font-bold text-success">
{{ hitAnalysis.request_cache_hit_rate }}%
</div>
<div class="text-xs text-muted-foreground">
{{ formatNumber(hitAnalysis.requests_with_cache_hit) }} / {{ formatNumber(hitAnalysis.total_requests) }} 请求
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">Token 命中率</div>
<div class="text-2xl font-bold">{{ hitAnalysis.token_cache_hit_rate }}%</div>
<div class="text-xs text-muted-foreground">{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens 命中</div>
<div class="text-xs text-muted-foreground">
Token 命中率
</div>
<div class="text-2xl font-bold">
{{ hitAnalysis.token_cache_hit_rate }}%
</div>
<div class="text-xs text-muted-foreground">
{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens 命中
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">缓存创建费用</div>
<div class="text-2xl font-bold">{{ formatCost(hitAnalysis.total_cache_creation_cost_usd) }}</div>
<div class="text-xs text-muted-foreground">{{ formatTokens(hitAnalysis.total_cache_creation_tokens) }} tokens</div>
<div class="text-xs text-muted-foreground">
缓存创建费用
</div>
<div class="text-2xl font-bold">
{{ formatCost(hitAnalysis.total_cache_creation_cost_usd) }}
</div>
<div class="text-xs text-muted-foreground">
{{ formatTokens(hitAnalysis.total_cache_creation_tokens) }} tokens
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">缓存读取费用</div>
<div class="text-2xl font-bold">{{ formatCost(hitAnalysis.total_cache_read_cost_usd) }}</div>
<div class="text-xs text-muted-foreground">{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens</div>
<div class="text-xs text-muted-foreground">
缓存读取费用
</div>
<div class="text-2xl font-bold">
{{ formatCost(hitAnalysis.total_cache_read_cost_usd) }}
</div>
<div class="text-xs text-muted-foreground">
{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">预估节省</div>
<div class="text-2xl font-bold text-success">{{ formatCost(hitAnalysis.estimated_savings_usd) }}</div>
<div class="text-xs text-muted-foreground">
预估节省
</div>
<div class="text-2xl font-bold text-success">
{{ formatCost(hitAnalysis.estimated_savings_usd) }}
</div>
</div>
</div>
</div>
@@ -516,24 +641,41 @@ onBeforeUnmount(() => {
<Table v-if="ttlAnalysis && ttlAnalysis.users.length > 0">
<TableHeader>
<TableRow>
<TableHead class="w-10"></TableHead>
<TableHead class="w-[20%]">用户</TableHead>
<TableHead class="w-[15%] text-center">请求数</TableHead>
<TableHead class="w-[15%] text-center">使用频率</TableHead>
<TableHead class="w-[15%] text-center">推荐 TTL</TableHead>
<TableHead class="w-10" />
<TableHead class="w-[20%]">
用户
</TableHead>
<TableHead class="w-[15%] text-center">
请求数
</TableHead>
<TableHead class="w-[15%] text-center">
使用频率
</TableHead>
<TableHead class="w-[15%] text-center">
推荐 TTL
</TableHead>
<TableHead>说明</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="user in ttlAnalysis.users" :key="user.group_id">
<template
v-for="user in ttlAnalysis.users"
:key="user.group_id"
>
<TableRow
class="cursor-pointer hover:bg-muted/50"
@click="toggleUserExpand(user.group_id)"
>
<TableCell class="p-2">
<button class="p-1 hover:bg-muted rounded">
<ChevronDown v-if="expandedUserId === user.group_id" class="h-4 w-4 text-muted-foreground" />
<ChevronRight v-else class="h-4 w-4 text-muted-foreground" />
<ChevronDown
v-if="expandedUserId === user.group_id"
class="h-4 w-4 text-muted-foreground"
/>
<ChevronRight
v-else
class="h-4 w-4 text-muted-foreground"
/>
</button>
</TableCell>
<TableCell>
@@ -543,7 +685,10 @@ onBeforeUnmount(() => {
<span class="text-sm font-medium">{{ user.request_count }}</span>
</TableCell>
<TableCell class="text-center">
<span class="text-sm" :class="getFrequencyClass(user.recommended_ttl_minutes)">
<span
class="text-sm"
:class="getFrequencyClass(user.recommended_ttl_minutes)"
>
{{ getFrequencyLabel(user.recommended_ttl_minutes) }}
</span>
</TableCell>
@@ -559,27 +704,47 @@ onBeforeUnmount(() => {
</TableCell>
</TableRow>
<!-- 展开行显示用户散点图 -->
<TableRow v-if="expandedUserId === user.group_id" class="bg-muted/30">
<TableCell colspan="6" class="p-0">
<TableRow
v-if="expandedUserId === user.group_id"
class="bg-muted/30"
>
<TableCell
colspan="6"
class="p-0"
>
<div class="px-6 py-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium">请求间隔时间线</h4>
<h4 class="text-sm font-medium">
请求间隔时间线
</h4>
<div class="flex items-center gap-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-green-500"></span> 0-5分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-blue-500"></span> 5-15分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-purple-500"></span> 15-30分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-orange-500"></span> 30-60分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-red-500"></span> >60分钟</span>
<span v-if="userTimelineData" class="ml-2"> {{ userTimelineData.total_points }} 个数据点</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-green-500" /> 0-5分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-blue-500" /> 5-15分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-purple-500" /> 15-30分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-orange-500" /> 30-60分钟</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-red-500" /> >60分钟</span>
<span
v-if="userTimelineData"
class="ml-2"
> {{ userTimelineData.total_points }} 个数据点</span>
</div>
</div>
<div v-if="userTimelineLoading" class="h-64 flex items-center justify-center">
<div
v-if="userTimelineLoading"
class="h-64 flex items-center justify-center"
>
<span class="text-sm text-muted-foreground">加载中...</span>
</div>
<div v-else-if="userTimelineData && userTimelineData.points.length > 0" class="h-64">
<div
v-else-if="userTimelineData && userTimelineData.points.length > 0"
class="h-64"
>
<ScatterChart :data="userTimelineChartData" />
</div>
<div v-else class="h-64 flex items-center justify-center">
<div
v-else
class="h-64 flex items-center justify-center"
>
<span class="text-sm text-muted-foreground">暂无数据</span>
</div>
</div>
@@ -590,7 +755,10 @@ onBeforeUnmount(() => {
</Table>
<!-- 分析完成但无数据 -->
<div v-else-if="ttlAnalysis && ttlAnalysis.users.length === 0" class="px-6 py-12 text-center">
<div
v-else-if="ttlAnalysis && ttlAnalysis.users.length === 0"
class="px-6 py-12 text-center"
>
<BarChart3 class="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p class="text-sm text-muted-foreground">
未找到符合条件的用户数据
@@ -601,8 +769,13 @@ onBeforeUnmount(() => {
</div>
<!-- 加载中 -->
<div v-else-if="ttlAnalysisLoading" class="px-6 py-12 text-center">
<p class="text-sm text-muted-foreground">正在分析用户请求数据...</p>
<div
v-else-if="ttlAnalysisLoading"
class="px-6 py-12 text-center"
>
<p class="text-sm text-muted-foreground">
正在分析用户请求数据...
</p>
</div>
</Card>
</div>

View File

@@ -6,8 +6,12 @@
<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>
<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" />
@@ -20,8 +24,12 @@
<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>
<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" />
@@ -32,76 +40,119 @@
</div>
<!-- IP 黑名单管理 -->
<Card variant="default" class="overflow-hidden">
<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>
<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="添加黑名单"
@click="showAddBlacklistDialog = true"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loadingBlacklist" @click="loadBlacklistStats" />
<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
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 v-else class="p-6">
<div v-if="!blacklistStats.available" class="text-center py-8 text-muted-foreground">
<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>
<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">
<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">
<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">
<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>
<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="添加白名单"
@click="showAddWhitelistDialog = true"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loadingWhitelist" @click="loadWhitelist" />
<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
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 v-else-if="whitelistData.whitelist.length === 0" class="text-center py-12 text-muted-foreground">
<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>
@@ -111,18 +162,25 @@
<TableHeader>
<TableRow>
<TableHead>IP 地址 / CIDR</TableHead>
<TableHead class="text-right">操作</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>
<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"
@click="handleRemoveFromWhitelist(ip)"
>
<Trash2 class="w-4 h-4 mr-1.5" />
移除
@@ -174,11 +232,16 @@
</div>
</div>
<DialogFooter>
<Button variant="ghost" @click="showAddBlacklistDialog = false">取消</Button>
<Button
variant="ghost"
@click="showAddBlacklistDialog = false"
>
取消
</Button>
<Button
variant="destructive"
@click="handleAddToBlacklist"
:disabled="!blacklistForm.ip_address || !blacklistForm.reason"
@click="handleAddToBlacklist"
>
添加到黑名单
</Button>
@@ -209,10 +272,15 @@
</div>
</div>
<DialogFooter>
<Button variant="ghost" @click="showAddWhitelistDialog = false">取消</Button>
<Button
@click="handleAddToWhitelist"
variant="ghost"
@click="showAddWhitelistDialog = false"
>
取消
</Button>
<Button
:disabled="!whitelistForm.ip_address"
@click="handleAddToWhitelist"
>
添加到白名单
</Button>

View File

@@ -8,7 +8,9 @@
<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>
<h3 class="text-base font-semibold">
模型管理
</h3>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2">
@@ -31,8 +33,8 @@
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.streaming ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="capabilityFilters.streaming = !capabilityFilters.streaming"
title="流式输出"
@click="capabilityFilters.streaming = !capabilityFilters.streaming"
>
<Zap class="w-3.5 h-3.5" />
</button>
@@ -40,8 +42,8 @@
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.imageGeneration ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="capabilityFilters.imageGeneration = !capabilityFilters.imageGeneration"
title="图像生成"
@click="capabilityFilters.imageGeneration = !capabilityFilters.imageGeneration"
>
<Image class="w-3.5 h-3.5" />
</button>
@@ -49,8 +51,8 @@
<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="视觉理解"
@click="capabilityFilters.vision = !capabilityFilters.vision"
>
<Eye class="w-3.5 h-3.5" />
</button>
@@ -58,8 +60,8 @@
<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="工具调用"
@click="capabilityFilters.toolUse = !capabilityFilters.toolUse"
>
<Wrench class="w-3.5 h-3.5" />
</button>
@@ -67,8 +69,8 @@
<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="深度思考"
@click="capabilityFilters.extendedThinking = !capabilityFilters.extendedThinking"
>
<Brain class="w-3.5 h-3.5" />
</button>
@@ -81,12 +83,15 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openCreateModelDialog"
title="创建模型"
@click="openCreateModelDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loading" @click="refreshData" />
<RefreshButton
:loading="loading"
@click="refreshData"
/>
</div>
</div>
</div>
@@ -94,24 +99,46 @@
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[240px]">模型名称</TableHead>
<TableHead class="w-[140px]">能力/偏好</TableHead>
<TableHead class="w-[160px] text-center">价格 ($/M)</TableHead>
<TableHead class="w-[80px] text-center">提供商</TableHead>
<TableHead class="w-[70px] text-center">别名/映射</TableHead>
<TableHead class="w-[80px] text-center">调用次数</TableHead>
<TableHead class="w-[70px]">状态</TableHead>
<TableHead class="w-[140px] text-center">操作</TableHead>
<TableHead class="w-[240px]">
模型名称
</TableHead>
<TableHead class="w-[140px]">
能力/偏好
</TableHead>
<TableHead class="w-[160px] text-center">
价格 ($/M)
</TableHead>
<TableHead class="w-[80px] text-center">
提供商
</TableHead>
<TableHead class="w-[70px] text-center">
别名/映射
</TableHead>
<TableHead class="w-[80px] text-center">
调用次数
</TableHead>
<TableHead class="w-[70px]">
状态
</TableHead>
<TableHead class="w-[140px] text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<TableCell colspan="8" class="text-center py-8">
<TableCell
colspan="8"
class="text-center py-8"
>
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
</TableCell>
</TableRow>
<TableRow v-else-if="filteredGlobalModels.length === 0">
<TableCell colspan="8" class="text-center py-8 text-muted-foreground">
<TableCell
colspan="8"
class="text-center py-8 text-muted-foreground"
>
没有找到匹配的模型
</TableCell>
</TableRow>
@@ -125,7 +152,9 @@
>
<TableCell>
<div>
<div class="font-medium">{{ model.display_name }}</div>
<div class="font-medium">
{{ model.display_name }}
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1">
<span>{{ model.name }}</span>
<button
@@ -141,14 +170,34 @@
<TableCell>
<div class="space-y-1 w-fit">
<div class="flex flex-wrap gap-1">
<Zap v-if="model.default_supports_streaming" class="w-4 h-4 text-muted-foreground" title="流式输出" />
<Image v-if="model.default_supports_image_generation" class="w-4 h-4 text-muted-foreground" title="图像生成" />
<Eye v-if="model.default_supports_vision" class="w-4 h-4 text-muted-foreground" title="视觉理解" />
<Wrench v-if="model.default_supports_function_calling" class="w-4 h-4 text-muted-foreground" title="工具调用" />
<Brain v-if="model.default_supports_extended_thinking" class="w-4 h-4 text-muted-foreground" title="深度思考" />
<Zap
v-if="model.default_supports_streaming"
class="w-4 h-4 text-muted-foreground"
title="流式输出"
/>
<Image
v-if="model.default_supports_image_generation"
class="w-4 h-4 text-muted-foreground"
title="图像生成"
/>
<Eye
v-if="model.default_supports_vision"
class="w-4 h-4 text-muted-foreground"
title="视觉理解"
/>
<Wrench
v-if="model.default_supports_function_calling"
class="w-4 h-4 text-muted-foreground"
title="工具调用"
/>
<Brain
v-if="model.default_supports_extended_thinking"
class="w-4 h-4 text-muted-foreground"
title="深度思考"
/>
</div>
<template v-if="model.supported_capabilities?.length">
<div class="border-t border-border/50"></div>
<div class="border-t border-border/50" />
<div class="flex flex-wrap gap-0.5">
<span
v-for="capName in model.supported_capabilities"
@@ -170,7 +219,11 @@
<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>
<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">
@@ -178,14 +231,23 @@
<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
v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request"
class="text-muted-foreground"
>
-
</div>
</div>
</TableCell>
<TableCell class="text-center">
<Badge variant="secondary">{{ model.provider_count || 0 }}</Badge>
<Badge variant="secondary">
{{ model.provider_count || 0 }}
</Badge>
</TableCell>
<TableCell class="text-center">
<Badge variant="secondary">{{ model.alias_count || 0 }}</Badge>
<Badge variant="secondary">
{{ model.alias_count || 0 }}
</Badge>
</TableCell>
<TableCell class="text-center">
<span class="text-sm font-mono">{{ formatUsageCount(model.usage_count || 0) }}</span>
@@ -201,8 +263,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click.stop="selectModel(model)"
title="查看详情"
@click.stop="selectModel(model)"
>
<Eye class="w-4 h-4" />
</Button>
@@ -210,8 +272,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click.stop="editModel(model)"
title="编辑模型"
@click.stop="editModel(model)"
>
<Edit class="w-4 h-4" />
</Button>
@@ -219,8 +281,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click.stop="toggleModelStatus(model)"
:title="model.is_active ? '停用模型' : '启用模型'"
@click.stop="toggleModelStatus(model)"
>
<Power class="w-4 h-4" />
</Button>
@@ -228,8 +290,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click.stop="deleteModel(model)"
title="删除模型"
@click.stop="deleteModel(model)"
>
<Trash2 class="w-4 h-4" />
</Button>
@@ -299,22 +361,32 @@
<!-- 批量添加关联提供商对话框 -->
<Dialog
:model-value="batchAddProvidersDialogOpen"
@update:model-value="handleBatchAddProvidersDialogUpdate"
title="批量添加关联提供商"
description="为模型批量添加 Provider 实现, 提供商将自动继承模型的价格和能力, 可在添加后单独修改"
:icon="Server"
size="4xl"
@update:model-value="handleBatchAddProvidersDialogUpdate"
>
<template #default>
<div v-if="selectedModel" class="space-y-4">
<div
v-if="selectedModel"
class="space-y-4"
>
<!-- 模型信息头部 -->
<div class="rounded-lg border bg-muted/30 p-4">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-lg">{{ selectedModel.display_name }}</p>
<p class="text-sm text-muted-foreground font-mono">{{ selectedModel.name }}</p>
<p class="font-semibold text-lg">
{{ selectedModel.display_name }}
</p>
<p class="text-sm text-muted-foreground font-mono">
{{ selectedModel.name }}
</p>
</div>
<Badge variant="outline" class="text-xs">
<Badge
variant="outline"
class="text-xs"
>
当前 {{ selectedModelProviders.length }} Provider
</Badge>
</div>
@@ -326,7 +398,9 @@
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">可添加</p>
<p class="text-sm font-medium">
可添加
</p>
<Button
v-if="availableProvidersForBatchAdd.length > 0"
variant="ghost"
@@ -337,19 +411,33 @@
{{ isAllLeftSelected ? '取消全选' : '全选' }}
</Button>
</div>
<Badge variant="secondary" class="text-xs">
<Badge
variant="secondary"
class="text-xs"
>
{{ availableProvidersForBatchAdd.length }}
</Badge>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<div v-if="loadingProviderOptions" class="flex items-center justify-center h-full">
<div
v-if="loadingProviderOptions"
class="flex items-center justify-center h-full"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<div v-else-if="availableProvidersForBatchAdd.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<div
v-else-if="availableProvidersForBatchAdd.length === 0"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Building2 class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">所有 Provider 均已关联</p>
<p class="text-sm">
所有 Provider 均已关联
</p>
</div>
<div v-else class="p-2 space-y-1">
<div
v-else
class="p-2 space-y-1"
>
<div
v-for="provider in availableProvidersForBatchAdd"
:key="provider.id"
@@ -365,8 +453,12 @@
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{{ provider.display_name || provider.name }}</p>
<p class="text-xs text-muted-foreground truncate">{{ provider.name }}</p>
<p class="font-medium text-sm truncate">
{{ provider.display_name || provider.name }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ provider.name }}
</p>
</div>
<Badge
:variant="provider.is_active ? 'outline' : 'secondary'"
@@ -388,11 +480,18 @@
class="w-9 h-8"
:class="selectedLeftProviderIds.length > 0 && !submittingBatchAddProviders ? 'border-primary' : ''"
:disabled="selectedLeftProviderIds.length === 0 || submittingBatchAddProviders"
@click="batchAddSelectedProviders"
title="添加选中"
@click="batchAddSelectedProviders"
>
<Loader2 v-if="submittingBatchAddProviders" class="w-4 h-4 animate-spin" />
<ChevronRight v-else class="w-6 h-6 stroke-[3]" :class="selectedLeftProviderIds.length > 0 && !submittingBatchAddProviders ? 'text-primary' : ''" />
<Loader2
v-if="submittingBatchAddProviders"
class="w-4 h-4 animate-spin"
/>
<ChevronRight
v-else
class="w-6 h-6 stroke-[3]"
:class="selectedLeftProviderIds.length > 0 && !submittingBatchAddProviders ? 'text-primary' : ''"
/>
</Button>
<Button
variant="outline"
@@ -400,11 +499,18 @@
class="w-9 h-8"
:class="selectedRightProviderIds.length > 0 && !submittingBatchRemoveProviders ? 'border-primary' : ''"
:disabled="selectedRightProviderIds.length === 0 || submittingBatchRemoveProviders"
@click="batchRemoveSelectedProviders"
title="移除选中"
@click="batchRemoveSelectedProviders"
>
<Loader2 v-if="submittingBatchRemoveProviders" class="w-4 h-4 animate-spin" />
<ChevronLeft v-else class="w-6 h-6 stroke-[3]" :class="selectedRightProviderIds.length > 0 && !submittingBatchRemoveProviders ? 'text-primary' : ''" />
<Loader2
v-if="submittingBatchRemoveProviders"
class="w-4 h-4 animate-spin"
/>
<ChevronLeft
v-else
class="w-6 h-6 stroke-[3]"
:class="selectedRightProviderIds.length > 0 && !submittingBatchRemoveProviders ? 'text-primary' : ''"
/>
</Button>
</div>
@@ -412,7 +518,9 @@
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">已添加</p>
<p class="text-sm font-medium">
已添加
</p>
<Button
v-if="selectedModelProviders.length > 0"
variant="ghost"
@@ -423,16 +531,27 @@
{{ isAllRightSelected ? '取消全选' : '全选' }}
</Button>
</div>
<Badge variant="secondary" class="text-xs">
<Badge
variant="secondary"
class="text-xs"
>
{{ selectedModelProviders.length }}
</Badge>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<div v-if="selectedModelProviders.length === 0" class="flex flex-col items-center justify-center h-full text-muted-foreground">
<div
v-if="selectedModelProviders.length === 0"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Building2 class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">暂无关联提供商</p>
<p class="text-sm">
暂无关联提供商
</p>
</div>
<div v-else class="p-2 space-y-1">
<div
v-else
class="p-2 space-y-1"
>
<!-- 已存在的可选中删除 -->
<div
v-for="provider in selectedModelProviders"
@@ -449,8 +568,12 @@
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{{ provider.display_name }}</p>
<p class="text-xs text-muted-foreground truncate">{{ provider.identifier }}</p>
<p class="font-medium text-sm truncate">
{{ provider.display_name }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ provider.identifier }}
</p>
</div>
<Badge
:variant="provider.is_active ? 'outline' : 'secondary'"
@@ -485,7 +608,6 @@
@update:open="handleEditProviderDialogUpdate"
@saved="handleEditProviderSaved"
/>
</div>
</template>
@@ -623,9 +745,9 @@ const { confirmDanger } = useConfirm()
// 格式化调用次数(大数字简化显示)
function formatUsageCount(count: number): string {
if (count >= 1000000) {
return (count / 1000000).toFixed(1) + 'M'
return `${(count / 1000000).toFixed(1) }M`
} else if (count >= 1000) {
return (count / 1000).toFixed(1) + 'K'
return `${(count / 1000).toFixed(1) }K`
}
return count.toString()
}

View File

@@ -1,12 +1,17 @@
<template>
<div class="space-y-4">
<!-- 提供商表格 -->
<Card variant="default" class="overflow-hidden">
<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>
<h3 class="text-base font-semibold text-foreground">
提供商管理
</h3>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2">
@@ -27,8 +32,8 @@
<!-- 调度策略 -->
<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="点击调整调度策略"
@click="openPriorityDialog"
>
<span class="text-muted-foreground/80">调度:</span>
<span class="font-medium text-foreground/90">{{ priorityModeConfig.label }}</span>
@@ -42,23 +47,32 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openAddProviderDialog"
title="新增提供商"
@click="openAddProviderDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loading" @click="loadProviders" />
<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
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 v-else-if="filteredProviders.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
<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 }}" 的提供商
@@ -67,24 +81,48 @@
暂无提供商点击右上角添加
</template>
</div>
<Button v-if="searchQuery" variant="outline" size="sm" @click="searchQuery = ''">
<Button
v-if="searchQuery"
variant="outline"
size="sm"
@click="searchQuery = ''"
>
清除搜索
</Button>
</div>
<!-- 桌面端表格 -->
<div v-else class="overflow-x-auto">
<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>
<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>
@@ -102,7 +140,10 @@
</div>
</TableCell>
<TableCell class="py-3.5">
<Badge variant="outline" class="text-xs font-normal border-border/50">
<Badge
variant="outline"
class="text-xs font-normal border-border/50"
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
</TableCell>
@@ -113,12 +154,15 @@
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"
@click.stop
>
{{ formatWebsiteDisplay(provider.website) }}
</a>
<span v-else class="text-xs text-muted-foreground/50">-</span>
<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">
@@ -154,39 +198,60 @@
<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>
<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
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">
<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
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">
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="text-xs"
>
{{ provider.is_active ? '活跃' : '已停用' }}
</Badge>
</TableCell>
<TableCell class="py-3.5" @click.stop>
<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="查看详情"
@click="openProviderDrawer(provider.id)"
>
<Eye class="h-3.5 w-3.5" />
</Button>
@@ -194,8 +259,8 @@
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
@click="openEditProviderDialog(provider)"
title="编辑提供商"
@click="openEditProviderDialog(provider)"
>
<Edit class="h-3.5 w-3.5" />
</Button>
@@ -203,8 +268,8 @@
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
@click="toggleProviderStatus(provider)"
:title="provider.is_active ? '停用提供商' : '启用提供商'"
@click="toggleProviderStatus(provider)"
>
<Power class="h-3.5 w-3.5" />
</Button>
@@ -212,8 +277,8 @@
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
@click="handleDeleteProvider(provider)"
title="删除提供商"
@click="handleDeleteProvider(provider)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>

View File

@@ -1,8 +1,14 @@
<template>
<PageContainer>
<PageHeader title="系统设置" description="管理系统级别的配置和参数">
<PageHeader
title="系统设置"
description="管理系统级别的配置和参数"
>
<template #actions>
<Button @click="saveSystemConfig" :disabled="loading">
<Button
:disabled="loading"
@click="saveSystemConfig"
>
{{ loading ? '保存中...' : '保存所有配置' }}
</Button>
</template>
@@ -10,10 +16,16 @@
<div class="mt-6 space-y-6">
<!-- 基础配置 -->
<CardSection title="基础配置" description="配置系统默认参数">
<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
for="default-quota"
class="block text-sm font-medium"
>
默认用户配额(美元)
</Label>
<Input
@@ -30,7 +42,10 @@
</div>
<div>
<Label for="rate-limit" class="block text-sm font-medium">
<Label
for="rate-limit"
class="block text-sm font-medium"
>
每分钟请求限制
</Label>
<Input
@@ -44,19 +59,24 @@
0 表示不限制
</p>
</div>
</div>
</CardSection>
<!-- 用户注册配置 -->
<CardSection title="用户注册" description="控制用户注册和验证">
<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
for="enable-registration"
class="cursor-pointer"
>
开放用户注册
</Label>
</div>
@@ -66,7 +86,10 @@
id="require-email-verification"
v-model:checked="systemConfig.require_email_verification"
/>
<Label for="require-email-verification" class="cursor-pointer">
<Label
for="require-email-verification"
class="cursor-pointer"
>
需要邮箱验证
</Label>
</div>
@@ -74,10 +97,16 @@
</CardSection>
<!-- API Key 管理配置 -->
<CardSection title="API Key 管理" description="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">
<Label
for="api-key-expire"
class="block text-sm font-medium"
>
API密钥过期天数
</Label>
<Input
@@ -99,7 +128,10 @@
v-model:checked="systemConfig.auto_delete_expired_keys"
/>
<div>
<Label for="auto-delete-expired-keys" class="cursor-pointer">
<Label
for="auto-delete-expired-keys"
class="cursor-pointer"
>
自动删除过期 Key
</Label>
<p class="text-xs text-muted-foreground">
@@ -112,20 +144,38 @@
</CardSection>
<!-- 日志记录配置 -->
<CardSection title="日志记录" description="控制请求日志的记录方式和内容">
<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
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">
<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>
<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">
@@ -134,7 +184,10 @@
</div>
<div>
<Label for="max-request-body-size" class="block text-sm font-medium">
<Label
for="max-request-body-size"
class="block text-sm font-medium"
>
最大请求体大小 (KB)
</Label>
<Input
@@ -150,7 +203,10 @@
</div>
<div>
<Label for="max-response-body-size" class="block text-sm font-medium">
<Label
for="max-response-body-size"
class="block text-sm font-medium"
>
最大响应体大小 (KB)
</Label>
<Input
@@ -166,7 +222,10 @@
</div>
<div>
<Label for="sensitive-headers" class="block text-sm font-medium">
<Label
for="sensitive-headers"
class="block text-sm font-medium"
>
敏感请求头
</Label>
<Input
@@ -183,7 +242,10 @@
</CardSection>
<!-- 日志清理策略 -->
<CardSection title="日志清理策略" description="配置日志的分级保留和自动清理">
<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">
@@ -191,7 +253,10 @@
id="enable-auto-cleanup"
v-model:checked="systemConfig.enable_auto_cleanup"
/>
<Label for="enable-auto-cleanup" class="cursor-pointer">
<Label
for="enable-auto-cleanup"
class="cursor-pointer"
>
启用自动清理任务
</Label>
<span class="text-xs text-muted-foreground ml-2">
@@ -201,7 +266,10 @@
</div>
<div>
<Label for="detail-log-retention-days" class="block text-sm font-medium">
<Label
for="detail-log-retention-days"
class="block text-sm font-medium"
>
详细日志保留天数
</Label>
<Input
@@ -217,7 +285,10 @@
</div>
<div>
<Label for="compressed-log-retention-days" class="block text-sm font-medium">
<Label
for="compressed-log-retention-days"
class="block text-sm font-medium"
>
压缩日志保留天数
</Label>
<Input
@@ -233,7 +304,10 @@
</div>
<div>
<Label for="header-retention-days" class="block text-sm font-medium">
<Label
for="header-retention-days"
class="block text-sm font-medium"
>
请求头保留天数
</Label>
<Input
@@ -249,7 +323,10 @@
</div>
<div>
<Label for="log-retention-days" class="block text-sm font-medium">
<Label
for="log-retention-days"
class="block text-sm font-medium"
>
完整日志保留天数
</Label>
<Input
@@ -265,7 +342,10 @@
</div>
<div>
<Label for="cleanup-batch-size" class="block text-sm font-medium">
<Label
for="cleanup-batch-size"
class="block text-sm font-medium"
>
每批次清理记录数
</Label>
<Input
@@ -283,7 +363,9 @@
<!-- 清理策略说明 -->
<div class="mt-4 p-4 bg-muted/50 rounded-lg">
<h4 class="text-sm font-medium mb-2">清理策略说明</h4>
<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>

View File

@@ -1,11 +1,16 @@
<template>
<div class="space-y-6 pb-8">
<!-- 用户表格 -->
<Card variant="default" class="overflow-hidden">
<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>
<h3 class="text-base font-semibold">
用户管理
</h3>
<!-- 筛选器和操作按钮 -->
<div class="flex items-center gap-2">
@@ -25,26 +30,44 @@
<div class="h-4 w-px bg-border" />
<!-- 角色筛选 -->
<Select v-model="filterRole" v-model:open="filterRoleOpen">
<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>
<SelectItem value="all">
全部角色
</SelectItem>
<SelectItem value="admin">
管理员
</SelectItem>
<SelectItem value="user">
普通用户
</SelectItem>
</SelectContent>
</Select>
<!-- 状态筛选 -->
<Select v-model="filterStatus" v-model:open="filterStatusOpen">
<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>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="active">
活跃
</SelectItem>
<SelectItem value="inactive">
禁用
</SelectItem>
</SelectContent>
</Select>
@@ -56,14 +79,17 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openCreateDialog"
title="新增用户"
@click="openCreateDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton :loading="usersStore.loading || loadingStats" @click="refreshUsers" />
<RefreshButton
:loading="usersStore.loading || loadingStats"
@click="refreshUsers"
/>
</div>
</div>
</div>
@@ -73,17 +99,35 @@
<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>
<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">
<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">
@@ -92,20 +136,34 @@
</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">
<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 || '-'">
<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
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>
@@ -119,52 +177,96 @@
<span class="font-medium text-foreground">${{ formatCurrency(userStats[user.id]?.total_cost) }}</span>
</div>
</div>
<div v-else class="text-xs text-muted-foreground">
<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">
<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">
<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 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">
<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="编辑用户">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑用户"
@click="editUser(user)"
>
<SquarePen class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="manageApiKeys(user)" title="查看API Keys">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="查看API Keys"
@click="manageApiKeys(user)"
>
<Key class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="toggleUserStatus(user)"
:title="user.is_active ? '禁用用户' : '启用用户'"
@click="toggleUserStatus(user)"
>
<PauseCircle v-if="user.is_active" class="h-4 w-4" />
<PlayCircle v-else class="h-4 w-4" />
<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="重置配额">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="重置配额"
@click="resetQuota(user)"
>
<RotateCcw class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="deleteUser(user)" title="删除用户">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除用户"
@click="deleteUser(user)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
@@ -176,7 +278,11 @@
<!-- 移动端卡片列表 -->
<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
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">
@@ -186,13 +292,21 @@
</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">
<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">
<Badge
:variant="user.is_active ? 'success' : 'destructive'"
class="font-medium text-xs flex-shrink-0"
>
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
</div>
@@ -204,18 +318,33 @@
<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
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 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 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 class="text-muted-foreground mb-1">
消费金额
</div>
<div class="font-semibold text-sm text-foreground">
${{ formatCurrency(userStats[user.id]?.total_cost) }}
</div>
</div>
</div>
@@ -241,11 +370,21 @@
<!-- 操作按钮 - 响应式布局 -->
<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)">
<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)">
<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>
@@ -256,15 +395,31 @@
: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" />
<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)">
<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)">
<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>
@@ -284,15 +439,18 @@
<!-- 用户表单对话框创建/编辑共用 -->
<UserFormDialog
ref="userFormDialogRef"
:open="showUserFormDialog"
:user="editingUser"
@close="closeUserFormDialog"
@submit="handleUserFormSubmit"
ref="userFormDialogRef"
/>
<!-- API Keys 管理对话框 -->
<Dialog v-model="showApiKeysDialog" size="xl">
<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">
@@ -300,8 +458,12 @@
<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>
<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>
@@ -341,9 +503,9 @@
{{ apiKey.key_display || 'sk-****' }}
</code>
<button
@click="copyFullKey(apiKey)"
class="p-0.5 hover:bg-muted rounded transition-colors"
title="复制完整密钥"
@click="copyFullKey(apiKey)"
>
<Copy class="w-3 h-3 text-muted-foreground" />
</button>
@@ -364,8 +526,8 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="deleteApiKey(apiKey)"
title="删除"
@click="deleteApiKey(apiKey)"
>
<Trash2 class="h-4 w-4" />
</Button>
@@ -382,23 +544,40 @@
<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>
<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">
<Button
variant="outline"
class="h-10 px-5"
@click="showApiKeysDialog = false"
>
取消
</Button>
<Button
class="h-10 px-5"
:disabled="creatingApiKey"
@click="createApiKey"
>
{{ creatingApiKey ? '创建中...' : '创建' }}
</Button>
</template>
</Dialog>
<!-- API Key 显示对话框 -->
<Dialog v-model="showNewApiKeyDialog" size="lg">
<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">
@@ -406,8 +585,12 @@
<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>
<h3 class="text-lg font-semibold text-foreground leading-tight">
创建成功
</h3>
<p class="text-xs text-muted-foreground">
请妥善保管, 切勿泄露给他人.
</p>
</div>
</div>
</div>
@@ -418,14 +601,17 @@
<Label class="text-sm font-medium">API Key</Label>
<div class="flex items-center gap-2">
<Input
ref="apiKeyInput"
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
class="h-11"
@click="copyApiKey"
>
复制
</Button>
</div>
@@ -433,7 +619,12 @@
</div>
<template #footer>
<Button @click="closeNewApiKeyDialog" class="h-10 px-5">确定</Button>
<Button
class="h-10 px-5"
@click="closeNewApiKeyDialog"
>
确定
</Button>
</template>
</Dialog>
</div>

View File

@@ -12,7 +12,10 @@
:class="badgeClass"
:style="badgeStyle"
>
<component :is="badgeIcon" class="h-3 w-3" />
<component
:is="badgeIcon"
class="h-3 w-3"
/>
{{ badgeText }}
</div>
@@ -37,20 +40,27 @@
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']">
<div
class="flex flex-wrap items-center gap-3 px-4 py-3"
:class="[panelClasses.commandPanel]"
>
<PlatformSelect
:model-value="platformValue"
@update:model-value="$emit('update:platformValue', $event)"
:options="platformOptions"
class="shrink-0"
@update:model-value="$emit('update:platformValue', $event)"
/>
<div class="flex-1 min-w-[180px]">
<CodeHighlight :code="installCommand" language="bash" dense />
<CodeHighlight
:code="installCommand"
language="bash"
dense
/>
</div>
<button
@click="$emit('copy', installCommand)"
:class="panelClasses.iconButtonSmall"
title="复制配置"
@click="$emit('copy', installCommand)"
>
<Copy class="h-3.5 w-3.5" />
</button>
@@ -65,16 +75,19 @@
:class="idx < configFiles.length - 1 ? 'mb-3' : ''"
:style="cardStyleFn(idx + 1)"
>
<div :class="[panelClasses.configPanel, 'overflow-hidden']">
<div
class="overflow-hidden"
:class="[panelClasses.configPanel]"
>
<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="复制配置"
@click="$emit('copy', config.content)"
>
<Copy class="h-3.5 w-3.5" />
</button>
@@ -82,7 +95,10 @@
</div>
<div :class="panelClasses.codeBody">
<div class="config-code-wrapper">
<CodeHighlight :code="config.content" :language="config.language" />
<CodeHighlight
:code="config.content"
:language="config.language"
/>
</div>
</div>
</div>
@@ -90,7 +106,10 @@
</div>
<!-- Logo placeholder column -->
<div :class="logoOrder" class="flex items-center justify-center h-full min-h-[300px] relative">
<div
:class="logoOrder"
class="flex items-center justify-center h-full min-h-[300px] relative"
>
<slot name="logo" />
</div>
</div>
@@ -104,6 +123,13 @@ import PlatformSelect from '@/components/PlatformSelect.vue'
import CodeHighlight from '@/components/CodeHighlight.vue'
import { panelClasses, type PlatformOption } from './home-config'
const props = withDefaults(defineProps<Props>(), {
contentPosition: 'left'
})
defineEmits<{
copy: [text: string]
'update:platformValue': [value: string]
}>()
// Expose section element for parent scroll tracking
const sectionRef = ref<HTMLElement | null>(null)
defineExpose({ sectionEl: sectionRef })
@@ -133,15 +159,6 @@ interface Props {
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' : ''
)

View File

@@ -8,8 +8,8 @@
<button
v-for="(section, index) in sections"
:key="index"
@click="scrollToSection(index)"
class="scroll-indicator-btn group"
@click="scrollToSection(index)"
>
<span class="scroll-indicator-label">{{ section.name }}</span>
<div
@@ -28,9 +28,14 @@
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" />
<HeaderLogo
size="h-9 w-9"
class-name="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>
<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>
@@ -40,11 +45,11 @@
<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'"
@click="scrollToSection(index)"
>
{{ section.name }}
<div
@@ -57,13 +62,22 @@
<!-- 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' ? '深色模式' : '浅色模式'"
@click="toggleDarkMode"
>
<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" />
<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
@@ -75,8 +89,8 @@
</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]"
@click="showLoginDialog = true"
>
登录
</button>
@@ -123,7 +137,8 @@
:disable-ripple="currentSection === SECTIONS.GEMINI || currentSection === SECTIONS.FEATURES"
:anim-delay="logoTransitionDelay"
:static="currentSection === SECTIONS.FEATURES"
:class="[currentLogoClass, 'logo-active']"
class="logo-active"
:class="[currentLogoClass]"
/>
</div>
</Transition>
@@ -131,7 +146,10 @@
</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">
<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
@@ -144,13 +162,13 @@
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 />
AI 开发工具统一接入平台<br>
整合 Claude CodeCodex CLIGemini CLI 等多个 AI 编程助手
</p>
<button
@click="scrollToSection(SECTIONS.CLAUDE)"
class="mt-16 transition-all duration-700 cursor-pointer hover:scale-110"
:style="getScrollIndicatorStyle(SECTIONS.HOME)"
@click="scrollToSection(SECTIONS.CLAUDE)"
>
<ChevronDown class="h-8 w-8 mx-auto text-[#91918d] dark:text-muted-foreground/80 animate-bounce" />
</button>
@@ -160,12 +178,12 @@
<!-- Section 1: Claude Code -->
<CliSection
ref="section1"
v-model:platform-value="claudePlatform"
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' }]"
@@ -180,12 +198,12 @@
<!-- Section 2: Codex CLI -->
<CliSection
ref="section2"
v-model:platform-value="codexPlatform"
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="[
@@ -203,12 +221,12 @@
<!-- Section 3: Gemini CLI -->
<CliSection
ref="section3"
v-model:platform-value="geminiPlatform"
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="[
@@ -228,7 +246,10 @@
</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">
<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"
@@ -273,8 +294,12 @@
: '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>
<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'
@@ -286,7 +311,10 @@
</div>
</div>
<div class="mt-12 transition-all duration-700" :style="getButtonsStyle(SECTIONS.FEATURES)">
<div
class="mt-12 transition-all duration-700"
:style="getButtonsStyle(SECTIONS.FEATURES)"
>
<RouterLink
v-if="authStore.isAuthenticated"
:to="dashboardPath"
@@ -297,8 +325,8 @@
</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"
@click="showLoginDialog = true"
>
<Rocket class="h-5 w-5" />
立即开始使用
@@ -312,11 +340,22 @@
<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>
<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>
<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>

View File

@@ -1,8 +1,12 @@
<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>
<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">
@@ -14,8 +18,10 @@
@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 }">
<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>
@@ -27,10 +33,25 @@
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
: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>
@@ -62,20 +83,22 @@
<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>
<p class="text-center text-sm text-[#666663] dark:text-gray-400 mt-3">
{{ scheme.description }}
</p>
</div>
</div>
@@ -95,11 +118,21 @@
{{ colorSchemes[selectedScheme].name }}
</h2>
<button
@click="showPreview = false"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
@click="showPreview = false"
>
<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
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>
@@ -126,7 +159,7 @@
<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>
@@ -135,7 +168,7 @@
<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>
@@ -145,8 +178,8 @@
<!-- 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"
@click="applyScheme"
>
应用此方案
</button>
@@ -161,8 +194,18 @@
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
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>

View File

@@ -3,15 +3,25 @@
<!-- 页面头部统计卡片 + 公告 -->
<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">
<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">
<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" />
@@ -30,40 +40,75 @@
/>
<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">
<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">
<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
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
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>
<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>
<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>
@@ -71,8 +116,15 @@
<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>
<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>
@@ -80,18 +132,33 @@
<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>
<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">
<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]">
<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>
@@ -102,17 +169,31 @@
</div>
<!-- 普通用户缓存统计 -->
<div v-else-if="!isAdmin && cacheStats && cacheStats.total_cache_tokens > 0" class="mt-6">
<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>
<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>
<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>
@@ -120,8 +201,12 @@
<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>
<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>
@@ -129,18 +214,31 @@
<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>
<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">
<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>
<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>
@@ -156,27 +254,48 @@
: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>
<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">
<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">
<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>
<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-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"
@@ -202,7 +321,7 @@
<span
v-if="!announcement.is_read && !announcement.is_pinned"
class="h-1.5 w-1.5 rounded-full bg-white"
></span>
/>
</span>
</div>
@@ -244,13 +363,28 @@
<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]">
<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
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>
@@ -258,17 +392,28 @@
<!-- 每日模型成本堆叠柱状图 -->
<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]">
<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;">
<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
v-else
class="flex h-full items-center justify-center text-xs text-muted-foreground"
>
暂无数据
</div>
</div>
@@ -280,18 +425,35 @@
<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>
<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">
<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>
@@ -299,87 +461,150 @@
</TableCell>
</TableRow>
<TableRow v-else-if="dailyStats.length === 0">
<TableCell colspan="7" class="text-center py-8 text-muted-foreground text-xs">
<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>
<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="success" class="text-[10px]">${{ stat.cost.toFixed(4) }}</Badge>
<Badge
variant="secondary"
class="text-[10px]"
>
{{ formatTokens(stat.tokens) }}
</Badge>
</TableCell>
<TableCell class="text-center">
<Badge variant="outline" class="text-[10px]">{{ formatResponseTime(stat.avg_response_time) }}</Badge>
<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>
<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
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 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 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 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 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">
<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)"
v-if="selectedAnnouncement"
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>
<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
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>
v-html="renderMarkdown(selectedAnnouncement.content)"
/>
</div>
<template #footer>
<Button variant="outline" @click="detailDialogOpen = false" class="h-10 px-5">关闭</Button>
<Button
variant="outline"
class="h-10 px-5"
@click="detailDialogOpen = false"
>
关闭
</Button>
</template>
</Dialog>
</template>

View File

@@ -15,7 +15,10 @@
<!-- 分析统计 -->
<!-- 管理员模型 + 提供商 + API格式3 -->
<div v-if="isAdminPage" class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div
v-if="isAdminPage"
class="grid grid-cols-1 lg:grid-cols-3 gap-4"
>
<UsageModelTable
:data="enhancedModelStats"
:is-admin="authStore.isAdmin"
@@ -30,7 +33,10 @@
/>
</div>
<!-- 用户模型 + API格式2 -->
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div
v-else
class="grid grid-cols-1 lg:grid-cols-2 gap-4"
>
<UsageModelTable
:data="enhancedModelStats"
:is-admin="authStore.isAdmin"
@@ -74,8 +80,8 @@
<!-- 请求详情抽屉 - 仅管理员可见 -->
<RequestDetailDrawer
v-if="isAdminPage"
:isOpen="detailModalOpen"
:requestId="selectedRequestId"
:is-open="detailModalOpen"
:request-id="selectedRequestId"
@close="detailModalOpen = false"
/>
</div>

View File

@@ -1,18 +1,27 @@
<template>
<div class="space-y-6 pb-8">
<!-- 公告列表卡片 -->
<Card variant="default" class="overflow-hidden">
<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>
<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">
<Badge
v-if="unreadCount > 0"
variant="default"
class="px-3 py-1"
>
{{ unreadCount }} 条未读
</Badge>
<div class="h-4 w-px bg-border" />
@@ -21,39 +30,80 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openCreateDialog"
title="新建公告"
@click="openCreateDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loading" @click="loadAnnouncements(currentPage)" />
<RefreshButton
:loading="loading"
@click="loadAnnouncements(currentPage)"
/>
</div>
</div>
</div>
<!-- 内容区域 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<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">
<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>
<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">
<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>
<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>
@@ -65,8 +115,15 @@
>
<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)]">
<component
:is="getAnnouncementIcon(announcement.type)"
class="w-5 h-5"
:class="getIconColor(announcement.type)"
/>
<span
class="text-xs font-medium"
:class="[getTypeTextColor(announcement.type)]"
>
{{ getTypeLabel(announcement.type) }}
</span>
</div>
@@ -75,7 +132,10 @@
<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" />
<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) }}
@@ -89,37 +149,67 @@
{{ 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
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
v-else
variant="default"
class="text-xs px-2.5 py-0.5"
>
未读
</Badge>
</TableCell>
<TableCell v-if="isAdmin" class="py-4" @click.stop>
<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"
@update:model-value="toggleAnnouncementPin(announcement, $event)"
/>
</div>
</TableCell>
<TableCell v-if="isAdmin" class="py-4" @click.stop>
<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"
@update:model-value="toggleAnnouncementActive(announcement, $event)"
/>
</div>
</TableCell>
<TableCell v-if="isAdmin" class="py-4" @click.stop>
<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">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openEditDialog(announcement)"
>
<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">
<Button
variant="ghost"
size="icon"
class="h-9 w-9 hover:bg-rose-500/10 hover:text-rose-600"
@click="confirmDelete(announcement)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
@@ -141,7 +231,10 @@
</Card>
<!-- 创建/编辑公告对话框 -->
<Dialog v-model="dialogOpen" size="xl">
<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">
@@ -149,43 +242,96 @@
<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>
<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">
<form
class="space-y-4"
@submit.prevent="saveAnnouncement"
>
<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 />
<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 />
<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">
<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>
<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" />
<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>
@@ -196,27 +342,50 @@
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>
>
<Label
for="pinned"
class="cursor-pointer text-sm"
>置顶公告</Label>
</div>
<div v-if="editingAnnouncement" class="flex items-center gap-2">
<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>
>
<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" />
<Button
:disabled="saving"
class="h-10 px-5"
@click="saveAnnouncement"
>
<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>
<Button
variant="outline"
type="button"
class="h-10 px-5"
@click="dialogOpen = false"
>
取消
</Button>
</template>
</Dialog>
@@ -233,27 +402,40 @@
/>
<!-- 公告详情对话框 -->
<Dialog v-model="detailDialogOpen" size="lg">
<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)">
<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)"
v-if="viewingAnnouncement"
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>
<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
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>
@@ -261,13 +443,20 @@
</div>
<div
v-html="renderMarkdown(viewingAnnouncement.content)"
class="prose prose-sm dark:prose-invert max-w-none"
></div>
v-html="renderMarkdown(viewingAnnouncement.content)"
/>
</div>
<template #footer>
<Button variant="outline" @click="detailDialogOpen = false" type="button" class="h-10 px-5">关闭</Button>
<Button
variant="outline"
type="button"
class="h-10 px-5"
@click="detailDialogOpen = false"
>
关闭
</Button>
</template>
</Dialog>
</div>

View File

@@ -6,7 +6,9 @@
<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>
<h3 class="text-base font-semibold">
可用模型
</h3>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2">
@@ -29,8 +31,8 @@
<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"
@click="capabilityFilters.vision = !capabilityFilters.vision"
>
<Eye class="w-3.5 h-3.5" />
</button>
@@ -38,8 +40,8 @@
<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"
@click="capabilityFilters.toolUse = !capabilityFilters.toolUse"
>
<Wrench class="w-3.5 h-3.5" />
</button>
@@ -47,8 +49,8 @@
<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"
@click="capabilityFilters.extendedThinking = !capabilityFilters.extendedThinking"
>
<Brain class="w-3.5 h-3.5" />
</button>
@@ -57,7 +59,10 @@
<div class="h-4 w-px bg-border" />
<!-- 刷新按钮 -->
<RefreshButton :loading="loading" @click="refreshData" />
<RefreshButton
:loading="loading"
@click="refreshData"
/>
</div>
</div>
</div>
@@ -66,21 +71,37 @@
<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>
<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">
<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
colspan="5"
class="text-center py-12 text-muted-foreground"
>
没有找到匹配的模型
</TableCell>
</TableRow>
@@ -115,8 +136,8 @@
<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"
: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'
@@ -124,19 +145,40 @@
: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" />
<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>
<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" />
<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">
@@ -148,7 +190,11 @@
<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>
<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">
@@ -156,7 +202,12 @@
<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
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">
@@ -177,7 +228,7 @@
:total="filteredModels.length"
:page-size="pageSize"
@update:current="currentPage = $event"
@update:pageSize="pageSize = $event"
@update:page-size="pageSize = $event"
/>
</Card>

View File

@@ -1,11 +1,16 @@
<template>
<div class="space-y-6 pb-8">
<!-- API Keys 表格 -->
<Card variant="default" class="overflow-hidden">
<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>
<h3 class="text-base font-semibold">
我的 API Keys
</h3>
<!-- 操作按钮 -->
<div class="flex items-center gap-2">
@@ -14,32 +19,45 @@
variant="ghost"
size="icon"
class="h-8 w-8"
@click="showCreateDialog = true"
title="创建新 API Key"
@click="showCreateDialog = true"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton :loading="loading" @click="loadApiKeys" />
<RefreshButton
:loading="loading"
@click="loadApiKeys"
/>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<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">
<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">
<Button
size="lg"
class="shadow-lg shadow-primary/20"
@click="showCreateDialog = true"
>
<Plus class="mr-2 h-4 w-4" />
创建新 API Key
</Button>
@@ -48,18 +66,37 @@
</div>
<!-- 桌面端表格 -->
<div v-else class="hidden md:block overflow-x-auto">
<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>
<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>
@@ -71,7 +108,10 @@
<!-- 密钥名称 -->
<TableCell class="py-4">
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold truncate" :title="apiKey.name">
<div
class="text-sm font-semibold truncate"
:title="apiKey.name"
>
{{ apiKey.name }}
</div>
<div class="text-xs text-muted-foreground mt-0.5">
@@ -87,8 +127,8 @@
<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"
: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'
@@ -96,12 +136,21 @@
: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" />
<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>
<span
v-else
class="text-muted-foreground text-xs"
>-</span>
</div>
</TableCell>
@@ -112,11 +161,11 @@
{{ apiKey.key_display || 'sk-••••••••' }}
</code>
<Button
@click="copyApiKey(apiKey)"
variant="ghost"
size="icon"
class="h-6 w-6"
title="复制完整密钥"
@click="copyApiKey(apiKey)"
>
<Copy class="h-3.5 w-3.5" />
</Button>
@@ -159,20 +208,20 @@
<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 ? '禁用' : '启用'"
@click="toggleApiKey(apiKey)"
>
<Power class="h-4 w-4" />
</Button>
<Button
@click="confirmDelete(apiKey)"
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除"
@click="confirmDelete(apiKey)"
>
<Trash2 class="h-4 w-4" />
</Button>
@@ -184,7 +233,10 @@
</div>
<!-- 移动端卡片列表 -->
<div v-if="!loading && apiKeys.length > 0" class="md:hidden space-y-3 p-4">
<div
v-if="!loading && apiKeys.length > 0"
class="md:hidden space-y-3 p-4"
>
<Card
v-for="apiKey in paginatedApiKeys"
:key="apiKey.id"
@@ -195,7 +247,9 @@
<!-- 第一行名称状态操作 -->
<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>
<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"
@@ -205,29 +259,29 @@
</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="复制"
@click="copyApiKey(apiKey)"
>
<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 ? '禁用' : '启用'"
@click="toggleApiKey(apiKey)"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
@click="confirmDelete(apiKey)"
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除"
@click="confirmDelete(apiKey)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
@@ -264,7 +318,7 @@
:total="apiKeys.length"
:page-size="pageSize"
@update:current="currentPage = $event"
@update:pageSize="pageSize = $event"
@update:page-size="pageSize = $event"
/>
</Card>
@@ -277,8 +331,12 @@
<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>
<h3 class="text-lg font-semibold text-foreground leading-tight">
创建 API 密钥
</h3>
<p class="text-xs text-muted-foreground">
创建一个新的密钥用于访问 API 服务
</p>
</div>
</div>
</div>
@@ -286,7 +344,10 @@
<div class="space-y-4">
<div class="space-y-2">
<Label for="key-name" class="text-sm font-semibold">密钥名称</Label>
<Label
for="key-name"
class="text-sm font-semibold"
>密钥名称</Label>
<Input
id="key-name"
v-model="newKeyName"
@@ -295,21 +356,39 @@
autocomplete="off"
required
/>
<p class="text-xs text-muted-foreground">给密钥起一个有意义的名称方便识别</p>
<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" />
<Button
variant="outline"
class="h-11 px-6"
@click="showCreateDialog = false"
>
取消
</Button>
<Button
class="h-11 px-6 shadow-lg shadow-primary/20"
:disabled="creating"
@click="createApiKey"
>
<Loader2
v-if="creating"
class="animate-spin h-4 w-4 mr-2"
/>
{{ creating ? '创建中...' : '创建' }}
</Button>
</template>
</Dialog>
<!-- 新密钥创建成功对话框 -->
<Dialog v-model="showKeyDialog" size="lg">
<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">
@@ -317,8 +396,12 @@
<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>
<h3 class="text-lg font-semibold text-foreground leading-tight">
创建成功
</h3>
<p class="text-xs text-muted-foreground">
请妥善保管, 切勿泄露给他人
</p>
</div>
</div>
</div>
@@ -335,7 +418,10 @@
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
class="h-11"
@click="copyTextToClipboard(newKeyValue)"
>
复制
</Button>
</div>
@@ -343,7 +429,12 @@
</div>
<template #footer>
<Button @click="showKeyDialog = false" class="h-10 px-5">确定</Button>
<Button
class="h-10 px-5"
@click="showKeyDialog = false"
>
确定
</Button>
</template>
</Dialog>
@@ -440,7 +531,7 @@ async function loadApiKeys() {
} else if (error.response.status === 401) {
showError('认证失败,请重新登录')
} else {
showError('加载 API 密钥失败:' + (error.response?.data?.detail || error.message))
showError(`加载 API 密钥失败:${ error.response?.data?.detail || error.message}`)
}
} finally {
loading.value = false

View File

@@ -1,22 +1,38 @@
<template>
<div class="container mx-auto px-4 py-8">
<h2 class="text-2xl font-bold text-foreground mb-6">个人设置</h2>
<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">
<h3 class="text-lg font-medium text-foreground mb-4">
基本信息
</h3>
<form
class="space-y-4"
@submit.prevent="updateProfile"
>
<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" />
<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" />
<Input
id="email"
v-model="profileForm.email"
type="email"
class="mt-1"
/>
</div>
</div>
@@ -32,11 +48,21 @@
<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>
<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">
<Button
type="submit"
:disabled="savingProfile"
>
{{ savingProfile ? '保存中...' : '保存修改' }}
</Button>
</form>
@@ -44,21 +70,44 @@
<!-- 修改密码 -->
<Card class="p-6">
<h3 class="text-lg font-medium text-foreground mb-4">修改密码</h3>
<form @submit.prevent="changePassword" class="space-y-4">
<h3 class="text-lg font-medium text-foreground mb-4">
修改密码
</h3>
<form
class="space-y-4"
@submit.prevent="changePassword"
>
<div>
<Label for="old-password">当前密码</Label>
<Input id="old-password" v-model="passwordForm.old_password" type="password" class="mt-1" />
<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" />
<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" />
<Input
id="confirm-password"
v-model="passwordForm.confirm_password"
type="password"
class="mt-1"
/>
</div>
<Button type="submit" :disabled="changingPassword">
<Button
type="submit"
:disabled="changingPassword"
>
{{ changingPassword ? '修改中...' : '修改密码' }}
</Button>
</form>
@@ -66,7 +115,9 @@
<!-- 偏好设置 -->
<Card class="p-6">
<h3 class="text-lg font-medium text-foreground mb-4">偏好设置</h3>
<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>
@@ -76,13 +127,22 @@
v-model:open="themeSelectOpen"
@update:model-value="handleThemeChange"
>
<SelectTrigger id="theme" class="mt-1">
<SelectTrigger
id="theme"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">浅色</SelectItem>
<SelectItem value="dark">深色</SelectItem>
<SelectItem value="system">跟随系统</SelectItem>
<SelectItem value="light">
浅色
</SelectItem>
<SelectItem value="dark">
深色
</SelectItem>
<SelectItem value="system">
跟随系统
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -94,62 +154,91 @@
v-model:open="languageSelectOpen"
@update:model-value="handleLanguageChange"
>
<SelectTrigger id="language" class="mt-1">
<SelectTrigger
id="language"
class="mt-1"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh-CN">简体中文</SelectItem>
<SelectItem value="en">English</SelectItem>
<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" />
<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>
<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
for="email-notifications"
class="text-sm font-medium cursor-pointer"
>
邮件通知
</Label>
<p class="text-xs text-muted-foreground mt-1">接收系统重要通知</p>
<p class="text-xs text-muted-foreground mt-1">
接收系统重要通知
</p>
</div>
<Switch
id="email-notifications"
v-model="preferencesForm.notifications.email"
@update:modelValue="updatePreferences"
@update:model-value="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
for="usage-alerts"
class="text-sm font-medium cursor-pointer"
>
使用提醒
</Label>
<p class="text-xs text-muted-foreground mt-1">当接近配额限制时提醒</p>
<p class="text-xs text-muted-foreground mt-1">
当接近配额限制时提醒
</p>
</div>
<Switch
id="usage-alerts"
v-model="preferencesForm.notifications.usage_alerts"
@update:modelValue="updatePreferences"
@update:model-value="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
for="announcement-notifications"
class="text-sm font-medium cursor-pointer"
>
公告通知
</Label>
<p class="text-xs text-muted-foreground mt-1">接收系统公告</p>
<p class="text-xs text-muted-foreground mt-1">
接收系统公告
</p>
</div>
<Switch
id="announcement-notifications"
v-model="preferencesForm.notifications.announcements"
@update:modelValue="updatePreferences"
@update:model-value="updatePreferences"
/>
</div>
</div>
@@ -162,7 +251,9 @@
<div class="space-y-6">
<!-- 账户信息 -->
<Card class="p-6">
<h3 class="text-lg font-medium text-foreground mb-4">账户信息</h3>
<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>
@@ -193,7 +284,9 @@
<!-- 使用配额 -->
<Card class="p-6">
<h3 class="text-lg font-medium text-foreground mb-4">使用配额</h3>
<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">
@@ -213,7 +306,7 @@
<div
class="bg-success h-2.5 rounded-full"
:style="`width: ${getUsagePercentage()}%`"
></div>
/>
</div>
</div>
</div>

View File

@@ -7,7 +7,10 @@
@click.self="handleClose"
>
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="handleClose"></div>
<div
class="absolute inset-0 bg-black/30 backdrop-blur-sm"
@click="handleClose"
/>
<!-- 抽屉内容 -->
<Card class="relative h-full w-[700px] rounded-none shadow-2xl overflow-y-auto">
@@ -15,9 +18,14 @@
<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>
<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">
<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>
@@ -29,11 +37,19 @@
<Copy class="w-3 h-3 text-muted-foreground" />
</button>
</div>
<p v-if="model.description" class="text-xs text-muted-foreground">
<p
v-if="model.description"
class="text-xs text-muted-foreground"
>
{{ model.description }}
</p>
</div>
<Button variant="ghost" size="icon" @click="handleClose" title="关闭">
<Button
variant="ghost"
size="icon"
title="关闭"
@click="handleClose"
>
<X class="w-4 h-4" />
</Button>
</div>
@@ -42,55 +58,92 @@
<div class="p-6 space-y-6">
<!-- 模型能力 -->
<div class="space-y-3">
<h4 class="font-semibold text-sm">模型能力</h4>
<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>
<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">
<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>
<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">
<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>
<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">
<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>
<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">
<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>
<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">
<Badge
:variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }}
</Badge>
</div>
@@ -98,8 +151,13 @@
</div>
<!-- 模型偏好 -->
<div v-if="getModelUserConfigurableCapabilities().length > 0" class="space-y-3">
<h4 class="font-semibold text-sm">模型偏好</h4>
<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()"
@@ -107,12 +165,19 @@
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>
<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"
: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"
@@ -120,8 +185,8 @@
@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"
: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'
]"
/>
@@ -132,10 +197,15 @@
<!-- 定价信息 -->
<div class="space-y-3">
<h4 class="font-semibold text-sm">定价信息</h4>
<h4 class="font-semibold text-sm">
定价信息
</h4>
<!-- 单阶梯固定价格展示 -->
<div v-if="getTierCount(model.default_tiered_pricing) <= 1" class="space-y-3">
<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>
@@ -163,19 +233,28 @@
</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">
<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">
<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
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>
@@ -186,12 +265,24 @@
<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>
<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>
@@ -201,7 +292,10 @@
class="text-xs"
>
<TableCell class="py-2">
<span v-if="tier.up_to === null" class="text-muted-foreground">
<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>
@@ -229,7 +323,10 @@
</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">
<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>
@@ -269,6 +366,13 @@ import type { PublicGlobalModel } from '@/api/public-models'
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
import type { CapabilityDefinition } from '@/api/endpoints'
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
'toggle-capability': [modelName: string, capName: string]
}>()
const { success: showSuccess, error: showError } = useToast()
interface Props {
@@ -279,13 +383,6 @@ interface Props {
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)