From c12cbaaabc05905677e84d3f40e8afce4637c151 Mon Sep 17 00:00:00 2001 From: Yuhang Wu Date: Thu, 20 Aug 2026 17:00:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E4=B9=A6=E7=B1=8D?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E3=80=81=E7=AE=A1=E7=90=86=E4=B8=8E=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 重构书籍导入逻辑,新增本地小说解析功能,自动提取书名、简介、章节 2. 修复参数命名不统一问题,统一使用introduction替代instruction 3. 新增右键菜单编辑删除书籍功能,添加编辑弹窗与表单校验 4. 优化书籍封面与简介展示样式,修复章节加载异常处理 5. 完善数据库表结构,兼容旧版本数据库 6. 替换上传图标为更贴合的导入图标,新增缺失的naive-ui组件类型声明 --- src-tauri/src/commands/book.rs | 51 ++++++-- src-tauri/src/services/db.rs | 35 +++++- src-tauri/src/services/parser.rs | 108 ++++++++++++++++- src/api/book.ts | 8 +- src/components/BookCover.vue | 2 +- src/types/components.d.ts | 4 + src/types/global.ts | 2 +- src/views/BookDetail.vue | 25 +++- src/views/Home.vue | 197 +++++++++++++++++++++++++++++-- 9 files changed, 396 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/commands/book.rs b/src-tauri/src/commands/book.rs index 30be69f..850dbde 100644 --- a/src-tauri/src/commands/book.rs +++ b/src-tauri/src/commands/book.rs @@ -4,6 +4,7 @@ use tauri::State; use tauri_helper::auto_collect_command; use crate::models::{ApiResponse, Books, Chapters, PageResult}; +use crate::services::parser::parse_book; #[derive(Debug, Deserialize, Serialize)] pub struct BookSaveReq { @@ -11,7 +12,7 @@ pub struct BookSaveReq { pub title: String, pub author: String, pub cover: String, - pub instruction: String, + pub introduction: String, } /** @@ -21,21 +22,45 @@ pub struct BookSaveReq { */ #[tauri::command] #[auto_collect_command] -pub async fn import_book( +pub async fn book_import( pool: State<'_, SqlitePool>, path: String, ) -> Result, String> { - let title = std::path::Path::new(&path) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("未命名") - .to_string(); - sqlx::query("INSERT INTO books (title, file_path) VALUES (?,?)") - .bind(&title) - .bind(&path) - .execute(&*pool) + let parsed = parse_book(&path)?; + + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + + let result = sqlx::query( + "INSERT INTO books (title, author, introduction, file_path, total_chapters, total_chars) VALUES (?,?,?,?,?,?)", + ) + .bind(&parsed.title) + .bind(&parsed.author) + .bind(&parsed.introduction) + .bind(&path) + .bind(parsed.total_chapters) + .bind(parsed.total_chars) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())?; + + let book_id = result.last_insert_rowid(); + + for chapter in &parsed.chapters { + sqlx::query( + "INSERT INTO chapters (book_id, number, title, content, total_chars) VALUES (?,?,?,?,?)", + ) + .bind(book_id) + .bind(chapter.number) + .bind(&chapter.title) + .bind(&chapter.content) + .bind(chapter.total_chars) + .execute(&mut *tx) .await .map_err(|e| e.to_string())?; + } + + tx.commit().await.map_err(|e| e.to_string())?; + Ok(ApiResponse::<()>::success_empty()) } @@ -71,11 +96,11 @@ pub async fn book_edit( pool: State<'_, SqlitePool>, params: BookSaveReq, ) -> Result, String> { - sqlx::query("UPDATE books SET title = ?, author = ?, cover = ?, instruction = ? WHERE id = ?") + sqlx::query("UPDATE books SET title = ?, author = ?, cover = ?, introduction = ? WHERE id = ?") .bind(params.title) .bind(params.author) .bind(params.cover) - .bind(params.instruction) + .bind(params.introduction) .bind(params.id) .execute(&*pool) .await diff --git a/src-tauri/src/services/db.rs b/src-tauri/src/services/db.rs index 2c933c8..36c0d84 100644 --- a/src-tauri/src/services/db.rs +++ b/src-tauri/src/services/db.rs @@ -34,7 +34,7 @@ pub async fn init_pool(app: &AppHandle) -> Result { ); CREATE TABLE IF NOT EXISTS chapters ( - id INTEGER, + id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, number INTEGER NOT NULL, title TEXT NOT NULL DEFAULT '', @@ -49,5 +49,38 @@ pub async fn init_pool(app: &AppHandle) -> Result { ) .execute(&pool) .await?; + + // 兼容旧库:确保 chapters.id 为主键(自增) + let id_is_pk: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM pragma_table_info('chapters') WHERE name = 'id' AND pk > 0", + ) + .fetch_one(&pool) + .await + .unwrap_or(0); + + if id_is_pk == 0 { + sqlx::query("DROP TABLE IF EXISTS chapters") + .execute(&pool) + .await?; + sqlx::query( + " + CREATE TABLE IF NOT EXISTS chapters ( + id INTEGER PRIMARY KEY, + book_id INTEGER NOT NULL, + number INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL, + total_chars INTEGER DEFAULT 0, + FOREIGN KEY (book_id) REFERENCES books ON DELETE CASCADE + ); + ", + ) + .execute(&pool) + .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_chapters_book ON chapters(book_id, number)") + .execute(&pool) + .await?; + } + Ok(pool) } diff --git a/src-tauri/src/services/parser.rs b/src-tauri/src/services/parser.rs index f629cd1..68bdc42 100644 --- a/src-tauri/src/services/parser.rs +++ b/src-tauri/src/services/parser.rs @@ -1,9 +1,105 @@ -// use regex::Regex; -// use std::path::Path; +use std::path::Path; +use std::sync::LazyLock; -// use crate::models::{Books, Chapters}; +use chardetng::{EncodingDetector, Iso2022JpDetection, Utf8Detection}; +use regex::Regex; -// static CHAPTER_RE: Lazy = Lazy::new(|| { -// Regex::new(r"(?m)^[ \t]*(第[0-9一二三四五六七八九十百千万零两]+[章节回卷集部篇][^\n]*|Chapter\s+\d+[^\n]*|楔子|序章|前言|引子|后记|尾声|番外[^\n]*)\s*$").unwrap() -// }); +static CHAPTER_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?m)^[ \t]*(第[0-9一二三四五六七八九十百千万零两]+[章节回卷集部篇][^\n]*|Chapter\s+\d+[^\n]*|楔子|序章|前言|引子|后记|尾声|番外[^\n]*)\s*$").unwrap() +}); +#[derive(Debug, Clone)] +pub struct ParsedChapter { + pub number: i32, + pub title: String, + pub content: String, + pub total_chars: i32, +} + +#[derive(Debug, Clone)] +pub struct ParsedBook { + pub title: String, + pub author: String, + pub introduction: String, + pub total_chapters: i32, + pub total_chars: i32, + pub chapters: Vec, +} + +/// 读取文件内容,自动识别编码并解码为 UTF-8 字符串 +pub fn read_to_string(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("读取文件失败: {e}"))?; + if bytes.is_empty() { + return Ok(String::new()); + } + let mut detector = EncodingDetector::new(Iso2022JpDetection::Deny); + detector.feed(&bytes, true); + let encoding = detector.guess(None, Utf8Detection::Allow); + let (text, _, _) = encoding.decode(&bytes); + Ok(text.into_owned()) +} + +/// 解析小说文件,返回书籍信息与章节列表 +pub fn parse_book(file_path: &str) -> Result { + let text = read_to_string(file_path)?; + if text.trim().is_empty() { + return Err("文件内容为空".to_string()); + } + + let title = Path::new(file_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("未命名") + .to_string(); + + let mut positions: Vec<(usize, usize)> = Vec::new(); + for m in CHAPTER_RE.find_iter(&text) { + positions.push((m.start(), m.end())); + } + + let mut chapters: Vec = Vec::new(); + let mut introduction = String::new(); + + if positions.is_empty() { + let content = text.trim().to_string(); + let total_chars = content.chars().count() as i32; + chapters.push(ParsedChapter { + number: 1, + title: "正文".to_string(), + content, + total_chars, + }); + } else { + introduction = text[..positions[0].0].trim().to_string(); + + for (i, &(start, end)) in positions.iter().enumerate() { + let chapter_title = text[start..end].trim().to_string(); + let content_start = end; + let content_end = if i + 1 < positions.len() { + positions[i + 1].0 + } else { + text.len() + }; + let content = text[content_start..content_end].trim().to_string(); + let total_chars = content.chars().count() as i32; + chapters.push(ParsedChapter { + number: i as i32 + 1, + title: chapter_title, + content, + total_chars, + }); + } + } + + let total_chars = chapters.iter().map(|c| c.total_chars).sum(); + let total_chapters = chapters.len() as i32; + + Ok(ParsedBook { + title, + author: String::new(), + introduction, + total_chapters, + total_chars, + chapters, + }) +} diff --git a/src/api/book.ts b/src/api/book.ts index 2421adc..76e7556 100644 --- a/src/api/book.ts +++ b/src/api/book.ts @@ -8,7 +8,7 @@ const BookApi = { * @returns 导入结果 */ import: async (path: string) => { - return await invoke>("import_book", { path }) + return await invoke>("book_import", { path }) }, /** * 获取书籍列表 @@ -44,13 +44,13 @@ const BookApi = { }, /** * 获取书籍章节列表 - * @param book_id 书籍ID + * @param bookId 书籍ID * @param page 页码 * @param limit 每页数量 * @returns 书籍章节列表 */ - chapters: async (book_id: number, page: number, limit: number) => { - return await invoke>>("chapter_page", { book_id, page, limit }) + chapters: async (bookId: number, page: number, limit: number) => { + return await invoke>>("chapter_page", { bookId, page, limit }) }, } diff --git a/src/components/BookCover.vue b/src/components/BookCover.vue index 0bd6439..38a6a90 100644 --- a/src/components/BookCover.vue +++ b/src/components/BookCover.vue @@ -4,7 +4,7 @@
- {{ title }} +

{{ title }}

{{ author || "佚名" }}
diff --git a/src/types/components.d.ts b/src/types/components.d.ts index fac502b..0df5cbe 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -20,7 +20,10 @@ declare module 'vue' { NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem'] NDialogProvider: typeof import('naive-ui')['NDialogProvider'] NDivider: typeof import('naive-ui')['NDivider'] + NDropdown: typeof import('naive-ui')['NDropdown'] NEmpty: typeof import('naive-ui')['NEmpty'] + NForm: typeof import('naive-ui')['NForm'] + NFormItem: typeof import('naive-ui')['NFormItem'] NIcon: typeof import('naive-ui')['NIcon'] NInput: typeof import('naive-ui')['NInput'] NLayout: typeof import('naive-ui')['NLayout'] @@ -29,6 +32,7 @@ declare module 'vue' { NList: typeof import('naive-ui')['NList'] NListItem: typeof import('naive-ui')['NListItem'] NMessageProvider: typeof import('naive-ui')['NMessageProvider'] + NModal: typeof import('naive-ui')['NModal'] NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] NPagination: typeof import('naive-ui')['NPagination'] NRadioButton: typeof import('naive-ui')['NRadioButton'] diff --git a/src/types/global.ts b/src/types/global.ts index 07c7f20..fb38a8d 100644 --- a/src/types/global.ts +++ b/src/types/global.ts @@ -41,7 +41,7 @@ interface BookSaveParams { title: string author: string cover: string - instruction: string + introduction: string } export type { ApiResponse, PageResult, Books, Chapters, BookSaveParams } diff --git a/src/views/BookDetail.vue b/src/views/BookDetail.vue index 0089897..dc396a4 100644 --- a/src/views/BookDetail.vue +++ b/src/views/BookDetail.vue @@ -25,7 +25,7 @@ {{ formatTime(book?.last_read_time) }} {{ readProgress }} - {{ book?.introduction || "暂无简介" }} +
{{ book?.introduction || "暂无简介" }}
@@ -138,6 +138,8 @@ const loadChapters = async () => { } else { message.error(res.msg) } + } catch (e) { + message.error("章节加载失败") } finally { loadingChapters.value = false } @@ -188,8 +190,9 @@ onMounted(() => { .summary-cover { width: 120px; - aspect-ratio: 2 / 3; + height: 180px; flex-shrink: 0; + align-self: flex-start; border-radius: var(--radius-sm); overflow: hidden; box-shadow: var(--shadow-card); @@ -213,6 +216,16 @@ onMounted(() => { text-overflow: ellipsis; } +.intro-content { + max-height: 120px; + overflow-y: auto; + line-height: 1.6; + font-size: 13px; + color: var(--color-text-secondary); + word-break: break-all; + white-space: pre-wrap; +} + .detail-divider { margin: 16px 0; } @@ -246,6 +259,14 @@ onMounted(() => { .chapter-scrollbar { flex: 1; min-height: 0; + + :deep(.n-scrollbar-container) { + height: 100%; + } + + :deep(.n-scrollbar-content) { + min-height: 100%; + } } .chapter-item { diff --git a/src/views/Home.vue b/src/views/Home.vue index cb451e2..28143d3 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -11,7 +11,7 @@ @@ -34,6 +34,7 @@ :key="book.id" class="book-card" @click="goDetail(book)" + @contextmenu.prevent="onContextMenu($event, book)" >
@@ -47,16 +48,89 @@
+ + + + + + + + + + + + + + + + + + + +
+ 确定要删除《{{ deleteBook?.title }}》吗?删除后不可恢复。 +
+ +