diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 0fa4b84..0be4f3f 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -59,7 +59,7 @@ - [ ] Markdown 编辑模式(与富文本互转/双模) - [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit` - [x] 删除帖子 → 软删进回收站 — SSR Admin(帖详情 + `/admin/trash`) -- [ ] 修订历史列表与单条详情(可做 diff) +- [x] 修订历史列表与单条详情(可做 diff)— SSR `/post/:id/revisions`(作者/管理员;无 diff) - [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites` - [x] 浏览量 — 详情页计数 - [x] 举报帖子 — SSR 表单 + `/admin/reports` diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 2e3e4ab..a07b832 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -148,7 +148,7 @@ | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | | 抽奖卡 | 显示参与人数;开奖;中奖名单 | | 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 | -| 修订 | 面板列出历史,可选对比 | +| 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) | | 图片 | Lightbox 查看 | --- diff --git a/public/assets/site.css b/public/assets/site.css index 79e247f..73441e2 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -948,3 +948,16 @@ points-only[data-locked="false"] { font-size: 0.85rem; } + +.j13-rev-list { list-style: none; margin: 1rem 0; padding: 0; } +.j13-rev-item { border-bottom: 1px solid var(--j13-border); } +.j13-rev-item a { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.75rem 0; + color: inherit; + text-decoration: none; +} +.j13-rev-item a:hover strong { color: var(--j13-accent); } +.j13-rev-snapshot { margin-top: 1rem; } diff --git a/routers/web/home.go b/routers/web/home.go index 20d2a9e..7699425 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -22,6 +22,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { g.GET("/post/:id", deps.PostView) g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet) g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost) + g.GET("/post/:id/revisions", authMW.RequireAuth(), deps.PostRevisionsGet) + g.GET("/post/:id/revisions/:rid", authMW.RequireAuth(), deps.PostRevisionDetailGet) g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment) g.GET("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditGet) g.POST("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditPost) diff --git a/routers/web/post.go b/routers/web/post.go index db02e29..603d94a 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -40,6 +40,7 @@ type PostPageData struct { Comments []CommentView CommentsLocked bool CanEdit bool + CanViewRevisions bool CanReportPost bool IsAdmin bool } @@ -143,6 +144,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()), + CanViewRevisions: canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()), CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(), IsAdmin: ctx.IsAdmin(), }) @@ -456,6 +458,116 @@ func commentHTMLToPlain(s string) string { return html.UnescapeString(s) } +type postRevisionRow struct { + ID uint + EditorName string + Title string + CreatedLabel string +} + +type postRevisionsData struct { + PageChrome + PostID uint + PostTitle string + Revisions []postRevisionRow +} + +type postRevisionDetailData struct { + PageChrome + PostID uint + PostTitle string + RevID uint + EditorName string + CreatedLabel string + Title string + Tags string + BodyHTML string +} + +// PostRevisionsGet 帖子修订历史列表(作者或管理员) +func (d Deps) PostRevisionsGet(c *gin.Context) { + ctx := d.ctx(c) + post, ok := d.loadRevisionPost(ctx, c) + if !ok { + return + } + revs, err := d.Post.ListRevisions(post.ID) + if err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(fmt.Sprintf("/post/%d", post.ID)) + return + } + rows := make([]postRevisionRow, 0, len(revs)) + for _, r := range revs { + name := displayName(&r.Editor) + if name == "" { + name = fmt.Sprintf("用户 #%d", r.EditorID) + } + rows = append(rows, postRevisionRow{ + ID: r.ID, EditorName: name, Title: r.Title, CreatedLabel: formatTime(r.CreatedAt), + }) + } + chrome := d.chrome(ctx, "修订历史 · "+post.Title+" · "+d.Settings.SiteBranding().Name, "", "") + ctx.HTML(http.StatusOK, "post/revisions", postRevisionsData{ + PageChrome: chrome, PostID: post.ID, PostTitle: post.Title, Revisions: rows, + }) +} + +// PostRevisionDetailGet 单条修订快照 +func (d Deps) PostRevisionDetailGet(c *gin.Context) { + ctx := d.ctx(c) + post, ok := d.loadRevisionPost(ctx, c) + if !ok { + return + } + rid, err := strconv.ParseUint(c.Param("rid"), 10, 64) + if err != nil || rid == 0 { + d.render404(ctx) + return + } + rev, err := d.Post.GetRevision(post.ID, uint(rid)) + if err != nil { + d.render404(ctx) + return + } + name := displayName(&rev.Editor) + if name == "" { + name = fmt.Sprintf("用户 #%d", rev.EditorID) + } + chrome := d.chrome(ctx, "修订 #"+strconv.FormatUint(rid, 10)+" · "+post.Title+" · "+d.Settings.SiteBranding().Name, "", "") + ctx.HTML(http.StatusOK, "post/revision_detail", postRevisionDetailData{ + PageChrome: chrome, PostID: post.ID, PostTitle: post.Title, + RevID: rev.ID, EditorName: name, CreatedLabel: formatTime(rev.CreatedAt), + Title: rev.Title, Tags: rev.Tags, BodyHTML: rev.Content, + }) +} + +func (d Deps) loadRevisionPost(ctx *webctx.Context, c *gin.Context) (*models.Post, bool) { + id, err := parsePostID(c, d) + if err != nil || id == 0 { + d.render404(ctx) + return nil, false + } + post, err := d.Post.FindByID(id) + if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) { + d.render404(ctx) + return nil, false + } + if !canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()) { + ctx.SetFlash("无权查看编辑历史") + ctx.Redirect(fmt.Sprintf("/post/%d", post.ID)) + return nil, false + } + return post, true +} + +func canViewPostRevisions(post *models.Post, userID uint, isAdmin bool) bool { + if post == nil || userID == 0 { + return false + } + return isAdmin || post.UserID == userID +} + func parsePostID(c *gin.Context, d Deps) (uint, error) { idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext) id, err := strconv.ParseUint(idStr, 10, 64) diff --git a/templates/post/body.tmpl b/templates/post/body.tmpl index 1676b02..df9bb9d 100644 --- a/templates/post/body.tmpl +++ b/templates/post/body.tmpl @@ -24,6 +24,7 @@ {{if .CanEdit}}编辑{{end}} + {{if .CanViewRevisions}}修订历史{{end}} {{if .CanReportPost}}
举报 diff --git a/templates/post/revision_detail.tmpl b/templates/post/revision_detail.tmpl new file mode 100644 index 0000000..c9cd66c --- /dev/null +++ b/templates/post/revision_detail.tmpl @@ -0,0 +1,19 @@ +{{define "post/revision_detail"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

+ ← 修订列表 + · 当前帖子 +

+

修订 #{{.RevID}}

+

{{.EditorName}} · {{.CreatedLabel}}

+ {{template "base/alert" .}} +
+

{{.Title}}

+ {{if .Tags}}

标签:{{.Tags}}

{{end}} +
{{safeHTML .BodyHTML}}
+
+
+{{template "base/footer" .}} +{{end}} diff --git a/templates/post/revisions.tmpl b/templates/post/revisions.tmpl new file mode 100644 index 0000000..93cfed5 --- /dev/null +++ b/templates/post/revisions.tmpl @@ -0,0 +1,24 @@ +{{define "post/revisions"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

修订历史

+

← {{.PostTitle}}

+ {{template "base/alert" .}} + {{if .Revisions}} + + {{else}} +

暂无修订记录。编辑帖子后会在此保存旧版本。

+ {{end}} +
+{{template "base/footer" .}} +{{end}} diff --git a/web_src/css/site.css b/web_src/css/site.css index 79e247f..73441e2 100644 --- a/web_src/css/site.css +++ b/web_src/css/site.css @@ -948,3 +948,16 @@ points-only[data-locked="false"] { font-size: 0.85rem; } + +.j13-rev-list { list-style: none; margin: 1rem 0; padding: 0; } +.j13-rev-item { border-bottom: 1px solid var(--j13-border); } +.j13-rev-item a { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.75rem 0; + color: inherit; + text-decoration: none; +} +.j13-rev-item a:hover strong { color: var(--j13-accent); } +.j13-rev-snapshot { margin-top: 1rem; }