feat: 实现书籍管理基础功能,完成导入、列表、编辑等接口

- 新增书籍数据模型与数据库表结构
- 实现后端书籍CRUD与章节分页接口
- 添加前端书籍列表页面与导入功能
- 补充类型定义与API封装
This commit is contained in:
Yuhang Wu 2026-08-20 15:56:35 +08:00
parent efae900fcf
commit 8177cf9ab6
9 changed files with 488 additions and 52 deletions

View File

@ -34,7 +34,7 @@ sqlx = { version = "0.9.0", features = [
"tls-native-tls",
"chrono",
] }
chrono = "0.4.45"
chrono = { version = "0.4.45", features = ["serde"] }
tauri-plugin-dialog = "2.7.2"
encoding_rs = "0.8.35"
regex = "1.13.1"

View File

@ -1,6 +1,18 @@
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use tauri::State;
use tauri_helper::auto_collect_command;
use crate::models::ApiResponse;
use crate::models::{ApiResponse, Books, Chapters, PageResult};
#[derive(Debug, Deserialize, Serialize)]
pub struct BookSaveReq {
pub id: i64,
pub title: String,
pub author: String,
pub cover: String,
pub instruction: String,
}
/**
*
@ -9,17 +21,137 @@ use crate::models::ApiResponse;
*/
#[tauri::command]
#[auto_collect_command]
pub async fn import_book(path: String) -> Result<ApiResponse<()>, String> {
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string());
Ok(ApiResponse::success(()))
pub async fn import_book(
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)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::<()>::success_empty())
}
/**
*
*
* @param title
* @returns
*/
#[tauri::command]
#[auto_collect_command]
pub async fn book_list() -> Result<ApiResponse<Vec<()>>, String> {
Ok(ApiResponse::success(Vec::new()))
pub async fn book_list(
pool: State<'_, SqlitePool>,
title: String,
) -> Result<ApiResponse<Vec<Books>>, String> {
let title = title.trim().to_string();
let books = sqlx::query_as::<_, Books>("SELECT * FROM books WHERE title LIKE ?")
.bind(format!("%{}%", title))
.fetch_all(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::success(books))
}
/**
*
* @param id ID
* @returns
*/
#[tauri::command]
#[auto_collect_command]
pub async fn book_edit(
pool: State<'_, SqlitePool>,
params: BookSaveReq,
) -> Result<ApiResponse<()>, String> {
sqlx::query("UPDATE books SET title = ?, author = ?, cover = ?, instruction = ? WHERE id = ?")
.bind(params.title)
.bind(params.author)
.bind(params.cover)
.bind(params.instruction)
.bind(params.id)
.execute(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::<()>::success_empty())
}
/**
*
* @param id ID
* @returns
*/
#[tauri::command]
#[auto_collect_command]
pub async fn book_del(pool: State<'_, SqlitePool>, id: i64) -> Result<ApiResponse<()>, String> {
sqlx::query("DELETE FROM books WHERE id = ?")
.bind(id)
.execute(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::<()>::success_empty())
}
/**
*
* @param id ID
* @returns
*/
#[tauri::command]
#[auto_collect_command]
pub async fn book_detail(
pool: State<'_, SqlitePool>,
id: i64,
) -> Result<ApiResponse<Books>, String> {
let book = sqlx::query_as::<_, Books>("SELECT * FROM books WHERE id = ?")
.bind(id)
.fetch_one(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::success(book))
}
/**
*
* @param book_id ID
* @param page
* @param limit
* @returns
*/
#[tauri::command]
#[auto_collect_command]
pub async fn chapter_page(
pool: State<'_, SqlitePool>,
book_id: i64,
page: i32,
limit: i32,
) -> Result<ApiResponse<PageResult<Chapters>>, String> {
let offset = (page - 1) * limit;
let total: i64 =
sqlx::query_scalar::<_, i64>("SELECT COUNT(id) FROM chapters WHERE book_id = ?")
.bind(book_id)
.fetch_one(&*pool)
.await
.map_err(|e| e.to_string())?;
let chapters =
sqlx::query_as::<_, Chapters>("SELECT * FROM chapters WHERE book_id = ? LIMIT ?,?")
.bind(book_id)
.bind(offset)
.bind(limit)
.fetch_all(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::success(PageResult {
total,
list: chapters,
page,
page_size: limit,
}))
}

View File

@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use sqlx::{FromRow};
#[derive(Debug, Serialize, Deserialize)]
pub struct PageResult<T> {
@ -21,7 +22,7 @@ impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self {
Self {
success: true,
code: 200,
code: 0,
msg: "操作成功".to_string(),
data: Some(data),
}
@ -30,7 +31,7 @@ impl<T> ApiResponse<T> {
pub fn success_empty() -> ApiResponse<()> {
ApiResponse {
success: true,
code: 200,
code: 0,
msg: "操作成功".to_string(),
data: None,
}
@ -46,35 +47,28 @@ impl<T> ApiResponse<T> {
}
}
#[derive(Debug, Serialize)]
pub struct BookInfo {
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Books {
pub id: i64,
pub title: String,
pub author: String,
pub cover: String,
pub total_chapters: i32,
pub total_chars: i32,
pub introduction: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Chapter {
pub id: i64,
pub number: i32,
pub title: String,
pub content: String,
pub char_count: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Book {
pub id: Option<i64>,
pub title: String,
pub author: String,
pub cover: String,
pub introduction: String,
pub file_path: String,
pub total_chapters: i32,
pub total_chars: i32,
pub chapters: Vec<Chapter>,
pub introduction: String,
pub create_time: chrono::NaiveDateTime,
pub last_read_chapter_id: i64,
pub last_read_position: i32,
pub last_read_time: chrono::NaiveDateTime,
}
#[derive(Debug, FromRow, Serialize, Deserialize, Clone)]
pub struct Chapters {
pub id: i64,
pub book_id: i64,
pub number: i32,
pub title: String,
pub content: String,
pub total_chars: i32,
}

View File

@ -1,4 +1,4 @@
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use tauri::{AppHandle, Manager};
pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
@ -6,12 +6,14 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
let app_dir = app.path().app_data_dir().expect("无法获取APP数据目录");
std::fs::create_dir_all(&app_dir).unwrap();
let db_path = app_dir.join("reader.db");
let db_url = format!("sqlite:{}", db_path.to_str().unwrap());
// 初始化数据库连接池
let options = SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.min_connections(1)
.connect(&db_url)
.connect_with(options)
.await?;
sqlx::query(
"
@ -20,6 +22,7 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
title TEXT DEFAULT '',
author TEXT DEFAULT '',
cover TEXT DEFAULT '',
introduction TEXT DEFAULT '',
file_path TEXT DEFAULT '',
total_chapters INTEGER DEFAULT 0,
total_chars INTEGER DEFAULT 0,
@ -33,7 +36,7 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
CREATE TABLE IF NOT EXISTS chapters (
id INTEGER,
book_id INTEGER NOT NULL,
chapter_number INTEGER NOT NULL,
number INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
total_chars INTEGER DEFAULT 0,
@ -41,7 +44,7 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
);
CREATE INDEX IF NOT EXISTS idx_chapters_book
ON chapters(book_id, chapter_number);
ON chapters(book_id, number);
",
)
.execute(&pool)

View File

@ -1 +1,2 @@
pub mod db;
pub mod parser;

View File

@ -0,0 +1,9 @@
// use regex::Regex;
// use std::path::Path;
// use crate::models::{Books, Chapters};
// static CHAPTER_RE: Lazy<Regex> = Lazy::new(|| {
// Regex::new(r"(?m)^[ \t]*(第[0-9一二三四五六七八九十百千万零两]+[章节回卷集部篇][^\n]*|Chapter\s+\d+[^\n]*|楔子|序章|前言|引子|后记|尾声|番外[^\n]*)\s*$").unwrap()
// });

57
src/api/book.ts Normal file
View File

@ -0,0 +1,57 @@
import { invoke } from "@tauri-apps/api/core"
import { ApiResponse, PageResult, Books, Chapters, BookSaveParams } from "@/types/global"
const BookApi = {
/**
*
* @param path
* @returns
*/
import: async (path: string) => {
return await invoke<ApiResponse<any>>("import_book", { path })
},
/**
*
* @param title
* @returns
*/
list: async (title: string) => {
return await invoke<ApiResponse<Books[]>>("book_list", { title })
},
/**
*
* @param params
* @returns
*/
edit: async (params: BookSaveParams) => {
return await invoke<ApiResponse<Books>>("book_edit", { params })
},
/**
*
* @param id ID
* @returns
*/
del: async (id: number) => {
return await invoke<ApiResponse<any>>("book_del", { id })
},
/**
*
* @param id ID
* @returns
*/
detail: async (id: number) => {
return await invoke<ApiResponse<Books>>("book_detail", { id })
},
/**
*
* @param book_id 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 })
},
}
export default BookApi

47
src/types/global.ts Normal file
View File

@ -0,0 +1,47 @@
interface ApiResponse<T> {
success: boolean
code: number
msg: string
data: T
}
interface PageResult<T> {
total: number
list: T[]
page: number
size: number
}
interface Books {
id: number
title: string
author: string
cover: string
instruction: string
file_path: string
total_chapters: number
total_chars: number
create_time: string
last_read_time: string
last_read_chapter: number
last_read_position: number
}
interface Chapters {
id: number
book_id: number
number: number
title: string
content: string
total_chars: number
}
interface BookSaveParams {
id: number
title: string
author: string
cover: string
instruction: string
}
export type { ApiResponse, PageResult, Books, Chapters, BookSaveParams }

View File

@ -1,12 +1,7 @@
<template> <div class="home">
<template>
<div class="home">
<header class="home-header">
<n-input
v-model:value="searchValue"
class="search-input"
round
clearable
placeholder="搜索书籍"
>
<n-input v-model:value="searchValue" class="search-input" round clearable placeholder="搜索书籍">
<template #prefix>
<n-icon :component="BookSearch24Regular" />
</template>
@ -15,6 +10,9 @@
<button class="setting-btn" title="设置" aria-label="设置" @click="goSettingView">
<n-icon :size="20" :component="SettingOutlined" />
</button>
<button class="setting-btn" title="阅读" aria-label="阅读" @click="goReaderView">
<n-icon :size="20" :component="SettingOutlined" />
</button>
</header>
<section class="home-body">
@ -29,6 +27,39 @@
<n-button size="small" type="primary" round @click="importBook">立即导入</n-button>
</template>
</n-empty>
<div v-else class="book-grid">
<div
v-for="book in bookList"
:key="book.id"
class="book-card"
@click="goDetail(book)"
>
<div class="book-cover">
<img
v-if="resolveCover(book.cover)"
:src="resolveCover(book.cover)"
:alt="book.title"
/>
<div
v-else
class="cover-fallback"
:style="{ background: coverGradient(book.title) }"
>
<span class="cover-spine"></span>
<div class="cover-meta">
<span class="cover-title">{{ book.title }}</span>
<span class="cover-author">{{ book.author || "佚名" }}</span>
</div>
</div>
</div>
<div class="book-info">
<div class="book-title" :title="book.title">{{ book.title }}</div>
<div class="book-author">{{ book.author || "佚名" }}</div>
</div>
</div>
</div>
</n-scrollbar>
</section>
</div>
@ -36,25 +67,84 @@
<script setup lang="ts">
import { open } from "@tauri-apps/plugin-dialog"
import { convertFileSrc } from "@tauri-apps/api/core"
import { BookSearch24Regular } from "@vicons/fluent"
import { SettingOutlined } from "@vicons/antd"
import BookApi from "@/api/book"
import type { Books } from "@/types/global"
const router = useRouter()
const message = useMessage()
const bookList = ref([])
const bookList = ref<Books[]>([])
const searchValue = ref("")
const coverGradients = [
"linear-gradient(135deg, #667eea, #764ba2)",
"linear-gradient(135deg, #f093fb, #f5576c)",
"linear-gradient(135deg, #4facfe, #00f2fe)",
"linear-gradient(135deg, #43e97b, #38f9d7)",
"linear-gradient(135deg, #fa709a, #fee140)",
"linear-gradient(135deg, #30cfd0, #330867)",
"linear-gradient(135deg, #ff9a9e, #fecfef)",
"linear-gradient(135deg, #a18cd1, #fbc2eb)",
]
const coverGradient = (title: string) => {
let hash = 0
for (let i = 0; i < title.length; i++) {
hash = (hash * 31 + title.charCodeAt(i)) >>> 0
}
return coverGradients[hash % coverGradients.length]
}
const resolveCover = (cover: string) => {
if (!cover) return ""
if (/^(https?:|data:|blob:|asset:)/.test(cover)) return cover
return convertFileSrc(cover)
}
const getBookList = async () => {
const res = await BookApi.list(searchValue.value)
if (res.code === 0) {
bookList.value = res.data
} else {
message.error(res.msg)
}
}
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 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) {
message.success("导入成功")
getBookList()
} else {
message.error(res.msg)
}
}
}
onMounted(() => {
getBookList()
})
</script>
<style lang="scss" scoped>
@ -160,7 +250,110 @@ const importBook = async () => {
align-items: center;
justify-content: center;
}
</style>
pt>
<style lang="scss" scoped></style>
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 20px;
padding-bottom: 8px;
}
.book-card {
cursor: pointer;
display: flex;
flex-direction: column;
}
.book-cover {
position: relative;
aspect-ratio: 2 / 3;
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow-card);
transition: transform 0.2s ease, box-shadow 0.2s ease;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.book-card:hover .book-cover {
transform: translateY(-4px);
box-shadow: var(--shadow-card-hover);
}
.cover-fallback {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 16px 12px;
color: #fff;
text-align: center;
}
.cover-spine {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 8px;
background: rgba(0, 0, 0, 0.18);
}
.cover-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cover-title {
font-size: 14px;
font-weight: 700;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.cover-author {
font-size: 12px;
opacity: 0.85;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-info {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.book-title {
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.book-author {
font-size: 12px;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>