1use 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 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 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 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 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 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 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 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 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 (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 (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#[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#[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 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#[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 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 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 span { class: "opacity-0 group-hover:opacity-100 transition-opacity text-[var(--color-paper-tertiary)]",
489 "→"
490 }
491 }
492 }
493 }
494}