diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 774587b..5b6a4d1 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -58,7 +58,7 @@ - [ ] TipTap 富文本能力(见 [06-pages-ux.md](06-pages-ux.md) 编辑器节)— 本分支改用 Markdown textarea 渐进增强 - [ ] Markdown 编辑模式(与富文本互转/双模) - [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit` -- [ ] 删除帖子 → 软删进回收站 +- [x] 删除帖子 → 软删进回收站 — SSR Admin(帖详情 + `/admin/trash`) - [ ] 修订历史列表与单条详情(可做 diff) - [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites` - [x] 浏览量 — 详情页计数 @@ -75,13 +75,13 @@ ### D.2 运营标记(管理员) -- [ ] 全局置顶 / 取消 -- [ ] 版内置顶 / 取消(仅板块列表抬升) -- [ ] 精华 / 取消 -- [ ] 禁止编辑(edit lock) -- [ ] 禁止评论 / 结贴(comments lock) +- [x] 全局置顶 / 取消 — SSR 帖详情 +- [x] 版内置顶 / 取消(仅板块列表抬升)— SSR 帖详情 +- [x] 精华 / 取消 — SSR 帖详情 +- [x] 禁止编辑(edit lock)— SSR 帖详情 +- [x] 禁止评论 / 结贴(comments lock)— SSR 帖详情 - [x] 审核通过 / 拒绝(拒绝可通知作者)— SSR `/admin/moderation` -- [ ] 回收站:恢复 / 彻底删除 +- [x] 回收站:恢复 / 彻底删除 — SSR `/admin/trash` --- diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 73b4a50..9b47e06 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -52,6 +52,7 @@ | `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 | | `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 | | `/admin/reports` | 举报处理 | 已迁 | +| `/admin/trash` | 帖回收站 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 | 未迁(原 SPA):users / badges / media / 完整 Limits 等。 @@ -188,8 +189,8 @@ | Boards | 拖拽或数字排序;图标/色板选择;增删改 | | Pages | 列表发布开关;编辑正文(HTML textarea);nav/footer 勾选 — SSR `/admin/pages` | | Links | 品牌友链增删;申请通过/拒绝;回链检测开关;nav/footer/aside 开关 — SSR `/admin/friend-links`(完整侧栏组件编辑 / 复检按钮未迁) | -| Posts | 按状态筛;通过/拒绝;置顶/版顶/精华/锁编/锁评;进回收站恢复/清除 | -| Comments | 审核;修订查看;回收站 | +| Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` | +| Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 | | Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` | | Users | 搜索;禁言;认证;设等级;调积分;授徽章 | | Badges | 定义自动/限定徽章 | diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md index 84431ae..4096ecb 100644 --- a/docs/rebuild-spec/08-gitea-ssr-architecture.md +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -100,4 +100,6 @@ Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_ 举报:帖/评 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 +运营标记:帖详情 Admin 条(全局/版内置顶、精华、禁编、锁评、软删);回收站 `/admin/trash`(恢复/彻底删除)。 + +Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`、`/admin/reports`、`/admin/trash`。 \ No newline at end of file diff --git a/public/assets/site.css b/public/assets/site.css index 3d5f2e7..8dec6d5 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -890,9 +890,13 @@ 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-report-handle .j13-form__row { +.j13-post__admin { display: flex; flex-wrap: wrap; - gap: 0.5rem; + gap: 0.35rem 0.65rem; + margin-top: 0.65rem; + padding-top: 0.55rem; + border-top: 1px dashed var(--j13-border); + font-size: 0.85rem; } diff --git a/routers/web/admin_post_ops.go b/routers/web/admin_post_ops.go new file mode 100644 index 0000000..c5fb195 --- /dev/null +++ b/routers/web/admin_post_ops.go @@ -0,0 +1,285 @@ +package web + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "github.com/gin-gonic/gin" +) + +func (d Deps) adminPostRedirect(postID uint) string { + return fmt.Sprintf("/post/%d", postID) +} + +func (d Deps) parseAdminPostID(c *gin.Context) (uint, string, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + return 0, "/", false + } + return uint(id), d.adminPostRedirect(uint(id)), true +} + +func (d Deps) requireAdminPostCSRF(ctx *webctx.Context, redir string) bool { + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect(redir) + return false + } + return true +} + +func formBoolEnable(c *gin.Context) bool { + v := strings.TrimSpace(c.PostForm("enable")) + return v == "1" || strings.EqualFold(v, "true") || v == "on" +} + +// AdminPostPinPost 全局置顶切换 +func (d Deps) AdminPostPinPost(c *gin.Context) { + ctx := d.ctx(c) + id, redir, ok := d.parseAdminPostID(c) + if !ok { + ctx.Redirect("/") + return + } + if !d.requireAdminPostCSRF(ctx, redir) { + return + } + enable := formBoolEnable(c) + if err := d.Post.SetPinned(id, enable); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(redir) + return + } + if enable { + ctx.SetFlash("已全局置顶") + } else { + ctx.SetFlash("已取消全局置顶") + } + ctx.Redirect(redir) +} + +// AdminPostBoardPinPost 版内置顶切换 +func (d Deps) AdminPostBoardPinPost(c *gin.Context) { + ctx := d.ctx(c) + id, redir, ok := d.parseAdminPostID(c) + if !ok { + ctx.Redirect("/") + return + } + if !d.requireAdminPostCSRF(ctx, redir) { + return + } + enable := formBoolEnable(c) + if err := d.Post.SetBoardPinned(id, enable); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(redir) + return + } + if enable { + ctx.SetFlash("已版内置顶") + } else { + ctx.SetFlash("已取消版内置顶") + } + ctx.Redirect(redir) +} + +// AdminPostFeaturePost 精华切换 +func (d Deps) AdminPostFeaturePost(c *gin.Context) { + ctx := d.ctx(c) + id, redir, ok := d.parseAdminPostID(c) + if !ok { + ctx.Redirect("/") + return + } + if !d.requireAdminPostCSRF(ctx, redir) { + return + } + enable := formBoolEnable(c) + if err := d.Post.SetFeatured(id, enable); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(redir) + return + } + if enable { + ctx.SetFlash("已设为精华") + } else { + ctx.SetFlash("已取消精华") + } + ctx.Redirect(redir) +} + +// AdminPostEditLockPost 禁止编辑切换 +func (d Deps) AdminPostEditLockPost(c *gin.Context) { + ctx := d.ctx(c) + id, redir, ok := d.parseAdminPostID(c) + if !ok { + ctx.Redirect("/") + return + } + if !d.requireAdminPostCSRF(ctx, redir) { + return + } + enable := formBoolEnable(c) + if err := d.Post.SetEditLocked(id, enable); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(redir) + return + } + if enable { + ctx.SetFlash("已禁止编辑") + } else { + ctx.SetFlash("已允许编辑") + } + ctx.Redirect(redir) +} + +// AdminPostCommentsLockPost 禁止评论切换 +func (d Deps) AdminPostCommentsLockPost(c *gin.Context) { + ctx := d.ctx(c) + id, redir, ok := d.parseAdminPostID(c) + if !ok { + ctx.Redirect("/") + return + } + if !d.requireAdminPostCSRF(ctx, redir) { + return + } + enable := formBoolEnable(c) + if err := d.Post.SetCommentsLocked(id, enable); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(redir) + return + } + if enable { + ctx.SetFlash("已锁定评论") + } else { + ctx.SetFlash("已解锁评论") + } + ctx.Redirect(redir) +} + +// AdminPostDeletePost 软删进回收站 +func (d Deps) AdminPostDeletePost(c *gin.Context) { + ctx := d.ctx(c) + id, redir, ok := d.parseAdminPostID(c) + if !ok { + ctx.Redirect("/") + return + } + if !d.requireAdminPostCSRF(ctx, redir) { + return + } + if err := d.Post.Delete(ctx.UserID(), id, true); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(redir) + return + } + ctx.SetFlash("帖子已移入回收站") + ctx.Redirect("/admin/trash") +} + +type adminTrashRow struct { + ID uint + Title string + AuthorName string + BoardName string + DeletedLabel string +} + +type adminTrashData struct { + AdminChrome + Items []adminTrashRow + Page int + HasPrev bool + HasMore bool + PrevPage int + NextPage int + Keyword string +} + +// AdminTrashGet 回收站 +func (d Deps) AdminTrashGet(c *gin.Context) { + ctx := d.ctx(c) + d.renderAdminTrash(ctx, "") +} + +func (d Deps) renderAdminTrash(ctx *webctx.Context, errMsg string) { + chrome := d.adminChrome(ctx, "回收站", "trash") + chrome.Error = errMsg + page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + keyword := strings.TrimSpace(ctx.C.Query("keyword")) + size := d.Settings.PageSizeDefault() + data := adminTrashData{ + AdminChrome: chrome, + Page: page, + PrevPage: page - 1, + NextPage: page + 1, + HasPrev: page > 1, + Keyword: keyword, + } + list, total, err := d.Post.ListTrash(page, size, keyword) + if err != nil { + data.Error = err.Error() + ctx.HTML(http.StatusOK, "admin/trash", data) + return + } + data.HasMore = int64(page*size) < total + data.Items = make([]adminTrashRow, 0, len(list)) + for _, it := range list { + author := strings.TrimSpace(it.User.Nickname) + if author == "" { + author = it.User.Username + } + bname := "" + if it.Board.ID > 0 { + bname = it.Board.Name + } + data.Items = append(data.Items, adminTrashRow{ + ID: it.ID, Title: it.Title, AuthorName: author, BoardName: bname, + DeletedLabel: it.DeletedAt.Local().Format("2006-01-02 15:04"), + }) + } + ctx.HTML(http.StatusOK, "admin/trash", data) +} + +// AdminTrashRestorePost 恢复 +func (d Deps) AdminTrashRestorePost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect("/admin/trash") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := d.Post.Restore(uint(id)); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect("/admin/trash") + return + } + ctx.SetFlash("已恢复") + ctx.Redirect(fmt.Sprintf("/post/%d", id)) +} + +// AdminTrashPurgePost 彻底删除 +func (d Deps) AdminTrashPurgePost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect("/admin/trash") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := d.Post.Purge(uint(id)); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect("/admin/trash") + return + } + ctx.SetFlash("已彻底删除") + ctx.Redirect("/admin/trash") +} diff --git a/routers/web/home.go b/routers/web/home.go index 67ff5d7..e0f87e9 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -29,6 +29,12 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { 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.POST("/post/:id/admin/pin", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostPinPost) + g.POST("/post/:id/admin/board-pin", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostBoardPinPost) + g.POST("/post/:id/admin/feature", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostFeaturePost) + g.POST("/post/:id/admin/edit-lock", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostEditLockPost) + g.POST("/post/:id/admin/comments-lock", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostCommentsLockPost) + g.POST("/post/:id/admin/delete", authMW.RequireAuth(), authMW.RequireAdmin(), deps.AdminPostDeletePost) g.GET("/login", deps.LoginGet) g.POST("/login", deps.LoginPost) g.POST("/logout", deps.LogoutPost) @@ -76,6 +82,9 @@ 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("/trash", deps.AdminTrashGet) + admin.POST("/trash/:id/restore", deps.AdminTrashRestorePost) + admin.POST("/trash/:id/purge", deps.AdminTrashPurgePost) } g.GET("/user/:id", deps.UserPublic) diff --git a/routers/web/post.go b/routers/web/post.go index c01fe3b..87f2bbd 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -23,8 +23,11 @@ type PostPageData struct { AuthorID uint BoardID uint BoardName string - Pinned bool + Pinned bool // 展示用:全局或版内置顶 + GlobalPinned bool + BoardPinned bool Featured bool + EditLocked bool PostTypeLabel string CreatedLabel string ViewCount int @@ -37,6 +40,7 @@ type PostPageData struct { CommentsLocked bool CanEdit bool CanReportPost bool + IsAdmin bool } // CommentView 评论 @@ -126,7 +130,8 @@ func (d Deps) PostView(c *gin.Context) { PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)), PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID, BoardID: post.BoardID, BoardName: boardName, - Pinned: post.Pinned || post.BoardPinned, Featured: post.Featured, + Pinned: post.Pinned || post.BoardPinned, GlobalPinned: post.Pinned, BoardPinned: post.BoardPinned, + Featured: post.Featured, EditLocked: post.EditLocked, PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt), ViewCount: post.ViewCount, LikeCount: post.LikeCount, Liked: d.Post.IsLiked(ctx.UserID(), post.ID), Favorited: d.Post.IsFavorited(ctx.UserID(), post.ID), @@ -134,6 +139,7 @@ func (d Deps) PostView(c *gin.Context) { CommentsLocked: post.CommentsLocked, CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()), CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(), + IsAdmin: ctx.IsAdmin(), }) } diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl index 5dd12bd..3b7cdc1 100644 --- a/templates/admin/nav.tmpl +++ b/templates/admin/nav.tmpl @@ -4,6 +4,7 @@ 板块 审核 举报 + 回收站 友链 单页 设置 diff --git a/templates/admin/trash.tmpl b/templates/admin/trash.tmpl new file mode 100644 index 0000000..5c1a93a --- /dev/null +++ b/templates/admin/trash.tmpl @@ -0,0 +1,52 @@ +{{define "admin/trash"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

回收站

+ {{template "admin/nav" .}} + {{template "base/alert" .}} + +
+ + +
+ + {{if .Items}} + + + + + + {{range .Items}} + + + + + + + + {{end}} + +
标题作者板块删除时间操作
{{.Title}}{{.AuthorName}}{{if .BoardName}}{{.BoardName}}{{else}}—{{end}}{{.DeletedLabel}} +
+ + +
+
+ + +
+
+ {{if or .HasPrev .HasMore}} + + {{end}} + {{else}} +

回收站为空。

+ {{end}} +
+{{template "base/footer" .}} +{{end}} diff --git a/templates/post/body.tmpl b/templates/post/body.tmpl index 7a24873..acd33da 100644 --- a/templates/post/body.tmpl +++ b/templates/post/body.tmpl @@ -5,6 +5,8 @@
{{if .Pinned}}置顶{{end}} {{if .Featured}}精华{{end}} + {{if .EditLocked}}禁编{{end}} + {{if .CommentsLocked}}已结贴{{end}} {{if .PostTypeLabel}}{{.PostTypeLabel}}{{end}} {{if .BoardName}}{{.BoardName}}{{end}} {{if .AuthorID}}{{.AuthorName}}{{else}}{{.AuthorName}}{{end}} @@ -46,6 +48,39 @@ {{end}}
{{end}} + {{if .IsAdmin}} +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + +
+
+ {{end}}
{{safeHTML .BodyHTML}}
diff --git a/web_src/css/site.css b/web_src/css/site.css index 3d5f2e7..8dec6d5 100644 --- a/web_src/css/site.css +++ b/web_src/css/site.css @@ -890,9 +890,13 @@ 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-report-handle .j13-form__row { +.j13-post__admin { display: flex; flex-wrap: wrap; - gap: 0.5rem; + gap: 0.35rem 0.65rem; + margin-top: 0.65rem; + padding-top: 0.55rem; + border-top: 1px dashed var(--j13-border); + font-size: 0.85rem; }