Skip to main content

yggdrasil/components/
admin_layout.rs

1//! 后台布局:桌面应用式外壳。
2//!
3//! 左侧栏(surface-1)按「内容 · 站点 · 系统」分组,文章、素材、评论右侧显示数量,
4//! 当前项为浮起的行与陶土小方点;侧栏可收成 56px 图标栏,拖拽右缘调宽,松手按
5//! heavy 弹簧吸附。主画布 surface-0,顶栏是面包屑与「查看前台」。767px 以下侧栏
6//! 收进左侧抽屉,顶栏为菜单、页面标题与搜索。样式见 styles/components/admin.css。
7//!
8//! 外壳在懒加载的父路由 `/admin` 里只构建一次(Leptos 组件体只运行一次),切换后台页面时
9//! 侧栏与顶栏保持同一批 DOM 节点;随路由变化的部分(激活栏目、面包屑、画布滚动方式)
10//! 跟随当前显示的页面(`use_rendered_route`),骨架屏按地址栏指向的目标页选择。
11
12use std::sync::Arc;
13
14use leptos::prelude::*;
15use leptos::task::spawn_local;
16use leptos_router::components::Outlet;
17use leptos_router::hooks::use_location;
18
19use crate::api::admin_stats::{get_admin_nav_counts, AdminNavCounts};
20use crate::api::auth::{get_current_user, logout};
21use crate::components::admin_palette::AdminPalette;
22use crate::components::palette::{use_search_palette, use_search_palette_provider};
23use crate::components::skeletons::admin_comments_skeleton::AdminCommentsSkeleton;
24use crate::components::skeletons::assets_skeleton::AssetsSkeleton;
25use crate::components::skeletons::dashboard_skeleton::AdminDashboardSkeleton;
26use crate::components::skeletons::friends_admin_skeleton::FriendsAdminSkeleton;
27use crate::components::skeletons::logs_skeleton::LogsSkeleton;
28use crate::components::skeletons::mcp_skeleton::McpSkeleton;
29use crate::components::skeletons::notes_admin_skeleton::{
30    AdminNotebooksSkeleton, AdminNotesSkeleton,
31};
32use crate::components::skeletons::post_preview_skeleton::PostPreviewSkeleton;
33use crate::components::skeletons::posts_skeleton::PostsSkeleton;
34use crate::components::skeletons::posts_trash_skeleton::PostsTrashSkeleton;
35use crate::components::skeletons::profile_skeleton::ProfileSkeleton;
36use crate::components::skeletons::runner_skeleton::RunnerSkeleton;
37use crate::components::skeletons::settings_admin_skeleton::SettingsAdminSkeleton;
38use crate::components::skeletons::system_skeleton::SystemSkeleton;
39use crate::components::skeletons::write_skeleton::WriteSkeleton;
40use crate::components::ui::{
41    Breadcrumb, ButtonSize, Crumb, Icon, IconButton, IconName, LoadError, SheetClose, SideSheet,
42    UserAvatar,
43};
44use crate::context::UserContext;
45use crate::routes::Route;
46use crate::theme::ThemeToggle;
47
48/// 侧栏默认宽度与可调范围(px)。
49const SIDEBAR_DEFAULT: f64 = 232.0;
50const SIDEBAR_MIN: f64 = 200.0;
51const SIDEBAR_MAX: f64 = 320.0;
52/// 图标栏宽度,与 admin.css 的 `--admin-rail` 一致。
53const SIDEBAR_RAIL: f64 = 56.0;
54/// 拖到这个宽度以下松手收成图标栏。
55const SIDEBAR_COLLAPSE_AT: f64 = 152.0;
56/// 方向键每次调整的宽度。
57const SIDEBAR_KEY_STEP: f64 = 16.0;
58/// 拖拽死区:移动不到 4px 算点按。
59const DRAG_DEAD_ZONE: f64 = 4.0;
60
61const SIDEBAR_WIDTH_KEY: &str = "yggdrasil-admin-sidebar-width";
62const SIDEBAR_COLLAPSED_KEY: &str = "yggdrasil-admin-sidebar-collapsed";
63
64/// 首屏恢复侧栏宽度与折叠状态,避免 WASM 接管前侧栏跳一下。外壳挂载后改由
65/// 根元素的 `data-sidebar` 与 `--admin-sidebar-w` 决定。
66const SIDEBAR_PRELOAD_SCRIPT: &str = r#"
67(function() {
68    try {
69        var html = document.documentElement;
70        var width = parseFloat(localStorage.getItem('yggdrasil-admin-sidebar-width'));
71        if (width >= 200 && width <= 320) html.style.setProperty('--admin-sidebar-w', width + 'px');
72        if (localStorage.getItem('yggdrasil-admin-sidebar-collapsed') === 'true') {
73            html.setAttribute('data-admin-sidebar', 'collapsed');
74        }
75    } catch (e) {}
76})();
77"#;
78
79/// 侧栏的宽度与折叠状态。`width` 是展开时的宽度,收起后保留,展开时恢复。
80#[derive(Clone, Copy, Debug, PartialEq)]
81struct SidebarState {
82    width: f64,
83    collapsed: bool,
84}
85
86impl Default for SidebarState {
87    fn default() -> Self {
88        Self {
89            width: SIDEBAR_DEFAULT,
90            collapsed: false,
91        }
92    }
93}
94
95impl SidebarState {
96    /// 从存储的原始值恢复;越界或损坏的宽度回到默认值。
97    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
98    fn restore(width: Option<&str>, collapsed: Option<&str>) -> Self {
99        let width = width
100            .and_then(|w| w.parse::<f64>().ok())
101            .filter(|w| (SIDEBAR_MIN..=SIDEBAR_MAX).contains(w))
102            .unwrap_or(SIDEBAR_DEFAULT);
103        Self {
104            width,
105            collapsed: collapsed == Some("true"),
106        }
107    }
108
109    /// 松手吸附:拖到阈值以下收起;阈值与最小宽度之间回弹到最小宽度。
110    fn settle(self, dragged: f64) -> Self {
111        if dragged < SIDEBAR_COLLAPSE_AT {
112            Self {
113                collapsed: true,
114                ..self
115            }
116        } else {
117            Self {
118                width: dragged.clamp(SIDEBAR_MIN, SIDEBAR_MAX),
119                collapsed: false,
120            }
121        }
122    }
123
124    /// 方向键调宽:收起时向右展开;最小宽度再向左则收起。
125    fn nudge(self, delta: f64) -> Self {
126        match (self.collapsed, delta > 0.0) {
127            (true, true) => Self {
128                collapsed: false,
129                ..self
130            },
131            (true, false) => self,
132            (false, false) if self.width <= SIDEBAR_MIN => Self {
133                collapsed: true,
134                ..self
135            },
136            (false, _) => Self {
137                width: (self.width + delta).clamp(SIDEBAR_MIN, SIDEBAR_MAX),
138                collapsed: false,
139            },
140        }
141    }
142
143    /// 侧栏当前显示宽度。
144    fn shown(self) -> f64 {
145        if self.collapsed {
146            SIDEBAR_RAIL
147        } else {
148            self.width
149        }
150    }
151}
152
153/// 拖拽中的显示宽度:跟手,下限是图标栏宽度。
154fn dragged_width(start_width: f64, dx: f64) -> f64 {
155    (start_width + dx).clamp(SIDEBAR_RAIL, SIDEBAR_MAX)
156}
157
158#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
159fn save_sidebar(state: SidebarState) {
160    #[cfg(target_arch = "wasm32")]
161    if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
162        let _ = storage.set_item(SIDEBAR_WIDTH_KEY, &state.width.round().to_string());
163        let _ = storage.set_item(SIDEBAR_COLLAPSED_KEY, &state.collapsed.to_string());
164    }
165}
166
167fn load_sidebar() -> SidebarState {
168    #[cfg(target_arch = "wasm32")]
169    {
170        let storage = web_sys::window().and_then(|w| w.local_storage().ok().flatten());
171        let read = |key: &str| {
172            storage
173                .as_ref()
174                .and_then(|s| s.get_item(key).ok().flatten())
175        };
176        SidebarState::restore(
177            read(SIDEBAR_WIDTH_KEY).as_deref(),
178            read(SIDEBAR_COLLAPSED_KEY).as_deref(),
179        )
180    }
181    #[cfg(not(target_arch = "wasm32"))]
182    {
183        let _ = (SIDEBAR_WIDTH_KEY, SIDEBAR_COLLAPSED_KEY);
184        SidebarState::default()
185    }
186}
187
188/// 后台页面种类:由 `/admin` 下的路径字符串分类,替代原先对 `Route` 枚举的匹配。
189///
190/// 侧栏激活项、面包屑、页面标题与骨架屏都由它决定。
191#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub enum AdminPage {
193    /// 仪表盘(`/admin`)。
194    Dashboard,
195    /// 文章管理。
196    Posts,
197    /// 文章回收站。
198    PostsTrash,
199    /// 写文章。
200    Write,
201    /// 编辑文章。
202    WriteEdit,
203    /// 文章预览。
204    PostPreview,
205    /// 笔记管理。
206    AdminNotes,
207    /// 新建笔记。
208    NewNote,
209    /// 编辑笔记。
210    EditNote,
211    /// 笔记本管理。
212    AdminNotebooks,
213    /// 素材管理。
214    Assets,
215    /// 友链管理。
216    FriendsAdmin,
217    /// 评论管理(含分页)。
218    AdminComments,
219    /// 系统状态。
220    System,
221    /// 代码运行器。
222    Runner,
223    /// MCP 接入。
224    Mcp,
225    /// 运行日志。
226    Logs,
227    /// 站点设置。
228    Settings,
229    /// 个人资料。
230    Profile,
231    /// 其余路径(未匹配的后台地址)。
232    Other,
233}
234
235impl AdminPage {
236    /// 按路径(不含查询串)分类;末尾斜杠忽略。
237    pub fn from_path(path: &str) -> Self {
238        let path = path.trim_end_matches('/');
239        let Some(rest) = path.strip_prefix("/admin") else {
240            return AdminPage::Other;
241        };
242        let mut segments = rest.split('/').skip(1);
243        let first = segments.next();
244        let second = segments.next();
245        let third = segments.next();
246        match (first, second, third, segments.next()) {
247            (None, ..) if rest.is_empty() => AdminPage::Dashboard,
248            (Some("write"), None, ..) => AdminPage::Write,
249            (Some("write"), Some(_), None, _) => AdminPage::WriteEdit,
250            (Some("preview"), Some(_), None, _) => AdminPage::PostPreview,
251            (Some("posts"), None, ..) => AdminPage::Posts,
252            (Some("posts"), Some("trash"), None, _) => AdminPage::PostsTrash,
253            (Some("notes"), None, ..) => AdminPage::AdminNotes,
254            (Some("notes"), Some("new"), None, _) => AdminPage::NewNote,
255            (Some("notes"), Some("edit"), Some(_), None) => AdminPage::EditNote,
256            (Some("notebooks"), None, ..) => AdminPage::AdminNotebooks,
257            (Some("assets"), None, ..) => AdminPage::Assets,
258            (Some("friends"), None, ..) => AdminPage::FriendsAdmin,
259            (Some("comments"), None, ..) => AdminPage::AdminComments,
260            (Some("comments"), Some(_), None, _) => AdminPage::AdminComments,
261            (Some("system"), None, ..) => AdminPage::System,
262            (Some("runner"), None, ..) => AdminPage::Runner,
263            (Some("mcp"), None, ..) => AdminPage::Mcp,
264            (Some("logs"), None, ..) => AdminPage::Logs,
265            (Some("settings"), None, ..) => AdminPage::Settings,
266            (Some("profile"), None, ..) => AdminPage::Profile,
267            _ => AdminPage::Other,
268        }
269    }
270
271    /// 写文章与笔记编辑器自带页头。
272    fn is_write(self) -> bool {
273        matches!(
274            self,
275            AdminPage::Write | AdminPage::WriteEdit | AdminPage::NewNote | AdminPage::EditNote
276        )
277    }
278
279    /// 写作、设置和日志页面在主内容区内部自行滚动。
280    fn scrolls_internally(self) -> bool {
281        self.is_write() || matches!(self, AdminPage::Settings | AdminPage::Logs)
282    }
283}
284
285/// 侧栏中的一个栏目。
286#[derive(Clone, Copy, Debug, PartialEq, Eq)]
287pub(crate) enum AdminSection {
288    Dashboard,
289    Posts,
290    Notes,
291    Assets,
292    Comments,
293    Friends,
294    Mcp,
295    Settings,
296    System,
297    Logs,
298    Runner,
299}
300
301/// 侧栏分组:分组名与其中的栏目。
302const NAV_GROUPS: [(Option<&str>, &[AdminSection]); 4] = [
303    (None, &[AdminSection::Dashboard]),
304    (
305        Some("内容"),
306        &[
307            AdminSection::Posts,
308            AdminSection::Notes,
309            AdminSection::Assets,
310            AdminSection::Comments,
311        ],
312    ),
313    (
314        Some("站点"),
315        &[
316            AdminSection::Friends,
317            AdminSection::Mcp,
318            AdminSection::Settings,
319        ],
320    ),
321    (
322        Some("系统"),
323        &[
324            AdminSection::System,
325            AdminSection::Logs,
326            AdminSection::Runner,
327        ],
328    ),
329];
330
331impl AdminSection {
332    pub(crate) fn label(self) -> &'static str {
333        match self {
334            AdminSection::Dashboard => "仪表盘",
335            AdminSection::Posts => "文章",
336            AdminSection::Notes => "笔记",
337            AdminSection::Assets => "素材",
338            AdminSection::Comments => "评论",
339            AdminSection::Friends => "友链",
340            AdminSection::Mcp => "MCP 接入",
341            AdminSection::Settings => "设置",
342            AdminSection::System => "系统状态",
343            AdminSection::Logs => "日志",
344            AdminSection::Runner => "代码运行器",
345        }
346    }
347
348    pub(crate) fn icon(self) -> IconName {
349        match self {
350            AdminSection::Dashboard => IconName::LayoutDashboard,
351            AdminSection::Posts => IconName::FileText,
352            AdminSection::Notes => IconName::NotebookPen,
353            AdminSection::Assets => IconName::Image,
354            AdminSection::Comments => IconName::MessageSquare,
355            AdminSection::Friends => IconName::Link,
356            AdminSection::Mcp => IconName::Plug,
357            AdminSection::Settings => IconName::SlidersHorizontal,
358            AdminSection::System => IconName::Activity,
359            AdminSection::Logs => IconName::List,
360            AdminSection::Runner => IconName::Terminal,
361        }
362    }
363
364    pub(crate) fn route(self) -> Route {
365        match self {
366            AdminSection::Dashboard => Route::Admin,
367            AdminSection::Posts => Route::Posts,
368            AdminSection::Notes => Route::AdminNotes,
369            AdminSection::Assets => Route::Assets,
370            AdminSection::Comments => Route::AdminComments,
371            AdminSection::Friends => Route::FriendsAdmin,
372            AdminSection::Mcp => Route::Mcp,
373            AdminSection::Settings => Route::SiteSettingsPage,
374            AdminSection::System => Route::System,
375            AdminSection::Logs => Route::Logs,
376            AdminSection::Runner => Route::Runner,
377        }
378    }
379
380    /// 页面所属栏目:写作、回收站与预览归「文章」,笔记本与笔记编辑归「笔记」。
381    /// 个人资料不在侧栏栏目里,由底部用户行标示。
382    pub(crate) fn of(page: AdminPage) -> Option<Self> {
383        Some(match page {
384            AdminPage::Dashboard => AdminSection::Dashboard,
385            AdminPage::Posts
386            | AdminPage::PostsTrash
387            | AdminPage::Write
388            | AdminPage::WriteEdit
389            | AdminPage::PostPreview => AdminSection::Posts,
390            AdminPage::AdminNotes
391            | AdminPage::AdminNotebooks
392            | AdminPage::NewNote
393            | AdminPage::EditNote => AdminSection::Notes,
394            AdminPage::Assets => AdminSection::Assets,
395            AdminPage::AdminComments => AdminSection::Comments,
396            AdminPage::FriendsAdmin => AdminSection::Friends,
397            AdminPage::Mcp => AdminSection::Mcp,
398            AdminPage::Settings => AdminSection::Settings,
399            AdminPage::System => AdminSection::System,
400            AdminPage::Logs => AdminSection::Logs,
401            AdminPage::Runner => AdminSection::Runner,
402            AdminPage::Profile | AdminPage::Other => return None,
403        })
404    }
405
406    fn group(self) -> &'static str {
407        NAV_GROUPS
408            .iter()
409            .find(|(_, items)| items.contains(&self))
410            .and_then(|(group, _)| *group)
411            .unwrap_or("")
412    }
413}
414
415/// 顶栏面包屑与手机顶栏标题。子页面以所属栏目为上一级(可点击),栏目页以分组名
416/// 为上一级。
417pub(crate) fn admin_crumbs(page: AdminPage) -> (Vec<Crumb>, &'static str) {
418    let sub = |section: AdminSection, title: &'static str| {
419        (
420            vec![
421                Crumb::link(section.label(), section.route()),
422                Crumb::current(title),
423            ],
424            title,
425        )
426    };
427    match page {
428        AdminPage::Dashboard => (vec![Crumb::current("概览")], "仪表盘"),
429        AdminPage::PostsTrash => sub(AdminSection::Posts, "回收站"),
430        AdminPage::Write => sub(AdminSection::Posts, "写文章"),
431        AdminPage::WriteEdit => sub(AdminSection::Posts, "编辑文章"),
432        AdminPage::PostPreview => sub(AdminSection::Posts, "预览"),
433        AdminPage::AdminNotebooks => sub(AdminSection::Notes, "笔记本"),
434        AdminPage::NewNote => sub(AdminSection::Notes, "新建笔记"),
435        AdminPage::EditNote => sub(AdminSection::Notes, "编辑笔记"),
436        AdminPage::Profile => (
437            vec![Crumb::text("账户"), Crumb::current("个人资料")],
438            "个人资料",
439        ),
440        other => match AdminSection::of(other) {
441            Some(section) => (
442                vec![
443                    Crumb::text(section.group()),
444                    Crumb::current(section.label()),
445                ],
446                section.label(),
447            ),
448            None => (vec![Crumb::current("后台")], "后台"),
449        },
450    }
451}
452
453/// 侧栏数量文字:评论有待审时显示待审数(陶土强调),否则显示总数。
454fn nav_count(section: AdminSection, counts: Option<AdminNavCounts>) -> Option<(String, bool)> {
455    let counts = counts?;
456    match section {
457        AdminSection::Posts => Some((counts.posts.to_string(), false)),
458        AdminSection::Assets => Some((counts.assets.to_string(), false)),
459        AdminSection::Comments if counts.pending_comments > 0 => {
460            Some((format!("{} 待审", counts.pending_comments), true))
461        }
462        AdminSection::Comments => Some((counts.comments.to_string(), false)),
463        _ => None,
464    }
465}
466
467/// 后台布局:校验登录态,提供侧栏/操作上下文,渲染外壳与嵌套路由出口。
468///
469/// 登录态校验期沿用同一布局内的页面骨架屏;懒加载子路由分包挂起时由 `main` 顶部的
470/// `.route-pending` 槽位占位目标页骨架,显隐跟随协调器的 `is-route-pending`;明确未登录时
471/// `navigation::replace("/login")`,验证请求失败则留在原位重试,不渲染子页面。
472/// 作为 `/admin` 懒加载父路由的视图使用,子路由内容由 `<Outlet/>` 渲染。
473#[component]
474pub fn AdminLayout() -> impl IntoView {
475    let ctx = expect_context::<UserContext>();
476    // 懒加载子路由切换期间旧页面仍在:外壳跟随已显示的页面,骨架屏占位目标页。
477    let route = crate::navigation::use_rendered_route();
478    let pathname = Memo::new(move |_| route.read().pathname.clone());
479    let page = Memo::new(move |_| AdminPage::from_path(&pathname.read()));
480    let target_path = use_location().pathname;
481    let target_page = Memo::new(move |_| AdminPage::from_path(&target_path.read()));
482    let pending = Memo::new(move |_| admin_pending(&target_path.read(), &pathname.read()));
483    let counts = RwSignal::new(None::<AdminNavCounts>);
484
485    let auth_failed = RwSignal::new(false);
486    let auth_retrying = RwSignal::new(false);
487    let auth_reload = RwSignal::new(0_u32);
488
489    // 进入后台与重试时校验会话;只有明确未登录才跳转,服务错误不记为已校验。
490    Effect::new(move |_| {
491        auth_reload.track();
492        if ctx.checked.get_untracked() {
493            return;
494        }
495        auth_retrying.set(true);
496        spawn_local(async move {
497            let response = get_current_user().await;
498            // 离开后台后丢弃迟到响应,避免覆盖后续登录状态或触发跳转。
499            if auth_retrying.try_get_untracked().is_none() {
500                return;
501            }
502            auth_retrying.set(false);
503            match response {
504                Ok(response) => {
505                    let signed_in = response.user.is_some();
506                    ctx.user.set(response.user.map(Arc::new));
507                    ctx.checked.set(true);
508                    if !signed_in {
509                        crate::navigation::replace("/login");
510                    }
511                }
512                Err(_) => auth_failed.set(true),
513            }
514        });
515    });
516
517    let signed_in = Memo::new(move |_| ctx.checked.get() && ctx.user.read().is_some());
518
519    // 登录后与每次换页刷新侧栏数量:增删文章、素材或审核评论后回到列表即可看到新值。
520    // 世代号丢弃过期响应。
521    let count_generation = StoredValue::new(0_u64);
522    Effect::new(move |_| {
523        let _ = pathname.get();
524        if !signed_in.get() {
525            return;
526        }
527        let mut id = 0;
528        count_generation.update_value(|g| {
529            *g += 1;
530            id = *g;
531        });
532        spawn_local(async move {
533            if let Ok(next) = get_admin_nav_counts().await {
534                if count_generation.try_get_value() == Some(id) {
535                    let _ = counts.try_set(Some(next));
536                }
537            }
538        });
539    });
540
541    let is_write = Memo::new(move |_| page.get().is_write());
542    let internal_scroll = Memo::new(move |_| page.get().scrolls_internally());
543
544    let on_logout = Callback::new(move |()| {
545        spawn_local(async move {
546            if logout().await.is_ok_and(|response| response.success) {
547                crate::bridges::navigation::clear_admin_state();
548            }
549            let _ = ctx.user.try_set(None);
550            let _ = ctx.checked.try_set(false);
551            crate::navigation::push("/login");
552        });
553    });
554    provide_context(AdminActions { logout: on_logout });
555    use_search_palette_provider();
556
557    let user_name = Memo::new(move |_| {
558        ctx.user
559            .read()
560            .as_ref()
561            .map(|user| user.display_label().to_string())
562    });
563    let avatar_url = Memo::new(move |_| {
564        ctx.user
565            .read()
566            .as_ref()
567            .and_then(|user| user.avatar_url.clone())
568    });
569
570    // 登录态校验期与分包挂起槽位共用的页面骨架。
571    let skeleton = move || {
572        view! {
573            <div class="flex-1 min-h-0 flex flex-col animate-pulse">
574                {move || admin_route_skeleton(target_page.get())}
575            </div>
576        }
577    };
578    let content = move || {
579        move || {
580            if signed_in.get() {
581                view! {
582                    // 分包迟到时在 main 顶部占位目标页骨架;显隐由 CSS 跟随协调器的 is-route-pending。
583                    <Show when=move || pending.get()>
584                        <div class="route-pending admin-route-pending" aria-hidden="true">
585                            {skeleton()}
586                        </div>
587                    </Show>
588                    // 兜底子页面未被自身 Suspense 包住的资源读取;分包未到时由上方槽位占位。
589                    <Suspense fallback=skeleton>
590                        <Outlet/>
591                    </Suspense>
592                }
593                .into_any()
594            } else if auth_failed.get() {
595                view! {
596                    <LoadError
597                        title="登录状态验证失败"
598                        retrying=auth_retrying
599                        on_retry=move |()| {
600                            auth_retrying.set(true);
601                            auth_reload.update(|n| *n = n.wrapping_add(1));
602                        }
603                    />
604                }
605                .into_any()
606            } else {
607                skeleton().into_any()
608            }
609        }
610    };
611
612    let crumbs = Signal::derive(move || admin_crumbs(page.get()).0);
613    let title = Signal::derive(move || admin_crumbs(page.get()).1);
614
615    view! {
616        <AdminShell
617            sidebar=move || {
618                view! {
619                    <AdminSidebar
620                        page=page
621                        user_name=user_name
622                        avatar_url=avatar_url
623                        counts=counts
624                        on_logout=on_logout
625                    />
626                }
627            }
628            drawer=move || {
629                view! {
630                    <AdminSidebar
631                        page=page
632                        user_name=user_name
633                        avatar_url=avatar_url
634                        counts=counts
635                        on_logout=on_logout
636                        drawer=true
637                    />
638                }
639            }
640            crumbs=crumbs
641            title=title
642            content=content
643            internal_scroll=internal_scroll
644            focus_write=is_write
645            checking=Signal::derive(move || !signed_in.get())
646        />
647        <Show when=move || signed_in.get()>
648            <AdminPalette on_logout=on_logout/>
649        </Show>
650    }
651}
652
653/// 后台页面共用的操作(如个人资料页的「退出登录」),由 AdminLayout 提供。
654#[derive(Clone, Copy)]
655pub struct AdminActions {
656    /// 退出当前设备并回到登录页。
657    pub logout: Callback<()>,
658}
659
660/// 后台页面标题行:左侧 h1 与副标题,右侧可选操作区。样式见 admin.css `.admin-page-head`。
661///
662/// - `title`:页面标题(手机宽度隐藏,顶栏已显示)
663/// - `class`:附加类,如日志页的 `logs-head`
664/// - `actions`:右侧内容(传闭包),统一包进 `.admin-page-actions`
665/// - `children`:副标题内容;为空时 `p` 由 CSS `min-height: 1lh` 保留一行高度
666#[component]
667pub(crate) fn AdminPageHead(
668    #[prop(into)] title: Signal<String>,
669    #[prop(optional)] class: Option<&'static str>,
670    #[prop(optional, into)] actions: Option<ViewFn>,
671    #[prop(optional)] children: Option<Children>,
672) -> impl IntoView {
673    let extra = class.unwrap_or_default();
674    view! {
675        <div class=format!("admin-page-head {extra}")>
676            <div>
677                <h1>{move || title.get()}</h1>
678                <p>{children.map(|children| children())}</p>
679            </div>
680            {actions.map(|actions| view! { <div class="admin-page-actions">{actions.run()}</div> })}
681        </div>
682    }
683}
684
685/// 后台外壳:侧栏、顶栏与主画布。认证和路由留在 [`AdminLayout`]。
686///
687/// Props:
688/// - `sidebar` / `drawer`:桌面侧栏与手机抽屉里的导航(抽屉每次打开重新创建)
689/// - `crumbs` / `title`:顶栏面包屑与手机顶栏标题
690/// - `content`:主画布内容(传返回响应式视图的闭包以随登录态切换)
691/// - `internal_scroll`:页面在主内容区内部自行滚动
692/// - `focus_write`:写作页自带页头,隐藏后台顶栏
693/// - `checking`:登录态校验中,主画布不滚动
694#[component]
695pub(crate) fn AdminShell(
696    #[prop(into)] sidebar: ViewFn,
697    #[prop(into)] drawer: ViewFn,
698    #[prop(into)] crumbs: Signal<Vec<Crumb>>,
699    #[prop(into)] title: Signal<&'static str>,
700    #[prop(into)] content: ViewFn,
701    #[prop(optional, into)] internal_scroll: Signal<bool>,
702    #[prop(optional, into)] focus_write: Signal<bool>,
703    #[prop(optional, into)] checking: Signal<bool>,
704) -> impl IntoView {
705    // 挂载前为 None:首屏由预载脚本写在 <html> 上的状态决定,服务端与水合输出一致。
706    let state = RwSignal::new(None::<SidebarState>);
707    let drag = RwSignal::new(None::<(f64, f64, f64)>);
708    let drawer_open = RwSignal::new(false);
709    let resize_ref = NodeRef::<leptos::html::Div>::new();
710
711    Effect::new(move |_| {
712        state.set(Some(load_sidebar()));
713    });
714    let commit = move |next: SidebarState| {
715        state.set(Some(next));
716        save_sidebar(next);
717    };
718    provide_context(SidebarControl {
719        state,
720        toggle: Callback::new(move |()| {
721            let current = state.get_untracked().unwrap_or_default();
722            commit(SidebarState {
723                collapsed: !current.collapsed,
724                ..current
725            });
726        }),
727    });
728
729    let shown = move || {
730        drag.get()
731            .map(|(start_x, start_width, x)| dragged_width(start_width, x - start_x))
732            .or(state.get().map(SidebarState::shown))
733    };
734    let data_sidebar = move || {
735        state
736            .get()
737            .map(|s| if s.collapsed { "collapsed" } else { "expanded" })
738    };
739    let root_style = move || match (state.get(), shown()) {
740        (Some(s), Some(w)) => format!(
741            "--admin-sidebar-w: {}px; --admin-sidebar-shown: {w}px;",
742            s.width
743        ),
744        _ => String::new(),
745    };
746    let valuenow = move || shown().unwrap_or(SIDEBAR_DEFAULT).round().to_string();
747    let scroll_class = move || {
748        if internal_scroll.get() || checking.get() {
749            "admin-scroll admin-scroll-fixed"
750        } else {
751            "admin-scroll"
752        }
753    };
754    let main_class = move || {
755        if internal_scroll.get() {
756            "admin-main admin-main-fill"
757        } else {
758            "admin-main"
759        }
760    };
761    let menu_id = "admin-nav-drawer";
762    let menu_trigger = format!("{menu_id}-trigger");
763    let palette = use_search_palette();
764
765    view! {
766        <div
767            class="admin-shell"
768            // 写作页自带页头,隐藏后台顶栏。
769            data-focus=move || focus_write.get().then_some("write")
770            data-sidebar=data_sidebar
771            data-resizing=move || drag.get().is_some().then_some("true")
772            style=root_style
773        >
774            <script inner_html=SIDEBAR_PRELOAD_SCRIPT></script>
775            <div class="admin-side-slot">
776                {sidebar.run()}
777                <div
778                    id="admin-resize"
779                    node_ref=resize_ref
780                    class="admin-resize"
781                    role="separator"
782                    tabindex="0"
783                    aria-orientation="vertical"
784                    aria-label="调整侧栏宽度"
785                    aria-valuemin=SIDEBAR_RAIL.to_string()
786                    aria-valuemax=SIDEBAR_MAX.to_string()
787                    aria-valuenow=valuenow
788                    title="拖动调整宽度,双击恢复默认"
789                    on:pointerdown=move |e| {
790                        if e.button() != 0 {
791                            return;
792                        }
793                        e.prevent_default();
794                        let x = f64::from(e.client_x());
795                        let start = state.get_untracked().unwrap_or_default().shown();
796                        drag.set(Some((x, start, x)));
797                        #[cfg(target_arch = "wasm32")]
798                        if let Some(el) = resize_ref.get_untracked() {
799                            let _ = el.set_pointer_capture(e.pointer_id());
800                        }
801                    }
802                    on:pointermove=move |e| {
803                        if let Some((start_x, start_width, _)) = drag.get_untracked() {
804                            drag.set(Some((start_x, start_width, f64::from(e.client_x()))));
805                        }
806                    }
807                    on:pointerup=move |_| {
808                        let Some((start_x, start_width, x)) = drag.get_untracked() else {
809                            return;
810                        };
811                        drag.set(None);
812                        if (x - start_x).abs() < DRAG_DEAD_ZONE {
813                            return;
814                        }
815                        let base = state.get_untracked().unwrap_or_default();
816                        commit(base.settle(dragged_width(start_width, x - start_x)));
817                    }
818                    on:pointercancel=move |_| drag.set(None)
819                    on:dblclick=move |_| commit(SidebarState::default())
820                    on:keydown=move |e| {
821                        let base = state.get_untracked().unwrap_or_default();
822                        let next = match e.key().as_str() {
823                            "ArrowLeft" => base.nudge(-SIDEBAR_KEY_STEP),
824                            "ArrowRight" => base.nudge(SIDEBAR_KEY_STEP),
825                            "Enter" => SidebarState {
826                                collapsed: !base.collapsed,
827                                ..base
828                            },
829                            _ => return,
830                        };
831                        e.prevent_default();
832                        commit(next);
833                    }
834                ></div>
835            </div>
836            <div class="admin-canvas">
837                <div data-vt-scroll="admin-main" class=scroll_class>
838                    <header class="admin-topbar">
839                        <button
840                            id=menu_trigger.clone()
841                            class="admin-topbar-menu touch-icon"
842                            type="button"
843                            aria-label="打开后台导航"
844                            aria-haspopup="dialog"
845                            aria-expanded=move || drawer_open.get().to_string()
846                            aria-controls=menu_id
847                            on:click=move |_| drawer_open.set(true)
848                        >
849                            <Icon name=IconName::Menu size=19/>
850                        </button>
851                        <div class="admin-topbar-crumbs">
852                            <Breadcrumb items=crumbs/>
853                        </div>
854                        <span class="admin-topbar-title">{move || title.get()}</span>
855                        <div class="admin-topbar-actions">
856                            {palette
857                                .map(|palette| {
858                                    view! {
859                                        <button
860                                            class="touch-icon"
861                                            type="button"
862                                            aria-label="搜索或执行命令"
863                                            aria-haspopup="dialog"
864                                            on:click=move |_| palette.open()
865                                        >
866                                            <Icon name=IconName::Search size=19/>
867                                        </button>
868                                    }
869                                })}
870                            <a class="btn btn-sm btn-ghost admin-topbar-site" href="/">
871                                "查看前台"
872                                <Icon name=IconName::ArrowUpRight size=14/>
873                            </a>
874                        </div>
875                    </header>
876                    <main class=main_class>{content.run()}</main>
877                </div>
878            </div>
879        </div>
880        <SideSheet open=drawer_open id=menu_id trigger_id=menu_trigger label="后台导航">
881            {drawer.run()}
882        </SideSheet>
883    }
884}
885
886/// 侧栏折叠状态与切换,由 [`AdminShell`] 提供给侧栏头部的收起按钮。
887#[derive(Clone, Copy)]
888struct SidebarControl {
889    state: RwSignal<Option<SidebarState>>,
890    toggle: Callback<()>,
891}
892
893/// 后台侧栏:栏目导航、搜索入口、主题切换与用户区。
894///
895/// Props:
896/// - `page`:当前后台页面(决定激活栏目)
897/// - `user_name` / `avatar_url`:底部用户行;未登录或未校验时显示占位
898/// - `counts`:文章、素材、评论的数量;未加载时不显示
899/// - `drawer`:放在手机抽屉里(始终展开、点选后关闭抽屉、没有收起按钮)
900#[component]
901pub(crate) fn AdminSidebar(
902    #[prop(into)] page: Signal<AdminPage>,
903    #[prop(into)] user_name: Signal<Option<String>>,
904    #[prop(into)] avatar_url: Signal<Option<String>>,
905    #[prop(into)] on_logout: Callback<()>,
906    #[prop(into)] counts: Signal<Option<AdminNavCounts>>,
907    #[prop(optional)] drawer: bool,
908) -> impl IntoView {
909    let control = use_context::<SidebarControl>();
910    let collapsed = Signal::derive(move || {
911        !drawer
912            && control
913                .and_then(|c| c.state.get())
914                .is_some_and(|s| s.collapsed)
915    });
916    let sheet_close = use_context::<SheetClose>().filter(|_| drawer);
917    let active_section = Memo::new(move |_| AdminSection::of(page.get()));
918    let profile_active = Memo::new(move |_| page.get() == AdminPage::Profile);
919    let after_pick = move || {
920        if let Some(close) = sheet_close {
921            close.close();
922        }
923    };
924    let palette = use_search_palette();
925    // 图标栏里按钮贴左缘,提示朝右展开以免被裁。
926    let toggle_align =
927        Signal::derive(move || if collapsed.get() { "start" } else { "end" }.to_string());
928
929    let head_button = if drawer {
930        view! {
931            <IconButton
932                icon=IconName::X
933                label="关闭导航"
934                tooltip_align="end"
935                onclick=move |_| after_pick()
936            />
937        }
938        .into_any()
939    } else if let Some(control) = control {
940        view! {
941            <IconButton
942                icon=IconName::PanelLeft
943                label=Signal::derive(move || {
944                    if collapsed.get() { "展开侧栏" } else { "收起侧栏" }.to_string()
945                })
946                pressed=Signal::derive(move || Some(collapsed.get()))
947                class="admin-side-toggle"
948                tooltip_align=toggle_align
949                tooltip_placement="bottom"
950                onclick=move |_| control.toggle.run(())
951            />
952        }
953        .into_any()
954    } else {
955        ().into_any()
956    };
957
958    let search_button = palette.map(|palette| {
959        view! {
960            <button
961                class="admin-search"
962                type="button"
963                aria-haspopup="dialog"
964                data-tip="搜索或执行命令 ⌘K"
965                on:click=move |_| {
966                    after_pick();
967                    palette.open();
968                }
969            >
970                <Icon name=IconName::Search size=15/>
971                <span class="admin-search-text">"搜索或执行命令"</span>
972                <kbd class="admin-search-kbd">"⌘K"</kbd>
973            </button>
974        }
975    });
976
977    let groups = NAV_GROUPS
978        .into_iter()
979        .map(|(group, items)| {
980            let label = group.map(|group| view! { <p class="admin-nav-label">{group}</p> });
981            let items = items
982                .iter()
983                .copied()
984                .map(|section| {
985                    view! {
986                        <a
987                            class="admin-nav-item"
988                            href=section.route().to_string()
989                            data-tip=section.label()
990                            aria-current=move || {
991                                (active_section.get() == Some(section)).then_some("page")
992                            }
993                            on:click=move |_| after_pick()
994                        >
995                            <span class="admin-nav-dot" aria-hidden="true"></span>
996                            <Icon name=section.icon() size=17 class="admin-nav-icon"/>
997                            <span class="admin-nav-text">{section.label()}</span>
998                            {move || {
999                                nav_count(section, counts.get())
1000                                    .map(|(count, alert)| {
1001                                        view! {
1002                                            <span class=if alert {
1003                                                "admin-nav-count admin-nav-count-alert"
1004                                            } else {
1005                                                "admin-nav-count"
1006                                            }>{count}</span>
1007                                        }
1008                                    })
1009                            }}
1010                        </a>
1011                    }
1012                })
1013                .collect_view();
1014            view! {
1015                <div class="admin-nav-group">
1016                    {label} {items}
1017                </div>
1018            }
1019        })
1020        .collect_view();
1021
1022    view! {
1023        <aside
1024            data-vt-shell=(!drawer).then_some("admin-sidebar")
1025            class=if drawer { "admin-side admin-side-drawer" } else { "admin-side" }
1026            aria-label="后台导航"
1027        >
1028            <div class="admin-side-head">
1029                <a class="wordmark" href="/" aria-label="Yggdrasil 首页">
1030                    <span class="wordmark-mark" aria-hidden="true"></span>
1031                    <span class="admin-side-text">"Yggdrasil"</span>
1032                </a>
1033                <span class="admin-side-badge admin-side-text">"后台"</span>
1034                {head_button}
1035            </div>
1036            {search_button}
1037            <nav class="admin-nav" aria-label="后台栏目">
1038                {groups}
1039            </nav>
1040            <div class="admin-side-foot">
1041                <div class="admin-side-theme">
1042                    <ThemeToggle/>
1043                </div>
1044                <div class="admin-user">
1045                    {move || match user_name.get() {
1046                        Some(name) => {
1047                            view! {
1048                                <a
1049                                    class="admin-user-link"
1050                                    href=Route::Profile.to_string()
1051                                    data-tip="个人资料"
1052                                    aria-current=move || profile_active.get().then_some("page")
1053                                    on:click=move |_| after_pick()
1054                                >
1055                                    <UserAvatar
1056                                        name=name.clone()
1057                                        avatar_url=avatar_url
1058                                        class="w-7 h-7 rounded-full text-xs flex-shrink-0"
1059                                    />
1060                                    <span class="admin-user-text">
1061                                        <span class="admin-user-name">{name}</span>
1062                                        <span class="admin-user-role">"个人资料"</span>
1063                                    </span>
1064                                </a>
1065                            }
1066                                .into_any()
1067                        }
1068                        None => {
1069                            view! {
1070                                <div class="admin-user-link" aria-hidden="true">
1071                                    <span class="w-7 h-7 rounded-full bg-ink-2 animate-pulse flex-shrink-0"></span>
1072                                    <span class="admin-user-text">
1073                                        <span class="h-3 w-16 rounded bg-ink-2 animate-pulse"></span>
1074                                    </span>
1075                                </div>
1076                            }
1077                                .into_any()
1078                        }
1079                    }}
1080                    <IconButton
1081                        icon=IconName::LogOut
1082                        label="退出登录"
1083                        size=ButtonSize::Sm
1084                        class="admin-user-logout"
1085                        tooltip_align="end"
1086                        onclick=move |_| on_logout.run(())
1087                    />
1088                </div>
1089            </div>
1090        </aside>
1091    }
1092}
1093
1094/// 懒加载子路由挂起期间,目标是否为需要后台骨架占位的后台页面。
1095fn admin_pending(target: &str, rendered: &str) -> bool {
1096    target != rendered
1097        && matches!(
1098            crate::route_prefetch::match_route(target),
1099            Some(crate::route_prefetch::RouteTarget::Admin(_))
1100        )
1101}
1102
1103/// 根据当前后台页面,渲染对应的专属骨架屏。
1104fn admin_route_skeleton(page: AdminPage) -> AnyView {
1105    match page {
1106        AdminPage::Dashboard | AdminPage::Other => view! { <AdminDashboardSkeleton/> }.into_any(),
1107        AdminPage::Write | AdminPage::WriteEdit | AdminPage::NewNote | AdminPage::EditNote => {
1108            view! { <WriteSkeleton/> }.into_any()
1109        }
1110        AdminPage::Posts => view! { <PostsSkeleton/> }.into_any(),
1111        AdminPage::AdminNotes => view! { <AdminNotesSkeleton/> }.into_any(),
1112        AdminPage::AdminNotebooks => view! { <AdminNotebooksSkeleton/> }.into_any(),
1113        AdminPage::PostsTrash => view! { <PostsTrashSkeleton/> }.into_any(),
1114        AdminPage::Assets => view! { <AssetsSkeleton/> }.into_any(),
1115        AdminPage::FriendsAdmin => view! { <FriendsAdminSkeleton/> }.into_any(),
1116        AdminPage::AdminComments => view! { <AdminCommentsSkeleton/> }.into_any(),
1117        AdminPage::System => view! { <SystemSkeleton/> }.into_any(),
1118        AdminPage::Runner => view! { <RunnerSkeleton/> }.into_any(),
1119        AdminPage::Mcp => view! { <McpSkeleton/> }.into_any(),
1120        AdminPage::Logs => view! { <LogsSkeleton/> }.into_any(),
1121        AdminPage::Settings => view! { <SettingsAdminSkeleton/> }.into_any(),
1122        AdminPage::Profile => view! { <ProfileSkeleton/> }.into_any(),
1123        AdminPage::PostPreview => view! { <PostPreviewSkeleton/> }.into_any(),
1124    }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130
1131    #[test]
1132    fn sidebar_restore_rejects_out_of_range_width() {
1133        let state = SidebarState::restore(Some("280"), Some("true"));
1134        assert_eq!(state.width, 280.0);
1135        assert!(state.collapsed);
1136        assert_eq!(
1137            SidebarState::restore(Some("40"), None).width,
1138            SIDEBAR_DEFAULT
1139        );
1140        assert_eq!(
1141            SidebarState::restore(Some("abc"), None).width,
1142            SIDEBAR_DEFAULT
1143        );
1144        assert!(!SidebarState::restore(None, Some("1")).collapsed);
1145    }
1146
1147    #[test]
1148    fn sidebar_settle_snaps_to_rail_or_minimum() {
1149        let base = SidebarState::default();
1150        assert!(base.settle(120.0).collapsed);
1151        // 收起时保留展开宽度,再展开回到原宽度。
1152        assert_eq!(base.settle(120.0).width, SIDEBAR_DEFAULT);
1153        let snapped = base.settle(170.0);
1154        assert!(!snapped.collapsed);
1155        assert_eq!(snapped.width, SIDEBAR_MIN);
1156        assert_eq!(base.settle(400.0).width, SIDEBAR_MAX);
1157        assert_eq!(base.settle(260.0).width, 260.0);
1158    }
1159
1160    #[test]
1161    fn sidebar_nudge_collapses_below_minimum_and_expands_from_rail() {
1162        let narrow = SidebarState {
1163            width: SIDEBAR_MIN,
1164            collapsed: false,
1165        };
1166        assert!(narrow.nudge(-SIDEBAR_KEY_STEP).collapsed);
1167        let rail = SidebarState {
1168            width: 240.0,
1169            collapsed: true,
1170        };
1171        assert_eq!(rail.nudge(-SIDEBAR_KEY_STEP), rail);
1172        let expanded = rail.nudge(SIDEBAR_KEY_STEP);
1173        assert!(!expanded.collapsed);
1174        assert_eq!(expanded.width, 240.0);
1175        assert_eq!(expanded.nudge(SIDEBAR_KEY_STEP).width, 256.0);
1176    }
1177
1178    #[test]
1179    fn dragged_width_follows_pointer_within_rail_and_maximum() {
1180        assert_eq!(dragged_width(232.0, -300.0), SIDEBAR_RAIL);
1181        assert_eq!(dragged_width(232.0, 40.0), 272.0);
1182        assert_eq!(dragged_width(232.0, 400.0), SIDEBAR_MAX);
1183    }
1184
1185    #[test]
1186    fn page_from_path_matches_every_admin_route() {
1187        let cases = [
1188            (Route::Admin, AdminPage::Dashboard),
1189            (Route::Write, AdminPage::Write),
1190            (Route::WriteEdit { id: 7 }, AdminPage::WriteEdit),
1191            (
1192                Route::PostPreview {
1193                    slug: "a b".to_string(),
1194                },
1195                AdminPage::PostPreview,
1196            ),
1197            (Route::Posts, AdminPage::Posts),
1198            (Route::PostsTrash, AdminPage::PostsTrash),
1199            (Route::AdminNotes, AdminPage::AdminNotes),
1200            (Route::NewNote, AdminPage::NewNote),
1201            (Route::EditNote { id: 3 }, AdminPage::EditNote),
1202            (Route::AdminNotebooks, AdminPage::AdminNotebooks),
1203            (Route::Assets, AdminPage::Assets),
1204            (Route::FriendsAdmin, AdminPage::FriendsAdmin),
1205            (Route::AdminComments, AdminPage::AdminComments),
1206            (
1207                Route::AdminCommentsPage { page: 2 },
1208                AdminPage::AdminComments,
1209            ),
1210            (Route::System, AdminPage::System),
1211            (Route::Runner, AdminPage::Runner),
1212            (Route::Mcp, AdminPage::Mcp),
1213            (Route::Logs, AdminPage::Logs),
1214            (Route::SiteSettingsPage, AdminPage::Settings),
1215            (Route::Profile, AdminPage::Profile),
1216        ];
1217        for (route, expected) in cases {
1218            assert_eq!(
1219                AdminPage::from_path(&route.to_string()),
1220                expected,
1221                "{route}"
1222            );
1223        }
1224        // 末尾斜杠与未知路径。
1225        assert_eq!(AdminPage::from_path("/admin/"), AdminPage::Dashboard);
1226        assert_eq!(AdminPage::from_path("/admin/posts/"), AdminPage::Posts);
1227        assert_eq!(AdminPage::from_path("/admin/nope"), AdminPage::Other);
1228        assert_eq!(AdminPage::from_path("/admin/posts/1/2"), AdminPage::Other);
1229        assert_eq!(AdminPage::from_path("/administrator"), AdminPage::Other);
1230        assert_eq!(AdminPage::from_path("/login"), AdminPage::Other);
1231    }
1232
1233    #[test]
1234    fn scroll_and_write_flags_follow_page() {
1235        assert!(AdminPage::Write.is_write());
1236        assert!(AdminPage::EditNote.is_write());
1237        assert!(!AdminPage::Posts.is_write());
1238        assert!(AdminPage::Settings.scrolls_internally());
1239        assert!(AdminPage::Logs.scrolls_internally());
1240        assert!(AdminPage::NewNote.scrolls_internally());
1241        assert!(!AdminPage::Dashboard.scrolls_internally());
1242    }
1243
1244    #[test]
1245    fn subpages_belong_to_their_section() {
1246        assert_eq!(
1247            AdminSection::of(AdminPage::PostsTrash),
1248            Some(AdminSection::Posts)
1249        );
1250        assert_eq!(
1251            AdminSection::of(AdminPage::EditNote),
1252            Some(AdminSection::Notes)
1253        );
1254        assert_eq!(AdminSection::of(AdminPage::Profile), None);
1255        let (crumbs, title) = admin_crumbs(AdminPage::AdminNotebooks);
1256        assert_eq!(title, "笔记本");
1257        assert_eq!(crumbs[0].to, Some(Route::AdminNotes.to_string()));
1258        let (crumbs, title) = admin_crumbs(AdminPage::Logs);
1259        assert_eq!(title, "日志");
1260        assert_eq!(crumbs[0].label, "系统");
1261        assert_eq!(crumbs[0].to, None);
1262        let (crumbs, title) = admin_crumbs(AdminPage::Other);
1263        assert_eq!(title, "后台");
1264        assert_eq!(crumbs.len(), 1);
1265    }
1266
1267    #[test]
1268    fn admin_pending_only_covers_admin_routes() {
1269        assert!(admin_pending("/admin/logs", "/admin"));
1270        assert!(!admin_pending("/admin", "/admin"));
1271        assert!(!admin_pending("/", "/admin"));
1272        assert!(!admin_pending("/login", "/admin"));
1273    }
1274
1275    #[test]
1276    fn comment_count_prefers_pending() {
1277        let mut counts = AdminNavCounts {
1278            posts: 41,
1279            assets: 37,
1280            comments: 8,
1281            pending_comments: 0,
1282        };
1283        assert_eq!(
1284            nav_count(AdminSection::Comments, Some(counts)),
1285            Some(("8".to_string(), false))
1286        );
1287        counts.pending_comments = 2;
1288        assert_eq!(
1289            nav_count(AdminSection::Comments, Some(counts)),
1290            Some(("2 待审".to_string(), true))
1291        );
1292        assert_eq!(nav_count(AdminSection::Notes, Some(counts)), None);
1293        assert_eq!(nav_count(AdminSection::Posts, None), None);
1294    }
1295
1296    #[test]
1297    fn page_head_renders_title_subtitle_and_actions() {
1298        let html = Owner::new().with(|| {
1299            view! {
1300                <AdminPageHead
1301                    title="日志"
1302                    class="logs-head"
1303                    actions=|| view! { <button>"刷新"</button> }
1304                >
1305                    "运行日志"
1306                </AdminPageHead>
1307            }
1308            .to_html()
1309        });
1310        assert!(
1311            html.contains("class=\"admin-page-head logs-head\""),
1312            "{html}"
1313        );
1314        assert!(html.contains("日志"), "{html}");
1315        assert!(html.contains("运行日志"), "{html}");
1316        assert!(html.contains("class=\"admin-page-actions\""), "{html}");
1317        let bare = Owner::new().with(|| view! { <AdminPageHead title="x"/> }.to_html());
1318        assert!(bare.contains("class=\"admin-page-head\""), "{bare}");
1319        assert!(!bare.contains("admin-page-actions"), "{bare}");
1320    }
1321}