mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a636a36f6 | ||
|
|
b61e1062bf | ||
|
|
1b18a1226d | ||
|
|
0b2372abab | ||
|
|
8aca1f9dd1 | ||
|
|
b63f2f78fc | ||
|
|
c971d239ff | ||
|
|
01d6e30e82 | ||
|
|
5fd78b6411 | ||
|
|
9ad5c85c2c | ||
|
|
279cd72f23 | ||
|
|
81e89d2dc4 | ||
|
|
c38b3d2a78 | ||
|
|
e8e6f972b4 | ||
|
|
d3155b82ea | ||
|
|
02018e10f3 | ||
|
|
e17cd1d61b | ||
|
|
b9d53647bd | ||
|
|
a872529b2e | ||
|
|
dfee7be944 | ||
|
|
392601efd5 | ||
|
|
249e256360 |
6357
pnpm-lock.yaml
generated
Normal file
6357
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||||
@@ -397,9 +449,8 @@ async function handleMessages(req, res) {
|
|||||||
|
|
||||||
// 添加代理配置
|
// 添加代理配置
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
const proxyHelper = new ProxyHelper()
|
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -671,16 +722,17 @@ 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,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
}
|
}
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
const proxyHelper = new ProxyHelper()
|
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
|
||||||
}
|
}
|
||||||
const response = await axios(axiosConfig)
|
const response = await axios(axiosConfig)
|
||||||
models = (response.data.models || []).map((m) => ({
|
models = (response.data.models || []).map((m) => ({
|
||||||
@@ -1169,8 +1221,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',
|
||||||
@@ -1180,9 +1232,8 @@ async function handleCountTokens(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
const proxyHelper = new ProxyHelper()
|
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1897,7 +1948,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',
|
||||||
@@ -1909,9 +1960,8 @@ async function handleStandardGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
const proxyHelper = new ProxyHelper()
|
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2168,7 +2218,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',
|
||||||
@@ -2184,9 +2242,8 @@ async function handleStandardStreamGenerateContent(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (proxyConfig) {
|
if (proxyConfig) {
|
||||||
const proxyHelper = new ProxyHelper()
|
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
|
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
|
||||||
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -255,6 +255,108 @@ router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, asy
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Cookie自动授权端点 (基于sessionKey自动完成OAuth流程)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// 普通OAuth的Cookie自动授权
|
||||||
|
router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionKey, proxy } = req.body
|
||||||
|
|
||||||
|
// 验证sessionKey参数
|
||||||
|
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'sessionKey不能为空',
|
||||||
|
message: '请提供有效的sessionKey值'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedSessionKey = sessionKey.trim()
|
||||||
|
|
||||||
|
logger.info('🍪 Starting Cookie-based OAuth authorization', {
|
||||||
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
|
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
||||||
|
hasProxy: !!proxy
|
||||||
|
})
|
||||||
|
|
||||||
|
// 执行Cookie自动授权流程
|
||||||
|
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, false)
|
||||||
|
|
||||||
|
logger.success('🎉 Cookie-based OAuth authorization completed successfully')
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
claudeAiOauth: result.claudeAiOauth,
|
||||||
|
organizationUuid: result.organizationUuid,
|
||||||
|
capabilities: result.capabilities
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Cookie-based OAuth authorization failed:', {
|
||||||
|
error: error.message,
|
||||||
|
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cookie授权失败',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Setup Token的Cookie自动授权
|
||||||
|
router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sessionKey, proxy } = req.body
|
||||||
|
|
||||||
|
// 验证sessionKey参数
|
||||||
|
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'sessionKey不能为空',
|
||||||
|
message: '请提供有效的sessionKey值'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedSessionKey = sessionKey.trim()
|
||||||
|
|
||||||
|
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
|
||||||
|
sessionKeyLength: trimmedSessionKey.length,
|
||||||
|
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
|
||||||
|
hasProxy: !!proxy
|
||||||
|
})
|
||||||
|
|
||||||
|
// 执行Cookie自动授权流程(Setup Token模式)
|
||||||
|
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, true)
|
||||||
|
|
||||||
|
logger.success('🎉 Cookie-based Setup Token authorization completed successfully')
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
claudeAiOauth: result.claudeAiOauth,
|
||||||
|
organizationUuid: result.organizationUuid,
|
||||||
|
capabilities: result.capabilities
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Cookie-based Setup Token authorization failed:', {
|
||||||
|
error: error.message,
|
||||||
|
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Cookie授权失败',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 获取所有Claude账户
|
// 获取所有Claude账户
|
||||||
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
groupId,
|
groupId,
|
||||||
dailyQuota,
|
dailyQuota,
|
||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
maxConcurrentTasks
|
maxConcurrentTasks,
|
||||||
|
disableAutoProtection
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
if (!name || !apiUrl || !apiKey) {
|
if (!name || !apiUrl || !apiKey) {
|
||||||
@@ -151,6 +152,10 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验上游错误自动防护开关
|
||||||
|
const normalizedDisableAutoProtection =
|
||||||
|
disableAutoProtection === true || disableAutoProtection === 'true'
|
||||||
|
|
||||||
// 验证accountType的有效性
|
// 验证accountType的有效性
|
||||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||||
return res
|
return res
|
||||||
@@ -180,7 +185,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
maxConcurrentTasks:
|
maxConcurrentTasks:
|
||||||
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||||
? Number(maxConcurrentTasks)
|
? Number(maxConcurrentTasks)
|
||||||
: 0
|
: 0,
|
||||||
|
disableAutoProtection: normalizedDisableAutoProtection
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||||
@@ -250,6 +256,13 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req,
|
|||||||
return res.status(404).json({ error: 'Account not found' })
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 规范化上游错误自动防护开关
|
||||||
|
if (mappedUpdates.disableAutoProtection !== undefined) {
|
||||||
|
mappedUpdates.disableAutoProtection =
|
||||||
|
mappedUpdates.disableAutoProtection === true ||
|
||||||
|
mappedUpdates.disableAutoProtection === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
// 处理分组的变更
|
// 处理分组的变更
|
||||||
if (mappedUpdates.accountType !== undefined) {
|
if (mappedUpdates.accountType !== undefined) {
|
||||||
// 如果之前是分组类型,需要从所有分组中移除
|
// 如果之前是分组类型,需要从所有分组中移除
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ class ClaudeConsoleAccountService {
|
|||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||||
maxConcurrentTasks = 0 // 最大并发任务数,0表示无限制
|
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
||||||
|
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 验证必填字段
|
// 验证必填字段
|
||||||
@@ -115,7 +116,8 @@ class ClaudeConsoleAccountService {
|
|||||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||||
quotaResetTime, // 额度重置时间
|
quotaResetTime, // 额度重置时间
|
||||||
quotaStoppedAt: '', // 因额度停用的时间
|
quotaStoppedAt: '', // 因额度停用的时间
|
||||||
maxConcurrentTasks: maxConcurrentTasks.toString() // 最大并发任务数,0表示无限制
|
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
||||||
|
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
@@ -153,6 +155,7 @@ class ClaudeConsoleAccountService {
|
|||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
quotaStoppedAt: null,
|
quotaStoppedAt: null,
|
||||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||||
|
disableAutoProtection, // 新增:返回自动防护开关
|
||||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +216,8 @@ class ClaudeConsoleAccountService {
|
|||||||
|
|
||||||
// 并发控制相关
|
// 并发控制相关
|
||||||
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
||||||
activeTaskCount
|
activeTaskCount,
|
||||||
|
disableAutoProtection: accountData.disableAutoProtection === 'true'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,6 +263,7 @@ class ClaudeConsoleAccountService {
|
|||||||
}
|
}
|
||||||
accountData.isActive = accountData.isActive === 'true'
|
accountData.isActive = accountData.isActive === 'true'
|
||||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
||||||
|
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
|
||||||
|
|
||||||
if (accountData.proxy) {
|
if (accountData.proxy) {
|
||||||
accountData.proxy = JSON.parse(accountData.proxy)
|
accountData.proxy = JSON.parse(accountData.proxy)
|
||||||
@@ -367,6 +372,9 @@ class ClaudeConsoleAccountService {
|
|||||||
if (updates.maxConcurrentTasks !== undefined) {
|
if (updates.maxConcurrentTasks !== undefined) {
|
||||||
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
|
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
|
||||||
}
|
}
|
||||||
|
if (updates.disableAutoProtection !== undefined) {
|
||||||
|
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||||
@@ -1510,6 +1518,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()
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class ClaudeConsoleRelayService {
|
|||||||
throw new Error('Claude Console Claude account not found')
|
throw new Error('Claude Console Claude account not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoProtectionDisabled = account.disableAutoProtection === true
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
|
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
|
||||||
)
|
)
|
||||||
@@ -248,27 +250,41 @@ class ClaudeConsoleRelayService {
|
|||||||
|
|
||||||
// 检查错误状态并相应处理
|
// 检查错误状态并相应处理
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
|
logger.warn(
|
||||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
`🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||||
|
}
|
||||||
} else if (accountDisabledError) {
|
} else if (accountDisabledError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
|
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
)
|
)
|
||||||
// 传入完整的错误详情到 webhook
|
// 传入完整的错误详情到 webhook
|
||||||
const errorDetails =
|
const errorDetails =
|
||||||
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
||||||
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
|
||||||
|
}
|
||||||
} else if (response.status === 429) {
|
} else if (response.status === 429) {
|
||||||
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
|
logger.warn(
|
||||||
|
`🚫 Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
// 收到429先检查是否因为超过了手动配置的每日额度
|
// 收到429先检查是否因为超过了手动配置的每日额度
|
||||||
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||||
})
|
})
|
||||||
|
|
||||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||||
|
}
|
||||||
} else if (response.status === 529) {
|
} else if (response.status === 529) {
|
||||||
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
|
logger.warn(
|
||||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
`🚫 Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||||
|
}
|
||||||
} else if (response.status === 200 || response.status === 201) {
|
} else if (response.status === 200 || response.status === 201) {
|
||||||
// 如果请求成功,检查并移除错误状态
|
// 如果请求成功,检查并移除错误状态
|
||||||
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
||||||
@@ -597,6 +613,7 @@ class ClaudeConsoleRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
response.data.on('end', async () => {
|
response.data.on('end', async () => {
|
||||||
|
const autoProtectionDisabled = account.disableAutoProtection === true
|
||||||
// 记录原始错误消息到日志(方便调试,包含供应商信息)
|
// 记录原始错误消息到日志(方便调试,包含供应商信息)
|
||||||
logger.error(
|
logger.error(
|
||||||
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
|
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
|
||||||
@@ -609,24 +626,41 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
logger.warn(
|
||||||
|
`🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||||
|
}
|
||||||
} else if (accountDisabledError) {
|
} else if (accountDisabledError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
|
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
)
|
)
|
||||||
// 传入完整的错误详情到 webhook
|
// 传入完整的错误详情到 webhook
|
||||||
await claudeConsoleAccountService.markConsoleAccountBlocked(
|
if (!autoProtectionDisabled) {
|
||||||
accountId,
|
await claudeConsoleAccountService.markConsoleAccountBlocked(
|
||||||
errorDataForCheck
|
accountId,
|
||||||
)
|
errorDataForCheck
|
||||||
|
)
|
||||||
|
}
|
||||||
} else if (response.status === 429) {
|
} else if (response.status === 429) {
|
||||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
logger.warn(
|
||||||
|
`🚫 [Stream] Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
// 检查是否因为超过每日额度
|
// 检查是否因为超过每日额度
|
||||||
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||||
})
|
})
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||||
|
}
|
||||||
} else if (response.status === 529) {
|
} else if (response.status === 529) {
|
||||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
logger.warn(
|
||||||
|
`🚫 [Stream] Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置响应头
|
// 设置响应头
|
||||||
@@ -1113,55 +1147,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 +1159,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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const zlib = require('zlib')
|
|||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const ProxyHelper = require('../utils/proxyHelper')
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
|
const { filterForClaude } = require('../utils/headerFilter')
|
||||||
const claudeAccountService = require('./claudeAccountService')
|
const claudeAccountService = require('./claudeAccountService')
|
||||||
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler')
|
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
@@ -13,6 +14,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 +791,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 +806,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 +816,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 +825,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 +837,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
|
||||||
}
|
}
|
||||||
@@ -881,62 +878,9 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 🔧 过滤客户端请求头
|
// 🔧 过滤客户端请求头
|
||||||
_filterClientHeaders(clientHeaders) {
|
_filterClientHeaders(clientHeaders) {
|
||||||
// 需要移除的敏感 headers
|
// 使用统一的 headerFilter 工具类 - 移除 CDN、浏览器和代理相关 headers
|
||||||
const sensitiveHeaders = [
|
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
|
||||||
'content-type',
|
return filterForClaude(clientHeaders)
|
||||||
'user-agent',
|
|
||||||
'x-api-key',
|
|
||||||
'authorization',
|
|
||||||
'x-authorization',
|
|
||||||
'host',
|
|
||||||
'content-length',
|
|
||||||
'connection',
|
|
||||||
'proxy-authorization',
|
|
||||||
'content-encoding',
|
|
||||||
'transfer-encoding'
|
|
||||||
]
|
|
||||||
|
|
||||||
// 🆕 需要移除的浏览器相关 headers(避免CORS问题)
|
|
||||||
const browserHeaders = [
|
|
||||||
'origin',
|
|
||||||
'referer',
|
|
||||||
'sec-fetch-mode',
|
|
||||||
'sec-fetch-site',
|
|
||||||
'sec-fetch-dest',
|
|
||||||
'sec-ch-ua',
|
|
||||||
'sec-ch-ua-mobile',
|
|
||||||
'sec-ch-ua-platform',
|
|
||||||
'accept-language',
|
|
||||||
'accept-encoding',
|
|
||||||
'accept',
|
|
||||||
'cache-control',
|
|
||||||
'pragma',
|
|
||||||
'anthropic-dangerous-direct-browser-access' // 这个头可能触发CORS检查
|
|
||||||
]
|
|
||||||
|
|
||||||
// 应该保留的 headers(用于会话一致性和追踪)
|
|
||||||
const allowedHeaders = [
|
|
||||||
'x-request-id',
|
|
||||||
'anthropic-version', // 保留API版本
|
|
||||||
'anthropic-beta' // 保留beta功能
|
|
||||||
]
|
|
||||||
|
|
||||||
const filteredHeaders = {}
|
|
||||||
|
|
||||||
// 转发客户端的非敏感 headers
|
|
||||||
Object.keys(clientHeaders || {}).forEach((key) => {
|
|
||||||
const lowerKey = key.toLowerCase()
|
|
||||||
// 如果在允许列表中,直接保留
|
|
||||||
if (allowedHeaders.includes(lowerKey)) {
|
|
||||||
filteredHeaders[key] = clientHeaders[key]
|
|
||||||
}
|
|
||||||
// 如果不在敏感列表和浏览器列表中,也保留
|
|
||||||
else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
|
|
||||||
filteredHeaders[key] = clientHeaders[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return filteredHeaders
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_applyRequestIdentityTransform(body, headers, context = {}) {
|
_applyRequestIdentityTransform(body, headers, context = {}) {
|
||||||
@@ -2249,26 +2193,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 {
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const ProxyHelper = require('../utils/proxyHelper')
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const { filterForOpenAI } = require('../utils/headerFilter')
|
||||||
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
|
||||||
const apiKeyService = require('./apiKeyService')
|
const apiKeyService = require('./apiKeyService')
|
||||||
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
|
||||||
@@ -73,9 +74,9 @@ class OpenAIResponsesRelayService {
|
|||||||
const targetUrl = `${fullAccount.baseApi}${req.path}`
|
const targetUrl = `${fullAccount.baseApi}${req.path}`
|
||||||
logger.info(`🎯 Forwarding to: ${targetUrl}`)
|
logger.info(`🎯 Forwarding to: ${targetUrl}`)
|
||||||
|
|
||||||
// 构建请求头
|
// 构建请求头 - 使用统一的 headerFilter 移除 CDN headers
|
||||||
const headers = {
|
const headers = {
|
||||||
...this._filterRequestHeaders(req.headers),
|
...filterForOpenAI(req.headers),
|
||||||
Authorization: `Bearer ${fullAccount.apiKey}`,
|
Authorization: `Bearer ${fullAccount.apiKey}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
@@ -810,29 +811,10 @@ class OpenAIResponsesRelayService {
|
|||||||
return { resetsInSeconds, errorData }
|
return { resetsInSeconds, errorData }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤请求头
|
// 过滤请求头 - 已迁移到 headerFilter 工具类
|
||||||
|
// 此方法保留用于向后兼容,实际使用 filterForOpenAI()
|
||||||
_filterRequestHeaders(headers) {
|
_filterRequestHeaders(headers) {
|
||||||
const filtered = {}
|
return filterForOpenAI(headers)
|
||||||
const skipHeaders = [
|
|
||||||
'host',
|
|
||||||
'content-length',
|
|
||||||
'authorization',
|
|
||||||
'x-api-key',
|
|
||||||
'x-cr-api-key',
|
|
||||||
'connection',
|
|
||||||
'upgrade',
|
|
||||||
'sec-websocket-key',
|
|
||||||
'sec-websocket-version',
|
|
||||||
'sec-websocket-extensions'
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
|
||||||
if (!skipHeaders.includes(key.toLowerCase())) {
|
|
||||||
filtered[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 估算费用(简化版本,实际应该根据不同的定价模型)
|
// 估算费用(简化版本,实际应该根据不同的定价模型)
|
||||||
|
|||||||
133
src/utils/headerFilter.js
Normal file
133
src/utils/headerFilter.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* 统一的 CDN Headers 过滤列表
|
||||||
|
*
|
||||||
|
* 用于各服务在原有过滤逻辑基础上,额外移除 Cloudflare CDN 和代理相关的 headers
|
||||||
|
* 避免触发上游 API(如 88code)的安全检查
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Cloudflare CDN headers(橙色云代理模式会添加这些)
|
||||||
|
const cdnHeaders = [
|
||||||
|
'x-real-ip',
|
||||||
|
'x-forwarded-for',
|
||||||
|
'x-forwarded-proto',
|
||||||
|
'x-forwarded-host',
|
||||||
|
'x-forwarded-port',
|
||||||
|
'x-accel-buffering',
|
||||||
|
'cf-ray',
|
||||||
|
'cf-connecting-ip',
|
||||||
|
'cf-ipcountry',
|
||||||
|
'cf-visitor',
|
||||||
|
'cf-request-id',
|
||||||
|
'cdn-loop',
|
||||||
|
'true-client-ip'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为 OpenAI/Responses API 过滤 headers
|
||||||
|
* 在原有 skipHeaders 基础上添加 CDN headers
|
||||||
|
*/
|
||||||
|
function filterForOpenAI(headers) {
|
||||||
|
const skipHeaders = [
|
||||||
|
'host',
|
||||||
|
'content-length',
|
||||||
|
'authorization',
|
||||||
|
'x-api-key',
|
||||||
|
'x-cr-api-key',
|
||||||
|
'connection',
|
||||||
|
'upgrade',
|
||||||
|
'sec-websocket-key',
|
||||||
|
'sec-websocket-version',
|
||||||
|
'sec-websocket-extensions',
|
||||||
|
...cdnHeaders // 添加 CDN headers
|
||||||
|
]
|
||||||
|
|
||||||
|
const filtered = {}
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (!skipHeaders.includes(key.toLowerCase())) {
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为 Claude/Anthropic API 过滤 headers
|
||||||
|
* 在原有逻辑基础上添加 CDN headers 到敏感列表
|
||||||
|
*/
|
||||||
|
function filterForClaude(headers) {
|
||||||
|
const sensitiveHeaders = [
|
||||||
|
'content-type',
|
||||||
|
'user-agent',
|
||||||
|
'x-api-key',
|
||||||
|
'authorization',
|
||||||
|
'x-authorization',
|
||||||
|
'host',
|
||||||
|
'content-length',
|
||||||
|
'connection',
|
||||||
|
'proxy-authorization',
|
||||||
|
'content-encoding',
|
||||||
|
'transfer-encoding',
|
||||||
|
...cdnHeaders // 添加 CDN headers
|
||||||
|
]
|
||||||
|
|
||||||
|
const browserHeaders = [
|
||||||
|
'origin',
|
||||||
|
'referer',
|
||||||
|
'sec-fetch-mode',
|
||||||
|
'sec-fetch-site',
|
||||||
|
'sec-fetch-dest',
|
||||||
|
'sec-ch-ua',
|
||||||
|
'sec-ch-ua-mobile',
|
||||||
|
'sec-ch-ua-platform',
|
||||||
|
'accept-language',
|
||||||
|
'accept-encoding',
|
||||||
|
'accept',
|
||||||
|
'cache-control',
|
||||||
|
'pragma',
|
||||||
|
'anthropic-dangerous-direct-browser-access'
|
||||||
|
]
|
||||||
|
|
||||||
|
const allowedHeaders = ['x-request-id', 'anthropic-version', 'anthropic-beta']
|
||||||
|
|
||||||
|
const filtered = {}
|
||||||
|
Object.keys(headers || {}).forEach((key) => {
|
||||||
|
const lowerKey = key.toLowerCase()
|
||||||
|
if (allowedHeaders.includes(lowerKey)) {
|
||||||
|
filtered[key] = headers[key]
|
||||||
|
} else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
|
||||||
|
filtered[key] = headers[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为 Gemini API 过滤 headers(如果需要转发客户端 headers 时使用)
|
||||||
|
* 目前 Gemini 服务不转发客户端 headers,仅提供此方法备用
|
||||||
|
*/
|
||||||
|
function filterForGemini(headers) {
|
||||||
|
const skipHeaders = [
|
||||||
|
'host',
|
||||||
|
'content-length',
|
||||||
|
'authorization',
|
||||||
|
'x-api-key',
|
||||||
|
'connection',
|
||||||
|
...cdnHeaders // 添加 CDN headers
|
||||||
|
]
|
||||||
|
|
||||||
|
const filtered = {}
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (!skipHeaders.includes(key.toLowerCase())) {
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cdnHeaders,
|
||||||
|
filterForOpenAI,
|
||||||
|
filterForClaude,
|
||||||
|
filterForGemini
|
||||||
|
}
|
||||||
@@ -18,6 +18,13 @@ const OAUTH_CONFIG = {
|
|||||||
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cookie自动授权配置常量
|
||||||
|
const COOKIE_OAUTH_CONFIG = {
|
||||||
|
CLAUDE_AI_URL: 'https://claude.ai',
|
||||||
|
ORGANIZATIONS_URL: 'https://claude.ai/api/organizations',
|
||||||
|
AUTHORIZE_URL_TEMPLATE: 'https://claude.ai/v1/oauth/{organization_uuid}/authorize'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机的 state 参数
|
* 生成随机的 state 参数
|
||||||
* @returns {string} 随机生成的 state (base64url编码)
|
* @returns {string} 随机生成的 state (base64url编码)
|
||||||
@@ -570,8 +577,299 @@ function extractExtInfo(data) {
|
|||||||
return Object.keys(ext).length > 0 ? ext : null
|
return Object.keys(ext).length > 0 ? ext : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Cookie自动授权相关方法 (基于Clove项目实现)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建带Cookie的请求头
|
||||||
|
* @param {string} sessionKey - sessionKey值
|
||||||
|
* @returns {object} 请求头对象
|
||||||
|
*/
|
||||||
|
function buildCookieHeaders(sessionKey) {
|
||||||
|
return {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Cookie: `sessionKey=${sessionKey}`,
|
||||||
|
Origin: COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL,
|
||||||
|
Referer: `${COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL}/new`,
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用Cookie获取组织UUID和能力列表
|
||||||
|
* @param {string} sessionKey - sessionKey值
|
||||||
|
* @param {object|null} proxyConfig - 代理配置(可选)
|
||||||
|
* @returns {Promise<{organizationUuid: string, capabilities: string[]}>}
|
||||||
|
*/
|
||||||
|
async function getOrganizationInfo(sessionKey, proxyConfig = null) {
|
||||||
|
const headers = buildCookieHeaders(sessionKey)
|
||||||
|
const agent = createProxyAgent(proxyConfig)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (agent) {
|
||||||
|
logger.info(`🌐 Using proxy for organization info: ${ProxyHelper.maskProxyInfo(proxyConfig)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('🔄 Fetching organization info with Cookie', {
|
||||||
|
url: COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL,
|
||||||
|
hasProxy: !!proxyConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
headers,
|
||||||
|
timeout: 30000,
|
||||||
|
maxRedirects: 0 // 禁止自动重定向,以便检测Cloudflare拦截(302)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent) {
|
||||||
|
axiosConfig.httpAgent = agent
|
||||||
|
axiosConfig.httpsAgent = agent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL, axiosConfig)
|
||||||
|
|
||||||
|
if (!response.data || !Array.isArray(response.data)) {
|
||||||
|
throw new Error('获取组织信息失败:响应格式无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到具有chat能力且能力最多的组织
|
||||||
|
let bestOrg = null
|
||||||
|
let maxCapabilities = []
|
||||||
|
|
||||||
|
for (const org of response.data) {
|
||||||
|
const capabilities = org.capabilities || []
|
||||||
|
|
||||||
|
// 必须有chat能力
|
||||||
|
if (!capabilities.includes('chat')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择能力最多的组织
|
||||||
|
if (capabilities.length > maxCapabilities.length) {
|
||||||
|
bestOrg = org
|
||||||
|
maxCapabilities = capabilities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestOrg || !bestOrg.uuid) {
|
||||||
|
throw new Error('未找到具有chat能力的组织')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success('✅ Found organization', {
|
||||||
|
uuid: bestOrg.uuid,
|
||||||
|
capabilities: maxCapabilities
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationUuid: bestOrg.uuid,
|
||||||
|
capabilities: maxCapabilities
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response) {
|
||||||
|
const { status } = error.response
|
||||||
|
|
||||||
|
if (status === 403 || status === 401) {
|
||||||
|
throw new Error('Cookie授权失败:无效的sessionKey或已过期')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 302) {
|
||||||
|
throw new Error('请求被Cloudflare拦截,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`获取组织信息失败:HTTP ${status}`)
|
||||||
|
} else if (error.request) {
|
||||||
|
throw new Error('获取组织信息失败:网络错误或超时')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用Cookie自动获取授权code
|
||||||
|
* @param {string} sessionKey - sessionKey值
|
||||||
|
* @param {string} organizationUuid - 组织UUID
|
||||||
|
* @param {string} scope - 授权scope
|
||||||
|
* @param {object|null} proxyConfig - 代理配置(可选)
|
||||||
|
* @returns {Promise<{authorizationCode: string, codeVerifier: string, state: string}>}
|
||||||
|
*/
|
||||||
|
async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyConfig = null) {
|
||||||
|
// 生成PKCE参数
|
||||||
|
const codeVerifier = generateCodeVerifier()
|
||||||
|
const codeChallenge = generateCodeChallenge(codeVerifier)
|
||||||
|
const state = generateState()
|
||||||
|
|
||||||
|
// 构建授权URL
|
||||||
|
const authorizeUrl = COOKIE_OAUTH_CONFIG.AUTHORIZE_URL_TEMPLATE.replace(
|
||||||
|
'{organization_uuid}',
|
||||||
|
organizationUuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// 构建请求payload
|
||||||
|
const payload = {
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: OAUTH_CONFIG.CLIENT_ID,
|
||||||
|
organization_uuid: organizationUuid,
|
||||||
|
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
|
||||||
|
scope,
|
||||||
|
state,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...buildCookieHeaders(sessionKey),
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = createProxyAgent(proxyConfig)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (agent) {
|
||||||
|
logger.info(
|
||||||
|
`🌐 Using proxy for Cookie authorization: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('🔄 Requesting authorization with Cookie', {
|
||||||
|
url: authorizeUrl,
|
||||||
|
scope,
|
||||||
|
hasProxy: !!proxyConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
headers,
|
||||||
|
timeout: 30000,
|
||||||
|
maxRedirects: 0 // 禁止自动重定向,以便检测Cloudflare拦截(302)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent) {
|
||||||
|
axiosConfig.httpAgent = agent
|
||||||
|
axiosConfig.httpsAgent = agent
|
||||||
|
axiosConfig.proxy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(authorizeUrl, payload, axiosConfig)
|
||||||
|
|
||||||
|
// 从响应中获取redirect_uri
|
||||||
|
const redirectUri = response.data?.redirect_uri
|
||||||
|
|
||||||
|
if (!redirectUri) {
|
||||||
|
throw new Error('授权响应中未找到redirect_uri')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('📎 Got redirect URI', { redirectUri: `${redirectUri.substring(0, 80)}...` })
|
||||||
|
|
||||||
|
// 解析redirect_uri获取authorization code
|
||||||
|
const url = new URL(redirectUri)
|
||||||
|
const authorizationCode = url.searchParams.get('code')
|
||||||
|
const responseState = url.searchParams.get('state')
|
||||||
|
|
||||||
|
if (!authorizationCode) {
|
||||||
|
throw new Error('redirect_uri中未找到授权码')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建完整的授权码(包含state,如果有的话)
|
||||||
|
const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode
|
||||||
|
|
||||||
|
logger.success('✅ Got authorization code via Cookie', {
|
||||||
|
codeLength: authorizationCode.length,
|
||||||
|
codePrefix: `${authorizationCode.substring(0, 10)}...`
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
authorizationCode: fullCode,
|
||||||
|
codeVerifier,
|
||||||
|
state
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response) {
|
||||||
|
const { status } = error.response
|
||||||
|
|
||||||
|
if (status === 403 || status === 401) {
|
||||||
|
throw new Error('Cookie授权失败:无效的sessionKey或已过期')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 302) {
|
||||||
|
throw new Error('请求被Cloudflare拦截,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorData = error.response.data
|
||||||
|
let errorMessage = `HTTP ${status}`
|
||||||
|
|
||||||
|
if (errorData) {
|
||||||
|
if (typeof errorData === 'string') {
|
||||||
|
errorMessage += `: ${errorData}`
|
||||||
|
} else if (errorData.error) {
|
||||||
|
errorMessage += `: ${errorData.error}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`授权请求失败:${errorMessage}`)
|
||||||
|
} else if (error.request) {
|
||||||
|
throw new Error('授权请求失败:网络错误或超时')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整的Cookie自动授权流程
|
||||||
|
* @param {string} sessionKey - sessionKey值
|
||||||
|
* @param {object|null} proxyConfig - 代理配置(可选)
|
||||||
|
* @param {boolean} isSetupToken - 是否为Setup Token模式
|
||||||
|
* @returns {Promise<{claudeAiOauth: object, organizationUuid: string, capabilities: string[]}>}
|
||||||
|
*/
|
||||||
|
async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = false) {
|
||||||
|
logger.info('🍪 Starting Cookie-based OAuth flow', {
|
||||||
|
isSetupToken,
|
||||||
|
hasProxy: !!proxyConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
// 步骤1:获取组织信息
|
||||||
|
logger.debug('Step 1/3: Fetching organization info...')
|
||||||
|
const { organizationUuid, capabilities } = await getOrganizationInfo(sessionKey, proxyConfig)
|
||||||
|
|
||||||
|
// 步骤2:确定scope并获取授权code
|
||||||
|
const scope = isSetupToken ? OAUTH_CONFIG.SCOPES_SETUP : 'user:profile user:inference'
|
||||||
|
|
||||||
|
logger.debug('Step 2/3: Getting authorization code...', { scope })
|
||||||
|
const { authorizationCode, codeVerifier, state } = await authorizeWithCookie(
|
||||||
|
sessionKey,
|
||||||
|
organizationUuid,
|
||||||
|
scope,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
// 步骤3:交换token
|
||||||
|
logger.debug('Step 3/3: Exchanging token...')
|
||||||
|
const tokenData = isSetupToken
|
||||||
|
? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig)
|
||||||
|
: await exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig)
|
||||||
|
|
||||||
|
logger.success('✅ Cookie-based OAuth flow completed', {
|
||||||
|
isSetupToken,
|
||||||
|
organizationUuid,
|
||||||
|
hasAccessToken: !!tokenData.accessToken,
|
||||||
|
hasRefreshToken: !!tokenData.refreshToken
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
claudeAiOauth: tokenData,
|
||||||
|
organizationUuid,
|
||||||
|
capabilities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
OAUTH_CONFIG,
|
OAUTH_CONFIG,
|
||||||
|
COOKIE_OAUTH_CONFIG,
|
||||||
generateOAuthParams,
|
generateOAuthParams,
|
||||||
generateSetupTokenParams,
|
generateSetupTokenParams,
|
||||||
exchangeCodeForTokens,
|
exchangeCodeForTokens,
|
||||||
@@ -584,5 +882,10 @@ module.exports = {
|
|||||||
generateCodeChallenge,
|
generateCodeChallenge,
|
||||||
generateAuthUrl,
|
generateAuthUrl,
|
||||||
generateSetupTokenAuthUrl,
|
generateSetupTokenAuthUrl,
|
||||||
createProxyAgent
|
createProxyAgent,
|
||||||
|
// Cookie自动授权相关方法
|
||||||
|
buildCookieHeaders,
|
||||||
|
getOrganizationInfo,
|
||||||
|
authorizeWithCookie,
|
||||||
|
oauthWithCookie
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
17
web/admin-spa/package-lock.json
generated
17
web/admin-spa/package-lock.json
generated
@@ -1157,6 +1157,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
@@ -1351,6 +1352,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1587,6 +1589,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -3060,13 +3063,15 @@
|
|||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -3618,6 +3623,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3764,6 +3770,7 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -3789,7 +3796,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/prettier-plugin-tailwindcss": {
|
"node_modules/prettier-plugin-tailwindcss": {
|
||||||
"version": "0.6.14",
|
"version": "0.6.14",
|
||||||
"resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
|
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
|
||||||
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
|
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -4028,6 +4035,7 @@
|
|||||||
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4525,6 +4533,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4915,6 +4924,7 @@
|
|||||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -5115,6 +5125,7 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
||||||
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.18",
|
"@vue/compiler-dom": "3.5.18",
|
||||||
"@vue/compiler-sfc": "3.5.18",
|
"@vue/compiler-sfc": "3.5.18",
|
||||||
|
|||||||
@@ -1451,6 +1451,26 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 上游错误处理 -->
|
||||||
|
<div v-if="form.platform === 'claude-console'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>上游错误处理</label
|
||||||
|
>
|
||||||
|
<label class="inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.disableAutoProtection"
|
||||||
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
上游错误不自动暂停调度
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OpenAI-Responses 特定字段 -->
|
<!-- OpenAI-Responses 特定字段 -->
|
||||||
@@ -1524,24 +1544,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>
|
||||||
@@ -2021,6 +2049,7 @@
|
|||||||
<!-- 步骤2: OAuth授权 -->
|
<!-- 步骤2: OAuth授权 -->
|
||||||
<OAuthFlow
|
<OAuthFlow
|
||||||
v-if="oauthStep === 2 && form.addType === 'oauth'"
|
v-if="oauthStep === 2 && form.addType === 'oauth'"
|
||||||
|
ref="oauthFlowRef"
|
||||||
:platform="form.platform"
|
:platform="form.platform"
|
||||||
:proxy="form.proxy"
|
:proxy="form.proxy"
|
||||||
@back="oauthStep = 1"
|
@back="oauthStep = 1"
|
||||||
@@ -2044,11 +2073,45 @@
|
|||||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">
|
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">
|
||||||
Claude Setup Token 授权
|
Claude Setup Token 授权
|
||||||
</h4>
|
</h4>
|
||||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
|
||||||
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<!-- 授权方式选择 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="mb-3 text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||||
|
选择授权方式:
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="authMethod"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500"
|
||||||
|
type="radio"
|
||||||
|
value="manual"
|
||||||
|
@change="onAuthMethodChange"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
<i class="fas fa-link mr-1" />手动授权
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="authMethod"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500"
|
||||||
|
type="radio"
|
||||||
|
value="cookie"
|
||||||
|
@change="onAuthMethodChange"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
<i class="fas fa-cookie mr-1" />Cookie 自动授权
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 手动授权流程 -->
|
||||||
|
<div v-if="authMethod === 'manual'" class="space-y-4">
|
||||||
|
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
|
||||||
|
</p>
|
||||||
<!-- 步骤1: 生成授权链接 -->
|
<!-- 步骤1: 生成授权链接 -->
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
@@ -2174,6 +2237,113 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cookie自动授权流程 -->
|
||||||
|
<div v-if="authMethod === 'cookie'" class="space-y-4">
|
||||||
|
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
使用 sessionKey 自动完成授权,无需手动打开链接。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-cookie text-blue-500" />sessionKey
|
||||||
|
<span
|
||||||
|
v-if="parsedSessionKeyCount > 1"
|
||||||
|
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||||
|
>
|
||||||
|
{{ parsedSessionKeyCount }} 个
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="sessionKey"
|
||||||
|
class="form-input w-full resize-y border-gray-300 font-mono text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
:class="{ 'border-red-500': cookieAuthError }"
|
||||||
|
placeholder="每行一个 sessionKey,例如: sk-ant-sid01-xxxxx... sk-ant-sid01-yyyyy..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="parsedSessionKeyCount > 1"
|
||||||
|
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
将批量创建 {{ parsedSessionKeyCount }} 个账户
|
||||||
|
</p>
|
||||||
|
<p v-if="cookieAuthError" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ cookieAuthError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 帮助说明 -->
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="flex items-center text-xs text-blue-600 hover:text-blue-700"
|
||||||
|
type="button"
|
||||||
|
@click="showSessionKeyHelp = !showSessionKeyHelp"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
showSessionKeyHelp
|
||||||
|
? 'fas fa-chevron-down mr-1'
|
||||||
|
: 'fas fa-chevron-right mr-1'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
如何获取 sessionKey?
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showSessionKeyHelp"
|
||||||
|
class="mt-3 rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
<ol class="space-y-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<li>1. 在浏览器中登录 <strong>claude.ai</strong></li>
|
||||||
|
<li>2. 按 <strong>F12</strong> 打开开发者工具</li>
|
||||||
|
<li>3. 切换到 <strong>"Application"</strong> (应用) 标签页</li>
|
||||||
|
<li>
|
||||||
|
4. 在左侧选择 <strong>"Cookies"</strong> →
|
||||||
|
<strong>"https://claude.ai"</strong>
|
||||||
|
</li>
|
||||||
|
<li>5. 找到键为 <strong>"sessionKey"</strong> 的那一行</li>
|
||||||
|
<li>6. 复制其 <strong>"Value"</strong> (值) 列的内容</li>
|
||||||
|
</ol>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
sessionKey 通常以 "sk-ant-" 开头
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 开始授权按钮 -->
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full px-4 py-3"
|
||||||
|
:disabled="cookieAuthLoading || !sessionKey.trim()"
|
||||||
|
type="button"
|
||||||
|
@click="handleCookieAuth"
|
||||||
|
>
|
||||||
|
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
|
||||||
|
<i v-else class="fas fa-magic mr-2" />
|
||||||
|
<template v-if="cookieAuthLoading && batchProgress.total > 1">
|
||||||
|
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
|
||||||
|
</template>
|
||||||
|
<template v-else-if="cookieAuthLoading"> 授权中... </template>
|
||||||
|
<template v-else> 开始自动授权 </template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1" />
|
||||||
|
<strong>提示:</strong>如果您设置了代理,Cookie授权也会使用相同的代理配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2188,6 +2358,7 @@
|
|||||||
上一步
|
上一步
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="authMethod === 'manual'"
|
||||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||||
:disabled="!canExchangeSetupToken || setupTokenExchanging"
|
:disabled="!canExchangeSetupToken || setupTokenExchanging"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2919,6 +3090,26 @@
|
|||||||
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
|
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 上游错误处理(编辑模式)-->
|
||||||
|
<div v-if="form.platform === 'claude-console'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
上游错误处理
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.disableAutoProtection"
|
||||||
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
上游错误不自动暂停调度
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
||||||
@@ -3025,23 +3216,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>
|
||||||
@@ -3522,6 +3721,9 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
|
|||||||
const isEdit = computed(() => !!props.account)
|
const isEdit = computed(() => !!props.account)
|
||||||
const show = ref(true)
|
const show = ref(true)
|
||||||
|
|
||||||
|
// OAuthFlow 组件引用
|
||||||
|
const oauthFlowRef = ref(null)
|
||||||
|
|
||||||
// OAuth步骤
|
// OAuth步骤
|
||||||
const oauthStep = ref(1)
|
const oauthStep = ref(1)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -3535,6 +3737,22 @@ const setupTokenAuthCode = ref('')
|
|||||||
const setupTokenCopied = ref(false)
|
const setupTokenCopied = ref(false)
|
||||||
const setupTokenSessionId = ref('')
|
const setupTokenSessionId = ref('')
|
||||||
|
|
||||||
|
// Cookie自动授权相关状态
|
||||||
|
const authMethod = ref('manual') // 'manual' | 'cookie'
|
||||||
|
const sessionKey = ref('')
|
||||||
|
const cookieAuthLoading = ref(false)
|
||||||
|
const cookieAuthError = ref('')
|
||||||
|
const showSessionKeyHelp = ref(false)
|
||||||
|
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
|
||||||
|
|
||||||
|
// 解析后的 sessionKey 数量
|
||||||
|
const parsedSessionKeyCount = computed(() => {
|
||||||
|
return sessionKey.value
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0).length
|
||||||
|
})
|
||||||
|
|
||||||
// Claude Code 统一 User-Agent 信息
|
// Claude Code 统一 User-Agent 信息
|
||||||
const unifiedUserAgent = ref('')
|
const unifiedUserAgent = ref('')
|
||||||
const clearingCache = ref(false)
|
const clearingCache = ref(false)
|
||||||
@@ -3734,6 +3952,7 @@ const form = ref({
|
|||||||
})(),
|
})(),
|
||||||
userAgent: props.account?.userAgent || '',
|
userAgent: props.account?.userAgent || '',
|
||||||
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
|
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
|
||||||
|
disableAutoProtection: props.account?.disableAutoProtection === true,
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
dailyQuota: props.account?.dailyQuota || 0,
|
dailyQuota: props.account?.dailyQuota || 0,
|
||||||
dailyUsage: props.account?.dailyUsage || 0,
|
dailyUsage: props.account?.dailyUsage || 0,
|
||||||
@@ -4177,10 +4396,198 @@ const exchangeSetupTokenCode = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理OAuth成功
|
// =============================================================================
|
||||||
const handleOAuthSuccess = async (tokenInfo) => {
|
// Cookie自动授权相关方法
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Cookie自动授权(支持批量)
|
||||||
|
const handleCookieAuth = async () => {
|
||||||
|
// 解析多行输入
|
||||||
|
const sessionKeys = sessionKey.value
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
|
||||||
|
if (sessionKeys.length === 0) {
|
||||||
|
cookieAuthError.value = '请输入至少一个 sessionKey'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieAuthLoading.value = true
|
||||||
|
cookieAuthError.value = ''
|
||||||
|
batchProgress.value = { current: 0, total: sessionKeys.length }
|
||||||
|
|
||||||
|
const isSetupToken = form.value.addType === 'setup-token'
|
||||||
|
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
const errors = []
|
||||||
|
|
||||||
|
for (let i = 0; i < sessionKeys.length; i++) {
|
||||||
|
batchProgress.value.current = i + 1
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
sessionKey: sessionKeys[i],
|
||||||
|
...(proxyPayload && { proxy: proxyPayload })
|
||||||
|
}
|
||||||
|
|
||||||
|
let result
|
||||||
|
if (isSetupToken) {
|
||||||
|
result = await accountsStore.oauthSetupTokenWithCookie(payload)
|
||||||
|
} else {
|
||||||
|
result = await accountsStore.oauthWithCookie(payload)
|
||||||
|
}
|
||||||
|
results.push(result)
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
index: i + 1,
|
||||||
|
key: sessionKeys[i].substring(0, 20) + '...',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batchProgress.value = { current: 0, total: 0 }
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
try {
|
||||||
|
// 成功后处理OAuth数据(传递数组)
|
||||||
|
// cookieAuthLoading 保持 true,直到账号创建完成
|
||||||
|
await handleOAuthSuccess(results)
|
||||||
|
} finally {
|
||||||
|
cookieAuthLoading.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cookieAuthLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0 && results.length === 0) {
|
||||||
|
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
|
||||||
|
} else if (errors.length > 0) {
|
||||||
|
cookieAuthError.value = `${errors.length} 个授权失败`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置Cookie授权状态
|
||||||
|
const resetCookieAuth = () => {
|
||||||
|
sessionKey.value = ''
|
||||||
|
cookieAuthError.value = ''
|
||||||
|
showSessionKeyHelp.value = false
|
||||||
|
batchProgress.value = { current: 0, total: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换授权方式时重置状态
|
||||||
|
const onAuthMethodChange = () => {
|
||||||
|
// 切换到手动模式时清除Cookie相关状态
|
||||||
|
if (authMethod.value === 'manual') {
|
||||||
|
resetCookieAuth()
|
||||||
|
} else {
|
||||||
|
// 切换到Cookie模式时清除手动授权状态
|
||||||
|
setupTokenAuthUrl.value = ''
|
||||||
|
setupTokenAuthCode.value = ''
|
||||||
|
setupTokenSessionId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 Claude 账户数据(辅助函数)
|
||||||
|
const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
|
||||||
|
const proxyPayload = buildProxyPayload(form.value.proxy)
|
||||||
|
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: accountName,
|
||||||
|
description: form.value.description,
|
||||||
|
accountType: form.value.accountType,
|
||||||
|
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
|
||||||
|
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
|
||||||
|
expiresAt: form.value.expiresAt || undefined,
|
||||||
|
proxy: proxyPayload,
|
||||||
|
claudeAiOauth: claudeOauthPayload,
|
||||||
|
priority: form.value.priority || 50,
|
||||||
|
autoStopOnWarning: form.value.autoStopOnWarning || false,
|
||||||
|
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
|
||||||
|
useUnifiedClientId: form.value.useUnifiedClientId || false,
|
||||||
|
unifiedClientId: clientId,
|
||||||
|
subscriptionInfo: {
|
||||||
|
accountType: form.value.subscriptionType || 'claude_max',
|
||||||
|
hasClaudeMax: form.value.subscriptionType === 'claude_max',
|
||||||
|
hasClaudePro: form.value.subscriptionType === 'claude_pro',
|
||||||
|
manuallySet: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 extInfo
|
||||||
|
if (claudeOauthPayload) {
|
||||||
|
const extInfoPayload = {}
|
||||||
|
const extSource = claudeOauthPayload.extInfo
|
||||||
|
if (extSource?.org_uuid) extInfoPayload.org_uuid = extSource.org_uuid
|
||||||
|
if (extSource?.account_uuid) extInfoPayload.account_uuid = extSource.account_uuid
|
||||||
|
|
||||||
|
if (!extSource) {
|
||||||
|
if (claudeOauthPayload.organization?.uuid) {
|
||||||
|
extInfoPayload.org_uuid = claudeOauthPayload.organization.uuid
|
||||||
|
}
|
||||||
|
if (claudeOauthPayload.account?.uuid) {
|
||||||
|
extInfoPayload.account_uuid = claudeOauthPayload.account.uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(extInfoPayload).length > 0) {
|
||||||
|
data.extInfo = extInfoPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理OAuth成功(支持批量)
|
||||||
|
const handleOAuthSuccess = async (tokenInfoOrList) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
const currentPlatform = form.value.platform
|
||||||
|
|
||||||
|
// Claude 平台支持批量创建
|
||||||
|
if (currentPlatform === 'claude' && Array.isArray(tokenInfoOrList)) {
|
||||||
|
const tokenInfoList = tokenInfoOrList
|
||||||
|
const isBatch = tokenInfoList.length > 1
|
||||||
|
const baseName = form.value.name
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
const errors = []
|
||||||
|
|
||||||
|
for (let i = 0; i < tokenInfoList.length; i++) {
|
||||||
|
const tokenInfo = tokenInfoList[i]
|
||||||
|
// 批量时自动命名
|
||||||
|
const accountName = isBatch ? `${baseName}_${i + 1}` : baseName
|
||||||
|
// 如果启用统一客户端标识,为每个账户生成独立 ID
|
||||||
|
const clientId = form.value.useUnifiedClientId ? generateClientId() : ''
|
||||||
|
const data = buildClaudeAccountData(tokenInfo, accountName, clientId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await accountsStore.createClaudeAccount(data)
|
||||||
|
results.push(result)
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({ name: accountName, error: error.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理结果
|
||||||
|
if (results.length > 0) {
|
||||||
|
const msg = isBatch
|
||||||
|
? `成功创建 ${results.length}/${tokenInfoList.length} 个账户`
|
||||||
|
: '账户创建成功'
|
||||||
|
showToast(msg, 'success')
|
||||||
|
emit('success', results[0]) // 兼容单个创建的返回
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
showToast(`${errors.length} 个账户创建失败`, 'error')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个 tokenInfo 或其他平台的处理(保持原有逻辑)
|
||||||
|
const tokenInfo = Array.isArray(tokenInfoOrList) ? tokenInfoOrList[0] : tokenInfoOrList
|
||||||
|
|
||||||
// OAuth模式也需要确保生成客户端ID
|
// OAuth模式也需要确保生成客户端ID
|
||||||
if (
|
if (
|
||||||
form.value.platform === 'claude' &&
|
form.value.platform === 'claude' &&
|
||||||
@@ -4202,8 +4609,6 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
|||||||
proxy: proxyPayload
|
proxy: proxyPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPlatform = form.value.platform
|
|
||||||
|
|
||||||
if (currentPlatform === 'claude') {
|
if (currentPlatform === 'claude') {
|
||||||
// Claude使用claudeAiOauth字段
|
// Claude使用claudeAiOauth字段
|
||||||
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
|
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
|
||||||
@@ -4364,6 +4769,8 @@ const handleOAuthSuccess = async (tokenInfo) => {
|
|||||||
// 错误已通过 toast 显示给用户
|
// 错误已通过 toast 显示给用户
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
// 重置 OAuthFlow 组件的加载状态(如果是通过 OAuth 模式调用)
|
||||||
|
oauthFlowRef.value?.resetCookieAuth()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4485,6 +4892,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)
|
||||||
@@ -4641,6 +5056,10 @@ const createAccount = async () => {
|
|||||||
data.userAgent = form.value.userAgent || null
|
data.userAgent = form.value.userAgent || null
|
||||||
// 如果不启用限流,传递 0 表示不限流
|
// 如果不启用限流,传递 0 表示不限流
|
||||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||||
|
// 上游错误处理(仅 Claude Console)
|
||||||
|
if (form.value.platform === 'claude-console') {
|
||||||
|
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||||
|
}
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||||
@@ -4748,6 +5167,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 +5175,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' &&
|
||||||
@@ -4955,6 +5388,8 @@ const updateAccount = async () => {
|
|||||||
data.userAgent = form.value.userAgent || null
|
data.userAgent = form.value.userAgent || null
|
||||||
// 如果不启用限流,传递 0 表示不限流
|
// 如果不启用限流,传递 0 表示不限流
|
||||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||||
|
// 上游错误处理
|
||||||
|
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||||
@@ -5576,7 +6011,9 @@ watch(
|
|||||||
dailyUsage: newAccount.dailyUsage || 0,
|
dailyUsage: newAccount.dailyUsage || 0,
|
||||||
quotaResetTime: newAccount.quotaResetTime || '00:00',
|
quotaResetTime: newAccount.quotaResetTime || '00:00',
|
||||||
// 并发控制字段
|
// 并发控制字段
|
||||||
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0
|
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
|
||||||
|
// 上游错误处理
|
||||||
|
disableAutoProtection: newAccount.disableAutoProtection === true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是Claude Console账户,加载实时使用情况
|
// 如果是Claude Console账户,加载实时使用情况
|
||||||
|
|||||||
@@ -13,128 +13,269 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
|
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
|
||||||
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
|
||||||
请按照以下步骤完成 Claude 账户的授权:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<!-- 授权方式选择 -->
|
||||||
<!-- 步骤1: 生成授权链接 -->
|
<div class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||||
|
选择授权方式
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="authMethod"
|
||||||
|
class="text-blue-600 focus:ring-blue-500"
|
||||||
|
name="claude-auth-method"
|
||||||
|
type="radio"
|
||||||
|
value="manual"
|
||||||
|
@change="onAuthMethodChange"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-blue-900 dark:text-blue-200">手动授权</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="authMethod"
|
||||||
|
class="text-blue-600 focus:ring-blue-500"
|
||||||
|
name="claude-auth-method"
|
||||||
|
type="radio"
|
||||||
|
value="cookie"
|
||||||
|
@change="onAuthMethodChange"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-blue-900 dark:text-blue-200">Cookie自动授权</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cookie自动授权表单 -->
|
||||||
|
<div v-if="authMethod === 'cookie'" class="space-y-4">
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||||
<div
|
使用 claude.ai 的 sessionKey 自动完成 OAuth 授权流程,无需手动打开浏览器。
|
||||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
</p>
|
||||||
|
|
||||||
|
<!-- sessionKey输入 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
1
|
<i class="fas fa-cookie text-blue-500" />
|
||||||
</div>
|
sessionKey
|
||||||
<div class="flex-1">
|
<span
|
||||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
v-if="parsedSessionKeyCount > 1"
|
||||||
点击下方按钮生成授权链接
|
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
v-if="!authUrl"
|
|
||||||
class="btn btn-primary px-4 py-2 text-sm"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="generateAuthUrl"
|
|
||||||
>
|
>
|
||||||
<i v-if="!loading" class="fas fa-link mr-2" />
|
{{ parsedSessionKeyCount }} 个
|
||||||
<div v-else class="loading-spinner mr-2" />
|
</span>
|
||||||
{{ loading ? '生成中...' : '生成授权链接' }}
|
<button
|
||||||
|
class="text-blue-500 hover:text-blue-600"
|
||||||
|
type="button"
|
||||||
|
@click="showSessionKeyHelp = !showSessionKeyHelp"
|
||||||
|
>
|
||||||
|
<i class="fas fa-question-circle" />
|
||||||
</button>
|
</button>
|
||||||
<div v-else class="space-y-3">
|
</label>
|
||||||
<div class="flex items-center gap-2">
|
<textarea
|
||||||
<input
|
v-model="sessionKey"
|
||||||
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
class="form-input w-full resize-y font-mono text-sm"
|
||||||
readonly
|
placeholder="每行一个 sessionKey,例如: sk-ant-sid01-xxxxx... sk-ant-sid01-yyyyy..."
|
||||||
type="text"
|
rows="3"
|
||||||
:value="authUrl"
|
/>
|
||||||
/>
|
<p
|
||||||
|
v-if="parsedSessionKeyCount > 1"
|
||||||
|
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
将批量创建 {{ parsedSessionKeyCount }} 个账户
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 帮助说明 -->
|
||||||
|
<div
|
||||||
|
v-if="showSessionKeyHelp"
|
||||||
|
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
|
||||||
|
>
|
||||||
|
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
|
||||||
|
<i class="fas fa-lightbulb mr-1" />如何获取 sessionKey
|
||||||
|
</h5>
|
||||||
|
<ol
|
||||||
|
class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<li>在浏览器中登录 <strong>claude.ai</strong></li>
|
||||||
|
<li>
|
||||||
|
按
|
||||||
|
<kbd class="rounded bg-gray-200 px-1 dark:bg-gray-700">F12</kbd>
|
||||||
|
打开开发者工具
|
||||||
|
</li>
|
||||||
|
<li>切换到 <strong>Application</strong>(应用)标签页</li>
|
||||||
|
<li>
|
||||||
|
在左侧找到 <strong>Cookies</strong> → <strong>https://claude.ai</strong>
|
||||||
|
</li>
|
||||||
|
<li>找到键为 <strong>sessionKey</strong> 的那一行</li>
|
||||||
|
<li>复制其 <strong>Value</strong>(值)列的内容</li>
|
||||||
|
</ol>
|
||||||
|
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
sessionKey 通常以
|
||||||
|
<code class="rounded bg-gray-200 px-1 dark:bg-gray-700">sk-ant-sid01-</code>
|
||||||
|
开头
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误信息 -->
|
||||||
|
<div
|
||||||
|
v-if="cookieAuthError"
|
||||||
|
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
<i class="fas fa-exclamation-circle mr-1" />
|
||||||
|
{{ cookieAuthError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 授权按钮 -->
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full px-4 py-3 text-base font-semibold"
|
||||||
|
:disabled="cookieAuthLoading || !sessionKey.trim()"
|
||||||
|
type="button"
|
||||||
|
@click="handleCookieAuth"
|
||||||
|
>
|
||||||
|
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
|
||||||
|
<i v-else class="fas fa-magic mr-2" />
|
||||||
|
<template v-if="cookieAuthLoading && batchProgress.total > 1">
|
||||||
|
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
|
||||||
|
</template>
|
||||||
|
<template v-else-if="cookieAuthLoading"> 正在授权... </template>
|
||||||
|
<template v-else> 开始自动授权 </template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 手动授权流程 -->
|
||||||
|
<div v-else>
|
||||||
|
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
请按照以下步骤完成 Claude 账户的授权:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- 步骤1: 生成授权链接 -->
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||||
|
点击下方按钮生成授权链接
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="!authUrl"
|
||||||
|
class="btn btn-primary px-4 py-2 text-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="generateAuthUrl"
|
||||||
|
>
|
||||||
|
<i v-if="!loading" class="fas fa-link mr-2" />
|
||||||
|
<div v-else class="loading-spinner mr-2" />
|
||||||
|
{{ loading ? '生成中...' : '生成授权链接' }}
|
||||||
|
</button>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
|
||||||
|
readonly
|
||||||
|
type="text"
|
||||||
|
:value="authUrl"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||||
|
title="复制链接"
|
||||||
|
@click="copyAuthUrl"
|
||||||
|
>
|
||||||
|
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
class="text-xs text-blue-600 hover:text-blue-700"
|
||||||
title="复制链接"
|
@click="regenerateAuthUrl"
|
||||||
@click="copyAuthUrl"
|
|
||||||
>
|
>
|
||||||
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
|
<i class="fas fa-sync-alt mr-1" />重新生成
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
class="text-xs text-blue-600 hover:text-blue-700"
|
|
||||||
@click="regenerateAuthUrl"
|
|
||||||
>
|
|
||||||
<i class="fas fa-sync-alt mr-1" />重新生成
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 步骤2: 访问链接并授权 -->
|
<!-- 步骤2: 访问链接并授权 -->
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
|
||||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
|
||||||
>
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
|
||||||
在浏览器中打开链接并完成授权
|
|
||||||
</p>
|
|
||||||
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
|
|
||||||
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
|
||||||
</p>
|
|
||||||
<div
|
<div
|
||||||
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||||
>
|
>
|
||||||
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
2
|
||||||
<i class="fas fa-exclamation-triangle mr-1" />
|
</div>
|
||||||
<strong>注意:</strong
|
<div class="flex-1">
|
||||||
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||||
|
在浏览器中打开链接并完成授权
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
请在新标签页中打开授权链接,登录您的 Claude 账户并授权。
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-yellow-800 dark:text-yellow-300">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1" />
|
||||||
|
<strong>注意:</strong
|
||||||
|
>如果您设置了代理,请确保浏览器也使用相同的代理访问授权页面。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 步骤3: 输入授权码 -->
|
<!-- 步骤3: 输入授权码 -->
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
|
||||||
>
|
>
|
||||||
3
|
3
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
|
||||||
输入 Authorization Code
|
输入 Authorization Code
|
||||||
</p>
|
|
||||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
|
||||||
授权完成后,页面会显示一个
|
|
||||||
<strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
|
||||||
</p>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="authCode"
|
|
||||||
class="form-input w-full resize-none font-mono text-sm"
|
|
||||||
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<i class="fas fa-info-circle mr-1" />
|
|
||||||
请粘贴从Claude页面复制的Authorization Code
|
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
授权完成后,页面会显示一个
|
||||||
|
<strong>Authorization Code</strong>,请将其复制并粘贴到下方输入框:
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="authCode"
|
||||||
|
class="form-input w-full resize-none font-mono text-sm"
|
||||||
|
placeholder="粘贴从Claude页面获取的Authorization Code..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
请粘贴从Claude页面复制的Authorization Code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -636,7 +777,9 @@
|
|||||||
>
|
>
|
||||||
上一步
|
上一步
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Cookie自动授权模式不显示此按钮(Claude平台) -->
|
||||||
<button
|
<button
|
||||||
|
v-if="!(platform === 'claude' && authMethod === 'cookie')"
|
||||||
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
|
||||||
:disabled="!canExchange || exchanging"
|
:disabled="!canExchange || exchanging"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -682,6 +825,22 @@ const verificationUriComplete = ref('')
|
|||||||
const remainingSeconds = ref(0)
|
const remainingSeconds = ref(0)
|
||||||
let countdownTimer = null
|
let countdownTimer = null
|
||||||
|
|
||||||
|
// Cookie自动授权相关状态
|
||||||
|
const authMethod = ref('manual') // 'manual' | 'cookie'
|
||||||
|
const sessionKey = ref('')
|
||||||
|
const cookieAuthLoading = ref(false)
|
||||||
|
const cookieAuthError = ref('')
|
||||||
|
const showSessionKeyHelp = ref(false)
|
||||||
|
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
|
||||||
|
|
||||||
|
// 解析后的 sessionKey 数量
|
||||||
|
const parsedSessionKeyCount = computed(() => {
|
||||||
|
return sessionKey.value
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0).length
|
||||||
|
})
|
||||||
|
|
||||||
// 计算是否可以交换code
|
// 计算是否可以交换code
|
||||||
const canExchange = computed(() => {
|
const canExchange = computed(() => {
|
||||||
if (props.platform === 'droid') {
|
if (props.platform === 'droid') {
|
||||||
@@ -984,4 +1143,93 @@ const exchangeCode = async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopCountdown()
|
stopCountdown()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Cookie自动授权处理(支持批量)
|
||||||
|
const handleCookieAuth = async () => {
|
||||||
|
// 解析多行输入
|
||||||
|
const sessionKeys = sessionKey.value
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
|
||||||
|
if (sessionKeys.length === 0) {
|
||||||
|
cookieAuthError.value = '请输入至少一个 sessionKey'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieAuthLoading.value = true
|
||||||
|
cookieAuthError.value = ''
|
||||||
|
batchProgress.value = { current: 0, total: sessionKeys.length }
|
||||||
|
|
||||||
|
// 构建代理配置
|
||||||
|
const proxyConfig = props.proxy?.enabled
|
||||||
|
? {
|
||||||
|
type: props.proxy.type,
|
||||||
|
host: props.proxy.host,
|
||||||
|
port: parseInt(props.proxy.port),
|
||||||
|
username: props.proxy.username || null,
|
||||||
|
password: props.proxy.password || null
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
const errors = []
|
||||||
|
|
||||||
|
for (let i = 0; i < sessionKeys.length; i++) {
|
||||||
|
batchProgress.value.current = i + 1
|
||||||
|
try {
|
||||||
|
const result = await accountsStore.oauthWithCookie({
|
||||||
|
sessionKey: sessionKeys[i],
|
||||||
|
proxy: proxyConfig
|
||||||
|
})
|
||||||
|
results.push(result)
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
index: i + 1,
|
||||||
|
key: sessionKeys[i].substring(0, 20) + '...',
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batchProgress.value = { current: 0, total: 0 }
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
// emit 后父组件会调用 handleOAuthSuccess 创建账号
|
||||||
|
// cookieAuthLoading 保持 true,成功后表单会关闭,失败时父组件会处理
|
||||||
|
emit('success', results) // 返回数组(单个时也是数组)
|
||||||
|
// 注意:不在这里设置 cookieAuthLoading = false
|
||||||
|
// 父组件创建账号完成后表单会关闭/重置
|
||||||
|
} else {
|
||||||
|
// 全部授权失败时才恢复按钮状态
|
||||||
|
cookieAuthLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0 && results.length === 0) {
|
||||||
|
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
|
||||||
|
} else if (errors.length > 0) {
|
||||||
|
cookieAuthError.value = `${errors.length} 个授权失败`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置Cookie授权状态
|
||||||
|
const resetCookieAuth = () => {
|
||||||
|
sessionKey.value = ''
|
||||||
|
cookieAuthError.value = ''
|
||||||
|
cookieAuthLoading.value = false
|
||||||
|
batchProgress.value = { current: 0, total: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换授权方式时重置状态
|
||||||
|
const onAuthMethodChange = () => {
|
||||||
|
resetCookieAuth()
|
||||||
|
authUrl.value = ''
|
||||||
|
authCode.value = ''
|
||||||
|
sessionId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法供父组件调用
|
||||||
|
defineExpose({
|
||||||
|
resetCookieAuth
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -750,6 +750,39 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cookie自动授权 - 普通OAuth
|
||||||
|
const oauthWithCookie = async (payload) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/admin/claude-accounts/oauth-with-cookie', payload)
|
||||||
|
if (response.success) {
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || 'Cookie授权失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie自动授权 - Setup Token
|
||||||
|
const oauthSetupTokenWithCookie = async (payload) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
'/admin/claude-accounts/setup-token-with-cookie',
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
if (response.success) {
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || 'Cookie授权失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 生成Gemini OAuth URL
|
// 生成Gemini OAuth URL
|
||||||
const generateGeminiAuthUrl = async (proxyConfig) => {
|
const generateGeminiAuthUrl = async (proxyConfig) => {
|
||||||
try {
|
try {
|
||||||
@@ -914,6 +947,8 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
exchangeClaudeCode,
|
exchangeClaudeCode,
|
||||||
generateClaudeSetupTokenUrl,
|
generateClaudeSetupTokenUrl,
|
||||||
exchangeClaudeSetupTokenCode,
|
exchangeClaudeSetupTokenCode,
|
||||||
|
oauthWithCookie,
|
||||||
|
oauthSetupTokenWithCookie,
|
||||||
generateGeminiAuthUrl,
|
generateGeminiAuthUrl,
|
||||||
exchangeGeminiCode,
|
exchangeGeminiCode,
|
||||||
generateOpenAIAuthUrl,
|
generateOpenAIAuthUrl,
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-indigo-500 to-blue-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-indigo-500 to-blue-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||||
></div>
|
></div>
|
||||||
<CustomDropdown
|
<CustomDropdown
|
||||||
v-model="accountSortBy"
|
v-model="accountsSortBy"
|
||||||
icon="fa-sort-amount-down"
|
:icon="accountsSortOrder === 'asc' ? 'fa-sort-amount-up' : 'fa-sort-amount-down'"
|
||||||
icon-color="text-indigo-500"
|
icon-color="text-indigo-500"
|
||||||
:options="sortOptions"
|
:options="sortOptions"
|
||||||
placeholder="选择排序"
|
placeholder="选择排序"
|
||||||
@change="sortAccounts()"
|
@change="handleDropdownSort"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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'
|
||||||
@@ -1872,8 +1873,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
|
|||||||
// 数据状态
|
// 数据状态
|
||||||
const accounts = ref([])
|
const accounts = ref([])
|
||||||
const accountsLoading = ref(false)
|
const accountsLoading = ref(false)
|
||||||
const accountSortBy = ref('name')
|
const accountsSortBy = ref('name')
|
||||||
const accountsSortBy = ref('')
|
|
||||||
const accountsSortOrder = ref('asc')
|
const accountsSortOrder = ref('asc')
|
||||||
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
||||||
const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X 个API Key"
|
const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X 个API Key"
|
||||||
@@ -1929,6 +1929,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) // 轻量级绑定计数缓存
|
||||||
@@ -2730,7 +2734,10 @@ const loadClaudeUsage = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序账户
|
// 记录上一次的排序字段,用于判断下拉选择是否是同一字段被再次选择
|
||||||
|
let lastDropdownSortField = 'name'
|
||||||
|
|
||||||
|
// 排序账户(表头点击使用)
|
||||||
const sortAccounts = (field) => {
|
const sortAccounts = (field) => {
|
||||||
if (field) {
|
if (field) {
|
||||||
if (accountsSortBy.value === field) {
|
if (accountsSortBy.value === field) {
|
||||||
@@ -2739,9 +2746,23 @@ const sortAccounts = (field) => {
|
|||||||
accountsSortBy.value = field
|
accountsSortBy.value = field
|
||||||
accountsSortOrder.value = 'asc'
|
accountsSortOrder.value = 'asc'
|
||||||
}
|
}
|
||||||
|
// 同步下拉选择器的状态记录
|
||||||
|
lastDropdownSortField = field
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 下拉选择器排序处理(支持再次选择同一选项时切换排序方向)
|
||||||
|
const handleDropdownSort = (field) => {
|
||||||
|
if (field === lastDropdownSortField) {
|
||||||
|
// 选择同一字段,切换排序方向
|
||||||
|
accountsSortOrder.value = accountsSortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||||
|
} else {
|
||||||
|
// 选择不同字段,重置为升序
|
||||||
|
accountsSortOrder.value = 'asc'
|
||||||
|
}
|
||||||
|
lastDropdownSortField = field
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化数字(与原版保持一致)
|
// 格式化数字(与原版保持一致)
|
||||||
const formatNumber = (num) => {
|
const formatNumber = (num) => {
|
||||||
if (num === null || num === undefined) return '0'
|
if (num === null || num === undefined) return '0'
|
||||||
@@ -3988,20 +4009,20 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 监听排序选择变化
|
// 监听排序选择变化 - 已重构为 handleDropdownSort,此处注释保留原逻辑参考
|
||||||
watch(accountSortBy, (newVal) => {
|
// watch(accountSortBy, (newVal) => {
|
||||||
const fieldMap = {
|
// const fieldMap = {
|
||||||
name: 'name',
|
// name: 'name',
|
||||||
dailyTokens: 'dailyTokens',
|
// dailyTokens: 'dailyTokens',
|
||||||
dailyRequests: 'dailyRequests',
|
// dailyRequests: 'dailyRequests',
|
||||||
totalTokens: 'totalTokens',
|
// totalTokens: 'totalTokens',
|
||||||
lastUsed: 'lastUsed'
|
// lastUsed: 'lastUsed'
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
if (fieldMap[newVal]) {
|
// if (fieldMap[newVal]) {
|
||||||
sortAccounts(fieldMap[newVal])
|
// sortAccounts(fieldMap[newVal])
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
|
|
||||||
watch(currentPage, () => {
|
watch(currentPage, () => {
|
||||||
updateSelectAllState()
|
updateSelectAllState()
|
||||||
@@ -4009,6 +4030,10 @@ watch(currentPage, () => {
|
|||||||
|
|
||||||
watch(paginatedAccounts, () => {
|
watch(paginatedAccounts, () => {
|
||||||
updateSelectAllState()
|
updateSelectAllState()
|
||||||
|
// 数据变化后重新检测是否需要横向滚动
|
||||||
|
nextTick(() => {
|
||||||
|
checkHorizontalScroll()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(accounts, () => {
|
watch(accounts, () => {
|
||||||
@@ -4122,9 +4147,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 +4282,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 +4312,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 +4345,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 操作列左侧阴影 */
|
/* 操作列左侧阴影 */
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 名称列右侧阴影(分隔效果) */
|
/* 名称列右侧阴影(分隔效果) */
|
||||||
|
|||||||
Reference in New Issue
Block a user