feat: 实现书籍管理基础功能,完成导入、列表、编辑等接口
- 新增书籍数据模型与数据库表结构 - 实现后端书籍CRUD与章节分页接口 - 添加前端书籍列表页面与导入功能 - 补充类型定义与API封装
This commit is contained in:
parent
efae900fcf
commit
8177cf9ab6
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,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 }
|
||||
|
|
@ -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"]}]
|
||||
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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue