mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(pool,scheduling): 号池调度维度、配额冷却机制与管理后台重构
- 新增 scheduling_dimensions 模块,为每个 Key 计算多维调度状态(手动/冷却/熔断/成本/健康) - 新增 quota_cooldown 模块,统一判定 Key 的有效冷却原因 - Pool 管理后台 API 扩展 Key 详情字段(调度状态/维度/配额/OAuth 信息) - 前端 Pool 管理页面重写,支持调度状态展示、批量清理封禁 Key - Handler 基类增加请求调度元数据采集,stream telemetry 增强 - 请求时间线组件增强,支持 attempted 候选展示 - Kiro OAuth 凭证导入解析改进 - 新增 usage 表 provider_key 索引迁移 - 补充调度维度、配额冷却、候选枚举等单元测试 Closes #197 Co-authored-by: AAEE86 <33052466+AAEE86@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import client from '../client'
|
||||
import type { AllowedModels, ProxyConfig } from './types/provider'
|
||||
|
||||
export interface PoolKeyStatus {
|
||||
key_id: string
|
||||
@@ -78,15 +79,80 @@ export interface PoolKeyDetail {
|
||||
key_name: string
|
||||
is_active: boolean
|
||||
auth_type: string
|
||||
oauth_expires_at?: number | null
|
||||
oauth_invalid_at?: number | null
|
||||
oauth_invalid_reason?: string | null
|
||||
oauth_plan_type?: string | null
|
||||
quota_updated_at?: number | null
|
||||
health_score?: number
|
||||
circuit_breaker_open?: boolean
|
||||
api_formats?: string[]
|
||||
rate_multipliers?: Record<string, number> | null
|
||||
internal_priority?: number
|
||||
rpm_limit?: number | null
|
||||
cache_ttl_minutes?: number
|
||||
max_probe_interval_minutes?: number
|
||||
note?: string | null
|
||||
allowed_models?: AllowedModels
|
||||
capabilities?: Record<string, boolean> | null
|
||||
auto_fetch_models?: boolean
|
||||
locked_models?: string[] | null
|
||||
model_include_patterns?: string[] | null
|
||||
model_exclude_patterns?: string[] | null
|
||||
proxy?: ProxyConfig | null
|
||||
account_quota: string | null
|
||||
cooldown_reason: string | null
|
||||
cooldown_ttl_seconds: number | null
|
||||
cost_window_usage: number
|
||||
cost_limit: number | null
|
||||
request_count: number
|
||||
total_tokens: number
|
||||
total_cost_usd: number
|
||||
sticky_sessions: number
|
||||
lru_score: number | null
|
||||
created_at: string | null
|
||||
last_used_at: string | null
|
||||
scheduling_status?: 'available' | 'degraded' | 'blocked'
|
||||
scheduling_reason?:
|
||||
| 'available'
|
||||
| 'manual_disabled'
|
||||
| 'cooldown'
|
||||
| 'circuit_open'
|
||||
| 'cost_exhausted'
|
||||
| 'cost_soft'
|
||||
| 'cost'
|
||||
| 'health_low'
|
||||
| 'health_degraded'
|
||||
| 'health'
|
||||
| string
|
||||
scheduling_label?: string
|
||||
scheduling_reasons?: PoolSchedulingReason[]
|
||||
scheduling_score?: number
|
||||
candidate_eligible?: boolean
|
||||
scheduling_blocked_count?: number
|
||||
scheduling_degraded_count?: number
|
||||
scheduling_dimensions?: PoolSchedulingDimension[]
|
||||
}
|
||||
|
||||
export interface PoolSchedulingReason {
|
||||
code: string
|
||||
label: string
|
||||
blocking: boolean
|
||||
source: 'manual' | 'pool' | 'health' | 'policy' | string
|
||||
ttl_seconds?: number | null
|
||||
detail?: string | null
|
||||
}
|
||||
|
||||
export interface PoolSchedulingDimension {
|
||||
code: string
|
||||
label: string
|
||||
status: 'ok' | 'degraded' | 'blocked' | string
|
||||
blocking: boolean
|
||||
source: 'manual' | 'pool' | 'health' | 'policy' | string
|
||||
weight: number
|
||||
score: number
|
||||
ttl_seconds?: number | null
|
||||
detail?: string | null
|
||||
}
|
||||
|
||||
export interface PoolKeysPageResponse {
|
||||
@@ -103,18 +169,6 @@ export interface PoolKeysQuery {
|
||||
status?: 'all' | 'active' | 'cooldown' | 'inactive'
|
||||
}
|
||||
|
||||
export interface PoolKeyImportItem {
|
||||
name: string
|
||||
api_key: string
|
||||
auth_type?: string
|
||||
}
|
||||
|
||||
export interface BatchImportResponse {
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: { index: number; reason: string }[]
|
||||
}
|
||||
|
||||
export interface PoolBatchAction {
|
||||
key_ids: string[]
|
||||
action: 'enable' | 'disable' | 'delete' | 'clear_cooldown' | 'reset_cost'
|
||||
@@ -133,14 +187,6 @@ export async function listPoolKeys(
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function batchImportPoolKeys(
|
||||
providerId: string,
|
||||
keys: PoolKeyImportItem[],
|
||||
): Promise<BatchImportResponse> {
|
||||
const response = await client.post(`/api/admin/pool/${providerId}/keys/batch-import`, { keys })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function batchActionPoolKeys(
|
||||
providerId: string,
|
||||
body: PoolBatchAction,
|
||||
@@ -148,3 +194,10 @@ export async function batchActionPoolKeys(
|
||||
const response = await client.post(`/api/admin/pool/${providerId}/keys/batch-action`, body)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function cleanupBannedPoolKeys(
|
||||
providerId: string,
|
||||
): Promise<{ affected: number; message: string }> {
|
||||
const response = await client.post(`/api/admin/pool/${providerId}/keys/cleanup-banned`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
281
frontend/src/components/common/JsonImportInput.vue
Normal file
281
frontend/src/components/common/JsonImportInput.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="!showManualInput"
|
||||
class="rounded-xl border-2 border-dashed transition-colors cursor-pointer"
|
||||
:class="isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/40'"
|
||||
@click="fileInputRef?.click()"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleFileDrop"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center py-10 gap-2">
|
||||
<div class="w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center">
|
||||
<Upload class="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-medium">
|
||||
{{ dropTitle }}
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground mt-0.5">
|
||||
{{ dropHint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label v-if="manualLabel">
|
||||
{{ manualLabel }}
|
||||
</Label>
|
||||
<Textarea
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:placeholder="manualPlaceholder"
|
||||
:class="textareaClass"
|
||||
spellcheck="false"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<p
|
||||
v-if="manualDescription"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ manualDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center pt-1">
|
||||
<button
|
||||
v-if="!showManualInput"
|
||||
type="button"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="showManualInput = true"
|
||||
>
|
||||
{{ pasteToggleText }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="switchToFileMode"
|
||||
>
|
||||
{{ fileToggleText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Upload } from 'lucide-vue-next'
|
||||
import { Label, Textarea } from '@/components/ui'
|
||||
|
||||
interface ImportInputErrorPayload {
|
||||
message: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
disabled?: boolean
|
||||
resetKey?: string | number
|
||||
accept?: string
|
||||
dropTitle?: string
|
||||
dropHint?: string
|
||||
manualLabel?: string
|
||||
manualPlaceholder?: string
|
||||
manualDescription?: string
|
||||
pasteToggleText?: string
|
||||
fileToggleText?: string
|
||||
textareaClass?: string
|
||||
}>(), {
|
||||
disabled: false,
|
||||
resetKey: '',
|
||||
accept: '.json,.txt',
|
||||
dropTitle: '拖入导入文件或点击选择',
|
||||
dropHint: '支持 .json / .txt,可多选',
|
||||
manualLabel: '',
|
||||
manualPlaceholder: '',
|
||||
manualDescription: '',
|
||||
pasteToggleText: '或手动粘贴 JSON',
|
||||
fileToggleText: '或选择 JSON 文件导入',
|
||||
textareaClass: 'min-h-[220px] text-xs font-mono break-all !rounded-xl',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
error: [payload: ImportInputErrorPayload]
|
||||
}>()
|
||||
|
||||
const showManualInput = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const acceptParts = computed(() => {
|
||||
return props.accept
|
||||
.split(',')
|
||||
.map(part => part.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const acceptedExtensions = computed(() => acceptParts.value.filter(part => part.startsWith('.')))
|
||||
const acceptedMimeTypes = computed(() => acceptParts.value.filter(part => part.includes('/')))
|
||||
|
||||
function resetUiState() {
|
||||
showManualInput.value = false
|
||||
isDragging.value = false
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function emitError(message: string, title?: string) {
|
||||
emit('error', { message, title })
|
||||
}
|
||||
|
||||
function isValidFileType(file: File): boolean {
|
||||
const name = file.name.toLowerCase()
|
||||
const type = (file.type || '').toLowerCase()
|
||||
|
||||
const extensionAllowed = acceptedExtensions.value.length > 0
|
||||
&& acceptedExtensions.value.some(ext => name.endsWith(ext))
|
||||
|
||||
const mimeAllowed = acceptedMimeTypes.value.length > 0
|
||||
&& acceptedMimeTypes.value.some((mimeType) => {
|
||||
if (mimeType.endsWith('/*')) {
|
||||
const prefix = mimeType.slice(0, -1)
|
||||
return type.startsWith(prefix)
|
||||
}
|
||||
return type === mimeType
|
||||
})
|
||||
|
||||
if (acceptedExtensions.value.length === 0 && acceptedMimeTypes.value.length === 0) {
|
||||
return true
|
||||
}
|
||||
return extensionAllowed || mimeAllowed
|
||||
}
|
||||
|
||||
function readFileAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result
|
||||
if (typeof content === 'string') {
|
||||
resolve(content)
|
||||
return
|
||||
}
|
||||
reject(new Error('读取失败'))
|
||||
}
|
||||
reader.onerror = () => reject(new Error('读取失败'))
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
function mergeFileContents(contents: string[]): string {
|
||||
const items: unknown[] = []
|
||||
|
||||
for (const raw of contents) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (Array.isArray(parsed)) {
|
||||
items.push(...parsed)
|
||||
} else {
|
||||
items.push(parsed)
|
||||
}
|
||||
continue
|
||||
} catch {
|
||||
// Fallback to line mode.
|
||||
}
|
||||
|
||||
const lines = trimmed
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'))
|
||||
items.push(...lines)
|
||||
}
|
||||
|
||||
if (items.length === 1) {
|
||||
return typeof items[0] === 'string' ? items[0] : JSON.stringify(items[0], null, 2)
|
||||
}
|
||||
return JSON.stringify(items, null, 2)
|
||||
}
|
||||
|
||||
async function readFiles(files: File[]) {
|
||||
const validFiles = files.filter(isValidFileType)
|
||||
if (validFiles.length === 0) {
|
||||
emitError('仅支持 .json 或 .txt 文件', '格式错误')
|
||||
return
|
||||
}
|
||||
if (validFiles.length < files.length) {
|
||||
emitError(`已忽略 ${files.length - validFiles.length} 个不支持的文件`, '提示')
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = await Promise.all(validFiles.map(readFileAsText))
|
||||
const merged = validFiles.length === 1 ? contents[0] : mergeFileContents(contents)
|
||||
emit('update:modelValue', merged)
|
||||
showManualInput.value = true
|
||||
} catch {
|
||||
emitError('文件读取失败', '错误')
|
||||
} finally {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
void readFiles(Array.from(files))
|
||||
}
|
||||
|
||||
function handleFileDrop(event: DragEvent) {
|
||||
isDragging.value = false
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files || files.length === 0) return
|
||||
void readFiles(Array.from(files))
|
||||
}
|
||||
|
||||
function switchToFileMode() {
|
||||
showManualInput.value = false
|
||||
emit('update:modelValue', '')
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
resetUiState()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (value.trim() && !showManualInput.value) {
|
||||
showManualInput.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -1,156 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
title="批量导入账号"
|
||||
description="以 JSON 格式批量导入 API Key 到号池"
|
||||
size="lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>JSON 数据</Label>
|
||||
<textarea
|
||||
v-model="jsonText"
|
||||
class="w-full h-48 p-3 text-sm font-mono border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="[{"name": "key-01", "api_key": "sk-xxx"}, ...]"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
格式: [{"name": "名称", "api_key": "密钥", "auth_type": "api_key"}],auth_type 可选
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="parseError"
|
||||
class="text-sm text-destructive"
|
||||
>
|
||||
{{ parseError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="parsedCount > 0"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
已解析 {{ parsedCount }} 个账号
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="importResult"
|
||||
class="space-y-1 text-sm"
|
||||
>
|
||||
<p class="text-green-600">
|
||||
成功导入: {{ importResult.imported }}
|
||||
</p>
|
||||
<p
|
||||
v-if="importResult.errors.length > 0"
|
||||
class="text-destructive"
|
||||
>
|
||||
失败: {{ importResult.errors.length }}
|
||||
</p>
|
||||
<div
|
||||
v-for="err in importResult.errors.slice(0, 5)"
|
||||
:key="err.index"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
#{{ err.index }}: {{ err.reason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
{{ importResult ? '关闭' : '取消' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!importResult"
|
||||
:disabled="loading || parsedCount === 0"
|
||||
@click="handleImport"
|
||||
>
|
||||
{{ loading ? '导入中...' : `导入 ${parsedCount} 个账号` }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Dialog, Button, Label } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { batchImportPoolKeys } from '@/api/endpoints/pool'
|
||||
import type { BatchImportResponse, PoolKeyImportItem } from '@/api/endpoints/pool'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
providerId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
imported: []
|
||||
}>()
|
||||
|
||||
const { error: showError } = useToast()
|
||||
const jsonText = ref('')
|
||||
const loading = ref(false)
|
||||
const parseError = ref('')
|
||||
const importResult = ref<BatchImportResponse | null>(null)
|
||||
|
||||
const parsedKeys = computed<PoolKeyImportItem[]>(() => {
|
||||
if (!jsonText.value.trim()) return []
|
||||
try {
|
||||
const data = JSON.parse(jsonText.value)
|
||||
if (!Array.isArray(data)) {
|
||||
return []
|
||||
}
|
||||
return data.map((item: Record<string, unknown>) => ({
|
||||
name: String(item.name || ''),
|
||||
api_key: String(item.api_key || ''),
|
||||
auth_type: String(item.auth_type || 'api_key'),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
watch(jsonText, (val) => {
|
||||
if (!val.trim()) {
|
||||
parseError.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(val)
|
||||
parseError.value = Array.isArray(data) ? '' : 'JSON 必须是数组格式'
|
||||
} catch {
|
||||
parseError.value = 'JSON 格式无效'
|
||||
}
|
||||
})
|
||||
|
||||
const parsedCount = computed(() => parsedKeys.value.length)
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) {
|
||||
jsonText.value = ''
|
||||
importResult.value = null
|
||||
parseError.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
async function handleImport() {
|
||||
if (!parsedKeys.value.length) return
|
||||
loading.value = true
|
||||
try {
|
||||
importResult.value = await batchImportPoolKeys(props.providerId, parsedKeys.value)
|
||||
if (importResult.value.imported > 0) {
|
||||
emit('imported')
|
||||
}
|
||||
} catch (err) {
|
||||
showError(parseApiError(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -404,70 +404,19 @@
|
||||
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
|
||||
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".json,.txt"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
|
||||
<!-- 拖拽模式 -->
|
||||
<div
|
||||
v-if="!showManualInput"
|
||||
class="rounded-xl border-2 border-dashed transition-colors cursor-pointer"
|
||||
:class="isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/40'"
|
||||
@click="fileInputRef?.click()"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleFileDrop"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<div class="w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center">
|
||||
<Upload class="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-medium">
|
||||
拖入授权文件或点击选择
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground mt-0.5">
|
||||
支持 .json / .txt,可多选
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 粘贴模式 -->
|
||||
<Textarea
|
||||
v-else
|
||||
<JsonImportInput
|
||||
v-model="importText"
|
||||
:disabled="importing"
|
||||
placeholder="粘贴 Refresh Token 或 JSON 内容"
|
||||
class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
|
||||
spellcheck="false"
|
||||
:reset-key="importInputResetKey"
|
||||
drop-title="拖入授权文件或点击选择"
|
||||
drop-hint="支持 .json / .txt,可多选"
|
||||
manual-placeholder="粘贴 Refresh Token 或 JSON 内容"
|
||||
paste-toggle-text="或手动粘贴 Refresh Token"
|
||||
file-toggle-text="或选择 JSON 文件导入"
|
||||
textarea-class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
|
||||
@error="handleImportInputError"
|
||||
/>
|
||||
|
||||
<!-- 底部切换链接 -->
|
||||
<div class="flex items-center justify-center pt-1">
|
||||
<button
|
||||
v-if="!showManualInput"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="showManualInput = true"
|
||||
>
|
||||
或手动粘贴 Refresh Token
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="showManualInput = false; importText = ''"
|
||||
>
|
||||
或选择 JSON 文件导入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="importTask"
|
||||
class="rounded-xl border border-border bg-muted/20 p-3 space-y-2"
|
||||
@@ -554,7 +503,7 @@ import {
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport,
|
||||
} from 'radix-vue'
|
||||
import { UserPlus, Copy, ExternalLink, Upload, Globe, AlertCircle, ShieldCheck, ChevronsUpDown, Check } from 'lucide-vue-next'
|
||||
import { UserPlus, Copy, ExternalLink, Globe, AlertCircle, ShieldCheck, ChevronsUpDown, Check } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useTotp } from '@/composables/useTotp'
|
||||
@@ -575,6 +524,7 @@ import type {
|
||||
} from '@/api/endpoints/provider_oauth'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import JsonImportInput from '@/components/common/JsonImportInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -712,9 +662,7 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
// 导入状态
|
||||
const importText = ref('')
|
||||
const importing = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const showManualInput = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const importInputResetKey = ref(0)
|
||||
const importTask = ref<OAuthBatchImportTaskStatusResponse | null>(null)
|
||||
let importPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const importPolling = ref(false)
|
||||
@@ -847,14 +795,10 @@ function resetForm() {
|
||||
importText.value = ''
|
||||
importing.value = false
|
||||
importTask.value = null
|
||||
isDragging.value = false
|
||||
showManualInput.value = false
|
||||
importInputResetKey.value += 1
|
||||
proxyPopoverOpen.value = false
|
||||
selectedProxyNodeId.value = ''
|
||||
mode.value = 'oauth'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function switchMode(newMode: DialogMode) {
|
||||
@@ -978,81 +922,8 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
|
||||
return { refresh_token: trimmed }
|
||||
}
|
||||
|
||||
function readFileAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result
|
||||
if (typeof content === 'string') resolve(content)
|
||||
else reject(new Error('读取失败'))
|
||||
}
|
||||
reader.onerror = () => reject(new Error('读取失败'))
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
function isValidFileType(file: File): boolean {
|
||||
return file.name.endsWith('.json') || file.name.endsWith('.txt')
|
||||
|| file.type === 'application/json' || file.type === 'text/plain'
|
||||
}
|
||||
|
||||
/** 合并多个文件内容为统一的凭据文本(JSON 数组) */
|
||||
function mergeFileContents(contents: string[]): string {
|
||||
const items: unknown[] = []
|
||||
for (const raw of contents) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (Array.isArray(parsed)) {
|
||||
items.push(...parsed)
|
||||
} else {
|
||||
items.push(parsed)
|
||||
}
|
||||
continue
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
// 按行拆分(纯 Token)
|
||||
const lines = trimmed.split('\n').filter(l => l.trim() && !l.trim().startsWith('#'))
|
||||
items.push(...lines.map(l => l.trim()))
|
||||
}
|
||||
if (items.length === 1) {
|
||||
// 单条:保持原始格式
|
||||
return typeof items[0] === 'string' ? items[0] : JSON.stringify(items[0], null, 2)
|
||||
}
|
||||
return JSON.stringify(items, null, 2)
|
||||
}
|
||||
|
||||
async function readFiles(files: File[]) {
|
||||
const validFiles = files.filter(isValidFileType)
|
||||
if (validFiles.length === 0) {
|
||||
showError('仅支持 .json 或 .txt 文件', '格式错误')
|
||||
return
|
||||
}
|
||||
if (validFiles.length < files.length) {
|
||||
showError(`已忽略 ${files.length - validFiles.length} 个不支持的文件`, '提示')
|
||||
}
|
||||
try {
|
||||
const contents = await Promise.all(validFiles.map(readFileAsText))
|
||||
const merged = validFiles.length === 1 ? contents[0] : mergeFileContents(contents)
|
||||
importText.value = merged
|
||||
showManualInput.value = true
|
||||
} catch {
|
||||
showError('文件读取失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (files && files.length > 0) readFiles(Array.from(files))
|
||||
}
|
||||
|
||||
function handleFileDrop(event: DragEvent) {
|
||||
isDragging.value = false
|
||||
const files = event.dataTransfer?.files
|
||||
if (files && files.length > 0) readFiles(Array.from(files))
|
||||
function handleImportInputError(payload: { message: string; title?: string }) {
|
||||
showError(payload.message, payload.title)
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<div
|
||||
class="node-dot"
|
||||
:class="[
|
||||
getStatusColorClass(group.primaryStatus),
|
||||
getStatusColorClass(getDisplayStatus(group.primary)),
|
||||
{ 'is-first-selected': isGroupSelected(group) && selectedAttemptIndex === 0 }
|
||||
]"
|
||||
@click.stop="selectFirstAttempt(group)"
|
||||
@@ -83,7 +83,7 @@
|
||||
:key="attempt.id"
|
||||
class="sub-dot"
|
||||
:class="[
|
||||
getStatusColorClass(attempt.status),
|
||||
getStatusColorClass(getDisplayStatus(attempt)),
|
||||
{ active: selectedAttemptIndex === idx + 1 }
|
||||
]"
|
||||
:title="attempt.key_name || `Key ${idx + 2}`"
|
||||
@@ -115,9 +115,9 @@
|
||||
<div class="panel-title">
|
||||
<span
|
||||
class="title-dot"
|
||||
:class="getStatusColorClass(currentAttempt.status)"
|
||||
:class="getStatusColorClass(getDisplayStatus(currentAttempt))"
|
||||
/>
|
||||
<span class="title-text">{{ selectedGroup.providerName }}</span>
|
||||
<span class="title-text">{{ currentGroupTitle }}</span>
|
||||
<a
|
||||
v-if="currentAttempt.provider_website"
|
||||
:href="currentAttempt.provider_website"
|
||||
@@ -130,7 +130,7 @@
|
||||
</a>
|
||||
<span
|
||||
class="status-tag"
|
||||
:class="getStatusColorClass(currentAttempt.status)"
|
||||
:class="getStatusColorClass(getDisplayStatus(currentAttempt))"
|
||||
>
|
||||
{{ currentAttempt.status_code || getStatusLabel(currentAttempt.status) }}
|
||||
</span>
|
||||
@@ -188,12 +188,12 @@
|
||||
<span class="info-value mono">{{ formatLatency(currentAttempt.extra_data.first_byte_time_ms) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttempt.extra_data?.provider_api_format"
|
||||
v-if="currentAttemptFormatDisplay"
|
||||
class="info-item"
|
||||
>
|
||||
<span class="info-label">格式</span>
|
||||
<span class="info-value">
|
||||
<code class="format-code">{{ formatApiFormat(currentAttempt.extra_data.provider_api_format) }}</code>
|
||||
<code class="format-code">{{ currentAttemptFormatDisplay }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -433,6 +433,7 @@ interface NodeGroup {
|
||||
endIndex: number
|
||||
hasConversion: boolean // 组内是否有格式转换候选
|
||||
providerApiFormat: string | null // 提供商 API 格式(如 openai:cli)
|
||||
isPoolGroup?: boolean
|
||||
}
|
||||
|
||||
// 用量数据类型
|
||||
@@ -464,8 +465,12 @@ const props = defineProps<{
|
||||
requestId: string
|
||||
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
|
||||
overrideStatusCode?: number
|
||||
/** 请求侧 API 格式(客户端入口格式) */
|
||||
requestApiFormat?: string | null
|
||||
/** 用量和费用数据 */
|
||||
usageData?: UsageData | null
|
||||
/** 请求元数据(用于号池调度组装) */
|
||||
requestMetadata?: Record<string, unknown> | null
|
||||
}>()
|
||||
|
||||
// 用量数据(从 props 获取)
|
||||
@@ -594,21 +599,54 @@ const proxyTimingBreakdown = (proxy: Record<string, unknown>): string => {
|
||||
return parts.join(' / ')
|
||||
}
|
||||
|
||||
const TIMELINE_STATUS: CandidateRecord['status'][] = [
|
||||
'success',
|
||||
'failed',
|
||||
'skipped',
|
||||
'cancelled',
|
||||
'pending',
|
||||
'streaming',
|
||||
'available',
|
||||
'unused',
|
||||
'stream_interrupted',
|
||||
]
|
||||
|
||||
const STATUS_PRIORITY: Record<string, number> = {
|
||||
available: 0,
|
||||
unused: 0,
|
||||
skipped: 1,
|
||||
failed: 2,
|
||||
cancelled: 2,
|
||||
stream_interrupted: 2,
|
||||
pending: 3,
|
||||
streaming: 3,
|
||||
success: 4,
|
||||
}
|
||||
|
||||
const toInt = (value: unknown, defaultValue = 0): number => {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? Math.trunc(num) : defaultValue
|
||||
}
|
||||
|
||||
const makeAttemptKey = (candidateIndex: number, retryIndex: number): string => {
|
||||
return `${candidateIndex}:${retryIndex}`
|
||||
}
|
||||
|
||||
const normalizeTimelineStatus = (value: unknown): CandidateRecord['status'] => {
|
||||
if (typeof value !== 'string') return 'failed'
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if ((TIMELINE_STATUS as string[]).includes(normalized)) {
|
||||
return normalized as CandidateRecord['status']
|
||||
}
|
||||
// 兜底:内部调度轨迹里非标准状态统一按失败展示
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
// 候选时间线(按实际执行顺序排序)
|
||||
const timeline = computed<CandidateRecord[]>(() => {
|
||||
const rawTimeline = computed<CandidateRecord[]>(() => {
|
||||
if (!trace.value) return []
|
||||
return [...trace.value.candidates]
|
||||
.filter(c => [
|
||||
'success',
|
||||
'failed',
|
||||
'skipped',
|
||||
'cancelled',
|
||||
'pending',
|
||||
'streaming',
|
||||
'available',
|
||||
'unused',
|
||||
'stream_interrupted'
|
||||
].includes(c.status))
|
||||
.filter(c => TIMELINE_STATUS.includes(c.status))
|
||||
.sort((a, b) => {
|
||||
const startedA = a.started_at ? new Date(a.started_at).getTime() : Infinity
|
||||
const startedB = b.started_at ? new Date(b.started_at).getTime() : Infinity
|
||||
@@ -620,18 +658,109 @@ const timeline = computed<CandidateRecord[]>(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 将相同 Provider 的所有请求合并为组(同提供商的 Key 放在子节点)
|
||||
const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
if (!timeline.value || timeline.value.length === 0) return []
|
||||
const schedulingAudit = computed<Record<string, unknown> | null>(() => {
|
||||
const metadata = props.requestMetadata
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null
|
||||
const raw = metadata.scheduling_audit
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
return raw as Record<string, unknown>
|
||||
})
|
||||
|
||||
const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
||||
const audit = schedulingAudit.value
|
||||
if (!audit) return []
|
||||
const attempts = audit.attempts
|
||||
if (!Array.isArray(attempts) || attempts.length === 0) return []
|
||||
|
||||
const traceMap = new Map<string, CandidateRecord>()
|
||||
for (const candidate of rawTimeline.value) {
|
||||
traceMap.set(makeAttemptKey(candidate.candidate_index, candidate.retry_index), candidate)
|
||||
}
|
||||
|
||||
return attempts
|
||||
.map((item, index) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
|
||||
const raw = item as Record<string, unknown>
|
||||
const candidateIndex = toInt(raw.candidate_index, index)
|
||||
const retryIndex = toInt(raw.retry_index, 0)
|
||||
const key = makeAttemptKey(candidateIndex, retryIndex)
|
||||
const fromTrace = traceMap.get(key)
|
||||
|
||||
const merged: CandidateRecord = fromTrace
|
||||
? { ...fromTrace }
|
||||
: {
|
||||
id: `pool-${props.requestId}-${candidateIndex}-${retryIndex}-${index}`,
|
||||
request_id: props.requestId,
|
||||
candidate_index: candidateIndex,
|
||||
retry_index: retryIndex,
|
||||
provider_id: undefined,
|
||||
provider_name: undefined,
|
||||
endpoint_id: undefined,
|
||||
key_id: undefined,
|
||||
key_name: undefined,
|
||||
status: 'failed',
|
||||
is_cached: false,
|
||||
created_at: new Date(0).toISOString(),
|
||||
}
|
||||
|
||||
merged.status = normalizeTimelineStatus(raw.status ?? merged.status)
|
||||
if (typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
|
||||
if (typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
|
||||
if (typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
|
||||
if (typeof raw.key_id === 'string') merged.key_id = raw.key_id
|
||||
if (typeof raw.key_name === 'string') merged.key_name = raw.key_name
|
||||
if (typeof raw.status_code === 'number') merged.status_code = raw.status_code
|
||||
if (typeof raw.error_type === 'string') merged.error_type = raw.error_type
|
||||
return merged
|
||||
})
|
||||
.filter((item): item is CandidateRecord => item !== null)
|
||||
})
|
||||
|
||||
const poolAttemptKeySet = computed<Set<string>>(() => {
|
||||
return new Set(
|
||||
poolAttemptCandidates.value.map((item) => makeAttemptKey(item.candidate_index, item.retry_index)),
|
||||
)
|
||||
})
|
||||
|
||||
const timeline = computed<CandidateRecord[]>(() => {
|
||||
if (poolAttemptCandidates.value.length === 0) return rawTimeline.value
|
||||
return rawTimeline.value.filter(
|
||||
(candidate) => !poolAttemptKeySet.value.has(makeAttemptKey(candidate.candidate_index, candidate.retry_index)),
|
||||
)
|
||||
})
|
||||
|
||||
const AUTH_TYPE_PROVIDER_LABEL_MAP: Record<string, string> = {
|
||||
codex: 'Codex',
|
||||
kiro: 'Kiro',
|
||||
antigravity: 'Antigravity',
|
||||
claude_code: 'Claude Code',
|
||||
gemini_cli: 'Gemini CLI',
|
||||
}
|
||||
|
||||
const normalizeProviderName = (value: string): string => {
|
||||
const text = value.trim()
|
||||
if (!text) return '未知'
|
||||
// 管理后台中常见“xx反代”命名,展示时保留提供商品牌名即可
|
||||
return text.replace(/反代$/u, '').trim() || text
|
||||
}
|
||||
|
||||
const getProviderDisplayName = (attempt: CandidateRecord | null | undefined): string => {
|
||||
if (!attempt) return '未知'
|
||||
const authType = String(attempt.key_auth_type || '').trim().toLowerCase()
|
||||
if (authType && AUTH_TYPE_PROVIDER_LABEL_MAP[authType]) {
|
||||
return AUTH_TYPE_PROVIDER_LABEL_MAP[authType]
|
||||
}
|
||||
const providerName = String(attempt.provider_name || '').trim()
|
||||
return providerName ? normalizeProviderName(providerName) : '未知'
|
||||
}
|
||||
|
||||
const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
|
||||
const groups: NodeGroup[] = []
|
||||
let currentGroup: NodeGroup | null = null
|
||||
|
||||
timeline.value.forEach((candidate, index) => {
|
||||
// 使用 provider_name 作为分组 key(同一个提供商的所有 Key 合并)
|
||||
items.forEach((candidate, index) => {
|
||||
const providerKey = candidate.provider_name || '未知'
|
||||
|
||||
// 如果属于同一个 Provider,合并到当前组
|
||||
if (currentGroup && currentGroup.id === providerKey) {
|
||||
currentGroup.allAttempts.push(candidate)
|
||||
currentGroup.retryCount++
|
||||
@@ -640,37 +769,67 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
if (candidate.extra_data?.needs_conversion) {
|
||||
currentGroup.hasConversion = true
|
||||
}
|
||||
// 按优先级提升组状态:success > streaming/pending > failed/cancelled/stream_interrupted > skipped > available/unused
|
||||
const statusPriority: Record<string, number> = {
|
||||
available: 0, unused: 0, skipped: 1,
|
||||
failed: 2, cancelled: 2, stream_interrupted: 2,
|
||||
pending: 3, streaming: 3, success: 4,
|
||||
}
|
||||
const currentPriority = statusPriority[currentGroup.primaryStatus] ?? 0
|
||||
const newPriority = statusPriority[candidate.status] ?? 0
|
||||
const currentPriority = STATUS_PRIORITY[currentGroup.primaryStatus] ?? 0
|
||||
const newPriority = STATUS_PRIORITY[candidate.status] ?? 0
|
||||
if (newPriority > currentPriority) {
|
||||
currentGroup.primaryStatus = candidate.status
|
||||
}
|
||||
} else {
|
||||
// 新建一个组
|
||||
currentGroup = {
|
||||
id: providerKey,
|
||||
providerName: candidate.provider_name || '未知',
|
||||
primary: candidate,
|
||||
primaryStatus: candidate.status,
|
||||
allAttempts: [candidate],
|
||||
retryCount: 0,
|
||||
totalLatency: candidate.latency_ms || 0,
|
||||
startIndex: index,
|
||||
endIndex: index,
|
||||
hasConversion: candidate.extra_data?.needs_conversion === true,
|
||||
providerApiFormat: candidate.extra_data?.provider_api_format || null,
|
||||
}
|
||||
groups.push(currentGroup)
|
||||
return
|
||||
}
|
||||
|
||||
currentGroup = {
|
||||
id: providerKey,
|
||||
providerName: getProviderDisplayName(candidate),
|
||||
primary: candidate,
|
||||
primaryStatus: candidate.status,
|
||||
allAttempts: [candidate],
|
||||
retryCount: 0,
|
||||
totalLatency: candidate.latency_ms || 0,
|
||||
startIndex: index,
|
||||
endIndex: index,
|
||||
hasConversion: candidate.extra_data?.needs_conversion === true,
|
||||
providerApiFormat: candidate.extra_data?.provider_api_format || null,
|
||||
isPoolGroup: false,
|
||||
}
|
||||
groups.push(currentGroup)
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// 将相同 Provider 的所有请求合并为组(同提供商的 Key 放在子节点)
|
||||
const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
const providerGroups = buildProviderGroups(timeline.value)
|
||||
const poolAttempts = poolAttemptCandidates.value
|
||||
if (poolAttempts.length === 0) {
|
||||
return providerGroups
|
||||
}
|
||||
|
||||
const poolPrimaryStatus = poolAttempts.reduce((best, current) => {
|
||||
const bestPriority = STATUS_PRIORITY[best] ?? 0
|
||||
const currentPriority = STATUS_PRIORITY[current.status] ?? 0
|
||||
return currentPriority > bestPriority ? current.status : best
|
||||
}, poolAttempts[0].status)
|
||||
|
||||
const successAttempt = poolAttempts.find((item) => item.status === 'success')
|
||||
const poolPrimary = successAttempt || poolAttempts[poolAttempts.length - 1] || poolAttempts[0]
|
||||
|
||||
const poolGroup: NodeGroup = {
|
||||
id: '__pool_group__',
|
||||
providerName: getProviderDisplayName(poolPrimary),
|
||||
primary: poolPrimary,
|
||||
primaryStatus: poolPrimaryStatus,
|
||||
allAttempts: poolAttempts,
|
||||
retryCount: Math.max(0, poolAttempts.length - 1),
|
||||
totalLatency: poolAttempts.reduce((sum, item) => sum + (item.latency_ms || 0), 0),
|
||||
startIndex: 0,
|
||||
endIndex: poolAttempts.length - 1,
|
||||
hasConversion: poolAttempts.some((item) => item.extra_data?.needs_conversion === true),
|
||||
providerApiFormat: null,
|
||||
isPoolGroup: true,
|
||||
}
|
||||
|
||||
return [poolGroup, ...providerGroups]
|
||||
})
|
||||
|
||||
// 格式转换分界点索引(首个 hasConversion=true 的 group index)
|
||||
@@ -687,16 +846,16 @@ const conversionBoundaryIndex = computed(() => {
|
||||
// 优先使用 latency_ms,因为它与 Usage.response_time_ms 使用相同的时间基准
|
||||
// 避免 finished_at - started_at 带来的额外延迟(数据库操作时间)
|
||||
const totalTraceLatency = computed(() => {
|
||||
if (!timeline.value || timeline.value.length === 0) return 0
|
||||
if (!rawTimeline.value || rawTimeline.value.length === 0) return 0
|
||||
|
||||
// 查找成功的候选,使用其 latency_ms
|
||||
const successCandidate = timeline.value.find(c => c.status === 'success')
|
||||
const successCandidate = rawTimeline.value.find(c => c.status === 'success')
|
||||
if (successCandidate?.latency_ms != null) {
|
||||
return successCandidate.latency_ms
|
||||
}
|
||||
|
||||
// 如果没有成功的候选,查找失败但有 latency_ms 的候选
|
||||
const failedWithLatency = timeline.value.find(c => c.status === 'failed' && c.latency_ms != null)
|
||||
const failedWithLatency = rawTimeline.value.find(c => c.status === 'failed' && c.latency_ms != null)
|
||||
if (failedWithLatency?.latency_ms != null) {
|
||||
return failedWithLatency.latency_ms
|
||||
}
|
||||
@@ -705,7 +864,7 @@ const totalTraceLatency = computed(() => {
|
||||
let earliestStart: number | null = null
|
||||
let latestEnd: number | null = null
|
||||
|
||||
for (const candidate of timeline.value) {
|
||||
for (const candidate of rawTimeline.value) {
|
||||
if (candidate.started_at) {
|
||||
const startTime = new Date(candidate.started_at).getTime()
|
||||
if (earliestStart === null || startTime < earliestStart) {
|
||||
@@ -738,6 +897,49 @@ const currentAttempt = computed(() => {
|
||||
return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary
|
||||
})
|
||||
|
||||
const currentGroupTitle = computed(() => {
|
||||
if (!selectedGroup.value || !currentAttempt.value) return ''
|
||||
if (selectedGroup.value.isPoolGroup) {
|
||||
return getProviderDisplayName(currentAttempt.value)
|
||||
}
|
||||
return selectedGroup.value.providerName
|
||||
})
|
||||
|
||||
const normalizeFormatSignature = (value: string): string => {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const currentAttemptFormatDisplay = computed(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt) return ''
|
||||
const extra = (
|
||||
attempt.extra_data && typeof attempt.extra_data === 'object' && !Array.isArray(attempt.extra_data)
|
||||
? attempt.extra_data
|
||||
: {}
|
||||
) as Record<string, unknown>
|
||||
|
||||
const providerRaw = typeof extra.provider_api_format === 'string' ? extra.provider_api_format : ''
|
||||
const clientRawFromExtra = typeof extra.client_api_format === 'string' ? extra.client_api_format : ''
|
||||
const requestRaw = clientRawFromExtra || (typeof props.requestApiFormat === 'string' ? props.requestApiFormat : '')
|
||||
|
||||
if (!providerRaw && !requestRaw) return ''
|
||||
|
||||
const providerText = providerRaw ? formatApiFormat(providerRaw) : ''
|
||||
const requestText = requestRaw ? formatApiFormat(requestRaw) : ''
|
||||
const convertedByFlag = extra.needs_conversion === true
|
||||
const convertedByDiff = Boolean(
|
||||
providerRaw &&
|
||||
requestRaw &&
|
||||
normalizeFormatSignature(providerRaw) !== normalizeFormatSignature(requestRaw),
|
||||
)
|
||||
|
||||
if ((convertedByFlag || convertedByDiff) && requestText && providerText) {
|
||||
return `${requestText} -> ${providerText}`
|
||||
}
|
||||
|
||||
return providerText || requestText
|
||||
})
|
||||
|
||||
// 计算当前尝试启用的能力标签(请求需要的能力)
|
||||
const activeCapabilities = computed(() => {
|
||||
if (!currentAttempt.value?.required_capabilities) return []
|
||||
@@ -1018,6 +1220,28 @@ const getStatusColorClass = (status: string) => {
|
||||
}
|
||||
return classes[status] || 'status-available'
|
||||
}
|
||||
|
||||
// 展示状态:进行中态优先(包括 started 但未 finished 的中间态),再按 HTTP 状态码兜底
|
||||
const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string => {
|
||||
if (!attempt) return 'available'
|
||||
const hasFinished = Boolean(attempt.finished_at)
|
||||
const isExplicitPending = (attempt.status === 'pending' || attempt.status === 'streaming') && !hasFinished
|
||||
const isImplicitPending = Boolean(
|
||||
attempt.started_at &&
|
||||
!hasFinished &&
|
||||
!['failed', 'cancelled', 'skipped', 'stream_interrupted'].includes(attempt.status),
|
||||
)
|
||||
|
||||
if (isExplicitPending || isImplicitPending) {
|
||||
return 'pending'
|
||||
}
|
||||
const code = attempt.status_code
|
||||
if (typeof code === 'number') {
|
||||
if (code >= 200 && code < 300) return 'success'
|
||||
if (code >= 400) return 'failed'
|
||||
}
|
||||
return attempt.status
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -378,57 +378,14 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 号池调度摘要 -->
|
||||
<Card v-if="poolSummary">
|
||||
<div class="p-3 sm:p-4">
|
||||
<div class="text-xs text-muted-foreground mb-2 font-medium">
|
||||
号池调度
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap text-sm">
|
||||
<span class="font-mono">
|
||||
{{ poolSummary.total_keys }} 候选
|
||||
</span>
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="font-mono">
|
||||
{{ poolSummary.attempted }} 尝试
|
||||
</span>
|
||||
<template v-if="poolSummary.skipped_cooldown > 0">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="font-mono text-amber-600 dark:text-amber-400">
|
||||
{{ poolSummary.skipped_cooldown }} 冷却跳过
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="poolSummary.skipped_cost > 0">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="font-mono text-orange-600 dark:text-orange-400">
|
||||
{{ poolSummary.skipped_cost }} 成本跳过
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="poolSummary.sticky_session">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 h-4"
|
||||
>
|
||||
粘性会话
|
||||
</Badge>
|
||||
</template>
|
||||
<template v-if="poolSummary.success_reason">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ poolSummary.success_reason }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 请求链路追踪卡片 -->
|
||||
<div v-if="detail.request_id || detail.id">
|
||||
<HorizontalRequestTimeline
|
||||
ref="timelineRef"
|
||||
:request-id="detail.request_id || detail.id"
|
||||
:override-status-code="detail.status_code"
|
||||
:request-api-format="detail.api_format || null"
|
||||
:request-metadata="traceRequestMetadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -781,11 +738,10 @@ const isDark = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// 号池调度摘要
|
||||
const poolSummary = computed(() => {
|
||||
const ps = detail.value?.metadata?.pool_summary as Record<string, unknown> | undefined
|
||||
if (!ps || !ps.enabled) return null
|
||||
return ps
|
||||
const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
|
||||
const meta = detail.value?.metadata
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null
|
||||
return meta as Record<string, unknown>
|
||||
})
|
||||
|
||||
// 检测是否有提供商请求头
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user