P0 blockers: - Fix migration numbering conflict and duplicate indexes - Change comments.post_id FK to ON DELETE CASCADE - Restrict public post detail endpoint to published posts only - Fix rate-limiting IP extraction and fallback to ConnectInfo - Harden HTML sanitizer: deny unknown URL schemes, restrict data URIs - Remove session token from login response body - Enforce image pixel/dimension limits on upload and serving P1 high-risk: - Validate uploads by magic bytes and decode GIF/WebP - Add pagination/rate-limiting to search, tag posts, and comments - Make first-admin registration and slug uniqueness check atomic - HTML-escape comment author fields - Improve HTML minify cache key and skip admin/error responses - Add mobile navigation menu P2 accessibility/quality: - Associate form labels with inputs - Key PostDetail article by slug to re-init scripts on navigation - Improve image viewer keyboard accessibility - Make theme toggle SSR-friendly and add aria-label - Invalidate slug 404 cache on create and pending count on new comment - Deduplicate tags case-insensitively P3 cleanup: - Remove unused tower-http dependency, expand make clean - Configure DB pool timeouts and verified recycling - Run background cleanup tasks immediately on startup - Use SHA-256 for stable disk cache keys - Log DB errors with Display instead of Debug - Update README migration instructions All tests pass (321), clippy clean, dx check clean.
117 lines
4.8 KiB
Rust
117 lines
4.8 KiB
Rust
//! 文章详情页面模块。
|
||
//!
|
||
//! 对应路由 `/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::router::components::Link;
|
||
|
||
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 {
|
||
// 使用信号保存当前 slug,以便在路由参数变化时重新触发 server future。
|
||
let mut slug_signal = use_signal(|| slug.clone());
|
||
if slug_signal() != slug {
|
||
slug_signal.set(slug.clone());
|
||
}
|
||
|
||
// 当 slug 信号变化时,自动重新调用 server function 获取文章详情。
|
||
let post = use_server_future(move || {
|
||
let s = slug_signal();
|
||
get_post_by_slug(s)
|
||
})?;
|
||
|
||
// 将结果映射为更直观的 Ok(post) / Err("not_found") / Err("error") 三种状态。
|
||
let post_data = post.read().as_ref().map(|r| match r {
|
||
Ok(SinglePostResponse { post: Some(post) }) => Ok(post.clone()),
|
||
Ok(SinglePostResponse { post: None }) => Err("not_found"),
|
||
Err(_) => Err("error"),
|
||
});
|
||
|
||
match post_data {
|
||
Some(Ok(post)) => {
|
||
rsx! {
|
||
article { class: "post-single", key: "{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() }
|
||
}
|
||
|
||
PostContent {
|
||
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-[#333] pt-8",
|
||
SuspenseBoundary {
|
||
fallback: move |_| rsx! {
|
||
crate::components::skeletons::comment_skeleton::CommentListSkeleton {}
|
||
},
|
||
crate::components::comments::section::CommentSection { post_id: post.id }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Some(Err("not_found")) => {
|
||
rsx! {
|
||
div { class: "text-center py-20",
|
||
h2 { class: "text-2xl font-bold text-paper-primary mb-4",
|
||
"文章不存在"
|
||
}
|
||
p { class: "text-paper-secondary mb-6",
|
||
"这篇文章可能已被删除或移动。"
|
||
}
|
||
Link {
|
||
class: "px-6 py-2 bg-paper-primary text-paper-theme rounded-full font-medium hover:opacity-80 transition-opacity",
|
||
to: Route::Home {},
|
||
"返回首页"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Some(Err("error")) => {
|
||
rsx! {
|
||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||
"加载失败"
|
||
}
|
||
}
|
||
}
|
||
_ => {
|
||
rsx! {
|
||
DelayedSkeleton { PostDetailSkeleton {} }
|
||
}
|
||
}
|
||
}
|
||
}
|