feat(admin): add user balance/concurrency history modal
- Add new API endpoint GET /admin/users/:id/balance-history with pagination and type filter - Add SumPositiveBalanceByUser for calculating total recharged amount - Create UserBalanceHistoryModal component with: - User info header (email, username, created_at, current balance, notes, total recharged) - Type filter dropdown (all/balance/admin_balance/concurrency/admin_concurrency/subscription) - Quick deposit/withdraw buttons - Paginated history list with icons and colored values - Add instant tooltip on balance column for better UX - Add z-index prop to BaseDialog for modal stacking control - Update i18n translations (zh/en)
This commit is contained in:
@@ -277,3 +277,44 @@ func (h *UserHandler) GetUserUsage(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, stats)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,6 +202,57 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
|
|||||||
return redeemCodeEntitiesToService(codes), nil
|
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 {
|
func redeemCodeEntityToService(m *dbent.RedeemCode) *service.RedeemCode {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
users.POST("/:id/balance", h.Admin.User.UpdateBalance)
|
users.POST("/:id/balance", h.Admin.User.UpdateBalance)
|
||||||
users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys)
|
users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys)
|
||||||
users.GET("/:id/usage", h.Admin.User.GetUserUsage)
|
users.GET("/:id/usage", h.Admin.User.GetUserUsage)
|
||||||
|
users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory)
|
||||||
|
|
||||||
// User attribute values
|
// User attribute values
|
||||||
users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes)
|
users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes)
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ type AdminService interface {
|
|||||||
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error)
|
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)
|
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]APIKey, int64, error)
|
||||||
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, 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
|
// Group management
|
||||||
ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool) ([]Group, int64, error)
|
ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool) ([]Group, int64, error)
|
||||||
@@ -522,6 +526,21 @@ func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64,
|
|||||||
}, nil
|
}, 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
|
// Group management implementations
|
||||||
func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool) ([]Group, int64, error) {
|
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}
|
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ type RedeemCodeRepository interface {
|
|||||||
List(ctx context.Context, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error)
|
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)
|
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)
|
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 生成兑换码请求
|
// GenerateCodesRequest 生成兑换码请求
|
||||||
|
|||||||
@@ -59,3 +59,6 @@ export {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default adminAPI
|
export default adminAPI
|
||||||
|
|
||||||
|
// Re-export types used by components
|
||||||
|
export type { BalanceHistoryItem } from './users'
|
||||||
|
|||||||
@@ -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 = {
|
|||||||
updateConcurrency,
|
updateConcurrency,
|
||||||
toggleStatus,
|
toggleStatus,
|
||||||
getUserApiKeys,
|
getUserApiKeys,
|
||||||
getUserUsageStats
|
getUserUsageStats,
|
||||||
|
getUserBalanceHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
export default usersAPI
|
export default usersAPI
|
||||||
|
|||||||
320
frontend/src/components/admin/user/UserBalanceHistoryModal.vue
Normal file
320
frontend/src/components/admin/user/UserBalanceHistoryModal.vue
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<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>
|
||||||
@@ -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 {
|
|||||||
width?: DialogWidth
|
width?: DialogWidth
|
||||||
closeOnEscape?: boolean
|
closeOnEscape?: boolean
|
||||||
closeOnClickOutside?: boolean
|
closeOnClickOutside?: boolean
|
||||||
|
zIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
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,
|
||||||
|
|||||||
@@ -832,6 +832,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'
|
||||||
|
|||||||
@@ -883,6 +883,20 @@ export default {
|
|||||||
failedToDeposit: '充值失败',
|
failedToDeposit: '充值失败',
|
||||||
failedToWithdraw: '退款失败',
|
failedToWithdraw: '退款失败',
|
||||||
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
|
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
|
||||||
|
// 余额变动记录
|
||||||
|
balanceHistory: '充值记录',
|
||||||
|
balanceHistoryTip: '点击查看充值记录',
|
||||||
|
balanceHistoryTitle: '用户充值和并发变动记录',
|
||||||
|
noBalanceHistory: '暂无变动记录',
|
||||||
|
allTypes: '全部类型',
|
||||||
|
typeBalance: '余额(兑换码)',
|
||||||
|
typeAdminBalance: '余额(管理员调整)',
|
||||||
|
typeConcurrency: '并发(兑换码)',
|
||||||
|
typeAdminConcurrency: '并发(管理员调整)',
|
||||||
|
typeSubscription: '订阅',
|
||||||
|
failedToLoadBalanceHistory: '加载余额记录失败',
|
||||||
|
createdAt: '创建时间',
|
||||||
|
totalRecharged: '总充值',
|
||||||
// Settings Dropdowns
|
// Settings Dropdowns
|
||||||
filterSettings: '筛选设置',
|
filterSettings: '筛选设置',
|
||||||
columnSettings: '列设置',
|
columnSettings: '列设置',
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{{ 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 @@
|
|||||||
<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'
|
|||||||
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)
|
|||||||
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 = () => {
|
|||||||
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user