增加板块内置顶,与全局置顶分离:首页仅抬升全局置顶,板块列表优先全局再板块。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -81,6 +81,10 @@ export const api = {
|
|||||||
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
|
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
|
||||||
method: 'POST', body: JSON.stringify({ pinned }),
|
method: 'POST', body: JSON.stringify({ pinned }),
|
||||||
}),
|
}),
|
||||||
|
adminBoardPinPost: (id: number, boardPinned: boolean) =>
|
||||||
|
request<{ message: string; board_pinned: boolean }>(`/api/admin/posts/${id}/board-pin`, {
|
||||||
|
method: 'POST', body: JSON.stringify({ board_pinned: boardPinned }),
|
||||||
|
}),
|
||||||
adminFeaturePost: (id: number, featured: boolean) =>
|
adminFeaturePost: (id: number, featured: boolean) =>
|
||||||
request<{ message: string; featured: boolean }>(`/api/admin/posts/${id}/feature`, {
|
request<{ message: string; featured: boolean }>(`/api/admin/posts/${id}/feature`, {
|
||||||
method: 'POST', body: JSON.stringify({ featured }),
|
method: 'POST', body: JSON.stringify({ featured }),
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export interface PostItem {
|
|||||||
/** 仅问答帖有意义 */
|
/** 仅问答帖有意义 */
|
||||||
question_resolved?: boolean;
|
question_resolved?: boolean;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
|
/** 板块内置顶(仅板块列表抬升,首页不抬升) */
|
||||||
|
board_pinned?: boolean;
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
edit_locked?: boolean;
|
edit_locked?: boolean;
|
||||||
status?: 'pending' | 'published' | 'rejected' | string;
|
status?: 'pending' | 'published' | 'rejected' | string;
|
||||||
|
|||||||
@@ -78,7 +78,10 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
|||||||
|
|
||||||
<div className="post-title-row">
|
<div className="post-title-row">
|
||||||
{post.pinned && (
|
{post.pinned && (
|
||||||
<span className="post-pin-badge" title="置顶">置顶</span>
|
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
|
||||||
|
)}
|
||||||
|
{post.board_pinned && (
|
||||||
|
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
|
||||||
)}
|
)}
|
||||||
{post.featured && (
|
{post.featured && (
|
||||||
<span className="post-feature-badge" title="精华">
|
<span className="post-feature-badge" title="精华">
|
||||||
|
|||||||
@@ -475,6 +475,19 @@ export default function PostDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBoardPin = async () => {
|
||||||
|
if (!post) return;
|
||||||
|
try {
|
||||||
|
const r = await api.adminBoardPinPost(postId, !post.board_pinned);
|
||||||
|
setPost(p => p ? { ...p, board_pinned: r.board_pinned } : p);
|
||||||
|
clearAllFeedCache();
|
||||||
|
window.dispatchEvent(new Event('posts-refresh'));
|
||||||
|
notify.success(r.message);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFeature = async () => {
|
const handleFeature = async () => {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
try {
|
try {
|
||||||
@@ -601,7 +614,10 @@ export default function PostDetailPage() {
|
|||||||
<div className="post-detail-head">
|
<div className="post-detail-head">
|
||||||
<h1 className="post-detail-title">
|
<h1 className="post-detail-title">
|
||||||
{post.pinned && (
|
{post.pinned && (
|
||||||
<span className="post-pin-badge post-pin-badge--detail" title="置顶">置顶</span>
|
<span className="post-pin-badge post-pin-badge--detail" title="全局置顶">全局置顶</span>
|
||||||
|
)}
|
||||||
|
{post.board_pinned && (
|
||||||
|
<span className="post-pin-badge post-pin-badge--board post-pin-badge--detail" title="板块置顶">板块置顶</span>
|
||||||
)}
|
)}
|
||||||
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
|
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
|
||||||
{post.status === 'pending' && <Badge variant="orange" className="mr-2 align-middle">审核中</Badge>}
|
{post.status === 'pending' && <Badge variant="orange" className="mr-2 align-middle">审核中</Badge>}
|
||||||
@@ -792,7 +808,11 @@ export default function PostDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={handlePin}>
|
<Button variant="outline" size="sm" onClick={handlePin}>
|
||||||
<Pin />
|
<Pin />
|
||||||
{post.pinned ? '取消置顶' : '置顶'}
|
{post.pinned ? '取消全局置顶' : '全局置顶'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleBoardPin}>
|
||||||
|
<Pin />
|
||||||
|
{post.board_pinned ? '取消板块置顶' : '板块置顶'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={handleLock}>
|
<Button variant="outline" size="sm" onClick={handleLock}>
|
||||||
<Lock />
|
<Lock />
|
||||||
|
|||||||
@@ -153,8 +153,9 @@ export default function AdminDashboardPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="space-x-1">
|
<td className="space-x-1">
|
||||||
{p.featured ? <Badge variant="orange">精华</Badge> : null}
|
{p.featured ? <Badge variant="orange">精华</Badge> : null}
|
||||||
{p.pinned ? <Badge variant="green">置顶</Badge> : null}
|
{p.pinned ? <Badge variant="green">全局置顶</Badge> : null}
|
||||||
{!p.featured && !p.pinned ? '—' : null}
|
{p.board_pinned ? <Badge variant="green">板块置顶</Badge> : null}
|
||||||
|
{!p.featured && !p.pinned && !p.board_pinned ? '—' : null}
|
||||||
</td>
|
</td>
|
||||||
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
|
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -111,6 +111,18 @@ export default function AdminPostsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleBoardPin = async (post: PostItem) => {
|
||||||
|
try {
|
||||||
|
const r = await api.adminBoardPinPost(post.id, !post.board_pinned);
|
||||||
|
clearAllFeedCache();
|
||||||
|
window.dispatchEvent(new Event('posts-refresh'));
|
||||||
|
notify.success(r.message);
|
||||||
|
load(page);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleFeature = async (post: PostItem) => {
|
const toggleFeature = async (post: PostItem) => {
|
||||||
try {
|
try {
|
||||||
const r = await api.adminFeaturePost(post.id, !post.featured);
|
const r = await api.adminFeaturePost(post.id, !post.featured);
|
||||||
@@ -196,7 +208,7 @@ export default function AdminPostsPage() {
|
|||||||
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
|
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
|
||||||
: tab === 'pending'
|
: tab === 'pending'
|
||||||
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
|
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
|
||||||
: '精华、置顶、锁定编辑、删除(移入回收站);支持按标题、标签或正文搜索'}
|
: '精华、全局置顶、板块置顶、锁定编辑、删除(移入回收站);支持按标题、标签或正文搜索'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -316,7 +328,8 @@ export default function AdminPostsPage() {
|
|||||||
<th>标签</th>
|
<th>标签</th>
|
||||||
<th>评论</th>
|
<th>评论</th>
|
||||||
<th>精华</th>
|
<th>精华</th>
|
||||||
<th>置顶</th>
|
<th>全局置顶</th>
|
||||||
|
<th>板块置顶</th>
|
||||||
<th>锁定</th>
|
<th>锁定</th>
|
||||||
<th>点赞</th>
|
<th>点赞</th>
|
||||||
<th>浏览</th>
|
<th>浏览</th>
|
||||||
@@ -348,6 +361,7 @@ export default function AdminPostsPage() {
|
|||||||
<td>{p.comment_count ?? 0}</td>
|
<td>{p.comment_count ?? 0}</td>
|
||||||
<td>{p.featured ? <Badge variant="orange">是</Badge> : '—'}</td>
|
<td>{p.featured ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||||
<td>{p.pinned ? <Badge variant="green">是</Badge> : '—'}</td>
|
<td>{p.pinned ? <Badge variant="green">是</Badge> : '—'}</td>
|
||||||
|
<td>{p.board_pinned ? <Badge variant="green">是</Badge> : '—'}</td>
|
||||||
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||||
<td>{p.like_count}</td>
|
<td>{p.like_count}</td>
|
||||||
<td>{p.view_count}</td>
|
<td>{p.view_count}</td>
|
||||||
@@ -373,7 +387,10 @@ export default function AdminPostsPage() {
|
|||||||
{p.featured ? '取消精华' : '精华'}
|
{p.featured ? '取消精华' : '精华'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
|
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
|
||||||
{p.pinned ? '取消置顶' : '置顶'}
|
{p.pinned ? '取消全局置顶' : '全局置顶'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => toggleBoardPin(p)}>
|
||||||
|
{p.board_pinned ? '取消板块置顶' : '板块置顶'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
|
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
|
||||||
{p.edit_locked ? <><LockOpen size={14} /> 解锁</> : <><Lock size={14} /> 锁定</>}
|
{p.edit_locked ? <><LockOpen size={14} /> 解锁</> : <><Lock size={14} /> 锁定</>}
|
||||||
|
|||||||
@@ -2321,6 +2321,13 @@ body:has(.admin-topbar) .ptr-indicator {
|
|||||||
border: 1px solid rgba(220, 38, 38, 0.22);
|
border: 1px solid rgba(220, 38, 38, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 板块置顶:与全局置顶同结构,用偏暖橙区分 */
|
||||||
|
.post-pin-badge--board {
|
||||||
|
color: #c2410c;
|
||||||
|
background: rgba(234, 88, 12, 0.1);
|
||||||
|
border-color: rgba(234, 88, 12, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.post-pin-badge--detail {
|
.post-pin-badge--detail {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
@@ -2335,6 +2342,12 @@ body:has(.admin-topbar) .ptr-indicator {
|
|||||||
border-color: rgba(248, 113, 113, 0.28);
|
border-color: rgba(248, 113, 113, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .post-pin-badge--board {
|
||||||
|
color: #fb923c;
|
||||||
|
background: rgba(251, 146, 60, 0.14);
|
||||||
|
border-color: rgba(251, 146, 60, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
.post-status-badge {
|
.post-status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ func (h *Handlers) APIAdminLockPost(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "edit_locked": req.Locked})
|
c.JSON(http.StatusOK, gin.H{"message": msg, "edit_locked": req.Locked})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIAdminPinPost 置顶/取消置顶(JSON)
|
// APIAdminPinPost 全局置顶/取消全局置顶(JSON)
|
||||||
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
|
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -212,13 +212,34 @@ func (h *Handlers) APIAdminPinPost(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := "已取消置顶"
|
msg := "已取消全局置顶"
|
||||||
if req.Pinned {
|
if req.Pinned {
|
||||||
msg = "已置顶"
|
msg = "已全局置顶"
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned})
|
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APIAdminBoardPinPost 板块内置顶/取消板块内置顶(JSON)
|
||||||
|
func (h *Handlers) APIAdminBoardPinPost(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
var req struct {
|
||||||
|
BoardPinned bool `json:"board_pinned"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Post.SetBoardPinned(uint(id), req.BoardPinned); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := "已取消板块置顶"
|
||||||
|
if req.BoardPinned {
|
||||||
|
msg = "已板块置顶"
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": msg, "board_pinned": req.BoardPinned})
|
||||||
|
}
|
||||||
|
|
||||||
// APIAdminFeaturePost 设为精华/取消精华(JSON)
|
// APIAdminFeaturePost 设为精华/取消精华(JSON)
|
||||||
func (h *Handlers) APIAdminFeaturePost(c *gin.Context) {
|
func (h *Handlers) APIAdminFeaturePost(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ type Post struct {
|
|||||||
Tags string `gorm:"size:256" json:"tags"`
|
Tags string `gorm:"size:256" json:"tags"`
|
||||||
PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question
|
PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question
|
||||||
QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义
|
QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义
|
||||||
Pinned bool `gorm:"default:false" json:"pinned"`
|
Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
|
||||||
|
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
|
||||||
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
|
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
|
||||||
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
||||||
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
|
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adminAPI.GET("/posts", h.APIAdminPosts)
|
adminAPI.GET("/posts", h.APIAdminPosts)
|
||||||
adminAPI.GET("/posts/trash", h.APIAdminTrashPosts)
|
adminAPI.GET("/posts/trash", h.APIAdminTrashPosts)
|
||||||
adminAPI.POST("/posts/:id/pin", h.APIAdminPinPost)
|
adminAPI.POST("/posts/:id/pin", h.APIAdminPinPost)
|
||||||
|
adminAPI.POST("/posts/:id/board-pin", h.APIAdminBoardPinPost)
|
||||||
adminAPI.POST("/posts/:id/feature", h.APIAdminFeaturePost)
|
adminAPI.POST("/posts/:id/feature", h.APIAdminFeaturePost)
|
||||||
adminAPI.POST("/posts/:id/lock", h.APIAdminLockPost)
|
adminAPI.POST("/posts/:id/lock", h.APIAdminLockPost)
|
||||||
adminAPI.POST("/posts/:id/approve", h.APIAdminApprovePost)
|
adminAPI.POST("/posts/:id/approve", h.APIAdminApprovePost)
|
||||||
|
|||||||
@@ -275,6 +275,9 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
|||||||
db.Count(&total)
|
db.Count(&total)
|
||||||
var posts []model.Post
|
var posts []model.Post
|
||||||
db = db.Order("pinned desc")
|
db = db.Order("pinned desc")
|
||||||
|
if q.BoardID > 0 {
|
||||||
|
db = db.Order("board_pinned desc")
|
||||||
|
}
|
||||||
switch normalizePostSort(q.Sort) {
|
switch normalizePostSort(q.Sort) {
|
||||||
case "reply":
|
case "reply":
|
||||||
// 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底(仅计已公开评论)
|
// 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底(仅计已公开评论)
|
||||||
@@ -693,6 +696,10 @@ func (s *PostService) SetPinned(postID uint, pinned bool) error {
|
|||||||
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("pinned", pinned).Error
|
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("pinned", pinned).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PostService) SetBoardPinned(postID uint, boardPinned bool) error {
|
||||||
|
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("board_pinned", boardPinned).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PostService) SetFeatured(postID uint, featured bool) error {
|
func (s *PostService) SetFeatured(postID uint, featured bool) error {
|
||||||
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("featured", featured).Error
|
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("featured", featured).Error
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user