四零语境前端代码仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

740 lines
25 KiB

<template>
<view class="yingbing-reader" ref="yingbingReader" @touchstart="touchstart" @touchmove="touchmove" @touchend="touchend">
<view class="yingbing-scroll-reader" :class="{'yingbing-hidden': pageType != 'scroll'}" :style="[wrapperStyle]">
<reader-header :progress="currentProgress" :title="currentTitle" v-if="headerShow && currentTitle"></reader-header>
<view class="yingbing-scroll yingbing-reader-content" ref="yingbingReaderContent">
<reader-scroller
ref="scroll"
:autoplay="autoplaySync"
:loadmoreable="!isLoading && !autoplaySync && pages.length > 0"
:pulldownable="!isLoading && !autoplaySync"
@pulldown="handlePulldown" @loadmore="handlePullup" @scroll="handleScroll" :data="pageType == 'scroll' ? pages : []">
<!-- #ifndef MP-->
<template v-slot="{item, index}">
<!-- #endif -->
<!-- #ifdef MP -->
<template v-for="(item, index) in pages" :slot="`wx:${index}`">
<!-- #endif -->
<view :style="{position: 'relative', 'height': contentHeight + 'px'}" v-if="item.type == 'slot' && pageType == 'scroll'">
<!-- 微信小程序vue2虽然支持在v-for中嵌套同名插槽,但会一直报错,引起页面卡顿,vue3不支持在v-for中嵌套同名插槽,所以增加2种微信小程序使用方式 -->
<!-- #ifdef MP -->
<slot :name="item.content + ':' + item.index"></slot>
<!-- #endif -->
<!-- #ifndef MP -->
<slot :name="item.content" :item="item" :index="index"></slot>
<!-- #endif -->
</view>
<read-content :item="item" v-if="item.type == 'text'"></read-content>
<view class="yingbing-reader-content-loading" :style="{'height': contentHeight + 'px'}" v-if="item.type == 'error'" @touchstart.stop.prevent @touchmove.stop.prevent @touchend.stop.prevent="handleReload(item)">
<text class="yingbing-reader-content-loading-text" :style="{color: color}">{{errorText}}</text>
</view>
<view class="yingbing-reader-content-loading" :style="{'height': contentHeight + 'px'}" v-if="item.type == 'loading'">
<reader-loading size="20px" :color="color || ''"></reader-loading>
<text class="yingbing-reader-content-loading-text" :style="{color: color}">{{loadingText}}</text>
</view>
</template>
</reader-scroller>
</view>
<reader-footer ref="footer" :total="currentTotal" :current="currentPage" v-if="footerShow && currentTotal"></reader-footer>
</view>
<yingbing-flip
:class="{'yingbing-hidden': pageType == 'scroll'}"
class="yingbing-flip-reader yingbing-reader-absolute"
ref="flip"
:autoplay="autoplaySync"
:interval="interval"
:current="current"
:type="pageType"
:data="pageType != 'scroll' ? pages : []"
:background="background"
:duration="300"
:unableClickPage="unableClickPage"
:pulldownable="!isLoading"
:pullupable="!isLoading"
@change="handleChange"
@pulldown="handlePulldown"
@pullup="handlePullup">
<!-- #ifndef MP-->
<template v-slot="{item, index}">
<!-- #endif -->
<!-- #ifdef MP -->
<template v-for="(item, index) in pages" :slot="`wx:${index}`">
<!-- #endif -->
<view class="yingbing-reader-absolute yingbing-flip-reader-wrapper" :style="[wrapperStyle]">
<reader-header :progress="item.progress" :title="item.title || title" v-if="getFlipHeaderShow(item)"></reader-header>
<view class="yingbing-flip-reader-content">
<template v-if="item.type == 'slot' && pageType != 'scroll'">
<!-- 微信小程序vue2虽然支持在v-for中嵌套同名插槽,但会一直报错,引起页面卡顿,vue3不支持在v-for中嵌套同名插槽,所以增加2种微信小程序使用方式 -->
<!-- #ifdef MP -->
<slot :name="item.content + ':' + item.index"></slot>
<!-- #endif -->
<!-- #ifndef MP -->
<slot :name="item.content" :item="item" :index="index"></slot>
<!-- #endif -->
</template>
<read-content class="yingbing-reader-absolute" :item="item" v-if="item.type == 'text'"></read-content>
<view class="yingbing-reader-absolute yingbing-reader-content-loading" v-if="item.type == 'error'" @touchstart.stop.prevent @touchmove.stop.prevent @touchend.stop.prevent="handleReload(item)">
<text class="yingbing-reader-content-loading-text" :style="{color: color}">{{errorText}}</text>
</view>
<view class="yingbing-reader-absolute yingbing-reader-content-loading" v-if="item.type == 'loading'">
<reader-loading size="20px" :color="color || ''"></reader-loading>
<text class="yingbing-reader-content-loading-text" :style="{color: color}">{{loadingText}}</text>
</view>
</view>
<reader-footer :total="item.total" :current="item.current" v-if="getFlipFooterShow(item)"></reader-footer>
</view>
</template>
<template #pulldownDefault>
<view class="loading-box">
<text class="loading-text">{{isPulldownEnd ? prevChapterEndText.split('').join('\n') : chapterLoading ? chapterLoadingText.split('').join('\n') : prevChapterDefaultText.split('').join('\n') }}</text>
</view>
</template>
<template #pulldownReady>
<view class="loading-box">
<text class="loading-text">{{isPulldownEnd ? prevChapterEndText.split('').join('\n') : chapterLoading ? chapterLoadingText.split('').join('\n') : chapterReadyText.split('').join('\n') }}</text>
</view>
</template>
<template #pulldownLoading>
<view class="loading-box">
<text class="loading-text">{{isPulldownEnd ? prevChapterEndText.split('').join('\n') : chapterLoading ? chapterLoadingText.split('').join('\n') : chapterLoadingText.split('').join('\n') }}</text>
</view>
</template>
<template #pulldownSuccess>
<view class="loading-box">
<text class="loading-text">{{isPulldownEnd ? prevChapterEndText.split('').join('\n') : chapterLoading ? chapterLoadingText.split('').join('\n') : chapterSuccessText.split('').join('\n') }}</text>
</view>
</template>
<template #pulldownFail>
<view class="loading-box">
<text class="loading-text">{{isPulldownEnd ? prevChapterEndText.split('').join('\n') : chapterLoading ? chapterLoadingText.split('').join('\n') : chapterFailText.split('').join('\n') }}</text>
</view>
</template>
<template #pullupDefault>
<view class="loading-box">
<text class="loading-text">{{isPullupEnd ? nextChapterEndText.split('').join('\n') : chapterLoading ? chapterLoadingText.split('').join('\n') : nextChapterDefaultText.split('').join('\n') }}</text>
</view>
</template>
<template #pullupReady>
<view class="loading-box">
<text class="loading-text">{{isPullupEnd ? nextChapterEndText.split('').join('\n') : chapterLoading ? chapterLoadingText.split('').join('\n') : chapterReadyText.split('').join('\n') }}</text>
</view>
</template>
<template #pullupLoading>
<view class="loading-box">
<text class="loading-text">{{isPullupEnd ? nextChapterEndText.split('').join('\n') : chapterLoading ? chapterLoadingText.split('').join('\n') : chapterLoadingText.split('').join('\n') }}</text>
</view>
</template>
<template #pullupSuccess>
<view class="loading-box">
<text class="loading-text">{{isPullupEnd ? nextChapterEndText.split('').join('\n') : chapterLoading ? chapterLoadingText.split('').join('\n') : chapterSuccessText.split('').join('\n') }}</text>
</view>
</template>
<template #pullupFail>
<view class="loading-box">
<text class="loading-text">{{isPullupEnd ? nextChapterEndText.split('').join('\n') : chapterLoading ? chapterLoadingText.split('').join('\n') : chapterFailText.split('').join('\n') }}</text>
</view>
</template>
</yingbing-flip>
<!-- 循环computed计算组件,避免计算冲突 -->
<computed
:window-width="windowWidth"
:window-height="windowHeight"
ref="computed" v-for="(c, i) in computeds"
:key="c"></computed>
</view>
</template>
<script>
import Computed from './computed.vue'
import ReadContent from './content.vue'
import ReaderLoading from '../loading/loading.vue'
import ReaderHeader from '../header/header.vue'
import ReaderFooter from '../footer/footer.vue'
import ReaderScroller from '../scroller/scroller.vue'
import FlipReaderMixin from './flip-reader.js'
import ScrollReaderMixin from './scroll-reader.js'
import TouchClickMixin from '../mixin/touch-click.js'
import TipMixin from '../mixin/tip.js'
const threshold = 50//点击事件阙值
export default {
options: {
addGlobalClass: true,
virtualHost: true, // 将自定义节点设置成虚拟的,更加接近Vue组件的表现。我们不希望自定义组件的这个节点本身可以设置样式、响应 flex 布局等,而是希望自定义组件内部的第一层节点能够响应 flex 布局或者样式由自定义组件本身完全决定
},
mixins: [FlipReaderMixin, ScrollReaderMixin, TouchClickMixin, TipMixin],
components: {
ReadContent,
ReaderHeader,
ReaderFooter,
ReaderLoading,
ReaderScroller,
Computed
},
provide () {
return {
getColor: () => this.color,
getFontSize: () => this.fontSize,
getFontFamily: () => this.fontFamily,
getLineGap: () => this.lineGap,
getTopGap: () => this.topGap,
getBottomGap: () => this.bottomGap,
getSlide: () => this.slide,
getHeaderShow: () => this.headerShow,
getFooterShow: () => this.footerShow,
getBackShow: () => this.backShow,
getSplit: () => this.split,
getTotalChapter: () => this.totalChapter,
getSelectable: () => this.selectable,
getPageType: () => this.pageType,
getMeasureSize: () => this.measureSize,
getCharSize: () => this.charSize
}
},
props: {
//自动翻页/滚动
autoplay: {
type: Boolean,
default: false
},
//自动翻页/滚动周期
interval: {
type: [String, Number],
default: 5000
},
//字体颜色
color: {
type: String,
default: '#333333'
},
//字体大小(单位px)
fontSize: {
type: [String, Number],
default: 15
},
//行间距(单位px)
lineGap: {
type: [Number, String],
default: 15
},
//页面左右边距(单位px)
slide: {
type: [Number, String],
default: 20
},
//页面上边距(单位px)
topGap: {
type: [Number, String],
default: 10
},
//页面下边距(单位px)
bottomGap: {
type: [Number, String],
default: 10
},
//展示头部
headerShow: {
type: Boolean,
default: true
},
//展示底部
footerShow: {
type: Boolean,
default: true
},
//展示返回图标
backShow: {
type: Boolean,
default: false
},
//总章节数量 用于计算进度
totalChapter: {
type: [Number, String],
default: 0
},
//字体名称
fontFamily: {
type: String,
default: 'Arial'
},
//背景颜色
background: {
type: String,
default: '#fcd281'
},
//开启预加载
preloadable: {
type: Boolean,
default: false
},
//开启文本选择
selectable: {
type: Boolean,
default: false
},
//还剩多少页时,开始加载下一章节
loadPageNum: {
type: [Number, String],
default: 4
},
//是否关闭点击左右2侧位置翻页
unableClickPage: {
type: Boolean,
default: false
},
//翻页方式
pageType: {
type: String,
default: 'scroll'
},
//分隔符
split: {
type: String,
default: ''
},
//字符尺寸
measureSize: {
type: Object,
default () {
return new Object
}
},
//指定字符尺寸集合
charSize: {
type: Array,
default () {
return new Array
}
}
},
computed: {
wrapperStyle () {
return {
'padding-top': this.topGap + 'px',
'padding-bottom': this.bottomGap + 'px',
'background': this.background,
'padding-left': this.slide + 'px',
'padding-right': this.slide + 'px'
}
},
//是否正在初始化或者跳转章节内容
isLoading () {
return this.pages.some(p => p.type == 'loading')
},
//是否渲染到第一章
isPulldownEnd () {
return this.pages.findIndex(c => c.isStart) > -1
},
//是否渲染到最后一章
isPullupEnd () {
return this.pages.findIndex(c => c.isEnd) > -1
}
},
data () {
return {
computeds: [],//计算集合,为了避免计算冲突
pages: [],//渲染页面集合
chapters: [],//章节列表集合
chapterLoading: false,//章节请求loading
current: 0,//当前页面索引
title: '',//小说标题
contentHeight: 0//内容区域高度
}
},
beforeDestroy () {
this.abort()
uni.$off('yingbing-reader-back')//清楚back监听
},
beforeCreate() {
uni.$on('yingbing-reader-back', () => {
this.$emit('back')
})
},
methods: {
//翻往上一页
prev () {
if ( this.current <= 0 ) {
uni.showToast({
title: '前面没有了',
icon: 'none'
})
return
}
this.$refs.scroll && this.$refs.scroll.scrollToIndex(this.current - 1, true)//滚动模式
this.$refs.flip && this.$refs.flip.flipToPrev()//翻页模式
},
//翻往下一页
next () {
if ( this.current >= this.pages.length - 1 ) {
uni.showToast({
title: '后面没有了',
icon: 'none'
})
return
}
this.$refs.scroll && this.$refs.scroll.scrollToIndex(this.current + 1, true)//滚动模式
this.$refs.flip && this.$refs.flip.flipToNext()//翻页模式
},
//页面刷新
refresh () {
this.abort()
if ( this.pages.length == 0 ) return//如果页面数量为0不做操作
const page = this.pages[this.current]//当前页面
const current = page.index//当前页面章节索引
const start = page.start//当前页面开始位置
this.refreshTimer = setTimeout(() => {
this.render({current, start})//调用change事件
}, 300)
},
//初始化
init ({chapters, title, current, start = 0}) {
if ( !chapters || chapters.length == 0 ) {
uni.showToast({
title: '至少传入一个章节内容',
icon: 'none'
})
return
}
this.abort()
this.autoplaySync = false//关闭自动播放
this.title = title || ''//存储小说标题
this.chapters = chapters//存储传入的章节内容
// this.pages = [{index: current, type: 'loading'}]//显示loading
setTimeout(() => {
const chapterIndex = this.chapters.findIndex(c => c.index == current)
if ( chapterIndex == -1 ) current = this.chapters[0].index//强制转换类型为int
this.render({current, start})
}, 20)
},
//跳转章节
change({chapters, current, start = 0}) {
if ( !current ) {//current必、传
uni.showToast({
title: 'current必传',
icon: 'none'
})
return
}
this.abort()
this.autoplaySync = false//关闭自动播放
if ( chapters && chapters.length > 0 ) {//如果传入章节内容
chapters.forEach(chapter => {
const index = this.chapters.findIndex(c => c.index == chapter.index)//是否已经包含相同章节
if (index > -1) this.chapters[index] = chapter//如果包含则更新
else this.chapters.push(chapter)//否则添加新章节
})
}
setTimeout(() => {
const chapterIndex = this.chapters.findIndex(c => c.index == current)//查找对应current的章节内容
if ( chapterIndex > -1 ) this.render({current, start})//如果过包含开始渲染内容
else this.handleChangeLoadmore(current, start)//如果不包含,抛出loadmore事件,去获取内容
}, 20)
},
async render (data) {
const rect = await this.getRect()//获取组件尺寸
this.windowWidth = rect.width//记录组件宽度
this.windowHeight = rect.height//记录组件高度
const contentRect = await this.getContentRect()//获取内容尺寸
this.contentHeight = contentRect.height//记录内容区域高度
if ( this.pageType == 'scroll' ) this.scrollRender(data)
else this.flipRender(data)
},
//重加载事件
handleReload (item) {
this.pages = [{
type: 'loading',
index: item.index,
start: item.start || undefined//记录章节定位
}]//显示刷新效果
this.handleChangeLoadmore(item.index, item.start)//触发loadmore
},
//change事件触发loadmore加载更多章节并渲染
handleChangeLoadmore (index, start) {
this.loadmoreFunc = (status, chapter) => {
//请求完成后删除请求集合中的章节索引
if (status == 'success') {//获取内容成功
this.render({chapter, current: index, start})
} else {
this.pages = [{
type: 'error',
index: index,
start: start || undefined//记录章节定位
}]//显示错误页面
this.current = 0
this.$refs.flip && this.$refs.flip.refresh()
}
}
this.$emit('loadmore', index, this.loadmoreFunc.bind(this))
},
//翻页改变事件
handleChange (e) {
this.current = e.current//记录当前定位
this.$emit('change', e)//抛出change事件
if ( e.current >= this.pages.length - this.loadPageNum && !this.chapterLoading ) {//如果翻到了最后loadPageNum页,并且没有加载章节
const page = this.pages[this.pages.length-1]//回去最后页面
if ( page.isEnd ) return
const index = page.index + 1//
const chapterIndex = this.chapters.findIndex(c => c.index == index)//查找对应index的章节内容
if ( chapterIndex > -1 ) {//如果过包含开始渲染内容
this.chapterLoading = true//开启章节加载等待
this.handleLoadRender('success', this.chapters[chapterIndex])//渲染章节
} else {
if ( this.preloadable ) {//开启了预加载功能
this.chapterLoading = true//开启章节加载等待
this.loadmoreFunc = (status, chapter) => {//如果不包含,抛出loadmore事件,去获取内容
this.handleLoadRender(status, chapter)//渲染章节
}
this.$emit('loadmore', index, this.loadmoreFunc.bind(this))
}
}
}
},
//渲染下一章节
handleLoadRender (status, chapter, callback, isPrev) {
if ( this.pageType == 'scroll' ) this.handleScrollLoadRender(status, chapter, callback, isPrev)
else this.handleFlipLoadRender(status, chapter, callback, isPrev)
},
//上拉事件
handlePullup (callback) {
// if ( this.isPullupEnd || this.chapterLoading || this.isLoading || this.current < this.pages.length - 1 ) {//如果最后页面是结束章节或者当前正在等待加载新章节或者当前页面不是最后一页则返回
// callback( this.isPullupEnd && this.pageType == 'scroll' ? 'end' : 'success')//如果是滚动模式并且章节全部加载关闭则返回end否则success
// return
// }
if ( this.pageType == 'scroll' ) {
if ( this.isPullupEnd || this.chapterLoading || this.isLoading ) {
callback(this.isPullupEnd ? 'end' : 'ready')
return
}
} else {
if ( this.isPullupEnd || this.chapterLoading || this.isLoading || this.current < this.pages.length - 1 ) {//如果最后页面是结束章节或者当前正在等待加载新章节或者当前页面不是最后一页则返回
callback('success')//如果是滚动模式并且章节全部加载关闭则返回end否则success
return
}
}
const index = this.pages[this.pages.length-1].index + 1
const chapterIndex = this.chapters.findIndex(c => c.index == index)//查找对应index的章节内容
this.chapterLoading = true//开启章节加载等待
if ( chapterIndex > -1 ) {//如果过包含开始渲染内容
this.handleLoadRender('success', this.chapters[chapterIndex], callback)//渲染章节
} else {
this.loadmoreFunc = (status, chapter) => {//如果不包含,抛出loadmore事件,去获取内容
this.handleLoadRender(status, chapter, callback)//渲染章节
}
this.$emit('loadmore', index, this.loadmoreFunc.bind(this))
}
},
//下拉事件
handlePulldown (callback) {
if ( this.isPulldownEnd || this.chapterLoading || this.isLoading ) {
callback( this.isPullupEnd && this.pageType == 'scroll' ? 'end' : 'success')//如果是滚动模式并且章节全部加载关闭则返回end否则success
return
}
const index = this.pages[0].index - 1
const chapterIndex = this.chapters.findIndex(c => c.index == index)//查找对应index的章节内容
this.chapterLoading = true//开启章节加载等待
if ( chapterIndex > -1 ) {//如果过包含开始渲染内容
this.handleLoadRender('success', this.chapters[chapterIndex], true)//渲染章节
} else {
this.loadmoreFunc = (status, chapter) => {//如果不包含,抛出loadmore事件,去获取内容
this.handleLoadRender(status, chapter, callback, true)//渲染章节
}
this.$emit('loadmore', index, this.loadmoreFunc)
}
},
//处理页面
handlePages (arr) {
arr = arr.filter(item => item.type != 'loading')//去掉加载页
arr.sort((a, b) => a.index - b.index)//根据章节索引排序
return arr
},
//清楚刷新定时器
clearRefreshTimer () {
if ( this.refreshTimer ) {
clearTimeout(this.refreshTimer)
this.refreshTimer = null
}
},
//清楚loadmore回调
clearLoadmoreFunc () {
if ( this.loadmoreFunc ) this.loadmoreFunc = null
},
//中断正在进行的计算和请求
abort () {
this.clearRefreshTimer()//清空刷新定时器
this.clearLoadmoreFunc()//清空请求回调
this.computeds = []
},
//获取计算组件id
getComputedId () {
return (new Date().getTime() / 1000).toString() + (Math.random() * 100)
},
getRect () {
return new Promise(resolve => {
// #ifdef APP-NVUE
uni.requireNativePlugin('dom').getComponentRect(this.$refs.yingbingReader, res => {
resolve(res.size)
})
// #endif
// #ifndef APP-NVUE
uni.createSelectorQuery().in(this).select('.yingbing-reader').boundingClientRect(data => {
resolve(data)
}).exec();
// #endif
})
},
//获取内容区域高度
getContentRect () {
return new Promise(resolve => {
// #ifdef APP-NVUE
uni.requireNativePlugin('dom').getComponentRect(this.$refs.yingbingReaderContent, res => {
resolve(res.size)
})
// #endif
// #ifndef APP-NVUE
uni.createSelectorQuery().in(this).select('.yingbing-reader-content').boundingClientRect(data => {
resolve(data)
}).exec();
// #endif
})
}
},
watch: {
pageType (newVal, oldVal) {
if ( newVal == 'scroll' ) {//当翻页模式变为滚动模式时
this.autoplaySync = false//暂时关闭自动播放
this.$nextTick(function () {
this.$refs.scroll && this.$refs.scroll.scrollToIndex(this.current)//定位滚动位置
setTimeout(() => {
this.autoplaySync = this.autoplay//开启自动播放
}, 100)
})
}
},
//颜色改变时刷新当前页面,否则APP-VUE可能不会立即生效
color () {
if ( this.$refs.flip && this.pages[this.current] ) this.$refs.flip.forceUpdate(this.current)
},
fontSize () {
this.$nextTick(function () {
this.refresh()
})
},
lineGap () {
this.$nextTick(function () {
this.refresh()
})
},
slide () {
this.$nextTick(function () {
this.refresh()
})
},
topGap () {
this.$nextTick(function () {
this.refresh()
})
},
bottomGap () {
this.$nextTick(function () {
this.refresh()
})
},
fontFamily () {
this.$nextTick(function () {
this.refresh()
})
},
autoplay (newVal) {
if ( this.pageType == 'scroll' ) {
if ( !this.isLoading ) this.autoplaySync = newVal
} else {
this.autoplaySync = newVal
}
}
}
}
</script>
<style scoped>
.yingbing-reader {
/* #ifndef APP-NVUE */
width: 100%;
height: 100%;
/* #endif */
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
position: relative;
overflow: hidden;
}
.yingbing-reader-content-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.yingbing-reader-content-loading-text {
margin-top: 10px;
font-size: 15px;
}
.yingbing-reader-content-loading-tip {
margin-top: 10px;
font-size: 12px;
}
.yingbing-hidden {
visibility: hidden;
}
/* 滚动模式样式 */
.yingbing-scroll-reader {
/* #ifndef APP-NVUE */
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
/* #endif */
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
position: relative;
}
.yingbing-scroll {
flex: 1;
position: relative;
}
/* 翻页模式样式 */
.yingbing-reader-absolute {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
.yingbing-flip-reader .loading-box {
background-color: rgba(0,0,0,.2);
align-items: center;
justify-content: center;
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
/* #ifndef APP-NVUE */
display: flex;
flex-direction: column;
height: 100%;
/* #endif */
}
.yingbing-flip-reader .loading-text {
color: #ffffff;
}
.yingbing-flip-reader-wrapper {
/* #ifndef APP-NVUE */
display: flex;
flex-direction: column;
/* #endif */
}
.yingbing-flip-reader-content {
position: relative;
flex: 1;
}
</style>