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
Expand all
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 {
...
@@ -62,3 +62,6 @@ export {
}
}
export
default
adminAPI
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(
...
@@ -174,6 +174,53 @@ export async function getUserUsageStats(
return
data
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
=
{
export
const
usersAPI
=
{
list
,
list
,
getById
,
getById
,
...
@@ -184,7 +231,8 @@ export const usersAPI = {
...
@@ -184,7 +231,8 @@ export const usersAPI = {
updateConcurrency
,
updateConcurrency
,
toggleStatus
,
toggleStatus
,
getUserApiKeys
,
getUserApiKeys
,
getUserUsageStats
getUserUsageStats
,
getUserBalanceHistory
}
}
export
default
usersAPI
export
default
usersAPI
frontend/src/api/keys.ts
View file @
4cce21b1
...
@@ -44,6 +44,8 @@ export async function getById(id: number): Promise<ApiKey> {
...
@@ -44,6 +44,8 @@ export async function getById(id: number): Promise<ApiKey> {
* @param customKey - Optional custom key value
* @param customKey - Optional custom key value
* @param ipWhitelist - Optional IP whitelist
* @param ipWhitelist - Optional IP whitelist
* @param ipBlacklist - Optional IP blacklist
* @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
* @returns Created API key
*/
*/
export
async
function
create
(
export
async
function
create
(
...
@@ -51,7 +53,9 @@ export async function create(
...
@@ -51,7 +53,9 @@ export async function create(
groupId
?:
number
|
null
,
groupId
?:
number
|
null
,
customKey
?:
string
,
customKey
?:
string
,
ipWhitelist
?:
string
[],
ipWhitelist
?:
string
[],
ipBlacklist
?:
string
[]
ipBlacklist
?:
string
[],
quota
?:
number
,
expiresInDays
?:
number
):
Promise
<
ApiKey
>
{
):
Promise
<
ApiKey
>
{
const
payload
:
CreateApiKeyRequest
=
{
name
}
const
payload
:
CreateApiKeyRequest
=
{
name
}
if
(
groupId
!==
undefined
)
{
if
(
groupId
!==
undefined
)
{
...
@@ -66,6 +70,12 @@ export async function create(
...
@@ -66,6 +70,12 @@ export async function create(
if
(
ipBlacklist
&&
ipBlacklist
.
length
>
0
)
{
if
(
ipBlacklist
&&
ipBlacklist
.
length
>
0
)
{
payload
.
ip_blacklist
=
ipBlacklist
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
)
const
{
data
}
=
await
apiClient
.
post
<
ApiKey
>
(
'
/keys
'
,
payload
)
return
data
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 @@
...
@@ -4,6 +4,7 @@
<div
<div
v-if=
"show"
v-if=
"show"
class=
"modal-overlay"
class=
"modal-overlay"
:style=
"zIndexStyle"
:aria-labelledby=
"dialogId"
:aria-labelledby=
"dialogId"
role=
"dialog"
role=
"dialog"
aria-modal=
"true"
aria-modal=
"true"
...
@@ -60,6 +61,7 @@ interface Props {
...
@@ -60,6 +61,7 @@ interface Props {
width
?:
DialogWidth
width
?:
DialogWidth
closeOnEscape
?:
boolean
closeOnEscape
?:
boolean
closeOnClickOutside
?:
boolean
closeOnClickOutside
?:
boolean
zIndex
?:
number
}
}
interface
Emits
{
interface
Emits
{
...
@@ -69,11 +71,17 @@ interface Emits {
...
@@ -69,11 +71,17 @@ interface Emits {
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
width
:
'
normal
'
,
width
:
'
normal
'
,
closeOnEscape
:
true
,
closeOnEscape
:
true
,
closeOnClickOutside
:
false
closeOnClickOutside
:
false
,
zIndex
:
50
})
})
const
emit
=
defineEmits
<
Emits
>
()
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
(()
=>
{
const
widthClasses
=
computed
(()
=>
{
// Width guidance: narrow=confirm/short prompts, normal=standard forms,
// Width guidance: narrow=confirm/short prompts, normal=standard forms,
// wide=multi-section forms or rich content, extra-wide=analytics/tables,
// 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 {
...
@@ -407,6 +407,7 @@ export default {
usage
:
'
Usage
'
,
usage
:
'
Usage
'
,
today
:
'
Today
'
,
today
:
'
Today
'
,
total
:
'
Total
'
,
total
:
'
Total
'
,
quota
:
'
Quota
'
,
useKey
:
'
Use Key
'
,
useKey
:
'
Use Key
'
,
useKeyModal
:
{
useKeyModal
:
{
title
:
'
Use API Key
'
,
title
:
'
Use API Key
'
,
...
@@ -470,6 +471,33 @@ export default {
...
@@ -470,6 +471,33 @@ export default {
geminiCli
:
'
Gemini CLI
'
,
geminiCli
:
'
Gemini CLI
'
,
geminiCliDesc
:
'
Import as Gemini CLI configuration
'
,
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
// Usage
...
@@ -843,6 +871,20 @@ export default {
...
@@ -843,6 +871,20 @@ export default {
failedToDeposit
:
'
Failed to deposit
'
,
failedToDeposit
:
'
Failed to deposit
'
,
failedToWithdraw
:
'
Failed to withdraw
'
,
failedToWithdraw
:
'
Failed to withdraw
'
,
useDepositWithdrawButtons
:
'
Please use deposit/withdraw buttons to adjust balance
'
,
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
:
{
roles
:
{
admin
:
'
Admin
'
,
admin
:
'
Admin
'
,
user
:
'
User
'
user
:
'
User
'
...
...
frontend/src/i18n/locales/zh.ts
View file @
4cce21b1
This diff is collapsed.
Click to expand it.
frontend/src/types/index.ts
View file @
4cce21b1
...
@@ -381,9 +381,12 @@ export interface ApiKey {
...
@@ -381,9 +381,12 @@ export interface ApiKey {
key
:
string
key
:
string
name
:
string
name
:
string
group_id
:
number
|
null
group_id
:
number
|
null
status
:
'
active
'
|
'
inactive
'
status
:
'
active
'
|
'
inactive
'
|
'
quota_exhausted
'
|
'
expired
'
ip_whitelist
:
string
[]
ip_whitelist
:
string
[]
ip_blacklist
:
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
created_at
:
string
updated_at
:
string
updated_at
:
string
group
?:
Group
group
?:
Group
...
@@ -395,6 +398,8 @@ export interface CreateApiKeyRequest {
...
@@ -395,6 +398,8 @@ export interface CreateApiKeyRequest {
custom_key
?:
string
// Optional custom API Key
custom_key
?:
string
// Optional custom API Key
ip_whitelist
?:
string
[]
ip_whitelist
?:
string
[]
ip_blacklist
?:
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
{
export
interface
UpdateApiKeyRequest
{
...
@@ -403,6 +408,9 @@ export interface UpdateApiKeyRequest {
...
@@ -403,6 +408,9 @@ export interface UpdateApiKeyRequest {
status
?:
'
active
'
|
'
inactive
'
status
?:
'
active
'
|
'
inactive
'
ip_whitelist
?:
string
[]
ip_whitelist
?:
string
[]
ip_blacklist
?:
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
{
export
interface
CreateGroupRequest
{
...
...
frontend/src/views/admin/UsersView.vue
View file @
4cce21b1
...
@@ -300,8 +300,29 @@
...
@@ -300,8 +300,29 @@
</span>
</span>
</
template
>
</
template
>
<
template
#cell-balance=
"{ value }"
>
<
template
#cell-balance=
"{ value, row }"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
value
.
toFixed
(
2
)
}}
</span>
<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
>
<
template
#cell-usage=
"{ row }"
>
<
template
#cell-usage=
"{ row }"
>
...
@@ -456,6 +477,15 @@
...
@@ -456,6 +477,15 @@
{{
t
(
'
admin.users.withdraw
'
)
}}
{{
t
(
'
admin.users.withdraw
'
)
}}
</button>
</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>
<div
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Delete (not for admin) -->
<!-- Delete (not for admin) -->
...
@@ -479,6 +509,7 @@
...
@@ -479,6 +509,7 @@
<UserApiKeysModal
:show=
"showApiKeysModal"
:user=
"viewingUser"
@
close=
"closeApiKeysModal"
/>
<UserApiKeysModal
:show=
"showApiKeysModal"
:user=
"viewingUser"
@
close=
"closeApiKeysModal"
/>
<UserAllowedGroupsModal
:show=
"showAllowedGroupsModal"
:user=
"allowedGroupsUser"
@
close=
"closeAllowedGroupsModal"
@
success=
"loadUsers"
/>
<UserAllowedGroupsModal
:show=
"showAllowedGroupsModal"
:user=
"allowedGroupsUser"
@
close=
"closeAllowedGroupsModal"
@
success=
"loadUsers"
/>
<UserBalanceModal
:show=
"showBalanceModal"
:user=
"balanceUser"
:operation=
"balanceOperation"
@
close=
"closeBalanceModal"
@
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"
/>
<UserAttributesConfigModal
:show=
"showAttributesModal"
@
close=
"handleAttributesModalClose"
/>
</AppLayout>
</AppLayout>
</template>
</template>
...
@@ -509,6 +540,7 @@ import UserEditModal from '@/components/admin/user/UserEditModal.vue'
...
@@ -509,6 +540,7 @@ import UserEditModal from '@/components/admin/user/UserEditModal.vue'
import
UserApiKeysModal
from
'
@/components/admin/user/UserApiKeysModal.vue
'
import
UserApiKeysModal
from
'
@/components/admin/user/UserApiKeysModal.vue
'
import
UserAllowedGroupsModal
from
'
@/components/admin/user/UserAllowedGroupsModal.vue
'
import
UserAllowedGroupsModal
from
'
@/components/admin/user/UserAllowedGroupsModal.vue
'
import
UserBalanceModal
from
'
@/components/admin/user/UserBalanceModal.vue
'
import
UserBalanceModal
from
'
@/components/admin/user/UserBalanceModal.vue
'
import
UserBalanceHistoryModal
from
'
@/components/admin/user/UserBalanceHistoryModal.vue
'
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
...
@@ -828,6 +860,10 @@ const showBalanceModal = ref(false)
...
@@ -828,6 +860,10 @@ const showBalanceModal = ref(false)
const
balanceUser
=
ref
<
AdminUser
|
null
>
(
null
)
const
balanceUser
=
ref
<
AdminUser
|
null
>
(
null
)
const
balanceOperation
=
ref
<
'
add
'
|
'
subtract
'
>
(
'
add
'
)
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
getDaysRemaining
=
(
expiresAt
:
string
):
number
=>
{
const
now
=
new
Date
()
const
now
=
new
Date
()
...
@@ -1078,6 +1114,30 @@ const closeBalanceModal = () => {
...
@@ -1078,6 +1114,30 @@ const closeBalanceModal = () => {
balanceUser
.
value
=
null
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
=
()
=>
{
const
handleScroll
=
()
=>
{
closeActionMenu
()
closeActionMenu
()
...
...
frontend/src/views/user/KeysView.vue
View file @
4cce21b1
...
@@ -108,12 +108,53 @@
...
@@ -108,12 +108,53 @@
$
{{
(
usageStats
[
row
.
id
]?.
total_actual_cost
??
0
).
toFixed
(
4
)
}}
$
{{
(
usageStats
[
row
.
id
]?.
total_actual_cost
??
0
).
toFixed
(
4
)
}}
</span>
</span>
</div>
</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>
</div>
</
template
>
</
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 }"
>
<
template
#cell-status=
"{ value }"
>
<span
:class=
"['badge', value === 'active' ? 'badge-success' : 'badge-gray']"
>
<span
:class=
"[
{{
t
(
'
admin.accounts.status.
'
+
value
)
}}
'badge',
value === 'active' ? 'badge-success' :
value === 'quota_exhausted' ? 'badge-warning' :
value === 'expired' ? 'badge-danger' :
'badge-gray'
]"
>
{{
t
(
'
keys.status.
'
+
value
)
}}
</span>
</span>
</
template
>
</
template
>
...
@@ -334,6 +375,145 @@
...
@@ -334,6 +375,145 @@
</div>
</div>
</div>
</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>
</form>
<
template
#footer
>
<
template
#footer
>
<div
class=
"flex justify-end gap-3"
>
<div
class=
"flex justify-end gap-3"
>
...
@@ -391,6 +571,18 @@
...
@@ -391,6 +571,18 @@
@
cancel=
"showDeleteDialog = false"
@
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 -->
<!-- Use Key Modal -->
<UseKeyModal
<UseKeyModal
:show=
"showUseKeyModal"
:show=
"showUseKeyModal"
...
@@ -514,6 +706,13 @@ import type { Column } from '@/components/common/types'
...
@@ -514,6 +706,13 @@ import type { Column } from '@/components/common/types'
import
type
{
BatchApiKeyUsageStats
}
from
'
@/api/usage
'
import
type
{
BatchApiKeyUsageStats
}
from
'
@/api/usage
'
import
{
formatDateTime
}
from
'
@/utils/format
'
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
{
interface
GroupOption
{
value
:
number
value
:
number
label
:
string
label
:
string
...
@@ -532,6 +731,7 @@ const columns = computed<Column[]>(() => [
...
@@ -532,6 +731,7 @@ const columns = computed<Column[]>(() => [
{
key
:
'
key
'
,
label
:
t
(
'
keys.apiKey
'
),
sortable
:
false
},
{
key
:
'
key
'
,
label
:
t
(
'
keys.apiKey
'
),
sortable
:
false
},
{
key
:
'
group
'
,
label
:
t
(
'
keys.group
'
),
sortable
:
false
},
{
key
:
'
group
'
,
label
:
t
(
'
keys.group
'
),
sortable
:
false
},
{
key
:
'
usage
'
,
label
:
t
(
'
keys.usage
'
),
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
:
'
status
'
,
label
:
t
(
'
common.status
'
),
sortable
:
true
},
{
key
:
'
created_at
'
,
label
:
t
(
'
keys.created
'
),
sortable
:
true
},
{
key
:
'
created_at
'
,
label
:
t
(
'
keys.created
'
),
sortable
:
true
},
{
key
:
'
actions
'
,
label
:
t
(
'
common.actions
'
),
sortable
:
false
}
{
key
:
'
actions
'
,
label
:
t
(
'
common.actions
'
),
sortable
:
false
}
...
@@ -553,6 +753,7 @@ const pagination = ref({
...
@@ -553,6 +753,7 @@ const pagination = ref({
const
showCreateModal
=
ref
(
false
)
const
showCreateModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showEditModal
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showDeleteDialog
=
ref
(
false
)
const
showResetQuotaDialog
=
ref
(
false
)
const
showUseKeyModal
=
ref
(
false
)
const
showUseKeyModal
=
ref
(
false
)
const
showCcsClientSelect
=
ref
(
false
)
const
showCcsClientSelect
=
ref
(
false
)
const
pendingCcsRow
=
ref
<
ApiKey
|
null
>
(
null
)
const
pendingCcsRow
=
ref
<
ApiKey
|
null
>
(
null
)
...
@@ -587,7 +788,13 @@ const formData = ref({
...
@@ -587,7 +788,13 @@ const formData = ref({
custom_key
:
''
,
custom_key
:
''
,
enable_ip_restriction
:
false
,
enable_ip_restriction
:
false
,
ip_whitelist
:
''
,
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验证
// 自定义Key验证
...
@@ -724,15 +931,21 @@ const handlePageSizeChange = (pageSize: number) => {
...
@@ -724,15 +931,21 @@ const handlePageSizeChange = (pageSize: number) => {
const
editKey
=
(
key
:
ApiKey
)
=>
{
const
editKey
=
(
key
:
ApiKey
)
=>
{
selectedKey
.
value
=
key
selectedKey
.
value
=
key
const
hasIPRestriction
=
(
key
.
ip_whitelist
?.
length
>
0
)
||
(
key
.
ip_blacklist
?.
length
>
0
)
const
hasIPRestriction
=
(
key
.
ip_whitelist
?.
length
>
0
)
||
(
key
.
ip_blacklist
?.
length
>
0
)
const
hasExpiration
=
!!
key
.
expires_at
formData
.
value
=
{
formData
.
value
=
{
name
:
key
.
name
,
name
:
key
.
name
,
group_id
:
key
.
group_id
,
group_id
:
key
.
group_id
,
status
:
key
.
status
,
status
:
key
.
status
===
'
quota_exhausted
'
||
key
.
status
===
'
expired
'
?
'
inactive
'
:
key
.
status
,
use_custom_key
:
false
,
use_custom_key
:
false
,
custom_key
:
''
,
custom_key
:
''
,
enable_ip_restriction
:
hasIPRestriction
,
enable_ip_restriction
:
hasIPRestriction
,
ip_whitelist
:
(
key
.
ip_whitelist
||
[]).
join
(
'
\n
'
),
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
showEditModal
.
value
=
true
}
}
...
@@ -820,6 +1033,28 @@ const handleSubmit = async () => {
...
@@ -820,6 +1033,28 @@ const handleSubmit = async () => {
const
ipWhitelist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_whitelist
)
:
[]
const
ipWhitelist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_whitelist
)
:
[]
const
ipBlacklist
=
formData
.
value
.
enable_ip_restriction
?
parseIPList
(
formData
.
value
.
ip_blacklist
)
:
[]
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
submitting
.
value
=
true
try
{
try
{
if
(
showEditModal
.
value
&&
selectedKey
.
value
)
{
if
(
showEditModal
.
value
&&
selectedKey
.
value
)
{
...
@@ -828,12 +1063,22 @@ const handleSubmit = async () => {
...
@@ -828,12 +1063,22 @@ const handleSubmit = async () => {
group_id
:
formData
.
value
.
group_id
,
group_id
:
formData
.
value
.
group_id
,
status
:
formData
.
value
.
status
,
status
:
formData
.
value
.
status
,
ip_whitelist
:
ipWhitelist
,
ip_whitelist
:
ipWhitelist
,
ip_blacklist
:
ipBlacklist
ip_blacklist
:
ipBlacklist
,
quota
:
quota
,
expires_at
:
expiresAt
})
})
appStore
.
showSuccess
(
t
(
'
keys.keyUpdatedSuccess
'
))
appStore
.
showSuccess
(
t
(
'
keys.keyUpdatedSuccess
'
))
}
else
{
}
else
{
const
customKey
=
formData
.
value
.
use_custom_key
?
formData
.
value
.
custom_key
:
undefined
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
'
))
appStore
.
showSuccess
(
t
(
'
keys.keyCreatedSuccess
'
))
// Only advance tour if active, on submit step, and creation succeeded
// Only advance tour if active, on submit step, and creation succeeded
if
(
onboardingStore
.
isCurrentStep
(
'
[data-tour="key-form-submit"]
'
))
{
if
(
onboardingStore
.
isCurrentStep
(
'
[data-tour="key-form-submit"]
'
))
{
...
@@ -883,7 +1128,42 @@ const closeModals = () => {
...
@@ -883,7 +1128,42 @@ const closeModals = () => {
custom_key
:
''
,
custom_key
:
''
,
enable_ip_restriction
:
false
,
enable_ip_restriction
:
false
,
ip_whitelist
:
''
,
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