feat: 调整 Feed 排序为新评论/新帖子/推荐帖

默认按新评论;推荐排序优先 featured,并将用户可见「精华」文案改为「推荐」。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 01:59:01 +08:00
parent 132ca5c82c
commit ba60a9b1c4
13 changed files with 44 additions and 40 deletions

View File

@@ -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"
/> />
); );

View File

@@ -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 不写进 URLlatest / hot 显式带上
if (sort !== 'reply') p.set('sort', sort);
const qs = p.toString(); const qs = p.toString();
if (boardId) { if (boardId) {

View File

@@ -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' && (

View File

@@ -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 />

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}

View File

@@ -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 ? '取消全局置顶' : '全局置顶'}

View File

@@ -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 {

View File

@@ -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),
} }

View File

@@ -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

View File

@@ -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 | hothot=推荐优先)
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"
} }
} }