import { useState, useEffect, useCallback, useRef, useMemo, Suspense, startTransition } from 'react'; import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; 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 { 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 { resolveAsideWidgets } from '../utils/asideWidgets'; import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar'; 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, usePostSearch, } from '../hooks/usePostSearch'; import { cn } from '@/lib/utils'; import { getBoardThemeIndex } from '../utils/boardTheme'; import { loginPath } from '../utils/authRedirect'; import { openForumPost } from '../utils/openPost'; 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'; import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate'; import { getBootUnread } from '../utils/authBoot'; export default function MainLayout() { const { user, loading: authLoading, logout } = useAuth(); const { theme, toggle } = useTheme(); const { branding } = useSiteBranding(); useMonitorPageview(); const mqMobile = useMediaQuery('(max-width: 768px)'); const hideAside = useMediaQuery('(max-width: 1100px)'); /** hydrate 首帧强制桌面布局(SSR 为桌面三栏),随后再跟 matchMedia */ const [forceDesktop, setForceDesktop] = useState(() => isHomeHydrating()); const isMobile = forceDesktop ? false : mqMobile; const nav = useNavigate(); const loc = useLocation(); const [params] = useSearchParams(); const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname); useEffect(() => { if (!forceDesktop) return; endHomeHydrate(); startTransition(() => setForceDesktop(false)); }, [forceDesktop]); const [boards, setBoards] = useState(() => getCachedBoards()); const [stats, setStats] = useState(() => getCachedStats()); const [recentComments, setRecentComments] = useState(() => getCachedRecentComments()); const [recentUsers, setRecentUsers] = useState(() => getCachedRecentUsers()); const [unreadMessages, setUnreadMessages] = useState(() => getBootUnread()); 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); const [asideOpen, setAsideOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [searchPanelOpen, setSearchPanelOpen] = useState(false); 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 { 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); 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); const sidebarDrawerRef = useRef(null); const sidebarCloseRef = useRef(null); const boardBarRef = useRef(null); const closeAside = useCallback(() => setAsideOpen(false), []); const closeSidebar = useCallback(() => setSidebarOpen(false), []); const openAside = useCallback(() => { setSidebarOpen(false); setAsideOpen(true); }, []); const openSidebar = useCallback(() => { setAsideOpen(false); setSidebarOpen(true); }, []); useOverlayA11y(asideOpen && hideAside && !isCompose, closeAside, asideDrawerRef, { initialFocusRef: asideCloseRef, }); useOverlayA11y(sidebarOpen && isMobile && !isCompose, closeSidebar, sidebarDrawerRef, { initialFocusRef: sidebarCloseRef, }); useEffect(() => { const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/); if (m) { setBoardId(parsePermalinkID(m[1]) || 0); return; } setBoardId(Number(params.get('board')) || 0); }, [loc.pathname, params]); useEffect(() => { setKeywordDraft(params.get('keyword') || ''); }, [params]); useEffect(() => { setAsideOpen(false); setSidebarOpen(false); }, [loc.pathname, loc.search]); useEffect(() => { if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null); }, [loc.pathname]); useEffect(() => { if (!hideAside) setAsideOpen(false); }, [hideAside]); useEffect(() => { if (!isMobile) setSidebarOpen(false); }, [isMobile]); const openSearchPanel = useCallback(() => setSearchPanelOpen(true), []); useEffect(() => { const onOpen = () => setSearchPanelOpen(true); window.addEventListener(POST_SEARCH_OPEN_EVENT, onOpen); return () => window.removeEventListener(POST_SEARCH_OPEN_EVENT, onOpen); }, []); useEffect(() => { if (isCompose) return; const onKey = (e: KeyboardEvent) => { if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'k') return; const tag = (e.target as HTMLElement | null)?.tagName; const editable = (e.target as HTMLElement | null)?.isContentEditable; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return; e.preventDefault(); if (isMobile) { setSearchPanelOpen(true); } else { searchInputRef.current?.focus(); searchInputRef.current?.select(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [isCompose, isMobile]); useEffect(() => { if (!asideOpen && !sidebarOpen) return; const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, [asideOpen, sidebarOpen]); const refreshBoards = useCallback(() => { return Promise.all([ api.boards().then(d => { const next = d.boards ?? []; setBoards(next); setCachedBoards(next); return next; }).catch(() => [] as Board[]), api.stats().then(next => { setStats(next); setCachedStats(next); return next; }).catch(() => null), ]).finally(() => { setBoardsLoading(false); }); }, []); useEffect(() => { refreshBoards(); const onRefresh = () => refreshBoards(); window.addEventListener('boards-refresh', onRefresh); 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); return; } api.messageUnreadCount() .then((r) => setUnreadMessages(r.count || 0)) .catch(() => setUnreadMessages(0)); }, [user]); useEffect(() => { refreshUnreadMessages(); const onRefresh = () => refreshUnreadMessages(); window.addEventListener('messages-unread-refresh', onRefresh); const timer = window.setInterval(refreshUnreadMessages, 60_000); return () => { window.removeEventListener('messages-unread-refresh', onRefresh); window.clearInterval(timer); }; }, [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; 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; const next = Array.isArray(d.tags) ? d.tags : []; setTags(next); setCachedTags(next); }).catch(() => {}).finally(() => { if (!cancelled) setTagsLoading(false); }); return () => { cancelled = true; }; }, [isCompose, showTagCloud, layoutRefreshTick]); const needAsideData = !isCompose && (!hideAside || asideOpen); const needRecentComments = needAsideData && showRecentComments; useEffect(() => { if (!needRecentComments) return; let cancelled = false; // 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动 if (!asideEverLoaded.current && !hasCachedAside()) { setAsideLoading(true); } api.recentComments().then(d => { if (cancelled) return; const next = Array.isArray(d.comments) ? d.comments : []; setRecentComments(next); setCachedRecentComments(next); }).catch(() => {}).finally(() => { if (!cancelled) { asideEverLoaded.current = true; setAsideLoading(false); } }); return () => { cancelled = true; }; }, [needRecentComments, layoutRefreshTick]); 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, layoutRefreshTick]); const doQuickSearch = () => { const { author, titleOnly, scopeBoardId } = postSearch.filters; postSearch.submitSearch({ keyword: keywordDraft, author, titleOnly, scopeBoardId, }, { refreshIfSame: true }); }; const handleHeaderClear = () => { const hasUrlSearch = postSearch.filters.isFiltered; setKeywordDraft(''); if (hasUrlSearch) postSearch.clearSearch(); }; const contextBoard = boards.find((b) => b.id === boardId); const searchPanelDraft = { keyword: keywordDraft, author: postSearch.filters.author, titleOnly: postSearch.filters.titleOnly, scopeBoardId: postSearch.filters.scopeBoardId, }; const openPost = useCallback((id: number, opts?: { floor?: number }) => { setAsideOpen(false); openForumPost(nav, id, forumLimits.open_posts_in_new_tab, opts); }, [nav, forumLimits.open_posts_in_new_tab]); const userInitial = user?.nickname?.charAt(0) || '?'; const isFeedHome = loc.pathname === '/' || /^\/board\/\d+/.test(loc.pathname); const outletKeyword = params.get('keyword') || ''; const outletTag = params.get('tag') || ''; const outletAuthor = params.get('author') || ''; // 搜索/标签结果页不选中任何板块芯片(避免看起来仍停在「全部」) const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag || !!outletAuthor ? -1 : boardId; const boardChipIds = useMemo(() => [0, ...boards.map(b => b.id)], [boards]); const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard)); const isPostDetail = /^\/post\/\d+/.test(loc.pathname) && !/\/edit$/.test(loc.pathname); const setPostOutlineSafe = useCallback((outline: { headings: PostHeading[]; scrollRoot: HTMLElement | null; title?: string; author?: User | null; publishedAt?: string; viewCount?: number; } | null) => { setPostOutline(outline); }, []); const layoutCtx = useMemo(() => ({ boardId, keyword: outletKeyword, setBoardId, boards, boardsLoading, stats, refreshBoards, isMobile, setPostOutline: setPostOutlineSafe, }), [boardId, outletKeyword, boards, boardsLoading, stats, refreshBoards, isMobile, setPostOutlineSafe]); const selectBoardChip = (id: number) => { setBoardId(id); navigateFeed(nav, buildHomeUrl(id, feedSort, { permalink: forumLimits })); }; const onBoardBarKeyDown = (e: React.KeyboardEvent) => { const next = moveTabIndex(e.key, activeChipIndex, boardChipIds.length); if (next == null) return; e.preventDefault(); selectBoardChip(boardChipIds[next]); requestAnimationFrame(() => { const tabs = boardBarRef.current?.querySelectorAll('[role="tab"]'); tabs?.[next]?.focus(); }); }; return (
{!isCompose && ( )} {/* 任意页点 Logo:回首页并强制刷新,不展示会话缓存 */} {!isCompose && isMobile && ( )} {!isCompose && !isMobile && (
{ e.preventDefault(); doQuickSearch(); }} >
setKeywordDraft(e.target.value)} maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined} enterKeyHint="search" /> {(keywordDraft || postSearch.filters.isFiltered) && ( )} Ctrl K
)}
{!isCompose && ( )}
{/* 平板:侧栏收起时用按钮打开社区动态;手机改由导航抽屉入口 */} {!isCompose && hideAside && !isMobile && ( )} {authLoading ? ( ) : user ? ( <> e.preventDefault()} > void transitionTo(nav, userPath(user.id))}>个人主页 void transitionTo(nav, '/profile')}> 账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''} void transitionTo(nav, '/messages')}> 站内消息{unreadMessages > 0 ? ` (${unreadMessages})` : ''} void transitionTo(nav, '/favorites')}>我的收藏 {isMobile && ( {theme === 'light' ? '切换暗色模式' : '切换亮色模式'} )} {user.role === 'admin' && ( <> nav('/admin/dashboard')}>管理后台 )} logout().then(() => nav('/login'))}> 退出登录 ) : ( )}
{!isCompose && ( { const ok = postSearch.submitSearch(input); if (ok) setKeywordDraft((input.keyword ?? '').trim()); return ok; }} onClear={postSearch.clearSearch} /> )}
{!isCompose && shellReady && ( )}
{shellReady && isMobile && !isCompose && isFeedHome && (
{boards.map((b, i) => { const themeIdx = getBoardThemeIndex(b); const isActive = mobileActiveBoard === b.id; const idx = i + 1; return ( ); })}
)} {shellReady ? ( ) : null}
{!isCompose && ( )}
{/* 桌面壳层贴底;手机端由各页 InFlowSiteFooter 随内容滚动 */} {!isCompose && !isMobile && }
{sidebarOpen && isMobile && !isCompose && (
{shellReady && ( )}
)} {asideOpen && hideAside && !isCompose && (
)} ); } export type LayoutCtx = { boardId: number; keyword: string; setBoardId: (id: number) => void; boards: Board[]; boardsLoading: boolean; stats: ForumStats | null; refreshBoards: () => void; isMobile: boolean; /** 详情页上报作者与目录,供右侧栏展示 */ setPostOutline: (outline: { headings: PostHeading[]; scrollRoot: HTMLElement | null; title?: string; author?: User | null; publishedAt?: string; viewCount?: number; } | null) => void; };