feat: 完成书籍导入、管理与详情页优化

1. 重构书籍导入逻辑,新增本地小说解析功能,自动提取书名、简介、章节
2. 修复参数命名不统一问题,统一使用introduction替代instruction
3. 新增右键菜单编辑删除书籍功能,添加编辑弹窗与表单校验
4. 优化书籍封面与简介展示样式,修复章节加载异常处理
5. 完善数据库表结构,兼容旧版本数据库
6. 替换上传图标为更贴合的导入图标,新增缺失的naive-ui组件类型声明
This commit is contained in:
Yuhang Wu 2026-08-20 17:00:51 +08:00
parent 117fbe5ac4
commit c12cbaaabc
9 changed files with 396 additions and 36 deletions

View File

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

View File

@ -34,7 +34,7 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
);
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<SqlitePool, sqlx::Error> {
)
.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)
}

View File

@ -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<Regex> = Lazy::new(|| {
// Regex::new(r"(?m)^[ \t]*(第[0-9一二三四五六七八九十百千万零两]+[章节回卷集部篇][^\n]*|Chapter\s+\d+[^\n]*|楔子|序章|前言|引子|后记|尾声|番外[^\n]*)\s*$").unwrap()
// });
static CHAPTER_RE: LazyLock<Regex> = 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<ParsedChapter>,
}
/// 读取文件内容,自动识别编码并解码为 UTF-8 字符串
pub fn read_to_string(path: &str) -> Result<String, String> {
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<ParsedBook, String> {
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<ParsedChapter> = 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,
})
}

View File

@ -8,7 +8,7 @@ const BookApi = {
* @returns
*/
import: async (path: string) => {
return await invoke<ApiResponse<any>>("import_book", { path })
return await invoke<ApiResponse<any>>("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<ApiResponse<PageResult<Chapters>>>("chapter_page", { book_id, page, limit })
chapters: async (bookId: number, page: number, limit: number) => {
return await invoke<ApiResponse<PageResult<Chapters>>>("chapter_page", { bookId, page, limit })
},
}

View File

@ -4,7 +4,7 @@
<div v-else class="cover-fallback" :style="{ background: coverGradient(title) }">
<span class="cover-spine"></span>
<div class="cover-meta">
<span class="cover-title">{{ title }}</span>
<p class="cover-title">{{ title }}</p>
<span class="cover-author">{{ author || "佚名" }}</span>
</div>
</div>

View File

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

View File

@ -41,7 +41,7 @@ interface BookSaveParams {
title: string
author: string
cover: string
instruction: string
introduction: string
}
export type { ApiResponse, PageResult, Books, Chapters, BookSaveParams }

View File

@ -25,7 +25,7 @@
<n-descriptions-item label="上次阅读">{{ formatTime(book?.last_read_time) }}</n-descriptions-item>
<n-descriptions-item label="阅读进度">{{ readProgress }}</n-descriptions-item>
<n-descriptions-item label="简介" :span="2">
{{ book?.introduction || "暂无简介" }}
<div class="intro-content">{{ book?.introduction || "暂无简介" }}</div>
</n-descriptions-item>
</n-descriptions>
</div>
@ -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 {

View File

@ -11,7 +11,7 @@
<n-icon :size="20" :component="SettingOutlined" />
</button>
<button class="setting-btn" title="导入" aria-label="导入" @click="importBook">
<n-icon :size="20" :component="CloudUploadOutlined" />
<n-icon :size="20" :component="ImportOutlined" />
</button>
</header>
@ -34,6 +34,7 @@
:key="book.id"
class="book-card"
@click="goDetail(book)"
@contextmenu.prevent="onContextMenu($event, book)"
>
<div class="book-cover-wrap">
<BookCover :title="book.title" :author="book.author" :cover="book.cover" />
@ -47,16 +48,89 @@
</div>
</n-scrollbar>
</section>
<n-dropdown
trigger="manual"
:show="showDropdown"
:x="dropdownX"
:y="dropdownY"
:options="dropdownOptions"
@select="handleDropdownSelect"
@clickoutside="showDropdown = false"
/>
<n-modal
v-model:show="showEditModal"
preset="card"
title="编辑书籍"
:bordered="false"
style="width: 480px"
>
<n-form
ref="formRef"
:model="editForm"
:rules="rules"
label-placement="left"
label-width="64"
require-mark-placement="left"
>
<n-form-item label="书名" path="title">
<n-input v-model:value="editForm.title" placeholder="请输入书名" />
</n-form-item>
<n-form-item label="作者" path="author">
<n-input v-model:value="editForm.author" placeholder="请输入作者" />
</n-form-item>
<n-form-item label="简介" path="introduction">
<n-input
v-model:value="editForm.introduction"
type="textarea"
placeholder="请输入简介"
:autosize="{ minRows: 3, maxRows: 6 }"
/>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showEditModal = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="submitEdit">保存</n-button>
</n-space>
</template>
</n-modal>
<n-modal
v-model:show="showDeleteModal"
preset="card"
title="删除书籍"
:bordered="false"
style="width: 400px"
>
<div class="delete-tip">
确定要删除{{ deleteBook?.title }}删除后不可恢复
</div>
<template #footer>
<n-space justify="end">
<n-button @click="showDeleteModal = false">取消</n-button>
<n-button type="error" :loading="deleting" @click="doDelete">删除</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { h, type Component } from "vue"
import { open } from "@tauri-apps/plugin-dialog"
import { BookSearch24Regular } from "@vicons/fluent"
import { SettingOutlined, CloudUploadOutlined } from "@vicons/antd"
import {
SettingOutlined,
ImportOutlined,
EditOutlined,
DeleteOutlined,
} from "@vicons/antd"
import { NIcon } from "naive-ui"
import BookApi from "@/api/book"
import BookCover from "@/components/BookCover.vue"
import type { Books } from "@/types/global"
import type { Books, BookSaveParams } from "@/types/global"
const router = useRouter()
const message = useMessage()
@ -64,6 +138,38 @@ const message = useMessage()
const bookList = ref<Books[]>([])
const searchValue = ref("")
const showDeleteModal = ref(false)
const deleting = ref(false)
const deleteBook = ref<Books | null>(null)
const showDropdown = ref(false)
const dropdownX = ref(0)
const dropdownY = ref(0)
const contextBook = ref<Books | null>(null)
const renderDropdownIcon = (icon: Component) => () => h(NIcon, null, { default: () => h(icon) })
const dropdownOptions = [
{ label: "编辑", key: "edit", icon: renderDropdownIcon(EditOutlined) },
{ label: "删除", key: "delete", icon: renderDropdownIcon(DeleteOutlined) },
]
const showEditModal = ref(false)
const saving = ref(false)
const formRef = ref<any>()
const editForm = reactive<BookSaveParams>({
id: 0,
title: "",
author: "",
cover: "",
introduction: "",
})
const rules = {
title: { required: true, message: "请输入书名", trigger: ["blur", "input"] },
}
const getBookList = async () => {
const res = await BookApi.list(searchValue.value)
if (res.code === 0) {
@ -77,20 +183,87 @@ const goSettingView = () => {
router.push({ name: "Setting" })
}
const goReaderView = () => {
router.push({ name: "Reader" })
}
const goDetail = (book: Books) => {
router.push({ name: "BookDetail", query: { id: book.id } })
}
const onContextMenu = (e: MouseEvent, book: Books) => {
contextBook.value = book
dropdownX.value = e.clientX
dropdownY.value = e.clientY
showDropdown.value = true
}
const handleDropdownSelect = (key: string | number) => {
showDropdown.value = false
if (key === "edit") {
openEditModal(contextBook.value)
} else if (key === "delete") {
confirmDelete(contextBook.value)
}
}
const openEditModal = (book: Books | null) => {
if (!book) return
editForm.id = book.id
editForm.title = book.title
editForm.author = book.author
editForm.cover = book.cover
editForm.introduction = book.introduction || ""
showEditModal.value = true
}
const submitEdit = async () => {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
const res = await BookApi.edit({ ...editForm })
if (res.code === 0) {
message.success("保存成功")
showEditModal.value = false
getBookList()
} else {
message.error(res.msg)
}
} finally {
saving.value = false
}
}
const confirmDelete = (book: Books | null) => {
if (!book) return
deleteBook.value = book
showDeleteModal.value = true
}
const doDelete = async () => {
if (!deleteBook.value || deleting.value) return
deleting.value = true
try {
const res = await BookApi.del(deleteBook.value.id)
if (res.code === 0) {
message.success("删除成功")
showDeleteModal.value = false
getBookList()
} else {
message.error(res.msg)
}
} catch (e) {
message.error("删除失败,请重试")
} finally {
deleting.value = false
}
}
const importBook = async () => {
const filePath = await open({
multiple: false,
filters: [{ name: "TXT", extensions: ["txt", "epub"] }]
})
console.log(filePath)
if (filePath) {
const res = await BookApi.import(filePath)
if (res.code === 0) {
@ -215,6 +388,7 @@ onMounted(() => {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 20px;
margin-top: 5px;
padding-bottom: 8px;
}
@ -260,4 +434,11 @@ onMounted(() => {
overflow: hidden;
text-overflow: ellipsis;
}
.delete-tip {
font-size: 14px;
line-height: 1.6;
color: var(--color-text-primary);
word-break: break-all;
}
</style>