mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-04-29 04:38:38 +00:00
1
This commit is contained in:
@@ -1614,7 +1614,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 上游错误处理 -->
|
||||
<div v-if="form.platform === 'claude-console'">
|
||||
<div v-if="autoProtectionPlatforms.includes(form.platform)">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>上游错误处理</label
|
||||
>
|
||||
@@ -1714,24 +1714,26 @@
|
||||
{{ errors.baseUrl }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
填写 API 基础地址,必须以
|
||||
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600">/models</code>
|
||||
结尾。系统会自动拼接
|
||||
支持三种格式,系统自动识别:
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
以 /models 结尾:
|
||||
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
|
||||
>/{model}:generateContent</code
|
||||
>https://proxy.com/v1beta/models</code
|
||||
>
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
官方:
|
||||
模板模式:
|
||||
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
|
||||
>https://generativelanguage.googleapis.com/v1beta/models</code
|
||||
>https://proxy.com/api/{model}:{action}</code
|
||||
>
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
上游为 CRS:
|
||||
域名:
|
||||
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
|
||||
>https://your-crs.com/gemini/v1beta/models</code
|
||||
>https://generativelanguage.googleapis.com</code
|
||||
>
|
||||
(自动拼接 /v1beta/models)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3371,26 +3373,24 @@
|
||||
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上游错误处理(编辑模式)-->
|
||||
<div v-if="form.platform === 'claude-console'">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
上游错误处理
|
||||
</label>
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.disableAutoProtection"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
上游错误不自动暂停调度
|
||||
</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||
</p>
|
||||
</div>
|
||||
<!-- 上游错误处理(编辑模式)-->
|
||||
<div v-if="autoProtectionPlatforms.includes(form.platform)">
|
||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
上游错误处理
|
||||
</label>
|
||||
<label class="inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="form.disableAutoProtection"
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"> 上游错误不自动暂停调度 </span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
||||
@@ -3505,24 +3505,26 @@
|
||||
{{ errors.baseUrl }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
填写 API 基础地址,必须以
|
||||
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600">/models</code>
|
||||
结尾。系统会自动拼接
|
||||
支持三种格式,系统自动识别:
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
以 /models 结尾:
|
||||
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
|
||||
>/{model}:generateContent</code
|
||||
>https://proxy.com/v1beta/models</code
|
||||
>
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
官方:
|
||||
模板模式:
|
||||
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
|
||||
>https://generativelanguage.googleapis.com/v1beta/models</code
|
||||
>https://proxy.com/api/{model}:{action}</code
|
||||
>
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
上游为 CRS:
|
||||
域名:
|
||||
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
|
||||
>https://your-crs.com/gemini/v1beta/models</code
|
||||
>https://generativelanguage.googleapis.com</code
|
||||
>
|
||||
(自动拼接 /v1beta/models)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4039,6 +4041,20 @@ const handleCancel = () => {
|
||||
const isEdit = computed(() => !!props.account)
|
||||
const show = ref(true)
|
||||
|
||||
// 支持 disableAutoProtection 的平台白名单
|
||||
const autoProtectionPlatforms = [
|
||||
'claude-console',
|
||||
'ccr',
|
||||
'droid',
|
||||
'bedrock',
|
||||
'azure-openai',
|
||||
'azure_openai',
|
||||
'gemini',
|
||||
'gemini-api',
|
||||
'openai',
|
||||
'openai-responses'
|
||||
]
|
||||
|
||||
// OAuthFlow 组件引用
|
||||
const oauthFlowRef = ref(null)
|
||||
|
||||
@@ -4274,7 +4290,9 @@ const form = ref({
|
||||
})(),
|
||||
userAgent: props.account?.userAgent || '',
|
||||
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
|
||||
disableAutoProtection: props.account?.disableAutoProtection === true,
|
||||
disableAutoProtection:
|
||||
props.account?.disableAutoProtection === true ||
|
||||
props.account?.disableAutoProtection === 'true',
|
||||
// 额度管理字段
|
||||
dailyQuota: props.account?.dailyQuota || 0,
|
||||
dailyUsage: props.account?.dailyUsage || 0,
|
||||
@@ -5242,13 +5260,9 @@ const createAccount = async () => {
|
||||
errors.value.apiKey = '请填写 API Key'
|
||||
hasError = true
|
||||
}
|
||||
// 验证 baseUrl 必须以 /models 结尾
|
||||
if (!form.value.baseUrl || form.value.baseUrl.trim() === '') {
|
||||
errors.value.baseUrl = '请填写 API 基础地址'
|
||||
hasError = true
|
||||
} else if (!form.value.baseUrl.trim().endsWith('/models')) {
|
||||
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
// 其他平台(如 Droid)使用多 API Key 输入
|
||||
@@ -5407,9 +5421,7 @@ const createAccount = async () => {
|
||||
data.userAgent = form.value.userAgent || null
|
||||
// 如果不启用限流,传递 0 表示不限流
|
||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||
// 上游错误处理(仅 Claude Console)
|
||||
if (form.value.platform === 'claude-console') {
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
data.interceptWarmup = !!form.value.interceptWarmup
|
||||
}
|
||||
// 额度管理字段
|
||||
@@ -5474,6 +5486,11 @@ const createAccount = async () => {
|
||||
data.schedulable = form.value.schedulable !== false
|
||||
}
|
||||
|
||||
// 支持 disableAutoProtection 的平台才写入
|
||||
if (autoProtectionPlatforms.includes(form.value.platform)) {
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
}
|
||||
|
||||
let result
|
||||
if (form.value.platform === 'claude') {
|
||||
result = await accountsStore.createClaudeAccount(data)
|
||||
@@ -5540,17 +5557,13 @@ const updateAccount = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Gemini API 的 baseUrl 验证(必须以 /models 结尾)
|
||||
// Gemini API 的 baseUrl 验证
|
||||
if (form.value.platform === 'gemini-api') {
|
||||
const baseUrl = form.value.baseUrl?.trim() || ''
|
||||
if (!baseUrl) {
|
||||
errors.value.baseUrl = '请填写 API 基础地址'
|
||||
return
|
||||
}
|
||||
if (!baseUrl.endsWith('/models')) {
|
||||
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 分组类型验证 - 更新账户流程修复
|
||||
@@ -5755,8 +5768,6 @@ const updateAccount = async () => {
|
||||
data.userAgent = form.value.userAgent || null
|
||||
// 如果不启用限流,传递 0 表示不限流
|
||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||
// 上游错误处理
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
// 拦截预热请求
|
||||
data.interceptWarmup = !!form.value.interceptWarmup
|
||||
// 额度管理字段
|
||||
@@ -5847,6 +5858,11 @@ const updateAccount = async () => {
|
||||
: []
|
||||
}
|
||||
|
||||
// 支持 disableAutoProtection 的平台才写入
|
||||
if (autoProtectionPlatforms.includes(props.account.platform)) {
|
||||
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||
}
|
||||
|
||||
if (props.account.platform === 'claude') {
|
||||
await accountsStore.updateClaudeAccount(props.account.id, data)
|
||||
} else if (props.account.platform === 'claude-console') {
|
||||
@@ -6399,7 +6415,8 @@ watch(
|
||||
// 并发控制字段
|
||||
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
|
||||
// 上游错误处理
|
||||
disableAutoProtection: newAccount.disableAutoProtection === true
|
||||
disableAutoProtection:
|
||||
newAccount.disableAutoProtection === true || newAccount.disableAutoProtection === 'true'
|
||||
}
|
||||
|
||||
// 如果是Claude Console账户,加载实时使用情况
|
||||
|
||||
@@ -111,30 +111,12 @@
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
测试模型
|
||||
</label>
|
||||
<input
|
||||
<ModelSelector
|
||||
v-model="config.model"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500"
|
||||
:disabled="!config.enabled"
|
||||
placeholder="claude-sonnet-4-5-20250929"
|
||||
type="text"
|
||||
:models="modelOptions"
|
||||
placeholder="输入模型 ID..."
|
||||
/>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="modelOption in modelOptions"
|
||||
:key="modelOption.value"
|
||||
:class="[
|
||||
'rounded-lg border px-3 py-1.5 text-xs font-medium transition',
|
||||
config.model === modelOption.value
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
|
||||
!config.enabled && 'cursor-not-allowed opacity-50'
|
||||
]"
|
||||
:disabled="!config.enabled"
|
||||
@click="config.model = modelOption.value"
|
||||
>
|
||||
{{ modelOption.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试历史 -->
|
||||
@@ -219,9 +201,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { APP_CONFIG } from '@/utils/tools'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { getModelsApi } from '@/utils/http_apis'
|
||||
import ModelSelector from '@/components/common/ModelSelector.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -256,12 +240,18 @@ const cronPresets = [
|
||||
{ label: '工作日 9:00', value: '0 9 * * 1-5' }
|
||||
]
|
||||
|
||||
// 模型选项
|
||||
const modelOptions = [
|
||||
{ label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5-20250929' },
|
||||
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5-20251001' },
|
||||
{ label: 'Claude Opus 4.5', value: 'claude-opus-4-5-20251101' }
|
||||
]
|
||||
// 模型选项(从 API 动态获取)
|
||||
const modelOptions = ref([])
|
||||
|
||||
const loadModels = async () => {
|
||||
const result = await getModelsApi()
|
||||
if (result.success && result.data) {
|
||||
const platform = props.account?.platform
|
||||
modelOptions.value = result.data.platforms?.[platform] || result.data.claude || []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadModels)
|
||||
|
||||
// 格式化时间戳
|
||||
function formatTimestamp(timestamp) {
|
||||
|
||||
@@ -1,654 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
|
||||
>
|
||||
<div class="absolute inset-0" @click="handleClose" />
|
||||
<div
|
||||
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
|
||||
>
|
||||
<!-- 顶部栏 -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl text-white shadow-lg',
|
||||
testStatus === 'success'
|
||||
? 'bg-gradient-to-br from-green-500 to-emerald-500'
|
||||
: testStatus === 'error'
|
||||
? 'bg-gradient-to-br from-red-500 to-pink-500'
|
||||
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
||||
]"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'fas',
|
||||
testStatus === 'idle'
|
||||
? 'fa-vial'
|
||||
: testStatus === 'testing'
|
||||
? 'fa-spinner fa-spin'
|
||||
: testStatus === 'success'
|
||||
? 'fa-check'
|
||||
: 'fa-times'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">账户连通性测试</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ account?.name || '未知账户' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
:disabled="testStatus === 'testing'"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i class="fas fa-times text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="px-5 py-4">
|
||||
<!-- 测试信息 -->
|
||||
<div class="mb-4 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">平台类型</span>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
platformBadgeClass
|
||||
]"
|
||||
>
|
||||
<i :class="platformIcon" />
|
||||
{{ platformLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Bedrock 账号类型 -->
|
||||
<div
|
||||
v-if="props.account?.platform === 'bedrock'"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span class="text-gray-500 dark:text-gray-400">账号类型</span>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
credentialTypeBadgeClass
|
||||
]"
|
||||
>
|
||||
<i :class="credentialTypeIcon" />
|
||||
{{ credentialTypeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||
<select
|
||||
v-model="selectedModel"
|
||||
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
:disabled="testStatus === 'testing'"
|
||||
>
|
||||
<option v-for="m in availableModels" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示 -->
|
||||
<div :class="['mb-4 rounded-xl border p-4 transition-all duration-300', statusCardClass]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="['flex h-8 w-8 items-center justify-center rounded-lg', statusIconBgClass]"
|
||||
>
|
||||
<i :class="['fas text-sm', statusIcon, statusIconClass]" />
|
||||
</div>
|
||||
<div>
|
||||
<p :class="['font-medium', statusTextClass]">{{ statusTitle }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ statusDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 响应内容区域 -->
|
||||
<div
|
||||
v-if="testStatus !== 'idle'"
|
||||
class="mb-4 overflow-hidden rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">AI 响应</span>
|
||||
<span v-if="responseText" class="text-xs text-gray-500 dark:text-gray-500">
|
||||
{{ responseText.length }} 字符
|
||||
</span>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-y-auto p-3">
|
||||
<p
|
||||
v-if="responseText"
|
||||
class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ responseText }}
|
||||
<span
|
||||
v-if="testStatus === 'testing'"
|
||||
class="inline-block h-4 w-1 animate-pulse bg-blue-500"
|
||||
/>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="testStatus === 'testing'"
|
||||
class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-circle-notch fa-spin" />
|
||||
等待响应中...
|
||||
</p>
|
||||
<p
|
||||
v-else-if="testStatus === 'error' && errorMessage"
|
||||
class="text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试时间 -->
|
||||
<div
|
||||
v-if="testDuration > 0"
|
||||
class="mb-4 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-clock" />
|
||||
<span>耗时 {{ (testDuration / 1000).toFixed(2) }} 秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div
|
||||
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
|
||||
>
|
||||
<button
|
||||
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
:disabled="testStatus === 'testing'"
|
||||
@click="handleClose"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
|
||||
testStatus === 'testing'
|
||||
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
|
||||
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
|
||||
]"
|
||||
:disabled="testStatus === 'testing'"
|
||||
@click="startTest"
|
||||
>
|
||||
<i :class="['fas', testStatus === 'testing' ? 'fa-spinner fa-spin' : 'fa-play']" />
|
||||
{{
|
||||
testStatus === 'testing'
|
||||
? '测试中...'
|
||||
: testStatus === 'idle'
|
||||
? '开始测试'
|
||||
: '重新测试'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { APP_CONFIG } from '@/utils/tools'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
account: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 状态
|
||||
const testStatus = ref('idle') // idle, testing, success, error
|
||||
const responseText = ref('')
|
||||
const errorMessage = ref('')
|
||||
const testDuration = ref(0)
|
||||
const testStartTime = ref(null)
|
||||
const eventSource = ref(null)
|
||||
const selectedModel = ref('')
|
||||
|
||||
// 可用模型列表 - 根据账户类型
|
||||
const availableModels = computed(() => {
|
||||
if (!props.account) return []
|
||||
const platform = props.account.platform
|
||||
const modelLists = {
|
||||
claude: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
|
||||
'claude-console': [
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-haiku-20241022'
|
||||
],
|
||||
bedrock: [
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-haiku-20241022'
|
||||
],
|
||||
gemini: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'],
|
||||
'openai-responses': ['gpt-4o-mini', 'gpt-4o', 'o3-mini'],
|
||||
'azure-openai': [props.account.deploymentName || 'gpt-4o-mini'],
|
||||
droid: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
|
||||
ccr: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022']
|
||||
}
|
||||
return modelLists[platform] || []
|
||||
})
|
||||
|
||||
// 默认测试模型
|
||||
const defaultModel = computed(() => {
|
||||
if (!props.account) return ''
|
||||
const platform = props.account.platform
|
||||
const models = {
|
||||
claude: 'claude-sonnet-4-5-20250929',
|
||||
'claude-console': 'claude-sonnet-4-5-20250929',
|
||||
bedrock: 'claude-sonnet-4-5-20250929',
|
||||
gemini: 'gemini-2.5-flash',
|
||||
'openai-responses': 'gpt-4o-mini',
|
||||
'azure-openai': props.account.deploymentName || 'gpt-4o-mini',
|
||||
droid: 'claude-sonnet-4-20250514',
|
||||
ccr: 'claude-sonnet-4-20250514'
|
||||
}
|
||||
return models[platform] || ''
|
||||
})
|
||||
|
||||
// 监听账户变化,重置选中的模型
|
||||
watch(
|
||||
() => props.account,
|
||||
() => {
|
||||
selectedModel.value = defaultModel.value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 是否使用 SSE 流式响应
|
||||
const useSSE = computed(() => {
|
||||
if (!props.account) return false
|
||||
return ['claude', 'claude-console'].includes(props.account.platform)
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const platformLabel = computed(() => {
|
||||
if (!props.account) return '未知'
|
||||
const platform = props.account.platform
|
||||
const labels = {
|
||||
claude: 'Claude OAuth',
|
||||
'claude-console': 'Claude Console',
|
||||
bedrock: 'AWS Bedrock',
|
||||
gemini: 'Gemini',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
'azure-openai': 'Azure OpenAI',
|
||||
droid: 'Droid',
|
||||
ccr: 'CCR'
|
||||
}
|
||||
return labels[platform] || platform
|
||||
})
|
||||
|
||||
const platformIcon = computed(() => {
|
||||
if (!props.account) return 'fas fa-question'
|
||||
const platform = props.account.platform
|
||||
const icons = {
|
||||
claude: 'fas fa-brain',
|
||||
'claude-console': 'fas fa-brain',
|
||||
bedrock: 'fab fa-aws',
|
||||
gemini: 'fas fa-gem',
|
||||
'openai-responses': 'fas fa-code',
|
||||
'azure-openai': 'fab fa-microsoft',
|
||||
droid: 'fas fa-robot',
|
||||
ccr: 'fas fa-key'
|
||||
}
|
||||
return icons[platform] || 'fas fa-robot'
|
||||
})
|
||||
|
||||
const platformBadgeClass = computed(() => {
|
||||
if (!props.account) return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
const platform = props.account.platform
|
||||
const classes = {
|
||||
claude: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300',
|
||||
'claude-console': 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300',
|
||||
bedrock: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300',
|
||||
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300',
|
||||
'openai-responses': 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300',
|
||||
'azure-openai': 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300',
|
||||
droid: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-300',
|
||||
ccr: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
|
||||
}
|
||||
return classes[platform] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
})
|
||||
|
||||
// Bedrock 账号类型相关
|
||||
const credentialTypeLabel = computed(() => {
|
||||
if (!props.account || props.account.platform !== 'bedrock') return ''
|
||||
const credentialType = props.account.credentialType
|
||||
if (credentialType === 'access_key') return 'Access Key'
|
||||
if (credentialType === 'bearer_token') return 'Bearer Token'
|
||||
return 'Unknown'
|
||||
})
|
||||
|
||||
const credentialTypeIcon = computed(() => {
|
||||
if (!props.account || props.account.platform !== 'bedrock') return ''
|
||||
const credentialType = props.account.credentialType
|
||||
if (credentialType === 'access_key') return 'fas fa-key'
|
||||
if (credentialType === 'bearer_token') return 'fas fa-ticket'
|
||||
return 'fas fa-question'
|
||||
})
|
||||
|
||||
const credentialTypeBadgeClass = computed(() => {
|
||||
if (!props.account || props.account.platform !== 'bedrock')
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
const credentialType = props.account.credentialType
|
||||
if (credentialType === 'access_key') {
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
}
|
||||
if (credentialType === 'bearer_token') {
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
})
|
||||
|
||||
const statusTitle = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return '准备就绪'
|
||||
case 'testing':
|
||||
return '正在测试...'
|
||||
case 'success':
|
||||
return '测试成功'
|
||||
case 'error':
|
||||
return '测试失败'
|
||||
default:
|
||||
return '未知状态'
|
||||
}
|
||||
})
|
||||
|
||||
const statusDescription = computed(() => {
|
||||
const apiName = platformLabel.value || 'API'
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return '点击下方按钮开始测试账户连通性'
|
||||
case 'testing':
|
||||
return '正在发送测试请求并等待响应'
|
||||
case 'success':
|
||||
return `账户可以正常访问 ${apiName}`
|
||||
case 'error':
|
||||
return errorMessage.value || `无法连接到 ${apiName}`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const statusCardClass = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'
|
||||
case 'testing':
|
||||
return 'border-blue-200 bg-blue-50 dark:border-blue-500/30 dark:bg-blue-900/20'
|
||||
case 'success':
|
||||
return 'border-green-200 bg-green-50 dark:border-green-500/30 dark:bg-green-900/20'
|
||||
case 'error':
|
||||
return 'border-red-200 bg-red-50 dark:border-red-500/30 dark:bg-red-900/20'
|
||||
default:
|
||||
return 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'
|
||||
}
|
||||
})
|
||||
|
||||
const statusIconBgClass = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return 'bg-gray-200 dark:bg-gray-700'
|
||||
case 'testing':
|
||||
return 'bg-blue-100 dark:bg-blue-500/30'
|
||||
case 'success':
|
||||
return 'bg-green-100 dark:bg-green-500/30'
|
||||
case 'error':
|
||||
return 'bg-red-100 dark:bg-red-500/30'
|
||||
default:
|
||||
return 'bg-gray-200 dark:bg-gray-700'
|
||||
}
|
||||
})
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return 'fa-hourglass-start'
|
||||
case 'testing':
|
||||
return 'fa-spinner fa-spin'
|
||||
case 'success':
|
||||
return 'fa-check-circle'
|
||||
case 'error':
|
||||
return 'fa-exclamation-circle'
|
||||
default:
|
||||
return 'fa-question-circle'
|
||||
}
|
||||
})
|
||||
|
||||
const statusIconClass = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return 'text-gray-500 dark:text-gray-400'
|
||||
case 'testing':
|
||||
return 'text-blue-500 dark:text-blue-400'
|
||||
case 'success':
|
||||
return 'text-green-500 dark:text-green-400'
|
||||
case 'error':
|
||||
return 'text-red-500 dark:text-red-400'
|
||||
default:
|
||||
return 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
})
|
||||
|
||||
const statusTextClass = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return 'text-gray-700 dark:text-gray-300'
|
||||
case 'testing':
|
||||
return 'text-blue-700 dark:text-blue-300'
|
||||
case 'success':
|
||||
return 'text-green-700 dark:text-green-300'
|
||||
case 'error':
|
||||
return 'text-red-700 dark:text-red-300'
|
||||
default:
|
||||
return 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
function getTestEndpoint() {
|
||||
if (!props.account) return ''
|
||||
const platform = props.account.platform
|
||||
const endpoints = {
|
||||
claude: `${APP_CONFIG.apiPrefix}/admin/claude-accounts/${props.account.id}/test`,
|
||||
'claude-console': `${APP_CONFIG.apiPrefix}/admin/claude-console-accounts/${props.account.id}/test`,
|
||||
bedrock: `${APP_CONFIG.apiPrefix}/admin/bedrock-accounts/${props.account.id}/test`,
|
||||
gemini: `${APP_CONFIG.apiPrefix}/admin/gemini-accounts/${props.account.id}/test`,
|
||||
'openai-responses': `${APP_CONFIG.apiPrefix}/admin/openai-responses-accounts/${props.account.id}/test`,
|
||||
'azure-openai': `${APP_CONFIG.apiPrefix}/admin/azure-openai-accounts/${props.account.id}/test`,
|
||||
droid: `${APP_CONFIG.apiPrefix}/admin/droid-accounts/${props.account.id}/test`,
|
||||
ccr: `${APP_CONFIG.apiPrefix}/admin/ccr-accounts/${props.account.id}/test`
|
||||
}
|
||||
return endpoints[platform] || ''
|
||||
}
|
||||
|
||||
async function startTest() {
|
||||
if (!props.account) return
|
||||
|
||||
// 重置状态
|
||||
testStatus.value = 'testing'
|
||||
responseText.value = ''
|
||||
errorMessage.value = ''
|
||||
testDuration.value = 0
|
||||
testStartTime.value = Date.now()
|
||||
|
||||
// 关闭之前的连接
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close()
|
||||
}
|
||||
|
||||
const endpoint = getTestEndpoint()
|
||||
if (!endpoint) {
|
||||
testStatus.value = 'error'
|
||||
errorMessage.value = '不支持的账户类型'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取认证token
|
||||
const authToken = localStorage.getItem('authToken')
|
||||
|
||||
// 使用fetch发送POST请求
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||
},
|
||||
body: JSON.stringify({ model: selectedModel.value })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
// 根据账户类型处理响应
|
||||
if (useSSE.value) {
|
||||
// SSE 流式响应 (Claude/Console)
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let streamDone = false
|
||||
|
||||
while (!streamDone) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
streamDone = true
|
||||
continue
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6))
|
||||
handleSSEEvent(data)
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// JSON 响应 (其他平台)
|
||||
const data = await response.json()
|
||||
testDuration.value = Date.now() - testStartTime.value
|
||||
|
||||
if (data.success) {
|
||||
testStatus.value = 'success'
|
||||
responseText.value = data.data?.responseText || 'Test passed'
|
||||
} else {
|
||||
testStatus.value = 'error'
|
||||
errorMessage.value = data.message || 'Test failed'
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
testStatus.value = 'error'
|
||||
errorMessage.value = err.message || '连接失败'
|
||||
testDuration.value = Date.now() - testStartTime.value
|
||||
}
|
||||
}
|
||||
|
||||
function handleSSEEvent(data) {
|
||||
switch (data.type) {
|
||||
case 'test_start':
|
||||
// 测试开始
|
||||
break
|
||||
case 'content':
|
||||
responseText.value += data.text
|
||||
break
|
||||
case 'message_stop':
|
||||
// 消息结束
|
||||
break
|
||||
case 'test_complete':
|
||||
testDuration.value = Date.now() - testStartTime.value
|
||||
if (data.success) {
|
||||
testStatus.value = 'success'
|
||||
} else {
|
||||
testStatus.value = 'error'
|
||||
errorMessage.value = data.error || '测试失败'
|
||||
}
|
||||
break
|
||||
case 'error':
|
||||
testStatus.value = 'error'
|
||||
errorMessage.value = data.error || '未知错误'
|
||||
testDuration.value = Date.now() - testStartTime.value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (testStatus.value === 'testing') return
|
||||
|
||||
// 关闭SSE连接
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close()
|
||||
eventSource.value = null
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
testStatus.value = 'idle'
|
||||
responseText.value = ''
|
||||
errorMessage.value = ''
|
||||
testDuration.value = 0
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 监听show变化,重置状态并设置测试模型
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
testStatus.value = 'idle'
|
||||
responseText.value = ''
|
||||
errorMessage.value = ''
|
||||
testDuration.value = 0
|
||||
|
||||
// 根据平台和账号类型设置测试模型
|
||||
if (props.account?.platform === 'bedrock') {
|
||||
const credentialType = props.account.credentialType
|
||||
if (credentialType === 'bearer_token') {
|
||||
// Bearer Token 模式使用 Sonnet 4.5
|
||||
selectedModel.value = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
|
||||
} else {
|
||||
// Access Key 模式使用 Haiku(更快更便宜)
|
||||
selectedModel.value = 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||
}
|
||||
} else {
|
||||
// 其他平台使用默认模型
|
||||
selectedModel.value = 'claude-sonnet-4-5-20250929'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,629 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
|
||||
>
|
||||
<div class="absolute inset-0" @click="handleClose" />
|
||||
<div
|
||||
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
|
||||
>
|
||||
<!-- 顶部栏 -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl text-white shadow-lg',
|
||||
testStatus === 'success'
|
||||
? 'bg-gradient-to-br from-green-500 to-emerald-500'
|
||||
: testStatus === 'error'
|
||||
? 'bg-gradient-to-br from-red-500 to-pink-500'
|
||||
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
||||
]"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'fas',
|
||||
testStatus === 'idle'
|
||||
? 'fa-vial'
|
||||
: testStatus === 'testing'
|
||||
? 'fa-spinner fa-spin'
|
||||
: testStatus === 'success'
|
||||
? 'fa-check'
|
||||
: 'fa-times'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
API Key 端点测试
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ displayName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
:disabled="testStatus === 'testing'"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i class="fas fa-times text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="max-h-[70vh] overflow-y-auto px-5 py-4">
|
||||
<!-- API Key 显示区域(只读) -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
API Key
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
class="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 pr-10 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
readonly
|
||||
type="text"
|
||||
:value="maskedApiKey"
|
||||
/>
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<i class="fas fa-lock text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
测试将使用此 API Key 调用当前服务的 /api 端点
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 测试信息 -->
|
||||
<div class="mb-4 space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试端点</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
|
||||
>
|
||||
<i class="fas fa-link" />
|
||||
{{ serviceConfig.displayEndpoint }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||
<select
|
||||
v-model="testModel"
|
||||
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
<option v-for="model in availableModels" :key="model.value" :value="model.value">
|
||||
{{ model.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ testModel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">最大输出 Token</span>
|
||||
<select
|
||||
v-model="maxTokens"
|
||||
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
<option v-for="opt in maxTokensOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试服务</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||
serviceConfig.name
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示词输入 -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
提示词
|
||||
</label>
|
||||
<textarea
|
||||
v-model="testPrompt"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
placeholder="输入测试提示词..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示 -->
|
||||
<div :class="['mb-4 rounded-xl border p-4 transition-all duration-300', statusCardClass]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="['flex h-8 w-8 items-center justify-center rounded-lg', statusIconBgClass]"
|
||||
>
|
||||
<i :class="['fas text-sm', statusIcon, statusIconClass]" />
|
||||
</div>
|
||||
<div>
|
||||
<p :class="['font-medium', statusTextClass]">{{ statusTitle }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ statusDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 响应内容区域 -->
|
||||
<div
|
||||
v-if="testStatus !== 'idle'"
|
||||
class="mb-4 overflow-hidden rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">AI 响应</span>
|
||||
<span v-if="responseText" class="text-xs text-gray-500 dark:text-gray-500">
|
||||
{{ responseText.length }} 字符
|
||||
</span>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-y-auto p-3">
|
||||
<p
|
||||
v-if="responseText"
|
||||
class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ responseText }}
|
||||
<span
|
||||
v-if="testStatus === 'testing'"
|
||||
class="inline-block h-4 w-1 animate-pulse bg-blue-500"
|
||||
/>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="testStatus === 'testing'"
|
||||
class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-circle-notch fa-spin" />
|
||||
等待响应中...
|
||||
</p>
|
||||
<p
|
||||
v-else-if="testStatus === 'error' && errorMessage"
|
||||
class="text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试时间 -->
|
||||
<div
|
||||
v-if="testDuration > 0"
|
||||
class="mb-4 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-clock" />
|
||||
<span>耗时 {{ (testDuration / 1000).toFixed(2) }} 秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div
|
||||
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
|
||||
>
|
||||
<button
|
||||
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
:disabled="testStatus === 'testing'"
|
||||
@click="handleClose"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
|
||||
testStatus === 'testing' || !apiKeyValue
|
||||
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
|
||||
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
|
||||
]"
|
||||
:disabled="testStatus === 'testing' || !apiKeyValue"
|
||||
@click="startTest"
|
||||
>
|
||||
<i :class="['fas', testStatus === 'testing' ? 'fa-spinner fa-spin' : 'fa-play']" />
|
||||
{{
|
||||
testStatus === 'testing'
|
||||
? '测试中...'
|
||||
: testStatus === 'idle'
|
||||
? '开始测试'
|
||||
: '重新测试'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted, onMounted } from 'vue'
|
||||
import { APP_CONFIG } from '@/utils/tools'
|
||||
import { getModelsApi } from '@/utils/http_apis'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// API Key 完整值(用于测试)
|
||||
apiKeyValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// API Key 名称(用于显示)
|
||||
apiKeyName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 服务类型: claude, gemini, openai
|
||||
serviceType: {
|
||||
type: String,
|
||||
default: 'claude',
|
||||
validator: (value) => ['claude', 'gemini', 'openai'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 状态
|
||||
const testStatus = ref('idle') // idle, testing, success, error
|
||||
const responseText = ref('')
|
||||
const errorMessage = ref('')
|
||||
const testDuration = ref(0)
|
||||
const testStartTime = ref(null)
|
||||
const abortController = ref(null)
|
||||
|
||||
// 测试模型
|
||||
const testModel = ref('claude-sonnet-4-5-20250929')
|
||||
|
||||
// 测试提示词
|
||||
const testPrompt = ref('hi')
|
||||
|
||||
// 最大输出 token
|
||||
const maxTokens = ref(1000)
|
||||
const maxTokensOptions = [
|
||||
{ value: 100, label: '100' },
|
||||
{ value: 500, label: '500' },
|
||||
{ value: 1000, label: '1000' },
|
||||
{ value: 2000, label: '2000' },
|
||||
{ value: 4096, label: '4096' }
|
||||
]
|
||||
|
||||
// 从 API 获取的模型列表
|
||||
const modelsFromApi = ref({
|
||||
claude: [],
|
||||
gemini: [],
|
||||
openai: []
|
||||
})
|
||||
|
||||
// 加载模型列表
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const result = await getModelsApi()
|
||||
if (result.success && result.data) {
|
||||
modelsFromApi.value = {
|
||||
claude: result.data.claude || [],
|
||||
gemini: result.data.gemini || [],
|
||||
openai: result.data.openai || []
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 服务配置
|
||||
const serviceConfig = computed(() => {
|
||||
const configs = {
|
||||
claude: {
|
||||
name: 'Claude',
|
||||
endpoint: '/api-key/test',
|
||||
defaultModel: 'claude-sonnet-4-5-20250929',
|
||||
displayEndpoint: '/api/v1/messages'
|
||||
},
|
||||
gemini: {
|
||||
name: 'Gemini',
|
||||
endpoint: '/api-key/test-gemini',
|
||||
defaultModel: 'gemini-2.5-pro',
|
||||
displayEndpoint: '/gemini/v1/models/:model:streamGenerateContent'
|
||||
},
|
||||
openai: {
|
||||
name: 'OpenAI (Codex)',
|
||||
endpoint: '/api-key/test-openai',
|
||||
defaultModel: 'gpt-5',
|
||||
displayEndpoint: '/openai/responses'
|
||||
}
|
||||
}
|
||||
return configs[props.serviceType] || configs.claude
|
||||
})
|
||||
|
||||
// 可用模型列表(从 API 获取)
|
||||
const availableModels = computed(() => {
|
||||
return modelsFromApi.value[props.serviceType] || []
|
||||
})
|
||||
|
||||
// 组件挂载时加载模型
|
||||
onMounted(() => {
|
||||
loadModels()
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const displayName = computed(() => {
|
||||
return props.apiKeyName || '当前 API Key'
|
||||
})
|
||||
|
||||
const maskedApiKey = computed(() => {
|
||||
const key = props.apiKeyValue
|
||||
if (!key) return ''
|
||||
if (key.length <= 10) return '****'
|
||||
return key.substring(0, 6) + '****' + key.substring(key.length - 4)
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const statusTitle = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return '准备就绪'
|
||||
case 'testing':
|
||||
return '正在测试...'
|
||||
case 'success':
|
||||
return '测试成功'
|
||||
case 'error':
|
||||
return '测试失败'
|
||||
default:
|
||||
return '未知状态'
|
||||
}
|
||||
})
|
||||
|
||||
const statusDescription = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return '点击下方按钮开始测试 API Key 连通性'
|
||||
case 'testing':
|
||||
return '正在通过 /api 端点发送测试请求'
|
||||
case 'success':
|
||||
return 'API Key 可以正常访问服务'
|
||||
case 'error':
|
||||
return errorMessage.value || '无法通过 API Key 访问服务'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const statusCardClass = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'
|
||||
case 'testing':
|
||||
return 'border-blue-200 bg-blue-50 dark:border-blue-500/30 dark:bg-blue-900/20'
|
||||
case 'success':
|
||||
return 'border-green-200 bg-green-50 dark:border-green-500/30 dark:bg-green-900/20'
|
||||
case 'error':
|
||||
return 'border-red-200 bg-red-50 dark:border-red-500/30 dark:bg-red-900/20'
|
||||
default:
|
||||
return 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'
|
||||
}
|
||||
})
|
||||
|
||||
const statusIconBgClass = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return 'bg-gray-200 dark:bg-gray-700'
|
||||
case 'testing':
|
||||
return 'bg-blue-100 dark:bg-blue-500/30'
|
||||
case 'success':
|
||||
return 'bg-green-100 dark:bg-green-500/30'
|
||||
case 'error':
|
||||
return 'bg-red-100 dark:bg-red-500/30'
|
||||
default:
|
||||
return 'bg-gray-200 dark:bg-gray-700'
|
||||
}
|
||||
})
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return 'fa-hourglass-start'
|
||||
case 'testing':
|
||||
return 'fa-spinner fa-spin'
|
||||
case 'success':
|
||||
return 'fa-check-circle'
|
||||
case 'error':
|
||||
return 'fa-exclamation-circle'
|
||||
default:
|
||||
return 'fa-question-circle'
|
||||
}
|
||||
})
|
||||
|
||||
const statusIconClass = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return 'text-gray-500 dark:text-gray-400'
|
||||
case 'testing':
|
||||
return 'text-blue-500 dark:text-blue-400'
|
||||
case 'success':
|
||||
return 'text-green-500 dark:text-green-400'
|
||||
case 'error':
|
||||
return 'text-red-500 dark:text-red-400'
|
||||
default:
|
||||
return 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
})
|
||||
|
||||
const statusTextClass = computed(() => {
|
||||
switch (testStatus.value) {
|
||||
case 'idle':
|
||||
return 'text-gray-700 dark:text-gray-300'
|
||||
case 'testing':
|
||||
return 'text-blue-700 dark:text-blue-300'
|
||||
case 'success':
|
||||
return 'text-green-700 dark:text-green-300'
|
||||
case 'error':
|
||||
return 'text-red-700 dark:text-red-300'
|
||||
default:
|
||||
return 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
async function startTest() {
|
||||
if (!props.apiKeyValue) return
|
||||
|
||||
// 重置状态
|
||||
testStatus.value = 'testing'
|
||||
responseText.value = ''
|
||||
errorMessage.value = ''
|
||||
testDuration.value = 0
|
||||
testStartTime.value = Date.now()
|
||||
|
||||
// 取消之前的请求
|
||||
if (abortController.value) {
|
||||
abortController.value.abort()
|
||||
}
|
||||
abortController.value = new AbortController()
|
||||
|
||||
// 使用公开的测试端点,不需要管理员认证
|
||||
// apiStats 路由挂载在 /apiStats 下
|
||||
const endpoint = `${APP_CONFIG.apiPrefix}/apiStats${serviceConfig.value.endpoint}`
|
||||
|
||||
try {
|
||||
// 使用fetch发送POST请求并处理SSE
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: props.apiKeyValue,
|
||||
model: testModel.value,
|
||||
prompt: testPrompt.value,
|
||||
maxTokens: maxTokens.value
|
||||
}),
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || errorData.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
// 处理SSE流
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let streamDone = false
|
||||
|
||||
while (!streamDone) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
streamDone = true
|
||||
continue
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6))
|
||||
handleSSEEvent(data)
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
// 请求被取消
|
||||
return
|
||||
}
|
||||
testStatus.value = 'error'
|
||||
errorMessage.value = err.message || '连接失败'
|
||||
testDuration.value = Date.now() - testStartTime.value
|
||||
}
|
||||
}
|
||||
|
||||
function handleSSEEvent(data) {
|
||||
switch (data.type) {
|
||||
case 'test_start':
|
||||
// 测试开始
|
||||
break
|
||||
case 'content':
|
||||
responseText.value += data.text
|
||||
break
|
||||
case 'message_stop':
|
||||
// 消息结束
|
||||
break
|
||||
case 'test_complete':
|
||||
testDuration.value = Date.now() - testStartTime.value
|
||||
if (data.success) {
|
||||
testStatus.value = 'success'
|
||||
} else {
|
||||
testStatus.value = 'error'
|
||||
errorMessage.value = data.error || '测试失败'
|
||||
}
|
||||
break
|
||||
case 'error':
|
||||
testStatus.value = 'error'
|
||||
errorMessage.value = data.error || '未知错误'
|
||||
testDuration.value = Date.now() - testStartTime.value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (testStatus.value === 'testing') return
|
||||
|
||||
// 取消请求
|
||||
if (abortController.value) {
|
||||
abortController.value.abort()
|
||||
abortController.value = null
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
testStatus.value = 'idle'
|
||||
responseText.value = ''
|
||||
errorMessage.value = ''
|
||||
testDuration.value = 0
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 监听show变化,重置状态
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
testStatus.value = 'idle'
|
||||
responseText.value = ''
|
||||
errorMessage.value = ''
|
||||
testDuration.value = 0
|
||||
// 重置为当前服务的默认模型
|
||||
testModel.value = serviceConfig.value.defaultModel
|
||||
// 重置提示词和 maxTokens
|
||||
testPrompt.value = 'hi'
|
||||
maxTokens.value = 1000
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听服务类型变化,重置模型
|
||||
watch(
|
||||
() => props.serviceType,
|
||||
() => {
|
||||
testModel.value = serviceConfig.value.defaultModel
|
||||
}
|
||||
)
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
69
web/admin-spa/src/components/common/ModelSelector.vue
Normal file
69
web/admin-spa/src/components/common/ModelSelector.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 下拉选择模式 -->
|
||||
<select
|
||||
v-if="!customMode"
|
||||
class="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
:disabled="disabled"
|
||||
:value="modelValue"
|
||||
@change="handleSelectChange"
|
||||
>
|
||||
<option v-for="m in models" :key="m.value" :value="m.value">
|
||||
{{ m.label }}
|
||||
</option>
|
||||
<option value="__custom__">自定义模型...</option>
|
||||
</select>
|
||||
|
||||
<!-- 自定义输入模式 -->
|
||||
<template v-else>
|
||||
<input
|
||||
class="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:placeholder-gray-500"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
type="text"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<button
|
||||
class="flex-shrink-0 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-gray-500 transition hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
:disabled="disabled"
|
||||
title="返回列表"
|
||||
@click="exitCustomMode"
|
||||
>
|
||||
<i class="fas fa-list text-[10px]" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
models: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: '输入模型 ID...' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const customMode = ref(false)
|
||||
|
||||
const handleSelectChange = (e) => {
|
||||
if (e.target.value === '__custom__') {
|
||||
customMode.value = true
|
||||
emit('update:modelValue', '')
|
||||
} else {
|
||||
emit('update:modelValue', e.target.value)
|
||||
}
|
||||
}
|
||||
|
||||
const exitCustomMode = () => {
|
||||
customMode.value = false
|
||||
// 切回列表时选中第一个预设模型
|
||||
if (props.models.length > 0) {
|
||||
emit('update:modelValue', props.models[0].value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
589
web/admin-spa/src/components/common/UnifiedTestModal.vue
Normal file
589
web/admin-spa/src/components/common/UnifiedTestModal.vue
Normal file
@@ -0,0 +1,589 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[1050] flex items-center justify-center bg-gray-900/40 backdrop-blur-sm"
|
||||
>
|
||||
<div class="absolute inset-0" @click="handleClose" />
|
||||
<div
|
||||
class="relative z-10 mx-3 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-gray-200/70 bg-white/95 shadow-2xl ring-1 ring-black/5 transition-all dark:border-gray-700/60 dark:bg-gray-900/95 dark:ring-white/10 sm:mx-4"
|
||||
>
|
||||
<!-- 顶部栏 -->
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-100 bg-white/80 px-5 py-4 backdrop-blur dark:border-gray-800 dark:bg-gray-900/80"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl text-white shadow-lg',
|
||||
headerIconBgClass
|
||||
]"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'fas',
|
||||
state.testStatus.value === 'idle'
|
||||
? 'fa-vial'
|
||||
: state.testStatus.value === 'testing'
|
||||
? 'fa-spinner fa-spin'
|
||||
: state.testStatus.value === 'success'
|
||||
? 'fa-check'
|
||||
: 'fa-times'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ modalTitle }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ modalSubtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
:disabled="state.testStatus.value === 'testing'"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i class="fas fa-times text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="max-h-[70vh] overflow-y-auto px-5 py-4">
|
||||
<!-- [apikey] API Key 显示 -->
|
||||
<div v-if="mode === 'apikey'" class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
API Key
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
class="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 pr-10 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
readonly
|
||||
type="text"
|
||||
:value="maskedApiKey"
|
||||
/>
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<i class="fas fa-lock text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试信息 -->
|
||||
<div class="mb-4 space-y-2">
|
||||
<!-- [account] 平台类型 -->
|
||||
<div v-if="mode === 'account'" class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">平台类型</span>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
platformBadgeClass
|
||||
]"
|
||||
>
|
||||
<i :class="platformIcon" />
|
||||
{{ platformLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- [account+bedrock] 凭证类型 -->
|
||||
<div
|
||||
v-if="mode === 'account' && account?.platform === 'bedrock'"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span class="text-gray-500 dark:text-gray-400">账号类型</span>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
credentialTypeBadgeClass
|
||||
]"
|
||||
>
|
||||
<i :class="credentialTypeIcon" />
|
||||
{{ credentialTypeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- [apikey] 测试端点 -->
|
||||
<div v-if="mode === 'apikey'" class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试端点</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
|
||||
>
|
||||
<i class="fas fa-link" />
|
||||
{{ apikeyServiceConfig.displayEndpoint }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 测试模型(两种模式都有) -->
|
||||
<div class="text-sm">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||
<ModelSelector
|
||||
v-model="selectedModel"
|
||||
:disabled="state.testStatus.value === 'testing'"
|
||||
:models="availableModels"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ selectedModel }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- [apikey] 最大输出 Token -->
|
||||
<div v-if="mode === 'apikey'" class="text-sm">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">最大输出 Token</span>
|
||||
<select
|
||||
v-model="maxTokens"
|
||||
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
<option v-for="opt in maxTokensOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- [apikey] 测试服务 -->
|
||||
<div v-if="mode === 'apikey'" class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">测试服务</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ apikeyServiceConfig.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- [apikey] 提示词输入 -->
|
||||
<div v-if="mode === 'apikey'" class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
提示词
|
||||
</label>
|
||||
<textarea
|
||||
v-model="testPrompt"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||
placeholder="输入测试提示词..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示 -->
|
||||
<div
|
||||
:class="[
|
||||
'mb-4 rounded-xl border p-4 transition-all duration-300',
|
||||
state.statusCardClass.value
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
state.statusIconBgClass.value
|
||||
]"
|
||||
>
|
||||
<i :class="['fas text-sm', state.statusIcon.value, state.statusIconClass.value]" />
|
||||
</div>
|
||||
<div>
|
||||
<p :class="['font-medium', state.statusTextClass.value]">
|
||||
{{ state.statusTitle.value }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ statusDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 响应内容区域 -->
|
||||
<div
|
||||
v-if="state.testStatus.value !== 'idle'"
|
||||
class="mb-4 overflow-hidden rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">AI 响应</span>
|
||||
<span
|
||||
v-if="state.responseText.value"
|
||||
class="text-xs text-gray-500 dark:text-gray-500"
|
||||
>
|
||||
{{ state.responseText.value.length }} 字符
|
||||
</span>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-y-auto p-3">
|
||||
<p
|
||||
v-if="state.responseText.value"
|
||||
class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ state.responseText.value }}
|
||||
<span
|
||||
v-if="state.testStatus.value === 'testing'"
|
||||
class="inline-block h-4 w-1 animate-pulse bg-blue-500"
|
||||
/>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="state.testStatus.value === 'testing'"
|
||||
class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-circle-notch fa-spin" />
|
||||
等待响应中...
|
||||
</p>
|
||||
<p
|
||||
v-else-if="state.testStatus.value === 'error' && state.errorMessage.value"
|
||||
class="text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ state.errorMessage.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试时间 -->
|
||||
<div
|
||||
v-if="state.testDuration.value > 0"
|
||||
class="mb-4 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-clock" />
|
||||
<span>耗时 {{ (state.testDuration.value / 1000).toFixed(2) }} 秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div
|
||||
class="flex items-center justify-end gap-3 border-t border-gray-100 bg-gray-50/80 px-5 py-3 dark:border-gray-800 dark:bg-gray-900/50"
|
||||
>
|
||||
<button
|
||||
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
:disabled="state.testStatus.value === 'testing'"
|
||||
@click="handleClose"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
|
||||
state.testStatus.value === 'testing' || disableTest
|
||||
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
|
||||
: 'bg-gradient-to-r from-blue-500 to-indigo-500 text-white hover:from-blue-600 hover:to-indigo-600 hover:shadow-md'
|
||||
]"
|
||||
:disabled="state.testStatus.value === 'testing' || disableTest"
|
||||
@click="startTest"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'fas',
|
||||
state.testStatus.value === 'testing' ? 'fa-spinner fa-spin' : 'fa-play'
|
||||
]"
|
||||
/>
|
||||
{{
|
||||
state.testStatus.value === 'testing'
|
||||
? '测试中...'
|
||||
: state.testStatus.value === 'idle'
|
||||
? '开始测试'
|
||||
: '重新测试'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { APP_CONFIG } from '@/utils/tools'
|
||||
import { getModelsApi } from '@/utils/http_apis'
|
||||
import { useTestState } from '@/utils/useTestState'
|
||||
import ModelSelector from '@/components/common/ModelSelector.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'account' }, // 'account' | 'apikey'
|
||||
// account 模式
|
||||
account: { type: Object, default: null },
|
||||
// apikey 模式
|
||||
apiKeyValue: { type: String, default: '' },
|
||||
apiKeyName: { type: String, default: '' },
|
||||
serviceType: { type: String, default: 'claude' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const state = useTestState()
|
||||
|
||||
// ========== 模型相关 ==========
|
||||
const selectedModel = ref('')
|
||||
const modelsFromApi = ref({ claude: [], gemini: [], openai: [], platforms: {} })
|
||||
|
||||
const loadModels = async () => {
|
||||
const result = await getModelsApi()
|
||||
if (result.success && result.data) {
|
||||
modelsFromApi.value = result.data
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadModels)
|
||||
|
||||
const availableModels = computed(() => {
|
||||
if (props.mode === 'account') {
|
||||
const platform = props.account?.platform
|
||||
if (!platform) return []
|
||||
// azure-openai 使用 deploymentName
|
||||
if (platform === 'azure-openai') {
|
||||
return [{ value: props.account.deploymentName, label: props.account.deploymentName }]
|
||||
}
|
||||
return modelsFromApi.value.platforms?.[platform] || []
|
||||
}
|
||||
// apikey 模式
|
||||
return modelsFromApi.value[props.serviceType] || []
|
||||
})
|
||||
|
||||
// 各平台回退默认模型(模型列表未加载时使用)
|
||||
const platformFallbackModels = {
|
||||
claude: 'claude-sonnet-4-5-20250929',
|
||||
'claude-console': 'claude-sonnet-4-5-20250929',
|
||||
gemini: 'gemini-2.5-pro',
|
||||
'openai-responses': 'gpt-5',
|
||||
droid: 'claude-sonnet-4-5-20250929',
|
||||
ccr: 'claude-sonnet-4-5-20250929'
|
||||
}
|
||||
|
||||
const defaultModel = computed(() => {
|
||||
if (props.mode === 'account') {
|
||||
const platform = props.account?.platform
|
||||
if (platform === 'azure-openai') return props.account?.deploymentName
|
||||
// bedrock 优先用列表,列表为空时按凭证类型回退
|
||||
if (platform === 'bedrock') {
|
||||
const models = availableModels.value
|
||||
if (models.length > 0) return models[0].value
|
||||
if (props.account?.credentialType === 'bearer_token')
|
||||
return 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
|
||||
return 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
|
||||
}
|
||||
const models = availableModels.value
|
||||
if (models.length > 0) return models[0].value
|
||||
return platformFallbackModels[platform] || platformFallbackModels.claude
|
||||
}
|
||||
// apikey 模式: 优先用列表,回退用 serviceConfig 的 defaultModel
|
||||
const models = availableModels.value
|
||||
if (models.length > 0) return models[0].value
|
||||
return apikeyServiceConfig.value.defaultModel
|
||||
})
|
||||
|
||||
// ========== apikey 模式专用 ==========
|
||||
const testPrompt = ref('hi')
|
||||
const maxTokens = ref(1000)
|
||||
const maxTokensOptions = [
|
||||
{ value: 100, label: '100' },
|
||||
{ value: 500, label: '500' },
|
||||
{ value: 1000, label: '1000' },
|
||||
{ value: 2000, label: '2000' },
|
||||
{ value: 4096, label: '4096' }
|
||||
]
|
||||
|
||||
const apikeyServiceConfigs = {
|
||||
claude: {
|
||||
name: 'Claude',
|
||||
endpoint: '/api-key/test',
|
||||
defaultModel: 'claude-sonnet-4-5-20250929',
|
||||
displayEndpoint: '/api/v1/messages'
|
||||
},
|
||||
gemini: {
|
||||
name: 'Gemini',
|
||||
endpoint: '/api-key/test-gemini',
|
||||
defaultModel: 'gemini-2.5-pro',
|
||||
displayEndpoint: '/gemini/v1/models/:model:streamGenerateContent'
|
||||
},
|
||||
openai: {
|
||||
name: 'OpenAI (Codex)',
|
||||
endpoint: '/api-key/test-openai',
|
||||
defaultModel: 'gpt-5',
|
||||
displayEndpoint: '/openai/responses'
|
||||
}
|
||||
}
|
||||
|
||||
const apikeyServiceConfig = computed(
|
||||
() => apikeyServiceConfigs[props.serviceType] || apikeyServiceConfigs.claude
|
||||
)
|
||||
|
||||
const maskedApiKey = computed(() => {
|
||||
const key = props.apiKeyValue
|
||||
if (!key) return ''
|
||||
if (key.length <= 10) return '****'
|
||||
return key.substring(0, 6) + '****' + key.substring(key.length - 4)
|
||||
})
|
||||
|
||||
const disableTest = computed(() => props.mode === 'apikey' && !props.apiKeyValue)
|
||||
|
||||
// ========== account 模式 - 平台信息 ==========
|
||||
const platformConfigs = {
|
||||
claude: {
|
||||
label: 'Claude OAuth',
|
||||
icon: 'fas fa-brain',
|
||||
badge: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
||||
},
|
||||
'claude-console': {
|
||||
label: 'Claude Console',
|
||||
icon: 'fas fa-brain',
|
||||
badge: 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
|
||||
},
|
||||
bedrock: {
|
||||
label: 'AWS Bedrock',
|
||||
icon: 'fab fa-aws',
|
||||
badge: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
|
||||
},
|
||||
gemini: {
|
||||
label: 'Gemini',
|
||||
icon: 'fas fa-gem',
|
||||
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
},
|
||||
'openai-responses': {
|
||||
label: 'OpenAI Responses',
|
||||
icon: 'fas fa-code',
|
||||
badge: 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
|
||||
},
|
||||
'azure-openai': {
|
||||
label: 'Azure OpenAI',
|
||||
icon: 'fab fa-microsoft',
|
||||
badge: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300'
|
||||
},
|
||||
droid: {
|
||||
label: 'Droid',
|
||||
icon: 'fas fa-robot',
|
||||
badge: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-300'
|
||||
},
|
||||
ccr: {
|
||||
label: 'CCR',
|
||||
icon: 'fas fa-key',
|
||||
badge: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
|
||||
}
|
||||
}
|
||||
|
||||
const platformConfig = computed(
|
||||
() =>
|
||||
platformConfigs[props.account?.platform] || {
|
||||
label: '未知',
|
||||
icon: 'fas fa-question',
|
||||
badge: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
}
|
||||
)
|
||||
const platformLabel = computed(() => platformConfig.value.label)
|
||||
const platformIcon = computed(() => platformConfig.value.icon)
|
||||
const platformBadgeClass = computed(() => platformConfig.value.badge)
|
||||
|
||||
const credentialTypeLabel = computed(() => {
|
||||
const ct = props.account?.credentialType
|
||||
if (ct === 'access_key') return 'Access Key'
|
||||
if (ct === 'bearer_token') return 'Bearer Token'
|
||||
return 'Unknown'
|
||||
})
|
||||
const credentialTypeIcon = computed(() => {
|
||||
const ct = props.account?.credentialType
|
||||
if (ct === 'access_key') return 'fas fa-key'
|
||||
if (ct === 'bearer_token') return 'fas fa-ticket'
|
||||
return 'fas fa-question'
|
||||
})
|
||||
const credentialTypeBadgeClass = computed(() => {
|
||||
const ct = props.account?.credentialType
|
||||
if (ct === 'access_key') return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
if (ct === 'bearer_token')
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
})
|
||||
|
||||
// ========== 通用计算属性 ==========
|
||||
const modalTitle = computed(() =>
|
||||
props.mode === 'account' ? '账户连通性测试' : 'API Key 端点测试'
|
||||
)
|
||||
const modalSubtitle = computed(() => {
|
||||
if (props.mode === 'account') return props.account?.name || '未知账户'
|
||||
return props.apiKeyName || '当前 API Key'
|
||||
})
|
||||
|
||||
const headerIconBgClass = computed(() => {
|
||||
const s = state.testStatus.value
|
||||
if (s === 'success') return 'bg-gradient-to-br from-green-500 to-emerald-500'
|
||||
if (s === 'error') return 'bg-gradient-to-br from-red-500 to-pink-500'
|
||||
return 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
||||
})
|
||||
|
||||
const statusDescription = computed(() => {
|
||||
const s = state.testStatus.value
|
||||
const apiName = props.mode === 'account' ? platformLabel.value : apikeyServiceConfig.value.name
|
||||
if (s === 'idle')
|
||||
return props.mode === 'account'
|
||||
? '点击下方按钮开始测试账户连通性'
|
||||
: '点击下方按钮开始测试 API Key 连通性'
|
||||
if (s === 'testing') return '正在发送测试请求并等待响应'
|
||||
if (s === 'success')
|
||||
return props.mode === 'account' ? `账户可以正常访问 ${apiName}` : 'API Key 可以正常访问服务'
|
||||
if (s === 'error') return state.errorMessage.value || `无法连接到 ${apiName}`
|
||||
return ''
|
||||
})
|
||||
|
||||
// ========== 测试逻辑 ==========
|
||||
const getAccountEndpoint = () => {
|
||||
if (!props.account) return ''
|
||||
const platform = props.account.platform
|
||||
const endpoints = {
|
||||
claude: `${APP_CONFIG.apiPrefix}/admin/claude-accounts/${props.account.id}/test`,
|
||||
'claude-console': `${APP_CONFIG.apiPrefix}/admin/claude-console-accounts/${props.account.id}/test`,
|
||||
bedrock: `${APP_CONFIG.apiPrefix}/admin/bedrock-accounts/${props.account.id}/test`,
|
||||
gemini: `${APP_CONFIG.apiPrefix}/admin/gemini-accounts/${props.account.id}/test`,
|
||||
'openai-responses': `${APP_CONFIG.apiPrefix}/admin/openai-responses-accounts/${props.account.id}/test`,
|
||||
'azure-openai': `${APP_CONFIG.apiPrefix}/admin/azure-openai-accounts/${props.account.id}/test`,
|
||||
droid: `${APP_CONFIG.apiPrefix}/admin/droid-accounts/${props.account.id}/test`,
|
||||
ccr: `${APP_CONFIG.apiPrefix}/admin/ccr-accounts/${props.account.id}/test`
|
||||
}
|
||||
return endpoints[platform] || ''
|
||||
}
|
||||
|
||||
const startTest = () => {
|
||||
if (props.mode === 'account') {
|
||||
const endpoint = getAccountEndpoint()
|
||||
if (!endpoint) return
|
||||
const authToken = localStorage.getItem('authToken')
|
||||
const useSSE = ['claude', 'claude-console', 'bedrock'].includes(props.account.platform)
|
||||
state.sendTestRequest(
|
||||
endpoint,
|
||||
{ model: selectedModel.value },
|
||||
{
|
||||
useSSE,
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
const endpoint = `${APP_CONFIG.apiPrefix}/apiStats${apikeyServiceConfig.value.endpoint}`
|
||||
state.sendTestRequest(
|
||||
endpoint,
|
||||
{
|
||||
apiKey: props.apiKeyValue,
|
||||
model: selectedModel.value,
|
||||
prompt: testPrompt.value,
|
||||
maxTokens: maxTokens.value
|
||||
},
|
||||
{ useSSE: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (state.testStatus.value === 'testing') return
|
||||
state.cleanup()
|
||||
state.resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// ========== 监听 ==========
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
state.resetState()
|
||||
selectedModel.value = defaultModel.value
|
||||
if (props.mode === 'apikey') {
|
||||
testPrompt.value = 'hi'
|
||||
maxTokens.value = 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.account, props.serviceType],
|
||||
() => {
|
||||
selectedModel.value = defaultModel.value
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
345
web/admin-spa/src/components/settings/ModelPricingSection.vue
Normal file
345
web/admin-spa/src/components/settings/ModelPricingSection.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 状态卡片 -->
|
||||
<div
|
||||
class="mb-6 rounded-xl border border-gray-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-4 dark:border-gray-700 dark:from-blue-900/20 dark:to-indigo-900/20"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400"
|
||||
>
|
||||
<i class="fas fa-coins text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
模型总数:
|
||||
<span class="font-bold text-blue-600 dark:text-blue-400">{{ modelCount }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">上次更新: {{ lastUpdated }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition',
|
||||
refreshing
|
||||
? 'cursor-not-allowed bg-gray-200 text-gray-400 dark:bg-gray-700 dark:text-gray-500'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600 hover:shadow-md'
|
||||
]"
|
||||
:disabled="refreshing"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<i :class="['fas', refreshing ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
|
||||
{{ refreshing ? '刷新中...' : '立即刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索 + 平台筛选 -->
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div class="relative flex-1">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white py-2 pl-9 pr-3 text-sm text-gray-700 placeholder-gray-400 transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
placeholder="搜索模型名称..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="tab in platformTabs"
|
||||
:key="tab.key"
|
||||
:class="[
|
||||
'rounded-lg px-3 py-2 text-xs font-medium transition',
|
||||
activePlatform === tab.key
|
||||
? 'bg-blue-500 text-white shadow-sm'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||
]"
|
||||
@click="activePlatform = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="py-12 text-center">
|
||||
<i class="fas fa-spinner fa-spin mb-4 text-2xl text-blue-500" />
|
||||
<p class="text-gray-500 dark:text-gray-400">加载价格数据中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div v-else class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th
|
||||
class="cursor-pointer px-3 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
@click="toggleSort('name')"
|
||||
>
|
||||
模型名称
|
||||
<i
|
||||
v-if="sortField === 'name'"
|
||||
:class="['fas ml-1', sortAsc ? 'fa-sort-up' : 'fa-sort-down']"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="cursor-pointer px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
@click="toggleSort('input')"
|
||||
>
|
||||
输入 $/MTok
|
||||
<i
|
||||
v-if="sortField === 'input'"
|
||||
:class="['fas ml-1', sortAsc ? 'fa-sort-up' : 'fa-sort-down']"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="cursor-pointer px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
@click="toggleSort('output')"
|
||||
>
|
||||
输出 $/MTok
|
||||
<i
|
||||
v-if="sortField === 'output'"
|
||||
:class="['fas ml-1', sortAsc ? 'fa-sort-up' : 'fa-sort-down']"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
class="hidden px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 md:table-cell"
|
||||
>
|
||||
缓存创建
|
||||
</th>
|
||||
<th
|
||||
class="hidden px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 md:table-cell"
|
||||
>
|
||||
缓存读取
|
||||
</th>
|
||||
<th
|
||||
class="hidden px-3 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 lg:table-cell"
|
||||
>
|
||||
上下文窗口
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
<tr
|
||||
v-for="model in sortedModels"
|
||||
:key="model.name"
|
||||
class="transition hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<td class="whitespace-nowrap px-3 py-2.5">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{{ model.name }}</div>
|
||||
<div v-if="model.provider" class="text-xs text-gray-400">{{ model.provider }}</div>
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-3 py-2.5 text-right font-mono text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ formatPrice(model.inputCost) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-3 py-2.5 text-right font-mono text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ formatPrice(model.outputCost) }}
|
||||
</td>
|
||||
<td
|
||||
class="hidden whitespace-nowrap px-3 py-2.5 text-right font-mono text-gray-500 dark:text-gray-400 md:table-cell"
|
||||
>
|
||||
{{ formatPrice(model.cacheCreateCost) }}
|
||||
</td>
|
||||
<td
|
||||
class="hidden whitespace-nowrap px-3 py-2.5 text-right font-mono text-gray-500 dark:text-gray-400 md:table-cell"
|
||||
>
|
||||
{{ formatPrice(model.cacheReadCost) }}
|
||||
</td>
|
||||
<td
|
||||
class="hidden whitespace-nowrap px-3 py-2.5 text-right text-gray-500 dark:text-gray-400 lg:table-cell"
|
||||
>
|
||||
{{ formatContext(model.maxTokens) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="sortedModels.length === 0">
|
||||
<td class="px-3 py-8 text-center text-gray-500 dark:text-gray-400" colspan="6">
|
||||
<i class="fas fa-search mb-2 text-2xl text-gray-300 dark:text-gray-600" />
|
||||
<p>没有匹配的模型</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 模型数量统计 -->
|
||||
<div v-if="!loading" class="mt-3 text-right text-xs text-gray-400 dark:text-gray-500">
|
||||
显示 {{ sortedModels.length }} / {{ allModels.length }} 个模型
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
getModelPricingApi,
|
||||
getModelPricingStatusApi,
|
||||
refreshModelPricingApi
|
||||
} from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
// ========== 状态 ==========
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const pricingData = ref({})
|
||||
const pricingStatus = ref({})
|
||||
const searchQuery = ref('')
|
||||
const activePlatform = ref('all')
|
||||
const sortField = ref('name')
|
||||
const sortAsc = ref(true)
|
||||
|
||||
const platformTabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'claude', label: 'Claude' },
|
||||
{ key: 'gemini', label: 'Gemini' },
|
||||
{ key: 'openai', label: 'OpenAI' },
|
||||
{ key: 'other', label: '其他' }
|
||||
]
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
const modelCount = computed(() => Object.keys(pricingData.value).length)
|
||||
|
||||
const lastUpdated = computed(() => {
|
||||
if (!pricingStatus.value.lastUpdated) return '未知'
|
||||
return new Date(pricingStatus.value.lastUpdated).toLocaleString('zh-CN')
|
||||
})
|
||||
|
||||
const allModels = computed(() =>
|
||||
Object.entries(pricingData.value).map(([name, data]) => ({
|
||||
name,
|
||||
provider: detectProvider(name),
|
||||
inputCost: (data.input_cost_per_token || 0) * 1e6,
|
||||
outputCost: (data.output_cost_per_token || 0) * 1e6,
|
||||
cacheCreateCost: (data.cache_creation_input_token_cost || 0) * 1e6,
|
||||
cacheReadCost: (data.cache_read_input_token_cost || 0) * 1e6,
|
||||
maxTokens: data.max_tokens || data.max_output_tokens || 0
|
||||
}))
|
||||
)
|
||||
|
||||
const filteredModels = computed(() => {
|
||||
let models = allModels.value
|
||||
|
||||
// 平台筛选
|
||||
if (activePlatform.value !== 'all') {
|
||||
const platformFilters = {
|
||||
claude: (n) => n.includes('claude'),
|
||||
gemini: (n) => n.includes('gemini'),
|
||||
openai: (n) =>
|
||||
n.includes('gpt') ||
|
||||
n.includes('o1') ||
|
||||
n.includes('o3') ||
|
||||
n.includes('o4') ||
|
||||
n.includes('codex'),
|
||||
other: (n) =>
|
||||
!n.includes('claude') &&
|
||||
!n.includes('gemini') &&
|
||||
!n.includes('gpt') &&
|
||||
!n.includes('o1') &&
|
||||
!n.includes('o3') &&
|
||||
!n.includes('o4') &&
|
||||
!n.includes('codex')
|
||||
}
|
||||
const filter = platformFilters[activePlatform.value]
|
||||
if (filter) models = models.filter((m) => filter(m.name.toLowerCase()))
|
||||
}
|
||||
|
||||
// 搜索筛选
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
models = models.filter((m) => m.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return models
|
||||
})
|
||||
|
||||
const sortedModels = computed(() => {
|
||||
const models = [...filteredModels.value]
|
||||
const fieldMap = {
|
||||
name: (m) => m.name,
|
||||
input: (m) => m.inputCost,
|
||||
output: (m) => m.outputCost
|
||||
}
|
||||
const getter = fieldMap[sortField.value]
|
||||
if (!getter) return models
|
||||
|
||||
models.sort((a, b) => {
|
||||
const va = getter(a)
|
||||
const vb = getter(b)
|
||||
if (typeof va === 'string') return sortAsc.value ? va.localeCompare(vb) : vb.localeCompare(va)
|
||||
return sortAsc.value ? va - vb : vb - va
|
||||
})
|
||||
return models
|
||||
})
|
||||
|
||||
// ========== 方法 ==========
|
||||
const detectProvider = (name) => {
|
||||
const n = name.toLowerCase()
|
||||
if (n.includes('claude')) return 'Anthropic'
|
||||
if (n.includes('gemini')) return 'Google'
|
||||
if (
|
||||
n.includes('gpt') ||
|
||||
n.includes('o1') ||
|
||||
n.includes('o3') ||
|
||||
n.includes('o4') ||
|
||||
n.includes('codex')
|
||||
)
|
||||
return 'OpenAI'
|
||||
if (n.includes('deepseek')) return 'DeepSeek'
|
||||
if (n.includes('llama') || n.includes('meta')) return 'Meta'
|
||||
if (n.includes('mistral')) return 'Mistral'
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatPrice = (price) => {
|
||||
if (!price || price === 0) return '-'
|
||||
if (price < 0.01) return `$${price.toFixed(4)}`
|
||||
if (price < 1) return `$${price.toFixed(3)}`
|
||||
return `$${price.toFixed(2)}`
|
||||
}
|
||||
|
||||
const formatContext = (tokens) => {
|
||||
if (!tokens) return '-'
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}K`
|
||||
return String(tokens)
|
||||
}
|
||||
|
||||
const toggleSort = (field) => {
|
||||
if (sortField.value === field) {
|
||||
sortAsc.value = !sortAsc.value
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortAsc.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
const [pricingResult, statusResult] = await Promise.all([
|
||||
getModelPricingApi(),
|
||||
getModelPricingStatusApi()
|
||||
])
|
||||
if (pricingResult.success) pricingData.value = pricingResult.data
|
||||
if (statusResult.success) pricingStatus.value = statusResult.data
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
refreshing.value = true
|
||||
const result = await refreshModelPricingApi()
|
||||
if (result.success) {
|
||||
showToast('价格数据已刷新', 'success')
|
||||
await loadData()
|
||||
} else {
|
||||
showToast(result.message || '刷新失败', 'error')
|
||||
}
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
Reference in New Issue
Block a user