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; +} +