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

@@ -1,18 +1,14 @@
{
"_charts-DKSCc2_C.js": {
"file": "assets/charts-DKSCc2_C.js",
"_charts-BET_YNJb.js": {
"file": "assets/charts-BET_YNJb.js",
"name": "charts",
"imports": [
"_ui-DhAz00L0.js",
"_ui-BSfOMVFg.js",
"_vendor-2vc8h_ct.js"
]
},
"_ui-D0C0OGrH.css": {
"file": "assets/ui-D0C0OGrH.css",
"src": "_ui-D0C0OGrH.css"
},
"_ui-DhAz00L0.js": {
"file": "assets/ui-DhAz00L0.js",
"_ui-BSfOMVFg.js": {
"file": "assets/ui-BSfOMVFg.js",
"name": "ui",
"imports": [
"_vendor-2vc8h_ct.js"
@@ -21,6 +17,10 @@
"assets/ui-D0C0OGrH.css"
]
},
"_ui-D0C0OGrH.css": {
"file": "assets/ui-D0C0OGrH.css",
"src": "_ui-D0C0OGrH.css"
},
"_utils-6WF66_dS.js": {
"file": "assets/utils-6WF66_dS.js",
"name": "utils",
@@ -33,18 +33,18 @@
"name": "vendor"
},
"index.html": {
"file": "assets/index-BdCPAYQ7.js",
"file": "assets/index-DX2o9_TA.js",
"name": "index",
"src": "index.html",
"isEntry": true,
"imports": [
"_vendor-2vc8h_ct.js",
"_utils-6WF66_dS.js",
"_ui-DhAz00L0.js",
"_charts-DKSCc2_C.js"
"_ui-BSfOMVFg.js",
"_charts-BET_YNJb.js"
],
"css": [
"assets/index-ChiFk16x.css"
"assets/index-DwDrBOQB.css"
]
}
}

View File

@@ -11,13 +11,13 @@
</style>
<!-- 引入 uni-app web-view SDK必须 -->
<script type="text/javascript" src="/websdk.js"></script>
<script type="module" crossorigin src="/assets/index-BdCPAYQ7.js"></script>
<script type="module" crossorigin src="/assets/index-DX2o9_TA.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-2vc8h_ct.js">
<link rel="modulepreload" crossorigin href="/assets/utils-6WF66_dS.js">
<link rel="modulepreload" crossorigin href="/assets/ui-DhAz00L0.js">
<link rel="modulepreload" crossorigin href="/assets/charts-DKSCc2_C.js">
<link rel="modulepreload" crossorigin href="/assets/ui-BSfOMVFg.js">
<link rel="modulepreload" crossorigin href="/assets/charts-BET_YNJb.js">
<link rel="stylesheet" crossorigin href="/assets/ui-D0C0OGrH.css">
<link rel="stylesheet" crossorigin href="/assets/index-ChiFk16x.css">
<link rel="stylesheet" crossorigin href="/assets/index-DwDrBOQB.css">
</head>
<body>
<div id="root"></div>

View File

@@ -7,6 +7,7 @@ import {
UserOutline,
} from "antd-mobile-icons";
import { useUserStore } from "@/store/module/user";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { loginWithPassword, loginWithCode, sendVerificationCode } from "./api";
import style from "./login.module.scss";
@@ -75,6 +76,8 @@ const Login: React.FC = () => {
response.then(res => {
const { member, kefuData, deviceTotal } = res;
// 清空WebSocket连接状态
useWebSocketStore.getState().clearConnectionState();
login(res.token, member, deviceTotal);
const { self, token } = kefuData;
login2(token.access_token);

View File

@@ -5,7 +5,7 @@ import {
UpdateLikeTaskData,
LikeRecord,
PaginatedResponse,
} from "@/pages/workspace/auto-like/record/data";
} from "@/pages/mobile/workspace/auto-like/record/data";
// 获取自动点赞任务列表
export function fetchAutoLikeTasks(
@@ -36,7 +36,7 @@ export function deleteAutoLikeTask(id: string): Promise<any> {
// 切换任务状态
export function toggleAutoLikeTask(data): Promise<any> {
return request("/v1/workbench/update-status", { ...data, type: 1 }, "POST");
return request("/v1/workbench/update-status", { ...data }, "POST");
}
// 复制自动点赞任务

View File

@@ -201,8 +201,7 @@ const AutoLike: React.FC = () => {
// 切换任务状态
const toggleTaskStatus = async (id: string, status: number) => {
try {
const newStatus = status === 1 ? "2" : "1";
await toggleAutoLikeTask(id, newStatus);
await toggleAutoLikeTask({ id });
Toast.show({
content: status === 1 ? "已暂停" : "已启动",
position: "top",

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)}
/>

View File

@@ -33,6 +33,7 @@ export interface CkUserInfo {
export interface CkChatState {
userInfo: CkUserInfo | null;
isLoggedIn: boolean;
searchKeyword: string;
contractList: ContractData[];
chatSessions: any[];
kfUserList: KfUserListData[];
@@ -42,6 +43,8 @@ export interface CkChatState {
newContractList: ContactGroupByLabel[];
getContractList: () => ContractData[];
getNewContractList: () => ContactGroupByLabel[];
setSearchKeyword: (keyword: string) => void;
clearSearchKeyword: () => void;
asyncKfSelected: (data: number) => void;
asyncWeChatGroup: (data: weChatGroup[]) => void;
asyncCountLables: (data: ContactGroupByLabel[]) => void;
@@ -49,13 +52,13 @@ export interface CkChatState {
asyncKfUserList: (data: KfUserListData[]) => void;
getKfUserInfo: (wechatAccountId: number) => KfUserListData | undefined;
asyncContractList: (data: ContractData[]) => void;
getChatSessions: () => any[];
asyncChatSessions: (data: any[]) => void;
updateChatSession: (session: ContractData | weChatGroup) => void;
deleteCtrlUser: (userId: number) => void;
updateCtrlUser: (user: KfUserListData) => void;
clearkfUserList: () => void;
getChatSessions: () => any[];
addChatSession: (session: any) => void;
updateChatSession: (session: any) => void;
deleteChatSession: (sessionId: string) => void;
setUserInfo: (userInfo: CkUserInfo) => void;
clearUserInfo: () => void;

View File

@@ -131,6 +131,71 @@ export const useCkChatStore = createPersistStore<CkChatState>(
return cachedResult;
};
})(),
// 搜索好友和群组的新方法 - 从本地数据库查询并返回扁平化的搜索结果
searchContactsAndGroups: (() => {
let cachedResult: (ContractData | weChatGroup)[] = [];
let lastKfSelected: number | null = null;
let lastSearchKeyword: string = "";
return async () => {
const state = useCkChatStore.getState();
// 检查是否需要重新计算缓存
const shouldRecalculate =
lastKfSelected !== state.kfSelected ||
lastSearchKeyword !== state.searchKeyword;
if (shouldRecalculate) {
if (state.searchKeyword.trim()) {
const keyword = state.searchKeyword.toLowerCase();
// 从本地数据库查询联系人数据
let allContacts: any[] = await contractService.findAll();
// 从本地数据库查询群组数据
let allGroups: any[] = await weChatGroupService.findAll();
// 根据选中的客服筛选联系人
if (state.kfSelected !== 0) {
allContacts = allContacts.filter(
item => item.wechatAccountId === state.kfSelected,
);
}
// 根据选中的客服筛选群组
if (state.kfSelected !== 0) {
allGroups = allGroups.filter(
item => item.wechatAccountId === state.kfSelected,
);
}
// 搜索匹配的联系人
const matchedContacts = allContacts.filter(item => {
const nickname = (item.nickname || "").toLowerCase();
const conRemark = (item.conRemark || "").toLowerCase();
return nickname.includes(keyword) || conRemark.includes(keyword);
});
// 搜索匹配的群组
const matchedGroups = allGroups.filter(item => {
const nickname = (item.nickname || "").toLowerCase();
const conRemark = (item.conRemark || "").toLowerCase();
return nickname.includes(keyword) || conRemark.includes(keyword);
});
// 合并搜索结果
cachedResult = [...matchedContacts, ...matchedGroups];
} else {
cachedResult = [];
}
lastKfSelected = state.kfSelected;
lastSearchKeyword = state.searchKeyword;
}
return cachedResult;
};
})(),
// 异步设置联系人分组列表
asyncNewContractList: async (data: any[]) => {
set({ newContractList: data });
@@ -297,6 +362,7 @@ export const useCkChatStore = createPersistStore<CkChatState>(
})(),
// 添加聊天会话
addChatSession: (session: ContractData | weChatGroup) => {
session.unreadCount = 0;
set(state => {
// 检查是否已存在相同id的会话
const exists = state.chatSessions.some(item => item.id === session.id);
@@ -307,47 +373,20 @@ export const useCkChatStore = createPersistStore<CkChatState>(
: [...state.chatSessions, session as ContractData | weChatGroup],
};
});
// 清除getChatSessions缓存
const state = useCkChatStore.getState();
if (
state.getChatSessions &&
typeof state.getChatSessions === "function"
) {
// 触发缓存重新计算
state.getChatSessions();
}
},
// 更新聊天会话
updateChatSession: (session: ContractData | weChatGroup) => {
set(state => ({
chatSessions: state.chatSessions.map(item =>
item.id === session.id ? session : item,
item.id === session.id ? { ...item, ...session } : item,
),
}));
// 清除getChatSessions缓存
const state = useCkChatStore.getState();
if (
state.getChatSessions &&
typeof state.getChatSessions === "function"
) {
// 触发缓存重新计算
state.getChatSessions();
}
},
// 删除聊天会话
deleteChatSession: (sessionId: string) => {
set(state => ({
chatSessions: state.chatSessions.filter(item => item.id !== sessionId),
}));
// 清除getChatSessions缓存
const state = useCkChatStore.getState();
if (
state.getChatSessions &&
typeof state.getChatSessions === "function"
) {
// 触发缓存重新计算
state.getChatSessions();
}
},
// 设置用户信息
setUserInfo: (userInfo: CkUserInfo) => {
@@ -472,4 +511,6 @@ export const setSearchKeyword = (keyword: string) =>
useCkChatStore.getState().setSearchKeyword(keyword);
export const clearSearchKeyword = () =>
useCkChatStore.getState().clearSearchKeyword();
export const searchContactsAndGroups = () =>
useCkChatStore.getState().searchContactsAndGroups();
useCkChatStore.getState().getKfSelectedUser();

View File

@@ -0,0 +1,25 @@
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
// 微信聊天相关的类型定义
export interface WeChatState {
// 当前选中的联系人/群组
currentContract: ContractData | weChatGroup | null;
// 当前聊天用户的消息列表(只存储当前聊天用户的消息)
currentMessages: ChatRecord[];
// 消息加载状态
messagesLoading: boolean;
// Actions
setCurrentContact: (
contract: ContractData | weChatGroup,
isExist?: boolean,
) => void;
loadChatMessages: (contact: ContractData | weChatGroup) => Promise<void>;
// 视频消息处理方法
setVideoLoading: (messageId: number, isLoading: boolean) => void;
setVideoUrl: (messageId: number, videoUrl: string) => void;
addMessage: (message: ChatRecord) => void;
receivedMsg: (message: ChatRecord) => void;
}

View File

@@ -0,0 +1,194 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { getChatMessages } from "@/pages/pc/ckbox/api";
import { WeChatState } from "./weChat.data";
import { clearUnreadCount, updateConfig } from "@/pages/pc/ckbox/api";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import {
addChatSession,
updateChatSession,
useCkChatStore,
} from "@/store/module/ckchat/ckchat";
export const useWeChatStore = create<WeChatState>()(
persist(
(set, get) => ({
// 初始状态
currentContract: null,
currentMessages: [],
messagesLoading: false,
// Actions
setCurrentContact: (
contract: ContractData | weChatGroup,
isExist?: boolean,
) => {
const state = useWeChatStore.getState();
// 切换联系人时清空当前消息,等待重新加载
set({ currentMessages: [] });
clearUnreadCount([contract.id]).then(() => {
if (isExist) {
updateChatSession({ ...contract, unreadCount: 0 });
} else {
addChatSession(contract);
}
set({ currentContract: contract });
updateConfig({
id: contract.id,
config: { chat: true },
});
state.loadChatMessages(contract);
});
},
loadChatMessages: async contact => {
set({ messagesLoading: true });
try {
const params: any = {
wechatAccountId: contact.wechatAccountId,
From: 1,
To: 4704624000000,
Count: 10,
olderData: true,
};
if ("chatroomId" in contact && contact.chatroomId) {
params.wechatChatroomId = contact.chatroomId;
} else {
params.wechatFriendId = contact.id;
}
const messages = await getChatMessages(params);
set({ currentMessages: messages || [] });
} catch (error) {
console.error("获取聊天消息失败:", error);
} finally {
set({ messagesLoading: false });
}
},
setMessageLoading: loading => {
set({ messagesLoading: Boolean(loading) });
},
addMessage: message => {
set(state => ({
currentMessages: [...state.currentMessages, message],
}));
},
receivedMsg: message => {
const currentContract = useWeChatStore.getState().currentContract;
if (
currentContract &&
currentContract.wechatAccountId == message.wechatAccountId &&
currentContract.id == message.wechatFriendId
) {
set(state => ({
currentMessages: [...state.currentMessages, message],
}));
} else {
//更新消息列表unread数值根据接收的++1 这样
const chatSessions = useCkChatStore.getState().chatSessions;
const session = chatSessions.find(
item => item.id == message.wechatFriendId,
);
if (session) {
session.unreadCount = Number(session.unreadCount) + 1;
updateChatSession(session);
}
}
},
updateMessage: (messageId, updates) => {
set(state => ({
currentMessages: state.currentMessages.map(msg =>
msg.id === messageId ? { ...msg, ...updates } : msg,
),
}));
},
// 便捷选择器
getCurrentContact: () => get().currentContract,
getCurrentMessages: () => get().currentMessages,
getMessagesLoading: () => get().messagesLoading,
// 视频消息处理方法
setVideoLoading: (messageId: number, isLoading: boolean) => {
set(state => ({
currentMessages: state.currentMessages.map(msg => {
if (msg.id === messageId) {
try {
const content = JSON.parse(msg.content);
// 更新加载状态
const updatedContent = { ...content, isLoading };
return {
...msg,
content: JSON.stringify(updatedContent),
};
} catch (e) {
console.error("更新视频加载状态失败:", e);
return msg;
}
}
return msg;
}),
}));
},
setVideoUrl: (messageId: number, videoUrl: string) => {
set(state => ({
currentMessages: state.currentMessages.map(msg => {
if (msg.id === messageId) {
try {
const content = JSON.parse(msg.content);
// 检查视频是否已经下载完毕,避免重复更新
if (content.videoUrl && content.videoUrl === videoUrl) {
console.log("视频已下载,跳过重复更新:", messageId);
return msg;
}
// 设置视频URL并清除加载状态
const updatedContent = {
...content,
videoUrl,
isLoading: false,
};
return {
...msg,
content: JSON.stringify(updatedContent),
};
} catch (e) {
console.error("更新视频URL失败:", e);
return msg;
}
}
return msg;
}),
}));
},
clearAllData: () => {
set({
currentContract: null,
currentMessages: [],
messagesLoading: false,
});
},
}),
{
name: "wechat-storage",
partialize: state => ({
// currentContract 不做持久化,登录和页面刷新时直接清空
}),
},
),
);
// 导出便捷的选择器函数
export const useCurrentContact = () =>
useWeChatStore(state => state.currentContract);
export const useCurrentMessages = () =>
useWeChatStore(state => state.currentMessages);
export const useMessagesLoading = () =>
useWeChatStore(state => state.messagesLoading);

View File

@@ -0,0 +1,27 @@
export interface FriendMessage {
id: number;
wechatFriendId: number;
wechatAccountId: number;
tenantId: number;
accountId: number;
synergyAccountId: number;
content: string;
msgType: number;
msgSubType: number;
msgSvrId: string;
isSend: boolean;
createTime: string;
isDeleted: boolean;
deleteTime: string;
sendStatus: number;
wechatTime: number;
origin: number;
msgId: number;
recalled: boolean;
}
export interface Messages {
friendMessage: FriendMessage | null;
chatroomMessage: string;
seq: number;
cmdType: string;
}

View File

@@ -2,8 +2,14 @@
import { deepCopy } from "@/utils/common";
import { WebSocketMessage } from "./websocket";
import { getkfUserList, asyncKfUserList } from "@/store/module/ckchat/ckchat";
import { Messages } from "./msg.data";
import { useWeChatStore } from "@/store/module/weChat/weChat";
// 消息处理器类型定义
type MessageHandler = (message: WebSocketMessage) => void;
const setVideoUrl = useWeChatStore.getState().setVideoUrl;
const addMessage = useWeChatStore.getState().addMessage;
const receivedMsg = useWeChatStore.getState().receivedMsg;
// 消息处理器映射
const messageHandlers: Record<string, MessageHandler> = {
@@ -19,6 +25,31 @@ const messageHandlers: Record<string, MessageHandler> = {
});
asyncKfUserList(kfUserList);
},
// 发送消息响应
CmdSendMessageResp: message => {
console.log("发送消息响应", message);
addMessage(message.friendMessage);
// 在这里添加具体的处理逻辑
},
CmdSendMessageResult: message => {
console.log("发送消息结果", message);
// 在这里添加具体的处理逻辑
},
// 接收消息响应
CmdReceiveMessageResp: message => {
console.log("接收消息响应", message);
addMessage(message.friendMessage);
// 在这里添加具体的处理逻辑
},
//收到消息
CmdNewMessage: (message: Messages) => {
// 在这里添加具体的处理逻辑
receivedMsg(message.friendMessage);
},
CmdFriendInfoChanged: message => {
// console.log("好友信息变更", message);
// 在这里添加具体的处理逻辑
},
// 登录响应
CmdSignInResp: message => {
@@ -30,6 +61,15 @@ const messageHandlers: Record<string, MessageHandler> = {
CmdNotify: message => {
console.log("通知消息", message);
// 在这里添加具体的处理逻辑
if (message.notify == "Kicked out") {
// 被踢出时直接跳转到登录页面
window.location.href = "/login";
}
},
CmdDownloadVideoResult: message => {
// 在这里添加具体的处理逻辑
setVideoUrl(message.friendMessageId, message.url);
},
// 可以继续添加更多处理器...

View File

@@ -51,6 +51,7 @@ interface WebSocketState {
// 重连相关
reconnectAttempts: number;
reconnectTimer: NodeJS.Timeout | null;
aliveStatusTimer: NodeJS.Timeout | null; // 客服用户状态查询定时器
// 方法
connect: (config: Partial<WebSocketConfig>) => void;
@@ -60,6 +61,7 @@ interface WebSocketState {
clearMessages: () => void;
markAsRead: () => void;
reconnect: () => void;
clearConnectionState: () => void; // 清空连接状态
// 内部方法
_handleOpen: () => void;
@@ -68,6 +70,8 @@ interface WebSocketState {
_handleError: (event: Event) => void;
_startReconnectTimer: () => void;
_stopReconnectTimer: () => void;
_startAliveStatusTimer: () => void; // 启动客服状态查询定时器
_stopAliveStatusTimer: () => void; // 停止客服状态查询定时器
}
// 默认配置
@@ -92,6 +96,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
unreadCount: 0,
reconnectAttempts: 0,
reconnectTimer: null,
aliveStatusTimer: null,
// 连接WebSocket
connect: (config: Partial<WebSocketConfig>) => {
@@ -183,6 +188,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
}
currentState._stopReconnectTimer();
currentState._stopAliveStatusTimer();
set({
status: WebSocketStatus.DISCONNECTED,
@@ -226,7 +232,16 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
currentState.status !== WebSocketStatus.CONNECTED ||
!currentState.ws
) {
Toast.show({ content: "WebSocket未连接", position: "top" });
Toast.show({
content: "WebSocket未连接正在重新连接...",
position: "top",
});
// 重置连接状态并发起重新连接
set({ status: WebSocketStatus.DISCONNECTED });
if (currentState.config) {
currentState.connect(currentState.config);
}
return;
}
@@ -242,6 +257,12 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
} catch (error) {
// console.error("命令发送失败:", error);
Toast.show({ content: "命令发送失败", position: "top" });
// 发送失败时也尝试重新连接
set({ status: WebSocketStatus.DISCONNECTED });
if (currentState.config) {
currentState.connect(currentState.config);
}
}
},
@@ -269,6 +290,34 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
}
},
// 清空连接状态(用于退出登录时)
clearConnectionState: () => {
const currentState = get();
// 断开现有连接
if (currentState.ws) {
currentState.ws.close();
}
// 停止所有定时器
currentState._stopReconnectTimer();
currentState._stopAliveStatusTimer();
// 重置所有状态
set({
status: WebSocketStatus.DISCONNECTED,
ws: null,
config: null,
messages: [],
unreadCount: 0,
reconnectAttempts: 0,
reconnectTimer: null,
aliveStatusTimer: null,
});
// console.log("WebSocket连接状态已清空");
},
// 内部方法:处理连接打开
_handleOpen: () => {
const currentState = get();
@@ -291,6 +340,9 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
}
Toast.show({ content: "WebSocket连接成功", position: "top" });
// 启动客服状态查询定时器
currentState._startAliveStatusTimer();
},
// 内部方法:处理消息接收
@@ -319,6 +371,9 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
});
}
// 停止客服状态查询定时器
get()._stopAliveStatusTimer();
// 断开连接
get().disconnect();
return;
@@ -414,6 +469,51 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
set({ reconnectTimer: null });
}
},
// 内部方法:启动客服状态查询定时器
_startAliveStatusTimer: () => {
const currentState = get();
// 先停止现有定时器
currentState._stopAliveStatusTimer();
// 获取客服用户列表
const { kfUserList } = useCkChatStore.getState();
// 如果没有客服用户,不启动定时器
if (!kfUserList || kfUserList.length === 0) {
return;
}
// 启动定时器每5秒查询一次
const timer = setInterval(() => {
const state = get();
// 检查连接状态
if (state.status === WebSocketStatus.CONNECTED) {
const { kfUserList: currentKfUserList } = useCkChatStore.getState();
if (currentKfUserList && currentKfUserList.length > 0) {
state.sendCommand("CmdRequestWechatAccountsAliveStatus", {
wechatAccountIds: currentKfUserList.map(v => v.id),
});
}
} else {
// 如果连接断开,停止定时器
state._stopAliveStatusTimer();
}
}, 5 * 1000);
set({ aliveStatusTimer: timer });
},
// 内部方法:停止客服状态查询定时器
_stopAliveStatusTimer: () => {
const currentState = get();
if (currentState.aliveStatusTimer) {
clearInterval(currentState.aliveStatusTimer);
set({ aliveStatusTimer: null });
}
},
}),
{
name: "websocket-store",
@@ -424,6 +524,7 @@ export const useWebSocketStore = createPersistStore<WebSocketState>(
messages: state.messages.slice(-100), // 只保留最近100条消息
unreadCount: state.unreadCount,
reconnectAttempts: state.reconnectAttempts,
// 注意:定时器不需要持久化,重新连接时会重新创建
}),
onRehydrateStorage: () => state => {
// 页面刷新后,如果之前是连接状态,尝试重新连接