feat: 添加 Provider Ops 扩展操作系统,支持余额监控

主要更改:
- 新增 Provider Ops 服务框架,支持通过架构配置执行余额查询等扩展操作
- 后端:添加 provider_ops 服务层和 API 路由
- 前端:添加 ProviderAuthDialog 组件配置认证信息
- 前端:添加 providerOps API 和认证模板系统

UI/组件优化:
- Input 组件:新增 masked 属性,使用 CSS 遮蔽敏感信息,避免触发密码管理器
- Pagination 组件:移除首页/末页/上下页按钮,改为页码跳转输入框
- KeyFormDialog:使用 masked 属性简化 API Key 输入逻辑
- ProviderManagement:重新设计表格布局,显示余额监控数据
This commit is contained in:
fawney19
2026-01-17 19:50:35 +08:00
parent 3cdf471473
commit c71027c466
29 changed files with 4653 additions and 144 deletions

View File

@@ -3,7 +3,6 @@
<!-- 提供商表格 -->
<Card
variant="default"
class="overflow-hidden"
>
<!-- 标题和操作栏 -->
<div class="px-4 sm:px-6 py-3 sm:py-3.5 border-b border-border/50">
@@ -99,24 +98,18 @@
<Table>
<TableHeader>
<TableRow class="border-b border-border/40 hover:bg-transparent">
<TableHead class="w-[150px] h-11 font-medium text-foreground/80">
<TableHead class="w-[180px] h-11 font-medium text-foreground/80">
提供商信息
</TableHead>
<TableHead class="w-[100px] h-11 font-medium text-foreground/80">
计费类型
</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80">
官网
<TableHead class="w-[140px] h-11 font-medium text-foreground/80 text-center">
余额监控
</TableHead>
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">
资源统计
</TableHead>
<TableHead class="w-[240px] h-11 font-medium text-foreground/80">
<TableHead class="w-[280px] h-11 font-medium text-foreground/80">
端点健康
</TableHead>
<TableHead class="w-[140px] h-11 font-medium text-foreground/80">
配额/限流
</TableHead>
<TableHead class="w-[80px] h-11 font-medium text-foreground/80 text-center">
状态
</TableHead>
@@ -134,28 +127,49 @@
@click="handleRowClick($event, provider.id)"
>
<TableCell class="py-3.5">
<span class="text-sm font-medium text-foreground">{{ provider.name }}</span>
<div class="space-y-0.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-xs text-primary/80 hover:text-primary hover:underline truncate block max-w-[160px]"
:title="provider.website"
@click.stop
>
{{ formatWebsiteDisplay(provider.website) }}
</a>
</div>
</TableCell>
<TableCell class="py-3.5">
<Badge
variant="outline"
class="text-xs font-normal border-border/50"
<TableCell class="py-3.5 text-center">
<!-- 显示从上游 API 查询的余额 -->
<div
v-if="provider.ops_configured && getProviderBalance(provider.id)"
class="text-xs"
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
</TableCell>
<TableCell class="py-3.5">
<a
v-if="provider.website"
:href="provider.website"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary/80 hover:text-primary hover:underline truncate block max-w-[100px]"
:title="provider.website"
@click.stop
<span class="font-semibold text-foreground/90">
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
</span>
</div>
<!-- 显示本地配置的月度配额 -->
<div
v-else-if="provider.billing_type === 'monthly_quota'"
class="space-y-0.5 text-xs"
>
{{ formatWebsiteDisplay(provider.website) }}
</a>
<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"
@@ -204,25 +218,6 @@
class="text-xs text-muted-foreground/50"
>暂无端点</span>
</TableCell>
<TableCell class="py-3.5">
<div class="space-y-0.5 text-xs">
<div
v-if="provider.billing_type === 'monthly_quota'"
class="text-muted-foreground/70"
>
配额: <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
v-else
class="text-muted-foreground/50"
>
按量付费
</div>
</div>
</TableCell>
<TableCell class="py-3.5 text-center">
<Badge
:variant="provider.is_active ? 'success' : 'secondary'"
@@ -254,6 +249,15 @@
>
<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="openOpsConfigDialog(provider)"
>
<KeyRound class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -311,10 +315,29 @@
variant="ghost"
size="icon"
class="h-7 w-7"
title="查看详情"
@click="openProviderDrawer(provider.id)"
>
<Eye class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑"
@click="openEditProviderDialog(provider)"
>
<Edit class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="扩展操作配置"
@click="openOpsConfigDialog(provider)"
>
<KeyRound class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -334,7 +357,7 @@
</div>
</div>
<!-- 第二行计费类型 + 资源统计 -->
<!-- 第二行计费类型 + 余额/配额 + 资源统计 -->
<div class="flex flex-wrap items-center gap-3 text-xs">
<Badge
variant="outline"
@@ -342,6 +365,23 @@
>
{{ formatBillingType(provider.billing_type || 'pay_as_you_go') }}
</Badge>
<!-- 余额从上游 API 查询 -->
<span
v-if="provider.ops_configured && getProviderBalance(provider.id)"
class="text-muted-foreground"
>
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
</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>
@@ -371,19 +411,6 @@
{{ endpoint.api_format }}
</span>
</div>
<!-- 第四行配额 -->
<div
v-if="provider.billing_type === 'monthly_quota'"
class="flex items-center gap-3 text-xs text-muted-foreground"
>
<span>
配额: <span
class="font-semibold"
:class="getQuotaUsedColorClass(provider)"
>${{ (provider.monthly_used_usd ?? 0).toFixed(2) }}</span> / ${{ (provider.monthly_quota_usd ?? 0).toFixed(2) }}
</span>
</div>
</div>
</div>
@@ -421,6 +448,13 @@
@toggle-status="toggleProviderStatus"
@refresh="loadProviders"
/>
<ProviderAuthDialog
v-model:open="opsConfigDialogOpen"
:provider-id="opsConfigProviderId"
:provider-website="opsConfigProviderWebsite"
@saved="handleOpsConfigSaved"
/>
</template>
<script setup lang="ts">
@@ -432,7 +466,8 @@ import {
Eye,
Trash2,
ChevronDown,
Power
Power,
KeyRound
} from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
@@ -446,7 +481,7 @@ import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue'
import Pagination from '@/components/ui/pagination.vue'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { ProviderFormDialog, PriorityManagementDialog } from '@/features/providers/components'
import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components'
import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
@@ -458,6 +493,7 @@ import {
type ProviderWithEndpointsSummary
} from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import { batchQueryBalance, type ActionResultResponse } from '@/api/providerOps'
import { formatBillingType } from '@/utils/format'
const { error: showError, success: showSuccess } = useToast()
@@ -473,6 +509,17 @@ const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false)
const selectedProviderId = ref<string | null>(null)
// 扩展操作配置对话框
const opsConfigDialogOpen = ref(false)
const opsConfigProviderId = ref('')
const opsConfigProviderWebsite = ref('')
// 余额数据缓存 {providerId: ActionResultResponse}
const balanceCache = ref<Record<string, ActionResultResponse>>({})
// 余额加载请求版本计数器(用于防止竞态条件)
// 使用普通变量而非 ref因为不需要响应式仅用于比较请求版本
let balanceLoadVersion = 0
// 搜索
const searchQuery = ref('')
@@ -542,8 +589,12 @@ async function loadPriorityMode() {
// 加载提供商列表
async function loadProviders() {
loading.value = true
// 清空旧的余额缓存,避免数据累积
balanceCache.value = {}
try {
providers.value = await getProvidersSummary()
// 异步加载配置了 ops 的 provider 的余额数据
loadBalances()
} catch (err: any) {
showError(err.response?.data?.detail || '加载提供商列表失败', '错误')
} finally {
@@ -551,6 +602,70 @@ async function loadProviders() {
}
}
// 异步加载余额数据(使用批量接口)
async function loadBalances() {
const currentVersion = ++balanceLoadVersion
try {
const opsProviderIds = providers.value
.filter(p => p.ops_configured)
.map(p => p.id)
if (opsProviderIds.length === 0) return
const results = await batchQueryBalance(opsProviderIds)
// 检查是否有新的请求已经开始,如果有则丢弃当前结果
if (currentVersion !== balanceLoadVersion) return
// 将成功的结果存入缓存
for (const [providerId, result] of Object.entries(results)) {
if (result.status === 'success') {
balanceCache.value[providerId] = result
}
}
} catch (e) {
console.warn('[loadBalances] 加载余额数据失败:', e)
}
}
/**
* 类型守卫:检查是否为 BalanceInfo简化版
* 只检查余额显示所需的字段,完整的 BalanceInfo 还包含 total_granted, total_used, expires_at, extra
*/
function isBalanceInfo(data: unknown): data is { total_available: number | null; currency: string } {
if (typeof data !== 'object' || data === null) return false
if (!('total_available' in data) || !('currency' in data)) return false
const d = data as Record<string, unknown>
// total_available 必须是 number 或 null
if (d.total_available !== null && typeof d.total_available !== 'number') return false
// currency 必须是 string
if (typeof d.currency !== 'string') return false
return true
}
// 获取 provider 的余额显示
function getProviderBalance(providerId: string): { available: number | null; currency: string } | null {
const result = balanceCache.value[providerId]
if (!result || result.status !== 'success' || !result.data) {
return null
}
if (!isBalanceInfo(result.data)) {
return null
}
return {
available: result.data.total_available,
currency: result.data.currency || 'USD'
}
}
// 格式化余额显示
function formatBalanceDisplay(balance: { available: number | null; currency: string } | null): string {
if (!balance || balance.available == null) {
return '-'
}
const symbol = balance.currency === 'USD' ? '$' : balance.currency
return `${symbol}${balance.available.toFixed(2)}`
}
// 格式化官网显示
function formatWebsiteDisplay(url: string): string {
@@ -660,6 +775,19 @@ function openEditProviderDialog(provider: ProviderWithEndpointsSummary) {
providerDialogOpen.value = true
}
// 打开扩展操作配置对话框
function openOpsConfigDialog(provider: ProviderWithEndpointsSummary) {
opsConfigProviderId.value = provider.id
opsConfigProviderWebsite.value = provider.website || ''
opsConfigDialogOpen.value = true
}
// 扩展操作配置保存回调
function handleOpsConfigSaved() {
opsConfigDialogOpen.value = false
loadProviders()
}
// 处理提供商编辑完成
function handleProviderUpdated() {
loadProviders()