feat(project): 初始化项目结构和配置

This commit is contained in:
2026-06-10 04:09:02 +08:00
parent 2d301cf1c3
commit 45f883b772
1815 changed files with 315166 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view></router-view>
</template>
+226
View File
@@ -0,0 +1,226 @@
/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/api */
/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/web */
import type { webReadConfig } from '@/web'
import ajax from './axios'
import type {
BaseBook,
Book,
BookChapter,
BookProgress,
SeachBook,
} from '@/book'
import type { Source } from '@/source'
export type LeagdoApiResponse<T> = {
isSuccess: boolean
errorMsg: string
data: T
}
export let legado_http_entry_point = ''
export let legado_webSocket_entry_point = ''
let wsOnError: typeof WebSocket.prototype.onerror = () => {}
let wsOnMessage: typeof WebSocket.prototype.onmessage = () => {}
export const setWebsocketOnMessage = (callback: typeof wsOnMessage) =>
(wsOnMessage = callback)
export const setWebsocketOnError = (callback: typeof wsOnError) => {
//WebSocket.prototype.onerror = callback
wsOnError = callback
}
export const setApiEntryPoint = (
http_entry_point: string,
webSocket_entry_point: string,
) => {
legado_http_entry_point = new URL(http_entry_point).toString()
legado_webSocket_entry_point = new URL(webSocket_entry_point).toString()
ajax.defaults.baseURL = legado_http_entry_point
}
// 书架API
// Http
const getReadConfig = async (http_url = legado_http_entry_point) => {
const { data } = await ajax.get<LeagdoApiResponse<string>>('getReadConfig', {
baseURL: http_url.toString(),
timeout: 3000,
})
if (data.isSuccess) {
try {
return JSON.parse(data.data) as webReadConfig
} catch {}
}
}
const saveReadConfig = (config: webReadConfig) =>
ajax.post<LeagdoApiResponse<string>>('saveReadConfig', config)
/** @deprecated: 使用`API.saveBookProgressWithBeacon`以确保在页面或者直接关闭的情况下保存进度 */
const saveBookProgress = (bookProgress: BookProgress) =>
ajax.post('saveBookProgress', bookProgress)
/**主要在直接关闭浏览器情况下可靠发送书籍进度 */
const saveBookProgressWithBeacon = (bookProgress: BookProgress) => {
if (!bookProgress) return
// 常规请求可能会被取消 使用Fetch keep-alive 或者 navigator.sendBeacon
navigator.sendBeacon(
new URL('saveBookProgress', legado_http_entry_point),
JSON.stringify(bookProgress),
)
}
const getBookShelf = () => ajax.get<LeagdoApiResponse<Book[]>>('getBookshelf')
const getChapterList = (/** @type {string} */ bookUrl: string) =>
ajax.get<LeagdoApiResponse<BookChapter[]>>(
'getChapterList?url=' + encodeURIComponent(bookUrl),
)
const getBookContent = (
/** @type {string} */ bookUrl: string,
/** @type {number} */ chapterIndex: number,
) =>
ajax.get<LeagdoApiResponse<string>>(
'getBookContent?url=' +
encodeURIComponent(bookUrl) +
'&index=' +
chapterIndex,
)
// webSocket
const search = (
searchKey: string,
onReceive: (data: SeachBook[]) => void,
onFinish: () => void,
) => {
const socket = new WebSocket(
new URL('searchBook', legado_webSocket_entry_point),
)
socket.onerror = wsOnError
socket.onopen = () => {
socket.send(`{"key":"${searchKey}"}`)
}
socket.onmessage = event => {
try {
onReceive(JSON.parse(event.data))
wsOnMessage?.call(socket, event)
} catch {
onFinish()
}
}
socket.onclose = () => {
onFinish()
}
}
const saveBook = (book: BaseBook) =>
ajax.post<LeagdoApiResponse<string>>('saveBook', book)
const deleteBook = (book: BaseBook) =>
ajax.post<LeagdoApiResponse<string>>('deleteBook', book)
const isBookSource = /bookSource/i.test(location.href)
// 源编辑API
// Http
const getSources = () =>
isBookSource ? ajax.get('getBookSources') : ajax.get('getRssSources')
const saveSource = (data: Source) =>
isBookSource
? ajax.post<LeagdoApiResponse<string>>('saveBookSource', data)
: ajax.post<LeagdoApiResponse<string>>('saveRssSource', data)
const saveSources = (data: Source[]) =>
isBookSource
? ajax.post<LeagdoApiResponse<Source[]>>('saveBookSources', data)
: ajax.post<LeagdoApiResponse<Source[]>>('saveRssSources', data)
const deleteSource = (data: Source[]) =>
isBookSource
? ajax.post<LeagdoApiResponse<string>>('deleteBookSources', data)
: ajax.post<LeagdoApiResponse<string>>('deleteRssSources', data)
// webSocket
const debug = (
/** @type {string} */ sourceUrl: string,
/** @type {string} */ searchKey: string,
/** @type {(data: string) => void} */ onReceive: (data: string) => void,
/** @type {() => void} */ onFinish: () => void,
) => {
const url = new URL(
`${isBookSource ? 'bookSource' : 'rssSource'}Debug`,
legado_webSocket_entry_point,
)
const socket = new WebSocket(url)
socket.onerror = wsOnError
socket.onopen = () => {
socket.send(JSON.stringify({ tag: sourceUrl, key: searchKey }))
}
socket.onmessage = event => {
onReceive(event.data)
wsOnMessage?.call(socket, event)
}
socket.onclose = () => {
onFinish()
}
}
/**
* 从阅读获取需要特定处理的书籍封面
* @param {string} coverUrl
*/
const getProxyCoverUrl = (coverUrl: string) => {
if (coverUrl.startsWith(legado_http_entry_point)) return coverUrl
return new URL(
'cover?path=' + encodeURIComponent(coverUrl),
legado_http_entry_point,
).toString()
}
/**
* 从阅读获取需要特定处理的图片
* @param {string} bookUrl
* @param {string} src
* @param {number|`${number}`} width
*/
const getProxyImageUrl = (
bookUrl: string,
src: string,
width: number | `${number}`,
) => {
if (src.startsWith(legado_http_entry_point)) return src
return new URL(
'image?path=' +
encodeURIComponent(src) +
'&url=' +
encodeURIComponent(bookUrl) +
'&width=' +
width,
legado_http_entry_point,
).toString()
}
export default {
getReadConfig,
saveReadConfig,
saveBookProgress,
saveBookProgressWithBeacon,
getBookShelf,
getChapterList,
getBookContent,
search,
saveBook,
deleteBook,
getSources,
saveSources,
saveSource,
deleteSource,
debug,
getProxyCoverUrl,
getProxyImageUrl,
}
+15
View File
@@ -0,0 +1,15 @@
import axios from 'axios'
/** @type {string} localStorage保存自定义阅读http服务接口的键值 */
export const baseURL_localStorage_key = 'remoteUrl'
const SECOND = 1000
const ajax = axios.create({
baseURL:
import.meta.env.VITE_API ||
localStorage.getItem(baseURL_localStorage_key) ||
location.origin,
timeout: 120 * SECOND,
})
export default ajax
+113
View File
@@ -0,0 +1,113 @@
import type { AxiosResponse } from 'axios'
import type { LeagdoApiResponse } from './api'
import API, {
setWebsocketOnError,
setApiEntryPoint,
legado_http_entry_point,
setWebsocketOnMessage,
} from './api'
import ajax from './axios'
import { validatorHttpUrl } from '@/utils/utils'
import { createApp } from 'vue'
import App from '@/App.vue'
import store, { useConnectionStore } from '@/store'
createApp(App).use(store)
const connectionStore = useConnectionStore()
const LeagdoApiResponseKeys: string[] = Array.of('isSuccess', 'errorMsg')
const notification = ElMessage
/** Axios.Interceptor: check if resp is LeagaoLeagdoApiResponse*/
const responseCheckInterceptor = (resp: AxiosResponse) => {
let isLeagdoApiResponse = true
try {
const data = resp.data
for (const key of LeagdoApiResponseKeys) {
if (!(key in data)) {
isLeagdoApiResponse = false
LeagdoApiResponseKeys.length = 0
}
}
if ((data as LeagdoApiResponse<unknown>).isSuccess === true) {
if (!('data' in data)) {
isLeagdoApiResponse = false
}
}
} catch {
isLeagdoApiResponse = false
}
if (isLeagdoApiResponse === false) {
notification.warning({ message: '后端返回内容格式错误', grouping: true })
throw new Error()
}
connectionStore.setConnectType('primary')
connectionStore.setConnectStatus('已连接 ' + legado_http_entry_point)
return resp
}
const axiosErrorInterceptor = (err: unknown) => {
notification.error({
message: '后端连接失败,请检查阅读WEB服务或者设置其它可用链接',
grouping: true,
})
connectionStore.setConnectType('danger')
connectionStore.setConnectStatus('连接异常')
throw err
}
// http全局
ajax.interceptors.response.use(responseCheckInterceptor, axiosErrorInterceptor)
// websocket
setWebsocketOnError(axiosErrorInterceptor)
setWebsocketOnMessage(() => {
connectionStore.setConnectType('primary')
connectionStore.setConnectStatus('已连接 ' + legado_http_entry_point)
})
/**
* 按照阅读的默认规则 解析阅读HTTP WebSocket API入口地址
* @returns [http_url, webSocekt_url]
*/
export const parseLeagdoHttpUrlWithDefault = (
http_url: string | URL,
): [string, string] => {
let url = new URL(location.origin) //默认当前网址的origin部分
if (validatorHttpUrl(http_url)) {
url = new URL(http_url)
}
const { protocol, port } = url
// websocket服务端口 为http服务端口 + 1
let legado_webSocket_port
if (port !== '') {
legado_webSocket_port = String(Number(port) + 1)
} else {
legado_webSocket_port = protocol.startsWith('https:') ? '444' : '81'
}
// websocket协议是否为加密版本
const legado_webSocket_protocol = protocol.startsWith('https:')
? 'wss://'
: 'ws://'
const http_entry_point = url.toString()
url.protocol = legado_webSocket_protocol
url.port = legado_webSocket_port
const webSocket_entry_point = url.toString()
console.info('legado_api_config:')
console.table({
'http API入口': http_entry_point,
'webSocket API入口': webSocket_entry_point,
})
return [http_entry_point, webSocket_entry_point]
}
//export const useLeagdoRemoteUrlDialog = () => { }
setApiEntryPoint(
...parseLeagdoHttpUrlWithDefault(ajax.defaults.baseURL as string),
)
export default API
export * from './api'
+14
View File
@@ -0,0 +1,14 @@
body {
padding: 0;
margin: 0;
height: 100vh;
}
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
margin: 0;
height: 100%;
}
+9
View File
@@ -0,0 +1,9 @@
code {
border-radius: 4px;
padding: 0.15rem 0.5rem;
background-color: var(--el-fill-color-light);
transition:
color 0.25s,
background-color 0.5s;
font-size: 14px;
}
+5
View File
@@ -0,0 +1,5 @@
@charset "UTF-8";
@font-face {
font-family: 'iconfont';
src: url('./iconfont.woff') format('woff');
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
@charset "UTF-8";
@font-face {
font-family: 'FZZCYSK';
src: local('☺'), url('./popfont.ttf');
font-style: normal;
font-weight: normal;
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
@charset "UTF-8";
@font-face {
font-family: 'FZZCYSK';
src: local('☺'), url('./shelffont.ttf');
font-style: normal;
font-weight: normal;
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

+12
View File
@@ -0,0 +1,12 @@
kbd {
align-items: center;
background: rgba(125, 125, 125, 0.1);
border-radius: 3px;
border: 0;
padding: 4px 5px;
font-weight: bold;
box-shadow:
inset 0 -2px 0 0 #cdcde6,
inset 0 0 1px 1px #fff,
0 1px 2px 1px rgba(30, 35, 90, 0.4);
}
+13
View File
@@ -0,0 +1,13 @@
@import './kbd.css';
@import './code.css';
body {
padding: 0;
margin: 0;
}
.el-tabs__header {
position: sticky;
top: 0px;
z-index: 2;
}
+94
View File
@@ -0,0 +1,94 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElMessage: typeof import('element-plus/es')['ElMessage']
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const store: typeof import('./store/index')['default']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useBookStore: typeof import('./store/bookStore')['useBookStore']
const useConnectionStore: typeof import('./store/connectionStore')['useConnectionStore']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useSourceStore: typeof import('./store/sourceStore')['useSourceStore']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
Vendored Executable
+109
View File
@@ -0,0 +1,109 @@
/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/data/entities */
export type BaseBook = {
name: string
author: string
bookUrl: string
kind?: string
wordCount?: string
variable?: string
/** 忽略序列化
infoHtml?: string
tocHtml?: string
*/
}
export type Book = BaseBook & {
// 目录页Url (toc=table of Contents)
tocUrl: string
// 书源URL(默认BookType.local)
origin: string
//书源名称 or 本地书籍文件名
originName: string
// 分类信息(用户修改)
customTag?: string
// 封面Url(书源获取)
coverUrl?: string
// 封面Url(用户修改)
customCoverUrl?: string
// 简介内容(书源获取)
intro?: string
// 简介内容(用户修改)
customnumberro?: string
// 自定义字符集名称(仅适用于本地书籍)
charset?: string
// 类型详见BookType
type: number
// 自定义分组索引号
group: number
// 最新章节标题
latestChapterTitle?: string
// 最新章节标题更新时间
latestChapterTime: number
// 最近一次更新书籍信息的时间
lastCheckTime: number
// 最近一次发现新章节的数量
lastCheckCount: number
// 书籍目录总数
totalChapterNum: number
// 当前章节名称
durChapterTitle?: string
// 当前章节索引
durChapterIndex: number
// 当前阅读的进度(首行字符的索引位置)
durChapterPos: number
// 最近一次阅读书籍的时间(打开正文的时间)
durChapterTime: number
// 刷新书架时更新书籍信息
canUpdate: boolean
// 手动排序
order: number
//书源排序
originOrder: number
//阅读设置
readConfig?: ReadConfig
//同步时间
syncTime: number
}
export type SeachBook = BaseBook & {
/** 书源 */
origin: string
originName: string
/** BookType */
type: number
coverUrl?: string
intro?: string
latestChapterTitle?: string
/** 目录页Url (toc=table of Contents) */
tocUrl: string
time: number
originOrder: number
chapterWordCountText?: string
chapterWordCount: number0
respondTime: number
}
export type BookProgress = Pick<
Book,
| 'name'
| 'author'
| 'durChapterIndex'
| 'durChapterPos'
| 'durChapterTime'
| 'durChapterTitle'
>
export type BookChapter = {
url: string // 章节地址
title: string // 章节标题
isVolume: boolean // 是否是卷名
baseUrl: string // 用来拼接相对url
bookUrl: string // 书籍地址
index: number // 章节序号
isVip: boolean // 是否VIP
isPay: boolean // 是否已购买
resourceUrl?: string // 音频真实URL
tag?: string // 更新时间或其他章节附加信息
start?: number // 章节起始位置
end?: number // 章节终止位置
startFragmentId?: string //EPUB书籍当前章节的fragmentId
endFragmentId?: string //EPUB书籍下一章节的fragmentId
variable?: string //变量
}
+44
View File
@@ -0,0 +1,44 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
BookItems: typeof import('./components/BookItems.vue')['default']
CatalogItem: typeof import('./components/CatalogItem.vue')['default']
ChapterContent: typeof import('./components/ChapterContent.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElOption: typeof import('element-plus/es')['ElOption']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
PopCatalog: typeof import('./components/PopCatalog.vue')['default']
ReadSettings: typeof import('./components/ReadSettings.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SourceDebug: typeof import('./components/SourceDebug.vue')['default']
SourceHelp: typeof import('./components/SourceHelp.vue')['default']
SourceItem: typeof import('./components/SourceItem.vue')['default']
SourceJson: typeof import('./components/SourceJson.vue')['default']
SourceList: typeof import('./components/SourceList.vue')['default']
SourceTabForm: typeof import('./components/SourceTabForm.vue')['default']
SourceTabTools: typeof import('./components/SourceTabTools.vue')['default']
ToolBar: typeof import('./components/ToolBar.vue')['default']
}
}
+196
View File
@@ -0,0 +1,196 @@
<template>
<div class="books-wrapper">
<div class="wrapper">
<div
class="book"
v-for="book in books"
:key="book.bookUrl"
@click="handleClick(book)"
>
<div class="cover-img">
<img
class="cover"
:src="getCover(book)"
:key="book.coverUrl"
@error.once="proxyImage"
alt=""
loading="lazy"
/>
</div>
<div class="info">
<div class="name">{{ book.name }}</div>
<div class="sub">
<div class="author">
{{ book.author }}
</div>
<div class="tags" v-if="isSearch">
<el-tag
v-for="tag in book.kind?.split(',').slice(0, 2)"
:key="tag"
>
{{ tag }}
</el-tag>
</div>
<div class="update-info" v-if="!isSearch">
<div class="dot"></div>
<div class="size">{{ (book as Book).totalChapterNum }}</div>
<div class="dot"></div>
<div class="date">
{{ dateFormat((book as Book).lastCheckTime) }}
</div>
</div>
</div>
<div class="intro" v-if="isSearch">{{ book.intro }}</div>
<div class="dur-chapter" v-if="!isSearch">
已读{{ (book as Book).durChapterTitle }}
</div>
<div class="last-chapter">最新{{ book.latestChapterTitle }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Book, SeachBook } from '@/book'
import { dateFormat, isLegadoUrl } from '../utils/utils'
import API from '@api'
const props = defineProps<{
books: Array<Book | SeachBook>
isSearch: boolean
}>()
const emit = defineEmits(['bookClick'])
const handleClick = (book: Book | SeachBook) => emit('bookClick', book)
const getCover = ({ bookUrl, coverUrl }: Book | SeachBook) => {
if (coverUrl === undefined) return API.getProxyCoverUrl(bookUrl)
return isLegadoUrl(coverUrl) ? API.getProxyCoverUrl(coverUrl) : coverUrl
}
const proxyImage = (evt: Event) => {
const target = evt.target as HTMLImageElement
target.src = API.getProxyCoverUrl(target.src)
}
const subJustify = computed(() =>
props.isSearch ? 'space-between' : 'flex-start',
)
</script>
<style lang="scss" scoped>
.books-wrapper {
overflow: auto;
.wrapper {
display: grid;
grid-template-columns: repeat(auto-fill, 380px);
justify-content: space-around;
grid-gap: 10px;
.book {
user-select: none;
display: flex;
cursor: pointer;
margin-bottom: 18px;
padding: 24px 24px;
width: 360px;
flex-direction: row;
justify-content: space-around;
.cover-img {
width: 84px;
height: 112px;
.cover {
width: 84px;
height: 112px;
}
}
.info {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: left;
height: 112px;
margin-left: 20px;
flex: 1;
overflow: hidden;
.name {
width: fit-content;
font-size: 16px;
font-weight: 700;
color: #33373d;
}
.sub {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: v-bind('subJustify');
font-size: 12px;
font-weight: 600;
color: #6b6b6b;
.tags {
:deep(.el-tag) {
margin-right: 0.5em;
}
}
.update-info {
display: flex;
.dot {
margin: 0 7px;
}
}
}
.intro,
.dur-chapter,
.last-chapter {
color: #969ba3;
font-size: 13px;
margin-top: 3px;
font-weight: 500;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
line-clamp: 1;
text-align: left;
}
}
}
.book:hover {
background: rgba(0, 0, 0, 0.1);
transition-duration: 0.5s;
}
}
.wrapper:last-child {
margin-right: auto;
}
}
.books-wrapper::-webkit-scrollbar {
width: 0 !important;
}
@media screen and (max-width: 750px) {
.books-wrapper {
.wrapper {
display: flex;
flex-direction: column;
.book {
box-sizing: border-box;
width: 100%;
margin-bottom: 0;
padding: 10px 20px;
}
}
}
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div class="wrapper">
<div
v-for="cata in catas"
class="cata-text"
:key="cata.url"
:class="{ selected: isSelected(cata.index) }"
@click="gotoChapter(cata)"
>
{{ cata.title }}
</div>
</div>
</template>
<script setup lang="ts">
import type { BookChapter } from '@/book'
const props = defineProps<{
index: number
source: BookChapter | { index: number; catas: BookChapter[] }
gotoChapter: (chapter: BookChapter) => void
currentChapterIndex: number
}>()
const isSelected = (idx: number) => {
return idx == props.currentChapterIndex
}
// PC端 一个虚拟列表中有两个章节
const catas = computed(() => {
const source = props.source
if ('catas' in source) return source.catas
return [props.source as BookChapter]
})
</script>
<style lang="scss" scoped>
.selected {
color: #eb4259;
}
.wrapper {
display: flex;
.cata-text {
width: 100%;
margin-right: 26px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
</style>
+190
View File
@@ -0,0 +1,190 @@
<template>
<div class="title" data-chapterpos="0" ref="titleRef">{{ title }}</div>
<div
v-for="(para, index) in contents"
:key="index"
ref="paragraphRef"
:data-chapterpos="chapterPos[index]"
>
<img
class="full"
v-if="/^\s*<img[^>]*src[^>]+>$/.test(String(para))"
:src="getImageSrc(para)"
@error.once="proxyImage"
loading="lazy"
/>
<p v-else :style="{ fontFamily, fontSize }" v-html="replaceImage(para)" @error.capture="handleImgLoadError" />
</div>
</template>
<script setup lang="ts">
import { isLegadoUrl } from '@/utils/utils'
import API from '@api'
import jump from '@/plugins/jump'
import type { webReadConfig } from '@/web'
const store = useBookStore()
const readWidth = computed(() => store.config.readWidth)
const fontSize = computed(() => store.config.fontSize)
const bookUrl = computed(() => store.readingBook.bookUrl)
const props = defineProps<{
chapterIndex: number
contents: Array<string>
title: string
spacing: webReadConfig['spacing']
fontFamily: string
fontSize: string
}>()
const imgPattern = /<img[^>]*src=['"]([^'"]*(?:['"][^>]+\})?)['"][^>]*>/g
const replaceImage = (content: string) => {
return content.replace(imgPattern, (match, src) => {
if (isLegadoUrl(src)) {
const proxySrc = API.getProxyImageUrl(
bookUrl.value,
src,
fontSize.value * 2,
)
return match.replace(src, proxySrc)
}
return match
})
}
const getImageSrc = (content: string) => {
const imgPattern = /<img[^>]*src=['"]([^'"]*(?:['"][^>]+\})?)['"][^>]*>/
const src = content.match(imgPattern)![1] //reg tested in template
if (isLegadoUrl(src))
return API.getProxyImageUrl(
bookUrl.value,
src,
readWidth.value,
)
return src
}
const proxyImage = (event: Event) => {
/* 获取IMG标签原始的src
<img src="/test" />
假设location.href = http://example.com
event.target.src 返回 http://example.com/test
(event.target as HTMLImageElement)?.getAttribute("src") 返回/test
*/
const src = (event.target as HTMLImageElement)?.getAttribute("src")
if (src != null && src.length > 0) {
(event.target as HTMLImageElement).src = API.getProxyImageUrl(
bookUrl.value,
src,
readWidth.value,
)
}
}
/**
* 处理传入的IMG标签错误事件,自动替换图片的代理链接
*/
const handleImgLoadError = (event: Event) => {
if ((event.target as HTMLElement)?.tagName === "IMG") {
console.log("[ChapterContent]: IMG Load Error, replace src:",
(event.target as HTMLImageElement)?.getAttribute("src"), "=>",
API.getProxyImageUrl(
bookUrl.value,
(event.target as HTMLImageElement)?.getAttribute("src") ?? "",
readWidth.value,
)
)
proxyImage(event)
}
}
const calculateWordCount = (paragraph: string) => {
//内嵌图片文字为1
const imagePlaceHolder = ' '
return paragraph.replace(imgPattern, imagePlaceHolder).length
}
const chapterPos = computed(() => {
let pos = -1
return Array.from(props.contents, content => {
pos += calculateWordCount(content) + 1 //计算上一段的换行符
return pos
})
})
const titleRef = ref<HTMLElement>()
const paragraphRef = ref<HTMLParagraphElement[]>()
const scrollToReadedLength = (length: number) => {
if (length === 0) return
const paragraphIndex = chapterPos.value.findIndex(
wordCount => wordCount >= length,
)
if (paragraphIndex === -1) return
nextTick(() => {
jump(paragraphRef.value![paragraphIndex], {
duration: 0,
})
})
}
defineExpose({
scrollToReadedLength,
})
let intersectionObserver: IntersectionObserver | null = null
const emit = defineEmits(['readedLengthChange'])
onMounted(() => {
intersectionObserver = new IntersectionObserver(
entries => {
for (const { target, isIntersecting } of entries) {
if (isIntersecting) {
emit(
'readedLengthChange',
props.chapterIndex,
parseInt((target as HTMLElement).dataset.chapterpos as string),
)
}
}
},
{
rootMargin: `0px 0px -${window.innerHeight - 24}px 0px`,
},
)
intersectionObserver.observe(titleRef.value!)
paragraphRef.value!.forEach(element => {
intersectionObserver!.observe(element)
})
})
onUnmounted(() => {
intersectionObserver?.disconnect()
intersectionObserver = null
})
</script>
<style lang="scss" scoped>
.title {
margin-bottom: 57px;
font:
24px / 32px PingFangSC-Regular,
HelveticaNeue-Light,
'Helvetica Neue Light',
'Microsoft YaHei',
sans-serif;
}
p {
display: block;
word-wrap: break-word;
/* word-break: break-all; */
letter-spacing: calc(v-bind('props.spacing.letter') * 1em);
line-height: calc(1 + v-bind('props.spacing.line'));
margin: calc(v-bind('props.spacing.paragraph') * 1em) 0;
:deep(img) {
height: 1em;
}
}
.full {
display: block;
width: 100%;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div
:class="{ 'cata-wrapper': true, visible: popCataVisible }"
:style="popupTheme"
>
<div class="title">目录</div>
<virtual-list
style="height: 300px; overflow: auto"
:class="{ night: isNight, day: !isNight }"
ref="virtualListRef"
data-key="index"
wrap-class="data-wrapper"
item-class="cata"
:data-sources="virtualListdata"
:data-component="CatalogItem"
:estimate-size="40"
:extra-props="{ gotoChapter, currentChapterIndex }"
/>
</div>
</template>
<script setup lang="ts">
import VirtualList from 'vue3-virtual-scroll-list'
import settings from '../config/themeConfig'
import '../assets/fonts/popfont.css'
import CatalogItem from './CatalogItem.vue'
import type { BookChapter } from '@/book'
const store = useBookStore()
const { catalog, popCataVisible, miniInterface } = storeToRefs(store)
//主题
const isNight = computed(() => store.theme)
const theme = computed(() => store.theme)
const popupTheme = computed(() => {
return {
background: settings.themes[theme.value].popup,
}
})
//虚拟列表 数据源
const virtualListdata = computed(() => {
const catalogValue = catalog.value
if (miniInterface.value) return catalogValue
// pc端 virtualListIitem有2个章节
const length = Math.ceil(catalogValue.length / 2)
const virtualListDataSource = new Array<{
index: number
catas: BookChapter[]
}>(length)
let i = 0
while (i < length) {
virtualListDataSource[i] = {
index: i,
catas: catalogValue.slice(2 * i, 2 * i + 2),
}
i++
}
return virtualListDataSource
})
//打开目录 计算当前章节对应的虚拟列表位置
const virtualListRef = ref()
const currentChapterIndex = computed({
get: () => store.readingBook.chapterIndex,
set: value => (store.readingBook.chapterIndex = value),
})
const virtualListIndex = computed(() => {
const index = currentChapterIndex.value
if (miniInterface.value) return index
// pc端 virtualListIitem有2个章节
return Math.floor(index / 2)
})
onUpdated(() => {
// dom更新触发ResizeObserver,更新虚拟列表内部的sizes Map
if (!popCataVisible.value) return
virtualListRef.value.scrollToIndex(virtualListIndex.value)
})
// 点击加载对应章节内容
const emit = defineEmits(['getContent'])
const gotoChapter = (chapter: BookChapter) => {
const chapterIndex = catalog.value.indexOf(chapter)
currentChapterIndex.value = chapterIndex
store.setPopCataVisible(false)
store.setContentLoading(true)
store.saveBookProgress()
emit('getContent', chapterIndex)
}
</script>
<style lang="scss" scoped>
.cata-wrapper {
margin: -16px;
padding: 18px 0 24px 25px;
/* background: #ede7da url('../assets/imgs/themes/popup_1.png') repeat; */
.title {
font-size: 18px;
font-weight: 400;
font-family: FZZCYSK;
margin: 0 0 20px 0;
color: #ed4259;
width: fit-content;
border-bottom: 1px solid #ed4259;
}
:deep(.data-wrapper) {
.cata {
/*width: 50%;*/
height: 40px;
cursor: pointer;
font:
16px / 40px PingFangSC-Regular,
HelveticaNeue-Light,
'Helvetica Neue Light',
'Microsoft YaHei',
sans-serif;
}
}
.night {
:deep(.cata) {
border-bottom: 1px solid #666;
}
}
.day {
:deep(.cata) {
border-bottom: 1px solid #f2f2f2;
}
}
}
</style>
+596
View File
@@ -0,0 +1,596 @@
<template>
<div
class="settings-wrapper"
:style="popupTheme"
:class="{ night: isNight, day: !isNight }"
>
<div class="settings-title">设置</div>
<div class="setting-list">
<ul>
<li class="theme-list">
<i>阅读主题</i>
<span
class="theme-item"
v-for="(themeColor, index) in themeColors"
:key="index"
:style="themeColor"
ref="themes"
@click="setTheme(index)"
:class="{ selected: theme == index }"
><em v-if="index < 6" class="iconfont">&#58980;</em
><em v-else class="moon-icon">{{ moonIcon }}</em></span
>
</li>
<li class="font-list">
<i>正文字体</i>
<span
class="font-item"
v-for="(font, index) in fonts"
:key="index"
:class="{ selected: selectedFont == index }"
@click="setFont(index)"
>{{ font }}</span
>
</li>
<li class="font-list">
<i>自定字体</i>
<el-tooltip effect="dark" content="自定义的字体名称" placement="top">
<input
type="text"
class="font-item font-item-input"
v-model="customFontName"
placeholder="请输入自定义的字体名称"
/>
</el-tooltip>
<el-popover
placement="top"
width="270"
trigger="click"
v-model:visible="customFontSavePopVisible"
>
<p>
已经安装在您的设备上的字体请确认输入的字体名称完整无误或者从网络下载字体
</p>
<div style="text-align: right; margin: 0">
<el-button
size="small"
plain
@click="customFontSavePopVisible = false"
>取消</el-button
>
<el-button type="primary" size="small" @click="setCustomFont()"
>确定</el-button
>
<el-button type="primary" size="small" @click="loadFontFromURL()"
>网络下载</el-button
>
</div>
<template #reference>
<span type="text" class="font-item">保存</span>
</template>
</el-popover>
</li>
<li class="font-size">
<i>字体大小</i>
<div class="resize">
<span class="less" @click="lessFontSize"
><em class="iconfont">&#58966;</em></span
><b></b> <span class="lang">{{ fontSize }}</span
><b></b>
<span class="more" @click="moreFontSize"
><em class="iconfont">&#58976;</em></span
>
</div>
</li>
<li class="letter-spacing">
<i>字距</i>
<div class="resize">
<span class="less" @click="lessLetterSpacing"
><em class="iconfont">&#58966;</em></span
><b></b> <span class="lang">{{ spacing.letter.toFixed(2) }}</span
><b></b>
<span class="more" @click="moreLetterSpacing"
><em class="iconfont">&#58976;</em></span
>
</div>
</li>
<li class="line-spacing">
<i>行距</i>
<div class="resize">
<span class="less" @click="lessLineSpacing"
><em class="iconfont">&#58966;</em></span
><b></b> <span class="lang">{{ spacing.line.toFixed(1) }}</span
><b></b>
<span class="more" @click="moreLineSpacing"
><em class="iconfont">&#58976;</em></span
>
</div>
</li>
<li class="paragraph-spacing">
<i>段距</i>
<div class="resize">
<div class="resize">
<span class="less" @click="lessParagraphSpacing"
><em class="iconfont">&#58966;</em></span
><b></b>
<span class="lang">{{ spacing.paragraph.toFixed(1) }}</span
><b></b>
<span class="more" @click="moreParagraphSpacing"
><em class="iconfont">&#58976;</em></span
>
</div>
</div>
</li>
<li class="read-width" v-if="!store.miniInterface">
<i>页面宽度</i>
<div class="resize">
<span class="less" @click="lessReadWidth"
><em class="iconfont">&#58965;</em></span
><b></b> <span class="lang">{{ readWidth }}</span
><b></b>
<span class="more" @click="moreReadWidth"
><em class="iconfont">&#58975;</em></span
>
</div>
</li>
<li class="paragraph-spacing">
<i>翻页速度</i>
<div class="resize">
<div class="resize">
<span class="less" @click="lessJumpDuration">
<em class="iconfont">&#xe625;</em>
</span>
<b></b> <span class="lang">{{ jumpDuration }}</span
><b></b>
<span class="more" @click="moreJumpDuration"
><em class="iconfont">&#xe626;</em></span
>
</div>
</div>
</li>
<li class="infinite-loading">
<i>无限加载</i>
<span
class="infinite-loading-item"
:key="0"
:class="{ selected: infiniteLoading == false }"
@click="setInfiniteLoading(false)"
>关闭</span
>
<span
class="infinite-loading-item"
:key="1"
:class="{ selected: infiniteLoading == true }"
@click="setInfiniteLoading(true)"
>开启</span
>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import '../assets/fonts/popfont.css'
import '../assets/fonts/iconfont.css'
import settings from '../config/themeConfig'
import API from '@api'
import { useDebounceFn } from '@vueuse/shared'
const store = useBookStore()
const saveConfigDebounce = useDebounceFn(
() => API.saveReadConfig(store.config),
500,
)
//阅读界面设置改变时保存同步配置
watch(
() => store.config,
() => {
saveConfigDebounce()
},
{
deep: 2, //深度为2
},
)
//主题颜色
const theme = computed(() => store.theme)
const isNight = computed(() => store.isNight)
const moonIcon = computed(() => (theme.value == 6 ? '' : ''))
const themeColors = [
{
background: 'rgba(250, 245, 235, 0.8)',
},
{
background: 'rgba(245, 234, 204, 0.8)',
},
{
background: 'rgba(230, 242, 230, 0.8)',
},
{
background: 'rgba(228, 241, 245, 0.8)',
},
{
background: 'rgba(245, 228, 228, 0.8)',
},
{
background: 'rgba(224, 224, 224, 0.8)',
},
{
background: 'rgba(0, 0, 0, 0.5)',
},
]
const popupTheme = computed(() => {
return {
background: settings.themes[theme.value].popup,
}
})
const setTheme = (theme: number) => {
store.config.theme = theme
}
//预置字体
const fonts = ref(['雅黑', '宋体', '楷书'])
const setFont = (font: number) => {
store.config.font = font
}
const selectedFont = computed(() => {
return store.config.font
})
//自定义字体
const customFontName = ref(store.config.customFontName)
const customFontSavePopVisible = ref(false)
const setCustomFont = () => {
customFontSavePopVisible.value = false
store.config.font = -1
store.config.customFontName = customFontName.value
}
// 加载网络字体
const loadFontFromURL = () => {
customFontSavePopVisible.value = false
ElMessageBox.prompt('请输入 字体网络链接', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /^https?:.+$/,
inputErrorMessage: 'url 形式不正确',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true
instance.confirmButtonText = '下载中……'
// instance.inputValue
const url = instance.inputValue
if (typeof FontFace !== 'function') {
ElMessage.error('浏览器不支持FontFace')
return done()
}
const fontface = new FontFace(customFontName.value, `url("${url}")`)
document.fonts.add(fontface)
fontface
.load()
//API.getBookShelf()
.then(function () {
instance.confirmButtonLoading = false
ElMessage.info('字体加载成功!')
setCustomFont()
done()
})
.catch(function (error) {
instance.confirmButtonLoading = false
instance.confirmButtonText = '确定'
ElMessage.error('下载失败,请检查您输入的 url')
throw error
})
} else {
done()
}
},
})
}
//字体大小
const fontSize = computed(() => {
return store.config.fontSize
})
const moreFontSize = () => {
if (store.config.fontSize < 48) store.config.fontSize += 2
}
const lessFontSize = () => {
if (store.config.fontSize > 12) store.config.fontSize -= 2
}
//字 行 段落间距
const spacing = computed(() => {
return store.config.spacing
})
const lessLetterSpacing = () => {
store.config.spacing.letter -= 0.01
}
const moreLetterSpacing = () => {
store.config.spacing.letter += 0.01
}
const lessLineSpacing = () => {
store.config.spacing.line -= 0.1
}
const moreLineSpacing = () => {
store.config.spacing.line += 0.1
}
const lessParagraphSpacing = () => {
store.config.spacing.paragraph -= 0.1
}
const moreParagraphSpacing = () => {
store.config.spacing.paragraph += 0.1
}
//页面宽度
const readWidth = computed(() => {
return store.config.readWidth
})
const moreReadWidth = () => {
// 此时会截断页面
if (store.config.readWidth + 160 + 2 * 68 > window.innerWidth) return
store.config.readWidth += 160
}
const lessReadWidth = () => {
if (store.config.readWidth > 640) store.config.readWidth -= 160
}
//翻页速度
const jumpDuration = computed(() => {
return store.config.jumpDuration
})
const moreJumpDuration = () => {
store.config.jumpDuration += 100
}
const lessJumpDuration = () => {
if (store.config.jumpDuration === 0) return
store.config.jumpDuration -= 100
}
//无限加载
const infiniteLoading = computed(() => {
return store.config.infiniteLoading
})
const setInfiniteLoading = (loading: boolean) => {
store.config.infiniteLoading = loading
}
</script>
<style lang="scss" scoped>
:deep(.iconfont) {
font-family: iconfont;
font-style: normal;
}
:deep(.moon-icon) {
font-family: iconfont;
font-style: normal;
}
.settings-wrapper {
user-select: none;
margin: -13px;
/* width: 478px;
height: 350px; */
text-align: left;
padding: 40px 0 40px 24px;
background: #ede7da url('../assets/imgs/themes/popup_1.png') repeat;
.settings-title {
font-size: 18px;
line-height: 22px;
margin-bottom: 28px;
font-family: FZZCYSK;
font-weight: 400;
}
.setting-list {
max-height: calc(70vh - 50px);
overflow: auto;
ul {
list-style: none outside none;
margin: 0;
padding: 0;
li {
list-style: none outside none;
i {
font:
12px / 16px PingFangSC-Regular,
'-apple-system',
Simsun;
display: inline-block;
min-width: 48px;
margin-right: 16px;
vertical-align: middle;
color: #666;
}
.theme-item {
line-height: 32px;
width: 34px;
height: 34px;
margin-right: 16px;
margin-top: 5px;
border-radius: 100%;
display: inline-block;
cursor: pointer;
text-align: center;
vertical-align: middle;
.iconfont {
display: none;
}
}
.selected {
color: #ed4259;
.iconfont {
display: inline;
}
}
}
.font-list,
.infinite-loading {
margin-top: 28px;
.font-item,
.infinite-loading-item {
width: 78px;
height: 34px;
cursor: pointer;
margin-right: 16px;
border-radius: 2px;
text-align: center;
vertical-align: middle;
display: inline-block;
font:
14px / 34px PingFangSC-Regular,
HelveticaNeue-Light,
'Helvetica Neue Light',
'Microsoft YaHei',
sans-serif;
}
.font-item-input {
width: 168px;
color: #000000;
}
.selected {
color: #ed4259;
border: 1px solid #ed4259;
}
.font-item:hover,
.infinite-loading-item:hover {
border: 1px solid #ed4259;
color: #ed4259;
}
}
.font-size,
.read-width,
.letter-spacing,
.line-spacing,
.paragraph-spacing {
margin-top: 28px;
.resize {
display: inline-block;
width: 274px;
height: 34px;
vertical-align: middle;
border-radius: 2px;
span {
width: 89px;
height: 34px;
line-height: 34px;
display: inline-block;
cursor: pointer;
text-align: center;
vertical-align: middle;
em {
font-style: normal;
}
}
.less:hover,
.more:hover {
color: #ed4259;
}
.lang {
color: #a6a6a6;
font-weight: 400;
font-family: FZZCYSK;
}
b {
display: inline-block;
height: 20px;
vertical-align: middle;
}
}
}
}
}
}
.night {
:deep(.theme-item) {
border: 1px solid #666;
}
:deep(.selected) {
border: 1px solid #666;
}
:deep(.moon-icon) {
color: #ed4259;
}
:deep(.font-list),
.infinite-loading {
.font-item,
.infinite-loading-item {
border: 1px solid #666;
background: rgba(45, 45, 45, 0.5);
}
}
:deep(.resize) {
border: 1px solid #666;
background: rgba(45, 45, 45, 0.5);
b {
border-right: 1px solid #666;
}
}
}
.day {
:deep(.theme-item) {
border: 1px solid #e5e5e5;
}
:deep(.selected) {
border: 1px solid #ed4259;
}
:deep(.moon-icon) {
display: inline;
color: rgba(255, 255, 255, 0.2);
}
:deep(.font-list),
.infinite-loading {
.font-item,
.infinite-loading-item {
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(0, 0, 0, 0.1);
}
}
:deep(.resize) {
border: 1px solid #e5e5e5;
background: rgba(255, 255, 255, 0.5);
b {
border-right: 1px solid #e5e5e5;
}
}
}
@media screen and (max-width: 500px) {
.settings-wrapper i {
display: flex !important;
flex-wrap: wrap;
padding-bottom: 5px !important;
}
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<template>
<el-input
v-if="isBookSource"
id="debug-key"
v-model="searchKey"
placeholder="搜索书名、作者"
:prefix-icon="Search"
style="padding-bottom: 4px"
@keydown.enter="startDebug"
/>
<el-input
id="debug-text"
v-model="printDebug"
type="textarea"
readonly
:rows="29"
placeholder="这里用于输出调试信息"
/>
</template>
<script setup lang="ts">
import API from '@api'
import { Search } from '@element-plus/icons-vue'
const store = useSourceStore()
const printDebug = ref('')
const searchKey = ref('')
watch(
() => store.isDebuging,
() => {
if (store.isDebuging) startDebug()
},
)
const appendDebugMsg = (msg: string) => {
const debugDom = document.querySelector('#debug-text')
debugDom!.scrollTop = debugDom!.scrollHeight
printDebug.value += msg + '\n'
}
const startDebug = async () => {
printDebug.value = ''
try {
await API.saveSource(store.currentSource)
} catch (e) {
store.debugFinish()
throw e
}
API.debug(
store.currentSourceUrl,
searchKey.value || store.searchKey,
appendDebugMsg,
store.debugFinish,
)
}
const isBookSource = computed(() => {
return /bookSource/i.test(window.location.href)
})
</script>
<style lang="scss" scoped>
:deep(#debug-text) {
height: calc(100vh - 45px - 36px - 5px);
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { Link } from '@element-plus/icons-vue'
</script>
<template>
<el-link :icon="Link" href="/help/#appHelp" target="_blank"
>APP帮助文档</el-link
><br />
<el-link :icon="Link" href="/help/#ruleHelp" target="_blank"
>书源制作教程</el-link
><br />
<el-link :icon="Link" href="/help/#jsHelp" target="_blank"
>js变量和函数</el-link
><br />
<el-link :icon="Link" href="/help/#xpathHelp" target="_blank"
>xpath语法教程</el-link
><br />
<el-link :icon="Link" href="/help/#regexHelp" target="_blank"
>正则表达式教程</el-link
><br />
<el-link :icon="Link" href="/help/#txtTocRuleHelp" target="_blank"
>txt目录正则说明</el-link
><br />
<el-link :icon="Link" href="/help/#debugHelp" target="_blank"
>书源调试说明</el-link
><br />
<el-link :icon="Link" href="/help/#httpTTSHelp" target="_blank"
>在线朗读规则</el-link
><br />
<el-link :icon="Link" href="/help/#webDavBookHelp" target="_blank">
WebDav书籍简明使用教程</el-link
><br />
<el-link :icon="Link" href="/help/#webDavHelp" target="_blank">
WebDav备份教程</el-link
><br />
<el-link :icon="Link" href="https://regexr-cn.com/" target="_blank"
>正则表达式在线验证工具</el-link
><br />
<div style="margin-top: 20px">
<span
><el-text
><code>^$()[]{}.?+*|</code> 这些是Java正则特殊符号,匹配需转义</el-text
></span
><br />
<span
><el-text><code>(?s)</code> 前缀表示跨行解析</el-text></span
><br />
<span
><el-text><code>(?m)</code> 前缀表示逐行匹配</el-text></span
><br />
<span
><el-text><code>(?i)</code> 前缀表示忽略大小写</el-text></span
><br />
</div>
</template>
<style lang="scss" scoped>
.el-link {
padding: 4px;
}
.el-text {
padding-top: 20px;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<template>
<el-checkbox
size="large"
border
:value="sourceUrl"
:class="{
error: isSaveError,
edit: sourceUrl == currentSourceUrl,
}"
>
{{ getSourceName(source) }}
<el-button text :icon="Edit" @click="handleSourceClick(source)" />
</el-checkbox>
</template>
<script setup lang="ts">
import { Edit } from '@element-plus/icons-vue'
import { getSourceUniqueKey, getSourceName } from '@/utils/souce'
import type { Source } from '@/source'
const props = defineProps<{
source: Source
}>()
const store = useSourceStore()
const currentSourceUrl = computed(() => store.currentSourceUrl)
const sourceUrl = computed(() => getSourceUniqueKey(props.source))
const handleSourceClick = (source: Source) => {
store.changeCurrentSource(source)
}
const isSaveError = computed(() => {
const map = store.savedSourcesMap
if (map.size == 0) return false
return !map.has(sourceUrl.value)
})
</script>
<style lang="scss" scoped>
:deep(.el-checkbox__label) {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.error {
border-color: var(--el-color-error) !important;
color: var(--el-color-error) !important;
--el-checkbox-checked-text-color: var(--el-color-error);
--el-checkbox-checked-bg-color: var(--el-color-error);
--el-checkbox-checked-input-border-color: var(--el-color-error);
}
.edit {
border-color: var(--el-color-dark) !important;
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<template>
<el-input
id="source-json"
v-model="sourceString"
type="textarea"
placeholder="这里输出序列化的JSON数据,可直接导入'阅读'APP"
:rows="30"
@change="update"
style="margin-bottom: 4px"
></el-input>
</template>
<script setup lang="ts">
import { useSourceStore } from '@/store'
const store = useSourceStore()
const sourceString = ref('')
const update = async (string: string) => {
try {
store.changeEditTabSource(JSON.parse(string))
} catch {
ElMessage({
message: '粘贴的源格式错误',
type: 'error',
})
}
}
watchEffect(async () => {
const source = store.editTabSource
if (Object.keys(source).length > 0) {
sourceString.value = JSON.stringify(source, null, 4)
} else {
sourceString.value = ''
}
})
</script>
<style lang="scss" scoped>
:deep(.el-input) {
width: 100%;
}
:deep(#source-json) {
height: calc(100vh - 50px);
}
</style>
+161
View File
@@ -0,0 +1,161 @@
<template>
<el-input
v-model="searchKey"
class="search"
:prefix-icon="Search"
placeholder="筛选源"
/>
<div class="tool">
<el-button @click="importSourceFile" :icon="Folder">打开</el-button>
<el-button
:disabled="sourcesFiltered.length === 0"
@click="outExport"
:icon="Download"
>
导出</el-button
>
<el-button
type="danger"
:icon="Delete"
@click="deleteSelectSources"
:disabled="sourceSelect.length === 0"
>删除</el-button
>
<el-button
type="danger"
:icon="Delete"
@click="clearAllSources"
:disabled="sources.length === 0"
>清空</el-button
>
</div>
<el-checkbox-group id="source-list" v-model="sourceUrlSelect">
<virtual-list
style="height: 100%; overflow-y: auto; overflow-x: hidden"
:data-key="(source: Source) => getSourceName(source)"
:data-sources="sourcesFiltered"
:data-component="SourceItem"
:estimate-size="45"
/>
</el-checkbox-group>
</template>
<script setup lang="ts">
import API from '@api'
import { Folder, Delete, Download, Search } from '@element-plus/icons-vue'
import {
isSourceMatches,
getSourceUniqueKey,
getSourceName,
convertSourcesToMap,
} from '@utils/souce'
import VirtualList from 'vue3-virtual-scroll-list'
import SourceItem from './SourceItem.vue'
import type { Source } from '@/source'
const store = useSourceStore()
const sourceUrlSelect = ref<string[]>([])
const searchKey = ref('')
const sources = computed(() => store.sources)
/* 筛选源 */
const sourcesFiltered = computed<Source[]>(() => {
const key = searchKey.value
if (key === '') return sources.value
return sources.value.filter(source => isSourceMatches(source, key))
})
// 计算当前筛选关键词下的选中源
const sourceSelect = computed<Source[]>(() => {
const urls = sourceUrlSelect.value
if (urls.length == 0) return []
const sourcesFilteredMap =
searchKey.value == ''
? store.sourcesMap
: convertSourcesToMap(sourcesFiltered.value)
return urls.reduce((sources, sourceUrl) => {
const source = sourcesFilteredMap.get(sourceUrl)
if (source) sources.push(source)
return sources
}, [] as Source[])
})
const deleteSelectSources = () => {
const sourceSelectValue = sourceSelect.value
API.deleteSource(sourceSelectValue).then(({ data }) => {
if (!data.isSuccess) return ElMessage.error(data.errorMsg)
store.deleteSources(sourceSelectValue)
const sourceUrlSelectRawValue = toRaw(sourceUrlSelect.value)
sourceSelectValue.forEach(source => {
const index = sourceUrlSelectRawValue.indexOf(getSourceUniqueKey(source))
if (index > -1) sourceUrlSelectRawValue.splice(index, 1)
})
sourceUrlSelect.value = sourceUrlSelectRawValue
})
}
const clearAllSources = () => {
store.clearAllSource()
sourceUrlSelect.value = []
}
//导入本地文件
const importSourceFile = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json,.txt'
input.addEventListener('change', () => {
const files = input.files
if (files === null) {
return ElMessage.info('未选择文件')
}
const reader = new FileReader()
reader.readAsText(files[0])
reader.onload = () => {
try {
const jsonData = JSON.parse(reader.result as string)
store.saveSources(jsonData)
} catch (e: unknown) {
ElMessage.error('上传的源格式错误: ' + (e as Error).message)
}
}
})
input.click()
}
const isBookSource = /bookSource/i.test(window.location.href)
const outExport = () => {
const exportFile = document.createElement('a')
const sources =
sourceUrlSelect.value.length === 0
? sourcesFiltered.value
: sourceSelect.value,
sourceType = isBookSource ? 'BookSource' : 'RssSource'
exportFile.download = `${sourceType}_${Date()
.replace(/.*?\s(\d+)\s(\d+)\s(\d+:\d+:\d+).*/, '$2$1$3')
.replace(/:/g, '')}.json`
const myBlob = new Blob([JSON.stringify(sources, null, 4)], {
type: 'application/json',
})
exportFile.href = window.URL.createObjectURL(myBlob)
exportFile.click()
window.URL.revokeObjectURL(exportFile.href) //avoid memory leak
}
</script>
<style lang="scss" scoped>
.tool {
display: flex;
margin: 4px 0;
justify-content: center;
}
#source-list {
margin-top: 6px;
height: calc(100vh - 112px - 7px);
:deep(.el-checkbox) {
margin-bottom: 4px;
width: 100%;
}
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<template>
<el-tabs id="source-edit">
<el-tab-pane
v-for="{ name, children } in Object.values(config)"
:label="name"
:key="name"
>
<el-form label-position="right" label-width="auto">
<el-form-item
v-for="{
type,
title,
namespace,
id,
array,
hint,
required = false,
} in children"
:label="title"
:key="title"
:required="required"
>
<el-input
v-if="type == 'String' && typeof namespace == 'undefined'"
type="textarea"
v-model="currentSource[id]"
:placeholder="hint"
autosize
/>
<el-input
v-if="type == 'String' && typeof namespace != 'undefined'"
type="textarea"
v-model="currentSource[namespace][id]"
:placeholder="hint"
autosize
/>
<el-switch
v-if="(type as string) === 'Boolean'"
v-model="currentSource[id]"
/>
<el-input-number
v-if="(type as string) === 'Number'"
v-model="currentSource[id]"
:min="0"
/>
<el-select
v-if="(type as string) === 'Array'"
v-model="currentSource[id]"
>
<el-option
v-for="(optionName, index) in array"
:value="index"
:key="optionName"
:label="optionName"
/>
</el-select>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</template>
<script setup lang="ts">
import type { SourceConfig } from '@/config/sourceConfig'
const store = useSourceStore()
defineProps<{ config: SourceConfig }>()
const currentSource = computed(() => store.currentSource)
/*
修改currentSource的属性 没有直接修改本身
const { currentSource } = storeToRefs(store);
*/
</script>
<style lang="scss" scoped>
:deep(.el-tab-pane) {
height: calc(100vh - 55px);
padding-top: 15px;
padding-right: 5px;
overflow-y: auto;
}
:deep(.el-tabs__header) {
margin: 0;
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<template>
<el-tabs v-model="current_tab">
<el-tab-pane
v-for="(tab, index) in tabData"
:key="tab[0]"
:name="tab[0]"
:label="tab[1]"
>
<source-json v-if="index == 0" />
<source-debug v-if="index == 1" />
<source-list v-if="index == 2" />
<source-help v-if="index == 3" />
</el-tab-pane>
</el-tabs>
</template>
<script setup lang="ts">
import { useSourceStore } from '@/store'
const store = useSourceStore()
const current_tab = computed({
get: () => store.currentTab,
set: val => (store.currentTab = val),
})
const tabData = ref([
['editTab', '编辑源'],
['editDebug', '调试源'],
['editList', '源列表'],
['editHelp', '帮助信息'],
])
</script>
<style lang="scss" scoped>
:deep(.el-tabs__header) {
margin-bottom: 5px;
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<template>
<div class="menu flex-column-center">
<el-button
v-for="button in buttons"
size="large"
:key="button.name"
@click="button.action"
>
{{ button.name }}
</el-button>
<el-button size="large" @click="() => (hotkeysDialogVisible = true)"
>快捷键</el-button
>
</div>
<el-dialog
v-model="hotkeysDialogVisible"
:show-close="false"
:before-close="stopRecordKeyDown"
>
<template #header="{ titleClass, titleId }">
<div class="hotkeys-header flex-space-between">
<div :id="titleId" :class="titleClass">
快捷键设置
<span v-if="recordKeyDowning">
<el-text> / 录入中 </el-text>
</span>
</div>
<el-button
:disabled="recordKeyDowning"
@click="saveHotKeys"
:icon="CircleCheckFilled"
>保存</el-button
>
</div>
</template>
<div class="hotkeys-settings flex-column-center">
<div
v-for="(button, buttonIndex) in buttons"
:key="button.name"
class="hotkeys-item flex-space-between"
>
<span class="title"
><el-text>{{ button.name }}</el-text></span
>
<div class="hotkeys-item__content">
<div v-for="(key, hotKeysIndex) in button.hotKeys" :key="key">
<kbd>{{ key }}</kbd>
<span v-if="hotKeysIndex + 1 < button.hotKeys.length">
<el-text>+</el-text>
</span>
</div>
<span v-if="button.hotKeys.length == 0">未设置</span>
</div>
<el-button
:disabled="recordKeyDowning"
text
:icon="Edit"
@click="recordKeyDown(buttonIndex)"
>编辑</el-button
>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import API from '@api'
import { CircleCheckFilled, Edit } from '@element-plus/icons-vue'
import hotkeys from 'hotkeys-js'
import { getSourceName, isInvaildSource, normalizeSource } from '../utils/souce'
const store = useSourceStore()
const pull = () => {
const loadingMsg = ElMessage({
message: '加载中……',
showClose: true,
duration: 0,
})
API.getSources()
.then(({ data }) => {
if (data.isSuccess) {
store.changeTabName('editList')
store.saveSources(data.data)
ElMessage({
message: `成功拉取${data.data.length}条源`,
type: 'success',
})
} else {
ElMessage({
message: data.errorMsg ?? '后端错误',
type: 'error',
})
}
})
.finally(() => loadingMsg.close())
}
const push = () => {
const sources = store.sources
store.changeTabName('editList')
if (sources.length === 0) {
return ElMessage({
message: '空空如也',
type: 'info',
})
}
ElMessage({
message: '正在推送中',
type: 'info',
})
API.saveSources(sources).then(({ data }) => {
if (data.isSuccess) {
const okData = data.data
if (Array.isArray(okData)) {
let failMsg = ``
if (sources.length > okData.length) {
failMsg = '\n推送失败的源将用红色字体标注!'
store.setPushReturnSources(okData)
}
ElMessage({
message: `批量推送源到「阅读3.0APP」\n共计: ${
sources.length
}\n成功: ${okData.length}\n失败: ${
sources.length - okData.length
}${failMsg}`,
type: 'success',
})
}
} else {
ElMessage({
message: `批量推送源失败!\nErrorMsg: ${data.errorMsg}`,
type: 'error',
})
}
})
}
const conver2Tab = () => {
store.changeTabName('editTab')
store.changeEditTabSource(store.currentSource)
}
const conver2Source = () => {
store.changeCurrentSource(store.editTabSource)
}
const undo = () => {
store.editHistoryUndo()
}
const clearEdit = () => {
store.clearEdit()
ElMessage({
message: '已清除',
type: 'success',
})
}
const redo = () => {
store.clearEdit()
store.clearAllHistory()
ElMessage({
message: '已清除所有历史记录',
type: 'success',
})
}
const saveSource = () => {
const source = store.currentSource
if (isInvaildSource(source)) {
normalizeSource(source)
API.saveSource(source).then(({ data }) => {
const sourceName = getSourceName(source)
if (data.isSuccess) {
ElMessage({
message: `源《${sourceName}》已成功保存到「阅读3.0APP」`,
type: 'success',
})
//save to store
store.saveCurrentSource()
} else {
ElMessage({
message: `源《${sourceName}》保存失败!\nErrorMsg: ${data.errorMsg}`,
type: 'error',
})
}
})
} else {
ElMessage({
message: `请检查<必填>项是否全部填写`,
type: 'error',
})
}
}
const debug = () => {
store.startDebug()
}
const buttons = ref<{ name: string; hotKeys: string[]; action: () => void }[]>(
Array.of(
{ name: '⇈推送源', hotKeys: [], action: push },
{ name: '⇊拉取源', hotKeys: [], action: pull },
{ name: '⋙生成源', hotKeys: [], action: conver2Tab },
{ name: '⋘编辑源', hotKeys: [], action: conver2Source },
{ name: '✗清空表单', hotKeys: [], action: clearEdit },
{ name: '↶撤销操作', hotKeys: [], action: undo },
{ name: '↷重做操作', hotKeys: [], action: redo },
{ name: '⇏调试源', hotKeys: [], action: debug },
{ name: '✓保存源', hotKeys: [], action: saveSource },
),
)
const hotkeysDialogVisible = ref(true)
const recordKeyDowning = ref(false)
const recordKeyDownIndex = ref(-1)
const stopRecordKeyDown = () => {
if (!recordKeyDowning.value) {
hotkeysDialogVisible.value = false
}
recordKeyDowning.value = false
}
watch(
hotkeysDialogVisible,
visibale => {
if (!visibale) {
hotkeys.unbind('*')
readHotkeysConfig()
bindHotKeys()
return
}
readHotkeysConfig()
hotkeys.unbind()
/**监听按键 */
hotkeys('*', event => {
event.preventDefault()
const pressedKeys = hotkeys.getPressedKeyString()
if (pressedKeys.length == 1 && pressedKeys[0] == 'esc') {
//单独按下esc 不录入
return
}
if (recordKeyDowning.value && recordKeyDownIndex.value > -1)
buttons.value[recordKeyDownIndex.value].hotKeys = pressedKeys
})
},
{ immediate: true },
)
const recordKeyDown = (index: number) => {
recordKeyDowning.value = true
ElMessage({
message: '按ESC键或者点击空白处结束录入',
type: 'info',
})
buttons.value[index].hotKeys = []
recordKeyDownIndex.value = index
}
const saveHotKeys = () => {
const hotKeysConfig: string[][] = []
buttons.value.forEach(({ hotKeys }) => {
hotKeysConfig.push(hotKeys)
})
saveHotkeysConfig(hotKeysConfig)
hotkeysDialogVisible.value = false
}
const bindHotKeys = () => {
// hotkeys默认过滤INPUT SELECT TEXTAREA
hotkeys.filter = () => true
buttons.value.forEach(({ hotKeys, action }) => {
if (hotKeys.length == 0) return
hotkeys(hotKeys.join('+'), event => {
event.preventDefault()
action.call(null)
})
})
}
const saveHotkeysConfig = (config: string[][]) => {
localStorage.setItem('legado_web_hotkeys', JSON.stringify(config))
}
/**
* 读取快捷键配置
* @return 是否成功读取配置
*/
function readHotkeysConfig() {
try {
const localStorageConfig = localStorage.getItem('legado_web_hotkeys')
if (localStorageConfig === null) return false
const config = JSON.parse(localStorageConfig)
if (!Array.isArray(config) || config.length == 0) return false
buttons.value.forEach((button, index) => (button.hotKeys = config[index]))
return true
} catch {
ElMessage({ message: '快捷键配置错误', type: 'error' })
localStorage.removeItem('legado_web_hotkeys')
}
return false
}
onMounted(() => {
/**读取热键配置 */
if (readHotkeysConfig()) {
hotkeysDialogVisible.value = false
}
})
</script>
<style lang="scss" scoped>
.flex-space-between {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.flex-column-center {
display: flex;
flex-direction: column;
justify-content: center;
}
.menu > .el-button {
margin: 4px;
padding: 1em;
width: 6em;
}
.hotkeys-item {
.title {
width: 5em;
display: flex;
justify-content: flex-end;
margin-right: 1em;
}
.hotkeys-item__content {
display: flex;
flex-wrap: wrap;
flex: 1;
div {
margin-bottom: 1em;
}
span {
margin: 0.5em;
}
}
}
</style>
+585
View File
@@ -0,0 +1,585 @@
export default {
base: {
name: '基础',
children: [
{
title: '源类型',
id: 'bookSourceType',
type: 'Array',
array: ['文本', '音频', '图片', '文件'],
required: true,
},
{
title: '源域名',
id: 'bookSourceUrl',
type: 'String',
hint: '通常填写网站主页,例: https://www.qidian.com',
required: true,
},
{
title: '源名称',
id: 'bookSourceName',
type: 'String',
hint: '会显示在源列表',
required: true,
},
{
title: '源分组',
id: 'bookSourceGroup',
type: 'String',
hint: '描述源的特征信息',
},
{
title: '源注释',
id: 'bookSourceComment',
type: 'String',
hint: '描述源作者和状态',
},
{
title: '登录地址',
id: 'loginUrl',
type: 'String',
hint: '填写网站登录网址,仅在需要登录的源有用',
},
{
title: '登录界面',
id: 'loginUi',
type: 'String',
hint: '自定义登录界面',
},
{
title: '登录检测',
id: 'loginCheckJs',
type: 'String',
hint: '登录检测js',
},
{
title: '封面解密',
id: 'coverDecodeJs',
type: 'String',
hint: '封面解密js',
},
{
title: '链接验证',
id: 'bookUrlPattern',
type: 'String',
hint: '书籍URL正则,当详情页URL与源URL的域名不一致时有效,用于添加网址',
},
{
title: '请求头',
id: 'header',
type: 'String',
hint: '客户端标识',
},
{
title: '变量说明',
id: 'variableComment',
type: 'String',
hint: '书源变量说明',
},
{
title: '并发率',
id: 'concurrentRate',
type: 'String',
hint: '并发率,如1000(访问间隔1000ms)或者1/1000(1000ms内访问1次)',
},
{
title: 'js库',
id: 'jsLib',
type: 'String',
hint: 'js库, 可填写js或者key-value object获取在线js文件',
},
],
},
search: {
name: '搜索',
children: [
{
title: '搜索地址',
id: 'searchUrl',
type: 'String',
hint: '[域名可省略]/search.php@kw={{key}}',
},
{
title: '校验文字',
namespace: 'ruleSearch',
id: 'checkKeyWord',
type: 'String',
hint: '校验关键字,强烈建议填写',
},
{
title: '列表规则',
namespace: 'ruleSearch',
id: 'bookList',
type: 'String',
hint: '选择书籍节点 (规则结果为List<Element>)',
},
{
title: '书名规则',
namespace: 'ruleSearch',
id: 'name',
type: 'String',
hint: '选择节点书名 (规则结果为String)',
},
{
title: '作者规则',
namespace: 'ruleSearch',
id: 'author',
type: 'String',
hint: '选择节点作者 (规则结果为String)',
},
{
title: '分类规则',
namespace: 'ruleSearch',
id: 'kind',
type: 'String',
hint: '选择节点分类信息 (规则结果为String)',
},
{
title: '字数规则',
namespace: 'ruleSearch',
id: 'wordCount',
type: 'String',
hint: '选择节点字数信息 (规则结果为String)',
},
{
title: '最新章节',
namespace: 'ruleSearch',
id: 'lastChapter',
type: 'String',
hint: '选择节点最新章节 (规则结果为String)',
},
{
title: '简介规则',
namespace: 'ruleSearch',
id: 'intro',
type: 'String',
hint: '选择节点书籍简介 (规则结果为String)',
},
{
title: '封面规则',
namespace: 'ruleSearch',
id: 'coverUrl',
type: 'String',
hint: '选择节点书籍封面 (规则结果为String类型的url)',
},
{
title: '详情地址',
namespace: 'ruleSearch',
id: 'bookUrl',
type: 'String',
hint: '选择书籍详情页网址 (规则结果为String类型的url)',
},
],
},
find: {
name: '发现',
children: [
{
title: '发现地址',
id: 'exploreUrl',
type: 'String',
hint: '单个发现格式<name>::<url>或者{url:<url>,title:<name>,style:...};前者用换行符或者&&连接,后者放在数组内;可用js动态生成',
},
{
title: '列表规则',
namespace: 'ruleExplore',
id: 'bookList',
type: 'String',
hint: '选择书籍节点 (规则结果为List<Element>)',
},
{
title: '书名规则',
namespace: 'ruleExplore',
id: 'name',
type: 'String',
hint: '选择节点书名 (规则结果为String)',
},
{
title: '作者规则',
namespace: 'ruleExplore',
id: 'author',
type: 'String',
hint: '选择节点作者 (规则结果为String)',
},
{
title: '分类规则',
namespace: 'ruleExplore',
id: 'kind',
type: 'String',
hint: '选择节点分类信息 (规则结果为String)',
},
{
title: '字数规则',
namespace: 'ruleExplore',
id: 'wordCount',
type: 'String',
hint: '选择节点字数信息 (规则结果为String)',
},
{
title: '最新章节',
namespace: 'ruleExplore',
id: 'lastChapter',
type: 'String',
hint: '选择节点最新章节 (规则结果为String)',
},
{
title: '简介规则',
namespace: 'ruleExplore',
id: 'intro',
type: 'String',
hint: '选择节点书籍简介 (规则结果为String)',
},
{
title: '封面规则',
namespace: 'ruleExplore',
id: 'coverUrl',
type: 'String',
hint: '选择节点书籍封面 (规则结果为String类型的url)',
},
{
title: '详情地址',
namespace: 'ruleExplore',
id: 'bookUrl',
type: 'String',
hint: '选择书籍详情页网址 (规则结果为String类型的url)',
},
],
},
detail: {
name: '详情',
children: [
{
title: '预处理',
namespace: 'ruleBookInfo',
id: 'init',
type: 'String',
hint: '用于加速详情信息检索,只支持AllInOne规则',
},
{
title: '书名规则',
namespace: 'ruleBookInfo',
id: 'name',
type: 'String',
hint: '选择节点书名 (规则结果为String)',
},
{
title: '作者规则',
namespace: 'ruleBookInfo',
id: 'author',
type: 'String',
hint: '选择节点作者 (规则结果为String)',
},
{
title: '分类规则',
namespace: 'ruleBookInfo',
id: 'kind',
type: 'String',
hint: '选择节点分类信息 (规则结果为String)',
},
{
title: '字数规则',
namespace: 'ruleBookInfo',
id: 'wordCount',
type: 'String',
hint: '选择节点字数信息 (规则结果为String)',
},
{
title: '最新章节',
namespace: 'ruleBookInfo',
id: 'lastChapter',
type: 'String',
hint: '选择节点最新章节 (规则结果为String)',
},
{
title: '简介规则',
namespace: 'ruleBookInfo',
id: 'intro',
type: 'String',
hint: '选择节点书籍简介 (规则结果为String)',
},
{
title: '封面规则',
namespace: 'ruleBookInfo',
id: 'coverUrl',
type: 'String',
hint: '选择节点书籍封面 (规则结果为String类型的url)',
},
{
title: '目录地址',
namespace: 'ruleBookInfo',
id: 'tocUrl',
type: 'String',
hint: '选择书籍详情页网址 (规则结果为String类型的url, 与详情页相同时可省略)',
},
{
title: '修改书籍',
namespace: 'ruleBookInfo',
id: 'canReName',
type: 'String',
hint: '允许修改书名作者(规则结果为String类型, 默认不允许)',
},
{
title: '下载URL',
namespace: 'ruleBookInfo',
id: 'downloadUrls',
type: 'String',
hint: '文件类书源下载地址 (规则结果为String类型的url, 多个链接返回数组)',
},
],
},
directory: {
name: '目录',
children: [
{
title: '更新前JS',
namespace: 'ruleToc',
id: 'preUpdateJs',
type: 'String',
hint: '更新目录前调用JS 动态更新目录链接',
},
{
title: '列表规则',
namespace: 'ruleToc',
id: 'chapterList',
type: 'String',
hint: '选择目录列表的章节节点 (规则结果为List<Element>)',
},
{
title: '章节名称',
namespace: 'ruleToc',
id: 'chapterName',
type: 'String',
hint: '选择章节名称 (规则结果为String)',
},
{
title: '章节地址',
namespace: 'ruleToc',
id: 'chapterUrl',
type: 'String',
hint: '选择章节链接 (规则结果为String类型的Url)',
},
{
title: '标题处理',
namespace: 'ruleToc',
id: 'formatJs',
type: 'String',
hint: '遍历去重后的章节列表的回调,提供index(章节序号从1开始)、title(章节标题)变量,额外提供gInt(初始值0),返回值作为新的标题',
},
{
title: '卷名标识',
namespace: 'ruleToc',
id: 'isVolume',
type: 'String',
hint: '章节名称是否是卷名 (规则结果为Bool)',
},
{
title: '章节信息',
namespace: 'ruleToc',
id: 'updateTime',
type: 'String',
hint: '选择章节信息(如更新时间) (规则结果为String)',
},
{
title: '收费标识',
namespace: 'ruleToc',
id: 'isVip',
type: 'String',
hint: '章节是否为VIP章节 (规则结果为Bool)',
},
{
title: '购买标识',
namespace: 'ruleToc',
id: 'isPay',
type: 'String',
hint: '章节是否为已购买 (规则结果为Bool)',
},
{
title: '翻页规则',
namespace: 'ruleToc',
id: 'nextTocUrl',
type: 'String',
hint: '选择目录下一页链接 (规则结果为List<String>或String)',
},
],
},
content: {
name: '正文',
children: [
{
title: '正文规则',
namespace: 'ruleContent',
id: 'content',
type: 'String',
hint: '选择正文内容 (规则结果为String)',
},
{
title: '标题规则',
namespace: 'ruleContent',
id: 'title',
type: 'String',
hint: '获取结果将会覆盖章节标题 (规则结果为String)',
},
{
title: '翻页规则',
namespace: 'ruleContent',
id: 'nextContentUrl',
type: 'String',
hint: '选择下一分页(不是下一章)链接 (规则结果为String类型的Url)',
},
{
title: '脚本注入',
namespace: 'ruleContent',
id: 'webJs',
type: 'String',
hint: '注入javascript,用于模拟鼠标点击等,必须有返回值,一般为String类型',
},
{
title: '资源正则',
namespace: 'ruleContent',
id: 'sourceRegex',
type: 'String',
hint: '匹配资源的url特征,用于嗅探',
},
{
title: '替换规则',
namespace: 'ruleContent',
id: 'replaceRegex',
type: 'String',
hint: '多页内容合并后替换,用于正文净化',
},
{
title: '图片样式',
namespace: 'ruleContent',
id: 'imageStyle',
type: 'String',
hint: 'FULL:铺满 不填:默认样式',
},
{
title: '图片解密',
namespace: 'ruleContent',
id: 'imageDecode',
type: 'String',
hint: '填写JavaScript 返回解密图片的bytes ',
},
{
title: '购买操作',
namespace: 'ruleContent',
id: 'payAction',
type: 'String',
hint: '填写JavaScript 返回购买链接或者调用购买接口',
},
],
},
/*
review: {
name: "段评",
children: [
{
title: "段评URL",
namespace: "ruleReview",
id: "reviewUrl",
type: "String",
hint: "段评URL",
},
{
title: "发布头像",
namespace: "ruleReview",
id: "avatarRule",
type: "String",
hint: "段评发布者头像",
},
{
title: "段评内容",
namespace: "ruleReview",
id: "contentRule",
type: "String",
hint: "段评内容",
},
{
title: "发布时间",
namespace: "ruleReview",
id: "postTimeRule",
type: "String",
hint: "段评发布时间",
},
{
title: "回复URL",
namespace: "ruleReview",
id: "reviewQuoteUrl",
type: "String",
hint: "获取段评回复URL",
},
{
title: "点赞URL",
namespace: "ruleReview",
id: "voteUpUrl",
type: "String",
hint: "点赞URL",
},
{
title: "点踩URL",
namespace: "ruleReview",
id: "voteDownUrl",
type: "String",
hint: "点踩URL",
},
{
title: "发送回复",
namespace: "ruleReview",
id: "postReviewUrl",
type: "String",
hint: "发送回复URL",
},
{
title: "回复段评",
namespace: "ruleReview",
id: "postQuoteUrl",
type: "String",
hint: "发送回复段评URL",
},
{
title: "删除段评",
namespace: "ruleReview",
id: "deleteUrl",
type: "String",
hint: "删除段评URL",
},
],
},*/
other: {
name: '其他',
children: [
{
title: '启用搜索',
id: 'enabled',
type: 'Boolean',
},
{
title: '启用发现',
id: 'enabledExplore',
type: 'Boolean',
},
// {
// title: "启用段评",
// id: "enabledReview",
// type: "Boolean",
// },
{
title: 'CookieJar',
id: 'enabledCookieJar',
type: 'Boolean',
},
{
title: '搜索权重',
id: 'weight',
type: 'Number',
},
{
title: '排序编号',
id: 'customOrder',
type: 'Number',
},
],
},
}
+222
View File
@@ -0,0 +1,222 @@
export default {
base: {
name: '基础',
children: [
{
title: '源域名',
id: 'sourceUrl',
type: 'String',
hint: '通常填写网站主页,例: https://www.qidian.com',
required: true,
},
{
title: '图标',
id: 'sourceIcon',
type: 'String',
hint: '填写图片网络链接',
},
{
title: '源名称',
id: 'sourceName',
type: 'String',
hint: '会显示在源列表',
required: true,
},
{
title: '源分组',
id: 'sourceGroup',
type: 'String',
hint: '描述源的特征信息',
},
{
title: '源注释',
id: 'sourceComment',
type: 'String',
hint: '描述源作者和状态',
},
{
title: '分类地址',
id: 'sortUrl',
type: 'String',
hint: '名称1::链接1\n名称2::链接2',
},
{
title: '登录地址',
id: 'loginUrl',
type: 'String',
hint: '填写网站登录网址,仅在需要登录的源有用',
},
{
title: '登录界面',
id: 'loginUi',
type: 'String',
hint: '自定义登录界面',
},
{
title: '登录检测',
id: 'loginCheckJs',
type: 'String',
hint: '登录检测js',
},
{
title: '封面解密',
id: 'coverDecodeJs',
type: 'String',
hint: '封面解密js',
},
{
title: '请求头',
id: 'header',
type: 'String',
hint: '客户端标识',
},
{
title: '变量说明',
id: 'variableComment',
type: 'String',
hint: '源变量说明',
},
{
title: '并发率',
id: 'concurrentRate',
type: 'String',
hint: '并发率',
},
{
title: 'js库',
id: 'jsLib',
type: 'String',
hint: 'js库, 可填写js或者key-value object获取在线js文件',
},
],
},
list: {
name: '列表',
children: [
{
title: '列表规则',
id: 'ruleArticles',
type: 'String',
hint: '规则结果为List<Element>',
},
{
title: '翻页规则',
id: 'ruleNextPage',
type: 'String',
hint: '下一页链接 规则结果为List<String>或String',
},
{
title: '标题规则',
id: 'ruleTitle',
type: 'String',
hint: '文章标题 规则结果为String',
},
{
title: '时间规则',
id: 'rulePubDate',
type: 'String',
hint: '文章发布时间 规则结果为String',
},
{
title: '描述规则',
id: 'ruleDescription',
type: 'String',
hint: '文章简要描述 规则结果为String',
},
{
title: '图片规则',
id: 'ruleImage',
type: 'String',
hint: '文章图片链接 规则结果为String',
},
{
title: '链接规则',
id: 'ruleLink',
type: 'String',
hint: '文章链接 规则结果为String',
},
],
},
webView: {
name: 'WebView',
children: [
{
title: '内容规则',
id: 'ruleContent',
type: 'String',
hint: '文章正文',
},
{
title: '样式规则',
id: 'style',
type: 'String',
hint: '文章正文样式 填写css',
},
{
title: '注入规则',
id: 'injectJs',
type: 'String',
hint: '注入网页的JavaScript',
},
{
title: '黑名单',
id: 'contentBlacklist',
type: 'String',
hint: 'webView链接加载黑名单,英文逗号隔开',
},
{
title: '白名单',
id: 'contentWhitelist',
type: 'String',
hint: 'webView链接加载白名单,英文逗号隔开',
},
{
title: '链接拦截',
id: 'shouldOverrideUrlLoading',
type: 'String',
hint: '填写js,变量url为当前资源链接,返回true拦截',
},
],
},
other: {
name: '其他',
children: [
{
title: '列表样式',
id: 'articleStyle',
type: 'Array',
array: ['默认', '大图', '双列'],
},
{
title: '加载地址',
id: 'loadWithBaseUrl',
type: 'Boolean',
},
{
title: '启用JS',
id: 'enableJs',
type: 'Boolean',
},
{
title: '启用',
id: 'enabled',
type: 'Boolean',
},
{
title: 'Cookie',
id: 'enabledCookieJar',
type: 'Boolean',
},
{
title: '单URL',
id: 'singleUrl',
type: 'Boolean',
},
{
title: '排序编号',
id: 'customOrder',
type: 'Number',
},
],
},
}
+18
View File
@@ -0,0 +1,18 @@
import type { Source } from '@/source'
import bookSourceEditConfig from './bookSourceEditConfig'
import rssSourceEditConfig from './rssSourceEditConfig'
type SourceConfigKey =
| keyof typeof bookSourceEditConfig
| keyof typeof rssSourceEditConfig
type SourceConfigRecord = {
title: string
type: string //"array" | "String" | "Boolean"
array?: string[]
hint?: string
required?: boolean
namespace?: Partial<keyof Source>
id: Partial<keyof Source>
}
type SourceConfigValue = { name: string; children: SourceConfigRecord[] }
export type SourceConfig = Partial<Record<SourceConfigKey, SourceConfigValue>>
+65
View File
@@ -0,0 +1,65 @@
import body_0 from '../assets/imgs/themes/body_0.png'
import content_0 from '../assets/imgs/themes/content_0.png'
import popup_0 from '../assets/imgs/themes/popup_0.png'
import body_1 from '../assets/imgs/themes/body_1.png'
import content_1 from '../assets/imgs/themes/content_1.png'
import popup_1 from '../assets/imgs/themes/popup_1.png'
import body_2 from '../assets/imgs/themes/body_2.png'
import content_2 from '../assets/imgs/themes/content_2.png'
import popup_2 from '../assets/imgs/themes/popup_2.png'
import body_3 from '../assets/imgs/themes/body_3.png'
import content_3 from '../assets/imgs/themes/content_3.png'
import popup_3 from '../assets/imgs/themes/popup_3.png'
import body_5 from '../assets/imgs/themes/body_5.png'
import content_5 from '../assets/imgs/themes/content_5.png'
import popup_5 from '../assets/imgs/themes/popup_5.png'
import body_6 from '../assets/imgs/themes/body_6.png'
import content_6 from '../assets/imgs/themes/content_6.png'
import popup_6 from '../assets/imgs/themes/popup_6.png'
const settings = {
themes: [
{
body: '#ede7da url(' + body_0 + ') repeat',
content: '#ede7da url(' + content_0 + ') repeat',
popup: '#ede7da url(' + popup_0 + ') repeat',
},
{
body: '#ede7da url(' + body_1 + ') repeat',
content: '#ede7da url(' + content_1 + ') repeat',
popup: '#ede7da url(' + popup_1 + ') repeat',
},
{
body: '#ede7da url(' + body_2 + ') repeat',
content: '#ede7da url(' + content_2 + ') repeat',
popup: '#ede7da url(' + popup_2 + ') repeat',
},
{
body: '#ede7da url(' + body_3 + ') repeat',
content: '#ede7da url(' + content_3 + ') repeat',
popup: '#ede7da url(' + popup_3 + ') repeat',
},
{
body: '#ebcece repeat',
content: '#f5e4e4 repeat',
popup: '#faeceb repeat',
},
{
body: '#ede7da url(' + body_5 + ') repeat',
content: '#ede7da url(' + content_5 + ') repeat',
popup: '#ede7da url(' + popup_5 + ') repeat',
},
{
body: '#ede7da url(' + body_6 + ') repeat',
content: '#ede7da url(' + content_6 + ') repeat',
popup: '#ede7da url(' + popup_6 + ') repeat',
},
],
fonts: [
'Microsoft YaHei, PingFangSC-Regular, HelveticaNeue-Light, Helvetica Neue Light, sans-serif',
'PingFangSC-Regular, -apple-system, Simsun',
'Kaiti',
],
}
export default settings
+8
View File
@@ -0,0 +1,8 @@
.el-loading-spinner {
font-size: 36px;
color: #b5b5b5;
}
.el-loading-text {
font-weight: 500;
color: #b5b5b5 !important;
}
+40
View File
@@ -0,0 +1,40 @@
import { watch, unref, onUnmounted } from 'vue'
import { ElLoading } from 'element-plus'
import loadingSvg from '@element-plus/icons-svg/loading.svg?raw'
import 'element-plus/theme-chalk/el-loading.css'
import './loading.css'
export const useLoading = (
target: MaybeRef<string | HTMLElement | undefined>,
text: string,
spinner = loadingSvg,
) => {
// loading spinner
const isLoading = ref(false)
let loadingInstance: ReturnType<typeof ElLoading.service> | null = null
const closeLoading = () => (isLoading.value = false)
const showLoading = () => (isLoading.value = true)
watch(isLoading, loading => {
if (!loading) return loadingInstance?.close()
loadingInstance = ElLoading.service({
target: unref(target),
spinner: spinner,
text: text,
lock: true,
background: 'rgba(0, 0, 0, 0)',
})
})
const loadingWrapper = (promise: Promise<unknown>) => {
if (!(promise instanceof Promise))
throw TypeError('loadingWrapper argument must be Promise')
showLoading()
return promise.finally(closeLoading)
}
onUnmounted(() => {
closeLoading()
})
return { isLoading, showLoading, closeLoading, loadingWrapper }
}
+22
View File
@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from '@/router'
import store from '@/store'
import 'element-plus/theme-chalk/dark/css-vars.css'
createApp(App).use(store).use(router).mount('#app')
// 书架 同步Element PLUS 夜间模式
watch(
() => useBookStore().isNight,
isNight => {
if (isNight) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
},
)
window.addEventListener('vite:preloadError', event => {
event.preventDefault()
})
+33
View File
@@ -0,0 +1,33 @@
# 「阅读3.0」 web 端(已打包进阅读3.0,不能设置IP)
本程序为「阅读3.0」的配套 web 端,需要保证手机和电脑在同一局域网内,然后手机端打开 web 服务。
~~在线地址 http://alanskycn.gitee.io/vip/reader/~~
## 具体实现
使用 Vue3 开发
## 功能特性
- 本地存储阅读记录与设置
- 阅读主题切换
- 夜间模式
- 字号调节
- 字体调节
- 阅读宽度调节
## 使用方法
```shell
pnpm install
#安装项目
pnpm dev
#开发模式
pnpm build
#打包
pnpm lint:fix
#格式化代码
```
- 调试的时候可以修改.env.development里面的地址连接手机端调试
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh" class="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import App from '@/App.vue'
import bookRouter from '@/router/bookRouter'
import store from '@/store'
import 'element-plus/theme-chalk/dark/css-vars.css'
createApp(App).use(store).use(bookRouter).mount('#app')
// 同步Element PLUS 夜间模式
watch(
() => useBookStore().isNight,
isNight => {
if (isNight) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
},
)
+34
View File
@@ -0,0 +1,34 @@
# legado_web_editor
## 🚧开发注意
如果你想要调试项目 请修改文件`.env.development``VITE_API`为阅读web服务ip
## 路由
/rssSource 订阅源编辑
/rssSource 书源编辑
## 🎨Project setup
```
pnpm i
```
### Compiles and hot-reloads for development
```
pnpm dev
```
### Compiles and minifies for production
```
pnpm build
```
### Lints and fixes files
```
pnpm lint:fix
```
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh" class="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import App from '@/App.vue'
import sourceRouter from '@/router/sourceRouter'
import store from '@/store'
import 'element-plus/theme-chalk/dark/css-vars.css'
createApp(App).use(store).use(sourceRouter).mount('#app')
+18
View File
@@ -0,0 +1,18 @@
export {}
export type Options = {
duration?: number | [(distance: number) => number]
offset?: number
callback?: () => void // "undefined" is a suitable default, and won't be called
easing?: (
timeElapsed: number,
start: number,
distance: number,
duration: number,
) => number
a11y?: boolean
container?: HTMLElement | string
}
export default function (
target: number | string | HTMLElement,
options: Options = {},
): void
+186
View File
@@ -0,0 +1,186 @@
const easeInOutQuad = (t, b, c, d) => {
t /= d / 2
if (t < 1) return (c / 2) * t * t + b
t--
return (-c / 2) * (t * (t - 2) - 1) + b
}
const jumper = () => {
// private variable cache
// no variables are created during a jump, preventing memory leaks
let container // container element to be scrolled (node)
let element // element to scroll to (node)
let start // where scroll starts (px)
let stop // where scroll stops (px)
let offset // adjustment from the stop position (px)
let easing // easing function (function)
let a11y // accessibility support flag (boolean)
let distance // distance of scroll (px)
let duration // scroll duration (ms)
let timeStart // time scroll started (ms)
let timeElapsed // time spent scrolling thus far (ms)
let next // next scroll position (px)
let callback // to call when done scrolling (function)
// scroll position helper
function location() {
let top = container.scrollTop || container.scrollY || container.pageYOffset
top = typeof top === 'undefined' ? 0 : top
return top
}
// element offset helper
function top(element) {
const elementTop = element.getBoundingClientRect().top
const containerTop = container.getBoundingClientRect
? container.getBoundingClientRect().top
: 0
return elementTop - containerTop + start
}
// scrollTo helper
function scrollTo(top) {
container.scrollTo
? container.scrollTo(0, top) // window
: (container.scrollTop = top) // custom container
}
// rAF loop helper
function loop(timeCurrent) {
// store time scroll started, if not started already
if (!timeStart) {
timeStart = timeCurrent
}
// determine time spent scrolling so far
timeElapsed = timeCurrent - timeStart
// calculate next scroll position
next = easing(timeElapsed, start, distance, duration)
// scroll to it
scrollTo(next)
// check progress
timeElapsed < duration
? requestAnimationFrame(loop) // continue scroll loop
: done() // scrolling is done
}
// scroll finished helper
function done() {
// account for rAF time rounding inaccuracies
scrollTo(start + distance)
// if scrolling to an element, and accessibility is enabled
if (element && a11y) {
// add tabindex indicating programmatic focus
element.setAttribute('tabindex', '-1')
// focus the element
element.focus()
}
// if it exists, fire the callback
if (typeof callback === 'function') {
callback()
}
// reset time for next jump
timeStart = false
}
// API
function jump(target, options = {}) {
// resolve options, or use defaults
duration = options.duration || 1000
offset = options.offset || 0
callback = options.callback // "undefined" is a suitable default, and won't be called
easing = options.easing || easeInOutQuad
a11y = options.a11y || false
// resolve container
switch (typeof options.container) {
case 'object':
// we assume container is an HTML element (Node)
container = options.container
break
case 'string':
container = document.querySelector(options.container)
break
default:
container = window
}
// cache starting position
start = location()
// resolve target
switch (typeof target) {
// scroll from current position
case 'number':
element = undefined // no element to scroll to
a11y = false // make sure accessibility is off
stop = start + target
break
// scroll to element (node)
// bounding rect is relative to the viewport
case 'object':
element = target
stop = top(element)
break
// scroll to element (selector)
// bounding rect is relative to the viewport
case 'string':
element = document.querySelector(target)
stop = top(element)
break
}
// resolve scroll distance, accounting for offset
distance = stop - start + offset
// resolve duration
switch (typeof options.duration) {
// number in ms
case 'number':
duration = options.duration
break
// function passed the distance of the scroll
case 'function':
duration = options.duration(distance)
break
}
// start the loop
requestAnimationFrame(loop)
}
// expose only the jump method
return jump
}
// export singleton
const singleton = jumper()
export default singleton
+22
View File
@@ -0,0 +1,22 @@
import { createWebHashHistory, createRouter } from 'vue-router'
export const bookRoutes = [
{
path: '/',
name: 'shelf',
component: () => import('../views/BookShelf.vue'),
},
{
path: '/chapter',
name: 'chapter',
component: () => import('../views/BookChapter.vue'),
},
]
const router = createRouter({
// mode: "history",
history: createWebHashHistory(),
routes: bookRoutes,
})
export default router
+15
View File
@@ -0,0 +1,15 @@
import { createWebHashHistory, createRouter } from 'vue-router'
import { bookRoutes } from './bookRouter'
import { sourceRoutes } from './sourceRouter'
const router = createRouter({
// history: createWebHistory(process.env.BASE_URL),
history: createWebHashHistory(),
routes: [bookRoutes, sourceRoutes].flat(),
})
router.afterEach(to => {
if (to.name == 'shelf') document.title = '书架'
})
export default router
+23
View File
@@ -0,0 +1,23 @@
import sourceEditor from '../views/SourceEditor.vue'
import { createWebHashHistory, createRouter } from 'vue-router'
export const sourceRoutes = [
{
path: '/bookSource',
name: 'book-home',
component: sourceEditor,
},
{
path: '/rssSource',
name: 'rss-home',
component: sourceEditor,
},
]
const router = createRouter({
// history: createWebHistory(process.env.BASE_URL),
history: createWebHashHistory(),
routes: sourceRoutes,
})
export default router
Vendored Executable
+165
View File
@@ -0,0 +1,165 @@
/** https://github.com/gedoor/legado/tree/master/app/src/main/java/io/legado/app/data/entities */
type BaseSource = {
/**
* 并发率
*/
concurrentRate?: string
/**
* 登录地址
*/
loginUrl?: string
/**
* 登录UI
*/
loginUi?: string
/**
* 请求头
*/
header?: string
/**
* 启用cookieJar
*/
enabledCookieJar?: boolean
/**
* js库
*/
jsLib?: string
}
type BookSoure = BaseSource & {
// 地址,包括 http/https
bookSourceUrl: string
// 名称
bookSourceName: string
// 分组
bookSourceGroup?: string
// 类型,0 文本,1 音频, 2 图片, 3 文件(指的是类似知轩藏书只提供下载的网站)
bookSourceType: number
// 详情页url正则
bookUrlPattern?: string
// 手动排序编号
customOrder: number
// 是否启用
enabled: boolean
// 启用发现
enabledExplore: boolean
// 登录检测js
loginCheckJs?: string
// 封面解密js
coverDecodeJs?: string
// 注释
bookSourceComment?: string
// 自定义变量说明
variableComment?: string
// 最后更新时间,用于排序
lastUpdateTime: number
// 响应时间,用于排序
respondTime: number
// 智能排序的权重
weight: number
// 发现url
exploreUrl?: string
// 发现筛选规则
exploreScreen?: string
// 发现规则
ruleExplore?: ExploreRule
// 搜索url
searchUrl?: string
// 搜索规则
ruleSearch?: SearchRule
// 书籍信息页规则
ruleBookInfo?: BookInfoRule
// 目录页规则
ruleToc?: TocRule
// 正文页规则
ruleContent?: ContentRule
// 段评规则
ruleReview?: ReviewRule
}
type RuleSearch = {
checkKeyWord?: string
[prop: string]: string
}
/* type ExploreRule = {
[prop:string]: string
}
type BookInfoRule = {
[prop:string]: string
}
type TocRule = {
[prop:string]: string
}
type ContentRule = {
[prop:string]: string
}
type ReviewRule = {
[prop:string]: string
} */
type RssSource = BaseSource & {
sourceUrl: string
// 名称
sourceName: string
// 图标
sourceIcon: string
// 分组
sourceGroup?: string
// 注释
sourceComment?: string
// 是否启用
enabled: boolean
// 自定义变量说明
variableComment?: string
/**登录检测js**/
loginCheckJs?: string
/**封面解密js**/
coverDecodeJs?: string
/**分类Url**/
sortUrl?: string
/**是否单url源**/
singleUrl: boolean
/*列表规则*/
/**列表样式,0,1,2**/
articleStyle: number
/**列表规则**/
ruleArticles?: string
/**下一页规则**/
ruleNextPage?: string
/**标题规则**/
ruleTitle?: string
/**发布日期规则**/
rulePubDate?: string
/*webView规则*/
/**描述规则**/
ruleDescription?: string
/**图片规则**/
ruleImage?: string
/**链接规则**/
ruleLink?: string
/**正文规则**/
ruleContent?: string
/**正文url白名单**/
contentWhitelist?: string
/**正文url黑名单**/
contentBlacklist?: string
/**
* 跳转url拦截,
* js, 返回true拦截,js变量url,可以通过js打开url,比如调用阅读搜索,添加书架等,简化规则写法,不用webView js注入
* **/
shouldOverrideUrlLoading?: string
/**webView样式**/
style?: string
enableJs: boolean
loadWithBaseUrl: boolean
/**注入js**/
injectJs?: string
/*其它规则*/
/**最后更新时间,用于排序**/
lastUpdateTime: number
customOrder: number
}
type Source = BookSoure | RssSource
export { Source, BookSoure, RssSource }
+210
View File
@@ -0,0 +1,210 @@
import { defineStore } from 'pinia'
import API from '@api'
import type {
BaseBook,
Book,
BookChapter,
BookProgress,
SeachBook,
} from '@/book'
import type { webReadConfig } from '@/web'
import { ElMessage } from 'element-plus/es'
const default_config: webReadConfig = {
theme: 0,
font: 0,
fontSize: 18,
readWidth: 800,
infiniteLoading: false,
customFontName: '',
jumpDuration: 1000,
spacing: {
paragraph: 1,
line: 0.8,
letter: 0,
},
}
let webReadConfigLoadedDate: Date | undefined
export const useBookStore = defineStore('book', {
state: () => {
return {
searchBooks: [] as SeachBook[],
shelf: [] as Book[],
catalog: [] as BookChapter[],
readingBook: { chapterPos: 0, chapterIndex: 0 } as BaseBook & {
chapterPos: number
chapterIndex: number
isSeachBook?: boolean
},
popCataVisible: false,
contentLoading: true,
showContent: false,
config: default_config,
miniInterface: false,
readSettingsVisible: false,
}
},
getters: {
bookProgress: (state): BookProgress | undefined => {
if (state.catalog.length == 0) return
const { chapterIndex, chapterPos, name, author } = state.readingBook
const title = state.catalog[chapterIndex]?.title
if (!title) return
return {
name,
author,
durChapterIndex: chapterIndex,
durChapterPos: chapterPos,
durChapterTime: new Date().getTime(),
durChapterTitle: title,
}
},
theme: state => {
return state.config.theme
},
isNight: state => state.config.theme == 6,
},
actions: {
/** 从后端加载书架书籍,优先返回内存缓存 */
async loadBookShelf(): Promise<Book[]> {
const fetchBookshellf_promise = API.getBookShelf().then(resp => {
console.log('API.getBookShelf数据返回')
const { isSuccess, data, errorMsg } = resp.data
if (isSuccess === true) {
if (
this.shelf.length !== data.length &&
this.shelf.length > 0 &&
data.length > 0
) {
ElMessage.info(`书架数据已更新`)
}
this.shelf = data.sort((a, b) => {
const x = a['durChapterTime'] || 0
const y = b['durChapterTime'] || 0
return y - x
})
} else {
if (errorMsg.includes('还没有添加小说') && this.shelf.length > 0) {
ElMessage.info('当前书架上的书籍已经被删除')
return (this.shelf = [])
}
ElMessage.error(errorMsg ?? '后端返回格式错误!')
}
console.log('书架数据已更新')
return this.shelf
})
if (this.shelf.length > 0) {
// bookshelf data fetched before:do not await
console.log('返回缓存书架数据')
return this.shelf
} else {
console.log('从阅读后端获取书架数据...')
return await fetchBookshellf_promise
}
},
/** 从后端加载书籍目录,优先返回内存缓存 */
async loadWebCatalog(
book: typeof this.readingBook,
): Promise<BookChapter[]> {
const { bookUrl, name, chapterIndex } = book
const fetchChapterList_promise = API.getChapterList(
bookUrl as string,
).then(res => {
const { isSuccess, data, errorMsg } = res.data
if (isSuccess === false) {
ElMessage.error(errorMsg)
throw new Error()
}
if (
bookUrl === this.readingBook.bookUrl &&
data.length !== this.catalog.length &&
data.length > 0 &&
this.catalog.length > 0
) {
ElMessage.info(`书籍${name}: 章节目录已更新`)
}
this.catalog = data
console.log(`书籍${name}: 章节目录已更新`)
return this.catalog
})
if (
bookUrl === this.readingBook.bookUrl &&
this.catalog.length > 0 &&
this.catalog.length - 1 >= chapterIndex
) {
console.log(`返回书籍《${name}》 缓存的章节目录`)
return this.catalog
} else {
console.log(`从阅读后端获取书籍《${name}》 章节目录数据...`)
return await fetchChapterList_promise
}
},
setPopCataVisible(visible: boolean) {
this.popCataVisible = visible
},
setContentLoading(loading: boolean) {
this.contentLoading = loading
},
setReadingBook(readingBook: typeof this.readingBook) {
this.readingBook = readingBook
},
/** 只从从后端加载一次web阅读配置 */
async loadWebConfig() {
if (webReadConfigLoadedDate === undefined) {
const _config = await API.getReadConfig()
webReadConfigLoadedDate = new Date()
console.log(
`${this.$id}.loadWebConfig: ${webReadConfigLoadedDate.toLocaleString()}成功加载阅读配置`,
)
return this.setConfig(_config)
}
console.log(
`${this.$id}.loadWebConfig: 已于${webReadConfigLoadedDate.toLocaleString()}成功加载`,
)
},
setConfig(config?: webReadConfig) {
this.config = Object.assign({}, this.config, config)
},
setReadSettingsVisible(visible: boolean) {
this.readSettingsVisible = visible
},
setShowContent(visible: boolean) {
this.showContent = visible
},
setMiniInterface(mini: boolean) {
this.miniInterface = mini
},
async setSearchBooks(books: SeachBook[]) {
books.forEach(book => {
const isSeachBook = this.shelf.every(
item => item.bookUrl !== book.bookUrl,
)
if (isSeachBook === true) {
this.searchBooks.push(book)
}
})
},
clearSearchBooks() {
this.searchBooks = []
},
/** 1.保存进度到app 2.修改内存中的数据*/
async saveBookProgress() {
if (!this.bookProgress) return Promise.resolve()
const { bookUrl } = this.readingBook
const shelfRaw = toRaw(this.shelf)
const findIndex = shelfRaw.findIndex(book => book.bookUrl === bookUrl)
if (findIndex > -1) {
this.shelf[findIndex] = Object.assign(
{},
shelfRaw[findIndex],
this.bookProgress,
)
}
// 直接关闭浏览器时 http请求可能被取消
// return API.saveBookProgress(this.bookProgress)
return API.saveBookProgressWithBeacon(this.bookProgress)
},
},
})
+24
View File
@@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
export const useConnectionStore = defineStore('connection', {
state: () => {
return {
connectStatus: '正在连接后端服务器……',
connectType: 'primary' as 'primary' | 'success' | 'danger',
newConnect: false,
}
},
actions: {
setConnectStatus(connectStatus: string) {
if (this.newConnect === true) return
this.connectStatus = connectStatus
},
setConnectType(connectType: 'primary' | 'success' | 'danger') {
if (this.newConnect === true) return
this.connectType = connectType
},
setNewConnect(newConnect: boolean) {
this.newConnect = newConnect
},
},
})
+6
View File
@@ -0,0 +1,6 @@
import { createPinia } from 'pinia'
export * from './bookStore'
export * from './sourceStore'
export * from './connectionStore'
export default createPinia()
+134
View File
@@ -0,0 +1,134 @@
import { defineStore } from 'pinia'
import {
emptyBookSource,
emptyRssSource,
getSourceUniqueKey,
convertSourcesToMap,
} from '@utils/souce'
import type { BookSoure, RssSource, Source } from '@/source'
const isBookSource = /bookSource/i.test(location.href)
const emptySource = isBookSource ? emptyBookSource : emptyRssSource
export const useSourceStore = defineStore('source', {
state: () => {
return {
bookSources: shallowRef([] as BookSoure[]), // 临时存放所有书源,
rssSources: shallowRef([] as RssSource[]), // 临时存放所有订阅源
savedSources: [] as Source[], // 批量保存到阅读app成功的源
currentSource: JSON.parse(JSON.stringify(emptySource)) as Source, // 当前编辑的源
currentTab: localStorage.getItem('tabName') || 'editTab',
editTabSource: {} as Source, // 生成序列化的json数据
isDebuging: false,
}
},
getters: {
sources: (state): Source[] =>
isBookSource ? state.bookSources : state.rssSources,
sourcesMap: function (): Map<string, Source> {
return convertSourcesToMap(this.sources)
},
savedSourcesMap: (state): Map<string, Source> =>
convertSourcesToMap(state.savedSources),
currentSourceUrl: state =>
isBookSource
? (state.currentSource as BookSoure).bookSourceUrl
: (state.currentSource as RssSource).sourceUrl,
searchKey: (state): string =>
isBookSource
? (state.currentSource as BookSoure)?.ruleSearch?.checkKeyWord || '我的'
: '',
},
actions: {
startDebug() {
this.currentTab = 'editDebug'
this.isDebuging = true
},
debugFinish() {
this.isDebuging = false
},
//拉取源后保存
saveSources(data: Source[]) {
if (isBookSource) {
this.bookSources = markRaw(data) as BookSoure[]
} else {
this.rssSources = markRaw(data) as RssSource[]
}
},
//批量推送
setPushReturnSources(returnSoures: Source[]) {
this.savedSources = returnSoures
},
//删除源
deleteSources(data: Source[]) {
const sources: Source[] = isBookSource
? this.bookSources
: this.rssSources
data.forEach(source => {
const index = sources.indexOf(source)
if (index > -1) sources.splice(index, 1)
})
},
//保存当前编辑源
saveCurrentSource() {
const source = this.currentSource,
map = this.sourcesMap
map.set(getSourceUniqueKey(source), JSON.parse(JSON.stringify(source)))
this.saveSources(Array.from(map.values()))
},
// 更改当前编辑的源qq
changeCurrentSource(source: Source) {
this.currentSource = JSON.parse(JSON.stringify(source))
},
// update editTab tabName and editTab info
changeTabName(tabName: string) {
this.currentTab = tabName
localStorage.setItem('tabName', tabName)
},
changeEditTabSource(source: Source) {
this.editTabSource = JSON.parse(JSON.stringify(source))
},
editHistory(history: Source) {
let historyObj
if (localStorage.getItem('history')) {
historyObj = JSON.parse(localStorage.getItem('history')!)
historyObj.new.push(history)
if (historyObj.new.length > 50) {
historyObj.new.shift()
}
if (historyObj.old.length > 50) {
historyObj.old.shift()
}
localStorage.setItem('history', JSON.stringify(historyObj))
} else {
const arr = { new: [history], old: [] }
localStorage.setItem('history', JSON.stringify(arr))
}
},
editHistoryUndo() {
if (localStorage.getItem('history')) {
const historyObj = JSON.parse(localStorage.getItem('history')!)
historyObj.old.push(this.currentSource)
if (historyObj.new.length) {
this.currentSource = historyObj.new.pop()
}
localStorage.setItem('history', JSON.stringify(historyObj))
}
},
clearAllHistory() {
localStorage.setItem('history', JSON.stringify({ new: [], old: [] }))
},
clearEdit() {
this.editTabSource = {} as Source
this.currentSource = JSON.parse(JSON.stringify(emptySource)) //复制一份新对象
},
// clear all source
clearAllSource() {
this.bookSources = []
this.rssSources = []
this.savedSources = []
},
},
})
+75
View File
@@ -0,0 +1,75 @@
import type { BookSoure, RssSource, Source } from '../source'
import { isNullOrBlank } from './utils'
const isBookSource = (source: Source): source is BookSoure =>
'bookSourceName' in source
export const isInvaildSource: (source: Source) => boolean = source => {
if (isBookSource(source)) {
return (
!isNullOrBlank(source.bookSourceName) &&
!isNullOrBlank(source.bookSourceUrl) &&
!isNullOrBlank(source.bookSourceType)
)
}
return !isNullOrBlank(source.sourceName) && !isNullOrBlank(source.sourceUrl)
}
export const getSourceUniqueKey = (source: Source) =>
isBookSource(source) ? source.bookSourceUrl : source.sourceUrl
export const getSourceName = (source: Source) =>
isBookSource(source) ? source.bookSourceName : source.sourceName
export const isSourceMatches: (source: Source, searchKey: string) => boolean = (
source,
searchKey,
) => {
// TODO: 正则和普通字符串识别 识别 * . \ [ ] <= <! != = ?: () \d\w\s\...
if (isBookSource(source)) {
return (
(source.bookSourceName.includes(searchKey) ||
source.bookSourceUrl.includes(searchKey) ||
source.bookSourceGroup?.includes(searchKey) ||
source.bookSourceComment?.includes(searchKey)) ??
false
)
}
return (
(source.sourceName.includes(searchKey) ||
source.sourceUrl.includes(searchKey) ||
source.sourceGroup?.includes(searchKey) ||
source.sourceComment?.includes(searchKey)) ??
false
)
}
export const convertSourcesToMap = (sources: Source[]): Map<string, Source> => {
const map = new Map()
sources.forEach(source => map.set(getSourceUniqueKey(source), source))
return map
}
export const normalizeSource = (source: any) => {
for (const key in source) {
const value = source[key]
if (
value === '' ||
value === null ||
(typeof value === 'string' && !value.trim())
) {
delete source[key]
} else if (value instanceof Object) {
normalizeSource(value)
}
}
}
export const emptyBookSource = {
ruleSearch: {},
ruleBookInfo: {},
ruleToc: {},
ruleContent: {},
// ruleReview: {},
ruleExplore: {},
} as BookSoure
export const emptyRssSource = {} as RssSource
+55
View File
@@ -0,0 +1,55 @@
import { formatDate } from '@vueuse/shared'
export const isNullOrBlank = (string: string | null | undefined | number) =>
string == null ||
(string as string).length === 0 ||
/^\s+$/.test(string as string)
export const isLegadoUrl = (/** @type {string} */ url: string) =>
/,\s*\{/.test(url) ||
!(
url.startsWith('http') ||
url.startsWith('data:') ||
url.startsWith('blob:')
)
/**
* 验证输入的URL是否符合阅读后端地址规则
* @param allowedProtocols 允许的协议,默认`["https:", "http:"]`
*/
export const validatorHttpUrl = (
http_url: string | URL,
allowedProtocols: string[] = ['https:', 'http:'],
) => {
try {
const url = new URL(http_url)
const { protocol } = url
if (!allowedProtocols.includes(protocol))
throw new Error(
`Expected protocol ${allowedProtocols.join('/')}, but ${protocol}`,
)
return true
} catch {
return false
}
}
export const dateFormat = (/** @type {number} */ t: number) => {
const time = new Date().getTime()
const offset = Math.floor((time - t) / 1000)
let str = ''
if (offset <= 30) {
str = '刚刚'
} else if (offset < 60) {
str = offset + '秒前'
} else if (offset < 3600) {
str = Math.floor(offset / 60) + '分钟前'
} else if (offset < 86400) {
str = Math.floor(offset / 3600) + '小时前'
} else if (offset < 2592000) {
str = Math.floor(offset / 86400) + '天前'
} else {
str = formatDate(new Date(t), 'YYYY-MM-DD')
}
return str
}
+770
View File
@@ -0,0 +1,770 @@
<template>
<div
class="chapter-wrapper"
:style="bodyTheme"
:class="{ night: isNight, day: !isNight }"
@click="showToolBar = !showToolBar"
>
<div class="tool-bar" :style="leftBarTheme">
<div class="tools">
<el-popover
placement="right"
:width="popupWidth"
trigger="click"
:show-arrow="false"
v-model:visible="popCataVisible"
popper-class="pop-cata"
>
<PopCatalog @getContent="getContent" class="popup" />
<template #reference>
<div class="tool-icon" :class="{ 'no-point': false }">
<div class="iconfont">&#58905;</div>
<div class="icon-text">目录</div>
</div>
</template>
</el-popover>
<el-popover
placement="right"
:width="popupWidth"
trigger="click"
:show-arrow="false"
v-model:visible="readSettingsVisible"
popper-class="pop-setting"
>
<read-settings class="popup" />
<template #reference>
<div class="tool-icon" :class="{ 'no-point': noPoint }">
<div class="iconfont">&#58971;</div>
<div class="icon-text">设置</div>
</div>
</template>
</el-popover>
<div class="tool-icon" @click="toShelf">
<div class="iconfont">&#58892;</div>
<div class="icon-text">书架</div>
</div>
<div class="tool-icon" :class="{ 'no-point': noPoint }" @click="toTop">
<div class="iconfont">&#58914;</div>
<div class="icon-text">顶部</div>
</div>
<div
class="tool-icon"
:class="{ 'no-point': noPoint }"
@click="toBottom"
>
<div class="iconfont">&#58915;</div>
<div class="icon-text">底部</div>
</div>
</div>
</div>
<div class="read-bar" :style="rightBarTheme">
<div class="tools">
<div
class="tool-icon"
:class="{ 'no-point': noPoint }"
@click="toPreChapter"
>
<div class="iconfont">&#58920;</div>
<span v-if="miniInterface">上一章</span>
</div>
<div
class="tool-icon"
:class="{ 'no-point': noPoint }"
@click="toNextChapter"
>
<span v-if="miniInterface">下一章</span>
<div class="iconfont">&#58913;</div>
</div>
</div>
</div>
<div class="chapter-bar"></div>
<div class="chapter" ref="content" :style="chapterTheme">
<div class="content">
<div class="top-bar" ref="top"></div>
<div
v-for="data in chapterData"
:key="data.index"
:chapterIndex="data.index"
ref="chapter"
>
<chapter-content
ref="chapterRef"
:chapterIndex="data.index"
:contents="data.content"
:title="data.title"
:spacing="store.config.spacing"
:fontSize="fontSize"
:fontFamily="fontFamily"
@readedLengthChange="onReadedLengthChange"
v-if="showContent"
/>
</div>
<div class="loading" ref="loading"></div>
<div class="bottom-bar" ref="bottom"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import jump from '@/plugins/jump'
import settings from '@/config/themeConfig'
import API from '@api'
import { useLoading } from '@/hooks/loading'
import { useThrottleFn } from '@vueuse/shared'
import { isNullOrBlank } from '@/utils/utils'
const content = ref()
// loading spinner
const { isLoading, loadingWrapper } = useLoading(content, '正在获取信息')
const store = useBookStore()
const {
catalog,
popCataVisible,
readSettingsVisible,
miniInterface,
showContent,
bookProgress,
theme,
isNight,
} = storeToRefs(store)
const chapterPos = computed({
get: () => store.readingBook.chapterPos,
set: value => (store.readingBook.chapterPos = value),
})
const chapterIndex = computed({
get: () => store.readingBook.chapterIndex,
set: value => (store.readingBook.chapterIndex = value),
})
const isSeachBook = computed({
get: () => store.readingBook.isSeachBook,
set: value => (store.readingBook.isSeachBook = value),
})
// 当前阅读书籍readingBook持久化
watch(
() => store.readingBook,
book => {
// 保存localStorage
// localStorage.setItem(book.bookUrl, JSON.stringify(book));
// 最近阅读
localStorage.setItem('readingRecent', JSON.stringify(book))
//保存 sessionStorage
sessionStorage.setItem('chapterIndex', book.chapterIndex.toString())
sessionStorage.setItem('chapterPos', book.chapterPos.toString())
},
{ deep: 1 },
)
// 无限滚动
const infiniteLoading = computed(() => store.config.infiniteLoading)
let scrollObserver: IntersectionObserver | null
const loading = ref()
watchEffect(() => {
if (!infiniteLoading.value) {
scrollObserver?.disconnect()
} else {
scrollObserver?.observe(loading.value)
}
})
const loadMore = () => {
const index = chapterData.value.slice(-1)[0].index
if (catalog.value.length - 1 > index) {
getContent(index + 1, false)
store.saveBookProgress() // 保存的是上一章的进度,不是预载的本章进度
}
}
// IntersectionObserver回调 底部加载
const onReachBottom = (entries: IntersectionObserverEntry[]) => {
if (isLoading.value) return
for (const { isIntersecting } of entries) {
if (!isIntersecting) return
loadMore()
}
}
// 字体
const fontFamily = computed(() => {
if (store.config.font >= 0) {
return settings.fonts[store.config.font]
}
return store.config.customFontName
})
const fontSize = computed(() => {
return store.config.fontSize + 'px'
})
// 主题部分
const bodyColor = computed(() => settings.themes[theme.value].body)
const chapterColor = computed(() => settings.themes[theme.value].content)
const popupColor = computed(() => settings.themes[theme.value].popup)
const readWidth = computed(() => {
if (!miniInterface.value) {
return store.config.readWidth - 130 + 'px'
} else {
return window.innerWidth + 'px'
}
})
const popupWidth = computed(() => {
if (!miniInterface.value) {
return store.config.readWidth - 33
} else {
return window.innerWidth - 33
}
})
const bodyTheme = computed(() => {
return {
background: bodyColor.value,
}
})
const chapterTheme = computed(() => {
return {
background: chapterColor.value,
width: readWidth.value,
}
})
const showToolBar = ref(false)
const leftBarTheme = computed(() => {
return {
background: popupColor.value,
marginLeft: miniInterface.value
? 0
: -(store.config.readWidth / 2 + 68) + 'px',
display: miniInterface.value && !showToolBar.value ? 'none' : 'block',
}
})
const rightBarTheme = computed(() => {
return {
background: popupColor.value,
marginRight: miniInterface.value
? 0
: -(store.config.readWidth / 2 + 52) + 'px',
display: miniInterface.value && !showToolBar.value ? 'none' : 'block',
}
})
/**
* pc移动端判断 最大阅读宽度修正
* 阅读宽度最小为640px 加上工具栏 68px 52px 取较大值 为 776px
*/
const onResize = () => {
store.setMiniInterface(window.innerWidth < 776)
const width = store.config.readWidth /**包含padding */
checkPageWidth(width)
}
/** 判断阅读宽度是否超出页面或者低于默认值640 */
const checkPageWidth = (readWidth: number) => {
if (store.miniInterface) return
if (readWidth < 640) store.config.readWidth = 640
if (readWidth + 2 * 68 > window.innerWidth) store.config.readWidth -= 160
}
watch(
() => store.config.readWidth,
width => checkPageWidth(width),
)
// 顶部底部跳转
const top = ref()
const bottom = ref()
const toTop = () => {
jump(top.value)
}
const toBottom = () => {
jump(bottom.value)
}
// 书架路由切换
const router = useRouter()
const toShelf = () => {
router.push('/')
}
// 获取章节内容
const chapterData = ref<{ index: number; content: string[]; title: string }[]>(
[],
)
const noPoint = ref(true)
const getContent = (index: number, reloadChapter = true, chapterPos = 0) => {
if (reloadChapter) {
//展示进度条
store.setShowContent(false)
//强制滚回顶层
jump(top.value, { duration: 0 })
//从目录,按钮切换章节时保存进度 预加载时不保存
saveReadingBookProgressToBrowser(index, chapterPos)
chapterData.value = []
}
const bookUrl = store.readingBook.bookUrl
const { title, index: chapterIndex } = catalog.value[index]
loadingWrapper(
API.getBookContent(bookUrl, chapterIndex).then(
res => {
if (res.data.isSuccess) {
const data = res.data.data
const content = data.split(/\n+/)
chapterData.value.push({ index, content, title })
if (reloadChapter) toChapterPos(chapterPos)
} else {
ElMessage({ message: res.data.errorMsg, type: 'error' })
const content = [res.data.errorMsg]
chapterData.value.push({ index, content, title })
}
store.setContentLoading(true)
noPoint.value = false
store.setShowContent(true)
if (!res.data.isSuccess) {
throw res.data
}
},
err => {
const content = ['获取章节内容失败!']
chapterData.value.push({ index, content, title })
store.setShowContent(true)
throw err
},
),
)
}
// 章节进度跳转和计算
const chapter = ref()
const chapterRef = ref()
const toChapterPos = (pos: number) => {
nextTick(() => {
if (chapterRef.value.length === 1)
chapterRef.value[0].scrollToReadedLength(pos)
})
}
// 60秒保存一次进度
const saveBookProgressThrottle = useThrottleFn(
() => store.saveBookProgress(),
60000,
)
const onReadedLengthChange = (index: number, pos: number) => {
saveReadingBookProgressToBrowser(index, pos)
saveBookProgressThrottle()
}
// 文档标题
watchEffect(() => {
document.title = catalog.value[chapterIndex.value]?.title || document.title
})
// 阅读记录保存浏览器
const saveReadingBookProgressToBrowser = (index: number, pos: number) => {
// 保存pinia
chapterIndex.value = index
chapterPos.value = pos
}
// 进度同步
// 返回导航变化 同步请求会在获取书架前完成
/**
* VisibilityChange https://developer.mozilla.org/zh-CN/docs/Web/API/Document/visibilitychange_event
* 监听关闭页面 切换tab 返回桌面 等操作
* 注意不用监听点击链接导航变化 不对Safari<14.5兼容处理
**/
const onVisibilityChange = () => {
const _bookProgress = bookProgress.value
if (document.visibilityState == 'hidden' && _bookProgress) {
store.saveBookProgress()
}
}
// 定时同步
// 章节切换
const toNextChapter = () => {
store.setContentLoading(true)
const index = chapterIndex.value + 1
if (typeof catalog.value[index] !== 'undefined') {
ElMessage({
message: '下一章',
type: 'info',
})
getContent(index)
store.saveBookProgress()
} else {
ElMessage({
message: '本章是最后一章',
type: 'error',
})
}
}
const toPreChapter = () => {
store.setContentLoading(true)
const index = chapterIndex.value - 1
if (typeof catalog.value[index] !== 'undefined') {
ElMessage({
message: '上一章',
type: 'info',
})
getContent(index)
store.saveBookProgress()
} else {
ElMessage({
message: '本章是第一章',
type: 'error',
})
}
}
let canJump = true
// 监听方向键
const handleKeyPress = (event: KeyboardEvent) => {
if (!canJump) return
switch (event.key) {
case 'ArrowLeft':
event.stopPropagation()
event.preventDefault()
toPreChapter()
break
case 'ArrowRight':
event.stopPropagation()
event.preventDefault()
toNextChapter()
break
case 'ArrowUp':
event.stopPropagation()
event.preventDefault()
if (document.documentElement.scrollTop === 0) {
ElMessage.warning('已到达页面顶部')
} else {
canJump = false
jump(0 - document.documentElement.clientHeight + 100, {
duration: store.config.jumpDuration,
callback: () => (canJump = true),
})
}
break
case 'ArrowDown':
event.stopPropagation()
event.preventDefault()
if (
document.documentElement.clientHeight +
document.documentElement.scrollTop ===
document.documentElement.scrollHeight
) {
ElMessage.warning('已到达页面底部')
} else {
canJump = false
jump(document.documentElement.clientHeight - 100, {
duration: store.config.jumpDuration,
callback: () => (canJump = true),
})
}
break
}
}
// 阻止默认滚动事件
const ignoreKeyPress = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
event.stopPropagation()
}
}
onMounted(async () => {
await store.loadWebConfig()
//获取书籍数据
const bookUrl = sessionStorage.getItem('bookUrl')
const name = sessionStorage.getItem('bookName')
const author = sessionStorage.getItem('bookAuthor')
const chapterIndex = Number(sessionStorage.getItem('chapterIndex') || 0)
const chapterPos = Number(sessionStorage.getItem('chapterPos') || 0)
const isSeachBook = sessionStorage.getItem('isSeachBook') === 'true'
if (isNullOrBlank(bookUrl) || isNullOrBlank(name) || author === null) {
ElMessage.warning('书籍信息为空,即将自动返回书架页面...')
return setTimeout(toShelf, 500)
}
const book: typeof store.readingBook = {
// @ts-expect-error: bookUrl name author is NON_Blank string here
bookUrl,
// @ts-expect-error: bookUrl name author is NON_Blank string here
name,
author,
chapterIndex,
chapterPos,
isSeachBook,
}
onResize()
window.addEventListener('resize', onResize)
loadingWrapper(
store.loadWebCatalog(book).then(chapters => {
store.setReadingBook(book)
getContent(chapterIndex, true, chapterPos)
window.addEventListener('keyup', handleKeyPress)
window.addEventListener('keydown', ignoreKeyPress)
// 兼容Safari < 14
document.addEventListener('visibilitychange', onVisibilityChange)
//监听底部加载
scrollObserver = new IntersectionObserver(onReachBottom, {
rootMargin: '-100% 0% 20% 0%',
})
if (infiniteLoading.value === true) scrollObserver.observe(loading.value)
//第二次点击同一本书 页面标题不会变化
document.title = '...'
document.title = (name as string) + ' | ' + chapters[chapterIndex].title
}),
)
})
onUnmounted(() => {
window.removeEventListener('keyup', handleKeyPress)
window.removeEventListener('keydown', ignoreKeyPress)
window.removeEventListener('resize', onResize)
// 兼容Safari < 14
document.removeEventListener('visibilitychange', onVisibilityChange)
readSettingsVisible.value = false
popCataVisible.value = false
scrollObserver?.disconnect()
scrollObserver = null
})
const addToBookShelfConfirm = async () => {
const book = store.readingBook
// 阅读的是搜索的书籍 并未在书架
if (book.isSeachBook === true) {
await ElMessageBox.confirm(`是否将《${book.name}》放入书架?`, '放入书架', {
confirmButtonText: '确认',
cancelButtonText: '否',
type: 'info',
/*
ElMessageBox.confirm默认在触发hashChange事件时自动关闭
按下物理返回键时触发hashChange事件
使用router.push("/")则不会触发hashChange事件
*/
closeOnHashChange: false,
})
.then(() => {
//选择是,无动作
isSeachBook.value = false
})
.catch(async () => {
//选择否,删除书籍
await API.deleteBook(book)
})
.finally(() => sessionStorage.removeItem('isSeachBook'))
}
}
onBeforeRouteLeave(async (to, from, next) => {
console.log('onBeforeRouteLeave')
// 弹窗时停止响应按键翻页
window.removeEventListener('keyup', handleKeyPress)
await addToBookShelfConfirm()
next()
})
</script>
<style lang="scss" scoped>
:deep(.pop-setting) {
margin-left: 68px;
top: 0;
}
:deep(.pop-cata) {
margin-left: 10px;
}
.chapter-wrapper {
padding: 0 4%;
overflow-x: hidden;
:deep(.no-point) {
pointer-events: none;
}
.tool-bar {
position: fixed;
top: 0;
left: 50%;
z-index: 100;
.tools {
display: flex;
flex-direction: column;
.tool-icon {
font-size: 18px;
width: 58px;
height: 48px;
text-align: center;
padding-top: 12px;
cursor: pointer;
outline: none;
.iconfont {
font-family: iconfont;
width: 16px;
height: 16px;
font-size: 16px;
margin: 0 auto 6px;
}
.icon-text {
font-size: 12px;
}
}
}
}
.read-bar {
position: fixed;
bottom: 0;
right: 50%;
z-index: 100;
.tools {
display: flex;
flex-direction: column;
.tool-icon {
font-size: 18px;
width: 42px;
height: 31px;
padding-top: 12px;
text-align: center;
align-items: center;
cursor: pointer;
outline: none;
margin-top: -1px;
.iconfont {
font-family: iconfont;
width: 16px;
height: 16px;
font-size: 16px;
margin: 0 auto 6px;
}
}
}
}
.chapter {
font-family: 'Microsoft YaHei', PingFangSC-Regular, HelveticaNeue-Light,
'Helvetica Neue Light', sans-serif;
text-align: left;
padding: 0 65px;
min-height: 100vh;
width: 670px;
margin: 0 auto;
.content {
font-size: 18px;
line-height: 1.8;
font-family: 'Microsoft YaHei', PingFangSC-Regular, HelveticaNeue-Light,
'Helvetica Neue Light', sans-serif;
.bottom-bar,
.top-bar {
height: 64px;
}
}
}
}
.day {
:deep(.popup) {
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.12),
0 0 6px rgba(0, 0, 0, 0.04);
}
:deep(.tool-icon) {
border: 1px solid rgba(0, 0, 0, 0.1);
margin-top: -1px;
color: #000;
.icon-text {
color: rgba(0, 0, 0, 0.4);
}
}
:deep(.chapter) {
border: 1px solid #d8d8d8;
color: #262626;
}
}
.night {
:deep(.popup) {
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.48),
0 0 6px rgba(0, 0, 0, 0.16);
}
:deep(.tool-icon) {
border: 1px solid #444;
margin-top: -1px;
color: #666;
.icon-text {
color: #666;
}
}
:deep(.chapter) {
border: 1px solid #444;
color: #666;
}
:deep(.popper__arrow) {
background: #666;
}
}
@media screen and (max-width: 776px) {
.chapter-wrapper {
padding: 0;
.tool-bar {
left: 0;
width: 100vw;
margin-left: 0 !important;
.tools {
flex-direction: row;
justify-content: space-between;
.tool-icon {
border: none;
}
}
}
.read-bar {
right: 0;
width: 100vw;
margin-right: 0 !important;
.tools {
flex-direction: row;
justify-content: space-between;
padding: 0 15px;
.tool-icon {
border: none;
width: auto;
.iconfont {
display: inline-block;
}
}
}
}
.chapter {
width: 100vw !important;
padding: 0 20px;
box-sizing: border-box;
}
}
}
</style>
+488
View File
@@ -0,0 +1,488 @@
<template>
<div :class="{ 'index-wrapper': true, night: isNight, day: !isNight }">
<div class="navigation-wrapper">
<div class="navigation-title-wrapper">
<div class="navigation-title">阅读</div>
<div class="navigation-sub-title">清风不识字何故乱翻书</div>
</div>
<div class="search-wrapper">
<el-input
placeholder="搜索书籍,在线书籍自动加入书架"
v-model="searchWord"
class="search-input"
:prefix-icon="SearchIcon"
@keyup.enter="searchBook"
>
</el-input>
</div>
<div class="bottom-wrapper">
<div class="recent-wrapper">
<div class="recent-title">最近阅读</div>
<div class="reading-recent">
<el-tag
:type="
readingRecent.name == '尚无阅读记录' ? 'warning' : 'primary'
"
class="recent-book"
size="large"
@click="
toDetail(
readingRecent.bookUrl,
readingRecent.name,
readingRecent.author,
readingRecent.chapterIndex,
readingRecent.chapterPos,
readingRecent.isSeachBook,
true,
)
"
:class="{ 'no-point': readingRecent.bookUrl == '' }"
>
{{ readingRecent.name }}
</el-tag>
</div>
</div>
<div class="setting-wrapper">
<div class="setting-title">基本设定</div>
<div class="setting-item">
<el-tag
:type="connectType"
size="large"
class="setting-connect"
:class="{ 'no-point': newConnect }"
@click="setLegadoRetmoteUrl"
>
{{ connectStatus }}
</el-tag>
</div>
</div>
</div>
<div class="bottom-icons">
<a
href="https://github.com/gedoor/legado_web_bookshelf"
target="_blank"
>
<div class="bottom-icon">
<img :src="githubUrl" alt="" />
</div>
</a>
</div>
</div>
<div class="shelf-wrapper" ref="shelfWrapper">
<book-items
:books="books"
@bookClick="handleBookClick"
:isSearch="isSearching"
></book-items>
</div>
</div>
</template>
<script setup lang="ts">
import '@/assets/bookshelf.css'
import '@/assets/fonts/shelffont.css'
import { useBookStore } from '@/store'
import githubUrl from '@/assets/imgs/github.png'
import { useLoading } from '@/hooks/loading'
import { Search as SearchIcon } from '@element-plus/icons-vue'
import { baseURL_localStorage_key } from '@/api/axios'
import API, {
legado_http_entry_point,
parseLeagdoHttpUrlWithDefault,
setApiEntryPoint,
} from '@api'
import { validatorHttpUrl } from '@/utils/utils'
import type { Book, SeachBook } from '@/book'
import type { webReadConfig } from '@/web'
const store = useBookStore()
const isNight = computed(() => store.isNight)
/** shortcuts of `store.setConfig` */
const applyReadConfig = (config?: webReadConfig) => {
try {
if (config !== undefined) store.setConfig(config)
} catch {
ElMessage.info('阅读界面配置解析错误')
}
}
const readingRecent = ref<typeof store.readingBook>({
name: '尚无阅读记录',
author: '',
bookUrl: '',
chapterIndex: 0,
chapterPos: 0,
isSeachBook: false,
})
const shelfWrapper = ref<HTMLElement>()
//const shelfWrapper = useTemplateRef<HTMLElement>("shelfWrapper")
const { showLoading, closeLoading, loadingWrapper, isLoading } = useLoading(
shelfWrapper,
'正在获取书籍信息',
)
// 书架书籍和在线书籍搜索
const books = shallowRef<Book[] | SeachBook[]>([])
const shelf = computed(() => store.shelf)
const searchWord = ref('')
const isSearching = ref(false)
watchEffect(() => {
if (isSearching.value && searchWord.value != '') return
isSearching.value = false
books.value = []
if (searchWord.value == '') {
books.value = shelf.value
return
}
books.value = shelf.value.filter(book => {
return (
book.name.includes(searchWord.value) ||
book.author.includes(searchWord.value)
)
})
})
//搜索在线书籍
const searchBook = () => {
if (searchWord.value == '') return
books.value = []
store.clearSearchBooks()
showLoading()
isSearching.value = true
API.search(
searchWord.value,
searcBooks => {
if (isLoading) {
closeLoading()
}
try {
store.setSearchBooks(searcBooks)
books.value = store.searchBooks
//store.searchBooks.forEach((item) => books.value.push(item));
} catch (e) {
ElMessage.error('后端数据错误')
throw e
}
},
() => {
closeLoading()
if (books.value.length == 0) {
ElMessage.info('搜索结果为空')
}
},
)
}
//连接状态
const connectionStore = useConnectionStore()
const { connectStatus, connectType, newConnect } = storeToRefs(connectionStore)
const setLegadoRetmoteUrl = () => {
ElMessageBox.prompt(
'请输入 后端地址 ( 如:http://127.0.0.1:9527 或者通过内网穿透的地址)',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: legado_http_entry_point,
inputValidator: value => validatorHttpUrl(value),
inputErrorMessage: '输入的格式不对',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
connectionStore.setNewConnect(true)
instance.confirmButtonLoading = true
instance.confirmButtonText = '校验中……'
// instance.inputValue
const url = new URL(instance.inputValue).toString()
API.getReadConfig(url)
.then(function (config) {
connectionStore.setNewConnect(false)
applyReadConfig(config)
instance.confirmButtonLoading = false
store.clearSearchBooks()
setApiEntryPoint(...parseLeagdoHttpUrlWithDefault(url))
if (url === location.origin) {
localStorage.removeItem(baseURL_localStorage_key)
} else {
localStorage.setItem(baseURL_localStorage_key, url)
}
store.loadBookShelf()
done()
})
.catch(function (error) {
connectionStore.setNewConnect(false)
instance.confirmButtonLoading = false
instance.confirmButtonText = '确定'
throw error
})
} else {
done()
}
},
},
)
}
const router = useRouter()
const handleBookClick = async (book: SeachBook | Book) => {
// 判断是否为 searchBook
const isSeachBook = 'respondTime' in book
if (isSeachBook) {
await API.saveBook(book)
}
const {
bookUrl,
name,
author,
// @ts-expect-error: descruct with default value
durChapterIndex = 0,
// @ts-expect-error: descruct with default value
durChapterPos = 0,
} = book
toDetail(bookUrl, name, author, durChapterIndex, durChapterPos, isSeachBook)
}
const toDetail = (
bookUrl: string,
bookName: string,
bookAuthor: string,
chapterIndex: number,
chapterPos: number,
isSeachBook: boolean | undefined = false,
fromReadRecentClick = false,
) => {
if (bookName === '尚无阅读记录') return
// 最近书籍不再书架上 自动搜索
if (
fromReadRecentClick &&
shelf.value.every(book => book.bookUrl !== bookUrl)
) {
searchWord.value = bookName
searchBook()
return
}
sessionStorage.setItem('bookUrl', bookUrl)
sessionStorage.setItem('bookName', bookName)
sessionStorage.setItem('bookAuthor', bookAuthor)
sessionStorage.setItem('chapterIndex', String(chapterIndex))
sessionStorage.setItem('chapterPos', String(chapterPos))
sessionStorage.setItem('isSeachBook', String(isSeachBook))
readingRecent.value = {
name: bookName,
author: bookAuthor,
bookUrl,
chapterIndex,
chapterPos,
isSeachBook,
}
localStorage.setItem('readingRecent', JSON.stringify(readingRecent.value))
router.push({
path: '/chapter',
})
}
const loadShelf = async () => {
await store.loadWebConfig()
await store.saveBookProgress()
//确保各种网络情况下同步请求先完成
await store.loadBookShelf()
}
onMounted(() => {
//获取最近阅读书籍
const readingRecentStr = localStorage.getItem('readingRecent')
if (readingRecentStr != null) {
readingRecent.value = JSON.parse(readingRecentStr)
if (typeof readingRecent.value.chapterIndex == 'undefined') {
readingRecent.value.chapterIndex = 0
}
}
console.log('bookshelf mounted')
loadingWrapper(loadShelf())
})
</script>
<style lang="scss" scoped>
.index-wrapper {
height: 100%;
width: 100%;
display: flex;
flex-direction: row;
.navigation-wrapper {
width: 260px;
min-width: 260px;
padding: 48px 36px;
background-color: #f7f7f7;
.navigation-title {
font-size: 24px;
font-weight: 500;
font-family: FZZCYSK;
}
.navigation-sub-title {
font-size: 16px;
font-weight: 300;
font-family: FZZCYSK;
margin-top: 16px;
color: #b1b1b1;
}
.search-wrapper {
.search-input {
border-radius: 50%;
margin-top: 24px;
:deep(.el-input__wrapper) {
border-radius: 50px;
border-color: #e3e3e3;
}
}
}
.bottom-wrapper {
display: flex;
flex-direction: column;
}
.recent-wrapper {
margin-top: 36px;
.recent-title {
font-size: 14px;
color: #b1b1b1;
font-family: FZZCYSK;
}
.reading-recent {
margin: 18px 0;
.recent-book {
font-size: 10px;
/* // font-weight: 400;
// margin: 12px 0;
// font-weight: 500;
// color: #6B7C87; */
cursor: pointer;
/* // padding: 6px 18px; */
}
}
}
.setting-wrapper {
margin-top: 36px;
.setting-title {
font-size: 14px;
color: #b1b1b1;
font-family: FZZCYSK;
}
.no-point {
pointer-events: none;
}
.setting-connect {
font-size: 8px;
margin-top: 16px;
/* // color: #6B7C87; */
cursor: pointer;
}
}
.bottom-icons {
position: fixed;
bottom: 0;
height: 120px;
width: 260px;
align-items: center;
display: flex;
flex-direction: row;
}
}
.shelf-wrapper {
padding: 48px 48px;
width: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
}
}
@media screen and (max-width: 750px) {
.index-wrapper {
overflow-x: hidden;
flex-direction: column;
.navigation-wrapper {
padding: 20px 24px;
box-sizing: border-box;
width: 100%;
.navigation-title-wrapper {
white-space: nowrap;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.bottom-wrapper {
flex-direction: row;
> * {
flex-grow: 1;
margin-top: 18px;
.reading-recent,
.setting-item {
margin-bottom: 0px;
}
}
}
.bottom-icons {
display: none;
}
}
.shelf-wrapper {
padding: 0;
flex-grow: 1;
:deep(.el-loading-spinner) {
display: none;
}
}
}
}
.night {
.navigation-wrapper {
background-color: #454545;
.navigation-title {
color: #aeaeae;
}
.search-wrapper {
.search-input {
.el-input__wrapper {
background-color: #454545;
}
.el-input__inner {
color: #b1b1b1;
}
}
}
}
:deep(.shelf-wrapper) {
background-color: #161819;
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<div class="editor">
<source-tab-form class="left" :config="config" />
<tool-bar />
<source-tab-tools class="right" />
</div>
</template>
<script setup lang="ts">
import bookSourceConfig from '@/config/bookSourceEditConfig'
import rssSourceConfig from '@/config/rssSourceEditConfig'
import '@/assets/sourceeditor.css'
import { useDark } from '@vueuse/core'
import type { SourceConfig } from '@/config/sourceConfig'
useDark()
let config: SourceConfig
if (/bookSource/i.test(location.href)) {
config = bookSourceConfig as SourceConfig
document.title = '书源管理'
} else {
config = rssSourceConfig as SourceConfig
document.title = '订阅源管理'
}
</script>
<style lang="scss" scoped>
.editor {
display: flex;
height: 100vh;
overflow: hidden;
.left {
flex: 1;
margin-left: 20px;
}
.right {
flex: 1;
width: 360px;
margin-right: 20px;
}
}
</style>
Vendored Executable
+14
View File
@@ -0,0 +1,14 @@
export type webReadConfig = {
theme: number
font: number
fontSize: number
readWidth: number
infiniteLoading: boolean
customFontName: string
jumpDuration: number
spacing: {
paragraph: number
line: number
letter: number
}
}