feat: expand admin user management
This commit is contained in:
+77
-3
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -28,10 +30,26 @@ func (a *App) adminListUsers(c *gin.Context) {
|
|||||||
c.JSON(500, gin.H{"error": err.Error()})
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i := range users {
|
type userView struct {
|
||||||
users[i].PasswordHash = ""
|
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) {
|
func (a *App) adminCreateUser(c *gin.Context) {
|
||||||
@@ -88,6 +106,62 @@ func (a *App) adminCreateUser(c *gin.Context) {
|
|||||||
c.JSON(201, u)
|
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) {
|
func (a *App) adminAudit(c *gin.Context) {
|
||||||
var logs []AuditLog
|
var logs []AuditLog
|
||||||
q := a.db.Order("id desc").Limit(200)
|
q := a.db.Order("id desc").Limit(200)
|
||||||
|
|||||||
+4
-2
@@ -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()}
|
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'}
|
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 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 '<div class="result"><div><div class="name">'+esc(u.username)+'</div><div class="meta">#'+u.id+' · '+esc(u.role)+' · '+esc(u.status)+'</div></div><div class="toolbar"><button class="btn alt" onclick="toggleUser('+u.id+',\''+next+'\')">'+(u.status==='active'?'禁用':'启用')+'</button></div></div>'}).join('')||'<div class="empty">暂无用户</div>';const b=await api('/admin/redeem');$('batches').innerHTML=(b.data||[]).map(x=>'<div class="notice">#'+x.id+' · '+x.credits+' 积分 × '+x.count+(x.note?' · '+esc(x.note):'')+' <button class="btn alt" onclick="revokeBatch('+x.id+')">撤销未使用</button></div>').join('');const l=await api('/admin/audit');$('audit').innerHTML=(l.data||[]).map(x=>'<div class="notice">'+esc(x.action)+' · '+esc(x.targetType)+'#'+esc(x.targetId)+' · '+esc(x.metadata||'')+' · '+esc(x.createdAt)+'</div>').join('')||'暂无日志'}catch(e){$('users').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 '<div class="result" style="align-items:flex-start"><div style="flex:1;min-width:0"><div class="name">'+esc(u.username)+' <span class="muted">#'+u.id+'</span></div><div class="meta">角色:'+esc(u.role)+' · 状态:'+esc(u.status)+'</div><div class="meta" style="margin-top:6px">积分:<b>'+u.balance+'</b> · 预留:'+u.reserved+' · 可用:<b>'+u.available+'</b></div><div class="meta">下载任务:'+u.taskCount+' · 已完成:'+u.completedCount+'</div><div class="toolbar" style="margin-top:10px;display:flex;flex-wrap:wrap;gap:7px"><button class="btn alt" onclick="adjustCredits('+u.id+',1)">+积分</button><button class="btn alt" onclick="adjustCredits('+u.id+',-1)">-积分</button><button class="btn alt" onclick="resetUserPassword('+u.id+')">重置密码</button><button class="btn alt" onclick="toggleUser('+u.id+',\''+next+'\')">'+(u.status==='active'?'禁用':'启用')+'</button></div></div></div>'}).join('')||'<div class="empty">暂无用户</div>';const b=await api('/admin/redeem');$('batches').innerHTML=(b.data||[]).map(x=>'<div class="notice">#'+x.id+' · '+x.credits+' 积分 × '+x.count+(x.note?' · '+esc(x.note):'')+' <button class="btn alt" onclick="revokeBatch('+x.id+')">撤销未使用</button></div>').join('');const l=await api('/admin/audit');$('audit').innerHTML=(l.data||[]).map(x=>'<div class="notice">'+esc(x.action)+' · '+esc(x.targetType)+'#'+esc(x.targetId)+' · '+esc(x.metadata||'')+' · '+esc(x.createdAt)+'</div>').join('')||'暂无日志'}catch(e){$('users').textContent=e.message}}
|
||||||
async function toggleUser(id,status){await api('/admin/users/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status})});loadAdmin()}
|
async function toggleUser(id,status){try{await api('/admin/users/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status})});loadAdmin()}catch(e){alert('操作失败:'+e.message)}}
|
||||||
|
async function adjustCredits(id,sign){const raw=prompt(sign>0?'增加多少积分?':'扣除多少积分?','10');if(raw===null)return;const amount=Math.floor(Number(raw));if(!Number.isFinite(amount)||amount<=0){alert('请输入正整数');return}const reason=prompt('调整原因(可选)','管理员调整积分');if(reason===null)return;try{const x=await api('/admin/users/'+id+'/credits',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({amount:sign*amount,reason})});alert('调整成功,当前可用积分:'+x.available);loadAdmin()}catch(e){alert('积分调整失败:'+e.message)}}
|
||||||
|
async function resetUserPassword(id){const pass=prompt('输入新密码(至少 8 位):');if(pass===null)return;if(pass.length<8){alert('密码至少 8 位');return}try{await api('/admin/users/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pass})});alert('密码已重置,该用户的其他登录会话已失效');loadAdmin()}catch(e){alert('密码重置失败:'+e.message)}}
|
||||||
async function revokeBatch(id){await api('/admin/redeem/'+id+'/revoke',{method:'POST'});loadAdmin()}
|
async function revokeBatch(id){await api('/admin/redeem/'+id+'/revoke',{method:'POST'});loadAdmin()}
|
||||||
|
|
||||||
function hideLogin(){document.getElementById('loginOverlay').style.display='none'}
|
function hideLogin(){document.getElementById('loginOverlay').style.display='none'}
|
||||||
|
|||||||
Reference in New Issue
Block a user