四零语境前端代码仓库
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
12 KiB

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