Initial Commit
This commit is contained in:
commit
1d71a02738
237 changed files with 64293 additions and 0 deletions
562
components/voice-recorder/voice-recorder.js
Normal file
562
components/voice-recorder/voice-recorder.js
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
// 🎤 语音录制组件逻辑
|
||||
const voiceMessageManager = require('../../utils/voice-message-manager.js');
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
// 是否显示录音界面
|
||||
visible: {
|
||||
type: Boolean,
|
||||
value: false,
|
||||
observer: 'onVisibleChange'
|
||||
},
|
||||
|
||||
// 最大录音时长(毫秒)
|
||||
maxDuration: {
|
||||
type: Number,
|
||||
value: 60000
|
||||
},
|
||||
|
||||
// 最小录音时长(毫秒)
|
||||
minDuration: {
|
||||
type: Number,
|
||||
value: 1000
|
||||
}
|
||||
},
|
||||
|
||||
data: {
|
||||
// 录音状态:idle, recording, paused, completed, error
|
||||
recordingState: 'idle',
|
||||
|
||||
// 状态文本
|
||||
statusText: '准备录音',
|
||||
|
||||
// 录音时长
|
||||
recordingDuration: 0,
|
||||
|
||||
// 实时波形数据
|
||||
realtimeWaveform: [],
|
||||
|
||||
// 最终波形数据
|
||||
finalWaveform: [],
|
||||
|
||||
// 录音文件信息
|
||||
tempFilePath: '',
|
||||
fileSize: 0,
|
||||
|
||||
// 播放状态
|
||||
isPlaying: false,
|
||||
|
||||
// 错误信息
|
||||
errorMessage: '',
|
||||
|
||||
// 权限引导
|
||||
showPermissionGuide: false,
|
||||
|
||||
// 定时器
|
||||
durationTimer: null,
|
||||
waveformTimer: null
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
console.log('🎤 语音录制组件加载');
|
||||
this.initComponent();
|
||||
},
|
||||
|
||||
detached() {
|
||||
console.log('🎤 语音录制组件卸载');
|
||||
this.cleanup();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 事件阻断占位
|
||||
noop() {},
|
||||
// 初始化组件
|
||||
initComponent() {
|
||||
// 注册语音管理器事件
|
||||
this.registerVoiceEvents();
|
||||
|
||||
// 初始化波形数据
|
||||
this.initWaveform();
|
||||
},
|
||||
|
||||
// 可见性变化处理
|
||||
onVisibleChange(visible) {
|
||||
if (visible) {
|
||||
this.resetRecorder();
|
||||
} else {
|
||||
this.cleanup();
|
||||
}
|
||||
},
|
||||
|
||||
// 注册语音管理器事件
|
||||
registerVoiceEvents() {
|
||||
// 录音开始事件
|
||||
voiceMessageManager.on('recordStart', () => {
|
||||
this.setData({
|
||||
recordingState: 'recording',
|
||||
statusText: '正在录音...',
|
||||
recordingDuration: 0
|
||||
});
|
||||
|
||||
this.startDurationTimer();
|
||||
this.startWaveformAnimation();
|
||||
});
|
||||
|
||||
// 录音停止事件
|
||||
voiceMessageManager.on('recordStop', (data) => {
|
||||
this.setData({
|
||||
recordingState: 'completed',
|
||||
statusText: '录音完成',
|
||||
recordingDuration: data.duration,
|
||||
tempFilePath: data.tempFilePath,
|
||||
fileSize: data.fileSize
|
||||
});
|
||||
|
||||
this.stopTimers();
|
||||
this.generateFinalWaveform();
|
||||
});
|
||||
|
||||
// 录音暂停事件
|
||||
voiceMessageManager.on('recordPause', () => {
|
||||
this.setData({
|
||||
recordingState: 'paused',
|
||||
statusText: '录音已暂停'
|
||||
});
|
||||
|
||||
this.stopTimers();
|
||||
});
|
||||
|
||||
// 录音恢复事件
|
||||
voiceMessageManager.on('recordResume', () => {
|
||||
this.setData({
|
||||
recordingState: 'recording',
|
||||
statusText: '正在录音...'
|
||||
});
|
||||
|
||||
this.startDurationTimer();
|
||||
this.startWaveformAnimation();
|
||||
});
|
||||
|
||||
// 录音错误事件
|
||||
voiceMessageManager.on('recordError', (error) => {
|
||||
console.error('🎤 录音错误:', error);
|
||||
|
||||
this.setData({
|
||||
recordingState: 'error',
|
||||
statusText: '录音失败',
|
||||
errorMessage: this.getErrorMessage(error)
|
||||
});
|
||||
|
||||
this.stopTimers();
|
||||
});
|
||||
|
||||
// 录音帧数据事件
|
||||
voiceMessageManager.on('recordFrame', (data) => {
|
||||
this.updateRealtimeWaveform(data.frameBuffer);
|
||||
});
|
||||
},
|
||||
|
||||
// 🎤 ===== 录音控制 =====
|
||||
|
||||
// 开始录音
|
||||
async startRecording() {
|
||||
console.log('🎤 开始录音');
|
||||
|
||||
try {
|
||||
await voiceMessageManager.startRecording({
|
||||
duration: this.properties.maxDuration,
|
||||
format: 'mp3'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('🎤 开始录音失败:', error);
|
||||
|
||||
if (error.message.includes('权限')) {
|
||||
this.setData({
|
||||
showPermissionGuide: true
|
||||
});
|
||||
} else {
|
||||
this.setData({
|
||||
recordingState: 'error',
|
||||
statusText: '录音失败',
|
||||
errorMessage: this.getErrorMessage(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 停止录音
|
||||
stopRecording() {
|
||||
console.log('🎤 停止录音');
|
||||
|
||||
try {
|
||||
// 检查最小录音时长
|
||||
if (this.data.recordingDuration < this.properties.minDuration) {
|
||||
wx.showToast({
|
||||
title: `录音时长不能少于${this.properties.minDuration / 1000}秒`,
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
voiceMessageManager.stopRecording();
|
||||
|
||||
} catch (error) {
|
||||
console.error('🎤 停止录音失败:', error);
|
||||
this.setData({
|
||||
recordingState: 'error',
|
||||
statusText: '停止录音失败',
|
||||
errorMessage: this.getErrorMessage(error)
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 暂停录音
|
||||
pauseRecording() {
|
||||
console.log('🎤 暂停录音');
|
||||
|
||||
try {
|
||||
voiceMessageManager.pauseRecording();
|
||||
} catch (error) {
|
||||
console.error('🎤 暂停录音失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 恢复录音
|
||||
resumeRecording() {
|
||||
console.log('🎤 恢复录音');
|
||||
|
||||
try {
|
||||
voiceMessageManager.resumeRecording();
|
||||
} catch (error) {
|
||||
console.error('🎤 恢复录音失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 取消录音
|
||||
cancelRecording() {
|
||||
console.log('🎤 取消录音');
|
||||
|
||||
try {
|
||||
voiceMessageManager.cancelRecording();
|
||||
this.resetRecorder();
|
||||
} catch (error) {
|
||||
console.error('🎤 取消录音失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 丢弃录音
|
||||
discardRecording() {
|
||||
console.log('🎤 丢弃录音');
|
||||
this.resetRecorder();
|
||||
},
|
||||
|
||||
// 🔊 ===== 播放控制 =====
|
||||
|
||||
// 播放预览
|
||||
async playPreview() {
|
||||
if (!this.data.tempFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.data.isPlaying) {
|
||||
voiceMessageManager.stopPlaying();
|
||||
this.setData({ isPlaying: false });
|
||||
} else {
|
||||
await voiceMessageManager.playVoiceMessage(this.data.tempFilePath);
|
||||
this.setData({ isPlaying: true });
|
||||
|
||||
// 监听播放结束
|
||||
const onPlayEnd = () => {
|
||||
this.setData({ isPlaying: false });
|
||||
voiceMessageManager.off('playEnd', onPlayEnd);
|
||||
};
|
||||
voiceMessageManager.on('playEnd', onPlayEnd);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('🎤 播放预览失败:', error);
|
||||
wx.showToast({
|
||||
title: '播放失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 📤 ===== 发送录音 =====
|
||||
|
||||
// 发送录音
|
||||
async sendRecording() {
|
||||
if (!this.data.tempFilePath || !this.data.recordingDuration) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📤 发送录音');
|
||||
|
||||
try {
|
||||
wx.showLoading({
|
||||
title: '上传中...',
|
||||
mask: true
|
||||
});
|
||||
|
||||
// 上传语音文件
|
||||
const uploadResult = await voiceMessageManager.uploadVoiceFile(
|
||||
this.data.tempFilePath,
|
||||
this.data.recordingDuration
|
||||
);
|
||||
|
||||
wx.hideLoading();
|
||||
|
||||
if (uploadResult.success) {
|
||||
// 触发发送事件
|
||||
this.triggerEvent('send', {
|
||||
type: 'voice',
|
||||
url: uploadResult.url,
|
||||
duration: uploadResult.duration,
|
||||
size: uploadResult.size,
|
||||
tempFilePath: this.data.tempFilePath
|
||||
});
|
||||
|
||||
// 关闭录音界面
|
||||
this.closeRecorder();
|
||||
|
||||
wx.showToast({
|
||||
title: '发送成功',
|
||||
icon: 'success'
|
||||
});
|
||||
|
||||
} else {
|
||||
throw new Error('上传失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
wx.hideLoading();
|
||||
console.error('📤 发送录音失败:', error);
|
||||
|
||||
wx.showToast({
|
||||
title: '发送失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 🎨 ===== 界面控制 =====
|
||||
|
||||
// 关闭录音界面
|
||||
closeRecorder() {
|
||||
console.log('❌ 关闭录音界面');
|
||||
|
||||
// 如果正在录音,先停止
|
||||
if (this.data.recordingState === 'recording') {
|
||||
this.cancelRecording();
|
||||
}
|
||||
|
||||
// 如果正在播放,先停止
|
||||
if (this.data.isPlaying) {
|
||||
voiceMessageManager.stopPlaying();
|
||||
}
|
||||
|
||||
// 先自隐,再通知父级,提升关闭成功率
|
||||
this.setData({ visible: false });
|
||||
this.triggerEvent('close');
|
||||
},
|
||||
|
||||
// 遮罩点击
|
||||
onOverlayTap() {
|
||||
// 点击遮罩关闭
|
||||
this.closeRecorder();
|
||||
},
|
||||
|
||||
// 🔐 ===== 权限处理 =====
|
||||
|
||||
// 取消权限申请
|
||||
cancelPermission() {
|
||||
this.setData({
|
||||
showPermissionGuide: false
|
||||
});
|
||||
},
|
||||
|
||||
// 打开设置页面
|
||||
openSettings() {
|
||||
wx.openSetting({
|
||||
success: (res) => {
|
||||
if (res.authSetting['scope.record']) {
|
||||
this.setData({
|
||||
showPermissionGuide: false
|
||||
});
|
||||
|
||||
wx.showToast({
|
||||
title: '权限已开启',
|
||||
icon: 'success'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 🔧 ===== 工具方法 =====
|
||||
|
||||
// 重置录音器
|
||||
resetRecorder() {
|
||||
this.setData({
|
||||
recordingState: 'idle',
|
||||
statusText: '准备录音',
|
||||
recordingDuration: 0,
|
||||
tempFilePath: '',
|
||||
fileSize: 0,
|
||||
isPlaying: false,
|
||||
errorMessage: '',
|
||||
showPermissionGuide: false
|
||||
});
|
||||
|
||||
this.stopTimers();
|
||||
this.initWaveform();
|
||||
},
|
||||
|
||||
// 初始化波形
|
||||
initWaveform() {
|
||||
const waveform = Array(20).fill(0).map(() => Math.random() * 30 + 10);
|
||||
this.setData({
|
||||
realtimeWaveform: waveform,
|
||||
finalWaveform: []
|
||||
});
|
||||
},
|
||||
|
||||
// 开始时长计时器
|
||||
startDurationTimer() {
|
||||
this.stopTimers();
|
||||
|
||||
this.data.durationTimer = setInterval(() => {
|
||||
const duration = this.data.recordingDuration + 100;
|
||||
this.setData({
|
||||
recordingDuration: duration
|
||||
});
|
||||
|
||||
// 检查最大时长
|
||||
if (duration >= this.properties.maxDuration) {
|
||||
this.stopRecording();
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
|
||||
// 开始波形动画
|
||||
startWaveformAnimation() {
|
||||
this.data.waveformTimer = setInterval(() => {
|
||||
const waveform = Array(20).fill(0).map(() => Math.random() * 80 + 20);
|
||||
this.setData({
|
||||
realtimeWaveform: waveform
|
||||
});
|
||||
}, 150);
|
||||
},
|
||||
|
||||
// 停止定时器
|
||||
stopTimers() {
|
||||
if (this.data.durationTimer) {
|
||||
clearInterval(this.data.durationTimer);
|
||||
this.data.durationTimer = null;
|
||||
}
|
||||
|
||||
if (this.data.waveformTimer) {
|
||||
clearInterval(this.data.waveformTimer);
|
||||
this.data.waveformTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新实时波形
|
||||
updateRealtimeWaveform(frameBuffer) {
|
||||
if (!frameBuffer) return;
|
||||
|
||||
// 简化的波形数据处理
|
||||
const waveform = Array(20).fill(0).map(() => Math.random() * 80 + 20);
|
||||
this.setData({
|
||||
realtimeWaveform: waveform
|
||||
});
|
||||
},
|
||||
|
||||
// 生成最终波形
|
||||
generateFinalWaveform() {
|
||||
const duration = this.data.recordingDuration;
|
||||
const barCount = Math.min(Math.max(Math.floor(duration / 200), 15), 40);
|
||||
|
||||
const waveform = Array(barCount).fill(0).map(() => Math.random() * 70 + 15);
|
||||
this.setData({
|
||||
finalWaveform: waveform
|
||||
});
|
||||
},
|
||||
|
||||
// 格式化时长
|
||||
formatDuration(duration) {
|
||||
if (!duration || duration <= 0) return '00:00';
|
||||
|
||||
const totalSeconds = Math.floor(duration / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
// 获取文件大小文本
|
||||
getFileSizeText(fileSize) {
|
||||
if (!fileSize || fileSize <= 0) return '';
|
||||
|
||||
if (fileSize < 1024) {
|
||||
return `${fileSize}B`;
|
||||
} else if (fileSize < 1024 * 1024) {
|
||||
return `${(fileSize / 1024).toFixed(1)}KB`;
|
||||
} else {
|
||||
return `${(fileSize / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取质量文本
|
||||
getQualityText(duration) {
|
||||
if (!duration || duration <= 0) return '';
|
||||
|
||||
const seconds = Math.floor(duration / 1000);
|
||||
if (seconds < 3) return '音质:一般';
|
||||
if (seconds < 10) return '音质:良好';
|
||||
return '音质:优秀';
|
||||
},
|
||||
|
||||
// 获取错误消息
|
||||
getErrorMessage(error) {
|
||||
if (error.message) {
|
||||
if (error.message.includes('权限')) {
|
||||
return '需要录音权限';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
return '录音超时';
|
||||
} else if (error.message.includes('fail')) {
|
||||
return '录音失败';
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return '未知错误';
|
||||
},
|
||||
|
||||
// 清理资源
|
||||
cleanup() {
|
||||
this.stopTimers();
|
||||
|
||||
// 如果正在录音,取消录音
|
||||
if (this.data.recordingState === 'recording') {
|
||||
voiceMessageManager.cancelRecording();
|
||||
}
|
||||
|
||||
// 如果正在播放,停止播放
|
||||
if (this.data.isPlaying) {
|
||||
voiceMessageManager.stopPlaying();
|
||||
}
|
||||
|
||||
// 移除事件监听器
|
||||
voiceMessageManager.off('recordStart');
|
||||
voiceMessageManager.off('recordStop');
|
||||
voiceMessageManager.off('recordPause');
|
||||
voiceMessageManager.off('recordResume');
|
||||
voiceMessageManager.off('recordError');
|
||||
voiceMessageManager.off('recordFrame');
|
||||
}
|
||||
}
|
||||
});
|
||||
4
components/voice-recorder/voice-recorder.json
Normal file
4
components/voice-recorder/voice-recorder.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"component": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
150
components/voice-recorder/voice-recorder.wxml
Normal file
150
components/voice-recorder/voice-recorder.wxml
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<!-- 🎤 语音录制组件 -->
|
||||
<view class="voice-recorder-container {{visible ? 'visible' : 'hidden'}}">
|
||||
<!-- 录音遮罩 -->
|
||||
<view class="recorder-overlay" bindtap="onOverlayTap" catchtouchmove="noop"></view>
|
||||
|
||||
<!-- 录音界面 -->
|
||||
<view class="recorder-content" catchtap="noop" catchtouchmove="noop">
|
||||
<!-- 录音状态显示 -->
|
||||
<view class="recorder-status">
|
||||
<view class="status-icon {{recordingState}}">
|
||||
<view wx:if="{{recordingState === 'idle'}}" class="icon-microphone">🎤</view>
|
||||
<view wx:elif="{{recordingState === 'recording'}}" class="icon-recording">
|
||||
<view class="recording-dot"></view>
|
||||
</view>
|
||||
<view wx:elif="{{recordingState === 'paused'}}" class="icon-paused">⏸️</view>
|
||||
<view wx:elif="{{recordingState === 'completed'}}" class="icon-completed">✅</view>
|
||||
<view wx:elif="{{recordingState === 'error'}}" class="icon-error">❌</view>
|
||||
</view>
|
||||
|
||||
<text class="status-text">{{statusText}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 录音时长 -->
|
||||
<view class="recording-duration">
|
||||
<text class="duration-text">{{formatDuration(recordingDuration)}}</text>
|
||||
<text class="max-duration-text">/ {{formatDuration(maxDuration)}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 实时波形显示 -->
|
||||
<view wx:if="{{recordingState === 'recording'}}" class="realtime-waveform">
|
||||
<view class="waveform-container">
|
||||
<view wx:for="{{realtimeWaveform}}"
|
||||
wx:key="index"
|
||||
class="wave-bar realtime"
|
||||
style="height: {{item}}%;">
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音完成预览 -->
|
||||
<view wx:if="{{recordingState === 'completed'}}" class="recording-preview">
|
||||
<view class="preview-waveform">
|
||||
<view class="waveform-container">
|
||||
<view wx:for="{{finalWaveform}}"
|
||||
wx:key="index"
|
||||
class="wave-bar preview"
|
||||
style="height: {{item}}%;">
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="preview-info">
|
||||
<text class="file-size">{{getFileSizeText(fileSize)}}</text>
|
||||
<text class="quality-text">{{getQualityText(recordingDuration)}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音控制按钮 -->
|
||||
<view class="recorder-controls">
|
||||
<!-- 开始/停止录音按钮 -->
|
||||
<view wx:if="{{recordingState === 'idle' || recordingState === 'error'}}"
|
||||
class="control-button primary"
|
||||
bindtouchstart="startRecording"
|
||||
bindtouchend="stopRecording"
|
||||
bindtouchcancel="cancelRecording">
|
||||
<text class="button-text">按住录音</text>
|
||||
</view>
|
||||
|
||||
<!-- 录音中的控制 -->
|
||||
<view wx:elif="{{recordingState === 'recording'}}" class="recording-controls">
|
||||
<view class="control-button secondary" bindtap="pauseRecording">
|
||||
<text class="button-text">暂停</text>
|
||||
</view>
|
||||
<view class="control-button danger" bindtap="cancelRecording">
|
||||
<text class="button-text">取消</text>
|
||||
</view>
|
||||
<view class="control-button primary" bindtap="stopRecording">
|
||||
<text class="button-text">完成</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 暂停状态的控制 -->
|
||||
<view wx:elif="{{recordingState === 'paused'}}" class="paused-controls">
|
||||
<view class="control-button secondary" bindtap="resumeRecording">
|
||||
<text class="button-text">继续</text>
|
||||
</view>
|
||||
<view class="control-button danger" bindtap="cancelRecording">
|
||||
<text class="button-text">取消</text>
|
||||
</view>
|
||||
<view class="control-button primary" bindtap="stopRecording">
|
||||
<text class="button-text">完成</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音完成的控制 -->
|
||||
<view wx:elif="{{recordingState === 'completed'}}" class="completed-controls">
|
||||
<view class="control-button secondary" bindtap="playPreview">
|
||||
<text class="button-text">{{isPlaying ? '暂停' : '试听'}}</text>
|
||||
</view>
|
||||
<view class="control-button danger" bindtap="discardRecording">
|
||||
<text class="button-text">重录</text>
|
||||
</view>
|
||||
<view class="control-button primary" bindtap="sendRecording">
|
||||
<text class="button-text">发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音提示 -->
|
||||
<view class="recorder-tips">
|
||||
<text wx:if="{{recordingState === 'idle'}}" class="tip-text">
|
||||
按住录音按钮开始录制,最长{{Math.floor(maxDuration/1000)}}秒
|
||||
</text>
|
||||
<text wx:elif="{{recordingState === 'recording'}}" class="tip-text">
|
||||
松开结束录音,向上滑动取消
|
||||
</text>
|
||||
<text wx:elif="{{recordingState === 'paused'}}" class="tip-text">
|
||||
录音已暂停,可以继续录制或完成录音
|
||||
</text>
|
||||
<text wx:elif="{{recordingState === 'completed'}}" class="tip-text">
|
||||
录音完成,可以试听或发送
|
||||
</text>
|
||||
<text wx:elif="{{recordingState === 'error'}}" class="tip-text error">
|
||||
{{errorMessage}}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<view class="close-button" bindtap="closeRecorder">
|
||||
<text class="close-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 权限引导 -->
|
||||
<view wx:if="{{showPermissionGuide}}" class="permission-guide">
|
||||
<view class="guide-content">
|
||||
<view class="guide-icon">🎤</view>
|
||||
<text class="guide-title">需要录音权限</text>
|
||||
<text class="guide-desc">使用语音消息功能需要录音权限,请在设置中开启</text>
|
||||
<view class="guide-buttons">
|
||||
<view class="guide-button secondary" bindtap="cancelPermission">
|
||||
<text class="button-text">取消</text>
|
||||
</view>
|
||||
<view class="guide-button primary" bindtap="openSettings">
|
||||
<text class="button-text">去设置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
534
components/voice-recorder/voice-recorder.wxss
Normal file
534
components/voice-recorder/voice-recorder.wxss
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
/* 🎤 语音录制组件样式 */
|
||||
|
||||
/* CSS变量定义 */
|
||||
.voice-recorder-container {
|
||||
--primary-color: #007AFF;
|
||||
--primary-light: #5AC8FA;
|
||||
--primary-dark: #0051D5;
|
||||
--success-color: #34C759;
|
||||
--warning-color: #FF9500;
|
||||
--danger-color: #FF3B30;
|
||||
--background-color: #F2F2F7;
|
||||
--surface-color: #FFFFFF;
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #8E8E93;
|
||||
--text-tertiary: #C7C7CC;
|
||||
--border-color: #E5E5EA;
|
||||
--shadow-light: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
--shadow-medium: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
--radius-small: 8rpx;
|
||||
--radius-medium: 12rpx;
|
||||
--radius-large: 20rpx;
|
||||
--radius-xl: 32rpx;
|
||||
}
|
||||
|
||||
/* 🌙 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.voice-recorder-container {
|
||||
--primary-color: #0A84FF;
|
||||
--background-color: #000000;
|
||||
--surface-color: #1C1C1E;
|
||||
--text-primary: #FFFFFF;
|
||||
--text-secondary: #8E8E93;
|
||||
--text-tertiary: #48484A;
|
||||
--border-color: #38383A;
|
||||
--shadow-light: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
||||
--shadow-medium: 0 8rpx 24rpx rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.voice-recorder-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.voice-recorder-container.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.voice-recorder-container.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 🎨 录音遮罩 */
|
||||
.recorder-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
/* 🎨 录音内容 */
|
||||
.recorder-content {
|
||||
width: 640rpx;
|
||||
max-width: 90vw;
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-medium);
|
||||
padding: 60rpx 40rpx 40rpx;
|
||||
position: relative;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100rpx) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎤 录音状态 */
|
||||
.recorder-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-icon.idle {
|
||||
background: var(--background-color);
|
||||
border: 2rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.status-icon.recording {
|
||||
background: linear-gradient(135deg, var(--danger-color) 0%, #FF6B6B 100%);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-icon.paused {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
|
||||
.status-icon.completed {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.status-icon.error {
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.icon-microphone {
|
||||
font-size: 48rpx;
|
||||
}
|
||||
|
||||
.icon-recording {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.recording-dot {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
border-radius: 12rpx;
|
||||
background: white;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.icon-paused,
|
||||
.icon-completed,
|
||||
.icon-error {
|
||||
font-size: 48rpx;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ⏱️ 录音时长 */
|
||||
.recording-duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 40rpx;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.duration-text {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.max-duration-text {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* 🌊 实时波形 */
|
||||
.realtime-waveform {
|
||||
height: 120rpx;
|
||||
margin-bottom: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waveform-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 8rpx;
|
||||
border-radius: 4rpx;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 16rpx;
|
||||
}
|
||||
|
||||
.wave-bar.realtime {
|
||||
background: var(--primary-color);
|
||||
animation: waveAnimation 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wave-bar.preview {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes waveAnimation {
|
||||
0%, 100% { transform: scaleY(0.5); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* 📊 录音预览 */
|
||||
.recording-preview {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.preview-waveform {
|
||||
height: 80rpx;
|
||||
margin-bottom: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
|
||||
.file-size,
|
||||
.quality-text {
|
||||
font-size: 24rpx;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 🎛️ 录音控制 */
|
||||
.recorder-controls {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
height: 88rpx;
|
||||
border-radius: var(--radius-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16rpx;
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.control-button:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.control-button.primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.control-button.secondary {
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
border: 1rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.control-button.danger {
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
color: var(--danger-color);
|
||||
border: 1rpx solid rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
|
||||
.control-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.control-button.primary:active {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-color) 100%);
|
||||
}
|
||||
|
||||
.button-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 录音中的控制按钮 */
|
||||
.recording-controls,
|
||||
.paused-controls,
|
||||
.completed-controls {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.recording-controls .control-button,
|
||||
.paused-controls .control-button,
|
||||
.completed-controls .control-button {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 💡 录音提示 */
|
||||
.recorder-tips {
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
font-size: 26rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tip-text.error {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* ❌ 关闭按钮 */
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
background: var(--background-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.close-button:active {
|
||||
background: var(--border-color);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
font-size: 36rpx;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* 🔐 权限引导 */
|
||||
.permission-guide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.guide-content {
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--radius-large);
|
||||
padding: 60rpx 40rpx 40rpx;
|
||||
margin: 40rpx;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-medium);
|
||||
}
|
||||
|
||||
.guide-icon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.guide-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.guide-desc {
|
||||
font-size: 28rpx;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 40rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.guide-buttons {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.guide-button {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: var(--radius-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.guide-button.primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.guide-button.secondary {
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
border: 1rpx solid var(--border-color);
|
||||
}
|
||||
|
||||
.guide-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 📱 响应式设计 */
|
||||
@media screen and (max-width: 375px) {
|
||||
.recorder-content {
|
||||
width: 560rpx;
|
||||
padding: 50rpx 30rpx 30rpx;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
}
|
||||
|
||||
.duration-text {
|
||||
font-size: 40rpx;
|
||||
}
|
||||
|
||||
.realtime-waveform {
|
||||
height: 100rpx;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
height: 76rpx;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 414px) {
|
||||
.recorder-content {
|
||||
width: 720rpx;
|
||||
padding: 70rpx 50rpx 50rpx;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
}
|
||||
|
||||
.duration-text {
|
||||
font-size: 56rpx;
|
||||
}
|
||||
|
||||
.realtime-waveform {
|
||||
height: 140rpx;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 🎭 动画增强 */
|
||||
.voice-recorder-container.visible .recorder-content {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.voice-recorder-container.hidden .recorder-content {
|
||||
animation: slideDown 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(100rpx) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* 触摸反馈 */
|
||||
.control-button {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 可访问性 */
|
||||
.control-button[aria-pressed="true"] {
|
||||
outline: 2rpx solid var(--primary-color);
|
||||
outline-offset: 4rpx;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue