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

489 lines
11 KiB

  1. <template>
  2. <view class="article-detail">
  3. <!-- 导航栏 -->
  4. <uv-navbar
  5. :placeholder="true"
  6. left-icon="arrow-left"
  7. :title="articleData.title || '文章详情'"
  8. @leftClick="goBack"
  9. ></uv-navbar>
  10. <!-- 文章内容 -->
  11. <view class="article-container" v-if="articleData.id">
  12. <!-- 文章标题 -->
  13. <view class="article-title">{{ articleData.title }}</view>
  14. <!-- 文章信息 -->
  15. <view class="article-info">
  16. <text class="article-time">{{ formatTime(articleData.createTime) }}</text>
  17. <text class="article-author">{{ articleData.createBy }}</text>
  18. </view>
  19. <!-- 文章内容 -->
  20. <view class="article-content">
  21. <rich-text :nodes="articleData.content"></rich-text>
  22. </view>
  23. </view>
  24. <!-- 加载状态 -->
  25. <view class="loading-container" v-else-if="loading">
  26. <uv-loading-icon mode="circle"></uv-loading-icon>
  27. <text class="loading-text">加载中...</text>
  28. </view>
  29. <!-- 音频播放悬浮按钮 -->
  30. <view
  31. class="audio-float-button"
  32. v-if="hasAudio && articleData.id"
  33. @click="toggleAudio"
  34. >
  35. <uv-icon
  36. :name="isPlaying ? 'pause-circle-fill' : 'play-circle-fill'"
  37. size="50"
  38. :color="isPlaying ? '#ff6b6b' : '#4CAF50'"
  39. ></uv-icon>
  40. </view>
  41. </view>
  42. </template>
  43. <script>
  44. export default {
  45. data() {
  46. return {
  47. articleId: '',
  48. articleData: {},
  49. loading: true,
  50. isPlaying: false,
  51. audioUrl: '',
  52. hasAudio: false,
  53. audioContext: null
  54. }
  55. },
  56. onLoad(options) {
  57. if (options.id) {
  58. this.articleId = options.id
  59. this.getArticleDetail()
  60. }
  61. },
  62. onUnload() {
  63. // 页面卸载时清理音频资源
  64. this.stopAudio();
  65. },
  66. methods: {
  67. // 创建HTML5 Audio实例并包装为uni-app兼容接口
  68. createHTML5Audio() {
  69. const audio = new Audio();
  70. // 包装为uni-app兼容的接口
  71. const wrappedAudio = {
  72. // 原生HTML5 Audio实例
  73. _nativeAudio: audio,
  74. // 基本属性
  75. get src() { return audio.src; },
  76. set src(value) { audio.src = value; },
  77. get duration() { return audio.duration || 0; },
  78. get currentTime() { return audio.currentTime || 0; },
  79. get paused() { return audio.paused; },
  80. // 支持倍速的关键属性
  81. get playbackRate() { return audio.playbackRate; },
  82. set playbackRate(value) {
  83. try {
  84. audio.playbackRate = value;
  85. } catch (error) {
  86. console.error('HTML5 Audio倍速设置失败:', error);
  87. }
  88. },
  89. // 基本方法
  90. play() {
  91. return audio.play().catch(error => {
  92. console.error('HTML5 Audio播放失败:', error);
  93. });
  94. },
  95. pause() {
  96. audio.pause();
  97. },
  98. stop() {
  99. audio.pause();
  100. audio.currentTime = 0;
  101. },
  102. seek(time) {
  103. audio.currentTime = time;
  104. },
  105. destroy() {
  106. audio.pause();
  107. audio.src = '';
  108. audio.load();
  109. },
  110. // 事件绑定方法
  111. onCanplay(callback) {
  112. audio.addEventListener('canplay', callback);
  113. },
  114. onPlay(callback) {
  115. audio.addEventListener('play', callback);
  116. },
  117. onPause(callback) {
  118. audio.addEventListener('pause', callback);
  119. },
  120. onEnded(callback) {
  121. audio.addEventListener('ended', callback);
  122. },
  123. onTimeUpdate(callback) {
  124. audio.addEventListener('timeupdate', callback);
  125. },
  126. onError(callback) {
  127. // 包装错误事件,过滤掉非关键错误
  128. const wrappedCallback = (error) => {
  129. // 只在有src且音频正在播放时才传递错误事件
  130. if (audio.src && audio.src.trim() !== '' && !audio.paused) {
  131. callback(error);
  132. } else {
  133. console.log('HTML5 Audio错误(已忽略):', {
  134. hasSrc: !!audio.src,
  135. paused: audio.paused,
  136. errorType: error.type || 'unknown'
  137. });
  138. }
  139. };
  140. audio.addEventListener('error', wrappedCallback);
  141. },
  142. // 移除事件监听
  143. offCanplay(callback) {
  144. audio.removeEventListener('canplay', callback);
  145. },
  146. offPlay(callback) {
  147. audio.removeEventListener('play', callback);
  148. },
  149. offPause(callback) {
  150. audio.removeEventListener('pause', callback);
  151. },
  152. offEnded(callback) {
  153. audio.removeEventListener('ended', callback);
  154. },
  155. offTimeUpdate(callback) {
  156. audio.removeEventListener('timeupdate', callback);
  157. },
  158. offError(callback) {
  159. audio.removeEventListener('error', callback);
  160. }
  161. };
  162. return wrappedAudio;
  163. },
  164. // 返回上一页
  165. goBack() {
  166. uni.navigateBack()
  167. },
  168. // 获取文章详情
  169. async getArticleDetail() {
  170. try {
  171. this.loading = true
  172. const res = await this.$api.home.getArticleDetail({ id: this.articleId })
  173. if (res.code === 200) {
  174. this.articleData = res.result
  175. // 处理音频数据
  176. if (res.result.audios && Object.keys(res.result.audios).length > 0) {
  177. // 获取第一个音频URL
  178. const audioKeys = Object.keys(res.result.audios)
  179. this.audioUrl = res.result.audios[audioKeys[0]]
  180. this.hasAudio = true
  181. }
  182. } else {
  183. uni.showToast({
  184. title: res.message || '获取文章详情失败',
  185. icon: 'none'
  186. })
  187. }
  188. } catch (error) {
  189. console.error('获取文章详情失败:', error)
  190. uni.showToast({
  191. title: '网络错误,请重试',
  192. icon: 'none'
  193. })
  194. } finally {
  195. this.loading = false
  196. }
  197. },
  198. // 切换音频播放状态
  199. toggleAudio() {
  200. if (!this.audioUrl) {
  201. console.error('音频URL为空');
  202. return;
  203. }
  204. try {
  205. if (this.isPlaying) {
  206. // 暂停音频
  207. this.pauseAudio();
  208. } else {
  209. // 播放音频
  210. this.playAudio();
  211. }
  212. } catch (error) {
  213. console.error('音频播放切换异常:', error);
  214. this.isPlaying = false;
  215. }
  216. },
  217. // 播放音频
  218. playAudio() {
  219. if (!this.audioUrl) {
  220. console.error('音频URL为空');
  221. return;
  222. }
  223. try {
  224. // 销毁旧的音频实例
  225. if (this.audioContext) {
  226. this.audioContext.destroy();
  227. this.audioContext = null;
  228. }
  229. // 平台判断:H5环境使用createHTML5Audio,其他环境使用uni.createInnerAudioContext
  230. if (uni.getSystemInfoSync().platform === 'devtools' || process.env.NODE_ENV === 'development' || typeof window !== 'undefined') {
  231. // H5环境:使用包装的HTML5 Audio
  232. this.audioContext = this.createHTML5Audio();
  233. } else {
  234. // 非H5环境(小程序等)
  235. this.audioContext = uni.createInnerAudioContext();
  236. }
  237. // 设置音频源
  238. this.audioContext.src = this.audioUrl;
  239. // 绑定事件监听
  240. this.audioContext.onPlay(() => {
  241. this.isPlaying = true;
  242. });
  243. this.audioContext.onPause(() => {
  244. this.isPlaying = false;
  245. });
  246. this.audioContext.onEnded(() => {
  247. this.isPlaying = false;
  248. });
  249. this.audioContext.onError((error) => {
  250. console.error('音频播放错误:', error);
  251. this.isPlaying = false;
  252. });
  253. // 开始播放
  254. this.audioContext.play();
  255. } catch (error) {
  256. console.error('音频播放异常:', error);
  257. this.isPlaying = false;
  258. }
  259. },
  260. // 暂停音频
  261. pauseAudio() {
  262. if (this.audioContext) {
  263. try {
  264. this.audioContext.pause();
  265. this.isPlaying = false;
  266. } catch (error) {
  267. console.error('暂停音频失败:', error);
  268. }
  269. }
  270. },
  271. // 停止音频
  272. stopAudio() {
  273. if (this.audioContext) {
  274. try {
  275. this.audioContext.stop();
  276. this.audioContext.destroy();
  277. this.isPlaying = false;
  278. this.audioContext = null;
  279. } catch (error) {
  280. console.error('停止音频失败:', error);
  281. }
  282. }
  283. },
  284. // 格式化时间
  285. formatTime(timeStr) {
  286. if (!timeStr) return ''
  287. // 处理iOS兼容性问题,将 "2025-10-23 16:36:43" 格式转换为 "2025/10/23 16:36:43"
  288. let formattedTimeStr = timeStr.replace(/-/g, '/')
  289. const date = new Date(formattedTimeStr)
  290. // 检查日期是否有效
  291. if (isNaN(date.getTime())) {
  292. console.warn('Invalid date format:', timeStr)
  293. return timeStr // 返回原始字符串
  294. }
  295. const year = date.getFullYear()
  296. const month = String(date.getMonth() + 1).padStart(2, '0')
  297. const day = String(date.getDate()).padStart(2, '0')
  298. return `${year}-${month}-${day}`
  299. }
  300. }
  301. }
  302. </script>
  303. <style lang="scss" scoped>
  304. .article-detail {
  305. min-height: 100vh;
  306. background: #fff;
  307. }
  308. .article-container {
  309. padding: 30rpx;
  310. }
  311. .article-title {
  312. font-size: 40rpx;
  313. font-weight: 600;
  314. color: #333;
  315. line-height: 1.4;
  316. margin-bottom: 30rpx;
  317. }
  318. .article-info {
  319. display: flex;
  320. align-items: center;
  321. gap: 30rpx;
  322. margin-bottom: 40rpx;
  323. padding-bottom: 30rpx;
  324. border-bottom: 1px solid #f0f0f0;
  325. .article-time {
  326. font-size: 26rpx;
  327. color: #999;
  328. }
  329. .article-author {
  330. font-size: 26rpx;
  331. color: #666;
  332. &::before {
  333. content: '作者:';
  334. }
  335. }
  336. }
  337. .article-content {
  338. font-size: 32rpx;
  339. line-height: 1.8;
  340. color: #333;
  341. // 富文本内容样式
  342. :deep(.rich-text) {
  343. p {
  344. margin-bottom: 20rpx;
  345. line-height: 1.8;
  346. }
  347. img {
  348. max-width: 100%;
  349. height: auto;
  350. border-radius: 8rpx;
  351. margin: 20rpx 0;
  352. }
  353. section {
  354. margin: 20rpx 0;
  355. }
  356. }
  357. }
  358. .loading-container {
  359. display: flex;
  360. flex-direction: column;
  361. align-items: center;
  362. justify-content: center;
  363. height: 400rpx;
  364. .loading-text {
  365. margin-top: 20rpx;
  366. font-size: 28rpx;
  367. color: #999;
  368. }
  369. }
  370. // 音频播放悬浮按钮
  371. .audio-float-button {
  372. position: fixed;
  373. right: 30rpx;
  374. bottom: 100rpx;
  375. width: 100rpx;
  376. height: 100rpx;
  377. border-radius: 50%;
  378. background: rgba(255, 255, 255, 0.9);
  379. box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
  380. display: flex;
  381. align-items: center;
  382. justify-content: center;
  383. z-index: 999;
  384. transition: all 0.3s ease;
  385. &:active {
  386. transform: scale(0.95);
  387. }
  388. // 添加呼吸动画效果
  389. &::before {
  390. content: '';
  391. position: absolute;
  392. top: -10rpx;
  393. left: -10rpx;
  394. right: -10rpx;
  395. bottom: -10rpx;
  396. border-radius: 50%;
  397. background: rgba(76, 175, 80, 0.2);
  398. animation: pulse 2s infinite;
  399. z-index: -1;
  400. }
  401. }
  402. @keyframes pulse {
  403. 0% {
  404. transform: scale(1);
  405. opacity: 1;
  406. }
  407. 50% {
  408. transform: scale(1.1);
  409. opacity: 0.7;
  410. }
  411. 100% {
  412. transform: scale(1);
  413. opacity: 1;
  414. }
  415. }
  416. </style>