Skip to main content

yggdrasil/
router.rs

1//! 全站路由配置
2//!
3//! 使用 Dioxus Router 定义前端路由层级,包含前台布局、后台管理布局、
4//! 独立的登录与注册页面。`Route` 枚举上的 `#[route("/path")]` 属性
5//! 既用于生成 URL 匹配规则,也用于组件导航。
6
7use dioxus::prelude::*;
8use std::sync::Arc;
9
10use crate::components::admin_layout::AdminLayout;
11use crate::components::frontend_layout::FrontendLayout;
12use crate::context::UserContext;
13use crate::pages::about::About;
14use crate::pages::admin::{
15    Admin, AdminComments, AdminCommentsPage, Assets, FriendsAdmin, Logs, Mcp, PostPreview, Posts,
16    PostsTrash, Profile, Runner, SiteSettingsPage, System, Write, WriteEdit,
17};
18use crate::pages::archives::Archives;
19use crate::pages::changelog::Changelog;
20use crate::pages::friends::Friends;
21use crate::pages::home::{Home, HomePage};
22use crate::pages::login::Login;
23use crate::pages::not_found::NotFound;
24use crate::pages::post_detail::PostDetail;
25use crate::pages::register::Register;
26use crate::pages::search::Search;
27use crate::pages::tags::{TagDetail, Tags};
28use crate::theme::{use_theme_provider, ThemePreload};
29
30/// 全站路由枚举,每个变体对应一个页面路径
31#[derive(Clone, Routable, Debug, PartialEq)]
32#[rustfmt::skip]
33pub enum Route {
34    // 前台页面共享布局,最外层嵌套错误边界布局以拦截报错
35    #[layout(ErrorLayout)]
36        #[layout(FrontendLayout)]
37            /// 首页
38            #[route("/")]
39            Home {},
40            /// 首页分页
41            #[route("/page/:page")]
42            HomePage { page: i32 },
43            /// 文章归档页
44            #[route("/archives")]
45            Archives {},
46            /// 标签列表页
47            #[route("/tags")]
48            Tags {},
49            /// 单个标签下的文章列表
50            #[route("/tags/:tag")]
51            TagDetail { tag: String },
52            /// 文章详情页,按 slug 匹配
53            #[route("/post/:slug")]
54            PostDetail { slug: String },
55            /// 搜索页
56            #[route("/search")]
57            Search {},
58            /// 关于页面
59            #[route("/about")]
60            About {},
61            /// 友链页
62            #[route("/friends")]
63            Friends {},
64            /// 更新日志页
65            #[route("/changelog")]
66            Changelog {},
67            /// 404 兜底路由,匹配所有未命中路径
68            #[route("/:..segments")]
69            NotFound { segments: Vec<String> },
70        #[end_layout]
71    #[end_layout]
72
73    // 后台管理路由嵌套在 `/admin` 下
74    #[nest("/admin")]
75    // 后台页面共享管理布局
76    #[layout(AdminLayout)]
77        /// 后台仪表盘
78        #[route("/")]
79        Admin {},
80        /// 写文章页
81        #[route("/write")]
82        Write {},
83        /// 编辑文章页
84        #[route("/write/:id")]
85        WriteEdit { id: i32 },
86        /// 草稿/文章预览(管理员只读)
87        #[route("/preview/:slug")]
88        PostPreview { slug: String },
89        /// 文章管理(全部文章列表)
90        #[route("/posts")]
91        Posts {},
92        /// 回收站(已软删除文章;原 /admin/posts 的客户端 tab,独立路由后归「内容管理」子菜单)
93        #[route("/posts/trash")]
94        PostsTrash {},
95        /// 素材管理(上传图片注册表:浏览/搜索/删除/孤儿清理)
96        #[route("/assets")]
97        Assets {},
98        /// 友链管理
99        #[route("/friends")]
100        FriendsAdmin {},
101        /// 评论管理
102        #[route("/comments")]
103        AdminComments {},
104        /// 评论管理分页
105        #[route("/comments/:page")]
106        AdminCommentsPage { page: i32 },
107        /// 系统管理(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)
108        #[route("/system")]
109        System {},
110        /// 代码试运行沙箱(作者预览可运行代码块输出)
111        #[route("/runner")]
112        Runner {},
113        /// MCP 令牌管理 + 客户端配置生成
114        #[route("/mcp")]
115        Mcp {},
116        /// 运行日志(进程内日志查询 + SSE 实时流 + 导出)
117        #[route("/logs")]
118        Logs {},
119        /// 站点配置(页脚 GitHub 链接等公开配置)
120        #[route("/settings")]
121        SiteSettingsPage {},
122        /// 个人信息(当前账号的展示资料与密码)
123        #[route("/profile")]
124        Profile {},
125    #[end_layout]
126    #[end_nest]
127
128    /// 登录页面
129    #[route("/login")]
130    Login {},
131    /// 注册页面
132    #[route("/register")]
133    Register {},
134}
135
136/// 应用路由器组件
137///
138/// 初始化主题提供者、全局用户上下文,并挂载样式表与 `Router`。
139#[component]
140pub fn AppRouter() -> Element {
141    let _theme = use_theme_provider();
142
143    // 提供全局用户上下文,供登录状态与路由守卫使用
144    let user = use_signal(|| None::<Arc<crate::models::user::PublicUser>>);
145    let checked = use_signal(|| false);
146    use_context_provider(|| UserContext { user, checked });
147
148    rsx! {
149        document::Stylesheet { href: "/style.css" }
150        document::Stylesheet { href: "/highlight.css" }
151        document::Title { "Yggdrasil Blog" }
152        document::Link { rel: "icon", href: "/favicon.ico" }
153        div {
154            ThemePreload {}
155            Router::<Route> {}
156        }
157    }
158}
159
160#[component]
161fn ErrorLayout() -> Element {
162    rsx! {
163        ErrorBoundary {
164            handle_error: move |err: ErrorContext| {
165                // 克隆一份错误边界句柄,供 fallback 内的「返回首页」按钮清除错误。
166                // ErrorContext 内部是 Rc<RefCell<...>>,clone 廉价。
167                // 不清除就导航会卡死:ErrorBoundary 持有错误时永远渲染 fallback,
168                // 不渲染 children(Outlet),导致 URL 变了但页面不变。
169                let err_ctx = err.clone();
170                // Commit the status code on the server side
171                #[cfg(feature = "server")]
172                {
173                    if let Some(captured_error) = err.error() {
174                        let _ = dioxus::fullstack::FullstackContext::commit_error_status(
175                            captured_error,
176                        );
177                    }
178                }
179                let mut is_404 = false;
180                if let Some(captured_error) = err.error() {
181                    if let Some(ServerFnError::ServerError { code, message, .. }) = captured_error
182                        .downcast_ref::<ServerFnError>()
183
184                    {
185                        if *code == 404 || message == "not_found" {
186                            is_404 = true;
187                        }
188                    } else { // Warning / Alert Icon in a soft red container
189                        let err_str = format!("{:?}", captured_error);
190                        if err_str.contains("NotFound") || err_str.contains("404")
191                            || err_str.contains("not found")
192                        {
193                            is_404 = true;
194                        }
195                    }
196                }
197                if is_404 {
198                    rsx! {
199                        NotFound { segments: vec![] }
200                    }
201                } else {
202                    rsx! {
203                        div { class: "flex flex-col items-center justify-center min-h-[50vh] md:min-h-[55vh] px-6 animate-page-enter",
204                            div { class: "w-full max-w-md bg-paper-entry border border-paper-border rounded-[2rem] p-8 md:p-10 shadow-sm hover:border-paper-secondary transition-all duration-300 flex flex-col items-center text-center",
205                                // Warning / Alert Icon in a soft red container
206                                div { class: "relative mb-6",
207                                    div { class: "w-16 h-16 bg-red-500/10 dark:bg-red-500/20 text-red-500 rounded-full flex items-center justify-center",
208                                        svg {
209                                            xmlns: "http://www.w3.org/2000/svg",
210                                            width: "28",
211                                            height: "28",
212                                            view_box: "0 0 24 24",
213                                            fill: "none",
214                                            stroke: "currentColor",
215                                            stroke_width: "2",
216                                            stroke_linecap: "round",
217                                            stroke_linejoin: "round",
218                                            circle { cx: "12", cy: "12", r: "10" }
219                                            line {
220                                                x1: "12",
221                                                y1: "8",
222                                                x2: "12",
223                                                y2: "12",
224                                            }
225                                            line {
226                                                x1: "12",
227                                                y1: "16",
228                                                x2: "12.01",
229                                                y2: "16",
230                                            }
231                                        }
232                                    }
233                                }
234
235                                // Premium Typography
236                                h1 { class: "text-lg md:text-xl font-bold tracking-tight text-paper-primary mb-3",
237                                    "加载失败"
238                                }
239                                p { class: "text-sm text-paper-secondary leading-relaxed mb-8 max-w-[280px]",
240                                    "抱歉,加载页面时出现了一些错误,请稍后再试。"
241                                }
242
243                                // CTA: 清除错误边界后命令式导航回首页。
244                                // 必须用按钮而非 Link —— Link 无法在导航前清除错误,
245                                // 会导致 ErrorBoundary 卡在 fallback,页面不随路由切换更新。
246                                button {
247                                    r#type: "button",
248                                    onclick: {
249                                        let err_ctx = err_ctx.clone();
250                                        move |_| {
251                                            err_ctx.clear_errors();
252                                            let _ = dioxus::router::navigator().push(Route::Home {});
253                                        }
254                                    },
255                                    class: "group inline-flex items-center gap-2 px-6 py-2.5 text-sm font-medium text-paper-primary bg-paper-theme border border-paper-border rounded-full hover:border-paper-secondary hover:bg-paper-border transition-all duration-200 cursor-pointer shadow-sm active:scale-[0.98]",
256                                    svg {
257                                        xmlns: "http://www.w3.org/2000/svg",
258                                        width: "16",
259                                        height: "16",
260                                        view_box: "0 0 24 24",
261                                        fill: "none",
262                                        stroke: "currentColor",
263                                        stroke_width: "2",
264                                        stroke_linecap: "round",
265                                        stroke_linejoin: "round",
266                                        class: "transition-transform group-hover:-translate-x-0.5",
267                                        path { d: "M19 12H5M12 19l-7-7 7-7" }
268                                    }
269                                    "返回首页"
270                                }
271                            }
272                        }
273                    }
274                }
275            },
276            Outlet::<Route> {}
277        }
278    }
279}