docs(hooks, theme): 补充中文注释

This commit is contained in:
xfy 2026-06-12 19:07:43 +08:00
parent 671a9fea7a
commit 1904907add
4 changed files with 99 additions and 1 deletions

View File

@ -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<i64>,
/// 评论层级深度。
pub depth: i32,
/// 作者昵称。
pub author_name: String,
/// 作者主页 URL。
pub author_url: Option<String>,
/// 头像 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<String, Vec<PendingComment>>;
/// 从 localStorage 读取指定键的值。
///
/// 仅在 `wasm32` 目标下执行实际读取SSR 构建下直接返回 None。
#[allow(unused_variables)]
fn read_storage(key: &str) -> Option<String> {
#[cfg(target_arch = "wasm32")]
@ -42,6 +70,9 @@ fn read_storage(key: &str) -> Option<String> {
}
}
/// 将值写入 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<AuthorInfo> {
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<PendingComment> {
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<PendingComment> {
.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<PendingComment> {
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('&', "&amp;")
@ -194,6 +245,7 @@ pub(crate) fn escape_html(input: &str) -> String {
.replace('\'', "&#39;")
}
/// 将待发布评论的 Markdown 内容渲染为安全的 HTML纯文本 + 换行转 `<br>`)。
pub fn render_pending_content(md: &str) -> String {
let escaped = escape_html(md);
escaped.replace('\n', "<br>")

View File

@ -1,3 +1,7 @@
//! 骨架屏延迟加载状态 Hook。
//!
//! 通过延迟显示骨架屏,避免数据加载很快时出现闪烁。
use crate::utils::time::sleep_ms;
use dioxus::prelude::*;

View File

@ -1,2 +1,11 @@
//! 共享的 Dioxus Hooks 模块。
//!
//! 该模块集中管理可在组件树中复用的自定义 Hook包括
//! - 评论草稿在浏览器 localStorage 中的持久化WASM 端)
//! - 骨架屏延迟加载状态
/// 评论草稿持久化 Hook基于浏览器的 localStorage仅在 WASM 端有效)。
pub mod comment_storage;
/// 骨架屏延迟加载状态 Hook。
pub mod delayed_loading;

View File

@ -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<Theme> {
let theme = use_signal(detect_initial_theme);
@ -73,6 +96,7 @@ pub fn use_theme_provider() -> Signal<Theme> {
{
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<Theme> {
}
}
}
// 将当前主题持久化到 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> {
theme
}
/// 读取当前主题 Signal 的 Hook。
///
/// 需在 `use_theme_provider` 之后的组件树中使用。
pub fn use_theme() -> Signal<Theme> {
use_context::<Signal<Theme>>()
}
@ -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();