feat: SSR 帖子修订历史列表与快照
作者与管理员可查看编辑前版本,帖详情增加修订入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
|
||||
| 抽奖卡 | 显示参与人数;开奖;中奖名单 |
|
||||
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 |
|
||||
| 修订 | 面板列出历史,可选对比 |
|
||||
| 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) |
|
||||
| 图片 | Lightbox 查看 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<button type="submit">{{if .Favorited}}取消收藏{{else}}收藏{{end}}</button>
|
||||
</form>
|
||||
{{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}}
|
||||
<details class="j13-report">
|
||||
<summary class="j13-linkbtn">举报</summary>
|
||||
|
||||
19
templates/post/revision_detail.tmpl
Normal file
19
templates/post/revision_detail.tmpl
Normal 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}}
|
||||
24
templates/post/revisions.tmpl
Normal file
24
templates/post/revisions.tmpl
Normal 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}}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user