diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs index b205b9e..6ecea00 100644 --- a/src/components/admin_layout.rs +++ b/src/components/admin_layout.rs @@ -6,6 +6,7 @@ use crate::components::footer::Footer; use crate::components::header::{Header, NavItemConfig}; use crate::components::write_skeleton::WriteSkeleton; use crate::context::UserContext; +use crate::hooks::delayed_loading::use_delayed_loading; use crate::router::Route; #[component] @@ -13,6 +14,7 @@ pub fn AdminLayout() -> Element { let mut ctx: UserContext = use_context(); let navigator = dioxus::router::navigator(); let route = use_route::(); + let show_skeleton = use_delayed_loading(move || !(ctx.checked)()); // 只在首次挂载时加载用户数据 use_effect(move || { @@ -96,10 +98,12 @@ pub fn AdminLayout() -> Element { div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20]", Header { nav_items: admin_nav_items, right_content: logout_button } main { class: "flex-1 w-full max-w-5xl mx-auto px-6 py-8", - {match route { - Route::Write {} => rsx! { WriteSkeleton {} }, - _ => rsx! { AdminDashboardSkeleton {} }, - }} + div { class: if show_skeleton() { "" } else { "opacity-0" }, + {match route { + Route::Write {} => rsx! { WriteSkeleton {} }, + _ => rsx! { AdminDashboardSkeleton {} }, + }} + } } Footer {} } diff --git a/src/hooks/delayed_loading.rs b/src/hooks/delayed_loading.rs new file mode 100644 index 0000000..691f9ed --- /dev/null +++ b/src/hooks/delayed_loading.rs @@ -0,0 +1,53 @@ +use dioxus::prelude::*; + +/// 骨架屏最小显示延迟(毫秒)。加载时间低于此值时不会显示骨架屏,避免闪烁。 +pub const MIN_SKELETON_DELAY_MS: u32 = 200; + +#[cfg(target_arch = "wasm32")] +async fn sleep_ms(ms: u32) { + use wasm_bindgen::JsCast; + let js_code = format!("new Promise(r => setTimeout(r, {}))", ms); + if let Ok(promise_val) = js_sys::eval(&js_code) { + if let Ok(promise) = promise_val.dyn_into::() { + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +async fn sleep_ms(ms: u32) { + tokio::time::sleep(std::time::Duration::from_millis(ms as u64)).await; +} + +/// 延迟加载状态 Hook。 +/// +/// 当 `is_loading` 返回 true 时,延迟 `MIN_SKELETON_DELAY_MS` 毫秒后才返回 true; +/// 当 `is_loading` 返回 false 时,立即返回 false。 +/// +/// 用于骨架屏:避免数据加载很快时出现骨架屏一闪而过的问题。 +pub fn use_delayed_loading(is_loading: F) -> Signal +where + F: Fn() -> bool + Clone + 'static, +{ + let mut should_show = use_signal(|| false); + + use_effect(move || { + let loading = is_loading(); + + if loading { + if !should_show() { + let is_loading_clone = is_loading.clone(); + spawn(async move { + sleep_ms(MIN_SKELETON_DELAY_MS).await; + if is_loading_clone() { + should_show.set(true); + } + }); + } + } else { + should_show.set(false); + } + }); + + should_show +} diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs new file mode 100644 index 0000000..5d29053 --- /dev/null +++ b/src/hooks/mod.rs @@ -0,0 +1 @@ +pub mod delayed_loading; diff --git a/src/main.rs b/src/main.rs index c727347..bcd5483 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod auth; mod components; mod context; mod db; +mod hooks; mod models; mod pages; mod router; diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index c271cef..e1bd421 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -1,12 +1,15 @@ use dioxus::prelude::*; use crate::api::posts::{get_post_stats, list_posts, PostListResponse, PostStatsResponse}; +use crate::hooks::delayed_loading::use_delayed_loading; use crate::models::post::Post; #[component] pub fn Admin() -> Element { let stats_res = use_resource(get_post_stats); let posts_res = use_resource(list_posts); + let show_stats_skeleton = use_delayed_loading(move || stats_res.read().is_none()); + let show_posts_skeleton = use_delayed_loading(move || posts_res.read().is_none()); rsx! { div { class: "space-y-8", @@ -23,7 +26,7 @@ pub fn Admin() -> Element { _ => { rsx! { for _ in 0..3 { - div { class: "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center space-y-3 animate-pulse", + div { class: if show_stats_skeleton() { "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center space-y-3 animate-pulse" } else { "rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center space-y-3 opacity-0" }, div { class: "h-9 w-16 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded" } div { class: "h-4 w-20 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded" } } @@ -68,7 +71,7 @@ pub fn Admin() -> Element { } _ => { rsx! { - div { class: "space-y-4 animate-pulse", + div { class: if show_posts_skeleton() { "space-y-4 animate-pulse" } else { "space-y-4 opacity-0" }, for _ in 0..5 { div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]", div { class: "h-4 w-[45%] bg-gray-200 dark:bg-[#2a2a2a] rounded" } diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index 9a2f567..df26ef7 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -1,12 +1,14 @@ use dioxus::prelude::*; use crate::api::posts::{delete_post, list_posts, CreatePostResponse, PostListResponse}; +use crate::hooks::delayed_loading::use_delayed_loading; use crate::models::post::{Post, PostStatus}; #[component] pub fn Posts() -> Element { let mut posts_res = use_resource(list_posts); let mut deleting = use_signal(|| None::); + let show_skeleton = use_delayed_loading(move || posts_res.read().is_none()); rsx! { div { class: "space-y-6", @@ -84,7 +86,7 @@ pub fn Posts() -> Element { } None => { rsx! { - div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] animate-pulse", + div { class: if show_skeleton() { "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] animate-pulse" } else { "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] opacity-0" }, for _ in 0..5 { div { class: "flex items-center px-4 py-3 border-b border-gray-100 dark:border-[#333] last:border-0", div { class: "h-4 w-1/3 bg-gray-200 dark:bg-[#2a2a2a] rounded" } diff --git a/src/pages/archives.rs b/src/pages/archives.rs index ce30757..9fbcb2e 100644 --- a/src/pages/archives.rs +++ b/src/pages/archives.rs @@ -3,6 +3,7 @@ use dioxus::prelude::*; use crate::api::posts::{list_published_posts, PostListResponse}; use crate::components::nav::use_nav_items; use crate::components::page_layout::PageLayout; +use crate::hooks::delayed_loading::use_delayed_loading; use crate::models::post::Post; use crate::router::Route; @@ -81,6 +82,7 @@ pub fn Archives() -> Element { let route = use_route::(); let posts_res = use_resource(move || list_published_posts(1, 10000)); let nav_items = use_nav_items(route); + let show_skeleton = use_delayed_loading(move || posts_res.read().is_none()); rsx! { PageLayout { nav_items, @@ -125,7 +127,7 @@ pub fn Archives() -> Element { } None => { rsx! { - div { class: "space-y-8 animate-pulse", + div { class: if show_skeleton() { "space-y-8 animate-pulse" } else { "space-y-8 opacity-0" }, for _ in 0..2 { div { class: "space-y-4", div { class: "h-8 w-20 bg-gray-200 dark:bg-[#2a2a2a] rounded" } diff --git a/src/pages/home.rs b/src/pages/home.rs index 0cdfeac..aff2796 100644 --- a/src/pages/home.rs +++ b/src/pages/home.rs @@ -4,6 +4,7 @@ use crate::api::posts::{list_published_posts, PostListResponse}; use crate::components::nav::use_nav_items; use crate::components::page_layout::PageLayout; use crate::components::post_card::PostCard; +use crate::hooks::delayed_loading::use_delayed_loading; use crate::router::Route; const POSTS_PER_PAGE: i32 = 10; @@ -24,6 +25,7 @@ fn HomeContent(page: i32) -> Element { let current_page = page.max(1); let posts_res = use_resource(move || list_published_posts(current_page, POSTS_PER_PAGE)); let nav_items = use_nav_items(route); + let show_skeleton = use_delayed_loading(move || posts_res.read().is_none()); rsx! { PageLayout { nav_items, @@ -51,7 +53,7 @@ fn HomeContent(page: i32) -> Element { } None => { rsx! { - div { class: "space-y-6 py-4", + div { class: if show_skeleton() { "space-y-6 py-4" } else { "space-y-6 py-4 opacity-0" }, for _ in 0..3 { div { class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333] animate-pulse", div { class: "h-7 w-3/4 bg-gray-200 dark:bg-[#2a2a2a] rounded mb-3" } diff --git a/src/pages/post_detail.rs b/src/pages/post_detail.rs index 8125359..a8e1444 100644 --- a/src/pages/post_detail.rs +++ b/src/pages/post_detail.rs @@ -3,6 +3,7 @@ use dioxus::prelude::*; use crate::api::posts::{get_post_by_slug, SinglePostResponse}; use crate::components::nav::use_nav_items; use crate::components::page_layout::PageLayout; +use crate::hooks::delayed_loading::use_delayed_loading; use crate::router::Route; #[component] @@ -11,6 +12,7 @@ pub fn PostDetail(slug: String) -> Element { let slug_clone = slug.clone(); let post_res = use_resource(move || get_post_by_slug(slug_clone.clone())); let nav_items = use_nav_items(route); + let show_skeleton = use_delayed_loading(move || post_res.read().is_none()); rsx! { PageLayout { nav_items, @@ -86,7 +88,7 @@ pub fn PostDetail(slug: String) -> Element { } None => { rsx! { - div { class: "animate-pulse py-6 space-y-4", + div { class: if show_skeleton() { "animate-pulse py-6 space-y-4" } else { "py-6 space-y-4 opacity-0" }, div { class: "h-10 w-3/4 bg-gray-200 dark:bg-[#2a2a2a] rounded" } div { class: "h-4 w-32 bg-gray-200 dark:bg-[#2a2a2a] rounded" } div { class: "h-4 w-full bg-gray-200 dark:bg-[#2a2a2a] rounded mt-8" } diff --git a/src/pages/search.rs b/src/pages/search.rs index bbe7881..2f68aea 100644 --- a/src/pages/search.rs +++ b/src/pages/search.rs @@ -4,6 +4,7 @@ use crate::api::posts::{search_posts, PostListResponse}; use crate::components::nav::use_nav_items; use crate::components::page_layout::PageLayout; use crate::components::post_card::PostCard; +use crate::hooks::delayed_loading::use_delayed_loading; use crate::router::Route; #[component] @@ -13,6 +14,7 @@ pub fn Search() -> Element { let mut search_res = use_signal(|| None::>); let mut is_searching = use_signal(|| false); let nav_items = use_nav_items(route); + let show_skeleton = use_delayed_loading(move || is_searching()); let mut on_search = move || { let q = query().trim().to_string(); @@ -53,7 +55,7 @@ pub fn Search() -> Element { } } if is_searching() { - div { class: "space-y-6 py-4 animate-pulse", + div { class: if show_skeleton() { "space-y-6 py-4 animate-pulse" } else { "space-y-6 py-4 opacity-0" }, for _ in 0..3 { div { class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333]", div { class: "h-7 w-3/4 bg-gray-200 dark:bg-[#2a2a2a] rounded mb-3" } diff --git a/src/pages/tags.rs b/src/pages/tags.rs index 82d1390..d4d7754 100644 --- a/src/pages/tags.rs +++ b/src/pages/tags.rs @@ -4,6 +4,7 @@ use crate::api::posts::{get_posts_by_tag, list_tags, PostListResponse, TagListRe use crate::components::nav::use_nav_items; use crate::components::page_layout::PageLayout; use crate::components::post_card::PostCard; +use crate::hooks::delayed_loading::use_delayed_loading; use crate::router::Route; #[component] @@ -11,6 +12,7 @@ pub fn Tags() -> Element { let route = use_route::(); let tags_res = use_resource(list_tags); let nav_items = use_nav_items(route); + let show_skeleton = use_delayed_loading(move || tags_res.read().is_none()); rsx! { PageLayout { nav_items, @@ -78,7 +80,7 @@ pub fn Tags() -> Element { } None => { rsx! { - div { class: "flex flex-wrap gap-4 mt-6 animate-pulse", + div { class: if show_skeleton() { "flex flex-wrap gap-4 mt-6 animate-pulse" } else { "flex flex-wrap gap-4 mt-6 opacity-0" }, for _ in 0..8 { div { class: "h-8 w-16 bg-gray-200 dark:bg-[#2a2a2a] rounded-lg" } } @@ -96,6 +98,7 @@ pub fn TagDetail(tag: String) -> Element { let tag_clone = tag.clone(); let posts_res = use_resource(move || get_posts_by_tag(tag_clone.clone())); let nav_items = use_nav_items(route); + let show_skeleton = use_delayed_loading(move || posts_res.read().is_none()); rsx! { PageLayout { nav_items, @@ -146,7 +149,7 @@ pub fn TagDetail(tag: String) -> Element { } None => { rsx! { - div { class: "space-y-6 py-4 animate-pulse", + div { class: if show_skeleton() { "space-y-6 py-4 animate-pulse" } else { "space-y-6 py-4 opacity-0" }, for _ in 0..3 { div { class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333]", div { class: "h-7 w-3/4 bg-gray-200 dark:bg-[#2a2a2a] rounded mb-3" }