feat: Admin 用户列表禁言、认证与调积分

SSR /admin/users 复用 ListUsers/BanUser/SetVerified/AdminAdjust,并回写 02/06/09 指针至徽章。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 03:38:38 +08:00
parent 5fb3b2a165
commit 02a5ad0c30
10 changed files with 363 additions and 27 deletions

View File

@@ -133,7 +133,7 @@
- [ ] Exp → 等级 Lv110 - [ ] Exp → 等级 Lv110
- [ ] 自动徽章(注册天数 / 获赞 / 创作分成) - [ ] 自动徽章(注册天数 / 获赞 / 创作分成)
- [ ] 限定徽章:管理员定义与授予/撤销 - [ ] 限定徽章:管理员定义与授予/撤销
- [ ] 管理员调整积分、设定等级 - [x] 管理员调整积分、设定认证(免审)— SSR `/admin/users`(等级设定 UI 未做,仅只读展示 Lv
--- ---

View File

@@ -52,10 +52,11 @@
| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 | | `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 |
| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 | | `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 |
| `/admin/reports` | 举报处理 | 已迁 | | `/admin/reports` | 举报处理 | 已迁 |
| `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 |
| `/admin/trash` | 帖回收站 | 已迁 | | `/admin/trash` | 帖回收站 | 已迁 |
| `/admin/login` | 重定向前台登录 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 |
未迁(原 SPA**users下一刀见 [09](09-ssr-progress.md)** / badges / media / 完整 Limits 等。 未迁(原 SPAbadges / media / 完整 Limits 等。
--- ---

View File

@@ -12,8 +12,8 @@
| 项 | 值 | | 项 | 值 |
|----|-----| |----|-----|
| **上一刀** | SSR 抽奖帖 `lottery`已推送 `e06c548` | | **上一刀** | Admin `/admin/users`禁言 / 认证 / 调积分 |
| **下一刀** | **Admin `/admin/users`**禁言 / 验证 / 调积分)— 见下文「下一刀实现提纲」 | | **下一刀** | **Admin `/admin/badges`**徽章定义 CRUD + 授予/撤销) |
| **工作区** | 应干净;有未提交改动时先处理再开新刀 | | **工作区** | 应干净;有未提交改动时先处理再开新刀 |
--- ---
@@ -59,12 +59,13 @@ flowchart LR
| dashboard / boards / moderation / reports / trash | 已迁 | | dashboard / boards / moderation / reports / trash | 已迁 |
| settings品牌、限流、敏感词、SMTP、侧栏、SQLite 备份 | 已迁 | | settings品牌、限流、敏感词、SMTP、侧栏、SQLite 备份 | 已迁 |
| friend-links / pages | 已迁 | | friend-links / pages | 已迁 |
| **users / badges / media** | **** | | **users**(禁言 / 认证 / 调积分) | **** |
| **badges / media** | **未迁** |
| settingsOIDC / Gitea / 存储 / 伪静态 Tab | **未迁** | | settingsOIDC / Gitea / 存储 / 伪静态 Tab | **未迁** |
### 近期提交(摘) ### 近期提交(摘)
`poll` → SQLite 备份 → 友链复检 → `question``bounty``lottery``e06c548` `poll` → SQLite 备份 → 友链复检 → `question``bounty``lottery`**Admin users**
--- ---
@@ -77,8 +78,8 @@ flowchart LR
### P1 — 推荐刀序(服务层多半已齐) ### P1 — 推荐刀序(服务层多半已齐)
1. **Admin 用户** ← 当前指针 1. ~~Admin 用户~~
2. Admin 徽章定义 + 授予/撤销 2. **Admin 徽章** ← 当前指针
3. Admin 媒体列表/删除 3. Admin 媒体列表/删除
### P2 — 成长与防刷 ### P2 — 成长与防刷
@@ -89,7 +90,7 @@ flowchart LR
- 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin - 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin
- TipTap / Markdown 双模(现行为 textarea + 门控) - TipTap / Markdown 双模(现行为 textarea + 门控)
- 游客评论、评论嵌套树(建议:全局 `#floor` + 缩进,编号语义先写入 `06` - 游客评论、评论嵌套树(建议:全局 `#floor` + 缩进)
- 修订 diff、完整 Limits 字数 - 修订 diff、完整 Limits 字数
### 后置(不阻塞验收) ### 后置(不阻塞验收)
@@ -106,36 +107,33 @@ flowchart LR
|----|----|------| |----|----|------|
| ✓ | 五种帖类型 + 备份/复检 + §O 勾选 | 核心闭环 | | ✓ | 五种帖类型 + 备份/复检 + §O 勾选 | 核心闭环 |
| ✓ | 本文 `09` | 可查阅进度 | | ✓ | 本文 `09` | 可查阅进度 |
| | Admin `/admin/users` | 禁言/证/调积分 | | | Admin `/admin/users` | 禁言/证/调积分 |
| 3 | Admin `/admin/badges` | 徽章 CRUD + 授予 | | | Admin `/admin/badges` | 徽章 CRUD + 授予 |
| 4 | Admin `/admin/media` | 媒体列表删除 | | 4 | Admin `/admin/media` | 媒体列表删除 |
| 5 | 等级/自动徽章 **或** 防刷分成 | 按当时偏好 | | 5 | 等级/自动徽章 **或** 防刷分成 | 按当时偏好 |
| … | P3 / 后置 | 需明确点名再开 | | … | P3 / 后置 | 需明确点名再开 |
--- ---
## 下一刀实现提纲Admin 用户管理 ## 下一刀实现提纲Admin 徽章
> 确认「继续」后按此实现;细则副本:[plans/admin-users.md](plans/admin-users.md)。不恢复论坛管理 JSON `/api` 为 UI 主路径 > 确认「继续」后实现限定徽章定义与授予/撤销;复用 `services/badge.go`
### 范围 ### 范围(草案)
- 路由:`GET /admin/users``POST /admin/users/:id/ban``/verify``/points` - `GET/POST /admin/badges`:徽章定义列表与创建/编辑/删除
- 复用:`UserService.ListUsers` / `BanUser``SetVerified``PointsService.AdminAdjust` - 授予/撤销:挂在用户页或徽章详情(`GrantBadge` / 撤销)
- 模板:`templates/admin/users.tmpl` + `admin/nav` 入口 - 回写 `02` §H 限定徽章;指针 → media
- 列表分页、关键词展示角色、积分、禁言、verified
- 操作:禁言/解禁、切换认证、调积分delta + 备注CSRF + PRG
- 规格:勾选 `02` §H「管理员调整积分、设定等级」中与积分/认证相关部分(等级 UI 可后置);`06` 标明 `/admin/users` 已迁
### 不做(本刀) ### 不做
- 设定 Exp/等级 UI可只读展示 level 自动徽章规则引擎P2、等级设定 UI。
- 徽章授予(下一刀 badges
- 恢复 `/api/admin/users` 给浏览器
### 验证 ---
- `build.bat`;冒烟:列表 → 禁言 → 调积分 → 认证开关 ## 已完成Admin 用户管理
见 [plans/admin-users.md](plans/admin-users.md);路由 `/admin/users` + ban / verify / points。
--- ---

View File

@@ -1,6 +1,6 @@
# Admin 用户管理SSR— 实现计划 # Admin 用户管理SSR— 实现计划
> 状态:待执行(指针见 [09-ssr-progress.md](../docs/rebuild-spec/09-ssr-progress.md) > 状态:已完成SSR `/admin/users`
> 分支:`rebuild/gitea-ssr` > 分支:`rebuild/gitea-ssr`
## 范围 ## 范围

View File

@@ -1026,6 +1026,20 @@ points-only[data-locked="false"] {
.j13-admin-filters a { color: var(--j13-muted); text-decoration: none; } .j13-admin-filters a { color: var(--j13-muted); text-decoration: none; }
.j13-admin-filters a.is-active, .j13-admin-filters a.is-active,
.j13-admin-filters a:hover { color: var(--j13-accent); font-weight: 600; } .j13-admin-filters a:hover { color: var(--j13-accent); font-weight: 600; }
.j13-admin-user-ops {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
max-width: 28rem;
}
.j13-admin-points {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: center;
margin: 0;
}
.j13-post__admin { .j13-post__admin {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

194
routers/web/admin_users.go Normal file
View File

@@ -0,0 +1,194 @@
package web
import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
type adminUserRow struct {
ID uint
Username string
Nickname string
Email string
Role string
RoleLabel string
Points int
Level int
Verified bool
Banned bool
IsAdmin bool
Created string
}
type adminUsersData struct {
AdminChrome
Users []adminUserRow
Keyword string
Filter string
Page int
HasPrev bool
HasMore bool
PrevPage int
NextPage int
QuerySuffix string
}
// AdminUsersGet 用户列表
func (d Deps) AdminUsersGet(c *gin.Context) {
ctx := d.ctx(c)
d.renderAdminUsers(ctx, "")
}
func (d Deps) renderAdminUsers(ctx *webctx.Context, errMsg string) {
chrome := d.adminChrome(ctx, "用户", "users")
chrome.Error = errMsg
page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
keyword := strings.TrimSpace(ctx.C.Query("q"))
filter := strings.TrimSpace(ctx.C.DefaultQuery("filter", "all"))
if filter == "" {
filter = "all"
}
size := d.Settings.PageSizeDefault()
if size < 1 {
size = 20
}
data := adminUsersData{
AdminChrome: chrome,
Keyword: keyword,
Filter: filter,
Page: page,
PrevPage: page - 1,
NextPage: page + 1,
HasPrev: page > 1,
}
q := url.Values{}
if keyword != "" {
q.Set("q", keyword)
}
if filter != "" && filter != "all" {
q.Set("filter", filter)
}
data.QuerySuffix = q.Encode()
if d.User != nil {
list, total, err := d.User.ListUsers(services.UserListQuery{
Page: page, Size: size, Keyword: keyword, Filter: filter,
})
if err != nil {
chrome.Error = err.Error()
data.AdminChrome = chrome
ctx.HTML(http.StatusOK, "admin/users", data)
return
}
data.HasMore = int64(page*size) < total
data.Users = make([]adminUserRow, 0, len(list))
for _, u := range list {
nick := strings.TrimSpace(u.Nickname)
if nick == "" {
nick = u.Username
}
data.Users = append(data.Users, adminUserRow{
ID: u.ID, Username: u.Username, Nickname: nick, Email: u.Email,
Role: string(u.Role), RoleLabel: adminUserRoleLabel(u.Role),
Points: u.Points, Level: models.LevelFromExp(u.Exp),
Verified: u.Verified, Banned: u.Banned,
IsAdmin: u.Role == models.RoleAdmin,
Created: u.CreatedAt.Format("2006-01-02"),
})
}
}
ctx.HTML(http.StatusOK, "admin/users", data)
}
func adminUserRoleLabel(role models.Role) string {
switch role {
case models.RoleAdmin:
return "管理员"
default:
return "用户"
}
}
// AdminUserBanPost 禁言 / 解禁
func (d Deps) AdminUserBanPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminUsers(ctx, "无效请求,请重试")
return
}
if d.User == nil {
d.renderAdminUsers(ctx, "用户服务未就绪")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
banned := c.PostForm("banned") == "1" || c.PostForm("banned") == "on"
if err := d.User.BanUser(uint(id64), banned); err != nil {
d.renderAdminUsers(ctx, err.Error())
return
}
if banned {
ctx.SetFlash("已禁言该用户")
} else {
ctx.SetFlash("已解除禁言")
}
ctx.Redirect("/admin/users")
}
// AdminUserVerifyPost 认证开关
func (d Deps) AdminUserVerifyPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminUsers(ctx, "无效请求,请重试")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
verified := c.PostForm("verified") == "1" || c.PostForm("verified") == "on"
if err := services.SetVerified(uint(id64), verified); err != nil {
d.renderAdminUsers(ctx, err.Error())
return
}
if verified {
ctx.SetFlash("已设为认证用户")
} else {
ctx.SetFlash("已取消认证")
}
ctx.Redirect("/admin/users")
}
// AdminUserPointsPost 调整积分
func (d Deps) AdminUserPointsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminUsers(ctx, "无效请求,请重试")
return
}
if d.Points == nil {
d.renderAdminUsers(ctx, "积分服务未就绪")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
delta, err := strconv.Atoi(strings.TrimSpace(c.PostForm("delta")))
if err != nil || delta == 0 {
d.renderAdminUsers(ctx, "请填写非零整数增减额")
return
}
note := strings.TrimSpace(c.PostForm("note"))
bal, err := d.Points.AdminAdjust(uint(id64), delta, note)
if err != nil {
d.renderAdminUsers(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("积分已调整,当前余额 %d", bal))
ctx.Redirect("/admin/users")
}

View File

@@ -97,6 +97,10 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
admin.POST("/pages/:id/publish", deps.AdminPagePublishPost) admin.POST("/pages/:id/publish", deps.AdminPagePublishPost)
admin.GET("/reports", deps.AdminReportsGet) admin.GET("/reports", deps.AdminReportsGet)
admin.POST("/reports/:id/handle", deps.AdminReportHandlePost) admin.POST("/reports/:id/handle", deps.AdminReportHandlePost)
admin.GET("/users", deps.AdminUsersGet)
admin.POST("/users/:id/ban", deps.AdminUserBanPost)
admin.POST("/users/:id/verify", deps.AdminUserVerifyPost)
admin.POST("/users/:id/points", deps.AdminUserPointsPost)
admin.GET("/trash", deps.AdminTrashGet) admin.GET("/trash", deps.AdminTrashGet)
admin.POST("/trash/:id/restore", deps.AdminTrashRestorePost) admin.POST("/trash/:id/restore", deps.AdminTrashRestorePost)
admin.POST("/trash/:id/purge", deps.AdminTrashPurgePost) admin.POST("/trash/:id/purge", deps.AdminTrashPurgePost)

View File

@@ -4,6 +4,7 @@
<a href="/admin/boards"{{if eq .NavActive "boards"}} class="is-active"{{end}}>板块</a> <a href="/admin/boards"{{if eq .NavActive "boards"}} class="is-active"{{end}}>板块</a>
<a href="/admin/moderation"{{if eq .NavActive "moderation"}} class="is-active"{{end}}>审核</a> <a href="/admin/moderation"{{if eq .NavActive "moderation"}} class="is-active"{{end}}>审核</a>
<a href="/admin/reports"{{if eq .NavActive "reports"}} class="is-active"{{end}}>举报</a> <a href="/admin/reports"{{if eq .NavActive "reports"}} class="is-active"{{end}}>举报</a>
<a href="/admin/users"{{if eq .NavActive "users"}} class="is-active"{{end}}>用户</a>
<a href="/admin/trash"{{if eq .NavActive "trash"}} class="is-active"{{end}}>回收站</a> <a href="/admin/trash"{{if eq .NavActive "trash"}} class="is-active"{{end}}>回收站</a>
<a href="/admin/friend-links"{{if eq .NavActive "friend-links"}} class="is-active"{{end}}>友链</a> <a href="/admin/friend-links"{{if eq .NavActive "friend-links"}} class="is-active"{{end}}>友链</a>
<a href="/admin/pages"{{if eq .NavActive "pages"}} class="is-active"{{end}}>单页</a> <a href="/admin/pages"{{if eq .NavActive "pages"}} class="is-active"{{end}}>单页</a>

110
templates/admin/users.tmpl Normal file
View File

@@ -0,0 +1,110 @@
{{define "admin/users"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-admin">
<h1>用户管理</h1>
{{template "admin/nav" .}}
{{template "base/alert" .}}
<form method="get" action="/admin/users" class="j13-form j13-admin-form j13-admin-search">
<label>搜索 <input name="q" value="{{.Keyword}}" placeholder="用户名 / 昵称 / 邮箱 / ID"/></label>
<label>筛选
<select name="filter">
<option value="all"{{if eq .Filter "all"}} selected{{end}}>全部</option>
<option value="verified"{{if eq .Filter "verified"}} selected{{end}}>认证用户</option>
<option value="banned"{{if eq .Filter "banned"}} selected{{end}}>已禁言</option>
<option value="admin"{{if eq .Filter "admin"}} selected{{end}}>管理员</option>
</select>
</label>
<button type="submit">查询</button>
</form>
<p class="j13-admin-filters">
<a href="/admin/users"{{if eq .Filter "all"}} class="is-active"{{end}}>全部</a>
<a href="/admin/users?filter=verified"{{if eq .Filter "verified"}} class="is-active"{{end}}>认证</a>
<a href="/admin/users?filter=banned"{{if eq .Filter "banned"}} class="is-active"{{end}}>禁言</a>
<a href="/admin/users?filter=admin"{{if eq .Filter "admin"}} class="is-active"{{end}}>管理员</a>
</p>
{{if .Users}}
<table class="j13-admin-table">
<thead>
<tr>
<th>ID</th>
<th>用户</th>
<th>角色</th>
<th>积分 / Lv</th>
<th>状态</th>
<th>注册</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.ID}}</td>
<td>
<a href="/user/{{.ID}}">{{.Nickname}}</a>
<div class="j13-muted">@{{.Username}}{{if .Email}} · {{.Email}}{{end}}</div>
</td>
<td>{{.RoleLabel}}</td>
<td>{{.Points}} · Lv{{.Level}}</td>
<td>
{{if .Banned}}<span class="j13-tag j13-tag--rejected">禁言</span>{{end}}
{{if .Verified}}<span class="j13-tag j13-tag--resolved">认证</span>{{end}}
{{if and (not .Banned) (not .Verified)}}<span class="j13-muted">—</span>{{end}}
</td>
<td>{{.Created}}</td>
<td class="j13-admin-user-ops">
{{if not .IsAdmin}}
<form method="post" action="/admin/users/{{.ID}}/ban" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
{{if .Banned}}
<input type="hidden" name="banned" value="0"/>
<button type="submit" class="j13-btn-secondary">解禁</button>
{{else}}
<input type="hidden" name="banned" value="1"/>
<button type="submit">禁言</button>
{{end}}
</form>
<form method="post" action="/admin/users/{{.ID}}/verify" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
{{if .Verified}}
<input type="hidden" name="verified" value="0"/>
<button type="submit" class="j13-btn-secondary">取消认证</button>
{{else}}
<input type="hidden" name="verified" value="1"/>
<button type="submit" class="j13-btn-secondary">设为认证</button>
{{end}}
</form>
{{else}}
<span class="j13-muted">管理员不可禁言</span>
{{end}}
<form method="post" action="/admin/users/{{.ID}}/points" class="j13-form j13-admin-points">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<input name="delta" type="number" required placeholder="±积分" style="width:5.5rem"/>
<input name="note" maxlength="120" placeholder="备注(可选)" style="width:8rem"/>
<button type="submit" class="j13-btn-secondary">调积分</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{if or .HasPrev .HasMore}}
<nav class="j13-pager">
{{if .HasPrev}}
<a href="/admin/users?{{if .QuerySuffix}}{{.QuerySuffix}}&amp;{{end}}page={{.PrevPage}}">上一页</a>
{{end}}
<span>第 {{.Page}} 页</span>
{{if .HasMore}}
<a href="/admin/users?{{if .QuerySuffix}}{{.QuerySuffix}}&amp;{{end}}page={{.NextPage}}">下一页</a>
{{end}}
</nav>
{{end}}
{{else}}
<p class="j13-empty">没有匹配的用户。</p>
{{end}}
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -1026,6 +1026,20 @@ points-only[data-locked="false"] {
.j13-admin-filters a { color: var(--j13-muted); text-decoration: none; } .j13-admin-filters a { color: var(--j13-muted); text-decoration: none; }
.j13-admin-filters a.is-active, .j13-admin-filters a.is-active,
.j13-admin-filters a:hover { color: var(--j13-accent); font-weight: 600; } .j13-admin-filters a:hover { color: var(--j13-accent); font-weight: 600; }
.j13-admin-user-ops {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
max-width: 28rem;
}
.j13-admin-points {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: center;
margin: 0;
}
.j13-post__admin { .j13-post__admin {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;