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::skeletons::admin_comments_skeleton::AdminCommentsSkeleton;
11use crate::components::skeletons::assets_skeleton::AssetsSkeleton;
12use crate::components::skeletons::dashboard_skeleton::AdminDashboardSkeleton;
13
14use crate::components::skeletons::friends_admin_skeleton::FriendsAdminSkeleton;
15use crate::components::skeletons::logs_skeleton::LogsSkeleton;
16use crate::components::skeletons::mcp_skeleton::McpSkeleton;
17use crate::components::skeletons::post_preview_skeleton::PostPreviewSkeleton;
18use crate::components::skeletons::posts_skeleton::PostsSkeleton;
19use crate::components::skeletons::posts_trash_skeleton::PostsTrashSkeleton;
20use crate::components::skeletons::profile_skeleton::ProfileSkeleton;
21use crate::components::skeletons::runner_skeleton::RunnerSkeleton;
22use crate::components::skeletons::settings_admin_skeleton::SettingsAdminSkeleton;
23use crate::components::skeletons::system_skeleton::SystemSkeleton;
24use crate::components::skeletons::write_skeleton::WriteSkeleton;
25use crate::components::ui::UserAvatar;
26use crate::context::UserContext;
27use crate::router::Route;
28use crate::theme::ThemeToggle;
29
30#[component]
31pub fn AdminLayout() -> Element {
32    let mut ctx: UserContext = use_context();
33    let navigator = dioxus::router::navigator();
34    let route = use_route::<Route>();
35
36    use_effect(move || {
37        if !(ctx.checked)() {
38            (ctx.checked).set(true);
39            spawn(async move {
40                match get_current_user().await {
41                    Ok(response) => {
42                        if let Some(user) = response.user {
43                            ctx.user.set(Some(std::sync::Arc::new(user)));
44                        } else {
45                            let _ = navigator.push(Route::Login {});
46                        }
47                    }
48                    Err(_) => {
49                        let _ = navigator.push(Route::Login {});
50                    }
51                }
52            });
53        }
54    });
55
56    let nav_items_top = vec![(Route::Admin {}, "仪表盘"), (Route::Write {}, "写文章")];
57    let nav_items_bottom = vec![(Route::Assets {}, "素材"), (Route::FriendsAdmin {}, "友链")];
58
59    let is_write_route =
60        matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
61    // settings 与 write 一样自组织内部滚动(页头/左菜单固定,右侧内容列 overflow-y-auto),
62    // 需要卡片与 main 只提供有界高度而不滚动。注意不能用 h-full 百分比链条:
63    // main 是 flex item 且 min-height:auto,长内容会先把 main 撑大、百分比再相对
64    // 撑大后的高度解析,形成循环依赖导致约束失效(实证于限流分区)。
65    let internal_scroll_route = is_write_route
66        || matches!(route, Route::SiteSettingsPage {})
67        || matches!(route, Route::Logs {});
68
69    // 所有 admin 页面共用同一 shell:外层圆角卡片(滚动容器) + 内部 main 负责居中限宽。
70    // write/settings 路由例外:卡片不滚动(overflow-hidden),main 作为 flex 容器不带头尾 padding,
71    // 由页面自身组织 [内容区 flex-1 min-h-0 overflow-y-auto] 的分区布局,
72    // 这样固定区永远贴卡片边缘不随内容滚动,也不会出现 sticky + 负 margin 的跳动。
73    let card_overflow = if internal_scroll_route {
74        "overflow-hidden"
75    } else {
76        "overflow-y-auto"
77    };
78    let main_class = if internal_scroll_route {
79        "flex-1 w-full max-w-7xl mx-auto flex flex-col min-h-0"
80    } else {
81        "flex-1 w-full max-w-7xl mx-auto px-6 py-12"
82    };
83
84    let root_class =
85        "min-h-dvh flex bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] font-sans";
86
87    // 侧栏底部用户卡片(→ /admin/profile)的激活态样式,与导航项同一套 pill 语言。
88    let is_profile_route = matches!(route, Route::Profile {});
89    let chip_state_class = if is_profile_route {
90        "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
91    } else {
92        "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
93    };
94    // 读取一次全局用户上下文(订阅:资料更新后卡片即时刷新)。
95    let side_user = (ctx.user)();
96
97    let nav_content = rsx! {
98        aside { class: "w-48 flex-shrink-0 hidden md:flex flex-col h-screen sticky top-0 p-3 bg-[var(--color-paper-entry)]",
99            // Logo
100            div { class: "mb-8 px-3",
101                // 外链形态(原生 <a href>,浏览器整页加载):admin→前台是跨
102                // layout 分支的导航,客户端路由会在切走 AdminLayout 的
103                // SuspenseBoundary 时触发 dioxus 0.7.10 的双重回收 bug
104                //(cannot reclaim ElementId → interpreter 崩溃 → 后续点击
105                // 全部失效,详见 src/pages/admin/preview.rs 模块文档)。
106                // 整页加载无 boundary 卸载路径,彻底规避。
107                Link {
108                    class: "font-extrabold text-2xl tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors",
109                    to: NavigationTarget::<Route>::External("/".to_string()),
110                    "Yggdrasil."
111                }
112            }
113            // Nav Items
114            nav { class: "flex-1 flex flex-col gap-2",
115                for (dest, label) in nav_items_top {
116                    {
117                        let is_active = route == dest || (label == "写文章" && is_write_route);
118                        let base_class = "flex items-center px-3 py-2.5 rounded-2xl text-sm font-medium transition-all";
119                        let text_class = if is_active {
120                            "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
121                        } else {
122                            "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
123                        };
124                        rsx! {
125                            Link { key: "{label}", class: "{base_class} {text_class}", to: dest, "{label}" }
126                        }
127                    }
128                }
129                // 「内容管理」子菜单:全部文章 / 回收站 / 评论管理(issue #17)。
130                ContentNavGroup {}
131                for (dest, label) in nav_items_bottom {
132                    {
133                        let is_active = route == dest || (label == "写文章" && is_write_route);
134                        let base_class = "flex items-center px-3 py-2.5 rounded-2xl text-sm font-medium transition-all";
135                        let text_class = if is_active {
136                            "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
137                        } else {
138                            "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
139                        };
140                        rsx! {
141                            Link { key: "{label}", class: "{base_class} {text_class}", to: dest, "{label}" }
142                        }
143                    }
144                }
145                ToolsNavGroup {}
146            }
147            // Bottom Tools:用户卡片(→ 个人信息页)+ 主题切换 / 退出
148            div { class: "mt-auto pt-4 border-t border-[var(--color-paper-border)] flex flex-col gap-1",
149                if let Some(user) = side_user {
150                    Link {
151                        class: "flex items-center gap-2.5 px-3 py-2 rounded-2xl text-sm font-medium transition-all {chip_state_class}",
152                        to: Route::Profile {},
153                        UserAvatar {
154                            name: user.display_label().to_string(),
155                            avatar_url: user.avatar_url.clone(),
156                            class: "w-7 h-7 rounded-full text-xs flex-shrink-0 border border-[var(--color-paper-border)]",
157                        }
158                        span { class: "truncate", "{user.display_label()}" }
159                    }
160                } else {
161                    // 登录态校验期间的占位(与骨架屏同语言)。
162                    div { class: "flex items-center gap-2.5 px-3 py-2",
163                        div { class: "w-7 h-7 rounded-full bg-[var(--color-paper-theme)] animate-pulse flex-shrink-0" }
164                        div { class: "h-3.5 w-16 rounded bg-[var(--color-paper-theme)] animate-pulse" }
165                    }
166                }
167                div { class: "flex items-center justify-between px-3",
168                    ThemeToggle {}
169                    button {
170                        class: "text-sm font-medium px-3 py-1.5 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",
171                        onclick: move |_| {
172                            spawn(async move {
173                                let _ = logout().await;
174                                ctx.user.set(None);
175                                ctx.checked.set(false);
176                                let _ = navigator.push(Route::Login {});
177                            });
178                        },
179                        "退出"
180                    }
181                }
182            }
183        }
184    };
185
186    let skeleton_route = route.clone();
187    match ((ctx.checked)(), (ctx.user)()) {
188        (true, Some(_)) => {
189            rsx! {
190                div { class: "{root_class}",
191                    {nav_content}
192                    div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
193                        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",
194                            main { class: "{main_class}",
195                                // 与前台 frontend_layout.rs 同理:admin 内的 use_server_future(...)?
196                                // (如 preview.rs)pending 时会向上抛 RenderError::Suspended;没有
197                                // SuspenseBoundary 时挂起 scope 渲染为空占位节点,主内容区在 server fn
198                                // 往返期间整片空白。fallback 复用登录校验期的同款路由骨架屏(同样的
199                                // flex wrapper + animate-pulse),骨架屏→骨架屏→内容全程无缝、无空白帧。
200                                SuspenseBoundary {
201                                    fallback: move |_| rsx! {
202                                        div { class: "flex-1 min-h-0 flex flex-col animate-pulse", {admin_route_skeleton(&skeleton_route)} }
203                                    },
204                                    Outlet::<Route> {}
205                                }
206                            }
207                        }
208                    }
209                }
210            }
211        }
212        _ => {
213            rsx! {
214                div { class: "{root_class}",
215                    {nav_content}
216                    div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
217                        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",
218                            main { class: "{main_class}",
219                                // flex-1 撑满 main(使 write 骨架屏能引用到确定高度),
220                                // 非 write 页面的 py-12 padding 由 main_class 自带,这里不重复加。
221                                div { class: "flex-1 min-h-0 flex flex-col animate-pulse",
222                                    {admin_route_skeleton(&route)}
223                                }
224                            }
225                        }
226                    }
227                }
228            }
229        }
230    }
231}
232
233/// 根据当前后台路由,渲染对应的专属骨架屏。
234fn admin_route_skeleton(route: &Route) -> Element {
235    match route {
236        Route::Admin {} => rsx! {
237            AdminDashboardSkeleton {}
238        },
239        Route::Write {} | Route::WriteEdit { .. } => rsx! {
240            WriteSkeleton {}
241        },
242        Route::Posts {} => rsx! {
243            PostsSkeleton {}
244        },
245        Route::PostsTrash {} => rsx! {
246            PostsTrashSkeleton {}
247        },
248        Route::Assets {} => rsx! {
249            AssetsSkeleton {}
250        },
251        Route::FriendsAdmin {} => rsx! {
252            FriendsAdminSkeleton {}
253        },
254        Route::AdminComments {} | Route::AdminCommentsPage { .. } => {
255            rsx! {
256                AdminCommentsSkeleton {}
257            }
258        }
259        Route::System {} => rsx! {
260            SystemSkeleton {}
261        },
262        Route::Runner {} => rsx! {
263            RunnerSkeleton {}
264        },
265        Route::Mcp {} => rsx! {
266            McpSkeleton {}
267        },
268        Route::Logs {} => rsx! {
269            LogsSkeleton {}
270        },
271        Route::SiteSettingsPage {} => rsx! {
272            SettingsAdminSkeleton {}
273        },
274        Route::Profile {} => rsx! {
275            ProfileSkeleton {}
276        },
277        Route::PostPreview { .. } => rsx! {
278            PostPreviewSkeleton {}
279        },
280        _ => rsx! {
281            AdminDashboardSkeleton {}
282        },
283    }
284}
285
286/// 「内容管理」子菜单组:全部文章 / 回收站 / 评论管理。
287///
288/// 父项整行点击仅切换展开/收起(不跳转),chevron 旋转 + grid-template-rows
289/// 0fr↔1fr 过渡动画(复用 posts_trash.rs AutoPurgeSettings 的既有模式)。
290/// 当前路由落在组内时自动展开,保证激活子项始终可见;用户手动收起后,
291/// 仅当再次从组外导航进入组内路由时才重新展开。
292#[component]
293fn ContentNavGroup() -> Element {
294    let route = use_route::<Route>();
295    // 判断路由是否属于本组(回收站/评论分页路由一并归入)。
296    fn in_group(route: &Route) -> bool {
297        matches!(
298            route,
299            Route::Posts {}
300                | Route::PostsTrash {}
301                | Route::AdminComments {}
302                | Route::AdminCommentsPage { .. }
303        )
304    }
305    let group_active = in_group(&route);
306    let mut expanded = use_signal(|| group_active);
307
308    // 路由从组外进入组内时自动展开。闭包内读 router().current 建立
309    // ReactiveContext 订阅(仓库约定 #5),路由变化时本 effect 重跑。
310    use_effect(move || {
311        let current = router().current::<Route>();
312        if in_group(&current) {
313            expanded.set(true);
314        }
315    });
316
317    let chevron_rotate = if expanded() { "rotate-180" } else { "" };
318    // 父项样式:与顶层导航项同盒模型;组内路由激活时仅提为 primary 文字色,
319    // 不给自己加 pill(pill 高亮由激活子项承担,避免双层高亮竞争)。
320    let parent_text_class = if group_active {
321        "text-[var(--color-paper-primary)] border border-transparent"
322    } else {
323        "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
324    };
325
326    rsx! {
327        div { class: "flex flex-col gap-1",
328            button {
329                class: "flex items-center justify-between w-full px-3 py-2.5 rounded-2xl text-sm font-medium transition-all cursor-pointer {parent_text_class}",
330                onclick: move |_| expanded.set(!expanded()),
331                span { "内容管理" }
332                svg {
333                    class: "w-4 h-4 transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
334                    view_box: "0 0 24 24",
335                    fill: "none",
336                    stroke: "currentColor",
337                    stroke_width: "2",
338                    path {
339                        stroke_linecap: "round",
340                        stroke_linejoin: "round",
341                        d: "M19 9l-7 7-7-7",
342                    }
343                }
344            }
345            // 展开动画容器:与 AutoPurgeSettings 完全同款(grid 0fr↔1fr + 内层 overflow-hidden)。
346            div {
347                class: "grid transition-all duration-300 ease-in-out",
348                style: if expanded() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
349                div { class: "overflow-hidden min-h-0",
350                    // 左侧竖线引导 + 缩进表示层级。
351                    div { class: "ml-3 pl-2.5 border-l border-[var(--color-paper-border)] flex flex-col gap-1",
352                        for (dest, label, active) in [
353                            (Route::Posts {}, "全部文章", matches!(route, Route::Posts {})),
354                            (Route::PostsTrash {}, "回收站", matches!(route, Route::PostsTrash {})),
355                            (
356                                Route::AdminComments {},
357                                "评论管理",
358                                matches!(route, Route::AdminComments {} | Route::AdminCommentsPage { .. }),
359                            ),
360                        ]
361                        {
362                            Link {
363                                key: "{label}",
364                                class: if active { "flex items-center px-2.5 py-1.5 rounded-xl text-sm font-medium transition-all bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]" } else { "flex items-center px-2.5 py-1.5 rounded-xl text-sm font-medium transition-all text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent" },
365                                to: dest,
366                                "{label}"
367                            }
368                        }
369                    }
370                }
371            }
372        }
373    }
374}
375
376/// 「工具」子菜单组:设置 / 试运行 / MCP / 日志 / 系统(issue #26)。
377///
378/// 父项整行点击仅切换展开/收起(不跳转),chevron 旋转 + grid-template-rows
379/// 0fr↔1fr 过渡动画(复用 posts_trash.rs AutoPurgeSettings 的既有模式)。
380/// 当前路由落在组内时自动展开,保证激活子项始终可见;用户手动收起后,
381/// 仅当再次从组外导航进入组内路由时才重新展开。
382#[component]
383fn ToolsNavGroup() -> Element {
384    let route = use_route::<Route>();
385    // 判断路由是否属于本组。
386    fn in_group(route: &Route) -> bool {
387        matches!(
388            route,
389            Route::Runner {}
390                | Route::Mcp {}
391                | Route::System {}
392                | Route::SiteSettingsPage {}
393                | Route::Logs {}
394        )
395    }
396    let group_active = in_group(&route);
397    let mut expanded = use_signal(|| group_active);
398
399    // 路由从组外进入组内时自动展开。闭包内读 router().current 建立
400    // ReactiveContext 订阅(仓库约定 #5),路由变化时本 effect 重跑。
401    use_effect(move || {
402        let current = router().current::<Route>();
403        if in_group(&current) {
404            expanded.set(true);
405        }
406    });
407
408    let chevron_rotate = if expanded() { "rotate-180" } else { "" };
409    // 父项样式:与顶层导航项同盒模型;组内路由激活时仅提为 primary 文字色,
410    // 不给自己加 pill(pill 高亮由激活子项承担,避免双层高亮竞争)。
411    let parent_text_class = if group_active {
412        "text-[var(--color-paper-primary)] border border-transparent"
413    } else {
414        "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
415    };
416
417    rsx! {
418        div { class: "flex flex-col gap-1",
419            button {
420                class: "flex items-center justify-between w-full px-3 py-2.5 rounded-2xl text-sm font-medium transition-all cursor-pointer {parent_text_class}",
421                onclick: move |_| expanded.set(!expanded()),
422                span { "工具" }
423                svg {
424                    class: "w-4 h-4 transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
425                    view_box: "0 0 24 24",
426                    fill: "none",
427                    stroke: "currentColor",
428                    stroke_width: "2",
429                    path {
430                        stroke_linecap: "round",
431                        stroke_linejoin: "round",
432                        d: "M19 9l-7 7-7-7",
433                    }
434                }
435            }
436            // 展开动画容器:与 AutoPurgeSettings 完全同款(grid 0fr↔1fr + 内层 overflow-hidden)。
437            div {
438                class: "grid transition-all duration-300 ease-in-out",
439                style: if expanded() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
440                div { class: "overflow-hidden min-h-0",
441                    // 左侧竖线引导 + 缩进表示层级。
442                    div { class: "ml-3 pl-2.5 border-l border-[var(--color-paper-border)] flex flex-col gap-1",
443                        for (dest, label, active) in [
444                            (
445                                Route::SiteSettingsPage {},
446                                "设置",
447                                matches!(route, Route::SiteSettingsPage {}),
448                            ),
449                            (Route::Runner {}, "试运行", matches!(route, Route::Runner {})),
450                            (Route::Mcp {}, "MCP", matches!(route, Route::Mcp {})),
451                            (Route::Logs {}, "日志", matches!(route, Route::Logs {})),
452                            (Route::System {}, "系统", matches!(route, Route::System {})),
453                        ]
454                        {
455                            Link {
456                                key: "{label}",
457                                class: if active { "flex items-center px-2.5 py-1.5 rounded-xl text-sm font-medium transition-all bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]" } else { "flex items-center px-2.5 py-1.5 rounded-xl text-sm font-medium transition-all text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent" },
458                                to: dest,
459                                "{label}"
460                            }
461                        }
462                    }
463                }
464            }
465        }
466    }
467}