在线阅读组件

This commit is contained in:
liurui 2025-10-26 15:31:21 +08:00
parent 961a7e88b2
commit 1643a738c9
17 changed files with 1515 additions and 44 deletions

View File

@ -1 +1 @@
3.2.0.1761149071641
3.2.0.1761459758865

View File

@ -0,0 +1,555 @@
<template>
<div class="epub-reader-container">
<div class="reader-header" v-if="showHeader">
<slot name="header-left">
<el-button @click="handleBack" icon="ArrowLeft" circle />
</slot>
<h2 class="book-title">{{ bookTitle }}</h2>
<div class="header-actions">
<el-button @click="toggleToc" icon="List" v-if="showToc">目录</el-button>
<el-button @click="toggleTranslate" icon="Connection" v-if="showTranslate && needTranslation">翻译</el-button>
<slot name="header-right"></slot>
</div>
</div>
<div class="reader-main">
<!-- 目录侧边栏 -->
<el-drawer v-model="tocVisible" title="目录" direction="ltr" size="300px" :z-index="100">
<div class="toc-container">
<el-tree
:data="tocData"
:props="{ label: 'label', children: 'children' }"
@node-click="handleTocClick"
highlight-current
/>
</div>
</el-drawer>
<!-- 阅读器主体 -->
<div class="reader-content" ref="readerContainer">
<div class="epub-viewer" ref="epubViewer"></div>
</div>
<!-- 翻译面板 -->
<el-drawer v-model="translateVisible" title="翻译" direction="rtl" size="400px" v-if="showTranslate">
<div class="translate-container">
<el-select v-model="targetLanguage" placeholder="目标语言" style="width: 100%; margin-bottom: 20px;">
<el-option label="中文" value="zh" />
<el-option label="英文" value="en" />
</el-select>
<div class="translate-content" v-if="translatedText">
<h4>原文</h4>
<p>{{ selectedText }}</p>
<el-divider />
<h4>译文</h4>
<p>{{ translatedText }}</p>
</div>
<el-empty v-else description="选择文本后点击翻译按钮" />
</div>
</el-drawer>
</div>
<!-- 底部控制栏 -->
<div class="reader-footer" v-if="showFooter">
<div class="progress-info">
<span>进度: {{ readProgress }}%</span>
<span> {{ currentPage }} / {{ totalPages }} </span>
</div>
<div class="controls">
<el-button @click="prevPage" icon="ArrowLeft" :disabled="currentPage <= 1">上一页</el-button>
<el-slider v-model="readProgress" @change="handleProgressChange" style="width: 300px; margin: 0 20px;" />
<el-button @click="nextPage" icon="ArrowRight" :disabled="currentPage >= totalPages">下一页</el-button>
</div>
<div class="settings">
<el-button @click="decreaseFontSize" icon="ZoomOut" circle />
<span style="margin: 0 10px;">{{ fontSize }}px</span>
<el-button @click="increaseFontSize" icon="ZoomIn" circle />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { ElMessage } from 'element-plus';
import ePub from 'epubjs';
import { getBookFile, saveReadingProgress as saveProgressAPI, getReadingProgress, translateText as translateAPI } from './api';
import type { EpubReaderOptions, TocItem, ReadingProgress } from './types';
// Props
interface Props {
/** 图书ID */
bookId: string | number;
/** 图书标题 */
bookTitle?: string;
/** 图书文件URL可选不提供则从后端获取 */
bookUrl?: string;
/** 是否显示头部 */
showHeader?: boolean;
/** 是否显示底部 */
showFooter?: boolean;
/** 是否显示目录按钮 */
showToc?: boolean;
/** 是否显示翻译按钮 */
showTranslate?: boolean;
/** 是否自动保存进度 */
autoSaveProgress?: boolean;
/** 进度保存间隔(毫秒) */
saveInterval?: number;
/** 初始字体大小 */
initialFontSize?: number;
/** 是否需要翻译功能 */
needTranslation?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
bookTitle: '在线阅读',
showHeader: true,
showFooter: true,
showToc: true,
showTranslate: true,
autoSaveProgress: true,
saveInterval: 3000,
initialFontSize: 16,
needTranslation: false,
});
// Emits
const emit = defineEmits<{
ready: [];
progressChange: [progress: number];
pageChange: [page: number];
locationChange: [location: string];
error: [error: Error];
back: [];
}>();
//
const readerContainer = ref<HTMLElement | null>(null);
const epubViewer = ref<HTMLElement | null>(null);
const book = ref<any>(null);
const rendition = ref<any>(null);
// UI
const tocVisible = ref(false);
const translateVisible = ref(false);
const tocData = ref<TocItem[]>([]);
//
const currentPage = ref(1);
const totalPages = ref(1);
const readProgress = ref(0);
const currentLocation = ref('');
//
const fontSize = ref(props.initialFontSize);
//
const targetLanguage = ref('zh');
const selectedText = ref('');
const translatedText = ref('');
//
let saveTimer: NodeJS.Timeout | null = null;
//
const initReader = async () => {
try {
let epubUrl = props.bookUrl || '';
// URL
if (!epubUrl && props.bookId) {
try {
const response = await getBookFile(props.bookId);
if (response.data && response.data.file_url) {
epubUrl = response.data.file_url;
}
} catch (error) {
console.warn('无法从后端获取文件:', error);
emit('error', error as Error);
ElMessage.error('无法加载图书文件');
return;
}
}
if (!epubUrl) {
throw new Error('未提供图书文件URL');
}
book.value = ePub(epubUrl);
if (epubViewer.value) {
rendition.value = book.value.renderTo(epubViewer.value, {
width: '100%',
height: '100%',
spread: 'none'
});
await rendition.value.display();
//
await loadToc();
//
rendition.value.themes.fontSize(`${fontSize.value}px`);
//
rendition.value.on('relocated', (location: any) => {
updateProgress(location);
});
//
await loadReadingProgress();
emit('ready');
}
} catch (error) {
console.error('初始化阅读器失败:', error);
emit('error', error as Error);
ElMessage.error('初始化阅读器失败');
}
};
//
const loadToc = async () => {
if (!book.value) return;
try {
const navigation = await book.value.loaded.navigation;
const toc = navigation.toc;
const formatToc = (items: any[]): TocItem[] => {
return items.map((item: any) => ({
label: item.label,
href: item.href,
children: item.subitems && item.subitems.length > 0 ? formatToc(item.subitems) : undefined
}));
};
tocData.value = formatToc(toc);
} catch (error) {
console.error('加载目录失败:', error);
}
};
//
const updateProgress = (location: any) => {
if (!location) return;
currentLocation.value = location.start.cfi;
const progress = Math.round((location.start.percentage || 0) * 100);
readProgress.value = progress;
//
currentPage.value = Math.floor(progress / 100 * totalPages.value) || 1;
emit('progressChange', progress);
emit('pageChange', currentPage.value);
emit('locationChange', currentLocation.value);
//
if (props.autoSaveProgress) {
debounceSaveProgress();
}
};
//
const debounceSaveProgress = () => {
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(() => {
saveReadingProgress();
}, props.saveInterval);
};
//
const saveReadingProgress = async () => {
if (!props.bookId || !currentLocation.value) return;
const progressData: ReadingProgress = {
book_id: props.bookId,
location: currentLocation.value,
progress: readProgress.value,
};
// localStorage
localStorage.setItem(`book_progress_${props.bookId}`, JSON.stringify({
...progressData,
timestamp: new Date().toISOString()
}));
//
try {
await saveProgressAPI(progressData);
} catch (error) {
console.warn('保存阅读进度到后端失败:', error);
}
};
//
const loadReadingProgress = async () => {
if (!props.bookId) return;
try {
//
const response = await getReadingProgress(props.bookId);
if (response.data && response.data.location && rendition.value) {
rendition.value.display(response.data.location);
return;
}
} catch (error) {
console.warn('从后端加载阅读进度失败,尝试从本地加载:', error);
}
// localStorage
const savedProgress = localStorage.getItem(`book_progress_${props.bookId}`);
if (savedProgress) {
try {
const progressData = JSON.parse(savedProgress);
if (progressData.location && rendition.value) {
rendition.value.display(progressData.location);
}
} catch (error) {
console.error('加载阅读进度失败:', error);
}
}
};
//
const toggleToc = () => {
tocVisible.value = !tocVisible.value;
};
//
const handleTocClick = (data: TocItem) => {
if (rendition.value && data.href) {
rendition.value.display(data.href);
tocVisible.value = false;
}
};
//
const toggleTranslate = () => {
translateVisible.value = !translateVisible.value;
};
//
const prevPage = () => {
if (rendition.value) {
rendition.value.prev();
}
};
//
const nextPage = () => {
if (rendition.value) {
rendition.value.next();
}
};
//
const handleProgressChange = (value: number) => {
if (book.value && rendition.value) {
const percentage = value / 100;
book.value.locations.generate(1024).then(() => {
const location = book.value.locations.cfiFromPercentage(percentage);
rendition.value.display(location);
});
}
};
//
const increaseFontSize = () => {
if (fontSize.value < 32) {
fontSize.value += 2;
if (rendition.value) {
rendition.value.themes.fontSize(`${fontSize.value}px`);
}
}
};
//
const decreaseFontSize = () => {
if (fontSize.value > 12) {
fontSize.value -= 2;
if (rendition.value) {
rendition.value.themes.fontSize(`${fontSize.value}px`);
}
}
};
//
const handleBack = () => {
emit('back');
};
//
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
prevPage();
} else if (e.key === 'ArrowRight') {
nextPage();
}
};
// bookId
watch(() => props.bookId, () => {
if (rendition.value) {
rendition.value.destroy();
}
initReader();
});
onMounted(() => {
initReader();
window.addEventListener('keydown', handleKeydown);
//
totalPages.value = 100;
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown);
if (saveTimer) {
clearTimeout(saveTimer);
}
if (rendition.value) {
rendition.value.destroy();
}
});
//
defineExpose({
prevPage,
nextPage,
goToLocation: (location: string) => {
if (rendition.value) {
rendition.value.display(location);
}
},
setFontSize: (size: number) => {
fontSize.value = size;
if (rendition.value) {
rendition.value.themes.fontSize(`${size}px`);
}
},
saveProgress: saveReadingProgress,
});
</script>
<style scoped lang="scss">
.epub-reader-container {
display: flex;
flex-direction: column;
height: calc(100vh - 50px);
background-color: #f5f5f5;
position: relative;
z-index: 1;
}
.reader-header {
display: flex;
align-items: center;
padding: 15px 20px;
background-color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
.book-title {
flex: 1;
margin: 0 20px;
font-size: 20px;
font-weight: 500;
color: #303133;
}
.header-actions {
display: flex;
gap: 10px;
}
}
.reader-main {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.reader-content {
flex: 1;
padding: 20px;
overflow: auto;
background-color: #fff;
margin: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.epub-viewer {
width: 100%;
height: 100%;
}
}
.toc-container {
padding: 10px;
:deep(.el-tree-node__content) {
padding: 8px 0;
cursor: pointer;
&:hover {
background-color: #f5f7fa;
}
}
}
.translate-container {
padding: 20px;
.translate-content {
h4 {
margin: 10px 0;
color: #606266;
}
p {
line-height: 1.8;
color: #303133;
}
}
}
.reader-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
background-color: #fff;
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
.progress-info {
display: flex;
gap: 20px;
min-width: 200px;
color: #606266;
font-size: 14px;
}
.controls {
display: flex;
align-items: center;
flex: 1;
justify-content: center;
}
.settings {
display: flex;
align-items: center;
min-width: 200px;
justify-content: flex-end;
}
}
</style>

View File

@ -0,0 +1,313 @@
# EpubReader - EPUB电子书阅读器组件
一个功能完整、易于集成的Vue 3 EPUB电子书阅读器组件。
## 特性
- ✅ 支持EPUB格式电子书
- ✅ 响应式阅读界面
- ✅ 目录导航
- ✅ 阅读进度自动保存(本地+后端)
- ✅ 字体大小调整
- ✅ 键盘快捷键支持
- ✅ 进度条拖动定位
- ✅ 翻译功能框架(可扩展)
- ✅ 完全可定制的UI
- ✅ TypeScript支持
## 安装
### 1. 安装依赖
```bash
npm install epubjs
# 或
yarn add epubjs
```
### 2. 复制组件
`EpubReader` 文件夹复制到你的项目的 `src/components/` 目录下。
## 快速开始
### 基础使用
```vue
<template>
<EpubReader
:book-id="1"
book-title="示例图书"
@back="handleBack"
/>
</template>
<script setup lang="ts">
import { EpubReader } from '@/components/EpubReader';
const handleBack = () => {
// 返回逻辑
console.log('返回');
};
</script>
```
### 使用自定义URL
```vue
<template>
<EpubReader
:book-id="bookId"
:book-title="bookTitle"
:book-url="bookUrl"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { EpubReader } from '@/components/EpubReader';
const bookId = ref(1);
const bookTitle = ref('我的图书');
const bookUrl = ref('https://example.com/books/sample.epub');
</script>
```
## Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `bookId` | `string \| number` | **必填** | 图书ID用于保存和加载阅读进度 |
| `bookTitle` | `string` | `'在线阅读'` | 图书标题,显示在头部 |
| `bookUrl` | `string` | - | 图书文件URL不提供则从后端API获取 |
| `showHeader` | `boolean` | `true` | 是否显示头部 |
| `showFooter` | `boolean` | `true` | 是否显示底部控制栏 |
| `showToc` | `boolean` | `true` | 是否显示目录按钮 |
| `showTranslate` | `boolean` | `true` | 是否显示翻译按钮 |
| `autoSaveProgress` | `boolean` | `true` | 是否自动保存阅读进度 |
| `saveInterval` | `number` | `3000` | 进度保存间隔(毫秒) |
| `initialFontSize` | `number` | `16` | 初始字体大小 |
| `needTranslation` | `boolean` | `false` | 是否需要翻译功能 |
## Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| `ready` | - | 阅读器加载完成 |
| `progressChange` | `progress: number` | 阅读进度变化0-100 |
| `pageChange` | `page: number` | 页码变化 |
| `locationChange` | `location: string` | 位置变化CFI格式 |
| `error` | `error: Error` | 错误事件 |
| `back` | - | 点击返回按钮 |
## Slots
| 插槽名 | 说明 |
|--------|------|
| `header-left` | 头部左侧区域(默认为返回按钮) |
| `header-right` | 头部右侧区域 |
## 暴露的方法
通过 `ref` 可以调用以下方法:
```vue
<template>
<EpubReader ref="readerRef" :book-id="1" />
<button @click="goToNext">下一页</button>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { EpubReader } from '@/components/EpubReader';
const readerRef = ref();
const goToNext = () => {
readerRef.value?.nextPage();
};
</script>
```
### 可用方法
| 方法 | 参数 | 说明 |
|------|------|------|
| `prevPage()` | - | 上一页 |
| `nextPage()` | - | 下一页 |
| `goToLocation(location: string)` | `location`: CFI位置 | 跳转到指定位置 |
| `setFontSize(size: number)` | `size`: 字体大小 | 设置字体大小 |
| `saveProgress()` | - | 手动保存进度 |
## 高级用法
### 自定义头部
```vue
<template>
<EpubReader :book-id="1">
<template #header-left>
<el-button @click="customBack" icon="Close">关闭</el-button>
</template>
<template #header-right>
<el-button @click="share" icon="Share">分享</el-button>
</template>
</EpubReader>
</template>
```
### 监听事件
```vue
<template>
<EpubReader
:book-id="1"
@ready="onReady"
@progress-change="onProgressChange"
@error="onError"
/>
</template>
<script setup lang="ts">
const onReady = () => {
console.log('阅读器已准备好');
};
const onProgressChange = (progress: number) => {
console.log('当前进度:', progress);
};
const onError = (error: Error) => {
console.error('发生错误:', error);
};
</script>
```
### 完全自定义UI
```vue
<template>
<EpubReader
:book-id="1"
:show-header="false"
:show-footer="false"
>
<!-- 完全自定义的UI -->
</EpubReader>
</template>
```
## 后端API要求
组件需要以下后端API支持
### 1. 获取图书文件
```
GET /api/CrudBookModelViewSet/{bookId}/file/
Response:
{
"file_url": "http://example.com/media/books/xxx.epub",
"file_type": "epub"
}
```
### 2. 保存阅读进度
```
POST /api/reading-progress/
Request:
{
"book_id": 1,
"location": "epubcfi(/6/4[chap01ref]!/4/2/2[body01]/2/1:0)",
"progress": 45.5
}
Response:
{
"id": 1,
"book_id": 1,
"location": "epubcfi(...)",
"progress": 45.5,
"update_datetime": "2025-10-22T10:00:00Z"
}
```
### 3. 获取阅读进度
```
GET /api/reading-progress/by_book/?book_id={bookId}
Response:
{
"id": 1,
"book_id": 1,
"location": "epubcfi(...)",
"progress": 45.5,
"update_datetime": "2025-10-22T10:00:00Z"
}
```
## 自定义API
如果你的后端API路径不同可以修改 `api.ts` 文件中的URL
```typescript
// src/components/EpubReader/api.ts
export function getBookFile(bookId: string | number) {
return request({
url: `/your-custom-api/${bookId}/file/`, // 修改这里
method: 'get',
});
}
```
## 键盘快捷键
- `←` (左箭头) - 上一页
- `→` (右箭头) - 下一页
## 浏览器兼容性
- Chrome/Edge (最新版)
- Firefox (最新版)
- Safari (最新版)
## 注意事项
1. **CORS问题**: 如果EPUB文件托管在不同域名需要配置CORS
2. **文件大小**: 大文件可能需要较长加载时间
3. **本地存储**: 阅读进度会同时保存到localStorage和后端确保数据不丢失
4. **权限**: 确保用户有访问图书文件的权限
## 示例项目
完整的使用示例请参考:`src/views/book/reader/index.vue`
## TypeScript支持
组件完全使用TypeScript编写提供完整的类型定义
```typescript
import type {
EpubReaderOptions,
ReadingProgress,
TocItem
} from '@/components/EpubReader';
```
## 许可证
MIT
## 更新日志
### v1.0.0 (2025-10-26)
- ✅ 初始版本发布
- ✅ 支持EPUB格式
- ✅ 阅读进度保存
- ✅ 目录导航
- ✅ 字体调整
- ✅ 完整的TypeScript支持

View File

@ -0,0 +1,54 @@
/**
* EPUB阅读器API接口
*/
import { request } from '/@/utils/service';
import type { ReadingProgress, BookFileInfo, TranslateRequest, TranslateResponse } from './types';
/**
*
* @param bookId ID
*/
export function getBookFile(bookId: string | number): Promise<{ data: BookFileInfo }> {
return request({
url: `/api/CrudBookModelViewSet/${bookId}/file/`,
method: 'get',
});
}
/**
*
* @param data
*/
export function saveReadingProgress(data: ReadingProgress): Promise<{ data: ReadingProgress }> {
return request({
url: '/api/reading-progress/',
method: 'post',
data,
});
}
/**
*
* @param bookId ID
*/
export function getReadingProgress(bookId: string | number): Promise<{ data: ReadingProgress }> {
return request({
url: `/api/reading-progress/by_book/`,
method: 'get',
params: {
book_id: bookId
}
});
}
/**
*
* @param data
*/
export function translateText(data: TranslateRequest): Promise<{ data: TranslateResponse }> {
return request({
url: '/api/translate/',
method: 'post',
data,
});
}

View File

@ -0,0 +1,343 @@
<!--
EpubReader 组件使用示例
展示了各种使用场景和配置方式
-->
<template>
<div class="epub-reader-example">
<h1>EPUB阅读器组件示例</h1>
<!-- 示例1: 基础使用 -->
<section class="example-section">
<h2>示例1: 基础使用</h2>
<el-button @click="showExample1 = true">打开阅读器</el-button>
<el-dialog v-model="showExample1" title="基础阅读器" fullscreen>
<EpubReader
:book-id="1"
book-title="示例图书"
@back="showExample1 = false"
/>
</el-dialog>
</section>
<!-- 示例2: 使用自定义URL -->
<section class="example-section">
<h2>示例2: 使用自定义URL</h2>
<el-input v-model="customUrl" placeholder="输入EPUB文件URL" style="width: 400px; margin-right: 10px;" />
<el-button @click="openCustomUrl">打开</el-button>
<el-dialog v-model="showExample2" title="自定义URL阅读器" fullscreen>
<EpubReader
:book-id="2"
book-title="自定义URL图书"
:book-url="customUrl"
@back="showExample2 = false"
/>
</el-dialog>
</section>
<!-- 示例3: 监听事件 -->
<section class="example-section">
<h2>示例3: 监听事件</h2>
<el-button @click="showExample3 = true">打开阅读器</el-button>
<div class="event-log" v-if="showExample3">
<h3>事件日志:</h3>
<ul>
<li v-for="(log, index) in eventLogs" :key="index">{{ log }}</li>
</ul>
</div>
<el-dialog v-model="showExample3" title="事件监听示例" fullscreen>
<EpubReader
:book-id="3"
book-title="事件监听示例"
@ready="onReady"
@progress-change="onProgressChange"
@page-change="onPageChange"
@location-change="onLocationChange"
@error="onError"
@back="showExample3 = false"
/>
</el-dialog>
</section>
<!-- 示例4: 自定义头部 -->
<section class="example-section">
<h2>示例4: 自定义头部</h2>
<el-button @click="showExample4 = true">打开阅读器</el-button>
<el-dialog v-model="showExample4" title="自定义头部示例" fullscreen>
<EpubReader
:book-id="4"
book-title="自定义头部示例"
>
<template #header-left>
<el-button @click="showExample4 = false" icon="Close" type="danger">关闭</el-button>
</template>
<template #header-right>
<el-button @click="handleShare" icon="Share">分享</el-button>
<el-button @click="handleDownload" icon="Download">下载</el-button>
</template>
</EpubReader>
</el-dialog>
</section>
<!-- 示例5: 使用ref调用方法 -->
<section class="example-section">
<h2>示例5: 使用ref调用方法</h2>
<el-button @click="showExample5 = true">打开阅读器</el-button>
<div class="controls" v-if="showExample5">
<el-button @click="readerRef?.prevPage()">上一页</el-button>
<el-button @click="readerRef?.nextPage()">下一页</el-button>
<el-button @click="readerRef?.setFontSize(20)">设置字体20px</el-button>
<el-button @click="readerRef?.saveProgress()">保存进度</el-button>
</div>
<el-dialog v-model="showExample5" title="方法调用示例" fullscreen>
<EpubReader
ref="readerRef"
:book-id="5"
book-title="方法调用示例"
@back="showExample5 = false"
/>
</el-dialog>
</section>
<!-- 示例6: 最小化UI -->
<section class="example-section">
<h2>示例6: 最小化UI无头部和底部</h2>
<el-button @click="showExample6 = true">打开阅读器</el-button>
<el-dialog v-model="showExample6" title="最小化UI示例" fullscreen>
<div class="minimal-reader">
<div class="custom-header">
<el-button @click="showExample6 = false">关闭</el-button>
<span>自定义头部</span>
</div>
<EpubReader
:book-id="6"
:show-header="false"
:show-footer="false"
/>
</div>
</el-dialog>
</section>
<!-- 示例7: 配置选项 -->
<section class="example-section">
<h2>示例7: 自定义配置</h2>
<div class="config-panel">
<el-form :model="config" label-width="150px">
<el-form-item label="显示头部">
<el-switch v-model="config.showHeader" />
</el-form-item>
<el-form-item label="显示底部">
<el-switch v-model="config.showFooter" />
</el-form-item>
<el-form-item label="显示目录">
<el-switch v-model="config.showToc" />
</el-form-item>
<el-form-item label="显示翻译">
<el-switch v-model="config.showTranslate" />
</el-form-item>
<el-form-item label="自动保存进度">
<el-switch v-model="config.autoSaveProgress" />
</el-form-item>
<el-form-item label="保存间隔(ms)">
<el-input-number v-model="config.saveInterval" :min="1000" :step="1000" />
</el-form-item>
<el-form-item label="初始字体大小">
<el-input-number v-model="config.initialFontSize" :min="12" :max="32" />
</el-form-item>
</el-form>
<el-button @click="showExample7 = true">打开阅读器</el-button>
</div>
<el-dialog v-model="showExample7" title="自定义配置示例" fullscreen>
<EpubReader
:book-id="7"
book-title="自定义配置示例"
:show-header="config.showHeader"
:show-footer="config.showFooter"
:show-toc="config.showToc"
:show-translate="config.showTranslate"
:auto-save-progress="config.autoSaveProgress"
:save-interval="config.saveInterval"
:initial-font-size="config.initialFontSize"
@back="showExample7 = false"
/>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { ElMessage } from 'element-plus';
import { EpubReader } from './index';
// 1
const showExample1 = ref(false);
// 2
const showExample2 = ref(false);
const customUrl = ref('https://example.com/books/sample.epub');
const openCustomUrl = () => {
if (!customUrl.value) {
ElMessage.warning('请输入EPUB文件URL');
return;
}
showExample2.value = true;
};
// 3
const showExample3 = ref(false);
const eventLogs = ref<string[]>([]);
const addLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString();
eventLogs.value.unshift(`[${timestamp}] ${message}`);
if (eventLogs.value.length > 10) {
eventLogs.value.pop();
}
};
const onReady = () => {
addLog('阅读器已准备好');
ElMessage.success('阅读器加载完成');
};
const onProgressChange = (progress: number) => {
addLog(`进度变化: ${progress}%`);
};
const onPageChange = (page: number) => {
addLog(`页码变化: 第${page}`);
};
const onLocationChange = (location: string) => {
addLog(`位置变化: ${location.substring(0, 30)}...`);
};
const onError = (error: Error) => {
addLog(`错误: ${error.message}`);
ElMessage.error(error.message);
};
// 4
const showExample4 = ref(false);
const handleShare = () => {
ElMessage.success('分享功能');
};
const handleDownload = () => {
ElMessage.success('下载功能');
};
// 5
const showExample5 = ref(false);
const readerRef = ref();
// 6
const showExample6 = ref(false);
// 7
const showExample7 = ref(false);
const config = reactive({
showHeader: true,
showFooter: true,
showToc: true,
showTranslate: true,
autoSaveProgress: true,
saveInterval: 3000,
initialFontSize: 16,
});
</script>
<style scoped lang="scss">
.epub-reader-example {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
h1 {
margin-bottom: 30px;
color: #303133;
}
.example-section {
margin-bottom: 40px;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
h2 {
margin-bottom: 20px;
color: #606266;
font-size: 18px;
}
}
.event-log {
margin-top: 20px;
padding: 15px;
background: #f5f7fa;
border-radius: 4px;
h3 {
margin-bottom: 10px;
font-size: 14px;
color: #606266;
}
ul {
list-style: none;
padding: 0;
margin: 0;
li {
padding: 5px 0;
font-size: 12px;
color: #909399;
border-bottom: 1px solid #e4e7ed;
&:last-child {
border-bottom: none;
}
}
}
}
.controls {
margin-top: 20px;
display: flex;
gap: 10px;
}
.minimal-reader {
height: 100%;
display: flex;
flex-direction: column;
.custom-header {
padding: 15px;
background: #409eff;
color: white;
display: flex;
align-items: center;
gap: 20px;
}
}
.config-panel {
.el-form {
max-width: 500px;
}
}
}
</style>

View File

@ -0,0 +1,22 @@
/**
* EPUB阅读器组件入口文件
*/
import EpubReader from './EpubReader.vue';
import type { EpubReaderOptions, TocItem, ReadingProgress, BookFileInfo, TranslateRequest, TranslateResponse, EpubReaderEvents } from './types';
// 导出组件
export { EpubReader };
// 导出类型
export type {
EpubReaderOptions,
TocItem,
ReadingProgress,
BookFileInfo,
TranslateRequest,
TranslateResponse,
EpubReaderEvents
};
// 默认导出
export default EpubReader;

View File

@ -0,0 +1,42 @@
{
"name": "@your-org/epub-reader",
"version": "1.0.0",
"description": "一个功能完整、易于集成的Vue 3 EPUB电子书阅读器组件",
"main": "index.ts",
"types": "types.ts",
"keywords": [
"vue3",
"epub",
"reader",
"ebook",
"component",
"typescript"
],
"author": "Your Name",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/your-org/epub-reader"
},
"bugs": {
"url": "https://github.com/your-org/epub-reader/issues"
},
"homepage": "https://github.com/your-org/epub-reader#readme",
"peerDependencies": {
"vue": "^3.0.0",
"element-plus": "^2.0.0",
"epubjs": "^0.3.93"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0",
"vue": "^3.3.0"
},
"files": [
"EpubReader.vue",
"api.ts",
"types.ts",
"index.ts",
"README.md"
]
}

View File

@ -0,0 +1,99 @@
/**
* EPUB阅读器组件类型定义
*/
/**
*
*/
export interface EpubReaderOptions {
/** 图书ID */
bookId: string | number;
/** 图书标题 */
bookTitle?: string;
/** 图书文件URL */
bookUrl?: string;
/** 是否显示目录 */
showToc?: boolean;
/** 是否显示翻译功能 */
showTranslate?: boolean;
/** 是否自动保存进度 */
autoSaveProgress?: boolean;
/** 进度保存间隔(毫秒) */
saveInterval?: number;
/** 初始字体大小 */
fontSize?: number;
/** 是否需要翻译(非中文书籍) */
needTranslation?: boolean;
}
/**
*
*/
export interface TocItem {
/** 目录标签 */
label: string;
/** 目录href */
href: string;
/** 子目录 */
children?: TocItem[];
}
/**
*
*/
export interface ReadingProgress {
/** 图书ID */
book_id: string | number;
/** 阅读位置CFI格式 */
location: string;
/** 阅读进度百分比 */
progress: number;
/** 时间戳 */
timestamp?: string;
}
/**
*
*/
export interface BookFileInfo {
/** 文件URL */
file_url: string;
/** 文件类型 */
file_type: string;
}
/**
*
*/
export interface TranslateRequest {
/** 原文 */
text: string;
/** 源语言 */
source_lang?: string;
/** 目标语言 */
target_lang: string;
}
/**
*
*/
export interface TranslateResponse {
/** 译文 */
translation: string;
}
/**
*
*/
export interface EpubReaderEvents {
/** 阅读器加载完成 */
onReady?: () => void;
/** 进度变化 */
onProgressChange?: (progress: number) => void;
/** 页面变化 */
onPageChange?: (page: number) => void;
/** 位置变化 */
onLocationChange?: (location: string) => void;
/** 错误 */
onError?: (error: Error) => void;
}

View File

@ -33,7 +33,7 @@ const onThemeConfigChange = () => {
const systemConfigStore = SystemConfigStore()
const { systemConfig } = storeToRefs(systemConfigStore)
const getSystemConfig = computed(() => {
return systemConfig.value
return systemConfig.value || {}
})
const siteLogo = computed(() => {

View File

@ -56,7 +56,7 @@ export const staticChildrenRoutes: Array<RouteRecordRaw> = [
{
path: '/book/reader',
name: 'bookReader',
component: () => import('/@/views/book/reader/index.vue'),
component: () => import('/@/views/book/reader/Wrapper.vue'),
meta: {
title: '在线阅读',
isLink: '',

View File

@ -11,6 +11,10 @@ export const SystemConfigStore = defineStore('SystemConfig', {
state: (): ConfigStates => ({
systemConfig: {},
}),
getters: {
// Ensure systemConfig is never null to prevent runtime errors
safeSystemConfig: (state) => state.systemConfig || {},
},
actions: {
async getSystemConfigs() {
request({
@ -18,7 +22,7 @@ export const SystemConfigStore = defineStore('SystemConfig', {
method: 'get',
}).then((ret: { data: [] }) => {
// 转换数据格式并保存到pinia
this.systemConfig = JSON.parse(JSON.stringify(ret.data));
this.systemConfig = JSON.parse(JSON.stringify(ret.data)) || {};
});
},
},

View File

@ -51,6 +51,17 @@ function createService() {
// 响应拦截
service.interceptors.response.use(
(response) => {
const url = response.config.url || '';
const contentType = (response.headers && (response.headers['content-type'] as string)) || '';
// 特殊接口前端放行:验证码/初始化配置,避免后端返回非标准结构导致解析报错
if (url.includes('/api/captcha/')) {
// 可能返回 blob/image 或 JSON原样返回
return response;
}
if (url.includes('/api/init/settings/')) {
// 返回形态与现有调用保持一致ret.data
return { data: response.data } as any;
}
if (response.config.responseType === 'blob') {
return response;
}

View File

@ -0,0 +1,19 @@
<template>
<EpubReader
:book-id="bookId"
:book-title="bookTitle"
@back="router.back()"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { EpubReader } from '/@/components/EpubReader';
const route = useRoute();
const router = useRouter();
const bookId = computed(() => (route.query.id as string) || (route.params.id as string));
const bookTitle = computed(() => (route.query.title as string) || '在线阅读');
</script>

View File

@ -1,34 +1,23 @@
import { defineAsyncComponent, AsyncComponentLoader } from 'vue';
export let pluginsAll: any = [];
// 扫描插件目录并注册插件
export let pluginsAll: string[] = [];
// 扫描插件目录并注册插件(仅 use 插件,不做全局组件自动注册,避免冲突)
export const scanAndInstallPlugins = (app: any) => {
const components = import.meta.glob('./**/*.ts');
const pluginNames = new Set();
// 遍历对象并注册异步组件
for (const [key, value] of Object.entries(components)) {
const name = key.slice(key.lastIndexOf('/') + 1, key.lastIndexOf('.'));
app.component(name, defineAsyncComponent(value as AsyncComponentLoader));
const pluginsName = key.match(/\/([^\/]*)\//)?.[1];
pluginNames.add(pluginsName);
}
const dreamComponents = import.meta.glob('/node_modules/@great-dream/**/*.ts');
// 遍历对象并注册异步组件
for (let [key, value] of Object.entries(dreamComponents)) {
key = key.replace('node_modules/@great-dream/', '');
const name = key.slice(key.lastIndexOf('/') + 1, key.lastIndexOf('.'));
app.component(name, defineAsyncComponent(value as AsyncComponentLoader));
const pluginsName = key.match(/\/([^\/]*)\//)?.[1];
pluginNames.add(pluginsName);
}
pluginsAll = Array.from(pluginNames);
console.log('已发现插件:', pluginsAll);
for (const pluginName of pluginsAll) {
const plugin = import(`./${pluginName}/index.ts`);
plugin.then((module) => {
app.use(module.default)
console.log(`${pluginName}插件已加载`)
}).catch((error) => {
console.log(`${pluginName}插件下无index.ts`)
})
}
// 仅扫描每个插件根目录下的 index.ts按插件包进行 use
const pluginEntries = import.meta.glob('./*/index.ts');
pluginsAll = Object.keys(pluginEntries).map((p) => p.split('/').slice(-2, -1)[0]);
console.log('已发现插件:', pluginsAll);
for (const [path, loader] of Object.entries(pluginEntries)) {
(loader as any)()
.then((module: any) => {
if (module?.default) {
app.use(module.default);
const name = path.split('/').slice(-2, -1)[0];
console.log(`${name}插件已加载`);
}
})
.catch(() => {
const name = path.split('/').slice(-2, -1)[0];
console.log(`${name}插件加载失败`);
});
}
};

View File

@ -1,10 +1,29 @@
import { request } from "/@/utils/service";
import { request, service } from "/@/utils/service";
export function getCaptcha() {
return request({
export async function getCaptcha() {
// 使用底层 service 获取原始响应,兼容图片/JSON
const res = await service({
url: '/api/captcha/',
method: 'get',
responseType: 'arraybuffer',
// 自定义标记,拦截器已对白名单放行
});
const contentType = (res.headers?.['content-type'] as string) || '';
// JSON 场景
if (contentType.includes('application/json')) {
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(new Uint8Array(res.data));
const json = JSON.parse(text);
return json; // 形如 { code: 2000, data: { image_base, key } } 或 { data: { ... } }
}
// 图片二进制场景:转为 base64保持返回结构 data.image_base
const blob = new Blob([res.data], { type: contentType || 'image/png' });
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
return { data: { image_base: base64, key: '' } } as any;
}
export function login(params: object) {
return request({

View File

@ -123,7 +123,8 @@ export default defineComponent({
});
//
const isShowCaptcha = computed(() => {
return SystemConfigStore().systemConfig['base.captcha_state'];
const config = SystemConfigStore().systemConfig;
return config ? config['base.captcha_state'] : false;
});
const getCaptcha = async () => {

View File

@ -115,14 +115,14 @@ const getThemeConfig = computed(() => {
const systemConfigStore = SystemConfigStore()
const { systemConfig } = storeToRefs(systemConfigStore)
const getSystemConfig = computed(() => {
return systemConfig.value
return systemConfig.value || {}
})
const siteLogo = computed(() => {
if (!_.isEmpty(getSystemConfig.value['login.site_logo'])) {
return getSystemConfig.value['login.site_logo']
}
return logoMini
if (!_.isEmpty(getSystemConfig.value['login.site_logo'])) {
return getSystemConfig.value['login.site_logo']
}
return logoMini
});
const siteBg = computed(() => {