mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
fix: 合并冲突 - 保留多选支持并添加暗黑模式样式
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,6 +26,7 @@ redis_data/
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
logs1/
|
||||
*.log
|
||||
startup.log
|
||||
app.log
|
||||
|
||||
80
package-lock.json
generated
80
package-lock.json
generated
@@ -44,6 +44,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -7605,6 +7606,85 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-tailwindcss": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
|
||||
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "*",
|
||||
"@prettier/plugin-hermes": "*",
|
||||
"@prettier/plugin-oxc": "*",
|
||||
"@prettier/plugin-pug": "*",
|
||||
"@shopify/prettier-plugin-liquid": "*",
|
||||
"@trivago/prettier-plugin-sort-imports": "*",
|
||||
"@zackad/prettier-plugin-twig": "*",
|
||||
"prettier": "^3.0",
|
||||
"prettier-plugin-astro": "*",
|
||||
"prettier-plugin-css-order": "*",
|
||||
"prettier-plugin-jsdoc": "*",
|
||||
"prettier-plugin-marko": "*",
|
||||
"prettier-plugin-multiline-arrays": "*",
|
||||
"prettier-plugin-organize-attributes": "*",
|
||||
"prettier-plugin-organize-imports": "*",
|
||||
"prettier-plugin-sort-imports": "*",
|
||||
"prettier-plugin-svelte": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@ianvs/prettier-plugin-sort-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"@prettier/plugin-hermes": {
|
||||
"optional": true
|
||||
},
|
||||
"@prettier/plugin-oxc": {
|
||||
"optional": true
|
||||
},
|
||||
"@prettier/plugin-pug": {
|
||||
"optional": true
|
||||
},
|
||||
"@shopify/prettier-plugin-liquid": {
|
||||
"optional": true
|
||||
},
|
||||
"@trivago/prettier-plugin-sort-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"@zackad/prettier-plugin-twig": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-astro": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-css-order": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-jsdoc": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-marko": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-multiline-arrays": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-organize-attributes": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-organize-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-sort-imports": {
|
||||
"optional": true
|
||||
},
|
||||
"prettier-plugin-svelte": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
108
scripts/test-official-models.js
Normal file
108
scripts/test-official-models.js
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 官方模型版本识别测试 - 最终版 v2
|
||||
*/
|
||||
|
||||
const { isOpus45OrNewer } = require('../src/utils/modelHelper')
|
||||
|
||||
// 官方模型
|
||||
const officialModels = [
|
||||
{ name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false },
|
||||
{ name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false },
|
||||
{ name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false },
|
||||
{ name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true }
|
||||
]
|
||||
|
||||
// 非 Opus 模型
|
||||
const nonOpusModels = [
|
||||
{ name: 'claude-sonnet-4-20250514', desc: 'Sonnet 4' },
|
||||
{ name: 'claude-sonnet-4-5-20250929', desc: 'Sonnet 4.5' },
|
||||
{ name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' },
|
||||
{ name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' },
|
||||
{ name: 'claude-3-haiku-20240307', desc: 'Haiku 3' },
|
||||
{ name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' }
|
||||
]
|
||||
|
||||
// 其他格式测试
|
||||
const otherFormats = [
|
||||
{ name: 'claude-opus-4.5', expected: true, desc: 'Opus 4.5 点分隔' },
|
||||
{ name: 'claude-opus-4-5', expected: true, desc: 'Opus 4.5 横线分隔' },
|
||||
{ name: 'opus-4.5', expected: true, desc: 'Opus 4.5 无前缀' },
|
||||
{ name: 'opus-4-5', expected: true, desc: 'Opus 4-5 无前缀' },
|
||||
{ name: 'opus-latest', expected: true, desc: 'Opus latest' },
|
||||
{ name: 'claude-opus-5', expected: true, desc: 'Opus 5 (未来)' },
|
||||
{ name: 'claude-opus-5-0', expected: true, desc: 'Opus 5.0 (未来)' },
|
||||
{ name: 'opus-4.0', expected: false, desc: 'Opus 4.0' },
|
||||
{ name: 'opus-4.1', expected: false, desc: 'Opus 4.1' },
|
||||
{ name: 'opus-4.4', expected: false, desc: 'Opus 4.4' },
|
||||
{ name: 'opus-4', expected: false, desc: 'Opus 4' },
|
||||
{ name: 'opus-4-0', expected: false, desc: 'Opus 4-0' },
|
||||
{ name: 'opus-4-1', expected: false, desc: 'Opus 4-1' },
|
||||
{ name: 'opus-4-4', expected: false, desc: 'Opus 4-4' },
|
||||
{ name: 'opus', expected: false, desc: '仅 opus' },
|
||||
{ name: null, expected: false, desc: 'null' },
|
||||
{ name: '', expected: false, desc: '空字符串' }
|
||||
]
|
||||
|
||||
console.log('='.repeat(90))
|
||||
console.log('官方模型版本识别测试 - 最终版 v2')
|
||||
console.log('='.repeat(90))
|
||||
console.log()
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
// 测试官方 Opus 模型
|
||||
console.log('📌 官方 Opus 模型:')
|
||||
for (const m of officialModels) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL'
|
||||
if (result === m.expectPro) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌'
|
||||
console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`)
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('📌 非 Opus 模型 (不受此函数影响):')
|
||||
for (const m of nonOpusModels) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
console.log(
|
||||
` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}`
|
||||
)
|
||||
if (result) {
|
||||
failed++ // 非 Opus 模型不应返回 true
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('📌 其他格式测试:')
|
||||
for (const m of otherFormats) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
const status = result === m.expected ? '✅ PASS' : '❌ FAIL'
|
||||
if (result === m.expected) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name
|
||||
console.log(
|
||||
` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('='.repeat(90))
|
||||
console.log('测试结果:', passed, '通过,', failed, '失败')
|
||||
console.log('='.repeat(90))
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n❌ 有测试失败,请检查函数逻辑')
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log('\n✅ 所有测试通过!函数可以安全使用')
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -16,6 +16,65 @@ const pricingService = require('../../services/pricingService')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
const accountTypeNames = {
|
||||
claude: 'Claude官方',
|
||||
'claude-console': 'Claude Console',
|
||||
ccr: 'Claude Console Relay',
|
||||
openai: 'OpenAI',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
droid: 'Droid',
|
||||
unknown: '未知渠道'
|
||||
}
|
||||
|
||||
const resolveAccountByPlatform = async (accountId, platform) => {
|
||||
const serviceMap = {
|
||||
claude: claudeAccountService,
|
||||
'claude-console': claudeConsoleAccountService,
|
||||
gemini: geminiAccountService,
|
||||
'gemini-api': geminiApiAccountService,
|
||||
openai: openaiAccountService,
|
||||
'openai-responses': openaiResponsesAccountService,
|
||||
droid: droidAccountService,
|
||||
ccr: ccrAccountService
|
||||
}
|
||||
|
||||
if (platform && serviceMap[platform]) {
|
||||
try {
|
||||
const account = await serviceMap[platform].getAccount(accountId)
|
||||
if (account) {
|
||||
return { ...account, platform }
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [platformName, service] of Object.entries(serviceMap)) {
|
||||
try {
|
||||
const account = await service.getAccount(accountId)
|
||||
if (account) {
|
||||
return { ...account, platform: platformName }
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getApiKeyName = async (keyId) => {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
return keyData?.name || keyData?.label || keyId
|
||||
} catch (error) {
|
||||
logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`)
|
||||
return keyId
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 账户使用统计
|
||||
|
||||
// 获取所有账户的使用统计
|
||||
@@ -1861,18 +1920,6 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
|
||||
|
||||
const rawRecords = await redis.getUsageRecords(keyId, 5000)
|
||||
|
||||
const accountTypeNames = {
|
||||
claude: 'Claude官方',
|
||||
'claude-console': 'Claude Console',
|
||||
ccr: 'Claude Console Relay',
|
||||
openai: 'OpenAI',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
droid: 'Droid',
|
||||
unknown: '未知渠道'
|
||||
}
|
||||
|
||||
const accountServices = [
|
||||
{ type: 'claude', getter: (id) => claudeAccountService.getAccount(id) },
|
||||
{ type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) },
|
||||
@@ -1895,10 +1942,15 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
|
||||
return accountCache.get(cacheKey)
|
||||
}
|
||||
|
||||
const servicesToTry = type
|
||||
let servicesToTry = type
|
||||
? accountServices.filter((svc) => svc.type === type)
|
||||
: accountServices
|
||||
|
||||
// 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号
|
||||
if (!servicesToTry.length) {
|
||||
servicesToTry = accountServices
|
||||
}
|
||||
|
||||
for (const service of servicesToTry) {
|
||||
try {
|
||||
const account = await service.getter(id)
|
||||
@@ -2008,12 +2060,14 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
|
||||
}
|
||||
|
||||
if (record.accountId) {
|
||||
const key = `${record.accountId}:${record.accountType || 'unknown'}`
|
||||
if (!accountOptionMap.has(key)) {
|
||||
accountOptionMap.set(key, {
|
||||
const normalizedType = record.accountType || 'unknown'
|
||||
if (!accountOptionMap.has(record.accountId)) {
|
||||
accountOptionMap.set(record.accountId, {
|
||||
id: record.accountId,
|
||||
accountType: record.accountType || 'unknown'
|
||||
accountTypes: new Set([normalizedType])
|
||||
})
|
||||
} else {
|
||||
accountOptionMap.get(record.accountId).accountTypes.add(normalizedType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2087,13 +2141,33 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
|
||||
|
||||
const accountOptions = []
|
||||
for (const option of accountOptionMap.values()) {
|
||||
const info = await resolveAccountInfo(option.id, option.accountType)
|
||||
const resolvedType = info?.type || option.accountType || 'unknown'
|
||||
const types = Array.from(option.accountTypes || [])
|
||||
|
||||
// 优先按历史出现的 accountType 解析,若失败则回退全量解析
|
||||
let resolvedInfo = null
|
||||
for (const type of types) {
|
||||
resolvedInfo = await resolveAccountInfo(option.id, type)
|
||||
if (resolvedInfo && resolvedInfo.name) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!resolvedInfo) {
|
||||
resolvedInfo = await resolveAccountInfo(option.id)
|
||||
}
|
||||
|
||||
const chosenType = resolvedInfo?.type || types[0] || 'unknown'
|
||||
const chosenTypeName = accountTypeNames[chosenType] || '未知渠道'
|
||||
|
||||
if (!resolvedInfo) {
|
||||
logger.warn(`⚠️ 保留无法解析的账户筛选项: ${option.id}, types=${types.join(',') || 'none'}`)
|
||||
}
|
||||
|
||||
accountOptions.push({
|
||||
id: option.id,
|
||||
name: info?.name || option.id,
|
||||
accountType: resolvedType,
|
||||
accountTypeName: accountTypeNames[resolvedType] || '未知渠道'
|
||||
name: resolvedInfo?.name || option.id,
|
||||
accountType: chosenType,
|
||||
accountTypeName: chosenTypeName,
|
||||
rawTypes: types
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2146,4 +2220,287 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取账户的请求记录时间线
|
||||
router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params
|
||||
const {
|
||||
platform,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
startDate,
|
||||
endDate,
|
||||
model,
|
||||
apiKeyId,
|
||||
sortOrder = 'desc'
|
||||
} = req.query
|
||||
|
||||
const pageNumber = Math.max(parseInt(page, 10) || 1, 1)
|
||||
const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200)
|
||||
const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc'
|
||||
|
||||
const startTime = startDate ? new Date(startDate) : null
|
||||
const endTime = endDate ? new Date(endDate) : null
|
||||
|
||||
if (
|
||||
(startDate && Number.isNaN(startTime?.getTime())) ||
|
||||
(endDate && Number.isNaN(endTime?.getTime()))
|
||||
) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid date range' })
|
||||
}
|
||||
|
||||
if (startTime && endTime && startTime > endTime) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'Start date must be before or equal to end date' })
|
||||
}
|
||||
|
||||
const accountInfo = await resolveAccountByPlatform(accountId, platform)
|
||||
if (!accountInfo) {
|
||||
return res.status(404).json({ success: false, error: 'Account not found' })
|
||||
}
|
||||
|
||||
const allApiKeys = await apiKeyService.getAllApiKeys(true)
|
||||
const apiKeyNameCache = new Map(
|
||||
allApiKeys.map((key) => [key.id, key.name || key.label || key.id])
|
||||
)
|
||||
|
||||
let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys
|
||||
if (apiKeyId && keysToUse.length === 0) {
|
||||
keysToUse = [{ id: apiKeyId }]
|
||||
}
|
||||
|
||||
const toUsageObject = (record) => ({
|
||||
input_tokens: record.inputTokens || 0,
|
||||
output_tokens: record.outputTokens || 0,
|
||||
cache_creation_input_tokens: record.cacheCreateTokens || 0,
|
||||
cache_read_input_tokens: record.cacheReadTokens || 0,
|
||||
cache_creation: record.cacheCreation || record.cache_creation || null
|
||||
})
|
||||
|
||||
const withinRange = (record) => {
|
||||
if (!record.timestamp) {
|
||||
return false
|
||||
}
|
||||
const ts = new Date(record.timestamp)
|
||||
if (Number.isNaN(ts.getTime())) {
|
||||
return false
|
||||
}
|
||||
if (startTime && ts < startTime) {
|
||||
return false
|
||||
}
|
||||
if (endTime && ts > endTime) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const filteredRecords = []
|
||||
const modelSet = new Set()
|
||||
const apiKeyOptionMap = new Map()
|
||||
let earliestTimestamp = null
|
||||
let latestTimestamp = null
|
||||
|
||||
const batchSize = 10
|
||||
for (let i = 0; i < keysToUse.length; i += batchSize) {
|
||||
const batch = keysToUse.slice(i, i + batchSize)
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (key) => {
|
||||
try {
|
||||
const records = await redis.getUsageRecords(key.id, 5000)
|
||||
return { keyId: key.id, records: records || [] }
|
||||
} catch (error) {
|
||||
logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`)
|
||||
return { keyId: key.id, records: [] }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
for (const { keyId, records } of batchResults) {
|
||||
const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId))
|
||||
for (const record of records) {
|
||||
if (record.accountId !== accountId) {
|
||||
continue
|
||||
}
|
||||
if (!withinRange(record)) {
|
||||
continue
|
||||
}
|
||||
if (model && record.model !== model) {
|
||||
continue
|
||||
}
|
||||
|
||||
const accountType = record.accountType || accountInfo.platform || 'unknown'
|
||||
const normalizedModel = record.model || 'unknown'
|
||||
|
||||
modelSet.add(normalizedModel)
|
||||
apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName })
|
||||
|
||||
if (record.timestamp) {
|
||||
const ts = new Date(record.timestamp)
|
||||
if (!Number.isNaN(ts.getTime())) {
|
||||
if (!earliestTimestamp || ts < earliestTimestamp) {
|
||||
earliestTimestamp = ts
|
||||
}
|
||||
if (!latestTimestamp || ts > latestTimestamp) {
|
||||
latestTimestamp = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filteredRecords.push({
|
||||
...record,
|
||||
model: normalizedModel,
|
||||
accountType,
|
||||
apiKeyId: keyId,
|
||||
apiKeyName
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filteredRecords.sort((a, b) => {
|
||||
const aTime = new Date(a.timestamp).getTime()
|
||||
const bTime = new Date(b.timestamp).getTime()
|
||||
if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
|
||||
return 0
|
||||
}
|
||||
return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime
|
||||
})
|
||||
|
||||
const summary = {
|
||||
totalRequests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0
|
||||
}
|
||||
|
||||
for (const record of filteredRecords) {
|
||||
const usage = toUsageObject(record)
|
||||
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
|
||||
const computedCost =
|
||||
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
|
||||
const totalTokens =
|
||||
record.totalTokens ||
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
|
||||
summary.totalRequests += 1
|
||||
summary.inputTokens += usage.input_tokens
|
||||
summary.outputTokens += usage.output_tokens
|
||||
summary.cacheCreateTokens += usage.cache_creation_input_tokens
|
||||
summary.cacheReadTokens += usage.cache_read_input_tokens
|
||||
summary.totalTokens += totalTokens
|
||||
summary.totalCost += computedCost
|
||||
}
|
||||
|
||||
const totalRecords = filteredRecords.length
|
||||
const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0
|
||||
const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1
|
||||
const startIndex = (safePage - 1) * pageSizeNumber
|
||||
const pageRecords =
|
||||
totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber)
|
||||
|
||||
const enrichedRecords = []
|
||||
for (const record of pageRecords) {
|
||||
const usage = toUsageObject(record)
|
||||
const costData = CostCalculator.calculateCost(usage, record.model || 'unknown')
|
||||
const computedCost =
|
||||
typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0
|
||||
const totalTokens =
|
||||
record.totalTokens ||
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
|
||||
enrichedRecords.push({
|
||||
timestamp: record.timestamp,
|
||||
model: record.model || 'unknown',
|
||||
apiKeyId: record.apiKeyId,
|
||||
apiKeyName: record.apiKeyName,
|
||||
accountId,
|
||||
accountName: accountInfo.name || accountInfo.email || accountId,
|
||||
accountType: record.accountType,
|
||||
accountTypeName: accountTypeNames[record.accountType] || '未知渠道',
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||
cacheReadTokens: usage.cache_read_input_tokens,
|
||||
ephemeral5mTokens: record.ephemeral5mTokens || 0,
|
||||
ephemeral1hTokens: record.ephemeral1hTokens || 0,
|
||||
totalTokens,
|
||||
isLongContextRequest: record.isLongContext || record.isLongContextRequest || false,
|
||||
cost: Number(computedCost.toFixed(6)),
|
||||
costFormatted:
|
||||
record.costFormatted ||
|
||||
costData?.formatted?.total ||
|
||||
CostCalculator.formatCost(computedCost),
|
||||
costBreakdown: record.costBreakdown || {
|
||||
input: costData?.costs?.input || 0,
|
||||
output: costData?.costs?.output || 0,
|
||||
cacheCreate: costData?.costs?.cacheWrite || 0,
|
||||
cacheRead: costData?.costs?.cacheRead || 0,
|
||||
total: costData?.costs?.total || computedCost
|
||||
},
|
||||
responseTime: record.responseTime || null
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
records: enrichedRecords,
|
||||
pagination: {
|
||||
currentPage: safePage,
|
||||
pageSize: pageSizeNumber,
|
||||
totalRecords,
|
||||
totalPages,
|
||||
hasNextPage: totalPages > 0 && safePage < totalPages,
|
||||
hasPreviousPage: totalPages > 0 && safePage > 1
|
||||
},
|
||||
filters: {
|
||||
startDate: startTime ? startTime.toISOString() : null,
|
||||
endDate: endTime ? endTime.toISOString() : null,
|
||||
model: model || null,
|
||||
apiKeyId: apiKeyId || null,
|
||||
platform: accountInfo.platform,
|
||||
sortOrder: normalizedSortOrder
|
||||
},
|
||||
accountInfo: {
|
||||
id: accountId,
|
||||
name: accountInfo.name || accountInfo.email || accountId,
|
||||
platform: accountInfo.platform || platform || 'unknown',
|
||||
status: accountInfo.status ?? accountInfo.isActive ?? null
|
||||
},
|
||||
summary: {
|
||||
...summary,
|
||||
totalCost: Number(summary.totalCost.toFixed(6)),
|
||||
avgCost:
|
||||
summary.totalRequests > 0
|
||||
? Number((summary.totalCost / summary.totalRequests).toFixed(6))
|
||||
: 0
|
||||
},
|
||||
availableFilters: {
|
||||
models: Array.from(modelSet),
|
||||
apiKeys: Array.from(apiKeyOptionMap.values()),
|
||||
dateRange: {
|
||||
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
|
||||
latest: latestTimestamp ? latestTimestamp.toISOString() : null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get account usage records:', error)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: 'Failed to get account usage records', message: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -15,6 +15,7 @@ const claudeCodeHeadersService = require('../services/claudeCodeHeadersService')
|
||||
const sessionHelper = require('../utils/sessionHelper')
|
||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||
const pricingService = require('../services/pricingService')
|
||||
const { getEffectiveModel } = require('../utils/modelHelper')
|
||||
|
||||
// 🔧 辅助函数:检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||
@@ -75,9 +76,9 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
}
|
||||
]
|
||||
|
||||
// 如果启用了模型限制,过滤模型列表
|
||||
// 如果启用了模型限制,视为黑名单:过滤掉受限模型
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id))
|
||||
models = models.filter((model) => !apiKeyData.restrictedModels.includes(model.id))
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -114,9 +115,9 @@ router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 检查模型限制
|
||||
// 模型限制(黑名单):命中则直接拒绝
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
||||
if (apiKeyData.restrictedModels.includes(modelId)) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: `Model '${modelId}' not found`,
|
||||
@@ -199,9 +200,10 @@ async function handleChatCompletion(req, res, apiKeyData) {
|
||||
// 转换 OpenAI 请求为 Claude 格式
|
||||
const claudeRequest = openaiToClaude.convertRequest(req.body)
|
||||
|
||||
// 检查模型限制
|
||||
// 模型限制(黑名单):命中受限模型则拒绝
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) {
|
||||
const effectiveModel = getEffectiveModel(claudeRequest.model || '')
|
||||
if (apiKeyData.restrictedModels.includes(effectiveModel)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: `Model ${req.body.model} is not allowed for this API key`,
|
||||
|
||||
@@ -247,9 +247,11 @@ const handleResponses = async (req, res) => {
|
||||
|
||||
// 从请求体中提取模型和流式标志
|
||||
let requestedModel = req.body?.model || null
|
||||
const isCodexModel =
|
||||
typeof requestedModel === 'string' && requestedModel.toLowerCase().includes('codex')
|
||||
|
||||
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5
|
||||
if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5-codex') {
|
||||
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),并且不是 Codex 系列,则覆盖为 gpt-5
|
||||
if (requestedModel && requestedModel.startsWith('gpt-5-') && !isCodexModel) {
|
||||
logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`)
|
||||
requestedModel = 'gpt-5'
|
||||
req.body.model = 'gpt-5' // 同时更新请求体中的模型
|
||||
|
||||
@@ -16,6 +16,22 @@ const {
|
||||
const tokenRefreshService = require('./tokenRefreshService')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper')
|
||||
const { isOpus45OrNewer } = require('../utils/modelHelper')
|
||||
|
||||
/**
|
||||
* Check if account is Pro (not Max)
|
||||
* Compatible with both API real-time data (hasClaudePro) and local config (accountType)
|
||||
* @param {Object} info - Subscription info object
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProAccount(info) {
|
||||
// API real-time status takes priority
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
return true
|
||||
}
|
||||
// Local configured account type
|
||||
return info.accountType === 'claude_pro'
|
||||
}
|
||||
|
||||
class ClaudeAccountService {
|
||||
constructor() {
|
||||
@@ -852,31 +868,39 @@ class ClaudeAccountService {
|
||||
!this.isSubscriptionExpired(account)
|
||||
)
|
||||
|
||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
||||
// Filter Opus models based on account type and model version
|
||||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||||
const isNewOpus = isOpus45OrNewer(modelName)
|
||||
|
||||
activeAccounts = activeAccounts.filter((account) => {
|
||||
// 检查账号的订阅信息
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const info = JSON.parse(account.subscriptionInfo)
|
||||
// Pro 和 Free 账号不支持 Opus
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
return false // Claude Pro 不支持 Opus
|
||||
|
||||
// Free account: does not support any Opus model
|
||||
if (info.accountType === 'free') {
|
||||
return false
|
||||
}
|
||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||||
return false // 明确标记为 Pro 或 Free 的账号不支持
|
||||
|
||||
// Pro account: only supports Opus 4.5+
|
||||
if (isProAccount(info)) {
|
||||
return isNewOpus
|
||||
}
|
||||
|
||||
// Max account: supports all Opus versions
|
||||
return true
|
||||
} catch (e) {
|
||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||||
// Parse failed, assume legacy data (Max), default support
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
// Account without subscription info, default to supported (legacy data compatibility)
|
||||
return true
|
||||
})
|
||||
|
||||
if (activeAccounts.length === 0) {
|
||||
throw new Error('No Claude accounts available that support Opus model')
|
||||
const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)'
|
||||
throw new Error(`No Claude accounts available that support ${modelDesc} model`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,31 +994,39 @@ class ClaudeAccountService {
|
||||
!this.isSubscriptionExpired(account)
|
||||
)
|
||||
|
||||
// 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号
|
||||
// Filter Opus models based on account type and model version
|
||||
if (modelName && modelName.toLowerCase().includes('opus')) {
|
||||
const isNewOpus = isOpus45OrNewer(modelName)
|
||||
|
||||
sharedAccounts = sharedAccounts.filter((account) => {
|
||||
// 检查账号的订阅信息
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const info = JSON.parse(account.subscriptionInfo)
|
||||
// Pro 和 Free 账号不支持 Opus
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
return false // Claude Pro 不支持 Opus
|
||||
|
||||
// Free account: does not support any Opus model
|
||||
if (info.accountType === 'free') {
|
||||
return false
|
||||
}
|
||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||||
return false // 明确标记为 Pro 或 Free 的账号不支持
|
||||
|
||||
// Pro account: only supports Opus 4.5+
|
||||
if (isProAccount(info)) {
|
||||
return isNewOpus
|
||||
}
|
||||
|
||||
// Max account: supports all Opus versions
|
||||
return true
|
||||
} catch (e) {
|
||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||||
// Parse failed, assume legacy data (Max), default support
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
// Account without subscription info, default to supported (legacy data compatibility)
|
||||
return true
|
||||
})
|
||||
|
||||
if (sharedAccounts.length === 0) {
|
||||
throw new Error('No shared Claude accounts available that support Opus model')
|
||||
const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)'
|
||||
throw new Error(`No shared Claude accounts available that support ${modelDesc} model`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1948,7 +1948,13 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 🛠️ 统一的错误处理方法
|
||||
async _handleServerError(accountId, statusCode, _sessionHash = null, context = '') {
|
||||
async _handleServerError(
|
||||
accountId,
|
||||
statusCode,
|
||||
sessionHash = null,
|
||||
context = '',
|
||||
accountType = 'claude-official'
|
||||
) {
|
||||
try {
|
||||
await claudeAccountService.recordServerError(accountId, statusCode)
|
||||
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
|
||||
@@ -1962,6 +1968,18 @@ class ClaudeRelayService {
|
||||
`⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}`
|
||||
)
|
||||
|
||||
// 标记账户为临时不可用(5分钟)
|
||||
try {
|
||||
await unifiedClaudeScheduler.markAccountTemporarilyUnavailable(
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash,
|
||||
300
|
||||
)
|
||||
} catch (markError) {
|
||||
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, markError)
|
||||
}
|
||||
|
||||
if (errorCount > threshold) {
|
||||
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
|
||||
// ⚠️ 只记录5xx/504告警,不再自动停止调度,避免上游抖动导致误停
|
||||
|
||||
@@ -5,7 +5,33 @@ const ccrAccountService = require('./ccrAccountService')
|
||||
const accountGroupService = require('./accountGroupService')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper')
|
||||
|
||||
/**
|
||||
* Check if account is Pro (not Max)
|
||||
*
|
||||
* ACCOUNT TYPE LOGIC (as of 2025-12-05):
|
||||
* Pro accounts can be identified by either:
|
||||
* 1. API real-time data: hasClaudePro=true && hasClaudeMax=false
|
||||
* 2. Local config data: accountType='claude_pro'
|
||||
*
|
||||
* Account type restrictions for Opus models:
|
||||
* - Free account: No Opus access at all
|
||||
* - Pro account: Only Opus 4.5+ (new versions)
|
||||
* - Max account: All Opus versions (legacy 3.x, 4.0, 4.1 and new 4.5+)
|
||||
*
|
||||
* Compatible with both API real-time data (hasClaudePro) and local config (accountType)
|
||||
* @param {Object} info - Subscription info object
|
||||
* @returns {boolean} - true if Pro account (not Free, not Max)
|
||||
*/
|
||||
function isProAccount(info) {
|
||||
// API real-time status takes priority
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
return true
|
||||
}
|
||||
// Local configured account type
|
||||
return info.accountType === 'claude_pro'
|
||||
}
|
||||
|
||||
class UnifiedClaudeScheduler {
|
||||
constructor() {
|
||||
@@ -46,8 +72,14 @@ class UnifiedClaudeScheduler {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Opus 模型的订阅级别检查
|
||||
// 2. Opus model subscription level check
|
||||
// VERSION RESTRICTION LOGIC:
|
||||
// - Free: No Opus models
|
||||
// - Pro: Only Opus 4.5+ (isOpus45OrNewer = true)
|
||||
// - Max: All Opus versions
|
||||
if (requestedModel.toLowerCase().includes('opus')) {
|
||||
const isNewOpus = isOpus45OrNewer(requestedModel)
|
||||
|
||||
if (account.subscriptionInfo) {
|
||||
try {
|
||||
const info =
|
||||
@@ -55,27 +87,36 @@ class UnifiedClaudeScheduler {
|
||||
? JSON.parse(account.subscriptionInfo)
|
||||
: account.subscriptionInfo
|
||||
|
||||
// Pro 和 Free 账号不支持 Opus
|
||||
if (info.hasClaudePro === true && info.hasClaudeMax !== true) {
|
||||
// Free account: does not support any Opus model
|
||||
if (info.accountType === 'free') {
|
||||
logger.info(
|
||||
`🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}`
|
||||
`🚫 Claude account ${account.name} (Free) does not support Opus model${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') {
|
||||
|
||||
// Pro account: only supports Opus 4.5+
|
||||
// Reject legacy Opus (3.x, 4.0-4.4) but allow new Opus (4.5+)
|
||||
if (isProAccount(info)) {
|
||||
if (!isNewOpus) {
|
||||
logger.info(
|
||||
`🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}`
|
||||
`🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
// Opus 4.5+ supported
|
||||
return true
|
||||
}
|
||||
|
||||
// Max account: supports all Opus versions (no restriction)
|
||||
} catch (e) {
|
||||
// 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max)
|
||||
// Parse failed, assume legacy data (Max), default support
|
||||
logger.debug(
|
||||
`Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max`
|
||||
)
|
||||
}
|
||||
}
|
||||
// 没有订阅信息的账号,默认当作支持(兼容旧数据)
|
||||
// Account without subscription info, default to supported (legacy data compatibility)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +218,16 @@ class UnifiedClaudeScheduler {
|
||||
// 普通专属账户
|
||||
const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId)
|
||||
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
boundAccount.id,
|
||||
'claude-official'
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.warn(
|
||||
`⏱️ Bound Claude OAuth account ${boundAccount.id} is temporarily unavailable, falling back to pool`
|
||||
)
|
||||
} else {
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id)
|
||||
if (isRateLimited) {
|
||||
const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id)
|
||||
@@ -203,6 +254,7 @@ class UnifiedClaudeScheduler {
|
||||
accountType: 'claude-official'
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status}), falling back to pool`
|
||||
@@ -221,6 +273,16 @@ class UnifiedClaudeScheduler {
|
||||
boundConsoleAccount.status === 'active' &&
|
||||
this._isSchedulable(boundConsoleAccount.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
boundConsoleAccount.id,
|
||||
'claude-console'
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.warn(
|
||||
`⏱️ Bound Claude Console account ${boundConsoleAccount.id} is temporarily unavailable, falling back to pool`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
@@ -228,6 +290,7 @@ class UnifiedClaudeScheduler {
|
||||
accountId: apiKeyData.claudeConsoleAccountId,
|
||||
accountType: 'claude-console'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable}), falling back to pool`
|
||||
@@ -245,6 +308,16 @@ class UnifiedClaudeScheduler {
|
||||
boundBedrockAccountResult.data.isActive === true &&
|
||||
this._isSchedulable(boundBedrockAccountResult.data.schedulable)
|
||||
) {
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
apiKeyData.bedrockAccountId,
|
||||
'bedrock'
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.warn(
|
||||
`⏱️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is temporarily unavailable, falling back to pool`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}`
|
||||
)
|
||||
@@ -252,6 +325,7 @@ class UnifiedClaudeScheduler {
|
||||
accountId: apiKeyData.bedrockAccountId,
|
||||
accountType: 'bedrock'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable}), falling back to pool`
|
||||
@@ -496,6 +570,18 @@ class UnifiedClaudeScheduler {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
account.id,
|
||||
'claude-official'
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.debug(
|
||||
`⏭️ Skipping Claude Official account ${account.name} - temporarily unavailable`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id)
|
||||
if (isRateLimited) {
|
||||
@@ -584,6 +670,18 @@ class UnifiedClaudeScheduler {
|
||||
// 继续处理该账号
|
||||
}
|
||||
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
currentAccount.id,
|
||||
'claude-console'
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.debug(
|
||||
`⏭️ Skipping Claude Console account ${currentAccount.name} - temporarily unavailable`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
|
||||
currentAccount.id
|
||||
@@ -682,7 +780,15 @@ class UnifiedClaudeScheduler {
|
||||
account.accountType === 'shared' &&
|
||||
this._isSchedulable(account.schedulable)
|
||||
) {
|
||||
// 检查是否可调度
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(
|
||||
account.id,
|
||||
'bedrock'
|
||||
)
|
||||
if (isTempUnavailable) {
|
||||
logger.debug(`⏭️ Skipping Bedrock account ${account.name} - temporarily unavailable`)
|
||||
continue
|
||||
}
|
||||
|
||||
availableAccounts.push({
|
||||
...account,
|
||||
@@ -731,6 +837,13 @@ class UnifiedClaudeScheduler {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否临时不可用
|
||||
const isTempUnavailable = await this.isAccountTemporarilyUnavailable(account.id, 'ccr')
|
||||
if (isTempUnavailable) {
|
||||
logger.debug(`⏭️ Skipping CCR account ${account.name} - temporarily unavailable`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id)
|
||||
const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id)
|
||||
@@ -1099,6 +1212,42 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// ⏱️ 标记账户为临时不可用状态(用于5xx等临时故障,默认5分钟后自动恢复)
|
||||
async markAccountTemporarilyUnavailable(
|
||||
accountId,
|
||||
accountType,
|
||||
sessionHash = null,
|
||||
ttlSeconds = 300
|
||||
) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `temp_unavailable:${accountType}:${accountId}`
|
||||
await client.setex(key, ttlSeconds, '1')
|
||||
if (sessionHash) {
|
||||
await this._deleteSessionMapping(sessionHash)
|
||||
}
|
||||
logger.warn(
|
||||
`⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s`
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error)
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否临时不可用
|
||||
async isAccountTemporarilyUnavailable(accountId, accountType) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `temp_unavailable:${accountType}:${accountId}`
|
||||
return (await client.exists(key)) === 1
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check temp unavailable status: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(
|
||||
accountId,
|
||||
|
||||
@@ -70,9 +70,119 @@ function getVendorType(modelStr) {
|
||||
return vendor
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model is Opus 4.5 or newer.
|
||||
*
|
||||
* VERSION LOGIC (as of 2025-12-05):
|
||||
* - Opus 4.5+ (including 5.0, 6.0, etc.) → returns true (Pro account eligible)
|
||||
* - Opus 4.4 and below (including 3.x, 4.0, 4.1) → returns false (Max account only)
|
||||
*
|
||||
* Supported naming formats:
|
||||
* - New format: claude-opus-{major}[-{minor}][-date], e.g., claude-opus-4-5-20251101
|
||||
* - New format: claude-opus-{major}.{minor}, e.g., claude-opus-4.5
|
||||
* - Old format: claude-{version}-opus[-date], e.g., claude-3-opus-20240229
|
||||
* - Special: opus-latest, claude-opus-latest → always returns true
|
||||
*
|
||||
* @param {string} modelName - Model name
|
||||
* @returns {boolean} - Whether the model is Opus 4.5 or newer
|
||||
*/
|
||||
function isOpus45OrNewer(modelName) {
|
||||
if (!modelName) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerModel = modelName.toLowerCase()
|
||||
if (!lowerModel.includes('opus')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle 'latest' special case
|
||||
if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Old format: claude-{version}-opus (version before opus)
|
||||
// e.g., claude-3-opus-20240229, claude-3.5-opus
|
||||
const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[.-](\d+))?[- ]opus/)
|
||||
if (oldFormatMatch) {
|
||||
const majorVersion = parseInt(oldFormatMatch[1], 10)
|
||||
const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0
|
||||
|
||||
// Old format version refers to Claude major version
|
||||
// majorVersion > 4: 5.x, 6.x, ... → true
|
||||
// majorVersion === 4 && minorVersion >= 5: 4.5, 4.6, ... → true
|
||||
// Others (3.x, 4.0-4.4): → false
|
||||
if (majorVersion > 4) {
|
||||
return true
|
||||
}
|
||||
if (majorVersion === 4 && minorVersion >= 5) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// New format 1: opus-{major}.{minor} (dot-separated)
|
||||
// e.g., claude-opus-4.5, opus-4.5
|
||||
const dotFormatMatch = lowerModel.match(/opus[- ]?(\d+)\.(\d+)/)
|
||||
if (dotFormatMatch) {
|
||||
const majorVersion = parseInt(dotFormatMatch[1], 10)
|
||||
const minorVersion = parseInt(dotFormatMatch[2], 10)
|
||||
|
||||
// Same version logic as old format
|
||||
// opus-5.0, opus-6.0 → true
|
||||
// opus-4.5, opus-4.6 → true
|
||||
// opus-4.0, opus-4.4 → false
|
||||
if (majorVersion > 4) {
|
||||
return true
|
||||
}
|
||||
if (majorVersion === 4 && minorVersion >= 5) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// New format 2: opus-{major}[-{minor}][-date] (hyphen-separated)
|
||||
// e.g., claude-opus-4-5-20251101, claude-opus-4-20250514, claude-opus-4-1-20250805
|
||||
// If opus-{major} is followed by 8-digit date, there's no minor version
|
||||
|
||||
// Extract content after 'opus'
|
||||
const opusIndex = lowerModel.indexOf('opus')
|
||||
const afterOpus = lowerModel.substring(opusIndex + 4)
|
||||
|
||||
// Match: -{major}-{minor}-{date} or -{major}-{date} or -{major}
|
||||
// IMPORTANT: Minor version regex is (\d{1,2}) not (\d+)
|
||||
// This prevents matching 8-digit dates as minor version
|
||||
// Example: opus-4-20250514 → major=4, minor=undefined (not 20250514)
|
||||
// Example: opus-4-5-20251101 → major=4, minor=5
|
||||
// Future-proof: Supports up to 2-digit minor versions (0-99)
|
||||
const versionMatch = afterOpus.match(/^[- ](\d+)(?:[- ](\d{1,2})(?=[- ]\d{8}|$))?/)
|
||||
|
||||
if (versionMatch) {
|
||||
const majorVersion = parseInt(versionMatch[1], 10)
|
||||
const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0
|
||||
|
||||
// Same version logic: >= 4.5 returns true
|
||||
// opus-5-0-date, opus-6-date → true
|
||||
// opus-4-5-date, opus-4-10-date → true (supports 2-digit minor)
|
||||
// opus-4-date (no minor, treated as 4.0) → false
|
||||
// opus-4-1-date, opus-4-4-date → false
|
||||
if (majorVersion > 4) {
|
||||
return true
|
||||
}
|
||||
if (majorVersion === 4 && minorVersion >= 5) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Other cases containing 'opus' but cannot parse version, assume legacy
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseVendorPrefixedModel,
|
||||
hasVendorPrefix,
|
||||
getEffectiveModel,
|
||||
getVendorType
|
||||
getVendorType,
|
||||
isOpus45OrNewer
|
||||
}
|
||||
|
||||
81
src/utils/unstableUpstreamHelper.js
Normal file
81
src/utils/unstableUpstreamHelper.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const logger = require('./logger')
|
||||
|
||||
function parseList(envValue) {
|
||||
if (!envValue) {
|
||||
return []
|
||||
}
|
||||
return envValue
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const unstableTypes = new Set(parseList(process.env.UNSTABLE_ERROR_TYPES))
|
||||
const unstableKeywords = parseList(process.env.UNSTABLE_ERROR_KEYWORDS)
|
||||
const unstableStatusCodes = new Set([408, 499, 502, 503, 504, 522])
|
||||
|
||||
function normalizeErrorPayload(payload) {
|
||||
if (!payload) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
return normalizeErrorPayload(JSON.parse(payload))
|
||||
} catch (e) {
|
||||
return { message: payload }
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.error && typeof payload.error === 'object') {
|
||||
return {
|
||||
type: payload.error.type || payload.error.error || payload.error.code,
|
||||
code: payload.error.code || payload.error.error || payload.error.type,
|
||||
message: payload.error.message || payload.error.msg || payload.message || payload.error.error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: payload.type || payload.code,
|
||||
code: payload.code || payload.type,
|
||||
message: payload.message || ''
|
||||
}
|
||||
}
|
||||
|
||||
function isUnstableUpstreamError(statusCode, payload) {
|
||||
const normalizedStatus = Number(statusCode)
|
||||
if (Number.isFinite(normalizedStatus) && normalizedStatus >= 500) {
|
||||
return true
|
||||
}
|
||||
if (Number.isFinite(normalizedStatus) && unstableStatusCodes.has(normalizedStatus)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { type, code, message } = normalizeErrorPayload(payload)
|
||||
const lowerType = (type || '').toString().toLowerCase()
|
||||
const lowerCode = (code || '').toString().toLowerCase()
|
||||
const lowerMessage = (message || '').toString().toLowerCase()
|
||||
|
||||
if (lowerType === 'server_error' || lowerCode === 'server_error') {
|
||||
return true
|
||||
}
|
||||
if (unstableTypes.has(lowerType) || unstableTypes.has(lowerCode)) {
|
||||
return true
|
||||
}
|
||||
if (unstableKeywords.length > 0) {
|
||||
return unstableKeywords.some((kw) => lowerMessage.includes(kw))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function logUnstable(accountLabel, statusCode) {
|
||||
logger.warn(
|
||||
`Detected unstable upstream error (${statusCode}) for account ${accountLabel}, marking temporarily unavailable`
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isUnstableUpstreamError,
|
||||
logUnstable
|
||||
}
|
||||
@@ -44,6 +44,13 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-full bg-purple-100 px-3 py-2 text-xs font-semibold text-purple-700 transition hover:bg-purple-200 dark:bg-purple-500/10 dark:text-purple-200 dark:hover:bg-purple-500/20"
|
||||
@click="goTimeline"
|
||||
>
|
||||
<i class="fas fa-clock" /> 请求时间线
|
||||
</button>
|
||||
<button
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-500 transition hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
@click="handleClose"
|
||||
@@ -51,6 +58,7 @@
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4 sm:px-6">
|
||||
@@ -325,6 +333,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Chart from 'chart.js/auto'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -343,6 +352,7 @@ const emit = defineEmits(['close'])
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { isDarkMode } = storeToRefs(themeStore)
|
||||
const router = useRouter()
|
||||
|
||||
const chartCanvas = ref(null)
|
||||
let chartInstance = null
|
||||
@@ -579,6 +589,14 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const goTimeline = () => {
|
||||
if (!props.account?.id) return
|
||||
router.push({
|
||||
path: `/accounts/${props.account.id}/usage-records`,
|
||||
query: { platform: props.account.platform || props.account.accountType }
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
|
||||
@@ -77,7 +77,21 @@ const getActionClass = (action) => {
|
||||
return colorMap[action.color] || colorMap.gray
|
||||
}
|
||||
|
||||
const instanceId = Symbol('action-dropdown')
|
||||
const handleGlobalOpen = (event) => {
|
||||
if (event?.detail?.id !== instanceId) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
if (!isOpen.value) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('action-dropdown-open', {
|
||||
detail: { id: instanceId }
|
||||
})
|
||||
)
|
||||
}
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
@@ -102,7 +116,8 @@ const updateDropdownPosition = () => {
|
||||
|
||||
const trigger = triggerRef.value.getBoundingClientRect()
|
||||
const dropdownHeight = 200 // 预估高度
|
||||
const dropdownWidth = 160 // 预估宽度
|
||||
const dropdownWidth = 180 // 预估宽度,略大以减少遮挡
|
||||
const gap = 8
|
||||
const spaceBelow = window.innerHeight - trigger.bottom
|
||||
const spaceAbove = trigger.top
|
||||
const spaceRight = window.innerWidth - trigger.right
|
||||
@@ -112,16 +127,16 @@ const updateDropdownPosition = () => {
|
||||
|
||||
// 计算垂直位置
|
||||
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||
top = trigger.bottom + 4
|
||||
top = trigger.bottom + gap
|
||||
} else {
|
||||
top = trigger.top - dropdownHeight - 4
|
||||
top = trigger.top - dropdownHeight - gap
|
||||
}
|
||||
|
||||
// 计算水平位置 - 优先显示在左侧(因为按钮在右侧固定列)
|
||||
if (spaceLeft >= dropdownWidth) {
|
||||
left = trigger.left - dropdownWidth + trigger.width
|
||||
} else if (spaceRight >= dropdownWidth) {
|
||||
left = trigger.left
|
||||
// 计算水平位置 - 优先向右展开,避免遮挡左侧内容
|
||||
if (spaceRight >= dropdownWidth + gap) {
|
||||
left = trigger.right + gap
|
||||
} else if (spaceLeft >= dropdownWidth + gap) {
|
||||
left = trigger.left - dropdownWidth - gap + trigger.width
|
||||
} else {
|
||||
left = window.innerWidth - dropdownWidth - 10
|
||||
}
|
||||
@@ -164,11 +179,13 @@ onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('action-dropdown-open', handleGlobalOpen)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('action-dropdown-open', handleGlobalOpen)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -41,19 +41,25 @@
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="flex cursor-pointer items-center gap-2 whitespace-nowrap px-3 py-2 text-sm transition-colors duration-150"
|
||||
class="flex cursor-pointer items-center gap-2 whitespace-nowrap py-2 text-sm transition-colors duration-150"
|
||||
:class="[
|
||||
isSelected(option.value)
|
||||
? 'bg-blue-50 font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: option.isGroup
|
||||
? 'bg-gray-50 font-semibold text-gray-800 dark:bg-gray-700/50 dark:text-gray-200'
|
||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
]"
|
||||
:style="{
|
||||
paddingLeft: option.indent ? `${12 + option.indent * 16}px` : '12px',
|
||||
paddingRight: '12px'
|
||||
}"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<i v-if="option.icon" :class="['fas', option.icon, 'text-xs']"></i>
|
||||
<span>{{ option.label }}</span>
|
||||
<i
|
||||
v-if="isSelected(option.value)"
|
||||
class="fas fa-check ml-auto pl-3 text-xs text-blue-600"
|
||||
class="fas fa-check ml-auto pl-3 text-xs text-blue-600 dark:text-blue-400"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ const DashboardView = () => import('@/views/DashboardView.vue')
|
||||
const ApiKeysView = () => import('@/views/ApiKeysView.vue')
|
||||
const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue')
|
||||
const AccountsView = () => import('@/views/AccountsView.vue')
|
||||
const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue')
|
||||
const TutorialView = () => import('@/views/TutorialView.vue')
|
||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
||||
@@ -110,6 +111,18 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/accounts/:accountId/usage-records',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'AccountUsageRecords',
|
||||
component: AccountUsageRecordsView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/tutorial',
|
||||
component: MainLayout,
|
||||
|
||||
574
web/admin-spa/src/views/AccountUsageRecordsView.vue
Normal file
574
web/admin-spa/src/views/AccountUsageRecordsView.vue
Normal file
@@ -0,0 +1,574 @@
|
||||
<template>
|
||||
<div class="space-y-4 p-4 lg:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
@click="goBack"
|
||||
>
|
||||
← 返回
|
||||
</button>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||
账户请求详情时间线
|
||||
</p>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ accountDisplayName }}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ accountId }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">渠道:{{ platformDisplayName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-clock text-blue-500" />
|
||||
<span v-if="dateRangeHint">{{ dateRangeHint }}</span>
|
||||
<span v-else>显示近 5000 条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总请求</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(summary.totalRequests) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总 Token</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(summary.totalTokens) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">总费用</p>
|
||||
<p class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ formatCost(summary.totalCost) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-xs uppercase text-gray-500 dark:text-gray-400">平均费用/次</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ formatCost(summary.avgCost) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<el-date-picker
|
||||
v-model="filters.dateRange"
|
||||
class="max-w-[320px]"
|
||||
clearable
|
||||
end-placeholder="结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
start-placeholder="开始时间"
|
||||
type="datetimerange"
|
||||
unlink-panels
|
||||
value-format="YYYY-MM-DDTHH:mm:ss[Z]"
|
||||
/>
|
||||
|
||||
<el-select
|
||||
v-model="filters.model"
|
||||
class="w-[180px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有模型"
|
||||
>
|
||||
<el-option
|
||||
v-for="modelOption in availableModels"
|
||||
:key="modelOption"
|
||||
:label="modelOption"
|
||||
:value="modelOption"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="filters.apiKeyId"
|
||||
class="w-[220px]"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="所有 API Key"
|
||||
>
|
||||
<el-option
|
||||
v-for="apiKey in availableApiKeys"
|
||||
:key="apiKey.id"
|
||||
:label="apiKey.name || apiKey.id"
|
||||
:value="apiKey.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="filters.sortOrder" class="w-[140px]" placeholder="排序">
|
||||
<el-option label="时间降序" value="desc" />
|
||||
<el-option label="时间升序" value="asc" />
|
||||
</el-select>
|
||||
|
||||
<el-button @click="resetFilters"> <i class="fas fa-undo mr-2" /> 重置 </el-button>
|
||||
<el-button :loading="exporting" type="primary" @click="exportCsv">
|
||||
<i class="fas fa-file-export mr-2" /> 导出 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex items-center justify-center p-10 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin mr-2" /> 加载中...
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="records.length === 0"
|
||||
class="flex flex-col items-center gap-2 p-10 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<i class="fas fa-inbox text-2xl" />
|
||||
<p>暂无记录</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div class="hidden overflow-x-auto md:block">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
时间
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
API Key
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
模型
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
输入
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
输出
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
缓存(创/读)
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
总 Token
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
费用
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<tr v-for="record in records" :key="record.timestamp + record.model">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ formatDate(record.timestamp) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold">
|
||||
{{ record.apiKeyName || record.apiKeyId || '未知 Key' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
ID: {{ record.apiKeyId }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ record.model }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-blue-600 dark:text-blue-400">
|
||||
{{ formatNumber(record.inputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
{{ formatNumber(record.outputTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
{{ formatNumber(record.cacheCreateTokens) }} /
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-800 dark:text-gray-100">
|
||||
{{ formatNumber(record.totalTokens) }}
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
{{ record.costFormatted || formatCost(record.cost) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 md:hidden">
|
||||
<div
|
||||
v-for="record in records"
|
||||
:key="record.timestamp + record.model"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ record.apiKeyName || record.apiKeyId || '未知 Key' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
ID: {{ record.apiKeyId }} · {{ formatDate(record.timestamp) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
渠道:{{ platformDisplayName }}
|
||||
</p>
|
||||
</div>
|
||||
<el-button size="small" @click="openDetail(record)">详情</el-button>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div>模型:{{ record.model }}</div>
|
||||
<div>总 Token:{{ formatNumber(record.totalTokens) }}</div>
|
||||
<div>输入:{{ formatNumber(record.inputTokens) }}</div>
|
||||
<div>输出:{{ formatNumber(record.outputTokens) }}</div>
|
||||
<div>
|
||||
缓存创/读:{{ formatNumber(record.cacheCreateTokens) }} /
|
||||
{{ formatNumber(record.cacheReadTokens) }}
|
||||
</div>
|
||||
<div class="text-yellow-600 dark:text-yellow-400">
|
||||
费用:{{ record.costFormatted || formatCost(record.cost) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 pb-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
共 {{ pagination.totalRecords }} 条记录
|
||||
</div>
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="pagination.currentPage"
|
||||
layout="prev, pager, next, sizes"
|
||||
:page-size="pagination.pageSize"
|
||||
:page-sizes="[20, 50, 100, 200]"
|
||||
:total="pagination.totalRecords"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RecordDetailModal :record="activeRecord" :show="detailVisible" @close="closeDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { apiClient } from '@/config/api'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const accountId = computed(() => route.params.accountId)
|
||||
const platform = computed(() => route.query.platform)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const records = ref([])
|
||||
const availableModels = ref([])
|
||||
const availableApiKeys = ref([])
|
||||
|
||||
const pagination = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 50,
|
||||
totalRecords: 0
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
dateRange: null,
|
||||
model: '',
|
||||
apiKeyId: '',
|
||||
sortOrder: 'desc'
|
||||
})
|
||||
|
||||
const summary = reactive({
|
||||
totalRequests: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
avgCost: 0
|
||||
})
|
||||
|
||||
const accountInfo = reactive({
|
||||
id: accountId.value,
|
||||
name: '',
|
||||
platform: platform.value || ''
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const activeRecord = ref(null)
|
||||
|
||||
const accountDisplayName = computed(() => accountInfo.name || accountInfo.id || accountId.value)
|
||||
const platformDisplayName = computed(() => {
|
||||
const map = {
|
||||
claude: 'Claude官方',
|
||||
'claude-console': 'Claude Console',
|
||||
ccr: 'Claude Console Relay',
|
||||
openai: 'OpenAI',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
droid: 'Droid',
|
||||
unknown: '未知渠道'
|
||||
}
|
||||
const key = accountInfo.platform || platform.value || 'unknown'
|
||||
return map[key] || '未知渠道'
|
||||
})
|
||||
|
||||
const dateRangeHint = computed(() => {
|
||||
if (!filters.dateRange || filters.dateRange.length !== 2) return ''
|
||||
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
|
||||
})
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatCost = (value) => {
|
||||
const num = typeof value === 'number' ? value : 0
|
||||
if (num >= 1) return `$${num.toFixed(2)}`
|
||||
if (num >= 0.001) return `$${num.toFixed(4)}`
|
||||
return `$${num.toFixed(6)}`
|
||||
}
|
||||
|
||||
const buildParams = (page) => {
|
||||
const params = {
|
||||
page,
|
||||
pageSize: pagination.pageSize,
|
||||
sortOrder: filters.sortOrder
|
||||
}
|
||||
|
||||
if (filters.model) params.model = filters.model
|
||||
if (filters.apiKeyId) params.apiKeyId = filters.apiKeyId
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = dayjs(filters.dateRange[0]).toISOString()
|
||||
params.endDate = dayjs(filters.dateRange[1]).toISOString()
|
||||
}
|
||||
if (platform.value) {
|
||||
params.platform = platform.value
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const syncResponseState = (data) => {
|
||||
records.value = data.records || []
|
||||
|
||||
const pageInfo = data.pagination || {}
|
||||
pagination.currentPage = pageInfo.currentPage || 1
|
||||
pagination.pageSize = pageInfo.pageSize || pagination.pageSize
|
||||
pagination.totalRecords = pageInfo.totalRecords || 0
|
||||
|
||||
const filterEcho = data.filters || {}
|
||||
if (filterEcho.model !== undefined) filters.model = filterEcho.model || ''
|
||||
if (filterEcho.apiKeyId !== undefined) filters.apiKeyId = filterEcho.apiKeyId || ''
|
||||
if (filterEcho.sortOrder) filters.sortOrder = filterEcho.sortOrder
|
||||
if (filterEcho.startDate && filterEcho.endDate) {
|
||||
const nextRange = [filterEcho.startDate, filterEcho.endDate]
|
||||
const currentRange = filters.dateRange || []
|
||||
if (currentRange[0] !== nextRange[0] || currentRange[1] !== nextRange[1]) {
|
||||
filters.dateRange = nextRange
|
||||
}
|
||||
}
|
||||
|
||||
const summaryData = data.summary || {}
|
||||
summary.totalRequests = summaryData.totalRequests || 0
|
||||
summary.totalTokens = summaryData.totalTokens || 0
|
||||
summary.totalCost = summaryData.totalCost || 0
|
||||
summary.avgCost = summaryData.avgCost || 0
|
||||
|
||||
accountInfo.id = data.accountInfo?.id || accountId.value
|
||||
accountInfo.name = data.accountInfo?.name || ''
|
||||
accountInfo.platform = data.accountInfo?.platform || platform.value || ''
|
||||
|
||||
availableModels.value = data.availableFilters?.models || []
|
||||
availableApiKeys.value = data.availableFilters?.apiKeys || []
|
||||
}
|
||||
|
||||
const fetchRecords = async (page = pagination.currentPage) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||
params: buildParams(page)
|
||||
})
|
||||
syncResponseState(response.data || {})
|
||||
} catch (error) {
|
||||
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
pagination.currentPage = page
|
||||
fetchRecords(page)
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.pageSize = size
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.model = ''
|
||||
filters.apiKeyId = ''
|
||||
filters.dateRange = null
|
||||
filters.sortOrder = 'desc'
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
const openDetail = (record) => {
|
||||
activeRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const closeDetail = () => {
|
||||
detailVisible.value = false
|
||||
activeRecord.value = null
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/accounts')
|
||||
}
|
||||
|
||||
const exportCsv = async () => {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const aggregated = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||
|
||||
while (page <= totalPages && page <= maxPages) {
|
||||
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||
params: { ...buildParams(page), pageSize: 200 }
|
||||
})
|
||||
const payload = response.data || {}
|
||||
aggregated.push(...(payload.records || []))
|
||||
totalPages = payload.pagination?.totalPages || 1
|
||||
page += 1
|
||||
}
|
||||
|
||||
if (aggregated.length === 0) {
|
||||
showToast('没有可导出的记录', 'info')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'时间',
|
||||
'API Key',
|
||||
'模型',
|
||||
'输入Token',
|
||||
'输出Token',
|
||||
'缓存创建Token',
|
||||
'缓存读取Token',
|
||||
'总Token',
|
||||
'费用'
|
||||
]
|
||||
|
||||
const csvRows = [headers.join(',')]
|
||||
aggregated.forEach((record) => {
|
||||
const row = [
|
||||
formatDate(record.timestamp),
|
||||
record.apiKeyName || record.apiKeyId || '',
|
||||
record.model || '',
|
||||
record.inputTokens || 0,
|
||||
record.outputTokens || 0,
|
||||
record.cacheCreateTokens || 0,
|
||||
record.cacheReadTokens || 0,
|
||||
record.totalTokens || 0,
|
||||
record.costFormatted || formatCost(record.cost)
|
||||
]
|
||||
csvRows.push(row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
})
|
||||
|
||||
const blob = new Blob([csvRows.join('\n')], {
|
||||
type: 'text/csv;charset=utf-8;'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `account-${accountId.value}-usage-records.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showToast('导出 CSV 成功', 'success')
|
||||
} catch (error) {
|
||||
showToast(`导出失败:${error.message || '未知错误'}`, 'error')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [filters.model, filters.apiKeyId, filters.sortOrder],
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => filters.dateRange,
|
||||
() => {
|
||||
pagination.currentPage = 1
|
||||
fetchRecords(1)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords()
|
||||
})
|
||||
</script>
|
||||
@@ -58,6 +58,20 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 状态筛选器 -->
|
||||
<div class="group relative min-w-[120px]">
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-green-500 to-emerald-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||
></div>
|
||||
<CustomDropdown
|
||||
v-model="statusFilter"
|
||||
icon="fa-check-circle"
|
||||
icon-color="text-green-500"
|
||||
:options="statusOptions"
|
||||
placeholder="选择状态"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="group relative min-w-[200px]">
|
||||
<div
|
||||
@@ -83,6 +97,22 @@
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center sm:gap-3">
|
||||
<!-- 账户统计按钮 -->
|
||||
<div class="relative">
|
||||
<el-tooltip content="查看账户统计汇总" effect="dark" placement="bottom">
|
||||
<button
|
||||
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
|
||||
@click="showAccountStatsModal = true"
|
||||
>
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-violet-500 to-purple-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||
></div>
|
||||
<i class="fas fa-chart-bar relative text-violet-500" />
|
||||
<span class="relative">统计</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<div class="relative">
|
||||
<el-tooltip
|
||||
@@ -1668,7 +1698,6 @@
|
||||
<i class="fas fa-chart-line" />
|
||||
详情
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canTestAccount(account)"
|
||||
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-cyan-50 px-3 py-2 text-xs text-cyan-600 transition-colors hover:bg-cyan-100 dark:bg-cyan-900/40 dark:text-cyan-300 dark:hover:bg-cyan-800/50"
|
||||
@@ -1850,6 +1879,146 @@
|
||||
:show="showAccountTestModal"
|
||||
@close="closeAccountTestModal"
|
||||
/>
|
||||
|
||||
<!-- 账户统计弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showAccountStatsModal"
|
||||
:style="{ maxWidth: '1200px' }"
|
||||
title="账户统计汇总"
|
||||
width="90%"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-sm" style="min-width: 1000px">
|
||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="border border-gray-300 px-4 py-2 text-left dark:border-gray-600">
|
||||
平台类型
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
正常
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
不可调度
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
限流0-1h
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
限流1-5h
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
限流5-12h
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
限流12-24h
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
限流>24h
|
||||
</th>
|
||||
<th class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
其他
|
||||
</th>
|
||||
<th
|
||||
class="border border-gray-300 bg-blue-50 px-4 py-2 text-center font-bold dark:border-gray-600 dark:bg-blue-900/30"
|
||||
>
|
||||
合计
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="stat in accountStats" :key="stat.platform">
|
||||
<td class="border border-gray-300 px-4 py-2 font-medium dark:border-gray-600">
|
||||
{{ stat.platformLabel }}
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-green-600 dark:text-green-400">{{ stat.normal }}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-yellow-600 dark:text-yellow-400">{{ stat.unschedulable }}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ stat.rateLimit0_1h }}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{ stat.rateLimit1_5h }}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
stat.rateLimit5_12h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
stat.rateLimit12_24h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
stat.rateLimitOver24h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-red-600 dark:text-red-400">{{ stat.other }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="border border-gray-300 bg-blue-50 px-4 py-2 text-center font-bold dark:border-gray-600 dark:bg-blue-900/30"
|
||||
>
|
||||
{{ stat.total }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg-blue-50 font-bold dark:bg-blue-900/30">
|
||||
<td class="border border-gray-300 px-4 py-2 dark:border-gray-600">合计</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-green-600 dark:text-green-400">{{
|
||||
accountStatsTotal.normal
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-yellow-600 dark:text-yellow-400">{{
|
||||
accountStatsTotal.unschedulable
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimit0_1h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimit1_5h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimit5_12h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimit12_24h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-orange-600 dark:text-orange-400">{{
|
||||
accountStatsTotal.rateLimitOver24h
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
<span class="text-red-600 dark:text-red-400">{{ accountStatsTotal.other }}</span>
|
||||
</td>
|
||||
<td class="border border-gray-300 px-4 py-2 text-center dark:border-gray-600">
|
||||
{{ accountStatsTotal.total }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
注:限流时间列表示剩余限流时间在指定范围内的账户数量
|
||||
</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1880,6 +2049,7 @@ const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X
|
||||
const accountGroups = ref([])
|
||||
const groupFilter = ref('all')
|
||||
const platformFilter = ref('all')
|
||||
const statusFilter = ref('normal') // 状态过滤 (normal/rateLimited/other/all)
|
||||
const searchKeyword = ref('')
|
||||
const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize'
|
||||
const getInitialPageSize = () => {
|
||||
@@ -1929,6 +2099,9 @@ const expiryEditModalRef = ref(null)
|
||||
const showAccountTestModal = ref(false)
|
||||
const testingAccount = ref(null)
|
||||
|
||||
// 账户统计弹窗状态
|
||||
const showAccountStatsModal = ref(false)
|
||||
|
||||
// 表格横向滚动检测
|
||||
const tableContainerRef = ref(null)
|
||||
const needsHorizontalScroll = ref(false)
|
||||
@@ -1946,21 +2119,102 @@ const sortOptions = ref([
|
||||
{ value: 'dailyTokens', label: '按今日Token排序', icon: 'fa-coins' },
|
||||
{ value: 'dailyRequests', label: '按今日请求数排序', icon: 'fa-chart-line' },
|
||||
{ value: 'totalTokens', label: '按总Token排序', icon: 'fa-database' },
|
||||
{ value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' }
|
||||
{ value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' },
|
||||
{ value: 'rateLimitTime', label: '按限流时间排序', icon: 'fa-hourglass' }
|
||||
])
|
||||
|
||||
const platformOptions = ref([
|
||||
{ value: 'all', label: '所有平台', icon: 'fa-globe' },
|
||||
{ value: 'claude', label: 'Claude', icon: 'fa-brain' },
|
||||
// 平台层级结构定义
|
||||
const platformHierarchy = [
|
||||
{
|
||||
value: 'group-claude',
|
||||
label: 'Claude(全部)',
|
||||
icon: 'fa-brain',
|
||||
children: [
|
||||
{ value: 'claude', label: 'Claude 官方/OAuth', icon: 'fa-brain' },
|
||||
{ value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' },
|
||||
{ value: 'gemini', label: 'Gemini', icon: 'fab fa-google' },
|
||||
{ value: 'gemini-api', label: 'Gemini API', icon: 'fa-key' },
|
||||
{ value: 'openai', label: 'OpenAi', icon: 'fa-openai' },
|
||||
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' },
|
||||
{ value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' },
|
||||
{ value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' },
|
||||
{ value: 'ccr', label: 'CCR', icon: 'fa-code-branch' },
|
||||
{ value: 'droid', label: 'Droid', icon: 'fa-robot' }
|
||||
{ value: 'ccr', label: 'CCR Relay', icon: 'fa-code-branch' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'group-openai',
|
||||
label: 'Codex / OpenAI(全部)',
|
||||
icon: 'fa-openai',
|
||||
children: [
|
||||
{ value: 'openai', label: 'OpenAI 官方', icon: 'fa-openai' },
|
||||
{ value: 'openai-responses', label: 'OpenAI-Responses (Codex)', icon: 'fa-server' },
|
||||
{ value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'group-gemini',
|
||||
label: 'Gemini(全部)',
|
||||
icon: 'fab fa-google',
|
||||
children: [
|
||||
{ value: 'gemini', label: 'Gemini OAuth', icon: 'fab fa-google' },
|
||||
{ value: 'gemini-api', label: 'Gemini API', icon: 'fa-key' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'group-droid',
|
||||
label: 'Droid(全部)',
|
||||
icon: 'fa-robot',
|
||||
children: [{ value: 'droid', label: 'Droid', icon: 'fa-robot' }]
|
||||
}
|
||||
]
|
||||
|
||||
// 平台分组映射
|
||||
const platformGroupMap = {
|
||||
'group-claude': ['claude', 'claude-console', 'bedrock', 'ccr'],
|
||||
'group-openai': ['openai', 'openai-responses', 'azure_openai'],
|
||||
'group-gemini': ['gemini', 'gemini-api'],
|
||||
'group-droid': ['droid']
|
||||
}
|
||||
|
||||
// 平台请求处理器
|
||||
const platformRequestHandlers = {
|
||||
claude: (params) => apiClient.get('/admin/claude-accounts', { params }),
|
||||
'claude-console': (params) => apiClient.get('/admin/claude-console-accounts', { params }),
|
||||
bedrock: (params) => apiClient.get('/admin/bedrock-accounts', { params }),
|
||||
gemini: (params) => apiClient.get('/admin/gemini-accounts', { params }),
|
||||
openai: (params) => apiClient.get('/admin/openai-accounts', { params }),
|
||||
azure_openai: (params) => apiClient.get('/admin/azure-openai-accounts', { params }),
|
||||
'openai-responses': (params) => apiClient.get('/admin/openai-responses-accounts', { params }),
|
||||
ccr: (params) => apiClient.get('/admin/ccr-accounts', { params }),
|
||||
droid: (params) => apiClient.get('/admin/droid-accounts', { params }),
|
||||
'gemini-api': (params) => apiClient.get('/admin/gemini-api-accounts', { params })
|
||||
}
|
||||
|
||||
const allPlatformKeys = Object.keys(platformRequestHandlers)
|
||||
|
||||
// 根据过滤器获取需要加载的平台列表
|
||||
const getPlatformsForFilter = (filter) => {
|
||||
if (filter === 'all') return allPlatformKeys
|
||||
if (platformGroupMap[filter]) return platformGroupMap[filter]
|
||||
if (allPlatformKeys.includes(filter)) return [filter]
|
||||
return allPlatformKeys
|
||||
}
|
||||
|
||||
// 平台选项(两级结构)
|
||||
const platformOptions = computed(() => {
|
||||
const options = [{ value: 'all', label: '所有平台', icon: 'fa-globe', indent: 0 }]
|
||||
|
||||
platformHierarchy.forEach((group) => {
|
||||
options.push({ ...group, indent: 0, isGroup: true })
|
||||
group.children?.forEach((child) => {
|
||||
options.push({ ...child, indent: 1, parent: group.value })
|
||||
})
|
||||
})
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const statusOptions = ref([
|
||||
{ value: 'normal', label: '正常', icon: 'fa-check-circle' },
|
||||
{ value: 'unschedulable', label: '不可调度', icon: 'fa-ban' },
|
||||
{ value: 'rateLimited', label: '限流', icon: 'fa-hourglass-half' },
|
||||
{ value: 'other', label: '其他', icon: 'fa-exclamation-triangle' },
|
||||
{ value: 'all', label: '全部状态', icon: 'fa-list' }
|
||||
])
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
@@ -2199,6 +2453,33 @@ const sortedAccounts = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
// 状态过滤 (normal/unschedulable/rateLimited/other/all)
|
||||
// 限流: isActive && rate-limited (最高优先级)
|
||||
// 正常: isActive && !rate-limited && !blocked && schedulable
|
||||
// 不可调度: isActive && !rate-limited && !blocked && schedulable === false
|
||||
// 其他: 非限流的(未激活 || 被阻止)
|
||||
if (statusFilter.value !== 'all') {
|
||||
sourceAccounts = sourceAccounts.filter((account) => {
|
||||
const isRateLimited = isAccountRateLimited(account)
|
||||
const isBlocked = account.status === 'blocked' || account.status === 'unauthorized'
|
||||
|
||||
if (statusFilter.value === 'rateLimited') {
|
||||
// 限流: 激活且限流中(优先判断)
|
||||
return account.isActive && isRateLimited
|
||||
} else if (statusFilter.value === 'normal') {
|
||||
// 正常: 激活且非限流且非阻止且可调度
|
||||
return account.isActive && !isRateLimited && !isBlocked && account.schedulable !== false
|
||||
} else if (statusFilter.value === 'unschedulable') {
|
||||
// 不可调度: 激活且非限流且非阻止但不可调度
|
||||
return account.isActive && !isRateLimited && !isBlocked && account.schedulable === false
|
||||
} else if (statusFilter.value === 'other') {
|
||||
// 其他: 非限流的异常账户(未激活或被阻止)
|
||||
return !isRateLimited && (!account.isActive || isBlocked)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if (!accountsSortBy.value) return sourceAccounts
|
||||
|
||||
const sorted = [...sourceAccounts].sort((a, b) => {
|
||||
@@ -2229,6 +2510,23 @@ const sortedAccounts = computed(() => {
|
||||
bVal = b.isActive ? 1 : 0
|
||||
}
|
||||
|
||||
// 处理限流时间排序: 未限流优先,然后按剩余时间从小到大
|
||||
if (accountsSortBy.value === 'rateLimitTime') {
|
||||
const aIsRateLimited = isAccountRateLimited(a)
|
||||
const bIsRateLimited = isAccountRateLimited(b)
|
||||
const aMinutes = aIsRateLimited ? getRateLimitRemainingMinutes(a) : 0
|
||||
const bMinutes = bIsRateLimited ? getRateLimitRemainingMinutes(b) : 0
|
||||
|
||||
// 未限流的排在前面
|
||||
if (!aIsRateLimited && bIsRateLimited) return -1
|
||||
if (aIsRateLimited && !bIsRateLimited) return 1
|
||||
|
||||
// 都未限流或都限流时,按剩余时间升序
|
||||
if (aMinutes < bMinutes) return -1
|
||||
if (aMinutes > bMinutes) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
if (aVal < bVal) return accountsSortOrder.value === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return accountsSortOrder.value === 'asc' ? 1 : -1
|
||||
return 0
|
||||
@@ -2242,6 +2540,120 @@ const totalPages = computed(() => {
|
||||
return Math.ceil(total / pageSize.value) || 0
|
||||
})
|
||||
|
||||
// 账户统计数据(按平台和状态分类)
|
||||
const accountStats = computed(() => {
|
||||
const platforms = [
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
{ value: 'claude-console', label: 'Claude Console' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'gemini-api', label: 'Gemini API' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'azure_openai', label: 'Azure OpenAI' },
|
||||
{ value: 'bedrock', label: 'Bedrock' },
|
||||
{ value: 'openai-responses', label: 'OpenAI-Responses' },
|
||||
{ value: 'ccr', label: 'CCR' },
|
||||
{ value: 'droid', label: 'Droid' }
|
||||
]
|
||||
|
||||
return platforms
|
||||
.map((p) => {
|
||||
const platformAccounts = accounts.value.filter((acc) => acc.platform === p.value)
|
||||
|
||||
// 先筛选限流账户(优先级最高)
|
||||
const rateLimitedAccounts = platformAccounts.filter((acc) => isAccountRateLimited(acc))
|
||||
|
||||
// 正常: 非限流 && 激活 && 非阻止 && 可调度
|
||||
const normal = platformAccounts.filter((acc) => {
|
||||
const isRateLimited = isAccountRateLimited(acc)
|
||||
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
return !isRateLimited && acc.isActive && !isBlocked && acc.schedulable !== false
|
||||
}).length
|
||||
|
||||
// 不可调度: 非限流 && 激活 && 非阻止 && 不可调度
|
||||
const unschedulable = platformAccounts.filter((acc) => {
|
||||
const isRateLimited = isAccountRateLimited(acc)
|
||||
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
return !isRateLimited && acc.isActive && !isBlocked && acc.schedulable === false
|
||||
}).length
|
||||
|
||||
// 其他: 非限流的异常账户(未激活或被阻止)
|
||||
const other = platformAccounts.filter((acc) => {
|
||||
const isRateLimited = isAccountRateLimited(acc)
|
||||
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
|
||||
return !isRateLimited && (!acc.isActive || isBlocked)
|
||||
}).length
|
||||
|
||||
const rateLimit0_1h = rateLimitedAccounts.filter((acc) => {
|
||||
const minutes = getRateLimitRemainingMinutes(acc)
|
||||
return minutes > 0 && minutes <= 60
|
||||
}).length
|
||||
|
||||
const rateLimit1_5h = rateLimitedAccounts.filter((acc) => {
|
||||
const minutes = getRateLimitRemainingMinutes(acc)
|
||||
return minutes > 60 && minutes <= 300
|
||||
}).length
|
||||
|
||||
const rateLimit5_12h = rateLimitedAccounts.filter((acc) => {
|
||||
const minutes = getRateLimitRemainingMinutes(acc)
|
||||
return minutes > 300 && minutes <= 720
|
||||
}).length
|
||||
|
||||
const rateLimit12_24h = rateLimitedAccounts.filter((acc) => {
|
||||
const minutes = getRateLimitRemainingMinutes(acc)
|
||||
return minutes > 720 && minutes <= 1440
|
||||
}).length
|
||||
|
||||
const rateLimitOver24h = rateLimitedAccounts.filter((acc) => {
|
||||
const minutes = getRateLimitRemainingMinutes(acc)
|
||||
return minutes > 1440
|
||||
}).length
|
||||
|
||||
return {
|
||||
platform: p.value,
|
||||
platformLabel: p.label,
|
||||
normal,
|
||||
unschedulable,
|
||||
rateLimit0_1h,
|
||||
rateLimit1_5h,
|
||||
rateLimit5_12h,
|
||||
rateLimit12_24h,
|
||||
rateLimitOver24h,
|
||||
other,
|
||||
total: platformAccounts.length
|
||||
}
|
||||
})
|
||||
.filter((stat) => stat.total > 0) // 只显示有账户的平台
|
||||
})
|
||||
|
||||
// 账户统计合计
|
||||
const accountStatsTotal = computed(() => {
|
||||
return accountStats.value.reduce(
|
||||
(total, stat) => {
|
||||
total.normal += stat.normal
|
||||
total.unschedulable += stat.unschedulable
|
||||
total.rateLimit0_1h += stat.rateLimit0_1h
|
||||
total.rateLimit1_5h += stat.rateLimit1_5h
|
||||
total.rateLimit5_12h += stat.rateLimit5_12h
|
||||
total.rateLimit12_24h += stat.rateLimit12_24h
|
||||
total.rateLimitOver24h += stat.rateLimitOver24h
|
||||
total.other += stat.other
|
||||
total.total += stat.total
|
||||
return total
|
||||
},
|
||||
{
|
||||
normal: 0,
|
||||
unschedulable: 0,
|
||||
rateLimit0_1h: 0,
|
||||
rateLimit1_5h: 0,
|
||||
rateLimit5_12h: 0,
|
||||
rateLimit12_24h: 0,
|
||||
rateLimitOver24h: 0,
|
||||
other: 0,
|
||||
total: 0
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const pageNumbers = computed(() => {
|
||||
const total = totalPages.value
|
||||
const current = currentPage.value
|
||||
@@ -2355,190 +2767,14 @@ const loadAccounts = async (forceReload = false) => {
|
||||
try {
|
||||
// 构建查询参数(用于其他筛选情况)
|
||||
const params = {}
|
||||
if (platformFilter.value !== 'all') {
|
||||
if (platformFilter.value !== 'all' && !platformGroupMap[platformFilter.value]) {
|
||||
params.platform = platformFilter.value
|
||||
}
|
||||
if (groupFilter.value !== 'all') {
|
||||
params.groupId = groupFilter.value
|
||||
}
|
||||
|
||||
// 根据平台筛选决定需要请求哪些接口
|
||||
const requests = []
|
||||
|
||||
if (platformFilter.value === 'all') {
|
||||
// 请求所有平台
|
||||
requests.push(
|
||||
apiClient.get('/admin/claude-accounts', { params }),
|
||||
apiClient.get('/admin/claude-console-accounts', { params }),
|
||||
apiClient.get('/admin/bedrock-accounts', { params }),
|
||||
apiClient.get('/admin/gemini-accounts', { params }),
|
||||
apiClient.get('/admin/openai-accounts', { params }),
|
||||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
||||
apiClient.get('/admin/openai-responses-accounts', { params }),
|
||||
apiClient.get('/admin/ccr-accounts', { params }),
|
||||
apiClient.get('/admin/droid-accounts', { params }),
|
||||
apiClient.get('/admin/gemini-api-accounts', { params })
|
||||
)
|
||||
} else {
|
||||
// 只请求指定平台,其他平台设为null占位
|
||||
switch (platformFilter.value) {
|
||||
case 'claude':
|
||||
requests.push(
|
||||
apiClient.get('/admin/claude-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
||||
)
|
||||
break
|
||||
case 'claude-console':
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||
apiClient.get('/admin/claude-console-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
||||
)
|
||||
break
|
||||
case 'bedrock':
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||
apiClient.get('/admin/bedrock-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
||||
)
|
||||
break
|
||||
case 'gemini':
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
apiClient.get('/admin/gemini-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
||||
)
|
||||
break
|
||||
case 'openai':
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
apiClient.get('/admin/openai-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
||||
)
|
||||
break
|
||||
case 'azure_openai':
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
apiClient.get('/admin/azure-openai-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
||||
)
|
||||
break
|
||||
case 'openai-responses':
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
apiClient.get('/admin/openai-responses-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
||||
)
|
||||
break
|
||||
case 'ccr':
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||
apiClient.get('/admin/ccr-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
||||
)
|
||||
break
|
||||
case 'droid':
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||
apiClient.get('/admin/droid-accounts', { params }),
|
||||
Promise.resolve({ success: true, data: [] }) // gemini-api 占位
|
||||
)
|
||||
break
|
||||
case 'gemini-api':
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }), // claude 占位
|
||||
Promise.resolve({ success: true, data: [] }), // claude-console 占位
|
||||
Promise.resolve({ success: true, data: [] }), // bedrock 占位
|
||||
Promise.resolve({ success: true, data: [] }), // gemini 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // azure-openai 占位
|
||||
Promise.resolve({ success: true, data: [] }), // openai-responses 占位
|
||||
Promise.resolve({ success: true, data: [] }), // ccr 占位
|
||||
Promise.resolve({ success: true, data: [] }), // droid 占位
|
||||
apiClient.get('/admin/gemini-api-accounts', { params })
|
||||
)
|
||||
break
|
||||
default:
|
||||
// 默认情况下返回空数组
|
||||
requests.push(
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] }),
|
||||
Promise.resolve({ success: true, data: [] })
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
const platformsToFetch = getPlatformsForFilter(platformFilter.value)
|
||||
|
||||
// 使用缓存机制加载绑定计数和分组数据(不再加载完整的 API Keys 数据)
|
||||
await Promise.all([loadBindingCounts(forceReload), loadAccountGroups(forceReload)])
|
||||
@@ -2546,125 +2782,137 @@ const loadAccounts = async (forceReload = false) => {
|
||||
// 后端账户API已经包含分组信息,不需要单独加载分组成员关系
|
||||
// await loadGroupMembers(forceReload)
|
||||
|
||||
const [
|
||||
claudeData,
|
||||
claudeConsoleData,
|
||||
bedrockData,
|
||||
geminiData,
|
||||
openaiData,
|
||||
azureOpenaiData,
|
||||
openaiResponsesData,
|
||||
ccrData,
|
||||
droidData,
|
||||
geminiApiData
|
||||
] = await Promise.all(requests)
|
||||
const platformResults = await Promise.all(
|
||||
platformsToFetch.map(async (platform) => {
|
||||
const handler = platformRequestHandlers[platform]
|
||||
if (!handler) {
|
||||
return { platform, success: true, data: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await handler(params)
|
||||
return { platform, success: res?.success, data: res?.data }
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load ${platform} accounts:`, error)
|
||||
return { platform, success: false, data: [] }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const allAccounts = []
|
||||
const counts = bindingCounts.value || {}
|
||||
let openaiResponsesRaw = []
|
||||
|
||||
// 获取绑定计数数据
|
||||
const counts = bindingCounts.value
|
||||
const appendAccounts = (platform, data) => {
|
||||
if (!data || data.length === 0) return
|
||||
|
||||
if (claudeData.success) {
|
||||
const claudeAccounts = (claudeData.data || []).map((acc) => {
|
||||
// 从绑定计数缓存获取数量
|
||||
switch (platform) {
|
||||
case 'claude': {
|
||||
const items = data.map((acc) => {
|
||||
const boundApiKeysCount = counts.claudeAccountId?.[acc.id] || 0
|
||||
// 后端已经包含了groupInfos,直接使用
|
||||
return { ...acc, platform: 'claude', boundApiKeysCount }
|
||||
})
|
||||
allAccounts.push(...claudeAccounts)
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
|
||||
if (claudeConsoleData.success) {
|
||||
const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => {
|
||||
// 从绑定计数缓存获取数量
|
||||
case 'claude-console': {
|
||||
const items = data.map((acc) => {
|
||||
const boundApiKeysCount = counts.claudeConsoleAccountId?.[acc.id] || 0
|
||||
// 后端已经包含了groupInfos,直接使用
|
||||
return { ...acc, platform: 'claude-console', boundApiKeysCount }
|
||||
})
|
||||
allAccounts.push(...claudeConsoleAccounts)
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
|
||||
if (bedrockData.success) {
|
||||
const bedrockAccounts = (bedrockData.data || []).map((acc) => {
|
||||
// Bedrock账户暂时不支持直接绑定
|
||||
// 后端已经包含了groupInfos,直接使用
|
||||
return { ...acc, platform: 'bedrock', boundApiKeysCount: 0 }
|
||||
})
|
||||
allAccounts.push(...bedrockAccounts)
|
||||
case 'bedrock': {
|
||||
const items = data.map((acc) => ({ ...acc, platform: 'bedrock', boundApiKeysCount: 0 }))
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
|
||||
if (geminiData.success) {
|
||||
const geminiAccounts = (geminiData.data || []).map((acc) => {
|
||||
// 从绑定计数缓存获取数量
|
||||
case 'gemini': {
|
||||
const items = data.map((acc) => {
|
||||
const boundApiKeysCount = counts.geminiAccountId?.[acc.id] || 0
|
||||
// 后端已经包含了groupInfos,直接使用
|
||||
return { ...acc, platform: 'gemini', boundApiKeysCount }
|
||||
})
|
||||
allAccounts.push(...geminiAccounts)
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
if (openaiData.success) {
|
||||
const openaiAccounts = (openaiData.data || []).map((acc) => {
|
||||
// 从绑定计数缓存获取数量
|
||||
case 'openai': {
|
||||
const items = data.map((acc) => {
|
||||
const boundApiKeysCount = counts.openaiAccountId?.[acc.id] || 0
|
||||
// 后端已经包含了groupInfos,直接使用
|
||||
return { ...acc, platform: 'openai', boundApiKeysCount }
|
||||
})
|
||||
allAccounts.push(...openaiAccounts)
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
if (azureOpenaiData && azureOpenaiData.success) {
|
||||
const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => {
|
||||
// 从绑定计数缓存获取数量
|
||||
case 'azure_openai': {
|
||||
const items = data.map((acc) => {
|
||||
const boundApiKeysCount = counts.azureOpenaiAccountId?.[acc.id] || 0
|
||||
// 后端已经包含了groupInfos,直接使用
|
||||
return { ...acc, platform: 'azure_openai', boundApiKeysCount }
|
||||
})
|
||||
allAccounts.push(...azureOpenaiAccounts)
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
|
||||
if (openaiResponsesData && openaiResponsesData.success) {
|
||||
const openaiResponsesAccounts = (openaiResponsesData.data || []).map((acc) => {
|
||||
// 从绑定计数缓存获取数量
|
||||
// OpenAI-Responses账户使用 responses: 前缀
|
||||
const boundApiKeysCount = counts.openaiAccountId?.[`responses:${acc.id}`] || 0
|
||||
// 后端已经包含了groupInfos,直接使用
|
||||
return { ...acc, platform: 'openai-responses', boundApiKeysCount }
|
||||
})
|
||||
allAccounts.push(...openaiResponsesAccounts)
|
||||
case 'openai-responses': {
|
||||
openaiResponsesRaw = data
|
||||
break
|
||||
}
|
||||
|
||||
// CCR 账户
|
||||
if (ccrData && ccrData.success) {
|
||||
const ccrAccounts = (ccrData.data || []).map((acc) => {
|
||||
// CCR 不支持 API Key 绑定,固定为 0
|
||||
return { ...acc, platform: 'ccr', boundApiKeysCount: 0 }
|
||||
})
|
||||
allAccounts.push(...ccrAccounts)
|
||||
case 'ccr': {
|
||||
const items = data.map((acc) => ({ ...acc, platform: 'ccr', boundApiKeysCount: 0 }))
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
|
||||
// Droid 账户
|
||||
if (droidData && droidData.success) {
|
||||
const droidAccounts = (droidData.data || []).map((acc) => {
|
||||
// 从绑定计数缓存获取数量
|
||||
case 'droid': {
|
||||
const items = data.map((acc) => {
|
||||
const boundApiKeysCount = counts.droidAccountId?.[acc.id] || acc.boundApiKeysCount || 0
|
||||
return {
|
||||
...acc,
|
||||
platform: 'droid',
|
||||
boundApiKeysCount
|
||||
}
|
||||
return { ...acc, platform: 'droid', boundApiKeysCount }
|
||||
})
|
||||
allAccounts.push(...droidAccounts)
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
|
||||
// Gemini API 账户
|
||||
if (geminiApiData && geminiApiData.success) {
|
||||
const geminiApiAccounts = (geminiApiData.data || []).map((acc) => {
|
||||
// 从绑定计数缓存获取数量
|
||||
// Gemini-API账户使用 api: 前缀
|
||||
case 'gemini-api': {
|
||||
const items = data.map((acc) => {
|
||||
const boundApiKeysCount = counts.geminiAccountId?.[`api:${acc.id}`] || 0
|
||||
// 后端已经包含了groupInfos,直接使用
|
||||
return { ...acc, platform: 'gemini-api', boundApiKeysCount }
|
||||
})
|
||||
allAccounts.push(...geminiApiAccounts)
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
platformResults.forEach(({ platform, success, data }) => {
|
||||
if (success) {
|
||||
appendAccounts(platform, data || [])
|
||||
}
|
||||
})
|
||||
|
||||
if (openaiResponsesRaw.length > 0) {
|
||||
let autoRecoveryConfigMap = {}
|
||||
try {
|
||||
const configsRes = await apiClient.get(
|
||||
'/admin/openai-responses-accounts/auto-recovery-configs'
|
||||
)
|
||||
if (configsRes.success && Array.isArray(configsRes.data)) {
|
||||
autoRecoveryConfigMap = configsRes.data.reduce((map, config) => {
|
||||
if (config?.accountId) {
|
||||
map[config.accountId] = config
|
||||
}
|
||||
return map
|
||||
}, {})
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to load auto-recovery configs:', error)
|
||||
}
|
||||
|
||||
const responsesAccounts = openaiResponsesRaw.map((acc) => {
|
||||
const boundApiKeysCount = counts.openaiAccountId?.[`responses:${acc.id}`] || 0
|
||||
const autoRecoveryConfig = autoRecoveryConfigMap[acc.id] || acc.autoRecoveryConfig || null
|
||||
return { ...acc, platform: 'openai-responses', boundApiKeysCount, autoRecoveryConfig }
|
||||
})
|
||||
|
||||
allAccounts.push(...responsesAccounts)
|
||||
}
|
||||
|
||||
// 根据分组筛选器过滤账户
|
||||
@@ -3014,6 +3262,58 @@ const formatRateLimitTime = (minutes) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查账户是否被限流
|
||||
const isAccountRateLimited = (account) => {
|
||||
if (!account) return false
|
||||
|
||||
// 检查 rateLimitStatus
|
||||
if (account.rateLimitStatus) {
|
||||
if (typeof account.rateLimitStatus === 'string' && account.rateLimitStatus === 'limited') {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
typeof account.rateLimitStatus === 'object' &&
|
||||
account.rateLimitStatus.isRateLimited === true
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取限流剩余时间(分钟)
|
||||
const getRateLimitRemainingMinutes = (account) => {
|
||||
if (!account || !account.rateLimitStatus) return 0
|
||||
|
||||
if (typeof account.rateLimitStatus === 'object') {
|
||||
const status = account.rateLimitStatus
|
||||
if (Number.isFinite(status.minutesRemaining)) {
|
||||
return Math.max(0, Math.ceil(status.minutesRemaining))
|
||||
}
|
||||
if (Number.isFinite(status.remainingMinutes)) {
|
||||
return Math.max(0, Math.ceil(status.remainingMinutes))
|
||||
}
|
||||
if (Number.isFinite(status.remainingSeconds)) {
|
||||
return Math.max(0, Math.ceil(status.remainingSeconds / 60))
|
||||
}
|
||||
if (status.rateLimitResetAt) {
|
||||
const diffMs = new Date(status.rateLimitResetAt).getTime() - Date.now()
|
||||
return diffMs > 0 ? Math.ceil(diffMs / 60000) : 0
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有 rateLimitUntil 字段,计算剩余时间
|
||||
if (account.rateLimitUntil) {
|
||||
const now = new Date().getTime()
|
||||
const untilTime = new Date(account.rateLimitUntil).getTime()
|
||||
const diff = untilTime - now
|
||||
return diff > 0 ? Math.ceil(diff / 60000) : 0
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// 打开创建账户模态框
|
||||
const openCreateAccountModal = () => {
|
||||
newAccountPlatform.value = null // 重置选择的平台
|
||||
|
||||
Reference in New Issue
Block a user