diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index f1412fc..70c60c3 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -133,7 +133,7 @@ - [ ] Exp → 等级 Lv1–10 - [ ] 自动徽章(注册天数 / 获赞 / 创作分成) - [ ] 限定徽章:管理员定义与授予/撤销 -- [ ] 管理员调整积分、设定等级 +- [x] 管理员调整积分、设定认证(免审)— SSR `/admin/users`(等级设定 UI 未做,仅只读展示 Lv) --- diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index d9c1870..23fe780 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -52,10 +52,11 @@ | `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 | | `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 | | `/admin/reports` | 举报处理 | 已迁 | +| `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 | | `/admin/trash` | 帖回收站 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 | -未迁(原 SPA):**users(下一刀,见 [09](09-ssr-progress.md))** / badges / media / 完整 Limits 等。 +未迁(原 SPA):badges / media / 完整 Limits 等。 --- diff --git a/docs/rebuild-spec/09-ssr-progress.md b/docs/rebuild-spec/09-ssr-progress.md index ee21c4f..957ee55 100644 --- a/docs/rebuild-spec/09-ssr-progress.md +++ b/docs/rebuild-spec/09-ssr-progress.md @@ -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 | 已迁 | | settings:品牌、限流、敏感词、SMTP、侧栏、SQLite 备份 | 已迁 | | friend-links / pages | 已迁 | -| **users / badges / media** | **未迁** | +| **users**(禁言 / 认证 / 调积分) | **已迁** | +| **badges / media** | **未迁** | | settings:OIDC / Gitea / 存储 / 伪静态 Tab | **未迁** | ### 近期提交(摘) -`poll` → SQLite 备份 → 友链复检 → `question` → `bounty` → `lottery`(`e06c548`) +`poll` → SQLite 备份 → 友链复检 → `question` → `bounty` → `lottery` → **Admin users** --- @@ -77,8 +78,8 @@ flowchart LR ### P1 — 推荐刀序(服务层多半已齐) -1. **Admin 用户** ← 当前指针 -2. Admin 徽章定义 + 授予/撤销 +1. ~~Admin 用户~~ ✓ +2. **Admin 徽章** ← 当前指针 3. Admin 媒体列表/删除 ### P2 — 成长与防刷 @@ -89,7 +90,7 @@ flowchart LR - 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin - TipTap / Markdown 双模(现行为 textarea + 门控) -- 游客评论、评论嵌套树(建议:全局 `#floor` + 缩进,编号语义先写入 `06`) +- 游客评论、评论嵌套树(建议:全局 `#floor` + 缩进) - 修订 diff、完整 Limits 字数 ### 后置(不阻塞验收) @@ -106,36 +107,33 @@ flowchart LR |----|----|------| | ✓ | 五种帖类型 + 备份/复检 + §O 勾选 | 核心闭环 | | ✓ | 本文 `09` | 可查阅进度 | -| → | Admin `/admin/users` | 禁言/验证/调积分 | -| 3 | Admin `/admin/badges` | 徽章 CRUD + 授予 | +| ✓ | Admin `/admin/users` | 禁言/认证/调积分 | +| → | Admin `/admin/badges` | 徽章 CRUD + 授予 | | 4 | Admin `/admin/media` | 媒体列表删除 | | 5 | 等级/自动徽章 **或** 防刷分成 | 按当时偏好 | | … | 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` -- 复用:`UserService.ListUsers` / `BanUser`;`SetVerified`;`PointsService.AdminAdjust` -- 模板:`templates/admin/users.tmpl` + `admin/nav` 入口 -- 列表:分页、关键词;展示角色、积分、禁言、verified -- 操作:禁言/解禁、切换认证、调积分(delta + 备注);CSRF + PRG -- 规格:勾选 `02` §H「管理员调整积分、设定等级」中与积分/认证相关部分(等级 UI 可后置);`06` 标明 `/admin/users` 已迁 +- `GET/POST /admin/badges`:徽章定义列表与创建/编辑/删除 +- 授予/撤销:挂在用户页或徽章详情(`GrantBadge` / 撤销) +- 回写 `02` §H 限定徽章;指针 → media -### 不做(本刀) +### 不做 -- 设定 Exp/等级 UI(可只读展示 level) -- 徽章授予(下一刀 badges) -- 恢复 `/api/admin/users` 给浏览器 +自动徽章规则引擎(P2)、等级设定 UI。 -### 验证 +--- -- `build.bat`;冒烟:列表 → 禁言 → 调积分 → 认证开关 +## 已完成:Admin 用户管理 + +见 [plans/admin-users.md](plans/admin-users.md);路由 `/admin/users` + ban / verify / points。 --- diff --git a/docs/rebuild-spec/plans/admin-users.md b/docs/rebuild-spec/plans/admin-users.md index d39c899..eb3a926 100644 --- a/docs/rebuild-spec/plans/admin-users.md +++ b/docs/rebuild-spec/plans/admin-users.md @@ -1,6 +1,6 @@ # Admin 用户管理(SSR)— 实现计划 -> 状态:待执行(指针见 [09-ssr-progress.md](../docs/rebuild-spec/09-ssr-progress.md)) +> 状态:已完成(SSR `/admin/users`) > 分支:`rebuild/gitea-ssr` ## 范围 diff --git a/public/assets/site.css b/public/assets/site.css index 8102ebf..bd0ce4b 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -1026,6 +1026,20 @@ points-only[data-locked="false"] { .j13-admin-filters a { color: var(--j13-muted); text-decoration: none; } .j13-admin-filters a.is-active, .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 { display: flex; flex-wrap: wrap; diff --git a/routers/web/admin_users.go b/routers/web/admin_users.go new file mode 100644 index 0000000..92fb975 --- /dev/null +++ b/routers/web/admin_users.go @@ -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") +} diff --git a/routers/web/home.go b/routers/web/home.go index f439ced..3385117 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -97,6 +97,10 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { admin.POST("/pages/:id/publish", deps.AdminPagePublishPost) admin.GET("/reports", deps.AdminReportsGet) 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.POST("/trash/:id/restore", deps.AdminTrashRestorePost) admin.POST("/trash/:id/purge", deps.AdminTrashPurgePost) diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl index 3b7cdc1..517ba4c 100644 --- a/templates/admin/nav.tmpl +++ b/templates/admin/nav.tmpl @@ -4,6 +4,7 @@ 板块 审核 举报 + 用户 回收站 友链 单页 diff --git a/templates/admin/users.tmpl b/templates/admin/users.tmpl new file mode 100644 index 0000000..fb154e9 --- /dev/null +++ b/templates/admin/users.tmpl @@ -0,0 +1,110 @@ +{{define "admin/users"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

用户管理

+ {{template "admin/nav" .}} + {{template "base/alert" .}} + + + +

+ 全部 + 认证 + 禁言 + 管理员 +

+ + {{if .Users}} + + + + + + + + + + + + + + {{range .Users}} + + + + + + + + + + {{end}} + +
ID用户角色积分 / Lv状态注册操作
{{.ID}} + {{.Nickname}} +
@{{.Username}}{{if .Email}} · {{.Email}}{{end}}
+
{{.RoleLabel}}{{.Points}} · Lv{{.Level}} + {{if .Banned}}禁言{{end}} + {{if .Verified}}认证{{end}} + {{if and (not .Banned) (not .Verified)}}{{end}} + {{.Created}} + {{if not .IsAdmin}} +
+ + {{if .Banned}} + + + {{else}} + + + {{end}} +
+
+ + {{if .Verified}} + + + {{else}} + + + {{end}} +
+ {{else}} + 管理员不可禁言 + {{end}} +
+ + + + +
+
+ {{if or .HasPrev .HasMore}} + + {{end}} + {{else}} +

没有匹配的用户。

+ {{end}} +
+{{template "base/footer" .}} +{{end}} diff --git a/web_src/css/site.css b/web_src/css/site.css index 8102ebf..bd0ce4b 100644 --- a/web_src/css/site.css +++ b/web_src/css/site.css @@ -1026,6 +1026,20 @@ points-only[data-locked="false"] { .j13-admin-filters a { color: var(--j13-muted); text-decoration: none; } .j13-admin-filters a.is-active, .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 { display: flex; flex-wrap: wrap;