1use dioxus::prelude::*;
7use dioxus::router::components::Link;
8
9use crate::api::auth::{get_current_user, logout};
10use crate::components::skeletons::admin_comments_skeleton::AdminCommentsSkeleton;
11use crate::components::skeletons::assets_skeleton::AssetsSkeleton;
12use crate::components::skeletons::dashboard_skeleton::AdminDashboardSkeleton;
13
14use crate::components::skeletons::friends_admin_skeleton::FriendsAdminSkeleton;
15use crate::components::skeletons::logs_skeleton::LogsSkeleton;
16use crate::components::skeletons::mcp_skeleton::McpSkeleton;
17use crate::components::skeletons::post_preview_skeleton::PostPreviewSkeleton;
18use crate::components::skeletons::posts_skeleton::PostsSkeleton;
19use crate::components::skeletons::posts_trash_skeleton::PostsTrashSkeleton;
20use crate::components::skeletons::profile_skeleton::ProfileSkeleton;
21use crate::components::skeletons::runner_skeleton::RunnerSkeleton;
22use crate::components::skeletons::settings_admin_skeleton::SettingsAdminSkeleton;
23use crate::components::skeletons::system_skeleton::SystemSkeleton;
24use crate::components::skeletons::write_skeleton::WriteSkeleton;
25use crate::components::ui::UserAvatar;
26use crate::context::UserContext;
27use crate::router::Route;
28use crate::theme::ThemeToggle;
29
30#[component]
31pub fn AdminLayout() -> Element {
32 let mut ctx: UserContext = use_context();
33 let navigator = dioxus::router::navigator();
34 let route = use_route::<Route>();
35
36 use_effect(move || {
37 if !(ctx.checked)() {
38 (ctx.checked).set(true);
39 spawn(async move {
40 match get_current_user().await {
41 Ok(response) => {
42 if let Some(user) = response.user {
43 ctx.user.set(Some(std::sync::Arc::new(user)));
44 } else {
45 let _ = navigator.push(Route::Login {});
46 }
47 }
48 Err(_) => {
49 let _ = navigator.push(Route::Login {});
50 }
51 }
52 });
53 }
54 });
55
56 let nav_items_top = vec![(Route::Admin {}, "仪表盘"), (Route::Write {}, "写文章")];
57 let nav_items_bottom = vec![(Route::Assets {}, "素材"), (Route::FriendsAdmin {}, "友链")];
58
59 let is_write_route =
60 matches!(route, Route::Write {}) || matches!(route, Route::WriteEdit { .. });
61 let internal_scroll_route = is_write_route
66 || matches!(route, Route::SiteSettingsPage {})
67 || matches!(route, Route::Logs {});
68
69 let card_overflow = if internal_scroll_route {
74 "overflow-hidden"
75 } else {
76 "overflow-y-auto"
77 };
78 let main_class = if internal_scroll_route {
79 "flex-1 w-full max-w-7xl mx-auto flex flex-col min-h-0"
80 } else {
81 "flex-1 w-full max-w-7xl mx-auto px-6 py-12"
82 };
83
84 let root_class =
85 "min-h-dvh flex bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] font-sans";
86
87 let is_profile_route = matches!(route, Route::Profile {});
89 let chip_state_class = if is_profile_route {
90 "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
91 } else {
92 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
93 };
94 let side_user = (ctx.user)();
96
97 let nav_content = rsx! {
98 aside { "data-vt-shell": "admin-sidebar", class: "w-48 flex-shrink-0 hidden md:flex flex-col h-screen sticky top-0 p-3 bg-[var(--color-paper-entry)]",
99 div { class: "mb-8 px-3",
101 Link {
108 class: "font-extrabold text-2xl tracking-tight text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors",
109 to: NavigationTarget::<Route>::External("/".to_string()),
110 "Yggdrasil."
111 }
112 }
113 nav { class: "flex-1 flex flex-col gap-2",
115 for (dest, label) in nav_items_top {
116 {nav_item(&route, dest, label, is_write_route)}
117 }
118 ContentNavGroup {}
120 for (dest, label) in nav_items_bottom {
121 {nav_item(&route, dest, label, is_write_route)}
122 }
123 ToolsNavGroup {}
124 }
125 div { class: "mt-auto pt-4 border-t border-[var(--color-paper-border)] flex flex-col gap-1",
127 if let Some(user) = side_user {
128 Link {
129 class: "flex items-center gap-2.5 px-3 py-2 rounded-2xl text-sm font-medium transition-all {chip_state_class}",
130 to: Route::Profile {},
131 UserAvatar {
132 name: user.display_label().to_string(),
133 avatar_url: user.avatar_url.clone(),
134 class: "w-7 h-7 rounded-full text-xs flex-shrink-0 border border-[var(--color-paper-border)]",
135 }
136 span { class: "truncate", "{user.display_label()}" }
137 }
138 } else {
139 div { class: "flex items-center gap-2.5 px-3 py-2",
141 div { class: "w-7 h-7 rounded-full bg-[var(--color-paper-theme)] animate-pulse flex-shrink-0" }
142 div { class: "h-3.5 w-16 rounded bg-[var(--color-paper-theme)] animate-pulse" }
143 }
144 }
145 div { class: "flex items-center justify-between px-3",
146 ThemeToggle {}
147 button {
148 class: "text-sm font-medium px-3 py-1.5 rounded-2xl bg-[var(--color-paper-theme)] border border-[var(--color-paper-border)] shadow-sm hover:shadow-md transition-all text-[var(--color-paper-secondary)] hover:text-red-500 cursor-pointer",
149 onclick: move |_| {
150 spawn(async move {
151 if logout().await.is_ok_and(|response| response.success) {
152 crate::bridges::navigation::clear_admin_state();
153 }
154 ctx.user.set(None);
155 ctx.checked.set(false);
156 let _ = navigator.push(Route::Login {});
157 });
158 },
159 "退出"
160 }
161 }
162 }
163 }
164 };
165
166 let skeleton_route = route.clone();
167 match ((ctx.checked)(), (ctx.user)()) {
168 (true, Some(_)) => {
169 rsx! {
170 div { class: "{root_class}",
171 {nav_content}
172 div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
173 div { "data-vt-scroll": "admin-main", class: "flex-1 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] {card_overflow} relative flex flex-col",
174 main { class: "{main_class}",
175 SuspenseBoundary {
181 fallback: move |_| rsx! {
182 div { class: "flex-1 min-h-0 flex flex-col animate-pulse", {admin_route_skeleton(&skeleton_route)} }
183 },
184 Outlet::<Route> {}
185 }
186 }
187 }
188 }
189 }
190 }
191 }
192 _ => {
193 rsx! {
194 div { class: "{root_class}",
195 {nav_content}
196 div { class: "flex-1 flex flex-col min-w-0 h-screen p-2 md:p-4",
197 div { "data-vt-scroll": "admin-main", class: "flex-1 bg-[var(--color-paper-theme)] rounded-[2rem] shadow-sm border border-[var(--color-paper-border)] overflow-hidden relative flex flex-col",
198 main { class: "{main_class}",
199 div { class: "flex-1 min-h-0 flex flex-col animate-pulse",
202 {admin_route_skeleton(&route)}
203 }
204 }
205 }
206 }
207 }
208 }
209 }
210 }
211}
212
213fn nav_item(route: &Route, dest: Route, label: &'static str, is_write_route: bool) -> Element {
216 let is_active = *route == dest || (label == "写文章" && is_write_route);
217 let base_class = "flex items-center px-3 py-2.5 rounded-2xl text-sm font-medium transition-all";
218 let text_class = if is_active {
219 "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]"
220 } else {
221 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
222 };
223 rsx! {
224 Link { key: "{label}", class: "{base_class} {text_class}", to: dest, "{label}" }
225 }
226}
227
228fn admin_route_skeleton(route: &Route) -> Element {
230 match route {
231 Route::Admin {} => rsx! {
232 AdminDashboardSkeleton {}
233 },
234 Route::Write {} | Route::WriteEdit { .. } => rsx! {
235 WriteSkeleton {}
236 },
237 Route::Posts {} => rsx! {
238 PostsSkeleton {}
239 },
240 Route::PostsTrash {} => rsx! {
241 PostsTrashSkeleton {}
242 },
243 Route::Assets {} => rsx! {
244 AssetsSkeleton {}
245 },
246 Route::FriendsAdmin {} => rsx! {
247 FriendsAdminSkeleton {}
248 },
249 Route::AdminComments {} | Route::AdminCommentsPage { .. } => {
250 rsx! {
251 AdminCommentsSkeleton {}
252 }
253 }
254 Route::System {} => rsx! {
255 SystemSkeleton {}
256 },
257 Route::Runner {} => rsx! {
258 RunnerSkeleton {}
259 },
260 Route::Mcp {} => rsx! {
261 McpSkeleton {}
262 },
263 Route::Logs {} => rsx! {
264 LogsSkeleton {}
265 },
266 Route::SiteSettingsPage {} => rsx! {
267 SettingsAdminSkeleton {}
268 },
269 Route::Profile {} => rsx! {
270 ProfileSkeleton {}
271 },
272 Route::PostPreview { .. } => rsx! {
273 PostPreviewSkeleton {}
274 },
275 _ => rsx! {
276 AdminDashboardSkeleton {}
277 },
278 }
279}
280
281#[derive(Clone, Copy, PartialEq)]
288enum NavGroupKind {
289 Content,
291 Tools,
293}
294
295impl NavGroupKind {
296 fn label(self) -> &'static str {
297 match self {
298 NavGroupKind::Content => "内容管理",
299 NavGroupKind::Tools => "工具",
300 }
301 }
302
303 fn contains(self, route: &Route) -> bool {
305 match self {
306 NavGroupKind::Content => matches!(
307 route,
308 Route::Posts {}
309 | Route::PostsTrash {}
310 | Route::AdminComments {}
311 | Route::AdminCommentsPage { .. }
312 ),
313 NavGroupKind::Tools => matches!(
314 route,
315 Route::Runner {}
316 | Route::Mcp {}
317 | Route::System {}
318 | Route::SiteSettingsPage {}
319 | Route::Logs {}
320 ),
321 }
322 }
323}
324
325#[component]
332fn NavGroup(kind: NavGroupKind, items: Vec<(Route, &'static str, bool)>) -> Element {
333 let route = use_route::<Route>();
334 let group_active = kind.contains(&route);
335 let mut expanded = use_signal(|| group_active);
336 let mut was_in_group = use_signal(|| group_active);
340
341 use_effect(move || {
344 let current = router().current::<Route>();
345 let now_in_group = kind.contains(¤t);
346 let was = was_in_group();
347
348 if now_in_group && !was {
349 expanded.set(true);
350 }
351 if was != now_in_group {
352 was_in_group.set(now_in_group);
353 }
354 });
355
356 let chevron_rotate = if expanded() { "rotate-180" } else { "" };
357 let parent_text_class = if group_active {
360 "text-[var(--color-paper-primary)] border border-transparent"
361 } else {
362 "text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent"
363 };
364 let label = kind.label();
365
366 rsx! {
367 div { class: "flex flex-col gap-1",
368 button {
369 class: "flex items-center justify-between w-full px-3 py-2.5 rounded-2xl text-sm font-medium transition-all cursor-pointer {parent_text_class}",
370 onclick: move |_| expanded.set(!expanded()),
371 span { "{label}" }
372 svg {
373 class: "w-4 h-4 transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
374 view_box: "0 0 24 24",
375 fill: "none",
376 stroke: "currentColor",
377 stroke_width: "2",
378 path {
379 stroke_linecap: "round",
380 stroke_linejoin: "round",
381 d: "M19 9l-7 7-7-7",
382 }
383 }
384 }
385 div {
387 class: "grid transition-all duration-300 ease-in-out",
388 style: if expanded() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
389 div { class: "overflow-hidden min-h-0",
390 div { class: "ml-3 pl-2.5 border-l border-[var(--color-paper-border)] flex flex-col gap-1",
392 for (dest, item_label, active) in items {
393 Link {
394 key: "{item_label}",
395 class: if active { "flex items-center px-2.5 py-1.5 rounded-xl text-sm font-medium transition-all bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] shadow-sm border border-[var(--color-paper-border)]" } else { "flex items-center px-2.5 py-1.5 rounded-xl text-sm font-medium transition-all text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)]/50 hover:text-[var(--color-paper-primary)] border border-transparent" },
396 to: dest,
397 "{item_label}"
398 }
399 }
400 }
401 }
402 }
403 }
404 }
405}
406
407#[component]
410fn ContentNavGroup() -> Element {
411 let route = use_route::<Route>();
412 let items = vec![
413 (
414 Route::Posts {},
415 "全部文章",
416 matches!(route, Route::Posts {}),
417 ),
418 (
419 Route::PostsTrash {},
420 "回收站",
421 matches!(route, Route::PostsTrash {}),
422 ),
423 (
424 Route::AdminComments {},
425 "评论管理",
426 matches!(
427 route,
428 Route::AdminComments {} | Route::AdminCommentsPage { .. }
429 ),
430 ),
431 ];
432 rsx! {
433 NavGroup { kind: NavGroupKind::Content, items }
434 }
435}
436
437#[component]
440fn ToolsNavGroup() -> Element {
441 let route = use_route::<Route>();
442 let items = vec![
443 (
444 Route::SiteSettingsPage {},
445 "设置",
446 matches!(route, Route::SiteSettingsPage {}),
447 ),
448 (
449 Route::Runner {},
450 "试运行",
451 matches!(route, Route::Runner {}),
452 ),
453 (Route::Mcp {}, "MCP", matches!(route, Route::Mcp {})),
454 (Route::Logs {}, "日志", matches!(route, Route::Logs {})),
455 (Route::System {}, "系统", matches!(route, Route::System {})),
456 ];
457 rsx! {
458 NavGroup { kind: NavGroupKind::Tools, items }
459 }
460}