feat: 添加阅读设置页面与全局阅读配置功能

1. 新增阅读设置页面,支持调整字体大小、颜色、背景色和窗口大小
2. 新增全局设置store,持久化保存外观和阅读配置
3. 为窗口添加阴影和尺寸设置的权限与功能
4. 重构外观设置页面样式,统一配置项布局
5. 适配阅读页面样式,使用配置动态控制阅读样式
This commit is contained in:
Yuhang Wu 2026-08-21 11:55:30 +08:00
parent f64bd6aff9
commit 72728cf7ac
10 changed files with 310 additions and 107 deletions

View File

@ -9,6 +9,8 @@
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-set-shadow",
"core:window:allow-set-size",
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"dialog:allow-open" "dialog:allow-open"

View File

@ -28,4 +28,7 @@
/* 尺寸 */ /* 尺寸 */
--titlebar-height: 38px; --titlebar-height: 38px;
--set-item-title-size: 14px;
--set-item-desc-size: 12px;
} }

View File

@ -45,6 +45,15 @@ const routes = [
title: "外观设置", title: "外观设置",
}, },
}, },
{
path: "/setting/reader-setting",
name: "ReaderSetting",
component: () => import("@/views/setting/ReaderSetting.vue"),
meta: {
isCache: true,
title: "阅读设置",
},
},
{ {
path: "/setting/shortcut", path: "/setting/shortcut",
name: "Shortcut", name: "Shortcut",

20
src/stores/setting.ts Normal file
View File

@ -0,0 +1,20 @@
import { defineStore } from "pinia"
export const useSettingStore = defineStore("setting", {
state: () => ({
appearance: {
theme: "light",
showShadow: false,
},
reader: {
fontSize: 12,
showShadow: false,
fontColor: "#000000",
backgroundColor: "#ffffff",
windowWidth: 300,
windowHeight: 400,
}
}),
persist: true,
})

View File

@ -16,6 +16,7 @@ declare module 'vue' {
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']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDescriptions: typeof import('naive-ui')['NDescriptions'] NDescriptions: typeof import('naive-ui')['NDescriptions']
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem'] NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
@ -29,6 +30,7 @@ declare module 'vue' {
NGrid: typeof import('naive-ui')['NGrid'] NGrid: typeof import('naive-ui')['NGrid']
NIcon: typeof import('naive-ui')['NIcon'] NIcon: typeof import('naive-ui')['NIcon']
NInput: typeof import('naive-ui')['NInput'] NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
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']
@ -42,6 +44,7 @@ declare module 'vue' {
NSpace: typeof import('naive-ui')['NSpace'] NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin'] NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch'] NSwitch: typeof import('naive-ui')['NSwitch']
NText: typeof import('naive-ui')['NText']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }

View File

@ -118,7 +118,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { h, type Component } from "vue" import {getCurrentWindow, LogicalSize} from '@tauri-apps/api/window'
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 { import {
@ -131,10 +131,13 @@ import { NIcon } from "naive-ui"
import BookApi from "@/api/book" import BookApi from "@/api/book"
import BookCover from "@/components/BookCover.vue" import BookCover from "@/components/BookCover.vue"
import { useLoading } from "@/hooks/useLoading" import { useLoading } from "@/hooks/useLoading"
import { useSettingStore } from '@/stores/setting'
import type { Books, BookSaveParams } from "@/types/global" import type { Books, BookSaveParams } from "@/types/global"
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const settingStore = useSettingStore()
const appWindow = getCurrentWindow()
const { withLoading } = useLoading() const { withLoading } = useLoading()
const bookList = ref<Books[]>([]) const bookList = ref<Books[]>([])
@ -280,6 +283,8 @@ const importBook = async () => {
onMounted(() => { onMounted(() => {
getBookList() getBookList()
appWindow.setShadow(settingStore.appearance.showShadow)
appWindow.setSize(new LogicalSize(800, 600))
}) })
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="glass-panel" data-tauri-drag-region> <div class="glass-panel" :style="readerStyle" data-tauri-drag-region>
<div class="reading-wrap"> <div class="reading-wrap">
<div class="chapter-name">{{ chapter?.title || "加载中..." }}</div> <div class="chapter-name">{{ chapter?.title || "加载中..." }}</div>
@ -28,8 +28,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick } from "vue" import { useSettingStore } from '@/stores/setting'
import { ArrowLeftOutlined } from "@vicons/antd" import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'
import BookApi from "@/api/book" import BookApi from "@/api/book"
import { useLoading } from "@/hooks/useLoading" import { useLoading } from "@/hooks/useLoading"
import type { Chapters } from "@/types/global" import type { Chapters } from "@/types/global"
@ -37,6 +37,8 @@ import type { Chapters } from "@/types/global"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const settingStore = useSettingStore()
const appWindow = getCurrentWindow()
const { withLoading } = useLoading() const { withLoading } = useLoading()
const chapterId = computed(() => Number(route.query.id) || 0) const chapterId = computed(() => Number(route.query.id) || 0)
@ -50,6 +52,12 @@ const goBack = () => router.back()
const currentText = computed(() => pages.value[currentPage.value] ?? "") const currentText = computed(() => pages.value[currentPage.value] ?? "")
const readerStyle = computed(() => ({
"--reader-font-size": `${settingStore.reader.fontSize}px`,
"--reader-font-color": settingStore.reader.fontColor,
"--reader-background-color": settingStore.reader.backgroundColor,
}))
const pageInfo = computed(() => { const pageInfo = computed(() => {
if (pages.value.length === 0) return "" if (pages.value.length === 0) return ""
return `${currentPage.value + 1} / ${pages.value.length}` return `${currentPage.value + 1} / ${pages.value.length}`
@ -75,7 +83,7 @@ const paginate = () => {
const el = pageEl.value const el = pageEl.value
if (!el) return if (!el) return
const fontSize = 6 const fontSize = Math.max(1, Number(settingStore.reader.fontSize) || 12)
const lineHeight = 1.8 const lineHeight = 1.8
const width = el.clientWidth const width = el.clientWidth
const height = el.clientHeight const height = el.clientHeight
@ -129,6 +137,11 @@ const onKeydown = (e: KeyboardEvent) => {
const onResize = () => paginate() const onResize = () => paginate()
watch(
() => settingStore.reader.fontSize,
() => nextTick(paginate)
)
const loadChapter = async () => { const loadChapter = async () => {
if (!chapterId.value) { if (!chapterId.value) {
message.error("章节参数错误") message.error("章节参数错误")
@ -165,6 +178,8 @@ const switchChapter = async (offset: number) => {
onMounted(() => { onMounted(() => {
window.addEventListener("keydown", onKeydown) window.addEventListener("keydown", onKeydown)
window.addEventListener("resize", onResize) window.addEventListener("resize", onResize)
appWindow.setShadow(settingStore.reader.showShadow)
appWindow.setSize(new LogicalSize(settingStore.reader.windowWidth, settingStore.reader.windowHeight))
loadChapter() loadChapter()
}) })
@ -179,11 +194,10 @@ onUnmounted(() => {
position: relative; position: relative;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background: rgba(20, 20, 20, 0.05); background: var(--reader-background-color);
backdrop-filter: blur(20px);
/* border: 1px solid rgba(255, 255, 255, 0.2); */ /* border: 1px solid rgba(255, 255, 255, 0.2); */
padding: 20px; padding: 10px;
color: #3b3b3b; color: var(--reader-font-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@ -212,7 +226,7 @@ onUnmounted(() => {
width: 100%; width: 100%;
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;
padding: 44px 40px 16px; /* padding: 44px 40px 16px; */
} }
.chapter-name { .chapter-name {
@ -220,7 +234,7 @@ onUnmounted(() => {
text-align: center; text-align: center;
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
color: #3b3b3b; color: var(--reader-font-color);
margin-bottom: 20px; margin-bottom: 20px;
} }
@ -233,9 +247,9 @@ onUnmounted(() => {
.page-text { .page-text {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
font-size: 12px; font-size: var(--reader-font-size);
line-height: 1.8; line-height: 1.8;
color: #3b3b3b; color: var(--reader-font-color);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
text-align: justify; text-align: justify;
@ -252,7 +266,12 @@ onUnmounted(() => {
.page-info { .page-info {
font-size: 12px; font-size: 12px;
color: rgba(0, 0, 0, 0.5); color: var(--reader-font-color);
opacity: 0.65;
}
.glass-panel :deep(.n-button) {
color: var(--reader-font-color);
} }
.nav-btns { .nav-btns {

View File

@ -53,6 +53,7 @@ const route = useRoute()
const menuItems = [ const menuItems = [
{ label: "外观", name: "Appearance", href: "#appearance", icon: BgColorsOutlined }, { label: "外观", name: "Appearance", href: "#appearance", icon: BgColorsOutlined },
{ label: "阅读设置", name: "ReaderSetting", href: "#reader-setting", icon: BgColorsOutlined },
{ label: "快捷键", name: "Shortcut", href: "#shortcut", icon: KeyOutlined }, { label: "快捷键", name: "Shortcut", href: "#shortcut", icon: KeyOutlined },
{ label: "关于", name: "About", href: "#about", icon: InfoCircleOutlined }, { label: "关于", name: "About", href: "#about", icon: InfoCircleOutlined },
] ]

View File

@ -1,46 +1,52 @@
<template> <template>
<div class="setting-page"> <div class="appearance">
<h2 class="page-title">外观</h2> <h2 class="page-title">外观</h2>
<p class="page-desc">自定义应用的外观与阅读体验</p> <p class="page-desc">自定义应用的外观与阅读体验</p>
<div class="setting-group"> <n-grid :cols="4">
<div class="group-title">主题</div> <n-gi :span="2">
<div class="setting-item"> <div class="set-item-title">主题模式</div>
<div class="item-info"> <div class="set-item-desc">选择应用的整体配色方案</div>
<span class="item-label">主题模式</span> </n-gi>
<span class="item-desc">选择应用的整体配色方案</span> <n-gi>
</div> <n-radio-group v-model:value="theme" size="small" name="theme">
<n-radio-group v-model:value="theme" name="theme">
<n-radio-button value="light">浅色</n-radio-button> <n-radio-button value="light">浅色</n-radio-button>
<n-radio-button value="dark">深色</n-radio-button> <n-radio-button value="dark">深色</n-radio-button>
<n-radio-button value="system">跟随系统</n-radio-button> <n-radio-button value="system">跟随系统</n-radio-button>
</n-radio-group> </n-radio-group>
</div> </n-gi>
</div> <n-gi :span="4">
<n-divider style="margin: 16px 0" />
</n-gi>
<div class="setting-group"> <n-gi :span="3">
<div class="group-title">阅读</div> <div class="set-item-title">窗口阴影</div>
<div class="setting-item"> <div class="set-item-desc">是否显示应用窗口阴影效果</div>
<div class="item-info"> </n-gi>
<span class="item-label">自动换行</span> <n-gi>
<span class="item-desc">长段落自动换行显示</span> <n-switch v-model:value="showShadow" />
</div> </n-gi>
<n-switch v-model:value="wordWrap" /> </n-grid>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const theme = ref("light") import { getCurrentWindow } from '@tauri-apps/api/window';
const wordWrap = ref(true) import { useSettingStore } from '@/stores/setting'
const settingStore = useSettingStore()
const theme = ref(settingStore.appearance.theme)
const showShadow = ref(settingStore.appearance.showShadow)
watch(showShadow, (newVal) => {
settingStore.appearance.showShadow = newVal
getCurrentWindow().setShadow(newVal)
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.setting-page { .appearance {
max-width: 760px; padding: 20px;
padding: 22px 26px 32px;
} }
.page-title { .page-title {
@ -56,74 +62,18 @@ const wordWrap = ref(true)
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.setting-group { .set-item-title {
margin-bottom: 20px; font-size: var(--set-item-title-size);
font-weight: bold;
} }
.group-title { .set-item-desc {
margin-bottom: 6px; font-size: var(--set-item-desc-size);
font-size: 13px; color: #666;
font-weight: 600;
color: var(--color-text-secondary);
} }
.setting-item { :deep(.n-radio-group) {
display: flex; flex-wrap: wrap;
align-items: center; justify-content: flex-end;
justify-content: space-between;
min-height: 56px;
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
margin-bottom: 0;
transition: background-color 0.2s ease;
&:hover {
background-color: color-mix(in srgb, var(--color-surface-hover) 42%, transparent);
}
}
.item-info {
display: flex;
flex-direction: column;
padding: 0 16px;
}
.item-label {
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
}
.item-desc {
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 2px;
}
:deep(.n-radio-group),
:deep(.n-switch) {
margin: 0 16px 0 12px;
}
@media (max-width: 640px) {
.setting-page {
padding: 18px 16px 24px;
}
.setting-item {
align-items: flex-start;
flex-direction: column;
gap: 10px;
padding: 12px 16px;
}
.item-info {
padding: 0;
}
:deep(.n-radio-group),
:deep(.n-switch) {
margin: 0;
}
} }
</style> </style>

View File

@ -0,0 +1,191 @@
<template>
<div class="reader-setting">
<n-grid :cols="4" :x-gap="16" :y-gap="4">
<n-gi :span="3">
<div class="set-item-title">字体大小</div>
<div class="set-item-desc">设置阅读模式字体大小</div>
</n-gi>
<n-gi>
<n-input-number v-model:value="fontSize" :min="8" :max="72" size="small" />
</n-gi>
<n-gi :span="4">
<n-divider style="margin: 16px 0" />
</n-gi>
<n-gi :span="3">
<div class="set-item-title">窗口阴影</div>
<div class="set-item-desc">是否显示阅读模式窗口阴影效果</div>
</n-gi>
<n-gi>
<n-switch v-model:value="showShadow" />
</n-gi>
<n-gi :span="4">
<n-divider style="margin: 16px 0" />
</n-gi>
<n-gi :span="3">
<div class="set-item-title">字体颜色</div>
<div class="set-item-desc">设置阅读模式字体颜色</div>
</n-gi>
<n-gi>
<div class="color-picker-control">
<n-color-picker v-model:value="fontColor" show-alpha :modes="['hex']" placement="bottom-end">
<template #trigger="{ value, onClick, ref: triggerRef }">
<div :ref="triggerRef" class="color-swatch" :style="{ backgroundColor: value || '#000000' }"
role="button" tabindex="0" aria-label="选择字体颜色" @click="onClick" @keydown.enter="onClick"
@keydown.space.prevent="onClick" />
</template>
</n-color-picker>
<div class="color-values">
<span>{{ getColorDetails(fontColor).color }}</span>
<small>透明度 {{ getColorDetails(fontColor).opacity }}%</small>
</div>
</div>
</n-gi>
<n-gi :span="4">
<n-divider style="margin: 16px 0" />
</n-gi>
<n-gi :span="3">
<div class="set-item-title">背景颜色/透明度</div>
<div class="set-item-desc">设置阅读模式背景颜色&透明度</div>
</n-gi>
<n-gi>
<div class="color-picker-control">
<n-color-picker v-model:value="backgroundColor" show-alpha :modes="['hex']" placement="bottom-end">
<template #trigger="{ value, onClick, ref: triggerRef }">
<div :ref="triggerRef" class="color-swatch" :style="{ backgroundColor: value || '#ffffff' }"
role="button" tabindex="0" aria-label="选择背景颜色" @click="onClick" @keydown.enter="onClick"
@keydown.space.prevent="onClick" />
</template>
</n-color-picker>
<div class="color-values">
<span>{{ getColorDetails(backgroundColor).color }}</span>
<small>透明度 {{ getColorDetails(backgroundColor).opacity }}%</small>
</div>
</div>
</n-gi>
<n-gi :span="4">
<n-divider style="margin: 16px 0" />
</n-gi>
<n-gi :span="3">
<div class="set-item-title">窗口大小</div>
<div class="set-item-desc">设置阅读模式窗口大小</div>
</n-gi>
<n-gi>
<n-input-number v-model:value="windowWidth" :min="100" :max="1920" size="small">
<template #prefix>
</template>
</n-input-number>
<n-input-number v-model:value="windowHeight" :min="100" :max="1080" size="small">
<template #prefix>
</template>
</n-input-number>
</n-gi>
</n-grid>
</div>
</template>
<script setup lang="ts">
import { useSettingStore } from '@/stores/setting'
const settingStore = useSettingStore()
const fontSize = ref(settingStore.reader.fontSize)
const showShadow = ref(settingStore.reader.showShadow)
const fontColor = ref(settingStore.reader.fontColor)
const backgroundColor = ref(settingStore.reader.backgroundColor)
const windowWidth = ref(settingStore.reader.windowWidth)
const windowHeight = ref(settingStore.reader.windowHeight)
const getColorDetails = (value: string | null) => {
const color = value || '#000000'
const match = color.match(/^#([\da-f]{6})([\da-f]{2})$/i)
|| color.match(/^#([\da-f]{3})([\da-f])$/i)
const alphaMatch = color.match(/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)$/i)
const alpha = match
? Math.round((parseInt(match[2].length === 1 ? match[2] + match[2] : match[2], 16) / 255) * 100)
: alphaMatch
? Math.round(Number(alphaMatch[1]) * 100)
: 100
return {
color: match ? `#${match[1].toUpperCase()}` : color,
opacity: Math.min(100, Math.max(0, alpha)),
}
}
watch(fontSize, (newVal) => {
settingStore.reader.fontSize = newVal
})
watch(showShadow, (newVal) => {
settingStore.reader.showShadow = newVal
// getCurrentWindow().setShadow(newVal)
})
watch(fontColor, (newVal) => {
settingStore.reader.fontColor = newVal
})
watch(backgroundColor, (newVal) => {
settingStore.reader.backgroundColor = newVal
})
watch(windowWidth, (newVal) => {
settingStore.reader.windowWidth = newVal
})
watch(windowHeight, (newVal) => {
settingStore.reader.windowHeight = newVal
})
</script>
<style lang="scss" scoped>
.reader-setting {
padding: 20px;
}
.set-item-title {
font-size: var(--set-item-title-size);
font-weight: bold;
}
.set-item-desc {
font-size: var(--set-item-desc-size);
color: var(--color-text-secondary);
}
.color-picker-control {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.color-swatch {
width: 22px;
height: 22px;
border: 2px solid #ffffff;
border-radius: 50%;
cursor: pointer;
// background-image: linear-gradient(45deg, #d7d7d7 25%, transparent 25%), linear-gradient(-45deg, #d7d7d7 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d7d7d7 75%), linear-gradient(-45deg, transparent 75%, #d7d7d7 75%);
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
background-size: 8px 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
.color-values {
display: flex;
flex-direction: column;
gap: 1px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 16px;
small {
color: var(--color-text-secondary);
font-family: inherit;
font-size: 11px;
}
}
</style>