refactor: 拆分 gateway 单体为独立 crate,新增 systemd 部署方案

将 gateway 内部的 model-fetch、provider-transport、scheduler-core、
usage-runtime、video-tasks-core 模块提取为独立 crate;重构 gateway
内部模块结构(state/router/cache/data/query 等);移除大量遗留模块
文件;新增 systemd 二进制部署骨架及相关文档;更新前端 usage 相关
API 和组件。
This commit is contained in:
fawney19
2026-04-05 20:23:16 +08:00
parent cbc811f6ce
commit 763ff03a7b
777 changed files with 42659 additions and 21469 deletions

View File

@@ -89,6 +89,7 @@ export interface RefundCompleteRequest {
export const adminWalletApi = {
async listWallets(params?: {
status?: string
owner_type?: 'user' | 'api_key'
limit?: number
offset?: number
}): Promise<AdminWalletListResponse> {
@@ -98,6 +99,7 @@ export const adminWalletApi = {
async listAllWallets(params?: {
status?: string
owner_type?: 'user' | 'api_key'
}): Promise<AdminWallet[]> {
const items: AdminWallet[] = []
const limit = 200

View File

@@ -1,5 +1,6 @@
import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { AxiosRequestConfig } from 'axios'
import type { BillingSummary } from './auth'
// LDAP 配置导出结构
@@ -589,11 +590,13 @@ export const adminApi = {
async updateSystemConfig(
key: string,
value: unknown,
description?: string
description?: string,
requestConfig?: AxiosRequestConfig,
): Promise<{ key: string; value: unknown; description?: string }> {
const response = await apiClient.put<{ key: string; value: unknown; description?: string }>(
`/api/admin/system/configs/${key}`,
{ value, description }
{ value, description },
requestConfig,
)
return response.data
},

View File

@@ -4,6 +4,10 @@ import type { EndpointAPIKey, AllowedModels } from './types'
// Re-export types for convenience
export type { EndpointAPIKey, AllowedModels }
interface KeyRequestOptions {
timeout?: number
}
/**
* 能力定义类型
*/
@@ -179,9 +183,14 @@ export async function updateProviderKey(
model_include_patterns: string[] // 模型包含规则
model_exclude_patterns: string[] // 模型排除规则
proxy: import('./types').ProxyConfig | null // Key 级别代理配置
}>
}>,
requestOptions?: KeyRequestOptions,
): Promise<EndpointAPIKey> {
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
const response = await client.put(
`/api/admin/endpoints/keys/${keyId}`,
data,
requestOptions,
)
return response.data
}

View File

@@ -8,6 +8,10 @@ import type {
ProxyConfig,
} from './types'
interface ProviderRequestOptions {
timeout?: number
}
/**
* 获取 Providers 摘要(分页)
*/
@@ -73,9 +77,10 @@ export async function updateProvider(
claude_code_advanced: ClaudeCodeAdvancedConfig | null
pool_advanced: PoolAdvancedConfig | null
failover_rules: FailoverRulesConfig | null
}>
}>,
requestOptions?: ProviderRequestOptions,
): Promise<ProviderWithEndpointsSummary> {
const response = await client.patch(`/api/admin/providers/${providerId}`, data)
const response = await client.patch(`/api/admin/providers/${providerId}`, data, requestOptions)
return response.data
}

View File

@@ -92,6 +92,73 @@ export interface UsageFilters {
page_size?: number
}
function normalizeActivityHeatmapResponse(payload: unknown): ActivityHeatmap {
const today = new Date()
const endDate = today.toISOString().slice(0, 10)
const start = new Date(today)
start.setUTCDate(start.getUTCDate() - 364)
const startDate = start.toISOString().slice(0, 10)
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
const candidate = payload as Partial<ActivityHeatmap>
if (Array.isArray(candidate.days)) {
return {
start_date: typeof candidate.start_date === 'string' ? candidate.start_date : startDate,
end_date: typeof candidate.end_date === 'string' ? candidate.end_date : endDate,
total_days: typeof candidate.total_days === 'number' ? candidate.total_days : candidate.days.length,
max_requests: typeof candidate.max_requests === 'number' ? candidate.max_requests : 0,
days: candidate.days,
}
}
}
const grouped = new Map<string, { requests: number; total_tokens: number; total_cost: number; actual_total_cost?: number }>()
if (Array.isArray(payload)) {
for (const item of payload) {
if (!item || typeof item !== 'object') continue
const raw = item as Record<string, unknown>
const date = typeof raw.date === 'string' ? raw.date : ''
if (!date) continue
grouped.set(date, {
requests: typeof raw.requests === 'number'
? raw.requests
: typeof raw.request_count === 'number'
? raw.request_count
: 0,
total_tokens: typeof raw.total_tokens === 'number' ? raw.total_tokens : 0,
total_cost: typeof raw.total_cost === 'number' ? raw.total_cost : 0,
actual_total_cost: typeof raw.actual_total_cost === 'number' ? raw.actual_total_cost : undefined,
})
}
}
const days: ActivityHeatmap['days'] = []
let maxRequests = 0
const cursor = new Date(start)
while (cursor <= today) {
const date = cursor.toISOString().slice(0, 10)
const existing = grouped.get(date)
const requests = existing?.requests ?? 0
maxRequests = Math.max(maxRequests, requests)
days.push({
date,
requests,
total_tokens: existing?.total_tokens ?? 0,
total_cost: existing?.total_cost ?? 0,
actual_total_cost: existing?.actual_total_cost,
})
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return {
start_date: startDate,
end_date: endDate,
total_days: days.length,
max_requests: maxRequests,
days,
}
}
export const usageApi = {
async getUsageRecords(filters?: UsageFilters): Promise<{
records: UsageRecord[]
@@ -219,6 +286,7 @@ export const usageApi = {
first_byte_time_ms: number | null
provider?: string | null
api_key_name?: string | null
provider_key_name?: string | null
api_format?: string | null
endpoint_api_format?: string | null
has_format_conversion?: boolean | null
@@ -238,8 +306,8 @@ export const usageApi = {
return cachedRequest(
'admin-usage-activity-heatmap',
async () => {
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
return response.data
const response = await apiClient.get<ActivityHeatmap | unknown[]>('/api/admin/usage/heatmap')
return normalizeActivityHeatmapResponse(response.data)
},
60000
)

View File

@@ -538,6 +538,7 @@ const loadingKeys = ref(false)
const saving = ref(false)
const SAVE_CONCURRENCY = 6
const PRIORITY_REQUEST_TIMEOUT_MS = 5 * 60 * 1000
let originalProviderPriorityById = new Map<string, number>()
let originalPoolPriorityByProviderId = new Map<string, number | null>()
@@ -761,6 +762,11 @@ function snapshotKeyBaseline() {
)
}
function snapshotCurrentPriorityBaseline() {
snapshotProviderBaseline(sortedProviders.value)
snapshotKeyBaseline()
}
function arePriorityMapsEqual(
left: Record<string, number> | undefined,
right: Record<string, number> | undefined,
@@ -1035,7 +1041,9 @@ async function loadKeysByFormat() {
try {
loadingKeys.value = true
const { default: client } = await import('@/api/client')
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format', {
timeout: PRIORITY_REQUEST_TIMEOUT_MS,
})
// 每个格式独立管理优先级,额外做一次前端归一化兜底,避免历史数据导致重复格式/脏键
const data: Record<string, KeyWithMeta[]> = {}
@@ -1475,7 +1483,11 @@ async function save() {
}
if (Object.keys(payload).length > 0) {
providerTasks.push(() => updateProvider(provider.id, payload))
providerTasks.push(() => updateProvider(
provider.id,
payload,
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
))
}
})
@@ -1487,7 +1499,11 @@ async function save() {
priorityByFormat,
))
.map(([keyId, priorityByFormat]) => () =>
updateProviderKey(keyId, { global_priority_by_format: priorityByFormat })
updateProviderKey(
keyId,
{ global_priority_by_format: priorityByFormat },
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
)
)
await runTasksWithConcurrency([...providerTasks, ...keyTasks])
@@ -1497,16 +1513,16 @@ async function save() {
await adminApi.updateSystemConfig(
'provider_priority_mode',
newMode,
'Provider/Key 优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)'
'Provider/Key 优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)',
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
)
await adminApi.updateSystemConfig(
'scheduling_mode',
schedulingMode.value,
'调度模式cache_affinity(缓存亲和模式) 或 load_balance(负载均衡模式) 或 fixed_order(固定顺序模式)'
'调度模式cache_affinity(缓存亲和模式) 或 load_balance(负载均衡模式) 或 fixed_order(固定顺序模式)',
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
)
await loadAllProviders()
await loadKeysByFormat()
snapshotCurrentPriorityBaseline()
success('优先级已保存')
emit('saved')

View File

@@ -264,9 +264,9 @@
:response-time-ms="record.response_time_ms ?? null"
/></span>
<span
v-else-if="record.response_time_ms != null"
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="tabular-nums"
>{{ record.first_byte_time_ms != null ? (record.first_byte_time_ms / 1000).toFixed(1) + '/' : '' }}{{ (record.response_time_ms / 1000).toFixed(1) }}s</span>
>{{ record.first_byte_time_ms != null ? (record.first_byte_time_ms / 1000).toFixed(1) + '/' : '' }}{{ record.response_time_ms != null ? (record.response_time_ms / 1000).toFixed(1) : '-' }}{{ record.response_time_ms != null ? 's' : '' }}</span>
<span
v-else
class="tabular-nums"
@@ -419,11 +419,11 @@
<div class="flex flex-col text-xs gap-0.5">
<span>{{ record.provider }}</span>
<span
v-if="record.api_key_name"
v-if="record.provider_key_name || record.api_key_name"
class="text-muted-foreground truncate"
:title="record.api_key_name"
:title="record.provider_key_name || record.api_key_name"
>
{{ record.api_key_name }}
{{ record.provider_key_name || record.api_key_name }}
<span
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-foreground/60"
@@ -608,7 +608,7 @@
</div>
<!-- 已完成状态首字 + 总耗时 -->
<div
v-else-if="record.response_time_ms != null"
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="flex flex-col items-end text-xs gap-0.5"
>
<span
@@ -619,7 +619,14 @@
v-else
class="text-muted-foreground"
>-</span>
<span class="text-muted-foreground tabular-nums">{{ (record.response_time_ms / 1000).toFixed(2) }}s</span>
<span
v-if="record.response_time_ms != null"
class="text-muted-foreground tabular-nums"
>{{ (record.response_time_ms / 1000).toFixed(2) }}s</span>
<span
v-else
class="text-muted-foreground"
>-</span>
</div>
<span
v-else

View File

@@ -395,6 +395,7 @@ export function useUsageData(options: UseUsageDataOptions) {
endpoint_api_format: existing.endpoint_api_format || record.endpoint_api_format,
has_format_conversion: existing.has_format_conversion ?? record.has_format_conversion,
api_key_name: existing.api_key_name || record.api_key_name,
provider_key_name: existing.provider_key_name || record.provider_key_name,
rate_multiplier: existing.rate_multiplier ?? record.rate_multiplier,
target_model: existing.target_model || record.target_model
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { parseResponse, renderResponse } from '../registry'
describe('Conversation stream compatibility', () => {
it('parses raw OpenAI chat SSE text from stored usage records', () => {
const requestBody = {
model: 'gpt-5.4',
stream: true,
messages: [
{ role: 'user', content: 'Hello' },
],
}
const rawSse = [
'data: {"id":"chatcmpl_123","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}',
'',
'data: {"id":"chatcmpl_123","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"Hello from stream"},"finish_reason":null}]}',
'',
'data: {"id":"chatcmpl_123","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
'',
'data: [DONE]',
'',
].join('\n')
const parsed = parseResponse(rawSse, requestBody, 'openai:chat')
expect(parsed.apiFormat).toBe('openai')
expect(parsed.isStream).toBe(true)
expect(parsed.messages).toHaveLength(1)
expect(parsed.messages[0]?.content[0]).toMatchObject({
type: 'text',
text: 'Hello from stream',
})
})
it('renders raw OpenAI CLI SSE text from stored usage records', () => {
const requestBody = {
model: 'gpt-5.4',
stream: true,
input: 'Hello',
}
const rawSse = [
'event: response.created',
'data: {"type":"response.created","response":{"id":"resp_123","object":"response","model":"gpt-5.4","status":"in_progress"}}',
'',
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","delta":"Hello from CLI stream"}',
'',
'event: response.completed',
'data: {"type":"response.completed","response":{"id":"resp_123","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello from CLI stream"}]}]}}',
'',
'data: [DONE]',
'',
].join('\n')
const rendered = renderResponse(rawSse, requestBody, 'openai:cli')
expect(rendered.error).toBeUndefined()
expect(rendered.isStream).toBe(true)
expect(rendered.blocks).toHaveLength(1)
expect(rendered.blocks[0]).toMatchObject({
type: 'message',
role: 'assistant',
})
const firstBlock = rendered.blocks[0]
if (!firstBlock || firstBlock.type !== 'message') {
throw new Error('expected first render block to be message')
}
expect(firstBlock.content[0]).toMatchObject({
type: 'text',
content: 'Hello from CLI stream',
})
})
})

View File

@@ -2,7 +2,7 @@
* 解析器注册表和统一入口
*/
import type { ApiFormat, ApiFormatParser, ParsedConversation } from './types'
import type { ApiFormat, ApiFormatParser, ParsedConversation, StreamResponseBody } from './types'
import { createEmptyConversation, isStreamResponse } from './types'
import type { RenderResult } from './render'
import { createEmptyRenderResult } from './render'
@@ -10,6 +10,67 @@ import { claudeParser } from './claude'
import { openaiParser } from './openai'
import { geminiParser } from './gemini'
function parseRawSseResponse(responseBody: unknown): StreamResponseBody | null {
if (typeof responseBody !== 'string') {
return null
}
const text = responseBody.trim()
if (!text.includes('data:')) {
return null
}
const chunks: unknown[] = []
const eventBlocks = text.split(/\r?\n\r?\n/)
for (const block of eventBlocks) {
if (!block.trim()) {
continue
}
const dataLines = block
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line.startsWith('data:'))
.map(line => line.slice(5).trim())
.filter(Boolean)
if (dataLines.length === 0) {
continue
}
const payload = dataLines.join('\n')
if (payload === '[DONE]') {
continue
}
try {
chunks.push(JSON.parse(payload))
} catch {
// 保持原始数据不变,交给现有解析器兜底
}
}
if (chunks.length === 0) {
return null
}
return {
metadata: {
stream: true,
},
chunks,
}
}
function normalizeResponseBody(responseBody: unknown): unknown {
if (isStreamResponse(responseBody)) {
return responseBody
}
return parseRawSseResponse(responseBody) ?? responseBody
}
/**
* 解析器注册表
*/
@@ -84,7 +145,11 @@ export function parseRequest(
return createEmptyConversation('unknown', '无请求体')
}
const parser = parserRegistry.detectParser(requestBody, responseBody, formatHint)
const parser = parserRegistry.detectParser(
requestBody,
normalizeResponseBody(responseBody),
formatHint
)
if (!parser) {
return createEmptyConversation('unknown', '无法识别的 API 格式')
}
@@ -104,17 +169,18 @@ export function parseResponse(
return createEmptyConversation('unknown', '无响应体')
}
const parser = parserRegistry.detectParser(requestBody, responseBody, formatHint)
const normalizedResponseBody = normalizeResponseBody(responseBody)
const parser = parserRegistry.detectParser(requestBody, normalizedResponseBody, formatHint)
if (!parser) {
return createEmptyConversation('unknown', '无法识别的 API 格式')
}
// 判断是否为流式响应
if (isStreamResponse(responseBody)) {
return parser.parseStreamResponse(responseBody.chunks || [])
if (isStreamResponse(normalizedResponseBody)) {
return parser.parseStreamResponse(normalizedResponseBody.chunks || [])
}
return parser.parseResponse(responseBody)
return parser.parseResponse(normalizedResponseBody)
}
/**
@@ -125,7 +191,7 @@ export function detectApiFormat(
responseBody: unknown,
hint?: string
): ApiFormat {
return parserRegistry.detectFormat(requestBody, responseBody, hint)
return parserRegistry.detectFormat(requestBody, normalizeResponseBody(responseBody), hint)
}
/**
@@ -140,7 +206,11 @@ export function renderRequest(
return createEmptyRenderResult('无请求体')
}
const parser = parserRegistry.detectParser(requestBody, responseBody, formatHint)
const parser = parserRegistry.detectParser(
requestBody,
normalizeResponseBody(responseBody),
formatHint
)
if (!parser) {
return createEmptyRenderResult('无法识别的 API 格式')
}
@@ -160,10 +230,11 @@ export function renderResponse(
return createEmptyRenderResult('无响应体')
}
const parser = parserRegistry.detectParser(requestBody, responseBody, formatHint)
const normalizedResponseBody = normalizeResponseBody(responseBody)
const parser = parserRegistry.detectParser(requestBody, normalizedResponseBody, formatHint)
if (!parser) {
return createEmptyRenderResult('无法识别的 API 格式')
}
return parser.renderResponse(responseBody)
return parser.renderResponse(normalizedResponseBody)
}

View File

@@ -83,6 +83,7 @@ export interface UsageRecord {
} | null
provider?: string // 仅管理员可见
api_key_name?: string
provider_key_name?: string | null
rate_multiplier?: number
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)

View File

@@ -781,41 +781,46 @@ onMounted(async () => {
await refreshApiKeys()
})
async function loadApiKeys() {
loading.value = true
try {
const response = await adminApi.getAllApiKeys({
skip: skip.value,
limit: limit.value
})
apiKeys.value = response.api_keys
total.value = response.total
} catch (err: unknown) {
log.error('加载独立Keys失败:', err)
error(parseApiError(err, '加载独立 Keys 失败'))
} finally {
loading.value = false
}
async function fetchApiKeyWalletMap(): Promise<Record<string, AdminWallet>> {
const wallets = await adminWalletApi.listAllWallets({ owner_type: 'api_key' })
return wallets
.filter((wallet) => !!wallet.api_key_id)
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
acc[wallet.api_key_id as string] = wallet
return acc
}, {})
}
async function loadApiKeyWallets() {
try {
const wallets = await adminWalletApi.listAllWallets()
apiKeyWalletMap.value = wallets
.filter((wallet) => wallet.owner_type === 'api_key' && !!wallet.api_key_id)
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
acc[wallet.api_key_id as string] = wallet
return acc
}, {})
apiKeyWalletMap.value = await fetchApiKeyWalletMap()
} catch (err: unknown) {
log.error('加载独立 Key 钱包失败:', err)
}
}
async function refreshApiKeys() {
// 先拉取 Key 列表,再拉钱包,避免并发请求导致新钱包映射短暂缺失。
await loadApiKeys()
await loadApiKeyWallets()
loading.value = true
try {
const [response, walletMap] = await Promise.all([
adminApi.getAllApiKeys({
skip: skip.value,
limit: limit.value
}),
fetchApiKeyWalletMap().catch((err: unknown) => {
log.error('加载独立 Key 钱包失败:', err)
return apiKeyWalletMap.value
})
])
apiKeys.value = response.api_keys
total.value = response.total
apiKeyWalletMap.value = walletMap
} catch (err: unknown) {
log.error('加载独立Keys失败:', err)
error(parseApiError(err, '加载独立 Keys 失败'))
} finally {
loading.value = false
}
}
function handlePageChange(page: number) {

View File

@@ -216,7 +216,7 @@
<!-- 移动端卡片列表 -->
<div
v-if="!loading && filteredGlobalModels.length > 0"
v-if="!loading && paginatedGlobalModels.length > 0"
class="xl:hidden divide-y divide-border/40"
>
<div
@@ -294,9 +294,9 @@
<!-- 分页 -->
<Pagination
v-if="!loading && filteredGlobalModels.length > 0"
v-if="!loading && totalGlobalModels > 0"
:current="catalogCurrentPage"
:total="filteredGlobalModels.length"
:total="totalGlobalModels"
:page-size="catalogPageSize"
cache-key="model-management-page-size"
@update:current="catalogCurrentPage = $event"
@@ -508,7 +508,14 @@
<!-- 模型列表 -->
<div class="border rounded-lg overflow-hidden">
<div class="max-h-96 overflow-y-auto">
<template v-if="filteredBatchManageModels.length > 0">
<div
v-if="batchManageLoading"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<template v-else-if="filteredBatchManageModels.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10"
>
@@ -569,7 +576,7 @@
<!-- 空状态 -->
<div
v-if="filteredBatchManageModels.length === 0"
v-else
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<p class="text-sm">
@@ -611,7 +618,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onBeforeUnmount, watch } from 'vue'
import {
Plus,
Edit,
@@ -701,12 +708,14 @@ const editingModel = ref<GlobalModelResponse | null>(null)
// 数据
const globalModels = ref<GlobalModelResponse[]>([])
const providers = ref<ProviderWithEndpointsSummary[]>([])
const GLOBAL_MODELS_FETCH_PAGE_SIZE = 1000
const totalGlobalModels = ref(0)
const batchManageModels = ref<GlobalModelResponse[]>([])
const batchManageLoading = ref(false)
const GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE = 1000
let globalModelsRequestId = 0
let modelSelectionRequestId = 0
let modelProvidersRequestId = 0
let providersRequestId = 0
let batchManageModelsRequestId = 0
let providerOptionsRequest: Promise<void> | null = null
// 模型目录分页
@@ -988,15 +997,6 @@ async function saveBatchProviderChanges() {
const filteredGlobalModels = computed(() => {
let result = globalModels.value
// 搜索(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) {
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result = result.filter(m => {
const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
// 能力筛选
if (capabilityFilters.value.streaming) {
result = result.filter(m => m.config?.streaming !== false)
@@ -1018,20 +1018,55 @@ const filteredGlobalModels = computed(() => {
})
// 模型目录分页计算
const paginatedGlobalModels = computed(() => {
const start = (catalogCurrentPage.value - 1) * catalogPageSize.value
const end = start + catalogPageSize.value
return filteredGlobalModels.value.slice(start, end)
const paginatedGlobalModels = computed(() => filteredGlobalModels.value)
watch(searchQuery, () => {
catalogCurrentPage.value = 1
})
// 搜索或筛选变化时重置到第一页
watch([searchQuery, capabilityFilters], () => {
watch(catalogPageSize, () => {
catalogCurrentPage.value = 1
}, { deep: true })
})
const globalModelsQueryParams = computed(() => ({
skip: Math.max(0, (catalogCurrentPage.value - 1) * catalogPageSize.value),
limit: catalogPageSize.value,
search: searchQuery.value.trim() || undefined,
}))
let modelSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
async function loadGlobalModels() {
const requestId = ++globalModelsRequestId
loading.value = true
try {
const response = await listGlobalModels(globalModelsQueryParams.value)
if (requestId !== globalModelsRequestId) return
const pageModels = response.models || []
const total = typeof response.total === 'number' ? response.total : pageModels.length
const totalPages = Math.max(1, Math.ceil(total / Math.max(catalogPageSize.value, 1)))
if (total > 0 && catalogCurrentPage.value > totalPages) {
catalogCurrentPage.value = totalPages
return
}
globalModels.value = pageModels
totalGlobalModels.value = total
} catch (err: unknown) {
if (requestId !== globalModelsRequestId) return
log.error('加载模型失败:', err)
showError(parseApiError(err, '加载模型失败'), '加载模型失败')
} finally {
if (requestId === globalModelsRequestId) {
loading.value = false
}
}
}
async function loadBatchManageModels() {
const requestId = ++batchManageModelsRequestId
batchManageLoading.value = true
try {
const allModels: GlobalModelResponse[] = []
let skip = 0
@@ -1040,7 +1075,7 @@ async function loadGlobalModels() {
while (true) {
const response = await listGlobalModels({
skip,
limit: GLOBAL_MODELS_FETCH_PAGE_SIZE,
limit: GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE,
})
if (expectedTotal === null && typeof response.total === 'number') {
expectedTotal = response.total
@@ -1051,22 +1086,22 @@ async function loadGlobalModels() {
if (expectedTotal !== null && allModels.length >= expectedTotal) {
break
}
if (pageModels.length < GLOBAL_MODELS_FETCH_PAGE_SIZE) {
if (pageModels.length < GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE) {
break
}
skip += pageModels.length
}
if (requestId !== globalModelsRequestId) return
globalModels.value = allModels
if (requestId !== batchManageModelsRequestId) return
batchManageModels.value = allModels
} catch (err: unknown) {
if (requestId !== globalModelsRequestId) return
log.error('加载模型失败:', err)
if (requestId !== batchManageModelsRequestId) return
log.error('加载批量管理模型失败:', err)
showError(parseApiError(err, '加载模型失败'), '加载模型失败')
} finally {
if (requestId === globalModelsRequestId) {
loading.value = false
if (requestId === batchManageModelsRequestId) {
batchManageLoading.value = false
}
}
}
@@ -1246,8 +1281,8 @@ function closeBatchAddProvidersDialog() {
// 批量管理全局模型 - 过滤
const filteredBatchManageModels = computed(() => {
const query = batchManageSearchQuery.value.toLowerCase().trim()
if (!query) return globalModels.value
return globalModels.value.filter(m => {
if (!query) return batchManageModels.value
return batchManageModels.value.filter(m => {
const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
return searchableText.includes(query)
})
@@ -1260,7 +1295,7 @@ function hasNoPrice(m: GlobalModelResponse): boolean {
}
const batchManageShortcuts = computed(() => {
const models = globalModels.value
const models = batchManageModels.value
const defs: { label: string; description: string; filter: (m: GlobalModelResponse) => boolean }[] = [
{ label: '无提供商', description: '没有关联任何提供商的模型', filter: m => (m.provider_count || 0) === 0 },
{ label: '无活跃提供商', description: '有提供商但没有活跃提供商的模型', filter: m => (m.active_provider_count || 0) === 0 && (m.provider_count || 0) > 0 },
@@ -1273,7 +1308,7 @@ const batchManageShortcuts = computed(() => {
// 批量管理 - 应用快捷选中
function applyBatchManageShortcut(filter: (m: GlobalModelResponse) => boolean) {
const matchedIds = globalModels.value.filter(filter).map(m => m.id)
const matchedIds = batchManageModels.value.filter(filter).map(m => m.id)
selectedBatchManageModelIds.value = new Set(matchedIds)
}
@@ -1313,6 +1348,7 @@ function openBatchManageDialog() {
batchManageSearchQuery.value = ''
selectedBatchManageModelIds.value = new Set()
batchManageDialogOpen.value = true
loadBatchManageModels()
}
// 确认批量删除模型
@@ -1344,7 +1380,7 @@ async function confirmBatchDeleteModels() {
}
selectedBatchManageModelIds.value = new Set()
await loadGlobalModels()
await Promise.all([loadGlobalModels(), loadBatchManageModels()])
} catch (err: unknown) {
showError(parseApiError(err, '批量删除失败'), '错误')
} finally {
@@ -1491,30 +1527,24 @@ async function refreshData() {
await loadGlobalModels()
}
async function loadProviders() {
const requestId = ++providersRequestId
try {
const nextProviders = (await getProvidersSummary({ page_size: 9999 })).items
if (requestId !== providersRequestId) return
providers.value = nextProviders
} catch (err: unknown) {
if (requestId !== providersRequestId) return
showError(parseApiError(err, '加载 Provider 列表失败'), '加载 Provider 列表失败')
watch(globalModelsQueryParams, (newParams, oldParams) => {
if (modelSearchDebounceTimer) clearTimeout(modelSearchDebounceTimer)
const isSearchOnly = newParams.search !== oldParams?.search
&& newParams.skip === oldParams?.skip
&& newParams.limit === oldParams?.limit
if (isSearchOnly) {
modelSearchDebounceTimer = setTimeout(loadGlobalModels, 300)
} else {
loadGlobalModels()
}
}
onMounted(async () => {
await Promise.all([
refreshData(),
loadProviders(),
])
})
}, { immediate: true })
onBeforeUnmount(() => {
if (modelSearchDebounceTimer) clearTimeout(modelSearchDebounceTimer)
globalModelsRequestId += 1
batchManageModelsRequestId += 1
modelSelectionRequestId += 1
modelProvidersRequestId += 1
providersRequestId += 1
})
</script>

View File

@@ -416,6 +416,11 @@ async function pollActiveRequests() {
if ('api_key_name' in update) {
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
}
if ('provider_key_name' in update) {
record.provider_key_name = typeof update.provider_key_name === 'string'
? update.provider_key_name
: undefined
}
}
}