feat: 文章回收站与自动清理

This commit is contained in:
xfy 2026-06-16 12:42:11 +08:00
commit 63e352d471
18 changed files with 1171 additions and 1 deletions

View File

@ -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;

View File

@ -20,6 +20,8 @@ pub mod posts;
pub mod rate_limit;
/// HTML 消毒器。
pub mod sanitizer;
/// 回收站与站点配置接口。
pub mod settings;
/// URL slug 生成与校验。
pub mod slug;
/// 图片上传的 Axum 处理器。

View File

@ -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),

View File

@ -171,6 +171,65 @@ pub async fn list_posts(page: i32, per_page: i32) -> Result<PostListResponse, Se
}
}
/// 获取回收站中已软删除的文章列表。
///
/// 需要 admin 权限;按删除时间降序,不走缓存。
#[server(ListDeletedPosts, "/api")]
pub async fn list_deleted_posts(
page: i32,
per_page: i32,
) -> Result<PostListResponse, ServerFnError> {
// 与 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() 计算。

View File

@ -16,6 +16,7 @@ mod stats;
mod tags;
mod types;
mod update;
mod trash;
/// 创建新文章。
#[allow(unused_imports)]
@ -25,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 获取文章详情。
@ -44,6 +48,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")]

286
src/api/posts/trash.rs Normal file
View File

@ -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<CreatePostResponse, ServerFnError> {
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, &current_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<CreatePostResponse, ServerFnError> {
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<i32>) -> Result<CreatePostResponse, ServerFnError> {
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, &current_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<i32>) -> Result<CreatePostResponse, ServerFnError> {
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<CreatePostResponse, ServerFnError> {
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,
})
}
}

108
src/api/settings.rs Normal file
View File

@ -0,0 +1,108 @@
//! 回收站配置接口:读取与更新自动清理设置。
//!
//! 所有接口需要 admin 权限。配置持久化到 settings 键值表。
//! Dioxus server function注册在 `/api` 路径下。
// 与 posts 模块一致Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
#![allow(clippy::unused_unit, deprecated)]
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<TrashSettings, ServerFnError> {
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(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(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<TrashSettings, ServerFnError> {
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,
})
}

View File

@ -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,

View File

@ -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 { .. }),
},
];
// 右侧操作区:主题切换 + 登出按钮

View File

@ -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(

View File

@ -9,3 +9,5 @@ pub mod comment;
pub mod post;
/// 用户模型、用户角色与可公开用户信息。
pub mod user;
/// 回收站与站点配置模型。
pub mod settings;

View File

@ -61,6 +61,8 @@ pub struct Post {
pub created_at: DateTime<Utc>,
/// 最后更新时间。
pub updated_at: DateTime<Utc>,
/// 软删除时间None 表示未删除。仅回收站查询填充。
pub deleted_at: Option<DateTime<Utc>>,
/// 关联标签列表。
pub tags: Vec<String>,
/// 封面图片 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,

71
src/models/settings.rs Normal file
View File

@ -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);
}
}

View File

@ -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};

517
src/pages/admin/trash.rs Normal file
View File

@ -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<HashSet<i32>> = use_signal(HashSet::new);
let mut posts: Signal<Vec<Post>> = use_signal(Vec::new);
let mut total: Signal<i64> = use_signal(|| 0);
#[allow(unused_mut)]
let mut loading: Signal<bool> = use_signal(|| false);
#[allow(unused_mut)]
let mut error: Signal<Option<String>> = use_signal(|| None);
// 自动清理配置(含本地草稿态用于表单输入)。
let mut settings: Signal<TrashSettings> = use_signal(TrashSettings::default);
let mut settings_draft_days: Signal<String> = use_signal(|| "30".to_string());
let mut settings_draft_enabled: Signal<bool> = use_signal(|| false);
let mut settings_panel_open: Signal<bool> = use_signal(|| false);
let mut saving_settings: Signal<bool> = use_signal(|| false);
// 配置只加载一次的标记,避免每次翻页 effect 重复拉取。
let mut settings_loaded: Signal<bool> = 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()),
// 齿轮 SVGMaterial 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]", "天后自动彻底删除1365" }
}
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<i32> = 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<i32> = 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<i32> = 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<bool>,
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", "»" }
}
}
}
}
}

View File

@ -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]

View File

@ -8,3 +8,6 @@ pub mod ip_purge;
/// 定时删除已过期会话,避免 `sessions` 表无限增长。
#[cfg(feature = "server")]
pub mod session_cleanup;
/// 定时清理回收站中超过保留期的已删除文章。
#[cfg(feature = "server")]
pub mod post_purge;

74
src/tasks/post_purge.rs Normal file
View File

@ -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<u64, tokio_postgres::Error> {
// 读取配置,缺键时回退默认值。
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)
}