Skip to main content

yggdrasil/components/
admin_layout.rs

1//! 后台管理布局组件
2//!
3//! 提供全新设计的柔和/软扁平化风格的管理员专属后台布局。
4//! 采用圆角矩形、大空间距与友好的交互设计。
5
6use dioxus::prelude::*;
7use dioxus::router::components::Link;
8
9use crate::api::auth::{get_current_user, logout};
10use crate::components::admin_skeleton::AdminDashboardSkeleton;
11
12use crate::components::write_skeleton::WriteSkeleton;
13use crate::context::UserContext;
14use crate::router::Route;
15use crate::theme::ThemeToggle;
16
17#[component]
18pub fn AdminLayout() -> Element {
19    let mut ctx: UserContext = use_context();
20    let navigator = dioxus::router::navigator();
21    let route = use_route::<Route>();
22
23    use_effect(move || {
24        if !(ctx.checked)() {
25            (ctx.checked).set(true);
26            spawn(async move {
27                match get_current_user().await {
28                    Ok(response) => {
29                        if let Some(user) = response.user {
30                            ctx.user.set(Some(std::sync::Arc::new(user)));
31                        } else {
32                            let _ = navigator.push(Route::Login {});
33                        }
34                    }
35                    Err(_) => {
36                        let _ = navigator.push(Route::Login {});
37                    }
38                }
39            });
40        }
41    });
42
43    let admin_nav_items = vec![
44        (Route::Admin {}, "仪表盘"),
45        (Route::Write {}, "写文章"),
46        (Route::Posts {}, "管理文章"),
47        (Route::Assets {}, "素材"),
48        (Route::FriendsAdmin {}, "友链"),
49        (Route::Runner {}, "试运行"),
50        (Route::Mcp {}, "MCP"),
51        (Route::System {}, "系统"),
52    ];
53
54    let is_write_route =
55        matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
56    // 「管理文章」高亮:单路由 /admin/posts(列表 + 回收站 tab 均归此项)。
57    let is_posts_route = matches!(route, Route::Posts {});
58
59    // 所有 admin 页面共用同一 shell:外层圆角卡片(滚动容器) + 内部 main 负责居中限宽。
60    // write 路由例外:卡片不滚动(overflow-hidden),main 作为 flex 容器不带头尾 padding,
61    // 由 write 页面自身组织 [内容区 flex-1 overflow-y-auto] + [底栏 flex-shrink-0] 的分区布局,
62    // 这样底栏永远贴卡片底部不随内容滚动,也不会出现 sticky + 负 margin 的跳动。
63    let card_overflow = if is_write_route {
64        "overflow-hidden"
65    } else {
66        "overflow-y-auto"
67    };
68    let main_class = if is_write_route {
69        "flex-1 w-full max-w-7xl mx-auto flex flex-col min-h-0"
70    } else {
71        "flex-1 w-full max-w-7xl mx-auto px-6 py-12"
72    };
73
74    let root_class =
75        "min-h-dvh flex bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] font-sans";
76
77    let nav_content = rsx! {
78        aside { class: "w-64 flex-shrink-0 hidden md:flex flex-col h-screen sticky top-0 p-4 bg-[var(--color-paper-entry)]",
79            // Logo
80            div { class: "mb-10 px-4",
81                Link {
82                    class: "font-extrabold text-2xl tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors",
83                    to: Route::Home {},
84                    "Yggdrasil."
85                }
86            }
87            // Nav Items
88            nav { class: "flex-1 flex flex-col gap-2",
89                for (dest, label) in admin_nav_items {
90                    {
91                        let is_active = route == dest
92                            || (label == "写文章" && is_write_route)
93                            || (label == "管理文章" && is_posts_route);
94                        let base_class = "flex items-center px-4 py-3 rounded-2xl text-sm font-medium transition-all";
95                        let text_class = if is_active {
96                            "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
97                        } else {
98                            "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
99                        };
100                        rsx! {
101                            Link { key: "{label}", class: "{base_class} {text_class}", to: dest, "{label}" }
102                        }
103                    }
104                }
105            }
106            // Bottom Tools
107            div { class: "mt-auto pt-6 border-t border-[var(--color-paper-border)] flex items-center justify-between px-4",
108                ThemeToggle {}
109                button {
110                    class: "text-sm font-medium px-4 py-2 rounded-2xl bg-[var(--color-paper-theme)] border border-[var(--color-paper-border)] shadow-sm hover:shadow-md transition-all text-[var(--color-paper-secondary)] hover:text-red-500 cursor-pointer",
111                    onclick: move |_| {
112                        spawn(async move {
113                            let _ = logout().await;
114                            ctx.user.set(None);
115                            ctx.checked.set(false);
116                            let _ = navigator.push(Route::Login {});
117                        });
118                    },
119                    "退出"
120                }
121            }
122        }
123    };
124
125    match ((ctx.checked)(), (ctx.user)()) {
126        (true, Some(_)) => {
127            rsx! {
128                div { class: "{root_class}",
129                    {nav_content}
130                    div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
131                        div { class: "flex-1 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] {card_overflow} relative flex flex-col",
132                            main { class: "{main_class}", Outlet::<Route> {} }
133                        }
134                    }
135                }
136            }
137        }
138        _ => {
139            rsx! {
140                div { class: "{root_class}",
141                    {nav_content}
142                    div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
143                        div { class: "flex-1 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] overflow-hidden relative flex flex-col",
144                            main { class: "{main_class}",
145                                // flex-1 撑满 main(使 write 骨架屏能引用到确定高度),
146                                // 非 write 页面的 py-12 padding 由 main_class 自带,这里不重复加。
147                                div { class: "flex-1 min-h-0 flex flex-col animate-pulse",
148                                    {
149                                        match route {
150                                            Route::Write {} | Route::WriteEdit { .. } => rsx! {
151                                                WriteSkeleton {}
152                                            },
153                                            _ => rsx! {
154                                                AdminDashboardSkeleton {}
155                                            },
156                                        }
157                                    }
158                                }
159                            }
160                        }
161                    }
162                }
163            }
164        }
165    }
166}