Skip to main content

yggdrasil/
router.rs

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