yggdrasil/src/pages/post_detail.rs
xfy 886eeca915
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
style: 全项目 rsx! 格式规范化(cargo fmt)
2026-07-15 17:49:03 +08:00

133 lines
6.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 文章详情页面模块。
//!
//! 对应路由 `/post/:slug`。
//!
//! 数据获取:通过 `use_server_future` 调用 `get_post_by_slug` server function
//! 根据 URL 中的 slug 获取单篇文章详情(含正文 HTML、目录、封面及上下篇导航
//!
//! # 反应式取数的关键
//! Dioxus 0.7 的 `use_server_future`(内部即 `use_resource`)只在闭包内读取的
//! **signal** 变化时才会重跑 future——它通过 `ReactiveContext` 追踪闭包执行期间的
//! 订阅。但本组件的 `slug` 是路由宏注入的普通 `String` prop被 `move` 进闭包后
//! 成了冻结快照,读取它不会建立订阅。因此上/下一篇导航(同一路由变体间的 slug
//! 变化)会复用组件实例、更新 props却无法触发 future 重跑——表现为「URL 变了
//! 但内容不变,刷新才生效」。
//!
//! 修复:在闭包内通过 `router().current::<Route>()` 读取当前 slug。`current()`
//! 内部调用 `subscribe_to_current_context()`,在 `use_server_future` 的
//! ReactiveContext 中注册订阅路由变化时订阅触发future 自动重跑。
//! 在 `wasm32` 目标下server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*;
use crate::api::posts::{get_post_by_slug, SinglePostResponse};
use crate::components::post::post_content::PostContent;
use crate::components::post::post_cover::PostCover;
use crate::components::post::post_footer::PostFooter;
use crate::components::post::post_header::PostHeader;
use crate::components::post::post_toc::PostToc;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton;
use crate::router::Route;
/// 文章详情页面组件,对应路由 `/post/:slug`。
///
/// 根据 slug 异步获取文章,渲染文章头部、封面、目录、正文、页脚及评论区;
/// 若文章不存在或加载失败,则展示对应的提示页面。
#[component]
pub fn PostDetail(slug: String) -> Element {
// 取得路由上下文句柄(不订阅组件层渲染,仅在闭包内按需订阅)。
// 见模块文档必须在闭包内读取路由状态才能建立反应式订阅future 才会在
// slug 变化(上/下一篇导航)时重跑。`slug` prop 本身是冻结的 String 快照,
// 不能作为依赖。
let router = dioxus::router::router();
let post = use_server_future(move || {
// 在闭包内读取当前 slugcurrent() 内部会 subscribe_to_current_context()
// 把订阅注册到 use_server_future 的 ReactiveContext路由变化即重跑。
let current_slug = match router.current::<Route>() {
Route::PostDetail { slug } => slug,
// 组件卸载/路由切走的瞬间可能命中其它变体,退回用 prop 值兜底。
_ => slug.clone(),
};
get_post_by_slug(current_slug)
})?;
// 将结果映射为 Ok(post) 或 Err(ServerFnError) 用于错误边界捕获
let post_data = post.read().as_ref().map(|r| match r {
Ok(SinglePostResponse { post: Some(post) }) => Ok(post.clone()),
Ok(SinglePostResponse { post: None }) => Err(ServerFnError::ServerError {
message: "not_found".to_string(),
code: 404,
details: None,
}),
Err(e) => Err(e.clone()),
});
let post = match post_data {
Some(Ok(post)) => post,
Some(Err(err)) => {
// Bubble the error up to the ErrorBoundary
return Err(err.into());
}
None => {
return rsx! {
DelayedSkeleton { PostDetailSkeleton {} }
};
}
};
rsx! {
article { class: "post-single animate-page-enter", key: "{post.slug}",
PostHeader { post: post.clone() }
// 如果文章设置了封面图,则渲染封面组件。
if let Some(cover) = &post.cover_image {
PostCover { src: cover.clone() }
}
// 如果文章生成了目录 HTML则渲染目录组件。
if let Some(toc) = &post.toc_html {
PostToc { toc_html: toc.clone() }
}
// 用单元素 keyed 列表包裹 PostContentkey 绑定 slug。
//
// 为什么不能直接给 PostContent 加 keyDioxus 的 key diffdiff_keyed_children
// 只在「兄弟节点列表」里生效,对单个非列表元素的 key 变化会走 diff_non_keyed
// 路径、按位置复用,不触发 remount。把组件放进单元素 for 循环并带 key
// 才会进入 keyed diff——slug 变化时旧 PostContent 被移除、新的被创建。
//
// 为什么需要 remount上下篇切换时 PostContent 若被复用(仅重渲染),
// (1) 内部 CodeRunner 用片段索引作 key两篇文章索引可能相同如 1/3/5
// keyed diff 按相同 key 复用 CodeRunner 实例——其挂载 use_effect 的
// 「防重复 init」守卫阻止 CodeMirror 挂载到新的已替换的DOM 容器,
// 表现为翻页后代码块消失;
// (2) PostContent 自身的 use_effect__initPostContent 复制按钮 / 灯箱初始化)
// 也不会重跑,新文章的交互脚本不初始化。
// 强制 remount 让编辑器与脚本随文章切换全部重新初始化。
for post_slug in std::iter::once(post.slug.clone()) {
PostContent {
key: "{post_slug}",
content_html: post.content_html.clone().unwrap_or_default(),
}
}
PostFooter { post: post.clone() }
// 仅对已发布文章展示评论区域,使用 SuspenseBoundary 处理加载状态。
if post.status == crate::models::post::PostStatus::Published {
div { class: "mt-12 border-t border-gray-200 dark:border-gray-700 pt-8",
SuspenseBoundary {
fallback: move |_| rsx! {
DelayedSkeleton { crate::components::skeletons::comment_skeleton::CommentListSkeleton {} }
},
crate::components::comments::section::CommentSection { post_id: post.id }
}
}
}
}
}
}