Files
jiang13-forum/frontend/src/layouts/MainLayout.tsx
freefire b451703642 完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。
同步更新 README 与 ROADMAP,反映最新功能与开发状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 04:11:38 +08:00

269 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, Suspense } from 'react';
import PageLoader from '../components/PageLoader';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Moon, Sun, Search, Plus } 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 { api } from '../api/client';
import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types';
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
import RightPanel from '../components/RightPanel';
import { useForumLimits } from '../hooks/useForumLimits';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { notify } from '@/lib/notify';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
const { theme, toggle } = useTheme();
const isMobile = useMediaQuery('(max-width: 768px)');
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<Board[]>(() => getCachedBoards());
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
const [layoutReady, setLayoutReady] = useState(() => getCachedBoards().length > 0 || !!getCachedStats());
const [hot, setHot] = useState<PostItem[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [online, setOnline] = useState<OnlineStats | null>(null);
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();
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
const refreshBoards = useCallback(() => {
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(() => setLayoutReady(true));
}, []);
const refreshOnline = useCallback(() => {
api.online().then(d => {
setOnline({
count: d.count ?? 0,
members: d.members ?? 0,
guests: d.guests ?? 0,
users: Array.isArray(d.users) ? d.users : [],
});
}).catch(() => {});
}, []);
useEffect(() => {
refreshBoards();
api.hotPosts().then(d => setHot(Array.isArray(d.posts) ? d.posts : [])).catch(() => {});
api.notifications().then(d => setNotifications(Array.isArray(d.notifications) ? d.notifications : [])).catch(() => {});
refreshOnline();
api.presence().catch(() => {});
const onlineTimer = setInterval(refreshOnline, 30000);
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
const onRefresh = () => refreshBoards();
window.addEventListener('boards-refresh', onRefresh);
return () => {
clearInterval(onlineTimer);
clearInterval(presenceTimer);
window.removeEventListener('boards-refresh', onRefresh);
};
}, [refreshBoards, refreshOnline]);
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 userInitial = user?.nickname?.charAt(0) || '?';
const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId;
return (
<div className="app-shell">
<div className="app-frame">
<header className="app-header">
<div className="header-inner">
<button type="button" className="header-brand" onClick={() => nav('/')}>
<span className="header-logo-mark"></span>
{!isMobile && <span className="header-logo-text"></span>}
</button>
{!isCompose && (
<div className="header-search-wrap">
<Search className="header-search-icon" size={16} />
<input
className="header-search-input"
type="search"
placeholder="搜索帖子..."
value={keyword}
onChange={e => setKeyword(e.target.value)}
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
onKeyDown={e => e.key === 'Enter' && doSearch()}
/>
{keyword && (
<button
type="button"
className="header-search-clear"
onClick={() => { setKeyword(''); nav('/'); }}
aria-label="清除搜索"
>×</button>
)}
</div>
)}
<div className="header-actions">
{!isCompose && (
<button
type="button"
className="header-compose-btn"
onClick={() => user ? nav('/compose') : nav('/login')}
>
<Plus size={16} />
{!isMobile && <span></span>}
</button>
)}
<div className="header-action-group">
<button
type="button"
className="header-icon-btn"
onClick={toggle}
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
>
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
</button>
{authLoading ? (
<span className="header-auth-slot header-auth-slot--loading" aria-hidden />
) : user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="header-user-btn" title={user.nickname}>
{user.avatar
? <img src={user.avatar} alt="" className="header-user-avatar" />
: <span className="header-user-initial">{userInitial}</span>}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => nav('/profile')}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/favorites')}></DropdownMenuItem>
{user.role === 'admin' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => nav('/admin/dashboard')}></DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout().then(() => nav('/login'))}>
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button type="button" className="header-login-btn" onClick={() => nav('/login')}>
</button>
)}
</div>
</div>
</div>
</header>
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
{!isCompose && (
<Sidebar
boards={boards}
activeBoard={boardId}
onSelectBoard={setBoardId}
/>
)}
<div className={`content-workspace${isCompose ? ' content-workspace--compose' : ''}`}>
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}>
{isMobile && !isCompose && (
<div className="mobile-board-bar">
<span
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
onClick={() => { setBoardId(0); nav(buildHomeUrl(0, feedSort)); }}
></span>
{boards.map(b => (
<span
key={b.id}
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
onClick={() => { setBoardId(b.id); nav(buildHomeUrl(b.id, feedSort)); }}
>{b.name}</span>
))}
</div>
)}
<Suspense fallback={<PageLoader />}>
<Outlet context={{
boardId,
keyword: params.get('keyword') || '',
setBoardId,
boards,
stats,
layoutReady,
refreshBoards,
isMobile,
} satisfies LayoutCtx} />
</Suspense>
</main>
{!isCompose && (
<aside className="aside-panel">
<RightPanel
hot={hot}
notifications={notifications}
online={online}
onPostClick={(id) => nav(`/post/${id}`)}
/>
</aside>
)}
</div>
</div>
</div>
</div>
);
}
export type LayoutCtx = {
boardId: number;
keyword: string;
setBoardId: (id: number) => void;
boards: Board[];
stats: ForumStats | null;
layoutReady: boolean;
refreshBoards: () => void;
isMobile: boolean;
};