docs(hooks, theme): 补充中文注释
This commit is contained in:
parent
671a9fea7a
commit
1904907add
@ -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('&', "&")
|
||||
@ -194,6 +245,7 @@ pub(crate) fn escape_html(input: &str) -> String {
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// 将待发布评论的 Markdown 内容渲染为安全的 HTML(纯文本 + 换行转 `<br>`)。
|
||||
pub fn render_pending_content(md: &str) -> String {
|
||||
let escaped = escape_html(md);
|
||||
escaped.replace('\n', "<br>")
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
//! 骨架屏延迟加载状态 Hook。
|
||||
//!
|
||||
//! 通过延迟显示骨架屏,避免数据加载很快时出现闪烁。
|
||||
|
||||
use crate::utils::time::sleep_ms;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
|
||||
@ -1,2 +1,11 @@
|
||||
//! 共享的 Dioxus Hooks 模块。
|
||||
//!
|
||||
//! 该模块集中管理可在组件树中复用的自定义 Hook,包括:
|
||||
//! - 评论草稿在浏览器 localStorage 中的持久化(WASM 端)
|
||||
//! - 骨架屏延迟加载状态
|
||||
|
||||
/// 评论草稿持久化 Hook,基于浏览器的 localStorage(仅在 WASM 端有效)。
|
||||
pub mod comment_storage;
|
||||
|
||||
/// 骨架屏延迟加载状态 Hook。
|
||||
pub mod delayed_loading;
|
||||
|
||||
35
src/theme.rs
35
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<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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user