Skip to main content

yggdrasil/pages/
not_found.rs

1//! 404 与页面加载失败页面。
2//!
3//! [`NotFound`]:访问未命中任何路由的地址,或页面数据报告“不存在”时渲染。
4//! 一句话说明、写错的地址、返回首页 / 搜索,以及按地址里的词匹配到的「也许你在找」。
5//! [`PageLoadFailed`]:页面数据加载失败(非 404)时的兜底卡片。
6
7use leptos::prelude::*;
8use leptos::task::spawn_local;
9use leptos_meta::Title;
10use leptos_router::hooks::use_location;
11
12use crate::api::posts::{recommend_for_path, PathRecommendations};
13use crate::components::empty_state::{EmptyState, EmptyStateAction};
14use crate::components::mascot::Mascot;
15use crate::components::ui::{Icon, IconName};
16
17/// 404 页面组件。
18///
19/// 展示 404 标记、说明、当前地址、返回首页与搜索,以及推荐文章。渲染时把响应状态置为 404
20/// (需在首个响应块发出前渲染:`SsrMode::Async` 路由与未匹配路由总是满足;流式路由只有在渲染
21/// NotFound 的分支之前不 await 任何数据时才满足。依赖服务端数据判定 404 的页面必须用
22/// `SsrMode::Async`。)
23#[component]
24pub fn NotFound() -> impl IntoView {
25    crate::request_ctx::set_status(404);
26    let location = use_location();
27    // 当前地址(路径 + 查询),错误冒泡时路由仍是原页面,同样取当前地址。
28    let path = Memo::new(move |_| {
29        let pathname = location.pathname.get();
30        let search = location.search.get();
31        if search.is_empty() {
32            pathname
33        } else {
34            format!("{pathname}?{search}")
35        }
36    });
37
38    view! {
39        <Title text="页面不存在 · Yggdrasil"/>
40        <div class="not-found page-enter">
41            {Mascot::Panic.img("mascot not-found-mascot")}
42            <p class="not-found-code tabular-nums">"404"</p>
43            <h1 class="not-found-title">"这个页面不存在"</h1>
44            <p class="not-found-description">"地址可能写错了,或者页面已经移走。"</p>
45            <p class="not-found-path">
46                <code title=move || path.get()>{move || path.get()}</code>
47            </p>
48            <div class="not-found-actions">
49                <button
50                    type="button"
51                    class="btn btn-primary btn-lg"
52                    on:click=move |_| crate::navigation::push("/")
53                >
54                    "返回首页"
55                </button>
56                <a class="btn btn-secondary btn-lg" href="/search">
57                    <Icon name=IconName::Search size=16/>
58                    "搜索"
59                </a>
60            </div>
61            <Recommendations path=path/>
62        </div>
63    }
64}
65
66/// 页面加载失败(非 404):网络或服务出了点问题,可返回首页。
67///
68/// 渲染时把响应状态置为 500(同样只在响应头尚可修改时生效)。
69#[component]
70pub fn PageLoadFailed() -> impl IntoView {
71    crate::request_ctx::set_status(500);
72    view! {
73        <div class="flex min-h-[50vh] items-center justify-center px-6 md:min-h-[55vh] animate-page-enter">
74            <EmptyState
75                mascot=Mascot::Cry
76                title="页面加载失败"
77                description="网络或服务出了点问题,稍后再试一次。"
78                action=EmptyStateAction::new("返回首页", move |_| crate::navigation::push("/"))
79            />
80        </div>
81    }
82}
83
84/// 「也许你在找」:无命中或请求失败时整块不显示。
85///
86/// 只在浏览器里请求(Effect 不在 SSR 中执行);地址变化时重新请求,
87/// 用世代号丢弃过期响应。
88#[component]
89fn Recommendations(#[prop(into)] path: Signal<String>) -> impl IntoView {
90    let result = RwSignal::new(None::<PathRecommendations>);
91    let generation = StoredValue::new(0u64);
92    Effect::new(move |_| {
93        let path = path.get();
94        let current = generation.get_value() + 1;
95        generation.set_value(current);
96        result.set(None);
97        spawn_local(async move {
98            if let Ok(found) = recommend_for_path(path).await {
99                if generation.try_get_value() == Some(current) {
100                    let _ = result.try_set(Some(found));
101                }
102            }
103        });
104    });
105
106    move || {
107        let found = result
108            .try_get()
109            .flatten()
110            .filter(|found| !found.posts.is_empty())?;
111        let words = found.words.join("」「");
112        Some(view! {
113            <section class="not-found-maybe" aria-labelledby="not-found-maybe-title">
114                <h2 id="not-found-maybe-title" class="section-label">
115                    "也许你在找"
116                </h2>
117                <ul>
118                    {found
119                        .posts
120                        .into_iter()
121                        .map(|post| {
122                            let href = crate::routes::Route::PostDetail {
123                                slug: post.slug.clone(),
124                            }
125                                .to_string();
126                            let date = post
127                                .published_at
128                                .map(|at| {
129                                    view! {
130                                        <time class="tabular-nums">
131                                            {crate::utils::time::ymd(&at, '.')}
132                                        </time>
133                                    }
134                                });
135                            view! {
136                                <li>
137                                    <a class="not-found-row" href=href>
138                                        <span>{post.title}</span>
139                                        {date}
140                                    </a>
141                                </li>
142                            }
143                        })
144                        .collect_view()}
145                </ul>
146                <p class="not-found-note">{format!("按地址里的词「{words}」匹配到的文章。")}</p>
147            </section>
148        })
149    }
150}