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