feat: add use_delayed_loading hook to prevent skeleton flash
This commit is contained in:
parent
9c5b09a278
commit
f3c1718cd0
@ -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::<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 {}
|
||||
}
|
||||
|
||||
53
src/hooks/delayed_loading.rs
Normal file
53
src/hooks/delayed_loading.rs
Normal file
@ -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::<js_sys::Promise>() {
|
||||
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<F>(is_loading: F) -> Signal<bool>
|
||||
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
|
||||
}
|
||||
1
src/hooks/mod.rs
Normal file
1
src/hooks/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod delayed_loading;
|
||||
@ -3,6 +3,7 @@ mod auth;
|
||||
mod components;
|
||||
mod context;
|
||||
mod db;
|
||||
mod hooks;
|
||||
mod models;
|
||||
mod pages;
|
||||
mod router;
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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::<i32>);
|
||||
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" }
|
||||
|
||||
@ -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::<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" }
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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::<Result<PostListResponse, ServerFnError>>);
|
||||
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" }
|
||||
|
||||
@ -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::<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" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user