feat: SSR 帖子修订历史列表与快照

作者与管理员可查看编辑前版本,帖详情增加修订入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 22:28:04 +08:00
parent c32c81d1ca
commit f10ba99012
9 changed files with 186 additions and 2 deletions

View File

@@ -59,7 +59,7 @@
- [ ] Markdown 编辑模式(与富文本互转/双模) - [ ] Markdown 编辑模式(与富文本互转/双模)
- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit` - [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
- [x] 删除帖子 → 软删进回收站 — SSR Admin帖详情 + `/admin/trash` - [x] 删除帖子 → 软删进回收站 — SSR Admin帖详情 + `/admin/trash`
- [ ] 修订历史列表与单条详情(可做 diff - [x] 修订历史列表与单条详情(可做 diff— SSR `/post/:id/revisions`(作者/管理员;无 diff
- [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites` - [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites`
- [x] 浏览量 — 详情页计数 - [x] 浏览量 — 详情页计数
- [x] 举报帖子 — SSR 表单 + `/admin/reports` - [x] 举报帖子 — SSR 表单 + `/admin/reports`

View File

@@ -148,7 +148,7 @@
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
| 抽奖卡 | 显示参与人数;开奖;中奖名单 | | 抽奖卡 | 显示参与人数;开奖;中奖名单 |
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR嵌套树 UI 未做 | | 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR嵌套树 UI 未做 |
| 修订 | 面板列出历史,可选对比 | | 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff |
| 图片 | Lightbox 查看 | | 图片 | Lightbox 查看 |
--- ---

View File

@@ -948,3 +948,16 @@ points-only[data-locked="false"] {
font-size: 0.85rem; 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; }

View File

@@ -22,6 +22,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g.GET("/post/:id", deps.PostView) g.GET("/post/:id", deps.PostView)
g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet) g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet)
g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost) 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.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment)
g.GET("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditGet) g.GET("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditGet)
g.POST("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditPost) g.POST("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditPost)

View File

@@ -40,6 +40,7 @@ type PostPageData struct {
Comments []CommentView Comments []CommentView
CommentsLocked bool CommentsLocked bool
CanEdit bool CanEdit bool
CanViewRevisions bool
CanReportPost bool CanReportPost bool
IsAdmin bool IsAdmin bool
} }
@@ -143,6 +144,7 @@ func (d Deps) PostView(c *gin.Context) {
BodyHTML: body, CommentCount: len(cv), Comments: cv, BodyHTML: body, CommentCount: len(cv), Comments: cv,
CommentsLocked: post.CommentsLocked, CommentsLocked: post.CommentsLocked,
CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()), CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()),
CanViewRevisions: canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()),
CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(), CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(),
IsAdmin: ctx.IsAdmin(), IsAdmin: ctx.IsAdmin(),
}) })
@@ -456,6 +458,116 @@ func commentHTMLToPlain(s string) string {
return html.UnescapeString(s) 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) { func parsePostID(c *gin.Context, d Deps) (uint, error) {
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext) idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
id, err := strconv.ParseUint(idStr, 10, 64) id, err := strconv.ParseUint(idStr, 10, 64)

View File

@@ -24,6 +24,7 @@
<button type="submit">{{if .Favorited}}取消收藏{{else}}收藏{{end}}</button> <button type="submit">{{if .Favorited}}取消收藏{{else}}收藏{{end}}</button>
</form> </form>
{{if .CanEdit}}<a class="j13-linkbtn" href="/post/{{.PostID}}/edit">编辑</a>{{end}} {{if .CanEdit}}<a class="j13-linkbtn" href="/post/{{.PostID}}/edit">编辑</a>{{end}}
{{if .CanViewRevisions}}<a class="j13-linkbtn" href="/post/{{.PostID}}/revisions">修订历史</a>{{end}}
{{if .CanReportPost}} {{if .CanReportPost}}
<details class="j13-report"> <details class="j13-report">
<summary class="j13-linkbtn">举报</summary> <summary class="j13-linkbtn">举报</summary>

View File

@@ -0,0 +1,19 @@
{{define "post/revision_detail"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-main--solo">
<p class="j13-muted">
<a href="/post/{{.PostID}}/revisions">← 修订列表</a>
· <a href="/post/{{.PostID}}">当前帖子</a>
</p>
<h1>修订 #{{.RevID}}</h1>
<p class="j13-muted">{{.EditorName}} · {{.CreatedLabel}}</p>
{{template "base/alert" .}}
<article class="j13-post j13-rev-snapshot">
<h2 class="j13-post__title">{{.Title}}</h2>
{{if .Tags}}<p class="j13-muted">标签:{{.Tags}}</p>{{end}}
<div class="j13-post__body">{{safeHTML .BodyHTML}}</div>
</article>
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -0,0 +1,24 @@
{{define "post/revisions"}}
{{template "base/head" .}}
{{template "base/navbar" .}}
<main class="j13-main j13-main--solo">
<h1>修订历史</h1>
<p class="j13-muted"><a href="/post/{{.PostID}}">← {{.PostTitle}}</a></p>
{{template "base/alert" .}}
{{if .Revisions}}
<ul class="j13-rev-list">
{{range .Revisions}}
<li class="j13-rev-item">
<a href="/post/{{$.PostID}}/revisions/{{.ID}}">
<strong>{{.Title}}</strong>
<span class="j13-muted">{{.EditorName}} · {{.CreatedLabel}}</span>
</a>
</li>
{{end}}
</ul>
{{else}}
<p class="j13-empty">暂无修订记录。编辑帖子后会在此保存旧版本。</p>
{{end}}
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -948,3 +948,16 @@ points-only[data-locked="false"] {
font-size: 0.85rem; 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; }