1use 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
48const SIDEBAR_DEFAULT: f64 = 232.0;
50const SIDEBAR_MIN: f64 = 200.0;
51const SIDEBAR_MAX: f64 = 320.0;
52const SIDEBAR_RAIL: f64 = 56.0;
54const SIDEBAR_COLLAPSE_AT: f64 = 152.0;
56const SIDEBAR_KEY_STEP: f64 = 16.0;
58const 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
64const 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#[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 #[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 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 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 fn shown(self) -> f64 {
145 if self.collapsed {
146 SIDEBAR_RAIL
147 } else {
148 self.width
149 }
150 }
151}
152
153fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
192pub enum AdminPage {
193 Dashboard,
195 Posts,
197 PostsTrash,
199 Write,
201 WriteEdit,
203 PostPreview,
205 AdminNotes,
207 NewNote,
209 EditNote,
211 AdminNotebooks,
213 Assets,
215 FriendsAdmin,
217 AdminComments,
219 System,
221 Runner,
223 Mcp,
225 Logs,
227 Settings,
229 Profile,
231 Other,
233}
234
235impl AdminPage {
236 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 fn is_write(self) -> bool {
273 matches!(
274 self,
275 AdminPage::Write | AdminPage::WriteEdit | AdminPage::NewNote | AdminPage::EditNote
276 )
277 }
278
279 fn scrolls_internally(self) -> bool {
281 self.is_write() || matches!(self, AdminPage::Settings | AdminPage::Logs)
282 }
283}
284
285#[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
301const 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 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
415pub(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
453fn 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#[component]
474pub fn AdminLayout() -> impl IntoView {
475 let ctx = expect_context::<UserContext>();
476 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 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 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 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 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 <Show when=move || pending.get()>
584 <div class="route-pending admin-route-pending" aria-hidden="true">
585 {skeleton()}
586 </div>
587 </Show>
588 <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#[derive(Clone, Copy)]
655pub struct AdminActions {
656 pub logout: Callback<()>,
658}
659
660#[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#[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 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 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#[derive(Clone, Copy)]
888struct SidebarControl {
889 state: RwSignal<Option<SidebarState>>,
890 toggle: Callback<()>,
891}
892
893#[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 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
1094fn 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
1103fn 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 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 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}