refactor(frontend): extract provider key identity block

This commit is contained in:
elky
2026-06-30 17:01:39 +08:00
parent e5351b7d9d
commit 974def5fef
4 changed files with 380 additions and 135 deletions
@@ -112,137 +112,30 @@
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover/item:text-muted-foreground transition-colors shrink-0">
<GripVertical class="w-4 h-4" />
</div>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-1.5">
<span
class="text-sm font-medium truncate"
:class="key.name ? 'cursor-pointer hover:text-primary transition-colors' : ''"
:title="key.name ? legacyT('点击复制') : ''"
@click.stop="key.name && copyToClipboard(key.name)"
>{{ key.name || legacyT('未命名密钥') }}</span>
<!-- OAuth 订阅类型标签 (Codex) -->
<Badge
v-if="key.oauth_plan_type"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
:class="getOAuthPlanTypeClass(key.oauth_plan_type)"
>
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
<Badge
v-if="getOAuthOrgBadge(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="getOAuthOrgBadge(key)?.title"
>
{{ getOAuthOrgBadge(key)?.label }}
</Badge>
<!-- Kiro 订阅类型标签 -->
<Badge
v-if="shouldShowKiroSubscriptionBadge(key)"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
:class="getOAuthPlanTypeClass(getKiroSubscriptionBadgeLabel(key))"
>
{{ getKiroSubscriptionBadgeLabel(key) }}
</Badge>
</div>
<div class="flex items-center gap-1">
<span class="text-[11px] font-mono text-muted-foreground">
{{ getProviderMaskedSecretLabel(key, provider.provider_type) }}
</span>
<Button
v-if="canExportOAuthCredential(key)"
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
:title="legacyT('下载 OAuth 授权文件')"
@click.stop="downloadRefreshToken(key)"
>
<Download class="w-2.5 h-2.5" />
</Button>
<Button
v-else
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
:title="legacyT('复制密钥')"
@click.stop="copyFullKey(key)"
>
<Copy class="w-2.5 h-2.5" />
</Button>
<!-- OAuth 状态(失效/过期/倒计时)和刷新按钮 -->
<template v-if="shouldShowOAuthRefreshControl(key, provider.provider_type)">
<!-- 账号级别异常:醒目提示 + 清除按钮 -->
<template v-if="isAccountLevelBlock(key)">
<Badge
variant="destructive"
class="text-[10px] px-1.5 py-0 shrink-0 gap-0.5"
:title="getOAuthStatusTitle(key)"
>
<ShieldX class="w-2.5 h-2.5" />
{{ legacyT('账号异常') }}
</Badge>
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0 text-destructive hover:text-destructive"
:disabled="clearingOAuthInvalidKeyId === key.id"
:title="legacyT('清除异常标记(确认账号已完成验证后使用)')"
@click.stop="handleClearOAuthInvalid(key)"
>
<RefreshCw
class="w-2.5 h-2.5"
:class="{ 'animate-spin': clearingOAuthInvalidKeyId === key.id }"
/>
</Button>
</template>
<!-- 普通 OAuth 状态 -->
<template v-else>
<span
class="text-[10px]"
:class="{
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
}"
:title="getOAuthStatusTitle(key)"
>
{{ getKeyOAuthExpires(key)?.text }}
</span>
<Badge
v-if="key.oauth_temporary"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
:title="legacyT('仅通过 Access Token 导入,无法自动刷新,到期后需要重新导入')"
>
{{ legacyT('临时') }}
</Badge>
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
:disabled="refreshingOAuthKeyId === key.id || !canRefreshOAuthCredential(key)"
:title="getOAuthRefreshButtonTitle(key)"
@click.stop="handleRefreshOAuth(key)"
>
<RefreshCw
class="w-2.5 h-2.5"
:class="{ 'animate-spin': refreshingOAuthKeyId === key.id }"
/>
</Button>
</template>
</template>
<!-- Antigravity 账号未激活提示 -->
<span
v-if="provider.provider_type === 'antigravity' && key.is_active && isOAuthManagedCredential(key) && !hasAntigravityQuotaDisplayData(key)"
class="text-[10px] text-orange-500 dark:text-orange-400"
:title="legacyT('该账号尚未完成 Gemini Code Assist 激活,无法获取配额和使用模型')"
>
{{ legacyT('账号未激活') }}
</span>
</div>
</div>
<ProviderKeyIdentityBlock
:api-key="key"
:masked-secret-label="getProviderMaskedSecretLabel(key, provider.provider_type)"
:oauth-plan-label="key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : null"
:oauth-plan-class="key.oauth_plan_type ? getOAuthPlanTypeClass(key.oauth_plan_type) : ''"
:oauth-org-badge="getOAuthOrgBadge(key)"
:kiro-subscription-label="shouldShowKiroSubscriptionBadge(key) ? getKiroSubscriptionBadgeLabel(key) : null"
:kiro-subscription-class="shouldShowKiroSubscriptionBadge(key) ? getOAuthPlanTypeClass(getKiroSubscriptionBadgeLabel(key)) : ''"
:can-export-credential="canExportOAuthCredential(key)"
:show-o-auth-refresh-control="shouldShowOAuthRefreshControl(key, provider.provider_type)"
:account-level-block="isAccountLevelBlock(key)"
:oauth-status="getKeyOAuthExpires(key)"
:oauth-status-title="getOAuthStatusTitle(key)"
:oauth-refresh-button-title="getOAuthRefreshButtonTitle(key)"
:can-refresh-credential="canRefreshOAuthCredential(key)"
:clearing-o-auth-invalid="clearingOAuthInvalidKeyId === key.id"
:refreshing-o-auth="refreshingOAuthKeyId === key.id"
:antigravity-inactive="provider.provider_type === 'antigravity' && key.is_active && isOAuthManagedCredential(key) && !hasAntigravityQuotaDisplayData(key)"
@copy-name="copyToClipboard"
@download-credential="downloadRefreshToken(key)"
@copy-full-key="copyFullKey(key)"
@clear-o-auth-invalid="handleClearOAuthInvalid(key)"
@refresh-o-auth="handleRefreshOAuth(key)"
/>
</div>
<ProviderKeyActionCluster
:api-key="key"
@@ -967,17 +860,13 @@ import {
Plus,
Key,
Loader2,
RefreshCw,
GripVertical,
Copy,
Download,
ShieldX,
} from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { useEscapeKey } from '@/composables/useEscapeKey'
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 { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
@@ -1009,6 +898,7 @@ import AntigravityQuotaDialog from '@/features/providers/components/AntigravityQ
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 ProviderKeyIdentityBlock from '@/features/providers/components/ProviderKeyIdentityBlock.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'
@@ -0,0 +1,211 @@
<template>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-1.5">
<span
class="text-sm font-medium truncate"
:class="apiKey.name ? 'cursor-pointer hover:text-primary transition-colors' : ''"
:title="apiKey.name ? legacyT('点击复制') : ''"
data-testid="provider-key-name"
@click.stop="apiKey.name && $emit('copyName', apiKey.name)"
>
{{ apiKey.name || legacyT('未命名密钥') }}
</span>
<Badge
v-if="oauthPlanLabel"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
:class="oauthPlanClass"
data-testid="provider-key-oauth-plan"
>
{{ oauthPlanLabel }}
</Badge>
<Badge
v-if="oauthOrgBadge"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="oauthOrgBadge.title"
data-testid="provider-key-oauth-org"
>
{{ oauthOrgBadge.label }}
</Badge>
<Badge
v-if="kiroSubscriptionLabel"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
:class="kiroSubscriptionClass"
data-testid="provider-key-kiro-plan"
>
{{ kiroSubscriptionLabel }}
</Badge>
</div>
<div class="flex items-center gap-1">
<span class="text-[11px] font-mono text-muted-foreground">
{{ maskedSecretLabel }}
</span>
<Button
v-if="canExportCredential"
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
:title="legacyT('下载 OAuth 授权文件')"
@click.stop="$emit('downloadCredential')"
>
<Download class="w-2.5 h-2.5" />
</Button>
<Button
v-else
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
:title="legacyT('复制密钥')"
@click.stop="$emit('copyFullKey')"
>
<Copy class="w-2.5 h-2.5" />
</Button>
<template v-if="showOAuthRefreshControl">
<template v-if="accountLevelBlock">
<Badge
variant="destructive"
class="text-[10px] px-1.5 py-0 shrink-0 gap-0.5"
:title="oauthStatusTitle"
data-testid="provider-key-account-block"
>
<ShieldX class="w-2.5 h-2.5" />
{{ legacyT('账号异常') }}
</Badge>
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0 text-destructive hover:text-destructive"
:disabled="clearingOAuthInvalid"
:title="legacyT('清除异常标记(确认账号已完成验证后使用)')"
@click.stop="$emit('clearOAuthInvalid')"
>
<RefreshCw
class="w-2.5 h-2.5"
:class="{ 'animate-spin': clearingOAuthInvalid }"
/>
</Button>
</template>
<template v-else>
<span
class="text-[10px]"
:class="oauthStatusClass"
:title="oauthStatusTitle"
data-testid="provider-key-oauth-status"
>
{{ oauthStatus?.text }}
</span>
<Badge
v-if="apiKey.oauth_temporary"
variant="outline"
class="text-[10px] px-1.5 py-0 shrink-0"
:title="legacyT('仅通过 Access Token 导入,无法自动刷新,到期后需要重新导入')"
data-testid="provider-key-temporary"
>
{{ legacyT('临时') }}
</Badge>
<Button
variant="ghost"
size="icon"
class="h-4 w-4 shrink-0"
:disabled="refreshingOAuth || !canRefreshCredential"
:title="oauthRefreshButtonTitle"
@click.stop="$emit('refreshOAuth')"
>
<RefreshCw
class="w-2.5 h-2.5"
:class="{ 'animate-spin': refreshingOAuth }"
/>
</Button>
</template>
</template>
<span
v-if="antigravityInactive"
class="text-[10px] text-orange-500 dark:text-orange-400"
:title="legacyT('该账号尚未完成 Gemini Code Assist 激活,无法获取配额和使用模型')"
data-testid="provider-key-antigravity-inactive"
>
{{ legacyT('账号未激活') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Copy, Download, RefreshCw, ShieldX } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import { useI18n } from '@/i18n'
import type { EndpointAPIKey } from '@/api/endpoints'
import type { OAuthStatusInfo } from '@/composables/useCountdownTimer'
interface OAuthOrgBadgeDisplay {
label: string
title: string
}
const props = withDefaults(defineProps<{
apiKey: EndpointAPIKey
maskedSecretLabel: string
oauthPlanLabel?: string | null
oauthPlanClass?: string
oauthOrgBadge?: OAuthOrgBadgeDisplay | null
kiroSubscriptionLabel?: string | null
kiroSubscriptionClass?: string
canExportCredential?: boolean
showOAuthRefreshControl?: boolean
accountLevelBlock?: boolean
oauthStatus?: OAuthStatusInfo | null
oauthStatusTitle?: string
oauthRefreshButtonTitle?: string
canRefreshCredential?: boolean
clearingOAuthInvalid?: boolean
refreshingOAuth?: boolean
antigravityInactive?: boolean
}>(), {
oauthPlanLabel: null,
oauthPlanClass: '',
oauthOrgBadge: null,
kiroSubscriptionLabel: null,
kiroSubscriptionClass: '',
canExportCredential: false,
showOAuthRefreshControl: false,
accountLevelBlock: false,
oauthStatus: null,
oauthStatusTitle: '',
oauthRefreshButtonTitle: '',
canRefreshCredential: false,
clearingOAuthInvalid: false,
refreshingOAuth: false,
antigravityInactive: false,
})
defineEmits<{
(e: 'copyName', name: string): void
(e: 'downloadCredential'): void
(e: 'copyFullKey'): void
(e: 'clearOAuthInvalid'): void
(e: 'refreshOAuth'): void
}>()
const { legacyT } = useI18n()
const oauthStatusClass = computed(() => {
const status = props.oauthStatus
return {
'text-destructive': status?.isInvalid || status?.isExpired,
'text-warning': status?.isExpiringSoon && !status?.isExpired && !status?.isInvalid,
'text-muted-foreground': !status?.isExpired && !status?.isExpiringSoon && !status?.isInvalid,
}
})
</script>
@@ -0,0 +1,143 @@
import { describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h } from 'vue'
import ProviderKeyIdentityBlock from '@/features/providers/components/ProviderKeyIdentityBlock.vue'
import type { EndpointAPIKey } from '@/api/endpoints'
import { createI18n } from '@/i18n'
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
Copy: Icon,
Download: Icon,
RefreshCw: Icon,
ShieldX: 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: 'oauth',
name: 'Primary key',
internal_priority: 10,
cache_ttl_minutes: 0,
max_probe_interval_minutes: 5,
health_score: 1,
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(ProviderKeyIdentityBlock, props)
},
}))
app.use(createI18n())
app.mount(root)
return {
root,
unmount: () => {
app.unmount()
root.remove()
},
}
}
describe('ProviderKeyIdentityBlock', () => {
it('renders identity badges and emits copy/download/refresh actions', () => {
const onCopyName = vi.fn()
const onDownloadCredential = vi.fn()
const onRefreshOAuth = vi.fn()
const { root, unmount } = mount({
apiKey: createProviderKey({
oauth_temporary: true,
}),
maskedSecretLabel: 'oauth:***',
oauthPlanLabel: 'Team',
oauthPlanClass: 'text-purple-600',
oauthOrgBadge: { label: 'org:demo', title: 'org_id: demo' },
kiroSubscriptionLabel: 'Pro',
kiroSubscriptionClass: 'text-blue-600',
canExportCredential: true,
showOAuthRefreshControl: true,
oauthStatus: { text: '12m', isExpired: false, isExpiringSoon: true, isInvalid: false },
oauthStatusTitle: 'OAuth expires soon',
oauthRefreshButtonTitle: 'Refresh OAuth',
canRefreshCredential: true,
onCopyName,
onDownloadCredential,
onRefreshOAuth,
})
expect(root.querySelector('[data-testid="provider-key-name"]')?.textContent).toContain('Primary key')
expect(root.querySelector('[data-testid="provider-key-oauth-plan"]')?.textContent).toContain('Team')
expect(root.querySelector('[data-testid="provider-key-oauth-org"]')?.textContent).toContain('org:demo')
expect(root.querySelector('[data-testid="provider-key-kiro-plan"]')?.textContent).toContain('Pro')
expect(root.querySelector('[data-testid="provider-key-oauth-status"]')?.textContent).toContain('12m')
expect(root.querySelector('[data-testid="provider-key-temporary"]')).toBeTruthy()
;(root.querySelector('[data-testid="provider-key-name"]') as HTMLElement).click()
;(root.querySelector('button[title="下载 OAuth 授权文件"]') as HTMLButtonElement).click()
;(root.querySelector('button[title="Refresh OAuth"]') as HTMLButtonElement).click()
expect(onCopyName).toHaveBeenCalledWith('Primary key')
expect(onDownloadCredential).toHaveBeenCalledTimes(1)
expect(onRefreshOAuth).toHaveBeenCalledTimes(1)
unmount()
})
it('renders account block and inactive account states', () => {
const onCopyFullKey = vi.fn()
const onClearOAuthInvalid = vi.fn()
const { root, unmount } = mount({
apiKey: createProviderKey({ name: '' }),
maskedSecretLabel: 'sk-***',
canExportCredential: false,
showOAuthRefreshControl: true,
accountLevelBlock: true,
oauthStatusTitle: 'Account blocked',
antigravityInactive: true,
onCopyFullKey,
onClearOAuthInvalid,
})
expect(root.querySelector('[data-testid="provider-key-name"]')?.textContent).toContain('未命名密钥')
expect(root.querySelector('[data-testid="provider-key-account-block"]')?.textContent).toContain('账号异常')
expect(root.querySelector('[data-testid="provider-key-antigravity-inactive"]')?.textContent).toContain('账号未激活')
;(root.querySelector('button[title="复制密钥"]') as HTMLButtonElement).click()
;(root.querySelector('button[title="清除异常标记(确认账号已完成验证后使用)"]') as HTMLButtonElement).click()
expect(onCopyFullKey).toHaveBeenCalledTimes(1)
expect(onClearOAuthInvalid).toHaveBeenCalledTimes(1)
unmount()
})
})
@@ -1,5 +1,6 @@
export { default as ProviderFormDialog } from './ProviderFormDialog.vue'
export { default as ProviderKeyActionCluster } from './ProviderKeyActionCluster.vue'
export { default as ProviderKeyIdentityBlock } from './ProviderKeyIdentityBlock.vue'
export { default as ProviderMonthlyQuotaCard } from './ProviderMonthlyQuotaCard.vue'
export { default as ProviderQuotaProgressRow } from './ProviderQuotaProgressRow.vue'
export { default as ProviderQuotaSectionHeader } from './ProviderQuotaSectionHeader.vue'