record.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739
  1. <template>
  2. <Theme>
  3. <view class="record-page">
  4. <!-- 页面标题 -->
  5. <view class="page-title">录音留言</view>
  6. <!-- 录音区域 -->
  7. <view class="record-section">
  8. <view
  9. class="record-area"
  10. :class="{ recording: isRecording }"
  11. @click="toggleRecord"
  12. >
  13. <view class="record-icon">
  14. <i class="icon-font icon-microphone"></i>
  15. </view>
  16. <view class="record-text">
  17. {{ isRecording ? "录音中..." : "点击开始录音" }}
  18. </view>
  19. </view>
  20. </view>
  21. <!-- 录音文件显示区域 -->
  22. <view class="audio-display" v-if="voicePath">
  23. <view class="audio-waveform">
  24. <view
  25. class="wave-bar"
  26. v-for="(bar, index) in displayWaveBars"
  27. :key="index"
  28. :style="{ height: bar + 'px' }"
  29. ></view>
  30. </view>
  31. <view class="audio-duration">{{ formatTime(recordTime) }}</view>
  32. <view class="audio-controls">
  33. <view
  34. class="control-btn play-btn"
  35. :class="{ playing: isPlaying }"
  36. @click="togglePlay"
  37. >
  38. <i
  39. class="icon-font"
  40. :class="isPlaying ? 'icon-pause' : 'icon-play'"
  41. ></i>
  42. </view>
  43. <view class="control-btn delete-btn" @click="deleteRecord">
  44. <i class="icon-font icon-delete"></i>
  45. </view>
  46. </view>
  47. </view>
  48. <!-- 空状态 -->
  49. <view class="empty-state" v-else>
  50. <view class="empty-text">暂无录音</view>
  51. </view>
  52. </view>
  53. <Tabbar page="index" />
  54. </Theme>
  55. </template>
  56. <script setup>
  57. import { ref, onMounted, onUnmounted } from "vue";
  58. import Theme from "@/components/Theme.vue";
  59. import Tabbar from "@/components/tabbar.vue";
  60. import { onShow } from "@dcloudio/uni-app";
  61. // 录音状态
  62. const isRecording = ref(false);
  63. const recordTime = ref(0);
  64. const recordTimer = ref(null);
  65. const voicePath = ref("");
  66. // 播放状态
  67. const isPlaying = ref(false);
  68. let audioContext = null;
  69. // 录音管理器
  70. let recorderManager = null;
  71. // 平台检测
  72. const platform = ref(uni.getSystemInfoSync().platform);
  73. const isH5 = ref(process.env.UNI_PLATFORM === "h5");
  74. const isApp = ref(process.env.UNI_PLATFORM === "app-plus");
  75. // 录音时的波形动画
  76. const waveBars = ref([]);
  77. // 显示时的静态波形
  78. const displayWaveBars = ref([]);
  79. // 开始录音
  80. const startRecord = () => {
  81. // 检查平台支持
  82. if (isH5.value) {
  83. // H5平台使用Web API
  84. startH5Record();
  85. } else {
  86. startAppRecord();
  87. }
  88. };
  89. // H5录音功能
  90. const startH5Record = () => {
  91. // 检查浏览器支持
  92. // if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
  93. // uni.showToast({
  94. // title: "浏览器不支持录音功能",
  95. // icon: "none",
  96. // });
  97. // return;
  98. // }
  99. navigator.mediaDevices
  100. .getUserMedia({ audio: true })
  101. .then((stream) => {
  102. // 创建MediaRecorder
  103. const mediaRecorder = new MediaRecorder(stream);
  104. const audioChunks = [];
  105. mediaRecorder.ondataavailable = (event) => {
  106. audioChunks.push(event.data);
  107. };
  108. mediaRecorder.onstop = () => {
  109. const audioBlob = new Blob(audioChunks, { type: "audio/wav" });
  110. const audioUrl = URL.createObjectURL(audioBlob);
  111. // 停止所有音频轨道
  112. stream.getTracks().forEach((track) => track.stop());
  113. // 保存录音文件
  114. saveRecordFile({
  115. tempFilePath: audioUrl,
  116. fileSize: audioBlob.size,
  117. });
  118. };
  119. mediaRecorder.onerror = (err) => {
  120. console.error("H5录音错误", err);
  121. isRecording.value = false;
  122. stopRecordTimer();
  123. stopWaveAnimation();
  124. uni.showToast({
  125. title: "录音失败",
  126. icon: "none",
  127. });
  128. };
  129. // 开始录音
  130. mediaRecorder.start();
  131. isRecording.value = true;
  132. startRecordTimer();
  133. startWaveAnimation();
  134. // 保存MediaRecorder引用用于停止
  135. recorderManager = mediaRecorder;
  136. })
  137. .catch((error) => {
  138. console.error("H5录音权限错误", error);
  139. uni.showToast({
  140. title: "需要录音权限",
  141. icon: "none",
  142. });
  143. });
  144. };
  145. // APP录音功能
  146. const startAppRecord = () => {
  147. try {
  148. recorderManager = uni.getRecorderManager();
  149. recorderManager.onStart(() => {
  150. console.log("录音开始");
  151. isRecording.value = true;
  152. startRecordTimer();
  153. startWaveAnimation();
  154. });
  155. recorderManager.onStop((res) => {
  156. console.log("录音结束", res);
  157. isRecording.value = false;
  158. stopRecordTimer();
  159. stopWaveAnimation();
  160. saveRecordFile(res);
  161. });
  162. recorderManager.onError((err) => {
  163. console.error("录音错误", err);
  164. isRecording.value = false;
  165. stopRecordTimer();
  166. stopWaveAnimation();
  167. uni.showToast({
  168. title: "录音失败",
  169. icon: "none",
  170. });
  171. });
  172. const options = {
  173. duration: 60000,
  174. sampleRate: 16000,
  175. numberOfChannels: 1,
  176. encodeBitRate: 96000,
  177. format: "mp3",
  178. frameSize: 50,
  179. };
  180. recorderManager.start(options);
  181. } catch (error) {
  182. console.error("录音初始化失败", error);
  183. uni.showToast({
  184. title: "录音初始化失败",
  185. icon: "none",
  186. });
  187. }
  188. };
  189. // 停止录音
  190. const stopRecord = () => {
  191. if (recorderManager) {
  192. if (isH5.value) {
  193. // H5平台停止MediaRecorder
  194. recorderManager.stop();
  195. } else if (isApp.value) {
  196. // APP平台停止uni.getRecorderManager
  197. recorderManager.stop();
  198. }
  199. }
  200. };
  201. // 切换录音状态
  202. const toggleRecord = () => {
  203. if (isRecording.value) {
  204. stopRecord();
  205. } else {
  206. startRecord();
  207. }
  208. };
  209. // 录音计时器
  210. const startRecordTimer = () => {
  211. recordTime.value = 0;
  212. recordTimer.value = setInterval(() => {
  213. recordTime.value++;
  214. }, 1000);
  215. };
  216. const stopRecordTimer = () => {
  217. if (recordTimer.value) {
  218. clearInterval(recordTimer.value);
  219. recordTimer.value = null;
  220. }
  221. };
  222. // 波形动画
  223. const startWaveAnimation = () => {
  224. const animateWave = () => {
  225. if (isRecording.value) {
  226. // 创建更真实的波浪效果
  227. waveBars.value = Array.from({ length: 25 }, (_, index) => {
  228. // 使用正弦波创建更自然的波浪
  229. const time = Date.now() / 100;
  230. const frequency = 0.1 + (index % 3) * 0.05; // 不同频率
  231. const amplitude = 30 + Math.random() * 20; // 随机振幅
  232. const baseHeight = 20;
  233. return (
  234. baseHeight +
  235. Math.abs(Math.sin(time * frequency + index * 0.5)) * amplitude
  236. );
  237. });
  238. setTimeout(animateWave, 150);
  239. }
  240. };
  241. animateWave();
  242. };
  243. const stopWaveAnimation = () => {
  244. waveBars.value = [];
  245. };
  246. // 生成静态波形显示
  247. const generateDisplayWave = () => {
  248. // 根据录音时长生成静态波形
  249. const duration = recordTime.value;
  250. const waveCount = Math.min(30, Math.max(15, duration * 2)); // 根据时长调整波形数量
  251. displayWaveBars.value = Array.from({ length: waveCount }, (_, index) => {
  252. // 使用录音时长作为种子,生成固定的波形
  253. const seed = duration + index;
  254. const amplitude = 20 + (seed % 40); // 20-60的振幅
  255. const baseHeight = 15;
  256. return baseHeight + amplitude;
  257. });
  258. };
  259. // 保存录音文件
  260. const saveRecordFile = (res) => {
  261. voicePath.value = res.tempFilePath;
  262. // 生成静态波形显示
  263. generateDisplayWave();
  264. // 保存到本地存储
  265. uni.setStorageSync("voicePath", voicePath.value);
  266. uni.setStorageSync("recordTime", recordTime.value);
  267. uni.showToast({
  268. title: "录音保存成功",
  269. icon: "success",
  270. });
  271. };
  272. // 播放录音
  273. const togglePlay = () => {
  274. if (isPlaying.value) {
  275. // 停止播放
  276. stopPlay();
  277. } else {
  278. // 开始播放
  279. startPlay();
  280. }
  281. };
  282. // 开始播放
  283. const startPlay = () => {
  284. if (!voicePath.value) {
  285. uni.showToast({
  286. title: "没有录音文件",
  287. icon: "none",
  288. });
  289. return;
  290. }
  291. if (isH5.value) {
  292. // H5平台使用HTML5 Audio
  293. startH5Play();
  294. } else if (isApp.value) {
  295. // APP平台使用uni.createInnerAudioContext
  296. startAppPlay();
  297. } else {
  298. uni.showToast({
  299. title: "当前平台不支持播放功能",
  300. icon: "none",
  301. });
  302. }
  303. };
  304. // H5播放功能
  305. const startH5Play = () => {
  306. if (audioContext) {
  307. audioContext.pause();
  308. audioContext = null;
  309. }
  310. try {
  311. audioContext = new Audio(voicePath.value);
  312. audioContext.onplay = () => {
  313. isPlaying.value = true;
  314. };
  315. audioContext.onended = () => {
  316. isPlaying.value = false;
  317. };
  318. audioContext.onerror = (err) => {
  319. console.error("H5播放错误", err);
  320. isPlaying.value = false;
  321. uni.showToast({
  322. title: "播放失败",
  323. icon: "none",
  324. });
  325. };
  326. audioContext.play();
  327. } catch (error) {
  328. console.error("H5播放初始化失败", error);
  329. uni.showToast({
  330. title: "播放功能不支持",
  331. icon: "none",
  332. });
  333. }
  334. };
  335. // APP播放功能
  336. const startAppPlay = () => {
  337. if (audioContext) {
  338. audioContext.destroy();
  339. }
  340. try {
  341. audioContext = uni.createInnerAudioContext();
  342. audioContext.src = voicePath.value;
  343. audioContext.onPlay(() => {
  344. isPlaying.value = true;
  345. });
  346. audioContext.onEnded(() => {
  347. isPlaying.value = false;
  348. });
  349. audioContext.onError((err) => {
  350. console.error("APP播放错误", err);
  351. isPlaying.value = false;
  352. uni.showToast({
  353. title: "播放失败",
  354. icon: "none",
  355. });
  356. });
  357. audioContext.play();
  358. } catch (error) {
  359. console.error("APP播放初始化失败", error);
  360. uni.showToast({
  361. title: "播放功能不支持",
  362. icon: "none",
  363. });
  364. }
  365. };
  366. // 停止播放
  367. const stopPlay = () => {
  368. if (audioContext) {
  369. if (isH5.value) {
  370. // H5平台停止Audio
  371. audioContext.pause();
  372. audioContext = null;
  373. } else if (isApp.value) {
  374. // APP平台停止uni.createInnerAudioContext
  375. audioContext.stop();
  376. audioContext.destroy();
  377. audioContext = null;
  378. }
  379. }
  380. isPlaying.value = false;
  381. };
  382. // 删除录音
  383. const deleteRecord = () => {
  384. uni.showModal({
  385. title: "确认删除",
  386. content: "确定要删除这条录音吗?",
  387. success: (res) => {
  388. if (res.confirm) {
  389. // 如果正在播放,先停止
  390. if (isPlaying.value) {
  391. stopPlay();
  392. }
  393. // 删除录音文件
  394. if (voicePath.value) {
  395. if (isH5.value) {
  396. // H5平台释放Blob URL
  397. try {
  398. URL.revokeObjectURL(voicePath.value);
  399. console.log("H5文件URL释放成功");
  400. } catch (error) {
  401. console.error("H5文件URL释放失败", error);
  402. }
  403. } else if (isApp.value) {
  404. // APP平台删除文件
  405. try {
  406. uni.getFileSystemManager().unlink({
  407. filePath: voicePath.value,
  408. success: () => {
  409. console.log("文件删除成功");
  410. },
  411. fail: (err) => {
  412. console.error("文件删除失败", err);
  413. },
  414. });
  415. } catch (error) {
  416. console.error("文件删除异常", error);
  417. }
  418. }
  419. }
  420. // 清除录音数据
  421. voicePath.value = "";
  422. recordTime.value = 0;
  423. displayWaveBars.value = [];
  424. // 更新本地存储
  425. uni.removeStorageSync("voicePath");
  426. uni.removeStorageSync("recordTime");
  427. uni.showToast({
  428. title: "删除成功",
  429. icon: "success",
  430. });
  431. }
  432. },
  433. });
  434. };
  435. // 格式化时间
  436. const formatTime = (seconds) => {
  437. const mins = Math.floor(seconds / 60);
  438. const secs = seconds % 60;
  439. return `${mins.toString().padStart(2, "0")}:${secs
  440. .toString()
  441. .padStart(2, "0")}`;
  442. };
  443. // 提交表单
  444. const submitForm = () => {
  445. if (!voicePath.value) {
  446. uni.showToast({
  447. title: "请先录制语音",
  448. icon: "none",
  449. });
  450. return;
  451. }
  452. // 这里可以调用后台接口提交表单数据
  453. const formData = {
  454. voiceFile: voicePath.value, // 录音文件路径
  455. voiceDuration: recordTime.value, // 录音时长
  456. // 其他表单字段...
  457. };
  458. console.log("提交表单数据:", formData);
  459. uni.showToast({
  460. title: "表单提交成功",
  461. icon: "success",
  462. });
  463. // 实际项目中这里应该调用API
  464. // uni.request({
  465. // url: 'your-api-url',
  466. // method: 'POST',
  467. // data: formData,
  468. // success: (res) => {
  469. // Toast("提交成功");
  470. // },
  471. // fail: (err) => {
  472. // Toast("提交失败");
  473. // }
  474. // });
  475. };
  476. // 加载录音
  477. const loadRecord = () => {
  478. try {
  479. const savedVoicePath = uni.getStorageSync("voicePath");
  480. const savedRecordTime = uni.getStorageSync("recordTime");
  481. if (savedVoicePath) {
  482. voicePath.value = savedVoicePath;
  483. recordTime.value = savedRecordTime || 0;
  484. generateDisplayWave();
  485. }
  486. } catch (error) {
  487. console.error("加载录音失败", error);
  488. }
  489. };
  490. onMounted(() => {
  491. loadRecord();
  492. });
  493. onUnmounted(() => {
  494. // 清理资源
  495. stopRecordTimer();
  496. stopPlay();
  497. });
  498. onShow(() => {
  499. // 页面显示时重新加载录音
  500. loadRecord();
  501. });
  502. </script>
  503. <style lang="less" scoped>
  504. .record-page {
  505. min-height: 100vh;
  506. background: #f5f5f5;
  507. padding: 20px;
  508. padding-bottom: 120rpx;
  509. .page-title {
  510. font-size: 18px;
  511. font-weight: bold;
  512. color: #000;
  513. margin-bottom: 20px;
  514. }
  515. .record-section {
  516. margin-bottom: 20px;
  517. .record-area {
  518. background: #fff;
  519. border: 2px solid #007aff;
  520. border-radius: 12px;
  521. padding: 20px;
  522. display: flex;
  523. align-items: center;
  524. cursor: pointer;
  525. transition: all 0.3s ease;
  526. &.recording {
  527. border-color: #ff3b30;
  528. background: #fff5f5;
  529. }
  530. .record-icon {
  531. width: 24px;
  532. height: 24px;
  533. margin-right: 12px;
  534. display: flex;
  535. align-items: center;
  536. justify-content: center;
  537. .icon-font {
  538. font-size: 24px;
  539. color: #999;
  540. }
  541. }
  542. .record-text {
  543. font-size: 16px;
  544. color: #999;
  545. flex: 1;
  546. }
  547. }
  548. }
  549. .audio-display {
  550. background: #fff;
  551. border: 1px solid #e0e0e0;
  552. border-radius: 12px;
  553. padding: 16px;
  554. display: flex;
  555. align-items: center;
  556. gap: 12px;
  557. .audio-waveform {
  558. flex: 1;
  559. display: flex;
  560. align-items: center;
  561. gap: 2px;
  562. height: 40px;
  563. .wave-bar {
  564. width: 3px;
  565. background: #000;
  566. border-radius: 1.5px;
  567. min-height: 4px;
  568. }
  569. }
  570. .audio-duration {
  571. font-size: 16px;
  572. color: #000;
  573. font-weight: 500;
  574. min-width: 50px;
  575. text-align: center;
  576. }
  577. .audio-controls {
  578. display: flex;
  579. gap: 8px;
  580. .control-btn {
  581. width: 32px;
  582. height: 32px;
  583. border-radius: 50%;
  584. display: flex;
  585. align-items: center;
  586. justify-content: center;
  587. cursor: pointer;
  588. transition: all 0.2s ease;
  589. &.play-btn {
  590. background: #000;
  591. &.playing {
  592. background: #ff3b30;
  593. }
  594. .icon-font {
  595. font-size: 14px;
  596. color: #fff;
  597. }
  598. }
  599. &.delete-btn {
  600. background: #ff3b30;
  601. .icon-font {
  602. font-size: 14px;
  603. color: #fff;
  604. }
  605. }
  606. &:active {
  607. transform: scale(0.95);
  608. }
  609. }
  610. }
  611. }
  612. .empty-state {
  613. background: #fff;
  614. border: 1px solid #e0e0e0;
  615. border-radius: 12px;
  616. padding: 40px 20px;
  617. text-align: center;
  618. .empty-text {
  619. font-size: 16px;
  620. color: #999;
  621. }
  622. }
  623. }
  624. // 录音时的脉冲动画
  625. @keyframes pulse {
  626. 0% {
  627. transform: scale(1);
  628. opacity: 1;
  629. }
  630. 50% {
  631. transform: scale(1.05);
  632. opacity: 0.8;
  633. }
  634. 100% {
  635. transform: scale(1);
  636. opacity: 1;
  637. }
  638. }
  639. .recording {
  640. animation: pulse 1.5s infinite;
  641. }
  642. </style>