diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs index 985ee6a..71b1143 100644 --- a/src/api/posts/helpers.rs +++ b/src/api/posts/helpers.rs @@ -6,7 +6,7 @@ #[cfg(feature = "server")] use crate::api::error::AppError; #[cfg(feature = "server")] -use crate::models::post::{Post, PostStatus}; +use crate::models::post::{Post, PostListItem, PostStatus}; #[cfg(feature = "server")] use crate::utils::text::count_words; @@ -61,6 +61,46 @@ pub(super) async fn row_to_post_list( } } +/// 将数据库行转换为轻量列表项 DTO。 +/// +/// 不包含 `content_md`/`content_html`,但会临时读取 `content_md` 以计算字数与阅读时长。 +/// 同步函数,不依赖数据库连接。 +#[cfg(feature = "server")] +pub(super) fn row_to_post_list_item(row: &tokio_postgres::Row) -> PostListItem { + let id: i32 = row.get("id"); + let status_str: String = row.get("status"); + let status = PostStatus::from_str(&status_str).unwrap_or(PostStatus::Draft); + + // 聚合标签并过滤空字符串。 + let tags: Vec = row + .try_get::<_, Vec>("tags") + .unwrap_or_default() + .into_iter() + .filter(|t| !t.is_empty()) + .collect(); + + // 临时读取 content_md 计算字数与阅读时长,不存入 DTO。 + let content_md: String = row.get("content_md"); + let word_count = count_words(&content_md); + + PostListItem { + id, + author_id: row.get("author_id"), + title: row.get("title"), + slug: row.get("slug"), + summary: row.get("summary"), + status, + published_at: row.get("published_at"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + deleted_at: row.try_get("deleted_at").ok(), + tags, + cover_image: row.get("cover_image"), + reading_time: (word_count / 200).max(1), + word_count, + } +} + /// 将数据库行转换为完整文章详情。 /// /// 相比列表项额外包含上一篇/下一篇导航, diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index 07b5058..8ffef87 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -8,7 +8,7 @@ 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_item}; use super::types::PostListResponse; #[cfg(feature = "server")] use crate::api::error::AppError; @@ -81,8 +81,8 @@ pub async fn list_published_posts( let limit = per_page as i64; let rows = client .query( - "SELECT - p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, + "SELECT + p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags FROM posts p @@ -99,7 +99,7 @@ pub async fn list_published_posts( let mut posts = Vec::new(); for row in &rows { - posts.push(row_to_post_list(&client, row).await); + posts.push(row_to_post_list_item(row)); } crate::cache::set_post_list(&cache_key, posts.clone(), total).await; @@ -138,8 +138,8 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result Result Result Result Result Result, + /// 文章列表(轻量 DTO,不含正文)。 + pub posts: Vec, /// 符合查询条件的总数。 pub total: i64, } diff --git a/src/cache.rs b/src/cache.rs index dd0bea7..86b3430 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -14,7 +14,7 @@ use std::time::Duration; #[cfg(feature = "server")] use crate::models::comment::PublicComment; #[cfg(feature = "server")] -use crate::models::post::{Post, PostStats, Tag}; +use crate::models::post::{Post, PostListItem, PostStats, Tag}; // ============================================================================ // 缓存 TTL 配置 @@ -80,7 +80,7 @@ pub enum CacheKey { /// 文章列表缓存类型,值为(文章列表,总数)。 #[cfg(feature = "server")] -pub type PostListCache = Cache, i64)>; +pub type PostListCache = Cache, i64)>; /// 标签列表缓存类型。 #[cfg(feature = "server")] @@ -167,13 +167,13 @@ static PENDING_COUNT_CACHE: LazyLock> = LazyLock::new(|| { /// 读取文章分页列表缓存。 #[cfg(feature = "server")] -pub async fn get_post_list(key: &CacheKey) -> Option<(Vec, i64)> { +pub async fn get_post_list(key: &CacheKey) -> Option<(Vec, i64)> { POST_LIST_CACHE.get(key).await } /// 写入文章分页列表缓存。 #[cfg(feature = "server")] -pub async fn set_post_list(key: &CacheKey, posts: Vec, total: i64) { +pub async fn set_post_list(key: &CacheKey, posts: Vec, total: i64) { let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await; } @@ -224,7 +224,7 @@ pub async fn set_post_by_slug(slug: &str, post: Option) { /// 按标签读取文章列表缓存。 #[cfg(feature = "server")] -pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec, i64)> { +pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec, i64)> { TAG_POSTS_CACHE .get(&CacheKey::PostsByTag(tag.to_string())) .await @@ -232,7 +232,7 @@ pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec, i64)> { /// 按标签写入文章列表缓存。 #[cfg(feature = "server")] -pub async fn set_posts_by_tag(tag: &str, posts: Vec, total: i64) { +pub async fn set_posts_by_tag(tag: &str, posts: Vec, total: i64) { let _ = TAG_POSTS_CACHE .insert(CacheKey::PostsByTag(tag.to_string()), (posts, total)) .await; @@ -379,15 +379,31 @@ mod tests { page: 999, per_page: 99, }; - let posts = vec![]; + let posts = vec![PostListItem { + id: 1, + author_id: 1, + title: "List Item".to_string(), + slug: "list-item".to_string(), + summary: None, + status: PostStatus::Published, + published_at: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + deleted_at: None, + tags: vec!["rust".to_string()], + cover_image: None, + reading_time: 1, + word_count: 10, + }]; - set_post_list(&key, posts.clone(), 0).await; + set_post_list(&key, posts.clone(), 1).await; let cached = get_post_list(&key).await; assert!(cached.is_some()); let (cached_posts, cached_total) = cached.unwrap(); - assert_eq!(cached_posts.len(), 0); - assert_eq!(cached_total, 0); + assert_eq!(cached_posts.len(), 1); + assert_eq!(cached_posts[0].title, "List Item"); + assert_eq!(cached_total, 1); } #[tokio::test] diff --git a/src/components/post_card.rs b/src/components/post_card.rs index 452d204..91de51b 100644 --- a/src/components/post_card.rs +++ b/src/components/post_card.rs @@ -6,7 +6,7 @@ use dioxus::prelude::*; use dioxus::router::components::Link; use crate::components::image_viewer::ImageViewer; -use crate::models::post::Post; +use crate::models::post::PostListItem; use crate::router::Route; /// 文章卡片组件。 @@ -23,7 +23,7 @@ use crate::router::Route; /// 关键事件: /// - 点击标签时阻止事件冒泡,避免触发整卡跳转 #[component] -pub fn PostCard(post: Post) -> Element { +pub fn PostCard(post: PostListItem) -> Element { let post_slug = post.slug.clone(); let date_str = post.formatted_date(); let has_cover = post.cover_image.is_some(); diff --git a/src/models/post.rs b/src/models/post.rs index 265da79..c3f4721 100644 --- a/src/models/post.rs +++ b/src/models/post.rs @@ -113,6 +113,77 @@ impl Post { } } +/// 文章列表项 DTO。 +/// +/// 仅包含列表/标签/搜索/归档等场景需要的字段,不含 `content_md` 与 `content_html`, +/// 以降低缓存内存占用与序列化体积。`deleted_at` 保留,供回收站列表使用。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PostListItem { + /// 文章主键。 + pub id: i32, + /// 作者用户主键。 + pub author_id: i32, + /// 文章标题。 + pub title: String, + /// URL slug,用于生成文章链接。 + pub slug: String, + /// 摘要,可选。 + pub summary: Option, + /// 文章发布状态。 + pub status: PostStatus, + /// 正式发布时间,None 表示尚未发布。 + pub published_at: Option>, + /// 创建时间。 + pub created_at: DateTime, + /// 最后更新时间。 + pub updated_at: DateTime, + /// 软删除时间,None 表示未删除。仅回收站查询填充。 + pub deleted_at: Option>, + /// 关联标签列表。 + pub tags: Vec, + /// 封面图片 URL。 + pub cover_image: Option, + /// 预计阅读时间(分钟)。 + pub reading_time: u32, + /// 字数统计。 + pub word_count: u32, +} + +impl PostListItem { + /// 返回用于展示的文章日期:优先使用发布时间,否则回退到创建时间。 + pub fn formatted_date(&self) -> String { + self.published_at + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| self.created_at.format("%Y-%m-%d").to_string()) + } + + /// 返回中文状态标签。 + pub fn status_label(&self) -> &'static str { + match self.status { + PostStatus::Published => "已发布", + PostStatus::Draft => "草稿", + } + } + + /// 返回状态文本在 light/dark 模式下的 Tailwind 颜色类。 + pub fn status_class(&self) -> &'static str { + match self.status { + PostStatus::Published => "text-green-600 dark:text-green-400", + PostStatus::Draft => "text-gray-400 dark:text-[#9b9c9d]", + } + } + + /// 返回状态徽章在 light/dark 模式下的 Tailwind 背景与颜色类。 + pub fn status_badge_class(&self) -> &'static str { + match self.status { + PostStatus::Published => { + "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300" + } + PostStatus::Draft => "bg-gray-100 dark:bg-[#333] text-gray-600 dark:text-[#9b9c9d]", + } + } +} + /// 前后文章导航结构体。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PostNav { diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index 2fe339a..87f848e 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -13,7 +13,7 @@ use crate::api::posts::{get_post_stats, list_posts}; #[cfg(target_arch = "wasm32")] use crate::api::posts::{PostListResponse, PostStatsResponse}; use crate::components::ui::ADMIN_CARD_CLASS; -use crate::models::post::{Post, PostStats}; +use crate::models::post::{PostListItem, PostStats}; use crate::router::Route; /// 后台仪表盘页面组件。 @@ -25,7 +25,7 @@ use crate::router::Route; pub fn Admin() -> Element { // 仪表盘状态:统计数据、最近文章、待审核评论数与首次加载标志。 let mut stats = use_signal(|| None::); - let mut recent_posts = use_signal(|| None::>); + let mut recent_posts = use_signal(|| None::>); let mut pending_count = use_signal(|| None::); let mut loaded = use_signal(|| false); @@ -165,7 +165,7 @@ fn StatCard(value: String, label: String) -> Element { /// 最近文章列表项,显示标题、状态标签与发布日期。 #[component] -fn RecentPostItem(post: Post) -> Element { +fn RecentPostItem(post: PostListItem) -> Element { let date_str = post.formatted_date(); let status_label = post.status_label(); let status_class = post.status_class(); diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index f87deee..8ae0061 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -15,7 +15,7 @@ use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, R use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::posts_skeleton::PostsSkeleton; use crate::components::ui::{EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_TEXT_RED}; -use crate::models::post::Post; +use crate::models::post::PostListItem; use crate::router::Route; /// 每页展示的文章数量。 @@ -34,7 +34,7 @@ pub fn Posts() -> Element { pub fn PostsPage(page: i32) -> Element { let current_page = page.max(1); // 文章列表、总数、加载状态、删除中 ID、重建缓存状态与结果。 - let mut posts = use_signal(Vec::new); + 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::); @@ -96,7 +96,7 @@ pub fn PostsPage(page: i32) -> Element { } }); - let get_posts = move || -> Vec { posts() }; + let get_posts = move || -> Vec { posts() }; rsx! { div { class: "space-y-6", @@ -215,7 +215,7 @@ pub fn PostsPage(page: i32) -> Element { /// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。 #[component] -fn PostRow(post: Post, deleting: bool, on_delete: EventHandler) -> Element { +fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler) -> Element { let date_str = post.formatted_date(); rsx! { diff --git a/src/pages/admin/trash.rs b/src/pages/admin/trash.rs index f922d39..3d4c075 100644 --- a/src/pages/admin/trash.rs +++ b/src/pages/admin/trash.rs @@ -23,7 +23,7 @@ use crate::components::ui::{ EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN, BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS, }; -use crate::models::post::Post; +use crate::models::post::PostListItem; use crate::models::settings::TrashSettings; use crate::router::Route; @@ -44,7 +44,7 @@ pub fn Trash() -> Element { pub fn TrashPage(page: i32) -> Element { let current_page = page.max(1); let mut selected_ids: Signal> = use_signal(HashSet::new); - let mut posts: Signal> = use_signal(Vec::new); + let mut posts: Signal> = use_signal(Vec::new); let mut total: Signal = use_signal(|| 0); #[allow(unused_mut)] let mut loading: Signal = use_signal(|| false); @@ -500,7 +500,7 @@ pub fn TrashPage(page: i32) -> Element { /// 计算剩余天数(保留期 - 已删除天数)。 /// /// 返回 (剩余天数, 是否已过期)。基于客户端时钟计算,轻微漂移可接受。 -fn remaining_days(post: &Post, retention_days: i32) -> (i64, bool) { +fn remaining_days(post: &PostListItem, retention_days: i32) -> (i64, bool) { #[cfg(target_arch = "wasm32")] { if let Some(deleted_at) = post.deleted_at { @@ -523,7 +523,7 @@ fn remaining_days(post: &Post, retention_days: i32) -> (i64, bool) { /// 回收站表格行组件。 #[component] fn TrashRow( - post: Post, + post: PostListItem, retention_days: i32, selected: bool, on_select: EventHandler, diff --git a/src/pages/archives.rs b/src/pages/archives.rs index 126176f..89be690 100644 --- a/src/pages/archives.rs +++ b/src/pages/archives.rs @@ -13,7 +13,7 @@ use dioxus::router::components::Link; use crate::api::posts::{list_published_posts, PostListResponse}; use crate::components::skeletons::archive_skeleton::ArchiveSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; -use crate::models::post::Post; +use crate::models::post::PostListItem; use crate::router::Route; /// 按年份分组的文章归档结构。 @@ -28,13 +28,13 @@ struct YearGroup { struct MonthGroup { month: String, month_en: String, - posts: Vec, + posts: Vec, } /// 将文章列表按 `formatted_date()` 返回的 `YYYY-MM-DD` 格式进行年、月分组。 /// /// 返回的结果按原始文章顺序组织,调用前已按发布时间降序排列。 -fn group_posts(posts: &[Post]) -> Vec { +fn group_posts(posts: &[PostListItem]) -> Vec { let mut years: Vec = vec![]; for post in posts { @@ -204,7 +204,7 @@ fn MonthSection(month_group: MonthGroup, year: String) -> Element { /// 单条归档文章组件,展示标题与发布日期,并通过覆盖层链接到文章详情。 #[component] -fn ArchiveEntry(post: Post) -> Element { +fn ArchiveEntry(post: PostListItem) -> Element { let date_str = post.formatted_date(); rsx! {