From a75a84d2b5c087a33da2bd40cf25ee40f60ae681 Mon Sep 17 00:00:00 2001 From: Feplus2 <202785243+Feplus2@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:02:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B9=A6=E7=B1=8D=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E7=AB=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src-tauri/src/core/books/commands.rs | 106 +++++++++++-- .../app/src-tauri/src/core/books/models.rs | 4 + packages/app/src-tauri/src/core/database.rs | 20 +++ packages/app/src-tauri/src/core/schema.sql | 1 + packages/app/src-tauri/src/lib.rs | 12 ++ packages/app/src/components/home-layout.tsx | 9 ++ packages/app/src/components/sidebar.tsx | 29 +++- packages/app/src/pages/library/trash.tsx | 144 ++++++++++++++++++ packages/app/src/services/book-service.ts | 31 ++++ packages/app/src/types/simple-book.ts | 3 + 10 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 packages/app/src/pages/library/trash.tsx diff --git a/packages/app/src-tauri/src/core/books/commands.rs b/packages/app/src-tauri/src/core/books/commands.rs index 8583b140..a280a64b 100644 --- a/packages/app/src-tauri/src/core/books/commands.rs +++ b/packages/app/src-tauri/src/core/books/commands.rs @@ -117,7 +117,7 @@ pub async fn get_books( let opts = options.unwrap_or_default(); let mut query = String::from("SELECT * FROM books"); - let mut conditions = Vec::new(); + let mut conditions = vec!["trashed_at IS NULL".to_string()]; if let Some(search_query) = &opts.search_query { if !search_query.trim().is_empty() { @@ -251,28 +251,108 @@ pub async fn update_book( pub async fn delete_book(app_handle: AppHandle, id: String) -> Result<(), String> { let db_pool = get_db_pool(&app_handle).await?; + // 软删除:仅标记 trashed_at,磁盘文件与关联数据(book_status/threads 等)全部保留,回收站可恢复 + let result = sqlx::query("UPDATE books SET trashed_at = ? WHERE id = ? AND trashed_at IS NULL") + .bind(chrono::Utc::now().timestamp_millis()) + .bind(&id) + .execute(&db_pool) + .await + .map_err(|e| format!("删除书籍失败: {}", e))?; + + if result.rows_affected() == 0 { + return Err("书籍不存在或已在回收站".to_string()); + } + + Ok(()) +} + +/// 恢复:清除 trashed_at,书籍回到书架 +#[tauri::command] +pub async fn restore_book(app_handle: AppHandle, id: String) -> Result<(), String> { + let db_pool = get_db_pool(&app_handle).await?; + + let result = sqlx::query("UPDATE books SET trashed_at = NULL, updated_at = ? WHERE id = ?") + .bind(chrono::Utc::now().timestamp_millis()) + .bind(&id) + .execute(&db_pool) + .await + .map_err(|e| format!("恢复书籍失败: {}", e))?; + + if result.rows_affected() == 0 { + return Err("书籍不存在".to_string()); + } + + Ok(()) +} + +/// 回收站列表:按删除时间倒序 +#[tauri::command] +pub async fn get_trashed_books(app_handle: AppHandle) -> Result, String> { + let db_pool = get_db_pool(&app_handle).await?; + + let rows = sqlx::query("SELECT * FROM books WHERE trashed_at IS NOT NULL ORDER BY trashed_at DESC") + .fetch_all(&db_pool) + .await + .map_err(|e| format!("查询回收站失败: {}", e))?; + + let books: Result, sqlx::Error> = rows.iter().map(SimpleBook::from_db_row).collect(); + books.map_err(|e| format!("转换查询结果失败: {}", e)) +} + +/// 彻底删除(回收站操作/自动清理共用):删磁盘目录 + DELETE 行(外键级联清关联数据) +async fn purge_book_by_id(app_handle: &AppHandle, db_pool: &SqlitePool, id: &str) -> Result<(), String> { let app_data_dir = app_handle .path() .app_data_dir() .map_err(|e| format!("获取应用目录失败: {}", e))?; - let book_dir = app_data_dir.join("books").join(&id); + let book_dir = app_data_dir.join("books").join(id); if book_dir.exists() { std::fs::remove_dir_all(&book_dir).map_err(|e| format!("删除书籍文件失败: {}", e))?; } - // 外键约束会自动删除相关的 book_status, reading_sessions 和 threads - let result = sqlx::query("DELETE FROM books WHERE id = ?") - .bind(&id) - .execute(&db_pool) + sqlx::query("DELETE FROM books WHERE id = ?") + .bind(id) + .execute(db_pool) .await - .map_err(|e| format!("删除书籍失败: {}", e))?; + .map_err(|e| format!("彻底删除书籍失败: {}", e))?; - if result.rows_affected() == 0 { - return Err("书籍不存在".to_string()); + Ok(()) +} + +/// 彻底删除单本书(回收站手动操作) +#[tauri::command] +pub async fn purge_book(app_handle: AppHandle, id: String) -> Result<(), String> { + let db_pool = get_db_pool(&app_handle).await?; + purge_book_by_id(&app_handle, &db_pool, &id).await +} + +/// 回收站保留天数(将来可做成用户配置) +const TRASH_RETENTION_DAYS: i64 = 30; + +/// 启动时自动清理:超过保留期的回收站书籍执行彻底删除,返回清理数量 +pub async fn purge_expired_trash(app_handle: &AppHandle) -> Result { + let db_pool = get_db_pool(app_handle).await?; + + let cutoff = chrono::Utc::now().timestamp_millis() - TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000; + let rows = sqlx::query("SELECT id FROM books WHERE trashed_at IS NOT NULL AND trashed_at < ?") + .bind(cutoff) + .fetch_all(&db_pool) + .await + .map_err(|e| format!("查询过期回收站书籍失败: {}", e))?; + + let mut purged = 0; + for row in rows { + let id: String = row.get("id"); + purge_book_by_id(app_handle, &db_pool, &id).await?; + purged += 1; } - Ok(()) + if purged > 0 { + log::info!("回收站自动清理:彻底删除 {} 本超过 {} 天的书籍", purged, TRASH_RETENTION_DAYS); + } + + Ok(purged) } #[tauri::command] @@ -371,11 +451,11 @@ pub async fn get_books_with_status( s.completed_at, s.metadata, s.created_at as status_created_at, s.updated_at as status_updated_at FROM books b LEFT JOIN book_status s ON b.id = s.book_id" ); - let mut conditions = Vec::new(); + let mut conditions = vec!["b.trashed_at IS NULL".to_string()]; if let Some(search_query) = &opts.search_query { if !search_query.trim().is_empty() { - conditions.push("(b.title LIKE ? OR b.author LIKE ?)"); + conditions.push("(b.title LIKE ? OR b.author LIKE ?)".to_string()); } } @@ -391,7 +471,7 @@ pub async fn get_books_with_status( None }; - if let Some(ref condition) = tag_condition { + if let Some(condition) = tag_condition { conditions.push(condition); } diff --git a/packages/app/src-tauri/src/core/books/models.rs b/packages/app/src-tauri/src/core/books/models.rs index a99f6f03..afd65f32 100644 --- a/packages/app/src-tauri/src/core/books/models.rs +++ b/packages/app/src-tauri/src/core/books/models.rs @@ -15,6 +15,8 @@ pub struct SimpleBook { pub file_size: i64, pub language: String, pub tags: Option>, + #[serde(rename = "trashedAt")] + pub trashed_at: Option, #[serde(rename = "createdAt")] pub created_at: i64, #[serde(rename = "updatedAt")] @@ -128,6 +130,7 @@ impl SimpleBook { file_size, language, tags: None, + trashed_at: None, created_at: now, updated_at: now, } @@ -149,6 +152,7 @@ impl SimpleBook { file_size: row.try_get("file_size")?, language: row.try_get("language")?, tags, + trashed_at: row.try_get("trashed_at")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, }) diff --git a/packages/app/src-tauri/src/core/database.rs b/packages/app/src-tauri/src/core/database.rs index 507bb710..9221c3d5 100644 --- a/packages/app/src-tauri/src/core/database.rs +++ b/packages/app/src-tauri/src/core/database.rs @@ -43,6 +43,8 @@ pub async fn initialize(app_handle: &AppHandle) -> Result Result Result<(), Box> { + // books.trashed_at(回收站软删除时间戳,毫秒,可空) + let result = sqlx::query("ALTER TABLE books ADD COLUMN trashed_at INTEGER") + .execute(pool) + .await; + + match result { + Ok(_) => println!("Migration applied: books.trashed_at added."), + Err(e) if e.to_string().contains("duplicate column name") => {} + Err(e) => return Err(e.into()), + } + + Ok(()) +} + + async fn initialize_default_skills(pool: &SqlitePool) -> Result<(), Box> { let default_skills_json = include_str!("./default-skills.json"); let default_skills: Vec = serde_json::from_str(default_skills_json)?; diff --git a/packages/app/src-tauri/src/core/schema.sql b/packages/app/src-tauri/src/core/schema.sql index e07435e2..c773f8b9 100644 --- a/packages/app/src-tauri/src/core/schema.sql +++ b/packages/app/src-tauri/src/core/schema.sql @@ -1,3 +1,4 @@ +-- 注意:books.trashed_at 列由 database.rs 的 fork 专属迁移添加,勿在此定义(避免与 ALTER 重复) CREATE TABLE IF NOT EXISTS threads ( id TEXT PRIMARY KEY NOT NULL, book_id TEXT, diff --git a/packages/app/src-tauri/src/lib.rs b/packages/app/src-tauri/src/lib.rs index e5d23b43..d56778dd 100644 --- a/packages/app/src-tauri/src/lib.rs +++ b/packages/app/src-tauri/src/lib.rs @@ -17,6 +17,9 @@ use crate::core::{ get_books_with_status, get_reading_session, get_reading_sessions_by_book, + get_trashed_books, + purge_book, + restore_book, save_book, update_book, update_book_note, @@ -111,6 +114,12 @@ pub fn run() { let state = app_handle.state::(); let mut db_pool_guard = state.db_pool.lock().await; *db_pool_guard = Some(pool); + + // 启动时清理回收站:超过保留期的书籍彻底删除 + drop(db_pool_guard); + if let Err(e) = core::books::commands::purge_expired_trash(&app_handle).await { + log::error!("回收站自动清理失败: {}", e); + } }); Ok(()) }) @@ -127,6 +136,9 @@ pub fn run() { get_book_by_id, update_book, delete_book, + restore_book, + get_trashed_books, + purge_book, get_book_status, update_book_status, get_books_with_status, diff --git a/packages/app/src/components/home-layout.tsx b/packages/app/src/components/home-layout.tsx index b26a72b3..1ed3388d 100644 --- a/packages/app/src/components/home-layout.tsx +++ b/packages/app/src/components/home-layout.tsx @@ -3,6 +3,7 @@ import { useBookUpload } from "@/hooks/use-book-upload"; import { useSafeAreaInsets } from "@/hooks/use-safe-areaInsets"; import ChatPage from "@/pages/chat"; import LibraryPage from "@/pages/library"; +import TrashPage from "@/pages/library/trash"; import SkillsPage from "@/pages/skills"; import StatisticsPage from "@/pages/statistics"; import { useAppSettingsStore } from "@/store/app-settings-store"; @@ -116,6 +117,14 @@ const HomeLayout = () => { } /> + + + + } + /> ([]); + const [trashCount, setTrashCount] = useState(0); const sidebarRef = useRef(null); + // 回收站计数徽标(路由变化时刷新) + // biome-ignore lint/correctness/useExhaustiveDependencies: 仅作刷新触发,effect 内不直接引用 + useEffect(() => { + getTrashedBooks() + .then((books) => setTrashCount(books.length)) + .catch(() => setTrashCount(0)); + }, [location.pathname]); + const clearSelectedTags = useCallback(() => { setSelectedTagsForDelete([]); }, []); @@ -212,6 +222,23 @@ export default function Sidebar() { })}
+ + + 回收站 + {trashCount > 0 && ( + + {trashCount} + + )} + {actionButtons.map((button, index) => { const Icon = button.icon; diff --git a/packages/app/src/pages/library/trash.tsx b/packages/app/src/pages/library/trash.tsx new file mode 100644 index 00000000..1ee19c20 --- /dev/null +++ b/packages/app/src/pages/library/trash.tsx @@ -0,0 +1,144 @@ +import { Button } from "@/components/ui/button"; +import { getTrashedBooks, purgeBook, restoreBook } from "@/services/book-service"; +import { useLibraryStore } from "@/store/library-store"; +import type { SimpleBook } from "@/types/simple-book"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { appDataDir } from "@tauri-apps/api/path"; +import { ask } from "@tauri-apps/plugin-dialog"; +import dayjs from "dayjs"; +import { Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; + +// 与 Rust 侧 TRASH_RETENTION_DAYS 保持一致 +const RETENTION_DAYS = 30; + +type TrashedBook = SimpleBook & { coverUrl?: string }; + +export default function TrashPage() { + const { refreshBooks } = useLibraryStore(); + const [trashedBooks, setTrashedBooks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const loadTrashedBooks = useCallback(async () => { + setIsLoading(true); + try { + const books = await getTrashedBooks(); + const appDataDirPath = await appDataDir(); + setTrashedBooks( + books.map((book) => ({ + ...book, + coverUrl: book.coverPath ? convertFileSrc(`${appDataDirPath}/${book.coverPath}`) : undefined, + })), + ); + } catch (error) { + console.error("加载回收站失败:", error); + toast.error("加载回收站失败"); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadTrashedBooks(); + }, [loadTrashedBooks]); + + const remainingDays = (trashedAt?: number | null): number => { + if (!trashedAt) return RETENTION_DAYS; + const elapsedDays = Math.floor((Date.now() - trashedAt) / (24 * 60 * 60 * 1000)); + return Math.max(0, RETENTION_DAYS - elapsedDays); + }; + + const handleRestore = async (book: TrashedBook) => { + try { + await restoreBook(book.id); + toast.success(`已恢复《${book.title}》`); + await loadTrashedBooks(); + await refreshBooks(); + } catch (error) { + console.error("恢复书籍失败:", error); + toast.error("恢复书籍失败"); + } + }; + + const handlePurge = async (book: TrashedBook) => { + try { + const confirmed = await ask( + `确定要彻底删除《${book.title}》吗?\n\n书籍文件和全部数据(阅读进度、笔记、对话)将被永久移除,此操作无法撤销。`, + { title: "彻底删除", kind: "warning" }, + ); + if (!confirmed) return; + + await purgeBook(book.id); + toast.success("已彻底删除"); + await loadTrashedBooks(); + } catch (error) { + console.error("彻底删除书籍失败:", error); + toast.error("彻底删除书籍失败"); + } + }; + + return ( +
+
+

回收站

+ + 删除的书籍保留 {RETENTION_DAYS} 天后自动彻底清除 + +
+ + {isLoading ? ( +
+
加载中...
+
+ ) : trashedBooks.length === 0 ? ( +
+
+
+ +
+

回收站是空的

+

+ 删除的书籍会在这里保留 {RETENTION_DAYS} 天,期间可随时恢复 +

+
+
+ ) : ( +
+
+ {trashedBooks.map((book) => ( +
+ {book.coverUrl ? ( + {book.title} + ) : ( +
+ 📖 +
+ )} +
+

+ {book.title} +

+

+ {book.author || "未知作者"} +

+

+ 删除于 {dayjs(book.trashedAt).format("YYYY-MM-DD HH:mm")} · 剩余 {remainingDays(book.trashedAt)} 天 +

+
+
+ + +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/packages/app/src/services/book-service.ts b/packages/app/src/services/book-service.ts index ff34b05c..d291f1bb 100644 --- a/packages/app/src/services/book-service.ts +++ b/packages/app/src/services/book-service.ts @@ -182,6 +182,37 @@ export async function deleteBook(id: string): Promise { } } +/** 从回收站恢复书籍 */ +export async function restoreBook(id: string): Promise { + try { + await invoke("restore_book", { id }); + } catch (error) { + console.error("恢复书籍失败:", error); + throw new Error(`恢复书籍失败: ${error instanceof Error ? error.message : "未知错误"}`); + } +} + +/** 回收站列表(按删除时间倒序) */ +export async function getTrashedBooks(): Promise { + try { + const result = await invoke("get_trashed_books"); + return result; + } catch (error) { + console.error("获取回收站失败:", error); + throw new Error(`获取回收站失败: ${error instanceof Error ? error.message : "未知错误"}`); + } +} + +/** 彻底删除(删磁盘文件与全部关联数据,不可恢复) */ +export async function purgeBook(id: string): Promise { + try { + await invoke("purge_book", { id }); + } catch (error) { + console.error("彻底删除书籍失败:", error); + throw new Error(`彻底删除书籍失败: ${error instanceof Error ? error.message : "未知错误"}`); + } +} + export async function searchBooks( query: string, options: Omit = {}, diff --git a/packages/app/src/types/simple-book.ts b/packages/app/src/types/simple-book.ts index 0965be2d..c711107b 100644 --- a/packages/app/src/types/simple-book.ts +++ b/packages/app/src/types/simple-book.ts @@ -11,6 +11,9 @@ export interface SimpleBook { tags?: string[]; + /** 回收站软删除时间戳(毫秒),null/undefined = 未删除 */ + trashedAt?: number | null; + createdAt: number; updatedAt: number; }