Files
Aether/frontend/src/features/providers/components/ProviderTableRow.vue
T

322 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<TableRow
class="border-b border-border/30 hover:bg-muted/20 transition-colors cursor-pointer"
@mousedown="$emit('mousedown', $event)"
@click="$emit('rowClick', $event, provider.id)"
>
<TableCell
v-if="$slots['drag-handle']"
class="w-9 px-2 py-3.5"
@click.stop
>
<slot name="drag-handle" />
</TableCell>
<TableCell
v-if="$slots.priority"
class="w-px whitespace-nowrap px-2 py-3.5 text-center"
@click.stop
@mousedown.stop
@pointerdown.stop
>
<div class="flex justify-center">
<slot name="priority" />
</div>
</TableCell>
<TableCell class="py-3.5">
<div class="space-y-0.5">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
<a
v-if="safeProviderWebsite"
:href="safeProviderWebsite"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
:title="safeProviderWebsite"
@click.stop
>
<ExternalLink class="w-3.5 h-3.5" />
</a>
</div>
<!-- 内联编辑备注 -->
<div
v-if="editingDescriptionId === provider.id"
data-desc-editor
class="flex items-center gap-1 max-w-[220px]"
@click.stop
>
<input
v-model="localDescriptionValue"
v-auto-focus
class="flex-1 min-w-0 text-xs px-1.5 py-0.5 rounded border border-border bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
:placeholder="legacyT('输入备注...')"
@keydown="handleDescriptionKeydown"
>
<button
class="shrink-0 p-0.5 rounded hover:bg-muted text-primary"
:title="legacyT('保存')"
@click="handleSave"
>
<Check class="w-3.5 h-3.5" />
</button>
<button
class="shrink-0 p-0.5 rounded hover:bg-muted text-muted-foreground"
:title="legacyT('取消')"
@click="handleCancel"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
<span
v-else-if="provider.description"
class="text-xs text-muted-foreground truncate block max-w-[200px] group/desc cursor-pointer hover:text-foreground/70 transition-colors"
:title="provider.description"
@click="handleStartEdit"
>{{ provider.description }} <Pencil class="w-3 h-3 inline-block opacity-0 group-hover/desc:opacity-50 transition-opacity" /></span>
<span
v-else
class="text-xs text-muted-foreground cursor-pointer hover:text-foreground/70 transition-colors"
@click="handleStartEdit"
>{{ legacyT('添加备注') }}</span>
</div>
</TableCell>
<TableCell class="py-3.5">
<ProviderBalanceCell
:provider="provider"
:is-balance-loading="isBalanceLoading"
:get-provider-balance="getProviderBalance"
:get-provider-balance-breakdown="getProviderBalanceBreakdown"
:get-provider-balance-error="getProviderBalanceError"
:get-provider-checkin="getProviderCheckin"
:get-provider-cookie-expired="getProviderCookieExpired"
:get-provider-balance-extra="getProviderBalanceExtra"
:format-balance-display="formatBalanceDisplay"
:format-reset-countdown="formatResetCountdown"
:get-quota-used-color-class="getQuotaUsedColorClass"
/>
</TableCell>
<TableCell class="py-3.5 text-center">
<div class="inline-grid grid-cols-[1.75rem_1.75rem_1.75rem] gap-x-0.5 gap-y-0.5 text-xs text-left">
<span class="text-muted-foreground/70">{{ legacyT('端点:') }}</span>
<span class="font-medium text-foreground/90 tabular-nums text-right">{{ provider.active_endpoints }}</span>
<span class="text-muted-foreground/50 tabular-nums">/{{ provider.total_endpoints }}</span>
<span class="text-muted-foreground/70">{{ `${getCredentialLabel(provider)}:` }}</span>
<span class="font-medium text-foreground/90 tabular-nums text-right">{{ provider.active_keys }}</span>
<span class="text-muted-foreground/50 tabular-nums">/{{ provider.total_keys }}</span>
<span class="text-muted-foreground/70">{{ legacyT('模型:') }}</span>
<span class="font-medium text-foreground/90 tabular-nums text-right">{{ provider.active_models }}</span>
<span class="text-muted-foreground/50 tabular-nums">/{{ provider.total_models }}</span>
</div>
</TableCell>
<TableCell class="py-3.5 align-middle">
<div
v-if="provider.endpoint_health_details && provider.endpoint_health_details.length > 0"
class="grid grid-cols-3 gap-x-3 gap-y-2 max-w-[240px]"
>
<div
v-for="endpoint in sortEndpoints(provider.endpoint_health_details)"
:key="endpoint.api_format"
class="flex flex-col gap-1.5"
:title="getEndpointTooltip(endpoint, locale)"
>
<!-- 上排:缩写 + 百分比 -->
<div class="flex items-center justify-between text-[10px] leading-none">
<span class="font-medium text-muted-foreground/80">
{{ formatApiFormatShort(endpoint.api_format) }}
</span>
<span class="font-medium text-muted-foreground/80">
{{ getEndpointHealthLabel(endpoint) }}
</span>
</div>
<!-- 下排:进度条 -->
<div class="h-1.5 w-full bg-border dark:bg-border/80 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="getEndpointDotColor(endpoint)"
:style="{ width: getEndpointHealthBarWidth(endpoint) }"
/>
</div>
</div>
</div>
<span
v-else
class="text-xs text-muted-foreground/50"
>{{ legacyT('暂无端点') }}</span>
</TableCell>
<TableCell class="py-3.5 text-center">
<div class="flex flex-col items-center gap-1.5">
<slot name="scheduling" />
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
class="whitespace-nowrap text-xs"
>
{{ legacyT(provider.is_active ? '全局启用' : '全局停用') }}
</Badge>
</div>
</TableCell>
<TableCell
class="py-3.5"
@click.stop
>
<div class="flex items-center justify-center gap-0.5">
<slot name="group-action" />
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
:title="legacyT('查看详情')"
@click="$emit('viewDetail', provider.id)"
>
<Eye class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
:title="legacyT('编辑提供商')"
@click="$emit('editProvider', provider)"
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
:title="legacyT('扩展操作配置')"
@click="$emit('openOpsConfig', provider)"
>
<KeyRound class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
:title="legacyT(provider.is_active ? '全局停用提供商' : '全局启用提供商')"
@click="$emit('toggleStatus', provider)"
>
<Power class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
:title="legacyT('删除提供商')"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
Edit,
Eye,
Trash2,
Power,
KeyRound,
ExternalLink,
Pencil,
Check,
X,
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import TableRow from '@/components/ui/table-row.vue'
import TableCell from '@/components/ui/table-cell.vue'
import ProviderBalanceCell from './ProviderBalanceCell.vue'
import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
import {
sortEndpoints,
getEndpointHealthLabel,
getEndpointHealthBarWidth,
getEndpointDotColor,
getEndpointTooltip,
} from '@/features/providers/composables/useEndpointStatus'
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
import { useI18n } from '@/i18n'
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
const props = defineProps<{
provider: ProviderWithEndpointsSummary
editingDescriptionId: string | null
// Balance functions
isBalanceLoading: (providerId: string) => boolean
getProviderBalance: (providerId: string) => { available: number | null; currency: string } | null
getProviderBalanceBreakdown: (providerId: string) => { balance: number; points: number; currency: string } | null
getProviderBalanceError: (providerId: string) => { status: string; message: string } | null
getProviderCheckin: (providerId: string) => { success: boolean | null; message: string } | null
getProviderCookieExpired: (providerId: string) => { expired: boolean; message: string } | null
getProviderBalanceExtra: (providerId: string, architectureId?: string) => BalanceExtraItem[]
formatBalanceDisplay: (balance: { available: number | null; currency: string } | null) => string
formatResetCountdown: (resetsAt: number) => string
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
}>()
const emit = defineEmits<{
'mousedown': [event: MouseEvent]
'rowClick': [event: MouseEvent, providerId: string]
'viewDetail': [providerId: string]
'editProvider': [provider: ProviderWithEndpointsSummary]
'openOpsConfig': [provider: ProviderWithEndpointsSummary]
'toggleStatus': [provider: ProviderWithEndpointsSummary]
'deleteProvider': [provider: ProviderWithEndpointsSummary]
'startEditDescription': [event: Event, provider: ProviderWithEndpointsSummary]
'saveDescription': [event: Event, provider: ProviderWithEndpointsSummary, value: string]
'cancelEditDescription': [event?: Event]
}>()
const vAutoFocus = {
mounted: (el: HTMLElement) => el.focus(),
}
const localDescriptionValue = ref('')
const { legacyT, locale } = useI18n()
const safeProviderWebsite = computed(() => safeExternalWebUrl(props.provider.website))
// 当进入编辑模式时,同步 props 的 description
watch(
() => props.editingDescriptionId,
(newId) => {
if (newId === props.provider.id) {
localDescriptionValue.value = props.provider.description || ''
}
},
)
function handleStartEdit(event: Event) {
event.stopPropagation()
emit('startEditDescription', event, props.provider)
}
function handleSave(event: Event) {
event.stopPropagation()
emit('saveDescription', event, props.provider, localDescriptionValue.value)
}
function handleCancel(event?: Event) {
event?.stopPropagation()
emit('cancelEditDescription', event)
}
function handleDescriptionKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
handleSave(event)
} else if (event.key === 'Escape') {
handleCancel(event)
}
}
function getCredentialLabel(provider: ProviderWithEndpointsSummary): string {
const providerType = String(provider.provider_type || '').trim().toLowerCase()
return legacyT(providerType && providerType !== 'custom' ? '账号' : '密钥')
}
</script>