diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index aa33f53..65ea55a 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -6,9 +6,9 @@ import type { Board } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils';
-import { Skeleton } from '@/components/ui/skeleton';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
+import { transitionTo } from '../utils/spaTransition';
import BoardIconDisplay from './BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
import ArticleOutline from './ArticleOutline';
@@ -62,11 +62,11 @@ export default function Sidebar({
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
- const sort = parseFeedSort(params.get('sort'));
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const { navPages } = useSitePages();
const { limits } = useForumLimits();
+ const sort = parseFeedSort(params.get('sort'), limits.feed_sort_tabs);
const showFriendLinksNav = limits.nav_show_friend_links !== false;
const showShowcaseNav = !!limits.nav_show_showcase;
const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav;
@@ -137,22 +137,12 @@ export default function Sidebar({
浏览
{(boardsLoading && boards.length === 0) ? (
- <>
-
板块
-
- >
+
板块
) : boards.length > 0 ? (
<>
板块
@@ -198,10 +188,10 @@ export default function Sidebar({
<>
站点
>
diff --git a/frontend/src/components/TagCloud.tsx b/frontend/src/components/TagCloud.tsx
index 8ef740f..6646cf3 100644
--- a/frontend/src/components/TagCloud.tsx
+++ b/frontend/src/components/TagCloud.tsx
@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import type { TagCount } from '../api/types';
-import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface Props {
@@ -63,20 +62,7 @@ export default function TagCloud({ tags, loading = false, activeTag = '' }: Prop
}, [tags]);
if (loading && tags.length === 0) {
- return (
-
- {Array.from({ length: 10 }, (_, i) => (
-
- ))}
-
- );
+ return null;
}
if (items.length === 0) {
diff --git a/frontend/src/components/TopProgressBar.tsx b/frontend/src/components/TopProgressBar.tsx
new file mode 100644
index 0000000..47f67c0
--- /dev/null
+++ b/frontend/src/components/TopProgressBar.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useRef, useState } from 'react';
+import { subscribeSpaTransition } from '../utils/spaTransition';
+
+/**
+ * 视口顶部 2px 细进度条(类 Next.js / YouTube)。
+ * 由 spaTransition start/done 驱动,假进度爬升至约 80%,结束冲到 100%。
+ */
+export default function TopProgressBar() {
+ const [visible, setVisible] = useState(false);
+ const [width, setWidth] = useState(0);
+ const [fading, setFading] = useState(false);
+ const timerRef = useRef
| null>(null);
+ const fadeRef = useRef | null>(null);
+ const activeRef = useRef(false);
+
+ useEffect(() => {
+ const clearTick = () => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ };
+ const clearFade = () => {
+ if (fadeRef.current) {
+ clearTimeout(fadeRef.current);
+ fadeRef.current = null;
+ }
+ };
+
+ return subscribeSpaTransition((active) => {
+ if (active) {
+ activeRef.current = true;
+ clearFade();
+ setFading(false);
+ setVisible(true);
+ setWidth(12);
+ clearTick();
+ timerRef.current = setInterval(() => {
+ setWidth((w) => {
+ if (w >= 80) return w;
+ // 越接近 80 越慢
+ const step = Math.max(0.4, (80 - w) * 0.045);
+ return Math.min(80, w + step);
+ });
+ }, 120);
+ return;
+ }
+
+ if (!activeRef.current) return;
+ activeRef.current = false;
+ clearTick();
+ setWidth(100);
+ setFading(true);
+ clearFade();
+ fadeRef.current = setTimeout(() => {
+ setVisible(false);
+ setFading(false);
+ setWidth(0);
+ }, 220);
+ });
+ }, []);
+
+ if (!visible && width <= 0) return null;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/VirtualPostList.tsx b/frontend/src/components/VirtualPostList.tsx
index 6d8b9d6..b2a3d06 100644
--- a/frontend/src/components/VirtualPostList.tsx
+++ b/frontend/src/components/VirtualPostList.tsx
@@ -4,7 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox, SearchX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
-import PostListSkeleton, { feedListRowEstimate } from './PostListSkeleton';
+import { feedListRowEstimate } from './PostListSkeleton';
import FeedPagination from './FeedPagination';
import { InFlowSiteFooter } from './SiteFooter';
import { useAuth } from '../hooks/useAuth';
@@ -14,6 +14,7 @@ import { loginPath } from '../utils/authRedirect';
import { dispatchOpenPostSearch } from '../hooks/usePostSearch';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
+import { getDefaultFeedSortFromCache } from './FeedSortBar';
interface Props {
posts: PostItem[];
@@ -56,7 +57,7 @@ function getMobileFeedScrollEl(): HTMLElement | null {
export default function VirtualPostList({
posts,
- sort = 'reply',
+ sort = getDefaultFeedSortFromCache(),
loading,
hasMore,
showPagination,
@@ -242,9 +243,7 @@ export default function VirtualPostList({
return (
- {isInitialLoad ? (
-
- ) : isEmpty ? (
+ {isInitialLoad ? null : isEmpty ? (
{isSearchEmpty
?
diff --git a/frontend/src/components/admin/FeedSortTabList.tsx b/frontend/src/components/admin/FeedSortTabList.tsx
new file mode 100644
index 0000000..97c5ec1
--- /dev/null
+++ b/frontend/src/components/admin/FeedSortTabList.tsx
@@ -0,0 +1,89 @@
+import { Pencil } from 'lucide-react';
+import { Input } from '@/components/ui/input';
+import AdminSortableList, { SortableDragHandle } from './AdminSortableList';
+import type { FeedSortId, FeedSortTab } from '../../api/types';
+import { normalizeFeedSortTabs } from '../../utils/feedSortTabs';
+
+const TAB_META: Record
= {
+ reply: { hint: '按最后评论时间', placeholder: '新评论' },
+ latest: { hint: '按发帖时间', placeholder: '新帖子' },
+ hot: { hint: '推荐优先,再按互动', placeholder: '推荐帖' },
+};
+
+type Props = {
+ tabs: FeedSortTab[];
+ onChange: (next: FeedSortTab[]) => void;
+};
+
+export default function FeedSortTabList({ tabs, onChange }: Props) {
+ const items = normalizeFeedSortTabs(tabs);
+
+ const handleToggle = (id: FeedSortId, enabled: boolean) => {
+ const next = items.map(t => (t.id === id ? { ...t, enabled } : t));
+ // 不允许全部关闭:关掉最后一项时忽略
+ if (!enabled && !next.some(t => t.enabled)) return;
+ onChange(normalizeFeedSortTabs(next));
+ };
+
+ const handleLabel = (id: FeedSortId, label: string) => {
+ onChange(normalizeFeedSortTabs(items.map(t => (t.id === id ? { ...t, label } : t))));
+ };
+
+ return (
+ tab.id}
+ onReorder={next => onChange(normalizeFeedSortTabs(next))}
+ showMoveButtons={false}
+ className="admin-sortable-list admin-sortable-list--boxed"
+ ariaLabel="首页排序标签"
+ renderItem={(tab, _index, controls) => {
+ const meta = TAB_META[tab.id];
+ return (
+
+
+
+
+
+ {meta.hint}
+
+
+
+
+ );
+ }}
+ />
+ );
+}
diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx
index 24a037a..13c4729 100644
--- a/frontend/src/hooks/useAuth.tsx
+++ b/frontend/src/hooks/useAuth.tsx
@@ -1,6 +1,8 @@
-import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
+import { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode } from 'react';
import { api } from '../api/client';
import type { User } from '../api/types';
+import { clearAllFeedCache } from '../utils/feedCache';
+import { clearSessionSnapshots } from '../utils/sessionPageCache';
interface AuthCtx {
user: User | null;
@@ -32,6 +34,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// 初始化只拉一次用户信息
useEffect(() => { refresh(); }, [refresh]);
+ const prevUserId = useRef('init');
+ useEffect(() => {
+ if (loading) return;
+ const id = user?.id ?? null;
+ if (prevUserId.current === 'init') {
+ prevUserId.current = id;
+ return;
+ }
+ if (prevUserId.current !== id) {
+ prevUserId.current = id;
+ clearSessionSnapshots();
+ clearAllFeedCache();
+ }
+ }, [user, loading]);
+
const logout = async () => {
await api.logout();
setUser(null);
diff --git a/frontend/src/hooks/useCheckIn.ts b/frontend/src/hooks/useCheckIn.ts
index 5cd1e11..bd5465d 100644
--- a/frontend/src/hooks/useCheckIn.ts
+++ b/frontend/src/hooks/useCheckIn.ts
@@ -2,38 +2,115 @@ import { useCallback, useEffect, useState } from 'react';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { CheckInStatus } from '../api/types';
+import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
+import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
+import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { useAuth } from './useAuth';
+/** 签到会话快照键(与 prefetchRoute 共用) */
+export function checkInCacheKey(userId: number) {
+ return `checkin:${userId}`;
+}
+
/** 每日签到状态与操作(侧栏、积分页等复用) */
export function useCheckIn(enabled = true) {
const { user, refresh } = useAuth();
- const [status, setStatus] = useState(null);
- const [loading, setLoading] = useState(false);
+ const userId = user?.id;
+
+ const [status, setStatus] = useState(() => {
+ if (!userId || !enabled) return null;
+ return getSessionSnapshot(checkInCacheKey(userId)) ?? null;
+ });
+ const [loading, setLoading] = useState(() => {
+ if (!userId || !enabled) return false;
+ return getSessionSnapshot(checkInCacheKey(userId)) === undefined;
+ });
const [busy, setBusy] = useState(false);
- const load = useCallback(() => {
- if (!user || !enabled) {
+ const load = useCallback((opts?: { force?: boolean }) => {
+ if (!userId || !enabled) {
setStatus(null);
setLoading(false);
return;
}
+ const key = checkInCacheKey(userId);
+ if (!opts?.force) {
+ const hit = getSessionSnapshot(key);
+ if (hit !== undefined) {
+ setStatus(hit);
+ setLoading(false);
+ return;
+ }
+ }
setLoading(true);
api.checkInStatus()
- .then(d => setStatus(d.check_in))
- .catch(e => notify.error(e instanceof Error ? e.message : '加载签到状态失败'))
+ .then((d) => {
+ setSessionSnapshot(key, d.check_in);
+ setStatus(d.check_in);
+ })
+ .catch((e) => notify.error(e instanceof Error ? e.message : '加载签到状态失败'))
.finally(() => setLoading(false));
- }, [user, enabled]);
+ }, [userId, enabled]);
useEffect(() => {
- load();
- }, [load]);
+ if (!userId || !enabled) {
+ setStatus(null);
+ setLoading(false);
+ return;
+ }
+ const key = checkInCacheKey(userId);
+ const hit = getSessionSnapshot(key);
+ if (hit !== undefined) {
+ setStatus(hit);
+ setLoading(false);
+ return;
+ }
+ setStatus(null);
+ setLoading(true);
+ load({ force: true });
+ }, [userId, enabled, load]);
+
+ // 软刷新 commit:仅快照命中才盖;未命中保持旧 UI,后台静默再拉
+ useEffect(() => {
+ const applyHitOrSilent = (forceLoading: boolean) => {
+ if (!userId || !enabled) return;
+ const key = checkInCacheKey(userId);
+ const hit = getSessionSnapshot(key);
+ if (hit !== undefined) {
+ setStatus(hit);
+ setLoading(false);
+ return;
+ }
+ if (forceLoading) {
+ load({ force: true });
+ return;
+ }
+ // 软刷新未命中:不 setLoading,静默覆盖
+ api.checkInStatus()
+ .then((d) => {
+ setSessionSnapshot(key, d.check_in);
+ setStatus(d.check_in);
+ setLoading(false);
+ })
+ .catch(() => { /* 保持旧 UI */ });
+ };
+ const onForce = () => applyHitOrSilent(true);
+ const onCommit = () => applyHitOrSilent(false);
+ window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
+ window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
+ return () => {
+ window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
+ window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
+ };
+ }, [userId, enabled, load]);
const doCheckIn = useCallback(async () => {
- if (!user) return;
+ if (!userId) return;
setBusy(true);
try {
const r = await api.checkIn();
notify.success(`签到成功,+${r.check_in.today_points} 积分`);
+ setSessionSnapshot(checkInCacheKey(userId), r.check_in);
setStatus(r.check_in);
await refresh();
} catch (e: unknown) {
@@ -41,13 +118,13 @@ export function useCheckIn(enabled = true) {
} finally {
setBusy(false);
}
- }, [user, refresh]);
+ }, [userId, refresh]);
return {
status,
loading,
busy,
doCheckIn,
- reload: load,
+ reload: () => load({ force: true }),
};
}
diff --git a/frontend/src/hooks/useForumLimits.ts b/frontend/src/hooks/useForumLimits.ts
index 01e58b7..1a550c1 100644
--- a/frontend/src/hooks/useForumLimits.ts
+++ b/frontend/src/hooks/useForumLimits.ts
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { ForumLimitsPublic } from '../api/types';
-import { DEFAULT_ASIDE_WIDGETS } from '../api/types';
+import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types';
const DEFAULT_LIMITS: ForumLimitsPublic = {
post_title_max: 128,
@@ -27,6 +27,7 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title',
+ feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
permalink_enabled: false,
permalink_ext: 'html',
monitor_pageview: false,
@@ -79,6 +80,11 @@ export function useForumLimits() {
return { limits, loading };
}
+/** 冷启动 / 预热:确保 limits 已写入模块缓存 */
+export function ensureForumLimitsLoaded(): Promise {
+ return fetchLimits();
+}
+
/** 清除缓存并通知已挂载的 hook 重新拉取 */
export function invalidateForumLimitsCache() {
cached = null;
diff --git a/frontend/src/hooks/usePostSearch.ts b/frontend/src/hooks/usePostSearch.ts
index 7d62d64..c0be654 100644
--- a/frontend/src/hooks/usePostSearch.ts
+++ b/frontend/src/hooks/usePostSearch.ts
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { buildHomeUrl } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
+import { transitionTo } from '../utils/spaTransition';
import { notify } from '@/lib/notify';
import { parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
@@ -190,7 +191,7 @@ export function usePostSearch(
}
saveRecentSearch({ keyword: kw, author, titleOnly, scopeBoardId });
- nav(target);
+ void transitionTo(nav, target);
return true;
}, [nav, params, loc.pathname, limits, buildUrl]);
diff --git a/frontend/src/hooks/useSessionResource.ts b/frontend/src/hooks/useSessionResource.ts
new file mode 100644
index 0000000..422c4de
--- /dev/null
+++ b/frontend/src/hooks/useSessionResource.ts
@@ -0,0 +1,144 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
+import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
+import {
+ getSessionSnapshot,
+ setSessionSnapshot,
+ deleteSessionSnapshot,
+} from '../utils/sessionPageCache';
+
+type Fetcher = () => Promise;
+
+/**
+ * 会话快照数据源:有缓存则跳过请求;无缓存时保留上一份画面直到新数据返回。
+ * 手机下拉软刷新 commit / PAGE_FORCE_REFRESH_EVENT 会应用新快照。
+ */
+export function useSessionResource(
+ key: string | null,
+ fetcher: Fetcher,
+ opts?: {
+ enabled?: boolean;
+ onError?: (e: unknown) => void;
+ },
+) {
+ const enabled = opts?.enabled !== false;
+ const fetcherRef = useRef(fetcher);
+ fetcherRef.current = fetcher;
+ const onErrorRef = useRef(opts?.onError);
+ onErrorRef.current = opts?.onError;
+
+ const [data, setData] = useState(() =>
+ key && enabled ? getSessionSnapshot(key) : undefined,
+ );
+ const [loading, setLoading] = useState(() => {
+ if (!key || !enabled) return false;
+ return getSessionSnapshot(key) === undefined;
+ });
+ const [pending, setPending] = useState(false);
+ const seqRef = useRef(0);
+ const dataRef = useRef(data);
+ dataRef.current = data;
+
+ useEffect(() => {
+ if (!key || !enabled) {
+ if (!enabled) {
+ setLoading(false);
+ setPending(false);
+ }
+ return;
+ }
+
+ const hit = getSessionSnapshot(key);
+ if (hit !== undefined) {
+ setData(hit);
+ setLoading(false);
+ setPending(false);
+ return;
+ }
+
+ const seq = ++seqRef.current;
+ const keep = dataRef.current !== undefined;
+ if (keep) setPending(true);
+ else setLoading(true);
+
+ fetcherRef.current()
+ .then((next) => {
+ if (seq !== seqRef.current) return;
+ setSessionSnapshot(key, next);
+ setData(next);
+ })
+ .catch((e: unknown) => {
+ if (seq !== seqRef.current) return;
+ onErrorRef.current?.(e);
+ if (!keep) setData(undefined);
+ })
+ .finally(() => {
+ if (seq !== seqRef.current) return;
+ setLoading(false);
+ setPending(false);
+ });
+
+ return () => {
+ seqRef.current += 1;
+ };
+ }, [key, enabled]);
+
+ const replace = useCallback((next: T | ((prev: T | undefined) => T)) => {
+ setData((prev) => {
+ const value = typeof next === 'function' ? (next as (p: T | undefined) => T)(prev) : next;
+ if (key) setSessionSnapshot(key, value);
+ return value;
+ });
+ }, [key]);
+
+ const invalidate = useCallback(() => {
+ if (key) deleteSessionSnapshot(key);
+ }, [key]);
+
+ useEffect(() => {
+ const reload = (opts: { allowLoading: boolean }) => {
+ if (!key || !enabled) return;
+ const warm = getSessionSnapshot(key);
+ if (warm !== undefined) {
+ setData(warm);
+ setLoading(false);
+ setPending(false);
+ return;
+ }
+ const seq = ++seqRef.current;
+ const keep = dataRef.current !== undefined;
+ if (opts.allowLoading) {
+ deleteSessionSnapshot(key);
+ if (keep) setPending(true);
+ else setLoading(true);
+ }
+ fetcherRef.current()
+ .then((next) => {
+ if (seq !== seqRef.current) return;
+ setSessionSnapshot(key, next);
+ setData(next);
+ })
+ .catch((e: unknown) => {
+ if (seq !== seqRef.current) return;
+ onErrorRef.current?.(e);
+ if (opts.allowLoading && !keep) setData(undefined);
+ })
+ .finally(() => {
+ if (seq !== seqRef.current) return;
+ setLoading(false);
+ setPending(false);
+ });
+ };
+ const onForce = () => reload({ allowLoading: true });
+ // 软刷新 commit:未命中不卸 UI,后台静默再拉
+ const onCommit = () => reload({ allowLoading: false });
+ window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
+ window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
+ return () => {
+ window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
+ window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
+ };
+ }, [key, enabled]);
+
+ return { data, loading, pending, replace, invalidate };
+}
diff --git a/frontend/src/hooks/useSiteBranding.ts b/frontend/src/hooks/useSiteBranding.ts
index 12d6adb..793ed71 100644
--- a/frontend/src/hooks/useSiteBranding.ts
+++ b/frontend/src/hooks/useSiteBranding.ts
@@ -123,6 +123,13 @@ export function useSiteBranding() {
return { branding, loading };
}
+/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
+export function refetchSiteBranding() {
+ inflight = null;
+ cacheEpoch += 1;
+ listeners.forEach(fn => fn());
+}
+
/** 清除缓存并通知已挂载的 hook 重新拉取 */
export function invalidateSiteBrandingCache() {
cached = null;
diff --git a/frontend/src/hooks/useSitePages.ts b/frontend/src/hooks/useSitePages.ts
index 504b103..4e41226 100644
--- a/frontend/src/hooks/useSitePages.ts
+++ b/frontend/src/hooks/useSitePages.ts
@@ -1,32 +1,60 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { SitePageSummary } from '../api/types';
+import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
+import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
let cache: SitePageSummary[] | null = null;
let pending: Promise | null = null;
+/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */
+export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise {
+ if (opts?.force) {
+ cache = null;
+ pending = null;
+ }
+ if (cache) return Promise.resolve(cache);
+ if (!pending) {
+ pending = api.pages()
+ .then(d => {
+ cache = d.pages ?? [];
+ return cache;
+ })
+ .catch(() => {
+ cache = [];
+ return cache;
+ })
+ .finally(() => { pending = null; });
+ }
+ return pending;
+}
+
/** 已发布单页摘要(页脚/侧栏导航) */
export function useSitePages() {
const [pages, setPages] = useState(cache ?? []);
useEffect(() => {
- if (cache) {
- setPages(cache);
- return;
- }
- if (!pending) {
- pending = api.pages()
- .then(d => {
- cache = d.pages ?? [];
- return cache;
- })
- .catch(() => {
- cache = [];
- return cache;
- })
- .finally(() => { pending = null; });
- }
- pending.then(setPages);
+ const load = () => {
+ void ensureSitePagesLoaded().then(setPages);
+ };
+ if (cache) setPages(cache);
+ else load();
+
+ const onForce = () => {
+ cache = null;
+ pending = null;
+ load();
+ };
+ const onCommit = () => {
+ // 软刷新:cache 已由 prefetch 写好,同步进 state
+ void ensureSitePagesLoaded().then(setPages);
+ };
+ window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
+ window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
+ return () => {
+ window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
+ window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
+ };
}, []);
return {
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
index ac39070..8c1d755 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -1,6 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
-import PageLoader from '../components/PageLoader';
-import FeedPageSkeleton from '../components/FeedPageSkeleton';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
import {
@@ -23,7 +21,12 @@ import BackToTop from '../components/BackToTop';
import { useForumLimits } from '../hooks/useForumLimits';
import { resolveAsideWidgets } from '../utils/asideWidgets';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
-import { navigateFeed } from '../utils/feedCache';
+import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
+import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
+import { prefetchRoute, wasColdBootEnsured } from '../utils/prefetchRoute';
+import {
+ transitionTo,
+} from '../utils/spaTransition';
import PostSearchPanel from '../components/search/PostSearchPanel';
import {
POST_SEARCH_OPEN_EVENT,
@@ -33,12 +36,13 @@ import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
import { loginPath } from '../utils/authRedirect';
import { openForumPost } from '../utils/openPost';
-import { useSiteBranding } from '../hooks/useSiteBranding';
+import { refetchSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
import { useMonitorPageview } from '../hooks/useMonitorPageview';
import SiteBrandMark from '../components/SiteBrandMark';
import SiteFooter from '../components/SiteFooter';
import { userPath } from '../utils/userPath';
import { parsePermalinkID } from '../utils/permalink';
+import { ensureSitePagesLoaded } from '../hooks/useSitePages';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
@@ -73,15 +77,20 @@ export default function MainLayout() {
const searchInputRef = useRef(null);
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
+ const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
const asideEverLoaded = useRef(false);
+ /** 冷启动门闩:main.tsx 已预热则可同步放行;否则等齐再呈现 */
+ const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured());
+ const coldBootDone = useRef(isCompose || wasColdBootEnsured());
+ const bootGen = useRef(0);
const [boardId, setBoardId] = useState(() => {
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
if (m) return parsePermalinkID(m[1]) || 0;
return Number(params.get('board')) || 0;
});
const [keywordDraft, setKeywordDraft] = useState(params.get('keyword') || '');
- const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits();
+ const feedSort = parseFeedSort(params.get('sort'), forumLimits.feed_sort_tabs);
const postSearch = usePostSearch(forumLimits);
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled);
@@ -195,6 +204,94 @@ export default function MainLayout() {
return () => window.removeEventListener('boards-refresh', onRefresh);
}, [refreshBoards]);
+ // 冷启动兜底:main 未预热时静默等齐(不打进度条);站内已预热则保持打开
+ useEffect(() => {
+ if (isCompose) {
+ coldBootDone.current = true;
+ setShellReady(true);
+ return;
+ }
+ if (coldBootDone.current || wasColdBootEnsured()) {
+ coldBootDone.current = true;
+ setShellReady(true);
+ setBoardsLoading(false);
+ setAsideLoading(false);
+ setTagsLoading(false);
+ asideEverLoaded.current = true;
+ return;
+ }
+
+ const gen = ++bootGen.current;
+ let cancelled = false;
+
+ (async () => {
+ try {
+ const path = `${loc.pathname}${loc.search}`;
+ const tasks: Promise[] = [
+ prefetchRoute(path, { force: false }),
+ refreshBoards(),
+ ensureSitePagesLoaded(),
+ ];
+
+ if (!hideAside) {
+ if (showRecentComments) {
+ tasks.push(
+ api.recentComments().then((d) => {
+ const next = Array.isArray(d.comments) ? d.comments : [];
+ setRecentComments(next);
+ setCachedRecentComments(next);
+ }).catch(() => undefined),
+ );
+ }
+ if (showRecentUsers) {
+ tasks.push(
+ api.recentUsers().then((d) => {
+ const next = Array.isArray(d.users) ? d.users : [];
+ setRecentUsers(next);
+ setCachedRecentUsers(next);
+ }).catch(() => undefined),
+ );
+ }
+ if (showTagCloud) {
+ tasks.push(
+ api.tags(40).then((d) => {
+ const next = Array.isArray(d.tags) ? d.tags : [];
+ setTags(next);
+ setCachedTags(next);
+ }).catch(() => undefined),
+ );
+ }
+ }
+
+ await Promise.all(tasks);
+ } catch {
+ // 失败也放行
+ } finally {
+ if (!cancelled && bootGen.current === gen) {
+ asideEverLoaded.current = true;
+ setAsideLoading(false);
+ setTagsLoading(false);
+ setBoardsLoading(false);
+ coldBootDone.current = true;
+ setShellReady(true);
+ }
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ isCompose,
+ loc.pathname,
+ loc.search,
+ hideAside,
+ showRecentComments,
+ showRecentUsers,
+ showTagCloud,
+ refreshBoards,
+ ]);
+
const refreshUnreadMessages = useCallback(() => {
if (!user) {
setUnreadMessages(0);
@@ -216,10 +313,50 @@ export default function MainLayout() {
};
}, [refreshUnreadMessages]);
+ useEffect(() => {
+ const syncFromCache = () => {
+ // 仅当 cache 有内容时覆盖,避免空数组盖住已渲染的非空 UI
+ const nextBoards = getCachedBoards();
+ if (nextBoards.length > 0) setBoards(nextBoards);
+ setBoardsLoading(false);
+ const nextStats = getCachedStats();
+ if (nextStats) setStats(nextStats);
+ const nextComments = getCachedRecentComments();
+ if (nextComments.length > 0 || hasCachedAside()) setRecentComments(nextComments);
+ const nextUsers = getCachedRecentUsers();
+ if (nextUsers.length > 0 || hasCachedAside()) setRecentUsers(nextUsers);
+ const nextTags = getCachedTags();
+ if (nextTags.length > 0) setTags(nextTags);
+ setTagsLoading(false);
+ setAsideLoading(false);
+ asideEverLoaded.current = true;
+ refetchSiteBranding();
+ refreshUnreadMessages();
+ };
+ const onForce = () => {
+ // 旧路径:仍可能被别处派发;尽量静默刷新壳层
+ refreshBoards();
+ refreshUnreadMessages();
+ refetchSiteBranding();
+ setLayoutRefreshTick(n => n + 1);
+ };
+ const onCommit = () => {
+ // 软刷新:预热已写入 cache,同一拍同步进 state,不触发分批 loading
+ syncFromCache();
+ };
+ window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
+ window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
+ return () => {
+ window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
+ window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
+ };
+ }, [refreshBoards, refreshUnreadMessages]);
+
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
useEffect(() => {
if (isCompose || !showTagCloud) return;
let cancelled = false;
+ // 有 session 缓存则静默刷新(软刷新不卸空白)
if (getCachedTags().length === 0) setTagsLoading(true);
api.tags(40).then(d => {
if (cancelled) return;
@@ -232,7 +369,7 @@ export default function MainLayout() {
return () => {
cancelled = true;
};
- }, [isCompose, showTagCloud]);
+ }, [isCompose, showTagCloud, layoutRefreshTick]);
const needAsideData = !isCompose && (!hideAside || asideOpen);
const needRecentComments = needAsideData && showRecentComments;
@@ -259,7 +396,7 @@ export default function MainLayout() {
return () => {
cancelled = true;
};
- }, [needRecentComments]);
+ }, [needRecentComments, layoutRefreshTick]);
const needRecentUsers = needAsideData && showRecentUsers;
useEffect(() => {
@@ -284,7 +421,7 @@ export default function MainLayout() {
return () => {
cancelled = true;
};
- }, [needRecentUsers]);
+ }, [needRecentUsers, layoutRefreshTick]);
const doQuickSearch = () => {
const { author, titleOnly, scopeBoardId } = postSearch.filters;
@@ -386,8 +523,8 @@ export default function MainLayout() {
)}
- {/* 点 Logo:回首页并强制刷新列表(已在首页时也会重拉) */}
-