feat(ckbox): 实现聊天界面功能并集成WebSocket

添加联系人列表、聊天会话和消息窗口组件
集成WebSocket连接实现实时聊天功能
重构API调用以获取聊天记录和联系人信息
添加样式文件美化聊天界面
This commit is contained in:
2025-08-21 10:51:23 +08:00
parent ad5fc0bb52
commit 8e89c5ba73
9 changed files with 518 additions and 176 deletions

View File

@@ -129,9 +129,19 @@ const Login: React.FC = () => {
});
//获取触客宝
getToken2().then(() => {
getToken2().then((Token: string) => {
getChuKeBaoUserInfo().then(res => {
setUserInfo(res);
// 使用WebSocket store连接
const { connect } = useWebSocketStore.getState();
connect({
accessToken: Token,
accountId: getAccountId()?.toString() || "",
client: "kefu-client",
autoReconnect: true,
reconnectInterval: 3000,
maxReconnectAttempts: 5,
});
});
});
};

View File

@@ -19,16 +19,24 @@ export const getContactList = (params: { prevId: number; count: number }) => {
return request("/api/wechatFriend/list", params, "GET");
};
// 搜索联系人
export const searchContacts = (keyword: string): Promise<ContactData[]> => {
return request("/v1/contacts/search", { keyword }, "GET");
};
// 获取聊天会话列表
export const getChatSessions = (): Promise<ChatSession[]> => {
return request("/v1/chats/sessions", {}, "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

@@ -41,7 +41,7 @@ import {
} from "@ant-design/icons";
import dayjs from "dayjs";
import { ChatSession, MessageData, MessageType } from "../../data";
// import { getChatHistory, sendMessage } from "../api";
import { getChatMessage } from "../../api";
import styles from "./ChatWindow.module.scss";
const { Header, Content, Footer, Sider } = Layout;
@@ -78,38 +78,65 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const fetchChatHistory = async () => {
try {
setLoading(true);
// 模拟聊天历史数据
const mockMessages: MessageData[] = [
{
id: "1",
senderId: "other",
senderName: chat.name,
content: "你好,请问有什么可以帮助您的吗?",
type: MessageType.TEXT,
timestamp: dayjs().subtract(10, "minute").toISOString(),
isRead: true,
},
{
id: "2",
senderId: "me",
senderName: "我",
content: "我想了解一下你们的产品",
type: MessageType.TEXT,
timestamp: dayjs().subtract(8, "minute").toISOString(),
isRead: true,
},
{
id: "3",
senderId: "other",
senderName: chat.name,
content: "好的,我来为您详细介绍",
type: MessageType.TEXT,
timestamp: dayjs().subtract(5, "minute").toISOString(),
isRead: true,
},
];
setMessages(mockMessages);
// 从chat对象中提取wechatFriendId
// 假设chat.id存储的是wechatFriendId
const wechatFriendId = parseInt(chat.id);
if (isNaN(wechatFriendId)) {
messageApi.error("无效的聊天ID");
return;
}
// 调用API获取聊天历史
const response = await getChatMessage({
wechatAccountId: 32686452, // 使用实际的wechatAccountId
wechatFriendId: wechatFriendId,
From: 0,
To: 0,
Count: 50, // 获取最近的50条消息
olderData: false,
keyword: "",
});
console.log("聊天历史响应:", response);
if (response && Array.isArray(response)) {
// 将API返回的消息记录转换为MessageData格式
const chatMessages: MessageData[] = response.map(item => {
// 解析content字段它是一个JSON字符串
let msgContent = "";
try {
const contentObj = JSON.parse(item.content);
msgContent = contentObj.content || "";
} catch (e) {
msgContent = item.content;
}
// 判断消息是发送还是接收
const isSend = item.isSend === true;
return {
id: item.id.toString(),
senderId: isSend ? "me" : "other",
senderName: isSend ? "我" : chat.name,
content: msgContent,
type: MessageType.TEXT, // 默认为文本类型实际应根据msgType字段判断
timestamp: item.createTime || new Date(item.wechatTime).toISOString(),
isRead: true, // 默认已读
};
});
// 按时间排序
chatMessages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
setMessages(chatMessages);
} else {
// 如果没有消息,显示空数组
setMessages([]);
}
} catch (error) {
console.error("获取聊天记录失败:", error);
messageApi.error("获取聊天记录失败");
} finally {
setLoading(false);

View File

@@ -0,0 +1,66 @@
.contactListSimple {
display: flex;
flex-direction: column;
height: 100%;
background-color: #fff;
color: #333;
.header {
padding: 10px 15px;
font-weight: bold;
border-bottom: 1px solid #f0f0f0;
}
.list {
flex: 1;
overflow-y: auto;
:global(.ant-list-item) {
padding: 10px 15px;
border-bottom: none;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
}
}
}
.contactItem {
display: flex;
align-items: center;
padding: 8px 15px;
&.selected {
background-color: #f5f5f5;
}
}
.avatarContainer {
margin-right: 10px;
}
.avatar {
background-color: #1890ff;
}
.contactInfo {
flex: 1;
overflow: hidden;
}
.name {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
font-size: 12px;
color: #aaa;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}

View File

@@ -0,0 +1,50 @@
import React from "react";
import { List, Avatar, Badge } from "antd";
import { ContactData } from "../../data";
import styles from "./ContactListSimple.module.scss";
interface ContactListSimpleProps {
contacts: ContactData[];
onContactClick: (contact: ContactData) => void;
selectedContactId?: string;
}
const ContactListSimple: React.FC<ContactListSimpleProps> = ({
contacts,
onContactClick,
selectedContactId,
}) => {
return (
<div className={styles.contactListSimple}>
<div className={styles.header}></div>
<List
className={styles.list}
dataSource={contacts}
renderItem={contact => (
<List.Item
key={contact.id}
onClick={() => onContactClick(contact)}
className={`${styles.contactItem} ${contact.id === selectedContactId ? styles.selected : ""}`}
>
<div className={styles.avatarContainer}>
<Badge dot={contact.online} color="green" offset={[-5, 5]}>
<Avatar
src={contact.avatar}
icon={
!contact.avatar && <span>{contact.name.charAt(0)}</span>
}
className={styles.avatar}
/>
</Badge>
</div>
<div className={styles.contactInfo}>
<div className={styles.name}>{contact.name}</div>
</div>
</List.Item>
)}
/>
</div>
);
};
export default ContactListSimple;

View File

@@ -0,0 +1,63 @@
.sidebar {
background: #fff;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
.searchBar {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
:global(.ant-input) {
border-radius: 20px;
background: #f5f5f5;
border: none;
&:focus {
background: #fff;
border: 1px solid #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
}
}
.tabs {
flex: 1;
display: flex;
flex-direction: column;
:global(.ant-tabs-content) {
flex: 1;
overflow: hidden;
}
:global(.ant-tabs-tabpane) {
height: 100%;
overflow: hidden;
}
:global(.ant-tabs-nav) {
margin: 0;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
:global(.ant-tabs-tab) {
padding: 12px 0;
margin: 0 16px 0 0;
}
}
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
padding: 20px;
text-align: center;
}
}

View File

@@ -0,0 +1,125 @@
import React, { useState } from "react";
import { Layout, Input, Tabs } from "antd";
import {
SearchOutlined,
UserOutlined,
TeamOutlined,
MessageOutlined,
} from "@ant-design/icons";
import { ContactData, ChatSession } from "../../data";
import ContactListSimple from "./ContactListSimple";
import MessageList from "../MessageList/index";
import styles from "./SidebarMenu.module.scss";
const { Sider } = Layout;
const { TabPane } = Tabs;
interface SidebarMenuProps {
contacts: ContactData[];
chatSessions: ChatSession[];
currentChat: ChatSession | null;
onContactClick: (contact: ContactData) => void;
onChatSelect: (chat: ChatSession) => void;
loading?: boolean;
}
const SidebarMenu: React.FC<SidebarMenuProps> = ({
contacts,
chatSessions,
currentChat,
onContactClick,
onChatSelect,
loading = false,
}) => {
const [searchText, setSearchText] = useState("");
const [activeTab, setActiveTab] = useState("contacts");
const handleSearch = (value: string) => {
setSearchText(value);
};
const getFilteredContacts = () => {
if (!searchText) return contacts;
return contacts.filter(
contact =>
contact.name.toLowerCase().includes(searchText.toLowerCase()) ||
contact.phone.includes(searchText),
);
};
const getFilteredSessions = () => {
if (!searchText) return chatSessions;
return chatSessions.filter(session =>
session.name.toLowerCase().includes(searchText.toLowerCase()),
);
};
return (
<Sider width={300} className={styles.sidebar}>
{/* 搜索栏 */}
<div className={styles.searchBar}>
<Input
placeholder="搜索联系人、群组"
prefix={<SearchOutlined />}
value={searchText}
onChange={e => handleSearch(e.target.value)}
allowClear
/>
</div>
{/* 标签页 */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
className={styles.tabs}
>
<TabPane
tab={
<span>
<MessageOutlined />
</span>
}
key="chats"
>
<MessageList
sessions={getFilteredSessions()}
currentChat={currentChat}
onChatSelect={onChatSelect}
/>
</TabPane>
<TabPane
tab={
<span>
<UserOutlined />
</span>
}
key="contacts"
>
<ContactListSimple
contacts={getFilteredContacts()}
onContactClick={onContactClick}
selectedContactId={currentChat?.id.split("_")[1]}
/>
</TabPane>
<TabPane
tab={
<span>
<TeamOutlined />
</span>
}
key="groups"
>
<div className={styles.emptyState}>
<TeamOutlined style={{ fontSize: 48, color: "#ccc" }} />
<p></p>
</div>
</TabPane>
</Tabs>
</Sider>
);
};
export default SidebarMenu;

View File

@@ -1,47 +1,66 @@
import React, { useState, useEffect, useRef } from "react";
import { Layout, Input, Button, Tabs, Space, message, Tooltip } from "antd";
import {
SearchOutlined,
UserOutlined,
TeamOutlined,
MessageOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
import { Layout, Button, Space, message, Tooltip } from "antd";
import { InfoCircleOutlined, MessageOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import { ContactData, MessageData, ChatSession } from "./data";
import ChatWindow from "./components/ChatWindow/index";
import ContactList from "./components/ContactList/index";
import MessageList from "./components/MessageList/index";
import SidebarMenu from "./components/SidebarMenu/index";
import styles from "./index.module.scss";
import { getContactList } from "./api";
const { Sider, Content } = Layout;
const { TabPane } = Tabs;
import { getContactList, getChatMessage } from "./api";
const { Content } = Layout;
import { loginWithToken, getChuKeBaoUserInfo } from "@/pages/login/api";
import { useCkChatStore } from "@/store/module/ckchat";
import { useUserStore } from "@/store/module/user";
const CkboxPage: React.FC = () => {
const [messageApi, contextHolder] = message.useMessage();
const [contacts, setContacts] = useState<ContactData[]>([]);
const [chatSessions, setChatSessions] = useState<ChatSession[]>([]);
const [currentChat, setCurrentChat] = useState<ChatSession | null>(null);
const [searchText, setSearchText] = useState("");
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState("contacts");
const [showProfile, setShowProfile] = useState(true);
const { setUserInfo, getAccountId } = useCkChatStore();
const { login2 } = useUserStore();
useEffect(() => {
fetchContacts();
fetchChatSessions();
//获取触客宝
getToken2().then(() => {
getChuKeBaoUserInfo().then(res => {
setUserInfo(res);
setTimeout(() => {
fetchContacts();
fetchChatSessions();
});
});
});
}, []);
const getToken2 = () => {
return new Promise((resolve, reject) => {
const params = {
grant_type: "password",
password: "kr123456",
username: "kr_xf3",
};
loginWithToken(params)
.then(res => {
login2(res.access_token);
resolve(res.access_token);
})
.catch(err => {
reject(err);
});
});
};
const fetchContacts = async () => {
try {
setLoading(true);
// 使用API获取联系人数据
const response = await getContactList({ prevId: 0, count: 500 });
console.log(response);
if (response && response.data) {
if (response) {
// 转换API返回的数据结构为组件所需的ContactData结构
const contactList: ContactData[] = response.data.map((item: any) => ({
const contactList: ContactData[] = response.map((item: any) => ({
id: item.id.toString(),
name: item.nickname || item.conRemark || item.alias || "",
phone: item.phone || "",
@@ -63,41 +82,83 @@ const CkboxPage: React.FC = () => {
const fetchChatSessions = async () => {
try {
// 模拟聊天会话数据
const sessions: ChatSession[] = [
{
id: "1",
type: "private",
name: "张三",
avatar: "",
lastMessage: "你好,请问有什么可以帮助您的吗?",
lastTime: dayjs().subtract(5, "minute").toISOString(),
unreadCount: 2,
online: true,
},
{
id: "2",
type: "group",
name: "技术支持群",
avatar: "",
lastMessage: "新版本已经发布,请大家及时更新",
lastTime: dayjs().subtract(1, "hour").toISOString(),
unreadCount: 0,
online: false,
},
{
id: "3",
type: "private",
name: "李四",
avatar: "",
lastMessage: "谢谢您的帮助!",
lastTime: dayjs().subtract(2, "hour").toISOString(),
unreadCount: 0,
online: false,
},
];
setChatSessions(sessions);
// 先确保联系人列表已加载
if (contacts.length === 0) {
await fetchContacts();
}
const response = await getChatMessage({
wechatAccountId: 32686452, // 使用实际的wechatAccountId
wechatFriendId: 0, // 可以设置为0获取所有好友的消息
From: 0,
To: 0,
Count: 50, // 获取最近的50条消息
olderData: false,
keyword: "",
});
console.log("聊天消息响应:", response);
if (response && Array.isArray(response)) {
// 创建一个Map来存储每个好友ID对应的最新消息
const friendMessageMap = new Map<number, any>();
// 遍历所有消息,只保留每个好友的最新消息
response.forEach(item => {
const friendId = item.wechatFriendId;
const existingMessage = friendMessageMap.get(friendId);
// 如果Map中没有这个好友的消息或者当前消息比已存在的更新则更新Map
if (
!existingMessage ||
new Date(item.createTime) > new Date(existingMessage.createTime)
) {
friendMessageMap.set(friendId, item);
}
});
// 将Map转换为数组
const latestMessages = Array.from(friendMessageMap.values());
// 将API返回的消息记录转换为ChatSession格式
const sessions: ChatSession[] = latestMessages.map(item => {
// 解析content字段它是一个JSON字符串
let msgContent = "";
try {
const contentObj = JSON.parse(item.content);
msgContent = contentObj.content || "";
} catch (e) {
msgContent = item.content;
}
// 尝试从联系人列表中找到对应的联系人信息
const contact = contacts.find(
c => c.id === item.wechatFriendId.toString(),
);
return {
id: item.id.toString(),
type: "private", // 假设都是私聊
name: contact ? contact.name : `联系人 ${item.wechatFriendId}`, // 使用联系人名称或默认名称
avatar: contact?.avatar || "", // 使用联系人头像或默认空字符串
lastMessage: msgContent,
lastTime:
item.createTime || new Date(item.wechatTime).toISOString(),
unreadCount: 0, // 未读消息数需要另外获取
online: contact?.online || false, // 使用联系人在线状态或默认为false
};
});
// 按最后消息时间排序
sessions.sort(
(a, b) =>
new Date(b.lastTime).getTime() - new Date(a.lastTime).getTime(),
);
setChatSessions(sessions);
}
} catch (error) {
console.error("获取聊天记录失败:", error);
messageApi.error("获取聊天记录失败");
}
};
@@ -154,93 +215,18 @@ const CkboxPage: React.FC = () => {
}
};
const handleSearch = (value: string) => {
setSearchText(value);
};
const getFilteredContacts = () => {
if (!searchText) return contacts;
return contacts.filter(
contact =>
contact.name.toLowerCase().includes(searchText.toLowerCase()) ||
contact.phone.includes(searchText),
);
};
const getFilteredSessions = () => {
if (!searchText) return chatSessions;
return chatSessions.filter(session =>
session.name.toLowerCase().includes(searchText.toLowerCase()),
);
};
return (
<Layout className={styles.ckboxLayout}>
{contextHolder}
{/* 左侧边栏 */}
<Sider width={300} className={styles.sidebar}>
{/* 搜索栏 */}
<div className={styles.searchBar}>
<Input
placeholder="搜索联系人、群组"
prefix={<SearchOutlined />}
value={searchText}
onChange={e => handleSearch(e.target.value)}
allowClear
/>
</div>
{/* 标签页 */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
className={styles.tabs}
>
<TabPane
tab={
<span>
<MessageOutlined />
</span>
}
key="chats"
>
<MessageList
sessions={getFilteredSessions()}
currentChat={currentChat}
onChatSelect={setCurrentChat}
/>
</TabPane>
<TabPane
tab={
<span>
<UserOutlined />
</span>
}
key="contacts"
>
<ContactList
contacts={getFilteredContacts()}
onContactClick={handleContactClick}
/>
</TabPane>
<TabPane
tab={
<span>
<TeamOutlined />
</span>
}
key="groups"
>
<div className={styles.emptyState}>
<TeamOutlined style={{ fontSize: 48, color: "#ccc" }} />
<p></p>
</div>
</TabPane>
</Tabs>
</Sider>
<SidebarMenu
contacts={contacts}
chatSessions={chatSessions}
currentChat={currentChat}
onContactClick={handleContactClick}
onChatSelect={setCurrentChat}
loading={loading}
/>
{/* 主内容区 */}
<Content className={styles.mainContent}>

View File

@@ -0,0 +1,7 @@
import { useCkChatStore } from "@/store/module/ckchat";
import { useWebSocketStore } from "@/store/module/websocket";
const { connect } = useWebSocketStore.getState();
//获取微信账户组
export const getWechatAccountGroup = () => {
console.log(connect);
};