refactor(frontend): optimize view pages (admin, shared, user)

This commit is contained in:
fawney19
2025-12-13 22:26:47 +08:00
parent 9ca845f9d0
commit d564842c4d
14 changed files with 980 additions and 268 deletions

View File

@@ -2,12 +2,12 @@
<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">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<h3 class="text-sm sm:text-base font-semibold shrink-0">
别名管理
</h3>
<div class="flex items-center gap-2">
<div class="flex flex-wrap 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" />
@@ -15,11 +15,11 @@
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"
class="w-32 sm: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" />
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 提供商过滤器 -->
<Select
@@ -27,7 +27,7 @@
:model-value="aliasProviderFilter"
@update:model-value="aliasProviderFilter = $event"
>
<SelectTrigger class="w-40 h-8 text-xs border-border/60">
<SelectTrigger class="w-28 sm:w-40 h-8 text-xs border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -47,7 +47,7 @@
</SelectContent>
</Select>
<div class="h-4 w-px bg-border" />
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
@@ -73,7 +73,7 @@
<Loader2 class="w-10 h-10 animate-spin text-primary" />
</div>
<div v-else>
<Table class="text-sm">
<Table class="hidden xl:table text-sm">
<TableHeader>
<TableRow>
<TableHead class="w-[200px]">
@@ -185,6 +185,83 @@
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div
v-if="filteredAliases.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="alias in paginatedAliases"
:key="alias.id"
class="p-4 space-y-2"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-mono font-medium truncate">{{ alias.alias }}</span>
<Badge
:variant="alias.is_active ? 'default' : 'secondary'"
class="text-xs shrink-0"
>
{{ alias.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<div class="text-xs text-muted-foreground mt-1">
<span class="font-medium">{{ alias.global_model_display_name || alias.global_model_name }}</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="openEditAliasDialog(alias)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="toggleAliasStatus(alias)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="confirmDeleteAlias(alias)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<Badge
variant="secondary"
class="text-xs"
>
{{ alias.mapping_type === 'mapping' ? '映射' : '别名' }}
</Badge>
<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>
</div>
</div>
</div>
<!-- 分页 -->
<Pagination
v-if="!loadingAliases && filteredAliases.length > 0"

View File

@@ -14,10 +14,10 @@
</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">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div class="shrink-0">
<h3 class="text-sm sm:text-base font-semibold">
独立余额 API Keys
</h3>
<p class="text-xs text-muted-foreground mt-0.5">
@@ -28,7 +28,7 @@
> · 即将到期 {{ expiringSoonCount }}</span>
</p>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap 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" />
@@ -36,19 +36,19 @@
v-model="searchQuery"
type="text"
placeholder="搜索..."
class="h-8 w-40 pl-8 pr-2 text-xs"
class="h-8 w-28 sm:w-40 pl-8 pr-2 text-xs"
/>
</div>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
<div class="hidden sm:block 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">
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
@@ -67,7 +67,7 @@
v-model="filterBalance"
v-model:open="filterBalanceOpen"
>
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
@@ -82,7 +82,7 @@
</Select>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 创建独立 Key 按钮 -->
<Button

View File

@@ -6,17 +6,17 @@
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">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div class="shrink-0">
<h3 class="text-sm sm: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="flex flex-wrap 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" />
@@ -24,19 +24,19 @@
id="audit-logs-search"
v-model="searchQuery"
placeholder="搜索用户ID..."
class="w-64 h-8 text-sm pl-8"
class="w-32 sm:w-64 h-8 text-sm pl-8"
@input="handleSearchChange"
/>
</div>
<!-- 分隔线 -->
<div class="h-4 w-px bg-border" />
<div class="hidden sm:block 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">
<SelectTrigger class="w-24 sm:w-40 h-8 border-border/60">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
@@ -81,7 +81,7 @@
v-model:open="daysSelectOpen"
@update:model-value="handleDaysChange"
>
<SelectTrigger class="w-28 h-8 border-border/60">
<SelectTrigger class="w-20 sm:w-28 h-8 border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -110,7 +110,7 @@
>
<FilterX class="w-3.5 h-3.5" />
</Button>
<div class="h-4 w-px bg-border" />
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 导出按钮 -->
<Button
variant="ghost"
@@ -145,7 +145,7 @@
</div>
<div v-else>
<Table>
<Table class="hidden xl:table">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-12 font-semibold">
@@ -242,6 +242,60 @@
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div
v-if="logs.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="logItem in logs"
:key="logItem.id"
class="p-4 space-y-2 hover:bg-muted/30 cursor-pointer transition-colors"
@click="showLogDetail(logItem)"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<Badge :variant="getEventTypeBadgeVariant(logItem.event_type)">
<component
:is="getEventTypeIcon(logItem.event_type)"
class="h-3 w-3 mr-1"
/>
{{ getEventTypeLabel(logItem.event_type) }}
</Badge>
<div class="text-xs text-muted-foreground mt-1.5">
{{ formatDateTime(logItem.created_at) }}
</div>
</div>
<Badge
v-if="logItem.status_code"
:variant="getStatusCodeVariant(logItem.status_code)"
class="shrink-0"
>
{{ logItem.status_code }}
</Badge>
</div>
<div
v-if="logItem.user_id"
class="text-sm"
>
{{ logItem.user_email || `用户 ${logItem.user_id}` }}
</div>
<div
class="text-xs text-muted-foreground truncate"
:title="logItem.description"
>
{{ logItem.description || '无描述' }}
</div>
<div
v-if="logItem.ip_address"
class="flex items-center text-xs text-muted-foreground"
>
<Globe class="h-3 w-3 mr-1" />
{{ logItem.ip_address }}
</div>
</div>
</div>
<!-- 分页控件 -->
<Pagination
:current="currentPage"

View File

@@ -365,21 +365,19 @@ onBeforeUnmount(() => {
<!-- 缓存亲和性列表 -->
<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="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<h3 class="text-sm sm:text-base font-semibold shrink-0">
亲和性列表
</h3>
<div class="flex flex-wrap 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"
class="w-32 sm:w-48 h-8 text-sm pl-8 pr-8"
/>
<button
v-if="tableKeyword"
@@ -390,7 +388,7 @@ onBeforeUnmount(() => {
<X class="h-3.5 w-3.5" />
</button>
</div>
<div class="h-4 w-px bg-border" />
<div class="hidden sm:block h-4 w-px bg-border" />
<Button
variant="ghost"
size="icon"
@@ -408,7 +406,7 @@ onBeforeUnmount(() => {
</div>
</div>
<Table>
<Table class="hidden xl:table">
<TableHeader>
<TableRow>
<TableHead class="w-36">
@@ -537,6 +535,60 @@ onBeforeUnmount(() => {
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div
v-if="!listLoading && affinityList.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="item in paginatedAffinityList"
:key="`m-${item.affinity_key}-${item.endpoint_id}-${item.key_id}`"
class="p-4 space-y-2"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<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">{{ item.username || '未知' }}</span>
</div>
<div class="text-xs text-muted-foreground mt-0.5">
{{ item.user_api_key_name || '未命名' }} · {{ item.user_api_key_prefix || '---' }}
</div>
</div>
<Button
size="icon"
variant="ghost"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive shrink-0"
:disabled="clearingRowAffinityKey === item.affinity_key"
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{{ item.provider_name || '未知' }}</span>
<span>·</span>
<span class="truncate max-w-[100px]">{{ item.model_display_name || '---' }}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">{{ item.endpoint_api_format || '---' }}</span>
<span>{{ getRemainingTime(item.expire_at) }} · {{ item.request_count }}</span>
</div>
</div>
</div>
<div
v-else-if="!listLoading && affinityList.length === 0"
class="xl:hidden text-center py-6 text-sm text-muted-foreground"
>
暂无缓存记录
</div>
<Pagination
v-if="affinityList.length > 0"
:current="currentPage"
@@ -549,18 +601,18 @@ onBeforeUnmount(() => {
<!-- TTL 分析区域 -->
<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">
<BarChart3 class="h-5 w-5 text-muted-foreground" />
<h3 class="text-base font-semibold">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div class="flex items-center gap-3 shrink-0">
<BarChart3 class="h-5 w-5 text-muted-foreground hidden sm:block" />
<h3 class="text-sm sm:text-base font-semibold">
TTL 分析
</h3>
<span class="text-xs text-muted-foreground">分析用户请求间隔推荐合适的缓存 TTL</span>
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔推荐合适的缓存 TTL</span>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<Select v-model="analysisHours">
<SelectTrigger class="w-28 h-8">
<SelectTrigger class="w-24 sm:w-28 h-8">
<SelectValue placeholder="时间段" />
</SelectTrigger>
<SelectContent>

View File

@@ -44,17 +44,17 @@
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">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div class="shrink-0">
<h3 class="text-sm sm: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">
<div class="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="icon"
@@ -114,17 +114,17 @@
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">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div class="shrink-0">
<h3 class="text-sm sm: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">
<div class="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="icon"
@@ -158,7 +158,7 @@
</div>
<div v-else>
<Table>
<Table class="hidden sm:table">
<TableHeader>
<TableRow>
<TableHead>IP 地址 / CIDR</TableHead>
@@ -189,6 +189,25 @@
</TableRow>
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="ip in whitelistData.whitelist"
:key="ip"
class="p-4 flex items-center justify-between gap-3"
>
<span class="font-mono text-sm truncate">{{ ip }}</span>
<Button
variant="ghost"
size="sm"
class="h-8 px-3 shrink-0"
@click="handleRemoveFromWhitelist(ip)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
</div>
</Card>

View File

@@ -5,15 +5,15 @@
<!-- 模型列表 -->
<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">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/60">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<!-- 左侧标题 -->
<h3 class="text-base font-semibold">
<h3 class="text-sm sm:text-base font-semibold shrink-0">
模型管理
</h3>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2">
<div class="flex flex-wrap 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" />
@@ -22,11 +22,11 @@
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"
class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
/>
</div>
<div class="h-4 w-px bg-border" />
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 能力筛选 -->
<div class="flex items-center border rounded-md border-border/60 h-8 overflow-hidden">
@@ -76,7 +76,7 @@
</button>
</div>
<div class="h-4 w-px bg-border" />
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
@@ -96,7 +96,7 @@
</div>
</div>
<Table>
<Table class="hidden xl:table">
<TableHeader>
<TableRow>
<TableHead class="w-[240px]">
@@ -302,6 +302,109 @@
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div
v-if="!loading && filteredGlobalModels.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="model in paginatedGlobalModels"
:key="model.id"
class="p-4 space-y-3 hover:bg-muted/50 cursor-pointer transition-colors"
@click="selectModel(model)"
>
<!-- 第一行名称 + 状态 + 操作 -->
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium truncate">{{ model.display_name }}</span>
<Badge
:variant="model.is_active ? 'default' : 'secondary'"
class="text-xs shrink-0"
>
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<span class="font-mono truncate">{{ model.name }}</span>
<button
class="p-0.5 rounded hover:bg-muted transition-colors shrink-0"
@click.stop="copyToClipboard(model.name)"
>
<Copy class="w-3 h-3" />
</button>
</div>
</div>
<div
class="flex items-center gap-0.5 shrink-0"
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="editModel(model)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="toggleModelStatus(model)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="deleteModel(model)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<!-- 第二行能力图标 -->
<div class="flex flex-wrap gap-1.5">
<Zap
v-if="model.default_supports_streaming"
class="w-4 h-4 text-muted-foreground"
/>
<Image
v-if="model.default_supports_image_generation"
class="w-4 h-4 text-muted-foreground"
/>
<Eye
v-if="model.default_supports_vision"
class="w-4 h-4 text-muted-foreground"
/>
<Wrench
v-if="model.default_supports_function_calling"
class="w-4 h-4 text-muted-foreground"
/>
<Brain
v-if="model.default_supports_extended_thinking"
class="w-4 h-4 text-muted-foreground"
/>
</div>
<!-- 第三行统计信息 -->
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>提供商 {{ model.provider_count || 0 }}</span>
<span>别名 {{ model.alias_count || 0 }}</span>
<span>调用 {{ formatUsageCount(model.usage_count || 0) }}</span>
<span
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
class="font-mono"
>
${{ getFirstTierPrice(model, 'input')?.toFixed(2) || '-' }}/${{ getFirstTierPrice(model, 'output')?.toFixed(2) || '-' }}
</span>
</div>
</div>
</div>
<!-- 分页 -->
<Pagination
v-if="!loading && filteredGlobalModels.length > 0"

View File

@@ -6,15 +6,15 @@
class="overflow-hidden"
>
<!-- 标题和操作栏 -->
<div class="px-6 py-3.5 border-b border-border/50">
<div class="flex items-center justify-between gap-4">
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/50">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<!-- 左侧标题 -->
<h3 class="text-base font-semibold text-foreground">
<h3 class="text-sm sm:text-base font-semibold text-foreground shrink-0">
提供商管理
</h3>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2">
<div class="flex flex-wrap 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" />
@@ -23,11 +23,11 @@
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"
class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
/>
</div>
<div class="h-4 w-px bg-border" />
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 调度策略 -->
<button
@@ -35,12 +35,12 @@
title="点击调整调度策略"
@click="openPriorityDialog"
>
<span class="text-muted-foreground/80">调度:</span>
<span class="text-muted-foreground/80 hidden sm:inline">调度:</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" />
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 操作按钮 -->
<Button
@@ -94,7 +94,7 @@
<!-- 桌面端表格 -->
<div
v-else
class="overflow-x-auto"
class="hidden xl:block overflow-x-auto"
>
<Table>
<TableHeader>
@@ -289,6 +289,118 @@
</Table>
</div>
<!-- 移动端卡片列表 -->
<div
v-if="!loading && filteredProviders.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
v-for="provider in paginatedProviders"
:key="provider.id"
class="p-4 space-y-3 hover:bg-muted/20 transition-colors cursor-pointer"
@click="openProviderDrawer(provider.id)"
>
<!-- 第一行名称 + 状态 + 操作 -->
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-foreground truncate">{{ provider.display_name }}</span>
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="text-xs shrink-0"
>
{{ provider.is_active ? '活跃' : '停用' }}
</Badge>
</div>
<span class="text-xs text-muted-foreground/70 font-mono">{{ provider.name }}</span>
</div>
<div
class="flex items-center gap-0.5 shrink-0"
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="openEditProviderDialog(provider)"
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="toggleProviderStatus(provider)"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="handleDeleteProvider(provider)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<!-- 第二行计费类型 + 资源统计 -->
<div class="flex flex-wrap items-center gap-3 text-xs">
<Badge
variant="outline"
class="text-xs font-normal border-border/50"
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
<span class="text-muted-foreground">
端点 {{ provider.active_endpoints }}/{{ provider.total_endpoints }}
</span>
<span class="text-muted-foreground">
密钥 {{ provider.active_keys }}/{{ provider.total_keys }}
</span>
<span class="text-muted-foreground">
模型 {{ provider.active_models }}/{{ provider.total_models }}
</span>
</div>
<!-- 第三行端点健康 -->
<div
v-if="provider.endpoint_health_details && provider.endpoint_health_details.length > 0"
class="flex flex-wrap gap-1.5"
>
<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)"
>
<span
class="w-1.5 h-1.5 rounded-full"
:class="getEndpointDotColor(endpoint, provider)"
/>
{{ endpoint.api_format }}
</span>
</div>
<!-- 第四行配额/限流 -->
<div
v-if="provider.billing_type === 'monthly_quota' || rpmUsage(provider)"
class="flex items-center gap-3 text-xs text-muted-foreground"
>
<span v-if="provider.billing_type === 'monthly_quota'">
配额: <span
class="font-semibold"
:class="getQuotaUsedColorClass(provider)"
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / ${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}
</span>
<span v-if="rpmUsage(provider)">
RPM: {{ rpmUsage(provider) }}
</span>
</div>
</div>
</div>
<!-- 分页 -->
<Pagination
v-if="!loading && filteredProviders.length > 0"

View File

@@ -6,8 +6,86 @@
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 class="px-4 sm:px-6 py-3.5 border-b border-border/60">
<!-- 移动端标题行 + 筛选器行 -->
<div class="flex flex-col gap-3 sm:hidden">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">
用户管理
</h3>
<div class="flex items-center gap-2">
<!-- 新增用户按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="新增用户"
@click="openCreateDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<!-- 刷新按钮 -->
<RefreshButton
:loading="usersStore.loading || loadingStats"
@click="refreshUsers"
/>
</div>
</div>
<!-- 筛选器 -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<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-mobile"
v-model="searchQuery"
type="text"
placeholder="搜索..."
class="w-full pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60"
/>
</div>
<Select
v-model="filterRole"
v-model:open="filterRoleOpenMobile"
>
<SelectTrigger class="w-24 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="filterStatusOpenMobile"
>
<SelectTrigger class="w-20 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>
</div>
<!-- 桌面端单行布局 -->
<div class="hidden sm:flex items-center justify-between gap-4">
<h3 class="text-base font-semibold">
用户管理
</h3>
@@ -172,10 +250,6 @@
<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
@@ -338,14 +412,6 @@
{{ 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">
@@ -707,6 +773,8 @@ const filterRole = ref('all')
const filterStatus = ref('all')
const filterRoleOpen = ref(false)
const filterStatusOpen = ref(false)
const filterRoleOpenMobile = ref(false)
const filterStatusOpenMobile = ref(false)
const currentPage = ref(1)
const pageSize = ref(20)