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