feat(聊天窗口): 添加表情选择器组件并集成到消息输入

refactor(聊天窗口): 重构聊天窗口组件结构,提取消息记录为独立组件

feat(消息解析): 添加微信小程序消息解析功能

style(表情选择器): 添加表情选择器样式文件

chore: 添加xmldom依赖用于XML解析
This commit is contained in:
超级老白兔
2025-09-09 14:44:02 +08:00
parent f724e91421
commit 983dbf0009
10 changed files with 690 additions and 629 deletions

View File

@@ -18,6 +18,7 @@
"react-router-dom": "^6.20.0",
"react-window": "^1.8.11",
"vconsole": "^3.15.1",
"xmldom": "^0.6.0",
"zustand": "^5.0.6"
},
"devDependencies": {

View File

@@ -50,6 +50,9 @@ importers:
vconsole:
specifier: ^3.15.1
version: 3.15.1
xmldom:
specifier: ^0.6.0
version: 0.6.0
zustand:
specifier: ^5.0.6
version: 5.0.7(@types/react@19.1.10)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1))
@@ -2355,6 +2358,10 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xmldom@0.6.0:
resolution: {integrity: sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg==}
engines: {node: '>=10.0.0'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4974,6 +4981,8 @@ snapshots:
wrappy@1.0.2: {}
xmldom@0.6.0: {}
yallist@3.1.1: {}
yocto-queue@0.1.0: {}

View File

@@ -0,0 +1,168 @@
/* 表情选择器容器 */
.emoji-picker-container {
position: relative;
display: inline-block;
}
/* 默认触发器按钮 */
.emoji-picker-trigger {
background: none;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 8px 12px;
font-size: 18px;
cursor: pointer;
transition: all 0.2s ease;
}
.emoji-picker-trigger:hover {
background-color: #f5f5f5;
border-color: #d0d0d0;
}
/* 表情选择器面板 */
.emoji-picker-panel {
position: absolute;
bottom: 100%;
left: 0;
z-index: 1000;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 320px;
max-height: 400px;
overflow: hidden;
margin-bottom: 4px;
}
/* 分类标签栏 */
.emoji-categories {
display: flex;
background-color: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
padding: 8px;
gap: 4px;
}
.category-btn {
background: none;
border: none;
padding: 8px 12px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s ease;
flex: 1;
text-align: center;
}
.category-btn:hover {
background-color: #e9ecef;
}
.category-btn.active {
background-color: #007bff;
color: white;
}
/* 表情网格 */
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
padding: 12px;
max-height: 280px;
overflow-y: auto;
}
/* 表情项 */
.emoji-item {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.emoji-item:hover {
background-color: #f0f0f0;
}
.emoji-image {
width: 24px;
height: 24px;
object-fit: contain;
}
/* 空状态 */
.emoji-empty {
text-align: center;
padding: 40px 20px;
color: #999;
font-size: 14px;
}
/* 滚动条样式 */
.emoji-grid::-webkit-scrollbar {
width: 6px;
}
.emoji-grid::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.emoji-grid::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.emoji-grid::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 响应式设计 */
@media (max-width: 480px) {
.emoji-picker-panel {
width: 280px;
}
.emoji-grid {
grid-template-columns: repeat(7, 1fr);
}
}
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
.emoji-picker-panel {
background: #2d3748;
border-color: #4a5568;
color: white;
}
.emoji-categories {
background-color: #1a202c;
border-bottom-color: #4a5568;
}
.category-btn:hover {
background-color: #4a5568;
}
.emoji-item:hover {
background-color: #4a5568;
}
.emoji-picker-trigger {
border-color: #4a5568;
color: white;
}
.emoji-picker-trigger:hover {
background-color: #4a5568;
}
}

View File

@@ -0,0 +1,120 @@
import React, { useState, useRef, useEffect } from "react";
import {
EmojiCategory,
EmojiInfo,
getAllEmojis,
getEmojisByCategory,
} from "./wechatEmoji";
import "./EmojiPicker.css";
interface EmojiPickerProps {
onEmojiSelect: (emoji: EmojiInfo) => void;
trigger?: React.ReactNode;
className?: string;
}
const EmojiPicker: React.FC<EmojiPickerProps> = ({
onEmojiSelect,
trigger,
className = "",
}) => {
const [isOpen, setIsOpen] = useState(false);
const [activeCategory, setActiveCategory] = useState<EmojiCategory>(
EmojiCategory.FACE,
);
const pickerRef = useRef<HTMLDivElement>(null);
// 分类配置
const categories = [
{ key: EmojiCategory.FACE, label: "😊", title: "人脸" },
{ key: EmojiCategory.GESTURE, label: "👋", title: "手势" },
{ key: EmojiCategory.ANIMAL, label: "🐷", title: "动物" },
{ key: EmojiCategory.BLESSING, label: "🎉", title: "祝福" },
{ key: EmojiCategory.OTHER, label: "❤️", title: "其他" },
];
// 获取当前分类的表情
const currentEmojis = getEmojisByCategory(activeCategory);
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
pickerRef.current &&
!pickerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
// 处理表情选择
const handleEmojiClick = (emoji: EmojiInfo) => {
onEmojiSelect(emoji);
setIsOpen(false);
};
// 默认触发器
const defaultTrigger = <button className="emoji-picker-trigger">😊</button>;
return (
<div className={`emoji-picker-container ${className}`} ref={pickerRef}>
{/* 触发器 */}
<div onClick={() => setIsOpen(!isOpen)}>{trigger || defaultTrigger}</div>
{/* 表情选择器面板 */}
{isOpen && (
<div className="emoji-picker-panel">
{/* 分类标签 */}
<div className="emoji-categories">
{categories.map(category => (
<button
key={category.key}
className={`category-btn ${
activeCategory === category.key ? "active" : ""
}`}
onClick={() => setActiveCategory(category.key)}
title={category.title}
>
{category.label}
</button>
))}
</div>
{/* 表情网格 */}
<div className="emoji-grid">
{currentEmojis.map(emoji => (
<div
key={emoji.name}
className="emoji-item"
onClick={() => handleEmojiClick(emoji)}
title={emoji.name}
>
<img
src={emoji.path}
alt={emoji.name}
className="emoji-image"
/>
</div>
))}
</div>
{/* 空状态 */}
{currentEmojis.length === 0 && (
<div className="emoji-empty"></div>
)}
</div>
)}
</div>
);
};
export default EmojiPicker;

View File

@@ -0,0 +1,127 @@
import React, { useState } from "react";
import EmojiPicker from "./EmojiPicker";
import { EmojiInfo } from "./wechatEmoji";
const EmojiPickerDemo: React.FC = () => {
const [selectedEmojis, setSelectedEmojis] = useState<EmojiInfo[]>([]);
const [message, setMessage] = useState("");
// 处理表情选择
const handleEmojiSelect = (emoji: EmojiInfo) => {
setSelectedEmojis(prev => [...prev, emoji]);
// 模拟发送表情
console.log("发送表情:", emoji.name);
alert(`发送了表情: ${emoji.name}`);
};
// 清空选择的表情
const clearEmojis = () => {
setSelectedEmojis([]);
};
return (
<div style={{ padding: "20px", maxWidth: "600px", margin: "0 auto" }}>
<h2></h2>
{/* 输入区域 */}
<div style={{ marginBottom: "20px" }}>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="输入消息..."
style={{
width: "100%",
height: "100px",
padding: "10px",
border: "1px solid #ddd",
borderRadius: "4px",
resize: "vertical",
}}
/>
</div>
{/* 表情选择器 */}
<div style={{ marginBottom: "20px" }}>
<label
style={{ display: "block", marginBottom: "8px", fontWeight: "bold" }}
>
:
</label>
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
</div>
{/* 已选择的表情 */}
{selectedEmojis.length > 0 && (
<div style={{ marginBottom: "20px" }}>
<h3>:</h3>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "8px",
padding: "10px",
backgroundColor: "#f5f5f5",
borderRadius: "4px",
}}
>
{selectedEmojis.map((emoji, index) => (
<div
key={index}
style={{
display: "flex",
alignItems: "center",
gap: "4px",
padding: "4px 8px",
backgroundColor: "white",
borderRadius: "4px",
fontSize: "12px",
}}
>
<img
src={emoji.path}
alt={emoji.name}
style={{ width: "20px", height: "20px" }}
/>
<span>{emoji.name}</span>
</div>
))}
</div>
<button
onClick={clearEmojis}
style={{
marginTop: "10px",
padding: "6px 12px",
backgroundColor: "#dc3545",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
</button>
</div>
)}
{/* 使用说明 */}
<div
style={{
backgroundColor: "#e7f3ff",
padding: "15px",
borderRadius: "4px",
fontSize: "14px",
}}
>
<h4>使:</h4>
<ul>
<li></li>
<li></li>
<li>&ldquo;&rdquo;</li>
<li></li>
</ul>
</div>
</div>
);
};
export default EmojiPickerDemo;

View File

@@ -0,0 +1,18 @@
// 导出主要组件
export { default as EmojiPicker } from "./EmojiPicker";
// 导出表情数据和类型
export {
EmojiCategory,
type EmojiInfo,
type EmojiName,
getAllEmojis,
getEmojisByCategory,
getEmojiInfo,
getEmojiPath,
searchEmojis,
EMOJI_CATEGORIES,
} from "./wechatEmoji";
// 默认导出
export { default } from "./EmojiPicker";

View File

@@ -14,6 +14,8 @@ import {
} from "@ant-design/icons";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { EmojiPicker } from "@/components/EmojiSeclection";
import { EmojiInfo } from "@/components/EmojiSeclection/wechatEmoji";
import styles from "./MessageEnter.module.scss";
const { Footer } = Layout;
@@ -28,6 +30,7 @@ const { sendCommand } = useWebSocketStore.getState();
const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const [inputValue, setInputValue] = useState("");
const [showMaterialModal, setShowMaterialModal] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const handleSend = async () => {
if (!inputValue.trim()) return;
@@ -92,6 +95,22 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
// 这里可以根据不同的素材类型显示不同的模态框
};
// 处理表情选择
const handleEmojiSelect = (emoji: EmojiInfo) => {
console.log("选择表情:", emoji.name);
// 发送表情消息
const params = {
wechatAccountId: contract.wechatAccountId,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0,
msgType: 1, // 文本消息类型
content: `[${emoji.name}]`, // 表情以文本形式发送
};
sendCommand("CmdSendMessage", params);
setShowEmojiPicker(false);
};
return (
<>
{/* 聊天输入 */}
@@ -99,13 +118,7 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
<div className={styles.inputContainer}>
<div className={styles.inputToolbar}>
<div className={styles.leftTool}>
<Tooltip title="表情">
<Button
type="text"
icon={<SmileOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
<Tooltip title="上传附件">
<Button
type="text"

View File

@@ -7,7 +7,7 @@ import {
PlayCircleFilled,
} from "@ant-design/icons";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { formatWechatTime } from "@/utils/common";
import { formatWechatTime, parseWeappMsgStr } from "@/utils/common";
import styles from "./MessageRecord.module.scss";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
@@ -341,6 +341,103 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
// 尝试解析JSON格式的消息
if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) {
// 首先尝试使用parseWeappMsgStr解析小程序消息
try {
const parsedData = parseWeappMsgStr(trimmedContent);
// 检查是否为小程序消息
if (parsedData.type === "miniprogram" && parsedData.appmsg) {
console.log(parsedData);
const { appmsg } = parsedData;
const title = appmsg.title || "小程序消息";
const appName =
appmsg.sourcedisplayname || appmsg.appname || "小程序";
// 获取缩略图URL
let thumbUrl = "";
if (appmsg.weappinfo && appmsg.weappinfo.thumburl) {
thumbUrl = appmsg.weappinfo.thumburl
.replace(/[`"']/g, "")
.replace(/&amp;/g, "&");
}
// 获取小程序类型
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>
{thumbUrl && (
<div className={styles.miniProgramImageArea}>
<img
src={thumbUrl}
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}>
{thumbUrl && (
<img
src={thumbUrl}
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.warn(
"parseWeappMsgStr解析失败使用备用解析方法:",
parseError,
);
}
// 备用解析方法:处理其他格式的消息
const messageData = JSON.parse(trimmedContent);
// 处理文章类型消息
@@ -421,7 +518,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
// 从XML中提取appname或使用默认值
const appNameMatch =
xmlContent.match(/<appname\s*\/?>([^<]*)<\/appname>/) ||
xmlContent.match(/<appname\s*\/?>[^<]*<\/appname>/) ||
xmlContent.match(
/<sourcedisplayname>([^<]*)<\/sourcedisplayname>/,
);

View File

@@ -1,27 +1,20 @@
import React, { useEffect, useRef, useState } from "react";
import { Layout, Button, Avatar, Space, Tooltip } from "antd";
import React, { useState } from "react";
import { Layout, Button, Avatar, Space, Dropdown, Menu, Tooltip } from "antd";
import {
PhoneOutlined,
VideoCameraOutlined,
InfoCircleOutlined,
MoreOutlined,
UserOutlined,
DownloadOutlined,
FileOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
PlayCircleFilled,
TeamOutlined,
FolderOutlined,
EnvironmentOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import styles from "./ChatWindow.module.scss";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { formatWechatTime } from "@/utils/common";
import ProfileCard from "./components/ProfileCard";
import MessageEnter from "./components/MessageEnter";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import MessageRecord from "./components/MessageRecord";
const { Header, Content } = Layout;
interface ChatWindowProps {
@@ -29,595 +22,30 @@ interface ChatWindowProps {
}
const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const currentMessages = useWeChatStore(state => state.currentMessages);
const [showProfile, setShowProfile] = useState(true);
const currentGroupMembers = useWeChatStore(
state => state.currentGroupMembers,
const onToggleProfile = () => {
setShowProfile(!showProfile);
};
const chatMenu = (
<Menu>
<Menu.Item key="profile" icon={<UserOutlined />}>
</Menu.Item>
<Menu.Item key="call" icon={<PhoneOutlined />}>
</Menu.Item>
<Menu.Item key="video" icon={<VideoCameraOutlined />}>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="pin"></Menu.Item>
<Menu.Item key="mute"></Menu.Item>
<Menu.Divider />
<Menu.Item key="clear" danger>
</Menu.Item>
</Menu>
);
const prevMessagesRef = useRef(currentMessages);
useEffect(() => {
const prevMessages = prevMessagesRef.current;
const hasVideoStateChange = currentMessages.some((msg, index) => {
// 首先检查消息对象本身是否为null或undefined
if (!msg || !msg.content) return false;
const prevMsg = prevMessages[index];
if (!prevMsg || !prevMsg.content || prevMsg.id !== msg.id) return false;
try {
const currentContent =
typeof msg.content === "string"
? JSON.parse(msg.content)
: msg.content;
const prevContent =
typeof prevMsg.content === "string"
? JSON.parse(prevMsg.content)
: prevMsg.content;
// 检查视频状态是否发生变化开始加载、完成加载、获得URL
const currentHasVideo =
currentContent.previewImage && currentContent.tencentUrl;
const prevHasVideo = prevContent.previewImage && prevContent.tencentUrl;
if (currentHasVideo && prevHasVideo) {
// 检查加载状态变化或视频URL变化
return (
currentContent.isLoading !== prevContent.isLoading ||
currentContent.videoUrl !== prevContent.videoUrl
);
}
return false;
} catch (e) {
return false;
}
});
// 只有在没有视频状态变化时才自动滚动到底部
if (!hasVideoStateChange) {
scrollToBottom();
}
// 更新上一次的消息状态
prevMessagesRef.current = currentMessages;
}, [currentMessages]);
const scrollToBottom = () => {
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,
});
};
// 解析消息内容,判断消息类型并返回对应的渲染内容
const parseMessageContent = (
content: string | null | undefined,
msg: ChatRecord,
) => {
// 处理null或undefined的内容
if (content === null || content === undefined) {
return <div className={styles.messageText}></div>;
}
// 检查是否为表情包
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);
// 处理视频消息格式 {"previewImage":"https://...", "tencentUrl":"...", "videoUrl":"...", "isLoading":true}
if (videoData.previewImage && videoData.tencentUrl) {
// 提取预览图URL去掉可能的引号
const previewImageUrl = videoData.previewImage.replace(/[`"']/g, "");
// 创建点击处理函数
const handlePlayClick = (e: React.MouseEvent) => {
e.stopPropagation();
// 如果没有视频URL且不在加载中则发起下载请求
if (!videoData.videoUrl && !videoData.isLoading) {
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
}
};
// 如果已有视频URL显示视频播放器
if (videoData.videoUrl) {
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>
);
}
// 显示预览图,根据加载状态显示不同的图标
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer} onClick={handlePlayClick}>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{
maxWidth: "100%",
borderRadius: "8px",
opacity: videoData.isLoading ? "0.7" : "1",
}}
/>
<div className={styles.videoPlayIcon}>
{videoData.isLoading ? (
<div className={styles.loadingSpinner}></div>
) : (
<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[]) => {
return messages
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
.map(msg => ({
time: formatWechatTime(msg?.wechatTime),
messages: [msg],
}));
};
const groupMemberAvatar = (msg: ChatRecord) => {
if (!msg?.sender) {
return undefined;
}
const groupMember = currentGroupMembers.find(
v => v?.wechatId === msg.sender.wechatId,
);
return groupMember?.avatar;
};
const clearWechatidInContent = (sender, content: string) => {
if (!sender || !sender.wechatId || !content) return content;
return content.replace(new RegExp(`${sender.wechatId}:\n`, "g"), "");
};
const renderMessage = (msg: ChatRecord) => {
console.log(msg);
// 添加null检查防止访问null对象的属性
if (!msg) return null;
const isOwn = msg?.isSend;
const isGroup = !!contract.chatroomId;
return (
<div
key={msg.id || `msg-${Date.now()}`}
className={`${styles.messageItem} ${
isOwn ? styles.ownMessage : styles.otherMessage
}`}
>
<div className={styles.messageContent}>
{/* 如果不是群聊 */}
{!isGroup && !isOwn && (
<>
<Avatar
size={32}
src={contract.avatar}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div className={styles.messageBubble}>
{!isOwn && (
<div className={styles.messageSender}>
{contract.nickname}
</div>
)}
{parseMessageContent(msg?.content, msg)}
</div>
</>
)}
{/* 如果是群聊 */}
{isGroup && !isOwn && (
<>
<Avatar
size={32}
src={groupMemberAvatar(msg)}
icon={<UserOutlined />}
className={styles.messageAvatar}
/>
<div className={styles.messageBubble}>
{!isOwn && (
<div className={styles.messageSender}>
{msg?.sender?.nickname}
</div>
)}
{parseMessageContent(
clearWechatidInContent(msg?.sender, msg?.content),
msg,
)}
</div>
</>
)}
{isOwn && (
<div className={styles.messageBubble}>
{parseMessageContent(msg?.content, msg)}
</div>
)}
</div>
</div>
);
};
return (
<Layout className={styles.chatWindow}>
@@ -640,30 +68,27 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
</div>
</div>
<Space>
<Tooltip title={showProfile ? "隐藏资料" : "显示资料"}>
<Tooltip title="个人资料">
<Button
type={showProfile ? "primary" : "default"}
onClick={onToggleProfile}
type="text"
icon={<InfoCircleOutlined />}
onClick={() => setShowProfile(!showProfile)}
size="small"
>
{showProfile ? "隐藏资料" : "显示资料"}
</Button>
className={styles.headerButton}
/>
</Tooltip>
<Dropdown overlay={chatMenu} trigger={["click"]}>
<Button
type="text"
icon={<MoreOutlined />}
className={styles.headerButton}
/>
</Dropdown>
</Space>
</Header>
{/* 聊天内容 */}
<Content className={styles.chatContent}>
<div className={styles.messagesContainer}>
{groupMessagesByTime(currentMessages).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>
<MessageRecord contract={contract} />
</Content>
{/* 消息输入组件 */}
@@ -674,7 +99,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
<ProfileCard
contract={contract}
showProfile={showProfile}
onToggleProfile={() => setShowProfile(!showProfile)}
onToggleProfile={onToggleProfile}
/>
</Layout>
);

View File

@@ -155,3 +155,86 @@ export function deepCopy<T>(obj: T): T {
return clonedObj;
}
/**
* 专门解析微信小程序消息格式外层JSON+内层XML的函数
* @param {string} inputStr - 输入的字符串外层为JSON含contentXml和type字段
* @returns {Object} 合并后的完整JSON对象外层字段 + 解析后的XML内容
* @throws {Error} 当输入格式错误、JSON解析失败或XML解析失败时抛出异常
*/
export function parseWeappMsgStr(inputStr: string): any {
try {
// 1. 解析外层JSON
const outerJson = JSON.parse(inputStr);
// 2. 检查必要字段
if (!outerJson.contentXml || outerJson.type !== "miniprogram") {
throw new Error("Invalid miniprogram message format");
}
// 3. 解析内层XML为JSON
const xmlContent = outerJson.contentXml;
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlContent, "text/xml");
// 检查XML解析是否成功
if (xmlDoc.getElementsByTagName("parsererror").length > 0) {
throw new Error("XML parsing failed");
}
// 4. 提取XML中的关键信息
const msgElement = xmlDoc.getElementsByTagName("msg")[0];
const appmsgElement = xmlDoc.getElementsByTagName("appmsg")[0];
const weappinfoElement = xmlDoc.getElementsByTagName("weappinfo")[0];
if (!msgElement || !appmsgElement) {
throw new Error("Invalid XML structure");
}
// 5. 构建appmsg对象
const appmsg: any = {
title: appmsgElement.getElementsByTagName("title")[0]?.textContent || "",
des: appmsgElement.getElementsByTagName("des")[0]?.textContent || "",
type: appmsgElement.getElementsByTagName("type")[0]?.textContent || "",
sourcedisplayname: appmsgElement.getElementsByTagName("sourcedisplayname")[0]?.textContent || "",
appname: appmsgElement.getElementsByTagName("appname")[0]?.textContent || ""
};
// 6. 处理weappinfo信息
if (weappinfoElement) {
appmsg.weappinfo = {
username: weappinfoElement.getElementsByTagName("username")[0]?.textContent || "",
appid: weappinfoElement.getElementsByTagName("appid")[0]?.textContent || "",
type: weappinfoElement.getElementsByTagName("type")[0]?.textContent || "",
version: weappinfoElement.getElementsByTagName("version")[0]?.textContent || "",
weappiconurl: weappinfoElement.getElementsByTagName("weappiconurl")[0]?.textContent || "",
pagepath: weappinfoElement.getElementsByTagName("pagepath")[0]?.textContent || ""
};
// 处理thumburl - 从weappiconurl中提取
const weappiconurl = appmsg.weappinfo.weappiconurl;
if (weappiconurl && weappiconurl.includes('http')) {
// 清理URL中的特殊字符和CDATA标记
appmsg.weappinfo.thumburl = weappiconurl
.replace(/<!\[CDATA\[|\]\]>/g, '')
.replace(/[`"']/g, '')
.replace(/&amp;/g, '&')
.trim();
} else {
appmsg.weappinfo.thumburl = "";
}
}
// 7. 合并结果
const result = {
...outerJson,
type: "miniprogram",
appmsg
};
return result;
} catch (error) {
console.error("parseWeappMsgStr error:", error);
throw new Error(`Failed to parse miniprogram message: ${error.message}`);
}
}