Merge branch 'yongpxu-dev' into develop

# Conflicts:
#	Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/ProfileCard/index.tsx   resolved by develop version
This commit is contained in:
超级老白兔
2025-09-04 10:52:55 +08:00
25 changed files with 1158 additions and 867 deletions

View File

@@ -22,8 +22,13 @@ export function WechatGroup(params) {
export function clearUnreadCount(params) {
return request("/api/WechatFriend/clearUnreadCount", params, "PUT");
}
//更新配置
export function updateConfig(params) {
return request("/api/WechatFriend/updateConfig", params, "PUT");
}
//获取聊天记录-2 获取列表
export function getMessages(params: {
export function getChatMessages(params: {
wechatAccountId: number;
wechatFriendId?: number;
wechatChatroomId?: number;
@@ -73,19 +78,6 @@ export const getControlTerminalList = params => {
return request("/api/wechataccount", params, "GET");
};
// 搜索联系人
export const getChatMessage = (params: {
wechatAccountId: number;
wechatFriendId: number;
From: number;
To: number;
Count: number;
olderData: boolean;
keyword: string;
}) => {
return request("/api/FriendMessage/SearchMessage", params, "GET");
};
// 获取聊天历史
export const getChatHistory = (
chatId: string,

View File

@@ -128,128 +128,6 @@
}
}
.chatFooter {
background: #fff;
border-top: 1px solid #f0f0f0;
padding: 0;
height: auto;
min-height: auto;
flex-shrink: 0;
.inputContainer {
.inputToolbar {
display: flex;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
justify-content: space-between;
.leftTool {
display: flex;
gap: 4px;
}
.rightTool {
display: flex;
gap: 8px;
padding: 8px;
}
.toolbarButton {
color: #666;
border: none;
padding: 8px;
border-radius: 4px;
font-size: 18px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
transition: all 0.2s;
&:hover {
color: #1890ff;
background: #e6f7ff;
}
&:active {
background: #bae7ff;
}
}
}
.inputArea {
display: flex;
padding: 12px 16px;
gap: 8px;
align-items: flex-end;
background: #fff;
.messageInput {
flex: 1;
border: 1px solid #d9d9d9;
border-radius: 4px;
resize: none;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
min-height: 36px;
max-height: 120px;
background: #fff;
transition: all 0.2s;
&:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
&::placeholder {
color: #bfbfbf;
}
}
.sendButton {
border-radius: 4px;
height: 36px;
padding: 0 16px;
font-size: 14px;
font-weight: 500;
background: #1890ff;
border: 1px solid #1890ff;
color: #fff;
transition: all 0.2s;
&:hover {
background: #40a9ff;
border-color: #40a9ff;
}
&:active {
background: #096dd9;
border-color: #096dd9;
}
&:disabled {
background: #f5f5f5;
border-color: #d9d9d9;
color: #bfbfbf;
cursor: not-allowed;
}
}
}
.inputHint {
padding: 4px 16px 8px;
font-size: 12px;
color: #8c8c8c;
background: #fff;
border-top: 1px solid #f0f0f0;
}
}
}
// 右侧个人资料卡片
.profileSider {
background: #fff;
@@ -748,19 +626,6 @@
}
}
.chatFooter {
padding: 12px;
.inputContainer {
.inputArea {
.sendButton {
height: 28px;
padding: 0 12px;
}
}
}
}
.messageItem {
.messageContent {
max-width: 85%;

View File

@@ -0,0 +1,181 @@
// MessageEnter 组件样式 - 微信风格
.chatFooter {
background: #f7f7f7;
border-top: 1px solid #e1e1e1;
padding: 0;
height: auto;
min-height: 100px;
}
.inputContainer {
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.inputToolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: none;
}
.leftTool {
display: flex;
gap: 2px;
align-items: center;
}
.toolbarButton {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #666;
font-size: 16px;
transition: all 0.15s;
border: none;
background: transparent;
&:hover {
background: #e6e6e6;
color: #333;
}
&:active {
background: #d9d9d9;
}
}
.rightTool {
display: flex;
gap: 12px;
align-items: center;
}
.rightToolItem {
display: flex;
align-items: center;
gap: 3px;
color: #666;
font-size: 11px;
cursor: pointer;
padding: 3px 6px;
border-radius: 3px;
transition: all 0.15s;
&:hover {
background: #e6e6e6;
color: #333;
}
}
.inputArea {
display: flex;
flex-direction: column;
padding: 4px 0;
}
.inputWrapper {
border: 1px solid #d1d1d1;
border-radius: 4px;
background: #fff;
overflow: hidden;
&:focus-within {
border-color: #07c160;
}
}
.messageInput {
width: 100%;
border: none;
resize: none;
font-size: 13px;
line-height: 1.4;
padding: 8px 10px;
background: transparent;
&:focus {
box-shadow: none;
outline: none;
}
&::placeholder {
color: #b3b3b3;
}
}
.sendButtonArea {
padding: 8px 10px;
display: flex;
justify-content: flex-end;
}
.sendButton {
height: 32px;
border-radius: 4px;
font-weight: normal;
min-width: 60px;
font-size: 13px;
background: #07c160;
border-color: #07c160;
&:hover {
background: #06ad56;
border-color: #06ad56;
}
&:active {
background: #059748;
border-color: #059748;
}
&:disabled {
background: #b3b3b3;
border-color: #b3b3b3;
opacity: 1;
}
}
.inputHint {
font-size: 11px;
color: #999;
text-align: right;
margin-top: 2px;
}
// 响应式设计
@media (max-width: 768px) {
.inputContainer {
padding: 8px 12px;
}
.inputToolbar {
flex-wrap: wrap;
gap: 8px;
}
.rightTool {
gap: 8px;
}
.rightToolItem {
font-size: 11px;
padding: 2px 6px;
}
.inputArea {
flex-direction: column;
gap: 8px;
}
.sendButton {
align-self: flex-end;
min-width: 60px;
}
}

View File

@@ -0,0 +1,301 @@
import React, { useState } from "react";
import { Layout, Input, Button, Dropdown, Menu, Tooltip, Modal } from "antd";
import {
ShareAltOutlined,
SendOutlined,
SmileOutlined,
FolderOutlined,
AudioOutlined,
AudioOutlined as AudioHoldOutlined,
CodeSandboxOutlined,
MessageOutlined,
EnvironmentOutlined,
StarOutlined,
} from "@ant-design/icons";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import styles from "./MessageEnter.module.scss";
const { Footer } = Layout;
const { TextArea } = Input;
interface MessageEnterProps {
contract: ContractData | weChatGroup;
}
const { sendCommand } = useWebSocketStore.getState();
const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const [inputValue, setInputValue] = useState("");
const [showMaterialModal, setShowMaterialModal] = useState(false);
const handleSend = async () => {
if (!inputValue.trim()) return;
console.log("发送消息", contract);
const params = {
wechatAccountId: contract.wechatAccountId,
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
msgSubType: 0,
msgType: 1,
content: inputValue,
};
sendCommand("CmdSendMessage", params);
setInputValue("");
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
handleSend();
}
// Ctrl+Enter 换行由 TextArea 自动处理,不需要阻止默认行为
};
// 素材菜单项
const materialMenuItems = [
{
key: "text",
label: "文字素材",
icon: <span>📝</span>,
},
{
key: "audio",
label: "语音素材",
icon: <span>🎵</span>,
},
{
key: "image",
label: "图片素材",
icon: <span>🖼</span>,
},
{
key: "video",
label: "视频素材",
icon: <span>🎬</span>,
},
{
key: "link",
label: "链接素材",
icon: <span>🔗</span>,
},
{
key: "card",
label: "名片素材",
icon: <span>📇</span>,
},
];
const handleMaterialSelect = (key: string) => {
console.log("选择素材类型:", key);
setShowMaterialModal(true);
// 这里可以根据不同的素材类型显示不同的模态框
};
return (
<>
{/* 聊天输入 */}
<Footer className={styles.chatFooter}>
<div className={styles.inputContainer}>
<div className={styles.inputToolbar}>
<div className={styles.leftTool}>
<Tooltip title="表情">
<Button
type="text"
icon={<SmileOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="上传附件">
<Button
type="text"
icon={<FolderOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="收藏">
<Button
type="text"
icon={<StarOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="位置">
<Button
type="text"
icon={<EnvironmentOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="语音">
<Button
type="text"
icon={<AudioOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="按住说话">
<Button
type="text"
icon={<AudioHoldOutlined />}
className={styles.toolbarButton}
style={{ position: "relative" }}
>
<span
style={{
position: "absolute",
top: "2px",
right: "2px",
fontSize: "8px",
color: "#52c41a",
fontWeight: "bold",
}}
>
H
</span>
</Button>
</Tooltip>
<Dropdown
overlay={
<Menu
items={materialMenuItems}
onClick={({ key }) => handleMaterialSelect(key)}
style={{
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
}}
/>
}
trigger={["click"]}
placement="topLeft"
>
<Button
type="text"
icon={<CodeSandboxOutlined />}
className={styles.toolbarButton}
/>
</Dropdown>
</div>
<div className={styles.rightTool}>
<div className={styles.rightToolItem}>
<ShareAltOutlined />
</div>
<div className={styles.rightToolItem}>
<MessageOutlined />
</div>
</div>
</div>
<div className={styles.inputArea}>
<div className={styles.inputWrapper}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="输入消息..."
className={styles.messageInput}
autoSize={{ minRows: 2, maxRows: 6 }}
/>
<div className={styles.sendButtonArea}>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
disabled={!inputValue.trim()}
className={styles.sendButton}
>
</Button>
</div>
</div>
</div>
<div className={styles.inputHint}>Ctrl+Enter换行Enter发送</div>
</div>
</Footer>
{/* 素材选择模态框 */}
<Modal
title="选择素材"
open={showMaterialModal}
onCancel={() => setShowMaterialModal(false)}
footer={[
<Button key="cancel" onClick={() => setShowMaterialModal(false)}>
</Button>,
<Button
key="confirm"
type="primary"
onClick={() => setShowMaterialModal(false)}
>
</Button>,
]}
width={800}
>
<div style={{ display: "flex", height: "400px" }}>
{/* 左侧素材分类 */}
<div
style={{
width: "200px",
background: "#f5f5f5",
borderRight: "1px solid #e8e8e8",
}}
>
<div style={{ padding: "16px", borderBottom: "1px solid #e8e8e8" }}>
<h4 style={{ margin: 0, color: "#262626" }}></h4>
</div>
<div style={{ padding: "8px 0" }}>
<div
style={{
padding: "8px 16px",
cursor: "pointer",
background: "#e6f7ff",
borderLeft: "3px solid #1890ff",
color: "#1890ff",
}}
>
4
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
...
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
D2辅助
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
ROS反馈演示...
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
...
</div>
</div>
<div style={{ padding: "16px", borderTop: "1px solid #e8e8e8" }}>
<h4 style={{ margin: 0, color: "#262626" }}></h4>
</div>
</div>
{/* 右侧内容区域 */}
<div style={{ flex: 1, padding: "16px" }}>
<div style={{ marginBottom: "16px" }}>
<Input.Search placeholder="昵称" style={{ width: "100%" }} />
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "300px",
color: "#8c8c8c",
}}
>
</div>
</div>
</div>
</Modal>
</>
);
};
export default MessageEnter;

View File

@@ -1,312 +1,112 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useEffect, useRef } from "react";
import { Layout, Button, Avatar, Space, Dropdown, Menu, Tooltip } from "antd";
import {
Layout,
Input,
Button,
Avatar,
Space,
Dropdown,
Menu,
message,
Tooltip,
Modal,
} from "antd";
import {
ShareAltOutlined,
SendOutlined,
SmileOutlined,
FolderOutlined,
PhoneOutlined,
VideoCameraOutlined,
MoreOutlined,
UserOutlined,
AudioOutlined,
AudioOutlined as AudioHoldOutlined,
DownloadOutlined,
CodeSandboxOutlined,
MessageOutlined,
FileOutlined,
FilePdfOutlined,
FileWordOutlined,
FileExcelOutlined,
FilePptOutlined,
PlayCircleFilled,
EnvironmentOutlined,
TeamOutlined,
StarOutlined,
FolderOutlined,
EnvironmentOutlined,
} from "@ant-design/icons";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { getMessages } from "@/pages/pc/ckbox/api";
import styles from "./ChatWindow.module.scss";
import {
useWebSocketStore,
WebSocketMessage,
} from "@/store/module/websocket/websocket";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { formatWechatTime } from "@/utils/common";
import Person from "./components/Person";
const { Header, Content, Footer } = Layout;
const { TextArea } = Input;
import ProfileCard from "./components/ProfileCard";
import MessageEnter from "./components/MessageEnter";
import { useWeChatStore } from "@/store/module/weChat/weChat";
const { Header, Content } = Layout;
interface ChatWindowProps {
contract: ContractData | weChatGroup;
onSendMessage: (message: string) => void;
showProfile?: boolean;
onToggleProfile?: () => void;
}
const ChatWindow: React.FC<ChatWindowProps> = ({
contract,
onSendMessage,
showProfile = true,
onToggleProfile,
}) => {
const [messageApi, contextHolder] = message.useMessage();
const [messages, setMessages] = useState<ChatRecord[]>([]);
const [inputValue, setInputValue] = useState("");
const [loading, setLoading] = useState(false);
const [showMaterialModal, setShowMaterialModal] = useState(false);
const [pendingVideoRequests, setPendingVideoRequests] = useState<
Record<string, string>
>({});
const messagesEndRef = useRef<HTMLDivElement>(null);
const currentMessages = useWeChatStore(state => state.currentMessages);
const prevMessagesRef = useRef(currentMessages);
useEffect(() => {
setLoading(true);
const params: any = {
wechatAccountId: contract.wechatAccountId,
From: 1,
To: +new Date() + 1000,
Count: 100,
olderData: true,
};
if (contract.groupId == 1) {
params.wechatFriendId = contract.id;
} else {
params.wechatChatroomId = contract.id;
}
getMessages(params)
.then(msg => {
setMessages(msg);
})
.finally(() => {
setLoading(false);
});
}, [contract.id]);
const prevMessages = prevMessagesRef.current;
// 检查是否有视频状态变化(从加载中变为已完成或开始加载)
console.log("currentMessages", currentMessages);
const hasVideoStateChange = currentMessages.some((msg, index) => {
const prevMsg = prevMessages[index];
if (!prevMsg || prevMsg.id !== msg.id) return false;
useEffect(() => {
// 只有在非视频加载操作时才自动滚动到底部
// 检查是否有视频正在加载中
const hasLoadingVideo = messages.some(msg => {
try {
const content =
const currentContent =
typeof msg.content === "string"
? JSON.parse(msg.content)
: msg.content;
return content.isLoading === true;
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 (!hasLoadingVideo) {
// 只有在没有视频状态变化时才自动滚动到底部
if (!hasVideoStateChange) {
scrollToBottom();
}
}, [messages]);
// 添加 WebSocket 消息订阅 - 监听视频下载响应消息
useEffect(() => {
// 只有当有待处理的视频请求时才订阅WebSocket消息
if (Object.keys(pendingVideoRequests).length === 0) {
return;
}
console.log("开始监听视频下载响应,当前待处理请求:", pendingVideoRequests);
// 订阅 WebSocket 消息变化
const unsubscribe = useWebSocketStore.subscribe(state => {
// 只处理新增的消息
const messages = state.messages as WebSocketMessage[];
// 筛选出视频下载响应消息
messages.forEach(message => {
if (message?.content?.cmdType === "CmdDownloadVideoResult") {
console.log("收到视频下载响应:", message.content);
// 检查是否是我们正在等待的视频响应
const messageId = Object.keys(pendingVideoRequests).find(
id => pendingVideoRequests[id] === message.content.friendMessageId,
);
if (messageId) {
console.log("找到对应的消息ID:", messageId);
// 从待处理队列中移除
setPendingVideoRequests(prev => {
const newRequests = { ...prev };
delete newRequests[messageId];
return newRequests;
});
// 更新消息内容将视频URL添加到对应的消息中
setMessages(prevMessages => {
return prevMessages.map(msg => {
if (msg.id === Number(messageId)) {
try {
const msgContent =
typeof msg.content === "string"
? JSON.parse(msg.content)
: msg.content;
// 更新消息内容添加视频URL并移除加载状态
return {
...msg,
content: JSON.stringify({
...msgContent,
videoUrl: message.content.url,
isLoading: false,
}),
};
} catch (e) {
console.error("解析消息内容失败:", e);
}
}
return msg;
});
});
}
}
});
});
// 组件卸载时取消订阅
return () => {
unsubscribe();
};
}, [pendingVideoRequests]); // 依赖于pendingVideoRequests当队列变化时重新设置订阅
// 更新上一次的消息状态
prevMessagesRef.current = currentMessages;
}, [currentMessages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const handleSend = async () => {
if (!inputValue.trim()) return;
try {
const newMessage: ChatRecord = {
id: contract.id,
wechatAccountId: contract.wechatAccountId,
wechatFriendId: contract.id,
tenantId: 0,
accountId: 0,
synergyAccountId: 0,
content: inputValue,
msgType: 0,
msgSubType: 0,
msgSvrId: "",
isSend: false,
createTime: "",
isDeleted: false,
deleteTime: "",
sendStatus: 0,
wechatTime: 0,
origin: 0,
msgId: 0,
recalled: false,
};
setMessages(prev => [...prev, newMessage]);
onSendMessage(inputValue);
setInputValue("");
} catch (error) {
messageApi.error("发送失败");
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// 素材菜单项
const materialMenuItems = [
{
key: "text",
label: "文字素材",
icon: <span>📝</span>,
},
{
key: "audio",
label: "语音素材",
icon: <span>🎵</span>,
},
{
key: "image",
label: "图片素材",
icon: <span>🖼</span>,
},
{
key: "video",
label: "视频素材",
icon: <span>🎬</span>,
},
{
key: "link",
label: "链接素材",
icon: <span>🔗</span>,
},
{
key: "card",
label: "名片素材",
icon: <span>📇</span>,
},
];
const handleMaterialSelect = (key: string) => {
console.log("选择素材类型:", key);
setShowMaterialModal(true);
// 这里可以根据不同的素材类型显示不同的模态框
};
// 处理视频播放请求发送socket请求获取真实视频地址
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
// 生成请求ID (使用当前时间戳作为唯一标识)
const requestSeq = `${+new Date()}`;
console.log("发送视频下载请求:", { messageId, requestSeq });
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: requestSeq, // 使用唯一的请求ID
seq: `${+new Date()}`, // 使用时间戳作为请求序列号
tencentUrl: tencentUrl,
wechatAccountId: contract.wechatAccountId,
});
// 将消息ID和请求序列号添加到待处理队列
setPendingVideoRequests(prev => ({
...prev,
[messageId]: messageId,
}));
// 更新消息状态为加载中
setMessages(prevMessages => {
return prevMessages.map(msg => {
if (msg.id === messageId) {
// 保存原始内容添加loading状态
const originalContent = msg.content;
return {
...msg,
content: JSON.stringify({
...JSON.parse(originalContent),
isLoading: true,
}),
};
}
return msg;
});
});
};
// 解析消息内容,判断消息类型并返回对应的渲染内容
@@ -337,21 +137,22 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
content.trim().endsWith("}")
) {
const videoData = JSON.parse(content);
// 处理用户提供的JSON格式 {"previewImage":"https://...", "tencentUrl":"..."}
// 处理视频消息格式 {"previewImage":"https://...", "tencentUrl":"...", "videoUrl":"...", "isLoading":true}
if (videoData.previewImage && videoData.tencentUrl) {
// 提取预览图URL去掉可能的引号
const previewImageUrl = videoData.previewImage.replace(/[`"']/g, "");
// 创建点击处理函数调用handleVideoPlayRequest发送socket请求获取真实视频地址
// 创建点击处理函数
const handlePlayClick = (e: React.MouseEvent) => {
e.stopPropagation();
// 调用处理函数传入tencentUrl和消息ID
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
// 如果没有视频URL且不在加载中则发起下载请求
if (!videoData.videoUrl && !videoData.isLoading) {
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
}
};
// 检查是否已下载视频URL
// 如果已有视频URL显示视频播放器
if (videoData.videoUrl) {
// 已获取到视频URL显示视频播放器
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
@@ -374,30 +175,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
);
}
// 检查是否处于加载状态
if (videoData.isLoading) {
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<img
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{
maxWidth: "100%",
borderRadius: "8px",
opacity: "0.7",
}}
/>
<div className={styles.videoPlayIcon}>
<div className={styles.loadingSpinner}></div>
</div>
</div>
</div>
);
}
// 默认显示预览图和播放按钮
// 显示预览图,根据加载状态显示不同的图标
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer} onClick={handlePlayClick}>
@@ -405,12 +183,20 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
src={previewImageUrl}
alt="视频预览"
className={styles.videoThumbnail}
style={{ maxWidth: "100%", borderRadius: "8px" }}
style={{
maxWidth: "100%",
borderRadius: "8px",
opacity: videoData.isLoading ? "0.7" : "1",
}}
/>
<div className={styles.videoPlayIcon}>
<PlayCircleFilled
style={{ fontSize: "48px", color: "#fff" }}
/>
{videoData.isLoading ? (
<div className={styles.loadingSpinner}></div>
) : (
<PlayCircleFilled
style={{ fontSize: "48px", color: "#fff" }}
/>
)}
</div>
</div>
</div>
@@ -740,14 +526,10 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
// 用于分组消息并添加时间戳的辅助函数
const groupMessagesByTime = (messages: ChatRecord[]) => {
const groups: { time: string; messages: ChatRecord[] }[] = [];
messages.forEach(msg => {
// 使用 formatWechatTime 函数格式化时间戳
const formattedTime = formatWechatTime(msg.wechatTime);
groups.push({ time: formattedTime, messages: [msg] });
});
return groups;
return messages.map(msg => ({
time: formatWechatTime(msg?.wechatTime),
messages: [msg],
}));
};
const renderMessage = (msg: ChatRecord) => {
@@ -802,7 +584,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
return (
<Layout className={styles.chatWindow}>
{contextHolder}
{/* 聊天主体区域 */}
<Layout className={styles.chatMain}>
{/* 聊天头部 */}
@@ -849,228 +630,26 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{/* 聊天内容 */}
<Content className={styles.chatContent}>
<div className={styles.messagesContainer}>
{loading ? (
<div className={styles.loadingContainer}>
<div>...</div>
</div>
) : (
<>
{groupMessagesByTime(messages).map((group, groupIndex) => (
<React.Fragment key={`group-${groupIndex}`}>
<div className={styles.messageTime}>{group.time}</div>
{group.messages.map(renderMessage)}
</React.Fragment>
))}
<div ref={messagesEndRef} />
</>
)}
{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>
{/* 聊天输入 */}
<Footer className={styles.chatFooter}>
<div className={styles.inputContainer}>
<div className={styles.inputToolbar}>
<div className={styles.leftTool}>
<Tooltip title="表情">
<Button
type="text"
icon={<SmileOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="上传附件">
<Button
type="text"
icon={<FolderOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="收藏">
<Button
type="text"
icon={<StarOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="位置">
<Button
type="text"
icon={<EnvironmentOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="语音">
<Button
type="text"
icon={<AudioOutlined />}
className={styles.toolbarButton}
/>
</Tooltip>
<Tooltip title="按住说话">
<Button
type="text"
icon={<AudioHoldOutlined />}
className={styles.toolbarButton}
style={{ position: "relative" }}
>
<span
style={{
position: "absolute",
top: "2px",
right: "2px",
fontSize: "8px",
color: "#52c41a",
fontWeight: "bold",
}}
>
H
</span>
</Button>
</Tooltip>
<Dropdown
overlay={
<Menu
items={materialMenuItems}
onClick={({ key }) => handleMaterialSelect(key)}
style={{
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
}}
/>
}
trigger={["click"]}
placement="topLeft"
>
<Button
type="text"
icon={<CodeSandboxOutlined />}
className={styles.toolbarButton}
/>
</Dropdown>
</div>
<div className={styles.rightTool}>
<div className={styles.rightToolItem}>
<ShareAltOutlined />
</div>
<div className={styles.rightToolItem}>
<MessageOutlined />
</div>
</div>
</div>
<div className={styles.inputArea}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="输入消息..."
className={styles.messageInput}
autoSize={{ minRows: 2, maxRows: 6 }}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
disabled={!inputValue.trim()}
className={styles.sendButton}
>
</Button>
</div>
<div className={styles.inputHint}>Ctrl+Enter换行</div>
</div>
</Footer>
{/* 消息输入组件 */}
<MessageEnter contract={contract} />
</Layout>
{/* 右侧个人资料卡片 */}
<Person
<ProfileCard
contract={contract}
showProfile={showProfile}
onToggleProfile={onToggleProfile}
/>
{/* 素材选择模态框 */}
<Modal
title="选择素材"
open={showMaterialModal}
onCancel={() => setShowMaterialModal(false)}
footer={[
<Button key="cancel" onClick={() => setShowMaterialModal(false)}>
</Button>,
<Button
key="confirm"
type="primary"
onClick={() => setShowMaterialModal(false)}
>
</Button>,
]}
width={800}
>
<div style={{ display: "flex", height: "400px" }}>
{/* 左侧素材分类 */}
<div
style={{
width: "200px",
background: "#f5f5f5",
borderRight: "1px solid #e8e8e8",
}}
>
<div style={{ padding: "16px", borderBottom: "1px solid #e8e8e8" }}>
<h4 style={{ margin: 0, color: "#262626" }}></h4>
</div>
<div style={{ padding: "8px 0" }}>
<div
style={{
padding: "8px 16px",
cursor: "pointer",
background: "#e6f7ff",
borderLeft: "3px solid #1890ff",
color: "#1890ff",
}}
>
4
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
...
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
D2辅助
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
ROS反馈演示...
</div>
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
...
</div>
</div>
<div style={{ padding: "16px", borderTop: "1px solid #e8e8e8" }}>
<h4 style={{ margin: 0, color: "#262626" }}></h4>
</div>
</div>
{/* 右侧内容区域 */}
<div style={{ flex: 1, padding: "16px" }}>
<div style={{ marginBottom: "16px" }}>
<Input.Search placeholder="昵称" style={{ width: "100%" }} />
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "300px",
color: "#8c8c8c",
}}
>
</div>
</div>
</div>
</Modal>
</Layout>
);
};

View File

@@ -63,7 +63,6 @@
.lastMessage {
font-size: 12px;
color: #8c8c8c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
@@ -71,7 +70,7 @@
padding-right: 5px;
height: 18px; // 添加固定高度
line-height: 18px; // 设置行高与高度一致
&::before {
content: attr(data-count);
position: absolute;
@@ -88,7 +87,7 @@
text-align: center;
display: none;
}
&[data-count]:not([data-count=""]):not([data-count="0"]) {
&::before {
display: inline-block;
@@ -106,7 +105,7 @@
}
}
}
.lastDayMessage {
position: absolute;
bottom: 0;

View File

@@ -2,28 +2,28 @@ import React from "react";
import { List, Avatar, Badge } from "antd";
import { UserOutlined, TeamOutlined } from "@ant-design/icons";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import styles from "./MessageList.module.scss";
import { formatWechatTime } from "@/utils/common";
interface MessageListProps {
chatSessions: ContractData[] | weChatGroup[];
currentChat: ContractData | weChatGroup;
onContactClick: (chat: ContractData | weChatGroup) => void;
}
interface MessageListProps {}
const MessageList: React.FC<MessageListProps> = ({
chatSessions,
currentChat,
onContactClick,
}) => {
const MessageList: React.FC<MessageListProps> = () => {
const { setCurrentContact, currentContract } = useWeChatStore();
const chatSessions = useCkChatStore(state => state.chatSessions);
const onContactClick = (session: ContractData | weChatGroup) => {
setCurrentContact(session, true);
};
return (
<div className={styles.messageList}>
<List
dataSource={chatSessions as ContractData[]}
dataSource={chatSessions as (ContractData | weChatGroup)[]}
renderItem={session => (
<List.Item
key={session.id}
className={`${styles.messageItem} ${
currentChat?.id === session.id ? styles.active : ""
currentContract?.id === session.id ? styles.active : ""
}`}
onClick={() => onContactClick(session)}
>

View File

@@ -60,6 +60,13 @@
padding: 10px 0;
}
.noResults {
text-align: center;
color: #999;
padding: 20px;
font-size: 14px;
}
.list {
flex: 1;
overflow-y: auto;

View File

@@ -2,21 +2,24 @@ import React, { useState, useCallback, useEffect } from "react";
import { List, Avatar, Collapse, Button } from "antd";
import type { CollapseProps } from "antd";
import styles from "./WechatFriends.module.scss";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import {
useCkChatStore,
searchContactsAndGroups,
} from "@/store/module/ckchat/ckchat";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { addChatSession } from "@/store/module/ckchat/ckchat";
import { useWeChatStore } from "@/store/module/weChat/weChat";
interface WechatFriendsProps {
contracts: ContractData[] | weChatGroup[];
onContactClick: (contract: ContractData | weChatGroup) => void;
selectedContactId?: ContractData | weChatGroup;
}
const ContactListSimple: React.FC<WechatFriendsProps> = ({
contracts,
onContactClick,
selectedContactId,
}) => {
const [newContractList, setNewContractList] = useState<any[]>([]);
const [searchResults, setSearchResults] = useState<
(ContractData | weChatGroup)[]
>([]);
const getNewContractListFn = useCkChatStore(
state => state.getNewContractList,
);
@@ -26,17 +29,27 @@ const ContactListSimple: React.FC<WechatFriendsProps> = ({
// 使用useEffect来处理异步的getNewContractList调用
useEffect(() => {
const fetchNewContractList = async () => {
const fetchData = async () => {
try {
const result = await getNewContractListFn();
setNewContractList(result || []);
if (searchKeyword.trim()) {
// 有搜索关键词时,获取搜索结果
const searchResult = await searchContactsAndGroups();
setSearchResults(searchResult || []);
setNewContractList([]);
} else {
// 无搜索关键词时,获取分组列表
const result = await getNewContractListFn();
setNewContractList(result || []);
setSearchResults([]);
}
} catch (error) {
console.error("获取联系人分组列表失败:", error);
console.error("获取联系人数据失败:", error);
setNewContractList([]);
setSearchResults([]);
}
};
fetchNewContractList();
fetchData();
}, [getNewContractListFn, kfSelected, countLables, searchKeyword]);
const [activeKey, setActiveKey] = useState<string[]>([]); // 默认展开第一个分组
@@ -48,32 +61,39 @@ const ContactListSimple: React.FC<WechatFriendsProps> = ({
const [loading, setLoading] = useState<{ [key: string]: boolean }>({});
const [hasMore, setHasMore] = useState<{ [key: string]: boolean }>({});
const [page, setPage] = useState<{ [key: string]: number }>({});
const { setCurrentContact } = useWeChatStore();
const onContactClick = (contact: ContractData | weChatGroup) => {
addChatSession(contact);
setCurrentContact(contact);
};
// 渲染联系人项
const renderContactItem = (contact: ContractData) => (
<List.Item
key={contact.id}
onClick={() => onContactClick(contact)}
className={`${styles.contractItem} ${contact.id === selectedContactId?.id ? styles.selected : ""}`}
>
<div className={styles.avatarContainer}>
<Avatar
src={contact.avatar || contact.chatroomAvatar}
icon={
!(contact.avatar || contact.chatroomAvatar) && (
<span>{contact.nickname.charAt(0)}</span>
)
}
className={styles.avatar}
/>
</div>
<div className={styles.contractInfo}>
<div className={styles.name}>
{contact.conRemark || contact.nickname}
const renderContactItem = (contact: ContractData | weChatGroup) => {
// 判断是否为群组
const isGroup = "chatroomId" in contact;
const avatar = contact.avatar || contact.chatroomAvatar;
const name = contact.conRemark || contact.nickname;
return (
<List.Item
key={contact.id}
onClick={() => onContactClick(contact)}
className={`${styles.contractItem} ${contact.id === selectedContactId?.id ? styles.selected : ""}`}
>
<div className={styles.avatarContainer}>
<Avatar
src={avatar}
icon={!avatar && <span>{contact.nickname.charAt(0)}</span>}
className={styles.avatar}
/>
</div>
</div>
</List.Item>
);
<div className={styles.contractInfo}>
<div className={styles.name}>{name}</div>
{isGroup && <div className={styles.groupInfo}></div>}
</div>
</List.Item>
);
};
// 初始化分页数据
useEffect(() => {
@@ -188,22 +208,27 @@ const ContactListSimple: React.FC<WechatFriendsProps> = ({
return (
<div className={styles.contractListSimple}>
{newContractList && newContractList.length > 0 ? (
{searchKeyword.trim() ? (
// 搜索模式:直接显示搜索结果列表
<>
<div className={styles.header}></div>
<List
className={styles.list}
dataSource={searchResults}
renderItem={renderContactItem}
/>
{searchResults.length === 0 && (
<div className={styles.noResults}></div>
)}
</>
) : (
// 正常模式:显示分组
<Collapse
className={styles.groupCollapse}
activeKey={activeKey}
onChange={keys => setActiveKey(keys as string[])}
items={getCollapseItems()}
/>
) : (
<>
<div className={styles.header}></div>
<List
className={styles.list}
dataSource={contracts as ContractData[]}
renderItem={renderContactItem}
/>
</>
)}
</div>
);

View File

@@ -6,26 +6,15 @@ import {
ChromeOutlined,
MessageOutlined,
} from "@ant-design/icons";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import WechatFriends from "./WechatFriends";
import MessageList from "./MessageList/index";
import styles from "./SidebarMenu.module.scss";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
interface SidebarMenuProps {
contracts: ContractData[] | weChatGroup[];
currentChat: ContractData | weChatGroup;
onContactClick: (contract: ContractData | weChatGroup) => void;
loading?: boolean;
}
const SidebarMenu: React.FC<SidebarMenuProps> = ({
contracts,
currentChat,
onContactClick,
loading = false,
}) => {
const chatSessions = useCkChatStore(state => state.getChatSessions());
const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
const searchKeyword = useCkChatStore(state => state.searchKeyword);
const setSearchKeyword = useCkChatStore(state => state.setSearchKeyword);
const clearSearchKeyword = useCkChatStore(state => state.clearSearchKeyword);
@@ -93,7 +82,7 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({
{/* 搜索栏 */}
<div className={styles.searchBar}>
<Input
placeholder="搜索联系人、群组"
placeholder="搜索客户..."
prefix={<SearchOutlined />}
value={searchKeyword}
onChange={e => handleSearch(e.target.value)}
@@ -133,21 +122,9 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({
const renderContent = () => {
switch (activeTab) {
case "chats":
return (
<MessageList
chatSessions={chatSessions}
onContactClick={onContactClick}
currentChat={currentChat}
/>
);
return <MessageList />;
case "contracts":
return (
<WechatFriends
contracts={contracts as ContractData[]}
onContactClick={onContactClick}
selectedContactId={currentChat}
/>
);
return <WechatFriends />;
case "groups":
return (
<div className={styles.emptyState}>

View File

@@ -10,38 +10,15 @@ import styles from "./index.module.scss";
import { addChatSession } from "@/store/module/ckchat/ckchat";
const { Header, Content, Sider } = Layout;
import { chatInitAPIdata, initSocket } from "./main";
import { clearUnreadCount } from "@/pages/pc/ckbox/api";
import {
KfUserListData,
weChatGroup,
ContractData,
} from "@/pages/pc/ckbox/data";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { KfUserListData } from "@/pages/pc/ckbox/data";
const CkboxPage: React.FC = () => {
const [messageApi, contextHolder] = message.useMessage();
const [contracts, setContacts] = useState<any[]>([]);
const [currentChat, setCurrentChat] = useState<ContractData | weChatGroup>(
null,
);
const status = useWebSocketStore(state => state.status);
// 不要在组件初始化时获取sendCommand而是在需要时动态获取
const [loading, setLoading] = useState(false);
const [showProfile, setShowProfile] = useState(true);
const kfUserList = useCkChatStore(state => state.kfUserList);
const { sendCommand } = useWebSocketStore.getState();
useEffect(() => {
if (status == "connected" && kfUserList.length > 0) {
//查询客服用户激活状态
setInterval(() => {
sendCommand("CmdRequestWechatAccountsAliveStatus", {
wechatAccountIds: kfUserList.map(v => v.id),
});
}, 10 * 1000);
}
}, [status]);
const currentContract = useWeChatStore(state => state.currentContract);
useEffect(() => {
// 方法一:使用 Promise 链式调用处理异步函数
setLoading(true);
@@ -63,8 +40,6 @@ const CkboxPage: React.FC = () => {
addChatSession(v);
});
setContacts(isChatList);
// 数据加载完成后初始化WebSocket连接
initSocket();
})
@@ -76,46 +51,9 @@ const CkboxPage: React.FC = () => {
});
}, []);
//开始开启聊天
const handleContactClick = (contract: ContractData | weChatGroup) => {
clearUnreadCount([contract.id]).then(() => {
contract.unreadCount = 0;
addChatSession(contract);
setCurrentChat(contract);
});
};
const handleSendMessage = async (message: string) => {
if (!currentChat || !message.trim()) return;
try {
// 更新当前聊天会话
const updatedSession = {
...currentChat,
lastMessage: message,
lastTime: dayjs().toISOString(),
unreadCount: 0,
};
setCurrentChat(updatedSession);
messageApi.success("消息发送成功");
} catch (error) {
messageApi.error("消息发送失败");
}
};
// 处理垂直侧边栏用户选择
const handleVerticalUserSelect = (userId: string) => {
// setActiveVerticalUserId(userId);
// 这里可以根据选择的用户类别筛选不同的联系人列表
// 例如根据userId加载不同分类的联系人
};
return (
<PageSkeleton loading={loading}>
<Layout className={styles.ckboxLayout}>
{contextHolder}
<Header className={styles.header}></Header>
<Layout>
{/* 垂直侧边栏 */}
@@ -126,17 +64,12 @@ const CkboxPage: React.FC = () => {
{/* 左侧联系人边栏 */}
<Sider width={280} className={styles.sider}>
<SidebarMenu
contracts={contracts}
currentChat={currentChat}
onContactClick={handleContactClick}
loading={loading}
/>
<SidebarMenu loading={loading} />
</Sider>
{/* 主内容区 */}
<Content className={styles.mainContent}>
{currentChat ? (
{currentContract ? (
<div className={styles.chatContainer}>
<div className={styles.chatToolbar}>
<Space>
@@ -153,8 +86,7 @@ const CkboxPage: React.FC = () => {
</Space>
</div>
<ChatWindow
contract={currentChat}
onSendMessage={handleSendMessage}
contract={currentContract}
showProfile={showProfile}
onToggleProfile={() => setShowProfile(!showProfile)}
/>