Compare commits

..

No commits in common. "f7cec7ce262bf1447633d51d01d67352013c3e26" and "c12cbaaabc05905677e84d3f40e8afce4637c151" have entirely different histories.

14 changed files with 129 additions and 549 deletions

View File

@ -180,48 +180,3 @@ pub async fn chapter_page(
page_size: limit,
}))
}
/**
*
* @param id ID
* @returns
*/
#[tauri::command]
#[auto_collect_command]
pub async fn chapter_detail(
pool: State<'_, SqlitePool>,
id: i64,
) -> Result<ApiResponse<Chapters>, String> {
let chapter = sqlx::query_as::<_, Chapters>("SELECT * FROM chapters WHERE id = ?")
.bind(id)
.fetch_one(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::success(chapter))
}
/**
*
* @param book_id ID
* @param number
* @param offset -1 1
* @returns null
*/
#[tauri::command]
#[auto_collect_command]
pub async fn chapter_nav(
pool: State<'_, SqlitePool>,
book_id: i64,
number: i32,
offset: i32,
) -> Result<ApiResponse<Option<Chapters>>, String> {
let chapter = sqlx::query_as::<_, Chapters>(
"SELECT * FROM chapters WHERE book_id = ? AND number = ?",
)
.bind(book_id)
.bind(number + offset)
.fetch_optional(&*pool)
.await
.map_err(|e| e.to_string())?;
Ok(ApiResponse::success(chapter))
}

View File

@ -21,7 +21,7 @@
"decorations": false,
"alwaysOnTop": false,
"skipTaskbar": false,
"shadow": false,
"shadow": true,
"visible": true,
"center": false
}

View File

@ -3,7 +3,6 @@
<n-message-provider>
<n-notification-provider>
<n-dialog-provider>
<GlobalLoading />
<RouterView />
</n-dialog-provider>
</n-notification-provider>
@ -13,7 +12,6 @@
<script setup lang="ts">
import { zhCN, dateZhCN } from "naive-ui"
import GlobalLoading from "@/components/GlobalLoading.vue"
</script>
<style scoped>

View File

@ -2,14 +2,14 @@ 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>>("book_import", { path })
},
/**
*
* @param path
* @returns
*/
import: async (path: string) => {
return await invoke<ApiResponse<any>>("book_import", { path })
},
/**
*
* @param title
@ -52,24 +52,6 @@ const BookApi = {
chapters: async (bookId: number, page: number, limit: number) => {
return await invoke<ApiResponse<PageResult<Chapters>>>("chapter_page", { bookId, page, limit })
},
/**
*
* @param id ID
* @returns
*/
chapterDetail: async (id: number) => {
return await invoke<ApiResponse<Chapters>>("chapter_detail", { id })
},
/**
*
* @param bookId ID
* @param number
* @param offset -1 1
* @returns null
*/
chapterNav: async (bookId: number, number: number, offset: number) => {
return await invoke<ApiResponse<Chapters | null>>("chapter_nav", { bookId, number, offset })
},
}
export default BookApi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@ -3,7 +3,7 @@
/* 窗口与页面背景 */
--color-window-bg: #f6f7f9;
--color-surface: #ffffff;
--color-surface-hover: #e6e6e6;
--color-surface-hover: #fafafa;
/* 文字 */
--color-text-primary: #1d1d1f;

View File

@ -1,13 +1,19 @@
<template>
<div class="book-cover">
<img :src="resolvedSrc" :alt="title" />
<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">
<p class="cover-title">{{ title }}</p>
<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"
import defaultCover from "@/assets/cover.jpg"
const props = defineProps<{
title: string
@ -15,13 +21,30 @@ const props = defineProps<{
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) {
if (/^(https?:|data:|blob:|asset:)/.test(cover)) return cover
return convertFileSrc(cover)
}
return defaultCover
if (!cover) return ""
if (/^(https?:|data:|blob:|asset:)/.test(cover)) return cover
return convertFileSrc(cover)
})
</script>
@ -40,4 +63,52 @@ const resolvedSrc = computed(() => {
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>

View File

@ -1,58 +0,0 @@
<template>
<transition name="fade">
<div v-if="loading" class="global-loading">
<div class="global-loading-box">
<n-spin size="large" />
<span class="global-loading-text">{{ text }}</span>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { useLoadingStore } from "@/stores/loading"
const store = useLoadingStore()
const loading = computed(() => store.loading)
const text = computed(() => store.text)
</script>
<style lang="scss" scoped>
.global-loading {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(2px);
}
.global-loading-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 28px 36px;
border-radius: var(--radius-card);
background: var(--color-surface);
box-shadow: var(--shadow-card-hover);
}
.global-loading-text {
font-size: 14px;
color: var(--color-text-secondary);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -1,34 +0,0 @@
import { computed } from "vue"
import { useLoadingStore } from "@/stores/loading"
/**
* hook
* @example
* const { withLoading } = useLoading()
* await withLoading(() => BookApi.import(path), "导入中...")
*/
export function useLoading() {
const store = useLoadingStore()
const start = (text?: string) => store.start(text)
const stop = () => store.stop()
const reset = () => store.reset()
const withLoading = async <T>(fn: () => Promise<T>, text?: string): Promise<T> => {
store.start(text)
try {
return await fn()
} finally {
store.stop()
}
}
return {
loading: computed(() => store.loading),
text: computed(() => store.text),
start,
stop,
reset,
withLoading,
}
}

View File

@ -1,28 +0,0 @@
import { defineStore } from "pinia"
/**
* store
* count loading true
*/
export const useLoadingStore = defineStore("loading", {
state: () => ({
count: 0,
text: "加载中...",
}),
getters: {
loading: (state) => state.count > 0,
},
actions: {
start(text?: string) {
if (text) this.text = text
this.count++
},
stop() {
this.count = Math.max(0, this.count - 1)
},
reset() {
this.count = 0
this.text = "加载中..."
},
},
})

View File

@ -12,7 +12,6 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
BookCover: typeof import('./../components/BookCover.vue')['default']
GlobalLoading: typeof import('./../components/GlobalLoading.vue')['default']
NAnchor: typeof import('naive-ui')['NAnchor']
NAnchorLink: typeof import('naive-ui')['NAnchorLink']
NButton: typeof import('naive-ui')['NButton']
@ -25,8 +24,6 @@ declare module 'vue' {
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NIcon: typeof import('naive-ui')['NIcon']
NInput: typeof import('naive-ui')['NInput']
NLayout: typeof import('naive-ui')['NLayout']

View File

@ -1,13 +1,14 @@
<template>
<div class="book-detail">
<n-button class="back-btn" quaternary circle aria-label="返回" @click="goBack">
<template #icon>
<n-icon :component="ArrowLeftOutlined" />
</template>
</n-button>
<n-scrollbar class="page-scrollbar">
<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" />
@ -24,9 +25,7 @@
<n-descriptions-item label="上次阅读">{{ formatTime(book?.last_read_time) }}</n-descriptions-item>
<n-descriptions-item label="阅读进度">{{ readProgress }}</n-descriptions-item>
<n-descriptions-item label="简介" :span="2">
<n-scrollbar class="intro-scrollbar">
<div class="intro-content">{{ book?.introduction || "暂无简介" }}</div>
</n-scrollbar>
<div class="intro-content">{{ book?.introduction || "暂无简介" }}</div>
</n-descriptions-item>
</n-descriptions>
</div>
@ -43,22 +42,15 @@
<n-scrollbar class="chapter-scrollbar">
<n-spin :show="loadingChapters">
<n-grid
v-if="chapterList.length > 0"
class="chapter-grid"
:cols="2"
:x-gap="16"
:y-gap="6"
responsive="screen"
>
<n-gi v-for="ch in chapterList" :key="ch.id">
<div class="chapter-item" @click="readChapter(ch)">
<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" :title="ch.title">{{ ch.title }}</span>
<span class="chapter-title">{{ ch.title }}</span>
<span class="chapter-chars">{{ ch.total_chars }} </span>
</div>
</n-gi>
</n-grid>
</n-list-item>
</n-list>
<n-empty
v-if="!loadingChapters && chapterList.length === 0"
class="chapter-empty"
@ -80,22 +72,20 @@
/>
</div>
</section>
</n-scrollbar>
</div>
</template>
<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 { useLoading } from "@/hooks/useLoading"
import type { Books, Chapters } from "@/types/global"
const router = useRouter()
const route = useRoute()
const message = useMessage()
const { withLoading } = useLoading()
const bookId = computed(() => Number(route.query.id) || 0)
@ -108,10 +98,6 @@ const loadingChapters = ref(false)
const goBack = () => router.back()
const readChapter = (ch: Chapters) => {
router.push({ name: "Reader", query: { id: ch.id } })
}
const readProgress = computed(() => {
const b = book.value
if (!b || b.total_chapters <= 0) return "—"
@ -133,7 +119,7 @@ const formatTime = (t?: string | null) => {
const loadBook = async () => {
if (!bookId.value) return
const res = await withLoading(() => BookApi.detail(bookId.value), "加载中...")
const res = await BookApi.detail(bookId.value)
if (res.code === 0) {
book.value = res.data
} else {
@ -145,10 +131,7 @@ const loadChapters = async () => {
if (!bookId.value) return
loadingChapters.value = true
try {
const res = await withLoading(
() => BookApi.chapters(bookId.value, page.value, limit.value),
"加载章节中..."
)
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
@ -162,10 +145,6 @@ const loadChapters = async () => {
}
}
const goReader = (chapterId: number) => {
router.push({ name: "Reader", query: { id: chapterId } })
}
const onPageChange = (p: number) => {
page.value = p
loadChapters()
@ -185,47 +164,25 @@ onMounted(() => {
<style lang="scss" scoped>
.book-detail {
position: relative;
height: 93%;
height: 90%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px 24px;
}
.back-btn {
position: absolute;
top: 8px;
left: 24px;
z-index: 10;
background-color: var(--color-surface);
box-shadow: var(--shadow-card);
transition: box-shadow 0.2s ease, background-color 0.2s ease;
&:hover {
box-shadow: var(--shadow-card-hover);
}
}
.page-scrollbar {
flex: 1;
min-height: 0;
:deep(.n-scrollbar-container) {
height: 100%;
}
:deep(.n-scrollbar-content) {
min-height: 100%;
}
}
.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;
@ -259,19 +216,9 @@ onMounted(() => {
text-overflow: ellipsis;
}
.intro-scrollbar {
height: 120px;
:deep(.n-scrollbar-container) {
height: 100%;
}
:deep(.n-scrollbar-content) {
padding-right: 8px;
}
}
.intro-content {
max-height: 120px;
overflow-y: auto;
line-height: 1.6;
font-size: 13px;
color: var(--color-text-secondary);
@ -284,9 +231,10 @@ onMounted(() => {
}
.chapter-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.chapter-header {
@ -309,7 +257,8 @@ onMounted(() => {
}
.chapter-scrollbar {
max-height: 420px;
flex: 1;
min-height: 0;
:deep(.n-scrollbar-container) {
height: 100%;
@ -323,21 +272,13 @@ onMounted(() => {
.chapter-item {
display: flex;
align-items: center;
gap: 10px;
gap: 12px;
width: 100%;
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--color-surface-hover);
}
}
.chapter-index {
flex-shrink: 0;
min-width: 32px;
min-width: 40px;
color: var(--color-text-secondary);
font-size: 13px;
}

View File

@ -1,7 +1,7 @@
<template>
<div class="home">
<header class="home-header">
<n-input v-model:value="searchValue" class="search-input" round clearable placeholder="搜索书籍" @click.enter="getBookList">
<n-input v-model:value="searchValue" class="search-input" round clearable placeholder="搜索书籍">
<template #prefix>
<n-icon :component="BookSearch24Regular" />
</template>
@ -130,12 +130,10 @@ import {
import { NIcon } from "naive-ui"
import BookApi from "@/api/book"
import BookCover from "@/components/BookCover.vue"
import { useLoading } from "@/hooks/useLoading"
import type { Books, BookSaveParams } from "@/types/global"
const router = useRouter()
const message = useMessage()
const { withLoading } = useLoading()
const bookList = ref<Books[]>([])
const searchValue = ref("")
@ -173,7 +171,7 @@ const rules = {
}
const getBookList = async () => {
const res = await withLoading(() => BookApi.list(searchValue.value), "加载中...")
const res = await BookApi.list(searchValue.value)
if (res.code === 0) {
bookList.value = res.data
} else {
@ -243,11 +241,10 @@ const confirmDelete = (book: Books | null) => {
}
const doDelete = async () => {
const book = deleteBook.value
if (!book || deleting.value) return
if (!deleteBook.value || deleting.value) return
deleting.value = true
try {
const res = await withLoading(() => BookApi.del(book.id), "删除中...")
const res = await BookApi.del(deleteBook.value.id)
if (res.code === 0) {
message.success("删除成功")
showDeleteModal.value = false
@ -268,7 +265,7 @@ const importBook = async () => {
filters: [{ name: "TXT", extensions: ["txt", "epub"] }]
})
if (filePath) {
const res = await withLoading(() => BookApi.import(filePath), "导入中...")
const res = await BookApi.import(filePath)
if (res.code === 0) {
message.success("导入成功")
getBookList()

View File

@ -1,261 +1,20 @@
<template>
<div class="glass-panel" data-tauri-drag-region>
<div class="reading-wrap">
<div class="chapter-name">{{ chapter?.title || "加载中..." }}</div>
<div class="glass-panel">
<div ref="pageEl" class="page-body" @click="onPageClick">
<div class="page-text">{{ currentText }}</div>
</div>
<div class="page-nav">
<span class="page-info">{{ pageInfo }}</span>
<n-space justify="space-between" class="nav-btns">
<n-space>
<n-button size="small" text @click="switchChapter(-1)">上一章</n-button>
<n-button size="small" text :disabled="currentPage <= 0" @click="prevPage">上一页</n-button>
</n-space>
<n-button size="small" text @click="goBack">返回</n-button>
<n-space>
<n-button size="small" text :disabled="currentPage >= pages.length - 1" @click="nextPage">
下一页
</n-button>
<n-button size="small" text @click="switchChapter(1)">下一章</n-button>
</n-space>
</n-space>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick } from "vue"
import { ArrowLeftOutlined } from "@vicons/antd"
import BookApi from "@/api/book"
import { useLoading } from "@/hooks/useLoading"
import type { Chapters } from "@/types/global"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const { withLoading } = useLoading()
const chapterId = computed(() => Number(route.query.id) || 0)
const chapter = ref<Chapters | null>(null)
const pageEl = ref<HTMLElement | null>(null)
const pages = ref<string[]>([])
const currentPage = ref(0)
const goBack = () => router.back()
const currentText = computed(() => pages.value[currentPage.value] ?? "")
const pageInfo = computed(() => {
if (pages.value.length === 0) return ""
return `${currentPage.value + 1} / ${pages.value.length}`
})
const measureCharWidth = (fontSize: number) => {
const probe = document.createElement("span")
probe.textContent = "国"
probe.style.cssText = `font-size:${fontSize}px;position:absolute;visibility:hidden;white-space:nowrap;`
document.body.appendChild(probe)
const width = probe.getBoundingClientRect().width
document.body.removeChild(probe)
return width || fontSize
}
const paginate = () => {
const content = chapter.value?.content || ""
if (!content) {
pages.value = []
currentPage.value = 0
return
}
const el = pageEl.value
if (!el) return
const fontSize = 6
const lineHeight = 1.8
const width = el.clientWidth
const height = el.clientHeight
const charWidth = measureCharWidth(fontSize)
const charsPerLine = Math.max(1, Math.floor(width / charWidth))
const linesPerPage = Math.max(1, Math.floor(height / (fontSize * lineHeight)))
//
const logicalLines: string[] = []
for (const para of content.split(/\r?\n/)) {
if (para === "") {
logicalLines.push("")
continue
}
for (let i = 0; i < para.length; i += charsPerLine) {
logicalLines.push(para.slice(i, i + charsPerLine))
}
}
const arr: string[] = []
for (let i = 0; i < logicalLines.length; i += linesPerPage) {
arr.push(logicalLines.slice(i, i + linesPerPage).join("\n"))
}
pages.value = arr.length ? arr : [""]
currentPage.value = 0
}
const prevPage = () => {
if (currentPage.value > 0) currentPage.value--
}
const nextPage = () => {
if (currentPage.value < pages.value.length - 1) currentPage.value++
}
const onPageClick = (e: MouseEvent) => {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (e.clientX - rect.left < rect.width / 2) prevPage()
else nextPage()
}
const onKeydown = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft" || e.key === "PageUp") {
prevPage()
} else if (e.key === "ArrowRight" || e.key === "PageDown" || e.key === " ") {
e.preventDefault()
nextPage()
}
}
const onResize = () => paginate()
const loadChapter = async () => {
if (!chapterId.value) {
message.error("章节参数错误")
return
}
const res = await withLoading(() => BookApi.chapterDetail(chapterId.value), "加载中...")
if (res.code === 0) {
chapter.value = res.data
nextTick(paginate)
} else {
message.error(res.msg)
}
}
const switchChapter = async (offset: number) => {
const cur = chapter.value
if (!cur) return
const res = await withLoading(
() => BookApi.chapterNav(cur.book_id, cur.number, offset),
"加载中..."
)
if (res.code === 0) {
if (res.data) {
chapter.value = res.data
nextTick(paginate)
} else {
message.info(offset > 0 ? "已经是最后一章了" : "已经是第一章了")
}
} else {
message.error(res.msg)
}
}
onMounted(() => {
window.addEventListener("keydown", onKeydown)
window.addEventListener("resize", onResize)
loadChapter()
})
onUnmounted(() => {
window.removeEventListener("keydown", onKeydown)
window.removeEventListener("resize", onResize)
})
</script>
<style scoped>
.glass-panel {
position: relative;
width: 100vw;
height: 100vh;
background: rgba(20, 20, 20, 0.05);
backdrop-filter: blur(20px);
/* border: 1px solid rgba(255, 255, 255, 0.2); */
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 20px;
color: #3b3b3b;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.back-btn {
position: absolute;
top: 12px;
left: 16px;
z-index: 10;
background: transparent;
transition: background-color 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.06);
}
}
.reading-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 44px 40px 16px;
}
.chapter-name {
flex-shrink: 0;
text-align: center;
font-size: 22px;
font-weight: 700;
color: #3b3b3b;
margin-bottom: 20px;
}
.page-body {
flex: 1;
min-height: 0;
cursor: pointer;
}
.page-text {
height: 100%;
overflow: hidden;
font-size: 12px;
line-height: 1.8;
color: #3b3b3b;
white-space: pre-wrap;
word-break: break-word;
text-align: justify;
}
.page-nav {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-top: 16px;
}
.page-info {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
}
.nav-btns {
width: 100%;
}
</style>
</style>