Skip to main content

yggdrasil/pages/
post_detail.rs

1//! 文章详情页面模块。
2//!
3//! 对应路由 `/post/:slug`。
4//!
5//! 数据获取:通过 `use_server_future` 调用 `get_post_by_slug` server function,
6//! 根据 URL 中的 slug 获取单篇文章详情(含正文 HTML、目录、封面及上下篇导航)。
7//!
8//! # 反应式取数的关键
9//! Dioxus 0.7 的 `use_server_future`(内部即 `use_resource`)只在闭包内读取的
10//! **signal** 变化时才会重跑 future——它通过 `ReactiveContext` 追踪闭包执行期间的
11//! 订阅。但本组件的 `slug` 是路由宏注入的普通 `String` prop,被 `move` 进闭包后
12//! 成了冻结快照,读取它不会建立订阅。因此上/下一篇导航(同一路由变体间的 slug
13//! 变化)会复用组件实例、更新 props,却无法触发 future 重跑——表现为「URL 变了
14//! 但内容不变,刷新才生效」。
15//!
16//! 使用 memo 读取 `router().current::<Route>()` 中的 slug,future 仅订阅 memo。
17//! Router 在 VT 提交前也会通知订阅者;memo 的相等比较过滤仍指向旧 slug 的通知,
18//! 避免在旧快照捕获前重新挂起文章。实际 slug 变化仍会自动重跑 future。
19//! 在 `wasm32` 目标下,server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
20//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
21
22use dioxus::prelude::*;
23
24use crate::api::posts::{get_post_by_slug, SinglePostResponse};
25use crate::components::post::post_content::PostContent;
26use crate::components::post::post_cover::PostCover;
27use crate::components::post::post_footer::PostFooter;
28use crate::components::post::post_header::PostHeader;
29use crate::components::post::post_toc::PostToc;
30use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
31use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton;
32use crate::router::Route;
33
34/// 文章详情页面组件,对应路由 `/post/:slug`。
35///
36/// 根据 slug 异步获取文章,渲染文章头部、封面、目录、正文、页脚及评论区;
37/// 若文章不存在或加载失败,则展示对应的提示页面。
38#[component]
39pub fn PostDetail(slug: String) -> Element {
40    let router = dioxus::router::router();
41    let render_slug = slug.clone();
42    let current_slug = use_memo(move || match router.current::<Route>() {
43        Route::PostDetail { slug } => slug,
44        // 组件卸载/路由切走的瞬间可能命中其它变体,退回用 prop 值兜底。
45        _ => slug.clone(),
46    });
47    let post = use_server_future(move || get_post_by_slug(current_slug()))?;
48
49    // 将结果映射为 Ok(post) 或 Err(ServerFnError) 用于错误边界捕获
50    let post_data = post.read().as_ref().map(|r| match r {
51        Ok(SinglePostResponse { post: Some(post) }) => Ok(post.clone()),
52        Ok(SinglePostResponse { post: None }) => Err(ServerFnError::ServerError {
53            message: "not_found".to_string(),
54            code: 404,
55            details: None,
56        }),
57        Err(e) => Err(e.clone()),
58    });
59
60    let post = match post_data {
61        Some(Ok(post)) if post.slug == render_slug => post,
62        // The resource retains its last value while the next slug is loading.
63        Some(Ok(_)) => {
64            return rsx! { DelayedSkeleton { PostDetailSkeleton {} } };
65        }
66        Some(Err(err)) => {
67            // Bubble the error up to the ErrorBoundary
68            return Err(err.into());
69        }
70        None => {
71            return rsx! {
72                DelayedSkeleton { PostDetailSkeleton {} }
73            };
74        }
75    };
76
77    rsx! {
78        article { class: "post-single max-w-none animate-page-enter", key: "{post.slug}", "data-vt-detail": "{post.id}",
79            PostHeader { post: post.clone() }
80
81            // 如果文章设置了封面图,则渲染封面组件。
82            if let Some(cover) = &post.cover_image {
83                PostCover { src: cover.clone(), post_id: post.id }
84            }
85
86            // 与 PostContent 同样按 slug 强制 remount,重新绑定新文章标题的 scroll-spy。
87            // 外层非列表 article 的 key 不会触发 remount(见下方 keyed diff 说明)。
88            if let Some(toc) = &post.toc_html {
89                for toc_slug in std::iter::once(post.slug.clone()) {
90                    PostToc { key: "{toc_slug}", toc_html: toc.clone() }
91                }
92            }
93
94            // 用单元素 keyed 列表包裹 PostContent,key 绑定 slug。
95            //
96            // 为什么不能直接给 PostContent 加 key:Dioxus 的 key diff(diff_keyed_children)
97            // 只在「兄弟节点列表」里生效,对单个非列表元素的 key 变化会走 diff_non_keyed
98            // 路径、按位置复用,不触发 remount。把组件放进单元素 for 循环并带 key,
99            // 才会进入 keyed diff——slug 变化时旧 PostContent 被移除、新的被创建。
100            //
101            // 为什么需要 remount:上下篇切换时 PostContent 若被复用(仅重渲染),
102            // (1) 内部 CodeRunner 用片段索引作 key,两篇文章索引可能相同(如 1/3/5),
103            //     keyed diff 按相同 key 复用 CodeRunner 实例——其挂载 use_effect 的
104            //     「防重复 init」守卫阻止 CodeMirror 挂载到新的(已替换的)DOM 容器,
105            //     表现为翻页后代码块消失;
106            // (2) PostContent 自身的 use_effect(__initPostContent 复制按钮 / 灯箱初始化)
107            //     也不会重跑,新文章的交互脚本不初始化。
108            // 强制 remount 让编辑器与脚本随文章切换全部重新初始化。
109            for post_slug in std::iter::once(post.slug.clone()) {
110                PostContent {
111                    key: "{post_slug}",
112                    content_html: post.content_html.clone().unwrap_or_default(),
113                }
114            }
115
116            PostFooter { post: post.clone() }
117
118            // 仅对已发布文章展示评论区域,使用 SuspenseBoundary 处理加载状态。
119            if post.status == crate::models::post::PostStatus::Published {
120                div { class: "mt-12 border-t border-[var(--color-paper-border)]/60 pt-8",
121                    // 用单元素 keyed 列表包裹 CommentSection,key 绑定 post.id,强制
122                    // 上/下一篇切换时 remount(与上方 PostContent 同理,见其注释)。
123                    // 否则 PostDetail 组件实例被复用、CommentSection 也被复用,其
124                    // use_resource 闭包捕获的 post_id 是冻结快照、refresh_trigger 不随
125                    // 导航变化,资源不重启、仍请求旧文章评论——B 文章评论区显示 A 文章
126                    // 评论(issue #10)。remount 让 CommentContext 重置、资源以新
127                    // post_id 重新拉取。
128                    for comment_key in std::iter::once(post.id) {
129                        SuspenseBoundary {
130                            key: "{comment_key}",
131                            fallback: move |_| rsx! {
132                                DelayedSkeleton { crate::components::skeletons::comment_skeleton::CommentListSkeleton {} }
133                            },
134                            crate::components::comments::section::CommentSection { post_id: post.id }
135                        }
136                    }
137                }
138            }
139        }
140    }
141}