Skip to main content

yggdrasil/pages/
tags.rs

1//! 标签页面模块。
2//!
3//! 对应路由:
4//! - `/tags`:标签云,展示所有标签及关联文章数量。
5//! - `/tags/:tag`:标签详情页,展示指定标签下的已发布文章列表。
6//!
7//! 数据获取:
8//! - 标签云通过 `use_server_future(list_tags)` 获取全部标签信息。
9//! - 标签详情通过 `use_server_future` 调用 `get_posts_by_tag(tag)`
10//!   获取该标签下的全部已发布文章(不分页)。
11//!   在 `wasm32` 目标下,这些 server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
12//!   实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
13
14use dioxus::prelude::*;
15use dioxus::router::components::Link;
16
17use crate::api::posts::{get_posts_by_tag, list_tags, PostListResponse, TagListResponse};
18use crate::components::empty_state::EmptyState;
19use crate::components::post_card::PostCard;
20use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
21use crate::components::skeletons::tags_skeleton::{TagDetailSkeleton, TagsSkeleton};
22use crate::router::Route;
23
24/// 标签云页面组件,对应路由 `/tags`。
25///
26/// 渲染页面标题,并委托给 `TagsContent` 展示所有标签。
27#[component]
28pub fn Tags() -> Element {
29    rsx! {
30        div { class: "animate-page-enter",
31            header { class: "page-header mb-6",
32                h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
33                    "标签"
34                }
35            }
36            TagsContent {}
37        }
38    }
39}
40
41/// 标签云内容组件。
42///
43/// 通过 `use_server_future(list_tags)` 异步获取标签列表;
44/// 成功时渲染标签总数、文章总数以及每个标签的链接。
45#[component]
46fn TagsContent() -> Element {
47    let tags_res = use_server_future(list_tags)?;
48
49    // 将结果映射为仅包含标签列表的形式。
50    let tags_data = tags_res.read().as_ref().map(|r| match r {
51        Ok(TagListResponse { tags }) => Ok(tags.clone()),
52        Err(e) => Err(e.to_string()),
53    });
54
55    match tags_data {
56        Some(Ok(tags)) => {
57            if tags.is_empty() {
58                rsx! {
59                    EmptyState {
60                        title: "暂无标签",
61                        description: "当前还没有任何标签。",
62                    }
63                }
64            } else {
65                let total = tags.iter().map(|t| t.post_count).sum::<i64>();
66                rsx! {
67                    div { class: "mt-2 text-base text-paper-secondary",
68                        "共 "
69                        span { class: "font-medium text-paper-primary", "{tags.len()}" }
70                        " 个标签,"
71                        span { class: "font-medium text-paper-primary", "{total}" }
72                        " 篇文章"
73                    }
74                    ul { class: "flex flex-wrap gap-4 mt-6",
75                        for tag in tags {
76                            li { key: "{tag.name}",
77                                Link {
78                                    class: "inline-flex items-center px-3 py-1.5 text-base font-medium bg-paper-accent-soft text-paper-accent rounded-lg hover:bg-paper-accent hover:text-white transition-all duration-200",
79                                    to: Route::TagDetail {
80                                        tag: tag.name.clone(),
81                                    },
82                                    "{tag.name}"
83                                    sup { class: "ml-1 text-sm text-paper-secondary",
84                                        "{tag.post_count}"
85                                    }
86                                }
87                            }
88                        }
89                    }
90                }
91            }
92        }
93        Some(Err(_)) => {
94            rsx! {
95                div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" }
96            }
97        }
98        _ => {
99            rsx! {
100                DelayedSkeleton { TagsSkeleton {} }
101            }
102        }
103    }
104}
105
106/// 标签详情页面组件,对应路由 `/tags/:tag`。
107///
108/// 渲染当前标签名称,并委托给 `TagDetailContent` 展示该标签下的文章列表。
109#[component]
110pub fn TagDetail(tag: String) -> Element {
111    rsx! {
112        div { class: "animate-page-enter",
113            header { class: "page-header mb-6",
114                h1 { class: "text-4xl font-bold text-paper-primary tracking-tight",
115                    "{tag}"
116                }
117            }
118            TagDetailContent { tag: tag.clone() }
119        }
120    }
121}
122
123/// 标签详情内容组件。
124///
125/// 通过 `use_server_future` 调用 `get_posts_by_tag` 获取指定标签下的文章;
126/// 成功时渲染文章总数与文章卡片。
127///
128/// # 反应式取数
129/// 同 `post_detail.rs` / `home.rs`:`tag` 是普通 `String` prop,被 `move` 进
130/// `use_server_future` 闭包后成为冻结快照,不建立反应式订阅。从标签云点击
131/// 切换标签(`/tags/a → /tags/b`,同为 `Route::TagDetail` 变体,复用组件实例)
132/// 时 future 不会重跑。修复:在闭包内通过 `router().current::<Route>()` 读取
133/// 当前 tag 建立订阅,路由变化即重跑。
134#[component]
135fn TagDetailContent(tag: String) -> Element {
136    let router = dioxus::router::router();
137
138    let posts_res = use_server_future(move || {
139        let current_tag = match router.current::<Route>() {
140            Route::TagDetail { tag } => tag,
141            _ => tag.clone(),
142        };
143        get_posts_by_tag(current_tag)
144    })?;
145
146    // 将结果映射为 (posts, total) 形式以便渲染。
147    let posts_data = posts_res.read().as_ref().map(|r| match r {
148        Ok(PostListResponse { posts, total }) => Ok((posts.clone(), *total)),
149        Err(e) => Err(e.to_string()),
150    });
151
152    match posts_data {
153        Some(Ok((posts, total))) => {
154            if posts.is_empty() {
155                rsx! {
156                    EmptyState {
157                        title: "暂无文章",
158                        description: "该标签下还没有发布任何文章。",
159                    }
160                }
161            } else {
162                rsx! {
163                    div { class: "mt-2 mb-8 text-base text-paper-secondary",
164                        "共 "
165                        span { class: "font-medium text-paper-primary", "{total}" }
166                        " 篇文章"
167                    }
168                    div { class: "mt-8",
169                        for post in posts.iter() {
170                            PostCard { key: "{post.id}", post: post.clone() }
171                        }
172                    }
173                }
174            }
175        }
176        Some(Err(_)) => {
177            rsx! {
178                div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" }
179            }
180        }
181        _ => {
182            rsx! {
183                DelayedSkeleton { TagDetailSkeleton {} }
184            }
185        }
186    }
187}