feat: 完成书籍导入、管理与详情页优化
1. 重构书籍导入逻辑,新增本地小说解析功能,自动提取书名、简介、章节 2. 修复参数命名不统一问题,统一使用introduction替代instruction 3. 新增右键菜单编辑删除书籍功能,添加编辑弹窗与表单校验 4. 优化书籍封面与简介展示样式,修复章节加载异常处理 5. 完善数据库表结构,兼容旧版本数据库 6. 替换上传图标为更贴合的导入图标,新增缺失的naive-ui组件类型声明
This commit is contained in:
parent
117fbe5ac4
commit
c12cbaaabc
|
|
@ -4,6 +4,7 @@ use tauri::State;
|
||||||
use tauri_helper::auto_collect_command;
|
use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
use crate::models::{ApiResponse, Books, Chapters, PageResult};
|
use crate::models::{ApiResponse, Books, Chapters, PageResult};
|
||||||
|
use crate::services::parser::parse_book;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct BookSaveReq {
|
pub struct BookSaveReq {
|
||||||
|
|
@ -11,7 +12,7 @@ pub struct BookSaveReq {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub author: String,
|
pub author: String,
|
||||||
pub cover: String,
|
pub cover: String,
|
||||||
pub instruction: String,
|
pub introduction: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -21,21 +22,45 @@ pub struct BookSaveReq {
|
||||||
*/
|
*/
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[auto_collect_command]
|
#[auto_collect_command]
|
||||||
pub async fn import_book(
|
pub async fn book_import(
|
||||||
pool: State<'_, SqlitePool>,
|
pool: State<'_, SqlitePool>,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<ApiResponse<()>, String> {
|
) -> Result<ApiResponse<()>, String> {
|
||||||
let title = std::path::Path::new(&path)
|
let parsed = parse_book(&path)?;
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
let mut tx = pool.begin().await.map_err(|e| e.to_string())?;
|
||||||
.unwrap_or("未命名")
|
|
||||||
.to_string();
|
let result = sqlx::query(
|
||||||
sqlx::query("INSERT INTO books (title, file_path) VALUES (?,?)")
|
"INSERT INTO books (title, author, introduction, file_path, total_chapters, total_chars) VALUES (?,?,?,?,?,?)",
|
||||||
.bind(&title)
|
)
|
||||||
.bind(&path)
|
.bind(&parsed.title)
|
||||||
.execute(&*pool)
|
.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
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(ApiResponse::<()>::success_empty())
|
Ok(ApiResponse::<()>::success_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,11 +96,11 @@ pub async fn book_edit(
|
||||||
pool: State<'_, SqlitePool>,
|
pool: State<'_, SqlitePool>,
|
||||||
params: BookSaveReq,
|
params: BookSaveReq,
|
||||||
) -> Result<ApiResponse<()>, String> {
|
) -> 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.title)
|
||||||
.bind(params.author)
|
.bind(params.author)
|
||||||
.bind(params.cover)
|
.bind(params.cover)
|
||||||
.bind(params.instruction)
|
.bind(params.introduction)
|
||||||
.bind(params.id)
|
.bind(params.id)
|
||||||
.execute(&*pool)
|
.execute(&*pool)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS chapters (
|
CREATE TABLE IF NOT EXISTS chapters (
|
||||||
id INTEGER,
|
id INTEGER PRIMARY KEY,
|
||||||
book_id INTEGER NOT NULL,
|
book_id INTEGER NOT NULL,
|
||||||
number INTEGER NOT NULL,
|
number INTEGER NOT NULL,
|
||||||
title TEXT NOT NULL DEFAULT '',
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
|
@ -49,5 +49,38 @@ pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await?;
|
.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)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(|| {
|
static CHAPTER_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
// Regex::new(r"(?m)^[ \t]*(第[0-9一二三四五六七八九十百千万零两]+[章节回卷集部篇][^\n]*|Chapter\s+\d+[^\n]*|楔子|序章|前言|引子|后记|尾声|番外[^\n]*)\s*$").unwrap()
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const BookApi = {
|
||||||
* @returns 导入结果
|
* @returns 导入结果
|
||||||
*/
|
*/
|
||||||
import: async (path: string) => {
|
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 page 页码
|
||||||
* @param limit 每页数量
|
* @param limit 每页数量
|
||||||
* @returns 书籍章节列表
|
* @returns 书籍章节列表
|
||||||
*/
|
*/
|
||||||
chapters: async (book_id: number, page: number, limit: number) => {
|
chapters: async (bookId: number, page: number, limit: number) => {
|
||||||
return await invoke<ApiResponse<PageResult<Chapters>>>("chapter_page", { book_id, page, limit })
|
return await invoke<ApiResponse<PageResult<Chapters>>>("chapter_page", { bookId, page, limit })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<div v-else class="cover-fallback" :style="{ background: coverGradient(title) }">
|
<div v-else class="cover-fallback" :style="{ background: coverGradient(title) }">
|
||||||
<span class="cover-spine"></span>
|
<span class="cover-spine"></span>
|
||||||
<div class="cover-meta">
|
<div class="cover-meta">
|
||||||
<span class="cover-title">{{ title }}</span>
|
<p class="cover-title">{{ title }}</p>
|
||||||
<span class="cover-author">{{ author || "佚名" }}</span>
|
<span class="cover-author">{{ author || "佚名" }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ declare module 'vue' {
|
||||||
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
|
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
|
||||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||||
NDivider: typeof import('naive-ui')['NDivider']
|
NDivider: typeof import('naive-ui')['NDivider']
|
||||||
|
NDropdown: typeof import('naive-ui')['NDropdown']
|
||||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
|
NForm: typeof import('naive-ui')['NForm']
|
||||||
|
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||||
NIcon: typeof import('naive-ui')['NIcon']
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
NInput: typeof import('naive-ui')['NInput']
|
NInput: typeof import('naive-ui')['NInput']
|
||||||
NLayout: typeof import('naive-ui')['NLayout']
|
NLayout: typeof import('naive-ui')['NLayout']
|
||||||
|
|
@ -29,6 +32,7 @@ declare module 'vue' {
|
||||||
NList: typeof import('naive-ui')['NList']
|
NList: typeof import('naive-ui')['NList']
|
||||||
NListItem: typeof import('naive-ui')['NListItem']
|
NListItem: typeof import('naive-ui')['NListItem']
|
||||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||||
|
NModal: typeof import('naive-ui')['NModal']
|
||||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||||
NPagination: typeof import('naive-ui')['NPagination']
|
NPagination: typeof import('naive-ui')['NPagination']
|
||||||
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ interface BookSaveParams {
|
||||||
title: string
|
title: string
|
||||||
author: string
|
author: string
|
||||||
cover: string
|
cover: string
|
||||||
instruction: string
|
introduction: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ApiResponse, PageResult, Books, Chapters, BookSaveParams }
|
export type { ApiResponse, PageResult, Books, Chapters, BookSaveParams }
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
<n-descriptions-item label="上次阅读">{{ formatTime(book?.last_read_time) }}</n-descriptions-item>
|
<n-descriptions-item label="上次阅读">{{ formatTime(book?.last_read_time) }}</n-descriptions-item>
|
||||||
<n-descriptions-item label="阅读进度">{{ readProgress }}</n-descriptions-item>
|
<n-descriptions-item label="阅读进度">{{ readProgress }}</n-descriptions-item>
|
||||||
<n-descriptions-item label="简介" :span="2">
|
<n-descriptions-item label="简介" :span="2">
|
||||||
{{ book?.introduction || "暂无简介" }}
|
<div class="intro-content">{{ book?.introduction || "暂无简介" }}</div>
|
||||||
</n-descriptions-item>
|
</n-descriptions-item>
|
||||||
</n-descriptions>
|
</n-descriptions>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,6 +138,8 @@ const loadChapters = async () => {
|
||||||
} else {
|
} else {
|
||||||
message.error(res.msg)
|
message.error(res.msg)
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.error("章节加载失败")
|
||||||
} finally {
|
} finally {
|
||||||
loadingChapters.value = false
|
loadingChapters.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -188,8 +190,9 @@ onMounted(() => {
|
||||||
|
|
||||||
.summary-cover {
|
.summary-cover {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
aspect-ratio: 2 / 3;
|
height: 180px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
align-self: flex-start;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
|
|
@ -213,6 +216,16 @@ onMounted(() => {
|
||||||
text-overflow: ellipsis;
|
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 {
|
.detail-divider {
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
@ -246,6 +259,14 @@ onMounted(() => {
|
||||||
.chapter-scrollbar {
|
.chapter-scrollbar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
|
:deep(.n-scrollbar-container) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-scrollbar-content) {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-item {
|
.chapter-item {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<n-icon :size="20" :component="SettingOutlined" />
|
<n-icon :size="20" :component="SettingOutlined" />
|
||||||
</button>
|
</button>
|
||||||
<button class="setting-btn" title="导入" aria-label="导入" @click="importBook">
|
<button class="setting-btn" title="导入" aria-label="导入" @click="importBook">
|
||||||
<n-icon :size="20" :component="CloudUploadOutlined" />
|
<n-icon :size="20" :component="ImportOutlined" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
:key="book.id"
|
:key="book.id"
|
||||||
class="book-card"
|
class="book-card"
|
||||||
@click="goDetail(book)"
|
@click="goDetail(book)"
|
||||||
|
@contextmenu.prevent="onContextMenu($event, book)"
|
||||||
>
|
>
|
||||||
<div class="book-cover-wrap">
|
<div class="book-cover-wrap">
|
||||||
<BookCover :title="book.title" :author="book.author" :cover="book.cover" />
|
<BookCover :title="book.title" :author="book.author" :cover="book.cover" />
|
||||||
|
|
@ -47,16 +48,89 @@
|
||||||
</div>
|
</div>
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { h, type Component } from "vue"
|
||||||
import { open } from "@tauri-apps/plugin-dialog"
|
import { open } from "@tauri-apps/plugin-dialog"
|
||||||
import { BookSearch24Regular } from "@vicons/fluent"
|
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 BookApi from "@/api/book"
|
||||||
import BookCover from "@/components/BookCover.vue"
|
import BookCover from "@/components/BookCover.vue"
|
||||||
import type { Books } from "@/types/global"
|
import type { Books, BookSaveParams } from "@/types/global"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
@ -64,6 +138,38 @@ const message = useMessage()
|
||||||
const bookList = ref<Books[]>([])
|
const bookList = ref<Books[]>([])
|
||||||
const searchValue = ref("")
|
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 getBookList = async () => {
|
||||||
const res = await BookApi.list(searchValue.value)
|
const res = await BookApi.list(searchValue.value)
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
|
|
@ -77,20 +183,87 @@ const goSettingView = () => {
|
||||||
router.push({ name: "Setting" })
|
router.push({ name: "Setting" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const goReaderView = () => {
|
|
||||||
router.push({ name: "Reader" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const goDetail = (book: Books) => {
|
const goDetail = (book: Books) => {
|
||||||
router.push({ name: "BookDetail", query: { id: book.id } })
|
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 importBook = async () => {
|
||||||
const filePath = await open({
|
const filePath = await open({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
filters: [{ name: "TXT", extensions: ["txt", "epub"] }]
|
filters: [{ name: "TXT", extensions: ["txt", "epub"] }]
|
||||||
})
|
})
|
||||||
console.log(filePath)
|
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
const res = await BookApi.import(filePath)
|
const res = await BookApi.import(filePath)
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
|
|
@ -215,6 +388,7 @@ onMounted(() => {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
margin-top: 5px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,4 +434,11 @@ onMounted(() => {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delete-tip {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue