feat: 首页 Go SSR 与 React hydrate 同构,消壳层与帖行闪动

补齐侧栏/右栏图标与徽章、鉴权种子、StaticFeedList,并修正嵌套 a 与标题徽章对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 07:07:38 +08:00
parent 9e134916a4
commit 44eed97fe0
26 changed files with 2530 additions and 159 deletions

View File

@@ -10,7 +10,8 @@ export default function AsideCheckInStrip() {
const { user, loading: authLoading } = useAuth();
const { status, loading, busy, doCheckIn } = useCheckIn(!!user && !authLoading);
// 鉴权未完成:空白,避免「登录签到」→「今日已签到闪一下
// 鉴权未完成且无种子:空白,避免访客签到闪一下再消失
// 有 SSR boot 时 loading 一开始就是 false
if (authLoading) {
return null;
}

View File

@@ -79,7 +79,12 @@ export function feedSortLabel(sort: FeedSort, tabs?: FeedSortTab[] | null): stri
return list.find(t => t.id === sort)?.label ?? '帖子列表';
}
export default function FeedSortBar({ value, onChange, postTotal, pendingValue }: Props) {
export default function FeedSortBar({
value,
onChange,
postTotal,
pendingValue,
}: Props) {
const { limits } = useForumLimits();
const options = useMemo(
() => enabledFeedSortTabs(limits.feed_sort_tabs).map(t => ({

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
import { useLocation, useSearchParams, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding';
@@ -79,10 +78,6 @@ export default function RightPanel({
[asideWidgets],
);
const handleApplyClick = () => {
nav('/links?apply=1');
};
const renderWidget = (widget: AsideWidget) => {
switch (widget.id) {
case 'showcase':
@@ -97,15 +92,9 @@ export default function RightPanel({
</button>
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="widget-friend-links-apply"
onClick={handleApplyClick}
>
<a href="/links?apply=1" className="widget-friend-links-apply">
</Button>
</a>
</div>
<div className="widget-card-body widget-card-body--friend-links">
{friendLinks.length === 0 ? (

View File

@@ -9,7 +9,7 @@ function FooterSep() {
return <span className="site-footer__sep" aria-hidden>·</span>;
}
/** 站点页脚:版权、友链/展柜入口、单页、备案号 */
/** 站点页脚:版权、友链/展柜入口、单页、备案号(结构与 Go writeSSRFooter 对齐) */
export default function SiteFooter() {
const { branding } = useSiteBranding();
const { footerPages } = useSitePages();
@@ -19,8 +19,42 @@ export default function SiteFooter() {
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
const showFriendLinks = limits.footer_show_friend_links !== false;
const showShowcase = !!limits.footer_show_showcase;
const hasNavBeforePages = showFriendLinks || showShowcase;
const hasNavBeforeIcp = hasNavBeforePages || footerPages.length > 0;
const navItems: React.ReactNode[] = [];
if (showFriendLinks) {
navItems.push(
<span key="links" className="site-footer__friend">
<Link to="/links"></Link>
</span>,
);
}
if (showShowcase) {
navItems.push(
<span key="showcase" className="site-footer__friend">
<Link to="/showcase"></Link>
</span>,
);
}
for (const p of footerPages) {
navItems.push(
<span key={p.slug} className="site-footer__friend">
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
</span>,
);
}
if (icp) {
navItems.push(
<a
key="icp"
href={icpURL}
target="_blank"
rel="noopener noreferrer"
className="site-footer__icp"
>
{icp}
</a>,
);
}
return (
<footer className="site-footer">
@@ -38,35 +72,8 @@ export default function SiteFooter() {
</div>
<nav className="site-footer__nav" aria-label="站点链接">
{showFriendLinks && (
<span className="site-footer__friend">
<Link to="/links"></Link>
</span>
)}
{showShowcase && (
<span className="site-footer__friend">
{showFriendLinks && <FooterSep />}
<Link to="/showcase"></Link>
</span>
)}
{footerPages.map((p, i) => (
<span key={p.slug} className="site-footer__friend">
{(hasNavBeforePages || i > 0) && <FooterSep />}
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
</span>
))}
{icp && (
<>
{hasNavBeforeIcp && <FooterSep />}
<a
href={icpURL}
target="_blank"
rel="noopener noreferrer"
className="site-footer__icp"
>
{icp}
</a>
</>
{navItems.flatMap((node, i) =>
i === 0 ? [node] : [<FooterSep key={`sep-${i}`} />, node],
)}
</nav>
</div>

View File

@@ -0,0 +1,227 @@
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { getBoardThemeIndex } from '../utils/boardTheme';
import { postPath } from '../utils/permalink';
import { useForumLimits } from '../hooks/useForumLimits';
import { formatTime } from '../utils/content';
import { MessageCircle } from 'lucide-react';
/** 与 Go formatSSRRelativeTime / 前端 formatTime 对齐的列表时间 */
function stripHtmlPlain(html: string): string {
if (!html) return '';
const t = html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
return t;
}
function truncateRunes(s: string, max: number): string {
const chars = Array.from(s);
if (chars.length <= max) return s;
return chars.slice(0, max).join('');
}
function firstImageSrc(html: string): string {
const m = html.match(/<img[^>]+src=["']([^"']+)["']/i);
const src = m?.[1]?.trim() || '';
if (!src || src.startsWith('data:')) return '';
return src;
}
function displayName(post: PostItem): string {
const n = post.user?.nickname?.trim() || post.user?.username?.trim();
return n || '用户';
}
type Props = {
posts: PostItem[];
sort: FeedSort;
boardId: number;
};
/** 与 Go writeSSRPostRow 同构的静态列表hydrate 首帧专用) */
export default function StaticFeedList({ posts, sort, boardId }: Props) {
const { limits } = useForumLimits();
const style = limits.feed_list_style ?? 'title';
const titleOnly = style === 'title';
const needExcerpt = style === 'excerpt' || style === 'thumbnail';
const needThumb = style === 'thumbnail';
return (
<div className="post-list-scroll post-list-scroll--ssr">
<div className="content-surface content-surface--ssr">
{posts.length === 0 ? (
<div className="feed-empty"></div>
) : (
posts.map((post) => {
const href = postPath(post.id, limits);
const author = displayName(post);
const initial = Array.from(author)[0] || '?';
let excerpt = '';
let thumb = '';
if (needExcerpt || needThumb) {
excerpt = truncateRunes(stripHtmlPlain(post.content || ''), 120);
}
if (needThumb) thumb = firstImageSrc(post.content || '');
const rowClass = [
'post-row',
'post-row--v2',
titleOnly ? 'post-row--title-only' : '',
thumb ? 'post-row--has-thumb' : '',
].filter(Boolean).join(' ');
let timeLabel = formatTime(post.created_at);
if (sort === 'reply' && !post.last_reply_at) {
timeLabel = '暂无回复';
}
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 hasTypeBadge = post.post_type === 'question'
|| post.post_type === 'poll'
|| post.post_type === 'lottery'
|| (post.post_type === 'bounty' && (
(post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0)
|| post.bounty_status === 'awarded'
));
const titleRow = (
<div className="post-title-row">
{post.pinned ? (
<span className="post-pin-badge" title="全局置顶"></span>
) : null}
{post.board_pinned ? (
<span className="post-pin-badge post-pin-badge--board" title="板块置顶"></span>
) : null}
{post.featured ? <span className="post-feature-badge" title="推荐"></span> : null}
{post.status === 'pending' ? (
<span className="post-status-badge post-status-badge--pending" title="审核中"></span>
) : null}
{post.status === 'rejected' ? (
<span className="post-status-badge post-status-badge--rejected" title="未通过"></span>
) : null}
<span className="post-title">{post.title}</span>
{hasTypeBadge ? (
<span className="post-title-type-badges">
{post.post_type === 'question' ? (
<span
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
title={post.question_resolved ? '已解决' : '未解决'}
>
{post.question_resolved ? '已解决' : '未解决'}
</span>
) : null}
{post.post_type === 'poll' ? (
<span className="post-type-badge post-type-badge--poll" title="投票"></span>
) : null}
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 ? (
<span className="post-bounty-badge post-bounty-badge--open" title="悬赏">
{post.bounty_points}
</span>
) : null}
{post.post_type === 'bounty' && post.bounty_status === 'awarded' ? (
<span className="post-bounty-badge post-bounty-badge--awarded" title="已采纳"></span>
) : null}
{post.post_type === 'lottery' ? (
<span className="post-type-badge post-type-badge--lottery" title="抽奖">
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
) : null}
</span>
) : null}
</div>
);
const metaLeft = (
<div className="post-meta-left">
{boardId === 0 && post.board?.id ? (
<span className="post-list-board-btn">
<span
className={`post-list-board-badge board-badge board-badge--${getBoardThemeIndex(post.board)}`}
>
{post.board.name}
</span>
</span>
) : null}
<span className="post-meta-author">{author}</span>
<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>
{/* 外层是 <a class="post-row">,禁止嵌套 a */}
<span className="post-meta-last-reply-user">{lastReplyName}</span>
<span className="post-meta-last-reply-time">{formatTime(post.last_reply_at!)}</span>
</span>
) : null}
</div>
);
const stats = (
<div className="post-stats">
<span
className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}
title="评论"
>
<MessageCircle aria-hidden />
{commentCount}
</span>
</div>
);
return (
<a key={post.id} className={rowClass} href={href}>
{post.user?.avatar ? (
<span className="post-avatar user-link--avatar-only">
<img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
</span>
) : (
<span className="post-avatar user-link--avatar-only">{initial}</span>
)}
{thumb ? (
<div className="post-main post-main--with-thumb">
<div className="post-content">
{titleRow}
{excerpt ? <p className="post-excerpt">{excerpt}</p> : null}
<div className="post-meta post-meta--inline">
{metaLeft}
{stats}
</div>
</div>
<div className="post-aside">
<div className="post-thumb" aria-hidden>
<img src={thumb} alt="" loading="lazy" decoding="async" />
</div>
</div>
</div>
) : (
<div className="post-main">
<div className="post-text">
{titleRow}
{excerpt ? <p className="post-excerpt">{excerpt}</p> : null}
</div>
<div className="post-meta">
{metaLeft}
{stats}
</div>
</div>
)}
</a>
);
})
)}
</div>
</div>
);
}