Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 93 additions & 13 deletions packages/app/src-tauri/src/core/books/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<Vec<SimpleBook>, 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<Vec<SimpleBook>, 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<usize, String> {
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]
Expand Down Expand Up @@ -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());
}
}

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

Expand Down
4 changes: 4 additions & 0 deletions packages/app/src-tauri/src/core/books/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub struct SimpleBook {
pub file_size: i64,
pub language: String,
pub tags: Option<Vec<String>>,
#[serde(rename = "trashedAt")]
pub trashed_at: Option<i64>,
#[serde(rename = "createdAt")]
pub created_at: i64,
#[serde(rename = "updatedAt")]
Expand Down Expand Up @@ -128,6 +130,7 @@ impl SimpleBook {
file_size,
language,
tags: None,
trashed_at: None,
created_at: now,
updated_at: now,
}
Expand All @@ -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")?,
})
Expand Down
20 changes: 20 additions & 0 deletions packages/app/src-tauri/src/core/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,33 @@ pub async fn initialize(app_handle: &AppHandle) -> Result<SqlitePool, Box<dyn st
.await?;
println!("Database schema initialized.");

run_migrations(&pool).await?;

if is_new_db {
initialize_default_skills(&pool).await?;
}

Ok(pool)
}

/// fork 专属迁移通道:上游同步 schema.sql 时的增量变更都放这里,避免改 schema.sql 冲突。
/// 所有迁移必须幂等。
async fn run_migrations(pool: &SqlitePool) -> Result<(), Box<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
let default_skills_json = include_str!("./default-skills.json");
let default_skills: Vec<DefaultSkill> = serde_json::from_str(default_skills_json)?;
Expand Down
1 change: 1 addition & 0 deletions packages/app/src-tauri/src/core/schema.sql
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -111,6 +114,12 @@ pub fn run() {
let state = app_handle.state::<AppState>();
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(())
})
Expand All @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/components/home-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -116,6 +117,14 @@ const HomeLayout = () => {
</div>
}
/>
<Route
path="/trash"
element={
<div className="flex h-full flex-1 flex-col rounded-xl border bg-background shadow-around">
<TrashPage />
</div>
}
/>
<Route
path="/notes"
element={
Expand Down
29 changes: 28 additions & 1 deletion packages/app/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { useBooksOperations } from "@/pages/library/hooks/use-books-operations";
import { useLibraryUI } from "@/pages/library/hooks/use-library-ui";
import { useTagsManagement } from "@/pages/library/hooks/use-tags-management";
import { useTagsOperations } from "@/pages/library/hooks/use-tags-operations";
import { getTrashedBooks } from "@/services/book-service";
import { useAppSettingsStore } from "@/store/app-settings-store";
import { useLibraryStore } from "@/store/library-store";
import clsx from "clsx";
import { BarChart3, Brain, ChevronDown, ChevronRight, Library, Lightbulb, Settings } from "lucide-react";
import { BarChart3, Brain, ChevronDown, ChevronRight, Library, Lightbulb, Settings, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";

Expand Down Expand Up @@ -38,8 +39,17 @@ export default function Sidebar() {
const { handleBookUpdate } = useBooksOperations(refreshBooks);

const [selectedTagsForDelete, setSelectedTagsForDelete] = useState<string[]>([]);
const [trashCount, setTrashCount] = useState(0);
const sidebarRef = useRef<HTMLElement>(null);

// 回收站计数徽标(路由变化时刷新)
// biome-ignore lint/correctness/useExhaustiveDependencies: 仅作刷新触发,effect 内不直接引用
useEffect(() => {
getTrashedBooks()
.then((books) => setTrashCount(books.length))
.catch(() => setTrashCount(0));
}, [location.pathname]);

const clearSelectedTags = useCallback(() => {
setSelectedTagsForDelete([]);
}, []);
Expand Down Expand Up @@ -212,6 +222,23 @@ export default function Sidebar() {
})}
</nav>
<div className="space-y-1 px-2 py-3">
<Link
to="/trash"
className={clsx(
"flex w-full items-center gap-2 rounded-md p-1 py-1 text-left text-sm transition-colors hover:bg-border",
location.pathname === "/trash"
? "text-neutral-900 dark:text-neutral-100"
: "text-neutral-600 dark:text-neutral-300",
)}
>
<Trash2 size={16} className="flex-shrink-0" />
<span className="text-sm">回收站</span>
{trashCount > 0 && (
<span className="ml-auto rounded-full bg-neutral-200 px-1.5 text-neutral-600 text-xs dark:bg-neutral-700 dark:text-neutral-300">
{trashCount}
</span>
)}
</Link>
{actionButtons.map((button, index) => {
const Icon = button.icon;

Expand Down
Loading