From 7cc574f9a1c7f7cba6d1b8265f32ef618a5c06ac Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 27 Aug 2026 18:50:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E5=B8=96=E5=AD=90=E5=88=97=E8=A1=A8=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8F=B3=E4=BE=A7=E6=A0=8F=E6=9C=80=E6=96=B0=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首页列表精简 meta 与统计展示,板块色标前移并淡化;有回复时显示最后回复人。右侧栏新增最新注册(4 列头像网格),友链改为标签块并排换行。 Co-authored-by: Cursor --- frontend/src/api/client.ts | 3 +- frontend/src/api/types.ts | 15 +- frontend/src/components/PostListItem.tsx | 83 +++-- frontend/src/components/PostListSkeleton.tsx | 22 +- frontend/src/components/RightPanel.tsx | 61 +++- frontend/src/components/VirtualPostList.tsx | 2 +- .../src/components/admin/AsideWidgetList.tsx | 4 + frontend/src/layouts/MainLayout.tsx | 33 +- frontend/src/styles/global.css | 300 +++++++++++++++++- frontend/src/utils/asideWidgets.ts | 2 +- frontend/src/utils/layoutCache.ts | 17 +- handler/api.go | 13 + router/router.go | 3 +- service/aside_widgets_test.go | 9 +- service/post.go | 98 +++--- service/settings.go | 4 +- service/user.go | 38 +++ 17 files changed, 594 insertions(+), 113 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5fd0167..cd44bc1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply } from './types'; +import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply } from './types'; const BASE = ''; @@ -52,6 +52,7 @@ export const api = { return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`); }, recentComments: () => request<{ comments: RecentComment[] }>('/api/comments/recent'), + recentUsers: () => request<{ users: RecentUser[] }>('/api/users/recent'), favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'), createBoard: (body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) => request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b23dba0..c8bf626 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -106,6 +106,10 @@ export interface PostItem { view_count: number; comment_count: number; last_reply_at?: string; + /** 最后回复用户(登录用户);与 last_reply_guest_nick 二选一 */ + last_reply_user?: User; + /** 最后回复游客昵称 */ + last_reply_guest_nick?: string; created_at: string; updated_at?: string; board?: Board; @@ -187,7 +191,7 @@ export interface AdminDashboard { recent_posts: PostItem[]; } -export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'friend_links'; +export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'recent_users' | 'friend_links'; export interface AsideWidget { id: AsideWidgetId; @@ -197,6 +201,7 @@ export interface AsideWidget { export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [ { id: 'tag_cloud', enabled: false }, { id: 'recent_comments', enabled: false }, + { id: 'recent_users', enabled: false }, { id: 'friend_links', enabled: true }, ]; @@ -500,6 +505,14 @@ export interface RecentComment { created_at: string; } +/** 右栏「最新注册」用户 */ +export interface RecentUser { + id: number; + nickname: string; + avatar: string; + created_at: string; +} + /** 站内私信 */ export interface PrivateMessage { id: number; diff --git a/frontend/src/components/PostListItem.tsx b/frontend/src/components/PostListItem.tsx index af54ed3..d75a087 100644 --- a/frontend/src/components/PostListItem.tsx +++ b/frontend/src/components/PostListItem.tsx @@ -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 = (
- - · - {timeLabel} - {post.board && ( - <> - · - {post.board.name} - - )} - {tagList.map(t => ( + {showBoardBadge && post.board && ( - ))} + )} + + · + {timeLabel} + {showLastReply && ( + + + {post.last_reply_user ? ( + + ) : ( + {lastReplyName} + )} + {formatTime(post.last_reply_at!)} + + )}
); const stats = (
- {showImageIcon && ( - - - - )} {commentCount} - - - {likeCount} - - - - {viewCount} -
); return (
{Array.from({ length: count }, (_, i) => { const hasThumb = showThumb && i % 3 === 0; return ( -
+
{hasThumb ? (
@@ -36,14 +40,15 @@ export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Pro {showExcerpt && ( )} - +
+ + +
- -
@@ -56,11 +61,12 @@ export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Pro )}
- +
+ + +
- -
diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index db1a6ef..e178d18 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -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 ( +
+ {Array.from({ length: 8 }, (_, i) => ( +
+ + +
+ ))} +
+ ); +} + 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({ 申请 -
+
{friendLinks.length === 0 ? (
暂无友情链接
) : ( @@ -126,7 +142,9 @@ export default function RightPanel({ @@ -212,6 +230,41 @@ export default function RightPanel({
); + case 'recent_users': + return ( +
+
+ + 最新注册 +
+
+ {loading && userList.length === 0 ? ( + + ) : userList.length === 0 ? ( +
暂无用户
+ ) : ( +
+ {userList.map(item => ( + + + {item.avatar + ? + : (item.nickname?.[0] || '?')} + + {item.nickname} + + ))} +
+ )} +
+
+ ); default: return null; } diff --git a/frontend/src/components/VirtualPostList.tsx b/frontend/src/components/VirtualPostList.tsx index 5d88436..9dc20ce 100644 --- a/frontend/src/components/VirtualPostList.tsx +++ b/frontend/src/components/VirtualPostList.tsx @@ -263,7 +263,7 @@ export default function VirtualPostList({ transform: `translateY(${offsetY}px)`, }} > - + ); })} diff --git a/frontend/src/components/admin/AsideWidgetList.tsx b/frontend/src/components/admin/AsideWidgetList.tsx index c03f18f..d532258 100644 --- a/frontend/src/components/admin/AsideWidgetList.tsx +++ b/frontend/src/components/admin/AsideWidgetList.tsx @@ -10,6 +10,10 @@ const WIDGET_META: Record = { label: '最新评论', hint: '在右侧栏展示最近回复', }, + recent_users: { + label: '最新注册', + hint: '在右侧栏展示最近注册的用户', + }, friend_links: { label: '友情链接', hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请', diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index a9de33a..3d2765c 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -14,9 +14,9 @@ import { useAuth } from '../hooks/useAuth'; import { useTheme, useMediaQuery } from '../hooks/useTheme'; import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y'; import { api } from '../api/client'; -import type { Board, RecentComment, ForumStats, TagCount, User } from '../api/types'; +import type { Board, RecentComment, RecentUser, ForumStats, TagCount, User } from '../api/types'; import type { PostHeading } from '../utils/postHeadings'; -import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedTags } from '../utils/layoutCache'; +import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedRecentUsers, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedRecentUsers, setCachedTags } from '../utils/layoutCache'; import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar'; import RightPanel from '../components/RightPanel'; import BackToTop from '../components/BackToTop'; @@ -49,6 +49,7 @@ export default function MainLayout() { const [boards, setBoards] = useState(() => getCachedBoards()); const [stats, setStats] = useState(() => getCachedStats()); const [recentComments, setRecentComments] = useState(() => getCachedRecentComments()); + const [recentUsers, setRecentUsers] = useState(() => getCachedRecentUsers()); const [unreadMessages, setUnreadMessages] = useState(0); const [tags, setTags] = useState(() => getCachedTags()); const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0); @@ -82,6 +83,7 @@ export default function MainLayout() { const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]); const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled); const showRecentComments = asideWidgets.some(w => w.id === 'recent_comments' && w.enabled); + const showRecentUsers = asideWidgets.some(w => w.id === 'recent_users' && w.enabled); const asideDrawerRef = useRef(null); const asideCloseRef = useRef(null); @@ -241,6 +243,31 @@ export default function MainLayout() { }; }, [needRecentComments]); + const needRecentUsers = needAsideData && showRecentUsers; + useEffect(() => { + if (!needRecentUsers) return; + let cancelled = false; + if (!asideEverLoaded.current && !hasCachedAside()) { + setAsideLoading(true); + } + + api.recentUsers().then(d => { + if (cancelled) return; + const next = Array.isArray(d.users) ? d.users : []; + setRecentUsers(next); + setCachedRecentUsers(next); + }).catch(() => {}).finally(() => { + if (!cancelled) { + asideEverLoaded.current = true; + setAsideLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [needRecentUsers]); + const doSearch = () => { const kw = keyword.trim(); const author = searchAuthor.trim(); @@ -642,6 +669,7 @@ export default function MainLayout() {