Skip to main content

yggdrasil/pages/
archives.rs

1//! 归档与标签详情共用的索引页。
2//!
3//! `/archives`:衬线页标题、标签筛选(Chip)、按年份分组的文章行;年份大字吸附。
4//! `/tags/:tag`(见 `tags.rs`)沿用同一套标签区与年份分组,当前标签以浮起样式标示。
5//!
6//! 首屏请求轻量归档分页,按需加载更早的文章。标签接口失败时只隐藏标签区。
7
8use chrono::{DateTime, Datelike, Utc};
9use leptos::prelude::*;
10use leptos_meta::Title;
11use leptos_router::{lazy_route, LazyRoute};
12
13use crate::api::error::ApiError;
14use crate::api::posts::{
15    list_archive_posts, list_tags, ArchiveResponse, TagListResponse, ARCHIVE_PAGE_SIZE,
16};
17use crate::components::empty_state::EmptyState;
18use crate::components::skeletons::archive_skeleton::ArchiveBodySkeleton;
19use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
20use crate::components::ui::{Button, ButtonVariant, LoadError};
21use crate::hooks::ssr_resource::{prefetch_route_data, route_data, unit, PageJson, RouteData};
22use crate::models::post::Tag;
23use crate::routes::Route;
24
25/// 标签区默认显示的标签数,其余收进「全部 N 个」。
26const TAG_PREVIEW: usize = 16;
27/// 每行右侧最多列出的标签数。
28const ROW_TAGS: usize = 2;
29
30/// 年份分组里的一行。归档与标签详情的数据源不同,在这里统一形状。
31#[derive(Clone, Debug, PartialEq)]
32pub struct IndexRow {
33    pub id: i32,
34    pub slug: String,
35    pub title: String,
36    pub date: DateTime<Utc>,
37    pub tags: Vec<String>,
38}
39
40#[derive(Clone, Debug, PartialEq)]
41pub(crate) struct YearGroup {
42    pub year: i32,
43    pub rows: Vec<IndexRow>,
44}
45
46/// 列表已按时间降序;相邻同年的行归为一组。
47pub(crate) fn group_by_year(rows: &[IndexRow]) -> Vec<YearGroup> {
48    let mut groups: Vec<YearGroup> = Vec::new();
49    for row in rows {
50        let year = row.date.year();
51        match groups.last_mut() {
52            Some(group) if group.year == year => group.rows.push(row.clone()),
53            _ => groups.push(YearGroup {
54                year,
55                rows: vec![row.clone()],
56            }),
57        }
58    }
59    groups
60}
61
62/// 只保留有已发布文章的标签,按篇数降序、同数按名称排列。
63pub(crate) fn ranked_tags(mut tags: Vec<Tag>) -> Vec<Tag> {
64    tags.retain(|t| t.post_count > 0);
65    tags.sort_by(|a, b| {
66        b.post_count
67            .cmp(&a.post_count)
68            .then_with(|| a.name.cmp(&b.name))
69    });
70    tags
71}
72
73/// 收起时显示的标签:前若干个;当前标签排在后面也要露出来。
74fn visible_tags<'a>(tags: &'a [Tag], current: Option<&str>, expanded: bool) -> Vec<&'a Tag> {
75    if expanded {
76        return tags.iter().collect();
77    }
78    let mut shown: Vec<&Tag> = tags.iter().take(TAG_PREVIEW).collect();
79    if let Some(current) = current {
80        if !shown.iter().any(|t| t.name == current) {
81            if let Some(tag) = tags.iter().find(|t| t.name == current) {
82                shown.push(tag);
83            }
84        }
85    }
86    shown
87}
88
89/// 归档路由,对应 `/archives`(`SsrMode::InOrder`)。
90pub struct ArchivesRoute {
91    posts: RouteData<ArchiveResponse>,
92    tags: RouteData<TagListResponse>,
93}
94
95#[leptos::lazy]
96async fn fetch_archive() -> PageJson<ArchiveResponse> {
97    PageJson::new(list_archive_posts(1).await)
98}
99
100/// 全部标签;归档、标签详情与关于页共用(取数函数随分包加载,见 `hooks::ssr_resource`)。
101#[leptos::lazy]
102pub(crate) async fn fetch_tags() -> PageJson<TagListResponse> {
103    PageJson::new(list_tags().await)
104}
105
106#[lazy_route]
107impl LazyRoute for ArchivesRoute {
108    fn data() -> Self {
109        Self {
110            posts: route_data(|| (), unit(fetch_archive)),
111            tags: route_data(|| (), unit(fetch_tags)),
112        }
113    }
114
115    fn view(this: Self) -> AnyView {
116        let ArchivesRoute { posts, tags } = this;
117        view! {
118            <Title text="归档 · Yggdrasil"/>
119            <div class="archive">
120                <header class="archive-header page-enter">
121                    <h1 class="archive-title">"归档"</h1>
122                </header>
123                <Suspense fallback=|| {
124                    view! {
125                        <DelayedSkeleton>
126                            <ArchiveBodySkeleton/>
127                        </DelayedSkeleton>
128                    }
129                }>
130                    {move || Suspend::new(async move {
131                        // 文章失败只让列表区换成错误卡片;标签失败只隐藏标签区。
132                        let first = posts.await.ok();
133                        let tags = tag_list(tags.await);
134                        view! { <ArchivesBody first=first tags=tags/> }
135                    })}
136                </Suspense>
137            </div>
138        }
139        .into_any()
140    }
141}
142
143impl ArchivesRoute {
144    /// 悬停预取:与 `data()` 相同的取数函数与 source。
145    pub fn prefetch_data() {
146        prefetch_route_data((), unit(fetch_archive));
147        prefetch_route_data((), unit(fetch_tags));
148    }
149}
150
151/// 归档主体。`first` 为 `None` 表示首屏加载失败;重试与「加载更早」的结果都写进
152/// 本地信号,不重取外层 `Resource`(否则整段 DOM 会退回 `Suspense` 的骨架屏)。
153#[component]
154fn ArchivesBody(first: Option<ArchiveResponse>, tags: Vec<Tag>) -> impl IntoView {
155    let listing = RwSignal::new(first);
156    let older = RwSignal::new(Vec::<IndexRow>::new());
157    let next_page = RwSignal::new(2_i32);
158    let loading_more = RwSignal::new(false);
159    let load_more_failed = RwSignal::new(false);
160    let exhausted = RwSignal::new(false);
161    let retrying = RwSignal::new(false);
162    let retry_failed = RwSignal::new(false);
163
164    let loaded = Memo::new(move |_| listing.with(Option::is_some));
165    let total = move || listing.with(|l| l.as_ref().map_or(0, |r| r.total));
166    let rows = Signal::derive(move || {
167        let mut rows: Vec<IndexRow> = listing.with(|l| {
168            l.as_ref()
169                .map(|r| r.posts.iter().cloned().map(archive_row).collect())
170                .unwrap_or_default()
171        });
172        older.with(|older| rows.extend(older.iter().cloned()));
173        rows
174    });
175    let has_more = move || !exhausted.get() && (rows.with(Vec::len) as i64) < total();
176
177    let on_more = move |_| {
178        loading_more.set(true);
179        load_more_failed.set(false);
180        let page = next_page.get_untracked();
181        leptos::task::spawn_local(async move {
182            match list_archive_posts(page).await {
183                Ok(response) => {
184                    let _ = exhausted.try_set(response.posts.len() < ARCHIVE_PAGE_SIZE);
185                    let _ = older.try_update(|older| {
186                        older.extend(response.posts.into_iter().map(archive_row));
187                    });
188                    let _ = next_page.try_update(|page| *page += 1);
189                }
190                Err(_) => {
191                    let _ = load_more_failed.try_set(true);
192                }
193            }
194            let _ = loading_more.try_set(false);
195        });
196    };
197
198    let on_retry = Callback::new(move |()| {
199        retrying.set(true);
200        retry_failed.set(false);
201        leptos::task::spawn_local(async move {
202            let result = list_archive_posts(1).await;
203            let _ = retrying.try_set(false);
204            match result {
205                Ok(response) => {
206                    let _ = listing.try_set(Some(response));
207                }
208                Err(_) => {
209                    let _ = retry_failed.try_set(true);
210                }
211            }
212        });
213    });
214
215    view! {
216        {move || {
217            loaded
218                .get()
219                .then(|| {
220                    view! {
221                        <p class="archive-summary tabular-nums page-enter">
222                            {move || archive_summary(&rows.get(), total(), has_more())}
223                        </p>
224                    }
225                })
226        }}
227        <TagFilter tags=tags current=None/>
228        {move || {
229            if !loaded.get() {
230                return view! {
231                    <div class="archive-years" data-vt-list="true">
232                        <LoadError
233                            title="归档暂时没能加载"
234                            retrying=retrying
235                            retry_failed=retry_failed
236                            on_retry=on_retry
237                        />
238                    </div>
239                }
240                    .into_any();
241            }
242            if total() == 0 {
243                return view! {
244                    <span hidden data-vt-list="true"></span>
245                    <EmptyState title="还没有文章" description="发布文章后,这里会按年份排好。"/>
246                }
247                    .into_any();
248            }
249            view! {
250                <YearBlocks rows=rows/>
251                {move || {
252                    has_more()
253                        .then(|| {
254                            view! {
255                                <div class="archive-more">
256                                    <Button
257                                        variant=ButtonVariant::Secondary
258                                        loading=loading_more
259                                        onclick=on_more
260                                    >
261                                        "加载更早的文章"
262                                    </Button>
263                                    {move || {
264                                        load_more_failed
265                                            .get()
266                                            .then(|| {
267                                                view! {
268                                                    <p class="archive-more-error" role="alert">
269                                                        "没能加载更早的文章,再试一次。"
270                                                    </p>
271                                                }
272                                            })
273                                    }}
274                                </div>
275                            }
276                        })
277                }}
278            }
279                .into_any()
280        }}
281    }
282}
283
284fn archive_row(post: crate::models::archive::ArchivePost) -> IndexRow {
285    IndexRow {
286        id: post.id,
287        slug: post.slug,
288        title: post.title,
289        date: post.published_at,
290        tags: post.tags,
291    }
292}
293
294/// 「41 篇文章,写于 2020 — 2026。」;还有未加载的更早文章时不写起始年份。
295fn archive_summary(rows: &[IndexRow], total: i64, has_more: bool) -> String {
296    match (rows.first(), rows.last()) {
297        (Some(newest), Some(oldest)) if !has_more => {
298            let (from, to) = (oldest.date.year(), newest.date.year());
299            if from == to {
300                format!("{total} 篇文章,写于 {to}。")
301            } else {
302                format!("{total} 篇文章,写于 {from} — {to}。")
303            }
304        }
305        (Some(newest), _) => format!(
306            "{total} 篇文章,最近一篇写于 {}。",
307            crate::utils::time::ymd(&newest.date, '.')
308        ),
309        _ => "还没有文章。".to_string(),
310    }
311}
312
313/// 标签接口的结果:失败时为空,标签区随之隐藏。
314pub(crate) fn tag_list(data: Result<TagListResponse, ApiError>) -> Vec<Tag> {
315    data.map(|r| ranked_tags(r.tags)).unwrap_or_default()
316}
317
318/// 标签筛选区。点当前标签回到全部归档;展开状态随历史记录恢复。
319///
320/// `current` 直接传 `Option<String>`(`None` 或 `Some(..)`)。
321#[component]
322pub(crate) fn TagFilter(tags: Vec<Tag>, current: Option<String>) -> impl IntoView {
323    let entry_id = crate::bridges::navigation::entry_id();
324    // 服务端没有历史状态,水合首帧必须与之一致;恢复在挂载后的 Effect 里完成。
325    let expanded = RwSignal::new(false);
326    Effect::new(move |previous: Option<()>| {
327        if previous.is_none() {
328            if let Some(saved) = crate::bridges::navigation::read_state::<bool>("archive-tags-open")
329            {
330                if saved != expanded.get_untracked() {
331                    expanded.set(saved);
332                }
333            }
334        }
335        crate::bridges::navigation::write_state(&entry_id, "archive-tags-open", &expanded.get());
336    });
337    if tags.is_empty() {
338        return ().into_any();
339    }
340    let total = tags.len();
341    let shown_tags = tags.clone();
342    let list_current = current.clone();
343
344    view! {
345        <section class="archive-tags page-enter" aria-labelledby="archive-tags-title">
346            <div class="archive-tags-head">
347                <h2 id="archive-tags-title" class="archive-tags-title">
348                    "标签 " <span class="tabular-nums">{total}</span>
349                </h2>
350                {(total > TAG_PREVIEW)
351                    .then(|| {
352                        view! {
353                            <button
354                                type="button"
355                                class="archive-tags-toggle"
356                                aria-expanded=move || expanded.get().to_string()
357                                on:click=move |_| expanded.update(|open| *open = !*open)
358                            >
359                                {move || {
360                                    if expanded.get() { "收起".to_string() } else { format!("全部 {total} 个") }
361                                }}
362                            </button>
363                        }
364                    })}
365            </div>
366            <div class="archive-tags-list">
367                {move || {
368                    visible_tags(&shown_tags, list_current.as_deref(), expanded.get())
369                        .into_iter()
370                        .map(|tag| {
371                            if list_current.as_deref() == Some(tag.name.as_str()) {
372                                view! {
373                                    <a
374                                        class="chip is-current"
375                                        href=Route::Archives.to_string()
376                                        aria-current="page"
377                                        title="回到全部归档"
378                                    >
379                                        {tag.name.clone()}
380                                        <span class="chip-count">{tag.post_count}</span>
381                                    </a>
382                                }
383                                    .into_any()
384                            } else {
385                                view! {
386                                    <a
387                                        class="chip"
388                                        href=Route::TagDetail {
389                                            tag: tag.name.clone(),
390                                        }
391                                            .to_string()
392                                    >
393                                        {tag.name.clone()}
394                                        <span class="chip-count">{tag.post_count}</span>
395                                    </a>
396                                }
397                                    .into_any()
398                            }
399                        })
400                        .collect_view()
401                }}
402            </div>
403        </section>
404    }
405    .into_any()
406}
407
408/// 按年份分组的文章行。`hide_tag` 为标签详情的当前标签,不在行尾重复列出(缺省则都列出)。
409///
410/// 年份组按年份做 key:加载更早的文章只追加新年份、更新已有年份的行,
411/// 已显示的年份组不重建(不重播入场动画)。
412#[component]
413pub(crate) fn YearBlocks(
414    #[prop(into)] rows: Signal<Vec<IndexRow>>,
415    #[prop(optional)] hide_tag: Option<String>,
416) -> impl IntoView {
417    // 年份只会向后追加,序号即首次出现的位置。
418    let years = Memo::new(move |_| {
419        rows.with(|rows| {
420            group_by_year(rows)
421                .into_iter()
422                .enumerate()
423                .map(|(index, group)| (group.year, index))
424                .collect::<Vec<_>>()
425        })
426    });
427
428    view! {
429        <section class="archive-years" data-vt-list="true" aria-label="按年份">
430            <For
431                each=move || years.get()
432                key=|(year, _)| *year
433                children=move |(year, index)| {
434                    view! { <YearBlock year=year index=index rows=rows hide_tag=hide_tag.clone()/> }
435                }
436            />
437        </section>
438    }
439}
440
441#[component]
442fn YearBlock(
443    year: i32,
444    index: usize,
445    rows: Signal<Vec<IndexRow>>,
446    hide_tag: Option<String>,
447) -> impl IntoView {
448    let group_rows = Memo::new(move |_| {
449        rows.with(|rows| {
450            rows.iter()
451                .filter(|row| row.date.year() == year)
452                .cloned()
453                .collect::<Vec<_>>()
454        })
455    });
456
457    view! {
458        <div
459            class="archive-year page-enter"
460            style=format!("--enter-index: {}", index.min(5))
461        >
462            <div class="archive-year-label">
463                <h2 id=format!("y{year}") class="archive-year-number">
464                    {year}
465                </h2>
466                <p class="archive-year-count tabular-nums">
467                    {move || format!("{} 篇", group_rows.with(Vec::len))}
468                </p>
469            </div>
470            <ol class="archive-rows" aria-labelledby=format!("y{year}")>
471                <For
472                    each=move || group_rows.get()
473                    key=|row| row.id
474                    children=move |row| {
475                        view! {
476                            <li>
477                                <ArchiveRow row=row hide_tag=hide_tag.clone()/>
478                            </li>
479                        }
480                    }
481                />
482            </ol>
483        </div>
484    }
485}
486
487#[component]
488fn ArchiveRow(row: IndexRow, hide_tag: Option<String>) -> impl IntoView {
489    let tags = row
490        .tags
491        .iter()
492        .filter(|t| Some(t.as_str()) != hide_tag.as_deref())
493        .take(ROW_TAGS)
494        .cloned()
495        .collect::<Vec<_>>()
496        .join(" · ");
497    let datetime = crate::utils::time::ymd(&row.date, '-');
498    let date = crate::utils::time::md(&row.date, '.');
499    let tags = (!tags.is_empty()).then(|| view! { <span class="archive-entry-tags">{tags}</span> });
500    view! {
501        <a
502            class="archive-entry"
503            href=Route::PostDetail {
504                slug: row.slug.clone(),
505            }
506                .to_string()
507            data-vt-post-link=row.id.to_string()
508        >
509            <time class="archive-entry-date tabular-nums" datetime=datetime>
510                {date}
511            </time>
512            <span
513                class="archive-entry-title"
514                data-vt-post-id=row.id.to_string()
515                data-vt-role="title"
516            >
517                {row.title}
518            </span>
519            {tags}
520        </a>
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use chrono::TimeZone;
528
529    fn row(id: i32, y: i32, m: u32, d: u32) -> IndexRow {
530        IndexRow {
531            id,
532            slug: format!("post-{id}"),
533            title: format!("文章 {id}"),
534            date: Utc.with_ymd_and_hms(y, m, d, 8, 0, 0).unwrap(),
535            tags: Vec::new(),
536        }
537    }
538
539    fn tag(id: i32, name: &str, post_count: i64) -> Tag {
540        Tag {
541            id,
542            name: name.into(),
543            post_count,
544        }
545    }
546
547    #[test]
548    fn groups_adjacent_rows_by_year() {
549        let rows = [
550            row(1, 2026, 10, 15),
551            row(2, 2026, 1, 2),
552            row(3, 2024, 5, 22),
553        ];
554        let groups = group_by_year(&rows);
555        let years: Vec<_> = groups.iter().map(|g| (g.year, g.rows.len())).collect();
556        assert_eq!(years, [(2026, 2), (2024, 1)]);
557        assert!(group_by_year(&[]).is_empty());
558    }
559
560    #[test]
561    fn summary_names_the_year_range_only_when_everything_is_loaded() {
562        let rows = [row(1, 2026, 10, 15), row(2, 2020, 12, 11)];
563        assert_eq!(
564            archive_summary(&rows, 2, false),
565            "2 篇文章,写于 2020 — 2026。"
566        );
567        assert_eq!(
568            archive_summary(&rows, 150, true),
569            "150 篇文章,最近一篇写于 2026.10.15。"
570        );
571        assert_eq!(
572            archive_summary(&rows[..1], 1, false),
573            "1 篇文章,写于 2026。"
574        );
575        assert_eq!(archive_summary(&[], 0, false), "还没有文章。");
576    }
577
578    #[test]
579    fn ranks_tags_by_count_and_drops_empty_ones() {
580        let ranked = ranked_tags(vec![
581            tag(1, "Go", 4),
582            tag(2, "Rust", 16),
583            tag(3, "草稿", 0),
584            tag(4, "CSS", 4),
585        ]);
586        let names: Vec<_> = ranked.iter().map(|t| t.name.as_str()).collect();
587        assert_eq!(names, ["Rust", "CSS", "Go"]);
588    }
589
590    #[test]
591    fn collapsed_tags_still_show_the_current_one() {
592        let tags: Vec<Tag> = (0..20)
593            .map(|i| tag(i, &format!("t{i}"), 100 - i64::from(i)))
594            .collect();
595        let names = |shown: Vec<&Tag>| shown.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
596        assert_eq!(visible_tags(&tags, None, false).len(), TAG_PREVIEW);
597        let shown = names(visible_tags(&tags, Some("t19"), false));
598        assert_eq!(shown.len(), TAG_PREVIEW + 1);
599        assert_eq!(shown.last().map(String::as_str), Some("t19"));
600        assert_eq!(visible_tags(&tags, Some("t3"), false).len(), TAG_PREVIEW);
601        assert_eq!(visible_tags(&tags, None, true).len(), 20);
602    }
603}