群功能处理

This commit is contained in:
wong
2025-09-08 17:24:44 +08:00
parent e960c0863f
commit cc180dcb65
4 changed files with 834 additions and 87 deletions

View File

@@ -1,10 +1,35 @@
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useMemo, memo } from 'react';
import { Modal, Input, Avatar, Button, Checkbox, message } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { getFriendList } from './api';
import type { FriendSelectionItem } from './data';
import styles from './TwoColumnSelection.module.scss';
// 使用 React.memo 优化好友列表项组件
const FriendListItem = memo<{
friend: FriendSelectionItem;
isSelected: boolean;
onSelect: (friend: FriendSelectionItem) => void;
}>(({ friend, isSelected, onSelect }) => {
return (
<div
className={`${styles.friendItem} ${isSelected ? styles.selected : ''}`}
onClick={() => onSelect(friend)}
>
<Checkbox checked={isSelected} />
<Avatar src={friend.avatar} size={40}>
{friend.nickname?.charAt(0)}
</Avatar>
<div className={styles.friendInfo}>
<div className={styles.friendName}>{friend.nickname}</div>
<div className={styles.friendId}>{friend.wechatId}</div>
</div>
</div>
);
});
FriendListItem.displayName = 'FriendListItem';
interface TwoColumnSelectionProps {
visible: boolean;
onCancel: () => void;
@@ -12,6 +37,7 @@ interface TwoColumnSelectionProps {
title?: string;
deviceIds?: number[];
enableDeviceFilter?: boolean;
dataSource?: FriendSelectionItem[];
}
const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
@@ -21,14 +47,50 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
title = '选择好友',
deviceIds = [],
enableDeviceFilter = true,
dataSource,
}) => {
const [friends, setFriends] = useState<FriendSelectionItem[]>([]);
const [rawFriends, setRawFriends] = useState<FriendSelectionItem[]>([]);
const [selectedFriends, setSelectedFriends] = useState<FriendSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
// 使用 useMemo 缓存过滤结果,避免每次渲染都重新计算
const filteredFriends = useMemo(() => {
const sourceData = dataSource || rawFriends;
if (!searchQuery.trim()) {
return sourceData;
}
const query = searchQuery.toLowerCase();
return sourceData.filter(item =>
item.name?.toLowerCase().includes(query) ||
item.nickname?.toLowerCase().includes(query)
);
}, [dataSource, rawFriends, searchQuery]);
// 分页显示好友列表,避免一次性渲染太多项目
const ITEMS_PER_PAGE = 50;
const [displayPage, setDisplayPage] = useState(1);
const friends = useMemo(() => {
const startIndex = 0;
const endIndex = displayPage * ITEMS_PER_PAGE;
return filteredFriends.slice(startIndex, endIndex);
}, [filteredFriends, displayPage]);
const hasMoreFriends = filteredFriends.length > friends.length;
// 使用 useMemo 缓存选中状态映射,避免每次渲染都重新计算
const selectedFriendsMap = useMemo(() => {
const map = new Map();
selectedFriends.forEach(friend => {
map.set(friend.id, true);
});
return map;
}, [selectedFriends]);
// 获取好友列表
const fetchFriends = useCallback(async (page: number, keyword: string = '') => {
setLoading(true);
@@ -37,21 +99,22 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
page,
pageSize: 20,
};
if (keyword) {
params.keyword = keyword;
}
if (enableDeviceFilter && deviceIds.length > 0) {
params.deviceIds = deviceIds;
}
const response = await getFriendList(params);
if (response.success) {
setFriends(response.data.list || []);
setRawFriends(response.data.list || []);
setTotalPages(Math.ceil((response.data.total || 0) / 20));
} else {
setRawFriends([]);
message.error(response.message || '获取好友列表失败');
}
} catch (error) {
@@ -62,57 +125,100 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
}
}, [deviceIds, enableDeviceFilter]);
// 初始化和搜索
// 初始化数据加载
useEffect(() => {
if (visible) {
fetchFriends(1, searchQuery);
if (visible && !dataSource) {
// 只有在没有外部数据源时才调用 API
fetchFriends(1);
setCurrentPage(1);
}
}, [visible, searchQuery, fetchFriends]);
}, [visible, dataSource, fetchFriends]);
// 处理搜索
const handleSearch = (value: string) => {
setSearchQuery(value);
};
// 选择好友
const handleSelectFriend = (friend: FriendSelectionItem) => {
const isSelected = selectedFriends.some(f => f.id === friend.id);
if (isSelected) {
setSelectedFriends(selectedFriends.filter(f => f.id !== friend.id));
} else {
setSelectedFriends([...selectedFriends, friend]);
// 重置搜索状态
useEffect(() => {
if (visible) {
setSearchQuery('');
setSelectedFriends([]);
setLoading(false);
}
};
}, [visible]);
// 移除已选好友
const handleRemoveFriend = (friendId: number) => {
setSelectedFriends(selectedFriends.filter(f => f.id !== friendId));
};
// 防抖搜索处理
const handleSearch = useCallback(() => {
let timeoutId: NodeJS.Timeout;
return (value: string) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setDisplayPage(1); // 重置分页
if (!dataSource) {
fetchFriends(1, value);
}
}, 300);
};
}, [dataSource, fetchFriends])();
// 确认选择
const handleConfirmSelection = () => {
// API搜索处理当没有外部数据源时
const handleApiSearch = useCallback(async (keyword: string) => {
if (!dataSource) {
await fetchFriends(1, keyword);
}
}, [dataSource, fetchFriends]);
// 加载更多好友
const handleLoadMore = useCallback(() => {
setDisplayPage(prev => prev + 1);
}, []);
// 防抖搜索
useEffect(() => {
if (!dataSource && searchQuery.trim()) {
const timer = setTimeout(() => {
handleApiSearch(searchQuery);
}, 300);
return () => clearTimeout(timer);
}
}, [searchQuery, dataSource, handleApiSearch]);
// 选择好友 - 使用 useCallback 优化性能
const handleSelectFriend = useCallback((friend: FriendSelectionItem) => {
setSelectedFriends(prev => {
const isSelected = prev.some(f => f.id === friend.id);
if (isSelected) {
return prev.filter(f => f.id !== friend.id);
} else {
return [...prev, friend];
}
});
}, []);
// 移除已选好友 - 使用 useCallback 优化性能
const handleRemoveFriend = useCallback((friendId: number) => {
setSelectedFriends(prev => prev.filter(f => f.id !== friendId));
}, []);
// 确认选择 - 使用 useCallback 优化性能
const handleConfirmSelection = useCallback(() => {
const selectedIds = selectedFriends.map(f => f.id.toString());
onConfirm(selectedIds, selectedFriends);
setSelectedFriends([]);
setSearchQuery('');
};
}, [selectedFriends, onConfirm]);
// 取消选择
const handleCancelSelection = () => {
// 取消选择 - 使用 useCallback 优化性能
const handleCancel = useCallback(() => {
setSelectedFriends([]);
setSearchQuery('');
onCancel();
};
}, [onCancel]);
return (
<Modal
title={title}
open={visible}
onCancel={handleCancelSelection}
onCancel={handleCancel}
width={800}
footer={[
<Button key="cancel" onClick={handleCancelSelection}>
<Button key="cancel" onClick={handleCancel}>
</Button>,
<Button key="confirm" type="primary" onClick={handleConfirmSelection}>
@@ -126,35 +232,32 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
<div className={styles.leftColumn}>
<div className={styles.searchWrapper}>
<Input
placeholder="请输入昵称或微信号"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
prefix={<SearchOutlined />}
allowClear
placeholder="请输入昵称或微信号"
value={searchQuery}
onChange={(e) => {
const value = e.target.value;
setSearchQuery(value); // 立即更新显示
handleSearch(value); // 防抖处理搜索
}}
prefix={<SearchOutlined />}
allowClear
/>
</div>
<div className={styles.friendList}>
{loading ? (
<div className={styles.loading}>...</div>
) : friends.length > 0 ? (
// 使用 React.memo 优化列表项渲染
friends.map(friend => {
const isSelected = selectedFriends.some(f => f.id === friend.id);
const isSelected = selectedFriendsMap.has(friend.id);
return (
<div
<FriendListItem
key={friend.id}
className={`${styles.friendItem} ${isSelected ? styles.selected : ''}`}
onClick={() => handleSelectFriend(friend)}
>
<Checkbox checked={isSelected} />
<Avatar src={friend.avatar} size={40}>
{friend.nickname?.charAt(0)}
</Avatar>
<div className={styles.friendInfo}>
<div className={styles.friendName}>{friend.nickname}</div>
<div className={styles.friendId}>{friend.wechatId}</div>
</div>
</div>
friend={friend}
isSelected={isSelected}
onSelect={handleSelectFriend}
/>
);
})
) : (
@@ -162,6 +265,18 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
{searchQuery ? `没有找到包含"${searchQuery}"的好友` : '暂无好友'}
</div>
)}
{hasMoreFriends && (
<div className={styles.loadMoreWrapper}>
<Button
type="link"
onClick={handleLoadMore}
loading={loading}
>
</Button>
</div>
)}
</div>
</div>
@@ -170,7 +285,7 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
<div className={styles.selectedHeader}>
({selectedFriends.length})
</div>
<div className={styles.selectedList}>
{selectedFriends.length > 0 ? (
selectedFriends.map(friend => (
@@ -203,4 +318,4 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
);
};
export default TwoColumnSelection;
export default TwoColumnSelection;

View File

@@ -82,7 +82,6 @@ const Person: React.FC<PersonProps> = ({
);
const [hoveredMember, setHoveredMember] = useState<string | null>(null);
const [isAddFriendModalVisible, setIsAddFriendModalVisible] = useState(false);
const [selectedMember, setSelectedMember] = useState(null);
@@ -95,9 +94,9 @@ const Person: React.FC<PersonProps> = ({
const [isRemoveAdminSelectionVisible, setIsRemoveAdminSelectionVisible] = useState(false);
const [isTransferOwnerSelectionVisible, setIsTransferOwnerSelectionVisible] = useState(false);
const [selectedFriends, setSelectedFriends] = useState<FriendSelectionItem[]>([]);
const [contractList, setContractList] = useState<any[]>([]);
const handleAddFriend = (member) => {
console.log(selectedFriends);
setSelectedMember(member);
setGreeting(`你好, 我来自群聊${contractInfo.name}`);
setIsAddFriendModalVisible(true);
@@ -106,17 +105,15 @@ const Person: React.FC<PersonProps> = ({
// 群管理操作处理函数
const handleAddMember = (selectedIds: number[], selectedItems: FriendSelectionItem[]) => {
console.log('添加成员:', selectedIds, selectedItems);
sendCommand("CmdchatroomInvite", {
sendCommand("CmdChatroomInvite", {
wechatChatroomId: contract.id,
extra: JSON.stringify({
selectedIds
})
wechatFriendIds: selectedIds
});
messageApi.success(`已添加 ${selectedItems.length} 个成员`);
setIsFriendSelectionVisible(false);
};
//删除群成员
//删除群成员
const handleRemoveMember = (selectedIds: string[]) => {
console.log('删除成员:', selectedIds);
sendCommand("CmdChatroomOperate", {
@@ -203,9 +200,14 @@ const Person: React.FC<PersonProps> = ({
// 构建联系人或群聊详细信息
const kfSelectedUser = useCkChatStore(state =>
state.getKfUserInfo(contract.wechatAccountId || 0),
const kfSelectedUser = useCkChatStore(
state => state.getKfUserInfo(contract.wechatAccountId || 0),
);
const getSomeContractList = useCkChatStore(
state => state.getSomeContractList,
);
const { sendCommand } = useWebSocketStore();
// 权限控制:检查当前客服是否有群管理权限
@@ -875,7 +877,25 @@ const Person: React.FC<PersonProps> = ({
}}>
<Button
icon={<PlusOutlined />}
onClick={() => setIsFriendSelectionVisible(true)}
onClick={async () => {
try {
const contractData = await getSomeContractList(contract.wechatAccountId);
// 转换 ContractData[] 为 FriendSelectionItem[]
const friendSelectionData = (contractData || []).map(item => ({
id: item.id || item.serverId,
wechatId: item.wechatId,
nickname: item.nickname,
avatar: item.avatar || '',
conRemark: item.conRemark,
name: item.conRemark || item.nickname, // 用于搜索显示
}));
setContractList(friendSelectionData);
setIsFriendSelectionVisible(true);
} catch (error) {
console.error('获取联系人列表失败:', error);
messageApi.error('获取联系人列表失败');
}
}}
size="small"
type="primary"
ghost
@@ -1065,6 +1085,7 @@ const Person: React.FC<PersonProps> = ({
setSelectedFriends(selectedItems);
handleAddMember(selectedIds.map(id => parseInt(id)), selectedItems);
}}
dataSource={contractList}
title="添加群成员"
/>

View File

@@ -1,31 +1,630 @@
import React, { useState } from "react";
import React, { useEffect, useRef } from "react";
import { Layout, Button, Avatar, Space, Dropdown, Menu, Tooltip } from "antd";
import {
PhoneOutlined,
VideoCameraOutlined,
MoreOutlined,
UserOutlined,
DownloadOutlined,
FileOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
PlayCircleFilled,
TeamOutlined,
InfoCircleOutlined,
FolderOutlined,
EnvironmentOutlined,
} from "@ant-design/icons";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { ChatRecord, 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 MessageRecord from "./components/MessageRecord";
import { useWeChatStore } from "@/store/module/weChat/weChat";
const { Header, Content } = Layout;
interface ChatWindowProps {
contract: ContractData | weChatGroup;
showProfile?: boolean;
onToggleProfile?: () => void;
}
const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
const [showProfile, setShowProfile] = useState(true);
const onToggleProfile = () => {
setShowProfile(!showProfile);
const ChatWindow: React.FC<ChatWindowProps> = ({
contract,
showProfile = true,
onToggleProfile,
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const currentMessages = useWeChatStore(state => state.currentMessages);
const currentGroupMembers = useWeChatStore(
state => state.currentGroupMembers,
);
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>
);
};
const chatMenu = (
<Menu>
<Menu.Item key="profile" icon={<UserOutlined />}>
@@ -68,11 +667,17 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
</div>
</div>
<Space>
<Tooltip title="个人资料">
<Tooltip title="语音通话">
<Button
onClick={onToggleProfile}
type="text"
icon={<InfoCircleOutlined />}
icon={<PhoneOutlined />}
className={styles.headerButton}
/>
</Tooltip>
<Tooltip title="视频通话">
<Button
type="text"
icon={<VideoCameraOutlined />}
className={styles.headerButton}
/>
</Tooltip>
@@ -88,7 +693,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
{/* 聊天内容 */}
<Content className={styles.chatContent}>
<MessageRecord contract={contract} />
<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>
</Content>
{/* 消息输入组件 */}