5 changed files with 0 additions and 1612 deletions
@ -1,155 +0,0 @@ |
|||
# AudioControl 音频控制组件使用文档 |
|||
|
|||
## 组件功能 |
|||
- 在父组件右上角显示音频控制图标 |
|||
- 点击图标播放指定音频,同时暂停背景音乐 |
|||
- 再次点击暂停音频,恢复背景音乐 |
|||
- 音频播放结束后自动恢复背景音乐 |
|||
|
|||
## 组件属性 (Props) |
|||
|
|||
| 属性名 | 类型 | 必填 | 默认值 | 说明 | |
|||
|--------|------|------|--------|------| |
|||
| audioSrc | String | 是 | - | 音频文件路径 | |
|||
| visible | Boolean | 否 | true | 是否显示组件 | |
|||
|
|||
## 使用方法 |
|||
|
|||
### 1. 在父组件中引入和注册组件 |
|||
|
|||
```vue |
|||
<template> |
|||
<view class="parent-container"> |
|||
<!-- 父组件内容 --> |
|||
<view class="content"> |
|||
<!-- 你的页面内容 --> |
|||
</view> |
|||
|
|||
<!-- 音频控制组件 --> |
|||
<AudioControl |
|||
:audioSrc="audioUrl" |
|||
:visible="showAudio" |
|||
/> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import AudioControl from '@/components/AudioControl.vue'; |
|||
|
|||
export default { |
|||
components: { |
|||
AudioControl |
|||
}, |
|||
data() { |
|||
return { |
|||
audioUrl: 'https://your-domain.com/audio/sample.mp3', // 替换为你的音频URL |
|||
showAudio: true |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style> |
|||
.parent-container { |
|||
position: relative; /* 重要:确保AudioControl能正确定位 */ |
|||
width: 100vw; |
|||
height: 100vh; |
|||
} |
|||
</style> |
|||
``` |
|||
|
|||
### 2. 使用项目中的showImg方法(推荐) |
|||
|
|||
如果你的音频文件也存储在项目服务器上,可以使用项目的showImg方法: |
|||
|
|||
```vue |
|||
<script> |
|||
export default { |
|||
data() { |
|||
return { |
|||
audioUrl: this.showImg('/uploads/audio/your-audio-file.mp3'), |
|||
showAudio: true |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
### 3. 动态控制音频源 |
|||
|
|||
你可以根据不同的页面或条件播放不同的音频: |
|||
|
|||
```vue |
|||
<script> |
|||
export default { |
|||
data() { |
|||
return { |
|||
audioUrl: '', |
|||
showAudio: true |
|||
} |
|||
}, |
|||
mounted() { |
|||
// 根据页面设置不同的音频 |
|||
this.setAudioForCurrentPage(); |
|||
}, |
|||
methods: { |
|||
setAudioForCurrentPage() { |
|||
const currentRoute = this.$route.path; // 假设使用vue-router |
|||
|
|||
switch(currentRoute) { |
|||
case '/chapter1': |
|||
this.audioUrl = this.showImg('/uploads/audio/chapter1.mp3'); |
|||
break; |
|||
case '/chapter2': |
|||
this.audioUrl = this.showImg('/uploads/audio/chapter2.mp3'); |
|||
break; |
|||
default: |
|||
this.audioUrl = this.showImg('/uploads/audio/default.mp3'); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
## 样式说明 |
|||
|
|||
组件默认定位在父组件的右上角(top: 30rpx, right: 30rpx),如果需要调整位置,可以在父组件中覆盖样式: |
|||
|
|||
```vue |
|||
<style> |
|||
/* 调整音频控制组件位置 */ |
|||
.parent-container ::v-deep .audio-control { |
|||
top: 50rpx !important; |
|||
right: 50rpx !important; |
|||
} |
|||
</style> |
|||
``` |
|||
|
|||
## 注意事项 |
|||
|
|||
1. **父组件样式**:确保父组件设置了 `position: relative`,这样AudioControl组件才能正确定位 |
|||
2. **音频格式**:建议使用 mp3 格式的音频文件,兼容性最好 |
|||
3. **音频路径**:确保音频文件路径正确且可访问 |
|||
4. **背景音乐**:组件会自动处理与MusicControl组件的交互,无需额外配置 |
|||
|
|||
## 图标说明 |
|||
|
|||
- 🔊:音频未播放状态 |
|||
- 🎧:音频播放中状态,带有脉动动画效果 |
|||
|
|||
## 事件处理 |
|||
|
|||
组件内部已处理所有音频播放逻辑,包括: |
|||
- 播放音频时自动暂停背景音乐 |
|||
- 暂停音频时自动恢复背景音乐 |
|||
- 音频播放结束时自动恢复背景音乐 |
|||
- 组件销毁时自动清理资源 |
|||
|
|||
## 示例场景 |
|||
|
|||
适用于以下场景: |
|||
- 章节页面播放对应的音频解说 |
|||
- 展示页面播放介绍音频 |
|||
- 互动页面播放提示音频 |
|||
- 任何需要临时播放音频并暂停背景音乐的场景 |
@ -1,795 +0,0 @@ |
|||
<template> |
|||
<!-- 灵动岛占位区域 - 始终存在但控制可见性 --> |
|||
<view class="dynamic-island-placeholder" :class="{ visible: isScrolled }" |
|||
:style="{ height: 216 + 'rpx' }"> |
|||
<view class="dynamic-island" :class="{ |
|||
compact: actualCompactState, |
|||
fixed: isFixed, |
|||
}" :style="{ top: isFixed ? fixedTopPosition + 'px' : 0 }" @click="handleToggle"> |
|||
<!-- 展开状态 --> |
|||
<view v-if="!actualCompactState" class="expanded-content"> |
|||
<template> |
|||
<template v-if="styleType != 'timeShop'"> |
|||
<!-- 三栏布局 --> |
|||
<view class="three-column-layout"> |
|||
<!-- 右侧:头像和链接 --> |
|||
<view class="right-section"> |
|||
<view class="avatar-container" @click="toWebView"> |
|||
<image class="avatar" |
|||
src="https://epic.js-dyyj.com/uploads/20250728/7d9ba1fe109643681396cb03f60f3218.png" |
|||
mode="aspectFill"></image> |
|||
</view> |
|||
<view class="" style="display: flex;flex-direction: column;justify-content: space-between;height: 100%;"> |
|||
<view class="profile-info"> |
|||
<text class="profile-title">数字领航员</text> |
|||
<text class="profile-name">EVITA</text> |
|||
</view> |
|||
<view class="platform-link"> |
|||
<view class="link-text" style="margin-bottom: 15rpx;">DES介绍 >> |
|||
</view> |
|||
<view class="link-text">DES广播 >></view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
<!-- 左侧分隔线 --> |
|||
<view class="column-divider"></view> |
|||
<!-- 左侧:欢迎信息 --> |
|||
<view class="left-section"> |
|||
<view class="welcome-message"> |
|||
<view class="welcome-text">Hi!{{ |
|||
userInfo && userInfo.token ? userInfo.nickname : "用户" |
|||
}}欢迎回来~</view> |
|||
</view> |
|||
<view class="" style="font-size: 24rpx;font-weight: bold;color: #000000;"> |
|||
查看您的DES数据资产行 |
|||
</view> |
|||
<view class="" style="display: flex;align-items: flex-end;justify-content: space-between;"> |
|||
<view class=""> |
|||
<view class="stats-info" @click="toOrder"> |
|||
<text class="stats-number">2</text> |
|||
<text class="stats-unit">个</text> |
|||
</view> |
|||
<view class="stats-label">数字权益行</view> |
|||
</view> |
|||
<div> |
|||
<view class="stats-info" @click="toOrder"> |
|||
<text class="stats-number">2</text> |
|||
<text class="stats-unit">个</text> |
|||
</view> |
|||
<view class="stats-label">数字资产行</view> |
|||
</div> |
|||
<view class="middle-section"> |
|||
<view class="time-reward-container"> |
|||
<text class="time-reward-title" style="margin-bottom: 5rpx;">时间奖励</text> |
|||
<view class="time-reward-stats"> |
|||
<text class="time-reward-number">120</text> |
|||
<text class="time-reward-unit">点</text> |
|||
</view> |
|||
<text class="time-reward-label" style="font-weight: bold;">旅行时间行</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
</view> |
|||
</template> |
|||
<template v-if="styleType == 'timeShop'"> |
|||
<view class="bottom-section"> |
|||
<view class="" style="flex:1;"> |
|||
<view class="" style="display: flex;"> |
|||
<view class="time-reward-container" style="width: 200rpx;"> |
|||
<text class="time-reward-title">时间奖励</text> |
|||
<view class="time-reward-stats"> |
|||
<text class="time-reward-number">120</text> |
|||
<text class="time-reward-unit">点</text> |
|||
</view> |
|||
<text class="time-reward-label" |
|||
style="font-size: 24rpx;font-weight: bold;">旅行时间行</text> |
|||
</view> |
|||
<view class=""> |
|||
<view class="time-reward-number" style="font-size: 34rpx;"> |
|||
{{ |
|||
userInfo && userInfo.token ? userInfo.nickname : "用户" |
|||
}} |
|||
</view> |
|||
<view class="time-reward-label" |
|||
style="margin-top: 15rpx;font-weight: bold;font-size: 26rpx;"> |
|||
积分:999<text |
|||
style="color: #999999;font-size: 22rpx;margin-left: 10rpx;">积分获取规则</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
<view class="" |
|||
style="display: flex;align-items: center;font-size: 26rpx;font-weight: bold;display: flex;align-items: center;margin-top: 20rpx;"> |
|||
<view class="" style="width: 200rpx;"> |
|||
时长:{{ |
|||
userInfo && userInfo.token ? userInfo.hour+'h' : "-" |
|||
}} |
|||
</view> |
|||
<view class=""> |
|||
<image style="width: 22rpx;height: 22rpx;margin-right: 15rpx;" |
|||
:src="showImg('/uploads/20250822/c8ee7615823a1ffaba400a4d5746de9a.png')"> |
|||
</image> |
|||
点赞:120 |
|||
</view> |
|||
<view class=""> |
|||
<image style="width: 22rpx;height: 22rpx;margin: 0 15rpx;" |
|||
:src="showImg('/uploads/20250822/84c49f78f1c86b7340aaaa391bd4b7cf.png')"> |
|||
</image> |
|||
留言:120 |
|||
</view> |
|||
</view> |
|||
</view> |
|||
<image class="avatar" |
|||
src="https://epic.js-dyyj.com/uploads/20250728/7d9ba1fe109643681396cb03f60f3218.png" |
|||
mode="aspectFill"></image> |
|||
</view> |
|||
</template> |
|||
</template> |
|||
</view> |
|||
|
|||
<!-- 紧凑状态 --> |
|||
<view v-else class="compact-content"> |
|||
<text class="compact-name">{{ getCompactName() }}</text> |
|||
<image class="compact-avatar" |
|||
src="https://epic.js-dyyj.com/uploads/20250728/7d9ba1fe109643681396cb03f60f3218.png" |
|||
mode="aspectFill"></image> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: "DynamicIsland", |
|||
props: { |
|||
isCompact: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
styleType: { |
|||
type: String, |
|||
default: "", |
|||
}, |
|||
isFixed: { |
|||
type: Boolean, |
|||
default: false, |
|||
}, |
|||
title: { |
|||
type: String, |
|||
default: "用户", |
|||
}, |
|||
subtitle: { |
|||
type: String, |
|||
default: "周杰伦 - 青花瓷", |
|||
}, |
|||
avatarUrl: { |
|||
type: String, |
|||
default: "https://picsum.photos/80/80", |
|||
}, |
|||
actionText: { |
|||
type: String, |
|||
default: "暂停", |
|||
}, |
|||
// 新增页面标识符,用于区分不同页面的灵动岛实例 |
|||
pageId: { |
|||
type: String, |
|||
default: "default_page", |
|||
}, |
|||
}, |
|||
data() { |
|||
return { |
|||
isExpanded: false, |
|||
statusBarHeight: 0, |
|||
isScrolled: false, |
|||
scrollThreshold: 160, // 灵动岛大卡片高度(160rpx) |
|||
// 内部数据,减少对外部props的依赖 |
|||
currentTitle: "Hi!用户,欢迎回来~", |
|||
currentSubtitle: "2个权益 | 120时间银行", |
|||
currentAvatar: "https://picsum.photos/80/80", |
|||
currentAction: "激活你的Agent", |
|||
userInfo: {}, |
|||
JDSU_IMG_URL: "https://epic.js-dyyj.com", |
|||
}; |
|||
}, |
|||
computed: { |
|||
// 计算实际显示状态:只有在固定模式下才使用内部状态 |
|||
actualCompactState() { |
|||
if (this.isScrolled) { |
|||
return !this.isExpanded; |
|||
} |
|||
return false; // 非滚动状态下始终展开 |
|||
}, |
|||
// 计算固定定位的top值 |
|||
fixedTopPosition() { |
|||
// 状态栏高度 + 导航栏高度(40px) + 间距(20px) |
|||
return this.statusBarHeight + 40 + 20; |
|||
}, |
|||
// 计算占位区域高度 - 始终保持固定高度,等于灵动岛大卡片高度 |
|||
placeholderHeight() { |
|||
// 占位区域高度应该等于灵动岛展开状态的高度 |
|||
// 包括上下边距:32rpx(上) + 160rpx(灵动岛) + 24rpx(下) = 216rpx |
|||
const islandHeightRpx = 160; // 灵动岛展开高度 |
|||
const topMarginRpx = 32; // 上边距 |
|||
const bottomMarginRpx = 24; // 下边距 |
|||
return topMarginRpx + islandHeightRpx + bottomMarginRpx; |
|||
}, |
|||
// 使用内部数据或props数据 |
|||
title() { |
|||
return this.currentTitle; |
|||
}, |
|||
subtitle() { |
|||
return this.currentSubtitle; |
|||
}, |
|||
avatarUrl() { |
|||
return this.currentAvatar; |
|||
}, |
|||
actionText() { |
|||
return this.currentAction; |
|||
}, |
|||
// 计算是否固定 |
|||
isFixed() { |
|||
return this.isScrolled; |
|||
}, |
|||
}, |
|||
mounted() { |
|||
// uni-app中通过父组件传递点击事件 |
|||
this.setStatusBarHeight(); |
|||
// 监听页面滚动 |
|||
this.addScrollListener(); |
|||
// 获取用户信息 |
|||
this.getUserInfo(); |
|||
}, |
|||
beforeDestroy() { |
|||
// 清理滚动监听 |
|||
this.removeScrollListener(); |
|||
}, |
|||
methods: { |
|||
toLogin() { |
|||
uni.navigateTo({ |
|||
url: "/pages/login/login", |
|||
}); |
|||
}, |
|||
handleToggle() { |
|||
if (this.isScrolled) { |
|||
// 固定模式下切换内部状态 |
|||
this.isExpanded = !this.isExpanded; |
|||
this.$emit("toggle", this.isExpanded); |
|||
} else { |
|||
// 非固定模式下触发外部事件 |
|||
this.$emit("toggle"); |
|||
} |
|||
}, |
|||
handleAction() { |
|||
this.$emit("action"); |
|||
}, |
|||
// 外部调用收缩方法 |
|||
collapseIsland() { |
|||
if (this.isScrolled && this.isExpanded) { |
|||
this.isExpanded = false; |
|||
this.$emit("toggle", this.isExpanded); |
|||
} |
|||
}, |
|||
// 添加滚动监听 |
|||
addScrollListener() { |
|||
// 只监听带页面ID的滚动事件,避免不同页面间的状态冲突 |
|||
const eventName = `pageScroll_${this.pageId}`; |
|||
console.log("DynamicIsland 添加滚动监听:", eventName); |
|||
uni.$on(eventName, this.handlePageScroll); |
|||
}, |
|||
// 移除滚动监听 |
|||
removeScrollListener() { |
|||
// 移除带页面ID的滚动监听 |
|||
const eventName = `pageScroll_${this.pageId}`; |
|||
uni.$off(eventName, this.handlePageScroll); |
|||
}, |
|||
// 处理页面滚动 |
|||
handlePageScroll(e) { |
|||
const scrollTop = e.scrollTop || e; |
|||
const shouldScroll = scrollTop > this.scrollThreshold; |
|||
|
|||
if (this.isScrolled !== shouldScroll) { |
|||
this.isScrolled = shouldScroll; |
|||
// 添加触觉反馈 |
|||
} |
|||
|
|||
// 滚动时自动收缩展开的灵动岛 |
|||
if (this.isScrolled) { |
|||
this.collapseIsland(); |
|||
} |
|||
}, |
|||
toOrder() { |
|||
uni.switchTab({ |
|||
url: '/pages/index/iSoul' |
|||
}) |
|||
}, |
|||
getCompactName() { |
|||
// 从用户信息中获取昵称 |
|||
if (this.userInfo && this.userInfo.nickname) { |
|||
return this.userInfo.nickname; |
|||
} |
|||
return "用户"; |
|||
}, |
|||
getStatNumber(type) { |
|||
// 从副标题中解析统计数据 |
|||
if (this.subtitle) { |
|||
if (type === "权益") { |
|||
const match = this.subtitle.match(/(\d+)个权益/); |
|||
return match ? match[1] : "0"; |
|||
} else if (type === "时间银行") { |
|||
const match = this.subtitle.match(/(\d+)时间银行/); |
|||
return match ? match[1] : "0"; |
|||
} |
|||
} |
|||
return "0"; |
|||
}, |
|||
// 设置状态栏高度 |
|||
setStatusBarHeight() { |
|||
try { |
|||
const systemInfo = uni.getSystemInfoSync(); |
|||
this.statusBarHeight = systemInfo.statusBarHeight || 0; |
|||
} catch (e) { |
|||
console.warn("获取系统信息失败:", e); |
|||
this.statusBarHeight = 0; |
|||
} |
|||
}, |
|||
// 获取用户信息 |
|||
getUserInfo() { |
|||
try { |
|||
this.userInfo = |
|||
(uni.getStorageSync("userInfo") && |
|||
JSON.parse(uni.getStorageSync("userInfo"))) || |
|||
this.$store.state.user.userInfo || {}; |
|||
console.log(this.userInfo, "this.userInfo"); |
|||
// 更新标题显示用户昵称 |
|||
if (this.userInfo && this.userInfo.nickname) { |
|||
this.currentTitle = `Hi!${this.userInfo.nickname},欢迎回来~`; |
|||
} |
|||
} catch (e) { |
|||
console.warn("获取用户信息失败:", e); |
|||
this.userInfo = {}; |
|||
} |
|||
}, |
|||
toWebView() { |
|||
uni.navigateTo({ |
|||
url: "/subPackages/webPage/webPage?url=" + |
|||
"https://des.js-dyyj.com/dist/#/", |
|||
}); |
|||
}, |
|||
|
|||
// 图片显示方法 |
|||
showImg(img) { |
|||
if (!img) return; |
|||
if (img.indexOf("https://") != -1 || img.indexOf("http://") != -1) { |
|||
return img; |
|||
} else { |
|||
return this.JDSU_IMG_URL + img; |
|||
} |
|||
}, |
|||
}, |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped lang="scss"> |
|||
/* 灵动岛占位区域样式 - 始终存在但控制可见性 */ |
|||
.dynamic-island-placeholder { |
|||
width: 100%; |
|||
background: transparent; |
|||
position: relative; |
|||
opacity: 1; |
|||
transition: opacity 0.3s ease; |
|||
padding: 24rpx 0; |
|||
} |
|||
|
|||
.dynamic-island-placeholder.visible { |
|||
opacity: 1; |
|||
} |
|||
|
|||
/* 当灵动岛不是固定状态时,确保它在占位符内正常显示 */ |
|||
.dynamic-island-placeholder .dynamic-island:not(.fixed) { |
|||
position: relative; |
|||
z-index: 100; |
|||
} |
|||
|
|||
.dynamic-island { |
|||
// margin: 24rpx auto 24rpx; |
|||
margin: 0 auto; |
|||
z-index: 100; |
|||
|
|||
background: linear-gradient(180deg, #fffdb7 0%, #97fffa 100%); |
|||
backdrop-filter: blur(20rpx); |
|||
-webkit-backdrop-filter: blur(20rpx); |
|||
border-radius: 40rpx; |
|||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1), |
|||
0 0 0 1rpx rgba(255, 255, 255, 0.2) inset; |
|||
|
|||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); |
|||
overflow: hidden; |
|||
|
|||
// 展开状态 |
|||
width: 710rpx; |
|||
height: 216rpx; |
|||
|
|||
// 紧凑状态 |
|||
&.compact { |
|||
width: 300rpx; |
|||
height: 80rpx; |
|||
border-radius: 40rpx; |
|||
|
|||
.expanded-content { |
|||
opacity: 0; |
|||
visibility: hidden; |
|||
transition: opacity 0.15s ease-out, visibility 0s linear 0.15s; |
|||
} |
|||
} |
|||
|
|||
&:not(.compact) { |
|||
.compact-content { |
|||
opacity: 0; |
|||
visibility: hidden; |
|||
transition: opacity 0.15s ease-out, visibility 0s linear 0.15s; |
|||
} |
|||
} |
|||
|
|||
// 固定定位状态 |
|||
&.fixed { |
|||
position: fixed; |
|||
left: 50%; |
|||
transform: translateX(-50%); |
|||
z-index: 998; |
|||
margin: 0; |
|||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); |
|||
animation: slideInFromTop 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; |
|||
} |
|||
} |
|||
|
|||
.expanded-content { |
|||
display: flex; |
|||
flex-direction: column; |
|||
height: 100%; |
|||
padding: 24rpx 25rpx; |
|||
opacity: 1; |
|||
visibility: visible; |
|||
transition: opacity 0.2s ease-in 0.1s, visibility 0s linear 0s; |
|||
} |
|||
|
|||
/* 三栏布局样式 */ |
|||
.three-column-layout { |
|||
display: flex; |
|||
align-items: flex-end; |
|||
height: 100%; |
|||
width: 100%; |
|||
} |
|||
|
|||
/* 列分隔线 */ |
|||
.column-divider { |
|||
width: 2rpx; |
|||
height: 160rpx; |
|||
background: rgba(0, 0, 0, 0.1); |
|||
margin: 0rpx 25rpx; |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
/* 左侧区域 */ |
|||
.left-section { |
|||
color: #333; |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: space-between; |
|||
flex: 1; |
|||
height: 100%; |
|||
} |
|||
|
|||
.welcome-message { |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.welcome-text { |
|||
font-size: 22rpx; |
|||
color: #000000; |
|||
line-height: 1.2; |
|||
text-overflow: ellipsis; |
|||
overflow: hidden; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.welcome-subtitle { |
|||
font-size: 24rpx; |
|||
color: #000000; |
|||
margin-top: 4rpx; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.stats-info { |
|||
display: flex; |
|||
align-items: baseline; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.stats-number { |
|||
font-size: 40rpx; |
|||
color: #333; |
|||
font-weight: bold; |
|||
line-height: 1; |
|||
} |
|||
|
|||
.stats-unit { |
|||
font-size: 24rpx; |
|||
color: #000000; |
|||
margin-left: 4rpx; |
|||
} |
|||
|
|||
.stats-label { |
|||
font-size: 22rpx; |
|||
color: #000000; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
/* 中间区域 */ |
|||
.middle-section { |
|||
display: flex; |
|||
justify-content: flex-start; |
|||
align-items: center; |
|||
} |
|||
|
|||
.time-reward-container { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: flex-end; |
|||
align-items: flex-start; |
|||
} |
|||
|
|||
.time-reward-title { |
|||
font-size: 20rpx; |
|||
color: #000000; |
|||
font-weight: 500; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.time-reward-stats { |
|||
display: flex; |
|||
align-items: baseline; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.time-reward-number { |
|||
font-size: 40rpx; |
|||
color: #000000; |
|||
font-weight: bold; |
|||
line-height: 1; |
|||
} |
|||
|
|||
.time-reward-unit { |
|||
font-size: 24rpx; |
|||
color: #000000; |
|||
margin-left: 4rpx; |
|||
} |
|||
|
|||
.time-reward-label { |
|||
font-size: 22rpx; |
|||
color: #000000; |
|||
} |
|||
|
|||
/* 右侧区域 */ |
|||
.right-section { |
|||
display: flex; |
|||
align-items: flex-end; |
|||
height: 100%; |
|||
} |
|||
|
|||
.avatar-container { |
|||
position: relative; |
|||
margin-right: 10rpx; |
|||
width: 140rpx; |
|||
height: 140rpx; |
|||
} |
|||
|
|||
.avatar { |
|||
width: 140rpx; |
|||
height: 140rpx; |
|||
border-radius: 50%; |
|||
} |
|||
|
|||
.profile-info { |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: flex-start; |
|||
justify-content: flex-start; |
|||
} |
|||
|
|||
.profile-title { |
|||
font-size: 26rpx; |
|||
color: #000000; |
|||
margin-bottom: 4rpx; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.profile-name { |
|||
font-size: 26rpx; |
|||
color: #000000; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.platform-link { |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.link-text { |
|||
font-size: 22rpx; |
|||
color: #000000; |
|||
text-decoration: underline; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
/* 保留原有的底部区域样式用于timeShop模式 */ |
|||
.bottom-section { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
flex: 1; |
|||
color: #000000; |
|||
} |
|||
|
|||
.stats-section { |
|||
display: flex; |
|||
gap: 32rpx; |
|||
} |
|||
|
|||
.stat-item { |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
font-size: 28rpx; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.stat-number { |
|||
font-size: 32rpx; |
|||
color: #000000; |
|||
font-weight: bold; |
|||
line-height: 1; |
|||
margin-bottom: 4rpx; |
|||
} |
|||
|
|||
.stat-label { |
|||
font-size: 28rpx; |
|||
color: #000000; |
|||
line-height: 1; |
|||
font-weight: bold; |
|||
margin-top: 20rpx; |
|||
} |
|||
|
|||
.divider { |
|||
width: 2rpx; |
|||
height: 60rpx; |
|||
background: rgba(255, 255, 255, 0.3); |
|||
margin: 0 24rpx; |
|||
} |
|||
|
|||
.action-section { |
|||
display: flex; |
|||
align-items: center; |
|||
flex: 1; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.action-text { |
|||
font-size: 26rpx; |
|||
color: #ffffff; |
|||
font-weight: bold; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
max-width: 200rpx; |
|||
} |
|||
|
|||
// 添加点击反馈动画 |
|||
.dynamic-island:active { |
|||
transform: scale(0.98); |
|||
|
|||
&.fixed { |
|||
transform: translateX(-50%) scale(0.98); |
|||
} |
|||
} |
|||
|
|||
// 从顶部滑入动画 |
|||
@keyframes slideInFromTop { |
|||
0% { |
|||
transform: translateX(-50%) translateY(-100%); |
|||
opacity: 0; |
|||
} |
|||
|
|||
100% { |
|||
transform: translateX(-50%) translateY(0); |
|||
opacity: 1; |
|||
} |
|||
} |
|||
|
|||
.compact-content { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
height: 100%; |
|||
padding: 0 24rpx; |
|||
opacity: 1; |
|||
visibility: visible; |
|||
transition: opacity 0.2s ease-in 0.1s, visibility 0s linear 0s; |
|||
} |
|||
|
|||
.compact-name { |
|||
font-size: 27rpx; |
|||
color: #333; |
|||
font-weight: bold; |
|||
flex: 1; |
|||
text-align: left; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
max-width: 200rpx; |
|||
} |
|||
|
|||
.compact-avatar { |
|||
width: 48rpx; |
|||
height: 48rpx; |
|||
border-radius: 50%; |
|||
border: 2rpx solid rgba(0, 0, 0, 0.2); |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
// 动画定义 |
|||
@keyframes pulse { |
|||
0% { |
|||
opacity: 1; |
|||
transform: scale(1); |
|||
} |
|||
|
|||
50% { |
|||
opacity: 0.6; |
|||
transform: scale(1.1); |
|||
} |
|||
|
|||
100% { |
|||
opacity: 1; |
|||
transform: scale(1); |
|||
} |
|||
} |
|||
|
|||
// 响应式适配 |
|||
// @media (max-width: 750rpx) { |
|||
// .dynamic-island { |
|||
// &.is-expanded { |
|||
// width: 100vw; |
|||
// max-width: 750rpx; |
|||
// } |
|||
|
|||
// &.is-compact { |
|||
// width: 280rpx; |
|||
// } |
|||
// } |
|||
// } |
|||
.action-text-box { |
|||
color: #000000; |
|||
|
|||
.action-text-box-des { |
|||
font-size: 20rpx; |
|||
} |
|||
|
|||
.action-text-box-msg { |
|||
font-size: 24rpx; |
|||
display: flex; |
|||
align-items: center; |
|||
margin-top: 20rpx; |
|||
} |
|||
|
|||
.action-text-box-img { |
|||
width: 57rpx; |
|||
height: 46rpx; |
|||
margin-right: 10rpx; |
|||
} |
|||
} |
|||
</style> |
@ -1,293 +0,0 @@ |
|||
# SwipeToNext 组件使用文档 |
|||
|
|||
## 组件介绍 |
|||
|
|||
`SwipeToNext` 是一个通用的触底跳转组件,封装了手势检测、延迟防抖、提示文字等功能,可以在任何需要滑动跳转的页面中使用。 |
|||
|
|||
## 组件特性 |
|||
|
|||
- ✅ 手势滑动检测 |
|||
- ✅ 防误触发机制(延迟允许跳转) |
|||
- ✅ 可配置的滑动阈值 |
|||
- ✅ 自定义提示文字 |
|||
- ✅ 支持事件监听 |
|||
- ✅ 完全可配置的参数 |
|||
|
|||
## 使用方法 |
|||
|
|||
### 1. 引入组件 |
|||
|
|||
```vue |
|||
<script> |
|||
import SwipeToNext from '@/components/SwipeToNext.vue'; |
|||
|
|||
export default { |
|||
components: { |
|||
SwipeToNext |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
### 2. 基础使用 |
|||
|
|||
```vue |
|||
<template> |
|||
<SwipeToNext |
|||
:is-last-slide="isLastSlide" |
|||
:target-path="'/next/page'" |
|||
@swipe-to-next="handleSwipeToNext" |
|||
> |
|||
<!-- 你的页面内容 --> |
|||
<view class="content"> |
|||
<!-- 轮播图或其他内容 --> |
|||
</view> |
|||
</SwipeToNext> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
data() { |
|||
return { |
|||
isLastSlide: false |
|||
} |
|||
}, |
|||
methods: { |
|||
// 处理swiper切换或其他逻辑 |
|||
handlePageChange() { |
|||
// 根据你的逻辑设置 isLastSlide |
|||
this.isLastSlide = true; // 当到达最后一页时 |
|||
}, |
|||
handleSwipeToNext(targetPath) { |
|||
console.log('即将跳转到:', targetPath); |
|||
// 可以在这里添加额外的逻辑,如数据统计 |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
### 3. 完整配置使用 |
|||
|
|||
```vue |
|||
<template> |
|||
<SwipeToNext |
|||
:is-last-slide="isLastSlide" |
|||
:target-path="'/next/page'" |
|||
:show-tip="true" |
|||
tip-text="向上滑动查看更多内容" |
|||
:swipe-threshold="100" |
|||
:delay-time="800" |
|||
:enable-delay="true" |
|||
@swipe-to-next="handleSwipeToNext" |
|||
> |
|||
<!-- 你的页面内容 --> |
|||
<view class="content"> |
|||
<!-- 内容区域 --> |
|||
</view> |
|||
</SwipeToNext> |
|||
</template> |
|||
``` |
|||
|
|||
## Props 参数 |
|||
|
|||
| 参数名 | 类型 | 默认值 | 必填 | 说明 | |
|||
|--------|------|--------|------|------| |
|||
| `isLastSlide` | Boolean | `false` | ✅ | 是否在最后一页/最后一个状态 | |
|||
| `targetPath` | String | - | ✅ | 跳转的目标路径 | |
|||
| `showTip` | Boolean | `true` | ❌ | 是否显示提示文字 | |
|||
| `tipText` | String | `'继续向上滑动进入下一章节'` | ❌ | 提示文字内容 | |
|||
| `swipeThreshold` | Number | `80` | ❌ | 滑动阈值(像素) | |
|||
| `delayTime` | Number | `500` | ❌ | 延迟允许跳转的时间(毫秒) | |
|||
| `enableDelay` | Boolean | `true` | ❌ | 是否启用延迟机制 | |
|||
| `alwaysEnable` | Boolean | `false` | ❌ | 是否总是启用跳转(忽略isLastSlide状态) | |
|||
|
|||
## Events 事件 |
|||
|
|||
| 事件名 | 参数 | 说明 | |
|||
|--------|------|------| |
|||
| `swipe-to-next` | `targetPath` | 触发跳转时的回调事件 | |
|||
|
|||
## 使用场景示例 |
|||
|
|||
### 场景1:图片轮播页面 |
|||
|
|||
```vue |
|||
<template> |
|||
<SwipeToNext |
|||
:is-last-slide="currentIndex === images.length - 1" |
|||
:target-path="'/gallery/next'" |
|||
> |
|||
<swiper @change="handleSwiperChange"> |
|||
<swiper-item v-for="(img, index) in images" :key="index"> |
|||
<image :src="img" mode="aspectFit" /> |
|||
</swiper-item> |
|||
</swiper> |
|||
</SwipeToNext> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
data() { |
|||
return { |
|||
currentIndex: 0, |
|||
images: ['img1.jpg', 'img2.jpg', 'img3.jpg'] |
|||
} |
|||
}, |
|||
methods: { |
|||
handleSwiperChange(e) { |
|||
this.currentIndex = e.detail.current; |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
### 场景2:文章阅读页面 |
|||
|
|||
```vue |
|||
<template> |
|||
<SwipeToNext |
|||
:is-last-slide="isReadComplete" |
|||
:target-path="'/article/next'" |
|||
tip-text="继续滑动阅读下一篇文章" |
|||
:swipe-threshold="60" |
|||
> |
|||
<scroll-view @scrolltolower="handleScrollToBottom"> |
|||
<view class="article-content"> |
|||
<!-- 文章内容 --> |
|||
</view> |
|||
</scroll-view> |
|||
</SwipeToNext> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
data() { |
|||
return { |
|||
isReadComplete: false |
|||
} |
|||
}, |
|||
methods: { |
|||
handleScrollToBottom() { |
|||
// 滚动到底部时认为阅读完成 |
|||
this.isReadComplete = true; |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
### 场景4:单张图片或总是启用触底跳转 |
|||
|
|||
```vue |
|||
<template> |
|||
<SwipeToNext |
|||
:always-enable="true" |
|||
:target-path="'/next/chapter'" |
|||
tip-text="向上滑动查看下一内容" |
|||
:enable-delay="false" |
|||
> |
|||
<view class="single-image"> |
|||
<image :src="imageUrl" mode="aspectFit" /> |
|||
</view> |
|||
</SwipeToNext> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
data() { |
|||
return { |
|||
imageUrl: 'single-image.jpg' |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
```vue |
|||
<template> |
|||
<SwipeToNext |
|||
:is-last-slide="currentStep === totalSteps - 1" |
|||
:target-path="'/guide/complete'" |
|||
tip-text="向上滑动完成引导" |
|||
> |
|||
<view class="guide-step"> |
|||
<view class="step-content"> |
|||
步骤 {{ currentStep + 1 }} / {{ totalSteps }} |
|||
</view> |
|||
<button @click="nextStep">下一步</button> |
|||
</view> |
|||
</SwipeToNext> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
data() { |
|||
return { |
|||
currentStep: 0, |
|||
totalSteps: 5 |
|||
} |
|||
}, |
|||
methods: { |
|||
nextStep() { |
|||
if (this.currentStep < this.totalSteps - 1) { |
|||
this.currentStep++; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
## 特殊情况处理 |
|||
|
|||
### 单张图片问题 |
|||
|
|||
当只有一张图片时,传统的 `isLastSlide` 逻辑不适用。这时可以使用 `alwaysEnable` 参数: |
|||
|
|||
```vue |
|||
<!-- 单张图片的解决方案 --> |
|||
<SwipeToNext |
|||
:always-enable="true" |
|||
:target-path="'/next/page'" |
|||
:enable-delay="false" |
|||
> |
|||
<image src="single-image.jpg" /> |
|||
</SwipeToNext> |
|||
``` |
|||
|
|||
### 参数优先级 |
|||
|
|||
当 `alwaysEnable="true"` 时: |
|||
- 忽略 `isLastSlide` 的值 |
|||
- 总是显示提示文字 |
|||
- 总是允许触底跳转 |
|||
- 建议设置 `enableDelay="false"` 以获得更好的响应速度 |
|||
|
|||
|
|||
|
|||
1. **确保正确设置 `isLastSlide`**:这是控制是否允许跳转的关键属性 |
|||
2. **路径格式**:`targetPath` 需要是有效的 uni-app 路由路径 |
|||
3. **性能考虑**:如果不需要延迟机制,可以设置 `enableDelay: false` 来提高响应速度 |
|||
4. **样式覆盖**:组件内的提示文字样式可以通过全局样式覆盖 |
|||
5. **事件监听**:建议监听 `swipe-to-next` 事件进行数据统计或其他操作 |
|||
|
|||
## 自定义样式 |
|||
|
|||
如果需要自定义提示文字的样式,可以在页面中添加: |
|||
|
|||
```scss |
|||
// 覆盖组件样式 |
|||
.swipe-to-next .bottom-tip { |
|||
bottom: 200rpx !important; // 调整位置 |
|||
background: rgba(255, 255, 255, 0.9) !important; // 改变背景色 |
|||
|
|||
text { |
|||
color: #333 !important; // 改变文字颜色 |
|||
font-size: 32rpx !important; // 改变字体大小 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
这个组件极大地简化了触底跳转功能的实现,让你可以专注于业务逻辑而不用重复编写相同的手势检测代码。 |
@ -1,225 +0,0 @@ |
|||
# 跨页面音频控制解决方案 |
|||
|
|||
## 进一步优化:解决跨页面状态同步问题 |
|||
|
|||
### 问题描述 |
|||
在跨页面场景下,当音频正在播放时跳转到新页面,新页面的MusicControl组件不知道有音频在播放,点击背景音乐按钮时会直接播放背景音乐,导致音频和背景音乐同时播放。 |
|||
|
|||
### 解决方案 |
|||
|
|||
#### 1. MusicControl组件增强检测 |
|||
```javascript |
|||
// 在mounted生命周期中添加全局音频状态检测 |
|||
mounted() { |
|||
this.syncMusicState(); |
|||
this.checkGlobalAudioState(); // 新增:检查全局音频状态 |
|||
|
|||
// 定时器也要检查全局音频状态 |
|||
this.timer = setInterval(() => { |
|||
this.syncMusicState(); |
|||
this.checkGlobalAudioState(); |
|||
}, 1000); |
|||
} |
|||
|
|||
// 新增方法:检查全局音频状态 |
|||
checkGlobalAudioState() { |
|||
const app = getApp(); |
|||
if (app && app.globalData && app.globalData.currentAudio) { |
|||
const globalAudio = app.globalData.currentAudio; |
|||
this.isAudioPlaying = !globalAudio.paused; |
|||
} else { |
|||
this.isAudioPlaying = false; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
#### 2. 全局音频管理工具优化 |
|||
```javascript |
|||
// 在音频状态变化时发送全局事件 |
|||
pauseCurrentAudio() { |
|||
const audio = this.getCurrentAudio(); |
|||
if (audio && !audio.paused) { |
|||
audio.pause(); |
|||
this.notifyAudioStateChange(false); // 通知状态变化 |
|||
return true; |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
// 新增:通知音频状态变化 |
|||
notifyAudioStateChange(isPlaying) { |
|||
if (typeof uni !== 'undefined') { |
|||
uni.$emit('audioPlaying', isPlaying); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 修复后的交互流程 |
|||
```mermaid |
|||
graph TD |
|||
A[跨页面跳转] --> B[MusicControl组件加载] |
|||
B --> C[检查全局音频状态] |
|||
C --> D{有音频在播放?} |
|||
D -->|是| E[设置isAudioPlaying=true] |
|||
D -->|否| F[设置isAudioPlaying=false] |
|||
E --> G[点击背景音乐按钮] |
|||
F --> G |
|||
G --> H{检查isAudioPlaying} |
|||
H -->|有音频| I[先暂停音频再播放背景音乐] |
|||
H -->|无音频| J[直接播放背景音乐] |
|||
``` |
|||
|
|||
## 问题描述 |
|||
|
|||
AudioControl组件在页面跳转时会出现以下问题: |
|||
1. 组件状态重置,图标显示不正确 |
|||
2. 音频实例丢失连接,但音频可能仍在播放 |
|||
3. 无法在其他页面控制正在播放的音频 |
|||
|
|||
## 解决方案 |
|||
|
|||
### 1. 全局音频实例管理 |
|||
|
|||
在`App.vue`的`globalData`中添加`currentAudio`属性,用于保存当前的音频实例: |
|||
|
|||
```javascript |
|||
globalData: { |
|||
// ... 其他属性 |
|||
currentAudio: null // 全局音频实例 |
|||
} |
|||
``` |
|||
|
|||
### 2. AudioControl组件优化 |
|||
|
|||
#### 状态同步机制 |
|||
- 组件挂载时检查全局音频状态 |
|||
- 复用已存在的音频实例(如果URL匹配) |
|||
- 组件销毁时不销毁全局音频实例 |
|||
|
|||
#### 核心方法改进 |
|||
```javascript |
|||
// 检查全局音频状态 |
|||
checkGlobalAudioState() { |
|||
const app = getApp(); |
|||
if (app && app.globalData && app.globalData.currentAudio) { |
|||
const globalAudio = app.globalData.currentAudio; |
|||
if (globalAudio.src === this.audioSrc) { |
|||
this.isAudioPlaying = !globalAudio.paused; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 初始化音频时复用全局实例 |
|||
initAudio() { |
|||
const app = getApp(); |
|||
if (app && app.globalData && app.globalData.currentAudio) { |
|||
if (app.globalData.currentAudio.src === this.audioSrc) { |
|||
// 复用现有实例 |
|||
this.audioContext = app.globalData.currentAudio; |
|||
this.isAudioPlaying = !this.audioContext.paused; |
|||
return; |
|||
} |
|||
} |
|||
// 创建新实例... |
|||
} |
|||
``` |
|||
|
|||
### 3. 全局音频管理工具 |
|||
|
|||
创建了`utils/globalAudioManager.js`工具类,提供统一的音频控制接口: |
|||
|
|||
```javascript |
|||
// 在任何页面或组件中使用 |
|||
uni.$globalAudio.pauseCurrentAudio(); // 暂停当前音频 |
|||
uni.$globalAudio.playCurrentAudio(); // 播放当前音频 |
|||
uni.$globalAudio.isAudioPlaying(); // 检查播放状态 |
|||
uni.$globalAudio.getCurrentAudioSrc(); // 获取当前音频源 |
|||
``` |
|||
|
|||
## 使用示例 |
|||
|
|||
### 在页面中控制音频 |
|||
|
|||
```vue |
|||
<template> |
|||
<view> |
|||
<!-- 音频控制组件 --> |
|||
<AudioControl :audioSrc="audioUrl" /> |
|||
|
|||
<!-- 手动控制按钮 --> |
|||
<button @click="toggleAudio">切换音频</button> |
|||
<button @click="stopAudio">停止音频</button> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import AudioControl from '@/components/AudioControl.vue'; |
|||
|
|||
export default { |
|||
components: { AudioControl }, |
|||
data() { |
|||
return { |
|||
audioUrl: this.showImg('/uploads/audio/chapter1.mp3') |
|||
} |
|||
}, |
|||
methods: { |
|||
toggleAudio() { |
|||
if (uni.$globalAudio.isAudioPlaying()) { |
|||
uni.$globalAudio.pauseCurrentAudio(); |
|||
} else { |
|||
uni.$globalAudio.playCurrentAudio(); |
|||
} |
|||
}, |
|||
|
|||
stopAudio() { |
|||
uni.$globalAudio.stopCurrentAudio(); |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
### 在其他页面检查音频状态 |
|||
|
|||
```javascript |
|||
// 在任何页面的onShow生命周期中 |
|||
onShow() { |
|||
// 检查是否有音频在播放 |
|||
if (uni.$globalAudio.isAudioPlaying()) { |
|||
console.log('有音频正在播放:', uni.$globalAudio.getCurrentAudioSrc()); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## 技术优势 |
|||
|
|||
### ✅ **状态持久化** |
|||
- 音频实例在页面跳转时不会丢失 |
|||
- 组件状态能够正确同步全局音频状态 |
|||
|
|||
### ✅ **跨页面控制** |
|||
- 在任何页面都可以控制当前播放的音频 |
|||
- 提供统一的音频管理接口 |
|||
|
|||
### ✅ **资源优化** |
|||
- 避免创建多个音频实例 |
|||
- 自动清理无用的音频资源 |
|||
|
|||
### ✅ **用户体验** |
|||
- 页面跳转时音频播放不中断 |
|||
- 图标状态显示正确 |
|||
- 音频控制逻辑一致 |
|||
|
|||
## 注意事项 |
|||
|
|||
1. **页面生命周期**:音频实例与页面生命周期解耦,需要手动管理 |
|||
2. **内存管理**:确保在应用退出时正确清理音频资源 |
|||
3. **状态同步**:多个AudioControl组件需要监听相同的全局状态 |
|||
4. **错误处理**:增强错误处理机制,确保音频异常时的状态恢复 |
|||
|
|||
## 兼容性 |
|||
|
|||
- ✅ uni-app |
|||
- ✅ 小程序环境 |
|||
- ✅ H5环境 |
|||
- ✅ APP环境 |
@ -1,144 +0,0 @@ |
|||
# 音频与背景音乐交互功能说明 |
|||
|
|||
## Bug修复记录 |
|||
|
|||
### 问题描述 |
|||
当音频播放时点击背景音乐按钮,背景音乐暂停音频并开始播放。此时再点击关闭背景音乐,图标显示关闭状态但背景音乐仍在播放。 |
|||
|
|||
### 问题原因 |
|||
1. 点击背景音乐按钮时会发送事件暂停音频 |
|||
2. 音频暂停时会调用restoreBackgroundMusic恢复背景音乐 |
|||
3. 然后MusicControl再执行自己的切换逻辑 |
|||
4. 导致背景音乐被恢复后又被操作,状态混乱 |
|||
|
|||
### 解决方案 |
|||
1. **MusicControl组件优化**: |
|||
- 检测是否有音频在播放 |
|||
- 如有音频,先暂停音频,延迟执行背景音乐切换 |
|||
- 如无音频,直接切换背景音乐状态 |
|||
|
|||
2. **AudioControl组件优化**: |
|||
- 在handleBackgroundMusicToggle中只暂停音频 |
|||
- 不自动恢复背景音乐,让MusicControl自己控制 |
|||
|
|||
### 修复后的交互流程 |
|||
```mermaid |
|||
graph TD |
|||
A[点击背景音乐按钮] --> B{是否有音频播放?} |
|||
B -->|是| C[发送暂停音频事件] |
|||
C --> D[AudioControl暂停音频\n不恢复背景音乐] |
|||
D --> E[延迟100ms后切换背景音乐] |
|||
B -->|否| F[直接切换背景音乐状态] |
|||
``` |
|||
|
|||
## 实现的功能 |
|||
|
|||
### 🎵 **背景音乐控制音频** |
|||
当点击背景音乐控制按钮时: |
|||
- 如果有音频正在播放,会自动暂停音频 |
|||
- 然后正常切换背景音乐的播放/暂停状态 |
|||
|
|||
### 🎧 **音频控制背景音乐** |
|||
当点击音频控制按钮时: |
|||
- 播放音频时自动暂停背景音乐 |
|||
- 暂停音频时自动恢复背景音乐 |
|||
- 音频播放结束时自动恢复背景音乐 |
|||
|
|||
## 技术实现 |
|||
|
|||
### 事件通信机制 |
|||
使用uni-app的全局事件机制实现组件间通信: |
|||
|
|||
```javascript |
|||
// AudioControl组件发送事件 |
|||
uni.$emit('audioPlaying', true/false); |
|||
|
|||
// MusicControl组件发送事件 |
|||
uni.$emit('backgroundMusicToggle'); |
|||
|
|||
// 组件监听事件 |
|||
uni.$on('eventName', this.handlerFunction); |
|||
``` |
|||
|
|||
### 交互流程 |
|||
|
|||
#### 点击背景音乐按钮: |
|||
```mermaid |
|||
graph TD |
|||
A[点击背景音乐按钮] --> B[发送backgroundMusicToggle事件] |
|||
B --> C[AudioControl收到事件] |
|||
C --> D{音频是否在播放?} |
|||
D -->|是| E[暂停音频] |
|||
D -->|否| F[继续背景音乐操作] |
|||
E --> G[恢复背景音乐] |
|||
F --> H[切换背景音乐状态] |
|||
``` |
|||
|
|||
#### 点击音频按钮: |
|||
```mermaid |
|||
graph TD |
|||
A[点击音频按钮] --> B{当前音频状态?} |
|||
B -->|未播放| C[暂停背景音乐] |
|||
C --> D[播放音频] |
|||
D --> E[发送audioPlaying:true事件] |
|||
B -->|正在播放| F[暂停音频] |
|||
F --> G[恢复背景音乐] |
|||
G --> H[发送audioPlaying:false事件] |
|||
``` |
|||
|
|||
## 使用方法 |
|||
|
|||
在页面中同时使用两个组件: |
|||
|
|||
```vue |
|||
<template> |
|||
<view class="page-container"> |
|||
<!-- 页面内容 --> |
|||
|
|||
<!-- 音频控制组件 --> |
|||
<AudioControl :audioSrc="audioUrl" /> |
|||
|
|||
<!-- 背景音乐控制组件 --> |
|||
<MusicControl /> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import AudioControl from '@/components/AudioControl.vue'; |
|||
import MusicControl from '@/components/MusicControl.vue'; |
|||
|
|||
export default { |
|||
components: { |
|||
AudioControl, |
|||
MusicControl |
|||
}, |
|||
data() { |
|||
return { |
|||
audioUrl: this.showImg('/uploads/audio/your-audio.mp3') |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
``` |
|||
|
|||
## 优势特点 |
|||
|
|||
### ✅ **智能交互** |
|||
- 两个组件能够智能感知对方的状态 |
|||
- 避免同时播放音频和背景音乐造成的冲突 |
|||
- 提供良好的用户体验 |
|||
|
|||
### ✅ **解耦设计** |
|||
- 组件间通过事件通信,保持松耦合 |
|||
- 每个组件都能独立工作 |
|||
- 易于维护和扩展 |
|||
|
|||
### ✅ **状态同步** |
|||
- 实时同步音频和背景音乐的播放状态 |
|||
- 确保状态的一致性和准确性 |
|||
|
|||
## 注意事项 |
|||
|
|||
1. **事件监听清理**:组件销毁时会自动清理事件监听,避免内存泄漏 |
|||
2. **状态管理**:两个组件都维护各自的状态,通过事件保持同步 |
|||
3. **错误处理**:包含完善的错误处理机制,确保功能稳定运行 |
Loading…
Reference in new issue