mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
refactor: 继续拆分大型模块并增强模块注册健壮性
后端: - chat_handler_base 错误处理函数提取到 chat_error_utils 子模块 - CLI mixin 引入 CliHandlerProtocol 协议类改善类型标注 - aware_scheduler 拆分为 _candidate_builder 和 _candidate_sorter 子模块 - usage recording 拆分为 _billing_integration 和 _recording_helpers 子模块 - ModuleRegistry 添加循环依赖检测,将写操作从查询方法分离到 reconcile_module_state - 修正 plugin manager 入度注释 前端: - 路由守卫逻辑拆分为独立 guards 模块 - ProviderManagement 拆分为 TableHeader/TableRow/BalanceCell/MobileCard 子组件 - SystemSettings 拆分为多个 Section 子组件和 composables - 提取 useEndpointStatus/useProviderBalance/useProviderFilters composables 测试适配重构后的子模块结构
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<!-- 余额正在加载中 -->
|
||||
<div
|
||||
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
|
||||
class="flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<!-- 显示从上游 API 查询的余额 -->
|
||||
<div
|
||||
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<!-- 余额文字:balance + points 分开显示,或普通余额 -->
|
||||
<template
|
||||
v-for="(bd, idx) in [getProviderBalanceBreakdown(provider.id)]"
|
||||
:key="idx"
|
||||
>
|
||||
<div
|
||||
v-if="bd"
|
||||
class="min-w-[4.5rem] tabular-nums leading-tight"
|
||||
>
|
||||
<div class="font-semibold text-foreground/90">
|
||||
${{ bd.balance.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-muted-foreground/70 text-[10px]">
|
||||
${{ bd.points.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="font-semibold text-foreground/90 min-w-[4.5rem] tabular-nums"
|
||||
>
|
||||
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- 窗口限额 + 签到状态 + Cookie 失效警告 -->
|
||||
<div
|
||||
v-if="getProviderBalanceExtra(provider.id, provider.ops_architecture_id).length > 0 || getProviderCheckin(provider.id) || getProviderCookieExpired(provider.id)"
|
||||
class="text-muted-foreground/70 space-y-0.5"
|
||||
>
|
||||
<!-- 限额(进度条 + 倒计时,每行一个) -->
|
||||
<template
|
||||
v-for="item in getProviderBalanceExtra(provider.id, provider.ops_architecture_id)"
|
||||
:key="item.label"
|
||||
>
|
||||
<div
|
||||
:title="item.tooltip"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span class="text-[10px] text-muted-foreground/60 w-4">{{ item.label }}</span>
|
||||
<div class="w-12 h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
:class="[
|
||||
item.percent !== undefined && item.percent >= 50 ? 'bg-green-500' :
|
||||
item.percent !== undefined && item.percent >= 20 ? 'bg-amber-500' : 'bg-red-500'
|
||||
]"
|
||||
:style="{ width: `${item.percent ?? 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] text-muted-foreground/50 w-7 text-right tabular-nums">{{ item.value }}</span>
|
||||
<span
|
||||
v-if="item.resetsAt"
|
||||
class="text-[10px] text-muted-foreground/40 w-14 text-right tabular-nums"
|
||||
>{{ formatResetCountdown(item.resetsAt) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Cookie 失效警告 -->
|
||||
<div
|
||||
v-if="getProviderCookieExpired(provider.id)"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] text-amber-600 dark:text-amber-500"
|
||||
:title="getProviderCookieExpired(provider.id)?.message"
|
||||
>签到 Cookie 已失效</span>
|
||||
</div>
|
||||
<!-- 签到状态 -->
|
||||
<div
|
||||
v-else-if="getProviderCheckin(provider.id)"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-if="getProviderCheckin(provider.id)?.success !== false"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>已签到</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] text-destructive/70"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>签到失败</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 余额查询失败时显示错误 -->
|
||||
<div
|
||||
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
|
||||
class="text-xs text-destructive/80"
|
||||
:title="getProviderBalanceError(provider.id)?.message"
|
||||
>
|
||||
{{ getProviderBalanceError(provider.id)?.message }}
|
||||
</div>
|
||||
<!-- 显示本地配置的月度配额 -->
|
||||
<div
|
||||
v-else-if="provider.billing_type === 'monthly_quota'"
|
||||
class="space-y-0.5 text-xs"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] font-normal border-border/50"
|
||||
>
|
||||
{{ formatBillingType(provider.billing_type) }}
|
||||
</Badge>
|
||||
<div class="text-muted-foreground/70 pt-0.5">
|
||||
<span
|
||||
class="font-semibold"
|
||||
:class="getQuotaUsedColorClass(provider)"
|
||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / <span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-muted-foreground/50"
|
||||
>-</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
|
||||
defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
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
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div
|
||||
class="p-4 space-y-3 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
@click="$emit('viewDetail', provider.id)"
|
||||
>
|
||||
<!-- 第一行:名称 + 状态 + 操作 -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0 space-y-0.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-foreground truncate">{{ provider.name }}</span>
|
||||
<a
|
||||
v-if="provider.website"
|
||||
:href="provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
:title="provider.website"
|
||||
@click.stop
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<Badge
|
||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||
class="text-xs shrink-0"
|
||||
>
|
||||
{{ provider.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- 内联编辑备注 (移动端) -->
|
||||
<div
|
||||
v-if="editingDescriptionId === provider.id"
|
||||
data-desc-editor
|
||||
class="flex items-center gap-1 max-w-[180px]"
|
||||
@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="输入备注..."
|
||||
@keydown="handleDescriptionKeydown"
|
||||
>
|
||||
<button
|
||||
class="shrink-0 p-0.5 rounded hover:bg-muted text-primary"
|
||||
title="保存"
|
||||
@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="取消"
|
||||
@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-[120px] 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"
|
||||
>添加备注</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-0.5 shrink-0"
|
||||
@click.stop
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="查看详情"
|
||||
@click="$emit('viewDetail', provider.id)"
|
||||
>
|
||||
<Eye class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="编辑"
|
||||
@click="$emit('editProvider', provider)"
|
||||
>
|
||||
<Edit class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="扩展操作配置"
|
||||
@click="$emit('openOpsConfig', provider)"
|
||||
>
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('toggleStatus', provider)"
|
||||
>
|
||||
<Power class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@click="$emit('deleteProvider', provider)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:计费类型 + 余额/配额 + 资源统计 -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-xs font-normal border-border/50"
|
||||
>
|
||||
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
|
||||
</Badge>
|
||||
<!-- 余额加载中 -->
|
||||
<span
|
||||
v-if="provider.ops_configured && isBalanceLoading(provider.id)"
|
||||
class="text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
加载中...
|
||||
</span>
|
||||
<!-- 余额(从上游 API 查询) -->
|
||||
<span
|
||||
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
|
||||
<!-- Cookie 失效警告 -->
|
||||
<span
|
||||
v-if="getProviderCookieExpired(provider.id)"
|
||||
class="ml-1 text-amber-600 dark:text-amber-500"
|
||||
:title="getProviderCookieExpired(provider.id)?.message"
|
||||
>签到 Cookie 已失效</span>
|
||||
<!-- 签到状态显示 -->
|
||||
<span
|
||||
v-else-if="getProviderCheckin(provider.id) && getProviderCheckin(provider.id)?.success !== false"
|
||||
class="ml-1 text-muted-foreground"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>已签到</span>
|
||||
<span
|
||||
v-else-if="getProviderCheckin(provider.id)?.success === false"
|
||||
class="ml-1 text-destructive/70"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>签到失败</span>
|
||||
</span>
|
||||
<!-- 余额查询失败时显示错误 -->
|
||||
<span
|
||||
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
|
||||
class="text-destructive/80"
|
||||
:title="getProviderBalanceError(provider.id)?.message"
|
||||
>
|
||||
{{ getProviderBalanceError(provider.id)?.message }}
|
||||
</span>
|
||||
<!-- 本地配额 -->
|
||||
<span
|
||||
v-else-if="provider.billing_type === 'monthly_quota'"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
配额 <span
|
||||
class="font-semibold"
|
||||
:class="getQuotaUsedColorClass(provider)"
|
||||
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span>/<span class="font-medium">${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}</span>
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
端点 {{ provider.active_endpoints }}/{{ provider.total_endpoints }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
密钥 {{ provider.active_keys }}/{{ provider.total_keys }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">
|
||||
模型 {{ provider.active_models }}/{{ provider.total_models }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 第三行:端点健康 -->
|
||||
<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)"
|
||||
>
|
||||
<!-- 上排:缩写 + 百分比 -->
|
||||
<div class="flex items-center justify-between text-[10px] leading-none">
|
||||
<span class="font-medium text-muted-foreground/80">
|
||||
{{ API_FORMAT_SHORT[endpoint.api_format] || endpoint.api_format.substring(0,2) }}
|
||||
</span>
|
||||
<span class="font-medium text-muted-foreground/80">
|
||||
{{ isEndpointAvailable(endpoint) ? `${(endpoint.health_score * 100).toFixed(0)}%` : '-' }}
|
||||
</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: isEndpointAvailable(endpoint) ? `${Math.max(endpoint.health_score * 100, 5)}%` : '100%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
Edit,
|
||||
Eye,
|
||||
Trash2,
|
||||
Power,
|
||||
KeyRound,
|
||||
ExternalLink,
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import { type ProviderWithEndpointsSummary, API_FORMAT_SHORT } from '@/api/endpoints'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
editingDescriptionId: string | null
|
||||
// Balance functions
|
||||
isBalanceLoading: (providerId: string) => boolean
|
||||
getProviderBalance: (providerId: string) => { available: number | null; 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
|
||||
formatBalanceDisplay: (balance: { available: number | null; currency: string } | null) => string
|
||||
getQuotaUsedColorClass: (provider: ProviderWithEndpointsSummary) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'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('')
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/50">
|
||||
<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 text-foreground shrink-0">
|
||||
提供商管理
|
||||
</h3>
|
||||
|
||||
<!-- 右侧:操作区 -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/70 z-10 pointer-events-none" />
|
||||
<Input
|
||||
id="provider-search"
|
||||
:model-value="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索提供商..."
|
||||
class="w-32 sm:w-44 pl-8 pr-3 h-8 text-sm bg-muted/30 border-border/50 focus:border-primary/50 transition-colors"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<Select
|
||||
:model-value="filterStatus"
|
||||
@update:model-value="$emit('update:filterStatus', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="status in statusFilters"
|
||||
:key="status.value"
|
||||
:value="status.value"
|
||||
>
|
||||
{{ status.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- API 格式筛选 -->
|
||||
<Select
|
||||
:model-value="filterApiFormat"
|
||||
@update:model-value="$emit('update:filterApiFormat', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-20 sm:w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="fmt in apiFormatFilters"
|
||||
:key="fmt.value"
|
||||
:value="fmt.value"
|
||||
>
|
||||
{{ fmt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 模型筛选 -->
|
||||
<Select
|
||||
:model-value="filterModel"
|
||||
@update:model-value="$emit('update:filterModel', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-20 sm:w-36 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="全部模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="model in modelFilters"
|
||||
:key="model.value"
|
||||
:value="model.value"
|
||||
>
|
||||
{{ model.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- 重置筛选 -->
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="重置筛选"
|
||||
@click="$emit('resetFilters')"
|
||||
>
|
||||
<FilterX class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 调度策略 -->
|
||||
<button
|
||||
class="group inline-flex items-center gap-1.5 px-2.5 h-8 rounded-md border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-primary/40 transition-all duration-200 text-xs"
|
||||
title="点击调整调度策略"
|
||||
@click="$emit('openPriorityDialog')"
|
||||
>
|
||||
<span class="text-muted-foreground/80 hidden sm:inline">调度:</span>
|
||||
<span class="font-medium text-foreground/90">{{ priorityModeLabel }}</span>
|
||||
<ChevronDown class="w-3 h-3 text-muted-foreground/70 group-hover:text-foreground transition-colors" />
|
||||
</button>
|
||||
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="新增提供商"
|
||||
@click="$emit('addProvider')"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search, Plus, ChevronDown, FilterX } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import type { FilterOption } from '@/features/providers/composables/useProviderFilters'
|
||||
|
||||
defineProps<{
|
||||
searchQuery: string
|
||||
filterStatus: string
|
||||
filterApiFormat: string
|
||||
filterModel: string
|
||||
statusFilters: FilterOption[]
|
||||
apiFormatFilters: FilterOption[]
|
||||
modelFilters: FilterOption[]
|
||||
hasActiveFilters: boolean
|
||||
priorityModeLabel: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:filterStatus': [value: string]
|
||||
'update:filterApiFormat': [value: string]
|
||||
'update:filterModel': [value: string]
|
||||
'resetFilters': []
|
||||
'openPriorityDialog': []
|
||||
'addProvider': []
|
||||
'refresh': []
|
||||
}>()
|
||||
</script>
|
||||
288
frontend/src/features/providers/components/ProviderTableRow.vue
Normal file
288
frontend/src/features/providers/components/ProviderTableRow.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<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 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="provider.website"
|
||||
:href="provider.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
:title="provider.website"
|
||||
@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="输入备注..."
|
||||
@keydown="handleDescriptionKeydown"
|
||||
>
|
||||
<button
|
||||
class="shrink-0 p-0.5 rounded hover:bg-muted text-primary"
|
||||
title="保存"
|
||||
@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="取消"
|
||||
@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"
|
||||
>添加备注</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="space-y-0.5 text-xs">
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-muted-foreground/70">端点:</span>
|
||||
<span class="font-medium text-foreground/90">{{ provider.active_endpoints }}</span>
|
||||
<span class="text-muted-foreground/50">/{{ provider.total_endpoints }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-muted-foreground/70">密钥:</span>
|
||||
<span class="font-medium text-foreground/90">{{ provider.active_keys }}</span>
|
||||
<span class="text-muted-foreground/50">/{{ provider.total_keys }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<span class="text-muted-foreground/70">模型:</span>
|
||||
<span class="font-medium text-foreground/90">{{ provider.active_models }}</span>
|
||||
<span class="text-muted-foreground/50">/{{ provider.total_models }}</span>
|
||||
</div>
|
||||
</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)"
|
||||
>
|
||||
<!-- 上排:缩写 + 百分比 -->
|
||||
<div class="flex items-center justify-between text-[10px] leading-none">
|
||||
<span class="font-medium text-muted-foreground/80">
|
||||
{{ API_FORMAT_SHORT[endpoint.api_format] || endpoint.api_format.substring(0,2) }}
|
||||
</span>
|
||||
<span class="font-medium text-muted-foreground/80">
|
||||
{{ isEndpointAvailable(endpoint) ? `${(endpoint.health_score * 100).toFixed(0)}%` : '-' }}
|
||||
</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: isEndpointAvailable(endpoint) ? `${Math.max(endpoint.health_score * 100, 5)}%` : '100%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs text-muted-foreground/50"
|
||||
>暂无端点</span>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 text-center">
|
||||
<Badge
|
||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ provider.is_active ? '活跃' : '已停用' }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="py-3.5"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="查看详情"
|
||||
@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="编辑提供商"
|
||||
@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="扩展操作配置"
|
||||
@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="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="删除提供商"
|
||||
@click="$emit('deleteProvider', provider)"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { 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, API_FORMAT_SHORT } from '@/api/endpoints'
|
||||
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
|
||||
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
|
||||
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('')
|
||||
|
||||
// 当进入编辑模式时,同步 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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user