当前位置: 首页 > news >正文

微信小程序实现长按录音,点击播放等功能,CSS实现语音录制动画效果

有一个需求需要在微信小程序上实现一个长按时进行语音录制,录制时间最大为60秒,录制完成后,可点击播放,播放时再次点击停止播放,可以反复录制,新录制的语音把之前的语音覆盖掉,也可以主动长按删除

// index.js
const recorderManager = wx.getRecorderManager()
const innerAudioContext = wx.createInnerAudioContext()
let recordingTimerInterval = null // 录音时长计时器
let countdownTimerInterval = null // 倒计时计时器
let playbackCountdownInterval = null // 播放倒计时计时器Page({
/*** 页面的初始数据*/data: {// 语音输入部分inputType: 'input',count: null, // 录制倒计时longPress: '1', // 1显示 按住说话 2显示 说话中delShow: false, // 删除提示框显示隐藏time: 0, // 录音时长recordedDuration: 0, // 已录制音频的时长duration: 60000, // 录音最大值ms 60000/1分钟tempFilePath: '', //音频路径playStatus: 0, //录音播放状态 0:未播放 1:正在播放currentTime: 0, // 当前播放进度(秒)remain: 0, // 当前剩余时长(秒) = duration - currentTimewarningShown: false, // 是否已显示50秒提示minDuration: 2, // 录音最小时长秒数animationArray: Array.from({ length: 15 }, (_, index) => {// length 这个名字就不再需要,因为我们已经在这里写死了 15const centerIndex = Math.floor((15 - 1) / 2) // 7const distance = Math.abs(index - centerIndex)// 中心延迟为 0,向外越来越大const delay = distance * 0.2return { delay }})},/*** 开始录音倒计时* @param {number} val - 倒计时秒数*/startCountdown(val) {this.setData({count: Number(val)})countdownTimerInterval = setInterval(() => {if (this.data.count > 0) {this.setData({count: this.data.count - 1})} else {this.setData({longPress: '1'})clearInterval(countdownTimerInterval)countdownTimerInterval = null}}, 1000)},/*** 开始录音时长计时*/startRecordingTimer() {if (recordingTimerInterval) return // 防止重复启动计时器recordingTimerInterval = setInterval(() => {this.setData({time: this.data.time + 1})// 当录音时长达到50秒且未显示提示时,显示提示if (this.data.time === 50 && !this.data.warningShown) {wx.showToast({title: '录音即将结束',icon: 'none',duration: 2000})this.setData({warningShown: true})}// 如果录音时长达到最大值,自动停止录音if (this.data.time >= this.data.duration / 1000) {wx.showToast({title: '录音已达到最大时长',icon: 'none'})this.touchendBtn()}}, 1000)},/*** 停止录音时长计时* @param {string} newTempFilePath - 新录音的文件路径*/stopRecordingTimer(newTempFilePath) {if (recordingTimerInterval) {clearInterval(recordingTimerInterval)recordingTimerInterval = null}const duration = this.data.timeif (duration >= this.data.minDuration) {this.setData({recordedDuration: duration,tempFilePath: newTempFilePath},() => {console.log('录音已停止,时长:', this.data.recordedDuration, '秒')})} else {// 录音时长过短,提示用户wx.showToast({title: '录音时间太短',icon: 'none'})// 不覆盖之前的 tempFilePath,保留旧的录音// 仅重置 timethis.setData({time: 0},() => {console.log('录音时间太短,不保存此次录音。')})}},/*** 开始播放倒计时* @param {number} val - 播放倒计时秒数*/startPlaybackCountdown(val) {// 先停止可能存在的旧计时器if (playbackCountdownInterval) {clearInterval(playbackCountdownInterval)playbackCountdownInterval = null}this.setData({count: Number(val)})playbackCountdownInterval = setInterval(() => {if (this.data.count > 0) {this.setData({count: this.data.count - 1})} else {// 播放结束this.setData({playStatus: 0,count: null})innerAudioContext.stop()clearInterval(playbackCountdownInterval)playbackCountdownInterval = null}}, 1000)},/*** 停止播放倒计时*/stopPlaybackCountdown() {if (playbackCountdownInterval) {clearInterval(playbackCountdownInterval)playbackCountdownInterval = null}this.setData({count: null})},/*** 清除所有计时器*/clearAllTimers() {if (recordingTimerInterval) {clearInterval(recordingTimerInterval)recordingTimerInterval = null}if (countdownTimerInterval) {clearInterval(countdownTimerInterval)countdownTimerInterval = null}if (playbackCountdownInterval) {clearInterval(playbackCountdownInterval)playbackCountdownInterval = null}},/*** 重置录音状态*/resetRecordingState() {this.setData({longPress: '1',time: 0,recordedDuration: 0,count: null,warningShown: false // 重置警告提示})this.stopRecordingTimer()this.stopCountdownTimer()},/*** 处理输入类型变化* @param {object} e - 事件对象*/handleChangeInputType(e) {const { type } = e.currentTarget.datasetthis.setData({inputType: type})},/*** 检查录音权限*/checkRecordPermission() {wx.getSetting({success: res => {if (!res.authSetting['scope.record']) {// 没有录音权限,尝试授权wx.authorize({scope: 'scope.record',success: () => {// 授权成功,可以开始录音this.startRecording()},fail: () => {// 授权失败,提示用户前往设置授权wx.showModal({title: '授权提示',content: '录音权限未授权,请前往设置授权',success: res => {if (res.confirm) {wx.openSetting()}}})}})} else {// 已经授权,可以开始录音this.startRecording()}},fail: () => {// 获取设置失败,提示用户wx.showToast({title: '获取权限失败,请重试',icon: 'none'})}})},/*** 开始录音的封装函数*/startRecording() {this.setData({longPress: '2',time: 0, // 在开始录音前重置 timewarningShown: false // 重置警告提示})this.startCountdown(this.data.duration / 1000) // 录音倒计时60秒//recorderManager.stop() // 确保之前的录音已停止this.startRecordingTimer()const options = {duration: this.data.duration * 1000, // 指定录音的时长,单位 mssampleRate: 16000, // 采样率numberOfChannels: 1, // 录音通道数encodeBitRate: 96000, // 编码码率format: 'mp3', // 音频格式,有效值 aac/mp3frameSize: 10 // 指定帧大小,单位 KB}recorderManager.start(options)},/*** 长按录音事件*/longpressBtn() {this.checkRecordPermission()},/*** 长按松开录音事件*/touchendBtn() {this.setData({longPress: '1'})recorderManager.stop()this.stopCountdownTimer()},/*** 停止倒计时计时器*/stopCountdownTimer() {if (countdownTimerInterval) {clearInterval(countdownTimerInterval)countdownTimerInterval = null}this.setData({count: null})},/*** 播放录音*/playBtn() {if (!this.data.tempFilePath) {wx.showToast({title: '没有录音文件',icon: 'none'})return}// 如果已经在播放,就先停止if (this.data.playStatus === 1) {innerAudioContext.stop()// 重置状态this.setData({playStatus: 0,currentTime: 0,remain: 0})} else {// 重新开始播放console.log('开始播放', this.data.tempFilePath)innerAudioContext.src = this.data.tempFilePath// 在 iOS 下,即使系统静音,也能播放音频innerAudioContext.obeyMuteSwitch = false// 播放innerAudioContext.play()// playStatus 会在 onPlay 中置为 1// 如果想在点击之后就立即把 playStatus 置为 1 也行}},/*** 生命周期函数--监听页面加载*/onLoad(options) {// 绑定录音停止事件recorderManager.onStop(res => {// 将新录音的文件路径传递给 stopRecordingTimerthis.stopRecordingTimer(res.tempFilePath)console.log('录音已停止,文件路径:', res.tempFilePath)console.log('录音时长:', this.data.recordedDuration, '秒')})// 绑定录音开始事件recorderManager.onStart(res => {console.log('录音开始', res)})// 绑定录音错误事件recorderManager.onError(err => {console.error('录音错误:', err)wx.showToast({title: '录音失败,请重试',icon: 'none'})this.resetRecordingState()})// 当音频真正开始播放时innerAudioContext.onPlay(() => {console.log('onPlay 音频开始播放')// 设置为播放状态this.setData({playStatus: 1})})// 绑定音频播放结束事件innerAudioContext.onEnded(() => {console.log('onEnded 音频播放结束')// 停止播放并重置this.setData({playStatus: 0,currentTime: 0,remain: 0})// 如果想让界面上回到音频的总时长也可以手动 set remain = recordedDuration// 但通常播放结束,就显示 0 或不显示都行})innerAudioContext.onTimeUpdate(() => {const current = Math.floor(innerAudioContext.currentTime) // 取整或保留小数都可const total = Math.floor(innerAudioContext.duration)// 若 total 不准确(部分手机可能最初获取到是 0),可做一些保护if (total > 0) {const remain = total - currentthis.setData({currentTime: current,remain: remain > 0 ? remain : 0})}})// 绑定音频播放错误事件innerAudioContext.onError(err => {console.error('播放错误:', err)wx.showToast({title: '播放失败,请重试',icon: 'none'})this.setData({playStatus: 0,currentTime: 0,remain: 0})})},/*** 生命周期函数--监听页面卸载*/onUnload() {this.clearAllTimers()recorderManager.stop()innerAudioContext.stop()innerAudioContext.destroy()},
})
// index.wxml
<view wx:else class="voice-input"><view wx:if="{{tempFilePath !== ''}}" class="voice-msg" bind:tap="playBtn"><imagesrc="{{ playStatus === 0 ? '/sendingaudio.png' : '/voice.gif' }}"mode="aspectFill"style="transform: rotate(180deg); width: 22rpx; height: 32rpx"/><text class="voice-msg-text">{{ playStatus === 1 ? (remain + "''") : (recordedDuration + "''") }}</text></view><viewclass="voice-input-btn {{longPress == '1' ? '' : 'record-btn-2'}}"bind:longpress="longpressBtn"bind:touchend="touchendBtn"><!-- 语音音阶动画 --><view class="prompt-layer prompt-layer-1" wx:if="{{longPress == '2'}}"><!-- <view class="prompt-layer prompt-layer-1" wx:if="{{longPress == '2'}}"> --><view class="prompt-loader"><viewclass="em"wx:for="{{animationArray}}"wx:key="index"style="--delay: {{item.delay}}s;"></view></view><text class="p">{{'剩余:' + count + 's' + (warningShown ? ',即将结束录音' : '')}}</text><text class="span">松手结束录音</text></view><text class="voice-input-btn-text">{{longPress == '1' ? '按住 说话' : '说话中...'}}</text></view></view>
/*  index.wxss */
.voice-btn {box-sizing: border-box;padding: 6rpx 16rpx;background: #2197ee;border-radius: 28rpx;display: flex;align-items: center;justify-content: center;gap: 10rpx;
}.voice-text {line-height: 42rpx;color: #ffffff;font-size: 30rpx;
}.voice-input {box-sizing: border-box;display: flex;flex-direction: column;padding: 30rpx 76rpx;
}.voice-msg {width: 100%;height: 56rpx;background: #95ec69;border-radius: 10rpx;box-shadow: 0 3rpx 6rpx rgba(0, 0, 0, 0.13);margin-bottom: 26rpx;box-sizing: border-box;padding: 0 20rpx;display: flex;align-items: center;gap: 16rpx;
}.voice-msg-text {color: #000000;font-size: 30rpx;line-height: 56rpx;
}.voice-input-btn {width: 100%;box-sizing: border-box;padding: 12rpx 0;background: #ffffff;border: 2rpx solid;border-color: #1f75e3;border-radius: 8rpx;box-sizing: border-box;text-align: center;position: relative;
}.voice-input-btn-text {color: #1f75e3;font-size: 36rpx;line-height: 50rpx;
}/* 提示小弹窗 */
.prompt-layer {border-radius: 16rpx;background: #2197ee;padding: 16rpx 32rpx;box-sizing: border-box;position: absolute;left: 50%;transform: translateX(-50%);
}.prompt-layer::after {content: '';display: block;border: 12rpx solid rgba(0, 0, 0, 0);border-top-color: #2197ee;position: absolute;bottom: -20rpx;left: 50%;transform: translateX(-50%);
}.prompt-layer-1 {font-size: 32rpx;width: 80%;text-align: center;display: flex;flex-direction: column;align-items: center;justify-content: center;top: -178rpx;
}
.prompt-layer-1 .p {color: #ffffff;
}
.prompt-layer-1 .span {color: rgba(255, 255, 255, 0.6);
}/* 语音音阶------------- */
/* 容器样式 */
.prompt-loader {width: 250rpx;height: 40rpx;display: flex;align-items: center; /* 对齐到容器底部 */justify-content: space-between;margin-bottom: 12rpx;
}/* 音阶条样式 */
.prompt-loader .em {background: #ffffff;width: 6rpx;border-radius: 6rpx;height: 40rpx;margin-right: 5rpx;/* 通用动画属性 */animation: load 2.5s infinite linear;animation-delay: var(--delay);will-change: transform;transform-origin: center
}/* 移除最后一个元素的右边距 */
.prompt-loader .em:last-child {margin-right: 0;
}/* 动画关键帧 */
@keyframes load {0% {transform: scaleY(1);}50% {transform: scaleY(0.1);}100% {transform: scaleY(1);}
}
.record-btn-2 {background-color: rgba(33, 151, 238, 0.2);
}

相关文章:

微信小程序实现长按录音,点击播放等功能,CSS实现语音录制动画效果

有一个需求需要在微信小程序上实现一个长按时进行语音录制&#xff0c;录制时间最大为60秒&#xff0c;录制完成后&#xff0c;可点击播放&#xff0c;播放时再次点击停止播放&#xff0c;可以反复录制&#xff0c;新录制的语音把之前的语音覆盖掉&#xff0c;也可以主动长按删…...

校园跑腿小程序---轮播图,导航栏开发

hello hello~ &#xff0c;这里是 code袁~&#x1f496;&#x1f496; &#xff0c;欢迎大家点赞&#x1f973;&#x1f973;关注&#x1f4a5;&#x1f4a5;收藏&#x1f339;&#x1f339;&#x1f339; &#x1f981;作者简介&#xff1a;一名喜欢分享和记录学习的在校大学生…...

详细全面讲解C++中重载、隐藏、覆盖的区别

文章目录 总结1、重载示例代码特点1. 模板函数和非模板函数重载2. 重载示例与调用规则示例代码调用规则解释3. 特殊情况与注意事项二义性问题 函数特化与重载的交互 2. 函数隐藏&#xff08;Function Hiding&#xff09;概念示例代码特点 3. 函数覆盖&#xff08;重写&#xff…...

一文读懂单片机的串口

目录 串口通信的基本概念 串口通信的关键参数 单片机串口的硬件连接 单片机串口的工作原理 数据发送过程 数据接收过程 单片机串口的编程实现 以51单片机为例 硬件连接 初始化串口 发送数据 接收数据 串口中断服务函数 代码示例 单片机串口的应用实例 单片机与…...

HTML5 网站模板

HTML5 网站模板 参考 HTML5 Website Templates...

mybatis分页插件:PageHelper、mybatis-plus-jsqlparser(解决SQL_SERVER2005连接分页查询OFFSET问题)

文章目录 引言I PageHelper坐标II mybatis-plus-jsqlparser坐标Spring Boot 添加分页插件自定义 Mapper 方法中使用分页注意事项解决SQL_SERVER2005连接分页查询OFFSET问题知识扩展MyBatis-Plus 框架结构mybatis-plus-jsqlparser的 Page 类引言 PageHelper import com.github.p…...

uniapp中rpx和upx的区别

在 UniApp 中&#xff0c;rpx 和 upx 是两种不同的单位&#xff0c;它们的主要区别在于适用的场景和计算方式。 ### rpx&#xff08;Responsive Pixel&#xff09; - **适用场景**&#xff1a;rpx 是一种响应式单位&#xff0c;主要用于小程序和移动端的布局。 - **计算方式**…...

什么是卷积网络中的平移不变性?平移shft在数据增强中的意义

今天来介绍一下数据增强中的平移shft操作和卷积网络中的平移不变性。 1、什么是平移 Shift 平移是指在数据增强&#xff08;data augmentation&#xff09;过程中&#xff0c;通过对输入图像或目标进行位置偏移&#xff08;平移&#xff09;&#xff0c;让目标在图像中呈现出…...

java.net.SocketException: Connection reset 异常原因分析和解决方法

导致此异常的原因&#xff0c;总结下来有三种情况&#xff1a; 一、服务器端偶尔出现了异常&#xff0c;导致连接关闭 解决方法&#xff1a; 采用出错重试机制 二、 服务器端和客户端使用的连接方式不一致 解决方法&#xff1a; 服务器端和客户端使用相同的连接方式&#xff…...

Maven 仓库的分类

Maven 是一个广泛使用的项目构建和依赖管理工具&#xff0c;在 Java 开发生态中占据重要地位。作为 Maven 的核心概念之一&#xff0c;仓库&#xff08;Repository&#xff09;扮演着至关重要的角色&#xff0c;用于存储项目的依赖、插件以及构建所需的各种资源。 了解 Maven 仓…...

隧道网络:为数据传输开辟安全通道

什么是隧道网络&#xff1f; 想象一下&#xff0c;你正在一个陌生的城市旅行&#xff0c;并且想要访问家里的电脑。但是&#xff0c;直接连接是不可能的&#xff0c;因为家庭网络通常受到防火墙或路由器的保护&#xff0c;不允许外部直接访问。这时候&#xff0c;隧道网络&…...

CentOS 7 下 Nginx 的详细安装与配置

1、安装方式 1.1、通过编译方式安装 下载Nginx1.16.1的安装包 https://nginx.org/download/nginx-1.16.1.tar.gz 下载后上传至/home目录下。 1.2、通过yum方式安装 这种方式安装更简单。 2、通过编译源码包安装Nginx 2.1、安装必要依赖 sudo yum -y install gcc gcc-c sudo…...

JAVA 使用apache poi实现EXCEL文件的输出;apache poi实现标题行的第一个字符为红色;EXCEL设置某几个字符为别的颜色

设置输出文件的列宽&#xff0c;防止文件过于丑陋 Sheet sheet workbook.createSheet(FileConstants.ERROR_FILE_SHEET_NAME); sheet.setColumnWidth(0, 40 * 256); sheet.setColumnWidth(1, 20 * 256); sheet.setColumnWidth(2, 20 * 256); sheet.setColumnWidth(3, 20 * 25…...

通过vba实现在PPT中添加计时器功能

目录 一、前言 二、具体实现步骤 1、准备 2、开启宏、打开开发工具 3、添加计时器显示控件 3.1、开启母版 3.2、插入计时器控件 4、vba代码实现 4.1、添加模块 4.2、添加代码 4.3、保存为pptm 5、效果展示 一、前言 要求/目标:在PPT中每一页上面增加一个计时器功能…...

检验统计量与p值笔记

一、背景 以雨量数据为例&#xff0c;当获得一个站点一年的日雨量数据后&#xff0c;我们需要估计该站点的雨量的概率分布情况&#xff0c;因此我们利用有参估计的方式如极大似然法估计得到了假定该随机变量服从某一分布的参数&#xff0c;从而得到该站点的概率密度函数&#x…...

【集成学习】Bagging、Boosting、Stacking算法详解

文章目录 1. 相关算法详解&#xff1a;2. 算法详细解释&#xff1a;2.1 Bagging&#xff1a;2.2 Boosting&#xff1a;2.3 Stacking&#xff1a;2.4 K-fold Multi-level Stacking&#xff1a; 集成学习&#xff08;Ensemble Learning&#xff09;是一种通过结合多个模型的预测结…...

Rabbit Rocket kafka 怎么实现消息有序消费和延迟消费的

在消息队列系统中&#xff0c;像 RabbitMQ、RocketMQ 和 Kafka 这样的系统&#xff0c;都支持不同的方式来实现消息的有序消费和延迟消费。下面我们分别探讨这些系统中如何实现这两种需求&#xff1a; 1. RabbitMQ&#xff1a;实现消息有序消费和延迟消费 有序消费&#xff1…...

【Ubuntu与Linux操作系统:五、文件与目录管理】

第5章 磁盘存储管理 5.1 Linux磁盘存储概述 磁盘存储是Linux系统存储数据的重要组件&#xff0c;它通过分区和文件系统组织和管理数据。Linux支持多种文件系统&#xff0c;如ext4、xfs和btrfs&#xff0c;并以块的形式管理存储设备。 1. 分区与文件系统&#xff1a; 分区&am…...

32_Redis分片集群原理

1.Redis集群分片 1.1 Redis集群分片介绍 Redis集群没有使用一致性hash,而是引入了哈希槽的概念。Redis集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽。 用于将密钥映射到散列插槽的基本算法如下: HASH_SLOT = CRC16(key) mod 16384 集群的每…...

微信小程序mp3音频播放组件,仅需传入url即可

// index.js // packageChat/components/audio-player/index.js Component({/*** 组件的属性列表*/properties: {/*** MP3 文件的 URL*/src: {type: String,value: ,observer(newVal, oldVal) {if (newVal ! oldVal && newVal) {// 如果 InnerAudioContext 已存在&…...

无法与IP建立连接,未能下载VSCode服务器

如题&#xff0c;在远程连接服务器的时候突然遇到了这个提示。 查阅了一圈&#xff0c;发现是VSCode版本自动更新惹的祸&#xff01;&#xff01;&#xff01; 在VSCode的帮助->关于这里发现前几天VSCode自动更新了&#xff0c;我的版本号变成了1.100.3 才导致了远程连接出…...

剑指offer20_链表中环的入口节点

链表中环的入口节点 给定一个链表&#xff0c;若其中包含环&#xff0c;则输出环的入口节点。 若其中不包含环&#xff0c;则输出null。 数据范围 节点 val 值取值范围 [ 1 , 1000 ] [1,1000] [1,1000]。 节点 val 值各不相同。 链表长度 [ 0 , 500 ] [0,500] [0,500]。 …...

dify打造数据可视化图表

一、概述 在日常工作和学习中&#xff0c;我们经常需要和数据打交道。无论是分析报告、项目展示&#xff0c;还是简单的数据洞察&#xff0c;一个清晰直观的图表&#xff0c;往往能胜过千言万语。 一款能让数据可视化变得超级简单的 MCP Server&#xff0c;由蚂蚁集团 AntV 团队…...

10-Oracle 23 ai Vector Search 概述和参数

一、Oracle AI Vector Search 概述 企业和个人都在尝试各种AI&#xff0c;使用客户端或是内部自己搭建集成大模型的终端&#xff0c;加速与大型语言模型&#xff08;LLM&#xff09;的结合&#xff0c;同时使用检索增强生成&#xff08;Retrieval Augmented Generation &#…...

C++使用 new 来创建动态数组

问题&#xff1a; 不能使用变量定义数组大小 原因&#xff1a; 这是因为数组在内存中是连续存储的&#xff0c;编译器需要在编译阶段就确定数组的大小&#xff0c;以便正确地分配内存空间。如果允许使用变量来定义数组的大小&#xff0c;那么编译器就无法在编译时确定数组的大…...

NPOI Excel用OLE对象的形式插入文件附件以及插入图片

static void Main(string[] args) {XlsWithObjData();Console.WriteLine("输出完成"); }static void XlsWithObjData() {// 创建工作簿和单元格,只有HSSFWorkbook,XSSFWorkbook不可以HSSFWorkbook workbook new HSSFWorkbook();HSSFSheet sheet (HSSFSheet)workboo…...

jmeter聚合报告中参数详解

sample、average、min、max、90%line、95%line,99%line、Error错误率、吞吐量Thoughput、KB/sec每秒传输的数据量 sample&#xff08;样本数&#xff09; 表示测试中发送的请求数量&#xff0c;即测试执行了多少次请求。 单位&#xff0c;以个或者次数表示。 示例&#xff1a;…...

MinIO Docker 部署:仅开放一个端口

MinIO Docker 部署:仅开放一个端口 在实际的服务器部署中,出于安全和管理的考虑,我们可能只能开放一个端口。MinIO 是一个高性能的对象存储服务,支持 Docker 部署,但默认情况下它需要两个端口:一个是 API 端口(用于存储和访问数据),另一个是控制台端口(用于管理界面…...

零知开源——STM32F103RBT6驱动 ICM20948 九轴传感器及 vofa + 上位机可视化教程

STM32F1 本教程使用零知标准板&#xff08;STM32F103RBT6&#xff09;通过I2C驱动ICM20948九轴传感器&#xff0c;实现姿态解算&#xff0c;并通过串口将数据实时发送至VOFA上位机进行3D可视化。代码基于开源库修改优化&#xff0c;适合嵌入式及物联网开发者。在基础驱动上新增…...

基于鸿蒙(HarmonyOS5)的打车小程序

1. 开发环境准备 安装DevEco Studio (鸿蒙官方IDE)配置HarmonyOS SDK申请开发者账号和必要的API密钥 2. 项目结构设计 ├── entry │ ├── src │ │ ├── main │ │ │ ├── ets │ │ │ │ ├── pages │ │ │ │ │ ├── H…...