Implements a fully self-built comment system for the blog: Data layer: - comments table with BIGSERIAL PK, parent_id self-reference (ON DELETE SET NULL), depth tracking (max 20), status workflow (pending/approved/spam/trash), content hashing for dedup, GDPR consent tracking, IP/UA storage with auto-purge - 5 partial indexes optimized for read patterns - updated_at auto-trigger API (9 Dioxus server functions): - Public: get_comments, get_comment_count, create_comment - Admin: get_pending_comments, get_pending_count, get_all_comments, approve_comment (with ancestor auto-approval), spam_comment, trash_comment, batch_update_comment_status Security: - Function-level rate limiting (1/sec, burst 5) via FullstackContext IP extraction - Input validation (name, email, URL scheme, content length, consent) - Parent chain validation (must be approved, same post) - Strict comment Markdown renderer (headings→strong, no img/id/data URIs, nofollow links) - Honeypot anti-spam field - 5-minute dedup window via SHA-256 content hash Frontend: - CommentSection with SuspenseBoundary isolation - Flat-list rendering with depth-based CSS indentation (responsive) - Gravatar via cravatar.cn (server-computed, email never exposed) - Inline reply forms (one-at-a-time via Signal) - Admin action buttons (approve/spam/delete) visible per-comment - CommentForm with privacy consent, Markdown hint, loading states Admin: - /admin/comments page with status tabs, batch operations, pagination - Pending count badge on admin dashboard Infrastructure: - Shared get_current_admin_user moved from posts/helpers to auth module - COMMENT_LIMITER rate limiter tier - Moka caches (60s TTL for comments, 10s for pending count) - IP/UA purge background task (daily, 90-day retention)
96 lines
3.4 KiB
Rust
96 lines
3.4 KiB
Rust
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;
|
|
|
|
#[component]
|
|
pub fn PostDetail(slug: String) -> Element {
|
|
let mut slug_signal = use_signal(|| slug.clone());
|
|
if slug_signal() != slug {
|
|
slug_signal.set(slug.clone());
|
|
}
|
|
|
|
let post = use_server_future(move || {
|
|
let s = slug_signal();
|
|
get_post_by_slug(s)
|
|
})?;
|
|
|
|
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",
|
|
PostHeader { post: post.clone() }
|
|
|
|
if let Some(cover) = &post.cover_image {
|
|
PostCover { src: cover.clone() }
|
|
}
|
|
|
|
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() }
|
|
|
|
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 {} }
|
|
}
|
|
}
|
|
}
|
|
}
|