Browse Source

阅读小说功能

dev_des
zhangminghao 1 month ago
parent
commit
a09e24ddcd
  1. 31
      pages.json
  2. 71
      subPackages/other/components/battery.vue
  3. 5916
      subPackages/other/components/chapters.js
  4. 455
      subPackages/other/components/directory.js
  5. 124
      subPackages/other/components/myProgress.vue
  6. 68
      subPackages/other/components/utils.js
  7. 134
      subPackages/other/components/virtualList.vue
  8. 249
      subPackages/other/index.vue
  9. 7
      subPackages/other/ipPoster.vue
  10. 48
      subPackages/other/novelCatalog.vue
  11. 448
      subPackages/other/playNovel.vue
  12. 2322
      subPackages/other/read.vue
  13. 2
      uni.scss

31
pages.json

@ -328,6 +328,37 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "other/index",
"style": {
"navigationBarTextStyle": "white",
"navigationStyle": "custom"
}
},
{
"path": "other/read",
"style": {
"navigationBarTextStyle": "white",
"navigationStyle": "custom"
}
},
{
"path": "other/novelCatalog",
"style": {
"navigationBarTitleText": "园门修真传",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "other/playNovel",
"style": {
"navigationBarTitleText": "园门修真传",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{ {
"path": "points/index", "path": "points/index",
"style": { "style": {

71
subPackages/other/components/battery.vue

@ -0,0 +1,71 @@
<template>
<view class="battery-container">
<view class="battery-body">
<view class="battery" :style="{width: `${level}%`}"></view>
<text class="iconfont charging" v-if="charging">&#xe625;</text>
</view>
<view class="battery-head"></view>
</view>
</template>
<script>
export default {
props:{
level: {
type: Number,
default: 0
},
charging: {
type: Boolean,
default: false
}
},
data() {
return {
}
},
mounted() {
},
methods: {
}
}
</script>
<style lang="scss" scoped>
.battery-container{
display: flex;
justify-content: center;
align-items: center;
width: 25px;
height: 10px;
.battery-body{
position: relative;
padding: 1px;
width: 22px;
height: 100%;
border-radius: 1px;
border: $minor-text-color solid 1px;
.battery{
height: 100%;
background-color: $minor-text-color;
}
.charging{
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
height: 12px;
line-height: 12px;
font-size: 15px;
color: #333;
}
}
.battery-head{
width: 2px;
height: 6px;
background-color: $minor-text-color;
}
}
</style>

5916
subPackages/other/components/chapters.js

File diff suppressed because one or more lines are too long

455
subPackages/other/components/directory.js

@ -0,0 +1,455 @@
const chaptersList = [
{
index: 0,
chapterId: '1',
name: '第1章 考研失败是有原因的'
},
{
index: 1,
chapterId: '2',
name: '第2章 收服上古神兽'
},
{
index: 2,
chapterId: '3',
name: '第3章 宗门选拔'
},
{
index: 3,
chapterId: '4',
name: '第4章 成功登百阶'
},
{
index: 4,
chapterId: '5',
name: '第5章 你们求不得的我应有尽有'
},
{
index: 5,
chapterId: '6',
name: '第6章 月华果的主人居然是他?'
},
{
index: 6,
chapterId: '7',
name: '第7章 大师姐的黑暗料理'
},
{
index: 7,
chapterId: '8',
name: '第8章 有钱的无为宗'
},
{
index: 8,
chapterId: '9',
name: '第9章 天下武修第一人'
},
{
index: 9,
chapterId: '10',
name: '第10章 虽然没灵力,但我有外挂'
},
{
index: 10,
chapterId: '11',
name: '第11章 前尘往事难道破'
},
{
index: 11,
chapterId: '12',
name: '第12章 鬼艮的秘密'
},
{
index: 12,
chapterId: '13',
name: '第13章 屎中添花'
},
{
index: 13,
chapterId: '14',
name: '第14章 强盗一般的人物'
},
{
index: 14,
chapterId: '15',
name: '第15章 恶人先告状'
},
{
index: 15,
chapterId: '16',
name: '第16章 被关惩戒堂'
},
{
index: 16,
chapterId: '17',
name: '第17章 惩戒堂里的东西'
},
{
index: 17,
chapterId: '18',
name: '第18章 一举拿下魄'
},
{
index: 18,
chapterId: '19',
name: '第19章 掌门掐架'
},
{
index: 19,
chapterId: '20',
name: '第20章 无为道法'
},
{
index: 20,
chapterId: '21',
name: '第21章 他的精神力是最上乘'
},
{
index: 21,
chapterId: '22',
name: '第22章 时空回溯的能力'
},
{
index: 22,
chapterId: '23',
name: '第23章 碧波龙鳞扇'
},
{
index: 23,
chapterId: '24',
name: '第24章 萧玉林当他的贴身保镖'
},
{
index: 24,
chapterId: '25',
name: '第25章 大师姐的饭菜'
},
{
index: 25,
chapterId: '26',
name: '第26章 张景轩是登徒子'
},
{
index: 26,
chapterId: '27',
name: '第27章 言出法随,风动'
},
{
index: 27,
chapterId: '28',
name: '第28章 师父给的压力'
},
{
index: 28,
chapterId: '29',
name: '第29章 新徒比试大会(一)'
},
{
index: 29,
chapterId: '30',
name: '第30章 新徒比试大会(二)'
},
{
index: 30,
chapterId: '31',
name: '第31章 新徒比试大会(三)'
},
{
index: 31,
chapterId: '32',
name: '第32章 走后门'
},
{
index: 32,
chapterId: '33',
name: '第33章 把魄当狗遛'
},
{
index: 33,
chapterId: '34',
name: '第34章 万事不要逞强'
},
{
index: 34,
chapterId: '35',
name: '第35章 进入侯林'
},
{
index: 35,
chapterId: '36',
name: '第36章 侯林密林,第二境无觅处'
},
{
index: 36,
chapterId: '37',
name: '第37章 灵草收集完'
},
{
index: 37,
chapterId: '38',
name: '第38章 赛博武器'
},
{
index: 38,
chapterId: '39',
name: '第39章 起阵画符得心应手'
},
{
index: 39,
chapterId: '40',
name: '第40章 窥天镜异动'
},
{
index: 40,
chapterId: '41',
name: '第41章 姜思张锁仙对战'
},
{
index: 41,
chapterId: '42',
name: '第42章 有人跟踪他'
},
{
index: 42,
chapterId: '43',
name: '第43章 灵狼求救'
},
{
index: 43,
chapterId: '44',
name: '第44章 无奈当奶妈'
},
{
index: 44,
chapterId: '45',
name: '第45章 误入留园'
},
{
index: 45,
chapterId: '46',
name: '第46章 青芽决'
},
{
index: 46,
chapterId: '47',
name: '第47章 仙缘初现'
},
{
index: 47,
chapterId: '48',
name: '第48章 青木长生'
},
{
index: 48,
chapterId: '49',
name: '第49章 封印解密'
},
{
index: 49,
chapterId: '50',
name: '第50章 临时小队'
},
{
index: 50,
chapterId: '51',
name: '第51章 梦中警示'
},
{
index: 51,
chapterId: '52',
name: '第52章 封印松动'
},
{
index: 52,
chapterId: '53',
name: '第53章 血脉真相'
},
{
index: 53,
chapterId: '54',
name: '第54章 故人重逢'
},
{
index: 54,
chapterId: '55',
name: '第55章 青帝遗藏'
},
{
index: 55,
chapterId: '56',
name: '第56章 青帝归来'
},
{
index: 56,
chapterId: '57',
name: '第57章 继承'
},
{
index: 57,
chapterId: '58',
name: '第58章 月食'
},
{
index: 58,
chapterId: '59',
name: '第59章 异变'
},
{
index: 59,
chapterId: '60',
name: '第60章 躁动'
},
{
index: 60,
chapterId: '61',
name: '第61章 血池'
},
{
index: 61,
chapterId: '62',
name: '第62章 共鸣'
},
{
index: 62,
chapterId: '63',
name: '第63章 轮回'
},
{
index: 63,
chapterId: '64',
name: '第64章 囚徒'
},
{
index: 64,
chapterId: '65',
name: '第65章 分裂的同盟'
},
{
index: 65,
chapterId: '66',
name: '第66章 祭祀'
},
{
index: 66,
chapterId: '67',
name: '第67章 千毒窟救援'
},
{
index: 67,
chapterId: '68',
name: '第68章 白帝城'
},
{
index: 68,
chapterId: '69',
name: '第69章 白帝疑云'
},
{
index: 69,
chapterId: '70',
name: '第70章 白帝七卫'
},
{
index: 70,
chapterId: '71',
name: '第71章 核心'
},
{
index: 71,
chapterId: '72',
name: '第72章 平衡'
},
{
index: 72,
chapterId: '73',
name: '第73章 数据'
},
{
index: 73,
chapterId: '74',
name: '第74章 中枢密室'
},
{
index: 74,
chapterId: '75',
name: '第75章 崩塌'
},
{
index: 75,
chapterId: '76',
name: '第76章 异星迷途'
},
{
index: 76,
chapterId: '77',
name: '第77章 卫星避难所'
},
{
index: 77,
chapterId: '78',
name: '第78章 启动'
},
{
index: 78,
chapterId: '79',
name: '第79章 玄冰劫火'
},
{
index: 79,
chapterId: '80',
name: '第80章 晶劫之战'
},
{
index: 80,
chapterId: '81',
name: '第81章 归墟之秘'
},
{
index: 81,
chapterId: '82',
name: '第82章 两界之子'
},
{
index: 82,
chapterId: '83',
name: '第83章 北冥寒渊'
},
{
index: 83,
chapterId: '84',
name: '第84章 最后一块'
},
{
index: 84,
chapterId: '85',
name: '第85章 终局之战'
},
{
index: 85,
chapterId: '86',
name: '第86章 异常'
},
{
index: 86,
chapterId: '87',
name: '第87章 遗址'
},
{
index: 87,
chapterId: '88',
name: '第88章 灵晶潮汐'
},
{
index: 88,
chapterId: '89',
name: '第89章 晶界迷踪'
}
];
// 正确导出模块
export default {
chaptersList
};
// 也支持默认导出
export { chaptersList };

124
subPackages/other/components/myProgress.vue

@ -0,0 +1,124 @@
<template>
<view class="progress-container">
<view class="progress-container2" id="progress" @touchstart="touchstart" @touchend="touchend" @touchmove="touchmove">
<view class="progress-box">
<progress :percent="percent" activeColor="#000" backgroundColor="#1c1c1c" stroke-width="3"/>
</view>
<view class="ball-box" :class="{bigger: isTouch, shadow: isTouch}" :style="{left: `${percent}%`}"></view>
</view>
</view>
</template>
<script>
export default {
props:{
total: {
type: Number,
default: 1
},
index: {
type: Number,
default: 0
}
},
data() {
return {
left: 0, //
right: 0, //
isTouch: false,
// touchTimer: null, //
percent: 0,
}
},
watch:{
index() {
this.percent = this.index / this.total * 100
}
},
mounted() {
this.percent = this.index / this.total * 100
this.getLocation()
},
methods: {
getLocation() {
const query = uni.createSelectorQuery().in(this);
query.select('#progress').boundingClientRect(data => {
this.left = data.left
this.right = data.right
}).exec();
},
touchstart() {
this.isTouch = true
this.$emit('progressStart')
},
touchend(e) {
this.isTouch = false
let index = this.calcIndex(e.changedTouches[0].clientX)
this.$emit('progressEnd', index)
this.percent = index / this.total * 100
},
touchmove(e) {
// if (!this.touchTimer) {
let index = this.calcIndex(e.touches[0].clientX)
this.$emit('indexChange', index)
this.percent = index / this.total * 100
// this.touchTimer = setTimeout(() => {
// this.touchTimer = null;
// }, 100)
// }
},
/**
* 输入位置计算index
**/
calcIndex(px) {
let single = (this.right - this.left) / this.total
let index = Math.round((px - this.left) / single)
index = index < 0 ? 0 : index
index = index > this.total ? this.total : index
return index
}
}
}
</script>
<style lang="scss" scoped>
.progress-container{
padding: 0 10px;
width: 100%;
height: 100%;
.progress-container2{
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
.progress-box{
width: 100%;
}
.ball-box{
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #000;
transform: translateX(-50%);
}
.shadow{
box-shadow: 0px 0px 1px 5px rgba(#888,.4);
}
.bigger{
width: 20px;
height: 20px;
}
}
}
</style>

68
subPackages/other/components/utils.js

File diff suppressed because one or more lines are too long

134
subPackages/other/components/virtualList.vue

@ -0,0 +1,134 @@
<template>
<view class="virtual-list" style="position: relative;">
<movable-area style="position: absolute;right: 0;width: 30px;height: 100%;">
<movable-view class="action-bar-box" direction="vertical" @change="change" :y="y" :animation="false">
<view style="border-bottom: #000 solid 2px;width: 100%;"></view>
<view style="border-bottom: #000 solid 2px;width: 100%;"></view>
</movable-view>
</movable-area>
<scroll-view scroll-y="true"
:style="{
'height': scrollHeight + 'px',
'position': 'relative',
'zIndex': 1
}"
@scroll="handleScroll" :scroll-top="scrollTop" :show-scrollbar="false">
<view class="scroll-bar"
:style="{
'height': localHeight + 'px'
}"></view>
<view class="list"
:style="{
'transform': `translateY(${offset}px)`
}">
<view class="item-wrap"
v-for="(item, index) in visibleData"
:key="index">
<slot :item="item" :active="active"></slot>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
export default {
name: 'VirtualList',
props: {
// items
items: Array,
// item
remain: Number,
// item
size: Number,
//
active: Number,
// 使
scrollHeight: Number
},
data() {
return {
//
start: 0,
//
end: this.remain,
// list
offset: 0,
scrollTop: 0,
y: 0
}
},
created() {
//
this.scrollTop = this.size * this.active
},
computed: {
//
preCount() {
return Math.min(this.start, this.remain);
},
nextCount() {
return Math.min(this.items.length - this.end, this.remain);
},
// item
visibleData() {
const start = this.start - this.preCount;
const end = this.end + this.nextCount;
console.log(this.items,'this.items.slice(start, end)');
return this.items.slice(start, end);
},
localHeight() {
return this.items.length * this.size
}
},
methods: {
change(e) {
if (e.detail.source !== 'touch') {
return
}
let y = e.detail.y;
let scroll = y/(this.scrollHeight-40)*(this.localHeight-this.scrollHeight);
scroll = scroll < 0 ? 0 : scroll;
this.scrollTop = scroll;
},
handleScroll(ev) {
const scrollTop = ev.detail.scrollTop;
this.y = scrollTop/(this.localHeight-this.scrollHeight)*(this.scrollHeight-40)
//
const start = Math.floor(scrollTop / this.size)
this.start = start < 0 ? 0 : start;
//
this.end = this.start + this.remain;
//
const offset = scrollTop - (scrollTop % this.size) - this.preCount * this.size
this.offset = offset < 0 ? 0 : offset;
}
}
}
</script>
<style scoped>
.list {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.action-bar-box{
padding: 3px;
display: flex;
flex-flow: column;
justify-content: space-around;
align-items: center;
position: absolute;
right: 0;
background-color: transparent;
border-radius: 10rpx;
box-shadow: 0 0 5px #000;
width: 20px;
height: 40px;
z-index:2;
}
</style>

249
subPackages/other/index.vue

@ -0,0 +1,249 @@
// ... existing code ...
<template>
<view class="content">
<!-- 顶部导航 -->
<!-- <view class="top-nav">
<view class="back-btn" @click="goBack">返回</view>
<view class="add-book-btn" @click="addToBookshelf">+ 加入书架</view>
</view> -->
<BackButton />
<!-- 封面图 -->
<image class="cover-image" :src="showImg('/uploads/20250918/478322390dfe8befd6fb30643e1b5cb1.png')"
mode="aspectFill"></image>
<!-- 小说信息 -->
<view class="book-info">
<text class="title">{{ bookTitle }}</text>
<view class="info-row">
<text class="info-text">{{ updateInfo }} | 字数: {{ wordCount }}万字</text>
</view>
<!-- 更新进度 -->
<view class="progress-row">
<text class="progress-label">更新进度:</text>
<text class="progress-text">{{ progressText }}</text>
</view>
</view>
<!-- 分类标签 -->
<view class="tags-container">
<view class="tag-item" v-for="tag in tags" :key="tag">{{ tag }}</view>
</view>
<view class="line">
</view>
<!-- 简介 -->
<view class="intro-container">
<text class="intro-text">{{ intro }}</text>
</view>
<!-- 按钮组 -->
<view class="button-group">
<view class="read-btn" @click="startReading">
<text class="btn-icon">📖</text>
<text class="btn-text">开始阅读</text>
</view>
<view class="read-btn" @click="playAudio">
<text class="btn-icon">🎧</text>
<text class="btn-text">点击播放</text>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
bookTitle: '《园门修真传》',
updateInfo: '1天前更新',
wordCount: '20',
progressText: '第八十九章(未完结)',
tags: ['穿越玄幻', '修真进阶', '古典园林', '热血爽文'],
intro: '张锁仙意外穿越修仙世界,凭借着自己的扫地圣体本以为会在宗门大开杀戒,没想到竟被扔到山门做个杂役弟子?从此过上了钓鱼种田的不被卷生活。可偏偏宗门大会要拉他一个没有灵力的人上场看笑话?'
}
},
onLoad() {
},
methods: {
goBack() {
uni.navigateBack();
},
addToBookshelf() {
uni.showToast({
title: '已加入书架',
icon: 'success'
});
},
startReading() {
uni.navigateTo({
url: '/subPackages/other/read'
});
},
playAudio() {
uni.navigateTo({
url: '/subPackages/other/novelCatalog'
});
}
}
}
</script>
<style>
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20rpx;
background-color: #F6EBD4;
min-height: 100vh;
}
.top-nav {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 30rpx;
position: relative;
z-index: 10;
background-color: #ffffff;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
}
.back-btn {
font-size: 32rpx;
color: #333;
}
.add-book-btn {
font-size: 28rpx;
color: #e64340;
background-color: #fff;
border: 1rpx solid #e64340;
border-radius: 20rpx;
padding: 8rpx 16rpx;
}
.cover-image {
width: 300rpx;
height: 400rpx;
margin: 30rpx auto;
border-radius: 16rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.book-info {
width: 100%;
text-align: center;
padding: 20rpx 0;
}
.title {
font-size: 48rpx;
font-weight: bold;
color: #333;
line-height: 1.4;
}
.info-row {
font-size: 28rpx;
color: #666;
margin: 10rpx 0;
}
.progress-row {
display: flex;
justify-content: center;
align-items: center;
font-size: 28rpx;
color: #999;
margin-top: 10rpx;
}
.progress-label {
margin-right: 10rpx;
}
.progress-text {
color: #333;
}
.tags-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15rpx;
margin: 30rpx 0;
}
.tag-item {
background-color: #76352D;
color: #fff;
padding: 6rpx 12rpx;
border-radius: 12rpx;
font-size: 24rpx;
}
.line {
width: 100%;
height: 1rpx;
/* border: 1rpx solid #d3d3d3; */
background: #d3d3d3;
margin: 20rpx 0;
}
.intro-container {
width: 100%;
padding: 20rpx 40rpx;
/* background-color: #ffffff; */
border-radius: 16rpx;
/* box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.1); */
margin-bottom: 40rpx;
}
.intro-text {
font-size: 22rpx;
color: #333;
line-height: 1.8;
text-align: left;
}
.button-group {
width: 100%;
display: flex;
justify-content: space-around;
gap: 30rpx;
}
.read-btn,
.play-btn {
display: flex;
align-items: center;
justify-content: center;
width: 300rpx;
height: 80rpx;
border-radius: 40rpx;
font-size: 32rpx;
color: #333;
background-color: #ffffff;
border: 1rpx solid #ddd;
}
.read-btn {
border-color: #e64340;
color: #e64340;
}
.play-btn {
border-color: #888;
color: #888;
}
.btn-icon {
font-size: 36rpx;
margin-right: 10rpx;
}
</style>

7
subPackages/other/ipPoster.vue

@ -15,7 +15,7 @@
<image class="bannerImg" mode="aspectFill" src="https://des.js-dyyj.com/data/2025/09/11/4f5581b4-e354-4a2b-8f41-5d37fcd3904b.png"> </image> <image class="bannerImg" mode="aspectFill" src="https://des.js-dyyj.com/data/2025/09/11/4f5581b4-e354-4a2b-8f41-5d37fcd3904b.png"> </image>
<view class="action-box"> <view class="action-box">
<image @click="toPath" class="action-img" mode="aspectFill" src="https://des.js-dyyj.com/data/2025/09/15/0f60d3fa-fe52-4b79-aeb5-22c707fc06b2.png"> </image> <image @click="toPath" class="action-img" mode="aspectFill" src="https://des.js-dyyj.com/data/2025/09/15/0f60d3fa-fe52-4b79-aeb5-22c707fc06b2.png"> </image>
<image @click="toNone" class="action-img" mode="aspectFill" src="https://des.js-dyyj.com/data/2025/09/15/855a0a1a-0685-407a-b833-24f473308b00.png"> </image> <image @click="toNovel" class="action-img" mode="aspectFill" src="https://des.js-dyyj.com/data/2025/09/15/855a0a1a-0685-407a-b833-24f473308b00.png"> </image>
<image @click="toNone" class="action-img" mode="aspectFill" src="https://des.js-dyyj.com/data/2025/09/15/02d41295-048b-4523-a09f-0401df0381f9.png"> </image> <image @click="toNone" class="action-img" mode="aspectFill" src="https://des.js-dyyj.com/data/2025/09/15/02d41295-048b-4523-a09f-0401df0381f9.png"> </image>
</view> </view>
</view> </view>
@ -47,6 +47,11 @@
icon: 'none' icon: 'none'
}) })
}, },
toNovel(){
uni.navigateTo({
url:'/subPackages/other/index'
})
},
toPath(){ toPath(){
console.log(this.info.mini) console.log(this.info.mini)
if(this.info.mini){ if(this.info.mini){

48
subPackages/other/novelCatalog.vue

@ -0,0 +1,48 @@
<template>
<view class="content">
<!-- <BackButton />
<view class="">
园门修真转
</view> -->
<scroll-view scroll-y="true" @scroll="handleScroll" :scroll-top="scrollTop" :show-scrollbar="false">
<view class="chapter" v-for="item in directoryList" :key="item.chapterId" @click="changePlay">
<view class="">
{{item.name}}
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import directoryModule from './components/directory.js'
// directoryList
const directoryListJs = directoryModule.chaptersList || directoryModule.default?.chaptersList || [];
export default {
data() {
return {
directoryList: directoryListJs,
}
},
methods: {
changePlay(){
uni.navigateTo({
url:'/subPackages/other/playNovel'
})
},
}
}
</script>
<style scoped lang="scss">
.content{
padding: 30rpx;
background: #595959;
color: #fff;
.chapter{
padding-bottom: 30rpx;
font-size: 28rpx;
}
}
</style>

448
subPackages/other/playNovel.vue

@ -0,0 +1,448 @@
<template>
<view class="novel-player-container">
<!-- 1. 小说封面图区域适配多端屏幕比例 -->
<view class="novel-cover-wrapper">
<!-- 封面图加载态+失败备用 -->
<image
class="novel-cover"
:class="{ 'cover-loading': isCoverLoading }"
:src="showImg('/uploads/20250918/478322390dfe8befd6fb30643e1b5cb1.png')"
mode="widthFix"
@load="isCoverLoading = false"
@error="handleCoverError"
lazy-load
></image>
<!-- 封面加载失败备用视图 -->
<view class="cover-fallback" v-if="isCoverError">
<uni-icons type="image" size="40" color="#999"></uni-icons>
<text class="fallback-text">封面加载失败</text>
</view>
</view>
<!-- 2. 小说信息区域 -->
<view class="novel-info">
<text class="novel-title">园界修真传</text>
<text class="novel-chapter">第1章考研失败是有原因的</text>
</view>
<!-- 3. 音频核心控制区域 -->
<view class="audio-player">
<!-- Uniapp 音频组件替代原生audio支持多端 -->
<uni-audio
ref="audioRef"
:src="audioUrl"
:preload="preloadMode"
:initial-time="0"
@play="updatePlayStatus(true)"
@pause="updatePlayStatus(false)"
@ended="handleAudioEnd"
@timeupdate="updateProgress"
@error="handleAudioError"
hidden
></uni-audio>
<!-- 播放/暂停按钮 -->
<button
class="play-btn"
:disabled="isAudioLoading"
@click="togglePlayPause"
hover-class="play-btn-hover"
>
<uni-icons
:type="isPlaying ? 'pause' : 'play'"
size="24"
color="#fff"
></uni-icons>
</button>
<!-- 进度条控制支持点击+拖动 -->
<view class="progress-container" @click="handleProgressClick">
<!-- 已播放进度 -->
<view
class="progress-played"
:style="{ width: `${progressPercent}%` }"
></view>
<!-- 进度滑块 -->
<view
class="progress-thumb"
:style="{ left: `${progressPercent}%` }"
@touchstart="startDragProgress"
@touchmove="onDragProgress"
@touchend="endDragProgress"
></view>
</view>
<!-- 时间显示已播放/总时长 -->
<view class="time-display">
<text>{{ formatTime(currentTime) }}</text>
<text class="time-split">/</text>
<text>{{ formatTime(totalTime) }}</text>
</view>
<!-- 音量控制 -->
<view class="volume-control">
<uni-icons
:type="isMuted ? 'volume-off' : 'volume-up'"
size="18"
color="#666"
@click="toggleMute"
></uni-icons>
<slider
class="volume-slider"
min="0"
max="100"
:value="currentVolume"
@change="adjustVolume"
activeColor="#32c5ff"
></slider>
</view>
<!-- 清晰度选择模拟多音质切换 -->
<view class="quality-select">
<text class="quality-label">清晰度</text>
<picker
class="quality-picker"
:value="selectedQuality"
:range="qualityOptions"
:range-key="'label'"
@change="switchAudioQuality"
>
<text>{{ qualityOptions.find(item => item.value === selectedQuality).label }}</text>
<uni-icons type="down" size="14" color="#666" class="picker-icon"></uni-icons>
</picker>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'PlayNovel',
data() {
return {
//
coverUrl: '/static/image.png', // Uniapp static
isCoverLoading: true, //
isCoverError: false, //
//
audioUrl: 'https://des.js-dyyj.com/data/2025/09/05/9875a62d-14ef-481e-b88f-19c061478ce6.MP3', // /CDN
preloadMode: 'auto', // auto/metadata/none
isAudioLoading: false, //
isPlaying: false, //
currentTime: 0, //
totalTime: 0, //
progressPercent: 0, // 0-100
currentVolume: 80, // 0-100
isMuted: false, //
isDragging: false, //
//
qualityOptions: [
{ label: '标准', value: 'low' },
{ label: '高清', value: 'medium' },
{ label: '无损', value: 'high' }
],
selectedQuality: 'medium' //
};
},
onReady() {
// Uniapp
this.audioRef = this.$refs.audioRef;
//
this.audioRef.setVolume(this.currentVolume / 100);
//
this.switchAudioQuality({ detail: { value: 'medium' } });
},
onUnload() {
//
if (this.audioRef) {
this.audioRef.pause();
}
},
methods: {
// ---------------------- ----------------------
handleCoverError() {
this.isCoverLoading = false;
this.isCoverError = true;
},
// ---------------------- ----------------------
// /
togglePlayPause() {
if (this.isPlaying) {
this.audioRef.pause();
} else {
this.isAudioLoading = true;
this.audioRef.play().catch(err => {
console.error('播放失败:', err);
this.isAudioLoading = false;
uni.showToast({ title: '音频播放失败', icon: 'none' });
});
}
},
//
updatePlayStatus(isPlaying) {
this.isPlaying = isPlaying;
this.isAudioLoading = false;
},
//
handleAudioEnd() {
this.isPlaying = false;
this.currentTime = 0;
this.progressPercent = 0;
uni.showToast({ title: '本章播放完毕', icon: 'none' });
//
},
//
handleAudioError() {
this.isAudioLoading = false;
uni.showToast({ title: '音频加载失败', icon: 'none' });
},
// ---------------------- ----------------------
//
updateProgress() {
if (!this.isDragging) {
this.currentTime = this.audioRef.currentTime;
this.totalTime = this.audioRef.duration || 0;
this.progressPercent = (this.currentTime / this.totalTime) * 100 || 0;
}
},
//
handleProgressClick(e) {
const containerWidth = e.currentTarget.offsetWidth;
const clickLeft = e.touches[0].clientX - e.currentTarget.offsetLeft;
const percent = (clickLeft / containerWidth) * 100;
this.setProgress(percent);
},
//
startDragProgress() {
this.isDragging = true;
},
//
onDragProgress(e) {
if (!this.isDragging) return;
const containerWidth = e.currentTarget.offsetWidth;
const dragLeft = e.touches[0].clientX - e.currentTarget.offsetLeft;
let percent = (dragLeft / containerWidth) * 100;
// 0-100
percent = Math.max(0, Math.min(100, percent));
this.progressPercent = percent;
},
//
endDragProgress() {
this.isDragging = false;
this.setProgress(this.progressPercent);
},
//
setProgress(percent) {
const targetTime = (percent / 100) * this.totalTime;
this.audioRef.seek(targetTime);
this.currentTime = targetTime;
this.progressPercent = percent;
},
// ---------------------- ----------------------
//
toggleMute() {
this.isMuted = !this.isMuted;
this.audioRef.setMuted(this.isMuted);
},
//
adjustVolume(e) {
this.currentVolume = e.detail.value;
this.audioRef.setVolume(this.currentVolume / 100);
//
if (this.isMuted && this.currentVolume > 0) {
this.isMuted = false;
this.audioRef.setMuted(false);
}
},
// ---------------------- ----------------------
switchAudioQuality(e) {
const quality = e.detail.value;
this.selectedQuality = quality;
//
const audioMap = {
low: '/static/audio/chapter1-low.mp3',
medium: '/static/audio/chapter1-medium.mp3',
high: '/static/audio/chapter1-high.mp3'
};
this.audioUrl = audioMap[quality];
//
if (this.isPlaying) {
this.audioRef.play();
}
},
// ---------------------- ----------------------
// : 8:16
formatTime(seconds) {
if (!seconds) return '00:00';
const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60);
// 1 01
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
}
};
</script>
<style scoped>
/* 容器整体样式 */
.novel-player-container {
padding: 20rpx;
background: #595959;
min-height: 100vh;
}
/* 封面图样式 */
.novel-cover-wrapper {
width: 100%;
border-radius: 20rpx;
overflow: hidden;
margin-bottom: 30rpx;
position: relative;
}
.novel-cover {
width: 100%;
background-color: #f5f5f5;
}
.cover-loading {
opacity: 0.5;
}
.cover-fallback {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #f5f5f5;
}
.fallback-text {
margin-top: 20rpx;
font-size: 24rpx;
color: #999;
}
/* 小说信息样式 */
.novel-info {
margin-bottom: 30rpx;
text-align: center;
}
.novel-title {
font-size: 36rpx;
font-weight: bold;
color: #fff;
margin-bottom: 10rpx;
display: block;
}
.novel-chapter {
font-size: 28rpx;
color: #fff;
display: block;
}
/* 音频播放器样式 */
.audio-player {
display: flex;
flex-direction: column;
gap: 25rpx;
}
/* 播放按钮样式 */
.play-btn {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: #32c5ff;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 10rpx;
}
.play-btn-hover {
background-color: #28a4e0;
}
/* 进度条样式 */
.progress-container {
width: 100%;
height: 12rpx;
background-color: #eee;
border-radius: 6rpx;
position: relative;
touch-action: none; /* 防止移动端默认触摸行为 */
}
.progress-played {
height: 100%;
background-color: #32c5ff;
border-radius: 6rpx;
}
.progress-thumb {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background-color: #32c5ff;
position: absolute;
top: 50%;
transform: translateY(-50%);
margin-left: -12rpx;
box-shadow: 0 0 10rpx rgba(50, 197, 255, 0.5);
}
/* 时间显示样式 */
.time-display {
font-size: 24rpx;
color: #999;
display: flex;
justify-content: space-between;
}
.time-split {
margin: 0 10rpx;
}
/* 音量控制样式 */
.volume-control {
display: flex;
align-items: center;
gap: 15rpx;
}
.volume-slider {
flex: 1;
height: 8rpx;
}
/* 清晰度选择样式 */
.quality-select {
display: flex;
align-items: center;
gap: 15rpx;
font-size: 26rpx;
color: #666;
}
.quality-picker {
display: flex;
align-items: center;
gap: 8rpx;
color: #32c5ff;
}
.picker-icon {
margin-top: 4rpx;
}
</style>

2322
subPackages/other/read.vue

File diff suppressed because it is too large

2
uni.scss

@ -26,7 +26,7 @@ $uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色如加载更多的提示信息 $uni-text-color-grey:#999;//辅助灰色如加载更多的提示信息
$uni-text-color-placeholder: #808080; $uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0; $uni-text-color-disable:#c0c0c0;
$minor-text-color:#909399;//次要文字
/* 背景颜色 */ /* 背景颜色 */
$uni-bg-color:#ffffff; $uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8; $uni-bg-color-grey:#f8f8f8;

Loading…
Cancel
Save