fix(admin): 后台管理页改为 CSR 避免闪烁

- 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()
This commit is contained in:
xfy 2026-06-11 18:04:07 +08:00
parent ea440cd61c
commit 75e0659cb2
3 changed files with 167 additions and 132 deletions

View File

@ -1,35 +1,63 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus::router::components::Link; use dioxus::router::components::Link;
#[cfg(target_arch = "wasm32")]
use crate::api::comments::get_pending_count; use crate::api::comments::get_pending_count;
use crate::api::posts::{get_post_stats, list_posts, PostListResponse, PostStatsResponse}; #[cfg(target_arch = "wasm32")]
use crate::hooks::delayed_loading::use_delayed_loading; use crate::api::posts::{get_post_stats, list_posts};
use crate::models::post::Post; #[cfg(target_arch = "wasm32")]
use crate::api::posts::{PostListResponse, PostStatsResponse};
use crate::models::post::{Post, PostStats};
use crate::router::Route; use crate::router::Route;
#[component] #[component]
#[allow(unused_mut)]
pub fn Admin() -> Element { pub fn Admin() -> Element {
let stats_res = use_resource(get_post_stats); let mut stats = use_signal(|| None::<PostStats>);
let posts_res = use_resource(|| list_posts(1, 5)); let mut recent_posts = use_signal(|| None::<Vec<Post>>);
let pending_res = use_resource(get_pending_count); let mut pending_count = use_signal(|| None::<i64>);
let show_stats_skeleton = use_delayed_loading(move || stats_res.read().is_none()); let mut loaded = use_signal(|| false);
let show_posts_skeleton = use_delayed_loading(move || posts_res.read().is_none());
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! { rsx! {
div { class: "space-y-8", div { class: "space-y-8",
div { class: "grid grid-cols-1 md:grid-cols-3 gap-6", div { class: "grid grid-cols-1 md:grid-cols-3 gap-6",
match &*stats_res.read() { match stats() {
Some(Ok(PostStatsResponse { stats })) => { Some(s) => {
rsx! { rsx! {
StatCard { value: stats.total.to_string(), label: "文章总数" } StatCard { value: s.total.to_string(), label: "文章总数" }
StatCard { value: stats.drafts.to_string(), label: "草稿数" } StatCard { value: s.drafts.to_string(), label: "草稿数" }
StatCard { value: stats.published.to_string(), label: "已发布" } StatCard { value: s.published.to_string(), label: "已发布" }
} }
} }
_ => { None => {
rsx! { rsx! {
for _ in 0..3 { 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-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" } div { class: "h-4 w-20 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded" }
} }
@ -42,18 +70,18 @@ pub fn Admin() -> Element {
Link { 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", 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 {}, to: Route::AdminComments {},
match &*pending_res.read() { match pending_count() {
Some(Ok(resp)) => { Some(count) => {
rsx! { rsx! {
div { class: "text-3xl font-bold text-amber-600 dark:text-amber-400", 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", div { class: "text-sm text-gray-500 dark:text-[#9b9c9d] mt-2",
"待审核评论" "待审核评论"
} }
} }
} }
_ => { None => {
rsx! { rsx! {
div { class: "h-9 w-16 mx-auto bg-gray-200 dark:bg-[#2a2a2a] rounded animate-pulse" } 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" } 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", h2 { class: "text-xl font-bold text-gray-900 dark:text-[#dadadb] mb-4",
"最近文章" "最近文章"
} }
match &*posts_res.read() { match recent_posts() {
Some(Ok(PostListResponse { posts, total: _ })) => { Some(posts) => {
rsx! { rsx! {
div { class: "space-y-0", div { class: "space-y-0",
for post in posts.iter().take(5) { 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 => { None => {
rsx! { 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 { for _ in 0..5 {
div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]", 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" } div { class: "h-4 w-[45%] bg-gray-200 dark:bg-[#2a2a2a] rounded" }

View File

@ -1,7 +1,11 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus::router::components::Link; 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::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::posts_skeleton::PostsSkeleton; use crate::components::skeletons::posts_skeleton::PostsSkeleton;
use crate::models::post::Post; use crate::models::post::Post;
@ -10,22 +14,43 @@ use crate::router::Route;
const POSTS_PER_PAGE: i32 = 20; const POSTS_PER_PAGE: i32 = 20;
#[component] #[component]
#[allow(unused_variables)]
pub fn Posts() -> Element { pub fn Posts() -> Element {
rsx! { PostsPage { page: 1 } } rsx! { PostsPage { page: 1 } }
} }
#[component] #[component]
#[allow(unused_variables)]
pub fn PostsPage(page: i32) -> Element { pub fn PostsPage(page: i32) -> Element {
let current_page = page.max(1); 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::<i32>); let mut deleting = use_signal(|| None::<i32>);
let posts_data = posts_res.read().as_ref().map(|r| match r { use_effect(move || {
Ok(PostListResponse { posts, total }) => Ok((posts.clone(), *total)), let _ = current_page;
Err(e) => Err(e.to_string()),
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<Post> { posts() };
rsx! { rsx! {
div { class: "space-y-6", div { class: "space-y-6",
@ -40,16 +65,13 @@ pub fn PostsPage(page: i32) -> Element {
} }
} }
match posts_data { if loading() && posts().is_empty() {
Some(Ok((posts, total))) => { DelayedSkeleton { PostsSkeleton {} }
if posts.is_empty() { } else if posts().is_empty() {
rsx! {
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]", div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
"暂无文章" "暂无文章"
} }
}
} else { } else {
rsx! {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden", div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden",
table { class: "w-full text-sm", table { class: "w-full text-sm",
thead { thead {
@ -61,25 +83,26 @@ pub fn PostsPage(page: i32) -> Element {
} }
} }
tbody { tbody {
for post in posts.iter() { for post in get_posts().iter() {
PostRow { PostRow {
post: post.clone(), post: post.clone(),
deleting: deleting() == Some(post.id), deleting: deleting() == Some(post.id),
on_delete: move |id| { on_delete: move |id| {
deleting.set(Some(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 { spawn(async move {
match delete_post(id).await { match delete_post(id_for_api).await {
Ok(CreatePostResponse { success: true, .. }) => { Ok(CreatePostResponse { success: false, message: _message, .. }) => {
posts_res.restart();
}
Ok(CreatePostResponse { success: false, message, .. }) => {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
web_sys::window().map(|w| w.alert_with_message(&message).ok()); web_sys::window().map(|w| w.alert_with_message(&_message).ok());
} }
Err(_e) => { Err(_e) => {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
web_sys::window().map(|w| w.alert_with_message("删除失败").ok()); web_sys::window().map(|w| w.alert_with_message("删除失败").ok());
} }
_ => {}
} }
deleting.set(None); deleting.set(None);
}); });
@ -89,22 +112,7 @@ pub fn PostsPage(page: i32) -> Element {
} }
} }
} }
Pagination { current_page, total } Pagination { current_page, total: total() }
}
}
}
Some(Err(_e)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败"
}
}
}
None => {
rsx! {
DelayedSkeleton { PostsSkeleton {} }
}
}
} }
} }
} }
@ -178,7 +186,8 @@ fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element
td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]", td { class: "px-4 py-3 text-gray-500 dark:text-[#9b9c9d]",
"{date_str}" "{date_str}"
} }
td { class: "px-4 py-3 text-right flex justify-end gap-3", td { class: "px-4 py-3 text-right",
div { class: "flex justify-end gap-3",
Link { Link {
class: "text-xs text-gray-600 dark:text-[#9b9c9d] hover:text-gray-900 dark:hover:text-[#dadadb] transition-colors cursor-pointer", 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 }, to: Route::WriteEdit { id: post.id },
@ -197,4 +206,5 @@ fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element
} }
} }
} }
}
} }

View File

@ -4,9 +4,9 @@ use dioxus::prelude::*;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::posts::{create_post, update_post, CreatePostResponse}; use crate::api::posts::{create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse};
use crate::api::posts::{get_post_by_id, SinglePostResponse};
use crate::components::write_skeleton::WriteSkeleton; use crate::components::write_skeleton::WriteSkeleton;
use crate::models::post::Post;
use crate::router::Route; use crate::router::Route;
#[component] #[component]
@ -40,31 +40,14 @@ fn write_editor(post_id: Option<i32>) -> Element {
let mut has_backfilled = use_signal(|| false); let mut has_backfilled = use_signal(|| false);
let mut load_error = use_signal(|| None::<String>); let mut load_error = use_signal(|| None::<String>);
// 编辑模式:加载文章数据 // 编辑模式加载文章数据CSR
let post_res = use_resource(move || async move { let mut edit_post = use_signal(|| None::<Post>);
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
}
});
// 数据回填 effect
use_effect(move || { use_effect(move || {
if !is_edit || has_backfilled() { if !is_edit || has_backfilled() {
return; return;
} }
if let Some(Some(post)) = post_res.read().as_ref() { if let Some(ref post) = edit_post() {
has_backfilled.set(true); has_backfilled.set(true);
title.set(post.title.clone()); title.set(post.title.clone());
summary.set(post.summary.clone().unwrap_or_default()); summary.set(post.summary.clone().unwrap_or_default());
@ -76,6 +59,27 @@ fn write_editor(post_id: Option<i32>) -> 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")] #[cfg(target_arch = "wasm32")]
use_drop(move || { use_drop(move || {
let _ = js_sys::eval( let _ = js_sys::eval(
@ -99,7 +103,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
// 编辑模式:等数据加载完再初始化 // 编辑模式:等数据加载完再初始化
if is_edit && post_res.read().is_none() { if is_edit && edit_post().is_none() {
return; return;
} }
@ -168,7 +172,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
// 编辑模式:等数据加载完再开始轮询 // 编辑模式:等数据加载完再开始轮询
if is_edit && post_res.read().is_none() { if is_edit && edit_post().is_none() {
return; return;
} }