feat(cache): add PostListItem DTO and use it in list/tag/search caches

This commit is contained in:
xfy 2026-06-17 15:51:19 +08:00
parent 31d5a99d2a
commit 170c021b37
11 changed files with 175 additions and 48 deletions

View File

@ -6,7 +6,7 @@
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::models::post::{Post, PostStatus}; use crate::models::post::{Post, PostListItem, PostStatus};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::utils::text::count_words; 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<String> = row
.try_get::<_, Vec<String>>("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,
}
}
/// 将数据库行转换为完整文章详情。 /// 将数据库行转换为完整文章详情。
/// ///
/// 相比列表项额外包含上一篇/下一篇导航, /// 相比列表项额外包含上一篇/下一篇导航,

View File

@ -8,7 +8,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")] #[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; use super::types::PostListResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
@ -81,8 +81,8 @@ pub async fn list_published_posts(
let limit = per_page as i64; let limit = per_page as i64;
let rows = client let rows = client
.query( .query(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, 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, 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 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p FROM posts p
@ -99,7 +99,7 @@ pub async fn list_published_posts(
let mut posts = Vec::new(); let mut posts = Vec::new();
for row in &rows { 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; 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<PostListResponse, Se
let limit = per_page as i64; let limit = per_page as i64;
let rows = client let rows = client
.query( .query(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, 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, 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 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p FROM posts p
@ -156,7 +156,7 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, Se
let mut posts = Vec::new(); let mut posts = Vec::new();
for row in &rows { for row in &rows {
posts.push(row_to_post_list(&client, row).await); posts.push(row_to_post_list_item(row));
} }
Ok(PostListResponse { posts, total }) Ok(PostListResponse { posts, total })
@ -197,8 +197,8 @@ pub async fn list_deleted_posts(
let limit = per_page as i64; let limit = per_page as i64;
let rows = client let rows = client
.query( .query(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, 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, p.deleted_at, p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, p.deleted_at,
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p FROM posts p
@ -215,7 +215,7 @@ pub async fn list_deleted_posts(
let mut posts = Vec::new(); let mut posts = Vec::new();
for row in &rows { for row in &rows {
posts.push(row_to_post_list(&client, row).await); posts.push(row_to_post_list_item(row));
} }
Ok(PostListResponse { posts, total }) Ok(PostListResponse { posts, total })
@ -250,8 +250,8 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
// 通过 JOIN 筛选含目标标签的已发布文章,并聚合该文章的所有标签。 // 通过 JOIN 筛选含目标标签的已发布文章,并聚合该文章的所有标签。
let rows = client let rows = client
.query( .query(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, 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, p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags COALESCE(array_agg(t2.name) FILTER (WHERE t2.name IS NOT NULL), '{}') as tags
FROM posts p FROM posts p
@ -270,7 +270,7 @@ pub async fn get_posts_by_tag(tag_name: String) -> Result<PostListResponse, Serv
let mut posts = Vec::new(); let mut posts = Vec::new();
for row in &rows { for row in &rows {
posts.push(row_to_post_list(&client, row).await); posts.push(row_to_post_list_item(row));
} }
// 当前查询未分页,返回全部匹配文章,因此 total 等于结果长度。 // 当前查询未分页,返回全部匹配文章,因此 total 等于结果长度。

View File

@ -8,7 +8,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use super::helpers::row_to_post_list; use super::helpers::row_to_post_list_item;
use super::types::PostListResponse; use super::types::PostListResponse;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::error::AppError; use crate::api::error::AppError;
@ -56,8 +56,8 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
// 使用 ILIKE 做前缀模糊匹配,并按 word_similarity 降序、发布时间降序排序。 // 使用 ILIKE 做前缀模糊匹配,并按 word_similarity 降序、发布时间降序排序。
let rows = client let rows = client
.query( .query(
"SELECT "SELECT
p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, 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, 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, COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags,
word_similarity(p.search_text, $2) AS sml word_similarity(p.search_text, $2) AS sml
@ -76,7 +76,7 @@ pub async fn search_posts(query: String) -> Result<PostListResponse, ServerFnErr
let mut posts = Vec::new(); let mut posts = Vec::new();
for row in &rows { for row in &rows {
posts.push(row_to_post_list(&client, row).await); posts.push(row_to_post_list_item(row));
} }
let total = posts.len() as i64; let total = posts.len() as i64;

View File

@ -1,6 +1,6 @@
//! 文章 API 的请求与响应数据结构。 //! 文章 API 的请求与响应数据结构。
use crate::models::post::{Post, PostStats, Tag}; use crate::models::post::{Post, PostListItem, PostStats, Tag};
/// 创建/更新/删除文章的统一响应结构。 /// 创建/更新/删除文章的统一响应结构。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -18,8 +18,8 @@ pub struct CreatePostResponse {
/// 文章列表响应。 /// 文章列表响应。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PostListResponse { pub struct PostListResponse {
/// 文章列表 /// 文章列表(轻量 DTO不含正文
pub posts: Vec<Post>, pub posts: Vec<PostListItem>,
/// 符合查询条件的总数。 /// 符合查询条件的总数。
pub total: i64, pub total: i64,
} }

View File

@ -14,7 +14,7 @@ use std::time::Duration;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::models::comment::PublicComment; use crate::models::comment::PublicComment;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::models::post::{Post, PostStats, Tag}; use crate::models::post::{Post, PostListItem, PostStats, Tag};
// ============================================================================ // ============================================================================
// 缓存 TTL 配置 // 缓存 TTL 配置
@ -80,7 +80,7 @@ pub enum CacheKey {
/// 文章列表缓存类型,值为(文章列表,总数)。 /// 文章列表缓存类型,值为(文章列表,总数)。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub type PostListCache = Cache<CacheKey, (Vec<Post>, i64)>; pub type PostListCache = Cache<CacheKey, (Vec<PostListItem>, i64)>;
/// 标签列表缓存类型。 /// 标签列表缓存类型。
#[cfg(feature = "server")] #[cfg(feature = "server")]
@ -167,13 +167,13 @@ static PENDING_COUNT_CACHE: LazyLock<Cache<CacheKey, i64>> = LazyLock::new(|| {
/// 读取文章分页列表缓存。 /// 读取文章分页列表缓存。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<Post>, i64)> { pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
POST_LIST_CACHE.get(key).await POST_LIST_CACHE.get(key).await
} }
/// 写入文章分页列表缓存。 /// 写入文章分页列表缓存。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn set_post_list(key: &CacheKey, posts: Vec<Post>, total: i64) { pub async fn set_post_list(key: &CacheKey, posts: Vec<PostListItem>, total: i64) {
let _ = POST_LIST_CACHE.insert(key.clone(), (posts, total)).await; 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<Post>) {
/// 按标签读取文章列表缓存。 /// 按标签读取文章列表缓存。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<Post>, i64)> { pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, i64)> {
TAG_POSTS_CACHE TAG_POSTS_CACHE
.get(&CacheKey::PostsByTag(tag.to_string())) .get(&CacheKey::PostsByTag(tag.to_string()))
.await .await
@ -232,7 +232,7 @@ pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<Post>, i64)> {
/// 按标签写入文章列表缓存。 /// 按标签写入文章列表缓存。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub async fn set_posts_by_tag(tag: &str, posts: Vec<Post>, total: i64) { pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
let _ = TAG_POSTS_CACHE let _ = TAG_POSTS_CACHE
.insert(CacheKey::PostsByTag(tag.to_string()), (posts, total)) .insert(CacheKey::PostsByTag(tag.to_string()), (posts, total))
.await; .await;
@ -379,15 +379,31 @@ mod tests {
page: 999, page: 999,
per_page: 99, 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; let cached = get_post_list(&key).await;
assert!(cached.is_some()); assert!(cached.is_some());
let (cached_posts, cached_total) = cached.unwrap(); let (cached_posts, cached_total) = cached.unwrap();
assert_eq!(cached_posts.len(), 0); assert_eq!(cached_posts.len(), 1);
assert_eq!(cached_total, 0); assert_eq!(cached_posts[0].title, "List Item");
assert_eq!(cached_total, 1);
} }
#[tokio::test] #[tokio::test]

View File

@ -6,7 +6,7 @@ use dioxus::prelude::*;
use dioxus::router::components::Link; use dioxus::router::components::Link;
use crate::components::image_viewer::ImageViewer; use crate::components::image_viewer::ImageViewer;
use crate::models::post::Post; use crate::models::post::PostListItem;
use crate::router::Route; use crate::router::Route;
/// 文章卡片组件。 /// 文章卡片组件。
@ -23,7 +23,7 @@ use crate::router::Route;
/// 关键事件: /// 关键事件:
/// - 点击标签时阻止事件冒泡,避免触发整卡跳转 /// - 点击标签时阻止事件冒泡,避免触发整卡跳转
#[component] #[component]
pub fn PostCard(post: Post) -> Element { pub fn PostCard(post: PostListItem) -> Element {
let post_slug = post.slug.clone(); let post_slug = post.slug.clone();
let date_str = post.formatted_date(); let date_str = post.formatted_date();
let has_cover = post.cover_image.is_some(); let has_cover = post.cover_image.is_some();

View File

@ -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<String>,
/// 文章发布状态。
pub status: PostStatus,
/// 正式发布时间None 表示尚未发布。
pub published_at: Option<DateTime<Utc>>,
/// 创建时间。
pub created_at: DateTime<Utc>,
/// 最后更新时间。
pub updated_at: DateTime<Utc>,
/// 软删除时间None 表示未删除。仅回收站查询填充。
pub deleted_at: Option<DateTime<Utc>>,
/// 关联标签列表。
pub tags: Vec<String>,
/// 封面图片 URL。
pub cover_image: Option<String>,
/// 预计阅读时间(分钟)。
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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PostNav { pub struct PostNav {

View File

@ -13,7 +13,7 @@ use crate::api::posts::{get_post_stats, list_posts};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::posts::{PostListResponse, PostStatsResponse}; use crate::api::posts::{PostListResponse, PostStatsResponse};
use crate::components::ui::ADMIN_CARD_CLASS; use crate::components::ui::ADMIN_CARD_CLASS;
use crate::models::post::{Post, PostStats}; use crate::models::post::{PostListItem, PostStats};
use crate::router::Route; use crate::router::Route;
/// 后台仪表盘页面组件。 /// 后台仪表盘页面组件。
@ -25,7 +25,7 @@ use crate::router::Route;
pub fn Admin() -> Element { pub fn Admin() -> Element {
// 仪表盘状态:统计数据、最近文章、待审核评论数与首次加载标志。 // 仪表盘状态:统计数据、最近文章、待审核评论数与首次加载标志。
let mut stats = use_signal(|| None::<PostStats>); let mut stats = use_signal(|| None::<PostStats>);
let mut recent_posts = use_signal(|| None::<Vec<Post>>); let mut recent_posts = use_signal(|| None::<Vec<PostListItem>>);
let mut pending_count = use_signal(|| None::<i64>); let mut pending_count = use_signal(|| None::<i64>);
let mut loaded = use_signal(|| false); let mut loaded = use_signal(|| false);
@ -165,7 +165,7 @@ fn StatCard(value: String, label: String) -> Element {
/// 最近文章列表项,显示标题、状态标签与发布日期。 /// 最近文章列表项,显示标题、状态标签与发布日期。
#[component] #[component]
fn RecentPostItem(post: Post) -> Element { fn RecentPostItem(post: PostListItem) -> Element {
let date_str = post.formatted_date(); let date_str = post.formatted_date();
let status_label = post.status_label(); let status_label = post.status_label();
let status_class = post.status_class(); let status_class = post.status_class();

View File

@ -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::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::posts_skeleton::PostsSkeleton; 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::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; use crate::router::Route;
/// 每页展示的文章数量。 /// 每页展示的文章数量。
@ -34,7 +34,7 @@ pub fn Posts() -> Element {
pub fn PostsPage(page: i32) -> Element { pub fn PostsPage(page: i32) -> Element {
let current_page = page.max(1); let current_page = page.max(1);
// 文章列表、总数、加载状态、删除中 ID、重建缓存状态与结果。 // 文章列表、总数、加载状态、删除中 ID、重建缓存状态与结果。
let mut posts = use_signal(Vec::new); let mut posts = use_signal(Vec::<PostListItem>::new);
let mut total = use_signal(|| 0_i64); let mut total = use_signal(|| 0_i64);
let mut loading = use_signal(|| true); let mut loading = use_signal(|| true);
let mut deleting = use_signal(|| None::<i32>); let mut deleting = use_signal(|| None::<i32>);
@ -96,7 +96,7 @@ pub fn PostsPage(page: i32) -> Element {
} }
}); });
let get_posts = move || -> Vec<Post> { posts() }; let get_posts = move || -> Vec<PostListItem> { posts() };
rsx! { rsx! {
div { class: "space-y-6", div { class: "space-y-6",
@ -215,7 +215,7 @@ pub fn PostsPage(page: i32) -> Element {
/// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。 /// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。
#[component] #[component]
fn PostRow(post: Post, deleting: bool, on_delete: EventHandler<i32>) -> Element { fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) -> Element {
let date_str = post.formatted_date(); let date_str = post.formatted_date();
rsx! { rsx! {

View File

@ -23,7 +23,7 @@ use crate::components::ui::{
EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN, EmptyState, Pagination, StatusBadge, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_SOLID_GREEN,
BTN_SOLID_RED, BTN_TEXT_ACCENT, BTN_TEXT_RED, CHECKBOX_CLASS, 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::models::settings::TrashSettings;
use crate::router::Route; use crate::router::Route;
@ -44,7 +44,7 @@ pub fn Trash() -> Element {
pub fn TrashPage(page: i32) -> Element { pub fn TrashPage(page: i32) -> Element {
let current_page = page.max(1); let current_page = page.max(1);
let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new); let mut selected_ids: Signal<HashSet<i32>> = use_signal(HashSet::new);
let mut posts: Signal<Vec<Post>> = use_signal(Vec::new); let mut posts: Signal<Vec<PostListItem>> = use_signal(Vec::new);
let mut total: Signal<i64> = use_signal(|| 0); let mut total: Signal<i64> = use_signal(|| 0);
#[allow(unused_mut)] #[allow(unused_mut)]
let mut loading: Signal<bool> = use_signal(|| false); let mut loading: Signal<bool> = 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")] #[cfg(target_arch = "wasm32")]
{ {
if let Some(deleted_at) = post.deleted_at { if let Some(deleted_at) = post.deleted_at {
@ -523,7 +523,7 @@ fn remaining_days(post: &Post, retention_days: i32) -> (i64, bool) {
/// 回收站表格行组件。 /// 回收站表格行组件。
#[component] #[component]
fn TrashRow( fn TrashRow(
post: Post, post: PostListItem,
retention_days: i32, retention_days: i32,
selected: bool, selected: bool,
on_select: EventHandler<bool>, on_select: EventHandler<bool>,

View File

@ -13,7 +13,7 @@ use dioxus::router::components::Link;
use crate::api::posts::{list_published_posts, PostListResponse}; use crate::api::posts::{list_published_posts, PostListResponse};
use crate::components::skeletons::archive_skeleton::ArchiveSkeleton; use crate::components::skeletons::archive_skeleton::ArchiveSkeleton;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::models::post::Post; use crate::models::post::PostListItem;
use crate::router::Route; use crate::router::Route;
/// 按年份分组的文章归档结构。 /// 按年份分组的文章归档结构。
@ -28,13 +28,13 @@ struct YearGroup {
struct MonthGroup { struct MonthGroup {
month: String, month: String,
month_en: String, month_en: String,
posts: Vec<Post>, posts: Vec<PostListItem>,
} }
/// 将文章列表按 `formatted_date()` 返回的 `YYYY-MM-DD` 格式进行年、月分组。 /// 将文章列表按 `formatted_date()` 返回的 `YYYY-MM-DD` 格式进行年、月分组。
/// ///
/// 返回的结果按原始文章顺序组织,调用前已按发布时间降序排列。 /// 返回的结果按原始文章顺序组织,调用前已按发布时间降序排列。
fn group_posts(posts: &[Post]) -> Vec<YearGroup> { fn group_posts(posts: &[PostListItem]) -> Vec<YearGroup> {
let mut years: Vec<YearGroup> = vec![]; let mut years: Vec<YearGroup> = vec![];
for post in posts { for post in posts {
@ -204,7 +204,7 @@ fn MonthSection(month_group: MonthGroup, year: String) -> Element {
/// 单条归档文章组件,展示标题与发布日期,并通过覆盖层链接到文章详情。 /// 单条归档文章组件,展示标题与发布日期,并通过覆盖层链接到文章详情。
#[component] #[component]
fn ArchiveEntry(post: Post) -> Element { fn ArchiveEntry(post: PostListItem) -> Element {
let date_str = post.formatted_date(); let date_str = post.formatted_date();
rsx! { rsx! {