Merge branch 'yongpxu-dev' into yongpxu-dev2
This commit is contained in:
26
Cunkebao/dist/.vite/manifest.json
vendored
26
Cunkebao/dist/.vite/manifest.json
vendored
@@ -1,18 +1,14 @@
|
||||
{
|
||||
"_charts-CM0JFsjx.js": {
|
||||
"file": "assets/charts-CM0JFsjx.js",
|
||||
"_charts-BjEBSMrK.js": {
|
||||
"file": "assets/charts-BjEBSMrK.js",
|
||||
"name": "charts",
|
||||
"imports": [
|
||||
"_ui-Dkyp_L4f.js",
|
||||
"_ui-CiJ_pikt.js",
|
||||
"_vendor-BPPoWDlG.js"
|
||||
]
|
||||
},
|
||||
"_ui-D0C0OGrH.css": {
|
||||
"file": "assets/ui-D0C0OGrH.css",
|
||||
"src": "_ui-D0C0OGrH.css"
|
||||
},
|
||||
"_ui-Dkyp_L4f.js": {
|
||||
"file": "assets/ui-Dkyp_L4f.js",
|
||||
"_ui-CiJ_pikt.js": {
|
||||
"file": "assets/ui-CiJ_pikt.js",
|
||||
"name": "ui",
|
||||
"imports": [
|
||||
"_vendor-BPPoWDlG.js"
|
||||
@@ -21,6 +17,10 @@
|
||||
"assets/ui-D0C0OGrH.css"
|
||||
]
|
||||
},
|
||||
"_ui-D0C0OGrH.css": {
|
||||
"file": "assets/ui-D0C0OGrH.css",
|
||||
"src": "_ui-D0C0OGrH.css"
|
||||
},
|
||||
"_utils-DiZV3oaL.js": {
|
||||
"file": "assets/utils-DiZV3oaL.js",
|
||||
"name": "utils",
|
||||
@@ -33,18 +33,18 @@
|
||||
"name": "vendor"
|
||||
},
|
||||
"index.html": {
|
||||
"file": "assets/index-DYycL-yo.js",
|
||||
"file": "assets/index-BesOjMPu.js",
|
||||
"name": "index",
|
||||
"src": "index.html",
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"_vendor-BPPoWDlG.js",
|
||||
"_utils-DiZV3oaL.js",
|
||||
"_ui-Dkyp_L4f.js",
|
||||
"_charts-CM0JFsjx.js"
|
||||
"_ui-CiJ_pikt.js",
|
||||
"_charts-BjEBSMrK.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/index-CHPV8625.css"
|
||||
"assets/index-677RgwmW.css"
|
||||
]
|
||||
}
|
||||
}
|
||||
8
Cunkebao/dist/index.html
vendored
8
Cunkebao/dist/index.html
vendored
@@ -11,13 +11,13 @@
|
||||
</style>
|
||||
<!-- 引入 uni-app web-view SDK(必须) -->
|
||||
<script type="text/javascript" src="/websdk.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DYycL-yo.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BesOjMPu.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-BPPoWDlG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-DiZV3oaL.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-Dkyp_L4f.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-CM0JFsjx.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-CiJ_pikt.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-BjEBSMrK.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/ui-D0C0OGrH.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CHPV8625.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-677RgwmW.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
411
Cunkebao/src/components/Upload/AudioRecorder/index.tsx
Normal file
411
Cunkebao/src/components/Upload/AudioRecorder/index.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { Button, message, Modal } from "antd";
|
||||
import {
|
||||
AudioOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
SendOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { uploadFile } from "@/api/common";
|
||||
|
||||
interface AudioRecorderProps {
|
||||
onAudioUploaded: (audioData: { url: string; durationMs: number }) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
maxDuration?: number; // 最大录音时长(秒)
|
||||
}
|
||||
|
||||
type RecordingState =
|
||||
| "idle"
|
||||
| "recording"
|
||||
| "recorded"
|
||||
| "playing"
|
||||
| "uploading";
|
||||
|
||||
const AudioRecorder: React.FC<AudioRecorderProps> = ({
|
||||
onAudioUploaded,
|
||||
className,
|
||||
disabled = false,
|
||||
maxDuration = 60,
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [state, setState] = useState<RecordingState>("idle");
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [audioUrl, setAudioUrl] = useState<string>("");
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
// 打开弹窗
|
||||
const openRecorder = () => {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
// 关闭弹窗并重置状态
|
||||
const closeRecorder = () => {
|
||||
if (state === "recording") {
|
||||
stopRecording();
|
||||
}
|
||||
if (state === "playing") {
|
||||
pauseAudio();
|
||||
}
|
||||
deleteRecording();
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
// 开始录音
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
// 尝试使用MP3格式,如果不支持则回退到WebM
|
||||
const mp3Types = [
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/mpeg; codecs=mp3",
|
||||
"audio/mp4",
|
||||
"audio/mp4; codecs=mp4a.40.2",
|
||||
];
|
||||
|
||||
let mimeType = "audio/webm"; // 默认回退格式
|
||||
|
||||
// 检测并选择支持的MP3格式
|
||||
for (const type of mp3Types) {
|
||||
if (MediaRecorder.isTypeSupported(type)) {
|
||||
mimeType = type;
|
||||
console.log(`使用音频格式: ${type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeType === "audio/webm") {
|
||||
console.log("浏览器不支持MP3格式,使用WebM格式");
|
||||
}
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
setAudioBlob(blob);
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
setState("recorded");
|
||||
|
||||
// 停止所有音频轨道
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setState("recording");
|
||||
setRecordingTime(0);
|
||||
|
||||
// 开始计时
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingTime(prev => {
|
||||
const newTime = prev + 1;
|
||||
if (newTime >= maxDuration) {
|
||||
stopRecording();
|
||||
}
|
||||
return newTime;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("录音失败:", error);
|
||||
message.error("无法访问麦克风,请检查权限设置");
|
||||
}
|
||||
}, [maxDuration]);
|
||||
|
||||
// 停止录音
|
||||
const stopRecording = useCallback(() => {
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state === "recording"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 播放录音
|
||||
const playAudio = useCallback(() => {
|
||||
if (audioRef.current && audioUrl) {
|
||||
audioRef.current.play();
|
||||
setState("playing");
|
||||
}
|
||||
}, [audioUrl]);
|
||||
|
||||
// 暂停播放
|
||||
const pauseAudio = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setState("recorded");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 删除录音
|
||||
const deleteRecording = useCallback(() => {
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
}
|
||||
setAudioBlob(null);
|
||||
setAudioUrl("");
|
||||
setRecordingTime(0);
|
||||
setState("idle");
|
||||
}, [audioUrl]);
|
||||
|
||||
// 发送录音
|
||||
const sendAudio = useCallback(async () => {
|
||||
if (!audioBlob) return;
|
||||
|
||||
try {
|
||||
setState("uploading");
|
||||
|
||||
// 创建文件对象
|
||||
const timestamp = Date.now();
|
||||
const fileExtension =
|
||||
audioBlob.type.includes("mp3") ||
|
||||
audioBlob.type.includes("mpeg") ||
|
||||
audioBlob.type.includes("mp4")
|
||||
? "mp3"
|
||||
: "webm";
|
||||
const audioFile = new File(
|
||||
[audioBlob],
|
||||
`audio_${timestamp}.${fileExtension}`,
|
||||
{
|
||||
type: audioBlob.type,
|
||||
},
|
||||
);
|
||||
|
||||
// 打印文件格式信息
|
||||
console.log("音频文件信息:", {
|
||||
fileName: audioFile.name,
|
||||
fileType: audioFile.type,
|
||||
fileSize: audioFile.size,
|
||||
fileExtension: fileExtension,
|
||||
blobType: audioBlob.type,
|
||||
});
|
||||
|
||||
// 上传文件
|
||||
const filePath = await uploadFile(audioFile);
|
||||
|
||||
// 调用回调函数,传递音频URL和时长(毫秒)
|
||||
onAudioUploaded({
|
||||
url: filePath,
|
||||
durationMs: recordingTime * 1000, // 将秒转换为毫秒
|
||||
});
|
||||
|
||||
// 重置状态并关闭弹窗
|
||||
deleteRecording();
|
||||
setVisible(false);
|
||||
message.success("语音发送成功");
|
||||
} catch (error) {
|
||||
console.error("语音上传失败:", error);
|
||||
message.error("语音发送失败,请重试");
|
||||
setState("recorded");
|
||||
}
|
||||
}, [audioBlob, onAudioUploaded, deleteRecording]);
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// 渲染弹窗内容
|
||||
const renderModalContent = () => {
|
||||
switch (state) {
|
||||
case "idle":
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 20px" }}>
|
||||
<div
|
||||
style={{ marginBottom: "20px", fontSize: "16px", color: "#666" }}
|
||||
>
|
||||
点击下方按钮开始录音
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<AudioOutlined />}
|
||||
onClick={startRecording}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
width: "80px",
|
||||
height: "80px",
|
||||
fontSize: "24px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "recording":
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 20px" }}>
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
color: "#ff4d4f",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
{formatTime(recordingTime)}
|
||||
</div>
|
||||
<div style={{ fontSize: "14px", color: "#999" }}>
|
||||
正在录音中...
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
size="large"
|
||||
onClick={stopRecording}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
width: "80px",
|
||||
height: "80px",
|
||||
fontSize: "24px",
|
||||
}}
|
||||
>
|
||||
⏹
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "recorded":
|
||||
case "playing":
|
||||
return (
|
||||
<div style={{ padding: "20px" }}>
|
||||
<div style={{ textAlign: "center", marginBottom: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
录音时长: {formatTime(recordingTime)}
|
||||
</div>
|
||||
<div style={{ fontSize: "14px", color: "#666" }}>
|
||||
{state === "playing"
|
||||
? "正在播放..."
|
||||
: "录音完成,可以试听或发送"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: "12px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="large"
|
||||
icon={
|
||||
state === "playing" ? (
|
||||
<PauseCircleOutlined />
|
||||
) : (
|
||||
<PlayCircleOutlined />
|
||||
)
|
||||
}
|
||||
onClick={state === "playing" ? pauseAudio : playAudio}
|
||||
title={state === "playing" ? "暂停播放" : "播放预览"}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="large"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={deleteRecording}
|
||||
title="删除重录"
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<SendOutlined />}
|
||||
onClick={sendAudio}
|
||||
loading={state === ("uploading" as RecordingState)}
|
||||
style={{ minWidth: "120px" }}
|
||||
>
|
||||
发送录音
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "uploading":
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 20px" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading
|
||||
size="large"
|
||||
style={{ minWidth: "120px" }}
|
||||
>
|
||||
发送中...
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<AudioOutlined />}
|
||||
onClick={openRecorder}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
title="点击录音"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="录音"
|
||||
open={visible}
|
||||
onCancel={closeRecorder}
|
||||
footer={null}
|
||||
width={400}
|
||||
centered
|
||||
maskClosable={state === "idle"}
|
||||
>
|
||||
{renderModalContent()}
|
||||
{audioUrl && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
onEnded={() => setState("recorded")}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioRecorder;
|
||||
@@ -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,26 @@ 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 | { url: string; durationMs: number },
|
||||
fileType: number,
|
||||
) => {
|
||||
// msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件)
|
||||
const msgType = getMsgTypeByFileFormat(filePath);
|
||||
let msgType = 1;
|
||||
if ([FileType.TEXT].includes(fileType)) {
|
||||
msgType = getMsgTypeByFileFormat(filePath as string);
|
||||
} else if ([FileType.IMAGE].includes(fileType)) {
|
||||
msgType = 3;
|
||||
} else if ([FileType.AUDIO].includes(fileType)) {
|
||||
msgType = 34;
|
||||
}
|
||||
|
||||
const params = {
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
@@ -105,7 +122,9 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
||||
msgSubType: 0,
|
||||
msgType,
|
||||
content: filePath,
|
||||
content: [FileType.AUDIO].includes(fileType)
|
||||
? JSON.stringify(filePath)
|
||||
: (filePath as { url: string; durationMs: number }).url,
|
||||
};
|
||||
sendCommand("CmdSendMessage", params);
|
||||
};
|
||||
@@ -119,7 +138,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 +152,9 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
}
|
||||
/>
|
||||
<SimpleFileUpload
|
||||
onFileUploaded={handleFileUploaded}
|
||||
onFileUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.IMAGE)
|
||||
}
|
||||
maxSize={1}
|
||||
type={1}
|
||||
slot={
|
||||
@@ -143,13 +166,12 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
}
|
||||
/>
|
||||
|
||||
<Tooltip title="语音">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<AudioOutlined />}
|
||||
className={styles.toolbarButton}
|
||||
/>
|
||||
</Tooltip>
|
||||
<AudioRecorder
|
||||
onAudioUploaded={audioData =>
|
||||
handleFileUploaded(audioData, 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,139 @@
|
||||
// 消息气泡样式
|
||||
.messageBubble {
|
||||
word-wrap: break-word;
|
||||
border-radius: 8px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
// 音频错误提示样式
|
||||
.audioError {
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 8px;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #ffccc7;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 语音消息容器
|
||||
.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;
|
||||
}
|
||||
|
||||
// 音频文本显示
|
||||
.audioText {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
// 进度条容器
|
||||
.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,245 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { PauseCircleFilled, SoundOutlined } from "@ant-design/icons";
|
||||
import styles from "./AudioMessage.module.scss";
|
||||
|
||||
interface AudioMessageProps {
|
||||
audioUrl: string;
|
||||
msgId: string;
|
||||
}
|
||||
|
||||
interface AudioData {
|
||||
durationMs?: number;
|
||||
url: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// 解析音频URL,支持两种格式:纯URL字符串和JSON字符串
|
||||
const parseAudioUrl = (audioUrl: string): AudioData => {
|
||||
try {
|
||||
// 尝试解析为JSON
|
||||
const parsed = JSON.parse(audioUrl);
|
||||
if (parsed.url) {
|
||||
return {
|
||||
durationMs: parsed.durationMs,
|
||||
url: parsed.url,
|
||||
text: parsed.text,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果解析失败,说明是纯URL字符串
|
||||
}
|
||||
|
||||
// 返回纯URL格式
|
||||
return {
|
||||
url: audioUrl,
|
||||
};
|
||||
};
|
||||
|
||||
// 测试音频URL是否可访问
|
||||
const testAudioUrl = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(url, { method: "HEAD" });
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const AudioMessage: React.FC<AudioMessageProps> = ({ audioUrl, msgId }) => {
|
||||
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
|
||||
const [audioProgress, setAudioProgress] = useState<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
const [audioError, setAudioError] = useState<string | null>(null);
|
||||
const audioRefs = useRef<Record<string, HTMLAudioElement>>({});
|
||||
|
||||
// 解析音频数据
|
||||
const audioData = parseAudioUrl(audioUrl);
|
||||
const actualAudioUrl = audioData.url;
|
||||
const audioDuration = audioData.durationMs;
|
||||
const audioText = audioData.text;
|
||||
|
||||
const audioId = `audio_${msgId}_${Date.now()}`;
|
||||
const isPlaying = playingAudioId === audioId;
|
||||
const progress = audioProgress[audioId] || 0;
|
||||
|
||||
// 格式化时长显示
|
||||
const formatDuration = (ms?: number): string => {
|
||||
if (!ms) return "语音";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// 播放/暂停音频
|
||||
const handleAudioToggle = async () => {
|
||||
const audio = audioRefs.current[audioId];
|
||||
if (!audio) {
|
||||
// 先测试URL是否可访问
|
||||
const isUrlAccessible = await testAudioUrl(actualAudioUrl);
|
||||
if (!isUrlAccessible) {
|
||||
setAudioError("音频文件无法访问,请检查网络连接");
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的错误状态
|
||||
setAudioError(null);
|
||||
|
||||
const newAudio = new Audio();
|
||||
|
||||
// 设置跨域属性
|
||||
newAudio.crossOrigin = "anonymous";
|
||||
newAudio.preload = "metadata";
|
||||
|
||||
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", e => {
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请稍后重试");
|
||||
});
|
||||
|
||||
// 设置音频源并尝试播放
|
||||
newAudio.src = actualAudioUrl;
|
||||
|
||||
try {
|
||||
await newAudio.play();
|
||||
setPlayingAudioId(audioId);
|
||||
} catch (error) {
|
||||
// 尝试备用方案:不设置crossOrigin
|
||||
try {
|
||||
const fallbackAudio = new Audio(actualAudioUrl);
|
||||
audioRefs.current[audioId] = fallbackAudio;
|
||||
|
||||
// 重新绑定事件监听器
|
||||
fallbackAudio.addEventListener("timeupdate", () => {
|
||||
const currentProgress =
|
||||
(fallbackAudio.currentTime / fallbackAudio.duration) * 100;
|
||||
setAudioProgress(prev => ({
|
||||
...prev,
|
||||
[audioId]: currentProgress,
|
||||
}));
|
||||
});
|
||||
|
||||
fallbackAudio.addEventListener("ended", () => {
|
||||
setPlayingAudioId(null);
|
||||
setAudioProgress(prev => ({ ...prev, [audioId]: 0 }));
|
||||
});
|
||||
|
||||
fallbackAudio.addEventListener("error", e => {
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请稍后重试");
|
||||
});
|
||||
|
||||
await fallbackAudio.play();
|
||||
setPlayingAudioId(audioId);
|
||||
} catch (fallbackError) {
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请检查音频格式或网络连接");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setPlayingAudioId(null);
|
||||
} else {
|
||||
// 停止其他正在播放的音频
|
||||
Object.values(audioRefs.current).forEach(a => a.pause());
|
||||
setPlayingAudioId(null);
|
||||
|
||||
try {
|
||||
await audio.play();
|
||||
setPlayingAudioId(audioId);
|
||||
} catch (error) {
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.messageBubble}>
|
||||
{audioError && (
|
||||
<div
|
||||
className={styles.audioError}
|
||||
onClick={() => {
|
||||
setAudioError(null);
|
||||
handleAudioToggle();
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
title="点击重试"
|
||||
>
|
||||
{audioError} (点击重试)
|
||||
</div>
|
||||
)}
|
||||
<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}>
|
||||
{formatDuration(audioDuration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{progress > 0 && (
|
||||
<div className={styles.audioProgress}>
|
||||
<div
|
||||
className={styles.audioProgressBar}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{audioText && (
|
||||
<div className={styles.audioText} title={audioText}>
|
||||
{audioText.length > 10
|
||||
? `${audioText.substring(0, 10)}...`
|
||||
: audioText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioMessage;
|
||||
@@ -0,0 +1,315 @@
|
||||
// 通用消息文本样式
|
||||
.messageText {
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// 小程序消息基础样式
|
||||
.miniProgramMessage {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 通用小程序卡片基础样式
|
||||
.miniProgramCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 280px;
|
||||
min-height: 64px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 通用小程序元素样式
|
||||
.miniProgramThumb {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.2s ease;
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.miniProgramTitle {
|
||||
padding-left: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.miniProgramApp {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 类型1小程序样式(默认横向布局)
|
||||
.miniProgramType1 {
|
||||
// 继承基础样式,无需额外定义
|
||||
}
|
||||
|
||||
// 类型2小程序样式(垂直图片布局)
|
||||
.miniProgramType2 {
|
||||
.miniProgramCardType2 {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
min-height: 220px;
|
||||
max-width: 280px;
|
||||
|
||||
.miniProgramAppTop {
|
||||
padding: 12px 16px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before {
|
||||
content: "📱";
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramImageArea {
|
||||
width: calc(100% - 32px);
|
||||
height: 0;
|
||||
padding-bottom: 75%; // 4:3 宽高比
|
||||
margin: 0px 16px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #f8f9fa;
|
||||
|
||||
.miniProgramImage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
border: 0.5px solid #e1e8ed;
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramContent {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.miniProgramTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.miniProgramIdentifier {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: 3px;
|
||||
|
||||
&::before {
|
||||
content: "🏷️";
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文章类型消息样式
|
||||
.articleMessage {
|
||||
.articleCard {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
min-height: auto;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.articleTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.articleContent {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.articleTextArea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.articleDescription {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.articleImageArea {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.articleImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// 文件消息样式
|
||||
.fileMessage {
|
||||
.fileCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
max-width: 250px;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fileAction {
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
// 小程序消息移动端适配
|
||||
.miniProgramMessage {
|
||||
.miniProgramCard {
|
||||
max-width: 260px;
|
||||
padding: 10px 14px;
|
||||
min-height: 56px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.miniProgramThumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramTitle {
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.miniProgramApp {
|
||||
font-size: 11px;
|
||||
padding: 1px 4px;
|
||||
|
||||
&::before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import React from "react";
|
||||
import { parseWeappMsgStr } from "@/utils/common";
|
||||
import styles from "./SmallProgramMessage.module.scss";
|
||||
|
||||
interface SmallProgramMessageProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
||||
content,
|
||||
}) => {
|
||||
// 统一的错误消息渲染函数
|
||||
const renderErrorMessage = (fallbackText: string) => (
|
||||
<div className={styles.messageText}>{fallbackText}</div>
|
||||
);
|
||||
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[小程序/文章/文件消息 - 无效内容]");
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmedContent = content.trim();
|
||||
|
||||
// 尝试解析JSON格式的消息
|
||||
if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) {
|
||||
const messageData = JSON.parse(trimmedContent);
|
||||
|
||||
// 处理文章类型消息
|
||||
if (messageData.type === "link" && messageData.title && messageData.url) {
|
||||
const { title, desc, thumbPath, url } = messageData;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.miniProgramMessage} ${styles.articleMessage}`}
|
||||
>
|
||||
<div
|
||||
className={`${styles.miniProgramCard} ${styles.articleCard}`}
|
||||
onClick={() => window.open(url, "_blank")}
|
||||
>
|
||||
{/* 标题在第一行 */}
|
||||
<div className={styles.articleTitle}>{title}</div>
|
||||
|
||||
{/* 下方:文字在左,图片在右 */}
|
||||
<div className={styles.articleContent}>
|
||||
<div className={styles.articleTextArea}>
|
||||
{desc && (
|
||||
<div className={styles.articleDescription}>{desc}</div>
|
||||
)}
|
||||
</div>
|
||||
{thumbPath && (
|
||||
<div className={styles.articleImageArea}>
|
||||
<img
|
||||
src={thumbPath}
|
||||
alt="文章缩略图"
|
||||
className={styles.articleImage}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.miniProgramApp}>文章</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 处理小程序消息 - 统一使用parseWeappMsgStr解析
|
||||
if (messageData.type === "miniprogram" && messageData.contentXml) {
|
||||
try {
|
||||
const parsedData = parseWeappMsgStr(trimmedContent);
|
||||
|
||||
if (parsedData.appmsg) {
|
||||
const { appmsg } = parsedData;
|
||||
const title = appmsg.title || "小程序消息";
|
||||
const appName =
|
||||
appmsg.sourcedisplayname || appmsg.appname || "小程序";
|
||||
|
||||
// 获取小程序类型
|
||||
const miniProgramType =
|
||||
appmsg.weappinfo && appmsg.weappinfo.type
|
||||
? parseInt(appmsg.weappinfo.type)
|
||||
: 1;
|
||||
|
||||
// 根据type类型渲染不同布局
|
||||
if (miniProgramType === 2) {
|
||||
// 类型2:图片区域布局
|
||||
return (
|
||||
<div
|
||||
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
|
||||
>
|
||||
<div
|
||||
className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}
|
||||
>
|
||||
<div className={styles.miniProgramAppTop}>{appName}</div>
|
||||
<div className={styles.miniProgramTitle}>{title}</div>
|
||||
<div className={styles.miniProgramImageArea}>
|
||||
<img
|
||||
src={parsedData.previewImage}
|
||||
alt="小程序图片"
|
||||
className={styles.miniProgramImage}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.miniProgramContent}>
|
||||
<div className={styles.miniProgramIdentifier}>小程序</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 默认类型:横向布局
|
||||
return (
|
||||
<div
|
||||
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
|
||||
>
|
||||
<div className={styles.miniProgramCard}>
|
||||
<img
|
||||
src={parsedData.previewImage}
|
||||
alt="小程序缩略图"
|
||||
className={styles.miniProgramThumb}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className={styles.miniProgramInfo}>
|
||||
<div className={styles.miniProgramTitle}>{title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.miniProgramApp}>{appName}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error("parseWeappMsgStr解析失败:", parseError);
|
||||
return renderErrorMessage("[小程序消息 - 解析失败]");
|
||||
}
|
||||
}
|
||||
|
||||
// 验证传统JSON格式的小程序数据结构
|
||||
if (
|
||||
messageData &&
|
||||
typeof messageData === "object" &&
|
||||
(messageData.title || messageData.appName)
|
||||
) {
|
||||
return (
|
||||
<div className={styles.miniProgramMessage}>
|
||||
<div className={styles.miniProgramCard}>
|
||||
{messageData.thumb && (
|
||||
<img
|
||||
src={messageData.thumb}
|
||||
alt="小程序缩略图"
|
||||
className={styles.miniProgramThumb}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.miniProgramInfo}>
|
||||
<div className={styles.miniProgramTitle}>
|
||||
{messageData.title || "小程序消息"}
|
||||
</div>
|
||||
{messageData.appName && (
|
||||
<div className={styles.miniProgramApp}>
|
||||
{messageData.appName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 增强的文件消息处理
|
||||
const isFileUrl =
|
||||
content.startsWith("http") ||
|
||||
content.startsWith("https") ||
|
||||
content.startsWith("file://") ||
|
||||
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(content);
|
||||
|
||||
if (isFileUrl) {
|
||||
// 尝试从URL中提取文件名
|
||||
const fileName = content.split("/").pop()?.split("?")[0] || "文件";
|
||||
const fileExtension = fileName.split(".").pop()?.toLowerCase();
|
||||
|
||||
// 根据文件类型选择图标
|
||||
let fileIcon = "📄";
|
||||
if (fileExtension) {
|
||||
const iconMap: { [key: string]: string } = {
|
||||
pdf: "📕",
|
||||
doc: "📘",
|
||||
docx: "📘",
|
||||
xls: "📗",
|
||||
xlsx: "📗",
|
||||
ppt: "📙",
|
||||
pptx: "📙",
|
||||
txt: "📝",
|
||||
zip: "🗜️",
|
||||
rar: "🗜️",
|
||||
"7z": "🗜️",
|
||||
jpg: "🖼️",
|
||||
jpeg: "🖼️",
|
||||
png: "🖼️",
|
||||
gif: "🖼️",
|
||||
mp4: "🎬",
|
||||
avi: "🎬",
|
||||
mov: "🎬",
|
||||
mp3: "🎵",
|
||||
wav: "🎵",
|
||||
flac: "🎵",
|
||||
};
|
||||
fileIcon = iconMap[fileExtension] || "📄";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.fileMessage}>
|
||||
<div className={styles.fileCard}>
|
||||
<div className={styles.fileIcon}>{fileIcon}</div>
|
||||
<div className={styles.fileInfo}>
|
||||
<div className={styles.fileName}>
|
||||
{fileName.length > 20
|
||||
? fileName.substring(0, 20) + "..."
|
||||
: fileName}
|
||||
</div>
|
||||
<div
|
||||
className={styles.fileAction}
|
||||
onClick={() => {
|
||||
try {
|
||||
window.open(content, "_blank");
|
||||
} catch (e) {
|
||||
console.error("文件打开失败:", e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
点击查看
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderErrorMessage("[小程序/文件消息]");
|
||||
} catch (e) {
|
||||
console.warn("小程序/文件消息解析失败:", e);
|
||||
return renderErrorMessage("[小程序/文件消息 - 解析失败]");
|
||||
}
|
||||
};
|
||||
|
||||
export default SmallProgramMessage;
|
||||
@@ -0,0 +1,153 @@
|
||||
// 通用消息文本样式
|
||||
.messageText {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
// 消息气泡样式
|
||||
.messageBubble {
|
||||
display: inline-block;
|
||||
max-width: 70%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
word-wrap: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// 视频消息样式
|
||||
.videoMessage {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
background: #000;
|
||||
|
||||
.videoContainer {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
video {
|
||||
display: block;
|
||||
max-width: 300px;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.videoThumbnail {
|
||||
display: block;
|
||||
max-width: 300px;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.videoPlayIcon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
|
||||
.loadingSpinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.playButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
|
||||
svg {
|
||||
margin-left: 2px; // 视觉居中调整
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.messageBubble {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.videoMessage .videoThumbnail,
|
||||
.videoMessage .videoContainer video {
|
||||
max-width: 200px;
|
||||
max-height: 250px;
|
||||
}
|
||||
|
||||
.videoMessage .videoPlayIcon {
|
||||
.loadingSpinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.videoMessage .downloadButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
|
||||
svg {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import React from "react";
|
||||
import { DownloadOutlined, PlayCircleFilled } from "@ant-design/icons";
|
||||
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
import styles from "./VideoMessage.module.scss";
|
||||
|
||||
interface VideoMessageProps {
|
||||
content: string;
|
||||
msg: ChatRecord;
|
||||
contract: ContractData | weChatGroup;
|
||||
}
|
||||
|
||||
const VideoMessage: React.FC<VideoMessageProps> = ({
|
||||
content,
|
||||
msg,
|
||||
contract,
|
||||
}) => {
|
||||
// 检测是否为直接视频链接的函数
|
||||
const isDirectVideoLink = (content: string): boolean => {
|
||||
const trimmedContent = content.trim();
|
||||
return (
|
||||
trimmedContent.startsWith("http") &&
|
||||
(trimmedContent.includes(".mp4") ||
|
||||
trimmedContent.includes(".mov") ||
|
||||
trimmedContent.includes(".avi") ||
|
||||
trimmedContent.includes("video"))
|
||||
);
|
||||
};
|
||||
|
||||
// 处理视频播放请求,发送socket请求获取真实视频地址
|
||||
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
|
||||
console.log("发送视频下载请求:", { messageId, tencentUrl });
|
||||
|
||||
// 先设置加载状态
|
||||
useWeChatStore.getState().setVideoLoading(messageId, true);
|
||||
|
||||
// 构建socket请求数据
|
||||
useWebSocketStore.getState().sendCommand("CmdDownloadVideo", {
|
||||
chatroomMessageId: contract.chatroomId ? messageId : 0,
|
||||
friendMessageId: contract.chatroomId ? 0 : messageId,
|
||||
seq: `${+new Date()}`, // 使用时间戳作为请求序列号
|
||||
tencentUrl: tencentUrl,
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染错误消息
|
||||
const renderErrorMessage = (message: string) => (
|
||||
<div className={styles.messageText}>{message}</div>
|
||||
);
|
||||
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[视频消息 - 无效内容]");
|
||||
}
|
||||
|
||||
// 如果content是直接的视频链接(已预览过或下载好的视频)
|
||||
if (isDirectVideoLink(content)) {
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<video
|
||||
controls
|
||||
src={content}
|
||||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
<a
|
||||
href={content}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试解析JSON格式的视频数据
|
||||
if (content.startsWith("{") && content.endsWith("}")) {
|
||||
const videoData = JSON.parse(content);
|
||||
|
||||
// 验证必要的视频数据字段
|
||||
if (
|
||||
videoData &&
|
||||
typeof videoData === "object" &&
|
||||
videoData.previewImage &&
|
||||
videoData.tencentUrl
|
||||
) {
|
||||
const previewImageUrl = String(videoData.previewImage).replace(
|
||||
/[`"']/g,
|
||||
"",
|
||||
);
|
||||
|
||||
// 创建点击处理函数
|
||||
const handlePlayClick = (e: React.MouseEvent, msg: ChatRecord) => {
|
||||
e.stopPropagation();
|
||||
// 如果没有视频URL且不在加载中,则发起下载请求
|
||||
if (!videoData.videoUrl && !videoData.isLoading) {
|
||||
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 如果已有视频URL,显示视频播放器
|
||||
if (videoData.videoUrl) {
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<video
|
||||
controls
|
||||
src={videoData.videoUrl}
|
||||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
<a
|
||||
href={videoData.videoUrl}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示预览图,根据加载状态显示不同的图标
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div
|
||||
className={styles.videoContainer}
|
||||
onClick={e => handlePlayClick(e, msg)}
|
||||
>
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="视频预览"
|
||||
className={styles.videoThumbnail}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
borderRadius: "8px",
|
||||
opacity: videoData.isLoading ? "0.7" : "1",
|
||||
}}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
const parent = target.parentElement?.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className={styles.videoPlayIcon}>
|
||||
{videoData.isLoading ? (
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
) : (
|
||||
<PlayCircleFilled
|
||||
style={{ fontSize: "48px", color: "#fff" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return renderErrorMessage("[视频消息]");
|
||||
} catch (e) {
|
||||
console.warn("视频消息解析失败:", e);
|
||||
return renderErrorMessage("[视频消息 - 解析失败]");
|
||||
}
|
||||
};
|
||||
|
||||
export default VideoMessage;
|
||||
@@ -1,23 +1,21 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Avatar, Divider } from "antd";
|
||||
import {
|
||||
UserOutlined,
|
||||
LoadingOutlined,
|
||||
DownloadOutlined,
|
||||
PlayCircleFilled,
|
||||
} from "@ant-design/icons";
|
||||
import { UserOutlined, LoadingOutlined } from "@ant-design/icons";
|
||||
import AudioMessage from "./components/AudioMessage/AudioMessage";
|
||||
import SmallProgramMessage from "./components/SmallProgramMessage";
|
||||
import VideoMessage from "./components/VideoMessage";
|
||||
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import { formatWechatTime, parseWeappMsgStr } from "@/utils/common";
|
||||
import { formatWechatTime } from "@/utils/common";
|
||||
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
|
||||
import styles from "./MessageRecord.module.scss";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
|
||||
interface MessageRecordProps {
|
||||
contract: ContractData | weChatGroup;
|
||||
}
|
||||
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);
|
||||
@@ -27,18 +25,6 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
);
|
||||
const prevMessagesRef = useRef(currentMessages);
|
||||
|
||||
// 检测是否为直接视频链接的函数
|
||||
const isDirectVideoLink = (content: string): boolean => {
|
||||
const trimmedContent = content.trim();
|
||||
return (
|
||||
trimmedContent.startsWith("http") &&
|
||||
(trimmedContent.includes(".mp4") ||
|
||||
trimmedContent.includes(".mov") ||
|
||||
trimmedContent.includes(".avi") ||
|
||||
trimmedContent.includes("video"))
|
||||
);
|
||||
};
|
||||
|
||||
// 判断是否为表情包URL的工具函数
|
||||
const isEmojiUrl = (content: string): boolean => {
|
||||
return (
|
||||
@@ -153,23 +139,6 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
// 处理视频播放请求,发送socket请求获取真实视频地址
|
||||
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
|
||||
console.log("发送视频下载请求:", { messageId, tencentUrl });
|
||||
|
||||
// 先设置加载状态
|
||||
useWeChatStore.getState().setVideoLoading(messageId, true);
|
||||
|
||||
// 构建socket请求数据
|
||||
useWebSocketStore.getState().sendCommand("CmdDownloadVideo", {
|
||||
chatroomMessageId: contract.chatroomId ? messageId : 0,
|
||||
friendMessageId: contract.chatroomId ? 0 : messageId,
|
||||
seq: `${+new Date()}`, // 使用时间戳作为请求序列号
|
||||
tencentUrl: tencentUrl,
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
});
|
||||
};
|
||||
|
||||
// 解析消息内容,根据msgType判断消息类型并返回对应的渲染内容
|
||||
const parseMessageContent = (
|
||||
content: string | null | undefined,
|
||||
@@ -225,136 +194,9 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
);
|
||||
|
||||
case 43: // 视频消息
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[视频消息 - 无效内容]");
|
||||
}
|
||||
|
||||
// 如果content是直接的视频链接(已预览过或下载好的视频)
|
||||
if (isDirectVideoLink(content)) {
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<video
|
||||
controls
|
||||
src={content}
|
||||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
<a
|
||||
href={content}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试解析JSON格式的视频数据
|
||||
if (content.startsWith("{") && content.endsWith("}")) {
|
||||
const videoData = JSON.parse(content);
|
||||
|
||||
// 验证必要的视频数据字段
|
||||
if (
|
||||
videoData &&
|
||||
typeof videoData === "object" &&
|
||||
videoData.previewImage &&
|
||||
videoData.tencentUrl
|
||||
) {
|
||||
const previewImageUrl = String(videoData.previewImage).replace(
|
||||
/[`"']/g,
|
||||
"",
|
||||
);
|
||||
|
||||
// 创建点击处理函数
|
||||
const handlePlayClick = (
|
||||
e: React.MouseEvent,
|
||||
msg: ChatRecord,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
// 如果没有视频URL且不在加载中,则发起下载请求
|
||||
if (!videoData.videoUrl && !videoData.isLoading) {
|
||||
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 如果已有视频URL,显示视频播放器
|
||||
if (videoData.videoUrl) {
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<video
|
||||
controls
|
||||
src={videoData.videoUrl}
|
||||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
<a
|
||||
href={videoData.videoUrl}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示预览图,根据加载状态显示不同的图标
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div
|
||||
className={styles.videoContainer}
|
||||
onClick={e => handlePlayClick(e, msg)}
|
||||
>
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="视频预览"
|
||||
className={styles.videoThumbnail}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
borderRadius: "8px",
|
||||
opacity: videoData.isLoading ? "0.7" : "1",
|
||||
}}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
const parent = target.parentElement?.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = `<div class="${styles.messageText}">[视频预览加载失败]</div>`;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className={styles.videoPlayIcon}>
|
||||
{videoData.isLoading ? (
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
) : (
|
||||
<PlayCircleFilled
|
||||
style={{ fontSize: "48px", color: "#fff" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return renderErrorMessage("[视频消息]");
|
||||
} catch (e) {
|
||||
console.warn("视频消息解析失败:", e);
|
||||
return renderErrorMessage("[视频消息 - 解析失败]");
|
||||
}
|
||||
return (
|
||||
<VideoMessage content={content || ""} msg={msg} contract={contract} />
|
||||
);
|
||||
|
||||
case 47: // 动图表情包(gif、其他表情包)
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
@@ -383,259 +225,16 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
}
|
||||
return renderErrorMessage("[表情包]");
|
||||
|
||||
case 49: // 小程序/文章/其他:图文、文件
|
||||
case 34: // 语音消息
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[小程序/文章/文件消息 - 无效内容]");
|
||||
return renderErrorMessage("[语音消息 - 无效内容]");
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmedContent = content.trim();
|
||||
// content直接是音频URL字符串
|
||||
return <AudioMessage audioUrl={content} msgId={String(msg.id)} />;
|
||||
|
||||
// 尝试解析JSON格式的消息
|
||||
if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) {
|
||||
const messageData = JSON.parse(trimmedContent);
|
||||
|
||||
// 处理文章类型消息
|
||||
if (
|
||||
messageData.type === "link" &&
|
||||
messageData.title &&
|
||||
messageData.url
|
||||
) {
|
||||
const { title, desc, thumbPath, url } = messageData;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.miniProgramMessage} ${styles.articleMessage}`}
|
||||
>
|
||||
<div
|
||||
className={`${styles.miniProgramCard} ${styles.articleCard}`}
|
||||
onClick={() => window.open(url, "_blank")}
|
||||
>
|
||||
{/* 标题在第一行 */}
|
||||
<div className={styles.articleTitle}>{title}</div>
|
||||
|
||||
{/* 下方:文字在左,图片在右 */}
|
||||
<div className={styles.articleContent}>
|
||||
<div className={styles.articleTextArea}>
|
||||
{desc && (
|
||||
<div className={styles.articleDescription}>
|
||||
{desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{thumbPath && (
|
||||
<div className={styles.articleImageArea}>
|
||||
<img
|
||||
src={thumbPath}
|
||||
alt="文章缩略图"
|
||||
className={styles.articleImage}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.miniProgramApp}>文章</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 处理小程序消息 - 统一使用parseWeappMsgStr解析
|
||||
if (messageData.type === "miniprogram" && messageData.contentXml) {
|
||||
try {
|
||||
const parsedData = parseWeappMsgStr(trimmedContent);
|
||||
|
||||
if (parsedData.appmsg) {
|
||||
const { appmsg } = parsedData;
|
||||
const title = appmsg.title || "小程序消息";
|
||||
const appName =
|
||||
appmsg.sourcedisplayname || appmsg.appname || "小程序";
|
||||
|
||||
// 获取小程序类型
|
||||
const miniProgramType =
|
||||
appmsg.weappinfo && appmsg.weappinfo.type
|
||||
? parseInt(appmsg.weappinfo.type)
|
||||
: 1;
|
||||
|
||||
// 根据type类型渲染不同布局
|
||||
if (miniProgramType === 2) {
|
||||
// 类型2:图片区域布局
|
||||
return (
|
||||
<div
|
||||
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
|
||||
>
|
||||
<div
|
||||
className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}
|
||||
>
|
||||
<div className={styles.miniProgramAppTop}>
|
||||
{appName}
|
||||
</div>
|
||||
<div className={styles.miniProgramTitle}>{title}</div>
|
||||
<div className={styles.miniProgramImageArea}>
|
||||
<img
|
||||
src={parsedData.previewImage}
|
||||
alt="小程序图片"
|
||||
className={styles.miniProgramImage}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.miniProgramContent}>
|
||||
<div className={styles.miniProgramIdentifier}>
|
||||
小程序
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 默认类型:横向布局
|
||||
return (
|
||||
<div
|
||||
className={`${styles.miniProgramMessage} ${styles.miniProgramType1}`}
|
||||
>
|
||||
<div className={styles.miniProgramCard}>
|
||||
<img
|
||||
src={parsedData.previewImage}
|
||||
alt="小程序缩略图"
|
||||
className={styles.miniProgramThumb}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className={styles.miniProgramInfo}>
|
||||
<div className={styles.miniProgramTitle}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.miniProgramApp}>{appName}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error("parseWeappMsgStr解析失败:", parseError);
|
||||
return renderErrorMessage("[小程序消息 - 解析失败]");
|
||||
}
|
||||
}
|
||||
|
||||
// 验证传统JSON格式的小程序数据结构
|
||||
if (
|
||||
messageData &&
|
||||
typeof messageData === "object" &&
|
||||
(messageData.title || messageData.appName)
|
||||
) {
|
||||
return (
|
||||
<div className={styles.miniProgramMessage}>
|
||||
<div className={styles.miniProgramCard}>
|
||||
{messageData.thumb && (
|
||||
<img
|
||||
src={messageData.thumb}
|
||||
alt="小程序缩略图"
|
||||
className={styles.miniProgramThumb}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.miniProgramInfo}>
|
||||
<div className={styles.miniProgramTitle}>
|
||||
{messageData.title || "小程序消息"}
|
||||
</div>
|
||||
{messageData.appName && (
|
||||
<div className={styles.miniProgramApp}>
|
||||
{messageData.appName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 增强的文件消息处理
|
||||
const isFileUrl =
|
||||
content.startsWith("http") ||
|
||||
content.startsWith("https") ||
|
||||
content.startsWith("file://") ||
|
||||
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(content);
|
||||
|
||||
if (isFileUrl) {
|
||||
// 尝试从URL中提取文件名
|
||||
const fileName = content.split("/").pop()?.split("?")[0] || "文件";
|
||||
const fileExtension = fileName.split(".").pop()?.toLowerCase();
|
||||
|
||||
// 根据文件类型选择图标
|
||||
let fileIcon = "📄";
|
||||
if (fileExtension) {
|
||||
const iconMap: { [key: string]: string } = {
|
||||
pdf: "📕",
|
||||
doc: "📘",
|
||||
docx: "📘",
|
||||
xls: "📗",
|
||||
xlsx: "📗",
|
||||
ppt: "📙",
|
||||
pptx: "📙",
|
||||
txt: "📝",
|
||||
zip: "🗜️",
|
||||
rar: "🗜️",
|
||||
"7z": "🗜️",
|
||||
jpg: "🖼️",
|
||||
jpeg: "🖼️",
|
||||
png: "🖼️",
|
||||
gif: "🖼️",
|
||||
mp4: "🎬",
|
||||
avi: "🎬",
|
||||
mov: "🎬",
|
||||
mp3: "🎵",
|
||||
wav: "🎵",
|
||||
flac: "🎵",
|
||||
};
|
||||
fileIcon = iconMap[fileExtension] || "📄";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.fileMessage}>
|
||||
<div className={styles.fileCard}>
|
||||
<div className={styles.fileIcon}>{fileIcon}</div>
|
||||
<div className={styles.fileInfo}>
|
||||
<div className={styles.fileName}>
|
||||
{fileName.length > 20
|
||||
? fileName.substring(0, 20) + "..."
|
||||
: fileName}
|
||||
</div>
|
||||
<div
|
||||
className={styles.fileAction}
|
||||
onClick={() => {
|
||||
try {
|
||||
window.open(content, "_blank");
|
||||
} catch (e) {
|
||||
console.error("文件打开失败:", e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
点击查看
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderErrorMessage("[小程序/文件消息]");
|
||||
} catch (e) {
|
||||
console.warn("小程序/文件消息解析失败:", e);
|
||||
return renderErrorMessage("[小程序/文件消息 - 解析失败]");
|
||||
}
|
||||
case 49: // 小程序/文章/其他:图文、文件
|
||||
return <SmallProgramMessage content={content || ""} />;
|
||||
|
||||
default: {
|
||||
// 兼容旧版本和未知消息类型的处理逻辑
|
||||
|
||||
Reference in New Issue
Block a user