feat(微信聊天): 添加语音消息功能支持
实现语音消息的录制、播放和发送功能,包括: 1. 新增AudioRecorder组件用于录音 2. 添加AudioMessage组件展示语音消息 3. 修改消息输入组件支持语音消息类型 4. 调整样式适配语音消息展示
This commit is contained in:
@@ -326,18 +326,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.messageItem {
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
|
||||
.messageContent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
.messageTime {
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
@@ -346,252 +334,6 @@
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.messageItem {
|
||||
.messageContent {
|
||||
.messageAvatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.messageBubble {
|
||||
max-width: 100%;
|
||||
.messageSender {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.messageText {
|
||||
color: #262626;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
background: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.emojiMessage {
|
||||
img {
|
||||
max-width: 120px;
|
||||
max-height: 120px;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.imageMessage {
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.videoMessage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
video {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.videoContainer {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover .videoPlayIcon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.videoThumbnail {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.videoPlayIcon {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
.loadingSpinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audioMessage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
|
||||
audio {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
margin-left: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fileMessage {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
width: 240px;
|
||||
|
||||
&:hover {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.locationMessage {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #fff2e8;
|
||||
}
|
||||
}
|
||||
|
||||
.messageTime {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ownMessage {
|
||||
.messageContent {
|
||||
flex-direction: row-reverse;
|
||||
margin-left: auto;
|
||||
|
||||
.messageBubble {
|
||||
color: #262626;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
max-width: 100%;
|
||||
.messageText {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.messageTime {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.otherMessage {
|
||||
.messageContent {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 1200px) {
|
||||
.profileSider {
|
||||
@@ -631,12 +373,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.messageItem {
|
||||
.messageContent {
|
||||
max-width: 85%;
|
||||
}
|
||||
}
|
||||
|
||||
.profileContent {
|
||||
padding: 12px;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
import { EmojiPicker } from "@/components/EmojiSeclection";
|
||||
import { EmojiInfo } from "@/components/EmojiSeclection/wechatEmoji";
|
||||
import SimpleFileUpload from "@/components/Upload/SimpleFileUpload";
|
||||
import AudioRecorder from "@/components/Upload/AudioRecorder";
|
||||
import styles from "./MessageEnter.module.scss";
|
||||
|
||||
const { Footer } = Layout;
|
||||
@@ -94,10 +95,23 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
// 其他格式默认为文件
|
||||
return 49; // 文件
|
||||
};
|
||||
|
||||
const handleFileUploaded = (filePath: string) => {
|
||||
const FileType = {
|
||||
TEXT: 1,
|
||||
IMAGE: 2,
|
||||
VIDEO: 3,
|
||||
AUDIO: 4,
|
||||
FILE: 5,
|
||||
};
|
||||
const handleFileUploaded = (filePath: string, fileType: number) => {
|
||||
// msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件)
|
||||
const msgType = getMsgTypeByFileFormat(filePath);
|
||||
let msgType = 1;
|
||||
if ([FileType.TEXT].includes(fileType)) {
|
||||
msgType = getMsgTypeByFileFormat(filePath);
|
||||
} else if ([FileType.IMAGE].includes(fileType)) {
|
||||
msgType = 3;
|
||||
} else if ([FileType.AUDIO].includes(fileType)) {
|
||||
msgType = 34;
|
||||
}
|
||||
|
||||
const params = {
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
@@ -119,7 +133,9 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
<div className={styles.leftTool}>
|
||||
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
|
||||
<SimpleFileUpload
|
||||
onFileUploaded={handleFileUploaded}
|
||||
onFileUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.FILE)
|
||||
}
|
||||
maxSize={1}
|
||||
type={4}
|
||||
slot={
|
||||
@@ -131,7 +147,9 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
}
|
||||
/>
|
||||
<SimpleFileUpload
|
||||
onFileUploaded={handleFileUploaded}
|
||||
onFileUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.IMAGE)
|
||||
}
|
||||
maxSize={1}
|
||||
type={1}
|
||||
slot={
|
||||
@@ -143,13 +161,12 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip title="语音">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<AudioOutlined />}
|
||||
className={styles.toolbarButton}
|
||||
/>
|
||||
</Tooltip>
|
||||
<AudioRecorder
|
||||
onAudioUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.AUDIO)
|
||||
}
|
||||
className={styles.toolbarButton}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.rightTool}>
|
||||
<div className={styles.rightToolItem}>
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
// 头像
|
||||
@@ -533,10 +532,6 @@
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.messageContent {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.messageBubble {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// 消息气泡样式
|
||||
.messageBubble {
|
||||
word-wrap: break-word;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
// 语音消息容器
|
||||
.audioMessage {
|
||||
min-width: 200px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 音频控制容器
|
||||
.audioContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
// 播放图标
|
||||
.audioIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
// 音频内容区域
|
||||
.audioContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 波形动画容器
|
||||
.audioWaveform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 30px; // 固定高度防止抖动
|
||||
}
|
||||
|
||||
// 波形条
|
||||
.waveBar {
|
||||
width: 3px;
|
||||
background-color: #d9d9d9;
|
||||
border-radius: 1.5px;
|
||||
transition: all 0.3s ease;
|
||||
transform-origin: center; // 设置变换原点为中心
|
||||
|
||||
&.playing {
|
||||
animation: waveAnimation 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// 音频时长显示
|
||||
.audioDuration {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 进度条容器
|
||||
.audioProgress {
|
||||
margin-top: 8px;
|
||||
height: 2px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 进度条
|
||||
.audioProgressBar {
|
||||
height: 100%;
|
||||
background-color: #1890ff;
|
||||
border-radius: 1px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
// 波形动画
|
||||
@keyframes waveAnimation {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.5);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1.2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { PauseCircleFilled, SoundOutlined } from "@ant-design/icons";
|
||||
import styles from "./AudioMessage.module.scss";
|
||||
|
||||
interface AudioMessageProps {
|
||||
audioUrl: string;
|
||||
msgId: number;
|
||||
}
|
||||
|
||||
const AudioMessage: React.FC<AudioMessageProps> = ({ audioUrl, msgId }) => {
|
||||
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
|
||||
const [audioProgress, setAudioProgress] = useState<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
const audioRefs = useRef<Record<string, HTMLAudioElement>>({});
|
||||
|
||||
const audioId = `audio_${msgId}_${Date.now()}`;
|
||||
const isPlaying = playingAudioId === audioId;
|
||||
const progress = audioProgress[audioId] || 0;
|
||||
|
||||
// 播放/暂停音频
|
||||
const handleAudioToggle = () => {
|
||||
const audio = audioRefs.current[audioId];
|
||||
if (!audio) {
|
||||
const newAudio = new Audio(audioUrl);
|
||||
audioRefs.current[audioId] = newAudio;
|
||||
|
||||
newAudio.addEventListener("timeupdate", () => {
|
||||
const currentProgress =
|
||||
(newAudio.currentTime / newAudio.duration) * 100;
|
||||
setAudioProgress(prev => ({
|
||||
...prev,
|
||||
[audioId]: currentProgress,
|
||||
}));
|
||||
});
|
||||
|
||||
newAudio.addEventListener("ended", () => {
|
||||
setPlayingAudioId(null);
|
||||
setAudioProgress(prev => ({ ...prev, [audioId]: 0 }));
|
||||
});
|
||||
|
||||
newAudio.addEventListener("error", () => {
|
||||
console.error("音频播放失败");
|
||||
setPlayingAudioId(null);
|
||||
});
|
||||
|
||||
newAudio.play();
|
||||
setPlayingAudioId(audioId);
|
||||
} else {
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setPlayingAudioId(null);
|
||||
} else {
|
||||
// 停止其他正在播放的音频
|
||||
Object.values(audioRefs.current).forEach(a => a.pause());
|
||||
setPlayingAudioId(null);
|
||||
|
||||
audio.play();
|
||||
setPlayingAudioId(audioId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.audioMessage}>
|
||||
<div className={styles.audioContainer} onClick={handleAudioToggle}>
|
||||
<div className={styles.audioIcon}>
|
||||
{isPlaying ? (
|
||||
<PauseCircleFilled
|
||||
style={{ fontSize: "20px", color: "#1890ff" }}
|
||||
/>
|
||||
) : (
|
||||
<SoundOutlined style={{ fontSize: "20px", color: "#666" }} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.audioContent}>
|
||||
<div className={styles.audioWaveform}>
|
||||
{/* 音频波形效果 */}
|
||||
{Array.from({ length: 20 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.waveBar} ${isPlaying ? styles.playing : ""}`}
|
||||
style={{
|
||||
height: `${Math.random() * 20 + 10}px`,
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
backgroundColor: progress > i * 5 ? "#1890ff" : "#d9d9d9",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.audioDuration}>语音</div>
|
||||
</div>
|
||||
</div>
|
||||
{progress > 0 && (
|
||||
<div className={styles.audioProgress}>
|
||||
<div
|
||||
className={styles.audioProgressBar}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioMessage;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Avatar, Divider } from "antd";
|
||||
import {
|
||||
UserOutlined,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DownloadOutlined,
|
||||
PlayCircleFilled,
|
||||
} from "@ant-design/icons";
|
||||
import AudioMessage from "./components/AudioMessage/AudioMessage";
|
||||
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import { formatWechatTime, parseWeappMsgStr } from "@/utils/common";
|
||||
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
|
||||
@@ -18,6 +19,7 @@ interface MessageRecordProps {
|
||||
}
|
||||
const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentMessages = useWeChatStore(state => state.currentMessages);
|
||||
const loadChatMessages = useWeChatStore(state => state.loadChatMessages);
|
||||
const messagesLoading = useWeChatStore(state => state.messagesLoading);
|
||||
@@ -383,6 +385,14 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
}
|
||||
return renderErrorMessage("[表情包]");
|
||||
|
||||
case 34: // 语音消息
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[语音消息 - 无效内容]");
|
||||
}
|
||||
|
||||
// content直接是音频URL字符串
|
||||
return <AudioMessage audioUrl={content} msgId={msg.id} />;
|
||||
|
||||
case 49: // 小程序/文章/其他:图文、文件
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[小程序/文章/文件消息 - 无效内容]");
|
||||
|
||||
Reference in New Issue
Block a user