feat: 单页详情右侧栏与帖子共用目录布局

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 18:34:59 +08:00
parent 0d81787125
commit bc9a031890
3 changed files with 66 additions and 16 deletions

View File

@@ -232,11 +232,13 @@ export default function RightPanel({
<div className={`aside-panel-inner${isPostDetail ? ' aside-panel-inner--post-detail' : ''}`}> <div className={`aside-panel-inner${isPostDetail ? ' aside-panel-inner--post-detail' : ''}`}>
{isPostDetail && ( {isPostDetail && (
<> <>
{postDetail.author?.id ? (
<PostAuthorCard <PostAuthorCard
author={postDetail.author} author={postDetail.author}
publishedAt={postDetail.publishedAt} publishedAt={postDetail.publishedAt}
viewCount={postDetail.viewCount} viewCount={postDetail.viewCount}
/> />
) : null}
<div className="widget-card widget-card--outline"> <div className="widget-card widget-card--outline">
<div className="widget-card-head"> <div className="widget-card-head">
<ListTree className="widget-card-icon widget-card-icon--outline" aria-hidden /> <ListTree className="widget-card-icon widget-card-icon--outline" aria-hidden />

View File

@@ -148,7 +148,10 @@ export default function MainLayout() {
setSidebarOpen(false); setSidebarOpen(false);
}, [loc.pathname, loc.search]); }, [loc.pathname, loc.search]);
useEffect(() => { useEffect(() => {
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null); const isArticleAside =
(/^\/post\/\d+/.test(loc.pathname) && !/\/edit$/.test(loc.pathname))
|| /^\/page\//.test(loc.pathname);
if (!isArticleAside) setPostOutline(null);
}, [loc.pathname]); }, [loc.pathname]);
useEffect(() => { useEffect(() => {
if (!hideAside) setAsideOpen(false); if (!hideAside) setAsideOpen(false);
@@ -477,6 +480,10 @@ export default function MainLayout() {
const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard)); const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard));
const isPostDetail = /^\/post\/\d+/.test(loc.pathname) && !/\/edit$/.test(loc.pathname); const isPostDetail = /^\/post\/\d+/.test(loc.pathname) && !/\/edit$/.test(loc.pathname);
const isSitePage = /^\/page\//.test(loc.pathname);
const isArticleAside = isPostDetail || isSitePage;
// 帖子有作者 →「作者与目录」;单页仅目录
const articleAsideLabel = isPostDetail ? '作者与目录' : '文章目录';
const setPostOutlineSafe = useCallback((outline: { const setPostOutlineSafe = useCallback((outline: {
headings: PostHeading[]; headings: PostHeading[];
scrollRoot: HTMLElement | null; scrollRoot: HTMLElement | null;
@@ -632,10 +639,10 @@ export default function MainLayout() {
type="button" type="button"
className="header-icon-btn" className="header-icon-btn"
onClick={openAside} onClick={openAside}
aria-label={isPostDetail ? '打开作者与目录' : '打开社区动态'} aria-label={isArticleAside ? `打开${articleAsideLabel}` : '打开社区动态'}
aria-expanded={asideOpen} aria-expanded={asideOpen}
aria-controls="aside-drawer" aria-controls="aside-drawer"
title={isPostDetail ? '作者与目录' : '社区动态'} title={isArticleAside ? articleAsideLabel : '社区动态'}
> >
<PanelRight size={18} aria-hidden /> <PanelRight size={18} aria-hidden />
</button> </button>
@@ -815,7 +822,7 @@ export default function MainLayout() {
asideWidgets={asideWidgets} asideWidgets={asideWidgets}
onPostClick={openPost} onPostClick={openPost}
postDetail={isPostDetail ? { postDetail={isArticleAside ? {
author: postOutline?.author ?? null, author: postOutline?.author ?? null,
publishedAt: postOutline?.publishedAt, publishedAt: postOutline?.publishedAt,
viewCount: postOutline?.viewCount, viewCount: postOutline?.viewCount,
@@ -879,7 +886,7 @@ export default function MainLayout() {
onClick={() => { closeSidebar(); openAside(); }} onClick={() => { closeSidebar(); openAside(); }}
> >
<PanelRight size={16} aria-hidden /> <PanelRight size={16} aria-hidden />
{isPostDetail ? '作者与目录' : '社区动态'} {isArticleAside ? articleAsideLabel : '社区动态'}
</button> </button>
<button <button
type="button" type="button"
@@ -910,10 +917,10 @@ export default function MainLayout() {
className="aside-drawer" className="aside-drawer"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={isPostDetail ? '作者与目录' : '社区动态'} aria-label={isArticleAside ? articleAsideLabel : '社区动态'}
> >
<div className="aside-drawer-head"> <div className="aside-drawer-head">
<span>{isPostDetail ? '作者与目录' : '社区动态'}</span> <span>{isArticleAside ? articleAsideLabel : '社区动态'}</span>
<button <button
ref={asideCloseRef} ref={asideCloseRef}
type="button" type="button"
@@ -934,7 +941,7 @@ export default function MainLayout() {
loading={asideLoading} loading={asideLoading}
asideWidgets={asideWidgets} asideWidgets={asideWidgets}
onPostClick={openPost} onPostClick={openPost}
postDetail={isPostDetail ? { postDetail={isArticleAside ? {
author: postOutline?.author ?? null, author: postOutline?.author ?? null,
publishedAt: postOutline?.publishedAt, publishedAt: postOutline?.publishedAt,
viewCount: postOutline?.viewCount, viewCount: postOutline?.viewCount,

View File

@@ -1,13 +1,18 @@
import { useParams } from 'react-router-dom'; import { useEffect, useMemo, useRef } from 'react';
import { useOutletContext, useParams } from 'react-router-dom';
import NotFoundPage from './NotFoundPage'; import NotFoundPage from './NotFoundPage';
import { api } from '../api/client'; import { api } from '../api/client';
import type { SitePage } from '../api/types'; import type { SitePage } from '../api/types';
import ArticleOutline from '../components/ArticleOutline';
import PostContent from '../components/PostContent'; import PostContent from '../components/PostContent';
import { usePageSEO } from '../hooks/usePageSEO'; import { usePageSEO } from '../hooks/usePageSEO';
import { parsePermalinkSlug, pagePath } from '../utils/permalink'; import { parsePermalinkSlug, pagePath } from '../utils/permalink';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { useSessionResource } from '../hooks/useSessionResource'; import { useSessionResource } from '../hooks/useSessionResource';
import type { LayoutCtx } from '../layouts/MainLayout';
import { extractHeadingsFromHtml } from '../utils/postHeadings';
import { renderPostContentHtml } from '../utils/postContent';
/** 自定义单页(关于我们、版规等) */ /** 自定义单页(关于我们、版规等) */
export default function SitePageView() { export default function SitePageView() {
@@ -15,12 +20,25 @@ export default function SitePageView() {
const slug = parsePermalinkSlug(rawSlug); const slug = parsePermalinkSlug(rawSlug);
const { limits } = useForumLimits(); const { limits } = useForumLimits();
const { user } = useAuth(); const { user } = useAuth();
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
const pageRef = useRef<HTMLDivElement>(null);
const { data: page, loading } = useSessionResource<SitePage | null>( const { data: page, loading } = useSessionResource<SitePage | null>(
slug ? `sitepage:${slug}` : null, slug ? `sitepage:${slug}` : null,
() => api.page(slug).then(d => d.page), () => api.page(slug).then(d => d.page),
{ enabled: !!slug }, { enabled: !!slug },
); );
const notFound = !slug || (!loading && !page); const notFound = !slug || (!loading && !page);
const isLoggedIn = !!user;
const pageContent = page?.content ?? '';
// 同步从正文派生目录,与帖子详情共用右侧栏目录布局
const headings = useMemo(() => {
if (!pageContent.trim()) return [];
const rendered = renderPostContentHtml(pageContent, isLoggedIn, {
openLinksInNewTab: limits.open_content_links_in_new_tab,
});
return extractHeadingsFromHtml(rendered);
}, [pageContent, isLoggedIn, limits.open_content_links_in_new_tab]);
usePageSEO({ usePageSEO({
title: page?.title, title: page?.title,
@@ -29,19 +47,42 @@ export default function SitePageView() {
ogType: 'article', ogType: 'article',
}); });
useEffect(() => {
if (loading || !page) {
setPostOutline({ headings: [], scrollRoot: null, title: '文章目录' });
return () => setPostOutline(null);
}
setPostOutline({
headings,
scrollRoot: pageRef.current,
title: '文章目录',
});
return () => setPostOutline(null);
}, [headings, loading, page, setPostOutline]);
if (!slug) return <NotFoundPage title="页面不存在" />; if (!slug) return <NotFoundPage title="页面不存在" />;
if (loading) return null; if (loading) return null;
if (notFound || !page) return <NotFoundPage title="页面不存在" description="该页面不存在或未发布" />; if (notFound || !page) return <NotFoundPage title="页面不存在" description="该页面不存在或未发布" />;
return ( return (
<div className="page-wrap"> <div className="page-wrap" ref={pageRef}>
<article className="site-page"> <article className="site-page">
<header className="site-page__head"> <header className="site-page__head">
<h1>{page.title}</h1> <h1>{page.title}</h1>
</header> </header>
{isMobile && headings.length > 0 && (
<details className="post-detail-toc-mobile">
<summary>{headings.length}</summary>
<ArticleOutline
headings={headings}
scrollRoot={pageRef.current}
title="目录"
/>
</details>
)}
<PostContent <PostContent
html={page.content} html={page.content}
isLoggedIn={!!user} isLoggedIn={isLoggedIn}
className="site-page__body post-detail-content" className="site-page__body post-detail-content"
/> />
</article> </article>