回收站
+ {{template "admin/nav" .}} + {{template "base/alert" .}} + + + + {{if .Items}} +| 标题 | 作者 | 板块 | 删除时间 | 操作 |
|---|---|---|---|---|
| {{.Title}} | +{{.AuthorName}} | +{{if .BoardName}}{{.BoardName}}{{else}}—{{end}} | +{{.DeletedLabel}} | ++ + + | +
回收站为空。
+ {{end}} +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}}
+
+
+
+ {{if or .HasPrev .HasMore}}
+
+ {{end}}
+ {{else}}
+
+
+
+ {{range .Items}}
+ 标题 作者 板块 删除时间 操作
+
+ {{end}}
+
+ {{.Title}}
+ {{.AuthorName}}
+ {{if .BoardName}}{{.BoardName}}{{else}}—{{end}}
+ {{.DeletedLabel}}
+
+
+
+
+