feat: Provider 摘要 API 改为服务端分页,支持搜索和筛选

- 后端 /summary 接口新增 page/page_size/search/status/api_format/model_id 参数
- 新增 ProviderSummaryPageResponse 分页响应模型
- 前端 useProviderFilters 从客户端筛选改为构建服务端查询参数
- ProviderManagement 通过 watch queryParams 实现分页/筛选联动,搜索 debounce 300ms
- PriorityManagementDialog 改为对话框打开时自行加载全量 providers
- 其他使用方(StandaloneKeyFormDialog/UserFormDialog/ReplayDialog/ModelManagement)适配新接口
This commit is contained in:
fawney19
2026-03-09 12:45:54 +08:00
parent 40736a8334
commit 0046123e22
10 changed files with 182 additions and 151 deletions

View File

@@ -9,13 +9,32 @@ import type {
} from './types' } from './types'
/** /**
* 获取 Providers 摘要(包含 Endpoints 统计 * 获取 Providers 摘要(分页
*/ */
export async function getProvidersSummary(): Promise<ProviderWithEndpointsSummary[]> { export interface ProviderSummaryQuery {
return dedupedRequest('providers:summary', async () => { page?: number
const response = await client.get<ProviderWithEndpointsSummary[]>('/api/admin/providers/summary') page_size?: number
return response.data search?: string
}) status?: string
api_format?: string
model_id?: string
}
export interface ProviderSummaryPageResponse {
total: number
page: number
page_size: number
items: ProviderWithEndpointsSummary[]
}
export async function getProvidersSummary(
params: ProviderSummaryQuery = {},
): Promise<ProviderSummaryPageResponse> {
const response = await client.get<ProviderSummaryPageResponse>(
'/api/admin/providers/summary',
{ params },
)
return response.data
} }
/** /**

View File

@@ -401,12 +401,12 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
// 加载选项数据 // 加载选项数据
async function loadAccessRestrictionOptions() { async function loadAccessRestrictionOptions() {
try { try {
const [providersData, modelsData, formatsData] = await Promise.all([ const [providersResponse, modelsData, formatsData] = await Promise.all([
getProvidersSummary(), getProvidersSummary({ page_size: 9999 }),
getGlobalModels({ limit: 1000, is_active: true }), getGlobalModels({ limit: 1000, is_active: true }),
adminApi.getApiFormats() adminApi.getApiFormats()
]) ])
providers.value = providersData providers.value = providersResponse.items
globalModels.value = modelsData.models || [] globalModels.value = modelsData.models || []
allApiFormats.value = formatsData.formats?.map((f: { value: string }) => f.value) || [] allApiFormats.value = formatsData.formats?.map((f: { value: string }) => f.value) || []
} catch (err) { } catch (err) {

View File

@@ -472,7 +472,7 @@ import Badge from '@/components/ui/badge.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser' import { parseApiError } from '@/utils/errorParser'
import { updateProvider, updateProviderKey } from '@/api/endpoints' import { updateProvider, updateProviderKey } from '@/api/endpoints'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints' import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
import { batchQueryBalance, type ActionResultResponse, type BalanceInfo } from '@/api/providerOps' import { batchQueryBalance, type ActionResultResponse, type BalanceInfo } from '@/api/providerOps'
import { API_FORMAT_SHORT } from '@/api/endpoints/types' import { API_FORMAT_SHORT } from '@/api/endpoints/types'
@@ -508,7 +508,6 @@ interface KeyWithMeta {
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
providers: ProviderWithEndpointsSummary[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -663,7 +662,7 @@ function normalizeRateMultipliers(
const providerById = computed(() => { const providerById = computed(() => {
const map = new Map<string, ProviderWithEndpointsSummary>() const map = new Map<string, ProviderWithEndpointsSummary>()
props.providers.forEach((provider) => { sortedProviders.value.forEach((provider) => {
map.set(provider.id, provider) map.set(provider.id, provider)
}) })
return map return map
@@ -671,7 +670,7 @@ const providerById = computed(() => {
const providerIdByName = computed(() => { const providerIdByName = computed(() => {
const map = new Map<string, string>() const map = new Map<string, string>()
props.providers.forEach((provider) => { sortedProviders.value.forEach((provider) => {
if (!map.has(provider.name)) { if (!map.has(provider.name)) {
map.set(provider.name, provider.id) map.set(provider.name, provider.id)
} }
@@ -686,7 +685,7 @@ function resolveProviderId(key: Pick<KeyWithMeta, 'provider_id' | 'provider_name
const poolProviderIds = computed(() => { const poolProviderIds = computed(() => {
const set = new Set<string>() const set = new Set<string>()
props.providers.forEach((provider) => { sortedProviders.value.forEach((provider) => {
if (provider.pool_advanced) { if (provider.pool_advanced) {
set.add(provider.id) set.add(provider.id)
} }
@@ -835,16 +834,10 @@ function sortProvidersByActiveAndPriority(providers: ProviderWithEndpointsSummar
}) })
} }
// 监听 props.providers 变化
watch(() => props.providers, (newProviders) => {
if (newProviders) {
sortedProviders.value = sortProvidersByActiveAndPriority(newProviders)
}
}, { immediate: true })
// 监听对话框打开 // 监听对话框打开
watch(internalOpen, async (open) => { watch(internalOpen, async (open) => {
if (open) { if (open) {
await loadAllProviders()
await loadCurrentPriorityMode() await loadCurrentPriorityMode()
await loadKeysByFormat() await loadKeysByFormat()
// 异步加载余额数据 // 异步加载余额数据
@@ -852,6 +845,16 @@ watch(internalOpen, async (open) => {
} }
}) })
// 加载全量 providers优先级管理需要完整列表
async function loadAllProviders() {
try {
const response = await getProvidersSummary({ page: 1, page_size: 9999 })
sortedProviders.value = sortProvidersByActiveAndPriority(response.items)
} catch {
sortedProviders.value = []
}
}
// 加载当前的优先级模式配置 // 加载当前的优先级模式配置
async function loadCurrentPriorityMode() { async function loadCurrentPriorityMode() {
try { try {

View File

@@ -1,5 +1,5 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints' import type { ProviderSummaryQuery } from '@/api/endpoints'
export interface FilterOption { export interface FilterOption {
value: string value: string
@@ -7,7 +7,6 @@ export interface FilterOption {
} }
export function useProviderFilters( export function useProviderFilters(
providers: () => ProviderWithEndpointsSummary[],
globalModels: () => { id: string; name: string }[], globalModels: () => { id: string; name: string }[],
) { ) {
// 搜索与筛选 // 搜索与筛选
@@ -33,11 +32,8 @@ export function useProviderFilters(
{ value: 'gemini:cli', label: 'Gemini CLI' }, { value: 'gemini:cli', label: 'Gemini CLI' },
] ]
// 动态计算模型筛选选项:只展示当前提供商列表中实际关联的全局模型
const modelFilters = computed<FilterOption[]>(() => { const modelFilters = computed<FilterOption[]>(() => {
const usedIds = new Set(providers().flatMap(p => p.global_model_ids || []))
const items = globalModels() const items = globalModels()
.filter(m => usedIds.has(m.id))
.map(m => ({ value: m.id, label: m.name })) .map(m => ({ value: m.id, label: m.name }))
.sort((a, b) => a.label.localeCompare(b.label)) .sort((a, b) => a.label.localeCompare(b.label))
return [{ value: 'all', label: '全部模型' }, ...items] return [{ value: 'all', label: '全部模型' }, ...items]
@@ -52,68 +48,22 @@ export function useProviderFilters(
) )
}) })
// 筛选后的提供商列表
const filteredProviders = computed(() => {
let result = [...providers()]
// 搜索筛选(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value.trim()) {
const keywords = searchQuery.value
.toLowerCase()
.split(/\s+/)
.filter(k => k.length > 0)
result = result.filter(p => {
const searchableText = `${p.name}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
// 状态筛选
if (filterStatus.value !== 'all') {
const isActive = filterStatus.value === 'active'
result = result.filter(p => p.is_active === isActive)
}
// API 格式筛选
if (filterApiFormat.value !== 'all') {
result = result.filter(
p => p.api_formats && p.api_formats.includes(filterApiFormat.value),
)
}
// 模型筛选
if (filterModel.value !== 'all') {
result = result.filter(
p => p.global_model_ids && p.global_model_ids.includes(filterModel.value),
)
}
// 排序
return result.sort((a, b) => {
// 1. 优先显示活跃的提供商
if (a.is_active !== b.is_active) {
return a.is_active ? -1 : 1
}
// 2. 按优先级排序
if (a.provider_priority !== b.provider_priority) {
return a.provider_priority - b.provider_priority
}
// 3. 按名称排序
return a.name.localeCompare(b.name)
})
})
// 分页 // 分页
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const total = ref(0)
const paginatedProviders = computed(() => { // 服务端分页查询参数
const start = (currentPage.value - 1) * pageSize.value const queryParams = computed<ProviderSummaryQuery>(() => ({
const end = start + pageSize.value page: currentPage.value,
return filteredProviders.value.slice(start, end) page_size: pageSize.value,
}) search: searchQuery.value.trim() || undefined,
status: filterStatus.value !== 'all' ? filterStatus.value : undefined,
api_format: filterApiFormat.value !== 'all' ? filterApiFormat.value : undefined,
model_id: filterModel.value !== 'all' ? filterModel.value : undefined,
}))
// 搜索/筛选时重置分页 // 搜索/筛选变化时重置分页到第1页
watch([searchQuery, filterStatus, filterApiFormat, filterModel], () => { watch([searchQuery, filterStatus, filterApiFormat, filterModel], () => {
currentPage.value = 1 currentPage.value = 1
}) })
@@ -134,10 +84,10 @@ export function useProviderFilters(
apiFormatFilters, apiFormatFilters,
modelFilters, modelFilters,
hasActiveFilters, hasActiveFilters,
filteredProviders,
currentPage, currentPage,
pageSize, pageSize,
paginatedProviders, total,
queryParams,
resetFilters, resetFilters,
} }
} }

View File

@@ -388,8 +388,8 @@ watch(() => props.isOpen, async (isOpen) => {
showRequestHeaders.value = false showRequestHeaders.value = false
showResponseHeaders.value = false showResponseHeaders.value = false
try { try {
const summary = await getProvidersSummary() const response = await getProvidersSummary({ page_size: 9999 })
providers.value = summary providers.value = response.items
.filter(p => p.is_active && p.active_endpoints > 0) .filter(p => p.is_active && p.active_endpoints > 0)
.map(p => ({ id: p.id, name: p.name })) .map(p => ({ id: p.id, name: p.name }))
} catch (e) { } catch (e) {

View File

@@ -536,12 +536,12 @@ const isFormValid = computed(() => {
// 加载访问控制选项 // 加载访问控制选项
async function loadAccessControlOptions(): Promise<void> { async function loadAccessControlOptions(): Promise<void> {
try { try {
const [providersData, modelsData, formatsData] = await Promise.all([ const [providersResponse, modelsData, formatsData] = await Promise.all([
getProvidersSummary(), getProvidersSummary({ page_size: 9999 }),
getGlobalModels({ limit: 1000, is_active: true }), getGlobalModels({ limit: 1000, is_active: true }),
adminApi.getApiFormats() adminApi.getApiFormats()
]) ])
providers.value = providersData providers.value = providersResponse.items
globalModels.value = modelsData.models || [] globalModels.value = modelsData.models || []
apiFormats.value = formatsData.formats || [] apiFormats.value = formatsData.formats || []
} catch (err) { } catch (err) {

View File

@@ -1168,7 +1168,7 @@ async function ensureProviderOptions() {
providerOptionsRequest = (async () => { providerOptionsRequest = (async () => {
try { try {
loadingProviderOptions.value = true loadingProviderOptions.value = true
providerOptions.value = await getProvidersSummary() providerOptions.value = (await getProvidersSummary({ page_size: 9999 })).items
} catch (err: unknown) { } catch (err: unknown) {
const message = parseApiError(err, '加载 Provider 列表失败') const message = parseApiError(err, '加载 Provider 列表失败')
showError(message, '错误') showError(message, '错误')
@@ -1494,7 +1494,7 @@ async function refreshData() {
async function loadProviders() { async function loadProviders() {
const requestId = ++providersRequestId const requestId = ++providersRequestId
try { try {
const nextProviders = await getProvidersSummary() const nextProviders = (await getProvidersSummary({ page_size: 9999 })).items
if (requestId !== providersRequestId) return if (requestId !== providersRequestId) return
providers.value = nextProviders providers.value = nextProviders
} catch (err: unknown) { } catch (err: unknown) {

View File

@@ -36,7 +36,7 @@
<!-- 空状态 --> <!-- 空状态 -->
<div <div
v-else-if="filteredProviders.length === 0" v-else-if="providers.length === 0"
class="flex flex-col items-center justify-center py-16 text-center" class="flex flex-col items-center justify-center py-16 text-center"
> >
<div class="text-muted-foreground mb-2"> <div class="text-muted-foreground mb-2">
@@ -87,7 +87,7 @@
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<ProviderTableRow <ProviderTableRow
v-for="provider in paginatedProviders" v-for="provider in providers"
:key="provider.id" :key="provider.id"
:provider="provider" :provider="provider"
:editing-description-id="editingDescriptionId" :editing-description-id="editingDescriptionId"
@@ -118,11 +118,11 @@
<!-- 移动端卡片列表 --> <!-- 移动端卡片列表 -->
<div <div
v-if="!loading && filteredProviders.length > 0" v-if="!loading && providers.length > 0"
class="xl:hidden divide-y divide-border/40" class="xl:hidden divide-y divide-border/40"
> >
<ProviderMobileCard <ProviderMobileCard
v-for="provider in paginatedProviders" v-for="provider in providers"
:key="provider.id" :key="provider.id"
:provider="provider" :provider="provider"
:editing-description-id="editingDescriptionId" :editing-description-id="editingDescriptionId"
@@ -146,9 +146,9 @@
<!-- 分页 --> <!-- 分页 -->
<Pagination <Pagination
v-if="!loading && filteredProviders.length > 0" v-if="!loading && total > 0"
:current="currentPage" :current="currentPage"
:total="filteredProviders.length" :total="total"
:page-size="pageSize" :page-size="pageSize"
cache-key="provider-management-page-size" cache-key="provider-management-page-size"
@update:current="currentPage = $event" @update:current="currentPage = $event"
@@ -168,7 +168,6 @@
<PriorityManagementDialog <PriorityManagementDialog
v-model="priorityDialogOpen" v-model="priorityDialogOpen"
:providers="providers"
@saved="handlePrioritySaved" @saved="handlePrioritySaved"
/> />
@@ -190,7 +189,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import Table from '@/components/ui/table.vue' import Table from '@/components/ui/table.vue'
@@ -247,13 +246,12 @@ const {
apiFormatFilters, apiFormatFilters,
modelFilters, modelFilters,
hasActiveFilters, hasActiveFilters,
filteredProviders,
currentPage, currentPage,
pageSize, pageSize,
paginatedProviders, total,
queryParams,
resetFilters, resetFilters,
} = useProviderFilters( } = useProviderFilters(
() => providers.value,
() => globalModels.value, () => globalModels.value,
) )
@@ -349,14 +347,15 @@ async function loadGlobalModelList() {
} }
} }
// 加载提供商列表 // 加载提供商列表(服务端分页)
async function loadProviders() { async function loadProviders() {
const requestId = ++providersRequestId const requestId = ++providersRequestId
loading.value = true loading.value = true
try { try {
const nextProviders = await getProvidersSummary() const response = await getProvidersSummary(queryParams.value)
if (requestId !== providersRequestId) return if (requestId !== providersRequestId) return
providers.value = nextProviders providers.value = response.items
total.value = response.total
// 异步加载配置了 ops 的 provider 的余额数据 // 异步加载配置了 ops 的 provider 的余额数据
loadBalances(providers.value) loadBalances(providers.value)
} catch (err: unknown) { } catch (err: unknown) {
@@ -369,6 +368,24 @@ async function loadProviders() {
} }
} }
// 分页/筛选/搜索变化时重新加载
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(queryParams, (newParams, oldParams) => {
if (debounceTimer) clearTimeout(debounceTimer)
// 搜索输入 debounce 300ms其他变化立即执行
const isSearchOnly = newParams.search !== oldParams?.search &&
newParams.page === oldParams?.page &&
newParams.page_size === oldParams?.page_size &&
newParams.status === oldParams?.status &&
newParams.api_format === oldParams?.api_format &&
newParams.model_id === oldParams?.model_id
if (isSearchOnly) {
debounceTimer = setTimeout(loadProviders, 300)
} else {
loadProviders()
}
}, { deep: true })
// 使用复用的行点击逻辑 // 使用复用的行点击逻辑
const { handleMouseDown, shouldTriggerRowClick } = useRowClick() const { handleMouseDown, shouldTriggerRowClick } = useRowClick()
@@ -508,6 +525,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
document.removeEventListener('click', handleGlobalClick, true) document.removeEventListener('click', handleGlobalClick, true)
stopTick() stopTick()
}) })

View File

@@ -35,6 +35,7 @@ from src.models.endpoint_models import (
EndpointHealthEvent, EndpointHealthEvent,
EndpointHealthMonitor, EndpointHealthMonitor,
ProviderEndpointHealthMonitorResponse, ProviderEndpointHealthMonitorResponse,
ProviderSummaryPageResponse,
ProviderUpdateRequest, ProviderUpdateRequest,
ProviderWithEndpointsSummary, ProviderWithEndpointsSummary,
) )
@@ -46,46 +47,26 @@ router = APIRouter(tags=["Provider Summary"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@router.get("/summary", response_model=list[ProviderWithEndpointsSummary]) @router.get("/summary", response_model=ProviderSummaryPageResponse)
async def get_providers_summary( async def get_providers_summary(
request: Request, request: Request,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=10000),
search: str = Query("", description="按名称搜索"),
status: str = Query("all", description="all/active/inactive"),
api_format: str = Query("all", description="API 格式筛选"),
model_id: str = Query("all", description="全局模型 ID 筛选"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[ProviderWithEndpointsSummary]: ) -> ProviderSummaryPageResponse:
""" """获取提供商摘要信息(分页)"""
获取所有提供商摘要信息 adapter = AdminProviderSummaryAdapter(
page=page,
获取所有提供商的详细摘要信息,包含端点、密钥、模型统计和健康状态。 page_size=page_size,
search=search,
**返回字段**(数组,每项包含): status=status,
- `id`: 提供商 ID api_format=api_format,
- `name`: 提供商名称 model_id=model_id,
- `description`: 描述信息 )
- `website`: 官网地址
- `provider_priority`: 优先级
- `is_active`: 是否启用
- `billing_type`: 计费类型
- `monthly_quota_usd`: 月度配额(美元)
- `monthly_used_usd`: 本月已使用金额(美元)
- `quota_reset_day`: 配额重置日期
- `quota_last_reset_at`: 上次配额重置时间
- `quota_expires_at`: 配额过期时间
- `timeout`: 默认请求超时(秒)
- `max_retries`: 默认最大重试次数
- `proxy`: 默认代理配置
- `total_endpoints`: 端点总数
- `active_endpoints`: 活跃端点数
- `total_keys`: 密钥总数
- `active_keys`: 活跃密钥数
- `total_models`: 模型总数
- `active_models`: 活跃模型数
- `avg_health_score`: 平均健康分数0-1
- `unhealthy_endpoints`: 不健康端点数(健康分数 < 0.5
- `api_formats`: 支持的 API 格式列表
- `endpoint_health_details`: 端点健康详情(包含 api_format, health_score, is_active, active_keys
- `created_at`: 创建时间
- `updated_at`: 更新时间
"""
adapter = AdminProviderSummaryAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -788,20 +769,71 @@ class AdminProviderHealthMonitorAdapter(AdminApiAdapter):
return response.model_dump() return response.model_dump()
@dataclass
class AdminProviderSummaryAdapter(AdminApiAdapter): class AdminProviderSummaryAdapter(AdminApiAdapter):
@cache_result( page: int = 1
key_prefix="admin:providers:summary", page_size: int = 20
ttl=CacheTTL.ADMIN_USAGE_RECORDS, search: str = ""
user_specific=False, status: str = "all"
) api_format: str = "all"
model_id: str = "all"
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
query = db.query(Provider)
# 搜索筛选
if self.search.strip():
keywords = self.search.strip().lower().split()
for kw in keywords:
query = query.filter(func.lower(Provider.name).contains(kw))
# 状态筛选
if self.status == "active":
query = query.filter(Provider.is_active == True)
elif self.status == "inactive":
query = query.filter(Provider.is_active == False)
# API 格式筛选
if self.api_format != "all":
query = query.filter(
Provider.id.in_(
db.query(ProviderEndpoint.provider_id)
.filter(ProviderEndpoint.api_format == self.api_format)
.distinct()
)
)
# 全局模型 ID 筛选
if self.model_id != "all":
query = query.filter(
Provider.id.in_(
db.query(Model.provider_id)
.filter(
Model.global_model_id == self.model_id,
Model.is_active == True,
)
.distinct()
)
)
total = query.count()
providers = ( providers = (
db.query(Provider) query.order_by(Provider.provider_priority.asc(), Provider.created_at.asc())
.order_by(Provider.provider_priority.asc(), Provider.created_at.asc()) .offset((self.page - 1) * self.page_size)
.limit(self.page_size)
.all() .all()
) )
return [item.model_dump() for item in _build_provider_summaries_batch(db, providers)]
items = _build_provider_summaries_batch(db, providers)
return ProviderSummaryPageResponse(
total=total,
page=self.page,
page_size=self.page_size,
items=items,
).model_dump()
@dataclass @dataclass

View File

@@ -1041,6 +1041,15 @@ class ProviderWithEndpointsSummary(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class ProviderSummaryPageResponse(BaseModel):
"""Provider 摘要分页响应"""
total: int
page: int
page_size: int
items: list[ProviderWithEndpointsSummary]
# ========== 健康监控可视化模型 ========== # ========== 健康监控可视化模型 ==========