mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c38b3d2a78 | ||
|
|
e8e6f972b4 | ||
|
|
d3155b82ea | ||
|
|
02018e10f3 |
@@ -972,6 +972,9 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
const maxAttempts = 2
|
||||
let attempt = 0
|
||||
|
||||
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
|
||||
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
|
||||
|
||||
const processRequest = async () => {
|
||||
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
req.apiKey,
|
||||
@@ -1003,6 +1006,17 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
})
|
||||
}
|
||||
|
||||
// 🔍 claude-console 账户特殊处理:检查 count_tokens 端点是否可用
|
||||
if (accountType === 'claude-console') {
|
||||
const isUnavailable = await claudeConsoleAccountService.isCountTokensUnavailable(accountId)
|
||||
if (isUnavailable) {
|
||||
logger.info(
|
||||
`⏭️ count_tokens unavailable for Claude Console account ${accountId}, returning fallback response`
|
||||
)
|
||||
return { fallbackResponse: true }
|
||||
}
|
||||
}
|
||||
|
||||
const relayOptions = {
|
||||
skipUsageRecord: true,
|
||||
customPath: '/v1/messages/count_tokens'
|
||||
@@ -1028,6 +1042,23 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
relayOptions
|
||||
)
|
||||
|
||||
// 🔍 claude-console 账户:检测上游 404 响应并标记
|
||||
if (accountType === 'claude-console' && response.statusCode === 404) {
|
||||
logger.warn(
|
||||
`⚠️ count_tokens endpoint returned 404 for Claude Console account ${accountId}, marking as unavailable`
|
||||
)
|
||||
// 标记失败不应影响 fallback 响应
|
||||
try {
|
||||
await claudeConsoleAccountService.markCountTokensUnavailable(accountId)
|
||||
} catch (markError) {
|
||||
logger.error(
|
||||
`❌ Failed to mark count_tokens unavailable for account ${accountId}, but will still return fallback:`,
|
||||
markError
|
||||
)
|
||||
}
|
||||
return { fallbackResponse: true }
|
||||
}
|
||||
|
||||
res.status(response.statusCode)
|
||||
|
||||
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']
|
||||
@@ -1050,11 +1081,21 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
}
|
||||
|
||||
logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`)
|
||||
return { fallbackResponse: false }
|
||||
}
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
try {
|
||||
await processRequest()
|
||||
const result = await processRequest()
|
||||
|
||||
// 🔍 处理 fallback 响应(claude-console 账户 count_tokens 不可用)
|
||||
if (result && result.fallbackResponse) {
|
||||
if (!res.headersSent) {
|
||||
return res.status(200).json({ input_tokens: 0 })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
if (error.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') {
|
||||
|
||||
@@ -5,6 +5,7 @@ const apiKeyService = require('../services/apiKeyService')
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
const claudeAccountService = require('../services/claudeAccountService')
|
||||
const openaiAccountService = require('../services/openaiAccountService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -792,8 +793,8 @@ 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')
|
||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
|
||||
@@ -805,7 +806,6 @@ router.post('/api-key/test', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 基本格式验证
|
||||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid API key format',
|
||||
@@ -813,7 +813,6 @@ router.post('/api-key/test', async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 首先验证API Key是否有效(不触发激活)
|
||||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||
if (!validation.valid) {
|
||||
return res.status(401).json({
|
||||
@@ -824,244 +823,29 @@ router.post('/api-key/test', async (req, res) => {
|
||||
|
||||
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 apiUrl = `http://127.0.0.1:${port}/api/v1/messages?beta=true`
|
||||
|
||||
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()
|
||||
}
|
||||
await sendStreamTestRequest({
|
||||
apiUrl,
|
||||
authorization: apiKey,
|
||||
responseStream: res,
|
||||
payload: createClaudeTestPayload(model, { stream: true }),
|
||||
timeout: 60000,
|
||||
extraHeaders: { 'x-api-key': apiKey }
|
||||
})
|
||||
} 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'
|
||||
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`
|
||||
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||||
)
|
||||
res.end()
|
||||
}
|
||||
|
||||
@@ -1510,6 +1510,71 @@ class ClaudeConsoleAccountService {
|
||||
const expiryDate = new Date(account.subscriptionExpiresAt)
|
||||
return expiryDate <= new Date()
|
||||
}
|
||||
|
||||
// 🚫 标记账户的 count_tokens 端点不可用
|
||||
async markCountTokensUnavailable(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 检查账户是否存在
|
||||
const exists = await client.exists(accountKey)
|
||||
if (!exists) {
|
||||
logger.warn(
|
||||
`⚠️ Cannot mark count_tokens unavailable for non-existent account: ${accountId}`
|
||||
)
|
||||
return { success: false, reason: 'Account not found' }
|
||||
}
|
||||
|
||||
await client.hset(accountKey, {
|
||||
countTokensUnavailable: 'true',
|
||||
countTokensUnavailableAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`🚫 Marked count_tokens endpoint as unavailable for Claude Console account: ${accountId}`
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark count_tokens unavailable for account ${accountId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账户的 count_tokens 不可用标记
|
||||
async removeCountTokensUnavailable(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
await client.hdel(accountKey, 'countTokensUnavailable', 'countTokensUnavailableAt')
|
||||
|
||||
logger.info(
|
||||
`✅ Removed count_tokens unavailable mark for Claude Console account: ${accountId}`
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Failed to remove count_tokens unavailable mark for account ${accountId}:`,
|
||||
error
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户的 count_tokens 端点是否不可用
|
||||
async isCountTokensUnavailable(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
const value = await client.hget(accountKey, 'countTokensUnavailable')
|
||||
return value === 'true'
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check count_tokens availability for account ${accountId}:`, error)
|
||||
return false // 出错时默认返回可用,避免误阻断
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeConsoleAccountService()
|
||||
|
||||
@@ -1113,55 +1113,11 @@ class ClaudeConsoleRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🧪 测试账号连接(供Admin API使用,独立处理以确保错误时也返回SSE格式)
|
||||
// 🧪 测试账号连接(供Admin API使用)
|
||||
async testAccountConnection(accountId, responseStream) {
|
||||
const testRequestBody = {
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
max_tokens: 32000,
|
||||
stream: true,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'hi'
|
||||
}
|
||||
],
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 辅助函数:发送 SSE 事件
|
||||
const sendSSEEvent = (type, data) => {
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
try {
|
||||
responseStream.write(`data: ${JSON.stringify({ type, ...data })}\n\n`)
|
||||
} catch {
|
||||
// 忽略写入错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:结束测试并关闭流
|
||||
const endTest = (success, error = null) => {
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
try {
|
||||
if (success) {
|
||||
sendSSEEvent('test_complete', { success: true })
|
||||
} else {
|
||||
sendSSEEvent('test_complete', { success: false, error: error || '测试失败' })
|
||||
}
|
||||
responseStream.end()
|
||||
} catch {
|
||||
// 忽略写入错误
|
||||
}
|
||||
}
|
||||
}
|
||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
const account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
@@ -1169,178 +1125,32 @@ class ClaudeConsoleRelayService {
|
||||
|
||||
logger.info(`🧪 Testing Claude Console account connection: ${account.name} (${accountId})`)
|
||||
|
||||
// 创建代理agent
|
||||
const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy)
|
||||
|
||||
// 设置响应头
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
}
|
||||
|
||||
// 发送测试开始事件
|
||||
sendSSEEvent('test_start', {})
|
||||
|
||||
// 构建完整的API URL
|
||||
const cleanUrl = account.apiUrl.replace(/\/$/, '')
|
||||
const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
|
||||
const apiUrl = cleanUrl.endsWith('/v1/messages')
|
||||
? cleanUrl
|
||||
: `${cleanUrl}/v1/messages?beta=true`
|
||||
|
||||
// 决定使用的 User-Agent
|
||||
const userAgent = account.userAgent || this.defaultUserAgent
|
||||
|
||||
// 准备请求配置
|
||||
const requestConfig = {
|
||||
method: 'POST',
|
||||
url: apiEndpoint,
|
||||
data: testRequestBody,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': userAgent
|
||||
},
|
||||
timeout: 30000, // 测试请求使用较短超时
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
requestConfig.httpAgent = proxyAgent
|
||||
requestConfig.httpsAgent = proxyAgent
|
||||
requestConfig.proxy = false
|
||||
}
|
||||
|
||||
// 设置认证方式
|
||||
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
|
||||
requestConfig.headers['x-api-key'] = account.apiKey
|
||||
} else {
|
||||
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const response = await axios(requestConfig)
|
||||
|
||||
logger.debug(`🌊 Claude Console test response status: ${response.status}`)
|
||||
|
||||
// 处理非200响应
|
||||
if (response.status !== 200) {
|
||||
logger.error(
|
||||
`❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
|
||||
)
|
||||
|
||||
// 收集错误响应数据
|
||||
return new Promise((resolve) => {
|
||||
const errorChunks = []
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
errorChunks.push(chunk)
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
try {
|
||||
const fullErrorData = Buffer.concat(errorChunks).toString()
|
||||
logger.error(
|
||||
`📝 [Test] Upstream error response from ${account?.name || accountId}: ${fullErrorData.substring(0, 500)}`
|
||||
)
|
||||
|
||||
// 尝试解析错误信息
|
||||
let errorMessage = `API Error: ${response.status}`
|
||||
try {
|
||||
const errorJson = JSON.parse(fullErrorData)
|
||||
// 直接提取所有可能的错误信息字段
|
||||
errorMessage =
|
||||
errorJson.message ||
|
||||
errorJson.error?.message ||
|
||||
errorJson.statusMessage ||
|
||||
errorJson.error ||
|
||||
(typeof errorJson === 'string' ? errorJson : JSON.stringify(errorJson))
|
||||
} catch {
|
||||
errorMessage = fullErrorData.substring(0, 200) || `API Error: ${response.status}`
|
||||
}
|
||||
|
||||
endTest(false, errorMessage)
|
||||
resolve()
|
||||
} catch {
|
||||
endTest(false, `API Error: ${response.status}`)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
endTest(false, err.message || '流读取错误')
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 处理成功的流式响应
|
||||
return new Promise((resolve) => {
|
||||
let buffer = ''
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
try {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const jsonStr = line.substring(6).trim()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
// 转换 content_block_delta 为 content
|
||||
if (data.type === 'content_block_delta' && data.delta && data.delta.text) {
|
||||
sendSSEEvent('content', { text: data.delta.text })
|
||||
}
|
||||
|
||||
// 处理消息完成
|
||||
if (data.type === 'message_stop') {
|
||||
endTest(true)
|
||||
}
|
||||
|
||||
// 处理错误事件
|
||||
if (data.type === 'error') {
|
||||
const errorMsg = data.error?.message || data.message || '未知错误'
|
||||
endTest(false, errorMsg)
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略处理错误
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
logger.info(`✅ Test request completed for account: ${account.name}`)
|
||||
// 如果还没结束,发送完成事件
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
endTest(true)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
logger.error(`❌ Test stream error:`, err)
|
||||
endTest(false, err.message || '流处理错误')
|
||||
resolve()
|
||||
})
|
||||
await sendStreamTestRequest({
|
||||
apiUrl,
|
||||
authorization: `Bearer ${account.apiKey}`,
|
||||
responseStream,
|
||||
proxyAgent: claudeConsoleAccountService._createProxyAgent(account.proxy),
|
||||
extraHeaders: account.userAgent ? { 'User-Agent': account.userAgent } : {}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`❌ Test account connection failed:`, error)
|
||||
endTest(false, error.message || '测试失败')
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache'
|
||||
})
|
||||
}
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
responseStream.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
|
||||
)
|
||||
responseStream.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const redis = require('../models/redis')
|
||||
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||
const { formatDateWithTimezone } = require('../utils/dateHelper')
|
||||
const requestIdentityService = require('./requestIdentityService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
@@ -2244,26 +2245,7 @@ class ClaudeRelayService {
|
||||
|
||||
// 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture)
|
||||
async testAccountConnection(accountId, responseStream) {
|
||||
const testRequestBody = {
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
max_tokens: 100,
|
||||
stream: true,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: this.claudeCodeSystemPrompt,
|
||||
cache_control: {
|
||||
type: 'ephemeral'
|
||||
}
|
||||
}
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'hi'
|
||||
}
|
||||
]
|
||||
}
|
||||
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
|
||||
|
||||
try {
|
||||
// 获取账户信息
|
||||
|
||||
242
src/utils/testPayloadHelper.js
Normal file
242
src/utils/testPayloadHelper.js
Normal file
@@ -0,0 +1,242 @@
|
||||
const crypto = require('crypto')
|
||||
|
||||
/**
|
||||
* 生成随机十六进制字符串
|
||||
* @param {number} bytes - 字节数
|
||||
* @returns {string} 十六进制字符串
|
||||
*/
|
||||
function randomHex(bytes = 32) {
|
||||
return crypto.randomBytes(bytes).toString('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Claude Code 风格的会话字符串
|
||||
* @returns {string} 会话字符串,格式: user_{64位hex}_account__session_{uuid}
|
||||
*/
|
||||
function generateSessionString() {
|
||||
const hex64 = randomHex(32) // 32 bytes => 64 hex characters
|
||||
const uuid = crypto.randomUUID()
|
||||
return `user_${hex64}_account__session_${uuid}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Claude 测试请求体
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} options - 可选配置
|
||||
* @param {boolean} options.stream - 是否流式(默认false)
|
||||
* @returns {object} 测试请求体
|
||||
*/
|
||||
function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) {
|
||||
const payload = {
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
cache_control: {
|
||||
type: 'ephemeral'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||
cache_control: {
|
||||
type: 'ephemeral'
|
||||
}
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
user_id: generateSessionString()
|
||||
},
|
||||
max_tokens: 21333,
|
||||
temperature: 1
|
||||
}
|
||||
|
||||
if (options.stream) {
|
||||
payload.stream = true
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送流式测试请求并处理SSE响应
|
||||
* @param {object} options - 配置选项
|
||||
* @param {string} options.apiUrl - API URL
|
||||
* @param {string} options.authorization - Authorization header值
|
||||
* @param {object} options.responseStream - Express响应流
|
||||
* @param {object} [options.payload] - 请求体(默认使用createClaudeTestPayload)
|
||||
* @param {object} [options.proxyAgent] - 代理agent
|
||||
* @param {number} [options.timeout] - 超时时间(默认30000)
|
||||
* @param {object} [options.extraHeaders] - 额外的请求头
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function sendStreamTestRequest(options) {
|
||||
const axios = require('axios')
|
||||
const logger = require('./logger')
|
||||
|
||||
const {
|
||||
apiUrl,
|
||||
authorization,
|
||||
responseStream,
|
||||
payload = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true }),
|
||||
proxyAgent = null,
|
||||
timeout = 30000,
|
||||
extraHeaders = {}
|
||||
} = options
|
||||
|
||||
const sendSSE = (type, data = {}) => {
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
try {
|
||||
responseStream.write(`data: ${JSON.stringify({ type, ...data })}\n\n`)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endTest = (success, error = null) => {
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
try {
|
||||
responseStream.write(
|
||||
`data: ${JSON.stringify({ type: 'test_complete', success, error: error || undefined })}\n\n`
|
||||
)
|
||||
responseStream.end()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
})
|
||||
}
|
||||
|
||||
sendSSE('test_start', { message: 'Test started' })
|
||||
|
||||
const requestConfig = {
|
||||
method: 'POST',
|
||||
url: apiUrl,
|
||||
data: payload,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'User-Agent': 'claude-cli/2.0.52 (external, cli)',
|
||||
authorization,
|
||||
...extraHeaders
|
||||
},
|
||||
timeout,
|
||||
responseType: 'stream',
|
||||
validateStatus: () => true
|
||||
}
|
||||
|
||||
if (proxyAgent) {
|
||||
requestConfig.httpAgent = proxyAgent
|
||||
requestConfig.httpsAgent = proxyAgent
|
||||
requestConfig.proxy = false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios(requestConfig)
|
||||
logger.debug(`🌊 Test response status: ${response.status}`)
|
||||
|
||||
// 处理非200响应
|
||||
if (response.status !== 200) {
|
||||
return new Promise((resolve) => {
|
||||
const chunks = []
|
||||
response.data.on('data', (chunk) => chunks.push(chunk))
|
||||
response.data.on('end', () => {
|
||||
const errorData = Buffer.concat(chunks).toString()
|
||||
let errorMsg = `API Error: ${response.status}`
|
||||
try {
|
||||
const json = JSON.parse(errorData)
|
||||
errorMsg = json.message || json.error?.message || json.error || errorMsg
|
||||
} catch {
|
||||
if (errorData.length < 200) {
|
||||
errorMsg = errorData || errorMsg
|
||||
}
|
||||
}
|
||||
endTest(false, errorMsg)
|
||||
resolve()
|
||||
})
|
||||
response.data.on('error', (err) => {
|
||||
endTest(false, err.message)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 处理成功的流式响应
|
||||
return new Promise((resolve) => {
|
||||
let buffer = ''
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) {
|
||||
continue
|
||||
}
|
||||
const jsonStr = line.substring(5).trim()
|
||||
if (!jsonStr || jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
if (data.type === 'content_block_delta' && data.delta?.text) {
|
||||
sendSSE('content', { text: data.delta.text })
|
||||
}
|
||||
if (data.type === 'message_stop') {
|
||||
sendSSE('message_stop')
|
||||
}
|
||||
if (data.type === 'error' || data.error) {
|
||||
const errMsg = data.error?.message || data.message || data.error || 'Unknown error'
|
||||
sendSSE('error', { error: errMsg })
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
response.data.on('end', () => {
|
||||
if (!responseStream.destroyed && !responseStream.writableEnded) {
|
||||
endTest(true)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
endTest(false, err.message)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Stream test request failed:', error.message)
|
||||
endTest(false, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
randomHex,
|
||||
generateSessionString,
|
||||
createClaudeTestPayload,
|
||||
sendStreamTestRequest
|
||||
}
|
||||
@@ -162,7 +162,7 @@
|
||||
|
||||
<!-- 桌面端表格视图 -->
|
||||
<div v-else class="table-wrapper hidden md:block">
|
||||
<div class="table-container">
|
||||
<div ref="tableContainerRef" class="table-container">
|
||||
<table class="w-full">
|
||||
<thead
|
||||
class="sticky top-0 z-10 bg-gradient-to-b from-gray-50 to-gray-100/90 backdrop-blur-sm dark:from-gray-700 dark:to-gray-800/90"
|
||||
@@ -214,22 +214,7 @@
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[110px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="sortAccounts('expiresAt')"
|
||||
>
|
||||
到期时间
|
||||
<i
|
||||
v-if="accountsSortBy === 'expiresAt'"
|
||||
:class="[
|
||||
'fas',
|
||||
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
|
||||
'ml-1'
|
||||
]"
|
||||
/>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||||
</th>
|
||||
<th
|
||||
class="w-[120px] min-w-[180px] max-w-[20s0px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
class="w-[120px] min-w-[180px] max-w-[200px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="sortAccounts('status')"
|
||||
>
|
||||
状态
|
||||
@@ -243,26 +228,6 @@
|
||||
/>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="sortAccounts('priority')"
|
||||
>
|
||||
优先级
|
||||
<i
|
||||
v-if="accountsSortBy === 'priority'"
|
||||
:class="[
|
||||
'fas',
|
||||
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
|
||||
'ml-1'
|
||||
]"
|
||||
/>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
代理
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
@@ -338,7 +303,7 @@
|
||||
class="fas fa-info-circle mt-[2px] text-[10px] text-indigo-500"
|
||||
></i>
|
||||
<span class="font-medium text-white dark:text-gray-900"
|
||||
>当“重置剩余”为 0 时,进度条与百分比会同步清零。</span
|
||||
>当"重置剩余"为 0 时,进度条与百分比会同步清零。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,7 +359,43 @@
|
||||
最后使用
|
||||
</th>
|
||||
<th
|
||||
class="operations-column sticky right-0 z-20 min-w-[200px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
class="min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="sortAccounts('priority')"
|
||||
>
|
||||
优先级
|
||||
<i
|
||||
v-if="accountsSortBy === 'priority'"
|
||||
:class="[
|
||||
'fas',
|
||||
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
|
||||
'ml-1'
|
||||
]"
|
||||
/>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
代理
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[110px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="sortAccounts('expiresAt')"
|
||||
>
|
||||
到期时间
|
||||
<i
|
||||
v-if="accountsSortBy === 'expiresAt'"
|
||||
:class="[
|
||||
'fas',
|
||||
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
|
||||
'ml-1'
|
||||
]"
|
||||
/>
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||||
</th>
|
||||
<th
|
||||
class="operations-column sticky right-0 z-20 px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
:class="needsHorizontalScroll ? 'min-w-[170px]' : 'min-w-[200px]'"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
@@ -615,49 +616,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- 已设置过期时间 -->
|
||||
<span v-if="account.expiresAt">
|
||||
<span
|
||||
v-if="isExpired(account.expiresAt)"
|
||||
class="inline-flex cursor-pointer items-center text-red-600 hover:underline"
|
||||
style="font-size: 13px"
|
||||
@click.stop="startEditAccountExpiry(account)"
|
||||
>
|
||||
<i class="fas fa-exclamation-circle mr-1 text-xs" />
|
||||
已过期
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isExpiringSoon(account.expiresAt)"
|
||||
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline"
|
||||
style="font-size: 13px"
|
||||
@click.stop="startEditAccountExpiry(account)"
|
||||
>
|
||||
<i class="fas fa-clock mr-1 text-xs" />
|
||||
{{ formatExpireDate(account.expiresAt) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="cursor-pointer text-gray-600 hover:underline dark:text-gray-400"
|
||||
style="font-size: 13px"
|
||||
@click.stop="startEditAccountExpiry(account)"
|
||||
>
|
||||
{{ formatExpireDate(account.expiresAt) }}
|
||||
</span>
|
||||
</span>
|
||||
<!-- 永不过期 -->
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex cursor-pointer items-center text-gray-400 hover:underline dark:text-gray-500"
|
||||
style="font-size: 13px"
|
||||
@click.stop="startEditAccountExpiry(account)"
|
||||
>
|
||||
<i class="fas fa-infinity mr-1 text-xs" />
|
||||
永不过期
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="w-[100px] min-w-[100px] max-w-[100px] whitespace-nowrap px-3 py-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
@@ -748,46 +706,6 @@
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<div
|
||||
v-if="
|
||||
account.platform === 'claude' ||
|
||||
account.platform === 'claude-console' ||
|
||||
account.platform === 'bedrock' ||
|
||||
account.platform === 'gemini' ||
|
||||
account.platform === 'openai' ||
|
||||
account.platform === 'openai-responses' ||
|
||||
account.platform === 'azure_openai' ||
|
||||
account.platform === 'ccr' ||
|
||||
account.platform === 'droid' ||
|
||||
account.platform === 'gemini-api'
|
||||
"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="h-2 w-16 rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-2 rounded-full bg-gradient-to-r from-green-500 to-blue-600 transition-all duration-300"
|
||||
:style="{ width: 101 - (account.priority || 50) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="min-w-[20px] text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ account.priority || 50 }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-400">
|
||||
<span class="text-xs">N/A</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-600">
|
||||
<div
|
||||
v-if="formatProxyDisplay(account.proxy)"
|
||||
class="break-all rounded bg-blue-50 px-2 py-1 font-mono text-xs"
|
||||
:title="formatProxyDisplay(account.proxy)"
|
||||
>
|
||||
{{ formatProxyDisplay(account.proxy) }}
|
||||
</div>
|
||||
<div v-else class="text-gray-400">无代理</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<div v-if="account.usage && account.usage.daily" class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -1153,11 +1071,94 @@
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ formatLastUsed(account.lastUsedAt) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<div
|
||||
v-if="
|
||||
account.platform === 'claude' ||
|
||||
account.platform === 'claude-console' ||
|
||||
account.platform === 'bedrock' ||
|
||||
account.platform === 'gemini' ||
|
||||
account.platform === 'openai' ||
|
||||
account.platform === 'openai-responses' ||
|
||||
account.platform === 'azure_openai' ||
|
||||
account.platform === 'ccr' ||
|
||||
account.platform === 'droid' ||
|
||||
account.platform === 'gemini-api'
|
||||
"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="h-2 w-16 rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-2 rounded-full bg-gradient-to-r from-green-500 to-blue-600 transition-all duration-300"
|
||||
:style="{ width: 101 - (account.priority || 50) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="min-w-[20px] text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ account.priority || 50 }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-400">
|
||||
<span class="text-xs">N/A</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-600">
|
||||
<div
|
||||
v-if="formatProxyDisplay(account.proxy)"
|
||||
class="break-all rounded bg-blue-50 px-2 py-1 font-mono text-xs"
|
||||
:title="formatProxyDisplay(account.proxy)"
|
||||
>
|
||||
{{ formatProxyDisplay(account.proxy) }}
|
||||
</div>
|
||||
<div v-else class="text-gray-400">无代理</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- 已设置过期时间 -->
|
||||
<span v-if="account.expiresAt">
|
||||
<span
|
||||
v-if="isExpired(account.expiresAt)"
|
||||
class="inline-flex cursor-pointer items-center text-red-600 hover:underline"
|
||||
style="font-size: 13px"
|
||||
@click.stop="startEditAccountExpiry(account)"
|
||||
>
|
||||
<i class="fas fa-exclamation-circle mr-1 text-xs" />
|
||||
已过期
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isExpiringSoon(account.expiresAt)"
|
||||
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline"
|
||||
style="font-size: 13px"
|
||||
@click.stop="startEditAccountExpiry(account)"
|
||||
>
|
||||
<i class="fas fa-clock mr-1 text-xs" />
|
||||
{{ formatExpireDate(account.expiresAt) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="cursor-pointer text-gray-600 hover:underline dark:text-gray-400"
|
||||
style="font-size: 13px"
|
||||
@click.stop="startEditAccountExpiry(account)"
|
||||
>
|
||||
{{ formatExpireDate(account.expiresAt) }}
|
||||
</span>
|
||||
</span>
|
||||
<!-- 永不过期 -->
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex cursor-pointer items-center text-gray-400 hover:underline dark:text-gray-500"
|
||||
style="font-size: 13px"
|
||||
@click.stop="startEditAccountExpiry(account)"
|
||||
>
|
||||
<i class="fas fa-infinity mr-1 text-xs" />
|
||||
永不过期
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="operations-column sticky right-0 z-10 whitespace-nowrap px-3 py-4 text-sm font-medium"
|
||||
>
|
||||
<!-- 大屏显示所有按钮 -->
|
||||
<div class="hidden items-center gap-1 2xl:flex">
|
||||
<!-- 宽度足够时显示所有按钮 -->
|
||||
<div v-if="!needsHorizontalScroll" class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="showResetButton(account)"
|
||||
:class="[
|
||||
@@ -1224,8 +1225,8 @@
|
||||
<span class="ml-1">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 小屏显示:2个快捷按钮 + 下拉菜单 -->
|
||||
<div class="flex items-center gap-1 2xl:hidden">
|
||||
<!-- 需要横向滚动时使用缩减形式:2个快捷按钮 + 下拉菜单 -->
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<button
|
||||
:class="[
|
||||
'rounded px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
@@ -1853,7 +1854,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -1929,6 +1930,10 @@ const expiryEditModalRef = ref(null)
|
||||
const showAccountTestModal = ref(false)
|
||||
const testingAccount = ref(null)
|
||||
|
||||
// 表格横向滚动检测
|
||||
const tableContainerRef = ref(null)
|
||||
const needsHorizontalScroll = ref(false)
|
||||
|
||||
// 缓存状态标志
|
||||
const apiKeysLoaded = ref(false) // 用于其他功能
|
||||
const bindingCountsLoaded = ref(false) // 轻量级绑定计数缓存
|
||||
@@ -4009,6 +4014,10 @@ watch(currentPage, () => {
|
||||
|
||||
watch(paginatedAccounts, () => {
|
||||
updateSelectAllState()
|
||||
// 数据变化后重新检测是否需要横向滚动
|
||||
nextTick(() => {
|
||||
checkHorizontalScroll()
|
||||
})
|
||||
})
|
||||
|
||||
watch(accounts, () => {
|
||||
@@ -4122,9 +4131,41 @@ const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 检测表格是否需要横向滚动
|
||||
const checkHorizontalScroll = () => {
|
||||
if (tableContainerRef.value) {
|
||||
needsHorizontalScroll.value =
|
||||
tableContainerRef.value.scrollWidth > tableContainerRef.value.clientWidth
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口大小变化时重新检测
|
||||
let resizeObserver = null
|
||||
|
||||
onMounted(() => {
|
||||
// 首次加载时强制刷新所有数据
|
||||
loadAccounts(true)
|
||||
|
||||
// 设置ResizeObserver监听表格容器大小变化
|
||||
nextTick(() => {
|
||||
if (tableContainerRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
checkHorizontalScroll()
|
||||
})
|
||||
resizeObserver.observe(tableContainerRef.value)
|
||||
checkHorizontalScroll()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', checkHorizontalScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
window.removeEventListener('resize', checkHorizontalScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4225,29 +4266,29 @@ onMounted(() => {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* 表头左侧固定列背景 */
|
||||
/* 表头左侧固定列背景 - 使用纯色避免滚动时重叠 */
|
||||
.table-container thead .checkbox-column,
|
||||
.table-container thead .name-column {
|
||||
z-index: 30;
|
||||
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
|
||||
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
|
||||
}
|
||||
|
||||
.dark .table-container thead .checkbox-column,
|
||||
.dark .table-container thead .name-column {
|
||||
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
|
||||
background: linear-gradient(to bottom, #374151, #1f2937);
|
||||
}
|
||||
|
||||
/* 表头右侧操作列背景 */
|
||||
/* 表头右侧操作列背景 - 使用纯色避免滚动时重叠 */
|
||||
.table-container thead .operations-column {
|
||||
z-index: 30;
|
||||
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
|
||||
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
|
||||
}
|
||||
|
||||
.dark .table-container thead .operations-column {
|
||||
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
|
||||
background: linear-gradient(to bottom, #374151, #1f2937);
|
||||
}
|
||||
|
||||
/* tbody 中的左侧固定列背景处理 */
|
||||
/* tbody 中的左侧固定列背景处理 - 使用纯色避免滚动时重叠 */
|
||||
.table-container tbody tr:nth-child(odd) .checkbox-column,
|
||||
.table-container tbody tr:nth-child(odd) .name-column {
|
||||
background-color: #ffffff;
|
||||
@@ -4255,28 +4296,28 @@ onMounted(() => {
|
||||
|
||||
.table-container tbody tr:nth-child(even) .checkbox-column,
|
||||
.table-container tbody tr:nth-child(even) .name-column {
|
||||
background-color: rgba(249, 250, 251, 0.7);
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(odd) .checkbox-column,
|
||||
.dark .table-container tbody tr:nth-child(odd) .name-column {
|
||||
background-color: rgba(31, 41, 55, 0.4);
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(even) .checkbox-column,
|
||||
.dark .table-container tbody tr:nth-child(even) .name-column {
|
||||
background-color: rgba(55, 65, 81, 0.3);
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
/* hover 状态下的左侧固定列背景 */
|
||||
.table-container tbody tr:hover .checkbox-column,
|
||||
.table-container tbody tr:hover .name-column {
|
||||
background-color: rgba(239, 246, 255, 0.6);
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:hover .checkbox-column,
|
||||
.dark .table-container tbody tr:hover .name-column {
|
||||
background-color: rgba(30, 58, 138, 0.2);
|
||||
background-color: #1e3a5f;
|
||||
}
|
||||
|
||||
/* 名称列右侧阴影(分隔效果) */
|
||||
@@ -4288,30 +4329,30 @@ onMounted(() => {
|
||||
box-shadow: 8px 0 12px -8px rgba(30, 41, 59, 0.45);
|
||||
}
|
||||
|
||||
/* tbody 中的操作列背景处理 */
|
||||
/* tbody 中的操作列背景处理 - 使用纯色避免滚动时重叠 */
|
||||
.table-container tbody tr:nth-child(odd) .operations-column {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.table-container tbody tr:nth-child(even) .operations-column {
|
||||
background-color: rgba(249, 250, 251, 0.7);
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(odd) .operations-column {
|
||||
background-color: rgba(31, 41, 55, 0.4);
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(even) .operations-column {
|
||||
background-color: rgba(55, 65, 81, 0.3);
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
/* hover 状态下的操作列背景 */
|
||||
.table-container tbody tr:hover .operations-column {
|
||||
background-color: rgba(239, 246, 255, 0.6);
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:hover .operations-column {
|
||||
background-color: rgba(30, 58, 138, 0.2);
|
||||
background-color: #1e3a5f;
|
||||
}
|
||||
|
||||
/* 操作列左侧阴影 */
|
||||
|
||||
@@ -4841,40 +4841,40 @@ onUnmounted(() => {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
/* 确保操作列在浅色模式下有正确的背景 */
|
||||
/* 确保操作列在浅色模式下有正确的背景 - 使用纯色避免滚动时重叠 */
|
||||
.table-container thead .operations-column {
|
||||
z-index: 30;
|
||||
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
|
||||
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
|
||||
}
|
||||
|
||||
.dark .table-container thead .operations-column {
|
||||
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
|
||||
background: linear-gradient(to bottom, #374151, #1f2937);
|
||||
}
|
||||
|
||||
/* tbody 中的操作列背景处理 */
|
||||
/* tbody 中的操作列背景处理 - 使用纯色避免滚动时重叠 */
|
||||
.table-container tbody tr:nth-child(odd) .operations-column {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.table-container tbody tr:nth-child(even) .operations-column {
|
||||
background-color: rgba(249, 250, 251, 0.7);
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(odd) .operations-column {
|
||||
background-color: rgba(31, 41, 55, 0.4);
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(even) .operations-column {
|
||||
background-color: rgba(55, 65, 81, 0.3);
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
/* hover 状态下的操作列背景 */
|
||||
.table-container tbody tr:hover .operations-column {
|
||||
background-color: rgba(239, 246, 255, 0.6);
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:hover .operations-column {
|
||||
background-color: rgba(30, 58, 138, 0.2);
|
||||
background-color: #1e3a5f;
|
||||
}
|
||||
|
||||
.table-container tbody .operations-column {
|
||||
@@ -4892,19 +4892,19 @@ onUnmounted(() => {
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
/* 表头左侧固定列背景 */
|
||||
/* 表头左侧固定列背景 - 使用纯色避免滚动时重叠 */
|
||||
.table-container thead .checkbox-column,
|
||||
.table-container thead .name-column {
|
||||
z-index: 30;
|
||||
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
|
||||
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
|
||||
}
|
||||
|
||||
.dark .table-container thead .checkbox-column,
|
||||
.dark .table-container thead .name-column {
|
||||
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
|
||||
background: linear-gradient(to bottom, #374151, #1f2937);
|
||||
}
|
||||
|
||||
/* tbody 中的左侧固定列背景处理 */
|
||||
/* tbody 中的左侧固定列背景处理 - 使用纯色避免滚动时重叠 */
|
||||
.table-container tbody tr:nth-child(odd) .checkbox-column,
|
||||
.table-container tbody tr:nth-child(odd) .name-column {
|
||||
background-color: #ffffff;
|
||||
@@ -4912,28 +4912,28 @@ onUnmounted(() => {
|
||||
|
||||
.table-container tbody tr:nth-child(even) .checkbox-column,
|
||||
.table-container tbody tr:nth-child(even) .name-column {
|
||||
background-color: rgba(249, 250, 251, 0.7);
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(odd) .checkbox-column,
|
||||
.dark .table-container tbody tr:nth-child(odd) .name-column {
|
||||
background-color: rgba(31, 41, 55, 0.4);
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:nth-child(even) .checkbox-column,
|
||||
.dark .table-container tbody tr:nth-child(even) .name-column {
|
||||
background-color: rgba(55, 65, 81, 0.3);
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
/* hover 状态下的左侧固定列背景 */
|
||||
.table-container tbody tr:hover .checkbox-column,
|
||||
.table-container tbody tr:hover .name-column {
|
||||
background-color: rgba(239, 246, 255, 0.6);
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.dark .table-container tbody tr:hover .checkbox-column,
|
||||
.dark .table-container tbody tr:hover .name-column {
|
||||
background-color: rgba(30, 58, 138, 0.2);
|
||||
background-color: #1e3a5f;
|
||||
}
|
||||
|
||||
/* 名称列右侧阴影(分隔效果) */
|
||||
|
||||
Reference in New Issue
Block a user