mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Revert "合并所有新功能到Wei-Shaw仓库(排除ApiStatsView.vue)"
This commit is contained in:
@@ -373,92 +373,6 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔒 安全检查 GPT-5 High 推理级别周费用限制
|
|
||||||
const weeklyGPT5HighCostLimit = parseFloat(validation.keyData.weeklyGPT5HighCostLimit) || 0
|
|
||||||
|
|
||||||
if (weeklyGPT5HighCostLimit > 0) {
|
|
||||||
try {
|
|
||||||
// 从请求中获取模型和推理级别信息
|
|
||||||
const requestBody = req.body || {}
|
|
||||||
const model = String(requestBody.model || '').toLowerCase()
|
|
||||||
|
|
||||||
// 只对 GPT-5 模型进行检查
|
|
||||||
if (model.includes('gpt-5')) {
|
|
||||||
// 安全提取推理级别
|
|
||||||
let detectedLevel = 'medium' // 默认值
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 多种方式提取推理级别
|
|
||||||
detectedLevel = requestBody.reasoning_effort ||
|
|
||||||
requestBody.model_reasoning_effort ||
|
|
||||||
req.headers['reasoning-effort'] ||
|
|
||||||
'medium'
|
|
||||||
|
|
||||||
// 检查 reasoning 字段
|
|
||||||
const reasoningField = requestBody.reasoning
|
|
||||||
if (reasoningField) {
|
|
||||||
if (typeof reasoningField === 'string') {
|
|
||||||
if (reasoningField.includes('high') || reasoningField.includes('maximum')) {
|
|
||||||
detectedLevel = 'high'
|
|
||||||
}
|
|
||||||
} else if (typeof reasoningField === 'object' && reasoningField.effort) {
|
|
||||||
detectedLevel = reasoningField.effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保级别值是字符串
|
|
||||||
detectedLevel = String(detectedLevel).toLowerCase()
|
|
||||||
} catch (levelError) {
|
|
||||||
logger.warn('Error extracting reasoning level, using default:', levelError)
|
|
||||||
detectedLevel = 'medium'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只对 High 级别进行限制检查
|
|
||||||
if (detectedLevel === 'high') {
|
|
||||||
const weeklyGPT5HighCost = parseFloat(validation.keyData.weeklyGPT5HighCost) || 0
|
|
||||||
|
|
||||||
if (weeklyGPT5HighCost >= weeklyGPT5HighCostLimit) {
|
|
||||||
logger.security(
|
|
||||||
`💰 Weekly GPT-5 High cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${weeklyGPT5HighCost.toFixed(2)}/$${weeklyGPT5HighCostLimit}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// 安全计算下周一的重置时间
|
|
||||||
let resetDate
|
|
||||||
try {
|
|
||||||
const now = new Date()
|
|
||||||
const nextMonday = new Date(now)
|
|
||||||
nextMonday.setDate(now.getDate() + (7 - now.getDay() + 1) % 7 || 7)
|
|
||||||
nextMonday.setHours(0, 0, 0, 0)
|
|
||||||
resetDate = nextMonday
|
|
||||||
} catch (dateError) {
|
|
||||||
logger.warn('Error calculating reset date:', dateError)
|
|
||||||
resetDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7天后
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(429).json({
|
|
||||||
error: 'Weekly GPT-5 High cost limit exceeded',
|
|
||||||
message: `已达到 GPT-5 High推理级别周费用限制 ($${weeklyGPT5HighCostLimit})`,
|
|
||||||
currentCost: weeklyGPT5HighCost,
|
|
||||||
costLimit: weeklyGPT5HighCostLimit,
|
|
||||||
reasoningLevel: 'high',
|
|
||||||
resetAt: resetDate.toISOString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录使用情况(不影响性能)
|
|
||||||
if (weeklyGPT5HighCostLimit > 0) {
|
|
||||||
logger.api(
|
|
||||||
`💰 GPT-5 High weekly cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${weeklyGPT5HighCost.toFixed(2)}/$${weeklyGPT5HighCostLimit} (${((weeklyGPT5HighCost / weeklyGPT5HighCostLimit) * 100).toFixed(1)}%)`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (gpt5Error) {
|
|
||||||
logger.warn('Error in GPT-5 High cost check, continuing with request:', gpt5Error)
|
|
||||||
// 发生错误时不阻止请求,确保服务可用性
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将验证信息添加到请求对象(只包含必要信息)
|
// 将验证信息添加到请求对象(只包含必要信息)
|
||||||
req.apiKey = {
|
req.apiKey = {
|
||||||
id: validation.keyData.id,
|
id: validation.keyData.id,
|
||||||
|
|||||||
@@ -733,62 +733,6 @@ class RedisClient {
|
|||||||
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
|
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 💰 获取本周 GPT-5 High 费用
|
|
||||||
async getWeeklyGPT5HighCost(keyId) {
|
|
||||||
try {
|
|
||||||
if (!keyId) {
|
|
||||||
logger.warn('getWeeklyGPT5HighCost: keyId is required')
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentWeek = getWeekStringInTimezone()
|
|
||||||
const costKey = `usage:gpt5-high:weekly:${keyId}:${currentWeek}`
|
|
||||||
const cost = await this.client.get(costKey)
|
|
||||||
const result = parseFloat(cost || 0)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`💰 Getting weekly GPT-5 High cost for ${keyId}, week: ${currentWeek}, result: $${result.toFixed(4)}`
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error getting weekly GPT-5 High cost:', error)
|
|
||||||
return 0 // 发生错误时返回0,不影响正常流程
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 💰 增加本周 GPT-5 High 费用
|
|
||||||
async incrementWeeklyGPT5HighCost(keyId, amount) {
|
|
||||||
try {
|
|
||||||
if (!keyId || !amount || amount <= 0) {
|
|
||||||
logger.warn('incrementWeeklyGPT5HighCost: invalid parameters', { keyId, amount })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentWeek = getWeekStringInTimezone()
|
|
||||||
const weeklyKey = `usage:gpt5-high:weekly:${keyId}:${currentWeek}`
|
|
||||||
const totalKey = `usage:gpt5-high:total:${keyId}`
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`💰 Incrementing weekly GPT-5 High cost for ${keyId}, week: ${currentWeek}, amount: $${amount.toFixed(4)}`
|
|
||||||
)
|
|
||||||
|
|
||||||
// 使用 pipeline 批量执行,提高性能和一致性
|
|
||||||
const pipeline = this.client.pipeline()
|
|
||||||
pipeline.incrbyfloat(weeklyKey, amount)
|
|
||||||
pipeline.incrbyfloat(totalKey, amount)
|
|
||||||
pipeline.expire(weeklyKey, 60 * 60 * 24 * 14) // 2周后过期
|
|
||||||
|
|
||||||
await pipeline.exec()
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`💰 Weekly GPT-5 High cost updated successfully for ${keyId}`
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error incrementing weekly GPT-5 High cost:', error)
|
|
||||||
// 不抛出错误,避免影响主流程
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 💰 计算账户的每日费用(基于模型使用)
|
// 💰 计算账户的每日费用(基于模型使用)
|
||||||
async getAccountDailyCost(accountId) {
|
async getAccountDailyCost(accountId) {
|
||||||
const CostCalculator = require('../utils/costCalculator')
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
@@ -1412,9 +1356,12 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔗 会话sticky映射管理
|
// 🔗 会话sticky映射管理
|
||||||
async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) {
|
async setSessionAccountMapping(sessionHash, accountId, ttl = null) {
|
||||||
|
const appConfig = require('../../config/config')
|
||||||
|
// 从配置读取TTL(小时),转换为秒,默认1小时
|
||||||
|
const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlHours || 1) * 60 * 60
|
||||||
const key = `sticky_session:${sessionHash}`
|
const key = `sticky_session:${sessionHash}`
|
||||||
await this.client.set(key, accountId, 'EX', ttl)
|
await this.client.set(key, accountId, 'EX', defaultTTL)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSessionAccountMapping(sessionHash) {
|
async getSessionAccountMapping(sessionHash) {
|
||||||
@@ -1422,6 +1369,57 @@ class RedisClient {
|
|||||||
return await this.client.get(key)
|
return await this.client.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🚀 智能会话TTL续期:剩余时间少于阈值时自动续期
|
||||||
|
async extendSessionAccountMappingTTL(sessionHash) {
|
||||||
|
const appConfig = require('../../config/config')
|
||||||
|
const key = `sticky_session:${sessionHash}`
|
||||||
|
|
||||||
|
// 📊 从配置获取参数
|
||||||
|
const ttlHours = appConfig.session?.stickyTtlHours || 1 // 小时,默认1小时
|
||||||
|
const thresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 // 分钟,默认0(不续期)
|
||||||
|
|
||||||
|
// 如果阈值为0,不执行续期
|
||||||
|
if (thresholdMinutes === 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullTTL = ttlHours * 60 * 60 // 转换为秒
|
||||||
|
const renewalThreshold = thresholdMinutes * 60 // 转换为秒
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前剩余TTL(秒)
|
||||||
|
const remainingTTL = await this.client.ttl(key)
|
||||||
|
|
||||||
|
// 键不存在或已过期
|
||||||
|
if (remainingTTL === -2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键存在但没有TTL(永不过期,不需要处理)
|
||||||
|
if (remainingTTL === -1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎯 智能续期策略:仅在剩余时间少于阈值时才续期
|
||||||
|
if (remainingTTL < renewalThreshold) {
|
||||||
|
await this.client.expire(key, fullTTL)
|
||||||
|
logger.debug(
|
||||||
|
`🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}min, renewed to ${ttlHours}h)`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剩余时间充足,无需续期
|
||||||
|
logger.debug(
|
||||||
|
`✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}min)`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to extend session TTL:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deleteSessionAccountMapping(sessionHash) {
|
async deleteSessionAccountMapping(sessionHash) {
|
||||||
const key = `sticky_session:${sessionHash}`
|
const key = `sticky_session:${sessionHash}`
|
||||||
return await this.client.del(key)
|
return await this.client.del(key)
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
|
|||||||
// 获取所有API Keys
|
// 获取所有API Keys
|
||||||
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { timeRange = 'all' } = req.query // all, 7days, monthly
|
const { timeRange = 'all', startDate, endDate } = req.query // all, 7days, monthly, custom
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys()
|
const apiKeys = await apiKeyService.getAllApiKeys()
|
||||||
|
|
||||||
// 获取用户服务来补充owner信息
|
// 获取用户服务来补充owner信息
|
||||||
@@ -132,7 +132,32 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const searchPatterns = []
|
const searchPatterns = []
|
||||||
|
|
||||||
if (timeRange === 'today') {
|
if (timeRange === 'custom' && startDate && endDate) {
|
||||||
|
// 自定义日期范围
|
||||||
|
const redisClient = require('../models/redis')
|
||||||
|
const start = new Date(startDate)
|
||||||
|
const end = new Date(endDate)
|
||||||
|
|
||||||
|
// 确保日期范围有效
|
||||||
|
if (start > end) {
|
||||||
|
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制最大范围为365天
|
||||||
|
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||||
|
if (daysDiff > 365) {
|
||||||
|
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成日期范围内每天的搜索模式
|
||||||
|
const currentDate = new Date(start)
|
||||||
|
while (currentDate <= end) {
|
||||||
|
const tzDate = redisClient.getDateInTimezone(currentDate)
|
||||||
|
const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}`
|
||||||
|
searchPatterns.push(`usage:daily:*:${dateStr}`)
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1)
|
||||||
|
}
|
||||||
|
} else if (timeRange === 'today') {
|
||||||
// 今日 - 使用时区日期
|
// 今日 - 使用时区日期
|
||||||
const redisClient = require('../models/redis')
|
const redisClient = require('../models/redis')
|
||||||
const tzDate = redisClient.getDateInTimezone(now)
|
const tzDate = redisClient.getDateInTimezone(now)
|
||||||
@@ -233,7 +258,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost)
|
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 7天或本月:重新计算统计数据
|
// 7天、本月或自定义日期范围:重新计算统计数据
|
||||||
const tempUsage = {
|
const tempUsage = {
|
||||||
requests: 0,
|
requests: 0,
|
||||||
tokens: 0,
|
tokens: 0,
|
||||||
@@ -274,12 +299,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
const tzDate = redisClient.getDateInTimezone(now)
|
const tzDate = redisClient.getDateInTimezone(now)
|
||||||
const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||||
|
|
||||||
const modelKeys =
|
let modelKeys = []
|
||||||
|
if (timeRange === 'custom' && startDate && endDate) {
|
||||||
|
// 自定义日期范围:获取范围内所有日期的模型统计
|
||||||
|
const start = new Date(startDate)
|
||||||
|
const end = new Date(endDate)
|
||||||
|
const currentDate = new Date(start)
|
||||||
|
|
||||||
|
while (currentDate <= end) {
|
||||||
|
const tzDateForKey = redisClient.getDateInTimezone(currentDate)
|
||||||
|
const dateStr = `${tzDateForKey.getUTCFullYear()}-${String(tzDateForKey.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}`
|
||||||
|
const dayKeys = await client.keys(`usage:${apiKey.id}:model:daily:*:${dateStr}`)
|
||||||
|
modelKeys = modelKeys.concat(dayKeys)
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
modelKeys =
|
||||||
timeRange === 'today'
|
timeRange === 'today'
|
||||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
|
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
|
||||||
: timeRange === '7days'
|
: timeRange === '7days'
|
||||||
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
|
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
|
||||||
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`)
|
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`)
|
||||||
|
}
|
||||||
|
|
||||||
const modelStatsMap = new Map()
|
const modelStatsMap = new Map()
|
||||||
|
|
||||||
@@ -295,8 +336,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (timeRange === 'today') {
|
} else if (timeRange === 'today' || timeRange === 'custom') {
|
||||||
// today选项已经在查询时过滤了,不需要额外处理
|
// today和custom选项已经在查询时过滤了,不需要额外处理
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelMatch = key.match(
|
const modelMatch = key.match(
|
||||||
@@ -947,10 +988,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
dailyCostLimit,
|
dailyCostLimit,
|
||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
weeklyGPT5HighCostLimit,
|
|
||||||
tags,
|
tags,
|
||||||
ownerId, // 新增:所有者ID字段
|
ownerId // 新增:所有者ID字段
|
||||||
icon // 新增:图标(base64编码)
|
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
// 只允许更新指定字段
|
// 只允许更新指定字段
|
||||||
@@ -1113,22 +1152,6 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
updates.weeklyOpusCostLimit = costLimit
|
updates.weeklyOpusCostLimit = costLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 GPT-5 High 周费用限制
|
|
||||||
if (
|
|
||||||
weeklyGPT5HighCostLimit !== undefined &&
|
|
||||||
weeklyGPT5HighCostLimit !== null &&
|
|
||||||
weeklyGPT5HighCostLimit !== ''
|
|
||||||
) {
|
|
||||||
const costLimit = Number(weeklyGPT5HighCostLimit)
|
|
||||||
// 明确验证非负数(0 表示禁用,负数无意义)
|
|
||||||
if (isNaN(costLimit) || costLimit < 0) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: 'Weekly GPT-5 High cost limit must be a non-negative number' })
|
|
||||||
}
|
|
||||||
updates.weeklyGPT5HighCostLimit = costLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理标签
|
// 处理标签
|
||||||
if (tags !== undefined) {
|
if (tags !== undefined) {
|
||||||
if (!Array.isArray(tags)) {
|
if (!Array.isArray(tags)) {
|
||||||
@@ -1140,19 +1163,6 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
updates.tags = tags
|
updates.tags = tags
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理图标
|
|
||||||
if (icon !== undefined) {
|
|
||||||
// icon 可以是空字符串(清除图标)或 base64 编码的字符串
|
|
||||||
if (icon !== '' && typeof icon !== 'string') {
|
|
||||||
return res.status(400).json({ error: 'Icon must be a string' })
|
|
||||||
}
|
|
||||||
// 简单验证 base64 格式(如果不为空)
|
|
||||||
if (icon && !icon.startsWith('data:image/')) {
|
|
||||||
return res.status(400).json({ error: 'Icon must be a valid base64 image' })
|
|
||||||
}
|
|
||||||
updates.icon = icon
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能
|
// 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能
|
||||||
if (isActive !== undefined) {
|
if (isActive !== undefined) {
|
||||||
if (typeof isActive !== 'boolean') {
|
if (typeof isActive !== 'boolean') {
|
||||||
@@ -3913,10 +3923,10 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 限制最大范围为31天
|
// 限制最大范围为365天
|
||||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||||
if (daysDiff > 31) {
|
if (daysDiff > 365) {
|
||||||
return res.status(400).json({ error: 'Date range cannot exceed 31 days' })
|
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成日期范围内所有日期的搜索模式
|
// 生成日期范围内所有日期的搜索模式
|
||||||
@@ -4377,10 +4387,10 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
|
|||||||
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 限制最大范围为31天
|
// 限制最大范围为365天
|
||||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||||
if (daysDiff > 31) {
|
if (daysDiff > 365) {
|
||||||
return res.status(400).json({ error: 'Date range cannot exceed 31 days' })
|
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成日期范围内所有日期的搜索模式
|
// 生成日期范围内所有日期的搜索模式
|
||||||
|
|||||||
@@ -2,31 +2,13 @@ const express = require('express')
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const config = require('../../config/config')
|
||||||
const { authenticateApiKey } = require('../middleware/auth')
|
const { authenticateApiKey } = require('../middleware/auth')
|
||||||
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
|
const unifiedOpenAIScheduler = require('../services/unifiedOpenAIScheduler')
|
||||||
const openaiAccountService = require('../services/openaiAccountService')
|
const openaiAccountService = require('../services/openaiAccountService')
|
||||||
const apiKeyService = require('../services/apiKeyService')
|
const apiKeyService = require('../services/apiKeyService')
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
const ProxyHelper = require('../utils/proxyHelper')
|
const ProxyHelper = require('../utils/proxyHelper')
|
||||||
const redis = require('../models/redis') // 新增:用于GPT-5 High费用记录
|
|
||||||
|
|
||||||
// 🔥 计算GPT-5 High推理级别的额外费用
|
|
||||||
function calculateGPT5HighCost(usageData) {
|
|
||||||
if (!usageData) return 0
|
|
||||||
|
|
||||||
// GPT-5 High推理级别费用(示例费率,实际需要根据OpenAI官方定价调整)
|
|
||||||
const inputTokens = usageData.prompt_tokens || 0
|
|
||||||
const outputTokens = usageData.completion_tokens || 0
|
|
||||||
|
|
||||||
// High推理级别的额外费用(美元)
|
|
||||||
const inputCostPerToken = 0.00002 // $0.02 per 1K tokens for input
|
|
||||||
const outputCostPerToken = 0.0001 // $0.10 per 1K tokens for output
|
|
||||||
|
|
||||||
const inputCost = (inputTokens / 1000) * inputCostPerToken
|
|
||||||
const outputCost = (outputTokens / 1000) * outputCostPerToken
|
|
||||||
|
|
||||||
return inputCost + outputCost
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建代理 Agent(使用统一的代理工具)
|
// 创建代理 Agent(使用统一的代理工具)
|
||||||
function createProxyAgent(proxy) {
|
function createProxyAgent(proxy) {
|
||||||
@@ -122,103 +104,7 @@ const handleResponses = async (req, res) => {
|
|||||||
null
|
null
|
||||||
|
|
||||||
// 从请求体中提取模型和流式标志
|
// 从请求体中提取模型和流式标志
|
||||||
const originalModel = req.body?.model || null // 保存原始模型名称用于限制检查
|
let requestedModel = req.body?.model || null
|
||||||
let requestedModel = originalModel
|
|
||||||
|
|
||||||
// 🔍 详细分析 Codex CLI 请求格式(用于推理级别识别)
|
|
||||||
logger.info(`🔍 Codex CLI request analysis:`, {
|
|
||||||
model: req.body?.model,
|
|
||||||
temperature: req.body?.temperature,
|
|
||||||
max_tokens: req.body?.max_tokens,
|
|
||||||
reasoning_effort: req.body?.reasoning_effort,
|
|
||||||
model_reasoning_effort: req.body?.model_reasoning_effort,
|
|
||||||
stream: req.body?.stream,
|
|
||||||
allHeaders: Object.keys(req.headers),
|
|
||||||
allBodyKeys: Object.keys(req.body || {})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 🎯 尝试从请求中识别推理级别
|
|
||||||
let effectiveModel = originalModel
|
|
||||||
|
|
||||||
// 检查所有可能的推理级别字段
|
|
||||||
const reasoningEffort =
|
|
||||||
req.body?.reasoning_effort ||
|
|
||||||
req.body?.model_reasoning_effort ||
|
|
||||||
req.headers['reasoning-effort']
|
|
||||||
|
|
||||||
// 🔥 检查 reasoning 字段(可能包含推理级别信息)
|
|
||||||
const reasoningField = req.body?.reasoning
|
|
||||||
|
|
||||||
logger.info(`🔥 Detailed reasoning analysis:`, {
|
|
||||||
reasoningField,
|
|
||||||
reasoningEffort,
|
|
||||||
reasoningType: typeof reasoningField
|
|
||||||
})
|
|
||||||
|
|
||||||
// 如果是 GPT-5,尝试从各种字段中提取推理级别
|
|
||||||
if (originalModel === 'gpt-5') {
|
|
||||||
let detectedLevel = null
|
|
||||||
|
|
||||||
// 方法1: 直接从 reasoning_effort 获取
|
|
||||||
if (reasoningEffort) {
|
|
||||||
detectedLevel = reasoningEffort
|
|
||||||
}
|
|
||||||
// 方法2: 从 reasoning 字段分析(可能是对象或字符串)
|
|
||||||
else if (reasoningField) {
|
|
||||||
if (typeof reasoningField === 'string') {
|
|
||||||
// 如果是字符串,查找级别关键词
|
|
||||||
if (reasoningField.includes('high') || reasoningField.includes('maximum')) {
|
|
||||||
detectedLevel = 'high'
|
|
||||||
} else if (reasoningField.includes('medium') || reasoningField.includes('balanced')) {
|
|
||||||
detectedLevel = 'medium'
|
|
||||||
} else if (reasoningField.includes('low') || reasoningField.includes('fast')) {
|
|
||||||
detectedLevel = 'low'
|
|
||||||
} else if (reasoningField.includes('minimal') || reasoningField.includes('quick')) {
|
|
||||||
detectedLevel = 'minimal'
|
|
||||||
}
|
|
||||||
} else if (typeof reasoningField === 'object') {
|
|
||||||
// 检查 effort 字段 (Codex CLI 使用这种格式)
|
|
||||||
if (reasoningField.effort) {
|
|
||||||
detectedLevel = reasoningField.effort
|
|
||||||
} else if (reasoningField.level) {
|
|
||||||
detectedLevel = reasoningField.level
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detectedLevel) {
|
|
||||||
effectiveModel = `gpt-5 ${detectedLevel}`
|
|
||||||
logger.info(
|
|
||||||
`🎯 Detected GPT-5 with reasoning level: ${detectedLevel} → Effective model for restriction: ${effectiveModel}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🚀 检查模型限制(使用有效模型名称)
|
|
||||||
if (
|
|
||||||
apiKeyData.enableModelRestriction &&
|
|
||||||
apiKeyData.restrictedModels &&
|
|
||||||
apiKeyData.restrictedModels.length > 0
|
|
||||||
) {
|
|
||||||
logger.info(
|
|
||||||
`🔒 OpenAI Model restriction check - Original: ${originalModel}, Effective: ${effectiveModel}, Restricted: ${JSON.stringify(apiKeyData.restrictedModels)}`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (effectiveModel && apiKeyData.restrictedModels.includes(effectiveModel)) {
|
|
||||||
logger.warn(
|
|
||||||
`🚫 OpenAI Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${effectiveModel}`
|
|
||||||
)
|
|
||||||
return res.status(403).json({
|
|
||||||
error: {
|
|
||||||
type: 'forbidden',
|
|
||||||
message: '暂无该模型访问权限',
|
|
||||||
code: 'model_restricted'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果通过限制检查,再进行模型规范化
|
|
||||||
|
|
||||||
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5
|
// 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5
|
||||||
if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5') {
|
if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5') {
|
||||||
@@ -294,7 +180,7 @@ const handleResponses = async (req, res) => {
|
|||||||
// 配置请求选项
|
// 配置请求选项
|
||||||
const axiosConfig = {
|
const axiosConfig = {
|
||||||
headers,
|
headers,
|
||||||
timeout: 60 * 1000 * 10,
|
timeout: config.requestTimeout || 600000,
|
||||||
validateStatus: () => true
|
validateStatus: () => true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,61 +368,6 @@ const handleResponses = async (req, res) => {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`📊 Recorded OpenAI non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${actualModel}`
|
`📊 Recorded OpenAI non-stream usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${actualModel}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 安全记录 GPT-5 High 推理级别费用(非流式响应)
|
|
||||||
try {
|
|
||||||
if (actualModel && String(actualModel).toLowerCase().includes('gpt-5')) {
|
|
||||||
// 安全提取推理级别
|
|
||||||
const originalRequestBody = req.body || {}
|
|
||||||
let detectedLevel = 'medium' // 安全默认值
|
|
||||||
|
|
||||||
try {
|
|
||||||
detectedLevel =
|
|
||||||
originalRequestBody.reasoning_effort ||
|
|
||||||
originalRequestBody.model_reasoning_effort ||
|
|
||||||
'medium'
|
|
||||||
|
|
||||||
// 检查 reasoning 字段
|
|
||||||
const reasoningField = originalRequestBody.reasoning
|
|
||||||
if (reasoningField) {
|
|
||||||
if (typeof reasoningField === 'string') {
|
|
||||||
if (reasoningField.includes('high') || reasoningField.includes('maximum')) {
|
|
||||||
detectedLevel = 'high'
|
|
||||||
}
|
|
||||||
} else if (typeof reasoningField === 'object' && reasoningField.effort) {
|
|
||||||
detectedLevel = reasoningField.effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (levelError) {
|
|
||||||
logger.debug(
|
|
||||||
'Error extracting reasoning level for cost recording (non-stream):',
|
|
||||||
levelError
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是 High 级别,记录额外的费用
|
|
||||||
if (String(detectedLevel).toLowerCase() === 'high') {
|
|
||||||
const gpt5HighCost = calculateGPT5HighCost(usageData)
|
|
||||||
|
|
||||||
if (gpt5HighCost > 0) {
|
|
||||||
// 记录GPT-5 High专门的费用统计(用于周限制)
|
|
||||||
await redis.incrementWeeklyGPT5HighCost(apiKeyData.id, gpt5HighCost)
|
|
||||||
logger.info(
|
|
||||||
`💰 Recorded GPT-5 High weekly cost (non-stream): $${gpt5HighCost.toFixed(4)} for key ${apiKeyData.id} (${apiKeyData.name})`
|
|
||||||
)
|
|
||||||
|
|
||||||
// 🔧 关键修复:同时记录到常规费用统计中
|
|
||||||
await redis.incrementDailyCost(apiKeyData.id, gpt5HighCost)
|
|
||||||
logger.info(
|
|
||||||
`💰 Recorded GPT-5 High to daily cost (non-stream): $${gpt5HighCost.toFixed(4)} for key ${apiKeyData.id} (${apiKeyData.name})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (gpt5CostError) {
|
|
||||||
logger.warn('Error in GPT-5 High cost recording (non-stream):', gpt5CostError)
|
|
||||||
// 不影响主流程
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回响应
|
// 返回响应
|
||||||
@@ -656,58 +487,6 @@ const handleResponses = async (req, res) => {
|
|||||||
logger.info(
|
logger.info(
|
||||||
`📊 Recorded OpenAI usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${modelToRecord} (actual: ${actualModel}, requested: ${requestedModel})`
|
`📊 Recorded OpenAI usage - Input: ${inputTokens}, Output: ${outputTokens}, Total: ${usageData.total_tokens || inputTokens + outputTokens}, Model: ${modelToRecord} (actual: ${actualModel}, requested: ${requestedModel})`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 安全记录 GPT-5 High 推理级别费用
|
|
||||||
try {
|
|
||||||
if (actualModel && String(actualModel).toLowerCase().includes('gpt-5')) {
|
|
||||||
// 安全提取推理级别
|
|
||||||
const originalRequestBody = req.body || {}
|
|
||||||
let detectedLevel = 'medium' // 安全默认值
|
|
||||||
|
|
||||||
try {
|
|
||||||
detectedLevel =
|
|
||||||
originalRequestBody.reasoning_effort ||
|
|
||||||
originalRequestBody.model_reasoning_effort ||
|
|
||||||
'medium'
|
|
||||||
|
|
||||||
// 检查 reasoning 字段
|
|
||||||
const reasoningField = originalRequestBody.reasoning
|
|
||||||
if (reasoningField) {
|
|
||||||
if (typeof reasoningField === 'string') {
|
|
||||||
if (reasoningField.includes('high') || reasoningField.includes('maximum')) {
|
|
||||||
detectedLevel = 'high'
|
|
||||||
}
|
|
||||||
} else if (typeof reasoningField === 'object' && reasoningField.effort) {
|
|
||||||
detectedLevel = reasoningField.effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (levelError) {
|
|
||||||
logger.debug('Error extracting reasoning level for cost recording:', levelError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是 High 级别,记录额外的费用
|
|
||||||
if (String(detectedLevel).toLowerCase() === 'high') {
|
|
||||||
const gpt5HighCost = calculateGPT5HighCost(usageData)
|
|
||||||
|
|
||||||
if (gpt5HighCost > 0) {
|
|
||||||
// 记录GPT-5 High专门的费用统计(用于周限制)
|
|
||||||
await redis.incrementWeeklyGPT5HighCost(apiKeyData.id, gpt5HighCost)
|
|
||||||
logger.info(
|
|
||||||
`💰 Recorded GPT-5 High weekly cost: $${gpt5HighCost.toFixed(4)} for key ${apiKeyData.id} (${apiKeyData.name})`
|
|
||||||
)
|
|
||||||
|
|
||||||
// 🔧 关键修复:同时记录到常规费用统计中
|
|
||||||
await redis.incrementDailyCost(apiKeyData.id, gpt5HighCost)
|
|
||||||
logger.info(
|
|
||||||
`💰 Recorded GPT-5 High to daily cost: $${gpt5HighCost.toFixed(4)} for key ${apiKeyData.id} (${apiKeyData.name})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (gpt5CostError) {
|
|
||||||
logger.warn('Error in GPT-5 High cost recording:', gpt5CostError)
|
|
||||||
// 不影响主流程
|
|
||||||
}
|
|
||||||
usageReported = true
|
usageReported = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to record OpenAI usage:', error)
|
logger.error('Failed to record OpenAI usage:', error)
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ class ApiKeyService {
|
|||||||
allowedClients = [],
|
allowedClients = [],
|
||||||
dailyCostLimit = 0,
|
dailyCostLimit = 0,
|
||||||
weeklyOpusCostLimit = 0,
|
weeklyOpusCostLimit = 0,
|
||||||
weeklyGPT5HighCostLimit = 0, // 新增:GPT-5 High推理级别周费用限制
|
|
||||||
tags = [],
|
tags = [],
|
||||||
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
||||||
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
||||||
@@ -70,7 +69,6 @@ class ApiKeyService {
|
|||||||
allowedClients: JSON.stringify(allowedClients || []),
|
allowedClients: JSON.stringify(allowedClients || []),
|
||||||
dailyCostLimit: String(dailyCostLimit || 0),
|
dailyCostLimit: String(dailyCostLimit || 0),
|
||||||
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
|
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
|
||||||
weeklyGPT5HighCostLimit: String(weeklyGPT5HighCostLimit || 0), // 新增:GPT-5 High周费用限制
|
|
||||||
tags: JSON.stringify(tags || []),
|
tags: JSON.stringify(tags || []),
|
||||||
activationDays: String(activationDays || 0), // 新增:激活后有效天数
|
activationDays: String(activationDays || 0), // 新增:激活后有效天数
|
||||||
expirationMode: expirationMode || 'fixed', // 新增:过期模式
|
expirationMode: expirationMode || 'fixed', // 新增:过期模式
|
||||||
@@ -114,7 +112,6 @@ class ApiKeyService {
|
|||||||
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
|
allowedClients: JSON.parse(keyData.allowedClients || '[]'),
|
||||||
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0),
|
||||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||||
weeklyGPT5HighCostLimit: parseFloat(keyData.weeklyGPT5HighCostLimit || 0), // 新增:GPT-5 High周费用限制
|
|
||||||
tags: JSON.parse(keyData.tags || '[]'),
|
tags: JSON.parse(keyData.tags || '[]'),
|
||||||
activationDays: parseInt(keyData.activationDays || 0),
|
activationDays: parseInt(keyData.activationDays || 0),
|
||||||
expirationMode: keyData.expirationMode || 'fixed',
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
@@ -122,8 +119,7 @@ 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
|
||||||
icon: keyData.icon || '' // 新增:图标
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,10 +412,8 @@ class ApiKeyService {
|
|||||||
key.permissions = key.permissions || 'all' // 兼容旧数据
|
key.permissions = key.permissions || 'all' // 兼容旧数据
|
||||||
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
|
key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0)
|
||||||
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
|
key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0)
|
||||||
key.weeklyGPT5HighCostLimit = parseFloat(key.weeklyGPT5HighCostLimit || 0) // 新增:GPT-5 High周费用限制
|
|
||||||
key.dailyCost = (await redis.getDailyCost(key.id)) || 0
|
key.dailyCost = (await redis.getDailyCost(key.id)) || 0
|
||||||
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
|
key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0
|
||||||
key.weeklyGPT5HighCost = (await redis.getWeeklyGPT5HighCost(key.id)) || 0 // 新增:GPT-5 High当前周费用
|
|
||||||
key.activationDays = parseInt(key.activationDays || 0)
|
key.activationDays = parseInt(key.activationDays || 0)
|
||||||
key.expirationMode = key.expirationMode || 'fixed'
|
key.expirationMode = key.expirationMode || 'fixed'
|
||||||
key.isActivated = key.isActivated === 'true'
|
key.isActivated = key.isActivated === 'true'
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ const {
|
|||||||
const tokenRefreshService = require('./tokenRefreshService')
|
const tokenRefreshService = require('./tokenRefreshService')
|
||||||
const LRUCache = require('../utils/lruCache')
|
const LRUCache = require('../utils/lruCache')
|
||||||
|
|
||||||
// Gemini CLI OAuth 配置 - 从环境变量或配置文件读取
|
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
|
||||||
const OAUTH_CLIENT_ID = process.env.GEMINI_OAUTH_CLIENT_ID || config.gemini?.oauthClientId || 'your-oauth-client-id'
|
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
|
||||||
const OAUTH_CLIENT_SECRET = process.env.GEMINI_OAUTH_CLIENT_SECRET || config.gemini?.oauthClientSecret || 'your-oauth-client-secret'
|
const OAUTH_CLIENT_SECRET = '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'
|
||||||
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
const OAUTH_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
||||||
|
|
||||||
// 加密相关常量
|
// 加密相关常量
|
||||||
|
|||||||
@@ -110,9 +110,10 @@
|
|||||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
|
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm"
|
||||||
>名称 <span class="text-red-500">*</span></label
|
>名称 <span class="text-red-500">*</span></label
|
||||||
>
|
>
|
||||||
|
<div>
|
||||||
<input
|
<input
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
:class="{ 'border-red-500': errors.name }"
|
:class="{ 'border-red-500': errors.name }"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
form.createType === 'batch'
|
form.createType === 'batch'
|
||||||
@@ -123,57 +124,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
@input="errors.name = ''"
|
@input="errors.name = ''"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||||
{{ errors.name }}
|
{{ errors.name }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图标上传 -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
||||||
图标 (可选)
|
|
||||||
</label>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- 当前图标预览 -->
|
|
||||||
<div v-if="form.icon" class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="h-12 w-12 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-600"
|
|
||||||
>
|
|
||||||
<img alt="API Key图标" class="h-full w-full object-cover" :src="form.icon" />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="text-sm text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
|
||||||
type="button"
|
|
||||||
@click="form.icon = ''"
|
|
||||||
>
|
|
||||||
移除图标
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 图标上传按钮 -->
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
ref="iconInput"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
type="file"
|
|
||||||
@change="handleIconUpload"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
|
||||||
type="button"
|
|
||||||
@click="$refs.iconInput.click()"
|
|
||||||
>
|
|
||||||
<i class="fas fa-upload" />
|
|
||||||
选择图标
|
|
||||||
</button>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
支持 PNG、JPG 格式,建议尺寸 64x64px
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 标签 -->
|
<!-- 标签 -->
|
||||||
<div>
|
<div>
|
||||||
<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"
|
||||||
@@ -428,56 +384,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- GPT-5 High推理级别周费用限制 -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
|
||||||
>GPT-5 High推理级别周费用限制 (美元)</label
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
|
||||||
type="button"
|
|
||||||
@click="form.weeklyGPT5HighCostLimit = '5'"
|
|
||||||
>
|
|
||||||
$5
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
|
||||||
type="button"
|
|
||||||
@click="form.weeklyGPT5HighCostLimit = '20'"
|
|
||||||
>
|
|
||||||
$20
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
|
||||||
type="button"
|
|
||||||
@click="form.weeklyGPT5HighCostLimit = '50'"
|
|
||||||
>
|
|
||||||
$50
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
|
||||||
type="button"
|
|
||||||
@click="form.weeklyGPT5HighCostLimit = ''"
|
|
||||||
>
|
|
||||||
自定义
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-model="form.weeklyGPT5HighCostLimit"
|
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
|
||||||
min="0"
|
|
||||||
placeholder="0 表示无限制"
|
|
||||||
step="0.01"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
设置 GPT-5 High推理级别的周费用限制(周一到周日),0 或留空表示无限制
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<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
|
||||||
@@ -898,7 +804,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
import { useClientsStore } from '@/stores/clients'
|
import { useClientsStore } from '@/stores/clients'
|
||||||
import { useApiKeysStore } from '@/stores/apiKeys'
|
import { useApiKeysStore } from '@/stores/apiKeys'
|
||||||
@@ -957,7 +862,6 @@ const form = reactive({
|
|||||||
concurrencyLimit: '',
|
concurrencyLimit: '',
|
||||||
dailyCostLimit: '',
|
dailyCostLimit: '',
|
||||||
weeklyOpusCostLimit: '',
|
weeklyOpusCostLimit: '',
|
||||||
weeklyGPT5HighCostLimit: '', // 新增:GPT-5 High推理级别周费用限制
|
|
||||||
expireDuration: '',
|
expireDuration: '',
|
||||||
customExpireDate: '',
|
customExpireDate: '',
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
@@ -973,8 +877,7 @@ const form = reactive({
|
|||||||
modelInput: '',
|
modelInput: '',
|
||||||
enableClientRestriction: false,
|
enableClientRestriction: false,
|
||||||
allowedClients: [],
|
allowedClients: [],
|
||||||
tags: [],
|
tags: []
|
||||||
icon: '' // 新增:图标(base64编码)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载支持的客户端和已存在的标签
|
// 加载支持的客户端和已存在的标签
|
||||||
@@ -995,35 +898,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 处理图标上传
|
|
||||||
const handleIconUpload = (event) => {
|
|
||||||
const file = event.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
// 检查文件类型
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
ElMessage.error('请选择图片文件')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查文件大小 (限制为 2MB)
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
|
||||||
ElMessage.error('图片大小不能超过 2MB')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取文件并转换为 base64
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => {
|
|
||||||
form.icon = e.target.result
|
|
||||||
ElMessage.success('图标上传成功')
|
|
||||||
}
|
|
||||||
reader.onerror = () => {
|
|
||||||
ElMessage.error('图标上传失败')
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新账号列表
|
// 刷新账号列表
|
||||||
const refreshAccounts = async () => {
|
const refreshAccounts = async () => {
|
||||||
accountsLoading.value = true
|
accountsLoading.value = true
|
||||||
@@ -1178,22 +1052,7 @@ const removeRestrictedModel = (index) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 常用模型列表
|
// 常用模型列表
|
||||||
const commonModels = ref([
|
const commonModels = ref(['claude-opus-4-20250514', 'claude-opus-4-1-20250805'])
|
||||||
// Claude 模型
|
|
||||||
'claude-opus-4-20250514',
|
|
||||||
'claude-opus-4-1-20250805',
|
|
||||||
// OpenAI 模型
|
|
||||||
'gpt-5',
|
|
||||||
'gpt-5 minimal',
|
|
||||||
'gpt-5 low',
|
|
||||||
'gpt-5 medium',
|
|
||||||
'gpt-5 high',
|
|
||||||
'gpt-4o',
|
|
||||||
'gpt-4o-mini',
|
|
||||||
'o1',
|
|
||||||
'o1-mini',
|
|
||||||
'o1-preview'
|
|
||||||
])
|
|
||||||
|
|
||||||
// 可用的快捷模型(过滤掉已在限制列表中的)
|
// 可用的快捷模型(过滤掉已在限制列表中的)
|
||||||
const availableQuickModels = computed(() => {
|
const availableQuickModels = computed(() => {
|
||||||
@@ -1296,11 +1155,6 @@ const createApiKey = async () => {
|
|||||||
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
|
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
|
||||||
? parseFloat(form.weeklyOpusCostLimit)
|
? parseFloat(form.weeklyOpusCostLimit)
|
||||||
: 0,
|
: 0,
|
||||||
// 新增:GPT-5 High推理级别周费用限制
|
|
||||||
weeklyGPT5HighCostLimit:
|
|
||||||
form.weeklyGPT5HighCostLimit !== '' && form.weeklyGPT5HighCostLimit !== null
|
|
||||||
? parseFloat(form.weeklyGPT5HighCostLimit)
|
|
||||||
: 0,
|
|
||||||
expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined,
|
expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined,
|
||||||
expirationMode: form.expirationMode,
|
expirationMode: form.expirationMode,
|
||||||
activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined,
|
activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined,
|
||||||
@@ -1309,8 +1163,7 @@ const createApiKey = async () => {
|
|||||||
enableModelRestriction: form.enableModelRestriction,
|
enableModelRestriction: form.enableModelRestriction,
|
||||||
restrictedModels: form.restrictedModels,
|
restrictedModels: form.restrictedModels,
|
||||||
enableClientRestriction: form.enableClientRestriction,
|
enableClientRestriction: form.enableClientRestriction,
|
||||||
allowedClients: form.allowedClients,
|
allowedClients: form.allowedClients
|
||||||
icon: form.icon || undefined // 新增:图标
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理Claude账户绑定(区分OAuth和Console)
|
// 处理Claude账户绑定(区分OAuth和Console)
|
||||||
|
|||||||
@@ -32,14 +32,16 @@
|
|||||||
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm"
|
||||||
>名称</label
|
>名称</label
|
||||||
>
|
>
|
||||||
|
<div>
|
||||||
<input
|
<input
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
class="form-input flex-1 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
maxlength="100"
|
maxlength="100"
|
||||||
placeholder="请输入API Key名称"
|
placeholder="请输入API Key名称"
|
||||||
required
|
required
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
|
||||||
用于识别此 API Key 的用途
|
用于识别此 API Key 的用途
|
||||||
</p>
|
</p>
|
||||||
@@ -320,56 +322,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- GPT-5 High推理级别周费用限制 -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
|
||||||
>GPT-5 High推理级别周费用限制 (美元)</label
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
|
||||||
type="button"
|
|
||||||
@click="form.weeklyGPT5HighCostLimit = '5'"
|
|
||||||
>
|
|
||||||
$5
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
|
||||||
type="button"
|
|
||||||
@click="form.weeklyGPT5HighCostLimit = '20'"
|
|
||||||
>
|
|
||||||
$20
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
|
||||||
type="button"
|
|
||||||
@click="form.weeklyGPT5HighCostLimit = '50'"
|
|
||||||
>
|
|
||||||
$50
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
|
||||||
type="button"
|
|
||||||
@click="form.weeklyGPT5HighCostLimit = ''"
|
|
||||||
>
|
|
||||||
自定义
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-model="form.weeklyGPT5HighCostLimit"
|
|
||||||
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
|
||||||
min="0"
|
|
||||||
placeholder="0 表示无限制"
|
|
||||||
step="0.01"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
设置 GPT-5 High推理级别的周费用限制(周一到周日),0 或留空表示无限制
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<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
|
||||||
@@ -762,7 +714,6 @@ const form = reactive({
|
|||||||
concurrencyLimit: '',
|
concurrencyLimit: '',
|
||||||
dailyCostLimit: '',
|
dailyCostLimit: '',
|
||||||
weeklyOpusCostLimit: '',
|
weeklyOpusCostLimit: '',
|
||||||
weeklyGPT5HighCostLimit: '', // 新增:GPT-5 High推理级别周费用限制
|
|
||||||
permissions: 'all',
|
permissions: 'all',
|
||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
geminiAccountId: '',
|
geminiAccountId: '',
|
||||||
@@ -792,22 +743,7 @@ const removeRestrictedModel = (index) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 常用模型列表
|
// 常用模型列表
|
||||||
const commonModels = ref([
|
const commonModels = ref(['claude-opus-4-20250514', 'claude-opus-4-1-20250805'])
|
||||||
// Claude 模型
|
|
||||||
'claude-opus-4-20250514',
|
|
||||||
'claude-opus-4-1-20250805',
|
|
||||||
// OpenAI 模型
|
|
||||||
'gpt-5',
|
|
||||||
'gpt-5 minimal',
|
|
||||||
'gpt-5 low',
|
|
||||||
'gpt-5 medium',
|
|
||||||
'gpt-5 high',
|
|
||||||
'gpt-4o',
|
|
||||||
'gpt-4o-mini',
|
|
||||||
'o1',
|
|
||||||
'o1-mini',
|
|
||||||
'o1-preview'
|
|
||||||
])
|
|
||||||
|
|
||||||
// 可用的快捷模型(过滤掉已在限制列表中的)
|
// 可用的快捷模型(过滤掉已在限制列表中的)
|
||||||
const availableQuickModels = computed(() => {
|
const availableQuickModels = computed(() => {
|
||||||
@@ -894,11 +830,6 @@ const updateApiKey = async () => {
|
|||||||
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
|
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
|
||||||
? parseFloat(form.weeklyOpusCostLimit)
|
? parseFloat(form.weeklyOpusCostLimit)
|
||||||
: 0,
|
: 0,
|
||||||
// 新增:GPT-5 High推理级别周费用限制
|
|
||||||
weeklyGPT5HighCostLimit:
|
|
||||||
form.weeklyGPT5HighCostLimit !== '' && form.weeklyGPT5HighCostLimit !== null
|
|
||||||
? parseFloat(form.weeklyGPT5HighCostLimit)
|
|
||||||
: 0,
|
|
||||||
permissions: form.permissions,
|
permissions: form.permissions,
|
||||||
tags: form.tags
|
tags: form.tags
|
||||||
}
|
}
|
||||||
@@ -1122,7 +1053,6 @@ onMounted(async () => {
|
|||||||
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
|
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
|
||||||
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
|
||||||
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
|
||||||
form.weeklyGPT5HighCostLimit = props.apiKey.weeklyGPT5HighCostLimit || '' // 新增
|
|
||||||
form.permissions = props.apiKey.permissions || 'all'
|
form.permissions = props.apiKey.permissions || 'all'
|
||||||
// 处理 Claude 账号(区分 OAuth 和 Console)
|
// 处理 Claude 账号(区分 OAuth 和 Console)
|
||||||
if (props.apiKey.claudeConsoleAccountId) {
|
if (props.apiKey.claudeConsoleAccountId) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="relative h-8 w-full overflow-hidden rounded-md shadow-sm" :class="containerClass">
|
<div class="relative h-8 w-full overflow-hidden rounded-lg shadow-sm" :class="containerClass">
|
||||||
<!-- 背景层 -->
|
<!-- 背景层 -->
|
||||||
<div class="absolute inset-0" :class="backgroundClass"></div>
|
<div class="absolute inset-0" :class="backgroundClass"></div>
|
||||||
|
|
||||||
@@ -12,16 +12,16 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- 文字层 - 使用双层文字技术确保可读性 -->
|
<!-- 文字层 - 使用双层文字技术确保可读性 -->
|
||||||
<div class="relative z-10 flex h-full items-center justify-between px-2.5">
|
<div class="relative z-10 flex h-full items-center justify-between px-3">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<i :class="['text-[10px]', iconClass]" />
|
<i :class="['text-xs', iconClass]" />
|
||||||
<span class="text-[10px] font-semibold" :class="labelTextClass">{{ label }}</span>
|
<span class="text-xs font-semibold" :class="labelTextClass">{{ label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mr-1 flex items-center gap-0.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-[10px] font-bold tabular-nums" :class="currentValueClass">
|
<span class="text-xs font-bold tabular-nums" :class="currentValueClass">
|
||||||
${{ current.toFixed(2) }}
|
${{ current.toFixed(2) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[9px] font-medium" :class="limitTextClass">
|
<span class="text-xs font-medium" :class="limitTextClass">
|
||||||
/ ${{ limit.toFixed(2) }}
|
/ ${{ limit.toFixed(2) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +48,7 @@ const props = defineProps({
|
|||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
validator: (value) => ['daily', 'opus', 'window', 'gpt5-high'].includes(value)
|
validator: (value) => ['daily', 'opus', 'window'].includes(value)
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -88,8 +88,6 @@ const backgroundClass = computed(() => {
|
|||||||
return 'bg-violet-50/50 dark:bg-violet-950/20'
|
return 'bg-violet-50/50 dark:bg-violet-950/20'
|
||||||
case 'window':
|
case 'window':
|
||||||
return 'bg-sky-50/50 dark:bg-sky-950/20'
|
return 'bg-sky-50/50 dark:bg-sky-950/20'
|
||||||
case 'gpt5-high':
|
|
||||||
return 'bg-orange-50/50 dark:bg-orange-950/20'
|
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100/50 dark:bg-gray-800/30'
|
return 'bg-gray-100/50 dark:bg-gray-800/30'
|
||||||
}
|
}
|
||||||
@@ -129,16 +127,6 @@ const progressBarClass = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.type === 'gpt5-high') {
|
|
||||||
if (p >= 90) {
|
|
||||||
return 'bg-red-400 dark:bg-red-500'
|
|
||||||
} else if (p >= 70) {
|
|
||||||
return 'bg-amber-400 dark:bg-amber-500'
|
|
||||||
} else {
|
|
||||||
return 'bg-orange-400 dark:bg-orange-500'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'bg-gray-300 dark:bg-gray-400'
|
return 'bg-gray-300 dark:bg-gray-400'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -163,9 +151,6 @@ const iconClass = computed(() => {
|
|||||||
case 'window':
|
case 'window':
|
||||||
colorClass = 'text-blue-700 dark:text-blue-400'
|
colorClass = 'text-blue-700 dark:text-blue-400'
|
||||||
break
|
break
|
||||||
case 'gpt5-high':
|
|
||||||
colorClass = 'text-orange-700 dark:text-orange-400'
|
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
colorClass = 'text-gray-600 dark:text-gray-400'
|
colorClass = 'text-gray-600 dark:text-gray-400'
|
||||||
}
|
}
|
||||||
@@ -182,9 +167,6 @@ const iconClass = computed(() => {
|
|||||||
case 'window':
|
case 'window':
|
||||||
iconName = 'fas fa-clock'
|
iconName = 'fas fa-clock'
|
||||||
break
|
break
|
||||||
case 'gpt5-high':
|
|
||||||
iconName = 'fas fa-brain'
|
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
iconName = 'fas fa-infinity'
|
iconName = 'fas fa-infinity'
|
||||||
}
|
}
|
||||||
@@ -209,8 +191,6 @@ const labelTextClass = computed(() => {
|
|||||||
return 'text-purple-900 dark:text-purple-100'
|
return 'text-purple-900 dark:text-purple-100'
|
||||||
case 'window':
|
case 'window':
|
||||||
return 'text-blue-900 dark:text-blue-100'
|
return 'text-blue-900 dark:text-blue-100'
|
||||||
case 'gpt5-high':
|
|
||||||
return 'text-orange-900 dark:text-orange-100'
|
|
||||||
default:
|
default:
|
||||||
return 'text-gray-900 dark:text-gray-100'
|
return 'text-gray-900 dark:text-gray-100'
|
||||||
}
|
}
|
||||||
@@ -239,8 +219,6 @@ const currentValueClass = computed(() => {
|
|||||||
return 'text-purple-800 dark:text-purple-200'
|
return 'text-purple-800 dark:text-purple-200'
|
||||||
case 'window':
|
case 'window':
|
||||||
return 'text-blue-800 dark:text-blue-200'
|
return 'text-blue-800 dark:text-blue-200'
|
||||||
case 'gpt5-high':
|
|
||||||
return 'text-orange-800 dark:text-orange-200'
|
|
||||||
default:
|
default:
|
||||||
return 'text-gray-900 dark:text-gray-100'
|
return 'text-gray-900 dark:text-gray-100'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,8 +403,8 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'table-row transition-all duration-150',
|
'table-row transition-all duration-150',
|
||||||
index % 2 === 0
|
index % 2 === 0
|
||||||
? 'bg-white dark:bg-gray-800/30'
|
? 'bg-white dark:bg-gray-800/40'
|
||||||
: 'bg-gray-50/70 dark:bg-gray-800/50',
|
: '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:bg-blue-50/60 hover:shadow-sm dark:hover:bg-blue-900/20'
|
||||||
]"
|
]"
|
||||||
@@ -432,7 +432,7 @@
|
|||||||
<!-- 显示所有者信息 -->
|
<!-- 显示所有者信息 -->
|
||||||
<div
|
<div
|
||||||
v-if="isLdapEnabled && key.ownerDisplayName"
|
v-if="isLdapEnabled && key.ownerDisplayName"
|
||||||
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
class="mt-1 text-xs text-red-600"
|
||||||
>
|
>
|
||||||
<i class="fas fa-user mr-1" />
|
<i class="fas fa-user mr-1" />
|
||||||
{{ key.ownerDisplayName }}
|
{{ key.ownerDisplayName }}
|
||||||
@@ -521,7 +521,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="!key.tags || key.tags.length === 0"
|
v-if="!key.tags || key.tags.length === 0"
|
||||||
class="text-xs text-gray-400 dark:text-gray-500"
|
class="text-xs text-gray-400"
|
||||||
>无标签</span
|
>无标签</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -555,7 +555,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<!-- 限制 -->
|
<!-- 限制 -->
|
||||||
<td class="px-2 py-2" style="font-size: 12px">
|
<td class="px-2 py-2" style="font-size: 12px">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-2">
|
||||||
<!-- 每日费用限制进度条 -->
|
<!-- 每日费用限制进度条 -->
|
||||||
<LimitProgressBar
|
<LimitProgressBar
|
||||||
v-if="key.dailyCostLimit > 0"
|
v-if="key.dailyCostLimit > 0"
|
||||||
@@ -574,15 +574,6 @@
|
|||||||
type="opus"
|
type="opus"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- GPT-5 High 周费用限制进度条 -->
|
|
||||||
<LimitProgressBar
|
|
||||||
v-if="key.weeklyGPT5HighCostLimit > 0"
|
|
||||||
:current="key.weeklyGPT5HighCost || 0"
|
|
||||||
label="GPT-5H"
|
|
||||||
:limit="key.weeklyGPT5HighCostLimit"
|
|
||||||
type="gpt5-high"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 时间窗口限制进度条 -->
|
<!-- 时间窗口限制进度条 -->
|
||||||
<WindowLimitBar
|
<WindowLimitBar
|
||||||
v-if="key.rateLimitWindow > 0"
|
v-if="key.rateLimitWindow > 0"
|
||||||
@@ -601,19 +592,18 @@
|
|||||||
v-if="
|
v-if="
|
||||||
!key.dailyCostLimit &&
|
!key.dailyCostLimit &&
|
||||||
!key.weeklyOpusCostLimit &&
|
!key.weeklyOpusCostLimit &&
|
||||||
!key.weeklyGPT5HighCostLimit &&
|
|
||||||
!key.rateLimitWindow
|
!key.rateLimitWindow
|
||||||
"
|
"
|
||||||
class="text-center"
|
class="dark:to-gray-750 relative h-7 w-full overflow-hidden rounded-md border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 dark:border-gray-700 dark:from-gray-800"
|
||||||
>
|
>
|
||||||
<span
|
<div class="flex h-full items-center justify-center gap-1.5">
|
||||||
class="inline-flex items-center gap-1 rounded-full bg-gray-200 px-2 py-1 text-xs text-gray-600 dark:bg-gray-600 dark:text-gray-300"
|
<i class="fas fa-infinity text-xs text-gray-400 dark:text-gray-500" />
|
||||||
>
|
<span class="text-xs font-medium text-gray-400 dark:text-gray-500">
|
||||||
<i class="fas fa-infinity" />
|
无限制
|
||||||
<span>无限制</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- Token数量 -->
|
<!-- Token数量 -->
|
||||||
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
<td class="whitespace-nowrap px-3 py-3 text-right" style="font-size: 13px">
|
||||||
@@ -677,7 +667,7 @@
|
|||||||
<span v-else-if="key.expiresAt">
|
<span v-else-if="key.expiresAt">
|
||||||
<span
|
<span
|
||||||
v-if="isApiKeyExpired(key.expiresAt)"
|
v-if="isApiKeyExpired(key.expiresAt)"
|
||||||
class="inline-flex cursor-pointer items-center text-red-600 hover:underline dark:text-red-400"
|
class="inline-flex cursor-pointer items-center text-red-600 hover:underline"
|
||||||
style="font-size: 13px"
|
style="font-size: 13px"
|
||||||
@click.stop="startEditExpiry(key)"
|
@click.stop="startEditExpiry(key)"
|
||||||
>
|
>
|
||||||
@@ -686,7 +676,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else-if="isApiKeyExpiringSoon(key.expiresAt)"
|
v-else-if="isApiKeyExpiringSoon(key.expiresAt)"
|
||||||
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline dark:text-orange-400"
|
class="inline-flex cursor-pointer items-center text-orange-600 hover:underline"
|
||||||
style="font-size: 13px"
|
style="font-size: 13px"
|
||||||
@click.stop="startEditExpiry(key)"
|
@click.stop="startEditExpiry(key)"
|
||||||
>
|
>
|
||||||
@@ -717,7 +707,7 @@
|
|||||||
<td class="whitespace-nowrap px-3 py-3" style="font-size: 13px">
|
<td class="whitespace-nowrap px-3 py-3" style="font-size: 13px">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
class="rounded px-2 py-1 text-xs font-medium text-purple-600 transition-colors hover:bg-purple-50 hover:text-purple-900 dark:text-purple-400 dark:hover:bg-purple-900/20"
|
class="rounded px-2 py-1 text-xs font-medium text-purple-600 transition-colors hover:bg-purple-50 hover:text-purple-900 dark:hover:bg-purple-900/20"
|
||||||
title="查看详细统计"
|
title="查看详细统计"
|
||||||
@click="showUsageDetails(key)"
|
@click="showUsageDetails(key)"
|
||||||
>
|
>
|
||||||
@@ -726,7 +716,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="key && key.id"
|
v-if="key && key.id"
|
||||||
class="rounded px-2 py-1 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 hover:text-indigo-900 dark:text-indigo-400 dark:hover:bg-indigo-900/20"
|
class="rounded px-2 py-1 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 hover:text-indigo-900 dark:hover:bg-indigo-900/20"
|
||||||
title="模型使用分布"
|
title="模型使用分布"
|
||||||
@click="toggleApiKeyModelStats(key.id)"
|
@click="toggleApiKeyModelStats(key.id)"
|
||||||
>
|
>
|
||||||
@@ -739,7 +729,7 @@
|
|||||||
<span class="ml-1 hidden xl:inline">模型</span>
|
<span class="ml-1 hidden xl:inline">模型</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded px-2 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-900 dark:text-blue-400 dark:hover:bg-blue-900/20"
|
class="rounded px-2 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 hover:text-blue-900 dark:hover:bg-blue-900/20"
|
||||||
title="编辑"
|
title="编辑"
|
||||||
@click="openEditApiKeyModal(key)"
|
@click="openEditApiKeyModal(key)"
|
||||||
>
|
>
|
||||||
@@ -752,7 +742,7 @@
|
|||||||
(isApiKeyExpired(key.expiresAt) ||
|
(isApiKeyExpired(key.expiresAt) ||
|
||||||
isApiKeyExpiringSoon(key.expiresAt))
|
isApiKeyExpiringSoon(key.expiresAt))
|
||||||
"
|
"
|
||||||
class="rounded px-2 py-1 text-xs font-medium text-green-600 transition-colors hover:bg-green-50 hover:text-green-900 dark:text-green-400 dark:hover:bg-green-900/20"
|
class="rounded px-2 py-1 text-xs font-medium text-green-600 transition-colors hover:bg-green-50 hover:text-green-900 dark:hover:bg-green-900/20"
|
||||||
title="续期"
|
title="续期"
|
||||||
@click="openRenewApiKeyModal(key)"
|
@click="openRenewApiKeyModal(key)"
|
||||||
>
|
>
|
||||||
@@ -762,8 +752,8 @@
|
|||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
key.isActive
|
key.isActive
|
||||||
? 'text-orange-600 hover:bg-orange-50 hover:text-orange-900 dark:text-orange-400 dark:hover:bg-orange-900/20'
|
? 'text-orange-600 hover:bg-orange-50 hover:text-orange-900 dark:hover:bg-orange-900/20'
|
||||||
: 'text-green-600 hover:bg-green-50 hover:text-green-900 dark:text-green-400 dark:hover:bg-green-900/20',
|
: 'text-green-600 hover:bg-green-50 hover:text-green-900 dark:hover:bg-green-900/20',
|
||||||
'rounded px-2 py-1 text-xs font-medium transition-colors'
|
'rounded px-2 py-1 text-xs font-medium transition-colors'
|
||||||
]"
|
]"
|
||||||
:title="key.isActive ? '禁用' : '激活'"
|
:title="key.isActive ? '禁用' : '激活'"
|
||||||
@@ -775,7 +765,7 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded px-2 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-900 dark:text-red-400 dark:hover:bg-red-900/20"
|
class="rounded px-2 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 hover:text-red-900 dark:hover:bg-red-900/20"
|
||||||
title="删除"
|
title="删除"
|
||||||
@click="deleteApiKey(key.id)"
|
@click="deleteApiKey(key.id)"
|
||||||
>
|
>
|
||||||
@@ -920,7 +910,7 @@
|
|||||||
<i class="fas fa-dollar-sign mr-1 text-xs text-green-500" />
|
<i class="fas fa-dollar-sign mr-1 text-xs text-green-500" />
|
||||||
费用:
|
费用:
|
||||||
</span>
|
</span>
|
||||||
<span class="font-semibold text-green-600 dark:text-green-400">{{
|
<span class="font-semibold text-green-600">{{
|
||||||
calculateModelCost(stat)
|
calculateModelCost(stat)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -951,7 +941,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="stat.cacheCreateTokens > 0"
|
v-if="stat.cacheCreateTokens > 0"
|
||||||
class="flex items-center justify-between text-xs text-purple-600 dark:text-purple-400"
|
class="flex items-center justify-between text-xs text-purple-600"
|
||||||
>
|
>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-save mr-1" />
|
<i class="fas fa-save mr-1" />
|
||||||
@@ -963,7 +953,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="stat.cacheReadTokens > 0"
|
v-if="stat.cacheReadTokens > 0"
|
||||||
class="flex items-center justify-between text-xs text-purple-600 dark:text-purple-400"
|
class="flex items-center justify-between text-xs text-purple-600"
|
||||||
>
|
>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-download mr-1" />
|
<i class="fas fa-download mr-1" />
|
||||||
@@ -992,9 +982,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-right">
|
<div class="mt-1 text-right">
|
||||||
<span
|
<span class="text-xs font-medium text-indigo-600">
|
||||||
class="text-xs font-medium text-indigo-600 dark:text-indigo-400"
|
|
||||||
>
|
|
||||||
{{
|
{{
|
||||||
calculateApiKeyModelPercentage(
|
calculateApiKeyModelPercentage(
|
||||||
stat.allTokens,
|
stat.allTokens,
|
||||||
@@ -1164,10 +1152,7 @@
|
|||||||
使用共享池
|
使用共享池
|
||||||
</div>
|
</div>
|
||||||
<!-- 显示所有者信息 -->
|
<!-- 显示所有者信息 -->
|
||||||
<div
|
<div v-if="isLdapEnabled && key.ownerDisplayName" class="text-xs text-red-600">
|
||||||
v-if="isLdapEnabled && key.ownerDisplayName"
|
|
||||||
class="text-xs text-red-600 dark:text-red-400"
|
|
||||||
>
|
|
||||||
<i class="fas fa-user mr-1" />
|
<i class="fas fa-user mr-1" />
|
||||||
{{ key.ownerDisplayName }}
|
{{ key.ownerDisplayName }}
|
||||||
</div>
|
</div>
|
||||||
@@ -1182,7 +1167,7 @@
|
|||||||
globalDateFilter.type === 'custom' ? '累计统计' : '今日使用'
|
globalDateFilter.type === 'custom' ? '累计统计' : '今日使用'
|
||||||
}}</span>
|
}}</span>
|
||||||
<button
|
<button
|
||||||
class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
class="text-xs text-blue-600 hover:text-blue-800"
|
||||||
@click="showUsageDetails(key)"
|
@click="showUsageDetails(key)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-chart-line mr-1" />详情
|
<i class="fas fa-chart-line mr-1" />详情
|
||||||
@@ -1196,7 +1181,7 @@
|
|||||||
<p class="text-xs text-gray-500 dark:text-gray-400">请求</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">请求</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-green-600 dark:text-green-400">
|
<p class="text-sm font-semibold text-green-600">
|
||||||
${{ (key.dailyCost || 0).toFixed(2) }}
|
${{ (key.dailyCost || 0).toFixed(2) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">费用</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">费用</p>
|
||||||
@@ -1269,9 +1254,7 @@
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
:class="
|
:class="
|
||||||
isApiKeyExpiringSoon(key.expiresAt)
|
isApiKeyExpiringSoon(key.expiresAt) ? 'font-semibold text-orange-600' : ''
|
||||||
? 'font-semibold text-orange-600 dark:text-orange-400'
|
|
||||||
: ''
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ key.expiresAt ? formatDate(key.expiresAt) : '永不过期' }}
|
{{ key.expiresAt ? formatDate(key.expiresAt) : '永不过期' }}
|
||||||
@@ -1308,7 +1291,7 @@
|
|||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div class="mt-3 flex gap-2 border-t border-gray-100 pt-3 dark:border-gray-600">
|
<div class="mt-3 flex gap-2 border-t border-gray-100 pt-3 dark:border-gray-600">
|
||||||
<button
|
<button
|
||||||
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-3 py-1.5 text-xs text-blue-600 transition-colors hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
|
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-3 py-1.5 text-xs text-blue-600 transition-colors hover:bg-blue-100 dark:bg-blue-900/30 dark:hover:bg-blue-900/50"
|
||||||
@click="showUsageDetails(key)"
|
@click="showUsageDetails(key)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-chart-line" />
|
<i class="fas fa-chart-line" />
|
||||||
@@ -1326,7 +1309,7 @@
|
|||||||
key.expiresAt &&
|
key.expiresAt &&
|
||||||
(isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))
|
(isApiKeyExpired(key.expiresAt) || isApiKeyExpiringSoon(key.expiresAt))
|
||||||
"
|
"
|
||||||
class="flex-1 rounded-lg bg-orange-50 px-3 py-1.5 text-xs text-orange-600 transition-colors hover:bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50"
|
class="flex-1 rounded-lg bg-orange-50 px-3 py-1.5 text-xs text-orange-600 transition-colors hover:bg-orange-100 dark:bg-orange-900/30 dark:hover:bg-orange-900/50"
|
||||||
@click="openRenewApiKeyModal(key)"
|
@click="openRenewApiKeyModal(key)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-clock mr-1" />
|
<i class="fas fa-clock mr-1" />
|
||||||
@@ -1335,8 +1318,8 @@
|
|||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
key.isActive
|
key.isActive
|
||||||
? 'bg-orange-50 text-orange-600 hover:bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50'
|
? 'bg-orange-50 text-orange-600 hover:bg-orange-100 dark:bg-orange-900/30 dark:hover:bg-orange-900/50'
|
||||||
: 'bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50',
|
: 'bg-green-50 text-green-600 hover:bg-green-100 dark:bg-green-900/30 dark:hover:bg-green-900/50',
|
||||||
'rounded-lg px-3 py-1.5 text-xs transition-colors'
|
'rounded-lg px-3 py-1.5 text-xs transition-colors'
|
||||||
]"
|
]"
|
||||||
@click="toggleApiKeyStatus(key)"
|
@click="toggleApiKeyStatus(key)"
|
||||||
@@ -1345,7 +1328,7 @@
|
|||||||
{{ key.isActive ? '禁用' : '激活' }}
|
{{ key.isActive ? '禁用' : '激活' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs text-red-600 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs text-red-600 transition-colors hover:bg-red-100 dark:bg-red-900/30 dark:hover:bg-red-900/50"
|
||||||
@click="deleteApiKey(key.id)"
|
@click="deleteApiKey(key.id)"
|
||||||
>
|
>
|
||||||
<i class="fas fa-trash" />
|
<i class="fas fa-trash" />
|
||||||
@@ -1605,17 +1588,11 @@
|
|||||||
<!-- 创建者 -->
|
<!-- 创建者 -->
|
||||||
<td v-if="isLdapEnabled" class="px-3 py-3">
|
<td v-if="isLdapEnabled" class="px-3 py-3">
|
||||||
<div class="text-xs">
|
<div class="text-xs">
|
||||||
<span
|
<span v-if="key.createdBy === 'admin'" class="text-blue-600">
|
||||||
v-if="key.createdBy === 'admin'"
|
|
||||||
class="text-blue-600 dark:text-blue-400"
|
|
||||||
>
|
|
||||||
<i class="fas fa-user-shield mr-1 text-xs" />
|
<i class="fas fa-user-shield mr-1 text-xs" />
|
||||||
管理员
|
管理员
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span v-else-if="key.userUsername" class="text-green-600">
|
||||||
v-else-if="key.userUsername"
|
|
||||||
class="text-green-600 dark:text-green-400"
|
|
||||||
>
|
|
||||||
<i class="fas fa-user mr-1 text-xs" />
|
<i class="fas fa-user mr-1 text-xs" />
|
||||||
{{ key.userUsername }}
|
{{ key.userUsername }}
|
||||||
</span>
|
</span>
|
||||||
@@ -1635,17 +1612,11 @@
|
|||||||
<!-- 删除者 -->
|
<!-- 删除者 -->
|
||||||
<td class="px-3 py-3">
|
<td class="px-3 py-3">
|
||||||
<div class="text-xs">
|
<div class="text-xs">
|
||||||
<span
|
<span v-if="key.deletedByType === 'admin'" class="text-blue-600">
|
||||||
v-if="key.deletedByType === 'admin'"
|
|
||||||
class="text-blue-600 dark:text-blue-400"
|
|
||||||
>
|
|
||||||
<i class="fas fa-user-shield mr-1 text-xs" />
|
<i class="fas fa-user-shield mr-1 text-xs" />
|
||||||
{{ key.deletedBy }}
|
{{ key.deletedBy }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span v-else-if="key.deletedByType === 'user'" class="text-green-600">
|
||||||
v-else-if="key.deletedByType === 'user'"
|
|
||||||
class="text-green-600 dark:text-green-400"
|
|
||||||
>
|
|
||||||
<i class="fas fa-user mr-1 text-xs" />
|
<i class="fas fa-user mr-1 text-xs" />
|
||||||
{{ key.deletedBy }}
|
{{ key.deletedBy }}
|
||||||
</span>
|
</span>
|
||||||
@@ -3340,7 +3311,6 @@ const formatDate = (dateString) => {
|
|||||||
// if (progress >= 100) return 'bg-red-500'
|
// if (progress >= 100) return 'bg-red-500'
|
||||||
// if (progress >= 80) return 'bg-yellow-500'
|
// if (progress >= 80) return 'bg-yellow-500'
|
||||||
// return 'bg-green-500'
|
// return 'bg-green-500'
|
||||||
|
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// 获取 Opus 周费用进度 - 已移到 LimitBadge 组件中
|
// 获取 Opus 周费用进度 - 已移到 LimitBadge 组件中
|
||||||
|
|||||||
604
web/admin-spa/src/views/ApiStatsView.vue
Normal file
604
web/admin-spa/src/views/ApiStatsView.vue
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen 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 class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||||
|
<LogoTitle
|
||||||
|
:loading="oemLoading"
|
||||||
|
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||||
|
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
|
||||||
|
:title="oemSettings.siteName"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-2 md:gap-4">
|
||||||
|
<!-- 主题切换按钮 -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ThemeToggle mode="dropdown" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分隔线 -->
|
||||||
|
<div
|
||||||
|
v-if="oemSettings.ldapEnabled || oemSettings.showAdminButton !== false"
|
||||||
|
class="h-8 w-px bg-gradient-to-b from-transparent via-gray-300 to-transparent opacity-50 dark:via-gray-600"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 用户登录按钮 (仅在 LDAP 启用时显示) -->
|
||||||
|
<router-link
|
||||||
|
v-if="oemSettings.ldapEnabled"
|
||||||
|
class="user-login-button flex items-center gap-2 rounded-2xl px-4 py-2 text-white transition-all duration-300 md:px-5 md:py-2.5"
|
||||||
|
to="/user-login"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user text-sm md:text-base" />
|
||||||
|
<span class="text-xs font-semibold tracking-wide md:text-sm">用户登录</span>
|
||||||
|
</router-link>
|
||||||
|
<!-- 管理后台按钮 -->
|
||||||
|
<router-link
|
||||||
|
v-if="oemSettings.showAdminButton !== false"
|
||||||
|
class="admin-button-refined flex items-center gap-2 rounded-2xl px-4 py-2 transition-all duration-300 md:px-5 md:py-2.5"
|
||||||
|
to="/dashboard"
|
||||||
|
>
|
||||||
|
<i class="fas fa-shield-alt text-sm md:text-base" />
|
||||||
|
<span class="text-xs font-semibold tracking-wide md:text-sm">管理后台</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 切换 -->
|
||||||
|
<div class="mb-6 md:mb-8">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']"
|
||||||
|
@click="currentTab = 'stats'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chart-line mr-1 md:mr-2" />
|
||||||
|
<span class="text-sm md:text-base">统计查询</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="['tab-pill-button', currentTab === 'tutorial' ? 'active' : '']"
|
||||||
|
@click="currentTab = 'tutorial'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-graduation-cap mr-1 md:mr-2" />
|
||||||
|
<span class="text-sm md:text-base">使用教程</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计内容 -->
|
||||||
|
<div v-if="currentTab === 'stats'" class="tab-content">
|
||||||
|
<!-- API Key 输入区域 -->
|
||||||
|
<ApiKeyInput />
|
||||||
|
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<div v-if="error" class="mb-6 md:mb-8">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2" />
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计数据展示区域 -->
|
||||||
|
<div v-if="statsData" class="fade-in">
|
||||||
|
<div class="glass-strong rounded-3xl p-4 shadow-xl 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="flex flex-col items-start justify-between gap-3 md:flex-row md:items-center md:gap-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 md:gap-3">
|
||||||
|
<i class="fas fa-clock text-base text-blue-500 md:text-lg" />
|
||||||
|
<span class="text-base font-medium text-gray-700 dark:text-gray-200 md:text-lg"
|
||||||
|
>统计时间范围</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full gap-2 md:w-auto">
|
||||||
|
<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="['period-btn', { active: statsPeriod === 'daily' }]"
|
||||||
|
:disabled="loading || modelStatsLoading"
|
||||||
|
@click="switchPeriod('daily')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-calendar-day text-xs md:text-sm" />
|
||||||
|
今日
|
||||||
|
</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="['period-btn', { active: statsPeriod === 'monthly' }]"
|
||||||
|
:disabled="loading || modelStatsLoading"
|
||||||
|
@click="switchPeriod('monthly')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-calendar-alt text-xs md:text-sm" />
|
||||||
|
本月
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 基本信息和统计概览 -->
|
||||||
|
<StatsOverview />
|
||||||
|
|
||||||
|
<!-- Token 分布和限制配置 -->
|
||||||
|
<div class="mb-6 grid grid-cols-1 gap-4 md:mb-8 md:gap-6 lg:grid-cols-2">
|
||||||
|
<TokenDistribution />
|
||||||
|
<!-- 单key模式下显示限制配置 -->
|
||||||
|
<LimitConfig v-if="!multiKeyMode" />
|
||||||
|
<!-- 多key模式下显示聚合统计卡片,填充右侧空白 -->
|
||||||
|
<AggregatedStatsCard v-if="multiKeyMode" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模型使用统计 -->
|
||||||
|
<ModelUsageStats />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 教程内容 -->
|
||||||
|
<div v-if="currentTab === 'tutorial'" class="tab-content">
|
||||||
|
<div class="glass-strong rounded-3xl shadow-xl">
|
||||||
|
<TutorialView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useApiStatsStore } from '@/stores/apistats'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||||
|
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||||
|
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
|
||||||
|
import StatsOverview from '@/components/apistats/StatsOverview.vue'
|
||||||
|
import TokenDistribution from '@/components/apistats/TokenDistribution.vue'
|
||||||
|
import LimitConfig from '@/components/apistats/LimitConfig.vue'
|
||||||
|
import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue'
|
||||||
|
import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue'
|
||||||
|
import TutorialView from './TutorialView.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const apiStatsStore = useApiStatsStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
// 当前标签页
|
||||||
|
const currentTab = ref('stats')
|
||||||
|
|
||||||
|
// 主题相关
|
||||||
|
const isDarkMode = computed(() => themeStore.isDarkMode)
|
||||||
|
|
||||||
|
const {
|
||||||
|
apiKey,
|
||||||
|
apiId,
|
||||||
|
loading,
|
||||||
|
modelStatsLoading,
|
||||||
|
oemLoading,
|
||||||
|
error,
|
||||||
|
statsPeriod,
|
||||||
|
statsData,
|
||||||
|
oemSettings,
|
||||||
|
multiKeyMode
|
||||||
|
} = storeToRefs(apiStatsStore)
|
||||||
|
|
||||||
|
const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore
|
||||||
|
|
||||||
|
// 处理键盘快捷键
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
// Ctrl/Cmd + Enter 查询
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
||||||
|
if (!loading.value && apiKey.value.trim()) {
|
||||||
|
queryStats()
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC 清除数据
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('API Stats Page loaded')
|
||||||
|
|
||||||
|
// 初始化主题(因为该页面不在 MainLayout 内)
|
||||||
|
themeStore.initTheme()
|
||||||
|
|
||||||
|
// 加载 OEM 设置
|
||||||
|
loadOemSettings()
|
||||||
|
|
||||||
|
// 检查 URL 参数
|
||||||
|
const urlApiId = route.query.apiId
|
||||||
|
const urlApiKey = route.query.apiKey
|
||||||
|
|
||||||
|
if (
|
||||||
|
urlApiId &&
|
||||||
|
urlApiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
|
||||||
|
) {
|
||||||
|
// 如果 URL 中有 apiId,直接使用 apiId 加载数据
|
||||||
|
apiId.value = urlApiId
|
||||||
|
loadStatsWithApiId()
|
||||||
|
} else if (urlApiKey && urlApiKey.length > 10) {
|
||||||
|
// 向后兼容,支持 apiKey 参数
|
||||||
|
apiKey.value = urlApiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加键盘事件监听
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 API Key 变化
|
||||||
|
watch(apiKey, (newValue) => {
|
||||||
|
if (!newValue) {
|
||||||
|
apiStatsStore.clearData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 渐变背景 */
|
||||||
|
.gradient-bg {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式的渐变背景 */
|
||||||
|
.gradient-bg-dark {
|
||||||
|
background: linear-gradient(135deg, #1e293b 0%, #334155 50%, #475569 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-bg::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 80%, rgba(240, 147, 251, 0.2) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 20%, rgba(102, 126, 234, 0.2) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 40% 40%, rgba(118, 75, 162, 0.1) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式的背景覆盖 */
|
||||||
|
.gradient-bg-dark::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 80%, rgba(100, 116, 139, 0.1) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 20%, rgba(71, 85, 105, 0.1) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 40% 40%, rgba(30, 41, 59, 0.1) 0%, transparent 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 玻璃态效果 - 使用CSS变量 */
|
||||||
|
.glass-strong {
|
||||||
|
background: var(--glass-strong-color);
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow:
|
||||||
|
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式的玻璃态效果 */
|
||||||
|
:global(.dark) .glass-strong {
|
||||||
|
box-shadow:
|
||||||
|
0 25px 50px -12px rgba(0, 0, 0, 0.7),
|
||||||
|
0 0 0 1px rgba(55, 65, 81, 0.3),
|
||||||
|
inset 0 1px 0 rgba(75, 85, 99, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题渐变 */
|
||||||
|
.header-title {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户登录按钮 */
|
||||||
|
.user-login-button {
|
||||||
|
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(52, 211, 153, 0.25),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式下的用户登录按钮 */
|
||||||
|
:global(.dark) .user-login-button {
|
||||||
|
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.4);
|
||||||
|
color: white;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(52, 211, 153, 0.3),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-login-button::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-login-button:hover {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 20px rgba(52, 211, 153, 0.35),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-login-button:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式下的悬停效果 */
|
||||||
|
:global(.dark) .user-login-button:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 8px 20px rgba(52, 211, 153, 0.4),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(52, 211, 153, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-login-button:active {
|
||||||
|
transform: translateY(-1px) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保图标和文字在所有模式下都清晰可见 */
|
||||||
|
.user-login-button i,
|
||||||
|
.user-login-button span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 管理后台按钮 - 精致版本 */
|
||||||
|
.admin-button-refined {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(102, 126, 234, 0.25),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式下的管理后台按钮 */
|
||||||
|
:global(.dark) .admin-button-refined {
|
||||||
|
background: rgba(55, 65, 81, 0.8);
|
||||||
|
border: 1px solid rgba(107, 114, 128, 0.4);
|
||||||
|
color: #f3f4f6;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-button-refined::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-button-refined:hover {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 20px rgba(118, 75, 162, 0.35),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-button-refined:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式下的悬停效果 */
|
||||||
|
:global(.dark) .admin-button-refined:hover {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-color: rgba(147, 51, 234, 0.4);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 20px rgba(102, 126, 234, 0.3),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-button-refined:active {
|
||||||
|
transform: translateY(-1px) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保图标和文字在所有模式下都清晰可见 */
|
||||||
|
.admin-button-refined i,
|
||||||
|
.admin-button-refined span {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 时间范围按钮 */
|
||||||
|
.period-btn {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn.active {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(102, 126, 234, 0.3),
|
||||||
|
0 4px 6px -2px rgba(102, 126, 234, 0.05);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn:not(.active) {
|
||||||
|
color: #374151;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border: 1px solid rgba(229, 231, 235, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark) .period-btn:not(.active) {
|
||||||
|
color: #e5e7eb;
|
||||||
|
background: rgba(55, 65, 81, 0.4);
|
||||||
|
border: 1px solid rgba(75, 85, 99, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn:not(.active):hover {
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
color: #1f2937;
|
||||||
|
border-color: rgba(209, 213, 219, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark) .period-btn:not(.active):hover {
|
||||||
|
background: rgba(75, 85, 99, 0.6);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(107, 114, 128, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab 胶囊按钮样式 */
|
||||||
|
.tab-pill-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗夜模式下的Tab按钮基础样式 */
|
||||||
|
:global(html.dark) .tab-pill-button {
|
||||||
|
color: rgba(209, 213, 219, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.tab-pill-button {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pill-button:hover {
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark) .tab-pill-button:hover {
|
||||||
|
color: #f3f4f6;
|
||||||
|
background: rgba(100, 116, 139, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pill-button.active {
|
||||||
|
background: white;
|
||||||
|
color: #764ba2;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.dark) .tab-pill-button.active {
|
||||||
|
background: rgba(71, 85, 105, 0.9);
|
||||||
|
color: #f3f4f6;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pill-button i {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab 内容切换动画 */
|
||||||
|
.tab-content {
|
||||||
|
animation: tabFadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tabFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user