feat: 添加用户余额充值/退款功能 (#17)
## 功能特性 ### 前端 - 在用户列表操作列添加充值和退款按钮 - 实现充值/退款对话框,支持输入金额和备注 - 从编辑用户表单中移除余额字段,防止直接修改 - 添加余额不足验证,实时显示操作后余额 - 优化备注提示词,提供多种场景示例 ### 后端 - 为 redeem_codes 表添加 notes 字段(迁移文件) - 在 UpdateUserBalance 接口添加 notes 参数支持 - 添加余额验证:金额必须大于0,操作后余额不能为负 - UpdateUser 接口移除 balance 字段处理,防止误操作 - 完整的审计日志和缓存管理 ## 安全保护 - 前端:余额不足时禁用提交按钮,实时提示 - 后端:双重验证(输入金额 > 0 + 结果余额 >= 0) - 权限:仅管理员可访问(AdminAuth 中间件) - 审计:所有操作记录到 redeem_codes 表 ## 修改文件 后端: - backend/migrations/004_add_redeem_code_notes.sql - backend/internal/model/redeem_code.go - backend/internal/service/admin_service.go - backend/internal/handler/admin/user_handler.go 前端: - frontend/src/views/admin/UsersView.vue - frontend/src/api/admin/users.ts - frontend/src/i18n/locales/zh.ts - frontend/src/i18n/locales/en.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,8 +49,9 @@ type UpdateUserRequest struct {
|
|||||||
|
|
||||||
// UpdateBalanceRequest represents balance update request
|
// UpdateBalanceRequest represents balance update request
|
||||||
type UpdateBalanceRequest struct {
|
type UpdateBalanceRequest struct {
|
||||||
Balance float64 `json:"balance" binding:"required"`
|
Balance float64 `json:"balance" binding:"required,gt=0"`
|
||||||
Operation string `json:"operation" binding:"required,oneof=set add subtract"`
|
Operation string `json:"operation" binding:"required,oneof=set add subtract"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// List handles listing all users with pagination
|
// List handles listing all users with pagination
|
||||||
@@ -183,7 +184,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.adminService.UpdateUserBalance(c.Request.Context(), userID, req.Balance, req.Operation)
|
user, err := h.adminService.UpdateUserBalance(c.Request.Context(), userID, req.Balance, req.Operation, req.Notes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "Failed to update balance: "+err.Error())
|
response.InternalError(c, "Failed to update balance: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type RedeemCode struct {
|
|||||||
Status string `gorm:"size:20;default:unused;not null" json:"status"` // unused/used
|
Status string `gorm:"size:20;default:unused;not null" json:"status"` // unused/used
|
||||||
UsedBy *int64 `gorm:"index" json:"used_by"`
|
UsedBy *int64 `gorm:"index" json:"used_by"`
|
||||||
UsedAt *time.Time `json:"used_at"`
|
UsedAt *time.Time `json:"used_at"`
|
||||||
|
Notes string `gorm:"type:text" json:"notes"`
|
||||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||||
|
|
||||||
// 订阅类型专用字段
|
// 订阅类型专用字段
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type AdminService interface {
|
|||||||
CreateUser(ctx context.Context, input *CreateUserInput) (*model.User, error)
|
CreateUser(ctx context.Context, input *CreateUserInput) (*model.User, error)
|
||||||
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*model.User, error)
|
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*model.User, error)
|
||||||
DeleteUser(ctx context.Context, id int64) error
|
DeleteUser(ctx context.Context, id int64) error
|
||||||
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string) (*model.User, error)
|
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*model.User, error)
|
||||||
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]model.ApiKey, int64, error)
|
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]model.ApiKey, int64, error)
|
||||||
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
|
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
|
||||||
|
|
||||||
@@ -271,8 +271,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
|||||||
return nil, errors.New("cannot disable admin user")
|
return nil, errors.New("cannot disable admin user")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track balance and concurrency changes for logging
|
|
||||||
oldBalance := user.Balance
|
|
||||||
oldConcurrency := user.Concurrency
|
oldConcurrency := user.Concurrency
|
||||||
|
|
||||||
if input.Email != "" {
|
if input.Email != "" {
|
||||||
@@ -284,7 +282,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新用户字段
|
|
||||||
if input.Username != nil {
|
if input.Username != nil {
|
||||||
user.Username = *input.Username
|
user.Username = *input.Username
|
||||||
}
|
}
|
||||||
@@ -295,22 +292,14 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
|||||||
user.Notes = *input.Notes
|
user.Notes = *input.Notes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role is not allowed to be changed via API to prevent privilege escalation
|
|
||||||
if input.Status != "" {
|
if input.Status != "" {
|
||||||
user.Status = input.Status
|
user.Status = input.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只在指针非 nil 时更新 Balance(支持设置为 0)
|
|
||||||
if input.Balance != nil {
|
|
||||||
user.Balance = *input.Balance
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只在指针非 nil 时更新 Concurrency(支持设置为任意值)
|
|
||||||
if input.Concurrency != nil {
|
if input.Concurrency != nil {
|
||||||
user.Concurrency = *input.Concurrency
|
user.Concurrency = *input.Concurrency
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只在指针非 nil 时更新 AllowedGroups
|
|
||||||
if input.AllowedGroups != nil {
|
if input.AllowedGroups != nil {
|
||||||
user.AllowedGroups = *input.AllowedGroups
|
user.AllowedGroups = *input.AllowedGroups
|
||||||
}
|
}
|
||||||
@@ -319,41 +308,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 余额变化时失效缓存
|
|
||||||
if input.Balance != nil && *input.Balance != oldBalance {
|
|
||||||
if s.billingCacheService != nil {
|
|
||||||
go func() {
|
|
||||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := s.billingCacheService.InvalidateUserBalance(cacheCtx, id); err != nil {
|
|
||||||
log.Printf("invalidate user balance cache failed: user_id=%d err=%v", id, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create adjustment records for balance/concurrency changes
|
|
||||||
balanceDiff := user.Balance - oldBalance
|
|
||||||
if balanceDiff != 0 {
|
|
||||||
code, err := model.GenerateRedeemCode()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("failed to generate adjustment redeem code: %v", err)
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
adjustmentRecord := &model.RedeemCode{
|
|
||||||
Code: code,
|
|
||||||
Type: model.AdjustmentTypeAdminBalance,
|
|
||||||
Value: balanceDiff,
|
|
||||||
Status: model.StatusUsed,
|
|
||||||
UsedBy: &user.ID,
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
adjustmentRecord.UsedAt = &now
|
|
||||||
if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil {
|
|
||||||
log.Printf("failed to create balance adjustment redeem code: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
concurrencyDiff := user.Concurrency - oldConcurrency
|
concurrencyDiff := user.Concurrency - oldConcurrency
|
||||||
if concurrencyDiff != 0 {
|
if concurrencyDiff != 0 {
|
||||||
code, err := model.GenerateRedeemCode()
|
code, err := model.GenerateRedeemCode()
|
||||||
@@ -390,12 +344,14 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error {
|
|||||||
return s.userRepo.Delete(ctx, id)
|
return s.userRepo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string) (*model.User, error) {
|
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*model.User, error) {
|
||||||
user, err := s.userRepo.GetByID(ctx, userID)
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldBalance := user.Balance
|
||||||
|
|
||||||
switch operation {
|
switch operation {
|
||||||
case "set":
|
case "set":
|
||||||
user.Balance = balance
|
user.Balance = balance
|
||||||
@@ -405,11 +361,14 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64,
|
|||||||
user.Balance -= balance
|
user.Balance -= balance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.Balance < 0 {
|
||||||
|
return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 失效余额缓存
|
|
||||||
if s.billingCacheService != nil {
|
if s.billingCacheService != nil {
|
||||||
go func() {
|
go func() {
|
||||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
@@ -420,6 +379,30 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64,
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
balanceDiff := user.Balance - oldBalance
|
||||||
|
if balanceDiff != 0 {
|
||||||
|
code, err := model.GenerateRedeemCode()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to generate adjustment redeem code: %v", err)
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustmentRecord := &model.RedeemCode{
|
||||||
|
Code: code,
|
||||||
|
Type: model.AdjustmentTypeAdminBalance,
|
||||||
|
Value: balanceDiff,
|
||||||
|
Status: model.StatusUsed,
|
||||||
|
UsedBy: &user.ID,
|
||||||
|
Notes: notes,
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
adjustmentRecord.UsedAt = &now
|
||||||
|
|
||||||
|
if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil {
|
||||||
|
log.Printf("failed to create balance adjustment redeem code: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
backend/migrations/004_add_redeem_code_notes.sql
Normal file
6
backend/migrations/004_add_redeem_code_notes.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- 为 redeem_codes 表添加备注字段
|
||||||
|
|
||||||
|
ALTER TABLE redeem_codes
|
||||||
|
ADD COLUMN IF NOT EXISTS notes TEXT DEFAULT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN redeem_codes.notes IS '备注说明(管理员调整时的原因说明)';
|
||||||
@@ -84,16 +84,19 @@ export async function deleteUser(id: number): Promise<{ message: string }> {
|
|||||||
* @param id - User ID
|
* @param id - User ID
|
||||||
* @param balance - New balance
|
* @param balance - New balance
|
||||||
* @param operation - Operation type ('set', 'add', 'subtract')
|
* @param operation - Operation type ('set', 'add', 'subtract')
|
||||||
|
* @param notes - Optional notes for the balance adjustment
|
||||||
* @returns Updated user
|
* @returns Updated user
|
||||||
*/
|
*/
|
||||||
export async function updateBalance(
|
export async function updateBalance(
|
||||||
id: number,
|
id: number,
|
||||||
balance: number,
|
balance: number,
|
||||||
operation: 'set' | 'add' | 'subtract' = 'set'
|
operation: 'set' | 'add' | 'subtract' = 'set',
|
||||||
|
notes?: string
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const { data } = await apiClient.post<User>(`/admin/users/${id}/balance`, {
|
const { data } = await apiClient.post<User>(`/admin/users/${id}/balance`, {
|
||||||
balance,
|
balance,
|
||||||
operation,
|
operation,
|
||||||
|
notes: notes || '',
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -520,6 +520,26 @@ export default {
|
|||||||
allowedGroupsUpdated: 'Allowed groups updated successfully',
|
allowedGroupsUpdated: 'Allowed groups updated successfully',
|
||||||
failedToLoadGroups: 'Failed to load groups',
|
failedToLoadGroups: 'Failed to load groups',
|
||||||
failedToUpdateAllowedGroups: 'Failed to update allowed groups',
|
failedToUpdateAllowedGroups: 'Failed to update allowed groups',
|
||||||
|
deposit: 'Deposit',
|
||||||
|
withdraw: 'Withdraw',
|
||||||
|
depositAmount: 'Deposit Amount',
|
||||||
|
withdrawAmount: 'Withdraw Amount',
|
||||||
|
currentBalance: 'Current Balance',
|
||||||
|
depositNotesPlaceholder: 'e.g., New user registration bonus, promotional credit, compensation, etc.',
|
||||||
|
withdrawNotesPlaceholder: 'e.g., Service issue refund, incorrect charge reversal, account closure refund, etc.',
|
||||||
|
notesOptional: 'Notes are optional but helpful for record keeping',
|
||||||
|
amountHint: 'Please enter a positive amount',
|
||||||
|
newBalance: 'New Balance',
|
||||||
|
depositing: 'Depositing...',
|
||||||
|
withdrawing: 'Withdrawing...',
|
||||||
|
confirmDeposit: 'Confirm Deposit',
|
||||||
|
confirmWithdraw: 'Confirm Withdraw',
|
||||||
|
depositSuccess: 'Deposit successful',
|
||||||
|
withdrawSuccess: 'Withdraw successful',
|
||||||
|
failedToDeposit: 'Failed to deposit',
|
||||||
|
failedToWithdraw: 'Failed to withdraw',
|
||||||
|
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance',
|
||||||
|
insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Groups
|
// Groups
|
||||||
|
|||||||
@@ -587,6 +587,25 @@ export default {
|
|||||||
allowedGroupsUpdated: '允许分组更新成功',
|
allowedGroupsUpdated: '允许分组更新成功',
|
||||||
failedToLoadGroups: '加载分组列表失败',
|
failedToLoadGroups: '加载分组列表失败',
|
||||||
failedToUpdateAllowedGroups: '更新允许分组失败',
|
failedToUpdateAllowedGroups: '更新允许分组失败',
|
||||||
|
deposit: '充值',
|
||||||
|
withdraw: '退款',
|
||||||
|
depositAmount: '充值金额',
|
||||||
|
withdrawAmount: '退款金额',
|
||||||
|
depositNotesPlaceholder: '例如:新用户注册奖励、活动充值、补偿充值等',
|
||||||
|
withdrawNotesPlaceholder: '例如:服务问题退款、错误充值退回、账户注销退款等',
|
||||||
|
notesOptional: '备注为可选项,有助于未来查账',
|
||||||
|
amountHint: '请输入正数金额',
|
||||||
|
newBalance: '操作后余额',
|
||||||
|
depositing: '充值中...',
|
||||||
|
withdrawing: '退款中...',
|
||||||
|
confirmDeposit: '确认充值',
|
||||||
|
confirmWithdraw: '确认退款',
|
||||||
|
depositSuccess: '充值成功',
|
||||||
|
withdrawSuccess: '退款成功',
|
||||||
|
failedToDeposit: '充值失败',
|
||||||
|
failedToWithdraw: '退款失败',
|
||||||
|
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
|
||||||
|
insufficientBalance: '余额不足,退款后余额不能为负数',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Groups Management
|
// Groups Management
|
||||||
|
|||||||
@@ -207,6 +207,26 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Deposit -->
|
||||||
|
<button
|
||||||
|
@click="handleDeposit(row)"
|
||||||
|
class="p-2 rounded-lg hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-gray-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
|
||||||
|
:title="t('admin.users.deposit')"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Withdraw -->
|
||||||
|
<button
|
||||||
|
@click="handleWithdraw(row)"
|
||||||
|
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||||
|
:title="t('admin.users.withdraw')"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<!-- Edit -->
|
<!-- Edit -->
|
||||||
<button
|
<button
|
||||||
@click="handleEdit(row)"
|
@click="handleEdit(row)"
|
||||||
@@ -476,24 +496,13 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
<p class="input-hint">{{ t('admin.users.notesHint') }}</p>
|
<p class="input-hint">{{ t('admin.users.notesHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div>
|
||||||
<div>
|
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
||||||
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
|
<input
|
||||||
<input
|
v-model.number="editForm.concurrency"
|
||||||
v-model.number="editForm.balance"
|
type="number"
|
||||||
type="number"
|
class="input"
|
||||||
step="any"
|
|
||||||
class="input"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
|
||||||
<input
|
|
||||||
v-model.number="editForm.concurrency"
|
|
||||||
type="number"
|
|
||||||
class="input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-4">
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
@@ -729,6 +738,114 @@
|
|||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Deposit/Withdraw Modal -->
|
||||||
|
<Modal
|
||||||
|
:show="showBalanceModal"
|
||||||
|
:title="balanceOperation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
|
||||||
|
size="md"
|
||||||
|
@close="closeBalanceModal"
|
||||||
|
>
|
||||||
|
<form v-if="balanceUser" @submit.prevent="handleBalanceSubmit" class="space-y-5">
|
||||||
|
<div class="flex items-center gap-3 p-4 rounded-xl bg-gray-50 dark:bg-dark-700">
|
||||||
|
<div class="flex h-10 w-10 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">
|
||||||
|
{{ balanceUser.email.charAt(0).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">{{ balanceUser.email }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
{{ t('admin.users.currentBalance') }}: ${{ balanceUser.balance.toFixed(2) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">
|
||||||
|
{{ balanceOperation === 'add' ? t('admin.users.depositAmount') : t('admin.users.withdrawAmount') }}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-dark-400 font-medium">
|
||||||
|
$
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="balanceForm.amount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
required
|
||||||
|
class="input pl-8"
|
||||||
|
:placeholder="balanceOperation === 'add' ? '10.00' : '5.00'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="input-hint">
|
||||||
|
{{ t('admin.users.amountHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.users.notes') }}</label>
|
||||||
|
<textarea
|
||||||
|
v-model="balanceForm.notes"
|
||||||
|
rows="3"
|
||||||
|
class="input"
|
||||||
|
:placeholder="balanceOperation === 'add'
|
||||||
|
? t('admin.users.depositNotesPlaceholder')
|
||||||
|
: t('admin.users.withdrawNotesPlaceholder')"
|
||||||
|
></textarea>
|
||||||
|
<p class="input-hint">{{ t('admin.users.notesOptional') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="balanceForm.amount > 0" class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-blue-700 dark:text-blue-300">{{ t('admin.users.newBalance') }}:</span>
|
||||||
|
<span class="font-bold text-blue-900 dark:text-blue-100">
|
||||||
|
${{ calculateNewBalance().toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="balanceOperation === 'subtract' && calculateNewBalance() < 0" class="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
|
||||||
|
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ t('admin.users.insufficientBalance') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
@click="closeBalanceModal"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="balanceSubmitting || !balanceForm.amount || balanceForm.amount <= 0 || (balanceOperation === 'subtract' && calculateNewBalance() < 0)"
|
||||||
|
class="btn"
|
||||||
|
:class="balanceOperation === 'add' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : 'btn-danger'"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="balanceSubmitting"
|
||||||
|
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<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"></path>
|
||||||
|
</svg>
|
||||||
|
{{ balanceSubmitting
|
||||||
|
? (balanceOperation === 'add' ? t('admin.users.depositing') : t('admin.users.withdrawing'))
|
||||||
|
: (balanceOperation === 'add' ? t('admin.users.confirmDeposit') : t('admin.users.confirmWithdraw'))
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- Delete Confirmation Dialog -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="showDeleteDialog"
|
:show="showDeleteDialog"
|
||||||
@@ -828,6 +945,16 @@ const selectedGroupIds = ref<number[]>([])
|
|||||||
const loadingGroups = ref(false)
|
const loadingGroups = ref(false)
|
||||||
const savingAllowedGroups = ref(false)
|
const savingAllowedGroups = ref(false)
|
||||||
|
|
||||||
|
// Balance (Deposit/Withdraw) modal state
|
||||||
|
const showBalanceModal = ref(false)
|
||||||
|
const balanceUser = ref<User | null>(null)
|
||||||
|
const balanceOperation = ref<'add' | 'subtract'>('add')
|
||||||
|
const balanceSubmitting = ref(false)
|
||||||
|
const balanceForm = reactive({
|
||||||
|
amount: 0,
|
||||||
|
notes: ''
|
||||||
|
})
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -844,7 +971,6 @@ const editForm = reactive({
|
|||||||
username: '',
|
username: '',
|
||||||
wechat: '',
|
wechat: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
balance: 0,
|
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
})
|
})
|
||||||
const editPasswordCopied = ref(false)
|
const editPasswordCopied = ref(false)
|
||||||
@@ -997,7 +1123,6 @@ const handleEdit = (user: User) => {
|
|||||||
editForm.username = user.username || ''
|
editForm.username = user.username || ''
|
||||||
editForm.wechat = user.wechat || ''
|
editForm.wechat = user.wechat || ''
|
||||||
editForm.notes = user.notes || ''
|
editForm.notes = user.notes || ''
|
||||||
editForm.balance = user.balance
|
|
||||||
editForm.concurrency = user.concurrency
|
editForm.concurrency = user.concurrency
|
||||||
editPasswordCopied.value = false
|
editPasswordCopied.value = false
|
||||||
showEditModal.value = true
|
showEditModal.value = true
|
||||||
@@ -1015,13 +1140,11 @@ const handleUpdateUser = async () => {
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
// Build update data - only include password if not empty
|
|
||||||
const updateData: Record<string, any> = {
|
const updateData: Record<string, any> = {
|
||||||
email: editForm.email,
|
email: editForm.email,
|
||||||
username: editForm.username,
|
username: editForm.username,
|
||||||
wechat: editForm.wechat,
|
wechat: editForm.wechat,
|
||||||
notes: editForm.notes,
|
notes: editForm.notes,
|
||||||
balance: editForm.balance,
|
|
||||||
concurrency: editForm.concurrency
|
concurrency: editForm.concurrency
|
||||||
}
|
}
|
||||||
if (editForm.password.trim()) {
|
if (editForm.password.trim()) {
|
||||||
@@ -1141,6 +1264,69 @@ const confirmDelete = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeposit = (user: User) => {
|
||||||
|
balanceUser.value = user
|
||||||
|
balanceOperation.value = 'add'
|
||||||
|
balanceForm.amount = 0
|
||||||
|
balanceForm.notes = ''
|
||||||
|
showBalanceModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWithdraw = (user: User) => {
|
||||||
|
balanceUser.value = user
|
||||||
|
balanceOperation.value = 'subtract'
|
||||||
|
balanceForm.amount = 0
|
||||||
|
balanceForm.notes = ''
|
||||||
|
showBalanceModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBalanceModal = () => {
|
||||||
|
showBalanceModal.value = false
|
||||||
|
balanceUser.value = null
|
||||||
|
balanceForm.amount = 0
|
||||||
|
balanceForm.notes = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateNewBalance = () => {
|
||||||
|
if (!balanceUser.value) return 0
|
||||||
|
if (balanceOperation.value === 'add') {
|
||||||
|
return balanceUser.value.balance + balanceForm.amount
|
||||||
|
} else {
|
||||||
|
return balanceUser.value.balance - balanceForm.amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBalanceSubmit = async () => {
|
||||||
|
if (!balanceUser.value || balanceForm.amount <= 0) return
|
||||||
|
|
||||||
|
balanceSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminAPI.users.updateBalance(
|
||||||
|
balanceUser.value.id,
|
||||||
|
balanceForm.amount,
|
||||||
|
balanceOperation.value,
|
||||||
|
balanceForm.notes
|
||||||
|
)
|
||||||
|
|
||||||
|
const successMsg = balanceOperation.value === 'add'
|
||||||
|
? t('admin.users.depositSuccess')
|
||||||
|
: t('admin.users.withdrawSuccess')
|
||||||
|
|
||||||
|
appStore.showSuccess(successMsg)
|
||||||
|
closeBalanceModal()
|
||||||
|
loadUsers()
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMsg = balanceOperation.value === 'add'
|
||||||
|
? t('admin.users.failedToDeposit')
|
||||||
|
: t('admin.users.failedToWithdraw')
|
||||||
|
|
||||||
|
appStore.showError(error.response?.data?.detail || errorMsg)
|
||||||
|
console.error('Error updating balance:', error)
|
||||||
|
} finally {
|
||||||
|
balanceSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadUsers()
|
loadUsers()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user