Merge remote-tracking branch 'origin/aether-rust-pioneer' into payment-billing-plans

# Conflicts:
#	crates/aether-data/src/lifecycle/bootstrap/postgres.rs
#	crates/aether-data/src/lifecycle/migrate/tests.rs
This commit is contained in:
Entropy.Xu
2026-05-13 01:29:18 +08:00
185 changed files with 14980 additions and 3638 deletions

View File

@@ -317,6 +317,15 @@
</TableCell>
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="一键安装并配置 CLI"
@click="openInstallDialog(apiKey)"
>
<Terminal class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -533,6 +542,15 @@
</div>
<div class="grid grid-cols-2 gap-2 pt-0.5">
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="openInstallDialog(apiKey)"
>
<Terminal class="mr-1.5 h-3.5 w-3.5" />
安装
</Button>
<Button
variant="outline"
size="sm"
@@ -652,6 +670,123 @@
</template>
</Dialog>
<!-- 一键安装并配置 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 || selectedInstallApiKey?.key_display || '未选择' }}
</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>
<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>
</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"
@click="copyInstallCommand"
>
{{ installCopied ? '已复制' : '复制命令' }}
</Button>
</template>
</Dialog>
<WalletOpsDrawer
:open="showWalletActionDrawer"
:wallet="walletActionTarget?.wallet || null"
@@ -667,11 +802,12 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from '@/api/me'
import type { AdminWallet } from '@/api/admin-wallets'
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
@@ -710,7 +846,8 @@ import {
Copy,
CheckCircle,
SquarePen,
Search
Search,
Terminal
} from 'lucide-vue-next'
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
@@ -729,8 +866,16 @@ const total = ref(0)
const currentPage = ref(1)
const limit = ref(100)
const showNewKeyDialog = ref(false)
const showInstallDialog = ref(false)
const newKeyValue = ref('')
const keyInput = ref<HTMLInputElement>()
const selectedInstallApiKey = ref<AdminApiKey | null>(null)
const installCli = ref<InstallTargetCli>('claude_code')
const installSystem = ref<InstallSessionTargetSystem>('linux')
const installSession = ref<ApiKeyInstallSession | null>(null)
const installLoading = ref(false)
const installCopied = ref(false)
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
// 统一的表单对话框状态
const showKeyFormDialog = ref(false)
@@ -750,6 +895,18 @@ const statusFilters = [
{ value: 'inactive' as const, label: '禁用' }
]
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' }
]
const installSystemOptions: Array<{ value: InstallSessionTargetSystem; label: string }> = [
{ value: 'macos', label: 'macOS' },
{ value: 'linux', label: 'Linux' },
{ value: 'windows', label: 'Windows' }
]
const balanceFilters = [
{ value: 'all' as const, label: '全部类型' },
{ value: 'limited' as const, label: '限额' },
@@ -760,6 +917,20 @@ const hasActiveFilters = computed(() => {
return searchQuery.value !== '' || filterStatus.value !== 'all' || filterBalance.value !== 'all'
})
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 使用后立即失效,如需再次执行请重新生成。'
})
function clearFilters() {
searchQuery.value = ''
filterStatus.value = 'all'
@@ -808,9 +979,32 @@ const showWalletActionDrawer = ref(false)
const walletActionTarget = ref<{ apiKey: AdminApiKey; wallet: AdminWallet } | null>(null)
onMounted(async () => {
installSystem.value = detectCurrentSystem()
await refreshApiKeys()
})
onBeforeUnmount(() => {
resetInstallCopiedState()
})
watch(showInstallDialog, (isOpen) => {
if (!isOpen) {
resetInstallCopiedState()
}
})
function clearInstallCopiedResetTimer() {
if (installCopiedResetTimer) {
clearTimeout(installCopiedResetTimer)
installCopiedResetTimer = null
}
}
function resetInstallCopiedState() {
clearInstallCopiedResetTimer()
installCopied.value = false
}
function buildAdminWalletFromApiKey(apiKey: AdminApiKey): AdminWallet | null {
if (!apiKey.wallet?.id) {
return null
@@ -867,6 +1061,64 @@ function handlePageChange(page: number) {
refreshApiKeys()
}
function detectCurrentSystem(): InstallSessionTargetSystem {
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: AdminApiKey) {
selectedInstallApiKey.value = apiKey
installSession.value = null
resetInstallCopiedState()
showInstallDialog.value = true
await refreshInstallCommand()
}
async function selectInstallCli(value: InstallTargetCli) {
installCli.value = value
await refreshInstallCommand()
}
async function selectInstallSystem(value: InstallSessionTargetSystem) {
installSystem.value = value
await refreshInstallCommand()
}
async function refreshInstallCommand() {
if (!selectedInstallApiKey.value) return
installLoading.value = true
installSession.value = null
resetInstallCopiedState()
try {
installSession.value = await adminApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
target_cli: installCli.value,
target_system: installSystem.value,
})
} catch (err: unknown) {
log.error('生成 CLI 安装命令失败:', err)
error(parseApiError(err, '生成 CLI 安装命令失败'))
} finally {
installLoading.value = false
}
}
async function copyInstallCommand() {
if (!installCommand.value) return
const copied = await copyToClipboard(installCommand.value, false)
if (!copied) return
installCopied.value = true
success('安装命令已复制到剪贴板')
clearInstallCopiedResetTimer()
installCopiedResetTimer = setTimeout(() => {
installCopied.value = false
installCopiedResetTimer = null
}, 2000)
}
async function toggleApiKey(apiKey: AdminApiKey) {
try {
const response = await adminApi.toggleApiKey(apiKey.id)
@@ -1063,7 +1315,12 @@ async function copyKeyPrefix(apiKey: AdminApiKey) {
try {
// 调用后端 API 获取完整密钥
const response = await adminApi.getFullApiKey(apiKey.id)
await copyToClipboard(response.key)
const copied = await copyToClipboard(response.key, false)
if (copied) {
success('完整密钥已复制到剪贴板')
} else {
error('复制失败,请手动复制')
}
} catch (err) {
log.error('复制密钥失败:', err)
error('复制失败,请重试')

View File

@@ -405,6 +405,12 @@
>
最后使用
</SortableTableHead>
<TableHead
class="font-semibold text-center whitespace-nowrap"
:style="{ width: desktopColumnWidths.score }"
>
分数
</TableHead>
<SortableTableHead
class="font-semibold text-center whitespace-nowrap"
column-key="status"
@@ -665,6 +671,67 @@
{{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }}
</span>
</TableCell>
<TableCell class="py-3 text-center align-middle">
<div class="inline-flex items-center justify-center gap-1">
<span class="font-mono text-xs tabular-nums text-foreground/90">
{{ formatPoolScore(key.pool_score?.score) }}
</span>
<Popover
v-if="key.pool_score"
:open="scoreDesktopPopoverOpenKeyId === key.key_id"
@update:open="(open: boolean) => handleScoreDesktopPopoverToggle(key.key_id, open)"
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 rounded-full border border-transparent text-muted-foreground/80 hover:border-border/60 hover:bg-muted/60 hover:text-foreground"
title="查看评分计算结果"
aria-label="查看评分计算结果"
@click.stop
>
<CircleHelp class="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
v-if="scoreDesktopPopoverOpenKeyId === key.key_id"
class="w-[22rem] max-w-[calc(100vw-1rem)] overflow-hidden rounded-xl border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
side="bottom"
align="end"
:side-offset="8"
>
<div class="text-left">
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2.5">
<span class="text-xs font-semibold text-foreground">评分计算结果</span>
<span class="font-mono text-xs tabular-nums text-foreground/90">
{{ formatPoolScore(key.pool_score?.score) }}
</span>
</div>
<div class="space-y-2 px-3 py-2.5">
<div class="flex flex-wrap items-center gap-1.5">
<Badge
variant="outline"
class="h-5 rounded-md border-border/60 bg-background/60 px-2 text-[10px] font-normal"
>
{{ getPoolScoreHardStateLabel(key.pool_score?.hard_state) }}
</Badge>
<Badge
variant="secondary"
class="h-5 rounded-md px-2 text-[10px] font-normal"
>
{{ getPoolScoreProbeStatusLabel(key.pool_score?.probe_status) }}
</Badge>
<span class="text-[10px] text-muted-foreground">
更新 {{ formatUnixSeconds(key.pool_score?.updated_at) }}
</span>
</div>
<pre class="max-h-56 overflow-auto rounded-md border border-border/50 bg-muted/30 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap break-words">{{ formatPoolScoreReason(key.pool_score?.score_reason) }}</pre>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</TableCell>
<TableCell class="py-3 text-center">
<Badge
:variant="keyUiStateMap[key.key_id]?.schedulingBadgeVariant || 'default'"
@@ -928,6 +995,68 @@
<span class="text-muted-foreground">最后使用</span>
<span class="font-medium text-foreground/90">{{ keyUiStateMap[key.key_id]?.lastUsedRelative || '-' }}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground">分数</span>
<div class="flex items-center gap-1">
<span class="font-mono font-medium text-foreground/90 tabular-nums">
{{ formatPoolScore(key.pool_score?.score) }}
</span>
<Popover
v-if="key.pool_score"
:open="scoreMobilePopoverOpenKeyId === key.key_id"
@update:open="(open: boolean) => handleScoreMobilePopoverToggle(key.key_id, open)"
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 rounded-full border border-transparent text-muted-foreground/80 hover:border-border/60 hover:bg-muted/60 hover:text-foreground"
title="查看评分计算结果"
aria-label="查看评分计算结果"
@click.stop
>
<CircleHelp class="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
v-if="scoreMobilePopoverOpenKeyId === key.key_id"
class="w-[22rem] max-w-[calc(100vw-1rem)] overflow-hidden rounded-xl border-border/60 bg-card/95 p-0 text-card-foreground shadow-xl shadow-black/5 backdrop-blur supports-[backdrop-filter]:bg-card/90"
side="bottom"
align="end"
:side-offset="8"
>
<div class="text-left">
<div class="flex items-center justify-between gap-3 border-b border-border/60 bg-muted/30 px-3 py-2.5">
<span class="text-xs font-semibold text-foreground">评分计算结果</span>
<span class="font-mono text-xs tabular-nums text-foreground/90">
{{ formatPoolScore(key.pool_score?.score) }}
</span>
</div>
<div class="space-y-2 px-3 py-2.5">
<div class="flex flex-wrap items-center gap-1.5">
<Badge
variant="outline"
class="h-5 rounded-md border-border/60 bg-background/60 px-2 text-[10px] font-normal"
>
{{ getPoolScoreHardStateLabel(key.pool_score?.hard_state) }}
</Badge>
<Badge
variant="secondary"
class="h-5 rounded-md px-2 text-[10px] font-normal"
>
{{ getPoolScoreProbeStatusLabel(key.pool_score?.probe_status) }}
</Badge>
<span class="text-[10px] text-muted-foreground">
更新 {{ formatUnixSeconds(key.pool_score?.updated_at) }}
</span>
</div>
<pre class="max-h-56 overflow-auto rounded-md border border-border/50 bg-muted/30 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap break-words">{{ formatPoolScoreReason(key.pool_score?.score_reason) }}</pre>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
@@ -1273,6 +1402,7 @@ import {
Users,
Settings2,
SlidersHorizontal,
CircleHelp,
} from 'lucide-vue-next'
import {
@@ -1391,6 +1521,8 @@ import {
getQuotaDisplayText,
} from '@/utils/providerKeyQuota'
type PoolKeyScore = NonNullable<PoolKeyDetail['pool_score']>
const { success, error: showError, warning: showWarning } = useToast()
const { confirm } = useConfirm()
const { copyToClipboard } = useClipboard()
@@ -1432,6 +1564,24 @@ const poolKeyStatusFilterOptions: Array<{ value: PoolManagementViewState['status
{ value: 'cooldown', label: '冷却中' },
{ value: 'inactive', label: '禁用' },
]
const poolScoreHardStateOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'available', label: '可用' },
{ value: 'unknown', label: '未知' },
{ value: 'cooldown', label: '冷却' },
{ value: 'quota_exhausted', label: '额度耗尽' },
{ value: 'auth_invalid', label: '授权无效' },
{ value: 'banned', label: '封禁' },
{ value: 'inactive', label: '禁用' },
]
const poolScoreProbeStatusOptions = [
{ value: 'all', label: '全部探测' },
{ value: 'never', label: '未探测' },
{ value: 'ok', label: '正常' },
{ value: 'failed', label: '失败' },
{ value: 'stale', label: '过期' },
{ value: 'in_progress', label: '探测中' },
]
async function loadOverview(options: { cacheTtlMs?: number } = {}) {
const requestId = ++overviewRequestId
@@ -1662,23 +1812,25 @@ const showAccountQuotaColumn = computed(() => {
const desktopColumnWidths = computed(() => {
if (showAccountQuotaColumn.value) {
return {
name: '22%',
quota: '21%',
stats: '15%',
name: '21%',
quota: '18%',
stats: '13%',
imported: '10%',
lastUsed: '9%',
lastUsed: '8%',
score: '9%',
status: '7%',
actions: '16%',
actions: '14%',
}
}
return {
name: '34%',
name: '31%',
quota: '0%',
stats: '16%',
imported: '12%',
lastUsed: '12%',
status: '9%',
actions: '17%',
stats: '15%',
imported: '11%',
lastUsed: '11%',
score: '9%',
status: '8%',
actions: '15%',
}
})
@@ -1703,6 +1855,8 @@ async function selectProvider(
closeProviderProxyPopovers()
proxyDesktopPopoverOpenKeyId.value = null
proxyMobilePopoverOpenKeyId.value = null
scoreDesktopPopoverOpenKeyId.value = null
scoreMobilePopoverOpenKeyId.value = null
suppressFiltersWatch = true
if (!options.preservePagination) {
currentPage.value = 1
@@ -1766,6 +1920,8 @@ const resettingCycleKeyId = ref<string | null>(null)
const savingProxyKeyId = ref<string | null>(null)
const proxyDesktopPopoverOpenKeyId = ref<string | null>(null)
const proxyMobilePopoverOpenKeyId = ref<string | null>(null)
const scoreDesktopPopoverOpenKeyId = ref<string | null>(null)
const scoreMobilePopoverOpenKeyId = ref<string | null>(null)
const deletingKeyId = ref<string | null>(null)
const togglingKeyId = ref<string | null>(null)
const editingPriorityKeyId = ref<string | null>(null)
@@ -2449,6 +2605,20 @@ function getKeyProxyNodeName(key: PoolKeyDetail): string | null {
return node ? node.name : `${key.proxy.node_id.slice(0, 8)}...`
}
function handleScoreDesktopPopoverToggle(keyId: string, open: boolean) {
scoreDesktopPopoverOpenKeyId.value = open ? keyId : null
if (open) {
scoreMobilePopoverOpenKeyId.value = null
}
}
function handleScoreMobilePopoverToggle(keyId: string, open: boolean) {
scoreMobilePopoverOpenKeyId.value = open ? keyId : null
if (open) {
scoreDesktopPopoverOpenKeyId.value = null
}
}
function handleProxyDesktopPopoverToggle(keyId: string, open: boolean) {
proxyDesktopPopoverOpenKeyId.value = open ? keyId : null
if (open) {
@@ -3584,6 +3754,37 @@ function formatStatUsd(value: number | string | null | undefined): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatPoolScore(value: number | null | undefined): string {
const n = Number(value)
if (!Number.isFinite(n)) return '-'
return n.toFixed(3)
}
function formatPoolScoreReason(value: PoolKeyScore['score_reason'] | null | undefined): string {
if (!value) return '暂无计算结果'
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
function getPoolScoreHardStateLabel(value: PoolKeyScore['hard_state'] | null | undefined): string {
if (!value) return '-'
return poolScoreHardStateOptions.find(item => item.value === value)?.label || value
}
function getPoolScoreProbeStatusLabel(value: PoolKeyScore['probe_status'] | null | undefined): string {
if (!value) return '-'
return poolScoreProbeStatusOptions.find(item => item.value === value)?.label || value
}
function formatUnixSeconds(seconds: number | null | undefined): string {
const raw = Number(seconds ?? 0)
if (!Number.isFinite(raw) || raw <= 0) return '-'
return formatRelativeTime(new Date(raw * 1000).toISOString())
}
function formatRelativeTime(isoStr: string): string {
const date = new Date(isoStr)
const pad = (n: number) => String(n).padStart(2, '0')

View File

@@ -139,11 +139,12 @@ vi.mock('lucide-vue-next', async () => {
Users: Icon,
Settings2: Icon,
SlidersHorizontal: Icon,
CircleHelp: Icon,
}
})
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const { computed, defineComponent, h, inject, provide } = await import('vue')
const passthrough = (name: string, tag = 'div') => defineComponent({
name,
inheritAttrs: false,
@@ -204,6 +205,53 @@ vi.mock('@/components/ui', async () => {
},
})
const popoverContextKey = Symbol('PopoverStubContext')
const Popover = defineComponent({
name: 'PopoverStub',
inheritAttrs: false,
props: {
open: Boolean,
},
emits: ['update:open'],
setup(props, { slots, emit }) {
const context = {
open: computed(() => props.open),
toggle: () => emit('update:open', !props.open),
}
provide(popoverContextKey, context)
return () => slots.default?.()
},
})
const PopoverTrigger = defineComponent({
name: 'PopoverTriggerStub',
inheritAttrs: false,
setup(_, { attrs, slots }) {
const context = inject<{ open: { value: boolean }, toggle: () => void } | null>(popoverContextKey, null)
return () => {
return h('span', {
...attrs,
onClickCapture: () => {
context?.toggle()
},
}, slots.default?.())
}
},
})
const PopoverContent = defineComponent({
name: 'PopoverContentStub',
inheritAttrs: false,
setup(_, { attrs, slots }) {
const context = inject<{ open: { value: boolean } } | null>(popoverContextKey, null)
return () => {
if (!context?.open.value) return null
return h('div', { ...attrs, 'data-state': 'open' }, slots.default?.())
}
},
})
return {
Card: passthrough('CardStub'),
Badge: passthrough('BadgeStub', 'span'),
@@ -224,9 +272,9 @@ vi.mock('@/components/ui', async () => {
TableCell: passthrough('TableCellStub', 'td'),
Switch,
Pagination,
Popover: passthrough('PopoverStub'),
PopoverTrigger: passthrough('PopoverTriggerStub'),
PopoverContent: passthrough('PopoverContentStub'),
Popover,
PopoverTrigger,
PopoverContent,
}
})
@@ -523,6 +571,87 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(root.textContent).not.toContain('总计')
})
it('renders unified pool score in the key list with a calculation entry point', async () => {
const scoredKey = createPoolKey('codex', {
pool_score: {
id: 'pms-account-score',
capability: 'account',
scope_kind: 'account',
scope_id: null,
score: 0.875,
hard_state: 'available',
score_version: 1,
score_reason: { weights: { manual_priority: 0.3 } },
last_ranked_at: 1_700_000_000,
last_scheduled_at: 1_700_000_010,
last_success_at: 1_700_000_020,
last_failure_at: null,
failure_count: 0,
last_probe_attempt_at: 1_700_000_030,
last_probe_success_at: 1_700_000_040,
last_probe_failure_at: null,
probe_failure_count: 0,
probe_status: 'ok',
updated_at: 1_700_000_050,
},
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(scoredKey))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
const root = mountPoolManagement()
await settle()
expect(root.textContent).toContain('0.875')
expect(root.querySelectorAll('button[title="查看评分计算结果"]').length).toBeGreaterThan(0)
})
it('opens only one score popover across desktop and mobile layouts', async () => {
const scoredKey = createPoolKey('codex', {
pool_score: {
id: 'pms-account-score',
capability: 'account',
scope_kind: 'account',
scope_id: null,
score: 0.662,
hard_state: 'available',
score_version: 1,
score_reason: {
rules: {
probe_failure_penalty: 0.05,
},
},
last_ranked_at: 1_700_000_000,
last_scheduled_at: null,
last_success_at: null,
last_failure_at: null,
failure_count: 0,
last_probe_attempt_at: null,
last_probe_success_at: null,
last_probe_failure_at: null,
probe_failure_count: 0,
probe_status: 'ok',
updated_at: 1_700_000_050,
},
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(scoredKey))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
const root = mountPoolManagement()
await settle()
const helpButtons = root.querySelectorAll<HTMLButtonElement>('button[title="查看评分计算结果"]')
expect(helpButtons.length).toBe(2)
helpButtons[0]?.click()
await settle()
expect(root.querySelectorAll('pre').length).toBe(1)
expect(root.textContent).toContain('评分计算结果')
expect(root.textContent).toContain('0.662')
})
it('refreshes quota only for keys on the current page', async () => {
const pageKeys = [
createPoolKey('codex', { key_id: 'codex-page-key-1', quota_updated_at: null }),

View File

@@ -589,14 +589,34 @@
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-sm font-semibold">复制到目标机器执行</Label>
<Button
variant="ghost"
size="sm"
:disabled="installLoading || !selectedInstallApiKey"
@click="refreshInstallCommand"
>
{{ installLoading ? '生成中...' : '重新生成' }}
</Button>
<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>
</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>
@@ -618,9 +638,9 @@
<Button
class="h-10 px-5 shadow-lg shadow-primary/20"
:disabled="!installCommand || installLoading"
@click="copyTextToClipboard(installCommand)"
@click="copyInstallCommand"
>
复制命令
{{ installCopied ? '已复制' : '复制命令' }}
</Button>
</template>
</Dialog>
@@ -640,8 +660,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { meApi, type ApiKey, type InstallTargetCli, type InstallTargetSystem, type ApiKeyInstallSession } from '@/api/me'
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { meApi, type ApiKey, type InstallSessionTargetSystem, type InstallTargetCli, type ApiKeyInstallSession } from '@/api/me'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
@@ -674,7 +694,7 @@ const installCliOptions: Array<{ value: InstallTargetCli; label: string }> = [
{ value: 'gemini_cli', label: 'Gemini CLI' }
]
const installSystemOptions: Array<{ value: Exclude<InstallTargetSystem, 'auto'>; label: string }> = [
const installSystemOptions: Array<{ value: InstallSessionTargetSystem; label: string }> = [
{ value: 'macos', label: 'macOS' },
{ value: 'linux', label: 'Linux' },
{ value: 'windows', label: 'Windows' }
@@ -708,9 +728,11 @@ const editingApiKey = ref<ApiKey | null>(null)
const selectedInstallApiKey = ref<ApiKey | null>(null)
const pendingFirstInstallApiKey = ref<ApiKey | null>(null)
const installCli = ref<InstallTargetCli>('claude_code')
const installSystem = ref<Exclude<InstallTargetSystem, 'auto'>>('linux')
const installSystem = ref<InstallSessionTargetSystem>('linux')
const installSession = ref<ApiKeyInstallSession | null>(null)
const installLoading = ref(false)
const installCopied = ref(false)
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
const installCommand = computed(() => {
if (!installSession.value) return ''
@@ -731,6 +753,16 @@ onMounted(() => {
loadApiKeys()
})
onBeforeUnmount(() => {
resetInstallCopiedState()
})
watch(showInstallDialog, (isOpen) => {
if (!isOpen) {
resetInstallCopiedState()
}
})
watch(showKeyDialog, (isOpen) => {
if (!isOpen && pendingFirstInstallApiKey.value) {
closeCreatedKeyDialog()
@@ -756,6 +788,18 @@ async function loadApiKeys() {
}
}
function clearInstallCopiedResetTimer() {
if (installCopiedResetTimer) {
clearTimeout(installCopiedResetTimer)
installCopiedResetTimer = null
}
}
function resetInstallCopiedState() {
clearInstallCopiedResetTimer()
installCopied.value = false
}
function openEditApiKeyDialog(apiKey: ApiKey) {
editingApiKey.value = apiKey
newKeyName.value = apiKey.name || ''
@@ -772,7 +816,7 @@ function openCreateApiKeyDialog() {
showCreateDialog.value = true
}
function detectCurrentSystem(): Exclude<InstallTargetSystem, 'auto'> {
function detectCurrentSystem(): InstallSessionTargetSystem {
const platform = window.navigator.platform.toLowerCase()
const userAgent = window.navigator.userAgent.toLowerCase()
if (platform.includes('mac')) return 'macos'
@@ -783,6 +827,7 @@ function detectCurrentSystem(): Exclude<InstallTargetSystem, 'auto'> {
async function openInstallDialog(apiKey: ApiKey) {
selectedInstallApiKey.value = apiKey
installSession.value = null
resetInstallCopiedState()
showInstallDialog.value = true
await refreshInstallCommand()
}
@@ -792,7 +837,7 @@ async function selectInstallCli(value: InstallTargetCli) {
await refreshInstallCommand()
}
async function selectInstallSystem(value: Exclude<InstallTargetSystem, 'auto'>) {
async function selectInstallSystem(value: InstallSessionTargetSystem) {
installSystem.value = value
await refreshInstallCommand()
}
@@ -801,6 +846,7 @@ async function refreshInstallCommand() {
if (!selectedInstallApiKey.value) return
installLoading.value = true
installSession.value = null
resetInstallCopiedState()
try {
installSession.value = await meApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
target_cli: installCli.value,
@@ -814,6 +860,20 @@ async function refreshInstallCommand() {
}
}
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)
}
function closeCreatedKeyDialog() {
showKeyDialog.value = false
const pending = pendingFirstInstallApiKey.value
@@ -911,19 +971,22 @@ async function copyApiKey(apiKey: ApiKey) {
try {
// 调用后端 API 获取完整密钥
const response = await meApi.getFullApiKey(apiKey.id)
await copyTextToClipboard(response.key, false) // 不显示内部提示
success('完整密钥已复制到剪贴板')
const copied = await copyTextToClipboard(response.key, false) // 不显示内部提示
if (copied) {
success('完整密钥已复制到剪贴板')
}
} catch (error) {
log.error('复制密钥失败:', error)
showError('复制失败,请重试')
}
}
async function copyTextToClipboard(text: string, showToast: boolean = true) {
async function copyTextToClipboard(text: string, showToast: boolean = true): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
if (showToast) success('已复制到剪贴板')
return true
} else {
const textArea = document.createElement('textarea')
textArea.value = text
@@ -938,8 +1001,12 @@ async function copyTextToClipboard(text: string, showToast: boolean = true) {
const successful = document.execCommand('copy')
if (successful && showToast) {
success('已复制到剪贴板')
} else if (!successful) {
}
if (successful) {
return true
} else {
showError('复制失败,请手动复制')
return false
}
} finally {
document.body.removeChild(textArea)
@@ -948,6 +1015,7 @@ async function copyTextToClipboard(text: string, showToast: boolean = true) {
} catch (error) {
log.error('复制失败:', error)
showError('复制失败,请手动选择文本进行复制')
return false
}
}