feat: 文章属性前端完成 — Phase 3

- 写文章页:可见性/类型/转载来源/创作声明/禁止转载 属性表单
- 类型切换联动(转载显示来源,原创显示禁止转载复选框)
- 文章详情页:私密角标、置顶标记、创作声明横幅、转载来源
- 管理后台:置顶/取消置顶操作按钮
- PostService Create/Update 签名扩展接受文章属性
- DTO/接口同步更新
This commit is contained in:
2026-06-22 00:20:52 +08:00
parent 9c452afb40
commit 977c52513a
13 changed files with 225 additions and 18 deletions

View File

@ -42,9 +42,9 @@ type postUseCase interface {
// studioUseCase StudioController 对 PostService 的最小依赖ISP9 个方法)
type studioUseCase interface {
Create(userID uint, title, body string) (*model.Post, error)
Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
Update(postID uint, title, body string) error
Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool) error
Delete(postID uint) error
SubmitForAudit(postID uint) error
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)

View File

@ -30,7 +30,7 @@ func (ctrl *StudioController) Create(c *gin.Context) {
return
}
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited)
if err != nil {
common.Error(c, http.StatusInternalServerError, "发布失败")
return
@ -69,7 +69,7 @@ func (ctrl *StudioController) Update(c *gin.Context) {
return
}
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited); err != nil {
common.Error(c, http.StatusInternalServerError, "编辑失败")
return
}

View File

@ -34,12 +34,22 @@ type PostListResult struct {
type PostCreateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
Visibility string `json:"visibility"`
PostType string `json:"post_type"`
ReprintSource string `json:"reprint_source"`
Declaration string `json:"declaration"`
ReprintProhibited bool `json:"reprint_prohibited"`
}
// PostUpdateRequest 编辑请求
type PostUpdateRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Body string `json:"body" binding:"required,min=1"`
Visibility string `json:"visibility"`
PostType string `json:"post_type"`
ReprintSource string `json:"reprint_source"`
Declaration string `json:"declaration"`
ReprintProhibited bool `json:"reprint_prohibited"`
}
// PostRejectRequest 退回请求

View File

@ -6,7 +6,7 @@ import (
)
// Update 编辑帖子(权限在 controller 层检查)
func (s *PostService) Update(postID uint, title, body string) error {
func (s *PostService) Update(postID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool) error {
post, err := s.findPost(postID)
if err != nil {
return err
@ -16,6 +16,13 @@ func (s *PostService) Update(postID uint, title, body string) error {
return common.ErrPostCannotEdit
}
// 文章属性随时可更新(不影响已发布内容展示)
post.Visibility = visibility
post.PostType = postType
post.ReprintSource = reprintSource
post.Declaration = declaration
post.ReprintProhibited = reprintProhibited
// 已通过帖子:编辑内容保存为待审修订,不影响当前展示
if post.Status == model.PostStatusApproved {
post.PendingTitle = title

View File

@ -68,7 +68,7 @@ func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
// Create 创建帖子
// body 为 Vditor 输出的 Markdown后端直接存储不做 HTML 消毒
// MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 HTML
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
func (s *PostService) Create(userID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool) (*model.Post, error) {
status := model.PostStatusApproved
if s.auditEnabled() {
status = model.PostStatusDraft
@ -81,6 +81,11 @@ func (s *PostService) Create(userID uint, title, body string) (*model.Post, erro
UserID: userID,
Status: status,
AllowComment: true,
Visibility: visibility,
PostType: postType,
ReprintSource: reprintSource,
Declaration: declaration,
ReprintProhibited: reprintProhibited,
}
if err := s.repo.Create(post); err != nil {
return nil, err

View File

@ -18,6 +18,12 @@
<span>收藏:{{.Post.FavoritesCount}}</span>
<span>评论:{{.Post.CommentsCount}}</span>
<span>发布时间:{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
{{if eq .Post.Visibility "private"}}
<span class="post-badge post-badge-private">私密</span>
{{end}}
{{if .Post.PinType}}
<span class="post-badge post-badge-pinned">📌 {{if eq .Post.PinType "global"}}全局置顶{{else}}分类置顶{{end}}</span>
{{end}}
{{if and .Post.IsLocked (eq $.Post.UserID $.UID)}}
<span class="post-status post-status-locked">已锁定{{if .Post.LockReason}}{{.Post.LockReason}}{{end}}</span>
{{end}}
@ -25,6 +31,19 @@
<span class="post-status post-status-revision">修订审核中</span>
{{end}}
</div>
<!-- 文章属性声明 -->
{{if or .Post.Declaration .Post.ReprintSource .Post.ReprintProhibited (eq .Post.PostType "reprint")}}
<div class="post-declarations">
{{if .Post.Declaration}}
<div class="post-declaration-item">⚡ {{declarationLabel .Post.Declaration}}</div>
{{end}}
{{if eq .Post.PostType "reprint"}}
<div class="post-declaration-item">📎 转载自:{{.Post.ReprintSource}}</div>
{{else if .Post.ReprintProhibited}}
<div class="post-declaration-item">⚠ 未经作者许可,禁止转载</div>
{{end}}
</div>
{{end}}
</header>
{{if .Post.RejectReason}}

View File

@ -45,6 +45,40 @@
{{if and .Post .Post.IsLocked}}disabled{{end}}>
</div>
<!-- 文章属性面板 -->
<div class="editor-meta"{{if and .Post .Post.IsLocked}} style="opacity:0.5;pointer-events:none"{{end}}>
<div class="meta-row">
<span class="meta-label">可见性</span>
<label class="meta-radio"><input type="radio" name="visibility" value="public" checked> 公开</label>
<label class="meta-radio"><input type="radio" name="visibility" value="private"> 私密</label>
</div>
<div class="meta-row">
<span class="meta-label">类型</span>
<label class="meta-radio"><input type="radio" name="post_type" value="original" checked> 原创</label>
<label class="meta-radio"><input type="radio" name="post_type" value="reprint"> 转载</label>
<span id="reprintSourceArea" style="display:none;margin-left:8px">
<input type="text" id="reprintSource" placeholder="转载来源" style="width:260px">
</span>
</div>
<div class="meta-row" id="reprintProhibitArea">
<span class="meta-label"></span>
<label class="meta-checkbox"><input type="checkbox" id="reprintProhibited"> 未经作者许可,禁止转载</label>
</div>
<div class="meta-row">
<span class="meta-label">声明</span>
<select id="declaration" style="width:auto">
<option value="">(无特别声明)</option>
<option value="ai_generated">AI 辅助创作</option>
<option value="fictional">含虚构情节</option>
<option value="speculative">含猜测性内容</option>
<option value="disturbing">可能令人不适</option>
<option value="spoiler">含剧透内容</option>
<option value="controversial">涉及争议性话题</option>
<option value="opinion">个人观点</option>
</select>
</div>
</div>
<div class="editor-main">
<div class="editor-pane">
<div id="vditor"></div>

View File

@ -1374,3 +1374,39 @@
margin-top: 2px;
padding-top: 6px;
}
/* --- 文章属性声明 --- */
.post-declarations {
margin-top: 10px;
padding: 10px 14px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.post-declaration-item {
font-size: 13px;
color: #92400e;
line-height: 1.5;
}
.post-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.post-badge-private {
background: #f3f4f6;
color: #6b7280;
}
.post-badge-pinned {
background: #dbeafe;
color: #1d4ed8;
}

View File

@ -759,3 +759,48 @@
width: 100%;
}
}
/* --- Editor Meta (文章属性) --- */
.editor-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
padding: 10px 16px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 12px;
font-size: 14px;
}
.meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.meta-label {
font-weight: 600;
color: #374151;
min-width: 40px;
}
.meta-radio {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: #4b5563;
}
.meta-radio input[type="radio"] {
margin: 0;
}
.meta-checkbox {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
color: #4b5563;
}

View File

@ -98,6 +98,16 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
// 类型切换联动:转载→显示来源,原创→显示禁止转载
const typeRadios = document.querySelectorAll('input[name="post_type"]')
const reprintSourceArea = document.getElementById('reprintSourceArea')
const reprintProhibitArea = document.getElementById('reprintProhibitArea')
typeRadios.forEach(r => r.addEventListener('change', () => {
const isReprint = document.querySelector('input[name="post_type"]:checked')?.value === 'reprint'
if (reprintSourceArea) reprintSourceArea.style.display = isReprint ? '' : 'none'
if (reprintProhibitArea) reprintProhibitArea.style.display = isReprint ? 'none' : ''
}))
if (!isLocked) {
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
@ -174,6 +184,13 @@ async function handleSubmit(e) {
const body = vditor ? vditor.getValue() : ''
if (!body.trim()) { showToast('请输入正文', 'warning'); return }
// 读取文章属性
const visibility = document.querySelector('input[name="visibility"]:checked')?.value || 'public'
const postType = document.querySelector('input[name="post_type"]:checked')?.value || 'original'
const reprintSource = document.getElementById('reprintSource')?.value.trim() || ''
const declaration = document.getElementById('declaration')?.value || ''
const reprintProhibited = document.getElementById('reprintProhibited')?.checked || false
const isEdit = !!postId
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
const method = isEdit ? 'PUT' : 'POST'
@ -184,7 +201,13 @@ async function handleSubmit(e) {
'Content-Type': 'application/json',
'X-CSRF-Token': getCSRFToken(),
},
body: JSON.stringify({ title, body }),
body: JSON.stringify({
title, body,
visibility, post_type: postType,
reprint_source: reprintSource,
declaration,
reprint_prohibited: reprintProhibited,
}),
})
const data = await resp.json()

View File

@ -42,7 +42,7 @@
<td>{{.ID}}</td>
<td><a href="/posts/{{.ID}}" target="_blank" class="post-link">{{.Title}}</a>{{if .PendingBody}} <span class="revision-badge">修订中</span>{{end}}</td>
<td>{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</td>
<td><span class="status-badge status-{{.Status}}">{{index $.StatusNames .Status}}</span>{{if .IsLocked}} <span class="status-badge status-locked">已锁定</span>{{end}}</td>
<td><span class="status-badge status-{{.Status}}">{{index $.StatusNames .Status}}</span>{{if .IsLocked}} <span class="status-badge status-locked">已锁定</span>{{end}}{{if .PinType}} <span class="status-badge status-pinned">📌</span>{{end}}</td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td class="actions-cell">
<div class="action-btns">
@ -63,6 +63,14 @@
{{if .IsLocked}}
<button class="btn btn-sm btn-success post-unlock" data-id="{{.ID}}">解锁</button>
{{end}}
{{if eq .Status "approved"}}
{{if not .PinType}}
<button class="btn btn-sm btn-info post-pin" data-id="{{.ID}}" data-pin-type="global">全局置顶</button>
<button class="btn btn-sm btn-info post-pin" data-id="{{.ID}}" data-pin-type="category">分类置顶</button>
{{else}}
<button class="btn btn-sm btn-secondary post-unpin" data-id="{{.ID}}">取消置顶</button>
{{end}}
{{end}}
</div>
</td>
</tr>

View File

@ -80,6 +80,7 @@
.status-approved { background: #d1fae5; color: #059669; }
.status-rejected { background: #fee2e2; color: #dc2626; }
.status-locked { background: #fee2e2; color: #b91c1c; }
.status-pinned { background: #dbeafe; color: #1d4ed8; }
.revision-section { margin-bottom: 24px; }
.revision-section h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #4338ca; }
@ -111,6 +112,7 @@
.btn-success { background: #10b981; color: #fff; }
.btn-warning { background: #f59e0b; color: #fff; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-info { background: #3b82f6; color: #fff; }
/* 分页 */
.pagination {

View File

@ -52,4 +52,22 @@
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
// 置顶
document.querySelectorAll('.post-pin').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = btn.getAttribute('data-id');
var pinType = btn.getAttribute('data-pin-type');
api.post('/api/admin/posts/' + id + '/pin', { pin_type: pinType })
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
});
// 取消置顶
handleClick('.post-unpin', '确定取消置顶吗?', function(id) {
api.post('/api/admin/posts/' + id + '/unpin')
.then(function(d) { if (d.success) refreshPage(); else showToast(d.message || '操作失败', 'error'); })
.catch(function() { showToast('操作失败', 'error'); });
});
})();