Compare commits

...

2 Commits

Author SHA1 Message Date
xfy
9cf6a7e4e6 fix(admin): replace use_delayed_loading with DelayedSkeleton for posts page
Use the same skeleton pattern as Home/Archives/Search pages.
Skeleton shows immediately (static gray blocks) instead of opacity-0 blank,
eliminating white flash during fast loads.
2026-06-10 18:15:46 +08:00
xfy
265eb15887 feat(admin): add pagination to posts management page
- list_posts API now accepts page/per_page params with LIMIT/OFFSET
- Add /admin/posts/:page route with PostsPage component
- Pagination component with prev/next navigation (20 posts/page)
- Update dashboard to use new list_posts(1, 5) signature
2026-06-10 18:03:36 +08:00
7 changed files with 121 additions and 20 deletions

View File

@ -73,13 +73,27 @@ pub async fn list_published_posts(
}
#[server(ListPosts, "/api")]
pub async fn list_posts() -> Result<PostListResponse, ServerFnError> {
pub async fn list_posts(
page: i32,
per_page: i32,
) -> Result<PostListResponse, ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
let client = get_conn().await.map_err(AppError::db_conn)?;
let count_row = client
.query_one(
"SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL",
&[],
)
.await
.map_err(AppError::query)?;
let total: i64 = count_row.get(0);
let offset = ((page - 1).max(0) as i64) * (per_page as i64);
let limit = per_page as i64;
let rows = client
.query(
"SELECT
@ -91,8 +105,9 @@ pub async fn list_posts() -> Result<PostListResponse, ServerFnError> {
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.deleted_at IS NULL
GROUP BY p.id
ORDER BY p.created_at DESC",
&[],
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2",
&[&limit, &offset],
)
.await
.map_err(AppError::query)?;
@ -102,7 +117,6 @@ pub async fn list_posts() -> Result<PostListResponse, ServerFnError> {
posts.push(row_to_post_list(&client, row).await);
}
let total = posts.len() as i64;
Ok(PostListResponse { posts, total })
}

View File

@ -4,5 +4,6 @@ pub mod delayed_skeleton;
pub mod home_skeleton;
pub mod post_card_skeleton;
pub mod post_detail_skeleton;
pub mod posts_skeleton;
pub mod search_skeleton;
pub mod tags_skeleton;

View File

@ -0,0 +1,31 @@
use dioxus::prelude::*;
use crate::components::skeletons::atoms::SkeletonBox;
#[component]
pub fn PostsSkeleton() -> Element {
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]",
th { class: "px-4 py-3", SkeletonBox { class: "h-3 w-10" } }
th { class: "px-4 py-3 w-24", SkeletonBox { class: "h-3 w-10 mx-auto" } }
th { class: "px-4 py-3 w-32", SkeletonBox { class: "h-3 w-10" } }
th { class: "px-4 py-3 w-24", SkeletonBox { class: "h-3 w-10 ml-auto" } }
}
}
tbody {
for _ in 0..10 {
tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0",
td { class: "px-4 py-3", SkeletonBox { class: "h-4 w-1/3" } }
td { class: "px-4 py-3", SkeletonBox { class: "h-5 w-14 mx-auto rounded" } }
td { class: "px-4 py-3", SkeletonBox { class: "h-4 w-20" } }
td { class: "px-4 py-3", SkeletonBox { class: "h-4 w-12 ml-auto" } }
}
}
}
}
}
}
}

View File

@ -9,7 +9,7 @@ use crate::router::Route;
#[component]
pub fn Admin() -> Element {
let stats_res = use_resource(get_post_stats);
let posts_res = use_resource(list_posts);
let posts_res = use_resource(|| list_posts(1, 5));
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());

View File

@ -3,5 +3,5 @@ pub mod posts;
pub mod write;
pub use dashboard::Admin;
pub use posts::Posts;
pub use posts::{Posts, PostsPage};
pub use write::{Write, WriteEdit};

View File

@ -2,16 +2,30 @@ use dioxus::prelude::*;
use dioxus::router::components::Link;
use crate::api::posts::{delete_post, list_posts, CreatePostResponse, PostListResponse};
use crate::hooks::delayed_loading::use_delayed_loading;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
use crate::models::post::Post;
use crate::router::Route;
const POSTS_PER_PAGE: i32 = 20;
#[component]
#[allow(unused_variables)]
pub fn Posts() -> Element {
let mut posts_res = use_resource(list_posts);
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 deleting = use_signal(|| None::<i32>);
let show_skeleton = use_delayed_loading(move || posts_res.read().is_none());
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()),
});
rsx! {
div { class: "space-y-6",
@ -26,8 +40,8 @@ pub fn Posts() -> Element {
}
}
match &*posts_res.read() {
Some(Ok(PostListResponse { posts, total: _ })) => {
match posts_data {
Some(Ok((posts, total))) => {
if posts.is_empty() {
rsx! {
div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]",
@ -75,6 +89,7 @@ pub fn Posts() -> Element {
}
}
}
Pagination { current_page, total }
}
}
}
@ -87,14 +102,7 @@ pub fn Posts() -> Element {
}
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 {
div { class: "flex items-center px-4 py-3 border-b border-gray-100 dark:border-[#333] last:border-0",
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" }
}
}
}
DelayedSkeleton { PostsSkeleton {} }
}
}
}
@ -102,6 +110,51 @@ pub fn Posts() -> Element {
}
}
#[component]
fn Pagination(current_page: i32, total: i64) -> Element {
let has_prev = current_page > 1;
let total_pages = ((total + POSTS_PER_PAGE as i64 - 1) / POSTS_PER_PAGE as i64).max(1) as i32;
let has_next = current_page < total_pages;
rsx! {
nav { class: "flex mt-6 justify-between",
if has_prev {
Link {
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
to: if current_page - 1 <= 1 {
Route::Posts {}
} else {
Route::PostsPage { page: current_page - 1 }
},
span { class: "mr-1", "«" }
"上一页"
}
} else {
span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed",
span { class: "mr-1", "«" }
"上一页"
}
}
span { class: "text-sm text-gray-500 dark:text-[#9b9c9d] self-center",
"{current_page} / {total_pages} 页 (共 {total} 篇)"
}
if has_next {
Link {
class: "inline-flex items-center px-4 py-2 text-sm text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer",
to: Route::PostsPage { page: current_page + 1 },
"下一页"
span { class: "ml-1", "»" }
}
} else {
span { class: "inline-flex items-center px-4 py-2 text-sm text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full cursor-not-allowed",
"下一页"
span { class: "ml-1", "»" }
}
}
}
}
}
#[component]
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element {
let date_str = post.formatted_date();

View File

@ -5,7 +5,7 @@ use crate::components::admin_layout::AdminLayout;
use crate::components::frontend_layout::FrontendLayout;
use crate::context::UserContext;
use crate::pages::about::About;
use crate::pages::admin::{Admin, Posts, Write, WriteEdit};
use crate::pages::admin::{Admin, Posts, PostsPage, Write, WriteEdit};
use crate::pages::archives::Archives;
use crate::pages::home::{Home, HomePage};
use crate::pages::login::Login;
@ -50,6 +50,8 @@ pub enum Route {
WriteEdit { id: i32 },
#[route("/posts")]
Posts {},
#[route("/posts/:page")]
PostsPage { page: i32 },
#[end_layout]
#[end_nest]