diff --git a/Cargo.lock b/Cargo.lock index 3d892dd..7e4ba90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4639,6 +4639,7 @@ dependencies = [ "tokio-postgres", "uuid", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] diff --git a/Cargo.toml b/Cargo.toml index ad2175c..f60a34d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ http = "1" [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { version = "0.3", features = ["Document", "Window", "HtmlDocument", "Storage", "Element", "DomTokenList", "MediaQueryList", "HtmlScriptElement"] } wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" js-sys = "0.3" [profile.release] diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs index 8992738..f8066b0 100644 --- a/src/components/admin_layout.rs +++ b/src/components/admin_layout.rs @@ -3,6 +3,8 @@ use dioxus::prelude::*; use crate::api::auth::{get_current_user, logout}; use crate::components::header::{Header, NavItemConfig}; use crate::components::footer::Footer; +use crate::components::admin_skeleton::{AdminSkeleton, AdminDashboardSkeleton}; +use crate::components::write_skeleton::WriteSkeleton; use crate::context::UserContext; use crate::router::Route; @@ -86,9 +88,17 @@ pub fn AdminLayout() -> Element { } } (false, _) => { + // 使用与真实布局完全相同的结构包裹内容骨架,避免 checked 变化时的布局闪烁 rsx! { - div { class: "min-h-screen flex items-center justify-center bg-white dark:bg-[#1d1e20]", - p { class: "text-gray-600 dark:text-[#9b9c9d]", "加载中..." } + 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::WritePage {} => rsx! { WriteSkeleton {} }, + _ => rsx! { AdminDashboardSkeleton {} }, + }} + } + Footer {} } } } diff --git a/src/components/admin_skeleton.rs b/src/components/admin_skeleton.rs new file mode 100644 index 0000000..e5b4afb --- /dev/null +++ b/src/components/admin_skeleton.rs @@ -0,0 +1,76 @@ +use dioxus::prelude::*; + +/// 仅仪表盘内容区骨架(不含 header/footer) +#[component] +pub fn AdminDashboardSkeleton() -> Element { + rsx! { + div { class: "space-y-8", + // 统计卡片骨架 + div { class: "grid grid-cols-1 md:grid-cols-3 gap-6", + 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", + div { class: "h-9 w-16 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + div { class: "h-4 w-20 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + } + } + } + + // 快捷操作骨架 + div { class: "grid grid-cols-1 md:grid-cols-2 gap-4", + div { class: "h-12 bg-gray-200 dark:bg-[#2a2a2a] rounded-full animate-pulse" } + div { class: "h-12 bg-gray-200 dark:bg-[#2a2a2a] rounded-full animate-pulse" } + } + + // 最近文章列表骨架 + div { class: "space-y-4", + div { class: "h-6 w-24 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + div { class: "space-y-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 animate-pulse" } + div { class: "h-3 w-20 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + } + } + } + } + } + } +} + +/// 完整的仪表盘页面骨架(含 header/footer + 内容) +#[component] +pub fn AdminSkeleton() -> Element { + rsx! { + div { class: "min-h-screen flex flex-col bg-white dark:bg-[#1d1e20]", + // Header 骨架 + header { class: "sticky top-0 z-40 w-full border-b border-gray-200 dark:border-[#333] bg-white/80 dark:bg-[#1d1e20]/80 backdrop-blur-sm", + nav { class: "max-w-3xl mx-auto px-6 h-[60px] flex items-center justify-between", + // Logo 占位 + div { class: "w-32 h-7 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + // 导航项 + 右侧按钮占位 + div { class: "flex items-center gap-4", + div { class: "hidden md:flex items-center gap-2", + div { class: "w-12 h-5 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + div { class: "w-12 h-5 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + div { class: "w-10 h-5 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + } + div { class: "w-10 h-5 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + } + } + } + + // 内容区骨架 + main { class: "flex-1 w-full max-w-5xl mx-auto px-6 py-8", + AdminDashboardSkeleton {} + } + + // Footer 骨架 + footer { class: "w-full border-t border-gray-200 dark:border-[#333] py-6", + div { class: "max-w-3xl mx-auto px-6 flex justify-between items-center", + div { class: "h-4 w-32 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + div { class: "h-4 w-24 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + } + } + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index fb22d54..43d6b6a 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,3 +1,6 @@ pub mod header; pub mod footer; pub mod admin_layout; +pub mod admin_skeleton; +pub use admin_skeleton::{AdminSkeleton, AdminDashboardSkeleton}; +pub mod write_skeleton; diff --git a/src/components/write_skeleton.rs b/src/components/write_skeleton.rs new file mode 100644 index 0000000..de2af60 --- /dev/null +++ b/src/components/write_skeleton.rs @@ -0,0 +1,40 @@ +use dioxus::prelude::*; + +#[component] +pub fn WriteSkeleton() -> Element { + rsx! { + div { class: "space-y-4", + // 标题输入骨架 + div { class: "w-full h-[52px] bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse mb-4" } + + // 编辑器区域骨架 + div { + class: "w-full h-[600px] border border-gray-200 dark:border-[#333] rounded-lg bg-white dark:bg-[#1e1e1e] p-6 space-y-4", + // 工具栏骨架 + div { class: "flex gap-2 pb-4 border-b border-gray-100 dark:border-[#333]", + for _ in 0..8 { + div { class: "w-8 h-8 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } + } + } + // 内容行骨架 + div { class: "space-y-3 pt-2", + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-[90%]" } + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-full" } + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-[85%]" } + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-[95%]" } + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-[60%]" } + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-full" } + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-[75%]" } + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-[80%]" } + div { class: "h-4" } + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-[70%]" } + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-full" } + div { class: "h-4 bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse w-[90%]" } + } + } + + // 保存按钮骨架 + div { class: "mt-4 h-10 w-28 bg-gray-200 dark:bg-[#2a2a2a] rounded-full animate-pulse" } + } + } +} diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs index d03013f..6bd597b 100644 --- a/src/pages/admin/write.rs +++ b/src/pages/admin/write.rs @@ -1,16 +1,26 @@ use dioxus::prelude::*; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; + +use crate::components::write_skeleton::WriteSkeleton; + #[component] pub fn WritePage() -> Element { let mut title = use_signal(|| "".to_string()); let mut content = use_signal(|| "".to_string()); + let mut loading = use_signal(|| true); + // 初始化 Tiptap 编辑器 use_effect(move || { #[cfg(target_arch = "wasm32")] { let _ = js_sys::eval( r#" (function initEditor() { + // 如果已经初始化过,直接标记为就绪 + if (window.__tiptap_ready) return; + var container = document.getElementById('tiptap-editor'); if (!container) { setTimeout(initEditor, 50); @@ -24,6 +34,7 @@ pub fn WritePage() -> Element { window.__tiptap_content = markdown; } }); + window.__tiptap_ready = true; return; } setTimeout(initEditor, 50); @@ -33,9 +44,43 @@ pub fn WritePage() -> Element { } }); + // 轮询编辑器就绪状态 + use_effect(move || { + #[cfg(target_arch = "wasm32")] + { + wasm_bindgen_futures::spawn_local(async move { + loop { + if let Ok(promise_val) = js_sys::eval("new Promise(r => setTimeout(r, 100))") { + if let Ok(promise) = promise_val.dyn_into::() { + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; + } + } + if let Ok(ready) = js_sys::eval("window.__tiptap_ready") { + if ready.as_bool().unwrap_or(false) { + loading.set(false); + break; + } + } + } + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } + }); + rsx! { - div { class: "space-y-4", - // 标题输入 + div { class: "space-y-4 relative", + // 骨架屏覆盖层:编辑器初始化期间显示 + if loading() { + div { class: "absolute inset-0 z-10 bg-white dark:bg-[#1d1e20]", + WriteSkeleton {} + } + } + + // 真实内容始终渲染,确保 #tiptap-editor 在 DOM 中 + // 初始化期间被骨架屏遮住,就绪后骨架屏消失 input { class: "w-full text-2xl font-bold bg-transparent border-b border-gray-200 dark:border-[#333] py-2 mb-4 text-gray-900 dark:text-[#dadadb] placeholder-gray-400 dark:placeholder-[#9b9c9d] focus:outline-none", placeholder: "文章标题...", @@ -43,13 +88,11 @@ pub fn WritePage() -> Element { oninput: move |evt| title.set(evt.value()), } - // Tiptap 编辑器容器 div { class: "w-full h-[600px] border border-gray-200 dark:border-[#333] rounded-lg overflow-hidden bg-white dark:bg-[#1e1e1e]", id: "tiptap-editor", } - // 保存按钮 button { class: "mt-4 px-6 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full font-medium hover:opacity-80 transition-opacity", onclick: move |_| {