Skip to main content

yggdrasil/components/
frontend_layout.rs

1//! 前台布局组件
2//!
3//! 包裹所有前台路由,提供统一的 Header、Footer 与主内容区容器,
4//! 并为不同路由在 Suspense 中展示对应的骨架屏。
5//!
6//! 布局在主包里只构建一次(Leptos 组件体只运行一次),随路由变化的部分
7//! (导航激活项、主区宽度、站点分享信息)跟随当前显示的页面
8//! ([`use_rendered_route`][crate::navigation::use_rendered_route]),骨架屏按地址栏指向的目标页选择。
9
10use leptos::children::{ChildrenFn, ViewFn};
11use leptos::error::Errors;
12use leptos::prelude::*;
13use leptos_router::components::Outlet;
14use leptos_router::hooks::use_location;
15
16use crate::api::error::ApiError;
17use crate::components::footer::Footer;
18use crate::components::header::{Header, SearchIconLink};
19use crate::components::nav::build_nav_items;
20use crate::components::palette::use_search_palette_provider;
21use crate::components::search_palette::SearchPalette;
22use crate::components::share_meta::SiteShareMeta;
23use crate::components::skeletons::archive_skeleton::{ArchiveSkeleton, TagDetailSkeleton};
24use crate::components::skeletons::changelog_skeleton::ChangelogSkeleton;
25use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
26use crate::components::skeletons::friends_skeleton::FriendsSkeleton;
27use crate::components::skeletons::home_skeleton::HomeSkeleton;
28use crate::components::skeletons::notes_route_skeleton::NotesRouteSkeleton;
29use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton;
30use crate::components::skeletons::search_skeleton::SearchSkeleton;
31use crate::components::skeletons::writing_guide_skeleton::WritingGuideSkeleton;
32use crate::navigation::use_rendered_route;
33use crate::pages::not_found::{NotFound, PageLoadFailed};
34use crate::route_prefetch::{match_route, RouteTarget};
35use crate::routes::{is_post_path, is_showcase_path};
36use crate::theme::ThemeToggle;
37use crate::utils::error::is_not_found;
38
39/// 路径对应的骨架屏种类。
40///
41/// 纯数据分类,与渲染分离以便单测;渲染见 [`route_skeleton`]。
42#[derive(Debug, Clone, PartialEq)]
43enum SkeletonKind {
44    /// 笔记索引、笔记本与单篇笔记(`/notes` 及其子路径)。
45    Notes,
46    /// 首页分页(`/page/<n>`),页码至少为 1。
47    HomePage(i32),
48    /// 归档。
49    Archives,
50    /// 单个标签(`/tags/<tag>`),携带解码后的标签名。
51    TagDetail(String),
52    /// 友链。
53    Friends,
54    /// 搜索。
55    Search,
56    /// 文章详情。
57    PostDetail,
58    /// 更新日志。
59    Changelog,
60    /// 写作指南。
61    WritingGuide,
62    /// 组件图鉴及其详情。
63    Showcase,
64    /// 其余路径:首页骨架。
65    Fallback,
66}
67
68/// 按路径(`use_location().pathname`,不含查询串)选择骨架屏种类。
69fn skeleton_kind(path: &str) -> SkeletonKind {
70    let path = match path.trim_end_matches('/') {
71        "" => "/",
72        trimmed => trimmed,
73    };
74    if path == "/notes" || path.starts_with("/notes/") {
75        return SkeletonKind::Notes;
76    }
77    if let Some(page) = path
78        .strip_prefix("/page/")
79        .and_then(|page| page.parse::<i32>().ok())
80    {
81        return SkeletonKind::HomePage(page.max(1));
82    }
83    if let Some(tag) = path.strip_prefix("/tags/").filter(|tag| !tag.contains('/')) {
84        return SkeletonKind::TagDetail(
85            percent_encoding::percent_decode_str(tag)
86                .decode_utf8_lossy()
87                .into_owned(),
88        );
89    }
90    if is_post_path(path) {
91        return SkeletonKind::PostDetail;
92    }
93    if is_showcase_path(path) {
94        return SkeletonKind::Showcase;
95    }
96    match path {
97        "/archives" => SkeletonKind::Archives,
98        "/friends" => SkeletonKind::Friends,
99        "/search" => SkeletonKind::Search,
100        "/changelog" => SkeletonKind::Changelog,
101        "/about/writing" => SkeletonKind::WritingGuide,
102        _ => SkeletonKind::Fallback,
103    }
104}
105
106/// 路径对应的骨架屏,立即渲染。
107fn skeleton_view(path: &str) -> AnyView {
108    match skeleton_kind(path) {
109        SkeletonKind::Notes => view! { <NotesRouteSkeleton path=path.to_string()/> }.into_any(),
110        SkeletonKind::HomePage(page) => view! { <HomeSkeleton current_page=page/> }.into_any(),
111        SkeletonKind::Archives => view! { <ArchiveSkeleton/> }.into_any(),
112        SkeletonKind::TagDetail(tag) => view! { <TagDetailSkeleton tag=tag/> }.into_any(),
113        SkeletonKind::Friends => view! {
114            <div class="friends">
115                <FriendsSkeleton/>
116            </div>
117        }
118        .into_any(),
119        SkeletonKind::Search => view! { <SearchSkeleton/> }.into_any(),
120        SkeletonKind::PostDetail => view! { <PostDetailSkeleton/> }.into_any(),
121        SkeletonKind::Changelog => view! { <ChangelogSkeleton/> }.into_any(),
122        SkeletonKind::WritingGuide => view! { <WritingGuideSkeleton/> }.into_any(),
123        SkeletonKind::Showcase => {
124            view! { <div class="py-20" role="status">"正在翻开组件图鉴…"</div> }.into_any()
125        }
126        SkeletonKind::Fallback => view! { <HomeSkeleton/> }.into_any(),
127    }
128}
129
130/// 布局 Suspense 兜底:标签页与图鉴立即显示,其余延迟 200ms(见 `DelayedSkeleton`)。
131fn route_skeleton(path: &str) -> AnyView {
132    if matches!(
133        skeleton_kind(path),
134        SkeletonKind::TagDetail(_) | SkeletonKind::Showcase
135    ) {
136        return skeleton_view(path);
137    }
138    let path = path.to_string();
139    view! { <DelayedSkeleton>{skeleton_view(&path)}</DelayedSkeleton> }.into_any()
140}
141
142/// 懒加载分包挂起期间要占位的前台目标路径。
143///
144/// 地址栏已变、路由器仍显示旧页面时返回目标路径。目标不是前台页面(后台、登录、注册由别的
145/// 外壳渲染;未匹配路径同步渲染 404,不会挂起)时返回 `None`,旧 `main` 整块淡出。
146fn pending_target(target: &str, rendered: &str) -> Option<String> {
147    if target == rendered {
148        return None;
149    }
150    match match_route(target)? {
151        RouteTarget::Login | RouteTarget::Register | RouteTarget::Admin(_) => None,
152        _ => Some(target.to_string()),
153    }
154}
155
156/// 路径对应的主区宽度类:文章页 1240px(680px 正文 + 右侧目录),图鉴 1152px,其余与页头同宽。
157fn main_width_class(path: &str) -> &'static str {
158    if is_post_path(path) {
159        "max-w-[1240px]"
160    } else if is_showcase_path(path) {
161        "max-w-6xl"
162    } else {
163        "max-w-[880px]"
164    }
165}
166
167/// 路由内容渲染出错时按错误类型兜底,不解析展示文本。
168fn route_error_fallback(errors: ArcRwSignal<Errors>) -> impl IntoView {
169    move || {
170        let missing = errors.with(|errors| {
171            errors
172                .iter()
173                .any(|(_, error)| error.downcast_ref::<ApiError>().is_some_and(is_not_found))
174        });
175        if missing {
176            view! { <NotFound/> }.into_any()
177        } else {
178            view! { <PageLoadFailed/> }.into_any()
179        }
180    }
181}
182
183/// 前台整体布局组件。
184///
185/// 负责渲染 Header(含前台导航与主题切换)、主内容区与 Footer。
186/// 骨架屏分两处:懒加载分包挂起时(路由器仍显示旧页面)由 `main` 顶部的 `.route-pending`
187/// 槽位占位目标页骨架,显隐跟随协调器的 `is-route-pending`;页面挂载后的数据读取由
188/// Suspense 兜底显示。
189///
190/// Props:
191/// - `children`:显式传入的页面内容(未匹配路由的 404 兜底用);缺省渲染嵌套路由的 `<Outlet/>`
192#[component]
193pub fn FrontendLayout(#[prop(optional)] children: Option<ChildrenFn>) -> impl IntoView {
194    // 骨架屏占位的是正在加载的目标页,按地址栏选。
195    let target_path = use_location().pathname;
196    // 其余随路由变化的部分跟随已显示的页面:懒加载路由切换期间旧页面仍在,
197    // 外壳提前换成新页面的宽度或导航会把旧页面套进错误的版式。
198    let route = use_rendered_route();
199    let pathname = Memo::new(move |_| route.read().pathname.clone());
200    let nav_items = Memo::new(move |_| build_nav_items(&pathname.read()));
201    let palette = use_search_palette_provider();
202    // 页头与页脚在所有前台页面统一为 880px 索引宽度,栏目切换时外壳不跳动。
203    // 主区按页面放宽:文章页 1240px(680px 正文 + 右侧目录),图鉴 1152px,其余与页头同宽。
204    let max_width = "max-w-[880px]";
205    // 文章页由 PostDetail 写自己的分享元信息,其余页面用站点的。
206    let is_post = Memo::new(move |_| is_post_path(&pathname.read()));
207    let main_width = Signal::derive(move || pathname.with(|path| main_width_class(path)));
208    // 分享元信息里的页面地址:路径加查询串。
209    let share_path = Memo::new(move |_| route.read().key());
210    // 分包迟到时在 main 顶部占位目标页骨架;显隐由 CSS 跟随协调器的 is-route-pending。
211    let pending = Memo::new(move |_| pending_target(&target_path.read(), &pathname.read()));
212
213    view! {
214        <FrontendShell
215            max_width=main_width
216            header=move || {
217                view! {
218                    <Header
219                        max_width=max_width
220                        nav_items=nav_items
221                        right_content=move || {
222                            view! {
223                                <SearchIconLink on_click=move |_: leptos::ev::MouseEvent| {
224                                    palette.open()
225                                }/>
226                                <ThemeToggle icon_only=true/>
227                            }
228                        }
229                    />
230                }
231            }
232            footer=move || view! { <Footer max_width=max_width/> }
233            main_content=move || {
234                let children = children.clone();
235                view! {
236                    // 分包迟到时在 main 顶部占位目标页骨架;显隐由 CSS 跟随协调器的 is-route-pending。
237                    <Show when=move || pending.read().is_some()>
238                        <div
239                            class=move || {
240                                format!(
241                                    "route-pending site-route-pending {}",
242                                    pending.read().as_deref().map_or("", main_width_class),
243                                )
244                            }
245                            aria-hidden="true"
246                        >
247                            {move || pending.get().map(|path| skeleton_view(&path))}
248                        </div>
249                    </Show>
250                    <Show when=move || !is_post.get()>
251                        <SiteShareMeta path=share_path/>
252                    </Show>
253                    // 页面自己的 Suspense 先接住数据读取;这里兜底未被包住的资源读取。
254                    // 懒加载分包未到时路由器保留旧页面、不会走到这里,由上方 .route-pending 占位。
255                    // 错误边界在内层:路由内容出错时仍保留页头页脚与骨架屏逻辑。
256                    <Suspense fallback=move || route_skeleton(&target_path.get_untracked())>
257                        <ErrorBoundary fallback=route_error_fallback>
258                            {match children {
259                                Some(children) => children().into_any(),
260                                None => view! { <Outlet/> }.into_any(),
261                            }}
262                        </ErrorBoundary>
263                    </Suspense>
264                }
265            }
266        />
267        // 放在 main 外:栏目过渡只捕获 main,面板若在其中会被截进正文快照,
268        // 遮罩只盖住正文区并跟着正文一起滑入。
269        <SearchPalette/>
270    }
271}
272
273/// 前台页面与图鉴样例共用的布局关系;路由内容由外层提供。
274///
275/// Props:
276/// - `header` / `main_content` / `footer`:三块内容
277/// - `max_width`:主区宽度类(可随路径变化)
278/// - `min_height`:最外层容器的最小高度类
279#[component]
280pub(crate) fn FrontendShell(
281    #[prop(into)] header: ViewFn,
282    #[prop(into)] main_content: ViewFn,
283    #[prop(into)] footer: ViewFn,
284    #[prop(into, default = "max-w-[880px]".into())] max_width: Signal<&'static str>,
285    #[prop(default = "min-h-screen")] min_height: &'static str,
286) -> impl IntoView {
287    view! {
288        <div class=format!("site-shell {min_height} flex flex-col bg-surface-0")>
289            {header.run()}
290            <main class=move || {
291                format!(
292                    "flex-1 w-full {} mx-auto px-6 py-6 md:py-12 overflow-x-clip",
293                    max_width.get(),
294                )
295            }>{main_content.run()}</main>
296            {footer.run()}
297        </div>
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn error_boundary_does_not_turn_message_text_into_a_404() {
307        for (error, missing) in [
308            (ApiError::NotFound("文章不存在".into()), true),
309            (ApiError::BadRequest("not_found:业务拒绝".into()), false),
310            (ApiError::Internal("not_found".into()), false),
311        ] {
312            let html = Owner::new().with(|| {
313                provide_context(leptos_router::location::RequestUrl::new("/"));
314                view! {
315                    <leptos_router::components::Router>
316                        <ErrorBoundary fallback=route_error_fallback>
317                            {Err::<(), _>(error)}
318                        </ErrorBoundary>
319                    </leptos_router::components::Router>
320                }
321                .to_html()
322            });
323            assert_eq!(html.contains("这个页面不存在"), missing);
324            assert_eq!(html.contains("页面加载失败"), !missing);
325        }
326    }
327
328    #[test]
329    fn skeleton_kind_matches_base_route_mapping() {
330        assert_eq!(skeleton_kind("/notes"), SkeletonKind::Notes);
331        assert_eq!(skeleton_kind("/notes/book/3"), SkeletonKind::Notes);
332        assert_eq!(skeleton_kind("/notes/some-note"), SkeletonKind::Notes);
333        assert_eq!(skeleton_kind("/page/3"), SkeletonKind::HomePage(3));
334        assert_eq!(skeleton_kind("/page/0"), SkeletonKind::HomePage(1));
335        assert_eq!(skeleton_kind("/page/-4"), SkeletonKind::HomePage(1));
336        assert_eq!(skeleton_kind("/archives"), SkeletonKind::Archives);
337        assert_eq!(skeleton_kind("/archives/"), SkeletonKind::Archives);
338        assert_eq!(skeleton_kind("/friends"), SkeletonKind::Friends);
339        assert_eq!(skeleton_kind("/search"), SkeletonKind::Search);
340        assert_eq!(skeleton_kind("/post/hello"), SkeletonKind::PostDetail);
341        assert_eq!(skeleton_kind("/changelog"), SkeletonKind::Changelog);
342        assert_eq!(skeleton_kind("/about/writing"), SkeletonKind::WritingGuide);
343        assert_eq!(skeleton_kind("/about/components"), SkeletonKind::Showcase);
344        assert_eq!(
345            skeleton_kind("/about/components/button"),
346            SkeletonKind::Showcase
347        );
348    }
349
350    #[test]
351    fn skeleton_kind_decodes_tag_name() {
352        assert_eq!(
353            skeleton_kind("/tags/Rust%20%E7%BC%96%E7%A8%8B"),
354            SkeletonKind::TagDetail("Rust 编程".to_string())
355        );
356        // `/tags` 本身不是标签页(服务端重定向到 /archives)。
357        assert_eq!(skeleton_kind("/tags"), SkeletonKind::Fallback);
358    }
359
360    #[test]
361    fn skeleton_kind_falls_back_to_home() {
362        assert_eq!(skeleton_kind("/"), SkeletonKind::Fallback);
363        assert_eq!(skeleton_kind("/about"), SkeletonKind::Fallback);
364        assert_eq!(skeleton_kind("/page/abc"), SkeletonKind::Fallback);
365        assert_eq!(skeleton_kind("/no-such-page"), SkeletonKind::Fallback);
366    }
367
368    #[test]
369    fn main_width_follows_page_kind() {
370        assert_eq!(main_width_class("/post/hello"), "max-w-[1240px]");
371        assert_eq!(main_width_class("/about/components/button"), "max-w-6xl");
372        assert_eq!(main_width_class("/"), "max-w-[880px]");
373        assert_eq!(main_width_class("/archives"), "max-w-[880px]");
374    }
375
376    #[test]
377    fn pending_target_only_covers_frontend_routes() {
378        assert_eq!(pending_target("/archives", "/archives"), None);
379        assert_eq!(
380            pending_target("/archives", "/"),
381            Some("/archives".to_string())
382        );
383        assert_eq!(
384            pending_target("/post/hello", "/"),
385            Some("/post/hello".to_string())
386        );
387        assert_eq!(pending_target("/admin", "/"), None);
388        assert_eq!(pending_target("/admin/logs", "/"), None);
389        assert_eq!(pending_target("/login", "/"), None);
390        assert_eq!(pending_target("/register", "/"), None);
391        assert_eq!(pending_target("/no/such/page", "/"), None);
392    }
393
394    #[test]
395    fn shell_renders_regions_in_order_with_width_class() {
396        let html = Owner::new().with(|| {
397            view! {
398                <FrontendShell
399                    header=|| view! { <header id="h"></header> }
400                    main_content=|| view! { <p id="m"></p> }
401                    footer=|| view! { <footer id="f"></footer> }
402                    max_width="max-w-6xl"
403                    min_height="min-h-0"
404                />
405            }
406            .to_html()
407        });
408        assert!(html.contains(
409            "class=\"flex-1 w-full max-w-6xl mx-auto px-6 py-6 md:py-12 overflow-x-clip\""
410        ));
411        let h = html.find("id=\"h\"").unwrap();
412        let main = html.find("<main").unwrap();
413        let m = html.find("id=\"m\"").unwrap();
414        let f = html.find("id=\"f\"").unwrap();
415        assert!(h < main && main < m && m < f);
416    }
417
418    #[test]
419    fn shell_defaults_to_index_width() {
420        let html = Owner::new().with(|| {
421            view! {
422                <FrontendShell
423                    header=|| ()
424                    main_content=|| ()
425                    footer=|| ()
426                />
427            }
428            .to_html()
429        });
430        assert!(html.contains("flex-1 w-full max-w-[880px] mx-auto"));
431    }
432}