Skip to main content

yggdrasil/pages/
tags.rs

1//! 标签专题:刊物式开场、可排序文章流与关联主题。
2//! 保留 SSR 取数(最近 200 篇);每个标签拥有独立的请求和浏览状态。
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use dioxus::prelude::*;
7
8use crate::api::posts::{get_posts_by_tag, PostListResponse};
9use crate::components::post_card::PostCard;
10use crate::components::skeletons::tags_skeleton::TagPostsLoading;
11use crate::models::post::PostListItem;
12use crate::router::Route;
13
14#[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15enum TagSortOrder {
16    Latest,
17    Earliest,
18}
19
20impl TagSortOrder {
21    fn value(self) -> &'static str {
22        match self {
23            Self::Latest => "latest",
24            Self::Earliest => "earliest",
25        }
26    }
27
28    fn label(self) -> &'static str {
29        match self {
30            Self::Latest => "最新",
31            Self::Earliest => "最早",
32        }
33    }
34
35    fn opening(self) -> &'static str {
36        match self {
37            Self::Latest => "最近写下",
38            Self::Earliest => "从最初读起",
39        }
40    }
41}
42
43fn sorted_posts(posts: &[PostListItem], order: TagSortOrder) -> Vec<&PostListItem> {
44    let mut sorted: Vec<_> = posts.iter().collect();
45    sorted.sort_by_key(|post| (post.published_at.unwrap_or(post.created_at), post.id));
46    if order == TagSortOrder::Latest {
47        sorted.reverse();
48    }
49    sorted
50}
51
52/// 每篇文章对同一关联标签只计一次,数量相同按名称排序,确保 SSR 与浏览器一致。
53fn related_tags(posts: &[PostListItem], current_tag: &str) -> Vec<String> {
54    let mut counts = BTreeMap::new();
55    for post in posts {
56        for tag in post.tags.iter().collect::<BTreeSet<_>>() {
57            if tag != current_tag && !tag.trim().is_empty() {
58                *counts.entry(tag).or_insert(0_usize) += 1;
59            }
60        }
61    }
62    let mut tags: Vec<_> = counts.into_iter().collect();
63    tags.sort_by(|(a, a_count), (b, b_count)| b_count.cmp(a_count).then_with(|| a.cmp(b)));
64    tags.into_iter()
65        .take(6)
66        .map(|(tag, _)| tag.clone())
67        .collect()
68}
69
70#[component]
71pub fn TagDetail(tag: String) -> Element {
72    rsx! {
73        div { class: "tag-page",
74            TagIntro { tag: tag.clone() }
75            // 单元素 keyed 列表使同路由变体切换时卸载旧请求、重置排序。
76            // key 放在 Suspense 外面,旧结果不会在新标题下短暂出现。
77            for current_tag in std::iter::once(tag) {
78                div { key: "{current_tag}", id: "tag-posts", class: "tag-workspace",
79                    SuspenseBoundary {
80                        fallback: |_| rsx! { TagPostsLoading {} },
81                        TagDetailContent { tag: current_tag }
82                    }
83                }
84            }
85        }
86    }
87}
88
89/// 路由兜底和实际页面共用开场;已知的主题名称无需等待文章请求。
90#[component]
91pub(crate) fn TagIntro(tag: String) -> Element {
92    rsx! {
93        header { class: "tag-intro",
94            Link { class: "tag-text-link tag-back", to: Route::Archives {},
95                span { class: "tag-arrow", aria_hidden: "true", "←" }
96                span { "归档与标签" }
97            }
98            div { class: "tag-hero",
99                div { class: "tag-hero-copy tag-enter",
100                    p { class: "tag-eyebrow",
101                        span { class: "tag-eyebrow-mark", aria_hidden: "true", "#" }
102                        "TOPIC COLLECTION" span { class: "tag-eyebrow-translation", " / 主题手记" }
103                    }
104                    h1 { "{tag}" }
105                    p { class: "tag-description", "沿着一个主题,拾起散落的思考。" br {} "让有关的文字,在这里慢慢生长。" }
106                }
107                div { class: "tag-hero-art tag-enter", style: "--tag-delay: 100ms", aria_hidden: "true",
108                    TagIllustration {}
109                    span { class: "tag-art-caption", "A BRANCH OF THE GARDEN" }
110                }
111            }
112        }
113    }
114}
115
116#[component]
117fn TagIllustration(#[props(default = false)] paused: bool) -> Element {
118    rsx! {
119        svg { class: "tag-illustration", view_box: "0 0 240 240", fill: "none", "aria-hidden": "true",
120            circle { cx: "120", cy: "120", r: "104", stroke: "currentColor", stroke_width: "0.7", stroke_dasharray: "1 7", opacity: "0.3" }
121            path { d: "M120 7V23M120 217V233M7 120H23M217 120H233", stroke: "currentColor", stroke_width: "0.7", opacity: "0.4" }
122            g { class: "tag-illustration-pages", stroke: "currentColor", stroke_linejoin: "round",
123                path { d: "M65 63L164 48L185 187L86 202Z", stroke_width: "0.9", opacity: "0.22" }
124                path { d: "M58 51H168V191H58Z", stroke_width: "1", opacity: "0.48" }
125                path { d: "M70 51V191M83 170H145M83 178H120", stroke_width: "0.8", opacity: "0.28" }
126                path { d: "M141 51V91L151 83L161 91V51", stroke_width: "1.1", fill: "currentColor", fill_opacity: "0.06" }
127            }
128            g { class: "tag-illustration-sprout", stroke: "currentColor", stroke_width: "1.4", stroke_linecap: "round", stroke_linejoin: "round",
129                path { d: "M112 156V109M99 156H126" }
130                path { d: "M112 138C92 138 86 124 88 111C105 112 115 120 112 138ZM112 119C111 100 122 92 136 94C136 109 127 119 112 119Z" }
131                path { d: "M112 137L97 121M112 119L127 103", opacity: "0.65" }
132            }
133            if paused {
134                circle { cx: "179", cy: "174", r: "23", fill: "var(--color-paper-theme)", stroke: "currentColor", stroke_width: "0.8" }
135                path { d: "M174 167V181M184 167V181", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round" }
136            } else {
137                path { d: "M184 101V113M178 107H190M49 151V159M45 155H53", stroke: "currentColor", stroke_width: "1", stroke_linecap: "round", opacity: "0.55" }
138                circle { cx: "184", cy: "145", r: "2", fill: "currentColor", opacity: "0.5" }
139            }
140        }
141    }
142}
143
144#[component]
145fn TagDetailContent(tag: String) -> Element {
146    let entry_id = use_hook(crate::bridges::navigation::entry_id);
147    let mut order = use_signal(|| {
148        crate::bridges::navigation::read_state("tag-order").unwrap_or(TagSortOrder::Latest)
149    });
150    // Capture the entry at mount: a late effect from the outgoing page must not
151    // overwrite the destination history entry.
152    use_effect(move || {
153        crate::bridges::navigation::write_state(&entry_id, "tag-order", &order());
154    });
155    // 父组件按标签 remount,因此闭包中的 tag 在此作用域内不会过期。
156    let request_tag = tag.clone();
157    let mut posts_res = use_server_future(move || get_posts_by_tag(request_tag.clone()))?;
158    let posts_data = posts_res.read();
159
160    match posts_data.as_ref() {
161        Some(Ok(PostListResponse { posts, total })) => {
162            let sorted = sorted_posts(posts, order());
163            let related = related_tags(posts, &tag);
164            let recent = posts
165                .iter()
166                .max_by_key(|post| post.published_at.unwrap_or(post.created_at));
167            let truncated = *total > posts.len() as i64;
168            rsx! {
169                section { class: "tag-results", "data-vt-list": "true", aria_label: "主题文章", aria_busy: "false",
170                    p { class: "sr-only", role: "status", aria_atomic: "true",
171                        "已加载 {posts.len()} 篇文章,按{order().label()}排序。"
172                    }
173                    div { class: "tag-toolbar tag-enter",
174                        div { class: "tag-collection-meta",
175                            p { class: "tag-count", "共 " strong { "{total}" } " 篇文章" }
176                            if let Some(post) = recent {
177                                p { class: "tag-recent", "最近收录 " time { datetime: post.formatted_date(), "{post.formatted_date()}" } }
178                            }
179                        }
180                        if posts.len() > 1 {
181                            div { class: "tag-sort-group",
182                                if truncated { span { class: "tag-sort-note", "当前列表排序" } }
183                                div { class: "tag-sort", role: "group", aria_label: "文章排序", "data-order": order().value(),
184                                    for choice in [TagSortOrder::Latest, TagSortOrder::Earliest] {
185                                        button {
186                                            r#type: "button",
187                                            aria_pressed: (order() == choice).to_string(),
188                                            onclick: move |_| { if order() != choice { order.set(choice); } },
189                                            "{choice.label()}"
190                                        }
191                                    }
192                                }
193                            }
194                        }
195                    }
196                    if truncated {
197                        p { class: "tag-limit-note", "共 {total} 篇,当前展示最近 {posts.len()} 篇;排序和相关标签基于当前列表。" }
198                    }
199                    if posts.is_empty() {
200                        TagStatus {}
201                    } else {
202                        div { class: "tag-post-list", "data-order": order().value(),
203                            for (index, post) in sorted.into_iter().enumerate() {
204                                div {
205                                    key: "{post.id}",
206                                    class: if index == 0 { "tag-entry tag-featured tag-enter" } else { "tag-entry tag-enter" },
207                                    style: "--tag-delay: {index.min(5) * 50}ms",
208                                    if index == 0 {
209                                        span { class: "tag-featured-label", span { aria_hidden: "true", "01 /" } "{order().opening()}" }
210                                    } else {
211                                        span { class: "tag-entry-number", aria_hidden: "true", {format!("{:02}", index + 1)} }
212                                    }
213                                    PostCard { post: post.clone(), compact: true }
214                                    span { class: "tag-entry-arrow", aria_hidden: "true", "↗" }
215                                }
216                            }
217                        }
218                    }
219                    if !related.is_empty() {
220                        nav { class: "tag-related tag-enter", aria_label: "相关标签",
221                            div { class: "tag-related-heading",
222                                div {
223                                    p { class: "tag-eyebrow", "CONNECTED NOTES / 相邻枝叶" }
224                                    h2 { "沿着枝叶,继续阅读" }
225                                }
226                                Link { class: "tag-text-link", to: Route::Archives {},
227                                    span { "全部标签" } span { class: "tag-arrow", aria_hidden: "true", "↗" }
228                                }
229                            }
230                            div { class: "tag-related-links",
231                                for name in related {
232                                    Link { key: "{name}", class: "tag-related-link", to: Route::TagDetail { tag: name.clone() },
233                                        span { class: "tag-related-mark", aria_hidden: "true", "#" }
234                                        span { "{name}" }
235                                        span { class: "tag-arrow", aria_hidden: "true", "↗" }
236                                    }
237                                }
238                            }
239                        }
240                    }
241                }
242            }
243        }
244        Some(Err(_)) => rsx! {
245            section { class: "tag-results", "data-vt-list": "true", aria_label: "主题文章", aria_busy: "false",
246                TagStatus { failed: true, on_retry: move |_| {
247                    if !posts_res.pending() { posts_res.restart(); }
248                } }
249            }
250        },
251        _ => rsx! { TagPostsLoading {} },
252    }
253}
254
255#[component]
256fn TagStatus(
257    #[props(default = false)] failed: bool,
258    #[props(default)] on_retry: EventHandler<()>,
259) -> Element {
260    rsx! {
261        div { class: "tag-status tag-enter", "data-state": if failed { "error" } else { "empty" },
262            div { class: "tag-status-art", aria_hidden: "true", TagIllustration { paused: failed } }
263            div { class: "tag-status-copy",
264                p { class: "tag-eyebrow", if failed { "A LITTLE PAUSE / 稍作停留" } else { "STILL GROWING / 等待生长" } }
265                h2 { if failed { "文字暂时未能展开" } else { "这里的文字,还在生长" } }
266                p { class: "tag-status-description", role: "status", aria_atomic: "true",
267                    if failed { "文章暂时未能加载,稍等片刻,再试一次。" }
268                    else { "这个主题下还没有发布的文章,先去别的枝叶间逛逛吧。" }
269                }
270                div { class: "tag-status-actions",
271                    if failed {
272                        button { class: "tag-button", r#type: "button", onclick: move |_| on_retry.call(()),
273                            span { class: "tag-retry-icon", aria_hidden: "true", "↻" } "重新加载"
274                        }
275                    }
276                    Link { class: "tag-text-link", to: Route::Archives {},
277                        span { "返回归档" } span { class: "tag-arrow", aria_hidden: "true", "↗" }
278                    }
279                }
280            }
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::models::post::PostStatus;
289    use chrono::{TimeZone, Utc};
290
291    fn post(id: i32, created_day: u32, published_day: Option<u32>, tags: &[&str]) -> PostListItem {
292        let date = |day| Utc.with_ymd_and_hms(2026, 7, day, 0, 0, 0).unwrap();
293        PostListItem {
294            id,
295            author_id: 1,
296            title: format!("Article {id}"),
297            slug: format!("article-{id}"),
298            summary: None,
299            status: PostStatus::Published,
300            published_at: published_day.map(date),
301            created_at: date(created_day),
302            updated_at: date(created_day),
303            deleted_at: None,
304            tags: tags.iter().map(|tag| (*tag).to_string()).collect(),
305            cover_image: None,
306            reading_time: 1,
307            word_count: 0,
308        }
309    }
310
311    #[test]
312    fn sorting_uses_publication_then_creation_and_stable_id_ties() {
313        let posts = vec![
314            post(4, 1, Some(10), &[]),
315            post(2, 20, Some(5), &[]),
316            post(3, 10, None, &[]),
317            post(1, 2, None, &[]),
318        ];
319        let ids = |order| {
320            sorted_posts(&posts, order)
321                .iter()
322                .map(|post| post.id)
323                .collect::<Vec<_>>()
324        };
325        assert_eq!(ids(TagSortOrder::Latest), [4, 3, 2, 1]);
326        assert_eq!(ids(TagSortOrder::Earliest), [1, 2, 3, 4]);
327        assert_eq!(
328            posts.iter().map(|post| post.id).collect::<Vec<_>>(),
329            [4, 2, 3, 1]
330        );
331        assert!(sorted_posts(&[], TagSortOrder::Latest).is_empty());
332    }
333
334    #[test]
335    fn related_tags_count_articles_exclude_self_and_break_ties_by_name() {
336        let posts = vec![
337            post(
338                1,
339                1,
340                None,
341                &["Architecture", "Rust", "Rust", "CI-CD", "", " "],
342            ),
343            post(2, 2, None, &["Architecture", "Docker", "CI-CD"]),
344        ];
345        assert_eq!(
346            related_tags(&posts, "Architecture"),
347            ["CI-CD", "Docker", "Rust"]
348        );
349        assert!(related_tags(&[post(1, 1, None, &["Architecture"])], "Architecture").is_empty());
350        assert!(related_tags(&[], "Architecture").is_empty());
351    }
352
353    #[test]
354    fn related_tags_keep_the_six_most_connected_topics() {
355        let posts = vec![
356            post(1, 1, None, &["H", "G", "F", "E", "D", "C", "B", "A"]),
357            post(2, 2, None, &["H", "G"]),
358        ];
359        assert_eq!(related_tags(&posts, "A"), ["G", "H", "B", "C", "D", "E"]);
360    }
361}