添加骨架屏组件,优化 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:
xfy 2026-05-27 09:25:26 +08:00
parent 61376f6ba9
commit 9c834ba1df
7 changed files with 180 additions and 6 deletions

1
Cargo.lock generated
View File

@ -4639,6 +4639,7 @@ dependencies = [
"tokio-postgres",
"uuid",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]

View File

@ -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]

View File

@ -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 {}
}
}
}

View 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" }
}
}
}
}
}

View File

@ -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;

View 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" }
}
}
}

View File

@ -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 |_| {