Skip to main content

yggdrasil/pages/
tags.rs

1//! 标签详情 `/tags/:tag`:与归档同一套版式,只列出该标签下的文章。
2//! 文章取自 `get_posts_by_tag`(最近 200 篇);每个标签拥有独立的请求。
3
4use leptos::prelude::*;
5use leptos_meta::Title;
6use leptos_router::hooks::use_params_map;
7use leptos_router::{lazy_route, LazyRoute};
8
9use crate::api::posts::{get_posts_by_tag, PostListResponse, TagListResponse};
10use crate::components::empty_state::EmptyState;
11use crate::components::mascot::Mascot;
12use crate::components::skeletons::archive_skeleton::ArchiveBodySkeleton;
13use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
14use crate::components::ui::{Icon, IconName, LoadError};
15use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
16use crate::models::post::PostListItem;
17use crate::pages::archives::{fetch_tags, tag_list, IndexRow, TagFilter, YearBlocks};
18use crate::routes::Route;
19
20/// 标签详情路由,对应 `/tags/:tag`(`SsrMode::Async`:`<Title>` 随标签而定,
21/// 且需要在流式输出前确定响应头)。
22pub struct TagRoute {
23    tag: Memo<String>,
24    posts: RouteData<PostListResponse>,
25    tags: RouteData<TagListResponse>,
26}
27
28#[leptos::lazy]
29async fn fetch_tag_posts(tag: String) -> PageJson<PostListResponse> {
30    PageJson::new(get_posts_by_tag(tag).await)
31}
32
33#[lazy_route]
34impl LazyRoute for TagRoute {
35    fn data() -> Self {
36        let params = use_params_map();
37        let tag = Memo::new(move |_| params.read().get("tag").unwrap_or_default());
38        Self {
39            tag,
40            posts: route_data(move || tag.get(), fetch_tag_posts),
41            tags: route_data(|| (), unit(fetch_tags)),
42        }
43    }
44
45    fn view(this: Self) -> AnyView {
46        let TagRoute { tag, posts, tags } = this;
47        // 按标签重建整页(含 Suspense),旧结果不会在新标题下短暂出现。
48        (move || {
49            let current = tag.get();
50            view! {
51                <Title text=format!("{current} · 标签 · Yggdrasil")/>
52                <div class="archive">
53                    <TagIntro tag=current.clone()/>
54                    <Suspense fallback=|| {
55                        view! {
56                            <DelayedSkeleton>
57                                <ArchiveBodySkeleton/>
58                            </DelayedSkeleton>
59                        }
60                    }>
61                        {
62                            let current = current.clone();
63                            move || {
64                                let current = current.clone();
65                                Suspend::new(async move {
66                                    let listing = posts.await.ok();
67                                    let tags = tag_list(tags.await);
68                                    view! { <TagBody tag=current initial=listing tags=tags/> }
69                                })
70                            }
71                        }
72                    </Suspense>
73                </div>
74            }
75        })
76        .into_any()
77    }
78}
79
80impl TagRoute {
81    /// 悬停预取:与 `data()` 相同的取数函数与 source。
82    pub fn prefetch_data(tag: String) {
83        prefetch_route_data(tag, fetch_tag_posts);
84        prefetch_route_data((), unit(fetch_tags));
85    }
86}
87
88/// 路由兜底和实际页面共用开场;标签名无需等待文章请求。
89#[component]
90pub fn TagIntro(#[prop(into)] tag: String) -> impl IntoView {
91    view! {
92        <header class="archive-header page-enter">
93            <a class="archive-back" href=Route::Archives.to_string()>
94                <Icon name=IconName::ArrowLeft size=14/>
95                "全部归档"
96            </a>
97            <h1 class="archive-title">
98                <span class="archive-title-hash" aria-hidden="true">
99                    "# "
100                </span>
101                {tag}
102            </h1>
103        </header>
104    }
105}
106
107/// 标签详情主体。`initial` 为 `None` 表示文章加载失败;重试结果写进本地信号,
108/// 不重取外层 `Resource`(否则整段 DOM 会退回 `Suspense` 的骨架屏)。
109#[component]
110fn TagBody(
111    tag: String,
112    initial: Option<PostListResponse>,
113    tags: Vec<crate::models::post::Tag>,
114) -> impl IntoView {
115    let listing = RwSignal::new(initial);
116    let retrying = RwSignal::new(false);
117    let retry_failed = RwSignal::new(false);
118
119    let request_tag = tag.clone();
120    let on_retry = Callback::new(move |()| {
121        retrying.set(true);
122        retry_failed.set(false);
123        let tag = request_tag.clone();
124        leptos::task::spawn_local(async move {
125            let result = get_posts_by_tag(tag).await;
126            let _ = retrying.try_set(false);
127            match result {
128                Ok(response) => {
129                    let _ = listing.try_set(Some(response));
130                }
131                Err(_) => {
132                    let _ = retry_failed.try_set(true);
133                }
134            }
135        });
136    });
137
138    let body_tag = tag.clone();
139    view! {
140        {move || {
141            listing
142                .with(|listing| {
143                    listing
144                        .as_ref()
145                        .map(|PostListResponse { posts, total }| tag_summary(posts, *total))
146                })
147                .map(|summary| {
148                    view! { <p class="archive-summary tabular-nums page-enter">{summary}</p> }
149                })
150        }}
151        <TagFilter tags=tags current=Some(tag.clone())/>
152        {move || {
153            let Some(PostListResponse { posts, total }) = listing.get() else {
154                return view! {
155                    <div class="archive-years" data-vt-list="true">
156                        <LoadError
157                            title="这个标签下的文章暂时没能加载"
158                            retrying=retrying
159                            retry_failed=retry_failed
160                            on_retry=on_retry
161                        />
162                    </div>
163                }
164                    .into_any();
165            };
166            let rows: Vec<IndexRow> = posts.iter().map(tag_row).collect();
167            let truncated = total > posts.len() as i64;
168            let listed = posts.len();
169            if rows.is_empty() {
170                return view! {
171                    <span hidden data-vt-list="true"></span>
172                    <EmptyState
173                        title="这个标签下还没有文章"
174                        description="换一个标签,或者回到全部归档看看。"
175                        mascot=Mascot::Doze
176                    />
177                }
178                    .into_any();
179            }
180            view! {
181                <YearBlocks rows=rows hide_tag=body_tag.clone()/>
182                {truncated
183                    .then(|| {
184                        view! {
185                            <p class="archive-limit-note tabular-nums">
186                                {format!("共 {total} 篇,这里列出最近的 {listed} 篇。")}
187                            </p>
188                        }
189                    })}
190            }
191                .into_any()
192        }}
193    }
194}
195
196fn tag_row(post: &PostListItem) -> IndexRow {
197    IndexRow {
198        id: post.id,
199        slug: post.slug.clone(),
200        title: post.title.clone(),
201        date: post.published_at.unwrap_or(post.created_at),
202        tags: post.tags.clone(),
203    }
204}
205
206/// 「16 篇文章,最近一篇写于 2026.10.15。」
207fn tag_summary(posts: &[PostListItem], total: i64) -> String {
208    match posts
209        .iter()
210        .map(|p| p.published_at.unwrap_or(p.created_at))
211        .max()
212    {
213        Some(latest) => format!(
214            "{total} 篇文章,最近一篇写于 {}。",
215            crate::utils::time::ymd(&latest, '.')
216        ),
217        None => "还没有文章。".to_string(),
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::models::post::PostStatus;
225    use chrono::{TimeZone, Utc};
226
227    fn post(id: i32, created_day: u32, published_day: Option<u32>) -> PostListItem {
228        let date = |day| Utc.with_ymd_and_hms(2026, 7, day, 0, 0, 0).unwrap();
229        PostListItem {
230            id,
231            author_id: 1,
232            title: format!("Article {id}"),
233            slug: format!("article-{id}"),
234            summary: None,
235            status: PostStatus::Published,
236            published_at: published_day.map(date),
237            created_at: date(created_day),
238            updated_at: date(created_day),
239            deleted_at: None,
240            tags: Vec::new(),
241            cover_image: None,
242            reading_time: 1,
243            word_count: 0,
244        }
245    }
246
247    #[test]
248    fn summary_uses_the_latest_publication_or_creation_date() {
249        let posts = [post(1, 1, Some(10)), post(2, 20, None), post(3, 2, Some(5))];
250        assert_eq!(
251            tag_summary(&posts, 3),
252            "3 篇文章,最近一篇写于 2026.07.20。"
253        );
254        assert_eq!(tag_summary(&[], 0), "还没有文章。");
255    }
256
257    #[test]
258    fn rows_fall_back_to_creation_date() {
259        let row = tag_row(&post(7, 3, None));
260        assert_eq!(row.date, Utc.with_ymd_and_hms(2026, 7, 3, 0, 0, 0).unwrap());
261        assert_eq!(row.slug, "article-7");
262    }
263}