feat: Admin 徽章定义 CRUD 与限定颁发收回

SSR /admin/badges 复用 UpsertDef/AwardLimited/Revoke,并新增 DeleteDef;指针指向 media。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 03:42:41 +08:00
parent 02a5ad0c30
commit 0c5fe8a1b4
13 changed files with 518 additions and 21 deletions

View File

@@ -131,8 +131,8 @@
- [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务 - [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务
- [ ] 短龄同 IP 互刷拒绝分成 - [ ] 短龄同 IP 互刷拒绝分成
- [ ] Exp → 等级 Lv110 - [ ] Exp → 等级 Lv110
- [ ] 自动徽章(注册天数 / 获赞 / 创作分成) - [ ] 自动徽章(注册天数 / 获赞 / 创作分成)— 种子定义 + `EvaluateAuto` 已有Admin 可改定义,独立调度 UI 未做
- [ ] 限定徽章:管理员定义与授予/撤销 - [x] 限定徽章:管理员定义与授予/撤销 — SSR `/admin/badges`
- [x] 管理员调整积分、设定认证(免审)— SSR `/admin/users`(等级设定 UI 未做,仅只读展示 Lv - [x] 管理员调整积分、设定认证(免审)— SSR `/admin/users`(等级设定 UI 未做,仅只读展示 Lv
--- ---

View File

@@ -53,10 +53,11 @@
| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 | | `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 |
| `/admin/reports` | 举报处理 | 已迁 | | `/admin/reports` | 举报处理 | 已迁 |
| `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 | | `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 |
| `/admin/badges` | 徽章定义 CRUD、限定颁发/收回 | 已迁 |
| `/admin/trash` | 帖回收站 | 已迁 | | `/admin/trash` | 帖回收站 | 已迁 |
| `/admin/login` | 重定向前台登录 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 |
未迁(原 SPAbadges / media / 完整 Limits 等。 未迁(原 SPAmedia / 完整 Limits 等。
--- ---
@@ -194,8 +195,8 @@
| Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` | | Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` |
| Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 | | Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 |
| Reports | 处理动作dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` | | Reports | 处理动作dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` |
| Users | 搜索;禁言;认证;设等级;调积分;授徽章 | | Users | 搜索;禁言;认证;调积分 — SSR `/admin/users`(设等级 / 授徽章挂在徽章页) |
| Badges | 定义自动/限定徽章 | | Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
| Media | 分类浏览;批量删 | | Media | 分类浏览;批量删 |
| Settings | Tab论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌/限流/侧栏/敏感词/邮件/SQLite 备份OIDC/Gitea/存储/伪静态未迁) | | Settings | Tab论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌/限流/侧栏/敏感词/邮件/SQLite 备份OIDC/Gitea/存储/伪静态未迁) |

View File

@@ -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 ```mermaid
flowchart LR flowchart LR
@@ -60,12 +60,13 @@ flowchart LR
| settings品牌、限流、敏感词、SMTP、侧栏、SQLite 备份 | 已迁 | | settings品牌、限流、敏感词、SMTP、侧栏、SQLite 备份 | 已迁 |
| friend-links / pages | 已迁 | | friend-links / pages | 已迁 |
| **users**(禁言 / 认证 / 调积分) | **已迁** | | **users**(禁言 / 认证 / 调积分) | **已迁** |
| **badges / media** | **** | | **badges**(定义 CRUD + 限定颁发/收回) | **** |
| **media** | **未迁** |
| settingsOIDC / Gitea / 存储 / 伪静态 Tab | **未迁** | | settingsOIDC / Gitea / 存储 / 伪静态 Tab | **未迁** |
### 近期提交(摘) ### 近期提交(摘)
`poll` → SQLite 备份 → 友链复检 → `question``bounty``lottery`**Admin users** `lottery` → Admin users**Admin badges**
--- ---
@@ -79,12 +80,12 @@ flowchart LR
### P1 — 推荐刀序(服务层多半已齐) ### P1 — 推荐刀序(服务层多半已齐)
1. ~~Admin 用户~~ 1. ~~Admin 用户~~
2. **Admin 徽章** ← 当前指针 2. ~~Admin 徽章~~
3. Admin 媒体列表/删除 3. **Admin 媒体** ← 当前指针
### P2 — 成长与防刷 ### P2 — 成长与防刷
- Exp → Lv110、自动徽章、短龄同 IP 互刷拒绝分成(`02` §H - Exp → Lv110、自动徽章调度 UI、短龄同 IP 互刷拒绝分成(`02` §H
### P3 — 体验 / 编辑器(可砍或长期) ### P3 — 体验 / 编辑器(可砍或长期)
@@ -108,29 +109,33 @@ flowchart LR
| ✓ | 五种帖类型 + 备份/复检 + §O 勾选 | 核心闭环 | | ✓ | 五种帖类型 + 备份/复检 + §O 勾选 | 核心闭环 |
| ✓ | 本文 `09` | 可查阅进度 | | ✓ | 本文 `09` | 可查阅进度 |
| ✓ | Admin `/admin/users` | 禁言/认证/调积分 | | ✓ | Admin `/admin/users` | 禁言/认证/调积分 |
| | Admin `/admin/badges` | 徽章 CRUD + 授予 | | | Admin `/admin/badges` | 徽章 CRUD + 授予 |
| 4 | Admin `/admin/media` | 媒体列表删除 | | | Admin `/admin/media` | 媒体列表删除 |
| 5 | 等级/自动徽章 **或** 防刷分成 | 按当时偏好 | | 5 | 等级/自动徽章 **或** 防刷分成 | 按当时偏好 |
| … | P3 / 后置 | 需明确点名再开 | | … | P3 / 后置 | 需明确点名再开 |
--- ---
## 下一刀实现提纲Admin 徽章 ## 下一刀实现提纲Admin 媒体
> 确认「继续」后实现限定徽章定义与授予/撤销;复用 `services/badge.go` > 确认「继续」后实现媒体库列表与删除;对齐既有上传/存储服务
### 范围(草案) ### 范围(草案)
- `GET/POST /admin/badges`:徽章定义列表与创建/编辑/删除 - `GET /admin/media`:按时间分页列表(缩略图 / 路径 / 引用粗览)
- 授予/撤销:挂在用户页或徽章详情(`GrantBadge` / 撤销) - `POST /admin/media/:id/delete`:删库文件 + 记录
- 回写 `02` §H 限定徽章;指针 → media - 回写 `02` / `06`;指针 → 成长或按偏好
### 不做 ### 不做
自动徽章规则引擎P2、等级设定 UI S3 热切换产品化、全站未引用扫描清理器
--- ---
## 已完成Admin 徽章
见 [plans/admin-badges.md](plans/admin-badges.md);路由 `/admin/badges` + award / revoke。
## 已完成Admin 用户管理 ## 已完成Admin 用户管理
见 [plans/admin-users.md](plans/admin-users.md);路由 `/admin/users` + ban / verify / points。 见 [plans/admin-users.md](plans/admin-users.md);路由 `/admin/users` + ban / verify / points。

View File

@@ -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` |
## 不做
自动徽章规则引擎调度 UIP2服务层 `EvaluateAuto` 仍可被触发)、等级设定。
## 验证
`build.bat`;冒烟:创建限定徽章 → 颁发给用户 → 收回 → 删除。

View File

@@ -1040,6 +1040,12 @@ points-only[data-locked="false"] {
align-items: center; align-items: center;
margin: 0; margin: 0;
} }
.j13-admin-badge-award-btns {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.j13-post__admin { .j13-post__admin {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -103,6 +103,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Message: messageSvc, Filter: filter, Message: messageSvc, Filter: filter,
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore, Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
Points: services.NewPointsService(), Points: services.NewPointsService(),
Badge: services.NewBadgeService(),
FriendLink: friendLinkApplySvc, FriendLink: friendLinkApplySvc,
Mail: mailSvc, Mail: mailSvc,
SitePage: sitePageSvc, SitePage: sitePageSvc,

319
routers/web/admin_badges.go Normal file
View File

@@ -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")
}

View File

@@ -24,6 +24,7 @@ type Deps struct {
EmailCode *services.EmailCodeService EmailCode *services.EmailCodeService
Store *services.UploadStore Store *services.UploadStore
Points *services.PointsService Points *services.PointsService
Badge *services.BadgeService
FriendLink *services.FriendLinkApplyService FriendLink *services.FriendLinkApplyService
Mail *services.MailService Mail *services.MailService
SitePage *services.SitePageService SitePage *services.SitePageService

View File

@@ -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/ban", deps.AdminUserBanPost)
admin.POST("/users/:id/verify", deps.AdminUserVerifyPost) admin.POST("/users/:id/verify", deps.AdminUserVerifyPost)
admin.POST("/users/:id/points", deps.AdminUserPointsPost) 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.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

@@ -53,6 +53,20 @@ func (s *BadgeService) UpsertDef(def *models.BadgeDef) error {
}).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 站长颁发限定徽章 // AwardLimited 站长颁发限定徽章
func (s *BadgeService) AwardLimited(userID, badgeID, adminID uint) error { func (s *BadgeService) AwardLimited(userID, badgeID, adminID uint) error {
var def models.BadgeDef var def models.BadgeDef

114
templates/admin/badges.tmpl Normal file
View File

@@ -0,0 +1,114 @@
{{define "admin/badges"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-admin">
<h1>徽章管理</h1>
{{template "admin/nav" .}}
{{template "base/alert" .}}
<section>
<h2>{{if .Form.IsEdit}}编辑:{{.Form.Name}}{{else}}新建徽章{{end}}</h2>
<form method="post" action="{{if .Form.IsEdit}}/admin/badges/{{.Form.ID}}{{else}}/admin/badges{{end}}" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
{{if .Form.IsEdit}}
<p class="j13-muted">代码 <code>{{.Form.Code}}</code>(不可改)</p>
{{else}}
<label>代码 <input name="code" required pattern="[a-z0-9_]{2,64}" value="{{.Form.Code}}" placeholder="vip_founder"/></label>
{{end}}
<label>名称 <input name="name" required maxlength="64" value="{{.Form.Name}}"/></label>
<label>说明 <input name="description" maxlength="256" value="{{.Form.Description}}"/></label>
<label>图标 key <input name="icon" maxlength="64" value="{{.Form.Icon}}" placeholder="star"/></label>
<label>类型
<select name="kind">
<option value="limited"{{if eq .Form.Kind "limited"}} selected{{end}}>限定(管理员颁发)</option>
<option value="auto"{{if eq .Form.Kind "auto"}} selected{{end}}>自动(达标授予)</option>
</select>
</label>
<label>指标(自动)
<select name="metric">
<option value=""{{if eq .Form.Metric ""}} selected{{end}}>—</option>
<option value="tenure_days"{{if eq .Form.Metric "tenure_days"}} selected{{end}}>注册天数</option>
<option value="likes_received"{{if eq .Form.Metric "likes_received"}} selected{{end}}>帖子获赞</option>
<option value="creator_income"{{if eq .Form.Metric "creator_income"}} selected{{end}}>创作分成</option>
</select>
</label>
<label>阈值 <input name="threshold" type="number" min="0" value="{{.Form.Threshold}}"/></label>
<label>排序 <input name="sort_order" type="number" value="{{.Form.SortOrder}}"/></label>
<label class="j13-check"><input type="checkbox" name="enabled" value="1"{{if .Form.Enabled}} checked{{end}}/> 启用</label>
<button type="submit">{{if .Form.IsEdit}}保存{{else}}创建{{end}}</button>
{{if .Form.IsEdit}}<a href="/admin/badges" class="j13-linkbtn">取消编辑</a>{{end}}
</form>
</section>
<section>
<h2>颁发 / 收回限定徽章</h2>
{{if .LimitedBadges}}
<form method="post" action="/admin/badges/award" class="j13-form j13-admin-form j13-admin-badge-award">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label>用户名或 ID <input name="user" required value="{{.AwardUser}}" placeholder="user2 或 2"/></label>
<label>徽章
<select name="badge_id" required>
<option value="">选择…</option>
{{range .LimitedBadges}}
<option value="{{.ID}}"{{if eq .ID $.AwardBadgeID}} selected{{end}}>{{.Name}} ({{.Code}})</option>
{{end}}
</select>
</label>
<div class="j13-admin-badge-award-btns">
<button type="submit">颁发</button>
<button type="submit" formaction="/admin/badges/revoke" class="j13-btn-secondary">收回</button>
</div>
</form>
{{else}}
<p class="j13-muted">暂无已启用的限定徽章,请先创建。</p>
{{end}}
</section>
<section>
<h2>徽章定义</h2>
{{if .Badges}}
<table class="j13-admin-table">
<thead>
<tr>
<th>名称</th>
<th>代码</th>
<th>类型</th>
<th>门槛</th>
<th>排序</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .Badges}}
<tr>
<td>
{{.Name}}
{{if .Description}}<div class="j13-muted">{{.Description}}</div>{{end}}
</td>
<td><code>{{.Code}}</code></td>
<td>{{.KindLabel}}</td>
<td>
{{if .IsLimited}}—
{{else}}{{.Metric}} ≥ {{.Threshold}}{{end}}
</td>
<td>{{.SortOrder}}</td>
<td>{{if .Enabled}}启用{{else}}停用{{end}}</td>
<td>
<a href="/admin/badges/{{.ID}}/edit">编辑</a>
<form method="post" action="/admin/badges/{{.ID}}/delete" class="j13-inline-form" onsubmit="return confirm('确认删除该徽章?已发放记录将一并清除。');">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<button type="submit" class="j13-linkbtn">删除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="j13-empty">暂无徽章定义</p>
{{end}}
</section>
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -5,6 +5,7 @@
<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/users"{{if eq .NavActive "users"}} class="is-active"{{end}}>用户</a>
<a href="/admin/badges"{{if eq .NavActive "badges"}} 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>

View File

@@ -1040,6 +1040,12 @@ points-only[data-locked="false"] {
align-items: center; align-items: center;
margin: 0; margin: 0;
} }
.j13-admin-badge-award-btns {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.j13-post__admin { .j13-post__admin {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;