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

@@ -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

@@ -4,252 +4,314 @@
<div class="flex-1 flex flex-col min-w-0">
<!-- 模型列表 -->
<Card class="overflow-hidden">
<!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<!-- 左侧标题 -->
<h3 class="text-base font-semibold">模型管理</h3>
<!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<!-- 左侧标题 -->
<h3 class="text-base font-semibold">
模型管理
</h3>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" />
<Input
id="model-search"
v-model="searchQuery"
type="text"
placeholder="搜索模型名称..."
class="w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
/>
</div>
<div class="h-4 w-px bg-border" />
<!-- 能力筛选 -->
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.streaming ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="capabilityFilters.streaming = !capabilityFilters.streaming"
title="流式输出"
>
<Zap class="w-3.5 h-3.5" />
</button>
<div class="w-px h-4 bg-border/60" />
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.imageGeneration ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="capabilityFilters.imageGeneration = !capabilityFilters.imageGeneration"
title="图像生成"
>
<Image class="w-3.5 h-3.5" />
</button>
<div class="w-px h-4 bg-border/60" />
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.vision ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="capabilityFilters.vision = !capabilityFilters.vision"
title="视觉理解"
>
<Eye class="w-3.5 h-3.5" />
</button>
<div class="w-px h-4 bg-border/60" />
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.toolUse ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="capabilityFilters.toolUse = !capabilityFilters.toolUse"
title="工具调用"
>
<Wrench class="w-3.5 h-3.5" />
</button>
<div class="w-px h-4 bg-border/60" />
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.extendedThinking ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="capabilityFilters.extendedThinking = !capabilityFilters.extendedThinking"
title="深度思考"
>
<Brain class="w-3.5 h-3.5" />
</button>
</div>
<div class="h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openCreateModelDialog"
title="创建模型"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loading" @click="refreshData" />
</div>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" />
<Input
id="model-search"
v-model="searchQuery"
type="text"
placeholder="搜索模型名称..."
class="w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
/>
</div>
<div class="h-4 w-px bg-border" />
<!-- 能力筛选 -->
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.streaming ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
title="流式输出"
@click="capabilityFilters.streaming = !capabilityFilters.streaming"
>
<Zap class="w-3.5 h-3.5" />
</button>
<div class="w-px h-4 bg-border/60" />
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.imageGeneration ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
title="图像生成"
@click="capabilityFilters.imageGeneration = !capabilityFilters.imageGeneration"
>
<Image class="w-3.5 h-3.5" />
</button>
<div class="w-px h-4 bg-border/60" />
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.vision ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
title="视觉理解"
@click="capabilityFilters.vision = !capabilityFilters.vision"
>
<Eye class="w-3.5 h-3.5" />
</button>
<div class="w-px h-4 bg-border/60" />
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.toolUse ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
title="工具调用"
@click="capabilityFilters.toolUse = !capabilityFilters.toolUse"
>
<Wrench class="w-3.5 h-3.5" />
</button>
<div class="w-px h-4 bg-border/60" />
<button
class="px-2.5 h-full text-xs transition-colors"
:class="capabilityFilters.extendedThinking ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
title="深度思考"
@click="capabilityFilters.extendedThinking = !capabilityFilters.extendedThinking"
>
<Brain class="w-3.5 h-3.5" />
</button>
</div>
<div class="h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="创建模型"
@click="openCreateModelDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton
:loading="loading"
@click="refreshData"
/>
</div>
</div>
</div>
<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>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<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>
</TableRow>
<template v-else>
<TableRow
v-for="model in paginatedGlobalModels"
:key="model.id"
class="cursor-pointer hover:bg-muted/50 group"
@mousedown="handleMouseDown"
@click="handleRowClick($event, model)"
>
<TableCell>
<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
class="p-0.5 rounded hover:bg-muted transition-colors"
title="复制模型 ID"
@click.stop="copyToClipboard(model.name)"
>
<Copy class="w-3 h-3" />
</button>
</div>
<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>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<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>
</TableRow>
<template v-else>
<TableRow
v-for="model in paginatedGlobalModels"
:key="model.id"
class="cursor-pointer hover:bg-muted/50 group"
@mousedown="handleMouseDown"
@click="handleRowClick($event, model)"
>
<TableCell>
<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
class="p-0.5 rounded hover:bg-muted transition-colors"
title="复制模型 ID"
@click.stop="copyToClipboard(model.name)"
>
<Copy class="w-3 h-3" />
</button>
</div>
</div>
</TableCell>
<TableCell>
<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="深度思考"
/>
</div>
<template v-if="model.supported_capabilities?.length">
<div class="border-t border-border/50" />
<div class="flex flex-wrap gap-0.5">
<span
v-for="capName in model.supported_capabilities"
:key="capName"
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
:title="getCapabilityDisplayName(capName)"
>{{ getCapabilityShortName(capName) }}</span>
</div>
</TableCell>
<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="深度思考" />
</div>
<template v-if="model.supported_capabilities?.length">
<div class="border-t border-border/50"></div>
<div class="flex flex-wrap gap-0.5">
<span
v-for="capName in model.supported_capabilities"
:key="capName"
class="text-[11px] px-1 py-0.5 rounded bg-muted/60 text-muted-foreground"
:title="getCapabilityDisplayName(capName)"
>{{ getCapabilityShortName(capName) }}</span>
</div>
</template>
</div>
</TableCell>
<TableCell class="text-center">
<div class="text-xs space-y-0.5">
<!-- Token 计费 -->
<div v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')">
<span class="text-muted-foreground">In:</span>
<span class="font-mono ml-1">{{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}</span>
<span class="text-muted-foreground mx-1">/</span>
<span class="text-muted-foreground">Out:</span>
<span class="font-mono ml-1">{{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}</span>
<!-- 阶梯计费标记 -->
<span v-if="hasTieredPricing(model)" class="ml-1 text-muted-foreground" title="阶梯计费">[阶梯]</span>
</div>
<!-- 按次计费 -->
<div v-if="model.default_price_per_request && model.default_price_per_request > 0">
<span class="text-muted-foreground">按次:</span>
<span class="font-mono ml-1">${{ model.default_price_per_request.toFixed(3) }}/</span>
</div>
<!-- 无计费配置 -->
<div v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request" class="text-muted-foreground">-</div>
</div>
</TableCell>
<TableCell class="text-center">
<Badge variant="secondary">{{ model.provider_count || 0 }}</Badge>
</TableCell>
<TableCell class="text-center">
<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>
</TableCell>
<TableCell>
<Badge :variant="model.is_active ? 'default' : 'secondary'">
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</TableCell>
<TableCell>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click.stop="selectModel(model)"
title="查看详情"
>
<Eye class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click.stop="editModel(model)"
title="编辑模型"
>
<Edit class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click.stop="toggleModelStatus(model)"
:title="model.is_active ? '停用模型' : '启用模型'"
>
<Power class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click.stop="deleteModel(model)"
title="删除模型"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</template>
</div>
</TableCell>
<TableCell class="text-center">
<div class="text-xs space-y-0.5">
<!-- Token 计费 -->
<div v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')">
<span class="text-muted-foreground">In:</span>
<span class="font-mono ml-1">{{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}</span>
<span class="text-muted-foreground mx-1">/</span>
<span class="text-muted-foreground">Out:</span>
<span class="font-mono ml-1">{{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}</span>
<!-- 阶梯计费标记 -->
<span
v-if="hasTieredPricing(model)"
class="ml-1 text-muted-foreground"
title="阶梯计费"
>[阶梯]</span>
</div>
<!-- 按次计费 -->
<div v-if="model.default_price_per_request && model.default_price_per_request > 0">
<span class="text-muted-foreground">按次:</span>
<span class="font-mono ml-1">${{ model.default_price_per_request.toFixed(3) }}/</span>
</div>
<!-- 无计费配置 -->
<div
v-if="!getFirstTierPrice(model, 'input') && !getFirstTierPrice(model, 'output') && !model.default_price_per_request"
class="text-muted-foreground"
>
-
</div>
</div>
</TableCell>
<TableCell class="text-center">
<Badge variant="secondary">
{{ model.provider_count || 0 }}
</Badge>
</TableCell>
<TableCell class="text-center">
<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>
</TableCell>
<TableCell>
<Badge :variant="model.is_active ? 'default' : 'secondary'">
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</TableCell>
<TableCell>
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="查看详情"
@click.stop="selectModel(model)"
>
<Eye class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑模型"
@click.stop="editModel(model)"
>
<Edit class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="model.is_active ? '停用模型' : '启用模型'"
@click.stop="toggleModelStatus(model)"
>
<Power class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="删除模型"
@click.stop="deleteModel(model)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
<!-- 分页 -->
<Pagination
v-if="!loading && filteredGlobalModels.length > 0"
:current="catalogCurrentPage"
:total="filteredGlobalModels.length"
:page-size="catalogPageSize"
@update:current="catalogCurrentPage = $event"
@update:page-size="catalogPageSize = $event"
/>
</Card>
<!-- 分页 -->
<Pagination
v-if="!loading && filteredGlobalModels.length > 0"
:current="catalogCurrentPage"
:total="filteredGlobalModels.length"
:page-size="catalogPageSize"
@update:current="catalogCurrentPage = $event"
@update:page-size="catalogPageSize = $event"
/>
</Card>
</div>
<!-- 创建/编辑模型对话框 -->
@@ -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>
@@ -71,112 +97,192 @@
<!-- 桌面端表格 -->
<div class="hidden xl:block overflow-x-auto">
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[200px] h-12 font-semibold">用户信息</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">邮箱</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">使用统计</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">配额(美元)</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">创建时间</TableHead>
<TableHead class="w-[90px] h-12 font-semibold text-center">状态</TableHead>
<TableHead class="w-[220px] h-12 font-semibold text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in paginatedUsers" :key="user.id" class="border-b border-border/40 hover:bg-muted/30 transition-colors">
<TableCell class="py-4">
<div class="flex items-center gap-3">
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md">
<AvatarFallback class="bg-primary text-sm font-bold text-white">
{{ user.username.charAt(0).toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<div class="truncate text-sm font-semibold mb-1" :title="user.username">{{ user.username }}</div>
<Badge :variant="user.role === 'admin' ? 'default' : 'secondary'" class="text-xs px-2 py-0.5">
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</Badge>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[200px] h-12 font-semibold">
用户信息
</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">
邮箱
</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">
使用统计
</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">
配额(美元)
</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">
创建时间
</TableHead>
<TableHead class="w-[90px] h-12 font-semibold text-center">
状态
</TableHead>
<TableHead class="w-[220px] h-12 font-semibold text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="user in paginatedUsers"
:key="user.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<TableCell class="py-4">
<div class="flex items-center gap-3">
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md">
<AvatarFallback class="bg-primary text-sm font-bold text-white">
{{ user.username.charAt(0).toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<div
class="truncate text-sm font-semibold mb-1"
:title="user.username"
>
{{ user.username }}
</div>
<Badge
:variant="user.role === 'admin' ? 'default' : 'secondary'"
class="text-xs px-2 py-0.5"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</Badge>
</div>
</div>
</div>
</TableCell>
<TableCell class="py-4">
<span class="block truncate text-sm text-muted-foreground" :title="user.email || '-'">
{{ user.email || '-' }}
</span>
</TableCell>
<TableCell class="py-4">
<div v-if="userStats[user.id]" class="space-y-1 text-xs">
<div class="flex items-center text-muted-foreground">
<span class="w-14">请求:</span>
<span class="font-medium text-foreground">{{ formatNumber(userStats[user.id]?.request_count) }}</span>
</div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">Tokens:</span>
<span class="font-medium text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</span>
</div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">费用:</span>
<span class="font-medium text-foreground">${{ formatCurrency(userStats[user.id]?.total_cost) }}</span>
</div>
</div>
<div v-else class="text-xs text-muted-foreground">
<span v-if="loadingStats">加载中...</span>
<span v-else>无数据</span>
</div>
</TableCell>
<TableCell class="py-4">
<div class="space-y-1.5 text-xs">
<div v-if="user.quota_usd != null" class="text-muted-foreground">
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium">${{ user.quota_usd.toFixed(2) }}</span>
</div>
<div v-else class="text-muted-foreground">
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium text-amber-600">无限制</span>
</div>
<div class="text-muted-foreground">累计: <span class="font-medium text-foreground">${{ (user.total_usd || 0).toFixed(2) }}</span></div>
</div>
</TableCell>
<TableCell class="py-4 text-xs text-muted-foreground">
{{ formatDate(user.created_at) }}
</TableCell>
<TableCell class="py-4 text-center">
<Badge :variant="user.is_active ? 'success' : 'destructive'" class="font-medium px-3 py-1">
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
</TableCell>
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="editUser(user)" title="编辑用户">
<SquarePen class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="manageApiKeys(user)" title="查看API Keys">
<Key class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="toggleUserStatus(user)"
:title="user.is_active ? '禁用用户' : '启用用户'"
</TableCell>
<TableCell class="py-4">
<span
class="block truncate text-sm text-muted-foreground"
:title="user.email || '-'"
>
<PauseCircle v-if="user.is_active" class="h-4 w-4" />
<PlayCircle v-else class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="resetQuota(user)" title="重置配额">
<RotateCcw class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="deleteUser(user)" title="删除用户">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
{{ user.email || '-' }}
</span>
</TableCell>
<TableCell class="py-4">
<div
v-if="userStats[user.id]"
class="space-y-1 text-xs"
>
<div class="flex items-center text-muted-foreground">
<span class="w-14">请求:</span>
<span class="font-medium text-foreground">{{ formatNumber(userStats[user.id]?.request_count) }}</span>
</div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">Tokens:</span>
<span class="font-medium text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</span>
</div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">费用:</span>
<span class="font-medium text-foreground">${{ formatCurrency(userStats[user.id]?.total_cost) }}</span>
</div>
</div>
<div
v-else
class="text-xs text-muted-foreground"
>
<span v-if="loadingStats">加载中...</span>
<span v-else>无数据</span>
</div>
</TableCell>
<TableCell class="py-4">
<div class="space-y-1.5 text-xs">
<div
v-if="user.quota_usd != null"
class="text-muted-foreground"
>
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium">${{ user.quota_usd.toFixed(2) }}</span>
</div>
<div
v-else
class="text-muted-foreground"
>
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium text-amber-600">无限制</span>
</div>
<div class="text-muted-foreground">
累计: <span class="font-medium text-foreground">${{ (user.total_usd || 0).toFixed(2) }}</span>
</div>
</div>
</TableCell>
<TableCell class="py-4 text-xs text-muted-foreground">
{{ formatDate(user.created_at) }}
</TableCell>
<TableCell class="py-4 text-center">
<Badge
:variant="user.is_active ? 'success' : 'destructive'"
class="font-medium px-3 py-1"
>
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
</TableCell>
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑用户"
@click="editUser(user)"
>
<SquarePen class="h-4 w-4" />
</Button>
<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"
: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"
/>
</Button>
<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"
title="删除用户"
@click="deleteUser(user)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 移动端卡片列表 -->
<div class="xl:hidden divide-y divide-border/40">
<div v-for="user in paginatedUsers" :key="user.id" class="p-4 sm:p-5 hover:bg-muted/30 transition-colors">
<div
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>