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
4cce21b1
Unverified
Commit
4cce21b1
authored
Feb 03, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 03, 2026
Browse files
Merge branch 'main' into main
parents
0707f3d9
c0c9c984
Changes
52
Hide whitespace changes
Inline
Side-by-side
backend/migrations/045_add_api_key_quota.sql
0 → 100644
View file @
4cce21b1
-- 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 @
0707f3d9
//go:build tools
// +build tools
package
tools
import
(
_
"entgo.io/ent/cmd/ent"
_
"github.com/google/wire/cmd/wire"
)
frontend/src/api/admin/index.ts
View file @
4cce21b1
...
...
@@ -62,3 +62,6 @@ export {
}
export
default
adminAPI
// Re-export types used by components
export
type
{
BalanceHistoryItem
}
from
'
./users
'
frontend/src/api/admin/users.ts
View file @
4cce21b1
...
...
@@ -174,6 +174,53 @@ export async function getUserUsageStats(
return
data
}
/**
* Balance history item returned from the API
*/
export
interface
BalanceHistoryItem
{
id
:
number
code
:
string
type
:
string
value
:
number
status
:
string
used_by
:
number
|
null
used_at
:
string
|
null
created_at
:
string
group_id
:
number
|
null
validity_days
:
number
notes
:
string
user
?:
{
id
:
number
;
email
:
string
}
|
null
group
?:
{
id
:
number
;
name
:
string
}
|
null
}
// Balance history response extends pagination with total_recharged summary
export
interface
BalanceHistoryResponse
extends
PaginatedResponse
<
BalanceHistoryItem
>
{
total_recharged
:
number
}
/**
* Get user's balance/concurrency change history
* @param id - User ID
* @param page - Page number
* @param pageSize - Items per page
* @param type - Optional type filter (balance, admin_balance, concurrency, admin_concurrency, subscription)
* @returns Paginated balance history with total_recharged
*/
export
async
function
getUserBalanceHistory
(
id
:
number
,
page
:
number
=
1
,
pageSize
:
number
=
20
,
type
?:
string
):
Promise
<
BalanceHistoryResponse
>
{
const
params
:
Record
<
string
,
any
>
=
{
page
,
page_size
:
pageSize
}
if
(
type
)
params
.
type
=
type
const
{
data
}
=
await
apiClient
.
get
<
BalanceHistoryResponse
>
(
`/admin/users/
${
id
}
/balance-history`
,
{
params
}
)
return
data
}
export
const
usersAPI
=
{
list
,
getById
,
...
...
@@ -184,7 +231,8 @@ export const usersAPI = {
updateConcurrency
,
toggleStatus
,
getUserApiKeys
,
getUserUsageStats
getUserUsageStats
,
getUserBalanceHistory
}
export
default
usersAPI
frontend/src/api/keys.ts
View file @
4cce21b1
...
...
@@ -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/components/admin/user/UserBalanceHistoryModal.vue
0 → 100644
View file @
4cce21b1
<
template
>
<BaseDialog
:show=
"show"
:title=
"t('admin.users.balanceHistoryTitle')"
width=
"wide"
:close-on-click-outside=
"true"
:z-index=
"40"
@
close=
"$emit('close')"
>
<div
v-if=
"user"
class=
"space-y-4"
>
<!-- User header: two-row layout with full user info -->
<div
class=
"rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<!-- Row 1: avatar + email/username/created_at (left) + current balance (right) -->
<div
class=
"flex items-center gap-3"
>
<div
class=
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
>
<span
class=
"text-lg font-medium text-primary-700 dark:text-primary-300"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span>
</div>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"flex items-center gap-2"
>
<p
class=
"truncate font-medium text-gray-900 dark:text-white"
>
{{
user
.
email
}}
</p>
<span
v-if=
"user.username"
class=
"flex-shrink-0 rounded bg-primary-50 px-1.5 py-0.5 text-xs text-primary-600 dark:bg-primary-900/20 dark:text-primary-400"
>
{{
user
.
username
}}
</span>
</div>
<p
class=
"text-xs text-gray-400 dark:text-dark-500"
>
{{
t
(
'
admin.users.createdAt
'
)
}}
:
{{
formatDateTime
(
user
.
created_at
)
}}
</p>
</div>
<!-- Current balance: prominent display on the right -->
<div
class=
"flex-shrink-0 text-right"
>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{
t
(
'
admin.users.currentBalance
'
)
}}
</p>
<p
class=
"text-xl font-bold text-gray-900 dark:text-white"
>
$
{{
user
.
balance
?.
toFixed
(
2
)
||
'
0.00
'
}}
</p>
</div>
</div>
<!-- Row 2: notes + total recharged -->
<div
class=
"mt-2.5 flex items-center justify-between border-t border-gray-200/60 pt-2.5 dark:border-dark-600/60"
>
<p
class=
"min-w-0 flex-1 truncate text-xs text-gray-500 dark:text-dark-400"
:title=
"user.notes || ''"
>
<template
v-if=
"user.notes"
>
{{
t
(
'
admin.users.notes
'
)
}}
:
{{
user
.
notes
}}
</
template
>
<
template
v-else
>
</
template
>
</p>
<p
class=
"ml-4 flex-shrink-0 text-xs text-gray-500 dark:text-dark-400"
>
{{ t('admin.users.totalRecharged') }}:
<span
class=
"font-semibold text-emerald-600 dark:text-emerald-400"
>
${{ totalRecharged.toFixed(2) }}
</span>
</p>
</div>
</div>
<!-- Type filter + Action buttons -->
<div
class=
"flex items-center gap-3"
>
<Select
v-model=
"typeFilter"
:options=
"typeOptions"
class=
"w-56"
@
change=
"loadHistory(1)"
/>
<!-- Deposit button - matches menu style -->
<button
@
click=
"emit('deposit')"
class=
"flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
>
<Icon
name=
"plus"
size=
"sm"
class=
"text-emerald-500"
:stroke-width=
"2"
/>
{{ t('admin.users.deposit') }}
</button>
<!-- Withdraw button - matches menu style -->
<button
@
click=
"emit('withdraw')"
class=
"flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
>
<svg
class=
"h-4 w-4 text-amber-500"
fill=
"none"
viewBox=
"0 0 24 24"
stroke=
"currentColor"
>
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
stroke-width=
"2"
d=
"M20 12H4"
/>
</svg>
{{ t('admin.users.withdraw') }}
</button>
</div>
<!-- Loading -->
<div
v-if=
"loading"
class=
"flex justify-center py-8"
>
<svg
class=
"h-8 w-8 animate-spin text-primary-500"
fill=
"none"
viewBox=
"0 0 24 24"
>
<circle
class=
"opacity-25"
cx=
"12"
cy=
"12"
r=
"10"
stroke=
"currentColor"
stroke-width=
"4"
/>
<path
class=
"opacity-75"
fill=
"currentColor"
d=
"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<!-- Empty state -->
<div
v-else-if=
"history.length === 0"
class=
"py-8 text-center"
>
<p
class=
"text-sm text-gray-500"
>
{{ t('admin.users.noBalanceHistory') }}
</p>
</div>
<!-- History list -->
<div
v-else
class=
"max-h-[28rem] space-y-3 overflow-y-auto"
>
<div
v-for=
"item in history"
:key=
"item.id"
class=
"rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800"
>
<div
class=
"flex items-start justify-between"
>
<!-- Left: type icon + description -->
<div
class=
"flex items-start gap-3"
>
<div
:class=
"[
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg',
getIconBg(item)
]"
>
<Icon
:name=
"getIconName(item)"
size=
"sm"
:class=
"getIconColor(item)"
/>
</div>
<div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ getItemTitle(item) }}
</p>
<!-- Notes (admin adjustment reason) -->
<p
v-if=
"item.notes"
class=
"mt-0.5 text-xs text-gray-500 dark:text-dark-400"
:title=
"item.notes"
>
{{ item.notes.length > 60 ? item.notes.substring(0, 55) + '...' : item.notes }}
</p>
<p
class=
"mt-0.5 text-xs text-gray-400 dark:text-dark-500"
>
{{ formatDateTime(item.used_at || item.created_at) }}
</p>
</div>
</div>
<!-- Right: value -->
<div
class=
"text-right"
>
<p
:class=
"['text-sm font-semibold', getValueColor(item)]"
>
{{ formatValue(item) }}
</p>
<p
v-if=
"isAdminType(item.type)"
class=
"text-xs text-gray-400 dark:text-dark-500"
>
{{ t('redeem.adminAdjustment') }}
</p>
<p
v-else
class=
"font-mono text-xs text-gray-400 dark:text-dark-500"
>
{{ item.code.slice(0, 8) }}...
</p>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div
v-if=
"totalPages > 1"
class=
"flex items-center justify-center gap-2 pt-2"
>
<button
:disabled=
"currentPage <= 1"
class=
"btn btn-secondary px-3 py-1 text-sm"
@
click=
"loadHistory(currentPage - 1)"
>
{{ t('pagination.previous') }}
</button>
<span
class=
"text-sm text-gray-500 dark:text-dark-400"
>
{{ currentPage }} / {{ totalPages }}
</span>
<button
:disabled=
"currentPage >= totalPages"
class=
"btn btn-secondary px-3 py-1 text-sm"
@
click=
"loadHistory(currentPage + 1)"
>
{{ t('pagination.next') }}
</button>
</div>
</div>
</BaseDialog>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
watch
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
adminAPI
,
type
BalanceHistoryItem
}
from
'
@/api/admin
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
type
{
AdminUser
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
props
=
defineProps
<
{
show
:
boolean
;
user
:
AdminUser
|
null
}
>
()
const
emit
=
defineEmits
([
'
close
'
,
'
deposit
'
,
'
withdraw
'
])
const
{
t
}
=
useI18n
()
const
history
=
ref
<
BalanceHistoryItem
[]
>
([])
const
loading
=
ref
(
false
)
const
currentPage
=
ref
(
1
)
const
total
=
ref
(
0
)
const
totalRecharged
=
ref
(
0
)
const
pageSize
=
15
const
typeFilter
=
ref
(
''
)
const
totalPages
=
computed
(()
=>
Math
.
ceil
(
total
.
value
/
pageSize
)
||
1
)
// Type filter options
const
typeOptions
=
computed
(()
=>
[
{
value
:
''
,
label
:
t
(
'
admin.users.allTypes
'
)
},
{
value
:
'
balance
'
,
label
:
t
(
'
admin.users.typeBalance
'
)
},
{
value
:
'
admin_balance
'
,
label
:
t
(
'
admin.users.typeAdminBalance
'
)
},
{
value
:
'
concurrency
'
,
label
:
t
(
'
admin.users.typeConcurrency
'
)
},
{
value
:
'
admin_concurrency
'
,
label
:
t
(
'
admin.users.typeAdminConcurrency
'
)
},
{
value
:
'
subscription
'
,
label
:
t
(
'
admin.users.typeSubscription
'
)
}
])
// Watch modal open
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
&&
props
.
user
)
{
typeFilter
.
value
=
''
loadHistory
(
1
)
}
})
const
loadHistory
=
async
(
page
:
number
)
=>
{
if
(
!
props
.
user
)
return
loading
.
value
=
true
currentPage
.
value
=
page
try
{
const
res
=
await
adminAPI
.
users
.
getUserBalanceHistory
(
props
.
user
.
id
,
page
,
pageSize
,
typeFilter
.
value
||
undefined
)
history
.
value
=
res
.
items
||
[]
total
.
value
=
res
.
total
||
0
totalRecharged
.
value
=
res
.
total_recharged
||
0
}
catch
(
error
)
{
console
.
error
(
'
Failed to load balance history:
'
,
error
)
}
finally
{
loading
.
value
=
false
}
}
// Helper: check if admin type
const
isAdminType
=
(
type
:
string
)
=>
type
===
'
admin_balance
'
||
type
===
'
admin_concurrency
'
// Helper: check if balance type (includes admin_balance)
const
isBalanceType
=
(
type
:
string
)
=>
type
===
'
balance
'
||
type
===
'
admin_balance
'
// Helper: check if subscription type
const
isSubscriptionType
=
(
type
:
string
)
=>
type
===
'
subscription
'
// Icon name based on type
const
getIconName
=
(
item
:
BalanceHistoryItem
)
=>
{
if
(
isBalanceType
(
item
.
type
))
return
'
dollar
'
if
(
isSubscriptionType
(
item
.
type
))
return
'
badge
'
return
'
bolt
'
// concurrency
}
// Icon background color
const
getIconBg
=
(
item
:
BalanceHistoryItem
)
=>
{
if
(
isBalanceType
(
item
.
type
))
{
return
item
.
value
>=
0
?
'
bg-emerald-100 dark:bg-emerald-900/30
'
:
'
bg-red-100 dark:bg-red-900/30
'
}
if
(
isSubscriptionType
(
item
.
type
))
return
'
bg-purple-100 dark:bg-purple-900/30
'
return
item
.
value
>=
0
?
'
bg-blue-100 dark:bg-blue-900/30
'
:
'
bg-orange-100 dark:bg-orange-900/30
'
}
// Icon text color
const
getIconColor
=
(
item
:
BalanceHistoryItem
)
=>
{
if
(
isBalanceType
(
item
.
type
))
{
return
item
.
value
>=
0
?
'
text-emerald-600 dark:text-emerald-400
'
:
'
text-red-600 dark:text-red-400
'
}
if
(
isSubscriptionType
(
item
.
type
))
return
'
text-purple-600 dark:text-purple-400
'
return
item
.
value
>=
0
?
'
text-blue-600 dark:text-blue-400
'
:
'
text-orange-600 dark:text-orange-400
'
}
// Value text color
const
getValueColor
=
(
item
:
BalanceHistoryItem
)
=>
{
if
(
isBalanceType
(
item
.
type
))
{
return
item
.
value
>=
0
?
'
text-emerald-600 dark:text-emerald-400
'
:
'
text-red-600 dark:text-red-400
'
}
if
(
isSubscriptionType
(
item
.
type
))
return
'
text-purple-600 dark:text-purple-400
'
return
item
.
value
>=
0
?
'
text-blue-600 dark:text-blue-400
'
:
'
text-orange-600 dark:text-orange-400
'
}
// Item title
const
getItemTitle
=
(
item
:
BalanceHistoryItem
)
=>
{
switch
(
item
.
type
)
{
case
'
balance
'
:
return
t
(
'
redeem.balanceAddedRedeem
'
)
case
'
admin_balance
'
:
return
item
.
value
>=
0
?
t
(
'
redeem.balanceAddedAdmin
'
)
:
t
(
'
redeem.balanceDeductedAdmin
'
)
case
'
concurrency
'
:
return
t
(
'
redeem.concurrencyAddedRedeem
'
)
case
'
admin_concurrency
'
:
return
item
.
value
>=
0
?
t
(
'
redeem.concurrencyAddedAdmin
'
)
:
t
(
'
redeem.concurrencyReducedAdmin
'
)
case
'
subscription
'
:
return
t
(
'
redeem.subscriptionAssigned
'
)
default
:
return
t
(
'
common.unknown
'
)
}
}
// Format display value
const
formatValue
=
(
item
:
BalanceHistoryItem
)
=>
{
if
(
isBalanceType
(
item
.
type
))
{
const
sign
=
item
.
value
>=
0
?
'
+
'
:
''
return
`
${
sign
}
$
${
item
.
value
.
toFixed
(
2
)}
`
}
if
(
isSubscriptionType
(
item
.
type
))
{
const
days
=
item
.
validity_days
||
Math
.
round
(
item
.
value
)
const
groupName
=
item
.
group
?.
name
||
''
return
groupName
?
`
${
days
}
d -
${
groupName
}
`
:
`
${
days
}
d`
}
// concurrency types
const
sign
=
item
.
value
>=
0
?
'
+
'
:
''
return
`
${
sign
}${
item
.
value
}
`
}
</
script
>
frontend/src/components/common/BaseDialog.vue
View file @
4cce21b1
...
...
@@ -4,6 +4,7 @@
<div
v-if=
"show"
class=
"modal-overlay"
:style=
"zIndexStyle"
:aria-labelledby=
"dialogId"
role=
"dialog"
aria-modal=
"true"
...
...
@@ -60,6 +61,7 @@ interface Props {
width
?:
DialogWidth
closeOnEscape
?:
boolean
closeOnClickOutside
?:
boolean
zIndex
?:
number
}
interface
Emits
{
...
...
@@ -69,11 +71,17 @@ interface Emits {
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
width
:
'
normal
'
,
closeOnEscape
:
true
,
closeOnClickOutside
:
false
closeOnClickOutside
:
false
,
zIndex
:
50
})
const
emit
=
defineEmits
<
Emits
>
()
// Custom z-index style (overrides the default z-50 from CSS)
const
zIndexStyle
=
computed
(()
=>
{
return
props
.
zIndex
!==
50
?
{
zIndex
:
props
.
zIndex
}
:
undefined
})
const
widthClasses
=
computed
(()
=>
{
// Width guidance: narrow=confirm/short prompts, normal=standard forms,
// wide=multi-section forms or rich content, extra-wide=analytics/tables,
...
...
frontend/src/i18n/locales/en.ts
View file @
4cce21b1
...
...
@@ -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
...
...
@@ -843,6 +871,20 @@ export default {
failedToDeposit
:
'
Failed to deposit
'
,
failedToWithdraw
:
'
Failed to withdraw
'
,
useDepositWithdrawButtons
:
'
Please use deposit/withdraw buttons to adjust balance
'
,
// Balance History
balanceHistory
:
'
Recharge History
'
,
balanceHistoryTip
:
'
Click to open recharge history
'
,
balanceHistoryTitle
:
'
User Recharge & Concurrency History
'
,
noBalanceHistory
:
'
No records found for this user
'
,
allTypes
:
'
All Types
'
,
typeBalance
:
'
Balance (Redeem)
'
,
typeAdminBalance
:
'
Balance (Admin)
'
,
typeConcurrency
:
'
Concurrency (Redeem)
'
,
typeAdminConcurrency
:
'
Concurrency (Admin)
'
,
typeSubscription
:
'
Subscription
'
,
failedToLoadBalanceHistory
:
'
Failed to load balance history
'
,
createdAt
:
'
Created
'
,
totalRecharged
:
'
Total Recharged
'
,
roles
:
{
admin
:
'
Admin
'
,
user
:
'
User
'
...
...
frontend/src/i18n/locales/zh.ts
View file @
4cce21b1
...
...
@@ -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
:
'
全部状态
'
,
...
...
@@ -894,6 +929,20 @@ export default {
failedToDeposit
:
'
充值失败
'
,
failedToWithdraw
:
'
退款失败
'
,
useDepositWithdrawButtons
:
'
请使用充值/退款按钮调整余额
'
,
// 余额变动记录
balanceHistory
:
'
充值记录
'
,
balanceHistoryTip
:
'
点击查看充值记录
'
,
balanceHistoryTitle
:
'
用户充值和并发变动记录
'
,
noBalanceHistory
:
'
暂无变动记录
'
,
allTypes
:
'
全部类型
'
,
typeBalance
:
'
余额(兑换码)
'
,
typeAdminBalance
:
'
余额(管理员调整)
'
,
typeConcurrency
:
'
并发(兑换码)
'
,
typeAdminConcurrency
:
'
并发(管理员调整)
'
,
typeSubscription
:
'
订阅
'
,
failedToLoadBalanceHistory
:
'
加载余额记录失败
'
,
createdAt
:
'
创建时间
'
,
totalRecharged
:
'
总充值
'
,
// Settings Dropdowns
filterSettings
:
'
筛选设置
'
,
columnSettings
:
'
列设置
'
,
...
...
@@ -1014,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
:
{
...
...
@@ -1080,7 +1131,8 @@ export default {
},
claudeCode
:
{
title
:
'
Claude Code 客户端限制
'
,
tooltip
:
'
启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。
'
,
tooltip
:
'
启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。
'
,
enabled
:
'
仅限 Claude Code
'
,
disabled
:
'
允许所有客户端
'
,
fallbackGroup
:
'
降级分组
'
,
...
...
@@ -1102,7 +1154,8 @@ export default {
},
modelRouting
:
{
title
:
'
模型路由配置
'
,
tooltip
:
'
配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。
'
,
tooltip
:
'
配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。
'
,
enabled
:
'
已启用
'
,
disabled
:
'
已禁用
'
,
disabledHint
:
'
启用后,配置的路由规则才会生效
'
,
...
...
@@ -1630,8 +1683,7 @@ export default {
regenerate
:
'
重新生成
'
,
step2OpenUrl
:
'
在浏览器中打开 URL 并完成授权
'
,
openUrlDesc
:
'
在新标签页中打开授权 URL,登录您的 Claude 账号并授权。
'
,
proxyWarning
:
'
注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。
'
,
proxyWarning
:
'
注意:如果您配置了代理,请确保浏览器使用相同的代理访问授权页面。
'
,
step3EnterCode
:
'
输入授权码
'
,
authCodeDesc
:
'
授权完成后,页面会显示一个授权码。复制并粘贴到下方:
'
,
authCode
:
'
授权码
'
,
...
...
@@ -1663,45 +1715,50 @@ export default {
authCodeHint
:
'
您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别
'
},
// Gemini specific
gemini
:
{
title
:
'
Gemini 账户授权
'
,
followSteps
:
'
请按照以下步骤完成 Gemini 账户的授权:
'
,
step1GenerateUrl
:
'
生成授权链接
'
,
generateAuthUrl
:
'
生成授权链接
'
,
projectIdLabel
:
'
Project ID(可选)
'
,
projectIdPlaceholder
:
'
例如:my-gcp-project 或 cloud-ai-companion-xxxxx
'
,
projectIdHint
:
'
留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。
'
,
howToGetProjectId
:
'
如何获取
'
,
step2OpenUrl
:
'
在浏览器中打开链接并完成授权
'
,
openUrlDesc
:
'
请在新标签页中打开授权链接,登录您的 Google 账户并授权。
'
,
step3EnterCode
:
'
输入回调链接或 Code
'
,
authCodeDesc
:
'
授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。
'
,
authCode
:
'
回调链接或 Code
'
,
authCodePlaceholder
:
'
方式1(推荐):粘贴回调链接
\n
方式2:仅粘贴 code 参数的值
'
,
authCodeHint
:
'
系统会自动从链接中解析 code/state。
'
,
gemini
:
{
title
:
'
Gemini 账户授权
'
,
followSteps
:
'
请按照以下步骤完成 Gemini 账户的授权:
'
,
step1GenerateUrl
:
'
生成授权链接
'
,
generateAuthUrl
:
'
生成授权链接
'
,
projectIdLabel
:
'
Project ID(可选)
'
,
projectIdPlaceholder
:
'
例如:my-gcp-project 或 cloud-ai-companion-xxxxx
'
,
projectIdHint
:
'
留空则在兑换授权码后自动探测;若自动探测失败,可填写后重新生成授权链接再授权。
'
,
howToGetProjectId
:
'
如何获取
'
,
step2OpenUrl
:
'
在浏览器中打开链接并完成授权
'
,
openUrlDesc
:
'
请在新标签页中打开授权链接,登录您的 Google 账户并授权。
'
,
step3EnterCode
:
'
输入回调链接或 Code
'
,
authCodeDesc
:
'
授权完成后,复制浏览器跳转后的回调链接(推荐)或仅复制 code,粘贴到下方即可。
'
,
authCode
:
'
回调链接或 Code
'
,
authCodePlaceholder
:
'
方式1(推荐):粘贴回调链接
\n
方式2:仅粘贴 code 参数的值
'
,
authCodeHint
:
'
系统会自动从链接中解析 code/state。
'
,
redirectUri
:
'
Redirect URI
'
,
redirectUriHint
:
'
需要在 Google OAuth Client 中配置,且必须与此处完全一致。
'
,
confirmRedirectUri
:
'
我已在 Google OAuth Client 中配置了该 Redirect URI(必须完全一致)
'
,
invalidRedirectUri
:
'
Redirect URI 必须是合法的 http(s) URL
'
,
redirectUriNotConfirmed
:
'
请确认 Redirect URI 已在 Google OAuth Client 中正确配置
'
,
missingRedirectUri
:
'
缺少 Redirect URI
'
,
failedToGenerateUrl
:
'
生成 Gemini 授权链接失败
'
,
missingExchangeParams
:
'
缺少 code / session_id / state
'
,
failedToExchangeCode
:
'
Gemini 授权码兑换失败
'
,
missingProjectId
:
'
GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。
'
,
modelPassthrough
:
'
Gemini 直接转发模型
'
,
modelPassthroughDesc
:
'
所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。
'
,
stateWarningTitle
:
'
提示
'
,
stateWarningDesc
:
'
建议粘贴完整回调链接(包含 code 和 state)。
'
,
oauthTypeLabel
:
'
OAuth 类型
'
,
redirectUriNotConfirmed
:
'
请确认 Redirect URI 已在 Google OAuth Client 中正确配置
'
,
missingRedirectUri
:
'
缺少 Redirect URI
'
,
failedToGenerateUrl
:
'
生成 Gemini 授权链接失败
'
,
missingExchangeParams
:
'
缺少 code / session_id / state
'
,
failedToExchangeCode
:
'
Gemini 授权码兑换失败
'
,
missingProjectId
:
'
GCP Project ID 获取失败:您的 Google 账号未关联有效的 GCP 项目。请前往 Google Cloud Console 激活 GCP 并绑定信用卡,或在授权时手动填写 Project ID。
'
,
modelPassthrough
:
'
Gemini 直接转发模型
'
,
modelPassthroughDesc
:
'
所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。
'
,
stateWarningTitle
:
'
提示
'
,
stateWarningDesc
:
'
建议粘贴完整回调链接(包含 code 和 state)。
'
,
oauthTypeLabel
:
'
OAuth 类型
'
,
needsProjectId
:
'
内置授权(Code Assist)
'
,
needsProjectIdDesc
:
'
需要 GCP 项目与 Project ID
'
,
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
'
},
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
'
},
// Antigravity specific
antigravity
:
{
title
:
'
Antigravity 账户授权
'
,
...
...
@@ -1723,7 +1780,7 @@ export default {
missingExchangeParams
:
'
缺少 code / session_id / state
'
,
failedToExchangeCode
:
'
Antigravity 授权码兑换失败
'
}
},
},
// Gemini specific (platform-wide)
gemini
:
{
helpButton
:
'
使用帮助
'
,
...
...
@@ -1738,7 +1795,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
'
,
...
...
@@ -1882,9 +1940,9 @@ export default {
outputCopied
:
'
输出已复制
'
,
startingTestForAccount
:
'
开始测试账号:{name}
'
,
testAccountTypeLabel
:
'
账号类型:{type}
'
,
selectTestModel
:
'
选择测试模型
'
,
testModel
:
'
测试模型
'
,
testPrompt
:
'
提示词:"hi"
'
,
selectTestModel
:
'
选择测试模型
'
,
testModel
:
'
测试模型
'
,
testPrompt
:
'
提示词:"hi"
'
,
// Stats Modal
viewStats
:
'
查看统计
'
,
usageStatistics
:
'
使用统计
'
,
...
...
@@ -2567,7 +2625,7 @@ export default {
internal
:
'
内部
'
},
total
:
'
总计:
'
,
searchPlaceholder
:
'
搜索 request_id / client_request_id / message
'
,
searchPlaceholder
:
'
搜索 request_id / client_request_id / message
'
},
// Error Detail Modal
errorDetail
:
{
...
...
@@ -2998,7 +3056,8 @@ export default {
ignoreCountTokensErrors
:
'
忽略 count_tokens 错误
'
,
ignoreCountTokensErrorsHint
:
'
启用后,count_tokens 请求的错误将不会写入错误日志。
'
,
ignoreContextCanceled
:
'
忽略客户端断连错误
'
,
ignoreContextCanceledHint
:
'
启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。
'
,
ignoreContextCanceledHint
:
'
启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。
'
,
ignoreNoAvailableAccounts
:
'
忽略无可用账号错误
'
,
ignoreNoAvailableAccountsHint
:
'
启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。
'
,
ignoreInvalidApiKeyErrors
:
'
忽略无效 API Key 错误
'
,
...
...
@@ -3118,7 +3177,8 @@ export default {
siteKeyHint
:
'
从 Cloudflare Dashboard 获取
'
,
cloudflareDashboard
:
'
Cloudflare Dashboard
'
,
secretKeyHint
:
'
服务端验证密钥(请保密)
'
,
secretKeyConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
},
secretKeyConfiguredHint
:
'
密钥已配置,留空以保留当前值。
'
},
linuxdo
:
{
title
:
'
LinuxDo Connect 登录
'
,
description
:
'
配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录
'
,
...
...
@@ -3172,9 +3232,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"按钮
'
},
...
...
@@ -3411,131 +3474,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 @
4cce21b1
...
...
@@ -381,9 +381,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
...
...
@@ -395,6 +398,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
{
...
...
@@ -403,6 +408,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/admin/UsersView.vue
View file @
4cce21b1
...
...
@@ -300,8 +300,29 @@
</span>
</
template
>
<
template
#cell-balance=
"{ value }"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
value
.
toFixed
(
2
)
}}
</span>
<
template
#cell-balance=
"{ value, row }"
>
<div
class=
"flex items-center gap-2"
>
<div
class=
"group relative"
>
<button
class=
"font-medium text-gray-900 underline decoration-dashed decoration-gray-300 underline-offset-4 transition-colors hover:text-primary-600 dark:text-white dark:decoration-dark-500 dark:hover:text-primary-400"
@
click=
"handleBalanceHistory(row)"
>
$
{{
value
.
toFixed
(
2
)
}}
</button>
<!-- Instant tooltip -->
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity duration-75 group-hover:opacity-100 dark:bg-dark-600"
>
{{
t
(
'
admin.users.balanceHistoryTip
'
)
}}
<div
class=
"absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-dark-600"
></div>
</div>
</div>
<button
@
click.stop=
"handleDeposit(row)"
class=
"rounded px-2 py-0.5 text-xs font-medium text-emerald-600 transition-colors hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20"
:title=
"t('admin.users.deposit')"
>
{{
t
(
'
admin.users.deposit
'
)
}}
</button>
</div>
</
template
>
<
template
#cell-usage=
"{ row }"
>
...
...
@@ -456,6 +477,15 @@
{{
t
(
'
admin.users.withdraw
'
)
}}
</button>
<!-- Balance History -->
<button
@
click=
"handleBalanceHistory(user); closeActionMenu()"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<Icon
name=
"dollar"
size=
"sm"
class=
"text-gray-400"
:stroke-width=
"2"
/>
{{
t
(
'
admin.users.balanceHistory
'
)
}}
</button>
<div
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Delete (not for admin) -->
...
...
@@ -479,6 +509,7 @@
<UserApiKeysModal
:show=
"showApiKeysModal"
:user=
"viewingUser"
@
close=
"closeApiKeysModal"
/>
<UserAllowedGroupsModal
:show=
"showAllowedGroupsModal"
:user=
"allowedGroupsUser"
@
close=
"closeAllowedGroupsModal"
@
success=
"loadUsers"
/>
<UserBalanceModal
:show=
"showBalanceModal"
:user=
"balanceUser"
:operation=
"balanceOperation"
@
close=
"closeBalanceModal"
@
success=
"loadUsers"
/>
<UserBalanceHistoryModal
:show=
"showBalanceHistoryModal"
:user=
"balanceHistoryUser"
@
close=
"closeBalanceHistoryModal"
@
deposit=
"handleDepositFromHistory"
@
withdraw=
"handleWithdrawFromHistory"
/>
<UserAttributesConfigModal
:show=
"showAttributesModal"
@
close=
"handleAttributesModalClose"
/>
</AppLayout>
</template>
...
...
@@ -509,6 +540,7 @@ import UserEditModal from '@/components/admin/user/UserEditModal.vue'
import
UserApiKeysModal
from
'
@/components/admin/user/UserApiKeysModal.vue
'
import
UserAllowedGroupsModal
from
'
@/components/admin/user/UserAllowedGroupsModal.vue
'
import
UserBalanceModal
from
'
@/components/admin/user/UserBalanceModal.vue
'
import
UserBalanceHistoryModal
from
'
@/components/admin/user/UserBalanceHistoryModal.vue
'
const
appStore
=
useAppStore
()
...
...
@@ -828,6 +860,10 @@ const showBalanceModal = ref(false)
const
balanceUser
=
ref
<
AdminUser
|
null
>
(
null
)
const
balanceOperation
=
ref
<
'
add
'
|
'
subtract
'
>
(
'
add
'
)
// Balance History modal state
const
showBalanceHistoryModal
=
ref
(
false
)
const
balanceHistoryUser
=
ref
<
AdminUser
|
null
>
(
null
)
// 计算剩余天数
const
getDaysRemaining
=
(
expiresAt
:
string
):
number
=>
{
const
now
=
new
Date
()
...
...
@@ -1078,6 +1114,30 @@ const closeBalanceModal = () => {
balanceUser
.
value
=
null
}
const
handleBalanceHistory
=
(
user
:
AdminUser
)
=>
{
balanceHistoryUser
.
value
=
user
showBalanceHistoryModal
.
value
=
true
}
const
closeBalanceHistoryModal
=
()
=>
{
showBalanceHistoryModal
.
value
=
false
balanceHistoryUser
.
value
=
null
}
// Handle deposit from balance history modal
const
handleDepositFromHistory
=
()
=>
{
if
(
balanceHistoryUser
.
value
)
{
handleDeposit
(
balanceHistoryUser
.
value
)
}
}
// Handle withdraw from balance history modal
const
handleWithdrawFromHistory
=
()
=>
{
if
(
balanceHistoryUser
.
value
)
{
handleWithdraw
(
balanceHistoryUser
.
value
)
}
}
// 滚动时关闭菜单
const
handleScroll
=
()
=>
{
closeActionMenu
()
...
...
frontend/src/views/user/KeysView.vue
View file @
4cce21b1
...
...
@@ -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
3
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