mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 支持apikey测试claude端点
This commit is contained in:
10
README.md
10
README.md
@@ -490,12 +490,12 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义
|
|||||||
{
|
{
|
||||||
"custom_models": [
|
"custom_models": [
|
||||||
{
|
{
|
||||||
"model_display_name": "Sonnet 4.5 [crs]",
|
"model_display_name": "Opus 4.5 [crs]",
|
||||||
"model": "claude-sonnet-4-5-20250929",
|
"model": "claude-opus-4-5-20251101",
|
||||||
"base_url": "http://127.0.0.1:3000/droid/claude",
|
"base_url": "http://127.0.0.1:3000/droid/claude",
|
||||||
"api_key": "后台创建的API密钥",
|
"api_key": "后台创建的API密钥",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"max_tokens": 8192
|
"max_tokens": 64000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_display_name": "GPT5-Codex [crs]",
|
"model_display_name": "GPT5-Codex [crs]",
|
||||||
@@ -511,7 +511,7 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义
|
|||||||
"base_url": "http://127.0.0.1:3000/droid/comm/v1/",
|
"base_url": "http://127.0.0.1:3000/droid/comm/v1/",
|
||||||
"api_key": "后台创建的API密钥",
|
"api_key": "后台创建的API密钥",
|
||||||
"provider": "generic-chat-completion-api",
|
"provider": "generic-chat-completion-api",
|
||||||
"max_tokens": 32000
|
"max_tokens": 65535
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_display_name": "GLM-4.6 [crs]",
|
"model_display_name": "GLM-4.6 [crs]",
|
||||||
@@ -519,7 +519,7 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义
|
|||||||
"base_url": "http://127.0.0.1:3000/droid/comm/v1/",
|
"base_url": "http://127.0.0.1:3000/droid/comm/v1/",
|
||||||
"api_key": "后台创建的API密钥",
|
"api_key": "后台创建的API密钥",
|
||||||
"provider": "generic-chat-completion-api",
|
"provider": "generic-chat-completion-api",
|
||||||
"max_tokens": 32000
|
"max_tokens": 202800
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -790,6 +790,283 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
||||||
|
router.post('/api-key/test', async (req, res) => {
|
||||||
|
const axios = require('axios')
|
||||||
|
const config = require('../../config/config')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'API Key is required',
|
||||||
|
message: 'Please provide your API Key'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基本格式验证
|
||||||
|
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid API key format',
|
||||||
|
message: 'API key format is invalid'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首先验证API Key是否有效(不触发激活)
|
||||||
|
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid API key',
|
||||||
|
message: validation.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.api(`🧪 API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`)
|
||||||
|
|
||||||
|
// 设置SSE响应头
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no')
|
||||||
|
|
||||||
|
// 发送测试开始事件
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||||
|
|
||||||
|
// 构建测试请求,模拟 Claude CLI 客户端
|
||||||
|
const port = config.server.port || 3000
|
||||||
|
const baseURL = `http://127.0.0.1:${port}`
|
||||||
|
|
||||||
|
const testPayload = {
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'hi'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
system: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: "You are Claude Code, Anthropic's official CLI for Claude."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens: 32000,
|
||||||
|
temperature: 1,
|
||||||
|
stream: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'claude-cli/2.0.52 (external, cli)',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': config.claude.apiVersion || '2023-06-01'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 向自身服务发起测试请求
|
||||||
|
// 使用 validateStatus 允许所有状态码通过,以便我们可以处理流式错误响应
|
||||||
|
const response = await axios.post(`${baseURL}/api/v1/messages`, testPayload, {
|
||||||
|
headers,
|
||||||
|
responseType: 'stream',
|
||||||
|
timeout: 60000, // 60秒超时
|
||||||
|
validateStatus: () => true // 接受所有状态码,自行处理错误
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查响应状态码,如果不是2xx,尝试读取错误信息
|
||||||
|
if (response.status >= 400) {
|
||||||
|
logger.error(
|
||||||
|
`🧪 API Key test received error status ${response.status} for: ${validation.keyData.name}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 尝试从流中读取错误信息
|
||||||
|
let errorBody = ''
|
||||||
|
for await (const chunk of response.data) {
|
||||||
|
errorBody += chunk.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorMessage = `HTTP ${response.status}`
|
||||||
|
try {
|
||||||
|
// 尝试解析SSE格式的错误
|
||||||
|
const lines = errorBody.split('\n')
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const dataStr = line.substring(6).trim()
|
||||||
|
if (dataStr && dataStr !== '[DONE]') {
|
||||||
|
const data = JSON.parse(dataStr)
|
||||||
|
if (data.error?.message) {
|
||||||
|
errorMessage = data.error.message
|
||||||
|
break
|
||||||
|
} else if (data.message) {
|
||||||
|
errorMessage = data.message
|
||||||
|
break
|
||||||
|
} else if (typeof data.error === 'string') {
|
||||||
|
errorMessage = data.error
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果不是SSE格式,尝试直接解析JSON
|
||||||
|
if (errorMessage === `HTTP ${response.status}`) {
|
||||||
|
const jsonError = JSON.parse(errorBody)
|
||||||
|
errorMessage =
|
||||||
|
jsonError.error?.message || jsonError.message || jsonError.error || errorMessage
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败,使用原始错误体或默认消息
|
||||||
|
if (errorBody && errorBody.length < 500) {
|
||||||
|
errorMessage = errorBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`)
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMessage })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let receivedContent = ''
|
||||||
|
let testSuccess = false
|
||||||
|
let upstreamError = null
|
||||||
|
|
||||||
|
// 处理流式响应
|
||||||
|
response.data.on('data', (chunk) => {
|
||||||
|
const lines = chunk.toString().split('\n')
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const dataStr = line.substring(6).trim()
|
||||||
|
if (dataStr === '[DONE]') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(dataStr)
|
||||||
|
|
||||||
|
// 检查上游返回的错误事件
|
||||||
|
if (data.type === 'error' || data.error) {
|
||||||
|
let errorMsg = 'Unknown upstream error'
|
||||||
|
|
||||||
|
// 优先从 data.error 提取(如果是对象,获取其 message)
|
||||||
|
if (typeof data.error === 'object' && data.error?.message) {
|
||||||
|
errorMsg = data.error.message
|
||||||
|
} else if (typeof data.error === 'string' && data.error !== 'Claude API error') {
|
||||||
|
// 如果 error 是字符串且不是通用错误,直接使用
|
||||||
|
errorMsg = data.error
|
||||||
|
} else if (data.details) {
|
||||||
|
// 尝试从 details 字段解析详细错误(claudeRelayService 格式)
|
||||||
|
try {
|
||||||
|
const details =
|
||||||
|
typeof data.details === 'string' ? JSON.parse(data.details) : data.details
|
||||||
|
if (details.error?.message) {
|
||||||
|
errorMsg = details.error.message
|
||||||
|
} else if (details.message) {
|
||||||
|
errorMsg = details.message
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// details 不是有效 JSON,尝试直接使用
|
||||||
|
if (typeof data.details === 'string' && data.details.length < 500) {
|
||||||
|
errorMsg = data.details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.message) {
|
||||||
|
errorMsg = data.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加状态码信息(如果有)
|
||||||
|
if (data.status && errorMsg !== 'Unknown upstream error') {
|
||||||
|
errorMsg = `[${data.status}] ${errorMsg}`
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamError = errorMsg
|
||||||
|
logger.error(`🧪 Upstream error in test for: ${validation.keyData.name}:`, errorMsg)
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取文本内容
|
||||||
|
if (data.type === 'content_block_delta' && data.delta?.text) {
|
||||||
|
receivedContent += data.delta.text
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息结束
|
||||||
|
if (data.type === 'message_stop') {
|
||||||
|
testSuccess = true
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('end', () => {
|
||||||
|
// 如果有上游错误,标记为失败
|
||||||
|
if (upstreamError) {
|
||||||
|
testSuccess = false
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.api(
|
||||||
|
`🧪 API Key test completed for: ${validation.keyData.name}, success: ${testSuccess}, content length: ${receivedContent.length}${upstreamError ? `, error: ${upstreamError}` : ''}`
|
||||||
|
)
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: 'test_complete',
|
||||||
|
success: testSuccess,
|
||||||
|
contentLength: receivedContent.length,
|
||||||
|
error: upstreamError || undefined
|
||||||
|
})}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('error', (err) => {
|
||||||
|
logger.error(`🧪 API Key test stream error for: ${validation.keyData.name}`, err)
|
||||||
|
|
||||||
|
// 如果已经捕获了上游错误,优先使用那个
|
||||||
|
let errorMsg = upstreamError || err.message || 'Stream error'
|
||||||
|
|
||||||
|
// 如果错误消息是通用的 "Claude API error: xxx",提供更友好的提示
|
||||||
|
if (errorMsg.startsWith('Claude API error:') && upstreamError) {
|
||||||
|
errorMsg = upstreamError
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理客户端断开连接
|
||||||
|
req.on('close', () => {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
response.data.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ API Key test failed:', error)
|
||||||
|
|
||||||
|
// 如果还未发送响应头,返回JSON错误
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message || 'Internal server error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经是SSE流,发送错误事件
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'error', error: error.response?.data?.error?.message || error.message || 'Test failed' })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 📊 用户模型统计查询接口 - 安全的自查询接口
|
// 📊 用户模型统计查询接口 - 安全的自查询接口
|
||||||
router.post('/api/user-model-stats', async (req, res) => {
|
router.post('/api/user-model-stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
496
web/admin-spa/src/components/apikeys/ApiKeyTestModal.vue
Normal file
496
web/admin-spa/src/components/apikeys/ApiKeyTestModal.vue
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
<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" />
|
||||||
|
/api/v1/messages
|
||||||
|
</span>
|
||||||
|
</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">{{ testModel }}</span>
|
||||||
|
</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">Claude Code</span>
|
||||||
|
</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' || !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 } from 'vue'
|
||||||
|
import { API_PREFIX } from '@/config/api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// API Key 完整值(用于测试)
|
||||||
|
apiKeyValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// API Key 名称(用于显示)
|
||||||
|
apiKeyName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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 = `${API_PREFIX}/apiStats/api-key/test`
|
||||||
|
|
||||||
|
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
|
||||||
|
}),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 组件卸载时清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (abortController.value) {
|
||||||
|
abortController.value.abort()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
>统计时间范围</span
|
>统计时间范围</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full gap-2 md:w-auto">
|
<div class="flex w-full items-center gap-2 md:w-auto">
|
||||||
<button
|
<button
|
||||||
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
|
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
|
||||||
:class="['period-btn', { active: statsPeriod === 'daily' }]"
|
:class="['period-btn', { active: statsPeriod === 'daily' }]"
|
||||||
@@ -115,6 +115,16 @@
|
|||||||
<i class="fas fa-calendar-alt text-xs md:text-sm" />
|
<i class="fas fa-calendar-alt text-xs md:text-sm" />
|
||||||
本月
|
本月
|
||||||
</button>
|
</button>
|
||||||
|
<!-- 测试按钮 - 仅在单Key模式下显示 -->
|
||||||
|
<button
|
||||||
|
v-if="!multiKeyMode"
|
||||||
|
class="test-btn flex items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:gap-2 md:px-6 md:text-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="openTestModal"
|
||||||
|
>
|
||||||
|
<i class="fas fa-vial text-xs md:text-sm" />
|
||||||
|
测试
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,6 +157,14 @@
|
|||||||
<TutorialView />
|
<TutorialView />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key 测试弹窗 -->
|
||||||
|
<ApiKeyTestModal
|
||||||
|
:api-key-name="statsData?.name || ''"
|
||||||
|
:api-key-value="apiKey"
|
||||||
|
:show="showTestModal"
|
||||||
|
@close="closeTestModal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -165,6 +183,7 @@ import LimitConfig from '@/components/apistats/LimitConfig.vue'
|
|||||||
import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
|
import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
|
||||||
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
||||||
import TutorialView from './TutorialView.vue'
|
import TutorialView from './TutorialView.vue'
|
||||||
|
import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const apiStatsStore = useApiStatsStore()
|
const apiStatsStore = useApiStatsStore()
|
||||||
@@ -191,6 +210,19 @@ const {
|
|||||||
|
|
||||||
const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore
|
const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore
|
||||||
|
|
||||||
|
// 测试弹窗状态
|
||||||
|
const showTestModal = ref(false)
|
||||||
|
|
||||||
|
// 打开测试弹窗
|
||||||
|
const openTestModal = () => {
|
||||||
|
showTestModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭测试弹窗
|
||||||
|
const closeTestModal = () => {
|
||||||
|
showTestModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// 处理键盘快捷键
|
// 处理键盘快捷键
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
// Ctrl/Cmd + Enter 查询
|
// Ctrl/Cmd + Enter 查询
|
||||||
@@ -513,6 +545,36 @@ watch(apiKey, (newValue) => {
|
|||||||
border-color: rgba(107, 114, 128, 0.8);
|
border-color: rgba(107, 114, 128, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 测试按钮样式 */
|
||||||
|
.test-btn {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 10px -2px rgba(6, 182, 212, 0.3),
|
||||||
|
0 2px 4px -1px rgba(6, 182, 212, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 15px -3px rgba(6, 182, 212, 0.4),
|
||||||
|
0 4px 6px -2px rgba(6, 182, 212, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tab 胶囊按钮样式 */
|
/* Tab 胶囊按钮样式 */
|
||||||
.tab-pill-button {
|
.tab-pill-button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user