mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge remote-tracking branch 'upstream/main' into feature/account-subscription-expiry-check
This commit is contained in:
@@ -411,11 +411,10 @@ export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
|||||||
**Gemini CLI 设置环境变量:**
|
**Gemini CLI 设置环境变量:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
|
GEMINI_MODEL="gemini-2.5-pro"
|
||||||
export GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" # 使用相同的API密钥即可
|
GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名
|
||||||
export GOOGLE_GENAI_USE_GCA="true"
|
GEMINI_API_KEY="后台创建的API密钥" # 使用相同的API密钥即可
|
||||||
```
|
```
|
||||||
|
|
||||||
**使用 Claude Code:**
|
**使用 Claude Code:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -65,6 +65,44 @@ const TOKEN_COUNT_PATHS = new Set([
|
|||||||
'/droid/claude/v1/messages/count_tokens'
|
'/droid/claude/v1/messages/count_tokens'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
function extractApiKey(req) {
|
||||||
|
const candidates = [
|
||||||
|
req.headers['x-api-key'],
|
||||||
|
req.headers['x-goog-api-key'],
|
||||||
|
req.headers['authorization'],
|
||||||
|
req.headers['api-key'],
|
||||||
|
req.query?.key
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
let value = candidate
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value = value.find((item) => typeof item === 'string' && item.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = value.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^Bearer\s+/i.test(trimmed)) {
|
||||||
|
trimmed = trimmed.replace(/^Bearer\s+/i, '').trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRequestPath(value) {
|
function normalizeRequestPath(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '/'
|
return '/'
|
||||||
@@ -95,18 +133,18 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 安全提取API Key,支持多种格式(包括Gemini CLI支持)
|
// 安全提取API Key,支持多种格式(包括Gemini CLI支持)
|
||||||
const apiKey =
|
const apiKey = extractApiKey(req)
|
||||||
req.headers['x-api-key'] ||
|
|
||||||
req.headers['x-goog-api-key'] ||
|
if (apiKey) {
|
||||||
req.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
|
req.headers['x-api-key'] = apiKey
|
||||||
req.headers['api-key'] ||
|
}
|
||||||
req.query.key
|
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`)
|
logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`)
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
error: 'Missing API key',
|
error: 'Missing API key',
|
||||||
message: 'Please provide an API key in the x-api-key header or Authorization header'
|
message:
|
||||||
|
'Please provide an API key in the x-api-key, x-goog-api-key, or Authorization header'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,6 +988,7 @@ const corsMiddleware = (req, res, next) => {
|
|||||||
'Accept',
|
'Accept',
|
||||||
'Authorization',
|
'Authorization',
|
||||||
'x-api-key',
|
'x-api-key',
|
||||||
|
'x-goog-api-key',
|
||||||
'api-key',
|
'api-key',
|
||||||
'x-admin-token',
|
'x-admin-token',
|
||||||
'anthropic-version',
|
'anthropic-version',
|
||||||
|
|||||||
@@ -7,12 +7,38 @@ const logger = require('../utils/logger')
|
|||||||
const browserFallbackMiddleware = (req, res, next) => {
|
const browserFallbackMiddleware = (req, res, next) => {
|
||||||
const userAgent = req.headers['user-agent'] || ''
|
const userAgent = req.headers['user-agent'] || ''
|
||||||
const origin = req.headers['origin'] || ''
|
const origin = req.headers['origin'] || ''
|
||||||
const authHeader = req.headers['authorization'] || req.headers['x-api-key'] || ''
|
|
||||||
|
const extractHeader = (value) => {
|
||||||
|
let candidate = value
|
||||||
|
|
||||||
|
if (Array.isArray(candidate)) {
|
||||||
|
candidate = candidate.find((item) => typeof item === 'string' && item.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof candidate !== 'string') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = candidate.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^Bearer\s+/i.test(trimmed)) {
|
||||||
|
trimmed = trimmed.replace(/^Bearer\s+/i, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyHeader =
|
||||||
|
extractHeader(req.headers['x-api-key']) || extractHeader(req.headers['x-goog-api-key'])
|
||||||
|
const normalizedKey = extractHeader(req.headers['authorization']) || apiKeyHeader
|
||||||
|
|
||||||
// 检查是否为Chrome插件或浏览器请求
|
// 检查是否为Chrome插件或浏览器请求
|
||||||
const isChromeExtension = origin.startsWith('chrome-extension://')
|
const isChromeExtension = origin.startsWith('chrome-extension://')
|
||||||
const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/')
|
const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/')
|
||||||
const hasApiKey = authHeader.startsWith('cr_') // 我们的API Key格式
|
const hasApiKey = normalizedKey.startsWith('cr_') // 我们的API Key格式
|
||||||
|
|
||||||
if ((isChromeExtension || isBrowserRequest) && hasApiKey) {
|
if ((isChromeExtension || isBrowserRequest) && hasApiKey) {
|
||||||
// 为Chrome插件请求添加特殊标记
|
// 为Chrome插件请求添加特殊标记
|
||||||
@@ -23,8 +49,8 @@ const browserFallbackMiddleware = (req, res, next) => {
|
|||||||
req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)'
|
req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)'
|
||||||
|
|
||||||
// 确保设置正确的认证头
|
// 确保设置正确的认证头
|
||||||
if (!req.headers['authorization'] && req.headers['x-api-key']) {
|
if (!req.headers['authorization'] && apiKeyHeader) {
|
||||||
req.headers['authorization'] = `Bearer ${req.headers['x-api-key']}`
|
req.headers['authorization'] = `Bearer ${apiKeyHeader}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加必要的Anthropic头
|
// 添加必要的Anthropic头
|
||||||
|
|||||||
@@ -8938,6 +8938,109 @@ router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (r
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取单个 Droid 账户详细信息
|
||||||
|
router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
// 获取账户基本信息
|
||||||
|
const account = await droidAccountService.getAccount(id)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Not Found',
|
||||||
|
message: 'Droid account not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取使用统计信息
|
||||||
|
let usageStats
|
||||||
|
try {
|
||||||
|
usageStats = await redis.getAccountUsageStats(account.id, 'droid')
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error)
|
||||||
|
usageStats = {
|
||||||
|
daily: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
total: { tokens: 0, requests: 0, allTokens: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分组信息
|
||||||
|
let groupInfos = []
|
||||||
|
try {
|
||||||
|
groupInfos = await accountGroupService.getAccountGroups(account.id)
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error)
|
||||||
|
groupInfos = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取绑定的 API Key 数量
|
||||||
|
const allApiKeys = await redis.getAllApiKeys()
|
||||||
|
const groupIds = groupInfos.map((group) => group.id)
|
||||||
|
const boundApiKeysCount = allApiKeys.reduce((count, key) => {
|
||||||
|
const binding = key.droidAccountId
|
||||||
|
if (!binding) {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
if (binding === account.id) {
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
if (binding.startsWith('group:')) {
|
||||||
|
const groupId = binding.substring('group:'.length)
|
||||||
|
if (groupIds.includes(groupId)) {
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
// 获取解密的 API Keys(用于管理界面)
|
||||||
|
let decryptedApiKeys = []
|
||||||
|
try {
|
||||||
|
decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id)
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error)
|
||||||
|
decryptedApiKeys = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回完整的账户信息,包含实际的 API Keys
|
||||||
|
const accountDetails = {
|
||||||
|
...account,
|
||||||
|
// 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt
|
||||||
|
expiresAt: account.subscriptionExpiresAt || null,
|
||||||
|
schedulable: account.schedulable === 'true',
|
||||||
|
boundApiKeysCount,
|
||||||
|
groupInfos,
|
||||||
|
// 包含实际的 API Keys(用于管理界面)
|
||||||
|
apiKeys: decryptedApiKeys.map((entry) => ({
|
||||||
|
key: entry.key,
|
||||||
|
id: entry.id,
|
||||||
|
usageCount: entry.usageCount || 0,
|
||||||
|
lastUsedAt: entry.lastUsedAt || null,
|
||||||
|
status: entry.status || 'active', // 使用实际的状态,默认为 active
|
||||||
|
errorMessage: entry.errorMessage || '', // 包含错误信息
|
||||||
|
createdAt: entry.createdAt || null
|
||||||
|
})),
|
||||||
|
usage: {
|
||||||
|
daily: usageStats.daily,
|
||||||
|
total: usageStats.total,
|
||||||
|
averages: usageStats.averages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: accountDetails
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get Droid account ${req.params.id}:`, error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get Droid account',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 删除 Droid 账户
|
// 删除 Droid 账户
|
||||||
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,13 +13,10 @@ const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
|||||||
|
|
||||||
// 生成会话哈希
|
// 生成会话哈希
|
||||||
function generateSessionHash(req) {
|
function generateSessionHash(req) {
|
||||||
const sessionData = [
|
const apiKeyPrefix =
|
||||||
req.headers['user-agent'],
|
req.headers['x-api-key']?.substring(0, 10) || req.headers['x-goog-api-key']?.substring(0, 10)
|
||||||
req.ip,
|
|
||||||
req.headers['x-api-key']?.substring(0, 10)
|
const sessionData = [req.headers['user-agent'], req.ip, apiKeyPrefix].filter(Boolean).join(':')
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(':')
|
|
||||||
|
|
||||||
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
return crypto.createHash('sha256').update(sessionData).digest('hex')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ const crypto = require('crypto')
|
|||||||
|
|
||||||
// 生成会话哈希
|
// 生成会话哈希
|
||||||
function generateSessionHash(req) {
|
function generateSessionHash(req) {
|
||||||
const sessionData = [
|
const authSource =
|
||||||
req.headers['user-agent'],
|
req.headers['authorization'] || req.headers['x-api-key'] || req.headers['x-goog-api-key']
|
||||||
req.ip,
|
|
||||||
req.headers['authorization']?.substring(0, 20)
|
const sessionData = [req.headers['user-agent'], req.ip, authSource?.substring(0, 20)]
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(':')
|
.join(':')
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,10 @@ class DroidAccountService {
|
|||||||
? []
|
? []
|
||||||
: normalizedExisting
|
: normalizedExisting
|
||||||
.filter((entry) => entry && entry.id && entry.encryptedKey)
|
.filter((entry) => entry && entry.id && entry.encryptedKey)
|
||||||
.map((entry) => ({ ...entry }))
|
.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
status: entry.status || 'active' // 确保有默认状态
|
||||||
|
}))
|
||||||
|
|
||||||
const hashSet = new Set(entries.map((entry) => entry.hash).filter(Boolean))
|
const hashSet = new Set(entries.map((entry) => entry.hash).filter(Boolean))
|
||||||
|
|
||||||
@@ -214,7 +217,9 @@ class DroidAccountService {
|
|||||||
encryptedKey: this._encryptSensitiveData(trimmed),
|
encryptedKey: this._encryptSensitiveData(trimmed),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastUsedAt: '',
|
lastUsedAt: '',
|
||||||
usageCount: '0'
|
usageCount: '0',
|
||||||
|
status: 'active', // 新增状态字段
|
||||||
|
errorMessage: '' // 新增错误信息字段
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +235,9 @@ class DroidAccountService {
|
|||||||
id: entry.id,
|
id: entry.id,
|
||||||
createdAt: entry.createdAt || '',
|
createdAt: entry.createdAt || '',
|
||||||
lastUsedAt: entry.lastUsedAt || '',
|
lastUsedAt: entry.lastUsedAt || '',
|
||||||
usageCount: entry.usageCount || '0'
|
usageCount: entry.usageCount || '0',
|
||||||
|
status: entry.status || 'active', // 新增状态字段
|
||||||
|
errorMessage: entry.errorMessage || '' // 新增错误信息字段
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +259,9 @@ class DroidAccountService {
|
|||||||
hash: entry.hash || '',
|
hash: entry.hash || '',
|
||||||
createdAt: entry.createdAt || '',
|
createdAt: entry.createdAt || '',
|
||||||
lastUsedAt: entry.lastUsedAt || '',
|
lastUsedAt: entry.lastUsedAt || '',
|
||||||
usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0
|
usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0,
|
||||||
|
status: entry.status || 'active', // 新增状态字段
|
||||||
|
errorMessage: entry.errorMessage || '' // 新增错误信息字段
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +357,56 @@ class DroidAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记指定的 Droid API Key 条目为异常状态
|
||||||
|
*/
|
||||||
|
async markApiKeyAsError(accountId, keyId, errorMessage = '') {
|
||||||
|
if (!accountId || !keyId) {
|
||||||
|
return { marked: false, error: '参数无效' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountData = await redis.getDroidAccount(accountId)
|
||||||
|
if (!accountData) {
|
||||||
|
return { marked: false, error: '账户不存在' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = this._parseApiKeyEntries(accountData.apiKeys)
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
return { marked: false, error: '无API Key条目' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let marked = false
|
||||||
|
const updatedEntries = entries.map((entry) => {
|
||||||
|
if (entry && entry.id === keyId) {
|
||||||
|
marked = true
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
status: 'error',
|
||||||
|
errorMessage: errorMessage || 'API Key异常'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!marked) {
|
||||||
|
return { marked: false, error: '未找到指定的API Key' }
|
||||||
|
}
|
||||||
|
|
||||||
|
accountData.apiKeys = JSON.stringify(updatedEntries)
|
||||||
|
await redis.setDroidAccount(accountId, accountData)
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 已标记 Droid API Key ${keyId} 为异常状态(Account: ${accountId}):${errorMessage}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return { marked: true }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 标记 Droid API Key 异常状态失败:${keyId}(Account: ${accountId})`, error)
|
||||||
|
return { marked: false, error: error.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 WorkOS Refresh Token 刷新并验证凭证
|
* 使用 WorkOS Refresh Token 刷新并验证凭证
|
||||||
*/
|
*/
|
||||||
@@ -994,7 +1053,7 @@ class DroidAccountService {
|
|||||||
? updates.apiKeyUpdateMode.trim().toLowerCase()
|
? updates.apiKeyUpdateMode.trim().toLowerCase()
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
let apiKeyUpdateMode = ['append', 'replace', 'delete'].includes(rawApiKeyMode)
|
let apiKeyUpdateMode = ['append', 'replace', 'delete', 'update'].includes(rawApiKeyMode)
|
||||||
? rawApiKeyMode
|
? rawApiKeyMode
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
@@ -1056,6 +1115,60 @@ class DroidAccountService {
|
|||||||
} else if (removeApiKeysInput.length > 0) {
|
} else if (removeApiKeysInput.length > 0) {
|
||||||
logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`)
|
logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`)
|
||||||
}
|
}
|
||||||
|
} else if (apiKeyUpdateMode === 'update') {
|
||||||
|
// 更新模式:根据提供的 key 匹配现有条目并更新状态
|
||||||
|
mergedApiKeys = [...existingApiKeyEntries]
|
||||||
|
const updatedHashes = new Set()
|
||||||
|
|
||||||
|
for (const updateItem of newApiKeysInput) {
|
||||||
|
if (!updateItem || typeof updateItem !== 'object') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = updateItem.key || updateItem.apiKey || ''
|
||||||
|
if (!key || typeof key !== 'string') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = key.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = crypto.createHash('sha256').update(trimmed).digest('hex')
|
||||||
|
updatedHashes.add(hash)
|
||||||
|
|
||||||
|
// 查找现有条目
|
||||||
|
const existingIndex = mergedApiKeys.findIndex((entry) => entry && entry.hash === hash)
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
// 更新现有条目的状态信息
|
||||||
|
const existingEntry = mergedApiKeys[existingIndex]
|
||||||
|
mergedApiKeys[existingIndex] = {
|
||||||
|
...existingEntry,
|
||||||
|
status: updateItem.status || existingEntry.status || 'active',
|
||||||
|
errorMessage:
|
||||||
|
updateItem.errorMessage !== undefined
|
||||||
|
? updateItem.errorMessage
|
||||||
|
: existingEntry.errorMessage || '',
|
||||||
|
lastUsedAt:
|
||||||
|
updateItem.lastUsedAt !== undefined
|
||||||
|
? updateItem.lastUsedAt
|
||||||
|
: existingEntry.lastUsedAt || '',
|
||||||
|
usageCount:
|
||||||
|
updateItem.usageCount !== undefined
|
||||||
|
? String(updateItem.usageCount)
|
||||||
|
: existingEntry.usageCount || '0'
|
||||||
|
}
|
||||||
|
apiKeysUpdated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKeysUpdated) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 更新模式未匹配任何 Droid API Key: ${accountId} (提供 ${updatedHashes.size} 个哈希)`
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys
|
const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys
|
||||||
const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length
|
const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length
|
||||||
@@ -1078,6 +1191,10 @@ class DroidAccountService {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}`
|
`🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}`
|
||||||
)
|
)
|
||||||
|
} else if (apiKeyUpdateMode === 'update') {
|
||||||
|
logger.info(
|
||||||
|
`🔑 更新模式更新 Droid API keys for ${accountId}: 更新了 ${newApiKeysInput.length} 个 API Key 的状态信息`
|
||||||
|
)
|
||||||
} else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) {
|
} else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}`
|
`🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}`
|
||||||
|
|||||||
@@ -121,12 +121,18 @@ class DroidRelayService {
|
|||||||
throw new Error(`Droid account ${account.id} 未配置任何 API Key`)
|
throw new Error(`Droid account ${account.id} 未配置任何 API Key`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 过滤掉异常状态的API Key
|
||||||
|
const activeEntries = entries.filter((entry) => entry.status !== 'error')
|
||||||
|
if (!activeEntries || activeEntries.length === 0) {
|
||||||
|
throw new Error(`Droid account ${account.id} 没有可用的 API Key(所有API Key均已异常)`)
|
||||||
|
}
|
||||||
|
|
||||||
const stickyKey = this._composeApiKeyStickyKey(account.id, endpointType, sessionHash)
|
const stickyKey = this._composeApiKeyStickyKey(account.id, endpointType, sessionHash)
|
||||||
|
|
||||||
if (stickyKey) {
|
if (stickyKey) {
|
||||||
const mappedKeyId = await redis.getSessionAccountMapping(stickyKey)
|
const mappedKeyId = await redis.getSessionAccountMapping(stickyKey)
|
||||||
if (mappedKeyId) {
|
if (mappedKeyId) {
|
||||||
const mappedEntry = entries.find((entry) => entry.id === mappedKeyId)
|
const mappedEntry = activeEntries.find((entry) => entry.id === mappedKeyId)
|
||||||
if (mappedEntry) {
|
if (mappedEntry) {
|
||||||
await redis.extendSessionAccountMappingTTL(stickyKey)
|
await redis.extendSessionAccountMappingTTL(stickyKey)
|
||||||
await droidAccountService.touchApiKeyUsage(account.id, mappedEntry.id)
|
await droidAccountService.touchApiKeyUsage(account.id, mappedEntry.id)
|
||||||
@@ -138,7 +144,7 @@ class DroidRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedEntry = entries[Math.floor(Math.random() * entries.length)]
|
const selectedEntry = activeEntries[Math.floor(Math.random() * activeEntries.length)]
|
||||||
if (!selectedEntry) {
|
if (!selectedEntry) {
|
||||||
throw new Error(`Droid account ${account.id} 没有可用的 API Key`)
|
throw new Error(`Droid account ${account.id} 没有可用的 API Key`)
|
||||||
}
|
}
|
||||||
@@ -150,7 +156,7 @@ class DroidRelayService {
|
|||||||
await droidAccountService.touchApiKeyUsage(account.id, selectedEntry.id)
|
await droidAccountService.touchApiKeyUsage(account.id, selectedEntry.id)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔐 随机选取 Droid API Key ${selectedEntry.id}(Account: ${account.id}, Keys: ${entries.length})`
|
`🔐 随机选取 Droid API Key ${selectedEntry.id}(Account: ${account.id}, Active Keys: ${activeEntries.length}/${entries.length})`
|
||||||
)
|
)
|
||||||
|
|
||||||
return selectedEntry
|
return selectedEntry
|
||||||
@@ -1144,39 +1150,50 @@ class DroidRelayService {
|
|||||||
|
|
||||||
if (authMethod === 'api_key') {
|
if (authMethod === 'api_key') {
|
||||||
if (selectedAccountApiKey?.id) {
|
if (selectedAccountApiKey?.id) {
|
||||||
let removalResult = null
|
let markResult = null
|
||||||
|
const errorMessage = `${statusCode}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
removalResult = await droidAccountService.removeApiKeyEntry(
|
// 标记API Key为异常状态而不是删除
|
||||||
|
markResult = await droidAccountService.markApiKeyAsError(
|
||||||
accountId,
|
accountId,
|
||||||
selectedAccountApiKey.id
|
selectedAccountApiKey.id,
|
||||||
|
errorMessage
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ 移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})失败:`,
|
`❌ 标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId})失败:`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash)
|
await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash)
|
||||||
|
|
||||||
if (removalResult?.removed) {
|
if (markResult?.marked) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`🚫 上游返回 ${statusCode},已移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})`
|
`⚠️ 上游返回 ${statusCode},已标记 Droid API Key ${selectedAccountApiKey.id} 为异常状态(Account: ${accountId})`
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ 上游返回 ${statusCode},但未能移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})`
|
`⚠️ 上游返回 ${statusCode},但未能标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId}):${markResult?.error || '未知错误'}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!removalResult || removalResult.remainingCount === 0) {
|
// 检查是否还有可用的API Key
|
||||||
await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key 已全部失效')
|
try {
|
||||||
|
const availableEntries = await droidAccountService.getDecryptedApiKeyEntries(accountId)
|
||||||
|
const activeEntries = availableEntries.filter((entry) => entry.status !== 'error')
|
||||||
|
|
||||||
|
if (activeEntries.length === 0) {
|
||||||
|
await this._stopDroidAccountScheduling(accountId, statusCode, '所有API Key均已异常')
|
||||||
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
|
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.info(`ℹ️ Droid 账号 ${accountId} 仍有 ${activeEntries.length} 个可用 API Key`)
|
||||||
`ℹ️ Droid 账号 ${accountId} 仍有 ${removalResult.remainingCount} 个 API Key 可用`
|
}
|
||||||
)
|
} catch (error) {
|
||||||
|
logger.error(`❌ 检查可用API Key失败(Account: ${accountId}):`, error)
|
||||||
|
await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key检查失败')
|
||||||
|
await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1817,7 +1817,8 @@
|
|||||||
<li>新会话将随机命中一个 Key,并在会话有效期内保持粘性。</li>
|
<li>新会话将随机命中一个 Key,并在会话有效期内保持粘性。</li>
|
||||||
<li>若某 Key 失效,会自动切换到剩余可用 Key,最大化成功率。</li>
|
<li>若某 Key 失效,会自动切换到剩余可用 Key,最大化成功率。</li>
|
||||||
<li>
|
<li>
|
||||||
若上游返回 4xx 错误码,该 Key 会被自动移除;全部 Key 清空后账号将暂停调度。
|
若上游返回 4xx 错误码,该 Key 会被自动标记为异常;全部 Key
|
||||||
|
异常后账号将暂停调度。
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -3011,10 +3012,18 @@
|
|||||||
>
|
>
|
||||||
<i class="fas fa-retweet text-sm text-white" />
|
<i class="fas fa-retweet text-sm text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex-1">
|
||||||
<h5 class="mb-2 font-semibold text-purple-900 dark:text-purple-200">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
更新 API Key
|
<h5 class="font-semibold text-purple-900 dark:text-purple-200">更新 API Key</h5>
|
||||||
</h5>
|
<button
|
||||||
|
class="flex items-center gap-1.5 rounded-lg bg-purple-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600"
|
||||||
|
type="button"
|
||||||
|
@click="showApiKeyManagement = true"
|
||||||
|
>
|
||||||
|
<i class="fas fa-list-ul" />
|
||||||
|
<span>管理 API Key</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p class="mb-1 text-sm text-purple-800 dark:text-purple-200">
|
<p class="mb-1 text-sm text-purple-800 dark:text-purple-200">
|
||||||
当前已保存 <strong>{{ existingApiKeyCount }}</strong> 条 API Key。您可以追加新的
|
当前已保存 <strong>{{ existingApiKeyCount }}</strong> 条 API Key。您可以追加新的
|
||||||
Key,或通过下方模式快速覆盖、删除指定 Key。
|
Key,或通过下方模式快速覆盖、删除指定 Key。
|
||||||
@@ -3187,6 +3196,15 @@
|
|||||||
@close="showGroupManagement = false"
|
@close="showGroupManagement = false"
|
||||||
@refresh="handleGroupRefresh"
|
@refresh="handleGroupRefresh"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- API Key 管理模态框 -->
|
||||||
|
<ApiKeyManagementModal
|
||||||
|
v-if="showApiKeyManagement"
|
||||||
|
:account-id="props.account?.id"
|
||||||
|
:account-name="props.account?.name"
|
||||||
|
@close="showApiKeyManagement = false"
|
||||||
|
@refresh="handleApiKeyRefresh"
|
||||||
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -3200,6 +3218,7 @@ import ProxyConfig from './ProxyConfig.vue'
|
|||||||
import OAuthFlow from './OAuthFlow.vue'
|
import OAuthFlow from './OAuthFlow.vue'
|
||||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
import GroupManagementModal from './GroupManagementModal.vue'
|
import GroupManagementModal from './GroupManagementModal.vue'
|
||||||
|
import ApiKeyManagementModal from './ApiKeyManagementModal.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
account: {
|
account: {
|
||||||
@@ -3239,6 +3258,9 @@ const clearingCache = ref(false)
|
|||||||
// 平台分组状态
|
// 平台分组状态
|
||||||
const platformGroup = ref('')
|
const platformGroup = ref('')
|
||||||
|
|
||||||
|
// API Key 管理模态框
|
||||||
|
const showApiKeyManagement = ref(false)
|
||||||
|
|
||||||
// 根据现有平台确定分组
|
// 根据现有平台确定分组
|
||||||
const determinePlatformGroup = (platform) => {
|
const determinePlatformGroup = (platform) => {
|
||||||
if (['claude', 'claude-console', 'ccr', 'bedrock'].includes(platform)) {
|
if (['claude', 'claude-console', 'ccr', 'bedrock'].includes(platform)) {
|
||||||
@@ -4816,6 +4838,18 @@ const handleGroupRefresh = async () => {
|
|||||||
await loadGroups()
|
await loadGroups()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 API Key 管理模态框刷新
|
||||||
|
const handleApiKeyRefresh = async () => {
|
||||||
|
// 刷新账户信息以更新 API Key 数量
|
||||||
|
if (props.account?.id) {
|
||||||
|
try {
|
||||||
|
await accountsStore.fetchAccounts()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh account data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 监听平台变化,重置表单
|
// 监听平台变化,重置表单
|
||||||
watch(
|
watch(
|
||||||
() => form.value.platform,
|
() => form.value.platform,
|
||||||
|
|||||||
437
web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue
Normal file
437
web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="show" class="modal fixed inset-0 z-[60] flex items-center justify-center p-3 sm:p-4">
|
||||||
|
<div
|
||||||
|
class="modal-content custom-scrollbar mx-auto max-h-[90vh] w-full max-w-4xl overflow-y-auto p-4 sm:p-6 md:p-8"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||||
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 sm:h-10 sm:w-10 sm:rounded-xl"
|
||||||
|
>
|
||||||
|
<i class="fas fa-key text-sm text-white sm:text-base" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
|
||||||
|
API Key 管理
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 sm:text-sm">
|
||||||
|
{{ accountName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="p-1 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-lg sm:text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="py-8 text-center">
|
||||||
|
<div class="loading-spinner-lg mx-auto mb-4" />
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key 列表 -->
|
||||||
|
<div
|
||||||
|
v-else-if="apiKeys.length === 0"
|
||||||
|
class="rounded-lg bg-gray-50 py-8 text-center dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-key mb-4 text-4xl text-gray-300 dark:text-gray-600" />
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">暂无 API Key</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- API Key 网格布局 -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="(apiKey, index) in paginatedApiKeys"
|
||||||
|
:key="index"
|
||||||
|
class="relative rounded-lg border border-gray-200 bg-white p-4 transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<!-- 左上角错误状态码角标 -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
(apiKey.status === 'error' || apiKey.status === 'disabled') && apiKey.errorMessage
|
||||||
|
"
|
||||||
|
class="absolute -left-2 -top-2 z-10"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center justify-center rounded-full px-2 py-0.5 text-[10px] font-semibold shadow-sm"
|
||||||
|
:class="[
|
||||||
|
apiKey.status === 'error'
|
||||||
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
|
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
|
]"
|
||||||
|
:title="`错误状态码: ${apiKey.errorMessage}`"
|
||||||
|
>
|
||||||
|
{{ apiKey.errorMessage }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<!-- API Key 信息 -->
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<span
|
||||||
|
class="flex-1 break-all font-mono text-xs font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
:title="apiKey.key"
|
||||||
|
>
|
||||||
|
{{ maskApiKey(apiKey.key) }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
class="text-xs text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
title="复制 API Key"
|
||||||
|
@click="copyApiKey(apiKey.key)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-copy" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="apiKey.status === 'error' || apiKey.status === 'disabled'"
|
||||||
|
class="text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:class="[
|
||||||
|
apiKey.status === 'error'
|
||||||
|
? 'text-orange-500 hover:text-orange-700 dark:text-orange-400 dark:hover:text-orange-300'
|
||||||
|
: 'text-yellow-500 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300'
|
||||||
|
]"
|
||||||
|
:disabled="resetting === getOriginalIndex(index)"
|
||||||
|
title="重置状态"
|
||||||
|
@click="resetApiKeyStatus(apiKey, getOriginalIndex(index))"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="resetting === getOriginalIndex(index)"
|
||||||
|
class="loading-spinner-sm"
|
||||||
|
/>
|
||||||
|
<i v-else class="fas fa-redo"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-xs text-red-500 transition-colors hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:text-red-600"
|
||||||
|
:disabled="deleting === getOriginalIndex(index)"
|
||||||
|
@click="deleteApiKey(apiKey, getOriginalIndex(index))"
|
||||||
|
>
|
||||||
|
<div v-if="deleting === getOriginalIndex(index)" class="loading-spinner-sm" />
|
||||||
|
<i v-else class="fas fa-trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计信息(一行显示) -->
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-3 text-xs text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
apiKey.status === 'active'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: apiKey.status === 'error'
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="mr-1"
|
||||||
|
:class="[
|
||||||
|
apiKey.status === 'active'
|
||||||
|
? 'fas fa-check-circle'
|
||||||
|
: apiKey.status === 'error'
|
||||||
|
? 'fas fa-exclamation-triangle'
|
||||||
|
: 'fas fa-exclamation-circle'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
apiKey.status === 'active'
|
||||||
|
? '正常'
|
||||||
|
: apiKey.status === 'error'
|
||||||
|
? '异常'
|
||||||
|
: apiKey.status === 'disabled'
|
||||||
|
? '禁用'
|
||||||
|
: apiKey.status || '未知'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
>使用: <strong>{{ apiKey.usageCount || 0 }}</strong
|
||||||
|
>次</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-if="apiKey.lastUsedAt">
|
||||||
|
<span>{{ formatTime(apiKey.lastUsedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页控制(底部) -->
|
||||||
|
<div v-if="totalPages > 1" class="mt-4 flex items-center justify-between">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
显示 {{ (currentPage - 1) * pageSize + 1 }}-{{
|
||||||
|
Math.min(currentPage * pageSize, totalItems)
|
||||||
|
}}
|
||||||
|
项,共 {{ totalItems }} 项
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
@click="currentPage = 1"
|
||||||
|
>
|
||||||
|
<i class="fas fa-angle-double-left" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
@click="currentPage--"
|
||||||
|
>
|
||||||
|
<i class="fas fa-angle-left" />
|
||||||
|
</button>
|
||||||
|
<span class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ currentPage }} / {{ totalPages }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
@click="currentPage++"
|
||||||
|
>
|
||||||
|
<i class="fas fa-angle-right" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
@click="currentPage = totalPages"
|
||||||
|
>
|
||||||
|
<i class="fas fa-angle-double-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
|
import { apiClient } from '@/config/api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
accountId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
accountName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'refresh'])
|
||||||
|
|
||||||
|
const show = ref(true)
|
||||||
|
const loading = ref(false)
|
||||||
|
const deleting = ref(null)
|
||||||
|
const resetting = ref(null)
|
||||||
|
const apiKeys = ref([])
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(18)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const totalItems = computed(() => apiKeys.value.length)
|
||||||
|
const totalPages = computed(() => Math.ceil(totalItems.value / pageSize.value))
|
||||||
|
const paginatedApiKeys = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
|
const end = start + pageSize.value
|
||||||
|
return apiKeys.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取原始索引的方法
|
||||||
|
const getOriginalIndex = (paginatedIndex) => {
|
||||||
|
return (currentPage.value - 1) * pageSize.value + paginatedIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载 API Keys
|
||||||
|
const loadApiKeys = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/admin/droid-accounts/${props.accountId}`)
|
||||||
|
const account = response.data
|
||||||
|
|
||||||
|
// 解析 apiKeys
|
||||||
|
let parsedKeys = []
|
||||||
|
if (Array.isArray(account.apiKeys)) {
|
||||||
|
parsedKeys = account.apiKeys
|
||||||
|
} else if (typeof account.apiKeys === 'string') {
|
||||||
|
try {
|
||||||
|
parsedKeys = JSON.parse(account.apiKeys)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse apiKeys:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为统一格式
|
||||||
|
const formattedKeys = parsedKeys.map((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
// 对于字符串类型的API Key,保持默认状态为active
|
||||||
|
return {
|
||||||
|
key: item,
|
||||||
|
usageCount: 0,
|
||||||
|
status: 'active',
|
||||||
|
lastUsedAt: null,
|
||||||
|
errorMessage: ''
|
||||||
|
}
|
||||||
|
} else if (typeof item === 'object' && item !== null) {
|
||||||
|
// 对于对象类型的API Key,保留所有状态信息
|
||||||
|
return {
|
||||||
|
key: item.key || item.apiKey || '',
|
||||||
|
usageCount: item.usageCount || item.count || 0,
|
||||||
|
status: item.status || 'active', // 保留后端返回的状态
|
||||||
|
lastUsedAt: item.lastUsedAt || item.lastUsed || null,
|
||||||
|
errorMessage: item.errorMessage || '' // 保留后端返回的错误信息
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 其他情况,默认为active状态
|
||||||
|
return {
|
||||||
|
key: String(item),
|
||||||
|
usageCount: 0,
|
||||||
|
status: 'active',
|
||||||
|
lastUsedAt: null,
|
||||||
|
errorMessage: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按最新使用时间排序(最近使用的在前,未使用的在后)
|
||||||
|
apiKeys.value = formattedKeys.sort((a, b) => {
|
||||||
|
// 如果都有 lastUsedAt,按时间降序排序
|
||||||
|
if (a.lastUsedAt && b.lastUsedAt) {
|
||||||
|
return new Date(b.lastUsedAt) - new Date(a.lastUsedAt)
|
||||||
|
}
|
||||||
|
// 如果 a 有时间,b 没有,a 排在前面
|
||||||
|
if (a.lastUsedAt && !b.lastUsedAt) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
// 如果 b 有时间,a 没有,b 排在前面
|
||||||
|
if (!a.lastUsedAt && b.lastUsedAt) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
// 如果都没有时间,按使用次数降序排序
|
||||||
|
return (b.usageCount || 0) - (a.usageCount || 0)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load API keys:', error)
|
||||||
|
showToast('加载 API Key 失败', 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
// 重置到第一页
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 API Key
|
||||||
|
const deleteApiKey = async (apiKey, index) => {
|
||||||
|
if (!confirm(`确定要删除 API Key "${maskApiKey(apiKey.key)}" 吗?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleting.value = index
|
||||||
|
try {
|
||||||
|
// 准备更新数据:删除指定的 key
|
||||||
|
const updateData = {
|
||||||
|
removeApiKeys: [apiKey.key],
|
||||||
|
apiKeyUpdateMode: 'delete'
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||||
|
|
||||||
|
showToast('API Key 已删除', 'success')
|
||||||
|
await loadApiKeys()
|
||||||
|
emit('refresh')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete API key:', error)
|
||||||
|
showToast(error.response?.data?.error || '删除 API Key 失败', 'error')
|
||||||
|
} finally {
|
||||||
|
deleting.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置 API Key 状态
|
||||||
|
const resetApiKeyStatus = async (apiKey, index) => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`确定要重置 API Key "${maskApiKey(apiKey.key)}" 的状态吗?这将清除错误信息并恢复为正常状态。`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetting.value = index
|
||||||
|
try {
|
||||||
|
// 准备更新数据:重置指定 key 的状态
|
||||||
|
const updateData = {
|
||||||
|
apiKeys: [
|
||||||
|
{
|
||||||
|
key: apiKey.key,
|
||||||
|
status: 'active',
|
||||||
|
errorMessage: ''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
apiKeyUpdateMode: 'update'
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||||
|
|
||||||
|
showToast('API Key 状态已重置', 'success')
|
||||||
|
await loadApiKeys()
|
||||||
|
emit('refresh')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset API key status:', error)
|
||||||
|
showToast(error.response?.data?.error || '重置 API Key 状态失败', 'error')
|
||||||
|
} finally {
|
||||||
|
resetting.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 掩码显示 API Key
|
||||||
|
const maskApiKey = (key) => {
|
||||||
|
if (!key || key.length < 12) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制 API Key
|
||||||
|
const copyApiKey = async (key) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(key)
|
||||||
|
showToast('API Key 已复制', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error)
|
||||||
|
showToast('复制失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
if (!timestamp) return '-'
|
||||||
|
try {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadApiKeys()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -3108,6 +3108,25 @@ const getDroidApiKeyCount = (account) => {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先使用 apiKeys 数组来计算正常状态的 API Keys
|
||||||
|
if (Array.isArray(account.apiKeys)) {
|
||||||
|
// 只计算状态不是 'error' 的 API Keys
|
||||||
|
return account.apiKeys.filter((apiKey) => apiKey.status !== 'error').length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是字符串格式的 apiKeys,尝试解析
|
||||||
|
if (typeof account.apiKeys === 'string' && account.apiKeys.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(account.apiKeys)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
// 只计算状态不是 'error' 的 API Keys
|
||||||
|
return parsed.filter((apiKey) => apiKey.status !== 'error').length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略解析错误,继续使用其他字段
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
account.apiKeyCount,
|
account.apiKeyCount,
|
||||||
account.api_key_count,
|
account.api_key_count,
|
||||||
@@ -3122,21 +3141,6 @@ const getDroidApiKeyCount = (account) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(account.apiKeys)) {
|
|
||||||
return account.apiKeys.length
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof account.apiKeys === 'string' && account.apiKeys.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(account.apiKeys)
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
return parsed.length
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 忽略解析错误,维持默认值
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -403,13 +403,13 @@
|
|||||||
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
||||||
>
|
>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
$env:CODE_ASSIST_ENDPOINT = "{{ geminiBaseUrl }}"
|
$env:GOOGLE_GEMINI_BASE_URL = "{{ geminiBaseUrl }}"
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
$env:GOOGLE_CLOUD_ACCESS_TOKEN = "你的API密钥"
|
$env:GEMINI_API_KEY = "你的API密钥"
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
$env:GOOGLE_GENAI_USE_GCA = "true"
|
$env:GEMINI_MODEL = "gemini-2.5-pro"
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-yellow-700 dark:text-yellow-400">
|
<p class="mt-2 text-xs text-yellow-700 dark:text-yellow-400">
|
||||||
@@ -431,16 +431,16 @@
|
|||||||
>
|
>
|
||||||
<div class="mb-2"># 设置用户级环境变量(永久生效)</div>
|
<div class="mb-2"># 设置用户级环境变量(永久生效)</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
[System.Environment]::SetEnvironmentVariable("CODE_ASSIST_ENDPOINT", "{{
|
[System.Environment]::SetEnvironmentVariable("GOOGLE_GEMINI_BASE_URL", "{{
|
||||||
geminiBaseUrl
|
geminiBaseUrl
|
||||||
}}", [System.EnvironmentVariableTarget]::User)
|
}}", [System.EnvironmentVariableTarget]::User)
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
[System.Environment]::SetEnvironmentVariable("GOOGLE_CLOUD_ACCESS_TOKEN",
|
[System.Environment]::SetEnvironmentVariable("GEMINI_API_KEY", "你的API密钥",
|
||||||
"你的API密钥", [System.EnvironmentVariableTarget]::User)
|
[System.EnvironmentVariableTarget]::User)
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
[System.Environment]::SetEnvironmentVariable("GOOGLE_GENAI_USE_GCA", "true",
|
[System.Environment]::SetEnvironmentVariable("GEMINI_MODEL", "gemini-2.5-pro",
|
||||||
[System.EnvironmentVariableTarget]::User)
|
[System.EnvironmentVariableTarget]::User)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -459,11 +459,9 @@
|
|||||||
<div
|
<div
|
||||||
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
||||||
>
|
>
|
||||||
<div class="whitespace-nowrap text-gray-300">echo $env:CODE_ASSIST_ENDPOINT</div>
|
<div class="whitespace-nowrap text-gray-300">echo $env:GOOGLE_GEMINI_BASE_URL</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">echo $env:GEMINI_API_KEY</div>
|
||||||
echo $env:GOOGLE_CLOUD_ACCESS_TOKEN
|
<div class="whitespace-nowrap text-gray-300">echo $env:GEMINI_MODEL</div>
|
||||||
</div>
|
|
||||||
<div class="whitespace-nowrap text-gray-300">echo $env:GOOGLE_GENAI_USE_GCA</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1047,13 +1045,13 @@
|
|||||||
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
||||||
>
|
>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"
|
export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"
|
export GEMINI_API_KEY="你的API密钥"
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
export GOOGLE_GENAI_USE_GCA="true"
|
export GEMINI_MODEL="gemini-2.5-pro"
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-yellow-700 dark:text-yellow-400">
|
<p class="mt-2 text-xs text-yellow-700 dark:text-yellow-400">
|
||||||
@@ -1075,13 +1073,13 @@
|
|||||||
>
|
>
|
||||||
<div class="mb-2"># 对于 zsh (默认)</div>
|
<div class="mb-2"># 对于 zsh (默认)</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.zshrc
|
echo 'export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"' >> ~/.zshrc
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.zshrc
|
echo 'export GEMINI_API_KEY="你的API密钥"' >> ~/.zshrc
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.zshrc
|
echo 'export GEMINI_MODEL="gemini-2.5-pro"' >> ~/.zshrc
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">source ~/.zshrc</div>
|
<div class="whitespace-nowrap text-gray-300">source ~/.zshrc</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1090,13 +1088,13 @@
|
|||||||
>
|
>
|
||||||
<div class="mb-2"># 对于 bash</div>
|
<div class="mb-2"># 对于 bash</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.bash_profile
|
echo 'export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"' >> ~/.bash_profile
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.bash_profile
|
echo 'export GEMINI_API_KEY="你的API密钥"' >> ~/.bash_profile
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.bash_profile
|
echo 'export GEMINI_MODEL="gemini-2.5-pro"' >> ~/.bash_profile
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">source ~/.bash_profile</div>
|
<div class="whitespace-nowrap text-gray-300">source ~/.bash_profile</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1112,9 +1110,9 @@
|
|||||||
<div
|
<div
|
||||||
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
||||||
>
|
>
|
||||||
<div class="whitespace-nowrap text-gray-300">echo $CODE_ASSIST_ENDPOINT</div>
|
<div class="whitespace-nowrap text-gray-300">echo $GOOGLE_GEMINI_BASE_URL</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">echo $GOOGLE_CLOUD_ACCESS_TOKEN</div>
|
<div class="whitespace-nowrap text-gray-300">echo $GEMINI_API_KEY</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">echo $GOOGLE_GENAI_USE_GCA</div>
|
<div class="whitespace-nowrap text-gray-300">echo $GEMINI_MODEL</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1660,13 +1658,13 @@
|
|||||||
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
class="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
||||||
>
|
>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"
|
export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"
|
export GEMINI_API_KEY="你的API密钥"
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
export GOOGLE_GENAI_USE_GCA="true"
|
export GEMINI_MODEL="gemini-2.5-pro"
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-yellow-700 dark:text-yellow-400">
|
<p class="mt-2 text-xs text-yellow-700 dark:text-yellow-400">
|
||||||
@@ -1688,13 +1686,13 @@
|
|||||||
>
|
>
|
||||||
<div class="mb-2"># 对于 bash (默认)</div>
|
<div class="mb-2"># 对于 bash (默认)</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.bashrc
|
echo 'export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"' >> ~/.bashrc
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.bashrc
|
echo 'export GEMINI_API_KEY="你的API密钥"' >> ~/.bashrc
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.bashrc
|
echo 'export GEMINI_MODEL="gemini-2.5-pro"' >> ~/.bashrc
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">source ~/.bashrc</div>
|
<div class="whitespace-nowrap text-gray-300">source ~/.bashrc</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1703,13 +1701,13 @@
|
|||||||
>
|
>
|
||||||
<div class="mb-2"># 对于 zsh</div>
|
<div class="mb-2"># 对于 zsh</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.zshrc
|
echo 'export GOOGLE_GEMINI_BASE_URL="{{ geminiBaseUrl }}"' >> ~/.zshrc
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.zshrc
|
echo 'export GEMINI_API_KEY="你的API密钥"' >> ~/.zshrc
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">
|
<div class="whitespace-nowrap text-gray-300">
|
||||||
echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.zshrc
|
echo 'export GEMINI_MODEL="gemini-2.5-pro"' >> ~/.zshrc
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">source ~/.zshrc</div>
|
<div class="whitespace-nowrap text-gray-300">source ~/.zshrc</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1725,9 +1723,9 @@
|
|||||||
<div
|
<div
|
||||||
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
class="space-y-1 overflow-x-auto rounded bg-gray-900 p-2 font-mono text-xs text-green-400 sm:p-3 sm:text-sm"
|
||||||
>
|
>
|
||||||
<div class="whitespace-nowrap text-gray-300">echo $CODE_ASSIST_ENDPOINT</div>
|
<div class="whitespace-nowrap text-gray-300">echo $GOOGLE_GEMINI_BASE_URL</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">echo $GOOGLE_CLOUD_ACCESS_TOKEN</div>
|
<div class="whitespace-nowrap text-gray-300">echo $GEMINI_API_KEY</div>
|
||||||
<div class="whitespace-nowrap text-gray-300">echo $GOOGLE_GENAI_USE_GCA</div>
|
<div class="whitespace-nowrap text-gray-300">echo $GEMINI_MODEL</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user