diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx
index e7598e4..a8f7825 100644
--- a/frontend/src/hooks/useAuth.tsx
+++ b/frontend/src/hooks/useAuth.tsx
@@ -36,9 +36,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, []);
- // 有 SSR 种子时仍后台校验;无种子则首屏拉取
+ // SSR +data / 冷启动预热已注入登录态种子(含已确认访客 null)时首屏不重复请求;
+ // 无种子(纯 SPA 且未预热)才首屏拉取。登录/退出均显式更新状态,不依赖 mount 校验。
// 不清空 auth 种子:StrictMode 重挂会再次跑 useState initializer,破坏性 clear 会导致 user 空窗闪动
- useEffect(() => { void refresh(); }, [refresh]);
+ useEffect(() => {
+ if (peekAuthSeed().hasSeed) return;
+ void refresh();
+ }, [refresh]);
const prevUserId = useRef
('init');
useEffect(() => {
diff --git a/frontend/src/hooks/useSiteBranding.ts b/frontend/src/hooks/useSiteBranding.ts
index 1f57414..c3110d6 100644
--- a/frontend/src/hooks/useSiteBranding.ts
+++ b/frontend/src/hooks/useSiteBranding.ts
@@ -61,8 +61,10 @@ function fetchBranding(): Promise {
preferCacheOnce = false;
return Promise.resolve(cached);
}
+ // SSR +data / 冷启动预热已 seed 品牌配置时首屏直接复用,不重复请求;
+ // 最新配置同步由显式刷新(refetchSiteBranding / ensureSiteBrandingLoaded force)触发
+ if (cached) return Promise.resolve(cached);
if (inflight) return inflight;
- // 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置
inflight = api.siteBranding()
.then(b => {
cached = { ...DEFAULT_BRANDING, ...b };
@@ -137,6 +139,7 @@ export function useSiteBranding() {
/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
export function refetchSiteBranding() {
+ cached = null;
inflight = null;
cacheEpoch += 1;
listeners.forEach(fn => fn());
@@ -146,6 +149,7 @@ export function refetchSiteBranding() {
export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise {
if (!opts?.force && cached) return cached;
if (opts?.force) {
+ cached = null;
inflight = null;
preferCacheOnce = false;
}
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
index c1baa59..e4ca21e 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -12,13 +12,16 @@ 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, RecentUser, ForumStats, TagCount, User } from '../api/types';
-import type { PostHeading } from '../utils/postHeadings';
+import type { Board, RecentComment, RecentUser, ForumStats, TagCount, User, UserActivityStats, UserPublic } from '../api/types';
+import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
+import { renderPostContentHtml } from '../utils/postContent';
+import { getSessionSnapshot } from '../utils/sessionPageCache';
+import { postDetailCacheKey, type PostDetailSnapshot } from '../utils/homeBoot';
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';
-import { useForumLimits } from '../hooks/useForumLimits';
+import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
import { resolveAsideWidgets } from '../utils/asideWidgets';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
@@ -44,7 +47,7 @@ import { userPath } from '../utils/userPath';
import { parsePermalinkID } from '../utils/permalink';
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate';
-import { getBootUnread } from '../utils/authBoot';
+import { getBootUnread, peekAuthSeed } from '../utils/authBoot';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
@@ -73,14 +76,33 @@ export default function MainLayout() {
const [notifyUnread, setNotifyUnread] = useState(0);
const [tags, setTags] = useState(() => getCachedTags());
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
- const [postOutline, setPostOutline] = useState<{
- headings: PostHeading[];
- scrollRoot: HTMLElement | null;
- title?: string;
- author?: User | null;
- publishedAt?: string;
- viewCount?: number;
- } | null>(null);
+ // SSR / hydrate 首帧:详情页快照已在渲染前 seed(onRenderHtml / onRenderClient),
+ // 直接从中派生右栏作者与目录——子组件的 setPostOutline 在 effect 中,SSR 不执行,
+ // 不走这一步作者卡/目录会整体退化为客户端挂载。首帧后的 SPA 导航仍由 effect 上报。
+ const [postOutline, setPostOutline] = useState(() => {
+ const m = loc.pathname.match(/^\/post\/(\d+)/);
+ if (!m || /\/edit$/.test(loc.pathname)) return null;
+ const id = Number(m[1]);
+ if (!id) return null;
+ const snap = getSessionSnapshot(postDetailCacheKey(id));
+ if (!snap?.post) return null;
+ const content = snap.post.content ?? '';
+ const rendered = content.trim()
+ ? renderPostContentHtml(content, !!user, {
+ openLinksInNewTab: getCachedForumLimits()?.open_content_links_in_new_tab,
+ })
+ : '';
+ return {
+ headings: extractHeadingsFromHtml(rendered),
+ scrollRoot: null,
+ title: '文章目录',
+ author: snap.post.user ?? null,
+ publishedAt: snap.post.created_at,
+ viewCount: snap.post.view_count,
+ authorProfile: snap.authorProfile ?? null,
+ authorStats: snap.authorStats ?? null,
+ };
+ });
const [asideOpen, setAsideOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
@@ -335,8 +357,15 @@ export default function MainLayout() {
void transitionTo(nav, path);
}, [nav, notifyUnread, dmUnread]);
+ // 仅首屏跳过一次:SSR/冷启动种子已带未读数(state 从 getBootUnread 初始化),不重复请求;
+ // 之后 user 变化(登录/退出)、事件刷新与 60s 轮询照常
+ const unreadImmediateSkipped = useRef(false);
useEffect(() => {
- refreshUnreadMessages();
+ if (!unreadImmediateSkipped.current && peekAuthSeed().hasSeed) {
+ unreadImmediateSkipped.current = true;
+ } else {
+ refreshUnreadMessages();
+ }
const onRefresh = () => refreshUnreadMessages();
window.addEventListener('messages-unread-refresh', onRefresh);
const timer = window.setInterval(refreshUnreadMessages, 60_000);
@@ -387,6 +416,13 @@ export default function MainLayout() {
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
useEffect(() => {
if (isCompose || !showTagCloud) return;
+ // SSR +data / 冷启动预热已 seed 侧栏数据(含空数组)时首屏直接复用,不重复请求;
+ // 下拉强制刷新(layoutRefreshTick 递增)时仍重拉最新
+ if (layoutRefreshTick === 0 && hasCachedAside()) {
+ setTags(getCachedTags());
+ setTagsLoading(false);
+ return;
+ }
let cancelled = false;
// 有 session 缓存则静默刷新(软刷新不卸空白)
if (getCachedTags().length === 0) setTagsLoading(true);
@@ -407,6 +443,14 @@ export default function MainLayout() {
const needRecentComments = needAsideData && showRecentComments;
useEffect(() => {
if (!needRecentComments) return;
+ // SSR +data / 冷启动预热已 seed:首屏同步缓存进 state,不重复请求;
+ // 下拉强制刷新(layoutRefreshTick 递增)时仍重拉最新
+ if (layoutRefreshTick === 0 && hasCachedAside()) {
+ setRecentComments(getCachedRecentComments());
+ asideEverLoaded.current = true;
+ setAsideLoading(false);
+ return;
+ }
let cancelled = false;
// 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动
if (!asideEverLoaded.current && !hasCachedAside()) {
@@ -433,6 +477,12 @@ export default function MainLayout() {
const needRecentUsers = needAsideData && showRecentUsers;
useEffect(() => {
if (!needRecentUsers) return;
+ if (layoutRefreshTick === 0 && hasCachedAside()) {
+ setRecentUsers(getCachedRecentUsers());
+ asideEverLoaded.current = true;
+ setAsideLoading(false);
+ return;
+ }
let cancelled = false;
if (!asideEverLoaded.current && !hasCachedAside()) {
setAsideLoading(true);
@@ -503,14 +553,7 @@ export default function MainLayout() {
const isArticleAside = isPostDetail || isSitePage;
// 帖子有作者 →「作者与目录」;单页仅目录
const articleAsideLabel = isPostDetail ? '作者与目录' : '文章目录';
- const setPostOutlineSafe = useCallback((outline: {
- headings: PostHeading[];
- scrollRoot: HTMLElement | null;
- title?: string;
- author?: User | null;
- publishedAt?: string;
- viewCount?: number;
- } | null) => {
+ const setPostOutlineSafe = useCallback((outline: PostOutlineState) => {
setPostOutline(outline);
}, []);
const layoutCtx = useMemo(() => ({
@@ -848,6 +891,8 @@ export default function MainLayout() {
headings: postOutline?.headings ?? [],
scrollRoot: postOutline?.scrollRoot ?? null,
outlineTitle: postOutline?.title,
+ authorProfile: postOutline?.authorProfile ?? null,
+ authorStats: postOutline?.authorStats ?? null,
} : null}
/>
)}
@@ -967,6 +1012,8 @@ export default function MainLayout() {
headings: postOutline?.headings ?? [],
scrollRoot: postOutline?.scrollRoot ?? null,
outlineTitle: postOutline?.title,
+ authorProfile: postOutline?.authorProfile ?? null,
+ authorStats: postOutline?.authorStats ?? null,
} : null}
/>
@@ -979,6 +1026,19 @@ export default function MainLayout() {
);
}
+/** 详情页/单页上报到右栏的文章目录与作者信息 */
+export type PostOutlineState = {
+ headings: PostHeading[];
+ scrollRoot: HTMLElement | null;
+ title?: string;
+ author?: User | null;
+ publishedAt?: string;
+ viewCount?: number;
+ /** SSR/预热快照携带的作者主页资料与统计(作者卡首帧直接渲染,缺省由卡片自行拉取) */
+ authorProfile?: UserPublic | null;
+ authorStats?: UserActivityStats | null;
+} | null;
+
export type LayoutCtx = {
boardId: number;
keyword: string;
@@ -989,12 +1049,5 @@ export type LayoutCtx = {
refreshBoards: () => void;
isMobile: boolean;
/** 详情页上报作者与目录,供右侧栏展示 */
- setPostOutline: (outline: {
- headings: PostHeading[];
- scrollRoot: HTMLElement | null;
- title?: string;
- author?: User | null;
- publishedAt?: string;
- viewCount?: number;
- } | null) => void;
+ setPostOutline: (outline: PostOutlineState) => void;
};
diff --git a/frontend/src/pages/PostDetailPage.tsx b/frontend/src/pages/PostDetailPage.tsx
index d7e15a5..18147ff 100644
--- a/frontend/src/pages/PostDetailPage.tsx
+++ b/frontend/src/pages/PostDetailPage.tsx
@@ -33,7 +33,7 @@ import {
} from '@/components/ui/dialog';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
-import type { PostItem, Comment, ReportReason, PollView, PostLotteryView } from '../api/types';
+import type { PostItem, Comment, PollView, PostLotteryView, ReportReason } from '../api/types';
import { REPORT_REASON_OPTIONS } from '../utils/report';
import CommentThreadList from '../components/CommentThreadList';
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
@@ -48,7 +48,7 @@ import ArticleOutline from '../components/ArticleOutline';
import { useAuth } from '../hooks/useAuth';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
-import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
+import { formatDateTime } from '../utils/content';
import { collectCommentSubtreeIds } from '../utils/comment';
import { loadMyCommentIds } from '../utils/guest';
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
@@ -56,6 +56,7 @@ import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
import { loginPath } from '../utils/authRedirect';
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
+import { buildPostDetailSnapshot, postDetailCacheKey, type PostDetailSnapshot } from '../utils/homeBoot';
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
import { useForumLimits } from '../hooks/useForumLimits';
import type { LayoutCtx } from '../layouts/MainLayout';
@@ -64,26 +65,6 @@ import { renderPostContentHtml } from '../utils/postContent';
import { InFlowSiteFooter } from '../components/SiteFooter';
import NotFoundPage from './NotFoundPage';
-type PostDetailSnapshot = {
- post: PostItem;
- comments: Comment[];
- poll: PollView | null;
- lottery: PostLotteryView | null;
- liked: boolean;
- favorited: boolean;
- canEdit: boolean;
- isEdited: boolean;
- editBlockReason: string;
- editWindowHours: number;
- bountyCanRefund: boolean;
- bountyRefundBlockReason: string;
- bountyEligibleReplyCount: number;
-};
-
-function postDetailCacheKey(id: number) {
- return `post:${id}`;
-}
-
/** 格式化剩余可编辑时间 */
function formatEditRemaining(createdAt: string, windowHours: number): string {
if (windowHours <= 0) return '';
@@ -143,6 +124,8 @@ export default function PostDetailPage() {
const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true);
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? '');
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0);
+ const [authorProfile, setAuthorProfile] = useState