Compare commits

..

10 Commits

Author SHA1 Message Date
github-actions[bot]
c38b3d2a78 chore: sync VERSION file with release v1.1.217 [skip ci] 2025-12-01 07:13:46 +00:00
shaw
e8e6f972b4 fix: 增强console账号test端点 2025-12-01 15:08:40 +08:00
shaw
d3155b82ea style: 优化表格布局 2025-12-01 14:20:53 +08:00
shaw
02018e10f3 feat: 为console类型账号增加count_tokens端点判断 2025-12-01 10:14:12 +08:00
github-actions[bot]
e17cd1d61b chore: sync VERSION file with release v1.1.216 [skip ci] 2025-11-30 13:13:59 +00:00
Wesley Liddick
b9d53647bd Merge pull request #727 from xilu0/main
fix: 修复 Claude API 400 错误:tool_result/tool_use 不匹配问题
2025-11-30 08:13:43 -05:00
github-actions[bot]
a872529b2e chore: sync VERSION file with release v1.1.215 [skip ci] 2025-11-29 13:31:00 +00:00
shaw
dfee7be944 fix: 调整gemini-api BaseApi后缀以适配更多端点 2025-11-29 21:30:28 +08:00
github-actions[bot]
392601efd5 chore: sync VERSION file with release v1.1.215 [skip ci] 2025-11-29 09:51:09 +00:00
Dave
249e256360 fix: 修复 Claude API 400 错误:tool_result/tool_use 不匹配问题
错误信息:
     messages.14.content.0: unexpected tool_use_id found in tool_result blocks: toolu_01Ekn6YJMk7yt7hNcn4PZxtM.
     Each tool_result block must have a corresponding tool_use block in the previous message.
根本原因:
     文件: src/services/claudeRelayService.js 中的 _enforceCacheControlLimit() 方法
原实现问题:
     1. 当 cache_control 块超过 4 个时,直接删除整个内容块(splice)
     2. 这会删除 tool_use 块,导致后续的 tool_result 找不到对应的 tool_use_id
     3. 也会删除用户的文本消息,导致上下文丢失
重要背景(官方文档确认)
     根据 Claude API 官方文档:
     - 最多可定义 4 个 cache_control 断点
     - 如果超过限制,API 不会报错,只是静默地忽略多余的断点
     - "20 个块回溯窗口" 是缓存命中检查的范围,与断点数量限制无关
     因此,这个函数的原始设计(删除内容块)是不必要且有害的。
修复方案:
     保留函数但修改行为:只删除 cache_control 属性,保留内容本身
修改位置;
     文件: src/services/claudeRelayService.js
修改内容:
     将 removeFromMessages() 和 removeFromSystem() 函数从"删除整个内容块"改为"只删除 cache_control 属性":
     // 修改前:直接删除整个内容块
     message.content.splice(contentIndex, 1)
     // 修改后:只删除 cache_control 属性,保留内容
     delete contentItem.cache_control
效果对比;
     | 场景         | 修复前            | 修复后            |
     |------------|----------------|----------------|
     | 用户文本消息     |  整个消息被删除      |  保留消息,只移除缓存标记 |
     | tool_use 块 |  被删除导致 400 错误 |  保留完整内容       |
     | system 提示词 |  整个提示词被删除     |  保留提示词内容      |
     | 缓存功能       | ⚠️ 强制限制        |  降级(不缓存但内容完整) |
2025-11-29 17:50:45 +08:00
11 changed files with 722 additions and 662 deletions

View File

@@ -1 +1 @@
1.1.214 1.1.217

View File

@@ -22,6 +22,54 @@ const ProxyHelper = require('../utils/proxyHelper')
// 工具函数 // 工具函数
// ============================================================================ // ============================================================================
/**
* 构建 Gemini API URL
* 兼容新旧 baseUrl 格式:
* - 新格式(以 /models 结尾): https://xxx.com/v1beta/models -> 直接拼接 /{model}:action
* - 旧格式(不以 /models 结尾): https://xxx.com -> 拼接 /v1beta/models/{model}:action
*
* @param {string} baseUrl - 账户配置的基础地址
* @param {string} model - 模型名称
* @param {string} action - API 动作 (generateContent, streamGenerateContent, countTokens)
* @param {string} apiKey - API Key
* @param {object} options - 额外选项 { stream: boolean, listModels: boolean }
* @returns {string} 完整的 API URL
*/
function buildGeminiApiUrl(baseUrl, model, action, apiKey, options = {}) {
const { stream = false, listModels = false } = options
// 移除末尾的斜杠(如果有)
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '')
// 检查是否为新格式(以 /models 结尾)
const isNewFormat = normalizedBaseUrl.endsWith('/models')
let url
if (listModels) {
// 获取模型列表
if (isNewFormat) {
// 新格式: baseUrl 已包含 /v1beta/models直接添加查询参数
url = `${normalizedBaseUrl}?key=${apiKey}`
} else {
// 旧格式: 需要拼接 /v1beta/models
url = `${normalizedBaseUrl}/v1beta/models?key=${apiKey}`
}
} else {
// 模型操作 (generateContent, streamGenerateContent, countTokens)
const streamParam = stream ? '&alt=sse' : ''
if (isNewFormat) {
// 新格式: baseUrl 已包含 /v1beta/models直接拼接 /{model}:action
url = `${normalizedBaseUrl}/${model}:${action}?key=${apiKey}${streamParam}`
} else {
// 旧格式: 需要拼接 /v1beta/models/{model}:action
url = `${normalizedBaseUrl}/v1beta/models/${model}:${action}?key=${apiKey}${streamParam}`
}
}
return url
}
/** /**
* 生成会话哈希 * 生成会话哈希
*/ */
@@ -378,9 +426,13 @@ async function handleMessages(req, res) {
// 解析代理配置 // 解析代理配置
const proxyConfig = parseProxyConfig(account) const proxyConfig = parseProxyConfig(account)
const apiUrl = stream const apiUrl = buildGeminiApiUrl(
? `${account.baseUrl}/v1beta/models/${model}:streamGenerateContent?key=${account.apiKey}&alt=sse` account.baseUrl,
: `${account.baseUrl}/v1beta/models/${model}:generateContent?key=${account.apiKey}` model,
stream ? 'streamGenerateContent' : 'generateContent',
account.apiKey,
{ stream }
)
const axiosConfig = { const axiosConfig = {
method: 'POST', method: 'POST',
@@ -671,7 +723,9 @@ async function handleModels(req, res) {
// API Key 账户:使用 API Key 获取模型列表 // API Key 账户:使用 API Key 获取模型列表
const proxyConfig = parseProxyConfig(account) const proxyConfig = parseProxyConfig(account)
try { try {
const apiUrl = `${account.baseUrl}/v1beta/models?key=${account.apiKey}` const apiUrl = buildGeminiApiUrl(account.baseUrl, null, null, account.apiKey, {
listModels: true
})
const axiosConfig = { const axiosConfig = {
method: 'GET', method: 'GET',
url: apiUrl, url: apiUrl,
@@ -1169,8 +1223,8 @@ async function handleCountTokens(req, res) {
let response let response
if (isApiAccount) { if (isApiAccount) {
// API Key 账户:直接使用 API Key 请求 // API Key 账户:直接使用 API Key 请求
const modelPath = model.startsWith('models/') ? model : `models/${model}` const modelName = model.startsWith('models/') ? model.replace('models/', '') : model
const apiUrl = `${account.baseUrl}/v1beta/${modelPath}:countTokens?key=${account.apiKey}` const apiUrl = buildGeminiApiUrl(account.baseUrl, modelName, 'countTokens', account.apiKey)
const axiosConfig = { const axiosConfig = {
method: 'POST', method: 'POST',
@@ -1897,7 +1951,7 @@ async function handleStandardGenerateContent(req, res) {
if (isApiAccount) { if (isApiAccount) {
// Gemini API 账户:直接使用 API Key 请求 // Gemini API 账户:直接使用 API Key 请求
const apiUrl = `${account.baseUrl}/v1beta/models/${model}:generateContent?key=${account.apiKey}` const apiUrl = buildGeminiApiUrl(account.baseUrl, model, 'generateContent', account.apiKey)
const axiosConfig = { const axiosConfig = {
method: 'POST', method: 'POST',
@@ -2168,7 +2222,15 @@ async function handleStandardStreamGenerateContent(req, res) {
if (isApiAccount) { if (isApiAccount) {
// Gemini API 账户:直接使用 API Key 请求流式接口 // Gemini API 账户:直接使用 API Key 请求流式接口
const apiUrl = `${account.baseUrl}/v1beta/models/${model}:streamGenerateContent?key=${account.apiKey}&alt=sse` const apiUrl = buildGeminiApiUrl(
account.baseUrl,
model,
'streamGenerateContent',
account.apiKey,
{
stream: true
}
)
const axiosConfig = { const axiosConfig = {
method: 'POST', method: 'POST',

View File

@@ -972,6 +972,9 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
const maxAttempts = 2 const maxAttempts = 2
let attempt = 0 let attempt = 0
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const processRequest = async () => { const processRequest = async () => {
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey, 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 = { const relayOptions = {
skipUsageRecord: true, skipUsageRecord: true,
customPath: '/v1/messages/count_tokens' customPath: '/v1/messages/count_tokens'
@@ -1028,6 +1042,23 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
relayOptions 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) res.status(response.statusCode)
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length'] 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}`) logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`)
return { fallbackResponse: false }
} }
while (attempt < maxAttempts) { while (attempt < maxAttempts) {
try { 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 return
} catch (error) { } catch (error) {
if (error.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') { if (error.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') {

View File

@@ -5,6 +5,7 @@ const apiKeyService = require('../services/apiKeyService')
const CostCalculator = require('../utils/costCalculator') const CostCalculator = require('../utils/costCalculator')
const claudeAccountService = require('../services/claudeAccountService') const claudeAccountService = require('../services/claudeAccountService')
const openaiAccountService = require('../services/openaiAccountService') const openaiAccountService = require('../services/openaiAccountService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
const router = express.Router() const router = express.Router()
@@ -792,8 +793,8 @@ router.post('/api/batch-model-stats', async (req, res) => {
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务 // 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
router.post('/api-key/test', async (req, res) => { router.post('/api-key/test', async (req, res) => {
const axios = require('axios')
const config = require('../../config/config') const config = require('../../config/config')
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
try { try {
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body 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) { if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
return res.status(400).json({ return res.status(400).json({
error: 'Invalid API key format', 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) const validation = await apiKeyService.validateApiKeyForStats(apiKey)
if (!validation.valid) { if (!validation.valid) {
return res.status(401).json({ 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})`) 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 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 = { await sendStreamTestRequest({
model, apiUrl,
messages: [ authorization: apiKey,
{ responseStream: res,
role: 'user', payload: createClaudeTestPayload(model, { stream: true }),
content: 'hi' timeout: 60000,
} extraHeaders: { 'x-api-key': apiKey }
],
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) { } catch (error) {
logger.error('❌ API Key test failed:', error) logger.error('❌ API Key test failed:', error)
// 如果还未发送响应头返回JSON错误
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).json({ return res.status(500).json({
error: 'Test failed', error: 'Test failed',
message: error.response?.data?.error?.message || error.message || 'Internal server error' message: error.message || 'Internal server error'
}) })
} }
// 如果已经是SSE流发送错误事件
res.write( 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() res.end()
} }

View File

@@ -1510,6 +1510,71 @@ class ClaudeConsoleAccountService {
const expiryDate = new Date(account.subscriptionExpiresAt) const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date() 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() module.exports = new ClaudeConsoleAccountService()

View File

@@ -1113,55 +1113,11 @@ class ClaudeConsoleRelayService {
} }
} }
// 🧪 测试账号连接供Admin API使用独立处理以确保错误时也返回SSE格式 // 🧪 测试账号连接供Admin API使用
async testAccountConnection(accountId, responseStream) { async testAccountConnection(accountId, responseStream) {
const testRequestBody = { const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
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 {
// 忽略写入错误
}
}
}
try { try {
// 获取账户信息
const account = await claudeConsoleAccountService.getAccount(accountId) const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) { if (!account) {
throw new Error('Account not found') throw new Error('Account not found')
@@ -1169,178 +1125,32 @@ class ClaudeConsoleRelayService {
logger.info(`🧪 Testing Claude Console account connection: ${account.name} (${accountId})`) 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 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 await sendStreamTestRequest({
const userAgent = account.userAgent || this.defaultUserAgent apiUrl,
authorization: `Bearer ${account.apiKey}`,
// 准备请求配置 responseStream,
const requestConfig = { proxyAgent: claudeConsoleAccountService._createProxyAgent(account.proxy),
method: 'POST', extraHeaders: account.userAgent ? { 'User-Agent': account.userAgent } : {}
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()
})
}) })
} catch (error) { } catch (error) {
logger.error(`❌ Test account connection failed:`, 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()
}
} }
} }

View File

@@ -13,6 +13,7 @@ const redis = require('../models/redis')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator') const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const { formatDateWithTimezone } = require('../utils/dateHelper') const { formatDateWithTimezone } = require('../utils/dateHelper')
const requestIdentityService = require('./requestIdentityService') const requestIdentityService = require('./requestIdentityService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
class ClaudeRelayService { class ClaudeRelayService {
constructor() { constructor() {
@@ -789,7 +790,8 @@ class ClaudeRelayService {
return total return total
} }
const removeFromMessages = () => { // 只移除 cache_control 属性,保留内容本身,避免丢失用户消息
const removeCacheControlFromMessages = () => {
if (!Array.isArray(body.messages)) { if (!Array.isArray(body.messages)) {
return false return false
} }
@@ -803,12 +805,8 @@ class ClaudeRelayService {
for (let contentIndex = 0; contentIndex < message.content.length; contentIndex += 1) { for (let contentIndex = 0; contentIndex < message.content.length; contentIndex += 1) {
const contentItem = message.content[contentIndex] const contentItem = message.content[contentIndex]
if (contentItem && contentItem.cache_control) { if (contentItem && contentItem.cache_control) {
message.content.splice(contentIndex, 1) // 只删除 cache_control 属性,保留内容
delete contentItem.cache_control
if (message.content.length === 0) {
body.messages.splice(messageIndex, 1)
}
return true return true
} }
} }
@@ -817,7 +815,8 @@ class ClaudeRelayService {
return false return false
} }
const removeFromSystem = () => { // 只移除 cache_control 属性,保留 system 内容
const removeCacheControlFromSystem = () => {
if (!Array.isArray(body.system)) { if (!Array.isArray(body.system)) {
return false return false
} }
@@ -825,12 +824,8 @@ class ClaudeRelayService {
for (let index = 0; index < body.system.length; index += 1) { for (let index = 0; index < body.system.length; index += 1) {
const systemItem = body.system[index] const systemItem = body.system[index]
if (systemItem && systemItem.cache_control) { if (systemItem && systemItem.cache_control) {
body.system.splice(index, 1) // 只删除 cache_control 属性,保留内容
delete systemItem.cache_control
if (body.system.length === 0) {
delete body.system
}
return true return true
} }
} }
@@ -841,12 +836,13 @@ class ClaudeRelayService {
let total = countCacheControlBlocks() let total = countCacheControlBlocks()
while (total > MAX_CACHE_CONTROL_BLOCKS) { while (total > MAX_CACHE_CONTROL_BLOCKS) {
if (removeFromMessages()) { // 优先从 messages 中移除 cache_control再从 system 中移除
if (removeCacheControlFromMessages()) {
total -= 1 total -= 1
continue continue
} }
if (removeFromSystem()) { if (removeCacheControlFromSystem()) {
total -= 1 total -= 1
continue continue
} }
@@ -2249,26 +2245,7 @@ class ClaudeRelayService {
// 🧪 测试账号连接供Admin API使用直接复用 _makeClaudeStreamRequestWithUsageCapture // 🧪 测试账号连接供Admin API使用直接复用 _makeClaudeStreamRequestWithUsageCapture
async testAccountConnection(accountId, responseStream) { async testAccountConnection(accountId, responseStream) {
const testRequestBody = { const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
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'
}
]
}
try { try {
// 获取账户信息 // 获取账户信息

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

View File

@@ -1524,24 +1524,32 @@
<input <input
v-model="form.baseUrl" v-model="form.baseUrl"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="https://generativelanguage.googleapis.com" :class="{ 'border-red-500 dark:border-red-400': errors.baseUrl }"
placeholder="https://generativelanguage.googleapis.com/v1beta/models"
required required
type="url" type="url"
/> />
<p v-if="errors.baseUrl" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.baseUrl }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
填写 API 基础地址(可包含路径前缀),系统会自动拼接 填写 API 基础地址,必须以
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600">/models</code>
结尾。系统会自动拼接
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>/v1beta/models/{model}:generateContent</code >/{model}:generateContent</code
> >
</p> </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
官方: 官方:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://generativelanguage.googleapis.com</code >https://generativelanguage.googleapis.com/v1beta/models</code
> >
| 上游为 CRS: </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
上游为 CRS:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://your-crs.com/gemini</code >https://your-crs.com/gemini/v1beta/models</code
> >
</p> </p>
</div> </div>
@@ -3025,23 +3033,31 @@
<input <input
v-model="form.baseUrl" v-model="form.baseUrl"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
placeholder="https://generativelanguage.googleapis.com" :class="{ 'border-red-500 dark:border-red-400': errors.baseUrl }"
placeholder="https://generativelanguage.googleapis.com/v1beta/models"
type="url" type="url"
/> />
<p v-if="errors.baseUrl" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.baseUrl }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
填写 API 基础地址(可包含路径前缀),系统会自动拼接 填写 API 基础地址,必须以
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600">/models</code>
结尾。系统会自动拼接
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>/v1beta/models/{model}:generateContent</code >/{model}:generateContent</code
> >
</p> </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
官方: 官方:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://generativelanguage.googleapis.com</code >https://generativelanguage.googleapis.com/v1beta/models</code
> >
| 上游为 CRS: </p>
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
上游为 CRS:
<code class="rounded bg-gray-100 px-1 dark:bg-gray-600" <code class="rounded bg-gray-100 px-1 dark:bg-gray-600"
>https://your-crs.com/gemini</code >https://your-crs.com/gemini/v1beta/models</code
> >
</p> </p>
</div> </div>
@@ -4485,6 +4501,14 @@ const createAccount = async () => {
errors.value.apiKey = '请填写 API Key' errors.value.apiKey = '请填写 API Key'
hasError = true hasError = true
} }
// 验证 baseUrl 必须以 /models 结尾
if (!form.value.baseUrl || form.value.baseUrl.trim() === '') {
errors.value.baseUrl = '请填写 API 基础地址'
hasError = true
} else if (!form.value.baseUrl.trim().endsWith('/models')) {
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
hasError = true
}
} else { } else {
// 其他平台(如 Droid使用多 API Key 输入 // 其他平台(如 Droid使用多 API Key 输入
const apiKeys = parseApiKeysInput(form.value.apiKeysInput) const apiKeys = parseApiKeysInput(form.value.apiKeysInput)
@@ -4748,6 +4772,7 @@ const updateAccount = async () => {
// 清除之前的错误 // 清除之前的错误
errors.value.name = '' errors.value.name = ''
errors.value.apiKeys = '' errors.value.apiKeys = ''
errors.value.baseUrl = ''
// 验证账户名称 // 验证账户名称
if (!form.value.name || form.value.name.trim() === '') { if (!form.value.name || form.value.name.trim() === '') {
@@ -4755,6 +4780,19 @@ const updateAccount = async () => {
return return
} }
// Gemini API 的 baseUrl 验证(必须以 /models 结尾)
if (form.value.platform === 'gemini-api') {
const baseUrl = form.value.baseUrl?.trim() || ''
if (!baseUrl) {
errors.value.baseUrl = '请填写 API 基础地址'
return
}
if (!baseUrl.endsWith('/models')) {
errors.value.baseUrl = 'API 基础地址必须以 /models 结尾'
return
}
}
// 分组类型验证 - 更新账户流程修复 // 分组类型验证 - 更新账户流程修复
if ( if (
form.value.accountType === 'group' && form.value.accountType === 'group' &&

View File

@@ -162,7 +162,7 @@
<!-- 桌面端表格视图 --> <!-- 桌面端表格视图 -->
<div v-else class="table-wrapper hidden md:block"> <div v-else class="table-wrapper hidden md:block">
<div class="table-container"> <div ref="tableContainerRef" class="table-container">
<table class="w-full"> <table class="w-full">
<thead <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" 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" /> <i v-else class="fas fa-sort ml-1 text-gray-400" />
</th> </th>
<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" 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('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-[100px] min-w-[120px] max-w-[150px] 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')" @click="sortAccounts('status')"
> >
状态 状态
@@ -243,26 +228,6 @@
/> />
<i v-else class="fas fa-sort ml-1 text-gray-400" /> <i v-else class="fas fa-sort ml-1 text-gray-400" />
</th> </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 <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" 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" class="fas fa-info-circle mt-[2px] text-[10px] text-indigo-500"
></i> ></i>
<span class="font-medium text-white dark:text-gray-900" <span class="font-medium text-white dark:text-gray-900"
>重置剩余 0 进度条与百分比会同步清零</span >"重置剩余" 0 进度条与百分比会同步清零</span
> >
</div> </div>
</div> </div>
@@ -394,7 +359,43 @@
最后使用 最后使用
</th> </th>
<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> </th>
@@ -615,49 +616,6 @@
</div> </div>
</div> </div>
</td> </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"> <td class="w-[100px] min-w-[100px] max-w-[100px] whitespace-nowrap px-3 py-4">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span <span
@@ -748,46 +706,6 @@
</span> </span>
</div> </div>
</td> </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"> <td class="whitespace-nowrap px-3 py-4 text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1"> <div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2"> <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"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-300">
{{ formatLastUsed(account.lastUsedAt) }} {{ formatLastUsed(account.lastUsedAt) }}
</td> </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 <td
class="operations-column sticky right-0 z-10 whitespace-nowrap px-3 py-4 text-sm font-medium" 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 <button
v-if="showResetButton(account)" v-if="showResetButton(account)"
:class="[ :class="[
@@ -1224,8 +1225,8 @@
<span class="ml-1">删除</span> <span class="ml-1">删除</span>
</button> </button>
</div> </div>
<!-- 小屏显示2个快捷按钮 + 下拉菜单 --> <!-- 需要横向滚动时使用缩减形式2个快捷按钮 + 下拉菜单 -->
<div class="flex items-center gap-1 2xl:hidden"> <div v-else class="flex items-center gap-1">
<button <button
:class="[ :class="[
'rounded px-2.5 py-1 text-xs font-medium transition-colors', 'rounded px-2.5 py-1 text-xs font-medium transition-colors',
@@ -1853,7 +1854,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
@@ -1929,6 +1930,10 @@ const expiryEditModalRef = ref(null)
const showAccountTestModal = ref(false) const showAccountTestModal = ref(false)
const testingAccount = ref(null) const testingAccount = ref(null)
// 表格横向滚动检测
const tableContainerRef = ref(null)
const needsHorizontalScroll = ref(false)
// 缓存状态标志 // 缓存状态标志
const apiKeysLoaded = ref(false) // 用于其他功能 const apiKeysLoaded = ref(false) // 用于其他功能
const bindingCountsLoaded = ref(false) // 轻量级绑定计数缓存 const bindingCountsLoaded = ref(false) // 轻量级绑定计数缓存
@@ -4009,6 +4014,10 @@ watch(currentPage, () => {
watch(paginatedAccounts, () => { watch(paginatedAccounts, () => {
updateSelectAllState() updateSelectAllState()
// 数据变化后重新检测是否需要横向滚动
nextTick(() => {
checkHorizontalScroll()
})
}) })
watch(accounts, () => { 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(() => { onMounted(() => {
// 首次加载时强制刷新所有数据 // 首次加载时强制刷新所有数据
loadAccounts(true) 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> </script>
@@ -4225,29 +4266,29 @@ onMounted(() => {
background-color: rgba(255, 255, 255, 0.02); background-color: rgba(255, 255, 255, 0.02);
} }
/* 表头左侧固定列背景 */ /* 表头左侧固定列背景 - 使用纯色避免滚动时重叠 */
.table-container thead .checkbox-column, .table-container thead .checkbox-column,
.table-container thead .name-column { .table-container thead .name-column {
z-index: 30; 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 .checkbox-column,
.dark .table-container thead .name-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 { .table-container thead .operations-column {
z-index: 30; 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 { .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) .checkbox-column,
.table-container tbody tr:nth-child(odd) .name-column { .table-container tbody tr:nth-child(odd) .name-column {
background-color: #ffffff; background-color: #ffffff;
@@ -4255,28 +4296,28 @@ onMounted(() => {
.table-container tbody tr:nth-child(even) .checkbox-column, .table-container tbody tr:nth-child(even) .checkbox-column,
.table-container tbody tr:nth-child(even) .name-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) .checkbox-column,
.dark .table-container tbody tr:nth-child(odd) .name-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) .checkbox-column,
.dark .table-container tbody tr:nth-child(even) .name-column { .dark .table-container tbody tr:nth-child(even) .name-column {
background-color: rgba(55, 65, 81, 0.3); background-color: #374151;
} }
/* hover 状态下的左侧固定列背景 */ /* hover 状态下的左侧固定列背景 */
.table-container tbody tr:hover .checkbox-column, .table-container tbody tr:hover .checkbox-column,
.table-container tbody tr:hover .name-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 .checkbox-column,
.dark .table-container tbody tr:hover .name-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); box-shadow: 8px 0 12px -8px rgba(30, 41, 59, 0.45);
} }
/* tbody 中的操作列背景处理 */ /* tbody 中的操作列背景处理 - 使用纯色避免滚动时重叠 */
.table-container tbody tr:nth-child(odd) .operations-column { .table-container tbody tr:nth-child(odd) .operations-column {
background-color: #ffffff; background-color: #ffffff;
} }
.table-container tbody tr:nth-child(even) .operations-column { .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 { .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 { .dark .table-container tbody tr:nth-child(even) .operations-column {
background-color: rgba(55, 65, 81, 0.3); background-color: #374151;
} }
/* hover 状态下的操作列背景 */ /* hover 状态下的操作列背景 */
.table-container tbody tr:hover .operations-column { .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 { .dark .table-container tbody tr:hover .operations-column {
background-color: rgba(30, 58, 138, 0.2); background-color: #1e3a5f;
} }
/* 操作列左侧阴影 */ /* 操作列左侧阴影 */

View File

@@ -4841,40 +4841,40 @@ onUnmounted(() => {
z-index: 12; z-index: 12;
} }
/* 确保操作列在浅色模式下有正确的背景 */ /* 确保操作列在浅色模式下有正确的背景 - 使用纯色避免滚动时重叠 */
.table-container thead .operations-column { .table-container thead .operations-column {
z-index: 30; 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 { .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 { .table-container tbody tr:nth-child(odd) .operations-column {
background-color: #ffffff; background-color: #ffffff;
} }
.table-container tbody tr:nth-child(even) .operations-column { .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 { .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 { .dark .table-container tbody tr:nth-child(even) .operations-column {
background-color: rgba(55, 65, 81, 0.3); background-color: #374151;
} }
/* hover 状态下的操作列背景 */ /* hover 状态下的操作列背景 */
.table-container tbody tr:hover .operations-column { .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 { .dark .table-container tbody tr:hover .operations-column {
background-color: rgba(30, 58, 138, 0.2); background-color: #1e3a5f;
} }
.table-container tbody .operations-column { .table-container tbody .operations-column {
@@ -4892,19 +4892,19 @@ onUnmounted(() => {
z-index: 12; z-index: 12;
} }
/* 表头左侧固定列背景 */ /* 表头左侧固定列背景 - 使用纯色避免滚动时重叠 */
.table-container thead .checkbox-column, .table-container thead .checkbox-column,
.table-container thead .name-column { .table-container thead .name-column {
z-index: 30; 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 .checkbox-column,
.dark .table-container thead .name-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) .checkbox-column,
.table-container tbody tr:nth-child(odd) .name-column { .table-container tbody tr:nth-child(odd) .name-column {
background-color: #ffffff; background-color: #ffffff;
@@ -4912,28 +4912,28 @@ onUnmounted(() => {
.table-container tbody tr:nth-child(even) .checkbox-column, .table-container tbody tr:nth-child(even) .checkbox-column,
.table-container tbody tr:nth-child(even) .name-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) .checkbox-column,
.dark .table-container tbody tr:nth-child(odd) .name-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) .checkbox-column,
.dark .table-container tbody tr:nth-child(even) .name-column { .dark .table-container tbody tr:nth-child(even) .name-column {
background-color: rgba(55, 65, 81, 0.3); background-color: #374151;
} }
/* hover 状态下的左侧固定列背景 */ /* hover 状态下的左侧固定列背景 */
.table-container tbody tr:hover .checkbox-column, .table-container tbody tr:hover .checkbox-column,
.table-container tbody tr:hover .name-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 .checkbox-column,
.dark .table-container tbody tr:hover .name-column { .dark .table-container tbody tr:hover .name-column {
background-color: rgba(30, 58, 138, 0.2); background-color: #1e3a5f;
} }
/* 名称列右侧阴影(分隔效果) */ /* 名称列右侧阴影(分隔效果) */