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 is_write_route =
57        matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
58    // 写作、设置和日志页面在主内容区内部自行滚动。
59    let internal_scroll_route = is_write_route
60        || matches!(route, Route::SiteSettingsPage {})
61        || matches!(route, Route::Logs {});
62    let side_user = (ctx.user)();
63    let sidebar = rsx! {
64        AdminSidebar {
65            route: route.clone(),
66            user_name: side_user.as_ref().map(|user| user.display_label().to_string()),
67            avatar_url: side_user.as_ref().and_then(|user| user.avatar_url.clone()),
68            on_logout: move |_| {
69                spawn(async move {
70                    if logout().await.is_ok_and(|response| response.success) {
71                        crate::bridges::navigation::clear_admin_state();
72                    }
73                    ctx.user.set(None);
74                    ctx.checked.set(false);
75                    let _ = navigator.push(Route::Login {});
76                });
77            },
78        }
79    };
80    let authenticated = matches!(((ctx.checked)(), (ctx.user)()), (true, Some(_)));
81    let skeleton_route = route.clone();
82    let content = if authenticated {
83        rsx! {
84            // 路由挂起时沿用同一布局内的页面骨架。
85            SuspenseBoundary {
86                fallback: move |_| rsx! {
87                    div { class: "flex-1 min-h-0 flex flex-col animate-pulse", {admin_route_skeleton(&skeleton_route)} }
88                },
89                Outlet::<Route> {}
90            }
91        }
92    } else {
93        rsx! {
94            div { class: "flex-1 min-h-0 flex flex-col animate-pulse",
95                {admin_route_skeleton(&route)}
96            }
97        }
98    };
99    rsx! {
100        AdminShell {
101            sidebar,
102            content,
103            internal_scroll: internal_scroll_route,
104            checking: !authenticated,
105        }
106    }
107}
108
109/// 认证和路由留在 AdminLayout,图鉴只向此壳传入固定内容。
110#[component]
111pub(crate) fn AdminShell(
112    sidebar: Element,
113    content: Element,
114    #[props(default)] internal_scroll: bool,
115    #[props(default)] checking: bool,
116    #[props(default)] preview: bool,
117) -> Element {
118    let root_class = if preview {
119        "showcase-admin-shell flex bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] font-sans w-full h-full overflow-hidden"
120    } else {
121        "min-h-dvh flex bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] font-sans"
122    };
123    let height_class = if preview { "h-full" } else { "h-screen" };
124    let card_overflow = if internal_scroll || checking {
125        "overflow-hidden"
126    } else {
127        "overflow-y-auto"
128    };
129    let main_class = if preview || internal_scroll {
130        "flex-1 w-full max-w-7xl mx-auto flex flex-col min-h-0"
131    } else {
132        "flex-1 w-full max-w-7xl mx-auto px-6 py-12"
133    };
134    rsx! {
135        div { class: "{root_class}",
136            {sidebar}
137            div { class: "flex-1 flex flex-col min-w-0 {height_class} p-2 md:p-4",
138                div {
139                    "data-vt-scroll": (!preview).then_some("admin-main"),
140                    class: "flex-1 min-h-0 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] {card_overflow} relative flex flex-col",
141                    main { class: "{main_class}", {content} }
142                }
143            }
144        }
145    }
146}
147
148/// 正式后台与图鉴共享侧栏结构;图鉴导航通过回调更新局部选中项。
149#[component]
150pub(crate) fn AdminSidebar(
151    route: Route,
152    user_name: Option<String>,
153    avatar_url: Option<String>,
154    on_logout: EventHandler<()>,
155    #[props(default)] demo_active: Option<String>,
156    #[props(default)] on_demo_navigate: Option<EventHandler<&'static str>>,
157    #[props(default)] preview: bool,
158) -> Element {
159    let nav_items_top = vec![(Route::Admin {}, "仪表盘"), (Route::Write {}, "写文章")];
160    let nav_items_bottom = vec![(Route::Assets {}, "素材"), (Route::FriendsAdmin {}, "友链")];
161    let is_write_route =
162        matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
163    let is_profile_route = demo_active
164        .as_deref()
165        .map_or(matches!(route, Route::Profile {}), |active| {
166            active == "个人信息"
167        });
168    let chip_state_class = if is_profile_route {
169        "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
170    } else {
171        "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
172    };
173    let aside_class = if preview {
174        "w-48 flex-shrink-0 flex flex-col h-full sticky top-0 p-3 bg-[var(--color-paper-entry)] overflow-hidden"
175    } else {
176        "w-48 flex-shrink-0 hidden md:flex flex-col h-screen sticky top-0 p-3 bg-[var(--color-paper-entry)]"
177    };
178    rsx! {
179        aside { "data-vt-shell": (!preview).then_some("admin-sidebar"), class: "{aside_class}",
180            div { class: "mb-8 px-3 flex-shrink-0",
181                if let Some(on_navigate) = on_demo_navigate {
182                    button {
183                        class: "font-extrabold text-2xl tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors",
184                        r#type: "button",
185                        onclick: move |_| on_navigate.call("仪表盘"),
186                        "Yggdrasil."
187                    }
188                } else {
189                    // admin→前台跨 layout,保留整页加载以避开 Dioxus 0.7.10 的卸载问题。
190                    Link {
191                        class: "font-extrabold text-2xl tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors",
192                        to: NavigationTarget::<Route>::External("/".to_string()),
193                        "Yggdrasil."
194                    }
195                }
196            }
197            nav { class: "flex-1 min-h-0 flex flex-col gap-2 overflow-y-auto overflow-x-hidden pr-0.5",
198                for (dest, label) in nav_items_top {
199                    {nav_item(&route, dest, label, is_write_route, demo_active.as_deref(), on_demo_navigate)}
200                }
201                ContentNavGroup { demo_active: demo_active.clone(), on_demo_navigate }
202                for (dest, label) in nav_items_bottom {
203                    {nav_item(&route, dest, label, is_write_route, demo_active.as_deref(), on_demo_navigate)}
204                }
205                ToolsNavGroup { demo_active: demo_active.clone(), on_demo_navigate }
206            }
207            div { class: "mt-auto pt-4 border-t border-[var(--color-paper-border)] flex flex-col gap-1 flex-shrink-0",
208                if let Some(name) = user_name {
209                    if let Some(on_navigate) = on_demo_navigate {
210                        button {
211                            class: "flex items-center gap-2.5 px-3 py-2 rounded-2xl text-sm font-medium transition-all {chip_state_class}",
212                            r#type: "button",
213                            onclick: move |_| on_navigate.call("个人信息"),
214                            UserAvatar {
215                                name: name.clone(),
216                                avatar_url: avatar_url.clone(),
217                                class: "w-7 h-7 rounded-full text-xs flex-shrink-0 border border-[var(--color-paper-border)]",
218                            }
219                            span { class: "truncate", "{name}" }
220                        }
221                    } else {
222                        Link {
223                            class: "flex items-center gap-2.5 px-3 py-2 rounded-2xl text-sm font-medium transition-all {chip_state_class}",
224                            to: Route::Profile {},
225                            UserAvatar {
226                                name: name.clone(),
227                                avatar_url: avatar_url.clone(),
228                                class: "w-7 h-7 rounded-full text-xs flex-shrink-0 border border-[var(--color-paper-border)]",
229                            }
230                            span { class: "truncate", "{name}" }
231                        }
232                    }
233                } else {
234                    div { class: "flex items-center gap-2.5 px-3 py-2",
235                        div { class: "w-7 h-7 rounded-full bg-[var(--color-paper-theme)] animate-pulse flex-shrink-0" }
236                        div { class: "h-3.5 w-16 rounded bg-[var(--color-paper-theme)] animate-pulse" }
237                    }
238                }
239                div { class: "flex items-center justify-between px-3",
240                    ThemeToggle {}
241                    button {
242                        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",
243                        r#type: "button",
244                        onclick: move |_| on_logout.call(()),
245                        "退出"
246                    }
247                }
248            }
249        }
250    }
251}
252
253fn nav_item(
254    route: &Route,
255    dest: Route,
256    label: &'static str,
257    is_write_route: bool,
258    demo_active: Option<&str>,
259    on_demo_navigate: Option<EventHandler<&'static str>>,
260) -> Element {
261    let is_active = demo_active.map_or(
262        *route == dest || (label == "写文章" && is_write_route),
263        |active| active == label,
264    );
265    let base_class = "flex items-center px-3 py-2.5 rounded-2xl text-sm font-medium transition-all";
266    let text_class = if is_active {
267        "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
268    } else {
269        "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
270    };
271    rsx! {
272        if let Some(on_navigate) = on_demo_navigate {
273            button { key: "{label}", class: "{base_class} {text_class}", r#type: "button", onclick: move |_| on_navigate.call(label), "{label}" }
274        } else {
275            Link { key: "{label}", class: "{base_class} {text_class}", to: dest, "{label}" }
276        }
277    }
278}
279
280/// 根据当前后台路由,渲染对应的专属骨架屏。
281fn admin_route_skeleton(route: &Route) -> Element {
282    match route {
283        Route::Admin {} => rsx! {
284            AdminDashboardSkeleton {}
285        },
286        Route::Write {} | Route::WriteEdit { .. } => rsx! {
287            WriteSkeleton {}
288        },
289        Route::Posts {} => rsx! {
290            PostsSkeleton {}
291        },
292        Route::AdminNotes {}
293        | Route::AdminNotebooks {}
294        | Route::NewNote {}
295        | Route::EditNote { .. } => rsx! {
296            crate::pages::notes::NotesSkeleton {}
297        },
298        Route::PostsTrash {} => rsx! {
299            PostsTrashSkeleton {}
300        },
301        Route::Assets {} => rsx! {
302            AssetsSkeleton {}
303        },
304        Route::FriendsAdmin {} => rsx! {
305            FriendsAdminSkeleton {}
306        },
307        Route::AdminComments {} | Route::AdminCommentsPage { .. } => {
308            rsx! {
309                AdminCommentsSkeleton {}
310            }
311        }
312        Route::System {} => rsx! {
313            SystemSkeleton {}
314        },
315        Route::Runner {} => rsx! {
316            RunnerSkeleton {}
317        },
318        Route::Mcp {} => rsx! {
319            McpSkeleton {}
320        },
321        Route::Logs {} => rsx! {
322            LogsSkeleton {}
323        },
324        Route::SiteSettingsPage {} => rsx! {
325            SettingsAdminSkeleton {}
326        },
327        Route::Profile {} => rsx! {
328            ProfileSkeleton {}
329        },
330        Route::PostPreview { .. } => rsx! {
331            PostPreviewSkeleton {}
332        },
333        _ => rsx! {
334            AdminDashboardSkeleton {}
335        },
336    }
337}
338
339/// 侧栏子菜单组的分类标识:内容管理组与工具组结构完全一致(父项整行点击
340/// 切换展开/收起,chevron 旋转 + grid-template-rows 0fr↔1fr 过渡动画,复用
341/// posts_trash.rs AutoPurgeSettings 的既有模式),仅路由集合与标签不同,故
342/// 抽成单一 NavGroup 组件、以枚举区分(而非函数指针 props:函数指针参与
343/// derive(PartialEq) 比较会触发 rustc 的
344/// `unpredictable_function_pointer_comparisons` lint)。
345#[derive(Clone, Copy, PartialEq)]
346enum NavGroupKind {
347    /// 「内容管理」:全部文章 / 回收站 / 评论管理。
348    Content,
349    /// 「工具」:设置 / 试运行 / MCP / 日志 / 系统(issue #26)。
350    Tools,
351}
352
353impl NavGroupKind {
354    fn label(self) -> &'static str {
355        match self {
356            NavGroupKind::Content => "内容管理",
357            NavGroupKind::Tools => "工具",
358        }
359    }
360
361    /// 判断路由是否属于本组(回收站/评论分页路由一并归入内容管理组)。
362    fn contains(self, route: &Route) -> bool {
363        match self {
364            NavGroupKind::Content => matches!(
365                route,
366                Route::Posts {}
367                    | Route::AdminNotes {}
368                    | Route::AdminNotebooks {}
369                    | Route::NewNote {}
370                    | Route::EditNote { .. }
371                    | Route::PostsTrash {}
372                    | Route::AdminComments {}
373                    | Route::AdminCommentsPage { .. }
374            ),
375            NavGroupKind::Tools => matches!(
376                route,
377                Route::Runner {}
378                    | Route::Mcp {}
379                    | Route::System {}
380                    | Route::SiteSettingsPage {}
381                    | Route::Logs {}
382            ),
383        }
384    }
385}
386
387/// 侧栏可折叠子菜单组的共享实现。`kind` 选择路由归属判定与标签,`items` 提供
388/// 子项列表(路由 / 标签 / 是否激活,由调用方基于当前路由预先算好)。
389///
390/// 当前路由落在组内时自动展开,保证激活子项始终可见;用户手动收起后,
391/// 仅当再次从组外导航进入组内路由时才重新展开——而非组内路由间导航
392/// (如评论分页)也强制重新展开。
393#[component]
394fn NavGroup(
395    kind: NavGroupKind,
396    items: Vec<(Route, &'static str, bool)>,
397    #[props(default)] on_demo_navigate: Option<EventHandler<&'static str>>,
398) -> Element {
399    let route = use_route::<Route>();
400    let group_active = if on_demo_navigate.is_some() {
401        items.iter().any(|(_, _, active)| *active)
402    } else {
403        kind.contains(&route)
404    };
405    let mut expanded = use_signal(|| group_active);
406    // 记录上一次 effect 运行时路由是否在组内,用于判断"从组外→组内"的跳变
407    // (而非"当前在组内"这一恒真条件),避免组内路由间导航(如评论分页)
408    // 反复触发强制展开——这才是本组件文档承诺的行为。
409    let mut was_in_group = use_signal(|| group_active);
410
411    // 闭包内读 router().current 建立 ReactiveContext 订阅(仓库约定 #5),
412    // 路由变化时本 effect 重跑。
413    use_effect(move || {
414        if on_demo_navigate.is_some() {
415            return;
416        }
417        let current = router().current::<Route>();
418        let now_in_group = kind.contains(&current);
419        let was = was_in_group();
420
421        if now_in_group && !was {
422            expanded.set(true);
423        }
424        if was != now_in_group {
425            was_in_group.set(now_in_group);
426        }
427    });
428
429    let chevron_rotate = if expanded() { "rotate-180" } else { "" };
430    // 父项样式:与顶层导航项同盒模型;组内路由激活时仅提为 primary 文字色,
431    // 不给自己加 pill(pill 高亮由激活子项承担,避免双层高亮竞争)。
432    let parent_text_class = if group_active {
433        "text-[var(--color-paper-primary)] border border-transparent"
434    } else {
435        "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
436    };
437    let label = kind.label();
438
439    rsx! {
440        div { class: "flex flex-col gap-1",
441            button {
442                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}",
443                r#type: "button",
444                aria_expanded: expanded(),
445                onclick: move |_| expanded.set(!expanded()),
446                span { "{label}" }
447                svg {
448                    class: "w-4 h-4 transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
449                    view_box: "0 0 24 24",
450                    fill: "none",
451                    stroke: "currentColor",
452                    stroke_width: "2",
453                    path {
454                        stroke_linecap: "round",
455                        stroke_linejoin: "round",
456                        d: "M19 9l-7 7-7-7",
457                    }
458                }
459            }
460            // 展开动画容器:与 AutoPurgeSettings 完全同款(grid 0fr↔1fr + 内层 overflow-hidden)。
461            div {
462                class: "grid transition-all duration-300 ease-in-out",
463                style: if expanded() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
464                div { class: "overflow-hidden min-h-0",
465                    // 左侧竖线引导 + 缩进表示层级。
466                    div { class: "ml-3 pl-2.5 border-l border-[var(--color-paper-border)] flex flex-col gap-1",
467                        for (dest, item_label, active) in items {
468                            if let Some(on_navigate) = on_demo_navigate {
469                                button {
470                                    key: "{item_label}",
471                                    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" },
472                                    r#type: "button",
473                                    onclick: move |_| on_navigate.call(item_label),
474                                    "{item_label}"
475                                }
476                            } else {
477                                Link {
478                                    key: "{item_label}",
479                                    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" },
480                                    to: dest,
481                                    "{item_label}"
482                                }
483                            }
484                        }
485                    }
486                }
487            }
488        }
489    }
490}
491
492/// 「内容管理」子菜单组:全部文章 / 回收站 / 评论管理(issue #17)。NavGroup
493/// 的薄封装,只提供本组的子项列表。
494#[component]
495fn ContentNavGroup(
496    #[props(default)] demo_active: Option<String>,
497    #[props(default)] on_demo_navigate: Option<EventHandler<&'static str>>,
498) -> Element {
499    let route = use_route::<Route>();
500    let active = |label: &str, route_active: bool| {
501        demo_active
502            .as_ref()
503            .map_or(route_active, |name| name == label)
504    };
505    let items = vec![
506        (
507            Route::AdminNotes {},
508            "笔记",
509            active(
510                "笔记",
511                matches!(
512                    route,
513                    Route::AdminNotes {} | Route::NewNote {} | Route::EditNote { .. }
514                ),
515            ),
516        ),
517        (
518            Route::AdminNotebooks {},
519            "笔记本",
520            active("笔记本", matches!(route, Route::AdminNotebooks {})),
521        ),
522        (
523            Route::Posts {},
524            "全部文章",
525            active("全部文章", matches!(route, Route::Posts {})),
526        ),
527        (
528            Route::PostsTrash {},
529            "回收站",
530            active("回收站", matches!(route, Route::PostsTrash {})),
531        ),
532        (
533            Route::AdminComments {},
534            "评论管理",
535            active(
536                "评论管理",
537                matches!(
538                    route,
539                    Route::AdminComments {} | Route::AdminCommentsPage { .. }
540                ),
541            ),
542        ),
543    ];
544    rsx! {
545        NavGroup { kind: NavGroupKind::Content, items, on_demo_navigate }
546    }
547}
548
549/// 「工具」子菜单组:设置 / 试运行 / MCP / 日志 / 系统(issue #26)。NavGroup
550/// 的薄封装,只提供本组的子项列表。
551#[component]
552fn ToolsNavGroup(
553    #[props(default)] demo_active: Option<String>,
554    #[props(default)] on_demo_navigate: Option<EventHandler<&'static str>>,
555) -> Element {
556    let route = use_route::<Route>();
557    let active = |label: &str, route_active: bool| {
558        demo_active
559            .as_ref()
560            .map_or(route_active, |name| name == label)
561    };
562    let items = vec![
563        (
564            Route::SiteSettingsPage {},
565            "设置",
566            active("设置", matches!(route, Route::SiteSettingsPage {})),
567        ),
568        (
569            Route::Runner {},
570            "试运行",
571            active("试运行", matches!(route, Route::Runner {})),
572        ),
573        (
574            Route::Mcp {},
575            "MCP",
576            active("MCP", matches!(route, Route::Mcp {})),
577        ),
578        (
579            Route::Logs {},
580            "日志",
581            active("日志", matches!(route, Route::Logs {})),
582        ),
583        (
584            Route::System {},
585            "系统",
586            active("系统", matches!(route, Route::System {})),
587        ),
588    ];
589    rsx! {
590        NavGroup { kind: NavGroupKind::Tools, items, on_demo_navigate }
591    }
592}