From 0c5fe8a1b46a2700bb8e17333c7946c4a7703db3 Mon Sep 17 00:00:00 2001 From: freefire Date: Sun, 30 Aug 2026 03:42:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Admin=20=E5=BE=BD=E7=AB=A0=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=20CRUD=20=E4=B8=8E=E9=99=90=E5=AE=9A=E9=A2=81?= =?UTF-8?q?=E5=8F=91=E6=94=B6=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSR /admin/badges 复用 UpsertDef/AwardLimited/Revoke,并新增 DeleteDef;指针指向 media。 Co-authored-by: Cursor --- docs/rebuild-spec/02-features.md | 4 +- docs/rebuild-spec/06-pages-ux.md | 7 +- docs/rebuild-spec/09-ssr-progress.md | 37 +-- docs/rebuild-spec/plans/admin-badges.md | 22 ++ public/assets/site.css | 6 + routers/setup.go | 1 + routers/web/admin_badges.go | 319 ++++++++++++++++++++++++ routers/web/chrome.go | 1 + routers/web/home.go | 7 + services/badge.go | 14 ++ templates/admin/badges.tmpl | 114 +++++++++ templates/admin/nav.tmpl | 1 + web_src/css/site.css | 6 + 13 files changed, 518 insertions(+), 21 deletions(-) create mode 100644 docs/rebuild-spec/plans/admin-badges.md create mode 100644 routers/web/admin_badges.go create mode 100644 templates/admin/badges.tmpl diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 70c60c3..10a3601 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -131,8 +131,8 @@ - [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务 - [ ] 短龄同 IP 互刷拒绝分成 - [ ] Exp → 等级 Lv1–10 -- [ ] 自动徽章(注册天数 / 获赞 / 创作分成) -- [ ] 限定徽章:管理员定义与授予/撤销 +- [ ] 自动徽章(注册天数 / 获赞 / 创作分成)— 种子定义 + `EvaluateAuto` 已有;Admin 可改定义,独立调度 UI 未做 +- [x] 限定徽章:管理员定义与授予/撤销 — SSR `/admin/badges` - [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 23fe780..98df4ea 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -53,10 +53,11 @@ | `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 | | `/admin/reports` | 举报处理 | 已迁 | | `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 | +| `/admin/badges` | 徽章定义 CRUD、限定颁发/收回 | 已迁 | | `/admin/trash` | 帖回收站 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 | -未迁(原 SPA):badges / media / 完整 Limits 等。 +未迁(原 SPA):media / 完整 Limits 等。 --- @@ -194,8 +195,8 @@ | Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` | | Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 | | Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` | -| Users | 搜索;禁言;认证;设等级;调积分;授徽章 | -| Badges | 定义自动/限定徽章 | +| Users | 搜索;禁言;认证;调积分 — SSR `/admin/users`(设等级 / 授徽章挂在徽章页) | +| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` | | Media | 分类浏览;批量删 | | Settings | Tab:论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌/限流/侧栏/敏感词/邮件/SQLite 备份(OIDC/Gitea/存储/伪静态未迁) | diff --git a/docs/rebuild-spec/09-ssr-progress.md b/docs/rebuild-spec/09-ssr-progress.md index 957ee55..2669556 100644 --- a/docs/rebuild-spec/09-ssr-progress.md +++ b/docs/rebuild-spec/09-ssr-progress.md @@ -12,15 +12,15 @@ | 项 | 值 | |----|-----| -| **上一刀** | Admin `/admin/users`(禁言 / 认证 / 调积分) | -| **下一刀** | **Admin `/admin/badges`**(徽章定义 CRUD + 授予/撤销) | +| **上一刀** | Admin `/admin/badges`(定义 CRUD + 限定颁发/收回) | +| **下一刀** | **Admin `/admin/media`**(媒体列表 / 删除) | | **工作区** | 应干净;有未提交改动时先处理再开新刀 | --- ## 一句话状态 -论坛核心闭环(安装、认证、Feed、五种帖类型、评论、私信、友链、审核、回收站、基础设置、备份)**已迁完**。余量主要是 Admin 用户/徽章/媒体、成长体系、OIDC/存储产品化与体验打磨。 +论坛核心闭环(安装、认证、Feed、五种帖类型、评论、私信、友链、审核、回收站、基础设置、备份)**已迁完**。Admin 用户与徽章已齐;余量主要是媒体、成长体系、OIDC/存储产品化与体验打磨。 ```mermaid flowchart LR @@ -60,12 +60,13 @@ flowchart LR | settings:品牌、限流、敏感词、SMTP、侧栏、SQLite 备份 | 已迁 | | friend-links / pages | 已迁 | | **users**(禁言 / 认证 / 调积分) | **已迁** | -| **badges / media** | **未迁** | +| **badges**(定义 CRUD + 限定颁发/收回) | **已迁** | +| **media** | **未迁** | | settings:OIDC / Gitea / 存储 / 伪静态 Tab | **未迁** | ### 近期提交(摘) -`poll` → SQLite 备份 → 友链复检 → `question` → `bounty` → `lottery` → **Admin users** +`lottery` → Admin users → **Admin badges** --- @@ -79,12 +80,12 @@ flowchart LR ### P1 — 推荐刀序(服务层多半已齐) 1. ~~Admin 用户~~ ✓ -2. **Admin 徽章** ← 当前指针 -3. Admin 媒体列表/删除 +2. ~~Admin 徽章~~ ✓ +3. **Admin 媒体** ← 当前指针 ### P2 — 成长与防刷 -- Exp → Lv1–10、自动徽章、短龄同 IP 互刷拒绝分成(`02` §H) +- Exp → Lv1–10、自动徽章调度 UI、短龄同 IP 互刷拒绝分成(`02` §H) ### P3 — 体验 / 编辑器(可砍或长期) @@ -108,29 +109,33 @@ flowchart LR | ✓ | 五种帖类型 + 备份/复检 + §O 勾选 | 核心闭环 | | ✓ | 本文 `09` | 可查阅进度 | | ✓ | Admin `/admin/users` | 禁言/认证/调积分 | -| → | Admin `/admin/badges` | 徽章 CRUD + 授予 | -| 4 | Admin `/admin/media` | 媒体列表删除 | +| ✓ | Admin `/admin/badges` | 徽章 CRUD + 授予 | +| → | Admin `/admin/media` | 媒体列表删除 | | 5 | 等级/自动徽章 **或** 防刷分成 | 按当时偏好 | | … | P3 / 后置 | 需明确点名再开 | --- -## 下一刀实现提纲:Admin 徽章 +## 下一刀实现提纲:Admin 媒体 -> 确认「继续」后实现限定徽章定义与授予/撤销;复用 `services/badge.go`。 +> 确认「继续」后实现媒体库列表与删除;对齐既有上传/存储服务。 ### 范围(草案) -- `GET/POST /admin/badges`:徽章定义列表与创建/编辑/删除 -- 授予/撤销:挂在用户页或徽章详情(`GrantBadge` / 撤销) -- 回写 `02` §H 限定徽章;指针 → media +- `GET /admin/media`:按时间分页列表(缩略图 / 路径 / 引用粗览) +- `POST /admin/media/:id/delete`:删库文件 + 记录 +- 回写 `02` / `06`;指针 → 成长或按偏好 ### 不做 -自动徽章规则引擎(P2)、等级设定 UI。 +S3 热切换产品化、全站未引用扫描清理器。 --- +## 已完成:Admin 徽章 + +见 [plans/admin-badges.md](plans/admin-badges.md);路由 `/admin/badges` + award / revoke。 + ## 已完成:Admin 用户管理 见 [plans/admin-users.md](plans/admin-users.md);路由 `/admin/users` + ban / verify / points。 diff --git a/docs/rebuild-spec/plans/admin-badges.md b/docs/rebuild-spec/plans/admin-badges.md new file mode 100644 index 0000000..0b496f5 --- /dev/null +++ b/docs/rebuild-spec/plans/admin-badges.md @@ -0,0 +1,22 @@ +# Admin 徽章管理(SSR)— 实现计划 + +> 状态:已完成(SSR `/admin/badges`) +> 分支:`rebuild/gitea-ssr` + +## 范围 + +| 能力 | 服务 | 路由 | +|------|------|------| +| 列表(含停用) | `BadgeService.ListDefs(true)` | `GET /admin/badges` | +| 创建/更新定义 | `UpsertDef` | `POST /admin/badges`、`POST /admin/badges/:id` | +| 删除定义 | `DeleteDef`(清 `user_badges`) | `POST /admin/badges/:id/delete` | +| 颁发限定 | `AwardLimited` | `POST /admin/badges/award` | +| 收回 | `Revoke` | `POST /admin/badges/revoke` | + +## 不做 + +自动徽章规则引擎调度 UI(P2,服务层 `EvaluateAuto` 仍可被触发)、等级设定。 + +## 验证 + +`build.bat`;冒烟:创建限定徽章 → 颁发给用户 → 收回 → 删除。 diff --git a/public/assets/site.css b/public/assets/site.css index bd0ce4b..f40e437 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -1040,6 +1040,12 @@ points-only[data-locked="false"] { align-items: center; margin: 0; } +.j13-admin-badge-award-btns { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} .j13-post__admin { display: flex; flex-wrap: wrap; diff --git a/routers/setup.go b/routers/setup.go index 84448ec..fd3ac46 100644 --- a/routers/setup.go +++ b/routers/setup.go @@ -103,6 +103,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Message: messageSvc, Filter: filter, Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore, Points: services.NewPointsService(), + Badge: services.NewBadgeService(), FriendLink: friendLinkApplySvc, Mail: mailSvc, SitePage: sitePageSvc, diff --git a/routers/web/admin_badges.go b/routers/web/admin_badges.go new file mode 100644 index 0000000..feae72f --- /dev/null +++ b/routers/web/admin_badges.go @@ -0,0 +1,319 @@ +package web + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "git.iioio.com/freefire/jiang13-forum/models" + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "github.com/gin-gonic/gin" +) + +type adminBadgeRow struct { + ID uint + Code string + Name string + Description string + Icon string + Kind string + KindLabel string + Metric string + Threshold int + SortOrder int + Enabled bool + IsLimited bool +} + +type adminBadgeForm struct { + ID uint + Code string + Name string + Description string + Icon string + Kind string + Metric string + Threshold int + SortOrder int + Enabled bool + IsEdit bool +} + +type adminBadgesData struct { + AdminChrome + Badges []adminBadgeRow + LimitedBadges []adminBadgeRow + Form adminBadgeForm + AwardUser string + AwardBadgeID uint +} + +// AdminBadgesGet 徽章定义列表 +func (d Deps) AdminBadgesGet(c *gin.Context) { + ctx := d.ctx(c) + d.renderAdminBadges(ctx, "", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0) +} + +func (d Deps) renderAdminBadges(ctx *webctx.Context, errMsg string, form adminBadgeForm, awardUser string, awardBadgeID uint) { + chrome := d.adminChrome(ctx, "徽章", "badges") + chrome.Error = errMsg + if form.Kind == "" { + form.Kind = models.BadgeKindLimited + } + data := adminBadgesData{ + AdminChrome: chrome, + Form: form, + AwardUser: awardUser, + AwardBadgeID: awardBadgeID, + } + if d.Badge != nil { + list, err := d.Badge.ListDefs(true) + if err != nil { + chrome.Error = err.Error() + data.AdminChrome = chrome + } else { + data.Badges = make([]adminBadgeRow, 0, len(list)) + data.LimitedBadges = make([]adminBadgeRow, 0) + for _, b := range list { + row := adminBadgeRowFrom(b) + data.Badges = append(data.Badges, row) + if row.IsLimited && row.Enabled { + data.LimitedBadges = append(data.LimitedBadges, row) + } + } + } + } + ctx.HTML(http.StatusOK, "admin/badges", data) +} + +func adminBadgeRowFrom(b models.BadgeDef) adminBadgeRow { + label := "自动" + if b.Kind == models.BadgeKindLimited { + label = "限定" + } + return adminBadgeRow{ + ID: b.ID, Code: b.Code, Name: b.Name, Description: b.Description, Icon: b.Icon, + Kind: b.Kind, KindLabel: label, Metric: b.Metric, Threshold: b.Threshold, + SortOrder: b.SortOrder, Enabled: b.Enabled, IsLimited: b.Kind == models.BadgeKindLimited, + } +} + +func adminBadgeFormFrom(c *gin.Context) adminBadgeForm { + th, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("threshold"))) + so, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("sort_order"))) + kind := strings.TrimSpace(c.PostForm("kind")) + if kind == "" { + kind = models.BadgeKindLimited + } + return adminBadgeForm{ + Code: strings.TrimSpace(c.PostForm("code")), + Name: strings.TrimSpace(c.PostForm("name")), + Description: strings.TrimSpace(c.PostForm("description")), + Icon: strings.TrimSpace(c.PostForm("icon")), + Kind: kind, + Metric: strings.TrimSpace(c.PostForm("metric")), + Threshold: th, + SortOrder: so, + Enabled: c.PostForm("enabled") == "1" || c.PostForm("enabled") == "on", + } +} + +func (f adminBadgeForm) toDef() models.BadgeDef { + return models.BadgeDef{ + Code: f.Code, Name: f.Name, Description: f.Description, Icon: f.Icon, + Kind: f.Kind, Metric: f.Metric, Threshold: f.Threshold, + SortOrder: f.SortOrder, Enabled: f.Enabled, + } +} + +// AdminBadgeCreate 新建徽章定义 +func (d Deps) AdminBadgeCreate(c *gin.Context) { + ctx := d.ctx(c) + form := adminBadgeFormFrom(c) + if !ctx.CheckCSRF() { + d.renderAdminBadges(ctx, "无效请求,请重试", form, "", 0) + return + } + if d.Badge == nil { + d.renderAdminBadges(ctx, "徽章服务未就绪", form, "", 0) + return + } + def := form.toDef() + if err := d.Badge.UpsertDef(&def); err != nil { + d.renderAdminBadges(ctx, err.Error(), form, "", 0) + return + } + ctx.SetFlash("徽章已保存") + ctx.Redirect("/admin/badges") +} + +// AdminBadgeEditGet 编辑表单 +func (d Deps) AdminBadgeEditGet(c *gin.Context) { + ctx := d.ctx(c) + if d.Badge == nil { + d.renderAdminBadges(ctx, "徽章服务未就绪", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0) + return + } + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + list, err := d.Badge.ListDefs(true) + if err != nil { + d.renderAdminBadges(ctx, err.Error(), adminBadgeForm{}, "", 0) + return + } + for _, b := range list { + if b.ID == uint(id64) { + d.renderAdminBadges(ctx, "", adminBadgeForm{ + ID: b.ID, Code: b.Code, Name: b.Name, Description: b.Description, Icon: b.Icon, + Kind: b.Kind, Metric: b.Metric, Threshold: b.Threshold, SortOrder: b.SortOrder, + Enabled: b.Enabled, IsEdit: true, + }, "", 0) + return + } + } + ctx.SetFlash("徽章不存在") + ctx.Redirect("/admin/badges") +} + +// AdminBadgeUpdate 更新徽章定义 +func (d Deps) AdminBadgeUpdate(c *gin.Context) { + ctx := d.ctx(c) + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + form := adminBadgeFormFrom(c) + form.ID = uint(id64) + form.IsEdit = true + if !ctx.CheckCSRF() { + d.renderAdminBadges(ctx, "无效请求,请重试", form, "", 0) + return + } + if d.Badge == nil { + d.renderAdminBadges(ctx, "徽章服务未就绪", form, "", 0) + return + } + // 编辑时 code 不可改:从已有定义取 code,避免误覆盖其它徽章 + list, err := d.Badge.ListDefs(true) + if err != nil { + d.renderAdminBadges(ctx, err.Error(), form, "", 0) + return + } + var found *models.BadgeDef + for i := range list { + if list[i].ID == uint(id64) { + found = &list[i] + break + } + } + if found == nil { + d.renderAdminBadges(ctx, "徽章不存在", form, "", 0) + return + } + form.Code = found.Code + def := form.toDef() + if err := d.Badge.UpsertDef(&def); err != nil { + d.renderAdminBadges(ctx, err.Error(), form, "", 0) + return + } + ctx.SetFlash("徽章已更新") + ctx.Redirect("/admin/badges") +} + +// AdminBadgeDelete 删除徽章定义 +func (d Deps) AdminBadgeDelete(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminBadges(ctx, "无效请求,请重试", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0) + return + } + if d.Badge == nil { + d.renderAdminBadges(ctx, "徽章服务未就绪", adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0) + return + } + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := d.Badge.DeleteDef(uint(id64)); err != nil { + d.renderAdminBadges(ctx, err.Error(), adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true}, "", 0) + return + } + ctx.SetFlash("徽章已删除") + ctx.Redirect("/admin/badges") +} + +func (d Deps) resolveAwardUser(raw string) (uint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, errors.New("请填写用户名或用户 ID") + } + if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 { + u, e := d.User.GetByID(uint(id)) + if e != nil || u == nil { + return 0, errors.New("用户不存在") + } + return u.ID, nil + } + u, err := d.User.GetByUsername(raw) + if err != nil || u == nil { + return 0, errors.New("用户不存在") + } + return u.ID, nil +} + +// AdminBadgeAwardPost 颁发限定徽章 +func (d Deps) AdminBadgeAwardPost(c *gin.Context) { + ctx := d.ctx(c) + userRaw := strings.TrimSpace(c.PostForm("user")) + badgeID, _ := strconv.ParseUint(strings.TrimSpace(c.PostForm("badge_id")), 10, 64) + form := adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true} + if !ctx.CheckCSRF() { + d.renderAdminBadges(ctx, "无效请求,请重试", form, userRaw, uint(badgeID)) + return + } + if d.Badge == nil || d.User == nil { + d.renderAdminBadges(ctx, "服务未就绪", form, userRaw, uint(badgeID)) + return + } + uid, err := d.resolveAwardUser(userRaw) + if err != nil { + d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID)) + return + } + if badgeID == 0 { + d.renderAdminBadges(ctx, "请选择徽章", form, userRaw, 0) + return + } + if err := d.Badge.AwardLimited(uid, uint(badgeID), ctx.UserID()); err != nil { + d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID)) + return + } + ctx.SetFlash("已颁发徽章") + ctx.Redirect("/admin/badges") +} + +// AdminBadgeRevokePost 收回徽章 +func (d Deps) AdminBadgeRevokePost(c *gin.Context) { + ctx := d.ctx(c) + userRaw := strings.TrimSpace(c.PostForm("user")) + badgeID, _ := strconv.ParseUint(strings.TrimSpace(c.PostForm("badge_id")), 10, 64) + form := adminBadgeForm{Kind: models.BadgeKindLimited, Enabled: true} + if !ctx.CheckCSRF() { + d.renderAdminBadges(ctx, "无效请求,请重试", form, userRaw, uint(badgeID)) + return + } + if d.Badge == nil || d.User == nil { + d.renderAdminBadges(ctx, "服务未就绪", form, userRaw, uint(badgeID)) + return + } + uid, err := d.resolveAwardUser(userRaw) + if err != nil { + d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID)) + return + } + if badgeID == 0 { + d.renderAdminBadges(ctx, "请选择徽章", form, userRaw, 0) + return + } + if err := d.Badge.Revoke(uid, uint(badgeID)); err != nil { + d.renderAdminBadges(ctx, err.Error(), form, userRaw, uint(badgeID)) + return + } + ctx.SetFlash("已收回徽章") + ctx.Redirect("/admin/badges") +} diff --git a/routers/web/chrome.go b/routers/web/chrome.go index f37d86e..eda295d 100644 --- a/routers/web/chrome.go +++ b/routers/web/chrome.go @@ -24,6 +24,7 @@ type Deps struct { EmailCode *services.EmailCodeService Store *services.UploadStore Points *services.PointsService + Badge *services.BadgeService FriendLink *services.FriendLinkApplyService Mail *services.MailService SitePage *services.SitePageService diff --git a/routers/web/home.go b/routers/web/home.go index 3385117..aa871c0 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -101,6 +101,13 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { admin.POST("/users/:id/ban", deps.AdminUserBanPost) admin.POST("/users/:id/verify", deps.AdminUserVerifyPost) admin.POST("/users/:id/points", deps.AdminUserPointsPost) + admin.GET("/badges", deps.AdminBadgesGet) + admin.POST("/badges", deps.AdminBadgeCreate) + admin.POST("/badges/award", deps.AdminBadgeAwardPost) + admin.POST("/badges/revoke", deps.AdminBadgeRevokePost) + admin.GET("/badges/:id/edit", deps.AdminBadgeEditGet) + admin.POST("/badges/:id", deps.AdminBadgeUpdate) + admin.POST("/badges/:id/delete", deps.AdminBadgeDelete) admin.GET("/trash", deps.AdminTrashGet) admin.POST("/trash/:id/restore", deps.AdminTrashRestorePost) admin.POST("/trash/:id/purge", deps.AdminTrashPurgePost) diff --git a/services/badge.go b/services/badge.go index c61bcb1..f0a9f96 100644 --- a/services/badge.go +++ b/services/badge.go @@ -53,6 +53,20 @@ func (s *BadgeService) UpsertDef(def *models.BadgeDef) error { }).Error } +// DeleteDef 删除徽章定义并清理已发放记录 +func (s *BadgeService) DeleteDef(id uint) error { + var def models.BadgeDef + if err := models.DB.First(&def, id).Error; err != nil { + return errors.New("徽章不存在") + } + return models.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("badge_id = ?", id).Delete(&models.UserBadge{}).Error; err != nil { + return err + } + return tx.Delete(&def).Error + }) +} + // AwardLimited 站长颁发限定徽章 func (s *BadgeService) AwardLimited(userID, badgeID, adminID uint) error { var def models.BadgeDef diff --git a/templates/admin/badges.tmpl b/templates/admin/badges.tmpl new file mode 100644 index 0000000..d45a9d3 --- /dev/null +++ b/templates/admin/badges.tmpl @@ -0,0 +1,114 @@ +{{define "admin/badges"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

徽章管理

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

{{if .Form.IsEdit}}编辑:{{.Form.Name}}{{else}}新建徽章{{end}}

+
+ + {{if .Form.IsEdit}} +

代码 {{.Form.Code}}(不可改)

+ {{else}} + + {{end}} + + + + + + + + + + {{if .Form.IsEdit}}取消编辑{{end}} +
+
+ +
+

颁发 / 收回限定徽章

+ {{if .LimitedBadges}} +
+ + + +
+ + +
+
+ {{else}} +

暂无已启用的限定徽章,请先创建。

+ {{end}} +
+ +
+

徽章定义

+ {{if .Badges}} + + + + + + + + + + + + + + {{range .Badges}} + + + + + + + + + + {{end}} + +
名称代码类型门槛排序状态操作
+ {{.Name}} + {{if .Description}}
{{.Description}}
{{end}} +
{{.Code}}{{.KindLabel}} + {{if .IsLimited}}— + {{else}}{{.Metric}} ≥ {{.Threshold}}{{end}} + {{.SortOrder}}{{if .Enabled}}启用{{else}}停用{{end}} + 编辑 +
+ + +
+
+ {{else}} +

暂无徽章定义

+ {{end}} +
+
+{{template "base/footer" .}} +{{end}} diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl index 517ba4c..1df125e 100644 --- a/templates/admin/nav.tmpl +++ b/templates/admin/nav.tmpl @@ -5,6 +5,7 @@ 审核 举报 用户 + 徽章 回收站 友链 单页 diff --git a/web_src/css/site.css b/web_src/css/site.css index bd0ce4b..f40e437 100644 --- a/web_src/css/site.css +++ b/web_src/css/site.css @@ -1040,6 +1040,12 @@ points-only[data-locked="false"] { align-items: center; margin: 0; } +.j13-admin-badge-award-btns { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} .j13-post__admin { display: flex; flex-wrap: wrap;