diff --git a/admin_users.go b/admin_users.go index dbad944..15e145f 100644 --- a/admin_users.go +++ b/admin_users.go @@ -1,6 +1,8 @@ package main import ( + "errors" + "fmt" "strconv" "strings" @@ -28,10 +30,26 @@ func (a *App) adminListUsers(c *gin.Context) { c.JSON(500, gin.H{"error": err.Error()}) return } - for i := range users { - users[i].PasswordHash = "" + type userView struct { + User + Balance int64 `json:"balance"` + Reserved int64 `json:"reserved"` + Available int64 `json:"available"` + TaskCount int64 `json:"taskCount"` + CompletedCount int64 `json:"completedCount"` } - c.JSON(200, gin.H{"data": users}) + out := make([]userView, 0, len(users)) + for i := range users { + u := users[i] + u.PasswordHash = "" + var account CreditAccount + _ = a.db.Where("user_id = ?", u.ID).First(&account).Error + var taskCount, completedCount int64 + _ = a.db.Model(&Task{}).Where("user_id = ?", u.ID).Count(&taskCount).Error + _ = a.db.Model(&Task{}).Where("user_id = ? AND status = ?", u.ID, "done").Count(&completedCount).Error + out = append(out, userView{User: u, Balance: account.Balance, Reserved: account.Reserved, Available: account.Balance - account.Reserved, TaskCount: taskCount, CompletedCount: completedCount}) + } + c.JSON(200, gin.H{"data": out}) } func (a *App) adminCreateUser(c *gin.Context) { @@ -88,6 +106,62 @@ func (a *App) adminCreateUser(c *gin.Context) { c.JSON(201, u) } +func (a *App) adminAdjustUserCredits(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid user id"}) + return + } + var in struct { + Amount int64 `json:"amount"` + Reason string `json:"reason"` + } + if c.BindJSON(&in) != nil || in.Amount == 0 { + c.JSON(400, gin.H{"error": "amount must not be zero"}) + return + } + operator := c.MustGet("user").(*User) + var account CreditAccount + err = a.db.Transaction(func(tx *gorm.DB) error { + var u User + if err := tx.First(&u, id).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", u.ID).First(&account).Error; err != nil { + if err != gorm.ErrRecordNotFound { + return err + } + account = CreditAccount{UserID: u.ID} + if err := tx.Create(&account).Error; err != nil { + return err + } + } + newBalance := account.Balance + in.Amount + if newBalance < account.Reserved { + return errors.New("balance cannot be lower than reserved credits") + } + account.Balance = newBalance + if err := tx.Save(&account).Error; err != nil { + return err + } + reason := strings.TrimSpace(in.Reason) + if reason == "" { + reason = "admin credit adjustment" + } + return tx.Create(&CreditTransaction{UserID: u.ID, Type: "adjust", Amount: in.Amount, BalanceAfter: account.Balance, ReferenceType: "admin", ReferenceID: strconv.FormatUint(uint64(operator.ID), 10), Reason: reason}).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(404, gin.H{"error": "user not found"}) + return + } + c.JSON(400, gin.H{"error": err.Error()}) + return + } + a.audit(operator.ID, "user.credit.adjust", "user", strconv.FormatUint(id, 10), fmt.Sprintf("amount=%d;reason=%s", in.Amount, strings.TrimSpace(in.Reason))) + c.JSON(200, gin.H{"balance": account.Balance, "reserved": account.Reserved, "available": account.Balance - account.Reserved}) +} + func (a *App) adminAudit(c *gin.Context) { var logs []AuditLog q := a.db.Order("id desc").Limit(200) diff --git a/index.html b/index.html index 7b7537b..790637c 100644 --- a/index.html +++ b/index.html @@ -23,8 +23,10 @@ const $=id=>document.getElementById(id);let searchPage=1,searchKeyword='',search async function api(u,o){const r=await fetch('/api'+u,o);if(r.status===401){showLogin();throw new Error('请先登录')}if(!r.ok)throw new Error(await r.text());return r.status===204?null:r.json()} function showLogin(){document.getElementById('loginOverlay').style.display='flex'} async function loadAccount(){try{const c=await api('/credits');$('creditPanel').textContent='余额 '+c.balance+' · 预留 '+c.reserved+' · 可用 '+c.available;const u=window.currentUser||{};$('userDavCard').style.display=(u.role==='admin'||u.role==='privileged')?'':'none';if(u.role==='admin'||u.role==='privileged'){const d=await api('/webdav/config');$('userDavUrl').value=d.url||'';$('userDavUser').value=d.username||'';$('userDavRoot').value=d.rootPath||''}}catch(e){$('creditPanel').textContent=e.message}} -async function loadAdmin(){if(!window.isAdmin)return;try{const x=await api('/admin/users');$('users').innerHTML=(x.data||[]).map(u=>{const next=u.status==='active'?'disabled':'active';return '