1217 lines
37 KiB
TypeScript
1217 lines
37 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
||
import {
|
||
Layout,
|
||
Input,
|
||
Button,
|
||
Avatar,
|
||
Space,
|
||
Dropdown,
|
||
Menu,
|
||
message,
|
||
Tooltip,
|
||
Badge,
|
||
Card,
|
||
Tag,
|
||
Modal,
|
||
} from "antd";
|
||
import {
|
||
ShareAltOutlined,
|
||
SendOutlined,
|
||
SmileOutlined,
|
||
FolderOutlined,
|
||
PhoneOutlined,
|
||
VideoCameraOutlined,
|
||
MoreOutlined,
|
||
UserOutlined,
|
||
TeamOutlined,
|
||
MailOutlined,
|
||
EnvironmentOutlined,
|
||
CalendarOutlined,
|
||
BankOutlined,
|
||
CloseOutlined,
|
||
StarOutlined,
|
||
EnvironmentOutlined as LocationOutlined,
|
||
AudioOutlined,
|
||
AudioOutlined as AudioHoldOutlined,
|
||
DownloadOutlined,
|
||
CodeSandboxOutlined,
|
||
MessageOutlined,
|
||
FileOutlined,
|
||
FilePdfOutlined,
|
||
FileWordOutlined,
|
||
FileExcelOutlined,
|
||
FilePptOutlined,
|
||
PlayCircleFilled,
|
||
} from "@ant-design/icons";
|
||
import { ChatRecord, ContractData } from "@/pages/pc/ckbox/data";
|
||
import { clearUnreadCount, getMessages } from "@/pages/pc/ckbox/api";
|
||
import styles from "./ChatWindow.module.scss";
|
||
import { useWebSocketStore, WebSocketMessage } from "@/store/module/websocket";
|
||
import { formatWechatTime } from "@/utils/common";
|
||
const { Header, Content, Footer, Sider } = Layout;
|
||
const { TextArea } = Input;
|
||
|
||
interface ChatWindowProps {
|
||
contract: ContractData;
|
||
onSendMessage: (message: string) => void;
|
||
showProfile?: boolean;
|
||
onToggleProfile?: () => void;
|
||
}
|
||
|
||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||
contract,
|
||
onSendMessage,
|
||
showProfile = true,
|
||
onToggleProfile,
|
||
}) => {
|
||
const [messageApi, contextHolder] = message.useMessage();
|
||
const [messages, setMessages] = useState<ChatRecord[]>([]);
|
||
const [inputValue, setInputValue] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [showMaterialModal, setShowMaterialModal] = useState(false);
|
||
const [pendingVideoRequests, setPendingVideoRequests] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
clearUnreadCount([contract.id]).then(() => {
|
||
setLoading(true);
|
||
getMessages({
|
||
wechatAccountId: contract.wechatAccountId,
|
||
wechatFriendId: contract.id,
|
||
From: 1,
|
||
To: +new Date() + 1000,
|
||
Count: 100,
|
||
olderData: true,
|
||
})
|
||
.then(msg => {
|
||
setMessages(msg);
|
||
})
|
||
.finally(() => {
|
||
setLoading(false);
|
||
});
|
||
});
|
||
}, [contract.id]);
|
||
|
||
useEffect(() => {
|
||
// 只有在非视频加载操作时才自动滚动到底部
|
||
// 检查是否有视频正在加载中
|
||
const hasLoadingVideo = messages.some(msg => {
|
||
try {
|
||
const content =
|
||
typeof msg.content === "string"
|
||
? JSON.parse(msg.content)
|
||
: msg.content;
|
||
return content.isLoading === true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
if (!hasLoadingVideo) {
|
||
scrollToBottom();
|
||
}
|
||
}, [messages]);
|
||
|
||
// 添加 WebSocket 消息订阅 - 监听视频下载响应消息
|
||
useEffect(() => {
|
||
// 只有当有待处理的视频请求时才订阅WebSocket消息
|
||
if (Object.keys(pendingVideoRequests).length === 0) {
|
||
return;
|
||
}
|
||
|
||
console.log("开始监听视频下载响应,当前待处理请求:", pendingVideoRequests);
|
||
|
||
// 订阅 WebSocket 消息变化
|
||
const unsubscribe = useWebSocketStore.subscribe(state => {
|
||
// 只处理新增的消息
|
||
const messages = state.messages as WebSocketMessage[];
|
||
|
||
// 筛选出视频下载响应消息
|
||
messages.forEach(message => {
|
||
if (message?.content?.cmdType === "CmdDownloadVideoResult") {
|
||
console.log("收到视频下载响应:", message.content);
|
||
|
||
// 检查是否是我们正在等待的视频响应
|
||
const messageId = Object.keys(pendingVideoRequests).find(
|
||
id => pendingVideoRequests[id] === message.content.friendMessageId,
|
||
);
|
||
|
||
if (messageId) {
|
||
console.log("找到对应的消息ID:", messageId);
|
||
|
||
// 从待处理队列中移除
|
||
setPendingVideoRequests(prev => {
|
||
const newRequests = { ...prev };
|
||
delete newRequests[messageId];
|
||
return newRequests;
|
||
});
|
||
|
||
// 更新消息内容,将视频URL添加到对应的消息中
|
||
setMessages(prevMessages => {
|
||
return prevMessages.map(msg => {
|
||
if (msg.id === Number(messageId)) {
|
||
try {
|
||
const msgContent =
|
||
typeof msg.content === "string"
|
||
? JSON.parse(msg.content)
|
||
: msg.content;
|
||
|
||
// 更新消息内容,添加视频URL并移除加载状态
|
||
return {
|
||
...msg,
|
||
content: JSON.stringify({
|
||
...msgContent,
|
||
videoUrl: message.content.url,
|
||
isLoading: false,
|
||
}),
|
||
};
|
||
} catch (e) {
|
||
console.error("解析消息内容失败:", e);
|
||
}
|
||
}
|
||
return msg;
|
||
});
|
||
});
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// 组件卸载时取消订阅
|
||
return () => {
|
||
unsubscribe();
|
||
};
|
||
}, [pendingVideoRequests]); // 依赖于pendingVideoRequests,当队列变化时重新设置订阅
|
||
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
};
|
||
|
||
const handleSend = async () => {
|
||
if (!inputValue.trim()) return;
|
||
|
||
try {
|
||
const newMessage: ChatRecord = {
|
||
id: contract.id,
|
||
wechatAccountId: contract.wechatAccountId,
|
||
wechatFriendId: contract.id,
|
||
tenantId: 0,
|
||
accountId: 0,
|
||
synergyAccountId: 0,
|
||
content: inputValue,
|
||
msgType: 0,
|
||
msgSubType: 0,
|
||
msgSvrId: "",
|
||
isSend: false,
|
||
createTime: "",
|
||
isDeleted: false,
|
||
deleteTime: "",
|
||
sendStatus: 0,
|
||
wechatTime: 0,
|
||
origin: 0,
|
||
msgId: 0,
|
||
recalled: false,
|
||
};
|
||
|
||
setMessages(prev => [...prev, newMessage]);
|
||
onSendMessage(inputValue);
|
||
setInputValue("");
|
||
} catch (error) {
|
||
messageApi.error("发送失败");
|
||
}
|
||
};
|
||
|
||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
};
|
||
|
||
// 素材菜单项
|
||
const materialMenuItems = [
|
||
{
|
||
key: "text",
|
||
label: "文字素材",
|
||
icon: <span>📝</span>,
|
||
},
|
||
{
|
||
key: "audio",
|
||
label: "语音素材",
|
||
icon: <span>🎵</span>,
|
||
},
|
||
{
|
||
key: "image",
|
||
label: "图片素材",
|
||
icon: <span>🖼️</span>,
|
||
},
|
||
{
|
||
key: "video",
|
||
label: "视频素材",
|
||
icon: <span>🎬</span>,
|
||
},
|
||
{
|
||
key: "link",
|
||
label: "链接素材",
|
||
icon: <span>🔗</span>,
|
||
},
|
||
{
|
||
key: "card",
|
||
label: "名片素材",
|
||
icon: <span>📇</span>,
|
||
},
|
||
];
|
||
|
||
const handleMaterialSelect = (key: string) => {
|
||
console.log("选择素材类型:", key);
|
||
setShowMaterialModal(true);
|
||
// 这里可以根据不同的素材类型显示不同的模态框
|
||
};
|
||
|
||
// 处理视频播放请求,发送socket请求获取真实视频地址
|
||
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
|
||
// 生成请求ID (使用当前时间戳作为唯一标识)
|
||
const requestSeq = `${+new Date()}`;
|
||
console.log("发送视频下载请求:", { messageId, requestSeq });
|
||
|
||
// 构建socket请求数据
|
||
useWebSocketStore.getState().sendCommand("CmdDownloadVideo", {
|
||
chatroomMessageId: contract.chatroomId ? messageId : 0,
|
||
friendMessageId: contract.chatroomId ? 0 : messageId,
|
||
seq: requestSeq, // 使用唯一的请求ID
|
||
tencentUrl: tencentUrl,
|
||
wechatAccountId: contract.wechatAccountId,
|
||
});
|
||
|
||
// 将消息ID和请求序列号添加到待处理队列
|
||
setPendingVideoRequests(prev => ({
|
||
...prev,
|
||
[messageId]: messageId,
|
||
}));
|
||
|
||
// 更新消息状态为加载中
|
||
setMessages(prevMessages => {
|
||
return prevMessages.map(msg => {
|
||
if (msg.id === messageId) {
|
||
// 保存原始内容,添加loading状态
|
||
const originalContent = msg.content;
|
||
return {
|
||
...msg,
|
||
content: JSON.stringify({
|
||
...JSON.parse(originalContent),
|
||
isLoading: true,
|
||
}),
|
||
};
|
||
}
|
||
return msg;
|
||
});
|
||
});
|
||
};
|
||
|
||
// 解析消息内容,判断消息类型并返回对应的渲染内容
|
||
const parseMessageContent = (content: string, msg: ChatRecord) => {
|
||
// 检查是否为表情包
|
||
if (
|
||
typeof content === "string" &&
|
||
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") &&
|
||
content.includes("#")
|
||
) {
|
||
return (
|
||
<div className={styles.emojiMessage}>
|
||
<img
|
||
src={content}
|
||
alt="表情包"
|
||
style={{ maxWidth: "120px", maxHeight: "120px" }}
|
||
onClick={() => window.open(content, "_blank")}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 检查是否为带预览图的视频消息
|
||
try {
|
||
if (
|
||
typeof content === "string" &&
|
||
content.trim().startsWith("{") &&
|
||
content.trim().endsWith("}")
|
||
) {
|
||
const videoData = JSON.parse(content);
|
||
// 处理用户提供的JSON格式 {"previewImage":"https://...", "tencentUrl":"..."}
|
||
if (videoData.previewImage && videoData.tencentUrl) {
|
||
// 提取预览图URL,去掉可能的引号
|
||
const previewImageUrl = videoData.previewImage.replace(/[`"']/g, "");
|
||
|
||
// 创建点击处理函数,调用handleVideoPlayRequest发送socket请求获取真实视频地址
|
||
const handlePlayClick = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
// 调用处理函数,传入tencentUrl和消息ID
|
||
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
|
||
};
|
||
|
||
// 检查是否已下载视频URL
|
||
if (videoData.videoUrl) {
|
||
// 已获取到视频URL,显示视频播放器
|
||
return (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// 检查是否处于加载状态
|
||
if (videoData.isLoading) {
|
||
return (
|
||
<div className={styles.videoMessage}>
|
||
<div className={styles.videoContainer}>
|
||
<img
|
||
src={previewImageUrl}
|
||
alt="视频预览"
|
||
className={styles.videoThumbnail}
|
||
style={{
|
||
maxWidth: "100%",
|
||
borderRadius: "8px",
|
||
opacity: "0.7",
|
||
}}
|
||
/>
|
||
<div className={styles.videoPlayIcon}>
|
||
<div className={styles.loadingSpinner}></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 默认显示预览图和播放按钮
|
||
return (
|
||
<div className={styles.videoMessage}>
|
||
<div className={styles.videoContainer} onClick={handlePlayClick}>
|
||
<img
|
||
src={previewImageUrl}
|
||
alt="视频预览"
|
||
className={styles.videoThumbnail}
|
||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||
/>
|
||
<div className={styles.videoPlayIcon}>
|
||
<PlayCircleFilled
|
||
style={{ fontSize: "48px", color: "#fff" }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
// 保留原有的视频处理逻辑
|
||
else if (
|
||
videoData.type === "video" &&
|
||
videoData.url &&
|
||
videoData.thumb
|
||
) {
|
||
return (
|
||
<div className={styles.videoMessage}>
|
||
<div
|
||
className={styles.videoContainer}
|
||
onClick={() => window.open(videoData.url, "_blank")}
|
||
>
|
||
<img
|
||
src={videoData.thumb}
|
||
alt="视频预览"
|
||
className={styles.videoThumbnail}
|
||
/>
|
||
<div className={styles.videoPlayIcon}>
|
||
<VideoCameraOutlined
|
||
style={{ fontSize: "32px", color: "#fff" }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<a
|
||
href={videoData.url}
|
||
download
|
||
className={styles.downloadButton}
|
||
style={{ display: "flex" }}
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 解析JSON失败,不是视频消息
|
||
console.log("解析视频消息失败:", e);
|
||
}
|
||
|
||
// 检查是否为图片链接
|
||
if (
|
||
typeof content === "string" &&
|
||
(content.match(/\.(jpg|jpeg|png|gif)$/i) ||
|
||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
|
||
content.includes(".jpg")))
|
||
) {
|
||
return (
|
||
<div className={styles.imageMessage}>
|
||
<img
|
||
src={content}
|
||
alt="图片消息"
|
||
onClick={() => window.open(content, "_blank")}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 检查是否为视频链接
|
||
if (
|
||
typeof content === "string" &&
|
||
(content.match(/\.(mp4|avi|mov|wmv|flv)$/i) ||
|
||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
|
||
content.includes(".mp4")))
|
||
) {
|
||
return (
|
||
<div className={styles.videoMessage}>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// 检查是否为音频链接
|
||
if (
|
||
typeof content === "string" &&
|
||
(content.match(/\.(mp3|wav|ogg|m4a)$/i) ||
|
||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
|
||
content.includes(".mp3")))
|
||
) {
|
||
return (
|
||
<div className={styles.audioMessage}>
|
||
<audio controls src={content} style={{ maxWidth: "100%" }} />
|
||
<a
|
||
href={content}
|
||
download
|
||
className={styles.downloadButton}
|
||
style={{ display: "flex" }}
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 检查是否为Office文件链接
|
||
if (
|
||
typeof content === "string" &&
|
||
content.match(/\.(doc|docx|xls|xlsx|ppt|pptx|pdf)$/i)
|
||
) {
|
||
const fileName = content.split("/").pop() || "文件";
|
||
const fileExt = fileName.split(".").pop()?.toLowerCase();
|
||
|
||
// 根据文件类型选择不同的图标
|
||
let fileIcon = (
|
||
<FileOutlined
|
||
style={{ fontSize: "24px", marginRight: "8px", color: "#1890ff" }}
|
||
/>
|
||
);
|
||
|
||
if (fileExt === "pdf") {
|
||
fileIcon = (
|
||
<FilePdfOutlined
|
||
style={{ fontSize: "24px", marginRight: "8px", color: "#ff4d4f" }}
|
||
/>
|
||
);
|
||
} else if (fileExt === "doc" || fileExt === "docx") {
|
||
fileIcon = (
|
||
<FileWordOutlined
|
||
style={{ fontSize: "24px", marginRight: "8px", color: "#2f54eb" }}
|
||
/>
|
||
);
|
||
} else if (fileExt === "xls" || fileExt === "xlsx") {
|
||
fileIcon = (
|
||
<FileExcelOutlined
|
||
style={{ fontSize: "24px", marginRight: "8px", color: "#52c41a" }}
|
||
/>
|
||
);
|
||
} else if (fileExt === "ppt" || fileExt === "pptx") {
|
||
fileIcon = (
|
||
<FilePptOutlined
|
||
style={{ fontSize: "24px", marginRight: "8px", color: "#fa8c16" }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={styles.fileMessage}>
|
||
{fileIcon}
|
||
<div className={styles.fileInfo}>
|
||
<div
|
||
style={{
|
||
fontWeight: "bold",
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
}}
|
||
>
|
||
{fileName}
|
||
</div>
|
||
</div>
|
||
<a
|
||
href={content}
|
||
download={fileExt !== "pdf" ? fileName : undefined}
|
||
target={fileExt === "pdf" ? "_blank" : undefined}
|
||
className={styles.downloadButton}
|
||
onClick={e => e.stopPropagation()}
|
||
style={{ display: "flex" }}
|
||
rel="noreferrer"
|
||
>
|
||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 检查是否为文件消息(JSON格式)
|
||
try {
|
||
if (
|
||
typeof content === "string" &&
|
||
content.trim().startsWith("{") &&
|
||
content.trim().endsWith("}")
|
||
) {
|
||
const fileData = JSON.parse(content);
|
||
if (fileData.type === "file" && fileData.title) {
|
||
// 检查是否为Office文件
|
||
const fileExt = fileData.title.split(".").pop()?.toLowerCase();
|
||
let fileIcon = (
|
||
<FolderOutlined
|
||
style={{ fontSize: "24px", marginRight: "8px", color: "#1890ff" }}
|
||
/>
|
||
);
|
||
|
||
if (fileExt === "pdf") {
|
||
fileIcon = (
|
||
<FilePdfOutlined
|
||
style={{
|
||
fontSize: "24px",
|
||
marginRight: "8px",
|
||
color: "#ff4d4f",
|
||
}}
|
||
/>
|
||
);
|
||
} else if (fileExt === "doc" || fileExt === "docx") {
|
||
fileIcon = (
|
||
<FileWordOutlined
|
||
style={{
|
||
fontSize: "24px",
|
||
marginRight: "8px",
|
||
color: "#2f54eb",
|
||
}}
|
||
/>
|
||
);
|
||
} else if (fileExt === "xls" || fileExt === "xlsx") {
|
||
fileIcon = (
|
||
<FileExcelOutlined
|
||
style={{
|
||
fontSize: "24px",
|
||
marginRight: "8px",
|
||
color: "#52c41a",
|
||
}}
|
||
/>
|
||
);
|
||
} else if (fileExt === "ppt" || fileExt === "pptx") {
|
||
fileIcon = (
|
||
<FilePptOutlined
|
||
style={{
|
||
fontSize: "24px",
|
||
marginRight: "8px",
|
||
color: "#fa8c16",
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={styles.fileMessage}>
|
||
{fileIcon}
|
||
<div className={styles.fileInfo}>
|
||
<div
|
||
style={{
|
||
fontWeight: "bold",
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
}}
|
||
>
|
||
{fileData.title}
|
||
</div>
|
||
{fileData.totalLen && (
|
||
<div style={{ fontSize: "12px", color: "#8c8c8c" }}>
|
||
{Math.round(fileData.totalLen / 1024)} KB
|
||
</div>
|
||
)}
|
||
</div>
|
||
<a
|
||
href={fileData.url || "#"}
|
||
download={fileExt !== "pdf" ? fileData.title : undefined}
|
||
target={fileExt === "pdf" ? "_blank" : undefined}
|
||
className={styles.downloadButton}
|
||
style={{ display: "flex" }}
|
||
onClick={e => {
|
||
e.stopPropagation();
|
||
if (!fileData.url) {
|
||
console.log("文件URL不存在");
|
||
}
|
||
}}
|
||
rel="noreferrer"
|
||
>
|
||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// 解析JSON失败,不是文件消息
|
||
}
|
||
|
||
// 检查是否为位置信息
|
||
if (
|
||
typeof content === "string" &&
|
||
(content.includes("<location") || content.includes("<msg><location"))
|
||
) {
|
||
// 提取位置信息
|
||
const labelMatch = content.match(/label="([^"]*)"/i);
|
||
const poiNameMatch = content.match(/poiname="([^"]*)"/i);
|
||
const xMatch = content.match(/x="([^"]*)"/i);
|
||
const yMatch = content.match(/y="([^"]*)"/i);
|
||
|
||
const label = labelMatch
|
||
? labelMatch[1]
|
||
: poiNameMatch
|
||
? poiNameMatch[1]
|
||
: "位置信息";
|
||
const coordinates = xMatch && yMatch ? `${yMatch[1]}, ${xMatch[1]}` : "";
|
||
|
||
return (
|
||
<div className={styles.locationMessage}>
|
||
<EnvironmentOutlined
|
||
style={{ fontSize: "24px", marginRight: "8px", color: "#ff4d4f" }}
|
||
/>
|
||
<div>
|
||
<div style={{ fontWeight: "bold" }}>{label}</div>
|
||
{coordinates && (
|
||
<div style={{ fontSize: "12px", color: "#8c8c8c" }}>
|
||
{coordinates}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 默认为文本消息
|
||
return <div className={styles.messageText}>{content}</div>;
|
||
};
|
||
|
||
// 用于分组消息并添加时间戳的辅助函数
|
||
const groupMessagesByTime = (messages: ChatRecord[]) => {
|
||
const groups: { time: string; messages: ChatRecord[] }[] = [];
|
||
messages.forEach(msg => {
|
||
// 使用 formatWechatTime 函数格式化时间戳
|
||
const formattedTime = formatWechatTime(msg.wechatTime);
|
||
groups.push({ time: formattedTime, messages: [msg] });
|
||
});
|
||
|
||
return groups;
|
||
};
|
||
|
||
const renderMessage = (msg: ChatRecord) => {
|
||
const isOwn = msg.isSend;
|
||
return (
|
||
<div
|
||
key={msg.id}
|
||
className={`${styles.messageItem} ${
|
||
isOwn ? styles.ownMessage : styles.otherMessage
|
||
}`}
|
||
>
|
||
<div className={styles.messageContent}>
|
||
{!isOwn && (
|
||
<Avatar
|
||
size={32}
|
||
src={contract.avatar}
|
||
icon={<UserOutlined />}
|
||
className={styles.messageAvatar}
|
||
/>
|
||
)}
|
||
<div className={styles.messageBubble}>
|
||
{!isOwn && (
|
||
<div className={styles.messageSender}>{msg.senderName}</div>
|
||
)}
|
||
{parseMessageContent(msg.content, msg)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const chatMenuItems = [
|
||
{
|
||
key: "profile",
|
||
icon: <UserOutlined />,
|
||
label: "查看资料",
|
||
},
|
||
{
|
||
key: "call",
|
||
icon: <PhoneOutlined />,
|
||
label: "语音通话",
|
||
},
|
||
{
|
||
key: "video",
|
||
icon: <VideoCameraOutlined />,
|
||
label: "视频通话",
|
||
},
|
||
{
|
||
type: "divider",
|
||
},
|
||
{
|
||
key: "pin",
|
||
label: "置顶聊天",
|
||
},
|
||
{
|
||
key: "mute",
|
||
label: "消息免打扰",
|
||
},
|
||
{
|
||
type: "divider",
|
||
},
|
||
{
|
||
key: "clear",
|
||
danger: true,
|
||
label: "清空聊天记录",
|
||
},
|
||
];
|
||
|
||
// 模拟联系人详细信息
|
||
const contractInfo = {
|
||
name: contract.name,
|
||
avatar: contract.avatar,
|
||
phone: "13800138001",
|
||
email: "zhangsan@example.com",
|
||
department: "技术部",
|
||
position: "前端工程师",
|
||
company: "某某科技有限公司",
|
||
location: "北京市朝阳区",
|
||
joinDate: "2023-01-15",
|
||
status: "在线",
|
||
tags: ["技术专家", "前端", "React"],
|
||
bio: "专注于前端开发,热爱新技术,擅长React、Vue等框架。",
|
||
};
|
||
|
||
return (
|
||
<Layout className={styles.chatWindow}>
|
||
{contextHolder}
|
||
{/* 聊天主体区域 */}
|
||
<Layout className={styles.chatMain}>
|
||
{/* 聊天头部 */}
|
||
<Header className={styles.chatHeader}>
|
||
<div className={styles.chatHeaderInfo}>
|
||
<Avatar
|
||
size={40}
|
||
src={contract.avatar}
|
||
icon={
|
||
contract.type === "group" ? <TeamOutlined /> : <UserOutlined />
|
||
}
|
||
/>
|
||
<div
|
||
className={styles.chatHeaderDetails}
|
||
style={{
|
||
display: "flex",
|
||
}}
|
||
>
|
||
<div className={styles.chatHeaderName}>
|
||
{contract.name}
|
||
{contract.online && (
|
||
<span className={styles.chatHeaderOnlineStatus}>在线</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Space>
|
||
<Tooltip title="语音通话">
|
||
<Button
|
||
type="text"
|
||
icon={<PhoneOutlined />}
|
||
className={styles.headerButton}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="视频通话">
|
||
<Button
|
||
type="text"
|
||
icon={<VideoCameraOutlined />}
|
||
className={styles.headerButton}
|
||
/>
|
||
</Tooltip>
|
||
<Dropdown menu={{ items: chatMenuItems }} trigger={["click"]}>
|
||
<Button
|
||
type="text"
|
||
icon={<MoreOutlined />}
|
||
className={styles.headerButton}
|
||
/>
|
||
</Dropdown>
|
||
</Space>
|
||
</Header>
|
||
|
||
{/* 聊天内容 */}
|
||
<Content className={styles.chatContent}>
|
||
<div className={styles.messagesContainer}>
|
||
{loading ? (
|
||
<div className={styles.loadingContainer}>
|
||
<div>加载中...</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{groupMessagesByTime(messages).map((group, groupIndex) => (
|
||
<React.Fragment key={`group-${groupIndex}`}>
|
||
<div className={styles.messageTime}>{group.time}</div>
|
||
{group.messages.map(renderMessage)}
|
||
</React.Fragment>
|
||
))}
|
||
<div ref={messagesEndRef} />
|
||
</>
|
||
)}
|
||
</div>
|
||
</Content>
|
||
|
||
{/* 聊天输入 */}
|
||
<Footer className={styles.chatFooter}>
|
||
<div className={styles.inputContainer}>
|
||
<div className={styles.inputToolbar}>
|
||
<div className={styles.leftTool}>
|
||
<Tooltip title="表情">
|
||
<Button
|
||
type="text"
|
||
icon={<SmileOutlined />}
|
||
className={styles.toolbarButton}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="上传附件">
|
||
<Button
|
||
type="text"
|
||
icon={<FolderOutlined />}
|
||
className={styles.toolbarButton}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="收藏">
|
||
<Button
|
||
type="text"
|
||
icon={<StarOutlined />}
|
||
className={styles.toolbarButton}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="位置">
|
||
<Button
|
||
type="text"
|
||
icon={<LocationOutlined />}
|
||
className={styles.toolbarButton}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="语音">
|
||
<Button
|
||
type="text"
|
||
icon={<AudioOutlined />}
|
||
className={styles.toolbarButton}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip title="按住说话">
|
||
<Button
|
||
type="text"
|
||
icon={<AudioHoldOutlined />}
|
||
className={styles.toolbarButton}
|
||
style={{ position: "relative" }}
|
||
>
|
||
<span
|
||
style={{
|
||
position: "absolute",
|
||
top: "2px",
|
||
right: "2px",
|
||
fontSize: "8px",
|
||
color: "#52c41a",
|
||
fontWeight: "bold",
|
||
}}
|
||
>
|
||
H
|
||
</span>
|
||
</Button>
|
||
</Tooltip>
|
||
<Dropdown
|
||
overlay={
|
||
<Menu
|
||
items={materialMenuItems}
|
||
onClick={({ key }) => handleMaterialSelect(key)}
|
||
style={{
|
||
borderRadius: "8px",
|
||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||
}}
|
||
/>
|
||
}
|
||
trigger={["click"]}
|
||
placement="topLeft"
|
||
>
|
||
<Button
|
||
type="text"
|
||
icon={<CodeSandboxOutlined />}
|
||
className={styles.toolbarButton}
|
||
/>
|
||
</Dropdown>
|
||
</div>
|
||
<div className={styles.rightTool}>
|
||
<div className={styles.rightToolItem}>
|
||
<ShareAltOutlined />
|
||
转给他人
|
||
</div>
|
||
<div className={styles.rightToolItem}>
|
||
<MessageOutlined />
|
||
聊天记录
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.inputArea}>
|
||
<TextArea
|
||
value={inputValue}
|
||
onChange={e => setInputValue(e.target.value)}
|
||
onKeyPress={handleKeyPress}
|
||
placeholder="输入消息..."
|
||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||
className={styles.messageInput}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<SendOutlined />}
|
||
onClick={handleSend}
|
||
disabled={!inputValue.trim()}
|
||
className={styles.sendButton}
|
||
>
|
||
发送
|
||
</Button>
|
||
</div>
|
||
<div className={styles.inputHint}>按下Ctrl+Enter换行</div>
|
||
</div>
|
||
</Footer>
|
||
</Layout>
|
||
|
||
{/* 右侧个人资料卡片 */}
|
||
{showProfile && (
|
||
<Sider width={280} className={styles.profileSider}>
|
||
<div className={styles.profileSiderContent}>
|
||
<div className={styles.profileHeader}>
|
||
<h3>个人资料</h3>
|
||
<Button
|
||
type="text"
|
||
icon={<CloseOutlined />}
|
||
onClick={onToggleProfile}
|
||
className={styles.closeButton}
|
||
/>
|
||
</div>
|
||
<div className={styles.profileContent}>
|
||
{/* 基本信息 */}
|
||
<Card className={styles.profileCard}>
|
||
<div className={styles.profileBasic}>
|
||
<Avatar
|
||
size={80}
|
||
src={contractInfo.avatar}
|
||
icon={<UserOutlined />}
|
||
/>
|
||
<div className={styles.profileInfo}>
|
||
<h4>{contractInfo.name}</h4>
|
||
<p className={styles.profileStatus}>
|
||
<Badge status="success" text={contractInfo.status} />
|
||
</p>
|
||
<p className={styles.profilePosition}>
|
||
{contractInfo.position} · {contractInfo.department}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 联系信息 */}
|
||
<Card title="联系信息" className={styles.profileCard}>
|
||
<div className={styles.contractInfo}>
|
||
<div className={styles.contractItem}>
|
||
<PhoneOutlined />
|
||
<span className={styles.contractItemText}>
|
||
{contractInfo.phone}
|
||
</span>
|
||
</div>
|
||
<div className={styles.contractItem}>
|
||
<MailOutlined />
|
||
<span className={styles.contractItemText}>
|
||
{contractInfo.email}
|
||
</span>
|
||
</div>
|
||
<div className={styles.contractItem}>
|
||
<EnvironmentOutlined />
|
||
<span className={styles.contractItemText}>
|
||
{contractInfo.location}
|
||
</span>
|
||
</div>
|
||
<div className={styles.contractItem}>
|
||
<BankOutlined />
|
||
<span className={styles.contractItemText}>
|
||
{contractInfo.company}
|
||
</span>
|
||
</div>
|
||
<div className={styles.contractItem}>
|
||
<CalendarOutlined />
|
||
<span className={styles.contractItemText}>
|
||
入职时间:{contractInfo.joinDate}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 标签 */}
|
||
<Card title="标签" className={styles.profileCard}>
|
||
<div className={styles.tagsContainer}>
|
||
{contractInfo.tags.map((tag, index) => (
|
||
<Tag key={index} color="blue">
|
||
{tag}
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 个人简介 */}
|
||
<Card title="个人简介" className={styles.profileCard}>
|
||
<p className={styles.bioText}>{contractInfo.bio}</p>
|
||
</Card>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className={styles.profileActions}>
|
||
<Button type="primary" icon={<PhoneOutlined />} block>
|
||
语音通话
|
||
</Button>
|
||
<Button
|
||
icon={<VideoCameraOutlined />}
|
||
block
|
||
style={{ marginTop: 8 }}
|
||
>
|
||
视频通话
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Sider>
|
||
)}
|
||
|
||
{/* 素材选择模态框 */}
|
||
<Modal
|
||
title="选择素材"
|
||
open={showMaterialModal}
|
||
onCancel={() => setShowMaterialModal(false)}
|
||
footer={[
|
||
<Button key="cancel" onClick={() => setShowMaterialModal(false)}>
|
||
取消
|
||
</Button>,
|
||
<Button
|
||
key="confirm"
|
||
type="primary"
|
||
onClick={() => setShowMaterialModal(false)}
|
||
>
|
||
确定
|
||
</Button>,
|
||
]}
|
||
width={800}
|
||
>
|
||
<div style={{ display: "flex", height: "400px" }}>
|
||
{/* 左侧素材分类 */}
|
||
<div
|
||
style={{
|
||
width: "200px",
|
||
background: "#f5f5f5",
|
||
borderRight: "1px solid #e8e8e8",
|
||
}}
|
||
>
|
||
<div style={{ padding: "16px", borderBottom: "1px solid #e8e8e8" }}>
|
||
<h4 style={{ margin: 0, color: "#262626" }}>公共素材</h4>
|
||
</div>
|
||
<div style={{ padding: "8px 0" }}>
|
||
<div
|
||
style={{
|
||
padding: "8px 16px",
|
||
cursor: "pointer",
|
||
background: "#e6f7ff",
|
||
borderLeft: "3px solid #1890ff",
|
||
color: "#1890ff",
|
||
}}
|
||
>
|
||
暗黑4
|
||
</div>
|
||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||
针对老客户的...
|
||
</div>
|
||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||
D2辅助
|
||
</div>
|
||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||
ROS反馈演示...
|
||
</div>
|
||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||
一键宏产品素...
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: "16px", borderTop: "1px solid #e8e8e8" }}>
|
||
<h4 style={{ margin: 0, color: "#262626" }}>部门素材</h4>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧内容区域 */}
|
||
<div style={{ flex: 1, padding: "16px" }}>
|
||
<div style={{ marginBottom: "16px" }}>
|
||
<Input.Search placeholder="昵称" style={{ width: "100%" }} />
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
height: "300px",
|
||
color: "#8c8c8c",
|
||
}}
|
||
>
|
||
请选择左侧素材分类
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
export default ChatWindow;
|