feat: wire admin UI to credit ledger APIs

This commit is contained in:
OpenClaw Agent
2026-09-17 06:08:20 +08:00
parent d9df512e0a
commit 3025019504
4 changed files with 146 additions and 2 deletions
+49
View File
@@ -0,0 +1,49 @@
# AM Downloader 交互界面 (UI) 重构与优化文档
## 1. 概述与背景
`am-downloader` 原先的 `index.html` 采用单页平铺逻辑,随着多用户鉴权、积分兑换、用户级 WebDAV 与管理员后台的引入,旧版界面存在层级分散、导航冗余、缺乏现代感与移动端适配生硬等问题。
本项目已完成系统级的前端现代化重构,并保持对后端原有 API 契约和前端 DOM 事件完全兼容,目前已在运行中的 Docker 容器环境(端口 `9540`)热更新上线生效。
---
## 2. 视觉设计系统规范
### 2.1 配色与质感(Modern Dark Glassmorphism)
- **深色基底**:主背景采用 `#080c14`,配合双色极光渐变暗影(翡翠绿 `#10b981` 与天蓝 `#38bdf8`),提升流媒体科技质感。
- **毛玻璃面板**:卡片与导航栏统一采用 `rgba(17, 26, 42, 0.75)` 配以 `backdrop-filter: blur(16px)`,边框引入半透明微发光分割线(`rgba(255, 255, 255, 0.08)`)。
- **字体与层级**:接入 `Plus Jakarta Sans` 作为主要无衬线英文字体,`JetBrains Mono` 作为代码、卡密与音质参数等宽字体。
---
## 3. 核心功能与架构重构细节
### 3.1 导航体系净化与精简
1. **移除顶部及移动端独立「设置」按钮**:
- 彻底移除了原先顶栏导航与移动端底栏的独立「设置」入口。
- 所有系统运行配置统一纳管在管理员后台内部,避免入口碎片化。
2. **下载按钮去除角标数字**:
- 移除桌面端导航与移动端底栏「下载」按钮后方突兀的数字徽章(Badge),保持导航栏整体视觉纯粹和谐。
- 保留 DOM 节点以保证后台任务轮询更新逻辑的向后兼容,不报错不中断。
### 3.2 管理控制台(Admin Center)全面重构
1. **分段式选项卡(Segmented Tabs)**:
- 废弃原有的“点击磁贴切换子面板再返回”繁琐交互,重构为顶部毛玻璃分段 Tab 栏:
- `👥 用户管理`
- `◎ 积分账本`
- `◇ 卡密批次`
- `≡ 审计日志`
- `⚙️ 全局设置`(由原“纳管设置”更名,系统 API、下载存储策略、全局 WebDAV 完整收拢在此)
- Tab 切换平滑无刷新,记忆当前选中状态。
2. **抽屉详情交互(Slide-over Drawer)**:
- 用户详情不再替换整页或跳转离开当前列表。
- 点击任意用户的「查看详情」时,桌面端由右侧平滑滑出毛玻璃抽屉面板,移动端自适应为底部滑起抽屉(Bottom Sheet)。
- 抽屉内置:账号角色与状态、密码重置、账号启停、积分增减与实时流水。
- 配合磨砂半透明背景遮罩(Backdrop),点击空白处或「✕ 关闭」按钮即可无缝收起,列表浏览上下文不丢失。
---
## 4. 兼容性与上线记录
- **DOM 契约兼容**:保留所有核心配置 ID(`amApi`、`downloadDir`、`localFilePolicy`、`defaultQuality`、`davUrl` 等)以及事件回调机制。
- **上线状态**:最新代码已提交 Git(分支 `master`),并同步热更新至运行中的 `am-downloader` 容器内 `/app/index.html`。
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"github.com/gin-gonic/gin"
"strconv"
)
func (a *App) adminUserCreditTransactions(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
}
limit := 50
if n, e := strconv.Atoi(c.DefaultQuery("limit", "50")); e == nil && n > 0 && n <= 200 {
limit = n
}
var txs []CreditTransaction
q := a.db.Where("user_id = ?", id).Order("id desc").Limit(limit)
if before := c.Query("before"); before != "" {
if n, e := strconv.ParseUint(before, 10, 64); e == nil {
q = q.Where("id < ?", n)
}
}
if err := q.Find(&txs).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
type item struct {
ID uint `json:"id"`
Type string `json:"type"`
Amount int64 `json:"amount"`
BalanceAfter int64 `json:"balanceAfter"`
ReferenceType string `json:"referenceType"`
ReferenceID string `json:"referenceId"`
Reason string `json:"reason"`
CreatedAt interface{} `json:"createdAt"`
}
out := make([]item, 0, len(txs))
for _, t := range txs {
out = append(out, item{t.ID, t.Type, t.Amount, t.BalanceAfter, t.ReferenceType, t.ReferenceID, t.Reason, t.CreatedAt})
}
next := uint(0)
if len(txs) == limit {
next = txs[len(txs)-1].ID
}
c.JSON(200, gin.H{"data": out, "nextBefore": next})
}
func (a *App) adminCreditTransactions(c *gin.Context) {
limit := 100
if n, e := strconv.Atoi(c.DefaultQuery("limit", "100")); e == nil && n > 0 && n <= 300 {
limit = n
}
var txs []CreditTransaction
q := a.db.Order("id desc").Limit(limit)
if uid := c.Query("user_id"); uid != "" {
if n, e := strconv.ParseUint(uid, 10, 64); e == nil {
q = q.Where("user_id = ?", n)
}
}
if before := c.Query("before"); before != "" {
if n, e := strconv.ParseUint(before, 10, 64); e == nil {
q = q.Where("id < ?", n)
}
}
if err := q.Find(&txs).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
type item struct {
ID uint `json:"id"`
UserID uint `json:"userId"`
Type string `json:"type"`
Amount int64 `json:"amount"`
BalanceAfter int64 `json:"balanceAfter"`
ReferenceType string `json:"referenceType"`
ReferenceID string `json:"referenceId"`
Reason string `json:"reason"`
CreatedAt interface{} `json:"createdAt"`
}
out := make([]item, 0, len(txs))
for _, t := range txs {
out = append(out, item{t.ID, t.UserID, t.Type, t.Amount, t.BalanceAfter, t.ReferenceType, t.ReferenceID, t.Reason, t.CreatedAt})
}
next := uint(0)
if len(txs) == limit {
next = txs[len(txs)-1].ID
}
c.JSON(200, gin.H{"data": out, "nextBefore": next})
}
+2 -2
View File
@@ -1367,10 +1367,10 @@ async function loadUserCredit(id){try{const c=await api('/admin/users/'+id+'/cre
async function openUserDetail(id){const u=adminUsersCache.find(x=>x.id===id);if(!u)return;const next=u.status==='active'?'disabled':'active';$("detailUserName").textContent=u.username;$("detailUserMeta").textContent="#"+u.id+" · "+u.role+" · "+u.status;$("userDetailContent").innerHTML="<div class='detail-grid'><div class='detail-card'><h4>账户</h4><div class='muted'>角色</div><div>"+esc(u.role)+"</div><div class='muted' style='margin-top:8px'>状态</div><div>"+esc(u.status)+"</div><div class='detail-actions'><button class='btn alt' id='detailReset'>重置密码</button><button class='btn alt' id='detailToggle'>"+(u.status==='active'?'禁用账户':'启用账户')+"</button></div></div><div class='detail-card' id='detailCredits'>加载积分中…</div></div>";const drawer = $('adminUserDetail');
const backdrop = $('drawerBackdrop');
if(drawer) drawer.classList.add('active');
if(backdrop) backdrop.classList.add('active');$("detailReset").onclick=()=>resetUserPassword(id);$("detailToggle").onclick=()=>toggleUser(id,next);try{const c=await api('/admin/users/'+id+'/credits');$("detailCredits").innerHTML="<h4>积分</h4><div class='detail-value'>"+c.balance+"</div><div class='muted'>可用 "+c.available+" · 预留 "+c.reserved+" · 下载 "+c.tasks+" 次</div><div class='detail-actions'><button class='btn' id='detailAdd'>增加积分</button><button class='btn alt' id='detailSub'>扣除积分</button></div>";$("detailAdd").onclick=()=>adjustCredits(id,1);$("detailSub").onclick=()=>adjustCredits(id,-1)}catch(e){$("detailCredits").textContent=e.message}}
if(backdrop) backdrop.classList.add('active');$("detailReset").onclick=()=>resetUserPassword(id);$("detailToggle").onclick=()=>toggleUser(id,next);try{const c=await api('/admin/users/'+id+'/credits');$("detailCredits").innerHTML="<h4>积分</h4><div class='detail-value'>"+c.balance+"</div><div class='muted'>可用 "+c.available+" · 预留 "+c.reserved+" · 下载 "+c.tasks+" 次</div><div class='detail-actions'><button class='btn' id='detailAdd'>增加积分</button><button class='btn alt' id='detailSub'>扣除积分</button></div>";$("detailAdd").onclick=()=>adjustCredits(id,1);$("detailSub").onclick=()=>adjustCredits(id,-1);const l=await api('/admin/users/'+id+'/credits/transactions?limit=30');$("detailCredits").innerHTML+="<div class='ledger' style='margin-top:18px'><div class='muted' style='margin-bottom:6px'>最近积分流水</div>"+((l.data||[]).map(t=>"<div class='ledger-item'><div><b>"+esc(t.type)+"</b> <span class='"+(t.amount>=0?'positive':'negative')+"'>"+(t.amount>=0?'+':'')+t.amount+"</span></div><div class='meta'>余额 "+t.balanceAfter+" · "+esc(t.reason||'')+" · "+esc(t.createdAt||'')+"</div></div>").join('')||"<div class='empty'>暂无流水</div>")+"</div>"}catch(e){$("detailCredits").textContent=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);openUserDetail(id);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('密码已重置,该用户的其他登录会话已失效')}catch(e){alert('密码重置失败:'+e.message)}}
async function loadCreditLedger(){try{const u=await api('/admin/users');const users=u.data||[];let total=0;for(const x of users){try{const c=await api('/admin/users/'+x.id+'/credits');total+=Number(c.balance)||0}catch(e){}}$('adminCreditSummary').innerHTML='<div class="summary-card">用户<strong>'+users.length+'</strong></div><div class="summary-card">当前余额<strong>'+total+'</strong></div><div class="summary-card">单位<strong>积分</strong></div>';$('creditLedger').innerHTML='<div class="notice">积分流水按用户查看。进入「用户管理 → 查看详情」即可调整积分并查看该用户当前余额。</div>'}catch(e){$('adminCreditSummary').textContent=e.message}}
async function loadCreditLedger(){try{const u=await api('/admin/users');const users=u.data||[];let total=0;for(const x of users)total+=Number(x.balance)||0;const l=await api('/admin/credits/transactions?limit=100');$('adminCreditSummary').innerHTML='<div class="summary-card">用户<strong>'+users.length+'</strong></div><div class="summary-card">当前余额<strong>'+total+'</strong></div><div class="summary-card">流水<strong>'+((l.data||[]).length)+'</strong></div>';$('creditLedger').innerHTML=(l.data||[]).map(t=>'<div class="ledger-item"><div><b>#'+t.userId+'</b> · '+esc(t.type)+' <span class="'+(t.amount>=0?'positive':'negative')+'">'+(t.amount>=0?'+':'')+t.amount+'</span></div><div class="meta">余额 '+t.balanceAfter+' · '+esc(t.reason||'')+' · '+esc(t.createdAt||'')+'</div></div>').join('')||'<div class="empty">暂无积分流水</div>'}catch(e){$('adminCreditSummary').textContent=e.message}}
async function loadRedeemAdmin(){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('')}
async function loadAuditAdmin(){const l=await api('/admin/audit');$('audit').innerHTML=(l.data||[]).map(x=>'<div class="ledger-item"><b>'+esc(x.action)+'</b><div class="meta">'+esc(x.targetType)+'#'+esc(x.targetId)+' · '+esc(x.metadata||'')+' · '+esc(x.createdAt)+'</div></div>').join('')||'<div class="empty">暂无日志</div>'}
async function toggleUser(id,status){const u=adminUsersCache.find(x=>x.id===id);status=status||(u&&u.status==='active'?'disabled':'active');try{await api('/admin/users/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status})});const u=adminUsersCache.find(x=>x.id===id);if(u)u.status=status;renderAdminUsers()}catch(e){alert('操作失败:'+e.message)}}
+4
View File
@@ -59,6 +59,10 @@ func main() {
auth.DELETE("/tasks/:id", a.requirePermission(PermDownload), a.deleteTask)
admin := api.Group("/admin", a.requireAuth, a.requireAdmin)
admin.GET("/users", a.adminListUsers)
admin.GET("/users/:id/credits", a.adminUserCredits)
admin.POST("/users/:id/credits", a.adminAdjustCredits)
admin.GET("/users/:id/credits/transactions", a.adminUserCreditTransactions)
admin.GET("/credits/transactions", a.adminCreditTransactions)
admin.GET("/audit", a.adminAudit)
admin.POST("/users", a.adminCreateUser)
admin.PUT("/users/:id", a.adminUpdateUser)