Compare commits

..

25 Commits

Author SHA1 Message Date
github-actions[bot]
94aca4dc22 chore: sync VERSION file with release v1.1.222 [skip ci] 2025-12-05 01:06:39 +00:00
shaw
6bfef2525a Merge PR #753: feat: 新增 API Key
请求时间线接口与管理端详情页面
2025-12-05 09:03:53 +08:00
github-actions[bot]
5a636a36f6 chore: sync VERSION file with release v1.1.221 [skip ci] 2025-12-05 00:38:43 +00:00
shaw
b61e1062bf fix: 修复create_proxyagent调用方式 2025-12-05 08:37:43 +08:00
github-actions[bot]
1b18a1226d chore: sync VERSION file with release v1.1.220 [skip ci] 2025-12-04 13:01:54 +00:00
Wesley Liddick
0b2372abab Merge pull request #756 from SunSeekerX/feature_api_disable_switch
feat(account): 新增账户自动防护禁用开关
2025-12-04 08:01:35 -05:00
SunSeekerX
8aca1f9dd1 feat(account): 新增账户自动防护禁用开关
支持 disableAutoProtection 配置项,启用后上游 401/400/429/529 错误不再自动禁用账户
2025-12-04 20:47:12 +08:00
atoz03
95ef04c1a3 fix: 保持仪表盘趋势图非负并纠正小时区间
- 小时粒度请求使用用户选择的起止时间,避免近24小时被截成整天
  - 修正日期展示格式化逻辑,减少时区偏移导致的窗口错位
  - 趋势图 Y 轴(Token/请求数/费用等)强制最小值为 0,防止出现负刻度
2025-12-04 17:05:36 +08:00
atoz03
4919e392a5 feat: 仪表盘日期筛选默认今日并记忆用户偏好 2025-12-04 16:48:11 +08:00
atoz03
354d8da13f feat:已修复详情弹窗位置问题:RecordDetailModal 现在 append-to-body、destroy-on-close,并设定 top="10vh",点击列表底部的“详情”不会被滚动容器截断或浮在页面顶部看不到。 2025-12-04 15:17:48 +08:00
atoz03
3df0c7c650 feat:已修复 ESLint no-shadow 问题:geminiApiAccountService 不再重复声明,改用顶部引入的实例。后端/前端 lint 均通过(npm run lint:check、cd web/admin-spa && npm run lint) 2025-12-04 15:05:09 +08:00
atoz03
6a3dce523b chore: format usage stats route 2025-12-04 15:02:07 +08:00
atoz03
9fe2918a54 feat: keep API key stats modal and add timeline entry point 2025-12-04 14:56:27 +08:00
atoz03
92b30e1924 feat: add API key usage timeline API and admin UI 2025-12-04 14:41:38 +08:00
github-actions[bot]
b63f2f78fc chore: sync VERSION file with release v1.1.219 [skip ci] 2025-12-04 01:48:56 +00:00
Wesley Liddick
c971d239ff Merge pull request #752 from IanShaw027/fix/filter-cloudflare-cdn-headers
fix: 过滤 Cloudflare CDN headers 以防止 API 安全检查
2025-12-03 20:48:41 -05:00
Wesley Liddick
01d6e30e82 Merge pull request #751 from atoz03/feature/account-sort-toggle [skip ci]
feat(accounts): 支持账户排序正序/倒序切换
2025-12-03 20:48:24 -05:00
IanShaw027
5fd78b6411 fix: 过滤 Cloudflare CDN headers 以防止 API 安全检查
使用 Cloudflare 橙色云(CDN 代理模式)时,Cloudflare 会自动添加 CDN 相关的 headers
(cf-*, x-forwarded-*, cdn-loop 等),这会触发上游 API 提供商的安全检查:

1. 已确认问题:88code API 检测到 CDN headers 后返回 403 Forbidden,
   导致 Codex CLI 无法使用
2. 潜在风险:其他 API 提供商(OpenAI、Anthropic)可能也会因检测到
   代理/CDN 特征而采取限制措施

创建统一的 headerFilter 工具类,在所有转发服务中过滤 Cloudflare CDN headers,
使转发请求伪装成正常的直接客户端请求。

1. 新增 src/utils/headerFilter.js
   - 统一的 CDN headers 过滤列表(13 个 Cloudflare headers)
   - 提供 filterForOpenAI() 和 filterForClaude() 方法
   - 在现有过滤逻辑基础上添加 CDN header 过滤

2. 更新 src/services/openaiResponsesRelayService.js
   - 使用 filterForOpenAI() 替代内联的 _filterRequestHeaders()
   - 保持向后兼容性

3. 更新 src/services/claudeRelayService.js
   - 使用 filterForClaude() 替代 _filterClientHeaders() 实现
   - 简化代码,移除重复的 header 列表定义

4. 修复 src/routes/openaiRoutes.js
   - 添加对 input 字段的类型检查(可以是数组或字符串)
   - 防止 "startsWith is not a function" 错误

x-real-ip, x-forwarded-for, x-forwarded-proto, x-forwarded-host,
x-forwarded-port, x-accel-buffering, cf-ray, cf-connecting-ip,
cf-ipcountry, cf-visitor, cf-request-id, cdn-loop, true-client-ip

-  Codex CLI 通过中转服务成功调用 88code API(之前返回 403)
-  保留所有业务必需的 headers(conversation_id、session_id 等)
-  移除所有 Cloudflare CDN 痕迹
-  保持橙色云的 DDoS 防护和 CDN 加速优势
-  Docker 构建成功

1. 解决 88code 403 问题,Codex CLI 可正常使用
2. 降低因 CDN/代理特征被上游 API 识别的风险
3. 提升与各种 API 提供商的兼容性
4. 统一管理 CDN headers 过滤逻辑,便于维护
2025-12-03 07:07:12 -08:00
atoz03
9ad5c85c2c feat(accounts): 支持排序切换正序/倒序
- 统一下拉选择器和表头的排序变量
  - 再次点击同一排序选项/列头时切换排序方向
  - 动态更新排序图标指示当前方向
2025-12-03 20:25:26 +08:00
github-actions[bot]
279cd72f23 chore: sync VERSION file with release v1.1.218 [skip ci] 2025-12-02 12:52:01 +00:00
shaw
81e89d2dc4 feat: 支持sessionKey完成oauth授权 2025-12-02 20:43:47 +08:00
github-actions[bot]
c38b3d2a78 chore: sync VERSION file with release v1.1.217 [skip ci] 2025-12-01 07:13:46 +00:00
shaw
e8e6f972b4 fix: 增强console账号test端点 2025-12-01 15:08:40 +08:00
shaw
d3155b82ea style: 优化表格布局 2025-12-01 14:20:53 +08:00
shaw
02018e10f3 feat: 为console类型账号增加count_tokens端点判断 2025-12-01 10:14:12 +08:00
29 changed files with 9791 additions and 1098 deletions

View File

@@ -1 +1 @@
1.1.216
1.1.222

8
package-lock.json generated
View File

@@ -890,6 +890,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2998,6 +2999,7 @@
"integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3079,6 +3081,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3534,6 +3537,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -4421,6 +4425,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4477,6 +4482,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7575,6 +7581,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9014,6 +9021,7 @@
"resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz",
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2",

6357
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -449,9 +449,8 @@ async function handleMessages(req, res) {
// 添加代理配置
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -732,9 +731,8 @@ async function handleModels(req, res) {
headers: { 'Content-Type': 'application/json' }
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
const response = await axios(axiosConfig)
models = (response.data.models || []).map((m) => ({
@@ -1234,9 +1232,8 @@ async function handleCountTokens(req, res) {
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -1963,9 +1960,8 @@ async function handleStandardGenerateContent(req, res) {
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {
@@ -2246,9 +2242,8 @@ async function handleStandardStreamGenerateContent(req, res) {
}
if (proxyConfig) {
const proxyHelper = new ProxyHelper()
axiosConfig.httpsAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = proxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig)
axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig)
}
try {

View File

@@ -255,6 +255,108 @@ router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, asy
}
})
// =============================================================================
// Cookie自动授权端点 (基于sessionKey自动完成OAuth流程)
// =============================================================================
// 普通OAuth的Cookie自动授权
router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req, res) => {
try {
const { sessionKey, proxy } = req.body
// 验证sessionKey参数
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'sessionKey不能为空',
message: '请提供有效的sessionKey值'
})
}
const trimmedSessionKey = sessionKey.trim()
logger.info('🍪 Starting Cookie-based OAuth authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
hasProxy: !!proxy
})
// 执行Cookie自动授权流程
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, false)
logger.success('🎉 Cookie-based OAuth authorization completed successfully')
return res.json({
success: true,
data: {
claudeAiOauth: result.claudeAiOauth,
organizationUuid: result.organizationUuid,
capabilities: result.capabilities
}
})
} catch (error) {
logger.error('❌ Cookie-based OAuth authorization failed:', {
error: error.message,
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
})
return res.status(500).json({
success: false,
error: 'Cookie授权失败',
message: error.message
})
}
})
// Setup Token的Cookie自动授权
router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async (req, res) => {
try {
const { sessionKey, proxy } = req.body
// 验证sessionKey参数
if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) {
return res.status(400).json({
success: false,
error: 'sessionKey不能为空',
message: '请提供有效的sessionKey值'
})
}
const trimmedSessionKey = sessionKey.trim()
logger.info('🍪 Starting Cookie-based Setup Token authorization', {
sessionKeyLength: trimmedSessionKey.length,
sessionKeyPrefix: trimmedSessionKey.substring(0, 10) + '...',
hasProxy: !!proxy
})
// 执行Cookie自动授权流程Setup Token模式
const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, true)
logger.success('🎉 Cookie-based Setup Token authorization completed successfully')
return res.json({
success: true,
data: {
claudeAiOauth: result.claudeAiOauth,
organizationUuid: result.organizationUuid,
capabilities: result.capabilities
}
})
} catch (error) {
logger.error('❌ Cookie-based Setup Token authorization failed:', {
error: error.message,
sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0
})
return res.status(500).json({
success: false,
error: 'Cookie授权失败',
message: error.message
})
}
})
// 获取所有Claude账户
router.get('/claude-accounts', authenticateAdmin, async (req, res) => {
try {

View File

@@ -131,7 +131,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
groupId,
dailyQuota,
quotaResetTime,
maxConcurrentTasks
maxConcurrentTasks,
disableAutoProtection
} = req.body
if (!name || !apiUrl || !apiKey) {
@@ -151,6 +152,10 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
}
}
// 校验上游错误自动防护开关
const normalizedDisableAutoProtection =
disableAutoProtection === true || disableAutoProtection === 'true'
// 验证accountType的有效性
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
return res
@@ -180,7 +185,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
maxConcurrentTasks:
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
? Number(maxConcurrentTasks)
: 0
: 0,
disableAutoProtection: normalizedDisableAutoProtection
})
// 如果是分组类型将账户添加到分组CCR 归属 Claude 平台分组)
@@ -250,6 +256,13 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req,
return res.status(404).json({ error: 'Account not found' })
}
// 规范化上游错误自动防护开关
if (mappedUpdates.disableAutoProtection !== undefined) {
mappedUpdates.disableAutoProtection =
mappedUpdates.disableAutoProtection === true ||
mappedUpdates.disableAutoProtection === 'true'
}
// 处理分组的变更
if (mappedUpdates.accountType !== undefined) {
// 如果之前是分组类型,需要从所有分组中移除

View File

@@ -1,8 +1,10 @@
const express = require('express')
const apiKeyService = require('../../services/apiKeyService')
const ccrAccountService = require('../../services/ccrAccountService')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const geminiApiAccountService = require('../../services/geminiApiAccountService')
const openaiAccountService = require('../../services/openaiAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const droidAccountService = require('../../services/droidAccountService')
@@ -148,7 +150,6 @@ router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req,
accountData = await geminiAccountService.getAccount(accountId)
break
case 'gemini-api': {
const geminiApiAccountService = require('../../services/geminiApiAccountService')
accountData = await geminiApiAccountService.getAccount(accountId)
break
}
@@ -369,7 +370,9 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
logger.info(` endDate (raw): ${endDate}`)
logger.info(` startTime (parsed): ${startTime.toISOString()}`)
logger.info(` endTime (parsed): ${endTime.toISOString()}`)
logger.info(` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`)
logger.info(
` System timezone offset: ${require('../../../config/config').system.timezoneOffset || 8}`
)
} else {
// 默认最近24小时
endTime = new Date()
@@ -890,7 +893,6 @@ router.get('/account-usage-trend', authenticateAdmin, async (req, res) => {
})
]
} else if (group === 'gemini') {
const geminiApiAccountService = require('../../services/geminiApiAccountService')
const [geminiAccounts, geminiApiAccounts] = await Promise.all([
geminiAccountService.getAllAccounts(),
geminiApiAccountService.getAllAccounts(true)
@@ -1818,4 +1820,330 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
}
})
// 获取 API Key 的请求记录时间线
router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) => {
try {
const { keyId } = req.params
const {
page = 1,
pageSize = 50,
startDate,
endDate,
model,
accountId,
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 apiKeyInfo = await redis.getApiKey(keyId)
if (!apiKeyInfo || Object.keys(apiKeyInfo).length === 0) {
return res.status(404).json({ success: false, error: 'API key not found' })
}
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) },
{ type: 'ccr', getter: (id) => ccrAccountService.getAccount(id) },
{ type: 'openai', getter: (id) => openaiAccountService.getAccount(id) },
{ type: 'openai-responses', getter: (id) => openaiResponsesAccountService.getAccount(id) },
{ type: 'gemini', getter: (id) => geminiAccountService.getAccount(id) },
{ type: 'gemini-api', getter: (id) => geminiApiAccountService.getAccount(id) },
{ type: 'droid', getter: (id) => droidAccountService.getAccount(id) }
]
const accountCache = new Map()
const resolveAccountInfo = async (id, type) => {
if (!id) {
return null
}
const cacheKey = `${type || 'any'}:${id}`
if (accountCache.has(cacheKey)) {
return accountCache.get(cacheKey)
}
const servicesToTry = type
? accountServices.filter((svc) => svc.type === type)
: accountServices
for (const service of servicesToTry) {
try {
const account = await service.getter(id)
if (account) {
const info = {
id,
name: account.name || account.email || id,
type: service.type,
status: account.status || account.isActive
}
accountCache.set(cacheKey, info)
return info
}
} catch (error) {
logger.debug(`⚠️ Failed to resolve account ${id} via ${service.type}: ${error.message}`)
}
}
accountCache.set(cacheKey, null)
return null
}
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 = rawRecords.filter((record) => {
if (!withinRange(record)) {
return false
}
if (model && record.model !== model) {
return false
}
if (accountId && record.accountId !== accountId) {
return false
}
return true
})
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
}
const modelSet = new Set()
const accountOptionMap = new Map()
let earliestTimestamp = null
let latestTimestamp = null
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
if (record.model) {
modelSet.add(record.model)
}
if (record.accountId) {
const key = `${record.accountId}:${record.accountType || 'unknown'}`
if (!accountOptionMap.has(key)) {
accountOptionMap.set(key, {
id: record.accountId,
accountType: record.accountType || 'unknown'
})
}
}
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
}
}
}
}
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
const accountInfo = await resolveAccountInfo(record.accountId, record.accountType)
const resolvedAccountType = accountInfo?.type || record.accountType || 'unknown'
enrichedRecords.push({
timestamp: record.timestamp,
model: record.model || 'unknown',
accountId: record.accountId || null,
accountName: accountInfo?.name || null,
accountStatus: accountInfo?.status ?? null,
accountType: resolvedAccountType,
accountTypeName: accountTypeNames[resolvedAccountType] || '未知渠道',
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
})
}
const accountOptions = []
for (const option of accountOptionMap.values()) {
const info = await resolveAccountInfo(option.id, option.accountType)
const resolvedType = info?.type || option.accountType || 'unknown'
accountOptions.push({
id: option.id,
name: info?.name || option.id,
accountType: resolvedType,
accountTypeName: accountTypeNames[resolvedType] || '未知渠道'
})
}
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,
accountId: accountId || null,
sortOrder: normalizedSortOrder
},
apiKeyInfo: {
id: keyId,
name: apiKeyInfo.name || apiKeyInfo.label || keyId
},
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),
accounts: accountOptions,
dateRange: {
earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null,
latest: latestTimestamp ? latestTimestamp.toISOString() : null
}
}
}
})
} catch (error) {
logger.error('❌ Failed to get API key usage records:', error)
return res
.status(500)
.json({ error: 'Failed to get API key usage records', message: error.message })
}
})
module.exports = router

View File

@@ -33,7 +33,9 @@ function mapExpiryField(updates, accountType, accountId) {
if ('expiresAt' in mappedUpdates) {
mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt
delete mappedUpdates.expiresAt
logger.info(`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`)
logger.info(
`Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}`
)
}
return mappedUpdates
}

View File

@@ -972,6 +972,9 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
const maxAttempts = 2
let attempt = 0
// 引入 claudeConsoleAccountService 用于检查 count_tokens 可用性
const claudeConsoleAccountService = require('../services/claudeConsoleAccountService')
const processRequest = async () => {
const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey(
req.apiKey,
@@ -1003,6 +1006,17 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
})
}
// 🔍 claude-console 账户特殊处理:检查 count_tokens 端点是否可用
if (accountType === 'claude-console') {
const isUnavailable = await claudeConsoleAccountService.isCountTokensUnavailable(accountId)
if (isUnavailable) {
logger.info(
`⏭️ count_tokens unavailable for Claude Console account ${accountId}, returning fallback response`
)
return { fallbackResponse: true }
}
}
const relayOptions = {
skipUsageRecord: true,
customPath: '/v1/messages/count_tokens'
@@ -1028,6 +1042,23 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
relayOptions
)
// 🔍 claude-console 账户:检测上游 404 响应并标记
if (accountType === 'claude-console' && response.statusCode === 404) {
logger.warn(
`⚠️ count_tokens endpoint returned 404 for Claude Console account ${accountId}, marking as unavailable`
)
// 标记失败不应影响 fallback 响应
try {
await claudeConsoleAccountService.markCountTokensUnavailable(accountId)
} catch (markError) {
logger.error(
`❌ Failed to mark count_tokens unavailable for account ${accountId}, but will still return fallback:`,
markError
)
}
return { fallbackResponse: true }
}
res.status(response.statusCode)
const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length']
@@ -1050,11 +1081,21 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
}
logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`)
return { fallbackResponse: false }
}
while (attempt < maxAttempts) {
try {
await processRequest()
const result = await processRequest()
// 🔍 处理 fallback 响应claude-console 账户 count_tokens 不可用)
if (result && result.fallbackResponse) {
if (!res.headersSent) {
return res.status(200).json({ input_tokens: 0 })
}
return
}
return
} catch (error) {
if (error.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') {

View File

@@ -5,6 +5,7 @@ const apiKeyService = require('../services/apiKeyService')
const CostCalculator = require('../utils/costCalculator')
const claudeAccountService = require('../services/claudeAccountService')
const openaiAccountService = require('../services/openaiAccountService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
const router = express.Router()
@@ -792,8 +793,8 @@ router.post('/api/batch-model-stats', async (req, res) => {
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
router.post('/api-key/test', async (req, res) => {
const axios = require('axios')
const config = require('../../config/config')
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
try {
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
@@ -805,7 +806,6 @@ router.post('/api-key/test', async (req, res) => {
})
}
// 基本格式验证
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
return res.status(400).json({
error: 'Invalid API key format',
@@ -813,7 +813,6 @@ router.post('/api-key/test', async (req, res) => {
})
}
// 首先验证API Key是否有效不触发激活
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
if (!validation.valid) {
return res.status(401).json({
@@ -824,244 +823,29 @@ router.post('/api-key/test', async (req, res) => {
logger.api(`🧪 API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`)
// 设置SSE响应头
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
// 发送测试开始事件
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
// 构建测试请求,模拟 Claude CLI 客户端
const port = config.server.port || 3000
const baseURL = `http://127.0.0.1:${port}`
const apiUrl = `http://127.0.0.1:${port}/api/v1/messages?beta=true`
const testPayload = {
model,
messages: [
{
role: 'user',
content: 'hi'
}
],
system: [
{
type: 'text',
text: "You are Claude Code, Anthropic's official CLI for Claude."
}
],
max_tokens: 32000,
temperature: 1,
stream: true
}
const headers = {
'Content-Type': 'application/json',
'User-Agent': 'claude-cli/2.0.52 (external, cli)',
'x-api-key': apiKey,
'anthropic-version': config.claude.apiVersion || '2023-06-01'
}
// 向自身服务发起测试请求
// 使用 validateStatus 允许所有状态码通过,以便我们可以处理流式错误响应
const response = await axios.post(`${baseURL}/api/v1/messages`, testPayload, {
headers,
responseType: 'stream',
timeout: 60000, // 60秒超时
validateStatus: () => true // 接受所有状态码,自行处理错误
})
// 检查响应状态码如果不是2xx尝试读取错误信息
if (response.status >= 400) {
logger.error(
`🧪 API Key test received error status ${response.status} for: ${validation.keyData.name}`
)
// 尝试从流中读取错误信息
let errorBody = ''
for await (const chunk of response.data) {
errorBody += chunk.toString()
}
let errorMessage = `HTTP ${response.status}`
try {
// 尝试解析SSE格式的错误
const lines = errorBody.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.substring(6).trim()
if (dataStr && dataStr !== '[DONE]') {
const data = JSON.parse(dataStr)
if (data.error?.message) {
errorMessage = data.error.message
break
} else if (data.message) {
errorMessage = data.message
break
} else if (typeof data.error === 'string') {
errorMessage = data.error
break
}
}
}
}
// 如果不是SSE格式尝试直接解析JSON
if (errorMessage === `HTTP ${response.status}`) {
const jsonError = JSON.parse(errorBody)
errorMessage =
jsonError.error?.message || jsonError.message || jsonError.error || errorMessage
}
} catch {
// 解析失败,使用原始错误体或默认消息
if (errorBody && errorBody.length < 500) {
errorMessage = errorBody
}
}
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`)
res.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMessage })}\n\n`
)
res.end()
return
}
let receivedContent = ''
let testSuccess = false
let upstreamError = null
// 处理流式响应
response.data.on('data', (chunk) => {
const lines = chunk.toString().split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.substring(6).trim()
if (dataStr === '[DONE]') {
continue
}
try {
const data = JSON.parse(dataStr)
// 检查上游返回的错误事件
if (data.type === 'error' || data.error) {
let errorMsg = 'Unknown upstream error'
// 优先从 data.error 提取(如果是对象,获取其 message
if (typeof data.error === 'object' && data.error?.message) {
errorMsg = data.error.message
} else if (typeof data.error === 'string' && data.error !== 'Claude API error') {
// 如果 error 是字符串且不是通用错误,直接使用
errorMsg = data.error
} else if (data.details) {
// 尝试从 details 字段解析详细错误claudeRelayService 格式)
try {
const details =
typeof data.details === 'string' ? JSON.parse(data.details) : data.details
if (details.error?.message) {
errorMsg = details.error.message
} else if (details.message) {
errorMsg = details.message
}
} catch {
// details 不是有效 JSON尝试直接使用
if (typeof data.details === 'string' && data.details.length < 500) {
errorMsg = data.details
}
}
} else if (data.message) {
errorMsg = data.message
}
// 添加状态码信息(如果有)
if (data.status && errorMsg !== 'Unknown upstream error') {
errorMsg = `[${data.status}] ${errorMsg}`
}
upstreamError = errorMsg
logger.error(`🧪 Upstream error in test for: ${validation.keyData.name}:`, errorMsg)
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
continue
}
// 提取文本内容
if (data.type === 'content_block_delta' && data.delta?.text) {
receivedContent += data.delta.text
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`)
}
// 消息结束
if (data.type === 'message_stop') {
testSuccess = true
res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`)
}
} catch {
// 忽略解析错误
}
}
}
})
response.data.on('end', () => {
// 如果有上游错误,标记为失败
if (upstreamError) {
testSuccess = false
}
logger.api(
`🧪 API Key test completed for: ${validation.keyData.name}, success: ${testSuccess}, content length: ${receivedContent.length}${upstreamError ? `, error: ${upstreamError}` : ''}`
)
res.write(
`data: ${JSON.stringify({
type: 'test_complete',
success: testSuccess,
contentLength: receivedContent.length,
error: upstreamError || undefined
})}\n\n`
)
res.end()
})
response.data.on('error', (err) => {
logger.error(`🧪 API Key test stream error for: ${validation.keyData.name}`, err)
// 如果已经捕获了上游错误,优先使用那个
let errorMsg = upstreamError || err.message || 'Stream error'
// 如果错误消息是通用的 "Claude API error: xxx",提供更友好的提示
if (errorMsg.startsWith('Claude API error:') && upstreamError) {
errorMsg = upstreamError
}
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
res.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
)
res.end()
})
// 处理客户端断开连接
req.on('close', () => {
if (!res.writableEnded) {
response.data.destroy()
}
await sendStreamTestRequest({
apiUrl,
authorization: apiKey,
responseStream: res,
payload: createClaudeTestPayload(model, { stream: true }),
timeout: 60000,
extraHeaders: { 'x-api-key': apiKey }
})
} catch (error) {
logger.error('❌ API Key test failed:', error)
// 如果还未发送响应头返回JSON错误
if (!res.headersSent) {
return res.status(500).json({
error: 'Test failed',
message: error.response?.data?.error?.message || error.message || 'Internal server error'
message: error.message || 'Internal server error'
})
}
// 如果已经是SSE流发送错误事件
res.write(
`data: ${JSON.stringify({ type: 'error', error: error.response?.data?.error?.message || error.message || 'Test failed' })}\n\n`
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
)
res.end()
}

View File

@@ -67,7 +67,8 @@ class ClaudeConsoleAccountService {
schedulable = true, // 是否可被调度
dailyQuota = 0, // 每日额度限制美元0表示不限制
quotaResetTime = '00:00', // 额度重置时间HH:mm格式
maxConcurrentTasks = 0 // 最大并发任务数0表示无限制
maxConcurrentTasks = 0, // 最大并发任务数0表示无限制
disableAutoProtection = false // 是否关闭自动防护429/401/400/529 不自动禁用)
} = options
// 验证必填字段
@@ -115,7 +116,8 @@ class ClaudeConsoleAccountService {
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
quotaResetTime, // 额度重置时间
quotaStoppedAt: '', // 因额度停用的时间
maxConcurrentTasks: maxConcurrentTasks.toString() // 最大并发任务数0表示无限制
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数0表示无限制
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
}
const client = redis.getClientSafe()
@@ -153,6 +155,7 @@ class ClaudeConsoleAccountService {
quotaResetTime,
quotaStoppedAt: null,
maxConcurrentTasks, // 新增:返回并发限制配置
disableAutoProtection, // 新增:返回自动防护开关
activeTaskCount: 0 // 新增新建账户当前并发数为0
}
}
@@ -213,7 +216,8 @@ class ClaudeConsoleAccountService {
// 并发控制相关
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
activeTaskCount
activeTaskCount,
disableAutoProtection: accountData.disableAutoProtection === 'true'
})
}
}
@@ -259,6 +263,7 @@ class ClaudeConsoleAccountService {
}
accountData.isActive = accountData.isActive === 'true'
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
if (accountData.proxy) {
accountData.proxy = JSON.parse(accountData.proxy)
@@ -367,6 +372,9 @@ class ClaudeConsoleAccountService {
if (updates.maxConcurrentTasks !== undefined) {
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
}
if (updates.disableAutoProtection !== undefined) {
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
}
// ✅ 直接保存 subscriptionExpiresAt如果提供
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
@@ -1510,6 +1518,71 @@ class ClaudeConsoleAccountService {
const expiryDate = new Date(account.subscriptionExpiresAt)
return expiryDate <= new Date()
}
// 🚫 标记账户的 count_tokens 端点不可用
async markCountTokensUnavailable(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
// 检查账户是否存在
const exists = await client.exists(accountKey)
if (!exists) {
logger.warn(
`⚠️ Cannot mark count_tokens unavailable for non-existent account: ${accountId}`
)
return { success: false, reason: 'Account not found' }
}
await client.hset(accountKey, {
countTokensUnavailable: 'true',
countTokensUnavailableAt: new Date().toISOString()
})
logger.info(
`🚫 Marked count_tokens endpoint as unavailable for Claude Console account: ${accountId}`
)
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark count_tokens unavailable for account ${accountId}:`, error)
throw error
}
}
// ✅ 移除账户的 count_tokens 不可用标记
async removeCountTokensUnavailable(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
await client.hdel(accountKey, 'countTokensUnavailable', 'countTokensUnavailableAt')
logger.info(
`✅ Removed count_tokens unavailable mark for Claude Console account: ${accountId}`
)
return { success: true }
} catch (error) {
logger.error(
`❌ Failed to remove count_tokens unavailable mark for account ${accountId}:`,
error
)
throw error
}
}
// 🔍 检查账户的 count_tokens 端点是否不可用
async isCountTokensUnavailable(accountId) {
try {
const client = redis.getClientSafe()
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
const value = await client.hget(accountKey, 'countTokensUnavailable')
return value === 'true'
} catch (error) {
logger.error(`❌ Failed to check count_tokens availability for account ${accountId}:`, error)
return false // 出错时默认返回可用,避免误阻断
}
}
}
module.exports = new ClaudeConsoleAccountService()

View File

@@ -37,6 +37,8 @@ class ClaudeConsoleRelayService {
throw new Error('Claude Console Claude account not found')
}
const autoProtectionDisabled = account.disableAutoProtection === true
logger.info(
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
)
@@ -248,27 +250,41 @@ class ClaudeConsoleRelayService {
// 检查错误状态并相应处理
if (response.status === 401) {
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
logger.warn(
`🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
}
} else if (accountDisabledError) {
logger.error(
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 传入完整的错误详情到 webhook
const errorDetails =
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
}
} else if (response.status === 429) {
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
logger.warn(
`🚫 Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 收到429先检查是否因为超过了手动配置的每日额度
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
await claudeConsoleAccountService.markAccountRateLimited(accountId)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
}
} else if (response.status === 529) {
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
await claudeConsoleAccountService.markAccountOverloaded(accountId)
logger.warn(
`🚫 Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
}
} else if (response.status === 200 || response.status === 201) {
// 如果请求成功,检查并移除错误状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
@@ -597,6 +613,7 @@ class ClaudeConsoleRelayService {
})
response.data.on('end', async () => {
const autoProtectionDisabled = account.disableAutoProtection === true
// 记录原始错误消息到日志(方便调试,包含供应商信息)
logger.error(
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
@@ -609,24 +626,41 @@ class ClaudeConsoleRelayService {
)
if (response.status === 401) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
logger.warn(
`🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
}
} else if (accountDisabledError) {
logger.error(
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 传入完整的错误详情到 webhook
await claudeConsoleAccountService.markConsoleAccountBlocked(
accountId,
errorDataForCheck
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markConsoleAccountBlocked(
accountId,
errorDataForCheck
)
}
} else if (response.status === 429) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
logger.warn(
`🚫 [Stream] Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
// 检查是否因为超过每日额度
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
logger.error('❌ Failed to check quota after 429 error:', err)
})
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountRateLimited(accountId)
}
} else if (response.status === 529) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
logger.warn(
`🚫 [Stream] Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
)
if (!autoProtectionDisabled) {
await claudeConsoleAccountService.markAccountOverloaded(accountId)
}
}
// 设置响应头
@@ -1113,55 +1147,11 @@ class ClaudeConsoleRelayService {
}
}
// 🧪 测试账号连接供Admin API使用独立处理以确保错误时也返回SSE格式
// 🧪 测试账号连接供Admin API使用
async testAccountConnection(accountId, responseStream) {
const testRequestBody = {
model: 'claude-sonnet-4-5-20250929',
max_tokens: 32000,
stream: true,
messages: [
{
role: 'user',
content: 'hi'
}
],
system: [
{
type: 'text',
text: "You are Claude Code, Anthropic's official CLI for Claude."
}
]
}
// 辅助函数:发送 SSE 事件
const sendSSEEvent = (type, data) => {
if (!responseStream.destroyed && !responseStream.writableEnded) {
try {
responseStream.write(`data: ${JSON.stringify({ type, ...data })}\n\n`)
} catch {
// 忽略写入错误
}
}
}
// 辅助函数:结束测试并关闭流
const endTest = (success, error = null) => {
if (!responseStream.destroyed && !responseStream.writableEnded) {
try {
if (success) {
sendSSEEvent('test_complete', { success: true })
} else {
sendSSEEvent('test_complete', { success: false, error: error || '测试失败' })
}
responseStream.end()
} catch {
// 忽略写入错误
}
}
}
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
try {
// 获取账户信息
const account = await claudeConsoleAccountService.getAccount(accountId)
if (!account) {
throw new Error('Account not found')
@@ -1169,178 +1159,32 @@ class ClaudeConsoleRelayService {
logger.info(`🧪 Testing Claude Console account connection: ${account.name} (${accountId})`)
// 创建代理agent
const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy)
// 设置响应头
if (!responseStream.headersSent) {
responseStream.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
})
}
// 发送测试开始事件
sendSSEEvent('test_start', {})
// 构建完整的API URL
const cleanUrl = account.apiUrl.replace(/\/$/, '')
const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages`
const apiUrl = cleanUrl.endsWith('/v1/messages')
? cleanUrl
: `${cleanUrl}/v1/messages?beta=true`
// 决定使用的 User-Agent
const userAgent = account.userAgent || this.defaultUserAgent
// 准备请求配置
const requestConfig = {
method: 'POST',
url: apiEndpoint,
data: testRequestBody,
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'User-Agent': userAgent
},
timeout: 30000, // 测试请求使用较短超时
responseType: 'stream',
validateStatus: () => true
}
if (proxyAgent) {
requestConfig.httpAgent = proxyAgent
requestConfig.httpsAgent = proxyAgent
requestConfig.proxy = false
}
// 设置认证方式
if (account.apiKey && account.apiKey.startsWith('sk-ant-')) {
requestConfig.headers['x-api-key'] = account.apiKey
} else {
requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}`
}
// 发送请求
const response = await axios(requestConfig)
logger.debug(`🌊 Claude Console test response status: ${response.status}`)
// 处理非200响应
if (response.status !== 200) {
logger.error(
`❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}`
)
// 收集错误响应数据
return new Promise((resolve) => {
const errorChunks = []
response.data.on('data', (chunk) => {
errorChunks.push(chunk)
})
response.data.on('end', () => {
try {
const fullErrorData = Buffer.concat(errorChunks).toString()
logger.error(
`📝 [Test] Upstream error response from ${account?.name || accountId}: ${fullErrorData.substring(0, 500)}`
)
// 尝试解析错误信息
let errorMessage = `API Error: ${response.status}`
try {
const errorJson = JSON.parse(fullErrorData)
// 直接提取所有可能的错误信息字段
errorMessage =
errorJson.message ||
errorJson.error?.message ||
errorJson.statusMessage ||
errorJson.error ||
(typeof errorJson === 'string' ? errorJson : JSON.stringify(errorJson))
} catch {
errorMessage = fullErrorData.substring(0, 200) || `API Error: ${response.status}`
}
endTest(false, errorMessage)
resolve()
} catch {
endTest(false, `API Error: ${response.status}`)
resolve()
}
})
response.data.on('error', (err) => {
endTest(false, err.message || '流读取错误')
resolve()
})
})
}
// 处理成功的流式响应
return new Promise((resolve) => {
let buffer = ''
response.data.on('data', (chunk) => {
try {
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) {
continue
}
const jsonStr = line.substring(6).trim()
if (!jsonStr || jsonStr === '[DONE]') {
continue
}
try {
const data = JSON.parse(jsonStr)
// 转换 content_block_delta 为 content
if (data.type === 'content_block_delta' && data.delta && data.delta.text) {
sendSSEEvent('content', { text: data.delta.text })
}
// 处理消息完成
if (data.type === 'message_stop') {
endTest(true)
}
// 处理错误事件
if (data.type === 'error') {
const errorMsg = data.error?.message || data.message || '未知错误'
endTest(false, errorMsg)
}
} catch {
// 忽略解析错误
}
}
} catch {
// 忽略处理错误
}
})
response.data.on('end', () => {
logger.info(`✅ Test request completed for account: ${account.name}`)
// 如果还没结束,发送完成事件
if (!responseStream.destroyed && !responseStream.writableEnded) {
endTest(true)
}
resolve()
})
response.data.on('error', (err) => {
logger.error(`❌ Test stream error:`, err)
endTest(false, err.message || '流处理错误')
resolve()
})
await sendStreamTestRequest({
apiUrl,
authorization: `Bearer ${account.apiKey}`,
responseStream,
proxyAgent: claudeConsoleAccountService._createProxyAgent(account.proxy),
extraHeaders: account.userAgent ? { 'User-Agent': account.userAgent } : {}
})
} catch (error) {
logger.error(`❌ Test account connection failed:`, error)
endTest(false, error.message || '测试失败')
if (!responseStream.headersSent) {
responseStream.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
})
}
if (!responseStream.destroyed && !responseStream.writableEnded) {
responseStream.write(
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n`
)
responseStream.end()
}
}
}

View File

@@ -3,6 +3,7 @@ const zlib = require('zlib')
const fs = require('fs')
const path = require('path')
const ProxyHelper = require('../utils/proxyHelper')
const { filterForClaude } = require('../utils/headerFilter')
const claudeAccountService = require('./claudeAccountService')
const unifiedClaudeScheduler = require('./unifiedClaudeScheduler')
const sessionHelper = require('../utils/sessionHelper')
@@ -13,6 +14,7 @@ const redis = require('../models/redis')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const { formatDateWithTimezone } = require('../utils/dateHelper')
const requestIdentityService = require('./requestIdentityService')
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
class ClaudeRelayService {
constructor() {
@@ -876,62 +878,9 @@ class ClaudeRelayService {
// 🔧 过滤客户端请求头
_filterClientHeaders(clientHeaders) {
// 需要移除的敏感 headers
const sensitiveHeaders = [
'content-type',
'user-agent',
'x-api-key',
'authorization',
'x-authorization',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding'
]
// 🆕 需要移除的浏览器相关 headers避免CORS问题
const browserHeaders = [
'origin',
'referer',
'sec-fetch-mode',
'sec-fetch-site',
'sec-fetch-dest',
'sec-ch-ua',
'sec-ch-ua-mobile',
'sec-ch-ua-platform',
'accept-language',
'accept-encoding',
'accept',
'cache-control',
'pragma',
'anthropic-dangerous-direct-browser-access' // 这个头可能触发CORS检查
]
// 应该保留的 headers用于会话一致性和追踪
const allowedHeaders = [
'x-request-id',
'anthropic-version', // 保留API版本
'anthropic-beta' // 保留beta功能
]
const filteredHeaders = {}
// 转发客户端的非敏感 headers
Object.keys(clientHeaders || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
// 如果在允许列表中,直接保留
if (allowedHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
// 如果不在敏感列表和浏览器列表中,也保留
else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
filteredHeaders[key] = clientHeaders[key]
}
})
return filteredHeaders
// 使用统一的 headerFilter 工具类 - 移除 CDN、浏览器和代理相关 headers
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
return filterForClaude(clientHeaders)
}
_applyRequestIdentityTransform(body, headers, context = {}) {
@@ -2244,26 +2193,7 @@ class ClaudeRelayService {
// 🧪 测试账号连接供Admin API使用直接复用 _makeClaudeStreamRequestWithUsageCapture
async testAccountConnection(accountId, responseStream) {
const testRequestBody = {
model: 'claude-sonnet-4-5-20250929',
max_tokens: 100,
stream: true,
system: [
{
type: 'text',
text: this.claudeCodeSystemPrompt,
cache_control: {
type: 'ephemeral'
}
}
],
messages: [
{
role: 'user',
content: 'hi'
}
]
}
const testRequestBody = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true })
try {
// 获取账户信息

View File

@@ -1,6 +1,7 @@
const axios = require('axios')
const ProxyHelper = require('../utils/proxyHelper')
const logger = require('../utils/logger')
const { filterForOpenAI } = require('../utils/headerFilter')
const openaiResponsesAccountService = require('./openaiResponsesAccountService')
const apiKeyService = require('./apiKeyService')
const unifiedOpenAIScheduler = require('./unifiedOpenAIScheduler')
@@ -73,9 +74,9 @@ class OpenAIResponsesRelayService {
const targetUrl = `${fullAccount.baseApi}${req.path}`
logger.info(`🎯 Forwarding to: ${targetUrl}`)
// 构建请求头
// 构建请求头 - 使用统一的 headerFilter 移除 CDN headers
const headers = {
...this._filterRequestHeaders(req.headers),
...filterForOpenAI(req.headers),
Authorization: `Bearer ${fullAccount.apiKey}`,
'Content-Type': 'application/json'
}
@@ -810,29 +811,10 @@ class OpenAIResponsesRelayService {
return { resetsInSeconds, errorData }
}
// 过滤请求头
// 过滤请求头 - 已迁移到 headerFilter 工具类
// 此方法保留用于向后兼容,实际使用 filterForOpenAI()
_filterRequestHeaders(headers) {
const filtered = {}
const skipHeaders = [
'host',
'content-length',
'authorization',
'x-api-key',
'x-cr-api-key',
'connection',
'upgrade',
'sec-websocket-key',
'sec-websocket-version',
'sec-websocket-extensions'
]
for (const [key, value] of Object.entries(headers)) {
if (!skipHeaders.includes(key.toLowerCase())) {
filtered[key] = value
}
}
return filtered
return filterForOpenAI(headers)
}
// 估算费用(简化版本,实际应该根据不同的定价模型)

133
src/utils/headerFilter.js Normal file
View File

@@ -0,0 +1,133 @@
/**
* 统一的 CDN Headers 过滤列表
*
* 用于各服务在原有过滤逻辑基础上,额外移除 Cloudflare CDN 和代理相关的 headers
* 避免触发上游 API如 88code的安全检查
*/
// Cloudflare CDN headers橙色云代理模式会添加这些
const cdnHeaders = [
'x-real-ip',
'x-forwarded-for',
'x-forwarded-proto',
'x-forwarded-host',
'x-forwarded-port',
'x-accel-buffering',
'cf-ray',
'cf-connecting-ip',
'cf-ipcountry',
'cf-visitor',
'cf-request-id',
'cdn-loop',
'true-client-ip'
]
/**
* 为 OpenAI/Responses API 过滤 headers
* 在原有 skipHeaders 基础上添加 CDN headers
*/
function filterForOpenAI(headers) {
const skipHeaders = [
'host',
'content-length',
'authorization',
'x-api-key',
'x-cr-api-key',
'connection',
'upgrade',
'sec-websocket-key',
'sec-websocket-version',
'sec-websocket-extensions',
...cdnHeaders // 添加 CDN headers
]
const filtered = {}
for (const [key, value] of Object.entries(headers)) {
if (!skipHeaders.includes(key.toLowerCase())) {
filtered[key] = value
}
}
return filtered
}
/**
* 为 Claude/Anthropic API 过滤 headers
* 在原有逻辑基础上添加 CDN headers 到敏感列表
*/
function filterForClaude(headers) {
const sensitiveHeaders = [
'content-type',
'user-agent',
'x-api-key',
'authorization',
'x-authorization',
'host',
'content-length',
'connection',
'proxy-authorization',
'content-encoding',
'transfer-encoding',
...cdnHeaders // 添加 CDN headers
]
const browserHeaders = [
'origin',
'referer',
'sec-fetch-mode',
'sec-fetch-site',
'sec-fetch-dest',
'sec-ch-ua',
'sec-ch-ua-mobile',
'sec-ch-ua-platform',
'accept-language',
'accept-encoding',
'accept',
'cache-control',
'pragma',
'anthropic-dangerous-direct-browser-access'
]
const allowedHeaders = ['x-request-id', 'anthropic-version', 'anthropic-beta']
const filtered = {}
Object.keys(headers || {}).forEach((key) => {
const lowerKey = key.toLowerCase()
if (allowedHeaders.includes(lowerKey)) {
filtered[key] = headers[key]
} else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
filtered[key] = headers[key]
}
})
return filtered
}
/**
* 为 Gemini API 过滤 headers如果需要转发客户端 headers 时使用)
* 目前 Gemini 服务不转发客户端 headers仅提供此方法备用
*/
function filterForGemini(headers) {
const skipHeaders = [
'host',
'content-length',
'authorization',
'x-api-key',
'connection',
...cdnHeaders // 添加 CDN headers
]
const filtered = {}
for (const [key, value] of Object.entries(headers)) {
if (!skipHeaders.includes(key.toLowerCase())) {
filtered[key] = value
}
}
return filtered
}
module.exports = {
cdnHeaders,
filterForOpenAI,
filterForClaude,
filterForGemini
}

View File

@@ -18,6 +18,13 @@ const OAUTH_CONFIG = {
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
}
// Cookie自动授权配置常量
const COOKIE_OAUTH_CONFIG = {
CLAUDE_AI_URL: 'https://claude.ai',
ORGANIZATIONS_URL: 'https://claude.ai/api/organizations',
AUTHORIZE_URL_TEMPLATE: 'https://claude.ai/v1/oauth/{organization_uuid}/authorize'
}
/**
* 生成随机的 state 参数
* @returns {string} 随机生成的 state (base64url编码)
@@ -570,8 +577,299 @@ function extractExtInfo(data) {
return Object.keys(ext).length > 0 ? ext : null
}
// =============================================================================
// Cookie自动授权相关方法 (基于Clove项目实现)
// =============================================================================
/**
* 构建带Cookie的请求头
* @param {string} sessionKey - sessionKey值
* @returns {object} 请求头对象
*/
function buildCookieHeaders(sessionKey) {
return {
Accept: 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache',
Cookie: `sessionKey=${sessionKey}`,
Origin: COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL,
Referer: `${COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL}/new`,
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
}
/**
* 使用Cookie获取组织UUID和能力列表
* @param {string} sessionKey - sessionKey值
* @param {object|null} proxyConfig - 代理配置(可选)
* @returns {Promise<{organizationUuid: string, capabilities: string[]}>}
*/
async function getOrganizationInfo(sessionKey, proxyConfig = null) {
const headers = buildCookieHeaders(sessionKey)
const agent = createProxyAgent(proxyConfig)
try {
if (agent) {
logger.info(`🌐 Using proxy for organization info: ${ProxyHelper.maskProxyInfo(proxyConfig)}`)
}
logger.debug('🔄 Fetching organization info with Cookie', {
url: COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL,
hasProxy: !!proxyConfig
})
const axiosConfig = {
headers,
timeout: 30000,
maxRedirects: 0 // 禁止自动重定向以便检测Cloudflare拦截(302)
}
if (agent) {
axiosConfig.httpAgent = agent
axiosConfig.httpsAgent = agent
axiosConfig.proxy = false
}
const response = await axios.get(COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL, axiosConfig)
if (!response.data || !Array.isArray(response.data)) {
throw new Error('获取组织信息失败:响应格式无效')
}
// 找到具有chat能力且能力最多的组织
let bestOrg = null
let maxCapabilities = []
for (const org of response.data) {
const capabilities = org.capabilities || []
// 必须有chat能力
if (!capabilities.includes('chat')) {
continue
}
// 选择能力最多的组织
if (capabilities.length > maxCapabilities.length) {
bestOrg = org
maxCapabilities = capabilities
}
}
if (!bestOrg || !bestOrg.uuid) {
throw new Error('未找到具有chat能力的组织')
}
logger.success('✅ Found organization', {
uuid: bestOrg.uuid,
capabilities: maxCapabilities
})
return {
organizationUuid: bestOrg.uuid,
capabilities: maxCapabilities
}
} catch (error) {
if (error.response) {
const { status } = error.response
if (status === 403 || status === 401) {
throw new Error('Cookie授权失败无效的sessionKey或已过期')
}
if (status === 302) {
throw new Error('请求被Cloudflare拦截请稍后重试')
}
throw new Error(`获取组织信息失败HTTP ${status}`)
} else if (error.request) {
throw new Error('获取组织信息失败:网络错误或超时')
}
throw error
}
}
/**
* 使用Cookie自动获取授权code
* @param {string} sessionKey - sessionKey值
* @param {string} organizationUuid - 组织UUID
* @param {string} scope - 授权scope
* @param {object|null} proxyConfig - 代理配置(可选)
* @returns {Promise<{authorizationCode: string, codeVerifier: string, state: string}>}
*/
async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyConfig = null) {
// 生成PKCE参数
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()
// 构建授权URL
const authorizeUrl = COOKIE_OAUTH_CONFIG.AUTHORIZE_URL_TEMPLATE.replace(
'{organization_uuid}',
organizationUuid
)
// 构建请求payload
const payload = {
response_type: 'code',
client_id: OAUTH_CONFIG.CLIENT_ID,
organization_uuid: organizationUuid,
redirect_uri: OAUTH_CONFIG.REDIRECT_URI,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
}
const headers = {
...buildCookieHeaders(sessionKey),
'Content-Type': 'application/json'
}
const agent = createProxyAgent(proxyConfig)
try {
if (agent) {
logger.info(
`🌐 Using proxy for Cookie authorization: ${ProxyHelper.maskProxyInfo(proxyConfig)}`
)
}
logger.debug('🔄 Requesting authorization with Cookie', {
url: authorizeUrl,
scope,
hasProxy: !!proxyConfig
})
const axiosConfig = {
headers,
timeout: 30000,
maxRedirects: 0 // 禁止自动重定向以便检测Cloudflare拦截(302)
}
if (agent) {
axiosConfig.httpAgent = agent
axiosConfig.httpsAgent = agent
axiosConfig.proxy = false
}
const response = await axios.post(authorizeUrl, payload, axiosConfig)
// 从响应中获取redirect_uri
const redirectUri = response.data?.redirect_uri
if (!redirectUri) {
throw new Error('授权响应中未找到redirect_uri')
}
logger.debug('📎 Got redirect URI', { redirectUri: `${redirectUri.substring(0, 80)}...` })
// 解析redirect_uri获取authorization code
const url = new URL(redirectUri)
const authorizationCode = url.searchParams.get('code')
const responseState = url.searchParams.get('state')
if (!authorizationCode) {
throw new Error('redirect_uri中未找到授权码')
}
// 构建完整的授权码包含state如果有的话
const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode
logger.success('✅ Got authorization code via Cookie', {
codeLength: authorizationCode.length,
codePrefix: `${authorizationCode.substring(0, 10)}...`
})
return {
authorizationCode: fullCode,
codeVerifier,
state
}
} catch (error) {
if (error.response) {
const { status } = error.response
if (status === 403 || status === 401) {
throw new Error('Cookie授权失败无效的sessionKey或已过期')
}
if (status === 302) {
throw new Error('请求被Cloudflare拦截请稍后重试')
}
const errorData = error.response.data
let errorMessage = `HTTP ${status}`
if (errorData) {
if (typeof errorData === 'string') {
errorMessage += `: ${errorData}`
} else if (errorData.error) {
errorMessage += `: ${errorData.error}`
}
}
throw new Error(`授权请求失败:${errorMessage}`)
} else if (error.request) {
throw new Error('授权请求失败:网络错误或超时')
}
throw error
}
}
/**
* 完整的Cookie自动授权流程
* @param {string} sessionKey - sessionKey值
* @param {object|null} proxyConfig - 代理配置(可选)
* @param {boolean} isSetupToken - 是否为Setup Token模式
* @returns {Promise<{claudeAiOauth: object, organizationUuid: string, capabilities: string[]}>}
*/
async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = false) {
logger.info('🍪 Starting Cookie-based OAuth flow', {
isSetupToken,
hasProxy: !!proxyConfig
})
// 步骤1获取组织信息
logger.debug('Step 1/3: Fetching organization info...')
const { organizationUuid, capabilities } = await getOrganizationInfo(sessionKey, proxyConfig)
// 步骤2确定scope并获取授权code
const scope = isSetupToken ? OAUTH_CONFIG.SCOPES_SETUP : 'user:profile user:inference'
logger.debug('Step 2/3: Getting authorization code...', { scope })
const { authorizationCode, codeVerifier, state } = await authorizeWithCookie(
sessionKey,
organizationUuid,
scope,
proxyConfig
)
// 步骤3交换token
logger.debug('Step 3/3: Exchanging token...')
const tokenData = isSetupToken
? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig)
: await exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig)
logger.success('✅ Cookie-based OAuth flow completed', {
isSetupToken,
organizationUuid,
hasAccessToken: !!tokenData.accessToken,
hasRefreshToken: !!tokenData.refreshToken
})
return {
claudeAiOauth: tokenData,
organizationUuid,
capabilities
}
}
module.exports = {
OAUTH_CONFIG,
COOKIE_OAUTH_CONFIG,
generateOAuthParams,
generateSetupTokenParams,
exchangeCodeForTokens,
@@ -584,5 +882,10 @@ module.exports = {
generateCodeChallenge,
generateAuthUrl,
generateSetupTokenAuthUrl,
createProxyAgent
createProxyAgent,
// Cookie自动授权相关方法
buildCookieHeaders,
getOrganizationInfo,
authorizeWithCookie,
oauthWithCookie
}

View File

@@ -0,0 +1,242 @@
const crypto = require('crypto')
/**
* 生成随机十六进制字符串
* @param {number} bytes - 字节数
* @returns {string} 十六进制字符串
*/
function randomHex(bytes = 32) {
return crypto.randomBytes(bytes).toString('hex')
}
/**
* 生成 Claude Code 风格的会话字符串
* @returns {string} 会话字符串,格式: user_{64位hex}_account__session_{uuid}
*/
function generateSessionString() {
const hex64 = randomHex(32) // 32 bytes => 64 hex characters
const uuid = crypto.randomUUID()
return `user_${hex64}_account__session_${uuid}`
}
/**
* 生成 Claude 测试请求体
* @param {string} model - 模型名称
* @param {object} options - 可选配置
* @param {boolean} options.stream - 是否流式默认false
* @returns {object} 测试请求体
*/
function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) {
const payload = {
model,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'hi',
cache_control: {
type: 'ephemeral'
}
}
]
}
],
system: [
{
type: 'text',
text: "You are Claude Code, Anthropic's official CLI for Claude.",
cache_control: {
type: 'ephemeral'
}
}
],
metadata: {
user_id: generateSessionString()
},
max_tokens: 21333,
temperature: 1
}
if (options.stream) {
payload.stream = true
}
return payload
}
/**
* 发送流式测试请求并处理SSE响应
* @param {object} options - 配置选项
* @param {string} options.apiUrl - API URL
* @param {string} options.authorization - Authorization header值
* @param {object} options.responseStream - Express响应流
* @param {object} [options.payload] - 请求体默认使用createClaudeTestPayload
* @param {object} [options.proxyAgent] - 代理agent
* @param {number} [options.timeout] - 超时时间默认30000
* @param {object} [options.extraHeaders] - 额外的请求头
* @returns {Promise<void>}
*/
async function sendStreamTestRequest(options) {
const axios = require('axios')
const logger = require('./logger')
const {
apiUrl,
authorization,
responseStream,
payload = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true }),
proxyAgent = null,
timeout = 30000,
extraHeaders = {}
} = options
const sendSSE = (type, data = {}) => {
if (!responseStream.destroyed && !responseStream.writableEnded) {
try {
responseStream.write(`data: ${JSON.stringify({ type, ...data })}\n\n`)
} catch {
// ignore
}
}
}
const endTest = (success, error = null) => {
if (!responseStream.destroyed && !responseStream.writableEnded) {
try {
responseStream.write(
`data: ${JSON.stringify({ type: 'test_complete', success, error: error || undefined })}\n\n`
)
responseStream.end()
} catch {
// ignore
}
}
}
// 设置响应头
if (!responseStream.headersSent) {
responseStream.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
})
}
sendSSE('test_start', { message: 'Test started' })
const requestConfig = {
method: 'POST',
url: apiUrl,
data: payload,
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'User-Agent': 'claude-cli/2.0.52 (external, cli)',
authorization,
...extraHeaders
},
timeout,
responseType: 'stream',
validateStatus: () => true
}
if (proxyAgent) {
requestConfig.httpAgent = proxyAgent
requestConfig.httpsAgent = proxyAgent
requestConfig.proxy = false
}
try {
const response = await axios(requestConfig)
logger.debug(`🌊 Test response status: ${response.status}`)
// 处理非200响应
if (response.status !== 200) {
return new Promise((resolve) => {
const chunks = []
response.data.on('data', (chunk) => chunks.push(chunk))
response.data.on('end', () => {
const errorData = Buffer.concat(chunks).toString()
let errorMsg = `API Error: ${response.status}`
try {
const json = JSON.parse(errorData)
errorMsg = json.message || json.error?.message || json.error || errorMsg
} catch {
if (errorData.length < 200) {
errorMsg = errorData || errorMsg
}
}
endTest(false, errorMsg)
resolve()
})
response.data.on('error', (err) => {
endTest(false, err.message)
resolve()
})
})
}
// 处理成功的流式响应
return new Promise((resolve) => {
let buffer = ''
response.data.on('data', (chunk) => {
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data:')) {
continue
}
const jsonStr = line.substring(5).trim()
if (!jsonStr || jsonStr === '[DONE]') {
continue
}
try {
const data = JSON.parse(jsonStr)
if (data.type === 'content_block_delta' && data.delta?.text) {
sendSSE('content', { text: data.delta.text })
}
if (data.type === 'message_stop') {
sendSSE('message_stop')
}
if (data.type === 'error' || data.error) {
const errMsg = data.error?.message || data.message || data.error || 'Unknown error'
sendSSE('error', { error: errMsg })
}
} catch {
// ignore parse errors
}
}
})
response.data.on('end', () => {
if (!responseStream.destroyed && !responseStream.writableEnded) {
endTest(true)
}
resolve()
})
response.data.on('error', (err) => {
endTest(false, err.message)
resolve()
})
})
} catch (error) {
logger.error('❌ Stream test request failed:', error.message)
endTest(false, error.message)
}
}
module.exports = {
randomHex,
generateSessionString,
createClaudeTestPayload,
sendStreamTestRequest
}

View File

@@ -1157,6 +1157,7 @@
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -1351,6 +1352,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1587,6 +1589,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -3060,13 +3063,15 @@
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -3618,6 +3623,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3764,6 +3770,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -3789,7 +3796,7 @@
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.14",
"resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz",
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dev": true,
"license": "MIT",
@@ -4028,6 +4035,7 @@
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -4525,6 +4533,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4915,6 +4924,7 @@
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -5115,6 +5125,7 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",

View File

@@ -1451,6 +1451,26 @@
</p>
</div>
</div>
<!-- 上游错误处理 -->
<div v-if="form.platform === 'claude-console'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>上游错误处理</label
>
<label class="inline-flex cursor-pointer items-center">
<input
v-model="form.disableAutoProtection"
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
上游错误不自动暂停调度
</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div>
<!-- OpenAI-Responses 特定字段 -->
@@ -2029,6 +2049,7 @@
<!-- 步骤2: OAuth授权 -->
<OAuthFlow
v-if="oauthStep === 2 && form.addType === 'oauth'"
ref="oauthFlowRef"
:platform="form.platform"
:proxy="form.proxy"
@back="oauthStep = 1"
@@ -2052,11 +2073,45 @@
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">
Claude Setup Token 授权
</h4>
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
</p>
<div class="space-y-4">
<!-- 授权方式选择 -->
<div class="mb-4">
<p class="mb-3 text-sm font-medium text-blue-800 dark:text-blue-300">
选择授权方式:
</p>
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="authMethod"
class="mr-2 text-blue-600 focus:ring-blue-500"
type="radio"
value="manual"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-800 dark:text-blue-300">
<i class="fas fa-link mr-1" />手动授权
</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="authMethod"
class="mr-2 text-blue-600 focus:ring-blue-500"
type="radio"
value="cookie"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-800 dark:text-blue-300">
<i class="fas fa-cookie mr-1" />Cookie 自动授权
</span>
</label>
</div>
</div>
<!-- 手动授权流程 -->
<div v-if="authMethod === 'manual'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤通过 Setup Token 完成 Claude 账户的授权:
</p>
<!-- 步骤1: 生成授权链接 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
@@ -2182,6 +2237,113 @@
</div>
</div>
</div>
<!-- Cookie自动授权流程 -->
<div v-if="authMethod === 'cookie'" class="space-y-4">
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
使用 sessionKey 自动完成授权,无需手动打开链接。
</p>
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="space-y-4">
<div>
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-cookie text-blue-500" />sessionKey
<span
v-if="parsedSessionKeyCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
{{ parsedSessionKeyCount }} 个
</span>
</label>
<textarea
v-model="sessionKey"
class="form-input w-full resize-y border-gray-300 font-mono text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': cookieAuthError }"
placeholder="每行一个 sessionKey例如&#10;sk-ant-sid01-xxxxx...&#10;sk-ant-sid01-yyyyy..."
rows="3"
/>
<p
v-if="parsedSessionKeyCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
<i class="fas fa-info-circle mr-1" />
将批量创建 {{ parsedSessionKeyCount }} 个账户
</p>
<p v-if="cookieAuthError" class="mt-1 text-xs text-red-500">
{{ cookieAuthError }}
</p>
</div>
<!-- 帮助说明 -->
<div>
<button
class="flex items-center text-xs text-blue-600 hover:text-blue-700"
type="button"
@click="showSessionKeyHelp = !showSessionKeyHelp"
>
<i
:class="
showSessionKeyHelp
? 'fas fa-chevron-down mr-1'
: 'fas fa-chevron-right mr-1'
"
/>
如何获取 sessionKey
</button>
<div
v-if="showSessionKeyHelp"
class="mt-3 rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-700"
>
<ol class="space-y-2 text-xs text-gray-600 dark:text-gray-300">
<li>1. 在浏览器中登录 <strong>claude.ai</strong></li>
<li>2. 按 <strong>F12</strong> 打开开发者工具</li>
<li>3. 切换到 <strong>"Application"</strong> (应用) 标签页</li>
<li>
4. 在左侧选择 <strong>"Cookies"</strong> →
<strong>"https://claude.ai"</strong>
</li>
<li>5. 找到键为 <strong>"sessionKey"</strong> 的那一行</li>
<li>6. 复制其 <strong>"Value"</strong> (值) 列的内容</li>
</ol>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
sessionKey 通常以 "sk-ant-" 开头
</p>
</div>
</div>
<!-- 开始授权按钮 -->
<button
class="btn btn-primary w-full px-4 py-3"
:disabled="cookieAuthLoading || !sessionKey.trim()"
type="button"
@click="handleCookieAuth"
>
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
<i v-else class="fas fa-magic mr-2" />
<template v-if="cookieAuthLoading && batchProgress.total > 1">
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
</template>
<template v-else-if="cookieAuthLoading"> 授权中... </template>
<template v-else> 开始自动授权 </template>
</button>
</div>
</div>
<div
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
>
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" />
<strong>提示:</strong>如果您设置了代理Cookie授权也会使用相同的代理配置。
</p>
</div>
</div>
</div>
</div>
</div>
@@ -2196,6 +2358,7 @@
上一步
</button>
<button
v-if="authMethod === 'manual'"
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="!canExchangeSetupToken || setupTokenExchanging"
type="button"
@@ -2927,6 +3090,26 @@
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
</div>
</div>
<!-- 上游错误处理(编辑模式)-->
<div v-if="form.platform === 'claude-console'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
上游错误处理
</label>
<label class="inline-flex cursor-pointer items-center">
<input
v-model="form.disableAutoProtection"
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
上游错误不自动暂停调度
</span>
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
</div>
<!-- OpenAI-Responses 特定字段(编辑模式)-->
@@ -3538,6 +3721,9 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
const isEdit = computed(() => !!props.account)
const show = ref(true)
// OAuthFlow 组件引用
const oauthFlowRef = ref(null)
// OAuth步骤
const oauthStep = ref(1)
const loading = ref(false)
@@ -3551,6 +3737,22 @@ const setupTokenAuthCode = ref('')
const setupTokenCopied = ref(false)
const setupTokenSessionId = ref('')
// Cookie自动授权相关状态
const authMethod = ref('manual') // 'manual' | 'cookie'
const sessionKey = ref('')
const cookieAuthLoading = ref(false)
const cookieAuthError = ref('')
const showSessionKeyHelp = ref(false)
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
// 解析后的 sessionKey 数量
const parsedSessionKeyCount = computed(() => {
return sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0).length
})
// Claude Code 统一 User-Agent 信息
const unifiedUserAgent = ref('')
const clearingCache = ref(false)
@@ -3750,6 +3952,7 @@ const form = ref({
})(),
userAgent: props.account?.userAgent || '',
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
disableAutoProtection: props.account?.disableAutoProtection === true,
// 额度管理字段
dailyQuota: props.account?.dailyQuota || 0,
dailyUsage: props.account?.dailyUsage || 0,
@@ -4193,10 +4396,198 @@ const exchangeSetupTokenCode = async () => {
}
}
// 处理OAuth成功
const handleOAuthSuccess = async (tokenInfo) => {
// =============================================================================
// Cookie自动授权相关方法
// =============================================================================
// Cookie自动授权支持批量
const handleCookieAuth = async () => {
// 解析多行输入
const sessionKeys = sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (sessionKeys.length === 0) {
cookieAuthError.value = '请输入至少一个 sessionKey'
return
}
cookieAuthLoading.value = true
cookieAuthError.value = ''
batchProgress.value = { current: 0, total: sessionKeys.length }
const isSetupToken = form.value.addType === 'setup-token'
const proxyPayload = buildProxyPayload(form.value.proxy)
const results = []
const errors = []
for (let i = 0; i < sessionKeys.length; i++) {
batchProgress.value.current = i + 1
try {
const payload = {
sessionKey: sessionKeys[i],
...(proxyPayload && { proxy: proxyPayload })
}
let result
if (isSetupToken) {
result = await accountsStore.oauthSetupTokenWithCookie(payload)
} else {
result = await accountsStore.oauthWithCookie(payload)
}
results.push(result)
} catch (error) {
errors.push({
index: i + 1,
key: sessionKeys[i].substring(0, 20) + '...',
error: error.message
})
}
}
batchProgress.value = { current: 0, total: 0 }
if (results.length > 0) {
try {
// 成功后处理OAuth数据传递数组
// cookieAuthLoading 保持 true直到账号创建完成
await handleOAuthSuccess(results)
} finally {
cookieAuthLoading.value = false
}
} else {
cookieAuthLoading.value = false
}
if (errors.length > 0 && results.length === 0) {
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
} else if (errors.length > 0) {
cookieAuthError.value = `${errors.length} 个授权失败`
}
}
// 重置Cookie授权状态
const resetCookieAuth = () => {
sessionKey.value = ''
cookieAuthError.value = ''
showSessionKeyHelp.value = false
batchProgress.value = { current: 0, total: 0 }
}
// 切换授权方式时重置状态
const onAuthMethodChange = () => {
// 切换到手动模式时清除Cookie相关状态
if (authMethod.value === 'manual') {
resetCookieAuth()
} else {
// 切换到Cookie模式时清除手动授权状态
setupTokenAuthUrl.value = ''
setupTokenAuthCode.value = ''
setupTokenSessionId.value = ''
}
}
// 构建 Claude 账户数据(辅助函数)
const buildClaudeAccountData = (tokenInfo, accountName, clientId) => {
const proxyPayload = buildProxyPayload(form.value.proxy)
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
const data = {
name: accountName,
description: form.value.description,
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
proxy: proxyPayload,
claudeAiOauth: claudeOauthPayload,
priority: form.value.priority || 50,
autoStopOnWarning: form.value.autoStopOnWarning || false,
useUnifiedUserAgent: form.value.useUnifiedUserAgent || false,
useUnifiedClientId: form.value.useUnifiedClientId || false,
unifiedClientId: clientId,
subscriptionInfo: {
accountType: form.value.subscriptionType || 'claude_max',
hasClaudeMax: form.value.subscriptionType === 'claude_max',
hasClaudePro: form.value.subscriptionType === 'claude_pro',
manuallySet: true
}
}
// 处理 extInfo
if (claudeOauthPayload) {
const extInfoPayload = {}
const extSource = claudeOauthPayload.extInfo
if (extSource?.org_uuid) extInfoPayload.org_uuid = extSource.org_uuid
if (extSource?.account_uuid) extInfoPayload.account_uuid = extSource.account_uuid
if (!extSource) {
if (claudeOauthPayload.organization?.uuid) {
extInfoPayload.org_uuid = claudeOauthPayload.organization.uuid
}
if (claudeOauthPayload.account?.uuid) {
extInfoPayload.account_uuid = claudeOauthPayload.account.uuid
}
}
if (Object.keys(extInfoPayload).length > 0) {
data.extInfo = extInfoPayload
}
}
return data
}
// 处理OAuth成功支持批量
const handleOAuthSuccess = async (tokenInfoOrList) => {
loading.value = true
try {
const currentPlatform = form.value.platform
// Claude 平台支持批量创建
if (currentPlatform === 'claude' && Array.isArray(tokenInfoOrList)) {
const tokenInfoList = tokenInfoOrList
const isBatch = tokenInfoList.length > 1
const baseName = form.value.name
const results = []
const errors = []
for (let i = 0; i < tokenInfoList.length; i++) {
const tokenInfo = tokenInfoList[i]
// 批量时自动命名
const accountName = isBatch ? `${baseName}_${i + 1}` : baseName
// 如果启用统一客户端标识,为每个账户生成独立 ID
const clientId = form.value.useUnifiedClientId ? generateClientId() : ''
const data = buildClaudeAccountData(tokenInfo, accountName, clientId)
try {
const result = await accountsStore.createClaudeAccount(data)
results.push(result)
} catch (error) {
errors.push({ name: accountName, error: error.message })
}
}
// 处理结果
if (results.length > 0) {
const msg = isBatch
? `成功创建 ${results.length}/${tokenInfoList.length} 个账户`
: '账户创建成功'
showToast(msg, 'success')
emit('success', results[0]) // 兼容单个创建的返回
}
if (errors.length > 0) {
showToast(`${errors.length} 个账户创建失败`, 'error')
}
return
}
// 单个 tokenInfo 或其他平台的处理(保持原有逻辑)
const tokenInfo = Array.isArray(tokenInfoOrList) ? tokenInfoOrList[0] : tokenInfoOrList
// OAuth模式也需要确保生成客户端ID
if (
form.value.platform === 'claude' &&
@@ -4218,8 +4609,6 @@ const handleOAuthSuccess = async (tokenInfo) => {
proxy: proxyPayload
}
const currentPlatform = form.value.platform
if (currentPlatform === 'claude') {
// Claude使用claudeAiOauth字段
const claudeOauthPayload = tokenInfo.claudeAiOauth || tokenInfo
@@ -4380,6 +4769,8 @@ const handleOAuthSuccess = async (tokenInfo) => {
// 错误已通过 toast 显示给用户
} finally {
loading.value = false
// 重置 OAuthFlow 组件的加载状态(如果是通过 OAuth 模式调用)
oauthFlowRef.value?.resetCookieAuth()
}
}
@@ -4665,6 +5056,10 @@ const createAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理(仅 Claude Console
if (form.value.platform === 'claude-console') {
data.disableAutoProtection = !!form.value.disableAutoProtection
}
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -4993,6 +5388,8 @@ const updateAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
// 上游错误处理
data.disableAutoProtection = !!form.value.disableAutoProtection
// 额度管理字段
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
@@ -5614,7 +6011,9 @@ watch(
dailyUsage: newAccount.dailyUsage || 0,
quotaResetTime: newAccount.quotaResetTime || '00:00',
// 并发控制字段
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
// 上游错误处理
disableAutoProtection: newAccount.disableAutoProtection === true
}
// 如果是Claude Console账户加载实时使用情况

View File

@@ -13,128 +13,269 @@
</div>
<div class="flex-1">
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">Claude 账户授权</h4>
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤完成 Claude 账户的授权
</p>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<!-- 授权方式选择 -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-blue-800 dark:text-blue-300">
选择授权方式
</label>
<div class="flex gap-4">
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="authMethod"
class="text-blue-600 focus:ring-blue-500"
name="claude-auth-method"
type="radio"
value="manual"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">手动授权</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<input
v-model="authMethod"
class="text-blue-600 focus:ring-blue-500"
name="claude-auth-method"
type="radio"
value="cookie"
@change="onAuthMethodChange"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">Cookie自动授权</span>
</label>
</div>
</div>
<!-- Cookie自动授权表单 -->
<div v-if="authMethod === 'cookie'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
使用 claude.ai sessionKey 自动完成 OAuth 授权流程无需手动打开浏览器
</p>
<!-- sessionKey输入 -->
<div class="mb-4">
<label
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
>
1
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
点击下方按钮生成授权链接
</p>
<button
v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm"
:disabled="loading"
@click="generateAuthUrl"
<i class="fas fa-cookie text-blue-500" />
sessionKey
<span
v-if="parsedSessionKeyCount > 1"
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
>
<i v-if="!loading" class="fas fa-link mr-2" />
<div v-else class="loading-spinner mr-2" />
{{ loading ? '生成中...' : '生成授权链接' }}
{{ parsedSessionKeyCount }}
</span>
<button
class="text-blue-500 hover:text-blue-600"
type="button"
@click="showSessionKeyHelp = !showSessionKeyHelp"
>
<i class="fas fa-question-circle" />
</button>
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly
type="text"
:value="authUrl"
/>
</label>
<textarea
v-model="sessionKey"
class="form-input w-full resize-y font-mono text-sm"
placeholder="每行一个 sessionKey例如&#10;sk-ant-sid01-xxxxx...&#10;sk-ant-sid01-yyyyy..."
rows="3"
/>
<p
v-if="parsedSessionKeyCount > 1"
class="mt-1 text-xs text-blue-600 dark:text-blue-400"
>
<i class="fas fa-info-circle mr-1" />
将批量创建 {{ parsedSessionKeyCount }} 个账户
</p>
</div>
<!-- 帮助说明 -->
<div
v-if="showSessionKeyHelp"
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
>
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
<i class="fas fa-lightbulb mr-1" />如何获取 sessionKey
</h5>
<ol
class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300"
>
<li>在浏览器中登录 <strong>claude.ai</strong></li>
<li>
<kbd class="rounded bg-gray-200 px-1 dark:bg-gray-700">F12</kbd>
打开开发者工具
</li>
<li>切换到 <strong>Application</strong>应用标签页</li>
<li>
在左侧找到 <strong>Cookies</strong> <strong>https://claude.ai</strong>
</li>
<li>找到键为 <strong>sessionKey</strong> 的那一行</li>
<li>复制其 <strong>Value</strong>列的内容</li>
</ol>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-info-circle mr-1" />
sessionKey 通常以
<code class="rounded bg-gray-200 px-1 dark:bg-gray-700">sk-ant-sid01-</code>
开头
</p>
</div>
<!-- 错误信息 -->
<div
v-if="cookieAuthError"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="text-sm text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-circle mr-1" />
{{ cookieAuthError }}
</p>
</div>
<!-- 授权按钮 -->
<button
class="btn btn-primary w-full px-4 py-3 text-base font-semibold"
:disabled="cookieAuthLoading || !sessionKey.trim()"
type="button"
@click="handleCookieAuth"
>
<div v-if="cookieAuthLoading" class="loading-spinner mr-2" />
<i v-else class="fas fa-magic mr-2" />
<template v-if="cookieAuthLoading && batchProgress.total > 1">
正在授权 {{ batchProgress.current }}/{{ batchProgress.total }}...
</template>
<template v-else-if="cookieAuthLoading"> 正在授权... </template>
<template v-else> 开始自动授权 </template>
</button>
</div>
</div>
<!-- 手动授权流程 -->
<div v-else>
<p class="mb-4 text-sm text-blue-800 dark:text-blue-300">
请按照以下步骤完成 Claude 账户的授权
</p>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
1
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
点击下方按钮生成授权链接
</p>
<button
v-if="!authUrl"
class="btn btn-primary px-4 py-2 text-sm"
:disabled="loading"
@click="generateAuthUrl"
>
<i v-if="!loading" class="fas fa-link mr-2" />
<div v-else class="loading-spinner mr-2" />
{{ loading ? '生成中...' : '生成授权链接' }}
</button>
<div v-else class="space-y-3">
<div class="flex items-center gap-2">
<input
class="form-input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
readonly
type="text"
:value="authUrl"
/>
<button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
title="复制链接"
@click="copyAuthUrl"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
</button>
</div>
<button
class="rounded-lg bg-gray-100 px-3 py-2 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
title="复制链接"
@click="copyAuthUrl"
class="text-xs text-blue-600 hover:text-blue-700"
@click="regenerateAuthUrl"
>
<i :class="copied ? 'fas fa-check text-green-500' : 'fas fa-copy'" />
<i class="fas fa-sync-alt mr-1" />重新生成
</button>
</div>
<button
class="text-xs text-blue-600 hover:text-blue-700"
@click="regenerateAuthUrl"
>
<i class="fas fa-sync-alt mr-1" />重新生成
</button>
</div>
</div>
</div>
</div>
<!-- 步骤2: 访问链接并授权 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
2
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
在浏览器中打开链接并完成授权
</p>
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
请在新标签页中打开授权链接登录您的 Claude 账户并授权
</p>
<!-- 步骤2: 访问链接并授权 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" />
<strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
2
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
在浏览器中打开链接并完成授权
</p>
<p class="mb-2 text-sm text-blue-700 dark:text-blue-300">
请在新标签页中打开授权链接登录您的 Claude 账户并授权
</p>
<div
class="rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
>
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1" />
<strong>注意</strong
>如果您设置了代理请确保浏览器也使用相同的代理访问授权页面
</p>
</div>
</div>
</div>
</div>
</div>
<!-- 步骤3: 输入授权码 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
3
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
输入 Authorization Code
</p>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
授权完成后页面会显示一个
<strong>Authorization Code</strong>请将其复制并粘贴到下方输入框
</p>
<div class="space-y-3">
<div>
<label
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
</label>
<textarea
v-model="authCode"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴从Claude页面获取的Authorization Code..."
rows="3"
/>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
请粘贴从Claude页面复制的Authorization Code
<!-- 步骤3: 输入授权码 -->
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
3
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
输入 Authorization Code
</p>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
授权完成后页面会显示一个
<strong>Authorization Code</strong>请将其复制并粘贴到下方输入框
</p>
<div class="space-y-3">
<div>
<label
class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>
<i class="fas fa-key mr-2 text-blue-500" />Authorization Code
</label>
<textarea
v-model="authCode"
class="form-input w-full resize-none font-mono text-sm"
placeholder="粘贴从Claude页面获取的Authorization Code..."
rows="3"
/>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" />
请粘贴从Claude页面复制的Authorization Code
</p>
</div>
</div>
</div>
</div>
@@ -636,7 +777,9 @@
>
上一步
</button>
<!-- Cookie自动授权模式不显示此按钮Claude平台 -->
<button
v-if="!(platform === 'claude' && authMethod === 'cookie')"
class="btn btn-primary flex-1 px-6 py-3 font-semibold"
:disabled="!canExchange || exchanging"
type="button"
@@ -682,6 +825,22 @@ const verificationUriComplete = ref('')
const remainingSeconds = ref(0)
let countdownTimer = null
// Cookie自动授权相关状态
const authMethod = ref('manual') // 'manual' | 'cookie'
const sessionKey = ref('')
const cookieAuthLoading = ref(false)
const cookieAuthError = ref('')
const showSessionKeyHelp = ref(false)
const batchProgress = ref({ current: 0, total: 0 }) // 批量进度
// 解析后的 sessionKey 数量
const parsedSessionKeyCount = computed(() => {
return sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0).length
})
// 计算是否可以交换code
const canExchange = computed(() => {
if (props.platform === 'droid') {
@@ -984,4 +1143,93 @@ const exchangeCode = async () => {
onBeforeUnmount(() => {
stopCountdown()
})
// Cookie自动授权处理支持批量
const handleCookieAuth = async () => {
// 解析多行输入
const sessionKeys = sessionKey.value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (sessionKeys.length === 0) {
cookieAuthError.value = '请输入至少一个 sessionKey'
return
}
cookieAuthLoading.value = true
cookieAuthError.value = ''
batchProgress.value = { current: 0, total: sessionKeys.length }
// 构建代理配置
const proxyConfig = props.proxy?.enabled
? {
type: props.proxy.type,
host: props.proxy.host,
port: parseInt(props.proxy.port),
username: props.proxy.username || null,
password: props.proxy.password || null
}
: null
const results = []
const errors = []
for (let i = 0; i < sessionKeys.length; i++) {
batchProgress.value.current = i + 1
try {
const result = await accountsStore.oauthWithCookie({
sessionKey: sessionKeys[i],
proxy: proxyConfig
})
results.push(result)
} catch (error) {
errors.push({
index: i + 1,
key: sessionKeys[i].substring(0, 20) + '...',
error: error.message
})
}
}
batchProgress.value = { current: 0, total: 0 }
if (results.length > 0) {
// emit 后父组件会调用 handleOAuthSuccess 创建账号
// cookieAuthLoading 保持 true成功后表单会关闭失败时父组件会处理
emit('success', results) // 返回数组(单个时也是数组)
// 注意:不在这里设置 cookieAuthLoading = false
// 父组件创建账号完成后表单会关闭/重置
} else {
// 全部授权失败时才恢复按钮状态
cookieAuthLoading.value = false
}
if (errors.length > 0 && results.length === 0) {
cookieAuthError.value = '全部授权失败,请检查 sessionKey 是否有效'
} else if (errors.length > 0) {
cookieAuthError.value = `${errors.length} 个授权失败`
}
}
// 重置Cookie授权状态
const resetCookieAuth = () => {
sessionKey.value = ''
cookieAuthError.value = ''
cookieAuthLoading.value = false
batchProgress.value = { current: 0, total: 0 }
}
// 切换授权方式时重置状态
const onAuthMethodChange = () => {
resetCookieAuth()
authUrl.value = ''
authCode.value = ''
sessionId.value = ''
}
// 暴露方法供父组件调用
defineExpose({
resetCookieAuth
})
</script>

View File

@@ -0,0 +1,211 @@
<template>
<el-dialog
:append-to-body="true"
class="record-detail-modal"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="show"
:show-close="false"
top="10vh"
width="720px"
@close="emitClose"
>
<template #header>
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
请求详情
</p>
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ record?.model || '未知模型' }}
</p>
</div>
<button
aria-label="关闭"
class="rounded-full p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
@click="emitClose"
>
<i class="fas fa-times" />
</button>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-3 md:grid-cols-2">
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
>
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">基本信息</h4>
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">时间</span>
<span class="font-medium">{{ formattedTime }}</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">模型</span>
<span class="font-medium">{{ record?.model || '未知模型' }}</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">账户</span>
<span class="font-medium">{{ record?.accountName || '未知账户' }}</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">渠道</span>
<span class="font-medium">{{ record?.accountTypeName || '未知渠道' }}</span>
</li>
</ul>
</div>
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900"
>
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">Token 使用</h4>
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">输入 Token</span>
<span class="font-semibold text-blue-600 dark:text-blue-400">
{{ formatNumber(record?.inputTokens) }}
</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">输出 Token</span>
<span class="font-semibold text-green-600 dark:text-green-400">
{{ formatNumber(record?.outputTokens) }}
</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">缓存创建</span>
<span class="font-semibold text-purple-600 dark:text-purple-400">
{{ formatNumber(record?.cacheCreateTokens) }}
</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">缓存读取</span>
<span class="font-semibold text-orange-600 dark:text-orange-400">
{{ formatNumber(record?.cacheReadTokens) }}
</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">总计</span>
<span class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(record?.totalTokens) }}
</span>
</li>
</ul>
</div>
</div>
<div
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
>
<h4 class="mb-3 text-sm font-semibold text-gray-800 dark:text-gray-200">费用详情</h4>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
>
<span class="text-sm text-gray-500 dark:text-gray-400">输入费用</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formattedCosts.input }}
</span>
</div>
<div
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
>
<span class="text-sm text-gray-500 dark:text-gray-400">输出费用</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formattedCosts.output }}
</span>
</div>
<div
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
>
<span class="text-sm text-gray-500 dark:text-gray-400">缓存创建</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formattedCosts.cacheCreate }}
</span>
</div>
<div
class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2 dark:bg-gray-800"
>
<span class="text-sm text-gray-500 dark:text-gray-400">缓存读取</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formattedCosts.cacheRead }}
</span>
</div>
</div>
<div
class="mt-4 flex items-center justify-between rounded-md border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-800"
>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">总费用</span>
<div class="text-base font-bold text-yellow-600 dark:text-yellow-400">
{{ record?.costFormatted || formattedCosts.total }}
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<el-button type="primary" @click="emitClose">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { computed } from 'vue'
import dayjs from 'dayjs'
import { formatNumber } from '@/utils/format'
const props = defineProps({
show: {
type: Boolean,
default: false
},
record: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['close'])
const emitClose = () => emit('close')
const formattedTime = computed(() => {
if (!props.record?.timestamp) return '未知时间'
return dayjs(props.record.timestamp).format('YYYY-MM-DD HH:mm:ss')
})
const formattedCosts = computed(() => {
const breakdown = props.record?.costBreakdown || {}
const formatValue = (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)}`
}
return {
input: formatValue(breakdown.input),
output: formatValue(breakdown.output),
cacheCreate: formatValue(breakdown.cacheCreate),
cacheRead: formatValue(breakdown.cacheRead),
total: formatValue(breakdown.total)
}
})
</script>
<style scoped>
.record-detail-modal :deep(.el-dialog__header) {
margin: 0;
padding: 16px 16px 0;
}
.record-detail-modal :deep(.el-dialog__body) {
padding: 12px 16px 4px;
}
.record-detail-modal :deep(.el-dialog__footer) {
padding: 8px 16px 16px;
}
</style>

View File

@@ -231,6 +231,9 @@
<!-- 底部按钮 -->
<div class="mt-4 flex justify-end gap-2 sm:mt-6 sm:gap-3">
<button class="btn btn-primary px-4 py-2 text-sm" type="button" @click="openTimeline">
查看请求时间线
</button>
<button class="btn btn-secondary px-4 py-2 text-sm" type="button" @click="close">
关闭
</button>
@@ -256,7 +259,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['close'])
const emit = defineEmits(['close', 'open-timeline'])
// 计算属性
const totalRequests = computed(() => props.apiKey.usage?.total?.requests || 0)
@@ -320,6 +323,10 @@ const formatTokenCount = (count) => {
const close = () => {
emit('close')
}
const openTimeline = () => {
emit('open-timeline', props.apiKey?.id)
}
</script>
<style scoped>

View File

@@ -11,6 +11,7 @@ const UserManagementView = () => import('@/views/UserManagementView.vue')
const MainLayout = () => import('@/components/layout/MainLayout.vue')
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 TutorialView = () => import('@/views/TutorialView.vue')
const SettingsView = () => import('@/views/SettingsView.vue')
@@ -85,6 +86,18 @@ const routes = [
}
]
},
{
path: '/api-keys/:keyId/usage-records',
component: MainLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'ApiKeyUsageRecords',
component: ApiKeyUsageRecordsView
}
]
},
{
path: '/accounts',
component: MainLayout,

View File

@@ -750,6 +750,39 @@ export const useAccountsStore = defineStore('accounts', () => {
}
}
// Cookie自动授权 - 普通OAuth
const oauthWithCookie = async (payload) => {
try {
const response = await apiClient.post('/admin/claude-accounts/oauth-with-cookie', payload)
if (response.success) {
return response.data
} else {
throw new Error(response.message || 'Cookie授权失败')
}
} catch (err) {
error.value = err.message
throw err
}
}
// Cookie自动授权 - Setup Token
const oauthSetupTokenWithCookie = async (payload) => {
try {
const response = await apiClient.post(
'/admin/claude-accounts/setup-token-with-cookie',
payload
)
if (response.success) {
return response.data
} else {
throw new Error(response.message || 'Cookie授权失败')
}
} catch (err) {
error.value = err.message
throw err
}
}
// 生成Gemini OAuth URL
const generateGeminiAuthUrl = async (proxyConfig) => {
try {
@@ -914,6 +947,8 @@ export const useAccountsStore = defineStore('accounts', () => {
exchangeClaudeCode,
generateClaudeSetupTokenUrl,
exchangeClaudeSetupTokenCode,
oauthWithCookie,
oauthSetupTokenWithCookie,
generateGeminiAuthUrl,
exchangeGeminiCode,
generateOpenAIAuthUrl,

View File

@@ -67,22 +67,72 @@ export const useDashboardStore = defineStore('dashboard', () => {
groupLabel: 'Claude账户'
})
// 本地偏好
const STORAGE_KEYS = {
preset: 'dashboard:date:preset',
granularity: 'dashboard:trend:granularity'
}
const defaultPreset = 'today'
const defaultGranularity = 'day'
const getPresetOptions = (granularity) =>
granularity === 'hour'
? [
{ value: 'last24h', label: '近24小时', hours: 24 },
{ value: 'yesterday', label: '昨天', hours: 24 },
{ value: 'dayBefore', label: '前天', hours: 24 }
]
: [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
const readFromStorage = (key, fallback) => {
try {
const value = localStorage.getItem(key)
return value || fallback
} catch (error) {
return fallback
}
}
const saveToStorage = (key, value) => {
try {
localStorage.setItem(key, value)
} catch (error) {
// 忽略存储错误,避免影响渲染
}
}
const normalizePresetForGranularity = (preset, granularity) => {
const options = getPresetOptions(granularity)
const hasPreset = options.some((opt) => opt.value === preset)
if (hasPreset) return preset
return granularity === 'hour' ? 'last24h' : defaultPreset
}
const storedGranularity = readFromStorage(STORAGE_KEYS.granularity, defaultGranularity)
const initialGranularity = ['day', 'hour'].includes(storedGranularity)
? storedGranularity
: defaultGranularity
const initialPreset = normalizePresetForGranularity(
readFromStorage(STORAGE_KEYS.preset, defaultPreset),
initialGranularity
)
// 日期筛选
const dateFilter = ref({
type: 'preset', // preset 或 custom
preset: '7days', // today, 7days, 30days
preset: initialPreset, // today, 7days, 30days
customStart: '',
customEnd: '',
customRange: null,
presetOptions: [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
presetOptions: getPresetOptions(initialGranularity)
})
// 趋势图粒度
const trendGranularity = ref('day') // 'day' 或 'hour'
const trendGranularity = ref(initialGranularity) // 'day' 或 'hour'
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
const accountUsageGroup = ref('claude') // claude | openai | gemini
@@ -138,6 +188,21 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
}
const persistDatePreferences = (
preset = dateFilter.value.preset,
granularity = trendGranularity.value
) => {
saveToStorage(STORAGE_KEYS.preset, preset)
saveToStorage(STORAGE_KEYS.granularity, granularity)
}
const getEffectiveGranularity = () =>
dateFilter.value.type === 'preset' &&
dateFilter.value.preset === 'today' &&
trendGranularity.value === 'day'
? 'hour'
: trendGranularity.value
// 方法
async function loadDashboardData(timeRange = null) {
loading.value = true
@@ -232,7 +297,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
}
async function loadUsageTrend(days = 7, granularity = 'day') {
async function loadUsageTrend(days = 7, granularity = getEffectiveGranularity()) {
try {
let url = '/admin/usage-trend?'
@@ -241,26 +306,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
const convertToUTC = (systemTzTimeStr) => {
// 固定使用UTC+8因为后端系统时区是UTC+8
const systemTz = 8
// 解析系统时区时间字符串
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
// 创建UTC时间使其在系统时区显示为用户选择的时间
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
// 对应的UTC时间是 2025-07-24 16:00:00
const utcDate = new Date(
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
)
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
// 使用自定义时间范围 - 直接按系统时区字符串传递,避免额外时区偏移导致窗口错位
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else {
// 使用预设计算时间范围与loadApiKeysTrend保持一致
const now = new Date()
@@ -268,6 +316,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'today': {
// 今日使用系统时区的当日0点-23:59
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
// 近24小时从当前时间往前推24小时
endTime = new Date(now)
@@ -318,34 +372,28 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
}
async function loadModelStats(period = 'daily') {
async function loadModelStats(period = 'daily', granularity = null) {
const currentGranularity = granularity || getEffectiveGranularity()
try {
let url = `/admin/model-stats?period=${period}`
// 如果是自定义时间范围或小时粒度,传递具体的时间参数
if (dateFilter.value.type === 'custom' || trendGranularity.value === 'hour') {
if (dateFilter.value.type === 'custom' || currentGranularity === 'hour') {
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 系统时区时间转换为UTC
const convertToUTC = (systemTzTimeStr) => {
const systemTz = 8
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
const utcDate = new Date(
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
)
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
} else if (trendGranularity.value === 'hour' && dateFilter.value.type === 'preset') {
// 系统时区字符串直传,避免额外时区转换
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else if (currentGranularity === 'hour' && dateFilter.value.type === 'preset') {
// 小时粒度的预设时间范围
const now = new Date()
let startTime, endTime
switch (dateFilter.value.preset) {
case 'today': {
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
@@ -374,7 +422,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
}
} else if (dateFilter.value.type === 'preset' && trendGranularity.value === 'day') {
} else if (dateFilter.value.type === 'preset' && currentGranularity === 'day') {
// 天粒度的预设时间范围需要传递startDate和endDate参数
const now = new Date()
let startDate, endDate
@@ -409,36 +457,20 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
}
async function loadApiKeysTrend(metric = 'requests') {
async function loadApiKeysTrend(metric = 'requests', granularity = null) {
const currentGranularity = granularity || getEffectiveGranularity()
try {
let url = '/admin/api-keys-usage-trend?'
let days = 7
if (trendGranularity.value === 'hour') {
if (currentGranularity === 'hour') {
// 小时粒度,计算时间范围
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 使用自定义时间范围 - 需要将系统时区时间转换为UTC
const convertToUTC = (systemTzTimeStr) => {
// 固定使用UTC+8因为后端系统时区是UTC+8
const systemTz = 8
// 解析系统时区时间字符串
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
// 创建UTC时间使其在系统时区显示为用户选择的时间
// 例如:用户选择 UTC+8 的 2025-07-25 00:00:00
// 对应的UTC时间是 2025-07-24 16:00:00
const utcDate = new Date(
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
)
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
// 使用自定义时间范围 - 系统时区字符串直传
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else {
// 使用预设计算时间范围与setDateFilterPreset保持一致
const now = new Date()
@@ -446,6 +478,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'today': {
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
// 近24小时从当前时间往前推24小时
endTime = new Date(now)
@@ -511,29 +548,18 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
}
async function loadAccountUsageTrend(group = accountUsageGroup.value) {
async function loadAccountUsageTrend(group = accountUsageGroup.value, granularity = null) {
const currentGranularity = granularity || getEffectiveGranularity()
try {
let url = '/admin/account-usage-trend?'
let days = 7
if (trendGranularity.value === 'hour') {
if (currentGranularity === 'hour') {
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
const convertToUTC = (systemTzTimeStr) => {
const systemTz = 8
const [datePart, timePart] = systemTzTimeStr.split(' ')
const [year, month, day] = datePart.split('-').map(Number)
const [hours, minutes, seconds] = timePart.split(':').map(Number)
const utcDate = new Date(
Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)
)
return utcDate.toISOString()
}
url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}`
url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}`
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else {
const now = new Date()
let startTime
@@ -541,6 +567,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'today': {
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
@@ -603,110 +634,87 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
// 日期筛选相关方法
function setDateFilterPreset(preset) {
function setDateFilterPreset(preset, options = {}) {
const { silent = false, skipSave = false } = options
const normalizedPreset = normalizePresetForGranularity(preset, trendGranularity.value)
dateFilter.value.type = 'preset'
dateFilter.value.preset = preset
dateFilter.value.preset = normalizedPreset
// 根据预设计算并设置具体的日期范围
const option = dateFilter.value.presetOptions.find((opt) => opt.value === preset)
if (option) {
const now = new Date()
let startDate, endDate
const option = dateFilter.value.presetOptions.find((opt) => opt.value === normalizedPreset)
const now = new Date()
let startDate
let endDate
if (trendGranularity.value === 'hour') {
// 小时粒度的预设
switch (preset) {
case 'last24h': {
// 近24小时从当前时间往前推24小时
endDate = new Date(now)
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
}
case 'yesterday': {
// 昨天:获取本地时间的昨天
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
// 转换为系统时区的昨天0点和23:59
startDate = getSystemTimezoneDay(yesterday, true)
endDate = getSystemTimezoneDay(yesterday, false)
break
}
case 'dayBefore': {
// 前天:获取本地时间的前天
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
// 转换为系统时区的前天0点和23:59
startDate = getSystemTimezoneDay(dayBefore, true)
endDate = getSystemTimezoneDay(dayBefore, false)
break
}
if (trendGranularity.value === 'hour') {
switch (normalizedPreset) {
case 'today': {
startDate = getSystemTimezoneDay(now, true)
endDate = getSystemTimezoneDay(now, false)
break
}
} else {
// 天粒度的预设
startDate = new Date(now)
endDate = new Date(now)
if (preset === 'today') {
// 今日:从凌晨开始
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
} else {
// 其他预设:按天数计算
startDate.setDate(now.getDate() - (option.days - 1))
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
case 'last24h': {
endDate = new Date(now)
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
}
case 'yesterday': {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
startDate = getSystemTimezoneDay(yesterday, true)
endDate = getSystemTimezoneDay(yesterday, false)
break
}
case 'dayBefore': {
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
startDate = getSystemTimezoneDay(dayBefore, true)
endDate = getSystemTimezoneDay(dayBefore, false)
break
}
default: {
endDate = new Date(now)
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
}
}
} else {
startDate = new Date(now)
endDate = new Date(now)
dateFilter.value.customStart = startDate.toISOString().split('T')[0]
dateFilter.value.customEnd = endDate.toISOString().split('T')[0]
// 设置 customRange 为 Element Plus 需要的格式
// 对于小时粒度的昨天/前天,需要特殊处理显示
if (trendGranularity.value === 'hour' && (preset === 'yesterday' || preset === 'dayBefore')) {
// 获取本地日期
const targetDate = new Date()
if (preset === 'yesterday') {
targetDate.setDate(targetDate.getDate() - 1)
} else {
targetDate.setDate(targetDate.getDate() - 2)
}
// 显示系统时区的完整一天
const year = targetDate.getFullYear()
const month = String(targetDate.getMonth() + 1).padStart(2, '0')
const day = String(targetDate.getDate()).padStart(2, '0')
dateFilter.value.customRange = [
`${year}-${month}-${day} 00:00:00`,
`${year}-${month}-${day} 23:59:59`
]
} else {
// 其他情况近24小时或天粒度
const formatDateForDisplay = (date) => {
// 固定使用UTC+8来显示时间
const systemTz = 8
const tzOffset = systemTz * 60 * 60 * 1000
const localTime = new Date(date.getTime() + tzOffset)
const year = localTime.getUTCFullYear()
const month = String(localTime.getUTCMonth() + 1).padStart(2, '0')
const day = String(localTime.getUTCDate()).padStart(2, '0')
const hours = String(localTime.getUTCHours()).padStart(2, '0')
const minutes = String(localTime.getUTCMinutes()).padStart(2, '0')
const seconds = String(localTime.getUTCSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
dateFilter.value.customRange = [
formatDateForDisplay(startDate),
formatDateForDisplay(endDate)
]
if (normalizedPreset === 'today') {
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
} else if (option?.days) {
startDate.setDate(now.getDate() - (option.days - 1))
startDate.setHours(0, 0, 0, 0)
endDate.setHours(23, 59, 59, 999)
}
}
// 触发数据刷新
refreshChartsData()
const formatDateForDisplay = (date) => {
// 使用本地时间直接格式化,避免多余的时区偏移导致日期错位
const localTime = new Date(date)
const year = localTime.getFullYear()
const month = String(localTime.getMonth() + 1).padStart(2, '0')
const day = String(localTime.getDate()).padStart(2, '0')
const hours = String(localTime.getHours()).padStart(2, '0')
const minutes = String(localTime.getMinutes()).padStart(2, '0')
const seconds = String(localTime.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
dateFilter.value.customStart = startDate ? startDate.toISOString().split('T')[0] : ''
dateFilter.value.customEnd = endDate ? endDate.toISOString().split('T')[0] : ''
dateFilter.value.customRange =
startDate && endDate ? [formatDateForDisplay(startDate), formatDateForDisplay(endDate)] : null
if (!skipSave) {
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
}
if (!silent) {
refreshChartsData()
}
}
function onCustomDateRangeChange(value) {
@@ -751,20 +759,17 @@ export const useDashboardStore = defineStore('dashboard', () => {
refreshChartsData()
} else if (value === null) {
// 清空时恢复默认
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : '7days')
setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : defaultPreset)
}
}
function setTrendGranularity(granularity) {
function setTrendGranularity(granularity, options = {}) {
const { silent = false, skipSave = false, presetOverride } = options
trendGranularity.value = granularity
// 根据粒度更新预设选项
if (granularity === 'hour') {
dateFilter.value.presetOptions = [
{ value: 'last24h', label: '近24小时', hours: 24 },
{ value: 'yesterday', label: '昨天', hours: 24 },
{ value: 'dayBefore', label: '前天', hours: 24 }
]
dateFilter.value.presetOptions = getPresetOptions('hour')
// 检查当前自定义日期范围是否超过24小时
if (
@@ -777,46 +782,53 @@ export const useDashboardStore = defineStore('dashboard', () => {
const hoursDiff = (end - start) / (1000 * 60 * 60)
if (hoursDiff > 24) {
showToast('小时粒度下日期范围不能超过24小时已切换到近24小时', 'warning')
setDateFilterPreset('last24h')
setDateFilterPreset('last24h', { silent, skipSave })
return
}
}
// 如果当前是天粒度的预设,切换到小时粒度的默认预设
if (['today', '7days', '30days'].includes(dateFilter.value.preset)) {
setDateFilterPreset('last24h')
return
}
} else {
// 天粒度
dateFilter.value.presetOptions = [
{ value: 'today', label: '今日', days: 1 },
{ value: '7days', label: '7天', days: 7 },
{ value: '30days', label: '30天', days: 30 }
]
// 如果当前是小时粒度的预设,切换到天粒度的默认预设
if (['last24h', 'yesterday', 'dayBefore'].includes(dateFilter.value.preset)) {
setDateFilterPreset('7days')
return
}
dateFilter.value.presetOptions = getPresetOptions('day')
}
// 触发数据刷新
refreshChartsData()
if (dateFilter.value.type === 'custom') {
if (!skipSave) {
persistDatePreferences(dateFilter.value.preset || defaultPreset, trendGranularity.value)
}
if (!silent) {
refreshChartsData()
}
return
}
const nextPreset =
presetOverride ||
normalizePresetForGranularity(dateFilter.value.preset, trendGranularity.value)
setDateFilterPreset(nextPreset, { silent: true, skipSave: true })
if (!skipSave) {
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
}
if (!silent) {
refreshChartsData()
}
}
async function refreshChartsData() {
// 根据当前筛选条件刷新数据
let days
let modelPeriod = 'monthly'
const effectiveGranularity = getEffectiveGranularity()
if (dateFilter.value.type === 'preset') {
const option = dateFilter.value.presetOptions.find(
(opt) => opt.value === dateFilter.value.preset
)
if (trendGranularity.value === 'hour') {
if (effectiveGranularity === 'hour') {
// 小时粒度
days = 1 // 小时粒度默认查看1天的数据
modelPeriod = 'daily' // 小时粒度使用日统计
@@ -832,7 +844,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
} else {
// 自定义日期范围
if (trendGranularity.value === 'hour') {
if (effectiveGranularity === 'hour') {
// 小时粒度下的自定义范围,计算小时数
const start = new Date(dateFilter.value.customRange[0])
const end = new Date(dateFilter.value.customRange[1])
@@ -845,16 +857,16 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
await Promise.all([
loadUsageTrend(days, trendGranularity.value),
loadModelStats(modelPeriod),
loadApiKeysTrend(apiKeysTrendMetric.value),
loadAccountUsageTrend(accountUsageGroup.value)
loadUsageTrend(days, effectiveGranularity),
loadModelStats(modelPeriod, effectiveGranularity),
loadApiKeysTrend(apiKeysTrendMetric.value, effectiveGranularity),
loadAccountUsageTrend(accountUsageGroup.value, effectiveGranularity)
])
}
function setAccountUsageGroup(group) {
accountUsageGroup.value = group
return loadAccountUsageTrend(group)
return loadAccountUsageTrend(group, getEffectiveGranularity())
}
function calculateDaysBetween(start, end) {
@@ -870,6 +882,10 @@ export const useDashboardStore = defineStore('dashboard', () => {
return date > new Date()
}
// 初始化日期筛选:同步本地偏好并填充范围
setDateFilterPreset(dateFilter.value.preset, { silent: true, skipSave: true })
persistDatePreferences(dateFilter.value.preset, trendGranularity.value)
return {
// 状态
loading,

View File

@@ -19,12 +19,12 @@
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-indigo-500 to-blue-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<CustomDropdown
v-model="accountSortBy"
icon="fa-sort-amount-down"
v-model="accountsSortBy"
:icon="accountsSortOrder === 'asc' ? 'fa-sort-amount-up' : 'fa-sort-amount-down'"
icon-color="text-indigo-500"
:options="sortOptions"
placeholder="选择排序"
@change="sortAccounts()"
@change="handleDropdownSort"
/>
</div>
@@ -162,7 +162,7 @@
<!-- 桌面端表格视图 -->
<div v-else class="table-wrapper hidden md:block">
<div class="table-container">
<div ref="tableContainerRef" class="table-container">
<table class="w-full">
<thead
class="sticky top-0 z-10 bg-gradient-to-b from-gray-50 to-gray-100/90 backdrop-blur-sm dark:from-gray-700 dark:to-gray-800/90"
@@ -214,22 +214,7 @@
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="min-w-[110px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('expiresAt')"
>
到期时间
<i
v-if="accountsSortBy === 'expiresAt'"
:class="[
'fas',
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="w-[120px] min-w-[180px] max-w-[20s0px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
class="w-[120px] min-w-[180px] max-w-[200px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('status')"
>
状态
@@ -243,26 +228,6 @@
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('priority')"
>
优先级
<i
v-if="accountsSortBy === 'priority'"
:class="[
'fas',
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
代理
</th>
<th
class="min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
@@ -338,7 +303,7 @@
class="fas fa-info-circle mt-[2px] text-[10px] text-indigo-500"
></i>
<span class="font-medium text-white dark:text-gray-900"
>重置剩余 0 进度条与百分比会同步清零</span
>"重置剩余" 0 进度条与百分比会同步清零</span
>
</div>
</div>
@@ -394,7 +359,43 @@
最后使用
</th>
<th
class="operations-column sticky right-0 z-20 min-w-[200px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
class="min-w-[80px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('priority')"
>
优先级
<i
v-if="accountsSortBy === 'priority'"
:class="[
'fas',
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="min-w-[150px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
代理
</th>
<th
class="min-w-[110px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600"
@click="sortAccounts('expiresAt')"
>
到期时间
<i
v-if="accountsSortBy === 'expiresAt'"
:class="[
'fas',
accountsSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else class="fas fa-sort ml-1 text-gray-400" />
</th>
<th
class="operations-column sticky right-0 z-20 px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
:class="needsHorizontalScroll ? 'min-w-[170px]' : 'min-w-[200px]'"
>
操作
</th>
@@ -615,49 +616,6 @@
</div>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<div class="flex flex-col gap-1">
<!-- 已设置过期时间 -->
<span v-if="account.expiresAt">
<span
v-if="isExpired(account.expiresAt)"
class="inline-flex cursor-pointer items-center text-red-600 hover:underline"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
<i class="fas fa-exclamation-circle mr-1 text-xs" />
已过期
</span>
<span
v-else-if="isExpiringSoon(account.expiresAt)"
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
<i class="fas fa-clock mr-1 text-xs" />
{{ formatExpireDate(account.expiresAt) }}
</span>
<span
v-else
class="cursor-pointer text-gray-600 hover:underline dark:text-gray-400"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
{{ formatExpireDate(account.expiresAt) }}
</span>
</span>
<!-- 永不过期 -->
<span
v-else
class="inline-flex cursor-pointer items-center text-gray-400 hover:underline dark:text-gray-500"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
<i class="fas fa-infinity mr-1 text-xs" />
永不过期
</span>
</div>
</td>
<td class="w-[100px] min-w-[100px] max-w-[100px] whitespace-nowrap px-3 py-4">
<div class="flex flex-col gap-1">
<span
@@ -748,46 +706,6 @@
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<div
v-if="
account.platform === 'claude' ||
account.platform === 'claude-console' ||
account.platform === 'bedrock' ||
account.platform === 'gemini' ||
account.platform === 'openai' ||
account.platform === 'openai-responses' ||
account.platform === 'azure_openai' ||
account.platform === 'ccr' ||
account.platform === 'droid' ||
account.platform === 'gemini-api'
"
class="flex items-center gap-2"
>
<div class="h-2 w-16 rounded-full bg-gray-200">
<div
class="h-2 rounded-full bg-gradient-to-r from-green-500 to-blue-600 transition-all duration-300"
:style="{ width: 101 - (account.priority || 50) + '%' }"
/>
</div>
<span class="min-w-[20px] text-xs font-medium text-gray-700 dark:text-gray-200">
{{ account.priority || 50 }}
</span>
</div>
<div v-else class="text-sm text-gray-400">
<span class="text-xs">N/A</span>
</div>
</td>
<td class="px-3 py-4 text-sm text-gray-600">
<div
v-if="formatProxyDisplay(account.proxy)"
class="break-all rounded bg-blue-50 px-2 py-1 font-mono text-xs"
:title="formatProxyDisplay(account.proxy)"
>
{{ formatProxyDisplay(account.proxy) }}
</div>
<div v-else class="text-gray-400">无代理</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<div v-if="account.usage && account.usage.daily" class="space-y-1">
<div class="flex items-center gap-2">
@@ -1153,11 +1071,94 @@
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-600 dark:text-gray-300">
{{ formatLastUsed(account.lastUsedAt) }}
</td>
<td class="whitespace-nowrap px-3 py-4">
<div
v-if="
account.platform === 'claude' ||
account.platform === 'claude-console' ||
account.platform === 'bedrock' ||
account.platform === 'gemini' ||
account.platform === 'openai' ||
account.platform === 'openai-responses' ||
account.platform === 'azure_openai' ||
account.platform === 'ccr' ||
account.platform === 'droid' ||
account.platform === 'gemini-api'
"
class="flex items-center gap-2"
>
<div class="h-2 w-16 rounded-full bg-gray-200">
<div
class="h-2 rounded-full bg-gradient-to-r from-green-500 to-blue-600 transition-all duration-300"
:style="{ width: 101 - (account.priority || 50) + '%' }"
/>
</div>
<span class="min-w-[20px] text-xs font-medium text-gray-700 dark:text-gray-200">
{{ account.priority || 50 }}
</span>
</div>
<div v-else class="text-sm text-gray-400">
<span class="text-xs">N/A</span>
</div>
</td>
<td class="px-3 py-4 text-sm text-gray-600">
<div
v-if="formatProxyDisplay(account.proxy)"
class="break-all rounded bg-blue-50 px-2 py-1 font-mono text-xs"
:title="formatProxyDisplay(account.proxy)"
>
{{ formatProxyDisplay(account.proxy) }}
</div>
<div v-else class="text-gray-400">无代理</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<div class="flex flex-col gap-1">
<!-- 已设置过期时间 -->
<span v-if="account.expiresAt">
<span
v-if="isExpired(account.expiresAt)"
class="inline-flex cursor-pointer items-center text-red-600 hover:underline"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
<i class="fas fa-exclamation-circle mr-1 text-xs" />
已过期
</span>
<span
v-else-if="isExpiringSoon(account.expiresAt)"
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
<i class="fas fa-clock mr-1 text-xs" />
{{ formatExpireDate(account.expiresAt) }}
</span>
<span
v-else
class="cursor-pointer text-gray-600 hover:underline dark:text-gray-400"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
{{ formatExpireDate(account.expiresAt) }}
</span>
</span>
<!-- 永不过期 -->
<span
v-else
class="inline-flex cursor-pointer items-center text-gray-400 hover:underline dark:text-gray-500"
style="font-size: 13px"
@click.stop="startEditAccountExpiry(account)"
>
<i class="fas fa-infinity mr-1 text-xs" />
永不过期
</span>
</div>
</td>
<td
class="operations-column sticky right-0 z-10 whitespace-nowrap px-3 py-4 text-sm font-medium"
>
<!-- 大屏显示所有按钮 -->
<div class="hidden items-center gap-1 2xl:flex">
<!-- 宽度足够时显示所有按钮 -->
<div v-if="!needsHorizontalScroll" class="flex items-center gap-1">
<button
v-if="showResetButton(account)"
:class="[
@@ -1224,8 +1225,8 @@
<span class="ml-1">删除</span>
</button>
</div>
<!-- 小屏显示2个快捷按钮 + 下拉菜单 -->
<div class="flex items-center gap-1 2xl:hidden">
<!-- 需要横向滚动时使用缩减形式2个快捷按钮 + 下拉菜单 -->
<div v-else class="flex items-center gap-1">
<button
:class="[
'rounded px-2.5 py-1 text-xs font-medium transition-colors',
@@ -1853,7 +1854,7 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import { useConfirm } from '@/composables/useConfirm'
@@ -1872,8 +1873,7 @@ const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCanc
// 数据状态
const accounts = ref([])
const accountsLoading = ref(false)
const accountSortBy = ref('name')
const accountsSortBy = ref('')
const accountsSortBy = ref('name')
const accountsSortOrder = ref('asc')
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X 个API Key"
@@ -1929,6 +1929,10 @@ const expiryEditModalRef = ref(null)
const showAccountTestModal = ref(false)
const testingAccount = ref(null)
// 表格横向滚动检测
const tableContainerRef = ref(null)
const needsHorizontalScroll = ref(false)
// 缓存状态标志
const apiKeysLoaded = ref(false) // 用于其他功能
const bindingCountsLoaded = ref(false) // 轻量级绑定计数缓存
@@ -2730,7 +2734,10 @@ const loadClaudeUsage = async () => {
}
}
// 排序账户
// 记录上一次的排序字段,用于判断下拉选择是否是同一字段被再次选择
let lastDropdownSortField = 'name'
// 排序账户(表头点击使用)
const sortAccounts = (field) => {
if (field) {
if (accountsSortBy.value === field) {
@@ -2739,9 +2746,23 @@ const sortAccounts = (field) => {
accountsSortBy.value = field
accountsSortOrder.value = 'asc'
}
// 同步下拉选择器的状态记录
lastDropdownSortField = field
}
}
// 下拉选择器排序处理(支持再次选择同一选项时切换排序方向)
const handleDropdownSort = (field) => {
if (field === lastDropdownSortField) {
// 选择同一字段,切换排序方向
accountsSortOrder.value = accountsSortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
// 选择不同字段,重置为升序
accountsSortOrder.value = 'asc'
}
lastDropdownSortField = field
}
// 格式化数字(与原版保持一致)
const formatNumber = (num) => {
if (num === null || num === undefined) return '0'
@@ -3988,20 +4009,20 @@ watch(
}
)
// 监听排序选择变化
watch(accountSortBy, (newVal) => {
const fieldMap = {
name: 'name',
dailyTokens: 'dailyTokens',
dailyRequests: 'dailyRequests',
totalTokens: 'totalTokens',
lastUsed: 'lastUsed'
}
if (fieldMap[newVal]) {
sortAccounts(fieldMap[newVal])
}
})
// 监听排序选择变化 - 已重构为 handleDropdownSort此处注释保留原逻辑参考
// watch(accountSortBy, (newVal) => {
// const fieldMap = {
// name: 'name',
// dailyTokens: 'dailyTokens',
// dailyRequests: 'dailyRequests',
// totalTokens: 'totalTokens',
// lastUsed: 'lastUsed'
// }
//
// if (fieldMap[newVal]) {
// sortAccounts(fieldMap[newVal])
// }
// })
watch(currentPage, () => {
updateSelectAllState()
@@ -4009,6 +4030,10 @@ watch(currentPage, () => {
watch(paginatedAccounts, () => {
updateSelectAllState()
// 数据变化后重新检测是否需要横向滚动
nextTick(() => {
checkHorizontalScroll()
})
})
watch(accounts, () => {
@@ -4122,9 +4147,41 @@ const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
}
}
// 检测表格是否需要横向滚动
const checkHorizontalScroll = () => {
if (tableContainerRef.value) {
needsHorizontalScroll.value =
tableContainerRef.value.scrollWidth > tableContainerRef.value.clientWidth
}
}
// 窗口大小变化时重新检测
let resizeObserver = null
onMounted(() => {
// 首次加载时强制刷新所有数据
loadAccounts(true)
// 设置ResizeObserver监听表格容器大小变化
nextTick(() => {
if (tableContainerRef.value) {
resizeObserver = new ResizeObserver(() => {
checkHorizontalScroll()
})
resizeObserver.observe(tableContainerRef.value)
checkHorizontalScroll()
}
})
// 监听窗口大小变化
window.addEventListener('resize', checkHorizontalScroll)
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
window.removeEventListener('resize', checkHorizontalScroll)
})
</script>
@@ -4225,29 +4282,29 @@ onMounted(() => {
background-color: rgba(255, 255, 255, 0.02);
}
/* 表头左侧固定列背景 */
/* 表头左侧固定列背景 - 使用纯色避免滚动时重叠 */
.table-container thead .checkbox-column,
.table-container thead .name-column {
z-index: 30;
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
}
.dark .table-container thead .checkbox-column,
.dark .table-container thead .name-column {
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
background: linear-gradient(to bottom, #374151, #1f2937);
}
/* 表头右侧操作列背景 */
/* 表头右侧操作列背景 - 使用纯色避免滚动时重叠 */
.table-container thead .operations-column {
z-index: 30;
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
}
.dark .table-container thead .operations-column {
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
background: linear-gradient(to bottom, #374151, #1f2937);
}
/* tbody 中的左侧固定列背景处理 */
/* tbody 中的左侧固定列背景处理 - 使用纯色避免滚动时重叠 */
.table-container tbody tr:nth-child(odd) .checkbox-column,
.table-container tbody tr:nth-child(odd) .name-column {
background-color: #ffffff;
@@ -4255,28 +4312,28 @@ onMounted(() => {
.table-container tbody tr:nth-child(even) .checkbox-column,
.table-container tbody tr:nth-child(even) .name-column {
background-color: rgba(249, 250, 251, 0.7);
background-color: #f9fafb;
}
.dark .table-container tbody tr:nth-child(odd) .checkbox-column,
.dark .table-container tbody tr:nth-child(odd) .name-column {
background-color: rgba(31, 41, 55, 0.4);
background-color: #1f2937;
}
.dark .table-container tbody tr:nth-child(even) .checkbox-column,
.dark .table-container tbody tr:nth-child(even) .name-column {
background-color: rgba(55, 65, 81, 0.3);
background-color: #374151;
}
/* hover 状态下的左侧固定列背景 */
.table-container tbody tr:hover .checkbox-column,
.table-container tbody tr:hover .name-column {
background-color: rgba(239, 246, 255, 0.6);
background-color: #eff6ff;
}
.dark .table-container tbody tr:hover .checkbox-column,
.dark .table-container tbody tr:hover .name-column {
background-color: rgba(30, 58, 138, 0.2);
background-color: #1e3a5f;
}
/* 名称列右侧阴影(分隔效果) */
@@ -4288,30 +4345,30 @@ onMounted(() => {
box-shadow: 8px 0 12px -8px rgba(30, 41, 59, 0.45);
}
/* tbody 中的操作列背景处理 */
/* tbody 中的操作列背景处理 - 使用纯色避免滚动时重叠 */
.table-container tbody tr:nth-child(odd) .operations-column {
background-color: #ffffff;
}
.table-container tbody tr:nth-child(even) .operations-column {
background-color: rgba(249, 250, 251, 0.7);
background-color: #f9fafb;
}
.dark .table-container tbody tr:nth-child(odd) .operations-column {
background-color: rgba(31, 41, 55, 0.4);
background-color: #1f2937;
}
.dark .table-container tbody tr:nth-child(even) .operations-column {
background-color: rgba(55, 65, 81, 0.3);
background-color: #374151;
}
/* hover 状态下的操作列背景 */
.table-container tbody tr:hover .operations-column {
background-color: rgba(239, 246, 255, 0.6);
background-color: #eff6ff;
}
.dark .table-container tbody tr:hover .operations-column {
background-color: rgba(30, 58, 138, 0.2);
background-color: #1e3a5f;
}
/* 操作列左侧阴影 */

View File

@@ -0,0 +1,549 @@
<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">
API Key 请求详情时间线
</p>
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
{{ apiKeyDisplayName }}
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">ID: {{ keyId }}</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.accountId"
class="w-[220px]"
clearable
filterable
placeholder="所有账户"
>
<el-option
v-for="account in availableAccounts"
:key="account.id"
:label="`${account.name}${account.accountTypeName}`"
:value="account.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"
>
账户
</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.accountName || '未知账户' }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ record.accountTypeName || '未知渠道' }}
</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.accountName || '未知账户' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ formatDate(record.timestamp) }}
</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 keyId = computed(() => route.params.keyId)
const loading = ref(false)
const exporting = ref(false)
const records = ref([])
const availableModels = ref([])
const availableAccounts = ref([])
const pagination = reactive({
currentPage: 1,
pageSize: 50,
totalRecords: 0
})
const filters = reactive({
dateRange: null,
model: '',
accountId: '',
sortOrder: 'desc'
})
const summary = reactive({
totalRequests: 0,
totalTokens: 0,
totalCost: 0,
avgCost: 0
})
const apiKeyInfo = reactive({
id: keyId.value,
name: ''
})
const detailVisible = ref(false)
const activeRecord = ref(null)
const apiKeyDisplayName = computed(() => apiKeyInfo.name || apiKeyInfo.id || keyId.value)
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.accountId) params.accountId = filters.accountId
if (filters.dateRange && filters.dateRange.length === 2) {
params.startDate = dayjs(filters.dateRange[0]).toISOString()
params.endDate = dayjs(filters.dateRange[1]).toISOString()
}
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.accountId !== undefined) filters.accountId = filterEcho.accountId || ''
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
apiKeyInfo.id = data.apiKeyInfo?.id || keyId.value
apiKeyInfo.name = data.apiKeyInfo?.name || ''
availableModels.value = data.availableFilters?.models || []
availableAccounts.value = data.availableFilters?.accounts || []
}
const fetchRecords = async (page = pagination.currentPage) => {
loading.value = true
try {
const response = await apiClient.get(`/admin/api-keys/${keyId.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.accountId = ''
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('/api-keys')
}
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/api-keys/${keyId.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 = [
'时间',
'账户',
'渠道',
'模型',
'输入Token',
'输出Token',
'缓存创建Token',
'缓存读取Token',
'总Token',
'费用'
]
const csvRows = [headers.join(',')]
aggregated.forEach((record) => {
const row = [
formatDate(record.timestamp),
record.accountName || '',
record.accountTypeName || '',
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 = `api-key-${keyId.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.accountId, filters.sortOrder],
() => {
pagination.currentPage = 1
fetchRecords(1)
}
)
watch(
() => filters.dateRange,
() => {
pagination.currentPage = 1
fetchRecords(1)
},
{ deep: true }
)
onMounted(() => {
fetchRecords()
})
</script>

View File

@@ -2085,17 +2085,18 @@
@save="handleSaveExpiry"
/>
<!-- 使用详情弹窗 -->
<UsageDetailModal
:api-key="selectedApiKeyForDetail || {}"
:show="showUsageDetailModal"
@close="showUsageDetailModal = false"
@open-timeline="openTimeline"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api'
import { useClientsStore } from '@/stores/clients'
@@ -2114,6 +2115,7 @@ import CustomDropdown from '@/components/common/CustomDropdown.vue'
import ActionDropdown from '@/components/common/ActionDropdown.vue'
// 响应式数据
const router = useRouter()
const clientsStore = useClientsStore()
const authStore = useAuthStore()
const apiKeys = ref([])
@@ -4193,19 +4195,15 @@ const formatWindowTime = (seconds) => {
// 显示使用详情
const showUsageDetails = (apiKey) => {
// 获取异步加载的统计数据
const cachedStats = getCachedStats(apiKey.id)
// 合并异步统计数据到 apiKey 对象
const enrichedApiKey = {
...apiKey,
// 合并实时限制数据
dailyCost: cachedStats?.dailyCost ?? apiKey.dailyCost ?? 0,
currentWindowCost: cachedStats?.currentWindowCost ?? apiKey.currentWindowCost ?? 0,
windowRemainingSeconds: cachedStats?.windowRemainingSeconds ?? apiKey.windowRemainingSeconds,
windowStartTime: cachedStats?.windowStartTime ?? apiKey.windowStartTime ?? null,
windowEndTime: cachedStats?.windowEndTime ?? apiKey.windowEndTime ?? null,
// 合并 usage 数据(用于详情弹窗中的统计卡片)
usage: {
...apiKey.usage,
total: {
@@ -4226,6 +4224,13 @@ const showUsageDetails = (apiKey) => {
showUsageDetailModal.value = true
}
const openTimeline = (keyId) => {
const id = keyId || selectedApiKeyForDetail.value?.id
if (!id) return
showUsageDetailModal.value = false
router.push(`/api-keys/${id}/usage-records`)
}
// 格式化时间(秒转换为可读格式) - 已移到 WindowLimitBar 组件中
// const formatTime = (seconds) => {
// if (seconds === null || seconds === undefined) return '--:--'
@@ -4841,40 +4846,40 @@ onUnmounted(() => {
z-index: 12;
}
/* 确保操作列在浅色模式下有正确的背景 */
/* 确保操作列在浅色模式下有正确的背景 - 使用纯色避免滚动时重叠 */
.table-container thead .operations-column {
z-index: 30;
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
}
.dark .table-container thead .operations-column {
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
background: linear-gradient(to bottom, #374151, #1f2937);
}
/* tbody 中的操作列背景处理 */
/* tbody 中的操作列背景处理 - 使用纯色避免滚动时重叠 */
.table-container tbody tr:nth-child(odd) .operations-column {
background-color: #ffffff;
}
.table-container tbody tr:nth-child(even) .operations-column {
background-color: rgba(249, 250, 251, 0.7);
background-color: #f9fafb;
}
.dark .table-container tbody tr:nth-child(odd) .operations-column {
background-color: rgba(31, 41, 55, 0.4);
background-color: #1f2937;
}
.dark .table-container tbody tr:nth-child(even) .operations-column {
background-color: rgba(55, 65, 81, 0.3);
background-color: #374151;
}
/* hover 状态下的操作列背景 */
.table-container tbody tr:hover .operations-column {
background-color: rgba(239, 246, 255, 0.6);
background-color: #eff6ff;
}
.dark .table-container tbody tr:hover .operations-column {
background-color: rgba(30, 58, 138, 0.2);
background-color: #1e3a5f;
}
.table-container tbody .operations-column {
@@ -4892,19 +4897,19 @@ onUnmounted(() => {
z-index: 12;
}
/* 表头左侧固定列背景 */
/* 表头左侧固定列背景 - 使用纯色避免滚动时重叠 */
.table-container thead .checkbox-column,
.table-container thead .name-column {
z-index: 30;
background: linear-gradient(to bottom, #f9fafb, rgba(243, 244, 246, 0.9));
background: linear-gradient(to bottom, #f9fafb, #f3f4f6);
}
.dark .table-container thead .checkbox-column,
.dark .table-container thead .name-column {
background: linear-gradient(to bottom, #374151, rgba(31, 41, 55, 0.9));
background: linear-gradient(to bottom, #374151, #1f2937);
}
/* tbody 中的左侧固定列背景处理 */
/* tbody 中的左侧固定列背景处理 - 使用纯色避免滚动时重叠 */
.table-container tbody tr:nth-child(odd) .checkbox-column,
.table-container tbody tr:nth-child(odd) .name-column {
background-color: #ffffff;
@@ -4912,28 +4917,28 @@ onUnmounted(() => {
.table-container tbody tr:nth-child(even) .checkbox-column,
.table-container tbody tr:nth-child(even) .name-column {
background-color: rgba(249, 250, 251, 0.7);
background-color: #f9fafb;
}
.dark .table-container tbody tr:nth-child(odd) .checkbox-column,
.dark .table-container tbody tr:nth-child(odd) .name-column {
background-color: rgba(31, 41, 55, 0.4);
background-color: #1f2937;
}
.dark .table-container tbody tr:nth-child(even) .checkbox-column,
.dark .table-container tbody tr:nth-child(even) .name-column {
background-color: rgba(55, 65, 81, 0.3);
background-color: #374151;
}
/* hover 状态下的左侧固定列背景 */
.table-container tbody tr:hover .checkbox-column,
.table-container tbody tr:hover .name-column {
background-color: rgba(239, 246, 255, 0.6);
background-color: #eff6ff;
}
.dark .table-container tbody tr:hover .checkbox-column,
.dark .table-container tbody tr:hover .name-column {
background-color: rgba(30, 58, 138, 0.2);
background-color: #1e3a5f;
}
/* 名称列右侧阴影(分隔效果) */

View File

@@ -1036,6 +1036,7 @@ function createUsageTrendChart() {
type: 'linear',
display: true,
position: 'left',
min: 0,
title: {
display: true,
text: 'Token数量',
@@ -1055,6 +1056,7 @@ function createUsageTrendChart() {
type: 'linear',
display: true,
position: 'right',
min: 0,
title: {
display: true,
text: '请求数',
@@ -1073,7 +1075,8 @@ function createUsageTrendChart() {
y2: {
type: 'linear',
display: false, // 隐藏费用轴在tooltip中显示
position: 'right'
position: 'right',
min: 0
}
}
}
@@ -1253,6 +1256,7 @@ function createApiKeysUsageTrendChart() {
},
y: {
beginAtZero: true,
min: 0,
title: {
display: true,
text: apiKeysTrendMetric.value === 'tokens' ? 'Token 数量' : '请求次数',
@@ -1428,6 +1432,7 @@ function createAccountUsageTrendChart() {
},
y: {
beginAtZero: true,
min: 0,
title: {
display: true,
text: '消耗金额 (USD)',