feat: 添加全局加载状态与章节相关功能,优化界面样式

1.  新增全局加载状态管理store与hook,添加全局加载组件
2.  新增获取章节详情、相邻章节的后端接口与前端api调用
3.  优化书籍列表、删除、导入等操作的加载状态提示
4.  完成阅读页面开发,支持分页阅读与章节切换
5.  调整全局悬浮背景色与窗口阴影配置
6.  修复书籍列表搜索回车事件,优化章节列表点击跳转
This commit is contained in:
Yuhang Wu 2026-08-20 18:02:13 +08:00
parent a5dd0c446d
commit f7cec7ce26
12 changed files with 465 additions and 22 deletions

View File

@ -180,3 +180,48 @@ 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": true,
"shadow": false,
"visible": true,
"center": false
}

View File

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

View File

@ -52,6 +52,24 @@ 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

View File

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

View File

@ -0,0 +1,58 @@
<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>

34
src/hooks/useLoading.ts Normal file
View File

@ -0,0 +1,34 @@
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,
}
}

28
src/stores/loading.ts Normal file
View File

@ -0,0 +1,28 @@
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,6 +12,7 @@ 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']

View File

@ -52,7 +52,7 @@
responsive="screen"
>
<n-gi v-for="ch in chapterList" :key="ch.id">
<div class="chapter-item">
<div class="chapter-item" @click="readChapter(ch)">
<span class="chapter-index">{{ ch.number }}</span>
<span class="chapter-title" :title="ch.title">{{ ch.title }}</span>
<span class="chapter-chars">{{ ch.total_chars }} </span>
@ -85,16 +85,17 @@
</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)
@ -107,6 +108,10 @@ 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 "—"
@ -128,7 +133,7 @@ const formatTime = (t?: string | null) => {
const loadBook = async () => {
if (!bookId.value) return
const res = await BookApi.detail(bookId.value)
const res = await withLoading(() => BookApi.detail(bookId.value), "加载中...")
if (res.code === 0) {
book.value = res.data
} else {
@ -140,7 +145,10 @@ const loadChapters = async () => {
if (!bookId.value) return
loadingChapters.value = true
try {
const res = await BookApi.chapters(bookId.value, page.value, limit.value)
const res = await withLoading(
() => BookApi.chapters(bookId.value, page.value, limit.value),
"加载章节中..."
)
if (res.code === 0) {
chapterList.value = res.data.list
total.value = res.data.total
@ -154,6 +162,10 @@ const loadChapters = async () => {
}
}
const goReader = (chapterId: number) => {
router.push({ name: "Reader", query: { id: chapterId } })
}
const onPageChange = (p: number) => {
page.value = p
loadChapters()
@ -315,6 +327,7 @@ onMounted(() => {
width: 100%;
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {

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

View File

@ -1,20 +1,261 @@
<template>
<div class="glass-panel">
<div class="glass-panel" data-tauri-drag-region>
<div class="reading-wrap">
<div class="chapter-name">{{ chapter?.title || "加载中..." }}</div>
<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>