mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
1
This commit is contained in:
@@ -123,7 +123,8 @@ const config = {
|
|||||||
tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
|
tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
|
||||||
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟
|
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟
|
||||||
timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8(中国时区)
|
timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8(中国时区)
|
||||||
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8 // UTC偏移小时数,默认+8
|
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8, // UTC偏移小时数,默认+8
|
||||||
|
metricsWindow: parseInt(process.env.METRICS_WINDOW) || 5 // 实时指标统计窗口(分钟)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 🎨 Web界面配置
|
// 🎨 Web界面配置
|
||||||
|
|||||||
64
config/models.js
Normal file
64
config/models.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 模型列表配置
|
||||||
|
* 用于前端展示和测试功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CLAUDE_MODELS = [
|
||||||
|
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||||
|
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' },
|
||||||
|
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||||
|
{ value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' },
|
||||||
|
{ value: 'claude-opus-4-20250514', label: 'Claude Opus 4' },
|
||||||
|
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' },
|
||||||
|
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const GEMINI_MODELS = [
|
||||||
|
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||||
|
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||||
|
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||||
|
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const OPENAI_MODELS = [
|
||||||
|
{ value: 'gpt-5', label: 'GPT-5' },
|
||||||
|
{ value: 'gpt-5-mini', label: 'GPT-5 Mini' },
|
||||||
|
{ value: 'gpt-5-nano', label: 'GPT-5 Nano' },
|
||||||
|
{ value: 'gpt-5.1', label: 'GPT-5.1' },
|
||||||
|
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||||
|
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||||
|
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||||
|
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||||
|
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||||
|
{ value: 'codex-mini', label: 'Codex Mini' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 其他模型(用于账户编辑的模型映射)
|
||||||
|
const OTHER_MODELS = [
|
||||||
|
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
|
||||||
|
{ value: 'Qwen', label: 'Qwen' },
|
||||||
|
{ value: 'Kimi', label: 'Kimi' },
|
||||||
|
{ value: 'GLM', label: 'GLM' }
|
||||||
|
]
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CLAUDE_MODELS,
|
||||||
|
GEMINI_MODELS,
|
||||||
|
OPENAI_MODELS,
|
||||||
|
OTHER_MODELS,
|
||||||
|
// 按服务分组
|
||||||
|
getModelsByService: (service) => {
|
||||||
|
switch (service) {
|
||||||
|
case 'claude':
|
||||||
|
return CLAUDE_MODELS
|
||||||
|
case 'gemini':
|
||||||
|
return GEMINI_MODELS
|
||||||
|
case 'openai':
|
||||||
|
return OPENAI_MODELS
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 获取所有模型(用于账户编辑)
|
||||||
|
getAllModels: () => [...CLAUDE_MODELS, ...GEMINI_MODELS, ...OPENAI_MODELS, ...OTHER_MODELS]
|
||||||
|
}
|
||||||
21
src/app.js
21
src/app.js
@@ -52,11 +52,32 @@ class Application {
|
|||||||
await redis.connect()
|
await redis.connect()
|
||||||
logger.success('Redis connected successfully')
|
logger.success('Redis connected successfully')
|
||||||
|
|
||||||
|
// 📊 检查数据迁移(版本 > 1.1.250 时执行)
|
||||||
|
const { getAppVersion, versionGt } = require('./utils/commonHelper')
|
||||||
|
const currentVersion = getAppVersion()
|
||||||
|
const migratedVersion = await redis.getMigratedVersion()
|
||||||
|
if (versionGt(currentVersion, '1.1.250') && versionGt(currentVersion, migratedVersion)) {
|
||||||
|
logger.info(`🔄 检测到新版本 ${currentVersion},检查数据迁移...`)
|
||||||
|
try {
|
||||||
|
if (await redis.needsGlobalStatsMigration()) {
|
||||||
|
await redis.migrateGlobalStats()
|
||||||
|
}
|
||||||
|
await redis.cleanupSystemMetrics() // 清理过期的系统分钟统计
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('⚠️ 数据迁移出错,但不影响启动:', err.message)
|
||||||
|
}
|
||||||
|
await redis.setMigratedVersion(currentVersion)
|
||||||
|
logger.success(`✅ 数据迁移完成,版本: ${currentVersion}`)
|
||||||
|
}
|
||||||
|
|
||||||
// 📊 后台异步迁移 usage 索引(不阻塞启动)
|
// 📊 后台异步迁移 usage 索引(不阻塞启动)
|
||||||
redis.migrateUsageIndex().catch((err) => {
|
redis.migrateUsageIndex().catch((err) => {
|
||||||
logger.error('📊 Background usage index migration failed:', err)
|
logger.error('📊 Background usage index migration failed:', err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 📊 迁移 alltime 模型统计(阻塞式,确保数据完整)
|
||||||
|
await redis.migrateAlltimeModelStats()
|
||||||
|
|
||||||
// 💰 初始化价格服务
|
// 💰 初始化价格服务
|
||||||
logger.info('🔄 Initializing pricing service...')
|
logger.info('🔄 Initializing pricing service...')
|
||||||
await pricingService.initialize()
|
await pricingService.initialize()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const crypto = require('crypto')
|
|||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
|
const redis = require('../models/redis')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
const { parseSSELine } = require('../utils/sseParser')
|
const { parseSSELine } = require('../utils/sseParser')
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
@@ -805,16 +806,18 @@ function handleModelDetails(req, res) {
|
|||||||
*/
|
*/
|
||||||
async function handleUsage(req, res) {
|
async function handleUsage(req, res) {
|
||||||
try {
|
try {
|
||||||
const { usage } = req.apiKey
|
const keyData = req.apiKey
|
||||||
|
// 按需查询 usage 数据
|
||||||
|
const usage = await redis.getUsageStats(keyData.id)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
object: 'usage',
|
object: 'usage',
|
||||||
total_tokens: usage.total.tokens,
|
total_tokens: usage?.total?.tokens || 0,
|
||||||
total_requests: usage.total.requests,
|
total_requests: usage?.total?.requests || 0,
|
||||||
daily_tokens: usage.daily.tokens,
|
daily_tokens: usage?.daily?.tokens || 0,
|
||||||
daily_requests: usage.daily.requests,
|
daily_requests: usage?.daily?.requests || 0,
|
||||||
monthly_tokens: usage.monthly.tokens,
|
monthly_tokens: usage?.monthly?.tokens || 0,
|
||||||
monthly_requests: usage.monthly.requests
|
monthly_requests: usage?.monthly?.requests || 0
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get usage stats:', error)
|
logger.error('Failed to get usage stats:', error)
|
||||||
@@ -833,17 +836,18 @@ async function handleUsage(req, res) {
|
|||||||
async function handleKeyInfo(req, res) {
|
async function handleKeyInfo(req, res) {
|
||||||
try {
|
try {
|
||||||
const keyData = req.apiKey
|
const keyData = req.apiKey
|
||||||
|
// 按需查询 usage 数据(仅 key-info 端点需要)
|
||||||
|
const usage = await redis.getUsageStats(keyData.id)
|
||||||
|
const tokensUsed = usage?.total?.tokens || 0
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: keyData.id,
|
id: keyData.id,
|
||||||
name: keyData.name,
|
name: keyData.name,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions || 'all',
|
||||||
token_limit: keyData.tokenLimit,
|
token_limit: keyData.tokenLimit,
|
||||||
tokens_used: keyData.usage.total.tokens,
|
tokens_used: tokensUsed,
|
||||||
tokens_remaining:
|
tokens_remaining:
|
||||||
keyData.tokenLimit > 0
|
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
|
||||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
|
||||||
: null,
|
|
||||||
rate_limit: {
|
rate_limit: {
|
||||||
window: keyData.rateLimitWindow,
|
window: keyData.rateLimitWindow,
|
||||||
requests: keyData.rateLimitRequests
|
requests: keyData.rateLimitRequests
|
||||||
|
|||||||
@@ -1306,10 +1306,8 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
dailyCostLimit: validation.keyData.dailyCostLimit,
|
dailyCostLimit: validation.keyData.dailyCostLimit,
|
||||||
dailyCost: validation.keyData.dailyCost,
|
dailyCost: validation.keyData.dailyCost,
|
||||||
totalCostLimit: validation.keyData.totalCostLimit,
|
totalCostLimit: validation.keyData.totalCostLimit,
|
||||||
totalCost: validation.keyData.totalCost,
|
totalCost: validation.keyData.totalCost
|
||||||
usage: validation.keyData.usage
|
|
||||||
}
|
}
|
||||||
req.usage = validation.keyData.usage
|
|
||||||
|
|
||||||
const authDuration = Date.now() - startTime
|
const authDuration = Date.now() - startTime
|
||||||
const userAgent = req.headers['user-agent'] || 'No User-Agent'
|
const userAgent = req.headers['user-agent'] || 'No User-Agent'
|
||||||
|
|||||||
@@ -299,6 +299,96 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔄 自动迁移 alltime 模型统计(启动时调用)
|
||||||
|
async migrateAlltimeModelStats() {
|
||||||
|
const migrationKey = 'system:migration:alltime_model_stats_v1'
|
||||||
|
const migrated = await this.client.get(migrationKey)
|
||||||
|
if (migrated) {
|
||||||
|
logger.debug('📊 Alltime model stats migration already completed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('📊 Starting alltime model stats migration...')
|
||||||
|
const stats = { keys: 0, models: 0 }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 扫描所有月度模型统计数据并聚合到 alltime
|
||||||
|
// 格式: usage:{keyId}:model:monthly:{model}:{month}
|
||||||
|
let cursor = '0'
|
||||||
|
const aggregatedData = new Map() // keyId:model -> {inputTokens, outputTokens, ...}
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await this.client.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
'usage:*:model:monthly:*:*',
|
||||||
|
'COUNT',
|
||||||
|
500
|
||||||
|
)
|
||||||
|
cursor = newCursor
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
// usage:{keyId}:model:monthly:{model}:{month}
|
||||||
|
const match = key.match(/^usage:([^:]+):model:monthly:(.+):(\d{4}-\d{2})$/)
|
||||||
|
if (match) {
|
||||||
|
const [, keyId, model] = match
|
||||||
|
const aggregateKey = `${keyId}:${model}`
|
||||||
|
|
||||||
|
// 获取该月的数据
|
||||||
|
const data = await this.client.hgetall(key)
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
if (!aggregatedData.has(aggregateKey)) {
|
||||||
|
aggregatedData.set(aggregateKey, {
|
||||||
|
keyId,
|
||||||
|
model,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
requests: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const agg = aggregatedData.get(aggregateKey)
|
||||||
|
agg.inputTokens += parseInt(data.inputTokens) || 0
|
||||||
|
agg.outputTokens += parseInt(data.outputTokens) || 0
|
||||||
|
agg.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||||||
|
agg.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||||||
|
agg.requests += parseInt(data.requests) || 0
|
||||||
|
stats.keys++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
// 写入聚合后的 alltime 数据
|
||||||
|
const pipeline = this.client.pipeline()
|
||||||
|
for (const [, agg] of aggregatedData) {
|
||||||
|
const alltimeKey = `usage:${agg.keyId}:model:alltime:${agg.model}`
|
||||||
|
pipeline.hset(alltimeKey, {
|
||||||
|
inputTokens: agg.inputTokens.toString(),
|
||||||
|
outputTokens: agg.outputTokens.toString(),
|
||||||
|
cacheCreateTokens: agg.cacheCreateTokens.toString(),
|
||||||
|
cacheReadTokens: agg.cacheReadTokens.toString(),
|
||||||
|
requests: agg.requests.toString()
|
||||||
|
})
|
||||||
|
stats.models++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.models > 0) {
|
||||||
|
await pipeline.exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记迁移完成
|
||||||
|
await this.client.set(migrationKey, Date.now().toString())
|
||||||
|
logger.info(
|
||||||
|
`📊 Alltime model stats migration completed: scanned ${stats.keys} monthly keys, created ${stats.models} alltime keys`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('📊 Alltime model stats migration failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
if (this.client) {
|
if (this.client) {
|
||||||
await this.client.quit()
|
await this.client.quit()
|
||||||
@@ -996,6 +1086,14 @@ class RedisClient {
|
|||||||
pipeline.hincrby(keyModelMonthly, 'ephemeral5mTokens', ephemeral5mTokens)
|
pipeline.hincrby(keyModelMonthly, 'ephemeral5mTokens', ephemeral5mTokens)
|
||||||
pipeline.hincrby(keyModelMonthly, 'ephemeral1hTokens', ephemeral1hTokens)
|
pipeline.hincrby(keyModelMonthly, 'ephemeral1hTokens', ephemeral1hTokens)
|
||||||
|
|
||||||
|
// API Key级别的模型统计 - 所有时间(无 TTL)
|
||||||
|
const keyModelAlltime = `usage:${keyId}:model:alltime:${normalizedModel}`
|
||||||
|
pipeline.hincrby(keyModelAlltime, 'inputTokens', finalInputTokens)
|
||||||
|
pipeline.hincrby(keyModelAlltime, 'outputTokens', finalOutputTokens)
|
||||||
|
pipeline.hincrby(keyModelAlltime, 'cacheCreateTokens', finalCacheCreateTokens)
|
||||||
|
pipeline.hincrby(keyModelAlltime, 'cacheReadTokens', finalCacheReadTokens)
|
||||||
|
pipeline.hincrby(keyModelAlltime, 'requests', 1)
|
||||||
|
|
||||||
// 小时级别统计
|
// 小时级别统计
|
||||||
pipeline.hincrby(hourly, 'tokens', coreTokens)
|
pipeline.hincrby(hourly, 'tokens', coreTokens)
|
||||||
pipeline.hincrby(hourly, 'inputTokens', finalInputTokens)
|
pipeline.hincrby(hourly, 'inputTokens', finalInputTokens)
|
||||||
@@ -1040,9 +1138,9 @@ class RedisClient {
|
|||||||
pipeline.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期
|
pipeline.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期
|
||||||
pipeline.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期
|
pipeline.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期
|
||||||
|
|
||||||
// 系统级分钟统计的过期时间(窗口时间的2倍)
|
// 系统级分钟统计的过期时间(窗口时间的2倍,默认5分钟)
|
||||||
const configLocal = require('../../config/config')
|
const configLocal = require('../../config/config')
|
||||||
const { metricsWindow } = configLocal.system
|
const metricsWindow = configLocal.system?.metricsWindow || 5
|
||||||
pipeline.expire(systemMinuteKey, metricsWindow * 60 * 2)
|
pipeline.expire(systemMinuteKey, metricsWindow * 60 * 2)
|
||||||
|
|
||||||
// 添加索引(用于快速查询,避免 SCAN)
|
// 添加索引(用于快速查询,避免 SCAN)
|
||||||
@@ -1071,6 +1169,30 @@ class RedisClient {
|
|||||||
pipeline.expire(`usage:keymodel:daily:index:${today}`, 86400 * 32)
|
pipeline.expire(`usage:keymodel:daily:index:${today}`, 86400 * 32)
|
||||||
pipeline.expire(`usage:keymodel:hourly:index:${currentHour}`, 86400 * 7)
|
pipeline.expire(`usage:keymodel:hourly:index:${currentHour}`, 86400 * 7)
|
||||||
|
|
||||||
|
// 全局预聚合统计
|
||||||
|
const globalDaily = `usage:global:daily:${today}`
|
||||||
|
const globalMonthly = `usage:global:monthly:${currentMonth}`
|
||||||
|
pipeline.hincrby('usage:global:total', 'requests', 1)
|
||||||
|
pipeline.hincrby('usage:global:total', 'inputTokens', finalInputTokens)
|
||||||
|
pipeline.hincrby('usage:global:total', 'outputTokens', finalOutputTokens)
|
||||||
|
pipeline.hincrby('usage:global:total', 'cacheCreateTokens', finalCacheCreateTokens)
|
||||||
|
pipeline.hincrby('usage:global:total', 'cacheReadTokens', finalCacheReadTokens)
|
||||||
|
pipeline.hincrby('usage:global:total', 'allTokens', totalTokens)
|
||||||
|
pipeline.hincrby(globalDaily, 'requests', 1)
|
||||||
|
pipeline.hincrby(globalDaily, 'inputTokens', finalInputTokens)
|
||||||
|
pipeline.hincrby(globalDaily, 'outputTokens', finalOutputTokens)
|
||||||
|
pipeline.hincrby(globalDaily, 'cacheCreateTokens', finalCacheCreateTokens)
|
||||||
|
pipeline.hincrby(globalDaily, 'cacheReadTokens', finalCacheReadTokens)
|
||||||
|
pipeline.hincrby(globalDaily, 'allTokens', totalTokens)
|
||||||
|
pipeline.hincrby(globalMonthly, 'requests', 1)
|
||||||
|
pipeline.hincrby(globalMonthly, 'inputTokens', finalInputTokens)
|
||||||
|
pipeline.hincrby(globalMonthly, 'outputTokens', finalOutputTokens)
|
||||||
|
pipeline.hincrby(globalMonthly, 'cacheCreateTokens', finalCacheCreateTokens)
|
||||||
|
pipeline.hincrby(globalMonthly, 'cacheReadTokens', finalCacheReadTokens)
|
||||||
|
pipeline.hincrby(globalMonthly, 'allTokens', totalTokens)
|
||||||
|
pipeline.expire(globalDaily, 86400 * 32)
|
||||||
|
pipeline.expire(globalMonthly, 86400 * 365)
|
||||||
|
|
||||||
// 执行Pipeline
|
// 执行Pipeline
|
||||||
await pipeline.exec()
|
await pipeline.exec()
|
||||||
}
|
}
|
||||||
@@ -4521,4 +4643,151 @@ redisClient.removeFromIndex = async function (indexKey, id) {
|
|||||||
await client.srem(indexKey, id)
|
await client.srem(indexKey, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 数据迁移相关
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 迁移全局统计数据(从 API Key 数据聚合)
|
||||||
|
redisClient.migrateGlobalStats = async function () {
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
logger.info('🔄 开始迁移全局统计数据...')
|
||||||
|
|
||||||
|
const keyIds = await this.scanApiKeyIds()
|
||||||
|
if (!keyIds || keyIds.length === 0) {
|
||||||
|
logger.info('📊 没有 API Key 数据需要迁移')
|
||||||
|
return { success: true, migrated: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = {
|
||||||
|
requests: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
allTokens: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量获取所有 usage 数据
|
||||||
|
const pipeline = this.client.pipeline()
|
||||||
|
keyIds.forEach((id) => pipeline.hgetall(`usage:${id}`))
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
results.forEach(([err, usage]) => {
|
||||||
|
if (err || !usage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 兼容新旧字段格式(带 total 前缀和不带的)
|
||||||
|
total.requests += parseInt(usage.totalRequests || usage.requests) || 0
|
||||||
|
total.inputTokens += parseInt(usage.totalInputTokens || usage.inputTokens) || 0
|
||||||
|
total.outputTokens += parseInt(usage.totalOutputTokens || usage.outputTokens) || 0
|
||||||
|
total.cacheCreateTokens +=
|
||||||
|
parseInt(usage.totalCacheCreateTokens || usage.cacheCreateTokens) || 0
|
||||||
|
total.cacheReadTokens += parseInt(usage.totalCacheReadTokens || usage.cacheReadTokens) || 0
|
||||||
|
total.allTokens += parseInt(usage.totalAllTokens || usage.allTokens || usage.totalTokens) || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 写入全局统计
|
||||||
|
await this.client.hset('usage:global:total', total)
|
||||||
|
logger.success(
|
||||||
|
`✅ 迁移完成: ${keyIds.length} 个 API Key, ${total.requests} 请求, ${total.allTokens} tokens`
|
||||||
|
)
|
||||||
|
return { success: true, migrated: keyIds.length, total }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要迁移
|
||||||
|
redisClient.needsGlobalStatsMigration = async function () {
|
||||||
|
const exists = await this.client.exists('usage:global:total')
|
||||||
|
return exists === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取已迁移版本
|
||||||
|
redisClient.getMigratedVersion = async function () {
|
||||||
|
return (await this.client.get('system:migrated:version')) || '0.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置已迁移版本
|
||||||
|
redisClient.setMigratedVersion = async function (version) {
|
||||||
|
await this.client.set('system:migrated:version', version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取全局统计(用于 dashboard 快速查询)
|
||||||
|
redisClient.getGlobalStats = async function () {
|
||||||
|
const stats = await this.client.hgetall('usage:global:total')
|
||||||
|
if (!stats || !stats.requests) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requests: parseInt(stats.requests) || 0,
|
||||||
|
inputTokens: parseInt(stats.inputTokens) || 0,
|
||||||
|
outputTokens: parseInt(stats.outputTokens) || 0,
|
||||||
|
cacheCreateTokens: parseInt(stats.cacheCreateTokens) || 0,
|
||||||
|
cacheReadTokens: parseInt(stats.cacheReadTokens) || 0,
|
||||||
|
allTokens: parseInt(stats.allTokens) || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快速获取 API Key 计数(不拉全量数据)
|
||||||
|
redisClient.getApiKeyCount = async function () {
|
||||||
|
const keyIds = await this.scanApiKeyIds()
|
||||||
|
if (!keyIds || keyIds.length === 0) {
|
||||||
|
return { total: 0, active: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量获取 isActive 字段
|
||||||
|
const pipeline = this.client.pipeline()
|
||||||
|
keyIds.forEach((id) => pipeline.hget(`apikey:${id}`, 'isActive'))
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
|
||||||
|
let active = 0
|
||||||
|
results.forEach(([err, val]) => {
|
||||||
|
if (!err && (val === 'true' || val === true)) {
|
||||||
|
active++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { total: keyIds.length, active }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期的系统分钟统计数据(启动时调用)
|
||||||
|
redisClient.cleanupSystemMetrics = async function () {
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
logger.info('🧹 清理过期的系统分钟统计数据...')
|
||||||
|
|
||||||
|
const keys = await this.scanKeys('system:metrics:minute:*')
|
||||||
|
if (!keys || keys.length === 0) {
|
||||||
|
logger.info('📊 没有需要清理的系统分钟统计数据')
|
||||||
|
return { cleaned: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算当前分钟时间戳和保留窗口
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const metricsWindow = config.system?.metricsWindow || 5
|
||||||
|
const currentMinute = Math.floor(Date.now() / 60000)
|
||||||
|
const keepAfter = currentMinute - metricsWindow * 2 // 保留窗口的2倍
|
||||||
|
|
||||||
|
// 筛选需要删除的 key
|
||||||
|
const toDelete = keys.filter((key) => {
|
||||||
|
const match = key.match(/system:metrics:minute:(\d+)/)
|
||||||
|
if (!match) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const minute = parseInt(match[1])
|
||||||
|
return minute < keepAfter
|
||||||
|
})
|
||||||
|
|
||||||
|
if (toDelete.length === 0) {
|
||||||
|
logger.info('📊 没有过期的系统分钟统计数据')
|
||||||
|
return { cleaned: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分批删除
|
||||||
|
const batchSize = 1000
|
||||||
|
for (let i = 0; i < toDelete.length; i += batchSize) {
|
||||||
|
const batch = toDelete.slice(i, i + batchSize)
|
||||||
|
await this.client.del(...batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`✅ 清理完成: 删除 ${toDelete.length} 个过期的系统分钟统计 key`)
|
||||||
|
return { cleaned: toDelete.length }
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = redisClient
|
module.exports = redisClient
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ const config = require('../../../config/config')
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 有效的服务权限值
|
||||||
|
const VALID_SERVICES = ['claude', 'gemini', 'openai', 'droid']
|
||||||
|
|
||||||
|
// 验证 permissions 值(支持单选和多选)
|
||||||
|
const isValidPermissions = (permissions) => {
|
||||||
|
if (!permissions || permissions === 'all') return true
|
||||||
|
// 支持逗号分隔的多选格式
|
||||||
|
const services = permissions.split(',')
|
||||||
|
return services.every((s) => VALID_SERVICES.includes(s.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
// 👥 用户管理 (用于API Key分配)
|
// 👥 用户管理 (用于API Key分配)
|
||||||
|
|
||||||
// 获取所有用户列表(用于API Key分配)
|
// 获取所有用户列表(用于API Key分配)
|
||||||
@@ -1430,10 +1441,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
permissions !== undefined &&
|
permissions !== undefined &&
|
||||||
permissions !== null &&
|
permissions !== null &&
|
||||||
permissions !== '' &&
|
permissions !== '' &&
|
||||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
!isValidPermissions(permissions)
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1528,10 +1539,10 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
permissions !== undefined &&
|
permissions !== undefined &&
|
||||||
permissions !== null &&
|
permissions !== null &&
|
||||||
permissions !== '' &&
|
permissions !== '' &&
|
||||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)
|
!isValidPermissions(permissions)
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1637,10 +1648,10 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
updates.permissions !== undefined &&
|
updates.permissions !== undefined &&
|
||||||
!['claude', 'gemini', 'openai', 'droid', 'all'].includes(updates.permissions)
|
!isValidPermissions(updates.permissions)
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1917,9 +1928,9 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
if (permissions !== undefined) {
|
if (permissions !== undefined) {
|
||||||
// 验证权限值
|
// 验证权限值
|
||||||
if (!['claude', 'gemini', 'openai', 'droid', 'all'].includes(permissions)) {
|
if (!isValidPermissions(permissions)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, or all'
|
error: 'Invalid permissions value. Must be claude, gemini, openai, droid, all, or comma-separated combination'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
updates.permissions = permissions
|
updates.permissions = permissions
|
||||||
|
|||||||
@@ -414,4 +414,84 @@ router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试 Azure OpenAI 账户连通性
|
||||||
|
router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await azureOpenaiAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取解密后的 API Key
|
||||||
|
const apiKey = await azureOpenaiAccountService.getDecryptedApiKey(accountId)
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造测试请求
|
||||||
|
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||||
|
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
const deploymentName = account.deploymentName || 'gpt-4o-mini'
|
||||||
|
const apiVersion = account.apiVersion || '2024-02-15-preview'
|
||||||
|
const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`
|
||||||
|
const payload = createOpenAITestPayload(deploymentName)
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'api-key': apiKey
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理
|
||||||
|
if (account.proxy) {
|
||||||
|
const agent = getProxyAgent(account.proxy)
|
||||||
|
if (agent) {
|
||||||
|
requestConfig.httpsAgent = agent
|
||||||
|
requestConfig.httpAgent = agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
let responseText = ''
|
||||||
|
if (response.data?.choices?.[0]?.message?.content) {
|
||||||
|
responseText = response.data.choices[0].message.content
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ Azure OpenAI account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
accountName: account.name,
|
||||||
|
model: deploymentName,
|
||||||
|
latency,
|
||||||
|
responseText: responseText.substring(0, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
logger.error(`❌ Azure OpenAI account test failed: ${accountId}`, error.message)
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message,
|
||||||
|
latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -413,4 +413,89 @@ router.post('/reset-all-usage', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试 CCR 账户连通性
|
||||||
|
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { model = 'claude-sonnet-4-20250514' } = req.body
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await ccrAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取解密后的凭据
|
||||||
|
const credentials = await ccrAccountService.getDecryptedCredentials(accountId)
|
||||||
|
if (!credentials) {
|
||||||
|
return res.status(401).json({ error: 'Credentials not found or decryption failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造测试请求
|
||||||
|
const axios = require('axios')
|
||||||
|
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
const baseUrl = account.baseUrl || 'https://api.anthropic.com'
|
||||||
|
const apiUrl = `${baseUrl}/v1/messages`
|
||||||
|
const payload = {
|
||||||
|
model,
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': credentials.apiKey,
|
||||||
|
'anthropic-version': '2023-06-01'
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理
|
||||||
|
if (account.proxy) {
|
||||||
|
const agent = getProxyAgent(account.proxy)
|
||||||
|
if (agent) {
|
||||||
|
requestConfig.httpsAgent = agent
|
||||||
|
requestConfig.httpAgent = agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
let responseText = ''
|
||||||
|
if (response.data?.content?.[0]?.text) {
|
||||||
|
responseText = response.data.content[0].text
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ CCR account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
accountName: account.name,
|
||||||
|
model,
|
||||||
|
latency,
|
||||||
|
responseText: responseText.substring(0, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
logger.error(`❌ CCR account test failed: ${accountId}`, error.message)
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message,
|
||||||
|
latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -20,8 +20,14 @@ const router = express.Router()
|
|||||||
// 获取系统概览
|
// 获取系统概览
|
||||||
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// 先检查是否有全局预聚合数据
|
||||||
|
const globalStats = await redis.getGlobalStats()
|
||||||
|
|
||||||
|
// 根据是否有全局统计决定查询策略
|
||||||
|
let apiKeys = null
|
||||||
|
let apiKeyCount = null
|
||||||
|
|
||||||
const [
|
const [
|
||||||
apiKeys,
|
|
||||||
claudeAccounts,
|
claudeAccounts,
|
||||||
claudeConsoleAccounts,
|
claudeConsoleAccounts,
|
||||||
geminiAccounts,
|
geminiAccounts,
|
||||||
@@ -34,7 +40,6 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
systemAverages,
|
systemAverages,
|
||||||
realtimeMetrics
|
realtimeMetrics
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
apiKeyService.getAllApiKeysFast(),
|
|
||||||
claudeAccountService.getAllAccounts(),
|
claudeAccountService.getAllAccounts(),
|
||||||
claudeConsoleAccountService.getAllAccounts(),
|
claudeConsoleAccountService.getAllAccounts(),
|
||||||
geminiAccountService.getAllAccounts(),
|
geminiAccountService.getAllAccounts(),
|
||||||
@@ -48,6 +53,13 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
redis.getRealtimeSystemMetrics()
|
redis.getRealtimeSystemMetrics()
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// 有全局统计时只获取计数,否则拉全量
|
||||||
|
if (globalStats) {
|
||||||
|
apiKeyCount = await redis.getApiKeyCount()
|
||||||
|
} else {
|
||||||
|
apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||||
|
}
|
||||||
|
|
||||||
// 处理Bedrock账户数据
|
// 处理Bedrock账户数据
|
||||||
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
|
||||||
const normalizeBoolean = (value) => value === true || value === 'true'
|
const normalizeBoolean = (value) => value === true || value === 'true'
|
||||||
@@ -122,7 +134,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算使用统计(单次遍历)
|
// 计算使用统计
|
||||||
let totalTokensUsed = 0,
|
let totalTokensUsed = 0,
|
||||||
totalRequestsUsed = 0,
|
totalRequestsUsed = 0,
|
||||||
totalInputTokensUsed = 0,
|
totalInputTokensUsed = 0,
|
||||||
@@ -130,20 +142,37 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
totalCacheCreateTokensUsed = 0,
|
totalCacheCreateTokensUsed = 0,
|
||||||
totalCacheReadTokensUsed = 0,
|
totalCacheReadTokensUsed = 0,
|
||||||
totalAllTokensUsed = 0,
|
totalAllTokensUsed = 0,
|
||||||
activeApiKeys = 0
|
activeApiKeys = 0,
|
||||||
for (const key of apiKeys) {
|
totalApiKeys = 0
|
||||||
const usage = key.usage?.total
|
|
||||||
if (usage) {
|
if (globalStats) {
|
||||||
totalTokensUsed += usage.allTokens || 0
|
// 使用预聚合数据(快速路径)
|
||||||
totalRequestsUsed += usage.requests || 0
|
totalRequestsUsed = globalStats.requests
|
||||||
totalInputTokensUsed += usage.inputTokens || 0
|
totalInputTokensUsed = globalStats.inputTokens
|
||||||
totalOutputTokensUsed += usage.outputTokens || 0
|
totalOutputTokensUsed = globalStats.outputTokens
|
||||||
totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0
|
totalCacheCreateTokensUsed = globalStats.cacheCreateTokens
|
||||||
totalCacheReadTokensUsed += usage.cacheReadTokens || 0
|
totalCacheReadTokensUsed = globalStats.cacheReadTokens
|
||||||
totalAllTokensUsed += usage.allTokens || 0
|
totalAllTokensUsed = globalStats.allTokens
|
||||||
}
|
totalTokensUsed = totalAllTokensUsed
|
||||||
if (key.isActive) {
|
totalApiKeys = apiKeyCount.total
|
||||||
activeApiKeys++
|
activeApiKeys = apiKeyCount.active
|
||||||
|
} else {
|
||||||
|
// 回退到遍历(兼容旧数据)
|
||||||
|
totalApiKeys = apiKeys.length
|
||||||
|
for (const key of apiKeys) {
|
||||||
|
const usage = key.usage?.total
|
||||||
|
if (usage) {
|
||||||
|
totalTokensUsed += usage.allTokens || 0
|
||||||
|
totalRequestsUsed += usage.requests || 0
|
||||||
|
totalInputTokensUsed += usage.inputTokens || 0
|
||||||
|
totalOutputTokensUsed += usage.outputTokens || 0
|
||||||
|
totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0
|
||||||
|
totalCacheReadTokensUsed += usage.cacheReadTokens || 0
|
||||||
|
totalAllTokensUsed += usage.allTokens || 0
|
||||||
|
}
|
||||||
|
if (key.isActive) {
|
||||||
|
activeApiKeys++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +187,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
const dashboard = {
|
const dashboard = {
|
||||||
overview: {
|
overview: {
|
||||||
totalApiKeys: apiKeys.length,
|
totalApiKeys,
|
||||||
activeApiKeys,
|
activeApiKeys,
|
||||||
// 总账户统计(所有平台)
|
// 总账户统计(所有平台)
|
||||||
totalAccounts:
|
totalAccounts:
|
||||||
|
|||||||
@@ -196,31 +196,56 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
// 处理统计数据
|
// 处理统计数据
|
||||||
const allUsageStatsMap = new Map()
|
const allUsageStatsMap = new Map()
|
||||||
|
const parseUsage = (data) => ({
|
||||||
|
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
||||||
|
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
||||||
|
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
||||||
|
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
||||||
|
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
||||||
|
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
||||||
|
allTokens:
|
||||||
|
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
||||||
|
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
||||||
|
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
||||||
|
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
||||||
|
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建 accountId -> createdAt 映射用于计算 averages
|
||||||
|
const accountCreatedAtMap = new Map()
|
||||||
|
for (const account of accounts) {
|
||||||
|
accountCreatedAtMap.set(
|
||||||
|
account.id,
|
||||||
|
account.createdAt ? new Date(account.createdAt) : new Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < accountIds.length; i++) {
|
for (let i = 0; i < accountIds.length; i++) {
|
||||||
const accountId = accountIds[i]
|
const accountId = accountIds[i]
|
||||||
const [errTotal, total] = statsResults[i * 3]
|
const [errTotal, total] = statsResults[i * 3]
|
||||||
const [errDaily, daily] = statsResults[i * 3 + 1]
|
const [errDaily, daily] = statsResults[i * 3 + 1]
|
||||||
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
const [errMonthly, monthly] = statsResults[i * 3 + 2]
|
||||||
|
|
||||||
const parseUsage = (data) => ({
|
const totalData = errTotal ? {} : parseUsage(total)
|
||||||
requests: parseInt(data?.totalRequests || data?.requests) || 0,
|
const totalTokens = totalData.tokens || 0
|
||||||
tokens: parseInt(data?.totalTokens || data?.tokens) || 0,
|
const totalRequests = totalData.requests || 0
|
||||||
inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0,
|
|
||||||
outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0,
|
// 计算 averages
|
||||||
cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0,
|
const createdAt = accountCreatedAtMap.get(accountId)
|
||||||
cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0,
|
const now = new Date()
|
||||||
allTokens:
|
const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24)))
|
||||||
parseInt(data?.totalAllTokens || data?.allTokens) ||
|
const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60)
|
||||||
(parseInt(data?.totalInputTokens || data?.inputTokens) || 0) +
|
|
||||||
(parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) +
|
|
||||||
(parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) +
|
|
||||||
(parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
allUsageStatsMap.set(accountId, {
|
allUsageStatsMap.set(accountId, {
|
||||||
total: errTotal ? {} : parseUsage(total),
|
total: totalData,
|
||||||
daily: errDaily ? {} : parseUsage(daily),
|
daily: errDaily ? {} : parseUsage(daily),
|
||||||
monthly: errMonthly ? {} : parseUsage(monthly)
|
monthly: errMonthly ? {} : parseUsage(monthly),
|
||||||
|
averages: {
|
||||||
|
rpm: Math.round((totalRequests / totalMinutes) * 100) / 100,
|
||||||
|
tpm: Math.round((totalTokens / totalMinutes) * 100) / 100,
|
||||||
|
dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100,
|
||||||
|
dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +255,8 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
const usageStats = allUsageStatsMap.get(account.id) || {
|
const usageStats = allUsageStatsMap.get(account.id) || {
|
||||||
daily: { tokens: 0, requests: 0 },
|
daily: { tokens: 0, requests: 0 },
|
||||||
total: { tokens: 0, requests: 0 },
|
total: { tokens: 0, requests: 0 },
|
||||||
monthly: { tokens: 0, requests: 0 }
|
monthly: { tokens: 0, requests: 0 },
|
||||||
|
averages: { rpm: 0, tpm: 0, dailyRequests: 0, dailyTokens: 0 }
|
||||||
}
|
}
|
||||||
const dailyCost = dailyCostMap.get(account.id) || 0
|
const dailyCost = dailyCostMap.get(account.id) || 0
|
||||||
|
|
||||||
@@ -249,7 +275,8 @@ router.get('/droid-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
usage: {
|
usage: {
|
||||||
daily: { ...usageStats.daily, cost: dailyCost },
|
daily: { ...usageStats.daily, cost: dailyCost },
|
||||||
total: usageStats.total,
|
total: usageStats.total,
|
||||||
monthly: usageStats.monthly
|
monthly: usageStats.monthly,
|
||||||
|
averages: usageStats.averages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -574,4 +601,92 @@ router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req,
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试 Droid 账户连通性
|
||||||
|
router.post('/droid-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { model = 'claude-sonnet-4-20250514' } = req.body
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await droidAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 token 有效
|
||||||
|
const tokenResult = await droidAccountService.ensureValidToken(accountId)
|
||||||
|
if (!tokenResult.success) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Token refresh failed',
|
||||||
|
message: tokenResult.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = tokenResult.accessToken
|
||||||
|
|
||||||
|
// 构造测试请求
|
||||||
|
const axios = require('axios')
|
||||||
|
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
const apiUrl = 'https://api.factory.ai/v1/messages'
|
||||||
|
const payload = {
|
||||||
|
model,
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{ role: 'user', content: 'Say "Hello" in one word.' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理
|
||||||
|
if (account.proxy) {
|
||||||
|
const agent = getProxyAgent(account.proxy)
|
||||||
|
if (agent) {
|
||||||
|
requestConfig.httpsAgent = agent
|
||||||
|
requestConfig.httpAgent = agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
let responseText = ''
|
||||||
|
if (response.data?.content?.[0]?.text) {
|
||||||
|
responseText = response.data.content[0].text
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ Droid account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
accountName: account.name,
|
||||||
|
model,
|
||||||
|
latency,
|
||||||
|
responseText: responseText.substring(0, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
logger.error(`❌ Droid account test failed: ${accountId}`, error.message)
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message,
|
||||||
|
latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -491,4 +491,89 @@ router.post('/:id/reset-status', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试 Gemini 账户连通性
|
||||||
|
router.post('/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { model = 'gemini-2.5-flash' } = req.body
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await geminiAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 token 有效
|
||||||
|
const tokenResult = await geminiAccountService.ensureValidToken(accountId)
|
||||||
|
if (!tokenResult.success) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Token refresh failed',
|
||||||
|
message: tokenResult.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = tokenResult.accessToken
|
||||||
|
|
||||||
|
// 构造测试请求
|
||||||
|
const axios = require('axios')
|
||||||
|
const { createGeminiTestPayload } = require('../../utils/testPayloadHelper')
|
||||||
|
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`
|
||||||
|
const payload = createGeminiTestPayload(model)
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理
|
||||||
|
if (account.proxy) {
|
||||||
|
const agent = getProxyAgent(account.proxy)
|
||||||
|
if (agent) {
|
||||||
|
requestConfig.httpsAgent = agent
|
||||||
|
requestConfig.httpAgent = agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
let responseText = ''
|
||||||
|
if (response.data?.candidates?.[0]?.content?.parts?.[0]?.text) {
|
||||||
|
responseText = response.data.candidates[0].content.parts[0].text
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ Gemini account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
accountName: account.name,
|
||||||
|
model,
|
||||||
|
latency,
|
||||||
|
responseText: responseText.substring(0, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
logger.error(`❌ Gemini account test failed: ${accountId}`, error.message)
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message,
|
||||||
|
latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const systemRoutes = require('./system')
|
|||||||
const concurrencyRoutes = require('./concurrency')
|
const concurrencyRoutes = require('./concurrency')
|
||||||
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||||
const syncRoutes = require('./sync')
|
const syncRoutes = require('./sync')
|
||||||
|
const serviceRatesRoutes = require('./serviceRates')
|
||||||
|
const quotaCardsRoutes = require('./quotaCards')
|
||||||
|
|
||||||
// 挂载所有子路由
|
// 挂载所有子路由
|
||||||
// 使用完整路径的模块(直接挂载到根路径)
|
// 使用完整路径的模块(直接挂载到根路径)
|
||||||
@@ -41,6 +43,8 @@ router.use('/', systemRoutes)
|
|||||||
router.use('/', concurrencyRoutes)
|
router.use('/', concurrencyRoutes)
|
||||||
router.use('/', claudeRelayConfigRoutes)
|
router.use('/', claudeRelayConfigRoutes)
|
||||||
router.use('/', syncRoutes)
|
router.use('/', syncRoutes)
|
||||||
|
router.use('/', serviceRatesRoutes)
|
||||||
|
router.use('/', quotaCardsRoutes)
|
||||||
|
|
||||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||||
router.use('/account-groups', accountGroupsRoutes)
|
router.use('/account-groups', accountGroupsRoutes)
|
||||||
|
|||||||
@@ -452,4 +452,85 @@ router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, asy
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 测试 OpenAI-Responses 账户连通性
|
||||||
|
router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, async (req, res) => {
|
||||||
|
const { accountId } = req.params
|
||||||
|
const { model = 'gpt-4o-mini' } = req.body
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取账户信息
|
||||||
|
const account = await openaiResponsesAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取解密后的 API Key
|
||||||
|
const apiKey = await openaiResponsesAccountService.getDecryptedApiKey(accountId)
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(401).json({ error: 'API Key not found or decryption failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造测试请求
|
||||||
|
const axios = require('axios')
|
||||||
|
const { createOpenAITestPayload } = require('../../utils/testPayloadHelper')
|
||||||
|
const { getProxyAgent } = require('../../utils/proxyHelper')
|
||||||
|
|
||||||
|
const baseUrl = account.baseUrl || 'https://api.openai.com'
|
||||||
|
const apiUrl = `${baseUrl}/v1/chat/completions`
|
||||||
|
const payload = createOpenAITestPayload(model)
|
||||||
|
|
||||||
|
const requestConfig = {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置代理
|
||||||
|
if (account.proxy) {
|
||||||
|
const agent = getProxyAgent(account.proxy)
|
||||||
|
if (agent) {
|
||||||
|
requestConfig.httpsAgent = agent
|
||||||
|
requestConfig.httpAgent = agent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, payload, requestConfig)
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
let responseText = ''
|
||||||
|
if (response.data?.choices?.[0]?.message?.content) {
|
||||||
|
responseText = response.data.choices[0].message.content
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`✅ OpenAI-Responses account test passed: ${account.name} (${accountId}), latency: ${latency}ms`
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
accountName: account.name,
|
||||||
|
model,
|
||||||
|
latency,
|
||||||
|
responseText: responseText.substring(0, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
logger.error(`❌ OpenAI-Responses account test failed: ${accountId}`, error.message)
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.response?.data?.error?.message || error.message,
|
||||||
|
latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
329
src/routes/admin/quotaCards.js
Normal file
329
src/routes/admin/quotaCards.js
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* 额度卡/时间卡管理路由
|
||||||
|
*/
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
const quotaCardService = require('../../services/quotaCardService')
|
||||||
|
const apiKeyService = require('../../services/apiKeyService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 额度卡管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// 获取额度卡列表
|
||||||
|
router.get('/quota-cards', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status, limit = 100, offset = 0 } = req.query
|
||||||
|
const result = await quotaCardService.getAllCards({
|
||||||
|
status,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset)
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get quota cards:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取额度卡统计
|
||||||
|
router.get('/quota-cards/stats', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = await quotaCardService.getCardStats()
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get quota card stats:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取单个额度卡详情
|
||||||
|
router.get('/quota-cards/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const card = await quotaCardService.getCardById(req.params.id)
|
||||||
|
if (!card) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Card not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: card
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get quota card:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建额度卡
|
||||||
|
router.post('/quota-cards', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, quotaAmount, timeAmount, timeUnit, expiresAt, note, count = 1 } = req.body
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'type is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdBy = req.session?.username || 'admin'
|
||||||
|
const options = {
|
||||||
|
type,
|
||||||
|
quotaAmount: parseFloat(quotaAmount || 0),
|
||||||
|
timeAmount: parseInt(timeAmount || 0),
|
||||||
|
timeUnit: timeUnit || 'days',
|
||||||
|
expiresAt,
|
||||||
|
note,
|
||||||
|
createdBy
|
||||||
|
}
|
||||||
|
|
||||||
|
let result
|
||||||
|
if (count > 1) {
|
||||||
|
result = await quotaCardService.createCardsBatch(options, Math.min(count, 100))
|
||||||
|
} else {
|
||||||
|
result = await quotaCardService.createCard(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create quota card:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除未使用的额度卡
|
||||||
|
router.delete('/quota-cards/:id', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await quotaCardService.deleteCard(req.params.id)
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete quota card:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 核销记录管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// 获取核销记录列表
|
||||||
|
router.get('/redemptions', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId, apiKeyId, limit = 100, offset = 0 } = req.query
|
||||||
|
const result = await quotaCardService.getRedemptions({
|
||||||
|
userId,
|
||||||
|
apiKeyId,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset)
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get redemptions:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 撤销核销
|
||||||
|
router.post('/redemptions/:id/revoke', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { reason } = req.body
|
||||||
|
const revokedBy = req.session?.username || 'admin'
|
||||||
|
|
||||||
|
const result = await quotaCardService.revokeRedemption(req.params.id, revokedBy, reason)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to revoke redemption:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// API Key 聚合类型转换
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// 获取转换预览
|
||||||
|
router.get('/api-keys/:id/convert-preview', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const preview = await apiKeyService.getConvertToAggregatedPreview(req.params.id)
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: preview
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get convert preview:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 执行转换
|
||||||
|
router.post('/api-keys/:id/convert-to-aggregated', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { quotaLimit, permissions, serviceQuotaLimits, quotaUsed } = req.body
|
||||||
|
|
||||||
|
if (quotaLimit === undefined || quotaLimit === null) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'quotaLimit is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions || !Array.isArray(permissions) || permissions.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'permissions must be a non-empty array'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiKeyService.convertToAggregated(req.params.id, {
|
||||||
|
quotaLimit: parseFloat(quotaLimit),
|
||||||
|
permissions,
|
||||||
|
serviceQuotaLimits: serviceQuotaLimits || {},
|
||||||
|
quotaUsed: quotaUsed !== undefined ? parseFloat(quotaUsed) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to convert to aggregated:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动增加额度
|
||||||
|
router.post('/api-keys/:id/add-quota', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { quotaAmount } = req.body
|
||||||
|
|
||||||
|
if (!quotaAmount || quotaAmount <= 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'quotaAmount must be a positive number'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiKeyService.addQuota(req.params.id, parseFloat(quotaAmount))
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to add quota:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动减少额度
|
||||||
|
router.post('/api-keys/:id/deduct-quota', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { quotaAmount } = req.body
|
||||||
|
|
||||||
|
if (!quotaAmount || quotaAmount <= 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'quotaAmount must be a positive number'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiKeyService.deductQuotaLimit(req.params.id, parseFloat(quotaAmount))
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to deduct quota:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 延长有效期
|
||||||
|
router.post('/api-keys/:id/extend-expiry', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { amount, unit = 'days' } = req.body
|
||||||
|
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'amount must be a positive number'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiKeyService.extendExpiry(req.params.id, parseInt(amount), unit)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to extend expiry:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
72
src/routes/admin/serviceRates.js
Normal file
72
src/routes/admin/serviceRates.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 服务倍率配置管理路由
|
||||||
|
*/
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
const serviceRatesService = require('../../services/serviceRatesService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
|
||||||
|
// 获取服务倍率配置
|
||||||
|
router.get('/service-rates', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rates = await serviceRatesService.getRates()
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rates
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get service rates:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新服务倍率配置
|
||||||
|
router.put('/service-rates', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rates, baseService } = req.body
|
||||||
|
|
||||||
|
if (!rates || typeof rates !== 'object') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'rates is required and must be an object'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedBy = req.session?.username || 'admin'
|
||||||
|
const result = await serviceRatesService.saveRates({ rates, baseService }, updatedBy)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update service rates:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取可用服务列表
|
||||||
|
router.get('/service-rates/services', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const services = await serviceRatesService.getAvailableServices()
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: services
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get available services:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -267,6 +267,11 @@ router.get('/oem-settings', async (req, res) => {
|
|||||||
siteIcon: '',
|
siteIcon: '',
|
||||||
siteIconData: '', // Base64编码的图标数据
|
siteIconData: '', // Base64编码的图标数据
|
||||||
showAdminButton: true, // 是否显示管理后台按钮
|
showAdminButton: true, // 是否显示管理后台按钮
|
||||||
|
apiStatsNotice: {
|
||||||
|
enabled: false,
|
||||||
|
title: '',
|
||||||
|
content: ''
|
||||||
|
},
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +301,7 @@ router.get('/oem-settings', async (req, res) => {
|
|||||||
// 更新OEM设置
|
// 更新OEM设置
|
||||||
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { siteName, siteIcon, siteIconData, showAdminButton } = req.body
|
const { siteName, siteIcon, siteIconData, showAdminButton, apiStatsNotice } = req.body
|
||||||
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) {
|
||||||
@@ -328,6 +333,11 @@ router.put('/oem-settings', authenticateAdmin, async (req, res) => {
|
|||||||
siteIcon: (siteIcon || '').trim(),
|
siteIcon: (siteIcon || '').trim(),
|
||||||
siteIconData: (siteIconData || '').trim(), // Base64数据
|
siteIconData: (siteIconData || '').trim(), // Base64数据
|
||||||
showAdminButton: showAdminButton !== false, // 默认为true
|
showAdminButton: showAdminButton !== false, // 默认为true
|
||||||
|
apiStatsNotice: {
|
||||||
|
enabled: apiStatsNotice?.enabled === true,
|
||||||
|
title: (apiStatsNotice?.title || '').trim().slice(0, 100),
|
||||||
|
content: (apiStatsNotice?.content || '').trim().slice(0, 2000)
|
||||||
|
},
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,14 @@ async function getUsageDataByIndex(indexKey, keyPattern, scanPattern) {
|
|||||||
return `${match[1]}:${match[2]}`
|
return `${match[1]}:${match[2]}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 通用格式:提取最后一个 : 前的 id
|
// 通用格式:根据 keyPattern 中 {id} 的位置提取 id
|
||||||
|
const patternParts = keyPattern.split(':')
|
||||||
|
const idIndex = patternParts.findIndex((p) => p === '{id}')
|
||||||
|
if (idIndex !== -1) {
|
||||||
|
const parts = k.split(':')
|
||||||
|
return parts[idIndex]
|
||||||
|
}
|
||||||
|
// 回退:提取最后一个 : 前的 id
|
||||||
const parts = k.split(':')
|
const parts = k.split(':')
|
||||||
return parts[parts.length - 2]
|
return parts[parts.length - 2]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,10 +5,38 @@ const apiKeyService = require('../services/apiKeyService')
|
|||||||
const CostCalculator = require('../utils/costCalculator')
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
const claudeAccountService = require('../services/claudeAccountService')
|
const claudeAccountService = require('../services/claudeAccountService')
|
||||||
const openaiAccountService = require('../services/openaiAccountService')
|
const openaiAccountService = require('../services/openaiAccountService')
|
||||||
|
const serviceRatesService = require('../services/serviceRatesService')
|
||||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||||
|
const modelsConfig = require('../../config/models')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 📋 获取可用模型列表(公开接口)
|
||||||
|
router.get('/models', (req, res) => {
|
||||||
|
const { service } = req.query
|
||||||
|
|
||||||
|
if (service) {
|
||||||
|
// 返回指定服务的模型
|
||||||
|
const models = modelsConfig.getModelsByService(service)
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: models
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回所有模型(按服务分组)
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
claude: modelsConfig.CLAUDE_MODELS,
|
||||||
|
gemini: modelsConfig.GEMINI_MODELS,
|
||||||
|
openai: modelsConfig.OPENAI_MODELS,
|
||||||
|
other: modelsConfig.OTHER_MODELS,
|
||||||
|
all: modelsConfig.getAllModels()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// 🏠 重定向页面请求到新版 admin-spa
|
// 🏠 重定向页面请求到新版 admin-spa
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.redirect(301, '/admin-next/api-stats')
|
res.redirect(301, '/admin-next/api-stats')
|
||||||
@@ -800,13 +828,19 @@ router.post('/api/batch-model-stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// maxTokens 白名单
|
||||||
|
const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096]
|
||||||
|
const sanitizeMaxTokens = (value) =>
|
||||||
|
ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 1000
|
||||||
|
|
||||||
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
||||||
router.post('/api-key/test', async (req, res) => {
|
router.post('/api-key/test', async (req, res) => {
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
|
const { apiKey, model = 'claude-sonnet-4-5-20250929', prompt = 'hi' } = req.body
|
||||||
|
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -839,7 +873,7 @@ router.post('/api-key/test', async (req, res) => {
|
|||||||
apiUrl,
|
apiUrl,
|
||||||
authorization: apiKey,
|
authorization: apiKey,
|
||||||
responseStream: res,
|
responseStream: res,
|
||||||
payload: createClaudeTestPayload(model, { stream: true }),
|
payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }),
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
extraHeaders: { 'x-api-key': apiKey }
|
extraHeaders: { 'x-api-key': apiKey }
|
||||||
})
|
})
|
||||||
@@ -860,6 +894,318 @@ router.post('/api-key/test', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 🧪 Gemini API Key 端点测试接口
|
||||||
|
router.post('/api-key/test-gemini', async (req, res) => {
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const { createGeminiTestPayload } = require('../utils/testPayloadHelper')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { apiKey, model = 'gemini-2.5-pro', prompt = 'hi' } = req.body
|
||||||
|
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'API Key is required',
|
||||||
|
message: 'Please provide your API Key'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid API key format',
|
||||||
|
message: 'API key format is invalid'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid API key',
|
||||||
|
message: validation.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Gemini 权限
|
||||||
|
const permissions = validation.keyData.permissions || 'all'
|
||||||
|
if (permissions !== 'all' && !permissions.includes('gemini')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Permission denied',
|
||||||
|
message: 'This API key does not have Gemini permission'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.api(
|
||||||
|
`🧪 Gemini API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
|
||||||
|
)
|
||||||
|
|
||||||
|
const port = config.server.port || 3000
|
||||||
|
const apiUrl = `http://127.0.0.1:${port}/gemini/v1/models/${model}:streamGenerateContent?alt=sse`
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
})
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||||
|
|
||||||
|
const axios = require('axios')
|
||||||
|
const payload = createGeminiTestPayload(model, { prompt, maxTokens })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(apiUrl, payload, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
responseType: 'stream',
|
||||||
|
validateStatus: () => true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
// Gemini 格式: candidates[0].content.parts[0].text
|
||||||
|
const text = data.candidates?.[0]?.content?.parts?.[0]?.text
|
||||||
|
if (text) {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('end', () => {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('error', (err) => {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
} catch (axiosError) {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Gemini API Key test failed:', error)
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.message || 'Internal server error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🧪 OpenAI/Codex API Key 端点测试接口
|
||||||
|
router.post('/api-key/test-openai', async (req, res) => {
|
||||||
|
const config = require('../../config/config')
|
||||||
|
const { createOpenAITestPayload } = require('../utils/testPayloadHelper')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { apiKey, model = 'gpt-5', prompt = 'hi' } = req.body
|
||||||
|
const maxTokens = sanitizeMaxTokens(req.body.maxTokens)
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'API Key is required',
|
||||||
|
message: 'Please provide your API Key'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid API key format',
|
||||||
|
message: 'API key format is invalid'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid API key',
|
||||||
|
message: validation.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 OpenAI 权限
|
||||||
|
const permissions = validation.keyData.permissions || 'all'
|
||||||
|
if (permissions !== 'all' && !permissions.includes('openai')) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Permission denied',
|
||||||
|
message: 'This API key does not have OpenAI permission'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.api(
|
||||||
|
`🧪 OpenAI API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`
|
||||||
|
)
|
||||||
|
|
||||||
|
const port = config.server.port || 3000
|
||||||
|
const apiUrl = `http://127.0.0.1:${port}/openai/responses`
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
})
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`)
|
||||||
|
|
||||||
|
const axios = require('axios')
|
||||||
|
const payload = createOpenAITestPayload(model, { prompt, maxTokens })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(apiUrl, payload, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'User-Agent': 'codex_cli_rs/1.0.0'
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
responseType: 'stream',
|
||||||
|
validateStatus: () => true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
// OpenAI Responses 格式: output[].content[].text 或 delta
|
||||||
|
if (data.type === 'response.output_text.delta' && data.delta) {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta })}\n\n`)
|
||||||
|
} else if (data.type === 'response.content_part.delta' && data.delta?.text) {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('end', () => {
|
||||||
|
res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
response.data.on('error', (err) => {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
} catch (axiosError) {
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ OpenAI API Key test failed:', error)
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Test failed',
|
||||||
|
message: error.message || 'Internal server error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(
|
||||||
|
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||||||
|
)
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 📊 用户模型统计查询接口 - 安全的自查询接口
|
// 📊 用户模型统计查询接口 - 安全的自查询接口
|
||||||
router.post('/api/user-model-stats', async (req, res) => {
|
router.post('/api/user-model-stats', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -946,20 +1292,25 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
const today = redis.getDateStringInTimezone()
|
const today = redis.getDateStringInTimezone()
|
||||||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
|
||||||
const pattern =
|
let pattern
|
||||||
period === 'daily'
|
let matchRegex
|
||||||
? `usage:${keyId}:model:daily:*:${today}`
|
if (period === 'daily') {
|
||||||
: `usage:${keyId}:model:monthly:*:${currentMonth}`
|
pattern = `usage:${keyId}:model:daily:*:${today}`
|
||||||
|
matchRegex = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
||||||
|
} else if (period === 'alltime') {
|
||||||
|
pattern = `usage:${keyId}:model:alltime:*`
|
||||||
|
matchRegex = /usage:.+:model:alltime:(.+)$/
|
||||||
|
} else {
|
||||||
|
// monthly
|
||||||
|
pattern = `usage:${keyId}:model:monthly:*:${currentMonth}`
|
||||||
|
matchRegex = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
||||||
|
}
|
||||||
|
|
||||||
const results = await redis.scanAndGetAllChunked(pattern)
|
const results = await redis.scanAndGetAllChunked(pattern)
|
||||||
const modelStats = []
|
const modelStats = []
|
||||||
|
|
||||||
for (const { key, data } of results) {
|
for (const { key, data } of results) {
|
||||||
const match = key.match(
|
const match = key.match(matchRegex)
|
||||||
period === 'daily'
|
|
||||||
? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
|
|
||||||
: /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
continue
|
continue
|
||||||
@@ -977,6 +1328,15 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
|
|
||||||
const costData = CostCalculator.calculateCost(usage, model)
|
const costData = CostCalculator.calculateCost(usage, model)
|
||||||
|
|
||||||
|
// alltime 键不存储 allTokens,需要计算
|
||||||
|
const allTokens =
|
||||||
|
period === 'alltime'
|
||||||
|
? usage.input_tokens +
|
||||||
|
usage.output_tokens +
|
||||||
|
usage.cache_creation_input_tokens +
|
||||||
|
usage.cache_read_input_tokens
|
||||||
|
: parseInt(data.allTokens) || 0
|
||||||
|
|
||||||
modelStats.push({
|
modelStats.push({
|
||||||
model,
|
model,
|
||||||
requests: parseInt(data.requests) || 0,
|
requests: parseInt(data.requests) || 0,
|
||||||
@@ -984,7 +1344,7 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
outputTokens: usage.output_tokens,
|
outputTokens: usage.output_tokens,
|
||||||
cacheCreateTokens: usage.cache_creation_input_tokens,
|
cacheCreateTokens: usage.cache_creation_input_tokens,
|
||||||
cacheReadTokens: usage.cache_read_input_tokens,
|
cacheReadTokens: usage.cache_read_input_tokens,
|
||||||
allTokens: parseInt(data.allTokens) || 0,
|
allTokens,
|
||||||
costs: costData.costs,
|
costs: costData.costs,
|
||||||
formatted: costData.formatted,
|
formatted: costData.formatted,
|
||||||
pricing: costData.pricing
|
pricing: costData.pricing
|
||||||
@@ -1015,4 +1375,21 @@ router.post('/api/user-model-stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 📊 获取服务倍率配置(公开接口)
|
||||||
|
router.get('/service-rates', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rates = await serviceRatesService.getRates()
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: rates
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get service rates:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: 'Failed to retrieve service rates'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const openaiAccountService = require('../services/openaiAccountService')
|
|||||||
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
const openaiResponsesAccountService = require('../services/openaiResponsesAccountService')
|
||||||
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
|
const openaiResponsesRelayService = require('../services/openaiResponsesRelayService')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
|
const redis = require('../models/redis')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const ProxyHelper = require('../utils/proxyHelper')
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
@@ -857,16 +858,18 @@ router.post('/v1/responses/compact', authenticateApiKey, handleResponses)
|
|||||||
// 使用情况统计端点
|
// 使用情况统计端点
|
||||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { usage } = req.apiKey
|
const keyData = req.apiKey
|
||||||
|
// 按需查询 usage 数据
|
||||||
|
const usage = await redis.getUsageStats(keyData.id)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
object: 'usage',
|
object: 'usage',
|
||||||
total_tokens: usage.total.tokens,
|
total_tokens: usage?.total?.tokens || 0,
|
||||||
total_requests: usage.total.requests,
|
total_requests: usage?.total?.requests || 0,
|
||||||
daily_tokens: usage.daily.tokens,
|
daily_tokens: usage?.daily?.tokens || 0,
|
||||||
daily_requests: usage.daily.requests,
|
daily_requests: usage?.daily?.requests || 0,
|
||||||
monthly_tokens: usage.monthly.tokens,
|
monthly_tokens: usage?.monthly?.tokens || 0,
|
||||||
monthly_requests: usage.monthly.requests
|
monthly_requests: usage?.monthly?.requests || 0
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get usage stats:', error)
|
logger.error('Failed to get usage stats:', error)
|
||||||
@@ -883,25 +886,26 @@ router.get('/usage', authenticateApiKey, async (req, res) => {
|
|||||||
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const keyData = req.apiKey
|
const keyData = req.apiKey
|
||||||
|
// 按需查询 usage 数据(仅 key-info 端点需要)
|
||||||
|
const usage = await redis.getUsageStats(keyData.id)
|
||||||
|
const tokensUsed = usage?.total?.tokens || 0
|
||||||
res.json({
|
res.json({
|
||||||
id: keyData.id,
|
id: keyData.id,
|
||||||
name: keyData.name,
|
name: keyData.name,
|
||||||
description: keyData.description,
|
description: keyData.description,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions: keyData.permissions || 'all',
|
||||||
token_limit: keyData.tokenLimit,
|
token_limit: keyData.tokenLimit,
|
||||||
tokens_used: keyData.usage.total.tokens,
|
tokens_used: tokensUsed,
|
||||||
tokens_remaining:
|
tokens_remaining:
|
||||||
keyData.tokenLimit > 0
|
keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null,
|
||||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
|
||||||
: null,
|
|
||||||
rate_limit: {
|
rate_limit: {
|
||||||
window: keyData.rateLimitWindow,
|
window: keyData.rateLimitWindow,
|
||||||
requests: keyData.rateLimitRequests
|
requests: keyData.rateLimitRequests
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
total: keyData.usage.total,
|
total: usage?.total || {},
|
||||||
daily: keyData.usage.daily,
|
daily: usage?.daily || {},
|
||||||
monthly: keyData.usage.monthly
|
monthly: usage?.monthly || {}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -761,4 +761,166 @@ router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 额度卡核销相关路由
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const quotaCardService = require('../services/quotaCardService')
|
||||||
|
|
||||||
|
// 🎫 核销额度卡
|
||||||
|
router.post('/redeem-card', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { code, apiKeyId } = req.body
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing card code',
|
||||||
|
message: 'Card code is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKeyId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing API key ID',
|
||||||
|
message: 'API key ID is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 API Key 属于当前用户
|
||||||
|
const keyData = await redis.getApiKey(apiKeyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'API key not found',
|
||||||
|
message: 'The specified API key does not exist'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyData.userId !== req.user.id) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'You can only redeem cards to your own API keys'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行核销
|
||||||
|
const result = await quotaCardService.redeemCard(code, apiKeyId, req.user.id, req.user.username)
|
||||||
|
|
||||||
|
logger.success(`🎫 User ${req.user.username} redeemed card ${code} to key ${apiKeyId}`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Redeem card error:', error)
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Redeem failed',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📋 获取用户的核销历史
|
||||||
|
router.get('/redemption-history', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit = 50, offset = 0 } = req.query
|
||||||
|
|
||||||
|
const result = await quotaCardService.getRedemptions({
|
||||||
|
userId: req.user.id,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset)
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Get redemption history error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to get redemption history',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 📊 获取用户的额度信息
|
||||||
|
router.get('/quota-info', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.query
|
||||||
|
|
||||||
|
if (!apiKeyId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing API key ID',
|
||||||
|
message: 'API key ID is required'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 API Key 属于当前用户
|
||||||
|
const keyData = await redis.getApiKey(apiKeyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'API key not found',
|
||||||
|
message: 'The specified API key does not exist'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyData.userId !== req.user.id) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'You can only view your own API key quota'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为聚合 Key
|
||||||
|
if (keyData.isAggregated !== 'true') {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isAggregated: false,
|
||||||
|
message: 'This is a traditional API key, not using quota system'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析聚合 Key 数据
|
||||||
|
let permissions = []
|
||||||
|
let serviceQuotaLimits = {}
|
||||||
|
let serviceQuotaUsed = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
permissions = JSON.parse(keyData.permissions || '[]')
|
||||||
|
} catch (e) {
|
||||||
|
permissions = [keyData.permissions]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
serviceQuotaLimits = JSON.parse(keyData.serviceQuotaLimits || '{}')
|
||||||
|
serviceQuotaUsed = JSON.parse(keyData.serviceQuotaUsed || '{}')
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败使用默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isAggregated: true,
|
||||||
|
quotaLimit: parseFloat(keyData.quotaLimit || 0),
|
||||||
|
quotaUsed: parseFloat(keyData.quotaUsed || 0),
|
||||||
|
quotaRemaining: parseFloat(keyData.quotaLimit || 0) - parseFloat(keyData.quotaUsed || 0),
|
||||||
|
permissions,
|
||||||
|
serviceQuotaLimits,
|
||||||
|
serviceQuotaUsed,
|
||||||
|
expiresAt: keyData.expiresAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Get quota info error:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to get quota info',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -567,6 +567,34 @@ class AccountGroupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对于反向索引为空的账户,单独查询并补建索引(处理部分缺失情况)
|
||||||
|
const emptyIndexAccountIds = []
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const ids = accountGroupIdsMap.get(accountId) || []
|
||||||
|
if (ids.length === 0) {
|
||||||
|
emptyIndexAccountIds.push(accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (emptyIndexAccountIds.length > 0 && emptyIndexAccountIds.length < accountIds.length) {
|
||||||
|
// 部分账户索引缺失,逐个查询并补建
|
||||||
|
for (const accountId of emptyIndexAccountIds) {
|
||||||
|
try {
|
||||||
|
const groups = await this.getAccountGroups(accountId)
|
||||||
|
if (groups.length > 0) {
|
||||||
|
const groupIds = groups.map((g) => g.id)
|
||||||
|
accountGroupIdsMap.set(accountId, groupIds)
|
||||||
|
groupIds.forEach((id) => uniqueGroupIds.add(id))
|
||||||
|
// 异步补建反向索引
|
||||||
|
client
|
||||||
|
.sadd(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`, ...groupIds)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略错误,保持空数组
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 批量获取分组详情
|
// 批量获取分组详情
|
||||||
const groupDetailsMap = new Map()
|
const groupDetailsMap = new Map()
|
||||||
if (uniqueGroupIds.size > 0) {
|
if (uniqueGroupIds.size > 0) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { v4: uuidv4 } = require('uuid')
|
|||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const serviceRatesService = require('./serviceRatesService')
|
||||||
|
|
||||||
const ACCOUNT_TYPE_CONFIG = {
|
const ACCOUNT_TYPE_CONFIG = {
|
||||||
claude: { prefix: 'claude:account:' },
|
claude: { prefix: 'claude:account:' },
|
||||||
@@ -89,7 +90,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId = null,
|
azureOpenaiAccountId = null,
|
||||||
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
|
||||||
droidAccountId = null,
|
droidAccountId = null,
|
||||||
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all'
|
permissions = 'all', // 可选值:'claude'、'gemini'、'openai'、'droid' 或 'all',聚合Key为数组
|
||||||
isActive = true,
|
isActive = true,
|
||||||
concurrencyLimit = 0,
|
concurrencyLimit = 0,
|
||||||
rateLimitWindow = null,
|
rateLimitWindow = null,
|
||||||
@@ -106,7 +107,13 @@ class ApiKeyService {
|
|||||||
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
||||||
activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days'
|
activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days'
|
||||||
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
||||||
icon = '' // 新增:图标(base64编码)
|
icon = '', // 新增:图标(base64编码)
|
||||||
|
// 聚合 Key 相关字段
|
||||||
|
isAggregated = false, // 是否为聚合 Key
|
||||||
|
quotaLimit = 0, // CC 额度上限(聚合 Key 使用)
|
||||||
|
quotaUsed = 0, // 已消耗 CC 额度
|
||||||
|
serviceQuotaLimits = {}, // 分服务额度限制 { claude: 50, codex: 30 }
|
||||||
|
serviceQuotaUsed = {} // 分服务已消耗额度
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 生成简单的API Key (64字符十六进制)
|
// 生成简单的API Key (64字符十六进制)
|
||||||
@@ -114,6 +121,16 @@ class ApiKeyService {
|
|||||||
const keyId = uuidv4()
|
const keyId = uuidv4()
|
||||||
const hashedKey = this._hashApiKey(apiKey)
|
const hashedKey = this._hashApiKey(apiKey)
|
||||||
|
|
||||||
|
// 处理 permissions:聚合 Key 使用数组,传统 Key 使用字符串
|
||||||
|
let permissionsValue = permissions
|
||||||
|
if (isAggregated && !Array.isArray(permissions)) {
|
||||||
|
// 聚合 Key 但 permissions 不是数组,转换为数组
|
||||||
|
permissionsValue =
|
||||||
|
permissions === 'all'
|
||||||
|
? ['claude', 'codex', 'gemini', 'droid', 'bedrock', 'azure', 'ccr']
|
||||||
|
: [permissions]
|
||||||
|
}
|
||||||
|
|
||||||
const keyData = {
|
const keyData = {
|
||||||
id: keyId,
|
id: keyId,
|
||||||
name,
|
name,
|
||||||
@@ -132,7 +149,9 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
azureOpenaiAccountId: azureOpenaiAccountId || '',
|
||||||
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
|
||||||
droidAccountId: droidAccountId || '',
|
droidAccountId: droidAccountId || '',
|
||||||
permissions: permissions || 'all',
|
permissions: Array.isArray(permissionsValue)
|
||||||
|
? JSON.stringify(permissionsValue)
|
||||||
|
: permissionsValue || 'all',
|
||||||
enableModelRestriction: String(enableModelRestriction),
|
enableModelRestriction: String(enableModelRestriction),
|
||||||
restrictedModels: JSON.stringify(restrictedModels || []),
|
restrictedModels: JSON.stringify(restrictedModels || []),
|
||||||
enableClientRestriction: String(enableClientRestriction || false),
|
enableClientRestriction: String(enableClientRestriction || false),
|
||||||
@@ -152,7 +171,13 @@ class ApiKeyService {
|
|||||||
createdBy: options.createdBy || 'admin',
|
createdBy: options.createdBy || 'admin',
|
||||||
userId: options.userId || '',
|
userId: options.userId || '',
|
||||||
userUsername: options.userUsername || '',
|
userUsername: options.userUsername || '',
|
||||||
icon: icon || '' // 新增:图标(base64编码)
|
icon: icon || '', // 新增:图标(base64编码)
|
||||||
|
// 聚合 Key 相关字段
|
||||||
|
isAggregated: String(isAggregated),
|
||||||
|
quotaLimit: String(quotaLimit || 0),
|
||||||
|
quotaUsed: String(quotaUsed || 0),
|
||||||
|
serviceQuotaLimits: JSON.stringify(serviceQuotaLimits || {}),
|
||||||
|
serviceQuotaUsed: JSON.stringify(serviceQuotaUsed || {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存API Key数据并建立哈希映射
|
// 保存API Key数据并建立哈希映射
|
||||||
@@ -182,7 +207,17 @@ class ApiKeyService {
|
|||||||
logger.warn(`Failed to add key ${keyId} to API Key index:`, err.message)
|
logger.warn(`Failed to add key ${keyId} to API Key index:`, err.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success(`🔑 Generated new API key: ${name} (${keyId})`)
|
logger.success(
|
||||||
|
`🔑 Generated new API key: ${name} (${keyId})${isAggregated ? ' [Aggregated]' : ''}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 解析 permissions 用于返回
|
||||||
|
let parsedPermissions = keyData.permissions
|
||||||
|
try {
|
||||||
|
parsedPermissions = JSON.parse(keyData.permissions)
|
||||||
|
} catch (e) {
|
||||||
|
// 不是 JSON,保持原值(传统 Key 的字符串格式)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: keyId,
|
id: keyId,
|
||||||
@@ -202,7 +237,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
droidAccountId: keyData.droidAccountId,
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions,
|
permissions: parsedPermissions,
|
||||||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||||||
restrictedModels: JSON.parse(keyData.restrictedModels),
|
restrictedModels: JSON.parse(keyData.restrictedModels),
|
||||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||||
@@ -218,7 +253,13 @@ class ApiKeyService {
|
|||||||
activatedAt: keyData.activatedAt,
|
activatedAt: keyData.activatedAt,
|
||||||
createdAt: keyData.createdAt,
|
createdAt: keyData.createdAt,
|
||||||
expiresAt: keyData.expiresAt,
|
expiresAt: keyData.expiresAt,
|
||||||
createdBy: keyData.createdBy
|
createdBy: keyData.createdBy,
|
||||||
|
// 聚合 Key 相关字段
|
||||||
|
isAggregated: keyData.isAggregated === 'true',
|
||||||
|
quotaLimit: parseFloat(keyData.quotaLimit || 0),
|
||||||
|
quotaUsed: parseFloat(keyData.quotaUsed || 0),
|
||||||
|
serviceQuotaLimits: JSON.parse(keyData.serviceQuotaLimits || '{}'),
|
||||||
|
serviceQuotaUsed: JSON.parse(keyData.serviceQuotaUsed || '{}')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,15 +341,26 @@ class ApiKeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取使用统计(供返回数据使用)
|
// 按需获取费用统计(仅在有限制时查询,减少 Redis 调用)
|
||||||
const usage = await redis.getUsageStats(keyData.id)
|
const dailyCostLimit = parseFloat(keyData.dailyCostLimit || 0)
|
||||||
|
const totalCostLimit = parseFloat(keyData.totalCostLimit || 0)
|
||||||
|
const weeklyOpusCostLimit = parseFloat(keyData.weeklyOpusCostLimit || 0)
|
||||||
|
|
||||||
// 获取费用统计
|
const costQueries = []
|
||||||
const [dailyCost, costStats] = await Promise.all([
|
if (dailyCostLimit > 0) {
|
||||||
redis.getDailyCost(keyData.id),
|
costQueries.push(redis.getDailyCost(keyData.id).then((v) => ({ dailyCost: v || 0 })))
|
||||||
redis.getCostStats(keyData.id)
|
}
|
||||||
])
|
if (totalCostLimit > 0) {
|
||||||
const totalCost = costStats?.total || 0
|
costQueries.push(redis.getCostStats(keyData.id).then((v) => ({ totalCost: v?.total || 0 })))
|
||||||
|
}
|
||||||
|
if (weeklyOpusCostLimit > 0) {
|
||||||
|
costQueries.push(
|
||||||
|
redis.getWeeklyOpusCost(keyData.id).then((v) => ({ weeklyOpusCost: v || 0 }))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const costData =
|
||||||
|
costQueries.length > 0 ? Object.assign({}, ...(await Promise.all(costQueries))) : {}
|
||||||
|
|
||||||
// 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时)
|
// 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时)
|
||||||
// 注意:lastUsedAt的更新已移至recordUsage方法中
|
// 注意:lastUsedAt的更新已移至recordUsage方法中
|
||||||
@@ -339,6 +391,26 @@ class ApiKeyService {
|
|||||||
tags = []
|
tags = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析 permissions(聚合 Key 为数组,传统 Key 为字符串)
|
||||||
|
let permissions = keyData.permissions || 'all'
|
||||||
|
try {
|
||||||
|
permissions = JSON.parse(keyData.permissions)
|
||||||
|
} catch (e) {
|
||||||
|
// 不是 JSON,保持原值
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析聚合 Key 相关字段
|
||||||
|
let serviceQuotaLimits = {}
|
||||||
|
let serviceQuotaUsed = {}
|
||||||
|
try {
|
||||||
|
serviceQuotaLimits = keyData.serviceQuotaLimits
|
||||||
|
? JSON.parse(keyData.serviceQuotaLimits)
|
||||||
|
: {}
|
||||||
|
serviceQuotaUsed = keyData.serviceQuotaUsed ? JSON.parse(keyData.serviceQuotaUsed) : {}
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败使用默认值
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
keyData: {
|
keyData: {
|
||||||
@@ -354,7 +426,7 @@ class ApiKeyService {
|
|||||||
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
azureOpenaiAccountId: keyData.azureOpenaiAccountId,
|
||||||
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
|
||||||
droidAccountId: keyData.droidAccountId,
|
droidAccountId: keyData.droidAccountId,
|
||||||
permissions: keyData.permissions || 'all',
|
permissions,
|
||||||
tokenLimit: parseInt(keyData.tokenLimit),
|
tokenLimit: parseInt(keyData.tokenLimit),
|
||||||
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
|
||||||
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
rateLimitWindow: parseInt(keyData.rateLimitWindow || 0),
|
||||||
@@ -364,14 +436,19 @@ class ApiKeyService {
|
|||||||
restrictedModels,
|
restrictedModels,
|
||||||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||||||
allowedClients,
|
allowedClients,
|
||||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
dailyCostLimit,
|
||||||
totalCostLimit: parseFloat(keyData.totalCostLimit || 0),
|
totalCostLimit,
|
||||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
weeklyOpusCostLimit,
|
||||||
dailyCost: dailyCost || 0,
|
dailyCost: costData.dailyCost || 0,
|
||||||
totalCost,
|
totalCost: costData.totalCost || 0,
|
||||||
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0,
|
weeklyOpusCost: costData.weeklyOpusCost || 0,
|
||||||
tags,
|
tags,
|
||||||
usage
|
// 聚合 Key 相关字段
|
||||||
|
isAggregated: keyData.isAggregated === 'true',
|
||||||
|
quotaLimit: parseFloat(keyData.quotaLimit || 0),
|
||||||
|
quotaUsed: parseFloat(keyData.quotaUsed || 0),
|
||||||
|
serviceQuotaLimits,
|
||||||
|
serviceQuotaUsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -982,19 +1059,37 @@ class ApiKeyService {
|
|||||||
'tags',
|
'tags',
|
||||||
'userId', // 新增:用户ID(所有者变更)
|
'userId', // 新增:用户ID(所有者变更)
|
||||||
'userUsername', // 新增:用户名(所有者变更)
|
'userUsername', // 新增:用户名(所有者变更)
|
||||||
'createdBy' // 新增:创建者(所有者变更)
|
'createdBy', // 新增:创建者(所有者变更)
|
||||||
|
// 聚合 Key 相关字段
|
||||||
|
'isAggregated',
|
||||||
|
'quotaLimit',
|
||||||
|
'quotaUsed',
|
||||||
|
'serviceQuotaLimits',
|
||||||
|
'serviceQuotaUsed'
|
||||||
]
|
]
|
||||||
const updatedData = { ...keyData }
|
const updatedData = { ...keyData }
|
||||||
|
|
||||||
for (const [field, value] of Object.entries(updates)) {
|
for (const [field, value] of Object.entries(updates)) {
|
||||||
if (allowedUpdates.includes(field)) {
|
if (allowedUpdates.includes(field)) {
|
||||||
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
|
if (
|
||||||
// 特殊处理数组字段
|
field === 'restrictedModels' ||
|
||||||
updatedData[field] = JSON.stringify(value || [])
|
field === 'allowedClients' ||
|
||||||
|
field === 'tags' ||
|
||||||
|
field === 'serviceQuotaLimits' ||
|
||||||
|
field === 'serviceQuotaUsed'
|
||||||
|
) {
|
||||||
|
// 特殊处理数组/对象字段
|
||||||
|
updatedData[field] = JSON.stringify(
|
||||||
|
value || (field === 'serviceQuotaLimits' || field === 'serviceQuotaUsed' ? {} : [])
|
||||||
|
)
|
||||||
|
} else if (field === 'permissions') {
|
||||||
|
// permissions 可能是数组(聚合 Key)或字符串(传统 Key)
|
||||||
|
updatedData[field] = Array.isArray(value) ? JSON.stringify(value) : value || 'all'
|
||||||
} else if (
|
} else if (
|
||||||
field === 'enableModelRestriction' ||
|
field === 'enableModelRestriction' ||
|
||||||
field === 'enableClientRestriction' ||
|
field === 'enableClientRestriction' ||
|
||||||
field === 'isActivated'
|
field === 'isActivated' ||
|
||||||
|
field === 'isAggregated'
|
||||||
) {
|
) {
|
||||||
// 布尔值转字符串
|
// 布尔值转字符串
|
||||||
updatedData[field] = String(value)
|
updatedData[field] = String(value)
|
||||||
@@ -2171,6 +2266,375 @@ class ApiKeyService {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 聚合 Key 相关方法
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为聚合 Key
|
||||||
|
*/
|
||||||
|
isAggregatedKey(keyData) {
|
||||||
|
return keyData && keyData.isAggregated === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取转换为聚合 Key 的预览信息
|
||||||
|
* @param {string} keyId - API Key ID
|
||||||
|
* @returns {Object} 转换预览信息
|
||||||
|
*/
|
||||||
|
async getConvertToAggregatedPreview(keyId) {
|
||||||
|
try {
|
||||||
|
const keyData = await redis.getApiKey(keyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
throw new Error('API key not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyData.isAggregated === 'true') {
|
||||||
|
throw new Error('API key is already aggregated')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前服务倍率
|
||||||
|
const ratesConfig = await serviceRatesService.getRates()
|
||||||
|
|
||||||
|
// 确定原服务类型
|
||||||
|
let originalService = keyData.permissions || 'all'
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(originalService)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
originalService = parsed[0] || 'claude'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 不是 JSON,保持原值
|
||||||
|
}
|
||||||
|
|
||||||
|
// 映射 permissions 到服务类型
|
||||||
|
const serviceMap = {
|
||||||
|
all: 'claude',
|
||||||
|
claude: 'claude',
|
||||||
|
gemini: 'gemini',
|
||||||
|
openai: 'codex',
|
||||||
|
droid: 'droid',
|
||||||
|
bedrock: 'bedrock',
|
||||||
|
azure: 'azure',
|
||||||
|
ccr: 'ccr'
|
||||||
|
}
|
||||||
|
const mappedService = serviceMap[originalService] || 'claude'
|
||||||
|
const serviceRate = ratesConfig.rates[mappedService] || 1.0
|
||||||
|
|
||||||
|
// 获取已消耗的真实成本
|
||||||
|
const costStats = await redis.getCostStats(keyId)
|
||||||
|
const totalRealCost = costStats?.total || 0
|
||||||
|
|
||||||
|
// 获取原限额
|
||||||
|
const originalLimit = parseFloat(keyData.totalCostLimit || 0)
|
||||||
|
|
||||||
|
// 计算建议的 CC 额度
|
||||||
|
const suggestedQuotaLimit = originalLimit * serviceRate
|
||||||
|
const suggestedQuotaUsed = totalRealCost * serviceRate
|
||||||
|
|
||||||
|
// 获取可用服务列表
|
||||||
|
const availableServices = Object.keys(ratesConfig.rates)
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyId,
|
||||||
|
keyName: keyData.name,
|
||||||
|
originalService: mappedService,
|
||||||
|
originalPermissions: originalService,
|
||||||
|
originalLimit,
|
||||||
|
originalUsed: totalRealCost,
|
||||||
|
serviceRate,
|
||||||
|
suggestedQuotaLimit: Math.round(suggestedQuotaLimit * 1000) / 1000,
|
||||||
|
suggestedQuotaUsed: Math.round(suggestedQuotaUsed * 1000) / 1000,
|
||||||
|
availableServices,
|
||||||
|
ratesConfig: ratesConfig.rates
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get convert preview:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将传统 Key 转换为聚合 Key
|
||||||
|
* @param {string} keyId - API Key ID
|
||||||
|
* @param {Object} options - 转换选项
|
||||||
|
* @param {number} options.quotaLimit - CC 额度上限
|
||||||
|
* @param {Array<string>} options.permissions - 允许的服务列表
|
||||||
|
* @param {Object} options.serviceQuotaLimits - 分服务额度限制(可选)
|
||||||
|
* @returns {Object} 转换结果
|
||||||
|
*/
|
||||||
|
async convertToAggregated(keyId, options = {}) {
|
||||||
|
try {
|
||||||
|
const keyData = await redis.getApiKey(keyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
throw new Error('API key not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyData.isAggregated === 'true') {
|
||||||
|
throw new Error('API key is already aggregated')
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
quotaLimit,
|
||||||
|
permissions,
|
||||||
|
serviceQuotaLimits = {},
|
||||||
|
quotaUsed = null // 如果不传,自动计算
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (quotaLimit === undefined || quotaLimit === null) {
|
||||||
|
throw new Error('quotaLimit is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions || !Array.isArray(permissions) || permissions.length === 0) {
|
||||||
|
throw new Error('permissions must be a non-empty array')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算已消耗的 CC 额度
|
||||||
|
let calculatedQuotaUsed = quotaUsed
|
||||||
|
if (calculatedQuotaUsed === null) {
|
||||||
|
// 自动计算:获取原服务的倍率,按倍率换算已消耗成本
|
||||||
|
const preview = await this.getConvertToAggregatedPreview(keyId)
|
||||||
|
calculatedQuotaUsed = preview.suggestedQuotaUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 Key 数据
|
||||||
|
const updates = {
|
||||||
|
isAggregated: true,
|
||||||
|
quotaLimit,
|
||||||
|
quotaUsed: calculatedQuotaUsed,
|
||||||
|
permissions,
|
||||||
|
serviceQuotaLimits,
|
||||||
|
serviceQuotaUsed: {} // 转换时重置分服务统计
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateApiKey(keyId, updates)
|
||||||
|
|
||||||
|
logger.success(`🔄 Converted API key ${keyId} to aggregated key with ${quotaLimit} CC quota`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
keyId,
|
||||||
|
quotaLimit,
|
||||||
|
quotaUsed: calculatedQuotaUsed,
|
||||||
|
permissions
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to convert to aggregated key:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速检查聚合 Key 额度(用于请求前检查)
|
||||||
|
* @param {string} keyId - API Key ID
|
||||||
|
* @returns {Object} { allowed: boolean, reason?: string, quotaRemaining?: number }
|
||||||
|
*/
|
||||||
|
async quickQuotaCheck(keyId) {
|
||||||
|
try {
|
||||||
|
const [quotaUsed, quotaLimit, isAggregated, isActive] = await redis.client.hmget(
|
||||||
|
`api_key:${keyId}`,
|
||||||
|
'quotaUsed',
|
||||||
|
'quotaLimit',
|
||||||
|
'isAggregated',
|
||||||
|
'isActive'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 非聚合 Key 不检查额度
|
||||||
|
if (isAggregated !== 'true') {
|
||||||
|
return { allowed: true, isAggregated: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive !== 'true') {
|
||||||
|
return { allowed: false, reason: 'inactive', isAggregated: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = parseFloat(quotaUsed || 0)
|
||||||
|
const limit = parseFloat(quotaLimit || 0)
|
||||||
|
|
||||||
|
// 额度为 0 表示不限制
|
||||||
|
if (limit === 0) {
|
||||||
|
return { allowed: true, isAggregated: true, quotaRemaining: Infinity }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (used >= limit) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: 'quota_exceeded',
|
||||||
|
isAggregated: true,
|
||||||
|
quotaUsed: used,
|
||||||
|
quotaLimit: limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true, isAggregated: true, quotaRemaining: limit - used }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Quick quota check failed:', error)
|
||||||
|
// 出错时允许通过,避免阻塞请求
|
||||||
|
return { allowed: true, error: error.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步扣减聚合 Key 额度(请求完成后调用)
|
||||||
|
* @param {string} keyId - API Key ID
|
||||||
|
* @param {number} costUSD - 真实成本(USD)
|
||||||
|
* @param {string} service - 服务类型
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async deductQuotaAsync(keyId, costUSD, service) {
|
||||||
|
// 使用 setImmediate 确保不阻塞响应
|
||||||
|
setImmediate(async () => {
|
||||||
|
try {
|
||||||
|
// 获取服务倍率
|
||||||
|
const rate = await serviceRatesService.getServiceRate(service)
|
||||||
|
const quotaToDeduct = costUSD * rate
|
||||||
|
|
||||||
|
// 原子更新总额度
|
||||||
|
await redis.client.hincrbyfloat(`api_key:${keyId}`, 'quotaUsed', quotaToDeduct)
|
||||||
|
|
||||||
|
// 更新分服务额度
|
||||||
|
const serviceQuotaUsedStr = await redis.client.hget(`api_key:${keyId}`, 'serviceQuotaUsed')
|
||||||
|
let serviceQuotaUsed = {}
|
||||||
|
try {
|
||||||
|
serviceQuotaUsed = JSON.parse(serviceQuotaUsedStr || '{}')
|
||||||
|
} catch (e) {
|
||||||
|
serviceQuotaUsed = {}
|
||||||
|
}
|
||||||
|
serviceQuotaUsed[service] = (serviceQuotaUsed[service] || 0) + quotaToDeduct
|
||||||
|
await redis.client.hset(
|
||||||
|
`api_key:${keyId}`,
|
||||||
|
'serviceQuotaUsed',
|
||||||
|
JSON.stringify(serviceQuotaUsed)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`📊 Deducted ${quotaToDeduct.toFixed(4)} CC quota from key ${keyId} (service: ${service}, rate: ${rate})`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to deduct quota for key ${keyId}:`, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加聚合 Key 额度(用于核销额度卡)
|
||||||
|
* @param {string} keyId - API Key ID
|
||||||
|
* @param {number} quotaAmount - 要增加的 CC 额度
|
||||||
|
* @returns {Promise<Object>} { success: boolean, newQuotaLimit: number }
|
||||||
|
*/
|
||||||
|
async addQuota(keyId, quotaAmount) {
|
||||||
|
try {
|
||||||
|
const keyData = await redis.getApiKey(keyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
throw new Error('API key not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyData.isAggregated !== 'true') {
|
||||||
|
throw new Error('Only aggregated keys can add quota')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLimit = parseFloat(keyData.quotaLimit || 0)
|
||||||
|
const newLimit = currentLimit + quotaAmount
|
||||||
|
|
||||||
|
await redis.client.hset(`api_key:${keyId}`, 'quotaLimit', String(newLimit))
|
||||||
|
|
||||||
|
logger.success(`💰 Added ${quotaAmount} CC quota to key ${keyId}, new limit: ${newLimit}`)
|
||||||
|
|
||||||
|
return { success: true, previousLimit: currentLimit, newQuotaLimit: newLimit }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to add quota:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 减少聚合 Key 额度(用于撤销核销)
|
||||||
|
* @param {string} keyId - API Key ID
|
||||||
|
* @param {number} quotaAmount - 要减少的 CC 额度
|
||||||
|
* @returns {Promise<Object>} { success: boolean, newQuotaLimit: number, actualDeducted: number }
|
||||||
|
*/
|
||||||
|
async deductQuotaLimit(keyId, quotaAmount) {
|
||||||
|
try {
|
||||||
|
const keyData = await redis.getApiKey(keyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
throw new Error('API key not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyData.isAggregated !== 'true') {
|
||||||
|
throw new Error('Only aggregated keys can deduct quota')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLimit = parseFloat(keyData.quotaLimit || 0)
|
||||||
|
const currentUsed = parseFloat(keyData.quotaUsed || 0)
|
||||||
|
|
||||||
|
// 不能扣到比已使用的还少
|
||||||
|
const minLimit = currentUsed
|
||||||
|
const actualDeducted = Math.min(quotaAmount, currentLimit - minLimit)
|
||||||
|
const newLimit = Math.max(currentLimit - quotaAmount, minLimit)
|
||||||
|
|
||||||
|
await redis.client.hset(`api_key:${keyId}`, 'quotaLimit', String(newLimit))
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`💸 Deducted ${actualDeducted} CC quota from key ${keyId}, new limit: ${newLimit}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return { success: true, previousLimit: currentLimit, newQuotaLimit: newLimit, actualDeducted }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to deduct quota limit:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延长 API Key 有效期(用于核销时间卡)
|
||||||
|
* @param {string} keyId - API Key ID
|
||||||
|
* @param {number} amount - 时间数量
|
||||||
|
* @param {string} unit - 时间单位 'hours' | 'days' | 'months'
|
||||||
|
* @returns {Promise<Object>} { success: boolean, newExpiresAt: string }
|
||||||
|
*/
|
||||||
|
async extendExpiry(keyId, amount, unit = 'days') {
|
||||||
|
try {
|
||||||
|
const keyData = await redis.getApiKey(keyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
throw new Error('API key not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算新的过期时间
|
||||||
|
let baseDate = keyData.expiresAt ? new Date(keyData.expiresAt) : new Date()
|
||||||
|
// 如果已过期,从当前时间开始计算
|
||||||
|
if (baseDate < new Date()) {
|
||||||
|
baseDate = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
let milliseconds
|
||||||
|
switch (unit) {
|
||||||
|
case 'hours':
|
||||||
|
milliseconds = amount * 60 * 60 * 1000
|
||||||
|
break
|
||||||
|
case 'months':
|
||||||
|
// 简化处理:1个月 = 30天
|
||||||
|
milliseconds = amount * 30 * 24 * 60 * 60 * 1000
|
||||||
|
break
|
||||||
|
case 'days':
|
||||||
|
default:
|
||||||
|
milliseconds = amount * 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExpiresAt = new Date(baseDate.getTime() + milliseconds).toISOString()
|
||||||
|
|
||||||
|
await this.updateApiKey(keyId, { expiresAt: newExpiresAt })
|
||||||
|
|
||||||
|
logger.success(
|
||||||
|
`⏰ Extended key ${keyId} expiry by ${amount} ${unit}, new expiry: ${newExpiresAt}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return { success: true, previousExpiresAt: keyData.expiresAt, newExpiresAt }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to extend expiry:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出实例和单独的方法
|
// 导出实例和单独的方法
|
||||||
|
|||||||
579
src/services/quotaCardService.js
Normal file
579
src/services/quotaCardService.js
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
/**
|
||||||
|
* 额度卡/时间卡服务
|
||||||
|
* 管理员生成卡,用户核销,管理员可撤销
|
||||||
|
*/
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
const { v4: uuidv4 } = require('uuid')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
|
||||||
|
class QuotaCardService {
|
||||||
|
constructor() {
|
||||||
|
this.CARD_PREFIX = 'quota_card:'
|
||||||
|
this.REDEMPTION_PREFIX = 'redemption:'
|
||||||
|
this.CARD_CODE_PREFIX = 'CC' // 卡号前缀
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成卡号(16位,格式:CC_XXXX_XXXX_XXXX)
|
||||||
|
*/
|
||||||
|
_generateCardCode() {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 排除容易混淆的字符
|
||||||
|
let code = ''
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
code += chars.charAt(crypto.randomInt(chars.length))
|
||||||
|
}
|
||||||
|
return `${this.CARD_CODE_PREFIX}_${code.slice(0, 4)}_${code.slice(4, 8)}_${code.slice(8, 12)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建额度卡/时间卡
|
||||||
|
* @param {Object} options - 卡配置
|
||||||
|
* @param {string} options.type - 卡类型:'quota' | 'time' | 'combo'
|
||||||
|
* @param {number} options.quotaAmount - CC 额度数量(quota/combo 类型必填)
|
||||||
|
* @param {number} options.timeAmount - 时间数量(time/combo 类型必填)
|
||||||
|
* @param {string} options.timeUnit - 时间单位:'hours' | 'days' | 'months'
|
||||||
|
* @param {string} options.expiresAt - 卡本身的有效期(可选)
|
||||||
|
* @param {string} options.note - 备注
|
||||||
|
* @param {string} options.createdBy - 创建者 ID
|
||||||
|
* @returns {Object} 创建的卡信息
|
||||||
|
*/
|
||||||
|
async createCard(options = {}) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
type = 'quota',
|
||||||
|
quotaAmount = 0,
|
||||||
|
timeAmount = 0,
|
||||||
|
timeUnit = 'days',
|
||||||
|
expiresAt = null,
|
||||||
|
note = '',
|
||||||
|
createdBy = 'admin'
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
if (!['quota', 'time', 'combo'].includes(type)) {
|
||||||
|
throw new Error('Invalid card type')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((type === 'quota' || type === 'combo') && (!quotaAmount || quotaAmount <= 0)) {
|
||||||
|
throw new Error('quotaAmount is required for quota/combo cards')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((type === 'time' || type === 'combo') && (!timeAmount || timeAmount <= 0)) {
|
||||||
|
throw new Error('timeAmount is required for time/combo cards')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardId = uuidv4()
|
||||||
|
const cardCode = this._generateCardCode()
|
||||||
|
|
||||||
|
const cardData = {
|
||||||
|
id: cardId,
|
||||||
|
code: cardCode,
|
||||||
|
type,
|
||||||
|
quotaAmount: String(quotaAmount || 0),
|
||||||
|
timeAmount: String(timeAmount || 0),
|
||||||
|
timeUnit: timeUnit || 'days',
|
||||||
|
status: 'unused', // unused | redeemed | revoked | expired
|
||||||
|
createdBy,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: expiresAt || '',
|
||||||
|
note: note || '',
|
||||||
|
// 核销信息
|
||||||
|
redeemedBy: '',
|
||||||
|
redeemedByUsername: '',
|
||||||
|
redeemedApiKeyId: '',
|
||||||
|
redeemedApiKeyName: '',
|
||||||
|
redeemedAt: '',
|
||||||
|
// 撤销信息
|
||||||
|
revokedAt: '',
|
||||||
|
revokedBy: '',
|
||||||
|
revokeReason: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存卡数据
|
||||||
|
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, cardData)
|
||||||
|
|
||||||
|
// 建立卡号到 ID 的映射(用于快速查找)
|
||||||
|
await redis.client.set(`quota_card_code:${cardCode}`, cardId)
|
||||||
|
|
||||||
|
// 添加到卡列表索引
|
||||||
|
await redis.client.sadd('quota_cards:all', cardId)
|
||||||
|
await redis.client.sadd(`quota_cards:status:${cardData.status}`, cardId)
|
||||||
|
|
||||||
|
logger.success(`🎫 Created ${type} card: ${cardCode} (${cardId})`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: cardId,
|
||||||
|
code: cardCode,
|
||||||
|
type,
|
||||||
|
quotaAmount: parseFloat(quotaAmount || 0),
|
||||||
|
timeAmount: parseInt(timeAmount || 0),
|
||||||
|
timeUnit,
|
||||||
|
status: 'unused',
|
||||||
|
createdBy,
|
||||||
|
createdAt: cardData.createdAt,
|
||||||
|
expiresAt: cardData.expiresAt,
|
||||||
|
note
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to create card:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量创建卡
|
||||||
|
* @param {Object} options - 卡配置
|
||||||
|
* @param {number} count - 创建数量
|
||||||
|
* @returns {Array} 创建的卡列表
|
||||||
|
*/
|
||||||
|
async createCardsBatch(options = {}, count = 1) {
|
||||||
|
const cards = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const card = await this.createCard(options)
|
||||||
|
cards.push(card)
|
||||||
|
}
|
||||||
|
logger.success(`🎫 Batch created ${count} cards`)
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过卡号获取卡信息
|
||||||
|
*/
|
||||||
|
async getCardByCode(code) {
|
||||||
|
try {
|
||||||
|
const cardId = await redis.client.get(`quota_card_code:${code}`)
|
||||||
|
if (!cardId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return await this.getCardById(cardId)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get card by code:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 ID 获取卡信息
|
||||||
|
*/
|
||||||
|
async getCardById(cardId) {
|
||||||
|
try {
|
||||||
|
const cardData = await redis.client.hgetall(`${this.CARD_PREFIX}${cardId}`)
|
||||||
|
if (!cardData || Object.keys(cardData).length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: cardData.id,
|
||||||
|
code: cardData.code,
|
||||||
|
type: cardData.type,
|
||||||
|
quotaAmount: parseFloat(cardData.quotaAmount || 0),
|
||||||
|
timeAmount: parseInt(cardData.timeAmount || 0),
|
||||||
|
timeUnit: cardData.timeUnit,
|
||||||
|
status: cardData.status,
|
||||||
|
createdBy: cardData.createdBy,
|
||||||
|
createdAt: cardData.createdAt,
|
||||||
|
expiresAt: cardData.expiresAt,
|
||||||
|
note: cardData.note,
|
||||||
|
redeemedBy: cardData.redeemedBy,
|
||||||
|
redeemedByUsername: cardData.redeemedByUsername,
|
||||||
|
redeemedApiKeyId: cardData.redeemedApiKeyId,
|
||||||
|
redeemedApiKeyName: cardData.redeemedApiKeyName,
|
||||||
|
redeemedAt: cardData.redeemedAt,
|
||||||
|
revokedAt: cardData.revokedAt,
|
||||||
|
revokedBy: cardData.revokedBy,
|
||||||
|
revokeReason: cardData.revokeReason
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get card:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有卡列表
|
||||||
|
* @param {Object} options - 查询选项
|
||||||
|
* @param {string} options.status - 按状态筛选
|
||||||
|
* @param {number} options.limit - 限制数量
|
||||||
|
* @param {number} options.offset - 偏移量
|
||||||
|
*/
|
||||||
|
async getAllCards(options = {}) {
|
||||||
|
try {
|
||||||
|
const { status, limit = 100, offset = 0 } = options
|
||||||
|
|
||||||
|
let cardIds
|
||||||
|
if (status) {
|
||||||
|
cardIds = await redis.client.smembers(`quota_cards:status:${status}`)
|
||||||
|
} else {
|
||||||
|
cardIds = await redis.client.smembers('quota_cards:all')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序(按创建时间倒序)
|
||||||
|
const cards = []
|
||||||
|
for (const cardId of cardIds) {
|
||||||
|
const card = await this.getCardById(cardId)
|
||||||
|
if (card) {
|
||||||
|
cards.push(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const total = cards.length
|
||||||
|
const paginatedCards = cards.slice(offset, offset + limit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards: paginatedCards,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get all cards:', error)
|
||||||
|
return { cards: [], total: 0, limit: 100, offset: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核销卡
|
||||||
|
* @param {string} code - 卡号
|
||||||
|
* @param {string} apiKeyId - 目标 API Key ID
|
||||||
|
* @param {string} userId - 核销用户 ID
|
||||||
|
* @param {string} username - 核销用户名
|
||||||
|
* @returns {Object} 核销结果
|
||||||
|
*/
|
||||||
|
async redeemCard(code, apiKeyId, userId, username = '') {
|
||||||
|
try {
|
||||||
|
// 获取卡信息
|
||||||
|
const card = await this.getCardByCode(code)
|
||||||
|
if (!card) {
|
||||||
|
throw new Error('Card not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查卡状态
|
||||||
|
if (card.status !== 'unused') {
|
||||||
|
throw new Error(`Card is ${card.status}, cannot redeem`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查卡是否过期
|
||||||
|
if (card.expiresAt && new Date(card.expiresAt) < new Date()) {
|
||||||
|
// 更新卡状态为过期
|
||||||
|
await this._updateCardStatus(card.id, 'expired')
|
||||||
|
throw new Error('Card has expired')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 API Key 信息
|
||||||
|
const apiKeyService = require('./apiKeyService')
|
||||||
|
const keyData = await redis.getApiKey(apiKeyId)
|
||||||
|
if (!keyData || Object.keys(keyData).length === 0) {
|
||||||
|
throw new Error('API key not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 API Key 是否为聚合类型(只有聚合 Key 才能核销额度卡)
|
||||||
|
if (card.type !== 'time' && keyData.isAggregated !== 'true') {
|
||||||
|
throw new Error('Only aggregated keys can redeem quota cards')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行核销
|
||||||
|
const redemptionId = uuidv4()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// 记录核销前状态
|
||||||
|
const beforeQuota = parseFloat(keyData.quotaLimit || 0)
|
||||||
|
const beforeExpiry = keyData.expiresAt || ''
|
||||||
|
|
||||||
|
// 应用卡效果
|
||||||
|
let afterQuota = beforeQuota
|
||||||
|
let afterExpiry = beforeExpiry
|
||||||
|
|
||||||
|
if (card.type === 'quota' || card.type === 'combo') {
|
||||||
|
const result = await apiKeyService.addQuota(apiKeyId, card.quotaAmount)
|
||||||
|
afterQuota = result.newQuotaLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.type === 'time' || card.type === 'combo') {
|
||||||
|
const result = await apiKeyService.extendExpiry(apiKeyId, card.timeAmount, card.timeUnit)
|
||||||
|
afterExpiry = result.newExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新卡状态
|
||||||
|
await redis.client.hset(`${this.CARD_PREFIX}${card.id}`, {
|
||||||
|
status: 'redeemed',
|
||||||
|
redeemedBy: userId,
|
||||||
|
redeemedByUsername: username,
|
||||||
|
redeemedApiKeyId: apiKeyId,
|
||||||
|
redeemedApiKeyName: keyData.name || '',
|
||||||
|
redeemedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新状态索引
|
||||||
|
await redis.client.srem(`quota_cards:status:unused`, card.id)
|
||||||
|
await redis.client.sadd(`quota_cards:status:redeemed`, card.id)
|
||||||
|
|
||||||
|
// 创建核销记录
|
||||||
|
const redemptionData = {
|
||||||
|
id: redemptionId,
|
||||||
|
cardId: card.id,
|
||||||
|
cardCode: card.code,
|
||||||
|
cardType: card.type,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
apiKeyId,
|
||||||
|
apiKeyName: keyData.name || '',
|
||||||
|
quotaAdded: String(card.type === 'time' ? 0 : card.quotaAmount),
|
||||||
|
timeAdded: String(card.type === 'quota' ? 0 : card.timeAmount),
|
||||||
|
timeUnit: card.timeUnit,
|
||||||
|
beforeQuota: String(beforeQuota),
|
||||||
|
afterQuota: String(afterQuota),
|
||||||
|
beforeExpiry,
|
||||||
|
afterExpiry,
|
||||||
|
timestamp: now,
|
||||||
|
status: 'active' // active | revoked
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, redemptionData)
|
||||||
|
|
||||||
|
// 添加到核销记录索引
|
||||||
|
await redis.client.sadd('redemptions:all', redemptionId)
|
||||||
|
await redis.client.sadd(`redemptions:user:${userId}`, redemptionId)
|
||||||
|
await redis.client.sadd(`redemptions:apikey:${apiKeyId}`, redemptionId)
|
||||||
|
|
||||||
|
logger.success(`✅ Card ${card.code} redeemed by ${username || userId} to key ${apiKeyId}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
redemptionId,
|
||||||
|
cardCode: card.code,
|
||||||
|
cardType: card.type,
|
||||||
|
quotaAdded: card.type === 'time' ? 0 : card.quotaAmount,
|
||||||
|
timeAdded: card.type === 'quota' ? 0 : card.timeAmount,
|
||||||
|
timeUnit: card.timeUnit,
|
||||||
|
beforeQuota,
|
||||||
|
afterQuota,
|
||||||
|
beforeExpiry,
|
||||||
|
afterExpiry
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to redeem card:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销核销
|
||||||
|
* @param {string} redemptionId - 核销记录 ID
|
||||||
|
* @param {string} revokedBy - 撤销者 ID
|
||||||
|
* @param {string} reason - 撤销原因
|
||||||
|
* @returns {Object} 撤销结果
|
||||||
|
*/
|
||||||
|
async revokeRedemption(redemptionId, revokedBy, reason = '') {
|
||||||
|
try {
|
||||||
|
// 获取核销记录
|
||||||
|
const redemptionData = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${redemptionId}`)
|
||||||
|
if (!redemptionData || Object.keys(redemptionData).length === 0) {
|
||||||
|
throw new Error('Redemption record not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redemptionData.status !== 'active') {
|
||||||
|
throw new Error('Redemption is already revoked')
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyService = require('./apiKeyService')
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// 撤销效果
|
||||||
|
let actualQuotaDeducted = 0
|
||||||
|
if (parseFloat(redemptionData.quotaAdded) > 0) {
|
||||||
|
const result = await apiKeyService.deductQuotaLimit(
|
||||||
|
redemptionData.apiKeyId,
|
||||||
|
parseFloat(redemptionData.quotaAdded)
|
||||||
|
)
|
||||||
|
actualQuotaDeducted = result.actualDeducted
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:时间卡撤销比较复杂,这里简化处理,不回退时间
|
||||||
|
// 如果需要回退时间,可以在这里添加逻辑
|
||||||
|
|
||||||
|
// 更新核销记录状态
|
||||||
|
await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, {
|
||||||
|
status: 'revoked',
|
||||||
|
revokedAt: now,
|
||||||
|
revokedBy,
|
||||||
|
revokeReason: reason,
|
||||||
|
actualQuotaDeducted: String(actualQuotaDeducted)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新卡状态
|
||||||
|
const { cardId } = redemptionData
|
||||||
|
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, {
|
||||||
|
status: 'revoked',
|
||||||
|
revokedAt: now,
|
||||||
|
revokedBy,
|
||||||
|
revokeReason: reason
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新状态索引
|
||||||
|
await redis.client.srem(`quota_cards:status:redeemed`, cardId)
|
||||||
|
await redis.client.sadd(`quota_cards:status:revoked`, cardId)
|
||||||
|
|
||||||
|
logger.success(`🔄 Revoked redemption ${redemptionId} by ${revokedBy}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
redemptionId,
|
||||||
|
cardCode: redemptionData.cardCode,
|
||||||
|
actualQuotaDeducted,
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to revoke redemption:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取核销记录
|
||||||
|
* @param {Object} options - 查询选项
|
||||||
|
* @param {string} options.userId - 按用户筛选
|
||||||
|
* @param {string} options.apiKeyId - 按 API Key 筛选
|
||||||
|
* @param {number} options.limit - 限制数量
|
||||||
|
* @param {number} options.offset - 偏移量
|
||||||
|
*/
|
||||||
|
async getRedemptions(options = {}) {
|
||||||
|
try {
|
||||||
|
const { userId, apiKeyId, limit = 100, offset = 0 } = options
|
||||||
|
|
||||||
|
let redemptionIds
|
||||||
|
if (userId) {
|
||||||
|
redemptionIds = await redis.client.smembers(`redemptions:user:${userId}`)
|
||||||
|
} else if (apiKeyId) {
|
||||||
|
redemptionIds = await redis.client.smembers(`redemptions:apikey:${apiKeyId}`)
|
||||||
|
} else {
|
||||||
|
redemptionIds = await redis.client.smembers('redemptions:all')
|
||||||
|
}
|
||||||
|
|
||||||
|
const redemptions = []
|
||||||
|
for (const id of redemptionIds) {
|
||||||
|
const data = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${id}`)
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
redemptions.push({
|
||||||
|
id: data.id,
|
||||||
|
cardId: data.cardId,
|
||||||
|
cardCode: data.cardCode,
|
||||||
|
cardType: data.cardType,
|
||||||
|
userId: data.userId,
|
||||||
|
username: data.username,
|
||||||
|
apiKeyId: data.apiKeyId,
|
||||||
|
apiKeyName: data.apiKeyName,
|
||||||
|
quotaAdded: parseFloat(data.quotaAdded || 0),
|
||||||
|
timeAdded: parseInt(data.timeAdded || 0),
|
||||||
|
timeUnit: data.timeUnit,
|
||||||
|
beforeQuota: parseFloat(data.beforeQuota || 0),
|
||||||
|
afterQuota: parseFloat(data.afterQuota || 0),
|
||||||
|
beforeExpiry: data.beforeExpiry,
|
||||||
|
afterExpiry: data.afterExpiry,
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
status: data.status,
|
||||||
|
revokedAt: data.revokedAt,
|
||||||
|
revokedBy: data.revokedBy,
|
||||||
|
revokeReason: data.revokeReason,
|
||||||
|
actualQuotaDeducted: parseFloat(data.actualQuotaDeducted || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序(按时间倒序)
|
||||||
|
redemptions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const total = redemptions.length
|
||||||
|
const paginatedRedemptions = redemptions.slice(offset, offset + limit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
redemptions: paginatedRedemptions,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get redemptions:', error)
|
||||||
|
return { redemptions: [], total: 0, limit: 100, offset: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除未使用的卡
|
||||||
|
*/
|
||||||
|
async deleteCard(cardId) {
|
||||||
|
try {
|
||||||
|
const card = await this.getCardById(cardId)
|
||||||
|
if (!card) {
|
||||||
|
throw new Error('Card not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.status !== 'unused') {
|
||||||
|
throw new Error('Only unused cards can be deleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除卡数据
|
||||||
|
await redis.client.del(`${this.CARD_PREFIX}${cardId}`)
|
||||||
|
await redis.client.del(`quota_card_code:${card.code}`)
|
||||||
|
|
||||||
|
// 从索引中移除
|
||||||
|
await redis.client.srem('quota_cards:all', cardId)
|
||||||
|
await redis.client.srem(`quota_cards:status:unused`, cardId)
|
||||||
|
|
||||||
|
logger.success(`🗑️ Deleted card ${card.code}`)
|
||||||
|
|
||||||
|
return { success: true, cardCode: card.code }
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete card:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新卡状态(内部方法)
|
||||||
|
*/
|
||||||
|
async _updateCardStatus(cardId, newStatus) {
|
||||||
|
const card = await this.getCardById(cardId)
|
||||||
|
if (!card) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldStatus = card.status
|
||||||
|
await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, 'status', newStatus)
|
||||||
|
|
||||||
|
// 更新状态索引
|
||||||
|
await redis.client.srem(`quota_cards:status:${oldStatus}`, cardId)
|
||||||
|
await redis.client.sadd(`quota_cards:status:${newStatus}`, cardId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取卡统计信息
|
||||||
|
*/
|
||||||
|
async getCardStats() {
|
||||||
|
try {
|
||||||
|
const [unused, redeemed, revoked, expired] = await Promise.all([
|
||||||
|
redis.client.scard('quota_cards:status:unused'),
|
||||||
|
redis.client.scard('quota_cards:status:redeemed'),
|
||||||
|
redis.client.scard('quota_cards:status:revoked'),
|
||||||
|
redis.client.scard('quota_cards:status:expired')
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: unused + redeemed + revoked + expired,
|
||||||
|
unused,
|
||||||
|
redeemed,
|
||||||
|
revoked,
|
||||||
|
expired
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get card stats:', error)
|
||||||
|
return { total: 0, unused: 0, redeemed: 0, revoked: 0, expired: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new QuotaCardService()
|
||||||
227
src/services/serviceRatesService.js
Normal file
227
src/services/serviceRatesService.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* 服务倍率配置服务
|
||||||
|
* 管理不同服务的消费倍率,以 Claude 为基准(倍率 1.0)
|
||||||
|
* 用于聚合 Key 的虚拟额度计算
|
||||||
|
*/
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
class ServiceRatesService {
|
||||||
|
constructor() {
|
||||||
|
this.CONFIG_KEY = 'system:service_rates'
|
||||||
|
this.cachedRates = null
|
||||||
|
this.cacheExpiry = 0
|
||||||
|
this.CACHE_TTL = 60 * 1000 // 1分钟缓存
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认倍率配置
|
||||||
|
*/
|
||||||
|
getDefaultRates() {
|
||||||
|
return {
|
||||||
|
baseService: 'claude',
|
||||||
|
rates: {
|
||||||
|
claude: 1.0, // 基准:1 USD = 1 CC额度
|
||||||
|
codex: 1.0,
|
||||||
|
gemini: 1.0,
|
||||||
|
droid: 1.0,
|
||||||
|
bedrock: 1.0,
|
||||||
|
azure: 1.0,
|
||||||
|
ccr: 1.0
|
||||||
|
},
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取倍率配置(带缓存)
|
||||||
|
*/
|
||||||
|
async getRates() {
|
||||||
|
try {
|
||||||
|
// 检查缓存
|
||||||
|
if (this.cachedRates && Date.now() < this.cacheExpiry) {
|
||||||
|
return this.cachedRates
|
||||||
|
}
|
||||||
|
|
||||||
|
const configStr = await redis.client.get(this.CONFIG_KEY)
|
||||||
|
if (!configStr) {
|
||||||
|
const defaultRates = this.getDefaultRates()
|
||||||
|
this.cachedRates = defaultRates
|
||||||
|
this.cacheExpiry = Date.now() + this.CACHE_TTL
|
||||||
|
return defaultRates
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedConfig = JSON.parse(configStr)
|
||||||
|
// 合并默认值,确保新增服务有默认倍率
|
||||||
|
const defaultRates = this.getDefaultRates()
|
||||||
|
storedConfig.rates = {
|
||||||
|
...defaultRates.rates,
|
||||||
|
...storedConfig.rates
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedRates = storedConfig
|
||||||
|
this.cacheExpiry = Date.now() + this.CACHE_TTL
|
||||||
|
return storedConfig
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('获取服务倍率配置失败:', error)
|
||||||
|
return this.getDefaultRates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存倍率配置
|
||||||
|
*/
|
||||||
|
async saveRates(config, updatedBy = 'admin') {
|
||||||
|
try {
|
||||||
|
const defaultRates = this.getDefaultRates()
|
||||||
|
|
||||||
|
// 验证配置
|
||||||
|
this.validateRates(config)
|
||||||
|
|
||||||
|
const newConfig = {
|
||||||
|
baseService: config.baseService || defaultRates.baseService,
|
||||||
|
rates: {
|
||||||
|
...defaultRates.rates,
|
||||||
|
...config.rates
|
||||||
|
},
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.client.set(this.CONFIG_KEY, JSON.stringify(newConfig))
|
||||||
|
|
||||||
|
// 清除缓存
|
||||||
|
this.cachedRates = null
|
||||||
|
this.cacheExpiry = 0
|
||||||
|
|
||||||
|
logger.info(`✅ 服务倍率配置已更新 by ${updatedBy}`)
|
||||||
|
return newConfig
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('保存服务倍率配置失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证倍率配置
|
||||||
|
*/
|
||||||
|
validateRates(config) {
|
||||||
|
if (!config || typeof config !== 'object') {
|
||||||
|
throw new Error('无效的配置格式')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.rates) {
|
||||||
|
for (const [service, rate] of Object.entries(config.rates)) {
|
||||||
|
if (typeof rate !== 'number' || rate <= 0) {
|
||||||
|
throw new Error(`服务 ${service} 的倍率必须是正数`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个服务的倍率
|
||||||
|
*/
|
||||||
|
async getServiceRate(service) {
|
||||||
|
const config = await this.getRates()
|
||||||
|
return config.rates[service] || 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算消费的 CC 额度
|
||||||
|
* @param {number} costUSD - 真实成本(USD)
|
||||||
|
* @param {string} service - 服务类型
|
||||||
|
* @returns {number} CC 额度消耗
|
||||||
|
*/
|
||||||
|
async calculateQuotaConsumption(costUSD, service) {
|
||||||
|
const rate = await this.getServiceRate(service)
|
||||||
|
return costUSD * rate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模型名称获取服务类型
|
||||||
|
*/
|
||||||
|
getServiceFromModel(model) {
|
||||||
|
if (!model) {
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelLower = model.toLowerCase()
|
||||||
|
|
||||||
|
// Claude 系列
|
||||||
|
if (
|
||||||
|
modelLower.includes('claude') ||
|
||||||
|
modelLower.includes('anthropic') ||
|
||||||
|
modelLower.includes('opus') ||
|
||||||
|
modelLower.includes('sonnet') ||
|
||||||
|
modelLower.includes('haiku')
|
||||||
|
) {
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI / Codex 系列
|
||||||
|
if (
|
||||||
|
modelLower.includes('gpt') ||
|
||||||
|
modelLower.includes('o1') ||
|
||||||
|
modelLower.includes('o3') ||
|
||||||
|
modelLower.includes('o4') ||
|
||||||
|
modelLower.includes('codex') ||
|
||||||
|
modelLower.includes('davinci') ||
|
||||||
|
modelLower.includes('curie') ||
|
||||||
|
modelLower.includes('babbage') ||
|
||||||
|
modelLower.includes('ada')
|
||||||
|
) {
|
||||||
|
return 'codex'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini 系列
|
||||||
|
if (
|
||||||
|
modelLower.includes('gemini') ||
|
||||||
|
modelLower.includes('palm') ||
|
||||||
|
modelLower.includes('bard')
|
||||||
|
) {
|
||||||
|
return 'gemini'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Droid 系列
|
||||||
|
if (modelLower.includes('droid') || modelLower.includes('factory')) {
|
||||||
|
return 'droid'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock 系列(通常带有 aws 或特定前缀)
|
||||||
|
if (
|
||||||
|
modelLower.includes('bedrock') ||
|
||||||
|
modelLower.includes('amazon') ||
|
||||||
|
modelLower.includes('titan')
|
||||||
|
) {
|
||||||
|
return 'bedrock'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Azure 系列
|
||||||
|
if (modelLower.includes('azure')) {
|
||||||
|
return 'azure'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回 claude
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有支持的服务列表
|
||||||
|
*/
|
||||||
|
async getAvailableServices() {
|
||||||
|
const config = await this.getRates()
|
||||||
|
return Object.keys(config.rates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除缓存(用于测试或强制刷新)
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.cachedRates = null
|
||||||
|
this.cacheExpiry = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ServiceRatesService()
|
||||||
@@ -313,6 +313,51 @@ const getTimeRemaining = (expiresAt) => {
|
|||||||
return Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000))
|
return Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 版本处理
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// 获取应用版本号
|
||||||
|
const getAppVersion = () => {
|
||||||
|
if (process.env.APP_VERSION) {
|
||||||
|
return process.env.APP_VERSION
|
||||||
|
}
|
||||||
|
if (process.env.VERSION) {
|
||||||
|
return process.env.VERSION
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const versionFile = path.join(__dirname, '..', '..', 'VERSION')
|
||||||
|
if (fs.existsSync(versionFile)) {
|
||||||
|
return fs.readFileSync(versionFile, 'utf8').trim()
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
return require('../../package.json').version
|
||||||
|
} catch {}
|
||||||
|
return '1.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本比较: a > b
|
||||||
|
const versionGt = (a, b) => {
|
||||||
|
const pa = a.split('.').map(Number)
|
||||||
|
const pb = b.split('.').map(Number)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if ((pa[i] || 0) > (pb[i] || 0)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ((pa[i] || 0) < (pb[i] || 0)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本比较: a >= b
|
||||||
|
const versionGte = (a, b) => a === b || versionGt(a, b)
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// 加密
|
// 加密
|
||||||
createEncryptor,
|
createEncryptor,
|
||||||
@@ -351,5 +396,9 @@ module.exports = {
|
|||||||
getDateInTimezone,
|
getDateInTimezone,
|
||||||
getDateStringInTimezone,
|
getDateStringInTimezone,
|
||||||
isExpired,
|
isExpired,
|
||||||
getTimeRemaining
|
getTimeRemaining,
|
||||||
|
// 版本
|
||||||
|
getAppVersion,
|
||||||
|
versionGt,
|
||||||
|
versionGte
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ function generateSessionString() {
|
|||||||
* @param {string} model - 模型名称
|
* @param {string} model - 模型名称
|
||||||
* @param {object} options - 可选配置
|
* @param {object} options - 可选配置
|
||||||
* @param {boolean} options.stream - 是否流式(默认false)
|
* @param {boolean} options.stream - 是否流式(默认false)
|
||||||
|
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||||
|
* @param {number} options.maxTokens - 最大输出 token(默认 1000)
|
||||||
* @returns {object} 测试请求体
|
* @returns {object} 测试请求体
|
||||||
*/
|
*/
|
||||||
function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) {
|
function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) {
|
||||||
|
const { stream, prompt = 'hi', maxTokens = 1000 } = options
|
||||||
const payload = {
|
const payload = {
|
||||||
model,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -35,7 +38,7 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options =
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: 'hi',
|
text: prompt,
|
||||||
cache_control: {
|
cache_control: {
|
||||||
type: 'ephemeral'
|
type: 'ephemeral'
|
||||||
}
|
}
|
||||||
@@ -55,11 +58,11 @@ function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options =
|
|||||||
metadata: {
|
metadata: {
|
||||||
user_id: generateSessionString()
|
user_id: generateSessionString()
|
||||||
},
|
},
|
||||||
max_tokens: 21333,
|
max_tokens: maxTokens,
|
||||||
temperature: 1
|
temperature: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.stream) {
|
if (stream) {
|
||||||
payload.stream = true
|
payload.stream = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,9 +237,58 @@ async function sendStreamTestRequest(options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Gemini 测试请求体
|
||||||
|
* @param {string} model - 模型名称
|
||||||
|
* @param {object} options - 可选配置
|
||||||
|
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||||
|
* @param {number} options.maxTokens - 最大输出 token(默认 100)
|
||||||
|
* @returns {object} 测试请求体
|
||||||
|
*/
|
||||||
|
function createGeminiTestPayload(model = 'gemini-2.5-pro', options = {}) {
|
||||||
|
const { prompt = 'hi', maxTokens = 100 } = options
|
||||||
|
return {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: prompt }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
generationConfig: {
|
||||||
|
maxOutputTokens: maxTokens,
|
||||||
|
temperature: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 OpenAI Responses 测试请求体
|
||||||
|
* @param {string} model - 模型名称
|
||||||
|
* @param {object} options - 可选配置
|
||||||
|
* @param {string} options.prompt - 自定义提示词(默认 'hi')
|
||||||
|
* @param {number} options.maxTokens - 最大输出 token(默认 100)
|
||||||
|
* @returns {object} 测试请求体
|
||||||
|
*/
|
||||||
|
function createOpenAITestPayload(model = 'gpt-5', options = {}) {
|
||||||
|
const { prompt = 'hi', maxTokens = 100 } = options
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_output_tokens: maxTokens,
|
||||||
|
stream: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
randomHex,
|
randomHex,
|
||||||
generateSessionString,
|
generateSessionString,
|
||||||
createClaudeTestPayload,
|
createClaudeTestPayload,
|
||||||
|
createGeminiTestPayload,
|
||||||
|
createOpenAITestPayload,
|
||||||
sendStreamTestRequest
|
sendStreamTestRequest
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
<!-- 全局组件 -->
|
<!-- 全局组件 -->
|
||||||
<ToastNotification ref="toastRef" />
|
<ToastNotification ref="toastRef" />
|
||||||
<ConfirmDialog ref="confirmRef" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -13,12 +12,10 @@ import { onMounted, ref } from 'vue'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import ToastNotification from '@/components/common/ToastNotification.vue'
|
import ToastNotification from '@/components/common/ToastNotification.vue'
|
||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const toastRef = ref()
|
const toastRef = ref()
|
||||||
const confirmRef = ref()
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 初始化主题
|
// 初始化主题
|
||||||
|
|||||||
@@ -49,8 +49,8 @@
|
|||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,18 +87,6 @@
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -50%;
|
|
||||||
left: -50%;
|
|
||||||
width: 200%;
|
|
||||||
height: 200%;
|
|
||||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -106,10 +94,6 @@
|
|||||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@@ -160,15 +144,15 @@
|
|||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
|
||||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
@@ -202,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
|
background: linear-gradient(135deg, var(--text-secondary) 0%, var(--bg-gradient-end) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(107, 114, 128, 0.3),
|
0 10px 15px -3px rgba(107, 114, 128, 0.3),
|
||||||
@@ -231,7 +215,7 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
0 0 0 3px rgba(var(--primary-rgb), 0.1),
|
||||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
}
|
}
|
||||||
@@ -251,7 +235,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-row:hover {
|
.table-row:hover {
|
||||||
background: rgba(102, 126, 234, 0.05);
|
background: rgba(var(--primary-rgb), 0.05);
|
||||||
transform: scale(1.005);
|
transform: scale(1.005);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +260,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .modal-content {
|
.dark .modal-content {
|
||||||
background: rgba(17, 24, 39, 0.95);
|
background: var(--glass-strong-color);
|
||||||
border: 1px solid rgba(75, 85, 99, 0.3);
|
border: 1px solid var(--border-color);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 25px -5px rgba(0, 0, 0, 0.3),
|
0 10px 25px -5px rgba(0, 0, 0, 0.3),
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||||
@@ -419,8 +403,8 @@
|
|||||||
/* 玻璃态容器 */
|
/* 玻璃态容器 */
|
||||||
.glass,
|
.glass,
|
||||||
.glass-strong {
|
.glass-strong {
|
||||||
margin: 16px;
|
margin: 0;
|
||||||
border-radius: 20px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 统计卡片 */
|
/* 统计卡片 */
|
||||||
|
|||||||
@@ -47,6 +47,70 @@
|
|||||||
--table-hover: rgba(129, 140, 248, 0.1);
|
--table-hover: rgba(129, 140, 248, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 覆盖 Tailwind v3 的暗黑模式背景色使用主题色 */
|
||||||
|
.dark .bg-gray-800,
|
||||||
|
.dark\:bg-gray-800:is(.dark *) {
|
||||||
|
background-color: var(--glass-strong-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-gray-700,
|
||||||
|
.dark\:bg-gray-700:is(.dark *) {
|
||||||
|
background-color: var(--bg-gradient-mid) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-gray-900,
|
||||||
|
.dark\:bg-gray-900:is(.dark *) {
|
||||||
|
background-color: var(--bg-gradient-start) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖带透明度的背景色 */
|
||||||
|
.dark\:bg-gray-800\/40:is(.dark *) {
|
||||||
|
background-color: color-mix(in srgb, var(--glass-strong-color) 40%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark\:bg-gray-700\/30:is(.dark *) {
|
||||||
|
background-color: color-mix(in srgb, var(--bg-gradient-mid) 30%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖 Tailwind v3 的暗黑模式渐变色 */
|
||||||
|
.dark\:from-gray-700:is(.dark *) {
|
||||||
|
--tw-gradient-from: var(--bg-gradient-mid) !important;
|
||||||
|
--tw-gradient-to: var(--bg-gradient-mid) !important;
|
||||||
|
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark\:to-gray-800\/90:is(.dark *) {
|
||||||
|
--tw-gradient-to: var(--glass-strong-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖 Tailwind v3 的暗黑模式悬停背景色 */
|
||||||
|
.dark\:hover\:bg-gray-600:is(.dark *):hover {
|
||||||
|
background-color: var(--bg-gradient-end) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark\:hover\:bg-gray-700:is(.dark *):hover {
|
||||||
|
background-color: var(--bg-gradient-mid) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark\:hover\:bg-gray-500:is(.dark *):hover {
|
||||||
|
background-color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-gray-700,
|
||||||
|
.dark\:border-gray-700:is(.dark *) {
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-gray-600,
|
||||||
|
.dark\:border-gray-600:is(.dark *) {
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖悬停边框色 */
|
||||||
|
.dark\:hover\:border-gray-500:is(.dark *):hover {
|
||||||
|
border-color: var(--border-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* 优化后的transition - 避免布局跳动 */
|
/* 优化后的transition - 避免布局跳动 */
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
@@ -99,9 +163,9 @@ body::before {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
radial-gradient(circle at 20% 80%, rgba(var(--accent-rgb), 0.2) 0%, transparent 50%),
|
||||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
radial-gradient(circle at 80% 20%, rgba(var(--primary-rgb), 0.2) 0%, transparent 50%),
|
||||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
@@ -174,8 +238,8 @@ body::before {
|
|||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,15 +279,15 @@ body::before {
|
|||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
|
||||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
@@ -275,7 +339,7 @@ body::before {
|
|||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
0 0 0 3px rgba(var(--primary-rgb), 0.1),
|
||||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
@@ -283,9 +347,9 @@ body::before {
|
|||||||
|
|
||||||
.dark .form-input:focus {
|
.dark .form-input:focus {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px rgba(129, 140, 248, 0.2),
|
0 0 0 3px rgba(var(--primary-rgb), 0.2),
|
||||||
0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
||||||
background: rgba(17, 24, 39, 0.95);
|
background: var(--glass-strong-color);
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,19 +396,7 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .stat-card {
|
.dark .stat-card {
|
||||||
background: linear-gradient(135deg, rgba(31, 41, 55, 0.95) 0%, rgba(17, 24, 39, 0.8) 100%);
|
background: linear-gradient(135deg, var(--glass-strong-color) 0%, var(--bg-gradient-start) 100%);
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -50%;
|
|
||||||
left: -50%;
|
|
||||||
width: 200%;
|
|
||||||
height: 200%;
|
|
||||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
@@ -354,10 +406,6 @@ body::before {
|
|||||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@@ -407,15 +455,15 @@ body::before {
|
|||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
|
||||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
@@ -466,7 +514,7 @@ body::before {
|
|||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px rgba(102, 126, 234, 0.1),
|
0 0 0 3px rgba(var(--primary-rgb), 0.1),
|
||||||
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
@@ -474,9 +522,9 @@ body::before {
|
|||||||
|
|
||||||
.dark .form-input:focus {
|
.dark .form-input:focus {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px rgba(129, 140, 248, 0.2),
|
0 0 0 3px rgba(var(--primary-rgb), 0.2),
|
||||||
0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
||||||
background: rgba(17, 24, 39, 0.95);
|
background: var(--glass-strong-color);
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,8 +575,8 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .modal-content {
|
.dark .modal-content {
|
||||||
background: #1f2937;
|
background: var(--bg-gradient-start);
|
||||||
border: 1px solid rgba(75, 85, 99, 0.5);
|
border: 1px solid var(--border-color);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 25px 50px -12px rgba(0, 0, 0, 0.6),
|
0 25px 50px -12px rgba(0, 0, 0, 0.6),
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.02);
|
0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||||
@@ -633,11 +681,11 @@ body::before {
|
|||||||
/* 自定义滚动条样式 */
|
/* 自定义滚动条样式 */
|
||||||
.custom-scrollbar {
|
.custom-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
|
scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .custom-scrollbar {
|
.dark .custom-scrollbar {
|
||||||
scrollbar-color: rgba(129, 140, 248, 0.3) rgba(129, 140, 248, 0.05);
|
scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
@@ -646,38 +694,62 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
background: rgba(102, 126, 234, 0.05);
|
background: rgba(var(--primary-rgb), 0.05);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .custom-scrollbar::-webkit-scrollbar-track {
|
.dark .custom-scrollbar::-webkit-scrollbar-track {
|
||||||
background: rgba(129, 140, 248, 0.05);
|
background: rgba(var(--primary-rgb), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(var(--primary-rgb), 0.4) 0%,
|
||||||
|
rgba(var(--secondary-rgb), 0.4) 100%
|
||||||
|
);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
transition: background 0.3s ease;
|
transition: background 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: linear-gradient(135deg, rgba(129, 140, 248, 0.4) 0%, rgba(167, 139, 250, 0.4) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(var(--primary-rgb), 0.4) 0%,
|
||||||
|
rgba(var(--secondary-rgb), 0.4) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(var(--primary-rgb), 0.6) 0%,
|
||||||
|
rgba(var(--secondary-rgb), 0.6) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: linear-gradient(135deg, rgba(129, 140, 248, 0.6) 0%, rgba(167, 139, 250, 0.6) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(var(--primary-rgb), 0.6) 0%,
|
||||||
|
rgba(var(--secondary-rgb), 0.6) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(var(--primary-rgb), 0.8) 0%,
|
||||||
|
rgba(var(--secondary-rgb), 0.8) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:active {
|
.dark .custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||||
background: linear-gradient(135deg, rgba(129, 140, 248, 0.8) 0%, rgba(167, 139, 250, 0.8) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(var(--primary-rgb), 0.8) 0%,
|
||||||
|
rgba(var(--secondary-rgb), 0.8) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹窗滚动内容样式 */
|
/* 弹窗滚动内容样式 */
|
||||||
@@ -690,8 +762,8 @@ body::before {
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.glass,
|
.glass,
|
||||||
.glass-strong {
|
.glass-strong {
|
||||||
margin: 16px;
|
margin: 0;
|
||||||
border-radius: 20px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ body::before {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
radial-gradient(circle at 20% 80%, rgba(var(--accent-rgb), 0.2) 0%, transparent 50%),
|
||||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
radial-gradient(circle at 80% 20%, rgba(var(--primary-rgb), 0.2) 0%, transparent 50%),
|
||||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ h6 {
|
|||||||
/* 自定义滚动条样式 */
|
/* 自定义滚动条样式 */
|
||||||
.custom-scrollbar {
|
.custom-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(102, 126, 234, 0.3) rgba(102, 126, 234, 0.05);
|
scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
@@ -89,22 +89,34 @@ h6 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
background: rgba(102, 126, 234, 0.05);
|
background: rgba(var(--primary-rgb), 0.05);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(var(--primary-rgb), 0.4) 0%,
|
||||||
|
rgba(var(--secondary-rgb), 0.4) 100%
|
||||||
|
);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
transition: background 0.3s ease;
|
transition: background 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(var(--primary-rgb), 0.6) 0%,
|
||||||
|
rgba(var(--secondary-rgb), 0.6) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.8) 0%, rgba(118, 75, 162, 0.8) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba(var(--primary-rgb), 0.8) 0%,
|
||||||
|
rgba(var(--secondary-rgb), 0.8) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vue过渡动画 */
|
/* Vue过渡动画 */
|
||||||
@@ -137,8 +149,8 @@ h6 {
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.glass,
|
.glass,
|
||||||
.glass-strong {
|
.glass-strong {
|
||||||
margin: 16px;
|
margin: 0;
|
||||||
border-radius: 20px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
|
|||||||
@@ -3804,8 +3804,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
|
import { getModels } from '@/utils/http_apis'
|
||||||
import { useAccountsStore } from '@/stores/accounts'
|
import { useAccountsStore } from '@/stores/accounts'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import ProxyConfig from './ProxyConfig.vue'
|
import ProxyConfig from './ProxyConfig.vue'
|
||||||
@@ -4110,20 +4111,20 @@ const allowedModels = ref([
|
|||||||
'claude-3-5-haiku-20241022'
|
'claude-3-5-haiku-20241022'
|
||||||
]) // 白名单模式下选中的模型列表
|
]) // 白名单模式下选中的模型列表
|
||||||
|
|
||||||
// 常用模型列表
|
// 常用模型列表(从 API 获取)
|
||||||
const commonModels = [
|
const commonModels = ref([])
|
||||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5', color: 'blue' },
|
|
||||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4', color: 'blue' },
|
// 加载模型列表
|
||||||
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', color: 'indigo' },
|
const loadCommonModels = async () => {
|
||||||
{ value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku', color: 'green' },
|
try {
|
||||||
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', color: 'emerald' },
|
const result = await getModels()
|
||||||
{ value: 'claude-opus-4-20250514', label: 'Claude Opus 4', color: 'purple' },
|
if (result.success && result.data?.all) {
|
||||||
{ value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1', color: 'purple' },
|
commonModels.value = result.data.all
|
||||||
{ value: 'deepseek-chat', label: 'DeepSeek Chat', color: 'cyan' },
|
}
|
||||||
{ value: 'Qwen', label: 'Qwen', color: 'orange' },
|
} catch (error) {
|
||||||
{ value: 'Kimi', label: 'Kimi', color: 'pink' },
|
console.error('Failed to load models:', error)
|
||||||
{ value: 'GLM', label: 'GLM', color: 'teal' }
|
}
|
||||||
]
|
}
|
||||||
|
|
||||||
// 模型映射表数据
|
// 模型映射表数据
|
||||||
const modelMappings = ref([])
|
const modelMappings = ref([])
|
||||||
@@ -4330,7 +4331,7 @@ const loadAccountUsage = async () => {
|
|||||||
if (!isEdit.value || !props.account?.id) return
|
if (!isEdit.value || !props.account?.id) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/admin/claude-console-accounts/${props.account.id}/usage`)
|
const response = await httpApi.get(`/admin/claude-console-accounts/${props.account.id}/usage`)
|
||||||
if (response) {
|
if (response) {
|
||||||
// 更新表单中的使用量数据
|
// 更新表单中的使用量数据
|
||||||
form.value.dailyUsage = response.dailyUsage || 0
|
form.value.dailyUsage = response.dailyUsage || 0
|
||||||
@@ -5733,7 +5734,7 @@ const filteredGroups = computed(() => {
|
|||||||
const loadGroups = async () => {
|
const loadGroups = async () => {
|
||||||
loadingGroups.value = true
|
loadingGroups.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/account-groups')
|
const response = await httpApi.get('/admin/account-groups')
|
||||||
groups.value = response.data || []
|
groups.value = response.data || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('加载分组列表失败', 'error')
|
showToast('加载分组列表失败', 'error')
|
||||||
@@ -6186,7 +6187,7 @@ watch(
|
|||||||
// 否则查找账户所属的分组
|
// 否则查找账户所属的分组
|
||||||
const checkPromises = groups.value.map(async (group) => {
|
const checkPromises = groups.value.map(async (group) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/admin/account-groups/${group.id}/members`)
|
const response = await httpApi.get(`/admin/account-groups/${group.id}/members`)
|
||||||
const members = response.data || []
|
const members = response.data || []
|
||||||
if (members.some((m) => m.id === newAccount.id)) {
|
if (members.some((m) => m.id === newAccount.id)) {
|
||||||
foundGroupIds.push(group.id)
|
foundGroupIds.push(group.id)
|
||||||
@@ -6214,7 +6215,7 @@ watch(
|
|||||||
// 获取统一 User-Agent 信息
|
// 获取统一 User-Agent 信息
|
||||||
const fetchUnifiedUserAgent = async () => {
|
const fetchUnifiedUserAgent = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/claude-code-version')
|
const response = await httpApi.get('/admin/claude-code-version')
|
||||||
if (response.success && response.userAgent) {
|
if (response.success && response.userAgent) {
|
||||||
unifiedUserAgent.value = response.userAgent
|
unifiedUserAgent.value = response.userAgent
|
||||||
} else {
|
} else {
|
||||||
@@ -6230,7 +6231,7 @@ const fetchUnifiedUserAgent = async () => {
|
|||||||
const clearUnifiedCache = async () => {
|
const clearUnifiedCache = async () => {
|
||||||
clearingCache.value = true
|
clearingCache.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/claude-code-version/clear')
|
const response = await httpApi.post('/admin/claude-code-version/clear')
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
unifiedUserAgent.value = ''
|
unifiedUserAgent.value = ''
|
||||||
showToast('统一User-Agent缓存已清除', 'success')
|
showToast('统一User-Agent缓存已清除', 'success')
|
||||||
@@ -6336,6 +6337,9 @@ onMounted(() => {
|
|||||||
initModelMappings()
|
initModelMappings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载模型列表
|
||||||
|
loadCommonModels()
|
||||||
|
|
||||||
// 获取Claude Code统一User-Agent信息
|
// 获取Claude Code统一User-Agent信息
|
||||||
fetchUnifiedUserAgent()
|
fetchUnifiedUserAgent()
|
||||||
// 如果是编辑模式且是Claude Console账户,加载使用情况
|
// 如果是编辑模式且是Claude Console账户,加载使用情况
|
||||||
|
|||||||
@@ -220,8 +220,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { API_PREFIX } from '@/config/api'
|
import { API_PREFIX } from '@/utils/http_apis'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
|
|||||||
@@ -70,7 +70,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
|
<select
|
||||||
|
v-model="selectedModel"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs font-medium text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
:disabled="testStatus === 'testing'"
|
||||||
|
>
|
||||||
|
<option v-for="m in availableModels" :key="m" :value="m">{{ m }}</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,7 +183,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
import { API_PREFIX } from '@/config/api'
|
import { API_PREFIX } from '@/utils/http_apis'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -199,36 +205,112 @@ const errorMessage = ref('')
|
|||||||
const testDuration = ref(0)
|
const testDuration = ref(0)
|
||||||
const testStartTime = ref(null)
|
const testStartTime = ref(null)
|
||||||
const eventSource = ref(null)
|
const eventSource = ref(null)
|
||||||
|
const selectedModel = ref('')
|
||||||
|
|
||||||
// 测试模型
|
// 可用模型列表 - 根据账户类型
|
||||||
const testModel = ref('claude-sonnet-4-5-20250929')
|
const availableModels = computed(() => {
|
||||||
|
if (!props.account) return []
|
||||||
|
const platform = props.account.platform
|
||||||
|
const modelLists = {
|
||||||
|
claude: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
|
||||||
|
'claude-console': [
|
||||||
|
'claude-sonnet-4-5-20250929',
|
||||||
|
'claude-sonnet-4-20250514',
|
||||||
|
'claude-3-5-haiku-20241022'
|
||||||
|
],
|
||||||
|
bedrock: [
|
||||||
|
'claude-sonnet-4-5-20250929',
|
||||||
|
'claude-sonnet-4-20250514',
|
||||||
|
'claude-3-5-haiku-20241022'
|
||||||
|
],
|
||||||
|
gemini: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'],
|
||||||
|
'openai-responses': ['gpt-4o-mini', 'gpt-4o', 'o3-mini'],
|
||||||
|
'azure-openai': [props.account.deploymentName || 'gpt-4o-mini'],
|
||||||
|
droid: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
|
||||||
|
ccr: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022']
|
||||||
|
}
|
||||||
|
return modelLists[platform] || []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 默认测试模型
|
||||||
|
const defaultModel = computed(() => {
|
||||||
|
if (!props.account) return ''
|
||||||
|
const platform = props.account.platform
|
||||||
|
const models = {
|
||||||
|
claude: 'claude-sonnet-4-5-20250929',
|
||||||
|
'claude-console': 'claude-sonnet-4-5-20250929',
|
||||||
|
bedrock: 'claude-sonnet-4-5-20250929',
|
||||||
|
gemini: 'gemini-2.5-flash',
|
||||||
|
'openai-responses': 'gpt-4o-mini',
|
||||||
|
'azure-openai': props.account.deploymentName || 'gpt-4o-mini',
|
||||||
|
droid: 'claude-sonnet-4-20250514',
|
||||||
|
ccr: 'claude-sonnet-4-20250514'
|
||||||
|
}
|
||||||
|
return models[platform] || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听账户变化,重置选中的模型
|
||||||
|
watch(
|
||||||
|
() => props.account,
|
||||||
|
() => {
|
||||||
|
selectedModel.value = defaultModel.value
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 是否使用 SSE 流式响应
|
||||||
|
const useSSE = computed(() => {
|
||||||
|
if (!props.account) return false
|
||||||
|
return ['claude', 'claude-console'].includes(props.account.platform)
|
||||||
|
})
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const platformLabel = computed(() => {
|
const platformLabel = computed(() => {
|
||||||
if (!props.account) return '未知'
|
if (!props.account) return '未知'
|
||||||
const platform = props.account.platform
|
const platform = props.account.platform
|
||||||
if (platform === 'claude') return 'Claude OAuth'
|
const labels = {
|
||||||
if (platform === 'claude-console') return 'Claude Console'
|
claude: 'Claude OAuth',
|
||||||
return platform
|
'claude-console': 'Claude Console',
|
||||||
|
bedrock: 'AWS Bedrock',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
'openai-responses': 'OpenAI Responses',
|
||||||
|
'azure-openai': 'Azure OpenAI',
|
||||||
|
droid: 'Droid',
|
||||||
|
ccr: 'CCR'
|
||||||
|
}
|
||||||
|
return labels[platform] || platform
|
||||||
})
|
})
|
||||||
|
|
||||||
const platformIcon = computed(() => {
|
const platformIcon = computed(() => {
|
||||||
if (!props.account) return 'fas fa-question'
|
if (!props.account) return 'fas fa-question'
|
||||||
const platform = props.account.platform
|
const platform = props.account.platform
|
||||||
if (platform === 'claude' || platform === 'claude-console') return 'fas fa-brain'
|
const icons = {
|
||||||
return 'fas fa-robot'
|
claude: 'fas fa-brain',
|
||||||
|
'claude-console': 'fas fa-brain',
|
||||||
|
bedrock: 'fab fa-aws',
|
||||||
|
gemini: 'fas fa-gem',
|
||||||
|
'openai-responses': 'fas fa-code',
|
||||||
|
'azure-openai': 'fab fa-microsoft',
|
||||||
|
droid: 'fas fa-robot',
|
||||||
|
ccr: 'fas fa-key'
|
||||||
|
}
|
||||||
|
return icons[platform] || 'fas fa-robot'
|
||||||
})
|
})
|
||||||
|
|
||||||
const platformBadgeClass = computed(() => {
|
const platformBadgeClass = computed(() => {
|
||||||
if (!props.account) return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
if (!props.account) return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
const platform = props.account.platform
|
const platform = props.account.platform
|
||||||
if (platform === 'claude') {
|
const classes = {
|
||||||
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
|
claude: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300',
|
||||||
|
'claude-console': 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300',
|
||||||
|
bedrock: 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300',
|
||||||
|
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300',
|
||||||
|
'openai-responses': 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300',
|
||||||
|
'azure-openai': 'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300',
|
||||||
|
droid: 'bg-pink-100 text-pink-700 dark:bg-pink-500/20 dark:text-pink-300',
|
||||||
|
ccr: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300'
|
||||||
}
|
}
|
||||||
if (platform === 'claude-console') {
|
return classes[platform] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300'
|
|
||||||
}
|
|
||||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusTitle = computed(() => {
|
const statusTitle = computed(() => {
|
||||||
@@ -247,15 +329,16 @@ const statusTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const statusDescription = computed(() => {
|
const statusDescription = computed(() => {
|
||||||
|
const apiName = platformLabel.value || 'API'
|
||||||
switch (testStatus.value) {
|
switch (testStatus.value) {
|
||||||
case 'idle':
|
case 'idle':
|
||||||
return '点击下方按钮开始测试账户连通性'
|
return '点击下方按钮开始测试账户连通性'
|
||||||
case 'testing':
|
case 'testing':
|
||||||
return '正在发送测试请求并等待响应'
|
return '正在发送测试请求并等待响应'
|
||||||
case 'success':
|
case 'success':
|
||||||
return '账户可以正常访问 Claude API'
|
return `账户可以正常访问 ${apiName}`
|
||||||
case 'error':
|
case 'error':
|
||||||
return errorMessage.value || '无法连接到 Claude API'
|
return errorMessage.value || `无法连接到 ${apiName}`
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -340,13 +423,17 @@ const statusTextClass = computed(() => {
|
|||||||
function getTestEndpoint() {
|
function getTestEndpoint() {
|
||||||
if (!props.account) return ''
|
if (!props.account) return ''
|
||||||
const platform = props.account.platform
|
const platform = props.account.platform
|
||||||
if (platform === 'claude') {
|
const endpoints = {
|
||||||
return `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test`
|
claude: `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test`,
|
||||||
|
'claude-console': `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`,
|
||||||
|
bedrock: `${API_PREFIX}/admin/bedrock-accounts/${props.account.id}/test`,
|
||||||
|
gemini: `${API_PREFIX}/admin/gemini-accounts/${props.account.id}/test`,
|
||||||
|
'openai-responses': `${API_PREFIX}/admin/openai-responses-accounts/${props.account.id}/test`,
|
||||||
|
'azure-openai': `${API_PREFIX}/admin/azure-openai-accounts/${props.account.id}/test`,
|
||||||
|
droid: `${API_PREFIX}/admin/droid-accounts/${props.account.id}/test`,
|
||||||
|
ccr: `${API_PREFIX}/admin/ccr-accounts/${props.account.id}/test`
|
||||||
}
|
}
|
||||||
if (platform === 'claude-console') {
|
return endpoints[platform] || ''
|
||||||
return `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startTest() {
|
async function startTest() {
|
||||||
@@ -375,14 +462,14 @@ async function startTest() {
|
|||||||
// 获取认证token
|
// 获取认证token
|
||||||
const authToken = localStorage.getItem('authToken')
|
const authToken = localStorage.getItem('authToken')
|
||||||
|
|
||||||
// 使用fetch发送POST请求并处理SSE
|
// 使用fetch发送POST请求
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: authToken ? `Bearer ${authToken}` : ''
|
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ model: testModel.value })
|
body: JSON.stringify({ model: selectedModel.value })
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -390,31 +477,46 @@ async function startTest() {
|
|||||||
throw new Error(errorData.message || `HTTP ${response.status}`)
|
throw new Error(errorData.message || `HTTP ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理SSE流
|
// 根据账户类型处理响应
|
||||||
const reader = response.body.getReader()
|
if (useSSE.value) {
|
||||||
const decoder = new TextDecoder()
|
// SSE 流式响应 (Claude/Console)
|
||||||
let streamDone = false
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let streamDone = false
|
||||||
|
|
||||||
while (!streamDone) {
|
while (!streamDone) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) {
|
if (done) {
|
||||||
streamDone = true
|
streamDone = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunk = decoder.decode(value)
|
const chunk = decoder.decode(value)
|
||||||
const lines = chunk.split('\n')
|
const lines = chunk.split('\n')
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line.substring(6))
|
const data = JSON.parse(line.substring(6))
|
||||||
handleSSEEvent(data)
|
handleSSEEvent(data)
|
||||||
} catch {
|
} catch {
|
||||||
// 忽略解析错误
|
// 忽略解析错误
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// JSON 响应 (其他平台)
|
||||||
|
const data = await response.json()
|
||||||
|
testDuration.value = Date.now() - testStartTime.value
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
testStatus.value = 'success'
|
||||||
|
responseText.value = data.data?.responseText || 'Test passed'
|
||||||
|
} else {
|
||||||
|
testStatus.value = 'error'
|
||||||
|
errorMessage.value = data.message || 'Test failed'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
testStatus.value = 'error'
|
testStatus.value = 'error'
|
||||||
|
|||||||
@@ -381,13 +381,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmModal
|
||||||
|
:cancel-text="confirmModalConfig.cancelText"
|
||||||
|
:confirm-text="confirmModalConfig.confirmText"
|
||||||
|
:message="confirmModalConfig.message"
|
||||||
|
:show="showConfirmModal"
|
||||||
|
:title="confirmModalConfig.title"
|
||||||
|
:type="confirmModalConfig.type"
|
||||||
|
@cancel="handleCancelModal"
|
||||||
|
@confirm="handleConfirmModal"
|
||||||
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
accountId: {
|
accountId: {
|
||||||
@@ -417,6 +428,39 @@ const searchQuery = ref('')
|
|||||||
const searchMode = ref('fuzzy') // 'fuzzy' | 'exact'
|
const searchMode = ref('fuzzy') // 'fuzzy' | 'exact'
|
||||||
const batchDeleting = ref(false)
|
const batchDeleting = ref(false)
|
||||||
|
|
||||||
|
// ConfirmModal 状态
|
||||||
|
const showConfirmModal = ref(false)
|
||||||
|
const confirmModalConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'primary',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
const confirmResolve = ref(null)
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
type = 'primary'
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||||
|
confirmResolve.value = resolve
|
||||||
|
showConfirmModal.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleConfirmModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(true)
|
||||||
|
}
|
||||||
|
const handleCancelModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
// 掩码显示 API Key(提前声明供 computed 使用)
|
// 掩码显示 API Key(提前声明供 computed 使用)
|
||||||
const maskApiKey = (key) => {
|
const maskApiKey = (key) => {
|
||||||
if (!key || key.length < 12) {
|
if (!key || key.length < 12) {
|
||||||
@@ -474,7 +518,7 @@ const errorKeysCount = computed(() => {
|
|||||||
const loadApiKeys = async () => {
|
const loadApiKeys = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/admin/droid-accounts/${props.accountId}`)
|
const response = await httpApi.get(`/admin/droid-accounts/${props.accountId}`)
|
||||||
const account = response.data
|
const account = response.data
|
||||||
|
|
||||||
// 解析 apiKeys
|
// 解析 apiKeys
|
||||||
@@ -549,7 +593,15 @@ const loadApiKeys = async () => {
|
|||||||
|
|
||||||
// 删除 API Key
|
// 删除 API Key
|
||||||
const deleteApiKey = async (apiKey) => {
|
const deleteApiKey = async (apiKey) => {
|
||||||
if (!confirm(`确定要删除 API Key "${maskApiKey(apiKey.key)}" 吗?`)) {
|
if (
|
||||||
|
!(await showConfirm(
|
||||||
|
'删除 API Key',
|
||||||
|
`确定要删除 API Key "${maskApiKey(apiKey.key)}" 吗?`,
|
||||||
|
'删除',
|
||||||
|
'取消',
|
||||||
|
'danger'
|
||||||
|
))
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +613,7 @@ const deleteApiKey = async (apiKey) => {
|
|||||||
apiKeyUpdateMode: 'delete'
|
apiKeyUpdateMode: 'delete'
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||||
|
|
||||||
showToast('API Key 已删除', 'success')
|
showToast('API Key 已删除', 'success')
|
||||||
await loadApiKeys()
|
await loadApiKeys()
|
||||||
@@ -577,9 +629,13 @@ const deleteApiKey = async (apiKey) => {
|
|||||||
// 重置 API Key 状态
|
// 重置 API Key 状态
|
||||||
const resetApiKeyStatus = async (apiKey) => {
|
const resetApiKeyStatus = async (apiKey) => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!(await showConfirm(
|
||||||
`确定要重置 API Key "${maskApiKey(apiKey.key)}" 的状态吗?这将清除错误信息并恢复为正常状态。`
|
'重置状态',
|
||||||
)
|
`确定要重置 API Key "${maskApiKey(apiKey.key)}" 的状态吗?这将清除错误信息并恢复为正常状态。`,
|
||||||
|
'重置',
|
||||||
|
'取消',
|
||||||
|
'warning'
|
||||||
|
))
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -598,7 +654,7 @@ const resetApiKeyStatus = async (apiKey) => {
|
|||||||
apiKeyUpdateMode: 'update'
|
apiKeyUpdateMode: 'update'
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||||
|
|
||||||
showToast('API Key 状态已重置', 'success')
|
showToast('API Key 状态已重置', 'success')
|
||||||
await loadApiKeys()
|
await loadApiKeys()
|
||||||
@@ -619,7 +675,15 @@ const deleteAllErrorKeys = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`确定要删除所有 ${errorKeys.length} 个异常状态的 API Key 吗?此操作不可恢复!`)) {
|
if (
|
||||||
|
!(await showConfirm(
|
||||||
|
'删除异常 API Key',
|
||||||
|
`确定要删除所有 ${errorKeys.length} 个异常状态的 API Key 吗?此操作不可恢复!`,
|
||||||
|
'删除',
|
||||||
|
'取消',
|
||||||
|
'danger'
|
||||||
|
))
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,7 +695,7 @@ const deleteAllErrorKeys = async () => {
|
|||||||
apiKeyUpdateMode: 'delete'
|
apiKeyUpdateMode: 'delete'
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||||
|
|
||||||
showToast(`成功删除 ${errorKeys.length} 个异常 API Key`, 'success')
|
showToast(`成功删除 ${errorKeys.length} 个异常 API Key`, 'success')
|
||||||
await loadApiKeys()
|
await loadApiKeys()
|
||||||
@@ -652,15 +716,21 @@ const deleteAllKeys = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!(await showConfirm(
|
||||||
`确定要删除所有 ${apiKeys.value.length} 个 API Key 吗?此操作不可恢复!\n\n请再次确认:这将删除该账户下的所有 API Key。`
|
'删除全部 API Key',
|
||||||
)
|
`确定要删除所有 ${apiKeys.value.length} 个 API Key 吗?此操作不可恢复!\n\n请再次确认:这将删除该账户下的所有 API Key。`,
|
||||||
|
'删除',
|
||||||
|
'取消',
|
||||||
|
'danger'
|
||||||
|
))
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 二次确认
|
// 二次确认
|
||||||
if (!confirm('最后确认:真的要删除所有 API Key 吗?')) {
|
if (
|
||||||
|
!(await showConfirm('最后确认', '真的要删除所有 API Key 吗?', '确认删除', '取消', 'danger'))
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,7 +742,7 @@ const deleteAllKeys = async () => {
|
|||||||
apiKeyUpdateMode: 'delete'
|
apiKeyUpdateMode: 'delete'
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
|
||||||
|
|
||||||
showToast(`成功删除所有 ${keysToDelete.length} 个 API Key`, 'success')
|
showToast(`成功删除所有 ${keysToDelete.length} 个 API Key`, 'success')
|
||||||
await loadApiKeys()
|
await loadApiKeys()
|
||||||
|
|||||||
@@ -259,8 +259,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import ProxyConfig from '@/components/accounts/ProxyConfig.vue'
|
import ProxyConfig from '@/components/accounts/ProxyConfig.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -344,7 +344,7 @@ const submit = async () => {
|
|||||||
if (form.value.apiKey && form.value.apiKey.trim().length > 0) {
|
if (form.value.apiKey && form.value.apiKey.trim().length > 0) {
|
||||||
updates.apiKey = form.value.apiKey
|
updates.apiKey = form.value.apiKey
|
||||||
}
|
}
|
||||||
const res = await apiClient.put(`/admin/ccr-accounts/${props.account.id}`, updates)
|
const res = await httpApi.put(`/admin/ccr-accounts/${props.account.id}`, updates)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
// 不在这里显示 toast,由父组件统一处理
|
// 不在这里显示 toast,由父组件统一处理
|
||||||
emit('success')
|
emit('success')
|
||||||
@@ -367,7 +367,7 @@ const submit = async () => {
|
|||||||
dailyQuota: Number(form.value.dailyQuota || 0),
|
dailyQuota: Number(form.value.dailyQuota || 0),
|
||||||
quotaResetTime: form.value.quotaResetTime || '00:00'
|
quotaResetTime: form.value.quotaResetTime || '00:00'
|
||||||
}
|
}
|
||||||
const res = await apiClient.post('/admin/ccr-accounts', payload)
|
const res = await httpApi.post('/admin/ccr-accounts', payload)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
// 不在这里显示 toast,由父组件统一处理
|
// 不在这里显示 toast,由父组件统一处理
|
||||||
emit('success')
|
emit('success')
|
||||||
|
|||||||
@@ -21,72 +21,36 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 添加分组按钮 -->
|
<!-- Tab 切换栏 -->
|
||||||
<div class="mb-6">
|
<div class="mb-4 flex flex-wrap gap-2">
|
||||||
<button class="btn btn-primary px-4 py-2" @click="showCreateForm = true">
|
<button
|
||||||
<i class="fas fa-plus mr-2" />
|
v-for="tab in platformTabs"
|
||||||
创建新分组
|
:key="tab.key"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg px-3 py-1.5 text-sm font-medium transition-all',
|
||||||
|
activeTab === tab.key
|
||||||
|
? tab.key === 'claude'
|
||||||
|
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
||||||
|
: tab.key === 'gemini'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: tab.key === 'droid'
|
||||||
|
? 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300'
|
||||||
|
: 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||||
|
]"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
<span class="ml-1 text-xs opacity-70">({{ platformCounts[tab.key] }})</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 创建分组表单 -->
|
<!-- 添加分组按钮 -->
|
||||||
<div v-if="showCreateForm" class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
<div class="mb-6">
|
||||||
<h4 class="mb-4 text-lg font-semibold text-gray-900">创建新分组</h4>
|
<button class="btn btn-primary px-4 py-2" @click="openCreateForm">
|
||||||
<div class="space-y-4">
|
<i class="fas fa-plus mr-2" />
|
||||||
<div>
|
创建新分组
|
||||||
<label class="mb-2 block text-sm font-semibold text-gray-700">分组名称 *</label>
|
</button>
|
||||||
<input
|
|
||||||
v-model="createForm.name"
|
|
||||||
class="form-input w-full"
|
|
||||||
placeholder="输入分组名称"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-semibold text-gray-700">平台类型 *</label>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<label class="flex cursor-pointer items-center">
|
|
||||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="claude" />
|
|
||||||
<span class="text-sm text-gray-700">Claude</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex cursor-pointer items-center">
|
|
||||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="gemini" />
|
|
||||||
<span class="text-sm text-gray-700">Gemini</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex cursor-pointer items-center">
|
|
||||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="openai" />
|
|
||||||
<span class="text-sm text-gray-700">OpenAI</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex cursor-pointer items-center">
|
|
||||||
<input v-model="createForm.platform" class="mr-2" type="radio" value="droid" />
|
|
||||||
<span class="text-sm text-gray-700">Droid</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-semibold text-gray-700">描述 (可选)</label>
|
|
||||||
<textarea
|
|
||||||
v-model="createForm.description"
|
|
||||||
class="form-input w-full resize-none"
|
|
||||||
placeholder="分组描述..."
|
|
||||||
rows="2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary px-4 py-2"
|
|
||||||
:disabled="!createForm.name || !createForm.platform || creating"
|
|
||||||
@click="createGroup"
|
|
||||||
>
|
|
||||||
<div v-if="creating" class="loading-spinner mr-2" />
|
|
||||||
{{ creating ? '创建中...' : '创建' }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary px-4 py-2" @click="cancelCreate">取消</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分组列表 -->
|
<!-- 分组列表 -->
|
||||||
@@ -96,14 +60,17 @@
|
|||||||
<p class="text-gray-500">加载中...</p>
|
<p class="text-gray-500">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="groups.length === 0" class="rounded-lg bg-gray-50 py-8 text-center">
|
<div
|
||||||
<i class="fas fa-layer-group mb-4 text-4xl text-gray-300" />
|
v-else-if="filteredGroups.length === 0"
|
||||||
<p class="text-gray-500">暂无分组</p>
|
class="rounded-lg bg-gray-50 py-8 text-center dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<i class="fas fa-layer-group mb-4 text-4xl text-gray-300 dark:text-gray-600" />
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">暂无分组</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div
|
<div
|
||||||
v-for="group in groups"
|
v-for="group in filteredGroups"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
class="rounded-lg border bg-white p-4 transition-shadow hover:shadow-md"
|
class="rounded-lg border bg-white p-4 transition-shadow hover:shadow-md"
|
||||||
>
|
>
|
||||||
@@ -239,13 +206,106 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建分组模态框 -->
|
||||||
|
<div
|
||||||
|
v-if="showCreateForm"
|
||||||
|
class="modal fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4"
|
||||||
|
>
|
||||||
|
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">创建新分组</h3>
|
||||||
|
<button
|
||||||
|
class="text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
@click="cancelCreate"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>分组名称 *</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="createForm.name"
|
||||||
|
class="form-input w-full"
|
||||||
|
placeholder="输入分组名称"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>平台类型 *</label
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input v-model="createForm.platform" class="mr-2" type="radio" value="claude" />
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input v-model="createForm.platform" class="mr-2" type="radio" value="gemini" />
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input v-model="createForm.platform" class="mr-2" type="radio" value="openai" />
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center">
|
||||||
|
<input v-model="createForm.platform" class="mr-2" type="radio" value="droid" />
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>描述 (可选)</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="createForm.description"
|
||||||
|
class="form-input w-full resize-none"
|
||||||
|
placeholder="分组描述..."
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary flex-1 px-4 py-2"
|
||||||
|
:disabled="!createForm.name || !createForm.platform || creating"
|
||||||
|
@click="createGroup"
|
||||||
|
>
|
||||||
|
<div v-if="creating" class="loading-spinner mr-2" />
|
||||||
|
{{ creating ? '创建中...' : '创建' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary flex-1 px-4 py-2" @click="cancelCreate">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 删除确认对话框 -->
|
||||||
|
<ConfirmModal
|
||||||
|
cancel-text="取消"
|
||||||
|
confirm-text="确认删除"
|
||||||
|
:message="`确定要删除分组 "${deletingGroup?.name}" 吗?此操作不可撤销。`"
|
||||||
|
:show="showDeleteConfirm"
|
||||||
|
title="确认删除"
|
||||||
|
type="danger"
|
||||||
|
@cancel="cancelDelete"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'refresh'])
|
const emit = defineEmits(['close', 'refresh'])
|
||||||
|
|
||||||
@@ -253,6 +313,35 @@ const show = ref(true)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const groups = ref([])
|
const groups = ref([])
|
||||||
|
|
||||||
|
// Tab 切换
|
||||||
|
const activeTab = ref('all')
|
||||||
|
const platformTabs = [
|
||||||
|
{ key: 'all', label: '全部', color: 'gray' },
|
||||||
|
{ key: 'claude', label: 'Claude', color: 'purple' },
|
||||||
|
{ key: 'gemini', label: 'Gemini', color: 'blue' },
|
||||||
|
{ key: 'openai', label: 'OpenAI', color: 'gray' },
|
||||||
|
{ key: 'droid', label: 'Droid', color: 'cyan' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 各平台分组数量
|
||||||
|
const platformCounts = computed(() => {
|
||||||
|
const counts = { all: groups.value.length }
|
||||||
|
platformTabs.slice(1).forEach((tab) => {
|
||||||
|
counts[tab.key] = groups.value.filter((g) => g.platform === tab.key).length
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤后的分组列表
|
||||||
|
const filteredGroups = computed(() => {
|
||||||
|
if (activeTab.value === 'all') return groups.value
|
||||||
|
return groups.value.filter((g) => g.platform === activeTab.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除确认
|
||||||
|
const showDeleteConfirm = ref(false)
|
||||||
|
const deletingGroup = ref(null)
|
||||||
|
|
||||||
// 创建表单
|
// 创建表单
|
||||||
const showCreateForm = ref(false)
|
const showCreateForm = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
@@ -283,7 +372,7 @@ const formatDate = (dateStr) => {
|
|||||||
const loadGroups = async () => {
|
const loadGroups = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/account-groups')
|
const response = await httpApi.get('/admin/account-groups')
|
||||||
groups.value = response.data || []
|
groups.value = response.data || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('加载分组列表失败', 'error')
|
showToast('加载分组列表失败', 'error')
|
||||||
@@ -301,7 +390,7 @@ const createGroup = async () => {
|
|||||||
|
|
||||||
creating.value = true
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/admin/account-groups', {
|
await httpApi.post('/admin/account-groups', {
|
||||||
name: createForm.value.name,
|
name: createForm.value.name,
|
||||||
platform: createForm.value.platform,
|
platform: createForm.value.platform,
|
||||||
description: createForm.value.description
|
description: createForm.value.description
|
||||||
@@ -318,6 +407,12 @@ const createGroup = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开创建表单(根据当前 Tab 预选平台)
|
||||||
|
const openCreateForm = () => {
|
||||||
|
createForm.value.platform = activeTab.value !== 'all' ? activeTab.value : 'claude'
|
||||||
|
showCreateForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// 取消创建
|
// 取消创建
|
||||||
const cancelCreate = () => {
|
const cancelCreate = () => {
|
||||||
showCreateForm.value = false
|
showCreateForm.value = false
|
||||||
@@ -348,7 +443,7 @@ const updateGroup = async () => {
|
|||||||
|
|
||||||
updating.value = true
|
updating.value = true
|
||||||
try {
|
try {
|
||||||
await apiClient.put(`/admin/account-groups/${editingGroup.value.id}`, {
|
await httpApi.put(`/admin/account-groups/${editingGroup.value.id}`, {
|
||||||
name: editForm.value.name,
|
name: editForm.value.name,
|
||||||
description: editForm.value.description
|
description: editForm.value.description
|
||||||
})
|
})
|
||||||
@@ -375,20 +470,23 @@ const cancelEdit = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除分组
|
// 删除分组 - 打开确认对话框
|
||||||
const deleteGroup = async (group) => {
|
const deleteGroup = (group) => {
|
||||||
if (group.memberCount > 0) {
|
if (group.memberCount > 0) {
|
||||||
showToast('分组内还有成员,无法删除', 'error')
|
showToast('分组内还有成员,无法删除', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
deletingGroup.value = group
|
||||||
|
showDeleteConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) {
|
// 确认删除
|
||||||
return
|
const confirmDelete = async () => {
|
||||||
}
|
if (!deletingGroup.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/admin/account-groups/${group.id}`)
|
await httpApi.del(`/admin/account-groups/${deletingGroup.value.id}`)
|
||||||
showToast('分组删除成功', 'success')
|
showToast('分组删除成功', 'success')
|
||||||
|
cancelDelete()
|
||||||
await loadGroups()
|
await loadGroups()
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -396,6 +494,12 @@ const deleteGroup = async (group) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 取消删除
|
||||||
|
const cancelDelete = () => {
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
deletingGroup.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
// 组件挂载时加载数据
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadGroups()
|
loadGroups()
|
||||||
|
|||||||
@@ -794,7 +794,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { useAccountsStore } from '@/stores/accounts'
|
import { useAccountsStore } from '@/stores/accounts'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@@ -192,8 +192,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -221,7 +221,7 @@ const handleSubmit = async () => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.patch(`/users/${props.user.id}/role`, {
|
const response = await httpApi.patch(`/users/${props.user.id}/role`, {
|
||||||
role: selectedRole.value
|
role: selectedRole.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -347,8 +347,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -394,10 +394,10 @@ const loadUsageStats = async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [statsResponse, userResponse] = await Promise.all([
|
const [statsResponse, userResponse] = await Promise.all([
|
||||||
apiClient.get(`/users/${props.user.id}/usage-stats`, {
|
httpApi.get(`/users/${props.user.id}/usage-stats`, {
|
||||||
params: { period: selectedPeriod.value }
|
params: { period: selectedPeriod.value }
|
||||||
}),
|
}),
|
||||||
apiClient.get(`/users/${props.user.id}`)
|
httpApi.get(`/users/${props.user.id}`)
|
||||||
])
|
])
|
||||||
|
|
||||||
if (statsResponse.success) {
|
if (statsResponse.success) {
|
||||||
|
|||||||
@@ -85,19 +85,59 @@
|
|||||||
class="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
|
class="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
|
||||||
>
|
>
|
||||||
<i class="fas fa-link" />
|
<i class="fas fa-link" />
|
||||||
/api/v1/messages
|
{{ serviceConfig.displayEndpoint }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="text-sm">
|
||||||
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
<div class="mb-1 flex items-center justify-between">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ testModel }}</span>
|
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
|
||||||
|
<select
|
||||||
|
v-model="testModel"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option v-for="model in availableModels" :key="model.value" :value="model.value">
|
||||||
|
{{ model.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="text-right text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{{ testModel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<div class="mb-1 flex items-center justify-between">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">最大输出 Token</span>
|
||||||
|
<select
|
||||||
|
v-model="maxTokens"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option v-for="opt in maxTokensOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<span class="text-gray-500 dark:text-gray-400">模拟客户端</span>
|
<span class="text-gray-500 dark:text-gray-400">测试服务</span>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">Claude Code</span>
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{
|
||||||
|
serviceConfig.name
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 提示词输入 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
提示词
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="testPrompt"
|
||||||
|
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
|
||||||
|
placeholder="输入测试提示词..."
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 状态指示 -->
|
<!-- 状态指示 -->
|
||||||
<div :class="['mb-4 rounded-xl border p-4 transition-all duration-300', statusCardClass]">
|
<div :class="['mb-4 rounded-xl border p-4 transition-all duration-300', statusCardClass]">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -200,8 +240,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
import { ref, computed, watch, onUnmounted, onMounted } from 'vue'
|
||||||
import { API_PREFIX } from '@/config/api'
|
import { API_PREFIX } from '@/utils/http_apis'
|
||||||
|
import { getModels } from '@/utils/http_apis'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -217,6 +258,12 @@ const props = defineProps({
|
|||||||
apiKeyName: {
|
apiKeyName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
// 服务类型: claude, gemini, openai
|
||||||
|
serviceType: {
|
||||||
|
type: String,
|
||||||
|
default: 'claude',
|
||||||
|
validator: (value) => ['claude', 'gemini', 'openai'].includes(value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -233,6 +280,77 @@ const abortController = ref(null)
|
|||||||
// 测试模型
|
// 测试模型
|
||||||
const testModel = ref('claude-sonnet-4-5-20250929')
|
const testModel = ref('claude-sonnet-4-5-20250929')
|
||||||
|
|
||||||
|
// 测试提示词
|
||||||
|
const testPrompt = ref('hi')
|
||||||
|
|
||||||
|
// 最大输出 token
|
||||||
|
const maxTokens = ref(1000)
|
||||||
|
const maxTokensOptions = [
|
||||||
|
{ value: 100, label: '100' },
|
||||||
|
{ value: 500, label: '500' },
|
||||||
|
{ value: 1000, label: '1000' },
|
||||||
|
{ value: 2000, label: '2000' },
|
||||||
|
{ value: 4096, label: '4096' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 从 API 获取的模型列表
|
||||||
|
const modelsFromApi = ref({
|
||||||
|
claude: [],
|
||||||
|
gemini: [],
|
||||||
|
openai: []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载模型列表
|
||||||
|
const loadModels = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getModels()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
modelsFromApi.value = {
|
||||||
|
claude: result.data.claude || [],
|
||||||
|
gemini: result.data.gemini || [],
|
||||||
|
openai: result.data.openai || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load models:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务配置
|
||||||
|
const serviceConfig = computed(() => {
|
||||||
|
const configs = {
|
||||||
|
claude: {
|
||||||
|
name: 'Claude',
|
||||||
|
endpoint: '/api-key/test',
|
||||||
|
defaultModel: 'claude-sonnet-4-5-20250929',
|
||||||
|
displayEndpoint: '/api/v1/messages'
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
name: 'Gemini',
|
||||||
|
endpoint: '/api-key/test-gemini',
|
||||||
|
defaultModel: 'gemini-2.5-pro',
|
||||||
|
displayEndpoint: '/gemini/v1/models/:model:streamGenerateContent'
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
name: 'OpenAI (Codex)',
|
||||||
|
endpoint: '/api-key/test-openai',
|
||||||
|
defaultModel: 'gpt-5',
|
||||||
|
displayEndpoint: '/openai/responses'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configs[props.serviceType] || configs.claude
|
||||||
|
})
|
||||||
|
|
||||||
|
// 可用模型列表(从 API 获取)
|
||||||
|
const availableModels = computed(() => {
|
||||||
|
return modelsFromApi.value[props.serviceType] || []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件挂载时加载模型
|
||||||
|
onMounted(() => {
|
||||||
|
loadModels()
|
||||||
|
})
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const displayName = computed(() => {
|
const displayName = computed(() => {
|
||||||
return props.apiKeyName || '当前 API Key'
|
return props.apiKeyName || '当前 API Key'
|
||||||
@@ -370,7 +488,7 @@ async function startTest() {
|
|||||||
|
|
||||||
// 使用公开的测试端点,不需要管理员认证
|
// 使用公开的测试端点,不需要管理员认证
|
||||||
// apiStats 路由挂载在 /apiStats 下
|
// apiStats 路由挂载在 /apiStats 下
|
||||||
const endpoint = `${API_PREFIX}/apiStats/api-key/test`
|
const endpoint = `${API_PREFIX}/apiStats${serviceConfig.value.endpoint}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用fetch发送POST请求并处理SSE
|
// 使用fetch发送POST请求并处理SSE
|
||||||
@@ -381,7 +499,9 @@ async function startTest() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
apiKey: props.apiKeyValue,
|
apiKey: props.apiKeyValue,
|
||||||
model: testModel.value
|
model: testModel.value,
|
||||||
|
prompt: testPrompt.value,
|
||||||
|
maxTokens: maxTokens.value
|
||||||
}),
|
}),
|
||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
@@ -483,10 +603,23 @@ watch(
|
|||||||
responseText.value = ''
|
responseText.value = ''
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
testDuration.value = 0
|
testDuration.value = 0
|
||||||
|
// 重置为当前服务的默认模型
|
||||||
|
testModel.value = serviceConfig.value.defaultModel
|
||||||
|
// 重置提示词和 maxTokens
|
||||||
|
testPrompt.value = 'hi'
|
||||||
|
maxTokens.value = 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听服务类型变化,重置模型
|
||||||
|
watch(
|
||||||
|
() => props.serviceType,
|
||||||
|
() => {
|
||||||
|
testModel.value = serviceConfig.value.defaultModel
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 组件卸载时清理
|
// 组件卸载时清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (abortController.value) {
|
if (abortController.value) {
|
||||||
|
|||||||
@@ -172,12 +172,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ConfirmModal -->
|
||||||
|
<ConfirmModal
|
||||||
|
:cancel-text="confirmModalConfig.cancelText"
|
||||||
|
:confirm-text="confirmModalConfig.confirmText"
|
||||||
|
:message="confirmModalConfig.message"
|
||||||
|
:show="showConfirmModal"
|
||||||
|
:title="confirmModalConfig.title"
|
||||||
|
:type="confirmModalConfig.type"
|
||||||
|
@cancel="handleCancelModal"
|
||||||
|
@confirm="handleConfirmModal"
|
||||||
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
apiKeys: {
|
apiKeys: {
|
||||||
@@ -190,6 +203,39 @@ const emit = defineEmits(['close'])
|
|||||||
|
|
||||||
const showPreview = ref(false)
|
const showPreview = ref(false)
|
||||||
|
|
||||||
|
// ConfirmModal 状态
|
||||||
|
const showConfirmModal = ref(false)
|
||||||
|
const confirmModalConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'primary',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
const confirmResolve = ref(null)
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
type = 'primary'
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||||
|
confirmResolve.value = resolve
|
||||||
|
showConfirmModal.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleConfirmModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(true)
|
||||||
|
}
|
||||||
|
const handleCancelModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取基础名称
|
// 获取基础名称
|
||||||
const baseName = computed(() => {
|
const baseName = computed(() => {
|
||||||
if (props.apiKeys.length > 0) {
|
if (props.apiKeys.length > 0) {
|
||||||
@@ -282,45 +328,29 @@ const downloadApiKeys = () => {
|
|||||||
|
|
||||||
// 关闭弹窗(带确认)
|
// 关闭弹窗(带确认)
|
||||||
const handleClose = async () => {
|
const handleClose = async () => {
|
||||||
if (window.showConfirm) {
|
const confirmed = await showConfirm(
|
||||||
const confirmed = await window.showConfirm(
|
'关闭提醒',
|
||||||
'关闭提醒',
|
'关闭后将无法再次查看这些 API Key,请确保已经下载并妥善保存。\n\n确定要关闭吗?',
|
||||||
'关闭后将无法再次查看这些 API Key,请确保已经下载并妥善保存。\n\n确定要关闭吗?',
|
'确定关闭',
|
||||||
'确定关闭',
|
'返回下载',
|
||||||
'返回下载'
|
'warning'
|
||||||
)
|
)
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
const confirmed = confirm(
|
|
||||||
'关闭后将无法再次查看这些 API Key,请确保已经下载并妥善保存。\n\n确定要关闭吗?'
|
|
||||||
)
|
|
||||||
if (confirmed) {
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接关闭(不带确认)
|
// 直接关闭(不带确认)
|
||||||
const handleDirectClose = async () => {
|
const handleDirectClose = async () => {
|
||||||
if (window.showConfirm) {
|
const confirmed = await showConfirm(
|
||||||
const confirmed = await window.showConfirm(
|
'确定要关闭吗?',
|
||||||
'确定要关闭吗?',
|
'您还没有下载 API Keys,关闭后将无法再次查看。\n\n强烈建议您先下载保存。',
|
||||||
'您还没有下载 API Keys,关闭后将无法再次查看。\n\n强烈建议您先下载保存。',
|
'仍然关闭',
|
||||||
'仍然关闭',
|
'返回下载',
|
||||||
'返回下载'
|
'warning'
|
||||||
)
|
)
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
const confirmed = confirm('您还没有下载 API Keys,关闭后将无法再次查看。\n\n确定要关闭吗?')
|
|
||||||
if (confirmed) {
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -446,9 +446,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import AccountSelector from '@/components/common/AccountSelector.vue'
|
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -588,15 +588,15 @@ const refreshAccounts = async () => {
|
|||||||
droidData,
|
droidData,
|
||||||
groupsData
|
groupsData
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
apiClient.get('/admin/claude-accounts'),
|
httpApi.get('/admin/claude-accounts'),
|
||||||
apiClient.get('/admin/claude-console-accounts'),
|
httpApi.get('/admin/claude-console-accounts'),
|
||||||
apiClient.get('/admin/gemini-accounts'),
|
httpApi.get('/admin/gemini-accounts'),
|
||||||
apiClient.get('/admin/gemini-api-accounts'), // 获取 Gemini-API 账号
|
httpApi.get('/admin/gemini-api-accounts'), // 获取 Gemini-API 账号
|
||||||
apiClient.get('/admin/openai-accounts'),
|
httpApi.get('/admin/openai-accounts'),
|
||||||
apiClient.get('/admin/openai-responses-accounts'),
|
httpApi.get('/admin/openai-responses-accounts'),
|
||||||
apiClient.get('/admin/bedrock-accounts'),
|
httpApi.get('/admin/bedrock-accounts'),
|
||||||
apiClient.get('/admin/droid-accounts'),
|
httpApi.get('/admin/droid-accounts'),
|
||||||
apiClient.get('/admin/account-groups')
|
httpApi.get('/admin/account-groups')
|
||||||
])
|
])
|
||||||
|
|
||||||
// 合并Claude OAuth账户和Claude Console账户
|
// 合并Claude OAuth账户和Claude Console账户
|
||||||
@@ -801,7 +801,7 @@ const batchUpdateApiKeys = async () => {
|
|||||||
updates.tagOperation = tagOperation.value
|
updates.tagOperation = tagOperation.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await apiClient.put('/admin/api-keys/batch', {
|
const result = await httpApi.put('/admin/api-keys/batch', {
|
||||||
keyIds: props.selectedKeys,
|
keyIds: props.selectedKeys,
|
||||||
updates
|
updates
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -579,55 +579,59 @@
|
|||||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>服务权限</label
|
>服务权限</label
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
:checked="form.permissions === 'all'"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="all"
|
@change="toggleAllServices"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">全部服务</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="selectedServices"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="claude"
|
value="claude"
|
||||||
|
@change="updatePermissions"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="selectedServices"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="gemini"
|
value="gemini"
|
||||||
|
@change="updatePermissions"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="selectedServices"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="openai"
|
value="openai"
|
||||||
|
@change="updatePermissions"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="selectedServices"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="droid"
|
value="droid"
|
||||||
|
@change="updatePermissions"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Droid</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
控制此 API Key 可以访问哪些服务
|
控制此 API Key 可以访问哪些服务,可多选
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -662,7 +666,7 @@
|
|||||||
v-model="form.claudeAccountId"
|
v-model="form.claudeAccountId"
|
||||||
:accounts="localAccounts.claude"
|
:accounts="localAccounts.claude"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
|
:disabled="!isServiceEnabled('claude')"
|
||||||
:groups="localAccounts.claudeGroups"
|
:groups="localAccounts.claudeGroups"
|
||||||
placeholder="请选择Claude账号"
|
placeholder="请选择Claude账号"
|
||||||
platform="claude"
|
platform="claude"
|
||||||
@@ -676,7 +680,7 @@
|
|||||||
v-model="form.geminiAccountId"
|
v-model="form.geminiAccountId"
|
||||||
:accounts="localAccounts.gemini"
|
:accounts="localAccounts.gemini"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
|
:disabled="!isServiceEnabled('gemini')"
|
||||||
:groups="localAccounts.geminiGroups"
|
:groups="localAccounts.geminiGroups"
|
||||||
placeholder="请选择Gemini账号"
|
placeholder="请选择Gemini账号"
|
||||||
platform="gemini"
|
platform="gemini"
|
||||||
@@ -690,7 +694,7 @@
|
|||||||
v-model="form.openaiAccountId"
|
v-model="form.openaiAccountId"
|
||||||
:accounts="localAccounts.openai"
|
:accounts="localAccounts.openai"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
:disabled="!isServiceEnabled('openai')"
|
||||||
:groups="localAccounts.openaiGroups"
|
:groups="localAccounts.openaiGroups"
|
||||||
placeholder="请选择OpenAI账号"
|
placeholder="请选择OpenAI账号"
|
||||||
platform="openai"
|
platform="openai"
|
||||||
@@ -704,7 +708,7 @@
|
|||||||
v-model="form.bedrockAccountId"
|
v-model="form.bedrockAccountId"
|
||||||
:accounts="localAccounts.bedrock"
|
:accounts="localAccounts.bedrock"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
:disabled="!isServiceEnabled('claude')"
|
||||||
:groups="[]"
|
:groups="[]"
|
||||||
placeholder="请选择Bedrock账号"
|
placeholder="请选择Bedrock账号"
|
||||||
platform="bedrock"
|
platform="bedrock"
|
||||||
@@ -718,7 +722,7 @@
|
|||||||
v-model="form.droidAccountId"
|
v-model="form.droidAccountId"
|
||||||
:accounts="localAccounts.droid"
|
:accounts="localAccounts.droid"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
|
:disabled="!isServiceEnabled('droid')"
|
||||||
:groups="localAccounts.droidGroups"
|
:groups="localAccounts.droidGroups"
|
||||||
placeholder="请选择Droid账号"
|
placeholder="请选择Droid账号"
|
||||||
platform="droid"
|
platform="droid"
|
||||||
@@ -884,16 +888,29 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ConfirmModal -->
|
||||||
|
<ConfirmModal
|
||||||
|
:cancel-text="confirmModalConfig.cancelText"
|
||||||
|
:confirm-text="confirmModalConfig.confirmText"
|
||||||
|
:message="confirmModalConfig.message"
|
||||||
|
:show="showConfirmModal"
|
||||||
|
:title="confirmModalConfig.title"
|
||||||
|
:type="confirmModalConfig.type"
|
||||||
|
@cancel="handleCancelModal"
|
||||||
|
@confirm="handleConfirmModal"
|
||||||
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { useClientsStore } from '@/stores/clients'
|
import { useClientsStore } from '@/stores/clients'
|
||||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import AccountSelector from '@/components/common/AccountSelector.vue'
|
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
accounts: {
|
accounts: {
|
||||||
@@ -918,6 +935,40 @@ const clientsStore = useClientsStore()
|
|||||||
const apiKeysStore = useApiKeysStore()
|
const apiKeysStore = useApiKeysStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const accountsLoading = ref(false)
|
const accountsLoading = ref(false)
|
||||||
|
|
||||||
|
// ConfirmModal 状态
|
||||||
|
const showConfirmModal = ref(false)
|
||||||
|
const confirmModalConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'primary',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
const confirmResolve = ref(null)
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
type = 'primary'
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||||
|
confirmResolve.value = resolve
|
||||||
|
showConfirmModal.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleConfirmModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(true)
|
||||||
|
}
|
||||||
|
const handleCancelModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
const localAccounts = ref({
|
const localAccounts = ref({
|
||||||
claude: [],
|
claude: [],
|
||||||
gemini: [],
|
gemini: [],
|
||||||
@@ -980,6 +1031,39 @@ const form = reactive({
|
|||||||
tags: []
|
tags: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 多选服务
|
||||||
|
const allServices = ['claude', 'gemini', 'openai', 'droid']
|
||||||
|
const selectedServices = ref([...allServices])
|
||||||
|
|
||||||
|
// 切换全部服务
|
||||||
|
const toggleAllServices = (event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
selectedServices.value = [...allServices]
|
||||||
|
form.permissions = 'all'
|
||||||
|
} else {
|
||||||
|
selectedServices.value = []
|
||||||
|
form.permissions = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新权限
|
||||||
|
const updatePermissions = () => {
|
||||||
|
if (selectedServices.value.length === allServices.length) {
|
||||||
|
form.permissions = 'all'
|
||||||
|
} else if (selectedServices.value.length === 1) {
|
||||||
|
form.permissions = selectedServices.value[0]
|
||||||
|
} else if (selectedServices.value.length > 1) {
|
||||||
|
form.permissions = selectedServices.value.join(',')
|
||||||
|
} else {
|
||||||
|
form.permissions = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查服务是否启用
|
||||||
|
const isServiceEnabled = (service) => {
|
||||||
|
return form.permissions === 'all' || selectedServices.value.includes(service)
|
||||||
|
}
|
||||||
|
|
||||||
// 加载支持的客户端和已存在的标签
|
// 加载支持的客户端和已存在的标签
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
supportedClients.value = await clientsStore.loadSupportedClients()
|
supportedClients.value = await clientsStore.loadSupportedClients()
|
||||||
@@ -1046,15 +1130,15 @@ const refreshAccounts = async () => {
|
|||||||
droidData,
|
droidData,
|
||||||
groupsData
|
groupsData
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
apiClient.get('/admin/claude-accounts'),
|
httpApi.get('/admin/claude-accounts'),
|
||||||
apiClient.get('/admin/claude-console-accounts'),
|
httpApi.get('/admin/claude-console-accounts'),
|
||||||
apiClient.get('/admin/gemini-accounts'),
|
httpApi.get('/admin/gemini-accounts'),
|
||||||
apiClient.get('/admin/gemini-api-accounts'), // 获取 Gemini-API 账号
|
httpApi.get('/admin/gemini-api-accounts'), // 获取 Gemini-API 账号
|
||||||
apiClient.get('/admin/openai-accounts'),
|
httpApi.get('/admin/openai-accounts'),
|
||||||
apiClient.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
httpApi.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
|
||||||
apiClient.get('/admin/bedrock-accounts'),
|
httpApi.get('/admin/bedrock-accounts'),
|
||||||
apiClient.get('/admin/droid-accounts'),
|
httpApi.get('/admin/droid-accounts'),
|
||||||
apiClient.get('/admin/account-groups')
|
httpApi.get('/admin/account-groups')
|
||||||
])
|
])
|
||||||
|
|
||||||
// 合并Claude OAuth账户和Claude Console账户
|
// 合并Claude OAuth账户和Claude Console账户
|
||||||
@@ -1331,18 +1415,13 @@ const createApiKey = async () => {
|
|||||||
|
|
||||||
// 检查是否设置了时间窗口但费用限制为0
|
// 检查是否设置了时间窗口但费用限制为0
|
||||||
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
|
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
|
||||||
let confirmed = false
|
const confirmed = await showConfirm(
|
||||||
if (window.showConfirm) {
|
'费用限制提醒',
|
||||||
confirmed = await window.showConfirm(
|
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||||
'费用限制提醒',
|
'继续创建',
|
||||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
'返回修改',
|
||||||
'继续创建',
|
'warning'
|
||||||
'返回修改'
|
)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?')
|
|
||||||
}
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1435,7 +1514,7 @@ const createApiKey = async () => {
|
|||||||
name: form.name
|
name: form.name
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await apiClient.post('/admin/api-keys', data)
|
const result = await httpApi.post('/admin/api-keys', data)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast('API Key 创建成功', 'success')
|
showToast('API Key 创建成功', 'success')
|
||||||
@@ -1453,7 +1532,7 @@ const createApiKey = async () => {
|
|||||||
count: form.batchCount
|
count: form.batchCount
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await apiClient.post('/admin/api-keys/batch', data)
|
const result = await httpApi.post('/admin/api-keys/batch', data)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast(`成功创建 ${result.data.length} 个 API Key`, 'success')
|
showToast(`成功创建 ${result.data.length} 个 API Key`, 'success')
|
||||||
|
|||||||
@@ -412,55 +412,59 @@
|
|||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>服务权限</label
|
>服务权限</label
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
:checked="form.permissions === 'all'"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="all"
|
@change="toggleAllServices"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">全部服务</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">全部服务</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="selectedServices"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="claude"
|
value="claude"
|
||||||
|
@change="updatePermissions"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Claude</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Claude</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="selectedServices"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="gemini"
|
value="gemini"
|
||||||
|
@change="updatePermissions"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Gemini</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Gemini</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="selectedServices"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="openai"
|
value="openai"
|
||||||
|
@change="updatePermissions"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 OpenAI</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">OpenAI</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center">
|
<label class="flex cursor-pointer items-center">
|
||||||
<input
|
<input
|
||||||
v-model="form.permissions"
|
v-model="selectedServices"
|
||||||
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
type="radio"
|
type="checkbox"
|
||||||
value="droid"
|
value="droid"
|
||||||
|
@change="updatePermissions"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">仅 Droid</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Droid</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
控制此 API Key 可以访问哪些服务
|
控制此 API Key 可以访问哪些服务,可多选
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -495,7 +499,7 @@
|
|||||||
v-model="form.claudeAccountId"
|
v-model="form.claudeAccountId"
|
||||||
:accounts="localAccounts.claude"
|
:accounts="localAccounts.claude"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'claude'"
|
:disabled="!isServiceEnabled('claude')"
|
||||||
:groups="localAccounts.claudeGroups"
|
:groups="localAccounts.claudeGroups"
|
||||||
placeholder="请选择Claude账号"
|
placeholder="请选择Claude账号"
|
||||||
platform="claude"
|
platform="claude"
|
||||||
@@ -509,7 +513,7 @@
|
|||||||
v-model="form.geminiAccountId"
|
v-model="form.geminiAccountId"
|
||||||
:accounts="localAccounts.gemini"
|
:accounts="localAccounts.gemini"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'gemini'"
|
:disabled="!isServiceEnabled('gemini')"
|
||||||
:groups="localAccounts.geminiGroups"
|
:groups="localAccounts.geminiGroups"
|
||||||
placeholder="请选择Gemini账号"
|
placeholder="请选择Gemini账号"
|
||||||
platform="gemini"
|
platform="gemini"
|
||||||
@@ -523,7 +527,7 @@
|
|||||||
v-model="form.openaiAccountId"
|
v-model="form.openaiAccountId"
|
||||||
:accounts="localAccounts.openai"
|
:accounts="localAccounts.openai"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
:disabled="!isServiceEnabled('openai')"
|
||||||
:groups="localAccounts.openaiGroups"
|
:groups="localAccounts.openaiGroups"
|
||||||
placeholder="请选择OpenAI账号"
|
placeholder="请选择OpenAI账号"
|
||||||
platform="openai"
|
platform="openai"
|
||||||
@@ -537,7 +541,7 @@
|
|||||||
v-model="form.bedrockAccountId"
|
v-model="form.bedrockAccountId"
|
||||||
:accounts="localAccounts.bedrock"
|
:accounts="localAccounts.bedrock"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'openai'"
|
:disabled="!isServiceEnabled('claude')"
|
||||||
:groups="[]"
|
:groups="[]"
|
||||||
placeholder="请选择Bedrock账号"
|
placeholder="请选择Bedrock账号"
|
||||||
platform="bedrock"
|
platform="bedrock"
|
||||||
@@ -551,7 +555,7 @@
|
|||||||
v-model="form.droidAccountId"
|
v-model="form.droidAccountId"
|
||||||
:accounts="localAccounts.droid"
|
:accounts="localAccounts.droid"
|
||||||
default-option-text="使用共享账号池"
|
default-option-text="使用共享账号池"
|
||||||
:disabled="form.permissions !== 'all' && form.permissions !== 'droid'"
|
:disabled="!isServiceEnabled('droid')"
|
||||||
:groups="localAccounts.droidGroups"
|
:groups="localAccounts.droidGroups"
|
||||||
placeholder="请选择Droid账号"
|
placeholder="请选择Droid账号"
|
||||||
platform="droid"
|
platform="droid"
|
||||||
@@ -722,16 +726,29 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ConfirmModal -->
|
||||||
|
<ConfirmModal
|
||||||
|
:cancel-text="confirmModalConfig.cancelText"
|
||||||
|
:confirm-text="confirmModalConfig.confirmText"
|
||||||
|
:message="confirmModalConfig.message"
|
||||||
|
:show="showConfirmModal"
|
||||||
|
:title="confirmModalConfig.title"
|
||||||
|
:type="confirmModalConfig.type"
|
||||||
|
@cancel="handleCancelModal"
|
||||||
|
@confirm="handleConfirmModal"
|
||||||
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { useClientsStore } from '@/stores/clients'
|
import { useClientsStore } from '@/stores/clients'
|
||||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import AccountSelector from '@/components/common/AccountSelector.vue'
|
import AccountSelector from '@/components/common/AccountSelector.vue'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
apiKey: {
|
apiKey: {
|
||||||
@@ -762,6 +779,40 @@ const clientsStore = useClientsStore()
|
|||||||
const apiKeysStore = useApiKeysStore()
|
const apiKeysStore = useApiKeysStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const accountsLoading = ref(false)
|
const accountsLoading = ref(false)
|
||||||
|
|
||||||
|
// ConfirmModal 状态
|
||||||
|
const showConfirmModal = ref(false)
|
||||||
|
const confirmModalConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'primary',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
const confirmResolve = ref(null)
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
type = 'primary'
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||||
|
confirmResolve.value = resolve
|
||||||
|
showConfirmModal.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleConfirmModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(true)
|
||||||
|
}
|
||||||
|
const handleCancelModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
const localAccounts = ref({
|
const localAccounts = ref({
|
||||||
claude: [],
|
claude: [],
|
||||||
gemini: [],
|
gemini: [],
|
||||||
@@ -816,6 +867,50 @@ const form = reactive({
|
|||||||
ownerId: '' // 新增:所有者ID
|
ownerId: '' // 新增:所有者ID
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 多选服务
|
||||||
|
const allServices = ['claude', 'gemini', 'openai', 'droid']
|
||||||
|
const selectedServices = ref([...allServices])
|
||||||
|
|
||||||
|
// 切换全部服务
|
||||||
|
const toggleAllServices = (event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
selectedServices.value = [...allServices]
|
||||||
|
form.permissions = 'all'
|
||||||
|
} else {
|
||||||
|
selectedServices.value = []
|
||||||
|
form.permissions = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新权限
|
||||||
|
const updatePermissions = () => {
|
||||||
|
if (selectedServices.value.length === allServices.length) {
|
||||||
|
form.permissions = 'all'
|
||||||
|
} else if (selectedServices.value.length === 1) {
|
||||||
|
form.permissions = selectedServices.value[0]
|
||||||
|
} else if (selectedServices.value.length > 1) {
|
||||||
|
form.permissions = selectedServices.value.join(',')
|
||||||
|
} else {
|
||||||
|
form.permissions = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查服务是否启用
|
||||||
|
const isServiceEnabled = (service) => {
|
||||||
|
return form.permissions === 'all' || selectedServices.value.includes(service)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 permissions 初始化 selectedServices
|
||||||
|
const initSelectedServices = (permissions) => {
|
||||||
|
if (permissions === 'all') {
|
||||||
|
selectedServices.value = [...allServices]
|
||||||
|
} else if (permissions) {
|
||||||
|
selectedServices.value = permissions.split(',').filter((s) => allServices.includes(s))
|
||||||
|
} else {
|
||||||
|
selectedServices.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加限制的模型
|
// 添加限制的模型
|
||||||
const addRestrictedModel = () => {
|
const addRestrictedModel = () => {
|
||||||
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
|
if (form.modelInput && !form.restrictedModels.includes(form.modelInput)) {
|
||||||
@@ -869,18 +964,13 @@ const removeTag = (index) => {
|
|||||||
const updateApiKey = async () => {
|
const updateApiKey = async () => {
|
||||||
// 检查是否设置了时间窗口但费用限制为0
|
// 检查是否设置了时间窗口但费用限制为0
|
||||||
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
|
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
|
||||||
let confirmed = false
|
const confirmed = await showConfirm(
|
||||||
if (window.showConfirm) {
|
'费用限制提醒',
|
||||||
confirmed = await window.showConfirm(
|
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
||||||
'费用限制提醒',
|
'继续保存',
|
||||||
'您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n\n是否继续?',
|
'返回修改',
|
||||||
'继续保存',
|
'warning'
|
||||||
'返回修改'
|
)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
confirmed = confirm('您设置了时间窗口但费用限制为0,这意味着不会有费用限制。\n是否继续?')
|
|
||||||
}
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -989,7 +1079,7 @@ const updateApiKey = async () => {
|
|||||||
data.ownerId = form.ownerId
|
data.ownerId = form.ownerId
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
const result = await httpApi.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
emit('success')
|
emit('success')
|
||||||
@@ -1019,15 +1109,15 @@ const refreshAccounts = async () => {
|
|||||||
droidData,
|
droidData,
|
||||||
groupsData
|
groupsData
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
apiClient.get('/admin/claude-accounts'),
|
httpApi.get('/admin/claude-accounts'),
|
||||||
apiClient.get('/admin/claude-console-accounts'),
|
httpApi.get('/admin/claude-console-accounts'),
|
||||||
apiClient.get('/admin/gemini-accounts'),
|
httpApi.get('/admin/gemini-accounts'),
|
||||||
apiClient.get('/admin/gemini-api-accounts'),
|
httpApi.get('/admin/gemini-api-accounts'),
|
||||||
apiClient.get('/admin/openai-accounts'),
|
httpApi.get('/admin/openai-accounts'),
|
||||||
apiClient.get('/admin/openai-responses-accounts'),
|
httpApi.get('/admin/openai-responses-accounts'),
|
||||||
apiClient.get('/admin/bedrock-accounts'),
|
httpApi.get('/admin/bedrock-accounts'),
|
||||||
apiClient.get('/admin/droid-accounts'),
|
httpApi.get('/admin/droid-accounts'),
|
||||||
apiClient.get('/admin/account-groups')
|
httpApi.get('/admin/account-groups')
|
||||||
])
|
])
|
||||||
|
|
||||||
// 合并Claude OAuth账户和Claude Console账户
|
// 合并Claude OAuth账户和Claude Console账户
|
||||||
@@ -1140,7 +1230,7 @@ const refreshAccounts = async () => {
|
|||||||
// 加载用户列表
|
// 加载用户列表
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/users')
|
const response = await httpApi.get('/admin/users')
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
availableUsers.value = response.data || []
|
availableUsers.value = response.data || []
|
||||||
}
|
}
|
||||||
@@ -1242,6 +1332,7 @@ onMounted(async () => {
|
|||||||
form.totalCostLimit = props.apiKey.totalCostLimit || ''
|
form.totalCostLimit = props.apiKey.totalCostLimit || ''
|
||||||
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
||||||
form.permissions = props.apiKey.permissions || 'all'
|
form.permissions = props.apiKey.permissions || 'all'
|
||||||
|
initSelectedServices(form.permissions)
|
||||||
// 处理 Claude 账号(区分 OAuth 和 Console)
|
// 处理 Claude 账号(区分 OAuth 和 Console)
|
||||||
if (props.apiKey.claudeConsoleAccountId) {
|
if (props.apiKey.claudeConsoleAccountId) {
|
||||||
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`
|
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`
|
||||||
|
|||||||
@@ -211,11 +211,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ConfirmModal -->
|
||||||
|
<ConfirmModal
|
||||||
|
:cancel-text="confirmModalConfig.cancelText"
|
||||||
|
:confirm-text="confirmModalConfig.confirmText"
|
||||||
|
:message="confirmModalConfig.message"
|
||||||
|
:show="showConfirmModal"
|
||||||
|
:title="confirmModalConfig.title"
|
||||||
|
:type="confirmModalConfig.type"
|
||||||
|
@cancel="handleCancelModal"
|
||||||
|
@confirm="handleConfirmModal"
|
||||||
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, watch } from 'vue'
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -232,6 +245,39 @@ const emit = defineEmits(['close', 'save'])
|
|||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// ConfirmModal 状态
|
||||||
|
const showConfirmModal = ref(false)
|
||||||
|
const confirmModalConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'primary',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
const confirmResolve = ref(null)
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
type = 'primary'
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||||
|
confirmResolve.value = resolve
|
||||||
|
showConfirmModal.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleConfirmModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(true)
|
||||||
|
}
|
||||||
|
const handleCancelModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const localForm = reactive({
|
const localForm = reactive({
|
||||||
expireDuration: '',
|
expireDuration: '',
|
||||||
@@ -401,21 +447,13 @@ const handleSave = () => {
|
|||||||
|
|
||||||
// 立即激活
|
// 立即激活
|
||||||
const handleActivateNow = async () => {
|
const handleActivateNow = async () => {
|
||||||
// 使用确认弹窗
|
const confirmed = await showConfirm(
|
||||||
let confirmed = true
|
'激活 API Key',
|
||||||
if (window.showConfirm) {
|
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`,
|
||||||
confirmed = await window.showConfirm(
|
'确定激活',
|
||||||
'激活 API Key',
|
'取消',
|
||||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`,
|
'warning'
|
||||||
'确定激活',
|
)
|
||||||
'取消'
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
confirmed = confirm(
|
|
||||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -126,12 +126,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ConfirmModal -->
|
||||||
|
<ConfirmModal
|
||||||
|
:cancel-text="confirmModalConfig.cancelText"
|
||||||
|
:confirm-text="confirmModalConfig.confirmText"
|
||||||
|
:message="confirmModalConfig.message"
|
||||||
|
:show="showConfirmModal"
|
||||||
|
:title="confirmModalConfig.title"
|
||||||
|
:type="confirmModalConfig.type"
|
||||||
|
@cancel="handleCancelModal"
|
||||||
|
@confirm="handleConfirmModal"
|
||||||
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
apiKey: {
|
apiKey: {
|
||||||
@@ -144,6 +157,39 @@ const emit = defineEmits(['close'])
|
|||||||
|
|
||||||
const showFullKey = ref(false)
|
const showFullKey = ref(false)
|
||||||
|
|
||||||
|
// ConfirmModal 状态
|
||||||
|
const showConfirmModal = ref(false)
|
||||||
|
const confirmModalConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'primary',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
const confirmResolve = ref(null)
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
type = 'primary'
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||||
|
confirmResolve.value = resolve
|
||||||
|
showConfirmModal.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleConfirmModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(true)
|
||||||
|
}
|
||||||
|
const handleCancelModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取 API Base URL 前缀
|
// 获取 API Base URL 前缀
|
||||||
const getBaseUrlPrefix = () => {
|
const getBaseUrlPrefix = () => {
|
||||||
// 优先使用环境变量配置的自定义前缀
|
// 优先使用环境变量配置的自定义前缀
|
||||||
@@ -249,45 +295,29 @@ const copyKeyOnly = async () => {
|
|||||||
|
|
||||||
// 关闭弹窗(带确认)
|
// 关闭弹窗(带确认)
|
||||||
const handleClose = async () => {
|
const handleClose = async () => {
|
||||||
if (window.showConfirm) {
|
const confirmed = await showConfirm(
|
||||||
const confirmed = await window.showConfirm(
|
'关闭提醒',
|
||||||
'关闭提醒',
|
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?',
|
||||||
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?',
|
'确定关闭',
|
||||||
'确定关闭',
|
'取消',
|
||||||
'取消'
|
'warning'
|
||||||
)
|
)
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
const confirmed = confirm(
|
|
||||||
'关闭后将无法再次查看完整的API Key,请确保已经妥善保存。\n\n确定要关闭吗?'
|
|
||||||
)
|
|
||||||
if (confirmed) {
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接关闭(不带确认)
|
// 直接关闭(不带确认)
|
||||||
const handleDirectClose = async () => {
|
const handleDirectClose = async () => {
|
||||||
if (window.showConfirm) {
|
const confirmed = await showConfirm(
|
||||||
const confirmed = await window.showConfirm(
|
'确定要关闭吗?',
|
||||||
'确定要关闭吗?',
|
'您还没有保存API Key,关闭后将无法再次查看。\n\n建议您先复制API Key再关闭。',
|
||||||
'您还没有保存API Key,关闭后将无法再次查看。\n\n建议您先复制API Key再关闭。',
|
'仍然关闭',
|
||||||
'仍然关闭',
|
'返回复制',
|
||||||
'返回复制'
|
'warning'
|
||||||
)
|
)
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
const confirmed = confirm('您还没有保存API Key,关闭后将无法再次查看。\n\n确定要关闭吗?')
|
|
||||||
if (confirmed) {
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/tools'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
|
|||||||
@@ -97,8 +97,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
apiKey: {
|
apiKey: {
|
||||||
@@ -206,7 +206,7 @@ const renewApiKey = async () => {
|
|||||||
expiresAt: form.renewDuration === 'permanent' ? null : form.newExpiresAt
|
expiresAt: form.renewDuration === 'permanent' ? null : form.newExpiresAt
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await apiClient.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
const result = await httpApi.put(`/admin/api-keys/${props.apiKey.id}`, data)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast('API Key 续期成功', 'success')
|
showToast('API Key 续期成功', 'success')
|
||||||
|
|||||||
@@ -262,11 +262,11 @@ const hasValidInput = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .wide-card-input:focus {
|
:global(.dark) .wide-card-input:focus {
|
||||||
border-color: #60a5fa;
|
border-color: var(--primary-color);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 3px rgba(96, 165, 250, 0.15),
|
0 0 0 3px rgba(var(--primary-rgb), 0.15),
|
||||||
0 10px 15px -3px rgba(0, 0, 0, 0.4);
|
0 10px 15px -3px rgba(0, 0, 0, 0.4);
|
||||||
background: rgba(31, 41, 55, 0.95);
|
background: var(--glass-strong-color);
|
||||||
color: #f3f4f6;
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,18 +289,18 @@ const hasValidInput = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 20px 25px -5px rgba(102, 126, 234, 0.3),
|
0 20px 25px -5px rgba(var(--primary-rgb), 0.3),
|
||||||
0 10px 10px -5px rgba(102, 126, 234, 0.1);
|
0 10px 10px -5px rgba(var(--primary-rgb), 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn-primary:disabled {
|
||||||
@@ -322,8 +322,8 @@ const hasValidInput = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .security-notice {
|
:global(.dark) .security-notice {
|
||||||
background: rgba(31, 41, 55, 0.8) !important;
|
background: var(--glass-strong-color) !important;
|
||||||
border: 1px solid rgba(75, 85, 99, 0.5) !important;
|
border: 1px solid var(--border-color) !important;
|
||||||
color: #d1d5db !important;
|
color: #d1d5db !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,8 +334,8 @@ const hasValidInput = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .security-notice:hover {
|
:global(.dark) .security-notice:hover {
|
||||||
background: rgba(31, 41, 55, 0.9) !important;
|
background: var(--glass-strong-color) !important;
|
||||||
border-color: rgba(75, 85, 99, 0.6) !important;
|
border-color: var(--border-color) !important;
|
||||||
color: #e5e7eb !important;
|
color: #e5e7eb !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +371,7 @@ const hasValidInput = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .mode-switch-group {
|
:global(.dark) .mode-switch-group {
|
||||||
background: #1f2937;
|
background: var(--bg-gradient-start);
|
||||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +392,7 @@ const hasValidInput = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .mode-switch-btn {
|
:global(.dark) .mode-switch-btn {
|
||||||
color: #9ca3af;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-switch-btn:hover:not(.active) {
|
.mode-switch-btn:hover:not(.active) {
|
||||||
@@ -407,12 +407,12 @@ const hasValidInput = computed(() => {
|
|||||||
|
|
||||||
.mode-switch-btn.active {
|
.mode-switch-btn.active {
|
||||||
color: white;
|
color: white;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
|
box-shadow: 0 2px 4px rgba(var(--primary-rgb), 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-switch-btn.active:hover {
|
.mode-switch-btn.active:hover {
|
||||||
box-shadow: 0 4px 6px rgba(102, 126, 234, 0.3);
|
box-shadow: 0 4px 6px rgba(var(--primary-rgb), 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-switch-btn i {
|
.mode-switch-btn i {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-4 md:gap-6">
|
<div class="flex h-full flex-col gap-3 sm:gap-4 md:gap-6">
|
||||||
<!-- 限制配置 / 聚合模式提示 -->
|
<!-- 限制配置 / 聚合模式提示 -->
|
||||||
<div class="card flex h-full flex-col p-4 md:p-6">
|
<div class="card flex h-full flex-col p-3 sm:p-4 md:p-6">
|
||||||
<h3
|
<h3
|
||||||
class="mb-3 flex items-center text-lg font-bold text-gray-900 dark:text-gray-100 md:mb-4 md:text-xl"
|
class="mb-2 flex items-center text-base font-bold text-gray-900 dark:text-gray-100 sm:mb-3 sm:text-lg md:mb-4 md:text-xl"
|
||||||
>
|
>
|
||||||
<i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" />
|
<i class="fas fa-shield-alt mr-2 text-sm text-red-500 md:mr-3 md:text-base" />
|
||||||
{{ multiKeyMode ? '限制配置(聚合查询模式)' : '限制配置' }}
|
{{ multiKeyMode ? '限制配置(聚合查询模式)' : '限制配置' }}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card p-4 md:p-6">
|
<div class="card p-3 sm:p-4 md:p-6">
|
||||||
<div class="mb-4 md:mb-6">
|
<div class="mb-2 sm:mb-3 md:mb-4">
|
||||||
<h3
|
<h3
|
||||||
class="flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:text-xl"
|
class="flex flex-col text-base font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center sm:text-lg md:text-xl"
|
||||||
>
|
>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" />
|
<i class="fas fa-robot mr-2 text-sm text-indigo-500 md:mr-3 md:text-base" />
|
||||||
模型使用统计
|
模型使用统计
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 sm:ml-2 md:text-sm"
|
||||||
>({{ statsPeriod === 'daily' ? '今日' : '本月' }})</span
|
>({{ periodLabel }})</span
|
||||||
>
|
>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型统计加载状态 -->
|
<!-- 模型统计加载状态 -->
|
||||||
<div v-if="modelStatsLoading" class="py-6 text-center md:py-8">
|
<div v-if="loading" class="py-6 text-center md:py-8">
|
||||||
<i
|
<i
|
||||||
class="fas fa-spinner loading-spinner mb-2 text-xl text-gray-600 dark:text-gray-400 md:text-2xl"
|
class="fas fa-spinner loading-spinner mb-2 text-xl text-gray-600 dark:text-gray-400 md:text-2xl"
|
||||||
/>
|
/>
|
||||||
@@ -23,49 +23,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型统计数据 -->
|
<!-- 模型统计数据 -->
|
||||||
<div v-else-if="modelStats.length > 0" class="space-y-3 md:space-y-4">
|
<div v-else-if="stats.length > 0" class="space-y-2">
|
||||||
<div v-for="(model, index) in modelStats" :key="index" class="model-usage-item">
|
<div v-for="(model, index) in stats" :key="index" class="model-usage-item">
|
||||||
<div class="mb-2 flex items-start justify-between md:mb-3">
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<h4 class="break-all text-base font-bold text-gray-900 dark:text-gray-100 md:text-lg">
|
<h4
|
||||||
|
class="cursor-pointer text-sm font-bold text-gray-900 hover:text-indigo-600 dark:text-gray-100 dark:hover:text-indigo-400"
|
||||||
|
title="点击复制"
|
||||||
|
@click="copyModelName(model.model)"
|
||||||
|
>
|
||||||
{{ model.model }}
|
{{ model.model }}
|
||||||
|
<i class="fas fa-copy ml-1 text-xs text-gray-400" />
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">
|
<div class="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{{ model.requests }} 次请求
|
<span>{{ model.requests }}次</span>
|
||||||
</p>
|
<span>输入:{{ formatNumber(model.inputTokens) }}</span>
|
||||||
</div>
|
<span>输出:{{ formatNumber(model.outputTokens) }}</span>
|
||||||
<div class="ml-3 flex-shrink-0 text-right">
|
<span v-if="model.cacheCreateTokens"
|
||||||
<div class="text-base font-bold text-green-600 md:text-lg">
|
>缓存创建:{{ formatNumber(model.cacheCreateTokens) }}</span
|
||||||
{{ model.formatted?.total || '$0.000000' }}
|
>
|
||||||
</div>
|
<span v-if="model.cacheReadTokens"
|
||||||
<div class="text-xs text-gray-600 dark:text-gray-400 md:text-sm">总费用</div>
|
>缓存读取:{{ formatNumber(model.cacheReadTokens) }}</span
|
||||||
</div>
|
>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-2 text-xs md:grid-cols-4 md:gap-3 md:text-sm">
|
|
||||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
|
||||||
<div class="text-gray-600 dark:text-gray-400">输入 Token</div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{{ formatNumber(model.inputTokens) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
<div class="flex-shrink-0 text-xs sm:text-sm">
|
||||||
<div class="text-gray-600 dark:text-gray-400">输出 Token</div>
|
<span class="text-gray-500">官方API</span>
|
||||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
<span class="ml-1 font-semibold text-green-600">
|
||||||
{{ formatNumber(model.outputTokens) }}
|
{{ model.formatted?.total || '$0.00' }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<template v-if="serviceRates?.rates">
|
||||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
<span class="ml-2 text-gray-500">折合CC</span>
|
||||||
<div class="text-gray-600 dark:text-gray-400">缓存创建</div>
|
<span class="ml-1 font-semibold text-amber-600 dark:text-amber-400">
|
||||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
{{ calculateCcCost(model) }}
|
||||||
{{ formatNumber(model.cacheCreateTokens) }}
|
</span>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
<div class="rounded bg-gray-50 p-2 dark:bg-gray-700">
|
|
||||||
<div class="text-gray-600 dark:text-gray-400">缓存读取</div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{{ formatNumber(model.cacheReadTokens) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,19 +66,74 @@
|
|||||||
<!-- 无模型数据 -->
|
<!-- 无模型数据 -->
|
||||||
<div v-else class="py-6 text-center text-gray-500 dark:text-gray-400 md:py-8">
|
<div v-else class="py-6 text-center text-gray-500 dark:text-gray-400 md:py-8">
|
||||||
<i class="fas fa-chart-pie mb-3 text-2xl md:text-3xl" />
|
<i class="fas fa-chart-pie mb-3 text-2xl md:text-3xl" />
|
||||||
<p class="text-sm md:text-base">
|
<p class="text-sm md:text-base">暂无{{ periodLabel }}模型使用数据</p>
|
||||||
暂无{{ statsPeriod === 'daily' ? '今日' : '本月' }}模型使用数据
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useApiStatsStore } from '@/stores/apistats'
|
import { useApiStatsStore } from '@/stores/apistats'
|
||||||
|
import { copyText } from '@/utils/tools'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
period: {
|
||||||
|
type: String,
|
||||||
|
default: 'daily',
|
||||||
|
validator: (value) => ['daily', 'monthly', 'alltime'].includes(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const apiStatsStore = useApiStatsStore()
|
const apiStatsStore = useApiStatsStore()
|
||||||
const { statsPeriod, modelStats, modelStatsLoading } = storeToRefs(apiStatsStore)
|
const { dailyModelStats, monthlyModelStats, alltimeModelStats, modelStatsLoading, serviceRates } =
|
||||||
|
storeToRefs(apiStatsStore)
|
||||||
|
|
||||||
|
// 根据 period 选择对应的数据
|
||||||
|
const stats = computed(() => {
|
||||||
|
if (props.period === 'daily') return dailyModelStats.value
|
||||||
|
if (props.period === 'monthly') return monthlyModelStats.value
|
||||||
|
if (props.period === 'alltime') return alltimeModelStats.value
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = computed(() => modelStatsLoading.value)
|
||||||
|
|
||||||
|
const periodLabel = computed(() => {
|
||||||
|
if (props.period === 'daily') return '今日'
|
||||||
|
if (props.period === 'monthly') return '本月'
|
||||||
|
if (props.period === 'alltime') return '所有时间'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 复制模型名称
|
||||||
|
const copyModelName = (name) => copyText(name, '模型名称已复制')
|
||||||
|
|
||||||
|
// 根据模型名称判断服务类型
|
||||||
|
const getServiceFromModel = (model) => {
|
||||||
|
if (!model) return 'claude'
|
||||||
|
const m = model.toLowerCase()
|
||||||
|
if (m.includes('claude') || m.includes('sonnet') || m.includes('opus') || m.includes('haiku'))
|
||||||
|
return 'claude'
|
||||||
|
if (m.includes('gpt') || m.includes('o1') || m.includes('o3') || m.includes('o4')) return 'codex'
|
||||||
|
if (m.includes('gemini')) return 'gemini'
|
||||||
|
if (m.includes('droid') || m.includes('factory')) return 'droid'
|
||||||
|
if (m.includes('bedrock') || m.includes('amazon')) return 'bedrock'
|
||||||
|
if (m.includes('azure')) return 'azure'
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 CC 扣费
|
||||||
|
const calculateCcCost = (model) => {
|
||||||
|
const cost = model.costs?.total || 0
|
||||||
|
if (!cost || !serviceRates.value?.rates) return '$0.00'
|
||||||
|
const service = getServiceFromModel(model.model)
|
||||||
|
const rate = serviceRates.value.rates[service] || 1.0
|
||||||
|
const ccCost = cost * rate
|
||||||
|
if (ccCost >= 1) return '$' + ccCost.toFixed(2)
|
||||||
|
if (ccCost >= 0.01) return '$' + ccCost.toFixed(4)
|
||||||
|
return '$' + ccCost.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化数字
|
// 格式化数字
|
||||||
const formatNumber = (num) => {
|
const formatNumber = (num) => {
|
||||||
|
|||||||
256
web/admin-spa/src/components/apistats/ServiceCostCards.vue
Normal file
256
web/admin-spa/src/components/apistats/ServiceCostCards.vue
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="serviceRates && modelStats.length > 0" class="card p-3 sm:p-4 md:p-6">
|
||||||
|
<h3
|
||||||
|
class="mb-2 flex items-center justify-between text-base font-bold text-gray-900 dark:text-gray-100 sm:mb-3 sm:text-lg md:mb-4 md:text-xl"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<i class="fas fa-coins mr-2 text-sm text-amber-500 md:mr-3 md:text-base" />
|
||||||
|
服务费用统计
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||||
|
CC 倍率基准: Claude = 1.0
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div
|
||||||
|
v-for="service in serviceStats"
|
||||||
|
:key="service.name"
|
||||||
|
class="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<!-- 服务名和倍率 -->
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ service.label }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
||||||
|
>
|
||||||
|
{{ service.rate }}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token 详情 -->
|
||||||
|
<div class="mb-2 space-y-0.5 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>输入</span>
|
||||||
|
<span class="text-gray-900 dark:text-gray-200">{{
|
||||||
|
formatNumber(service.inputTokens)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>输出</span>
|
||||||
|
<span class="text-gray-900 dark:text-gray-200">{{
|
||||||
|
formatNumber(service.outputTokens)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="service.cacheCreateTokens" class="flex justify-between">
|
||||||
|
<span>缓存创建</span>
|
||||||
|
<span class="text-gray-900 dark:text-gray-200">{{
|
||||||
|
formatNumber(service.cacheCreateTokens)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="service.cacheReadTokens" class="flex justify-between">
|
||||||
|
<span>缓存读取</span>
|
||||||
|
<span class="text-gray-900 dark:text-gray-200">{{
|
||||||
|
formatNumber(service.cacheReadTokens)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 费用 -->
|
||||||
|
<div class="mb-2 space-y-0.5 border-t border-gray-200 pt-2 text-xs dark:border-gray-700">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">官方API</span>
|
||||||
|
<span class="font-semibold text-green-600 dark:text-green-400">
|
||||||
|
{{ service.officialCost }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">折合CC</span>
|
||||||
|
<span class="font-semibold text-amber-600 dark:text-amber-400">
|
||||||
|
{{ service.ccCost }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 价格参考 -->
|
||||||
|
<div
|
||||||
|
v-if="service.pricing"
|
||||||
|
class="space-y-0.5 border-t border-gray-200 pt-2 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>输入</span>
|
||||||
|
<span>{{ service.pricing.input }}/M</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>输出</span>
|
||||||
|
<span>{{ service.pricing.output }}/M</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="service.pricing.cacheCreate" class="flex justify-between">
|
||||||
|
<span>缓存创建</span>
|
||||||
|
<span>{{ service.pricing.cacheCreate }}/M</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="service.pricing.cacheRead" class="flex justify-between">
|
||||||
|
<span>缓存读取</span>
|
||||||
|
<span>{{ service.pricing.cacheRead }}/M</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useApiStatsStore } from '@/stores/apistats'
|
||||||
|
|
||||||
|
const apiStatsStore = useApiStatsStore()
|
||||||
|
const { modelStats, serviceRates } = storeToRefs(apiStatsStore)
|
||||||
|
|
||||||
|
// 服务标签映射
|
||||||
|
const serviceLabels = {
|
||||||
|
claude: 'Claude',
|
||||||
|
codex: 'Codex',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
droid: 'Droid',
|
||||||
|
bedrock: 'Bedrock',
|
||||||
|
azure: 'Azure',
|
||||||
|
ccr: 'CCR'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据模型名称判断服务类型
|
||||||
|
const getServiceFromModel = (model) => {
|
||||||
|
if (!model) return 'claude'
|
||||||
|
const m = model.toLowerCase()
|
||||||
|
if (m.includes('claude') || m.includes('sonnet') || m.includes('opus') || m.includes('haiku'))
|
||||||
|
return 'claude'
|
||||||
|
if (m.includes('gpt') || m.includes('o1') || m.includes('o3') || m.includes('o4')) return 'codex'
|
||||||
|
if (m.includes('gemini')) return 'gemini'
|
||||||
|
if (m.includes('droid') || m.includes('factory')) return 'droid'
|
||||||
|
if (m.includes('bedrock') || m.includes('amazon')) return 'bedrock'
|
||||||
|
if (m.includes('azure')) return 'azure'
|
||||||
|
return 'claude'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按服务聚合统计
|
||||||
|
const serviceStats = computed(() => {
|
||||||
|
if (!serviceRates.value?.rates || !modelStats.value?.length) return []
|
||||||
|
|
||||||
|
const stats = {}
|
||||||
|
|
||||||
|
// 初始化所有服务
|
||||||
|
Object.keys(serviceRates.value.rates).forEach((service) => {
|
||||||
|
stats[service] = {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheCreateTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cost: 0,
|
||||||
|
pricing: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 聚合模型数据
|
||||||
|
modelStats.value.forEach((model) => {
|
||||||
|
const service = getServiceFromModel(model.model)
|
||||||
|
if (stats[service]) {
|
||||||
|
stats[service].inputTokens += model.inputTokens || 0
|
||||||
|
stats[service].outputTokens += model.outputTokens || 0
|
||||||
|
stats[service].cacheCreateTokens += model.cacheCreateTokens || 0
|
||||||
|
stats[service].cacheReadTokens += model.cacheReadTokens || 0
|
||||||
|
stats[service].cost += model.costs?.total || 0
|
||||||
|
if (!stats[service].pricing && model.pricing) {
|
||||||
|
stats[service].pricing = model.pricing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 转换为数组并计算 CC 费用
|
||||||
|
return Object.entries(stats)
|
||||||
|
.filter(
|
||||||
|
([, data]) =>
|
||||||
|
data.inputTokens > 0 || data.outputTokens > 0 || data.cacheCreateTokens > 0 || data.cost > 0
|
||||||
|
)
|
||||||
|
.map(([service, data]) => {
|
||||||
|
const rate = serviceRates.value.rates[service] || 1.0
|
||||||
|
const ccCostValue = data.cost * rate
|
||||||
|
const p = data.pricing
|
||||||
|
return {
|
||||||
|
name: service,
|
||||||
|
label: serviceLabels[service] || service,
|
||||||
|
rate: rate,
|
||||||
|
inputTokens: data.inputTokens,
|
||||||
|
outputTokens: data.outputTokens,
|
||||||
|
cacheCreateTokens: data.cacheCreateTokens,
|
||||||
|
cacheReadTokens: data.cacheReadTokens,
|
||||||
|
officialCost: formatCost(data.cost),
|
||||||
|
ccCost: formatCost(ccCostValue),
|
||||||
|
pricing: p
|
||||||
|
? {
|
||||||
|
input: formatCost(p.input * 1e6),
|
||||||
|
output: formatCost(p.output * 1e6),
|
||||||
|
cacheCreate: p.cacheCreate ? formatCost(p.cacheCreate * 1e6) : null,
|
||||||
|
cacheRead: p.cacheRead ? formatCost(p.cacheRead * 1e6) : null
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化费用
|
||||||
|
const formatCost = (cost) => {
|
||||||
|
if (!cost || cost === 0) return '$0.00'
|
||||||
|
if (cost >= 1) return '$' + cost.toFixed(2)
|
||||||
|
if (cost >= 0.01) return '$' + cost.toFixed(4)
|
||||||
|
return '$' + cost.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化数字
|
||||||
|
const formatNumber = (num) => {
|
||||||
|
if (!num) return '0'
|
||||||
|
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B'
|
||||||
|
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M'
|
||||||
|
if (num >= 1e3) return (num / 1e3).toFixed(1) + 'K'
|
||||||
|
return num.toLocaleString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .card:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.5),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 md:space-y-8">
|
<div class="space-y-4 sm:space-y-6 md:space-y-8">
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 items-stretch gap-4 md:gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]"
|
class="grid grid-cols-1 items-stretch gap-3 sm:gap-4 md:gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]"
|
||||||
>
|
>
|
||||||
<!-- 基础信息 / 批量概要 -->
|
<!-- 基础信息 / 批量概要 -->
|
||||||
<div class="card-section">
|
<div class="card-section">
|
||||||
@@ -60,9 +60,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="info-grid">
|
<div v-else class="info-grid">
|
||||||
<div class="info-item">
|
<div
|
||||||
|
class="info-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||||
|
title="点击复制"
|
||||||
|
@click="copyText(statsData.name)"
|
||||||
|
>
|
||||||
<p class="info-label">名称</p>
|
<p class="info-label">名称</p>
|
||||||
<p class="info-value break-all">{{ statsData.name }}</p>
|
<p class="info-value flex items-center gap-1 break-all">
|
||||||
|
{{ statsData.name }}
|
||||||
|
<i class="fas fa-copy text-xs text-gray-400" />
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<p class="info-label">状态</p>
|
<p class="info-label">状态</p>
|
||||||
@@ -282,6 +289,7 @@ import { computed } from 'vue'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useApiStatsStore } from '@/stores/apistats'
|
import { useApiStatsStore } from '@/stores/apistats'
|
||||||
|
import { copyText } from '@/utils/tools'
|
||||||
|
|
||||||
const apiStatsStore = useApiStatsStore()
|
const apiStatsStore = useApiStatsStore()
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card p-4 md:p-6">
|
<div class="card p-3 sm:p-4 md:p-6">
|
||||||
<h3
|
<h3
|
||||||
class="mb-3 flex flex-col text-lg font-bold text-gray-900 dark:text-gray-100 sm:flex-row sm:items-center md:mb-4 md:text-xl"
|
class="mb-2 flex flex-col text-base font-bold text-gray-900 dark:text-gray-100 sm:mb-3 sm:flex-row sm:items-center sm:text-lg md:mb-4 md:text-xl"
|
||||||
>
|
>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" />
|
<i class="fas fa-coins mr-2 text-sm text-yellow-500 md:mr-3 md:text-base" />
|
||||||
|
|||||||
@@ -9,9 +9,25 @@
|
|||||||
<div class="modal-content mx-auto w-full max-w-md p-6">
|
<div class="modal-content mx-auto w-full max-w-md p-6">
|
||||||
<div class="mb-6 flex items-start gap-4">
|
<div class="mb-6 flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-amber-600"
|
:class="[
|
||||||
|
'flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl',
|
||||||
|
dialogType === 'danger'
|
||||||
|
? 'bg-gradient-to-br from-red-500 to-red-600'
|
||||||
|
: dialogType === 'warning'
|
||||||
|
? 'bg-gradient-to-br from-amber-500 to-amber-600'
|
||||||
|
: 'bg-primary'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<i class="fas fa-exclamation-triangle text-lg text-white" />
|
<i
|
||||||
|
:class="[
|
||||||
|
'text-lg text-white',
|
||||||
|
dialogType === 'danger'
|
||||||
|
? 'fas fa-trash-alt'
|
||||||
|
: dialogType === 'warning'
|
||||||
|
? 'fas fa-exclamation-triangle'
|
||||||
|
: 'fas fa-question-circle'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
@@ -32,8 +48,14 @@
|
|||||||
{{ cancelText }}
|
{{ cancelText }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-warning px-6 py-3"
|
:class="[
|
||||||
:class="{ 'cursor-not-allowed opacity-50': isProcessing }"
|
'btn px-6 py-3',
|
||||||
|
dialogType === 'danger'
|
||||||
|
? 'btn-danger'
|
||||||
|
: dialogType === 'warning'
|
||||||
|
? 'btn-warning'
|
||||||
|
: 'btn-primary'
|
||||||
|
]"
|
||||||
:disabled="isProcessing"
|
:disabled="isProcessing"
|
||||||
@click="handleConfirm"
|
@click="handleConfirm"
|
||||||
>
|
>
|
||||||
@@ -57,6 +79,7 @@ const title = ref('')
|
|||||||
const message = ref('')
|
const message = ref('')
|
||||||
const confirmText = ref('确认')
|
const confirmText = ref('确认')
|
||||||
const cancelText = ref('取消')
|
const cancelText = ref('取消')
|
||||||
|
const dialogType = ref('primary') // primary | warning | danger
|
||||||
let resolvePromise = null
|
let resolvePromise = null
|
||||||
|
|
||||||
// 显示确认对话框
|
// 显示确认对话框
|
||||||
@@ -64,13 +87,15 @@ const showConfirm = (
|
|||||||
titleText,
|
titleText,
|
||||||
messageText,
|
messageText,
|
||||||
confirmTextParam = '确认',
|
confirmTextParam = '确认',
|
||||||
cancelTextParam = '取消'
|
cancelTextParam = '取消',
|
||||||
|
type = 'primary'
|
||||||
) => {
|
) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
title.value = titleText
|
title.value = titleText
|
||||||
message.value = messageText
|
message.value = messageText
|
||||||
confirmText.value = confirmTextParam
|
confirmText.value = confirmTextParam
|
||||||
cancelText.value = cancelTextParam
|
cancelText.value = cancelTextParam
|
||||||
|
dialogType.value = type
|
||||||
isVisible.value = true
|
isVisible.value = true
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
resolvePromise = resolve
|
resolvePromise = resolve
|
||||||
@@ -155,8 +180,8 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .modal-content {
|
:global(.dark) .modal-content {
|
||||||
background: #1f2937;
|
background: var(--bg-gradient-start);
|
||||||
border: 1px solid #374151;
|
border: 1px solid var(--border-color);
|
||||||
box-shadow: 0 20px 64px rgba(0, 0, 0, 0.8);
|
box-shadow: 0 20px 64px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +197,11 @@ defineExpose({
|
|||||||
@apply bg-amber-600 text-white hover:bg-amber-700 focus:ring-amber-500;
|
@apply bg-amber-600 text-white hover:bg-amber-700 focus:ring-amber-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
@apply text-white hover:opacity-90 focus:ring-indigo-500;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
@apply h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-white;
|
@apply h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-white;
|
||||||
}
|
}
|
||||||
@@ -208,7 +238,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .modal-content::-webkit-scrollbar-track {
|
:global(.dark) .modal-content::-webkit-scrollbar-track {
|
||||||
background: #374151;
|
background: var(--bg-gradient-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content::-webkit-scrollbar-thumb {
|
.modal-content::-webkit-scrollbar-thumb {
|
||||||
@@ -217,7 +247,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .modal-content::-webkit-scrollbar-thumb {
|
:global(.dark) .modal-content::-webkit-scrollbar-thumb {
|
||||||
background: #4b5563;
|
background: var(--bg-gradient-end);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content::-webkit-scrollbar-thumb:hover {
|
.modal-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -6,9 +6,25 @@
|
|||||||
>
|
>
|
||||||
<div class="mb-6 flex items-start gap-4">
|
<div class="mb-6 flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-yellow-400 to-yellow-500"
|
:class="[
|
||||||
|
'flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full',
|
||||||
|
type === 'danger'
|
||||||
|
? 'bg-gradient-to-br from-red-400 to-red-500'
|
||||||
|
: type === 'warning'
|
||||||
|
? 'bg-gradient-to-br from-yellow-400 to-yellow-500'
|
||||||
|
: 'bg-primary'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<i class="fas fa-exclamation text-xl text-white" />
|
<i
|
||||||
|
:class="[
|
||||||
|
'text-xl text-white',
|
||||||
|
type === 'danger'
|
||||||
|
? 'fas fa-trash-alt'
|
||||||
|
: type === 'warning'
|
||||||
|
? 'fas fa-exclamation'
|
||||||
|
: 'fas fa-question'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="mb-2 text-lg font-bold text-gray-900 dark:text-white">
|
<h3 class="mb-2 text-lg font-bold text-gray-900 dark:text-white">
|
||||||
@@ -28,7 +44,14 @@
|
|||||||
{{ cancelText }}
|
{{ cancelText }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 rounded-xl bg-gradient-to-r from-yellow-500 to-orange-500 px-4 py-2.5 font-medium text-white shadow-sm transition-colors hover:from-yellow-600 hover:to-orange-600"
|
:class="[
|
||||||
|
'flex-1 rounded-xl px-4 py-2.5 font-medium text-white shadow-sm transition-all',
|
||||||
|
type === 'danger'
|
||||||
|
? 'bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700'
|
||||||
|
: type === 'warning'
|
||||||
|
? 'bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600'
|
||||||
|
: 'bg-primary hover:opacity-90'
|
||||||
|
]"
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
>
|
>
|
||||||
{{ confirmText }}
|
{{ confirmText }}
|
||||||
@@ -60,6 +83,11 @@ defineProps({
|
|||||||
cancelText: {
|
cancelText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '取消'
|
default: '取消'
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'primary', // primary | warning | danger
|
||||||
|
validator: (value) => ['primary', 'warning', 'danger'].includes(value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,87 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="theme-toggle-container">
|
<div class="theme-toggle-container">
|
||||||
<!-- 紧凑模式:仅显示图标按钮 -->
|
<!-- 紧凑模式:仅显示图标按钮 -->
|
||||||
<button
|
<div v-if="mode === 'compact'" class="flex items-center gap-2">
|
||||||
v-if="mode === 'compact'"
|
<!-- 色系切换按钮 -->
|
||||||
class="theme-toggle-button"
|
<div class="relative">
|
||||||
:title="themeTooltip"
|
<button
|
||||||
@click="handleCycleTheme"
|
class="color-scheme-button"
|
||||||
>
|
:title="`色系: ${themeStore.currentColorScheme.name}`"
|
||||||
<transition mode="out-in" name="fade">
|
@click="toggleColorMenu"
|
||||||
<i v-if="themeStore.themeMode === 'light'" key="sun" class="fas fa-sun" />
|
>
|
||||||
<i v-else-if="themeStore.themeMode === 'dark'" key="moon" class="fas fa-moon" />
|
<span class="color-dot" :style="{ background: themeStore.currentColorScheme.primary }" />
|
||||||
<i v-else key="auto" class="fas fa-circle-half-stroke" />
|
</button>
|
||||||
</transition>
|
<!-- 色系下拉菜单 -->
|
||||||
</button>
|
<transition name="dropdown">
|
||||||
|
<div v-if="showColorMenu" class="color-menu">
|
||||||
|
<button
|
||||||
|
v-for="(scheme, key) in themeStore.ColorSchemes"
|
||||||
|
:key="key"
|
||||||
|
class="color-option"
|
||||||
|
:class="{ active: themeStore.colorScheme === key }"
|
||||||
|
:title="scheme.name"
|
||||||
|
@click="selectColorScheme(key)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="color-preview"
|
||||||
|
:style="{
|
||||||
|
background: `linear-gradient(135deg, ${scheme.primary} 0%, ${scheme.secondary} 100%)`
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<span class="color-name">{{ scheme.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
<!-- 主题切换按钮 -->
|
||||||
|
<button class="theme-toggle-button" :title="themeTooltip" @click="handleCycleTheme">
|
||||||
|
<transition mode="out-in" name="fade">
|
||||||
|
<i v-if="themeStore.themeMode === 'light'" key="sun" class="fas fa-sun" />
|
||||||
|
<i v-else-if="themeStore.themeMode === 'dark'" key="moon" class="fas fa-moon" />
|
||||||
|
<i v-else key="auto" class="fas fa-circle-half-stroke" />
|
||||||
|
</transition>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 下拉菜单模式 - 改为创意切换开关 -->
|
<!-- 下拉菜单模式 - 改为创意切换开关 -->
|
||||||
<div v-else-if="mode === 'dropdown'" class="theme-switch-wrapper">
|
<div v-else-if="mode === 'dropdown'" class="theme-switch-wrapper">
|
||||||
|
<!-- 色系切换按钮 -->
|
||||||
|
<div class="relative mr-3">
|
||||||
|
<button
|
||||||
|
class="color-scheme-button-lg"
|
||||||
|
:title="`色系: ${themeStore.currentColorScheme.name}`"
|
||||||
|
@click="toggleColorMenu"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="color-dot-lg"
|
||||||
|
:style="{
|
||||||
|
background: `linear-gradient(135deg, ${themeStore.currentColorScheme.primary} 0%, ${themeStore.currentColorScheme.secondary} 100%)`
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<i class="fas fa-palette ml-1 text-xs opacity-60" />
|
||||||
|
</button>
|
||||||
|
<!-- 色系下拉菜单 -->
|
||||||
|
<transition name="dropdown">
|
||||||
|
<div v-if="showColorMenu" class="color-menu">
|
||||||
|
<button
|
||||||
|
v-for="(scheme, key) in themeStore.ColorSchemes"
|
||||||
|
:key="key"
|
||||||
|
class="color-option"
|
||||||
|
:class="{ active: themeStore.colorScheme === key }"
|
||||||
|
:title="scheme.name"
|
||||||
|
@click="selectColorScheme(key)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="color-preview"
|
||||||
|
:style="{
|
||||||
|
background: `linear-gradient(135deg, ${scheme.primary} 0%, ${scheme.secondary} 100%)`
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<span class="color-name">{{ scheme.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="theme-switch"
|
class="theme-switch"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -67,7 +133,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
@@ -88,6 +154,9 @@ defineProps({
|
|||||||
// Store
|
// Store
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
// 色系菜单状态
|
||||||
|
const showColorMenu = ref(false)
|
||||||
|
|
||||||
// 主题选项配置
|
// 主题选项配置
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
{
|
{
|
||||||
@@ -124,6 +193,34 @@ const handleCycleTheme = () => {
|
|||||||
const selectTheme = (mode) => {
|
const selectTheme = (mode) => {
|
||||||
themeStore.setThemeMode(mode)
|
themeStore.setThemeMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleColorMenu = () => {
|
||||||
|
showColorMenu.value = !showColorMenu.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectColorScheme = (scheme) => {
|
||||||
|
themeStore.setColorScheme(scheme)
|
||||||
|
showColorMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭菜单
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (
|
||||||
|
!e.target.closest('.color-scheme-button') &&
|
||||||
|
!e.target.closest('.color-scheme-button-lg') &&
|
||||||
|
!e.target.closest('.color-menu')
|
||||||
|
) {
|
||||||
|
showColorMenu.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -205,10 +302,10 @@ const selectTheme = (mode) => {
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 15px rgba(102, 126, 234, 0.3),
|
0 4px 15px color-mix(in srgb, var(--primary-color) 30%, transparent),
|
||||||
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -218,7 +315,7 @@ const selectTheme = (mode) => {
|
|||||||
.theme-switch:hover {
|
.theme-switch:hover {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 6px 20px rgba(102, 126, 234, 0.4),
|
0 6px 20px color-mix(in srgb, var(--primary-color) 40%, transparent),
|
||||||
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,16 +338,15 @@ const selectTheme = (mode) => {
|
|||||||
inset 0 1px 2px rgba(255, 255, 255, 0.05);
|
inset 0 1px 2px rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 自动模式样式 - 静态蓝紫渐变设计(优化版) */
|
/* 自动模式样式 - 使用主题色混合 */
|
||||||
.theme-switch.is-auto {
|
.theme-switch.is-auto {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
#c4b5fd 0%,
|
color-mix(in srgb, var(--accent-color) 70%, white) 0%,
|
||||||
/* 更柔和的起始:淡紫 */ #a78bfa 15%,
|
var(--accent-color) 25%,
|
||||||
/* 浅紫 */ #818cf8 40%,
|
color-mix(in srgb, var(--primary-color) 80%, var(--accent-color)) 50%,
|
||||||
/* 紫蓝 */ #6366f1 60%,
|
var(--primary-color) 75%,
|
||||||
/* 靛蓝 */ #4f46e5 85%,
|
var(--secondary-color) 100%
|
||||||
/* 深蓝紫 */ #4338ca 100% /* 更深的结束:深紫 */
|
|
||||||
);
|
);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -258,7 +354,7 @@ const selectTheme = (mode) => {
|
|||||||
background-size: 120% 120%;
|
background-size: 120% 120%;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 15px rgba(139, 92, 246, 0.25),
|
0 4px 15px color-mix(in srgb, var(--secondary-color) 25%, transparent),
|
||||||
inset 0 1px 3px rgba(0, 0, 0, 0.1),
|
inset 0 1px 3px rgba(0, 0, 0, 0.1),
|
||||||
inset 0 -1px 3px rgba(0, 0, 0, 0.1);
|
inset 0 -1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
@@ -426,7 +522,7 @@ const selectTheme = (mode) => {
|
|||||||
|
|
||||||
.handle-icon .fa-circle-half-stroke {
|
.handle-icon .fa-circle-half-stroke {
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
filter: drop-shadow(0 0 4px rgba(167, 139, 250, 0.5));
|
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent-color) 50%, transparent));
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,4 +604,78 @@ const selectTheme = (mode) => {
|
|||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 色系切换按钮样式 */
|
||||||
|
.color-scheme-button {
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
@apply h-9 w-9 rounded-full;
|
||||||
|
@apply bg-white/90 dark:bg-gray-800/90;
|
||||||
|
@apply hover:bg-white dark:hover:bg-gray-700;
|
||||||
|
@apply border border-gray-200/50 dark:border-gray-600/50;
|
||||||
|
@apply transition-all duration-200 ease-out;
|
||||||
|
@apply shadow-md hover:shadow-lg;
|
||||||
|
@apply hover:scale-110 active:scale-95;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-scheme-button-lg {
|
||||||
|
@apply flex items-center justify-center;
|
||||||
|
@apply h-9 rounded-full px-3;
|
||||||
|
@apply bg-white/90 dark:bg-gray-800/90;
|
||||||
|
@apply hover:bg-white dark:hover:bg-gray-700;
|
||||||
|
@apply border border-gray-200/50 dark:border-gray-600/50;
|
||||||
|
@apply transition-all duration-200 ease-out;
|
||||||
|
@apply shadow-md hover:shadow-lg;
|
||||||
|
@apply text-gray-600 dark:text-gray-300;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot {
|
||||||
|
@apply h-5 w-5 rounded-full;
|
||||||
|
@apply shadow-inner;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot-lg {
|
||||||
|
@apply h-5 w-5 rounded-full;
|
||||||
|
@apply shadow-inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-scheme-button:hover .color-dot {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 色系下拉菜单 */
|
||||||
|
.color-menu {
|
||||||
|
@apply absolute left-0 top-full mt-2;
|
||||||
|
@apply bg-white dark:bg-gray-800;
|
||||||
|
@apply rounded-xl shadow-xl;
|
||||||
|
@apply border border-gray-200 dark:border-gray-700;
|
||||||
|
@apply min-w-[140px] p-2;
|
||||||
|
@apply z-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option {
|
||||||
|
@apply flex w-full items-center gap-2;
|
||||||
|
@apply rounded-lg px-3 py-2;
|
||||||
|
@apply text-sm text-gray-700 dark:text-gray-300;
|
||||||
|
@apply hover:bg-gray-100 dark:hover:bg-gray-700;
|
||||||
|
@apply transition-all duration-150;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option.active {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-700;
|
||||||
|
@apply font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preview {
|
||||||
|
@apply h-5 w-5 rounded-full;
|
||||||
|
@apply shadow-sm;
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-name {
|
||||||
|
@apply flex-1 text-left;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -163,8 +163,8 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .toast {
|
:global(.dark) .toast {
|
||||||
background: #1f2937;
|
background: var(--bg-gradient-start);
|
||||||
border: 1px solid #374151;
|
border: 1px solid var(--border-color);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,8 +234,8 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .toast-close:hover {
|
:global(.dark) .toast-close:hover {
|
||||||
background: #374151;
|
background: var(--bg-gradient-mid);
|
||||||
color: #9ca3af;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-progress {
|
.toast-progress {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
|||||||
import { Chart } from 'chart.js/auto'
|
import { Chart } from 'chart.js/auto'
|
||||||
import { useDashboardStore } from '@/stores/dashboard'
|
import { useDashboardStore } from '@/stores/dashboard'
|
||||||
import { useChartConfig } from '@/composables/useChartConfig'
|
import { useChartConfig } from '@/composables/useChartConfig'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/tools'
|
||||||
|
|
||||||
const dashboardStore = useDashboardStore()
|
const dashboardStore = useDashboardStore()
|
||||||
const chartCanvas = ref(null)
|
const chartCanvas = ref(null)
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|||||||
import { Chart } from 'chart.js/auto'
|
import { Chart } from 'chart.js/auto'
|
||||||
import { useDashboardStore } from '@/stores/dashboard'
|
import { useDashboardStore } from '@/stores/dashboard'
|
||||||
import { useChartConfig } from '@/composables/useChartConfig'
|
import { useChartConfig } from '@/composables/useChartConfig'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
const dashboardStore = useDashboardStore()
|
const dashboardStore = useDashboardStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
const chartCanvas = ref(null)
|
const chartCanvas = ref(null)
|
||||||
let chart = null
|
let chart = null
|
||||||
|
|
||||||
@@ -83,16 +85,16 @@ const createChart = () => {
|
|||||||
{
|
{
|
||||||
label: '请求次数',
|
label: '请求次数',
|
||||||
data: dashboardStore.trendData.map((item) => item.requests),
|
data: dashboardStore.trendData.map((item) => item.requests),
|
||||||
borderColor: '#667eea',
|
borderColor: themeStore.currentColorScheme.primary,
|
||||||
backgroundColor: getGradient(ctx, '#667eea', 0.1),
|
backgroundColor: getGradient(ctx, themeStore.currentColorScheme.primary, 0.1),
|
||||||
yAxisID: 'y',
|
yAxisID: 'y',
|
||||||
tension: 0.4
|
tension: 0.4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Token使用量',
|
label: 'Token使用量',
|
||||||
data: dashboardStore.trendData.map((item) => item.tokens),
|
data: dashboardStore.trendData.map((item) => item.tokens),
|
||||||
borderColor: '#f093fb',
|
borderColor: themeStore.currentColorScheme.accent,
|
||||||
backgroundColor: getGradient(ctx, '#f093fb', 0.1),
|
backgroundColor: getGradient(ctx, themeStore.currentColorScheme.accent, 0.1),
|
||||||
yAxisID: 'y1',
|
yAxisID: 'y1',
|
||||||
tension: 0.4
|
tension: 0.4
|
||||||
}
|
}
|
||||||
@@ -169,6 +171,14 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听色系变化,重新创建图表
|
||||||
|
watch(
|
||||||
|
() => themeStore.colorScheme,
|
||||||
|
() => {
|
||||||
|
createChart()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
createChart()
|
createChart()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<!-- 用户菜单 -->
|
<!-- 用户菜单 -->
|
||||||
<div class="user-menu-container relative">
|
<div class="user-menu-container relative">
|
||||||
<button
|
<button
|
||||||
class="user-menu-button flex items-center gap-2 rounded-2xl bg-gradient-to-r from-blue-500 to-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105 hover:shadow-xl active:scale-95 sm:px-4 sm:py-2.5"
|
class="user-menu-button flex items-center gap-2 rounded-2xl px-3 py-2 text-sm font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105 hover:shadow-xl active:scale-95 sm:px-4 sm:py-2.5"
|
||||||
@click="userMenuOpen = !userMenuOpen"
|
@click="userMenuOpen = !userMenuOpen"
|
||||||
>
|
>
|
||||||
<i class="fas fa-user-circle text-sm sm:text-base" />
|
<i class="fas fa-user-circle text-sm sm:text-base" />
|
||||||
@@ -263,16 +263,29 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ConfirmModal -->
|
||||||
|
<ConfirmModal
|
||||||
|
:cancel-text="confirmModalConfig.cancelText"
|
||||||
|
:confirm-text="confirmModalConfig.confirmText"
|
||||||
|
:message="confirmModalConfig.message"
|
||||||
|
:show="showConfirmModal"
|
||||||
|
:title="confirmModalConfig.title"
|
||||||
|
:type="confirmModalConfig.type"
|
||||||
|
@cancel="handleCancelModal"
|
||||||
|
@confirm="handleConfirmModal"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -308,6 +321,39 @@ const changePasswordForm = reactive({
|
|||||||
newUsername: ''
|
newUsername: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ConfirmModal 状态
|
||||||
|
const showConfirmModal = ref(false)
|
||||||
|
const confirmModalConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'primary',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
const confirmResolve = ref(null)
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
type = 'primary'
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||||
|
confirmResolve.value = resolve
|
||||||
|
showConfirmModal.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleConfirmModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(true)
|
||||||
|
}
|
||||||
|
const handleCancelModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
// 检查更新(同时获取版本信息)
|
// 检查更新(同时获取版本信息)
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
if (versionInfo.value.checkingUpdate) {
|
if (versionInfo.value.checkingUpdate) {
|
||||||
@@ -317,7 +363,7 @@ const checkForUpdates = async () => {
|
|||||||
versionInfo.value.checkingUpdate = true
|
versionInfo.value.checkingUpdate = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.get('/admin/check-updates')
|
const result = await httpApi.get('/admin/check-updates')
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const data = result.data
|
const data = result.data
|
||||||
@@ -397,7 +443,7 @@ const changePassword = async () => {
|
|||||||
changePasswordLoading.value = true
|
changePasswordLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.post('/web/auth/change-password', {
|
const data = await httpApi.post('/web/auth/change-password', {
|
||||||
currentPassword: changePasswordForm.currentPassword,
|
currentPassword: changePasswordForm.currentPassword,
|
||||||
newPassword: changePasswordForm.newPassword,
|
newPassword: changePasswordForm.newPassword,
|
||||||
newUsername: changePasswordForm.newUsername || undefined
|
newUsername: changePasswordForm.newUsername || undefined
|
||||||
@@ -426,8 +472,15 @@ const changePassword = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 退出登录
|
// 退出登录
|
||||||
const logout = () => {
|
const logout = async () => {
|
||||||
if (confirm('确定要退出登录吗?')) {
|
const confirmed = await showConfirm(
|
||||||
|
'退出登录',
|
||||||
|
'确定要退出登录吗?',
|
||||||
|
'确定退出',
|
||||||
|
'取消',
|
||||||
|
'warning'
|
||||||
|
)
|
||||||
|
if (confirmed) {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
showToast('已安全退出', 'success')
|
showToast('已安全退出', 'success')
|
||||||
@@ -465,6 +518,12 @@ onUnmounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-button:hover {
|
||||||
|
box-shadow: 0 6px 16px rgba(var(--primary-rgb), 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 添加光泽效果 */
|
/* 添加光泽效果 */
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const tabRouteMap = computed(() => {
|
|||||||
dashboard: '/dashboard',
|
dashboard: '/dashboard',
|
||||||
apiKeys: '/api-keys',
|
apiKeys: '/api-keys',
|
||||||
accounts: '/accounts',
|
accounts: '/accounts',
|
||||||
|
quotaCards: '/quota-cards',
|
||||||
tutorial: '/tutorial',
|
tutorial: '/tutorial',
|
||||||
settings: '/settings'
|
settings: '/settings'
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,7 @@ const initActiveTab = () => {
|
|||||||
Dashboard: 'dashboard',
|
Dashboard: 'dashboard',
|
||||||
ApiKeys: 'apiKeys',
|
ApiKeys: 'apiKeys',
|
||||||
Accounts: 'accounts',
|
Accounts: 'accounts',
|
||||||
|
QuotaCards: 'quotaCards',
|
||||||
Tutorial: 'tutorial',
|
Tutorial: 'tutorial',
|
||||||
Settings: 'settings'
|
Settings: 'settings'
|
||||||
}
|
}
|
||||||
@@ -96,6 +98,7 @@ watch(
|
|||||||
Dashboard: 'dashboard',
|
Dashboard: 'dashboard',
|
||||||
ApiKeys: 'apiKeys',
|
ApiKeys: 'apiKeys',
|
||||||
Accounts: 'accounts',
|
Accounts: 'accounts',
|
||||||
|
QuotaCards: 'quotaCards',
|
||||||
Tutorial: 'tutorial',
|
Tutorial: 'tutorial',
|
||||||
Settings: 'settings'
|
Settings: 'settings'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ const tabs = computed(() => {
|
|||||||
const baseTabs = [
|
const baseTabs = [
|
||||||
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
|
{ key: 'dashboard', name: '仪表板', shortName: '仪表板', icon: 'fas fa-tachometer-alt' },
|
||||||
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
{ key: 'apiKeys', name: 'API Keys', shortName: 'API', icon: 'fas fa-key' },
|
||||||
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' }
|
{ key: 'accounts', name: '账户管理', shortName: '账户', icon: 'fas fa-user-circle' },
|
||||||
|
{ key: 'quotaCards', name: '额度卡', shortName: '额度卡', icon: 'fas fa-ticket-alt' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 只有在 LDAP 启用时才显示用户管理
|
// 只有在 LDAP 启用时才显示用户管理
|
||||||
|
|||||||
@@ -173,7 +173,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive, watch } from 'vue'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
|
|||||||
@@ -249,7 +249,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import CreateApiKeyModal from './CreateApiKeyModal.vue'
|
import CreateApiKeyModal from './CreateApiKeyModal.vue'
|
||||||
import ViewApiKeyModal from './ViewApiKeyModal.vue'
|
import ViewApiKeyModal from './ViewApiKeyModal.vue'
|
||||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|||||||
@@ -351,7 +351,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
show: {
|
show: {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Chart } from 'chart.js/auto'
|
import { Chart } from 'chart.js/auto'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
export function useChartConfig() {
|
export function useChartConfig() {
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
// 设置Chart.js默认配置
|
// 设置Chart.js默认配置
|
||||||
Chart.defaults.font.family =
|
Chart.defaults.font.family =
|
||||||
"'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
"'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
||||||
@@ -24,6 +27,12 @@ export function useChartConfig() {
|
|||||||
return gradient
|
return gradient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取当前主题的颜色方案
|
||||||
|
const getThemeColors = () => {
|
||||||
|
const scheme = themeStore.currentColorScheme
|
||||||
|
return [scheme.primary, scheme.secondary, scheme.accent, '#4facfe', '#00f2fe']
|
||||||
|
}
|
||||||
|
|
||||||
// 通用图表选项
|
// 通用图表选项
|
||||||
const commonOptions = {
|
const commonOptions = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -91,9 +100,12 @@ export function useChartConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 颜色方案
|
// 颜色方案 - 动态获取主题色
|
||||||
const colorSchemes = {
|
const colorSchemes = {
|
||||||
primary: ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#00f2fe'],
|
get primary() {
|
||||||
|
const scheme = themeStore.currentColorScheme
|
||||||
|
return [scheme.primary, scheme.secondary, scheme.accent, '#4facfe', '#00f2fe']
|
||||||
|
},
|
||||||
success: ['#10b981', '#059669', '#34d399', '#6ee7b7', '#a7f3d0'],
|
success: ['#10b981', '#059669', '#34d399', '#6ee7b7', '#a7f3d0'],
|
||||||
warning: ['#f59e0b', '#d97706', '#fbbf24', '#fcd34d', '#fde68a'],
|
warning: ['#f59e0b', '#d97706', '#fbbf24', '#fcd34d', '#fde68a'],
|
||||||
danger: ['#ef4444', '#dc2626', '#f87171', '#fca5a5', '#fecaca']
|
danger: ['#ef4444', '#dc2626', '#f87171', '#fca5a5', '#fecaca']
|
||||||
@@ -101,6 +113,7 @@ export function useChartConfig() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getGradient,
|
getGradient,
|
||||||
|
getThemeColors,
|
||||||
commonOptions,
|
commonOptions,
|
||||||
colorSchemes
|
colorSchemes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
// API 配置
|
|
||||||
import { APP_CONFIG, getLoginUrl } from './app'
|
|
||||||
|
|
||||||
// 开发环境使用 /webapi 前缀,生产环境不使用前缀
|
|
||||||
export const API_PREFIX = APP_CONFIG.apiPrefix
|
|
||||||
|
|
||||||
// 创建完整的 API URL
|
|
||||||
export function createApiUrl(path) {
|
|
||||||
// 确保路径以 / 开头
|
|
||||||
if (!path.startsWith('/')) {
|
|
||||||
path = '/' + path
|
|
||||||
}
|
|
||||||
return API_PREFIX + path
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 请求的基础配置
|
|
||||||
export function getRequestConfig(token) {
|
|
||||||
const config = {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
config.headers['Authorization'] = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一的 API 请求类
|
|
||||||
class ApiClient {
|
|
||||||
constructor() {
|
|
||||||
this.baseURL = API_PREFIX
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取认证 token
|
|
||||||
getAuthToken() {
|
|
||||||
const authToken = localStorage.getItem('authToken')
|
|
||||||
return authToken || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建请求配置
|
|
||||||
buildConfig(options = {}) {
|
|
||||||
const config = {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
},
|
|
||||||
...options
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加认证 token
|
|
||||||
const token = this.getAuthToken()
|
|
||||||
if (token) {
|
|
||||||
config.headers['Authorization'] = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理响应
|
|
||||||
async handleResponse(response) {
|
|
||||||
// 401 未授权,需要重新登录
|
|
||||||
if (response.status === 401) {
|
|
||||||
// 如果当前已经在登录页面,不要再次跳转
|
|
||||||
const currentPath = window.location.pathname + window.location.hash
|
|
||||||
const isLoginPage = currentPath.includes('/login') || currentPath.endsWith('/')
|
|
||||||
|
|
||||||
if (!isLoginPage) {
|
|
||||||
localStorage.removeItem('authToken')
|
|
||||||
// 使用统一的登录URL
|
|
||||||
window.location.href = getLoginUrl()
|
|
||||||
}
|
|
||||||
throw new Error('Unauthorized')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试解析 JSON
|
|
||||||
const contentType = response.headers.get('content-type')
|
|
||||||
if (contentType && contentType.includes('application/json')) {
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
// 如果响应不成功,抛出错误
|
|
||||||
if (!response.ok) {
|
|
||||||
// 创建一个包含完整错误信息的错误对象
|
|
||||||
const error = new Error(data.message || `HTTP ${response.status}`)
|
|
||||||
// 保留完整的响应数据,以便错误处理时可以访问详细信息
|
|
||||||
error.response = {
|
|
||||||
status: response.status,
|
|
||||||
data: data
|
|
||||||
}
|
|
||||||
// 为了向后兼容,也保留原始的 message
|
|
||||||
error.message = data.message || error.message
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非 JSON 响应
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET 请求
|
|
||||||
async get(url, options = {}) {
|
|
||||||
// 处理查询参数
|
|
||||||
let fullUrl = createApiUrl(url)
|
|
||||||
if (options.params) {
|
|
||||||
const params = new URLSearchParams(options.params)
|
|
||||||
fullUrl += '?' + params.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除 params 避免传递给 fetch
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
const { params, ...configOptions } = options
|
|
||||||
const config = this.buildConfig({
|
|
||||||
...configOptions,
|
|
||||||
method: 'GET'
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(fullUrl, config)
|
|
||||||
return await this.handleResponse(response)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API GET Error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST 请求
|
|
||||||
async post(url, data = null, options = {}) {
|
|
||||||
const fullUrl = createApiUrl(url)
|
|
||||||
const config = this.buildConfig({
|
|
||||||
...options,
|
|
||||||
method: 'POST',
|
|
||||||
body: data ? JSON.stringify(data) : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(fullUrl, config)
|
|
||||||
return await this.handleResponse(response)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API POST Error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT 请求
|
|
||||||
async put(url, data = null, options = {}) {
|
|
||||||
const fullUrl = createApiUrl(url)
|
|
||||||
const config = this.buildConfig({
|
|
||||||
...options,
|
|
||||||
method: 'PUT',
|
|
||||||
body: data ? JSON.stringify(data) : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(fullUrl, config)
|
|
||||||
return await this.handleResponse(response)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API PUT Error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PATCH 请求
|
|
||||||
async patch(url, data = null, options = {}) {
|
|
||||||
const fullUrl = createApiUrl(url)
|
|
||||||
const config = this.buildConfig({
|
|
||||||
...options,
|
|
||||||
method: 'PATCH',
|
|
||||||
body: data ? JSON.stringify(data) : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(fullUrl, config)
|
|
||||||
return await this.handleResponse(response)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API PATCH Error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE 请求
|
|
||||||
async delete(url, options = {}) {
|
|
||||||
const fullUrl = createApiUrl(url)
|
|
||||||
const { data, ...restOptions } = options
|
|
||||||
|
|
||||||
const config = this.buildConfig({
|
|
||||||
...restOptions,
|
|
||||||
method: 'DELETE',
|
|
||||||
body: data ? JSON.stringify(data) : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(fullUrl, config)
|
|
||||||
return await this.handleResponse(response)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API DELETE Error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出单例实例
|
|
||||||
export const apiClient = new ApiClient()
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
// API Stats 专用 API 客户端
|
|
||||||
// 与管理员 API 隔离,不需要认证
|
|
||||||
|
|
||||||
class ApiStatsClient {
|
|
||||||
constructor() {
|
|
||||||
this.baseURL = window.location.origin
|
|
||||||
// 开发环境需要为 admin 路径添加 /webapi 前缀
|
|
||||||
this.isDev = import.meta.env.DEV
|
|
||||||
}
|
|
||||||
|
|
||||||
async request(url, options = {}) {
|
|
||||||
try {
|
|
||||||
// 在开发环境中,为 /admin 路径添加 /webapi 前缀
|
|
||||||
if (this.isDev && url.startsWith('/admin')) {
|
|
||||||
url = '/webapi' + url
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${this.baseURL}${url}`, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
},
|
|
||||||
...options
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.message || `请求失败: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API Stats request error:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取 API Key ID
|
|
||||||
async getKeyId(apiKey) {
|
|
||||||
return this.request('/apiStats/api/get-key-id', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ apiKey })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户统计数据
|
|
||||||
async getUserStats(apiId) {
|
|
||||||
return this.request('/apiStats/api/user-stats', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ apiId })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取模型使用统计
|
|
||||||
async getUserModelStats(apiId, period = 'daily') {
|
|
||||||
return this.request('/apiStats/api/user-model-stats', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ apiId, period })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取 OEM 设置(用于网站名称和图标)
|
|
||||||
async getOemSettings() {
|
|
||||||
try {
|
|
||||||
return await this.request('/admin/oem-settings')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load OEM settings:', error)
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
siteName: 'Claude Relay Service',
|
|
||||||
siteIcon: '',
|
|
||||||
siteIconData: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量查询统计数据
|
|
||||||
async getBatchStats(apiIds) {
|
|
||||||
return this.request('/apiStats/api/batch-stats', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ apiIds })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量查询模型统计
|
|
||||||
async getBatchModelStats(apiIds, period = 'daily') {
|
|
||||||
return this.request('/apiStats/api/batch-model-stats', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ apiIds, period })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const apiStatsClient = new ApiStatsClient()
|
|
||||||
@@ -1,28 +1,13 @@
|
|||||||
// 应用配置
|
|
||||||
export const APP_CONFIG = {
|
export const APP_CONFIG = {
|
||||||
// 应用基础路径
|
|
||||||
basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'),
|
basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'),
|
||||||
|
|
||||||
// 应用标题
|
|
||||||
title: import.meta.env.VITE_APP_TITLE || 'Claude Relay Service - 管理后台',
|
|
||||||
|
|
||||||
// 是否为开发环境
|
|
||||||
isDev: import.meta.env.DEV,
|
|
||||||
|
|
||||||
// API 前缀
|
|
||||||
apiPrefix: import.meta.env.DEV ? '/webapi' : ''
|
apiPrefix: import.meta.env.DEV ? '/webapi' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取完整的应用URL
|
|
||||||
export function getAppUrl(path = '') {
|
export function getAppUrl(path = '') {
|
||||||
// 确保路径以 / 开头
|
if (path && !path.startsWith('/')) path = '/' + path
|
||||||
if (path && !path.startsWith('/')) {
|
|
||||||
path = '/' + path
|
|
||||||
}
|
|
||||||
return APP_CONFIG.basePath + (path.startsWith('#') ? path : '#' + path)
|
return APP_CONFIG.basePath + (path.startsWith('#') ? path : '#' + path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取登录页面URL
|
|
||||||
export function getLoginUrl() {
|
export function getLoginUrl() {
|
||||||
return getAppUrl('/login')
|
return getAppUrl('/login')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { APP_CONFIG } from '@/config/app'
|
import { APP_CONFIG } from '@/config/app'
|
||||||
|
import { showToast } from '@/utils/tools'
|
||||||
|
|
||||||
// 路由懒加载
|
// 路由懒加载
|
||||||
const LoginView = () => import('@/views/LoginView.vue')
|
const LoginView = () => import('@/views/LoginView.vue')
|
||||||
@@ -17,6 +18,7 @@ const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vu
|
|||||||
const TutorialView = () => import('@/views/TutorialView.vue')
|
const TutorialView = () => import('@/views/TutorialView.vue')
|
||||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||||
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
||||||
|
const QuotaCardsView = () => import('@/views/QuotaCardsView.vue')
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -159,6 +161,18 @@ const routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/quota-cards',
|
||||||
|
component: MainLayout,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'QuotaCards',
|
||||||
|
component: QuotaCardsView
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
// 捕获所有未匹配的路由
|
// 捕获所有未匹配的路由
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
@@ -203,8 +217,6 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If the error is about disabled account, redirect to login with error
|
// If the error is about disabled account, redirect to login with error
|
||||||
if (error.message && error.message.includes('disabled')) {
|
if (error.message && error.message.includes('disabled')) {
|
||||||
// Import showToast to display the error
|
|
||||||
const { showToast } = await import('@/utils/toast')
|
|
||||||
showToast(error.message, 'error')
|
showToast(error.message, 'error')
|
||||||
}
|
}
|
||||||
return next('/user-login')
|
return next('/user-login')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
|
|
||||||
export const useAccountsStore = defineStore('accounts', () => {
|
export const useAccountsStore = defineStore('accounts', () => {
|
||||||
// 状态
|
// 状态
|
||||||
@@ -24,7 +24,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/claude-accounts')
|
const response = await httpApi.getClaudeAccounts()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
claudeAccounts.value = response.data || []
|
claudeAccounts.value = response.data || []
|
||||||
} else {
|
} else {
|
||||||
@@ -43,7 +43,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/claude-console-accounts')
|
const response = await httpApi.getClaudeConsoleAccounts()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
claudeConsoleAccounts.value = response.data || []
|
claudeConsoleAccounts.value = response.data || []
|
||||||
} else {
|
} else {
|
||||||
@@ -62,7 +62,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/bedrock-accounts')
|
const response = await httpApi.getBedrockAccounts()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
bedrockAccounts.value = response.data || []
|
bedrockAccounts.value = response.data || []
|
||||||
} else {
|
} else {
|
||||||
@@ -81,7 +81,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/gemini-accounts')
|
const response = await httpApi.getGeminiAccounts()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
geminiAccounts.value = response.data || []
|
geminiAccounts.value = response.data || []
|
||||||
} else {
|
} else {
|
||||||
@@ -100,7 +100,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/openai-accounts')
|
const response = await httpApi.getOpenAIAccounts()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
openaiAccounts.value = response.data || []
|
openaiAccounts.value = response.data || []
|
||||||
} else {
|
} else {
|
||||||
@@ -119,7 +119,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/azure-openai-accounts')
|
const response = await httpApi.getAzureOpenAIAccounts()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
azureOpenaiAccounts.value = response.data || []
|
azureOpenaiAccounts.value = response.data || []
|
||||||
} else {
|
} else {
|
||||||
@@ -138,7 +138,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/openai-responses-accounts')
|
const response = await httpApi.getOpenAIResponsesAccounts()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
openaiResponsesAccounts.value = response.data || []
|
openaiResponsesAccounts.value = response.data || []
|
||||||
} else {
|
} else {
|
||||||
@@ -157,7 +157,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/droid-accounts')
|
const response = await httpApi.getDroidAccounts()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
droidAccounts.value = response.data || []
|
droidAccounts.value = response.data || []
|
||||||
} else {
|
} else {
|
||||||
@@ -199,7 +199,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/claude-accounts', data)
|
const response = await httpApi.createClaudeAccount(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchClaudeAccounts()
|
await fetchClaudeAccounts()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -219,7 +219,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/claude-console-accounts', data)
|
const response = await httpApi.createClaudeConsoleAccount(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchClaudeConsoleAccounts()
|
await fetchClaudeConsoleAccounts()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -239,7 +239,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/bedrock-accounts', data)
|
const response = await httpApi.createBedrockAccount(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchBedrockAccounts()
|
await fetchBedrockAccounts()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -259,7 +259,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/gemini-accounts', data)
|
const response = await httpApi.createGeminiAccount(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchGeminiAccounts()
|
await fetchGeminiAccounts()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -279,7 +279,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/openai-accounts', data)
|
const response = await httpApi.createOpenAIAccount(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchOpenAIAccounts()
|
await fetchOpenAIAccounts()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -299,7 +299,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/droid-accounts', data)
|
const response = await httpApi.createDroidAccount(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchDroidAccounts()
|
await fetchDroidAccounts()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -319,7 +319,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/droid-accounts/${id}`, data)
|
const response = await httpApi.updateDroidAccount(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchDroidAccounts()
|
await fetchDroidAccounts()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -339,7 +339,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/azure-openai-accounts', data)
|
const response = await httpApi.createAzureOpenAIAccount(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchAzureOpenAIAccounts()
|
await fetchAzureOpenAIAccounts()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -359,7 +359,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/openai-responses-accounts', data)
|
const response = await httpApi.createOpenAIResponsesAccount(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchOpenAIResponsesAccounts()
|
await fetchOpenAIResponsesAccounts()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -379,7 +379,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/gemini-api-accounts', data)
|
const response = await httpApi.createGeminiApiAccount(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchGeminiAccounts()
|
await fetchGeminiAccounts()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -399,7 +399,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/claude-accounts/${id}`, data)
|
const response = await httpApi.updateClaudeAccount(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchClaudeAccounts()
|
await fetchClaudeAccounts()
|
||||||
return response
|
return response
|
||||||
@@ -419,7 +419,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/claude-console-accounts/${id}`, data)
|
const response = await httpApi.updateClaudeConsoleAccount(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchClaudeConsoleAccounts()
|
await fetchClaudeConsoleAccounts()
|
||||||
return response
|
return response
|
||||||
@@ -439,7 +439,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/bedrock-accounts/${id}`, data)
|
const response = await httpApi.updateBedrockAccount(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchBedrockAccounts()
|
await fetchBedrockAccounts()
|
||||||
return response
|
return response
|
||||||
@@ -459,7 +459,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/gemini-accounts/${id}`, data)
|
const response = await httpApi.updateGeminiAccount(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchGeminiAccounts()
|
await fetchGeminiAccounts()
|
||||||
return response
|
return response
|
||||||
@@ -479,7 +479,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/openai-accounts/${id}`, data)
|
const response = await httpApi.updateOpenAIAccount(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchOpenAIAccounts()
|
await fetchOpenAIAccounts()
|
||||||
return response
|
return response
|
||||||
@@ -499,7 +499,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/azure-openai-accounts/${id}`, data)
|
const response = await httpApi.updateAzureOpenAIAccount(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchAzureOpenAIAccounts()
|
await fetchAzureOpenAIAccounts()
|
||||||
return response
|
return response
|
||||||
@@ -519,7 +519,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/openai-responses-accounts/${id}`, data)
|
const response = await httpApi.updateOpenAIResponsesAccount(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchOpenAIResponsesAccounts()
|
await fetchOpenAIResponsesAccounts()
|
||||||
return response
|
return response
|
||||||
@@ -539,7 +539,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/gemini-api-accounts/${id}`, data)
|
const response = await httpApi.updateGeminiApiAccount(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchGeminiAccounts()
|
await fetchGeminiAccounts()
|
||||||
return response
|
return response
|
||||||
@@ -578,7 +578,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
endpoint = `/admin/openai-accounts/${id}/toggle`
|
endpoint = `/admin/openai-accounts/${id}/toggle`
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.put(endpoint)
|
const response = await httpApi.toggleAccountStatus(endpoint)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (platform === 'claude') {
|
if (platform === 'claude') {
|
||||||
await fetchClaudeAccounts()
|
await fetchClaudeAccounts()
|
||||||
@@ -633,7 +633,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
endpoint = `/admin/openai-accounts/${id}`
|
endpoint = `/admin/openai-accounts/${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.delete(endpoint)
|
const response = await httpApi.deleteAccountByEndpoint(endpoint)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (platform === 'claude') {
|
if (platform === 'claude') {
|
||||||
await fetchClaudeAccounts()
|
await fetchClaudeAccounts()
|
||||||
@@ -669,7 +669,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(`/admin/claude-accounts/${id}/refresh`)
|
const response = await httpApi.refreshClaudeAccount(id)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchClaudeAccounts()
|
await fetchClaudeAccounts()
|
||||||
return response
|
return response
|
||||||
@@ -687,7 +687,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
// 生成Claude OAuth URL
|
// 生成Claude OAuth URL
|
||||||
const generateClaudeAuthUrl = async (proxyConfig) => {
|
const generateClaudeAuthUrl = async (proxyConfig) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/claude-accounts/generate-auth-url', proxyConfig)
|
const response = await httpApi.generateClaudeAuthUrl(proxyConfig)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.data // 返回整个对象,包含authUrl和sessionId
|
return response.data // 返回整个对象,包含authUrl和sessionId
|
||||||
} else {
|
} else {
|
||||||
@@ -702,7 +702,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
// 交换Claude OAuth Code
|
// 交换Claude OAuth Code
|
||||||
const exchangeClaudeCode = async (data) => {
|
const exchangeClaudeCode = async (data) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/claude-accounts/exchange-code', data)
|
const response = await httpApi.exchangeClaudeCode(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.data
|
return response.data
|
||||||
} else {
|
} else {
|
||||||
@@ -717,10 +717,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
// 生成Claude Setup Token URL
|
// 生成Claude Setup Token URL
|
||||||
const generateClaudeSetupTokenUrl = async (proxyConfig) => {
|
const generateClaudeSetupTokenUrl = async (proxyConfig) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await httpApi.generateClaudeSetupTokenUrl(proxyConfig)
|
||||||
'/admin/claude-accounts/generate-setup-token-url',
|
|
||||||
proxyConfig
|
|
||||||
)
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.data // 返回整个对象,包含authUrl和sessionId
|
return response.data // 返回整个对象,包含authUrl和sessionId
|
||||||
} else {
|
} else {
|
||||||
@@ -735,10 +732,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
// 交换Claude Setup Token Code
|
// 交换Claude Setup Token Code
|
||||||
const exchangeClaudeSetupTokenCode = async (data) => {
|
const exchangeClaudeSetupTokenCode = async (data) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await httpApi.exchangeClaudeSetupToken(data)
|
||||||
'/admin/claude-accounts/exchange-setup-token-code',
|
|
||||||
data
|
|
||||||
)
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.data
|
return response.data
|
||||||
} else {
|
} else {
|
||||||
@@ -753,7 +747,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
// Cookie自动授权 - 普通OAuth
|
// Cookie自动授权 - 普通OAuth
|
||||||
const oauthWithCookie = async (payload) => {
|
const oauthWithCookie = async (payload) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/claude-accounts/oauth-with-cookie', payload)
|
const response = await httpApi.claudeOAuthWithCookie(payload)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.data
|
return response.data
|
||||||
} else {
|
} else {
|
||||||
@@ -768,10 +762,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
// Cookie自动授权 - Setup Token
|
// Cookie自动授权 - Setup Token
|
||||||
const oauthSetupTokenWithCookie = async (payload) => {
|
const oauthSetupTokenWithCookie = async (payload) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await httpApi.claudeSetupTokenWithCookie(payload)
|
||||||
'/admin/claude-accounts/setup-token-with-cookie',
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.data
|
return response.data
|
||||||
} else {
|
} else {
|
||||||
@@ -786,7 +777,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
// 生成Gemini OAuth URL
|
// 生成Gemini OAuth URL
|
||||||
const generateGeminiAuthUrl = async (proxyConfig) => {
|
const generateGeminiAuthUrl = async (proxyConfig) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/gemini-accounts/generate-auth-url', proxyConfig)
|
const response = await httpApi.generateGeminiAuthUrl(proxyConfig)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.data // 返回整个对象,包含authUrl和sessionId
|
return response.data // 返回整个对象,包含authUrl和sessionId
|
||||||
} else {
|
} else {
|
||||||
@@ -801,7 +792,7 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
// 交换Gemini OAuth Code
|
// 交换Gemini OAuth Code
|
||||||
const exchangeGeminiCode = async (data) => {
|
const exchangeGeminiCode = async (data) => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/gemini-accounts/exchange-code', data)
|
const response = await httpApi.exchangeGeminiCode(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.data
|
return response.data
|
||||||
} else {
|
} else {
|
||||||
@@ -815,60 +806,33 @@ export const useAccountsStore = defineStore('accounts', () => {
|
|||||||
|
|
||||||
// 生成OpenAI OAuth URL
|
// 生成OpenAI OAuth URL
|
||||||
const generateOpenAIAuthUrl = async (proxyConfig) => {
|
const generateOpenAIAuthUrl = async (proxyConfig) => {
|
||||||
try {
|
const response = await httpApi.generateOpenAIAuthUrl(proxyConfig)
|
||||||
const response = await apiClient.post('/admin/openai-accounts/generate-auth-url', proxyConfig)
|
if (response.success) return response.data
|
||||||
if (response.success) {
|
error.value = response.message || '生成授权URL失败'
|
||||||
return response.data // 返回整个对象,包含authUrl和sessionId
|
return null
|
||||||
} else {
|
|
||||||
throw new Error(response.message || '生成授权URL失败')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err.message
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成Droid OAuth URL
|
// 生成Droid OAuth URL
|
||||||
const generateDroidAuthUrl = async (proxyConfig) => {
|
const generateDroidAuthUrl = async (proxyConfig) => {
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
const response = await httpApi.generateDroidAuthUrl(proxyConfig)
|
||||||
const response = await apiClient.post('/admin/droid-accounts/generate-auth-url', proxyConfig)
|
if (response.success) return response.data
|
||||||
if (response.success) {
|
error.value = response.message || '生成授权URL失败'
|
||||||
return response.data
|
return null
|
||||||
} else {
|
|
||||||
throw new Error(response.message || '生成授权URL失败')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err.message
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 交换OpenAI OAuth Code
|
// 交换OpenAI OAuth Code
|
||||||
const exchangeOpenAICode = async (data) => {
|
const exchangeOpenAICode = async (data) => {
|
||||||
try {
|
const response = await httpApi.exchangeOpenAICode(data)
|
||||||
const response = await apiClient.post('/admin/openai-accounts/exchange-code', data)
|
if (response.success) return response.data
|
||||||
if (response.success) {
|
error.value = response.message || '交换授权码失败'
|
||||||
return response.data
|
return null
|
||||||
} else {
|
|
||||||
throw new Error(response.message || '交换授权码失败')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err.message
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 交换Droid OAuth Code
|
// 交换Droid OAuth Code
|
||||||
const exchangeDroidCode = async (data) => {
|
const exchangeDroidCode = async (data) => {
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
return await httpApi.exchangeDroidCode(data)
|
||||||
const response = await apiClient.post('/admin/droid-accounts/exchange-code', data)
|
|
||||||
return response
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err.message
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序账户
|
// 排序账户
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { apiClient } from '@/config/api'
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import {
|
||||||
|
getApiKeys,
|
||||||
|
createApiKey as apiCreateApiKey,
|
||||||
|
updateApiKey as apiUpdateApiKey,
|
||||||
|
toggleApiKey as apiToggleApiKey,
|
||||||
|
deleteApiKey as apiDeleteApiKey,
|
||||||
|
getApiKeyStats,
|
||||||
|
getApiKeyTags
|
||||||
|
} from '@/utils/http_apis'
|
||||||
|
|
||||||
export const useApiKeysStore = defineStore('apiKeys', () => {
|
export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||||
// 状态
|
|
||||||
const apiKeys = ref([])
|
const apiKeys = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
@@ -11,14 +18,11 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
const sortBy = ref('')
|
const sortBy = ref('')
|
||||||
const sortOrder = ref('asc')
|
const sortOrder = ref('asc')
|
||||||
|
|
||||||
// Actions
|
|
||||||
|
|
||||||
// 获取API Keys列表
|
|
||||||
const fetchApiKeys = async () => {
|
const fetchApiKeys = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/api-keys')
|
const response = await getApiKeys()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
apiKeys.value = response.data || []
|
apiKeys.value = response.data || []
|
||||||
} else {
|
} else {
|
||||||
@@ -32,12 +36,11 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建API Key
|
|
||||||
const createApiKey = async (data) => {
|
const createApiKey = async (data) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/api-keys', data)
|
const response = await apiCreateApiKey(data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchApiKeys()
|
await fetchApiKeys()
|
||||||
return response.data
|
return response.data
|
||||||
@@ -52,12 +55,11 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新API Key
|
|
||||||
const updateApiKey = async (id, data) => {
|
const updateApiKey = async (id, data) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/api-keys/${id}`, data)
|
const response = await apiUpdateApiKey(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchApiKeys()
|
await fetchApiKeys()
|
||||||
return response
|
return response
|
||||||
@@ -72,12 +74,11 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换API Key状态
|
|
||||||
const toggleApiKey = async (id) => {
|
const toggleApiKey = async (id) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/api-keys/${id}/toggle`)
|
const response = await apiToggleApiKey(id)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchApiKeys()
|
await fetchApiKeys()
|
||||||
return response
|
return response
|
||||||
@@ -92,12 +93,11 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 续期API Key
|
|
||||||
const renewApiKey = async (id, data) => {
|
const renewApiKey = async (id, data) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.put(`/admin/api-keys/${id}`, data)
|
const response = await apiUpdateApiKey(id, data)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchApiKeys()
|
await fetchApiKeys()
|
||||||
return response
|
return response
|
||||||
@@ -112,12 +112,11 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除API Key
|
|
||||||
const deleteApiKey = async (id) => {
|
const deleteApiKey = async (id) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.delete(`/admin/api-keys/${id}`)
|
const response = await apiDeleteApiKey(id)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchApiKeys()
|
await fetchApiKeys()
|
||||||
return response
|
return response
|
||||||
@@ -132,12 +131,9 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取API Key统计
|
|
||||||
const fetchApiKeyStats = async (id, timeRange = 'all') => {
|
const fetchApiKeyStats = async (id, timeRange = 'all') => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/admin/api-keys/${id}/stats`, {
|
const response = await getApiKeyStats(id, { timeRange })
|
||||||
params: { timeRange }
|
|
||||||
})
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.stats
|
return response.stats
|
||||||
} else {
|
} else {
|
||||||
@@ -149,7 +145,6 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序API Keys
|
|
||||||
const sortApiKeys = (field) => {
|
const sortApiKeys = (field) => {
|
||||||
if (sortBy.value === field) {
|
if (sortBy.value === field) {
|
||||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||||
@@ -159,10 +154,9 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取已存在的标签
|
|
||||||
const fetchTags = async () => {
|
const fetchTags = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/api-keys/tags')
|
const response = await getApiKeyTags()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.data || []
|
return response.data || []
|
||||||
} else {
|
} else {
|
||||||
@@ -174,7 +168,6 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置store
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
apiKeys.value = []
|
apiKeys.value = []
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -185,15 +178,12 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
|
||||||
apiKeys,
|
apiKeys,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
statsTimeRange,
|
statsTimeRange,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
|
||||||
// Actions
|
|
||||||
fetchApiKeys,
|
fetchApiKeys,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
updateApiKey,
|
updateApiKey,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { apiStatsClient } from '@/config/apiStats'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
|
|
||||||
export const useApiStatsStore = defineStore('apistats', () => {
|
export const useApiStatsStore = defineStore('apistats', () => {
|
||||||
// 状态
|
// 状态
|
||||||
@@ -13,8 +13,12 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
const statsPeriod = ref('daily')
|
const statsPeriod = ref('daily')
|
||||||
const statsData = ref(null)
|
const statsData = ref(null)
|
||||||
const modelStats = ref([])
|
const modelStats = ref([])
|
||||||
|
const dailyModelStats = ref([])
|
||||||
|
const monthlyModelStats = ref([])
|
||||||
|
const alltimeModelStats = ref([])
|
||||||
const dailyStats = ref(null)
|
const dailyStats = ref(null)
|
||||||
const monthlyStats = ref(null)
|
const monthlyStats = ref(null)
|
||||||
|
const alltimeStats = ref(null)
|
||||||
const oemSettings = ref({
|
const oemSettings = ref({
|
||||||
siteName: '',
|
siteName: '',
|
||||||
siteIcon: '',
|
siteIcon: '',
|
||||||
@@ -29,6 +33,9 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
const individualStats = ref([]) // 各个 Key 的独立数据
|
const individualStats = ref([]) // 各个 Key 的独立数据
|
||||||
const invalidKeys = ref([]) // 无效的 Keys 列表
|
const invalidKeys = ref([]) // 无效的 Keys 列表
|
||||||
|
|
||||||
|
// 服务倍率配置
|
||||||
|
const serviceRates = ref(null)
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const currentPeriodData = computed(() => {
|
const currentPeriodData = computed(() => {
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
@@ -46,16 +53,20 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
if (multiKeyMode.value && aggregatedStats.value) {
|
if (multiKeyMode.value && aggregatedStats.value) {
|
||||||
if (statsPeriod.value === 'daily') {
|
if (statsPeriod.value === 'daily') {
|
||||||
return aggregatedStats.value.dailyUsage || defaultData
|
return aggregatedStats.value.dailyUsage || defaultData
|
||||||
} else {
|
} else if (statsPeriod.value === 'monthly') {
|
||||||
return aggregatedStats.value.monthlyUsage || defaultData
|
return aggregatedStats.value.monthlyUsage || defaultData
|
||||||
|
} else {
|
||||||
|
return aggregatedStats.value.alltimeUsage || defaultData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单个 Key 模式下使用原有逻辑
|
// 单个 Key 模式下使用原有逻辑
|
||||||
if (statsPeriod.value === 'daily') {
|
if (statsPeriod.value === 'daily') {
|
||||||
return dailyStats.value || defaultData
|
return dailyStats.value || defaultData
|
||||||
} else {
|
} else if (statsPeriod.value === 'monthly') {
|
||||||
return monthlyStats.value || defaultData
|
return monthlyStats.value || defaultData
|
||||||
|
} else {
|
||||||
|
return alltimeStats.value || defaultData
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -113,13 +124,13 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取 API Key ID
|
// 获取 API Key ID
|
||||||
const idResult = await apiStatsClient.getKeyId(trimmedKey)
|
const idResult = await httpApi.getKeyId(trimmedKey)
|
||||||
|
|
||||||
if (idResult.success) {
|
if (idResult.success) {
|
||||||
apiId.value = idResult.data.id
|
apiId.value = idResult.data.id
|
||||||
|
|
||||||
// 使用 apiId 查询统计数据
|
// 使用 apiId 查询统计数据
|
||||||
const statsResult = await apiStatsClient.getUserStats(apiId.value)
|
const statsResult = await httpApi.getUserStats(apiId.value)
|
||||||
|
|
||||||
if (statsResult.success) {
|
if (statsResult.success) {
|
||||||
statsData.value = statsResult.data
|
statsData.value = statsResult.data
|
||||||
@@ -132,6 +143,9 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
|
|
||||||
// 更新 URL
|
// 更新 URL
|
||||||
updateURL()
|
updateURL()
|
||||||
|
|
||||||
|
// 保存 API Key 到 localStorage
|
||||||
|
saveApiKeyToStorage()
|
||||||
} else {
|
} else {
|
||||||
throw new Error(statsResult.message || '查询失败')
|
throw new Error(statsResult.message || '查询失败')
|
||||||
}
|
}
|
||||||
@@ -156,14 +170,44 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
// 并行加载今日和本月的数据
|
// 并行加载今日和本月的数据
|
||||||
await Promise.all([loadPeriodStats('daily'), loadPeriodStats('monthly')])
|
await Promise.all([loadPeriodStats('daily'), loadPeriodStats('monthly')])
|
||||||
|
|
||||||
// 加载当前选择时间段的模型统计
|
// 并行加载三个时间段的模型统计
|
||||||
await loadModelStats(statsPeriod.value)
|
await loadAllModelStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载所有时间段的模型统计
|
||||||
|
async function loadAllModelStats() {
|
||||||
|
if (!apiId.value) return
|
||||||
|
|
||||||
|
modelStatsLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [dailyResult, monthlyResult, alltimeResult] = await Promise.all([
|
||||||
|
httpApi.getUserModelStats(apiId.value, 'daily'),
|
||||||
|
httpApi.getUserModelStats(apiId.value, 'monthly'),
|
||||||
|
httpApi.getUserModelStats(apiId.value, 'alltime')
|
||||||
|
])
|
||||||
|
|
||||||
|
dailyModelStats.value = dailyResult.success ? dailyResult.data || [] : []
|
||||||
|
monthlyModelStats.value = monthlyResult.success ? monthlyResult.data || [] : []
|
||||||
|
alltimeModelStats.value = alltimeResult.success ? alltimeResult.data || [] : []
|
||||||
|
|
||||||
|
// 保持 modelStats 兼容性(用于现有组件)
|
||||||
|
modelStats.value = dailyModelStats.value
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load all model stats error:', err)
|
||||||
|
dailyModelStats.value = []
|
||||||
|
monthlyModelStats.value = []
|
||||||
|
alltimeModelStats.value = []
|
||||||
|
modelStats.value = []
|
||||||
|
} finally {
|
||||||
|
modelStatsLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载指定时间段的统计数据
|
// 加载指定时间段的统计数据
|
||||||
async function loadPeriodStats(period) {
|
async function loadPeriodStats(period) {
|
||||||
try {
|
try {
|
||||||
const result = await apiStatsClient.getUserModelStats(apiId.value, period)
|
const result = await httpApi.getUserModelStats(apiId.value, period)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 计算汇总数据
|
// 计算汇总数据
|
||||||
@@ -194,8 +238,10 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
// 存储到对应的时间段数据
|
// 存储到对应的时间段数据
|
||||||
if (period === 'daily') {
|
if (period === 'daily') {
|
||||||
dailyStats.value = summary
|
dailyStats.value = summary
|
||||||
} else {
|
} else if (period === 'monthly') {
|
||||||
monthlyStats.value = summary
|
monthlyStats.value = summary
|
||||||
|
} else if (period === 'alltime') {
|
||||||
|
alltimeStats.value = summary
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Failed to load ${period} stats:`, result.message)
|
console.warn(`Failed to load ${period} stats:`, result.message)
|
||||||
@@ -212,7 +258,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
modelStatsLoading.value = true
|
modelStatsLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiStatsClient.getUserModelStats(apiId.value, period)
|
const result = await httpApi.getUserModelStats(apiId.value, period)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
modelStats.value = result.data || []
|
modelStats.value = result.data || []
|
||||||
@@ -244,7 +290,8 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
// 如果对应时间段的数据还没有加载,则加载它
|
// 如果对应时间段的数据还没有加载,则加载它
|
||||||
if (
|
if (
|
||||||
(period === 'daily' && !dailyStats.value) ||
|
(period === 'daily' && !dailyStats.value) ||
|
||||||
(period === 'monthly' && !monthlyStats.value)
|
(period === 'monthly' && !monthlyStats.value) ||
|
||||||
|
(period === 'alltime' && !alltimeStats.value)
|
||||||
) {
|
) {
|
||||||
await loadPeriodStats(period)
|
await loadPeriodStats(period)
|
||||||
}
|
}
|
||||||
@@ -263,7 +310,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
modelStats.value = []
|
modelStats.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiStatsClient.getUserStats(apiId.value)
|
const result = await httpApi.getUserStats(apiId.value)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
statsData.value = result.data
|
statsData.value = result.data
|
||||||
@@ -296,7 +343,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
async function loadOemSettings() {
|
async function loadOemSettings() {
|
||||||
oemLoading.value = true
|
oemLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await apiStatsClient.getOemSettings()
|
const result = await httpApi.getOemSettings()
|
||||||
if (result && result.success && result.data) {
|
if (result && result.success && result.data) {
|
||||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||||
}
|
}
|
||||||
@@ -313,6 +360,19 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载服务倍率配置
|
||||||
|
async function loadServiceRates() {
|
||||||
|
try {
|
||||||
|
const result = await httpApi.getServiceRates()
|
||||||
|
if (result && result.success && result.data) {
|
||||||
|
serviceRates.value = result.data
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading service rates:', err)
|
||||||
|
serviceRates.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 工具函数
|
// 工具函数
|
||||||
|
|
||||||
// 格式化费用
|
// 格式化费用
|
||||||
@@ -340,6 +400,18 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存 API Key 到 localStorage
|
||||||
|
function saveApiKeyToStorage() {
|
||||||
|
if (apiKey.value) {
|
||||||
|
localStorage.setItem('lastApiKey', apiKey.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 localStorage 加载 API Key
|
||||||
|
function loadApiKeyFromStorage() {
|
||||||
|
return localStorage.getItem('lastApiKey')
|
||||||
|
}
|
||||||
|
|
||||||
// 批量查询统计数据
|
// 批量查询统计数据
|
||||||
async function queryBatchStats() {
|
async function queryBatchStats() {
|
||||||
const keys = parseApiKeys()
|
const keys = parseApiKeys()
|
||||||
@@ -359,7 +431,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 批量获取 API Key IDs
|
// 批量获取 API Key IDs
|
||||||
const idResults = await Promise.allSettled(keys.map((key) => apiStatsClient.getKeyId(key)))
|
const idResults = await Promise.allSettled(keys.map((key) => httpApi.getKeyId(key)))
|
||||||
|
|
||||||
const validIds = []
|
const validIds = []
|
||||||
const validKeys = []
|
const validKeys = []
|
||||||
@@ -381,7 +453,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
apiKeys.value = validKeys
|
apiKeys.value = validKeys
|
||||||
|
|
||||||
// 批量查询统计数据
|
// 批量查询统计数据
|
||||||
const batchResult = await apiStatsClient.getBatchStats(validIds)
|
const batchResult = await httpApi.getBatchStats(validIds)
|
||||||
|
|
||||||
if (batchResult.success) {
|
if (batchResult.success) {
|
||||||
aggregatedStats.value = batchResult.data.aggregated
|
aggregatedStats.value = batchResult.data.aggregated
|
||||||
@@ -417,7 +489,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
modelStatsLoading.value = true
|
modelStatsLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiStatsClient.getBatchModelStats(apiIds.value, period)
|
const result = await httpApi.getBatchModelStats(apiIds.value, period)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
modelStats.value = result.data || []
|
modelStats.value = result.data || []
|
||||||
@@ -465,8 +537,12 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
function clearData() {
|
function clearData() {
|
||||||
statsData.value = null
|
statsData.value = null
|
||||||
modelStats.value = []
|
modelStats.value = []
|
||||||
|
dailyModelStats.value = []
|
||||||
|
monthlyModelStats.value = []
|
||||||
|
alltimeModelStats.value = []
|
||||||
dailyStats.value = null
|
dailyStats.value = null
|
||||||
monthlyStats.value = null
|
monthlyStats.value = null
|
||||||
|
alltimeStats.value = null
|
||||||
error.value = ''
|
error.value = ''
|
||||||
statsPeriod.value = 'daily'
|
statsPeriod.value = 'daily'
|
||||||
apiId.value = null
|
apiId.value = null
|
||||||
@@ -496,8 +572,12 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
statsPeriod,
|
statsPeriod,
|
||||||
statsData,
|
statsData,
|
||||||
modelStats,
|
modelStats,
|
||||||
|
dailyModelStats,
|
||||||
|
monthlyModelStats,
|
||||||
|
alltimeModelStats,
|
||||||
dailyStats,
|
dailyStats,
|
||||||
monthlyStats,
|
monthlyStats,
|
||||||
|
alltimeStats,
|
||||||
oemSettings,
|
oemSettings,
|
||||||
// 多 Key 模式状态
|
// 多 Key 模式状态
|
||||||
multiKeyMode,
|
multiKeyMode,
|
||||||
@@ -506,6 +586,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
aggregatedStats,
|
aggregatedStats,
|
||||||
individualStats,
|
individualStats,
|
||||||
invalidKeys,
|
invalidKeys,
|
||||||
|
serviceRates,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
currentPeriodData,
|
currentPeriodData,
|
||||||
@@ -515,12 +596,15 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
|||||||
queryStats,
|
queryStats,
|
||||||
queryBatchStats,
|
queryBatchStats,
|
||||||
loadAllPeriodStats,
|
loadAllPeriodStats,
|
||||||
|
loadAllModelStats,
|
||||||
loadPeriodStats,
|
loadPeriodStats,
|
||||||
loadModelStats,
|
loadModelStats,
|
||||||
loadBatchModelStats,
|
loadBatchModelStats,
|
||||||
switchPeriod,
|
switchPeriod,
|
||||||
loadStatsWithApiId,
|
loadStatsWithApiId,
|
||||||
loadOemSettings,
|
loadOemSettings,
|
||||||
|
loadServiceRates,
|
||||||
|
loadApiKeyFromStorage,
|
||||||
clearData,
|
clearData,
|
||||||
clearInput,
|
clearInput,
|
||||||
reset
|
reset
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { apiClient } from '@/config/api'
|
import { login as apiLogin, getAuthUser, getOemSettings } from '@/utils/http_apis'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
// 状态
|
// 状态
|
||||||
@@ -29,7 +29,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
loginError.value = ''
|
loginError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.post('/web/auth/login', credentials)
|
const result = await apiLogin(credentials)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
authToken.value = result.token
|
authToken.value = result.token
|
||||||
@@ -66,16 +66,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
async function verifyToken() {
|
async function verifyToken() {
|
||||||
try {
|
try {
|
||||||
// /web/auth/user 已做完整 token 验证(session 存在性、完整性)
|
const userResult = await getAuthUser()
|
||||||
// 成功返回即表示 token 有效,无需再调用 dashboard
|
|
||||||
const userResult = await apiClient.get('/web/auth/user')
|
|
||||||
if (!userResult.success || !userResult.user) {
|
if (!userResult.success || !userResult.user) {
|
||||||
logout()
|
logout()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
username.value = userResult.user.username
|
username.value = userResult.user.username
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// token 无效,需要重新登录
|
|
||||||
logout()
|
logout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,11 +80,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
async function loadOemSettings() {
|
async function loadOemSettings() {
|
||||||
oemLoading.value = true
|
oemLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.get('/admin/oem-settings')
|
const result = await getOemSettings()
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||||
|
|
||||||
// 设置favicon
|
|
||||||
if (result.data.siteIconData || result.data.siteIcon) {
|
if (result.data.siteIconData || result.data.siteIcon) {
|
||||||
const link = document.querySelector("link[rel*='icon']") || document.createElement('link')
|
const link = document.querySelector("link[rel*='icon']") || document.createElement('link')
|
||||||
link.type = 'image/x-icon'
|
link.type = 'image/x-icon'
|
||||||
@@ -96,7 +92,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
document.getElementsByTagName('head')[0].appendChild(link)
|
document.getElementsByTagName('head')[0].appendChild(link)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置页面标题
|
|
||||||
if (result.data.siteName) {
|
if (result.data.siteName) {
|
||||||
document.title = `${result.data.siteName} - 管理后台`
|
document.title = `${result.data.siteName} - 管理后台`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { apiClient } from '@/config/api'
|
import { getSupportedClients } from '@/utils/http_apis'
|
||||||
|
|
||||||
export const useClientsStore = defineStore('clients', {
|
export const useClientsStore = defineStore('clients', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -11,7 +11,6 @@ export const useClientsStore = defineStore('clients', {
|
|||||||
actions: {
|
actions: {
|
||||||
async loadSupportedClients() {
|
async loadSupportedClients() {
|
||||||
if (this.supportedClients.length > 0) {
|
if (this.supportedClients.length > 0) {
|
||||||
// 如果已经加载过,不重复加载
|
|
||||||
return this.supportedClients
|
return this.supportedClients
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +18,7 @@ export const useClientsStore = defineStore('clients', {
|
|||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/supported-clients')
|
const response = await getSupportedClients()
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.supportedClients = response.data || []
|
this.supportedClients = response.data || []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { apiClient } from '@/config/api'
|
import { getDashboard, getUsageCosts, getUsageStats } from '@/utils/http_apis'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
|
|
||||||
export const useDashboardStore = defineStore('dashboard', () => {
|
export const useDashboardStore = defineStore('dashboard', () => {
|
||||||
// 状态
|
// 状态
|
||||||
@@ -221,9 +221,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [dashboardResponse, todayCostsResponse, totalCostsResponse] = await Promise.all([
|
const [dashboardResponse, todayCostsResponse, totalCostsResponse] = await Promise.all([
|
||||||
apiClient.get('/admin/dashboard'),
|
getDashboard(),
|
||||||
apiClient.get(`/admin/usage-costs?period=${costsParams.today}`),
|
getUsageCosts(costsParams.today),
|
||||||
apiClient.get(`/admin/usage-costs?period=${costsParams.all}`)
|
getUsageCosts(costsParams.all)
|
||||||
])
|
])
|
||||||
|
|
||||||
if (dashboardResponse.success) {
|
if (dashboardResponse.success) {
|
||||||
@@ -363,7 +363,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
url += `granularity=day&days=${days}`
|
url += `granularity=day&days=${days}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.get(url)
|
const response = await getUsageStats(url)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
trendData.value = response.data
|
trendData.value = response.data
|
||||||
}
|
}
|
||||||
@@ -448,7 +448,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.get(url)
|
const response = await getUsageStats(url)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
dashboardModelStats.value = response.data
|
dashboardModelStats.value = response.data
|
||||||
}
|
}
|
||||||
@@ -535,7 +535,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
|
|
||||||
url += `&metric=${metric}`
|
url += `&metric=${metric}`
|
||||||
|
|
||||||
const response = await apiClient.get(url)
|
const response = await getUsageStats(url)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
apiKeysTrendData.value = {
|
apiKeysTrendData.value = {
|
||||||
data: response.data || [],
|
data: response.data || [],
|
||||||
@@ -618,7 +618,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
|||||||
|
|
||||||
url += `&group=${group}`
|
url += `&group=${group}`
|
||||||
|
|
||||||
const response = await apiClient.get(url)
|
const response = await getUsageStats(url)
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
accountUsageTrendData.value = {
|
accountUsageTrendData.value = {
|
||||||
data: response.data || [],
|
data: response.data || [],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { apiClient } from '@/config/api'
|
import { getOemSettings, updateOemSettings } from '@/utils/http_apis'
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
// 状态
|
// 状态
|
||||||
@@ -8,25 +8,22 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
siteName: 'Claude Relay Service',
|
siteName: 'Claude Relay Service',
|
||||||
siteIcon: '',
|
siteIcon: '',
|
||||||
siteIconData: '',
|
siteIconData: '',
|
||||||
showAdminButton: true, // 控制管理后台按钮的显示
|
showAdminButton: true,
|
||||||
|
apiStatsNotice: { enabled: false, title: '', content: '' },
|
||||||
updatedAt: null
|
updatedAt: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
// 移除自定义API请求方法,使用统一的apiClient
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const loadOemSettings = async () => {
|
const loadOemSettings = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.get('/admin/oem-settings')
|
const result = await getOemSettings()
|
||||||
|
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||||
|
|
||||||
// 应用设置到页面
|
|
||||||
applyOemSettings()
|
applyOemSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,12 +39,10 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const saveOemSettings = async (settings) => {
|
const saveOemSettings = async (settings) => {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.put('/admin/oem-settings', settings)
|
const result = await updateOemSettings(settings)
|
||||||
|
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||||
|
|
||||||
// 应用设置到页面
|
|
||||||
applyOemSettings()
|
applyOemSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +61,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
siteIcon: '',
|
siteIcon: '',
|
||||||
siteIconData: '',
|
siteIconData: '',
|
||||||
showAdminButton: true,
|
showAdminButton: true,
|
||||||
|
apiStatsNotice: { enabled: false, title: '', content: '' },
|
||||||
updatedAt: null
|
updatedAt: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,158 @@ export const ThemeMode = {
|
|||||||
AUTO: 'auto'
|
AUTO: 'auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 中国传统色系预设
|
||||||
|
export const ColorSchemes = {
|
||||||
|
purple: {
|
||||||
|
name: '默认紫',
|
||||||
|
nameEn: 'Purple',
|
||||||
|
primary: '#667eea',
|
||||||
|
secondary: '#764ba2',
|
||||||
|
accent: '#f093fb',
|
||||||
|
gradientStart: '#667eea',
|
||||||
|
gradientMid: '#764ba2',
|
||||||
|
gradientEnd: '#f093fb',
|
||||||
|
// 玻璃态背景色(亮色模式)
|
||||||
|
glassStrong: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
glass: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
// 暗黑模式
|
||||||
|
darkPrimary: '#818cf8',
|
||||||
|
darkSecondary: '#a78bfa',
|
||||||
|
darkAccent: '#c084fc',
|
||||||
|
darkGradientStart: '#1f2937',
|
||||||
|
darkGradientMid: '#374151',
|
||||||
|
darkGradientEnd: '#4b5563',
|
||||||
|
darkGlassStrong: 'rgba(31, 41, 55, 0.95)',
|
||||||
|
darkGlass: 'rgba(0, 0, 0, 0.2)'
|
||||||
|
},
|
||||||
|
celadon: {
|
||||||
|
name: '青瓷',
|
||||||
|
nameEn: 'Celadon',
|
||||||
|
primary: '#7faaaf',
|
||||||
|
secondary: '#5d8a8e',
|
||||||
|
accent: '#a8d8dc',
|
||||||
|
gradientStart: '#7faaaf',
|
||||||
|
gradientMid: '#5d8a8e',
|
||||||
|
gradientEnd: '#3d6a6e',
|
||||||
|
glassStrong: 'rgba(248, 253, 253, 0.95)',
|
||||||
|
glass: 'rgba(168, 216, 220, 0.1)',
|
||||||
|
darkPrimary: '#9fcacd',
|
||||||
|
darkSecondary: '#7daaae',
|
||||||
|
darkAccent: '#c8f8fc',
|
||||||
|
darkGradientStart: '#1a2a2b',
|
||||||
|
darkGradientMid: '#2a3a3b',
|
||||||
|
darkGradientEnd: '#3a4a4b',
|
||||||
|
darkGlassStrong: 'rgba(26, 42, 43, 0.95)',
|
||||||
|
darkGlass: 'rgba(0, 20, 20, 0.2)'
|
||||||
|
},
|
||||||
|
cinnabar: {
|
||||||
|
name: '朱砂',
|
||||||
|
nameEn: 'Cinnabar',
|
||||||
|
primary: '#c45a5a',
|
||||||
|
secondary: '#8b3a3a',
|
||||||
|
accent: '#e8a0a0',
|
||||||
|
gradientStart: '#c45a5a',
|
||||||
|
gradientMid: '#8b3a3a',
|
||||||
|
gradientEnd: '#5c2a2a',
|
||||||
|
glassStrong: 'rgba(255, 252, 252, 0.95)',
|
||||||
|
glass: 'rgba(232, 160, 160, 0.1)',
|
||||||
|
darkPrimary: '#e47a7a',
|
||||||
|
darkSecondary: '#ab5a5a',
|
||||||
|
darkAccent: '#f8c0c0',
|
||||||
|
darkGradientStart: '#2a1a1a',
|
||||||
|
darkGradientMid: '#3a2a2a',
|
||||||
|
darkGradientEnd: '#4a3a3a',
|
||||||
|
darkGlassStrong: 'rgba(42, 26, 26, 0.95)',
|
||||||
|
darkGlass: 'rgba(20, 0, 0, 0.2)'
|
||||||
|
},
|
||||||
|
jade: {
|
||||||
|
name: '墨玉',
|
||||||
|
nameEn: 'Jade',
|
||||||
|
primary: '#4a7c59',
|
||||||
|
secondary: '#2d5a3d',
|
||||||
|
accent: '#7eb08c',
|
||||||
|
gradientStart: '#4a7c59',
|
||||||
|
gradientMid: '#2d5a3d',
|
||||||
|
gradientEnd: '#1a3d28',
|
||||||
|
glassStrong: 'rgba(250, 255, 252, 0.95)',
|
||||||
|
glass: 'rgba(126, 176, 140, 0.1)',
|
||||||
|
darkPrimary: '#6a9c79',
|
||||||
|
darkSecondary: '#4d7a5d',
|
||||||
|
darkAccent: '#9ed0ac',
|
||||||
|
darkGradientStart: '#1a2a1e',
|
||||||
|
darkGradientMid: '#2a3a2e',
|
||||||
|
darkGradientEnd: '#3a4a3e',
|
||||||
|
darkGlassStrong: 'rgba(26, 42, 30, 0.95)',
|
||||||
|
darkGlass: 'rgba(0, 20, 10, 0.2)'
|
||||||
|
},
|
||||||
|
indigo: {
|
||||||
|
name: '藏蓝',
|
||||||
|
nameEn: 'Indigo',
|
||||||
|
primary: '#3a5a8c',
|
||||||
|
secondary: '#2a4066',
|
||||||
|
accent: '#6a8ab8',
|
||||||
|
gradientStart: '#3a5a8c',
|
||||||
|
gradientMid: '#2a4066',
|
||||||
|
gradientEnd: '#1a2a44',
|
||||||
|
glassStrong: 'rgba(250, 252, 255, 0.95)',
|
||||||
|
glass: 'rgba(106, 138, 184, 0.1)',
|
||||||
|
darkPrimary: '#5a7aac',
|
||||||
|
darkSecondary: '#4a6086',
|
||||||
|
darkAccent: '#8aaad8',
|
||||||
|
darkGradientStart: '#1a1a2a',
|
||||||
|
darkGradientMid: '#2a2a3a',
|
||||||
|
darkGradientEnd: '#3a3a4a',
|
||||||
|
darkGlassStrong: 'rgba(26, 26, 42, 0.95)',
|
||||||
|
darkGlass: 'rgba(0, 0, 20, 0.2)'
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
name: '琥珀',
|
||||||
|
nameEn: 'Amber',
|
||||||
|
primary: '#c49a3a',
|
||||||
|
secondary: '#8b6914',
|
||||||
|
accent: '#e8c86a',
|
||||||
|
gradientStart: '#c49a3a',
|
||||||
|
gradientMid: '#8b6914',
|
||||||
|
gradientEnd: '#5c4a0a',
|
||||||
|
glassStrong: 'rgba(255, 253, 248, 0.95)',
|
||||||
|
glass: 'rgba(232, 200, 106, 0.1)',
|
||||||
|
darkPrimary: '#e4ba5a',
|
||||||
|
darkSecondary: '#ab8934',
|
||||||
|
darkAccent: '#f8e88a',
|
||||||
|
darkGradientStart: '#2a2a1a',
|
||||||
|
darkGradientMid: '#3a3a2a',
|
||||||
|
darkGradientEnd: '#4a4a3a',
|
||||||
|
darkGlassStrong: 'rgba(42, 42, 26, 0.95)',
|
||||||
|
darkGlass: 'rgba(20, 20, 0, 0.2)'
|
||||||
|
},
|
||||||
|
rouge: {
|
||||||
|
name: '胭脂',
|
||||||
|
nameEn: 'Rouge',
|
||||||
|
primary: '#b85a6a',
|
||||||
|
secondary: '#8a3a4a',
|
||||||
|
accent: '#e8a0b0',
|
||||||
|
gradientStart: '#b85a6a',
|
||||||
|
gradientMid: '#8a3a4a',
|
||||||
|
gradientEnd: '#5c2a3a',
|
||||||
|
glassStrong: 'rgba(255, 250, 252, 0.95)',
|
||||||
|
glass: 'rgba(232, 160, 176, 0.1)',
|
||||||
|
darkPrimary: '#d87a8a',
|
||||||
|
darkSecondary: '#aa5a6a',
|
||||||
|
darkAccent: '#f8c0d0',
|
||||||
|
darkGradientStart: '#2a1a1e',
|
||||||
|
darkGradientMid: '#3a2a2e',
|
||||||
|
darkGradientEnd: '#4a3a3e',
|
||||||
|
darkGlassStrong: 'rgba(42, 26, 30, 0.95)',
|
||||||
|
darkGlass: 'rgba(20, 0, 10, 0.2)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useThemeStore = defineStore('theme', () => {
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
// 状态 - 支持三种模式:light, dark, auto
|
// 状态 - 支持三种模式:light, dark, auto
|
||||||
const themeMode = ref(ThemeMode.AUTO)
|
const themeMode = ref(ThemeMode.AUTO)
|
||||||
const systemPrefersDark = ref(false)
|
const systemPrefersDark = ref(false)
|
||||||
|
// 色系状态
|
||||||
|
const colorScheme = ref('purple')
|
||||||
|
|
||||||
// 计算属性 - 实际的暗黑模式状态
|
// 计算属性 - 实际的暗黑模式状态
|
||||||
const isDarkMode = computed(() => {
|
const isDarkMode = computed(() => {
|
||||||
@@ -30,6 +178,11 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
return isDarkMode.value ? ThemeMode.DARK : ThemeMode.LIGHT
|
return isDarkMode.value ? ThemeMode.DARK : ThemeMode.LIGHT
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 计算属性 - 当前色系配置
|
||||||
|
const currentColorScheme = computed(() => {
|
||||||
|
return ColorSchemes[colorScheme.value] || ColorSchemes.purple
|
||||||
|
})
|
||||||
|
|
||||||
// 初始化主题
|
// 初始化主题
|
||||||
const initTheme = () => {
|
const initTheme = () => {
|
||||||
// 检测系统主题偏好
|
// 检测系统主题偏好
|
||||||
@@ -46,6 +199,12 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
themeMode.value = ThemeMode.AUTO
|
themeMode.value = ThemeMode.AUTO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 localStorage 读取保存的色系
|
||||||
|
const savedColorScheme = localStorage.getItem('colorScheme')
|
||||||
|
if (savedColorScheme && ColorSchemes[savedColorScheme]) {
|
||||||
|
colorScheme.value = savedColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
// 应用主题
|
// 应用主题
|
||||||
applyTheme()
|
applyTheme()
|
||||||
|
|
||||||
@@ -62,6 +221,71 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
} else {
|
} else {
|
||||||
root.classList.remove('dark')
|
root.classList.remove('dark')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 应用色系 CSS 变量
|
||||||
|
applyColorScheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用色系 CSS 变量
|
||||||
|
const applyColorScheme = () => {
|
||||||
|
const root = document.documentElement
|
||||||
|
const scheme = currentColorScheme.value
|
||||||
|
const dark = isDarkMode.value
|
||||||
|
|
||||||
|
const primary = dark ? scheme.darkPrimary : scheme.primary
|
||||||
|
const secondary = dark ? scheme.darkSecondary : scheme.secondary
|
||||||
|
const accent = dark ? scheme.darkAccent : scheme.accent
|
||||||
|
|
||||||
|
// 设置主题色
|
||||||
|
root.style.setProperty('--primary-color', primary)
|
||||||
|
root.style.setProperty('--secondary-color', secondary)
|
||||||
|
root.style.setProperty('--accent-color', accent)
|
||||||
|
|
||||||
|
// 设置背景渐变
|
||||||
|
root.style.setProperty(
|
||||||
|
'--bg-gradient-start',
|
||||||
|
dark ? scheme.darkGradientStart : scheme.gradientStart
|
||||||
|
)
|
||||||
|
root.style.setProperty('--bg-gradient-mid', dark ? scheme.darkGradientMid : scheme.gradientMid)
|
||||||
|
root.style.setProperty('--bg-gradient-end', dark ? scheme.darkGradientEnd : scheme.gradientEnd)
|
||||||
|
|
||||||
|
// 设置玻璃态背景色
|
||||||
|
root.style.setProperty(
|
||||||
|
'--glass-strong-color',
|
||||||
|
dark ? scheme.darkGlassStrong : scheme.glassStrong
|
||||||
|
)
|
||||||
|
root.style.setProperty('--glass-color', dark ? scheme.darkGlass : scheme.glass)
|
||||||
|
|
||||||
|
// 设置表面颜色(卡片背景等)
|
||||||
|
root.style.setProperty('--surface-color', dark ? scheme.darkGlassStrong : scheme.glassStrong)
|
||||||
|
root.style.setProperty('--table-bg', dark ? scheme.darkGlassStrong : scheme.glassStrong)
|
||||||
|
root.style.setProperty('--input-bg', dark ? scheme.darkGlassStrong : scheme.glassStrong)
|
||||||
|
|
||||||
|
// 解析颜色为 RGB 值用于 rgba()
|
||||||
|
const hexToRgb = (hex) => {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||||
|
return result
|
||||||
|
? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
|
||||||
|
: '102, 126, 234'
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryRgb = hexToRgb(primary)
|
||||||
|
const secondaryRgb = hexToRgb(secondary)
|
||||||
|
const accentRgb = hexToRgb(accent)
|
||||||
|
|
||||||
|
// 设置 RGB 变量用于 rgba()
|
||||||
|
root.style.setProperty('--primary-rgb', primaryRgb)
|
||||||
|
root.style.setProperty('--secondary-rgb', secondaryRgb)
|
||||||
|
root.style.setProperty('--accent-rgb', accentRgb)
|
||||||
|
|
||||||
|
// 设置表格 hover 颜色(暗黑模式透明度更高)
|
||||||
|
root.style.setProperty('--table-hover', `rgba(${primaryRgb}, ${dark ? 0.1 : 0.05})`)
|
||||||
|
|
||||||
|
// 设置边框颜色(基于主题色)
|
||||||
|
root.style.setProperty('--border-color', `rgba(${primaryRgb}, ${dark ? 0.25 : 0.2})`)
|
||||||
|
|
||||||
|
// 设置输入框边框
|
||||||
|
root.style.setProperty('--input-border', `rgba(${primaryRgb}, ${dark ? 0.3 : 0.25})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置主题模式
|
// 设置主题模式
|
||||||
@@ -79,12 +303,33 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
themeMode.value = modes[nextIndex]
|
themeMode.value = modes[nextIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置色系
|
||||||
|
const setColorScheme = (scheme) => {
|
||||||
|
if (ColorSchemes[scheme]) {
|
||||||
|
colorScheme.value = scheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 循环切换色系
|
||||||
|
const cycleColorScheme = () => {
|
||||||
|
const schemes = Object.keys(ColorSchemes)
|
||||||
|
const currentIndex = schemes.indexOf(colorScheme.value)
|
||||||
|
const nextIndex = (currentIndex + 1) % schemes.length
|
||||||
|
colorScheme.value = schemes[nextIndex]
|
||||||
|
}
|
||||||
|
|
||||||
// 监听主题模式变化,自动保存到 localStorage 并应用
|
// 监听主题模式变化,自动保存到 localStorage 并应用
|
||||||
watch(themeMode, (newMode) => {
|
watch(themeMode, (newMode) => {
|
||||||
localStorage.setItem('themeMode', newMode)
|
localStorage.setItem('themeMode', newMode)
|
||||||
applyTheme()
|
applyTheme()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听色系变化,自动保存到 localStorage 并应用
|
||||||
|
watch(colorScheme, (newScheme) => {
|
||||||
|
localStorage.setItem('colorScheme', newScheme)
|
||||||
|
applyColorScheme()
|
||||||
|
})
|
||||||
|
|
||||||
// 监听系统主题偏好变化
|
// 监听系统主题偏好变化
|
||||||
watch(systemPrefersDark, () => {
|
watch(systemPrefersDark, () => {
|
||||||
// 只有在 auto 模式下才需要重新应用主题
|
// 只有在 auto 模式下才需要重新应用主题
|
||||||
@@ -132,15 +377,20 @@ export const useThemeStore = defineStore('theme', () => {
|
|||||||
isDarkMode,
|
isDarkMode,
|
||||||
currentTheme,
|
currentTheme,
|
||||||
systemPrefersDark,
|
systemPrefersDark,
|
||||||
|
colorScheme,
|
||||||
|
currentColorScheme,
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
|
ColorSchemes,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initTheme,
|
initTheme,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
cycleThemeMode,
|
cycleThemeMode,
|
||||||
watchSystemTheme,
|
watchSystemTheme,
|
||||||
|
setColorScheme,
|
||||||
|
cycleColorScheme,
|
||||||
|
|
||||||
// 兼容旧版 API
|
// 兼容旧版 API
|
||||||
toggleTheme,
|
toggleTheme,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { API_PREFIX } from '@/config/api'
|
import { API_PREFIX } from '@/utils/http_apis'
|
||||||
|
|
||||||
const API_BASE = `${API_PREFIX}/users`
|
const API_BASE = `${API_PREFIX}/users`
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
// 数字格式化函数
|
|
||||||
export function formatNumber(num) {
|
|
||||||
if (num === null || num === undefined) return '0'
|
|
||||||
|
|
||||||
const absNum = Math.abs(num)
|
|
||||||
|
|
||||||
if (absNum >= 1e9) {
|
|
||||||
return (num / 1e9).toFixed(2) + 'B'
|
|
||||||
} else if (absNum >= 1e6) {
|
|
||||||
return (num / 1e6).toFixed(2) + 'M'
|
|
||||||
} else if (absNum >= 1e3) {
|
|
||||||
return (num / 1e3).toFixed(1) + 'K'
|
|
||||||
}
|
|
||||||
|
|
||||||
return num.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 日期格式化函数
|
|
||||||
export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
|
|
||||||
if (!date) return ''
|
|
||||||
|
|
||||||
const d = new Date(date)
|
|
||||||
|
|
||||||
const year = d.getFullYear()
|
|
||||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(d.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(d.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
|
||||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
|
||||||
|
|
||||||
return format
|
|
||||||
.replace('YYYY', year)
|
|
||||||
.replace('MM', month)
|
|
||||||
.replace('DD', day)
|
|
||||||
.replace('HH', hours)
|
|
||||||
.replace('mm', minutes)
|
|
||||||
.replace('ss', seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 相对时间格式化
|
|
||||||
export function formatRelativeTime(date) {
|
|
||||||
if (!date) return ''
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const past = new Date(date)
|
|
||||||
const diffMs = now - past
|
|
||||||
const diffSecs = Math.floor(diffMs / 1000)
|
|
||||||
const diffMins = Math.floor(diffSecs / 60)
|
|
||||||
const diffHours = Math.floor(diffMins / 60)
|
|
||||||
const diffDays = Math.floor(diffHours / 24)
|
|
||||||
|
|
||||||
if (diffDays > 0) {
|
|
||||||
return `${diffDays}天前`
|
|
||||||
} else if (diffHours > 0) {
|
|
||||||
return `${diffHours}小时前`
|
|
||||||
} else if (diffMins > 0) {
|
|
||||||
return `${diffMins}分钟前`
|
|
||||||
} else {
|
|
||||||
return '刚刚'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字节格式化
|
|
||||||
export function formatBytes(bytes, decimals = 2) {
|
|
||||||
if (bytes === 0) return '0 Bytes'
|
|
||||||
|
|
||||||
const k = 1024
|
|
||||||
const dm = decimals < 0 ? 0 : decimals
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
|
||||||
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
|
||||||
}
|
|
||||||
262
web/admin-spa/src/utils/http_apis.js
Normal file
262
web/admin-spa/src/utils/http_apis.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { APP_CONFIG, getLoginUrl } from '@/config/app'
|
||||||
|
|
||||||
|
export const API_PREFIX = APP_CONFIG.apiPrefix
|
||||||
|
|
||||||
|
const axiosInstance = axios.create({
|
||||||
|
baseURL: APP_CONFIG.apiPrefix,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
axiosInstance.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('authToken')
|
||||||
|
if (token) config.headers['Authorization'] = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
(response) => response.data,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const path = window.location.pathname + window.location.hash
|
||||||
|
if (!path.includes('/login') && !path.endsWith('/')) {
|
||||||
|
localStorage.removeItem('authToken')
|
||||||
|
window.location.href = getLoginUrl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 通用请求函数 - 只会 resolve,调用方无需 try-catch
|
||||||
|
const request = async (config) => {
|
||||||
|
try {
|
||||||
|
return await axiosInstance(config)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Request failed:', error)
|
||||||
|
const data = error.response?.data
|
||||||
|
if (data && typeof data.success !== 'undefined') return data
|
||||||
|
const status = error.response?.status
|
||||||
|
const messages = {
|
||||||
|
401: '未授权,请重新登录',
|
||||||
|
403: '无权限访问',
|
||||||
|
404: '请求的资源不存在',
|
||||||
|
500: '服务器内部错误'
|
||||||
|
}
|
||||||
|
return { success: false, message: messages[status] || error.message || '请求失败' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = (url, config) => request({ method: 'get', url, ...config })
|
||||||
|
const post = (url, data, config) => request({ method: 'post', url, data, ...config })
|
||||||
|
const put = (url, data, config) => request({ method: 'put', url, data, ...config })
|
||||||
|
const patch = (url, data, config) => request({ method: 'patch', url, data, ...config })
|
||||||
|
const del = (url, config) => request({ method: 'delete', url, ...config })
|
||||||
|
|
||||||
|
// 模型
|
||||||
|
export const getModels = () => get('/apiStats/models')
|
||||||
|
export const getModelsByService = (service) => get('/apiStats/models', { params: { service } })
|
||||||
|
|
||||||
|
// API Key 测试
|
||||||
|
export const testClaudeApiKey = (data) => post('/apiStats/api-key/test', data)
|
||||||
|
export const testGeminiApiKey = (data) => post('/apiStats/api-key/test-gemini', data)
|
||||||
|
export const testOpenAIApiKey = (data) => post('/apiStats/api-key/test-openai', data)
|
||||||
|
|
||||||
|
// API Stats
|
||||||
|
export const getKeyId = (apiKey) => post('/apiStats/api/get-key-id', { apiKey })
|
||||||
|
export const getUserStats = (apiId) => post('/apiStats/api/user-stats', { apiId })
|
||||||
|
export const getUserModelStats = (apiId, period = 'daily') =>
|
||||||
|
post('/apiStats/api/user-model-stats', { apiId, period })
|
||||||
|
export const getBatchStats = (apiIds) => post('/apiStats/api/batch-stats', { apiIds })
|
||||||
|
export const getBatchModelStats = (apiIds, period = 'daily') =>
|
||||||
|
post('/apiStats/api/batch-model-stats', { apiIds, period })
|
||||||
|
|
||||||
|
// 认证
|
||||||
|
export const login = (credentials) => post('/web/auth/login', credentials)
|
||||||
|
export const getAuthUser = () => get('/web/auth/user')
|
||||||
|
|
||||||
|
// OEM 设置
|
||||||
|
export const getOemSettings = () => get('/admin/oem-settings')
|
||||||
|
export const updateOemSettings = (data) => put('/admin/oem-settings', data)
|
||||||
|
|
||||||
|
// 服务倍率配置(公开接口)
|
||||||
|
export const getServiceRates = () => get('/apiStats/service-rates')
|
||||||
|
|
||||||
|
// 仪表板
|
||||||
|
export const getDashboard = () => get('/admin/dashboard')
|
||||||
|
export const getUsageCosts = (period) => get(`/admin/usage-costs?period=${period}`)
|
||||||
|
export const getUsageStats = (url) => get(url)
|
||||||
|
|
||||||
|
// 客户端
|
||||||
|
export const getSupportedClients = () => get('/admin/supported-clients')
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
export const getApiKeys = () => get('/admin/api-keys')
|
||||||
|
export const getApiKeysWithParams = (params) => get(`/admin/api-keys?${params}`)
|
||||||
|
export const createApiKey = (data) => post('/admin/api-keys', data)
|
||||||
|
export const updateApiKey = (id, data) => put(`/admin/api-keys/${id}`, data)
|
||||||
|
export const toggleApiKey = (id) => put(`/admin/api-keys/${id}/toggle`)
|
||||||
|
export const deleteApiKey = (id) => del(`/admin/api-keys/${id}`)
|
||||||
|
export const getApiKeyStats = (id, params) => get(`/admin/api-keys/${id}/stats`, { params })
|
||||||
|
export const getApiKeyTags = () => get('/admin/api-keys/tags')
|
||||||
|
export const getApiKeyUsedModels = () => get('/admin/api-keys/used-models')
|
||||||
|
export const getApiKeysBatchStats = (data) => post('/admin/api-keys/batch-stats', data)
|
||||||
|
export const getApiKeysBatchLastUsage = (data) => post('/admin/api-keys/batch-last-usage', data)
|
||||||
|
export const getDeletedApiKeys = () => get('/admin/api-keys/deleted')
|
||||||
|
export const getApiKeysCostSortStatus = () => get('/admin/api-keys/cost-sort-status')
|
||||||
|
export const restoreApiKey = (id) => post(`/admin/api-keys/${id}/restore`)
|
||||||
|
export const permanentDeleteApiKey = (id) => del(`/admin/api-keys/${id}/permanent`)
|
||||||
|
export const clearAllDeletedApiKeys = () => del('/admin/api-keys/deleted/clear-all')
|
||||||
|
export const batchDeleteApiKeys = (data) => del('/admin/api-keys/batch', { data })
|
||||||
|
export const updateApiKeyExpiration = (id, data) =>
|
||||||
|
request({ method: 'patch', url: `/admin/api-keys/${id}/expiration`, data })
|
||||||
|
|
||||||
|
// Claude 账户
|
||||||
|
export const getClaudeAccounts = () => get('/admin/claude-accounts')
|
||||||
|
export const createClaudeAccount = (data) => post('/admin/claude-accounts', data)
|
||||||
|
export const updateClaudeAccount = (id, data) => put(`/admin/claude-accounts/${id}`, data)
|
||||||
|
export const deleteClaudeAccount = (id) => del(`/admin/claude-accounts/${id}`)
|
||||||
|
export const refreshClaudeAccount = (id) => post(`/admin/claude-accounts/${id}/refresh`)
|
||||||
|
export const generateClaudeAuthUrl = (data) =>
|
||||||
|
post('/admin/claude-accounts/generate-auth-url', data)
|
||||||
|
export const exchangeClaudeCode = (data) => post('/admin/claude-accounts/exchange-code', data)
|
||||||
|
export const generateClaudeSetupTokenUrl = (data) =>
|
||||||
|
post('/admin/claude-accounts/generate-setup-token-url', data)
|
||||||
|
export const exchangeClaudeSetupToken = (data) =>
|
||||||
|
post('/admin/claude-accounts/exchange-setup-token', data)
|
||||||
|
export const claudeOAuthWithCookie = (data) =>
|
||||||
|
post('/admin/claude-accounts/oauth-with-cookie', data)
|
||||||
|
export const claudeSetupTokenWithCookie = (data) =>
|
||||||
|
post('/admin/claude-accounts/setup-token-with-cookie', data)
|
||||||
|
export const generateClaudeWorkosAuthUrl = (data) =>
|
||||||
|
post('/admin/claude-accounts/generate-workos-auth-url', data)
|
||||||
|
|
||||||
|
// Claude Console 账户
|
||||||
|
export const getClaudeConsoleAccounts = () => get('/admin/claude-console-accounts')
|
||||||
|
export const createClaudeConsoleAccount = (data) => post('/admin/claude-console-accounts', data)
|
||||||
|
export const updateClaudeConsoleAccount = (id, data) =>
|
||||||
|
put(`/admin/claude-console-accounts/${id}`, data)
|
||||||
|
export const deleteClaudeConsoleAccount = (id) => del(`/admin/claude-console-accounts/${id}`)
|
||||||
|
|
||||||
|
// Bedrock 账户
|
||||||
|
export const getBedrockAccounts = () => get('/admin/bedrock-accounts')
|
||||||
|
export const createBedrockAccount = (data) => post('/admin/bedrock-accounts', data)
|
||||||
|
export const updateBedrockAccount = (id, data) => put(`/admin/bedrock-accounts/${id}`, data)
|
||||||
|
export const deleteBedrockAccount = (id) => del(`/admin/bedrock-accounts/${id}`)
|
||||||
|
|
||||||
|
// Gemini 账户
|
||||||
|
export const getGeminiAccounts = () => get('/admin/gemini-accounts')
|
||||||
|
export const createGeminiAccount = (data) => post('/admin/gemini-accounts', data)
|
||||||
|
export const updateGeminiAccount = (id, data) => put(`/admin/gemini-accounts/${id}`, data)
|
||||||
|
export const deleteGeminiAccount = (id) => del(`/admin/gemini-accounts/${id}`)
|
||||||
|
export const generateGeminiAuthUrl = (data) =>
|
||||||
|
post('/admin/gemini-accounts/generate-auth-url', data)
|
||||||
|
export const exchangeGeminiCode = (data) => post('/admin/gemini-accounts/exchange-code', data)
|
||||||
|
|
||||||
|
// Gemini API 账户
|
||||||
|
export const createGeminiApiAccount = (data) => post('/admin/gemini-api-accounts', data)
|
||||||
|
export const updateGeminiApiAccount = (id, data) => put(`/admin/gemini-api-accounts/${id}`, data)
|
||||||
|
|
||||||
|
// OpenAI 账户
|
||||||
|
export const getOpenAIAccounts = () => get('/admin/openai-accounts')
|
||||||
|
export const createOpenAIAccount = (data) => post('/admin/openai-accounts', data)
|
||||||
|
export const updateOpenAIAccount = (id, data) => put(`/admin/openai-accounts/${id}`, data)
|
||||||
|
export const deleteOpenAIAccount = (id) => del(`/admin/openai-accounts/${id}`)
|
||||||
|
export const generateOpenAIAuthUrl = (data) =>
|
||||||
|
post('/admin/openai-accounts/generate-auth-url', data)
|
||||||
|
export const exchangeOpenAICode = (data) => post('/admin/openai-accounts/exchange-code', data)
|
||||||
|
|
||||||
|
// OpenAI Responses 账户
|
||||||
|
export const getOpenAIResponsesAccounts = () => get('/admin/openai-responses-accounts')
|
||||||
|
export const createOpenAIResponsesAccount = (data) => post('/admin/openai-responses-accounts', data)
|
||||||
|
export const updateOpenAIResponsesAccount = (id, data) =>
|
||||||
|
put(`/admin/openai-responses-accounts/${id}`, data)
|
||||||
|
export const deleteOpenAIResponsesAccount = (id) => del(`/admin/openai-responses-accounts/${id}`)
|
||||||
|
|
||||||
|
// Azure OpenAI 账户
|
||||||
|
export const getAzureOpenAIAccounts = () => get('/admin/azure-openai-accounts')
|
||||||
|
export const createAzureOpenAIAccount = (data) => post('/admin/azure-openai-accounts', data)
|
||||||
|
export const updateAzureOpenAIAccount = (id, data) =>
|
||||||
|
put(`/admin/azure-openai-accounts/${id}`, data)
|
||||||
|
export const deleteAzureOpenAIAccount = (id) => del(`/admin/azure-openai-accounts/${id}`)
|
||||||
|
|
||||||
|
// Droid 账户
|
||||||
|
export const getDroidAccounts = () => get('/admin/droid-accounts')
|
||||||
|
export const createDroidAccount = (data) => post('/admin/droid-accounts', data)
|
||||||
|
export const updateDroidAccount = (id, data) => put(`/admin/droid-accounts/${id}`, data)
|
||||||
|
export const deleteDroidAccount = (id) => del(`/admin/droid-accounts/${id}`)
|
||||||
|
export const generateDroidAuthUrl = (data) => post('/admin/droid-accounts/generate-auth-url', data)
|
||||||
|
export const exchangeDroidCode = (data) => post('/admin/droid-accounts/exchange-code', data)
|
||||||
|
|
||||||
|
// CCR 账户
|
||||||
|
export const getCcrAccounts = () => get('/admin/ccr-accounts')
|
||||||
|
export const createCcrAccount = (data) => post('/admin/ccr-accounts', data)
|
||||||
|
export const updateCcrAccount = (id, data) => put(`/admin/ccr-accounts/${id}`, data)
|
||||||
|
export const deleteCcrAccount = (id) => del(`/admin/ccr-accounts/${id}`)
|
||||||
|
|
||||||
|
// Gemini API 账户
|
||||||
|
export const getGeminiApiAccounts = () => get('/admin/gemini-api-accounts')
|
||||||
|
|
||||||
|
// 账户通用操作
|
||||||
|
export const toggleAccountStatus = (endpoint) => put(endpoint)
|
||||||
|
export const deleteAccountByEndpoint = (endpoint) => del(endpoint)
|
||||||
|
export const testAccountByEndpoint = (endpoint) => post(endpoint)
|
||||||
|
export const updateAccountByEndpoint = (endpoint, data) => put(endpoint, data)
|
||||||
|
|
||||||
|
// 账户使用统计
|
||||||
|
export const getClaudeAccountsUsage = () => get('/admin/claude-accounts/usage')
|
||||||
|
export const getAccountsBindingCounts = () => get('/admin/accounts/binding-counts')
|
||||||
|
export const getAccountUsageHistory = (id, platform, days = 30) =>
|
||||||
|
get(`/admin/accounts/${id}/usage-history?platform=${platform}&days=${days}`)
|
||||||
|
|
||||||
|
// 账户组
|
||||||
|
export const getAccountGroups = () => get('/admin/account-groups')
|
||||||
|
export const createAccountGroup = (data) => post('/admin/account-groups', data)
|
||||||
|
export const updateAccountGroup = (id, data) => put(`/admin/account-groups/${id}`, data)
|
||||||
|
export const deleteAccountGroup = (id) => del(`/admin/account-groups/${id}`)
|
||||||
|
|
||||||
|
// 用户管理
|
||||||
|
export const getUsers = () => get('/admin/users')
|
||||||
|
export const createUser = (data) => post('/admin/users', data)
|
||||||
|
export const updateUser = (id, data) => put(`/admin/users/${id}`, data)
|
||||||
|
export const deleteUser = (id) => del(`/admin/users/${id}`)
|
||||||
|
export const updateUserRole = (id, data) => put(`/admin/users/${id}/role`, data)
|
||||||
|
export const getUserUsageStats = (id, params) => get(`/admin/users/${id}/usage-stats`, { params })
|
||||||
|
|
||||||
|
// 使用记录
|
||||||
|
export const getApiKeyUsageRecords = (id, params) =>
|
||||||
|
get(`/admin/api-keys/${id}/usage-records`, { params })
|
||||||
|
export const getAccountUsageRecords = (type, id, params) =>
|
||||||
|
get(`/admin/${type}-accounts/${id}/usage-records`, { params })
|
||||||
|
|
||||||
|
// 系统日志
|
||||||
|
export const getSystemLogs = (params) => get('/admin/logs', { params })
|
||||||
|
|
||||||
|
// 配额卡片
|
||||||
|
export const getQuotaCards = () => get('/admin/quota-cards')
|
||||||
|
export const createQuotaCard = (data) => post('/admin/quota-cards', data)
|
||||||
|
export const updateQuotaCard = (id, data) => put(`/admin/quota-cards/${id}`, data)
|
||||||
|
export const deleteQuotaCard = (id) => del(`/admin/quota-cards/${id}`)
|
||||||
|
export const redeemQuotaCard = (data) => post('/admin/quota-cards/redeem', data)
|
||||||
|
|
||||||
|
// 账户测试
|
||||||
|
export const testAccount = (type, id) => post(`/admin/${type}-accounts/${id}/test`)
|
||||||
|
export const getAccountTestHistory = (type, id) => get(`/admin/${type}-accounts/${id}/test-history`)
|
||||||
|
|
||||||
|
// 定时测试
|
||||||
|
export const getScheduledTests = () => get('/admin/scheduled-tests')
|
||||||
|
export const createScheduledTest = (data) => post('/admin/scheduled-tests', data)
|
||||||
|
export const updateScheduledTest = (id, data) => put(`/admin/scheduled-tests/${id}`, data)
|
||||||
|
export const deleteScheduledTest = (id) => del(`/admin/scheduled-tests/${id}`)
|
||||||
|
|
||||||
|
// 统一 User-Agent
|
||||||
|
export const getUnifiedUserAgent = () => get('/admin/unified-user-agent')
|
||||||
|
|
||||||
|
// 账户 API Keys 管理
|
||||||
|
export const getAccountApiKeys = (type, id) => get(`/admin/${type}-accounts/${id}/api-keys`)
|
||||||
|
export const updateAccountApiKeys = (type, id, data) =>
|
||||||
|
put(`/admin/${type}-accounts/${id}/api-keys`, data)
|
||||||
|
|
||||||
|
export default { get, post, put, patch, del, request }
|
||||||
|
export { get, post, put, patch, del }
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// Toast 通知管理
|
|
||||||
let toastContainer = null
|
|
||||||
let toastId = 0
|
|
||||||
|
|
||||||
export function showToast(message, type = 'info', title = '', duration = 3000) {
|
|
||||||
// 创建容器
|
|
||||||
if (!toastContainer) {
|
|
||||||
toastContainer = document.createElement('div')
|
|
||||||
toastContainer.id = 'toast-container'
|
|
||||||
toastContainer.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 10000;'
|
|
||||||
document.body.appendChild(toastContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建 toast
|
|
||||||
const id = ++toastId
|
|
||||||
const toast = document.createElement('div')
|
|
||||||
toast.className = `toast rounded-2xl p-4 shadow-2xl backdrop-blur-sm toast-${type}`
|
|
||||||
toast.style.cssText = `
|
|
||||||
position: relative;
|
|
||||||
min-width: 320px;
|
|
||||||
max-width: 500px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
transform: translateX(100%);
|
|
||||||
transition: transform 0.3s ease-in-out;
|
|
||||||
`
|
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
success: 'fas fa-check-circle',
|
|
||||||
error: 'fas fa-times-circle',
|
|
||||||
warning: 'fas fa-exclamation-triangle',
|
|
||||||
info: 'fas fa-info-circle'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理消息中的换行符,转换为 HTML 换行
|
|
||||||
const formattedMessage = message.replace(/\n/g, '<br>')
|
|
||||||
|
|
||||||
toast.innerHTML = `
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<div class="flex-shrink-0 mt-0.5">
|
|
||||||
<i class="${iconMap[type]} text-lg"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
${title ? `<h4 class="font-semibold text-sm mb-1">${title}</h4>` : ''}
|
|
||||||
<p class="text-sm opacity-90 leading-relaxed">${formattedMessage}</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="this.parentElement.parentElement.remove()"
|
|
||||||
class="flex-shrink-0 text-white/70 hover:text-white transition-colors ml-2">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
toastContainer.appendChild(toast)
|
|
||||||
|
|
||||||
// 触发动画
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.transform = 'translateX(0)'
|
|
||||||
}, 10)
|
|
||||||
|
|
||||||
// 自动移除
|
|
||||||
if (duration > 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.transform = 'translateX(100%)'
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.remove()
|
|
||||||
}, 300)
|
|
||||||
}, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
128
web/admin-spa/src/utils/tools.js
Normal file
128
web/admin-spa/src/utils/tools.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// Toast 通知管理
|
||||||
|
let toastContainer = null
|
||||||
|
let toastId = 0
|
||||||
|
|
||||||
|
export const showToast = (message, type = 'info', title = '', duration = 3000) => {
|
||||||
|
// 创建容器
|
||||||
|
if (!toastContainer) {
|
||||||
|
toastContainer = document.createElement('div')
|
||||||
|
toastContainer.id = 'toast-container'
|
||||||
|
toastContainer.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 10000;'
|
||||||
|
document.body.appendChild(toastContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = ++toastId
|
||||||
|
const toast = document.createElement('div')
|
||||||
|
toast.className = `toast rounded-2xl p-4 shadow-2xl backdrop-blur-sm toast-${type}`
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: relative;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 500px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
`
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
success: 'fas fa-check-circle',
|
||||||
|
error: 'fas fa-times-circle',
|
||||||
|
warning: 'fas fa-exclamation-triangle',
|
||||||
|
info: 'fas fa-info-circle'
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-shrink-0 mt-0.5">
|
||||||
|
<i class="${iconMap[type]} text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
${title ? `<h4 class="font-semibold text-sm mb-1">${title}</h4>` : ''}
|
||||||
|
<p class="text-sm opacity-90 leading-relaxed">${message.replace(/\n/g, '<br>')}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="this.parentElement.parentElement.remove()"
|
||||||
|
class="flex-shrink-0 text-white/70 hover:text-white transition-colors ml-2">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
toastContainer.appendChild(toast)
|
||||||
|
setTimeout(() => (toast.style.transform = 'translateX(0)'), 10)
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.transform = 'translateX(100%)'
|
||||||
|
setTimeout(() => toast.remove(), 300)
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制文本到剪贴板
|
||||||
|
export const copyText = async (text, successMsg = '已复制') => {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} else {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
showToast(successMsg, 'success')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error)
|
||||||
|
showToast('复制失败', 'error')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数字格式化
|
||||||
|
export const formatNumber = (num) => {
|
||||||
|
if (num === null || num === undefined) return '0'
|
||||||
|
const absNum = Math.abs(num)
|
||||||
|
if (absNum >= 1e9) return (num / 1e9).toFixed(2) + 'B'
|
||||||
|
if (absNum >= 1e6) return (num / 1e6).toFixed(2) + 'M'
|
||||||
|
if (absNum >= 1e3) return (num / 1e3).toFixed(1) + 'K'
|
||||||
|
return num.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期格式化
|
||||||
|
export const formatDate = (date, format = 'YYYY-MM-DD HH:mm:ss') => {
|
||||||
|
if (!date) return ''
|
||||||
|
const d = new Date(date)
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return format
|
||||||
|
.replace('YYYY', d.getFullYear())
|
||||||
|
.replace('MM', pad(d.getMonth() + 1))
|
||||||
|
.replace('DD', pad(d.getDate()))
|
||||||
|
.replace('HH', pad(d.getHours()))
|
||||||
|
.replace('mm', pad(d.getMinutes()))
|
||||||
|
.replace('ss', pad(d.getSeconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相对时间格式化
|
||||||
|
export const formatRelativeTime = (date) => {
|
||||||
|
if (!date) return ''
|
||||||
|
const diffMs = new Date() - new Date(date)
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
if (diffDays > 0) return `${diffDays}天前`
|
||||||
|
if (diffHours > 0) return `${diffHours}小时前`
|
||||||
|
if (diffMins > 0) return `${diffMins}分钟前`
|
||||||
|
return '刚刚'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字节格式化
|
||||||
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals < 0 ? 0 : decimals)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
@@ -301,9 +301,9 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/tools'
|
||||||
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -437,7 +437,7 @@ const syncResponseState = (data) => {
|
|||||||
const fetchRecords = async (page = pagination.currentPage) => {
|
const fetchRecords = async (page = pagination.currentPage) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
const response = await httpApi.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||||
params: buildParams(page)
|
params: buildParams(page)
|
||||||
})
|
})
|
||||||
syncResponseState(response.data || {})
|
syncResponseState(response.data || {})
|
||||||
@@ -492,7 +492,7 @@ const exportCsv = async () => {
|
|||||||
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||||
|
|
||||||
while (page <= totalPages && page <= maxPages) {
|
while (page <= totalPages && page <= maxPages) {
|
||||||
const response = await apiClient.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
const response = await httpApi.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||||
params: { ...buildParams(page), pageSize: 200 }
|
params: { ...buildParams(page), pageSize: 200 }
|
||||||
})
|
})
|
||||||
const payload = response.data || {}
|
const payload = response.data || {}
|
||||||
|
|||||||
@@ -150,6 +150,22 @@
|
|||||||
<span>{{ showCheckboxes ? '取消选择' : '选择' }}</span>
|
<span>{{ showCheckboxes ? '取消选择' : '选择' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- 分组管理按钮 -->
|
||||||
|
<div class="relative">
|
||||||
|
<el-tooltip content="管理账户分组" effect="dark" placement="bottom">
|
||||||
|
<button
|
||||||
|
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
|
||||||
|
@click="showGroupManagementModal = true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||||
|
></div>
|
||||||
|
<i class="fas fa-layer-group relative text-purple-500" />
|
||||||
|
<span class="relative">分组</span>
|
||||||
|
</button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 批量删除按钮 -->
|
<!-- 批量删除按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="selectedAccounts.length > 0"
|
v-if="selectedAccounts.length > 0"
|
||||||
@@ -460,8 +476,9 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
|
class="cursor-pointer truncate text-sm font-semibold text-gray-900 hover:text-blue-600 dark:text-gray-100 dark:hover:text-blue-400"
|
||||||
:title="account.name"
|
title="点击复制"
|
||||||
|
@click.stop="copyText(account.name)"
|
||||||
>
|
>
|
||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</div>
|
</div>
|
||||||
@@ -1355,7 +1372,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-semibold text-gray-900">
|
<h4
|
||||||
|
class="cursor-pointer text-sm font-semibold text-gray-900 hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
title="点击复制"
|
||||||
|
@click.stop="copyText(account.name || account.email)"
|
||||||
|
>
|
||||||
{{ account.name || account.email }}
|
{{ account.name || account.email }}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="mt-0.5 flex items-center gap-2">
|
<div class="mt-0.5 flex items-center gap-2">
|
||||||
@@ -2045,13 +2066,21 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 分组管理弹窗 -->
|
||||||
|
<GroupManagementModal
|
||||||
|
v-if="showGroupManagementModal"
|
||||||
|
@close="showGroupManagementModal = false"
|
||||||
|
@refresh="loadAccountGroups"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { apiClient } from '@/config/api'
|
import { copyText } from '@/utils/tools'
|
||||||
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import AccountForm from '@/components/accounts/AccountForm.vue'
|
import AccountForm from '@/components/accounts/AccountForm.vue'
|
||||||
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||||||
@@ -2062,6 +2091,7 @@ import AccountScheduledTestModal from '@/components/accounts/AccountScheduledTes
|
|||||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||||
|
import GroupManagementModal from '@/components/accounts/GroupManagementModal.vue'
|
||||||
|
|
||||||
// 使用确认弹窗
|
// 使用确认弹窗
|
||||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||||
@@ -2133,6 +2163,9 @@ const scheduledTestAccount = ref(null)
|
|||||||
// 账户统计弹窗状态
|
// 账户统计弹窗状态
|
||||||
const showAccountStatsModal = ref(false)
|
const showAccountStatsModal = ref(false)
|
||||||
|
|
||||||
|
// 分组管理弹窗状态
|
||||||
|
const showGroupManagementModal = ref(false)
|
||||||
|
|
||||||
// 表格横向滚动检测
|
// 表格横向滚动检测
|
||||||
const tableContainerRef = ref(null)
|
const tableContainerRef = ref(null)
|
||||||
const needsHorizontalScroll = ref(false)
|
const needsHorizontalScroll = ref(false)
|
||||||
@@ -2204,16 +2237,16 @@ const platformGroupMap = {
|
|||||||
|
|
||||||
// 平台请求处理器
|
// 平台请求处理器
|
||||||
const platformRequestHandlers = {
|
const platformRequestHandlers = {
|
||||||
claude: (params) => apiClient.get('/admin/claude-accounts', { params }),
|
claude: () => httpApi.getClaudeAccounts(),
|
||||||
'claude-console': (params) => apiClient.get('/admin/claude-console-accounts', { params }),
|
'claude-console': () => httpApi.getClaudeConsoleAccounts(),
|
||||||
bedrock: (params) => apiClient.get('/admin/bedrock-accounts', { params }),
|
bedrock: () => httpApi.getBedrockAccounts(),
|
||||||
gemini: (params) => apiClient.get('/admin/gemini-accounts', { params }),
|
gemini: () => httpApi.getGeminiAccounts(),
|
||||||
openai: (params) => apiClient.get('/admin/openai-accounts', { params }),
|
openai: () => httpApi.getOpenAIAccounts(),
|
||||||
azure_openai: (params) => apiClient.get('/admin/azure-openai-accounts', { params }),
|
azure_openai: () => httpApi.getAzureOpenAIAccounts(),
|
||||||
'openai-responses': (params) => apiClient.get('/admin/openai-responses-accounts', { params }),
|
'openai-responses': () => httpApi.getOpenAIResponsesAccounts(),
|
||||||
ccr: (params) => apiClient.get('/admin/ccr-accounts', { params }),
|
ccr: () => httpApi.getCcrAccounts(),
|
||||||
droid: (params) => apiClient.get('/admin/droid-accounts', { params }),
|
droid: () => httpApi.getDroidAccounts(),
|
||||||
'gemini-api': (params) => apiClient.get('/admin/gemini-api-accounts', { params })
|
'gemini-api': () => httpApi.getGeminiApiAccounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
const allPlatformKeys = Object.keys(platformRequestHandlers)
|
const allPlatformKeys = Object.keys(platformRequestHandlers)
|
||||||
@@ -2431,25 +2464,17 @@ const openAccountUsageModal = async (account) => {
|
|||||||
accountUsageOverview.value = {}
|
accountUsageOverview.value = {}
|
||||||
accountUsageGeneratedAt.value = ''
|
accountUsageGeneratedAt.value = ''
|
||||||
|
|
||||||
try {
|
const response = await httpApi.getAccountUsageHistory(account.id, account.platform, 30)
|
||||||
const response = await apiClient.get(
|
if (response.success) {
|
||||||
`/admin/accounts/${account.id}/usage-history?platform=${account.platform}&days=30`
|
const data = response.data || {}
|
||||||
)
|
accountUsageHistory.value = data.history || []
|
||||||
|
accountUsageSummary.value = data.summary || {}
|
||||||
if (response.success) {
|
accountUsageOverview.value = data.overview || {}
|
||||||
const data = response.data || {}
|
accountUsageGeneratedAt.value = data.generatedAt || ''
|
||||||
accountUsageHistory.value = data.history || []
|
} else {
|
||||||
accountUsageSummary.value = data.summary || {}
|
showToast(response.error || '加载账号使用详情失败', 'error')
|
||||||
accountUsageOverview.value = data.overview || {}
|
|
||||||
accountUsageGeneratedAt.value = data.generatedAt || ''
|
|
||||||
} else {
|
|
||||||
showToast(response.error || '加载账号使用详情失败', 'error')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast('加载账号使用详情失败', 'error')
|
|
||||||
} finally {
|
|
||||||
accountUsageLoading.value = false
|
|
||||||
}
|
}
|
||||||
|
accountUsageLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeAccountUsageModal = () => {
|
const closeAccountUsageModal = () => {
|
||||||
@@ -2459,7 +2484,16 @@ const closeAccountUsageModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 测试账户连通性相关函数
|
// 测试账户连通性相关函数
|
||||||
const supportedTestPlatforms = ['claude', 'claude-console']
|
const supportedTestPlatforms = [
|
||||||
|
'claude',
|
||||||
|
'claude-console',
|
||||||
|
'bedrock',
|
||||||
|
'gemini',
|
||||||
|
'openai-responses',
|
||||||
|
'azure-openai',
|
||||||
|
'droid',
|
||||||
|
'ccr'
|
||||||
|
]
|
||||||
|
|
||||||
const canTestAccount = (account) => {
|
const canTestAccount = (account) => {
|
||||||
return !!account && supportedTestPlatforms.includes(account.platform)
|
return !!account && supportedTestPlatforms.includes(account.platform)
|
||||||
@@ -2947,27 +2981,9 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (openaiResponsesRaw.length > 0) {
|
if (openaiResponsesRaw.length > 0) {
|
||||||
let autoRecoveryConfigMap = {}
|
|
||||||
try {
|
|
||||||
const configsRes = await apiClient.get(
|
|
||||||
'/admin/openai-responses-accounts/auto-recovery-configs'
|
|
||||||
)
|
|
||||||
if (configsRes.success && Array.isArray(configsRes.data)) {
|
|
||||||
autoRecoveryConfigMap = configsRes.data.reduce((map, config) => {
|
|
||||||
if (config?.accountId) {
|
|
||||||
map[config.accountId] = config
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.debug('Failed to load auto-recovery configs:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
const responsesAccounts = openaiResponsesRaw.map((acc) => {
|
const responsesAccounts = openaiResponsesRaw.map((acc) => {
|
||||||
const boundApiKeysCount = counts.openaiAccountId?.[`responses:${acc.id}`] || 0
|
const boundApiKeysCount = counts.openaiAccountId?.[`responses:${acc.id}`] || 0
|
||||||
const autoRecoveryConfig = autoRecoveryConfigMap[acc.id] || acc.autoRecoveryConfig || null
|
return { ...acc, platform: 'openai-responses', boundApiKeysCount }
|
||||||
return { ...acc, platform: 'openai-responses', boundApiKeysCount, autoRecoveryConfig }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
allAccounts.push(...responsesAccounts)
|
allAccounts.push(...responsesAccounts)
|
||||||
@@ -3019,24 +3035,15 @@ const loadAccounts = async (forceReload = false) => {
|
|||||||
|
|
||||||
// 异步加载 Claude 账户的 Usage 数据
|
// 异步加载 Claude 账户的 Usage 数据
|
||||||
const loadClaudeUsage = async () => {
|
const loadClaudeUsage = async () => {
|
||||||
try {
|
const response = await httpApi.getClaudeAccountsUsage()
|
||||||
const response = await apiClient.get('/admin/claude-accounts/usage')
|
if (response.success && response.data) {
|
||||||
if (response.success && response.data) {
|
const usageMap = response.data
|
||||||
const usageMap = response.data
|
accounts.value = accounts.value.map((account) => {
|
||||||
|
if (account.platform === 'claude' && usageMap[account.id]) {
|
||||||
// 更新账户列表中的 claudeUsage 数据
|
return { ...account, claudeUsage: usageMap[account.id] }
|
||||||
accounts.value = accounts.value.map((account) => {
|
}
|
||||||
if (account.platform === 'claude' && usageMap[account.id]) {
|
return account
|
||||||
return {
|
})
|
||||||
...account,
|
|
||||||
claudeUsage: usageMap[account.id]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return account
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.debug('Failed to load Claude usage data:', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3104,53 +3111,31 @@ const clearSearch = () => {
|
|||||||
|
|
||||||
// 加载绑定计数(轻量级接口,用于显示"绑定: X 个API Key")
|
// 加载绑定计数(轻量级接口,用于显示"绑定: X 个API Key")
|
||||||
const loadBindingCounts = async (forceReload = false) => {
|
const loadBindingCounts = async (forceReload = false) => {
|
||||||
if (!forceReload && bindingCountsLoaded.value) {
|
if (!forceReload && bindingCountsLoaded.value) return
|
||||||
return // 使用缓存数据
|
const response = await httpApi.getAccountsBindingCounts()
|
||||||
}
|
if (response.success) {
|
||||||
|
bindingCounts.value = response.data || {}
|
||||||
try {
|
bindingCountsLoaded.value = true
|
||||||
const response = await apiClient.get('/admin/accounts/binding-counts')
|
|
||||||
if (response.success) {
|
|
||||||
bindingCounts.value = response.data || {}
|
|
||||||
bindingCountsLoaded.value = true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 静默处理错误,绑定计数显示为 0
|
|
||||||
bindingCounts.value = {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载API Keys列表(保留用于其他功能,如删除账户时显示绑定信息)
|
// 加载API Keys列表(保留用于其他功能,如删除账户时显示绑定信息)
|
||||||
const loadApiKeys = async (forceReload = false) => {
|
const loadApiKeys = async (forceReload = false) => {
|
||||||
if (!forceReload && apiKeysLoaded.value) {
|
if (!forceReload && apiKeysLoaded.value) return
|
||||||
return // 使用缓存数据
|
const response = await httpApi.getApiKeys()
|
||||||
}
|
if (response.success) {
|
||||||
|
apiKeys.value = response.data?.items || response.data || []
|
||||||
try {
|
apiKeysLoaded.value = true
|
||||||
const response = await apiClient.get('/admin/api-keys')
|
|
||||||
if (response.success) {
|
|
||||||
apiKeys.value = response.data?.items || response.data || []
|
|
||||||
apiKeysLoaded.value = true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 静默处理错误
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载账户分组列表(缓存版本)
|
// 加载账户分组列表(缓存版本)
|
||||||
const loadAccountGroups = async (forceReload = false) => {
|
const loadAccountGroups = async (forceReload = false) => {
|
||||||
if (!forceReload && groupsLoaded.value) {
|
if (!forceReload && groupsLoaded.value) return
|
||||||
return // 使用缓存数据
|
const response = await httpApi.getAccountGroups()
|
||||||
}
|
if (response.success) {
|
||||||
|
accountGroups.value = response.data || []
|
||||||
try {
|
groupsLoaded.value = true
|
||||||
const response = await apiClient.get('/admin/account-groups')
|
|
||||||
if (response.success) {
|
|
||||||
accountGroups.value = response.data || []
|
|
||||||
groupsLoaded.value = true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 静默处理错误
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3435,20 +3420,10 @@ const resolveAccountDeleteEndpoint = (account) => {
|
|||||||
|
|
||||||
const performAccountDeletion = async (account) => {
|
const performAccountDeletion = async (account) => {
|
||||||
const endpoint = resolveAccountDeleteEndpoint(account)
|
const endpoint = resolveAccountDeleteEndpoint(account)
|
||||||
if (!endpoint) {
|
if (!endpoint) return { success: false, message: '不支持的账户类型' }
|
||||||
return { success: false, message: '不支持的账户类型' }
|
const data = await httpApi.deleteAccountByEndpoint(endpoint)
|
||||||
}
|
if (data.success) return { success: true, data }
|
||||||
|
return { success: false, message: data.message || '删除失败' }
|
||||||
try {
|
|
||||||
const data = await apiClient.delete(endpoint)
|
|
||||||
if (data.success) {
|
|
||||||
return { success: true, data }
|
|
||||||
}
|
|
||||||
return { success: false, message: data.message || '删除失败' }
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.response?.data?.message || error.message || '删除失败'
|
|
||||||
return { success: false, message }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除账户
|
// 删除账户
|
||||||
@@ -3579,17 +3554,13 @@ const batchDeleteAccounts = async () => {
|
|||||||
const resetAccountStatus = async (account) => {
|
const resetAccountStatus = async (account) => {
|
||||||
if (account.isResetting) return
|
if (account.isResetting) return
|
||||||
|
|
||||||
let confirmed = false
|
const confirmed = await showConfirm(
|
||||||
if (window.showConfirm) {
|
'重置账户状态',
|
||||||
confirmed = await window.showConfirm(
|
'确定要重置此账户的所有异常状态吗?这将清除限流状态、401错误计数等所有异常标记。',
|
||||||
'重置账户状态',
|
'确定重置',
|
||||||
'确定要重置此账户的所有异常状态吗?这将清除限流状态、401错误计数等所有异常标记。',
|
'取消',
|
||||||
'确定重置',
|
'warning'
|
||||||
'取消'
|
)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
confirmed = confirm('确定要重置此账户的所有异常状态吗?')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
@@ -3620,18 +3591,16 @@ const resetAccountStatus = async (account) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await apiClient.post(endpoint)
|
const data = await httpApi.testAccountByEndpoint(endpoint)
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('账户状态已重置', 'success')
|
showToast('账户状态已重置', 'success')
|
||||||
// 强制刷新,绕过前端缓存,确保最终一致性
|
|
||||||
loadAccounts(true)
|
loadAccounts(true)
|
||||||
} else {
|
} else {
|
||||||
showToast(data.message || '状态重置失败', 'error')
|
showToast(data.message || '状态重置失败', 'error')
|
||||||
}
|
}
|
||||||
|
account.isResetting = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('状态重置失败', 'error')
|
showToast(error.message || '状态重置失败', 'error')
|
||||||
} finally {
|
|
||||||
account.isResetting = false
|
account.isResetting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3639,49 +3608,43 @@ const resetAccountStatus = async (account) => {
|
|||||||
// 切换调度状态
|
// 切换调度状态
|
||||||
const toggleSchedulable = async (account) => {
|
const toggleSchedulable = async (account) => {
|
||||||
if (account.isTogglingSchedulable) return
|
if (account.isTogglingSchedulable) return
|
||||||
|
account.isTogglingSchedulable = true
|
||||||
|
|
||||||
try {
|
let endpoint
|
||||||
account.isTogglingSchedulable = true
|
if (account.platform === 'claude') {
|
||||||
|
endpoint = `/admin/claude-accounts/${account.id}/toggle-schedulable`
|
||||||
let endpoint
|
} else if (account.platform === 'claude-console') {
|
||||||
if (account.platform === 'claude') {
|
endpoint = `/admin/claude-console-accounts/${account.id}/toggle-schedulable`
|
||||||
endpoint = `/admin/claude-accounts/${account.id}/toggle-schedulable`
|
} else if (account.platform === 'bedrock') {
|
||||||
} else if (account.platform === 'claude-console') {
|
endpoint = `/admin/bedrock-accounts/${account.id}/toggle-schedulable`
|
||||||
endpoint = `/admin/claude-console-accounts/${account.id}/toggle-schedulable`
|
} else if (account.platform === 'gemini') {
|
||||||
} else if (account.platform === 'bedrock') {
|
endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable`
|
||||||
endpoint = `/admin/bedrock-accounts/${account.id}/toggle-schedulable`
|
} else if (account.platform === 'openai') {
|
||||||
} else if (account.platform === 'gemini') {
|
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
|
||||||
endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable`
|
} else if (account.platform === 'azure_openai') {
|
||||||
} else if (account.platform === 'openai') {
|
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
|
||||||
endpoint = `/admin/openai-accounts/${account.id}/toggle-schedulable`
|
} else if (account.platform === 'openai-responses') {
|
||||||
} else if (account.platform === 'azure_openai') {
|
endpoint = `/admin/openai-responses-accounts/${account.id}/toggle-schedulable`
|
||||||
endpoint = `/admin/azure-openai-accounts/${account.id}/toggle-schedulable`
|
} else if (account.platform === 'ccr') {
|
||||||
} else if (account.platform === 'openai-responses') {
|
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
|
||||||
endpoint = `/admin/openai-responses-accounts/${account.id}/toggle-schedulable`
|
} else if (account.platform === 'droid') {
|
||||||
} else if (account.platform === 'ccr') {
|
endpoint = `/admin/droid-accounts/${account.id}/toggle-schedulable`
|
||||||
endpoint = `/admin/ccr-accounts/${account.id}/toggle-schedulable`
|
} else if (account.platform === 'gemini-api') {
|
||||||
} else if (account.platform === 'droid') {
|
endpoint = `/admin/gemini-api-accounts/${account.id}/toggle-schedulable`
|
||||||
endpoint = `/admin/droid-accounts/${account.id}/toggle-schedulable`
|
} else {
|
||||||
} else if (account.platform === 'gemini-api') {
|
showToast('该账户类型暂不支持调度控制', 'warning')
|
||||||
endpoint = `/admin/gemini-api-accounts/${account.id}/toggle-schedulable`
|
|
||||||
} else {
|
|
||||||
showToast('该账户类型暂不支持调度控制', 'warning')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await apiClient.put(endpoint)
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
account.schedulable = data.schedulable
|
|
||||||
showToast(data.schedulable ? '已启用调度' : '已禁用调度', 'success')
|
|
||||||
} else {
|
|
||||||
showToast(data.message || '操作失败', 'error')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast('切换调度状态失败', 'error')
|
|
||||||
} finally {
|
|
||||||
account.isTogglingSchedulable = false
|
account.isTogglingSchedulable = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await httpApi.toggleAccountStatus(endpoint)
|
||||||
|
if (data.success) {
|
||||||
|
account.schedulable = data.schedulable
|
||||||
|
showToast(data.schedulable ? '已启用调度' : '已禁用调度', 'success')
|
||||||
|
} else {
|
||||||
|
showToast(data.message || '操作失败', 'error')
|
||||||
|
}
|
||||||
|
account.isTogglingSchedulable = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理创建成功
|
// 处理创建成功
|
||||||
@@ -4479,29 +4442,18 @@ const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await apiClient.put(endpoint, {
|
const data = await httpApi.updateAccountByEndpoint(endpoint, { expiresAt: expiresAt || null })
|
||||||
expiresAt: expiresAt || null
|
|
||||||
})
|
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('账户到期时间已更新', 'success')
|
showToast('账户到期时间已更新', 'success')
|
||||||
// 更新本地数据
|
|
||||||
account.expiresAt = expiresAt || null
|
account.expiresAt = expiresAt || null
|
||||||
closeAccountExpiryEdit()
|
closeAccountExpiryEdit()
|
||||||
} else {
|
} else {
|
||||||
showToast(data.message || '更新失败', 'error')
|
showToast(data.message || '更新失败', 'error')
|
||||||
// 重置保存状态
|
if (expiryEditModalRef.value) expiryEditModalRef.value.resetSaving()
|
||||||
if (expiryEditModalRef.value) {
|
|
||||||
expiryEditModalRef.value.resetSaving()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('更新账户过期时间失败:', error)
|
showToast(error.message || '更新失败', 'error')
|
||||||
showToast('更新失败', 'error')
|
if (expiryEditModalRef.value) expiryEditModalRef.value.resetSaving()
|
||||||
// 重置保存状态
|
|
||||||
if (expiryEditModalRef.value) {
|
|
||||||
expiryEditModalRef.value.resetSaving()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4616,28 +4568,41 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container::-webkit-scrollbar-track {
|
.dark .table-container::-webkit-scrollbar-track {
|
||||||
background: #374151;
|
background: var(--bg-gradient-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container::-webkit-scrollbar-thumb {
|
.dark .table-container::-webkit-scrollbar-thumb {
|
||||||
background: #4b5563;
|
background: var(--bg-gradient-end);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container::-webkit-scrollbar-thumb:hover {
|
.dark .table-container::-webkit-scrollbar-thumb:hover {
|
||||||
background: #6b7280;
|
background: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表格行样式 */
|
/* 统一 hover 背景 - 所有 td 使用主题色 */
|
||||||
.table-row {
|
.table-container tbody tr:hover > td {
|
||||||
transition: all 0.2s ease;
|
background-color: rgba(var(--primary-rgb), 0.06) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-row:hover {
|
.dark .table-container tbody tr:hover > td {
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
background-color: rgba(var(--primary-rgb), 0.16) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-row:hover {
|
/* 所有 td 的斑马纹背景 */
|
||||||
background-color: rgba(255, 255, 255, 0.02);
|
.table-container tbody tr:nth-child(odd) > td {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tbody tr:nth-child(even) > td {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .table-container tbody tr:nth-child(odd) > td {
|
||||||
|
background-color: var(--bg-gradient-start);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .table-container tbody tr:nth-child(even) > td {
|
||||||
|
background-color: var(--bg-gradient-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表头左侧固定列背景 - 使用纯色避免滚动时重叠 */
|
/* 表头左侧固定列背景 - 使用纯色避免滚动时重叠 */
|
||||||
@@ -4649,7 +4614,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.dark .table-container thead .checkbox-column,
|
.dark .table-container thead .checkbox-column,
|
||||||
.dark .table-container thead .name-column {
|
.dark .table-container thead .name-column {
|
||||||
background: linear-gradient(to bottom, #374151, #1f2937);
|
background: linear-gradient(to bottom, var(--bg-gradient-mid), var(--bg-gradient-start));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表头右侧操作列背景 - 使用纯色避免滚动时重叠 */
|
/* 表头右侧操作列背景 - 使用纯色避免滚动时重叠 */
|
||||||
@@ -4659,39 +4624,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container thead .operations-column {
|
.dark .table-container thead .operations-column {
|
||||||
background: linear-gradient(to bottom, #374151, #1f2937);
|
background: linear-gradient(to bottom, var(--bg-gradient-mid), var(--bg-gradient-start));
|
||||||
}
|
|
||||||
|
|
||||||
/* tbody 中的左侧固定列背景处理 - 使用纯色避免滚动时重叠 */
|
|
||||||
.table-container tbody tr:nth-child(odd) .checkbox-column,
|
|
||||||
.table-container tbody tr:nth-child(odd) .name-column {
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container tbody tr:nth-child(even) .checkbox-column,
|
|
||||||
.table-container tbody tr:nth-child(even) .name-column {
|
|
||||||
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: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .table-container tbody tr:nth-child(even) .checkbox-column,
|
|
||||||
.dark .table-container tbody tr:nth-child(even) .name-column {
|
|
||||||
background-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* hover 状态下的左侧固定列背景 */
|
|
||||||
.table-container tbody tr:hover .checkbox-column,
|
|
||||||
.table-container tbody tr:hover .name-column {
|
|
||||||
background-color: #eff6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .table-container tbody tr:hover .checkbox-column,
|
|
||||||
.dark .table-container tbody tr:hover .name-column {
|
|
||||||
background-color: #1e3a5f;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 名称列右侧阴影(分隔效果) */
|
/* 名称列右侧阴影(分隔效果) */
|
||||||
@@ -4703,32 +4636,6 @@ onUnmounted(() => {
|
|||||||
box-shadow: 8px 0 12px -8px rgba(30, 41, 59, 0.45);
|
box-shadow: 8px 0 12px -8px rgba(30, 41, 59, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tbody 中的操作列背景处理 - 使用纯色避免滚动时重叠 */
|
|
||||||
.table-container tbody tr:nth-child(odd) .operations-column {
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container tbody tr:nth-child(even) .operations-column {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .table-container tbody tr:nth-child(odd) .operations-column {
|
|
||||||
background-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .table-container tbody tr:nth-child(even) .operations-column {
|
|
||||||
background-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* hover 状态下的操作列背景 */
|
|
||||||
.table-container tbody tr:hover .operations-column {
|
|
||||||
background-color: #eff6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .table-container tbody tr:hover .operations-column {
|
|
||||||
background-color: #1e3a5f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 操作列左侧阴影 */
|
/* 操作列左侧阴影 */
|
||||||
.table-container tbody .operations-column {
|
.table-container tbody .operations-column {
|
||||||
box-shadow: -8px 0 12px -8px rgba(15, 23, 42, 0.16);
|
box-shadow: -8px 0 12px -8px rgba(15, 23, 42, 0.16);
|
||||||
|
|||||||
@@ -295,9 +295,9 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/tools'
|
||||||
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -410,7 +410,7 @@ const syncResponseState = (data) => {
|
|||||||
const fetchRecords = async (page = pagination.currentPage) => {
|
const fetchRecords = async (page = pagination.currentPage) => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
const response = await httpApi.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||||
params: buildParams(page)
|
params: buildParams(page)
|
||||||
})
|
})
|
||||||
syncResponseState(response.data || {})
|
syncResponseState(response.data || {})
|
||||||
@@ -465,7 +465,7 @@ const exportCsv = async () => {
|
|||||||
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||||
|
|
||||||
while (page <= totalPages && page <= maxPages) {
|
while (page <= totalPages && page <= maxPages) {
|
||||||
const response = await apiClient.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
const response = await httpApi.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||||
params: { ...buildParams(page), pageSize: 200 }
|
params: { ...buildParams(page), pageSize: 200 }
|
||||||
})
|
})
|
||||||
const payload = response.data || {}
|
const payload = response.data || {}
|
||||||
|
|||||||
@@ -425,16 +425,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="(key, index) in paginatedApiKeys" :key="key.id">
|
<template v-for="key in paginatedApiKeys" :key="key.id">
|
||||||
<!-- API Key 主行 - 添加斑马条纹和增强分隔 -->
|
<!-- API Key 主行 - 添加斑马条纹和增强分隔 -->
|
||||||
<tr
|
<tr
|
||||||
:class="[
|
:class="[
|
||||||
'table-row transition-all duration-150',
|
'table-row',
|
||||||
index % 2 === 0
|
|
||||||
? 'bg-white dark:bg-gray-800/40'
|
|
||||||
: 'bg-gray-50/70 dark:bg-gray-700/30',
|
|
||||||
'border-b-2 border-gray-200/80 dark:border-gray-700/50',
|
'border-b-2 border-gray-200/80 dark:border-gray-700/50',
|
||||||
'hover:bg-blue-50/60 hover:shadow-sm dark:hover:bg-blue-900/20'
|
'hover:shadow-sm'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
@@ -458,8 +455,9 @@
|
|||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<!-- 名称 -->
|
<!-- 名称 -->
|
||||||
<div
|
<div
|
||||||
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
|
class="cursor-pointer truncate text-sm font-semibold text-gray-900 hover:text-blue-600 dark:text-gray-100 dark:hover:text-blue-400"
|
||||||
:title="key.name"
|
title="点击复制"
|
||||||
|
@click.stop="copyText(key.name)"
|
||||||
>
|
>
|
||||||
{{ key.name }}
|
{{ key.name }}
|
||||||
</div>
|
</div>
|
||||||
@@ -1265,7 +1263,11 @@
|
|||||||
@change="updateSelectAllState"
|
@change="updateSelectAllState"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h4
|
||||||
|
class="cursor-pointer text-sm font-semibold text-gray-900 hover:text-blue-600 dark:text-gray-100 dark:hover:text-blue-400"
|
||||||
|
title="点击复制"
|
||||||
|
@click.stop="copyText(key.name)"
|
||||||
|
>
|
||||||
{{ key.name }}
|
{{ key.name }}
|
||||||
</h4>
|
</h4>
|
||||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
@@ -1854,8 +1856,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div
|
<div
|
||||||
class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100"
|
class="cursor-pointer truncate text-sm font-semibold text-gray-900 hover:text-blue-600 dark:text-gray-100 dark:hover:text-blue-400"
|
||||||
:title="key.name"
|
title="点击复制"
|
||||||
|
@click.stop="copyText(key.name)"
|
||||||
>
|
>
|
||||||
{{ key.name }}
|
{{ key.name }}
|
||||||
</div>
|
</div>
|
||||||
@@ -2114,14 +2117,26 @@
|
|||||||
@close="showUsageDetailModal = false"
|
@close="showUsageDetailModal = false"
|
||||||
@open-timeline="openTimeline"
|
@open-timeline="openTimeline"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
:cancel-text="confirmModalConfig.cancelText"
|
||||||
|
:confirm-text="confirmModalConfig.confirmText"
|
||||||
|
:message="confirmModalConfig.message"
|
||||||
|
:show="showConfirmModal"
|
||||||
|
:title="confirmModalConfig.title"
|
||||||
|
:type="confirmModalConfig.type"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { apiClient } from '@/config/api'
|
import { copyText } from '@/utils/tools'
|
||||||
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import * as XLSX from 'xlsx-js-style'
|
import * as XLSX from 'xlsx-js-style'
|
||||||
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
|
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
|
||||||
@@ -2135,6 +2150,7 @@ import UsageDetailModal from '@/components/apikeys/UsageDetailModal.vue'
|
|||||||
import LimitProgressBar from '@/components/apikeys/LimitProgressBar.vue'
|
import LimitProgressBar from '@/components/apikeys/LimitProgressBar.vue'
|
||||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -2304,6 +2320,39 @@ const renewingApiKey = ref(null)
|
|||||||
const newApiKeyData = ref(null)
|
const newApiKeyData = ref(null)
|
||||||
const batchApiKeyData = ref([])
|
const batchApiKeyData = ref([])
|
||||||
|
|
||||||
|
// ConfirmModal 状态
|
||||||
|
const showConfirmModal = ref(false)
|
||||||
|
const confirmModalConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'primary',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
const confirmResolve = ref(null)
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
type = 'primary'
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||||
|
confirmResolve.value = resolve
|
||||||
|
showConfirmModal.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleConfirm = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(true)
|
||||||
|
}
|
||||||
|
const handleCancel = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
// 计算排序后的API Keys(现在由后端处理,这里直接返回)
|
// 计算排序后的API Keys(现在由后端处理,这里直接返回)
|
||||||
const sortedApiKeys = computed(() => {
|
const sortedApiKeys = computed(() => {
|
||||||
// 后端已经处理了筛选、搜索和排序,直接返回
|
// 后端已经处理了筛选、搜索和排序,直接返回
|
||||||
@@ -2396,15 +2445,15 @@ const loadAccounts = async (forceRefresh = false) => {
|
|||||||
droidData,
|
droidData,
|
||||||
groupsData
|
groupsData
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
apiClient.get('/admin/claude-accounts'),
|
httpApi.getClaudeAccounts(),
|
||||||
apiClient.get('/admin/claude-console-accounts'),
|
httpApi.getClaudeConsoleAccounts(),
|
||||||
apiClient.get('/admin/gemini-accounts'),
|
httpApi.getGeminiAccounts(),
|
||||||
apiClient.get('/admin/gemini-api-accounts'), // 加载 Gemini-API 账号
|
httpApi.getGeminiApiAccounts(),
|
||||||
apiClient.get('/admin/openai-accounts'),
|
httpApi.getOpenAIAccounts(),
|
||||||
apiClient.get('/admin/openai-responses-accounts'), // 加载 OpenAI-Responses 账号
|
httpApi.getOpenAIResponsesAccounts(),
|
||||||
apiClient.get('/admin/bedrock-accounts'),
|
httpApi.getBedrockAccounts(),
|
||||||
apiClient.get('/admin/droid-accounts'),
|
httpApi.getDroidAccounts(),
|
||||||
apiClient.get('/admin/account-groups')
|
httpApi.getAccountGroups()
|
||||||
])
|
])
|
||||||
|
|
||||||
// 合并Claude OAuth账户和Claude Console账户
|
// 合并Claude OAuth账户和Claude Console账户
|
||||||
@@ -2510,7 +2559,7 @@ const loadAccounts = async (forceRefresh = false) => {
|
|||||||
// 加载已使用的模型列表
|
// 加载已使用的模型列表
|
||||||
const loadUsedModels = async () => {
|
const loadUsedModels = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.get('/admin/api-keys/used-models')
|
const data = await httpApi.get('/admin/api-keys/used-models')
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
availableModels.value = data.data || []
|
availableModels.value = data.data || []
|
||||||
}
|
}
|
||||||
@@ -2599,7 +2648,7 @@ const loadApiKeys = async (clearStatsCache = true) => {
|
|||||||
params.set('timeRange', globalDateFilter.preset)
|
params.set('timeRange', globalDateFilter.preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await apiClient.get(`/admin/api-keys?${params.toString()}`)
|
const data = await httpApi.getApiKeysWithParams(params.toString())
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// 更新数据
|
// 更新数据
|
||||||
apiKeys.value = data.data?.items || []
|
apiKeys.value = data.data?.items || []
|
||||||
@@ -2680,7 +2729,7 @@ const loadPageStats = async () => {
|
|||||||
requestBody.endDate = endDate
|
requestBody.endDate = endDate
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.post('/admin/api-keys/batch-stats', requestBody)
|
const response = await httpApi.post('/admin/api-keys/batch-stats', requestBody)
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// 更新缓存
|
// 更新缓存
|
||||||
@@ -2734,7 +2783,7 @@ const loadPageLastUsage = async () => {
|
|||||||
keyIds.forEach((id) => lastUsageLoading.value.add(id))
|
keyIds.forEach((id) => lastUsageLoading.value.add(id))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/api-keys/batch-last-usage', { keyIds })
|
const response = await httpApi.post('/admin/api-keys/batch-last-usage', { keyIds })
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// 更新缓存
|
// 更新缓存
|
||||||
@@ -2765,7 +2814,7 @@ const loadDeletedApiKeys = async () => {
|
|||||||
activeTab.value = 'deleted'
|
activeTab.value = 'deleted'
|
||||||
deletedApiKeysLoading.value = true
|
deletedApiKeysLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.get('/admin/api-keys/deleted')
|
const data = await httpApi.get('/admin/api-keys/deleted')
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
deletedApiKeys.value = data.apiKeys || []
|
deletedApiKeys.value = data.apiKeys || []
|
||||||
}
|
}
|
||||||
@@ -2844,7 +2893,7 @@ let costSortStatusTimer = null
|
|||||||
// 获取费用排序索引状态
|
// 获取费用排序索引状态
|
||||||
const fetchCostSortStatus = async () => {
|
const fetchCostSortStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.get('/admin/api-keys/cost-sort-status')
|
const data = await httpApi.get('/admin/api-keys/cost-sort-status')
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
costSortStatus.value = data.data || {}
|
costSortStatus.value = data.data || {}
|
||||||
|
|
||||||
@@ -3193,7 +3242,7 @@ const loadApiKeyModelStats = async (keyId, forceReload = false) => {
|
|||||||
|
|
||||||
url += '?' + params.toString()
|
url += '?' + params.toString()
|
||||||
|
|
||||||
const data = await apiClient.get(url)
|
const data = await httpApi.get(url)
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
apiKeyModelStats.value[keyId] = data.data || []
|
apiKeyModelStats.value[keyId] = data.data || []
|
||||||
}
|
}
|
||||||
@@ -3845,27 +3894,19 @@ const toggleApiKeyStatus = async (key) => {
|
|||||||
|
|
||||||
// 禁用时需要二次确认
|
// 禁用时需要二次确认
|
||||||
if (key.isActive) {
|
if (key.isActive) {
|
||||||
if (window.showConfirm) {
|
confirmed = await showConfirm(
|
||||||
confirmed = await window.showConfirm(
|
'禁用 API Key',
|
||||||
'禁用 API Key',
|
`确定要禁用 API Key "${key.name}" 吗?禁用后所有使用此 Key 的请求将返回 401 错误。`,
|
||||||
`确定要禁用 API Key "${key.name}" 吗?禁用后所有使用此 Key 的请求将返回 401 错误。`,
|
'确定禁用',
|
||||||
'确定禁用',
|
'取消',
|
||||||
'取消'
|
'warning'
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
confirmed = confirm(
|
|
||||||
`确定要禁用 API Key "${key.name}" 吗?禁用后所有使用此 Key 的请求将返回 401 错误。`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.put(`/admin/api-keys/${key.id}`, {
|
const data = await httpApi.updateApiKey(key.id, { isActive: !key.isActive })
|
||||||
isActive: !key.isActive
|
|
||||||
})
|
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(`API Key 已${key.isActive ? '禁用' : '激活'}`, 'success')
|
showToast(`API Key 已${key.isActive ? '禁用' : '激活'}`, 'success')
|
||||||
@@ -3885,24 +3926,18 @@ const toggleApiKeyStatus = async (key) => {
|
|||||||
// 更新API Key图标
|
// 更新API Key图标
|
||||||
// 删除API Key
|
// 删除API Key
|
||||||
const deleteApiKey = async (keyId) => {
|
const deleteApiKey = async (keyId) => {
|
||||||
let confirmed = false
|
const confirmed = await showConfirm(
|
||||||
|
'删除 API Key',
|
||||||
if (window.showConfirm) {
|
'确定要删除这个 API Key 吗?此操作不可恢复。',
|
||||||
confirmed = await window.showConfirm(
|
'确定删除',
|
||||||
'删除 API Key',
|
'取消',
|
||||||
'确定要删除这个 API Key 吗?此操作不可恢复。',
|
'danger'
|
||||||
'确定删除',
|
)
|
||||||
'取消'
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
confirmed = confirm('确定要删除这个 API Key 吗?此操作不可恢复。')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.delete(`/admin/api-keys/${keyId}`)
|
const data = await httpApi.deleteApiKey(keyId)
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('API Key 已删除', 'success')
|
showToast('API Key 已删除', 'success')
|
||||||
// 从选中列表中移除
|
// 从选中列表中移除
|
||||||
@@ -3922,24 +3957,18 @@ const deleteApiKey = async (keyId) => {
|
|||||||
|
|
||||||
// 恢复API Key
|
// 恢复API Key
|
||||||
const restoreApiKey = async (keyId) => {
|
const restoreApiKey = async (keyId) => {
|
||||||
let confirmed = false
|
const confirmed = await showConfirm(
|
||||||
|
'恢复 API Key',
|
||||||
if (window.showConfirm) {
|
'确定要恢复这个 API Key 吗?恢复后可以重新使用。',
|
||||||
confirmed = await window.showConfirm(
|
'确定恢复',
|
||||||
'恢复 API Key',
|
'取消',
|
||||||
'确定要恢复这个 API Key 吗?恢复后可以重新使用。',
|
'primary'
|
||||||
'确定恢复',
|
)
|
||||||
'取消'
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
confirmed = confirm('确定要恢复这个 API Key 吗?恢复后可以重新使用。')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.post(`/admin/api-keys/${keyId}/restore`)
|
const data = await httpApi.restoreApiKey(keyId)
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('API Key 已成功恢复', 'success')
|
showToast('API Key 已成功恢复', 'success')
|
||||||
// 刷新已删除列表
|
// 刷新已删除列表
|
||||||
@@ -3956,24 +3985,18 @@ const restoreApiKey = async (keyId) => {
|
|||||||
|
|
||||||
// 彻底删除API Key
|
// 彻底删除API Key
|
||||||
const permanentDeleteApiKey = async (keyId) => {
|
const permanentDeleteApiKey = async (keyId) => {
|
||||||
let confirmed = false
|
const confirmed = await showConfirm(
|
||||||
|
'彻底删除 API Key',
|
||||||
if (window.showConfirm) {
|
'确定要彻底删除这个 API Key 吗?此操作不可恢复,所有相关数据将被永久删除。',
|
||||||
confirmed = await window.showConfirm(
|
'确定彻底删除',
|
||||||
'彻底删除 API Key',
|
'取消',
|
||||||
'确定要彻底删除这个 API Key 吗?此操作不可恢复,所有相关数据将被永久删除。',
|
'danger'
|
||||||
'确定彻底删除',
|
)
|
||||||
'取消'
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
confirmed = confirm('确定要彻底删除这个 API Key 吗?此操作不可恢复,所有相关数据将被永久删除。')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.delete(`/admin/api-keys/${keyId}/permanent`)
|
const data = await httpApi.permanentDeleteApiKey(keyId)
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('API Key 已彻底删除', 'success')
|
showToast('API Key 已彻底删除', 'success')
|
||||||
// 刷新已删除列表
|
// 刷新已删除列表
|
||||||
@@ -3994,24 +4017,18 @@ const clearAllDeletedApiKeys = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let confirmed = false
|
const confirmed = await showConfirm(
|
||||||
|
'清空所有已删除的 API Keys',
|
||||||
if (window.showConfirm) {
|
`确定要彻底删除全部 ${count} 个已删除的 API Keys 吗?此操作不可恢复,所有相关数据将被永久删除。`,
|
||||||
confirmed = await window.showConfirm(
|
'确定清空全部',
|
||||||
'清空所有已删除的 API Keys',
|
'取消',
|
||||||
`确定要彻底删除全部 ${count} 个已删除的 API Keys 吗?此操作不可恢复,所有相关数据将被永久删除。`,
|
'danger'
|
||||||
'确定清空全部',
|
)
|
||||||
'取消'
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// 降级方案
|
|
||||||
confirmed = confirm(`确定要彻底删除全部 ${count} 个已删除的 API Keys 吗?此操作不可恢复。`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.delete('/admin/api-keys/deleted/clear-all')
|
const data = await httpApi.del('/admin/api-keys/deleted/clear-all')
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(data.message || '已清空所有已删除的 API Keys', 'success')
|
showToast(data.message || '已清空所有已删除的 API Keys', 'success')
|
||||||
|
|
||||||
@@ -4040,21 +4057,20 @@ const batchDeleteApiKeys = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let confirmed = false
|
const confirmed = await showConfirm(
|
||||||
const message = `确定要删除选中的 ${selectedCount} 个 API Key 吗?此操作不可恢复。`
|
'批量删除 API Keys',
|
||||||
|
`确定要删除选中的 ${selectedCount} 个 API Key 吗?此操作不可恢复。`,
|
||||||
if (window.showConfirm) {
|
'确定删除',
|
||||||
confirmed = await window.showConfirm('批量删除 API Keys', message, '确定删除', '取消')
|
'取消',
|
||||||
} else {
|
'danger'
|
||||||
confirmed = confirm(message)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
const keyIds = [...selectedApiKeys.value]
|
const keyIds = [...selectedApiKeys.value]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.delete('/admin/api-keys/batch', {
|
const data = await httpApi.del('/admin/api-keys/batch', {
|
||||||
data: { keyIds }
|
data: { keyIds }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4136,7 +4152,7 @@ const closeExpiryEdit = () => {
|
|||||||
const handleSaveExpiry = async ({ keyId, expiresAt, activateNow }) => {
|
const handleSaveExpiry = async ({ keyId, expiresAt, activateNow }) => {
|
||||||
try {
|
try {
|
||||||
// 使用新的PATCH端点来修改过期时间
|
// 使用新的PATCH端点来修改过期时间
|
||||||
const data = await apiClient.patch(`/admin/api-keys/${keyId}/expiration`, {
|
const data = await httpApi.updateApiKeyExpiration(keyId, {
|
||||||
expiresAt: expiresAt || null,
|
expiresAt: expiresAt || null,
|
||||||
activateNow: activateNow || false
|
activateNow: activateNow || false
|
||||||
})
|
})
|
||||||
@@ -4873,27 +4889,41 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container::-webkit-scrollbar-track {
|
.dark .table-container::-webkit-scrollbar-track {
|
||||||
background: #374151;
|
background: var(--bg-gradient-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container::-webkit-scrollbar-thumb {
|
.dark .table-container::-webkit-scrollbar-thumb {
|
||||||
background: #4b5563;
|
background: var(--bg-gradient-end);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container::-webkit-scrollbar-thumb:hover {
|
.dark .table-container::-webkit-scrollbar-thumb:hover {
|
||||||
background: #6b7280;
|
background: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-row {
|
/* 统一 hover 背景 - 所有 td 使用主题色 */
|
||||||
transition: background-color 0.2s ease;
|
.table-container tbody tr:hover > td {
|
||||||
|
background-color: rgba(var(--primary-rgb), 0.06) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-row:hover {
|
.dark .table-container tbody tr:hover > td {
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
background-color: rgba(var(--primary-rgb), 0.16) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-row:hover {
|
/* 所有 td 的斑马纹背景 */
|
||||||
background-color: rgba(255, 255, 255, 0.02);
|
.table-container tbody tr:nth-child(odd) > td {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container tbody tr:nth-child(even) > td {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .table-container tbody tr:nth-child(odd) > td {
|
||||||
|
background-color: var(--bg-gradient-start);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .table-container tbody tr:nth-child(even) > td {
|
||||||
|
background-color: var(--bg-gradient-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 固定操作列在右侧,兼容浅色和深色模式 */
|
/* 固定操作列在右侧,兼容浅色和深色模式 */
|
||||||
@@ -4910,33 +4940,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .table-container thead .operations-column {
|
.dark .table-container thead .operations-column {
|
||||||
background: linear-gradient(to bottom, #374151, #1f2937);
|
background: linear-gradient(to bottom, var(--bg-gradient-mid), var(--bg-gradient-start));
|
||||||
}
|
|
||||||
|
|
||||||
/* tbody 中的操作列背景处理 - 使用纯色避免滚动时重叠 */
|
|
||||||
.table-container tbody tr:nth-child(odd) .operations-column {
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container tbody tr:nth-child(even) .operations-column {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .table-container tbody tr:nth-child(odd) .operations-column {
|
|
||||||
background-color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .table-container tbody tr:nth-child(even) .operations-column {
|
|
||||||
background-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* hover 状态下的操作列背景 */
|
|
||||||
.table-container tbody tr:hover .operations-column {
|
|
||||||
background-color: #eff6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .table-container tbody tr:hover .operations-column {
|
|
||||||
background-color: #1e3a5f;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-container tbody .operations-column {
|
.table-container tbody .operations-column {
|
||||||
@@ -4963,39 +4967,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.dark .table-container thead .checkbox-column,
|
.dark .table-container thead .checkbox-column,
|
||||||
.dark .table-container thead .name-column {
|
.dark .table-container thead .name-column {
|
||||||
background: linear-gradient(to bottom, #374151, #1f2937);
|
background: linear-gradient(to bottom, var(--bg-gradient-mid), var(--bg-gradient-start));
|
||||||
}
|
|
||||||
|
|
||||||
/* tbody 中的左侧固定列背景处理 - 使用纯色避免滚动时重叠 */
|
|
||||||
.table-container tbody tr:nth-child(odd) .checkbox-column,
|
|
||||||
.table-container tbody tr:nth-child(odd) .name-column {
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container tbody tr:nth-child(even) .checkbox-column,
|
|
||||||
.table-container tbody tr:nth-child(even) .name-column {
|
|
||||||
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: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .table-container tbody tr:nth-child(even) .checkbox-column,
|
|
||||||
.dark .table-container tbody tr:nth-child(even) .name-column {
|
|
||||||
background-color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* hover 状态下的左侧固定列背景 */
|
|
||||||
.table-container tbody tr:hover .checkbox-column,
|
|
||||||
.table-container tbody tr:hover .name-column {
|
|
||||||
background-color: #eff6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .table-container tbody tr:hover .checkbox-column,
|
|
||||||
.dark .table-container tbody tr:hover .name-column {
|
|
||||||
background-color: #1e3a5f;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 名称列右侧阴影(分隔效果) */
|
/* 名称列右侧阴影(分隔效果) */
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen p-4 md:p-6" :class="isDarkMode ? 'gradient-bg-dark' : 'gradient-bg'">
|
<div
|
||||||
|
class="min-h-screen p-2 sm:p-4 md:p-6"
|
||||||
|
:class="isDarkMode ? 'gradient-bg-dark' : 'gradient-bg'"
|
||||||
|
>
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<div class="glass-strong mb-6 rounded-3xl p-4 shadow-xl md:mb-8 md:p-6">
|
<div
|
||||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
class="glass-strong mb-4 rounded-2xl p-3 shadow-xl sm:mb-6 sm:rounded-3xl sm:p-4 md:mb-8 md:p-6"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center justify-between gap-3 sm:gap-4 md:flex-row">
|
||||||
<LogoTitle
|
<LogoTitle
|
||||||
:loading="oemLoading"
|
:loading="oemLoading"
|
||||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
@@ -44,10 +49,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab 切换 -->
|
<!-- Tab 切换 -->
|
||||||
<div class="mb-6 md:mb-8">
|
<div class="mb-4 sm:mb-6 md:mb-8">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div
|
<div
|
||||||
class="inline-flex w-full max-w-md rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl md:w-auto"
|
class="inline-flex w-full max-w-md rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl sm:w-auto"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']"
|
:class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']"
|
||||||
@@ -73,7 +78,7 @@
|
|||||||
<ApiKeyInput />
|
<ApiKeyInput />
|
||||||
|
|
||||||
<!-- 错误提示 -->
|
<!-- 错误提示 -->
|
||||||
<div v-if="error" class="mb-6 md:mb-8">
|
<div v-if="error" class="mb-4 sm:mb-6 md:mb-8">
|
||||||
<div
|
<div
|
||||||
class="rounded-xl border border-red-500/30 bg-red-500/20 p-3 text-sm text-red-800 backdrop-blur-sm dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-200 md:p-4 md:text-base"
|
class="rounded-xl border border-red-500/30 bg-red-500/20 p-3 text-sm text-red-800 backdrop-blur-sm dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-200 md:p-4 md:text-base"
|
||||||
>
|
>
|
||||||
@@ -84,11 +89,13 @@
|
|||||||
|
|
||||||
<!-- 统计数据展示区域 -->
|
<!-- 统计数据展示区域 -->
|
||||||
<div v-if="statsData" class="fade-in">
|
<div v-if="statsData" class="fade-in">
|
||||||
<div class="glass-strong rounded-3xl p-4 shadow-xl md:p-6">
|
<div class="glass-strong rounded-2xl p-3 shadow-xl sm:rounded-3xl sm:p-4 md:p-6">
|
||||||
<!-- 时间范围选择器 -->
|
<!-- 时间范围选择器 -->
|
||||||
<div class="mb-4 border-b border-gray-200 pb-4 dark:border-gray-700 md:mb-6 md:pb-6">
|
<div
|
||||||
|
class="mb-3 border-b border-gray-200 pb-3 dark:border-gray-700 sm:mb-4 sm:pb-4 md:mb-6 md:pb-6"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-start justify-between gap-3 md:flex-row md:items-center md:gap-4"
|
class="flex flex-col items-start justify-between gap-2 sm:gap-3 md:flex-row md:items-center md:gap-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 md:gap-3">
|
<div class="flex items-center gap-2 md:gap-3">
|
||||||
<i class="fas fa-clock text-base text-blue-500 md:text-lg" />
|
<i class="fas fa-clock text-base text-blue-500 md:text-lg" />
|
||||||
@@ -100,7 +107,7 @@
|
|||||||
<button
|
<button
|
||||||
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
|
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
|
||||||
:class="['period-btn', { active: statsPeriod === 'daily' }]"
|
:class="['period-btn', { active: statsPeriod === 'daily' }]"
|
||||||
:disabled="loading || modelStatsLoading"
|
:disabled="loading"
|
||||||
@click="switchPeriod('daily')"
|
@click="switchPeriod('daily')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-calendar-day text-xs md:text-sm" />
|
<i class="fas fa-calendar-day text-xs md:text-sm" />
|
||||||
@@ -109,22 +116,71 @@
|
|||||||
<button
|
<button
|
||||||
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
|
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
|
||||||
:class="['period-btn', { active: statsPeriod === 'monthly' }]"
|
:class="['period-btn', { active: statsPeriod === 'monthly' }]"
|
||||||
:disabled="loading || modelStatsLoading"
|
:disabled="loading"
|
||||||
@click="switchPeriod('monthly')"
|
@click="switchPeriod('monthly')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-calendar-alt text-xs md:text-sm" />
|
<i class="fas fa-calendar-alt text-xs md:text-sm" />
|
||||||
本月
|
本月
|
||||||
</button>
|
</button>
|
||||||
<!-- 测试按钮 - 仅在单Key模式下显示 -->
|
|
||||||
<button
|
<button
|
||||||
v-if="!multiKeyMode"
|
class="flex flex-1 items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:flex-none md:gap-2 md:px-6 md:text-sm"
|
||||||
class="test-btn flex items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:gap-2 md:px-6 md:text-sm"
|
:class="['period-btn', { active: statsPeriod === 'alltime' }]"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="openTestModal"
|
@click="switchPeriod('alltime')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-vial text-xs md:text-sm" />
|
<i class="fas fa-infinity text-xs md:text-sm" />
|
||||||
测试
|
全部
|
||||||
</button>
|
</button>
|
||||||
|
<!-- 测试按钮下拉菜单 - 仅在单Key模式下显示 -->
|
||||||
|
<div v-if="!multiKeyMode" class="relative">
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'test-btn flex items-center justify-center gap-1 px-4 py-2 text-xs font-medium md:gap-2 md:px-6 md:text-sm',
|
||||||
|
!hasAnyTestPermission ? 'cursor-not-allowed opacity-50' : ''
|
||||||
|
]"
|
||||||
|
:disabled="loading || !hasAnyTestPermission"
|
||||||
|
:title="
|
||||||
|
hasAnyTestPermission
|
||||||
|
? '测试 API'
|
||||||
|
: `当前 Key 可用服务: ${availableServicesText}`
|
||||||
|
"
|
||||||
|
@click="toggleTestMenu"
|
||||||
|
>
|
||||||
|
<i class="fas fa-vial text-xs md:text-sm" />
|
||||||
|
测试
|
||||||
|
<i class="fas fa-chevron-down ml-1 text-xs" />
|
||||||
|
</button>
|
||||||
|
<!-- 下拉菜单 -->
|
||||||
|
<div
|
||||||
|
v-if="showTestMenu"
|
||||||
|
class="absolute right-0 top-full z-50 mt-1 min-w-[140px] overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="canTestClaude"
|
||||||
|
class="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
@click="openTestModal('claude')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot text-orange-500" />
|
||||||
|
Claude
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canTestGemini"
|
||||||
|
class="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
@click="openTestModal('gemini')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-gem text-blue-500" />
|
||||||
|
Gemini
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canTestOpenAI"
|
||||||
|
class="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
@click="openTestModal('openai')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-code text-green-500" />
|
||||||
|
Codex
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +190,7 @@
|
|||||||
|
|
||||||
<!-- Token 分布和限制配置 -->
|
<!-- Token 分布和限制配置 -->
|
||||||
<div
|
<div
|
||||||
class="mb-6 mt-6 grid grid-cols-1 gap-4 md:mb-8 md:mt-8 md:gap-6 xl:grid-cols-2 xl:items-stretch"
|
class="mb-4 mt-4 grid grid-cols-1 gap-3 sm:mb-6 sm:mt-6 sm:gap-4 md:mb-8 md:mt-8 md:gap-6 xl:grid-cols-2 xl:items-stretch"
|
||||||
>
|
>
|
||||||
<TokenDistribution class="h-full" />
|
<TokenDistribution class="h-full" />
|
||||||
<template v-if="multiKeyMode">
|
<template v-if="multiKeyMode">
|
||||||
@@ -145,8 +201,15 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模型使用统计 -->
|
<!-- 服务费用统计卡片 -->
|
||||||
<ModelUsageStats />
|
<ServiceCostCards class="mb-4 sm:mb-6" />
|
||||||
|
|
||||||
|
<!-- 模型使用统计 - 三个时间段 -->
|
||||||
|
<div class="space-y-4 sm:space-y-6">
|
||||||
|
<ModelUsageStats period="daily" />
|
||||||
|
<ModelUsageStats period="monthly" />
|
||||||
|
<ModelUsageStats period="alltime" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,9 +225,56 @@
|
|||||||
<ApiKeyTestModal
|
<ApiKeyTestModal
|
||||||
:api-key-name="statsData?.name || ''"
|
:api-key-name="statsData?.name || ''"
|
||||||
:api-key-value="apiKey"
|
:api-key-value="apiKey"
|
||||||
|
:service-type="testServiceType"
|
||||||
:show="showTestModal"
|
:show="showTestModal"
|
||||||
@close="closeTestModal"
|
@close="closeTestModal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- API Stats 通知弹框 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="showNotice"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||||
|
@click.self="dismissNotice"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-800"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-500 to-orange-600 text-white"
|
||||||
|
>
|
||||||
|
<i class="fas fa-bell" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ oemSettings.apiStatsNotice?.title || '通知' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="mb-4 whitespace-pre-wrap text-sm leading-relaxed text-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ oemSettings.apiStatsNotice?.content }}
|
||||||
|
</p>
|
||||||
|
<label class="mb-4 flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="dontShowAgain"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-blue-500 focus:ring-blue-500"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">本次会话不再显示</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="w-full rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-4 py-2.5 font-medium text-white transition-all hover:from-blue-600 hover:to-cyan-600"
|
||||||
|
@click="dismissNotice"
|
||||||
|
>
|
||||||
|
知道了
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -182,6 +292,7 @@ import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
|
|||||||
import LimitConfig from '@/components/apistats/LimitConfig.vue'
|
import LimitConfig from '@/components/apistats/LimitConfig.vue'
|
||||||
import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
|
import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
|
||||||
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
||||||
|
import ServiceCostCards from '@/components/apistats/ServiceCostCards.vue'
|
||||||
import TutorialView from './TutorialView.vue'
|
import TutorialView from './TutorialView.vue'
|
||||||
import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue'
|
import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue'
|
||||||
|
|
||||||
@@ -199,7 +310,6 @@ const {
|
|||||||
apiKey,
|
apiKey,
|
||||||
apiId,
|
apiId,
|
||||||
loading,
|
loading,
|
||||||
modelStatsLoading,
|
|
||||||
oemLoading,
|
oemLoading,
|
||||||
error,
|
error,
|
||||||
statsPeriod,
|
statsPeriod,
|
||||||
@@ -208,13 +318,77 @@ const {
|
|||||||
multiKeyMode
|
multiKeyMode
|
||||||
} = storeToRefs(apiStatsStore)
|
} = storeToRefs(apiStatsStore)
|
||||||
|
|
||||||
const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore
|
const {
|
||||||
|
queryStats,
|
||||||
|
switchPeriod,
|
||||||
|
loadStatsWithApiId,
|
||||||
|
loadOemSettings,
|
||||||
|
loadServiceRates,
|
||||||
|
loadApiKeyFromStorage,
|
||||||
|
reset
|
||||||
|
} = apiStatsStore
|
||||||
|
|
||||||
// 测试弹窗状态
|
// 测试弹窗状态
|
||||||
const showTestModal = ref(false)
|
const showTestModal = ref(false)
|
||||||
|
const showTestMenu = ref(false)
|
||||||
|
const testServiceType = ref('claude')
|
||||||
|
|
||||||
|
// 通知弹框状态
|
||||||
|
const showNotice = ref(false)
|
||||||
|
const dontShowAgain = ref(false)
|
||||||
|
const NOTICE_STORAGE_KEY = 'apiStatsNoticeRead'
|
||||||
|
|
||||||
|
// 检查是否可以测试 Claude(权限包含 claude 或 all)
|
||||||
|
const canTestClaude = computed(() => {
|
||||||
|
const permissions = statsData.value?.permissions
|
||||||
|
if (!permissions) return true // 默认允许
|
||||||
|
return permissions === 'all' || permissions.includes('claude')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查是否可以测试 Gemini
|
||||||
|
const canTestGemini = computed(() => {
|
||||||
|
const permissions = statsData.value?.permissions
|
||||||
|
if (!permissions) return true
|
||||||
|
return permissions === 'all' || permissions.includes('gemini')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查是否可以测试 OpenAI
|
||||||
|
const canTestOpenAI = computed(() => {
|
||||||
|
const permissions = statsData.value?.permissions
|
||||||
|
if (!permissions) return true
|
||||||
|
return permissions === 'all' || permissions.includes('openai')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查是否有任何测试权限
|
||||||
|
const hasAnyTestPermission = computed(() => {
|
||||||
|
return canTestClaude.value || canTestGemini.value || canTestOpenAI.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 可用服务文本
|
||||||
|
const availableServicesText = computed(() => {
|
||||||
|
const permissions = statsData.value?.permissions
|
||||||
|
if (!permissions || permissions === 'all') return '全部服务'
|
||||||
|
const serviceNames = {
|
||||||
|
claude: 'Claude',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
openai: 'OpenAI',
|
||||||
|
droid: 'Droid'
|
||||||
|
}
|
||||||
|
return permissions
|
||||||
|
.split(',')
|
||||||
|
.map((s) => serviceNames[s.trim()] || s.trim())
|
||||||
|
.join(', ')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 切换测试菜单
|
||||||
|
const toggleTestMenu = () => {
|
||||||
|
showTestMenu.value = !showTestMenu.value
|
||||||
|
}
|
||||||
|
|
||||||
// 打开测试弹窗
|
// 打开测试弹窗
|
||||||
const openTestModal = () => {
|
const openTestModal = (serviceType = 'claude') => {
|
||||||
|
testServiceType.value = serviceType
|
||||||
|
showTestMenu.value = false
|
||||||
showTestModal.value = true
|
showTestModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +397,29 @@ const closeTestModal = () => {
|
|||||||
showTestModal.value = false
|
showTestModal.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关闭通知弹框
|
||||||
|
const dismissNotice = () => {
|
||||||
|
showNotice.value = false
|
||||||
|
if (dontShowAgain.value) {
|
||||||
|
sessionStorage.setItem(NOTICE_STORAGE_KEY, '1')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否显示通知
|
||||||
|
const checkNotice = () => {
|
||||||
|
const notice = oemSettings.value?.apiStatsNotice
|
||||||
|
if (notice?.enabled && notice?.content && !sessionStorage.getItem(NOTICE_STORAGE_KEY)) {
|
||||||
|
showNotice.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭菜单
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (showTestMenu.value && !event.target.closest('.relative')) {
|
||||||
|
showTestMenu.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理键盘快捷键
|
// 处理键盘快捷键
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
// Ctrl/Cmd + Enter 查询
|
// Ctrl/Cmd + Enter 查询
|
||||||
@@ -240,14 +437,15 @@ const handleKeyDown = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// API Stats Page loaded
|
// API Stats Page loaded
|
||||||
|
|
||||||
// 初始化主题(因为该页面不在 MainLayout 内)
|
// 初始化主题(因为该页面不在 MainLayout 内)
|
||||||
themeStore.initTheme()
|
themeStore.initTheme()
|
||||||
|
|
||||||
// 加载 OEM 设置
|
// 加载 OEM 设置和服务倍率
|
||||||
loadOemSettings()
|
await Promise.all([loadOemSettings(), loadServiceRates()])
|
||||||
|
checkNotice()
|
||||||
|
|
||||||
// 检查 URL 参数
|
// 检查 URL 参数
|
||||||
const urlApiId = route.query.apiId
|
const urlApiId = route.query.apiId
|
||||||
@@ -259,19 +457,34 @@ onMounted(() => {
|
|||||||
) {
|
) {
|
||||||
// 如果 URL 中有 apiId,直接使用 apiId 加载数据
|
// 如果 URL 中有 apiId,直接使用 apiId 加载数据
|
||||||
apiId.value = urlApiId
|
apiId.value = urlApiId
|
||||||
|
// 同时从 localStorage 填充 API Key 到输入框
|
||||||
|
const savedApiKey = loadApiKeyFromStorage()
|
||||||
|
if (savedApiKey) {
|
||||||
|
apiKey.value = savedApiKey
|
||||||
|
}
|
||||||
loadStatsWithApiId()
|
loadStatsWithApiId()
|
||||||
} else if (urlApiKey && urlApiKey.length > 10) {
|
} else if (urlApiKey && urlApiKey.length > 10) {
|
||||||
// 向后兼容,支持 apiKey 参数
|
// 向后兼容,支持 apiKey 参数
|
||||||
apiKey.value = urlApiKey
|
apiKey.value = urlApiKey
|
||||||
|
} else {
|
||||||
|
// 没有 URL 参数,检查 localStorage
|
||||||
|
const savedApiKey = loadApiKeyFromStorage()
|
||||||
|
if (savedApiKey && savedApiKey.length > 10) {
|
||||||
|
apiKey.value = savedApiKey
|
||||||
|
queryStats()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加键盘事件监听
|
// 添加键盘事件监听
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
// 添加点击外部关闭菜单监听
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听 API Key 变化
|
// 监听 API Key 变化
|
||||||
@@ -285,7 +498,12 @@ watch(apiKey, (newValue) => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
/* 渐变背景 */
|
/* 渐变背景 */
|
||||||
.gradient-bg {
|
.gradient-bg {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--bg-gradient-start) 0%,
|
||||||
|
var(--bg-gradient-mid) 50%,
|
||||||
|
var(--bg-gradient-end) 100%
|
||||||
|
);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -293,7 +511,12 @@ watch(apiKey, (newValue) => {
|
|||||||
|
|
||||||
/* 暗色模式的渐变背景 */
|
/* 暗色模式的渐变背景 */
|
||||||
.gradient-bg-dark {
|
.gradient-bg-dark {
|
||||||
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--bg-gradient-start) 0%,
|
||||||
|
var(--bg-gradient-mid) 50%,
|
||||||
|
var(--bg-gradient-end) 100%
|
||||||
|
);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -307,9 +530,9 @@ watch(apiKey, (newValue) => {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
radial-gradient(circle at 20% 80%, rgba(var(--accent-rgb), 0.2) 0%, transparent 50%),
|
||||||
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
radial-gradient(circle at 80% 20%, rgba(var(--primary-rgb), 0.2) 0%, transparent 50%),
|
||||||
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -323,9 +546,9 @@ watch(apiKey, (newValue) => {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 20% 80%, rgba(100, 116, 139, 0.1) 0%, transparent 50%),
|
radial-gradient(circle at 20% 80%, rgba(var(--accent-rgb), 0.1) 0%, transparent 50%),
|
||||||
radial-gradient(circle at 80% 20%, rgba(71, 85, 105, 0.1) 0%, transparent 50%),
|
radial-gradient(circle at 80% 20%, rgba(var(--primary-rgb), 0.1) 0%, transparent 50%),
|
||||||
radial-gradient(circle at 40% 40%, rgba(30, 41, 59, 0.1) 0%, transparent 50%);
|
radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -353,7 +576,7 @@ watch(apiKey, (newValue) => {
|
|||||||
|
|
||||||
/* 标题渐变 */
|
/* 标题渐变 */
|
||||||
.header-title {
|
.header-title {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -430,13 +653,13 @@ watch(apiKey, (newValue) => {
|
|||||||
|
|
||||||
/* 管理后台按钮 - 精致版本 */
|
/* 管理后台按钮 - 精致版本 */
|
||||||
.admin-button-refined {
|
.admin-button-refined {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 12px rgba(102, 126, 234, 0.25),
|
0 4px 12px rgba(var(--primary-rgb), 0.25),
|
||||||
inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -460,16 +683,16 @@ watch(apiKey, (newValue) => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
background: linear-gradient(135deg, var(--secondary-color) 0%, var(--primary-color) 100%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-button-refined:hover {
|
.admin-button-refined:hover {
|
||||||
transform: translateY(-2px) scale(1.02);
|
transform: translateY(-2px) scale(1.02);
|
||||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
background: linear-gradient(135deg, var(--secondary-color) 0%, var(--primary-color) 100%);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 20px rgba(118, 75, 162, 0.35),
|
0 8px 20px rgba(var(--secondary-rgb), 0.35),
|
||||||
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
border-color: rgba(255, 255, 255, 0.4);
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -481,10 +704,10 @@ watch(apiKey, (newValue) => {
|
|||||||
|
|
||||||
/* 暗色模式下的悬停效果 */
|
/* 暗色模式下的悬停效果 */
|
||||||
:global(.dark) .admin-button-refined:hover {
|
:global(.dark) .admin-button-refined:hover {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
border-color: rgba(147, 51, 234, 0.4);
|
border-color: rgba(var(--secondary-rgb), 0.4);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 20px rgba(102, 126, 234, 0.3),
|
0 8px 20px rgba(var(--primary-rgb), 0.3),
|
||||||
inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -513,11 +736,11 @@ watch(apiKey, (newValue) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.period-btn.active {
|
.period-btn.active {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
0 10px 15px -3px rgba(var(--primary-rgb), 0.3),
|
||||||
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
0 4px 6px -2px rgba(var(--primary-rgb), 0.05);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,7 +841,7 @@ watch(apiKey, (newValue) => {
|
|||||||
|
|
||||||
.tab-pill-button.active {
|
.tab-pill-button.active {
|
||||||
background: white;
|
background: white;
|
||||||
color: #764ba2;
|
color: var(--secondary-color);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
@@ -667,4 +890,14 @@ watch(apiKey, (newValue) => {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 通知弹框动画 */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -899,15 +899,15 @@ function createUsageTrendChart() {
|
|||||||
{
|
{
|
||||||
label: '输入Token',
|
label: '输入Token',
|
||||||
data: inputData,
|
data: inputData,
|
||||||
borderColor: 'rgb(102, 126, 234)',
|
borderColor: themeStore.currentColorScheme.primary,
|
||||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
backgroundColor: `${themeStore.currentColorScheme.primary}1a`,
|
||||||
tension: 0.3
|
tension: 0.3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '输出Token',
|
label: '输出Token',
|
||||||
data: outputData,
|
data: outputData,
|
||||||
borderColor: 'rgb(240, 147, 251)',
|
borderColor: themeStore.currentColorScheme.accent,
|
||||||
backgroundColor: 'rgba(240, 147, 251, 0.1)',
|
backgroundColor: `${themeStore.currentColorScheme.accent}1a`,
|
||||||
tension: 0.3
|
tension: 0.3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -920,8 +920,8 @@ function createUsageTrendChart() {
|
|||||||
{
|
{
|
||||||
label: '缓存读取Token',
|
label: '缓存读取Token',
|
||||||
data: cacheReadData,
|
data: cacheReadData,
|
||||||
borderColor: 'rgb(147, 51, 234)',
|
borderColor: themeStore.currentColorScheme.secondary,
|
||||||
backgroundColor: 'rgba(147, 51, 234, 0.1)',
|
backgroundColor: `${themeStore.currentColorScheme.secondary}1a`,
|
||||||
tension: 0.3
|
tension: 0.3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1569,6 +1569,19 @@ watch(isDarkMode, () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听色系变化,重新创建图表
|
||||||
|
watch(
|
||||||
|
() => themeStore.colorScheme,
|
||||||
|
() => {
|
||||||
|
nextTick(() => {
|
||||||
|
createModelUsageChart()
|
||||||
|
createUsageTrendChart()
|
||||||
|
createApiKeysUsageTrendChart()
|
||||||
|
createAccountUsageTrendChart()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 加载所有数据
|
// 加载所有数据
|
||||||
|
|||||||
987
web/admin-spa/src/views/QuotaCardsView.vue
Normal file
987
web/admin-spa/src/views/QuotaCardsView.vue
Normal file
@@ -0,0 +1,987 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="card p-4 sm:p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-4 flex flex-col gap-4 sm:mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100 sm:mb-2 sm:text-xl">
|
||||||
|
额度卡管理
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 sm:text-base">
|
||||||
|
管理额度卡和时间卡,用户可核销增加额度
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus mr-2" />
|
||||||
|
创建卡片
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||||
|
总卡片数
|
||||||
|
</p>
|
||||||
|
<p class="text-xl font-bold text-gray-900 dark:text-gray-100 sm:text-2xl">
|
||||||
|
{{ stats.total }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-blue-500 to-blue-600">
|
||||||
|
<i class="fas fa-ticket-alt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||||
|
未使用
|
||||||
|
</p>
|
||||||
|
<p class="text-xl font-bold text-green-600 dark:text-green-400 sm:text-2xl">
|
||||||
|
{{ stats.unused }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-green-500 to-green-600">
|
||||||
|
<i class="fas fa-check-circle" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||||
|
已核销
|
||||||
|
</p>
|
||||||
|
<p class="text-xl font-bold text-purple-600 dark:text-purple-400 sm:text-2xl">
|
||||||
|
{{ stats.redeemed }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-purple-500 to-purple-600">
|
||||||
|
<i class="fas fa-exchange-alt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||||
|
已撤销
|
||||||
|
</p>
|
||||||
|
<p class="text-xl font-bold text-red-600 dark:text-red-400 sm:text-2xl">
|
||||||
|
{{ stats.revoked }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-red-500 to-red-600">
|
||||||
|
<i class="fas fa-ban" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav aria-label="Tabs" class="-mb-px flex space-x-8">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
:class="[
|
||||||
|
'whitespace-nowrap border-b-2 px-1 py-2 text-sm font-medium',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300'
|
||||||
|
]"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>
|
||||||
|
{{ tab.name }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2 text-blue-500" />
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cards Table -->
|
||||||
|
<div v-else-if="activeTab === 'cards'" class="overflow-x-auto">
|
||||||
|
<!-- Batch Actions -->
|
||||||
|
<div
|
||||||
|
v-if="selectedCards.length > 0"
|
||||||
|
class="mb-3 flex items-center gap-3 rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
已选择 {{ selectedCards.length }} 张卡片
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-red-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-600"
|
||||||
|
@click="deleteSelectedCards"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash mr-1" />
|
||||||
|
批量删除
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
|
@click="selectedCards = []"
|
||||||
|
>
|
||||||
|
取消选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="w-10 px-4 py-3">
|
||||||
|
<input
|
||||||
|
:checked="isAllSelected"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
:indeterminate="isIndeterminate"
|
||||||
|
type="checkbox"
|
||||||
|
@change="toggleSelectAll"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
创建时间
|
||||||
|
</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-700 dark:bg-gray-800">
|
||||||
|
<tr
|
||||||
|
v-for="card in cards"
|
||||||
|
:key="card.id"
|
||||||
|
:class="[
|
||||||
|
'hover:bg-gray-50 dark:hover:bg-gray-700/50',
|
||||||
|
selectedCards.includes(card.id) ? 'bg-blue-50 dark:bg-blue-900/10' : ''
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<input
|
||||||
|
v-if="card.status === 'unused'"
|
||||||
|
:checked="selectedCards.includes(card.id)"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
@change="toggleSelectCard(card.id)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<code
|
||||||
|
class="cursor-pointer rounded bg-gray-100 px-2 py-1 font-mono text-xs hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||||
|
title="点击复制"
|
||||||
|
@click="copyText(card.code)"
|
||||||
|
>
|
||||||
|
{{ card.code }}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex rounded-full px-2 py-1 text-xs font-medium',
|
||||||
|
card.type === 'quota'
|
||||||
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: card.type === 'time'
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||||
|
: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
card.type === 'quota' ? '额度卡' : card.type === 'time' ? '时间卡' : '组合卡'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||||
|
<span v-if="card.type === 'quota' || card.type === 'combo'"
|
||||||
|
>{{ card.quotaAmount }} CC</span
|
||||||
|
>
|
||||||
|
<span v-if="card.type === 'combo'"> + </span>
|
||||||
|
<span v-if="card.type === 'time' || card.type === 'combo'">
|
||||||
|
{{ card.timeAmount }}
|
||||||
|
{{ card.timeUnit === 'hours' ? '小时' : card.timeUnit === 'days' ? '天' : '月' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex rounded-full px-2 py-1 text-xs font-medium',
|
||||||
|
card.status === 'unused'
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||||
|
: card.status === 'redeemed'
|
||||||
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
card.status === 'unused'
|
||||||
|
? '未使用'
|
||||||
|
: card.status === 'redeemed'
|
||||||
|
? '已核销'
|
||||||
|
: '已撤销'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ card.redeemedByUsername || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatDate(card.createdAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
v-if="card.status === 'unused'"
|
||||||
|
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
title="删除"
|
||||||
|
@click="deleteCard(card)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="cards.length === 0">
|
||||||
|
<td
|
||||||
|
class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
colspan="8"
|
||||||
|
>
|
||||||
|
暂无卡片数据
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div
|
||||||
|
v-if="totalCards > 0"
|
||||||
|
class="flex flex-col items-center justify-between gap-3 border-t border-gray-200 px-4 py-3 dark:border-gray-700 sm:flex-row"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
共 {{ totalCards }} 条记录
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">每页</span>
|
||||||
|
<select
|
||||||
|
v-model="pageSize"
|
||||||
|
class="rounded-md border border-gray-200 bg-white px-2 py-1 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||||
|
@change="changePageSize"
|
||||||
|
>
|
||||||
|
<option v-for="size in pageSizeOptions" :key="size" :value="size">
|
||||||
|
{{ size }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
@click="changePage(currentPage - 1)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chevron-left" />
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ currentPage }} / {{ totalPages }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
:disabled="currentPage >= totalPages"
|
||||||
|
@click="changePage(currentPage + 1)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chevron-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Redemptions Table -->
|
||||||
|
<div v-else-if="activeTab === 'redemptions'" class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
API Key
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
增加额度
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
状态
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
核销时间
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-4 py-3 text-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-700 dark:bg-gray-800">
|
||||||
|
<tr
|
||||||
|
v-for="redemption in redemptions"
|
||||||
|
:key="redemption.id"
|
||||||
|
class="hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||||
|
>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<code
|
||||||
|
class="cursor-pointer rounded bg-gray-100 px-2 py-1 font-mono text-xs hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||||
|
title="点击复制"
|
||||||
|
@click="copyText(redemption.cardCode)"
|
||||||
|
>
|
||||||
|
{{ redemption.cardCode }}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="cursor-pointer text-sm text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
|
||||||
|
title="点击复制"
|
||||||
|
@click="copyText(redemption.username || redemption.userId)"
|
||||||
|
>
|
||||||
|
{{ redemption.username || redemption.userId }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="cursor-pointer text-sm text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
|
||||||
|
title="点击复制"
|
||||||
|
@click="copyText(redemption.apiKeyName || redemption.apiKeyId)"
|
||||||
|
>
|
||||||
|
{{ redemption.apiKeyName || redemption.apiKeyId }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||||
|
<span v-if="redemption.quotaAdded > 0">{{ redemption.quotaAdded }} CC</span>
|
||||||
|
<span v-if="redemption.quotaAdded > 0 && redemption.timeAdded > 0"> + </span>
|
||||||
|
<span v-if="redemption.timeAdded > 0">
|
||||||
|
{{ redemption.timeAdded }}
|
||||||
|
{{
|
||||||
|
redemption.timeUnit === 'hours'
|
||||||
|
? '小时'
|
||||||
|
: redemption.timeUnit === 'days'
|
||||||
|
? '天'
|
||||||
|
: '月'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex rounded-full px-2 py-1 text-xs font-medium',
|
||||||
|
redemption.status === 'active'
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ redemption.status === 'active' ? '有效' : '已撤销' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatDate(redemption.timestamp) }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
v-if="redemption.status === 'active'"
|
||||||
|
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
title="撤销核销"
|
||||||
|
@click="revokeRedemption(redemption)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-undo" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="redemptions.length === 0">
|
||||||
|
<td
|
||||||
|
class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
colspan="7"
|
||||||
|
>
|
||||||
|
暂无核销记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Card Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showCreateModal"
|
||||||
|
class="modal fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="modal-content mx-auto w-full max-w-lg p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
|
>
|
||||||
|
<i class="fas fa-ticket-alt text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">创建额度卡</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="p-1 text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
@click="showCreateModal = false"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>卡片类型</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-model="newCard.type"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="quota">额度卡</option>
|
||||||
|
<option value="time">时间卡</option>
|
||||||
|
<option value="combo">组合卡</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="newCard.type === 'quota' || newCard.type === 'combo'">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>额度数量 (CC)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="newCard.quotaAmount"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="newCard.type === 'time' || newCard.type === 'combo'">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>时间数量</label
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model.number="newCard.timeAmount"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
min="1"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-model="newCard.timeUnit"
|
||||||
|
class="block rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="hours">小时</option>
|
||||||
|
<option value="days">天</option>
|
||||||
|
<option value="months">月</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>批量生成数量</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model.number="newCard.count"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
max="100"
|
||||||
|
min="1"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>备注(可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="newCard.note"
|
||||||
|
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="例如:新年促销卡"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="mt-6 flex gap-3">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-gray-100 px-4 py-2.5 font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
|
type="button"
|
||||||
|
@click="showCreateModal = false"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-2.5 font-medium text-white shadow-sm transition-colors hover:from-blue-600 hover:to-blue-700 disabled:opacity-50"
|
||||||
|
:disabled="creating"
|
||||||
|
type="button"
|
||||||
|
@click="createCard"
|
||||||
|
>
|
||||||
|
<i v-if="creating" class="fas fa-spinner fa-spin mr-2" />
|
||||||
|
{{ creating ? '创建中...' : '创建' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Result Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showResultModal"
|
||||||
|
class="modal fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="modal-content mx-auto w-full max-w-lg p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600"
|
||||||
|
>
|
||||||
|
<i class="fas fa-check text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">创建成功</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
已创建 {{ createdCards.length }} 张卡片
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="p-1 text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
@click="showResultModal = false"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card List -->
|
||||||
|
<div class="mb-4 max-h-60 overflow-y-auto rounded-lg bg-gray-50 p-3 dark:bg-gray-700/50">
|
||||||
|
<div
|
||||||
|
v-for="(card, index) in createdCards"
|
||||||
|
:key="card.id"
|
||||||
|
class="flex items-center justify-between border-b border-gray-200 py-2 last:border-0 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-400">{{ index + 1 }}.</span>
|
||||||
|
<code class="font-mono text-sm text-gray-900 dark:text-white">{{ card.code }}</code>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<template v-if="card.type === 'quota' || card.type === 'combo'">
|
||||||
|
{{ card.quotaAmount }} CC
|
||||||
|
</template>
|
||||||
|
<template v-if="card.type === 'combo'"> + </template>
|
||||||
|
<template v-if="card.type === 'time' || card.type === 'combo'">
|
||||||
|
{{ card.timeAmount }}
|
||||||
|
{{ card.timeUnit === 'hours' ? '小时' : card.timeUnit === 'days' ? '天' : '月' }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning -->
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i class="fas fa-exclamation-triangle mt-0.5 text-yellow-500" />
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
|
请立即下载或复制卡号,关闭后将无法再次查看完整卡号列表。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-2.5 font-medium text-white shadow-sm transition-colors hover:from-blue-600 hover:to-blue-700"
|
||||||
|
type="button"
|
||||||
|
@click="downloadCards"
|
||||||
|
>
|
||||||
|
<i class="fas fa-download mr-2" />
|
||||||
|
下载 TXT
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-gray-100 px-4 py-2.5 font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
|
type="button"
|
||||||
|
@click="copyAllCards"
|
||||||
|
>
|
||||||
|
<i class="fas fa-copy mr-2" />
|
||||||
|
复制全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
<!-- Confirm Modal -->
|
||||||
|
<ConfirmModal
|
||||||
|
:cancel-text="confirmModalConfig.cancelText"
|
||||||
|
:confirm-text="confirmModalConfig.confirmText"
|
||||||
|
:message="confirmModalConfig.message"
|
||||||
|
:show="showConfirmModal"
|
||||||
|
:title="confirmModalConfig.title"
|
||||||
|
:type="confirmModalConfig.type"
|
||||||
|
@cancel="handleCancelModal"
|
||||||
|
@confirm="handleConfirmModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
import * as httpApi from '@/utils/http_apis'
|
||||||
|
import { showToast } from '@/utils/tools'
|
||||||
|
import { copyText } from '@/utils/tools'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showResultModal = ref(false)
|
||||||
|
const showConfirmModal = ref(false)
|
||||||
|
const confirmModalConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'primary',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
const confirmResolve = ref(null)
|
||||||
|
const createdCards = ref([])
|
||||||
|
const activeTab = ref('cards')
|
||||||
|
const selectedCards = ref([])
|
||||||
|
|
||||||
|
// 分页相关
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const pageSizeOptions = [10, 20, 50, 100]
|
||||||
|
const totalCards = ref(0)
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'cards', name: '卡片列表' },
|
||||||
|
{ id: 'redemptions', name: '核销记录' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const stats = ref({
|
||||||
|
total: 0,
|
||||||
|
unused: 0,
|
||||||
|
redeemed: 0,
|
||||||
|
revoked: 0,
|
||||||
|
expired: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const cards = ref([])
|
||||||
|
const redemptions = ref([])
|
||||||
|
|
||||||
|
// 可选择的卡片(只有未使用的才能选择)
|
||||||
|
const selectableCards = computed(() => cards.value.filter((c) => c.status === 'unused'))
|
||||||
|
|
||||||
|
// 是否全选
|
||||||
|
const isAllSelected = computed(
|
||||||
|
() =>
|
||||||
|
selectableCards.value.length > 0 && selectedCards.value.length === selectableCards.value.length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 是否部分选中
|
||||||
|
const isIndeterminate = computed(
|
||||||
|
() => selectedCards.value.length > 0 && selectedCards.value.length < selectableCards.value.length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 切换全选
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (isAllSelected.value) {
|
||||||
|
selectedCards.value = []
|
||||||
|
} else {
|
||||||
|
selectedCards.value = selectableCards.value.map((c) => c.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换单个选择
|
||||||
|
const toggleSelectCard = (cardId) => {
|
||||||
|
const index = selectedCards.value.indexOf(cardId)
|
||||||
|
if (index === -1) {
|
||||||
|
selectedCards.value.push(cardId)
|
||||||
|
} else {
|
||||||
|
selectedCards.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCard = ref({
|
||||||
|
type: 'quota',
|
||||||
|
quotaAmount: 10,
|
||||||
|
timeAmount: 30,
|
||||||
|
timeUnit: 'days',
|
||||||
|
count: 1,
|
||||||
|
note: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
return new Date(dateStr).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
type = 'primary'
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||||
|
confirmResolve.value = resolve
|
||||||
|
showConfirmModal.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleConfirmModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(true)
|
||||||
|
}
|
||||||
|
const handleCancelModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCards = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const offset = (currentPage.value - 1) * pageSize.value
|
||||||
|
const [cardsData, statsData, redemptionsData] = await Promise.all([
|
||||||
|
httpApi.get(`/admin/quota-cards?limit=${pageSize.value}&offset=${offset}`),
|
||||||
|
httpApi.get('/admin/quota-cards/stats'),
|
||||||
|
httpApi.get('/admin/redemptions')
|
||||||
|
])
|
||||||
|
|
||||||
|
cards.value = cardsData.data?.cards || []
|
||||||
|
totalCards.value = cardsData.data?.total || 0
|
||||||
|
stats.value = statsData.data || stats.value
|
||||||
|
redemptions.value = redemptionsData.data?.redemptions || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load cards:', error)
|
||||||
|
showToast('加载卡片数据失败', 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页计算
|
||||||
|
const totalPages = computed(() => Math.ceil(totalCards.value / pageSize.value))
|
||||||
|
|
||||||
|
// 页码变化
|
||||||
|
const changePage = (page) => {
|
||||||
|
currentPage.value = page
|
||||||
|
selectedCards.value = []
|
||||||
|
loadCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每页条数变化
|
||||||
|
const changePageSize = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
selectedCards.value = []
|
||||||
|
loadCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCard = async () => {
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const result = await httpApi.post('/admin/quota-cards', newCard.value)
|
||||||
|
showCreateModal.value = false
|
||||||
|
|
||||||
|
// 处理返回的卡片数据
|
||||||
|
const data = result.data
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
createdCards.value = data
|
||||||
|
} else if (data) {
|
||||||
|
createdCards.value = [data]
|
||||||
|
} else {
|
||||||
|
createdCards.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示结果弹窗
|
||||||
|
if (createdCards.value.length > 0) {
|
||||||
|
showResultModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`成功创建 ${createdCards.value.length} 张卡片`, 'success')
|
||||||
|
loadCards()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create card:', error)
|
||||||
|
showToast(error.message || '创建卡片失败', 'error')
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载卡片
|
||||||
|
const downloadCards = () => {
|
||||||
|
if (createdCards.value.length === 0) return
|
||||||
|
|
||||||
|
const content = createdCards.value
|
||||||
|
.map((card) => {
|
||||||
|
let label = ''
|
||||||
|
if (card.type === 'quota' || card.type === 'combo') {
|
||||||
|
label += `${card.quotaAmount}CC`
|
||||||
|
}
|
||||||
|
if (card.type === 'combo') {
|
||||||
|
label += '_'
|
||||||
|
}
|
||||||
|
if (card.type === 'time' || card.type === 'combo') {
|
||||||
|
const unitMap = { hours: 'h', days: 'd', months: 'm' }
|
||||||
|
label += `${card.timeAmount}${unitMap[card.timeUnit] || card.timeUnit}`
|
||||||
|
}
|
||||||
|
return `${label} ${card.code}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||||||
|
link.download = `quota-cards-${timestamp}.txt`
|
||||||
|
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
showToast('卡片文件已下载', 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制所有卡号
|
||||||
|
const copyAllCards = async () => {
|
||||||
|
if (createdCards.value.length === 0) return
|
||||||
|
|
||||||
|
const content = createdCards.value.map((card) => card.code).join('\n')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content)
|
||||||
|
showToast('已复制所有卡号', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error)
|
||||||
|
showToast('复制失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCard = async (card) => {
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
'删除卡片',
|
||||||
|
`确定删除卡片 ${card.code}?`,
|
||||||
|
'确定删除',
|
||||||
|
'取消',
|
||||||
|
'danger'
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await httpApi.del(`/admin/quota-cards/${card.id}`)
|
||||||
|
showToast('卡片已删除', 'success')
|
||||||
|
loadCards()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete card:', error)
|
||||||
|
showToast(error.message || '删除卡片失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSelectedCards = async () => {
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
'批量删除',
|
||||||
|
`确定删除选中的 ${selectedCards.value.length} 张卡片?`,
|
||||||
|
'确定删除',
|
||||||
|
'取消',
|
||||||
|
'danger'
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(selectedCards.value.map((id) => httpApi.del(`/admin/quota-cards/${id}`)))
|
||||||
|
showToast(`已删除 ${selectedCards.value.length} 张卡片`, 'success')
|
||||||
|
selectedCards.value = []
|
||||||
|
loadCards()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete cards:', error)
|
||||||
|
showToast(error.message || '批量删除失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeRedemption = async (redemption) => {
|
||||||
|
const reason = prompt('撤销原因(可选):')
|
||||||
|
if (reason === null) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await httpApi.post(`/admin/redemptions/${redemption.id}/revoke`, { reason })
|
||||||
|
showToast('核销已撤销', 'success')
|
||||||
|
loadCards()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to revoke redemption:', error)
|
||||||
|
showToast(error.message || '撤销核销失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadCards()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -48,6 +48,18 @@
|
|||||||
<i class="fas fa-robot mr-2"></i>
|
<i class="fas fa-robot mr-2"></i>
|
||||||
Claude 转发
|
Claude 转发
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'border-b-2 pb-2 text-sm font-medium transition-colors',
|
||||||
|
activeSection === 'serviceRates'
|
||||||
|
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
|
]"
|
||||||
|
@click="activeSection = 'serviceRates'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-balance-scale mr-2"></i>
|
||||||
|
服务倍率
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -194,6 +206,68 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<!-- API Stats 通知 -->
|
||||||
|
<tr class="border-b border-gray-100 dark:border-gray-700">
|
||||||
|
<td class="w-48 whitespace-nowrap px-6 py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="mr-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-amber-500 to-orange-600"
|
||||||
|
>
|
||||||
|
<i class="fas fa-bell text-xs text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
统计页通知
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">API Stats</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<label class="inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="oemSettings.apiStatsNotice.enabled"
|
||||||
|
class="peer sr-only"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="peer relative h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"
|
||||||
|
></div>
|
||||||
|
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{
|
||||||
|
oemSettings.apiStatsNotice.enabled ? '已启用' : '已禁用'
|
||||||
|
}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="oemSettings.apiStatsNotice.enabled" class="mt-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
标题
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="oemSettings.apiStatsNotice.title"
|
||||||
|
class="form-input w-full max-w-md dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
maxlength="100"
|
||||||
|
placeholder="通知标题"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
内容
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="oemSettings.apiStatsNotice.content"
|
||||||
|
class="form-input w-full max-w-md resize-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
maxlength="2000"
|
||||||
|
placeholder="通知内容(支持换行)"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-6" colspan="2">
|
<td class="px-6 py-6" colspan="2">
|
||||||
@@ -1025,6 +1099,113 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 服务倍率配置部分 -->
|
||||||
|
<div v-show="activeSection === 'serviceRates'">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="serviceRatesLoading" class="py-12 text-center">
|
||||||
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">正在加载配置...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- 说明卡片 -->
|
||||||
|
<div
|
||||||
|
class="mb-6 rounded-lg bg-gradient-to-r from-blue-50 to-indigo-50 p-6 dark:from-blue-900/20 dark:to-indigo-900/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div
|
||||||
|
class="mr-4 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-white"
|
||||||
|
>
|
||||||
|
<i class="fas fa-info"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
服务倍率说明
|
||||||
|
</h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
服务倍率用于计算不同服务消耗的虚拟额度(CC)。以
|
||||||
|
<strong>{{ serviceRates.baseService || 'claude' }}</strong>
|
||||||
|
为基准(倍率 1.0),其他服务按倍率换算。例如:Gemini 倍率 0.5 表示消耗 1 USD
|
||||||
|
只扣除 0.5 CC 额度。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 倍率配置表格 -->
|
||||||
|
<div class="rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
<i class="fas fa-sliders-h mr-2 text-blue-500"></i>
|
||||||
|
倍率配置
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
:disabled="serviceRatesSaving"
|
||||||
|
@click="saveServiceRates"
|
||||||
|
>
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
{{ serviceRatesSaving ? '保存中...' : '保存配置' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="(rate, service) in serviceRates.rates"
|
||||||
|
:key="service"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-gray-200 p-4 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="mr-3 flex h-10 w-10 items-center justify-center rounded-lg"
|
||||||
|
:class="getServiceIconClass(service)"
|
||||||
|
>
|
||||||
|
<i class="text-white" :class="getServiceIcon(service)"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{{ getServiceName(service) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ service }}
|
||||||
|
<span
|
||||||
|
v-if="service === serviceRates.baseService"
|
||||||
|
class="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
||||||
|
>
|
||||||
|
基准服务
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
v-model.number="serviceRates.rates[service]"
|
||||||
|
class="w-24 rounded-lg border border-gray-300 px-3 py-2 text-center text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
max="10"
|
||||||
|
min="0.1"
|
||||||
|
step="0.1"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">倍</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 更新信息 -->
|
||||||
|
<div
|
||||||
|
v-if="serviceRates.updatedAt"
|
||||||
|
class="mt-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-500 dark:bg-gray-700/50 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-history mr-2"></i>
|
||||||
|
最后更新:{{ formatDateTime(serviceRates.updatedAt) }}
|
||||||
|
<span v-if="serviceRates.updatedBy" class="ml-2">
|
||||||
|
由 <strong>{{ serviceRates.updatedBy }}</strong> 修改
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1603,15 +1784,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ConfirmModal -->
|
||||||
|
<ConfirmModal
|
||||||
|
:cancel-text="confirmModalConfig.cancelText"
|
||||||
|
:confirm-text="confirmModalConfig.confirmText"
|
||||||
|
:message="confirmModalConfig.message"
|
||||||
|
:show="showConfirmModal"
|
||||||
|
:title="confirmModalConfig.title"
|
||||||
|
:type="confirmModalConfig.type"
|
||||||
|
@cancel="handleCancelModal"
|
||||||
|
@confirm="handleConfirmModal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
|
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||||
|
|
||||||
// 定义组件名称,用于keep-alive排除
|
// 定义组件名称,用于keep-alive排除
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -1634,6 +1828,39 @@ const isMounted = ref(true)
|
|||||||
// API请求取消控制器
|
// API请求取消控制器
|
||||||
const abortController = ref(new AbortController())
|
const abortController = ref(new AbortController())
|
||||||
|
|
||||||
|
// ConfirmModal 状态
|
||||||
|
const showConfirmModal = ref(false)
|
||||||
|
const confirmModalConfig = ref({
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
type: 'primary',
|
||||||
|
confirmText: '确认',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
const confirmResolve = ref(null)
|
||||||
|
|
||||||
|
const showConfirm = (
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = '确认',
|
||||||
|
cancelText = '取消',
|
||||||
|
type = 'primary'
|
||||||
|
) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
confirmModalConfig.value = { title, message, confirmText, cancelText, type }
|
||||||
|
confirmResolve.value = resolve
|
||||||
|
showConfirmModal.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handleConfirmModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(true)
|
||||||
|
}
|
||||||
|
const handleCancelModal = () => {
|
||||||
|
showConfirmModal.value = false
|
||||||
|
confirmResolve.value?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
// 计算属性:隐藏管理后台按钮(反转 showAdminButton 的值)
|
// 计算属性:隐藏管理后台按钮(反转 showAdminButton 的值)
|
||||||
const hideAdminButton = computed({
|
const hideAdminButton = computed({
|
||||||
get() {
|
get() {
|
||||||
@@ -1688,6 +1915,24 @@ const claudeConfig = ref({
|
|||||||
updatedBy: null
|
updatedBy: null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 服务倍率配置
|
||||||
|
const serviceRatesLoading = ref(false)
|
||||||
|
const serviceRatesSaving = ref(false)
|
||||||
|
const serviceRates = ref({
|
||||||
|
baseService: 'claude',
|
||||||
|
rates: {
|
||||||
|
claude: 1.0,
|
||||||
|
codex: 1.0,
|
||||||
|
gemini: 1.0,
|
||||||
|
droid: 1.0,
|
||||||
|
bedrock: 1.0,
|
||||||
|
azure: 1.0,
|
||||||
|
ccr: 1.0
|
||||||
|
},
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null
|
||||||
|
})
|
||||||
|
|
||||||
// 平台表单相关
|
// 平台表单相关
|
||||||
const showAddPlatformModal = ref(false)
|
const showAddPlatformModal = ref(false)
|
||||||
const editingPlatform = ref(null)
|
const editingPlatform = ref(null)
|
||||||
@@ -1727,6 +1972,8 @@ const sectionWatcher = watch(activeSection, async (newSection) => {
|
|||||||
await loadWebhookConfig()
|
await loadWebhookConfig()
|
||||||
} else if (newSection === 'claude') {
|
} else if (newSection === 'claude') {
|
||||||
await loadClaudeConfig()
|
await loadClaudeConfig()
|
||||||
|
} else if (newSection === 'serviceRates') {
|
||||||
|
await loadServiceRates()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1854,6 +2101,9 @@ onMounted(async () => {
|
|||||||
if (activeSection.value === 'webhook') {
|
if (activeSection.value === 'webhook') {
|
||||||
await loadWebhookConfig()
|
await loadWebhookConfig()
|
||||||
}
|
}
|
||||||
|
if (activeSection.value === 'serviceRates') {
|
||||||
|
await loadServiceRates()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('加载设置失败', 'error')
|
showToast('加载设置失败', 'error')
|
||||||
}
|
}
|
||||||
@@ -1890,7 +2140,7 @@ onBeforeUnmount(() => {
|
|||||||
const loadWebhookConfig = async () => {
|
const loadWebhookConfig = async () => {
|
||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/webhook/config', {
|
const response = await httpApi.get('/admin/webhook/config', {
|
||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
if (response.success && isMounted.value) {
|
if (response.success && isMounted.value) {
|
||||||
@@ -1923,7 +2173,7 @@ const saveWebhookConfig = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.post('/admin/webhook/config', payload, {
|
const response = await httpApi.post('/admin/webhook/config', payload, {
|
||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
if (response.success && isMounted.value) {
|
if (response.success && isMounted.value) {
|
||||||
@@ -1943,7 +2193,7 @@ const loadClaudeConfig = async () => {
|
|||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
claudeConfigLoading.value = true
|
claudeConfigLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/admin/claude-relay-config', {
|
const response = await httpApi.get('/admin/claude-relay-config', {
|
||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
if (response.success && isMounted.value) {
|
if (response.success && isMounted.value) {
|
||||||
@@ -1996,7 +2246,7 @@ const saveClaudeConfig = async () => {
|
|||||||
concurrentRequestQueueTimeoutMs: claudeConfig.value.concurrentRequestQueueTimeoutMs
|
concurrentRequestQueueTimeoutMs: claudeConfig.value.concurrentRequestQueueTimeoutMs
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.put('/admin/claude-relay-config', payload, {
|
const response = await httpApi.put('/admin/claude-relay-config', payload, {
|
||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
if (response.success && isMounted.value) {
|
if (response.success && isMounted.value) {
|
||||||
@@ -2015,6 +2265,103 @@ const saveClaudeConfig = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载服务倍率配置
|
||||||
|
const loadServiceRates = async () => {
|
||||||
|
if (!isMounted.value) return
|
||||||
|
serviceRatesLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await httpApi.get('/admin/service-rates', {
|
||||||
|
signal: abortController.value.signal
|
||||||
|
})
|
||||||
|
if (response.success && isMounted.value) {
|
||||||
|
serviceRates.value = {
|
||||||
|
baseService: response.data?.baseService || 'claude',
|
||||||
|
rates: response.data?.rates || serviceRates.value.rates,
|
||||||
|
updatedAt: response.data?.updatedAt,
|
||||||
|
updatedBy: response.data?.updatedBy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') return
|
||||||
|
if (!isMounted.value) return
|
||||||
|
console.error('加载服务倍率配置失败:', error)
|
||||||
|
} finally {
|
||||||
|
if (isMounted.value) {
|
||||||
|
serviceRatesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存服务倍率配置
|
||||||
|
const saveServiceRates = async () => {
|
||||||
|
if (!isMounted.value) return
|
||||||
|
serviceRatesSaving.value = true
|
||||||
|
try {
|
||||||
|
const response = await httpApi.put(
|
||||||
|
'/admin/service-rates',
|
||||||
|
{
|
||||||
|
rates: serviceRates.value.rates,
|
||||||
|
baseService: serviceRates.value.baseService
|
||||||
|
},
|
||||||
|
{ signal: abortController.value.signal }
|
||||||
|
)
|
||||||
|
if (response.success && isMounted.value) {
|
||||||
|
serviceRates.value.updatedAt = response.data?.updatedAt || new Date().toISOString()
|
||||||
|
serviceRates.value.updatedBy = response.data?.updatedBy
|
||||||
|
showToast('服务倍率配置已保存', 'success')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') return
|
||||||
|
if (!isMounted.value) return
|
||||||
|
showToast('保存服务倍率配置失败', 'error')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
if (isMounted.value) {
|
||||||
|
serviceRatesSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务图标和名称映射
|
||||||
|
const getServiceIcon = (service) => {
|
||||||
|
const icons = {
|
||||||
|
claude: 'fas fa-robot',
|
||||||
|
codex: 'fas fa-code',
|
||||||
|
gemini: 'fas fa-gem',
|
||||||
|
droid: 'fas fa-android',
|
||||||
|
bedrock: 'fab fa-aws',
|
||||||
|
azure: 'fab fa-microsoft',
|
||||||
|
ccr: 'fas fa-server'
|
||||||
|
}
|
||||||
|
return icons[service] || 'fas fa-cog'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getServiceIconClass = (service) => {
|
||||||
|
const classes = {
|
||||||
|
claude: 'bg-gradient-to-br from-orange-500 to-amber-600',
|
||||||
|
codex: 'bg-gradient-to-br from-green-500 to-emerald-600',
|
||||||
|
gemini: 'bg-gradient-to-br from-blue-500 to-indigo-600',
|
||||||
|
droid: 'bg-gradient-to-br from-green-600 to-lime-600',
|
||||||
|
bedrock: 'bg-gradient-to-br from-yellow-500 to-orange-600',
|
||||||
|
azure: 'bg-gradient-to-br from-blue-600 to-cyan-600',
|
||||||
|
ccr: 'bg-gradient-to-br from-purple-500 to-pink-600'
|
||||||
|
}
|
||||||
|
return classes[service] || 'bg-gradient-to-br from-gray-500 to-gray-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getServiceName = (service) => {
|
||||||
|
const names = {
|
||||||
|
claude: 'Claude',
|
||||||
|
codex: 'Codex (OpenAI)',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
droid: 'Droid',
|
||||||
|
bedrock: 'AWS Bedrock',
|
||||||
|
azure: 'Azure OpenAI',
|
||||||
|
ccr: 'CCR'
|
||||||
|
}
|
||||||
|
return names[service] || service
|
||||||
|
}
|
||||||
|
|
||||||
// 验证 URL
|
// 验证 URL
|
||||||
const validateUrl = () => {
|
const validateUrl = () => {
|
||||||
// Bark和SMTP平台不需要验证URL
|
// Bark和SMTP平台不需要验证URL
|
||||||
@@ -2126,14 +2473,14 @@ const savePlatform = async () => {
|
|||||||
let response
|
let response
|
||||||
if (editingPlatform.value) {
|
if (editingPlatform.value) {
|
||||||
// 更新平台
|
// 更新平台
|
||||||
response = await apiClient.put(
|
response = await httpApi.put(
|
||||||
`/admin/webhook/platforms/${editingPlatform.value.id}`,
|
`/admin/webhook/platforms/${editingPlatform.value.id}`,
|
||||||
platformForm.value,
|
platformForm.value,
|
||||||
{ signal: abortController.value.signal }
|
{ signal: abortController.value.signal }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 添加平台
|
// 添加平台
|
||||||
response = await apiClient.post('/admin/webhook/platforms', platformForm.value, {
|
response = await httpApi.post('/admin/webhook/platforms', platformForm.value, {
|
||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2193,12 +2540,12 @@ const editPlatform = (platform) => {
|
|||||||
const deletePlatform = async (id) => {
|
const deletePlatform = async (id) => {
|
||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
|
|
||||||
if (!confirm('确定要删除这个平台吗?')) {
|
if (!(await showConfirm('删除平台', '确定要删除这个平台吗?', '删除', '取消', 'danger'))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.delete(`/admin/webhook/platforms/${id}`, {
|
const response = await httpApi.del(`/admin/webhook/platforms/${id}`, {
|
||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
if (response.success && isMounted.value) {
|
if (response.success && isMounted.value) {
|
||||||
@@ -2218,7 +2565,7 @@ const togglePlatform = async (id) => {
|
|||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await httpApi.post(
|
||||||
`/admin/webhook/platforms/${id}/toggle`,
|
`/admin/webhook/platforms/${id}/toggle`,
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -2273,7 +2620,7 @@ const testPlatform = async (platform) => {
|
|||||||
testData.url = platform.url
|
testData.url = platform.url
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.post('/admin/webhook/test', testData, {
|
const response = await httpApi.post('/admin/webhook/test', testData, {
|
||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
if (response.success && isMounted.value) {
|
if (response.success && isMounted.value) {
|
||||||
@@ -2296,7 +2643,7 @@ const testPlatformForm = async () => {
|
|||||||
|
|
||||||
testingConnection.value = true
|
testingConnection.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/admin/webhook/test', platformForm.value, {
|
const response = await httpApi.post('/admin/webhook/test', platformForm.value, {
|
||||||
signal: abortController.value.signal
|
signal: abortController.value.signal
|
||||||
})
|
})
|
||||||
if (response.success && isMounted.value) {
|
if (response.success && isMounted.value) {
|
||||||
@@ -2319,7 +2666,7 @@ const sendTestNotification = async () => {
|
|||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await httpApi.post(
|
||||||
'/admin/webhook/test-notification',
|
'/admin/webhook/test-notification',
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -2467,7 +2814,8 @@ const saveOemSettings = async () => {
|
|||||||
siteName: oemSettings.value.siteName,
|
siteName: oemSettings.value.siteName,
|
||||||
siteIcon: oemSettings.value.siteIcon,
|
siteIcon: oemSettings.value.siteIcon,
|
||||||
siteIconData: oemSettings.value.siteIconData,
|
siteIconData: oemSettings.value.siteIconData,
|
||||||
showAdminButton: oemSettings.value.showAdminButton
|
showAdminButton: oemSettings.value.showAdminButton,
|
||||||
|
apiStatsNotice: oemSettings.value.apiStatsNotice
|
||||||
}
|
}
|
||||||
const result = await settingsStore.saveOemSettings(settings)
|
const result = await settingsStore.saveOemSettings(settings)
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
@@ -2482,7 +2830,16 @@ const saveOemSettings = async () => {
|
|||||||
|
|
||||||
// 重置OEM设置
|
// 重置OEM设置
|
||||||
const resetOemSettings = async () => {
|
const resetOemSettings = async () => {
|
||||||
if (!confirm('确定要重置为默认设置吗?\n\n这将清除所有自定义的网站名称和图标设置。')) return
|
if (
|
||||||
|
!(await showConfirm(
|
||||||
|
'重置设置',
|
||||||
|
'确定要重置为默认设置吗?\n\n这将清除所有自定义的网站名称和图标设置。',
|
||||||
|
'重置',
|
||||||
|
'取消',
|
||||||
|
'warning'
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await settingsStore.resetOemSettings()
|
const result = await settingsStore.resetOemSettings()
|
||||||
@@ -2548,8 +2905,8 @@ const formatDateTime = settingsStore.formatDateTime
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root.dark .card {
|
:root.dark .card {
|
||||||
background: #1f2937;
|
background: var(--bg-gradient-start);
|
||||||
border: 1px solid #374151;
|
border: 1px solid var(--border-color);
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2560,7 +2917,7 @@ const formatDateTime = settingsStore.formatDateTime
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root.dark .table-container {
|
:root.dark .table-container {
|
||||||
border: 1px solid #4b5563;
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-row {
|
.table-row {
|
||||||
@@ -2572,7 +2929,7 @@ const formatDateTime = settingsStore.formatDateTime
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root.dark .table-row:hover {
|
:root.dark .table-row:hover {
|
||||||
background-color: #374151;
|
background-color: var(--bg-gradient-mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||||
import UserApiKeysManager from '@/components/user/UserApiKeysManager.vue'
|
import UserApiKeysManager from '@/components/user/UserApiKeysManager.vue'
|
||||||
import UserUsageStats from '@/components/user/UserUsageStats.vue'
|
import UserUsageStats from '@/components/user/UserUsageStats.vue'
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ import { ref, reactive, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -476,8 +476,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { apiClient } from '@/config/api'
|
import * as httpApi from '@/utils/http_apis'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/tools'
|
||||||
import { debounce } from 'lodash-es'
|
import { debounce } from 'lodash-es'
|
||||||
import UserUsageStatsModal from '@/components/admin/UserUsageStatsModal.vue'
|
import UserUsageStatsModal from '@/components/admin/UserUsageStatsModal.vue'
|
||||||
import ChangeRoleModal from '@/components/admin/ChangeRoleModal.vue'
|
import ChangeRoleModal from '@/components/admin/ChangeRoleModal.vue'
|
||||||
@@ -564,8 +564,8 @@ const loadUsers = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [usersResponse, statsResponse] = await Promise.all([
|
const [usersResponse, statsResponse] = await Promise.all([
|
||||||
apiClient.get('/users', { params }),
|
httpApi.get('/users', { params }),
|
||||||
apiClient.get('/users/stats/overview')
|
httpApi.get('/users/stats/overview')
|
||||||
])
|
])
|
||||||
|
|
||||||
if (usersResponse.success) {
|
if (usersResponse.success) {
|
||||||
@@ -631,7 +631,7 @@ const handleConfirmAction = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (action === 'toggleStatus') {
|
if (action === 'toggleStatus') {
|
||||||
const response = await apiClient.patch(`/users/${user.id}/status`, {
|
const response = await httpApi.patch(`/users/${user.id}/status`, {
|
||||||
isActive: !user.isActive
|
isActive: !user.isActive
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -643,7 +643,7 @@ const handleConfirmAction = async () => {
|
|||||||
showToast(`User ${user.isActive ? 'disabled' : 'enabled'} successfully`, 'success')
|
showToast(`User ${user.isActive ? 'disabled' : 'enabled'} successfully`, 'success')
|
||||||
}
|
}
|
||||||
} else if (action === 'disableKeys') {
|
} else if (action === 'disableKeys') {
|
||||||
const response = await apiClient.post(`/users/${user.id}/disable-keys`)
|
const response = await httpApi.post(`/users/${user.id}/disable-keys`)
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
showToast(`Disabled ${response.disabledCount} API keys`, 'success')
|
showToast(`Disabled ${response.disabledCount} API keys`, 'success')
|
||||||
|
|||||||
@@ -4,6 +4,32 @@ export default {
|
|||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// 主题色 - 使用 CSS 变量
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'var(--primary-color)',
|
||||||
|
rgb: 'rgb(var(--primary-rgb))'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'var(--secondary-color)',
|
||||||
|
rgb: 'rgb(var(--secondary-rgb))'
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'var(--accent-color)',
|
||||||
|
rgb: 'rgb(var(--accent-rgb))'
|
||||||
|
},
|
||||||
|
// 表面颜色
|
||||||
|
surface: 'var(--surface-color)',
|
||||||
|
'glass-strong': 'var(--glass-strong-color)',
|
||||||
|
glass: 'var(--glass-color)'
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
'theme-surface': 'var(--surface-color)',
|
||||||
|
'theme-glass': 'var(--glass-strong-color)'
|
||||||
|
},
|
||||||
|
borderColor: {
|
||||||
|
'theme-border': 'var(--border-color)'
|
||||||
|
},
|
||||||
animation: {
|
animation: {
|
||||||
gradient: 'gradient 8s ease infinite',
|
gradient: 'gradient 8s ease infinite',
|
||||||
float: 'float 6s ease-in-out infinite',
|
float: 'float 6s ease-in-out infinite',
|
||||||
|
|||||||
Reference in New Issue
Block a user