diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md
index 9464e17..774587b 100644
--- a/docs/rebuild-spec/02-features.md
+++ b/docs/rebuild-spec/02-features.md
@@ -62,7 +62,7 @@
- [ ] 修订历史列表与单条详情(可做 diff)
- [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites`
- [x] 浏览量 — 详情页计数
-- [ ] 举报帖子
+- [x] 举报帖子 — SSR 表单 + `/admin/reports`
- [ ] 内容审核状态展示(作者可见待审/被拒)
### D.1 帖子类型
@@ -104,7 +104,7 @@
- [ ] 游客评论(公开接口可写,字段 guest_*)
- [ ] 编辑评论(时限);删除评论
- [x] 评论点赞 — `POST /post/:id/comments/:cid/like`
-- [ ] 评论举报
+- [x] 评论举报 — SSR
- [ ] @ 提及 → 通知
- [ ] 回复提醒(站内信 + 可选邮件)
- [ ] 审核中 / 被拒评论可见性规则
@@ -185,7 +185,7 @@
## N. 管理后台其它
-- [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 — SSR `/admin/dashboard`(举报/友链待迁)
+- [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 + 待处理举报 — SSR `/admin/dashboard`
- [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings`
- [x] 基础限流(post/comment/register/login/window)— SSR;完整 Limits 字数等未迁
- [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区
diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md
index 22bdc4e..73b4a50 100644
--- a/docs/rebuild-spec/06-pages-ux.md
+++ b/docs/rebuild-spec/06-pages-ux.md
@@ -51,9 +51,10 @@
| `/admin/settings` | 品牌 + 基础限流 + 敏感词 + SMTP | 已迁 |
| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 |
| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 |
+| `/admin/reports` | 举报处理 | 已迁 |
| `/admin/login` | 重定向前台登录 | 已迁 |
-未迁(原 SPA):reports / users / badges / media / 完整 Limits 等。
+未迁(原 SPA):users / badges / media / 完整 Limits 等。
---
@@ -145,7 +146,7 @@
| 投票卡 | 选选项提交;显示百分比;作者可结束 |
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
| 抽奖卡 | 显示参与人数;开奖;中奖名单 |
-| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞 — SSR;@ / 编辑 / 举报未迁 |
+| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报 — SSR;@ / 编辑未迁 |
| 修订 | 面板列出历史,可选对比 |
| 图片 | Lightbox 查看 |
@@ -189,7 +190,7 @@
| Links | 品牌友链增删;申请通过/拒绝;回链检测开关;nav/footer/aside 开关 — SSR `/admin/friend-links`(完整侧栏组件编辑 / 复检按钮未迁) |
| Posts | 按状态筛;通过/拒绝;置顶/版顶/精华/锁编/锁评;进回收站恢复/清除 |
| Comments | 审核;修订查看;回收站 |
-| Reports | 处理动作四选一 |
+| Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` |
| Users | 搜索;禁言;认证;设等级;调积分;授徽章 |
| Badges | 定义自动/限定徽章 |
| Media | 分类浏览;批量删 |
diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md
index 73687b4..84431ae 100644
--- a/docs/rebuild-spec/08-gitea-ssr-architecture.md
+++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md
@@ -98,4 +98,6 @@ Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_
站点单页:公开 `/page/:slug`;nav/footer 按 `show_in_nav` / `show_in_footer` 注入;Admin `/admin/pages`(CRUD、发布、排序、展示开关)。
-Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`。
\ No newline at end of file
+举报:帖/评 SSR 表单;Admin `/admin/reports`(dismiss / resolve / reject_post / reject_comment)。
+
+Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`、`/admin/reports`。
\ No newline at end of file
diff --git a/public/assets/site.css b/public/assets/site.css
index 1f6f5f1..3d5f2e7 100644
--- a/public/assets/site.css
+++ b/public/assets/site.css
@@ -839,3 +839,60 @@ points-only[data-locked="false"] {
line-height: 1.65;
}
+.j13-report {
+ display: inline-block;
+ position: relative;
+}
+.j13-report > summary {
+ cursor: pointer;
+ list-style: none;
+}
+.j13-report > summary::-webkit-details-marker { display: none; }
+.j13-report__form {
+ position: absolute;
+ z-index: 5;
+ right: 0;
+ top: 100%;
+ margin-top: 0.35rem;
+ min-width: 14rem;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--j13-border);
+ border-radius: var(--j13-radius);
+ background: var(--j13-bg);
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.08);
+}
+.j13-report__form label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ font-size: 0.8rem;
+ color: var(--j13-muted);
+}
+.j13-report__form select,
+.j13-report__form input {
+ font: inherit;
+ color: var(--j13-text);
+ padding: 0.3rem 0.4rem;
+ border: 1px solid var(--j13-border);
+ border-radius: var(--j13-radius);
+ background: var(--j13-bg);
+}
+.j13-admin-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin: 0 0 1rem;
+ font-size: 0.9rem;
+}
+.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-report-handle .j13-form__row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
diff --git a/routers/setup.go b/routers/setup.go
index 861c2e6..9a42cfc 100644
--- a/routers/setup.go
+++ b/routers/setup.go
@@ -106,6 +106,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
FriendLink: friendLinkApplySvc,
Mail: mailSvc,
SitePage: sitePageSvc,
+ Report: reportSvc,
}, authMW)
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
diff --git a/routers/web/admin.go b/routers/web/admin.go
index eaa9cb9..2f2f7e9 100644
--- a/routers/web/admin.go
+++ b/routers/web/admin.go
@@ -32,11 +32,12 @@ func (d Deps) adminChrome(ctx *webctx.Context, title, nav string) AdminChrome {
type adminDashData struct {
AdminChrome
- UserCount int64
- PostCount int64
- PendingPosts int64
- PendingComments int64
- BoardCount int64
+ UserCount int64
+ PostCount int64
+ PendingPosts int64
+ PendingComments int64
+ PendingReports int64
+ BoardCount int64
}
// AdminDashboard 概览
@@ -48,12 +49,17 @@ func (d Deps) AdminDashboard(c *gin.Context) {
_ = models.DB.Model(&models.Board{}).Count(&boards).Error
pendingPosts, _ := d.Post.PendingPostCount()
pendingComments, _ := d.Comment.PendingCommentCount()
+ var pendingReports int64
+ if d.Report != nil {
+ pendingReports, _ = d.Report.PendingCount()
+ }
ctx.HTML(http.StatusOK, "admin/dashboard", adminDashData{
AdminChrome: d.adminChrome(ctx, "仪表盘", "dashboard"),
UserCount: users,
PostCount: posts,
PendingPosts: pendingPosts,
PendingComments: pendingComments,
+ PendingReports: pendingReports,
BoardCount: boards,
})
}
diff --git a/routers/web/admin_reports.go b/routers/web/admin_reports.go
new file mode 100644
index 0000000..9d10bda
--- /dev/null
+++ b/routers/web/admin_reports.go
@@ -0,0 +1,143 @@
+package web
+
+import (
+ "net/http"
+ "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 adminReportRow struct {
+ ID uint
+ CreatedAt string
+ Status string
+ StatusLabel string
+ ReasonLabel string
+ Detail string
+ ReporterName string
+ TargetLabel string
+ PostID uint
+ IsComment bool
+ Pending bool
+}
+
+type adminReportsData struct {
+ AdminChrome
+ Reports []adminReportRow
+ PendingCount int64
+ StatusFilter string
+ Page int
+ HasPrev bool
+ HasMore bool
+ PrevPage int
+ NextPage int
+}
+
+// AdminReportsGet 举报列表
+func (d Deps) AdminReportsGet(c *gin.Context) {
+ ctx := d.ctx(c)
+ d.renderAdminReports(ctx, "")
+}
+
+func (d Deps) renderAdminReports(ctx *webctx.Context, errMsg string) {
+ chrome := d.adminChrome(ctx, "举报", "reports")
+ chrome.Error = errMsg
+ status := strings.TrimSpace(ctx.C.DefaultQuery("status", "pending"))
+ if status == "" {
+ status = "pending"
+ }
+ page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1"))
+ if page < 1 {
+ page = 1
+ }
+ data := adminReportsData{
+ AdminChrome: chrome,
+ StatusFilter: status,
+ Page: page,
+ PrevPage: page - 1,
+ NextPage: page + 1,
+ HasPrev: page > 1,
+ }
+ if d.Report != nil {
+ data.PendingCount, _ = d.Report.PendingCount()
+ list, total, _ := d.Report.ListAdmin(services.ReportListQuery{
+ Status: status, Page: page, Size: d.Settings.PageSizeDefault(),
+ })
+ data.HasMore = int64(page*d.Settings.PageSizeDefault()) < total
+ data.Reports = make([]adminReportRow, 0, len(list))
+ for _, r := range list {
+ row := adminReportRow{
+ ID: r.ID, CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
+ Status: r.Status, StatusLabel: reportStatusLabel(r.Status),
+ ReasonLabel: services.ReportReasonLabel(r.Reason), Detail: r.Detail,
+ PostID: r.PostID, Pending: r.Status == models.ReportStatusPending,
+ }
+ if r.Reporter.ID > 0 {
+ row.ReporterName = r.Reporter.Username
+ }
+ isComment := r.CommentID != nil && *r.CommentID > 0
+ row.IsComment = isComment
+ title := ""
+ if r.Post.ID > 0 {
+ title = r.Post.Title
+ }
+ if isComment {
+ floor := 0
+ if r.Comment != nil {
+ floor = r.Comment.Floor
+ }
+ row.TargetLabel = "评论 #" + strconv.Itoa(floor) + " · 《" + title + "》"
+ } else {
+ row.TargetLabel = "帖子 · 《" + title + "》"
+ }
+ data.Reports = append(data.Reports, row)
+ }
+ }
+ ctx.HTML(http.StatusOK, "admin/reports", data)
+}
+
+// AdminReportHandlePost 处理举报
+func (d Deps) AdminReportHandlePost(c *gin.Context) {
+ ctx := d.ctx(c)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求,请重试")
+ ctx.Redirect("/admin/reports")
+ return
+ }
+ if d.Report == nil {
+ ctx.SetFlash("举报服务未就绪")
+ ctx.Redirect("/admin/reports")
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ action := strings.TrimSpace(c.PostForm("action"))
+ if _, err := d.Report.Handle(services.HandleReportInput{
+ ReportID: uint(id),
+ HandlerID: ctx.UserID(),
+ Action: action,
+ HandleNote: strings.TrimSpace(c.PostForm("handle_note")),
+ RejectReason: strings.TrimSpace(c.PostForm("reject_reason")),
+ }); err != nil {
+ d.renderAdminReports(ctx, err.Error())
+ return
+ }
+ ctx.SetFlash("举报已处理")
+ ctx.Redirect("/admin/reports?status=pending")
+}
+
+func reportStatusLabel(s string) string {
+ switch s {
+ case models.ReportStatusPending:
+ return "待处理"
+ case models.ReportStatusResolved:
+ return "已处理"
+ case models.ReportStatusDismissed:
+ return "已驳回"
+ default:
+ return s
+ }
+}
diff --git a/routers/web/chrome.go b/routers/web/chrome.go
index 90e3e12..28d8b40 100644
--- a/routers/web/chrome.go
+++ b/routers/web/chrome.go
@@ -27,6 +27,7 @@ type Deps struct {
FriendLink *services.FriendLinkApplyService
Mail *services.MailService
SitePage *services.SitePageService
+ Report *services.ReportService
}
// SitePageLink 导航/页脚站点单页链接
diff --git a/routers/web/home.go b/routers/web/home.go
index 2621449..67ff5d7 100644
--- a/routers/web/home.go
+++ b/routers/web/home.go
@@ -27,6 +27,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock)
+ g.POST("/post/:id/report", authMW.RequireAuth(), deps.PostReportPost)
+ g.POST("/post/:id/comments/:cid/report", authMW.RequireAuth(), deps.CommentReportPost)
g.GET("/login", deps.LoginGet)
g.POST("/login", deps.LoginPost)
g.POST("/logout", deps.LogoutPost)
@@ -72,6 +74,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
admin.POST("/pages/:id", deps.AdminPageUpdate)
admin.POST("/pages/:id/delete", deps.AdminPageDelete)
admin.POST("/pages/:id/publish", deps.AdminPagePublishPost)
+ admin.GET("/reports", deps.AdminReportsGet)
+ admin.POST("/reports/:id/handle", deps.AdminReportHandlePost)
}
g.GET("/user/:id", deps.UserPublic)
diff --git a/routers/web/post.go b/routers/web/post.go
index 32a8495..c01fe3b 100644
--- a/routers/web/post.go
+++ b/routers/web/post.go
@@ -36,6 +36,7 @@ type PostPageData struct {
Comments []CommentView
CommentsLocked bool
CanEdit bool
+ CanReportPost bool
}
// CommentView 评论
@@ -53,6 +54,7 @@ type CommentView struct {
LikeCount int
Liked bool
IsPrivate bool
+ CanReport bool
}
// PostView GET /post/:id
@@ -88,6 +90,7 @@ func (d Deps) PostView(c *gin.Context) {
CreatedLabel: formatTime(cm.CreatedAt),
Content: cm.Content, ContentHidden: cm.ContentHidden,
LikeCount: cm.LikeCount, Liked: cm.Liked, IsPrivate: cm.IsPrivate,
+ CanReport: ctx.IsSigned() && (cm.UserID == 0 || cm.UserID != ctx.UserID()),
}
if cm.ReplyTarget != nil {
view.ReplyToID = cm.ReplyTarget.ID
@@ -130,6 +133,7 @@ func (d Deps) PostView(c *gin.Context) {
BodyHTML: body, CommentCount: len(cv), Comments: cv,
CommentsLocked: post.CommentsLocked,
CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()),
+ CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(),
})
}
diff --git a/routers/web/report.go b/routers/web/report.go
new file mode 100644
index 0000000..f8216bd
--- /dev/null
+++ b/routers/web/report.go
@@ -0,0 +1,72 @@
+package web
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+// PostReportPost 举报帖子
+func (d Deps) PostReportPost(c *gin.Context) {
+ ctx := d.ctx(c)
+ postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ redir := fmt.Sprintf("/post/%d", postID)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求,请重试")
+ ctx.Redirect(redir)
+ return
+ }
+ if d.Report == nil {
+ ctx.SetFlash("举报服务未就绪")
+ ctx.Redirect(redir)
+ return
+ }
+ if d.Limiter != nil && !d.Limiter.Allow("report", fmt.Sprintf("%d", ctx.UserID())) {
+ ctx.SetFlash("举报过于频繁,请稍后再试")
+ ctx.Redirect(redir)
+ return
+ }
+ reason := strings.TrimSpace(c.PostForm("reason"))
+ detail := strings.TrimSpace(c.PostForm("detail"))
+ if _, err := d.Report.Create(ctx.UserID(), uint(postID), reason, detail); err != nil {
+ ctx.SetFlash(err.Error())
+ ctx.Redirect(redir)
+ return
+ }
+ ctx.SetFlash("举报已提交,感谢反馈")
+ ctx.Redirect(redir)
+}
+
+// CommentReportPost 举报评论
+func (d Deps) CommentReportPost(c *gin.Context) {
+ ctx := d.ctx(c)
+ postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ cid, _ := strconv.ParseUint(c.Param("cid"), 10, 64)
+ redir := fmt.Sprintf("/post/%d", postID)
+ if !ctx.CheckCSRF() {
+ ctx.SetFlash("无效请求,请重试")
+ ctx.Redirect(redir)
+ return
+ }
+ if d.Report == nil {
+ ctx.SetFlash("举报服务未就绪")
+ ctx.Redirect(redir)
+ return
+ }
+ if d.Limiter != nil && !d.Limiter.Allow("report", fmt.Sprintf("%d", ctx.UserID())) {
+ ctx.SetFlash("举报过于频繁,请稍后再试")
+ ctx.Redirect(redir)
+ return
+ }
+ reason := strings.TrimSpace(c.PostForm("reason"))
+ detail := strings.TrimSpace(c.PostForm("detail"))
+ if _, err := d.Report.CreateCommentReport(ctx.UserID(), uint(cid), reason, detail); err != nil {
+ ctx.SetFlash(err.Error())
+ ctx.Redirect(redir)
+ return
+ }
+ ctx.SetFlash("举报已提交,感谢反馈")
+ ctx.Redirect(redir)
+}
diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl
index 5f0074f..507e41f 100644
--- a/templates/admin/dashboard.tmpl
+++ b/templates/admin/dashboard.tmpl
@@ -11,8 +11,12 @@
{{.BoardCount}}板块
{{.PendingPosts}}待审帖
{{.PendingComments}}待审评
+ {{.PendingReports}}待处理举报
- 有待审内容时请前往 内容审核。
+
+ 有待审内容时请前往 内容审核;
+ 有举报时请前往 举报处理。
+
{{template "base/footer" .}}
{{end}}
diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl
index 26c93e1..5dd12bd 100644
--- a/templates/admin/nav.tmpl
+++ b/templates/admin/nav.tmpl
@@ -3,6 +3,7 @@
仪表盘
板块
审核
+ 举报
友链
单页
设置
diff --git a/templates/admin/reports.tmpl b/templates/admin/reports.tmpl
new file mode 100644
index 0000000..f0d3a84
--- /dev/null
+++ b/templates/admin/reports.tmpl
@@ -0,0 +1,55 @@
+{{define "admin/reports"}}
+{{template "base/head" .}}
+{{template "base/navbar" .}}
+
+ 举报处理{{if gt .PendingCount 0}}(待处理 {{.PendingCount}}){{end}}
+ {{template "admin/nav" .}}
+ {{template "base/alert" .}}
+
+
+ 待处理
+ 已处理
+ 已驳回
+ 全部
+
+
+ {{if .Reports}}
+
+ {{if or .HasPrev .HasMore}}
+
+ {{end}}
+ {{else}}
+ 暂无举报。
+ {{end}}
+
+{{template "base/footer" .}}
+{{end}}
diff --git a/templates/post/body.tmpl b/templates/post/body.tmpl
index b151a75..7a24873 100644
--- a/templates/post/body.tmpl
+++ b/templates/post/body.tmpl
@@ -22,6 +22,28 @@
{{if .CanEdit}}编辑{{end}}
+ {{if .CanReportPost}}
+
+ 举报
+
+
+ {{end}}
{{end}}
@@ -59,6 +81,28 @@
{{if not $.CommentsLocked}}
{{end}}
+ {{if .CanReport}}
+
+ 举报
+
+
+ {{end}}
{{end}}
diff --git a/web_src/css/site.css b/web_src/css/site.css
index 1f6f5f1..3d5f2e7 100644
--- a/web_src/css/site.css
+++ b/web_src/css/site.css
@@ -839,3 +839,60 @@ points-only[data-locked="false"] {
line-height: 1.65;
}
+.j13-report {
+ display: inline-block;
+ position: relative;
+}
+.j13-report > summary {
+ cursor: pointer;
+ list-style: none;
+}
+.j13-report > summary::-webkit-details-marker { display: none; }
+.j13-report__form {
+ position: absolute;
+ z-index: 5;
+ right: 0;
+ top: 100%;
+ margin-top: 0.35rem;
+ min-width: 14rem;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--j13-border);
+ border-radius: var(--j13-radius);
+ background: var(--j13-bg);
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.08);
+}
+.j13-report__form label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ font-size: 0.8rem;
+ color: var(--j13-muted);
+}
+.j13-report__form select,
+.j13-report__form input {
+ font: inherit;
+ color: var(--j13-text);
+ padding: 0.3rem 0.4rem;
+ border: 1px solid var(--j13-border);
+ border-radius: var(--j13-radius);
+ background: var(--j13-bg);
+}
+.j13-admin-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin: 0 0 1rem;
+ font-size: 0.9rem;
+}
+.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-report-handle .j13-form__row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+