feat: 调整 Feed 排序为新评论/新帖子/推荐帖
默认按新评论;推荐排序优先 featured,并将用户可见「精华」文案改为「推荐」。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Sparkles } from 'lucide-react';
|
import { BadgeCheck } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -6,13 +6,13 @@ interface Props {
|
|||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 精华帖标识 */
|
/** 推荐帖标识 */
|
||||||
export default function FeaturedIcon({ className, size = 16 }: Props) {
|
export default function FeaturedIcon({ className, size = 16 }: Props) {
|
||||||
return (
|
return (
|
||||||
<Sparkles
|
<BadgeCheck
|
||||||
className={cn('post-featured-icon', className)}
|
className={cn('post-featured-icon', className)}
|
||||||
size={size}
|
size={size}
|
||||||
aria-label="精华"
|
aria-label="推荐"
|
||||||
role="img"
|
role="img"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { boardPath, type PermalinkOpts } from '../utils/permalink';
|
import { boardPath, type PermalinkOpts } from '../utils/permalink';
|
||||||
import { getCachedForumLimits } from '../hooks/useForumLimits';
|
import { getCachedForumLimits } from '../hooks/useForumLimits';
|
||||||
import { Clock, MessageCircle, Flame } from 'lucide-react';
|
import { Clock, MessageCircle, BadgeCheck } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { moveTabIndex } from '../hooks/useOverlayA11y';
|
import { moveTabIndex } from '../hooks/useOverlayA11y';
|
||||||
|
|
||||||
@@ -13,9 +13,9 @@ const SORT_OPTIONS: {
|
|||||||
hint: string;
|
hint: string;
|
||||||
icon: typeof Clock;
|
icon: typeof Clock;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ key: 'latest', label: '最新发帖', hint: '按发布时间', icon: Clock },
|
{ key: 'reply', label: '新评论', hint: '最近有人评论', icon: MessageCircle },
|
||||||
{ key: 'reply', label: '最新回复', hint: '最近有人回复', icon: MessageCircle },
|
{ key: 'latest', label: '新帖子', hint: '按发帖时间', icon: Clock },
|
||||||
{ key: 'hot', label: '热门讨论', hint: '按互动热度', icon: Flame },
|
{ key: 'hot', label: '推荐帖', hint: '站内推荐', icon: BadgeCheck },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,14 +24,15 @@ interface Props {
|
|||||||
postTotal?: number;
|
postTotal?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解析 URL sort;缺省为新评论(reply) */
|
||||||
export function parseFeedSort(raw: string | null): FeedSort {
|
export function parseFeedSort(raw: string | null): FeedSort {
|
||||||
if (raw === 'reply' || raw === 'hot') return raw;
|
if (raw === 'latest' || raw === 'hot') return raw;
|
||||||
return 'latest';
|
return 'reply';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHomeUrl(
|
export function buildHomeUrl(
|
||||||
boardId: number,
|
boardId: number,
|
||||||
sort: FeedSort = 'latest',
|
sort: FeedSort = 'reply',
|
||||||
opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts },
|
opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts },
|
||||||
) {
|
) {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
@@ -47,7 +48,8 @@ export function buildHomeUrl(
|
|||||||
} else if (author) {
|
} else if (author) {
|
||||||
p.set('author', author);
|
p.set('author', author);
|
||||||
}
|
}
|
||||||
if (sort !== 'latest') p.set('sort', sort);
|
// 默认 reply 不写进 URL;latest / hot 显式带上
|
||||||
|
if (sort !== 'reply') p.set('sort', sort);
|
||||||
const qs = p.toString();
|
const qs = p.toString();
|
||||||
|
|
||||||
if (boardId) {
|
if (boardId) {
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
|
|||||||
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
|
<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="推荐">
|
||||||
<FeaturedIcon size={12} />
|
<FeaturedIcon size={12} />
|
||||||
精华
|
推荐
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{post.status === 'pending' && (
|
{post.status === 'pending' && (
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default function PostManageMenu({
|
|||||||
<DropdownMenuLabel>展示</DropdownMenuLabel>
|
<DropdownMenuLabel>展示</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onSelect={onFeature}>
|
<DropdownMenuItem onSelect={onFeature}>
|
||||||
<Sparkles size={14} aria-hidden />
|
<Sparkles size={14} aria-hidden />
|
||||||
{post.featured ? '取消精华' : '设为精华'}
|
{post.featured ? '取消推荐' : '设为推荐'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={onPin}>
|
<DropdownMenuItem onSelect={onPin}>
|
||||||
<Pin size={14} aria-hidden />
|
<Pin size={14} aria-hidden />
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function getMobileFeedScrollEl(): HTMLElement | null {
|
|||||||
|
|
||||||
export default function VirtualPostList({
|
export default function VirtualPostList({
|
||||||
posts,
|
posts,
|
||||||
sort = 'latest',
|
sort = 'reply',
|
||||||
loading,
|
loading,
|
||||||
hasMore,
|
hasMore,
|
||||||
showPagination,
|
showPagination,
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export default function MainLayout() {
|
|||||||
};
|
};
|
||||||
}, [refreshUnreadMessages]);
|
}, [refreshUnreadMessages]);
|
||||||
|
|
||||||
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/精华等不改标签)
|
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCompose || !showTagCloud) return;
|
if (isCompose || !showTagCloud) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export default function HomePage() {
|
|||||||
tag: tag || '',
|
tag: tag || '',
|
||||||
author: tag ? '' : author,
|
author: tag ? '' : author,
|
||||||
title_only: !tag && titleOnly ? '1' : '',
|
title_only: !tag && titleOnly ? '1' : '',
|
||||||
sort: sort === 'latest' ? '' : sort,
|
sort,
|
||||||
});
|
});
|
||||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||||
const total = data.total ?? 0;
|
const total = data.total ?? 0;
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ 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.board_pinned ? <Badge variant="green">板块置顶</Badge> : null}
|
{p.board_pinned ? <Badge variant="green">板块置顶</Badge> : null}
|
||||||
{!p.featured && !p.pinned && !p.board_pinned ? '—' : null}
|
{!p.featured && !p.pinned && !p.board_pinned ? '—' : null}
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export default function AdminPostsPage() {
|
|||||||
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
|
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
|
||||||
: tab === 'pending'
|
: tab === 'pending'
|
||||||
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
|
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
|
||||||
: '精华、全局置顶、板块置顶、锁定编辑/讨论、删除(移入回收站);支持按标题、标签或正文搜索'}
|
: '推荐、全局置顶、板块置顶、锁定编辑/讨论、删除(移入回收站);支持按标题、标签或正文搜索'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -337,7 +337,7 @@ 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>
|
||||||
@@ -396,7 +396,7 @@ export default function AdminPostsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" variant="outline" onClick={() => toggleFeature(p)}>
|
<Button size="sm" variant="outline" onClick={() => toggleFeature(p)}>
|
||||||
{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 ? '取消全局置顶' : '全局置顶'}
|
||||||
|
|||||||
@@ -2775,7 +2775,7 @@ body:has(.admin-topbar) .ptr-indicator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 手机 Feed:主栏整页滚动,板块条 / 最新发帖·回复排序栏随列表滚走,
|
* 手机 Feed:主栏整页滚动,板块条 / 新评论·新帖子排序栏随列表滚走,
|
||||||
* 避免它们占住视口上半,给帖子列表更多空间。
|
* 避免它们占住视口上半,给帖子列表更多空间。
|
||||||
*/
|
*/
|
||||||
.main-content--feed-mobile-scroll {
|
.main-content--feed-mobile-scroll {
|
||||||
@@ -3468,15 +3468,15 @@ body:has(.admin-topbar) .ptr-indicator {
|
|||||||
|
|
||||||
.post-feature-badge {
|
.post-feature-badge {
|
||||||
padding: 0 7px 0 5px;
|
padding: 0 7px 0 5px;
|
||||||
color: #b45309;
|
color: var(--j13-green);
|
||||||
background: rgba(245, 158, 11, 0.12);
|
background: var(--j13-green-bg);
|
||||||
border: 1px solid rgba(245, 158, 11, 0.22);
|
border: 1px solid color-mix(in srgb, var(--j13-green) 28%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .post-feature-badge {
|
.dark .post-feature-badge {
|
||||||
color: #fbbf24;
|
color: var(--j13-green);
|
||||||
background: rgba(251, 191, 36, 0.14);
|
background: var(--j13-green-bg);
|
||||||
border-color: rgba(251, 191, 36, 0.22);
|
border-color: color-mix(in srgb, var(--j13-green) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-feature-badge .post-featured-icon {
|
.post-feature-badge .post-featured-icon {
|
||||||
@@ -3611,12 +3611,12 @@ a.post-title:visited {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: -0.15em;
|
vertical-align: -0.15em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #d97706;
|
color: var(--j13-green);
|
||||||
stroke-width: 1.75;
|
stroke-width: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .post-featured-icon {
|
.dark .post-featured-icon {
|
||||||
color: #fbbf24;
|
color: var(--j13-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-meta {
|
.post-meta {
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ func (h *Handlers) APIAdminBoardPinPost(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "board_pinned": req.BoardPinned})
|
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)
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -300,9 +300,9 @@ func (h *Handlers) APIAdminFeaturePost(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.Featured {
|
if req.Featured {
|
||||||
msg = "已设为精华"
|
msg = "已设为推荐"
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "featured": req.Featured})
|
c.JSON(http.StatusOK, gin.H{"message": msg, "featured": req.Featured})
|
||||||
}
|
}
|
||||||
@@ -976,7 +976,7 @@ func (h *Handlers) APIPosts(c *gin.Context) {
|
|||||||
Tag: tag,
|
Tag: tag,
|
||||||
Author: author,
|
Author: author,
|
||||||
TitleOnly: titleOnly,
|
TitleOnly: titleOnly,
|
||||||
Sort: c.DefaultQuery("sort", "latest"),
|
Sort: c.DefaultQuery("sort", "reply"),
|
||||||
ViewerID: h.currentUserID(c),
|
ViewerID: h.currentUserID(c),
|
||||||
ViewerIsAdmin: h.isAdmin(c),
|
ViewerIsAdmin: h.isAdmin(c),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ type Post struct {
|
|||||||
LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
|
LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
|
||||||
Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
|
Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
|
||||||
BoardPinned bool `gorm:"default:false" json:"board_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"`
|
||||||
CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
|
CommentsLocked bool `gorm:"default:false" json:"comments_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
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ type PostListQuery struct {
|
|||||||
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE)
|
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE)
|
||||||
Author string // 作者用户名或昵称(解析为 UserID)
|
Author string // 作者用户名或昵称(解析为 UserID)
|
||||||
TitleOnly bool // 关键词仅匹配标题
|
TitleOnly bool // 关键词仅匹配标题
|
||||||
Sort string // latest | reply | hot
|
Sort string // reply | latest | hot(hot=推荐优先)
|
||||||
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
|
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
|
||||||
ViewerIsAdmin bool
|
ViewerIsAdmin bool
|
||||||
Status string // 管理端筛选:pending|published|rejected|all;空则按可见性规则
|
Status string // 管理端筛选:pending|published|rejected|all;空则按可见性规则
|
||||||
@@ -365,7 +365,8 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
|||||||
) DESC`)
|
) DESC`)
|
||||||
db = db.Order("posts.created_at DESC")
|
db = db.Order("posts.created_at DESC")
|
||||||
case "hot":
|
case "hot":
|
||||||
db = db.Order("like_count desc, view_count desc")
|
// 推荐帖:人工推荐(featured)优先,再按互动
|
||||||
|
db = db.Order("featured desc, like_count desc, view_count desc")
|
||||||
default:
|
default:
|
||||||
db = db.Order("id desc")
|
db = db.Order("id desc")
|
||||||
}
|
}
|
||||||
@@ -375,10 +376,11 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
|
|||||||
|
|
||||||
func normalizePostSort(sort string) string {
|
func normalizePostSort(sort string) string {
|
||||||
switch sort {
|
switch sort {
|
||||||
case "reply", "hot":
|
case "latest", "hot":
|
||||||
return sort
|
return sort
|
||||||
default:
|
default:
|
||||||
return "latest"
|
// 含空串 / reply:默认新评论
|
||||||
|
return "reply"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user