From 1643a738c965de7850005bbe313eca2bb9927a8b Mon Sep 17 00:00:00 2001 From: liurui Date: Sun, 26 Oct 2025 15:31:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E9=98=85=E8=AF=BB=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/version-build | 2 +- src/components/EpubReader/EpubReader.vue | 555 +++++++++++++++++++ src/components/EpubReader/README.md | 313 +++++++++++ src/components/EpubReader/api.ts | 54 ++ src/components/EpubReader/example.vue | 343 ++++++++++++ src/components/EpubReader/index.ts | 22 + src/components/EpubReader/package.json | 42 ++ src/components/EpubReader/types.ts | 99 ++++ src/layout/logo/index.vue | 2 +- src/router/route.ts | 2 +- src/stores/systemConfig.ts | 6 +- src/utils/service.ts | 11 + src/views/book/reader/Wrapper.vue | 19 + src/views/plugins/index.ts | 51 +- src/views/system/login/api.ts | 25 +- src/views/system/login/component/account.vue | 3 +- src/views/system/login/index.vue | 10 +- 17 files changed, 1515 insertions(+), 44 deletions(-) create mode 100644 src/components/EpubReader/EpubReader.vue create mode 100644 src/components/EpubReader/README.md create mode 100644 src/components/EpubReader/api.ts create mode 100644 src/components/EpubReader/example.vue create mode 100644 src/components/EpubReader/index.ts create mode 100644 src/components/EpubReader/package.json create mode 100644 src/components/EpubReader/types.ts create mode 100644 src/views/book/reader/Wrapper.vue diff --git a/public/version-build b/public/version-build index ba07c141..d341b585 100644 --- a/public/version-build +++ b/public/version-build @@ -1 +1 @@ -3.2.0.1761149071641 \ No newline at end of file +3.2.0.1761459758865 \ No newline at end of file diff --git a/src/components/EpubReader/EpubReader.vue b/src/components/EpubReader/EpubReader.vue new file mode 100644 index 00000000..958db9e3 --- /dev/null +++ b/src/components/EpubReader/EpubReader.vue @@ -0,0 +1,555 @@ + + + + + diff --git a/src/components/EpubReader/README.md b/src/components/EpubReader/README.md new file mode 100644 index 00000000..96b7687c --- /dev/null +++ b/src/components/EpubReader/README.md @@ -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 + + + +``` + +### 使用自定义URL + +```vue + + + +``` + +## 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 + + + +``` + +### 可用方法 + +| 方法 | 参数 | 说明 | +|------|------|------| +| `prevPage()` | - | 上一页 | +| `nextPage()` | - | 下一页 | +| `goToLocation(location: string)` | `location`: CFI位置 | 跳转到指定位置 | +| `setFontSize(size: number)` | `size`: 字体大小 | 设置字体大小 | +| `saveProgress()` | - | 手动保存进度 | + +## 高级用法 + +### 自定义头部 + +```vue + +``` + +### 监听事件 + +```vue + + + +``` + +### 完全自定义UI + +```vue + +``` + +## 后端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支持 diff --git a/src/components/EpubReader/api.ts b/src/components/EpubReader/api.ts new file mode 100644 index 00000000..47324166 --- /dev/null +++ b/src/components/EpubReader/api.ts @@ -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, + }); +} diff --git a/src/components/EpubReader/example.vue b/src/components/EpubReader/example.vue new file mode 100644 index 00000000..5b478350 --- /dev/null +++ b/src/components/EpubReader/example.vue @@ -0,0 +1,343 @@ + + + + + + diff --git a/src/components/EpubReader/index.ts b/src/components/EpubReader/index.ts new file mode 100644 index 00000000..818a8ba0 --- /dev/null +++ b/src/components/EpubReader/index.ts @@ -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; diff --git a/src/components/EpubReader/package.json b/src/components/EpubReader/package.json new file mode 100644 index 00000000..ed01bea3 --- /dev/null +++ b/src/components/EpubReader/package.json @@ -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" + ] +} diff --git a/src/components/EpubReader/types.ts b/src/components/EpubReader/types.ts new file mode 100644 index 00000000..d3d7fa56 --- /dev/null +++ b/src/components/EpubReader/types.ts @@ -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; +} diff --git a/src/layout/logo/index.vue b/src/layout/logo/index.vue index 1a656bbf..03987192 100644 --- a/src/layout/logo/index.vue +++ b/src/layout/logo/index.vue @@ -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(() => { diff --git a/src/router/route.ts b/src/router/route.ts index d4b8a572..3337a259 100644 --- a/src/router/route.ts +++ b/src/router/route.ts @@ -56,7 +56,7 @@ export const staticChildrenRoutes: Array = [ { path: '/book/reader', name: 'bookReader', - component: () => import('/@/views/book/reader/index.vue'), + component: () => import('/@/views/book/reader/Wrapper.vue'), meta: { title: '在线阅读', isLink: '', diff --git a/src/stores/systemConfig.ts b/src/stores/systemConfig.ts index a8230748..3b1e3fae 100644 --- a/src/stores/systemConfig.ts +++ b/src/stores/systemConfig.ts @@ -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)) || {}; }); }, }, diff --git a/src/utils/service.ts b/src/utils/service.ts index 0080f78c..07351068 100644 --- a/src/utils/service.ts +++ b/src/utils/service.ts @@ -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; } diff --git a/src/views/book/reader/Wrapper.vue b/src/views/book/reader/Wrapper.vue new file mode 100644 index 00000000..f2f3a7ff --- /dev/null +++ b/src/views/book/reader/Wrapper.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/views/plugins/index.ts b/src/views/plugins/index.ts index f1a33817..292bc779 100644 --- a/src/views/plugins/index.ts +++ b/src/views/plugins/index.ts @@ -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}插件加载失败`); + }); + } }; diff --git a/src/views/system/login/api.ts b/src/views/system/login/api.ts index 4502a753..dfc26cf9 100644 --- a/src/views/system/login/api.ts +++ b/src/views/system/login/api.ts @@ -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((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({ diff --git a/src/views/system/login/component/account.vue b/src/views/system/login/component/account.vue index 64d870c0..300377d8 100644 --- a/src/views/system/login/component/account.vue +++ b/src/views/system/login/component/account.vue @@ -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 () => { diff --git a/src/views/system/login/index.vue b/src/views/system/login/index.vue index 36acbe6f..f13971ff 100644 --- a/src/views/system/login/index.vue +++ b/src/views/system/login/index.vue @@ -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(() => {