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 { Moon, Sun, Search, Plus, PanelRight, X } 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, PostItem, RecentComment, ForumStats, TagCount } from '../api/types'; import type { PostHeading } from '../utils/postHeadings'; import { getCachedBoards, getCachedStats, getCachedHot, getCachedRecentComments, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedHot, setCachedRecentComments, 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 { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar'; import { navigateFeed } from '../utils/feedCache'; import { notify } from '@/lib/notify'; 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 SiteBrandMark from '../components/SiteBrandMark'; export default function MainLayout() { const { user, loading: authLoading, logout } = useAuth(); const { theme, toggle } = useTheme(); const { branding } = useSiteBranding(); const isMobile = useMediaQuery('(max-width: 768px)'); const hideAside = useMediaQuery('(max-width: 1100px)'); const nav = useNavigate(); const loc = useLocation(); const [params] = useSearchParams(); const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname); const [boards, setBoards] = useState(() => getCachedBoards()); const [stats, setStats] = useState(() => getCachedStats()); const [hot, setHot] = useState(() => getCachedHot()); const [recentComments, setRecentComments] = useState(() => getCachedRecentComments()); const [tags, setTags] = useState(() => getCachedTags()); const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0); const [postOutline, setPostOutline] = useState<{ headings: PostHeading[]; scrollRoot: HTMLElement | null; title?: string; } | null>(null); const [asideOpen, setAsideOpen] = useState(false); const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside()); const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0); const asideEverLoaded = useRef(false); const [boardId, setBoardId] = useState(Number(params.get('board')) || 0); const [keyword, setKeyword] = useState(params.get('keyword') || ''); const feedSort = parseFeedSort(params.get('sort')); const { limits: forumLimits } = useForumLimits(); const asideDrawerRef = useRef(null); const asideCloseRef = useRef(null); const boardBarRef = useRef(null); const closeAside = useCallback(() => setAsideOpen(false), []); useOverlayA11y(asideOpen && hideAside && !isCompose, closeAside, asideDrawerRef, { initialFocusRef: asideCloseRef, }); useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]); useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]); useEffect(() => { setAsideOpen(false); }, [loc.pathname, loc.search]); useEffect(() => { if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null); }, [loc.pathname]); useEffect(() => { if (!hideAside) setAsideOpen(false); }, [hideAside]); useEffect(() => { if (!asideOpen) return; const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, [asideOpen]); 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]); // 标签云:非编辑页拉取(左侧栏常显) useEffect(() => { if (isCompose) return; let cancelled = false; const loadTags = () => { 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); }); }; loadTags(); const onRefresh = () => loadTags(); window.addEventListener('posts-refresh', onRefresh); return () => { cancelled = true; window.removeEventListener('posts-refresh', onRefresh); }; }, [isCompose]); const needAsideData = !isCompose && (!hideAside || asideOpen); useEffect(() => { if (!needAsideData) return; let cancelled = false; // 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动 if (!asideEverLoaded.current && !hasCachedAside()) { setAsideLoading(true); } Promise.all([ api.hotPosts().then(d => { if (cancelled) return; const next = Array.isArray(d.posts) ? d.posts : []; setHot(next); setCachedHot(next); }).catch(() => {}), 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; }; }, [needAsideData]); const doSearch = () => { const kw = keyword.trim(); if (!kw) { nav('/'); return; } const len = [...kw].length; if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) { notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`); return; } if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) { notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`); return; } nav(`/?keyword=${encodeURIComponent(kw)}`); }; const openPost = useCallback((id: number) => { setAsideOpen(false); openForumPost(nav, id, forumLimits.open_posts_in_new_tab); }, [nav, forumLimits.open_posts_in_new_tab]); const userInitial = user?.nickname?.charAt(0) || '?'; const isFeedHome = loc.pathname === '/'; const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId; const boardChipIds = useMemo(() => [0, ...boards.map(b => b.id)], [boards]); const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard)); const outletKeyword = params.get('keyword') || ''; const isPostDetail = /^\/post\/\d+\/?$/.test(loc.pathname); const setPostOutlineSafe = useCallback((outline: LayoutCtx['postOutline']) => { setPostOutline(outline); }, []); const layoutCtx = useMemo(() => ({ boardId, keyword: outletKeyword, setBoardId, boards, stats, refreshBoards, isMobile, setPostOutline: setPostOutlineSafe, }), [boardId, outletKeyword, boards, stats, refreshBoards, isMobile, setPostOutlineSafe]); const selectBoardChip = (id: number) => { setBoardId(id); navigateFeed(nav, buildHomeUrl(id, feedSort)); }; 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 && (
setKeyword(e.target.value)} maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined} onKeyDown={e => e.key === 'Enter' && doSearch()} /> {keyword && ( )}
)}
{!isCompose && ( )}
{!isCompose && hideAside && ( )} {authLoading ? ( ) : user ? ( e.preventDefault()} > nav('/profile')}>个人中心 nav('/favorites')}>我的收藏 {user.role === 'admin' && ( <> nav('/admin/dashboard')}>管理后台 )} logout().then(() => nav('/login'))}> 退出登录 ) : ( )}
{!isCompose && ( )}
{isMobile && !isCompose && isFeedHome && (
{boards.map((b, i) => { const themeIdx = getBoardThemeIndex(b); const isActive = mobileActiveBoard === b.id; const idx = i + 1; return ( ); })}
)} : }>
{!isCompose && ( )}
{asideOpen && hideAside && !isCompose && (
)} ); } export type LayoutCtx = { boardId: number; keyword: string; setBoardId: (id: number) => void; boards: Board[]; stats: ForumStats | null; refreshBoards: () => void; isMobile: boolean; /** 详情页上报文章目录,供左侧栏展示 */ setPostOutline: (outline: { headings: PostHeading[]; scrollRoot: HTMLElement | null; title?: string; } | null) => void; };