feat: SSR for admin pages

This commit is contained in:
xfy 2026-06-03 14:33:49 +08:00
parent 1afb477829
commit 34e70f6c57
2 changed files with 148 additions and 113 deletions

View File

@ -1,42 +1,19 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::api::posts::{get_post_stats, list_posts, PostListResponse, PostStatsResponse}; use crate::api::posts::{get_post_stats, list_posts, PostListResponse, PostStatsResponse};
use crate::hooks::delayed_loading::use_delayed_loading; use crate::components::suspense_wrapper::SuspenseWrapper;
use crate::models::post::Post; use crate::models::post::Post;
#[component] #[component]
pub fn Admin() -> Element { pub fn Admin() -> Element {
let stats_res = use_resource(get_post_stats);
let posts_res = use_resource(list_posts);
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());
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() { SuspenseWrapper {
Some(Ok(PostStatsResponse { stats })) => { StatsSection {}
rsx! {
StatCard { value: stats.total.to_string(), label: "文章总数" }
StatCard { value: stats.drafts.to_string(), label: "草稿数" }
StatCard { value: stats.published.to_string(), label: "已发布" }
}
}
_ => {
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: "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: "grid grid-cols-1 md:grid-cols-2 gap-4", div { class: "grid grid-cols-1 md:grid-cols-2 gap-4",
button { button {
class: "bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity cursor-pointer", class: "bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full px-6 py-3 text-center font-medium hover:opacity-80 transition-opacity cursor-pointer",
@ -54,36 +31,85 @@ pub fn Admin() -> Element {
} }
} }
// 最近文章
div { class: "mb-8", div { class: "mb-8",
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() { SuspenseWrapper {
Some(Ok(PostListResponse { posts })) => { RecentPostsSection {}
rsx! { }
div { class: "space-y-0", }
for post in posts.iter().take(5) { }
RecentPostItem { post: post.clone() } }
} }
}
} #[component]
} fn StatsSection() -> Element {
_ => { let stats_res = use_server_future(get_post_stats)?;
rsx! {
div { class: if show_posts_skeleton() { "space-y-4 animate-pulse" } else { "space-y-4 opacity-0" }, let stats_data = stats_res.read().as_ref().map(|r| match r {
for _ in 0..5 { Ok(PostStatsResponse { stats }) => Ok(stats.clone()),
div { class: "flex justify-between items-center py-3 border-b border-gray-100 dark:border-[#333]", Err(_) => Err(()),
div { class: "h-4 w-[45%] bg-gray-200 dark:bg-[#2a2a2a] rounded" } });
div { class: "h-3 w-20 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
} match stats_data {
} Some(Ok(stats)) => {
} rsx! {
} StatCard { value: stats.total.to_string(), label: "文章总数" }
StatCard { value: stats.drafts.to_string(), label: "草稿数" }
StatCard { value: stats.published.to_string(), label: "已发布" }
}
}
Some(Err(_)) => {
rsx! {
div { class: "col-span-3 text-center text-red-500 dark:text-red-400 py-6",
"加载统计失败"
}
}
}
_ => {
rsx! {
div { class: "col-span-3 text-center text-gray-500 dark:text-[#9b9c9d] py-6",
"加载中..."
}
}
}
}
}
#[component]
fn RecentPostsSection() -> Element {
let posts_res = use_server_future(list_posts)?;
let posts_data = posts_res.read().as_ref().map(|r| match r {
Ok(PostListResponse { posts }) => Ok(posts.clone()),
Err(_) => Err(()),
});
match posts_data {
Some(Ok(posts)) => {
rsx! {
div { class: "space-y-0",
for post in posts.iter().take(5) {
RecentPostItem { post: post.clone() }
} }
} }
} }
} }
Some(Err(_)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-6",
"加载文章列表失败"
}
}
}
_ => {
rsx! {
div { class: "text-center text-gray-500 dark:text-[#9b9c9d] py-6",
"加载中..."
}
}
}
} }
} }

View File

@ -1,15 +1,11 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::api::posts::{delete_post, list_posts, CreatePostResponse, PostListResponse}; use crate::api::posts::{delete_post, list_posts, CreatePostResponse, PostListResponse};
use crate::hooks::delayed_loading::use_delayed_loading; use crate::components::suspense_wrapper::SuspenseWrapper;
use crate::models::post::{Post, PostStatus}; use crate::models::post::{Post, PostStatus};
#[component] #[component]
pub fn Posts() -> Element { pub fn Posts() -> Element {
let mut posts_res = use_resource(list_posts);
let mut deleting = use_signal(|| None::<i32>);
let show_skeleton = use_delayed_loading(move || posts_res.read().is_none());
rsx! { rsx! {
div { class: "space-y-6", div { class: "space-y-6",
div { class: "flex items-center justify-between", div { class: "flex items-center justify-between",
@ -24,52 +20,70 @@ pub fn Posts() -> Element {
"+ 写文章" "+ 写文章"
} }
} }
SuspenseWrapper {
PostsTable {}
}
}
}
}
match &*posts_res.read() { #[component]
Some(Ok(PostListResponse { posts })) => { fn PostsTable() -> Element {
if posts.is_empty() { let mut refresh = use_signal(|| 0);
rsx! { let mut deleting = use_signal(|| None::<i32>);
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]", let posts_res = use_server_future(move || {
"暂无文章" let _ = refresh();
list_posts()
})?;
let posts_data = posts_res.read().as_ref().map(|r| match r {
Ok(PostListResponse { posts }) => Ok(posts.clone()),
Err(e) => Err(e.to_string()),
});
match posts_data {
Some(Ok(posts)) => {
if posts.is_empty() {
rsx! {
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
"暂无文章"
}
}
} 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 {
} else { for post in posts.iter() {
rsx! { PostRow {
div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden", post: post.clone(),
table { class: "w-full text-sm", deleting: deleting() == Some(post.id),
thead { on_delete: move |id| {
tr { class: "border-b border-gray-200 dark:border-[#333] text-left text-gray-500 dark:text-[#9b9c9d]", deleting.set(Some(id));
th { class: "px-4 py-3 font-medium", "标题" } spawn(async move {
th { class: "px-4 py-3 font-medium w-24 text-center", "状态" } match delete_post(id).await {
th { class: "px-4 py-3 font-medium w-32", "日期" } Ok(CreatePostResponse { success: true, .. }) => {
th { class: "px-4 py-3 font-medium w-24 text-right", "操作" } refresh.set(refresh() + 1);
} }
} Ok(CreatePostResponse { success: false, message, .. }) => {
tbody { #[cfg(target_arch = "wasm32")]
for post in posts.iter() { web_sys::window().map(|w| w.alert_with_message(&message).ok());
PostRow { }
post: post.clone(), Err(e) => {
deleting: deleting() == Some(post.id), #[cfg(target_arch = "wasm32")]
on_delete: move |id| { web_sys::window().map(|w| w.alert_with_message(&format!("删除失败: {}", e)).ok());
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(&format!("删除失败: {}", e)).ok());
}
}
deleting.set(None);
});
} }
} deleting.set(None);
});
} }
} }
} }
@ -77,24 +91,19 @@ pub fn Posts() -> Element {
} }
} }
} }
Some(Err(e)) => { }
rsx! { }
div { class: "text-center text-red-500 dark:text-red-400 py-20", Some(Err(e)) => {
"加载失败: {e}" rsx! {
} div { class: "text-center text-red-500 dark:text-red-400 py-20",
} "加载失败: {e}"
} }
None => { }
rsx! { }
div { class: if show_skeleton() { "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] animate-pulse" } else { "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] opacity-0" }, _ => {
for _ in 0..5 { rsx! {
div { class: "flex items-center px-4 py-3 border-b border-gray-100 dark:border-[#333] last:border-0", div { class: "text-center text-gray-500 dark:text-[#9b9c9d] py-20",
div { class: "h-4 w-1/3 bg-gray-200 dark:bg-[#2a2a2a] rounded" } "加载中..."
div { class: "ml-auto h-4 w-16 bg-gray-200 dark:bg-[#2a2a2a] rounded" }
}
}
}
}
} }
} }
} }