基于mpvue的小说阅读器小程序,已隐去API,数据脱敏,仅供学习交流使用,如有侵权,请及时联系。
.
├── dist # 打包后的代码,也是实际上传小程序的代码
├── build # webpack配置文件
├── config # 环境配置文件
├── project.config.json # 项目配置文件
├── src # 程序源文件
│ ├── main.js # 入口文件
│ ├── components # 可复用的组件(Presentational Components)
│ ├── styles # 样式文件
│ ├── store # Vuex Store文件
│ └── pages # 页面文件
部分源码
首页
<template>
<div class="home-page">
<div :class="['refresh-bar', {show: refreshing}]">
<i></i>
<i></i>
<i></i>
</div>
<BookItem
v-for="(item, index) in bookList"
:key="index"
:title="item.title"
:content="`${formatTime(item.updated)}:${item.lastChapter}`"
:cover="getImgSrc(item.cover)"
:hasUpdated="item._hasUpdated"
@click="gotoChapterPage(item._id)"
@longpress="removeBook(item)"
/>
<div class="home-add" @click="gotoSearchPage">
<i class="iconfont icon-add"></i>
<span>添加你喜欢的小说</span>
</div>
</div>
</template>
<script>
import BookItem from '@/components/BookItem';
import { getImgSrc, formatTime } from '@/utils';
import store from '@/store';
export default {
data() {
return {
refreshing: false, // 是否在刷新
};
},
components: {
BookItem,
},
computed: {
// 书架列表
bookList() {
return store.state.bookCase;
}
},
methods: {
formatTime,
getImgSrc,
// 跳转搜索历史页
gotoSearchPage() {
wx.navigateTo({
url: '/pages/history/main'
});
},
// 跳转详情页面
gotoChapterPage(bookId) {
wx.navigateTo({
url: `/pages/chapter/main?bookId=${bookId}`
});
store.dispatch('confirmUpdate', bookId);
},
// 从书架移除
removeBook(book) {
wx.showModal({
title: '提示',
content: `确认从书架移除 ${book.title} ?`,
success(res) {
if (res.confirm) {
store.dispatch('removeFromBookCase', book._id);
}
}
});
},
},
// 下拉刷新的时候查询有无更新
async onPullDownRefresh() {
this.refreshing = true;
const ids = this.bookList.map(book => book._id) || [];
await store.dispatch('fetchBookUpdate', ids.join(','));
this.refreshing = false;
wx.stopPullDownRefresh();
},
onLoad() {
store.dispatch('getBookCase');
setTimeout(() => {
if (this.bookList && this.bookList.length >= 1) {
wx.startPullDownRefresh();
}
}, 500);
}
};
</script>
搜索历史
<template>
<div class="history-page">
<!-- 搜索 -->
<SearchBar readonly @click="gotoSearchPage()"/>
<!-- 其他内容 -->
<div class="history-container">
<div class="history-title">
<span>大家都在搜</span>
<span @click="changeHotWords"><i class="iconfont icon-refresh" /> 换一批</span>
</div>
<ul class="history-hotwords">
<li v-for="(item, index) in hotWords" :key="index" @click="gotoSearchPage(item.word)">{{item.word}}</li>
</ul>
<div class="history-title">
<span>搜索历史</span>
<span @click="clearSearchHistory"><i class="iconfont icon-trash" /> 清 空</span>
</div>
<ul class="history-list">
<li v-for="(item, index) in historyWords" :key="index" @click="gotoSearchPage(item)"><i class="iconfont icon-history"></i> {{item}}</li>
</ul>
</div>
</div>
</template>
详情页
<template>
<div class="detail-page">
<div class="detail-header">
<image class="book-cover" mode="aspectFill" :src="bookDetailsConver" />
<div class="detail-header__inner">
<h5>{{bookDetails.title}}</h5>
<p class="p2">
<em @click="gotoSearchPage(bookDetails.author)">{{bookDetails.author}}</em>
<span v-if="bookDetails.minorCate"> | {{bookDetails.minorCate}}</span>
<span> | {{wordCount}}字</span>
</p>
<p class="p3">{{updatedTime}}</p>
</div>
<div class="detail-header__cooperation">
<button @click="gotoChapterPage(bookDetails._id)">开始阅读</button>
<button @click="afterMore(bookDetails._id)" v-if="!isAddToBookCase">+追更新</button>
</div>
</div>
<ul class="detail-staes">
<li>
<p>追更人数</p>
<span>{{bookDetails.latelyFollower}}</span>
</li>
<li>
<p>读者存留率</p>
<span>{{bookDetails.retentionRatio}}%</span>
</li>
<li>
<p>日更新字数</p>
<span>{{bookDetails.serializeWordCount}}</span>
</li>
</ul>
<div class="detail-introduce" v-if="bookDetails.longIntro">
<h5>简介</h5>
<div class="container">
<rich-text :nodes="longIntroConver"/>
</div>
</div>
<div class="detail-likes" v-if="booksLike.length > 0">
<h5>猜你喜欢</h5>
<scroll-view scroll-x style="width: 100%">
<div class="like-item" @click="gotoDetailsPage(book._id)" v-for="(book, index) in booksLikeConver" :key="index">
<image lazy-load class="book-cover" mode="aspectFill" :src="book.cover" />
<p>{{book.title}}</p>
</div>
</scroll-view>
</div>
</div>
</template>
<script>
import store from '@/store';
import { getImgSrc, formatTime } from '@/utils';
export default {
data() {
return {
a: 1
};
},
methods: {
getImgSrc,
formatTime,
// 跳转搜索页面
gotoSearchPage(query = '') {
wx.navigateTo({
url: `/pages/search/main?search=${query}`
});
},
// 跳转章节页
gotoChapterPage(bookId = '') {
wx.navigateTo({
url: `/pages/chapter/main?bookId=${bookId}`
});
},
// 跳转详情页,同一个页面不能跳转?
// 用的同一实例?
// 数据会变掉?
gotoDetailsPage(bookId) {
if (bookId) {
wx.navigateTo({
url: `/pages/details/main?bookId=${bookId}`
});
}
},
// 获取详情及推荐
getBookDetails(bookId) {
store.dispatch('fetchBookDetails', bookId);
store.dispatch('fetchBookLikes', bookId);
},
// 追更,加入书柜
async afterMore(bookId) {
// 获取书源
const sources = await store.dispatch('fetchBookSource', bookId);
// 默认第一个书源,查章节目录
if (Array.isArray(sources) && sources.length > 0) {
const currentSource = sources[0];
const { chapters } = await store.dispatch('fetchChapterList', currentSource._id);
// 默认第一章节
if (Array.isArray(chapters) && chapters.length > 0) {
const currentChapter = chapters[0];
store.dispatch('addToBookCase', {
...this.bookDetails,
currentSource,
currentChapter
});
wx.showToast({
title: '已添加至书架',
icon: 'success',
duration: 1500
});
}
}
}
},
computed: {
// 书籍详情
bookDetails() {
return store.state.bookDetails;
},
// 猜你喜欢
booksLike() {
return store.state.booksLike;
},
// 字数
wordCount() {
const count = this.bookDetails.wordCount;
if (count > 10000) {
return `${Math.ceil(count / 10000)}万`;
}
return count;
},
// 更新时间
updatedTime() {
return formatTime(this.bookDetails.updated);
},
// 封面转过之后的数据(mpvue不支持在 template 内使用 methods 中的函数,尴尬)
bookDetailsConver() {
return getImgSrc(this.bookDetails.cover);
},
// 猜你喜欢数据封面转换
booksLikeConver() {
return this.booksLike.map(book => ({
...book,
cover: getImgSrc(book.cover)
}));
},
// 简介转换
longIntroConver() {
try {
// 暂时想不到其他法子,先把\n|\r转成标签
return (this.bookDetails.longIntro || '').replace(/\n|\r/g, ''
);
} catch (e) {
return '';
}
},
// 书架
bookCase() {
return store.state.bookCase;
},
// 是否被加入到书架了
isAddToBookCase() {
return this.bookCase.findIndex(book => book._id === this.bookDetails._id) !== -1;
}
},
onLoad() {
const { bookId } = this.$root.$mp.query;
if (bookId) {
this.getBookDetails(bookId);
}
// 如果没有书架数据的话,就去获取
if (this.bookCase.length === 0) {
store.dispatch('getBookCase');
}
},
// 分享
onShareAppMessage() {
return {
title: `好友向你推荐了一本好书——${this.bookDetails.title}`,
path: `/pages/details/main?bookId=${this.bookDetails._id}`,
imageUrl: this.bookDetailsConver
};
}
};
</script>
控制中心
<template>
<div :class="['chapter-page', {nightMode: isNightMode}]">
<rich-text class="chapter-content" @click="onPageClick" :nodes="chapterDetailsConver" :style="{fontSize: chapterFontSize + 'px'}"/>
<div class="turnPage" v-show="chapterDetailsConver">
<div class="button last" @click="gotoTargeChapter(currentPageIndex - 1)" v-if="currentPageIndex > 0">上一章</div>
<div class="button next" @click="gotoTargeChapter(currentPageIndex + 1)" v-if="currentPageIndex < chaptersSectionCount - 1">下一章</div>
</div>
<!-- 底部菜单 -->
<div :class="['chapter-footbar', { show: showFooterBar }]">
<ul>
<li @click="gotoHome" v-if="fromOtherPlace">
<i class="iconfont icon-home"></i>
<span>书架</span>
</li>
<li @click="toggleNightOrDay">
<i class="iconfont icon-sun1" v-if="isNightMode"></i>
<i class="iconfont icon-moon" v-else></i>
<span>{{isNightMode? "白天" : "夜间"}}</span>
</li>
<li @click="toggleSettingPanel">
<i class="iconfont icon-setting"></i>
<span>设置</span>
</li>
<li @click="toggleCategoryList">
<i class="iconfont icon-menu"></i>
<span>目录</span>
</li>
<li @click="gotoSourcePage(chapterListDataId)">
<i class="iconfont icon-change"></i>
<span>换源</span>
</li>
</ul>
<!-- 设置面板 -->
<div class="setting-panel" v-show="showFooterBar && showSettingPanel">
<!-- 亮度调节面板 -->
<div class="lightness setting-panel-normal">
<i class="iconfont icon-sun"></i>
<slider min="0" max="1" :value="lightness" step="0.05" color="#f5f5f5" activeColor="#4393e2" block-size="14" @changing="changeLightNess" @change="changeLightNess"/>
<div >
<span style="margin-right: 10px;">常亮</span>
<switch type="checkbox" :checked="isKeepLight" @change="toggleScreenLight"></switch>
</div>
</div>
<!-- 字体大小调节面板 -->
<div class="font-size setting-panel-normal">
<i class="iconfont icon-ziti"></i>
<slider min="12" max="30" :value="chapterFontSize" step="1" color="#f5f5f5" activeColor="#4393e2" block-size="14" @change="changeFontSize"/>
</div>
</div>
</div>
<!-- 目录 -->
<section :class="['chapter-picker', { showDirectory }]" @click="toggleCategoryList">
<header @click.stop>
<h3>目录(共{{chaptersSectionCount}}章)</h3>
<picker :range="chapterSectionArray" @change="chapterSectionArrayChange" :value="currentChapterSectionIndex">
<span class="picker">{{chapterSectionArray[currentChapterSectionIndex]}} <i class="iconfont icon-dropDown"></i></span>
</picker>
</header>
<ul @click.stop>
<li
:key="item.id"
:class="{active: (currentChapterSectionIndex * CHAPTER_SECTION_COUNT) + index === currentPageIndex}"
v-for="(item, index) in currentChapterSection"
@click="gotoTargeChapterFromItem(item, index)"
>{{item.title}}</li>
</ul>
<button>关闭</button>
</section>
</div>
</template>
<script>
// /pages/chapter/main?from=share&bookId=${书籍ID}&sourceId=${书源ID}&chapterIndex=${章节号}
import store from '@/store';
import isEqual from 'lodash/isEqual';
import _chunk from 'lodash/chunk';
import _get from 'lodash/get';
import { CHAPTER_FONT_SIZE, SCREEN_IS_LIGHT, CHAPTER_SECTION_COUNT } from '@/utils/constants';
import { keepUsefulAttributeInArray, getImgSrc } from '@/utils';
// 临时变量,书源
let _currentSource = null;
// 临时变量,保存页面onHide之前的数据,与onShow之后做对比,如果一直就不重新获取
let _lastChapter = null;
// 保存章节数据,不放在data中,防止数据过大,造成卡顿
let _chapterListData = {
chapters: []
};
// 保存章节分段数据
let _chaptersSection = [];
export default {
data() {
return {
CHAPTER_SECTION_COUNT,
fromOtherPlace: false, // 是否从其他地方跳转过来
showDirectory: false, // 是否展示目录
showFooterBar: false, // 是否展示底部菜单
isNightMode: false, // 是否是夜间模式
currentPageIndex: 0, // 当前查看第几章
bookInBookCase: null, // 书籍对应的书架的内容
lightness: 0, // 亮度
showSettingPanel: false, // 是否展示设置面板
chapterFontSize: 14, // 默认字体大小
isKeepLight: false, // 屏幕是否保持常亮
currentChapterSectionIndex: 0, // 当前处在章节分段第几段
chapterListDataId: 0, // 章节ID
chaptersSectionCount: 0, // 章节数量
currentChapterSection: [], // 当前查看的章节段落
};
},
computed: {
// 书架内容
bookCase() {
return store.state.bookCase;
},
// 书籍详情
bookDetails() {
return store.state.bookDetails;
},
// 书籍对应的书架的内容
bookInBookCase() {
const { bookId } = this.$root.$mp.query;
return this.bookCase.find(book => book._id === bookId);
},
// 书源
sourceList() {
return store.state.sourceList;
},
// 章节内容
chapterDetails() {
return store.state.chapterDetails;
},
// 章节内容转换
chapterDetailsConver() {
try {
// 暂时想不到其他法子,先把\n|\r转成标签
return (this.chapterDetails.cpContent || this.chapterDetails.body).replace(/\n|\r/g, ''
);
} catch (e) {
return '';
}
},
// 章节目录分段范围
// ['1-100','101-200',....]
chapterSectionArray() {
const totalArrayCount = Math.ceil(this.chaptersSectionCount / CHAPTER_SECTION_COUNT);
return Array.from({ length: totalArrayCount }).map((section, index) => `${(index * CHAPTER_SECTION_COUNT) + 1} - ${Math.min((index + 1) * CHAPTER_SECTION_COUNT, this.chaptersSectionCount)}`);
},
},
watch: {
showFooterBar(value) {
if (!value) {
this.showSettingPanel = false;
}
},
isKeepLight(value) {
wx.setKeepScreenOn({
keepScreenOn: value
});
},
// 监听showDirectory变化,需要重新计算currentChapterSectionIndex,并还原
showDirectory(value) {
// 只在展示的时候还原
if (value) {
this.currentChapterSectionIndex = Math.floor(this.currentPageIndex / CHAPTER_SECTION_COUNT);
} else {
// 只在关闭的时候隐藏底部操作栏
this.showFooterBar = false;
}
},
// 监听当前章节段落序号,改变章节段落
currentChapterSectionIndex(value) {
this.currentChapterSection = _chaptersSection[value];
}
},
methods: {
// 回首页
gotoHome() {
wx.redirectTo({
url: '/pages/index/main'
});
},
// 跳转哪一章
gotoTargeChapter(index) {
this.showDirectory = false;
this.showFooterBar = false;
this.currentPageIndex = +index;
// 先清空章节内容
store.dispatch('resetChapterDetails');
const chapter = _chapterListData.chapters[index];
if (chapter) {
chapter._index = index;
store.dispatch('fetchChapterDetails', chapter.link).then(() => {
wx.pageScrollTo({
scrollTop: 0,
duration: 300
});
});
// 更改titleBar文案
wx.setNavigationBarTitle({
title: chapter.title
});
if (this.bookInBookCase) {
store.dispatch('addToBookCase', {
...this.bookInBookCase,
currentChapter: chapter,
__order: false // 倒序插入
});
}
}
},
gotoTargeChapterFromItem(item, index) {
// 计算将要跳往哪一章
// eslint-disable-next-line
const targetChapterIndex = (this.currentChapterSectionIndex * CHAPTER_SECTION_COUNT) + Number(index);
this.gotoTargeChapter(targetChapterIndex);
},
onPageClick() {
// 切换底部显示
this.showFooterBar = !this.showFooterBar;
},
// 切换目录
toggleCategoryList() {
this.showDirectory = !this.showDirectory;
},
// 切换夜间/白天模式
toggleNightOrDay() {
this.isNightMode = !this.isNightMode;
},
// 切换设置面板
toggleSettingPanel() {
this.showSettingPanel = !this.showSettingPanel;
},
// 跳转换源页面
gotoSourcePage(bookId) {
this.showFooterBar = false;
// 如果没有加入书架,不允许换源
if (!this.bookInBookCase) {
wx.showToast({
title: '加入书架才能换源哦~',
icon: 'none',
duration: 2000
});
} else {
wx.navigateTo({
url: `/pages/source/main?bookId=${bookId}`
});
}
},
// 改变亮度
changeLightNess(e) {
const { value } = e.target;
if (typeof value === 'number') {
this.lightness = value;
wx.setScreenBrightness({ value });
}
},
// 改变字体大小
changeFontSize(e) {
const { value } = e.target;
if (typeof value === 'number') {
this.chapterFontSize = value;
wx.setStorage({
key: CHAPTER_FONT_SIZE,
data: value
});
}
},
// 保持屏幕常亮
toggleScreenLight(e) {
const { value } = e.target;
this.isKeepLight = value;
wx.setStorage({
key: SCREEN_IS_LIGHT,
data: value
});
},
// 章节序列切换区间
chapterSectionArrayChange(e) {
this.currentChapterSectionIndex = e.target.value;
}
},
onLoad() {
// 更改titleBar标题
wx.setNavigationBarColor({
frontColor: '#ffffff',
backgroundColor: '#333333',
animation: {
duration: 150,
timingFunc: 'easeIn'
}
});
// 获取屏幕亮度
wx.getScreenBrightness({
success: ({ value }) => {
if (typeof value === 'number') {
this.lightness = value;
}
}
});
// 是否常亮
wx.getStorage({
key: SCREEN_IS_LIGHT,
success: ({ data }) => {
this.isKeepLight = data;
}
});
// 获取之前设置的字体大小
wx.getStorage({
key: CHAPTER_FONT_SIZE,
success: ({ data }) => {
this.chapterFontSize = data;
}
});
// 获取书架数据
store.dispatch('getBookCase');
const { from } = this.$root.$mp.query;
// 如果是从其他地方跳转过来的,比如,分享页
if (from) {
this.fromOtherPlace = true;
}
},
onShow() {
const {
bookId,
sourceId,
chapterIndex,
from
} = this.$root.$mp.query;
this.bookInBookCase = this.bookCase.find(book => book._id === bookId);
const bookInBookCase = this.bookInBookCase; // eslint-disable-line
// 如果重新进来的时候,当前小说是否跟hide之前一致的,一致就不重新获取
// 什么时候不一致?当更改书源的时候
if (_lastChapter && isEqual(bookInBookCase, _lastChapter)) {
return;
}
_lastChapter = bookInBookCase;
if (bookId) {
store.dispatch('fetchBookSource', bookId)
.then((result) => {
// 默认拿第一个书源
let source = result[0];
// 如果是从分享页进来的
if (from === 'share' && sourceId) {
source = result.find(r => r._id === sourceId) || source;
} else if (bookInBookCase && bookInBookCase.currentSource) {
source = bookInBookCase.currentSource;
}
// 只在当前页面临时保存书源
_currentSource = source;
return store.dispatch('fetchChapterList', source._id);
}).then((result) => {
_chapterListData = result;
// 只保留必要的属性,减少数据量
_chapterListData.chapters = keepUsefulAttributeInArray(_chapterListData.chapters, ['_index', 'link', 'title', 'id']);
// 给章节分段
_chaptersSection = _chunk(_chapterListData.chapters, CHAPTER_SECTION_COUNT);
this.currentChapterSection = _chaptersSection[this.currentChapterSectionIndex];
// 章节ID
this.chapterListDataId = _chapterListData._id;
// 章节数量
this.chaptersSectionCount = _chapterListData.chapters.length;
// 默认第一章
let chapter = result.chapters[0];
/* eslint-disable */
// 如果是从分享页进来的
if (from === 'share' && chapterIndex) {
this.currentPageIndex = +chapterIndex;
chapter = result.chapters[+chapterIndex];
}
// 如果已收藏,本地有数据的话
else if (bookInBookCase && bookInBookCase.currentChapter && bookInBookCase.currentChapter._index) {
let { _index } = bookInBookCase.currentChapter;
// 如果之前的章节比现在书源的总章节还要大, 则最大不能超过该书源的总章节
if (_index > result.chapters.length - 1) {
_index = result.chapters.length - 1;
}
this.currentPageIndex = _index;
chapter = result.chapters[_index];
}
/* eslint-enable */
// 更改titleBar文案
wx.setNavigationBarTitle({
title: chapter.title
});
store.dispatch('fetchChapterDetails', chapter.link);
});
}
},
onHide() {
this.showDirectory = false;
this.showFooterBar = false;
},
// 分享
onShareAppMessage() {
const {
title,
_id,
cover
} = this.bookInBookCase || this.bookDetails;
let chapterTitle = _get(this.chapterDetails, 'title');
// 有的小说获取到的title是字符串的点.,此时拿书架保存的当前章节的title
if (chapterTitle.length <= 1) {
chapterTitle = _get(_chapterListData.chapters[this.currentPageIndex], 'title');
}
return {
title: `${title} - ${chapterTitle}——Deny阅读`,
path: `/pages/chapter/main?from=share&bookId=${_id}&sourceId=${_currentSource._id}&chapterIndex=${this.currentPageIndex}`,
imageUrl: getImgSrc(cover)
};
},
onUnload() {
this.showDirectory = false;
this.showFooterBar = false;
this.currentPageIndex = 0;
this.currentChapterSectionIndex = 0;
_currentSource = null;
_lastChapter = null;
}
};
</script>
npm start # 启动
npm run build # 打包
如果本项目对您有帮助,欢迎 “点赞,关注” 支持一下 谢谢~
源码获取关注公众号「码农园区」,回复 【uniapp源码】