From 959cc1fb0e5c9ce8ff1af77f105b5ed53f86fe02 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 12:28:29 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat(db):=20=E6=96=B0=E5=A2=9E=20settings?= =?UTF-8?q?=20=E9=94=AE=E5=80=BC=E8=A1=A8=E4=B8=8E=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E7=AB=99=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/006_settings.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 migrations/006_settings.sql diff --git a/migrations/006_settings.sql b/migrations/006_settings.sql new file mode 100644 index 0000000..ac6bd27 --- /dev/null +++ b/migrations/006_settings.sql @@ -0,0 +1,14 @@ +-- 回收站与站点配置键值表。 +-- 采用简单键值结构而非列式配置,便于后续扩展更多设置项。 +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 回填回收站默认配置:自动清理默认关闭,保留期 30 天。 +-- ON CONFLICT 保证重复执行迁移安全。 +INSERT INTO settings (key, value) VALUES + ('trash_auto_purge_enabled', 'false'), + ('trash_retention_days', '30') +ON CONFLICT (key) DO NOTHING; From 054d7450f7c7a2af3b5b4f475fe2ed6c68f2f0d0 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 12:29:37 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(model):=20Post=20=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?deleted=5Fat=20=E5=AD=97=E6=AE=B5=E5=B9=B6=E7=94=A8=20try=5Fget?= =?UTF-8?q?=20=E5=85=BC=E5=AE=B9=E7=8E=B0=E6=9C=89=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/posts/helpers.rs | 2 ++ src/cache.rs | 2 ++ src/models/post.rs | 3 +++ 3 files changed, 7 insertions(+) diff --git a/src/api/posts/helpers.rs b/src/api/posts/helpers.rs index 0ef1248..23aefb0 100644 --- a/src/api/posts/helpers.rs +++ b/src/api/posts/helpers.rs @@ -50,6 +50,7 @@ pub(super) async fn row_to_post_list( 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), @@ -143,6 +144,7 @@ pub(super) async fn row_to_post_full( 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), diff --git a/src/cache.rs b/src/cache.rs index 77cd385..af4c648 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -473,6 +473,7 @@ mod tests { published_at: None, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), + deleted_at: None, tags: vec![], cover_image: None, reading_time: 1, @@ -520,6 +521,7 @@ mod tests { published_at: None, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), + deleted_at: None, tags: vec![], cover_image: None, reading_time: 1, diff --git a/src/models/post.rs b/src/models/post.rs index 42b0302..b72933b 100644 --- a/src/models/post.rs +++ b/src/models/post.rs @@ -61,6 +61,8 @@ pub struct Post { pub created_at: DateTime, /// 最后更新时间。 pub updated_at: DateTime, + /// 软删除时间,None 表示未删除。仅回收站查询填充。 + pub deleted_at: Option>, /// 关联标签列表。 pub tags: Vec, /// 封面图片 URL。 @@ -161,6 +163,7 @@ mod tests { published_at: None, created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(), updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(), + deleted_at: None, tags: vec![], cover_image: None, reading_time: 1, From 65e090da27c6c46645f809b6092ae8a51fd59a89 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 12:31:55 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(api):=20=E6=96=B0=E5=A2=9E=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E7=AB=99=E6=81=A2=E5=A4=8D/=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E5=88=A0=E9=99=A4/=E6=89=B9=E9=87=8F/=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/posts/mod.rs | 4 + src/api/posts/trash.rs | 286 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 src/api/posts/trash.rs diff --git a/src/api/posts/mod.rs b/src/api/posts/mod.rs index f261514..a998ed3 100644 --- a/src/api/posts/mod.rs +++ b/src/api/posts/mod.rs @@ -16,6 +16,7 @@ mod stats; mod tags; mod types; mod update; +mod trash; /// 创建新文章。 #[allow(unused_imports)] @@ -44,6 +45,9 @@ pub use types::*; /// 更新指定文章。 #[allow(unused_imports)] pub use update::update_post; +/// 恢复已删除文章。 +#[allow(unused_imports)] +pub use trash::{batch_purge_posts, batch_restore_posts, empty_trash, purge_post, restore_post}; /// 将 Markdown 渲染为增强 HTML(含目录)。 #[cfg(feature = "server")] diff --git a/src/api/posts/trash.rs b/src/api/posts/trash.rs new file mode 100644 index 0000000..b81fc6f --- /dev/null +++ b/src/api/posts/trash.rs @@ -0,0 +1,286 @@ +//! 回收站操作接口:恢复、彻底删除、批量操作与一键清空。 +//! +//! 所有接口需要 admin 权限,操作后清空全部文章相关缓存。 +//! Dioxus server function,注册在 `/api` 路径下。 +//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作。 + +use dioxus::prelude::*; + +#[cfg(feature = "server")] +use super::helpers::get_current_admin_user; +use super::types::CreatePostResponse; +#[cfg(feature = "server")] +use crate::api::error::AppError; +#[cfg(feature = "server")] +use crate::api::slug::ensure_unique_slug; +#[cfg(feature = "server")] +use crate::db::pool::get_conn; + +/// 恢复一篇已删除的文章(将 deleted_at 置空)。 +/// +/// 若该文章原始 slug 已被其他未删除文章占用,自动追加数字后缀。 +#[server(RestorePost, "/api")] +pub async fn restore_post(post_id: i32) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + // 读取待恢复文章的当前 slug 与是否确已删除。 + let row = client + .query_opt( + "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + + let Some(row) = row else { + return Ok(CreatePostResponse { + success: false, + message: "文章不在回收站".to_string(), + post_id: None, + slug: None, + }); + }; + + let current_slug: String = row.get("slug"); + + // 恢复时确保 slug 在未删除文章中唯一(自动加后缀)。 + let new_slug = ensure_unique_slug(&client, ¤t_slug, Some(post_id)).await?; + + // 置空 deleted_at,并更新 slug(可能已加后缀)。 + let result = client + .execute( + "UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL", + &[&new_slug, &post_id], + ) + .await + .map_err(AppError::query)?; + + if result == 0 { + return Ok(CreatePostResponse { + success: false, + message: "文章不在回收站".to_string(), + post_id: None, + slug: None, + }); + } + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: "恢复成功".to_string(), + post_id: Some(post_id), + slug: Some(new_slug), + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} + +/// 彻底删除一篇已删除的文章(物理删除,不可恢复)。 +/// +/// 注意:仅删除数据库记录,不删除已上传的图片文件。 +/// post_tags 关联因外键 ON DELETE CASCADE 自动清理。 +#[server(PurgePost, "/api")] +pub async fn purge_post(post_id: i32) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + let result = client + .execute( + "DELETE FROM posts WHERE id = $1 AND deleted_at IS NOT NULL", + &[&post_id], + ) + .await + .map_err(AppError::query)?; + + if result == 0 { + return Ok(CreatePostResponse { + success: false, + message: "文章不在回收站".to_string(), + post_id: None, + slug: None, + }); + } + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: "彻底删除成功".to_string(), + post_id: Some(post_id), + slug: None, + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} + +/// 批量恢复文章。 +#[server(BatchRestorePosts, "/api")] +pub async fn batch_restore_posts(post_ids: Vec) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + if post_ids.is_empty() { + return Ok(CreatePostResponse { + success: true, + message: "无操作".to_string(), + post_id: None, + slug: None, + }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + // 逐条恢复,slug 冲突时自动加后缀。 + let mut restored = 0u64; + for id in &post_ids { + let row = client + .query_opt( + "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NOT NULL", + &[&id], + ) + .await + .map_err(AppError::query)?; + if let Some(row) = row { + let current_slug: String = row.get("slug"); + let new_slug = ensure_unique_slug(&client, ¤t_slug, Some(*id)).await?; + let n = client + .execute( + "UPDATE posts SET deleted_at = NULL, slug = $1 WHERE id = $2 AND deleted_at IS NOT NULL", + &[&new_slug, &id], + ) + .await + .map_err(AppError::query)?; + restored += n; + } + } + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: format!("已恢复 {restored} 篇"), + post_id: None, + slug: None, + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} + +/// 批量彻底删除文章。 +#[server(BatchPurgePosts, "/api")] +pub async fn batch_purge_posts(post_ids: Vec) -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + if post_ids.is_empty() { + return Ok(CreatePostResponse { + success: true, + message: "无操作".to_string(), + post_id: None, + slug: None, + }); + } + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let total = post_ids.len() as i64; + let result = client + .execute( + "DELETE FROM posts WHERE id = ANY($1) AND deleted_at IS NOT NULL", + &[&post_ids], + ) + .await + .map_err(AppError::query)?; + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: format!("已彻底删除 {result}/{total} 篇"), + post_id: None, + slug: None, + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} + +/// 清空回收站:彻底删除所有已软删除的文章。 +#[server(EmptyTrash, "/api")] +pub async fn empty_trash() -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + let result = client + .execute("DELETE FROM posts WHERE deleted_at IS NOT NULL", &[]) + .await + .map_err(AppError::query)?; + + crate::cache::invalidate_all_post_caches(); + + Ok(CreatePostResponse { + success: true, + message: format!("已清空回收站({result} 篇)"), + post_id: None, + slug: None, + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(CreatePostResponse { + success: false, + message: "server only".to_string(), + post_id: None, + slug: None, + }) + } +} From c4c27a34c50a191f523e2e496308748ba23decd7 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 12:32:37 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat(model):=20=E6=96=B0=E5=A2=9E=20TrashSe?= =?UTF-8?q?ttings=20=E9=85=8D=E7=BD=AE=E6=A8=A1=E5=9E=8B=E4=B8=8E=20clamp?= =?UTF-8?q?=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/models/mod.rs | 2 ++ src/models/settings.rs | 71 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/models/settings.rs diff --git a/src/models/mod.rs b/src/models/mod.rs index 4d54b91..fa0c7e8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -9,3 +9,5 @@ pub mod comment; pub mod post; /// 用户模型、用户角色与可公开用户信息。 pub mod user; +/// 回收站与站点配置模型。 +pub mod settings; diff --git a/src/models/settings.rs b/src/models/settings.rs new file mode 100644 index 0000000..d04a417 --- /dev/null +++ b/src/models/settings.rs @@ -0,0 +1,71 @@ +//! 回收站与站点配置模型。 + +/// 默认保留天数(天)。 +pub const DEFAULT_RETENTION_DAYS: i32 = 30; +/// 默认不启用自动清理。 +pub const DEFAULT_AUTO_PURGE_ENABLED: bool = false; +/// 保留天数下限(天)。 +pub const MIN_RETENTION_DAYS: i32 = 1; +/// 保留天数上限(天)。防止误填超大值导致永不清理。 +pub const MAX_RETENTION_DAYS: i32 = 365; + +/// 回收站配置。 +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct TrashSettings { + /// 是否启用自动定时清理。 + pub auto_purge_enabled: bool, + /// 已删除文章保留天数,超过后被后台任务物理删除。 + pub retention_days: i32, +} + +impl Default for TrashSettings { + fn default() -> Self { + Self { + auto_purge_enabled: DEFAULT_AUTO_PURGE_ENABLED, + retention_days: DEFAULT_RETENTION_DAYS, + } + } +} + +impl TrashSettings { + /// 将保留天数钳制到合法范围 [MIN, MAX]。 + pub fn clamp_retention(days: i32) -> i32 { + days.clamp(MIN_RETENTION_DAYS, MAX_RETENTION_DAYS) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_disabled_30_days() { + let s = TrashSettings::default(); + assert!(!s.auto_purge_enabled); + assert_eq!(s.retention_days, 30); + } + + #[test] + fn clamp_retention_keeps_valid() { + assert_eq!(TrashSettings::clamp_retention(7), 7); + assert_eq!(TrashSettings::clamp_retention(30), 30); + } + + #[test] + fn clamp_retention_clamps_below_min() { + assert_eq!(TrashSettings::clamp_retention(0), MIN_RETENTION_DAYS); + assert_eq!(TrashSettings::clamp_retention(-5), MIN_RETENTION_DAYS); + } + + #[test] + fn clamp_retention_clamps_above_max() { + assert_eq!(TrashSettings::clamp_retention(366), MAX_RETENTION_DAYS); + assert_eq!(TrashSettings::clamp_retention(i32::MAX), MAX_RETENTION_DAYS); + } + + #[test] + fn clamp_retention_boundary() { + assert_eq!(TrashSettings::clamp_retention(MIN_RETENTION_DAYS), MIN_RETENTION_DAYS); + assert_eq!(TrashSettings::clamp_retention(MAX_RETENTION_DAYS), MAX_RETENTION_DAYS); + } +} From b6de75ee97f65589220d232195ba2e9fd101dda2 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 12:34:28 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat(api):=20=E6=96=B0=E5=A2=9E=20list=5Fde?= =?UTF-8?q?leted=5Fposts=20=E5=9B=9E=E6=94=B6=E7=AB=99=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/posts/list.rs | 59 +++++++++++++++++++++++++++++++++++++++++++ src/api/posts/mod.rs | 3 +++ 2 files changed, 62 insertions(+) diff --git a/src/api/posts/list.rs b/src/api/posts/list.rs index 81ca38e..e7e16c9 100644 --- a/src/api/posts/list.rs +++ b/src/api/posts/list.rs @@ -171,6 +171,65 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result Result { + // 与 list_posts 一致的分页钳制。 + let (page, per_page) = clamp_pagination(page, per_page); + 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 NOT 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 + p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, + 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 + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.deleted_at IS NOT NULL + GROUP BY p.id + ORDER BY p.deleted_at DESC + LIMIT $1 OFFSET $2", + &[&limit, &offset], + ) + .await + .map_err(AppError::query)?; + + let mut posts = Vec::new(); + for row in &rows { + posts.push(row_to_post_list(&client, row).await); + } + + Ok(PostListResponse { posts, total }) + } + + #[cfg(not(feature = "server"))] + { + Ok(PostListResponse { + posts: Vec::new(), + total: 0, + }) + } +} + /// 获取指定标签下的已发布文章列表。 /// /// 优先命中缓存;当前实现返回全部匹配文章,因此 total 用 posts.len() 计算。 diff --git a/src/api/posts/mod.rs b/src/api/posts/mod.rs index a998ed3..2d1852d 100644 --- a/src/api/posts/mod.rs +++ b/src/api/posts/mod.rs @@ -26,6 +26,9 @@ pub use delete::delete_post; /// 获取管理员视角的全部文章分页列表。 #[allow(unused_imports)] pub use list::list_posts; +/// 获取回收站中已软删除的文章列表。 +#[allow(unused_imports)] +pub use list::list_deleted_posts; /// 获取已发布文章分页列表。 pub use list::{get_posts_by_tag, list_published_posts}; /// 根据 id 获取文章详情。 From f5e19def1aa0a4752c2efa453cb24487374eacc0 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 12:34:28 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat(api):=20=E6=96=B0=E5=A2=9E=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E7=AB=99=E9=85=8D=E7=BD=AE=E8=AF=BB=E5=86=99=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mod.rs | 2 + src/api/settings.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/api/settings.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index 719008c..fe97f9c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -20,6 +20,8 @@ pub mod posts; pub mod rate_limit; /// HTML 消毒器。 pub mod sanitizer; +/// 回收站与站点配置接口。 +pub mod settings; /// URL slug 生成与校验。 pub mod slug; /// 图片上传的 Axum 处理器。 diff --git a/src/api/settings.rs b/src/api/settings.rs new file mode 100644 index 0000000..cdd423d --- /dev/null +++ b/src/api/settings.rs @@ -0,0 +1,105 @@ +//! 回收站配置接口:读取与更新自动清理设置。 +//! +//! 所有接口需要 admin 权限。配置持久化到 settings 键值表。 +//! Dioxus server function,注册在 `/api` 路径下。 + +use dioxus::prelude::*; + +use crate::models::settings::TrashSettings; +#[cfg(feature = "server")] +use crate::api::auth::get_current_admin_user; +#[cfg(feature = "server")] +use crate::api::error::AppError; +#[cfg(feature = "server")] +use crate::db::pool::get_conn; + +/// 读取回收站配置。 +/// +/// settings 表缺失键时回退到默认值,保证向后兼容。 +#[server(GetTrashSettings, "/api")] +pub async fn get_trash_settings() -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + let enabled: bool = client + .query_opt( + "SELECT value FROM settings WHERE key = 'trash_auto_purge_enabled'", + &[], + ) + .await + .map_err(AppError::query)? + .and_then(|r| r.get::<_, String>("value").parse().ok()) + .unwrap_or_else(|| crate::models::settings::DEFAULT_AUTO_PURGE_ENABLED); + + let days: i32 = client + .query_opt( + "SELECT value FROM settings WHERE key = 'trash_retention_days'", + &[], + ) + .await + .map_err(AppError::query)? + .and_then(|r| r.get::<_, String>("value").parse().ok()) + .unwrap_or_else(|| crate::models::settings::DEFAULT_RETENTION_DAYS); + + Ok(TrashSettings { + auto_purge_enabled: enabled, + retention_days: TrashSettings::clamp_retention(days), + }) + } + + #[cfg(not(feature = "server"))] + { + Ok(TrashSettings::default()) + } +} + +/// 更新回收站配置。 +/// +/// retention_days 会被 clamp 到合法范围后写入。 +#[server(UpdateTrashSettings, "/api")] +pub async fn update_trash_settings( + auto_purge_enabled: bool, + retention_days: i32, +) -> Result { + let _user = get_current_admin_user().await?; + + let retention_days = TrashSettings::clamp_retention(retention_days); + + #[cfg(feature = "server")] + { + let client = get_conn().await.map_err(AppError::db_conn)?; + + // UPSERT 两个键。 + client + .execute( + "INSERT INTO settings (key, value, updated_at) VALUES ('trash_auto_purge_enabled', $1, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", + &[&auto_purge_enabled.to_string()], + ) + .await + .map_err(AppError::query)?; + + client + .execute( + "INSERT INTO settings (key, value, updated_at) VALUES ('trash_retention_days', $1, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", + &[&retention_days.to_string()], + ) + .await + .map_err(AppError::query)?; + + tracing::info!( + "Trash settings updated: auto_purge={}, retention_days={}", + auto_purge_enabled, + retention_days + ); + } + + Ok(TrashSettings { + auto_purge_enabled, + retention_days, + }) +} From 0ba8f9f835d4e74051c959e44992ac9f3460a0f7 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 12:34:28 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat(tasks):=20=E6=96=B0=E5=A2=9E=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E7=AB=99=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.rs | 5 +++ src/tasks/mod.rs | 3 ++ src/tasks/post_purge.rs | 74 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 src/tasks/post_purge.rs diff --git a/src/main.rs b/src/main.rs index 56e75b1..69521e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,6 +64,11 @@ fn main() { tasks::session_cleanup::run_cleanup().await; }); + // 启动后台定时任务:回收站自动清理 + tokio::spawn(async { + tasks::post_purge::run_purge().await; + }); + // 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖 let config = ServeConfig::builder().incremental( dioxus::server::IncrementalRendererConfig::default().invalidate_after( diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index 63c4af2..f8a3887 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -8,3 +8,6 @@ pub mod ip_purge; /// 定时删除已过期会话,避免 `sessions` 表无限增长。 #[cfg(feature = "server")] pub mod session_cleanup; +/// 定时清理回收站中超过保留期的已删除文章。 +#[cfg(feature = "server")] +pub mod post_purge; diff --git a/src/tasks/post_purge.rs b/src/tasks/post_purge.rs new file mode 100644 index 0000000..58107a5 --- /dev/null +++ b/src/tasks/post_purge.rs @@ -0,0 +1,74 @@ +//! 回收站自动清理后台任务。 +//! +//! 仅在 `server` feature 启用时编译,每天运行一次。 +//! 每次执行前读取 settings 表:若自动清理关闭则跳过,否则按保留天数物理删除过期文章。 + +use std::time::Duration; + +use tokio::time::interval; + +use crate::db::pool::get_conn; +use crate::models::settings::TrashSettings; + +/// 启动回收站自动清理循环,每天触发一次。 +/// +/// 每次读取最新配置:若 `trash_auto_purge_enabled` 关闭则 no-op, +/// 否则删除 `deleted_at < NOW() - retention_days` 的文章。 +/// 任何错误只记录日志,不中断循环。 +pub async fn run_purge() { + let mut ticker = interval(Duration::from_secs(86400)); + loop { + ticker.tick().await; + match get_conn().await { + Ok(client) => { + match purge_expired(&client).await { + Ok(n) => { + if n > 0 { + tracing::info!("Post auto-purge: removed {} expired trashed posts", n); + } + } + Err(e) => tracing::error!("Post auto-purge error: {:?}", e), + } + } + Err(e) => tracing::error!("Failed to get DB connection for post purge: {:?}", e), + } + } +} + +/// 读取配置并删除过期回收站文章,返回删除行数。 +/// +/// 抽取为独立函数便于单元测试(虽需 DB,但逻辑集中)。 +async fn purge_expired(client: &tokio_postgres::Client) -> Result { + // 读取配置,缺键时回退默认值。 + let enabled: bool = client + .query_opt( + "SELECT value FROM settings WHERE key = 'trash_auto_purge_enabled'", + &[], + ) + .await? + .and_then(|r| r.get::<_, String>("value").parse().ok()) + .unwrap_or(false); + + if !enabled { + return Ok(0); + } + + let days: i32 = client + .query_opt( + "SELECT value FROM settings WHERE key = 'trash_retention_days'", + &[], + ) + .await? + .and_then(|r| r.get::<_, String>("value").parse().ok()) + .unwrap_or(30); + + let days = TrashSettings::clamp_retention(days); + + let n = client + .execute( + "DELETE FROM posts WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - make_interval(days => $1)", + &[&days], + ) + .await?; + Ok(n) +} From 4ba4fedb231381f87e044c0dcaacb028b44dcf17 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 12:38:41 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=20/admin/tra?= =?UTF-8?q?sh=20=E5=9B=9E=E6=94=B6=E7=AB=99=E7=AE=A1=E7=90=86=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=B8=8E=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin_layout.rs | 5 + src/pages/admin/mod.rs | 4 + src/pages/admin/trash.rs | 517 +++++++++++++++++++++++++++++++++ src/router.rs | 8 +- 4 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 src/pages/admin/trash.rs diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs index 5663a6a..cd97d74 100644 --- a/src/components/admin_layout.rs +++ b/src/components/admin_layout.rs @@ -67,6 +67,11 @@ pub fn AdminLayout() -> Element { label: "管理文章", is_active: matches!(route, Route::Posts {}), }, + NavItemConfig { + route: Route::Trash {}, + label: "回收站", + is_active: matches!(route, Route::Trash {}) || matches!(route, Route::TrashPage { .. }), + }, ]; // 右侧操作区:主题切换 + 登出按钮 diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs index 75e89c8..0c2b849 100644 --- a/src/pages/admin/mod.rs +++ b/src/pages/admin/mod.rs @@ -8,6 +8,8 @@ pub mod comments; pub mod dashboard; /// 文章管理列表页面模块。 pub mod posts; +/// 回收站管理页面模块。 +pub mod trash; /// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。 pub mod write; @@ -17,5 +19,7 @@ pub use comments::{AdminComments, AdminCommentsPage}; pub use dashboard::Admin; /// 文章管理列表组件(带默认分页)。 pub use posts::{Posts, PostsPage}; +/// 回收站管理组件(带默认分页)。 +pub use trash::{Trash, TrashPage}; /// 文章编辑器组件(新建与编辑模式)。 pub use write::{Write, WriteEdit}; diff --git a/src/pages/admin/trash.rs b/src/pages/admin/trash.rs new file mode 100644 index 0000000..ec9fad0 --- /dev/null +++ b/src/pages/admin/trash.rs @@ -0,0 +1,517 @@ +//! 回收站管理页面。 +//! +//! 展示已软删除文章,支持恢复、彻底删除、批量操作、一键清空, +//! 以及自动清理配置(启用开关 + 保留天数)。 +//! 数据加载与操作仅在 WASM 前端通过 Dioxus server functions 交互。 + +use std::collections::HashSet; + +use dioxus::prelude::*; +use dioxus::router::components::Link; + +// 操作类 server function 在 SSR 与 WASM 均需可见(spawn 闭包需类型检查), +// 但部分仅用于 WASM 代码路径,SSR 下触发 unused imports,按项目惯例放行。 +#[allow(unused_imports)] +use crate::api::posts::{ + batch_purge_posts, batch_restore_posts, empty_trash, purge_post, restore_post, +}; +#[cfg(target_arch = "wasm32")] +use crate::api::posts::{list_deleted_posts, PostListResponse}; +#[allow(unused_imports)] +use crate::api::settings::{get_trash_settings, update_trash_settings}; +use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; +use crate::models::post::Post; +use crate::models::settings::TrashSettings; +use crate::router::Route; + +/// 每页展示的回收站文章数量。 +const TRASH_PER_PAGE: i32 = 20; + +/// 回收站入口组件,默认展示第 1 页。 +#[component] +pub fn Trash() -> Element { + rsx! { TrashPage { page: 1 } } +} + +/// 回收站分页组件。 +/// +/// 支持单条/批量恢复与彻底删除、一键清空,以及内联自动清理配置。 +#[allow(unused_mut, unused_variables)] +#[component] +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 total: Signal = use_signal(|| 0); + #[allow(unused_mut)] + let mut loading: Signal = use_signal(|| false); + #[allow(unused_mut)] + let mut error: Signal> = use_signal(|| None); + // 自动清理配置(含本地草稿态用于表单输入)。 + let mut settings: Signal = use_signal(TrashSettings::default); + let mut settings_draft_days: Signal = use_signal(|| "30".to_string()); + let mut settings_draft_enabled: Signal = use_signal(|| false); + let mut settings_panel_open: Signal = use_signal(|| false); + let mut saving_settings: Signal = use_signal(|| false); + // 配置只加载一次的标记,避免每次翻页 effect 重复拉取。 + let mut settings_loaded: Signal = use_signal(|| false); + + // 加载回收站列表;配置仅首次加载。 + use_effect(move || { + let _ = current_page; + loading.set(true); + error.set(None); + #[cfg(target_arch = "wasm32")] + { + let page = current_page; + spawn(async move { + match list_deleted_posts(page, TRASH_PER_PAGE).await { + Ok(PostListResponse { posts: list, total: t }) => { + posts.set(list); + total.set(t); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + if !settings_loaded() { + settings_loaded.set(true); + spawn(async move { + if let Ok(s) = get_trash_settings().await { + settings_draft_days.set(s.retention_days.to_string()); + settings_draft_enabled.set(s.auto_purge_enabled); + settings.set(s); + } + }); + } + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } + }); + + // 本地移除一篇文章(乐观更新)。 + let mut remove_post = move |id: i32| { + posts.with_mut(|list| list.retain(|p| p.id != id)); + total.with_mut(|t| *t = t.saturating_sub(1)); + selected_ids.with_mut(|s| { + s.remove(&id); + }); + }; + + rsx! { + div { class: "space-y-6", + // 标题 + 设置开关入口 + div { class: "flex items-center justify-between", + div { class: "flex items-center gap-3", + h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]", "回收站" } + span { class: "text-sm text-gray-500 dark:text-[#9b9c9d]", + "共 {total()} 篇" + } + } + button { + class: "px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-gray-700 dark:text-[#b0b0b1] border border-gray-300 dark:border-[#444] hover:border-gray-900 dark:hover:border-[#dadadb] hover:text-gray-900 dark:hover:text-[#dadadb] transition-all flex items-center gap-2", + onclick: move |_| settings_panel_open.set(!settings_panel_open()), + // 齿轮 SVG(Material Symbols 风格) + svg { + class: "w-4 h-4", + view_box: "0 -960 960 960", + fill: "currentColor", + path { d: "m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t23.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-838l-13-106h-79l-14 106q-31 8-57.5 23.5T321-737l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-276l13 96Zm39-180q54 0 92-38t38-92q0-54-38-92t-92-38q-54 0-92 38t-38 92q0 54 38 92t92 38Z" } + } + "自动清理设置" + } + } + + // 摘要条:当前清理状态 + div { class: "flex items-center gap-4 px-4 py-3 bg-gray-50 dark:bg-[#2a2a2a] rounded-lg", + div { class: "flex items-center gap-2", + {let dot_class = if settings().auto_purge_enabled { + "w-2 h-2 rounded-full bg-green-500" + } else { + "w-2 h-2 rounded-full bg-gray-400 dark:bg-[#666]" + }; + rsx! { + div { class: "{dot_class}" } + }} + span { class: "text-sm text-gray-600 dark:text-[#9b9c9d]", + if settings().auto_purge_enabled { + "自动清理已开启 · 超过 {settings().retention_days} 天自动删除" + } else { + "自动清理已关闭" + } + } + } + } + + // 设置面板(可折叠) + if settings_panel_open() { + div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] p-5 space-y-4", + div { + label { class: "flex items-center justify-between cursor-pointer", + span { class: "text-sm text-gray-700 dark:text-[#dadadb]", "启用自动清理" } + input { + r#type: "checkbox", + class: "w-4 h-4 rounded border-gray-300 dark:border-[#555]", + checked: settings_draft_enabled(), + onchange: move |e| settings_draft_enabled.set(e.checked()), + } + } + } + div { class: "flex items-center gap-3", + label { class: "text-sm text-gray-700 dark:text-[#dadadb] whitespace-nowrap", "保留天数" } + input { + r#type: "number", + min: "1", + max: "365", + class: "w-24 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-[#444] bg-white dark:bg-[#1d1e20] text-gray-900 dark:text-[#dadadb] focus:outline-none focus:border-gray-900 dark:focus:border-[#dadadb]", + value: "{settings_draft_days()}", + oninput: move |e| settings_draft_days.set(e.value()), + } + span { class: "text-xs text-gray-400 dark:text-[#666]", "天后自动彻底删除(1–365)" } + } + div { class: "flex justify-end", + button { + class: if saving_settings() { + "px-4 py-1.5 text-sm font-medium cursor-not-allowed text-gray-400 bg-gray-100 dark:bg-[#2a2a2a] rounded-full" + } else { + "px-4 py-1.5 text-sm font-medium text-white bg-gray-900 dark:bg-[#dadadb] dark:text-gray-900 rounded-full hover:opacity-80 transition-opacity cursor-pointer" + }, + disabled: saving_settings(), + onclick: move |_| { + let days: i32 = settings_draft_days().parse().unwrap_or(30); + let enabled = settings_draft_enabled(); + saving_settings.set(true); + spawn(async move { + if let Ok(s) = update_trash_settings(enabled, days).await { + settings.set(s); + } + saving_settings.set(false); + }); + }, + if saving_settings() { "保存中..." } else { "保存设置" } + } + } + } + } + + // 批量操作栏(选中时显示) + if !selected_ids().is_empty() { + div { class: "flex items-center gap-3 p-3 bg-gray-50 dark:bg-[#2a2a2a] rounded-lg", + span { class: "text-sm text-gray-600 dark:text-[#9b9c9d]", + "已选择 {selected_ids().len()} 条" + } + button { + class: "px-3 py-1.5 text-xs font-medium bg-green-600 text-white rounded hover:bg-green-700 transition-colors", + onclick: move |_| { + let ids: Vec = selected_ids().iter().copied().collect(); + spawn(async move { + let _ = batch_restore_posts(ids).await; + }); + for id in selected_ids() { remove_post(id); } + selected_ids.set(HashSet::new()); + }, + "批量恢复" + } + button { + class: "px-3 py-1.5 text-xs font-medium bg-red-600 text-white rounded hover:bg-red-700 transition-colors", + onclick: move |_| { + #[cfg(target_arch = "wasm32")] + { + if web_sys::window() + .and_then(|w| w.confirm_with_message("确定要彻底删除选中的文章吗?此操作不可恢复。").ok()) + .unwrap_or(false) + { + let ids: Vec = selected_ids().iter().copied().collect(); + spawn(async move { + let _ = batch_purge_posts(ids).await; + }); + for id in selected_ids() { remove_post(id); } + selected_ids.set(HashSet::new()); + } + } + }, + "批量彻底删除" + } + } + } + + // 主内容:错误 / 加载骨架 / 空态 / 列表 + { + if error().is_some() { + rsx! { + div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" } + } + } else if loading() && posts().is_empty() { + rsx! { + DelayedSkeleton { + div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] p-6 space-y-4", + for _ in 0..5 { + div { class: "h-10 bg-gray-200 dark:bg-[#2a2a2a] rounded" } + } + } + } + } + } else if posts().is_empty() { + rsx! { + div { class: "text-center py-20 text-gray-500 dark:text-[#9b9c9d]", + "回收站为空" + } + } + } else { + let list = posts(); + let all_selected = list.iter().all(|p| selected_ids().contains(&p.id)); + let all_ids: Vec = list.iter().map(|p| p.id).collect(); + rsx! { + div { class: "bg-white dark:bg-[#2e2e33] rounded-xl border border-gray-200 dark:border-[#333] overflow-hidden", + div { class: "overflow-x-auto", + 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 w-10", + input { + r#type: "checkbox", + class: "rounded border-gray-300 dark:border-[#555]", + checked: all_selected, + onchange: { + move |_| { + let mut s = selected_ids(); + if all_selected { + for id in &all_ids { s.remove(id); } + } else { + for id in &all_ids { s.insert(*id); } + } + selected_ids.set(s); + } + } + } + } + th { class: "px-4 py-3 font-medium", "标题" } + th { class: "px-4 py-3 font-medium", "原状态" } + th { class: "px-4 py-3 font-medium w-28", "删除时间" } + th { class: "px-4 py-3 font-medium w-24 text-center", "剩余" } + th { class: "px-4 py-3 font-medium w-32 text-right", "操作" } + } + } + tbody { + for post in list.iter() { + TrashRow { + key: "{post.id}", + post: post.clone(), + retention_days: settings().retention_days, + selected: selected_ids().contains(&post.id), + on_select: { + let id = post.id; + move |checked: bool| { + let mut s = selected_ids(); + if checked { s.insert(id); } else { s.remove(&id); } + selected_ids.set(s); + } + }, + on_restore: { + let id = post.id; + move |_| { + spawn(async move { + let _ = restore_post(id).await; + }); + remove_post(id); + } + }, + on_purge: { + let id = post.id; + move |_| { + #[cfg(target_arch = "wasm32")] + { + if web_sys::window() + .and_then(|w| w.confirm_with_message("确定要彻底删除这篇文章吗?此操作不可恢复。").ok()) + .unwrap_or(false) + { + spawn(async move { + let _ = purge_post(id).await; + }); + remove_post(id); + } + } + } + }, + } + } + } + } + } + } + // 底部:清空回收站 + 分页 + div { class: "flex items-center justify-between mt-4", + button { + class: "px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-900/50 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer", + onclick: move |_| { + #[cfg(target_arch = "wasm32")] + { + if web_sys::window() + .and_then(|w| w.confirm_with_message("确定要清空回收站吗?所有已删除文章将被彻底移除,此操作不可恢复。").ok()) + .unwrap_or(false) + { + spawn(async move { + let _ = empty_trash().await; + }); + posts.set(Vec::new()); + total.set(0); + selected_ids.set(HashSet::new()); + } + } + }, + "清空回收站" + } + } + TrashPagination { current_page, total: total() } + } + } + } + } + } +} + +/// 计算剩余天数(保留期 - 已删除天数)。 +/// +/// 返回 (剩余天数, 是否已过期)。基于客户端时钟计算,轻微漂移可接受。 +fn remaining_days(post: &Post, retention_days: i32) -> (i64, bool) { + #[cfg(target_arch = "wasm32")] + { + if let Some(deleted_at) = post.deleted_at { + let now_ms = js_sys::Date::now() as i64; // 毫秒 + let deleted_ms = deleted_at.timestamp_millis(); + let elapsed_days = (now_ms - deleted_ms) / 86_400_000; + let remaining = retention_days as i64 - elapsed_days; + (remaining, remaining <= 0) + } else { + (retention_days as i64, false) + } + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = post; + (retention_days as i64, false) + } +} + +/// 回收站表格行组件。 +#[component] +fn TrashRow( + post: Post, + retention_days: i32, + selected: bool, + on_select: EventHandler, + on_restore: EventHandler, + on_purge: EventHandler, +) -> Element { + let (remaining, expired) = remaining_days(&post, retention_days); + // 剩余天数徽章配色:>7 天中性,≤7 天鼠尾草绿(主题色),≤0/过期琥珀色。 + let badge_class = if expired { + "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" + } else if remaining <= 7 { + "bg-[#e8f0e8] text-[#5c7a5e] dark:bg-[#1e2e1e] dark:text-[#7da97f]" + } else { + "bg-gray-100 text-gray-600 dark:bg-[#333] dark:text-[#9b9c9d]" + }; + let badge_text = if expired { "待清理".to_string() } else { format!("{remaining}天") }; + let deleted_str = post + .deleted_at + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "—".to_string()); + + rsx! { + tr { class: "border-b border-gray-100 dark:border-[#333] last:border-0 hover:bg-gray-50 dark:hover:bg-[#2a2a2a] transition-colors", + td { class: "px-4 py-3", + input { + r#type: "checkbox", + class: "rounded border-gray-300 dark:border-[#555]", + checked: selected, + onchange: move |e| on_select.call(e.checked()), + } + } + td { class: "px-4 py-3", + div { class: "text-sm font-medium text-gray-900 dark:text-[#dadadb] truncate max-w-xs", + "{post.title}" + } + } + td { class: "px-4 py-3", + span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap {post.status_badge_class()}", + "{post.status_label()}" + } + } + td { class: "px-4 py-3 text-sm text-gray-500 dark:text-[#9b9c9d]", + "{deleted_str}" + } + td { class: "px-4 py-3 text-center", + span { class: "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap {badge_class}", + "{badge_text}" + } + } + td { class: "px-4 py-3 text-right", + div { class: "flex justify-end gap-2", + button { + class: "text-xs text-[#5c7a5e] hover:text-[#3d5a3f] dark:text-[#7da97f] dark:hover:text-[#9dc79f] transition-colors cursor-pointer", + onclick: move |_| on_restore.call(()), + "恢复" + } + button { + class: "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer", + onclick: move |_| on_purge.call(()), + "彻底删除" + } + } + } + } + } +} + +/// 回收站分页导航组件。 +#[component] +fn TrashPagination(current_page: i32, total: i64) -> Element { + let has_prev = current_page > 1; + let total_pages = + ((total + TRASH_PER_PAGE as i64 - 1) / TRASH_PER_PAGE as i64).max(1) as i32; + let has_next = current_page < total_pages; + + let prev_route = if current_page - 1 <= 1 { + Route::Trash {} + } else { + Route::TrashPage { page: current_page - 1 } + }; + let next_route = Route::TrashPage { page: current_page + 1 }; + + 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: prev_route, + 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: next_route, + "下一页" + 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", "»" } + } + } + } + } +} diff --git a/src/router.rs b/src/router.rs index 642a239..9cff4be 100644 --- a/src/router.rs +++ b/src/router.rs @@ -12,7 +12,7 @@ use crate::components::frontend_layout::FrontendLayout; use crate::context::UserContext; use crate::pages::about::About; use crate::pages::admin::{ - Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Write, WriteEdit, + Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Trash, TrashPage, Write, WriteEdit, }; use crate::pages::archives::Archives; use crate::pages::home::{Home, HomePage}; @@ -84,6 +84,12 @@ pub enum Route { /// 评论管理分页 #[route("/comments/:page")] AdminCommentsPage { page: i32 }, + /// 回收站 + #[route("/trash")] + Trash {}, + /// 回收站分页 + #[route("/trash/:page")] + TrashPage { page: i32 }, #[end_layout] #[end_nest] From 55bef35d1f65a06effcc19aa1b51cc7eb2936932 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 16 Jun 2026 12:40:43 +0800 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=E6=B8=85=E7=90=86=20clippy=20?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=EF=BC=88unwrap=5For=20=E4=B8=8E=E5=AE=8F=20d?= =?UTF-8?q?eprecated=20=E6=94=BE=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/settings.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/api/settings.rs b/src/api/settings.rs index cdd423d..631f077 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -3,6 +3,9 @@ //! 所有接口需要 admin 权限。配置持久化到 settings 键值表。 //! Dioxus server function,注册在 `/api` 路径下。 +// 与 posts 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。 +#![allow(clippy::unused_unit, deprecated)] + use dioxus::prelude::*; use crate::models::settings::TrashSettings; @@ -32,7 +35,7 @@ pub async fn get_trash_settings() -> Result { .await .map_err(AppError::query)? .and_then(|r| r.get::<_, String>("value").parse().ok()) - .unwrap_or_else(|| crate::models::settings::DEFAULT_AUTO_PURGE_ENABLED); + .unwrap_or(crate::models::settings::DEFAULT_AUTO_PURGE_ENABLED); let days: i32 = client .query_opt( @@ -42,7 +45,7 @@ pub async fn get_trash_settings() -> Result { .await .map_err(AppError::query)? .and_then(|r| r.get::<_, String>("value").parse().ok()) - .unwrap_or_else(|| crate::models::settings::DEFAULT_RETENTION_DAYS); + .unwrap_or(crate::models::settings::DEFAULT_RETENTION_DAYS); Ok(TrashSettings { auto_purge_enabled: enabled,