mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 🎸 export csv from web and support hourly TTL of key
This commit is contained in:
@@ -84,16 +84,214 @@ function sanitizeData(data, type) {
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSV 字段映射配置
|
||||||
|
const CSV_FIELD_MAPPING = {
|
||||||
|
// 基本信息
|
||||||
|
id: 'ID',
|
||||||
|
name: '名称',
|
||||||
|
description: '描述',
|
||||||
|
isActive: '状态',
|
||||||
|
createdAt: '创建时间',
|
||||||
|
lastUsedAt: '最后使用时间',
|
||||||
|
createdBy: '创建者',
|
||||||
|
|
||||||
|
// API Key 信息
|
||||||
|
apiKey: 'API密钥',
|
||||||
|
tokenLimit: '令牌限制',
|
||||||
|
|
||||||
|
// 过期设置
|
||||||
|
expirationMode: '过期模式',
|
||||||
|
expiresAt: '过期时间',
|
||||||
|
activationDays: '激活天数',
|
||||||
|
activationUnit: '激活单位',
|
||||||
|
isActivated: '已激活',
|
||||||
|
activatedAt: '激活时间',
|
||||||
|
|
||||||
|
// 权限设置
|
||||||
|
permissions: '服务权限',
|
||||||
|
|
||||||
|
// 限制设置
|
||||||
|
rateLimitWindow: '速率窗口(分钟)',
|
||||||
|
rateLimitRequests: '请求次数限制',
|
||||||
|
rateLimitCost: '费用限制(美元)',
|
||||||
|
concurrencyLimit: '并发限制',
|
||||||
|
dailyCostLimit: '日费用限制(美元)',
|
||||||
|
totalCostLimit: '总费用限制(美元)',
|
||||||
|
weeklyOpusCostLimit: '周Opus费用限制(美元)',
|
||||||
|
|
||||||
|
// 账户绑定
|
||||||
|
claudeAccountId: 'Claude专属账户',
|
||||||
|
claudeConsoleAccountId: 'Claude控制台账户',
|
||||||
|
geminiAccountId: 'Gemini专属账户',
|
||||||
|
openaiAccountId: 'OpenAI专属账户',
|
||||||
|
azureOpenaiAccountId: 'Azure OpenAI专属账户',
|
||||||
|
bedrockAccountId: 'Bedrock专属账户',
|
||||||
|
|
||||||
|
// 限制配置
|
||||||
|
enableModelRestriction: '启用模型限制',
|
||||||
|
restrictedModels: '限制的模型',
|
||||||
|
enableClientRestriction: '启用客户端限制',
|
||||||
|
allowedClients: '允许的客户端',
|
||||||
|
|
||||||
|
// 标签和用户
|
||||||
|
tags: '标签',
|
||||||
|
userId: '用户ID',
|
||||||
|
userUsername: '用户名',
|
||||||
|
|
||||||
|
// 其他信息
|
||||||
|
icon: '图标'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据格式化函数
|
||||||
|
function formatCSVValue(key, value, shouldSanitize = false) {
|
||||||
|
if (!value || value === '' || value === 'null' || value === 'undefined') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'apiKey':
|
||||||
|
if (shouldSanitize && value.length > 10) {
|
||||||
|
return `${value.substring(0, 10)}...[已脱敏]`
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
case 'isActive':
|
||||||
|
case 'isActivated':
|
||||||
|
case 'enableModelRestriction':
|
||||||
|
case 'enableClientRestriction':
|
||||||
|
return value === 'true' ? '是' : '否'
|
||||||
|
|
||||||
|
case 'expirationMode':
|
||||||
|
return value === 'activation' ? '首次使用后激活' : value === 'fixed' ? '固定时间' : value
|
||||||
|
|
||||||
|
case 'activationUnit':
|
||||||
|
return value === 'hours' ? '小时' : value === 'days' ? '天' : value
|
||||||
|
|
||||||
|
case 'permissions':
|
||||||
|
switch (value) {
|
||||||
|
case 'all':
|
||||||
|
return '全部服务'
|
||||||
|
case 'claude':
|
||||||
|
return '仅Claude'
|
||||||
|
case 'gemini':
|
||||||
|
return '仅Gemini'
|
||||||
|
case 'openai':
|
||||||
|
return '仅OpenAI'
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'restrictedModels':
|
||||||
|
case 'allowedClients':
|
||||||
|
case 'tags':
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
return Array.isArray(parsed) ? parsed.join('; ') : value
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'createdAt':
|
||||||
|
case 'lastUsedAt':
|
||||||
|
case 'activatedAt':
|
||||||
|
case 'expiresAt':
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
|
||||||
|
case 'rateLimitWindow':
|
||||||
|
case 'rateLimitRequests':
|
||||||
|
case 'concurrencyLimit':
|
||||||
|
case 'activationDays':
|
||||||
|
case 'tokenLimit':
|
||||||
|
return value === '0' || value === 0 ? '无限制' : value
|
||||||
|
|
||||||
|
case 'rateLimitCost':
|
||||||
|
case 'dailyCostLimit':
|
||||||
|
case 'totalCostLimit':
|
||||||
|
case 'weeklyOpusCostLimit':
|
||||||
|
return value === '0' || value === 0 ? '无限制' : `$${value}`
|
||||||
|
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转义 CSV 字段
|
||||||
|
function escapeCSVField(field) {
|
||||||
|
if (field === null || field === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = String(field)
|
||||||
|
|
||||||
|
// 如果包含逗号、引号或换行符,需要用引号包围
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||||
|
// 先转义引号(双引号变成两个双引号)
|
||||||
|
const escaped = str.replace(/"/g, '""')
|
||||||
|
return `"${escaped}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换数据为 CSV 格式
|
||||||
|
function convertToCSV(exportDataObj, shouldSanitize = false) {
|
||||||
|
if (!exportDataObj.data.apiKeys || exportDataObj.data.apiKeys.length === 0) {
|
||||||
|
throw new Error('CSV format only supports API Keys export. Please use --types=apikeys')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKeys } = exportDataObj.data
|
||||||
|
const fields = Object.keys(CSV_FIELD_MAPPING)
|
||||||
|
const headers = Object.values(CSV_FIELD_MAPPING)
|
||||||
|
|
||||||
|
// 生成标题行
|
||||||
|
const csvLines = [headers.map(escapeCSVField).join(',')]
|
||||||
|
|
||||||
|
// 生成数据行
|
||||||
|
for (const apiKey of apiKeys) {
|
||||||
|
const row = fields.map((field) => {
|
||||||
|
const value = formatCSVValue(field, apiKey[field], shouldSanitize)
|
||||||
|
return escapeCSVField(value)
|
||||||
|
})
|
||||||
|
csvLines.push(row.join(','))
|
||||||
|
}
|
||||||
|
|
||||||
|
return csvLines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
// 导出数据
|
// 导出数据
|
||||||
async function exportData() {
|
async function exportData() {
|
||||||
try {
|
try {
|
||||||
const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`
|
const format = params.format || 'json'
|
||||||
|
const fileExtension = format === 'csv' ? '.csv' : '.json'
|
||||||
|
const defaultFileName = `backup-${new Date().toISOString().split('T')[0]}${fileExtension}`
|
||||||
|
const outputFile = params.output || defaultFileName
|
||||||
const types = params.types ? params.types.split(',') : ['all']
|
const types = params.types ? params.types.split(',') : ['all']
|
||||||
const shouldSanitize = params.sanitize === true
|
const shouldSanitize = params.sanitize === true
|
||||||
|
|
||||||
|
// CSV 格式验证
|
||||||
|
if (format === 'csv' && !types.includes('apikeys') && !types.includes('all')) {
|
||||||
|
logger.error('❌ CSV format only supports API Keys export. Please use --types=apikeys')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('🔄 Starting data export...')
|
logger.info('🔄 Starting data export...')
|
||||||
logger.info(`📁 Output file: ${outputFile}`)
|
logger.info(`📁 Output file: ${outputFile}`)
|
||||||
logger.info(`📋 Data types: ${types.join(', ')}`)
|
logger.info(`📋 Data types: ${types.join(', ')}`)
|
||||||
|
logger.info(`📄 Output format: ${format.toUpperCase()}`)
|
||||||
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`)
|
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`)
|
||||||
|
|
||||||
// 连接 Redis
|
// 连接 Redis
|
||||||
@@ -203,8 +401,16 @@ async function exportData() {
|
|||||||
logger.success(`✅ Exported ${admins.length} admins`)
|
logger.success(`✅ Exported ${admins.length} admins`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入文件
|
// 根据格式写入文件
|
||||||
await fs.writeFile(outputFile, JSON.stringify(exportData, null, 2))
|
let fileContent
|
||||||
|
if (format === 'csv') {
|
||||||
|
fileContent = convertToCSV(exportDataObj, shouldSanitize)
|
||||||
|
// 添加 UTF-8 BOM 以便 Excel 正确识别中文
|
||||||
|
fileContent = `\ufeff${fileContent}`
|
||||||
|
await fs.writeFile(outputFile, fileContent, 'utf8')
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
// 显示导出摘要
|
// 显示导出摘要
|
||||||
console.log(`\n${'='.repeat(60)}`)
|
console.log(`\n${'='.repeat(60)}`)
|
||||||
@@ -471,8 +677,9 @@ Commands:
|
|||||||
import Import data from a JSON file to Redis
|
import Import data from a JSON file to Redis
|
||||||
|
|
||||||
Export Options:
|
Export Options:
|
||||||
--output=FILE Output filename (default: backup-YYYY-MM-DD.json)
|
--output=FILE Output filename (default: backup-YYYY-MM-DD.json/.csv)
|
||||||
--types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all)
|
--types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all)
|
||||||
|
--format=FORMAT Output format: json,csv (default: json)
|
||||||
--sanitize Remove sensitive data from export
|
--sanitize Remove sensitive data from export
|
||||||
|
|
||||||
Import Options:
|
Import Options:
|
||||||
@@ -492,6 +699,12 @@ Examples:
|
|||||||
|
|
||||||
# Export specific data types
|
# Export specific data types
|
||||||
node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json
|
node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json
|
||||||
|
|
||||||
|
# Export API keys to CSV format
|
||||||
|
node scripts/data-transfer.js export --types=apikeys --format=csv --sanitize
|
||||||
|
|
||||||
|
# Export to CSV with custom filename
|
||||||
|
node scripts/data-transfer.js export --types=apikeys --format=csv --output=api-keys.csv
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -547,6 +547,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags,
|
tags,
|
||||||
activationDays, // 新增:激活后有效天数
|
activationDays, // 新增:激活后有效天数
|
||||||
|
activationUnit, // 新增:激活时间单位 (hours/days)
|
||||||
expirationMode, // 新增:过期模式
|
expirationMode, // 新增:过期模式
|
||||||
icon // 新增:图标
|
icon // 新增:图标
|
||||||
} = req.body
|
} = req.body
|
||||||
@@ -643,14 +644,23 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (expirationMode === 'activation') {
|
if (expirationMode === 'activation') {
|
||||||
|
// 验证激活时间单位
|
||||||
|
if (!activationUnit || !['hours', 'days'].includes(activationUnit)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Activation unit must be either "hours" or "days" when using activation mode'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证激活时间数值
|
||||||
if (
|
if (
|
||||||
!activationDays ||
|
!activationDays ||
|
||||||
!Number.isInteger(Number(activationDays)) ||
|
!Number.isInteger(Number(activationDays)) ||
|
||||||
Number(activationDays) < 1
|
Number(activationDays) < 1
|
||||||
) {
|
) {
|
||||||
return res
|
const unitText = activationUnit === 'hours' ? 'hours' : 'days'
|
||||||
.status(400)
|
return res.status(400).json({
|
||||||
.json({ error: 'Activation days must be a positive integer when using activation mode' })
|
error: `Activation ${unitText} must be a positive integer when using activation mode`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// 激活模式下不应该设置固定过期时间
|
// 激活模式下不应该设置固定过期时间
|
||||||
if (expiresAt) {
|
if (expiresAt) {
|
||||||
@@ -684,6 +694,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
|||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags,
|
tags,
|
||||||
activationDays,
|
activationDays,
|
||||||
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon
|
||||||
})
|
})
|
||||||
@@ -724,6 +735,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags,
|
tags,
|
||||||
activationDays,
|
activationDays,
|
||||||
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon
|
||||||
} = req.body
|
} = req.body
|
||||||
@@ -774,6 +786,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
weeklyOpusCostLimit,
|
weeklyOpusCostLimit,
|
||||||
tags,
|
tags,
|
||||||
activationDays,
|
activationDays,
|
||||||
|
activationUnit,
|
||||||
expirationMode,
|
expirationMode,
|
||||||
icon
|
icon
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ApiKeyService {
|
|||||||
weeklyOpusCostLimit = 0,
|
weeklyOpusCostLimit = 0,
|
||||||
tags = [],
|
tags = [],
|
||||||
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能)
|
||||||
|
activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days'
|
||||||
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
|
||||||
icon = '' // 新增:图标(base64编码)
|
icon = '' // 新增:图标(base64编码)
|
||||||
} = options
|
} = options
|
||||||
@@ -73,6 +74,7 @@ class ApiKeyService {
|
|||||||
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
|
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),
|
||||||
tags: JSON.stringify(tags || []),
|
tags: JSON.stringify(tags || []),
|
||||||
activationDays: String(activationDays || 0), // 新增:激活后有效天数
|
activationDays: String(activationDays || 0), // 新增:激活后有效天数
|
||||||
|
activationUnit: activationUnit || 'days', // 新增:激活时间单位
|
||||||
expirationMode: expirationMode || 'fixed', // 新增:过期模式
|
expirationMode: expirationMode || 'fixed', // 新增:过期模式
|
||||||
isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态
|
isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态
|
||||||
activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间
|
activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间
|
||||||
@@ -117,6 +119,7 @@ class ApiKeyService {
|
|||||||
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0),
|
||||||
tags: JSON.parse(keyData.tags || '[]'),
|
tags: JSON.parse(keyData.tags || '[]'),
|
||||||
activationDays: parseInt(keyData.activationDays || 0),
|
activationDays: parseInt(keyData.activationDays || 0),
|
||||||
|
activationUnit: keyData.activationUnit || 'days',
|
||||||
expirationMode: keyData.expirationMode || 'fixed',
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
isActivated: keyData.isActivated === 'true',
|
isActivated: keyData.isActivated === 'true',
|
||||||
activatedAt: keyData.activatedAt,
|
activatedAt: keyData.activatedAt,
|
||||||
@@ -152,8 +155,18 @@ class ApiKeyService {
|
|||||||
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
|
if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') {
|
||||||
// 首次使用,需要激活
|
// 首次使用,需要激活
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const activationDays = parseInt(keyData.activationDays || 30) // 默认30天
|
const activationPeriod = parseInt(keyData.activationDays || 30) // 默认30
|
||||||
const expiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000)
|
const activationUnit = keyData.activationUnit || 'days' // 默认天
|
||||||
|
|
||||||
|
// 根据单位计算过期时间
|
||||||
|
let milliseconds
|
||||||
|
if (activationUnit === 'hours') {
|
||||||
|
milliseconds = activationPeriod * 60 * 60 * 1000 // 小时转毫秒
|
||||||
|
} else {
|
||||||
|
milliseconds = activationPeriod * 24 * 60 * 60 * 1000 // 天转毫秒
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date(now.getTime() + milliseconds)
|
||||||
|
|
||||||
// 更新激活状态和过期时间
|
// 更新激活状态和过期时间
|
||||||
keyData.isActivated = 'true'
|
keyData.isActivated = 'true'
|
||||||
@@ -167,7 +180,7 @@ class ApiKeyService {
|
|||||||
logger.success(
|
logger.success(
|
||||||
`🔓 API key activated: ${keyData.id} (${
|
`🔓 API key activated: ${keyData.id} (${
|
||||||
keyData.name
|
keyData.name
|
||||||
}), will expire in ${activationDays} days at ${expiresAt.toISOString()}`
|
}), will expire in ${activationPeriod} ${activationUnit} at ${expiresAt.toISOString()}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +374,7 @@ class ApiKeyService {
|
|||||||
expirationMode: keyData.expirationMode || 'fixed',
|
expirationMode: keyData.expirationMode || 'fixed',
|
||||||
isActivated: keyData.isActivated === 'true',
|
isActivated: keyData.isActivated === 'true',
|
||||||
activationDays: parseInt(keyData.activationDays || 0),
|
activationDays: parseInt(keyData.activationDays || 0),
|
||||||
|
activationUnit: keyData.activationUnit || 'days',
|
||||||
activatedAt: keyData.activatedAt || null,
|
activatedAt: keyData.activatedAt || null,
|
||||||
claudeAccountId: keyData.claudeAccountId,
|
claudeAccountId: keyData.claudeAccountId,
|
||||||
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
|
||||||
@@ -432,6 +446,7 @@ class ApiKeyService {
|
|||||||
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.activationDays = parseInt(key.activationDays || 0)
|
key.activationDays = parseInt(key.activationDays || 0)
|
||||||
|
key.activationUnit = key.activationUnit || 'days'
|
||||||
key.expirationMode = key.expirationMode || 'fixed'
|
key.expirationMode = key.expirationMode || 'fixed'
|
||||||
key.isActivated = key.isActivated === 'true'
|
key.isActivated = key.isActivated === 'true'
|
||||||
key.activatedAt = key.activatedAt || null
|
key.activatedAt = key.activatedAt || null
|
||||||
@@ -541,6 +556,7 @@ class ApiKeyService {
|
|||||||
'permissions',
|
'permissions',
|
||||||
'expiresAt',
|
'expiresAt',
|
||||||
'activationDays', // 新增:激活后有效天数
|
'activationDays', // 新增:激活后有效天数
|
||||||
|
'activationUnit', // 新增:激活时间单位
|
||||||
'expirationMode', // 新增:过期模式
|
'expirationMode', // 新增:过期模式
|
||||||
'isActivated', // 新增:是否已激活
|
'isActivated', // 新增:是否已激活
|
||||||
'activatedAt', // 新增:激活时间
|
'activatedAt', // 新增:激活时间
|
||||||
|
|||||||
@@ -492,11 +492,11 @@
|
|||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span v-if="form.expirationMode === 'fixed'">
|
<span v-if="form.expirationMode === 'fixed'">
|
||||||
<i class="fas fa-info-circle mr-1" />
|
<i class="fas fa-info-circle mr-1" />
|
||||||
固定时间模式:Key 创建后立即生效,按设定时间过期
|
固定时间模式:Key 创建后立即生效,按设定时间过期(支持小时和天数)
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
<i class="fas fa-info-circle mr-1" />
|
<i class="fas fa-info-circle mr-1" />
|
||||||
激活模式:Key 首次使用时激活,激活后按设定天数过期(适合批量销售)
|
激活模式:Key 首次使用时激活,激活后按设定时间过期(支持小时和天数,适合批量销售)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -509,6 +509,10 @@
|
|||||||
@change="updateExpireAt"
|
@change="updateExpireAt"
|
||||||
>
|
>
|
||||||
<option value="">永不过期</option>
|
<option value="">永不过期</option>
|
||||||
|
<option value="1h">1 小时</option>
|
||||||
|
<option value="3h">3 小时</option>
|
||||||
|
<option value="6h">6 小时</option>
|
||||||
|
<option value="12h">12 小时</option>
|
||||||
<option value="1d">1 天</option>
|
<option value="1d">1 天</option>
|
||||||
<option value="7d">7 天</option>
|
<option value="7d">7 天</option>
|
||||||
<option value="30d">30 天</option>
|
<option value="30d">30 天</option>
|
||||||
@@ -537,27 +541,36 @@
|
|||||||
<input
|
<input
|
||||||
v-model.number="form.activationDays"
|
v-model.number="form.activationDays"
|
||||||
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
class="form-input flex-1 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
max="3650"
|
:max="form.activationUnit === 'hours' ? 8760 : 3650"
|
||||||
min="1"
|
min="1"
|
||||||
placeholder="输入天数"
|
:placeholder="form.activationUnit === 'hours' ? '输入小时数' : '输入天数'"
|
||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">天</span>
|
<select
|
||||||
|
v-model="form.activationUnit"
|
||||||
|
class="form-input w-20 border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
@change="updateActivationValue"
|
||||||
|
>
|
||||||
|
<option value="hours">小时</option>
|
||||||
|
<option value="days">天</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="days in [30, 90, 180, 365]"
|
v-for="value in getQuickTimeOptions()"
|
||||||
:key="days"
|
:key="value.value"
|
||||||
class="rounded-md border border-gray-300 px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-700"
|
class="rounded-md border border-gray-300 px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||||
type="button"
|
type="button"
|
||||||
@click="form.activationDays = days"
|
@click="form.activationDays = value.value"
|
||||||
>
|
>
|
||||||
{{ days }}天
|
{{ value.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<i class="fas fa-clock mr-1" />
|
<i class="fas fa-clock mr-1" />
|
||||||
Key 将在首次使用后激活,激活后 {{ form.activationDays || 30 }} 天过期
|
Key 将在首次使用后激活,激活后
|
||||||
|
{{ form.activationDays || (form.activationUnit === 'hours' ? 24 : 30) }}
|
||||||
|
{{ form.activationUnit === 'hours' ? '小时' : '天' }}过期
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -917,6 +930,7 @@ const form = reactive({
|
|||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
expirationMode: 'fixed', // 过期模式:fixed(固定) 或 activation(激活)
|
expirationMode: 'fixed', // 过期模式:fixed(固定) 或 activation(激活)
|
||||||
activationDays: 30, // 激活后有效天数
|
activationDays: 30, // 激活后有效天数
|
||||||
|
activationUnit: 'days', // 激活时间单位:hours 或 days
|
||||||
permissions: 'all',
|
permissions: 'all',
|
||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
geminiAccountId: '',
|
geminiAccountId: '',
|
||||||
@@ -1185,6 +1199,40 @@ const removeTag = (index) => {
|
|||||||
form.tags.splice(index, 1)
|
form.tags.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取快捷时间选项
|
||||||
|
const getQuickTimeOptions = () => {
|
||||||
|
if (form.activationUnit === 'hours') {
|
||||||
|
return [
|
||||||
|
{ value: 1, label: '1小时' },
|
||||||
|
{ value: 3, label: '3小时' },
|
||||||
|
{ value: 6, label: '6小时' },
|
||||||
|
{ value: 12, label: '12小时' }
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
{ value: 30, label: '30天' },
|
||||||
|
{ value: 90, label: '90天' },
|
||||||
|
{ value: 180, label: '180天' },
|
||||||
|
{ value: 365, label: '365天' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单位变化时更新数值
|
||||||
|
const updateActivationValue = () => {
|
||||||
|
if (form.activationUnit === 'hours') {
|
||||||
|
// 从天切换到小时,设置一个合理的默认值
|
||||||
|
if (form.activationDays > 24) {
|
||||||
|
form.activationDays = 24
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 从小时切换到天,设置一个合理的默认值
|
||||||
|
if (form.activationDays < 1) {
|
||||||
|
form.activationDays = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 创建 API Key
|
// 创建 API Key
|
||||||
const createApiKey = async () => {
|
const createApiKey = async () => {
|
||||||
// 验证表单
|
// 验证表单
|
||||||
@@ -1260,6 +1308,7 @@ const createApiKey = async () => {
|
|||||||
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,
|
||||||
|
activationUnit: form.expirationMode === 'activation' ? form.activationUnit : undefined,
|
||||||
permissions: form.permissions,
|
permissions: form.permissions,
|
||||||
tags: form.tags.length > 0 ? form.tags : undefined,
|
tags: form.tags.length > 0 ? form.tags : undefined,
|
||||||
enableModelRestriction: form.enableModelRestriction,
|
enableModelRestriction: form.enableModelRestriction,
|
||||||
|
|||||||
@@ -46,7 +46,9 @@
|
|||||||
<i class="fas fa-pause-circle mr-1 text-blue-500" />
|
<i class="fas fa-pause-circle mr-1 text-blue-500" />
|
||||||
未激活
|
未激活
|
||||||
<span class="ml-2 text-xs font-normal text-gray-600">
|
<span class="ml-2 text-xs font-normal text-gray-600">
|
||||||
(激活后 {{ apiKey.activationDays || 30 }} 天过期)
|
(激活后
|
||||||
|
{{ apiKey.activationDays || (apiKey.activationUnit === 'hours' ? 24 : 30) }}
|
||||||
|
{{ apiKey.activationUnit === 'hours' ? '小时' : '天' }}过期)
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<!-- 已设置过期时间 -->
|
<!-- 已设置过期时间 -->
|
||||||
@@ -89,11 +91,15 @@
|
|||||||
@click="handleActivateNow"
|
@click="handleActivateNow"
|
||||||
>
|
>
|
||||||
<i class="fas fa-rocket mr-2" />
|
<i class="fas fa-rocket mr-2" />
|
||||||
立即激活 (激活后 {{ apiKey.activationDays || 30 }} 天过期)
|
立即激活 (激活后
|
||||||
|
{{ apiKey.activationDays || (apiKey.activationUnit === 'hours' ? 24 : 30) }}
|
||||||
|
{{ apiKey.activationUnit === 'hours' ? '小时' : '天' }}过期)
|
||||||
</button>
|
</button>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<i class="fas fa-info-circle mr-1" />
|
<i class="fas fa-info-circle mr-1" />
|
||||||
点击立即激活此 API Key,激活后将在 {{ apiKey.activationDays || 30 }} 天后过期
|
点击立即激活此 API Key,激活后将在
|
||||||
|
{{ apiKey.activationDays || (apiKey.activationUnit === 'hours' ? 24 : 30) }}
|
||||||
|
{{ apiKey.activationUnit === 'hours' ? '小时' : '天' }}后过期
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -400,14 +406,14 @@ const handleActivateNow = async () => {
|
|||||||
if (window.showConfirm) {
|
if (window.showConfirm) {
|
||||||
confirmed = await window.showConfirm(
|
confirmed = await window.showConfirm(
|
||||||
'激活 API Key',
|
'激活 API Key',
|
||||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`,
|
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`,
|
||||||
'确定激活',
|
'确定激活',
|
||||||
'取消'
|
'取消'
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 降级方案
|
// 降级方案
|
||||||
confirmed = confirm(
|
confirmed = confirm(
|
||||||
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || 30} 天后自动过期。`
|
`确定要立即激活此 API Key 吗?激活后将在 ${props.apiKey.activationDays || (props.apiKey.activationUnit === 'hours' ? 24 : 30)} ${props.apiKey.activationUnit === 'hours' ? '小时' : '天'}后自动过期。`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,9 @@
|
|||||||
<i class="fas fa-pause-circle mr-1 text-xs md:text-sm" />
|
<i class="fas fa-pause-circle mr-1 text-xs md:text-sm" />
|
||||||
未激活
|
未激活
|
||||||
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400"
|
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400"
|
||||||
>(首次使用后{{ statsData.activationDays || 30 }}天过期)</span
|
>(首次使用后
|
||||||
|
{{ statsData.activationDays || (statsData.activationUnit === 'hours' ? 24 : 30)
|
||||||
|
}}{{ statsData.activationUnit === 'hours' ? '小时' : '天' }}过期)</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<!-- 已设置过期时间 -->
|
<!-- 已设置过期时间 -->
|
||||||
|
|||||||
@@ -661,7 +661,9 @@
|
|||||||
style="font-size: 13px"
|
style="font-size: 13px"
|
||||||
>
|
>
|
||||||
<i class="fas fa-pause-circle mr-1 text-xs" />
|
<i class="fas fa-pause-circle mr-1 text-xs" />
|
||||||
未激活 ({{ key.activationDays || 30 }}天)
|
未激活 (
|
||||||
|
{{ key.activationDays || (key.activationUnit === 'hours' ? 24 : 30)
|
||||||
|
}}{{ key.activationUnit === 'hours' ? '小时' : '天' }})
|
||||||
</span>
|
</span>
|
||||||
<!-- 已设置过期时间 -->
|
<!-- 已设置过期时间 -->
|
||||||
<span v-else-if="key.expiresAt">
|
<span v-else-if="key.expiresAt">
|
||||||
@@ -3471,7 +3473,86 @@ const exportToExcel = () => {
|
|||||||
|
|
||||||
// 基础数据
|
// 基础数据
|
||||||
const baseData = {
|
const baseData = {
|
||||||
|
ID: key.id || '',
|
||||||
名称: key.name || '',
|
名称: key.name || '',
|
||||||
|
描述: key.description || '',
|
||||||
|
状态: key.isActive ? '启用' : '禁用',
|
||||||
|
API密钥: key.apiKey || '',
|
||||||
|
|
||||||
|
// 过期配置
|
||||||
|
过期模式:
|
||||||
|
key.expirationMode === 'activation'
|
||||||
|
? '首次使用后激活'
|
||||||
|
: key.expirationMode === 'fixed'
|
||||||
|
? '固定时间'
|
||||||
|
: '无',
|
||||||
|
激活期限: key.activationDays || '',
|
||||||
|
激活单位:
|
||||||
|
key.activationUnit === 'hours' ? '小时' : key.activationUnit === 'days' ? '天' : '',
|
||||||
|
已激活: key.isActivated ? '是' : '否',
|
||||||
|
激活时间: key.activatedAt ? formatDate(key.activatedAt) : '',
|
||||||
|
过期时间: key.expiresAt ? formatDate(key.expiresAt) : '',
|
||||||
|
|
||||||
|
// 权限配置
|
||||||
|
服务权限:
|
||||||
|
key.permissions === 'all'
|
||||||
|
? '全部服务'
|
||||||
|
: key.permissions === 'claude'
|
||||||
|
? '仅Claude'
|
||||||
|
: key.permissions === 'gemini'
|
||||||
|
? '仅Gemini'
|
||||||
|
: key.permissions === 'openai'
|
||||||
|
? '仅OpenAI'
|
||||||
|
: key.permissions || '',
|
||||||
|
|
||||||
|
// 限制配置
|
||||||
|
令牌限制: key.tokenLimit === '0' || key.tokenLimit === 0 ? '无限制' : key.tokenLimit || '',
|
||||||
|
并发限制:
|
||||||
|
key.concurrencyLimit === '0' || key.concurrencyLimit === 0
|
||||||
|
? '无限制'
|
||||||
|
: key.concurrencyLimit || '',
|
||||||
|
'速率窗口(分钟)':
|
||||||
|
key.rateLimitWindow === '0' || key.rateLimitWindow === 0
|
||||||
|
? '无限制'
|
||||||
|
: key.rateLimitWindow || '',
|
||||||
|
速率请求限制:
|
||||||
|
key.rateLimitRequests === '0' || key.rateLimitRequests === 0
|
||||||
|
? '无限制'
|
||||||
|
: key.rateLimitRequests || '',
|
||||||
|
'日费用限制($)':
|
||||||
|
key.dailyCostLimit === '0' || key.dailyCostLimit === 0
|
||||||
|
? '无限制'
|
||||||
|
: `$${key.dailyCostLimit}` || '',
|
||||||
|
'总费用限制($)':
|
||||||
|
key.totalCostLimit === '0' || key.totalCostLimit === 0
|
||||||
|
? '无限制'
|
||||||
|
: `$${key.totalCostLimit}` || '',
|
||||||
|
|
||||||
|
// 账户绑定
|
||||||
|
Claude专属账户: key.claudeAccountId || '',
|
||||||
|
Claude控制台账户: key.claudeConsoleAccountId || '',
|
||||||
|
Gemini专属账户: key.geminiAccountId || '',
|
||||||
|
OpenAI专属账户: key.openaiAccountId || '',
|
||||||
|
'Azure OpenAI专属账户': key.azureOpenaiAccountId || '',
|
||||||
|
Bedrock专属账户: key.bedrockAccountId || '',
|
||||||
|
|
||||||
|
// 模型和客户端限制
|
||||||
|
启用模型限制: key.enableModelRestriction ? '是' : '否',
|
||||||
|
限制的模型:
|
||||||
|
key.restrictedModels && key.restrictedModels.length > 0
|
||||||
|
? key.restrictedModels.join('; ')
|
||||||
|
: '',
|
||||||
|
启用客户端限制: key.enableClientRestriction ? '是' : '否',
|
||||||
|
允许的客户端:
|
||||||
|
key.allowedClients && key.allowedClients.length > 0 ? key.allowedClients.join('; ') : '',
|
||||||
|
|
||||||
|
// 创建信息
|
||||||
|
创建时间: key.createdAt ? formatDate(key.createdAt) : '',
|
||||||
|
创建者: key.createdBy || '',
|
||||||
|
用户ID: key.userId || '',
|
||||||
|
用户名: key.userUsername || '',
|
||||||
|
|
||||||
|
// 使用统计
|
||||||
标签: key.tags && key.tags.length > 0 ? key.tags.join(', ') : '无',
|
标签: key.tags && key.tags.length > 0 ? key.tags.join(', ') : '无',
|
||||||
请求总数: periodRequests,
|
请求总数: periodRequests,
|
||||||
'总费用($)': periodCost.toFixed(2),
|
'总费用($)': periodCost.toFixed(2),
|
||||||
@@ -3528,12 +3609,33 @@ const exportToExcel = () => {
|
|||||||
// 设置列宽
|
// 设置列宽
|
||||||
const headers = Object.keys(exportData[0] || {})
|
const headers = Object.keys(exportData[0] || {})
|
||||||
const columnWidths = headers.map((header) => {
|
const columnWidths = headers.map((header) => {
|
||||||
|
// 基本信息字段
|
||||||
|
if (header === 'ID') return { wch: 40 }
|
||||||
if (header === '名称') return { wch: 25 }
|
if (header === '名称') return { wch: 25 }
|
||||||
|
if (header === '描述') return { wch: 30 }
|
||||||
|
if (header === 'API密钥') return { wch: 45 }
|
||||||
if (header === '标签') return { wch: 20 }
|
if (header === '标签') return { wch: 20 }
|
||||||
if (header === '最后使用时间') return { wch: 20 }
|
|
||||||
|
// 时间字段
|
||||||
|
if (header.includes('时间')) return { wch: 20 }
|
||||||
|
|
||||||
|
// 限制字段
|
||||||
|
if (header.includes('限制')) return { wch: 15 }
|
||||||
if (header.includes('费用')) return { wch: 15 }
|
if (header.includes('费用')) return { wch: 15 }
|
||||||
if (header.includes('Token')) return { wch: 15 }
|
if (header.includes('Token')) return { wch: 15 }
|
||||||
if (header.includes('请求')) return { wch: 12 }
|
if (header.includes('请求')) return { wch: 12 }
|
||||||
|
|
||||||
|
// 账户绑定字段
|
||||||
|
if (header.includes('账户')) return { wch: 30 }
|
||||||
|
|
||||||
|
// 权限配置字段
|
||||||
|
if (header.includes('权限') || header.includes('模型') || header.includes('客户端'))
|
||||||
|
return { wch: 20 }
|
||||||
|
|
||||||
|
// 激活配置字段
|
||||||
|
if (header.includes('激活') || header.includes('过期')) return { wch: 18 }
|
||||||
|
|
||||||
|
// 默认宽度
|
||||||
return { wch: 15 }
|
return { wch: 15 }
|
||||||
})
|
})
|
||||||
ws['!cols'] = columnWidths
|
ws['!cols'] = columnWidths
|
||||||
|
|||||||
Reference in New Issue
Block a user