mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-06 00:10:21 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5831eb8a6a | ||
|
|
61838cdb3d | ||
|
|
50dba656fd | ||
|
|
0e2821456c | ||
|
|
f25ac3aff5 | ||
|
|
f6341b7f2b | ||
|
|
4e257512b9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,6 +28,7 @@ node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
|
||||
# 日志
|
||||
npm-debug.log*
|
||||
|
||||
@@ -49,8 +49,9 @@ type UpdateUserRequest struct {
|
||||
|
||||
// UpdateBalanceRequest represents balance update request
|
||||
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"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// List handles listing all users with pagination
|
||||
@@ -183,7 +184,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) {
|
||||
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 {
|
||||
response.InternalError(c, "Failed to update balance: "+err.Error())
|
||||
return
|
||||
|
||||
@@ -14,6 +14,7 @@ type RedeemCode struct {
|
||||
Status string `gorm:"size:20;default:unused;not null" json:"status"` // unused/used
|
||||
UsedBy *int64 `gorm:"index" json:"used_by"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
Notes string `gorm:"type:text" json:"notes"`
|
||||
CreatedAt time.Time `gorm:"not null" json:"created_at"`
|
||||
|
||||
// 订阅类型专用字段
|
||||
|
||||
@@ -23,14 +23,18 @@ func (r *AccountRepository) Create(ctx context.Context, account *model.Account)
|
||||
|
||||
func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*model.Account, error) {
|
||||
var account model.Account
|
||||
err := r.db.WithContext(ctx).Preload("Proxy").Preload("AccountGroups").First(&account, id).Error
|
||||
err := r.db.WithContext(ctx).Preload("Proxy").Preload("AccountGroups.Group").First(&account, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 填充 GroupIDs 虚拟字段
|
||||
// 填充 GroupIDs 和 Groups 虚拟字段
|
||||
account.GroupIDs = make([]int64, 0, len(account.AccountGroups))
|
||||
account.Groups = make([]*model.Group, 0, len(account.AccountGroups))
|
||||
for _, ag := range account.AccountGroups {
|
||||
account.GroupIDs = append(account.GroupIDs, ag.GroupID)
|
||||
if ag.Group != nil {
|
||||
account.Groups = append(account.Groups, ag.Group)
|
||||
}
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
@@ -303,3 +307,31 @@ func (r *AccountRepository) SetSchedulable(ctx context.Context, id int64, schedu
|
||||
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
|
||||
Update("schedulable", schedulable).Error
|
||||
}
|
||||
|
||||
// UpdateExtra updates specific fields in account's Extra JSONB field
|
||||
// It merges the updates into existing Extra data without overwriting other fields
|
||||
func (r *AccountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current account to preserve existing Extra data
|
||||
var account model.Account
|
||||
if err := r.db.WithContext(ctx).Select("extra").Where("id = ?", id).First(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize Extra if nil
|
||||
if account.Extra == nil {
|
||||
account.Extra = make(model.JSONB)
|
||||
}
|
||||
|
||||
// Merge updates into existing Extra
|
||||
for k, v := range updates {
|
||||
account.Extra[k] = v
|
||||
}
|
||||
|
||||
// Save updated Extra
|
||||
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
|
||||
Update("extra", account.Extra).Error
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sub2api/internal/pkg/oauth"
|
||||
@@ -139,20 +140,12 @@ func (s *claudeOAuthService) GetAuthorizationCode(ctx context.Context, sessionKe
|
||||
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error) {
|
||||
client := createReqClient(proxyURL)
|
||||
|
||||
// Parse code which may contain state in format "authCode#state"
|
||||
authCode := code
|
||||
codeState := ""
|
||||
if len(code) > 0 {
|
||||
parts := make([]string, 0, 2)
|
||||
for i, part := range []rune(code) {
|
||||
if part == '#' {
|
||||
authCode = code[:i]
|
||||
codeState = code[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
authCode = code
|
||||
}
|
||||
if idx := strings.Index(code, "#"); idx != -1 {
|
||||
authCode = code[:idx]
|
||||
codeState = code[idx+1:]
|
||||
}
|
||||
|
||||
reqBody := map[string]any{
|
||||
|
||||
@@ -22,7 +22,7 @@ type AdminService interface {
|
||||
CreateUser(ctx context.Context, input *CreateUserInput) (*model.User, error)
|
||||
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*model.User, 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)
|
||||
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")
|
||||
}
|
||||
|
||||
// Track balance and concurrency changes for logging
|
||||
oldBalance := user.Balance
|
||||
oldConcurrency := user.Concurrency
|
||||
|
||||
if input.Email != "" {
|
||||
@@ -284,7 +282,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户字段
|
||||
if input.Username != nil {
|
||||
user.Username = *input.Username
|
||||
}
|
||||
@@ -295,22 +292,14 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
user.Notes = *input.Notes
|
||||
}
|
||||
|
||||
// Role is not allowed to be changed via API to prevent privilege escalation
|
||||
if input.Status != "" {
|
||||
user.Status = input.Status
|
||||
}
|
||||
|
||||
// 只在指针非 nil 时更新 Balance(支持设置为 0)
|
||||
if input.Balance != nil {
|
||||
user.Balance = *input.Balance
|
||||
}
|
||||
|
||||
// 只在指针非 nil 时更新 Concurrency(支持设置为任意值)
|
||||
if input.Concurrency != nil {
|
||||
user.Concurrency = *input.Concurrency
|
||||
}
|
||||
|
||||
// 只在指针非 nil 时更新 AllowedGroups
|
||||
if input.AllowedGroups != nil {
|
||||
user.AllowedGroups = *input.AllowedGroups
|
||||
}
|
||||
@@ -319,41 +308,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
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
|
||||
if concurrencyDiff != 0 {
|
||||
code, err := model.GenerateRedeemCode()
|
||||
@@ -390,12 +344,14 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oldBalance := user.Balance
|
||||
|
||||
switch operation {
|
||||
case "set":
|
||||
user.Balance = balance
|
||||
@@ -405,11 +361,14 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64,
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 失效余额缓存
|
||||
if s.billingCacheService != nil {
|
||||
go func() {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -734,7 +734,7 @@ func (s *GatewayService) replaceModelInSSELine(line, fromModel, toModel string)
|
||||
}
|
||||
|
||||
func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
|
||||
// 解析message_start获取input tokens
|
||||
// 解析message_start获取input tokens(标准Claude API格式)
|
||||
var msgStart struct {
|
||||
Type string `json:"type"`
|
||||
Message struct {
|
||||
@@ -747,15 +747,30 @@ func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
|
||||
usage.CacheReadInputTokens = msgStart.Message.Usage.CacheReadInputTokens
|
||||
}
|
||||
|
||||
// 解析message_delta获取output tokens
|
||||
// 解析message_delta获取tokens(兼容GLM等把所有usage放在delta中的API)
|
||||
var msgDelta struct {
|
||||
Type string `json:"type"`
|
||||
Usage struct {
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if json.Unmarshal([]byte(data), &msgDelta) == nil && msgDelta.Type == "message_delta" {
|
||||
// output_tokens 总是从 message_delta 获取
|
||||
usage.OutputTokens = msgDelta.Usage.OutputTokens
|
||||
|
||||
// 如果 message_start 中没有值,则从 message_delta 获取(兼容GLM等API)
|
||||
if usage.InputTokens == 0 {
|
||||
usage.InputTokens = msgDelta.Usage.InputTokens
|
||||
}
|
||||
if usage.CacheCreationInputTokens == 0 {
|
||||
usage.CacheCreationInputTokens = msgDelta.Usage.CacheCreationInputTokens
|
||||
}
|
||||
if usage.CacheReadInputTokens == 0 {
|
||||
usage.CacheReadInputTokens = msgDelta.Usage.CacheReadInputTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -38,6 +39,18 @@ var openaiAllowedHeaders = map[string]bool{
|
||||
"session_id": true,
|
||||
}
|
||||
|
||||
// OpenAICodexUsageSnapshot represents Codex API usage limits from response headers
|
||||
type OpenAICodexUsageSnapshot struct {
|
||||
PrimaryUsedPercent *float64 `json:"primary_used_percent,omitempty"`
|
||||
PrimaryResetAfterSeconds *int `json:"primary_reset_after_seconds,omitempty"`
|
||||
PrimaryWindowMinutes *int `json:"primary_window_minutes,omitempty"`
|
||||
SecondaryUsedPercent *float64 `json:"secondary_used_percent,omitempty"`
|
||||
SecondaryResetAfterSeconds *int `json:"secondary_reset_after_seconds,omitempty"`
|
||||
SecondaryWindowMinutes *int `json:"secondary_window_minutes,omitempty"`
|
||||
PrimaryOverSecondaryPercent *float64 `json:"primary_over_secondary_percent,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIUsage represents OpenAI API response usage
|
||||
type OpenAIUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
@@ -284,6 +297,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and save Codex usage snapshot from response headers (for OAuth accounts)
|
||||
if account.Type == model.AccountTypeOAuth {
|
||||
if snapshot := extractCodexUsageHeaders(resp.Header); snapshot != nil {
|
||||
s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
return &OpenAIForwardResult{
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: *usage,
|
||||
@@ -708,3 +728,109 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractCodexUsageHeaders extracts Codex usage limits from response headers
|
||||
func extractCodexUsageHeaders(headers http.Header) *OpenAICodexUsageSnapshot {
|
||||
snapshot := &OpenAICodexUsageSnapshot{}
|
||||
hasData := false
|
||||
|
||||
// Helper to parse float64 from header
|
||||
parseFloat := func(key string) *float64 {
|
||||
if v := headers.Get(key); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return &f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to parse int from header
|
||||
parseInt := func(key string) *int {
|
||||
if v := headers.Get(key); v != "" {
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return &i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Primary (weekly) limits
|
||||
if v := parseFloat("x-codex-primary-used-percent"); v != nil {
|
||||
snapshot.PrimaryUsedPercent = v
|
||||
hasData = true
|
||||
}
|
||||
if v := parseInt("x-codex-primary-reset-after-seconds"); v != nil {
|
||||
snapshot.PrimaryResetAfterSeconds = v
|
||||
hasData = true
|
||||
}
|
||||
if v := parseInt("x-codex-primary-window-minutes"); v != nil {
|
||||
snapshot.PrimaryWindowMinutes = v
|
||||
hasData = true
|
||||
}
|
||||
|
||||
// Secondary (5h) limits
|
||||
if v := parseFloat("x-codex-secondary-used-percent"); v != nil {
|
||||
snapshot.SecondaryUsedPercent = v
|
||||
hasData = true
|
||||
}
|
||||
if v := parseInt("x-codex-secondary-reset-after-seconds"); v != nil {
|
||||
snapshot.SecondaryResetAfterSeconds = v
|
||||
hasData = true
|
||||
}
|
||||
if v := parseInt("x-codex-secondary-window-minutes"); v != nil {
|
||||
snapshot.SecondaryWindowMinutes = v
|
||||
hasData = true
|
||||
}
|
||||
|
||||
// Overflow ratio
|
||||
if v := parseFloat("x-codex-primary-over-secondary-limit-percent"); v != nil {
|
||||
snapshot.PrimaryOverSecondaryPercent = v
|
||||
hasData = true
|
||||
}
|
||||
|
||||
if !hasData {
|
||||
return nil
|
||||
}
|
||||
|
||||
snapshot.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// updateCodexUsageSnapshot saves the Codex usage snapshot to account's Extra field
|
||||
func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, accountID int64, snapshot *OpenAICodexUsageSnapshot) {
|
||||
if snapshot == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert snapshot to map for merging into Extra
|
||||
updates := make(map[string]any)
|
||||
if snapshot.PrimaryUsedPercent != nil {
|
||||
updates["codex_primary_used_percent"] = *snapshot.PrimaryUsedPercent
|
||||
}
|
||||
if snapshot.PrimaryResetAfterSeconds != nil {
|
||||
updates["codex_primary_reset_after_seconds"] = *snapshot.PrimaryResetAfterSeconds
|
||||
}
|
||||
if snapshot.PrimaryWindowMinutes != nil {
|
||||
updates["codex_primary_window_minutes"] = *snapshot.PrimaryWindowMinutes
|
||||
}
|
||||
if snapshot.SecondaryUsedPercent != nil {
|
||||
updates["codex_secondary_used_percent"] = *snapshot.SecondaryUsedPercent
|
||||
}
|
||||
if snapshot.SecondaryResetAfterSeconds != nil {
|
||||
updates["codex_secondary_reset_after_seconds"] = *snapshot.SecondaryResetAfterSeconds
|
||||
}
|
||||
if snapshot.SecondaryWindowMinutes != nil {
|
||||
updates["codex_secondary_window_minutes"] = *snapshot.SecondaryWindowMinutes
|
||||
}
|
||||
if snapshot.PrimaryOverSecondaryPercent != nil {
|
||||
updates["codex_primary_over_secondary_percent"] = *snapshot.PrimaryOverSecondaryPercent
|
||||
}
|
||||
updates["codex_usage_updated_at"] = snapshot.UpdatedAt
|
||||
|
||||
// Update account's Extra field asynchronously
|
||||
go func() {
|
||||
updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -34,4 +34,5 @@ type AccountRepository interface {
|
||||
SetOverloaded(ctx context.Context, id int64, until time.Time) error
|
||||
ClearRateLimit(ctx context.Context, id int64) error
|
||||
UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error
|
||||
UpdateExtra(ctx context.Context, id int64, updates map[string]any) error
|
||||
}
|
||||
|
||||
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 balance - New balance
|
||||
* @param operation - Operation type ('set', 'add', 'subtract')
|
||||
* @param notes - Optional notes for the balance adjustment
|
||||
* @returns Updated user
|
||||
*/
|
||||
export async function updateBalance(
|
||||
id: number,
|
||||
balance: number,
|
||||
operation: 'set' | 'add' | 'subtract' = 'set'
|
||||
operation: 'set' | 'add' | 'subtract' = 'set',
|
||||
notes?: string
|
||||
): Promise<User> {
|
||||
const { data } = await apiClient.post<User>(`/admin/users/${id}/balance`, {
|
||||
balance,
|
||||
operation,
|
||||
notes: notes || '',
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,31 @@
|
||||
<SetupTokenTimeWindow :account="account" />
|
||||
</template>
|
||||
|
||||
<!-- OpenAI accounts: no usage window API, show dash -->
|
||||
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
|
||||
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
|
||||
<div v-if="hasCodexUsage" class="space-y-1">
|
||||
<!-- 5h Window (Secondary) -->
|
||||
<UsageProgressBar
|
||||
v-if="codexSecondaryUsedPercent !== null"
|
||||
label="5h"
|
||||
:utilization="codexSecondaryUsedPercent"
|
||||
:resets-at="codexSecondaryResetAt"
|
||||
color="indigo"
|
||||
/>
|
||||
|
||||
<!-- Weekly Window (Primary) -->
|
||||
<UsageProgressBar
|
||||
v-if="codexPrimaryUsedPercent !== null"
|
||||
label="7d"
|
||||
:utilization="codexPrimaryUsedPercent"
|
||||
:resets-at="codexPrimaryResetAt"
|
||||
color="emerald"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
|
||||
<!-- Other accounts: no usage window -->
|
||||
<template v-else>
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
@@ -100,9 +124,44 @@ const showUsageWindows = computed(() =>
|
||||
props.account.type === 'oauth' || props.account.type === 'setup-token'
|
||||
)
|
||||
|
||||
// OpenAI Codex usage computed properties
|
||||
const hasCodexUsage = computed(() => {
|
||||
const extra = props.account.extra
|
||||
return extra && (
|
||||
extra.codex_primary_used_percent !== undefined ||
|
||||
extra.codex_secondary_used_percent !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const codexPrimaryUsedPercent = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra || extra.codex_primary_used_percent === undefined) return null
|
||||
return extra.codex_primary_used_percent
|
||||
})
|
||||
|
||||
const codexSecondaryUsedPercent = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra || extra.codex_secondary_used_percent === undefined) return null
|
||||
return extra.codex_secondary_used_percent
|
||||
})
|
||||
|
||||
const codexPrimaryResetAt = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra || extra.codex_primary_reset_after_seconds === undefined) return null
|
||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
})
|
||||
|
||||
const codexSecondaryResetAt = computed(() => {
|
||||
const extra = props.account.extra
|
||||
if (!extra || extra.codex_secondary_reset_after_seconds === undefined) return null
|
||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
||||
return resetTime.toISOString()
|
||||
})
|
||||
|
||||
const loadUsage = async () => {
|
||||
// Only fetch usage for Anthropic OAuth accounts
|
||||
// OpenAI doesn't have a usage window API - usage is updated from response headers during forwarding
|
||||
// OpenAI usage comes from account.extra field (updated during forwarding)
|
||||
if (props.account.platform !== 'anthropic' || props.account.type !== 'oauth') return
|
||||
|
||||
loading.value = true
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">ChatGPT Plus</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">ChatGPT OAuth</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<transition name="dropdown">
|
||||
<div
|
||||
v-if="tooltipOpen"
|
||||
class="absolute right-0 mt-2 w-80 bg-white dark:bg-dark-800 rounded-xl shadow-xl border border-gray-200 dark:border-dark-700 z-50"
|
||||
class="absolute right-0 mt-2 w-[340px] bg-white dark:bg-dark-800 rounded-xl shadow-xl border border-gray-200 dark:border-dark-700 z-50 overflow-hidden"
|
||||
>
|
||||
<div class="p-3 border-b border-gray-100 dark:border-dark-700">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
@@ -62,43 +62,43 @@
|
||||
<!-- Progress bars -->
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.daily') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.daily') }}</span>
|
||||
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 w-16 text-right">
|
||||
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
|
||||
{{ formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.weekly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.weekly') }}</span>
|
||||
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 w-16 text-right">
|
||||
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
|
||||
{{ formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.monthly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.monthly') }}</span>
|
||||
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
:class="getProgressBarClass(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 w-16 text-right">
|
||||
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
|
||||
{{ formatUsage(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -520,6 +520,26 @@ export default {
|
||||
allowedGroupsUpdated: 'Allowed groups updated successfully',
|
||||
failedToLoadGroups: 'Failed to load 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
|
||||
|
||||
@@ -93,7 +93,7 @@ export default {
|
||||
groups: '分组管理',
|
||||
subscriptions: '订阅管理',
|
||||
accounts: '账号管理',
|
||||
proxies: '代理管理',
|
||||
proxies: 'IP管理',
|
||||
redeemCodes: '兑换码',
|
||||
settings: '系统设置',
|
||||
myAccount: '我的账户',
|
||||
@@ -587,6 +587,25 @@ export default {
|
||||
allowedGroupsUpdated: '允许分组更新成功',
|
||||
failedToLoadGroups: '加载分组列表失败',
|
||||
failedToUpdateAllowedGroups: '更新允许分组失败',
|
||||
deposit: '充值',
|
||||
withdraw: '退款',
|
||||
depositAmount: '充值金额',
|
||||
withdrawAmount: '退款金额',
|
||||
depositNotesPlaceholder: '例如:新用户注册奖励、活动充值、补偿充值等',
|
||||
withdrawNotesPlaceholder: '例如:服务问题退款、错误充值退回、账户注销退款等',
|
||||
notesOptional: '备注为可选项,有助于未来查账',
|
||||
amountHint: '请输入正数金额',
|
||||
newBalance: '操作后余额',
|
||||
depositing: '充值中...',
|
||||
withdrawing: '退款中...',
|
||||
confirmDeposit: '确认充值',
|
||||
confirmWithdraw: '确认退款',
|
||||
depositSuccess: '充值成功',
|
||||
withdrawSuccess: '退款成功',
|
||||
failedToDeposit: '充值失败',
|
||||
failedToWithdraw: '退款失败',
|
||||
useDepositWithdrawButtons: '请使用充值/退款按钮调整余额',
|
||||
insufficientBalance: '余额不足,退款后余额不能为负数',
|
||||
},
|
||||
|
||||
// Groups Management
|
||||
@@ -1015,7 +1034,7 @@ export default {
|
||||
|
||||
// Proxies Management
|
||||
proxies: {
|
||||
title: '代理管理',
|
||||
title: 'IP管理',
|
||||
description: '管理代理服务器配置',
|
||||
createProxy: '添加代理',
|
||||
editProxy: '编辑代理',
|
||||
|
||||
@@ -316,6 +316,7 @@ export interface Account {
|
||||
platform: AccountPlatform;
|
||||
type: AccountType;
|
||||
credentials?: Record<string, unknown>;
|
||||
extra?: CodexUsageSnapshot & Record<string, unknown>; // Extra fields including Codex usage
|
||||
proxy_id: number | null;
|
||||
concurrency: number;
|
||||
priority: number;
|
||||
@@ -361,6 +362,18 @@ export interface AccountUsageInfo {
|
||||
seven_day_sonnet: UsageProgress | null;
|
||||
}
|
||||
|
||||
// OpenAI Codex usage snapshot (from response headers)
|
||||
export interface CodexUsageSnapshot {
|
||||
codex_primary_used_percent?: number; // Weekly limit usage percentage
|
||||
codex_primary_reset_after_seconds?: number; // Seconds until weekly reset
|
||||
codex_primary_window_minutes?: number; // Weekly window in minutes
|
||||
codex_secondary_used_percent?: number; // 5h limit usage percentage
|
||||
codex_secondary_reset_after_seconds?: number; // Seconds until 5h reset
|
||||
codex_secondary_window_minutes?: number; // 5h window in minutes
|
||||
codex_primary_over_secondary_percent?: number; // Overflow ratio
|
||||
codex_usage_updated_at?: string; // Last update timestamp
|
||||
}
|
||||
|
||||
export interface CreateAccountRequest {
|
||||
name: string;
|
||||
platform: AccountPlatform;
|
||||
|
||||
@@ -74,15 +74,17 @@
|
||||
</template>
|
||||
|
||||
<template #cell-platform="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'w-2 h-2 rounded-full',
|
||||
value === 'anthropic' ? 'bg-orange-500' : value === 'openai' ? 'bg-green-500' : 'bg-gray-400'
|
||||
]"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ value === 'anthropic' ? 'Anthropic' : value === 'openai' ? 'OpenAI' : value }}</span>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
value === 'anthropic'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||
]"
|
||||
>
|
||||
<PlatformIcon :platform="value" size="xs" />
|
||||
{{ value === 'anthropic' ? 'Anthropic' : 'OpenAI' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-type="{ value }">
|
||||
@@ -334,6 +336,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||
import AccountTestModal from '@/components/account/AccountTestModal.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -60,9 +60,15 @@
|
||||
</template>
|
||||
|
||||
<template #cell-group="{ row }">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
{{ row.group?.name || `Group #${row.group_id}` }}
|
||||
</span>
|
||||
<GroupBadge
|
||||
v-if="row.group"
|
||||
:name="row.group.name"
|
||||
:platform="row.group.platform"
|
||||
:subscription-type="row.group.subscription_type"
|
||||
:rate-multiplier="row.group.rate_multiplier"
|
||||
:show-rate="false"
|
||||
/>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
@@ -361,6 +367,7 @@ import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -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" />
|
||||
</svg>
|
||||
</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 -->
|
||||
<button
|
||||
@click="handleEdit(row)"
|
||||
@@ -476,24 +496,13 @@
|
||||
></textarea>
|
||||
<p class="input-hint">{{ t('admin.users.notesHint') }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.balance"
|
||||
type="number"
|
||||
step="any"
|
||||
class="input"
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.concurrency"
|
||||
type="number"
|
||||
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 class="flex justify-end gap-3 pt-4">
|
||||
@@ -729,6 +738,114 @@
|
||||
</template>
|
||||
</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 -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
@@ -828,6 +945,16 @@ const selectedGroupIds = ref<number[]>([])
|
||||
const loadingGroups = 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({
|
||||
email: '',
|
||||
password: '',
|
||||
@@ -844,7 +971,6 @@ const editForm = reactive({
|
||||
username: '',
|
||||
wechat: '',
|
||||
notes: '',
|
||||
balance: 0,
|
||||
concurrency: 1
|
||||
})
|
||||
const editPasswordCopied = ref(false)
|
||||
@@ -997,7 +1123,6 @@ const handleEdit = (user: User) => {
|
||||
editForm.username = user.username || ''
|
||||
editForm.wechat = user.wechat || ''
|
||||
editForm.notes = user.notes || ''
|
||||
editForm.balance = user.balance
|
||||
editForm.concurrency = user.concurrency
|
||||
editPasswordCopied.value = false
|
||||
showEditModal.value = true
|
||||
@@ -1015,13 +1140,11 @@ const handleUpdateUser = async () => {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// Build update data - only include password if not empty
|
||||
const updateData: Record<string, any> = {
|
||||
email: editForm.email,
|
||||
username: editForm.username,
|
||||
wechat: editForm.wechat,
|
||||
notes: editForm.notes,
|
||||
balance: editForm.balance,
|
||||
concurrency: editForm.concurrency
|
||||
}
|
||||
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(() => {
|
||||
loadUsers()
|
||||
})
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/groups.ts","./src/api/index.ts","./src/api/keys.ts","./src/api/redeem.ts","./src/api/setup.ts","./src/api/subscriptions.ts","./src/api/usage.ts","./src/api/user.ts","./src/api/admin/accounts.ts","./src/api/admin/dashboard.ts","./src/api/admin/groups.ts","./src/api/admin/index.ts","./src/api/admin/proxies.ts","./src/api/admin/redeem.ts","./src/api/admin/settings.ts","./src/api/admin/subscriptions.ts","./src/api/admin/system.ts","./src/api/admin/usage.ts","./src/api/admin/users.ts","./src/components/account/index.ts","./src/components/common/index.ts","./src/components/common/types.ts","./src/components/layout/index.ts","./src/composables/useAccountOAuth.ts","./src/composables/useClipboard.ts","./src/composables/useOpenAIOAuth.ts","./src/i18n/index.ts","./src/i18n/locales/en.ts","./src/i18n/locales/zh.ts","./src/router/index.ts","./src/router/meta.d.ts","./src/stores/app.ts","./src/stores/auth.ts","./src/stores/index.ts","./src/types/index.ts","./src/utils/format.ts","./src/views/auth/index.ts","./src/App.vue","./src/components/TurnstileWidget.vue","./src/components/account/AccountStatusIndicator.vue","./src/components/account/AccountTestModal.vue","./src/components/account/AccountTodayStatsCell.vue","./src/components/account/AccountUsageCell.vue","./src/components/account/CreateAccountModal.vue","./src/components/account/EditAccountModal.vue","./src/components/account/OAuthAuthorizationFlow.vue","./src/components/account/ReAuthAccountModal.vue","./src/components/account/SetupTokenTimeWindow.vue","./src/components/account/UsageProgressBar.vue","./src/components/charts/ModelDistributionChart.vue","./src/components/charts/TokenUsageTrend.vue","./src/components/common/ConfirmDialog.vue","./src/components/common/DataTable.vue","./src/components/common/DateRangePicker.vue","./src/components/common/EmptyState.vue","./src/components/common/GroupBadge.vue","./src/components/common/GroupSelector.vue","./src/components/common/LoadingSpinner.vue","./src/components/common/LocaleSwitcher.vue","./src/components/common/Modal.vue","./src/components/common/Pagination.vue","./src/components/common/PlatformIcon.vue","./src/components/common/ProxySelector.vue","./src/components/common/Select.vue","./src/components/common/StatCard.vue","./src/components/common/SubscriptionProgressMini.vue","./src/components/common/Toast.vue","./src/components/common/Toggle.vue","./src/components/common/VersionBadge.vue","./src/components/keys/UseKeyModal.vue","./src/components/layout/AppHeader.vue","./src/components/layout/AppLayout.vue","./src/components/layout/AppSidebar.vue","./src/components/layout/AuthLayout.vue","./src/views/HomeView.vue","./src/views/NotFoundView.vue","./src/views/admin/AccountsView.vue","./src/views/admin/DashboardView.vue","./src/views/admin/GroupsView.vue","./src/views/admin/ProxiesView.vue","./src/views/admin/RedeemView.vue","./src/views/admin/SettingsView.vue","./src/views/admin/SubscriptionsView.vue","./src/views/admin/UsageView.vue","./src/views/admin/UsersView.vue","./src/views/auth/EmailVerifyView.vue","./src/views/auth/LoginView.vue","./src/views/auth/RegisterView.vue","./src/views/setup/SetupWizardView.vue","./src/views/user/DashboardView.vue","./src/views/user/KeysView.vue","./src/views/user/ProfileView.vue","./src/views/user/RedeemView.vue","./src/views/user/SubscriptionsView.vue","./src/views/user/UsageView.vue"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user