2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="space-y-6 pb-8">
|
|
|
|
|
|
<!-- API Keys 表格 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="overflow-hidden"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 标题和操作栏 -->
|
2025-12-13 22:26:47 +08:00
|
|
|
|
<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">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
我的 API Keys
|
|
|
|
|
|
</h3>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 操作按钮 -->
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<!-- 新增 API Key 按钮 -->
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="创建新 API Key"
|
2026-03-15 14:22:59 +08:00
|
|
|
|
@click="openCreateApiKeyDialog"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Plus class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 刷新按钮 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<RefreshButton
|
|
|
|
|
|
:loading="loading"
|
|
|
|
|
|
@click="loadApiKeys"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 加载状态 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="loading"
|
|
|
|
|
|
class="flex items-center justify-center py-12"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<LoadingState message="加载中..." />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 空状态 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="apiKeys.length === 0"
|
|
|
|
|
|
class="flex items-center justify-center py-12"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<EmptyState
|
|
|
|
|
|
title="暂无 API 密钥"
|
|
|
|
|
|
description="创建你的第一个 API 密钥开始使用"
|
|
|
|
|
|
:icon="Key"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #actions>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
size="lg"
|
|
|
|
|
|
class="shadow-lg shadow-primary/20"
|
2026-03-15 14:22:59 +08:00
|
|
|
|
@click="openCreateApiKeyDialog"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Plus class="mr-2 h-4 w-4" />
|
|
|
|
|
|
创建新 API Key
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</EmptyState>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 桌面端表格 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="hidden md:block overflow-x-auto"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<TableHead class="min-w-[200px] h-12 font-semibold">
|
|
|
|
|
|
密钥名称
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="min-w-[160px] h-12 font-semibold">
|
|
|
|
|
|
密钥
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="min-w-[100px] h-12 font-semibold">
|
|
|
|
|
|
费用(USD)
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="min-w-[100px] h-12 font-semibold">
|
|
|
|
|
|
请求次数
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="min-w-[70px] h-12 font-semibold text-center">
|
|
|
|
|
|
状态
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="min-w-[100px] h-12 font-semibold">
|
|
|
|
|
|
最后使用
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="min-w-[80px] h-12 font-semibold text-center">
|
|
|
|
|
|
操作
|
|
|
|
|
|
</TableHead>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
<TableRow
|
|
|
|
|
|
v-for="apiKey in paginatedApiKeys"
|
|
|
|
|
|
:key="apiKey.id"
|
|
|
|
|
|
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 密钥名称 -->
|
|
|
|
|
|
<TableCell class="py-4">
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="text-sm font-semibold truncate"
|
|
|
|
|
|
:title="apiKey.name"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
{{ apiKey.name }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="text-xs text-muted-foreground mt-0.5">
|
|
|
|
|
|
创建于 {{ formatDate(apiKey.created_at) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 密钥显示 -->
|
|
|
|
|
|
<TableCell class="py-4">
|
|
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
|
<code class="text-xs font-mono text-muted-foreground bg-muted/30 px-2 py-1 rounded">
|
|
|
|
|
|
{{ apiKey.key_display || 'sk-••••••••' }}
|
|
|
|
|
|
</code>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-6 w-6"
|
|
|
|
|
|
title="复制完整密钥"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@click="copyApiKey(apiKey)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Copy class="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 费用 -->
|
|
|
|
|
|
<TableCell class="py-4">
|
|
|
|
|
|
<span class="text-sm font-semibold text-amber-600 dark:text-amber-500">
|
|
|
|
|
|
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 请求次数 -->
|
|
|
|
|
|
<TableCell class="py-4">
|
|
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
|
<Activity class="h-3.5 w-3.5 text-muted-foreground" />
|
|
|
|
|
|
<span class="text-sm font-medium text-foreground">
|
|
|
|
|
|
{{ formatNumber(apiKey.total_requests || 0) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 状态 -->
|
|
|
|
|
|
<TableCell class="py-4 text-center">
|
2026-01-16 01:18:54 +08:00
|
|
|
|
<div class="flex flex-col items-center gap-1">
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
:variant="apiKey.is_active ? 'success' : 'secondary'"
|
2026-03-15 14:22:59 +08:00
|
|
|
|
class="h-5 px-2 py-0 text-[10px] font-medium"
|
2026-01-16 01:18:54 +08:00
|
|
|
|
>
|
|
|
|
|
|
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="apiKey.is_locked"
|
|
|
|
|
|
variant="warning"
|
2026-03-15 14:22:59 +08:00
|
|
|
|
class="h-5 px-2 py-0 text-[10px] font-medium"
|
2026-01-16 01:18:54 +08:00
|
|
|
|
>
|
|
|
|
|
|
已锁定
|
|
|
|
|
|
</Badge>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="h-5 px-2 py-0 text-[10px] font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatRateLimitSimple(apiKey.rate_limit) }}
|
|
|
|
|
|
</Badge>
|
2026-04-17 14:21:43 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="h-5 px-2 py-0 text-[10px] font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatConcurrentLimitSimple(apiKey.concurrent_limit) }}
|
|
|
|
|
|
</Badge>
|
2026-01-16 01:18:54 +08:00
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 最后使用时间 -->
|
|
|
|
|
|
<TableCell class="py-4 text-sm text-muted-foreground">
|
|
|
|
|
|
{{ apiKey.last_used_at ? formatRelativeTime(apiKey.last_used_at) : '从未使用' }}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 操作按钮 -->
|
|
|
|
|
|
<TableCell class="py-4">
|
|
|
|
|
|
<div class="flex justify-center gap-1">
|
2026-05-08 23:16:16 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="一键安装并配置 CLI"
|
|
|
|
|
|
@click="openInstallDialog(apiKey)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Terminal class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
:title="apiKey.is_locked ? '已锁定' : '编辑'"
|
|
|
|
|
|
:disabled="apiKey.is_locked"
|
|
|
|
|
|
@click="openEditApiKeyDialog(apiKey)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SquarePen class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
2026-01-16 01:18:54 +08:00
|
|
|
|
:title="apiKey.is_locked ? '已锁定' : (apiKey.is_active ? '禁用' : '启用')"
|
|
|
|
|
|
:disabled="apiKey.is_locked"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@click="toggleApiKey(apiKey)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Power class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
2026-01-16 01:18:54 +08:00
|
|
|
|
:title="apiKey.is_locked ? '已锁定' : '删除'"
|
|
|
|
|
|
:disabled="apiKey.is_locked"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@click="confirmDelete(apiKey)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Trash2 class="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 移动端卡片列表 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="!loading && apiKeys.length > 0"
|
|
|
|
|
|
class="md:hidden space-y-3 p-4"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
v-for="apiKey in paginatedApiKeys"
|
|
|
|
|
|
:key="apiKey.id"
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="group hover:shadow-md hover:border-primary/30 transition-all duration-200"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="p-4">
|
|
|
|
|
|
<!-- 第一行:名称、状态、操作 -->
|
|
|
|
|
|
<div class="flex items-center justify-between mb-2">
|
|
|
|
|
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<h3 class="text-sm font-semibold text-foreground truncate">
|
|
|
|
|
|
{{ apiKey.name }}
|
|
|
|
|
|
</h3>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
:variant="apiKey.is_active ? 'success' : 'secondary'"
|
|
|
|
|
|
class="text-xs px-1.5 py-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ apiKey.is_active ? '活跃' : '禁用' }}
|
|
|
|
|
|
</Badge>
|
2026-01-16 01:18:54 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="apiKey.is_locked"
|
|
|
|
|
|
variant="warning"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
已锁定
|
|
|
|
|
|
</Badge>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatRateLimitSimple(apiKey.rate_limit) }}
|
|
|
|
|
|
</Badge>
|
2026-04-17 14:21:43 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatConcurrentLimitSimple(apiKey.concurrent_limit) }}
|
|
|
|
|
|
</Badge>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-0.5 flex-shrink-0">
|
2026-05-08 23:16:16 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
title="一键安装并配置 CLI"
|
|
|
|
|
|
@click="openInstallDialog(apiKey)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Terminal class="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
:title="apiKey.is_locked ? '已锁定' : '编辑'"
|
|
|
|
|
|
:disabled="apiKey.is_locked"
|
|
|
|
|
|
@click="openEditApiKeyDialog(apiKey)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SquarePen class="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
|
|
|
|
|
title="复制"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@click="copyApiKey(apiKey)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Copy class="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
2026-01-16 01:18:54 +08:00
|
|
|
|
:title="apiKey.is_locked ? '已锁定' : (apiKey.is_active ? '禁用' : '启用')"
|
|
|
|
|
|
:disabled="apiKey.is_locked"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@click="toggleApiKey(apiKey)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Power class="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-7 w-7"
|
2026-01-16 01:18:54 +08:00
|
|
|
|
:title="apiKey.is_locked ? '已锁定' : '删除'"
|
|
|
|
|
|
:disabled="apiKey.is_locked"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@click="confirmDelete(apiKey)"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Trash2 class="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 第二行:密钥、时间、统计 -->
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<div class="flex items-center gap-2 text-xs">
|
|
|
|
|
|
<code class="font-mono text-muted-foreground">{{ apiKey.key_display || 'sk-••••••••' }}</code>
|
|
|
|
|
|
<span class="text-muted-foreground">•</span>
|
|
|
|
|
|
<span class="text-muted-foreground">
|
|
|
|
|
|
{{ apiKey.last_used_at ? formatRelativeTime(apiKey.last_used_at) : '从未使用' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center gap-3 text-xs">
|
|
|
|
|
|
<span class="text-amber-600 dark:text-amber-500 font-semibold">
|
|
|
|
|
|
${{ (apiKey.total_cost_usd || 0).toFixed(4) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="text-muted-foreground">•</span>
|
|
|
|
|
|
<span class="text-foreground font-medium">
|
|
|
|
|
|
{{ formatNumber(apiKey.total_requests || 0) }} 次
|
|
|
|
|
|
</span>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
<span class="text-muted-foreground">•</span>
|
|
|
|
|
|
<span class="text-muted-foreground">
|
|
|
|
|
|
{{ formatRateLimitSimple(apiKey.rate_limit) }}
|
|
|
|
|
|
</span>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 分页 -->
|
|
|
|
|
|
<Pagination
|
|
|
|
|
|
v-if="apiKeys.length > 0"
|
|
|
|
|
|
:current="currentPage"
|
|
|
|
|
|
:total="apiKeys.length"
|
|
|
|
|
|
:page-size="pageSize"
|
2026-01-17 20:21:03 +08:00
|
|
|
|
cache-key="my-api-keys-page-size"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
@update:current="currentPage = $event"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
@update:page-size="pageSize = $event"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 创建 API 密钥对话框 -->
|
|
|
|
|
|
<Dialog v-model="showCreateDialog">
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="border-b border-border px-6 py-4">
|
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
|
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
|
|
|
|
|
<Key class="h-5 w-5 text-primary" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
2026-03-15 14:22:59 +08:00
|
|
|
|
{{ editingApiKey ? '编辑 API 密钥' : '创建 API 密钥' }}
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</h3>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
2026-04-17 14:21:43 +08:00
|
|
|
|
{{ editingApiKey ? '更新密钥名称、速率限制和并发限制' : '创建一个新的密钥用于访问 API 服务' }}
|
2025-12-12 16:15:54 +08:00
|
|
|
|
</p>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-4">
|
|
|
|
|
|
<div class="space-y-2">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Label
|
|
|
|
|
|
for="key-name"
|
|
|
|
|
|
class="text-sm font-semibold"
|
|
|
|
|
|
>密钥名称</Label>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
id="key-name"
|
|
|
|
|
|
v-model="newKeyName"
|
|
|
|
|
|
placeholder="例如:生产环境密钥"
|
|
|
|
|
|
class="h-11 border-border/60"
|
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
给密钥起一个有意义的名称方便识别
|
|
|
|
|
|
</p>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="key-rate-limit"
|
|
|
|
|
|
class="text-sm font-semibold"
|
|
|
|
|
|
>速率限制 (请求/分钟)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="key-rate-limit"
|
|
|
|
|
|
:model-value="newKeyRateLimit ?? ''"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
max="10000"
|
|
|
|
|
|
placeholder="留空不限"
|
|
|
|
|
|
class="h-11 border-border/60"
|
|
|
|
|
|
@update:model-value="(v) => newKeyRateLimit = parseNumberInput(v, { min: 0, max: 10000 })"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
留空不限
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-04-17 14:21:43 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
|
<Label
|
|
|
|
|
|
for="key-concurrent-limit"
|
|
|
|
|
|
class="text-sm font-semibold"
|
|
|
|
|
|
>并发限制</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="key-concurrent-limit"
|
|
|
|
|
|
:model-value="newKeyConcurrentLimit ?? ''"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
max="10000"
|
|
|
|
|
|
placeholder="0 = 不限并发"
|
|
|
|
|
|
class="h-11 border-border/60"
|
|
|
|
|
|
@update:model-value="(v) => newKeyConcurrentLimit = parseNumberInput(v, { min: 0, max: 10000 })"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
{{ editingApiKey ? '留空表示保持当前值,填 0 表示不限并发' : '留空表示不限并发,填 0 也表示不限并发' }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="h-11 px-6"
|
2026-03-15 14:22:59 +08:00
|
|
|
|
@click="closeApiKeyDialog"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
class="h-11 px-6 shadow-lg shadow-primary/20"
|
|
|
|
|
|
:disabled="creating"
|
2026-03-15 14:22:59 +08:00
|
|
|
|
@click="saveApiKey"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Loader2
|
|
|
|
|
|
v-if="creating"
|
|
|
|
|
|
class="animate-spin h-4 w-4 mr-2"
|
|
|
|
|
|
/>
|
2026-03-15 14:22:59 +08:00
|
|
|
|
{{ creating ? (editingApiKey ? '保存中...' : '创建中...') : (editingApiKey ? '保存' : '创建') }}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 新密钥创建成功对话框 -->
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Dialog
|
|
|
|
|
|
v-model="showKeyDialog"
|
|
|
|
|
|
size="lg"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<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">
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
|
|
|
|
|
创建成功
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
请妥善保管, 切勿泄露给他人
|
|
|
|
|
|
</p>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-4">
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
|
<Label class="text-sm font-medium">API 密钥</Label>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
:value="newKeyValue"
|
|
|
|
|
|
readonly
|
|
|
|
|
|
class="flex-1 font-mono text-sm bg-muted/50 h-11"
|
|
|
|
|
|
@click="($event.target as HTMLInputElement)?.select()"
|
|
|
|
|
|
/>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
class="h-11"
|
|
|
|
|
|
@click="copyTextToClipboard(newKeyValue)"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
复制
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
2025-12-12 16:15:54 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
class="h-10 px-5"
|
2026-05-08 23:16:16 +08:00
|
|
|
|
@click="closeCreatedKeyDialog"
|
2025-12-12 16:15:54 +08:00
|
|
|
|
>
|
|
|
|
|
|
确定
|
|
|
|
|
|
</Button>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
2026-05-08 23:16:16 +08:00
|
|
|
|
<!-- 一键安装并配置 CLI 对话框 -->
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
v-model="showInstallDialog"
|
|
|
|
|
|
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-primary/10 flex-shrink-0">
|
|
|
|
|
|
<Terminal class="h-5 w-5 text-primary" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
|
|
|
|
|
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
|
|
|
|
|
一键安装并配置 CLI
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground truncate">
|
|
|
|
|
|
当前密钥:{{ selectedInstallApiKey?.name || '未选择' }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-5">
|
|
|
|
|
|
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
|
|
|
|
|
选择要配置的 CLI 和目标系统,Aether 会生成 15 分钟内有效的一次性 install code。页面命令不会包含原始 API Key。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
|
<Label class="text-sm font-semibold">目标 CLI</Label>
|
|
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-for="option in installCliOptions"
|
|
|
|
|
|
:key="option.value"
|
|
|
|
|
|
:variant="installCli === option.value ? 'default' : 'outline'"
|
|
|
|
|
|
class="justify-start h-auto py-3"
|
|
|
|
|
|
@click="selectInstallCli(option.value)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ option.label }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
|
<Label class="text-sm font-semibold">目标系统</Label>
|
|
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
v-for="option in installSystemOptions"
|
|
|
|
|
|
:key="option.value"
|
|
|
|
|
|
:variant="installSystem === option.value ? 'default' : 'outline'"
|
|
|
|
|
|
class="justify-start h-auto py-3"
|
|
|
|
|
|
@click="selectInstallSystem(option.value)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ option.label }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
|
|
|
|
<Label class="text-sm font-semibold">复制到目标机器执行</Label>
|
2026-05-12 15:34:16 +08:00
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="gap-1.5"
|
|
|
|
|
|
:disabled="installLoading || !installCommand"
|
|
|
|
|
|
:title="installCopied ? '已复制' : '一键复制安装命令'"
|
|
|
|
|
|
@click="copyInstallCommand"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CheckCircle
|
|
|
|
|
|
v-if="installCopied"
|
|
|
|
|
|
class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Copy
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="h-3.5 w-3.5"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{{ installCopied ? '已复制' : '一键复制' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
:disabled="installLoading || !selectedInstallApiKey"
|
|
|
|
|
|
@click="refreshInstallCommand"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ installLoading ? '生成中...' : '重新生成' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-05-08 23:16:16 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
|
|
|
|
|
|
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ installCommand || '正在生成短命令...' }}</pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
{{ installCommandHint }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="h-10 px-5"
|
|
|
|
|
|
@click="showInstallDialog = false"
|
|
|
|
|
|
>
|
|
|
|
|
|
关闭
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
class="h-10 px-5 shadow-lg shadow-primary/20"
|
|
|
|
|
|
:disabled="!installCommand || installLoading"
|
2026-05-12 15:34:16 +08:00
|
|
|
|
@click="copyInstallCommand"
|
2026-05-08 23:16:16 +08:00
|
|
|
|
>
|
2026-05-12 15:34:16 +08:00
|
|
|
|
{{ installCopied ? '已复制' : '复制命令' }}
|
2026-05-08 23:16:16 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
<!-- 删除确认对话框 -->
|
|
|
|
|
|
<AlertDialog
|
|
|
|
|
|
v-model="showDeleteDialog"
|
|
|
|
|
|
type="danger"
|
|
|
|
|
|
title="确认删除"
|
|
|
|
|
|
:description="`确定要删除密钥 "${keyToDelete?.name}" 吗?此操作不可恢复。`"
|
|
|
|
|
|
confirm-text="删除"
|
|
|
|
|
|
:loading="deleting"
|
|
|
|
|
|
@confirm="deleteApiKey"
|
|
|
|
|
|
@cancel="showDeleteDialog = false"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-05-12 15:34:16 +08:00
|
|
|
|
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
|
|
|
|
|
import { meApi, type ApiKey, type InstallSessionTargetSystem, type InstallTargetCli, type ApiKeyInstallSession } from '@/api/me'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import Card from '@/components/ui/card.vue'
|
|
|
|
|
|
import Button from '@/components/ui/button.vue'
|
|
|
|
|
|
import Input from '@/components/ui/input.vue'
|
|
|
|
|
|
import Label from '@/components/ui/label.vue'
|
|
|
|
|
|
import Badge from '@/components/ui/badge.vue'
|
|
|
|
|
|
import { Dialog, Pagination } from '@/components/ui'
|
|
|
|
|
|
import { LoadingState, AlertDialog, EmptyState } from '@/components/common'
|
|
|
|
|
|
import {
|
|
|
|
|
|
Table,
|
|
|
|
|
|
TableBody,
|
|
|
|
|
|
TableCell,
|
|
|
|
|
|
TableHead,
|
|
|
|
|
|
TableHeader,
|
|
|
|
|
|
TableRow
|
|
|
|
|
|
} from '@/components/ui'
|
|
|
|
|
|
import RefreshButton from '@/components/ui/refresh-button.vue'
|
2026-05-08 23:16:16 +08:00
|
|
|
|
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen, Terminal } from 'lucide-vue-next'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
import { useToast } from '@/composables/useToast'
|
2025-12-12 20:22:15 +08:00
|
|
|
|
import { log } from '@/utils/logger'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2026-03-15 14:22:59 +08:00
|
|
|
|
import { formatRateLimitSimple } from '@/utils/format'
|
|
|
|
|
|
import { parseNumberInput } from '@/utils/form'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { getErrorStatus } from '@/types/api-error'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
const { success, error: showError } = useToast()
|
|
|
|
|
|
|
2026-05-08 23:16:16 +08:00
|
|
|
|
const installCliOptions: Array<{ value: InstallTargetCli; label: string }> = [
|
|
|
|
|
|
{ value: 'claude_code', label: 'Claude Code' },
|
|
|
|
|
|
{ value: 'codex_cli', label: 'Codex CLI' },
|
|
|
|
|
|
{ value: 'gemini_cli', label: 'Gemini CLI' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-12 15:34:16 +08:00
|
|
|
|
const installSystemOptions: Array<{ value: InstallSessionTargetSystem; label: string }> = [
|
2026-05-08 23:16:16 +08:00
|
|
|
|
{ value: 'macos', label: 'macOS' },
|
|
|
|
|
|
{ value: 'linux', label: 'Linux' },
|
|
|
|
|
|
{ value: 'windows', label: 'Windows' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const apiKeys = ref<ApiKey[]>([])
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const creating = ref(false)
|
|
|
|
|
|
const deleting = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
// 分页相关
|
|
|
|
|
|
const currentPage = ref(1)
|
|
|
|
|
|
const pageSize = ref(10)
|
|
|
|
|
|
|
|
|
|
|
|
const paginatedApiKeys = computed(() => {
|
|
|
|
|
|
const start = (currentPage.value - 1) * pageSize.value
|
|
|
|
|
|
return apiKeys.value.slice(start, start + pageSize.value)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const showCreateDialog = ref(false)
|
|
|
|
|
|
const showKeyDialog = ref(false)
|
|
|
|
|
|
const showDeleteDialog = ref(false)
|
2026-05-08 23:16:16 +08:00
|
|
|
|
const showInstallDialog = ref(false)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
const newKeyName = ref('')
|
2026-03-15 14:22:59 +08:00
|
|
|
|
const newKeyRateLimit = ref<number | undefined>(undefined)
|
2026-04-17 14:21:43 +08:00
|
|
|
|
const newKeyConcurrentLimit = ref<number | undefined>(undefined)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const newKeyValue = ref('')
|
|
|
|
|
|
const keyToDelete = ref<ApiKey | null>(null)
|
2026-03-15 14:22:59 +08:00
|
|
|
|
const editingApiKey = ref<ApiKey | null>(null)
|
2026-05-08 23:16:16 +08:00
|
|
|
|
const selectedInstallApiKey = ref<ApiKey | null>(null)
|
|
|
|
|
|
const pendingFirstInstallApiKey = ref<ApiKey | null>(null)
|
|
|
|
|
|
const installCli = ref<InstallTargetCli>('claude_code')
|
2026-05-12 15:34:16 +08:00
|
|
|
|
const installSystem = ref<InstallSessionTargetSystem>('linux')
|
2026-05-08 23:16:16 +08:00
|
|
|
|
const installSession = ref<ApiKeyInstallSession | null>(null)
|
|
|
|
|
|
const installLoading = ref(false)
|
2026-05-12 15:34:16 +08:00
|
|
|
|
const installCopied = ref(false)
|
|
|
|
|
|
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
|
2026-05-08 23:16:16 +08:00
|
|
|
|
|
|
|
|
|
|
const installCommand = computed(() => {
|
|
|
|
|
|
if (!installSession.value) return ''
|
|
|
|
|
|
return installSystem.value === 'windows'
|
|
|
|
|
|
? installSession.value.powershell_command
|
|
|
|
|
|
: installSession.value.unix_command
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const installCommandHint = computed(() => {
|
|
|
|
|
|
if (installSystem.value === 'windows') {
|
|
|
|
|
|
return 'Windows 请在 PowerShell 中执行。install code 使用后立即失效,如需再次执行请重新生成。'
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'macOS / Linux 请在 sh 兼容终端中执行。install code 使用后立即失效,如需再次执行请重新生成。'
|
|
|
|
|
|
})
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
2026-05-08 23:16:16 +08:00
|
|
|
|
installSystem.value = detectCurrentSystem()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
loadApiKeys()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-12 15:34:16 +08:00
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
resetInstallCopiedState()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch(showInstallDialog, (isOpen) => {
|
|
|
|
|
|
if (!isOpen) {
|
|
|
|
|
|
resetInstallCopiedState()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-08 23:16:16 +08:00
|
|
|
|
watch(showKeyDialog, (isOpen) => {
|
|
|
|
|
|
if (!isOpen && pendingFirstInstallApiKey.value) {
|
|
|
|
|
|
closeCreatedKeyDialog()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
|
async function loadApiKeys() {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
apiKeys.value = await meApi.getApiKeys()
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (error: unknown) {
|
2025-12-12 20:22:15 +08:00
|
|
|
|
log.error('加载 API 密钥失败:', error)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
const status = getErrorStatus(error)
|
|
|
|
|
|
if (status === undefined) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showError('无法连接到服务器,请检查后端服务是否运行')
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} else if (status === 401) {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showError('认证失败,请重新登录')
|
|
|
|
|
|
} else {
|
2026-02-22 00:43:41 +08:00
|
|
|
|
showError(parseApiError(error, '加载 API 密钥失败'))
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 15:34:16 +08:00
|
|
|
|
function clearInstallCopiedResetTimer() {
|
|
|
|
|
|
if (installCopiedResetTimer) {
|
|
|
|
|
|
clearTimeout(installCopiedResetTimer)
|
|
|
|
|
|
installCopiedResetTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetInstallCopiedState() {
|
|
|
|
|
|
clearInstallCopiedResetTimer()
|
|
|
|
|
|
installCopied.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
function openEditApiKeyDialog(apiKey: ApiKey) {
|
|
|
|
|
|
editingApiKey.value = apiKey
|
|
|
|
|
|
newKeyName.value = apiKey.name || ''
|
|
|
|
|
|
newKeyRateLimit.value = apiKey.rate_limit ?? undefined
|
2026-04-17 14:21:43 +08:00
|
|
|
|
newKeyConcurrentLimit.value = apiKey.concurrent_limit ?? undefined
|
2026-03-15 14:22:59 +08:00
|
|
|
|
showCreateDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openCreateApiKeyDialog() {
|
|
|
|
|
|
editingApiKey.value = null
|
|
|
|
|
|
newKeyName.value = ''
|
|
|
|
|
|
newKeyRateLimit.value = undefined
|
2026-04-17 14:21:43 +08:00
|
|
|
|
newKeyConcurrentLimit.value = undefined
|
2026-03-15 14:22:59 +08:00
|
|
|
|
showCreateDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 15:34:16 +08:00
|
|
|
|
function detectCurrentSystem(): InstallSessionTargetSystem {
|
2026-05-08 23:16:16 +08:00
|
|
|
|
const platform = window.navigator.platform.toLowerCase()
|
|
|
|
|
|
const userAgent = window.navigator.userAgent.toLowerCase()
|
|
|
|
|
|
if (platform.includes('mac')) return 'macos'
|
|
|
|
|
|
if (platform.includes('win') || userAgent.includes('windows')) return 'windows'
|
|
|
|
|
|
return 'linux'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openInstallDialog(apiKey: ApiKey) {
|
|
|
|
|
|
selectedInstallApiKey.value = apiKey
|
|
|
|
|
|
installSession.value = null
|
2026-05-12 15:34:16 +08:00
|
|
|
|
resetInstallCopiedState()
|
2026-05-08 23:16:16 +08:00
|
|
|
|
showInstallDialog.value = true
|
|
|
|
|
|
await refreshInstallCommand()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function selectInstallCli(value: InstallTargetCli) {
|
|
|
|
|
|
installCli.value = value
|
|
|
|
|
|
await refreshInstallCommand()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 15:34:16 +08:00
|
|
|
|
async function selectInstallSystem(value: InstallSessionTargetSystem) {
|
2026-05-08 23:16:16 +08:00
|
|
|
|
installSystem.value = value
|
|
|
|
|
|
await refreshInstallCommand()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshInstallCommand() {
|
|
|
|
|
|
if (!selectedInstallApiKey.value) return
|
|
|
|
|
|
installLoading.value = true
|
|
|
|
|
|
installSession.value = null
|
2026-05-12 15:34:16 +08:00
|
|
|
|
resetInstallCopiedState()
|
2026-05-08 23:16:16 +08:00
|
|
|
|
try {
|
|
|
|
|
|
installSession.value = await meApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
|
|
|
|
|
|
target_cli: installCli.value,
|
|
|
|
|
|
target_system: installSystem.value,
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log.error('生成 CLI 安装命令失败:', error)
|
|
|
|
|
|
showError(parseApiError(error, '生成 CLI 安装命令失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
installLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 15:34:16 +08:00
|
|
|
|
async function copyInstallCommand() {
|
|
|
|
|
|
if (!installCommand.value) return
|
|
|
|
|
|
const copied = await copyTextToClipboard(installCommand.value, false)
|
|
|
|
|
|
if (!copied) return
|
|
|
|
|
|
|
|
|
|
|
|
installCopied.value = true
|
|
|
|
|
|
success('安装命令已复制到剪贴板')
|
|
|
|
|
|
clearInstallCopiedResetTimer()
|
|
|
|
|
|
installCopiedResetTimer = setTimeout(() => {
|
|
|
|
|
|
installCopied.value = false
|
|
|
|
|
|
installCopiedResetTimer = null
|
|
|
|
|
|
}, 2000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 23:16:16 +08:00
|
|
|
|
function closeCreatedKeyDialog() {
|
|
|
|
|
|
showKeyDialog.value = false
|
|
|
|
|
|
const pending = pendingFirstInstallApiKey.value
|
|
|
|
|
|
pendingFirstInstallApiKey.value = null
|
|
|
|
|
|
if (pending) {
|
|
|
|
|
|
void openInstallDialog(pending)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 14:22:59 +08:00
|
|
|
|
function closeApiKeyDialog() {
|
|
|
|
|
|
showCreateDialog.value = false
|
|
|
|
|
|
editingApiKey.value = null
|
|
|
|
|
|
newKeyName.value = ''
|
|
|
|
|
|
newKeyRateLimit.value = undefined
|
2026-04-17 14:21:43 +08:00
|
|
|
|
newKeyConcurrentLimit.value = undefined
|
2026-03-15 14:22:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveApiKey() {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
if (!newKeyName.value.trim()) {
|
|
|
|
|
|
showError('请输入密钥名称')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
creating.value = true
|
|
|
|
|
|
try {
|
2026-05-08 23:16:16 +08:00
|
|
|
|
const isCreatingFirstApiKey = !editingApiKey.value && apiKeys.value.length === 0
|
2026-03-15 14:22:59 +08:00
|
|
|
|
if (editingApiKey.value) {
|
|
|
|
|
|
await meApi.updateApiKey(editingApiKey.value.id, {
|
|
|
|
|
|
name: newKeyName.value,
|
|
|
|
|
|
rate_limit: newKeyRateLimit.value ?? 0,
|
2026-04-17 14:21:43 +08:00
|
|
|
|
concurrent_limit: newKeyConcurrentLimit.value,
|
2026-03-15 14:22:59 +08:00
|
|
|
|
})
|
|
|
|
|
|
success('API 密钥更新成功')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const newKey = await meApi.createApiKey({
|
|
|
|
|
|
name: newKeyName.value,
|
|
|
|
|
|
rate_limit: newKeyRateLimit.value ?? 0,
|
2026-04-17 14:21:43 +08:00
|
|
|
|
concurrent_limit: newKeyConcurrentLimit.value,
|
2026-03-15 14:22:59 +08:00
|
|
|
|
})
|
|
|
|
|
|
newKeyValue.value = newKey.key || ''
|
2026-05-08 23:16:16 +08:00
|
|
|
|
if (isCreatingFirstApiKey) {
|
|
|
|
|
|
pendingFirstInstallApiKey.value = newKey
|
|
|
|
|
|
}
|
2026-03-15 14:22:59 +08:00
|
|
|
|
showKeyDialog.value = true
|
|
|
|
|
|
success('API 密钥创建成功')
|
|
|
|
|
|
}
|
|
|
|
|
|
closeApiKeyDialog()
|
2025-12-10 20:52:44 +08:00
|
|
|
|
await loadApiKeys()
|
|
|
|
|
|
} catch (error) {
|
2026-03-15 14:22:59 +08:00
|
|
|
|
log.error(editingApiKey.value ? '更新 API 密钥失败:' : '创建 API 密钥失败:', error)
|
|
|
|
|
|
showError(editingApiKey.value ? '更新 API 密钥失败' : '创建 API 密钥失败')
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
creating.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function confirmDelete(apiKey: ApiKey) {
|
|
|
|
|
|
keyToDelete.value = apiKey
|
|
|
|
|
|
showDeleteDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function deleteApiKey() {
|
|
|
|
|
|
if (!keyToDelete.value) return
|
|
|
|
|
|
|
|
|
|
|
|
deleting.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await meApi.deleteApiKey(keyToDelete.value.id)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
apiKeys.value = apiKeys.value.filter(k => k.id !== keyToDelete.value?.id)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showDeleteDialog.value = false
|
|
|
|
|
|
success('API 密钥已删除')
|
|
|
|
|
|
} catch (error) {
|
2025-12-12 20:22:15 +08:00
|
|
|
|
log.error('删除 API 密钥失败:', error)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showError('删除 API 密钥失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
deleting.value = false
|
|
|
|
|
|
keyToDelete.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function toggleApiKey(apiKey: ApiKey) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updated = await meApi.toggleApiKey(apiKey.id)
|
|
|
|
|
|
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
|
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
|
apiKeys.value[index].is_active = updated.is_active
|
|
|
|
|
|
}
|
|
|
|
|
|
success(updated.is_active ? '密钥已启用' : '密钥已禁用')
|
|
|
|
|
|
} catch (error) {
|
2025-12-12 20:22:15 +08:00
|
|
|
|
log.error('切换密钥状态失败:', error)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showError('操作失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function copyApiKey(apiKey: ApiKey) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 调用后端 API 获取完整密钥
|
|
|
|
|
|
const response = await meApi.getFullApiKey(apiKey.id)
|
2026-05-12 15:34:16 +08:00
|
|
|
|
const copied = await copyTextToClipboard(response.key, false) // 不显示内部提示
|
|
|
|
|
|
if (copied) {
|
|
|
|
|
|
success('完整密钥已复制到剪贴板')
|
|
|
|
|
|
}
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} catch (error) {
|
2025-12-12 20:22:15 +08:00
|
|
|
|
log.error('复制密钥失败:', error)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showError('复制失败,请重试')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 15:34:16 +08:00
|
|
|
|
async function copyTextToClipboard(text: string, showToast: boolean = true): Promise<boolean> {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
try {
|
|
|
|
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
|
|
|
|
await navigator.clipboard.writeText(text)
|
|
|
|
|
|
if (showToast) success('已复制到剪贴板')
|
2026-05-12 15:34:16 +08:00
|
|
|
|
return true
|
2025-12-10 20:52:44 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
const textArea = document.createElement('textarea')
|
|
|
|
|
|
textArea.value = text
|
|
|
|
|
|
textArea.style.position = 'fixed'
|
|
|
|
|
|
textArea.style.left = '-999999px'
|
|
|
|
|
|
textArea.style.top = '-999999px'
|
|
|
|
|
|
document.body.appendChild(textArea)
|
|
|
|
|
|
textArea.focus()
|
|
|
|
|
|
textArea.select()
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const successful = document.execCommand('copy')
|
|
|
|
|
|
if (successful && showToast) {
|
|
|
|
|
|
success('已复制到剪贴板')
|
2026-05-12 15:34:16 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (successful) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
} else {
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showError('复制失败,请手动复制')
|
2026-05-12 15:34:16 +08:00
|
|
|
|
return false
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
document.body.removeChild(textArea)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2025-12-12 20:22:15 +08:00
|
|
|
|
log.error('复制失败:', error)
|
2025-12-10 20:52:44 +08:00
|
|
|
|
showError('复制失败,请手动选择文本进行复制')
|
2026-05-12 15:34:16 +08:00
|
|
|
|
return false
|
2025-12-10 20:52:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatNumber(num: number | undefined | null): string {
|
|
|
|
|
|
if (num === undefined || num === null) {
|
|
|
|
|
|
return '0'
|
|
|
|
|
|
}
|
|
|
|
|
|
return num.toLocaleString('zh-CN')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-17 14:21:43 +08:00
|
|
|
|
function formatConcurrentLimitSimple(concurrentLimit?: number | null): string {
|
|
|
|
|
|
if (concurrentLimit == null || concurrentLimit === 0) {
|
|
|
|
|
|
return '不限并发'
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${concurrentLimit} 并发`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 00:35:32 +08:00
|
|
|
|
function formatDate(dateString?: string | null): string {
|
|
|
|
|
|
if (!dateString) return '未知'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const date = new Date(dateString)
|
2026-04-30 00:35:32 +08:00
|
|
|
|
if (Number.isNaN(date.getTime())) return '未知'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
return date.toLocaleDateString('zh-CN', {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: '2-digit',
|
|
|
|
|
|
day: '2-digit'
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatRelativeTime(dateString: string): string {
|
|
|
|
|
|
const date = new Date(dateString)
|
2026-04-30 00:35:32 +08:00
|
|
|
|
if (Number.isNaN(date.getTime())) return '未知'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
const now = new Date()
|
|
|
|
|
|
const diffMs = now.getTime() - date.getTime()
|
|
|
|
|
|
const diffMins = Math.floor(diffMs / (1000 * 60))
|
|
|
|
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
|
|
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
|
|
|
|
|
|
|
|
|
|
if (diffMins < 1) return '刚刚'
|
|
|
|
|
|
if (diffMins < 60) return `${diffMins}分钟前`
|
|
|
|
|
|
if (diffHours < 24) return `${diffHours}小时前`
|
|
|
|
|
|
if (diffDays < 7) return `${diffDays}天前`
|
|
|
|
|
|
|
|
|
|
|
|
return formatDate(dateString)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
</script>
|