群功能处理
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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="添加群成员"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 消息输入组件 */}
|
||||
|
||||
Reference in New Issue
Block a user