Compare commits

..

10 Commits

Author SHA1 Message Date
xfy
a59a58b41d Add frontend-design skill, remove design-taste-frontend 2026-06-10 15:24:33 +08:00
xfy
5e2487a067 fix: gate helper imports with #[cfg(feature = "server")] in posts API
Server-only helper functions (get_current_admin_user, row_to_post_list,
row_to_post_full, sync_tags, clean_tags) are defined behind
#[cfg(feature = "server")] in helpers.rs, but their imports in consumer
files were not gated, causing WASM build failures.
2026-06-10 14:49:54 +08:00
xfy
311ddbe204 perf(cache): cache COUNT(*) result separately to avoid redundant queries
- Add TotalPublishedPosts cache key for reusing total count across pages
- list_published_posts now checks total cache before running COUNT(*)
- Add note to get_posts_by_tag about total = posts.len() assumption
- Remove unused invalidate_total_published_posts helper
2026-06-10 14:44:53 +08:00
xfy
bd9053132b refactor(admin): use explicit field ignores and separate error/loading arms 2026-06-10 14:38:52 +08:00
xfy
7df69be5e8 feat(skills): replace frontend-design with design-taste-frontend 2026-06-10 14:36:30 +08:00
xfy
31b83cd449 chore: adapt admin pages to new PostListResponse shape 2026-06-10 14:35:51 +08:00
xfy
a2ec043c59 chore: adapt search page to new PostListResponse shape 2026-06-10 14:30:57 +08:00
xfy
4015412b41 feat: display accurate total in tag detail page 2026-06-10 14:25:54 +08:00
xfy
2ade51bd0f feat: display accurate total in archives page 2026-06-10 14:22:18 +08:00
xfy
a2b0c759af feat: use total count for accurate home page pagination 2026-06-10 14:18:22 +08:00
15 changed files with 1264 additions and 23 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")]
use super::helpers::{clean_tags, get_current_admin_user, sync_tags}; use super::helpers::{clean_tags, get_current_admin_user, sync_tags};
use super::types::CreatePostResponse; use super::types::CreatePostResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]

View File

@ -1,5 +1,6 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")]
use super::helpers::get_current_admin_user; use super::helpers::get_current_admin_user;
use super::types::CreatePostResponse; use super::types::CreatePostResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]

View File

@ -1,5 +1,6 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")]
use super::helpers::{get_current_admin_user, row_to_post_list}; use super::helpers::{get_current_admin_user, row_to_post_list};
use super::types::PostListResponse; use super::types::PostListResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -20,15 +21,21 @@ pub async fn list_published_posts(
let client = get_conn().await.map_err(AppError::db_conn)?; let client = get_conn().await.map_err(AppError::db_conn)?;
// Get total count // Get total count from cache or query
let count_row = client let total = if let Some(cached_total) = crate::cache::get_total_published_posts().await {
.query_one( cached_total
"SELECT COUNT(*) FROM posts WHERE status = 'published' AND deleted_at IS NULL", } else {
&[], let count_row = client
) .query_one(
.await "SELECT COUNT(*) FROM posts WHERE status = 'published' AND deleted_at IS NULL",
.map_err(AppError::query)?; &[],
let total: i64 = count_row.get(0); )
.await
.map_err(AppError::query)?;
let total: i64 = count_row.get(0);
crate::cache::set_total_published_posts(total).await;
total
};
let offset = ((page - 1).max(0) as i64) * (per_page as i64); let offset = ((page - 1).max(0) as i64) * (per_page as i64);
let limit = per_page as i64; let limit = per_page as i64;
@ -139,6 +146,9 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
posts.push(row_to_post_list(&client, row).await); posts.push(row_to_post_list(&client, row).await);
} }
// NOTE: total = posts.len() is correct because get_posts_by_tag
// currently fetches ALL matching posts (no LIMIT/OFFSET).
// If pagination is added later, switch to a proper COUNT(*) query.
let total = posts.len() as i64; let total = posts.len() as i64;
crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await; crate::cache::set_posts_by_tag(&tag_name, posts.clone(), total).await;
Ok(PostListResponse { posts, total }) Ok(PostListResponse { posts, total })

View File

@ -1,5 +1,6 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")]
use super::helpers::{get_current_admin_user, row_to_post_full, row_to_post_list}; use super::helpers::{get_current_admin_user, row_to_post_full, row_to_post_list};
use super::types::SinglePostResponse; use super::types::SinglePostResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]

View File

@ -1,5 +1,6 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")]
use super::helpers::row_to_post_list; use super::helpers::row_to_post_list;
use super::types::PostListResponse; use super::types::PostListResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]

View File

@ -1,5 +1,6 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")]
use super::helpers::get_current_admin_user; use super::helpers::get_current_admin_user;
use super::types::PostStatsResponse; use super::types::PostStatsResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]

View File

@ -1,5 +1,6 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")]
use super::helpers::{clean_tags, get_current_admin_user, sync_tags}; use super::helpers::{clean_tags, get_current_admin_user, sync_tags};
use super::types::CreatePostResponse; use super::types::CreatePostResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]

View File

@ -31,6 +31,7 @@ const TTL_TAG_POSTS: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Hash, Eq, PartialEq)] #[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub enum CacheKey { pub enum CacheKey {
PublishedPosts { page: i32, per_page: i32 }, PublishedPosts { page: i32, per_page: i32 },
TotalPublishedPosts,
AllTags, AllTags,
PostBySlug(String), PostBySlug(String),
PostsByTag(String), PostsByTag(String),
@ -109,6 +110,16 @@ pub async fn set_post_list(key: &CacheKey, posts: Vec<Post>, total: i64) {
let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await; let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await;
} }
#[cfg(feature = "server")]
pub async fn get_total_published_posts() -> Option<i64> {
POST_LIST_CACHE.get(&CacheKey::TotalPublishedPosts).await.map(|(_, total)| total)
}
#[cfg(feature = "server")]
pub async fn set_total_published_posts(total: i64) {
let _ = POST_LIST_CACHE.insert(CacheKey::TotalPublishedPosts, (vec![], total)).await;
}
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn get_tag_list() -> Option<Vec<Tag>> { pub async fn get_tag_list() -> Option<Vec<Tag>> {
TAG_LIST_CACHE.get(&CacheKey::AllTags).await TAG_LIST_CACHE.get(&CacheKey::AllTags).await

View File

@ -55,7 +55,7 @@ pub fn Admin() -> Element {
"最近文章" "最近文章"
} }
match &*posts_res.read() { match &*posts_res.read() {
Some(Ok(PostListResponse { posts })) => { Some(Ok(PostListResponse { posts, total: _ })) => {
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) {
@ -64,7 +64,14 @@ pub fn Admin() -> Element {
} }
} }
} }
_ => { Some(Err(_e)) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败"
}
}
}
None => {
rsx! { rsx! {
div { class: if show_posts_skeleton() { "space-y-4 animate-pulse" } else { "space-y-4 opacity-0" }, div { class: if show_posts_skeleton() { "space-y-4 animate-pulse" } else { "space-y-4 opacity-0" },
for _ in 0..5 { for _ in 0..5 {

View File

@ -27,7 +27,7 @@ pub fn Posts() -> Element {
} }
match &*posts_res.read() { match &*posts_res.read() {
Some(Ok(PostListResponse { posts })) => { Some(Ok(PostListResponse { posts, total: _ })) => {
if posts.is_empty() { if posts.is_empty() {
rsx! { 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]",

View File

@ -95,12 +95,12 @@ fn ArchivesContent() -> Element {
let posts_data = posts_res.read(); let posts_data = posts_res.read();
match &*posts_data { match &*posts_data {
Some(Ok(PostListResponse { posts })) => { Some(Ok(PostListResponse { posts, total })) => {
let grouped = group_posts(posts); let grouped = group_posts(posts);
rsx! { rsx! {
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]", div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
"" ""
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{posts.len()}" } span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{total}" }
" 篇文章" " 篇文章"
} }
for year_group in grouped.iter() { for year_group in grouped.iter() {

View File

@ -29,12 +29,12 @@ fn HomePosts(current_page: i32) -> Element {
let posts_res = use_server_future(move || list_published_posts(current_page, POSTS_PER_PAGE))?; let posts_res = use_server_future(move || list_published_posts(current_page, POSTS_PER_PAGE))?;
let posts_data = posts_res.read().as_ref().map(|r| match r { let posts_data = posts_res.read().as_ref().map(|r| match r {
Ok(PostListResponse { posts }) => Ok(posts.clone()), Ok(PostListResponse { posts, total }) => Ok((posts.clone(), *total)),
Err(e) => Err(e.to_string()), Err(e) => Err(e.to_string()),
}); });
match posts_data { match posts_data {
Some(Ok(posts)) => { Some(Ok((posts, total))) => {
rsx! { rsx! {
for post in posts.iter() { for post in posts.iter() {
PostCard { post: post.clone() } PostCard { post: post.clone() }
@ -44,7 +44,7 @@ fn HomePosts(current_page: i32) -> Element {
"暂无文章" "暂无文章"
} }
} }
Pagination { current_page, posts: posts.clone() } Pagination { current_page, total }
} }
} }
Some(Err(e)) => { Some(Err(e)) => {
@ -77,9 +77,10 @@ fn HomeInfo() -> Element {
} }
#[component] #[component]
fn Pagination(current_page: i32, posts: Vec<crate::models::post::Post>) -> Element { fn Pagination(current_page: i32, total: i64) -> Element {
let has_prev = current_page > 1; let has_prev = current_page > 1;
let has_next = posts.len() >= POSTS_PER_PAGE as usize; 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;
let prev = current_page - 1; let prev = current_page - 1;
let prev_route = if prev <= 1 { let prev_route = if prev <= 1 {
Route::Home {} Route::Home {}

View File

@ -49,7 +49,7 @@ pub fn Search() -> Element {
} }
if is_searching() { if is_searching() {
DelayedSkeleton { SearchSkeleton {} } DelayedSkeleton { SearchSkeleton {} }
} else if let Some(Ok(PostListResponse { posts })) = search_res() { } else if let Some(Ok(PostListResponse { posts, total: _ })) = search_res() {
if posts.is_empty() { if posts.is_empty() {
div { class: "text-center text-gray-500 dark:text-[#9b9c9d] py-20", div { class: "text-center text-gray-500 dark:text-[#9b9c9d] py-20",
"未找到相关文章" "未找到相关文章"

View File

@ -85,16 +85,16 @@ fn TagDetailContent(tag: String) -> Element {
let posts_res = use_server_future(move || get_posts_by_tag(tag.clone()))?; let posts_res = use_server_future(move || get_posts_by_tag(tag.clone()))?;
let posts_data = posts_res.read().as_ref().map(|r| match r { let posts_data = posts_res.read().as_ref().map(|r| match r {
Ok(PostListResponse { posts }) => Ok(posts.clone()), Ok(PostListResponse { posts, total }) => Ok((posts.clone(), *total)),
Err(e) => Err(e.to_string()), Err(e) => Err(e.to_string()),
}); });
match posts_data { match posts_data {
Some(Ok(posts)) => { Some(Ok((posts, total))) => {
rsx! { rsx! {
div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]", div { class: "mt-2 text-base text-gray-500 dark:text-[#9b9c9d]",
"" ""
span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{posts.len()}" } span { class: "font-medium text-gray-700 dark:text-[#dadadb]", "{total}" }
" 篇文章" " 篇文章"
} }
for post in posts.iter() { for post in posts.iter() {