Skip to main content

yggdrasil/pages/admin/
dashboard.rs

1//! 后台仪表盘。
2//!
3//! 问候与常用操作 → 四格数字(已发布、草稿、评论、标签)→ 左栏「每月发布篇数」柱图
4//! 与近期文章,右栏最新评论与站点信息。各区块分别加载,失败的区块原位显示「加载失败 +
5//! 重试」,不回退成 0。数据仅在 WASM 前端通过 server function 加载(Effect + 世代号,
6//! 服务端不请求)。样式见 styles/pages/admin-dashboard.css。
7
8use 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/// 区块的加载状态。
28#[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
44/// 一个独立加载的区块:状态信号 + 世代号(丢弃过期响应)。
45struct Section<T: Send + Sync + 'static> {
46    state: RwSignal<Load<T>>,
47    generation: RwSignal<u64>,
48}
49
50// 信号句柄本身是 Copy,不要求 `T: Clone`,所以不用 derive。
51impl<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    /// 只重载尚未就绪的区块;`fetch` 失败(`None`)时置为失败态。
68    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
93/// 按本地时间问候。
94fn 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
116/// 柱图纵轴上限:取最大值向上凑到 4 的倍数,网格线落在整数上;全为 0 时为 4。
117fn 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
122/// 使用最多的标签。
123fn 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
129/// 评论正文的一行预览:去掉图片与常见 Markdown 记号,只有图片的评论显示「\[图片\]」。
130fn comment_preview(md: &str) -> String {
131    let mut text = String::new();
132    let mut rest = md;
133    // 图片 `![alt](url)` 整段去掉。
134    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
158/// 仪表盘路由(无 SSR 数据,各区块在客户端独立加载)。
159pub 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    // 首次进入与点击重试时加载;只重载尚未就绪的区块,已就绪的保留。
184    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
320/// 一格数字:标签、数值(加载中为骨架)与可选附注;有目标路由时整格是链接。
321fn 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/// 四格数字。各格独立显示加载中与失败,失败时提供一次重试。
349#[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/// 每月发布篇数柱图:12 根柱,本月为陶土色;悬停或键盘聚焦显示提示,其余柱变淡。
426#[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            // 五条线(含底线)对应 max、¾、½、¼ 与 0。
494            <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("![](/uploads/a.webp)"), "[图片]");
791    }
792}