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