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