1use std::future::Future;
9
10use leptos::prelude::*;
11use leptos::task::spawn_local;
12use leptos_router::{lazy_route, LazyRoute};
13
14use crate::api::admin_stats::AdminNavCounts;
15use crate::api::database::backup::BackupInfo;
16use crate::components::admin_layout::AdminPageHead;
17use crate::components::skeletons::atoms::SkeletonBox;
18use crate::components::ui::{
19 button_class, ButtonSize, ButtonVariant, Icon, IconName, LoadError, StatusDot, UserAvatar,
20};
21use crate::context::UserContext;
22use crate::models::comment::{AdminComment, CommentStatus};
23use crate::models::post::{MonthCount, PostListItem, PostStats, Tag};
24use crate::routes::Route;
25use crate::utils::time::{hm, local_now, md, to_local};
26
27#[derive(Clone, PartialEq)]
29enum Load<T> {
30 Loading,
31 Ready(T),
32 Failed,
33}
34
35impl<T: Clone> Load<T> {
36 fn ready(&self) -> Option<T> {
37 match self {
38 Load::Ready(value) => Some(value.clone()),
39 _ => None,
40 }
41 }
42}
43
44struct Section<T: Send + Sync + 'static> {
46 state: RwSignal<Load<T>>,
47 generation: RwSignal<u64>,
48}
49
50impl<T: Send + Sync + 'static> Clone for Section<T> {
52 fn clone(&self) -> Self {
53 *self
54 }
55}
56
57impl<T: Send + Sync + 'static> Copy for Section<T> {}
58
59impl<T: Send + Sync + 'static> Section<T> {
60 fn new() -> Self {
61 Self {
62 state: RwSignal::new(Load::Loading),
63 generation: RwSignal::new(0),
64 }
65 }
66
67 fn refresh<Fut>(self, fetch: impl FnOnce() -> Fut)
69 where
70 Fut: Future<Output = Option<T>> + 'static,
71 {
72 if self.state.with_untracked(|s| matches!(s, Load::Ready(_))) {
73 return;
74 }
75 if !self.state.with_untracked(|s| matches!(s, Load::Loading)) {
76 self.state.set(Load::Loading);
77 }
78 let id = self.generation.get_untracked() + 1;
79 self.generation.set(id);
80 let future = fetch();
81 spawn_local(async move {
82 let next = match future.await {
83 Some(value) => Load::Ready(value),
84 None => Load::Failed,
85 };
86 if self.generation.try_get_untracked() == Some(id) {
87 let _ = self.state.try_set(next);
88 }
89 });
90 }
91}
92
93fn greeting(hour: u32) -> &'static str {
95 match hour {
96 5..=10 => "早上好",
97 11..=12 => "中午好",
98 13..=17 => "下午好",
99 _ => "晚上好",
100 }
101}
102
103fn weekday_label(weekday: chrono::Weekday) -> &'static str {
104 use chrono::Weekday::*;
105 match weekday {
106 Mon => "周一",
107 Tue => "周二",
108 Wed => "周三",
109 Thu => "周四",
110 Fri => "周五",
111 Sat => "周六",
112 Sun => "周日",
113 }
114}
115
116fn chart_max(months: &[MonthCount]) -> i64 {
118 let max = months.iter().map(|m| m.count).max().unwrap_or(0).max(1);
119 (max + 3) / 4 * 4
120}
121
122fn top_tag(tags: &[Tag]) -> Option<&Tag> {
124 tags.iter()
125 .filter(|t| t.post_count > 0)
126 .max_by(|a, b| a.post_count.cmp(&b.post_count).then(b.name.cmp(&a.name)))
127}
128
129fn comment_preview(md: &str) -> String {
131 let mut text = String::new();
132 let mut rest = md;
133 while let Some(start) = rest.find("![") {
135 text.push_str(&rest[..start]);
136 let tail = &rest[start..];
137 match tail.find(')') {
138 Some(end) if tail[..end].contains("](") => rest = &tail[end + 1..],
139 _ => {
140 text.push_str("![");
141 rest = &tail[2..];
142 }
143 }
144 }
145 text.push_str(rest);
146 let text: String = text
147 .chars()
148 .filter(|c| !matches!(c, '*' | '_' | '`' | '#' | '>'))
149 .collect();
150 let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
151 if text.is_empty() {
152 "[图片]".to_string()
153 } else {
154 text
155 }
156}
157
158pub struct DashboardRoute;
160
161#[lazy_route]
162impl LazyRoute for DashboardRoute {
163 fn data() -> Self {
164 Self
165 }
166
167 fn view(_this: Self) -> AnyView {
168 view! { <Admin/> }.into_any()
169 }
170}
171
172#[component]
173fn Admin() -> impl IntoView {
174 let ctx = expect_context::<UserContext>();
175 let stats = Section::<PostStats>::new();
176 let recent = Section::<Vec<PostListItem>>::new();
177 let counts = Section::<AdminNavCounts>::new();
178 let comments = Section::<Vec<AdminComment>>::new();
179 let tags = Section::<Vec<Tag>>::new();
180 let backups = Section::<Vec<BackupInfo>>::new();
181 let reload = RwSignal::new(0_u32);
182
183 Effect::new(move |_| {
185 reload.track();
186 stats.refresh(|| async {
187 crate::api::posts::get_post_stats()
188 .await
189 .ok()
190 .map(|res| res.stats)
191 });
192 recent.refresh(|| async {
193 crate::api::posts::list_posts(1, 5, Default::default())
194 .await
195 .ok()
196 .map(|res| res.posts)
197 });
198 counts.refresh(|| async { crate::api::admin_stats::get_admin_nav_counts().await.ok() });
199 comments.refresh(|| async {
200 crate::api::comments::get_all_comments(1, None)
201 .await
202 .ok()
203 .map(|res| res.comments.into_iter().take(4).collect())
204 });
205 tags.refresh(|| async {
206 crate::api::posts::list_tags()
207 .await
208 .ok()
209 .map(|res| res.tags)
210 });
211 backups.refresh(|| async { crate::api::database::backup::list_backups().await.ok() });
212 });
213 let retry = Callback::new(move |()| reload.update(|n| *n += 1));
214
215 let now = local_now();
216 let hour = chrono::Timelike::hour(&now);
217 let date_label = format!(
218 "{} 月 {} 日 {}",
219 chrono::Datelike::month(&now),
220 chrono::Datelike::day(&now),
221 weekday_label(chrono::Datelike::weekday(&now))
222 );
223 let name = Memo::new(move |_| {
224 ctx.user
225 .read()
226 .as_ref()
227 .map(|user| user.display_label().to_string())
228 .unwrap_or_default()
229 });
230 let title = Signal::derive(move || format!("{},{}", greeting(hour), name.get()));
231 let summary = move || match stats.state.get() {
232 Load::Ready(s) => format!("{date_label} · 近 30 天新增 {} 篇", s.recent_30d),
233 _ => date_label.clone(),
234 };
235
236 view! {
237 <div class="admin-dash">
238 <AdminPageHead
239 title=title
240 actions=|| {
241 view! {
242 <a
243 class=button_class(ButtonVariant::Primary, ButtonSize::Md)
244 href=Route::Write {}.to_string()
245 >
246 <Icon name=IconName::Plus size=16/>
247 "写文章"
248 </a>
249 <a
250 class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
251 href=Route::NewNote {}.to_string()
252 >
253 "快速记录"
254 </a>
255 <a
256 class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
257 href=Route::Assets {}.to_string()
258 >
259 "上传素材"
260 </a>
261 }
262 }
263 >
264 {summary}
265 </AdminPageHead>
266
267 <StatTiles stats=stats.state counts=counts.state tags=tags.state on_retry=retry/>
268
269 <div class="dash-cols">
270 <div class="dash-col">
271 <section class="admin-panel dash-chart" aria-labelledby="dash-chart-title">
272 <div class="dash-panel-head">
273 <h2 id="dash-chart-title">"每月发布篇数"</h2>
274 {move || {
275 stats
276 .state
277 .get()
278 .ready()
279 .map(|s| {
280 let total: i64 = s
281 .monthly_published
282 .iter()
283 .map(|m| m.count)
284 .sum();
285 view! { <span>{format!("近 12 个月 · 共 {total} 篇")}</span> }
286 })
287 }}
288 </div>
289 {move || match stats.state.get() {
290 Load::Ready(s) => {
291 view! { <PublishRhythm months=s.monthly_published/> }.into_any()
292 }
293 Load::Loading => {
294 view! { <SkeletonBox class="dash-chart-skeleton"/> }.into_any()
295 }
296 Load::Failed => {
297 view! { <LoadError title="发布统计加载失败" on_retry=retry/> }.into_any()
298 }
299 }}
300 </section>
301 <RecentPosts posts=recent.state on_retry=retry/>
302 </div>
303 <div class="dash-col">
304 <LatestComments
305 comments=comments.state
306 counts=Signal::derive(move || counts.state.get().ready())
307 on_retry=retry
308 />
309 <SiteInfo
310 stats=Signal::derive(move || stats.state.get().ready())
311 backups=backups.state
312 on_retry=retry
313 />
314 </div>
315 </div>
316 </div>
317 }
318}
319
320fn tile(
322 label: &'static str,
323 to: Option<String>,
324 value: Option<String>,
325 meta: Option<(String, bool)>,
326) -> AnyView {
327 let body = view! {
328 <span class="dash-tile-label">{label}</span>
329 {match value {
330 Some(value) => view! { <span class="dash-tile-value">{value}</span> }.into_any(),
331 None => view! { <SkeletonBox class="dash-tile-value-skeleton"/> }.into_any(),
332 }}
333 {meta
334 .map(|(meta, alert)| {
335 view! {
336 <span class={
337 if alert { "dash-tile-meta dash-tile-meta-alert" } else { "dash-tile-meta" }
338 }>{meta}</span>
339 }
340 })}
341 };
342 match to {
343 Some(href) => view! { <a class="admin-panel dash-tile" href=href>{body}</a> }.into_any(),
344 None => view! { <div class="admin-panel dash-tile">{body}</div> }.into_any(),
345 }
346}
347
348#[component]
350fn StatTiles(
351 #[prop(into)] stats: Signal<Load<PostStats>>,
352 #[prop(into)] counts: Signal<Load<AdminNavCounts>>,
353 #[prop(into)] tags: Signal<Load<Vec<Tag>>>,
354 #[prop(into)] on_retry: Callback<()>,
355) -> impl IntoView {
356 let failed = move || {
357 stats.with(|s| matches!(s, Load::Failed))
358 || counts.with(|c| matches!(c, Load::Failed))
359 || tags.with(|t| matches!(t, Load::Failed))
360 };
361 view! {
362 {move || {
363 let s = stats.get().ready();
364 let c = counts.get().ready();
365 let t = tags.get().ready();
366 view! {
367 <div class="dash-tiles">
368 {tile(
369 "已发布",
370 Some(Route::Posts {}.to_string()),
371 s.as_ref().map(|s| s.published.to_string()),
372 s.as_ref().map(|s| (format!("近 30 天新增 {}", s.recent_30d), false)),
373 )}
374 {tile(
375 "草稿",
376 Some(Route::Posts {}.to_string()),
377 s.as_ref().map(|s| s.drafts.to_string()),
378 s.as_ref()
379 .map(|s| {
380 if s.drafts == 0 {
381 ("没有未完成的草稿".to_string(), false)
382 } else {
383 ("等你写完".to_string(), false)
384 }
385 }),
386 )}
387 {tile(
388 "评论",
389 Some(Route::AdminComments {}.to_string()),
390 c.map(|c| c.comments.to_string()),
391 c.map(|c| (format!("待审 {}", c.pending_comments), c.pending_comments > 0)),
392 )}
393 {tile(
394 "标签",
395 None,
396 t.as_ref().map(|t| t.iter().filter(|t| t.post_count > 0).count().to_string()),
397 t.as_ref()
398 .map(|t| {
399 (
400 top_tag(t)
401 .map(|tag| format!("最常用:{}", tag.name))
402 .unwrap_or_else(|| "还没有标签".to_string()),
403 false,
404 )
405 }),
406 )}
407 </div>
408 }
409 }}
410 {move || {
411 failed()
412 .then(|| {
413 view! {
414 <LoadError
415 class="dash-tiles-error"
416 title="部分统计加载失败"
417 on_retry=on_retry
418 />
419 }
420 })
421 }}
422 }
423}
424
425#[component]
427fn PublishRhythm(months: Vec<MonthCount>) -> impl IntoView {
428 let hover = RwSignal::new(None::<usize>);
429 let max = chart_max(&months);
430 let last = months.len().saturating_sub(1);
431 let bars = months
432 .iter()
433 .copied()
434 .enumerate()
435 .map(|(i, m)| {
436 let pct = m.count as f64 / max as f64 * 100.0;
437 let label = format!("{} 年 {} 月 · {} 篇", m.year, m.month, m.count);
438 let bar_class = if m.count == 0 {
439 "dash-chart-bar dash-chart-bar-zero"
440 } else if i == last {
441 "dash-chart-bar dash-chart-bar-current"
442 } else {
443 "dash-chart-bar"
444 };
445 let bar_style = if m.count > 0 {
446 format!("height: {pct:.1}%;")
447 } else {
448 String::new()
449 };
450 let tip_label = label.clone();
451 view! {
452 <li
453 class="dash-chart-col"
454 tabindex="0"
455 aria-label=label
456 data-active=move || (hover.get() == Some(i)).then_some("true")
457 on:mouseenter=move |_| hover.set(Some(i))
458 on:mouseleave=move |_| hover.set(None)
459 on:focus=move |_| hover.set(Some(i))
460 on:blur=move |_| hover.set(None)
461 >
462 <span class=bar_class style=bar_style></span>
463 {move || {
464 (hover.get() == Some(i))
465 .then(|| {
466 view! {
467 <span
468 class="dash-chart-tip"
469 role="tooltip"
470 style=format!("bottom: calc({pct:.1}% + 8px);")
471 >
472 {tip_label.clone()}
473 </span>
474 }
475 })
476 }}
477 </li>
478 }
479 })
480 .collect_view();
481 let grid = (0..=4)
482 .map(|i| view! { <span data-value=(max - max / 4 * i).to_string()></span> })
483 .collect_view();
484 let labels = months
485 .iter()
486 .map(|m| view! { <span>{format!("{}月", m.month)}</span> })
487 .collect_view();
488 view! {
489 <div
490 class="dash-chart-plot"
491 data-hovering=move || hover.get().is_some().then_some("true")
492 >
493 <div class="dash-chart-grid" aria-hidden="true">
495 {grid}
496 </div>
497 <ol class="dash-chart-bars" aria-label="近 12 个月每月发布篇数">
498 {bars}
499 </ol>
500 <div class="dash-chart-labels" aria-hidden="true">
501 {labels}
502 </div>
503 </div>
504 }
505}
506
507#[component]
508fn RecentPosts(
509 #[prop(into)] posts: Signal<Load<Vec<PostListItem>>>,
510 #[prop(into)] on_retry: Callback<()>,
511) -> impl IntoView {
512 view! {
513 <section class="admin-panel dash-list" aria-labelledby="dash-recent-title">
514 <div class="dash-panel-head dash-panel-head-rule">
515 <h2 id="dash-recent-title">"近期文章"</h2>
516 <a class="dash-more" href=Route::Posts {}.to_string()>
517 "全部文章"
518 <Icon name=IconName::ArrowRight size=13/>
519 </a>
520 </div>
521 {move || match posts.get() {
522 Load::Loading => {
523 view! {
524 <div class="dash-list-body">
525 {(0..5)
526 .map(|_| view! { <SkeletonBox class="dash-row-skeleton"/> })
527 .collect_view()}
528 </div>
529 }
530 .into_any()
531 }
532 Load::Failed => {
533 view! { <LoadError title="近期文章加载失败" on_retry=on_retry/> }.into_any()
534 }
535 Load::Ready(posts) if posts.is_empty() => {
536 view! {
537 <p class="dash-empty">
538 "还没有文章。" <a href=Route::Write {}.to_string()>"写第一篇"</a>
539 </p>
540 }
541 .into_any()
542 }
543 Load::Ready(posts) => {
544 view! {
545 <ul class="dash-list-body">
546 {posts
547 .into_iter()
548 .map(|post| {
549 let t = to_local(post.published_at.unwrap_or(post.created_at));
550 let date = format!("{} {}", md(&t, '-'), hm(&t));
551 view! {
552 <li>
553 <a
554 class="dash-post-row"
555 href=Route::WriteEdit { id: post.id }.to_string()
556 >
557 <span class="dash-post-title">{post.title}</span>
558 <StatusDot
559 label=post.status.label()
560 tone=post.status.tone()
561 />
562 <time class="dash-post-date">{date}</time>
563 </a>
564 </li>
565 }
566 })
567 .collect_view()}
568 </ul>
569 }
570 .into_any()
571 }
572 }}
573 </section>
574 }
575}
576
577#[component]
578fn LatestComments(
579 #[prop(into)] comments: Signal<Load<Vec<AdminComment>>>,
580 #[prop(into)] counts: Signal<Option<AdminNavCounts>>,
581 #[prop(into)] on_retry: Callback<()>,
582) -> impl IntoView {
583 view! {
584 <section class="admin-panel dash-list" aria-labelledby="dash-comments-title">
585 <div class="dash-panel-head dash-panel-head-rule">
586 <h2 id="dash-comments-title">"最新评论"</h2>
587 {move || {
588 counts
589 .get()
590 .map(|c| c.pending_comments)
591 .map(|pending| {
592 view! {
593 <a
594 class={
595 if pending > 0 { "dash-chip dash-chip-alert" } else { "dash-chip" }
596 }
597 href=Route::AdminComments {}.to_string()
598 >
599 {format!("待审 {pending}")}
600 </a>
601 }
602 })
603 }}
604 </div>
605 {move || match comments.get() {
606 Load::Loading => {
607 view! {
608 <div class="dash-list-body">
609 {(0..4)
610 .map(|_| view! { <SkeletonBox class="dash-comment-skeleton"/> })
611 .collect_view()}
612 </div>
613 }
614 .into_any()
615 }
616 Load::Failed => {
617 view! { <LoadError title="最新评论加载失败" on_retry=on_retry/> }.into_any()
618 }
619 Load::Ready(list) if list.is_empty() => {
620 view! { <p class="dash-empty">"还没有评论。"</p> }.into_any()
621 }
622 Load::Ready(list) => {
623 view! {
624 <ul class="dash-list-body">
625 {list
626 .into_iter()
627 .map(|comment| {
628 let when = md(&to_local(comment.created_at), '-');
629 let preview = comment_preview(&comment.content_md);
630 let is_pending = comment.status == CommentStatus::Pending;
631 view! {
632 <li>
633 <a
634 class="dash-comment-row"
635 href=Route::AdminComments {}.to_string()
636 >
637 <UserAvatar
638 name=comment.author_name.clone()
639 avatar_url=Some(comment.avatar_url.clone())
640 class="w-[26px] h-[26px] rounded-full text-[11.5px] flex-shrink-0"
641 />
642 <span class="dash-comment-body">
643 <span class="dash-comment-meta">
644 <span class="dash-comment-author">
645 {comment.author_name.clone()}
646 </span>
647 {is_pending
648 .then(|| {
649 view! { <span class="dash-comment-pending">"待审"</span> }
650 })}
651 <time>{when}</time>
652 </span>
653 <span class="dash-comment-text">{preview}</span>
654 <span class="dash-comment-post">
655 {format!("于《{}》", comment.post_title)}
656 </span>
657 </span>
658 </a>
659 </li>
660 }
661 })
662 .collect_view()}
663 </ul>
664 }
665 .into_any()
666 }
667 }}
668 </section>
669 }
670}
671
672#[component]
673fn SiteInfo(
674 #[prop(into)] stats: Signal<Option<PostStats>>,
675 #[prop(into)] backups: Signal<Load<Vec<BackupInfo>>>,
676 #[prop(into)] on_retry: Callback<()>,
677) -> impl IntoView {
678 view! {
679 <section class="admin-panel dash-site" aria-labelledby="dash-site-title">
680 <h2 id="dash-site-title">"站点"</h2>
681 <dl>
682 <dt>"版本"</dt>
683 <dd class="font-mono">{env!("CARGO_PKG_VERSION")}</dd>
684 <dt>"回收站"</dt>
685 <dd>
686 {move || match stats.get() {
687 Some(s) => {
688 view! {
689 <a href=Route::PostsTrash {}.to_string()>{format!("{} 篇", s.trash)}</a>
690 }
691 .into_any()
692 }
693 None => "—".into_any(),
694 }}
695 </dd>
696 <dt>"上次备份"</dt>
697 <dd>
698 {move || match backups.get() {
699 Load::Ready(list) => {
700 list.iter()
701 .filter_map(|b| b.created_at.clone())
702 .max()
703 .map(|iso| crate::utils::time::format_relative_time_iso(&iso))
704 .unwrap_or_else(|| "还没有备份".to_string())
705 .into_any()
706 }
707 Load::Failed => {
708 view! {
709 <button
710 class="dash-inline-retry"
711 type="button"
712 on:click=move |_| on_retry.run(())
713 >
714 "读取失败,重试"
715 </button>
716 }
717 .into_any()
718 }
719 Load::Loading => "…".into_any(),
720 }}
721 </dd>
722 </dl>
723 <a class="dash-more" href=Route::System {}.to_string()>
724 "系统状态"
725 <Icon name=IconName::ArrowRight size=13/>
726 </a>
727 </section>
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734
735 fn months(counts: &[i64]) -> Vec<MonthCount> {
736 counts
737 .iter()
738 .enumerate()
739 .map(|(i, &count)| MonthCount {
740 year: 2026,
741 month: i as u32 + 1,
742 count,
743 })
744 .collect()
745 }
746
747 #[test]
748 fn chart_max_rounds_up_to_quarter_gridlines() {
749 assert_eq!(chart_max(&months(&[0, 0])), 4);
750 assert_eq!(chart_max(&months(&[3, 1])), 4);
751 assert_eq!(chart_max(&months(&[5])), 8);
752 assert_eq!(chart_max(&[]), 4);
753 }
754
755 #[test]
756 fn top_tag_prefers_count_then_name() {
757 let tags = vec![
758 Tag {
759 id: 0,
760 name: "Go".into(),
761 post_count: 2,
762 },
763 Tag {
764 id: 0,
765 name: "Rust".into(),
766 post_count: 5,
767 },
768 Tag {
769 id: 0,
770 name: "Axum".into(),
771 post_count: 5,
772 },
773 ];
774 assert_eq!(top_tag(&tags).map(|t| t.name.as_str()), Some("Axum"));
775 assert!(top_tag(&[]).is_none());
776 }
777
778 #[test]
779 fn greeting_follows_local_hour() {
780 assert_eq!(greeting(8), "早上好");
781 assert_eq!(greeting(12), "中午好");
782 assert_eq!(greeting(15), "下午好");
783 assert_eq!(greeting(23), "晚上好");
784 assert_eq!(greeting(3), "晚上好");
785 }
786
787 #[test]
788 fn comment_preview_strips_markdown_and_marks_images() {
789 assert_eq!(comment_preview("**好** 文章"), "好 文章");
790 assert_eq!(comment_preview(""), "[图片]");
791 }
792}