mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-13 14:40:20 +08:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -235,6 +235,12 @@ export interface RequestDetail {
|
||||
has_provider_request_body?: boolean
|
||||
has_response_body?: boolean
|
||||
has_client_response_body?: boolean
|
||||
body_load_errors?: {
|
||||
request_body?: boolean
|
||||
provider_request_body?: boolean
|
||||
response_body?: boolean
|
||||
client_response_body?: boolean
|
||||
} | null
|
||||
metadata?: Record<string, unknown>
|
||||
routing?: Record<string, unknown>
|
||||
body_capture?: Record<string, unknown>
|
||||
|
||||
@@ -830,8 +830,17 @@ export interface FailoverRuleItem {
|
||||
}
|
||||
|
||||
export interface FailoverRulesConfig {
|
||||
success_failover_patterns: FailoverRuleItem[]
|
||||
error_stop_patterns: FailoverRuleItem[]
|
||||
max_retries?: number
|
||||
stop_status_codes?: number[]
|
||||
stop_on_status_codes?: number[]
|
||||
early_stop_status_codes?: number[]
|
||||
non_retryable_status_codes?: number[]
|
||||
continue_on_status_codes?: number[]
|
||||
retryable_status_codes?: number[]
|
||||
retry_on_status_codes?: number[]
|
||||
continue_status_codes?: number[]
|
||||
success_failover_patterns?: FailoverRuleItem[]
|
||||
error_stop_patterns?: FailoverRuleItem[]
|
||||
}
|
||||
|
||||
export interface ProviderWithEndpointsSummary {
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface UsageRecordDetail {
|
||||
rate_multiplier?: number // 成本倍率(仅管理员可见)
|
||||
response_time_ms?: number | null
|
||||
first_byte_time_ms?: number | null
|
||||
updated_at?: string | null
|
||||
response_time_updated_at?: string | null
|
||||
is_stream: boolean
|
||||
upstream_is_stream?: boolean
|
||||
client_requested_stream?: boolean
|
||||
@@ -352,6 +354,8 @@ export const meApi = {
|
||||
rate_multiplier?: number | null
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
updated_at?: string | null
|
||||
response_time_updated_at?: string | null
|
||||
status_code?: number | null
|
||||
error_message?: string | null
|
||||
api_format?: string | null
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface UsageRecord {
|
||||
cost?: number
|
||||
response_time?: number
|
||||
created_at: string
|
||||
updated_at?: string | null
|
||||
response_time_updated_at?: string | null
|
||||
has_fallback?: boolean // 🆕 是否发生了 fallback
|
||||
client_family?: string | null
|
||||
client_ip?: string | null
|
||||
@@ -472,7 +474,10 @@ export const usageApi = {
|
||||
provider?: string
|
||||
api_format?: string // API 格式筛选(如 openai:chat, claude:messages)
|
||||
status?: string // 'stream' | 'standard' | 'error'
|
||||
client_family?: string
|
||||
hide_unknown?: boolean
|
||||
include_total?: boolean
|
||||
total_only?: boolean
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
@@ -480,6 +485,7 @@ export const usageApi = {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
total_is_estimated?: boolean
|
||||
}> {
|
||||
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
|
||||
return dedupedRequest(key, async () => {
|
||||
@@ -488,6 +494,38 @@ export const usageApi = {
|
||||
})
|
||||
},
|
||||
|
||||
async getAllUsageRecordTotal(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
search?: string
|
||||
user_id?: string
|
||||
username?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
api_format?: string
|
||||
status?: string
|
||||
client_family?: string
|
||||
hide_unknown?: boolean
|
||||
}): Promise<number> {
|
||||
const requestParams = compactParams({
|
||||
...params,
|
||||
include_total: true,
|
||||
total_only: true,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
})
|
||||
const key = buildCacheKey('usage:records:total', requestParams)
|
||||
return dedupedRequest(key, async () => {
|
||||
const response = await apiClient.get<UsageListResponse>('/api/admin/usage/records', {
|
||||
params: requestParams,
|
||||
})
|
||||
return assertNumber(response.data.total, 'total')
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃请求的状态(轻量级接口,用于轮询更新)
|
||||
* @param ids 可选,逗号分隔的请求 ID 列表
|
||||
@@ -511,6 +549,8 @@ export const usageApi = {
|
||||
rate_multiplier?: number | null
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
updated_at?: string | null
|
||||
response_time_updated_at?: string | null
|
||||
status_code?: number | null
|
||||
error_message?: string | null
|
||||
provider?: string | null
|
||||
|
||||
@@ -19,45 +19,95 @@
|
||||
HTTP 200 但响应体匹配正则时,视为失败并触发转移
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="addRule('success')"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 text-xs px-2"
|
||||
:title="successJsonMode ? '切回成功转移表单' : '切到成功转移 JSON'"
|
||||
@click="toggleSuccessJsonMode"
|
||||
>
|
||||
<Code2 class="w-3 h-3 mr-1" />
|
||||
{{ successJsonMode ? '表单' : 'JSON' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="successJsonMode"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
title="格式化成功转移 JSON"
|
||||
@click="formatSuccessJsonDraft"
|
||||
>
|
||||
<AlignLeft class="w-3 h-3 mr-1" />
|
||||
格式化
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!successJsonMode"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 text-xs px-2"
|
||||
@click="addRule('success')"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="successPatterns.length === 0"
|
||||
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
|
||||
v-if="successJsonMode"
|
||||
class="space-y-2"
|
||||
>
|
||||
暂无规则
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(rule, index) in successPatterns"
|
||||
:key="'s-' + index"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Input
|
||||
v-model="rule.pattern"
|
||||
placeholder="例如: relay:.*格式错误"
|
||||
size="sm"
|
||||
class="font-mono text-xs flex-1"
|
||||
<Textarea
|
||||
:model-value="successJsonDraft"
|
||||
class="min-h-[160px] font-mono text-xs leading-relaxed"
|
||||
spellcheck="false"
|
||||
placeholder="[{ "pattern": "relay:.*格式错误" }]"
|
||||
@update:model-value="updateSuccessJsonDraft"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
@click="removeRule('success', index)"
|
||||
<div
|
||||
v-if="successJsonError"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{{ successJsonError }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
仅管理成功转移规则;JSON 应为数组,<code class="bg-muted px-1 rounded">pattern</code> 必填。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="successPatterns.length === 0"
|
||||
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
|
||||
>
|
||||
暂无规则
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(rule, index) in successPatterns"
|
||||
:key="'s-' + index"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Input
|
||||
v-model="rule.pattern"
|
||||
placeholder="例如: relay:.*格式错误"
|
||||
size="sm"
|
||||
class="font-mono text-xs flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
@click="removeRule('success', index)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 错误终止规则 -->
|
||||
@@ -68,54 +118,104 @@
|
||||
错误终止规则
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
HTTP 非 200 且响应体匹配正则时,停止转移并直接返回错误。可选填状态码缩小匹配范围
|
||||
HTTP 非 200 且规则命中时,停止转移并直接返回错误。状态码不填则所有错误状态都尝试匹配正则
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
@click="addRule('error')"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 text-xs px-2"
|
||||
:title="errorJsonMode ? '切回错误终止表单' : '切到错误终止 JSON'"
|
||||
@click="toggleErrorJsonMode"
|
||||
>
|
||||
<Code2 class="w-3 h-3 mr-1" />
|
||||
{{ errorJsonMode ? '表单' : 'JSON' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="errorJsonMode"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
title="格式化错误终止 JSON"
|
||||
@click="formatErrorJsonDraft"
|
||||
>
|
||||
<AlignLeft class="w-3 h-3 mr-1" />
|
||||
格式化
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!errorJsonMode"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 text-xs px-2"
|
||||
@click="addRule('error')"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorPatterns.length === 0"
|
||||
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
|
||||
v-if="errorJsonMode"
|
||||
class="space-y-2"
|
||||
>
|
||||
暂无规则
|
||||
<Textarea
|
||||
:model-value="errorJsonDraft"
|
||||
class="min-h-[180px] font-mono text-xs leading-relaxed"
|
||||
spellcheck="false"
|
||||
placeholder="[{ "pattern": "content_policy_violation" }, { "status_codes": [429, 500, 503], "pattern": "" }]"
|
||||
@update:model-value="updateErrorJsonDraft"
|
||||
/>
|
||||
<div
|
||||
v-if="errorJsonError"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{{ errorJsonError }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
仅管理错误终止规则;JSON 应为数组,<code class="bg-muted px-1 rounded">status_codes</code> 选填数组,不填则所有错误状态都尝试匹配 <code class="bg-muted px-1 rounded">pattern</code>;两者至少填一个。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(rule, index) in errorPatterns"
|
||||
:key="'e-' + index"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Input
|
||||
v-model="statusCodeInputs[index]"
|
||||
placeholder="状态码 (可选)"
|
||||
size="sm"
|
||||
class="font-mono text-xs w-28 shrink-0"
|
||||
/>
|
||||
<Input
|
||||
v-model="rule.pattern"
|
||||
placeholder="例如: content_policy_violation"
|
||||
size="sm"
|
||||
class="font-mono text-xs flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
@click="removeRule('error', index)"
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="errorPatterns.length === 0"
|
||||
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
暂无规则
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(rule, index) in errorPatterns"
|
||||
:key="'e-' + index"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Input
|
||||
v-model="statusCodeInputs[index]"
|
||||
placeholder="状态码 (选填,可多个)"
|
||||
size="sm"
|
||||
class="font-mono text-xs w-40 shrink-0"
|
||||
/>
|
||||
<Input
|
||||
v-model="rule.pattern"
|
||||
placeholder="正则内容 (选填)"
|
||||
size="sm"
|
||||
class="font-mono text-xs flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
@click="removeRule('error', index)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,12 +243,13 @@ import {
|
||||
Dialog,
|
||||
Button,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { GitBranch, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { AlignLeft, Code2, GitBranch, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import type { FailoverRuleItem } from '@/api/endpoints/types'
|
||||
import type { FailoverRuleItem, FailoverRulesConfig } from '@/api/endpoints/types'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -166,15 +267,105 @@ const saving = ref(false)
|
||||
const successPatterns = ref<FailoverRuleItem[]>([])
|
||||
const errorPatterns = ref<FailoverRuleItem[]>([])
|
||||
const statusCodeInputs = ref<string[]>([])
|
||||
const successJsonMode = ref(false)
|
||||
const successJsonDraft = ref('')
|
||||
const successJsonError = ref<string | null>(null)
|
||||
const successJsonDirty = ref(false)
|
||||
const errorJsonMode = ref(false)
|
||||
const errorJsonDraft = ref('')
|
||||
const errorJsonError = ref<string | null>(null)
|
||||
const errorJsonDirty = ref(false)
|
||||
|
||||
const TOP_LEVEL_STOP_STATUS_CODE_KEYS = [
|
||||
'stop_status_codes',
|
||||
'stop_on_status_codes',
|
||||
'early_stop_status_codes',
|
||||
'non_retryable_status_codes',
|
||||
] as const
|
||||
|
||||
const MANAGED_FAILOVER_RULE_KEYS = [
|
||||
'success_failover_patterns',
|
||||
'error_stop_patterns',
|
||||
...TOP_LEVEL_STOP_STATUS_CODE_KEYS,
|
||||
] as const
|
||||
|
||||
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function uniqueStatusCodes(codes: number[]): number[] {
|
||||
return codes.filter((code, index, values) =>
|
||||
Number.isInteger(code) && code >= 100 && code <= 599 && values.indexOf(code) === index
|
||||
)
|
||||
}
|
||||
|
||||
function collectTopLevelStopStatusCodes(rules: ProviderWithEndpointsSummary['failover_rules']): number[] {
|
||||
if (!rules) return []
|
||||
return uniqueStatusCodes(TOP_LEVEL_STOP_STATUS_CODE_KEYS.flatMap(key => rules[key] || []))
|
||||
}
|
||||
|
||||
function hasPersistableFailoverValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return false
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
if (typeof value === 'string') return value.trim().length > 0
|
||||
if (isJsonObject(value)) return Object.values(value).some(hasPersistableFailoverValue)
|
||||
return true
|
||||
}
|
||||
|
||||
function buildPreservedFailoverRules(
|
||||
rules: ProviderWithEndpointsSummary['failover_rules'],
|
||||
): Record<string, unknown> {
|
||||
if (!isJsonObject(rules)) return {}
|
||||
const preserved: Record<string, unknown> = { ...rules }
|
||||
for (const key of MANAGED_FAILOVER_RULE_KEYS) {
|
||||
delete preserved[key]
|
||||
}
|
||||
return preserved
|
||||
}
|
||||
|
||||
function buildNextFailoverRules(
|
||||
filteredSuccess: FailoverRuleItem[],
|
||||
filteredError: FailoverRuleItem[],
|
||||
): FailoverRulesConfig | null {
|
||||
const nextRules = buildPreservedFailoverRules(props.provider?.failover_rules)
|
||||
if (filteredSuccess.length > 0) {
|
||||
nextRules.success_failover_patterns = filteredSuccess
|
||||
}
|
||||
if (filteredError.length > 0) {
|
||||
nextRules.error_stop_patterns = filteredError
|
||||
}
|
||||
|
||||
return Object.values(nextRules).some(hasPersistableFailoverValue)
|
||||
? nextRules as FailoverRulesConfig
|
||||
: null
|
||||
}
|
||||
|
||||
watch(() => [props.open, props.provider], () => {
|
||||
if (props.open && props.provider) {
|
||||
const rules = props.provider.failover_rules
|
||||
successPatterns.value = (rules?.success_failover_patterns || []).map(r => ({ ...r }))
|
||||
errorPatterns.value = (rules?.error_stop_patterns || []).map(r => ({ ...r }))
|
||||
successPatterns.value = (rules?.success_failover_patterns || []).map(r => ({
|
||||
...r,
|
||||
pattern: r.pattern || '',
|
||||
}))
|
||||
errorPatterns.value = (rules?.error_stop_patterns || []).map(r => ({
|
||||
...r,
|
||||
pattern: r.pattern || '',
|
||||
}))
|
||||
const topLevelStopStatusCodes = collectTopLevelStopStatusCodes(rules)
|
||||
if (topLevelStopStatusCodes.length > 0) {
|
||||
errorPatterns.value.push({
|
||||
pattern: '',
|
||||
description: '',
|
||||
status_codes: topLevelStopStatusCodes,
|
||||
})
|
||||
}
|
||||
statusCodeInputs.value = errorPatterns.value.map(r =>
|
||||
r.status_codes?.length ? r.status_codes.join(',') : ''
|
||||
)
|
||||
successJsonMode.value = false
|
||||
errorJsonMode.value = false
|
||||
refreshSuccessJsonDraft()
|
||||
refreshErrorJsonDraft()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -201,9 +392,16 @@ function handleClose() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function parseStatusCodes(input: string): { valid: true; codes?: number[] } | { valid: false; reason: string } {
|
||||
function parseStatusCodes(
|
||||
input: string,
|
||||
required = false,
|
||||
): { valid: true; codes: number[] } | { valid: false; reason: string } {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return { valid: true }
|
||||
if (!trimmed) {
|
||||
return required
|
||||
? { valid: false, reason: '状态码不能为空' }
|
||||
: { valid: true, codes: [] }
|
||||
}
|
||||
const parts = trimmed.split(/[,\s]+/)
|
||||
const codes: number[] = []
|
||||
for (const part of parts) {
|
||||
@@ -213,11 +411,21 @@ function parseStatusCodes(input: string): { valid: true; codes?: number[] } | {
|
||||
if (n < 100 || n > 599) return { valid: false, reason: `${n} 不在 100-599 范围内` }
|
||||
codes.push(n)
|
||||
}
|
||||
return { valid: true, codes: codes.length > 0 ? codes : undefined }
|
||||
const uniqueCodes = Array.from(new Set(codes))
|
||||
return uniqueCodes.length > 0
|
||||
? { valid: true, codes: uniqueCodes }
|
||||
: required
|
||||
? { valid: false, reason: '状态码不能为空' }
|
||||
: { valid: true, codes: [] }
|
||||
}
|
||||
|
||||
function validatePattern(pattern: string): string | null {
|
||||
if (!pattern.trim()) return '正则表达式不能为空'
|
||||
return validateOptionalPattern(pattern)
|
||||
}
|
||||
|
||||
function validateOptionalPattern(pattern: string): string | null {
|
||||
if (!pattern.trim()) return null
|
||||
try {
|
||||
new RegExp(pattern)
|
||||
return null
|
||||
@@ -226,12 +434,301 @@ function validatePattern(pattern: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function buildSuccessJsonRulesFromForm(): FailoverRuleItem[] {
|
||||
return successPatterns.value
|
||||
.map(rule => ({
|
||||
...rule,
|
||||
pattern: rule.pattern.trim(),
|
||||
status_codes: rule.status_codes?.length ? uniqueStatusCodes(rule.status_codes) : undefined,
|
||||
}))
|
||||
.filter(rule => rule.pattern)
|
||||
}
|
||||
|
||||
function buildErrorJsonRulesFromForm(): FailoverRuleItem[] {
|
||||
return errorPatterns.value
|
||||
.map((rule, index) => {
|
||||
const parsed = parseStatusCodes(statusCodeInputs.value[index] || '')
|
||||
const statusCodes = parsed.valid
|
||||
? parsed.codes
|
||||
: uniqueStatusCodes(rule.status_codes || [])
|
||||
return {
|
||||
...rule,
|
||||
pattern: rule.pattern.trim(),
|
||||
status_codes: statusCodes.length > 0 ? statusCodes : undefined,
|
||||
}
|
||||
})
|
||||
.filter(rule => rule.pattern || (rule.status_codes?.length || 0) > 0)
|
||||
}
|
||||
|
||||
function stringifyRuleArray(rules: FailoverRuleItem[]): string {
|
||||
return JSON.stringify(rules, null, 2)
|
||||
}
|
||||
|
||||
function refreshSuccessJsonDraft() {
|
||||
successJsonDraft.value = stringifyRuleArray(buildSuccessJsonRulesFromForm())
|
||||
successJsonError.value = null
|
||||
successJsonDirty.value = false
|
||||
}
|
||||
|
||||
function refreshErrorJsonDraft() {
|
||||
errorJsonDraft.value = stringifyRuleArray(buildErrorJsonRulesFromForm())
|
||||
errorJsonError.value = null
|
||||
errorJsonDirty.value = false
|
||||
}
|
||||
|
||||
function updateSuccessJsonDraft(value: string) {
|
||||
successJsonDraft.value = value
|
||||
successJsonDirty.value = true
|
||||
successJsonError.value = null
|
||||
}
|
||||
|
||||
function updateErrorJsonDraft(value: string) {
|
||||
errorJsonDraft.value = value
|
||||
errorJsonDirty.value = true
|
||||
errorJsonError.value = null
|
||||
}
|
||||
|
||||
function readJsonRuleArray(
|
||||
parsed: unknown,
|
||||
key: 'success_failover_patterns' | 'error_stop_patterns',
|
||||
): { root: Record<string, unknown>; value: unknown[]; error: string | null } {
|
||||
if (Array.isArray(parsed)) return { root: {}, value: parsed, error: null }
|
||||
if (!isJsonObject(parsed)) return { root: {}, value: [], error: '规则 JSON 必须是数组或对象' }
|
||||
const root = isJsonObject(parsed.failover_rules) ? parsed.failover_rules : parsed
|
||||
const raw = root[key]
|
||||
if (raw === undefined || raw === null) return { root, value: [], error: null }
|
||||
if (!Array.isArray(raw)) return { root, value: [], error: `${key} 必须是数组或 null` }
|
||||
return { root, value: raw, error: null }
|
||||
}
|
||||
|
||||
function parseJsonStatusCodes(
|
||||
value: unknown,
|
||||
label: string,
|
||||
required: boolean,
|
||||
): { codes: number[]; error: string | null } {
|
||||
if (value === undefined || value === null) {
|
||||
return required
|
||||
? { codes: [], error: `${label}status_codes 必填` }
|
||||
: { codes: [], error: null }
|
||||
}
|
||||
if (!Array.isArray(value)) return { codes: [], error: `${label}status_codes 必须是数组` }
|
||||
const codes: number[] = []
|
||||
for (const item of value) {
|
||||
if (!Number.isInteger(item)) {
|
||||
return { codes: [], error: `${label}status_codes 只能包含整数` }
|
||||
}
|
||||
const code = item as number
|
||||
if (code < 100 || code > 599) {
|
||||
return { codes: [], error: `${label}status_codes 只能填写 100-599` }
|
||||
}
|
||||
codes.push(code)
|
||||
}
|
||||
const uniqueCodes = uniqueStatusCodes(codes)
|
||||
if (required && uniqueCodes.length === 0) {
|
||||
return { codes: [], error: `${label}status_codes 必填` }
|
||||
}
|
||||
return { codes: uniqueCodes, error: null }
|
||||
}
|
||||
|
||||
function parseJsonSuccessRule(rule: unknown, index: number): { rule: FailoverRuleItem | null; error: string | null } {
|
||||
const label = `成功转移 JSON 第 ${index + 1} 条:`
|
||||
if (!isJsonObject(rule)) return { rule: null, error: `${label}必须是对象` }
|
||||
if (typeof rule.pattern !== 'string' || !rule.pattern.trim()) {
|
||||
return { rule: null, error: `${label}pattern 必填` }
|
||||
}
|
||||
const pattern = rule.pattern.trim()
|
||||
const patternError = validatePattern(pattern)
|
||||
if (patternError) return { rule: null, error: `${label}${patternError}` }
|
||||
const status = parseJsonStatusCodes(rule.status_codes, label, false)
|
||||
if (status.error) return { rule: null, error: status.error }
|
||||
|
||||
return {
|
||||
rule: {
|
||||
pattern,
|
||||
description: typeof rule.description === 'string' ? rule.description : undefined,
|
||||
status_codes: status.codes.length > 0 ? status.codes : undefined,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonErrorRule(rule: unknown, index: number): { rule: FailoverRuleItem | null; error: string | null } {
|
||||
const label = `错误终止 JSON 第 ${index + 1} 条:`
|
||||
if (!isJsonObject(rule)) return { rule: null, error: `${label}必须是对象` }
|
||||
const status = parseJsonStatusCodes(rule.status_codes, label, false)
|
||||
if (status.error) return { rule: null, error: status.error }
|
||||
if (rule.pattern !== undefined && rule.pattern !== null && typeof rule.pattern !== 'string') {
|
||||
return { rule: null, error: `${label}pattern 必须是字符串` }
|
||||
}
|
||||
const pattern = typeof rule.pattern === 'string' ? rule.pattern.trim() : ''
|
||||
const patternError = validateOptionalPattern(pattern)
|
||||
if (patternError) return { rule: null, error: `${label}${patternError}` }
|
||||
if (status.codes.length === 0 && !pattern) {
|
||||
return { rule: null, error: `${label}status_codes 和 pattern 至少填写一个` }
|
||||
}
|
||||
|
||||
return {
|
||||
rule: {
|
||||
pattern,
|
||||
description: typeof rule.description === 'string' ? rule.description : undefined,
|
||||
status_codes: status.codes.length > 0 ? status.codes : undefined,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
function parseSuccessRulesJsonDraft(draft: string): { value: FailoverRuleItem[] | null; error: string | null } {
|
||||
const raw = draft.trim()
|
||||
if (!raw) return { value: [], error: null }
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (error: unknown) {
|
||||
return { value: null, error: error instanceof Error ? error.message : 'JSON 格式无效' }
|
||||
}
|
||||
|
||||
const rules = readJsonRuleArray(parsed, 'success_failover_patterns')
|
||||
if (rules.error) return { value: null, error: rules.error }
|
||||
|
||||
const normalized: FailoverRuleItem[] = []
|
||||
for (let i = 0; i < rules.value.length; i++) {
|
||||
const parsedRule = parseJsonSuccessRule(rules.value[i], i)
|
||||
if (parsedRule.error || !parsedRule.rule) return { value: null, error: parsedRule.error }
|
||||
normalized.push(parsedRule.rule)
|
||||
}
|
||||
return { value: normalized, error: null }
|
||||
}
|
||||
|
||||
function parseErrorRulesJsonDraft(draft: string): { value: FailoverRuleItem[] | null; error: string | null } {
|
||||
const raw = draft.trim()
|
||||
if (!raw) return { value: [], error: null }
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (error: unknown) {
|
||||
return { value: null, error: error instanceof Error ? error.message : 'JSON 格式无效' }
|
||||
}
|
||||
|
||||
const rules = readJsonRuleArray(parsed, 'error_stop_patterns')
|
||||
if (rules.error) return { value: null, error: rules.error }
|
||||
|
||||
const normalized: FailoverRuleItem[] = []
|
||||
for (let i = 0; i < rules.value.length; i++) {
|
||||
const parsedRule = parseJsonErrorRule(rules.value[i], i)
|
||||
if (parsedRule.error || !parsedRule.rule) return { value: null, error: parsedRule.error }
|
||||
normalized.push(parsedRule.rule)
|
||||
}
|
||||
|
||||
for (const key of TOP_LEVEL_STOP_STATUS_CODE_KEYS) {
|
||||
const status = parseJsonStatusCodes(rules.root[key], `${key}:`, false)
|
||||
if (status.error) return { value: null, error: status.error }
|
||||
if (status.codes.length > 0) {
|
||||
normalized.push({
|
||||
pattern: '',
|
||||
description: '',
|
||||
status_codes: status.codes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { value: normalized, error: null }
|
||||
}
|
||||
|
||||
function applySuccessJsonDraft(options: { notify?: boolean; notifyError?: boolean } = {}): boolean {
|
||||
const notifyError = options.notifyError !== false
|
||||
const parsed = parseSuccessRulesJsonDraft(successJsonDraft.value)
|
||||
if (!parsed.value) {
|
||||
successJsonError.value = parsed.error
|
||||
if (notifyError) showError(parsed.error || '成功转移规则 JSON 无效', '验证失败')
|
||||
return false
|
||||
}
|
||||
|
||||
successPatterns.value = parsed.value.map(rule => ({ ...rule }))
|
||||
successJsonDraft.value = stringifyRuleArray(parsed.value)
|
||||
successJsonError.value = null
|
||||
successJsonDirty.value = false
|
||||
if (options.notify !== false) success('成功转移 JSON 已应用')
|
||||
return true
|
||||
}
|
||||
|
||||
function applyErrorJsonDraft(options: { notify?: boolean; notifyError?: boolean } = {}): boolean {
|
||||
const notifyError = options.notifyError !== false
|
||||
const parsed = parseErrorRulesJsonDraft(errorJsonDraft.value)
|
||||
if (!parsed.value) {
|
||||
errorJsonError.value = parsed.error
|
||||
if (notifyError) showError(parsed.error || '错误终止规则 JSON 无效', '验证失败')
|
||||
return false
|
||||
}
|
||||
|
||||
errorPatterns.value = parsed.value.map(rule => ({ ...rule }))
|
||||
statusCodeInputs.value = errorPatterns.value.map(rule => rule.status_codes?.join(',') || '')
|
||||
errorJsonDraft.value = stringifyRuleArray(parsed.value)
|
||||
errorJsonError.value = null
|
||||
errorJsonDirty.value = false
|
||||
if (options.notify !== false) success('错误终止 JSON 已应用')
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleSuccessJsonMode() {
|
||||
if (successJsonMode.value) {
|
||||
if (successJsonDirty.value && !applySuccessJsonDraft({ notify: false })) return
|
||||
successJsonMode.value = false
|
||||
return
|
||||
}
|
||||
refreshSuccessJsonDraft()
|
||||
successJsonMode.value = true
|
||||
}
|
||||
|
||||
function toggleErrorJsonMode() {
|
||||
if (errorJsonMode.value) {
|
||||
if (errorJsonDirty.value && !applyErrorJsonDraft({ notify: false })) return
|
||||
errorJsonMode.value = false
|
||||
return
|
||||
}
|
||||
refreshErrorJsonDraft()
|
||||
errorJsonMode.value = true
|
||||
}
|
||||
|
||||
function formatSuccessJsonDraft() {
|
||||
const currentDraft = successJsonDraft.value
|
||||
const parsed = parseSuccessRulesJsonDraft(currentDraft)
|
||||
if (!parsed.value) {
|
||||
successJsonError.value = parsed.error
|
||||
return
|
||||
}
|
||||
const formattedDraft = stringifyRuleArray(parsed.value)
|
||||
successJsonDraft.value = formattedDraft
|
||||
successJsonError.value = null
|
||||
if (formattedDraft !== currentDraft) {
|
||||
successJsonDirty.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorJsonDraft() {
|
||||
const currentDraft = errorJsonDraft.value
|
||||
const parsed = parseErrorRulesJsonDraft(currentDraft)
|
||||
if (!parsed.value) {
|
||||
errorJsonError.value = parsed.error
|
||||
return
|
||||
}
|
||||
const formattedDraft = stringifyRuleArray(parsed.value)
|
||||
errorJsonDraft.value = formattedDraft
|
||||
errorJsonError.value = null
|
||||
if (formattedDraft !== currentDraft) {
|
||||
errorJsonDirty.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.provider) return
|
||||
|
||||
if (successJsonMode.value && !applySuccessJsonDraft({ notify: false })) return
|
||||
if (errorJsonMode.value && !applyErrorJsonDraft({ notify: false })) return
|
||||
|
||||
// Validate patterns
|
||||
const allPatterns = [...successPatterns.value, ...errorPatterns.value]
|
||||
for (const rule of allPatterns) {
|
||||
for (const rule of successPatterns.value) {
|
||||
const err = validatePattern(rule.pattern)
|
||||
if (err) {
|
||||
showError(err, '验证失败')
|
||||
@@ -247,23 +744,39 @@ async function handleSave() {
|
||||
showError(`状态码格式错误: ${result.reason},请输入 100-599 之间的整数,多个用逗号分隔`, '验证失败')
|
||||
return
|
||||
}
|
||||
errorPatterns.value[i].status_codes = result.codes
|
||||
const patternErr = validateOptionalPattern(errorPatterns.value[i].pattern)
|
||||
if (patternErr) {
|
||||
showError(patternErr, '验证失败')
|
||||
return
|
||||
}
|
||||
const pattern = errorPatterns.value[i].pattern.trim()
|
||||
if (result.codes.length === 0 && !pattern) {
|
||||
showError(`第 ${i + 1} 条错误终止规则:状态码和正则内容至少填写一个`, '验证失败')
|
||||
return
|
||||
}
|
||||
errorPatterns.value[i].status_codes = result.codes.length > 0 ? result.codes : undefined
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const filteredSuccess = successPatterns.value.filter(r => r.pattern.trim())
|
||||
const filteredError = errorPatterns.value.filter(r => r.pattern.trim())
|
||||
const filteredSuccess = successPatterns.value
|
||||
.map(r => ({ ...r, pattern: r.pattern.trim() }))
|
||||
.filter(r => r.pattern)
|
||||
const filteredError = errorPatterns.value
|
||||
.map(r => {
|
||||
const statusCodes = r.status_codes?.length ? r.status_codes : undefined
|
||||
return {
|
||||
...r,
|
||||
pattern: r.pattern.trim(),
|
||||
status_codes: statusCodes,
|
||||
}
|
||||
})
|
||||
.filter(r => r.pattern || (r.status_codes?.length || 0) > 0)
|
||||
|
||||
const hasRules = filteredSuccess.length > 0 || filteredError.length > 0
|
||||
const nextFailoverRules = buildNextFailoverRules(filteredSuccess, filteredError)
|
||||
|
||||
await updateProvider(props.provider.id, {
|
||||
failover_rules: hasRules
|
||||
? {
|
||||
success_failover_patterns: filteredSuccess,
|
||||
error_stop_patterns: filteredError,
|
||||
}
|
||||
: null,
|
||||
failover_rules: nextFailoverRules,
|
||||
})
|
||||
|
||||
success('故障转移规则已保存')
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="modelValue"
|
||||
title="提供商批量处理"
|
||||
:description="dialogDescription"
|
||||
:icon="Users"
|
||||
size="2xl"
|
||||
persistent
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="space-y-3.5">
|
||||
<!-- 步骤 1:选择动作(顶部分段控件) -->
|
||||
<div>
|
||||
<div class="grid grid-cols-3 gap-1.5 rounded-xl border bg-muted/30 p-1.5">
|
||||
<button
|
||||
v-for="action in actionOptions"
|
||||
:key="action.value"
|
||||
type="button"
|
||||
class="group flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-60"
|
||||
:class="getSegmentClass(action)"
|
||||
:disabled="executing"
|
||||
@click="selectedAction = action.value"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span>{{ action.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="mt-2 flex items-center gap-1.5 px-1 text-xs leading-relaxed"
|
||||
:class="selectedActionMeta?.destructive ? 'text-destructive' : 'text-muted-foreground'"
|
||||
>
|
||||
<AlertTriangle
|
||||
v-if="selectedActionMeta?.destructive"
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<span>{{ selectedActionMeta?.hint }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 2:筛选 + 选择 -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<Search class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchText"
|
||||
class="h-9 w-full pl-9"
|
||||
placeholder="搜索提供商名称 / 类型 / 备注"
|
||||
:disabled="executing"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex h-9 cursor-pointer items-center gap-2 rounded-md border bg-background px-3 text-xs text-muted-foreground transition-colors hover:bg-muted/40">
|
||||
<Checkbox
|
||||
:checked="allFilteredSelected"
|
||||
:disabled="filteredProviders.length === 0 || executing"
|
||||
@update:checked="toggleFilteredSelection"
|
||||
/>
|
||||
<span>全选{{ searchText.trim() ? '筛选结果' : '本页' }}</span>
|
||||
</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-9 px-3 text-xs"
|
||||
:disabled="selectedCount === 0 || executing"
|
||||
@click="clearSelection"
|
||||
>
|
||||
清空选择
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 提供商列表(占满整宽) -->
|
||||
<div class="flex min-w-0 flex-col overflow-hidden rounded-lg border">
|
||||
<div class="grid grid-cols-[1.75rem_minmax(0,1fr)_8.5rem] items-center gap-2 border-b bg-muted/30 px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Checkbox
|
||||
:checked="allFilteredSelected"
|
||||
:disabled="filteredProviders.length === 0 || executing"
|
||||
@update:checked="toggleFilteredSelection"
|
||||
/>
|
||||
<span class="normal-case tracking-normal">提供商</span>
|
||||
<span class="text-right normal-case tracking-normal">资源(活跃 / 总数)</span>
|
||||
</div>
|
||||
<div class="max-h-[min(52vh,440px)] overflow-y-auto">
|
||||
<div
|
||||
v-if="filteredProviders.length === 0"
|
||||
class="flex flex-col items-center justify-center gap-1.5 py-14 text-center"
|
||||
>
|
||||
<Search class="h-6 w-6 text-muted-foreground/50" />
|
||||
<span class="text-sm text-muted-foreground">无匹配提供商</span>
|
||||
</div>
|
||||
<label
|
||||
v-for="provider in filteredProviders"
|
||||
:key="provider.id"
|
||||
class="grid cursor-pointer grid-cols-[1.75rem_minmax(0,1fr)_8.5rem] items-center gap-2 border-b px-3 py-2.5 transition-colors last:border-b-0"
|
||||
:class="rowClass(provider.id)"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedIdSet.has(provider.id)"
|
||||
:disabled="executing"
|
||||
@update:checked="(checked) => toggleProvider(provider.id, checked)"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="truncate text-sm font-medium">{{ provider.name }}</span>
|
||||
<Badge
|
||||
:variant="provider.is_active ? 'success' : 'secondary'"
|
||||
class="shrink-0 text-[10px]"
|
||||
>
|
||||
{{ provider.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span class="shrink-0 rounded bg-muted px-1.5 py-px font-mono text-[10px]">{{ provider.provider_type || 'custom' }}</span>
|
||||
<span
|
||||
v-if="provider.description"
|
||||
class="truncate"
|
||||
>{{ provider.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-0.5 text-[11px] leading-tight text-muted-foreground">
|
||||
<span><span class="text-muted-foreground/70">端点</span> <span class="font-medium tabular-nums text-foreground/80">{{ provider.active_endpoints }}/{{ provider.total_endpoints }}</span></span>
|
||||
<span><span class="text-muted-foreground/70">账号</span> <span class="font-medium tabular-nums text-foreground/80">{{ provider.active_keys }}/{{ provider.total_keys }}</span></span>
|
||||
<span><span class="text-muted-foreground/70">模型</span> <span class="font-medium tabular-nums text-foreground/80">{{ provider.active_models }}/{{ provider.total_models }}</span></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 执行进度 / 结果 -->
|
||||
<div
|
||||
v-if="executing"
|
||||
class="space-y-1.5 rounded-lg border bg-muted/15 px-3 py-2.5"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="truncate text-foreground">{{ progressLabel }}</span>
|
||||
<span class="shrink-0 font-medium tabular-nums text-muted-foreground">{{ progressDone }} / {{ progressTotal }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-150"
|
||||
:class="selectedActionMeta?.destructive ? 'bg-destructive' : 'bg-primary'"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="lastResultMessage"
|
||||
class="rounded-lg border bg-background px-3 py-2.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ lastResultMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>已选 <span class="font-semibold tabular-nums text-foreground">{{ selectedCount }}</span></span>
|
||||
<span class="text-border">·</span>
|
||||
<span>本页 <span class="tabular-nums">{{ providers.length }}</span></span>
|
||||
<span class="text-emerald-600 dark:text-emerald-400">活跃 <span class="tabular-nums">{{ activeProviderCount }}</span></span>
|
||||
<span>停用 <span class="tabular-nums">{{ inactiveProviderCount }}</span></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="executing"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
:variant="selectedAction === 'delete' ? 'destructive' : 'default'"
|
||||
:disabled="!canExecute"
|
||||
@click="confirmAndExecute"
|
||||
>
|
||||
{{ executeButtonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, type Component } from 'vue'
|
||||
import { AlertTriangle, Power, PowerOff, Search, Trash2, Users } from 'lucide-vue-next'
|
||||
import { Badge, Button, Checkbox, Dialog, Input } from '@/components/ui'
|
||||
import {
|
||||
deleteProvider,
|
||||
getProviderDeleteTask,
|
||||
updateProvider,
|
||||
type ProviderWithEndpointsSummary,
|
||||
} from '@/api/endpoints'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
|
||||
type ProviderBatchAction = 'enable' | 'disable' | 'delete'
|
||||
|
||||
interface ProviderBatchActionOption {
|
||||
value: ProviderBatchAction
|
||||
label: string
|
||||
hint: string
|
||||
icon: Component
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
providers: ProviderWithEndpointsSummary[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
const { success, warning, error: showError } = useToast()
|
||||
|
||||
const actionOptions: ProviderBatchActionOption[] = [
|
||||
{ value: 'enable', label: '启用', hint: '恢复所选提供商参与调度。', icon: Power },
|
||||
{ value: 'disable', label: '停用', hint: '停止所选提供商参与调度,保留配置。', icon: PowerOff },
|
||||
{ value: 'delete', label: '删除', hint: '永久删除所选提供商及其端点、账号和配置,此操作不可恢复。', icon: Trash2, destructive: true },
|
||||
]
|
||||
|
||||
const SEGMENT_ACCENT: Record<ProviderBatchAction, string> = {
|
||||
enable: 'bg-background text-emerald-600 shadow-sm ring-1 ring-emerald-500/25 dark:text-emerald-400',
|
||||
disable: 'bg-background text-primary shadow-sm ring-1 ring-primary/25',
|
||||
delete: 'bg-background text-destructive shadow-sm ring-1 ring-destructive/30',
|
||||
}
|
||||
|
||||
const searchText = ref('')
|
||||
const selectedProviderIds = ref<string[]>([])
|
||||
const selectedAction = ref<ProviderBatchAction>('disable')
|
||||
const executing = ref(false)
|
||||
const progressDone = ref(0)
|
||||
const progressTotal = ref(0)
|
||||
const progressLabel = ref('')
|
||||
const lastResultMessage = ref('')
|
||||
|
||||
const dialogDescription = computed(() => '批量启用、停用或删除当前页提供商')
|
||||
const selectedIdSet = computed(() => new Set(selectedProviderIds.value))
|
||||
const selectedCount = computed(() => selectedProviderIds.value.length)
|
||||
const selectedActionLabel = computed(() => actionOptions.find(action => action.value === selectedAction.value)?.label || '')
|
||||
const selectedActionMeta = computed(() => actionOptions.find(action => action.value === selectedAction.value))
|
||||
const executeButtonLabel = computed(() => {
|
||||
if (executing.value) return '执行中...'
|
||||
if (selectedCount.value > 0) return `${selectedActionLabel.value} ${selectedCount.value} 项`
|
||||
return `执行${selectedActionLabel.value}`
|
||||
})
|
||||
const activeProviderCount = computed(() => props.providers.filter(provider => provider.is_active).length)
|
||||
const inactiveProviderCount = computed(() => props.providers.length - activeProviderCount.value)
|
||||
const providerById = computed(() => new Map(props.providers.map(provider => [provider.id, provider])))
|
||||
const filteredProviders = computed(() => {
|
||||
const keyword = searchText.value.trim().toLowerCase()
|
||||
if (!keyword) return props.providers
|
||||
return props.providers.filter((provider) => {
|
||||
return [
|
||||
provider.name,
|
||||
provider.provider_type,
|
||||
provider.description,
|
||||
provider.website,
|
||||
].some(value => String(value || '').toLowerCase().includes(keyword))
|
||||
})
|
||||
})
|
||||
const allFilteredSelected = computed(() => {
|
||||
return filteredProviders.value.length > 0
|
||||
&& filteredProviders.value.every(provider => selectedIdSet.value.has(provider.id))
|
||||
})
|
||||
const progressPercent = computed(() => {
|
||||
if (progressTotal.value <= 0) return 0
|
||||
return Math.min(100, Math.round((progressDone.value / progressTotal.value) * 100))
|
||||
})
|
||||
const canExecute = computed(() => selectedCount.value > 0 && !executing.value)
|
||||
|
||||
function toggleProvider(providerId: string, checked: boolean): void {
|
||||
const next = new Set(selectedProviderIds.value)
|
||||
if (checked) next.add(providerId)
|
||||
else next.delete(providerId)
|
||||
selectedProviderIds.value = [...next]
|
||||
}
|
||||
|
||||
function toggleFilteredSelection(checked: boolean): void {
|
||||
const next = new Set(selectedProviderIds.value)
|
||||
for (const provider of filteredProviders.value) {
|
||||
if (checked) next.add(provider.id)
|
||||
else next.delete(provider.id)
|
||||
}
|
||||
selectedProviderIds.value = [...next]
|
||||
}
|
||||
|
||||
function clearSelection(): void {
|
||||
selectedProviderIds.value = []
|
||||
}
|
||||
|
||||
function handleDialogUpdate(open: boolean): void {
|
||||
if (executing.value && !open) return
|
||||
emit('update:modelValue', open)
|
||||
}
|
||||
|
||||
function getSegmentClass(action: ProviderBatchActionOption): string {
|
||||
if (selectedAction.value !== action.value) {
|
||||
return 'text-muted-foreground hover:bg-background/60 hover:text-foreground'
|
||||
}
|
||||
return SEGMENT_ACCENT[action.value]
|
||||
}
|
||||
|
||||
function rowClass(providerId: string): string {
|
||||
if (!selectedIdSet.value.has(providerId)) {
|
||||
return 'hover:bg-muted/40'
|
||||
}
|
||||
return selectedActionMeta.value?.destructive
|
||||
? 'bg-destructive/5 hover:bg-destructive/10'
|
||||
: 'bg-primary/5 hover:bg-primary/10'
|
||||
}
|
||||
|
||||
async function confirmAndExecute(): Promise<void> {
|
||||
if (!canExecute.value) return
|
||||
const action = actionOptions.find(item => item.value === selectedAction.value)
|
||||
const actionLabel = action?.label || '批量操作'
|
||||
const confirmed = await confirm({
|
||||
title: `批量${actionLabel}提供商`,
|
||||
message: selectedAction.value === 'delete'
|
||||
? `将删除 ${selectedCount.value} 个提供商,并同时删除其所有端点、账号和配置。此操作不可恢复,是否继续?`
|
||||
: `将对 ${selectedCount.value} 个提供商执行:${actionLabel},是否继续?`,
|
||||
confirmText: selectedAction.value === 'delete' ? '确认删除' : '确认执行',
|
||||
...(selectedAction.value === 'delete' ? { variant: 'destructive' as const } : {}),
|
||||
})
|
||||
if (!confirmed) return
|
||||
await executeBatchAction()
|
||||
}
|
||||
|
||||
const DELETE_POLL_INTERVAL_MS = 2000
|
||||
const DELETE_POLL_MAX_MS = 30 * 60 * 1000
|
||||
const DELETE_POLL_MAX_FAILURES = 3
|
||||
|
||||
async function pollProviderDeleteTask(providerId: string, taskId: string): Promise<void> {
|
||||
const deadline = Date.now() + DELETE_POLL_MAX_MS
|
||||
let consecutiveFailures = 0
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const task = await getProviderDeleteTask(providerId, taskId)
|
||||
consecutiveFailures = 0
|
||||
if (task.status === 'completed') return
|
||||
if (task.status === 'failed') {
|
||||
throw new Error(task.message || 'provider delete task failed')
|
||||
}
|
||||
} catch (err) {
|
||||
consecutiveFailures += 1
|
||||
if (consecutiveFailures >= DELETE_POLL_MAX_FAILURES) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, DELETE_POLL_INTERVAL_MS))
|
||||
}
|
||||
throw new Error('provider delete task timeout')
|
||||
}
|
||||
|
||||
async function executeBatchAction(): Promise<void> {
|
||||
if (executing.value) return
|
||||
|
||||
const targets = selectedProviderIds.value
|
||||
.map(id => providerById.value.get(id))
|
||||
.filter((provider): provider is ProviderWithEndpointsSummary => Boolean(provider))
|
||||
if (targets.length === 0) {
|
||||
warning('请先选择提供商')
|
||||
return
|
||||
}
|
||||
|
||||
executing.value = true
|
||||
progressDone.value = 0
|
||||
progressTotal.value = targets.length
|
||||
lastResultMessage.value = ''
|
||||
let successCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
try {
|
||||
for (const provider of targets) {
|
||||
progressLabel.value = `正在${selectedActionLabel.value}:${provider.name}`
|
||||
try {
|
||||
if (selectedAction.value === 'delete') {
|
||||
const result = await deleteProvider(provider.id)
|
||||
await pollProviderDeleteTask(provider.id, result.task_id)
|
||||
} else {
|
||||
await updateProvider(provider.id, { is_active: selectedAction.value === 'enable' })
|
||||
}
|
||||
successCount += 1
|
||||
} catch (err) {
|
||||
failedCount += 1
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[ProviderBatchActionDialog] ${selectedAction.value} failed (${provider.id}):`, err)
|
||||
} finally {
|
||||
progressDone.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
lastResultMessage.value = `执行完成:成功 ${successCount},失败 ${failedCount}`
|
||||
if (failedCount > 0) warning(lastResultMessage.value)
|
||||
else success(lastResultMessage.value)
|
||||
if (successCount > 0) {
|
||||
clearSelection()
|
||||
emit('changed')
|
||||
}
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '批量处理提供商失败'), '错误')
|
||||
} finally {
|
||||
executing.value = false
|
||||
progressDone.value = 0
|
||||
progressTotal.value = 0
|
||||
progressLabel.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
searchText.value = ''
|
||||
selectedProviderIds.value = []
|
||||
selectedAction.value = 'disable'
|
||||
lastResultMessage.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.providers.map(provider => provider.id),
|
||||
() => {
|
||||
const availableIds = new Set(props.providers.map(provider => provider.id))
|
||||
selectedProviderIds.value = selectedProviderIds.value.filter(id => availableIds.has(id))
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -1627,11 +1627,23 @@ const antigravityQuotaDialogKey = ref<EndpointAPIKey | null>(null)
|
||||
|
||||
// 故障转移规则
|
||||
const failoverRulesDialogOpen = ref(false)
|
||||
const FAILOVER_RULE_ARRAY_KEYS = [
|
||||
'success_failover_patterns',
|
||||
'error_stop_patterns',
|
||||
'stop_status_codes',
|
||||
'stop_on_status_codes',
|
||||
'early_stop_status_codes',
|
||||
'non_retryable_status_codes',
|
||||
'continue_on_status_codes',
|
||||
'retryable_status_codes',
|
||||
'retry_on_status_codes',
|
||||
'continue_status_codes',
|
||||
] as const
|
||||
const hasFailoverRules = computed(() => {
|
||||
const rules = provider.value?.failover_rules
|
||||
if (!rules) return false
|
||||
return (rules.success_failover_patterns?.length || 0) > 0
|
||||
|| (rules.error_stop_patterns?.length || 0) > 0
|
||||
return FAILOVER_RULE_ARRAY_KEYS.some(key => (rules[key]?.length || 0) > 0)
|
||||
|| typeof rules.max_retries === 'number'
|
||||
})
|
||||
|
||||
// Provider 级别代理配置状态
|
||||
|
||||
@@ -112,6 +112,16 @@
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="批量处理提供商"
|
||||
:disabled="loading"
|
||||
@click="$emit('batchProcess')"
|
||||
>
|
||||
<Users class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -131,7 +141,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search, Plus, ChevronDown, FilterX } from 'lucide-vue-next'
|
||||
import { Search, Plus, ChevronDown, FilterX, Users } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
@@ -162,6 +172,7 @@ defineEmits<{
|
||||
'update:filterModel': [value: string]
|
||||
'resetFilters': []
|
||||
'openPriorityDialog': []
|
||||
'batchProcess': []
|
||||
'addProvider': []
|
||||
'refresh': []
|
||||
}>()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as ProviderFormDialog } from './ProviderFormDialog.vue'
|
||||
export { default as ProviderBatchActionDialog } from './ProviderBatchActionDialog.vue'
|
||||
export { default as EndpointFormDialog } from './EndpointFormDialog.vue'
|
||||
export { default as KeyFormDialog } from './KeyFormDialog.vue'
|
||||
export { default as KeyAllowedModelsDialog } from './KeyAllowedModelsDialog.vue'
|
||||
|
||||
@@ -7,11 +7,13 @@ import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
createdAt?: string | null
|
||||
responseTimeUpdatedAt?: string | null
|
||||
status?: string | null
|
||||
responseTimeMs?: number | null
|
||||
precision?: number
|
||||
}>(), {
|
||||
createdAt: null,
|
||||
responseTimeUpdatedAt: null,
|
||||
status: null,
|
||||
responseTimeMs: null,
|
||||
precision: 2,
|
||||
@@ -30,6 +32,10 @@ function parseCreatedAtMs(value: string | null | undefined): number {
|
||||
return new Date(normalized).getTime()
|
||||
}
|
||||
|
||||
function finiteNonNegativeMs(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
function stopRaf() {
|
||||
if (rafId == null) return
|
||||
cancelAnimationFrame(rafId)
|
||||
@@ -61,8 +67,16 @@ onUnmounted(() => {
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!isActive.value) {
|
||||
if (props.responseTimeMs == null) return '-'
|
||||
return `${(props.responseTimeMs / 1000).toFixed(precision.value)}s`
|
||||
const responseTimeMs = finiteNonNegativeMs(props.responseTimeMs)
|
||||
if (responseTimeMs == null) return '-'
|
||||
return `${(responseTimeMs / 1000).toFixed(precision.value)}s`
|
||||
}
|
||||
|
||||
const responseTimeMs = finiteNonNegativeMs(props.responseTimeMs)
|
||||
const updatedAtMs = parseCreatedAtMs(props.responseTimeUpdatedAt)
|
||||
if (responseTimeMs != null && !Number.isNaN(updatedAtMs)) {
|
||||
const elapsedSinceUpdateMs = Math.max(0, now.value - updatedAtMs)
|
||||
return `${((responseTimeMs + elapsedSinceUpdateMs) / 1000).toFixed(precision.value)}s`
|
||||
}
|
||||
|
||||
if (!props.createdAt) return '-'
|
||||
|
||||
@@ -1401,9 +1401,8 @@ const currentAttemptFailureDiagnostic = computed<{
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt) return null
|
||||
const extra = extractObject(attempt.extra_data)
|
||||
const failureDiagnostic = extractObject(extra?.failure_diagnostic)
|
||||
const safeToShow = failureDiagnostic?.safe_to_show !== false
|
||||
const error = failureDiagnostic && safeToShow
|
||||
const failureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
||||
const error = failureDiagnostic
|
||||
? failureDiagnostic
|
||||
: extractObject(extra?.request_body_build_error)
|
||||
const path = typeof error?.path === 'string' && error.path.trim()
|
||||
@@ -1415,19 +1414,205 @@ const currentAttemptFailureDiagnostic = computed<{
|
||||
if (!path && !message) return null
|
||||
return {
|
||||
path: path || '$',
|
||||
message: message || '请求体转换失败',
|
||||
message: formatAttemptErrorMessage(message) || message || '请求体转换失败',
|
||||
}
|
||||
})
|
||||
|
||||
const isGenericExecutionRuntimeStatusMessage = (message: string): boolean =>
|
||||
/execution runtime (stream )?returned non-success status \d+/i.test(message)
|
||||
|
||||
const isLocalSyncFinalizeDiagnostic = (message: string): boolean =>
|
||||
/local sync attempt failed before terminal finalization/i.test(message)
|
||||
|| /unsupported provider stream (event|finish reason)/i.test(message)
|
||||
|
||||
const isActionableDiagnosticMessage = (message: string): boolean =>
|
||||
isLocalSyncFinalizeDiagnostic(message) || isConversionDiagnosticMessage(message)
|
||||
|
||||
const extractVisibleFailureDiagnostic = (
|
||||
extra: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null => {
|
||||
const failureDiagnostic = extractObject(extra?.failure_diagnostic)
|
||||
if (!failureDiagnostic || failureDiagnostic.safe_to_show === false) return null
|
||||
return failureDiagnostic
|
||||
}
|
||||
|
||||
const extractVisibleDiagnosticObjects = (
|
||||
extra: Record<string, unknown> | null | undefined,
|
||||
): Array<Record<string, unknown>> => [
|
||||
extractVisibleFailureDiagnostic(extra),
|
||||
extractObject(extra?.request_conversion_error),
|
||||
extractObject(extra?.request_body_build_error),
|
||||
].filter((value): value is Record<string, unknown> => Boolean(value))
|
||||
|
||||
const extractVisibleDiagnosticMessage = (
|
||||
extra: Record<string, unknown> | null | undefined,
|
||||
): string => {
|
||||
for (const diagnostic of extractVisibleDiagnosticObjects(extra)) {
|
||||
const message = readStringField(diagnostic, 'message')
|
||||
if (message) return message
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const chooseAttemptRawErrorMessage = (
|
||||
flowMessage: string,
|
||||
fallbackMessage: string,
|
||||
diagnosticMessage: string,
|
||||
): string => {
|
||||
const flow = flowMessage.trim()
|
||||
const fallback = fallbackMessage.trim()
|
||||
const diagnostic = diagnosticMessage.trim()
|
||||
|
||||
if (flow && fallback && !isActionableDiagnosticMessage(flow) && isActionableDiagnosticMessage(fallback)) {
|
||||
return fallback
|
||||
}
|
||||
if (flow && diagnostic && isGenericExecutionRuntimeStatusMessage(flow) && isActionableDiagnosticMessage(diagnostic)) {
|
||||
return diagnostic
|
||||
}
|
||||
if (fallback && diagnostic && isGenericExecutionRuntimeStatusMessage(fallback) && isActionableDiagnosticMessage(diagnostic)) {
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
return flow || fallback || diagnostic
|
||||
}
|
||||
|
||||
const decodeRustDebugString = (value: string): string => {
|
||||
try {
|
||||
return JSON.parse(`"${value}"`)
|
||||
} catch {
|
||||
return value.replace(/\\"/g, '"')
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeDiagnosticFieldPath = (field: string): string => {
|
||||
const trimmed = field.trim()
|
||||
if (!trimmed || trimmed === '$') return '$'
|
||||
if (trimmed.startsWith('$')) return trimmed
|
||||
if (trimmed.startsWith('[')) return `$${trimmed}`
|
||||
return `$.${trimmed}`
|
||||
}
|
||||
|
||||
const formatConversionPair = (source: string, target: string): string =>
|
||||
`${formatApiFormat(source.trim())} → ${formatApiFormat(target.trim())}`
|
||||
|
||||
const extractFieldDetail = (message: string): string => {
|
||||
const fieldMatch = message.match(/field\s+([^;=]+?)\s*=\s*(\"(?:\\.|[^"\\])*\"|[^;]+)/i)
|
||||
const unsupportedFieldMatch = message.match(/field\s+([^;]+?)\s+is unsupported/i)
|
||||
if (fieldMatch?.[1]) {
|
||||
return `字段 ${normalizeDiagnosticFieldPath(fieldMatch[1])} = ${fieldMatch[2].trim()}`
|
||||
}
|
||||
if (unsupportedFieldMatch?.[1]) {
|
||||
return `字段 ${normalizeDiagnosticFieldPath(unsupportedFieldMatch[1])} 不支持`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatUnsupportedStreamEventMessage = (message: string): string => {
|
||||
const detail = extractFieldDetail(message)
|
||||
const fieldDetail = detail ? `(${detail})` : ''
|
||||
return `流式格式转换失败:上游返回了当前不支持的 stream event${fieldDetail},无法无损转换到客户端请求格式`
|
||||
}
|
||||
|
||||
const formatUnsupportedFinishReasonMessage = (message: string): string => {
|
||||
const fieldDetail = extractFieldDetail(message)
|
||||
const legacyMatch = message.match(/unsupported provider stream finish reason\s+(.+?)\s+cannot be converted losslessly/i)
|
||||
const detail = fieldDetail || (legacyMatch?.[1]
|
||||
? `字段 $.finish_reason = ${legacyMatch[1].trim()}`
|
||||
: '')
|
||||
return `流式格式转换失败:上游返回了当前不支持的 finish reason${detail ? `(${detail})` : ''},无法无损转换到客户端请求格式`
|
||||
}
|
||||
|
||||
const formatKnownConversionErrorMessage = (message: string): string => {
|
||||
const lossy = message.match(/^lossy conversion blocked from\s+(\S+)\s+to\s+(\S+)\s+at\s+([^:]+):\s*(.+)$/i)
|
||||
if (lossy) {
|
||||
return `格式转换失败:${formatConversionPair(lossy[1], lossy[2])} 在字段 ${normalizeDiagnosticFieldPath(lossy[3])} 会丢失信息:${lossy[4].trim()}`
|
||||
}
|
||||
|
||||
const unaudited = message.match(/^unaudited field\s+(.+?)\s+in\s+(.+?)\s+cannot be converted to\s+([^:]+):\s*(.+)$/i)
|
||||
if (unaudited) {
|
||||
return `格式转换失败:${formatConversionPair(unaudited[2], unaudited[3])} 的字段 ${normalizeDiagnosticFieldPath(unaudited[1])} 尚未审计,不能安全转换:${unaudited[4].trim()}`
|
||||
}
|
||||
|
||||
const unsupportedField = message.match(/^unsupported field\s+(.+?)\s+in\s+([^:]+(?::[^:]+)?):\s*(.+)$/i)
|
||||
if (unsupportedField) {
|
||||
return `格式转换失败:${formatApiFormat(unsupportedField[2])} 不支持字段 ${normalizeDiagnosticFieldPath(unsupportedField[1])}:${unsupportedField[3].trim()}`
|
||||
}
|
||||
|
||||
const invalidEnum = message.match(/^invalid enum value\s+(.+?)\s+for\s+(.+)\.([^.\s]+)$/i)
|
||||
if (invalidEnum) {
|
||||
return `格式转换失败:${formatApiFormat(invalidEnum[2])} 字段 ${normalizeDiagnosticFieldPath(invalidEnum[3])} 的枚举值 ${invalidEnum[1].trim()} 无效`
|
||||
}
|
||||
|
||||
const invalidTarget = message.match(/^invalid target field\s+(.+?)\s+for\s+(.+?):\s*(.+)$/i)
|
||||
if (invalidTarget) {
|
||||
return `格式转换失败:目标格式 ${formatApiFormat(invalidTarget[2])} 字段 ${normalizeDiagnosticFieldPath(invalidTarget[1])} 无效:${invalidTarget[3].trim()}`
|
||||
}
|
||||
|
||||
const unsupportedFormat = message.match(/^unsupported AI format:\s*(.+)$/i)
|
||||
if (unsupportedFormat) {
|
||||
return `格式转换失败:不支持的 API 格式 ${unsupportedFormat[1].trim()}`
|
||||
}
|
||||
|
||||
const parseEmit = message.match(/^failed to\s+(parse|emit)\s+(.+?)\s+(request|response)$/i)
|
||||
if (parseEmit) {
|
||||
const action = parseEmit[1].toLowerCase() === 'parse' ? '解析' : '生成'
|
||||
const subject = parseEmit[3].toLowerCase() === 'request' ? '请求体' : '响应体'
|
||||
return `格式转换失败:无法${action} ${formatApiFormat(parseEmit[2])} ${subject}`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const isConversionDiagnosticMessage = (message: string): boolean => {
|
||||
const normalized = message.trim()
|
||||
if (!normalized) return false
|
||||
return /conversion|converted|convertible|cannot be converted|lossy conversion|unsupported field|unaudited field|invalid enum value|invalid target field|unsupported ai format|failed to (parse|emit) .+ (request|response)|unsupported provider stream (event|finish reason)|转换|无损|字段 .*不支持/i
|
||||
.test(normalized)
|
||||
}
|
||||
|
||||
const formatAttemptErrorMessage = (message: string, statusCode?: number): string => {
|
||||
const normalized = message.trim()
|
||||
if (!normalized) return ''
|
||||
if (/execution runtime (stream )?returned non-success status \d+/i.test(normalized)) {
|
||||
const directInternal = normalized.match(/^Internal\("((?:\\.|[^"\\])*)"\)$/i)
|
||||
if (directInternal?.[1]) {
|
||||
return formatAttemptErrorMessage(decodeRustDebugString(directInternal[1]), statusCode)
|
||||
}
|
||||
const localSyncInternal = normalized.match(/local sync attempt failed before terminal finalization:\s*Internal\("((?:\\.|[^"\\])*)"\)/i)
|
||||
if (localSyncInternal?.[1]) {
|
||||
return formatAttemptErrorMessage(decodeRustDebugString(localSyncInternal[1]), statusCode)
|
||||
}
|
||||
if (/unsupported provider stream event cannot be converted losslessly/i.test(normalized)) {
|
||||
return formatUnsupportedStreamEventMessage(normalized)
|
||||
}
|
||||
if (/unsupported provider stream finish reason/i.test(normalized)) {
|
||||
return formatUnsupportedFinishReasonMessage(normalized)
|
||||
}
|
||||
const conversionMessage = formatKnownConversionErrorMessage(normalized)
|
||||
if (conversionMessage) {
|
||||
return conversionMessage
|
||||
}
|
||||
if (isGenericExecutionRuntimeStatusMessage(normalized)) {
|
||||
return statusCode != null ? `上游返回非成功状态 ${statusCode}` : '上游返回非成功状态'
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
const shouldShowAttemptMessageWithUpstreamResponse = (
|
||||
rawMessage: string,
|
||||
upstreamResponse: Record<string, unknown> | null,
|
||||
): boolean => {
|
||||
if (!upstreamResponse) return true
|
||||
const normalized = rawMessage.trim()
|
||||
if (!normalized) return false
|
||||
if (isLocalSyncFinalizeDiagnostic(normalized)) return true
|
||||
if (isConversionDiagnosticMessage(normalized)) return true
|
||||
if (isGenericExecutionRuntimeStatusMessage(normalized)) return false
|
||||
|
||||
const hasBody = hasRenderableValue(upstreamResponse.body)
|
||||
const bodyState = (readStringField(upstreamResponse, 'body_state') ?? '').toLowerCase()
|
||||
return !hasBody && bodyState === 'disabled'
|
||||
}
|
||||
|
||||
const currentAttemptRequestError = computed<{
|
||||
message: string
|
||||
statusCode?: number
|
||||
@@ -1453,17 +1638,177 @@ const currentAttemptRequestError = computed<{
|
||||
const fallbackType = typeof attempt.error_type === 'string' && attempt.error_type.trim()
|
||||
? attempt.error_type.trim()
|
||||
: ''
|
||||
const message = formatAttemptErrorMessage(flowMessage || fallbackMessage, statusCode) || fallbackType
|
||||
const diagnosticMessage = extractVisibleDiagnosticMessage(extra)
|
||||
const rawMessage = chooseAttemptRawErrorMessage(flowMessage || '', fallbackMessage, diagnosticMessage)
|
||||
const message = formatAttemptErrorMessage(rawMessage, statusCode) || fallbackType
|
||||
const upstreamResponseDisplay = normalizeUpstreamResponseDisplay(extra?.upstream_response)
|
||||
if (!message && statusCode == null && !upstreamResponseDisplay) return null
|
||||
const visibleDiagnosticObjects = extractVisibleDiagnosticObjects(extra)
|
||||
const shouldAttachDiagnostic = Boolean(
|
||||
visibleDiagnosticObjects.length
|
||||
|| isLocalSyncFinalizeDiagnostic(rawMessage)
|
||||
|| isConversionDiagnosticMessage(rawMessage),
|
||||
)
|
||||
const diagnostic = shouldAttachDiagnostic
|
||||
? buildAttemptDiagnosticPayload(
|
||||
attempt,
|
||||
message || fallbackType || rawMessage || '未知失败',
|
||||
statusCode,
|
||||
upstreamResponseDisplay,
|
||||
rawMessage,
|
||||
)
|
||||
: null
|
||||
const upstreamResponseWithDiagnostic = diagnostic
|
||||
? { ...(upstreamResponseDisplay ?? {}), diagnostic }
|
||||
: upstreamResponseDisplay
|
||||
if (!message && statusCode == null && !upstreamResponseWithDiagnostic) return null
|
||||
const showMessage = shouldShowAttemptMessageWithUpstreamResponse(
|
||||
rawMessage || fallbackType,
|
||||
upstreamResponseDisplay,
|
||||
)
|
||||
|
||||
return {
|
||||
message: upstreamResponseDisplay ? '' : (message || '未知错误'),
|
||||
message: showMessage ? (message || '未知错误') : '',
|
||||
statusCode,
|
||||
upstreamResponse: upstreamResponseDisplay,
|
||||
upstreamResponse: upstreamResponseWithDiagnostic,
|
||||
}
|
||||
})
|
||||
|
||||
const diagnosticPathFromObject = (value: unknown): string => {
|
||||
const object = extractObject(value)
|
||||
if (!object) return ''
|
||||
return readStringField(object, 'path')
|
||||
|| readStringField(object, 'field_path')
|
||||
|| readStringField(object, 'fieldPath')
|
||||
|| readStringField(object, 'field')
|
||||
|| ''
|
||||
}
|
||||
|
||||
const diagnosticFieldPathFromMessage = (message: string): string => {
|
||||
const normalized = message.trim()
|
||||
if (!normalized) return ''
|
||||
const fieldMatch = normalized.match(/field\s+([^;=]+?)\s*(?:=|is unsupported|不支持)/i)
|
||||
if (fieldMatch?.[1]) return normalizeDiagnosticFieldPath(fieldMatch[1])
|
||||
const lossyMatch = normalized.match(/lossy conversion blocked from\s+\S+\s+to\s+\S+\s+at\s+([^:]+):/i)
|
||||
if (lossyMatch?.[1]) return normalizeDiagnosticFieldPath(lossyMatch[1])
|
||||
const invalidTargetMatch = normalized.match(/invalid target field\s+(.+?)\s+for\s+/i)
|
||||
if (invalidTargetMatch?.[1]) return normalizeDiagnosticFieldPath(invalidTargetMatch[1])
|
||||
const unsupportedFieldMatch = normalized.match(/unsupported field\s+(.+?)\s+in\s+/i)
|
||||
if (unsupportedFieldMatch?.[1]) return normalizeDiagnosticFieldPath(unsupportedFieldMatch[1])
|
||||
const invalidEnumMatch = normalized.match(/invalid enum value\s+.+?\s+for\s+.+\.([^.\s]+)$/i)
|
||||
if (invalidEnumMatch?.[1]) return normalizeDiagnosticFieldPath(invalidEnumMatch[1])
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveAttemptDiagnosticBreakpoint(attempt: CandidateRecord, rawMessageOverride = ''): string {
|
||||
const extra = extractObject(attempt.extra_data)
|
||||
const failureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
||||
const requestConversionError = extractObject(extra?.request_conversion_error)
|
||||
const requestBodyBuildError = extractObject(extra?.request_body_build_error)
|
||||
const errorFlow = extractObject(extra?.error_flow)
|
||||
const rawMessage = [
|
||||
rawMessageOverride,
|
||||
readStringField(errorFlow ?? {}, 'message') ?? '',
|
||||
readStringField(failureDiagnostic ?? {}, 'message') ?? '',
|
||||
readStringField(requestConversionError ?? {}, 'message') ?? '',
|
||||
readStringField(requestBodyBuildError ?? {}, 'message') ?? '',
|
||||
typeof attempt.error_message === 'string' ? attempt.error_message : '',
|
||||
].find(item => item.trim()) ?? ''
|
||||
|
||||
return diagnosticPathFromObject(failureDiagnostic)
|
||||
|| diagnosticPathFromObject(requestConversionError)
|
||||
|| diagnosticPathFromObject(requestBodyBuildError)
|
||||
|| diagnosticFieldPathFromMessage(rawMessage)
|
||||
|| '$'
|
||||
}
|
||||
|
||||
function buildAttemptDiagnosticPayload(
|
||||
attempt: CandidateRecord,
|
||||
summaryInput: string,
|
||||
statusCode: number | undefined,
|
||||
upstreamResponseDisplay: Record<string, unknown> | null,
|
||||
rawMessageForBreakpoint = '',
|
||||
): Record<string, unknown> | null {
|
||||
const extra = extractObject(attempt.extra_data)
|
||||
const rawFailureDiagnostic = extractVisibleFailureDiagnostic(extra)
|
||||
const rawRequestConversionError = extractObject(extra?.request_conversion_error)
|
||||
const rawRequestBodyBuildError = extractObject(extra?.request_body_build_error)
|
||||
const hasDiagnostic = Boolean(
|
||||
summaryInput
|
||||
|| rawFailureDiagnostic
|
||||
|| rawRequestConversionError
|
||||
|| rawRequestBodyBuildError
|
||||
|| attempt.error_message
|
||||
|| attempt.error_type
|
||||
|| attempt.skip_reason
|
||||
|| upstreamResponseDisplay,
|
||||
)
|
||||
if (!hasDiagnostic) return null
|
||||
|
||||
const summary = summaryInput
|
||||
|| readStringField(rawFailureDiagnostic ?? {}, 'message')
|
||||
|| readStringField(rawRequestConversionError ?? {}, 'message')
|
||||
|| readStringField(rawRequestBodyBuildError ?? {}, 'message')
|
||||
|| (typeof attempt.error_message === 'string' ? formatAttemptErrorMessage(attempt.error_message, attempt.status_code) : '')
|
||||
|| attempt.error_type
|
||||
|| currentAttemptSkipReasonDisplay.value
|
||||
|| '未知失败'
|
||||
const breakpoint = resolveAttemptDiagnosticBreakpoint(attempt, rawMessageForBreakpoint)
|
||||
const providerFormat = readStringField(extra ?? {}, 'provider_api_format')
|
||||
const clientFormat = readStringField(extra ?? {}, 'client_api_format')
|
||||
|| (typeof props.requestApiFormat === 'string' ? props.requestApiFormat : '')
|
||||
const conversionDisplay = clientFormat || providerFormat
|
||||
? `${clientFormat ? formatApiFormat(clientFormat) : '未知请求格式'} → ${providerFormat ? formatApiFormat(providerFormat) : '未知上游格式'}`
|
||||
: currentAttemptFormatDisplay.value
|
||||
const analysisHint = (() => {
|
||||
const raw = `${summary}\n${rawMessageForBreakpoint}\n${attempt.error_message ?? ''}`.toLowerCase()
|
||||
if (raw.includes('unsupported provider stream event')) {
|
||||
return '断点在上游流式事件解析/转换矩阵:先按 breakpoint 对应字段确认 event type,再决定是补 canonical mapping 还是加入 known noop。'
|
||||
}
|
||||
if (raw.includes('finish reason')) {
|
||||
return '断点在 finish_reason 映射:确认该结束原因是否可等价映射;不能无损映射时保持失败闭合。'
|
||||
}
|
||||
if (raw.includes('lossy conversion') || raw.includes('无损') || raw.includes('丢失信息') || raw.includes('request_conversion')) {
|
||||
return '断点在请求/响应格式转换器:检查 breakpoint 字段是否能被目标格式表达,不能表达就需要拒绝、降级或新增显式映射策略。'
|
||||
}
|
||||
return '先从 breakpoint 字段开始回放;若 breakpoint 为 $,优先查看 raw.failure_diagnostic / raw.error_message 和 upstream_response。'
|
||||
})()
|
||||
|
||||
const payload = {
|
||||
summary,
|
||||
breakpoint,
|
||||
analysis_hint: analysisHint,
|
||||
request: {
|
||||
request_id: trace.value?.request_id ?? attempt.request_id,
|
||||
path: currentAttemptRequestPathDisplay.value || null,
|
||||
format_conversion: conversionDisplay || null,
|
||||
client_api_format: clientFormat || null,
|
||||
provider_api_format: providerFormat || null,
|
||||
needs_conversion: extra?.needs_conversion ?? null,
|
||||
conversion_mode: readStringField(extra ?? {}, 'conversion_mode') ?? null,
|
||||
},
|
||||
node: {
|
||||
candidate_id: attempt.id,
|
||||
candidate_index: attempt.candidate_index,
|
||||
retry_index: attempt.retry_index,
|
||||
provider: attempt.provider_name || attempt.provider_id || null,
|
||||
key: currentAttemptKeyDisplay.value || null,
|
||||
status: attempt.status,
|
||||
status_code: statusCode ?? attempt.status_code ?? null,
|
||||
skip_reason: attempt.skip_reason ?? null,
|
||||
error_type: attempt.error_type ?? null,
|
||||
error_message: attempt.error_message ?? null,
|
||||
},
|
||||
raw: {
|
||||
failure_diagnostic: rawFailureDiagnostic,
|
||||
request_conversion_error: rawRequestConversionError,
|
||||
request_body_build_error: rawRequestBodyBuildError,
|
||||
error_flow: extractObject(extra?.error_flow),
|
||||
upstream_response: upstreamResponseDisplay ?? normalizeUpstreamResponseDisplay(extra?.upstream_response),
|
||||
},
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const currentAttemptExtraDataDisplay = computed<Record<string, unknown> | null>(() => {
|
||||
const extra = extractObject(currentAttempt.value?.extra_data)
|
||||
if (!extra) return null
|
||||
@@ -1727,7 +2072,7 @@ const loadTrace = async (silent = false) => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId, { attemptedOnly: true })
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) {
|
||||
internalTrace.value = null
|
||||
@@ -2765,6 +3110,7 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
|
||||
/* 跳过原因 */
|
||||
.skip-reason {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
|
||||
@@ -734,6 +734,24 @@
|
||||
>
|
||||
<Skeleton class="h-32 w-full" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="requestBodyLoadErrorVisible"
|
||||
class="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{{ bodyLoadErrorMessage }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-3 h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="retryBodyContentLoad"
|
||||
>
|
||||
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
<ConversationView
|
||||
v-else-if="contentViewMode === 'conversation'"
|
||||
:render-result="requestRenderResult"
|
||||
@@ -783,6 +801,24 @@
|
||||
>
|
||||
<Skeleton class="h-32 w-full" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="responseBodyLoadErrorVisible"
|
||||
class="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{{ bodyLoadErrorMessage }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-3 h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="retryBodyContentLoad"
|
||||
>
|
||||
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
<ConversationView
|
||||
v-else-if="contentViewMode === 'conversation'"
|
||||
:render-result="responseRenderResult"
|
||||
@@ -843,7 +879,12 @@ import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageS
|
||||
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
|
||||
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatCompactNumber, formatShortRequestId, formatTokens } from '@/utils/format'
|
||||
import {
|
||||
formatByteSize,
|
||||
formatCompactNumber,
|
||||
formatShortRequestId,
|
||||
formatTokens,
|
||||
} from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getEffectiveInputTokens } from '../token-normalization'
|
||||
import {
|
||||
@@ -942,6 +983,8 @@ type PricingTierLike = {
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
const METADATA_BYTE_FIELD_PATTERN = /(^bytes$|_bytes$|bytes$)/i
|
||||
|
||||
type NormalizedErrorDomain = {
|
||||
source?: string | null
|
||||
status_code?: number | null
|
||||
@@ -956,6 +999,29 @@ function asRecord(value: unknown): JsonRecord | null {
|
||||
return value as JsonRecord
|
||||
}
|
||||
|
||||
function shouldFormatMetadataByteField(key: string, value: unknown): value is number {
|
||||
return typeof value === 'number'
|
||||
&& Number.isFinite(value)
|
||||
&& METADATA_BYTE_FIELD_PATTERN.test(key)
|
||||
}
|
||||
|
||||
function formatMetadataDisplayValue(value: unknown, key = ''): unknown {
|
||||
if (shouldFormatMetadataByteField(key, value)) {
|
||||
return formatByteSize(value)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => formatMetadataDisplayValue(item))
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.reduce<Record<string, unknown>>((formatted, [childKey, childValue]) => {
|
||||
formatted[childKey] = formatMetadataDisplayValue(childValue, childKey)
|
||||
return formatted
|
||||
}, {})
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): NormalizedErrorDomain | null {
|
||||
if (!domain || typeof domain !== 'object') return null
|
||||
const message = typeof domain.message === 'string' ? domain.message.trim() : ''
|
||||
@@ -1130,6 +1196,7 @@ const curlCopying = ref(false)
|
||||
const curlCopied = ref(false)
|
||||
const replayDialogOpen = ref(false)
|
||||
const bodyLoading = ref(false)
|
||||
const bodyLoadError = ref<string | null>(null)
|
||||
const bodiesLoadedForRequestId = ref<string | null>(null)
|
||||
const showTimeline = ref(false)
|
||||
const AUTO_REFRESH_INTERVAL_MS = 1000
|
||||
@@ -1236,7 +1303,9 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
|
||||
merged.settlement = detail.value.settlement
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : null
|
||||
return Object.keys(merged).length > 0
|
||||
? formatMetadataDisplayValue(merged) as Record<string, unknown>
|
||||
: null
|
||||
})
|
||||
|
||||
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
|
||||
@@ -1288,6 +1357,36 @@ const isResponseBodyLoading = computed(() => {
|
||||
return bodyLoading.value && activeTab.value === 'response-body' && !currentResponseBody.value
|
||||
})
|
||||
|
||||
const requestBodyLoadFailed = computed(() => {
|
||||
const errors = detail.value?.body_load_errors
|
||||
return Boolean(errors?.request_body || errors?.provider_request_body)
|
||||
})
|
||||
|
||||
const responseBodyLoadFailed = computed(() => {
|
||||
const errors = detail.value?.body_load_errors
|
||||
return Boolean(errors?.response_body || errors?.client_response_body)
|
||||
})
|
||||
|
||||
const bodyLoadErrorMessage = computed(() => {
|
||||
return bodyLoadError.value || '正文内容加载失败,请重试'
|
||||
})
|
||||
|
||||
const requestBodyLoadErrorVisible = computed(() => {
|
||||
return Boolean(
|
||||
(bodyLoadError.value || requestBodyLoadFailed.value) &&
|
||||
activeTab.value === 'request-body' &&
|
||||
!currentRequestBody.value
|
||||
)
|
||||
})
|
||||
|
||||
const responseBodyLoadErrorVisible = computed(() => {
|
||||
return Boolean(
|
||||
(bodyLoadError.value || responseBodyLoadFailed.value) &&
|
||||
activeTab.value === 'response-body' &&
|
||||
!currentResponseBody.value
|
||||
)
|
||||
})
|
||||
|
||||
function clearTimelineMountTimer() {
|
||||
if (timelineMountTimer) {
|
||||
clearTimeout(timelineMountTimer)
|
||||
@@ -1918,6 +2017,15 @@ function hasContent(data: unknown): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function hasBodyLoadErrors(errors: RequestDetail['body_load_errors'] | null | undefined): boolean {
|
||||
return Boolean(
|
||||
errors?.request_body ||
|
||||
errors?.provider_request_body ||
|
||||
errors?.response_body ||
|
||||
errors?.client_response_body
|
||||
)
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
@@ -2118,6 +2226,7 @@ watch(() => props.isOpen, async (isOpen) => {
|
||||
showTimeline.value = false
|
||||
clearTimelineMountTimer()
|
||||
bodyLoading.value = false
|
||||
bodyLoadError.value = null
|
||||
bodiesLoadedForRequestId.value = null
|
||||
}
|
||||
})
|
||||
@@ -2131,6 +2240,7 @@ async function ensureBodyContentLoaded() {
|
||||
|
||||
const requestId = ++bodyLoadRequestId
|
||||
bodyLoading.value = true
|
||||
bodyLoadError.value = null
|
||||
try {
|
||||
const response = await dashboardApi.getRequestDetail(props.requestId, { includeBodies: true })
|
||||
if (requestId !== bodyLoadRequestId || !detail.value) return
|
||||
@@ -2144,6 +2254,7 @@ async function ensureBodyContentLoaded() {
|
||||
has_provider_request_body: response.has_provider_request_body,
|
||||
has_response_body: response.has_response_body,
|
||||
has_client_response_body: response.has_client_response_body,
|
||||
body_load_errors: response.body_load_errors,
|
||||
request_error: response.request_error,
|
||||
upstream_error: response.upstream_error,
|
||||
client_error: response.client_error,
|
||||
@@ -2152,10 +2263,13 @@ async function ensureBodyContentLoaded() {
|
||||
error_flow: response.error_flow,
|
||||
scheduling_failure: response.scheduling_failure,
|
||||
}
|
||||
bodiesLoadedForRequestId.value = cacheKey
|
||||
if (!hasBodyLoadErrors(response.body_load_errors)) {
|
||||
bodiesLoadedForRequestId.value = cacheKey
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== bodyLoadRequestId) return
|
||||
log.error('Failed to load request bodies:', err)
|
||||
bodyLoadError.value = '正文内容加载失败,请重试'
|
||||
} finally {
|
||||
if (requestId === bodyLoadRequestId) {
|
||||
bodyLoading.value = false
|
||||
@@ -2163,6 +2277,11 @@ async function ensureBodyContentLoaded() {
|
||||
}
|
||||
}
|
||||
|
||||
function retryBodyContentLoad() {
|
||||
bodyLoadError.value = null
|
||||
void ensureBodyContentLoaded()
|
||||
}
|
||||
|
||||
async function loadDetail(id: string, silent = false) {
|
||||
if (silent && loadDetailInFlight) {
|
||||
return
|
||||
@@ -2178,6 +2297,7 @@ async function loadDetail(id: string, silent = false) {
|
||||
clearTimelineMountTimer()
|
||||
++bodyLoadRequestId
|
||||
bodyLoading.value = false
|
||||
bodyLoadError.value = null
|
||||
}
|
||||
error.value = null
|
||||
try {
|
||||
|
||||
@@ -366,6 +366,7 @@
|
||||
<ElapsedTimeText
|
||||
class="text-primary"
|
||||
:created-at="record.created_at"
|
||||
:response-time-updated-at="record.response_time_updated_at ?? null"
|
||||
:status="getDisplayStatus(record)"
|
||||
:response-time-ms="record.response_time_ms ?? null"
|
||||
/>
|
||||
@@ -987,6 +988,7 @@
|
||||
<ElapsedTimeText
|
||||
class="text-primary"
|
||||
:created-at="record.created_at"
|
||||
:response-time-updated-at="record.response_time_updated_at ?? null"
|
||||
:status="getDisplayStatus(record)"
|
||||
:response-time-ms="record.response_time_ms ?? null"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, type App } from 'vue'
|
||||
import ElapsedTimeText from '../ElapsedTimeText.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountElapsedTimeText(props: Record<string, unknown>) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ElapsedTimeText, props),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('ElapsedTimeText', () => {
|
||||
it('uses active response timing from response_time_updated_at instead of stale created_at', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-08T12:00:10.000Z'))
|
||||
|
||||
const root = mountElapsedTimeText({
|
||||
status: 'streaming',
|
||||
createdAt: '2026-06-08T11:59:00Z',
|
||||
responseTimeUpdatedAt: '2026-06-08T12:00:06Z',
|
||||
responseTimeMs: 1500,
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toBe('5.50s')
|
||||
})
|
||||
|
||||
it('falls back to created_at when active timing has not reached the backend yet', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-08T12:00:10.000Z'))
|
||||
|
||||
const root = mountElapsedTimeText({
|
||||
status: 'pending',
|
||||
createdAt: '2026-06-08T12:00:06Z',
|
||||
responseTimeUpdatedAt: null,
|
||||
responseTimeMs: null,
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toBe('4.00s')
|
||||
})
|
||||
})
|
||||
@@ -570,6 +570,176 @@ describe('HorizontalRequestTimeline', () => {
|
||||
expect(root.textContent).not.toContain('该错误被标记为敏感上游错误')
|
||||
})
|
||||
|
||||
it('keeps local sync diagnostics visible when upstream response body capture is disabled', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-local-sync-diagnostic',
|
||||
provider_id: 'provider-local-sync',
|
||||
provider_name: 'Provider Local Sync',
|
||||
key_id: 'key-local-sync',
|
||||
key_name: 'Local Sync Key',
|
||||
candidate_index: 0,
|
||||
status: 'failed',
|
||||
status_code: 500,
|
||||
error_type: 'local_sync_attempt_aborted',
|
||||
error_message: 'Local sync attempt failed before terminal finalization: Internal("Unsupported provider stream event cannot be converted losslessly: field $.type = \\"response.future.delta\\"; fields: payload, response, type")',
|
||||
extra_data: {
|
||||
upstream_response: {
|
||||
status_code: 500,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body_state: 'disabled',
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const root = mountTimeline(trace)
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('错误信息')
|
||||
expect(root.textContent).toContain('HTTP 500')
|
||||
expect(root.textContent).toContain('流式格式转换失败')
|
||||
expect(root.textContent).toContain('上游返回了当前不支持的 stream event')
|
||||
expect(root.textContent).toContain('字段 $.type = "response.future.delta"')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"body_state":"disabled"')
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.type"')
|
||||
expect(errorJsonText).toContain('"analysis_hint"')
|
||||
expect(errorJsonText).toContain('"raw"')
|
||||
})
|
||||
|
||||
it('formats request conversion diagnostics with field paths on skipped trace nodes', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-request-conversion',
|
||||
provider_id: 'provider-request-conversion',
|
||||
provider_name: 'Provider Request Conversion',
|
||||
key_id: 'key-request-conversion',
|
||||
key_name: 'Request Conversion Key',
|
||||
candidate_index: 0,
|
||||
status: 'skipped',
|
||||
skip_reason: 'provider_request_body_build_failed',
|
||||
extra_data: {
|
||||
failure_diagnostic: {
|
||||
kind: 'request_conversion',
|
||||
path: '$.n',
|
||||
message: 'lossy conversion blocked from openai:chat to openai:responses at n: multiple completions cannot be represented losslessly',
|
||||
safe_to_show: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const root = mountTimeline(trace)
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('跳过原因')
|
||||
expect(root.textContent).toContain('上游请求体转换失败')
|
||||
expect(root.textContent).toContain('$.n')
|
||||
expect(root.textContent).toContain('格式转换失败')
|
||||
expect(root.textContent).toContain('OpenAI Chat → OpenAI Responses')
|
||||
expect(root.textContent).toContain('字段 $.n 会丢失信息')
|
||||
expect(root.querySelector('.diagnostic-json-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('formats unsupported stream finish reasons with the failing field', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-finish-reason',
|
||||
provider_id: 'provider-finish-reason',
|
||||
provider_name: 'Provider Finish Reason',
|
||||
key_id: 'key-finish-reason',
|
||||
key_name: 'Finish Reason Key',
|
||||
candidate_index: 0,
|
||||
status: 'failed',
|
||||
status_code: 500,
|
||||
error_message: 'Internal("Unsupported provider stream finish reason cannot be converted losslessly: field $.finish_reason = \\"future_reason\\"")',
|
||||
}),
|
||||
])
|
||||
|
||||
const root = mountTimeline(trace)
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('流式格式转换失败')
|
||||
expect(root.textContent).toContain('finish reason')
|
||||
expect(root.textContent).toContain('字段 $.finish_reason = "future_reason"')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.finish_reason"')
|
||||
})
|
||||
|
||||
it('uses conversion messages from error_flow as the diagnostic breakpoint source', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-error-flow-conversion',
|
||||
provider_id: 'provider-error-flow-conversion',
|
||||
provider_name: 'Provider Error Flow Conversion',
|
||||
key_id: 'key-error-flow-conversion',
|
||||
key_name: 'Error Flow Conversion Key',
|
||||
candidate_index: 0,
|
||||
status: 'failed',
|
||||
status_code: 500,
|
||||
error_message: 'execution runtime stream returned non-success status 500',
|
||||
extra_data: {
|
||||
upstream_response: {
|
||||
status_code: 500,
|
||||
body_state: 'disabled',
|
||||
},
|
||||
error_flow: {
|
||||
status_code: 500,
|
||||
message: 'lossy conversion blocked from openai:chat to openai:responses at n: multiple completions cannot be represented losslessly',
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const root = mountTimeline(trace)
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('格式转换失败')
|
||||
expect(root.textContent).toContain('OpenAI Chat → OpenAI Responses')
|
||||
expect(root.textContent).toContain('字段 $.n 会丢失信息')
|
||||
expect(root.textContent).not.toContain('上游返回非成功状态 500')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"body_state":"disabled"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.n"')
|
||||
expect(errorJsonText).toContain('断点在请求/响应格式转换器')
|
||||
})
|
||||
|
||||
it('shows failed diagnostic messages even when the only response panel data is diagnostic metadata', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
id: 'cand-diagnostic-only',
|
||||
provider_id: 'provider-diagnostic-only',
|
||||
provider_name: 'Provider Diagnostic Only',
|
||||
key_id: 'key-diagnostic-only',
|
||||
key_name: 'Diagnostic Only Key',
|
||||
candidate_index: 0,
|
||||
status: 'failed',
|
||||
error_type: 'request_conversion_failed',
|
||||
extra_data: {
|
||||
failure_diagnostic: {
|
||||
kind: 'request_conversion',
|
||||
path: '$.temperature',
|
||||
message: 'unsupported field temperature in openai:responses: temperature cannot be represented',
|
||||
safe_to_show: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const root = mountTimeline(trace)
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toContain('错误信息')
|
||||
expect(root.textContent).toContain('格式转换失败')
|
||||
expect(root.textContent).toContain('OpenAI Responses 不支持字段 $.temperature')
|
||||
const errorJsonText = root.querySelector('.error-block .error-json')?.textContent ?? ''
|
||||
expect(errorJsonText).toContain('"diagnostic"')
|
||||
expect(errorJsonText).toContain('"breakpoint":"$.temperature"')
|
||||
})
|
||||
|
||||
it('keeps the failure message when upstream response only records an empty body state', async () => {
|
||||
const trace = buildTrace([
|
||||
buildCandidate({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
|
||||
const {
|
||||
getAllUsageRecordsMock,
|
||||
getAllUsageRecordTotalMock,
|
||||
getUsageStatsMock,
|
||||
getUsageByModelMock,
|
||||
getUsageByProviderMock,
|
||||
@@ -10,6 +11,7 @@ const {
|
||||
meGetUsageMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getAllUsageRecordsMock: vi.fn(),
|
||||
getAllUsageRecordTotalMock: vi.fn(),
|
||||
getUsageStatsMock: vi.fn(),
|
||||
getUsageByModelMock: vi.fn(),
|
||||
getUsageByProviderMock: vi.fn(),
|
||||
@@ -20,6 +22,7 @@ const {
|
||||
vi.mock('@/api/usage', () => ({
|
||||
usageApi: {
|
||||
getAllUsageRecords: getAllUsageRecordsMock,
|
||||
getAllUsageRecordTotal: getAllUsageRecordTotalMock,
|
||||
getUsageStats: getUsageStatsMock,
|
||||
getUsageByModel: getUsageByModelMock,
|
||||
getUsageByProvider: getUsageByProviderMock,
|
||||
@@ -72,6 +75,7 @@ describe('useUsageData', () => {
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
getAllUsageRecordTotalMock.mockResolvedValue(1)
|
||||
getUsageStatsMock.mockRejectedValue({
|
||||
response: { status: 500 },
|
||||
message: 'stats failed',
|
||||
@@ -143,6 +147,35 @@ describe('useUsageData', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes exact admin record totals after an estimated first page', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, totalRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord()],
|
||||
total: 21,
|
||||
total_is_estimated: true,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
getAllUsageRecordTotalMock.mockResolvedValueOnce(122101)
|
||||
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(getAllUsageRecordsMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
include_total: false,
|
||||
}))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(getAllUsageRecordTotalMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
preset: 'last7days',
|
||||
tz_offset_minutes: 0,
|
||||
}))
|
||||
expect(totalRecords.value).toBe(122101)
|
||||
})
|
||||
|
||||
it('continues loading admin breakdowns when the summary request fails', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const {
|
||||
|
||||
@@ -366,13 +366,19 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
params.hide_unknown = true
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
const response = await usageApi.getAllUsageRecords({
|
||||
...params,
|
||||
include_total: false,
|
||||
})
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
const nextRecords = (response.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = response.total || 0
|
||||
if (response.total_is_estimated === true) {
|
||||
void refreshAdminRecordTotal(params, requestId)
|
||||
}
|
||||
} else {
|
||||
// 用户页面:使用用户 API
|
||||
const userData = await meApi.getUsage(params)
|
||||
@@ -397,6 +403,20 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAdminRecordTotal(
|
||||
params: Record<string, unknown>,
|
||||
requestId: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const total = await usageApi.getAllUsageRecordTotal(params)
|
||||
if (requestId === loadRecordsRequestId) {
|
||||
totalRecords.value = total
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('加载使用记录总数失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function mergePositiveDurationMs(
|
||||
existingValue: number | null | undefined,
|
||||
nextValue: number | null | undefined
|
||||
@@ -513,6 +533,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
actual_cost: existing.actual_cost ?? record.actual_cost,
|
||||
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
|
||||
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
|
||||
updated_at: existing.updated_at ?? record.updated_at,
|
||||
response_time_updated_at: existing.response_time_updated_at ?? record.response_time_updated_at,
|
||||
status_code: existing.status_code ?? record.status_code,
|
||||
error_message: existing.error_message ?? record.error_message,
|
||||
image_progress: existing.image_progress ?? record.image_progress,
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface UsageRecord {
|
||||
error_message?: string
|
||||
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
|
||||
created_at: string
|
||||
updated_at?: string | null
|
||||
response_time_updated_at?: string | null
|
||||
has_fallback?: boolean
|
||||
has_retry?: boolean
|
||||
image_progress?: ImageProgress | null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
classifyAccountBlockLabel,
|
||||
cleanAccountBlockReason,
|
||||
isAccountLevelBlockReason,
|
||||
isRefreshFailedReason,
|
||||
@@ -30,4 +31,9 @@ describe('accountBlock helpers', () => {
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('labels invalidated and expired oauth markers separately', () => {
|
||||
expect(classifyAccountBlockLabel('[OAUTH_EXPIRED] token invalidated')).toBe('Token 失效')
|
||||
expect(classifyAccountBlockLabel('[OAUTH_EXPIRED] session expired')).toBe('Token 过期')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatCompactNumber, formatTokens, formatUsageCount } from '../format'
|
||||
import { formatByteSize, formatCompactNumber, formatTokens, formatUsageCount } from '../format'
|
||||
|
||||
describe('format utils', () => {
|
||||
it('formats compact numbers beyond millions', () => {
|
||||
@@ -16,4 +16,11 @@ describe('format utils', () => {
|
||||
expect(formatTokens(1_500_000_000_000)).toBe('1.5T')
|
||||
expect(formatUsageCount(1_000_000_000)).toBe('1B')
|
||||
})
|
||||
|
||||
it('formats byte sizes with automatic units', () => {
|
||||
expect(formatByteSize(512)).toBe('0.5 KB')
|
||||
expect(formatByteSize(1024)).toBe('1 KB')
|
||||
expect(formatByteSize(12.5 * 1024 * 1024)).toBe('12.5 MB')
|
||||
expect(formatByteSize(2 * 1024 * 1024 * 1024)).toBe('2 GB')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,9 +28,33 @@ const KEYWORDS_DISABLED = [
|
||||
]
|
||||
|
||||
const KEYWORDS_TOKEN_INVALID = [
|
||||
'oauth_token_invalid',
|
||||
'token_invalidated',
|
||||
'authentication token has been invalidated',
|
||||
'token has been invalidated',
|
||||
'codex token 无效或已过期',
|
||||
'token invalidated',
|
||||
'invalidated',
|
||||
'revoked',
|
||||
'已撤销',
|
||||
'被撤销',
|
||||
'撤销',
|
||||
'作废',
|
||||
'已失效',
|
||||
'token 失效',
|
||||
'令牌失效',
|
||||
]
|
||||
|
||||
const KEYWORDS_TOKEN_EXPIRED = [
|
||||
'oauth_token_expired',
|
||||
'session has expired',
|
||||
'session expired',
|
||||
'access token expired',
|
||||
'expired access token',
|
||||
'token has expired',
|
||||
'token expired',
|
||||
'security token included in the request is expired',
|
||||
'已过期',
|
||||
'过期',
|
||||
]
|
||||
|
||||
// 需要验证类
|
||||
@@ -47,9 +71,29 @@ const ACCOUNT_BLOCK_REASON_KEYWORDS = [
|
||||
...KEYWORDS_SUSPENDED,
|
||||
...KEYWORDS_DISABLED,
|
||||
...KEYWORDS_TOKEN_INVALID,
|
||||
...KEYWORDS_TOKEN_EXPIRED,
|
||||
...KEYWORDS_VERIFICATION,
|
||||
]
|
||||
|
||||
function normalizeOAuthReasonDetail(reason: string): string {
|
||||
return reason
|
||||
.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '')
|
||||
.replace(/\s*\[REFRESH_FAILED\][\s\S]*$/i, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function isHardTokenInvalidReason(reason: string): boolean {
|
||||
const lowered = reason.toLowerCase()
|
||||
if (KEYWORDS_TOKEN_INVALID.some(keyword => lowered.includes(keyword))) return true
|
||||
return (lowered.includes('token 无效') || lowered.includes('令牌无效'))
|
||||
&& !KEYWORDS_TOKEN_EXPIRED.some(keyword => lowered.includes(keyword))
|
||||
}
|
||||
|
||||
function isTokenExpiredReason(reason: string): boolean {
|
||||
const lowered = reason.toLowerCase()
|
||||
return KEYWORDS_TOKEN_EXPIRED.some(keyword => lowered.includes(keyword))
|
||||
}
|
||||
|
||||
export function isAccountLevelBlockReason(reason: string | null | undefined): boolean {
|
||||
if (!reason) return false
|
||||
const text = reason.trim()
|
||||
@@ -62,9 +106,13 @@ export function isAccountLevelBlockReason(reason: string | null | undefined): bo
|
||||
}
|
||||
|
||||
export function classifyAccountBlockLabel(reason: string): string {
|
||||
if (reason.trim().startsWith('[OAUTH_EXPIRED]')) return 'Token 失效'
|
||||
const lowered = reason.toLowerCase()
|
||||
if (KEYWORDS_TOKEN_INVALID.some(kw => lowered.includes(kw))) return 'Token 失效'
|
||||
const detail = normalizeOAuthReasonDetail(reason) || reason
|
||||
if (reason.trim().startsWith('[OAUTH_EXPIRED]')) {
|
||||
return isHardTokenInvalidReason(detail) ? 'Token 失效' : 'Token 过期'
|
||||
}
|
||||
const lowered = detail.toLowerCase()
|
||||
if (isHardTokenInvalidReason(detail)) return 'Token 失效'
|
||||
if (isTokenExpiredReason(detail)) return 'Token 过期'
|
||||
if (KEYWORDS_VERIFICATION.some(kw => lowered.includes(kw))) return '需要验证'
|
||||
if (lowered.includes('deactivated_workspace')) return '工作区停用'
|
||||
if (KEYWORDS_DISABLED.some(kw => lowered.includes(kw))) return '账号停用'
|
||||
@@ -73,10 +121,7 @@ export function classifyAccountBlockLabel(reason: string): string {
|
||||
}
|
||||
|
||||
export function cleanAccountBlockReason(reason: string): string {
|
||||
return reason
|
||||
.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '')
|
||||
.replace(/\s*\[REFRESH_FAILED\][\s\S]*$/i, '')
|
||||
.trim()
|
||||
return normalizeOAuthReasonDetail(reason)
|
||||
}
|
||||
|
||||
export function isRefreshFailedReason(reason: string | null | undefined): boolean {
|
||||
|
||||
@@ -66,6 +66,25 @@ export function formatCompactNumber(
|
||||
return `${sign}${formatCompactScaledValue(absValue, unitIndex, options.fractionDigits)}`
|
||||
}
|
||||
|
||||
export function formatByteSize(bytes: number | undefined | null): string {
|
||||
if (bytes === undefined || bytes === null || !Number.isFinite(bytes)) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const absBytes = Math.max(0, Math.abs(bytes))
|
||||
const units = [
|
||||
{ value: 1024 ** 3, suffix: 'GB' },
|
||||
{ value: 1024 ** 2, suffix: 'MB' },
|
||||
{ value: 1024, suffix: 'KB' },
|
||||
] as const
|
||||
const unit = units.find(candidate => absBytes >= candidate.value) ?? units[2]
|
||||
const scaled = absBytes / unit.value
|
||||
const fractionDigits = scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2
|
||||
const formatted = trimTrailingDecimalZeros(scaled.toFixed(fractionDigits))
|
||||
|
||||
return `${bytes < 0 ? '-' : ''}${formatted} ${unit.suffix}`
|
||||
}
|
||||
|
||||
// Token formatting - intelligent display based on value size
|
||||
export function formatTokens(num: number | undefined | null): string {
|
||||
return formatCompactNumber(num)
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
@update:filter-model="filterModel = $event"
|
||||
@reset-filters="resetFilters"
|
||||
@open-priority-dialog="openPriorityDialog"
|
||||
@batch-process="openProviderBatchDialog"
|
||||
@add-provider="openAddProviderDialog"
|
||||
@refresh="loadProviders"
|
||||
/>
|
||||
@@ -191,7 +192,7 @@
|
||||
/>
|
||||
</template>
|
||||
</SortableTableHead>
|
||||
<TableHead class="w-[18%] min-w-[120px] text-center">
|
||||
<TableHead class="w-[18%] min-w-[160px] text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -277,6 +278,12 @@
|
||||
@provider-updated="handleProviderUpdated"
|
||||
/>
|
||||
|
||||
<ProviderBatchActionDialog
|
||||
v-model="providerBatchDialogOpen"
|
||||
:providers="displayedProviders"
|
||||
@changed="handleProviderBatchChanged"
|
||||
/>
|
||||
|
||||
<PriorityManagementDialog
|
||||
v-model="priorityDialogOpen"
|
||||
@saved="handlePrioritySaved"
|
||||
@@ -313,6 +320,7 @@ import SortableTableHead from '@/components/ui/sortable-table-head.vue'
|
||||
import TableFilterMenu from '@/components/ui/table-filter-menu.vue'
|
||||
import Pagination from '@/components/ui/pagination.vue'
|
||||
import { ProviderFormDialog, PriorityManagementDialog, ProviderAuthDialog } from '@/features/providers/components'
|
||||
import ProviderBatchActionDialog from '@/features/providers/components/ProviderBatchActionDialog.vue'
|
||||
import ProviderDetailDrawer from '@/features/providers/components/ProviderDetailDrawer.vue'
|
||||
import ProviderTableHeader from '@/features/providers/components/ProviderTableHeader.vue'
|
||||
import ProviderTableRow from '@/features/providers/components/ProviderTableRow.vue'
|
||||
@@ -355,6 +363,7 @@ const loading = ref(false)
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
let providersRequestId = 0
|
||||
const providerDialogOpen = ref(false)
|
||||
const providerBatchDialogOpen = ref(false)
|
||||
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
|
||||
const priorityDialogOpen = ref(false)
|
||||
const priorityMode = ref<'provider' | 'global_key'>('provider')
|
||||
@@ -671,6 +680,14 @@ function openPriorityDialog() {
|
||||
priorityDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openProviderBatchDialog() {
|
||||
providerBatchDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleProviderBatchChanged() {
|
||||
await loadProviders()
|
||||
}
|
||||
|
||||
// 打开提供商详情抽屉
|
||||
function openProviderDrawer(providerId: string) {
|
||||
selectedProviderId.value = providerId
|
||||
|
||||
@@ -522,6 +522,15 @@ async function pollActiveRequests() {
|
||||
record.rate_multiplier = update.rate_multiplier ?? undefined
|
||||
record.response_time_ms = update.response_time_ms ?? undefined
|
||||
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
|
||||
if ('updated_at' in update) {
|
||||
record.updated_at = typeof update.updated_at === 'string' ? update.updated_at : null
|
||||
}
|
||||
if ('response_time_updated_at' in update) {
|
||||
record.response_time_updated_at =
|
||||
typeof update.response_time_updated_at === 'string'
|
||||
? update.response_time_updated_at
|
||||
: null
|
||||
}
|
||||
record.status_code = update.status_code ?? undefined
|
||||
record.error_message = update.error_message ?? undefined
|
||||
if (typeof update.upstream_is_stream === 'boolean') {
|
||||
|
||||
Reference in New Issue
Block a user