Compare commits
3 Commits
5c1be1cdb0
...
117fbe5ac4
| Author | SHA1 | Date |
|---|---|---|
|
|
117fbe5ac4 | |
|
|
8177cf9ab6 | |
|
|
efae900fcf |
|
|
@ -488,6 +488,17 @@ dependencies = [
|
||||||
"rand_core",
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chardetng"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"encoding_rs",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
|
|
@ -1024,6 +1035,15 @@ version = "1.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|
@ -3829,7 +3849,10 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
name = "stealthreader"
|
name = "stealthreader"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chardetng",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"encoding_rs",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
|
|
||||||
|
|
@ -34,5 +34,8 @@ sqlx = { version = "0.9.0", features = [
|
||||||
"tls-native-tls",
|
"tls-native-tls",
|
||||||
"chrono",
|
"chrono",
|
||||||
] }
|
] }
|
||||||
chrono = "0.4.45"
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
tauri-plugin-dialog = "2.7.2"
|
tauri-plugin-dialog = "2.7.2"
|
||||||
|
encoding_rs = "0.8.35"
|
||||||
|
regex = "1.13.1"
|
||||||
|
chardetng = "1.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,157 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tauri::State;
|
||||||
use tauri_helper::auto_collect_command;
|
use tauri_helper::auto_collect_command;
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入书籍
|
||||||
|
* @param path 书籍文件路径
|
||||||
|
* @returns 导入结果
|
||||||
|
*/
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[auto_collect_command]
|
#[auto_collect_command]
|
||||||
pub async fn import_book() -> Result<(), String> {
|
pub async fn import_book(
|
||||||
Ok(())
|
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(
|
||||||
|
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,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ pub fn run() {
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let pool = tauri::async_runtime::block_on(async {
|
let pool = tauri::async_runtime::block_on(async {
|
||||||
init_pool().await.expect("sqlite数据库连接池初始化失败!")
|
init_pool(app.handle()).await.expect("sqlite数据库连接池初始化失败!")
|
||||||
});
|
});
|
||||||
app.manage(pool);
|
app.manage(pool);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{FromRow};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct PageResult<T> {
|
pub struct PageResult<T> {
|
||||||
|
|
@ -21,7 +22,7 @@ impl<T> ApiResponse<T> {
|
||||||
pub fn success(data: T) -> Self {
|
pub fn success(data: T) -> Self {
|
||||||
Self {
|
Self {
|
||||||
success: true,
|
success: true,
|
||||||
code: 200,
|
code: 0,
|
||||||
msg: "操作成功".to_string(),
|
msg: "操作成功".to_string(),
|
||||||
data: Some(data),
|
data: Some(data),
|
||||||
}
|
}
|
||||||
|
|
@ -30,7 +31,7 @@ impl<T> ApiResponse<T> {
|
||||||
pub fn success_empty() -> ApiResponse<()> {
|
pub fn success_empty() -> ApiResponse<()> {
|
||||||
ApiResponse {
|
ApiResponse {
|
||||||
success: true,
|
success: true,
|
||||||
code: 200,
|
code: 0,
|
||||||
msg: "操作成功".to_string(),
|
msg: "操作成功".to_string(),
|
||||||
data: None,
|
data: None,
|
||||||
}
|
}
|
||||||
|
|
@ -45,3 +46,29 @@ impl<T> ApiResponse<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Books {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub author: String,
|
||||||
|
pub cover: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub total_chapters: i32,
|
||||||
|
pub total_chars: i32,
|
||||||
|
pub introduction: String,
|
||||||
|
pub create_time: chrono::NaiveDateTime,
|
||||||
|
pub last_read_chapter_id: i64,
|
||||||
|
pub last_read_position: i32,
|
||||||
|
pub last_read_time: Option<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,
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,53 @@
|
||||||
use sqlx::pool::PoolOptions;
|
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
pub async fn init_pool() -> Result<SqlitePool, sqlx::Error> {
|
pub async fn init_pool(app: &AppHandle) -> Result<SqlitePool, sqlx::Error> {
|
||||||
SqlitePoolOptions::new()
|
// 数据库文件路径
|
||||||
|
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 options = SqliteConnectOptions::new()
|
||||||
|
.filename(&db_path)
|
||||||
|
.create_if_missing(true);
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
.min_connections(1)
|
.min_connections(1)
|
||||||
.connect("sqlite::memory:")
|
.connect_with(options)
|
||||||
.await
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"
|
||||||
|
CREATE TABLE IF NOT EXISTS books (
|
||||||
|
id INTEGER,
|
||||||
|
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,
|
||||||
|
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_read_chapter_id INTEGER DEFAULT 0,
|
||||||
|
last_read_position INTEGER DEFAULT 0,
|
||||||
|
last_read_time TIMESTAMP,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chapters (
|
||||||
|
id INTEGER,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chapters_book
|
||||||
|
ON chapters(book_id, number);
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod parser;
|
||||||
|
|
@ -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()
|
||||||
|
// });
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
<template>
|
||||||
|
<div class="book-cover">
|
||||||
|
<img v-if="resolvedSrc" :src="resolvedSrc" :alt="title" />
|
||||||
|
<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>
|
||||||
|
<span class="cover-author">{{ author || "佚名" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { convertFileSrc } from "@tauri-apps/api/core"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
author?: string
|
||||||
|
cover?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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 resolvedSrc = computed(() => {
|
||||||
|
const cover = props.cover
|
||||||
|
if (!cover) return ""
|
||||||
|
if (/^(https?:|data:|blob:|asset:)/.test(cover)) return cover
|
||||||
|
return convertFileSrc(cover)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.book-cover {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -11,24 +11,31 @@ export {}
|
||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
BookCover: typeof import('./../components/BookCover.vue')['default']
|
||||||
NAnchor: typeof import('naive-ui')['NAnchor']
|
NAnchor: typeof import('naive-ui')['NAnchor']
|
||||||
NAnchorLink: typeof import('naive-ui')['NAnchorLink']
|
NAnchorLink: typeof import('naive-ui')['NAnchorLink']
|
||||||
NButton: typeof import('naive-ui')['NButton']
|
NButton: typeof import('naive-ui')['NButton']
|
||||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||||
|
NDescriptions: typeof import('naive-ui')['NDescriptions']
|
||||||
|
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
|
||||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||||
|
NDivider: typeof import('naive-ui')['NDivider']
|
||||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
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']
|
||||||
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
||||||
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
|
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
|
||||||
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
|
NList: typeof import('naive-ui')['NList']
|
||||||
|
NListItem: typeof import('naive-ui')['NListItem']
|
||||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||||
|
NPagination: typeof import('naive-ui')['NPagination']
|
||||||
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
||||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
NSpace: typeof import('naive-ui')['NSpace']
|
NSpace: typeof import('naive-ui')['NSpace']
|
||||||
|
NSpin: typeof import('naive-ui')['NSpin']
|
||||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageResult<T> {
|
||||||
|
total: number
|
||||||
|
list: T[]
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Books {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
author: string
|
||||||
|
cover: string
|
||||||
|
introduction: string
|
||||||
|
file_path: string
|
||||||
|
total_chapters: number
|
||||||
|
total_chars: number
|
||||||
|
create_time: string
|
||||||
|
last_read_time: string | null
|
||||||
|
last_read_chapter_id: 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 }
|
||||||
|
|
@ -1,13 +1,291 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="book-detail">
|
||||||
|
<section class="detail-header">
|
||||||
|
<div class="header-toolbar">
|
||||||
|
<n-button quaternary circle aria-label="返回" @click="goBack">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="ArrowLeftOutlined" />
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="book-summary">
|
||||||
|
<div class="summary-cover">
|
||||||
|
<BookCover :title="book?.title ?? ''" :author="book?.author" :cover="book?.cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-meta">
|
||||||
|
<h2 class="summary-title">{{ book?.title || "加载中..." }}</h2>
|
||||||
|
|
||||||
|
<n-descriptions :column="2" label-placement="left" size="small">
|
||||||
|
<n-descriptions-item label="作者">{{ book?.author || "佚名" }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="总章节">{{ book?.total_chapters ?? 0 }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="总字数">{{ formatChars(book?.total_chars) }}</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="导入时间">{{ formatTime(book?.create_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="简介" :span="2">
|
||||||
|
{{ book?.introduction || "暂无简介" }}
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<n-divider class="detail-divider" />
|
||||||
|
|
||||||
|
<section class="chapter-section">
|
||||||
|
<div class="chapter-header">
|
||||||
|
<span class="chapter-name">章节列表</span>
|
||||||
|
<span class="chapter-count">共 {{ total }} 章</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-scrollbar class="chapter-scrollbar">
|
||||||
|
<n-spin :show="loadingChapters">
|
||||||
|
<n-list hoverable class="chapter-list">
|
||||||
|
<n-list-item v-for="ch in chapterList" :key="ch.id">
|
||||||
|
<div class="chapter-item">
|
||||||
|
<span class="chapter-index">{{ ch.number }}</span>
|
||||||
|
<span class="chapter-title">{{ ch.title }}</span>
|
||||||
|
<span class="chapter-chars">{{ ch.total_chars }} 字</span>
|
||||||
|
</div>
|
||||||
|
</n-list-item>
|
||||||
|
</n-list>
|
||||||
|
<n-empty
|
||||||
|
v-if="!loadingChapters && chapterList.length === 0"
|
||||||
|
class="chapter-empty"
|
||||||
|
description="暂无章节"
|
||||||
|
/>
|
||||||
|
</n-spin>
|
||||||
|
</n-scrollbar>
|
||||||
|
|
||||||
|
<div class="pagination-wrap">
|
||||||
|
<n-pagination
|
||||||
|
v-model:page="page"
|
||||||
|
:page-size="limit"
|
||||||
|
:item-count="total"
|
||||||
|
:page-slot="7"
|
||||||
|
show-size-picker
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
@update:page="onPageChange"
|
||||||
|
@update:page-size="onPageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { ArrowLeftOutlined } from "@vicons/antd"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
import BookApi from "@/api/book"
|
||||||
|
import BookCover from "@/components/BookCover.vue"
|
||||||
|
import type { Books, Chapters } from "@/types/global"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const bookId = computed(() => Number(route.query.id) || 0)
|
||||||
|
|
||||||
|
const book = ref<Books | null>(null)
|
||||||
|
const chapterList = ref<Chapters[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = ref(50)
|
||||||
|
const loadingChapters = ref(false)
|
||||||
|
|
||||||
|
const goBack = () => router.back()
|
||||||
|
|
||||||
|
const readProgress = computed(() => {
|
||||||
|
const b = book.value
|
||||||
|
if (!b || b.total_chapters <= 0) return "—"
|
||||||
|
const percent = Math.round((b.last_read_chapter_id / b.total_chapters) * 100)
|
||||||
|
return `第 ${b.last_read_chapter_id} 章 · ${percent}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatChars = (chars?: number) => {
|
||||||
|
if (!chars) return "0 字"
|
||||||
|
if (chars >= 10000) return `${(chars / 10000).toFixed(1)} 万字`
|
||||||
|
return `${chars} 字`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (t?: string | null) => {
|
||||||
|
if (!t) return "—"
|
||||||
|
const d = dayjs(t)
|
||||||
|
return d.isValid() ? d.format("YYYY-MM-DD HH:mm") : t
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBook = async () => {
|
||||||
|
if (!bookId.value) return
|
||||||
|
const res = await BookApi.detail(bookId.value)
|
||||||
|
if (res.code === 0) {
|
||||||
|
book.value = res.data
|
||||||
|
} else {
|
||||||
|
message.error(res.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadChapters = async () => {
|
||||||
|
if (!bookId.value) return
|
||||||
|
loadingChapters.value = true
|
||||||
|
try {
|
||||||
|
const res = await BookApi.chapters(bookId.value, page.value, limit.value)
|
||||||
|
if (res.code === 0) {
|
||||||
|
chapterList.value = res.data.list
|
||||||
|
total.value = res.data.total
|
||||||
|
} else {
|
||||||
|
message.error(res.msg)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingChapters.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (p: number) => {
|
||||||
|
page.value = p
|
||||||
|
loadChapters()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageSizeChange = (size: number) => {
|
||||||
|
limit.value = size
|
||||||
|
page.value = 1
|
||||||
|
loadChapters()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadBook()
|
||||||
|
loadChapters()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style lang="scss" scoped>
|
||||||
|
.book-detail {
|
||||||
|
height: 90%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cover {
|
||||||
|
width: 120px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-divider {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-scrollbar {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-index {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 40px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-chars {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-empty {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
<template> <div class="home">
|
<template>
|
||||||
|
<div class="home">
|
||||||
<header class="home-header">
|
<header class="home-header">
|
||||||
<n-input
|
<n-input v-model:value="searchValue" class="search-input" round clearable placeholder="搜索书籍">
|
||||||
v-model:value="searchValue"
|
|
||||||
class="search-input"
|
|
||||||
round
|
|
||||||
clearable
|
|
||||||
placeholder="搜索书籍"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<n-icon :component="BookSearch24Regular" />
|
<n-icon :component="BookSearch24Regular" />
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -15,6 +10,9 @@
|
||||||
<button class="setting-btn" title="设置" aria-label="设置" @click="goSettingView">
|
<button class="setting-btn" title="设置" aria-label="设置" @click="goSettingView">
|
||||||
<n-icon :size="20" :component="SettingOutlined" />
|
<n-icon :size="20" :component="SettingOutlined" />
|
||||||
</button>
|
</button>
|
||||||
|
<button class="setting-btn" title="导入" aria-label="导入" @click="importBook">
|
||||||
|
<n-icon :size="20" :component="CloudUploadOutlined" />
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="home-body">
|
<section class="home-body">
|
||||||
|
|
@ -29,6 +27,24 @@
|
||||||
<n-button size="small" type="primary" round @click="importBook">立即导入</n-button>
|
<n-button size="small" type="primary" round @click="importBook">立即导入</n-button>
|
||||||
</template>
|
</template>
|
||||||
</n-empty>
|
</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-wrap">
|
||||||
|
<BookCover :title="book.title" :author="book.author" :cover="book.cover" />
|
||||||
|
</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>
|
</n-scrollbar>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -37,24 +53,58 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 } from "@vicons/antd"
|
import { SettingOutlined, CloudUploadOutlined } from "@vicons/antd"
|
||||||
|
import BookApi from "@/api/book"
|
||||||
|
import BookCover from "@/components/BookCover.vue"
|
||||||
|
import type { Books } from "@/types/global"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
const bookList = ref([])
|
const bookList = ref<Books[]>([])
|
||||||
const searchValue = ref("")
|
const searchValue = ref("")
|
||||||
|
|
||||||
|
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 = () => {
|
const goSettingView = () => {
|
||||||
router.push({ name: "Setting" })
|
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 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)
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
@ -160,7 +210,54 @@ const importBook = async () => {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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-wrap {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card:hover .book-cover-wrap {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue