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::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::<PostStats>);
let mut recent_posts = use_signal(|| None::<Vec<Post>>);
let mut pending_count = use_signal(|| None::<i64>);
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" }

View File

@ -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::<i32>);
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<Post> { 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<i32>) -> 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 { "删除" }
}
}
}
}

View File

@ -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<i32>) -> Element {
let mut has_backfilled = use_signal(|| false);
let mut load_error = use_signal(|| None::<String>);
// 编辑模式:加载文章数据
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::<Post>);
// 数据回填 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<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")]
use_drop(move || {
let _ = js_sys::eval(
@ -99,7 +103,7 @@ fn write_editor(post_id: Option<i32>) -> 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<i32>) -> Element {
#[cfg(target_arch = "wasm32")]
{
// 编辑模式:等数据加载完再开始轮询
if is_edit && post_res.read().is_none() {
if is_edit && edit_post().is_none() {
return;
}