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
c89bbf51
Unverified
Commit
c89bbf51
authored
Feb 03, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 03, 2026
Browse files
Merge pull request #458 from bayma888/feature/admin-user-balance-history
feat(admin): 管理员可查看每个用户充值和并发变动记录、点击余额可直接查看、优化弹框UI
parents
bb3df578
730d2a9a
Changes
16
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/admin_service_stub_test.go
View file @
c89bbf51
...
...
@@ -290,5 +290,9 @@ func (s *stubAdminService) ExpireRedeemCode(ctx context.Context, id int64) (*ser
return
&
code
,
nil
}
func
(
s
*
stubAdminService
)
GetUserBalanceHistory
(
ctx
context
.
Context
,
userID
int64
,
page
,
pageSize
int
,
codeType
string
)
([]
service
.
RedeemCode
,
int64
,
float64
,
error
)
{
return
s
.
redeems
,
int64
(
len
(
s
.
redeems
)),
100.0
,
nil
}
// Ensure stub implements interface.
var
_
service
.
AdminService
=
(
*
stubAdminService
)(
nil
)
backend/internal/handler/admin/user_handler.go
View file @
c89bbf51
...
...
@@ -277,3 +277,44 @@ func (h *UserHandler) GetUserUsage(c *gin.Context) {
response
.
Success
(
c
,
stats
)
}
// GetBalanceHistory handles getting user's balance/concurrency change history
// GET /api/v1/admin/users/:id/balance-history
// Query params:
// - type: filter by record type (balance, admin_balance, concurrency, admin_concurrency, subscription)
func
(
h
*
UserHandler
)
GetBalanceHistory
(
c
*
gin
.
Context
)
{
userID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid user ID"
)
return
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
codeType
:=
c
.
Query
(
"type"
)
codes
,
total
,
totalRecharged
,
err
:=
h
.
adminService
.
GetUserBalanceHistory
(
c
.
Request
.
Context
(),
userID
,
page
,
pageSize
,
codeType
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Convert to admin DTO (includes notes field for admin visibility)
out
:=
make
([]
dto
.
AdminRedeemCode
,
0
,
len
(
codes
))
for
i
:=
range
codes
{
out
=
append
(
out
,
*
dto
.
RedeemCodeFromServiceAdmin
(
&
codes
[
i
]))
}
// Custom response with total_recharged alongside pagination
pages
:=
int
((
total
+
int64
(
pageSize
)
-
1
)
/
int64
(
pageSize
))
if
pages
<
1
{
pages
=
1
}
response
.
Success
(
c
,
gin
.
H
{
"items"
:
out
,
"total"
:
total
,
"page"
:
page
,
"page_size"
:
pageSize
,
"pages"
:
pages
,
"total_recharged"
:
totalRecharged
,
})
}
backend/internal/repository/redeem_code_repo.go
View file @
c89bbf51
...
...
@@ -202,6 +202,57 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
return
redeemCodeEntitiesToService
(
codes
),
nil
}
// ListByUserPaginated returns paginated balance/concurrency history for a user.
// Supports optional type filter (e.g. "balance", "admin_balance", "concurrency", "admin_concurrency", "subscription").
func
(
r
*
redeemCodeRepository
)
ListByUserPaginated
(
ctx
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
,
codeType
string
)
([]
service
.
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
{
q
:=
r
.
client
.
RedeemCode
.
Query
()
.
Where
(
redeemcode
.
UsedByEQ
(
userID
))
// Optional type filter
if
codeType
!=
""
{
q
=
q
.
Where
(
redeemcode
.
TypeEQ
(
codeType
))
}
total
,
err
:=
q
.
Count
(
ctx
)
if
err
!=
nil
{
return
nil
,
nil
,
err
}
codes
,
err
:=
q
.
WithGroup
()
.
Offset
(
params
.
Offset
())
.
Limit
(
params
.
Limit
())
.
Order
(
dbent
.
Desc
(
redeemcode
.
FieldUsedAt
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
nil
,
err
}
return
redeemCodeEntitiesToService
(
codes
),
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
}
// SumPositiveBalanceByUser returns total recharged amount (sum of value > 0 where type is balance/admin_balance).
func
(
r
*
redeemCodeRepository
)
SumPositiveBalanceByUser
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
error
)
{
var
result
[]
struct
{
Sum
float64
`json:"sum"`
}
err
:=
r
.
client
.
RedeemCode
.
Query
()
.
Where
(
redeemcode
.
UsedByEQ
(
userID
),
redeemcode
.
ValueGT
(
0
),
redeemcode
.
TypeIn
(
"balance"
,
"admin_balance"
),
)
.
Aggregate
(
dbent
.
As
(
dbent
.
Sum
(
redeemcode
.
FieldValue
),
"sum"
))
.
Scan
(
ctx
,
&
result
)
if
err
!=
nil
{
return
0
,
err
}
if
len
(
result
)
==
0
{
return
0
,
nil
}
return
result
[
0
]
.
Sum
,
nil
}
func
redeemCodeEntityToService
(
m
*
dbent
.
RedeemCode
)
*
service
.
RedeemCode
{
if
m
==
nil
{
return
nil
...
...
backend/internal/server/api_contract_test.go
View file @
c89bbf51
...
...
@@ -1150,6 +1150,14 @@ func (r *stubRedeemCodeRepo) ListByUser(ctx context.Context, userID int64, limit
return
append
([]
service
.
RedeemCode
(
nil
),
codes
...
),
nil
}
func
(
stubRedeemCodeRepo
)
ListByUserPaginated
(
ctx
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
,
codeType
string
)
([]
service
.
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
{
return
nil
,
nil
,
errors
.
New
(
"not implemented"
)
}
func
(
stubRedeemCodeRepo
)
SumPositiveBalanceByUser
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
error
)
{
return
0
,
errors
.
New
(
"not implemented"
)
}
type
stubUserSubscriptionRepo
struct
{
byUser
map
[
int64
][]
service
.
UserSubscription
activeByUser
map
[
int64
][]
service
.
UserSubscription
...
...
backend/internal/server/routes/admin.go
View file @
c89bbf51
...
...
@@ -175,6 +175,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users
.
POST
(
"/:id/balance"
,
h
.
Admin
.
User
.
UpdateBalance
)
users
.
GET
(
"/:id/api-keys"
,
h
.
Admin
.
User
.
GetUserAPIKeys
)
users
.
GET
(
"/:id/usage"
,
h
.
Admin
.
User
.
GetUserUsage
)
users
.
GET
(
"/:id/balance-history"
,
h
.
Admin
.
User
.
GetBalanceHistory
)
// User attribute values
users
.
GET
(
"/:id/attributes"
,
h
.
Admin
.
UserAttribute
.
GetUserAttributes
)
...
...
backend/internal/service/admin_service.go
View file @
c89bbf51
...
...
@@ -22,6 +22,10 @@ type AdminService interface {
UpdateUserBalance
(
ctx
context
.
Context
,
userID
int64
,
balance
float64
,
operation
string
,
notes
string
)
(
*
User
,
error
)
GetUserAPIKeys
(
ctx
context
.
Context
,
userID
int64
,
page
,
pageSize
int
)
([]
APIKey
,
int64
,
error
)
GetUserUsageStats
(
ctx
context
.
Context
,
userID
int64
,
period
string
)
(
any
,
error
)
// GetUserBalanceHistory returns paginated balance/concurrency change records for a user.
// codeType is optional - pass empty string to return all types.
// Also returns totalRecharged (sum of all positive balance top-ups).
GetUserBalanceHistory
(
ctx
context
.
Context
,
userID
int64
,
page
,
pageSize
int
,
codeType
string
)
([]
RedeemCode
,
int64
,
float64
,
error
)
// Group management
ListGroups
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
Group
,
int64
,
error
)
...
...
@@ -526,6 +530,21 @@ func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64,
},
nil
}
// GetUserBalanceHistory returns paginated balance/concurrency change records for a user.
func
(
s
*
adminServiceImpl
)
GetUserBalanceHistory
(
ctx
context
.
Context
,
userID
int64
,
page
,
pageSize
int
,
codeType
string
)
([]
RedeemCode
,
int64
,
float64
,
error
)
{
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
codes
,
result
,
err
:=
s
.
redeemCodeRepo
.
ListByUserPaginated
(
ctx
,
userID
,
params
,
codeType
)
if
err
!=
nil
{
return
nil
,
0
,
0
,
err
}
// Aggregate total recharged amount (only once, regardless of type filter)
totalRecharged
,
err
:=
s
.
redeemCodeRepo
.
SumPositiveBalanceByUser
(
ctx
,
userID
)
if
err
!=
nil
{
return
nil
,
0
,
0
,
err
}
return
codes
,
result
.
Total
,
totalRecharged
,
nil
}
// Group management implementations
func
(
s
*
adminServiceImpl
)
ListGroups
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
Group
,
int64
,
error
)
{
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
...
...
backend/internal/service/admin_service_delete_test.go
View file @
c89bbf51
...
...
@@ -282,6 +282,14 @@ func (s *redeemRepoStub) ListByUser(ctx context.Context, userID int64, limit int
panic
(
"unexpected ListByUser call"
)
}
func
(
s
*
redeemRepoStub
)
ListByUserPaginated
(
ctx
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
,
codeType
string
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
{
panic
(
"unexpected ListByUserPaginated call"
)
}
func
(
s
*
redeemRepoStub
)
SumPositiveBalanceByUser
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
error
)
{
panic
(
"unexpected SumPositiveBalanceByUser call"
)
}
type
subscriptionInvalidateCall
struct
{
userID
int64
groupID
int64
...
...
backend/internal/service/admin_service_search_test.go
View file @
c89bbf51
...
...
@@ -152,6 +152,14 @@ func (s *redeemRepoStubForAdminList) ListWithFilters(_ context.Context, params p
return
s
.
listWithFiltersCodes
,
result
,
nil
}
func
(
s
*
redeemRepoStubForAdminList
)
ListByUserPaginated
(
_
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
,
codeType
string
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
{
panic
(
"unexpected ListByUserPaginated call"
)
}
func
(
s
*
redeemRepoStubForAdminList
)
SumPositiveBalanceByUser
(
_
context
.
Context
,
userID
int64
)
(
float64
,
error
)
{
panic
(
"unexpected SumPositiveBalanceByUser call"
)
}
func
TestAdminService_ListAccounts_WithSearch
(
t
*
testing
.
T
)
{
t
.
Run
(
"search 参数正常传递到 repository 层"
,
func
(
t
*
testing
.
T
)
{
repo
:=
&
accountRepoStubForAdminList
{
...
...
backend/internal/service/redeem_service.go
View file @
c89bbf51
...
...
@@ -49,6 +49,11 @@ type RedeemCodeRepository interface {
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
codeType
,
status
,
search
string
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
ListByUser
(
ctx
context
.
Context
,
userID
int64
,
limit
int
)
([]
RedeemCode
,
error
)
// ListByUserPaginated returns paginated balance/concurrency history for a specific user.
// codeType filter is optional - pass empty string to return all types.
ListByUserPaginated
(
ctx
context
.
Context
,
userID
int64
,
params
pagination
.
PaginationParams
,
codeType
string
)
([]
RedeemCode
,
*
pagination
.
PaginationResult
,
error
)
// SumPositiveBalanceByUser returns the total recharged amount (sum of positive balance values) for a user.
SumPositiveBalanceByUser
(
ctx
context
.
Context
,
userID
int64
)
(
float64
,
error
)
}
// GenerateCodesRequest 生成兑换码请求
...
...
frontend/src/api/admin/index.ts
View file @
c89bbf51
...
...
@@ -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 @
c89bbf51
...
...
@@ -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/components/admin/user/UserBalanceHistoryModal.vue
0 → 100644
View file @
c89bbf51
<
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 @
c89bbf51
...
...
@@ -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 @
c89bbf51
...
...
@@ -843,6 +843,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 @
c89bbf51
...
...
@@ -894,6 +894,20 @@ export default {
failedToDeposit
:
'
充值失败
'
,
failedToWithdraw
:
'
退款失败
'
,
useDepositWithdrawButtons
:
'
请使用充值/退款按钮调整余额
'
,
// 余额变动记录
balanceHistory
:
'
充值记录
'
,
balanceHistoryTip
:
'
点击查看充值记录
'
,
balanceHistoryTitle
:
'
用户充值和并发变动记录
'
,
noBalanceHistory
:
'
暂无变动记录
'
,
allTypes
:
'
全部类型
'
,
typeBalance
:
'
余额(兑换码)
'
,
typeAdminBalance
:
'
余额(管理员调整)
'
,
typeConcurrency
:
'
并发(兑换码)
'
,
typeAdminConcurrency
:
'
并发(管理员调整)
'
,
typeSubscription
:
'
订阅
'
,
failedToLoadBalanceHistory
:
'
加载余额记录失败
'
,
createdAt
:
'
创建时间
'
,
totalRecharged
:
'
总充值
'
,
// Settings Dropdowns
filterSettings
:
'
筛选设置
'
,
columnSettings
:
'
列设置
'
,
...
...
frontend/src/views/admin/UsersView.vue
View file @
c89bbf51
...
...
@@ -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
()
...
...
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