Skip to main content

yggdrasil/pages/
archives.rs

1//! 归档页面模块。
2//!
3//! 对应路由 `/archives`。
4//!
5//! 数据获取:通过 `use_server_future` 调用 `list_published_posts(1, 10000)` server function,
6//! 一次性拉取全部已发布文章,然后在内存中按发布日期的年、月进行分组展示。
7//! 在 `wasm32` 目标下,server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
8//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
9
10use dioxus::prelude::*;
11use dioxus::router::components::Link;
12
13use crate::api::posts::{list_published_posts, PostListResponse};
14use crate::components::empty_state::EmptyState;
15use crate::components::skeletons::archive_skeleton::ArchiveSkeleton;
16use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
17use crate::models::post::PostListItem;
18use crate::router::Route;
19
20/// 按年份分组的文章归档结构。
21#[derive(Clone, PartialEq)]
22struct YearGroup {
23    year: String,
24    months: Vec<MonthGroup>,
25}
26
27/// 按月份分组的文章归档结构。
28#[derive(Clone, PartialEq)]
29struct MonthGroup {
30    month: String,
31    month_en: String,
32    posts: Vec<PostListItem>,
33}
34
35/// 将文章列表按 `formatted_date()` 返回的 `YYYY-MM-DD` 格式进行年、月分组。
36///
37/// 返回的结果按原始文章顺序组织,调用前已按发布时间降序排列。
38fn group_posts(posts: &[PostListItem]) -> Vec<YearGroup> {
39    let mut years: Vec<YearGroup> = vec![];
40
41    for post in posts {
42        let date_str = post.formatted_date();
43
44        // 将日期字符串拆分为 [年, 月, 日] 三部分。
45        let parts: Vec<&str> = date_str.split('-').collect();
46        if parts.len() != 3 {
47            continue;
48        }
49        let year = parts[0].to_string();
50        let month_num = parts[1];
51        // 将数字月份转换为英文月份名称,用于展示与锚点 id。
52        let month_en = match month_num {
53            "01" => "January",
54            "02" => "February",
55            "03" => "March",
56            "04" => "April",
57            "05" => "May",
58            "06" => "June",
59            "07" => "July",
60            "08" => "August",
61            "09" => "September",
62            "10" => "October",
63            "11" => "November",
64            "12" => "December",
65            _ => month_num,
66        };
67
68        // 尝试追加到当前年份与月份的组中;如果不匹配则新建分组。
69        if let Some(yg) = years.last_mut() {
70            if yg.year == year {
71                if let Some(mg) = yg.months.last_mut() {
72                    if mg.month_en == month_en {
73                        mg.posts.push(post.clone());
74                        continue;
75                    }
76                }
77                yg.months.push(MonthGroup {
78                    month: month_en.to_string(),
79                    month_en: month_en.to_string(),
80                    posts: vec![post.clone()],
81                });
82                continue;
83            }
84        }
85        years.push(YearGroup {
86            year,
87            months: vec![MonthGroup {
88                month: month_en.to_string(),
89                month_en: month_en.to_string(),
90                posts: vec![post.clone()],
91            }],
92        });
93    }
94
95    years
96}
97
98/// 归档页面组件,对应路由 `/archives`。
99///
100/// 渲染页面标题,并委托给 `ArchivesContent` 展示按年月分组的文章列表。
101#[component]
102pub fn Archives() -> Element {
103    rsx! {
104        div { class: "animate-page-enter",
105            header { class: "page-header mb-6",
106                h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
107                    "归档"
108                }
109            }
110            ArchivesContent {}
111        }
112    }
113}
114
115/// 归档页面内容组件。
116///
117/// 通过 `use_server_future` 获取全部已发布文章,按年月分组后渲染;
118/// 加载中显示骨架屏,失败显示错误提示。
119#[component]
120fn ArchivesContent() -> Element {
121    // 一次性获取足够多的已发布文章,用于生成完整的年/月归档。
122    let posts_res = use_server_future(move || list_published_posts(1, 10000))?;
123
124    let posts_data = posts_res.read();
125    match &*posts_data {
126        Some(Ok(PostListResponse { posts, total })) => {
127            if *total == 0 {
128                rsx! {
129                    EmptyState {
130                        title: "还没有文章归档",
131                        description: "发布文章后,这里会自动按年月进行归档显示。",
132                    }
133                }
134            } else {
135                let grouped = group_posts(posts);
136                rsx! {
137                    div { class: "mt-2 text-base text-paper-secondary",
138                        "共 "
139                        span { class: "font-medium text-paper-primary", "{total}" }
140                        " 篇文章"
141                    }
142                    for year_group in grouped.iter() {
143                        YearSection {
144                            key: "{year_group.year}",
145                            year_group: year_group.clone(),
146                        }
147                    }
148                }
149            }
150        }
151        Some(Err(e)) => {
152            rsx! {
153                div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败: {e}" }
154            }
155        }
156        None => {
157            rsx! {
158                DelayedSkeleton { ArchiveSkeleton {} }
159            }
160        }
161    }
162}
163
164/// 单一年份归档区块组件,展示该年份下的所有月份分组。
165#[component]
166fn YearSection(year_group: YearGroup) -> Element {
167    let total = year_group
168        .months
169        .iter()
170        .map(|m| m.posts.len())
171        .sum::<usize>();
172
173    rsx! {
174        div { class: "archive-year mt-10",
175            h2 {
176                class: "archive-year-header text-2xl font-bold text-paper-primary mb-4",
177                id: "{year_group.year}",
178                a {
179                    class: "archive-header-link hover:opacity-80 transition-opacity",
180                    href: "#{year_group.year}",
181                    "{year_group.year}"
182                }
183                sup { class: "archive-count text-sm text-paper-secondary ml-1", "{total}" }
184            }
185            for month_group in year_group.months.iter() {
186                MonthSection {
187                    key: "{month_group.month_en}",
188                    month_group: month_group.clone(),
189                    year: year_group.year.clone(),
190                }
191            }
192        }
193    }
194}
195
196/// 单一月份归档区块组件,展示该月份下的文章条目。
197#[component]
198fn MonthSection(month_group: MonthGroup, year: String) -> Element {
199    let count = month_group.posts.len();
200
201    rsx! {
202        div { class: "archive-month flex flex-col md:flex-row md:items-start py-2.5 border-b border-paper-border/50",
203            h3 {
204                class: "archive-month-header text-lg font-medium text-paper-secondary md:w-[200px] shrink-0 mt-0 mb-0 py-1.5",
205                id: "{year}-{month_group.month_en}",
206                a {
207                    class: "archive-header-link hover:opacity-80 transition-opacity",
208                    href: "#{year}-{month_group.month_en}",
209                    "{month_group.month}"
210                }
211                sup { class: "archive-count text-sm text-paper-secondary ml-1", "{count}" }
212            }
213            div { class: "archive-posts flex-1",
214                for post in month_group.posts.iter() {
215                    ArchiveEntry { key: "{post.id}", post: post.clone() }
216                }
217            }
218        }
219    }
220}
221
222/// 单条归档文章组件,展示标题与发布日期,并通过覆盖层链接到文章详情。
223#[component]
224fn ArchiveEntry(post: PostListItem) -> Element {
225    let date_str = post.formatted_date();
226
227    rsx! {
228        div { class: "archive-entry relative py-1.5 my-2.5 group",
229            h3 { class: "archive-entry-title text-base font-normal text-paper-primary m-0",
230                "{post.title}"
231            }
232            div { class: "archive-meta text-sm text-paper-secondary mt-1", "{date_str}" }
233            Link {
234                class: "entry-link absolute inset-0 z-10",
235                aria_label: "post link to {post.title}",
236                to: Route::PostDetail {
237                    slug: post.slug.clone(),
238                },
239            }
240        }
241    }
242}