diff --git a/src/hooks/comment_storage.rs b/src/hooks/comment_storage.rs index dd59dbb..aaa2eb0 100644 --- a/src/hooks/comment_storage.rs +++ b/src/hooks/comment_storage.rs @@ -1,33 +1,61 @@ +//! 评论草稿在浏览器 localStorage 中的持久化支持。 +//! +//! 注意:所有 localStorage 读写均通过 `#[cfg(target_arch = "wasm32")]` 限定, +//! 仅在 WASM 前端生效;服务端渲染(SSR)路径下这些函数会返回 None 或空操作。 + use chrono::DateTime; use serde::{Deserialize, Serialize}; +/// localStorage 中用于存储评论作者信息的键名。 const AUTHOR_KEY: &str = "yggdrasil-comment-author"; + +/// localStorage 中用于存储待发布评论草稿的键名。 const PENDING_KEY: &str = "yggdrasil-pending-comments"; + +/// 待发布评论草稿的过期时间(天)。 const TTL_DAYS: i64 = 7; +/// 评论作者信息。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthorInfo { + /// 昵称。 pub name: String, + /// 邮箱地址。 pub email: String, + /// 个人主页 URL。 #[serde(default)] pub url: String, } +/// 待发布评论草稿。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PendingComment { + /// 评论 ID。 pub id: i64, + /// 父评论 ID,顶级评论为 None。 pub parent_id: Option, + /// 评论层级深度。 pub depth: i32, + /// 作者昵称。 pub author_name: String, + /// 作者主页 URL。 pub author_url: Option, + /// 头像 URL。 pub avatar_url: String, + /// Markdown 格式的评论内容。 pub content_md: String, + /// 评论创建时间(RFC3339 字符串)。 pub created_at: String, + /// 草稿存入 localStorage 的时间(RFC3339 字符串)。 pub stored_at: String, } +/// 按文章 ID 组织的待发布评论草稿映射。 type PendingMap = std::collections::HashMap>; +/// 从 localStorage 读取指定键的值。 +/// +/// 仅在 `wasm32` 目标下执行实际读取;SSR 构建下直接返回 None。 #[allow(unused_variables)] fn read_storage(key: &str) -> Option { #[cfg(target_arch = "wasm32")] @@ -42,6 +70,9 @@ fn read_storage(key: &str) -> Option { } } +/// 将值写入 localStorage 指定键。 +/// +/// 仅在 `wasm32` 目标下执行实际写入;SSR 构建下为空操作。 #[allow(unused_variables)] fn write_storage(key: &str, value: &str) { #[cfg(target_arch = "wasm32")] @@ -54,6 +85,9 @@ fn write_storage(key: &str, value: &str) { } } +/// 获取当前时间戳(毫秒)。 +/// +/// WASM 端使用 `js_sys::Date`,服务端回退到 `chrono::Utc`。 fn now_millis() -> i64 { #[cfg(target_arch = "wasm32")] { @@ -65,6 +99,7 @@ fn now_millis() -> i64 { } } +/// 判断给定存储时间是否已经过期。 fn is_expired(stored_at: &str) -> bool { let Ok(dt) = DateTime::parse_from_rfc3339(stored_at) else { return true; @@ -74,6 +109,7 @@ fn is_expired(stored_at: &str) -> bool { (now_ms - stored_ms) > (TTL_DAYS * 24 * 60 * 60 * 1000) } +/// 保存评论作者信息到 localStorage。 pub fn save_author(name: &str, email: &str, url: &str) { let info = AuthorInfo { name: name.to_string(), @@ -85,16 +121,21 @@ pub fn save_author(name: &str, email: &str, url: &str) { } } +/// 从 localStorage 读取评论作者信息。 pub fn load_author() -> Option { let json = read_storage(AUTHOR_KEY)?; serde_json::from_str(&json).ok() } +/// 将一条待发布评论草稿保存到指定文章的草稿列表中。 +/// +/// 如果同一 ID 的草稿已存在则忽略,避免重复。 pub fn save_pending_comment(post_id: i32, comment: PendingComment) { let mut map: PendingMap = load_all_pending(); let key = post_id.to_string(); let list = map.entry(key).or_default(); + // 已存在相同 ID 时直接返回,避免重复保存。 if list.iter().any(|c| c.id == comment.id) { return; } @@ -105,6 +146,9 @@ pub fn save_pending_comment(post_id: i32, comment: PendingComment) { } } +/// 加载指定文章下所有未过期的待发布评论草稿。 +/// +/// 读取时会自动清理过期草稿,并将结果写回 localStorage。 pub fn load_pending_comments(post_id: i32) -> Vec { let mut map = load_all_pending(); let key = post_id.to_string(); @@ -116,6 +160,7 @@ pub fn load_pending_comments(post_id: i32) -> Vec { .filter(|c| !is_expired(&c.stored_at)) .collect(); + // 若有草稿被清理,或该文章已无草稿,都需要把更新后的映射写回 localStorage。 let pruned = non_expired.len() != original_len; if !non_expired.is_empty() { map.insert(key, non_expired.clone()); @@ -129,6 +174,9 @@ pub fn load_pending_comments(post_id: i32) -> Vec { non_expired } +/// 从指定文章的草稿列表中移除指定 ID 的评论。 +/// +/// 若移除后该文章无草稿,则删除该文章对应的键。 pub fn remove_pending_ids(post_id: i32, ids: &[i64]) { let mut map = load_all_pending(); let key = post_id.to_string(); @@ -148,6 +196,7 @@ pub fn remove_pending_ids(post_id: i32, ids: &[i64]) { } } +/// 清理所有文章中已过期的待发布评论草稿。 pub fn prune_all_expired() { let mut map = load_all_pending(); let mut changed = false; @@ -177,6 +226,7 @@ pub fn prune_all_expired() { } } +/// 加载全部待发布评论草稿映射。 fn load_all_pending() -> PendingMap { let json = match read_storage(PENDING_KEY) { Some(j) => j, @@ -185,6 +235,7 @@ fn load_all_pending() -> PendingMap { serde_json::from_str(&json).unwrap_or_default() } +/// HTML 转义辅助函数。 pub(crate) fn escape_html(input: &str) -> String { input .replace('&', "&") @@ -194,6 +245,7 @@ pub(crate) fn escape_html(input: &str) -> String { .replace('\'', "'") } +/// 将待发布评论的 Markdown 内容渲染为安全的 HTML(纯文本 + 换行转 `
`)。 pub fn render_pending_content(md: &str) -> String { let escaped = escape_html(md); escaped.replace('\n', "
") diff --git a/src/hooks/delayed_loading.rs b/src/hooks/delayed_loading.rs index b7f2922..5ca9890 100644 --- a/src/hooks/delayed_loading.rs +++ b/src/hooks/delayed_loading.rs @@ -1,3 +1,7 @@ +//! 骨架屏延迟加载状态 Hook。 +//! +//! 通过延迟显示骨架屏,避免数据加载很快时出现闪烁。 + use crate::utils::time::sleep_ms; use dioxus::prelude::*; diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 44abc3f..0af7a77 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -1,2 +1,11 @@ +//! 共享的 Dioxus Hooks 模块。 +//! +//! 该模块集中管理可在组件树中复用的自定义 Hook,包括: +//! - 评论草稿在浏览器 localStorage 中的持久化(WASM 端) +//! - 骨架屏延迟加载状态 + +/// 评论草稿持久化 Hook,基于浏览器的 localStorage(仅在 WASM 端有效)。 pub mod comment_storage; + +/// 骨架屏延迟加载状态 Hook。 pub mod delayed_loading; diff --git a/src/theme.rs b/src/theme.rs index 873d7f1..b1654a2 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,15 +1,27 @@ +//! 主题(浅色 / 深色)管理。 +//! +//! 提供两条初始化路径: +//! - **SSR**:从 HTTP 请求 Cookie 中的 `theme` 字段检测主题,避免首屏闪烁。 +//! - **WASM 客户端**:优先读取 `localStorage` 中的持久化主题;不存在时回退到 +//! `prefers-color-scheme` 媒体查询;切换时同步更新 DOM class 与 localStorage。 + use dioxus::prelude::*; +/// localStorage 中存储主题值的键名。 #[allow(dead_code)] const THEME_KEY: &str = "yggdrasil-theme"; +/// 应用主题枚举。 #[derive(Debug, Clone, Copy, PartialEq)] pub enum Theme { + /// 浅色主题。 Light, + /// 深色主题。 Dark, } impl Theme { + /// 切换到相反主题。 pub fn toggle(&self) -> Self { match self { Theme::Light => Theme::Dark, @@ -18,6 +30,10 @@ impl Theme { } } +/// 检测初始主题。 +/// +/// 在 WASM 客户端优先读取 localStorage,回退到系统颜色偏好; +/// 在 SSR 阶段解析请求 Cookie;否则默认浅色主题。 fn detect_initial_theme() -> Theme { #[cfg(target_arch = "wasm32")] { @@ -26,6 +42,7 @@ fn detect_initial_theme() -> Theme { None => return Theme::Light, }; + // 优先读取 localStorage 中持久化的主题值。 if let Ok(Some(storage)) = window.local_storage() { if let Ok(Some(value)) = storage.get_item(THEME_KEY) { return if value == "dark" { @@ -36,6 +53,7 @@ fn detect_initial_theme() -> Theme { } } + // 没有持久化值时,根据系统颜色偏好决定。 if let Ok(Some(media)) = window.match_media("(prefers-color-scheme: dark)") { if media.matches() { return Theme::Dark; @@ -45,10 +63,11 @@ fn detect_initial_theme() -> Theme { #[cfg(feature = "server")] { + // SSR 路径:从请求 Cookie 中解析 `theme` 字段。 if let Some(ctx) = dioxus::fullstack::FullstackContext::current() { if let Some(cookie) = ctx.parts_mut().headers.get("cookie") { if let Ok(cookie_str) = cookie.to_str() { - // Parse cookies properly: split by ';' then by '=' + // 按 ';' 分割 Cookie 字符串,再按 '=' 分割键值对。 for cookie_pair in cookie_str.split(';') { let mut parts = cookie_pair.trim().splitn(2, '='); if let (Some(name), Some(value)) = (parts.next(), parts.next()) { @@ -65,6 +84,10 @@ fn detect_initial_theme() -> Theme { Theme::Light } +/// 提供主题上下文的 Hook。 +/// +/// 初始化时按 SSR Cookie → WASM localStorage → 系统偏好的顺序检测主题; +/// 主题变化时同步更新 HTML 根元素的 `dark` class 与 localStorage。 pub fn use_theme_provider() -> Signal { let theme = use_signal(detect_initial_theme); @@ -73,6 +96,7 @@ pub fn use_theme_provider() -> Signal { { let current = theme(); if let Some(window) = web_sys::window() { + // 同步 HTML 根元素的 dark class,用于 Tailwind dark mode。 if let Some(document) = window.document() { if let Some(html) = document.document_element() { match current { @@ -85,6 +109,7 @@ pub fn use_theme_provider() -> Signal { } } } + // 将当前主题持久化到 localStorage。 if let Ok(Some(storage)) = window.local_storage() { let theme_str = match current { Theme::Dark => "dark", @@ -100,6 +125,9 @@ pub fn use_theme_provider() -> Signal { theme } +/// 读取当前主题 Signal 的 Hook。 +/// +/// 需在 `use_theme_provider` 之后的组件树中使用。 pub fn use_theme() -> Signal { use_context::>() } @@ -115,6 +143,10 @@ const THEME_PRELOAD_SCRIPT: &str = r#" })(); "#; +/// 首屏主题预加载脚本组件。 +/// +/// 通过内联脚本在页面渲染前读取 localStorage / 系统偏好并设置 `dark` class, +/// 防止主题切换时出现闪烁。 #[component] pub fn ThemePreload() -> Element { rsx! { @@ -124,6 +156,7 @@ pub fn ThemePreload() -> Element { } } +/// 主题切换按钮组件。 #[component] pub fn ThemeToggle() -> Element { let mut theme = use_theme();