Skip to main content

yggdrasil/pages/admin/
dashboard.rs

1//! 管理后台仪表盘页面。
2//!
3//! 单页监控 + 可行动导向:统计卡带(真实增量徽章 + 30 日 sparkline)→ 待审
4//! 评论行动卡 → 近期文章活动流。动效预算遵循 NN/g/Atlassian 共识(hover
5//! ≤200ms、入场 ≤400ms、数字滚动 ≤500ms);接口失败如实呈现「加载失败 +
6//! 重试」,不回退成 0 也不骨架永转(设计依据见
7//! `docs/research/2026-admin-dashboard-design.md`)。
8//! 数据仅在 WASM 前端通过 Dioxus server functions 异步加载。
9
10use dioxus::prelude::*;
11use dioxus::router::components::Link;
12
13#[cfg(target_arch = "wasm32")]
14use crate::api::comments::get_pending_count;
15#[cfg(target_arch = "wasm32")]
16use crate::api::posts::{get_post_stats, list_posts};
17#[cfg(target_arch = "wasm32")]
18use crate::api::posts::{PostListResponse, PostStatsResponse};
19use crate::components::empty_state::{EmptyState, EmptyStateAction};
20use crate::components::skeletons::atoms::SkeletonBox;
21use crate::components::ui::{
22    ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS, BTN_OUTLINE, BTN_PRIMARY, BTN_SECONDARY,
23};
24use crate::models::post::{PostListItem, PostStats};
25use crate::router::Route;
26
27#[component]
28#[allow(unused_mut)]
29pub fn Admin() -> Element {
30    let mut stats = use_signal(|| None::<PostStats>);
31    let mut recent_posts = use_signal(|| None::<Vec<PostListItem>>);
32    let mut pending_count = use_signal(|| None::<i64>);
33    // 三路加载各自的失败标志:接口失败如实呈现「加载失败 + 重试」,
34    // 不回退成 0(0 是真实数据,失败不是 0),也不让骨架屏永转。
35    let mut stats_failed = use_signal(|| false);
36    let mut posts_failed = use_signal(|| false);
37    let mut pending_failed = use_signal(|| false);
38    let mut loaded = use_signal(|| false);
39
40    use_effect(move || {
41        if !loaded() {
42            loaded.set(true);
43            #[cfg(target_arch = "wasm32")]
44            {
45                spawn(async move {
46                    match get_post_stats().await {
47                        Ok(PostStatsResponse { stats: s }) => stats.set(Some(s)),
48                        Err(_) => stats_failed.set(true),
49                    }
50                });
51                spawn(async move {
52                    match list_posts(1, 5, None).await {
53                        Ok(PostListResponse { posts, total: _ }) => recent_posts.set(Some(posts)),
54                        Err(_) => posts_failed.set(true),
55                    }
56                });
57                spawn(async move {
58                    match get_pending_count().await {
59                        Ok(resp) => pending_count.set(Some(resp.count)),
60                        Err(_) => pending_failed.set(true),
61                    }
62                });
63            }
64        }
65    });
66
67    // 重试:清失败标志并翻转 loaded,触发 use_effect 重新发起全部加载。
68    let mut retry = move |_| {
69        stats_failed.set(false);
70        posts_failed.set(false);
71        pending_failed.set(false);
72        loaded.set(false);
73    };
74
75    // 待审卡片进场类:数据未就绪(骨架屏)时为空,就绪后补挂以触发一次入场动画。
76    let pending_enter_class = if pending_count().is_some() {
77        "animate-page-enter"
78    } else {
79        ""
80    };
81
82    rsx! {
83        div { class: "animate-page-enter w-full max-w-7xl mx-auto space-y-8",
84            // 顶部标题和全局操作栏
85            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",
86                div {
87                    h1 { class: "animate-row-enter text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
88                        "仪表盘"
89                    }
90                    p {
91                        class: "animate-row-enter text-base text-[var(--color-paper-secondary)] mt-2",
92                        style: "animation-delay: 60ms",
93                        "数据概览与近期活动"
94                    }
95                }
96                div {
97                    class: "animate-row-enter flex items-center gap-3",
98                    style: "animation-delay: 120ms",
99                    Link { class: "{BTN_SECONDARY}", to: Route::Posts {}, "全部文章" }
100                    Link { class: "{BTN_PRIMARY}", to: Route::Write {}, "发布文章" }
101                }
102            }
103
104            // 数据指标 Bento Grid
105            div { class: "grid grid-cols-1 md:grid-cols-4 gap-4",
106                match (stats(), stats_failed()) {
107                    (Some(s), _) => {
108                        rsx! {
109                            StatCard {
110                                value: s.total,
111                                label: "总文章数".to_string(),
112                                trend: Some(TrendBadge {
113                                    // 涨跌颜色 + 箭头图标双编码(WCAG 2.2 SC 1.4.1:
114                                    // 颜色不得作为唯一信息载体);+0 走中性样式不带箭头。
115                                    text: if s.recent_30d > 0 {
116                                        format!("↑ 近30天 +{}", s.recent_30d)
117                                    } else {
118                                        "近30天 +0".to_string()
119                                    },
120                                    positive: s.recent_30d > 0,
121                                }),
122                                sparkline: Some(s.activity_30d.clone()),
123                                delay_ms: 0,
124                            }
125                            StatCard {
126                                value: s.published,
127                                label: "已发布".to_string(),
128                                trend: None,
129                                sparkline: None,
130                                delay_ms: 120,
131                            }
132                            StatCard {
133                                value: s.drafts,
134                                label: "草稿".to_string(),
135                                trend: None,
136                                sparkline: None,
137                                delay_ms: 240,
138                            }
139                        }
140                    }
141                    (None, true) => {
142                        rsx! {
143                            div { class: "{ADMIN_CARD_CLASS} md:col-span-3 p-8 h-36 flex flex-col sm:flex-row sm:items-center justify-between gap-4 animate-page-enter",
144                                div {
145                                    div { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
146                                        "总文章数 / 已发布 / 草稿"
147                                    }
148                                    div { class: "text-base text-[var(--color-paper-primary)] mt-2",
149                                        "统计数据加载失败"
150                                    }
151                                }
152                                button {
153                                    class: "{BTN_OUTLINE}",
154                                    onclick: retry,
155                                    "重试"
156                                }
157                            }
158                        }
159                    }
160                    (None, false) => {
161                        rsx! {
162                            for _ in 0..3 {
163                                div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col justify-between h-36 animate-pulse",
164                                    SkeletonBox { class: "h-3 w-20 rounded" }
165                                    SkeletonBox { class: "h-10 w-16 rounded mt-4" }
166                                }
167                            }
168                        }
169                    }
170                }
171
172                // 评论待办卡片 (独立色块突出)
173                // 数据就绪后补挂 animate-page-enter:类名变更触发 CSS 动画从 0% 播放,
174                // 骨架屏阶段不播(避免动画被骨架屏截断,见 yggdrasil-ui-design-taste 规范)。
175                match (pending_count(), pending_failed()) {
176                    (Some(count), _) => {
177                        let (color_class, text_class) = if count > 0 {
178                            (
179                                "text-amber-600 dark:text-amber-400",
180                                "text-amber-600 dark:text-amber-400",
181                            )
182                        } else {
183                            (
184                                "text-[var(--color-paper-secondary)]",
185                                "text-[var(--color-paper-primary)]",
186                            )
187                        };
188                        rsx! {
189                            Link {
190                                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-200 {pending_enter_class}",
191                                style: "animation-delay: 360ms",
192                                to: Route::AdminComments {},
193                                div { class: "text-sm font-medium {color_class}", "待审评论" }
194                                div { class: "flex items-baseline justify-between mt-4",
195                                    CountUp {
196                                        target: count,
197                                        class: format!("text-4xl font-light tracking-tight tabular-nums {text_class}"),
198                                    }
199                                    div { class: "text-xs font-medium text-[var(--color-paper-secondary)] group-hover:text-[var(--color-paper-primary)] transition-colors",
200                                        "去审核 →"
201                                    }
202                                }
203                            }
204                        }
205                    }
206                    (None, true) => {
207                        rsx! {
208                            div { class: "{ADMIN_CARD_CLASS} p-8 h-36 flex flex-col justify-between animate-page-enter",
209                                div { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
210                                    "待审评论"
211                                }
212                                div { class: "flex items-center justify-between mt-4",
213                                    span { class: "text-sm text-[var(--color-paper-tertiary)]",
214                                        "加载失败"
215                                    }
216                                    button {
217                                        class: "{BTN_OUTLINE}",
218                                        onclick: retry,
219                                        "重试"
220                                    }
221                                }
222                            }
223                        }
224                    }
225                    (None, false) => {
226                        rsx! {
227                            div { class: "{ADMIN_CARD_CLASS} p-8 h-36 flex flex-col justify-between animate-pulse",
228                                SkeletonBox { class: "h-3 w-24 rounded" }
229                                SkeletonBox { class: "h-10 w-16 rounded mt-4" }
230                            }
231                        }
232                    }
233                }
234            }
235
236            // 最近文章列表
237            div { class: "mt-12",
238                div {
239                    class: "animate-row-enter flex items-center justify-between mb-6",
240                    style: "animation-delay: 200ms",
241                    h2 { class: "text-xl font-bold text-[var(--color-paper-primary)] tracking-tight",
242                        "近期文章"
243                    }
244                }
245                match (recent_posts(), posts_failed()) {
246                    // 空库 / 无文章:展示空状态占位(与 posts.rs 列表页一致)。
247                    // 放在 ADMIN_TABLE_CLASS 容器之外,避免 overflow-hidden 裁掉插画的 py-20 内边距。
248                    (Some(posts), _) if posts.is_empty() => {
249                        rsx! {
250                            EmptyState {
251                                title: "暂无文章",
252                                description: "还没有创建任何文章,开始写下你的第一篇文字吧。",
253                                action: Some(EmptyStateAction {
254                                    label: "写文章".to_string(),
255                                    onclick: Callback::new(move |_| {
256                                        let _ = dioxus::router::navigator().push(Route::Write {});
257                                    }),
258                                }),
259                            }
260                        }
261                    }
262                    (Some(posts), _) => {
263                        rsx! {
264                            div { class: "{ADMIN_TABLE_CLASS}",
265                                div { class: "divide-y divide-paper-border",
266                                    for (i, post) in posts.iter().take(5).enumerate() {
267                                        RecentPostItem {
268                                            key: "{post.id}",
269                                            post: post.clone(),
270                                            delay_ms: (i as i32) * 80,
271                                        }
272                                    }
273                                }
274                            }
275                        }
276                    }
277                    (None, true) => {
278                        rsx! {
279                            div { class: "{ADMIN_TABLE_CLASS} px-8 py-6 flex items-center justify-between animate-page-enter",
280                                span { class: "text-sm text-[var(--color-paper-secondary)]",
281                                    "近期文章加载失败"
282                                }
283                                button {
284                                    class: "{BTN_OUTLINE}",
285                                    onclick: retry,
286                                    "重试"
287                                }
288                            }
289                        }
290                    }
291                    // 加载中:骨架屏。
292                    (None, false) => {
293                        rsx! {
294                            div { class: "{ADMIN_TABLE_CLASS}",
295                                div { class: "divide-y divide-paper-border animate-pulse",
296                                    for _ in 0..5 {
297                                        div { class: "flex justify-between items-center px-6 py-4",
298                                            SkeletonBox { class: "h-4 w-[40%] rounded" }
299                                            SkeletonBox { class: "h-3 w-24 rounded" }
300                                        }
301                                    }
302                                }
303                            }
304                        }
305                    }
306                }
307            }
308        }
309    }
310}
311
312/// 趋势徽章:`positive` 决定绿色语义样式(与全站 green 徽章约定一致),
313/// 文本自带 ↑ 箭头与颜色形成双编码;中性数据(如 +0)走描边样式。
314#[derive(Clone, PartialEq)]
315struct TrendBadge {
316    text: String,
317    positive: bool,
318}
319
320#[component]
321fn StatCard(
322    value: i64,
323    label: String,
324    trend: Option<TrendBadge>,
325    sparkline: Option<Vec<i64>>,
326    delay_ms: i32,
327) -> Element {
328    let badge_class = match &trend {
329        Some(t) if t.positive => {
330            "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300"
331        }
332        _ => "border border-[var(--color-paper-border)] text-[var(--color-paper-tertiary)]",
333    };
334    rsx! {
335        div {
336            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-200 animate-page-enter",
337            style: "animation-delay: {delay_ms}ms",
338            div { class: "flex justify-between items-start",
339                div { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
340                    "{label}"
341                }
342                if let Some(t) = trend {
343                    div { class: "text-xs px-2 py-0.5 rounded-full {badge_class}",
344                        "{t.text}"
345                    }
346                }
347            }
348            div { class: "flex items-end justify-between gap-2 mt-4",
349                CountUp {
350                    target: value,
351                    class: "text-4xl font-light tracking-tight tabular-nums text-[var(--color-paper-primary)]"
352                        .to_string(),
353                }
354                if let Some(data) = sparkline {
355                    Sparkline { data }
356                }
357            }
358        }
359    }
360}
361
362/// 迷你折线图:近 30 个自然日每日新建文章数。
363///
364/// 折线(位置/长度通道)是 NN/g 认可的前注意处理最准的趋势形式;无文章日
365/// 自然落为基线(全零时呈现一条平线,如实呈现而非隐藏)。装饰性图表,
366/// 趋势语义已由 TrendBadge 文案双编码承载,故对读屏器隐藏。
367#[component]
368fn Sparkline(data: Vec<i64>) -> Element {
369    const W: f64 = 96.0;
370    const H: f64 = 28.0;
371    const PAD: f64 = 2.0;
372    let n = data.len();
373    if n < 2 {
374        return rsx! {};
375    }
376    // max(1) 护住全零序列:所有点落基线,避免除零。
377    let max = (*data.iter().max().unwrap_or(&0)).max(1) as f64;
378    let step_x = W / (n - 1) as f64;
379    let points: Vec<String> = data
380        .iter()
381        .enumerate()
382        .map(|(i, v)| {
383            let y = H - PAD - (*v as f64 / max) * (H - 2.0 * PAD);
384            format!("{:.1},{:.1}", i as f64 * step_x, y)
385        })
386        .collect();
387    let polyline_points = points.join(" ");
388    let area_d = format!("M0,{H} L{} L{W},{H} Z", points.join(" L"));
389
390    rsx! {
391        svg {
392            class: "w-24 h-7 shrink-0",
393            view_box: "0 0 {W} {H}",
394            fill: "none",
395            "aria-hidden": "true",
396            path {
397                d: area_d,
398                fill: "color-mix(in srgb, var(--color-paper-accent) 12%, transparent)",
399            }
400            polyline {
401                points: polyline_points,
402                stroke: "var(--color-paper-accent)",
403                stroke_width: "2",
404                stroke_linecap: "round",
405                stroke_linejoin: "round",
406            }
407        }
408    }
409}
410
411/// 数字滚动组件:值从 0 以 easeOutQuint 缓动递增到 `target`(约 450ms)。
412///
413/// 语义是「数据已聚合完成」的信号而非装饰;450ms 在 NN/g 动效时长上限
414/// (500ms) 内。命中 `prefers-reduced-motion` 时直接显示终值。动画在
415/// `use_effect` 内驱动,渲染体保持纯净(见 dioxus-render-purity 规范);
416/// 数据仅 WASM 端加载,SSR 不挂载本组件。
417#[component]
418fn CountUp(target: i64, class: String) -> Element {
419    let mut display = use_signal(|| 0i64);
420
421    use_effect(move || {
422        #[cfg(target_arch = "wasm32")]
423        spawn(async move {
424            let reduced = web_sys::window()
425                .and_then(|w| {
426                    w.match_media("(prefers-reduced-motion: reduce)")
427                        .ok()
428                        .flatten()
429                })
430                .map(|m| m.matches())
431                .unwrap_or(false);
432            if reduced || target <= 0 {
433                display.set(target);
434                return;
435            }
436            const DURATION_MS: i64 = 450;
437            let start = crate::utils::time::now_millis();
438            loop {
439                crate::utils::time::sleep_ms(16).await;
440                let elapsed = crate::utils::time::now_millis() - start;
441                if elapsed >= DURATION_MS {
442                    display.set(target);
443                    break;
444                }
445                let t = elapsed as f64 / DURATION_MS as f64;
446                // easeOutQuint,与 CSS 侧 cubic-bezier(0.22, 1, 0.36, 1) 同族。
447                let eased = 1.0 - (1.0 - t).powi(5);
448                display.set((target as f64 * eased).round() as i64);
449            }
450        });
451        #[cfg(not(target_arch = "wasm32"))]
452        {
453            display.set(target);
454        }
455    });
456
457    rsx! {
458        div { class: "{class}", "{display}" }
459    }
460}
461
462#[component]
463fn RecentPostItem(post: PostListItem, delay_ms: i32) -> Element {
464    let date_str = post.formatted_date();
465    let status_label = post.status_label();
466    let status_class = post.status_class();
467
468    rsx! {
469        // 整行跳转后台只读预览(/admin/preview/:slug),草稿亦可预览。
470        Link {
471            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",
472            style: "animation-delay: {delay_ms}ms",
473            to: Route::PostPreview { slug: post.slug.clone() },
474            div { class: "flex items-center gap-6",
475                span { class: "text-xs font-mono text-[var(--color-paper-tertiary)] w-12 hidden sm:block",
476                    "#{post.id:04}"
477                }
478                span { class: "text-base font-semibold text-[var(--color-paper-primary)] group-hover:text-[var(--color-paper-accent)] transition-colors",
479                    "{post.title}"
480                }
481                span { class: "text-xs px-3 py-1 font-medium rounded-full {status_class}",
482                    "{status_label}"
483                }
484            }
485            span { class: "flex items-center gap-3 text-sm text-[var(--color-paper-secondary)] mt-2 sm:mt-0",
486                "{date_str}"
487                // 可点击性暗示:整行是 Link,hover 时浮现箭头。
488                span { class: "opacity-0 group-hover:opacity-100 transition-opacity text-[var(--color-paper-tertiary)]",
489                    "→"
490                }
491            }
492        }
493    }
494}