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::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" }
|
||||||
|
|||||||
@ -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 },
|
||||||
@ -198,3 +207,4 @@ fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user