Merge pull request #375 from Edric-Li/main

增强API KEYS 页面、增强粘性回话的续期、增强错误处理
This commit is contained in:
Wesley Liddick
2025-09-08 16:19:42 +08:00
committed by GitHub
17 changed files with 2191 additions and 1034 deletions

View File

@@ -22,6 +22,12 @@ REDIS_PASSWORD=
REDIS_DB=0
REDIS_ENABLE_TLS=
# 🔗 会话管理配置
# 粘性会话TTL配置小时默认1小时
STICKY_SESSION_TTL_HOURS=1
# 续期阈值分钟默认0分钟不续期
STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=0
# 🎯 Claude API 配置
CLAUDE_API_URL=https://api.anthropic.com/v1/messages
CLAUDE_API_VERSION=2023-06-01

View File

@@ -1 +1 @@
1.1.131
1.1.133

View File

@@ -32,6 +32,14 @@ const config = {
enableTLS: process.env.REDIS_ENABLE_TLS === 'true'
},
// 🔗 会话管理配置
session: {
// 粘性会话TTL配置小时默认1小时
stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1,
// 续期阈值分钟默认0分钟不续期
renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0
},
// 🎯 Claude API配置
claude: {
apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages',

View File

@@ -1356,9 +1356,12 @@ class RedisClient {
}
// 🔗 会话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}`
await this.client.set(key, accountId, 'EX', ttl)
await this.client.set(key, accountId, 'EX', defaultTTL)
}
async getSessionAccountMapping(sessionHash) {
@@ -1366,6 +1369,57 @@ class RedisClient {
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) {
const key = `sticky_session:${sessionHash}`
return await this.client.del(key)

View File

@@ -122,7 +122,7 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
// 获取所有API Keys
router.get('/api-keys', authenticateAdmin, async (req, res) => {
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()
// 获取用户服务来补充owner信息
@@ -132,7 +132,32 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
const now = new Date()
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 tzDate = redisClient.getDateInTimezone(now)
@@ -233,7 +258,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost)
}
} else {
// 7天本月:重新计算统计数据
// 7天本月或自定义日期范围:重新计算统计数据
const tempUsage = {
requests: 0,
tokens: 0,
@@ -274,12 +299,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
const tzDate = redisClient.getDateInTimezone(now)
const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
const modelKeys =
timeRange === 'today'
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
: timeRange === '7days'
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`)
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'
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
: timeRange === '7days'
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`)
}
const modelStatsMap = new Map()
@@ -295,8 +336,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
continue
}
}
} else if (timeRange === 'today') {
// today选项已经在查询时过滤了不需要额外处理
} else if (timeRange === 'today' || timeRange === 'custom') {
// today和custom选项已经在查询时过滤了,不需要额外处理
}
const modelMatch = key.match(
@@ -619,7 +660,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
weeklyOpusCostLimit,
tags,
activationDays,
expirationMode
expirationMode,
icon
})
logger.success(`🔑 Admin created new API key: ${name}`)
@@ -655,7 +697,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
weeklyOpusCostLimit,
tags,
activationDays,
expirationMode
expirationMode,
icon
} = req.body
// 输入验证
@@ -701,7 +744,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
weeklyOpusCostLimit,
tags,
activationDays,
expirationMode
expirationMode,
icon
})
// 保留原始 API Key 供返回
@@ -3878,10 +3922,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' })
}
// 限制最大范围为31
// 限制最大范围为365
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 31) {
return res.status(400).json({ error: 'Date range cannot exceed 31 days' })
if (daysDiff > 365) {
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
}
// 生成日期范围内所有日期的搜索模式
@@ -4342,10 +4386,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' })
}
// 限制最大范围为31
// 限制最大范围为365
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 31) {
return res.status(400).json({ error: 'Date range cannot exceed 31 days' })
if (daysDiff > 365) {
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
}
// 生成日期范围内所有日期的搜索模式

View File

@@ -36,7 +36,8 @@ class ApiKeyService {
weeklyOpusCostLimit = 0,
tags = [],
activationDays = 0, // 新增激活后有效天数0表示不使用此功能
expirationMode = 'fixed' // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活)
icon = '' // 新增图标base64编码
} = options
// 生成简单的API Key (64字符十六进制)
@@ -78,7 +79,8 @@ class ApiKeyService {
expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间
createdBy: options.createdBy || 'admin',
userId: options.userId || '',
userUsername: options.userUsername || ''
userUsername: options.userUsername || '',
icon: icon || '' // 新增图标base64编码
}
// 保存API Key数据并建立哈希映射

View File

@@ -3,8 +3,8 @@ const crypto = require('crypto')
const ProxyHelper = require('../utils/proxyHelper')
const axios = require('axios')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
const logger = require('../utils/logger')
const { maskToken } = require('../utils/tokenMask')
const {
logRefreshStart,
@@ -707,6 +707,8 @@ class ClaudeAccountService {
// 验证映射的账户是否仍然可用
const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId)
if (mappedAccount) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
)
@@ -733,7 +735,9 @@ class ClaudeAccountService {
// 如果有会话哈希,建立新的映射
if (sessionHash) {
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期
// 从配置获取TTL小时转换为秒
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
logger.info(
`🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
)
@@ -827,6 +831,8 @@ class ClaudeAccountService {
)
await redis.deleteSessionAccountMapping(sessionHash)
} else {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`
)
@@ -885,7 +891,9 @@ class ClaudeAccountService {
// 如果有会话哈希,建立新的映射
if (sessionHash) {
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期
// 从配置获取TTL小时转换为秒
const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds)
logger.info(
`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`
)

View File

@@ -211,19 +211,7 @@ class ClaudeRelayService {
// 检查是否为5xx状态码
else if (response.statusCode >= 500 && response.statusCode < 600) {
logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`)
// 记录5xx错误
await claudeAccountService.recordServerError(accountId, response.statusCode)
// 检查是否需要标记为临时错误状态连续3次500
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
logger.info(
`🔥 Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes`
)
if (errorCount > 10) {
logger.error(
`❌ Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error`
)
await claudeAccountService.markAccountTempError(accountId, sessionHash)
}
await this._handleServerError(accountId, response.statusCode, sessionHash)
}
// 检查是否为429状态码
else if (response.statusCode === 429) {
@@ -764,7 +752,7 @@ class ClaudeRelayService {
onRequest(req)
}
req.on('error', (error) => {
req.on('error', async (error) => {
console.error(': ❌ ', error)
logger.error('❌ Claude API request error:', error.message, {
code: error.code,
@@ -784,14 +772,19 @@ class ClaudeRelayService {
errorMessage = 'Connection refused by Claude API server'
} else if (error.code === 'ETIMEDOUT') {
errorMessage = 'Connection timed out to Claude API server'
await this._handleServerError(accountId, 504, null, 'Network')
}
reject(new Error(errorMessage))
})
req.on('timeout', () => {
req.on('timeout', async () => {
req.destroy()
logger.error('❌ Claude API request timeout')
await this._handleServerError(accountId, 504, null, 'Request')
reject(new Error('Request timeout'))
})
@@ -1013,19 +1006,7 @@ class ClaudeRelayService {
logger.warn(
`🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}`
)
// 记录5xx错误
await claudeAccountService.recordServerError(accountId, res.statusCode)
// 检查是否需要标记为临时错误状态连续3次500
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
logger.info(
`🔥 [Stream] Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes`
)
if (errorCount > 10) {
logger.error(
`❌ [Stream] Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error`
)
await claudeAccountService.markAccountTempError(accountId, sessionHash)
}
await this._handleServerError(accountId, res.statusCode, sessionHash, '[Stream]')
}
}
@@ -1361,7 +1342,7 @@ class ClaudeRelayService {
})
})
req.on('error', (error) => {
req.on('error', async (error) => {
logger.error('❌ Claude stream request error:', error.message, {
code: error.code,
errno: error.errno,
@@ -1408,9 +1389,10 @@ class ClaudeRelayService {
reject(error)
})
req.on('timeout', () => {
req.on('timeout', async () => {
req.destroy()
logger.error('❌ Claude stream request timeout')
if (!responseStream.headersSent) {
responseStream.writeHead(504, {
'Content-Type': 'text/event-stream',
@@ -1510,7 +1492,7 @@ class ClaudeRelayService {
})
})
req.on('error', (error) => {
req.on('error', async (error) => {
logger.error('❌ Claude stream request error:', error.message, {
code: error.code,
errno: error.errno,
@@ -1557,9 +1539,10 @@ class ClaudeRelayService {
reject(error)
})
req.on('timeout', () => {
req.on('timeout', async () => {
req.destroy()
logger.error('❌ Claude stream request timeout')
if (!responseStream.headersSent) {
responseStream.writeHead(504, {
'Content-Type': 'text/event-stream',
@@ -1596,6 +1579,33 @@ class ClaudeRelayService {
})
}
// 🛠️ 统一的错误处理方法
async _handleServerError(accountId, statusCode, sessionHash = null, context = '') {
try {
await claudeAccountService.recordServerError(accountId, statusCode)
const errorCount = await claudeAccountService.getServerErrorCount(accountId)
// 根据错误类型设置不同的阈值和日志前缀
const isTimeout = statusCode === 504
const threshold = 3 // 统一使用3次阈值
const prefix = context ? `${context} ` : ''
logger.warn(
`⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}`
)
if (errorCount > threshold) {
const errorTypeLabel = isTimeout ? 'timeout' : '5xx'
logger.error(
`${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), marking as temp_error`
)
await claudeAccountService.markAccountTempError(accountId, sessionHash)
}
} catch (handlingError) {
logger.error(`❌ Failed to handle ${context} server error:`, handlingError)
}
}
// 🔄 重试逻辑
async _retryRequest(requestFunc, maxRetries = 3) {
let lastError

View File

@@ -177,6 +177,8 @@ class UnifiedClaudeScheduler {
requestedModel
)
if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
@@ -789,6 +791,8 @@ class UnifiedClaudeScheduler {
requestedModel
)
if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)

View File

@@ -61,6 +61,8 @@ class UnifiedGeminiScheduler {
mappedAccount.accountType
)
if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
@@ -382,6 +384,8 @@ class UnifiedGeminiScheduler {
mappedAccount.accountType
)
if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)

View File

@@ -90,6 +90,8 @@ class UnifiedOpenAIScheduler {
mappedAccount.accountType
)
if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
@@ -406,6 +408,8 @@ class UnifiedOpenAIScheduler {
mappedAccount.accountType
)
if (isAvailable) {
// 🚀 智能会话续期剩余时间少于14天时自动续期到15天
await redis.extendSessionAccountMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})`
)

View File

@@ -15,7 +15,9 @@
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
@@ -1366,6 +1368,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
@@ -1643,6 +1654,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
@@ -1710,6 +1734,15 @@
"node": ">= 6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
@@ -1766,6 +1799,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2304,6 +2349,15 @@
"node": ">=0.10.0"
}
},
"node_modules/exit-on-epipe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/exsolve": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz",
@@ -2379,6 +2433,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz",
"integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -2497,6 +2557,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -3804,6 +3873,18 @@
}
}
},
"node_modules/printj": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz",
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
"license": "Apache-2.0",
"bin": {
"printj": "bin/printj.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -4083,6 +4164,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz",
@@ -5126,6 +5219,24 @@
"node": ">= 8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -5244,6 +5355,95 @@
"dev": true,
"license": "ISC"
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz",
"integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.2.0",
"cfb": "^1.1.4",
"codepage": "~1.14.0",
"commander": "~2.17.1",
"crc-32": "~1.2.0",
"exit-on-epipe": "~1.0.1",
"fflate": "^0.3.8",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/adler-32": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz",
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
"license": "Apache-2.0",
"dependencies": {
"exit-on-epipe": "~1.0.1",
"printj": "~1.1.0"
},
"bin": {
"adler32": "bin/adler32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/codepage": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz",
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
"license": "Apache-2.0",
"dependencies": {
"commander": "~2.14.1",
"exit-on-epipe": "~1.0.1"
},
"bin": {
"codepage": "bin/codepage.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
"license": "MIT"
},
"node_modules/xlsx-js-style/node_modules/commander": {
"version": "2.17.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
"license": "MIT"
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz",

View File

@@ -18,7 +18,9 @@
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.3.4",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0"
},
"devDependencies": {
"@playwright/test": "^1.55.0",

View File

@@ -110,19 +110,21 @@
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
>
<input
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="{ 'border-red-500': errors.name }"
:placeholder="
form.createType === 'batch'
? '输入基础名称(将自动添加序号)'
: '为您的 API Key 取一个名称'
"
required
type="text"
@input="errors.name = ''"
/>
<div>
<input
v-model="form.name"
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 }"
:placeholder="
form.createType === 'batch'
? '输入基础名称(将自动添加序号)'
: '为您的 API Key 取一个名称'
"
required
type="text"
@input="errors.name = ''"
/>
</div>
<p v-if="errors.name" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.name }}
</p>

View File

@@ -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"
>名称</label
>
<input
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"
maxlength="100"
placeholder="请输入API Key名称"
required
type="text"
/>
<div>
<input
v-model="form.name"
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"
placeholder="请输入API Key名称"
required
type="text"
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 sm:mt-2">
用于识别此 API Key 的用途
</p>

View File

@@ -6,7 +6,7 @@
<!-- 模态框 -->
<div
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-2xl flex-col p-4 sm:w-full sm:max-w-3xl sm:p-6 md:p-8"
class="modal-content relative mx-auto flex max-h-[90vh] w-[95%] max-w-5xl flex-col p-4 sm:w-full sm:p-6 md:p-8"
>
<!-- 标题栏 -->
<div class="mb-4 flex items-center justify-between sm:mb-6">

File diff suppressed because it is too large Load Diff