yggdrasil/pages/admin/
dashboard.rs1use 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 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 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 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 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 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 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 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#[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 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}