feat: 优化首页帖子列表并新增右侧栏最新注册组件

首页列表精简 meta 与统计展示,板块色标前移并淡化;有回复时显示最后回复人。右侧栏新增最新注册(4 列头像网格),友链改为标签块并排换行。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 18:50:09 +08:00
parent 2208af7070
commit 7cc574f9a1
17 changed files with 594 additions and 113 deletions

View File

@@ -1,45 +1,48 @@
import { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
import { MessageCircle } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge';
import FeaturedIcon from '@/components/FeaturedIcon';
import UserLink from '@/components/UserLink';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { useForumLimits } from '../hooks/useForumLimits';
import { formatTime } from '../utils/content';
import { postPath } from '../utils/permalink';
import { boardPath, postPath } from '../utils/permalink';
import { toPostImageThumbSrc } from '../utils/postContent';
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
import { parseTags } from './TagInput';
interface Props {
post: PostItem;
sort?: FeedSort;
/** 当前板块 id>0 时隐藏行内板块色标(避免板块页重复) */
boardId?: number;
onSelect: (id: number) => void;
}
function PostListItem({ post, sort = 'latest', onSelect }: Props) {
function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
const nav = useNavigate();
const { limits } = useForumLimits();
const feedStyle = limits.feed_list_style ?? 'title';
const showExcerpt = feedStyle === 'excerpt' || feedStyle === 'thumbnail';
const showThumb = feedStyle === 'thumbnail';
const titleOnly = feedStyle === 'title';
const initial = post.user?.nickname?.[0] || '?';
const timeLabel = sort === 'reply'
? (post.last_reply_at
? `${formatTime(post.last_reply_at)} 回复`
: '暂无回复')
const timeLabel = sort === 'reply' && !post.last_reply_at
? '暂无回复'
: formatTime(post.created_at);
const lastReplyName = post.last_reply_user?.nickname?.trim()
|| post.last_reply_user?.username?.trim()
|| post.last_reply_guest_nick?.trim()
|| '';
const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName);
const commentCount = post.comment_count ?? 0;
const likeCount = post.like_count ?? 0;
const viewCount = post.view_count ?? 0;
const href = postPath(post.id);
const firstImage = firstImageFromHTML(post.content || '');
const thumbSrc = showThumb && firstImage ? toPostImageThumbSrc(firstImage) : null;
const excerpt = showExcerpt ? excerptFromHTML(post.content || '', 60) : '';
const showImageIcon = !!firstImage && !thumbSrc;
const tagList = parseTags(post.tags || '').slice(0, 3);
const showBoardBadge = !!post.board && boardId !== post.board.id;
const openPost = () => onSelect(post.id);
const onKeyDown = (e: React.KeyboardEvent) => {
@@ -108,57 +111,53 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
const metaLeft = (
<div className="post-meta-left">
<UserLink user={post.user} stopPropagation className="post-meta-author" showBadges={false} />
<span className="post-meta-sep" aria-hidden>·</span>
<span className="post-meta-time">{timeLabel}</span>
{post.board && (
<>
<span className="post-meta-sep" aria-hidden>·</span>
<span className="post-meta-board">{post.board.name}</span>
</>
)}
{tagList.map(t => (
{showBoardBadge && post.board && (
<button
key={t}
type="button"
className="post-list-tag"
title={`筛选标签${t}`}
className="post-list-board-btn"
title={`进入板块${post.board.name}`}
onClick={(e) => {
e.stopPropagation();
nav(`/?tag=${encodeURIComponent(t)}`);
nav(boardPath(post.board!.id, limits));
}}
>
#{t}
<BoardBadge board={post.board} className="post-list-board-badge" />
</button>
))}
)}
<UserLink user={post.user} stopPropagation className="post-meta-author" showBadges={false} />
<span className="post-meta-sep post-meta-sep--before-time" aria-hidden>·</span>
<span className="post-meta-time post-meta-time--created">{timeLabel}</span>
{showLastReply && (
<span className="post-meta-last-reply">
<span className="post-meta-last-reply-arrow" aria-hidden></span>
{post.last_reply_user ? (
<UserLink
user={post.last_reply_user}
stopPropagation
className="post-meta-last-reply-user"
showBadges={false}
/>
) : (
<span className="post-meta-last-reply-user">{lastReplyName}</span>
)}
<span className="post-meta-last-reply-time">{formatTime(post.last_reply_at!)}</span>
</span>
)}
</div>
);
const stats = (
<div className="post-stats">
{showImageIcon && (
<span className="post-stat post-stat--media" title="含图片">
<ImageIcon aria-hidden />
</span>
)}
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`} title="评论">
<MessageCircle aria-hidden />
{commentCount}
</span>
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`} title="点赞">
<ThumbsUp aria-hidden />
{likeCount}
</span>
<span className={`post-stat${viewCount === 0 ? ' post-stat--zero' : ''}`} title="浏览">
<Eye aria-hidden />
{viewCount}
</span>
</div>
);
return (
<div
className={`post-row post-row--v2${thumbSrc ? ' post-row--has-thumb' : ''}`}
className={`post-row post-row--v2${titleOnly ? ' post-row--title-only' : ''}${thumbSrc ? ' post-row--has-thumb' : ''}`}
role="link"
tabIndex={0}
onClick={openPost}

View File

@@ -8,7 +8,7 @@ export function feedListRowEstimate(style: FeedListStyle): number {
switch (style) {
case 'excerpt': return 68;
case 'thumbnail': return 72;
default: return 52;
default: return 64;
}
}
@@ -21,13 +21,17 @@ interface Props {
export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Props) {
const showExcerpt = listStyle === 'excerpt' || listStyle === 'thumbnail';
const showThumb = listStyle === 'thumbnail';
const titleOnly = listStyle === 'title';
return (
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
{Array.from({ length: count }, (_, i) => {
const hasThumb = showThumb && i % 3 === 0;
return (
<div key={i} className={`post-row post-row--v2 post-row--skeleton${hasThumb ? ' post-row--has-thumb' : ''}`}>
<div
key={i}
className={`post-row post-row--v2 post-row--skeleton${titleOnly ? ' post-row--title-only' : ''}${hasThumb ? ' post-row--has-thumb' : ''}`}
>
<Skeleton className="skeleton--avatar skeleton--avatar-v2" />
{hasThumb ? (
<div className="post-main post-main--with-thumb">
@@ -36,14 +40,15 @@ export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Pro
{showExcerpt && (
<Skeleton className="skeleton--excerpt skeleton--excerpt-v2" style={{ width: `${72 + (i % 3) * 8}%` }} />
)}
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
<div className="post-meta-left post-meta-left--skeleton">
<Skeleton className="skeleton--board-badge" />
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
</div>
</div>
<div className="post-aside">
<Skeleton className="skeleton--thumb skeleton--thumb-tall" />
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>
@@ -56,11 +61,12 @@ export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Pro
)}
</div>
<div className="post-meta">
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
<div className="post-meta-left post-meta-left--skeleton">
<Skeleton className="skeleton--board-badge" />
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
</div>
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>

View File

@@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { ListTree, MessageCircle, Tags, Link2 } from 'lucide-react';
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
import { useLocation, useSearchParams, useNavigate } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import type { AsideWidget, RecentComment, TagCount, User, ForumStats, FriendLink } from '../api/types';
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding';
import { formatShortDateTime, formatTime } from '../utils/content';
@@ -25,6 +25,7 @@ export type PostDetailAside = {
interface Props {
recentComments: RecentComment[];
recentUsers: RecentUser[];
tags?: TagCount[];
tagsLoading?: boolean;
stats?: ForumStats | null;
@@ -53,8 +54,22 @@ function CommentSkeleton() {
);
}
function UserSkeleton() {
return (
<div className="widget-recent-users-grid" aria-busy="true" aria-label="用户加载中">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="widget-recent-user-cell widget-recent-user-cell--skeleton">
<Skeleton className="skeleton--recent-user-avatar" />
<Skeleton className="skeleton--recent-user-name" style={{ width: `${48 + (i % 3) * 10}%` }} />
</div>
))}
</div>
);
}
export default function RightPanel({
recentComments,
recentUsers,
tags = [],
tagsLoading = false,
stats = null,
@@ -69,6 +84,7 @@ export default function RightPanel({
const [params] = useSearchParams();
const activeTag = params.get('tag') || '';
const commentList = recentComments?.slice(0, 6) ?? [];
const userList = recentUsers?.slice(0, 8) ?? [];
const friendLinks = (branding.friend_links ?? []).filter(
(l: FriendLink) => l.name?.trim() && l.url?.trim(),
);
@@ -118,7 +134,7 @@ export default function RightPanel({
</Button>
</div>
<div className="widget-card-body">
<div className="widget-card-body widget-card-body--friend-links">
{friendLinks.length === 0 ? (
<div className="widget-empty"></div>
) : (
@@ -126,7 +142,9 @@ export default function RightPanel({
<ul className="widget-friend-links-list">
{friendLinks.slice(0, 8).map((link: FriendLink) => (
<li key={`${link.name}-${link.url}`}>
<a href={link.url} target="_blank" rel="noopener noreferrer">{link.name}</a>
<a href={link.url} target="_blank" rel="noopener noreferrer" title={link.name}>
{link.name}
</a>
</li>
))}
</ul>
@@ -212,6 +230,41 @@ export default function RightPanel({
</div>
</div>
);
case 'recent_users':
return (
<div key="recent_users" className="widget-card widget-card--users">
<div className="widget-card-head">
<UserPlus className="widget-card-icon widget-card-icon--users" aria-hidden />
</div>
<div className="widget-card-body widget-card-body--users">
{loading && userList.length === 0 ? (
<UserSkeleton />
) : userList.length === 0 ? (
<div className="widget-empty"></div>
) : (
<div className="widget-recent-users-grid">
{userList.map(item => (
<UserLink
key={item.id}
user={{ id: item.id, nickname: item.nickname, avatar: item.avatar }}
className="widget-recent-user-cell"
showBadges={false}
title={item.nickname}
>
<span className="widget-recent-user-avatar" aria-hidden>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.nickname?.[0] || '?')}
</span>
<span className="widget-recent-user-name">{item.nickname}</span>
</UserLink>
))}
</div>
)}
</div>
</div>
);
default:
return null;
}

View File

@@ -263,7 +263,7 @@ export default function VirtualPostList({
transform: `translateY(${offsetY}px)`,
}}
>
<PostListItem post={post} sort={sort} onSelect={onSelect} />
<PostListItem post={post} sort={sort} boardId={boardId} onSelect={onSelect} />
</div>
);
})}

View File

@@ -10,6 +10,10 @@ const WIDGET_META: Record<AsideWidgetId, { label: string; hint: string }> = {
label: '最新评论',
hint: '在右侧栏展示最近回复',
},
recent_users: {
label: '最新注册',
hint: '在右侧栏展示最近注册的用户',
},
friend_links: {
label: '友情链接',
hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请',