docs(pages-frontend): 补充中文注释

This commit is contained in:
xfy 2026-06-12 19:14:49 +08:00
parent 1904907add
commit abfab19839
7 changed files with 149 additions and 0 deletions

View File

@ -1,5 +1,15 @@
//! 关于页面模块。
//!
//! 对应路由 `/about`。
//!
//! 该页面为静态展示页面,不发起任何 server function 调用,
//! 直接渲染博客介绍、技术栈与主要特性。
use dioxus::prelude::*; use dioxus::prelude::*;
/// 关于页面组件,对应路由 `/about`。
///
/// 展示 Yggdrasil 博客的简介、技术栈与功能特性。
#[component] #[component]
pub fn About() -> Element { pub fn About() -> Element {
rsx! { rsx! {

View File

@ -1,3 +1,12 @@
//! 归档页面模块。
//!
//! 对应路由 `/archives`。
//!
//! 数据获取:通过 `use_server_future` 调用 `list_published_posts(1, 10000)` server function
//! 一次性拉取全部已发布文章,然后在内存中按发布日期的年、月进行分组展示。
//! 在 `wasm32` 目标下server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus::router::components::Link; use dioxus::router::components::Link;
@ -7,12 +16,14 @@ use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::models::post::Post; use crate::models::post::Post;
use crate::router::Route; use crate::router::Route;
/// 按年份分组的文章归档结构。
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
struct YearGroup { struct YearGroup {
year: String, year: String,
months: Vec<MonthGroup>, months: Vec<MonthGroup>,
} }
/// 按月份分组的文章归档结构。
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
struct MonthGroup { struct MonthGroup {
month: String, month: String,
@ -20,18 +31,23 @@ struct MonthGroup {
posts: Vec<Post>, posts: Vec<Post>,
} }
/// 将文章列表按 `formatted_date()` 返回的 `YYYY-MM-DD` 格式进行年、月分组。
///
/// 返回的结果按原始文章顺序组织,调用前已按发布时间降序排列。
fn group_posts(posts: &[Post]) -> Vec<YearGroup> { fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
let mut years: Vec<YearGroup> = vec![]; let mut years: Vec<YearGroup> = vec![];
for post in posts { for post in posts {
let date_str = post.formatted_date(); let date_str = post.formatted_date();
// 将日期字符串拆分为 [年, 月, 日] 三部分。
let parts: Vec<&str> = date_str.split('-').collect(); let parts: Vec<&str> = date_str.split('-').collect();
if parts.len() != 3 { if parts.len() != 3 {
continue; continue;
} }
let year = parts[0].to_string(); let year = parts[0].to_string();
let month_num = parts[1]; let month_num = parts[1];
// 将数字月份转换为英文月份名称,用于展示与锚点 id。
let month_en = match month_num { let month_en = match month_num {
"01" => "January", "01" => "January",
"02" => "February", "02" => "February",
@ -48,6 +64,7 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
_ => month_num, _ => month_num,
}; };
// 尝试追加到当前年份与月份的组中;如果不匹配则新建分组。
if let Some(yg) = years.last_mut() { if let Some(yg) = years.last_mut() {
if yg.year == year { if yg.year == year {
if let Some(mg) = yg.months.last_mut() { if let Some(mg) = yg.months.last_mut() {
@ -77,6 +94,9 @@ fn group_posts(posts: &[Post]) -> Vec<YearGroup> {
years years
} }
/// 归档页面组件,对应路由 `/archives`。
///
/// 渲染页面标题,并委托给 `ArchivesContent` 展示按年月分组的文章列表。
#[component] #[component]
pub fn Archives() -> Element { pub fn Archives() -> Element {
rsx! { rsx! {
@ -89,8 +109,13 @@ pub fn Archives() -> Element {
} }
} }
/// 归档页面内容组件。
///
/// 通过 `use_server_future` 获取全部已发布文章,按年月分组后渲染;
/// 加载中显示骨架屏,失败显示错误提示。
#[component] #[component]
fn ArchivesContent() -> Element { fn ArchivesContent() -> Element {
// 一次性获取足够多的已发布文章,用于生成完整的年/月归档。
let posts_res = use_server_future(move || list_published_posts(1, 10000))?; let posts_res = use_server_future(move || list_published_posts(1, 10000))?;
let posts_data = posts_res.read(); let posts_data = posts_res.read();
@ -123,6 +148,7 @@ fn ArchivesContent() -> Element {
} }
} }
/// 单一年份归档区块组件,展示该年份下的所有月份分组。
#[component] #[component]
fn YearSection(year_group: YearGroup) -> Element { fn YearSection(year_group: YearGroup) -> Element {
let total = year_group let total = year_group
@ -150,6 +176,7 @@ fn YearSection(year_group: YearGroup) -> Element {
} }
} }
/// 单一月份归档区块组件,展示该月份下的文章条目。
#[component] #[component]
fn MonthSection(month_group: MonthGroup, year: String) -> Element { fn MonthSection(month_group: MonthGroup, year: String) -> Element {
let count = month_group.posts.len(); let count = month_group.posts.len();
@ -175,6 +202,7 @@ fn MonthSection(month_group: MonthGroup, year: String) -> Element {
} }
} }
/// 单条归档文章组件,展示标题与发布日期,并通过覆盖层链接到文章详情。
#[component] #[component]
fn ArchiveEntry(post: Post) -> Element { fn ArchiveEntry(post: Post) -> Element {
let date_str = post.formatted_date(); let date_str = post.formatted_date();

View File

@ -1,3 +1,14 @@
//! 首页模块。
//!
//! 对应路由:
//! - `/`:首页,默认展示第 1 页文章。
//! - `/page/:page`:分页首页,展示指定页码的已发布文章列表。
//!
//! 数据获取:通过 `use_server_future` 调用 `list_published_posts` server function
//! 从服务端获取已发布文章的分页列表与总数,并渲染文章卡片与分页导航。
//! 在 `wasm32` 目标下server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus::router::components::Link; use dioxus::router::components::Link;
@ -7,13 +18,20 @@ use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::home_skeleton::HomeSkeleton; use crate::components::skeletons::home_skeleton::HomeSkeleton;
use crate::router::Route; use crate::router::Route;
// 每页展示的已发布文章数量,用于分页计算。
const POSTS_PER_PAGE: i32 = 10; const POSTS_PER_PAGE: i32 = 10;
/// 首页组件,对应路由 `/`。
///
/// 直接委托给 `HomePage` 并固定页码为 1。
#[component] #[component]
pub fn Home() -> Element { pub fn Home() -> Element {
rsx! { HomePage { page: 1 } } rsx! { HomePage { page: 1 } }
} }
/// 首页分页组件,对应路由 `/page/:page`。
///
/// 对传入的页码进行下限校正后,渲染头部信息与文章列表。
#[component] #[component]
pub fn HomePage(page: i32) -> Element { pub fn HomePage(page: i32) -> Element {
let current_page = page.max(1); let current_page = page.max(1);
@ -24,10 +42,16 @@ pub fn HomePage(page: i32) -> Element {
} }
} }
/// 首页文章列表与分页展示组件。
///
/// 通过 `use_server_future` 异步获取当前页文章;
/// 加载中显示骨架屏,加载失败显示错误提示,成功则渲染文章卡片与分页。
#[component] #[component]
fn HomePosts(current_page: i32) -> Element { fn HomePosts(current_page: i32) -> Element {
// 调用 server function 获取已发布文章分页数据。
let posts_res = use_server_future(move || list_published_posts(current_page, POSTS_PER_PAGE))?; let posts_res = use_server_future(move || list_published_posts(current_page, POSTS_PER_PAGE))?;
// 将结果映射为更便于本地使用的 (posts, total) 形式。
let posts_data = posts_res.read().as_ref().map(|r| match r { let posts_data = posts_res.read().as_ref().map(|r| match r {
Ok(PostListResponse { posts, total }) => Ok((posts.clone(), *total)), Ok(PostListResponse { posts, total }) => Ok((posts.clone(), *total)),
Err(e) => Err(e.to_string()), Err(e) => Err(e.to_string()),
@ -39,11 +63,13 @@ fn HomePosts(current_page: i32) -> Element {
for post in posts.iter() { for post in posts.iter() {
PostCard { post: post.clone() } PostCard { post: post.clone() }
} }
// 如果当前页没有任何文章,显示空状态提示。
if posts.is_empty() { if posts.is_empty() {
div { class: "text-center text-paper-secondary py-20", div { class: "text-center text-paper-secondary py-20",
"暂无文章" "暂无文章"
} }
} }
// 在列表底部渲染分页导航。
Pagination { current_page, total } Pagination { current_page, total }
} }
} }
@ -62,6 +88,7 @@ fn HomePosts(current_page: i32) -> Element {
} }
} }
/// 首页头部信息组件,展示站点名称与副标题。
#[component] #[component]
fn HomeInfo() -> Element { fn HomeInfo() -> Element {
rsx! { rsx! {
@ -76,12 +103,18 @@ fn HomeInfo() -> Element {
} }
} }
/// 分页导航组件。
///
/// 根据当前页码与文章总数计算总页数,并渲染上一页/下一页链接。
/// 第一页的上一页链接固定指向 `Route::Home`,避免生成 `/page/1`。
#[component] #[component]
fn Pagination(current_page: i32, total: i64) -> Element { fn Pagination(current_page: i32, total: i64) -> Element {
let has_prev = current_page > 1; let has_prev = current_page > 1;
// 向上取整计算总页数,至少为 1 页。
let total_pages = ((total + POSTS_PER_PAGE as i64 - 1) / POSTS_PER_PAGE as i64).max(1) as i32; let total_pages = ((total + POSTS_PER_PAGE as i64 - 1) / POSTS_PER_PAGE as i64).max(1) as i32;
let has_next = current_page < total_pages; let has_next = current_page < total_pages;
let prev = current_page - 1; let prev = current_page - 1;
// 当上一页为第 1 页时,使用 `/` 路由而非 `/page/1`。
let prev_route = if prev <= 1 { let prev_route = if prev <= 1 {
Route::Home {} Route::Home {}
} else { } else {

View File

@ -1,8 +1,18 @@
//! 404 页面模块。
//!
//! 对应路由 `/:..segments`。
//!
//! 当用户访问未匹配任何前端路由的 URL 时Dioxus Router 会回退到该 404 页面。
//! 该页面为静态展示页面,不发起任何 server function 调用。
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus::router::components::Link; use dioxus::router::components::Link;
use crate::router::Route; use crate::router::Route;
/// 404 页面组件,对应兜底路由 `/:..segments`。
///
/// 展示大号的装饰性 404 数字、状态标签、错误说明以及返回首页的链接。
#[component] #[component]
pub fn NotFound(segments: Vec<String>) -> Element { pub fn NotFound(segments: Vec<String>) -> Element {
let _ = segments; let _ = segments;

View File

@ -1,3 +1,14 @@
//! 文章详情页面模块。
//!
//! 对应路由 `/post/:slug`。
//!
//! 数据获取:通过 `use_server_future` 调用 `get_post_by_slug` server function
//! 根据 URL 中的 slug 获取单篇文章详情(含正文 HTML、目录、封面及上下篇导航
//! 由于 Dioxus 组件参数在路由切换时可能复用同一组件实例,
//! 这里使用 `use_signal` 保存当前 slug并在参数变化时更新信号以触发重新取数。
//! 在 `wasm32` 目标下server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus::router::components::Link; use dioxus::router::components::Link;
@ -11,18 +22,25 @@ use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton; use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton;
use crate::router::Route; use crate::router::Route;
/// 文章详情页面组件,对应路由 `/post/:slug`。
///
/// 根据 slug 异步获取文章,渲染文章头部、封面、目录、正文、页脚及评论区;
/// 若文章不存在或加载失败,则展示对应的提示页面。
#[component] #[component]
pub fn PostDetail(slug: String) -> Element { pub fn PostDetail(slug: String) -> Element {
// 使用信号保存当前 slug以便在路由参数变化时重新触发 server future。
let mut slug_signal = use_signal(|| slug.clone()); let mut slug_signal = use_signal(|| slug.clone());
if slug_signal() != slug { if slug_signal() != slug {
slug_signal.set(slug.clone()); slug_signal.set(slug.clone());
} }
// 当 slug 信号变化时,自动重新调用 server function 获取文章详情。
let post = use_server_future(move || { let post = use_server_future(move || {
let s = slug_signal(); let s = slug_signal();
get_post_by_slug(s) get_post_by_slug(s)
})?; })?;
// 将结果映射为更直观的 Ok(post) / Err("not_found") / Err("error") 三种状态。
let post_data = post.read().as_ref().map(|r| match r { let post_data = post.read().as_ref().map(|r| match r {
Ok(SinglePostResponse { post: Some(post) }) => Ok(post.clone()), Ok(SinglePostResponse { post: Some(post) }) => Ok(post.clone()),
Ok(SinglePostResponse { post: None }) => Err("not_found"), Ok(SinglePostResponse { post: None }) => Err("not_found"),
@ -35,10 +53,12 @@ pub fn PostDetail(slug: String) -> Element {
article { class: "post-single", article { class: "post-single",
PostHeader { post: post.clone() } PostHeader { post: post.clone() }
// 如果文章设置了封面图,则渲染封面组件。
if let Some(cover) = &post.cover_image { if let Some(cover) = &post.cover_image {
PostCover { src: cover.clone() } PostCover { src: cover.clone() }
} }
// 如果文章生成了目录 HTML则渲染目录组件。
if let Some(toc) = &post.toc_html { if let Some(toc) = &post.toc_html {
PostToc { toc_html: toc.clone() } PostToc { toc_html: toc.clone() }
} }
@ -49,6 +69,7 @@ pub fn PostDetail(slug: String) -> Element {
PostFooter { post: post.clone() } PostFooter { post: post.clone() }
// 仅对已发布文章展示评论区域,使用 SuspenseBoundary 处理加载状态。
if post.status == crate::models::post::PostStatus::Published { if post.status == crate::models::post::PostStatus::Published {
div { class: "mt-12 border-t border-gray-200 dark:border-[#333] pt-8", div { class: "mt-12 border-t border-gray-200 dark:border-[#333] pt-8",
SuspenseBoundary { SuspenseBoundary {

View File

@ -1,3 +1,13 @@
//! 搜索页面模块。
//!
//! 对应路由 `/search`。
//!
//! 数据获取:用户在输入框中键入关键词并触发搜索后,
//! 通过 Dioxus 的 `spawn` 在本地启动异步任务,调用 `search_posts` server function。
//! 与首页/归档不同,搜索是交互式客户端行为,不在服务端渲染阶段预取数据。
//! 在 `wasm32` 目标下,该 server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::api::posts::{search_posts, PostListResponse}; use crate::api::posts::{search_posts, PostListResponse};
@ -5,11 +15,19 @@ use crate::components::post_card::PostCard;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::search_skeleton::SearchSkeleton; use crate::components::skeletons::search_skeleton::SearchSkeleton;
/// 搜索页面组件,对应路由 `/search`。
///
/// 维护搜索关键词、搜索结果与加载状态,渲染搜索框与结果列表。
/// 结果通过客户端异步请求获取,而非 `use_server_future` 预取。
#[component] #[component]
pub fn Search() -> Element { pub fn Search() -> Element {
// 当前输入框中的搜索关键词。
let mut query = use_signal(|| "".to_string()); let mut query = use_signal(|| "".to_string());
// 搜索结果None 表示尚未执行搜索或已清空。
let mut search_res = use_signal(|| None::<Result<PostListResponse, ServerFnError>>); let mut search_res = use_signal(|| None::<Result<PostListResponse, ServerFnError>>);
// 是否正在发起搜索请求。
let mut is_searching = use_signal(|| false); let mut is_searching = use_signal(|| false);
// 触发搜索的回调:校验空查询后启动异步请求。
let mut on_search = move || { let mut on_search = move || {
let q = query().trim().to_string(); let q = query().trim().to_string();
if q.is_empty() { if q.is_empty() {
@ -47,6 +65,7 @@ pub fn Search() -> Element {
} }
} }
} }
// 根据搜索状态展示骨架屏、结果列表、空状态或错误提示。
if is_searching() { if is_searching() {
DelayedSkeleton { SearchSkeleton {} } DelayedSkeleton { SearchSkeleton {} }
} else if let Some(Ok(PostListResponse { posts, total: _ })) = search_res() { } else if let Some(Ok(PostListResponse { posts, total: _ })) = search_res() {

View File

@ -1,3 +1,15 @@
//! 标签页面模块。
//!
//! 对应路由:
//! - `/tags`:标签云,展示所有标签及关联文章数量。
//! - `/tags/:tag`:标签详情页,展示指定标签下的已发布文章列表。
//!
//! 数据获取:
//! - 标签云通过 `use_server_future(list_tags)` 获取全部标签信息。
//! - 标签详情通过 `use_server_future` 调用 `get_posts_by_tag(tag)` 获取该标签下的文章列表。
//! 在 `wasm32` 目标下,这些 server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus::router::components::Link; use dioxus::router::components::Link;
@ -7,6 +19,9 @@ use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::tags_skeleton::{TagDetailSkeleton, TagsSkeleton}; use crate::components::skeletons::tags_skeleton::{TagDetailSkeleton, TagsSkeleton};
use crate::router::Route; use crate::router::Route;
/// 标签云页面组件,对应路由 `/tags`。
///
/// 渲染页面标题,并委托给 `TagsContent` 展示所有标签。
#[component] #[component]
pub fn Tags() -> Element { pub fn Tags() -> Element {
rsx! { rsx! {
@ -19,10 +34,15 @@ pub fn Tags() -> Element {
} }
} }
/// 标签云内容组件。
///
/// 通过 `use_server_future(list_tags)` 异步获取标签列表;
/// 成功时渲染标签总数、文章总数以及每个标签的链接。
#[component] #[component]
fn TagsContent() -> Element { fn TagsContent() -> Element {
let tags_res = use_server_future(list_tags)?; let tags_res = use_server_future(list_tags)?;
// 将结果映射为仅包含标签列表的形式。
let tags_data = tags_res.read().as_ref().map(|r| match r { let tags_data = tags_res.read().as_ref().map(|r| match r {
Ok(TagListResponse { tags }) => Ok(tags.clone()), Ok(TagListResponse { tags }) => Ok(tags.clone()),
Err(e) => Err(e.to_string()), Err(e) => Err(e.to_string()),
@ -68,6 +88,9 @@ fn TagsContent() -> Element {
} }
} }
/// 标签详情页面组件,对应路由 `/tags/:tag`。
///
/// 渲染当前标签名称,并委托给 `TagDetailContent` 展示该标签下的文章列表。
#[component] #[component]
pub fn TagDetail(tag: String) -> Element { pub fn TagDetail(tag: String) -> Element {
rsx! { rsx! {
@ -80,10 +103,15 @@ pub fn TagDetail(tag: String) -> Element {
} }
} }
/// 标签详情内容组件。
///
/// 通过 `use_server_future` 调用 `get_posts_by_tag` 获取指定标签下的文章;
/// 成功时渲染文章总数与文章卡片。
#[component] #[component]
fn TagDetailContent(tag: String) -> Element { fn TagDetailContent(tag: String) -> Element {
let posts_res = use_server_future(move || get_posts_by_tag(tag.clone()))?; let posts_res = use_server_future(move || get_posts_by_tag(tag.clone()))?;
// 将结果映射为 (posts, total) 形式以便渲染。
let posts_data = posts_res.read().as_ref().map(|r| match r { let posts_data = posts_res.read().as_ref().map(|r| match r {
Ok(PostListResponse { posts, total }) => Ok((posts.clone(), *total)), Ok(PostListResponse { posts, total }) => Ok((posts.clone(), *total)),
Err(e) => Err(e.to_string()), Err(e) => Err(e.to_string()),