refactor(frontend): extract provider key actions

This commit is contained in:
elky
2026-06-30 17:01:39 +08:00
parent ed83184d55
commit e5351b7d9d
4 changed files with 417 additions and 144 deletions
@@ -244,142 +244,29 @@
</div>
</div>
</div>
<!-- 并发 + 健康度 + 操作按钮 -->
<div class="flex items-center gap-1 shrink-0">
<!-- 熔断徽章 -->
<Badge
v-if="key.circuit_breaker_open"
variant="destructive"
class="text-[10px] px-1.5 py-0 shrink-0"
:title="getKeyCircuitBreakerTitle(key)"
>
{{ legacyT('熔断') }}{{ getKeyCircuitProbeCountdown(key) }}
</Badge>
<!-- 健康度 -->
<div
v-if="key.health_score !== undefined"
class="flex items-center gap-1 mr-1"
>
<div class="w-10 h-1.5 bg-border rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="getHealthScoreBarColor(key.health_score || 0)"
:style="{ width: `${(key.health_score || 0) * 100}%` }"
/>
</div>
<span
class="text-[10px] font-medium tabular-nums"
:class="getHealthScoreColor(key.health_score || 0)"
>
{{ ((key.health_score || 0) * 100).toFixed(0) }}%
</span>
</div>
<Button
v-if="isKeyRecoverable(key)"
variant="ghost"
size="icon"
class="h-7 w-7 text-green-600"
:title="getRecoverKeyTitle(key)"
@click="handleRecoverKey(key)"
>
<RefreshCw class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="legacyT('模型权限')"
@click="handleKeyPermissions(key)"
>
<Shield class="w-3.5 h-3.5" />
</Button>
<!-- 代理节点配置 -->
<Popover
:open="proxyPopoverOpenKeyId === key.id"
@update:open="(v: boolean) => handleProxyPopoverToggle(key.id, v)"
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:class="key.proxy?.node_id ? 'text-blue-500' : ''"
:disabled="savingProxyKeyId === key.id"
:title="key.proxy?.node_id ? `${legacyT('代理')}: ${getKeyProxyNodeName(key)}` : legacyT('设置代理节点')"
@click.stop
>
<Globe class="w-3.5 h-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
class="w-72 p-3"
side="bottom"
align="end"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium">{{ legacyT('代理节点') }}</span>
<Button
v-if="key.proxy?.node_id"
variant="ghost"
size="sm"
class="h-6 px-2 text-[10px] text-muted-foreground"
:disabled="savingProxyKeyId === key.id"
@click="clearKeyProxy(key)"
>
{{ legacyT('清除') }}
</Button>
</div>
<ProxyNodeSelect
:model-value="key.proxy?.node_id || ''"
trigger-class="h-8"
@update:model-value="(v: string) => setKeyProxy(key, v)"
/>
<p class="text-[10px] text-muted-foreground">
{{ legacyT(key.proxy?.node_id ? '当前使用独立代理' : '未设置,使用提供商级别代理') }}
</p>
</div>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="legacyT('编辑密钥')"
@click="handleEditKey(endpoint, key)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
v-if="provider.provider_type === 'antigravity'"
variant="ghost"
size="icon"
class="h-7 w-7"
:title="legacyT('配额详情')"
@click="openAntigravityQuotaDialog(key)"
>
<BarChart3 class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="togglingKeyId === key.id"
:title="legacyT(key.is_active ? '点击停用' : '点击启用')"
@click="toggleKeyActive(key)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="legacyT('删除密钥')"
@click="handleDeleteKey(key)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
<ProviderKeyActionCluster
:api-key="key"
:provider-type="provider.provider_type"
:recoverable="isKeyRecoverable(key)"
:recover-title="getRecoverKeyTitle(key)"
:circuit-breaker-title="getKeyCircuitBreakerTitle(key)"
:circuit-probe-countdown="getKeyCircuitProbeCountdown(key)"
:health-score-bar-class="getHealthScoreBarColor(key.health_score || 0)"
:health-score-text-class="getHealthScoreColor(key.health_score || 0)"
:proxy-popover-open="proxyPopoverOpenKeyId === key.id"
:proxy-node-name="getKeyProxyNodeName(key)"
:saving-proxy="savingProxyKeyId === key.id"
:toggling="togglingKeyId === key.id"
@recover="handleRecoverKey(key)"
@permissions="handleKeyPermissions(key)"
@update:proxy-popover-open="(v: boolean) => handleProxyPopoverToggle(key.id, v)"
@clear-proxy="clearKeyProxy(key)"
@set-proxy="(v: string) => setKeyProxy(key, v)"
@edit="handleEditKey(endpoint, key)"
@open-antigravity-quota="openAntigravityQuotaDialog(key)"
@toggle-active="toggleKeyActive(key)"
@delete="handleDeleteKey(key)"
/>
</div>
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
<div
@@ -1080,17 +967,11 @@ import {
Plus,
Key,
Loader2,
Edit,
Trash2,
RefreshCw,
Power,
GripVertical,
Copy,
Download,
Shield,
BarChart3,
ShieldX,
Globe,
} from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { useEscapeKey } from '@/composables/useEscapeKey'
@@ -1098,7 +979,6 @@ import { useI18n } from '@/i18n'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Card from '@/components/ui/card.vue'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
@@ -1128,10 +1008,10 @@ import AlertDialog from '@/components/common/AlertDialog.vue'
import AntigravityQuotaDialog from '@/features/providers/components/AntigravityQuotaDialog.vue'
import FailoverRulesDialog from '@/features/providers/components/FailoverRulesDialog.vue'
import ProviderDetailHeader from '@/features/providers/components/ProviderDetailHeader.vue'
import ProviderKeyActionCluster from '@/features/providers/components/ProviderKeyActionCluster.vue'
import ProviderMonthlyQuotaCard from '@/features/providers/components/ProviderMonthlyQuotaCard.vue'
import ProviderQuotaProgressRow from '@/features/providers/components/ProviderQuotaProgressRow.vue'
import ProviderQuotaSectionHeader from '@/features/providers/components/ProviderQuotaSectionHeader.vue'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import {
deleteEndpointKey,
@@ -0,0 +1,210 @@
<template>
<div class="flex items-center gap-1 shrink-0">
<Badge
v-if="apiKey.circuit_breaker_open"
variant="destructive"
class="text-[10px] px-1.5 py-0 shrink-0"
:title="circuitBreakerTitle"
data-testid="provider-key-circuit-badge"
>
{{ legacyT('熔断') }}{{ circuitProbeCountdown }}
</Badge>
<div
v-if="apiKey.health_score !== undefined"
class="flex items-center gap-1 mr-1"
data-testid="provider-key-health"
>
<div class="w-10 h-1.5 bg-border rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="healthScoreBarClass"
:style="{ width: `${healthScorePercent}%` }"
/>
</div>
<span
class="text-[10px] font-medium tabular-nums"
:class="healthScoreTextClass"
>
{{ healthScorePercent.toFixed(0) }}%
</span>
</div>
<Button
v-if="recoverable"
variant="ghost"
size="icon"
class="h-7 w-7 text-green-600"
:title="recoverTitle"
@click="$emit('recover')"
>
<RefreshCw class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="legacyT('模型权限')"
@click="$emit('permissions')"
>
<Shield class="w-3.5 h-3.5" />
</Button>
<Popover
:open="proxyPopoverOpen"
@update:open="$emit('update:proxyPopoverOpen', $event)"
>
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:class="apiKey.proxy?.node_id ? 'text-blue-500' : ''"
:disabled="savingProxy"
:title="apiKey.proxy?.node_id ? `${legacyT('代理')}: ${proxyNodeName}` : legacyT('设置代理节点')"
@click.stop
>
<Globe class="w-3.5 h-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent
class="w-72 p-3"
side="bottom"
align="end"
>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium">{{ legacyT('代理节点') }}</span>
<Button
v-if="apiKey.proxy?.node_id"
variant="ghost"
size="sm"
class="h-6 px-2 text-[10px] text-muted-foreground"
:disabled="savingProxy"
@click="$emit('clearProxy')"
>
{{ legacyT('清除') }}
</Button>
</div>
<ProxyNodeSelect
:model-value="apiKey.proxy?.node_id || ''"
trigger-class="h-8"
@update:model-value="$emit('setProxy', $event)"
/>
<p class="text-[10px] text-muted-foreground">
{{ legacyT(apiKey.proxy?.node_id ? '当前使用独立代理' : '未设置,使用提供商级别代理') }}
</p>
</div>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="legacyT('编辑密钥')"
@click="$emit('edit')"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
v-if="providerType === 'antigravity'"
variant="ghost"
size="icon"
class="h-7 w-7"
:title="legacyT('配额详情')"
@click="$emit('openAntigravityQuota')"
>
<BarChart3 class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:disabled="toggling"
:title="legacyT(apiKey.is_active ? '点击停用' : '点击启用')"
@click="$emit('toggleActive')"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="legacyT('删除密钥')"
@click="$emit('delete')"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
BarChart3,
Edit,
Globe,
Power,
RefreshCw,
Shield,
Trash2,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
import { useI18n } from '@/i18n'
import type { EndpointAPIKey } from '@/api/endpoints'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
const props = withDefaults(defineProps<{
apiKey: EndpointAPIKey
providerType?: string | null
recoverable?: boolean
recoverTitle?: string
circuitBreakerTitle?: string
circuitProbeCountdown?: string
healthScoreBarClass?: string
healthScoreTextClass?: string
proxyPopoverOpen?: boolean
proxyNodeName?: string
savingProxy?: boolean
toggling?: boolean
}>(), {
providerType: null,
recoverable: false,
recoverTitle: '',
circuitBreakerTitle: '',
circuitProbeCountdown: '',
healthScoreBarClass: '',
healthScoreTextClass: '',
proxyPopoverOpen: false,
proxyNodeName: '',
savingProxy: false,
toggling: false,
})
defineEmits<{
(e: 'recover'): void
(e: 'permissions'): void
(e: 'update:proxyPopoverOpen', value: boolean): void
(e: 'clearProxy'): void
(e: 'setProxy', nodeId: string): void
(e: 'edit'): void
(e: 'openAntigravityQuota'): void
(e: 'toggleActive'): void
(e: 'delete'): void
}>()
const { legacyT } = useI18n()
const healthScorePercent = computed(() => {
const score = Number(props.apiKey.health_score ?? 0)
if (!Number.isFinite(score)) return 0
return Math.min(Math.max(score * 100, 0), 100)
})
</script>
@@ -0,0 +1,182 @@
import { describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h } from 'vue'
import ProviderKeyActionCluster from '@/features/providers/components/ProviderKeyActionCluster.vue'
import type { EndpointAPIKey } from '@/api/endpoints'
import { createI18n } from '@/i18n'
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string) => defineComponent({
name,
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
return {
Popover: passthrough('PopoverStub'),
PopoverTrigger: passthrough('PopoverTriggerStub'),
PopoverContent: passthrough('PopoverContentStub'),
}
})
vi.mock('@/features/providers/components/ProxyNodeSelect.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'ProxyNodeSelectStub',
emits: ['update:modelValue'],
setup(_, { emit }) {
return () => h('button', {
type: 'button',
'data-testid': 'proxy-node-select',
onClick: () => emit('update:modelValue', 'proxy-node-2'),
}, 'select proxy')
},
}),
}
})
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
BarChart3: Icon,
Edit: Icon,
Globe: Icon,
Power: Icon,
RefreshCw: Icon,
Shield: Icon,
Trash2: Icon,
}
})
function createProviderKey(overrides: Partial<EndpointAPIKey> = {}): EndpointAPIKey {
return {
id: 'provider-key-1',
provider_id: 'provider-1',
api_formats: ['openai:chat'],
api_key_masked: 'sk-***',
auth_type: 'api_key',
name: 'Primary key',
internal_priority: 10,
cache_ttl_minutes: 0,
max_probe_interval_minutes: 5,
health_score: 0.42,
consecutive_failures: 0,
request_count: 0,
success_count: 0,
error_count: 0,
success_rate: 1,
avg_response_time_ms: 0,
is_active: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
function mount(props: Record<string, unknown>) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup() {
return () => h(ProviderKeyActionCluster, props)
},
}))
app.use(createI18n())
app.mount(root)
return {
root,
unmount: () => {
app.unmount()
root.remove()
},
}
}
describe('ProviderKeyActionCluster', () => {
it('renders circuit, health, proxy and antigravity actions', () => {
const { root, unmount } = mount({
apiKey: createProviderKey({
circuit_breaker_open: true,
proxy: { node_id: 'proxy-node-1' },
}),
providerType: 'antigravity',
recoverable: true,
recoverTitle: 'Recover key',
circuitBreakerTitle: 'Circuit is open',
circuitProbeCountdown: ' 2m',
healthScoreBarClass: 'bg-red-500',
healthScoreTextClass: 'text-red-600',
proxyPopoverOpen: true,
proxyNodeName: 'Tokyo',
})
expect(root.querySelector('[data-testid="provider-key-circuit-badge"]')?.textContent).toContain('熔断 2m')
expect(root.querySelector('[data-testid="provider-key-health"]')?.textContent).toContain('42%')
expect(root.querySelector('button[title="Recover key"]')).toBeTruthy()
expect(root.querySelector('button[title="代理: Tokyo"]')).toBeTruthy()
expect(root.querySelector('button[title="配额详情"]')).toBeTruthy()
unmount()
})
it('emits operation and proxy events without owning business logic', () => {
const onRecover = vi.fn()
const onPermissions = vi.fn()
const onEdit = vi.fn()
const onOpenAntigravityQuota = vi.fn()
const onToggleActive = vi.fn()
const onDelete = vi.fn()
const onClearProxy = vi.fn()
const onSetProxy = vi.fn()
const { root, unmount } = mount({
apiKey: createProviderKey({ proxy: { node_id: 'proxy-node-1' } }),
providerType: 'antigravity',
recoverable: true,
recoverTitle: 'Recover key',
proxyPopoverOpen: true,
proxyNodeName: 'Tokyo',
onRecover,
onPermissions,
onEdit,
onOpenAntigravityQuota,
onToggleActive,
onDelete,
onClearProxy,
onSetProxy,
})
;(root.querySelector('button[title="Recover key"]') as HTMLButtonElement).click()
;(root.querySelector('button[title="模型权限"]') as HTMLButtonElement).click()
;(root.querySelector('button[title="编辑密钥"]') as HTMLButtonElement).click()
;(root.querySelector('button[title="配额详情"]') as HTMLButtonElement).click()
;(root.querySelector('button[title="点击停用"]') as HTMLButtonElement).click()
;(root.querySelector('button[title="删除密钥"]') as HTMLButtonElement).click()
;(Array.from(root.querySelectorAll('button')).find(button => button.textContent?.includes('清除')) as HTMLButtonElement).click()
;(root.querySelector('[data-testid="proxy-node-select"]') as HTMLButtonElement).click()
expect(onRecover).toHaveBeenCalledTimes(1)
expect(onPermissions).toHaveBeenCalledTimes(1)
expect(onEdit).toHaveBeenCalledTimes(1)
expect(onOpenAntigravityQuota).toHaveBeenCalledTimes(1)
expect(onToggleActive).toHaveBeenCalledTimes(1)
expect(onDelete).toHaveBeenCalledTimes(1)
expect(onClearProxy).toHaveBeenCalledTimes(1)
expect(onSetProxy).toHaveBeenCalledWith('proxy-node-2')
unmount()
})
})
@@ -1,4 +1,5 @@
export { default as ProviderFormDialog } from './ProviderFormDialog.vue'
export { default as ProviderKeyActionCluster } from './ProviderKeyActionCluster.vue'
export { default as ProviderMonthlyQuotaCard } from './ProviderMonthlyQuotaCard.vue'
export { default as ProviderQuotaProgressRow } from './ProviderQuotaProgressRow.vue'
export { default as ProviderQuotaSectionHeader } from './ProviderQuotaSectionHeader.vue'