feat: 支持apikey测试claude端点

This commit is contained in:
shaw
2025-11-28 17:16:37 +08:00
parent 53553c7e76
commit b58b8b1ac7
4 changed files with 841 additions and 6 deletions

View File

@@ -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
} }
] ]
} }

View File

@@ -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 {

View 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>

View File

@@ -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;