Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

View File

@@ -0,0 +1,378 @@
<template>
<div class="flex flex-col">
<Card class="overflow-hidden">
<!-- 搜索和过滤区域 -->
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<h3 class="text-base font-semibold">别名管理</h3>
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="alias-search"
v-model="aliasesSearch"
placeholder="搜索别名或关联模型"
class="w-44 pl-8 pr-3 h-8 text-sm border-border/60 focus-visible:ring-1"
/>
</div>
<div class="h-4 w-px bg-border" />
<!-- 提供商过滤器 -->
<Select v-model:open="aliasProviderSelectOpen" :model-value="aliasProviderFilter" @update:model-value="aliasProviderFilter = $event">
<SelectTrigger class="w-40 h-8 text-xs border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部别名</SelectItem>
<SelectItem value="global">仅全局别名</SelectItem>
<SelectItem v-for="provider in providers" :key="provider.id" :value="provider.id">
{{ provider.display_name }}
</SelectItem>
</SelectContent>
</Select>
<div class="h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openCreateAliasDialog"
title="新建别名"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loadingAliases" @click="loadAliases" />
</div>
</div>
</div>
<div v-if="loadingAliases" class="flex items-center justify-center py-12">
<Loader2 class="w-10 h-10 animate-spin text-primary" />
</div>
<div v-else>
<Table class="text-sm">
<TableHeader>
<TableRow>
<TableHead class="w-[200px]">别名</TableHead>
<TableHead class="w-[280px]">关联模型</TableHead>
<TableHead class="w-[70px] text-center">类型</TableHead>
<TableHead class="w-[100px] text-center">作用域</TableHead>
<TableHead class="w-[70px] text-center">状态</TableHead>
<TableHead class="w-[100px] text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="filteredAliases.length === 0">
<TableCell colspan="6" class="text-center py-8 text-muted-foreground">
{{ aliasProviderFilter === 'global' ? '暂无全局别名' : '暂无别名' }}
</TableCell>
</TableRow>
<TableRow v-for="alias in paginatedAliases" :key="alias.id">
<TableCell>
<span class="font-mono font-medium">{{ alias.alias }}</span>
</TableCell>
<TableCell>
<div class="flex flex-col gap-0.5">
<span class="font-medium">{{ alias.global_model_display_name || alias.global_model_name }}</span>
<span class="text-xs text-muted-foreground font-mono">{{ alias.global_model_name }}</span>
</div>
</TableCell>
<TableCell class="text-center">
<Badge variant="secondary" class="text-xs">
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
</Badge>
</TableCell>
<TableCell class="text-center">
<Badge
v-if="alias.provider_id"
variant="outline"
class="text-xs"
>
{{ alias.provider_name || 'Provider 特定' }}
</Badge>
<Badge
v-else
variant="default"
class="text-xs"
>
全局
</Badge>
</TableCell>
<TableCell class="text-center">
<Badge :variant="alias.is_active ? 'default' : 'secondary'" class="text-xs">
{{ alias.is_active ? '活跃' : '停用' }}
</Badge>
</TableCell>
<TableCell class="text-center">
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="openEditAliasDialog(alias)"
title="编辑别名"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="toggleAliasStatus(alias)"
:title="alias.is_active ? '停用别名' : '启用别名'"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="confirmDeleteAlias(alias)"
title="删除别名"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 分页 -->
<Pagination
v-if="!loadingAliases && filteredAliases.length > 0"
:current="aliasesCurrentPage"
:total="filteredAliases.length"
:page-size="aliasesPageSize"
@update:current="aliasesCurrentPage = $event"
@update:page-size="aliasesPageSize = $event"
/>
</div>
</Card>
<!-- 创建/编辑别名对话框 -->
<AliasDialog
:open="createAliasDialogOpen"
:editing-alias="editingAlias"
:global-models="globalModels"
:providers="providers"
@update:open="handleAliasDialogUpdate"
@submit="handleAliasSubmit"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import {
Edit,
Loader2,
Plus,
Power,
Search,
Trash2
} from 'lucide-vue-next'
import {
Card,
Button,
Input,
Badge,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
RefreshButton,
Pagination
} from '@/components/ui'
import AliasDialog from '@/features/models/components/AliasDialog.vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import {
getAliases,
createAlias,
updateAlias,
deleteAlias,
type ModelAlias,
type CreateModelAliasRequest,
type UpdateModelAliasRequest
} from '@/api/endpoints/aliases'
import { listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import { getProvidersSummary } from '@/api/endpoints/providers'
const { success, error: showError } = useToast()
const { confirmDanger } = useConfirm()
// 状态
const loadingAliases = ref(false)
const submitting = ref(false)
const aliasesSearch = ref('')
const aliasProviderFilter = ref<string>('all')
const aliasProviderSelectOpen = ref(false)
const createAliasDialogOpen = ref(false)
const editingAliasId = ref<string | null>(null)
// 数据
const allAliases = ref<ModelAlias[]>([])
const globalModels = ref<GlobalModelResponse[]>([])
const providers = ref<any[]>([])
// 分页
const aliasesCurrentPage = ref(1)
const aliasesPageSize = ref(20)
// 编辑中的别名对象
const editingAlias = computed(() => {
if (!editingAliasId.value) return null
return allAliases.value.find(a => a.id === editingAliasId.value) || null
})
// 筛选后的别名列表
const filteredAliases = computed(() => {
let result = allAliases.value
// 按 Provider 筛选
if (aliasProviderFilter.value === 'global') {
result = result.filter(alias => !alias.provider_id)
} else if (aliasProviderFilter.value !== 'all') {
result = result.filter(alias => alias.provider_id === aliasProviderFilter.value)
}
// 按搜索关键词筛选
const keyword = aliasesSearch.value.trim().toLowerCase()
if (keyword) {
result = result.filter(alias =>
alias.alias.toLowerCase().includes(keyword) ||
alias.global_model_name?.toLowerCase().includes(keyword) ||
alias.global_model_display_name?.toLowerCase().includes(keyword)
)
}
return result
})
// 分页计算
const paginatedAliases = computed(() => {
const start = (aliasesCurrentPage.value - 1) * aliasesPageSize.value
const end = start + aliasesPageSize.value
return filteredAliases.value.slice(start, end)
})
// 搜索或筛选变化时重置到第一页
watch([aliasesSearch, aliasProviderFilter], () => {
aliasesCurrentPage.value = 1
})
async function loadAliases() {
loadingAliases.value = true
try {
allAliases.value = await getAliases({ limit: 1000 })
} catch (err: any) {
showError(err.response?.data?.detail || err.message, '加载别名失败')
} finally {
loadingAliases.value = false
}
}
async function loadGlobalModelsList() {
try {
const response = await listGlobalModels()
globalModels.value = response.models || []
} catch (err: any) {
console.error('加载模型失败:', err)
}
}
async function loadProviders() {
try {
providers.value = await getProvidersSummary()
} catch (err: any) {
showError(err.response?.data?.detail || err.message, '加载 Provider 列表失败')
}
}
function openCreateAliasDialog() {
editingAliasId.value = null
createAliasDialogOpen.value = true
}
function openEditAliasDialog(alias: ModelAlias) {
editingAliasId.value = alias.id
createAliasDialogOpen.value = true
}
function handleAliasDialogUpdate(value: boolean) {
createAliasDialogOpen.value = value
if (!value) {
editingAliasId.value = null
}
}
async function handleAliasSubmit(data: CreateModelAliasRequest | UpdateModelAliasRequest, isEdit: boolean) {
submitting.value = true
try {
if (isEdit && editingAliasId.value) {
await updateAlias(editingAliasId.value, data as UpdateModelAliasRequest)
success(data.mapping_type === 'mapping' ? '映射已更新' : '别名已更新')
} else {
await createAlias(data as CreateModelAliasRequest)
success(data.mapping_type === 'mapping' ? '映射已创建' : '别名已创建')
}
createAliasDialogOpen.value = false
editingAliasId.value = null
await loadAliases()
} catch (err: any) {
const detail = err.response?.data?.detail || err.message
let errorMessage = detail
if (detail === '映射已存在') {
errorMessage = '目标作用域已存在同名别名,请先删除冲突的映射或选择其他作用域'
}
showError(errorMessage, isEdit ? '更新失败' : '创建失败')
} finally {
submitting.value = false
}
}
async function confirmDeleteAlias(alias: ModelAlias) {
const confirmed = await confirmDanger(
`确定要删除别名 "${alias.alias}" 吗?`,
'删除别名'
)
if (!confirmed) return
try {
await deleteAlias(alias.id)
success('别名已删除')
await loadAliases()
} catch (err: any) {
showError(err.response?.data?.detail || err.message, '删除失败')
}
}
async function toggleAliasStatus(alias: ModelAlias) {
try {
await updateAlias(alias.id, { is_active: !alias.is_active })
alias.is_active = !alias.is_active
success(alias.is_active ? '别名已启用' : '别名已停用')
} catch (err: any) {
showError(err.response?.data?.detail || err.message, '操作失败')
}
}
onMounted(async () => {
await Promise.all([
loadAliases(),
loadGlobalModelsList(),
loadProviders()
])
})
</script>

View File

@@ -0,0 +1,924 @@
<template>
<div class="space-y-6 pb-8">
<Card variant="default" class="overflow-hidden">
<!-- 加载状态 -->
<div v-if="loading" class="py-16 text-center space-y-4">
<Skeleton class="mx-auto h-10 w-10 rounded-full" />
<Skeleton class="mx-auto h-4 w-32" />
</div>
<div v-else>
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-base font-semibold">独立余额 API Keys</h3>
<p class="text-xs text-muted-foreground mt-0.5">
活跃 {{ activeKeyCount }} · 禁用 {{ inactiveKeyCount }} · 无限 Key {{ unlimitedKeyCount }}
<span v-if="expiringSoonCount > 0" class="text-amber-600"> · 即将到期 {{ expiringSoonCount }}</span>
</p>
</div>
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
v-model="searchQuery"
type="text"
placeholder="搜索..."
class="h-8 w-40 pl-8 pr-2 text-xs"
/>
</div>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
<!-- 状态筛选 -->
<Select v-model="filterStatus" v-model:open="filterStatusOpen">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="status in statusFilters" :key="status.value" :value="status.value">
{{ status.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 余额类型筛选 -->
<Select v-model="filterBalance" v-model:open="filterBalanceOpen">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="balance in balanceFilters" :key="balance.value" :value="balance.value">
{{ balance.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
<!-- 创建独立 Key 按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openCreateDialog"
title="创建独立 Key"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton :loading="loading" @click="loadApiKeys" />
</div>
</div>
</div>
<div class="hidden xl:block overflow-x-auto">
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[200px] h-12 font-semibold">密钥信息</TableHead>
<TableHead class="w-[160px] h-12 font-semibold">余额 (已用/总额)</TableHead>
<TableHead class="w-[130px] h-12 font-semibold">使用统计</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">有效期</TableHead>
<TableHead class="w-[140px] h-12 font-semibold">最近使用</TableHead>
<TableHead class="w-[70px] h-12 font-semibold text-center">状态</TableHead>
<TableHead class="w-[130px] h-12 font-semibold text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="filteredApiKeys.length === 0">
<TableCell colspan="7" class="h-64 text-center">
<div class="flex flex-col items-center justify-center space-y-4">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Key class="h-8 w-8 text-muted-foreground" />
</div>
<div v-if="hasActiveFilters">
<h3 class="text-lg font-semibold">未找到匹配的 Key</h3>
<p class="mt-2 text-sm text-muted-foreground">
尝试调整筛选条件
</p>
<Button variant="outline" size="sm" class="mt-3" @click="clearFilters">
清除筛选
</Button>
</div>
<div v-else>
<h3 class="text-lg font-semibold">暂无独立余额 Key</h3>
<p class="mt-2 text-sm text-muted-foreground">
点击右上角按钮创建独立余额 Key
</p>
</div>
</div>
</TableCell>
</TableRow>
<TableRow v-for="apiKey in filteredApiKeys" :key="apiKey.id" class="border-b border-border/40 hover:bg-muted/30 transition-colors">
<TableCell class="py-4">
<div class="space-y-1">
<div class="text-sm font-semibold text-foreground truncate" :title="apiKey.name || '未命名 Key'">
{{ apiKey.name || '未命名 Key' }}
</div>
<div class="flex items-center gap-1.5">
<code class="text-xs font-mono text-muted-foreground">
{{ apiKey.key_display || 'sk-****' }}
</code>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
@click="copyKeyPrefix(apiKey)"
title="复制完整密钥"
>
<Copy class="h-3 w-3" />
</Button>
</div>
</div>
</TableCell>
<TableCell class="py-4">
<div class="text-xs">
<div class="flex items-center gap-1.5">
<span class="font-mono font-medium">${{ (apiKey.balance_used_usd || 0).toFixed(2) }}</span>
<span class="text-muted-foreground">/</span>
<span :class="isBalanceLimited(apiKey) ? 'font-mono font-medium text-primary' : 'font-mono text-muted-foreground'">
{{ isBalanceLimited(apiKey) ? `$${(apiKey.current_balance_usd || 0).toFixed(2)}` : '无限' }}
</span>
</div>
</div>
</TableCell>
<TableCell class="py-4">
<div class="space-y-1 text-xs">
<div class="text-muted-foreground">
请求: <span class="font-medium text-foreground">{{ (apiKey.total_requests || 0).toLocaleString() }}</span>
</div>
<div class="text-muted-foreground">
速率: <span class="font-medium text-foreground">{{ apiKey.rate_limit ? `${apiKey.rate_limit}/min` : '未设置' }}</span>
</div>
</div>
</TableCell>
<TableCell class="py-4">
<div class="text-xs">
<div v-if="apiKey.expires_at" class="space-y-1">
<div class="text-foreground">{{ formatDate(apiKey.expires_at) }}</div>
<div class="text-muted-foreground">{{ getRelativeTime(apiKey.expires_at) }}</div>
</div>
<div v-else class="text-muted-foreground">永不过期</div>
</div>
</TableCell>
<TableCell class="py-4">
<div class="text-xs">
<span v-if="apiKey.last_used_at" class="text-foreground">{{ formatDate(apiKey.last_used_at) }}</span>
<span v-else class="text-muted-foreground">暂无记录</span>
</div>
</TableCell>
<TableCell class="py-4 text-center">
<Badge :variant="apiKey.is_active ? 'success' : 'destructive'" class="font-medium">
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
</TableCell>
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="editApiKey(apiKey)"
title="编辑"
>
<SquarePen class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openAddBalanceDialog(apiKey)"
title="调整余额"
>
<DollarSign class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="toggleApiKey(apiKey)"
:title="apiKey.is_active ? '禁用' : '启用'"
>
<Power class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="deleteApiKey(apiKey)"
title="删除"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="xl:hidden divide-y divide-border/40">
<div v-if="apiKeys.length === 0" class="p-8 text-center">
<Key class="h-12 w-12 mx-auto mb-3 text-muted-foreground/50" />
<p class="text-muted-foreground">暂无独立余额 Key</p>
</div>
<div v-for="apiKey in apiKeys" :key="apiKey.id" class="p-4 sm:p-5 hover:bg-muted/30 transition-colors">
<div class="space-y-4">
<div class="flex items-start justify-between gap-3">
<div class="space-y-2">
<div class="flex items-center gap-2">
<code class="inline-flex rounded-lg bg-muted px-3 py-1.5 text-xs font-mono font-semibold">
{{ apiKey.key_display || 'sk-****' }}
</code>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 hover:bg-muted flex-shrink-0"
@click="copyKeyPrefix(apiKey)"
title="复制完整密钥"
>
<Copy class="h-3.5 w-3.5" />
</Button>
</div>
<div class="text-sm font-semibold text-foreground" :class="{ 'text-muted-foreground': !apiKey.name }">
{{ apiKey.name || '未命名 Key' }}
</div>
</div>
<Badge :variant="apiKey.is_active ? 'success' : 'destructive'" class="text-xs flex-shrink-0">
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
</div>
<div class="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<span class="inline-flex items-center gap-1 rounded-full border border-border/60 px-2.5 py-0.5">
{{ isBalanceLimited(apiKey) ? '限额 Key' : '无限额度' }}
</span>
<span
v-if="apiKey.auto_delete_on_expiry"
class="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5"
>
过期自动删除
</span>
</div>
<div class="space-y-2 p-3 bg-muted/50 rounded-lg text-xs">
<div class="flex items-center justify-between text-muted-foreground">
<span>已用</span>
<span class="font-semibold">${{ (apiKey.balance_used_usd || 0).toFixed(2) }}</span>
</div>
<div class="flex items-center justify-between text-muted-foreground">
<span>剩余</span>
<span :class="getBalanceRemaining(apiKey) > 0 ? 'font-semibold text-emerald-600' : 'font-semibold text-rose-600'">
{{ isBalanceLimited(apiKey) ? `$${getBalanceRemaining(apiKey).toFixed(2)}` : '无限制' }}
</span>
</div>
<div class="flex items-center justify-between text-amber-600">
<span>总费用</span>
<span>${{ (apiKey.total_cost_usd || 0).toFixed(4) }}</span>
</div>
<div v-if="isBalanceLimited(apiKey)" class="h-1.5 rounded-full bg-background/40 overflow-hidden">
<div
class="h-full rounded-full bg-emerald-500"
:style="{ width: `${getBalanceProgress(apiKey)}%` }"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="p-2 bg-muted/40 rounded-lg">
<div class="text-muted-foreground mb-1">速率限制</div>
<div class="font-semibold">{{ apiKey.rate_limit ? `${apiKey.rate_limit}/min` : '未设置' }}</div>
</div>
<div class="p-2 bg-muted/40 rounded-lg">
<div class="text-muted-foreground mb-1">请求次数</div>
<div class="font-semibold">{{ (apiKey.total_requests || 0).toLocaleString() }}</div>
</div>
<div class="p-2 bg-muted/40 rounded-lg col-span-2">
<div class="text-muted-foreground mb-1">有效期</div>
<div class="font-semibold">
{{ apiKey.expires_at ? formatDate(apiKey.expires_at) : '永不过期' }}
</div>
<div v-if="apiKey.expires_at" class="text-[11px] text-muted-foreground">
{{ getRelativeTime(apiKey.expires_at) }}
</div>
</div>
</div>
<div class="text-xs text-muted-foreground space-y-1">
<p>创建: {{ formatDate(apiKey.created_at) }}</p>
<p>
最近使用:
<span v-if="apiKey.last_used_at" class="font-medium text-foreground">{{ formatDate(apiKey.last_used_at) }}</span>
<span v-else>暂无记录</span>
</p>
<p v-if="apiKey.expires_at">过期后: {{ apiKey.auto_delete_on_expiry ? '自动删除' : '仅禁用' }}</p>
</div>
<div class="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
@click="editApiKey(apiKey)"
>
<SquarePen class="h-3.5 w-3.5 mr-1.5" />
编辑
</Button>
<Button
variant="outline"
size="sm"
class="text-blue-600"
@click="openAddBalanceDialog(apiKey)"
>
<DollarSign class="h-3.5 w-3.5 mr-1.5" />
调整
</Button>
<Button
variant="outline"
size="sm"
@click="toggleApiKey(apiKey)"
>
<Power class="h-3.5 w-3.5 mr-1.5" />
{{ apiKey.is_active ? '禁用' : '启用' }}
</Button>
<Button
variant="outline"
size="sm"
class="text-rose-600"
@click="deleteApiKey(apiKey)"
>
<Trash2 class="h-3.5 w-3.5 mr-1.5" />
删除
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<Pagination
v-if="!loading && apiKeys.length > 0"
:current="currentPage"
:total="total"
:page-size="limit"
:show-page-size-selector="false"
@update:current="handlePageChange"
/>
</Card>
<!-- 创建/编辑独立Key对话框 -->
<StandaloneKeyFormDialog
:open="showKeyFormDialog"
:api-key="editingKeyData"
@close="closeKeyFormDialog"
@submit="handleKeyFormSubmit"
ref="keyFormDialogRef"
/>
<!-- Key 显示对话框 -->
<Dialog v-model="showNewKeyDialog" size="lg">
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">创建成功</h3>
<p class="text-xs text-muted-foreground">请妥善保管, 切勿泄露给他人.</p>
</div>
</div>
</div>
</template>
<div class="space-y-4">
<div class="space-y-2">
<Label class="text-sm font-medium">API Key</Label>
<div class="flex items-center gap-2">
<Input
type="text"
:value="newKeyValue"
readonly
class="flex-1 font-mono text-sm bg-muted/50 h-11"
@click="selectKey"
ref="keyInput"
/>
<Button @click="copyKey" class="h-11">
复制
</Button>
</div>
</div>
</div>
<template #footer>
<Button @click="closeNewKeyDialog" class="h-10 px-5">确定</Button>
</template>
</Dialog>
<!-- 余额调整对话框 -->
<Dialog v-model="showAddBalanceDialog" size="md">
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30 flex-shrink-0">
<DollarSign class="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">余额调整</h3>
<p class="text-xs text-muted-foreground">增加或扣除 API Key 余额</p>
</div>
</div>
</div>
</template>
<div class="space-y-4">
<div class="p-3 bg-muted/50 rounded-lg text-sm">
<div class="font-medium mb-2">当前余额信息</div>
<div class="space-y-1 text-xs text-muted-foreground">
<div>已用: <span class="font-semibold text-foreground">${{ (addBalanceKey.balance_used_usd || 0).toFixed(2) }}</span></div>
<div>当前余额: <span class="font-semibold text-foreground">${{ (addBalanceKey.current_balance_usd || 0).toFixed(2) }}</span></div>
</div>
</div>
<div class="space-y-2">
<Label for="addBalanceAmount" class="text-sm font-medium">调整金额 (USD)</Label>
<Input
id="addBalanceAmount"
:model-value="addBalanceAmount ?? ''"
type="number"
step="0.01"
placeholder="正数为增加,负数为扣除"
class="h-11"
@update:model-value="(v) => addBalanceAmount = parseNumberInput(v, { allowFloat: true })"
/>
<p class="text-xs text-muted-foreground">
<span v-if="addBalanceAmount && addBalanceAmount > 0" class="text-emerald-600">
增加 ${{ addBalanceAmount.toFixed(2) }},调整后余额: ${{ ((addBalanceKey.current_balance_usd || 0) + addBalanceAmount).toFixed(2) }}
</span>
<span v-else-if="addBalanceAmount && addBalanceAmount < 0" class="text-rose-600">
扣除 ${{ Math.abs(addBalanceAmount).toFixed(2) }},调整后余额: ${{ Math.max(0, (addBalanceKey.current_balance_usd || 0) + addBalanceAmount).toFixed(2) }}
</span>
<span v-else class="text-muted-foreground">
输入正数增加余额,负数扣除余额
</span>
</p>
</div>
</div>
<template #footer>
<div class="flex gap-3 justify-end">
<Button variant="outline" @click="showAddBalanceDialog = false" class="h-10 px-5">
取消
</Button>
<Button @click="handleAddBalance" :disabled="addingBalance || !addBalanceAmount || addBalanceAmount === 0" class="h-10 px-5">
{{ addingBalance ? '调整中...' : '确认调整' }}
</Button>
</div>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
import {
Dialog,
Card,
Button,
Badge,
Input,
Skeleton,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Pagination,
RefreshButton,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Label
} from '@/components/ui'
import {
Plus,
Key,
Trash2,
Power,
DollarSign,
Copy,
CheckCircle,
SquarePen,
Search
} from 'lucide-vue-next'
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
import { parseNumberInput } from '@/utils/form'
const { success, error } = useToast()
const { confirmDanger } = useConfirm()
const apiKeys = ref<AdminApiKey[]>([])
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const limit = ref(100)
const showNewKeyDialog = ref(false)
const newKeyValue = ref('')
const keyInput = ref<HTMLInputElement>()
// 统一的表单对话框状态
const showKeyFormDialog = ref(false)
const editingKeyData = ref<StandaloneKeyFormData | null>(null)
const keyFormDialogRef = ref<InstanceType<typeof StandaloneKeyFormDialog>>()
const EXPIRY_SOON_DAYS = 7
// 筛选相关
const searchQuery = ref('')
const filterStatus = ref<'all' | 'active' | 'inactive'>('all')
const filterStatusOpen = ref(false)
const filterBalance = ref<'all' | 'limited' | 'unlimited'>('all')
const filterBalanceOpen = ref(false)
const statusFilters = [
{ value: 'all' as const, label: '全部状态' },
{ value: 'active' as const, label: '活跃' },
{ value: 'inactive' as const, label: '禁用' }
]
const balanceFilters = [
{ value: 'all' as const, label: '全部类型' },
{ value: 'limited' as const, label: '限额' },
{ value: 'unlimited' as const, label: '无限' }
]
const hasActiveFilters = computed(() => {
return searchQuery.value !== '' || filterStatus.value !== 'all' || filterBalance.value !== 'all'
})
function clearFilters() {
searchQuery.value = ''
filterStatus.value = 'all'
filterBalance.value = 'all'
}
const skip = computed(() => (currentPage.value - 1) * limit.value)
const activeKeyCount = computed(() => apiKeys.value.filter(key => key.is_active).length)
const inactiveKeyCount = computed(() => Math.max(0, apiKeys.value.length - activeKeyCount.value))
const limitedKeyCount = computed(() => apiKeys.value.filter(isBalanceLimited).length)
const unlimitedKeyCount = computed(() => Math.max(0, apiKeys.value.length - limitedKeyCount.value))
const expiringSoonCount = computed(() => apiKeys.value.filter(key => isExpiringSoon(key)).length)
// 筛选后的 API Keys
const filteredApiKeys = computed(() => {
let result = apiKeys.value
// 搜索筛选
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(key =>
(key.name && key.name.toLowerCase().includes(query)) ||
(key.key_display && key.key_display.toLowerCase().includes(query)) ||
(key.username && key.username.toLowerCase().includes(query)) ||
(key.user_email && key.user_email.toLowerCase().includes(query))
)
}
// 状态筛选
if (filterStatus.value === 'active') {
result = result.filter(key => key.is_active)
} else if (filterStatus.value === 'inactive') {
result = result.filter(key => !key.is_active)
}
// 余额类型筛选
if (filterBalance.value === 'limited') {
result = result.filter(isBalanceLimited)
} else if (filterBalance.value === 'unlimited') {
result = result.filter(key => !isBalanceLimited(key))
}
return result
})
// 充值相关状态
const showAddBalanceDialog = ref(false)
const addBalanceKey = ref({
id: '',
name: '',
balance_used_usd: 0,
current_balance_usd: 0
})
const addBalanceAmount = ref<number | undefined>(undefined)
const addingBalance = ref(false)
onMounted(async () => {
await loadApiKeys()
})
async function loadApiKeys() {
loading.value = true
try {
const response = await adminApi.getAllApiKeys({
skip: skip.value,
limit: limit.value
})
apiKeys.value = response.api_keys
total.value = response.total
} catch (err: any) {
console.error('加载独立Keys失败:', err)
error(err.response?.data?.detail || '加载独立 Keys 失败')
} finally {
loading.value = false
}
}
function handlePageChange(page: number) {
currentPage.value = page
loadApiKeys()
}
async function toggleApiKey(apiKey: AdminApiKey) {
try {
const response = await adminApi.toggleApiKey(apiKey.id)
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
if (index !== -1) {
apiKeys.value[index].is_active = response.is_active
}
success(response.message)
} catch (err: any) {
console.error('切换密钥状态失败:', err)
error(err.response?.data?.detail || '操作失败')
}
}
async function deleteApiKey(apiKey: AdminApiKey) {
const confirmed = await confirmDanger(
`确定要删除这个独立余额 Key 吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`,
'删除独立 Key'
)
if (!confirmed) return
try {
const response = await adminApi.deleteApiKey(apiKey.id)
apiKeys.value = apiKeys.value.filter(k => k.id !== apiKey.id)
total.value = total.value - 1
success(response.message)
} catch (err: any) {
console.error('删除密钥失败:', err)
error(err.response?.data?.detail || '删除失败')
}
}
function editApiKey(apiKey: AdminApiKey) {
// 计算过期天数
let expireDays: number | undefined = undefined
let neverExpire = true
if (apiKey.expires_at) {
const expiresDate = new Date(apiKey.expires_at)
const now = new Date()
const diffMs = expiresDate.getTime() - now.getTime()
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffDays > 0) {
expireDays = diffDays
neverExpire = false
}
}
editingKeyData.value = {
id: apiKey.id,
name: apiKey.name || '',
expire_days: expireDays,
never_expire: neverExpire,
rate_limit: apiKey.rate_limit || 100,
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
allowed_providers: apiKey.allowed_providers || [],
allowed_api_formats: apiKey.allowed_api_formats || [],
allowed_models: apiKey.allowed_models || []
}
showKeyFormDialog.value = true
}
function openAddBalanceDialog(apiKey: AdminApiKey) {
addBalanceKey.value = {
id: apiKey.id,
name: apiKey.name || apiKey.key_display || 'sk-****',
balance_used_usd: apiKey.balance_used_usd || 0,
current_balance_usd: apiKey.current_balance_usd || 0
}
addBalanceAmount.value = undefined
showAddBalanceDialog.value = true
}
async function handleAddBalance() {
if (!addBalanceAmount.value || addBalanceAmount.value === 0) {
error('调整金额不能为 0')
return
}
// 验证扣除金额不能超过当前余额
if (addBalanceAmount.value < 0 && Math.abs(addBalanceAmount.value) > (addBalanceKey.value.current_balance_usd || 0)) {
error('扣除金额不能超过当前余额')
return
}
addingBalance.value = true
try {
const response = await adminApi.addApiKeyBalance(addBalanceKey.value.id, addBalanceAmount.value)
// 重新加载列表
await loadApiKeys()
showAddBalanceDialog.value = false
const action = addBalanceAmount.value > 0 ? '增加' : '扣除'
const amount = Math.abs(addBalanceAmount.value).toFixed(2)
success(response.message || `余额${action}成功,${action} $${amount}`)
} catch (err: any) {
console.error('余额调整失败:', err)
error(err.response?.data?.detail || '调整失败')
} finally {
addingBalance.value = false
}
}
function selectKey() {
keyInput.value?.select()
}
async function copyKey() {
try {
await navigator.clipboard.writeText(newKeyValue.value)
success('API Key 已复制到剪贴板')
} catch (err) {
error('复制失败,请手动复制')
}
}
async function copyKeyPrefix(apiKey: AdminApiKey) {
try {
// 调用后端 API 获取完整密钥
const response = await adminApi.getFullApiKey(apiKey.id)
await navigator.clipboard.writeText(response.key)
success('完整密钥已复制到剪贴板')
} catch (err) {
console.error('复制密钥失败:', err)
error('复制失败,请重试')
}
}
function closeNewKeyDialog() {
showNewKeyDialog.value = false
newKeyValue.value = ''
}
function isBalanceLimited(apiKey: AdminApiKey): boolean {
return apiKey.current_balance_usd !== null && apiKey.current_balance_usd !== undefined
}
function getBalanceProgress(apiKey: AdminApiKey): number {
if (!isBalanceLimited(apiKey)) {
return 0
}
// 总额 = 当前余额 + 已使用
const used = apiKey.balance_used_usd || 0
const remaining = apiKey.current_balance_usd || 0
const total = used + remaining
if (total <= 0) {
return 0
}
// 进度条显示剩余比例(绿色部分)
const ratio = (remaining / total) * 100
const normalized = Number.isFinite(ratio) ? ratio : 0
return Math.max(0, Math.min(100, normalized))
}
function isExpiringSoon(apiKey: AdminApiKey): boolean {
if (!apiKey.expires_at) {
return false
}
const expiresAt = new Date(apiKey.expires_at).getTime()
const now = Date.now()
const diffDays = (expiresAt - now) / (1000 * 60 * 60 * 24)
return diffDays > 0 && diffDays <= EXPIRY_SOON_DAYS
}
function getBalanceRemaining(apiKey: AdminApiKey): number {
// 计算剩余余额 = 当前余额 - 已使用余额
if (apiKey.current_balance_usd === null || apiKey.current_balance_usd === undefined) {
return 0
}
const remaining = apiKey.current_balance_usd - (apiKey.balance_used_usd || 0)
return Math.max(0, remaining) // 不能为负数
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
function getRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diff = date.getTime() - now.getTime()
if (diff < 0) return '已过期'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor(diff / (1000 * 60 * 60))
if (days > 0) return `${days}天后过期`
if (hours > 0) return `${hours}小时后过期`
return '即将过期'
}
// ========== 统一表单对话框方法 ==========
// 打开创建对话框
function openCreateDialog() {
editingKeyData.value = null
showKeyFormDialog.value = true
}
// 关闭表单对话框
function closeKeyFormDialog() {
showKeyFormDialog.value = false
editingKeyData.value = null
}
// 统一处理表单提交
async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
keyFormDialogRef.value?.setSaving(true)
try {
if (data.id) {
// 更新
const updateData: Partial<CreateStandaloneApiKeyRequest> = {
name: data.name || undefined,
rate_limit: data.rate_limit,
expire_days: data.never_expire ? null : (data.expire_days || null),
auto_delete_on_expiry: data.auto_delete_on_expiry,
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
}
await adminApi.updateApiKey(data.id, updateData)
success('API Key 更新成功')
} else {
// 创建
if (!data.initial_balance_usd || data.initial_balance_usd <= 0) {
error('初始余额必须大于 0')
return
}
const createData: CreateStandaloneApiKeyRequest = {
name: data.name || undefined,
initial_balance_usd: data.initial_balance_usd,
rate_limit: data.rate_limit,
expire_days: data.never_expire ? null : (data.expire_days || null),
auto_delete_on_expiry: data.auto_delete_on_expiry,
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
}
const response = await adminApi.createStandaloneApiKey(createData)
newKeyValue.value = response.key
showNewKeyDialog.value = true
success('独立 Key 创建成功')
}
closeKeyFormDialog()
await loadApiKeys()
} catch (err: any) {
console.error('保存独立Key失败:', err)
error(err.response?.data?.detail || '保存失败')
} finally {
keyFormDialogRef.value?.setSaving(false)
}
}
</script>

View File

@@ -0,0 +1,564 @@
<template>
<div class="space-y-6 pb-8">
<!-- 审计日志列表 -->
<Card variant="default" class="overflow-hidden">
<!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-base font-semibold">审计日志</h3>
<p class="text-xs text-muted-foreground mt-0.5">查看系统所有操作记录</p>
</div>
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="audit-logs-search"
v-model="searchQuery"
placeholder="搜索用户ID..."
class="w-64 h-8 text-sm pl-8"
@input="handleSearchChange"
/>
</div>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
<!-- 事件类型筛选 -->
<Select
v-model="filters.eventType"
v-model:open="eventTypeSelectOpen"
@update:model-value="handleEventTypeChange"
>
<SelectTrigger class="w-40 h-8 border-border/60">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">全部类型</SelectItem>
<SelectItem value="login_success">登录成功</SelectItem>
<SelectItem value="login_failed">登录失败</SelectItem>
<SelectItem value="logout">退出登录</SelectItem>
<SelectItem value="api_key_created">API密钥创建</SelectItem>
<SelectItem value="api_key_deleted">API密钥删除</SelectItem>
<SelectItem value="request_success">请求成功</SelectItem>
<SelectItem value="request_failed">请求失败</SelectItem>
<SelectItem value="user_created">用户创建</SelectItem>
<SelectItem value="user_updated">用户更新</SelectItem>
<SelectItem value="user_deleted">用户删除</SelectItem>
</SelectContent>
</Select>
<!-- 时间范围筛选 -->
<Select
v-model="filtersDaysString"
v-model:open="daysSelectOpen"
@update:model-value="handleDaysChange"
>
<SelectTrigger class="w-28 h-8 border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="7">7</SelectItem>
<SelectItem value="30">30</SelectItem>
<SelectItem value="90">90</SelectItem>
</SelectContent>
</Select>
<!-- 重置筛选 -->
<Button
v-if="hasActiveFilters"
variant="ghost"
size="icon"
class="h-8 w-8"
@click="handleResetFilters"
title="重置筛选"
>
<FilterX class="w-3.5 h-3.5" />
</Button>
<div class="h-4 w-px bg-border" />
<!-- 导出按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="exportLogs"
title="导出"
>
<Download class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton :loading="loading" @click="refreshLogs" />
</div>
</div>
</div>
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<div v-else-if="logs.length === 0" class="text-center py-12 text-muted-foreground">
暂无审计记录
</div>
<div v-else>
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold">时间</TableHead>
<TableHead class="h-12 font-semibold">用户</TableHead>
<TableHead class="h-12 font-semibold">事件类型</TableHead>
<TableHead class="h-12 font-semibold">描述</TableHead>
<TableHead class="h-12 font-semibold">IP地址</TableHead>
<TableHead class="h-12 font-semibold">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="log in logs" :key="log.id" @mousedown="handleMouseDown" @click="handleRowClick($event, log)" class="cursor-pointer border-b border-border/40 hover:bg-muted/30 transition-colors">
<TableCell class="text-xs py-4">
{{ formatDateTime(log.created_at) }}
</TableCell>
<TableCell class="py-4">
<div v-if="log.user_id" class="flex flex-col">
<span class="text-sm font-medium">
{{ log.user_email || `用户 ${log.user_id}` }}
</span>
<span v-if="log.user_username" class="text-xs text-muted-foreground">
{{ log.user_username }}
</span>
</div>
<span v-else class="text-muted-foreground italic">系统</span>
</TableCell>
<TableCell class="py-4">
<Badge :variant="getEventTypeBadgeVariant(log.event_type)">
<component :is="getEventTypeIcon(log.event_type)" class="h-3 w-3 mr-1" />
{{ getEventTypeLabel(log.event_type) }}
</Badge>
</TableCell>
<TableCell class="max-w-xs truncate py-4" :title="log.description">
{{ log.description || '无描述' }}
</TableCell>
<TableCell class="py-4">
<span v-if="log.ip_address" class="flex items-center text-sm">
<Globe class="h-3 w-3 mr-1 text-muted-foreground" />
{{ log.ip_address }}
</span>
<span v-else>-</span>
</TableCell>
<TableCell class="py-4">
<Badge v-if="log.status_code" :variant="getStatusCodeVariant(log.status_code)">
{{ log.status_code }}
</Badge>
<span v-else>-</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 分页控件 -->
<Pagination
:current="currentPage"
:total="totalRecords"
:page-size="pageSize"
:page-size-options="[10, 20, 50, 100]"
@update:current="handlePageChange"
@update:page-size="pageSize = $event; currentPage = 1; loadLogs()"
/>
</div>
</Card>
<!-- 详情对话框 (使用shadcn Dialog组件) -->
<div v-if="selectedLog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click="closeLogDetail">
<Card class="max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto" @click.stop>
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium">审计日志详情</h3>
<Button variant="ghost" size="sm" @click="closeLogDetail">
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-4">
<div>
<Label>事件类型</Label>
<p class="mt-1 text-sm">{{ getEventTypeLabel(selectedLog.event_type) }}</p>
</div>
<Separator />
<div>
<Label>描述</Label>
<p class="mt-1 text-sm">{{ selectedLog.description || '无描述' }}</p>
</div>
<div>
<Label>时间</Label>
<p class="mt-1 text-sm">{{ formatDateTime(selectedLog.created_at) }}</p>
</div>
<div v-if="selectedLog.user_id">
<Label>用户信息</Label>
<div class="mt-1 text-sm">
<p class="font-medium">{{ selectedLog.user_email || `用户 ${selectedLog.user_id}` }}</p>
<p v-if="selectedLog.user_username" class="text-muted-foreground">{{ selectedLog.user_username }}</p>
<p class="text-xs text-muted-foreground">ID: {{ selectedLog.user_id }}</p>
</div>
</div>
<div v-if="selectedLog.ip_address">
<Label>IP地址</Label>
<p class="mt-1 text-sm">{{ selectedLog.ip_address }}</p>
</div>
<div v-if="selectedLog.status_code">
<Label>状态码</Label>
<p class="mt-1 text-sm">{{ selectedLog.status_code }}</p>
</div>
<div v-if="selectedLog.error_message">
<Label>错误消息</Label>
<p class="mt-1 text-sm text-destructive">{{ selectedLog.error_message }}</p>
</div>
<div v-if="selectedLog.metadata">
<Label>元数据</Label>
<pre class="mt-1 text-sm bg-muted p-3 rounded-md overflow-x-auto">{{ JSON.stringify(selectedLog.metadata, null, 2) }}</pre>
</div>
</div>
</div>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import {
Card,
Button,
Badge,
Separator,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Input,
Pagination,
RefreshButton
} from '@/components/ui'
import { auditApi } from '@/api/audit'
import {
Download,
Shield,
Key,
Activity,
AlertTriangle,
CheckCircle,
XCircle,
Globe,
X,
User,
Settings,
Search,
FilterX
} from 'lucide-vue-next'
interface AuditLog {
id: string
event_type: string
user_id?: number
user_email?: string
user_username?: string
description: string
ip_address?: string
status_code?: number
error_message?: string
metadata?: any
created_at: string
}
const loading = ref(false)
const logs = ref<AuditLog[]>([])
const selectedLog = ref<AuditLog | null>(null)
// 搜索查询
const searchQuery = ref('')
// Select open state
const eventTypeSelectOpen = ref(false)
const daysSelectOpen = ref(false)
const filters = ref({
userId: '',
eventType: '__all__',
days: 7,
limit: 50
})
const filtersDaysString = ref('7')
const filtersLimitString = ref('50')
const currentPage = ref(1)
const pageSize = ref(20)
const totalRecords = ref(0)
let loadTimeout: number
const debouncedLoadLogs = () => {
clearTimeout(loadTimeout)
loadTimeout = window.setTimeout(resetAndLoad, 500)
}
const hasActiveFilters = computed(() => {
return searchQuery.value !== '' ||
filters.value.eventType !== '__all__' ||
filters.value.days !== 7
})
async function loadLogs() {
loading.value = true
try {
const offset = (currentPage.value - 1) * pageSize.value
const filterParams = {
user_id: filters.value.userId || undefined,
event_type: (filters.value.eventType !== '__all__' ? filters.value.eventType : undefined),
days: filters.value.days,
limit: pageSize.value,
offset: offset
}
const data = await auditApi.getAuditLogs(filterParams)
logs.value = data.items || []
totalRecords.value = data.meta?.total ?? logs.value.length
} catch (error) {
console.error('获取审计日志失败:', error)
logs.value = []
totalRecords.value = 0
} finally {
loading.value = false
}
}
function refreshLogs() {
loadLogs()
}
function clearFilters() {
filters.value = {
userId: '',
eventType: '__all__',
days: 7,
limit: 50
}
filtersDaysString.value = '7'
filtersLimitString.value = '50'
currentPage.value = 1
loadLogs()
}
// 搜索变化处理
function handleSearchChange() {
filters.value.userId = searchQuery.value
debouncedLoadLogs()
}
// 重置筛选条件
function handleResetFilters() {
searchQuery.value = ''
filters.value.userId = ''
filters.value.eventType = '__all__'
filters.value.days = 7
filtersDaysString.value = '7'
currentPage.value = 1
loadLogs()
}
// 页码变化处理
function handlePageChange(page: number) {
currentPage.value = page
loadLogs()
}
function handleEventTypeChange(value: string) {
filters.value.eventType = value
resetAndLoad()
}
function handleDaysChange(value: string) {
filtersDaysString.value = value
filters.value.days = parseInt(value)
resetAndLoad()
}
function handleLimitChange(value: string) {
filtersLimitString.value = value
filters.value.limit = parseInt(value)
loadLogs()
}
function resetAndLoad() {
currentPage.value = 1
loadLogs()
}
async function exportLogs() {
try {
let allLogs: AuditLog[] = []
let offset = 0
const batchSize = 500
let hasMore = true
while (hasMore) {
const data = await auditApi.getAuditLogs({
user_id: filters.value.userId || undefined,
event_type: filters.value.eventType !== '__all__' ? filters.value.eventType : undefined,
days: filters.value.days,
limit: batchSize,
offset
})
const batch = data.items || []
allLogs = allLogs.concat(batch)
if (batch.length < batchSize) {
hasMore = false
} else {
offset += batch.length
hasMore = offset < (data.meta?.total ?? offset)
}
}
const csvContent = [
['时间', '用户邮箱', '用户名', '用户ID', '事件类型', '描述', 'IP地址', '状态码', '错误消息'].join(','),
...allLogs.map((log: AuditLog) => [
log.created_at,
`"${log.user_email || ''}"`,
`"${log.user_username || ''}"`,
log.user_id || '',
log.event_type,
`"${log.description || ''}"`,
log.ip_address || '',
log.status_code || '',
`"${log.error_message || ''}"`
].join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`
link.click()
} catch (error) {
console.error('导出失败:', error)
}
}
// 使用复用的行点击逻辑
import { useRowClick } from '@/composables/useRowClick'
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
function handleRowClick(event: MouseEvent, log: AuditLog) {
if (!shouldTriggerRowClick(event)) return
showLogDetail(log)
}
function showLogDetail(log: AuditLog) {
selectedLog.value = log
}
function closeLogDetail() {
selectedLog.value = null
}
function getEventTypeLabel(eventType: string): string {
const labels: Record<string, string> = {
'login_success': '登录成功',
'login_failed': '登录失败',
'logout': '退出登录',
'api_key_created': 'API密钥创建',
'api_key_deleted': 'API密钥删除',
'api_key_used': 'API密钥使用',
'request_success': '请求成功',
'request_failed': '请求失败',
'request_rate_limited': '请求限流',
'request_quota_exceeded': '配额超出',
'user_created': '用户创建',
'user_updated': '用户更新',
'user_deleted': '用户删除',
'provider_added': '提供商添加',
'provider_updated': '提供商更新',
'provider_removed': '提供商删除',
'suspicious_activity': '可疑活动',
'unauthorized_access': '未授权访问',
'data_export': '数据导出',
'config_changed': '配置变更'
}
return labels[eventType] || eventType
}
function getEventTypeIcon(eventType: string) {
const icons: Record<string, any> = {
'login_success': CheckCircle,
'login_failed': XCircle,
'logout': User,
'api_key_created': Key,
'api_key_deleted': Key,
'api_key_used': Key,
'request_success': CheckCircle,
'request_failed': XCircle,
'request_rate_limited': AlertTriangle,
'request_quota_exceeded': AlertTriangle,
'user_created': User,
'user_updated': User,
'user_deleted': User,
'provider_added': Settings,
'provider_updated': Settings,
'provider_removed': Settings,
'suspicious_activity': Shield,
'unauthorized_access': Shield,
'data_export': Activity,
'config_changed': Settings
}
return icons[eventType] || Activity
}
function getEventTypeBadgeVariant(eventType: string): 'default' | 'success' | 'destructive' | 'warning' | 'secondary' {
if (eventType.includes('success') || eventType.includes('created')) return 'success'
if (eventType.includes('failed') || eventType.includes('deleted') || eventType.includes('unauthorized')) return 'destructive'
if (eventType.includes('limited') || eventType.includes('exceeded') || eventType.includes('suspicious')) return 'warning'
return 'secondary'
}
function getStatusCodeVariant(statusCode: number): 'default' | 'success' | 'destructive' | 'warning' {
if (statusCode < 300) return 'success'
if (statusCode < 400) return 'default'
if (statusCode < 500) return 'warning'
return 'destructive'
}
function formatDateTime(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
onMounted(() => {
loadLogs()
})
</script>

View File

@@ -0,0 +1,443 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, onBeforeUnmount } from 'vue'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Table from '@/components/ui/table.vue'
import TableBody from '@/components/ui/table-body.vue'
import TableCell from '@/components/ui/table-cell.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableHeader from '@/components/ui/table-header.vue'
import TableRow from '@/components/ui/table-row.vue'
import Input from '@/components/ui/input.vue'
import Pagination from '@/components/ui/pagination.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { Trash2, Eraser, Search, X } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { cacheApi, type CacheStats, type CacheConfig, type UserAffinity } from '@/api/cache'
const stats = ref<CacheStats | null>(null)
const config = ref<CacheConfig | null>(null)
const loading = ref(false)
const affinityList = ref<UserAffinity[]>([])
const listLoading = ref(false)
const tableKeyword = ref('')
const matchedUserId = ref<string | null>(null)
const clearingRowAffinityKey = ref<string | null>(null)
const currentPage = ref(1)
const pageSize = ref(20)
const { success: showSuccess, error: showError, info: showInfo } = useToast()
const { confirm: showConfirm } = useConfirm()
const currentTime = ref(Math.floor(Date.now() / 1000))
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let skipNextKeywordWatch = false
let countdownTimer: ReturnType<typeof setInterval> | null = null
// 计算分页后的数据
const paginatedAffinityList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return affinityList.value.slice(start, end)
})
// 页码变化处理
function handlePageChange() {
// 分页变化时滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 获取缓存统计
async function fetchCacheStats() {
loading.value = true
try {
stats.value = await cacheApi.getStats()
} catch (error) {
showError('获取缓存统计失败')
console.error(error)
} finally {
loading.value = false
}
}
// 获取缓存配置
async function fetchCacheConfig() {
try {
config.value = await cacheApi.getConfig()
} catch (error) {
console.error(error)
}
}
// 获取缓存亲和性列表
async function fetchAffinityList(keyword?: string) {
listLoading.value = true
try {
const response = await cacheApi.listAffinities(keyword)
affinityList.value = response.items
matchedUserId.value = response.matched_user_id ?? null
if (keyword && response.total === 0) {
showInfo('未找到匹配的缓存记录')
}
} catch (error) {
showError('获取缓存列表失败')
console.error(error)
} finally {
listLoading.value = false
}
}
async function resetAffinitySearch() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
if (!tableKeyword.value) {
currentPage.value = 1
await fetchAffinityList()
return
}
skipNextKeywordWatch = true
tableKeyword.value = ''
currentPage.value = 1
await fetchAffinityList()
}
// 清除缓存(按 affinity_key 或用户标识符)
async function clearUserCache(identifier: string, displayName?: string) {
const target = identifier?.trim()
if (!target) {
showError('无法识别标识符')
return
}
const label = displayName || target
const confirmed = await showConfirm({
title: '确认清除',
message: `确定要清除 ${label} 的缓存吗?`,
confirmText: '确认清除',
variant: 'destructive'
})
if (!confirmed) {
return
}
clearingRowAffinityKey.value = target
try {
await cacheApi.clearUserCache(target)
showSuccess('清除成功')
await fetchCacheStats()
await fetchAffinityList(tableKeyword.value.trim() || undefined)
} catch (error) {
showError('清除失败')
console.error(error)
} finally {
clearingRowAffinityKey.value = null
}
}
// 清除所有缓存
async function clearAllCache() {
const firstConfirm = await showConfirm({
title: '危险操作',
message: '警告:此操作会清除所有用户的缓存亲和性,确定继续吗?',
confirmText: '继续',
variant: 'destructive'
})
if (!firstConfirm) {
return
}
const secondConfirm = await showConfirm({
title: '再次确认',
message: '这将影响所有用户,请再次确认!',
confirmText: '确认清除',
variant: 'destructive'
})
if (!secondConfirm) {
return
}
try {
await cacheApi.clearAllCache()
showSuccess('已清除所有缓存')
await fetchCacheStats()
await fetchAffinityList(tableKeyword.value.trim() || undefined)
} catch (error) {
showError('清除失败')
console.error(error)
}
}
// 计算剩余时间(使用实时更新的 currentTime
function getRemainingTime(expireAt?: number) {
if (!expireAt) return '未知'
const remaining = expireAt - currentTime.value
if (remaining <= 0) return '已过期'
const minutes = Math.floor(remaining / 60)
const seconds = Math.floor(remaining % 60)
return `${minutes}${seconds}`
}
// 启动倒计时定时器
function startCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
}
countdownTimer = setInterval(() => {
currentTime.value = Math.floor(Date.now() / 1000)
// 过滤掉已过期的项目
const beforeCount = affinityList.value.length
affinityList.value = affinityList.value.filter(item => {
return item.expire_at && item.expire_at > currentTime.value
})
// 如果有项目被移除,显示提示
if (beforeCount > affinityList.value.length) {
const removedCount = beforeCount - affinityList.value.length
showInfo(`${removedCount} 个缓存已自动过期移除`)
}
}, 1000)
}
// 停止倒计时定时器
function stopCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
watch(tableKeyword, (value) => {
if (skipNextKeywordWatch) {
skipNextKeywordWatch = false
return
}
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
}
const keyword = value.trim()
searchDebounceTimer = setTimeout(() => {
fetchAffinityList(keyword || undefined)
searchDebounceTimer = null
}, 600)
})
onMounted(() => {
fetchCacheStats()
fetchCacheConfig()
fetchAffinityList()
startCountdown()
})
// 刷新所有数据
async function refreshData() {
await Promise.all([
fetchCacheStats(),
fetchCacheConfig(),
fetchAffinityList()
])
}
onBeforeUnmount(() => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
}
stopCountdown()
})
</script>
<template>
<div class="space-y-6">
<!-- 标题 -->
<div>
<h2 class="text-2xl font-bold">缓存监控</h2>
<p class="text-sm text-muted-foreground mt-1">
管理缓存亲和性提高 Prompt Caching 命中率
</p>
</div>
<!-- 核心指标 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<!-- 缓存命中率 -->
<Card class="p-4">
<div class="text-xs text-muted-foreground">命中率</div>
<div class="text-2xl font-bold text-success mt-1">
{{ stats ? (stats.affinity_stats.cache_hit_rate * 100).toFixed(1) : '0.0' }}%
</div>
<div class="text-xs text-muted-foreground mt-1">
{{ stats?.affinity_stats?.cache_hits || 0 }} / {{ (stats?.affinity_stats?.cache_hits || 0) + (stats?.affinity_stats?.cache_misses || 0) }}
</div>
</Card>
<!-- 活跃缓存数 -->
<Card class="p-4">
<div class="text-xs text-muted-foreground">活跃缓存</div>
<div class="text-2xl font-bold mt-1">
{{ stats?.affinity_stats?.total_affinities || 0 }}
</div>
<div class="text-xs text-muted-foreground mt-1">
TTL {{ config?.cache_ttl_seconds || 300 }}s
</div>
</Card>
<!-- Provider切换 -->
<Card class="p-4">
<div class="text-xs text-muted-foreground">Provider 切换</div>
<div class="text-2xl font-bold mt-1" :class="(stats?.affinity_stats?.provider_switches || 0) > 0 ? 'text-destructive' : ''">
{{ stats?.affinity_stats?.provider_switches || 0 }}
</div>
<div class="text-xs text-muted-foreground mt-1">
Key 切换 {{ stats?.affinity_stats?.key_switches || 0 }}
</div>
</Card>
<!-- 预留比例 -->
<Card class="p-4">
<div class="text-xs text-muted-foreground flex items-center gap-1">
预留比例
<Badge v-if="config?.dynamic_reservation?.enabled" variant="outline" class="text-[10px] px-1">动态</Badge>
</div>
<div class="text-2xl font-bold mt-1">
<template v-if="config?.dynamic_reservation?.enabled">
{{ (config.dynamic_reservation.config.stable_min_reservation * 100).toFixed(0) }}-{{ (config.dynamic_reservation.config.stable_max_reservation * 100).toFixed(0) }}%
</template>
<template v-else>
{{ config ? (config.cache_reservation_ratio * 100).toFixed(0) : '30' }}%
</template>
</div>
<div class="text-xs text-muted-foreground mt-1">
失效 {{ stats?.affinity_stats?.cache_invalidations || 0 }}
</div>
</Card>
</div>
<!-- 缓存亲和性列表 -->
<Card class="overflow-hidden">
<!-- 标题和操作栏 -->
<div class="px-6 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold">亲和性列表</h3>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="cache-affinity-search"
v-model="tableKeyword"
placeholder="搜索用户或 Key"
class="w-48 h-8 text-sm pl-8 pr-8"
/>
<button
v-if="tableKeyword"
type="button"
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10"
@click="resetAffinitySearch"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
<div class="h-4 w-px bg-border" />
<Button @click="clearAllCache" variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground/70 hover:text-destructive" title="清除全部缓存">
<Eraser class="h-4 w-4" />
</Button>
<RefreshButton :loading="loading || listLoading" @click="refreshData" />
</div>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-28">用户</TableHead>
<TableHead class="w-36">Key</TableHead>
<TableHead class="w-28">Provider</TableHead>
<TableHead class="w-40">模型</TableHead>
<TableHead class="w-36">API 格式 / Key</TableHead>
<TableHead class="w-20 text-center">剩余</TableHead>
<TableHead class="w-14 text-center">次数</TableHead>
<TableHead class="w-12 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="!listLoading && affinityList.length">
<TableRow v-for="item in paginatedAffinityList" :key="`${item.affinity_key}-${item.endpoint_id}-${item.key_id}`">
<TableCell>
<div class="flex items-center gap-1.5">
<Badge v-if="item.is_standalone" variant="outline" class="text-warning border-warning/30 text-[10px] px-1">独立</Badge>
<span class="text-sm font-medium truncate max-w-[90px]" :title="item.username ?? undefined">{{ item.username || '未知' }}</span>
</div>
</TableCell>
<TableCell>
<div class="flex items-center gap-1.5">
<span class="text-sm truncate max-w-[100px]" :title="item.user_api_key_name || undefined">{{ item.user_api_key_name || '未命名' }}</span>
<Badge v-if="item.rate_multiplier !== 1.0" variant="outline" class="text-warning border-warning/30 text-[10px] px-2">{{ item.rate_multiplier }}x</Badge>
</div>
<div class="text-xs text-muted-foreground font-mono">{{ item.user_api_key_prefix || '---' }}</div>
</TableCell>
<TableCell>
<div class="text-sm truncate max-w-[100px]" :title="item.provider_name || undefined">{{ item.provider_name || '未知' }}</div>
</TableCell>
<TableCell>
<div class="text-sm truncate max-w-[150px]" :title="item.model_display_name || undefined">{{ item.model_display_name || '---' }}</div>
<div class="text-xs text-muted-foreground" :title="item.model_name || undefined">{{ item.model_name || '---' }}</div>
</TableCell>
<TableCell>
<div class="text-sm">{{ item.endpoint_api_format || '---' }}</div>
<div class="text-xs text-muted-foreground font-mono">{{ item.key_prefix || '---' }}</div>
</TableCell>
<TableCell class="text-center">
<span class="text-xs">{{ getRemainingTime(item.expire_at) }}</span>
</TableCell>
<TableCell class="text-center">
<span class="text-sm">{{ item.request_count }}</span>
</TableCell>
<TableCell class="text-right">
<Button
size="icon"
variant="ghost"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
:disabled="clearingRowAffinityKey === item.affinity_key"
title="清除缓存"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
</TableBody>
<TableBody v-else>
<TableRow>
<TableCell colspan="8" class="text-center py-6 text-sm text-muted-foreground">
{{ listLoading ? '加载中...' : '暂无缓存记录' }}
</TableCell>
</TableRow>
</TableBody>
</Table>
<Pagination
v-if="affinityList.length > 0"
:current="currentPage"
:total="affinityList.length"
:page-size="pageSize"
@update:current="currentPage = $event; handlePageChange()"
@update:page-size="pageSize = $event"
/>
</Card>
</div>
</template>

View File

@@ -0,0 +1,371 @@
<template>
<div class="space-y-6 pb-8">
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">黑名单 IP 数量</p>
<h3 class="text-2xl font-bold mt-2">{{ blacklistStats.total || 0 }}</h3>
</div>
<div class="h-12 w-12 rounded-full bg-destructive/10 flex items-center justify-center">
<ShieldX class="h-6 w-6 text-destructive" />
</div>
</div>
</div>
</Card>
<Card>
<div class="p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">白名单 IP 数量</p>
<h3 class="text-2xl font-bold mt-2">{{ whitelistData.total || 0 }}</h3>
</div>
<div class="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<ShieldCheck class="h-6 w-6 text-primary" />
</div>
</div>
</div>
</Card>
</div>
<!-- IP 黑名单管理 -->
<Card variant="default" class="overflow-hidden">
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-base font-semibold">IP 黑名单</h3>
<p class="text-xs text-muted-foreground mt-0.5">管理被禁止访问的 IP 地址</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="showAddBlacklistDialog = true"
title="添加黑名单"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loadingBlacklist" @click="loadBlacklistStats" />
</div>
</div>
</div>
<div v-if="loadingBlacklist" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<div v-else class="p-6">
<div v-if="!blacklistStats.available" class="text-center py-8 text-muted-foreground">
<AlertCircle class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Redis 不可用无法管理黑名单</p>
<p class="text-xs mt-1">{{ blacklistStats.error }}</p>
</div>
<div v-else-if="blacklistStats.total === 0" class="text-center py-8 text-muted-foreground">
<ShieldX class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>暂无黑名单 IP</p>
</div>
<div v-else class="text-sm text-muted-foreground">
当前共有 <span class="font-semibold text-foreground">{{ blacklistStats.total }}</span> IP 在黑名单中
</div>
</div>
</Card>
<!-- IP 白名单管理 -->
<Card variant="default" class="overflow-hidden">
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-base font-semibold">IP 白名单</h3>
<p class="text-xs text-muted-foreground mt-0.5">管理可信任的 IP 地址支持 CIDR 格式</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="showAddWhitelistDialog = true"
title="添加白名单"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loadingWhitelist" @click="loadWhitelist" />
</div>
</div>
</div>
<div v-if="loadingWhitelist" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<div v-else-if="whitelistData.whitelist.length === 0" class="text-center py-12 text-muted-foreground">
<ShieldCheck class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>暂无白名单 IP</p>
</div>
<div v-else>
<Table>
<TableHeader>
<TableRow>
<TableHead>IP 地址 / CIDR</TableHead>
<TableHead class="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="ip in whitelistData.whitelist" :key="ip">
<TableCell class="font-mono text-sm">{{ ip }}</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
@click="handleRemoveFromWhitelist(ip)"
class="h-8 px-3"
>
<Trash2 class="w-4 h-4 mr-1.5" />
移除
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</Card>
<!-- 添加黑名单对话框 -->
<Dialog v-model:open="showAddBlacklistDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>添加 IP 到黑名单</DialogTitle>
<DialogDescription>
被加入黑名单的 IP 将无法访问任何接口
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium">IP 地址</label>
<Input
v-model="blacklistForm.ip_address"
placeholder="例如: 192.168.1.100"
class="font-mono"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">原因</label>
<Input
v-model="blacklistForm.reason"
placeholder="加入黑名单的原因"
maxlength="200"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium">过期时间可选</label>
<Input
v-model.number="blacklistForm.ttl"
type="number"
placeholder="留空表示永久,单位:秒"
min="1"
/>
<p class="text-xs text-muted-foreground">
留空表示永久封禁或输入秒数 3600 表示 1 小时
</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" @click="showAddBlacklistDialog = false">取消</Button>
<Button
variant="destructive"
@click="handleAddToBlacklist"
:disabled="!blacklistForm.ip_address || !blacklistForm.reason"
>
添加到黑名单
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 添加白名单对话框 -->
<Dialog v-model:open="showAddWhitelistDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>添加 IP 到白名单</DialogTitle>
<DialogDescription>
白名单中的 IP 不受速率限制
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium">IP 地址或 CIDR</label>
<Input
v-model="whitelistForm.ip_address"
placeholder="例如: 192.168.1.0/24 或 192.168.1.100"
class="font-mono"
/>
<p class="text-xs text-muted-foreground">
支持单个 IP CIDR 网段格式
</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" @click="showAddWhitelistDialog = false">取消</Button>
<Button
@click="handleAddToWhitelist"
:disabled="!whitelistForm.ip_address"
>
添加到白名单
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus, Trash2, ShieldX, ShieldCheck, AlertCircle } from 'lucide-vue-next'
import {
Card,
Button,
Input,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
RefreshButton
} from '@/components/ui'
import { blacklistApi, whitelistApi, type BlacklistStats, type WhitelistResponse } from '@/api/security'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
const { success, error } = useToast()
const { confirmDanger } = useConfirm()
// 黑名单状态
const loadingBlacklist = ref(false)
const blacklistStats = ref<BlacklistStats>({
available: false,
total: 0
})
const showAddBlacklistDialog = ref(false)
const blacklistForm = ref({
ip_address: '',
reason: '',
ttl: undefined as number | undefined
})
// 白名单状态
const loadingWhitelist = ref(false)
const whitelistData = ref<WhitelistResponse>({
whitelist: [],
total: 0
})
const showAddWhitelistDialog = ref(false)
const whitelistForm = ref({
ip_address: ''
})
/**
* 加载黑名单统计
*/
async function loadBlacklistStats() {
loadingBlacklist.value = true
try {
blacklistStats.value = await blacklistApi.getStats()
} catch (err: any) {
error(err.response?.data?.detail || '无法获取黑名单统计')
} finally {
loadingBlacklist.value = false
}
}
/**
* 加载白名单列表
*/
async function loadWhitelist() {
loadingWhitelist.value = true
try {
whitelistData.value = await whitelistApi.getList()
} catch (err: any) {
error(err.response?.data?.detail || '无法获取白名单列表')
} finally {
loadingWhitelist.value = false
}
}
/**
* 添加 IP 到黑名单
*/
async function handleAddToBlacklist() {
try {
await blacklistApi.add({
ip_address: blacklistForm.value.ip_address,
reason: blacklistForm.value.reason,
ttl: blacklistForm.value.ttl
})
success(`IP ${blacklistForm.value.ip_address} 已加入黑名单`)
showAddBlacklistDialog.value = false
blacklistForm.value = { ip_address: '', reason: '', ttl: undefined }
await loadBlacklistStats()
} catch (err: any) {
error(err.response?.data?.detail || '无法添加 IP 到黑名单')
}
}
/**
* 添加 IP 到白名单
*/
async function handleAddToWhitelist() {
try {
await whitelistApi.add({
ip_address: whitelistForm.value.ip_address
})
success(`IP ${whitelistForm.value.ip_address} 已加入白名单`)
showAddWhitelistDialog.value = false
whitelistForm.value = { ip_address: '' }
await loadWhitelist()
} catch (err: any) {
error(err.response?.data?.detail || '无法添加 IP 到白名单')
}
}
/**
* 从白名单移除 IP
*/
async function handleRemoveFromWhitelist(ip: string) {
const confirmed = await confirmDanger(
`确定要从白名单移除 ${ip} 吗?\n\n此操作无法撤销。`,
'移除白名单'
)
if (!confirmed) return
try {
await whitelistApi.remove(ip)
success(`IP ${ip} 已从白名单移除`)
await loadWhitelist()
} catch (err: any) {
error(err.response?.data?.detail || '无法从白名单移除 IP')
}
}
onMounted(() => {
loadBlacklistStats()
loadWhitelist()
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,561 @@
<template>
<div class="space-y-4">
<!-- 提供商表格 -->
<Card variant="default" class="overflow-hidden">
<!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/50">
<div class="flex items-center justify-between gap-4">
<!-- 左侧标题 -->
<h3 class="text-base font-semibold text-foreground">提供商管理</h3>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" />
<Input
id="provider-search"
v-model="searchQuery"
type="text"
placeholder="搜索提供商..."
class="w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
/>
</div>
<div class="h-4 w-px bg-border" />
<!-- 调度策略 -->
<button
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
@click="openPriorityDialog"
title="点击调整调度策略"
>
<span class="text-muted-foreground/80">调度:</span>
<span class="font-medium text-foreground/90">{{ priorityModeConfig.label }}</span>
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
</button>
<div class="h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openAddProviderDialog"
title="新增提供商"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton :loading="loading" @click="loadProviders" />
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- 空状态 -->
<div v-else-if="filteredProviders.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
<div class="text-muted-foreground mb-2">
<template v-if="searchQuery">
未找到匹配 "{{ searchQuery }}" 的提供商
</template>
<template v-else>
暂无提供商点击右上角添加
</template>
</div>
<Button v-if="searchQuery" variant="outline" size="sm" @click="searchQuery = ''">
清除搜索
</Button>
</div>
<!-- 桌面端表格 -->
<div v-else class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow class="border-b border-border/40 hover:bg-transparent">
<TableHead class="w-[150px] h-11 font-medium text-foreground/80">提供商信息</TableHead>
<TableHead class="w-[100px] h-11 font-medium text-foreground/80">计费类型</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80">官网</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">资源统计</TableHead>
<TableHead class="w-[240px] h-11 font-medium text-foreground/80">端点健康</TableHead>
<TableHead class="w-[140px] h-11 font-medium text-foreground/80">配额/限流</TableHead>
<TableHead class="w-[80px] h-11 font-medium text-foreground/80 text-center">状态</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="provider in paginatedProviders"
:key="provider.id"
class="border-b border-border/30 hover:bg-muted/20 transition-colors cursor-pointer"
@mousedown="handleMouseDown"
@click="handleRowClick($event, provider.id)"
>
<TableCell class="py-3.5">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-foreground">{{ provider.display_name }}</span>
<span class="text-xs text-muted-foreground/70 font-mono">{{ provider.name }}</span>
</div>
</TableCell>
<TableCell class="py-3.5">
<Badge variant="outline" class="text-xs font-normal border-border/50">
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
</TableCell>
<TableCell class="py-3.5">
<a
v-if="provider.website"
:href="provider.website"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary/80 hover:text-primary hover:underline truncate block max-w-[100px]"
@click.stop
:title="provider.website"
>
{{ formatWebsiteDisplay(provider.website) }}
</a>
<span v-else class="text-xs text-muted-foreground/50">-</span>
</TableCell>
<TableCell class="py-3.5 text-center">
<div class="space-y-0.5 text-xs">
<div class="flex items-center justify-center gap-1.5">
<span class="text-muted-foreground/70">端点:</span>
<span class="font-medium text-foreground/90">{{ provider.active_endpoints }}</span>
<span class="text-muted-foreground/50">/{{ provider.total_endpoints }}</span>
</div>
<div class="flex items-center justify-center gap-1.5">
<span class="text-muted-foreground/70">密钥:</span>
<span class="font-medium text-foreground/90">{{ provider.active_keys }}</span>
<span class="text-muted-foreground/50">/{{ provider.total_keys }}</span>
</div>
<div class="flex items-center justify-center gap-1.5">
<span class="text-muted-foreground/70">模型:</span>
<span class="font-medium text-foreground/90">{{ provider.active_models }}</span>
<span class="text-muted-foreground/50">/{{ provider.total_models }}</span>
</div>
</div>
</TableCell>
<TableCell class="py-3.5 align-middle">
<div
v-if="provider.endpoint_health_details && provider.endpoint_health_details.length > 0"
class="flex flex-wrap gap-1.5 max-w-[280px]"
>
<span
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
:key="endpoint.api_format"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md border text-[10px] font-medium tracking-wide uppercase leading-none"
:class="getEndpointTagClass(endpoint, provider)"
:title="getEndpointTooltip(endpoint, provider)"
>
<span
class="w-1.5 h-1.5 rounded-full"
:class="getEndpointDotColor(endpoint, provider)"
></span>
{{ endpoint.api_format }}
</span>
</div>
<span v-else class="text-xs text-muted-foreground/50">暂无端点</span>
</TableCell>
<TableCell class="py-3.5">
<div class="space-y-0.5 text-xs">
<div v-if="provider.billing_type === 'monthly_quota'" class="text-muted-foreground/70">
配额: <span class="font-semibold" :class="getQuotaUsedColorClass(provider)">${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
</div>
<div v-if="rpmUsage(provider)" class="flex items-center gap-1">
<span class="text-muted-foreground/70">RPM:</span>
<span class="font-medium text-foreground/80">{{ rpmUsage(provider) }}</span>
</div>
<div v-if="provider.billing_type !== 'monthly_quota' && !rpmUsage(provider)" class="text-muted-foreground/50">
无限制
</div>
</div>
</TableCell>
<TableCell class="py-3.5 text-center">
<Badge :variant="provider.is_active ? 'success' : 'secondary'" class="text-xs">
{{ provider.is_active ? '活跃' : '已停用' }}
</Badge>
</TableCell>
<TableCell class="py-3.5" @click.stop>
<div class="flex items-center justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
@click="openProviderDrawer(provider.id)"
title="查看详情"
>
<Eye class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
@click="openEditProviderDialog(provider)"
title="编辑提供商"
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
@click="toggleProviderStatus(provider)"
:title="provider.is_active ? '停用提供商' : '启用提供商'"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
@click="handleDeleteProvider(provider)"
title="删除提供商"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 分页 -->
<Pagination
v-if="!loading && filteredProviders.length > 0"
:current="currentPage"
:total="filteredProviders.length"
:page-size="pageSize"
@update:current="currentPage = $event"
@update:page-size="pageSize = $event"
/>
</Card>
</div>
<!-- 对话框 -->
<ProviderFormDialog
v-model="providerDialogOpen"
:provider="providerToEdit"
@provider-created="handleProviderAdded"
@provider-updated="handleProviderUpdated"
/>
<PriorityManagementDialog
v-model="priorityDialogOpen"
:providers="providers"
@saved="handlePrioritySaved"
/>
<ProviderDetailDrawer
:open="providerDrawerOpen"
:provider-id="selectedProviderId"
@update:open="providerDrawerOpen = $event"
@edit="openEditProviderDialog"
@toggle-status="toggleProviderStatus"
@refresh="loadProviders"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import {
Plus,
Search,
Edit,
Eye,
Trash2,
ChevronDown,
Power
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Card from '@/components/ui/card.vue'
import Input from '@/components/ui/input.vue'
import Table from '@/components/ui/table.vue'
import TableHeader from '@/components/ui/table-header.vue'
import TableBody from '@/components/ui/table-body.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import Pagination from '@/components/ui/pagination.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { ProviderFormDialog, PriorityManagementDialog } from '@/features/providers/components'
import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useRowClick } from '@/composables/useRowClick'
import {
getProvidersSummary,
deleteProvider,
updateProvider,
type ProviderWithEndpointsSummary
} from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import { formatBillingType } from '@/utils/format'
const { error: showError, success: showSuccess } = useToast()
const { confirmDanger } = useConfirm()
// 状态
const loading = ref(false)
const providers = ref<ProviderWithEndpointsSummary[]>([])
const providerDialogOpen = ref(false)
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
const priorityDialogOpen = ref(false)
const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false)
const selectedProviderId = ref<string | null>(null)
// 搜索
const searchQuery = ref('')
// 分页
const currentPage = ref(1)
const pageSize = ref(20)
// 优先级模式配置
const priorityModeConfig = computed(() => {
return {
label: priorityMode.value === 'global_key' ? '全局 Key 优先' : '提供商优先'
}
})
// 筛选后的提供商列表
const filteredProviders = computed(() => {
let result = [...providers.value]
// 搜索筛选
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase()
result = result.filter(p =>
p.display_name.toLowerCase().includes(query) ||
p.name.toLowerCase().includes(query)
)
}
// 排序
return result.sort((a, b) => {
// 1. 优先显示活跃的提供商
if (a.is_active !== b.is_active) {
return a.is_active ? -1 : 1
}
// 2. 按优先级排序
if (a.provider_priority !== b.provider_priority) {
return a.provider_priority - b.provider_priority
}
// 3. 按名称排序
return a.display_name.localeCompare(b.display_name)
})
})
// 分页
const paginatedProviders = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredProviders.value.slice(start, end)
})
// 搜索时重置分页
watch(searchQuery, () => {
currentPage.value = 1
})
// 加载优先级模式
async function loadPriorityMode() {
try {
const response = await adminApi.getSystemConfig('provider_priority_mode')
if (response.value) {
priorityMode.value = response.value as 'provider' | 'global_key'
}
} catch {
priorityMode.value = 'provider'
}
}
// 加载提供商列表
async function loadProviders() {
loading.value = true
try {
providers.value = await getProvidersSummary()
} catch (err: any) {
showError(err.response?.data?.detail || '加载提供商列表失败', '错误')
} finally {
loading.value = false
}
}
// 格式化官网显示
function formatWebsiteDisplay(url: string): string {
try {
const urlObj = new URL(url)
return urlObj.hostname.replace(/^www\./, '')
} catch {
return url
}
}
// 端点排序
function sortEndpoints(endpoints: any[]) {
return [...endpoints].sort((a, b) => {
const order = ['CLAUDE', 'OPENAI', 'CLAUDE_COMPATIBLE', 'OPENAI_COMPATIBLE', 'GEMINI', 'GEMINI_COMPATIBLE']
return order.indexOf(a.api_format) - order.indexOf(b.api_format)
})
}
// 判断端点是否可用(有 key
function isEndpointAvailable(endpoint: any, _provider: ProviderWithEndpointsSummary): boolean {
// 检查该端点是否有活跃的密钥
return (endpoint.active_keys ?? 0) > 0
}
// 端点标签样式
function getEndpointTagClass(endpoint: any, provider: ProviderWithEndpointsSummary): string {
if (!isEndpointAvailable(endpoint, provider)) {
return 'border-red-300/50 bg-red-50/50 text-red-600/80 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-400/80'
}
return 'border-border/40 bg-muted/20 text-foreground/70'
}
// 端点圆点颜色
function getEndpointDotColor(endpoint: any, provider: ProviderWithEndpointsSummary): string {
if (!isEndpointAvailable(endpoint, provider)) {
return 'bg-red-400'
}
const score = endpoint.health_score
if (score === undefined || score === null) {
return 'bg-muted-foreground/40'
}
if (score >= 0.8) {
return 'bg-green-500'
}
if (score >= 0.5) {
return 'bg-amber-500'
}
return 'bg-red-500'
}
// 端点提示文本
function getEndpointTooltip(endpoint: any, provider: ProviderWithEndpointsSummary): string {
if (provider.active_keys === 0) {
return `${endpoint.api_format}: 无可用密钥`
}
const score = endpoint.health_score
if (score === undefined || score === null) {
return `${endpoint.api_format}: 暂无健康数据`
}
return `${endpoint.api_format}: 健康度 ${(score * 100).toFixed(0)}%`
}
// 配额已用颜色(根据使用比例)
function getQuotaUsedColorClass(provider: ProviderWithEndpointsSummary): string {
const used = provider.monthly_used_usd ?? 0
const quota = provider.monthly_quota_usd ?? 0
if (quota <= 0) return 'text-foreground'
const ratio = used / quota
if (ratio >= 0.9) return 'text-red-600 dark:text-red-400'
if (ratio >= 0.7) return 'text-amber-600 dark:text-amber-400'
return 'text-foreground'
}
function rpmUsage(provider: ProviderWithEndpointsSummary): string | null {
const rpmLimit = provider.rpm_limit
const rpmUsed = provider.rpm_used ?? 0
if (rpmLimit === null || rpmLimit === undefined) {
return rpmUsed > 0 ? `${rpmUsed}` : null
}
if (rpmLimit === 0) {
return '已完全禁止'
}
return `${rpmUsed} / ${rpmLimit}`
}
// 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
// 处理行点击 - 只在非选中文本时打开抽屉
function handleRowClick(event: MouseEvent, providerId: string) {
if (!shouldTriggerRowClick(event)) return
openProviderDrawer(providerId)
}
// 打开添加提供商对话框
function openAddProviderDialog() {
providerToEdit.value = null
providerDialogOpen.value = true
}
// 打开优先级管理对话框
function openPriorityDialog() {
priorityDialogOpen.value = true
}
// 打开提供商详情抽屉
function openProviderDrawer(providerId: string) {
selectedProviderId.value = providerId
providerDrawerOpen.value = true
}
// 打开编辑提供商对话框
function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
providerToEdit.value = provider
providerDialogOpen.value = true
}
// 处理提供商编辑完成
function handleProviderUpdated() {
loadProviders()
}
// 优先级保存成功回调
async function handlePrioritySaved() {
await loadProviders()
await loadPriorityMode()
}
// 处理提供商添加
function handleProviderAdded() {
loadProviders()
}
// 删除提供商
async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
const confirmed = await confirmDanger(
'删除提供商',
`确定要删除提供商 "${provider.display_name}" 吗?\n\n这将同时删除其所有端点、密钥和配置。此操作不可恢复`
)
if (!confirmed) return
try {
await deleteProvider(provider.id)
showSuccess('提供商已删除')
loadProviders()
} catch (err: any) {
showError(err.response?.data?.detail || '删除提供商失败', '错误')
}
}
// 切换提供商状态
async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
try {
await updateProvider(provider.id, { is_active: !provider.is_active })
provider.is_active = !provider.is_active
showSuccess(provider.is_active ? '提供商已启用' : '提供商已停用')
} catch (err: any) {
showError(err.response?.data?.detail || '操作失败', '错误')
}
}
onMounted(() => {
loadProviders()
loadPriorityMode()
})
</script>

View File

@@ -0,0 +1,543 @@
<template>
<PageContainer>
<PageHeader title="系统设置" description="管理系统级别的配置和参数">
<template #actions>
<Button @click="saveSystemConfig" :disabled="loading">
{{ loading ? '保存中...' : '保存所有配置' }}
</Button>
</template>
</PageHeader>
<div class="mt-6 space-y-6">
<!-- 基础配置 -->
<CardSection title="基础配置" description="配置系统默认参数">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label for="default-quota" class="block text-sm font-medium">
默认用户配额(美元)
</Label>
<Input
id="default-quota"
v-model.number="systemConfig.default_user_quota_usd"
type="number"
step="0.01"
placeholder="10.00"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
新用户注册时的默认配额
</p>
</div>
<div>
<Label for="rate-limit" class="block text-sm font-medium">
每分钟请求限制
</Label>
<Input
id="rate-limit"
v-model.number="systemConfig.rate_limit_per_minute"
type="number"
placeholder="0"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
0 表示不限制
</p>
</div>
</div>
</CardSection>
<!-- 用户注册配置 -->
<CardSection title="用户注册" description="控制用户注册和验证">
<div class="space-y-4">
<div class="flex items-center space-x-2">
<Checkbox
id="enable-registration"
v-model:checked="systemConfig.enable_registration"
/>
<Label for="enable-registration" class="cursor-pointer">
开放用户注册
</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="require-email-verification"
v-model:checked="systemConfig.require_email_verification"
/>
<Label for="require-email-verification" class="cursor-pointer">
需要邮箱验证
</Label>
</div>
</div>
</CardSection>
<!-- API Key 管理配置 -->
<CardSection title="API Key 管理" description="API Key 相关配置">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label for="api-key-expire" class="block text-sm font-medium">
API密钥过期天数
</Label>
<Input
id="api-key-expire"
v-model.number="systemConfig.api_key_expire_days"
type="number"
placeholder="0"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
0 表示永不过期
</p>
</div>
<div class="flex items-center h-full pt-6">
<div class="flex items-center space-x-2">
<Checkbox
id="auto-delete-expired-keys"
v-model:checked="systemConfig.auto_delete_expired_keys"
/>
<div>
<Label for="auto-delete-expired-keys" class="cursor-pointer">
自动删除过期 Key
</Label>
<p class="text-xs text-muted-foreground">
关闭时仅禁用过期 Key
</p>
</div>
</div>
</div>
</div>
</CardSection>
<!-- 日志记录配置 -->
<CardSection title="日志记录" description="控制请求日志的记录方式和内容">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label for="request-log-level" class="block text-sm font-medium mb-2">
记录详细程度
</Label>
<Select v-model="systemConfig.request_log_level" v-model:open="logLevelSelectOpen">
<SelectTrigger id="request-log-level" class="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="basic">BASIC - 基本信息 (~1KB/)</SelectItem>
<SelectItem value="headers">HEADERS - 含请求头 (~2-3KB/)</SelectItem>
<SelectItem value="full">FULL - 完整请求响应 (~50KB/)</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
敏感信息会自动脱敏
</p>
</div>
<div>
<Label for="max-request-body-size" class="block text-sm font-medium">
最大请求体大小 (KB)
</Label>
<Input
id="max-request-body-size"
v-model.number="maxRequestBodySizeKB"
type="number"
placeholder="512"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过此大小的请求体将被截断记录
</p>
</div>
<div>
<Label for="max-response-body-size" class="block text-sm font-medium">
最大响应体大小 (KB)
</Label>
<Input
id="max-response-body-size"
v-model.number="maxResponseBodySizeKB"
type="number"
placeholder="512"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过此大小的响应体将被截断记录
</p>
</div>
<div>
<Label for="sensitive-headers" class="block text-sm font-medium">
敏感请求头
</Label>
<Input
id="sensitive-headers"
v-model="sensitiveHeadersStr"
placeholder="authorization, x-api-key, cookie"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
逗号分隔这些请求头会被脱敏处理
</p>
</div>
</div>
</CardSection>
<!-- 日志清理策略 -->
<CardSection title="日志清理策略" description="配置日志的分级保留和自动清理">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="md:col-span-2">
<div class="flex items-center space-x-2 mb-4">
<Checkbox
id="enable-auto-cleanup"
v-model:checked="systemConfig.enable_auto_cleanup"
/>
<Label for="enable-auto-cleanup" class="cursor-pointer">
启用自动清理任务
</Label>
<span class="text-xs text-muted-foreground ml-2">
(每天凌晨执行)
</span>
</div>
</div>
<div>
<Label for="detail-log-retention-days" class="block text-sm font-medium">
详细日志保留天数
</Label>
<Input
id="detail-log-retention-days"
v-model.number="systemConfig.detail_log_retention_days"
type="number"
placeholder="7"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过后压缩 body 字段
</p>
</div>
<div>
<Label for="compressed-log-retention-days" class="block text-sm font-medium">
压缩日志保留天数
</Label>
<Input
id="compressed-log-retention-days"
v-model.number="systemConfig.compressed_log_retention_days"
type="number"
placeholder="90"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过后删除 body 字段
</p>
</div>
<div>
<Label for="header-retention-days" class="block text-sm font-medium">
请求头保留天数
</Label>
<Input
id="header-retention-days"
v-model.number="systemConfig.header_retention_days"
type="number"
placeholder="90"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过后清空 headers 字段
</p>
</div>
<div>
<Label for="log-retention-days" class="block text-sm font-medium">
完整日志保留天数
</Label>
<Input
id="log-retention-days"
v-model.number="systemConfig.log_retention_days"
type="number"
placeholder="365"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过后删除整条记录
</p>
</div>
<div>
<Label for="cleanup-batch-size" class="block text-sm font-medium">
每批次清理记录数
</Label>
<Input
id="cleanup-batch-size"
v-model.number="systemConfig.cleanup_batch_size"
type="number"
placeholder="1000"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
避免单次操作过大影响性能
</p>
</div>
</div>
<!-- 清理策略说明 -->
<div class="mt-4 p-4 bg-muted/50 rounded-lg">
<h4 class="text-sm font-medium mb-2">清理策略说明</h4>
<div class="text-xs text-muted-foreground space-y-1">
<p>1. <strong>详细日志阶段</strong>: 保留完整的 request_body response_body</p>
<p>2. <strong>压缩日志阶段</strong>: body 字段被压缩存储节省空间</p>
<p>3. <strong>统计阶段</strong>: 仅保留 tokens成本等统计信息</p>
<p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p>
</div>
</div>
</CardSection>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { useToast } from '@/composables/useToast'
import { adminApi } from '@/api/admin'
const { success, error } = useToast()
interface SystemConfig {
// 基础配置
default_user_quota_usd: number
rate_limit_per_minute: number
// 用户注册
enable_registration: boolean
require_email_verification: boolean
// API Key 管理
api_key_expire_days: number
auto_delete_expired_keys: boolean
// 日志记录
request_log_level: string
max_request_body_size: number
max_response_body_size: number
sensitive_headers: string[]
// 日志清理
enable_auto_cleanup: boolean
detail_log_retention_days: number
compressed_log_retention_days: number
header_retention_days: number
log_retention_days: number
cleanup_batch_size: number
}
const loading = ref(false)
const logLevelSelectOpen = ref(false)
const systemConfig = ref<SystemConfig>({
// 基础配置
default_user_quota_usd: 10.0,
rate_limit_per_minute: 0,
// 用户注册
enable_registration: false,
require_email_verification: false,
// API Key 管理
api_key_expire_days: 0,
auto_delete_expired_keys: false,
// 日志记录
request_log_level: 'basic',
max_request_body_size: 1048576,
max_response_body_size: 1048576,
sensitive_headers: ['authorization', 'x-api-key', 'api-key', 'cookie', 'set-cookie'],
// 日志清理
enable_auto_cleanup: true,
detail_log_retention_days: 7,
compressed_log_retention_days: 90,
header_retention_days: 90,
log_retention_days: 365,
cleanup_batch_size: 1000,
})
// 计算属性KB 和 字节 之间的转换
const maxRequestBodySizeKB = computed({
get: () => Math.round(systemConfig.value.max_request_body_size / 1024),
set: (val: number) => {
systemConfig.value.max_request_body_size = val * 1024
}
})
const maxResponseBodySizeKB = computed({
get: () => Math.round(systemConfig.value.max_response_body_size / 1024),
set: (val: number) => {
systemConfig.value.max_response_body_size = val * 1024
}
})
// 计算属性:敏感请求头数组和字符串之间的转换
const sensitiveHeadersStr = computed({
get: () => systemConfig.value.sensitive_headers.join(', '),
set: (val: string) => {
systemConfig.value.sensitive_headers = val
.split(',')
.map(s => s.trim().toLowerCase())
.filter(s => s.length > 0)
}
})
onMounted(async () => {
await loadSystemConfig()
})
async function loadSystemConfig() {
try {
const configs = [
// 基础配置
'default_user_quota_usd',
'rate_limit_per_minute',
// 用户注册
'enable_registration',
'require_email_verification',
// API Key 管理
'api_key_expire_days',
'auto_delete_expired_keys',
// 日志记录
'request_log_level',
'max_request_body_size',
'max_response_body_size',
'sensitive_headers',
// 日志清理
'enable_auto_cleanup',
'detail_log_retention_days',
'compressed_log_retention_days',
'header_retention_days',
'log_retention_days',
'cleanup_batch_size',
]
for (const key of configs) {
try {
const response = await adminApi.getSystemConfig(key)
if (response.value !== null && response.value !== undefined) {
(systemConfig.value as any)[key] = response.value
}
} catch (err) {
// 配置不存在时使用默认值,无需处理
}
}
} catch (err) {
error('加载系统配置失败')
console.error('加载系统配置失败:', err)
}
}
async function saveSystemConfig() {
loading.value = true
try {
const configItems = [
// 基础配置
{
key: 'default_user_quota_usd',
value: systemConfig.value.default_user_quota_usd,
description: '默认用户配额(美元)'
},
{
key: 'rate_limit_per_minute',
value: systemConfig.value.rate_limit_per_minute,
description: '每分钟请求限制'
},
// 用户注册
{
key: 'enable_registration',
value: systemConfig.value.enable_registration,
description: '是否开放用户注册'
},
{
key: 'require_email_verification',
value: systemConfig.value.require_email_verification,
description: '是否需要邮箱验证'
},
// API Key 管理
{
key: 'api_key_expire_days',
value: systemConfig.value.api_key_expire_days,
description: 'API密钥过期天数'
},
{
key: 'auto_delete_expired_keys',
value: systemConfig.value.auto_delete_expired_keys,
description: '是否自动删除过期的API Key'
},
// 日志记录
{
key: 'request_log_level',
value: systemConfig.value.request_log_level,
description: '请求记录级别'
},
{
key: 'max_request_body_size',
value: systemConfig.value.max_request_body_size,
description: '最大请求体记录大小(字节)'
},
{
key: 'max_response_body_size',
value: systemConfig.value.max_response_body_size,
description: '最大响应体记录大小(字节)'
},
{
key: 'sensitive_headers',
value: systemConfig.value.sensitive_headers,
description: '敏感请求头列表'
},
// 日志清理
{
key: 'enable_auto_cleanup',
value: systemConfig.value.enable_auto_cleanup,
description: '是否启用自动清理任务'
},
{
key: 'detail_log_retention_days',
value: systemConfig.value.detail_log_retention_days,
description: '详细日志保留天数'
},
{
key: 'compressed_log_retention_days',
value: systemConfig.value.compressed_log_retention_days,
description: '压缩日志保留天数'
},
{
key: 'header_retention_days',
value: systemConfig.value.header_retention_days,
description: '请求头保留天数'
},
{
key: 'log_retention_days',
value: systemConfig.value.log_retention_days,
description: '完整日志保留天数'
},
{
key: 'cleanup_batch_size',
value: systemConfig.value.cleanup_batch_size,
description: '每批次清理的记录数'
},
]
const promises = configItems.map(item =>
adminApi.updateSystemConfig(item.key, item.value, item.description)
)
await Promise.all(promises)
success('系统配置已保存')
} catch (err) {
error('保存配置失败')
console.error('保存配置失败:', err)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,834 @@
<template>
<div class="space-y-6 pb-8">
<!-- 用户表格 -->
<Card variant="default" class="overflow-hidden">
<!-- 标题和筛选器 -->
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<h3 class="text-base font-semibold">用户管理</h3>
<!-- 筛选器和操作按钮 -->
<div class="flex items-center gap-2">
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
<Input
id="users-search"
v-model="searchQuery"
type="text"
placeholder="搜索用户名或邮箱..."
class="w-48 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60 focus:border-primary/40 transition-colors"
/>
</div>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
<!-- 角色筛选 -->
<Select v-model="filterRole" v-model:open="filterRoleOpen">
<SelectTrigger class="w-32 h-8 text-xs border-border/60">
<SelectValue placeholder="全部角色" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部角色</SelectItem>
<SelectItem value="admin">管理员</SelectItem>
<SelectItem value="user">普通用户</SelectItem>
</SelectContent>
</Select>
<!-- 状态筛选 -->
<Select v-model="filterStatus" v-model:open="filterStatusOpen">
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="active">活跃</SelectItem>
<SelectItem value="inactive">禁用</SelectItem>
</SelectContent>
</Select>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
<!-- 新增用户按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="openCreateDialog"
title="新增用户"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton :loading="usersStore.loading || loadingStats" @click="refreshUsers" />
</div>
</div>
</div>
<!-- 桌面端表格 -->
<div class="hidden xl:block overflow-x-auto">
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[200px] h-12 font-semibold">用户信息</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">邮箱</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">使用统计</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">配额(美元)</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">创建时间</TableHead>
<TableHead class="w-[90px] h-12 font-semibold text-center">状态</TableHead>
<TableHead class="w-[220px] h-12 font-semibold text-center">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in paginatedUsers" :key="user.id" class="border-b border-border/40 hover:bg-muted/30 transition-colors">
<TableCell class="py-4">
<div class="flex items-center gap-3">
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md">
<AvatarFallback class="bg-primary text-sm font-bold text-white">
{{ user.username.charAt(0).toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<div class="truncate text-sm font-semibold mb-1" :title="user.username">{{ user.username }}</div>
<Badge :variant="user.role === 'admin' ? 'default' : 'secondary'" class="text-xs px-2 py-0.5">
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</Badge>
</div>
</div>
</TableCell>
<TableCell class="py-4">
<span class="block truncate text-sm text-muted-foreground" :title="user.email || '-'">
{{ user.email || '-' }}
</span>
</TableCell>
<TableCell class="py-4">
<div v-if="userStats[user.id]" class="space-y-1 text-xs">
<div class="flex items-center text-muted-foreground">
<span class="w-14">请求:</span>
<span class="font-medium text-foreground">{{ formatNumber(userStats[user.id]?.request_count) }}</span>
</div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">Tokens:</span>
<span class="font-medium text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</span>
</div>
<div class="flex items-center text-muted-foreground">
<span class="w-14">费用:</span>
<span class="font-medium text-foreground">${{ formatCurrency(userStats[user.id]?.total_cost) }}</span>
</div>
</div>
<div v-else class="text-xs text-muted-foreground">
<span v-if="loadingStats">加载中...</span>
<span v-else>无数据</span>
</div>
</TableCell>
<TableCell class="py-4">
<div class="space-y-1.5 text-xs">
<div v-if="user.quota_usd != null" class="text-muted-foreground">
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium">${{ user.quota_usd.toFixed(2) }}</span>
</div>
<div v-else class="text-muted-foreground">
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium text-amber-600">无限制</span>
</div>
<div class="text-muted-foreground">累计: <span class="font-medium text-foreground">${{ (user.total_usd || 0).toFixed(2) }}</span></div>
</div>
</TableCell>
<TableCell class="py-4 text-xs text-muted-foreground">
{{ formatDate(user.created_at) }}
</TableCell>
<TableCell class="py-4 text-center">
<Badge :variant="user.is_active ? 'success' : 'destructive'" class="font-medium px-3 py-1">
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
</TableCell>
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="editUser(user)" title="编辑用户">
<SquarePen class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="manageApiKeys(user)" title="查看API Keys">
<Key class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="toggleUserStatus(user)"
:title="user.is_active ? '禁用用户' : '启用用户'"
>
<PauseCircle v-if="user.is_active" class="h-4 w-4" />
<PlayCircle v-else class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="resetQuota(user)" title="重置配额">
<RotateCcw class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="deleteUser(user)" title="删除用户">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 移动端卡片列表 -->
<div class="xl:hidden divide-y divide-border/40">
<div v-for="user in paginatedUsers" :key="user.id" class="p-4 sm:p-5 hover:bg-muted/30 transition-colors">
<!-- 用户头部 -->
<div class="flex items-start justify-between mb-3 sm:mb-4">
<div class="flex items-center gap-2 sm:gap-3">
<Avatar class="h-10 w-10 sm:h-12 sm:w-12 ring-2 ring-background shadow-md flex-shrink-0">
<AvatarFallback class="bg-primary text-sm sm:text-base font-bold text-white">
{{ user.username.charAt(0).toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="min-w-0">
<div class="font-semibold text-sm sm:text-base mb-1 truncate">{{ user.username }}</div>
<Badge :variant="user.role === 'admin' ? 'default' : 'secondary'" class="text-xs">
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</Badge>
</div>
</div>
<Badge :variant="user.is_active ? 'success' : 'destructive'" class="font-medium text-xs flex-shrink-0">
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
</div>
<!-- 用户信息 -->
<div class="space-y-2 sm:space-y-3 mb-3 sm:mb-4">
<div class="text-xs sm:text-sm">
<span class="text-muted-foreground">邮箱:</span>
<span class="ml-2 text-foreground truncate block sm:inline">{{ user.email || '-' }}</span>
</div>
<div v-if="userStats[user.id]" class="grid grid-cols-2 gap-2 p-2 sm:p-3 bg-muted/50 rounded-lg text-xs">
<div>
<div class="text-muted-foreground mb-1">请求次数</div>
<div class="font-semibold text-sm text-foreground">{{ formatNumber(userStats[user.id]?.request_count) }}</div>
</div>
<div>
<div class="text-muted-foreground mb-1">Tokens</div>
<div class="font-semibold text-sm text-foreground">{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}</div>
</div>
<div class="col-span-2">
<div class="text-muted-foreground mb-1">消费金额</div>
<div class="font-semibold text-sm text-foreground">${{ formatCurrency(userStats[user.id]?.total_cost) }}</div>
</div>
</div>
<div class="p-2 sm:p-3 bg-muted/50 rounded-lg text-xs space-y-1">
<div v-if="user.quota_usd != null">
<span class="text-muted-foreground">当前配额:</span>
<span class="ml-2 font-semibold text-sm">${{ (user.used_usd || 0).toFixed(2) }}</span> / ${{ user.quota_usd.toFixed(2) }}
</div>
<div v-else>
<span class="text-muted-foreground">当前配额:</span>
<span class="ml-2 font-semibold text-sm">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="text-amber-600">无限制</span>
</div>
<div>
<span class="text-muted-foreground">累计消费:</span>
<span class="ml-2 font-semibold text-sm">${{ (user.total_usd || 0).toFixed(2) }}</span>
</div>
<div>
<span class="text-muted-foreground">创建时间:</span>
<span class="ml-2 text-sm">{{ formatDate(user.created_at) }}</span>
</div>
</div>
</div>
<!-- 操作按钮 - 响应式布局 -->
<div class="grid grid-cols-2 sm:flex sm:flex-wrap gap-1.5 sm:gap-2">
<Button variant="outline" size="sm" class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[90px]" @click="editUser(user)">
<SquarePen class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">编辑</span>
</Button>
<Button variant="outline" size="sm" class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[100px]" @click="manageApiKeys(user)">
<Key class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">API Keys</span>
</Button>
<Button
variant="outline"
size="sm"
class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[90px]"
:class="user.is_active ? 'text-amber-600' : 'text-emerald-600'"
@click="toggleUserStatus(user)"
>
<PauseCircle v-if="user.is_active" class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
<PlayCircle v-else class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">{{ user.is_active ? '禁用' : '启用' }}</span>
</Button>
<Button variant="outline" size="sm" class="text-xs sm:text-sm h-8 sm:h-9" @click="resetQuota(user)">
<RotateCcw class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">重置</span>
</Button>
<Button variant="outline" size="sm" class="col-span-2 text-xs sm:text-sm h-8 sm:h-9 text-rose-600 sm:col-span-1" @click="deleteUser(user)">
<Trash2 class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">删除</span>
</Button>
</div>
</div>
</div>
<!-- 分页控件 -->
<Pagination
:current="currentPage"
:total="filteredUsers.length"
:page-size="pageSize"
@update:current="currentPage = $event"
@update:page-size="pageSize = $event"
/>
</Card>
<!-- 用户表单对话框创建/编辑共用 -->
<UserFormDialog
:open="showUserFormDialog"
:user="editingUser"
@close="closeUserFormDialog"
@submit="handleUserFormSubmit"
ref="userFormDialogRef"
/>
<!-- API Keys 管理对话框 -->
<Dialog v-model="showApiKeysDialog" size="xl">
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-kraft/10 flex-shrink-0">
<Key class="h-5 w-5 text-kraft" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">管理 API Keys</h3>
<p class="text-xs text-muted-foreground">查看和管理用户的 API 密钥</p>
</div>
</div>
</div>
</template>
<div class="max-h-[60vh] overflow-y-auto space-y-3">
<template v-if="userApiKeys.length > 0">
<div
v-for="apiKey in userApiKeys"
:key="apiKey.id"
class="rounded-lg border border-border bg-card p-4 hover:border-primary/30 transition-colors"
>
<div class="flex items-center justify-between gap-3">
<!-- 左侧信息 -->
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-foreground">
{{ apiKey.name || '未命名 API Key' }}
</span>
<Badge
:variant="apiKey.is_active ? 'success' : 'secondary'"
class="text-xs"
>
{{ apiKey.is_active ? '活跃' : '已禁用' }}
</Badge>
<Badge
v-if="apiKey.is_standalone"
variant="default"
class="text-xs bg-purple-500"
>
独立余额
</Badge>
</div>
<div class="flex items-center gap-1 mt-0.5">
<code class="text-xs font-mono text-muted-foreground">
{{ apiKey.key_display || 'sk-****' }}
</code>
<button
@click="copyFullKey(apiKey)"
class="p-0.5 hover:bg-muted rounded transition-colors"
title="复制完整密钥"
>
<Copy class="w-3 h-3 text-muted-foreground" />
</button>
</div>
</div>
</div>
<!-- 右侧统计和操作 -->
<div class="flex items-center gap-4 flex-shrink-0">
<div class="text-right text-sm">
<div class="text-muted-foreground">
{{ (apiKey.total_requests || 0).toLocaleString() }}
</div>
<div class="font-semibold text-rose-600">
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
</div>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="deleteApiKey(apiKey)"
title="删除"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</template>
<div
v-else
class="rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/20 px-4 py-12 text-center"
>
<div class="flex flex-col items-center gap-3">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<Key class="h-6 w-6 text-muted-foreground/50" />
</div>
<div>
<p class="mb-1 text-base font-semibold text-foreground">暂无 API Keys</p>
<p class="text-sm text-muted-foreground">点击下方按钮创建</p>
</div>
</div>
</div>
</div>
<template #footer>
<Button variant="outline" @click="showApiKeysDialog = false" class="h-10 px-5">取消</Button>
<Button @click="createApiKey" class="h-10 px-5" :disabled="creatingApiKey">
{{ creatingApiKey ? '创建中...' : '创建' }}
</Button>
</template>
</Dialog>
<!-- API Key 显示对话框 -->
<Dialog v-model="showNewApiKeyDialog" size="lg">
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 flex-shrink-0">
<CheckCircle class="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">创建成功</h3>
<p class="text-xs text-muted-foreground">请妥善保管, 切勿泄露给他人.</p>
</div>
</div>
</div>
</template>
<div class="space-y-4">
<div class="space-y-2">
<Label class="text-sm font-medium">API Key</Label>
<div class="flex items-center gap-2">
<Input
type="text"
:value="newApiKey"
readonly
class="flex-1 font-mono text-sm bg-muted/50 h-11"
@click="selectApiKey"
ref="apiKeyInput"
/>
<Button @click="copyApiKey" class="h-11">
复制
</Button>
</div>
</div>
</div>
<template #footer>
<Button @click="closeNewApiKeyDialog" class="h-10 px-5">确定</Button>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { usageApi, type UsageByUser } from '@/api/usage'
import { adminApi } from '@/api/admin'
// UI 组件
import {
Dialog,
Card,
Button,
Badge,
Input,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Avatar,
AvatarFallback,
Pagination,
RefreshButton
} from '@/components/ui'
import {
Plus,
SquarePen,
Key,
PauseCircle,
PlayCircle,
RotateCcw,
Trash2,
Copy,
Search,
CheckCircle
} from 'lucide-vue-next'
// 功能组件
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
const { success, error } = useToast()
const { confirmDanger, confirmWarning } = useConfirm()
const usersStore = useUsersStore()
// 用户表单对话框状态
const showUserFormDialog = ref(false)
const editingUser = ref<UserFormData | null>(null)
const userFormDialogRef = ref<InstanceType<typeof UserFormDialog>>()
// API Keys 对话框状态
const showApiKeysDialog = ref(false)
const showNewApiKeyDialog = ref(false)
const selectedUser = ref<any>(null)
const userApiKeys = ref<any[]>([])
const newApiKey = ref('')
const creatingApiKey = ref(false)
const apiKeyInput = ref<HTMLInputElement>()
// 用户统计
const userStats = ref<Record<string, UsageByUser>>({})
const loadingStats = ref(false)
const searchQuery = ref('')
const filterRole = ref('all')
const filterStatus = ref('all')
const filterRoleOpen = ref(false)
const filterStatusOpen = ref(false)
const currentPage = ref(1)
const pageSize = ref(20)
const filteredUsers = computed(() => {
let filtered = [...usersStore.users]
// 先排序:管理员优先,然后按创建时间倒序
filtered.sort((a, b) => {
// 管理员优先
if (a.role === 'admin' && b.role !== 'admin') return -1
if (a.role !== 'admin' && b.role === 'admin') return 1
// 同角色按创建时间倒序(新用户在前)
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(
u => u.username.toLowerCase().includes(query) || u.email?.toLowerCase().includes(query)
)
}
if (filterRole.value !== 'all') {
filtered = filtered.filter(u => u.role === filterRole.value)
}
if (filterStatus.value !== 'all') {
filtered = filtered.filter(u =>
filterStatus.value === 'active' ? u.is_active : !u.is_active
)
}
return filtered
})
const paginatedUsers = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredUsers.value.slice(start, start + pageSize.value)
})
// Watch filter changes and reset to first page
watch([searchQuery, filterRole, filterStatus], () => {
currentPage.value = 1
})
onMounted(async () => {
await usersStore.fetchUsers()
await loadUserStats()
})
async function refreshUsers() {
await usersStore.fetchUsers()
await loadUserStats()
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('zh-CN')
}
async function loadUserStats() {
loadingStats.value = true
try {
const data = await usageApi.getUsageByUser()
userStats.value = data.reduce((acc: any, stat: any) => {
acc[stat.user_id] = stat
return acc
}, {})
} catch (err) {
console.error('加载用户统计失败:', err)
} finally {
loadingStats.value = false
}
}
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`
}
return tokens.toString()
}
function formatNumber(value?: number | null): string {
const numericValue = typeof value === 'number' && Number.isFinite(value) ? value : 0
return numericValue.toLocaleString()
}
function formatCurrency(value?: number | null, fractionDigits = 4): string {
const numericValue = typeof value === 'number' && Number.isFinite(value) ? value : 0
return numericValue.toFixed(fractionDigits)
}
async function toggleUserStatus(user: any) {
const action = user.is_active ? '禁用' : '启用'
const confirmed = await confirmDanger(
`确定要${action}用户 ${user.username} 吗?`,
`${action}用户`
)
if (!confirmed) return
try {
await usersStore.updateUser(user.id, { is_active: !user.is_active })
success(`用户已${action}`)
} catch (err: any) {
error(err.response?.data?.detail || '未知错误', `${action}用户失败`)
}
}
// ========== 用户表单对话框方法 ==========
function openCreateDialog() {
editingUser.value = null
showUserFormDialog.value = true
}
function editUser(user: any) {
editingUser.value = {
id: user.id,
username: user.username,
email: user.email,
quota_usd: user.quota_usd,
role: user.role,
is_active: user.is_active,
allowed_providers: user.allowed_providers || [],
allowed_endpoints: user.allowed_endpoints || [],
allowed_models: user.allowed_models || []
}
showUserFormDialog.value = true
}
function closeUserFormDialog() {
showUserFormDialog.value = false
editingUser.value = null
}
async function handleUserFormSubmit(data: UserFormData & { password?: string }) {
userFormDialogRef.value?.setSaving(true)
try {
if (data.id) {
// 更新用户
const updateData: any = {
username: data.username,
email: data.email || undefined,
quota_usd: data.quota_usd,
role: data.role,
allowed_providers: data.allowed_providers,
allowed_endpoints: data.allowed_endpoints,
allowed_models: data.allowed_models
}
if (data.password) {
updateData.password = data.password
}
await usersStore.updateUser(data.id, updateData)
success('用户信息已更新')
} else {
// 创建用户
const newUser = await usersStore.createUser({
username: data.username,
password: data.password!,
email: data.email || undefined,
quota_usd: data.quota_usd,
role: data.role,
allowed_providers: data.allowed_providers,
allowed_endpoints: data.allowed_endpoints,
allowed_models: data.allowed_models
})
// 如果创建时指定为禁用,则更新状态
if (data.is_active === false && newUser) {
await usersStore.updateUser(newUser.id, { is_active: false })
}
success('用户创建成功')
}
closeUserFormDialog()
} catch (err: any) {
const title = data.id ? '更新用户失败' : '创建用户失败'
error(err.response?.data?.detail || '未知错误', title)
} finally {
userFormDialogRef.value?.setSaving(false)
}
}
async function manageApiKeys(user: any) {
selectedUser.value = user
showApiKeysDialog.value = true
await loadUserApiKeys(user.id)
}
async function loadUserApiKeys(userId: string) {
try {
userApiKeys.value = await usersStore.getUserApiKeys(userId)
} catch (err) {
console.error('加载API Keys失败:', err)
userApiKeys.value = []
}
}
async function createApiKey() {
if (!selectedUser.value) return
creatingApiKey.value = true
try {
const response = await usersStore.createApiKey(
selectedUser.value.id,
`Key-${new Date().toISOString().split('T')[0]}`
)
newApiKey.value = response.key || ''
showNewApiKeyDialog.value = true
await loadUserApiKeys(selectedUser.value.id)
} catch (err: any) {
error(err.response?.data?.detail || '未知错误', '创建 API Key 失败')
} finally {
creatingApiKey.value = false
}
}
function selectApiKey() {
apiKeyInput.value?.select()
}
async function copyApiKey() {
try {
await navigator.clipboard.writeText(newApiKey.value)
success('API Key已复制到剪贴板')
} catch (err) {
error('复制失败,请手动复制')
}
}
async function closeNewApiKeyDialog() {
showNewApiKeyDialog.value = false
newApiKey.value = ''
}
async function deleteApiKey(apiKey: any) {
const confirmed = await confirmDanger(
`确定要删除这个API Key吗\n\n${apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`,
'删除 API Key'
)
if (!confirmed) return
try {
await usersStore.deleteApiKey(selectedUser.value.id, apiKey.id)
await loadUserApiKeys(selectedUser.value.id)
success('API Key已删除')
} catch (err: any) {
error(err.response?.data?.detail || '未知错误', '删除 API Key 失败')
}
}
async function copyFullKey(apiKey: any) {
try {
// 调用后端 API 获取完整密钥
const response = await adminApi.getFullApiKey(apiKey.id)
await navigator.clipboard.writeText(response.key)
success('完整密钥已复制到剪贴板')
} catch (err: any) {
console.error('复制密钥失败:', err)
error(err.response?.data?.detail || '未知错误', '复制密钥失败')
}
}
async function resetQuota(user: any) {
const confirmed = await confirmWarning(
`确定要重置用户 ${user.username} 的配额使用量吗?\n\n这将把已使用金额重置为0。`,
'重置配额'
)
if (!confirmed) return
try {
await usersStore.resetUserQuota(user.id)
success('配额已重置')
} catch (err: any) {
error(err.response?.data?.detail || '未知错误', '重置配额失败')
}
}
async function deleteUser(user: any) {
const confirmed = await confirmDanger(
`确定要删除用户 ${user.username} 吗?\n\n此操作将删除\n• 用户账户\n• 所有API密钥\n• 所有使用记录\n\n此操作无法撤销`,
'删除用户'
)
if (!confirmed) return
try {
await usersStore.deleteUser(user.id)
success('用户已删除')
} catch (err: any) {
error(err.response?.data?.detail || '未知错误', '删除用户失败')
}
}
function getRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diff = now.getTime() - date.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const months = Math.floor(days / 30)
const years = Math.floor(days / 365)
if (years > 0) return `${years}年前`
if (months > 0) return `${months}月前`
if (days > 0) return `${days}天前`
if (hours > 0) return `${hours}小时前`
if (minutes > 0) return `${minutes}分钟前`
return '刚刚'
}
</script>