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-close",
"core:window:allow-minimize",
"core:window:allow-set-shadow",
"core:window:allow-set-size",
"opener:default",
"dialog:default",
"dialog:allow-open"

View File

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

View File

@ -45,6 +45,15 @@ const routes = [
title: "外观设置",
},
},
{
path: "/setting/reader-setting",
name: "ReaderSetting",
component: () => import("@/views/setting/ReaderSetting.vue"),
meta: {
isCache: true,
title: "阅读设置",
},
},
{
path: "/setting/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']
NAnchorLink: typeof import('naive-ui')['NAnchorLink']
NButton: typeof import('naive-ui')['NButton']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDescriptions: typeof import('naive-ui')['NDescriptions']
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
@ -29,6 +30,7 @@ declare module 'vue' {
NGrid: typeof import('naive-ui')['NGrid']
NIcon: typeof import('naive-ui')['NIcon']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
@ -42,6 +44,7 @@ declare module 'vue' {
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NText: typeof import('naive-ui')['NText']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View File

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

View File

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

View File

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

View File

@ -1,46 +1,52 @@
<template>
<div class="setting-page">
<div class="appearance">
<h2 class="page-title">外观</h2>
<p class="page-desc">自定义应用的外观与阅读体验</p>
<div class="setting-group">
<div class="group-title">主题</div>
<div class="setting-item">
<div class="item-info">
<span class="item-label">主题模式</span>
<span class="item-desc">选择应用的整体配色方案</span>
</div>
<n-radio-group v-model:value="theme" name="theme">
<n-grid :cols="4">
<n-gi :span="2">
<div class="set-item-title">主题模式</div>
<div class="set-item-desc">选择应用的整体配色方案</div>
</n-gi>
<n-gi>
<n-radio-group v-model:value="theme" size="small" name="theme">
<n-radio-button value="light">浅色</n-radio-button>
<n-radio-button value="dark">深色</n-radio-button>
<n-radio-button value="system">跟随系统</n-radio-button>
</n-radio-group>
</div>
</div>
<div class="setting-group">
<div class="group-title">阅读</div>
<div class="setting-item">
<div class="item-info">
<span class="item-label">自动换行</span>
<span class="item-desc">长段落自动换行显示</span>
</div>
<n-switch v-model:value="wordWrap" />
</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-switch v-model:value="showShadow" />
</n-gi>
</n-grid>
</div>
</template>
<script setup lang="ts">
const theme = ref("light")
const wordWrap = ref(true)
import { getCurrentWindow } from '@tauri-apps/api/window';
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>
<style lang="scss" scoped>
.setting-page {
max-width: 760px;
padding: 22px 26px 32px;
.appearance {
padding: 20px;
}
.page-title {
@ -56,74 +62,18 @@ const wordWrap = ref(true)
color: var(--color-text-secondary);
}
.setting-group {
margin-bottom: 20px;
.set-item-title {
font-size: var(--set-item-title-size);
font-weight: bold;
}
.group-title {
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary);
.set-item-desc {
font-size: var(--set-item-desc-size);
color: #666;
}
.setting-item {
display: flex;
align-items: center;
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;
}
:deep(.n-radio-group) {
flex-wrap: wrap;
justify-content: flex-end;
}
</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>