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 { "data-vt-shell": "admin-sidebar", 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                    {nav_item(&route, dest, label, is_write_route)}
117                }
118                // 「内容管理」子菜单:全部文章 / 回收站 / 评论管理(issue #17)。
119                ContentNavGroup {}
120                for (dest, label) in nav_items_bottom {
121                    {nav_item(&route, dest, label, is_write_route)}
122                }
123                ToolsNavGroup {}
124            }
125            // Bottom Tools:用户卡片(→ 个人信息页)+ 主题切换 / 退出
126            div { class: "mt-auto pt-4 border-t border-[var(--color-paper-border)] flex flex-col gap-1",
127                if let Some(user) = side_user {
128                    Link {
129                        class: "flex items-center gap-2.5 px-3 py-2 rounded-2xl text-sm font-medium transition-all {chip_state_class}",
130                        to: Route::Profile {},
131                        UserAvatar {
132                            name: user.display_label().to_string(),
133                            avatar_url: user.avatar_url.clone(),
134                            class: "w-7 h-7 rounded-full text-xs flex-shrink-0 border border-[var(--color-paper-border)]",
135                        }
136                        span { class: "truncate", "{user.display_label()}" }
137                    }
138                } else {
139                    // 登录态校验期间的占位(与骨架屏同语言)。
140                    div { class: "flex items-center gap-2.5 px-3 py-2",
141                        div { class: "w-7 h-7 rounded-full bg-[var(--color-paper-theme)] animate-pulse flex-shrink-0" }
142                        div { class: "h-3.5 w-16 rounded bg-[var(--color-paper-theme)] animate-pulse" }
143                    }
144                }
145                div { class: "flex items-center justify-between px-3",
146                    ThemeToggle {}
147                    button {
148                        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",
149                        onclick: move |_| {
150                            spawn(async move {
151                                if logout().await.is_ok_and(|response| response.success) {
152                                    crate::bridges::navigation::clear_admin_state();
153                                }
154                                ctx.user.set(None);
155                                ctx.checked.set(false);
156                                let _ = navigator.push(Route::Login {});
157                            });
158                        },
159                        "退出"
160                    }
161                }
162            }
163        }
164    };
165
166    let skeleton_route = route.clone();
167    match ((ctx.checked)(), (ctx.user)()) {
168        (true, Some(_)) => {
169            rsx! {
170                div { class: "{root_class}",
171                    {nav_content}
172                    div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
173                        div { "data-vt-scroll": "admin-main", class: "flex-1 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] {card_overflow} relative flex flex-col",
174                            main { class: "{main_class}",
175                                // 与前台 frontend_layout.rs 同理:admin 内的 use_server_future(...)?
176                                // (如 preview.rs)pending 时会向上抛 RenderError::Suspended;没有
177                                // SuspenseBoundary 时挂起 scope 渲染为空占位节点,主内容区在 server fn
178                                // 往返期间整片空白。fallback 复用登录校验期的同款路由骨架屏(同样的
179                                // flex wrapper + animate-pulse),骨架屏→骨架屏→内容全程无缝、无空白帧。
180                                SuspenseBoundary {
181                                    fallback: move |_| rsx! {
182                                        div { class: "flex-1 min-h-0 flex flex-col animate-pulse", {admin_route_skeleton(&skeleton_route)} }
183                                    },
184                                    Outlet::<Route> {}
185                                }
186                            }
187                        }
188                    }
189                }
190            }
191        }
192        _ => {
193            rsx! {
194                div { class: "{root_class}",
195                    {nav_content}
196                    div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
197                        div { "data-vt-scroll": "admin-main", class: "flex-1 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] overflow-hidden relative flex flex-col",
198                            main { class: "{main_class}",
199                                // flex-1 撑满 main(使 write 骨架屏能引用到确定高度),
200                                // 非 write 页面的 py-12 padding 由 main_class 自带,这里不重复加。
201                                div { class: "flex-1 min-h-0 flex flex-col animate-pulse",
202                                    {admin_route_skeleton(&route)}
203                                }
204                            }
205                        }
206                    }
207                }
208            }
209        }
210    }
211}
212
213/// 渲染单个顶层导航项(含激活态判定),供顶部/底部两组导航项列表共用,
214/// 避免同一 pill 高亮逻辑在两处重复。
215fn nav_item(route: &Route, dest: Route, label: &'static str, is_write_route: bool) -> Element {
216    let is_active = *route == dest || (label == "写文章" && is_write_route);
217    let base_class = "flex items-center px-3 py-2.5 rounded-2xl text-sm font-medium transition-all";
218    let text_class = if is_active {
219        "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
220    } else {
221        "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
222    };
223    rsx! {
224        Link { key: "{label}", class: "{base_class} {text_class}", to: dest, "{label}" }
225    }
226}
227
228/// 根据当前后台路由,渲染对应的专属骨架屏。
229fn admin_route_skeleton(route: &Route) -> Element {
230    match route {
231        Route::Admin {} => rsx! {
232            AdminDashboardSkeleton {}
233        },
234        Route::Write {} | Route::WriteEdit { .. } => rsx! {
235            WriteSkeleton {}
236        },
237        Route::Posts {} => rsx! {
238            PostsSkeleton {}
239        },
240        Route::PostsTrash {} => rsx! {
241            PostsTrashSkeleton {}
242        },
243        Route::Assets {} => rsx! {
244            AssetsSkeleton {}
245        },
246        Route::FriendsAdmin {} => rsx! {
247            FriendsAdminSkeleton {}
248        },
249        Route::AdminComments {} | Route::AdminCommentsPage { .. } => {
250            rsx! {
251                AdminCommentsSkeleton {}
252            }
253        }
254        Route::System {} => rsx! {
255            SystemSkeleton {}
256        },
257        Route::Runner {} => rsx! {
258            RunnerSkeleton {}
259        },
260        Route::Mcp {} => rsx! {
261            McpSkeleton {}
262        },
263        Route::Logs {} => rsx! {
264            LogsSkeleton {}
265        },
266        Route::SiteSettingsPage {} => rsx! {
267            SettingsAdminSkeleton {}
268        },
269        Route::Profile {} => rsx! {
270            ProfileSkeleton {}
271        },
272        Route::PostPreview { .. } => rsx! {
273            PostPreviewSkeleton {}
274        },
275        _ => rsx! {
276            AdminDashboardSkeleton {}
277        },
278    }
279}
280
281/// 侧栏子菜单组的分类标识:内容管理组与工具组结构完全一致(父项整行点击
282/// 切换展开/收起,chevron 旋转 + grid-template-rows 0fr↔1fr 过渡动画,复用
283/// posts_trash.rs AutoPurgeSettings 的既有模式),仅路由集合与标签不同,故
284/// 抽成单一 NavGroup 组件、以枚举区分(而非函数指针 props:函数指针参与
285/// derive(PartialEq) 比较会触发 rustc 的
286/// `unpredictable_function_pointer_comparisons` lint)。
287#[derive(Clone, Copy, PartialEq)]
288enum NavGroupKind {
289    /// 「内容管理」:全部文章 / 回收站 / 评论管理。
290    Content,
291    /// 「工具」:设置 / 试运行 / MCP / 日志 / 系统(issue #26)。
292    Tools,
293}
294
295impl NavGroupKind {
296    fn label(self) -> &'static str {
297        match self {
298            NavGroupKind::Content => "内容管理",
299            NavGroupKind::Tools => "工具",
300        }
301    }
302
303    /// 判断路由是否属于本组(回收站/评论分页路由一并归入内容管理组)。
304    fn contains(self, route: &Route) -> bool {
305        match self {
306            NavGroupKind::Content => matches!(
307                route,
308                Route::Posts {}
309                    | Route::PostsTrash {}
310                    | Route::AdminComments {}
311                    | Route::AdminCommentsPage { .. }
312            ),
313            NavGroupKind::Tools => matches!(
314                route,
315                Route::Runner {}
316                    | Route::Mcp {}
317                    | Route::System {}
318                    | Route::SiteSettingsPage {}
319                    | Route::Logs {}
320            ),
321        }
322    }
323}
324
325/// 侧栏可折叠子菜单组的共享实现。`kind` 选择路由归属判定与标签,`items` 提供
326/// 子项列表(路由 / 标签 / 是否激活,由调用方基于当前路由预先算好)。
327///
328/// 当前路由落在组内时自动展开,保证激活子项始终可见;用户手动收起后,
329/// 仅当再次从组外导航进入组内路由时才重新展开——而非组内路由间导航
330/// (如评论分页)也强制重新展开。
331#[component]
332fn NavGroup(kind: NavGroupKind, items: Vec<(Route, &'static str, bool)>) -> Element {
333    let route = use_route::<Route>();
334    let group_active = kind.contains(&route);
335    let mut expanded = use_signal(|| group_active);
336    // 记录上一次 effect 运行时路由是否在组内,用于判断"从组外→组内"的跳变
337    // (而非"当前在组内"这一恒真条件),避免组内路由间导航(如评论分页)
338    // 反复触发强制展开——这才是本组件文档承诺的行为。
339    let mut was_in_group = use_signal(|| group_active);
340
341    // 闭包内读 router().current 建立 ReactiveContext 订阅(仓库约定 #5),
342    // 路由变化时本 effect 重跑。
343    use_effect(move || {
344        let current = router().current::<Route>();
345        let now_in_group = kind.contains(&current);
346        let was = was_in_group();
347
348        if now_in_group && !was {
349            expanded.set(true);
350        }
351        if was != now_in_group {
352            was_in_group.set(now_in_group);
353        }
354    });
355
356    let chevron_rotate = if expanded() { "rotate-180" } else { "" };
357    // 父项样式:与顶层导航项同盒模型;组内路由激活时仅提为 primary 文字色,
358    // 不给自己加 pill(pill 高亮由激活子项承担,避免双层高亮竞争)。
359    let parent_text_class = if group_active {
360        "text-[var(--color-paper-primary)] border border-transparent"
361    } else {
362        "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
363    };
364    let label = kind.label();
365
366    rsx! {
367        div { class: "flex flex-col gap-1",
368            button {
369                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}",
370                onclick: move |_| expanded.set(!expanded()),
371                span { "{label}" }
372                svg {
373                    class: "w-4 h-4 transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
374                    view_box: "0 0 24 24",
375                    fill: "none",
376                    stroke: "currentColor",
377                    stroke_width: "2",
378                    path {
379                        stroke_linecap: "round",
380                        stroke_linejoin: "round",
381                        d: "M19 9l-7 7-7-7",
382                    }
383                }
384            }
385            // 展开动画容器:与 AutoPurgeSettings 完全同款(grid 0fr↔1fr + 内层 overflow-hidden)。
386            div {
387                class: "grid transition-all duration-300 ease-in-out",
388                style: if expanded() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
389                div { class: "overflow-hidden min-h-0",
390                    // 左侧竖线引导 + 缩进表示层级。
391                    div { class: "ml-3 pl-2.5 border-l border-[var(--color-paper-border)] flex flex-col gap-1",
392                        for (dest, item_label, active) in items {
393                            Link {
394                                key: "{item_label}",
395                                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" },
396                                to: dest,
397                                "{item_label}"
398                            }
399                        }
400                    }
401                }
402            }
403        }
404    }
405}
406
407/// 「内容管理」子菜单组:全部文章 / 回收站 / 评论管理(issue #17)。NavGroup
408/// 的薄封装,只提供本组的子项列表。
409#[component]
410fn ContentNavGroup() -> Element {
411    let route = use_route::<Route>();
412    let items = vec![
413        (
414            Route::Posts {},
415            "全部文章",
416            matches!(route, Route::Posts {}),
417        ),
418        (
419            Route::PostsTrash {},
420            "回收站",
421            matches!(route, Route::PostsTrash {}),
422        ),
423        (
424            Route::AdminComments {},
425            "评论管理",
426            matches!(
427                route,
428                Route::AdminComments {} | Route::AdminCommentsPage { .. }
429            ),
430        ),
431    ];
432    rsx! {
433        NavGroup { kind: NavGroupKind::Content, items }
434    }
435}
436
437/// 「工具」子菜单组:设置 / 试运行 / MCP / 日志 / 系统(issue #26)。NavGroup
438/// 的薄封装,只提供本组的子项列表。
439#[component]
440fn ToolsNavGroup() -> Element {
441    let route = use_route::<Route>();
442    let items = vec![
443        (
444            Route::SiteSettingsPage {},
445            "设置",
446            matches!(route, Route::SiteSettingsPage {}),
447        ),
448        (
449            Route::Runner {},
450            "试运行",
451            matches!(route, Route::Runner {}),
452        ),
453        (Route::Mcp {}, "MCP", matches!(route, Route::Mcp {})),
454        (Route::Logs {}, "日志", matches!(route, Route::Logs {})),
455        (Route::System {}, "系统", matches!(route, Route::System {})),
456    ];
457    rsx! {
458        NavGroup { kind: NavGroupKind::Tools, items }
459    }
460}