添加骨架屏组件,优化 admin 路由切换和编辑器初始化体验
- AdminLayout 使用骨架屏替代简单"加载中"文字,保持布局结构一致避免闪烁 - WritePage 添加编辑器初始化骨架屏和就绪状态轮询 - 新增 admin_skeleton.rs 和 write_skeleton.rs 组件 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
61376f6ba9
commit
9c834ba1df
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -4639,6 +4639,7 @@ dependencies = [
|
||||
"tokio-postgres",
|
||||
"uuid",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
src/components/admin_skeleton.rs
Normal file
76
src/components/admin_skeleton.rs
Normal file
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
40
src/components/write_skeleton.rs
Normal file
40
src/components/write_skeleton.rs
Normal file
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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::<js_sys::Promise>() {
|
||||
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 |_| {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user