feat(聊天窗口): 添加表情选择器组件并集成到消息输入
refactor(聊天窗口): 重构聊天窗口组件结构,提取消息记录为独立组件 feat(消息解析): 添加微信小程序消息解析功能 style(表情选择器): 添加表情选择器样式文件 chore: 添加xmldom依赖用于XML解析
This commit is contained in:
@@ -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": {
|
||||
|
||||
9
Cunkebao/pnpm-lock.yaml
generated
9
Cunkebao/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
168
Cunkebao/src/components/EmojiSeclection/EmojiPicker.css
Normal file
168
Cunkebao/src/components/EmojiSeclection/EmojiPicker.css
Normal 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;
|
||||
}
|
||||
}
|
||||
120
Cunkebao/src/components/EmojiSeclection/EmojiPicker.tsx
Normal file
120
Cunkebao/src/components/EmojiSeclection/EmojiPicker.tsx
Normal 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;
|
||||
127
Cunkebao/src/components/EmojiSeclection/EmojiPickerDemo.tsx
Normal file
127
Cunkebao/src/components/EmojiSeclection/EmojiPickerDemo.tsx
Normal 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>点击表情即可选择并“发送”</li>
|
||||
<li>支持键盘和鼠标操作</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPickerDemo;
|
||||
18
Cunkebao/src/components/EmojiSeclection/index.ts
Normal file
18
Cunkebao/src/components/EmojiSeclection/index.ts
Normal 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";
|
||||
@@ -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"
|
||||
|
||||
@@ -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(/&/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>/,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(/&/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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user