在线阅读组件
This commit is contained in:
parent
961a7e88b2
commit
1643a738c9
@ -1 +1 @@
|
|||||||
3.2.0.1761149071641
|
3.2.0.1761459758865
|
||||||
555
src/components/EpubReader/EpubReader.vue
Normal file
555
src/components/EpubReader/EpubReader.vue
Normal 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>
|
||||||
313
src/components/EpubReader/README.md
Normal file
313
src/components/EpubReader/README.md
Normal 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支持
|
||||||
54
src/components/EpubReader/api.ts
Normal file
54
src/components/EpubReader/api.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
343
src/components/EpubReader/example.vue
Normal file
343
src/components/EpubReader/example.vue
Normal 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>
|
||||||
22
src/components/EpubReader/index.ts
Normal file
22
src/components/EpubReader/index.ts
Normal 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;
|
||||||
42
src/components/EpubReader/package.json
Normal file
42
src/components/EpubReader/package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
99
src/components/EpubReader/types.ts
Normal file
99
src/components/EpubReader/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -33,7 +33,7 @@ const onThemeConfigChange = () => {
|
|||||||
const systemConfigStore = SystemConfigStore()
|
const systemConfigStore = SystemConfigStore()
|
||||||
const { systemConfig } = storeToRefs(systemConfigStore)
|
const { systemConfig } = storeToRefs(systemConfigStore)
|
||||||
const getSystemConfig = computed(() => {
|
const getSystemConfig = computed(() => {
|
||||||
return systemConfig.value
|
return systemConfig.value || {}
|
||||||
})
|
})
|
||||||
|
|
||||||
const siteLogo = computed(() => {
|
const siteLogo = computed(() => {
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export const staticChildrenRoutes: Array<RouteRecordRaw> = [
|
|||||||
{
|
{
|
||||||
path: '/book/reader',
|
path: '/book/reader',
|
||||||
name: 'bookReader',
|
name: 'bookReader',
|
||||||
component: () => import('/@/views/book/reader/index.vue'),
|
component: () => import('/@/views/book/reader/Wrapper.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '在线阅读',
|
title: '在线阅读',
|
||||||
isLink: '',
|
isLink: '',
|
||||||
|
|||||||
@ -11,6 +11,10 @@ export const SystemConfigStore = defineStore('SystemConfig', {
|
|||||||
state: (): ConfigStates => ({
|
state: (): ConfigStates => ({
|
||||||
systemConfig: {},
|
systemConfig: {},
|
||||||
}),
|
}),
|
||||||
|
getters: {
|
||||||
|
// Ensure systemConfig is never null to prevent runtime errors
|
||||||
|
safeSystemConfig: (state) => state.systemConfig || {},
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async getSystemConfigs() {
|
async getSystemConfigs() {
|
||||||
request({
|
request({
|
||||||
@ -18,7 +22,7 @@ export const SystemConfigStore = defineStore('SystemConfig', {
|
|||||||
method: 'get',
|
method: 'get',
|
||||||
}).then((ret: { data: [] }) => {
|
}).then((ret: { data: [] }) => {
|
||||||
// 转换数据格式并保存到pinia
|
// 转换数据格式并保存到pinia
|
||||||
this.systemConfig = JSON.parse(JSON.stringify(ret.data));
|
this.systemConfig = JSON.parse(JSON.stringify(ret.data)) || {};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -51,6 +51,17 @@ function createService() {
|
|||||||
// 响应拦截
|
// 响应拦截
|
||||||
service.interceptors.response.use(
|
service.interceptors.response.use(
|
||||||
(response) => {
|
(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') {
|
if (response.config.responseType === 'blob') {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/views/book/reader/Wrapper.vue
Normal file
19
src/views/book/reader/Wrapper.vue
Normal 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>
|
||||||
@ -1,34 +1,23 @@
|
|||||||
import { defineAsyncComponent, AsyncComponentLoader } from 'vue';
|
import { defineAsyncComponent, AsyncComponentLoader } from 'vue';
|
||||||
export let pluginsAll: any = [];
|
export let pluginsAll: string[] = [];
|
||||||
// 扫描插件目录并注册插件
|
// 扫描插件目录并注册插件(仅 use 插件,不做全局组件自动注册,避免冲突)
|
||||||
export const scanAndInstallPlugins = (app: any) => {
|
export const scanAndInstallPlugins = (app: any) => {
|
||||||
const components = import.meta.glob('./**/*.ts');
|
// 仅扫描每个插件根目录下的 index.ts,按插件包进行 use
|
||||||
const pluginNames = new Set();
|
const pluginEntries = import.meta.glob('./*/index.ts');
|
||||||
// 遍历对象并注册异步组件
|
pluginsAll = Object.keys(pluginEntries).map((p) => p.split('/').slice(-2, -1)[0]);
|
||||||
for (const [key, value] of Object.entries(components)) {
|
console.log('已发现插件:', pluginsAll);
|
||||||
const name = key.slice(key.lastIndexOf('/') + 1, key.lastIndexOf('.'));
|
for (const [path, loader] of Object.entries(pluginEntries)) {
|
||||||
app.component(name, defineAsyncComponent(value as AsyncComponentLoader));
|
(loader as any)()
|
||||||
const pluginsName = key.match(/\/([^\/]*)\//)?.[1];
|
.then((module: any) => {
|
||||||
pluginNames.add(pluginsName);
|
if (module?.default) {
|
||||||
}
|
app.use(module.default);
|
||||||
const dreamComponents = import.meta.glob('/node_modules/@great-dream/**/*.ts');
|
const name = path.split('/').slice(-2, -1)[0];
|
||||||
// 遍历对象并注册异步组件
|
console.log(`${name}插件已加载`);
|
||||||
for (let [key, value] of Object.entries(dreamComponents)) {
|
}
|
||||||
key = key.replace('node_modules/@great-dream/', '');
|
})
|
||||||
const name = key.slice(key.lastIndexOf('/') + 1, key.lastIndexOf('.'));
|
.catch(() => {
|
||||||
app.component(name, defineAsyncComponent(value as AsyncComponentLoader));
|
const name = path.split('/').slice(-2, -1)[0];
|
||||||
const pluginsName = key.match(/\/([^\/]*)\//)?.[1];
|
console.log(`${name}插件加载失败`);
|
||||||
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`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,29 @@
|
|||||||
import { request } from "/@/utils/service";
|
import { request, service } from "/@/utils/service";
|
||||||
|
|
||||||
export function getCaptcha() {
|
export async function getCaptcha() {
|
||||||
return request({
|
// 使用底层 service 获取原始响应,兼容图片/JSON
|
||||||
|
const res = await service({
|
||||||
url: '/api/captcha/',
|
url: '/api/captcha/',
|
||||||
method: 'get',
|
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) {
|
export function login(params: object) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@ -123,7 +123,8 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
// 是否关闭验证码
|
// 是否关闭验证码
|
||||||
const isShowCaptcha = computed(() => {
|
const isShowCaptcha = computed(() => {
|
||||||
return SystemConfigStore().systemConfig['base.captcha_state'];
|
const config = SystemConfigStore().systemConfig;
|
||||||
|
return config ? config['base.captcha_state'] : false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const getCaptcha = async () => {
|
const getCaptcha = async () => {
|
||||||
|
|||||||
@ -115,14 +115,14 @@ const getThemeConfig = computed(() => {
|
|||||||
const systemConfigStore = SystemConfigStore()
|
const systemConfigStore = SystemConfigStore()
|
||||||
const { systemConfig } = storeToRefs(systemConfigStore)
|
const { systemConfig } = storeToRefs(systemConfigStore)
|
||||||
const getSystemConfig = computed(() => {
|
const getSystemConfig = computed(() => {
|
||||||
return systemConfig.value
|
return systemConfig.value || {}
|
||||||
})
|
})
|
||||||
|
|
||||||
const siteLogo = computed(() => {
|
const siteLogo = computed(() => {
|
||||||
if (!_.isEmpty(getSystemConfig.value['login.site_logo'])) {
|
if (!_.isEmpty(getSystemConfig.value['login.site_logo'])) {
|
||||||
return getSystemConfig.value['login.site_logo']
|
return getSystemConfig.value['login.site_logo']
|
||||||
}
|
}
|
||||||
return logoMini
|
return logoMini
|
||||||
});
|
});
|
||||||
|
|
||||||
const siteBg = computed(() => {
|
const siteBg = computed(() => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user