Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
c0c9c984
Unverified
Commit
c0c9c984
authored
Feb 03, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 03, 2026
Browse files
Merge pull request #471 from bayma888/feature/api-key-quota-expiration
feat(api-key): 添加API密钥独立配额和过期时间功能
parents
ba5a0d47
3fed478e
Changes
37
Show whitespace changes
Inline
Side-by-side
backend/internal/server/middleware/api_key_auth_google_test.go
View file @
c0c9c984
...
...
@@ -75,6 +75,9 @@ func (f fakeAPIKeyRepo) ListKeysByUserID(ctx context.Context, userID int64) ([]s
func
(
f
fakeAPIKeyRepo
)
ListKeysByGroupID
(
ctx
context
.
Context
,
groupID
int64
)
([]
string
,
error
)
{
return
nil
,
errors
.
New
(
"not implemented"
)
}
func
(
f
fakeAPIKeyRepo
)
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
{
return
0
,
errors
.
New
(
"not implemented"
)
}
type
googleErrorResponse
struct
{
Error
struct
{
...
...
backend/internal/server/middleware/api_key_auth_test.go
View file @
c0c9c984
...
...
@@ -319,6 +319,10 @@ func (r *stubApiKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) (
return
nil
,
errors
.
New
(
"not implemented"
)
}
func
(
r
*
stubApiKeyRepo
)
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
{
return
0
,
errors
.
New
(
"not implemented"
)
}
type
stubUserSubscriptionRepo
struct
{
getActive
func
(
ctx
context
.
Context
,
userID
,
groupID
int64
)
(
*
service
.
UserSubscription
,
error
)
updateStatus
func
(
ctx
context
.
Context
,
subscriptionID
int64
,
status
string
)
error
...
...
backend/internal/service/api_key.go
View file @
c0c9c984
...
...
@@ -2,6 +2,14 @@ package service
import
"time"
// API Key status constants
const
(
StatusAPIKeyActive
=
"active"
StatusAPIKeyDisabled
=
"disabled"
StatusAPIKeyQuotaExhausted
=
"quota_exhausted"
StatusAPIKeyExpired
=
"expired"
)
type
APIKey
struct
{
ID
int64
UserID
int64
...
...
@@ -15,8 +23,53 @@ type APIKey struct {
UpdatedAt
time
.
Time
User
*
User
Group
*
Group
// Quota fields
Quota
float64
// Quota limit in USD (0 = unlimited)
QuotaUsed
float64
// Used quota amount
ExpiresAt
*
time
.
Time
// Expiration time (nil = never expires)
}
func
(
k
*
APIKey
)
IsActive
()
bool
{
return
k
.
Status
==
StatusActive
}
// IsExpired checks if the API key has expired
func
(
k
*
APIKey
)
IsExpired
()
bool
{
if
k
.
ExpiresAt
==
nil
{
return
false
}
return
time
.
Now
()
.
After
(
*
k
.
ExpiresAt
)
}
// IsQuotaExhausted checks if the API key quota is exhausted
func
(
k
*
APIKey
)
IsQuotaExhausted
()
bool
{
if
k
.
Quota
<=
0
{
return
false
// unlimited
}
return
k
.
QuotaUsed
>=
k
.
Quota
}
// GetQuotaRemaining returns remaining quota (-1 for unlimited)
func
(
k
*
APIKey
)
GetQuotaRemaining
()
float64
{
if
k
.
Quota
<=
0
{
return
-
1
// unlimited
}
remaining
:=
k
.
Quota
-
k
.
QuotaUsed
if
remaining
<
0
{
return
0
}
return
remaining
}
// GetDaysUntilExpiry returns days until expiry (-1 for never expires)
func
(
k
*
APIKey
)
GetDaysUntilExpiry
()
int
{
if
k
.
ExpiresAt
==
nil
{
return
-
1
// never expires
}
duration
:=
time
.
Until
(
*
k
.
ExpiresAt
)
if
duration
<
0
{
return
0
}
return
int
(
duration
.
Hours
()
/
24
)
}
backend/internal/service/api_key_auth_cache.go
View file @
c0c9c984
package
service
import
"time"
// APIKeyAuthSnapshot API Key 认证缓存快照(仅包含认证所需字段)
type
APIKeyAuthSnapshot
struct
{
APIKeyID
int64
`json:"api_key_id"`
...
...
@@ -10,6 +12,13 @@ type APIKeyAuthSnapshot struct {
IPBlacklist
[]
string
`json:"ip_blacklist,omitempty"`
User
APIKeyAuthUserSnapshot
`json:"user"`
Group
*
APIKeyAuthGroupSnapshot
`json:"group,omitempty"`
// Quota fields for API Key independent quota feature
Quota
float64
`json:"quota"`
// Quota limit in USD (0 = unlimited)
QuotaUsed
float64
`json:"quota_used"`
// Used quota amount
// Expiration field for API Key expiration feature
ExpiresAt
*
time
.
Time
`json:"expires_at,omitempty"`
// Expiration time (nil = never expires)
}
// APIKeyAuthUserSnapshot 用户快照
...
...
backend/internal/service/api_key_auth_cache_impl.go
View file @
c0c9c984
...
...
@@ -213,6 +213,9 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
Status
:
apiKey
.
Status
,
IPWhitelist
:
apiKey
.
IPWhitelist
,
IPBlacklist
:
apiKey
.
IPBlacklist
,
Quota
:
apiKey
.
Quota
,
QuotaUsed
:
apiKey
.
QuotaUsed
,
ExpiresAt
:
apiKey
.
ExpiresAt
,
User
:
APIKeyAuthUserSnapshot
{
ID
:
apiKey
.
User
.
ID
,
Status
:
apiKey
.
User
.
Status
,
...
...
@@ -256,6 +259,9 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
Status
:
snapshot
.
Status
,
IPWhitelist
:
snapshot
.
IPWhitelist
,
IPBlacklist
:
snapshot
.
IPBlacklist
,
Quota
:
snapshot
.
Quota
,
QuotaUsed
:
snapshot
.
QuotaUsed
,
ExpiresAt
:
snapshot
.
ExpiresAt
,
User
:
&
User
{
ID
:
snapshot
.
User
.
ID
,
Status
:
snapshot
.
User
.
Status
,
...
...
backend/internal/service/api_key_service.go
View file @
c0c9c984
...
...
@@ -24,6 +24,10 @@ var (
ErrAPIKeyInvalidChars
=
infraerrors
.
BadRequest
(
"API_KEY_INVALID_CHARS"
,
"api key can only contain letters, numbers, underscores, and hyphens"
)
ErrAPIKeyRateLimited
=
infraerrors
.
TooManyRequests
(
"API_KEY_RATE_LIMITED"
,
"too many failed attempts, please try again later"
)
ErrInvalidIPPattern
=
infraerrors
.
BadRequest
(
"INVALID_IP_PATTERN"
,
"invalid IP or CIDR pattern"
)
// ErrAPIKeyExpired = infraerrors.Forbidden("API_KEY_EXPIRED", "api key has expired")
ErrAPIKeyExpired
=
infraerrors
.
Forbidden
(
"API_KEY_EXPIRED"
,
"api key 已过期"
)
// ErrAPIKeyQuotaExhausted = infraerrors.TooManyRequests("API_KEY_QUOTA_EXHAUSTED", "api key quota exhausted")
ErrAPIKeyQuotaExhausted
=
infraerrors
.
TooManyRequests
(
"API_KEY_QUOTA_EXHAUSTED"
,
"api key 额度已用完"
)
)
const
(
...
...
@@ -51,6 +55,9 @@ type APIKeyRepository interface {
CountByGroupID
(
ctx
context
.
Context
,
groupID
int64
)
(
int64
,
error
)
ListKeysByUserID
(
ctx
context
.
Context
,
userID
int64
)
([]
string
,
error
)
ListKeysByGroupID
(
ctx
context
.
Context
,
groupID
int64
)
([]
string
,
error
)
// Quota methods
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
}
// APIKeyCache defines cache operations for API key service
...
...
@@ -85,6 +92,10 @@ type CreateAPIKeyRequest struct {
CustomKey
*
string
`json:"custom_key"`
// 可选的自定义key
IPWhitelist
[]
string
`json:"ip_whitelist"`
// IP 白名单
IPBlacklist
[]
string
`json:"ip_blacklist"`
// IP 黑名单
// Quota fields
Quota
float64
`json:"quota"`
// Quota limit in USD (0 = unlimited)
ExpiresInDays
*
int
`json:"expires_in_days"`
// Days until expiry (nil = never expires)
}
// UpdateAPIKeyRequest 更新API Key请求
...
...
@@ -94,6 +105,12 @@ type UpdateAPIKeyRequest struct {
Status
*
string
`json:"status"`
IPWhitelist
[]
string
`json:"ip_whitelist"`
// IP 白名单(空数组清空)
IPBlacklist
[]
string
`json:"ip_blacklist"`
// IP 黑名单(空数组清空)
// Quota fields
Quota
*
float64
`json:"quota"`
// Quota limit in USD (nil = no change, 0 = unlimited)
ExpiresAt
*
time
.
Time
`json:"expires_at"`
// Expiration time (nil = no change)
ClearExpiration
bool
`json:"-"`
// Clear expiration (internal use)
ResetQuota
*
bool
`json:"reset_quota"`
// Reset quota_used to 0
}
// APIKeyService API Key服务
...
...
@@ -289,6 +306,14 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK
Status
:
StatusActive
,
IPWhitelist
:
req
.
IPWhitelist
,
IPBlacklist
:
req
.
IPBlacklist
,
Quota
:
req
.
Quota
,
QuotaUsed
:
0
,
}
// Set expiration time if specified
if
req
.
ExpiresInDays
!=
nil
&&
*
req
.
ExpiresInDays
>
0
{
expiresAt
:=
time
.
Now
()
.
AddDate
(
0
,
0
,
*
req
.
ExpiresInDays
)
apiKey
.
ExpiresAt
=
&
expiresAt
}
if
err
:=
s
.
apiKeyRepo
.
Create
(
ctx
,
apiKey
);
err
!=
nil
{
...
...
@@ -436,6 +461,35 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
}
}
// Update quota fields
if
req
.
Quota
!=
nil
{
apiKey
.
Quota
=
*
req
.
Quota
// If quota is increased and status was quota_exhausted, reactivate
if
apiKey
.
Status
==
StatusAPIKeyQuotaExhausted
&&
*
req
.
Quota
>
apiKey
.
QuotaUsed
{
apiKey
.
Status
=
StatusActive
}
}
if
req
.
ResetQuota
!=
nil
&&
*
req
.
ResetQuota
{
apiKey
.
QuotaUsed
=
0
// If resetting quota and status was quota_exhausted, reactivate
if
apiKey
.
Status
==
StatusAPIKeyQuotaExhausted
{
apiKey
.
Status
=
StatusActive
}
}
if
req
.
ClearExpiration
{
apiKey
.
ExpiresAt
=
nil
// If clearing expiry and status was expired, reactivate
if
apiKey
.
Status
==
StatusAPIKeyExpired
{
apiKey
.
Status
=
StatusActive
}
}
else
if
req
.
ExpiresAt
!=
nil
{
apiKey
.
ExpiresAt
=
req
.
ExpiresAt
// If extending expiry and status was expired, reactivate
if
apiKey
.
Status
==
StatusAPIKeyExpired
&&
time
.
Now
()
.
Before
(
*
req
.
ExpiresAt
)
{
apiKey
.
Status
=
StatusActive
}
}
// 更新 IP 限制(空数组会清空设置)
apiKey
.
IPWhitelist
=
req
.
IPWhitelist
apiKey
.
IPBlacklist
=
req
.
IPBlacklist
...
...
@@ -572,3 +626,51 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
}
return
keys
,
nil
}
// CheckAPIKeyQuotaAndExpiry checks if the API key is valid for use (not expired, quota not exhausted)
// Returns nil if valid, error if invalid
func
(
s
*
APIKeyService
)
CheckAPIKeyQuotaAndExpiry
(
apiKey
*
APIKey
)
error
{
// Check expiration
if
apiKey
.
IsExpired
()
{
return
ErrAPIKeyExpired
}
// Check quota
if
apiKey
.
IsQuotaExhausted
()
{
return
ErrAPIKeyQuotaExhausted
}
return
nil
}
// UpdateQuotaUsed updates the quota_used field after a request
// Also checks if quota is exhausted and updates status accordingly
func
(
s
*
APIKeyService
)
UpdateQuotaUsed
(
ctx
context
.
Context
,
apiKeyID
int64
,
cost
float64
)
error
{
if
cost
<=
0
{
return
nil
}
// Use repository to atomically increment quota_used
newQuotaUsed
,
err
:=
s
.
apiKeyRepo
.
IncrementQuotaUsed
(
ctx
,
apiKeyID
,
cost
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"increment quota used: %w"
,
err
)
}
// Check if quota is now exhausted and update status if needed
apiKey
,
err
:=
s
.
apiKeyRepo
.
GetByID
(
ctx
,
apiKeyID
)
if
err
!=
nil
{
return
nil
// Don't fail the request, just log
}
// If quota is set and now exhausted, update status
if
apiKey
.
Quota
>
0
&&
newQuotaUsed
>=
apiKey
.
Quota
{
apiKey
.
Status
=
StatusAPIKeyQuotaExhausted
if
err
:=
s
.
apiKeyRepo
.
Update
(
ctx
,
apiKey
);
err
!=
nil
{
return
nil
// Don't fail the request
}
// Invalidate cache so next request sees the new status
s
.
InvalidateAuthCacheByKey
(
ctx
,
apiKey
.
Key
)
}
return
nil
}
backend/internal/service/api_key_service_cache_test.go
View file @
c0c9c984
...
...
@@ -99,6 +99,10 @@ func (s *authRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]
return
s
.
listKeysByGroupID
(
ctx
,
groupID
)
}
func
(
s
*
authRepoStub
)
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
{
panic
(
"unexpected IncrementQuotaUsed call"
)
}
type
authCacheStub
struct
{
getAuthCache
func
(
ctx
context
.
Context
,
key
string
)
(
*
APIKeyAuthCacheEntry
,
error
)
setAuthKeys
[]
string
...
...
backend/internal/service/api_key_service_delete_test.go
View file @
c0c9c984
...
...
@@ -118,6 +118,10 @@ func (s *apiKeyRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) (
panic
(
"unexpected ListKeysByGroupID call"
)
}
func
(
s
*
apiKeyRepoStub
)
IncrementQuotaUsed
(
ctx
context
.
Context
,
id
int64
,
amount
float64
)
(
float64
,
error
)
{
panic
(
"unexpected IncrementQuotaUsed call"
)
}
// apiKeyCacheStub 是 APIKeyCache 接口的测试桩实现。
// 用于验证删除操作时缓存清理逻辑是否被正确调用。
//
...
...
backend/internal/service/gateway_service.go
View file @
c0c9c984
...
...
@@ -4496,6 +4496,12 @@ type RecordUsageInput struct {
Subscription
*
UserSubscription
// 可选:订阅信息
UserAgent
string
// 请求的 User-Agent
IPAddress
string
// 请求的客户端 IP 地址
APIKeyService
APIKeyQuotaUpdater
// 可选:用于更新API Key配额
}
// APIKeyQuotaUpdater defines the interface for updating API Key quota
type
APIKeyQuotaUpdater
interface
{
UpdateQuotaUsed
(
ctx
context
.
Context
,
apiKeyID
int64
,
cost
float64
)
error
}
// RecordUsage 记录使用量并扣费(或更新订阅用量)
...
...
@@ -4635,6 +4641,13 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
}
}
// 更新 API Key 配额(如果设置了配额限制)
if
shouldBill
&&
cost
.
ActualCost
>
0
&&
apiKey
.
Quota
>
0
&&
input
.
APIKeyService
!=
nil
{
if
err
:=
input
.
APIKeyService
.
UpdateQuotaUsed
(
ctx
,
apiKey
.
ID
,
cost
.
ActualCost
);
err
!=
nil
{
log
.
Printf
(
"Update API key quota failed: %v"
,
err
)
}
}
// Schedule batch update for account last_used_at
s
.
deferredService
.
ScheduleLastUsedUpdate
(
account
.
ID
)
...
...
@@ -4652,6 +4665,7 @@ type RecordUsageLongContextInput struct {
IPAddress
string
// 请求的客户端 IP 地址
LongContextThreshold
int
// 长上下文阈值(如 200000)
LongContextMultiplier
float64
// 超出阈值部分的倍率(如 2.0)
APIKeyService
*
APIKeyService
// API Key 配额服务(可选)
}
// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini)
...
...
@@ -4788,6 +4802,12 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
}
// 异步更新余额缓存
s
.
billingCacheService
.
QueueDeductBalance
(
user
.
ID
,
cost
.
ActualCost
)
// API Key 独立配额扣费
if
input
.
APIKeyService
!=
nil
&&
apiKey
.
Quota
>
0
{
if
err
:=
input
.
APIKeyService
.
UpdateQuotaUsed
(
ctx
,
apiKey
.
ID
,
cost
.
ActualCost
);
err
!=
nil
{
log
.
Printf
(
"Add API key quota used failed: %v"
,
err
)
}
}
}
}
...
...
backend/internal/service/openai_gateway_service.go
View file @
c0c9c984
...
...
@@ -1688,6 +1688,7 @@ type OpenAIRecordUsageInput struct {
Subscription
*
UserSubscription
UserAgent
string
// 请求的 User-Agent
IPAddress
string
// 请求的客户端 IP 地址
APIKeyService
APIKeyQuotaUpdater
}
// RecordUsage records usage and deducts balance
...
...
@@ -1799,6 +1800,13 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
}
}
// Update API key quota if applicable (only for balance mode with quota set)
if
shouldBill
&&
cost
.
ActualCost
>
0
&&
apiKey
.
Quota
>
0
&&
input
.
APIKeyService
!=
nil
{
if
err
:=
input
.
APIKeyService
.
UpdateQuotaUsed
(
ctx
,
apiKey
.
ID
,
cost
.
ActualCost
);
err
!=
nil
{
log
.
Printf
(
"Update API key quota failed: %v"
,
err
)
}
}
// Schedule batch update for account last_used_at
s
.
deferredService
.
ScheduleLastUsedUpdate
(
account
.
ID
)
...
...
backend/migrations/045_add_api_key_quota.sql
0 → 100644
View file @
c0c9c984
-- Migration: Add quota fields to api_keys table
-- This migration adds independent quota and expiration support for API keys
-- Add quota limit field (0 = unlimited)
ALTER
TABLE
api_keys
ADD
COLUMN
IF
NOT
EXISTS
quota
DECIMAL
(
20
,
8
)
NOT
NULL
DEFAULT
0
;
-- Add used quota amount field
ALTER
TABLE
api_keys
ADD
COLUMN
IF
NOT
EXISTS
quota_used
DECIMAL
(
20
,
8
)
NOT
NULL
DEFAULT
0
;
-- Add expiration time field (NULL = never expires)
ALTER
TABLE
api_keys
ADD
COLUMN
IF
NOT
EXISTS
expires_at
TIMESTAMPTZ
;
-- Add indexes for efficient quota queries
CREATE
INDEX
IF
NOT
EXISTS
idx_api_keys_quota_quota_used
ON
api_keys
(
quota
,
quota_used
)
WHERE
deleted_at
IS
NULL
;
CREATE
INDEX
IF
NOT
EXISTS
idx_api_keys_expires_at
ON
api_keys
(
expires_at
)
WHERE
deleted_at
IS
NULL
;
-- Comment on columns for documentation
COMMENT
ON
COLUMN
api_keys
.
quota
IS
'Quota limit in USD for this API key (0 = unlimited)'
;
COMMENT
ON
COLUMN
api_keys
.
quota_used
IS
'Used quota amount in USD'
;
COMMENT
ON
COLUMN
api_keys
.
expires_at
IS
'Expiration time for this API key (null = never expires)'
;
backend/tools.go
deleted
100644 → 0
View file @
ba5a0d47
//go:build tools
// +build tools
package
tools
import
(
_
"entgo.io/ent/cmd/ent"
_
"github.com/google/wire/cmd/wire"
)
frontend/src/api/keys.ts
View file @
c0c9c984
...
...
@@ -44,6 +44,8 @@ export async function getById(id: number): Promise<ApiKey> {
* @param customKey - Optional custom key value
* @param ipWhitelist - Optional IP whitelist
* @param ipBlacklist - Optional IP blacklist
* @param quota - Optional quota limit in USD (0 = unlimited)
* @param expiresInDays - Optional days until expiry (undefined = never expires)
* @returns Created API key
*/
export
async
function
create
(
...
...
@@ -51,7 +53,9 @@ export async function create(
groupId
?:
number
|
null
,
customKey
?:
string
,
ipWhitelist
?:
string
[],
ipBlacklist
?:
string
[]
ipBlacklist
?:
string
[],
quota
?:
number
,
expiresInDays
?:
number
):
Promise
<
ApiKey
>
{
const
payload
:
CreateApiKeyRequest
=
{
name
}
if
(
groupId
!==
undefined
)
{
...
...
@@ -66,6 +70,12 @@ export async function create(
if
(
ipBlacklist
&&
ipBlacklist
.
length
>
0
)
{
payload
.
ip_blacklist
=
ipBlacklist
}
if
(
quota
!==
undefined
&&
quota
>
0
)
{
payload
.
quota
=
quota
}
if
(
expiresInDays
!==
undefined
&&
expiresInDays
>
0
)
{
payload
.
expires_in_days
=
expiresInDays
}
const
{
data
}
=
await
apiClient
.
post
<
ApiKey
>
(
'
/keys
'
,
payload
)
return
data
...
...
frontend/src/i18n/locales/en.ts
View file @
c0c9c984
...
...
@@ -407,6 +407,7 @@ export default {
usage
:
'
Usage
'
,
today
:
'
Today
'
,
total
:
'
Total
'
,
quota
:
'
Quota
'
,
useKey
:
'
Use Key
'
,
useKeyModal
:
{
title
:
'
Use API Key
'
,
...
...
@@ -470,6 +471,33 @@ export default {
geminiCli
:
'
Gemini CLI
'
,
geminiCliDesc
:
'
Import as Gemini CLI configuration
'
,
},
// Quota and expiration
quotaLimit
:
'
Quota Limit
'
,
quotaAmount
:
'
Quota Amount (USD)
'
,
quotaAmountPlaceholder
:
'
Enter quota limit in USD
'
,
quotaAmountHint
:
'
Set the maximum amount this key can spend. 0 = unlimited.
'
,
quotaUsed
:
'
Quota Used
'
,
reset
:
'
Reset
'
,
resetQuotaUsed
:
'
Reset used quota to 0
'
,
resetQuotaTitle
:
'
Confirm Reset Quota
'
,
resetQuotaConfirmMessage
:
'
Are you sure you want to reset the used quota (${used}) for key "{name}" to 0? This action cannot be undone.
'
,
quotaResetSuccess
:
'
Quota reset successfully
'
,
failedToResetQuota
:
'
Failed to reset quota
'
,
expiration
:
'
Expiration
'
,
expiresInDays
:
'
{days} days
'
,
extendDays
:
'
+{days} days
'
,
customDate
:
'
Custom
'
,
expirationDate
:
'
Expiration Date
'
,
expirationDateHint
:
'
Select when this API key should expire.
'
,
currentExpiration
:
'
Current expiration
'
,
expiresAt
:
'
Expires
'
,
noExpiration
:
'
Never
'
,
status
:
{
active
:
'
Active
'
,
inactive
:
'
Inactive
'
,
quota_exhausted
:
'
Quota Exhausted
'
,
expired
:
'
Expired
'
,
},
},
// Usage
...
...
frontend/src/i18n/locales/zh.ts
View file @
c0c9c984
...
...
@@ -291,7 +291,8 @@ export default {
sendingResetLink
:
'
发送中...
'
,
sendResetLinkFailed
:
'
发送重置链接失败,请重试。
'
,
resetEmailSent
:
'
重置链接已发送
'
,
resetEmailSentHint
:
'
如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。
'
,
resetEmailSentHint
:
'
如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。
'
,
backToLogin
:
'
返回登录
'
,
rememberedPassword
:
'
想起密码了?
'
,
// 重置密码
...
...
@@ -404,6 +405,7 @@ export default {
usage
:
'
用量
'
,
today
:
'
今日
'
,
total
:
'
累计
'
,
quota
:
'
额度
'
,
useKey
:
'
使用密钥
'
,
useKeyModal
:
{
title
:
'
使用 API 密钥
'
,
...
...
@@ -412,36 +414,41 @@ export default {
copied
:
'
已复制
'
,
note
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
noGroupTitle
:
'
请先分配分组
'
,
noGroupDescription
:
'
此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。
'
,
noGroupDescription
:
'
此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。
'
,
openai
:
{
description
:
'
将以下配置文件添加到 Codex CLI 配置目录中。
'
,
configTomlHint
:
'
请确保以下内容位于 config.toml 文件的开头部分
'
,
note
:
'
请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。
'
,
noteWindows
:
'
按 Win+R,输入 %userprofile%
\\
.codex 打开配置目录。如目录不存在,请先手动创建。
'
,
noteWindows
:
'
按 Win+R,输入 %userprofile%
\\
.codex 打开配置目录。如目录不存在,请先手动创建。
'
},
cliTabs
:
{
claudeCode
:
'
Claude Code
'
,
geminiCli
:
'
Gemini CLI
'
,
codexCli
:
'
Codex CLI
'
,
opencode
:
'
OpenCode
'
,
opencode
:
'
OpenCode
'
},
antigravity
:
{
description
:
'
为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。
'
,
claudeCode
:
'
Claude Code
'
,
geminiCli
:
'
Gemini CLI
'
,
claudeNote
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
geminiNote
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
claudeNote
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
geminiNote
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
},
gemini
:
{
description
:
'
将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。
'
,
description
:
'
将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。
'
,
modelComment
:
'
如果你有 Gemini 3 权限可以填:gemini-3-pro-preview
'
,
note
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
,
note
:
'
这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。
'
},
opencode
:
{
title
:
'
OpenCode 配置示例
'
,
subtitle
:
'
opencode.json
'
,
hint
:
'
配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。
'
,
}
,
hint
:
'
配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。
'
}
},
customKeyLabel
:
'
自定义密钥
'
,
customKeyPlaceholder
:
'
输入自定义密钥(至少16个字符)
'
,
...
...
@@ -457,15 +464,43 @@ export default {
ipBlacklistPlaceholder
:
'
1.2.3.4
\n
5.6.0.0/16
'
,
ipBlacklistHint
:
'
每行一个 IP 或 CIDR,这些 IP 将被禁止使用此密钥
'
,
ipRestrictionEnabled
:
'
已配置 IP 限制
'
,
ccSwitchNotInstalled
:
'
CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。
'
,
ccSwitchNotInstalled
:
'
CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。
'
,
ccsClientSelect
:
{
title
:
'
选择客户端
'
,
description
:
'
请选择您要导入到 CC-Switch 的客户端类型:
'
,
claudeCode
:
'
Claude Code
'
,
claudeCodeDesc
:
'
导入为 Claude Code 配置
'
,
geminiCli
:
'
Gemini CLI
'
,
geminiCliDesc
:
'
导入为 Gemini CLI 配置
'
,
},
geminiCliDesc
:
'
导入为 Gemini CLI 配置
'
},
// 配额和有效期
quotaLimit
:
'
额度限制
'
,
quotaAmount
:
'
额度金额 (USD)
'
,
quotaAmountPlaceholder
:
'
输入 USD 额度限制
'
,
quotaAmountHint
:
'
设置此密钥可消费的最大金额。0 = 无限制。
'
,
quotaUsed
:
'
已用额度
'
,
reset
:
'
重置
'
,
resetQuotaUsed
:
'
将已用额度重置为 0
'
,
resetQuotaTitle
:
'
确认重置额度
'
,
resetQuotaConfirmMessage
:
'
确定要将密钥 "{name}" 的已用额度(${used})重置为 0 吗?此操作不可撤销。
'
,
quotaResetSuccess
:
'
额度重置成功
'
,
failedToResetQuota
:
'
重置额度失败
'
,
expiration
:
'
密钥有效期
'
,
expiresInDays
:
'
{days} 天
'
,
extendDays
:
'
+{days} 天
'
,
customDate
:
'
自定义
'
,
expirationDate
:
'
过期时间
'
,
expirationDateHint
:
'
选择此 API 密钥的过期时间。
'
,
currentExpiration
:
'
当前过期时间
'
,
expiresAt
:
'
过期时间
'
,
noExpiration
:
'
永久有效
'
,
status
:
{
active
:
'
活跃
'
,
inactive
:
'
已停用
'
,
quota_exhausted
:
'
额度耗尽
'
,
expired
:
'
已过期
'
}
},
// Usage
...
...
@@ -757,8 +792,8 @@ export default {
editUser
:
'
编辑用户
'
,
deleteUser
:
'
删除用户
'
,
deleteConfirmMessage
:
"
确定要删除用户 '{email}' 吗?此操作无法撤销。
"
,
searchPlaceholder
:
'
搜索用户...
'
,
searchUsers
:
'
搜索用户
...
'
,
searchPlaceholder
:
'
搜索用户
邮箱或用户名、备注、支持模糊查询
...
'
,
searchUsers
:
'
搜索用户
邮箱或用户名、备注、支持模糊查询
'
,
roleFilter
:
'
角色筛选
'
,
allRoles
:
'
全部角色
'
,
allStatus
:
'
全部状态
'
,
...
...
@@ -1028,9 +1063,11 @@ export default {
exclusiveHint
:
'
专属分组,可以手动指定给特定用户
'
,
exclusiveTooltip
:
{
title
:
'
什么是专属分组?
'
,
description
:
'
开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。
'
,
description
:
'
开启后,用户在创建 API Key 时将无法看到此分组。只有管理员手动将用户分配到此分组后,用户才能使用。
'
,
example
:
'
使用场景:
'
,
exampleContent
:
'
公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。
'
exampleContent
:
'
公开分组费率 0.8,您可以创建一个费率 0.7 的专属分组,手动分配给 VIP 用户,让他们享受更优惠的价格。
'
},
rateMultiplierHint
:
'
1.0 = 标准费率,0.5 = 半价,2.0 = 双倍
'
,
platforms
:
{
...
...
@@ -1094,7 +1131,8 @@ export default {
},
claudeCode
:
{
title
:
'
Claude Code 客户端限制
'
,
tooltip
:
'
启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。
'
,
tooltip
:
'
启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。
'
,
enabled
:
'
仅限 Claude Code
'
,
disabled
:
'
允许所有客户端
'
,
fallbackGroup
:
'
降级分组
'
,
...
...
@@ -1111,7 +1149,8 @@ export default {
},
modelRouting
:
{
title
:
'
模型路由配置
'
,
tooltip
:
'
配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。
'
,
tooltip
:
'
配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。
'
,
enabled
:
'
已启用
'
,
disabled
:
'
已禁用
'
,
disabledHint
:
'
启用后,配置的路由规则才会生效
'
,
...
...
@@ -1614,8 +1653,7 @@ export default {
regenerate
:
'
重新生成
'
,
step2OpenUrl
:
'
在浏览器中打开 URL 并完成授权
'
,
openUrlDesc
:
'
在新标签页中打开授权 URL,登录您的 Claude 账号并授权。
'
,
proxyWarning
:
'
注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。
'
,
proxyWarning
:
'
注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。
'
,
step3EnterCode
:
'
输入授权码
'
,
authCodeDesc
:
'
授权完成后,页面会显示一个授权码。复制并粘贴到下方:
'
,
authCode
:
'
授权码
'
,
...
...
@@ -1654,12 +1692,14 @@ export default {
generateAuthUrl
:
'
生成授权链接
'
,
projectIdLabel
:
'
Project ID(可选)
'
,
projectIdPlaceholder
:
'
例如:my-gcp-project 或 cloud-ai-companion-xxxxx
'
,
projectIdHint
:
'
留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。
'
,
projectIdHint
:
'
留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。
'
,
howToGetProjectId
:
'
如何获取
'
,
step2OpenUrl
:
'
在浏览器中打开链接并完成授权
'
,
openUrlDesc
:
'
请在新标签页中打开授权链接,登录您的 Google 账户并授权。
'
,
step3EnterCode
:
'
输入回调链接或 Code
'
,
authCodeDesc
:
'
授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。
'
,
authCodeDesc
:
'
授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。
'
,
authCode
:
'
回调链接或 Code
'
,
authCodePlaceholder
:
'
方式1(推荐):粘贴回调链接
\n
方式2:仅粘贴 code 参数的值
'
,
authCodeHint
:
'
系统会自动从链接中解析 code/state。
'
,
...
...
@@ -1672,7 +1712,8 @@ export default {
failedToGenerateUrl
:
'
生成 Gemini 授权链接失败
'
,
missingExchangeParams
:
'
缺少 code / session_id / state
'
,
failedToExchangeCode
:
'
Gemini 授权码兑换失败
'
,
missingProjectId
:
'
GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。
'
,
missingProjectId
:
'
GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。
'
,
modelPassthrough
:
'
Gemini 直接转发模型
'
,
modelPassthroughDesc
:
'
所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。
'
,
stateWarningTitle
:
'
提示
'
,
...
...
@@ -1683,8 +1724,10 @@ export default {
noProjectIdNeeded
:
'
自定义授权(AI Studio)
'
,
noProjectIdNeededDesc
:
'
需管理员配置 OAuth Client
'
,
aiStudioNotConfiguredShort
:
'
未配置
'
,
aiStudioNotConfiguredTip
:
'
AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)
'
,
aiStudioNotConfigured
:
'
AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback
'
aiStudioNotConfiguredTip
:
'
AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)
'
,
aiStudioNotConfigured
:
'
AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback
'
},
// Antigravity specific
antigravity
:
{
...
...
@@ -1722,7 +1765,8 @@ export default {
tier
:
{
label
:
'
账号等级
'
,
hint
:
'
提示:系统会优先尝试自动识别账号等级;若自动识别不可用或失败,则使用你选择的等级作为回退(本地模拟配额)。
'
,
aiStudioHint
:
'
AI Studio 的配额是按模型分别限流(Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。
'
,
aiStudioHint
:
'
AI Studio 的配额是按模型分别限流(Pro/Flash 独立)。若已绑卡(按量付费),请选 Pay-as-you-go。
'
,
googleOne
:
{
free
:
'
Google One Free
'
,
pro
:
'
Google One Pro
'
,
...
...
@@ -2547,7 +2591,7 @@ export default {
internal
:
'
内部
'
},
total
:
'
总计:
'
,
searchPlaceholder
:
'
搜索 request_id / client_request_id / message
'
,
searchPlaceholder
:
'
搜索 request_id / client_request_id / message
'
},
// Error Detail Modal
errorDetail
:
{
...
...
@@ -2978,7 +3022,8 @@ export default {
ignoreCountTokensErrors
:
'
忽略 count_tokens 错误
'
,
ignoreCountTokensErrorsHint
:
'
启用后,count_tokens 请求的错误将不会写入错误日志。
'
,
ignoreContextCanceled
:
'
忽略客户端断连错误
'
,
ignoreContextCanceledHint
:
'
启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。
'
,
ignoreContextCanceledHint
:
'
启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。
'
,
ignoreNoAvailableAccounts
:
'
忽略无可用账号错误
'
,
ignoreNoAvailableAccountsHint
:
'
启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。
'
,
ignoreInvalidApiKeyErrors
:
'
忽略无效 API Key 错误
'
,
...
...
@@ -3097,7 +3142,8 @@ export default {
siteKeyHint
:
'
从 Cloudflare Dashboard 获取
'
,
cloudflareDashboard
:
'
Cloudflare Dashboard
'
,
secretKeyHint
:
'
服务端验证密钥(请保密)
'
,
secretKeyConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
},
secretKeyConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
},
linuxdo
:
{
title
:
'
LinuxDo Connect 登录
'
,
description
:
'
配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录
'
,
...
...
@@ -3151,9 +3197,12 @@ export default {
logoTypeError
:
'
请选择图片文件
'
,
logoReadError
:
'
读取图片文件失败
'
,
homeContent
:
'
首页内容
'
,
homeContentPlaceholder
:
'
在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。
'
,
homeContentHint
:
'
自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。
'
,
homeContentIframeWarning
:
'
⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。
'
,
homeContentPlaceholder
:
'
在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。
'
,
homeContentHint
:
'
自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。
'
,
homeContentIframeWarning
:
'
⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。
'
,
hideCcsImportButton
:
'
隐藏 CCS 导入按钮
'
,
hideCcsImportButtonHint
:
'
启用后将在 API Keys 页面隐藏"导入 CCS"按钮
'
},
...
...
@@ -3390,131 +3439,158 @@ export default {
admin
:
{
welcome
:
{
title
:
'
👋 欢迎使用 Sub2API
'
,
description
:
'
<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>
'
,
description
:
'
<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>
'
,
nextBtn
:
'
开始配置 🚀
'
,
prevBtn
:
'
跳过
'
},
groupManage
:
{
title
:
'
📦 第一步:分组管理
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>
'
},
createGroup
:
{
title
:
'
➕ 创建新分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>
'
},
groupName
:
{
title
:
'
✏️ 1. 分组名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>
'
,
nextBtn
:
'
下一步
'
},
groupPlatform
:
{
title
:
'
🤖 2. 选择平台
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 平台说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 平台说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>
'
,
nextBtn
:
'
下一步
'
},
groupMultiplier
:
{
title
:
'
💰 3. 费率倍数
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1,扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1,扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1,扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1,扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>
'
,
nextBtn
:
'
下一步
'
},
groupExclusive
:
{
title
:
'
🔒 4. 专属分组(可选)
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">控制分组的可见性和访问权限。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 权限说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">控制分组的可见性和访问权限。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 权限说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>
'
,
nextBtn
:
'
下一步
'
},
groupSubmit
:
{
title
:
'
✅ 保存分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
},
accountManage
:
{
title
:
'
🔗 第二步:添加账号
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 账号的作用:</b><ul style="margin: 8px 0 0 16px;"><li>连接到上游 AI 服务(Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 账号的作用:</b><ul style="margin: 8px 0 0 16px;"><li>连接到上游 AI 服务(Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>
'
},
createAccount
:
{
title
:
'
➕ 添加新账号
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>
'
},
accountName
:
{
title
:
'
✏️ 1. 账号名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>
'
,
nextBtn
:
'
下一步
'
},
accountPlatform
:
{
title
:
'
🤖 2. 选择平台
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>
'
,
nextBtn
:
'
下一步
'
},
accountType
:
{
title
:
'
🔐 3. 授权方式
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐:OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐:OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>
'
,
nextBtn
:
'
下一步
'
},
accountPriority
:
{
title
:
'
⚖️ 4. 优先级(可选)
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越小,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越小,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>
'
,
nextBtn
:
'
下一步
'
},
accountGroups
:
{
title
:
'
🎯 5. 分配分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>必须勾选至少一个分组</li><li>未分配分组的账号无法使用</li><li>一个账号可以分配给多个分组</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>请勾选刚才创建的测试分组</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>必须勾选至少一个分组</li><li>未分配分组的账号无法使用</li><li>一个账号可以分配给多个分组</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>请勾选刚才创建的测试分组</p></div>
'
,
nextBtn
:
'
下一步
'
},
accountSubmit
:
{
title
:
'
✅ 保存账号
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击保存按钮。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth 授权流程:</b><ul style="margin: 8px 0 0 16px;"><li>点击保存后会跳转到服务商页面</li><li>在服务商页面完成登录授权</li><li>授权成功后自动返回</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击保存按钮。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth 授权流程:</b><ul style="margin: 8px 0 0 16px;"><li>点击保存后会跳转到服务商页面</li><li>在服务商页面完成登录授权</li><li>授权成功后自动返回</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>
'
},
keyManage
:
{
title
:
'
🔑 第三步:生成密钥
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>
'
},
createKey
:
{
title
:
'
➕ 创建密钥
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>
'
},
keyName
:
{
title
:
'
✏️ 1. 密钥名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>
'
,
nextBtn
:
'
下一步
'
},
keyGroup
:
{
title
:
'
🎯 2. 选择分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择刚才配置好的分组。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 分组决定:</b><ul style="margin: 8px 0 0 16px;"><li>该密钥可以使用哪些账号</li><li>计费倍率是多少</li><li>是否为专属密钥</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>选择刚才创建的测试分组</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择刚才配置好的分组。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 分组决定:</b><ul style="margin: 8px 0 0 16px;"><li>该密钥可以使用哪些账号</li><li>计费倍率是多少</li><li>是否为专属密钥</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>选择刚才创建的测试分组</p></div>
'
,
nextBtn
:
'
下一步
'
},
keySubmit
:
{
title
:
'
🎉 生成并复制
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>密钥只显示一次,请立即复制</li><li>丢失后需要重新生成</li><li>妥善保管,不要泄露给他人</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>密钥只显示一次,请立即复制</li><li>丢失后需要重新生成</li><li>妥善保管,不要泄露给他人</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
}
},
// User tour steps
user
:
{
welcome
:
{
title
:
'
👋 欢迎使用 Sub2API
'
,
description
:
'
<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>
'
,
description
:
'
<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>
'
,
nextBtn
:
'
开始 🚀
'
,
prevBtn
:
'
跳过
'
},
keyManage
:
{
title
:
'
🔑 API 密钥管理
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>
'
},
createKey
:
{
title
:
'
➕ 创建新密钥
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>
'
},
keyName
:
{
title
:
'
✏️ 密钥名称
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>
'
,
nextBtn
:
'
下一步
'
},
keyGroup
:
{
title
:
'
🎯 选择分组
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>
'
,
nextBtn
:
'
下一步
'
},
keySubmit
:
{
title
:
'
🎉 完成创建
'
,
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
description
:
'
<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>
'
}
}
}
...
...
frontend/src/types/index.ts
View file @
c0c9c984
...
...
@@ -374,9 +374,12 @@ export interface ApiKey {
key
:
string
name
:
string
group_id
:
number
|
null
status
:
'
active
'
|
'
inactive
'
status
:
'
active
'
|
'
inactive
'
|
'
quota_exhausted
'
|
'
expired
'
ip_whitelist
:
string
[]
ip_blacklist
:
string
[]
quota
:
number
// Quota limit in USD (0 = unlimited)
quota_used
:
number
// Used quota amount in USD
expires_at
:
string
|
null
// Expiration time (null = never expires)
created_at
:
string
updated_at
:
string
group
?:
Group
...
...
@@ -388,6 +391,8 @@ export interface CreateApiKeyRequest {
custom_key
?:
string
// Optional custom API Key
ip_whitelist
?:
string
[]
ip_blacklist
?:
string
[]
quota
?:
number
// Quota limit in USD (0 = unlimited)
expires_in_days
?:
number
// Days until expiry (null = never expires)
}
export
interface
UpdateApiKeyRequest
{
...
...
@@ -396,6 +401,9 @@ export interface UpdateApiKeyRequest {
status
?:
'
active
'
|
'
inactive
'
ip_whitelist
?:
string
[]
ip_blacklist
?:
string
[]
quota
?:
number
// Quota limit in USD (null = no change, 0 = unlimited)
expires_at
?:
string
|
null
// Expiration time (null = no change)
reset_quota
?:
boolean
// Reset quota_used to 0
}
export
interface
CreateGroupRequest
{
...
...
frontend/src/views/user/KeysView.vue
View file @
c0c9c984
...
...
@@ -108,12 +108,53 @@
$
{{
(
usageStats
[
row
.
id
]?.
total_actual_cost
??
0
).
toFixed
(
4
)
}}
</span>
</div>
<!-- Quota progress (if quota is set) -->
<div
v-if=
"row.quota > 0"
class=
"mt-1.5"
>
<div
class=
"flex items-center gap-1.5"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
keys.quota
'
)
}}
:
</span>
<span
:class=
"[
'font-medium',
row.quota_used >= row.quota ? 'text-red-500' :
row.quota_used >= row.quota * 0.8 ? 'text-yellow-500' :
'text-gray-900 dark:text-white'
]"
>
$
{{
row
.
quota_used
?.
toFixed
(
2
)
||
'
0.00
'
}}
/ $
{{
row
.
quota
?.
toFixed
(
2
)
}}
</span>
</div>
<div
class=
"mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600"
>
<div
:class=
"[
'h-full rounded-full transition-all',
row.quota_used >= row.quota ? 'bg-red-500' :
row.quota_used >= row.quota * 0.8 ? 'bg-yellow-500' :
'bg-primary-500'
]"
:style=
"
{ width: Math.min((row.quota_used / row.quota) * 100, 100) + '%' }"
/>
</div>
</div>
</div>
</
template
>
<
template
#cell-expires_at=
"{ value }"
>
<span
v-if=
"value"
:class=
"[
'text-sm',
new Date(value)
<
new
Date
()
?
'
text-red-500
dark:text-red-400
'
:
'
text-gray-500
dark:text-dark-400
'
]"
>
{{
formatDateTime
(
value
)
}}
</span>
<span
v-else
class=
"text-sm text-gray-400 dark:text-dark-500"
>
{{
t
(
'
keys.noExpiration
'
)
}}
</span>
</
template
>
<
template
#cell-status=
"{ value }"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-gray']"
>
{{
t
(
'
admin.accounts.status.
'
+
value
)
}}
<span
:class=
"[
'badge',
value === 'active' ? 'badge-success' :
value === 'quota_exhausted' ? 'badge-warning' :
value === 'expired' ? 'badge-danger' :
'badge-gray'
]"
>
{{
t
(
'
keys.status.
'
+
value
)
}}
</span>
</
template
>
...
...
@@ -334,6 +375,145 @@
</div>
</div>
</div>
<!-- Quota Limit Section -->
<div
class=
"space-y-3"
>
<label
class=
"input-label"
>
{{ t('keys.quotaLimit') }}
</label>
<!-- Switch commented out - always show input, 0 = unlimited
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('keys.quotaLimit') }}</label>
<button
type="button"
@click="formData.enable_quota = !formData.enable_quota"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_quota ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_quota ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
-->
<div
class=
"space-y-4"
>
<div>
<div
class=
"relative"
>
<span
class=
"absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
>
$
</span>
<input
v-model.number=
"formData.quota"
type=
"number"
step=
"0.01"
min=
"0"
class=
"input pl-7"
:placeholder=
"t('keys.quotaAmountPlaceholder')"
/>
</div>
<p
class=
"input-hint"
>
{{ t('keys.quotaAmountHint') }}
</p>
</div>
<!-- Quota used display (only in edit mode) -->
<div
v-if=
"showEditModal && selectedKey && selectedKey.quota > 0"
>
<label
class=
"input-label"
>
{{ t('keys.quotaUsed') }}
</label>
<div
class=
"flex items-center gap-2"
>
<div
class=
"flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
${{ selectedKey.quota_used?.toFixed(4) || '0.0000' }}
</span>
<span
class=
"mx-2 text-gray-400"
>
/
</span>
<span
class=
"text-gray-500 dark:text-gray-400"
>
${{ selectedKey.quota?.toFixed(2) || '0.00' }}
</span>
</div>
<button
type=
"button"
@
click=
"confirmResetQuota"
class=
"btn btn-secondary text-sm"
:title=
"t('keys.resetQuotaUsed')"
>
{{ t('keys.reset') }}
</button>
</div>
</div>
</div>
</div>
<!-- Expiration Section -->
<div
class=
"space-y-3"
>
<div
class=
"flex items-center justify-between"
>
<label
class=
"input-label mb-0"
>
{{ t('keys.expiration') }}
</label>
<button
type=
"button"
@
click=
"formData.enable_expiration = !formData.enable_expiration"
:class=
"[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_expiration ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class=
"[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_expiration ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div
v-if=
"formData.enable_expiration"
class=
"space-y-4 pt-2"
>
<!-- Quick select buttons (for both create and edit mode) -->
<div
class=
"flex flex-wrap gap-2"
>
<button
v-for=
"days in ['7', '30', '90']"
:key=
"days"
type=
"button"
@
click=
"setExpirationDays(parseInt(days))"
:class=
"[
'rounded-lg px-3 py-1.5 text-sm transition-colors',
formData.expiration_preset === days
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
]"
>
{{ showEditModal ? t('keys.extendDays', { days }) : t('keys.expiresInDays', { days }) }}
</button>
<button
type=
"button"
@
click=
"formData.expiration_preset = 'custom'"
:class=
"[
'rounded-lg px-3 py-1.5 text-sm transition-colors',
formData.expiration_preset === 'custom'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
]"
>
{{ t('keys.customDate') }}
</button>
</div>
<!-- Date picker (always show for precise adjustment) -->
<div>
<label
class=
"input-label"
>
{{ t('keys.expirationDate') }}
</label>
<input
v-model=
"formData.expiration_date"
type=
"datetime-local"
class=
"input"
/>
<p
class=
"input-hint"
>
{{ t('keys.expirationDateHint') }}
</p>
</div>
<!-- Current expiration display (only in edit mode) -->
<div
v-if=
"showEditModal && selectedKey?.expires_at"
class=
"text-sm"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{ t('keys.currentExpiration') }}:
</span>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{ formatDateTime(selectedKey.expires_at) }}
</span>
</div>
</div>
</div>
</form>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
...
...
@@ -391,6 +571,18 @@
@
cancel=
"showDeleteDialog = false"
/>
<!-- Reset Quota Confirmation Dialog -->
<ConfirmDialog
:show=
"showResetQuotaDialog"
:title=
"t('keys.resetQuotaTitle')"
:message=
"t('keys.resetQuotaConfirmMessage', { name: selectedKey?.name, used: selectedKey?.quota_used?.toFixed(4) })"
:confirm-text=
"t('keys.reset')"
:cancel-text=
"t('common.cancel')"
:danger=
"true"
@
confirm=
"resetQuotaUsed"
@
cancel=
"showResetQuotaDialog = false"
/>
<!-- Use Key Modal -->
<UseKeyModal
:show=
"showUseKeyModal"
...
...
@@ -514,6 +706,13 @@ import type { Column } from '@/components/common/types'
import
type
{
BatchApiKeyUsageStats
}
from
'
@/api/usage
'
import
{
formatDateTime
}
from
'
@/utils/format
'
// Helper to format date for datetime-local input
const
formatDateTimeLocal
=
(
isoDate
:
string
):
string
=>
{
const
date
=
new
Date
(
isoDate
)
const
pad
=
(
n
:
number
)
=>
n
.
toString
().
padStart
(
2
,
'
0
'
)
return
`
${
date
.
getFullYear
()}
-
${
pad
(
date
.
getMonth
()
+
1
)}
-
${
pad
(
date
.
getDate
())}
T
${
pad
(
date
.
getHours
())}
:
${
pad
(
date
.
getMinutes
())}
`
}
interface
GroupOption
{
value
:
number
label
:
string
...
...
@@ -532,6 +731,7 @@ const columns = computed<Column[]>(() => [
{
key
:
'
key
'
,
label
:
t
(
'
keys.apiKey
'
),
sortable
:
false
},
{
key
:
'
group
'
,
label
:
t
(
'
keys.group
'
),
sortable
:
false
},
{
key
:
'
usage
'
,
label
:
t
(
'
keys.usage
'
),
sortable
:
false
},
{
key
:
'
expires_at
'
,
label
:
t
(
'
keys.expiresAt
'
),
sortable
:
true
},
{
key
:
'
status
'
,
label
:
t
(
'
common.status
'
),
sortable
:
true
},
{
key
:
'
created_at
'
,
label
:
t
(
'
keys.created
'
),
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
common.actions
'
),
sortable
:
false
}
...
...
@@ -553,6 +753,7 @@ const pagination = ref({
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showResetQuotaDialog
=
ref
(
false
)
const
showUseKeyModal
=
ref
(
false
)
const
showCcsClientSelect
=
ref
(
false
)
const
pendingCcsRow
=
ref
<
ApiKey
|
null
>
(
null
)
...
...
@@ -587,7 +788,13 @@ const formData = ref({
custom_key
:
''
,
enable_ip_restriction
:
false
,
ip_whitelist
:
''
,
ip_blacklist
:
''
ip_blacklist
:
''
,
// Quota settings (empty = unlimited)
enable_quota
:
false
,
quota
:
null
as
number
|
null
,
enable_expiration
:
false
,
expiration_preset
:
'
30
'
as
'
7
'
|
'
30
'
|
'
90
'
|
'
custom
'
,
expiration_date
:
''
})
// 自定义Key验证
...
...
@@ -724,15 +931,21 @@ const handlePageSizeChange = (pageSize: number) => {
const
editKey
=
(
key
:
ApiKey
)
=>
{
selectedKey
.
value
=
key
const
hasIPRestriction
=
(
key
.
ip_whitelist
?.
length
>
0
)
||
(
key
.
ip_blacklist
?.
length
>
0
)
const
hasExpiration
=
!!
key
.
expires_at
formData
.
value
=
{
name
:
key
.
name
,
group_id
:
key
.
group_id
,
status
:
key
.
status
,
status
:
key
.
status
===
'
quota_exhausted
'
||
key
.
status
===
'
expired
'
?
'
inactive
'
:
key
.
status
,
use_custom_key
:
false
,
custom_key
:
''
,
enable_ip_restriction
:
hasIPRestriction
,
ip_whitelist
:
(
key
.
ip_whitelist
||
[]).
join
(
'
\n
'
),
ip_blacklist
:
(
key
.
ip_blacklist
||
[]).
join
(
'
\n
'
)
ip_blacklist
:
(
key
.
ip_blacklist
||
[]).
join
(
'
\n
'
),
enable_quota
:
key
.
quota
>
0
,
quota
:
key
.
quota
>
0
?
key
.
quota
:
null
,
enable_expiration
:
hasExpiration
,
expiration_preset
:
'
custom
'
,
expiration_date
:
key
.
expires_at
?
formatDateTimeLocal
(
key
.
expires_at
)
:
''
}
showEditModal
.
value
=
true
}
...
...
@@ -820,6 +1033,28 @@ const handleSubmit = async () => {
const
ipWhitelist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_whitelist
)
:
[]
const
ipBlacklist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_blacklist
)
:
[]
// Calculate quota value (null/empty/0 = unlimited, stored as 0)
const
quota
=
formData
.
value
.
quota
&&
formData
.
value
.
quota
>
0
?
formData
.
value
.
quota
:
0
// Calculate expiration
let
expiresInDays
:
number
|
undefined
let
expiresAt
:
string
|
null
|
undefined
if
(
formData
.
value
.
enable_expiration
&&
formData
.
value
.
expiration_date
)
{
if
(
!
showEditModal
.
value
)
{
// Create mode: calculate days from date
const
expDate
=
new
Date
(
formData
.
value
.
expiration_date
)
const
now
=
new
Date
()
const
diffDays
=
Math
.
ceil
((
expDate
.
getTime
()
-
now
.
getTime
())
/
(
1000
*
60
*
60
*
24
))
expiresInDays
=
diffDays
>
0
?
diffDays
:
1
}
else
{
// Edit mode: use custom date directly
expiresAt
=
new
Date
(
formData
.
value
.
expiration_date
).
toISOString
()
}
}
else
if
(
showEditModal
.
value
)
{
// Edit mode: if expiration disabled or date cleared, send empty string to clear
expiresAt
=
''
}
submitting
.
value
=
true
try
{
if
(
showEditModal
.
value
&&
selectedKey
.
value
)
{
...
...
@@ -828,12 +1063,22 @@ const handleSubmit = async () => {
group_id
:
formData
.
value
.
group_id
,
status
:
formData
.
value
.
status
,
ip_whitelist
:
ipWhitelist
,
ip_blacklist
:
ipBlacklist
ip_blacklist
:
ipBlacklist
,
quota
:
quota
,
expires_at
:
expiresAt
})
appStore
.
showSuccess
(
t
(
'
keys.keyUpdatedSuccess
'
))
}
else
{
const
customKey
=
formData
.
value
.
use_custom_key
?
formData
.
value
.
custom_key
:
undefined
await
keysAPI
.
create
(
formData
.
value
.
name
,
formData
.
value
.
group_id
,
customKey
,
ipWhitelist
,
ipBlacklist
)
await
keysAPI
.
create
(
formData
.
value
.
name
,
formData
.
value
.
group_id
,
customKey
,
ipWhitelist
,
ipBlacklist
,
quota
,
expiresInDays
)
appStore
.
showSuccess
(
t
(
'
keys.keyCreatedSuccess
'
))
// Only advance tour if active, on submit step, and creation succeeded
if
(
onboardingStore
.
isCurrentStep
(
'
[data-tour="key-form-submit"]
'
))
{
...
...
@@ -883,7 +1128,42 @@ const closeModals = () => {
custom_key
:
''
,
enable_ip_restriction
:
false
,
ip_whitelist
:
''
,
ip_blacklist
:
''
ip_blacklist
:
''
,
enable_quota
:
false
,
quota
:
null
,
enable_expiration
:
false
,
expiration_preset
:
'
30
'
,
expiration_date
:
''
}
}
// Show reset quota confirmation dialog
const
confirmResetQuota
=
()
=>
{
showResetQuotaDialog
.
value
=
true
}
// Set expiration date based on quick select days
const
setExpirationDays
=
(
days
:
number
)
=>
{
formData
.
value
.
expiration_preset
=
days
.
toString
()
as
'
7
'
|
'
30
'
|
'
90
'
const
expDate
=
new
Date
()
expDate
.
setDate
(
expDate
.
getDate
()
+
days
)
formData
.
value
.
expiration_date
=
formatDateTimeLocal
(
expDate
.
toISOString
())
}
// Reset quota used for an API key
const
resetQuotaUsed
=
async
()
=>
{
if
(
!
selectedKey
.
value
)
return
showResetQuotaDialog
.
value
=
false
try
{
await
keysAPI
.
update
(
selectedKey
.
value
.
id
,
{
reset_quota
:
true
})
appStore
.
showSuccess
(
t
(
'
keys.quotaResetSuccess
'
))
// Update local state
if
(
selectedKey
.
value
)
{
selectedKey
.
value
.
quota_used
=
0
}
}
catch
(
error
:
any
)
{
const
errorMsg
=
error
.
response
?.
data
?.
detail
||
t
(
'
keys.failedToResetQuota
'
)
appStore
.
showError
(
errorMsg
)
}
}
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment