mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(provider): 重构模型测试对话框,加固 Vertex AI 传输层
模型测试: - 将消息输入替换为完整 JSON 请求体编辑器,支持格式化和校验 - 新增端点选择面板,测试前可选择目标端点 - 新增调试检查器,可查看每次尝试的请求/响应头和体 - 结果视图改用 HorizontalRequestTimeline 组件展示请求追踪 - endpoint_checker 返回完整调试数据,通过 candidate extra_data 持久化 Vertex AI: - 改进上下文检测逻辑,不再仅依赖 provider_type,支持从 base_url 推断 - Service Account 密钥现支持自动拉取模型(使用 auth_config 而非 api_key) - 移除 Gemini Developer API 回退,API Key 仅走 Express 模式 - 端点表单为 Vertex AI 显示格式特定的默认路径模板 - 密钥格式校验仅在 auth_type/api_formats 变更时执行 其他: - 禁用 ClaudeCode 提供商类型创建入口 - Dialog 组件新增 closeOnBackdrop 属性
This commit is contained in:
@@ -194,6 +194,7 @@ export interface TestModelFailoverRequest {
|
||||
api_format?: string
|
||||
endpoint_id?: string
|
||||
message?: string
|
||||
request_body?: Record<string, unknown>
|
||||
request_id?: string
|
||||
concurrency?: number
|
||||
}
|
||||
@@ -212,6 +213,11 @@ export interface TestAttemptDetail {
|
||||
error_message?: string | null
|
||||
status_code?: number | null
|
||||
latency_ms?: number | null
|
||||
request_url?: string | null
|
||||
request_headers?: Record<string, unknown> | null
|
||||
request_body?: unknown
|
||||
response_headers?: Record<string, unknown> | null
|
||||
response_body?: unknown
|
||||
}
|
||||
|
||||
export interface TestModelFailoverResponse {
|
||||
|
||||
@@ -109,6 +109,7 @@ const props = defineProps<{
|
||||
zIndex?: number // Custom z-index for nested dialogs (default: 60)
|
||||
noPadding?: boolean // Disable default content padding
|
||||
persistent?: boolean // Prevent closing on backdrop click
|
||||
closeOnBackdrop?: boolean // Allow closing on backdrop click (default: true)
|
||||
}>()
|
||||
|
||||
// Emits 定义
|
||||
@@ -145,7 +146,7 @@ function handleClose() {
|
||||
|
||||
// 处理背景点击
|
||||
function handleBackdropClick() {
|
||||
if (!props.persistent) {
|
||||
if (!props.persistent && props.closeOnBackdrop !== false) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface StartTestParams {
|
||||
apiFormat?: string
|
||||
endpointId?: string
|
||||
message?: string
|
||||
requestBody?: Record<string, unknown>
|
||||
concurrency?: number
|
||||
onSuccess?: (result: TestModelFailoverResponse) => void
|
||||
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
|
||||
@@ -61,6 +62,16 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTraceSnapshot(reqId: string) {
|
||||
try {
|
||||
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
|
||||
if (requestId.value !== reqId) return
|
||||
testTrace.value = trace
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) return
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling(opts: { clearState?: boolean } = {}) {
|
||||
tracePollToken += 1
|
||||
if (tracePollTimer) {
|
||||
@@ -128,6 +139,7 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
api_format: params.apiFormat,
|
||||
endpoint_id: params.endpointId,
|
||||
...(normalizedMessage ? { message: normalizedMessage } : {}),
|
||||
...(params.requestBody ? { request_body: params.requestBody } : {}),
|
||||
request_id: reqId,
|
||||
concurrency: params.concurrency,
|
||||
}, {
|
||||
@@ -135,6 +147,9 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
await refreshTraceSnapshot(reqId)
|
||||
stopPolling({ clearState: false })
|
||||
testResult.value = result
|
||||
const successAttempt = result.attempts.find(a => a.status === 'success')
|
||||
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
||||
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName
|
||||
@@ -142,10 +157,10 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
: ''
|
||||
params.onSuccess?.(result)
|
||||
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
|
||||
await refreshTraceSnapshot(reqId)
|
||||
stopPolling({ clearState: false })
|
||||
const handled = params.onFailure?.(result)
|
||||
if (!handled) {
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs text-muted-foreground">自定义路径</Label>
|
||||
<Input
|
||||
:model-value="getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')"
|
||||
:model-value="getDisplayedPath(endpoint)"
|
||||
:placeholder="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'"
|
||||
:disabled="isFixedProvider"
|
||||
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
|
||||
@@ -1276,10 +1276,19 @@ async function preloadDefaultBodyRules(endpoints: ProviderEndpoint[]): Promise<v
|
||||
|
||||
// 获取指定 API 格式的默认路径
|
||||
function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
||||
const providerType = (props.provider?.provider_type || '').toLowerCase()
|
||||
if (providerType === 'vertex_ai') {
|
||||
if (apiFormat === 'gemini:chat') {
|
||||
return '/v1/publishers/google/models/{model}:{action}'
|
||||
}
|
||||
if (apiFormat === 'claude:chat') {
|
||||
return '/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}'
|
||||
}
|
||||
}
|
||||
|
||||
const format = apiFormats.value.find(f => f.value === apiFormat)
|
||||
const defaultPath = format?.default_path || ''
|
||||
// Codex 端点使用 /responses 而非 /v1/responses
|
||||
const providerType = (props.provider?.provider_type || '').toLowerCase()
|
||||
const isCodex = providerType
|
||||
? providerType === 'codex'
|
||||
: (!!baseUrl && isCodexUrl(baseUrl))
|
||||
@@ -1289,6 +1298,13 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
||||
return defaultPath
|
||||
}
|
||||
|
||||
function getDisplayedPath(endpoint: ProviderEndpoint): string {
|
||||
if (isFixedProvider.value) {
|
||||
return getDefaultPath(endpoint.api_format, endpoint.base_url)
|
||||
}
|
||||
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
|
||||
}
|
||||
|
||||
// 判断是否是 Codex OAuth 端点
|
||||
function isCodexUrl(baseUrl: string): boolean {
|
||||
const url = baseUrl.replace(/\/+$/, '')
|
||||
|
||||
@@ -937,6 +937,7 @@
|
||||
ref="modelMappingTabRef"
|
||||
:key="`mapping-${provider.id}`"
|
||||
:provider="provider"
|
||||
:endpoints="endpoints"
|
||||
:provider-keys="providerKeys"
|
||||
:models="providerModels"
|
||||
:mapping-preview="providerMappingPreview"
|
||||
|
||||
@@ -45,8 +45,11 @@
|
||||
<SelectItem value="vertex_ai">
|
||||
Vertex AI
|
||||
</SelectItem>
|
||||
<SelectItem value="claude_code">
|
||||
ClaudeCode
|
||||
<SelectItem
|
||||
value="claude_code"
|
||||
disabled
|
||||
>
|
||||
ClaudeCode(暂不可用)
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
Codex
|
||||
@@ -432,6 +435,11 @@ watch(() => form.value.provider_type, () => {
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!isEditMode.value && form.value.provider_type === 'claude_code') {
|
||||
showError('ClaudeCode 提供商类型暂时禁用', '验证失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 月卡类型必须设置周期开始时间
|
||||
if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) {
|
||||
showError('月卡类型必须设置周期开始时间', '验证失败')
|
||||
|
||||
@@ -314,13 +314,19 @@
|
||||
:result="modelTest.testResult.value"
|
||||
mode="direct"
|
||||
:selecting-model-name="testingModelName"
|
||||
:endpoints="activeEndpoints"
|
||||
:selected-endpoint="selectedTestEndpoint"
|
||||
:testing="modelTest.testing.value"
|
||||
:trace="modelTest.testTrace.value"
|
||||
:request-id="modelTest.requestId.value"
|
||||
:message-draft="testMessageDraft"
|
||||
:request-body-draft="testRequestBodyDraft"
|
||||
:request-body-error="testRequestBodyError"
|
||||
:start-disabled="!selectedTestEndpoint || !!testRequestBodyError"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@select-endpoint="handleSelectTestEndpoint"
|
||||
@start="handleStartMappingTest"
|
||||
@update:message-draft="testMessageDraft = $event"
|
||||
@update:request-body-draft="testRequestBodyDraft = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -338,6 +344,7 @@ import ModelTestDialog from './ModelTestDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
type Model,
|
||||
type ProviderEndpoint,
|
||||
type ProviderModelAlias,
|
||||
type ProviderMappingPreviewResponse,
|
||||
} from '@/api/endpoints'
|
||||
@@ -345,6 +352,10 @@ import { type EndpointAPIKey } from '@/api/endpoints/keys'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import {
|
||||
buildDefaultModelTestRequestBody,
|
||||
parseModelTestRequestBodyDraft,
|
||||
} from './model-test-request'
|
||||
|
||||
interface MappingItem {
|
||||
name: string
|
||||
@@ -372,6 +383,7 @@ interface CombinedMapping {
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
endpoints?: ProviderEndpoint[]
|
||||
providerKeys?: EndpointAPIKey[]
|
||||
models?: Model[]
|
||||
mappingPreview?: ProviderMappingPreviewResponse | null
|
||||
@@ -396,7 +408,11 @@ const testingMapping = ref<string | null>(null)
|
||||
const pendingMappingKey = ref<string | null>(null)
|
||||
const testingModelName = ref<string | null>(null)
|
||||
const preselectedModelId = ref<string | null>(null)
|
||||
const testMessageDraft = ref('')
|
||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const testRequestBodyDraft = ref('')
|
||||
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
|
||||
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
|
||||
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
|
||||
|
||||
// 使用 props 传入的数据
|
||||
const models = computed(() => props.models ?? [])
|
||||
@@ -634,31 +650,60 @@ function handleTestDialogClose() {
|
||||
pendingMappingKey.value = null
|
||||
testingModelName.value = null
|
||||
testingMapping.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
testRequestBodyDraft.value = ''
|
||||
}
|
||||
|
||||
function handleTestDialogBack() {
|
||||
if (modelTest.testing.value) return
|
||||
modelTest.testResult.value = null
|
||||
modelTest.stopPolling()
|
||||
}
|
||||
|
||||
function handleSelectTestEndpoint(endpointId: string) {
|
||||
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
|
||||
if (!endpoint) return
|
||||
selectedTestEndpoint.value = endpoint
|
||||
}
|
||||
|
||||
// 测试映射(直连测试,带故障转移和实时进度)
|
||||
function runMappingTest(testingKey: string, modelName: string) {
|
||||
if (activeEndpoints.value.length === 0) {
|
||||
showError('暂无可用于测试的活跃端点')
|
||||
return
|
||||
}
|
||||
pendingMappingKey.value = testingKey
|
||||
modelTest.testResult.value = null
|
||||
modelTest.dialogOpen.value = true
|
||||
testingMapping.value = null
|
||||
testingModelName.value = modelName
|
||||
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
|
||||
testRequestBodyDraft.value = buildDefaultModelTestRequestBody(modelName)
|
||||
}
|
||||
|
||||
async function handleStartMappingTest() {
|
||||
if (modelTest.testing.value || !testingModelName.value || !pendingMappingKey.value) return
|
||||
if (modelTest.testing.value || !testingModelName.value) return
|
||||
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
|
||||
if (!endpoint) {
|
||||
showError('请选择要测试的端点')
|
||||
return
|
||||
}
|
||||
|
||||
const currentMappingKey = pendingMappingKey.value
|
||||
testingMapping.value = currentMappingKey
|
||||
const { value: requestBody, error } = parsedTestRequestBody.value
|
||||
if (!requestBody || error) {
|
||||
showError(`测试请求体无效: ${error || '无效 JSON'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const currentMappingKey = pendingMappingKey.value || testingModelName.value
|
||||
testingMapping.value = pendingMappingKey.value ? currentMappingKey : null
|
||||
await modelTest.startTest({
|
||||
mode: 'direct',
|
||||
modelName: testingModelName.value,
|
||||
displayLabel: `映射 "${testingModelName.value}"`,
|
||||
message: testMessageDraft.value,
|
||||
onSuccess: () => {
|
||||
pendingMappingKey.value = null
|
||||
testingModelName.value = null
|
||||
},
|
||||
displayLabel: `[${endpoint.api_format}] 映射 "${testingModelName.value}"`,
|
||||
apiFormat: endpoint.api_format,
|
||||
endpointId: endpoint.id,
|
||||
requestBody,
|
||||
})
|
||||
if (pendingMappingKey.value === currentMappingKey) {
|
||||
pendingMappingKey.value = null
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -221,13 +221,14 @@
|
||||
:testing="modelTest.testing.value"
|
||||
:trace="modelTest.testTrace.value"
|
||||
:request-id="modelTest.requestId.value"
|
||||
:show-endpoint-selector="activeEndpoints.length > 1"
|
||||
:message-draft="testMessageDraft"
|
||||
:request-body-draft="testRequestBodyDraft"
|
||||
:request-body-error="testRequestBodyError"
|
||||
:start-disabled="!selectedTestEndpoint || !!testRequestBodyError"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@start="handleStartPendingTest"
|
||||
@select-endpoint="handleSelectTestEndpoint"
|
||||
@update:message-draft="testMessageDraft = $event"
|
||||
@update:request-body-draft="testRequestBodyDraft = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -250,6 +251,10 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import ModelTestDialog from './ModelTestDialog.vue'
|
||||
import {
|
||||
buildDefaultModelTestRequestBody,
|
||||
parseModelTestRequestBodyDraft,
|
||||
} from './model-test-request'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
@@ -275,8 +280,10 @@ const localModels = ref<Model[]>([])
|
||||
const togglingModelId = ref<string | null>(null)
|
||||
const pendingTestModel = ref<Model | null>(null)
|
||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const testMessageDraft = ref('')
|
||||
const testRequestBodyDraft = ref('')
|
||||
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
|
||||
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
|
||||
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
|
||||
const models = computed(() => props.models ?? localModels.value)
|
||||
// 按名称排序的模型列表
|
||||
const sortedModels = computed(() => {
|
||||
@@ -442,19 +449,38 @@ function handleTestDialogClose() {
|
||||
modelTest.resetState()
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
testRequestBodyDraft.value = ''
|
||||
}
|
||||
|
||||
function handleTestDialogBack() {
|
||||
if (modelTest.testing.value) return
|
||||
modelTest.testResult.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
modelTest.stopPolling()
|
||||
}
|
||||
|
||||
async function handleSelectTestEndpoint(endpointId: string) {
|
||||
if (!pendingTestModel.value) return
|
||||
function handleSelectTestEndpoint(endpointId: string) {
|
||||
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
|
||||
if (!endpoint) return
|
||||
selectedTestEndpoint.value = endpoint
|
||||
}
|
||||
|
||||
async function handleStartPendingTest() {
|
||||
if (modelTest.testing.value) return
|
||||
if (!pendingTestModel.value) return
|
||||
|
||||
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
|
||||
if (!endpoint) {
|
||||
showError('请选择要测试的端点')
|
||||
return
|
||||
}
|
||||
|
||||
const { value: requestBody, error } = parsedTestRequestBody.value
|
||||
if (!requestBody || error) {
|
||||
showError(`测试请求体无效: ${error || '无效 JSON'}`)
|
||||
return
|
||||
}
|
||||
|
||||
selectedTestEndpoint.value = endpoint
|
||||
const model = pendingTestModel.value
|
||||
const modelName = model.global_model_name || model.provider_model_name
|
||||
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
|
||||
@@ -464,28 +490,16 @@ async function handleSelectTestEndpoint(endpointId: string) {
|
||||
displayLabel: `${endpointPrefix}${modelName}`,
|
||||
apiFormat: endpoint.api_format,
|
||||
endpointId: endpoint.id,
|
||||
message: testMessageDraft.value,
|
||||
requestBody,
|
||||
concurrency: 5,
|
||||
onSuccess: () => {
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
},
|
||||
onError: () => {
|
||||
if (activeEndpoints.value.length > 1) {
|
||||
selectedTestEndpoint.value = null
|
||||
return true
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleStartPendingTest() {
|
||||
if (modelTest.testing.value) return
|
||||
const endpoint = activeEndpoints.value[0]
|
||||
if (!endpoint) return
|
||||
await handleSelectTestEndpoint(endpoint.id)
|
||||
}
|
||||
|
||||
async function testModelConnection(model: Model) {
|
||||
if (modelTest.testing.value) return
|
||||
|
||||
@@ -495,7 +509,10 @@ async function testModelConnection(model: Model) {
|
||||
}
|
||||
|
||||
pendingTestModel.value = model
|
||||
selectedTestEndpoint.value = null
|
||||
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
|
||||
testRequestBodyDraft.value = buildDefaultModelTestRequestBody(
|
||||
model.global_model_name || model.provider_model_name,
|
||||
)
|
||||
modelTest.testResult.value = null
|
||||
modelTest.dialogOpen.value = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
|
||||
|
||||
export function buildDefaultModelTestRequestBody(modelName: string): string {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: DEFAULT_MODEL_TEST_MESSAGE,
|
||||
},
|
||||
],
|
||||
max_tokens: 30,
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
export function parseModelTestRequestBodyDraft(
|
||||
draft: string,
|
||||
): { value: Record<string, unknown> | null; error: string | null } {
|
||||
const normalized = draft.trim()
|
||||
if (!normalized) {
|
||||
return {
|
||||
value: null,
|
||||
error: '测试请求体不能为空',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {
|
||||
value: null,
|
||||
error: '测试请求体必须是 JSON 对象',
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: parsed as Record<string, unknown>,
|
||||
error: null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : '无效的 JSON',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,10 @@
|
||||
-->{{ proxyTimingBreakdown(currentAttempt.extra_data.proxy) }}<!--
|
||||
-->)</span>
|
||||
</span>
|
||||
<code
|
||||
v-if="typeof currentAttempt.extra_data.proxy.node_id === 'string' && currentAttempt.extra_data.proxy.node_id"
|
||||
class="text-xs font-mono text-muted-foreground"
|
||||
>节点 Key {{ currentAttempt.extra_data.proxy.node_id }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -462,7 +466,7 @@ interface UsageData {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
requestId: string
|
||||
requestId?: string | null
|
||||
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
|
||||
overrideStatusCode?: number
|
||||
/** 请求侧 API 格式(客户端入口格式) */
|
||||
@@ -471,6 +475,12 @@ const props = defineProps<{
|
||||
usageData?: UsageData | null
|
||||
/** 请求元数据(用于号池调度组装) */
|
||||
requestMetadata?: Record<string, unknown> | null
|
||||
/** 已获取的追踪数据;传入时不再内部拉取 */
|
||||
traceData?: RequestTrace | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectAttempt: [attempt: CandidateRecord | null]
|
||||
}>()
|
||||
|
||||
// 用量数据(从 props 获取)
|
||||
@@ -525,7 +535,8 @@ const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const trace = ref<RequestTrace | null>(null)
|
||||
const internalTrace = ref<RequestTrace | null>(null)
|
||||
const trace = computed(() => props.traceData ?? internalTrace.value)
|
||||
const selectedGroupIndex = ref(0)
|
||||
const selectedAttemptIndex = ref(0)
|
||||
const hoveredGroupIndex = ref<number | null>(null)
|
||||
@@ -1045,6 +1056,10 @@ const currentAttempt = computed(() => {
|
||||
return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary
|
||||
})
|
||||
|
||||
watch(currentAttempt, (attempt) => {
|
||||
emit('selectAttempt', attempt ?? null)
|
||||
}, { immediate: true })
|
||||
|
||||
const currentGroupTitle = computed(() => {
|
||||
if (!selectedGroup.value || !currentAttempt.value) return ''
|
||||
if (selectedGroup.value.isPoolGroup) {
|
||||
@@ -1224,7 +1239,7 @@ const navigateGroup = (direction: number) => {
|
||||
// 加载请求追踪数据
|
||||
const isSilentRefresh = ref(false)
|
||||
const loadTrace = async (silent = false) => {
|
||||
if (!props.requestId) return
|
||||
if (!props.requestId || props.traceData) return
|
||||
|
||||
isSilentRefresh.value = silent
|
||||
|
||||
@@ -1234,7 +1249,7 @@ const loadTrace = async (silent = false) => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
trace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
} catch (err: unknown) {
|
||||
if (!silent) {
|
||||
error.value = parseApiError(err, '加载失败')
|
||||
@@ -1304,12 +1319,31 @@ watch(groupedTimeline, (newGroups) => {
|
||||
selectedAttemptIndex.value = 0
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听 requestId 变化
|
||||
watch(() => props.requestId, () => {
|
||||
selectedGroupIndex.value = 0
|
||||
selectedAttemptIndex.value = 0
|
||||
loadTrace()
|
||||
}, { immediate: true })
|
||||
// 监听 requestId / 外部 trace 变化
|
||||
watch(
|
||||
[() => props.requestId, () => props.traceData],
|
||||
() => {
|
||||
selectedGroupIndex.value = 0
|
||||
selectedAttemptIndex.value = 0
|
||||
|
||||
if (props.traceData) {
|
||||
internalTrace.value = null
|
||||
loading.value = false
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.requestId) {
|
||||
internalTrace.value = null
|
||||
loading.value = false
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
void loadTrace()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
defineExpose({ refresh: () => loadTrace(true) })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user