Skip to main content

yggdrasil/pages/admin/
dashboard.rs

1//! 管理后台仪表盘页面。
2//!
3//! 采用高密度工业风设计的管理面板,突出核心数据指标与最新的工作流状态。
4//! 数据仅在 WASM 前端通过 Dioxus server functions 异步加载。
5
6use dioxus::prelude::*;
7use dioxus::router::components::Link;
8
9#[cfg(target_arch = "wasm32")]
10use crate::api::comments::get_pending_count;
11#[cfg(target_arch = "wasm32")]
12use crate::api::posts::{get_post_stats, list_posts};
13#[cfg(target_arch = "wasm32")]
14use crate::api::posts::{PostListResponse, PostStatsResponse};
15use crate::components::empty_state::{EmptyState, EmptyStateAction};
16use crate::components::skeletons::atoms::SkeletonBox;
17use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS, BTN_PRIMARY, BTN_SECONDARY};
18use crate::models::post::{PostListItem, PostStats};
19use crate::router::Route;
20
21#[component]
22#[allow(unused_mut)]
23pub fn Admin() -> Element {
24    let mut stats = use_signal(|| None::<PostStats>);
25    let mut recent_posts = use_signal(|| None::<Vec<PostListItem>>);
26    let mut pending_count = use_signal(|| None::<i64>);
27    let mut loaded = use_signal(|| false);
28
29    use_effect(move || {
30        if !loaded() {
31            loaded.set(true);
32            #[cfg(target_arch = "wasm32")]
33            {
34                spawn(async move {
35                    if let Ok(PostStatsResponse { stats: s }) = get_post_stats().await {
36                        stats.set(Some(s));
37                    }
38                });
39                spawn(async move {
40                    if let Ok(PostListResponse { posts, total: _ }) = list_posts(1, 5, None).await {
41                        recent_posts.set(Some(posts));
42                    }
43                });
44                spawn(async move {
45                    if let Ok(resp) = get_pending_count().await {
46                        pending_count.set(Some(resp.count));
47                    }
48                });
49            }
50        }
51    });
52
53    // 待审卡片进场类:数据未就绪(骨架屏)时为空,就绪后补挂以触发一次入场动画。
54    let pending_enter_class = if pending_count().is_some() {
55        "animate-page-enter"
56    } else {
57        ""
58    };
59
60    rsx! {
61        div { class: "w-full max-w-7xl mx-auto space-y-8",
62            // 顶部标题和全局操作栏
63            div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-8 border-b border-[var(--color-paper-border)]/50",
64                div {
65                    h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
66                        "仪表盘"
67                    }
68                    p { class: "text-base text-[var(--color-paper-secondary)] mt-2",
69                        "数据概览与近期活动"
70                    }
71                }
72                div { class: "flex items-center gap-3",
73                    Link { class: "{BTN_SECONDARY}", to: Route::Posts {}, "管理文章" }
74                    Link { class: "{BTN_PRIMARY}", to: Route::Write {}, "发布文章" }
75                }
76            }
77
78            // 数据指标 Bento Grid
79            div { class: "grid grid-cols-1 md:grid-cols-4 gap-4",
80                match stats() {
81                    Some(s) => {
82                        rsx! {
83                            StatCard {
84                                value: s.total,
85                                label: "总文章数".to_string(),
86                                trend: "+12%".to_string(),
87                                delay_ms: 0,
88                            }
89                            StatCard {
90                                value: s.published,
91                                label: "已发布".to_string(),
92                                trend: "活跃".to_string(),
93                                delay_ms: 120,
94                            }
95                            StatCard {
96                                value: s.drafts,
97                                label: "草稿".to_string(),
98                                trend: "待处理".to_string(),
99                                delay_ms: 240,
100                            }
101                        }
102                    }
103                    None => {
104                        rsx! {
105                            for _ in 0..3 {
106                                div { class: "{ADMIN_CARD_CLASS} p-6 flex flex-col justify-between h-32 animate-pulse",
107                                    SkeletonBox { class: "h-3 w-20 rounded" }
108                                    SkeletonBox { class: "h-10 w-16 rounded mt-4" }
109                                }
110                            }
111                        }
112                    }
113                }
114
115                // 评论待办卡片 (独立色块突出)
116                // 数据就绪后补挂 animate-page-enter:类名变更触发 CSS 动画从 0% 播放,
117                // 骨架屏阶段不播(避免动画被骨架屏截断,见 yggdrasil-ui-design-taste 规范)。
118                Link {
119                    class: "block {ADMIN_CARD_CLASS} p-8 bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-border)]/20 transition-all h-36 flex flex-col justify-between group hover:-translate-y-1 hover:shadow-md duration-300 {pending_enter_class}",
120                    style: "animation-delay: 360ms; animation-duration: 600ms",
121                    to: Route::AdminComments {},
122                    match pending_count() {
123                        Some(count) => {
124                            let (color_class, text_class) = if count > 0 {
125                                ("text-amber-500", "text-amber-500")
126                            } else {
127                                (
128                                    "text-[var(--color-paper-secondary)]",
129                                    "text-[var(--color-paper-primary)]",
130                                )
131                            };
132                            rsx! {
133                                div { class: "text-sm font-medium {color_class}", "待审评论" }
134                                div { class: "flex items-baseline justify-between mt-4",
135                                    CountUp {
136                                        target: count,
137                                        class: format!("text-4xl font-light tracking-tight {text_class}"),
138                                    }
139                                    div { class: "text-xs font-medium text-[var(--color-paper-secondary)] group-hover:text-[var(--color-paper-primary)] transition-colors",
140                                        "去审核 →"
141                                    }
142                                }
143                            }
144                        }
145                        None => {
146                            rsx! {
147                                SkeletonBox { class: "h-3 w-24 rounded" }
148                                SkeletonBox { class: "h-10 w-16 rounded mt-4" }
149                            }
150                        }
151                    }
152                }
153            }
154
155            // 最近文章列表
156            div { class: "mt-12",
157                div { class: "flex items-center justify-between mb-6",
158                    h2 { class: "text-xl font-bold text-[var(--color-paper-primary)] tracking-tight",
159                        "近期文章"
160                    }
161                }
162                match recent_posts() {
163                    // 空库 / 无文章:展示空状态占位(与 posts.rs 列表页一致)。
164                    // 放在 ADMIN_TABLE_CLASS 容器之外,避免 overflow-hidden 裁掉插画的 py-20 内边距。
165                    Some(posts) if posts.is_empty() => {
166                        rsx! {
167                            EmptyState {
168                                title: "暂无文章",
169                                description: "还没有创建任何文章,开始写下你的第一篇文字吧。",
170                                action: Some(EmptyStateAction {
171                                    label: "写文章".to_string(),
172                                    to: Route::Write {},
173                                }),
174                            }
175                        }
176                    }
177                    Some(posts) => {
178                        rsx! {
179                            div { class: "{ADMIN_TABLE_CLASS}",
180                                div { class: "divide-y divide-paper-border",
181                                    for (i, post) in posts.iter().take(5).enumerate() {
182                                        RecentPostItem {
183                                            key: "{post.id}",
184                                            post: post.clone(),
185                                            delay_ms: (i as i32) * 80,
186                                        }
187                                    }
188                                }
189                            }
190                        }
191                    }
192                    // 加载中:骨架屏。
193                    None => {
194                        rsx! {
195                            div { class: "{ADMIN_TABLE_CLASS}",
196                                div { class: "divide-y divide-paper-border animate-pulse",
197                                    for _ in 0..5 {
198                                        div { class: "flex justify-between items-center px-6 py-4",
199                                            SkeletonBox { class: "h-4 w-[40%] rounded" }
200                                            SkeletonBox { class: "h-3 w-24 rounded" }
201                                        }
202                                    }
203                                }
204                            }
205                        }
206                    }
207                }
208            }
209        }
210    }
211}
212
213#[component]
214fn StatCard(value: i64, label: String, trend: String, delay_ms: i32) -> Element {
215    rsx! {
216        div {
217            class: "{ADMIN_CARD_CLASS} p-8 flex flex-col justify-between h-36 relative group hover:-translate-y-1 hover:shadow-md transition-all duration-300 animate-page-enter",
218            style: "animation-delay: {delay_ms}ms; animation-duration: 600ms",
219            div { class: "flex justify-between items-start",
220                div { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
221                    "{label}"
222                }
223                div { class: "text-xs px-2 py-0.5 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-tertiary)]",
224                    "{trend}"
225                }
226            }
227            CountUp {
228                target: value,
229                class: "text-4xl font-light tracking-tight text-[var(--color-paper-primary)] mt-4"
230                    .to_string(),
231            }
232        }
233    }
234}
235
236/// 数字滚动组件:值从 0 以 easeOutQuint 缓动递增到 `target`(约 900ms)。
237///
238/// 命中 `prefers-reduced-motion` 时直接显示终值。动画在 `use_effect` 内驱动,
239/// 渲染体保持纯净(见 dioxus-render-purity 规范);数据仅 WASM 端加载,SSR 不挂载本组件。
240#[component]
241fn CountUp(target: i64, class: String) -> Element {
242    let mut display = use_signal(|| 0i64);
243
244    use_effect(move || {
245        #[cfg(target_arch = "wasm32")]
246        spawn(async move {
247            let reduced = web_sys::window()
248                .and_then(|w| {
249                    w.match_media("(prefers-reduced-motion: reduce)")
250                        .ok()
251                        .flatten()
252                })
253                .map(|m| m.matches())
254                .unwrap_or(false);
255            if reduced || target <= 0 {
256                display.set(target);
257                return;
258            }
259            const DURATION_MS: i64 = 900;
260            let start = crate::utils::time::now_millis();
261            loop {
262                crate::utils::time::sleep_ms(16).await;
263                let elapsed = crate::utils::time::now_millis() - start;
264                if elapsed >= DURATION_MS {
265                    display.set(target);
266                    break;
267                }
268                let t = elapsed as f64 / DURATION_MS as f64;
269                // easeOutQuint,与 CSS 侧 cubic-bezier(0.22, 1, 0.36, 1) 同族。
270                let eased = 1.0 - (1.0 - t).powi(5);
271                display.set((target as f64 * eased).round() as i64);
272            }
273        });
274        #[cfg(not(target_arch = "wasm32"))]
275        {
276            display.set(target);
277        }
278    });
279
280    rsx! {
281        div { class: "{class}", "{display}" }
282    }
283}
284
285#[component]
286fn RecentPostItem(post: PostListItem, delay_ms: i32) -> Element {
287    let date_str = post.formatted_date();
288    let status_label = post.status_label();
289    let status_class = post.status_class();
290
291    rsx! {
292        div {
293            class: "flex flex-col sm:flex-row sm:justify-between sm:items-center px-8 py-5 hover:bg-[var(--color-paper-accent-soft)] transition-colors cursor-pointer group animate-row-enter",
294            style: "animation-delay: {delay_ms}ms",
295            div { class: "flex items-center gap-6",
296                span { class: "text-xs font-mono text-[var(--color-paper-tertiary)] w-12 hidden sm:block",
297                    "#{post.id:04}"
298                }
299                span { class: "text-base font-semibold text-[var(--color-paper-primary)] group-hover:text-[var(--color-paper-accent)] transition-colors",
300                    "{post.title}"
301                }
302                span { class: "text-xs px-3 py-1 font-medium rounded-full {status_class}",
303                    "{status_label}"
304                }
305            }
306            span { class: "text-sm text-[var(--color-paper-secondary)] mt-2 sm:mt-0",
307                "{date_str}"
308            }
309        }
310    }
311}