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:
parent
ea440cd61c
commit
75e0659cb2
@ -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" }
|
||||
|
||||
@ -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 { "删除" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user