From 75e0659cb2a569424a8b41a7935f2374058a6bbc Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 11 Jun 2026 18:04:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E5=90=8E=E5=8F=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E6=94=B9=E4=B8=BA=20CSR=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=97=AA=E7=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard: use_resource → use_signal + wasm32-only 加载 - posts: use_server_future → use_signal + wasm32-only 加载 + 乐观删除 - write: use_resource → use_signal + wasm32-only 加载 - 移除 use_server_future/use_resource 导致的骨架屏闪烁 - 操作按钮使用乐观更新,不调用 restart() --- src/pages/admin/dashboard.rs | 79 ++++++++++------ src/pages/admin/posts.rs | 168 +++++++++++++++++++---------------- src/pages/admin/write.rs | 52 ++++++----- 3 files changed, 167 insertions(+), 132 deletions(-) diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index 0c7d4ec..de029a8 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -1,35 +1,63 @@ use dioxus::prelude::*; use dioxus::router::components::Link; +#[cfg(target_arch = "wasm32")] use crate::api::comments::get_pending_count; -use crate::api::posts::{get_post_stats, list_posts, PostListResponse, PostStatsResponse}; -use crate::hooks::delayed_loading::use_delayed_loading; -use crate::models::post::Post; +#[cfg(target_arch = "wasm32")] +use crate::api::posts::{get_post_stats, list_posts}; +#[cfg(target_arch = "wasm32")] +use crate::api::posts::{PostListResponse, PostStatsResponse}; +use crate::models::post::{Post, PostStats}; use crate::router::Route; #[component] +#[allow(unused_mut)] pub fn Admin() -> Element { - let stats_res = use_resource(get_post_stats); - let posts_res = use_resource(|| list_posts(1, 5)); - let pending_res = use_resource(get_pending_count); - 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()); + let mut stats = use_signal(|| None::); + let mut recent_posts = use_signal(|| None::>); + let mut pending_count = use_signal(|| None::); + let mut loaded = use_signal(|| false); + + use_effect(move || { + if !loaded() { + loaded.set(true); + + #[cfg(target_arch = "wasm32")] + { + spawn(async move { + if let Ok(PostStatsResponse { stats: s }) = get_post_stats().await { + stats.set(Some(s)); + } + }); + spawn(async move { + if let Ok(PostListResponse { posts, total: _ }) = list_posts(1, 5).await { + recent_posts.set(Some(posts)); + } + }); + spawn(async move { + if let Ok(resp) = get_pending_count().await { + pending_count.set(Some(resp.count)); + } + }); + } + } + }); rsx! { div { class: "space-y-8", div { class: "grid grid-cols-1 md:grid-cols-3 gap-6", - match &*stats_res.read() { - Some(Ok(PostStatsResponse { stats })) => { + match stats() { + Some(s) => { rsx! { - StatCard { value: stats.total.to_string(), label: "文章总数" } - StatCard { value: stats.drafts.to_string(), label: "草稿数" } - StatCard { value: stats.published.to_string(), label: "已发布" } + StatCard { value: s.total.to_string(), label: "文章总数" } + StatCard { value: s.drafts.to_string(), label: "草稿数" } + StatCard { value: s.published.to_string(), label: "已发布" } } } - _ => { + None => { rsx! { for _ in 0..3 { - 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: "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: "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" } } @@ -42,18 +70,18 @@ pub fn Admin() -> Element { Link { class: "block rounded-xl bg-white dark:bg-[#2e2e33] border border-gray-200 dark:border-[#333] p-6 text-center hover:border-gray-300 dark:hover:border-[#555] transition-colors", to: Route::AdminComments {}, - match &*pending_res.read() { - Some(Ok(resp)) => { + match pending_count() { + Some(count) => { rsx! { div { class: "text-3xl font-bold text-amber-600 dark:text-amber-400", - "{resp.count}" + "{count}" } div { class: "text-sm text-gray-500 dark:text-[#9b9c9d] mt-2", "待审核评论" } } } - _ => { + None => { rsx! { 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 mt-3 animate-pulse" } @@ -79,8 +107,8 @@ pub fn Admin() -> Element { h2 { class: "text-xl font-bold text-gray-900 dark:text-[#dadadb] mb-4", "最近文章" } - match &*posts_res.read() { - Some(Ok(PostListResponse { posts, total: _ })) => { + match recent_posts() { + Some(posts) => { rsx! { div { class: "space-y-0", for post in posts.iter().take(5) { @@ -89,16 +117,9 @@ pub fn Admin() -> Element { } } } - Some(Err(_e)) => { - rsx! { - div { class: "text-center text-red-500 dark:text-red-400 py-20", - "加载失败" - } - } - } None => { rsx! { - div { class: if show_posts_skeleton() { "space-y-4 animate-pulse" } else { "space-y-4 opacity-0" }, + div { class: "space-y-4 animate-pulse", 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 c83943a..e94597a 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -1,7 +1,11 @@ use dioxus::prelude::*; use dioxus::router::components::Link; -use crate::api::posts::{delete_post, list_posts, CreatePostResponse, PostListResponse}; +#[cfg(target_arch = "wasm32")] +use crate::api::posts::list_posts; +#[cfg(target_arch = "wasm32")] +use crate::api::posts::PostListResponse; +use crate::api::posts::{delete_post, CreatePostResponse}; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::posts_skeleton::PostsSkeleton; use crate::models::post::Post; @@ -10,23 +14,44 @@ use crate::router::Route; const POSTS_PER_PAGE: i32 = 20; #[component] -#[allow(unused_variables)] pub fn Posts() -> Element { rsx! { PostsPage { page: 1 } } } #[component] -#[allow(unused_variables)] pub fn PostsPage(page: i32) -> Element { let current_page = page.max(1); - let mut posts_res = use_server_future(move || list_posts(current_page, POSTS_PER_PAGE))?; + let mut posts = use_signal(Vec::new); + let mut total = use_signal(|| 0_i64); + let mut loading = use_signal(|| true); let mut deleting = use_signal(|| None::); - let posts_data = posts_res.read().as_ref().map(|r| match r { - Ok(PostListResponse { posts, total }) => Ok((posts.clone(), *total)), - Err(e) => Err(e.to_string()), + use_effect(move || { + let _ = current_page; + + loading.set(true); + #[cfg(target_arch = "wasm32")] + { + let p = current_page; + spawn(async move { + match list_posts(p, POSTS_PER_PAGE).await { + Ok(PostListResponse { posts: list, total: t }) => { + posts.set(list); + total.set(t); + } + Err(_) => {} + } + loading.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } }); + let get_posts = move || -> Vec { posts() }; + rsx! { div { class: "space-y-6", div { class: "flex items-center justify-between", @@ -40,71 +65,54 @@ pub fn PostsPage(page: i32) -> Element { } } - match posts_data { - Some(Ok((posts, total))) => { - if posts.is_empty() { - rsx! { - div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]", - "暂无文章" + if loading() && posts().is_empty() { + DelayedSkeleton { PostsSkeleton {} } + } else if posts().is_empty() { + div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]", + "暂无文章" + } + } else { + div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden", + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]", + th { class: "px-4 py-3 font-medium", "标题" } + th { class: "px-4 py-3 font-medium w-24 text-center", "状态" } + th { class: "px-4 py-3 font-medium w-32", "日期" } + th { class: "px-4 py-3 font-medium w-24 text-right", "操作" } + } } - } - } else { - rsx! { - div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden", - table { class: "w-full text-sm", - thead { - tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]", - th { class: "px-4 py-3 font-medium", "标题" } - th { class: "px-4 py-3 font-medium w-24 text-center", "状态" } - th { class: "px-4 py-3 font-medium w-32", "日期" } - th { class: "px-4 py-3 font-medium w-24 text-right", "操作" } - } - } - tbody { - for post in posts.iter() { - PostRow { - post: post.clone(), - deleting: deleting() == Some(post.id), - on_delete: move |id| { - deleting.set(Some(id)); - spawn(async move { - match delete_post(id).await { - Ok(CreatePostResponse { success: true, .. }) => { - posts_res.restart(); - } - Ok(CreatePostResponse { success: false, message, .. }) => { - #[cfg(target_arch = "wasm32")] - web_sys::window().map(|w| w.alert_with_message(&message).ok()); - } - Err(_e) => { - #[cfg(target_arch = "wasm32")] - web_sys::window().map(|w| w.alert_with_message("删除失败").ok()); - } - } - deleting.set(None); - }); + tbody { + for post in get_posts().iter() { + PostRow { + post: post.clone(), + deleting: deleting() == Some(post.id), + on_delete: move |id| { + deleting.set(Some(id)); + let id_for_api = id; + posts.with_mut(|list| list.retain(|p| p.id != id)); + total.with_mut(|t| *t = t.saturating_sub(1)); + spawn(async move { + match delete_post(id_for_api).await { + Ok(CreatePostResponse { success: false, message: _message, .. }) => { + #[cfg(target_arch = "wasm32")] + web_sys::window().map(|w| w.alert_with_message(&_message).ok()); + } + Err(_e) => { + #[cfg(target_arch = "wasm32")] + web_sys::window().map(|w| w.alert_with_message("删除失败").ok()); + } + _ => {} } - } + deleting.set(None); + }); } } } } - Pagination { current_page, total } } } - } - Some(Err(_e)) => { - rsx! { - div { class: "text-center text-red-500 dark:text-red-400 py-20", - "加载失败" - } - } - } - None => { - rsx! { - DelayedSkeleton { PostsSkeleton {} } - } - } + Pagination { current_page, total: total() } } } } @@ -178,21 +186,23 @@ fn PostRow(post: Post, deleting: bool, on_delete: EventHandler) -> Element td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]", "{date_str}" } - td { class: "px-4 py-3 text-right flex justify-end gap-3", - Link { - class: "text-xs text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] transition-colors cursor-pointer", - to: Route::WriteEdit { id: post.id }, - "编辑" - } - button { - class: if deleting { - "text-xs text-gray-400 cursor-not-allowed" - } else { - "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer" - }, - disabled: deleting, - onclick: move |_| on_delete.call(post.id), - if deleting { "删除中..." } else { "删除" } + td { class: "px-4 py-3 text-right", + div { class: "flex justify-end gap-3", + Link { + class: "text-xs text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] transition-colors cursor-pointer", + to: Route::WriteEdit { id: post.id }, + "编辑" + } + button { + class: if deleting { + "text-xs text-gray-400 cursor-not-allowed" + } else { + "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer" + }, + disabled: deleting, + onclick: move |_| on_delete.call(post.id), + if deleting { "删除中..." } else { "删除" } + } } } } diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs index 965efd3..4d2317d 100644 --- a/src/pages/admin/write.rs +++ b/src/pages/admin/write.rs @@ -4,9 +4,9 @@ use dioxus::prelude::*; use wasm_bindgen::JsCast; #[cfg(target_arch = "wasm32")] -use crate::api::posts::{create_post, update_post, CreatePostResponse}; -use crate::api::posts::{get_post_by_id, SinglePostResponse}; +use crate::api::posts::{create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse}; use crate::components::write_skeleton::WriteSkeleton; +use crate::models::post::Post; use crate::router::Route; #[component] @@ -40,31 +40,14 @@ fn write_editor(post_id: Option) -> Element { let mut has_backfilled = use_signal(|| false); let mut load_error = use_signal(|| None::); - // 编辑模式:加载文章数据 - let post_res = use_resource(move || async move { - if let Some(id) = post_id { - match get_post_by_id(id).await { - Ok(SinglePostResponse { post: Some(post) }) => Some(post), - Ok(SinglePostResponse { post: None }) => { - load_error.set(Some("文章不存在".to_string())); - None - } - Err(e) => { - load_error.set(Some(format!("加载失败: {}", e))); - None - } - } - } else { - None - } - }); + // 编辑模式:加载文章数据(CSR) + let mut edit_post = use_signal(|| None::); - // 数据回填 effect use_effect(move || { if !is_edit || has_backfilled() { return; } - if let Some(Some(post)) = post_res.read().as_ref() { + if let Some(ref post) = edit_post() { has_backfilled.set(true); title.set(post.title.clone()); summary.set(post.summary.clone().unwrap_or_default()); @@ -76,6 +59,27 @@ fn write_editor(post_id: Option) -> Element { } }); + use_effect(move || { + if is_edit { + #[cfg(target_arch = "wasm32")] + if let Some(id) = post_id { + spawn(async move { + match get_post_by_id(id).await { + Ok(SinglePostResponse { post: Some(post) }) => { + edit_post.set(Some(post)); + } + Ok(SinglePostResponse { post: None }) => { + load_error.set(Some("文章不存在".to_string())); + } + Err(e) => { + load_error.set(Some(format!("加载失败: {}", e))); + } + } + }); + } + } + }); + #[cfg(target_arch = "wasm32")] use_drop(move || { let _ = js_sys::eval( @@ -99,7 +103,7 @@ fn write_editor(post_id: Option) -> Element { #[cfg(target_arch = "wasm32")] { // 编辑模式:等数据加载完再初始化 - if is_edit && post_res.read().is_none() { + if is_edit && edit_post().is_none() { return; } @@ -168,7 +172,7 @@ fn write_editor(post_id: Option) -> Element { #[cfg(target_arch = "wasm32")] { // 编辑模式:等数据加载完再开始轮询 - if is_edit && post_res.read().is_none() { + if is_edit && edit_post().is_none() { return; }